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

1"""After Effects 風のカラーピッカー。 

2 

3OS 標準の色ダイアログ(macOS では NSColorPanel)は、絵を塗る作業で色を 

4決めるには遠回りだった。ここでは AE のカラーピッカーに寄せて、 

5 

6- 左に大きな彩度×明度の面、その右に縦の色相バー 

7- 右上に「新しい色/元の色」の見本(元の色をクリックすると戻る) 

8- H・S・B と R・G・B の数値欄、16進のカラーコード欄 

9- 画面スポイト 

10- OK/キャンセル 

11 

12を1枚にまとめる。内部では色相を HSV の浮動小数で持つので、彩度や明度を 

130 にしても色相を失わない(QColor は無彩色の色相を -1 にしてしまう)。 

14 

15:meth:`ColorPickerDialog.get_color` は ``QColorDialog.getColor`` と同じ使い方で、 

16キャンセルされたら無効な ``QColor`` を返す。 

17""" 

18from __future__ import annotations 

19 

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) 

35 

36from .i18n import tr 

37 

38_FIELD_SIZE = 256 

39 

40 

41def _clamp(value, low, high): 

42 return max(low, min(high, value)) 

43 

44 

45def _hsv_of(color): 

46 """``(色相 0..1、無彩色は -1, 彩度 0..1, 明度 0..1)``。""" 

47 return color.hsvHueF(), color.hsvSaturationF(), color.valueF() 

48 

49 

50class _SaturationValueField(QWidget): 

51 """横が彩度、縦が明度(上ほど明るい)の面。""" 

52 

53 changed = Signal(float, float) 

54 

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) 

64 

65 def set_hsv(self, hue, saturation, value): 

66 self._hue, self._saturation, self._value = hue, saturation, value 

67 self.update() 

68 

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 

87 

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) 

100 

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) 

107 

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

111 

112 def mouseMoveEvent(self, event): 

113 if event.buttons() & Qt.MouseButton.LeftButton: 

114 self._emit_at(event.position()) 

115 

116 

117class _HueStrip(QWidget): 

118 """縦の色相バー。上が 360°、下が 0°(どちらも赤)。""" 

119 

120 changed = Signal(float) 

121 

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) 

127 

128 def set_hue(self, hue): 

129 self._hue = hue 

130 self.update() 

131 

132 def _bar_rect(self): 

133 return QRectF(7, 0, self.width() - 14, self.height()) 

134 

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

153 

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) 

159 

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

163 

164 def mouseMoveEvent(self, event): 

165 if event.buttons() & Qt.MouseButton.LeftButton: 

166 self._emit_at(event.position()) 

167 

168 

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) 

174 

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 ) 

180 

181 

182class ColorPickerDialog(QDialog): 

183 """AE 風のカラーピッカー。``colorChanged`` は編集のたびに出る。""" 

184 

185 colorChanged = Signal(QColor) 

186 

187 def __init__(self, color=None, parent=None, title=None, preview=None): 

188 """``preview`` を渡すと「プレビュー」チェックボックスを出す。 

189 

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("色を選択")) 

211 

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) 

216 

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) 

229 

230 self.eyedropper = QPushButton(tr("スポイト")) 

231 self.eyedropper.setToolTip(tr("画面上の色を拾う(Esc でやめる)")) 

232 self.eyedropper.clicked.connect(self._begin_screen_pick) 

233 

234 top_right = QHBoxLayout() 

235 top_right.addLayout(swatches) 

236 top_right.addStretch(1) 

237 top_right.addWidget(self.eyedropper, 0, Qt.AlignmentFlag.AlignTop) 

238 

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) 

254 

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) 

270 

271 side = QVBoxLayout() 

272 side.addLayout(top_right) 

273 side.addSpacing(10) 

274 side.addLayout(numbers) 

275 side.addStretch(1) 

276 

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) 

283 

284 buttons = QDialogButtonBox( 

285 QDialogButtonBox.StandardButton.Ok 

286 | QDialogButtonBox.StandardButton.Cancel 

287 ) 

288 buttons.accepted.connect(self.accept) 

289 buttons.rejected.connect(self.reject) 

290 

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) 

302 

303 layout = QVBoxLayout(self) 

304 layout.setContentsMargins(12, 12, 12, 10) 

305 layout.addLayout(body) 

306 layout.addLayout(bottom) 

307 self._sync_widgets() 

308 

309 # ------------------------------------------------------------------ 

310 @staticmethod 

311 def get_color(initial=None, parent=None, title=None, preview=None): 

312 """``QColorDialog.getColor`` の置き換え。キャンセルなら無効な色を返す。 

313 

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

321 

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

328 

329 def original_color(self): 

330 return QColor(self._original) 

331 

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

342 

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 

353 

354 def _field_changed(self, saturation, value): 

355 self._saturation, self._value = saturation, value 

356 self._exact = None 

357 self._changed() 

358 

359 def _hue_changed(self, hue): 

360 self._hue = hue 

361 self._exact = None 

362 self._changed() 

363 

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",)) 

372 

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",)) 

383 

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

393 

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

399 

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

405 

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) 

414 

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

421 

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) 

441 

442 # ------------------------------------------------------------------ 

443 def _begin_screen_pick(self): 

444 from .utils import ScreenColorPickerOverlay 

445 

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

454 

455 def _screen_color_picked(self, color): 

456 self._screen_pick_finished() 

457 self.set_color(color) 

458 

459 def _screen_pick_finished(self): 

460 self._screen_picker = None 

461 self.raise_() 

462 self.activateWindow() 

463 

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) 

470 

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