First upload, 18 controller version
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
"""Qt UI modules for the Infinity Mirror control app."""
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,558 @@
|
||||
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.")
|
||||
@@ -0,0 +1,193 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.qt_compat import (
|
||||
QColor,
|
||||
QFont,
|
||||
QLinearGradient,
|
||||
QPainter,
|
||||
QPainterPath,
|
||||
QPen,
|
||||
QRectF,
|
||||
Qt,
|
||||
Signal,
|
||||
QWidget,
|
||||
event_posf,
|
||||
)
|
||||
|
||||
from app.config.device_assignment import tile_matches_device
|
||||
from app.config.models import InfinityMirrorConfig
|
||||
from app.network.wled import DiscoveredWledDevice, normalize_mac_address
|
||||
from app.ui.preview_layout import compute_preview_layout
|
||||
|
||||
|
||||
class MappingAssignmentPreview(QWidget):
|
||||
tileClicked = Signal(str)
|
||||
|
||||
def __init__(self, parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self._config = InfinityMirrorConfig()
|
||||
self._discovered_devices: list[DiscoveredWledDevice] = []
|
||||
self._active_device: DiscoveredWledDevice | None = None
|
||||
self._selected_tile_id: str | None = None
|
||||
self._tile_rects: dict[str, QRectF] = {}
|
||||
|
||||
self.setMinimumSize(520, 360)
|
||||
self.setMouseTracking(True)
|
||||
|
||||
def set_assignment_state(
|
||||
self,
|
||||
config: InfinityMirrorConfig,
|
||||
discovered_devices: list[DiscoveredWledDevice],
|
||||
*,
|
||||
active_device: DiscoveredWledDevice | None = None,
|
||||
selected_tile_id: str | None = None,
|
||||
) -> None:
|
||||
self._config = config
|
||||
self._discovered_devices = list(discovered_devices)
|
||||
self._active_device = active_device
|
||||
self._selected_tile_id = selected_tile_id
|
||||
self.update()
|
||||
|
||||
def mousePressEvent(self, event) -> None:
|
||||
point = event_posf(event)
|
||||
for tile_id, rect in self._tile_rects.items():
|
||||
if rect.contains(point):
|
||||
self.tileClicked.emit(tile_id)
|
||||
break
|
||||
super().mousePressEvent(event)
|
||||
|
||||
def paintEvent(self, event) -> None: # type: ignore[override]
|
||||
painter = QPainter(self)
|
||||
painter.setRenderHint(QPainter.Antialiasing, True)
|
||||
painter.setRenderHint(QPainter.TextAntialiasing, True)
|
||||
|
||||
rect = QRectF(self.rect())
|
||||
background = QLinearGradient(rect.topLeft(), rect.bottomRight())
|
||||
background.setColorAt(0.0, QColor("#12161D"))
|
||||
background.setColorAt(1.0, QColor("#1B2230"))
|
||||
painter.fillRect(rect, background)
|
||||
|
||||
if not self._config.tiles:
|
||||
painter.setPen(QColor("#A8B3C7"))
|
||||
painter.drawText(rect, Qt.AlignCenter, "Open a mapping to assign WLED devices.")
|
||||
return
|
||||
|
||||
layout = compute_preview_layout(rect, self._config)
|
||||
self._tile_rects = layout.tile_rects
|
||||
self._draw_canvas_shell(painter, layout.canvas_rect)
|
||||
|
||||
discovered_ips = {device.ip_address for device in self._discovered_devices}
|
||||
discovered_macs = {normalize_mac_address(device.mac_address) for device in self._discovered_devices if device.mac_address}
|
||||
active_tile_id = None
|
||||
if self._active_device is not None:
|
||||
for tile in self._config.sorted_tiles():
|
||||
if tile_matches_device(tile, self._active_device):
|
||||
active_tile_id = tile.tile_id
|
||||
break
|
||||
|
||||
for tile in self._config.sorted_tiles():
|
||||
tile_rect = layout.tile_rects[tile.tile_id]
|
||||
tile_assigned = bool(tile.controller_ip.strip() or tile.controller_mac.strip())
|
||||
tile_is_active = active_tile_id == tile.tile_id
|
||||
tile_is_selected = self._selected_tile_id == tile.tile_id
|
||||
|
||||
if tile_is_active:
|
||||
fill_color = QColor("#1D4E89")
|
||||
outline_color = QColor("#90CAF9")
|
||||
subtitle = self._active_device.instance_name or self._active_device.ip_address
|
||||
status = "Active Device"
|
||||
elif not tile_assigned:
|
||||
fill_color = QColor("#2A2F3A")
|
||||
outline_color = QColor("#4A5568")
|
||||
subtitle = "Unmapped"
|
||||
status = "Click to assign"
|
||||
elif (tile.controller_mac and normalize_mac_address(tile.controller_mac) in discovered_macs) or tile.controller_ip.strip() in discovered_ips:
|
||||
fill_color = QColor("#1D5A45")
|
||||
outline_color = QColor("#81E6D9")
|
||||
subtitle = tile.controller_name or tile.controller_host or tile.controller_ip
|
||||
status = "Mapped"
|
||||
else:
|
||||
fill_color = QColor("#6B4F1D")
|
||||
outline_color = QColor("#F6AD55")
|
||||
subtitle = tile.controller_name or tile.controller_host or tile.controller_ip
|
||||
status = "Assigned, not seen"
|
||||
|
||||
self._draw_tile(
|
||||
painter,
|
||||
rect=tile_rect,
|
||||
tile_id=tile.tile_id,
|
||||
row=tile.row,
|
||||
col=tile.col,
|
||||
subtitle=subtitle,
|
||||
status=status,
|
||||
fill_color=fill_color,
|
||||
outline_color=outline_color,
|
||||
selected=tile_is_selected,
|
||||
)
|
||||
|
||||
def _draw_canvas_shell(self, painter: QPainter, rect: QRectF) -> None:
|
||||
path = QPainterPath()
|
||||
path.addRoundedRect(rect, 10.0, 10.0)
|
||||
painter.fillPath(path, QColor("#202632"))
|
||||
painter.setPen(QPen(QColor("#334155"), 1.0))
|
||||
painter.drawPath(path)
|
||||
|
||||
def _draw_tile(
|
||||
self,
|
||||
painter: QPainter,
|
||||
*,
|
||||
rect: QRectF,
|
||||
tile_id: str,
|
||||
row: int,
|
||||
col: int,
|
||||
subtitle: str,
|
||||
status: str,
|
||||
fill_color: QColor,
|
||||
outline_color: QColor,
|
||||
selected: bool,
|
||||
) -> None:
|
||||
base = min(rect.width(), rect.height())
|
||||
rounding = max(4.0, base * 0.045)
|
||||
tile_path = QPainterPath()
|
||||
tile_path.addRoundedRect(rect, rounding, rounding)
|
||||
|
||||
painter.fillPath(tile_path, fill_color)
|
||||
|
||||
highlight = QLinearGradient(rect.topLeft(), rect.bottomLeft())
|
||||
highlight.setColorAt(0.0, QColor(255, 255, 255, 24))
|
||||
highlight.setColorAt(0.18, QColor(255, 255, 255, 8))
|
||||
highlight.setColorAt(1.0, QColor(0, 0, 0, 0))
|
||||
painter.fillPath(tile_path, highlight)
|
||||
|
||||
painter.setPen(QPen(outline_color, 1.3))
|
||||
painter.drawPath(tile_path)
|
||||
|
||||
if selected:
|
||||
painter.setPen(QPen(QColor("#E2E8F0"), 2.1))
|
||||
painter.drawRoundedRect(rect.adjusted(-3, -3, 3, 3), rounding + 2.0, rounding + 2.0)
|
||||
|
||||
padding_x = max(12.0, rect.width() * 0.08)
|
||||
padding_top = max(10.0, rect.height() * 0.08)
|
||||
padding_bottom = max(12.0, rect.height() * 0.08)
|
||||
|
||||
title_font = QFont()
|
||||
title_font.setPointSizeF(max(13.0, base * 0.1))
|
||||
title_font.setWeight(QFont.DemiBold)
|
||||
painter.setFont(title_font)
|
||||
painter.setPen(QColor("#F8FAFC"))
|
||||
painter.drawText(
|
||||
rect.adjusted(padding_x, padding_top, -padding_x, -rect.height() * 0.56),
|
||||
Qt.AlignLeft | Qt.AlignTop | Qt.TextWordWrap,
|
||||
tile_id,
|
||||
)
|
||||
|
||||
meta_font = QFont(title_font)
|
||||
meta_font.setPointSizeF(max(9.2, base * 0.06))
|
||||
meta_font.setWeight(QFont.Normal)
|
||||
painter.setFont(meta_font)
|
||||
painter.setPen(QColor(235, 244, 249, 175))
|
||||
painter.drawText(
|
||||
rect.adjusted(padding_x, rect.height() * 0.48, -padding_x, -padding_bottom),
|
||||
Qt.AlignLeft | Qt.AlignBottom | Qt.TextWordWrap,
|
||||
f"{subtitle}\n{status} | R{row} C{col}",
|
||||
)
|
||||
@@ -0,0 +1,382 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
from app.qt_compat import (
|
||||
QCheckBox,
|
||||
QColor,
|
||||
QColorDialog,
|
||||
QComboBox,
|
||||
QFont,
|
||||
QFormLayout,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QPainter,
|
||||
QPen,
|
||||
QPointF,
|
||||
QPushButton,
|
||||
QRectF,
|
||||
QScrollArea,
|
||||
QSlider,
|
||||
Qt,
|
||||
Signal,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
event_posf,
|
||||
)
|
||||
|
||||
from app.core.colors import PALETTES, canonical_palette_name
|
||||
from app.patterns.base import COMMON_PARAMETER_SPECS
|
||||
from app.ui.section_panel import SectionPanel
|
||||
|
||||
|
||||
class SliderField(QWidget):
|
||||
valueChanged = Signal(float)
|
||||
|
||||
def __init__(self, minimum: float, maximum: float, step: float, decimals: int = 2, parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self.minimum = minimum
|
||||
self.maximum = maximum
|
||||
self.step = step
|
||||
self.decimals = decimals
|
||||
|
||||
self.slider = QSlider(Qt.Horizontal, self)
|
||||
self.slider.setMinimum(0)
|
||||
self.slider.setMaximum(int(round((maximum - minimum) / step)))
|
||||
self.value_label = QLabel(self)
|
||||
self.value_label.setFixedWidth(64)
|
||||
self.value_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
|
||||
|
||||
layout = QHBoxLayout(self)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.addWidget(self.slider, 1)
|
||||
layout.addWidget(self.value_label)
|
||||
|
||||
self.slider.valueChanged.connect(self._on_slider_changed)
|
||||
self.set_value(minimum)
|
||||
|
||||
def value(self) -> float:
|
||||
return self.minimum + self.slider.value() * self.step
|
||||
|
||||
def set_value(self, value: float) -> None:
|
||||
clamped = max(self.minimum, min(self.maximum, float(value)))
|
||||
scaled = int(round((clamped - self.minimum) / self.step))
|
||||
scaled = max(self.slider.minimum(), min(self.slider.maximum(), scaled))
|
||||
self.slider.blockSignals(True)
|
||||
self.slider.setValue(scaled)
|
||||
self.slider.blockSignals(False)
|
||||
self._update_label()
|
||||
|
||||
def _on_slider_changed(self, _: int) -> None:
|
||||
self._update_label()
|
||||
self.valueChanged.emit(self.value())
|
||||
|
||||
def _update_label(self) -> None:
|
||||
self.value_label.setText(f"{self.value():.{self.decimals}f}")
|
||||
|
||||
|
||||
class ClickableLabel(QLabel):
|
||||
clicked = Signal()
|
||||
|
||||
def mousePressEvent(self, event) -> None: # type: ignore[override]
|
||||
if event.button() == Qt.LeftButton:
|
||||
self.clicked.emit()
|
||||
event.accept()
|
||||
return
|
||||
super().mousePressEvent(event)
|
||||
|
||||
|
||||
class ColorButton(QPushButton):
|
||||
colorChanged = Signal(str)
|
||||
|
||||
def __init__(self, color_hex: str, parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self._color_hex = color_hex
|
||||
self.clicked.connect(self.choose_color)
|
||||
self.setToolTip("Open a color picker.")
|
||||
self.set_color(color_hex)
|
||||
|
||||
def color(self) -> str:
|
||||
return self._color_hex
|
||||
|
||||
def set_color(self, color_hex: str) -> None:
|
||||
self._color_hex = color_hex
|
||||
color = QColor(color_hex)
|
||||
text_color = "#09120F" if color.lightnessF() > 0.62 else "#E8F0F4"
|
||||
self.setText(color_hex.upper())
|
||||
self.setStyleSheet(
|
||||
f"QPushButton {{ background: {color_hex}; color: {text_color}; border: 1px solid rgba(255,255,255,0.16); }}"
|
||||
)
|
||||
|
||||
def choose_color(self) -> None:
|
||||
color = QColorDialog.getColor(QColor(self._color_hex), self.window(), "Choose Color")
|
||||
if color.isValid():
|
||||
self.set_color(color.name())
|
||||
self.colorChanged.emit(color.name())
|
||||
|
||||
|
||||
class AngleSelector(QWidget):
|
||||
valueChanged = Signal(float)
|
||||
|
||||
_ANGLES = (0, 45, 90, 135, 180, 225, 270, 315)
|
||||
|
||||
def __init__(self, parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self._value = 0
|
||||
self.setMinimumSize(118, 118)
|
||||
self.setMaximumHeight(132)
|
||||
|
||||
def value(self) -> float:
|
||||
return float(self._value)
|
||||
|
||||
def set_value(self, value: float) -> None:
|
||||
snapped = self._snap_angle(value)
|
||||
if snapped != self._value:
|
||||
self._value = snapped
|
||||
self.update()
|
||||
|
||||
def _snap_angle(self, value: float) -> int:
|
||||
angle = int(round(float(value))) % 360
|
||||
return min(self._ANGLES, key=lambda candidate: min((candidate - angle) % 360, (angle - candidate) % 360))
|
||||
|
||||
def _point_for_angle(self, center: QPointF, radius: float, angle: int) -> QPointF:
|
||||
radians = math.radians(angle)
|
||||
return QPointF(center.x() + math.cos(radians) * radius, center.y() + math.sin(radians) * radius)
|
||||
|
||||
def mousePressEvent(self, event) -> None: # type: ignore[override]
|
||||
pos = event_posf(event)
|
||||
center = QPointF(self.width() / 2.0, self.height() / 2.0)
|
||||
dx = pos.x() - center.x()
|
||||
dy = pos.y() - center.y()
|
||||
if dx == 0.0 and dy == 0.0:
|
||||
return
|
||||
angle = (math.degrees(math.atan2(dy, dx)) + 360.0) % 360.0
|
||||
snapped = self._snap_angle(angle)
|
||||
if snapped != self._value:
|
||||
self._value = snapped
|
||||
self.update()
|
||||
self.valueChanged.emit(float(snapped))
|
||||
|
||||
def paintEvent(self, _event) -> None: # type: ignore[override]
|
||||
painter = QPainter(self)
|
||||
painter.setRenderHint(QPainter.Antialiasing, True)
|
||||
|
||||
bounds = QRectF(self.rect()).adjusted(8.0, 8.0, -8.0, -8.0)
|
||||
center = bounds.center()
|
||||
outer_radius = min(bounds.width(), bounds.height()) * 0.34
|
||||
label_radius = outer_radius + 15.0
|
||||
|
||||
painter.setPen(QPen(QColor("#3C3C3C"), 1.2))
|
||||
painter.setBrush(QColor("#252526"))
|
||||
painter.drawEllipse(center, outer_radius, outer_radius)
|
||||
|
||||
selected_point = self._point_for_angle(center, outer_radius - 10.0, self._value)
|
||||
painter.setPen(QPen(QColor("#007ACC"), 3.0))
|
||||
painter.drawLine(center, selected_point)
|
||||
painter.setBrush(QColor("#007ACC"))
|
||||
painter.drawEllipse(selected_point, 6.5, 6.5)
|
||||
|
||||
label_font = QFont(self.font())
|
||||
label_font.setPointSizeF(7.6)
|
||||
label_font.setWeight(QFont.Medium)
|
||||
painter.setFont(label_font)
|
||||
|
||||
for angle in self._ANGLES:
|
||||
node = self._point_for_angle(center, outer_radius, angle)
|
||||
active = angle == self._value
|
||||
painter.setPen(QPen(QColor("#007ACC") if active else QColor("#5A5A5A"), 1.2))
|
||||
painter.setBrush(QColor("#007ACC") if active else QColor("#2D2D30"))
|
||||
painter.drawEllipse(node, 5.5 if active else 4.5, 5.5 if active else 4.5)
|
||||
|
||||
label_point = self._point_for_angle(center, label_radius, angle)
|
||||
label_rect = QRectF(label_point.x() - 16.0, label_point.y() - 8.0, 32.0, 16.0)
|
||||
painter.setPen(QColor("#FFFFFF") if active else QColor("#A8A8A8"))
|
||||
painter.drawText(label_rect, Qt.AlignCenter, f"{angle}\N{DEGREE SIGN}")
|
||||
|
||||
painter.setPen(QColor("#A8A8A8"))
|
||||
painter.drawText(QRectF(center.x() - 26.0, center.y() - 10.0, 52.0, 20.0), Qt.AlignCenter, f"{self._value}\N{DEGREE SIGN}")
|
||||
painter.end()
|
||||
|
||||
|
||||
class PatternPanel(QWidget):
|
||||
def __init__(self, controller, parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self.controller = controller
|
||||
self._updating = False
|
||||
self._rows: dict[str, tuple[QLabel, QWidget]] = {}
|
||||
|
||||
root_layout = QVBoxLayout(self)
|
||||
root_layout.setContentsMargins(0, 0, 0, 0)
|
||||
root_layout.setSpacing(0)
|
||||
|
||||
scroll = QScrollArea(self)
|
||||
scroll.setWidgetResizable(True)
|
||||
scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
|
||||
scroll.setStyleSheet("QScrollArea { border: 0; background: transparent; }")
|
||||
root_layout.addWidget(scroll, 1)
|
||||
|
||||
content = QWidget()
|
||||
scroll.setWidget(content)
|
||||
|
||||
content_layout = QVBoxLayout(content)
|
||||
content_layout.setContentsMargins(0, 12, 0, 0)
|
||||
content_layout.setSpacing(14)
|
||||
|
||||
pattern_group = SectionPanel("Pattern")
|
||||
pattern_form = QFormLayout(pattern_group.body)
|
||||
pattern_form.setContentsMargins(12, 12, 12, 12)
|
||||
pattern_form.setSpacing(8)
|
||||
self.pattern_combo = QComboBox()
|
||||
for descriptor in self.controller.available_patterns():
|
||||
self.pattern_combo.addItem(descriptor.display_name, descriptor.pattern_id)
|
||||
pattern_form.addRow("Pattern", self.pattern_combo)
|
||||
content_layout.addWidget(pattern_group)
|
||||
|
||||
controls_group = SectionPanel("Look & Motion")
|
||||
self.controls_form = QFormLayout(controls_group.body)
|
||||
self.controls_form.setContentsMargins(12, 12, 12, 12)
|
||||
self.controls_form.setSpacing(8)
|
||||
content_layout.addWidget(controls_group)
|
||||
content_layout.addStretch(1)
|
||||
|
||||
self.widgets: dict[str, QWidget] = {}
|
||||
self._build_controls()
|
||||
|
||||
self.pattern_combo.currentIndexChanged.connect(self._on_pattern_changed)
|
||||
self.controller.state_changed.connect(self.refresh_from_state)
|
||||
self.refresh_from_state()
|
||||
|
||||
def _build_controls(self) -> None:
|
||||
self._add_combo("color_mode", list(COMMON_PARAMETER_SPECS["color_mode"].options))
|
||||
self._add_combo("palette", [(name, name) for name in PALETTES])
|
||||
self._add_color("primary_color")
|
||||
self._add_color("secondary_color")
|
||||
self._add_combo("direction", list(COMMON_PARAMETER_SPECS["direction"].options))
|
||||
self._add_angle("angle")
|
||||
self._add_combo("scan_style", list(COMMON_PARAMETER_SPECS["scan_style"].options))
|
||||
self._add_combo("checker_mode", list(COMMON_PARAMETER_SPECS["checker_mode"].options))
|
||||
self._add_combo("strobe_mode", list(COMMON_PARAMETER_SPECS["strobe_mode"].options))
|
||||
self._add_combo("stopwatch_mode", list(COMMON_PARAMETER_SPECS["stopwatch_mode"].options))
|
||||
self._add_combo("symmetry", list(COMMON_PARAMETER_SPECS["symmetry"].options))
|
||||
self._add_combo("center_pulse_mode", list(COMMON_PARAMETER_SPECS["center_pulse_mode"].options))
|
||||
self._add_slider("brightness")
|
||||
self._add_slider("fade")
|
||||
self._add_slider("on_width")
|
||||
self._add_slider("off_width")
|
||||
self._add_slider("block_size")
|
||||
self._add_slider("pixel_group_size")
|
||||
self._add_slider("strobe_duty_cycle")
|
||||
self._add_slider("randomness")
|
||||
self._add_slider("tempo_multiplier")
|
||||
|
||||
def _add_row(self, key: str, label_text: str, widget: QWidget) -> None:
|
||||
spec = COMMON_PARAMETER_SPECS[key]
|
||||
label: QLabel = QLabel(label_text)
|
||||
if spec.kind == "slider" and spec.reset_value is not None:
|
||||
clickable_label = ClickableLabel(label_text)
|
||||
clickable_label.setCursor(Qt.PointingHandCursor)
|
||||
clickable_label.clicked.connect(lambda field=key, reset_value=spec.reset_value: self._reset_slider(field, reset_value))
|
||||
label = clickable_label
|
||||
self.controls_form.addRow(label, widget)
|
||||
self._rows[key] = (label, widget)
|
||||
self.widgets[key] = widget
|
||||
tooltip = spec.tooltip
|
||||
if spec.kind == "slider" and spec.reset_value is not None:
|
||||
tooltip = f"{tooltip} Click the label to reset." if tooltip else "Click the label to reset."
|
||||
label.setToolTip(tooltip)
|
||||
widget.setToolTip(tooltip)
|
||||
|
||||
def _add_combo(self, key: str, options: list[tuple[str, str]]) -> None:
|
||||
combo = QComboBox()
|
||||
for value, label in options:
|
||||
combo.addItem(label, value)
|
||||
combo.currentIndexChanged.connect(lambda _: self._on_combo_changed(key))
|
||||
self._add_row(key, COMMON_PARAMETER_SPECS[key].label, combo)
|
||||
|
||||
def _add_slider(self, key: str) -> None:
|
||||
spec = COMMON_PARAMETER_SPECS[key]
|
||||
decimals = 2 if spec.step < 0.1 else 1
|
||||
slider = SliderField(spec.minimum, spec.maximum, spec.step, decimals=decimals)
|
||||
slider.valueChanged.connect(lambda value, field=key: self._on_slider_changed(field, value))
|
||||
self._add_row(key, spec.label, slider)
|
||||
|
||||
def _add_angle(self, key: str) -> None:
|
||||
selector = AngleSelector()
|
||||
selector.valueChanged.connect(lambda value, field=key: self._on_slider_changed(field, value))
|
||||
self._add_row(key, COMMON_PARAMETER_SPECS[key].label, selector)
|
||||
|
||||
def _add_checkbox(self, key: str) -> None:
|
||||
checkbox = QCheckBox()
|
||||
checkbox.stateChanged.connect(lambda _state, field=key, widget=checkbox: self._on_checkbox_changed(field, widget.isChecked()))
|
||||
self._add_row(key, COMMON_PARAMETER_SPECS[key].label, checkbox)
|
||||
|
||||
def _add_color(self, key: str) -> None:
|
||||
button = ColorButton("#4D7CFF" if key == "primary_color" else "#0E1630")
|
||||
button.colorChanged.connect(lambda value, field=key: self._on_color_changed(field, value))
|
||||
self._add_row(key, COMMON_PARAMETER_SPECS[key].label, button)
|
||||
|
||||
def _on_pattern_changed(self) -> None:
|
||||
if self._updating:
|
||||
return
|
||||
self.controller.set_pattern(self.pattern_combo.currentData())
|
||||
|
||||
def _on_combo_changed(self, key: str) -> None:
|
||||
if self._updating:
|
||||
return
|
||||
widget = self.widgets[key]
|
||||
self.controller.set_parameter(key, widget.currentData())
|
||||
|
||||
def _on_slider_changed(self, key: str, value: float) -> None:
|
||||
if self._updating:
|
||||
return
|
||||
self.controller.set_parameter(key, value)
|
||||
|
||||
def _reset_slider(self, key: str, value: float) -> None:
|
||||
if self._updating:
|
||||
return
|
||||
widget = self.widgets.get(key)
|
||||
if isinstance(widget, SliderField):
|
||||
widget.set_value(value)
|
||||
self.controller.set_parameter(key, value)
|
||||
|
||||
def _on_checkbox_changed(self, key: str, value: bool) -> None:
|
||||
if self._updating:
|
||||
return
|
||||
self.controller.set_parameter(key, value)
|
||||
|
||||
def _on_color_changed(self, key: str, value: str) -> None:
|
||||
if self._updating:
|
||||
return
|
||||
self.controller.set_parameter(key, value)
|
||||
|
||||
def refresh_from_state(self) -> None:
|
||||
self._updating = True
|
||||
self.pattern_combo.setCurrentIndex(max(0, self.pattern_combo.findData(self.controller.pattern_id)))
|
||||
params = self.controller.params
|
||||
|
||||
for key, widget in self.widgets.items():
|
||||
value = getattr(params, key)
|
||||
if key == "palette":
|
||||
value = canonical_palette_name(str(value))
|
||||
if isinstance(widget, QComboBox):
|
||||
index = widget.findData(value)
|
||||
widget.setCurrentIndex(max(0, index))
|
||||
elif isinstance(widget, SliderField):
|
||||
widget.set_value(float(value))
|
||||
elif isinstance(widget, AngleSelector):
|
||||
widget.set_value(float(value))
|
||||
elif isinstance(widget, QCheckBox):
|
||||
widget.setChecked(bool(value))
|
||||
elif isinstance(widget, ColorButton):
|
||||
widget.set_color(str(value))
|
||||
|
||||
descriptor = next(
|
||||
(descriptor for descriptor in self.controller.available_patterns() if descriptor.pattern_id == self.controller.pattern_id),
|
||||
None,
|
||||
)
|
||||
supported = set(descriptor.supported_parameters) if descriptor is not None else set()
|
||||
for key, (label, widget) in self._rows.items():
|
||||
visible = key in supported
|
||||
label.setVisible(visible)
|
||||
widget.setVisible(visible)
|
||||
self._updating = False
|
||||
@@ -0,0 +1,75 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.qt_compat import QHBoxLayout, QInputDialog, QListWidget, QListWidgetItem, QMessageBox, QPushButton, Qt, QVBoxLayout, QWidget
|
||||
from app.ui.section_panel import SectionPanel
|
||||
|
||||
|
||||
class PresetBrowser(QWidget):
|
||||
def __init__(self, controller, parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self.controller = controller
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
group = SectionPanel("Presets")
|
||||
group_layout = QVBoxLayout(group.body)
|
||||
group_layout.setContentsMargins(12, 12, 12, 12)
|
||||
group_layout.setSpacing(8)
|
||||
|
||||
self.list_widget = QListWidget()
|
||||
self.list_widget.itemDoubleClicked.connect(self._load_selected)
|
||||
group_layout.addWidget(self.list_widget, 1)
|
||||
|
||||
button_row = QHBoxLayout()
|
||||
self.save_button = QPushButton("Save Current")
|
||||
self.load_button = QPushButton("Load")
|
||||
self.delete_button = QPushButton("Delete")
|
||||
button_row.addWidget(self.save_button)
|
||||
button_row.addWidget(self.load_button)
|
||||
button_row.addWidget(self.delete_button)
|
||||
group_layout.addLayout(button_row)
|
||||
|
||||
layout.addWidget(group)
|
||||
|
||||
self.save_button.clicked.connect(self._save_current)
|
||||
self.load_button.clicked.connect(self._load_selected)
|
||||
self.delete_button.clicked.connect(self._delete_selected)
|
||||
self.controller.presets_changed.connect(self.refresh)
|
||||
self.refresh()
|
||||
|
||||
def refresh(self) -> None:
|
||||
self.list_widget.clear()
|
||||
for preset in self.controller.available_presets():
|
||||
item = QListWidgetItem(preset.name)
|
||||
item.setToolTip(f"{preset.pattern_id}\nPalette: {preset.palette}")
|
||||
self.list_widget.addItem(item)
|
||||
|
||||
def _selected_name(self) -> str | None:
|
||||
item = self.list_widget.currentItem()
|
||||
return item.text() if item else None
|
||||
|
||||
def _save_current(self) -> None:
|
||||
name, ok = QInputDialog.getText(self, "Save Preset", "Preset name:")
|
||||
if ok and name.strip():
|
||||
self.controller.save_current_preset(name.strip())
|
||||
self.refresh()
|
||||
|
||||
def _load_selected(self, *_args) -> None:
|
||||
name = self._selected_name()
|
||||
if name:
|
||||
self.controller.apply_preset(name)
|
||||
|
||||
def _delete_selected(self) -> None:
|
||||
name = self._selected_name()
|
||||
if not name:
|
||||
return
|
||||
confirm = QMessageBox.question(
|
||||
self,
|
||||
"Delete Preset",
|
||||
f"Delete preset '{name}'?",
|
||||
QMessageBox.Yes | QMessageBox.No,
|
||||
)
|
||||
if confirm == QMessageBox.Yes:
|
||||
self.controller.delete_preset(name)
|
||||
self.refresh()
|
||||
@@ -0,0 +1,52 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.qt_compat import QLabel, Qt, QVBoxLayout, QWidget
|
||||
|
||||
from app.ui.preview_modes import PREVIEW_MODE_TILE
|
||||
from app.ui.preview_widget import PreviewWidget
|
||||
|
||||
|
||||
class FullscreenPreviewWindow(QWidget):
|
||||
def __init__(
|
||||
self,
|
||||
controller,
|
||||
preview_mode: str = PREVIEW_MODE_TILE,
|
||||
scene_role: str = "live",
|
||||
technical_preview: bool | None = None,
|
||||
parent: QWidget | None = None,
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Infinity Mirror Preview")
|
||||
self.setWindowFlag(Qt.Window, True)
|
||||
self.setAttribute(Qt.WA_DeleteOnClose, False)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(0)
|
||||
|
||||
self.preview_widget = PreviewWidget(
|
||||
controller,
|
||||
preview_mode=preview_mode,
|
||||
scene_role=scene_role,
|
||||
technical_preview=technical_preview,
|
||||
)
|
||||
self.preview_widget.tileClicked.connect(controller.set_selected_tile)
|
||||
layout.addWidget(self.preview_widget, 1)
|
||||
|
||||
hint = QLabel("Press F11 or Escape to leave fullscreen preview")
|
||||
hint.setAlignment(Qt.AlignCenter)
|
||||
hint.setStyleSheet("background: #2D2D30; color: #CCCCCC; padding: 8px; font-size: 12px; border-top: 1px solid #3C3C3C;")
|
||||
layout.addWidget(hint)
|
||||
|
||||
def set_preview_mode(self, mode: str) -> None:
|
||||
self.preview_widget.set_preview_mode(mode)
|
||||
|
||||
def set_technical_preview(self, enabled: bool) -> None:
|
||||
self.preview_widget.set_technical_preview(enabled)
|
||||
|
||||
def keyPressEvent(self, event) -> None:
|
||||
if event.key() in {Qt.Key_F11, Qt.Key_Escape}:
|
||||
self.hide()
|
||||
event.accept()
|
||||
return
|
||||
super().keyPressEvent(event)
|
||||
@@ -0,0 +1,68 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from app.qt_compat import QRectF
|
||||
|
||||
from app.config.models import InfinityMirrorConfig, TileConfig
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PreviewLayout:
|
||||
canvas_rect: QRectF
|
||||
tile_rects: dict[str, QRectF]
|
||||
|
||||
|
||||
def compute_preview_layout(widget_rect: QRectF, config: InfinityMirrorConfig) -> PreviewLayout:
|
||||
rows = config.logical_display.rows
|
||||
cols = config.logical_display.cols
|
||||
outer = QRectF(widget_rect).adjusted(28, 28, -28, -28)
|
||||
gap = min(22.0, max(10.0, min(outer.width() / 48.0, outer.height() / 22.0)))
|
||||
tile_aspect = tile_aspect_ratio(config)
|
||||
usable_width = max(1.0, outer.width() - gap * (cols - 1))
|
||||
usable_height = max(1.0, outer.height() - gap * (rows - 1))
|
||||
tile_width = usable_width / max(1, cols)
|
||||
tile_height = tile_width / tile_aspect
|
||||
if tile_height * rows > usable_height:
|
||||
tile_height = usable_height / max(1, rows)
|
||||
tile_width = tile_height * tile_aspect
|
||||
|
||||
shell_padding = max(18.0, min(tile_width, tile_height) * 0.16)
|
||||
grid_width = tile_width * cols + gap * (cols - 1)
|
||||
grid_height = tile_height * rows + gap * (rows - 1)
|
||||
left = outer.left() + (outer.width() - grid_width) / 2.0
|
||||
top = outer.top() + (outer.height() - grid_height) / 2.0
|
||||
canvas_rect = QRectF(left - shell_padding, top - shell_padding, grid_width + shell_padding * 2.0, grid_height + shell_padding * 2.0)
|
||||
|
||||
tile_rects: dict[str, QRectF] = {}
|
||||
for tile in config.sorted_tiles():
|
||||
x = left + (tile.col - 1) * (tile_width + gap)
|
||||
y = top + (tile.row - 1) * (tile_height + gap)
|
||||
tile_rects[tile.tile_id] = QRectF(x, y, tile_width, tile_height)
|
||||
return PreviewLayout(canvas_rect=canvas_rect, tile_rects=tile_rects)
|
||||
|
||||
|
||||
def tile_aspect_ratio(config: InfinityMirrorConfig) -> float:
|
||||
logical_display = config.logical_display
|
||||
tile_width = max(1.0, float(logical_display.tile_width))
|
||||
tile_height = max(1.0, float(logical_display.tile_height))
|
||||
return max(0.55, min(1.8, tile_width / tile_height))
|
||||
|
||||
|
||||
def segment_display_rect(tile: TileConfig, rect: QRectF) -> QRectF:
|
||||
tile_width = max(0.001, tile.x1 - tile.x0)
|
||||
tile_height = max(0.001, tile.y1 - tile.y0)
|
||||
aspect_ratio = max(0.5, min(1.8, tile_width / tile_height))
|
||||
base = min(rect.width(), rect.height())
|
||||
margin = max(6.0, base * 0.07)
|
||||
available_rect = rect.adjusted(margin, margin, -margin, -margin)
|
||||
|
||||
fitted_width = available_rect.width()
|
||||
fitted_height = fitted_width / aspect_ratio
|
||||
if fitted_height > available_rect.height():
|
||||
fitted_height = available_rect.height()
|
||||
fitted_width = fitted_height * aspect_ratio
|
||||
|
||||
left = available_rect.left() + (available_rect.width() - fitted_width) / 2.0
|
||||
top = available_rect.top() + (available_rect.height() - fitted_height) / 2.0
|
||||
return QRectF(left, top, fitted_width, fitted_height)
|
||||
@@ -0,0 +1,24 @@
|
||||
from __future__ import annotations
|
||||
|
||||
PREVIEW_MODE_TILE = "tile"
|
||||
PREVIEW_MODE_TECHNICAL = "technical"
|
||||
PREVIEW_MODE_LEDS = "leds"
|
||||
PREVIEW_MODES = (PREVIEW_MODE_TILE, PREVIEW_MODE_TECHNICAL, PREVIEW_MODE_LEDS)
|
||||
|
||||
|
||||
def normalize_preview_mode(mode: str | None) -> str:
|
||||
normalized = str(mode or PREVIEW_MODE_TILE).strip().lower()
|
||||
return normalized if normalized in PREVIEW_MODES else PREVIEW_MODE_TILE
|
||||
|
||||
|
||||
def preview_mode_flags(mode: str) -> dict[str, bool]:
|
||||
preview_mode = normalize_preview_mode(mode)
|
||||
return {
|
||||
"show_fill": preview_mode in {PREVIEW_MODE_TILE, PREVIEW_MODE_TECHNICAL},
|
||||
"show_labels": preview_mode in {PREVIEW_MODE_TILE, PREVIEW_MODE_TECHNICAL},
|
||||
"show_leds": preview_mode in {PREVIEW_MODE_TECHNICAL, PREVIEW_MODE_LEDS},
|
||||
"show_guides": preview_mode == PREVIEW_MODE_TECHNICAL,
|
||||
"show_direction": preview_mode == PREVIEW_MODE_TECHNICAL,
|
||||
"show_overlay_title": preview_mode == PREVIEW_MODE_TECHNICAL,
|
||||
"show_technical_meta": preview_mode == PREVIEW_MODE_TECHNICAL,
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
from app.qt_compat import QColor, QFont, QLinearGradient, QPainter, QPainterPath, QPen, QPointF, QRectF, Qt
|
||||
|
||||
from app.config.models import SegmentConfig, TileConfig
|
||||
from app.core.geometry import segment_led_positions
|
||||
from app.core.types import PreviewFrame, RGBColor
|
||||
|
||||
from .preview_layout import PreviewLayout, segment_display_rect
|
||||
from .preview_modes import preview_mode_flags
|
||||
|
||||
|
||||
def _qcolor(color: RGBColor, alpha: float = 1.0) -> QColor:
|
||||
red, green, blue = color.to_8bit_tuple()
|
||||
qt_color = QColor(red, green, blue)
|
||||
qt_color.setAlphaF(max(0.0, min(1.0, alpha)))
|
||||
return qt_color
|
||||
|
||||
|
||||
def paint_empty_preview(painter: QPainter, rect: QRectF) -> None:
|
||||
painter.fillRect(rect, QColor("#1E1E1E"))
|
||||
painter.setPen(QColor("#8C8C8C"))
|
||||
painter.drawText(rect, Qt.AlignCenter, "Open a mapping to start the preview.")
|
||||
|
||||
|
||||
def paint_preview_scene(
|
||||
painter: QPainter,
|
||||
*,
|
||||
config,
|
||||
frame: PreviewFrame,
|
||||
preview_mode: str,
|
||||
selected_tile_id: str | None,
|
||||
target_rect: QRectF,
|
||||
layout: PreviewLayout,
|
||||
) -> None:
|
||||
flags = preview_mode_flags(preview_mode)
|
||||
background = QLinearGradient(0, 0, target_rect.width(), target_rect.height())
|
||||
background.setColorAt(0.0, _qcolor(frame.background_start))
|
||||
background.setColorAt(1.0, _qcolor(frame.background_end))
|
||||
painter.fillRect(target_rect, background)
|
||||
|
||||
_draw_canvas_shell(painter, layout.canvas_rect)
|
||||
for tile in config.sorted_tiles():
|
||||
tile_frame = frame.tiles.get(tile.tile_id)
|
||||
tile_rect = layout.tile_rects[tile.tile_id]
|
||||
_draw_tile(
|
||||
painter,
|
||||
tile=tile,
|
||||
tile_frame=tile_frame,
|
||||
rect=tile_rect,
|
||||
flags=flags,
|
||||
selected_tile_id=selected_tile_id,
|
||||
)
|
||||
|
||||
if flags["show_overlay_title"]:
|
||||
painter.setPen(QColor(204, 204, 204, 140))
|
||||
painter.drawText(
|
||||
target_rect.adjusted(24, 18, -24, -18),
|
||||
Qt.AlignTop | Qt.AlignRight,
|
||||
"Technical Preview",
|
||||
)
|
||||
|
||||
|
||||
def _draw_canvas_shell(painter: QPainter, rect: QRectF) -> None:
|
||||
path = QPainterPath()
|
||||
path.addRoundedRect(rect, 8, 8)
|
||||
painter.fillPath(path, QColor("#252526"))
|
||||
painter.setPen(QPen(QColor("#3C3C3C"), 1.0))
|
||||
painter.drawPath(path)
|
||||
|
||||
|
||||
def _draw_tile(
|
||||
painter: QPainter,
|
||||
*,
|
||||
tile: TileConfig,
|
||||
tile_frame,
|
||||
rect: QRectF,
|
||||
flags: dict[str, bool],
|
||||
selected_tile_id: str | None,
|
||||
) -> None:
|
||||
if tile_frame is None:
|
||||
return
|
||||
|
||||
base = min(rect.width(), rect.height())
|
||||
rounding = max(4.0, base * 0.045)
|
||||
fill_color = _qcolor(tile_frame.fill_color)
|
||||
rim_color = _qcolor(tile_frame.rim_color)
|
||||
diagonal_split = tile_frame.metadata.get("diagonal_split")
|
||||
|
||||
tile_path = QPainterPath()
|
||||
tile_path.addRoundedRect(rect, rounding, rounding)
|
||||
if flags["show_fill"] and isinstance(diagonal_split, dict):
|
||||
_draw_diagonal_split_fill(painter, tile_path, rect, diagonal_split)
|
||||
elif flags["show_fill"]:
|
||||
painter.fillPath(tile_path, fill_color)
|
||||
else:
|
||||
painter.fillPath(tile_path, QColor("#090B12"))
|
||||
|
||||
if flags["show_fill"]:
|
||||
highlight = QLinearGradient(rect.topLeft(), rect.bottomLeft())
|
||||
highlight.setColorAt(0.0, QColor(255, 255, 255, 26))
|
||||
highlight.setColorAt(0.12, QColor(255, 255, 255, 10))
|
||||
highlight.setColorAt(1.0, QColor(0, 0, 0, 0))
|
||||
painter.fillPath(tile_path, highlight)
|
||||
|
||||
outline_color = rim_color if flags["show_fill"] else QColor(255, 255, 255, 32)
|
||||
painter.setPen(QPen(outline_color, 1.2 if flags["show_leds"] else 1.0))
|
||||
painter.drawPath(tile_path)
|
||||
|
||||
if flags["show_fill"]:
|
||||
inner_rect = rect.adjusted(rect.width() * 0.08, rect.height() * 0.08, -rect.width() * 0.08, -rect.height() * 0.08)
|
||||
painter.setPen(QPen(QColor(255, 255, 255, 14), 1.0))
|
||||
painter.drawRoundedRect(inner_rect, rounding * 0.66, rounding * 0.66)
|
||||
|
||||
if not tile.enabled:
|
||||
painter.fillPath(tile_path, QColor(0, 0, 0, 125))
|
||||
painter.setPen(QPen(QColor(255, 255, 255, 36), 1.0, Qt.DashLine))
|
||||
painter.drawRoundedRect(rect.adjusted(6, 6, -6, -6), rounding * 0.8, rounding * 0.8)
|
||||
|
||||
if selected_tile_id == tile.tile_id:
|
||||
painter.setPen(QPen(QColor("#007ACC"), 2.0))
|
||||
painter.drawRoundedRect(rect.adjusted(-3, -3, 3, 3), rounding + 2, rounding + 2)
|
||||
|
||||
if flags["show_labels"]:
|
||||
_draw_labels(painter, tile, tile_frame, rect, technical_meta=flags["show_technical_meta"])
|
||||
|
||||
if flags["show_leds"]:
|
||||
_draw_segment_preview(
|
||||
painter,
|
||||
tile,
|
||||
tile_frame,
|
||||
rect,
|
||||
show_guides=flags["show_guides"],
|
||||
show_direction=flags["show_direction"],
|
||||
)
|
||||
|
||||
|
||||
def _draw_diagonal_split_fill(painter: QPainter, tile_path: QPainterPath, rect: QRectF, diagonal_split: dict[str, object]) -> None:
|
||||
color_a = diagonal_split.get("color_a")
|
||||
color_b = diagonal_split.get("color_b")
|
||||
if not isinstance(color_a, RGBColor) or not isinstance(color_b, RGBColor):
|
||||
painter.fillPath(tile_path, QColor("#000000"))
|
||||
return
|
||||
|
||||
painter.save()
|
||||
painter.setClipPath(tile_path)
|
||||
orientation = str(diagonal_split.get("orientation", "slash"))
|
||||
|
||||
first = QPainterPath()
|
||||
second = QPainterPath()
|
||||
if orientation == "backslash":
|
||||
first.moveTo(rect.topLeft())
|
||||
first.lineTo(rect.topRight())
|
||||
first.lineTo(rect.bottomRight())
|
||||
first.closeSubpath()
|
||||
|
||||
second.moveTo(rect.topLeft())
|
||||
second.lineTo(rect.bottomLeft())
|
||||
second.lineTo(rect.bottomRight())
|
||||
second.closeSubpath()
|
||||
else:
|
||||
first.moveTo(rect.topLeft())
|
||||
first.lineTo(rect.topRight())
|
||||
first.lineTo(rect.bottomLeft())
|
||||
first.closeSubpath()
|
||||
|
||||
second.moveTo(rect.topRight())
|
||||
second.lineTo(rect.bottomRight())
|
||||
second.lineTo(rect.bottomLeft())
|
||||
second.closeSubpath()
|
||||
|
||||
painter.fillPath(first, _qcolor(color_a))
|
||||
painter.fillPath(second, _qcolor(color_b))
|
||||
painter.restore()
|
||||
|
||||
|
||||
def _draw_labels(painter: QPainter, tile: TileConfig, tile_frame, rect: QRectF, technical_meta: bool = False) -> None:
|
||||
painter.save()
|
||||
base = min(rect.width(), rect.height())
|
||||
horizontal_padding = max(12.0, rect.width() * 0.08)
|
||||
top_padding = max(10.0, rect.height() * 0.07)
|
||||
bottom_padding = max(12.0, rect.height() * 0.08)
|
||||
|
||||
font = QFont()
|
||||
font.setPointSizeF(max(14.0, base * 0.105))
|
||||
font.setWeight(QFont.DemiBold)
|
||||
painter.setFont(font)
|
||||
painter.setPen(_qcolor(tile_frame.label_color, 0.92))
|
||||
title_rect = rect.adjusted(horizontal_padding, top_padding, -horizontal_padding, -rect.height() * 0.52)
|
||||
painter.drawText(title_rect, Qt.AlignLeft | Qt.AlignTop | Qt.TextWordWrap, tile.tile_id)
|
||||
|
||||
meta_font = QFont(font)
|
||||
meta_font.setPointSizeF(max(11.5, base * (0.07 if technical_meta else 0.082)))
|
||||
meta_font.setWeight(QFont.Normal)
|
||||
painter.setFont(meta_font)
|
||||
text = f"R{tile.row} C{tile.col}"
|
||||
if technical_meta:
|
||||
text = f"{tile.screen_name or tile.controller_ip}\nU{tile.universe} S{tile.subnet} {tile.led_total} LEDs"
|
||||
painter.setPen(QColor(235, 244, 249, 165))
|
||||
meta_rect = rect.adjusted(horizontal_padding, rect.height() * 0.56, -horizontal_padding, -bottom_padding)
|
||||
painter.drawText(meta_rect, Qt.AlignLeft | Qt.AlignBottom | Qt.TextWordWrap, text)
|
||||
painter.restore()
|
||||
|
||||
|
||||
def _draw_segment_preview(
|
||||
painter: QPainter,
|
||||
tile: TileConfig,
|
||||
tile_frame,
|
||||
rect: QRectF,
|
||||
*,
|
||||
show_guides: bool,
|
||||
show_direction: bool,
|
||||
) -> None:
|
||||
painter.save()
|
||||
led_radius = max(2.0, min(rect.width(), rect.height()) / 64.0)
|
||||
guide_pen = QPen(QColor(220, 228, 236, 46), max(0.9, led_radius * 0.55))
|
||||
guide_pen.setCapStyle(Qt.RoundCap)
|
||||
guide_pen.setJoinStyle(Qt.RoundJoin)
|
||||
for segment in tile.segments:
|
||||
points = _segment_points(tile, segment, rect)
|
||||
colors = tile_frame.led_pixels.get(segment.name, [])
|
||||
if show_guides and len(points) >= 2:
|
||||
painter.setPen(guide_pen)
|
||||
for start, end in zip(points, points[1:]):
|
||||
painter.drawLine(start, end)
|
||||
|
||||
painter.setPen(Qt.NoPen)
|
||||
for index, point in enumerate(points):
|
||||
color = colors[index] if index < len(colors) else tile_frame.rim_color
|
||||
if color.to_8bit_tuple() == (0, 0, 0):
|
||||
continue
|
||||
painter.setBrush(_qcolor(color, 0.94))
|
||||
painter.drawEllipse(point, led_radius, led_radius)
|
||||
|
||||
if show_direction and points:
|
||||
_draw_direction_arrow(painter, points, segment)
|
||||
|
||||
painter.restore()
|
||||
|
||||
|
||||
def _segment_points(tile: TileConfig, segment: SegmentConfig, rect: QRectF) -> list[QPointF]:
|
||||
display_rect = segment_display_rect(tile, rect)
|
||||
inset = max(2.0, min(display_rect.width(), display_rect.height()) * 0.02)
|
||||
insets = (
|
||||
inset / max(1.0, display_rect.width()),
|
||||
inset / max(1.0, display_rect.height()),
|
||||
)
|
||||
return [
|
||||
QPointF(display_rect.left() + x_pos * display_rect.width(), display_rect.top() + y_pos * display_rect.height())
|
||||
for x_pos, y_pos in segment_led_positions(tile, segment, insets=insets)
|
||||
]
|
||||
|
||||
|
||||
def _draw_direction_arrow(painter: QPainter, points: list[QPointF], segment: SegmentConfig) -> None:
|
||||
if len(points) < 2:
|
||||
return
|
||||
start = points[0]
|
||||
end = points[-1]
|
||||
|
||||
mid = QPointF((start.x() + end.x()) / 2.0, (start.y() + end.y()) / 2.0)
|
||||
dx = end.x() - start.x()
|
||||
dy = end.y() - start.y()
|
||||
length = math.hypot(dx, dy) or 1.0
|
||||
ux, uy = dx / length, dy / length
|
||||
arrow_len = 14.0
|
||||
left = QPointF(mid.x() - ux * arrow_len + -uy * arrow_len * 0.5, mid.y() - uy * arrow_len + ux * arrow_len * 0.5)
|
||||
right = QPointF(mid.x() - ux * arrow_len - -uy * arrow_len * 0.5, mid.y() - uy * arrow_len - ux * arrow_len * 0.5)
|
||||
|
||||
painter.setPen(QPen(QColor(255, 255, 255, 85), 1.0))
|
||||
painter.drawLine(start, end)
|
||||
painter.drawLine(mid, left)
|
||||
painter.drawLine(mid, right)
|
||||
@@ -0,0 +1,118 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.qt_compat import QPainter, QPointF, QRectF, Qt, Signal, QWidget, event_posf
|
||||
|
||||
from app.config.models import SegmentConfig, TileConfig
|
||||
from app.core.geometry import segment_led_positions, segment_side
|
||||
from app.core.types import PreviewFrame
|
||||
|
||||
from .preview_layout import compute_preview_layout, segment_display_rect, tile_aspect_ratio
|
||||
from .preview_modes import (
|
||||
PREVIEW_MODE_LEDS,
|
||||
PREVIEW_MODE_TECHNICAL,
|
||||
PREVIEW_MODE_TILE,
|
||||
normalize_preview_mode,
|
||||
preview_mode_flags,
|
||||
)
|
||||
from .preview_painter import paint_empty_preview, paint_preview_scene
|
||||
|
||||
|
||||
class PreviewWidget(QWidget):
|
||||
tileClicked = Signal(str)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
controller,
|
||||
preview_mode: str = PREVIEW_MODE_TILE,
|
||||
scene_role: str = "live",
|
||||
technical_preview: bool | None = None,
|
||||
parent: QWidget | None = None,
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
self.controller = controller
|
||||
self.scene_role = "next" if str(scene_role).strip().lower() == "next" else "live"
|
||||
if technical_preview is not None:
|
||||
preview_mode = PREVIEW_MODE_TECHNICAL if technical_preview else PREVIEW_MODE_TILE
|
||||
self.preview_mode = normalize_preview_mode(preview_mode)
|
||||
self.technical_preview = self.preview_mode == PREVIEW_MODE_TECHNICAL
|
||||
self.current_frame: PreviewFrame | None = self.controller.preview_frame_for(self.scene_role)
|
||||
self._tile_rects: dict[str, QRectF] = {}
|
||||
|
||||
self.setMinimumSize(640, 360)
|
||||
self.setMouseTracking(True)
|
||||
|
||||
if self.scene_role == "next":
|
||||
self.controller.next_frame_ready.connect(self._on_frame_ready)
|
||||
else:
|
||||
self.controller.frame_ready.connect(self._on_frame_ready)
|
||||
self.controller.config_changed.connect(self.update)
|
||||
self.controller.state_changed.connect(self.update)
|
||||
|
||||
def set_preview_mode(self, mode: str) -> None:
|
||||
self.preview_mode = normalize_preview_mode(mode)
|
||||
self.technical_preview = self.preview_mode == PREVIEW_MODE_TECHNICAL
|
||||
self.update()
|
||||
|
||||
def set_technical_preview(self, enabled: bool) -> None:
|
||||
self.set_preview_mode(PREVIEW_MODE_TECHNICAL if enabled else PREVIEW_MODE_TILE)
|
||||
|
||||
def _mode_flags(self) -> dict[str, bool]:
|
||||
return preview_mode_flags(self.preview_mode)
|
||||
|
||||
def _on_frame_ready(self, frame: PreviewFrame) -> None:
|
||||
self.current_frame = frame
|
||||
self.update()
|
||||
|
||||
def mousePressEvent(self, event) -> None:
|
||||
point = event_posf(event)
|
||||
for tile_id, rect in self._tile_rects.items():
|
||||
if rect.contains(point):
|
||||
self.tileClicked.emit(tile_id)
|
||||
break
|
||||
super().mousePressEvent(event)
|
||||
|
||||
def paintEvent(self, event) -> None:
|
||||
painter = QPainter(self)
|
||||
painter.setRenderHint(QPainter.Antialiasing, True)
|
||||
painter.setRenderHint(QPainter.TextAntialiasing, True)
|
||||
|
||||
frame = self.current_frame
|
||||
if frame is None or not self.controller.config.tiles:
|
||||
paint_empty_preview(painter, QRectF(self.rect()))
|
||||
return
|
||||
|
||||
layout = compute_preview_layout(QRectF(self.rect()), self.controller.config)
|
||||
self._tile_rects = layout.tile_rects
|
||||
paint_preview_scene(
|
||||
painter,
|
||||
config=self.controller.config,
|
||||
frame=frame,
|
||||
preview_mode=self.preview_mode,
|
||||
selected_tile_id=self.controller.selected_tile_id,
|
||||
target_rect=QRectF(self.rect()),
|
||||
layout=layout,
|
||||
)
|
||||
|
||||
def _compute_layout(self) -> tuple[QRectF, dict[str, QRectF]]:
|
||||
layout = compute_preview_layout(QRectF(self.rect()), self.controller.config)
|
||||
return layout.canvas_rect, layout.tile_rects
|
||||
|
||||
def _tile_aspect_ratio(self) -> float:
|
||||
return tile_aspect_ratio(self.controller.config)
|
||||
|
||||
def _segment_display_rect(self, tile: TileConfig, rect: QRectF) -> QRectF:
|
||||
return segment_display_rect(tile, rect)
|
||||
|
||||
def _segment_side(self, tile: TileConfig, segment: SegmentConfig) -> str | None:
|
||||
return segment_side(tile, segment)
|
||||
|
||||
def _segment_points_for_side(self, tile: TileConfig, segment: SegmentConfig, rect: QRectF) -> list[QPointF]:
|
||||
inset = max(2.0, min(rect.width(), rect.height()) * 0.02)
|
||||
insets = (
|
||||
inset / max(1.0, rect.width()),
|
||||
inset / max(1.0, rect.height()),
|
||||
)
|
||||
return [
|
||||
QPointF(rect.left() + x_pos * rect.width(), rect.top() + y_pos * rect.height())
|
||||
for x_pos, y_pos in segment_led_positions(tile, segment, insets=insets)
|
||||
]
|
||||
@@ -0,0 +1,85 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.qt_compat import QLabel, QVBoxLayout, QWidget
|
||||
|
||||
from app.ui.preview_fullscreen import FullscreenPreviewWindow
|
||||
from app.ui.preview_modes import PREVIEW_MODE_TILE, normalize_preview_mode
|
||||
from app.ui.preview_widget import PreviewWidget
|
||||
|
||||
|
||||
class ScenePreviewArea(QWidget):
|
||||
def __init__(self, controller, preview_mode: str = PREVIEW_MODE_TILE, parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self.controller = controller
|
||||
self.preview_mode = normalize_preview_mode(preview_mode)
|
||||
|
||||
self.fullscreen_preview = FullscreenPreviewWindow(self.controller, preview_mode=self.preview_mode, scene_role="live")
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(0, 12, 0, 0)
|
||||
layout.setSpacing(14)
|
||||
|
||||
self.preview_title = QLabel("Preview")
|
||||
self.preview_title.setStyleSheet("font-size: 12px; font-weight: 600; color: #CCCCCC; padding: 0 0 6px 2px;")
|
||||
layout.addWidget(self.preview_title)
|
||||
|
||||
self.single_preview_container = QWidget()
|
||||
single_layout = QVBoxLayout(self.single_preview_container)
|
||||
single_layout.setContentsMargins(0, 0, 0, 0)
|
||||
single_layout.setSpacing(0)
|
||||
self.preview_widget = PreviewWidget(self.controller, preview_mode=self.preview_mode, scene_role="live")
|
||||
self.preview_widget.tileClicked.connect(self.controller.set_selected_tile)
|
||||
single_layout.addWidget(self.preview_widget, 1)
|
||||
layout.addWidget(self.single_preview_container, 1)
|
||||
|
||||
self.foh_preview_container = QWidget()
|
||||
foh_layout = QVBoxLayout(self.foh_preview_container)
|
||||
foh_layout.setContentsMargins(0, 0, 0, 0)
|
||||
foh_layout.setSpacing(12)
|
||||
|
||||
self.live_preview_widget = PreviewWidget(self.controller, preview_mode=self.preview_mode, scene_role="live")
|
||||
self.live_preview_widget.tileClicked.connect(self.controller.set_selected_tile)
|
||||
live_panel, self.live_preview_label = self._build_scene_preview_panel("Live", self.live_preview_widget)
|
||||
foh_layout.addWidget(live_panel, 1)
|
||||
|
||||
self.next_preview_widget = PreviewWidget(self.controller, preview_mode=self.preview_mode, scene_role="next")
|
||||
self.next_preview_widget.tileClicked.connect(self.controller.set_selected_tile)
|
||||
next_panel, self.next_preview_label = self._build_scene_preview_panel("Next", self.next_preview_widget)
|
||||
foh_layout.addWidget(next_panel, 1)
|
||||
|
||||
layout.addWidget(self.foh_preview_container, 1)
|
||||
|
||||
def set_preview_mode(self, mode: str) -> None:
|
||||
self.preview_mode = normalize_preview_mode(mode)
|
||||
self.preview_widget.set_preview_mode(self.preview_mode)
|
||||
self.live_preview_widget.set_preview_mode(self.preview_mode)
|
||||
self.next_preview_widget.set_preview_mode(self.preview_mode)
|
||||
self.fullscreen_preview.set_preview_mode(self.preview_mode)
|
||||
|
||||
def set_foh_mode(self, enabled: bool) -> None:
|
||||
self.single_preview_container.setVisible(not enabled)
|
||||
self.foh_preview_container.setVisible(enabled)
|
||||
|
||||
def set_scene_labels(self, live_label: str, next_label: str) -> None:
|
||||
self.live_preview_label.setText(live_label)
|
||||
self.next_preview_label.setText(next_label)
|
||||
|
||||
def toggle_fullscreen(self) -> None:
|
||||
if self.fullscreen_preview.isVisible():
|
||||
self.fullscreen_preview.hide()
|
||||
else:
|
||||
self.fullscreen_preview.showFullScreen()
|
||||
|
||||
def _build_scene_preview_panel(self, title: str, preview_widget: PreviewWidget) -> tuple[QWidget, QLabel]:
|
||||
panel = QWidget()
|
||||
layout = QVBoxLayout(panel)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(6)
|
||||
|
||||
title_label = QLabel(title)
|
||||
title_label.setStyleSheet("font-size: 13px; font-weight: 600; color: #E6E6E6; padding-left: 2px;")
|
||||
layout.addWidget(title_label)
|
||||
|
||||
preview_widget.setMinimumHeight(220)
|
||||
layout.addWidget(preview_widget, 1)
|
||||
return panel, title_label
|
||||
@@ -0,0 +1,36 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.qt_compat import QHBoxLayout, QLabel, Qt, QVBoxLayout, QWidget
|
||||
|
||||
|
||||
class SectionPanel(QWidget):
|
||||
def __init__(self, title: str, parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self.setObjectName("sectionPanel")
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(0)
|
||||
|
||||
self.header_widget = QWidget(self)
|
||||
self.header_widget.setObjectName("sectionHeader")
|
||||
self.header_widget.setFixedHeight(40)
|
||||
header_layout = QHBoxLayout(self.header_widget)
|
||||
header_layout.setContentsMargins(12, 0, 12, 0)
|
||||
header_layout.setSpacing(0)
|
||||
|
||||
self.title_label = QLabel(self.header_widget)
|
||||
self.title_label.setObjectName("sectionHeaderLabel")
|
||||
self.title_label.setAlignment(Qt.AlignLeft | Qt.AlignVCenter)
|
||||
header_layout.addWidget(self.title_label, 1)
|
||||
|
||||
self.body = QWidget(self)
|
||||
self.body.setObjectName("sectionBody")
|
||||
|
||||
layout.addWidget(self.header_widget)
|
||||
layout.addWidget(self.body)
|
||||
|
||||
self.setTitle(title)
|
||||
|
||||
def setTitle(self, title: str) -> None:
|
||||
self.title_label.setText(title)
|
||||
@@ -0,0 +1,833 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
|
||||
from app.qt_compat import QComboBox, QDialog, QDialogButtonBox, QFormLayout, QGroupBox, QHBoxLayout, QHeaderView, QLabel, QLineEdit, QListWidget, QMessageBox, QObject, QPushButton, QPlainTextEdit, QSpinBox, QTabWidget, QTableWidget, QTableWidgetItem, Qt, QVBoxLayout, QWidget, Signal
|
||||
|
||||
from app.config.device_assignment import assign_device_to_tile, find_tile_for_device, tile_matches_device
|
||||
from app.config.models import InfinityMirrorConfig, SegmentConfig
|
||||
from app.config.xml_mapping import MappingValidationError, config_to_xml_string, load_config_from_string, save_config, validate_config
|
||||
from app.network.wled import DiscoveredWledDevice, build_scan_hosts, discover_wled_devices, identify_wled_device
|
||||
from app.ui.mapping_assignment_preview import MappingAssignmentPreview
|
||||
|
||||
|
||||
class NetworkScanWorker(QObject):
|
||||
progress = Signal(int, int, object)
|
||||
finished = Signal(object, str, int)
|
||||
|
||||
def __init__(self, config: InfinityMirrorConfig) -> None:
|
||||
super().__init__()
|
||||
self._config = config.clone()
|
||||
|
||||
def start(self) -> None:
|
||||
thread = threading.Thread(target=self._run, name="InfinityMirrorNetworkScan", daemon=True)
|
||||
thread.start()
|
||||
|
||||
def _run(self) -> None:
|
||||
try:
|
||||
hosts = build_scan_hosts(self._config)
|
||||
devices = discover_wled_devices(hosts, progress_callback=self._emit_progress)
|
||||
self.finished.emit(devices, "", len(hosts))
|
||||
except Exception as exc:
|
||||
self.finished.emit([], str(exc), 0)
|
||||
|
||||
def _emit_progress(self, completed: int, total: int, device: DiscoveredWledDevice | None) -> None:
|
||||
self.progress.emit(completed, total, device)
|
||||
|
||||
|
||||
class DeviceIdentifyWorker(QObject):
|
||||
finished = Signal(object, str)
|
||||
|
||||
def __init__(self, device: DiscoveredWledDevice) -> None:
|
||||
super().__init__()
|
||||
self._device = device
|
||||
|
||||
def start(self) -> None:
|
||||
thread = threading.Thread(target=self._run, name=f"WledIdentify-{self._device.ip_address}", daemon=True)
|
||||
thread.start()
|
||||
|
||||
def _run(self) -> None:
|
||||
try:
|
||||
identify_wled_device(self._device.ip_address, led_count=self._device.led_count)
|
||||
except Exception as exc:
|
||||
self.finished.emit(self._device, str(exc))
|
||||
return
|
||||
self.finished.emit(self._device, "")
|
||||
|
||||
|
||||
class SettingsDialog(QDialog):
|
||||
TILE_COLUMNS = [
|
||||
("tile_id", "Tile ID"),
|
||||
("row", "Row"),
|
||||
("col", "Col"),
|
||||
("screen_name", "Screen Name"),
|
||||
("controller_ip", "Controller IP"),
|
||||
("controller_name", "Controller Name"),
|
||||
("controller_host", "Controller Host"),
|
||||
("controller_mac", "Controller MAC"),
|
||||
("subnet", "Subnet"),
|
||||
("universe", "Universe"),
|
||||
("led_total", "LED Count"),
|
||||
("brightness_factor", "Brightness"),
|
||||
("enabled", "Enabled"),
|
||||
]
|
||||
SEGMENT_COLUMNS = [
|
||||
("name", "Name"),
|
||||
("side", "Side"),
|
||||
("start_channel", "Start Ch"),
|
||||
("led_count", "LED Count"),
|
||||
("orientation_rad", "Orientation"),
|
||||
("x0", "x0"),
|
||||
("y0", "y0"),
|
||||
("x1", "x1"),
|
||||
("y1", "y1"),
|
||||
("reverse", "Reverse"),
|
||||
]
|
||||
DEVICE_COLUMNS = [
|
||||
"IP Address",
|
||||
"Host / mDNS",
|
||||
"WLED Name",
|
||||
"MAC Address",
|
||||
"Status",
|
||||
"Map",
|
||||
"Identify",
|
||||
]
|
||||
|
||||
def __init__(self, config: InfinityMirrorConfig, controller=None, parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Mapping Settings")
|
||||
self.resize(1320, 900)
|
||||
|
||||
self.controller = controller
|
||||
self.working_config = config.clone()
|
||||
self.result_config: InfinityMirrorConfig | None = None
|
||||
self._active_segment_tile_id: str | None = None
|
||||
self._device_table_refreshing = False
|
||||
self._active_device_key: str | None = None
|
||||
self._selected_assignment_tile_id: str | None = None
|
||||
self._scan_worker: NetworkScanWorker | None = None
|
||||
self._identify_workers: set[DeviceIdentifyWorker] = set()
|
||||
self.discovered_devices: list[DiscoveredWledDevice] = []
|
||||
self._last_scan_host_count = 0
|
||||
|
||||
root_layout = QVBoxLayout(self)
|
||||
|
||||
header_group = QGroupBox("Mapping")
|
||||
header_form = QFormLayout(header_group)
|
||||
self.name_edit = QLineEdit(self.working_config.name)
|
||||
self.rows_spin = QSpinBox()
|
||||
self.rows_spin.setRange(1, 24)
|
||||
self.rows_spin.setValue(self.working_config.logical_display.rows)
|
||||
self.cols_spin = QSpinBox()
|
||||
self.cols_spin.setRange(1, 24)
|
||||
self.cols_spin.setValue(self.working_config.logical_display.cols)
|
||||
header_form.addRow("Name", self.name_edit)
|
||||
header_form.addRow("Rows", self.rows_spin)
|
||||
header_form.addRow("Cols", self.cols_spin)
|
||||
root_layout.addWidget(header_group)
|
||||
|
||||
self.tabs = QTabWidget()
|
||||
self.tabs.currentChanged.connect(self._handle_tab_changed)
|
||||
root_layout.addWidget(self.tabs, 1)
|
||||
|
||||
self.tiles_tab = QWidget()
|
||||
self.network_tab = QWidget()
|
||||
self.segments_tab = QWidget()
|
||||
self.raw_tab = QWidget()
|
||||
self.tabs.addTab(self.tiles_tab, "Tiles")
|
||||
self.tabs.addTab(self.network_tab, "Network Mapping")
|
||||
self.tabs.addTab(self.segments_tab, "Segments")
|
||||
self.tabs.addTab(self.raw_tab, "Raw XML")
|
||||
|
||||
self._build_tiles_tab()
|
||||
self._build_network_tab()
|
||||
self._build_segments_tab()
|
||||
self._build_raw_tab()
|
||||
self._populate_tiles_table()
|
||||
self._rebuild_segment_tile_combo()
|
||||
self._refresh_raw_xml()
|
||||
self._refresh_network_mapping_ui()
|
||||
|
||||
button_box = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Cancel)
|
||||
button_box.accepted.connect(self._save_and_accept)
|
||||
button_box.rejected.connect(self.reject)
|
||||
root_layout.addWidget(button_box)
|
||||
|
||||
def _build_tiles_tab(self) -> None:
|
||||
layout = QVBoxLayout(self.tiles_tab)
|
||||
self.tiles_table = QTableWidget(0, len(self.TILE_COLUMNS))
|
||||
self.tiles_table.setHorizontalHeaderLabels([label for _, label in self.TILE_COLUMNS])
|
||||
self.tiles_table.verticalHeader().setVisible(False)
|
||||
self.tiles_table.setAlternatingRowColors(True)
|
||||
self.tiles_table.setSelectionBehavior(QTableWidget.SelectRows)
|
||||
self.tiles_table.setSortingEnabled(False)
|
||||
layout.addWidget(self.tiles_table)
|
||||
|
||||
def _build_network_tab(self) -> None:
|
||||
layout = QVBoxLayout(self.network_tab)
|
||||
layout.setSpacing(12)
|
||||
|
||||
intro = QLabel(
|
||||
"Scan the local network for WLED controllers, flash one device at a time, "
|
||||
"then click the matching physical tile to save the assignment."
|
||||
)
|
||||
intro.setWordWrap(True)
|
||||
layout.addWidget(intro)
|
||||
|
||||
hint = QLabel(
|
||||
"Assignments save immediately to the open mapping file when the current mapping validates. "
|
||||
"If the mapping cannot be saved yet, the assignment stays in this dialog and you will see a warning."
|
||||
)
|
||||
hint.setWordWrap(True)
|
||||
hint.setStyleSheet("color: #A8B3C7;")
|
||||
layout.addWidget(hint)
|
||||
|
||||
content_row = QHBoxLayout()
|
||||
content_row.setSpacing(12)
|
||||
layout.addLayout(content_row, 1)
|
||||
|
||||
preview_group = QGroupBox("Mapping Preview")
|
||||
preview_layout = QVBoxLayout(preview_group)
|
||||
preview_layout.setSpacing(8)
|
||||
self.assignment_workflow_label = QLabel("Scan the network to begin assisted mapping.")
|
||||
self.assignment_workflow_label.setWordWrap(True)
|
||||
preview_layout.addWidget(self.assignment_workflow_label)
|
||||
self.mapping_preview = MappingAssignmentPreview()
|
||||
self.mapping_preview.tileClicked.connect(self._handle_mapping_preview_click)
|
||||
preview_layout.addWidget(self.mapping_preview, 1)
|
||||
content_row.addWidget(preview_group, 1)
|
||||
|
||||
device_group = QGroupBox("Discovered WLED Devices")
|
||||
device_layout = QVBoxLayout(device_group)
|
||||
device_layout.setSpacing(8)
|
||||
|
||||
scan_row = QHBoxLayout()
|
||||
self.scan_button = QPushButton("Scan Network")
|
||||
self.scan_button.clicked.connect(self._scan_network)
|
||||
scan_row.addWidget(self.scan_button)
|
||||
self.scan_status_label = QLabel("Not scanned yet.")
|
||||
self.scan_status_label.setWordWrap(True)
|
||||
scan_row.addWidget(self.scan_status_label, 1)
|
||||
device_layout.addLayout(scan_row)
|
||||
|
||||
self.mapping_summary_label = QLabel("No devices discovered.")
|
||||
self.mapping_summary_label.setWordWrap(True)
|
||||
self.mapping_summary_label.setStyleSheet("color: #C7D2E1;")
|
||||
device_layout.addWidget(self.mapping_summary_label)
|
||||
|
||||
self.discovered_devices_table = QTableWidget(0, len(self.DEVICE_COLUMNS))
|
||||
self.discovered_devices_table.setHorizontalHeaderLabels(self.DEVICE_COLUMNS)
|
||||
self.discovered_devices_table.verticalHeader().setVisible(False)
|
||||
self.discovered_devices_table.setAlternatingRowColors(True)
|
||||
self.discovered_devices_table.setSelectionBehavior(QTableWidget.SelectRows)
|
||||
self.discovered_devices_table.setSortingEnabled(False)
|
||||
self.discovered_devices_table.itemSelectionChanged.connect(self._on_discovered_device_selection_changed)
|
||||
header = self.discovered_devices_table.horizontalHeader()
|
||||
header.setSectionResizeMode(0, QHeaderView.ResizeToContents)
|
||||
header.setSectionResizeMode(1, QHeaderView.ResizeToContents)
|
||||
header.setSectionResizeMode(2, QHeaderView.ResizeToContents)
|
||||
header.setSectionResizeMode(3, QHeaderView.ResizeToContents)
|
||||
header.setSectionResizeMode(4, QHeaderView.Stretch)
|
||||
header.setSectionResizeMode(5, QHeaderView.ResizeToContents)
|
||||
header.setSectionResizeMode(6, QHeaderView.ResizeToContents)
|
||||
device_layout.addWidget(self.discovered_devices_table, 1)
|
||||
|
||||
self.assignment_feedback_label = QLabel("Select a device or press Identify, then click the matching tile.")
|
||||
self.assignment_feedback_label.setWordWrap(True)
|
||||
self.assignment_feedback_label.setStyleSheet("color: #A8B3C7;")
|
||||
device_layout.addWidget(self.assignment_feedback_label)
|
||||
|
||||
content_row.addWidget(device_group, 1)
|
||||
|
||||
def _build_segments_tab(self) -> None:
|
||||
layout = QVBoxLayout(self.segments_tab)
|
||||
|
||||
top_row = QHBoxLayout()
|
||||
top_row.addWidget(QLabel("Tile"))
|
||||
self.segment_tile_combo = QComboBox()
|
||||
self.segment_tile_combo.currentIndexChanged.connect(self._on_segment_tile_changed)
|
||||
top_row.addWidget(self.segment_tile_combo, 1)
|
||||
self.add_segment_button = QPushButton("Add Segment")
|
||||
self.remove_segment_button = QPushButton("Remove Selected")
|
||||
top_row.addWidget(self.add_segment_button)
|
||||
top_row.addWidget(self.remove_segment_button)
|
||||
layout.addLayout(top_row)
|
||||
|
||||
self.segments_table = QTableWidget(0, len(self.SEGMENT_COLUMNS))
|
||||
self.segments_table.setHorizontalHeaderLabels([label for _, label in self.SEGMENT_COLUMNS])
|
||||
self.segments_table.verticalHeader().setVisible(False)
|
||||
self.segments_table.setAlternatingRowColors(True)
|
||||
self.segments_table.setSelectionBehavior(QTableWidget.SelectRows)
|
||||
layout.addWidget(self.segments_table, 1)
|
||||
|
||||
self.add_segment_button.clicked.connect(self._add_segment_row)
|
||||
self.remove_segment_button.clicked.connect(self._remove_selected_segments)
|
||||
|
||||
def _build_raw_tab(self) -> None:
|
||||
layout = QVBoxLayout(self.raw_tab)
|
||||
|
||||
button_row = QHBoxLayout()
|
||||
self.refresh_raw_button = QPushButton("Refresh From Tables")
|
||||
self.validate_raw_button = QPushButton("Validate XML")
|
||||
self.apply_raw_button = QPushButton("Apply XML To Tables")
|
||||
button_row.addWidget(self.refresh_raw_button)
|
||||
button_row.addWidget(self.validate_raw_button)
|
||||
button_row.addWidget(self.apply_raw_button)
|
||||
button_row.addStretch(1)
|
||||
layout.addLayout(button_row)
|
||||
|
||||
self.raw_editor = QPlainTextEdit()
|
||||
self.raw_editor.setLineWrapMode(QPlainTextEdit.NoWrap)
|
||||
self.raw_editor.document().setModified(False)
|
||||
layout.addWidget(self.raw_editor, 1)
|
||||
|
||||
layout.addWidget(QLabel("Validation"))
|
||||
self.error_list = QListWidget()
|
||||
layout.addWidget(self.error_list)
|
||||
|
||||
self.refresh_raw_button.clicked.connect(self._refresh_raw_xml)
|
||||
self.validate_raw_button.clicked.connect(self._validate_raw_xml)
|
||||
self.apply_raw_button.clicked.connect(self._apply_raw_xml)
|
||||
|
||||
def _populate_tiles_table(self) -> None:
|
||||
self.tiles_table.setRowCount(len(self.working_config.tiles))
|
||||
for row, tile in enumerate(self.working_config.sorted_tiles()):
|
||||
values = {
|
||||
"tile_id": tile.tile_id,
|
||||
"row": str(tile.row),
|
||||
"col": str(tile.col),
|
||||
"screen_name": tile.screen_name,
|
||||
"controller_ip": tile.controller_ip,
|
||||
"controller_name": tile.controller_name,
|
||||
"controller_host": tile.controller_host,
|
||||
"controller_mac": tile.controller_mac,
|
||||
"subnet": str(tile.subnet),
|
||||
"universe": str(tile.universe),
|
||||
"led_total": str(tile.led_total),
|
||||
"brightness_factor": f"{tile.brightness_factor:.3f}",
|
||||
"enabled": tile.enabled,
|
||||
}
|
||||
for column, (key, _) in enumerate(self.TILE_COLUMNS):
|
||||
if key == "enabled":
|
||||
item = QTableWidgetItem()
|
||||
item.setFlags(item.flags() | Qt.ItemIsUserCheckable | Qt.ItemIsEnabled | Qt.ItemIsSelectable)
|
||||
item.setCheckState(Qt.Checked if tile.enabled else Qt.Unchecked)
|
||||
else:
|
||||
item = QTableWidgetItem(str(values[key]))
|
||||
self.tiles_table.setItem(row, column, item)
|
||||
self.tiles_table.resizeColumnsToContents()
|
||||
|
||||
def _rebuild_segment_tile_combo(self) -> None:
|
||||
self.segment_tile_combo.blockSignals(True)
|
||||
self.segment_tile_combo.clear()
|
||||
for tile in self.working_config.sorted_tiles():
|
||||
self.segment_tile_combo.addItem(tile.tile_id, tile.tile_id)
|
||||
current_tile_id = self._active_segment_tile_id or (self.working_config.sorted_tiles()[0].tile_id if self.working_config.tiles else None)
|
||||
if current_tile_id:
|
||||
index = self.segment_tile_combo.findData(current_tile_id)
|
||||
self.segment_tile_combo.setCurrentIndex(max(0, index))
|
||||
self._active_segment_tile_id = self.segment_tile_combo.currentData()
|
||||
self.segment_tile_combo.blockSignals(False)
|
||||
self._populate_segments_table()
|
||||
|
||||
def _populate_segments_table(self) -> None:
|
||||
tile = self._active_tile_for_segments()
|
||||
self.segments_table.setRowCount(0)
|
||||
if tile is None:
|
||||
return
|
||||
self.segments_table.setRowCount(len(tile.segments))
|
||||
for row, segment in enumerate(tile.segments):
|
||||
values = {
|
||||
"name": segment.name,
|
||||
"side": segment.side,
|
||||
"start_channel": str(segment.start_channel),
|
||||
"led_count": str(segment.led_count),
|
||||
"orientation_rad": f"{segment.orientation_rad:.5f}",
|
||||
"x0": f"{segment.x0:.3f}",
|
||||
"y0": f"{segment.y0:.3f}",
|
||||
"x1": f"{segment.x1:.3f}",
|
||||
"y1": f"{segment.y1:.3f}",
|
||||
"reverse": segment.reverse,
|
||||
}
|
||||
for column, (key, _) in enumerate(self.SEGMENT_COLUMNS):
|
||||
if key == "reverse":
|
||||
item = QTableWidgetItem()
|
||||
item.setFlags(item.flags() | Qt.ItemIsUserCheckable | Qt.ItemIsEnabled | Qt.ItemIsSelectable)
|
||||
item.setCheckState(Qt.Checked if segment.reverse else Qt.Unchecked)
|
||||
else:
|
||||
item = QTableWidgetItem(str(values[key]))
|
||||
self.segments_table.setItem(row, column, item)
|
||||
self.segments_table.resizeColumnsToContents()
|
||||
|
||||
def _active_tile_for_segments(self):
|
||||
return self.working_config.tile_lookup().get(self.segment_tile_combo.currentData())
|
||||
|
||||
def _on_segment_tile_changed(self) -> None:
|
||||
previous_tile_id = self._active_segment_tile_id
|
||||
self._sync_segments_table(previous_tile_id)
|
||||
self._active_segment_tile_id = self.segment_tile_combo.currentData()
|
||||
self._populate_segments_table()
|
||||
|
||||
def _add_segment_row(self) -> None:
|
||||
row = self.segments_table.rowCount()
|
||||
self.segments_table.insertRow(row)
|
||||
defaults = ["New Segment", "left", "1", "1", "0.0", "0.0", "0.0", "0.0", "0.0", False]
|
||||
for column, (_, _) in enumerate(self.SEGMENT_COLUMNS):
|
||||
value = defaults[column]
|
||||
if column == len(self.SEGMENT_COLUMNS) - 1:
|
||||
item = QTableWidgetItem()
|
||||
item.setFlags(item.flags() | Qt.ItemIsUserCheckable | Qt.ItemIsEnabled | Qt.ItemIsSelectable)
|
||||
item.setCheckState(Qt.Unchecked)
|
||||
else:
|
||||
item = QTableWidgetItem(str(value))
|
||||
self.segments_table.setItem(row, column, item)
|
||||
|
||||
def _remove_selected_segments(self) -> None:
|
||||
rows = sorted({item.row() for item in self.segments_table.selectedItems()}, reverse=True)
|
||||
for row in rows:
|
||||
self.segments_table.removeRow(row)
|
||||
|
||||
def _refresh_raw_xml(self) -> None:
|
||||
self._sync_tables_to_config()
|
||||
self._set_raw_xml_snapshot()
|
||||
self.error_list.clear()
|
||||
|
||||
def _set_raw_xml_snapshot(self) -> None:
|
||||
self.raw_editor.setPlainText(config_to_xml_string(self.working_config))
|
||||
self.raw_editor.document().setModified(False)
|
||||
|
||||
def _validate_raw_xml(self) -> None:
|
||||
self.error_list.clear()
|
||||
try:
|
||||
config = load_config_from_string(self.raw_editor.toPlainText(), validate=True)
|
||||
self.error_list.addItem(f"XML valid. {len(config.tiles)} tiles loaded.")
|
||||
except MappingValidationError as exc:
|
||||
for error in exc.errors:
|
||||
self.error_list.addItem(error)
|
||||
|
||||
def _apply_raw_xml(self) -> None:
|
||||
self.error_list.clear()
|
||||
try:
|
||||
self.working_config = load_config_from_string(self.raw_editor.toPlainText(), validate=True)
|
||||
self.name_edit.setText(self.working_config.name)
|
||||
self.rows_spin.setValue(self.working_config.logical_display.rows)
|
||||
self.cols_spin.setValue(self.working_config.logical_display.cols)
|
||||
self._populate_tiles_table()
|
||||
self._rebuild_segment_tile_combo()
|
||||
self._refresh_network_mapping_ui()
|
||||
self.raw_editor.document().setModified(False)
|
||||
self.error_list.addItem("Applied XML to editable tables.")
|
||||
except MappingValidationError as exc:
|
||||
for error in exc.errors:
|
||||
self.error_list.addItem(error)
|
||||
|
||||
def _handle_tab_changed(self, index: int) -> None:
|
||||
try:
|
||||
if self.tabs.widget(index) is self.network_tab:
|
||||
self._sync_general_fields()
|
||||
self._sync_tiles_table()
|
||||
self._sync_segments_table()
|
||||
self._refresh_network_mapping_ui()
|
||||
elif self.tabs.widget(index) is self.segments_tab:
|
||||
self._sync_tiles_table()
|
||||
self._rebuild_segment_tile_combo()
|
||||
elif self.tabs.widget(index) is self.raw_tab and not self.raw_editor.document().isModified():
|
||||
self._refresh_raw_xml()
|
||||
except (MappingValidationError, ValueError, KeyError, StopIteration) as exc:
|
||||
self.tabs.blockSignals(True)
|
||||
self.tabs.setCurrentWidget(self.tiles_tab)
|
||||
self.tabs.blockSignals(False)
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"Mapping Error",
|
||||
f"Please resolve the current tile or segment values before switching tabs.\n\n{exc}",
|
||||
)
|
||||
|
||||
def _sync_tables_to_config(self) -> None:
|
||||
self._sync_general_fields()
|
||||
self._sync_tiles_table()
|
||||
self._sync_segments_table()
|
||||
|
||||
def _sync_general_fields(self) -> None:
|
||||
self.working_config.name = self.name_edit.text().strip() or self.working_config.name
|
||||
self.working_config.logical_display.rows = self.rows_spin.value()
|
||||
self.working_config.logical_display.cols = self.cols_spin.value()
|
||||
|
||||
def _sync_tiles_table(self) -> None:
|
||||
tiles = self.working_config.sorted_tiles()
|
||||
for row, tile in enumerate(tiles):
|
||||
tile.tile_id = self._text(row, "tile_id")
|
||||
tile.row = int(self._text(row, "row"))
|
||||
tile.col = int(self._text(row, "col"))
|
||||
tile.screen_name = self._text(row, "screen_name")
|
||||
tile.controller_ip = self._text(row, "controller_ip")
|
||||
tile.controller_name = self._text(row, "controller_name")
|
||||
tile.controller_host = self._text(row, "controller_host")
|
||||
tile.controller_mac = self._text(row, "controller_mac")
|
||||
tile.subnet = int(self._text(row, "subnet"))
|
||||
tile.universe = int(self._text(row, "universe"))
|
||||
tile.led_total = int(self._text(row, "led_total"))
|
||||
tile.brightness_factor = float(self._text(row, "brightness_factor"))
|
||||
tile.enabled = self._check(row, "enabled")
|
||||
self.working_config.tiles = tiles
|
||||
|
||||
def _sync_segments_table(self, tile_id: str | None = None) -> None:
|
||||
lookup_id = tile_id if tile_id is not None else self._active_segment_tile_id or self.segment_tile_combo.currentData()
|
||||
tile = self.working_config.tile_lookup().get(lookup_id)
|
||||
if tile is None:
|
||||
return
|
||||
segments: list[SegmentConfig] = []
|
||||
for row in range(self.segments_table.rowCount()):
|
||||
segments.append(
|
||||
SegmentConfig(
|
||||
name=self._segment_text(row, "name"),
|
||||
side=self._segment_text(row, "side"),
|
||||
start_channel=int(self._segment_text(row, "start_channel")),
|
||||
led_count=int(self._segment_text(row, "led_count")),
|
||||
orientation_rad=float(self._segment_text(row, "orientation_rad")),
|
||||
x0=float(self._segment_text(row, "x0")),
|
||||
y0=float(self._segment_text(row, "y0")),
|
||||
x1=float(self._segment_text(row, "x1")),
|
||||
y1=float(self._segment_text(row, "y1")),
|
||||
reverse=self._segment_check(row, "reverse"),
|
||||
)
|
||||
)
|
||||
tile.segments = segments
|
||||
|
||||
def _scan_network(self) -> None:
|
||||
self._sync_general_fields()
|
||||
self._sync_tiles_table()
|
||||
self._sync_segments_table()
|
||||
|
||||
self.discovered_devices = []
|
||||
self._active_device_key = None
|
||||
self._selected_assignment_tile_id = None
|
||||
self._scan_worker = NetworkScanWorker(self.working_config)
|
||||
self._scan_worker.progress.connect(self._on_scan_progress)
|
||||
self._scan_worker.finished.connect(self._on_scan_finished)
|
||||
|
||||
self.scan_button.setEnabled(False)
|
||||
self.scan_status_label.setText("Scanning local subnets for WLED devices...")
|
||||
self.assignment_feedback_label.setText("Scanning the network. Results will appear below as devices respond.")
|
||||
self._refresh_network_mapping_ui()
|
||||
self._scan_worker.start()
|
||||
|
||||
def _on_scan_progress(self, completed: int, total: int, device: DiscoveredWledDevice | None) -> None:
|
||||
self.scan_status_label.setText(f"Scanning {completed}/{total} hosts...")
|
||||
if device is None:
|
||||
return
|
||||
if any(self._device_key(existing) == self._device_key(device) for existing in self.discovered_devices):
|
||||
return
|
||||
self.discovered_devices.append(device)
|
||||
self.discovered_devices.sort(key=lambda item: tuple(int(part) for part in item.ip_address.split(".")))
|
||||
if self._active_device() is None:
|
||||
self._active_device_key = self._device_key(self.discovered_devices[0])
|
||||
self._refresh_network_mapping_ui()
|
||||
|
||||
def _on_scan_finished(self, devices: list[DiscoveredWledDevice], error_message: str, host_count: int) -> None:
|
||||
self.scan_button.setEnabled(True)
|
||||
self._scan_worker = None
|
||||
self._last_scan_host_count = host_count
|
||||
|
||||
if error_message:
|
||||
self.scan_status_label.setText("Network scan failed.")
|
||||
self.assignment_feedback_label.setText(error_message)
|
||||
QMessageBox.warning(self, "Network Scan Error", error_message)
|
||||
return
|
||||
|
||||
self.discovered_devices = list(devices)
|
||||
if self._active_device() is None and self.discovered_devices:
|
||||
self._active_device_key = self._device_key(self.discovered_devices[0])
|
||||
if not self.discovered_devices:
|
||||
self.scan_status_label.setText(f"No WLED devices found after scanning {host_count} hosts.")
|
||||
self.assignment_feedback_label.setText("No WLED devices were detected. Check the subnet, power, and network connectivity.")
|
||||
else:
|
||||
self.scan_status_label.setText(f"Found {len(self.discovered_devices)} WLED device(s) across {host_count} hosts.")
|
||||
self._refresh_network_mapping_ui()
|
||||
|
||||
def _on_discovered_device_selection_changed(self) -> None:
|
||||
if self._device_table_refreshing:
|
||||
return
|
||||
row = self.discovered_devices_table.currentRow()
|
||||
if row < 0:
|
||||
return
|
||||
item = self.discovered_devices_table.item(row, 0)
|
||||
if item is None:
|
||||
return
|
||||
device_key = item.data(Qt.UserRole)
|
||||
if isinstance(device_key, str):
|
||||
self._select_device_for_assignment(device_key, select_table=False)
|
||||
|
||||
def _select_device_for_assignment(self, device_key: str, *, select_table: bool = True) -> None:
|
||||
self._active_device_key = device_key
|
||||
active_device = self._active_device()
|
||||
mapped_tile = find_tile_for_device(self.working_config, active_device) if active_device is not None else None
|
||||
self._selected_assignment_tile_id = mapped_tile.tile_id if mapped_tile is not None else self._selected_assignment_tile_id
|
||||
if select_table:
|
||||
self._select_device_row(device_key)
|
||||
self._refresh_network_mapping_ui()
|
||||
|
||||
def _select_device_row(self, device_key: str) -> None:
|
||||
for row in range(self.discovered_devices_table.rowCount()):
|
||||
item = self.discovered_devices_table.item(row, 0)
|
||||
if item is None:
|
||||
continue
|
||||
if item.data(Qt.UserRole) == device_key:
|
||||
self.discovered_devices_table.selectRow(row)
|
||||
return
|
||||
|
||||
def _identify_device(self, device_key: str) -> None:
|
||||
device = self._device_for_key(device_key)
|
||||
if device is None:
|
||||
return
|
||||
|
||||
self._select_device_for_assignment(device_key)
|
||||
worker = DeviceIdentifyWorker(device)
|
||||
self._identify_workers.add(worker)
|
||||
worker.finished.connect(self._on_identify_finished)
|
||||
self.assignment_feedback_label.setText(
|
||||
f"Identifying {device.ip_address}. Watch for the red pulse, then click the matching tile."
|
||||
)
|
||||
worker.start()
|
||||
|
||||
def _on_identify_finished(self, device: DiscoveredWledDevice, error_message: str) -> None:
|
||||
for worker in list(self._identify_workers):
|
||||
if worker._device == device:
|
||||
self._identify_workers.discard(worker)
|
||||
break
|
||||
if error_message:
|
||||
self.assignment_feedback_label.setText(error_message)
|
||||
QMessageBox.warning(self, "Identify Failed", error_message)
|
||||
else:
|
||||
mapped_tile = find_tile_for_device(self.working_config, device)
|
||||
if mapped_tile is not None:
|
||||
self._selected_assignment_tile_id = mapped_tile.tile_id
|
||||
self.assignment_feedback_label.setText(
|
||||
f"{device.ip_address} pulsed successfully. Click the matching tile to store the assignment."
|
||||
)
|
||||
self._refresh_network_mapping_ui()
|
||||
|
||||
def _handle_mapping_preview_click(self, tile_id: str) -> None:
|
||||
self._selected_assignment_tile_id = tile_id
|
||||
active_device = self._active_device()
|
||||
if active_device is None:
|
||||
self.assignment_feedback_label.setText(f"{tile_id} selected. Choose a device row or press Identify first.")
|
||||
self._refresh_network_mapping_ui()
|
||||
return
|
||||
self._assign_active_device_to_tile(active_device, tile_id)
|
||||
|
||||
def _assign_active_device_to_tile(self, device: DiscoveredWledDevice, tile_id: str) -> None:
|
||||
tile_lookup = self.working_config.tile_lookup()
|
||||
target_tile = tile_lookup.get(tile_id)
|
||||
if target_tile is None:
|
||||
self.assignment_feedback_label.setText(f"Tile {tile_id} no longer exists in the current mapping.")
|
||||
return
|
||||
|
||||
previous_tile = find_tile_for_device(self.working_config, device)
|
||||
displaced_tile_text = ""
|
||||
if target_tile.controller_ip or target_tile.controller_mac:
|
||||
if not tile_matches_device(target_tile, device):
|
||||
displaced_tile_text = target_tile.controller_ip or target_tile.controller_name or target_tile.controller_mac
|
||||
|
||||
assign_device_to_tile(self.working_config, device, tile_id)
|
||||
self._selected_assignment_tile_id = tile_id
|
||||
self._populate_tiles_table()
|
||||
if not self.raw_editor.document().isModified():
|
||||
self._set_raw_xml_snapshot()
|
||||
|
||||
persisted, persistence_message = self._persist_working_mapping()
|
||||
|
||||
summary_parts = [f"{device.ip_address} -> {tile_id}"]
|
||||
if previous_tile is not None and previous_tile.tile_id != tile_id:
|
||||
summary_parts.append(f"moved from {previous_tile.tile_id}")
|
||||
if displaced_tile_text:
|
||||
summary_parts.append(f"replaced {displaced_tile_text}")
|
||||
summary = ", ".join(summary_parts)
|
||||
|
||||
if persisted:
|
||||
self.assignment_feedback_label.setText(f"Saved {summary}. {persistence_message}")
|
||||
else:
|
||||
self.assignment_feedback_label.setText(f"Stored {summary} in the dialog. {persistence_message}")
|
||||
QMessageBox.warning(self, "Assignment Not Saved Yet", persistence_message)
|
||||
|
||||
self._refresh_network_mapping_ui()
|
||||
self._select_next_unmapped_device()
|
||||
|
||||
def _persist_working_mapping(self) -> tuple[bool, str]:
|
||||
if self.raw_editor.document().isModified():
|
||||
return (
|
||||
False,
|
||||
"Raw XML has unapplied changes. Apply or discard those edits before assisted assignments can be written to disk.",
|
||||
)
|
||||
|
||||
target_path = None
|
||||
if self.controller is not None and self.controller.mapping_path is not None:
|
||||
target_path = self.controller.mapping_path
|
||||
elif self.working_config.file_path is not None:
|
||||
target_path = self.working_config.file_path
|
||||
|
||||
if target_path is None:
|
||||
return False, "No mapping file is currently open. Use Save or Save As after closing Mapping Settings."
|
||||
|
||||
validation = validate_config(self.working_config)
|
||||
if not validation.is_valid:
|
||||
return False, "The mapping is currently invalid and could not be saved: " + "; ".join(validation.errors)
|
||||
|
||||
try:
|
||||
save_config(self.working_config, target_path)
|
||||
except (MappingValidationError, OSError, ValueError) as exc:
|
||||
if isinstance(exc, MappingValidationError):
|
||||
return False, "The mapping could not be saved: " + "; ".join(exc.errors)
|
||||
return False, f"The mapping file could not be written: {exc}"
|
||||
|
||||
if self.controller is not None:
|
||||
self.controller.replace_config(self.working_config.clone(), path=target_path)
|
||||
return True, f"Wrote {target_path.name}."
|
||||
|
||||
def _select_next_unmapped_device(self) -> None:
|
||||
for device in self.discovered_devices:
|
||||
if find_tile_for_device(self.working_config, device) is None:
|
||||
self._select_device_for_assignment(self._device_key(device))
|
||||
return
|
||||
self._refresh_network_mapping_ui()
|
||||
|
||||
def _refresh_network_mapping_ui(self) -> None:
|
||||
active_device = self._active_device()
|
||||
mapped_count = sum(1 for device in self.discovered_devices if find_tile_for_device(self.working_config, device) is not None)
|
||||
stale_tile_count = sum(
|
||||
1
|
||||
for tile in self.working_config.sorted_tiles()
|
||||
if (tile.controller_ip.strip() or tile.controller_mac.strip())
|
||||
and not any(tile_matches_device(tile, device) for device in self.discovered_devices)
|
||||
)
|
||||
unmapped_count = max(0, len(self.discovered_devices) - mapped_count)
|
||||
|
||||
if not self.discovered_devices:
|
||||
summary = "No devices discovered yet."
|
||||
else:
|
||||
summary = f"{len(self.discovered_devices)} discovered | {mapped_count} mapped | {unmapped_count} unmapped"
|
||||
if stale_tile_count:
|
||||
summary += f" | {stale_tile_count} saved assignment(s) not seen in this scan"
|
||||
self.mapping_summary_label.setText(summary)
|
||||
|
||||
if active_device is None:
|
||||
self.assignment_workflow_label.setText("Select a device row or press Identify, then click the matching tile.")
|
||||
else:
|
||||
mapped_tile = find_tile_for_device(self.working_config, active_device)
|
||||
if mapped_tile is None:
|
||||
self.assignment_workflow_label.setText(
|
||||
f"Active device: {active_device.ip_address}. Click the physical tile that just flashed."
|
||||
)
|
||||
else:
|
||||
self.assignment_workflow_label.setText(
|
||||
f"Active device: {active_device.ip_address} is currently mapped to {mapped_tile.tile_id}. "
|
||||
"Click a different tile to reassign it."
|
||||
)
|
||||
|
||||
self.mapping_preview.set_assignment_state(
|
||||
self.working_config,
|
||||
self.discovered_devices,
|
||||
active_device=active_device,
|
||||
selected_tile_id=self._selected_assignment_tile_id,
|
||||
)
|
||||
self._refresh_discovered_devices_table()
|
||||
|
||||
def _refresh_discovered_devices_table(self) -> None:
|
||||
self._device_table_refreshing = True
|
||||
try:
|
||||
self.discovered_devices_table.setRowCount(len(self.discovered_devices))
|
||||
for row, device in enumerate(self.discovered_devices):
|
||||
mapped_tile = find_tile_for_device(self.working_config, device)
|
||||
status = "Unmapped"
|
||||
if mapped_tile is not None:
|
||||
if mapped_tile.controller_ip.strip() and mapped_tile.controller_ip.strip() != device.ip_address:
|
||||
status = f"Mapped to {mapped_tile.tile_id} (saved IP {mapped_tile.controller_ip})"
|
||||
else:
|
||||
status = f"Mapped to {mapped_tile.tile_id}"
|
||||
|
||||
values = [
|
||||
device.ip_address,
|
||||
device.hostname,
|
||||
device.instance_name,
|
||||
device.mac_address,
|
||||
status,
|
||||
]
|
||||
device_key = self._device_key(device)
|
||||
for column, value in enumerate(values):
|
||||
item = QTableWidgetItem(value)
|
||||
item.setData(Qt.UserRole, device_key)
|
||||
if device_key == self._active_device_key:
|
||||
item.setBackground(Qt.darkBlue)
|
||||
item.setForeground(Qt.white)
|
||||
elif mapped_tile is not None:
|
||||
item.setBackground(Qt.darkGreen)
|
||||
self.discovered_devices_table.setItem(row, column, item)
|
||||
|
||||
map_button = QPushButton("Map This")
|
||||
map_button.clicked.connect(lambda _checked=False, key=device_key: self._select_device_for_assignment(key))
|
||||
self.discovered_devices_table.setCellWidget(row, 5, map_button)
|
||||
|
||||
identify_button = QPushButton("Identify")
|
||||
identify_button.clicked.connect(lambda _checked=False, key=device_key: self._identify_device(key))
|
||||
self.discovered_devices_table.setCellWidget(row, 6, identify_button)
|
||||
|
||||
self.discovered_devices_table.resizeRowsToContents()
|
||||
if self._active_device_key is not None:
|
||||
self._select_device_row(self._active_device_key)
|
||||
finally:
|
||||
self._device_table_refreshing = False
|
||||
|
||||
def _device_for_key(self, device_key: str | None) -> DiscoveredWledDevice | None:
|
||||
if not device_key:
|
||||
return None
|
||||
for device in self.discovered_devices:
|
||||
if self._device_key(device) == device_key:
|
||||
return device
|
||||
return None
|
||||
|
||||
def _active_device(self) -> DiscoveredWledDevice | None:
|
||||
return self._device_for_key(self._active_device_key)
|
||||
|
||||
def _device_key(self, device: DiscoveredWledDevice) -> str:
|
||||
return device.mac_address or device.ip_address
|
||||
|
||||
def _text(self, row: int, key: str) -> str:
|
||||
column = next(index for index, (field, _) in enumerate(self.TILE_COLUMNS) if field == key)
|
||||
item = self.tiles_table.item(row, column)
|
||||
return item.text().strip() if item else ""
|
||||
|
||||
def _segment_text(self, row: int, key: str) -> str:
|
||||
column = next(index for index, (field, _) in enumerate(self.SEGMENT_COLUMNS) if field == key)
|
||||
item = self.segments_table.item(row, column)
|
||||
return item.text().strip() if item else ""
|
||||
|
||||
def _check(self, row: int, key: str) -> bool:
|
||||
column = next(index for index, (field, _) in enumerate(self.TILE_COLUMNS) if field == key)
|
||||
item = self.tiles_table.item(row, column)
|
||||
return bool(item and item.checkState() == Qt.Checked)
|
||||
|
||||
def _segment_check(self, row: int, key: str) -> bool:
|
||||
column = next(index for index, (field, _) in enumerate(self.SEGMENT_COLUMNS) if field == key)
|
||||
item = self.segments_table.item(row, column)
|
||||
return bool(item and item.checkState() == Qt.Checked)
|
||||
|
||||
def _save_and_accept(self) -> None:
|
||||
try:
|
||||
if self.raw_editor.document().isModified():
|
||||
self.working_config = load_config_from_string(self.raw_editor.toPlainText(), validate=True)
|
||||
else:
|
||||
self._sync_tables_to_config()
|
||||
result = validate_config(self.working_config)
|
||||
if not result.is_valid:
|
||||
raise MappingValidationError(result.errors)
|
||||
except (MappingValidationError, ValueError) as exc:
|
||||
errors = exc.errors if isinstance(exc, MappingValidationError) else [str(exc)]
|
||||
self.error_list.clear()
|
||||
for error in errors:
|
||||
self.error_list.addItem(error)
|
||||
self.tabs.setCurrentWidget(self.raw_tab)
|
||||
QMessageBox.warning(self, "Validation Error", "Please resolve the validation errors before saving.")
|
||||
return
|
||||
|
||||
self.result_config = self.working_config
|
||||
self.accept()
|
||||
+189
@@ -0,0 +1,189 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.qt_compat import QApplication, QColor, QFont, QPalette
|
||||
|
||||
|
||||
def apply_dark_theme(app: QApplication) -> None:
|
||||
app.setStyle("Fusion")
|
||||
font = QFont(app.font())
|
||||
if font.pointSizeF() < 11.5:
|
||||
font.setPointSizeF(11.5)
|
||||
if hasattr(font, "setFamilies"):
|
||||
font.setFamilies(["Segoe UI Variable Text", "Segoe UI", "Bahnschrift"])
|
||||
else:
|
||||
font.setFamily("Segoe UI")
|
||||
app.setFont(font)
|
||||
|
||||
palette = QPalette()
|
||||
palette.setColor(QPalette.Window, QColor("#1E1E1E"))
|
||||
palette.setColor(QPalette.WindowText, QColor("#CCCCCC"))
|
||||
palette.setColor(QPalette.Base, QColor("#252526"))
|
||||
palette.setColor(QPalette.AlternateBase, QColor("#2D2D30"))
|
||||
palette.setColor(QPalette.ToolTipBase, QColor("#252526"))
|
||||
palette.setColor(QPalette.ToolTipText, QColor("#CCCCCC"))
|
||||
palette.setColor(QPalette.Text, QColor("#CCCCCC"))
|
||||
palette.setColor(QPalette.Button, QColor("#2D2D30"))
|
||||
palette.setColor(QPalette.ButtonText, QColor("#CCCCCC"))
|
||||
palette.setColor(QPalette.Highlight, QColor("#007ACC"))
|
||||
palette.setColor(QPalette.HighlightedText, QColor("#FFFFFF"))
|
||||
palette.setColor(QPalette.BrightText, QColor("#FFFFFF"))
|
||||
palette.setColor(QPalette.PlaceholderText, QColor("#7A7A7A"))
|
||||
app.setPalette(palette)
|
||||
|
||||
app.setStyleSheet(
|
||||
"""
|
||||
QWidget {
|
||||
background: #1E1E1E;
|
||||
color: #CCCCCC;
|
||||
}
|
||||
QMainWindow, QDialog {
|
||||
background: #1E1E1E;
|
||||
}
|
||||
QToolBar {
|
||||
background: #2D2D30;
|
||||
border: none;
|
||||
border-bottom: 1px solid #3C3C3C;
|
||||
spacing: 4px;
|
||||
padding: 4px 6px;
|
||||
}
|
||||
QToolButton {
|
||||
background: transparent;
|
||||
color: #CCCCCC;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 3px;
|
||||
padding: 6px 10px;
|
||||
margin: 0 1px;
|
||||
}
|
||||
QToolButton:hover {
|
||||
background: #37373D;
|
||||
border-color: #37373D;
|
||||
}
|
||||
QToolButton:pressed, QToolButton:checked {
|
||||
background: #094771;
|
||||
border-color: #007ACC;
|
||||
color: #FFFFFF;
|
||||
}
|
||||
QStatusBar {
|
||||
background: #007ACC;
|
||||
color: #FFFFFF;
|
||||
}
|
||||
QStatusBar QLabel {
|
||||
background: transparent;
|
||||
color: #FFFFFF;
|
||||
padding: 0 4px;
|
||||
}
|
||||
QWidget#sectionHeader {
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
QLabel#sectionHeaderLabel {
|
||||
background: transparent;
|
||||
color: #CCCCCC;
|
||||
font-size: 26px;
|
||||
font-weight: 600;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
QWidget#sectionBody {
|
||||
background: #252526;
|
||||
border: 1px solid #3C3C3C;
|
||||
border-radius: 0;
|
||||
}
|
||||
QGroupBox {
|
||||
background: #252526;
|
||||
border: 1px solid #3C3C3C;
|
||||
border-radius: 0;
|
||||
margin-top: 30px;
|
||||
font-weight: 600;
|
||||
padding: 10px 12px 12px 12px;
|
||||
}
|
||||
QGroupBox::title {
|
||||
subcontrol-origin: margin;
|
||||
subcontrol-position: top left;
|
||||
top: 7px;
|
||||
left: 12px;
|
||||
background: transparent;
|
||||
padding: 0 0 0 0;
|
||||
color: #CCCCCC;
|
||||
font-size: 12px;
|
||||
}
|
||||
QLabel {
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
}
|
||||
QPushButton, QComboBox, QLineEdit, QSpinBox, QDoubleSpinBox, QPlainTextEdit, QListWidget, QTableWidget {
|
||||
background: #1F1F1F;
|
||||
border: 1px solid #3C3C3C;
|
||||
border-radius: 3px;
|
||||
padding: 7px 10px;
|
||||
selection-background-color: #094771;
|
||||
selection-color: #FFFFFF;
|
||||
}
|
||||
QPushButton:hover, QComboBox:hover, QLineEdit:hover, QSpinBox:hover, QDoubleSpinBox:hover {
|
||||
border-color: #007ACC;
|
||||
}
|
||||
QPushButton {
|
||||
background: #2D2D30;
|
||||
min-height: 32px;
|
||||
}
|
||||
QPushButton:checked {
|
||||
background: #094771;
|
||||
color: #FFFFFF;
|
||||
border-color: #007ACC;
|
||||
}
|
||||
QComboBox::drop-down {
|
||||
border: none;
|
||||
width: 24px;
|
||||
}
|
||||
QListWidget::item {
|
||||
padding: 6px 8px;
|
||||
border-radius: 2px;
|
||||
margin: 1px 0;
|
||||
}
|
||||
QListWidget::item:selected {
|
||||
background: #094771;
|
||||
border: 1px solid #007ACC;
|
||||
}
|
||||
QHeaderView::section {
|
||||
background: #2D2D30;
|
||||
color: #CCCCCC;
|
||||
border: none;
|
||||
border-right: 1px solid #3C3C3C;
|
||||
padding: 6px 8px;
|
||||
}
|
||||
QTabWidget::pane {
|
||||
border: 1px solid #3C3C3C;
|
||||
border-radius: 0;
|
||||
top: -1px;
|
||||
}
|
||||
QTabBar::tab {
|
||||
background: #2D2D30;
|
||||
border: 1px solid #3C3C3C;
|
||||
border-bottom: none;
|
||||
padding: 8px 14px;
|
||||
margin-right: 2px;
|
||||
border-top-left-radius: 3px;
|
||||
border-top-right-radius: 3px;
|
||||
}
|
||||
QTabBar::tab:selected {
|
||||
background: #1E1E1E;
|
||||
color: #FFFFFF;
|
||||
}
|
||||
QScrollBar:vertical {
|
||||
background: #252526;
|
||||
width: 12px;
|
||||
}
|
||||
QScrollBar::handle:vertical {
|
||||
background: #424242;
|
||||
border-radius: 6px;
|
||||
min-height: 30px;
|
||||
}
|
||||
QScrollBar::handle:vertical:hover {
|
||||
background: #4F4F4F;
|
||||
}
|
||||
QSplitter::handle {
|
||||
background: #2D2D30;
|
||||
}
|
||||
"""
|
||||
)
|
||||
Reference in New Issue
Block a user