Coverage for paintmaskanimator/utils.py: 82%

500 statements  

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

1import sys 

2from contextlib import contextmanager 

3from pathlib import Path 

4from typing import Any 

5from PySide6.QtCore import QEvent, QObject, QPointF, QRect, QRectF, QTimer, Qt, Signal 

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

7from PySide6.QtWidgets import QApplication, QWidget 

8from .i18n import tr 

9from .constants import OUTSIDE_MARGIN 

10from typing import TYPE_CHECKING 

11from . import constants 

12from .logging_setup import get_logger 

13 

14log = get_logger(__name__) 

15 

16if TYPE_CHECKING: 

17 from PySide6.QtWidgets import QWidget as _DragBase 

18else: 

19 _DragBase = object 

20 

21 

22# アプリ全体(QApplication)に設置した Python のイベントフィルター。 

23_app_event_filters: list = [] 

24 

25 

26def install_app_event_filter(obj): 

27 """QApplication にイベントフィルターを設置し、一時停止できるよう記録する。""" 

28 app = QApplication.instance() 

29 if app is None: 29 ↛ 30line 29 didn't jump to line 30 because the condition on line 29 was never true

30 return 

31 app.installEventFilter(obj) 

32 if obj not in _app_event_filters: 32 ↛ exitline 32 didn't return from function 'install_app_event_filter' because the condition on line 32 was always true

33 _app_event_filters.append(obj) 

34 

35 

36def remove_app_event_filter(obj): 

37 app = QApplication.instance() 

38 if app is not None: 38 ↛ 40line 38 didn't jump to line 40 because the condition on line 38 was always true

39 app.removeEventFilter(obj) 

40 if obj in _app_event_filters: 

41 _app_event_filters.remove(obj) 

42 

43 

44def _is_alive(obj): 

45 import shiboken6 

46 

47 return shiboken6.isValid(obj) 

48 

49 

50@contextmanager 

51def app_event_filters_suspended(): 

52 """アプリ全体の Python イベントフィルターを一時的に外す。 

53 

54 PySide6 6.11 では、アプリ全体に Python のイベントフィルターがあると、 

55 QtWebEngine(アクション編集の Monaco エディター)の内部オブジェクトの 

56 ラッパー生成が再入してセグメンテーション違反で落ちる。フィルターの 

57 中身に関係なく起きるため、WebEngine を表示している間だけ外す。 

58 """ 

59 app = QApplication.instance() 

60 suspended = [obj for obj in _app_event_filters if _is_alive(obj)] 

61 if app is not None: 61 ↛ 64line 61 didn't jump to line 64 because the condition on line 61 was always true

62 for obj in suspended: 

63 app.removeEventFilter(obj) 

64 try: 

65 yield 

66 finally: 

67 app = QApplication.instance() 

68 if app is not None: 68 ↛ exitline 68 didn't return from function 'app_event_filters_suspended' because the condition on line 68 was always true

69 for obj in suspended: 

70 # 停止中に外された・破棄されたものは戻さない。 

71 if obj in _app_event_filters and _is_alive(obj): 

72 app.installEventFilter(obj) 

73 

74 

75def workspace_size(): 

76 return constants.CANVAS_WIDTH + OUTSIDE_MARGIN * 2, constants.CANVAS_HEIGHT + OUTSIDE_MARGIN * 2 

77 

78 

79def top_level_window_ids(widgets): 

80 """各ウィジェットが属するトップレベルウィンドウのHWNDを重複なく返す。 

81 

82 子ウィジェットに対して ``winId()`` を呼ぶと、Qt はそのウィジェットと 

83 先祖・兄弟までネイティブウィンドウ(WA_NativeWindow)に昇格させる。 

84 ドックのスプリッターやドックウィジェットまでネイティブになると、 

85 レイアウトのたびに親からの相対座標で ``QWindowsWindow::setGeometry`` 

86 が走り、マルチモニタ環境では 

87 「Unable to set geometry ... on QWidgetWindow/"subviewDockWindow"」 

88 「ads::CDockSplitterClassWindow must be a top level window」 

89 といった警告が大量に出る。設定したいウィンドウ単位の属性は 

90 トップレベルのHWNDに付けるだけで足り、子は alien のままでよい。 

91 """ 

