Coverage for paintmaskanimator/utils.py: 82%
500 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-11-02 04:50 +0000
« prev ^ index » next coverage.py v7.17.0, created at 2026-11-02 04:50 +0000
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
14log = get_logger(__name__)
16if TYPE_CHECKING:
17 from PySide6.QtWidgets import QWidget as _DragBase
18else:
19 _DragBase = object
22# アプリ全体(QApplication)に設置した Python のイベントフィルター。
23_app_event_filters: list = []
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)
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)
44def _is_alive(obj):
45 import shiboken6
47 return shiboken6.isValid(obj)
50@contextmanager
51def app_event_filters_suspended():
52 """アプリ全体の Python イベントフィルターを一時的に外す。
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)
75def workspace_size():
76 return constants.CANVAS_WIDTH + OUTSIDE_MARGIN * 2, constants.CANVAS_HEIGHT + OUTSIDE_MARGIN * 2
79def top_level_window_ids(widgets):
80 """各ウィジェットが属するトップレベルウィンドウのHWNDを重複なく返す。
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
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
115 try:
116 import ctypes
117 from ctypes import wintypes
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
131 # FWFS_OVERRIDE
132 flags = 0x00000001
133 disabled = wintypes.BOOL(False)
135 # 1~11:
136 # タッチ接触表示、ペン樽表示、タップ、ダブルタップ、
137 # 長押し、右タップ、タッチ系表示、PressAndTap。
138 feedback_types = range(1, 12)
140 for window_id in top_level_window_ids(widgets):
141 handle = wintypes.HWND(window_id)
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)
163class _DoubleClickFilter(QObject):
164 def __init__(self, callback, parent):
165 super().__init__(parent)
166 self._callback = callback
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)
178def on_double_click(widget, callback):
179 """``widget`` を左ダブルクリックしたら ``callback()`` を呼ぶ。
181 QPushButton などダブルクリックのシグナルを持たない部品に付ける。
182 1回目のクリックは通常どおり ``clicked`` として届く。
183 """
184 widget.installEventFilter(_DoubleClickFilter(callback, widget))
187class _BlankTemplate:
188 size: "tuple[int, int] | None" = None
189 image: "QImage | None" = None
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
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
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
230#: 一時ズーム(ドラッグ)で 1px 動かしたときの倍率。
231DRAG_ZOOM_STEP = 1.01
234def drag_zoom_factor(dx):
235 """ドラッグズームの倍率。CLIP STUDIO PAINT と同じく右で拡大・左で縮小。"""
236 return DRAG_ZOOM_STEP ** float(dx)
239_magnifier_cursor_cache = None
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)
254_tool_cursor_cache: dict = {}
257def tool_cursor(tool_id):
258 """ツールアイコンの形をしたカーソル。見つからなければ ``None``。
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
270 from . import icons
272 glyph = icons.pixmap(tool_id, 16, color="#000000", accent="#000000")
273 halo = icons.pixmap(tool_id, 16, color="#ffffff", accent="#ffffff")
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()
292 cursor = QCursor(pixmap, 6, 6)
293 _tool_cursor_cache[tool_id] = cursor
294 return cursor
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
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
344_LOUPE_LIGHT = QColor("#ffffff")
345_LOUPE_DARK = QColor("#1e1e1e")
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
353 return (
354 0.2126 * channel(color.red())
355 + 0.7152 * channel(color.green())
356 + 0.0722 * channel(color.blue())
357 )
360def loupe_ui_color(background):
361 """Return the white or dark UI colour that reads best over ``background``.
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)
375class ScreenColorLoupe(QWidget):
376 """Click-through screen magnifier shared by every eyedropper path."""
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()
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()
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)
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)
464def hide_screen_color_loupe(owner):
465 loupe = getattr(owner, "_screen_color_loupe", None)
466 if loupe is not None:
467 loupe.hide()
470class ScreenColorPickerOverlay(QWidget):
471 """Transparent, desktop-wide input layer for persistent screen picking.
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 """
478 colorPicked = Signal(QColor)
479 canceled = Signal()
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)
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
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)
525 def mouseMoveEvent(self, event):
526 show_screen_color_loupe(
527 self,
528 event.globalPosition().toPoint(),
529 self._before_color,
530 )
531 event.accept()
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)
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)
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)
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()
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()
598 def closeEvent(self, event):
599 hide_screen_color_loupe(self)
600 super().closeEvent(event)
603class _ScreenColorDragMixin(_DragBase):
604 """Mouse, pen and touch drag support for the screen eyedropper.
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 """
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
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)
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
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)
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)
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)
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)
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)
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)
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)
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)
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
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
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
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
783 return super().event(event)
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)]