383 lines
15 KiB
Python
383 lines
15 KiB
Python
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
|