Coverage for paintmaskanimator/color_panel_widgets.py: 41%

680 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-11-02 04:50 +0000

1"""The row widgets that make up the used-color panel. 

2 

3Split out of ``color_panel.py``: the source-colour swatch button, the three 

4checkbox variants (visibility / mask / selection) with their custom painting and 

5sweep-drag behaviour, and the swatch row itself (``ColorSelectionArea``) with its 

6drag-and-drop handling. ``UsedColorPanel`` composes these; they know nothing 

7about the panel beyond the signals they emit. 

8""" 

9from PySide6.QtCore import QByteArray, QPoint, QPointF, QRectF, QTimer, Qt, Signal 

10from PySide6.QtGui import QColor, QCursor, QPainter, QPainterPath, QPen, QPixmap 

11from PySide6.QtWidgets import ( 

12 QApplication, 

13 QCheckBox, 

14 QHBoxLayout, 

15 QLabel, 

16 QToolButton, 

17 QWidget, 

18) 

19from . import icons, theme 

20from .i18n import tr 

21from .color_picker_dialog import ColorPickerDialog 

22from .utils import ( 

23 ScreenColorPickerOverlay, 

24 _ScreenColorDragMixin, 

25 show_screen_color_loupe, 

26) 

27from .logging_setup import get_logger 

28 

29from PySide6.QtCore import QMimeData 

30from PySide6.QtGui import QDrag 

31from PySide6.QtWidgets import QGraphicsOpacityEffect 

32 

33log = get_logger(__name__) 

34 

35# 使用色をドラッグ&ドロップで統合するときに使う専用MIME形式。 

36USED_COLOR_MIME = "application/x-pma-used-color" 

37 

38 

39class ColorCategoryHeader(QWidget): 

40 """使用色カテゴリーの折りたたみ・表示・ドロップ用ヘッダー。""" 

41 

42 visibilityChanged = Signal(bool) 

43 collapsedChanged = Signal(bool) 

44 colorDropped = Signal(object) 

45 contextMenuRequested = Signal(QPoint) 

46 

47 def __init__(self, name, parent=None): 

48 super().__init__(parent) 

49 self._name = str(name) 

50 self._drop_active = False 

51 self.setAcceptDrops(True) 

52 self.setObjectName("usedColorCategoryHeader") 

53 self.setMinimumHeight(32) 

54 self.setStyleSheet( 

55 "QWidget#usedColorCategoryHeader{" 

56 "background-color:palette(midlight);" 

57 "border:2px solid palette(mid);border-radius:4px;}" 

58 "QWidget#usedColorCategoryHeader:hover{" 

59 "border-color:palette(highlight);}" 

60 ) 

61 layout = QHBoxLayout(self) 

62 layout.setContentsMargins(6, 2, 7, 2) 

63 layout.setSpacing(4) 

64 self.collapse_button = QToolButton(self) 

65 self.collapse_button.setCheckable(True) 

66 self.collapse_button.setChecked(False) 

67 self.collapse_button.setText("▼") 

68 self.collapse_button.setToolTip(tr("フォルダーを折りたたむ")) 

69 self.visibility_check = QCheckBox(self) 

70 self.visibility_check.setTristate(True) 

71 self.visibility_check.setToolTip(tr("フォルダー内の色を一括表示/非表示")) 

72 self.name_label = QLabel(self._name, self) 

73 self.name_label.setStyleSheet("font-weight:700;") 

74 layout.addWidget(self.collapse_button) 

75 layout.addWidget(self.visibility_check) 

76 layout.addWidget(self.name_label, 1) 

77 self.collapse_button.toggled.connect(self._on_collapsed) 

78 self.visibility_check.clicked.connect(self.visibilityChanged.emit) 

79 

80 def name(self): 

81 return self._name 

82 

83 def setName(self, name): 

84 self._name = str(name) 

85 self.name_label.setText(self._name) 

86 

87 def setCollapsed(self, collapsed): 

88 self.collapse_button.blockSignals(True) 

89 self.collapse_button.setChecked(bool(collapsed)) 

90 self.collapse_button.blockSignals(False) 

91 self._sync_collapse_text(bool(collapsed)) 

92 

93 def _sync_collapse_text(self, collapsed): 

94 self.collapse_button.setText("▶" if collapsed else "▼") 

95 self.collapse_button.setToolTip( 

96 tr("フォルダーを展開") if collapsed else tr("フォルダーを折りたたむ") 

97 ) 

98 

99 def _on_collapsed(self, collapsed): 

100 self._sync_collapse_text(collapsed) 

101 self.collapsedChanged.emit(bool(collapsed)) 

102 

103 def setVisibilityState(self, values): 

104 states = [bool(value) for value in values] 

105 if states and all(states): 

106 state = Qt.CheckState.Checked 

107 elif states and any(states): 107 ↛ 108line 107 didn't jump to line 108 because the condition on line 107 was never true

108 state = Qt.CheckState.PartiallyChecked 

109 else: 

