First upload, 18 controller version
This commit is contained in:
2
app/ui/__init__.py
Normal file
2
app/ui/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""Qt UI modules for the Infinity Mirror control app."""
|
||||
|
||||
BIN
app/ui/__pycache__/__init__.cpython-310.pyc
Normal file
BIN
app/ui/__pycache__/__init__.cpython-310.pyc
Normal file
Binary file not shown.
BIN
app/ui/__pycache__/main_window.cpython-310.pyc
Normal file
BIN
app/ui/__pycache__/main_window.cpython-310.pyc
Normal file
Binary file not shown.
BIN
app/ui/__pycache__/mapping_assignment_preview.cpython-310.pyc
Normal file
BIN
app/ui/__pycache__/mapping_assignment_preview.cpython-310.pyc
Normal file
Binary file not shown.
BIN
app/ui/__pycache__/pattern_panel.cpython-310.pyc
Normal file
BIN
app/ui/__pycache__/pattern_panel.cpython-310.pyc
Normal file
Binary file not shown.
BIN
app/ui/__pycache__/preset_browser.cpython-310.pyc
Normal file
BIN
app/ui/__pycache__/preset_browser.cpython-310.pyc
Normal file
Binary file not shown.
BIN
app/ui/__pycache__/preview_fullscreen.cpython-310.pyc
Normal file
BIN
app/ui/__pycache__/preview_fullscreen.cpython-310.pyc
Normal file
Binary file not shown.
BIN
app/ui/__pycache__/preview_layout.cpython-310.pyc
Normal file
BIN
app/ui/__pycache__/preview_layout.cpython-310.pyc
Normal file
Binary file not shown.
BIN
app/ui/__pycache__/preview_modes.cpython-310.pyc
Normal file
BIN
app/ui/__pycache__/preview_modes.cpython-310.pyc
Normal file
Binary file not shown.
BIN
app/ui/__pycache__/preview_painter.cpython-310.pyc
Normal file
BIN
app/ui/__pycache__/preview_painter.cpython-310.pyc
Normal file
Binary file not shown.
BIN
app/ui/__pycache__/preview_widget.cpython-310.pyc
Normal file
BIN
app/ui/__pycache__/preview_widget.cpython-310.pyc
Normal file
Binary file not shown.
BIN
app/ui/__pycache__/scene_preview_area.cpython-310.pyc
Normal file
BIN
app/ui/__pycache__/scene_preview_area.cpython-310.pyc
Normal file
Binary file not shown.
BIN
app/ui/__pycache__/section_panel.cpython-310.pyc
Normal file
BIN
app/ui/__pycache__/section_panel.cpython-310.pyc
Normal file
Binary file not shown.
BIN
app/ui/__pycache__/settings_dialog.cpython-310.pyc
Normal file
BIN
app/ui/__pycache__/settings_dialog.cpython-310.pyc
Normal file
Binary file not shown.
BIN
app/ui/__pycache__/theme.cpython-310.pyc
Normal file
BIN
app/ui/__pycache__/theme.cpython-310.pyc
Normal file
Binary file not shown.
558
app/ui/main_window.py
Normal file
558
app/ui/main_window.py
Normal 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.")
|
||||
193
app/ui/mapping_assignment_preview.py
Normal file
193
app/ui/mapping_assignment_preview.py
Normal 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
382
app/ui/pattern_panel.py
Normal 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
75
app/ui/preset_browser.py
Normal 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()
|
||||
52
app/ui/preview_fullscreen.py
Normal file
52
app/ui/preview_fullscreen.py
Normal 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
68
app/ui/preview_layout.py
Normal 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
24
app/ui/preview_modes.py
Normal 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
274
app/ui/preview_painter.py
Normal 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
118
app/ui/preview_widget.py
Normal 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)
|
||||
]
|
||||
85
app/ui/scene_preview_area.py
Normal file
85
app/ui/scene_preview_area.py
Normal 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
36
app/ui/section_panel.py
Normal 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
833
app/ui/settings_dialog.py
Normal 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
189
app/ui/theme.py
Normal 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;
|
||||
}
|
||||
"""
|
||||
)
|
||||
Reference in New Issue
Block a user