Coverage for paintmaskanimator/preview_controls.py: 82%
157 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-11-02 04:50 +0000
« prev ^ index » next coverage.py v7.17.0, created at 2026-11-02 04:50 +0000
1"""Compact controls used by the canvas preview bar."""
2from __future__ import annotations
4from PySide6.QtCore import QPoint, QRectF, QSize, Qt, Signal
5from PySide6.QtGui import QAction, QColor, QIcon, QPainter, QPainterPath, QPen, QPixmap
6from PySide6.QtWidgets import (
7 QAbstractSpinBox,
8 QDoubleSpinBox,
9 QHBoxLayout,
10 QLabel,
11 QMenu,
12 QSizePolicy,
13 QStackedWidget,
14 QToolButton,
15 QWidget,
16)
18from . import view_preview
19from .i18n import tr
22def channel_icon(channel: str, size: int = 22) -> QIcon:
23 """Return a small additive-RGB Venn icon, highlighting ``channel``."""
24 scale = 2
25 pixmap = QPixmap(size * scale, size * scale)
26 pixmap.fill(Qt.GlobalColor.transparent)
27 painter = QPainter(pixmap)
28 painter.setRenderHint(QPainter.RenderHint.Antialiasing)
29 painter.scale(scale * size / 22, scale * size / 22)
30 circles = (
31 (QColor("#ff2020"), 14.4, 14.0, view_preview.CHANNEL_RED),
32 (QColor("#00e82c"), 11.0, 8.0, view_preview.CHANNEL_GREEN),
33 (QColor("#2450ff"), 7.6, 14.0, view_preview.CHANNEL_BLUE),
34 )
35 selected = channel if channel in view_preview.CHANNELS else view_preview.CHANNEL_RGB
36 paths = []
37 for _color, cx, cy, _value in circles:
38 path = QPainterPath()
39 path.addEllipse(QRectF(cx - 6, cy - 6, 12, 12))
40 paths.append(path)
41 if selected == view_preview.CHANNEL_RGB:
42 for path, (color, _cx, _cy, _value) in zip(paths, circles):
43 painter.fillPath(path, color)
44 red, green, blue = paths
45 for path, color in (
46 (red.intersected(green), "#ffff00"),
47 (green.intersected(blue), "#00ffff"),
48 (blue.intersected(red), "#ff00ff"),
49 (red.intersected(green).intersected(blue), "#ffffff"),
50 ):
51 painter.fillPath(path, QColor(color))
52 elif selected == view_preview.CHANNEL_ALPHA:
53 # A white matte on black reads as alpha, without RGB outlines.
54 painter.setPen(QPen(QColor("#909090"), 1.0))
55 painter.setBrush(QColor("#202020"))
56 painter.drawRoundedRect(QRectF(2, 2, 18, 18), 2, 2)
57 painter.setPen(Qt.PenStyle.NoPen)
58 painter.setBrush(QColor("#ffffff"))
59 painter.drawEllipse(QRectF(5, 5, 12, 12))
60 else:
61 # Draw inactive outlines first so none cross the selected fill.
62 painter.setBrush(Qt.BrushStyle.NoBrush)
63 painter.setPen(QPen(QColor("#909090"), 1.2))
64 for path, (_color, _cx, _cy, value) in zip(paths, circles):
65 if value != selected:
66 painter.drawPath(path)
67 for path, (color, _cx, _cy, value) in zip(paths, circles):
68 if value == selected:
69 painter.fillPath(path, color)
70 painter.end()
71 pixmap.setDevicePixelRatio(scale)
72 return QIcon(pixmap)
75class ChannelPreviewButton(QToolButton):
76 """Icon-only channel selector with combo-box-compatible helpers."""
78 channelChanged = Signal(str)
80 def __init__(self, choices, parent=None):
81 super().__init__(parent)
82 self._choices = tuple(choices)
83 self._channel = view_preview.CHANNEL_RGB
84 self._menu = QMenu(self)
85 self._actions: list[QAction] = []
86 for value, label in self._choices:
87 action = self._menu.addAction(channel_icon(value), label)
88 action.setData(value)
89 action.triggered.connect(lambda _checked=False, v=value: self.setChannel(v, True))
90 self._actions.append(action)
91 self.setMenu(self._menu)
92 self.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
93 self.setFixedSize(30, 24)
94 self.setIconSize(QSize(20, 20))
95 self.setFocusPolicy(Qt.FocusPolicy.NoFocus)
96 self.setToolTip(tr("表示するチャンネル"))
97 self._refresh()
99 def setChannel(self, channel: str, emit: bool = False):
100 if channel not in view_preview.CHANNELS: 100 ↛ 101line 100 didn't jump to line 101 because the condition on line 100 was never true
101 channel = view_preview.CHANNEL_RGB
102 changed = channel != self._channel
103 self._channel = channel
104 self._refresh()
105 if emit and changed:
106 self.channelChanged.emit(channel)
108 def _refresh(self):
109 self.setIcon(channel_icon(self._channel))
110 label = next((label for value, label in self._choices if value == self._channel), "RGB")
111 self.setAccessibleName(tr("チャンネル") + ": " + label)
112 for action in self._actions:
113 action.setCheckable(True)
114 action.setChecked(action.data() == self._channel)
116 # Compatibility with the former QComboBox API used by integrations/tests.
117 def currentData(self):
118 return self._channel
120 def findData(self, value):
121 return next((i for i, (item, _label) in enumerate(self._choices) if item == value), -1)
123 def setCurrentIndex(self, index: int):
124 if 0 <= index < len(self._choices): 124 ↛ exitline 124 didn't return from function 'setCurrentIndex' because the condition on line 124 was always true
125 self.setChannel(self._choices[index][0], True)
128class _ExposureLink(QLabel):
129 clicked = Signal()
130 dragged = Signal(float)
132 def __init__(self, parent=None):
133 super().__init__(parent)
134 self._press_pos: QPoint | None = None
135 self._dragged = False
136 self.setCursor(Qt.CursorShape.SizeHorCursor)
137 self.setStyleSheet("QLabel { color: palette(link); text-decoration: underline; padding: 2px 3px; }")
139 def mousePressEvent(self, event):
140 if event.button() == Qt.MouseButton.LeftButton:
141 self._press_pos = event.position().toPoint()
142 self._dragged = False
143 event.accept()
144 return
145 super().mousePressEvent(event)
147 def mouseMoveEvent(self, event):
148 if self._press_pos is not None and event.buttons() & Qt.MouseButton.LeftButton:
149 dx = event.position().x() - self._press_pos.x()
150 if abs(dx) >= 3:
151 self._dragged = True
152 self.dragged.emit(dx / 20.0)
153 self._press_pos = event.position().toPoint()
154 event.accept()
155 return
156 super().mouseMoveEvent(event)
158 def mouseReleaseEvent(self, event):
159 if event.button() == Qt.MouseButton.LeftButton and self._press_pos is not None:
160 if not self._dragged:
161 self.clicked.emit()
162 self._press_pos = None
163 event.accept()
164 return
165 super().mouseReleaseEvent(event)
168class ExposureControl(QWidget):
169 """Link-like exposure value: drag to adjust, click to type."""
171 valueChanged = Signal(float)
173 def __init__(self, minimum: float, maximum: float, parent=None):
174 super().__init__(parent)
175 self.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
176 self.link = _ExposureLink()
177 self.spin = QDoubleSpinBox()
178 self.spin.setRange(minimum, maximum)
179 self.spin.setSingleStep(0.1)
180 self.spin.setDecimals(1)
181 self.spin.setButtonSymbols(QAbstractSpinBox.ButtonSymbols.NoButtons)
182 self.spin.setFixedWidth(62)
183 self.stack = QStackedWidget()
184 self.stack.addWidget(self.link)
185 self.stack.addWidget(self.spin)
186 layout = QHBoxLayout(self)
187 layout.setContentsMargins(0, 0, 0, 0)
188 layout.addWidget(self.stack)
189 self.link.clicked.connect(self.begin_edit)
190 self.link.dragged.connect(self._drag)
191 self.spin.valueChanged.connect(self.valueChanged)
192 self.spin.editingFinished.connect(self.end_edit)
193 self.setValue(0.0)
195 def value(self) -> float:
196 return self.spin.value()
198 def setValue(self, value: float):
199 self.spin.setValue(value)
200 self.link.setText(f"{self.spin.value():+.1f}")
202 def _drag(self, delta: float):
203 self.setValue(round((self.value() + delta) * 10.0) / 10.0)
205 def begin_edit(self):
206 self.stack.setCurrentWidget(self.spin)
207 self.spin.setFocus(Qt.FocusReason.MouseFocusReason)
208 self.spin.selectAll()
210 def end_edit(self):
211 self.link.setText(f"{self.value():+.1f}")
212 self.stack.setCurrentWidget(self.link)