110 state = Qt.CheckState.Unchecked 

111 self.visibility_check.blockSignals(True) 

112 self.visibility_check.setCheckState(state) 

113 self.visibility_check.blockSignals(False) 

114 

115 @staticmethod 

116 def _payload_rgb(mime): 

117 if not mime.hasFormat(USED_COLOR_MIME): 

118 return None 

119 data = bytes(mime.data(USED_COLOR_MIME)) 

120 if len(data) < 3: 

121 return None 

122 return (data[0], data[1], data[2]) 

123 

124 def dragEnterEvent(self, event): 

125 if self._payload_rgb(event.mimeData()) is None: 

126 event.ignore() 

127 return 

128 self._drop_active = True 

129 self.update() 

130 event.acceptProposedAction() 

131 

132 def dragLeaveEvent(self, event): 

133 self._drop_active = False 

134 self.update() 

135 super().dragLeaveEvent(event) 

136 

137 def dropEvent(self, event): 

138 rgb = self._payload_rgb(event.mimeData()) 

139 self._drop_active = False 

140 self.update() 

141 if rgb is None: 

142 event.ignore() 

143 return 

144 event.acceptProposedAction() 

145 self.colorDropped.emit(rgb) 

146 

147 def paintEvent(self, event): 

148 super().paintEvent(event) 

149 if self._drop_active: 

150 painter = QPainter(self) 

151 painter.setPen(QPen(QColor("#ffca28"), 3)) 

152 painter.setBrush(Qt.BrushStyle.NoBrush) 

153 painter.drawRoundedRect(self.rect().adjusted(1, 1, -2, -2), 3, 3) 

154 

155 def contextMenuEvent(self, event): 

156 self.contextMenuRequested.emit(event.globalPos()) 

157 event.accept() 

158 

159 

160def _draw_eye_icon(painter, rect, color, is_open): 

161 """タイムラインと同じ16pxの表示切替アイコンを中央に描く。""" 

162 dpr = painter.device().devicePixelRatioF() if painter.device() else 1.0 

163 pixmap = icons.pixmap( 

164 "eye" if is_open else "eye_off", 16, color.name(), 

165 device_pixel_ratio=dpr, 

166 ) 

167 painter.drawPixmap( 

168 int(rect.left() + (rect.width() - 16) / 2), 

169 int(rect.top() + (rect.height() - 16) / 2), 

170 pixmap, 

171 ) 

172 

173 

174class SourceColorButton(_ScreenColorDragMixin, QToolButton): 

175 screenColorPicked = Signal(QColor) 

176 contextMenuRequested = Signal(QPoint) 

177 

178 def __init__(self, parent=None): 

179 super().__init__(parent) 

180 self._init_screen_color_drag() 

181 self._right_click_candidate = False 

182 self._right_click_press_global = None 

183 self.setContextMenuPolicy(Qt.ContextMenuPolicy.DefaultContextMenu) 

184 

185 def mousePressEvent(self, event): 

186 if event.button() == Qt.MouseButton.RightButton: 

187 self._right_click_candidate = True 

188 self._right_click_press_global = event.globalPosition().toPoint() 

189 event.accept() 

190 return 

191 super().mousePressEvent(event) 

192 

193 def mouseMoveEvent(self, event): 

194 if ( 

195 self._right_click_candidate 

196 and self._right_click_press_global is not None 

197 and event.buttons() & Qt.MouseButton.RightButton 

198 ): 

199 distance = ( 

200 event.globalPosition().toPoint() - self._right_click_press_global 

201 ).manhattanLength() 

202 if distance >= QApplication.startDragDistance(): 

203 self._right_click_candidate = False 

204 self._screen_pick_press_global = self._right_click_press_global 

205 self._begin_screen_pick(Qt.MouseButton.RightButton) 

206 event.accept() 

207 return 

208 super().mouseMoveEvent(event) 

209 

210 def mouseReleaseEvent(self, event): 

211 if ( 

212 event.button() == Qt.MouseButton.RightButton 

213 and self._right_click_candidate 

214 and not self._screen_pick_active 

215 ): 

216 self._right_click_candidate = False 

217 global_pos = event.globalPosition().toPoint() 

218 self._right_click_press_global = None 

219 self.contextMenuRequested.emit(global_pos) 

220 event.accept() 

221 return 

222 self._right_click_candidate = False 

223 self._right_click_press_global = None 

224 super().mouseReleaseEvent(event) 

225 

226 def contextMenuEvent(self, event): 

227 self.contextMenuRequested.emit(event.globalPos()) 

228 event.accept() 

229 

230 

231class ScreenEyedropButton(_ScreenColorDragMixin, QToolButton): 

232 """置換色の登録・画面スポイト・編集要求を提供するボタン。""" 

233 

234 colorPicked = Signal(QColor) 

235 colorEditorRequested = Signal(QPoint) 

236 

237 def __init__(self, parent=None): 

238 super().__init__(parent) 

239 self._init_screen_color_drag() 