92 handles = [] 

93 for widget in widgets: 

94 if widget is None: 

95 continue 

96 try: 

97 window = widget.window() 

98 if window is None: 98 ↛ 99line 98 didn't jump to line 99 because the condition on line 98 was never true

99 continue 

100 handle = int(window.winId()) 

101 except (RuntimeError, ValueError, TypeError) as exc: 

102 # winId() fails for a not-yet-realized or destroyed widget. 

103 log.debug("winId() unavailable for widget: %s", exc) 

104 continue 

105 if handle and handle not in handles: 

106 handles.append(handle) 

107 return handles 

108 

109 

110def disable_windows_ink_feedback(*widgets): 

111 """Windows Inkの波紋・長押し・タップ視覚効果を無効化する。""" 

112 if sys.platform != "win32": 112 ↛ 115line 112 didn't jump to line 115 because the condition on line 112 was always true

113 return 

114 

115 try: 

116 import ctypes 

117 from ctypes import wintypes 

118 

119 function = ( 

120 ctypes.windll.user32.SetWindowFeedbackSetting 

121 ) 

122 function.argtypes = [ 

123 wintypes.HWND, 

124 ctypes.c_uint, 

125 wintypes.DWORD, 

126 ctypes.c_uint, 

127 ctypes.c_void_p, 

128 ] 

129 function.restype = wintypes.BOOL 

130 

131 # FWFS_OVERRIDE 

132 flags = 0x00000001 

133 disabled = wintypes.BOOL(False) 

134 

135 # 1~11: 

136 # タッチ接触表示、ペン樽表示、タップ、ダブルタップ、 

137 # 長押し、右タップ、タッチ系表示、PressAndTap。 

138 feedback_types = range(1, 12) 

139 

140 for window_id in top_level_window_ids(widgets): 

141 handle = wintypes.HWND(window_id) 

142 

143 for feedback_type in feedback_types: 

144 try: 

145 function( 

146 handle, 

147 int(feedback_type), 

148 flags, 

149 ctypes.sizeof(disabled), 

150 ctypes.byref(disabled), 

151 ) 

152 except OSError as exc: 

153 log.debug( 

154 "SetWindowFeedbackSetting(%s) failed: %s", 

155 feedback_type, 

156 exc, 

157 ) 

158 except (OSError, AttributeError, ImportError) as exc: 

159 # Windowsのバージョンや環境が未対応でも起動は継続する。 

160 log.debug("Windows Ink feedback tweak unavailable: %s", exc) 

161 

162 

163class _DoubleClickFilter(QObject): 

164 def __init__(self, callback, parent): 

165 super().__init__(parent) 

166 self._callback = callback 

167 

168 def eventFilter(self, watched, event): 

169 if ( 

170 event.type() == QEvent.Type.MouseButtonDblClick 

171 and event.button() == Qt.MouseButton.LeftButton 

172 ): 

173 self._callback() 

174 return True 

175 return super().eventFilter(watched, event) 

176 

177 

178def on_double_click(widget, callback): 

179 """``widget`` を左ダブルクリックしたら ``callback()`` を呼ぶ。 

180 

181 QPushButton などダブルクリックのシグナルを持たない部品に付ける。 

182 1回目のクリックは通常どおり ``clicked`` として届く。 

183 """ 

184 widget.installEventFilter(_DoubleClickFilter(callback, widget)) 

185 

186 

187class _BlankTemplate: 

188 size: "tuple[int, int] | None" = None 

189 image: "QImage | None" = None 

190 

191 

192def blank_image(fill: Any = Qt.GlobalColor.transparent): 

193 w, h = workspace_size() 

194 if fill == Qt.GlobalColor.transparent: 

195 # 透明な空画像は1枚の雛形を暗黙共有で配る。QImage は書き込んだ時点 

196 # (QPainter や bits())で複製されるので、雛形は汚れない。セルを空けるたびに 

197 # 作業領域全体を確保して塗りつぶすと、大きなキャンバスで重くなるため。 

198 template = _BlankTemplate.image 

199 if template is None or _BlankTemplate.size != (w, h): 

