Coverage for paintmaskanimator/color_picker_dialog.py: 91%
334 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"""After Effects 風のカラーピッカー。
3OS 標準の色ダイアログ(macOS では NSColorPanel)は、絵を塗る作業で色を
4決めるには遠回りだった。ここでは AE のカラーピッカーに寄せて、
6- 左に大きな彩度×明度の面、その右に縦の色相バー
7- 右上に「新しい色/元の色」の見本(元の色をクリックすると戻る)
8- H・S・B と R・G・B の数値欄、16進のカラーコード欄
9- 画面スポイト
10- OK/キャンセル
12を1枚にまとめる。内部では色相を HSV の浮動小数で持つので、彩度や明度を
130 にしても色相を失わない(QColor は無彩色の色相を -1 にしてしまう)。
15:meth:`ColorPickerDialog.get_color` は ``QColorDialog.getColor`` と同じ使い方で、
16キャンセルされたら無効な ``QColor`` を返す。
17"""
18from __future__ import annotations
20from PySide6.QtCore import QPointF, QRectF, QSize, Qt, Signal
21from PySide6.QtGui import QColor, QImage, QLinearGradient, QPainter, QPen
22from PySide6.QtWidgets import (
23 QCheckBox,
24 QDialog,
25 QDialogButtonBox,
26 QGridLayout,
27 QHBoxLayout,
28 QLabel,
29 QLineEdit,
30 QPushButton,
31 QSpinBox,
32 QVBoxLayout,
33 QWidget,
34)
36from .i18n import tr
38_FIELD_SIZE = 256
41def _clamp(value, low, high):
42 return max(low, min(high, value))
45def _hsv_of(color):
46 """``(色相 0..1、無彩色は -1, 彩度 0..1, 明度 0..1)``。"""
47 return color.hsvHueF(), color.hsvSaturationF(), color.valueF()
50class _SaturationValueField(QWidget):
51 """横が彩度、縦が明度(上ほど明るい)の面。"""
53 changed = Signal(float, float)
55 def __init__(self, parent=None):
56 super().__init__(parent)
57 self._hue = 0.0
58 self._saturation = 1.0
59 self._value = 1.0
60 self._cache_hue = None
61 self._cache = QImage()
62 self.setFixedSize(_FIELD_SIZE, _FIELD_SIZE)
63 self.setCursor(Qt.CursorShape.CrossCursor)
65 def set_hsv(self, hue, saturation, value):
66 self._hue, self._saturation, self._value = hue, saturation, value
67 self.update()
69 def _field_image(self):
70 hue = round(self._hue, 1)
71 if self._cache_hue == hue and not self._cache.isNull(): 71 ↛ 72line 71 didn't jump to line 72 because the condition on line 71 was never true
72 return self._cache
73 image = QImage(self.width(), self.height(), QImage.Format.Format_RGB32)
74 painter = QPainter(image)
75 across = QLinearGradient(0, 0, self.width(), 0)
76 across.setColorAt(0.0, QColor("white"))
77 across.setColorAt(1.0, QColor.fromHsvF((hue % 360.0) / 360.0, 1.0, 1.0))
78 painter.fillRect(image.rect(), across)
79 down = QLinearGradient(0, 0, 0, self.height())
80 down.setColorAt(0.0, QColor(0, 0, 0, 0))
81 down.setColorAt(1.0, QColor(0, 0, 0, 255))
82 painter.fillRect(image.rect(), down)
83 painter.end()
84 self._cache_hue = hue
85 self._cache = image
86 return image
88 def paintEvent(self, _event):
89 painter = QPainter(self)
90 painter.drawImage(0, 0, self._field_image())
91 painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
92 point = QPointF(
93 self._saturation * (self.width() - 1),
94 (1.0 - self._value) * (self.height() - 1),
95 )
96 ring = QColor("black") if self._value > 0.6 and self._saturation < 0.4 else QColor("white")
97 painter.setPen(QPen(ring, 1.5))
98 painter.setBrush(Qt.BrushStyle.NoBrush)
99 painter.drawEllipse(point, 5, 5)
101 def _emit_at(self, position):
102 saturation = _clamp(position.x() / max(1, self.width() - 1), 0.0, 1.0)
103 value = 1.0 - _clamp(position.y() / max(1, self.height() - 1), 0.0, 1.0)
104 self._saturation, self._value = saturation, value
105 self.update()
106 self.changed.emit(saturation, value)
108 def mousePressEvent(self, event):
109 if event.button() == Qt.MouseButton.LeftButton: 109 ↛ exitline 109 didn't return from function 'mousePressEvent' because the condition on line 109 was always true
110 self._emit_at(event.position())
112 def mouseMoveEvent(self, event):
113 if event.buttons() & Qt.MouseButton.LeftButton:
114 self._emit_at(event.position())
117class _HueStrip(QWidget):
118 """縦の色相バー。上が 360°、下が 0°(どちらも赤)。"""
120 changed = Signal(float)
122 def __init__(self, parent=None):
123 super().__init__(parent)
124 self._hue = 0.0
125 self.setFixedSize(28, _FIELD_SIZE)
126 self.setCursor(Qt.CursorShape.PointingHandCursor)
128 def set_hue(self, hue):
129 self._hue = hue
130 self.update()
132 def _bar_rect(self):
133 return QRectF(7, 0, self.width() - 14, self.height())
135 def paintEvent(self, _event):
136 painter = QPainter(self)
137 bar = self._bar_rect()
138 gradient = QLinearGradient(0, bar.top(), 0, bar.bottom())
139 for step in range(7):
140 stop = step / 6.0
141 gradient.setColorAt(stop, QColor.fromHsvF((1.0 - stop) % 1.0, 1.0, 1.0))
142 painter.fillRect(bar, gradient)
143 painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
144 y = (1.0 - self._hue / 360.0) * (self.height() - 1)
145 painter.setPen(Qt.PenStyle.NoPen)
146 painter.setBrush(self.palette().windowText())
147 for left, direction in ((0.0, 1.0), (float(self.width()), -1.0)):
148 painter.drawPolygon([
149 QPointF(left, y - 5),
150 QPointF(left + 6 * direction, y),
151 QPointF(left, y + 5),
152 ])
154 def _emit_at(self, position):
155 hue = (1.0 - _clamp(position.y() / max(1, self.height() - 1), 0.0, 1.0)) * 360.0
156 self._hue = hue
157 self.update()
158 self.changed.emit(hue)
160 def mousePressEvent(self, event):
161 if event.button() == Qt.MouseButton.LeftButton: 161 ↛ exitline 161 didn't return from function 'mousePressEvent' because the condition on line 161 was always true
162 self._emit_at(event.position())
164 def mouseMoveEvent(self, event):
165 if event.buttons() & Qt.MouseButton.LeftButton:
166 self._emit_at(event.position())
169class _Swatch(QPushButton):
170 def __init__(self, parent=None):
171 super().__init__(parent)
172 self.setFixedSize(QSize(64, 30))
173 self.setFocusPolicy(Qt.FocusPolicy.NoFocus)
175 def set_color(self, color):
176 self.setStyleSheet(
177 f"QPushButton{{background:{QColor(color).name()};"
178 "border:1px solid #555;border-radius:0;padding:0;}"
179 )
182class ColorPickerDialog(QDialog):
183 """AE 風のカラーピッカー。``colorChanged`` は編集のたびに出る。"""
185 colorChanged = Signal(QColor)
187 def __init__(self, color=None, parent=None, title=None, preview=None):
188 """``preview`` を渡すと「プレビュー」チェックボックスを出す。
190 チェックが入っている間は、編集のたびに ``preview(色)`` を呼んで
191 その場に反映する。キャンセルしたら ``preview(元の色)`` で戻し、
192 チェックを外して OK したときは ``preview(決めた色)`` を1回だけ呼ぶ。
193 """
194 super().__init__(parent)
195 self._preview = preview
196 self._previewed = False
197 initial = QColor(color) if color is not None else QColor("white")
198 if not initial.isValid(): 198 ↛ 199line 198 didn't jump to line 199 because the condition on line 198 was never true
199 initial = QColor("white")
200 self._original = QColor(initial)
201 hue, saturation, value = _hsv_of(initial)
202 self._hue = 0.0 if hue < 0 else hue * 360.0
203 self._saturation = saturation
204 self._value = value
205 # RGB で決まった色(数値入力・カラーコード・スポイトなど)はその値を
206 # そのまま返す。HSV を経由すると 8bit の値が1ずれることがあるため。
207 self._exact = QColor(initial.toRgb())
208 self._updating = False
209 self._screen_picker = None
210 self.setWindowTitle(title or tr("色を選択"))
212 self.field = _SaturationValueField()
213 self.hue_strip = _HueStrip()
214 self.field.changed.connect(self._field_changed)
215 self.hue_strip.changed.connect(self._hue_changed)
217 self.new_swatch = _Swatch()
218 self.new_swatch.setToolTip(tr("新しい色"))
219 self.original_swatch = _Swatch()
220 self.original_swatch.setToolTip(tr("元の色(クリックで戻す)"))
221 self.original_swatch.set_color(self._original)
222 self.original_swatch.clicked.connect(
223 lambda: self.set_color(self._original)
224 )
225 swatches = QVBoxLayout()
226 swatches.setSpacing(0)
227 swatches.addWidget(self.new_swatch)
228 swatches.addWidget(self.original_swatch)
230 self.eyedropper = QPushButton(tr("スポイト"))
231 self.eyedropper.setToolTip(tr("画面上の色を拾う(Esc でやめる)"))
232 self.eyedropper.clicked.connect(self._begin_screen_pick)
234 top_right = QHBoxLayout()
235 top_right.addLayout(swatches)
236 top_right.addStretch(1)
237 top_right.addWidget(self.eyedropper, 0, Qt.AlignmentFlag.AlignTop)
239 self.h_spin = self._spin(0, 360, "°")
240 self.s_spin = self._spin(0, 100, "%")
241 self.b_spin = self._spin(0, 100, "%")
242 self.r_spin = self._spin(0, 255)
243 self.g_spin = self._spin(0, 255)
244 self.blue_spin = self._spin(0, 255)
245 for spin in (self.h_spin, self.s_spin, self.b_spin):
246 spin.valueChanged.connect(self._hsb_edited)
247 for spin in (self.r_spin, self.g_spin, self.blue_spin):
248 spin.valueChanged.connect(self._rgb_edited)
249 self.hex_edit = QLineEdit()
250 self.hex_edit.setMaxLength(7)
251 self.hex_edit.setFixedWidth(84)
252 self.hex_edit.setToolTip(tr("カラーコードを入力して Enter"))
253 self.hex_edit.editingFinished.connect(self._hex_edited)
255 numbers = QGridLayout()
256 numbers.setHorizontalSpacing(6)
257 numbers.setVerticalSpacing(3)
258 for row, (label, spin) in enumerate((
259 ("H", self.h_spin), ("S", self.s_spin), ("B", self.b_spin),
260 )):
261 numbers.addWidget(QLabel(label), row, 0)
262 numbers.addWidget(spin, row, 1)
263 for row, (label, spin) in enumerate((
264 ("R", self.r_spin), ("G", self.g_spin), ("B", self.blue_spin),
265 )):
266 numbers.addWidget(QLabel(label), row, 2)
267 numbers.addWidget(spin, row, 3)
268 numbers.addWidget(QLabel("#"), 3, 0)
269 numbers.addWidget(self.hex_edit, 3, 1, 1, 3, Qt.AlignmentFlag.AlignLeft)
271 side = QVBoxLayout()
272 side.addLayout(top_right)
273 side.addSpacing(10)
274 side.addLayout(numbers)
275 side.addStretch(1)
277 body = QHBoxLayout()
278 body.setSpacing(8)
279 body.addWidget(self.field)
280 body.addWidget(self.hue_strip)
281 body.addSpacing(6)
282 body.addLayout(side)
284 buttons = QDialogButtonBox(
285 QDialogButtonBox.StandardButton.Ok
286 | QDialogButtonBox.StandardButton.Cancel
287 )
288 buttons.accepted.connect(self.accept)
289 buttons.rejected.connect(self.reject)
291 self.preview_check = QCheckBox(tr("プレビュー"))
292 self.preview_check.setToolTip(
293 tr("ON:選んでいる色をその場で反映します(キャンセルで元に戻ります)。")
294 )
295 self.preview_check.setChecked(True)
296 self.preview_check.setVisible(preview is not None)
297 self.preview_check.toggled.connect(self._preview_toggled)
298 bottom = QHBoxLayout()
299 bottom.addWidget(self.preview_check)
300 bottom.addStretch(1)
301 bottom.addWidget(buttons)
303 layout = QVBoxLayout(self)
304 layout.setContentsMargins(12, 12, 12, 10)
305 layout.addLayout(body)
306 layout.addLayout(bottom)
307 self._sync_widgets()
309 # ------------------------------------------------------------------
310 @staticmethod
311 def get_color(initial=None, parent=None, title=None, preview=None):
312 """``QColorDialog.getColor`` の置き換え。キャンセルなら無効な色を返す。
314 ``preview`` は「プレビュー」チェックボックス用(:meth:`__init__` を参照)。
315 """
316 dialog = ColorPickerDialog(initial, parent, title, preview)
317 accepted = dialog.exec() == QDialog.DialogCode.Accepted
318 color = dialog.color()
319 dialog.deleteLater()
320 return color if accepted else QColor()
322 def color(self):
323 if self._exact is not None:
324 return QColor(self._exact)
325 return QColor.fromHsvF(
326 (self._hue % 360.0) / 360.0, self._saturation, self._value
327 ).toRgb()
329 def original_color(self):
330 return QColor(self._original)
332 def set_color(self, color):
333 color = QColor(color)
334 if not color.isValid(): 334 ↛ 335line 334 didn't jump to line 335 because the condition on line 334 was never true
335 return
336 hue, saturation, value = _hsv_of(color)
337 if hue >= 0: 337 ↛ 339line 337 didn't jump to line 339 because the condition on line 337 was always true
338 self._hue = hue * 360.0
339 self._saturation, self._value = saturation, value
340 self._exact = QColor(color.toRgb())
341 self._changed()
343 # ------------------------------------------------------------------
344 @staticmethod
345 def _spin(low, high, suffix=""):
346 spin = QSpinBox()
347 spin.setRange(low, high)
348 spin.setSuffix(suffix)
349 spin.setFixedWidth(68)
350 spin.setButtonSymbols(QSpinBox.ButtonSymbols.NoButtons)
351 spin.setAlignment(Qt.AlignmentFlag.AlignRight)
352 return spin
354 def _field_changed(self, saturation, value):
355 self._saturation, self._value = saturation, value
356 self._exact = None
357 self._changed()
359 def _hue_changed(self, hue):
360 self._hue = hue
361 self._exact = None
362 self._changed()
364 def _hsb_edited(self):
365 if self._updating:
366 return
367 self._hue = float(self.h_spin.value() % 360)
368 self._saturation = self.s_spin.value() / 100.0
369 self._value = self.b_spin.value() / 100.0
370 self._exact = None
371 self._changed(keep=("hsb",))
373 def _rgb_edited(self):
374 if self._updating:
375 return
376 color = QColor(self.r_spin.value(), self.g_spin.value(), self.blue_spin.value())
377 hue, saturation, value = _hsv_of(color)
378 if hue >= 0: 378 ↛ 380line 378 didn't jump to line 380 because the condition on line 378 was always true
379 self._hue = hue * 360.0
380 self._saturation, self._value = saturation, value
381 self._exact = color
382 self._changed(keep=("rgb",))
384 def _hex_edited(self):
385 text = self.hex_edit.text().strip()
386 if not text.startswith("#"): 386 ↛ 387line 386 didn't jump to line 387 because the condition on line 386 was never true
387 text = "#" + text
388 color = QColor(text)
389 if color.isValid() and len(text) == 7: 389 ↛ 392line 389 didn't jump to line 392 because the condition on line 389 was always true
390 self.set_color(color)
391 else:
392 self._sync_widgets()
394 def _changed(self, keep=()):
395 self._sync_widgets(keep)
396 self.colorChanged.emit(self.color())
397 if self._preview is not None and self.preview_check.isChecked():
398 self._apply_preview(self.color())
400 def _apply_preview(self, color):
401 if self._preview is None: 401 ↛ 402line 401 didn't jump to line 402 because the condition on line 401 was never true
402 return
403 self._previewed = True
404 self._preview(QColor(color))
406 def _preview_toggled(self, checked):
407 if self._preview is None: 407 ↛ 408line 407 didn't jump to line 408 because the condition on line 407 was never true
408 return
409 if checked: 409 ↛ 410line 409 didn't jump to line 410 because the condition on line 409 was never true
410 self._apply_preview(self.color())
411 elif self._previewed: 411 ↛ 413line 411 didn't jump to line 413 because the condition on line 411 was never true
412 # プレビューをやめたら、決めるまでは元の色に戻しておく。
413 self._apply_preview(self._original)
415 def accept(self):
416 if self._preview is not None and ( 416 ↛ 420line 416 didn't jump to line 420 because the condition on line 416 was always true
417 self._previewed or not self.preview_check.isChecked()
418 ):
419 self._apply_preview(self.color())
420 super().accept()
422 def _sync_widgets(self, keep=()):
423 """数値欄・面・見本を今の色に合わせる。``keep`` は入力中の欄。"""
424 color = self.color()
425 self._updating = True
426 try:
427 if "hsb" not in keep:
428 self.h_spin.setValue(int(round(self._hue)) % 360)
429 self.s_spin.setValue(int(round(self._saturation * 100)))
430 self.b_spin.setValue(int(round(self._value * 100)))
431 if "rgb" not in keep:
432 self.r_spin.setValue(color.red())
433 self.g_spin.setValue(color.green())
434 self.blue_spin.setValue(color.blue())
435 self.hex_edit.setText(color.name(QColor.NameFormat.HexRgb).upper())
436 finally:
437 self._updating = False
438 self.field.set_hsv(self._hue, self._saturation, self._value)
439 self.hue_strip.set_hue(self._hue)
440 self.new_swatch.set_color(color)
442 # ------------------------------------------------------------------
443 def _begin_screen_pick(self):
444 from .utils import ScreenColorPickerOverlay
446 if self._screen_picker is not None: 446 ↛ 447line 446 didn't jump to line 447 because the condition on line 446 was never true
447 return
448 # モーダルな色選択ダイアログの子にしないと、Qt が入力を遮断する。
449 overlay = ScreenColorPickerOverlay(self.color(), parent=self)
450 self._screen_picker = overlay
451 overlay.colorPicked.connect(self._screen_color_picked)
452 overlay.canceled.connect(self._screen_pick_finished)
453 overlay.start()
455 def _screen_color_picked(self, color):
456 self._screen_pick_finished()
457 self.set_color(color)
459 def _screen_pick_finished(self):
460 self._screen_picker = None
461 self.raise_()
462 self.activateWindow()
464 def done(self, result):
465 overlay = self._screen_picker
466 if overlay is not None:
467 self._screen_picker = None
468 overlay.cancel()
469 super().done(result)
471 def reject(self):
472 overlay = self._screen_picker
473 if overlay is not None: 473 ↛ 474line 473 didn't jump to line 474 because the condition on line 473 was never true
474 self._screen_picker = None
475 overlay.cancel()
476 if self._preview is not None and self._previewed:
477 self._apply_preview(self._original)
478 super().reject()