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

1"""Parent/child grouping and row reordering for the used-color panel. 

2 

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 

14 

15log = get_logger(__name__) 

16 

17 

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 

24 

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 

33 

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 

41 

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 

84 

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

104 

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

127 

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 

139 

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

156 

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 

163 

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

192 

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 

214 

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

249 

250 def _drop_group_links_outside_parent_blocks(self): 

251 """親の直後に連続していない子の親子リンクを解除する。""" 

252 if not self.child_to_parent: 

253 return False 

254 

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 

269 

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) 

274 

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) 

282 

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) 

290 

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) 

301 

302 self.colors = [ 

303 key_to_color[rgb] for rgb in result if rgb in key_to_color 

304 ] 

305 self._reapply_row_order() 

306 

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) 

312 

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

331 

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 

339 

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 

356 

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 

368 

369 def _refresh_all_group_displays(self): 

370 for rgb in list(self.source_buttons): 

371 self._refresh_group_display(rgb) 

372 

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) 

407 

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 } 

414 

415 def _emit_preview(self): 

416 # 親子付けは階層整理だけに使い、キャンバス上の色は置換しない。 

417 # 空マッピングを通知して、旧状態の色プレビューも確実に解除する。 

418 self.previewGroupsChanged.emit({}) 

419 

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

427 

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

434 

435 def _mergeable_selection(self): 

436 return { 

437 tuple(value) for value in self.selected_rgbs 

438 if tuple(value) != self.background_rgb 

439 } 

440 

441 def _refresh_merge_button(self, *_args): 

442 self.merge_button.setEnabled(len(self._mergeable_selection()) >= 2) 

443 

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

454 

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)