Coverage for paintmaskanimator/color_panel.py: 67%
942 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 used-color panel — the list of colours in use in the document.
3Architecture: the row widgets it composes (swatch button, the three checkbox
4variants, the swatch row) live in ``color_panel_widgets.py``; cohesive method
5clusters were extracted into ``color_panel_<topic>.py`` as ``*Mixin`` classes and
6composed onto ``UsedColorPanel`` below (grouping/reorder, selection, visibility,
7mask colours, history snapshots). What remains here is the panel itself: the
8widget tree, building and refreshing colour rows from a colour list, swatch
9styling, and the row context menu. Type-only member declarations shared by the
10mixins live in ``_color_panel_members.py``.
11"""
12from typing import Any
13from PySide6.QtCore import QPoint, Qt, Signal
14from PySide6.QtGui import QColor, QCursor
15from PySide6.QtWidgets import (
16 QApplication,
17 QGridLayout,
18 QHBoxLayout,
19 QInputDialog,
20 QLabel,
21 QMenu,
22 QMessageBox,
23 QPushButton,
24 QScrollArea,
25 QSizePolicy,
26 QStyle,
27 QVBoxLayout,
28 QWidget,
29)
30from . import icons
31from .i18n import tr
32from .utils import on_double_click
33from .constants import ALT_KEY_LABEL, CTRL_KEY_LABEL
34from .logging_setup import get_logger
37from .color_panel_grouping import ColorGroupingMixin
38from .color_panel_history import ColorPanelHistoryMixin
39from .color_panel_mask import MaskColorsMixin
40from .color_panel_selection import ColorSelectionMixin
41from .color_panel_visibility import ColorVisibilityMixin
42from .color_panel_widgets import (
43 CheckClickArea,
44 ColorCategoryHeader,
45 ColorSelectionArea,
46 ColorSelectionCheckBox,
47 ColorVisibilityCheckBox,
48 MaskColorCheckBox,
49 ReplacementColorPopup,
50 ScreenEyedropButton,
51 SourceColorButton,
52)
54log = get_logger(__name__)
57class UsedColorPanel(
58 ColorGroupingMixin, ColorSelectionMixin, ColorVisibilityMixin,
59 MaskColorsMixin, ColorPanelHistoryMixin, QWidget
60):
61 mainColorRequested = Signal(QColor)
62 isolateColorClicked = Signal(QColor)
63 clearIsolateRequested = Signal()
64 sourceScreenColorPicked = Signal(QColor)
65 applyReplacementRequested = Signal(object)
66 # 色置換セットの保存・読み込み(Issue #42)。
67 saveReplacementSetRequested = Signal()
68 loadReplacementSetRequested = Signal()
69 mergeColorsRequested = Signal(object, object)
70 # 親子関係の更新時に旧色プレビューを解除する通知。
71 previewGroupsChanged = Signal(object)
72 deleteColorsRequested = Signal(object)
73 adjustLineThicknessRequested = Signal(object)
74 # 使用色の色相・彩度・明度調整(Issue #22)。
75 adjustHsvRequested = Signal(object)
76 focusColorRequested = Signal(object)
77 maskColorsChanged = Signal(object)
78 selectedColorsChanged = Signal(object)
79 visibleColorsChanged = Signal(object)
80 # 並べ替え・親子・表示/マスクの変更をUndo履歴へ積む要求。
81 # (変更前スナップショット, 履歴ラベル) を渡す。
82 historyStatePush = Signal(object, str)
84 # RGB-keyed state. Declared here so every assignment widens to plain
85 # ``int`` tuples instead of pyright inferring ``Literal[255]`` keys from the
86 # ``(255, 255, 255)`` background seed (which then rejected real colours).
87 enabled_colors: dict[tuple[int, int, int], bool]
88 mask_rgbs: set[tuple[int, int, int]]
90 def __init__(self, parent=None):
91 super().__init__(parent)
92 self.setMinimumWidth(300)
93 self.setMaximumWidth(16777215)
94 self.setSizePolicy(
95 QSizePolicy.Policy.Expanding,
96 QSizePolicy.Policy.Expanding,
97 )
98 self.setMinimumHeight(0)
99 self.background_rgb: tuple[int, int, int] = (255, 255, 255)
100 # 背景色の行に見せる色。画素としての背景は白/透明のままだが、
101 # キャンバスでは「背景色の表示色」で見えているので、それに合わせる。
102 self.background_display_color = QColor(*self.background_rgb)
103 self.colors = []
104 self.enabled_colors = {self.background_rgb: True}
105 self.mask_rgbs = {self.background_rgb}
106 self._mask_all_mode = True
108 # 親子グループ。{子rgb: 親rgb}。親自身は含めない。
109 # ドラッグで子付けしても、キャンバス上の色表示は変更しない。
110 self.child_to_parent = {}
112 # カラーチャート適用時の役割情報。使用色一覧は色を簡潔に見せるため
113 # タグ文字を常時描画しないが、再登録・PMAG保存用に保持する。
114 self.child_tags = {}
115 self.parent_tags = {}
116 self.tag_library = ["Base"]
117 self.tag_colors = {"Base": "#FFFFFF"}
118 self.tag_order = []
120 # 使用色カテゴリー(1色につき1フォルダー、1階層)。
121 self.category_order = []
122 self.category_colors = {}
123 self.category_collapsed = {}
124 self.category_widgets = {}
125 self.replacements = {}
126 self.replacement_buttons = {}
127 self._replacement_popup = None
128 self._replacement_popup_rgb = None
130 # Used-color selection is separate from the drawing mask.
131 # The most recently selected color is the parent; the others are children.
132 self.selected_rgbs = []
133 self.parent_rgb = None
134 self._selection_anchor_rgb = None
136 self.visibility_checks = {}
137 self.mask_checks = {}
138 self.selection_checks = {}
139 self.source_buttons = {}
140 self.source_wrappers = {}
141 self.row_widgets = {}
143 # ホバー中の色(HEXはホバー時のみ表示して色面積を最大化する)。
144 self._hovered_rgb = None
146 self._selection_sweep_active = False
147 self._selection_sweep_state = True
148 self._selection_sweep_touched = set()
149 self._selection_sweep_changed = False
151 self._visibility_sweep_active = False
152 self._visibility_sweep_state = True
153 self._visibility_sweep_touched = set()
154 self._visibility_sweep_changed = False
155 self._mask_sweep_active = False
156 self._mask_sweep_state = True
157 self._mask_sweep_touched = set()
158 self._mask_sweep_changed = False
159 self._isolated_rgb = None
160 self._pre_isolate_enabled = None
161 self._mask_alt_rgb = None
162 self._pre_mask_alt = None
164 # Undo履歴用。復元中は再記録を止め、スウィープ中は開始時状態を保持する。
165 self._history_suspended = False
166 self._sweep_history_before = None
168 layout = QVBoxLayout(self)
169 layout.setContentsMargins(3, 3, 3, 3)
170 layout.setSpacing(2)
171 # パネル名はドックのタブに出ているので、色数とフォルダー作成を1行に置く。
172 top_row = QHBoxLayout()
173 top_row.setContentsMargins(2, 0, 0, 2)
174 top_row.setSpacing(4)
175 self.count_label = QLabel(tr("0色"))
176 self.count_label.setStyleSheet("font-weight:600;")
177 top_row.addWidget(self.count_label)
178 top_row.addStretch(1)
179 self.category_button = QPushButton(tr("フォルダー"))
180 self.category_button.setIcon(icons.icon("folder_plus"))
181 self.category_button.setFixedHeight(24)
182 self.category_button.setToolTip(tr("使用色をまとめるフォルダーを作成します。"))
183 self.category_button.clicked.connect(self._prompt_create_category)
184 top_row.addWidget(self.category_button)
185 layout.addLayout(top_row)
187 header_widget = QWidget()
188 header = QGridLayout(header_widget)
189 scrollbar_width = self.style().pixelMetric(
190 QStyle.PixelMetric.PM_ScrollBarExtent
191 )
192 header.setContentsMargins(1, 0, 1 + scrollbar_width, 0)
193 header.setHorizontalSpacing(0)
194 header.setColumnMinimumWidth(0, 32)
195 header.setColumnMinimumWidth(1, 32)
196 header.setColumnMinimumWidth(2, 32)
197 selection_header = QLabel(tr("選択"))
198 selection_header.setAlignment(Qt.AlignmentFlag.AlignCenter)
199 selection_header.setStyleSheet("font-size:10px;color:palette(placeholder-text);")
200 selection_header.setToolTip(
201 tr("クリック:この色だけ選択/Shift+クリック:範囲選択/"
202 "{ctrl}+クリック:追加・解除。チェックと選択は連動します。").format(
203 ctrl=CTRL_KEY_LABEL)
204 )
205 header.addWidget(selection_header, 0, 0, Qt.AlignmentFlag.AlignCenter)
206 visibility_header = QLabel(tr("表示"))
207 visibility_header.setAlignment(Qt.AlignmentFlag.AlignCenter)
208 visibility_header.setStyleSheet("font-size:10px;color:palette(placeholder-text);")
209 visibility_header.setToolTip(tr("各行の薄い背景セル全体を右クリックして表示メニューを開けます。"))
210 header.addWidget(visibility_header, 0, 1, Qt.AlignmentFlag.AlignCenter)
211 mask_header = QLabel(tr("描画対象"))
212 mask_header.setAlignment(Qt.AlignmentFlag.AlignCenter)
213 mask_header.setStyleSheet("font-size:10px;color:palette(placeholder-text);")
214 mask_header.setToolTip(
215 tr("ONにした色の上へ描けます。各行の薄い背景セル全体を"
216 "右クリックして描画対象メニューを開けます。")
217 )
218 header.addWidget(mask_header, 0, 2, Qt.AlignmentFlag.AlignCenter)
219 # 列見出しは短くし、操作の説明はツールチップへ回す(以前は隣の列と重なって切れていた)。
220 color_header = QLabel(tr("色"))
221 color_header.setStyleSheet(
222 "font-size:10px;padding-left:6px;color:palette(placeholder-text);"
223 )
224 color_header.setToolTip(tr("ドラッグで並べ替え/親子付け"))
225 header.addWidget(color_header, 0, 3)
226 replacement_header = QLabel(tr("置換色"))
227 replacement_header.setStyleSheet("font-size:10px;color:palette(placeholder-text);")
228 replacement_header.setAlignment(Qt.AlignmentFlag.AlignCenter)
229 header.addWidget(replacement_header, 0, 4)
230 header.setColumnMinimumWidth(3, 144)
231 header.setColumnMinimumWidth(4, 72)
232 header.setColumnStretch(3, 1)
233 header.setColumnStretch(4, 1)
234 layout.addWidget(header_widget)
236 self.scroll: QScrollArea = QScrollArea()
237 self.scroll.setWidgetResizable(True)
238 self.scroll.setMinimumHeight(0)
239 self.scroll.setHorizontalScrollBarPolicy(
240 Qt.ScrollBarPolicy.ScrollBarAlwaysOff
241 )
242 self.scroll.setVerticalScrollBarPolicy(
243 Qt.ScrollBarPolicy.ScrollBarAlwaysOn
244 )
245 self.content = QWidget()
246 self.rows = QVBoxLayout(self.content)
247 self.rows.setContentsMargins(2, 2, 2, 2)
248 self.rows.setSpacing(1)
249 self.rows.addStretch(1)
250 self.scroll.setWidget(self.content)
251 layout.addWidget(self.scroll, 1)
253 # 見出しと同じ5列に配置し、各ボタンの意味と位置を揃える。
254 button_row = QGridLayout()
255 button_row.setHorizontalSpacing(0)
256 button_row.setContentsMargins(1, 0, 1 + scrollbar_width, 0)
257 button_row.setColumnMinimumWidth(0, 32)
258 button_row.setColumnMinimumWidth(1, 32)
259 button_row.setColumnMinimumWidth(2, 32)
260 button_row.setColumnMinimumWidth(3, 144)
261 button_row.setColumnMinimumWidth(4, 72)
262 button_row.setColumnStretch(3, 1)
263 button_row.setColumnStretch(4, 1)
265 self.clear_selection_button = QPushButton(tr("選択"))
266 self.clear_selection_button.setToolTip(tr("選択をすべて解除します。"))
267 self.clear_selection_button.clicked.connect(self._clear_used_color_selection)
268 self.clear_selection_button.setFixedWidth(32)
269 self.clear_selection_button.setStyleSheet("font-size:10px;padding:0px;")
270 self.clear_selection_button.setFixedHeight(22)
272 self.show_all_button = QPushButton(tr("全表示"))
273 self.show_all_button.setToolTip(
274 tr("非表示にした使用色と背景色をすべて表示します。")
275 )
276 self.show_all_button.clicked.connect(self._show_all_colors)
277 self.show_all_button.setFixedWidth(32)
278 self.show_all_button.setStyleSheet("font-size:10px;padding:0px;")
279 self.show_all_button.setFixedHeight(22)
281 self.clear_masks_button = QPushButton(tr("全体"))
282 self.clear_masks_button.setToolTip(
283 tr("すべての使用色と背景をマスクON(描画可能)にします。")
284 )
285 self.clear_masks_button.clicked.connect(self._set_all_masks_on)
286 self.clear_masks_button.setFixedWidth(32)
287 self.clear_masks_button.setStyleSheet("font-size:10px;padding:0px;")
288 self.clear_masks_button.setFixedHeight(22)
290 # 統合は親子付けとは独立した操作。複数選択した色を、最後に選んだ色へ
291 # 実画像上で塗り替える。親子付けは整理専用で、色は変えない。
292 self.merge_button = QPushButton(tr("統合"))
293 self.merge_button.setToolTip(
294 tr("選択した使用色(2色以上)を、最後に選んだ色へ統合します。"
295 "実際の画像を塗り替えます。Undoで元に戻せます。")
296 )
297 self.merge_button.clicked.connect(self._emit_merge)
298 self.merge_button.setEnabled(False)
299 self.merge_button.setMinimumWidth(72)
300 self.merge_button.setMaximumWidth(16777215)
301 self.merge_button.setSizePolicy(
302 QSizePolicy.Policy.Expanding,
303 QSizePolicy.Policy.Fixed,
304 )
305 self.merge_button.setStyleSheet("font-size:11px;padding:1px;")
306 self.merge_button.setFixedHeight(22)
307 self.selectedColorsChanged.connect(self._refresh_merge_button)
309 self.apply_button = QPushButton(tr("色置換"))
310 self.apply_button.setToolTip(
311 tr("登録した置換色を、選択レイヤーのすべてのコマへ適用します。")
312 )
313 self.apply_button.clicked.connect(self._emit_replacements)
314 # よく使う置換はファイルへ出し入れできる(Issue #42)。
315 self.apply_button.setContextMenuPolicy(
316 Qt.ContextMenuPolicy.CustomContextMenu
317 )
318 self.apply_button.customContextMenuRequested.connect(
319 self._show_replacement_set_menu
320 )
321 self.apply_button.setMinimumWidth(72)
322 self.apply_button.setSizePolicy(
323 QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed
324 )
325 self.apply_button.setStyleSheet("font-size:11px;padding:1px;")
326 self.apply_button.setFixedHeight(22)
328 for column, button in enumerate((
329 self.clear_selection_button,
330 self.show_all_button,
331 self.clear_masks_button,
332 )):
333 button.setMinimumWidth(0)
334 button_row.addWidget(button, 0, column)
335 # 選択色の統合は色スウォッチ列(col3)に置く。
336 action_buttons = QHBoxLayout()
337 action_buttons.setContentsMargins(0, 0, 0, 0)
338 action_buttons.setSpacing(2)
339 self.merge_button.setMinimumWidth(0)
340 action_buttons.addWidget(self.merge_button)
341 button_row.addLayout(action_buttons, 0, 3)
342 button_row.addWidget(self.apply_button, 0, 4)
343 layout.addLayout(button_row)
344 self.visibleColorsChanged.connect(self._refresh_category_headers)
346 def apply_theme(self):
347 """Re-apply palette-derived icons after a theme/accent change."""
348 self.category_button.setIcon(icons.icon("folder_plus"))
350 @staticmethod
351 def _rgb_key(color):
352 qc = QColor(color)
353 return (qc.red(), qc.green(), qc.blue())
355 def _background_display_hex(self):
356 return self.background_display_color.name(QColor.NameFormat.HexRgb).upper()
358 def set_background_display_color(self, color):
359 """背景色の表示色が変わったら、背景色の行の見本も同じ色にする。"""
360 color = QColor(color)
361 if not color.isValid() or color == self.background_display_color:
362 return
363 self.background_display_color = color
364 if self.background_rgb in self.source_buttons: 364 ↛ exitline 364 didn't return from function 'set_background_display_color' because the condition on line 364 was always true
365 self._set_source_button_style(self.background_rgb)
367 @staticmethod
368 def _text_color(rgb):
369 luminance = 0.299 * rgb[0] + 0.587 * rgb[1] + 0.114 * rgb[2]
370 return "#111111" if luminance >= 150 else "#ffffff"
372 def _set_replacement_button_style(self, source_rgb):
373 button = self.replacement_buttons.get(source_rgb)
374 if button is None: 374 ↛ 375line 374 didn't jump to line 375 because the condition on line 374 was never true
375 return
376 replacement_color = self.replacements.get(source_rgb)
377 if replacement_color is None:
378 button.setText(tr("未設定"))
379 button.setStyleSheet(
380 "QToolButton{background:#f2f2f2;border:1px dashed #888;}"
381 "QToolButton:hover{border:1px dashed #888;}"
382 )
383 return
384 button.setText(
385 replacement_color.name(QColor.NameFormat.HexRgb).upper()
386 )
387 replacement_rgb = (
388 replacement_color.red(),
389 replacement_color.green(),
390 replacement_color.blue(),
391 )
392 button.setStyleSheet(
393 "QToolButton{"
394 f"background:{replacement_color.name()};"
395 f"color:{self._text_color(replacement_rgb)};"
396 "border:1px solid #777;}"
397 "QToolButton:hover{border:1px solid #777;}"
398 )
400 def _toggle_replacement(self, source_rgb):
401 """未設定なら現在色を登録し、登録済みなら解除する。"""
402 if source_rgb == self.background_rgb:
403 return
404 if source_rgb in self.replacements:
405 self._clear_replacement(source_rgb)
406 else:
407 self._request_replacement(source_rgb)
409 def _close_replacement_editor(self):
410 popup = self._replacement_popup
411 self._replacement_popup = None
412 self._replacement_popup_rgb = None
413 if popup is not None:
414 # 閉じるだけなら編集した色を残す(キャンセル扱いで元に戻さない)。
415 popup.accept()
416 popup.deleteLater()
418 def _replacement_popup_destroyed(self, popup):
419 if self._replacement_popup is popup: 419 ↛ 420line 419 didn't jump to line 420 because the condition on line 419 was never true
420 self._replacement_popup = None
421 self._replacement_popup_rgb = None
423 def open_replacement_editor(self, source_rgb):
424 """置換色のカラーピッカーを開く(開いていれば閉じずにそのまま)。"""
425 if ( 425 ↛ 430line 425 didn't jump to line 430 because the condition on line 425 was never true
426 self._replacement_popup is not None
427 and self._replacement_popup.isVisible()
428 and self._replacement_popup_rgb == source_rgb
429 ):
430 self._replacement_popup.raise_()
431 self._replacement_popup.activateWindow()
432 return
433 self._toggle_replacement_editor(source_rgb, QCursor.pos())
435 def _toggle_replacement_editor(self, source_rgb, global_position):
436 """左クリックでカラーピッカーを開き、再度の左クリックで閉じる。"""
437 if source_rgb == self.background_rgb: 437 ↛ 438line 437 didn't jump to line 438 because the condition on line 437 was never true
438 return
439 if ( 439 ↛ 444line 439 didn't jump to line 444 because the condition on line 439 was never true
440 self._replacement_popup is not None
441 and self._replacement_popup.isVisible()
442 and self._replacement_popup_rgb == source_rgb
443 ):
444 self._close_replacement_editor()
445 return
447 self._close_replacement_editor()
448 color = self.replacements.get(source_rgb)
449 if color is None: 449 ↛ 462line 449 didn't jump to line 462 because the condition on line 449 was always true
450 window = self.window()
451 canvas = getattr(window, "canvas", None)
452 if canvas is not None: 452 ↛ 459line 452 didn't jump to line 459 because the condition on line 452 was always true
453 color = (
454 canvas.sub_color
455 if canvas.color_mode == "sub"
456 else canvas.main_color
457 )
458 else:
459 color = QColor(*source_rgb)
460 self._set_replacement_color(source_rgb, color)
462 popup = ReplacementColorPopup(
463 QColor(color), source_rgb, self,
464 preview=lambda changed, rgb=source_rgb:
465 self._set_replacement_color(rgb, changed),
466 )
467 self._replacement_popup = popup
468 self._replacement_popup_rgb = source_rgb
469 popup.destroyed.connect(
470 lambda _obj=None, current=popup:
471 self._replacement_popup_destroyed(current)
472 )
474 popup.adjustSize()
475 screen = (
476 QApplication.screenAt(global_position)
477 or QApplication.primaryScreen()
478 )
479 position = QPoint(global_position)
480 if screen is not None: 480 ↛ 492line 480 didn't jump to line 492 because the condition on line 480 was always true
481 available = screen.availableGeometry()
482 width = popup.sizeHint().width()
483 height = popup.sizeHint().height()
484 position.setX(max(
485 available.left(),
486 min(position.x(), available.right() - width + 1),
487 ))
488 position.setY(max(
489 available.top(),
490 min(position.y(), available.bottom() - height + 1),
491 ))
492 popup.move(position)
493 popup.show()
494 popup.raise_()
495 popup.activateWindow()
497 def _request_replacement(self, source_rgb):
498 if source_rgb == self.background_rgb:
499 return
500 canvas = getattr(self.window(), "canvas", None)
501 if canvas is None:
502 return
503 color = (
504 canvas.sub_color
505 if canvas.color_mode == "sub"
506 else canvas.main_color
507 )
508 self._set_replacement_color(source_rgb, color)
510 def register_replacements(self, mapping):
511 registered = 0
512 for source_rgb, target_rgb in mapping.items():
513 source_rgb = tuple(int(value) for value in source_rgb[:3])
514 target_rgb = tuple(int(value) for value in target_rgb[:3])
515 if (
516 source_rgb == self.background_rgb
517 or source_rgb not in self.replacement_buttons
518 ):
519 continue
520 self._set_replacement_color(source_rgb, QColor(*target_rgb))
521 registered += 1
522 return registered
524 def _set_replacement_color(self, source_rgb, color):
525 if source_rgb == self.background_rgb: 525 ↛ 526line 525 didn't jump to line 526 because the condition on line 525 was never true
526 return
527 replacement = QColor(color)
528 if not replacement.isValid(): 528 ↛ 529line 528 didn't jump to line 529 because the condition on line 528 was never true
529 return
530 self.replacements[source_rgb] = replacement
531 self._set_replacement_button_style(source_rgb)
533 def _clear_replacement(self, source_rgb):
534 if source_rgb == self.background_rgb: 534 ↛ 535line 534 didn't jump to line 535 because the condition on line 534 was never true
535 return
536 if self._replacement_popup_rgb == source_rgb: 536 ↛ 537line 536 didn't jump to line 537 because the condition on line 536 was never true
537 self._close_replacement_editor()
538 self.replacements.pop(source_rgb, None)
539 self._set_replacement_button_style(source_rgb)
541 def current_replacements(self):
542 """いま登録している ``{元の RGB: 置換後の RGB}``。"""
543 return {
544 tuple(source): (color.red(), color.green(), color.blue())
545 for source, color in self.replacements.items()
546 if tuple(source) != self.background_rgb
547 }
549 def _show_replacement_set_menu(self, pos):
550 menu = QMenu(self)
551 save = menu.addAction(tr("置換色を保存…"))
552 save.setEnabled(bool(self.current_replacements()))
553 load = menu.addAction(tr("置換色を読み込む…"))
554 chosen = menu.exec(self.apply_button.mapToGlobal(pos))
555 if chosen is save:
556 self.saveReplacementSetRequested.emit()
557 elif chosen is load:
558 self.loadReplacementSetRequested.emit()
560 def _emit_replacements(self):
561 mapping = {
562 tuple(source): (color.red(), color.green(), color.blue())
563 for source, color in self.replacements.items()
564 if tuple(source) != self.background_rgb
565 and tuple(source) in self.replacement_buttons
566 }
567 self.applyReplacementRequested.emit(mapping)
569 def _unique_category_name(self, requested):
570 base = str(requested or "").strip() or tr("新規フォルダー")
571 name = base
572 suffix = 2
573 existing = {value.casefold() for value in self.category_order}
574 while name.casefold() in existing: 574 ↛ 575line 574 didn't jump to line 575 because the condition on line 574 was never true
575 name = f"{base} {suffix}"
576 suffix += 1
577 return name
579 def help_text(self) -> str:
580 """パネル上部に常時表示していた説明。ヘルプボタンから開く。"""
581 return tr("使用色:クリックで選択(Shift=範囲/Ctrl=追加)。"
582 "[+フォルダー]でカテゴリーを作り、色をヘッダーへドラッグして格納。"
583 "フォルダーのチェックで所属色を一括表示/非表示。"
584 "ドラッグで並べ替え、色の中央へドロップ=その色の「子」として整理。"
585 "親子付けはフォルダーのような整理用で、色は変わりません。"
586 "解除は右クリックから行えます。"
587 "色を2色以上選んで[統合]を押すと、最後に選んだ色へ実画像を塗り替えます。")
589 def show_help(self) -> None:
590 """使用色パネルの使い方をダイアログで表示する。"""
591 box = QMessageBox(self)
592 box.setWindowTitle(tr("使用色パネルの使い方"))
593 box.setIcon(QMessageBox.Icon.Information)
594 box.setTextFormat(Qt.TextFormat.PlainText)
595 box.setText(self.help_text())
596 box.exec()
598 def _prompt_create_category(self):
599 name, accepted = QInputDialog.getText(
600 self, tr("使用色フォルダー"), tr("フォルダー名:"),
601 text=self._unique_category_name(tr("新規フォルダー")),
602 )
603 if accepted and str(name).strip():
604 self.create_category(str(name).strip())
606 def create_category(self, name, record_history=True):
607 name = self._unique_category_name(name)
608 before = self.capture_history_state() if record_history else None
609 self.category_order.append(name)
610 self.category_collapsed[name] = False
611 self._ensure_category_header(name)
612 self._reapply_row_order()
613 self._refresh_category_headers()
614 if record_history:
615 self._record_history(tr("使用色フォルダーを作成"), before)
616 return name
618 def _ensure_category_header(self, name):
619 header = self.category_widgets.get(name)
620 if header is not None:
621 header.setName(name)
622 return header
623 header = ColorCategoryHeader(name, self.content)
624 header.visibilityChanged.connect(
625 lambda visible, category=name:
626 self._set_category_visibility(category, visible)
627 )
628 header.collapsedChanged.connect(
629 lambda collapsed, category=name:
630 self._set_category_collapsed(category, collapsed)
631 )
632 header.colorDropped.connect(
633 lambda rgb, category=name:
634 self._move_colors_to_category(rgb, category)
635 )
636 header.contextMenuRequested.connect(
637 lambda global_pos, category=name:
638 self._show_category_context_menu(category, global_pos)
639 )
640 self.category_widgets[name] = header
641 return header
643 def _sync_category_headers(self):
644 for name in list(self.category_widgets):
645 if name in self.category_order:
646 continue
647 header = self.category_widgets.pop(name)
648 self.rows.removeWidget(header)
649 header.deleteLater()
650 for name in self.category_order:
651 self._ensure_category_header(name)
653 def _category_members(self, name, existing_only=True):
654 existing = set(self.source_buttons)
655 return [
656 self._rgb_key(color) for color in self.colors
657 if self.category_colors.get(self._rgb_key(color)) == name
658 and self._rgb_key(color) != self.background_rgb
659 and (not existing_only or self._rgb_key(color) in existing)
660 ]
662 def _refresh_category_headers(self, *_args):
663 for name in self.category_order:
664 header = self.category_widgets.get(name)
665 if header is None: 665 ↛ 666line 665 didn't jump to line 666 because the condition on line 665 was never true
666 continue
667 members = self._category_members(name)
668 header.setVisibilityState(
669 [self.enabled_colors.get(rgb, True) for rgb in members]
670 )
671 header.name_label.setText(tr("📁 {name}({len}色)").format(name=name, len=len(members)))
672 header.setToolTip(
673 tr("フォルダーへ色をドラッグして格納できます。"
674 "右クリックで名前変更・削除ができます。")
675 )
677 def _set_category_collapsed(self, name, collapsed):
678 if name not in self.category_order: 678 ↛ 679line 678 didn't jump to line 679 because the condition on line 678 was never true
679 return
680 self.category_collapsed[name] = bool(collapsed)
681 self._reapply_row_order()
683 def _set_category_visibility(self, name, visible):
684 members = self._category_members(name)
685 if not members: 685 ↛ 686line 685 didn't jump to line 686 because the condition on line 685 was never true
686 self._refresh_category_headers()
687 return
688 with self._history_edit(tr("使用色フォルダーの表示切り替え")):
689 self._isolated_rgb = None
690 self._pre_isolate_enabled = None
691 for rgb in members:
692 self._set_checkbox_without_signal(rgb, bool(visible))
693 self.visibleColorsChanged.emit(self.enabled_rgb_set())
695 def _move_colors_to_category(self, dragged_rgb, category):
696 if category not in self.category_order: 696 ↛ 697line 696 didn't jump to line 697 because the condition on line 696 was never true
697 return
698 dragged_rgb = tuple(dragged_rgb)
699 selected = {tuple(rgb) for rgb in self.selected_rgbs}
700 sources = selected if dragged_rgb in selected else {dragged_rgb}
701 sources.discard(self.background_rgb)
702 if not sources: 702 ↛ 703line 702 didn't jump to line 703 because the condition on line 702 was never true
703 return
704 with self._history_edit(tr("使用色をフォルダーへ移動")):
705 for rgb in sources:
706 self.category_colors[rgb] = category
707 self.category_collapsed[category] = False
708 header = self.category_widgets.get(category)
709 if header is not None: 709 ↛ 711line 709 didn't jump to line 711 because the condition on line 709 was always true
710 header.setCollapsed(False)
711 self._reapply_row_order()
712 self._refresh_category_headers()
714 def _move_colors_to_uncategorized(self, sources):
715 sources = {tuple(rgb) for rgb in sources}
716 with self._history_edit(tr("使用色をフォルダーから移動")):
717 for rgb in sources:
718 self.category_colors.pop(rgb, None)
719 self._reapply_row_order()
720 self._refresh_category_headers()
722 def _show_category_context_menu(self, name, global_position):
723 if name not in self.category_order:
724 return
725 menu = QMenu(self)
726 rename_action = menu.addAction(tr("名前を変更…"))
727 delete_action = menu.addAction(tr("フォルダーを削除"))
728 chosen = menu.exec(global_position)
729 if chosen is rename_action:
730 new_name, accepted = QInputDialog.getText(
731 self, tr("使用色フォルダー"), tr("フォルダー名:"), text=name
732 )
733 new_name = str(new_name).strip()
734 if accepted and new_name and new_name != name:
735 self.rename_category(name, new_name)
736 elif chosen is delete_action:
737 self.delete_category(name)
739 def rename_category(self, old_name, new_name):
740 if old_name not in self.category_order:
741 return
742 if new_name.casefold() != old_name.casefold():
743 new_name = self._unique_category_name(new_name)
744 before = self.capture_history_state()
745 index = self.category_order.index(old_name)
746 self.category_order[index] = new_name
747 for rgb, category in list(self.category_colors.items()):
748 if category == old_name:
749 self.category_colors[rgb] = new_name
750 collapsed = self.category_collapsed.pop(old_name, False)
751 self.category_collapsed[new_name] = collapsed
752 old_header = self.category_widgets.pop(old_name, None)
753 if old_header is not None:
754 self.rows.removeWidget(old_header)
755 old_header.deleteLater()
756 self._ensure_category_header(new_name).setCollapsed(collapsed)
757 self._reapply_row_order()
758 self._refresh_category_headers()
759 self._record_history(tr("使用色フォルダー名を変更"), before)
761 def delete_category(self, name, record_history=True):
762 if name not in self.category_order:
763 return
764 before = self.capture_history_state() if record_history else None
765 self.category_order.remove(name)
766 self.category_collapsed.pop(name, None)
767 for rgb, category in list(self.category_colors.items()):
768 if category == name:
769 self.category_colors.pop(rgb, None)
770 header = self.category_widgets.pop(name, None)
771 if header is not None:
772 self.rows.removeWidget(header)
773 header.deleteLater()
774 self._reapply_row_order()
775 if record_history:
776 self._record_history(tr("使用色フォルダーを削除"), before)
778 def clear_categories(self):
779 for header in self.category_widgets.values(): 779 ↛ 780line 779 didn't jump to line 780 because the loop on line 779 never started
780 self.rows.removeWidget(header)
781 header.deleteLater()
782 self.category_order = []
783 self.category_colors = {}
784 self.category_collapsed = {}
785 self.category_widgets = {}
786 self._reapply_row_order()
788 def serialize_categories(self):
789 return {
790 "folders": [
791 {
792 "name": name,
793 "collapsed": bool(self.category_collapsed.get(name, False)),
794 }
795 for name in self.category_order
796 ],
797 "assignments": {
798 "#{:02X}{:02X}{:02X}".format(*rgb): category
799 for rgb, category in self.category_colors.items()
800 if category in self.category_order
801 },
802 "enabled": {
803 "#{:02X}{:02X}{:02X}".format(*rgb): bool(enabled)
804 for rgb, enabled in self.enabled_colors.items()
805 },
806 }
808 def remap_saved_color_metadata(self, mapping):
809 """色置換後も親子・タグ・フォルダー情報を新RGBへ引き継ぐ。"""
810 def rgb3(value) -> tuple[int, int, int]:
811 return (int(value[0]), int(value[1]), int(value[2]))
813 normalized = {
814 rgb3(source): rgb3(destination)
815 for source, destination in dict(mapping or {}).items()
816 }
818 def remap(rgb):
819 key = rgb3(rgb)
820 return normalized.get(key, key)
822 groups = {}
823 for child, parent in self.child_to_parent.items(): 823 ↛ 824line 823 didn't jump to line 824 because the loop on line 823 never started
824 new_child, new_parent = remap(child), remap(parent)
825 if (
826 new_child != self.background_rgb
827 and new_parent != self.background_rgb
828 and new_child != new_parent
829 ):
830 groups[new_child] = new_parent
831 self.child_to_parent = groups
833 self.child_tags = {
834 remap(rgb): tag for rgb, tag in self.child_tags.items()
835 if remap(rgb) != self.background_rgb
836 }
837 self.parent_tags = {
838 remap(rgb): tag for rgb, tag in self.parent_tags.items()
839 if remap(rgb) != self.background_rgb
840 }
841 self.category_colors = {
842 remap(rgb): category for rgb, category in self.category_colors.items()
843 if remap(rgb) != self.background_rgb
844 }
845 self.enabled_colors = {
846 remap(rgb): bool(enabled)
847 for rgb, enabled in self.enabled_colors.items()
848 }
849 self.enabled_colors[self.background_rgb] = True
850 self.mask_rgbs = {remap(rgb) for rgb in self.mask_rgbs}
851 self.selected_rgbs = list(dict.fromkeys(
852 remap(rgb) for rgb in self.selected_rgbs
853 if remap(rgb) != self.background_rgb
854 ))
855 self.parent_rgb = (
856 remap(self.parent_rgb) if self.parent_rgb is not None else None
857 )
859 def restore_categories(self, payload):
860 if not isinstance(payload, dict): 860 ↛ 861line 860 didn't jump to line 861 because the condition on line 860 was never true
861 self.clear_categories()
862 return
863 self.clear_categories()
864 folders = payload.get("folders", [])
865 if isinstance(folders, list): 865 ↛ 876line 865 didn't jump to line 876 because the condition on line 865 was always true
866 for entry in folders:
867 if not isinstance(entry, dict): 867 ↛ 868line 867 didn't jump to line 868 because the condition on line 867 was never true
868 continue
869 name = str(entry.get("name", "")).strip()
870 if not name: 870 ↛ 871line 870 didn't jump to line 871 because the condition on line 870 was never true
871 continue
872 name = self.create_category(name, record_history=False)
873 collapsed = bool(entry.get("collapsed", False))
874 self.category_collapsed[name] = collapsed
875 self.category_widgets[name].setCollapsed(collapsed)
876 assignments = payload.get("assignments", {})
877 if isinstance(assignments, dict): 877 ↛ 882line 877 didn't jump to line 882 because the condition on line 877 was always true
878 for color_hex, category in assignments.items():
879 color = QColor(str(color_hex))
880 if color.isValid() and category in self.category_order: 880 ↛ 878line 880 didn't jump to line 878 because the condition on line 880 was always true
881 self.category_colors[self._rgb_key(color)] = category
882 enabled = payload.get("enabled", {})
883 if isinstance(enabled, dict): 883 ↛ 889line 883 didn't jump to line 889 because the condition on line 883 was always true
884 for color_hex, visible in enabled.items():
885 color = QColor(str(color_hex))
886 rgb = self._rgb_key(color)
887 if color.isValid() and rgb in self.visibility_checks: 887 ↛ 884line 887 didn't jump to line 884 because the condition on line 887 was always true
888 self._set_checkbox_without_signal(rgb, bool(visible))
889 self._reapply_row_order()
890 self._refresh_category_headers()
891 self.visibleColorsChanged.emit(self.enabled_rgb_set())
893 def _group_line_color(self, rgb):
894 """rgb が親子グループに属していれば、束ねる縦ライン色(ルート親色)を返す。"""
895 if rgb in self.child_to_parent:
896 root = self._group_root(rgb)
897 return QColor(*root)
898 if any(parent == rgb for parent in self.child_to_parent.values()):
899 return QColor(*rgb)
900 return None
902 def _apply_swatch_text(self, rgb):
903 """色スウォッチにカラーコードを常時表示する。"""
904 button = self.source_buttons.get(rgb)
905 if button is None: 905 ↛ 906line 905 didn't jump to line 906 because the condition on line 905 was never true
906 return
907 is_background = rgb == self.background_rgb
908 is_child = rgb in self.child_to_parent
909 hex_text = (
910 tr("背景色 {hex}").format(hex=self._background_display_hex())
911 if is_background
912 else "#{:02X}{:02X}{:02X}".format(*rgb)
913 )
914 if is_child:
915 button.setText(f" └ {hex_text}")
916 return
917 child_count = sum(
918 1 for parent in self.child_to_parent.values() if parent == rgb
919 )
920 button.setText(
921 tr("{text}(親・{count})").format(text=hex_text, count=child_count) if child_count else hex_text
922 )
924 def _set_source_button_style(self, rgb):
925 button = self.source_buttons.get(rgb)
926 wrapper = self.source_wrappers.get(rgb)
927 if button is None: 927 ↛ 928line 927 didn't jump to line 928 because the condition on line 927 was never true
928 return
930 selected = rgb in self.selected_rgbs
931 if wrapper is not None: 931 ↛ 934line 931 didn't jump to line 934 because the condition on line 931 was always true
932 wrapper.setSelected(selected)
933 wrapper.setGroupLine(self._group_line_color(rgb))
934 self._sync_selection_check(rgb)
936 # 子色のボタン見た目(元色+親色ボーダー)は _refresh_group_display が持つ。
937 if rgb not in self.child_to_parent:
938 if rgb == self.background_rgb:
939 color = QColor(self.background_display_color)
940 text_color = self._text_color(
941 (color.red(), color.green(), color.blue())
942 )
943 else:
944 color = QColor(*rgb)
945 text_color = self._text_color(rgb)
946 button.setStyleSheet(
947 "QToolButton{"
948 f"background:{color.name()};color:{text_color};"
949 "border:1px solid rgba(255,255,255,0.55);padding:2px;"
950 "}"
951 "QToolButton:hover{"
952 f"background:{color.name()};color:{text_color};"
953 "border:1px solid rgba(255,255,255,0.55);"
954 "}"
955 )
956 self._apply_swatch_text(rgb)
958 if rgb == self.background_rgb:
959 button.setToolTip(
960 tr("背景色 {hex}。並べ替えや親子付けの対象にはできません。").format(
961 hex=self._background_display_hex()
962 )
963 )
964 else:
965 group_note = ""
966 parent_of_this = self.child_to_parent.get(rgb)
967 if parent_of_this is not None:
968 group_note = (
969 tr(" 現在 #{value:02X}{value2:02X}{value3:02X} の子です。").format(value=parent_of_this[0], value2=parent_of_this[1], value3=parent_of_this[2])
970 )
971 button.setToolTip(
972 tr("クリック:この色だけ選択/Shift+クリック:範囲選択/"
973 "{ctrl}+クリック:選択に追加・解除。"
974 "ドラッグで並べ替え、色の中央へドロップ=その色の子として整理。"
975 "親子付けしても色表示は変わりません。"
976 "解除はドラッグと右クリックで行えます。").format(ctrl=CTRL_KEY_LABEL)
977 + group_note
978 )
980 def _on_swatch_hover(self, rgb, hovered):
981 if hovered:
982 previous = self._hovered_rgb
983 self._hovered_rgb = rgb
984 if previous is not None and previous != rgb:
985 self._apply_swatch_text(previous)
986 elif self._hovered_rgb == rgb:
987 self._hovered_rgb = None
988 self._apply_swatch_text(rgb)
990 def _clear_rows(self):
991 self._close_replacement_editor()
992 self.visibility_checks.clear()
993 self.mask_checks.clear()
994 self.selection_checks.clear()
995 self.source_buttons.clear()
996 self.source_wrappers.clear()
997 self.replacement_buttons.clear()
998 self.row_widgets.clear()
999 while self.rows.count() > 1:
1000 item = self.rows.takeAt(0)
1001 widget = item.widget() if item is not None else None
1002 if widget is not None:
1003 widget.deleteLater()
1005 def _remove_color_row(self, rgb):
1006 """Remove only one row so palette updates do not rebuild every widget."""
1007 if self._replacement_popup_rgb == rgb:
1008 self._close_replacement_editor()
1009 widget = self.row_widgets.pop(rgb, None)
1010 if widget is not None:
1011 self.rows.removeWidget(widget)
1012 widget.deleteLater()
1013 self.visibility_checks.pop(rgb, None)
1014 self.mask_checks.pop(rgb, None)
1015 self.selection_checks.pop(rgb, None)
1016 self.source_buttons.pop(rgb, None)
1017 self.source_wrappers.pop(rgb, None)
1018 self.replacement_buttons.pop(rgb, None)
1019 self.replacements.pop(rgb, None)
1021 def _make_source_wrapper(self, source, source_rgb):
1022 is_background = source_rgb == self.background_rgb
1023 wrapper = ColorSelectionArea()
1024 wrapper.setColorKey(source_rgb, is_background)
1025 if not is_background:
1026 wrapper.setCursor(Qt.CursorShape.OpenHandCursor)
1027 wrapper.clicked.connect(
1028 lambda modifiers, rgb=source_rgb:
1029 self._select_used_color(rgb, modifiers)
1030 )
1031 wrapper.colorDropped.connect(
1032 lambda dragged_rgb, mode, target_rgb=source_rgb:
1033 self._handle_color_drop(dragged_rgb, target_rgb, mode)
1034 )
1035 wrapper.contextMenuRequested.connect(
1036 lambda global_pos, rgb=source_rgb:
1037 self._show_used_color_context_menu(rgb, global_pos)
1038 )
1039 wrapper.hoverChanged.connect(
1040 lambda hovered, rgb=source_rgb: self._on_swatch_hover(rgb, hovered)
1041 )
1042 if not is_background:
1043 # ダブルクリックで、この色の置換色をカラーピッカーで設定する。
1044 on_double_click(
1045 wrapper, lambda rgb=source_rgb: self.open_replacement_editor(rgb)
1046 )
1047 wrapper_layout = QHBoxLayout(wrapper)
1048 # 右余白は0にしてスウォッチを選択列いっぱいへ広げる。
1049 wrapper_layout.setContentsMargins(2, 3, 0, 3)
1050 wrapper_layout.setSpacing(0)
1051 wrapper_layout.addWidget(source)
1052 self.source_wrappers[source_rgb] = wrapper
1053 return wrapper
1055 def _append_color_row(self, color):
1056 qc = QColor(color)
1057 source_rgb = self._rgb_key(qc)
1058 is_background = source_rgb == self.background_rgb
1060 # 行全体では選択操作を受けない。
1061 # 「選択」列の source_wrapper だけが親子選択メニューを担当する。
1062 row_widget = QWidget()
1063 row = QGridLayout(row_widget)
1064 row.setContentsMargins(0, 0, 0, 0)
1065 row.setHorizontalSpacing(0)
1066 row.setColumnMinimumWidth(0, 32)
1067 row.setColumnMinimumWidth(1, 32)
1068 row.setColumnMinimumWidth(2, 32)
1069 row.setColumnMinimumWidth(3, 72)
1070 row.setColumnMinimumWidth(4, 72)
1071 row.setColumnStretch(3, 1)
1072 row.setColumnStretch(4, 1)
1074 selection_check = ColorSelectionCheckBox()
1075 selection_check.setChecked(source_rgb in self.selected_rgbs)
1076 selection_check.setEnabled(not is_background)
1077 selection_check.setToolTip(
1078 tr("背景色は選択できません。")
1079 if is_background else
1080 (
1081 tr("クリック:この色だけ選択/Shift+クリック:範囲選択/"
1082 "{ctrl}+クリック:追加・解除/上下になぞる:一括選択/"
1083 "{alt}+クリック:この色だけ選択").format(
1084 ctrl=CTRL_KEY_LABEL, alt=ALT_KEY_LABEL)
1085 )
1086 )
1087 if not is_background:
1088 selection_check.toggled.connect(
1089 lambda checked, rgb=source_rgb: self._on_selection_check_toggled(rgb, checked)
1090 )
1091 selection_check.altClicked.connect(
1092 lambda rgb=source_rgb: self._select_single_used_color(rgb)
1093 )
1094 selection_check.sweepStarted.connect(
1095 lambda checked, rgb=source_rgb: self._begin_selection_sweep(rgb, checked)
1096 )
1097 selection_check.sweepMoved.connect(self._move_selection_sweep)
1098 selection_check.sweepFinished.connect(self._end_selection_sweep)
1099 self.selection_checks[source_rgb] = selection_check
1101 visible_check = ColorVisibilityCheckBox()
1102 visible_check.setChecked(
1103 self.enabled_colors.get(source_rgb, True)
1104 )
1105 visible_check.setToolTip(
1106 (
1107 tr("クリック:背景表示ON/OFF/上下になぞる:一括ON/OFF/"
1108 "Alt+クリック:背景だけ表示/右クリック:表示メニュー")
1109 )
1110 if is_background else
1111 (
1112 tr("クリック:表示ON/OFF/上下になぞる:一括ON/OFF/"
1113 "Alt+クリック:この色だけ表示/右クリック:表示メニュー")
1114 )
1115 )
1116 visible_check.toggled.connect(
1117 lambda checked, rgb=source_rgb: self._set_color_visible(rgb, checked)
1118 )
1119 visible_check.altClicked.connect(
1120 lambda rgb=source_rgb: self._isolate_visible_color(rgb)
1121 )
1122 visible_check.sweepStarted.connect(
1123 lambda checked, rgb=source_rgb: self._begin_visibility_sweep(rgb, checked)
1124 )
1125 visible_check.sweepMoved.connect(self._move_visibility_sweep)
1126 visible_check.sweepFinished.connect(self._end_visibility_sweep)
1127 self.visibility_checks[source_rgb] = visible_check
1129 if self._mask_all_mode: 1129 ↛ 1131line 1129 didn't jump to line 1131 because the condition on line 1129 was always true
1130 self.mask_rgbs.add(source_rgb)
1131 mask_check = MaskColorCheckBox()
1132 mask_check.setChecked(source_rgb in self.mask_rgbs)
1133 mask_check.setToolTip(
1134 (
1135 tr("描画対象:ONにするとこの色の上へ描けます。"
1136 "クリック:ON/OFF/上下になぞる:一括ON/OFF/"
1137 "Alt+クリック:この色だけON/右クリック:描画対象メニュー")
1138 )
1139 if not is_background else
1140 (
1141 tr("描画対象:ONにすると背景の上へ描けます。"
1142 "クリック:ON/OFF/上下になぞる:一括ON/OFF/"
1143 "Alt+クリック:背景だけON/右クリック:描画対象メニュー")
1144 )
1145 )
1146 mask_check.toggled.connect(
1147 lambda checked, rgb=source_rgb: self._set_mask_color(rgb, checked)
1148 )
1149 mask_check.altClicked.connect(
1150 lambda rgb=source_rgb: self._isolate_mask_color(rgb)
1151 )
1152 mask_check.sweepStarted.connect(
1153 lambda checked, rgb=source_rgb: self._begin_mask_sweep(rgb, checked)
1154 )
1155 mask_check.sweepMoved.connect(self._move_mask_sweep)
1156 mask_check.sweepFinished.connect(self._end_mask_sweep)
1157 self.mask_checks[source_rgb] = mask_check
1159 source = SourceColorButton()
1160 source.setFixedHeight(26)
1161 source.setMinimumWidth(72)
1162 source.setText(
1163 tr("背景色 #FFFFFF")
1164 if is_background
1165 else qc.name(QColor.NameFormat.HexRgb).upper()
1166 )
1167 # 「選択」列のマウス操作はすべて外側の ColorSelectionArea が担当する。
1168 # これにより、ドラッグはスポイトではなく選択ON/OFFの連続切替になる。
1169 source.setAttribute(
1170 Qt.WidgetAttribute.WA_TransparentForMouseEvents,
1171 True,
1172 )
1173 self.source_buttons[source_rgb] = source
1174 source_wrapper = self._make_source_wrapper(source, source_rgb)
1175 source_wrapper.setMinimumWidth(72)
1176 source_wrapper.setSizePolicy(
1177 QSizePolicy.Policy.Expanding,
1178 QSizePolicy.Policy.Fixed,
1179 )
1180 source.setSizePolicy(
1181 QSizePolicy.Policy.Expanding,
1182 QSizePolicy.Policy.Fixed,
1183 )
1184 self._set_source_button_style(source_rgb)
1186 # チェックボックスだけでなく、薄いグレーのセル全体をクリック領域にする。
1187 selection_area = CheckClickArea(selection_check)
1188 visible_area = CheckClickArea(visible_check)
1189 mask_area = CheckClickArea(mask_check)
1190 for widget in (selection_check, selection_area):
1191 widget.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
1192 widget.customContextMenuRequested.connect(
1193 lambda pos, w=widget, rgb=source_rgb:
1194 self._show_used_color_context_menu(rgb, w.mapToGlobal(pos))
1195 )
1196 for widget in (visible_check, visible_area):
1197 widget.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
1198 widget.customContextMenuRequested.connect(
1199 lambda pos, w=widget, rgb=source_rgb:
1200 self._show_visibility_context_menu(rgb, w.mapToGlobal(pos))
1201 )
1202 for widget in (mask_check, mask_area):
1203 widget.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
1204 widget.customContextMenuRequested.connect(
1205 lambda pos, w=widget, rgb=source_rgb:
1206 self._show_mask_context_menu(rgb, w.mapToGlobal(pos))
1207 )
1208 row.addWidget(selection_area, 0, 0)
1209 row.addWidget(visible_area, 0, 1)
1210 row.addWidget(mask_area, 0, 2)
1211 if is_background:
1212 row.addWidget(source_wrapper, 0, 3, 1, 2)
1213 else:
1214 replacement = ScreenEyedropButton()
1215 replacement.setFixedHeight(26)
1216 replacement.setMinimumWidth(72)
1217 replacement.setSizePolicy(
1218 QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed
1219 )
1220 replacement.setToolTip(
1221 tr("左クリック:色を編集。右クリック:画面全体のスポイトへ切替。"
1222 "スポイト中は左クリックで確定、Escで解除。")
1223 )
1224 replacement.colorPicked.connect(
1225 lambda color, rgb=source_rgb:
1226 self._set_replacement_color(rgb, color)
1227 )
1228 replacement.colorEditorRequested.connect(
1229 lambda global_pos, rgb=source_rgb:
1230 self._toggle_replacement_editor(rgb, global_pos)
1231 )
1232 self.replacement_buttons[source_rgb] = replacement
1233 self._set_replacement_button_style(source_rgb)
1234 row.addWidget(source_wrapper, 0, 3)
1235 row.addWidget(replacement, 0, 4)
1237 self.row_widgets[source_rgb] = row_widget
1238 # 末尾(ストレッチの手前)へ追加する。並び順は _reapply_row_order で整える。
1239 self.rows.insertWidget(max(0, self.rows.count() - 1), row_widget)
1240 self._refresh_group_display(source_rgb)
1242 def _normalized_colors(self, colors):
1243 seen = set()
1244 result = []
1245 for color in colors:
1246 qc = QColor(color)
1247 key = self._rgb_key(qc)
1248 if key not in seen: 1248 ↛ 1245line 1248 didn't jump to line 1245 because the condition on line 1248 was always true
1249 seen.add(key)
1250 result.append(qc)
1251 return result
1253 def set_colors(self, colors):
1254 normalized = [
1255 color for color in self._normalized_colors(colors)
1256 if self._rgb_key(color) != self.background_rgb
1257 ]
1258 incoming_by_key = {
1259 self._rgb_key(color): QColor(color) for color in normalized
1260 }
1261 incoming_keys = {self.background_rgb, *incoming_by_key.keys()}
1262 self.replacements = {
1263 rgb: color for rgb, color in self.replacements.items()
1264 if rgb in incoming_keys and rgb != self.background_rgb
1265 }
1266 current_keys = {self._rgb_key(color) for color in self.colors}
1267 added = incoming_keys - current_keys
1268 removed = current_keys - incoming_keys
1269 palette_changed = bool(added or removed)
1271 old_mask_rgbs = set(self.mask_rgbs)
1272 old_selected = list(self.selected_rgbs)
1273 old_group_mapping = self._group_mapping()
1275 # 消えた色だけを削除する。全行再構築と色順の並べ替えを避ける。
1276 for rgb in tuple(removed): 1276 ↛ 1277line 1276 didn't jump to line 1277 because the loop on line 1276 never started
1277 if rgb == self.background_rgb:
1278 continue
1279 self._remove_color_row(rgb)
1280 self.enabled_colors.pop(rgb, None)
1281 self.mask_rgbs.discard(rgb)
1282 # 消えた色が絡む親子関係は破棄する。
1283 self._drop_group_links_for(rgb)
1285 self.selected_rgbs = [
1286 rgb for rgb in self.selected_rgbs
1287 if rgb in incoming_keys and rgb != self.background_rgb
1288 ]
1289 self.parent_rgb = self.selected_rgbs[-1] if self.selected_rgbs else None
1291 # 既存の並びを維持し、新色だけ末尾へ追加する。
1292 retained = [
1293 QColor(color) for color in self.colors
1294 if self._rgb_key(color) in incoming_keys
1295 and self._rgb_key(color) != self.background_rgb
1296 ]
1297 self.colors = [QColor(*self.background_rgb), *retained]
1299 if self.background_rgb not in self.source_buttons:
1300 self.enabled_colors[self.background_rgb] = True
1301 self._append_color_row(QColor(*self.background_rgb))
1303 existing = {self._rgb_key(color) for color in self.colors}
1304 for color in normalized:
1305 rgb = self._rgb_key(color)
1306 if rgb in existing:
1307 continue
1308 self.colors.append(QColor(color))
1309 self.enabled_colors[rgb] = True
1310 self.mask_rgbs.add(rgb)
1311 self._append_color_row(color)
1312 existing.add(rgb)
1314 self.enabled_colors = {
1315 rgb: self.enabled_colors.get(rgb, True)
1316 for rgb in incoming_keys
1317 }
1318 for rgb in set(old_selected) | set(self.selected_rgbs): 1318 ↛ 1319line 1318 didn't jump to line 1319 because the loop on line 1318 never started
1319 if rgb in self.source_buttons:
1320 self._set_source_button_style(rgb)
1322 # 消えた色で親子が壊れた場合に備え、正規化と表示更新を行う。
1323 self._normalize_groups()
1324 self._reorder_children_under_parents()
1325 self._reapply_row_order()
1326 self._refresh_all_group_displays()
1327 new_group_mapping = self._group_mapping()
1328 if new_group_mapping != old_group_mapping: 1328 ↛ 1329line 1328 didn't jump to line 1329 because the condition on line 1328 was never true
1329 self._emit_preview()
1330 self.count_label.setText(tr("{len}色").format(len=len(self.colors)))
1331 if self._isolated_rgb not in incoming_keys: 1331 ↛ 1335line 1331 didn't jump to line 1335 because the condition on line 1331 was always true
1332 self._isolated_rgb = None
1333 self._pre_isolate_enabled = None
1335 self._mask_all_mode = self.all_masks_enabled()
1336 if old_mask_rgbs != self.mask_rgbs:
1337 self.maskColorsChanged.emit(set(self.mask_rgbs))
1338 if old_selected != self.selected_rgbs: 1338 ↛ 1339line 1338 didn't jump to line 1339 because the condition on line 1338 was never true
1339 self.selectedColorsChanged.emit(set(self.selected_rgbs))
1340 if palette_changed:
1341 self.visibleColorsChanged.emit(self.enabled_rgb_set())
1343 def add_color(self, color):
1344 """Add a newly drawn color immediately, before a whole-image scan."""
1345 qc = QColor(color)
1346 if not qc.isValid() or qc.alpha() == 0: 1346 ↛ 1347line 1346 didn't jump to line 1347 because the condition on line 1346 was never true
1347 return
1348 key = self._rgb_key(qc)
1349 if key in self.source_buttons:
1350 return
1351 self.colors.append(qc)
1352 self.enabled_colors[key] = True
1353 self.mask_rgbs.add(key)
1354 self._mask_all_mode = True
1355 self._append_color_row(qc)
1356 self.count_label.setText(tr("{len}色").format(len=len(self.colors)))
1357 self._emit_mask_state()
1358 self.visibleColorsChanged.emit(self.enabled_rgb_set())
1360 def _refresh_used_color_styles(self, affected=None):
1361 keys = set(self.source_buttons) if affected is None else set(affected)
1362 for key in keys:
1363 if key in self.source_buttons: 1363 ↛ 1362line 1363 didn't jump to line 1362 because the condition on line 1363 was always true
1364 self._set_source_button_style(key)
1366 def _set_used_color_parent(self, rgb):
1367 if rgb == self.background_rgb:
1368 return
1369 old = set(self.selected_rgbs)
1370 self.selected_rgbs = [value for value in self.selected_rgbs if value != rgb]
1371 self.selected_rgbs.append(rgb)
1372 self.parent_rgb = rgb
1373 self._refresh_used_color_styles(old | set(self.selected_rgbs))
1374 self.selectedColorsChanged.emit(set(self.selected_rgbs))
1376 def _show_used_color_context_menu(self, rgb, global_position):
1377 menu = QMenu(self)
1378 action_main_color = menu.addAction(tr("メインカラーにする"))
1379 menu.addSeparator()
1380 if rgb == self.background_rgb:
1381 chosen = menu.exec(global_position)
1382 if chosen is action_main_color:
1383 self.mainColorRequested.emit(QColor(*rgb))
1384 return
1385 parent = (
1386 tuple(self.parent_rgb)
1387 if self.parent_rgb is not None
1388 else None
1389 )
1391 selected_line_colors = {
1392 tuple(value) for value in self.selected_rgbs
1393 if tuple(value) != self.background_rgb
1394 }
1396 # 右クリックした色が選択中なら、親・子を含む選択色全体を削除する。
1397 # 未選択の色を右クリックした場合は、その色だけを削除対象にする。
1398 clicked_rgb = tuple(rgb)
1399 delete_sources = (
1400 set(selected_line_colors)
1401 if clicked_rgb in selected_line_colors
1402 else {clicked_rgb}
1403 )
1404 delete_sources.discard(self.background_rgb)
1406 action_delete = menu.addAction(
1407 tr("この色を消す")
1408 if len(delete_sources) <= 1
1409 else tr("選択した{count}色を消す").format(count=len(delete_sources))
1410 )
1411 action_delete.setToolTip(
1412 tr("選択した使用色を #FFFFFF へ統合します。")
1413 )
1414 action_merge = None
1415 if clicked_rgb in selected_line_colors and len(selected_line_colors) >= 2:
1416 action_merge = menu.addAction(
1417 tr("選択した{count}色をこの色へ統合").format(
1418 count=len(selected_line_colors)
1419 )
1420 )
1422 main_window: Any = self.window()
1423 canvas = getattr(main_window, "canvas", None)
1424 tween_running = bool(
1425 getattr(canvas, "tween_pending", None)
1426 )
1427 can_adjust_thickness = (
1428 not tween_running
1429 and parent is not None
1430 and tuple(rgb) == parent
1431 and parent in selected_line_colors
1432 )
1433 action_thickness = None
1434 if can_adjust_thickness:
1435 action_thickness = menu.addAction(tr("線の太さを調整"))
1436 action_thickness.setToolTip(
1437 tr("選択中の親色・子色をまとめて調整します。")
1438 )
1440 action_hsv = menu.addAction(
1441 tr("色相・彩度・明度を調整")
1442 if len(delete_sources) <= 1
1443 else tr("選択した{count}色の色相・彩度・明度を調整").format(
1444 count=len(delete_sources)
1445 )
1446 )
1447 action_hsv.setToolTip(
1448 tr("プレビューを見ながら調整し、OKで全コマへ適用します。")
1449 )
1451 action_focus = menu.addAction(tr("この色を選択範囲にする"))
1452 folder_menu = menu.addMenu(tr("フォルダーへ移動"))
1453 folder_actions = {}
1454 uncategorized_action = folder_menu.addAction(tr("未分類"))
1455 folder_menu.addSeparator()
1456 for category_name in self.category_order:
1457 action = folder_menu.addAction(category_name)
1458 folder_actions[action] = category_name
1459 folder_menu.addSeparator()
1460 new_folder_action = folder_menu.addAction(tr("新規フォルダー…"))
1461 menu.addSeparator()
1463 # 親子グループ関連。
1464 clicked_rgb = tuple(rgb)
1465 in_group = (
1466 clicked_rgb in self.child_to_parent
1467 or any(p == clicked_rgb for p in self.child_to_parent.values())
1468 )
1469 action_ungroup = None
1470 if in_group:
1471 action_ungroup = menu.addAction(tr("親子を解除"))
1472 action_ungroup.setToolTip(tr("この色に関わる親子関係を解除します。"))
1473 action_ungroup_all = None
1474 if self.child_to_parent:
1475 action_ungroup_all = menu.addAction(tr("親子をすべて解除"))
1476 action_clear = None
1477 if self.selected_rgbs:
1478 menu.addSeparator()
1479 action_clear = menu.addAction(tr("色の選択をすべて解除"))
1481 chosen = menu.exec(global_position)
1482 if chosen is action_main_color:
1483 self.mainColorRequested.emit(QColor(*rgb))
1484 elif chosen is action_delete:
1485 self.deleteColorsRequested.emit(
1486 set(delete_sources)
1487 )
1488 elif action_thickness is not None and chosen is action_thickness:
1489 self.adjustLineThicknessRequested.emit(
1490 set(selected_line_colors)
1491 )
1492 elif chosen is action_hsv:
1493 self.adjustHsvRequested.emit(set(delete_sources))
1494 elif chosen is action_focus:
1495 self._set_used_color_parent(rgb)
1496 self.focusColorRequested.emit(tuple(rgb))
1497 elif chosen is uncategorized_action:
1498 move_sources = (
1499 selected_line_colors
1500 if clicked_rgb in selected_line_colors
1501 else {clicked_rgb}
1502 )
1503 self._move_colors_to_uncategorized(move_sources)
1504 elif chosen in folder_actions:
1505 self._move_colors_to_category(
1506 clicked_rgb, folder_actions[chosen]
1507 )
1508 elif chosen is new_folder_action:
1509 name, accepted = QInputDialog.getText(
1510 self, tr("使用色フォルダー"), tr("フォルダー名:"),
1511 text=self._unique_category_name(tr("新規フォルダー")),
1512 )
1513 if accepted and str(name).strip():
1514 category = self.create_category(str(name).strip())
1515 self._move_colors_to_category(clicked_rgb, category)
1516 elif action_ungroup is not None and chosen is action_ungroup:
1517 with self._history_edit(tr("親子を解除")):
1518 if self._drop_group_links_for(clicked_rgb):
1519 self._normalize_groups()
1520 self._refresh_all_group_displays()
1521 self._emit_preview()
1522 elif action_ungroup_all is not None and chosen is action_ungroup_all:
1523 self._clear_groups()
1524 elif action_merge is not None and chosen is action_merge:
1525 self._merge_selected_into(clicked_rgb)
1526 elif action_clear is not None and chosen is action_clear:
1527 self._clear_used_color_selection()
1529 # ------------------------------------------------------------------
1530 # Undo履歴(並べ替え・親子・表示/マスク)
1531 # ------------------------------------------------------------------