240 self._screen_picker_overlay = None 

241 self._right_picker_candidate = False 

242 self.setContextMenuPolicy(Qt.ContextMenuPolicy.PreventContextMenu) 

243 self.clicked.connect(self._request_color_editor) 

244 

245 def _request_color_editor(self, _checked=False): 

246 self.colorEditorRequested.emit(QCursor.pos()) 

247 

248 def _screen_picker_closed(self, overlay): 

249 if self._screen_picker_overlay is overlay: 

250 self._screen_picker_overlay = None 

251 self._screen_pick_active = False 

252 

253 def _global_color_picked(self, overlay, color): 

254 self._screen_picker_closed(overlay) 

255 self.colorPicked.emit(QColor(color)) 

256 

257 def _begin_global_screen_pick(self): 

258 overlay = self._screen_picker_overlay 

259 if overlay is not None and overlay.isVisible(): 

260 return 

261 overlay = ScreenColorPickerOverlay() 

262 self._screen_picker_overlay = overlay 

263 self._screen_pick_active = True 

264 overlay.colorPicked.connect( 

265 lambda color, current=overlay: 

266 self._global_color_picked(current, color) 

267 ) 

268 overlay.canceled.connect( 

269 lambda current=overlay: self._screen_picker_closed(current) 

270 ) 

271 overlay.destroyed.connect( 

272 lambda _obj=None, current=overlay: 

273 self._screen_picker_closed(current) 

274 ) 

275 

276 # This method runs from the activating right-button release. Showing 

277 # the desktop window synchronously can make Windows deliver that same 

278 # release to the new window and immediately cancel it. 

279 def start_after_activation_release(): 

280 if self._screen_picker_overlay is overlay: 

281 overlay.start() 

282 

283 QTimer.singleShot(0, start_after_activation_release) 

284 

285 def mousePressEvent(self, event): 

286 if event.button() == Qt.MouseButton.RightButton: 

287 self._right_picker_candidate = True 

288 self._screen_pick_press_global = event.globalPosition().toPoint() 

289 event.accept() 

290 return 

291 # A plain left click belongs to the RGB / HSV editor. Bypass the mixin 

292 # so moving while left is held cannot start the eyedropper. 

293 QToolButton.mousePressEvent(self, event) 

294 

295 def mouseMoveEvent(self, event): 

296 if self._screen_pick_active and self._screen_picker_overlay is None: 296 ↛ 297line 296 didn't jump to line 297 because the condition on line 296 was never true

297 show_screen_color_loupe(self, event.globalPosition().toPoint()) 

298 event.accept() 

299 return 

300 if ( 300 ↛ 316line 300 didn't jump to line 316 because the condition on line 300 was always true

301 self._right_picker_candidate 

302 and self._screen_pick_press_global is not None 

303 and event.buttons() & Qt.MouseButton.RightButton 

304 ): 

305 distance = ( 

306 event.globalPosition().toPoint() - self._screen_pick_press_global 

307 ).manhattanLength() 

308 if distance >= QApplication.startDragDistance(): 308 ↛ 316line 308 didn't jump to line 316 because the condition on line 308 was always true

309 # Right-drag picks directly and confirms on release, like the 

310 # other colour buttons; a plain right click opens the overlay. 

311 self._right_picker_candidate = False 

312 self._begin_screen_pick(Qt.MouseButton.RightButton) 

313 show_screen_color_loupe(self, event.globalPosition().toPoint()) 

314 event.accept() 

315 return 

316 QToolButton.mouseMoveEvent(self, event) 

317 

318 def mouseReleaseEvent(self, event): 

319 if ( 

320 self._screen_pick_active 

321 and self._screen_picker_overlay is None 

322 and event.button() == self._screen_pick_button 

323 ): 

324 self._finish_screen_pick(event.globalPosition().toPoint()) 

325 event.accept() 

326 return 

327 if ( 

328 event.button() == Qt.MouseButton.RightButton 

329 and self._right_picker_candidate 

330 ): 

331 self._right_picker_candidate = False 

332 self._screen_pick_press_global = None 

333 self._begin_global_screen_pick() 

334 event.accept() 

335 return 

336 QToolButton.mouseReleaseEvent(self, event) 

337 

338 def hideEvent(self, event): 

339 overlay = self._screen_picker_overlay 

340 if overlay is not None: 340 ↛ 341line 340 didn't jump to line 341 because the condition on line 340 was never true

341 overlay.cancel() 

342 elif self._screen_pick_active: 342 ↛ 343line 342 didn't jump to line 343 because the condition on line 342 was never true

343 self._screen_pick_active = False 

344 self._screen_pick_button = Qt.MouseButton.NoButton 

345 self._cancel_screen_pick_cursor() 

346 self._right_picker_candidate = False 

347 QToolButton.hideEvent(self, event) 

348 

349 

350class ReplacementColorPopup(ColorPickerDialog): 

