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

1"""Compact controls used by the canvas preview bar.""" 

2from __future__ import annotations 

3 

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) 

17 

18from . import view_preview 

19from .i18n import tr 

20 

21 

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) 

73 

74 

75class ChannelPreviewButton(QToolButton): 

76 """Icon-only channel selector with combo-box-compatible helpers.""" 

77 

78 channelChanged = Signal(str) 

79 

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() 

98 

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) 

107 

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) 

115 

116 # Compatibility with the former QComboBox API used by integrations/tests. 

117 def currentData(self): 

118 return self._channel 

119 

120 def findData(self, value): 

121 return next((i for i, (item, _label) in enumerate(self._choices) if item == value), -1) 

122 

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) 

126 

127 

128class _ExposureLink(QLabel): 

129 clicked = Signal() 

130 dragged = Signal(float) 

131 

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; }") 

138 

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) 

146 

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) 

157 

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) 

166 

167 

168class ExposureControl(QWidget): 

169 """Link-like exposure value: drag to adjust, click to type.""" 

170 

171 valueChanged = Signal(float) 

172 

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) 

194 

195 def value(self) -> float: 

196 return self.spin.value() 

197 

198 def setValue(self, value: float): 

199 self.spin.setValue(value) 

200 self.link.setText(f"{self.spin.value():+.1f}") 

201 

202 def _drag(self, delta: float): 

203 self.setValue(round((self.value() + delta) * 10.0) / 10.0) 

204 

205 def begin_edit(self): 

206 self.stack.setCurrentWidget(self.spin) 

207 self.spin.setFocus(Qt.FocusReason.MouseFocusReason) 

208 self.spin.selectAll() 

209 

210 def end_edit(self): 

211 self.link.setText(f"{self.value():+.1f}") 

212 self.stack.setCurrentWidget(self.link) 

213