200 template = QImage(w, h, QImage.Format.Format_ARGB32_Premultiplied) 

201 template.fill(fill) 

202 _BlankTemplate.size = (w, h) 

203 _BlankTemplate.image = template 

204 return QImage(template) 

205 im = QImage(w, h, QImage.Format.Format_ARGB32_Premultiplied) 

206 im.fill(fill) 

207 return im 

208 

209 

210def paper_image(): 

211 """White paper only inside the real canvas; outside stays transparent/dark.""" 

212 im = blank_image() 

213 p = QPainter(im) 

214 p.fillRect(OUTSIDE_MARGIN, OUTSIDE_MARGIN, constants.CANVAS_WIDTH, constants.CANVAS_HEIGHT, QColor("white")) 

215 p.end() 

216 return im 

217 

218 

219def checker_pixmap(width=48, height=28, cell=7): 

220 pm = QPixmap(width, height) 

221 p = QPainter(pm) 

222 for y in range(0, height, cell): 

223 for x in range(0, width, cell): 

224 c = QColor(235,235,235) if ((x//cell)+(y//cell)) % 2 == 0 else QColor(165,165,165) 

225 p.fillRect(x, y, cell, cell, c) 

226 p.end() 

227 return pm 

228 

229 

230#: 一時ズーム(ドラッグ)で 1px 動かしたときの倍率。 

231DRAG_ZOOM_STEP = 1.01 

232 

233 

234def drag_zoom_factor(dx): 

235 """ドラッグズームの倍率。CLIP STUDIO PAINT と同じく右で拡大・左で縮小。""" 

236 return DRAG_ZOOM_STEP ** float(dx) 

237 

238 

239_magnifier_cursor_cache = None 

240 

241#: ツールの形をしたカーソルを出すツール。ブラシはリング描画、スポイト・ハンド・ 

242#: ズームはそれぞれ専用カーソルがあるので、ここには入れない。 

243TOOL_CURSOR_NAMES = ( 

244 "shape", 

245 "bucket", 

246 "closed_fill", 

247 "lasso_fill", 

248 "lasso", 

249 "rect_select", 

250 "auto_select", 

251 "dust", 

252) 

253 

254_tool_cursor_cache: dict = {} 

255 

256 

257def tool_cursor(tool_id): 

258 """ツールアイコンの形をしたカーソル。見つからなければ ``None``。 

259 

260 今どのツールを使っているかを、ツールバーへ視線を動かさず判別できるように 

261 する(Issue #45)。左上にクリック位置を示す十字、その右下にツールの絵を 

262 置く。暗い絵の上でも消えないよう、白い縁取りの上に黒で描く。 

263 """ 

264 if tool_id not in TOOL_CURSOR_NAMES: 

265 return None 

266 cached = _tool_cursor_cache.get(tool_id) 

267 if cached is not None: 

268 return cached 

269 

270 from . import icons 

271 

272 glyph = icons.pixmap(tool_id, 16, color="#000000", accent="#000000") 

273 halo = icons.pixmap(tool_id, 16, color="#ffffff", accent="#ffffff") 

274 

275 pixmap = QPixmap(32, 32) 

276 pixmap.fill(Qt.GlobalColor.transparent) 

277 painter = QPainter(pixmap) 

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

279 # 十字(クリック位置)。白を太く敷いてから黒を重ねる。 

280 for color, width in ((QColor("white"), 3.0), (QColor("black"), 1.4)): 

281 painter.setPen(QPen(color, width)) 

282 painter.drawLine(QPointF(0.5, 6.5), QPointF(12.5, 6.5)) 

283 painter.drawLine(QPointF(6.5, 0.5), QPointF(6.5, 12.5)) 

284 # ツールの絵。1px ずらした白版を敷いて縁取りにする。 

285 for dx in (-1, 0, 1): 

286 for dy in (-1, 0, 1): 

287 if dx or dy: 

288 painter.drawPixmap(14 + dx, 14 + dy, halo) 

289 painter.drawPixmap(14, 14, glyph) 

290 painter.end() 

291 

292 cursor = QCursor(pixmap, 6, 6) 

293 _tool_cursor_cache[tool_id] = cursor 

294 return cursor 

295 

296 

297 

298def magnifier_cursor(): 

299 """一時ズーム中に表示する虫眼鏡カーソル。""" 

300 global _magnifier_cursor_cache 

301 if _magnifier_cursor_cache is not None: 

302 return _magnifier_cursor_cache 

303 pixmap = QPixmap(24, 24) 

304 pixmap.fill(Qt.GlobalColor.transparent) 

305 painter = QPainter(pixmap) 

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

307 # 白い縁取りで、暗い背景でも黒い本体が見えるようにする。 

308 for color, width in ((QColor("white"), 4.0), (QColor("black"), 2.0)): 

309 pen = QPen(color, width) 

310 pen.setCapStyle(Qt.PenCapStyle.RoundCap) 

311 painter.setPen(pen) 

312 painter.setBrush(Qt.BrushStyle.NoBrush) 

313 painter.drawEllipse(QPointF(9.5, 9.5), 6.5, 6.5) 

314 painter.drawLine(QPointF(14.5, 14.5), QPointF(21.0, 21.0)) 

315 painter.setPen(QPen(QColor("black"), 1.6)) 

316 painter.drawLine(QPointF(6.5, 9.5), QPointF(12.5, 9.5)) 

317 painter.drawLine(QPointF(9.5, 6.5), QPointF(9.5, 12.5)) 

318 painter.end() 

319 _magnifier_cursor_cache = QCursor(pixmap, 9, 9) 

320 return _magnifier_cursor_cache 

321 

322 

323def _sample_screen_color(global_position): 

324 """Return the opaque screen color at a global position.""" 

325 screen = QApplication.screenAt(global_position) or QApplication.primaryScreen() 

326 if screen is None: 

327 return None 

328 geometry = screen.geometry() 

329 pixmap = screen.grabWindow( 

330 0, 

331 global_position.x() - geometry.x(), 

332 global_position.y() - geometry.y(), 

333 1, 

334 1, 

335 ) 

336 image = pixmap.toImage() 

337 if image.isNull(): 337 ↛ 338line 337 didn't jump to line 338 because the condition on line 337 was never true

338 return None 

339 color = image.pixelColor(0, 0) 

340 color.setAlpha(255) 

341 return color 

342 

343 

344_LOUPE_LIGHT = QColor("#ffffff") 

345_LOUPE_DARK = QColor("#1e1e1e") 

346 

347 

348def _relative_luminance(color): 

349 def channel(value): 

350 value /= 255.0 

351 return value / 12.92 if value <= 0.03928 else ((value + 0.055) / 1.055) ** 2.4 

352 

353 return ( 

354 0.2126 * channel(color.red()) 

355 + 0.7152 * channel(color.green()) 

356 + 0.0722 * channel(color.blue()) 

357 ) 

358 

359 

360def loupe_ui_color(background): 

361 """Return the white or dark UI colour that reads best over ``background``. 

362 

363 White lines and labels vanish when the eyedropper hovers over a bright 

364 colour, so the loupe switches to dark ones there. 

365 """ 

366 if background is None or not QColor(background).isValid(): 

367 return QColor(_LOUPE_LIGHT) 

368 luminance = _relative_luminance(QColor(background)) 

369 # Pick whichever of the two has the higher WCAG contrast ratio. 

370 light_contrast = 1.05 / (luminance + 0.05) 

371 dark_contrast = (luminance + 0.05) / (_relative_luminance(_LOUPE_DARK) + 0.05) 

372 return QColor(_LOUPE_LIGHT if light_contrast >= dark_contrast else _LOUPE_DARK) 

373 

374 

375class ScreenColorLoupe(QWidget): 

376 """Click-through screen magnifier shared by every eyedropper path.""" 

377 

378 def __init__(self, parent=None): 

379 super().__init__(parent) 

380 # WindowTransparentForInput makes the native window click-through too, 

381 # so a loupe under the pointer never swallows the eyedropper's drag. 

382 self.setWindowFlags( 

383 Qt.WindowType.Tool 

384 | Qt.WindowType.FramelessWindowHint 

385 | Qt.WindowType.WindowStaysOnTopHint 

386 | Qt.WindowType.WindowTransparentForInput 

387 ) 

388 self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents) 

389 self.setAttribute(Qt.WidgetAttribute.WA_ShowWithoutActivating) 

390 self.setFixedSize(112, 136) 

391 self._sample = QImage() 

392 self._before = QColor() 

393 self._after = QColor() 

394 

395 def show_at(self, global_position, before_color=None): 

396 screen = QApplication.screenAt(global_position) or QApplication.primaryScreen() 

397 if screen is None: 397 ↛ 398line 397 didn't jump to line 398 because the condition on line 397 was never true

398 return 

399 geometry = screen.geometry() 

400 radius = 6 

401 pixmap = screen.grabWindow( 

402 0, 

403 global_position.x() - geometry.x() - radius, 

404 global_position.y() - geometry.y() - radius, 

405 radius * 2 + 1, 

406 radius * 2 + 1, 

407 ) 

408 self._sample = pixmap.toImage() 

409 self._before = QColor(before_color) if before_color is not None else QColor() 

410 self._after = _sample_screen_color(global_position) or QColor() 

411 x = global_position.x() + 24 

412 if x + self.width() > geometry.right(): 412 ↛ 413line 412 didn't jump to line 413 because the condition on line 412 was never true

413 x = global_position.x() - self.width() - 24 

414 y = global_position.y() - self.height() // 2 

415 y = max(geometry.top(), min(geometry.bottom() - self.height() + 1, y)) 

416 self.move(x, y) 

417 self.show() 

418 self.raise_() 

419 self.update() 

420 

421 def paintEvent(self, _event): 

422 painter = QPainter(self) 

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

424 circle = QRectF(8, 4, 96, 96) 

425 path = QPainterPath() 

426 path.addEllipse(circle) 

427 painter.setClipPath(path) 

428 painter.fillRect(circle, QColor("#20242a")) 

429 if not self._sample.isNull(): 429 ↛ 432line 429 didn't jump to line 432 because the condition on line 429 was always true

430 painter.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform, False) 

