Coverage for paintmaskanimator/color_panel_widgets.py: 41%
680 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"""The row widgets that make up the used-color panel.
3Split out of ``color_panel.py``: the source-colour swatch button, the three
4checkbox variants (visibility / mask / selection) with their custom painting and
5sweep-drag behaviour, and the swatch row itself (``ColorSelectionArea``) with its
6drag-and-drop handling. ``UsedColorPanel`` composes these; they know nothing
7about the panel beyond the signals they emit.
8"""
9from PySide6.QtCore import QByteArray, QPoint, QPointF, QRectF, QTimer, Qt, Signal
10from PySide6.QtGui import QColor, QCursor, QPainter, QPainterPath, QPen, QPixmap
11from PySide6.QtWidgets import (
12 QApplication,
13 QCheckBox,
14 QHBoxLayout,
15 QLabel,
16 QToolButton,
17 QWidget,
18)
19from . import icons, theme
20from .i18n import tr
21from .color_picker_dialog import ColorPickerDialog
22from .utils import (
23 ScreenColorPickerOverlay,
24 _ScreenColorDragMixin,
25 show_screen_color_loupe,
26)
27from .logging_setup import get_logger
29from PySide6.QtCore import QMimeData
30from PySide6.QtGui import QDrag
31from PySide6.QtWidgets import QGraphicsOpacityEffect
33log = get_logger(__name__)
35# 使用色をドラッグ&ドロップで統合するときに使う専用MIME形式。
36USED_COLOR_MIME = "application/x-pma-used-color"
39class ColorCategoryHeader(QWidget):
40 """使用色カテゴリーの折りたたみ・表示・ドロップ用ヘッダー。"""
42 visibilityChanged = Signal(bool)
43 collapsedChanged = Signal(bool)
44 colorDropped = Signal(object)
45 contextMenuRequested = Signal(QPoint)
47 def __init__(self, name, parent=None):
48 super().__init__(parent)
49 self._name = str(name)
50 self._drop_active = False
51 self.setAcceptDrops(True)
52 self.setObjectName("usedColorCategoryHeader")
53 self.setMinimumHeight(32)
54 self.setStyleSheet(
55 "QWidget#usedColorCategoryHeader{"
56 "background-color:palette(midlight);"
57 "border:2px solid palette(mid);border-radius:4px;}"
58 "QWidget#usedColorCategoryHeader:hover{"
59 "border-color:palette(highlight);}"
60 )
61 layout = QHBoxLayout(self)
62 layout.setContentsMargins(6, 2, 7, 2)
63 layout.setSpacing(4)
64 self.collapse_button = QToolButton(self)
65 self.collapse_button.setCheckable(True)
66 self.collapse_button.setChecked(False)
67 self.collapse_button.setText("▼")
68 self.collapse_button.setToolTip(tr("フォルダーを折りたたむ"))
69 self.visibility_check = QCheckBox(self)
70 self.visibility_check.setTristate(True)
71 self.visibility_check.setToolTip(tr("フォルダー内の色を一括表示/非表示"))
72 self.name_label = QLabel(self._name, self)
73 self.name_label.setStyleSheet("font-weight:700;")
74 layout.addWidget(self.collapse_button)
75 layout.addWidget(self.visibility_check)
76 layout.addWidget(self.name_label, 1)
77 self.collapse_button.toggled.connect(self._on_collapsed)
78 self.visibility_check.clicked.connect(self.visibilityChanged.emit)
80 def name(self):
81 return self._name
83 def setName(self, name):
84 self._name = str(name)
85 self.name_label.setText(self._name)
87 def setCollapsed(self, collapsed):
88 self.collapse_button.blockSignals(True)
89 self.collapse_button.setChecked(bool(collapsed))
90 self.collapse_button.blockSignals(False)
91 self._sync_collapse_text(bool(collapsed))
93 def _sync_collapse_text(self, collapsed):
94 self.collapse_button.setText("▶" if collapsed else "▼")
95 self.collapse_button.setToolTip(
96 tr("フォルダーを展開") if collapsed else tr("フォルダーを折りたたむ")
97 )
99 def _on_collapsed(self, collapsed):
100 self._sync_collapse_text(collapsed)
101 self.collapsedChanged.emit(bool(collapsed))
103 def setVisibilityState(self, values):
104 states = [bool(value) for value in values]
105 if states and all(states):
106 state = Qt.CheckState.Checked
107 elif states and any(states): 107 ↛ 108line 107 didn't jump to line 108 because the condition on line 107 was never true
108 state = Qt.CheckState.PartiallyChecked
109 else:
110 state = Qt.CheckState.Unchecked
111 self.visibility_check.blockSignals(True)
112 self.visibility_check.setCheckState(state)
113 self.visibility_check.blockSignals(False)
115 @staticmethod
116 def _payload_rgb(mime):
117 if not mime.hasFormat(USED_COLOR_MIME):
118 return None
119 data = bytes(mime.data(USED_COLOR_MIME))
120 if len(data) < 3:
121 return None
122 return (data[0], data[1], data[2])
124 def dragEnterEvent(self, event):
125 if self._payload_rgb(event.mimeData()) is None:
126 event.ignore()
127 return
128 self._drop_active = True
129 self.update()
130 event.acceptProposedAction()
132 def dragLeaveEvent(self, event):
133 self._drop_active = False
134 self.update()
135 super().dragLeaveEvent(event)
137 def dropEvent(self, event):
138 rgb = self._payload_rgb(event.mimeData())
139 self._drop_active = False
140 self.update()
141 if rgb is None:
142 event.ignore()
143 return
144 event.acceptProposedAction()
145 self.colorDropped.emit(rgb)
147 def paintEvent(self, event):
148 super().paintEvent(event)
149 if self._drop_active:
150 painter = QPainter(self)
151 painter.setPen(QPen(QColor("#ffca28"), 3))
152 painter.setBrush(Qt.BrushStyle.NoBrush)
153 painter.drawRoundedRect(self.rect().adjusted(1, 1, -2, -2), 3, 3)
155 def contextMenuEvent(self, event):
156 self.contextMenuRequested.emit(event.globalPos())
157 event.accept()
160def _draw_eye_icon(painter, rect, color, is_open):
161 """タイムラインと同じ16pxの表示切替アイコンを中央に描く。"""
162 dpr = painter.device().devicePixelRatioF() if painter.device() else 1.0
163 pixmap = icons.pixmap(
164 "eye" if is_open else "eye_off", 16, color.name(),
165 device_pixel_ratio=dpr,
166 )
167 painter.drawPixmap(
168 int(rect.left() + (rect.width() - 16) / 2),
169 int(rect.top() + (rect.height() - 16) / 2),
170 pixmap,
171 )
174class SourceColorButton(_ScreenColorDragMixin, QToolButton):
175 screenColorPicked = Signal(QColor)
176 contextMenuRequested = Signal(QPoint)
178 def __init__(self, parent=None):
179 super().__init__(parent)
180 self._init_screen_color_drag()
181 self._right_click_candidate = False
182 self._right_click_press_global = None
183 self.setContextMenuPolicy(Qt.ContextMenuPolicy.DefaultContextMenu)
185 def mousePressEvent(self, event):
186 if event.button() == Qt.MouseButton.RightButton:
187 self._right_click_candidate = True
188 self._right_click_press_global = event.globalPosition().toPoint()
189 event.accept()
190 return
191 super().mousePressEvent(event)
193 def mouseMoveEvent(self, event):
194 if (
195 self._right_click_candidate
196 and self._right_click_press_global is not None
197 and event.buttons() & Qt.MouseButton.RightButton
198 ):
199 distance = (
200 event.globalPosition().toPoint() - self._right_click_press_global
201 ).manhattanLength()
202 if distance >= QApplication.startDragDistance():
203 self._right_click_candidate = False
204 self._screen_pick_press_global = self._right_click_press_global
205 self._begin_screen_pick(Qt.MouseButton.RightButton)
206 event.accept()
207 return
208 super().mouseMoveEvent(event)
210 def mouseReleaseEvent(self, event):
211 if (
212 event.button() == Qt.MouseButton.RightButton
213 and self._right_click_candidate
214 and not self._screen_pick_active
215 ):
216 self._right_click_candidate = False
217 global_pos = event.globalPosition().toPoint()
218 self._right_click_press_global = None
219 self.contextMenuRequested.emit(global_pos)
220 event.accept()
221 return
222 self._right_click_candidate = False
223 self._right_click_press_global = None
224 super().mouseReleaseEvent(event)
226 def contextMenuEvent(self, event):
227 self.contextMenuRequested.emit(event.globalPos())
228 event.accept()
231class ScreenEyedropButton(_ScreenColorDragMixin, QToolButton):
232 """置換色の登録・画面スポイト・編集要求を提供するボタン。"""
234 colorPicked = Signal(QColor)
235 colorEditorRequested = Signal(QPoint)
237 def __init__(self, parent=None):
238 super().__init__(parent)
239 self._init_screen_color_drag()
240 self._screen_picker_overlay = None
241 self._right_picker_candidate = False
242 self.setContextMenuPolicy(Qt.ContextMenuPolicy.PreventContextMenu)
243 self.clicked.connect(self._request_color_editor)
245 def _request_color_editor(self, _checked=False):
246 self.colorEditorRequested.emit(QCursor.pos())
248 def _screen_picker_closed(self, overlay):
249 if self._screen_picker_overlay is overlay:
250 self._screen_picker_overlay = None
251 self._screen_pick_active = False
253 def _global_color_picked(self, overlay, color):
254 self._screen_picker_closed(overlay)
255 self.colorPicked.emit(QColor(color))
257 def _begin_global_screen_pick(self):
258 overlay = self._screen_picker_overlay
259 if overlay is not None and overlay.isVisible():
260 return
261 overlay = ScreenColorPickerOverlay()
262 self._screen_picker_overlay = overlay
263 self._screen_pick_active = True
264 overlay.colorPicked.connect(
265 lambda color, current=overlay:
266 self._global_color_picked(current, color)
267 )
268 overlay.canceled.connect(
269 lambda current=overlay: self._screen_picker_closed(current)
270 )
271 overlay.destroyed.connect(
272 lambda _obj=None, current=overlay:
273 self._screen_picker_closed(current)
274 )
276 # This method runs from the activating right-button release. Showing
277 # the desktop window synchronously can make Windows deliver that same
278 # release to the new window and immediately cancel it.
279 def start_after_activation_release():
280 if self._screen_picker_overlay is overlay:
281 overlay.start()
283 QTimer.singleShot(0, start_after_activation_release)
285 def mousePressEvent(self, event):
286 if event.button() == Qt.MouseButton.RightButton:
287 self._right_picker_candidate = True
288 self._screen_pick_press_global = event.globalPosition().toPoint()
289 event.accept()
290 return
291 # A plain left click belongs to the RGB / HSV editor. Bypass the mixin
292 # so moving while left is held cannot start the eyedropper.
293 QToolButton.mousePressEvent(self, event)
295 def mouseMoveEvent(self, event):
296 if self._screen_pick_active and self._screen_picker_overlay is None: 296 ↛ 297line 296 didn't jump to line 297 because the condition on line 296 was never true
297 show_screen_color_loupe(self, event.globalPosition().toPoint())
298 event.accept()
299 return
300 if ( 300 ↛ 316line 300 didn't jump to line 316 because the condition on line 300 was always true
301 self._right_picker_candidate
302 and self._screen_pick_press_global is not None
303 and event.buttons() & Qt.MouseButton.RightButton
304 ):
305 distance = (
306 event.globalPosition().toPoint() - self._screen_pick_press_global
307 ).manhattanLength()
308 if distance >= QApplication.startDragDistance(): 308 ↛ 316line 308 didn't jump to line 316 because the condition on line 308 was always true
309 # Right-drag picks directly and confirms on release, like the
310 # other colour buttons; a plain right click opens the overlay.
311 self._right_picker_candidate = False
312 self._begin_screen_pick(Qt.MouseButton.RightButton)
313 show_screen_color_loupe(self, event.globalPosition().toPoint())
314 event.accept()
315 return
316 QToolButton.mouseMoveEvent(self, event)
318 def mouseReleaseEvent(self, event):
319 if (
320 self._screen_pick_active
321 and self._screen_picker_overlay is None
322 and event.button() == self._screen_pick_button
323 ):
324 self._finish_screen_pick(event.globalPosition().toPoint())
325 event.accept()
326 return
327 if (
328 event.button() == Qt.MouseButton.RightButton
329 and self._right_picker_candidate
330 ):
331 self._right_picker_candidate = False
332 self._screen_pick_press_global = None
333 self._begin_global_screen_pick()
334 event.accept()
335 return
336 QToolButton.mouseReleaseEvent(self, event)
338 def hideEvent(self, event):
339 overlay = self._screen_picker_overlay
340 if overlay is not None: 340 ↛ 341line 340 didn't jump to line 341 because the condition on line 340 was never true
341 overlay.cancel()
342 elif self._screen_pick_active: 342 ↛ 343line 342 didn't jump to line 343 because the condition on line 342 was never true
343 self._screen_pick_active = False
344 self._screen_pick_button = Qt.MouseButton.NoButton
345 self._cancel_screen_pick_cursor()
346 self._right_picker_candidate = False
347 QToolButton.hideEvent(self, event)
350class ReplacementColorPopup(ColorPickerDialog):
351 """置換色を編集するカラーピッカー(AE 風)。
353 開いたまま他の操作もできるよう、モーダルにはしない。「プレビュー」が
354 ON の間は編集をそのまま置換色へ反映し、キャンセルしたら開いたときの色へ戻す。
355 """
357 def __init__(self, color, source_rgb=None, parent=None, preview=None):
358 title = tr("置換色")
359 if source_rgb is not None: 359 ↛ 363line 359 didn't jump to line 363 because the condition on line 359 was always true
360 title = tr("置換色 元色 #{hex}").format(
361 hex="{:02X}{:02X}{:02X}".format(*source_rgb)
362 )
363 super().__init__(color, parent, title, preview)
364 self.setModal(False)
365 self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
368class ColorVisibilityCheckBox(QCheckBox):
369 """固定幅の目のアイコンと、複数行をなぞる表示ON/OFFに対応。"""
370 altClicked = Signal()
371 sweepStarted = Signal(bool)
372 sweepMoved = Signal(QPoint)
373 sweepFinished = Signal()
375 def __init__(self, parent=None):
376 super().__init__(parent)
377 self._sweeping = False
378 self._consume_release = False
379 self.setCursor(Qt.CursorShape.PointingHandCursor)
380 self.setFixedSize(30, 24)
381 self.setStyleSheet(
382 "QCheckBox{background:transparent;border:none;padding:0px;}"
383 )
385 def paintEvent(self, event):
386 painter = QPainter(self)
387 colors = theme.palette()
388 color = QColor(
389 colors["text"] if self.isChecked() and self.isEnabled()
390 else colors["text_muted"]
391 )
392 _draw_eye_icon(painter, self.rect(), color, self.isChecked())
393 painter.end()
395 def mousePressEvent(self, event):
396 if event.button() != Qt.MouseButton.LeftButton:
397 super().mousePressEvent(event)
398 return
399 if event.modifiers() & Qt.KeyboardModifier.AltModifier:
400 self._consume_release = True
401 self.altClicked.emit()
402 event.accept()
403 return
405 self._consume_release = False
406 self._sweeping = True
407 try:
408 self.grabMouse()
409 except RuntimeError as exc:
410 log.debug("grabMouse() failed: %s", exc)
411 self.sweepStarted.emit(not self.isChecked())
412 event.accept()
414 def mouseMoveEvent(self, event):
415 if self._sweeping:
416 self.sweepMoved.emit(event.globalPosition().toPoint())
417 event.accept()
418 return
419 super().mouseMoveEvent(event)
421 def mouseReleaseEvent(self, event):
422 if self._consume_release and event.button() == Qt.MouseButton.LeftButton:
423 self._consume_release = False
424 event.accept()
425 return
426 if self._sweeping and event.button() == Qt.MouseButton.LeftButton:
427 self._sweeping = False
428 try:
429 self.releaseMouse()
430 except RuntimeError as exc:
431 log.debug("releaseMouse() failed: %s", exc)
432 self.sweepFinished.emit()
433 event.accept()
434 return
435 super().mouseReleaseEvent(event)
440class MaskColorCheckBox(QCheckBox):
441 """使用色マスク専用。クリックと上下スライドのON/OFFに対応。"""
442 altClicked = Signal()
443 sweepStarted = Signal(bool)
444 sweepMoved = Signal(QPoint)
445 sweepFinished = Signal()
447 def __init__(self, parent=None):
448 super().__init__(parent)
449 self._sweeping = False
450 self._consume_release = False
451 self.setCursor(Qt.CursorShape.PointingHandCursor)
452 self.setFixedSize(30, 24)
453 self.setStyleSheet("QCheckBox{background:transparent;border:none;padding:0;}")
454 self.setAttribute(Qt.WidgetAttribute.WA_OpaquePaintEvent, False)
456 def paintEvent(self, event):
457 # ON は塗りつぶした的、OFF は細い輪だけにして、状態を形と色の両方で示す。
458 painter = QPainter(self)
459 painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
460 center = QPointF(self.rect().center()) + QPointF(0.5, 0.5)
461 if self.isChecked() and self.isEnabled(): 461 ↛ 470line 461 didn't jump to line 470 because the condition on line 461 was always true
462 accent = self.palette().highlight().color()
463 painter.setPen(QPen(accent, 1.6))
464 painter.setBrush(Qt.BrushStyle.NoBrush)
465 painter.drawEllipse(center, 6.5, 6.5)
466 painter.setPen(Qt.PenStyle.NoPen)
467 painter.setBrush(accent)
468 painter.drawEllipse(center, 3.5, 3.5)
469 else:
470 painter.setPen(QPen(self.palette().mid().color(), 1.4))
471 painter.setBrush(Qt.BrushStyle.NoBrush)
472 painter.drawEllipse(center, 6.5, 6.5)
473 painter.end()
475 def nextCheckState(self):
476 self.setChecked(not self.isChecked())
477 self.update()
479 def mousePressEvent(self, event):
480 if event.button() != Qt.MouseButton.LeftButton:
481 super().mousePressEvent(event)
482 return
483 if event.modifiers() & Qt.KeyboardModifier.AltModifier:
484 self._consume_release = True
485 self.altClicked.emit()
486 event.accept()
487 return
488 self._consume_release = False
489 self._sweeping = True
490 try:
491 self.grabMouse()
492 except RuntimeError as exc:
493 log.debug("grabMouse() failed: %s", exc)
494 self.sweepStarted.emit(not self.isChecked())
495 event.accept()
497 def mouseMoveEvent(self, event):
498 if self._sweeping:
499 self.sweepMoved.emit(event.globalPosition().toPoint())
500 event.accept()
501 return
502 super().mouseMoveEvent(event)
504 def mouseReleaseEvent(self, event):
505 if self._consume_release and event.button() == Qt.MouseButton.LeftButton:
506 self._consume_release = False
507 event.accept()
508 return
509 if self._sweeping and event.button() == Qt.MouseButton.LeftButton:
510 self._sweeping = False
511 try:
512 self.releaseMouse()
513 except RuntimeError as exc:
514 log.debug("releaseMouse() failed: %s", exc)
515 self.sweepFinished.emit()
516 event.accept()
517 return
518 super().mouseReleaseEvent(event)
521def _draw_check_glyph(painter, rect, color, is_checked):
522 """モダンな角丸チェックボックスを描く。ON=アクセント塗り+チェック。"""
523 painter.save()
524 painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
526 size = min(rect.width(), rect.height())
527 box = QRectF(0, 0, size, size)
528 box.moveCenter(QPointF(rect.center()))
529 box.adjust(6.0, 6.0, -6.0, -6.0)
531 if is_checked: 531 ↛ 532line 531 didn't jump to line 532 because the condition on line 531 was never true
532 painter.setPen(Qt.PenStyle.NoPen)
533 painter.setBrush(color)
534 painter.drawRoundedRect(box, 3.0, 3.0)
535 # 白いチェックマーク。
536 pen = QPen(QColor("#ffffff"))
537 pen.setWidthF(1.8)
538 pen.setCapStyle(Qt.PenCapStyle.RoundCap)
539 pen.setJoinStyle(Qt.PenJoinStyle.RoundJoin)
540 painter.setPen(pen)
541 painter.setBrush(Qt.BrushStyle.NoBrush)
542 path = QPainterPath()
543 path.moveTo(box.left() + box.width() * 0.24, box.top() + box.height() * 0.52)
544 path.lineTo(box.left() + box.width() * 0.44, box.top() + box.height() * 0.72)
545 path.lineTo(box.left() + box.width() * 0.78, box.top() + box.height() * 0.28)
546 painter.drawPath(path)
547 else:
548 pen = QPen(color)
549 pen.setWidthF(1.4)
550 painter.setPen(pen)
551 painter.setBrush(Qt.BrushStyle.NoBrush)
552 painter.drawRoundedRect(box, 3.0, 3.0)
553 painter.restore()
556class ColorSelectionCheckBox(QCheckBox):
557 """行頭の「選択」チェック。クリックと上下スイープの一括ON/OFFに対応。"""
558 altClicked = Signal()
559 sweepStarted = Signal(bool)
560 sweepMoved = Signal(QPoint)
561 sweepFinished = Signal()
563 def __init__(self, parent=None):
564 super().__init__(parent)
565 self._sweeping = False
566 self._consume_release = False
567 self.setCursor(Qt.CursorShape.PointingHandCursor)
568 self.setFixedSize(30, 24)
569 self.setStyleSheet(
570 "QCheckBox{background:transparent;border:none;padding:0px;}"
571 )
573 def paintEvent(self, event):
574 painter = QPainter(self)
575 color = (
576 self.palette().highlight().color()
577 if self.isChecked() else self.palette().mid().color()
578 )
579 _draw_check_glyph(painter, self.rect(), color, self.isChecked())
580 painter.end()
582 def mousePressEvent(self, event):
583 if event.button() != Qt.MouseButton.LeftButton:
584 super().mousePressEvent(event)
585 return
586 if event.modifiers() & Qt.KeyboardModifier.AltModifier:
587 self._consume_release = True
588 self.altClicked.emit()
589 event.accept()
590 return
591 self._consume_release = False
592 self._sweeping = True
593 try:
594 self.grabMouse()
595 except RuntimeError as exc:
596 log.debug("grabMouse() failed: %s", exc)
597 self.sweepStarted.emit(not self.isChecked())
598 event.accept()
600 def mouseMoveEvent(self, event):
601 if self._sweeping:
602 self.sweepMoved.emit(event.globalPosition().toPoint())
603 event.accept()
604 return
605 super().mouseMoveEvent(event)
607 def mouseReleaseEvent(self, event):
608 if self._consume_release and event.button() == Qt.MouseButton.LeftButton:
609 self._consume_release = False
610 event.accept()
611 return
612 if self._sweeping and event.button() == Qt.MouseButton.LeftButton:
613 self._sweeping = False
614 try:
615 self.releaseMouse()
616 except RuntimeError as exc:
617 log.debug("releaseMouse() failed: %s", exc)
618 self.sweepFinished.emit()
619 event.accept()
620 return
621 super().mouseReleaseEvent(event)
624class CheckClickArea(QWidget):
625 """薄い判定領域全体でチェックを操作できるラッパー。"""
627 def __init__(self, checkbox, parent=None):
628 super().__init__(parent)
629 self.checkbox = checkbox
630 self._sweeping = False
631 self.setFixedWidth(32)
632 self.setFixedHeight(26)
633 self.setCursor(Qt.CursorShape.PointingHandCursor)
634 self.setStyleSheet(
635 "CheckClickArea{background:transparent;border:none;}"
636 "CheckClickArea:hover{background:palette(alternate-base);border:none;}"
637 )
638 layout = QHBoxLayout(self)
639 layout.setContentsMargins(0, 0, 0, 0)
640 layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
641 layout.addWidget(checkbox)
643 def mousePressEvent(self, event):
644 if event.button() == Qt.MouseButton.LeftButton:
645 if event.modifiers() & Qt.KeyboardModifier.AltModifier:
646 self.checkbox.altClicked.emit()
647 elif self.checkbox.isEnabled() and hasattr(self.checkbox, "sweepStarted"):
648 self._sweeping = True
649 try:
650 self.grabMouse()
651 except RuntimeError as exc:
652 log.debug("grabMouse() failed: %s", exc)
653 self.checkbox.sweepStarted.emit(not self.checkbox.isChecked())
654 elif self.checkbox.isEnabled():
655 self.checkbox.setChecked(not self.checkbox.isChecked())
656 event.accept()
657 return
658 super().mousePressEvent(event)
660 def mouseMoveEvent(self, event):
661 if self._sweeping and event.buttons() & Qt.MouseButton.LeftButton:
662 self.checkbox.sweepMoved.emit(event.globalPosition().toPoint())
663 event.accept()
664 return
665 super().mouseMoveEvent(event)
667 def mouseReleaseEvent(self, event):
668 if self._sweeping and event.button() == Qt.MouseButton.LeftButton:
669 self._sweeping = False
670 try:
671 self.releaseMouse()
672 except RuntimeError as exc:
673 log.debug("releaseMouse() failed: %s", exc)
674 self.checkbox.sweepFinished.emit()
675 event.accept()
676 return
677 super().mouseReleaseEvent(event)
680class ColorSelectionArea(QWidget):
681 """使用色行/使用色枠。クリック、Shift選択、他色へのドラッグ統合に対応。"""
683 clicked = Signal(object)
684 dragStarted = Signal()
685 # (運んできた色rgb, モード) モード: "before" / "child" / "after"
686 colorDropped = Signal(object, str)
687 contextMenuRequested = Signal(QPoint)
688 hoverChanged = Signal(bool)
690 def __init__(self, parent=None):
691 super().__init__(parent)
692 self._selected = False
693 self._selection_frame_enabled = False
694 # 親子グループを示す左端の縦ライン色(None=グループなし)。
695 self._group_line_color = None
696 self._rgb = None
697 self._is_background = False
698 self._press_pos = None
699 self._press_modifiers = Qt.KeyboardModifier.NoModifier
700 self._drag_started = False
701 self._drop_mode = None
702 self.setMouseTracking(True)
703 self.setAcceptDrops(True)
705 def setColorKey(self, rgb, is_background=False):
706 self._rgb = rgb
707 self._is_background = bool(is_background)
709 def _mode_for_pos(self, y):
710 """行内のY位置から、並べ替え(before/after)か子化(child)かを決める。"""
711 height = max(1, self.height())
712 ratio = min(1.0, max(0.0, y / height))
713 if ratio < 0.28:
714 return "before"
715 if ratio > 0.72:
716 return "after"
717 return "child"
719 def setSelected(self, selected):
720 """選択状態を、色に依存しない単一アクセント枠で表す。"""
721 selected = bool(selected)
722 changed = self._selected != selected or not self._selection_frame_enabled
723 self._selected = selected
724 self._selection_frame_enabled = True
725 if changed:
726 self.update()
728 def setGroupLine(self, color):
729 """親子グループを示す左端の縦ライン色を設定する(None=非表示)。"""
730 new_color = QColor(color) if color is not None else None
731 old = self._group_line_color
732 same = (
733 (old is None and new_color is None)
734 or (old is not None and new_color is not None and old.rgb() == new_color.rgb())
735 )
736 self._group_line_color = new_color
737 if not same:
738 self.update()
740 def enterEvent(self, event):
741 self.hoverChanged.emit(True)
742 super().enterEvent(event)
744 def leaveEvent(self, event):
745 self.hoverChanged.emit(False)
746 super().leaveEvent(event)
748 def paintEvent(self, event):
749 super().paintEvent(event)
750 painter = QPainter(self)
751 painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
753 # 親子グループを、色を触らずに左端の縦ラインで束ねて示す。
754 if self._group_line_color is not None: 754 ↛ 755line 754 didn't jump to line 755 because the condition on line 754 was never true
755 painter.setPen(Qt.PenStyle.NoPen)
756 painter.setBrush(self._group_line_color)
757 painter.drawRoundedRect(QRectF(1.0, 1.0, 3.0, self.height() - 2.0), 1.5, 1.5)
759 # 選択は親子で色分けせず、テーマのアクセント色による細い角丸枠に統一。
760 if self._selection_frame_enabled and self._selected: 760 ↛ 761line 760 didn't jump to line 761 because the condition on line 760 was never true
761 accent = self.palette().highlight().color()
762 pen = QPen(accent)
763 pen.setWidthF(2.0)
764 painter.setPen(pen)
765 painter.setBrush(Qt.BrushStyle.NoBrush)
766 painter.drawRoundedRect(
767 QRectF(self.rect()).adjusted(1.5, 1.5, -1.5, -1.5), 4.0, 4.0
768 )
770 painter.setRenderHint(QPainter.RenderHint.Antialiasing, False)
771 if self._drop_mode == "child": 771 ↛ 773line 771 didn't jump to line 773 because the condition on line 771 was never true
772 # 子化のドロップ先は、半透明の塗り+太い黄色枠で強調する。
773 fill = QColor("#ffca28")
774 fill.setAlpha(60)
775 painter.fillRect(self.rect().adjusted(1, 1, -2, -2), fill)
776 painter.setPen(QPen(QColor("#ffca28"), 3))
777 painter.setBrush(Qt.BrushStyle.NoBrush)
778 painter.drawRect(self.rect().adjusted(1, 1, -2, -2))
779 elif self._drop_mode in ("before", "after"): 779 ↛ 781line 779 didn't jump to line 781 because the condition on line 779 was never true
780 # 並べ替えのドロップ位置を、太い挿入バー+左右の丸で明示する。
781 bar_color = QColor("#ffca28")
782 y = 2 if self._drop_mode == "before" else self.height() - 3
783 painter.setPen(QPen(bar_color, 4, Qt.PenStyle.SolidLine, Qt.PenCapStyle.RoundCap))
784 painter.drawLine(6, y, self.width() - 7, y)
785 painter.setPen(Qt.PenStyle.NoPen)
786 painter.setBrush(bar_color)
787 painter.drawEllipse(QPointF(6, y), 3.5, 3.5)
788 painter.drawEllipse(QPointF(self.width() - 7, y), 3.5, 3.5)
789 painter.end()
791 def mousePressEvent(self, event):
792 if event.button() == Qt.MouseButton.LeftButton:
793 self._press_pos = event.position().toPoint()
794 self._press_modifiers = event.modifiers()
795 self._drag_started = False
796 event.accept()
797 return
798 if event.button() == Qt.MouseButton.RightButton:
799 self.contextMenuRequested.emit(event.globalPosition().toPoint())
800 event.accept()
801 return
802 super().mousePressEvent(event)
804 def mouseMoveEvent(self, event):
805 if (
806 self._press_pos is not None
807 and not self._drag_started
808 and event.buttons() & Qt.MouseButton.LeftButton
809 and self._rgb is not None
810 and not self._is_background
811 ):
812 distance = (
813 event.position().toPoint() - self._press_pos
814 ).manhattanLength()
815 if distance >= QApplication.startDragDistance():
816 self._start_reorder_drag()
817 event.accept()
818 return
819 super().mouseMoveEvent(event)
821 def _start_reorder_drag(self):
822 if self._rgb is None:
823 return
824 self._drag_started = True
825 self.dragStarted.emit()
826 red, green, blue = self._rgb
827 mime = QMimeData()
828 mime.setData(
829 USED_COLOR_MIME,
830 QByteArray(bytes((red, green, blue))),
831 )
832 drag = QDrag(self)
833 drag.setMimeData(mime)
835 # ドラッグ中は運んでいる色を、少し浮き上がったカードとしてカーソルに付ける。
836 scale = self.devicePixelRatioF() if hasattr(self, "devicePixelRatioF") else 1.0
837 pw, ph = 40, 28
838 pixmap = QPixmap(int(pw * scale), int(ph * scale))
839 pixmap.setDevicePixelRatio(scale)
840 pixmap.fill(Qt.GlobalColor.transparent)
841 painter = QPainter(pixmap)
842 painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
843 # 影で「持ち上げている」感を出す。
844 shadow = QColor(0, 0, 0, 70)
845 painter.setPen(Qt.PenStyle.NoPen)
846 painter.setBrush(shadow)
847 painter.drawRoundedRect(QRectF(3, 4, pw - 5, ph - 5), 4, 4)
848 # 運んでいる色本体。
849 painter.setBrush(QColor(red, green, blue))
850 painter.setPen(QPen(QColor("#ffffff"), 1.5))
851 painter.drawRoundedRect(QRectF(1, 1, pw - 6, ph - 7), 4, 4)
852 painter.end()
853 drag.setPixmap(pixmap)
854 drag.setHotSpot(QPoint(int((pw - 6) / 2), int((ph - 7) / 2)))
856 # ドラッグ元の行を半透明にして、掴んでいることが分かるようにする。
857 effect = QGraphicsOpacityEffect(self)
858 effect.setOpacity(0.4)
859 self.setGraphicsEffect(effect)
860 try:
861 drag.exec(Qt.DropAction.MoveAction)
862 finally:
863 self.setGraphicsEffect(None) # pyright: ignore[reportArgumentType] # None clears the effect
864 self._press_pos = None
866 def mouseReleaseEvent(self, event):
867 if event.button() == Qt.MouseButton.LeftButton:
868 was_drag = self._drag_started
869 self._press_pos = None
870 self._drag_started = False
871 if not was_drag:
872 # ドラッグにならなければ、通常の選択トグルとして扱う。
873 self.clicked.emit(self._press_modifiers)
874 event.accept()
875 return
876 super().mouseReleaseEvent(event)
878 def _payload_rgb(self, mime):
879 if not mime.hasFormat(USED_COLOR_MIME):
880 return None
881 data = bytes(mime.data(USED_COLOR_MIME))
882 if len(data) < 3:
883 return None
884 return (data[0], data[1], data[2])
886 def _accepts(self, event):
887 payload = self._payload_rgb(event.mimeData())
888 # 背景行はドロップ先にしない。自分自身へのドロップも無視する。
889 return payload is not None and payload != self._rgb and not self._is_background
891 def dragEnterEvent(self, event):
892 if not self._accepts(event):
893 event.ignore()
894 return
895 event.acceptProposedAction()
896 self._drop_mode = self._mode_for_pos(event.position().toPoint().y())
897 self.update()
899 def dragMoveEvent(self, event):
900 if not self._accepts(event):
901 event.ignore()
902 return
903 event.acceptProposedAction()
904 mode = self._mode_for_pos(event.position().toPoint().y())
905 if mode != self._drop_mode:
906 self._drop_mode = mode
907 self.update()
909 def dragLeaveEvent(self, event):
910 if self._drop_mode is not None:
911 self._drop_mode = None
912 self.update()
914 def dropEvent(self, event):
915 mode = self._drop_mode or self._mode_for_pos(
916 event.position().toPoint().y()
917 )
918 self._drop_mode = None
919 self.update()
920 if not self._accepts(event):
921 event.ignore()
922 return
923 event.acceptProposedAction()
924 self.colorDropped.emit(self._payload_rgb(event.mimeData()), mode)
926 def contextMenuEvent(self, event):
927 self.contextMenuRequested.emit(event.globalPos())
928 event.accept()