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

1"""The used-color panel — the list of colours in use in the document. 

2 

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 

35 

36 

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) 

53 

54log = get_logger(__name__) 

55 

56 

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) 

83 

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

89 

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 

107 

108 # 親子グループ。{子rgb: 親rgb}。親自身は含めない。 

109 # ドラッグで子付けしても、キャンバス上の色表示は変更しない。 

110 self.child_to_parent = {} 

111 

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 = [] 

119 

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 

129 

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 

135 

136 self.visibility_checks = {} 

137 self.mask_checks = {} 

138 self.selection_checks = {} 

139 self.source_buttons = {} 

140 self.source_wrappers = {} 

141 self.row_widgets = {} 

142 

143 # ホバー中の色(HEXはホバー時のみ表示して色面積を最大化する)。 

144 self._hovered_rgb = None 

145 

146 self._selection_sweep_active = False 

147 self._selection_sweep_state = True 

148 self._selection_sweep_touched = set() 

149 self._selection_sweep_changed = False 

150 

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 

163 

164 # Undo履歴用。復元中は再記録を止め、スウィープ中は開始時状態を保持する。 

165 self._history_suspended = False 

166 self._sweep_history_before = None 

167 

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) 

186 

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) 

235 

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) 

252 

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) 

264 

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) 

271 

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) 

280 

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) 

289 

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) 

308 

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) 

327 

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) 

345 

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

349 

350 @staticmethod 

351 def _rgb_key(color): 

352 qc = QColor(color) 

353 return (qc.red(), qc.green(), qc.blue()) 

354 

355 def _background_display_hex(self): 

356 return self.background_display_color.name(QColor.NameFormat.HexRgb).upper() 

357 

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) 

366 

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" 

371 

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 ) 

399 

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) 

408 

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

417 

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 

422 

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

434 

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 

446 

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) 

461 

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 ) 

473 

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

496 

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) 

509 

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 

523 

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) 

532 

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) 

540 

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 } 

548 

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

559 

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) 

568 

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 

578 

579 def help_text(self) -> str: 

580 """パネル上部に常時表示していた説明。ヘルプボタンから開く。""" 

581 return tr("使用色:クリックで選択(Shift=範囲/Ctrl=追加)。" 

582 "[+フォルダー]でカテゴリーを作り、色をヘッダーへドラッグして格納。" 

583 "フォルダーのチェックで所属色を一括表示/非表示。" 

584 "ドラッグで並べ替え、色の中央へドロップ=その色の「子」として整理。" 

585 "親子付けはフォルダーのような整理用で、色は変わりません。" 

586 "解除は右クリックから行えます。" 

587 "色を2色以上選んで[統合]を押すと、最後に選んだ色へ実画像を塗り替えます。") 

588 

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

597 

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

605 

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 

617 

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 

642 

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) 

652 

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 ] 

661 

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 ) 

676 

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

682 

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

694 

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

713 

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

721 

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) 

738 

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) 

760 

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) 

777 

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

787 

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 } 

807 

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

812 

813 normalized = { 

814 rgb3(source): rgb3(destination) 

815 for source, destination in dict(mapping or {}).items() 

816 } 

817 

818 def remap(rgb): 

819 key = rgb3(rgb) 

820 return normalized.get(key, key) 

821 

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 

832 

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 ) 

858 

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

892 

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 

901 

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 ) 

923 

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 

929 

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) 

935 

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) 

957 

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 ) 

979 

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) 

989 

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

1004 

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) 

1020 

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 

1054 

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 

1059 

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) 

1073 

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 

1100 

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 

1128 

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 

1158 

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) 

1185 

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) 

1236 

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) 

1241 

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 

1252 

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) 

1270 

1271 old_mask_rgbs = set(self.mask_rgbs) 

1272 old_selected = list(self.selected_rgbs) 

1273 old_group_mapping = self._group_mapping() 

1274 

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) 

1284 

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 

1290 

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] 

1298 

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

1302 

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) 

1313 

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) 

1321 

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 

1334 

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

1342 

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

1359 

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) 

1365 

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

1375 

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 ) 

1390 

1391 selected_line_colors = { 

1392 tuple(value) for value in self.selected_rgbs 

1393 if tuple(value) != self.background_rgb 

1394 } 

1395 

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) 

1405 

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 ) 

1421 

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 ) 

1439 

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 ) 

1450 

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

1462 

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("色の選択をすべて解除")) 

1480 

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

1528 

1529 # ------------------------------------------------------------------ 

1530 # Undo履歴(並べ替え・親子・表示/マスク) 

1531 # ------------------------------------------------------------------