431 painter.drawImage(circle, self._sample) 

432 painter.setClipping(False) 

433 before = self._before if self._before.isValid() else QColor("#000000") 

434 after = self._after if self._after.isValid() else QColor("#000000") 

435 # The ring and crosshair sit over the colour being picked, so they 

436 # follow its brightness; each swatch label follows its own swatch. 

437 ui_color = loupe_ui_color(after) 

438 painter.setPen(QPen(ui_color, 2)) 

439 painter.setBrush(Qt.BrushStyle.NoBrush) 

440 painter.drawEllipse(circle) 

441 center = circle.center() 

442 painter.drawLine(QPointF(center.x() - 8, center.y()), QPointF(center.x() + 8, center.y())) 

443 painter.drawLine(QPointF(center.x(), center.y() - 8), QPointF(center.x(), center.y() + 8)) 

444 before_rect = QRectF(12, 106, 42, 22) 

445 after_rect = QRectF(58, 106, 42, 22) 

446 for rect, color, label in ( 

447 (before_rect, before, tr("前")), 

448 (after_rect, after, tr("後")), 

449 ): 

450 painter.fillRect(rect, color) 

451 painter.setPen(QPen(loupe_ui_color(color), 1)) 

452 painter.drawRect(rect) 

453 painter.drawText(rect, Qt.AlignmentFlag.AlignCenter, label) 

