559 lines
24 KiB
Python
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.")
|