351 """置換色を編集するカラーピッカー(AE 風)。 

352 

353 開いたまま他の操作もできるよう、モーダルにはしない。「プレビュー」が 

354 ON の間は編集をそのまま置換色へ反映し、キャンセルしたら開いたときの色へ戻す。 

355 """ 

356 

357 def __init__(self, color, source_rgb=None, parent=None, preview=None): 

358 title = tr("置換色") 

359 if source_rgb is not None: 359 ↛ 363line 359 didn't jump to line 363 because the condition on line 359 was always true

360 title = tr("置換色 元色 #{hex}").format( 

361 hex="{:02X}{:02X}{:02X}".format(*source_rgb) 

362 ) 

363 super().__init__(color, parent, title, preview) 

364 self.setModal(False) 

365 self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True) 

366 

367 

368class ColorVisibilityCheckBox(QCheckBox): 

369 """固定幅の目のアイコンと、複数行をなぞる表示ON/OFFに対応。""" 

370 altClicked = Signal() 

371 sweepStarted = Signal(bool) 

372 sweepMoved = Signal(QPoint) 

373 sweepFinished = Signal() 

374 

375 def __init__(self, parent=None): 

376 super().__init__(parent) 

377 self._sweeping = False 

378 self._consume_release = False 

379 self.setCursor(Qt.CursorShape.PointingHandCursor) 

380 self.setFixedSize(30, 24) 

381 self.setStyleSheet( 

382 "QCheckBox{background:transparent;border:none;padding:0px;}" 

383 ) 

384 

385 def paintEvent(self, event): 

386 painter = QPainter(self) 

387 colors = theme.palette() 

388 color = QColor( 

389 colors["text"] if self.isChecked() and self.isEnabled() 

390 else colors["text_muted"] 

391 ) 

392 _draw_eye_icon(painter, self.rect(), color, self.isChecked()) 

393 painter.end() 

394 

395 def mousePressEvent(self, event): 

396 if event.button() != Qt.MouseButton.LeftButton: 

397 super().mousePressEvent(event) 

398 return 

399 if event.modifiers() & Qt.KeyboardModifier.AltModifier: 

400 self._consume_release = True 

401 self.altClicked.emit() 

402 event.accept() 

403 return 

404 

405 self._consume_release = False 

406 self._sweeping = True 

407 try: 

408 self.grabMouse() 

409 except RuntimeError as exc: 

410 log.debug("grabMouse() failed: %s", exc) 

411 self.sweepStarted.emit(not self.isChecked()) 

412 event.accept() 

413 

414 def mouseMoveEvent(self, event): 

415 if self._sweeping: 

416 self.sweepMoved.emit(event.globalPosition().toPoint()) 

417 event.accept() 

418 return 

419 super().mouseMoveEvent(event) 

420 

421 def mouseReleaseEvent(self, event): 

422 if self._consume_release and event.button() == Qt.MouseButton.LeftButton: 

423 self._consume_release = False 

424 event.accept() 

425 return 

426 if self._sweeping and event.button() == Qt.MouseButton.LeftButton: 

427 self._sweeping = False 

428 try: 

429 self.releaseMouse() 

430 except RuntimeError as exc: 

431 log.debug("releaseMouse() failed: %s", exc) 

432 self.sweepFinished.emit() 

433 event.accept() 

434 return 

435 super().mouseReleaseEvent(event) 

436 

437 

438 

439 

440class MaskColorCheckBox(QCheckBox): 

441 """使用色マスク専用。クリックと上下スライドのON/OFFに対応。""" 

442 altClicked = Signal() 

443 sweepStarted = Signal(bool) 

444 sweepMoved = Signal(QPoint) 

445 sweepFinished = Signal() 

446 

447 def __init__(self, parent=None): 

448 super().__init__(parent) 

449 self._sweeping = False 

450 self._consume_release = False 

451 self.setCursor(Qt.CursorShape.PointingHandCursor) 

452 self.setFixedSize(30, 24) 

453 self.setStyleSheet("QCheckBox{background:transparent;border:none;padding:0;}") 

454 self.setAttribute(Qt.WidgetAttribute.WA_OpaquePaintEvent, False) 

455 

456 def paintEvent(self, event): 

457 # ON は塗りつぶした的、OFF は細い輪だけにして、状態を形と色の両方で示す。 

458 painter = QPainter(self) 

459 painter.setRenderHint(QPainter.RenderHint.Antialiasing, True) 

460 center = QPointF(self.rect().center()) + QPointF(0.5, 0.5) 

461 if self.isChecked() and self.isEnabled(): 461 ↛ 470line 461 didn't jump to line 470 because the condition on line 461 was always true

462 accent = self.palette().highlight().color() 

463 painter.setPen(QPen(accent, 1.6)) 

464 painter.setBrush(Qt.BrushStyle.NoBrush) 

465 painter.drawEllipse(center, 6.5, 6.5) 

466 painter.setPen(Qt.PenStyle.NoPen) 

467 painter.setBrush(accent) 

468 painter.drawEllipse(center, 3.5, 3.5) 

469 else: 

