Files
RFP_Infinity-Vis/app/ui/main_window.py

559 lines
24 KiB
Python

from __future__ import annotations
from pathlib import Path
from app.qt_compat import (
QAction,
QComboBox,
QDoubleSpinBox,
QFileDialog,
QFormLayout,
QHBoxLayout,
QLabel,
QKeySequence,
QMainWindow,
QMessageBox,
QPushButton,
QSplitter,
QStatusBar,
Qt,
QTimer,
QToolBar,
QVBoxLayout,
QWidget,
)
from app.config.xml_mapping import MappingValidationError
from app.ui.pattern_panel import PatternPanel
from app.ui.preset_browser import PresetBrowser
from app.ui.preview_widget import (
PREVIEW_MODE_LEDS,
PREVIEW_MODE_TECHNICAL,
PREVIEW_MODE_TILE,
normalize_preview_mode,
)
from app.ui.scene_preview_area import ScenePreviewArea
from app.ui.section_panel import SectionPanel
from app.ui.settings_dialog import SettingsDialog
ACTIVE_UTILITY_STYLE = "QPushButton { background: #094771; color: #FFFFFF; border: 1px solid #007ACC; font-weight: 600; }"
ALERT_UTILITY_STYLE = "QPushButton { background: #C63B1E; color: #FFFFFF; border: 1px solid #F48771; font-weight: 600; }"
PATTERN_PANEL_MIN_WIDTH = 340
RIGHT_PANEL_MIN_WIDTH = 380
CENTER_PREVIEW_MIN_WIDTH = 720
class MainWindow(QMainWindow):
def __init__(self, controller, parent: QWidget | None = None) -> None:
super().__init__(parent)
self.controller = controller
self.preview_mode = PREVIEW_MODE_TILE
self._startup_splitter_sized = False
self.setWindowTitle("Infinity Mirror Control")
self.resize(1840, 1040)
self.setMinimumSize(1480, 860)
self._blackout_blink_on = False
self._blackout_blink_timer = QTimer(self)
self._blackout_blink_timer.setInterval(420)
self._blackout_blink_timer.timeout.connect(self._toggle_blackout_blink)
self._diagnostics_timer = QTimer(self)
self._diagnostics_timer.setInterval(500)
self._diagnostics_timer.timeout.connect(self._refresh_diagnostics)
self._build_toolbar()
self._build_central_layout()
self._build_status_bar()
self.controller.status_message.connect(self.statusBar().showMessage)
self.controller.state_changed.connect(self._refresh_state)
self.controller.config_changed.connect(self._refresh_state)
self._diagnostics_timer.start()
self._refresh_state()
def _build_toolbar(self) -> None:
toolbar = QToolBar("Main")
toolbar.setMovable(False)
self.addToolBar(toolbar)
open_action = QAction("Open", self)
open_action.setShortcut(QKeySequence.Open)
open_action.triggered.connect(self.open_mapping)
toolbar.addAction(open_action)
save_action = QAction("Save", self)
save_action.setShortcut(QKeySequence.Save)
save_action.triggered.connect(self.save_mapping)
toolbar.addAction(save_action)
save_as_action = QAction("Save As", self)
save_as_action.setShortcut(QKeySequence("Ctrl+Shift+S"))
save_as_action.triggered.connect(self.save_mapping_as)
toolbar.addAction(save_as_action)
settings_action = QAction("Mapping Settings", self)
settings_action.setShortcut(QKeySequence("Ctrl+,"))
settings_action.triggered.connect(self.open_settings)
toolbar.addAction(settings_action)
toolbar.addWidget(QLabel("Tempo"))
self.tempo_spin = QDoubleSpinBox()
self.tempo_spin.setRange(10.0, 300.0)
self.tempo_spin.setDecimals(0)
self.tempo_spin.setSingleStep(1.0)
self.tempo_spin.setSuffix(" BPM")
self.tempo_spin.setFixedWidth(96)
self.tempo_spin.valueChanged.connect(self._change_tempo_bpm)
toolbar.addWidget(self.tempo_spin)
self.foh_toggle = QPushButton("FOH Mode")
self.foh_toggle.setCheckable(True)
self.foh_toggle.toggled.connect(self._toggle_foh_mode)
toolbar.addWidget(self.foh_toggle)
self.go_button = QPushButton("Go")
self.go_button.clicked.connect(lambda _checked=False: self._go_scene())
toolbar.addWidget(self.go_button)
self.fade_go_button = QPushButton("Fade Go")
self.fade_go_button.clicked.connect(lambda _checked=False: self._fade_go_scene())
toolbar.addWidget(self.fade_go_button)
toolbar.addWidget(QLabel("Fade"))
self.fade_time_spin = QDoubleSpinBox()
self.fade_time_spin.setRange(0.1, 30.0)
self.fade_time_spin.setDecimals(1)
self.fade_time_spin.setSingleStep(0.1)
self.fade_time_spin.setSuffix(" s")
self.fade_time_spin.setFixedWidth(84)
self.fade_time_spin.valueChanged.connect(self._change_transition_duration)
toolbar.addWidget(self.fade_time_spin)
self.foh_target_label = QLabel("Edit: Live")
self.foh_target_label.setStyleSheet("color: #CCCCCC; padding-left: 8px;")
toolbar.addWidget(self.foh_target_label)
self.fullscreen_action = QAction("Fullscreen Preview", self)
self.fullscreen_action.setShortcut(QKeySequence("F11"))
self.fullscreen_action.triggered.connect(self.toggle_fullscreen_preview)
self.addAction(self.fullscreen_action)
toolbar.addSeparator()
self.blackout_action = QAction("Blackout", self)
self.blackout_action.setShortcut(QKeySequence("Ctrl+B"))
self.blackout_action.triggered.connect(lambda: self._set_utility_mode("blackout"))
toolbar.addAction(self.blackout_action)
self._add_tempo_shortcuts()
def _add_tempo_shortcuts(self) -> None:
for shortcut, delta in (
("Left", -1.0),
("Right", 1.0),
("Shift+Left", -5.0),
("Shift+Right", 5.0),
):
action = QAction(self)
action.setShortcut(QKeySequence(shortcut))
action.setShortcutContext(Qt.ApplicationShortcut)
action.triggered.connect(lambda _checked=False, amount=delta: self._nudge_tempo_bpm(amount))
self.addAction(action)
def _build_central_layout(self) -> None:
splitter = QSplitter(Qt.Horizontal, self)
splitter.setChildrenCollapsible(False)
self.main_splitter = splitter
self.pattern_panel = PatternPanel(self.controller)
self.pattern_panel.setMinimumWidth(PATTERN_PANEL_MIN_WIDTH)
splitter.addWidget(self.pattern_panel)
self.preview_area = ScenePreviewArea(self.controller, preview_mode=self.preview_mode)
splitter.addWidget(self.preview_area)
side_panel = QWidget()
side_panel.setMinimumWidth(RIGHT_PANEL_MIN_WIDTH)
self.side_panel = side_panel
side_layout = QVBoxLayout(side_panel)
side_layout.setContentsMargins(0, 12, 0, 0)
side_layout.setSpacing(14)
self.preset_browser = PresetBrowser(self.controller)
side_layout.addWidget(self.preset_browser)
side_layout.addWidget(self._build_selected_tile_panel())
side_layout.addWidget(self._build_utility_panel())
side_layout.addStretch(1)
side_layout.addWidget(self._build_system_panel())
splitter.addWidget(side_panel)
splitter.setStretchFactor(0, 0)
splitter.setStretchFactor(1, 1)
splitter.setStretchFactor(2, 0)
splitter.setSizes([PATTERN_PANEL_MIN_WIDTH, 1120, RIGHT_PANEL_MIN_WIDTH])
self.setCentralWidget(splitter)
def showEvent(self, event) -> None: # type: ignore[override]
super().showEvent(event)
if not self._startup_splitter_sized:
self._startup_splitter_sized = True
QTimer.singleShot(0, self._apply_startup_splitter_sizes)
def _apply_startup_splitter_sizes(self) -> None:
splitter_width = self.main_splitter.size().width()
if splitter_width <= 0:
return
left_width = max(self.pattern_panel.minimumWidth(), min(420, int(splitter_width * 0.22)))
right_width = max(self.side_panel.minimumWidth(), min(440, int(splitter_width * 0.24)))
center_width = max(CENTER_PREVIEW_MIN_WIDTH, splitter_width - left_width - right_width)
overshoot = (left_width + center_width + right_width) - splitter_width
if overshoot > 0:
reducible_left = max(0, left_width - self.pattern_panel.minimumWidth())
reduce_left = min(reducible_left, overshoot // 2)
left_width -= reduce_left
overshoot -= reduce_left
reducible_right = max(0, right_width - self.side_panel.minimumWidth())
reduce_right = min(reducible_right, overshoot)
right_width -= reduce_right
overshoot -= reduce_right
center_width = max(1, splitter_width - left_width - right_width)
self.main_splitter.setSizes([left_width, center_width, right_width])
def _build_selected_tile_panel(self) -> QWidget:
group = SectionPanel("Selected Tile")
layout = QVBoxLayout(group.body)
layout.setContentsMargins(12, 12, 12, 12)
layout.setSpacing(8)
self.selected_tile_label = QLabel("Click a tile in the preview.")
self.selected_tile_label.setWordWrap(True)
self.selected_tile_label.setStyleSheet("font-size: 14px;")
layout.addWidget(self.selected_tile_label)
button_row = QHBoxLayout()
self.single_tile_button = QPushButton("White Test")
self.single_tile_button.clicked.connect(lambda: self._set_utility_mode("single_tile"))
self.clear_test_button = QPushButton("Live Pattern")
self.clear_test_button.clicked.connect(lambda: self._set_utility_mode("none"))
button_row.addWidget(self.single_tile_button)
button_row.addWidget(self.clear_test_button)
layout.addLayout(button_row)
return group
def _build_utility_panel(self) -> QWidget:
group = SectionPanel("Utilities")
layout = QVBoxLayout(group.body)
layout.setContentsMargins(12, 12, 12, 12)
layout.setSpacing(8)
self.utility_buttons: dict[str, QPushButton] = {}
for label, mode in [
("Blackout", "blackout"),
("Live Pattern", "none"),
]:
button = QPushButton(label)
button.setCheckable(True)
button.clicked.connect(lambda _checked=False, utility=mode: self._set_utility_mode(utility))
layout.addWidget(button)
self.utility_buttons[mode] = button
return group
def _build_system_panel(self) -> QWidget:
group = SectionPanel("View & Output")
layout = QFormLayout(group.body)
layout.setContentsMargins(12, 12, 12, 12)
layout.setSpacing(8)
self.preview_mode_combo = QComboBox()
self.preview_mode_combo.addItem("Tile Colors", PREVIEW_MODE_TILE)
self.preview_mode_combo.addItem("Technical", PREVIEW_MODE_TECHNICAL)
self.preview_mode_combo.addItem("LEDs Only", PREVIEW_MODE_LEDS)
self.preview_mode_combo.currentIndexChanged.connect(self._change_preview_mode)
layout.addRow("Preview", self.preview_mode_combo)
self.backend_combo = QComboBox()
for backend_id, name in self.controller.output_manager.backend_names():
self.backend_combo.addItem(name, backend_id)
self.backend_combo.currentIndexChanged.connect(self._change_backend)
layout.addRow("Backend", self.backend_combo)
self.output_toggle = QPushButton("Enable Output")
self.output_toggle.setCheckable(True)
self.output_toggle.clicked.connect(self._toggle_output)
layout.addRow("Output", self.output_toggle)
self.output_fps_spin = QDoubleSpinBox()
self.output_fps_spin.setRange(1.0, 60.0)
self.output_fps_spin.setDecimals(0)
self.output_fps_spin.setSingleStep(1.0)
self.output_fps_spin.setSuffix(" fps")
self.output_fps_spin.setFixedWidth(84)
self.output_fps_spin.valueChanged.connect(self._change_output_target_fps)
layout.addRow("Output FPS", self.output_fps_spin)
self.render_fps_value = QLabel("--")
self.render_fps_value.setTextInteractionFlags(Qt.TextSelectableByMouse)
layout.addRow("Render FPS", self.render_fps_value)
self.send_fps_value = QLabel("--")
self.send_fps_value.setTextInteractionFlags(Qt.TextSelectableByMouse)
layout.addRow("Send FPS", self.send_fps_value)
self.output_health_value = QLabel("--")
self.output_health_value.setWordWrap(True)
self.output_health_value.setTextInteractionFlags(Qt.TextSelectableByMouse)
layout.addRow("Output Health", self.output_health_value)
self.controller_fps_value = QLabel("n/a")
self.controller_fps_value.setWordWrap(True)
self.controller_fps_value.setTextInteractionFlags(Qt.TextSelectableByMouse)
layout.addRow("Controller FPS", self.controller_fps_value)
self.fullscreen_button = QPushButton("Fullscreen Preview")
self.fullscreen_button.clicked.connect(self.toggle_fullscreen_preview)
layout.addRow("Window", self.fullscreen_button)
return group
def _build_status_bar(self) -> None:
self.setStatusBar(QStatusBar(self))
self.statusBar().showMessage("Ready")
self.mapping_status_label = QLabel("")
self.mapping_status_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
self.statusBar().addPermanentWidget(self.mapping_status_label, 1)
def _toggle_blackout_blink(self) -> None:
self._blackout_blink_on = not self._blackout_blink_on
self._apply_live_pattern_blink()
def _apply_live_pattern_blink(self) -> None:
override_mode = self.controller.utility_mode
override_active = override_mode in {"blackout", "single_tile"}
style = ALERT_UTILITY_STYLE if override_active and self._blackout_blink_on else ""
self.clear_test_button.setStyleSheet(style)
live_pattern_button = self.utility_buttons.get("none")
if live_pattern_button is not None:
live_pattern_button.setStyleSheet(style)
self.single_tile_button.setStyleSheet(ACTIVE_UTILITY_STYLE if override_mode == "single_tile" else "")
blackout_button = self.utility_buttons.get("blackout")
if blackout_button is not None:
blackout_button.setStyleSheet(ACTIVE_UTILITY_STYLE if override_mode == "blackout" else "")
def open_mapping(self) -> None:
path, _ = QFileDialog.getOpenFileName(
self,
"Open Mapping",
str(self.controller.mapping_path.parent if self.controller.mapping_path else Path.home()),
"XML Files (*.xml)",
)
if not path:
return
try:
self.controller.load_mapping(path)
except MappingValidationError as exc:
QMessageBox.warning(self, "Mapping Error", "\n".join(exc.errors))
def save_mapping(self) -> None:
if self.controller.mapping_path is None:
self.save_mapping_as()
return
try:
self.controller.save_mapping()
except MappingValidationError as exc:
QMessageBox.warning(self, "Save Error", "\n".join(exc.errors))
def save_mapping_as(self) -> None:
path, _ = QFileDialog.getSaveFileName(
self,
"Save Mapping As",
str(self.controller.mapping_path or (Path.home() / "infinity_mirror_mapping.xml")),
"XML Files (*.xml)",
)
if not path:
return
try:
self.controller.save_mapping(path)
except MappingValidationError as exc:
QMessageBox.warning(self, "Save Error", "\n".join(exc.errors))
def open_settings(self) -> None:
dialog = SettingsDialog(self.controller.config, controller=self.controller, parent=self)
if dialog.exec() == SettingsDialog.Accepted and dialog.result_config is not None:
self.controller.replace_config(dialog.result_config)
self.statusBar().showMessage("Mapping updated in memory. Save to write XML.", 4000)
def _change_preview_mode(self) -> None:
self.preview_mode = normalize_preview_mode(self.preview_mode_combo.currentData())
self.preview_area.set_preview_mode(self.preview_mode)
def _toggle_foh_mode(self, enabled: bool) -> None:
self.controller.set_foh_mode(enabled)
def _go_scene(self) -> None:
self.controller.go_scene()
def _fade_go_scene(self) -> None:
self.controller.fade_go(self.fade_time_spin.value())
def _change_transition_duration(self, value: float) -> None:
self.controller.set_transition_duration(value)
def _change_tempo_bpm(self, value: float) -> None:
self.controller.set_tempo_bpm(value)
def _nudge_tempo_bpm(self, delta: float) -> None:
self.controller.set_tempo_bpm(self.controller.tempo_bpm + delta)
def toggle_technical_preview(self, enabled: bool) -> None:
self.preview_mode = PREVIEW_MODE_TECHNICAL if enabled else PREVIEW_MODE_TILE
self.preview_area.set_preview_mode(self.preview_mode)
def toggle_fullscreen_preview(self) -> None:
self.preview_area.toggle_fullscreen()
def _change_backend(self) -> None:
self.controller.set_backend(self.backend_combo.currentData())
def _toggle_output(self) -> None:
self.controller.set_output_enabled(self.output_toggle.isChecked())
def _change_output_target_fps(self, value: float) -> None:
self.controller.set_output_target_fps(value)
def _set_utility_mode(self, mode: str) -> None:
if mode == "none":
self.controller.clear_utility_mode()
else:
self.controller.set_utility_mode(mode)
def _pattern_display_name(self, pattern_id: str) -> str:
for descriptor in self.controller.available_patterns():
if descriptor.pattern_id == pattern_id:
return descriptor.display_name
return pattern_id.replace("_", " ").title()
def _refresh_scene_labels(self) -> None:
live_name = self._pattern_display_name(self.controller.scene_state("live").pattern_id)
next_name = self._pattern_display_name(self.controller.scene_state("next").pattern_id)
self.preview_area.set_scene_labels(
"Live | Fading" if self.controller.transition_active else f"Live | {live_name}",
f"Next | {next_name}",
)
def _refresh_state(self) -> None:
mapping_name = self.controller.mapping_path.name if self.controller.mapping_path else "Unsaved Mapping"
self.mapping_status_label.setText(mapping_name)
self._refresh_scene_labels()
self.preview_area.set_foh_mode(self.controller.foh_mode_enabled)
preview_index = self.preview_mode_combo.findData(self.preview_mode)
self.preview_mode_combo.blockSignals(True)
self.preview_mode_combo.setCurrentIndex(max(0, preview_index))
self.preview_mode_combo.blockSignals(False)
self.tempo_spin.blockSignals(True)
self.tempo_spin.setValue(self.controller.tempo_bpm)
self.tempo_spin.blockSignals(False)
self.foh_toggle.blockSignals(True)
self.foh_toggle.setChecked(self.controller.foh_mode_enabled)
self.foh_toggle.blockSignals(False)
self.go_button.setEnabled(self.controller.foh_mode_enabled)
self.fade_go_button.setEnabled(self.controller.foh_mode_enabled)
self.fade_time_spin.blockSignals(True)
self.fade_time_spin.setValue(self.controller.transition_duration_s)
self.fade_time_spin.blockSignals(False)
self.fade_time_spin.setEnabled(self.controller.foh_mode_enabled)
self.foh_target_label.setText("Edit: Next" if self.controller.foh_mode_enabled else "Edit: Live")
backend_index = self.backend_combo.findData(self.controller.output_manager.active_backend_id)
self.backend_combo.blockSignals(True)
self.backend_combo.setCurrentIndex(max(0, backend_index))
self.backend_combo.blockSignals(False)
self.output_toggle.blockSignals(True)
self.output_toggle.setChecked(self.controller.output_manager.output_enabled)
self.output_toggle.setText("Output Enabled" if self.controller.output_manager.output_enabled else "Enable Output")
self.output_toggle.blockSignals(False)
self.output_fps_spin.blockSignals(True)
self.output_fps_spin.setValue(self.controller.output_manager.target_fps())
self.output_fps_spin.blockSignals(False)
if self.controller.utility_mode in {"blackout", "single_tile"}:
self._blackout_blink_on = True
if not self._blackout_blink_timer.isActive():
self._blackout_blink_timer.start()
self._apply_live_pattern_blink()
else:
if self._blackout_blink_timer.isActive():
self._blackout_blink_timer.stop()
self._blackout_blink_on = False
self._apply_live_pattern_blink()
tile = self.controller.config.tile_lookup().get(self.controller.selected_tile_id) if self.controller.selected_tile_id else None
if tile is None:
self.selected_tile_label.setText("Click a tile in the preview to inspect or run a single-tile white test.")
self.single_tile_button.setEnabled(False)
else:
self.selected_tile_label.setText(
f"{tile.tile_id}\n{tile.screen_name or tile.controller_ip}\nRow {tile.row}, Col {tile.col} | Universe {tile.universe} | {tile.led_total} LEDs"
)
self.single_tile_button.setEnabled(True)
for mode, button in self.utility_buttons.items():
active = self.controller.utility_mode == mode or (mode == "none" and self.controller.utility_mode == "none")
button.setChecked(active)
self._refresh_diagnostics()
def _refresh_diagnostics(self) -> None:
diagnostics = self.controller.realtime_diagnostics()
render_text = "--" if diagnostics.render_fps <= 0.0 else f"{diagnostics.render_fps:.1f} fps"
self.render_fps_value.setText(render_text)
if diagnostics.output_enabled:
send_text = f"{diagnostics.send_fps:.1f} fps via {diagnostics.backend_name}"
else:
send_text = f"0.0 fps via {diagnostics.backend_name}"
self.send_fps_value.setText(send_text)
self.send_fps_value.setToolTip(
f"Target {diagnostics.target_output_fps:.0f} fps\n"
f"Last send {diagnostics.last_send_time_ms:.1f} ms\n"
f"Last schedule slip {diagnostics.last_schedule_slip_ms:.1f} ms"
)
health_parts = [
f"target {diagnostics.target_output_fps:.0f} fps",
f"stale drops {diagnostics.stale_frame_drops}",
f"budget misses {diagnostics.send_budget_misses}",
f"last send {diagnostics.last_send_time_ms:.1f} ms",
]
if diagnostics.send_failures:
health_parts.append(f"send failures {diagnostics.send_failures}")
self.output_health_value.setText(" | ".join(health_parts))
if diagnostics.controller_fps is None:
controller_text = "n/a"
if diagnostics.controller_source:
if "disabled during live output" in diagnostics.controller_source.lower():
controller_text = "n/a (disabled)"
elif diagnostics.controller_total_devices > 0:
controller_text = f"n/a ({diagnostics.controller_live_devices}/{diagnostics.controller_total_devices} live)"
else:
controller_text = "n/a"
else:
controller_text = (
f"{diagnostics.controller_fps:.1f} fps avg "
f"({diagnostics.controller_live_devices}/{diagnostics.controller_total_devices} live)"
)
self.controller_fps_value.setText(controller_text)
self.controller_fps_value.setToolTip(diagnostics.controller_source or "No verified controller-side FPS source for this backend.")