454 

455 

456def show_screen_color_loupe(owner, global_position, before_color=None): 

457 loupe = getattr(owner, "_screen_color_loupe", None) 

458 if loupe is None: 

459 loupe = ScreenColorLoupe() 

460 owner._screen_color_loupe = loupe 

461 loupe.show_at(global_position, before_color) 

462 

463 

464def hide_screen_color_loupe(owner): 

465 loupe = getattr(owner, "_screen_color_loupe", None) 

466 if loupe is not None: 

467 loupe.hide() 

468 

469 

470class ScreenColorPickerOverlay(QWidget): 

471 """Transparent, desktop-wide input layer for persistent screen picking. 

472 

473 Unlike ``QWidget.grabMouse()``, this remains an actual top-level window 

474 over every screen, so pointer movement and the confirming click continue 

475 to arrive while the cursor is over another application. 

476 """ 

477 

478 colorPicked = Signal(QColor) 

479 canceled = Signal() 

480 

481 def __init__(self, before_color=None, parent=None): 

482 super().__init__(parent) 

483 self._before_color = ( 

484 QColor(before_color) if before_color is not None else QColor() 

485 ) 

486 self._left_pressed = False 

487 self._finished = False 

488 self.setWindowFlags( 

489 Qt.WindowType.Tool 

490 | Qt.WindowType.FramelessWindowHint 

491 | Qt.WindowType.WindowStaysOnTopHint 

492 | Qt.WindowType.NoDropShadowWindowHint 

493 ) 