470 painter.setPen(QPen(self.palette().mid().color(), 1.4)) 

471 painter.setBrush(Qt.BrushStyle.NoBrush) 

472 painter.drawEllipse(center, 6.5, 6.5) 

473 painter.end() 

474 

475 def nextCheckState(self): 

476 self.setChecked(not self.isChecked()) 

477 self.update() 

478 

479 def mousePressEvent(self, event): 

480 if event.button() != Qt.MouseButton.LeftButton: 

481 super().mousePressEvent(event) 

482 return 

483 if event.modifiers() & Qt.KeyboardModifier.AltModifier: 

484 self._consume_release = True 

485 self.altClicked.emit() 

486 event.accept() 

487 return 

488 self._consume_release = False 

489 self._sweeping = True 

490 try: 

491 self.grabMouse() 

492 except RuntimeError as exc: 

493 log.debug("grabMouse() failed: %s", exc) 

494 self.sweepStarted.emit(not self.isChecked()) 

495 event.accept() 

496 

497 def mouseMoveEvent(self, event): 

498 if self._sweeping: 

499 self.sweepMoved.emit(event.globalPosition().toPoint()) 

500 event.accept() 

501 return 

502 super().mouseMoveEvent(event) 

503 

504 def mouseReleaseEvent(self, event): 

505 if self._consume_release and event.button() == Qt.MouseButton.LeftButton: 

506 self._consume_release = False 

507 event.accept() 

508 return 

509 if self._sweeping and event.button() == Qt.MouseButton.LeftButton: 

510 self._sweeping = False 

511 try: 

512 self.releaseMouse() 

513 except RuntimeError as exc: 

514 log.debug("releaseMouse() failed: %s", exc) 

515 self.sweepFinished.emit() 

516 event.accept() 

517 return 

518 super().mouseReleaseEvent(event) 

519 

520 

521def _draw_check_glyph(painter, rect, color, is_checked): 

522 """モダンな角丸チェックボックスを描く。ON=アクセント塗り+チェック。""" 

523 painter.save() 

524 painter.setRenderHint(QPainter.RenderHint.Antialiasing, True) 

525 

526 size = min(rect.width(), rect.height()) 

527 box = QRectF(0, 0, size, size) 

528 box.moveCenter(QPointF(rect.center())) 

529 box.adjust(6.0, 6.0, -6.0, -6.0) 

530 

531 if is_checked: 531 ↛ 532line 531 didn't jump to line 532 because the condition on line 531 was never true

532 painter.setPen(Qt.PenStyle.NoPen) 

533 painter.setBrush(color) 

534 painter.drawRoundedRect(box, 3.0, 3.0) 

535 # 白いチェックマーク。 

536 pen = QPen(QColor("#ffffff")) 

537 pen.setWidthF(1.8) 

538 pen.setCapStyle(Qt.PenCapStyle.RoundCap) 

539 pen.setJoinStyle(Qt.PenJoinStyle.RoundJoin) 

540 painter.setPen(pen) 

541 painter.setBrush(Qt.BrushStyle.NoBrush) 

542 path = QPainterPath() 

543 path.moveTo(box.left() + box.width() * 0.24, box.top() + box.height() * 0.52) 

544 path.lineTo(box.left() + box.width() * 0.44, box.top() + box.height() * 0.72) 

545 path.lineTo(box.left() + box.width() * 0.78, box.top() + box.height() * 0.28) 

546 painter.drawPath(path) 

547 else: 

548 pen = QPen(color) 

549 pen.setWidthF(1.4) 

550 painter.setPen(pen) 

551 painter.setBrush(Qt.BrushStyle.NoBrush) 

552 painter.drawRoundedRect(box, 3.0, 3.0) 

553 painter.restore() 

554 

555 

556class ColorSelectionCheckBox(QCheckBox): 

557 """行頭の「選択」チェック。クリックと上下スイープの一括ON/OFFに対応。""" 

558 altClicked = Signal() 

559 sweepStarted = Signal(bool) 

560 sweepMoved = Signal(QPoint) 

561 sweepFinished = Signal() 

562 

563 def __init__(self, parent=None): 

564 super().__init__(parent) 

565 self._sweeping = False 

566 self._consume_release = False 

567 self.setCursor(Qt.CursorShape.PointingHandCursor) 

568 self.setFixedSize(30, 24) 

569 self.setStyleSheet( 

570 "QCheckBox{background:transparent;border:none;padding:0px;}" 

571 ) 

572 

573 def paintEvent(self, event): 

574 painter = QPainter(self) 

575 color = ( 

576 self.palette().highlight().color() 

577 if self.isChecked() else self.palette().mid().color() 

578 ) 

579 _draw_check_glyph(painter, self.rect(), color, self.isChecked()) 

580 painter.end() 

581 

582 def mousePressEvent(self, event): 

583 if event.button() != Qt.MouseButton.LeftButton: 

584 super().mousePressEvent(event) 

585 return 

