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