494 # A fully transparent layered window can be treated as click-through 

495 # by the Windows compositor. A nearly invisible normal tool window is 

496 # still hit-testable and is hidden before the actual pixel capture. 

497 self.setWindowOpacity(0.01) 

498 self.setStyleSheet("background:#000000;") 

499 self.setMouseTracking(True) 

500 self.setFocusPolicy(Qt.FocusPolicy.StrongFocus) 

501 self.setCursor(Qt.CursorShape.CrossCursor) 

502 

503 @staticmethod 

504 def virtual_desktop_geometry(): 

505 screens = QApplication.screens() 

506 if not screens: 506 ↛ 507line 506 didn't jump to line 507 because the condition on line 506 was never true

507 return QRect() 

508 geometry = QRect(screens[0].geometry()) 

509 for screen in screens[1:]: 509 ↛ 510line 509 didn't jump to line 510 because the loop on line 509 never started

510 geometry = geometry.united(screen.geometry()) 

511 return geometry 

512 

513 def start(self): 

514 geometry = self.virtual_desktop_geometry() 

515 if geometry.isEmpty(): 515 ↛ 516line 515 didn't jump to line 516 because the condition on line 515 was never true

516 self.cancel() 

517 return 

518 self.setGeometry(geometry) 

519 self.show() 

520 self.raise_() 

521 self.activateWindow() 

522 self.setFocus(Qt.FocusReason.ActiveWindowFocusReason) 

523 show_screen_color_loupe(self, QCursor.pos(), self._before_color) 

524 

525 def mouseMoveEvent(self, event): 

526 show_screen_color_loupe( 

527 self, 

528 event.globalPosition().toPoint(), 

529 self._before_color, 

530 ) 

531 event.accept() 

532 

533 def mousePressEvent(self, event): 

534 if event.button() == Qt.MouseButton.LeftButton: 534 ↛ 546line 534 didn't jump to line 546 because the condition on line 534 was always true

535 self._left_pressed = True 

536 # Hold the pointer while dragging so the loupe keeps following it 

537 # even when the window manager would route moves elsewhere. 

538 self.grabMouse() 

539 show_screen_color_loupe( 

540 self, 

541 event.globalPosition().toPoint(), 

542 self._before_color, 

543 ) 

544 event.accept() 

545 return 

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

547 event.accept() 

548 return 

549 super().mousePressEvent(event) 

550 

551 def mouseReleaseEvent(self, event): 

552 if event.button() == Qt.MouseButton.LeftButton and self._left_pressed: 552 ↛ 558line 552 didn't jump to line 558 because the condition on line 552 was always true

553 self._left_pressed = False 

554 self.releaseMouse() 

555 self.finish(event.globalPosition().toPoint()) 

556 event.accept() 

557 return 

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

559 self.cancel() 

560 event.accept() 

561 return 

562 super().mouseReleaseEvent(event) 

563 

564 def keyPressEvent(self, event): 

565 if event.key() == Qt.Key.Key_Escape: 565 ↛ 569line 565 didn't jump to line 569 because the condition on line 565 was always true

566 self.cancel() 

567 event.accept() 

568 return 

569 super().keyPressEvent(event) 

570 

571 def finish(self, global_position): 

572 if self._finished: 572 ↛ 573line 572 didn't jump to line 573 because the condition on line 572 was never true

573 return 

574 self._finished = True 

575 hide_screen_color_loupe(self) 

576 # Remove our top-level windows from the composed desktop before the 

577 # pixel capture so the sampled value is the underlying application's 

578 # exact colour. 

579 self.hide() 

580 QApplication.processEvents() 