586 if event.modifiers() & Qt.KeyboardModifier.AltModifier: 

587 self._consume_release = True 

588 self.altClicked.emit() 

589 event.accept() 

590 return 

591 self._consume_release = False 

592 self._sweeping = True 

593 try: 

594 self.grabMouse() 

595 except RuntimeError as exc: 

596 log.debug("grabMouse() failed: %s", exc) 

597 self.sweepStarted.emit(not self.isChecked()) 

598 event.accept() 

599 

600 def mouseMoveEvent(self, event): 

601 if self._sweeping: 

602 self.sweepMoved.emit(event.globalPosition().toPoint()) 

603 event.accept() 

604 return 

605 super().mouseMoveEvent(event) 

606 

607 def mouseReleaseEvent(self, event): 

608 if self._consume_release and event.button() == Qt.MouseButton.LeftButton: 

609 self._consume_release = False 

610 event.accept() 

611 return 

612 if self._sweeping and event.button() == Qt.MouseButton.LeftButton: 

613 self._sweeping = False 

614 try: 

615 self.releaseMouse() 

616 except RuntimeError as exc: 

617 log.debug("releaseMouse() failed: %s", exc) 

618 self.sweepFinished.emit() 

619 event.accept() 

620 return 

621 super().mouseReleaseEvent(event) 

622 

623 

624class CheckClickArea(QWidget): 

625 """薄い判定領域全体でチェックを操作できるラッパー。""" 

626 

627 def __init__(self, checkbox, parent=None): 

628 super().__init__(parent) 

629 self.checkbox = checkbox 

630 self._sweeping = False 

631 self.setFixedWidth(32) 

632 self.setFixedHeight(26) 

633 self.setCursor(Qt.CursorShape.PointingHandCursor) 

634 self.setStyleSheet( 

635 "CheckClickArea{background:transparent;border:none;}" 

636 "CheckClickArea:hover{background:palette(alternate-base);border:none;}" 

637 ) 

638 layout = QHBoxLayout(self) 

639 layout.setContentsMargins(0, 0, 0, 0) 

640 layout.setAlignment(Qt.AlignmentFlag.AlignCenter) 

641 layout.addWidget(checkbox) 

642 

643 def mousePressEvent(self, event): 

644 if event.button() == Qt.MouseButton.LeftButton: 

645 if event.modifiers() & Qt.KeyboardModifier.AltModifier: 

646 self.checkbox.altClicked.emit() 

647 elif self.checkbox.isEnabled() and hasattr(self.checkbox, "sweepStarted"): 

648 self._sweeping = True 

649 try: 

650 self.grabMouse() 

651 except RuntimeError as exc: 

652 log.debug("grabMouse() failed: %s", exc) 

653 self.checkbox.sweepStarted.emit(not self.checkbox.isChecked()) 

654 elif self.checkbox.isEnabled(): 

655 self.checkbox.setChecked(not self.checkbox.isChecked()) 

656 event.accept() 

657 return 

658 super().mousePressEvent(event) 

659 

660 def mouseMoveEvent(self, event): 

661 if self._sweeping and event.buttons() & Qt.MouseButton.LeftButton: 

662 self.checkbox.sweepMoved.emit(event.globalPosition().toPoint()) 

663 event.accept() 

664 return 

665 super().mouseMoveEvent(event) 

666 

667 def mouseReleaseEvent(self, event): 

668 if self._sweeping and event.button() == Qt.MouseButton.LeftButton: 

669 self._sweeping = False 

670 try: 

671 self.releaseMouse() 

672 except RuntimeError as exc: 

673 log.debug("releaseMouse() failed: %s", exc) 

674 self.checkbox.sweepFinished.emit() 

675 event.accept() 

676 return 

677 super().mouseReleaseEvent(event) 

678 

679 

680class ColorSelectionArea(QWidget): 

681 """使用色行/使用色枠。クリック、Shift選択、他色へのドラッグ統合に対応。""" 

682 

683 clicked = Signal(object) 

684 dragStarted = Signal() 

685 # (運んできた色rgb, モード) モード: "before" / "child" / "after" 

686 colorDropped = Signal(object, str) 

687 contextMenuRequested = Signal(QPoint) 

688 hoverChanged = Signal(bool) 

689 

690 def __init__(self, parent=None): 

691 super().__init__(parent) 

692 self._selected = False 

693 self._selection_frame_enabled = False 

694 # 親子グループを示す左端の縦ライン色(None=グループなし)。 

695 self._group_line_color = None 

696 self._rgb = None 

697 self._is_background = False 

698 self._press_pos = None 

699 self._press_modifiers = Qt.KeyboardModifier.NoModifier 

700 self._drag_started = False 

701 self._drop_mode = None 

702 self.setMouseTracking(True) 

703 self.setAcceptDrops(True) 

704 

705 def setColorKey(self, rgb, is_background=False): 

706 self._rgb = rgb 

707 self._is_background = bool(is_background) 

708 

709 def _mode_for_pos(self, y): 

