First upload, 18 controller version

This commit is contained in:
2026-04-14 15:23:56 +02:00
commit 8c55001a1c
3810 changed files with 764061 additions and 0 deletions

2
app/ui/__init__.py Normal file
View File

@@ -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.

558
app/ui/main_window.py Normal file
View File

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

View File

@@ -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}",
)

382
app/ui/pattern_panel.py Normal file
View File

@@ -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

75
app/ui/preset_browser.py Normal file
View File

@@ -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()

View File

@@ -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)

68
app/ui/preview_layout.py Normal file
View File

@@ -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)

24
app/ui/preview_modes.py Normal file
View File

@@ -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,
}

274
app/ui/preview_painter.py Normal file
View File

@@ -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)

118
app/ui/preview_widget.py Normal file
View File

@@ -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)
]

View File

@@ -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

36
app/ui/section_panel.py Normal file
View File

@@ -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)

833
app/ui/settings_dialog.py Normal file
View File

@@ -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
app/ui/theme.py Normal file
View File

@@ -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;
}
"""
)