581 color = _sample_screen_color(global_position) 

582 self.close() 

583 if color is not None: 583 ↛ 586line 583 didn't jump to line 586 because the condition on line 583 was always true

584 self.colorPicked.emit(color) 

585 else: 

586 self.canceled.emit() 

587 self.deleteLater() 

588 

589 def cancel(self): 

590 if self._finished: 590 ↛ 591line 590 didn't jump to line 591 because the condition on line 590 was never true

591 return 

592 self._finished = True 

593 hide_screen_color_loupe(self) 

594 self.close() 

595 self.canceled.emit() 

596 self.deleteLater() 

597 

598 def closeEvent(self, event): 

599 hide_screen_color_loupe(self) 

600 super().closeEvent(event) 

601 

602 

603class _ScreenColorDragMixin(_DragBase): 

604 """Mouse, pen and touch drag support for the screen eyedropper. 

605 

606 At runtime this mixes into a concrete ``QAbstractButton`` subclass, so the 

607 Qt widget/button API and the ``colorPicked``/``screenColorPicked`` signals 

608 are all present. The ``TYPE_CHECKING`` declarations below tell pyright about 

609 the members the concrete host provides (see ``_canvas_members`` for the same 

610 pattern), so accessing them here is not flagged as an unknown attribute. 

611 """ 

612 

613 if TYPE_CHECKING: 

614 colorPicked: Signal 

615 screenColorPicked: Signal 

616 # QAbstractButton members not present on the QWidget type-check base. 

617 click: Any 

618 setDown: Any 

619 

620 def _init_screen_color_drag(self): 

621 self._screen_pick_active = False 

622 self._screen_pick_button = Qt.MouseButton.NoButton 

623 self._screen_pick_press_global = None 

624 self._left_drag_candidate = False 

625 self._screen_pick_cursor_pushed = False 

626 self._touch_pick_candidate = False 

627 self.setAttribute(Qt.WidgetAttribute.WA_AcceptTouchEvents, True) 

628 

629 @staticmethod 

630 def _event_global_position(event): 

631 if hasattr(event, "globalPosition"): 

632 return event.globalPosition().toPoint() 

633 if hasattr(event, "points"): 633 ↛ 637line 633 didn't jump to line 637 because the condition on line 633 was always true

634 points = event.points() 

635 if points: 

636 return points[0].globalPosition().toPoint() 

637 return None 

638 

639 def _emit_screen_color(self, color): 

640 if hasattr(self, "colorPicked"): 640 ↛ 642line 640 didn't jump to line 642 because the condition on line 640 was always true

641 self.colorPicked.emit(color) 

642 elif hasattr(self, "screenColorPicked"): 

643 self.screenColorPicked.emit(color) 

644 

645 def _begin_screen_pick(self, button=Qt.MouseButton.LeftButton): 

646 self._screen_pick_active = True 

647 self._screen_pick_button = button 

648 self._left_drag_candidate = False 

649 self._touch_pick_candidate = False 

650 try: 

651 self.grabMouse() 

652 except RuntimeError as exc: 

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

654 QApplication.setOverrideCursor(Qt.CursorShape.CrossCursor) 

655 self._screen_pick_cursor_pushed = True 

656 position = self._screen_pick_press_global or QCursor.pos() 

657 show_screen_color_loupe(self, position) 

658 try: 

659 self.setDown(False) 

660 except RuntimeError as exc: 

661 log.debug("setDown(False) failed: %s", exc) 

662 

663 def _cancel_screen_pick_cursor(self): 

664 try: 

665 self.releaseMouse() 

666 except RuntimeError as exc: 

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

668 if self._screen_pick_cursor_pushed: 

669 QApplication.restoreOverrideCursor() 

670 self._screen_pick_cursor_pushed = False 

671 hide_screen_color_loupe(self) 

672 

673 def _finish_screen_pick(self, global_position): 

674 self._screen_pick_active = False 

675 self._screen_pick_button = Qt.MouseButton.NoButton 

676 self._left_drag_candidate = False 

677 self._touch_pick_candidate = False 

678 self._screen_pick_press_global = None 

679 self._cancel_screen_pick_cursor() 