710 """行内のY位置から、並べ替え(before/after)か子化(child)かを決める。""" 

711 height = max(1, self.height()) 

712 ratio = min(1.0, max(0.0, y / height)) 

713 if ratio < 0.28: 

714 return "before" 

715 if ratio > 0.72: 

716 return "after" 

717 return "child" 

718 

719 def setSelected(self, selected): 

720 """選択状態を、色に依存しない単一アクセント枠で表す。""" 

721 selected = bool(selected) 

722 changed = self._selected != selected or not self._selection_frame_enabled 

723 self._selected = selected 

724 self._selection_frame_enabled = True 

725 if changed: 

726 self.update() 

727 

728 def setGroupLine(self, color): 

729 """親子グループを示す左端の縦ライン色を設定する(None=非表示)。""" 

730 new_color = QColor(color) if color is not None else None 

731 old = self._group_line_color 

732 same = ( 

733 (old is None and new_color is None) 

734 or (old is not None and new_color is not None and old.rgb() == new_color.rgb()) 

735 ) 

736 self._group_line_color = new_color 

737 if not same: 

738 self.update() 

739 

740 def enterEvent(self, event): 

741 self.hoverChanged.emit(True) 

742 super().enterEvent(event) 

743 

744 def leaveEvent(self, event): 

745 self.hoverChanged.emit(False) 

746 super().leaveEvent(event) 

747 

748 def paintEvent(self, event): 

749 super().paintEvent(event) 

750 painter = QPainter(self) 

751 painter.setRenderHint(QPainter.RenderHint.Antialiasing, True) 

752 

753 # 親子グループを、色を触らずに左端の縦ラインで束ねて示す。 

754 if self._group_line_color is not None: 754 ↛ 755line 754 didn't jump to line 755 because the condition on line 754 was never true

755 painter.setPen(Qt.PenStyle.NoPen) 

756 painter.setBrush(self._group_line_color) 

757 painter.drawRoundedRect(QRectF(1.0, 1.0, 3.0, self.height() - 2.0), 1.5, 1.5) 

758 

759 # 選択は親子で色分けせず、テーマのアクセント色による細い角丸枠に統一。 

760 if self._selection_frame_enabled and self._selected: 760 ↛ 761line 760 didn't jump to line 761 because the condition on line 760 was never true

761 accent = self.palette().highlight().color() 

762 pen = QPen(accent) 

763 pen.setWidthF(2.0) 

764 painter.setPen(pen) 

765 painter.setBrush(Qt.BrushStyle.NoBrush) 

766 painter.drawRoundedRect( 

767 QRectF(self.rect()).adjusted(1.5, 1.5, -1.5, -1.5), 4.0, 4.0 

768 ) 

769 

770 painter.setRenderHint(QPainter.RenderHint.Antialiasing, False) 

771 if self._drop_mode == "child": 771 ↛ 773line 771 didn't jump to line 773 because the condition on line 771 was never true

772 # 子化のドロップ先は、半透明の塗り+太い黄色枠で強調する。 

773 fill = QColor("#ffca28") 

774 fill.setAlpha(60) 

775 painter.fillRect(self.rect().adjusted(1, 1, -2, -2), fill) 

776 painter.setPen(QPen(QColor("#ffca28"), 3)) 

777 painter.setBrush(Qt.BrushStyle.NoBrush) 

778 painter.drawRect(self.rect().adjusted(1, 1, -2, -2)) 

779 elif self._drop_mode in ("before", "after"): 779 ↛ 781line 779 didn't jump to line 781 because the condition on line 779 was never true

780 # 並べ替えのドロップ位置を、太い挿入バー+左右の丸で明示する。 

781 bar_color = QColor("#ffca28") 

782 y = 2 if self._drop_mode == "before" else self.height() - 3 

783 painter.setPen(QPen(bar_color, 4, Qt.PenStyle.SolidLine, Qt.PenCapStyle.RoundCap)) 

784 painter.drawLine(6, y, self.width() - 7, y) 

785 painter.setPen(Qt.PenStyle.NoPen) 

786 painter.setBrush(bar_color) 

787 painter.drawEllipse(QPointF(6, y), 3.5, 3.5) 

788 painter.drawEllipse(QPointF(self.width() - 7, y), 3.5, 3.5) 

789 painter.end() 

790 

791 def mousePressEvent(self, event): 

792 if event.button() == Qt.MouseButton.LeftButton: 

793 self._press_pos = event.position().toPoint() 

794 self._press_modifiers = event.modifiers() 

795 self._drag_started = False 

796 event.accept() 

797 return 

798 if event.button() == Qt.MouseButton.RightButton: 

799 self.contextMenuRequested.emit(event.globalPosition().toPoint()) 

800 event.accept() 

801 return 

802 super().mousePressEvent(event) 

803 

804 def mouseMoveEvent(self, event): 

805 if ( 

806 self._press_pos is not None 

807 and not self._drag_started 

808 and event.buttons() & Qt.MouseButton.LeftButton 

809 and self._rgb is not None 

810 and not self._is_background 

811 ): 

