Coverage for paintmaskanimator/color_panel_grouping.py: 75%
284 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"""Parent/child grouping and row reordering for the used-color panel.
3Split out of ``color_panel.py`` as a mixin. These methods own the group graph
4(``_color_groups``): making one colour a child of another, dropping links, the
5drag-and-drop reorder of single rows and of multi-row selections, keeping
6children laid out under their parent, and the preview / merge signals
7that publish the current grouping to the main window.
8"""
9from typing import Any
10from PySide6.QtGui import QColor
11from .i18n import tr
12from ._color_panel_members import UsedColorPanelMembers
13from .logging_setup import get_logger
15log = get_logger(__name__)
18class ColorGroupingMixin(UsedColorPanelMembers):
19 def _color_index(self, rgb):
20 for index, color in enumerate(self.colors): 20 ↛ 23line 20 didn't jump to line 23 because the loop on line 20 didn't complete
21 if self._rgb_key(color) == rgb:
22 return index
23 return None
25 def _group_root(self, rgb):
26 """親子チェーンをたどって最終的な親(ルート色)を返す。"""
27 seen = set()
28 current = rgb
29 while current in self.child_to_parent and current not in seen:
30 seen.add(current)
31 current = self.child_to_parent[current]
32 return current
34 def _drop_group_links_for(self, rgb):
35 """指定色が親でも子でも、その親子リンクをすべて解除する。"""
36 changed = self.child_to_parent.pop(rgb, None) is not None
37 for child in [c for c, p in self.child_to_parent.items() if p == rgb]:
38 self.child_to_parent.pop(child, None)
39 changed = True
40 return changed
42 def _handle_color_drop(self, dragged_rgb, target_rgb, mode):
43 dragged_rgb = tuple(dragged_rgb)
44 target_rgb = tuple(target_rgb)
45 if dragged_rgb == target_rgb or dragged_rgb == self.background_rgb:
46 return
47 if target_rgb == self.background_rgb: 47 ↛ 48line 47 didn't jump to line 48 because the condition on line 47 was never true
48 return
49 label = tr("親子付け") if mode == "child" else tr("使用色の並べ替え")
50 with self._history_edit(label):
51 if mode == "child":
52 selected = {
53 tuple(value) for value in self.selected_rgbs
54 if tuple(value) != self.background_rgb
55 }
56 children = (
57 [
58 self._rgb_key(color) for color in self.colors
59 if self._rgb_key(color) in selected
60 and self._rgb_key(color) != target_rgb
61 ]
62 if dragged_rgb in selected
63 else [dragged_rgb]
64 )
65 for child_rgb in children:
66 self._make_child_of(child_rgb, target_rgb)
67 else:
68 selected = {
69 tuple(value) for value in self.selected_rgbs
70 if tuple(value) != self.background_rgb
71 }
72 # 親をつかんだ時は子も含むグループ全体を一つのブロックとして
73 # 移動する。親だけを先へ動かして子との連続が崩れ、親子が解除
74 # される従来挙動を避ける。
75 is_parent = any(
76 parent == dragged_rgb
77 for parent in self.child_to_parent.values()
78 )
79 if is_parent:
80 self._reorder_parent_group(
81 dragged_rgb, target_rgb, mode
82 )
83 return
85 moved_rgbs = (
86 selected
87 if dragged_rgb in selected and len(selected) > 1
88 else {dragged_rgb}
89 )
90 if dragged_rgb in selected and len(selected) > 1:
91 self._reorder_color_block(selected, target_rgb, mode)
92 else:
93 self._reorder_color(dragged_rgb, target_rgb, mode)
94 # 子を親の下からドラッグした時だけ、その子の親子関係を解除。
95 # 他グループの連続状態までは触らない。
96 detached = False
97 for moved_rgb in moved_rgbs:
98 if moved_rgb in self.child_to_parent:
99 self.child_to_parent.pop(moved_rgb, None)
100 detached = True
101 if detached:
102 self._refresh_all_group_displays()
103 self._emit_preview()
105 def _make_child_of(self, child_rgb, parent_rgb):
106 """child_rgb を parent_rgb の子にして、親色プレビューを更新する。"""
107 # 循環を避ける。ドロップ先が自分の子孫なら親子化しない。
108 probe = parent_rgb
109 guard = 0
110 while probe in self.child_to_parent and guard < len(self.child_to_parent) + 1: 110 ↛ 111line 110 didn't jump to line 111 because the condition on line 110 was never true
111 if probe == child_rgb:
112 return
113 probe = self.child_to_parent[probe]
114 guard += 1
115 # 親自身が誰かの子なら、実際のルートへ束ねる(単層に正規化)。
116 root = self._group_root(parent_rgb)
117 if root == child_rgb: 117 ↛ 118line 117 didn't jump to line 118 because the condition on line 117 was never true
118 return
119 # child_rgb にぶら下がっていた子は、まとめて新しいルートへ移す。
120 for grandchild in [c for c, p in self.child_to_parent.items() if p == child_rgb]: 120 ↛ 121line 120 didn't jump to line 121 because the loop on line 120 never started
121 self.child_to_parent[grandchild] = root
122 self.child_to_parent[child_rgb] = root
123 self._normalize_groups()
124 self._reorder_children_under_parents()
125 self._refresh_all_group_displays()
126 self._emit_preview()
128 def _normalize_groups(self):
129 """全リンクをルート直付けに正規化し、背景・自己参照を除去する。"""
130 cleaned = {}
131 for child, parent in self.child_to_parent.items():
132 if child == self.background_rgb or child == parent: 132 ↛ 133line 132 didn't jump to line 133 because the condition on line 132 was never true
133 continue
134 root = self._group_root(parent)
135 if root == child or root == self.background_rgb: 135 ↛ 136line 135 didn't jump to line 136 because the condition on line 135 was never true
136 continue
137 cleaned[child] = root
138 self.child_to_parent = cleaned
140 def _reorder_color(self, dragged_rgb, target_rgb, mode):
141 """dragged_rgb を target_rgb の前/後ろへ移動する(背景は先頭固定)。"""
142 drag_index = self._color_index(dragged_rgb)
143 if drag_index is None: 143 ↛ 144line 143 didn't jump to line 144 because the condition on line 143 was never true
144 return
145 moved = self.colors.pop(drag_index)
146 target_index = self._color_index(target_rgb)
147 if target_index is None: 147 ↛ 148line 147 didn't jump to line 148 because the condition on line 147 was never true
148 self.colors.insert(drag_index, moved)
149 return
150 if mode == "after":
151 target_index += 1
152 # 背景色(先頭)より前には入れない。
153 target_index = max(1, target_index)
154 self.colors.insert(target_index, moved)
155 self._reapply_row_order()
157 def _reorder_color_block(self, selected_rgbs, target_rgb, mode):
158 """複数選択色を現在の並び順のまま一括移動する。"""
159 selected = set(selected_rgbs)
160 selected.discard(self.background_rgb)
161 if not selected or target_rgb in selected: 161 ↛ 162line 161 didn't jump to line 162 because the condition on line 161 was never true
162 return
164 moved = [
165 color for color in self.colors
166 if self._rgb_key(color) in selected
167 ]
168 if not moved: 168 ↛ 169line 168 didn't jump to line 169 because the condition on line 168 was never true
169 return
170 remaining = [
171 color for color in self.colors
172 if self._rgb_key(color) not in selected
173 ]
174 target_index = next(
175 (
176 index for index, color in enumerate(remaining)
177 if self._rgb_key(color) == target_rgb
178 ),
179 None,
180 )
181 if target_index is None: 181 ↛ 182line 181 didn't jump to line 182 because the condition on line 181 was never true
182 return
183 if mode == "after":
184 target_index += 1
185 target_index = max(1, target_index)
186 self.colors = [
187 *remaining[:target_index],
188 *moved,
189 *remaining[target_index:],
190 ]
191 self._reapply_row_order()
193 def _reorder_parent_group(self, parent_rgb, target_rgb, mode):
194 """親とその子を連続ブロックのまま移動し、親子関係を維持する。"""
195 group_rgbs = [parent_rgb]
196 group_rgbs.extend(
197 self._rgb_key(color) for color in self.colors
198 if self.child_to_parent.get(self._rgb_key(color)) == parent_rgb
199 )
200 group_set = set(group_rgbs)
201 if target_rgb in group_set: 201 ↛ 202line 201 didn't jump to line 202 because the condition on line 201 was never true
202 return
203 by_key = {
204 self._rgb_key(color): color for color in self.colors
205 }
206 moved = [by_key[rgb] for rgb in group_rgbs if rgb in by_key]
207 remaining = [
208 color for color in self.colors
209 if self._rgb_key(color) not in group_set
210 ]
211 remaining_keys = [self._rgb_key(color) for color in remaining]
212 if target_rgb not in remaining_keys: 212 ↛ 213line 212 didn't jump to line 213 because the condition on line 212 was never true
213 return
215 # ドロップ先も親子グループなら、その途中へ割り込ませない。
216 # 「前」は相手の親より前、「後」は相手の最後の子より後へ置く。
217 target_parent = self.child_to_parent.get(target_rgb)
218 if target_parent is None and any(
219 parent == target_rgb
220 for parent in self.child_to_parent.values()
221 ):
222 target_parent = target_rgb
223 target_group = (
224 {
225 rgb for rgb in remaining_keys
226 if rgb == target_parent
227 or self.child_to_parent.get(rgb) == target_parent
228 }
229 if target_parent is not None
230 else {target_rgb}
231 )
232 target_indexes = [
233 index for index, rgb in enumerate(remaining_keys)
234 if rgb in target_group
235 ]
236 target_index = (
237 max(target_indexes) + 1
238 if mode == "after"
239 else min(target_indexes)
240 )
241 target_index = max(1, target_index)
242 self.colors = [
243 *remaining[:target_index],
244 *moved,
245 *remaining[target_index:],
246 ]
247 self._reapply_row_order()
248 self._refresh_all_group_displays()
250 def _drop_group_links_outside_parent_blocks(self):
251 """親の直後に連続していない子の親子リンクを解除する。"""
252 if not self.child_to_parent:
253 return False
255 order = [self._rgb_key(color) for color in self.colors]
256 retained_children = set()
257 parents = set(self.child_to_parent.values())
258 for parent in parents:
259 try:
260 index = order.index(parent) + 1
261 except ValueError:
262 continue
263 while (
264 index < len(order)
265 and self.child_to_parent.get(order[index]) == parent
266 ):
267 retained_children.add(order[index])
268 index += 1
270 detached = set(self.child_to_parent) - retained_children
271 for child in detached:
272 self.child_to_parent.pop(child, None)
273 return bool(detached)
275 def _reorder_children_under_parents(self):
276 """子色を、その親色(ルート)の直後へまとめて並べ替える。"""
277 if not self.child_to_parent:
278 return
279 key_to_color = {self._rgb_key(color): color for color in self.colors}
280 order = [self._rgb_key(color) for color in self.colors]
281 child_set = set(self.child_to_parent)
283 # 各ルート親ごとに、現在の並び順を保ったまま子をぶら下げる。
284 children_by_root = {}
285 for child in order:
286 if child in child_set:
287 children_by_root.setdefault(
288 self._group_root(child), []
289 ).append(child)
291 result = []
292 for rgb in order:
293 if rgb in child_set:
294 continue # 親の直後にまとめて置くのでここでは飛ばす。
295 result.append(rgb)
296 result.extend(children_by_root.get(rgb, []))
297 # 親が見つからない子は末尾へ回して取りこぼしを防ぐ。
298 for child in order:
299 if child in child_set and child not in result: 299 ↛ 300line 299 didn't jump to line 300 because the condition on line 299 was never true
300 result.append(child)
302 self.colors = [
303 key_to_color[rgb] for rgb in result if rgb in key_to_color
304 ]
305 self._reapply_row_order()
307 def _reapply_row_order(self):
308 """背景、カテゴリーフォルダー、未分類色の順に行を並べる。"""
309 self._sync_category_headers()
310 for widget in list(self.category_widgets.values()) + list(self.row_widgets.values()):
311 self.rows.removeWidget(widget)
313 def set_category_member_visual(widget, in_category):
314 row_layout = widget.layout()
315 if row_layout is not None: 315 ↛ 319line 315 didn't jump to line 319 because the condition on line 315 was always true
316 row_layout.setContentsMargins(
317 20 if in_category else 0, 0, 0, 0
318 )
319 if in_category:
320 widget.setObjectName("usedColorCategoryMember")
321 widget.setStyleSheet(
322 "QWidget#usedColorCategoryMember{"
323 "background-color:palette(alternate-base);"
324 "border-left:3px solid palette(highlight);}"
325 )
326 widget.setToolTip(tr("使用色フォルダーに格納されています。"))
327 else:
328 widget.setObjectName("usedColorRow")
329 widget.setStyleSheet("")
330 widget.setToolTip("")
332 position = 0
333 background = self.row_widgets.get(self.background_rgb)
334 if background is not None:
335 set_category_member_visual(background, False)
336 background.setVisible(True)
337 self.rows.insertWidget(position, background)
338 position += 1
340 for category in self.category_order:
341 header = self.category_widgets.get(category)
342 if header is None: 342 ↛ 343line 342 didn't jump to line 343 because the condition on line 342 was never true
343 continue
344 self.rows.insertWidget(position, header)
345 header.setVisible(True)
346 position += 1
347 collapsed = bool(self.category_collapsed.get(category, False))
348 for rgb in self._category_members(category):
349 widget = self.row_widgets.get(rgb)
350 if widget is None: 350 ↛ 351line 350 didn't jump to line 351 because the condition on line 350 was never true
351 continue
352 set_category_member_visual(widget, True)
353 widget.setVisible(not collapsed)
354 self.rows.insertWidget(position, widget)
355 position += 1
357 for color in self.colors:
358 rgb = self._rgb_key(color)
359 if rgb == self.background_rgb or rgb in self.category_colors:
360 continue
361 widget = self.row_widgets.get(rgb)
362 if widget is None: 362 ↛ 363line 362 didn't jump to line 363 because the condition on line 362 was never true
363 continue
364 set_category_member_visual(widget, False)
365 widget.setVisible(True)
366 self.rows.insertWidget(position, widget)
367 position += 1
369 def _refresh_all_group_displays(self):
370 for rgb in list(self.source_buttons):
371 self._refresh_group_display(rgb)
373 def _refresh_group_display(self, rgb):
374 """子色は元色のまま表示し、ボーダーを親色にして階層を示す。"""
375 button = self.source_buttons.get(rgb)
376 wrapper = self.source_wrappers.get(rgb)
377 if button is None: 377 ↛ 378line 377 didn't jump to line 378 because the condition on line 377 was never true
378 return
379 parent = self.child_to_parent.get(rgb)
380 if parent is not None:
381 # 元色は保持し、縁取り(ボーダー)だけを親色にする。
382 root = self._group_root(rgb)
383 parent_color = QColor(*root)
384 own = QColor(*rgb)
385 text_color = self._text_color(rgb)
386 button.setStyleSheet(
387 "QToolButton{"
388 f"background:{own.name()};color:{text_color};"
389 f"border:2px solid {parent_color.name()};padding:2px;}}"
390 "QToolButton:hover{"
391 f"background:{own.name()};color:{text_color};"
392 f"border:2px solid {parent_color.name()};}}"
393 )
394 if wrapper is not None: 394 ↛ 405line 394 didn't jump to line 405 because the condition on line 394 was always true
395 margin = wrapper.layout()
396 if margin is not None: 396 ↛ 405line 396 didn't jump to line 405 because the condition on line 396 was always true
397 # 行頭インデントで階層を示す(右余白は0でスウォッチを広げる)。
398 margin.setContentsMargins(20, 3, 0, 3)
399 else:
400 if wrapper is not None: 400 ↛ 405line 400 didn't jump to line 405 because the condition on line 400 was always true
401 margin = wrapper.layout()
402 if margin is not None: 402 ↛ 405line 402 didn't jump to line 405 because the condition on line 402 was always true
403 margin.setContentsMargins(2, 3, 0, 3)
404 # 選択枠・グループ縦ライン・チェック同期・HEX表示・ツールチップを更新。
405 self._set_source_button_style(rgb)
406 self._apply_swatch_text(rgb)
408 def _group_mapping(self):
409 """{子rgb: ルート親rgb} を返す(統合確定時に使用)。"""
410 return {
411 child: self._group_root(child)
412 for child in self.child_to_parent
413 }
415 def _emit_preview(self):
416 # 親子付けは階層整理だけに使い、キャンバス上の色は置換しない。
417 # 空マッピングを通知して、旧状態の色プレビューも確実に解除する。
418 self.previewGroupsChanged.emit({})
420 def _clear_groups(self):
421 if not self.child_to_parent:
422 return
423 with self._history_edit(tr("親子をすべて解除")):
424 self.child_to_parent = {}
425 self._refresh_all_group_displays()
426 self._emit_preview()
428 def _retain_parent_selection(self):
429 old = set(self.selected_rgbs)
430 self.selected_rgbs = [self.parent_rgb] if self.parent_rgb is not None else []
431 for rgb in old | set(self.selected_rgbs):
432 self._set_source_button_style(rgb)
433 self.selectedColorsChanged.emit(set(self.selected_rgbs))
435 def _mergeable_selection(self):
436 return {
437 tuple(value) for value in self.selected_rgbs
438 if tuple(value) != self.background_rgb
439 }
441 def _refresh_merge_button(self, *_args):
442 self.merge_button.setEnabled(len(self._mergeable_selection()) >= 2)
444 def _emit_merge(self):
445 if self.parent_rgb is None or len(self._mergeable_selection()) < 2: 445 ↛ 446line 445 didn't jump to line 446 because the condition on line 445 was never true
446 window: Any = self.window()
447 if hasattr(window, "statusBar"):
448 window.statusBar().showMessage(
449 tr("統合する使用色を2色以上選択してください。最後に選んだ色へ統合します。"),
450 2800,
451 )
452 return
453 self._merge_selected_into(tuple(self.parent_rgb))
455 def _merge_selected_into(self, target_rgb):
456 """選択中の色を target_rgb へ統合するよう要求する。"""
457 selected = self._mergeable_selection()
458 target_rgb = tuple(target_rgb)
459 if target_rgb not in selected or len(selected) < 2: 459 ↛ 460line 459 didn't jump to line 460 because the condition on line 459 was never true
460 return
461 self.mergeColorsRequested.emit(target_rgb, selected)