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.")