812 distance = ( 

813 event.position().toPoint() - self._press_pos 

814 ).manhattanLength() 

815 if distance >= QApplication.startDragDistance(): 

816 self._start_reorder_drag() 

817 event.accept() 

818 return 

819 super().mouseMoveEvent(event) 

820 

821 def _start_reorder_drag(self): 

822 if self._rgb is None: 

823 return 

824 self._drag_started = True 

825 self.dragStarted.emit() 

826 red, green, blue = self._rgb 

827 mime = QMimeData() 

828 mime.setData( 

829 USED_COLOR_MIME, 

830 QByteArray(bytes((red, green, blue))), 

831 ) 

832 drag = QDrag(self) 

833 drag.setMimeData(mime) 

834 

835 # ドラッグ中は運んでいる色を、少し浮き上がったカードとしてカーソルに付ける。 

836 scale = self.devicePixelRatioF() if hasattr(self, "devicePixelRatioF") else 1.0 

837 pw, ph = 40, 28 

838 pixmap = QPixmap(int(pw * scale), int(ph * scale)) 

839 pixmap.setDevicePixelRatio(scale) 

840 pixmap.fill(Qt.GlobalColor.transparent) 

841 painter = QPainter(pixmap) 

842 painter.setRenderHint(QPainter.RenderHint.Antialiasing, True) 

843 # 影で「持ち上げている」感を出す。 

844 shadow = QColor(0, 0, 0, 70) 

845 painter.setPen(Qt.PenStyle.NoPen) 

846 painter.setBrush(shadow) 

847 painter.drawRoundedRect(QRectF(3, 4, pw - 5, ph - 5), 4, 4) 

848 # 運んでいる色本体。 

849 painter.setBrush(QColor(red, green, blue)) 

850 painter.setPen(QPen(QColor("#ffffff"), 1.5)) 

851 painter.drawRoundedRect(QRectF(1, 1, pw - 6, ph - 7), 4, 4) 

852 painter.end() 

853 drag.setPixmap(pixmap) 

854 drag.setHotSpot(QPoint(int((pw - 6) / 2), int((ph - 7) / 2))) 

855 

856 # ドラッグ元の行を半透明にして、掴んでいることが分かるようにする。 

857 effect = QGraphicsOpacityEffect(self) 

858 effect.setOpacity(0.4) 

859 self.setGraphicsEffect(effect) 

860 try: 

861 drag.exec(Qt.DropAction.MoveAction) 

862 finally: 

863 self.setGraphicsEffect(None) # pyright: ignore[reportArgumentType] # None clears the effect 

864 self._press_pos = None 

865 

866 def mouseReleaseEvent(self, event): 

867 if event.button() == Qt.MouseButton.LeftButton: 

868 was_drag = self._drag_started 

869 self._press_pos = None 

870 self._drag_started = False 

871 if not was_drag: 

872 # ドラッグにならなければ、通常の選択トグルとして扱う。 

873 self.clicked.emit(self._press_modifiers) 

874 event.accept() 

875 return 

876 super().mouseReleaseEvent(event) 

877 

878 def _payload_rgb(self, mime): 

879 if not mime.hasFormat(USED_COLOR_MIME): 

880 return None 

881 data = bytes(mime.data(USED_COLOR_MIME)) 

882 if len(data) < 3: 

883 return None 

884 return (data[0], data[1], data[2]) 

885 

886 def _accepts(self, event): 

887 payload = self._payload_rgb(event.mimeData()) 

888 # 背景行はドロップ先にしない。自分自身へのドロップも無視する。 

889 return payload is not None and payload != self._rgb and not self._is_background 

890 

891 def dragEnterEvent(self, event): 

892 if not self._accepts(event): 

893 event.ignore() 

894 return 

895 event.acceptProposedAction() 

896 self._drop_mode = self._mode_for_pos(event.position().toPoint().y()) 

897 self.update() 

898 

899 def dragMoveEvent(self, event): 

900 if not self._accepts(event): 

901 event.ignore() 

902 return 

903 event.acceptProposedAction() 

904 mode = self._mode_for_pos(event.position().toPoint().y()) 

905 if mode != self._drop_mode: 

906 self._drop_mode = mode 

907 self.update() 

908 

909 def dragLeaveEvent(self, event): 

910 if self._drop_mode is not None: 

911 self._drop_mode = None 

912 self.update() 

913 

914 def dropEvent(self, event): 

915 mode = self._drop_mode or self._mode_for_pos( 

916 event.position().toPoint().y() 

917 ) 

918 self._drop_mode = None 

919 self.update() 

920 if not self._accepts(event): 

921 event.ignore() 

922 return 

923 event.acceptProposedAction() 

924 self.colorDropped.emit(self._payload_rgb(event.mimeData()), mode) 

925 

926 def contextMenuEvent(self, event): 

927 self.contextMenuRequested.emit(event.globalPos()) 

928 event.accept()