Files
RFP_Infinity-Vis/app/ui/pattern_panel.py

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