680 color = _sample_screen_color(global_position) 

681 if color is not None: 681 ↛ exitline 681 didn't return from function '_finish_screen_pick' because the condition on line 681 was always true

682 self._emit_screen_color(color) 

683 

684 def mousePressEvent(self, event): 

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

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

687 self._begin_screen_pick(Qt.MouseButton.RightButton) 

688 event.accept() 

689 return 

690 if event.button() == Qt.MouseButton.LeftButton: 690 ↛ 693line 690 didn't jump to line 693 because the condition on line 690 was always true

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

692 self._left_drag_candidate = True 

693 super().mousePressEvent(event) 

694 

695 def mouseMoveEvent(self, event): 

696 if self._screen_pick_active: 

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

698 event.accept() 

699 return 

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

701 self._left_drag_candidate 

702 and self._screen_pick_press_global is not None 

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

704 ): 

705 distance = ( 

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

707 ).manhattanLength() 

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

709 self._begin_screen_pick(Qt.MouseButton.LeftButton) 

710 event.accept() 

711 return 

712 super().mouseMoveEvent(event) 

713 

714 def mouseReleaseEvent(self, event): 

715 if ( 

716 self._screen_pick_active 

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

718 ): 

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

720 event.accept() 

721 return 

722 self._left_drag_candidate = False 

723 self._screen_pick_press_global = None 

724 super().mouseReleaseEvent(event) 

725 

726 def event(self, event): 

727 event_type = event.type() 

728 tablet_press = getattr(QEvent.Type, "TabletPress", None) 

729 tablet_move = getattr(QEvent.Type, "TabletMove", None) 

730 tablet_release = getattr(QEvent.Type, "TabletRelease", None) 

731 touch_begin = getattr(QEvent.Type, "TouchBegin", None) 

732 touch_update = getattr(QEvent.Type, "TouchUpdate", None) 

733 touch_end = getattr(QEvent.Type, "TouchEnd", None) 

734 touch_cancel = getattr(QEvent.Type, "TouchCancel", None) 

735 

736 if event_type in (tablet_press, touch_begin): 

737 position = self._event_global_position(event) 

738 if position is not None: 738 ↛ 744line 738 didn't jump to line 744 because the condition on line 738 was always true

739 self._screen_pick_press_global = position 

740 self._touch_pick_candidate = True 

741 event.accept() 

742 return True 

743 

744 if event_type in (tablet_move, touch_update): 744 ↛ 745line 744 didn't jump to line 745 because the condition on line 744 was never true

745 position = self._event_global_position(event) 

746 if position is not None: 

747 if self._screen_pick_active: 

748 show_screen_color_loupe(self, position) 

749 event.accept() 

750 return True 

751 if ( 

752 self._touch_pick_candidate 

753 and self._screen_pick_press_global is not None 

754 and (position - self._screen_pick_press_global).manhattanLength() 

755 >= max(4, QApplication.startDragDistance()) 

756 ): 

757 self._begin_screen_pick(Qt.MouseButton.LeftButton) 

758 event.accept() 

759 return True 

760 

761 if event_type in (tablet_release, touch_end): 

762 position = self._event_global_position(event) 

763 if self._screen_pick_active and position is not None: 763 ↛ 764line 763 didn't jump to line 764 because the condition on line 763 was never true

764 self._finish_screen_pick(position) 

765 event.accept() 

766 return True 

767 if self._touch_pick_candidate: 767 ↛ 775line 767 didn't jump to line 775 because the condition on line 767 was always true

768 self._touch_pick_candidate = False 

769 self._screen_pick_press_global = None 

770 # A tap keeps the button's ordinary click behavior. 

771 QTimer.singleShot(0, self.click) 

772 event.accept() 

773 return True 

774 

775 if event_type == touch_cancel: 

776 self._screen_pick_active = False 

777 self._touch_pick_candidate = False 

778 self._screen_pick_press_global = None 

779 self._cancel_screen_pick_cursor() 

780 event.accept() 

781 return True 

782 

783 return super().event(event) 

784 

785 

786def natural_path_key(path): 

787 import re 

788 return [int(part) if part.isdigit() else part.lower() 

789 for part in re.split(r"(\d+)", Path(path).name)]