prkit 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- prkit/__init__.py +3 -0
- prkit/__main__.py +3 -0
- prkit/app.py +183 -0
- prkit/cursor_watchdog.py +48 -0
- prkit/icon.py +25 -0
- prkit/keyboard.py +68 -0
- prkit/keys.py +111 -0
- prkit/mac_keyboard.py +240 -0
- prkit/overlays.py +170 -0
- prkit/platform.py +152 -0
- prkit/py.typed +0 -0
- prkit/settings.py +102 -0
- prkit/spotlight.py +26 -0
- prkit/tray.py +242 -0
- prkit/windows_cursor.py +467 -0
- prkit-0.1.0.dist-info/METADATA +100 -0
- prkit-0.1.0.dist-info/RECORD +19 -0
- prkit-0.1.0.dist-info/WHEEL +4 -0
- prkit-0.1.0.dist-info/entry_points.txt +4 -0
prkit/overlays.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from PySide6.QtCore import QEasingCurve, QPoint, QPropertyAnimation, QRect, Qt, QTimer
|
|
4
|
+
from PySide6.QtGui import QColor, QCursor, QFont, QGuiApplication, QPainter, QPaintEvent
|
|
5
|
+
from PySide6.QtWidgets import QWidget
|
|
6
|
+
|
|
7
|
+
from prkit.platform import (
|
|
8
|
+
configure_overlay_level,
|
|
9
|
+
keep_window_topmost,
|
|
10
|
+
make_window_click_through,
|
|
11
|
+
)
|
|
12
|
+
from prkit.settings import KeystrokeSettings, SpotlightSettings
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class OverlayWindow(QWidget):
|
|
16
|
+
def __init__(self) -> None:
|
|
17
|
+
flags = (
|
|
18
|
+
Qt.WindowType.FramelessWindowHint
|
|
19
|
+
| Qt.WindowType.Tool
|
|
20
|
+
| Qt.WindowType.WindowStaysOnTopHint
|
|
21
|
+
| Qt.WindowType.WindowDoesNotAcceptFocus
|
|
22
|
+
| Qt.WindowType.WindowTransparentForInput
|
|
23
|
+
)
|
|
24
|
+
super().__init__(None, flags)
|
|
25
|
+
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
|
|
26
|
+
self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents)
|
|
27
|
+
self.setAttribute(Qt.WidgetAttribute.WA_ShowWithoutActivating)
|
|
28
|
+
self.setAttribute(Qt.WidgetAttribute.WA_MacAlwaysShowToolWindow)
|
|
29
|
+
self.setFocusPolicy(Qt.FocusPolicy.NoFocus)
|
|
30
|
+
|
|
31
|
+
def showEvent(self, event: object) -> None:
|
|
32
|
+
super().showEvent(event) # type: ignore[arg-type]
|
|
33
|
+
QTimer.singleShot(0, self._configure_native_window)
|
|
34
|
+
|
|
35
|
+
def _configure_native_window(self) -> None:
|
|
36
|
+
make_window_click_through(self)
|
|
37
|
+
configure_overlay_level(self)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class SpotlightOverlay(OverlayWindow):
|
|
41
|
+
def __init__(self, settings: SpotlightSettings) -> None:
|
|
42
|
+
super().__init__()
|
|
43
|
+
self._color = QColor(settings.color)
|
|
44
|
+
self._opacity = settings.opacity
|
|
45
|
+
self._diameter = settings.size
|
|
46
|
+
self.setFixedSize(self._diameter, self._diameter)
|
|
47
|
+
self._timer = QTimer(self)
|
|
48
|
+
self._timer.setInterval(16)
|
|
49
|
+
self._timer.timeout.connect(self._follow_cursor)
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def diameter(self) -> int:
|
|
53
|
+
return self._diameter
|
|
54
|
+
|
|
55
|
+
def set_enabled(self, enabled: bool) -> None:
|
|
56
|
+
if enabled:
|
|
57
|
+
self._follow_cursor()
|
|
58
|
+
self.show()
|
|
59
|
+
self._timer.start()
|
|
60
|
+
else:
|
|
61
|
+
self._timer.stop()
|
|
62
|
+
self.hide()
|
|
63
|
+
|
|
64
|
+
def set_appearance(self, color: str, opacity: int, diameter: int) -> None:
|
|
65
|
+
center = (
|
|
66
|
+
QPoint(self.x() + self._diameter // 2, self.y() + self._diameter // 2)
|
|
67
|
+
if self.isVisible()
|
|
68
|
+
else QCursor.pos()
|
|
69
|
+
)
|
|
70
|
+
self._color = QColor(color)
|
|
71
|
+
self._opacity = opacity
|
|
72
|
+
self._diameter = diameter
|
|
73
|
+
self.setFixedSize(diameter, diameter)
|
|
74
|
+
self.move(center - QPoint(diameter // 2, diameter // 2))
|
|
75
|
+
self.update()
|
|
76
|
+
|
|
77
|
+
def _follow_cursor(self) -> None:
|
|
78
|
+
position = QCursor.pos()
|
|
79
|
+
self.move(position.x() - self._diameter // 2, position.y() - self._diameter // 2)
|
|
80
|
+
keep_window_topmost(self)
|
|
81
|
+
|
|
82
|
+
def sync_position(self) -> None:
|
|
83
|
+
self._follow_cursor()
|
|
84
|
+
|
|
85
|
+
def paintEvent(self, event: QPaintEvent) -> None:
|
|
86
|
+
del event
|
|
87
|
+
painter = QPainter(self)
|
|
88
|
+
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
|
89
|
+
color = QColor(self._color)
|
|
90
|
+
color.setAlphaF(self._opacity / 100)
|
|
91
|
+
painter.setPen(Qt.PenStyle.NoPen)
|
|
92
|
+
painter.setBrush(color)
|
|
93
|
+
painter.drawEllipse(self.rect().adjusted(1, 1, -1, -1))
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class KeystrokeOverlay(OverlayWindow):
|
|
97
|
+
DISPLAY_MILLISECONDS = 1200
|
|
98
|
+
FADE_MILLISECONDS = 250
|
|
99
|
+
|
|
100
|
+
def __init__(self, settings: KeystrokeSettings) -> None:
|
|
101
|
+
super().__init__()
|
|
102
|
+
self._color = QColor(settings.color)
|
|
103
|
+
self._opacity = settings.opacity
|
|
104
|
+
self._text = ""
|
|
105
|
+
font = QFont()
|
|
106
|
+
font.setPointSize(24)
|
|
107
|
+
font.setBold(True)
|
|
108
|
+
self.setFont(font)
|
|
109
|
+
self._hide_timer = QTimer(self)
|
|
110
|
+
self._hide_timer.setSingleShot(True)
|
|
111
|
+
self._hide_timer.timeout.connect(self._start_fade)
|
|
112
|
+
self._fade = QPropertyAnimation(self, b"windowOpacity", self)
|
|
113
|
+
self._fade.setDuration(self.FADE_MILLISECONDS)
|
|
114
|
+
self._fade.setStartValue(1.0)
|
|
115
|
+
self._fade.setEndValue(0.0)
|
|
116
|
+
self._fade.setEasingCurve(QEasingCurve.Type.OutCubic)
|
|
117
|
+
self._fade.finished.connect(self.hide)
|
|
118
|
+
|
|
119
|
+
def set_appearance(self, color: str, opacity: int) -> None:
|
|
120
|
+
self._color = QColor(color)
|
|
121
|
+
self._opacity = opacity
|
|
122
|
+
self.update()
|
|
123
|
+
|
|
124
|
+
def show_chord(self, text: str) -> None:
|
|
125
|
+
self._fade.stop()
|
|
126
|
+
self._hide_timer.stop()
|
|
127
|
+
self.setWindowOpacity(1.0)
|
|
128
|
+
self._text = text
|
|
129
|
+
metrics = self.fontMetrics()
|
|
130
|
+
width = max(160, metrics.horizontalAdvance(text) + 64)
|
|
131
|
+
height = metrics.height() + 36
|
|
132
|
+
self.setFixedSize(width, height)
|
|
133
|
+
self._place_on_cursor_screen()
|
|
134
|
+
self.show()
|
|
135
|
+
keep_window_topmost(self)
|
|
136
|
+
self.update()
|
|
137
|
+
|
|
138
|
+
def keys_released(self) -> None:
|
|
139
|
+
if self.isVisible():
|
|
140
|
+
self._hide_timer.start(self.DISPLAY_MILLISECONDS)
|
|
141
|
+
|
|
142
|
+
def hide_now(self) -> None:
|
|
143
|
+
self._hide_timer.stop()
|
|
144
|
+
self._fade.stop()
|
|
145
|
+
self.hide()
|
|
146
|
+
|
|
147
|
+
def _place_on_cursor_screen(self) -> None:
|
|
148
|
+
screen = QGuiApplication.screenAt(QCursor.pos()) or QGuiApplication.primaryScreen()
|
|
149
|
+
if screen is None:
|
|
150
|
+
return
|
|
151
|
+
available = screen.availableGeometry()
|
|
152
|
+
x = available.x() + (available.width() - self.width()) // 2
|
|
153
|
+
y = available.bottom() - self.height() - 47
|
|
154
|
+
self.move(x, y)
|
|
155
|
+
|
|
156
|
+
def _start_fade(self) -> None:
|
|
157
|
+
self._fade.start()
|
|
158
|
+
|
|
159
|
+
def paintEvent(self, event: QPaintEvent) -> None:
|
|
160
|
+
del event
|
|
161
|
+
painter = QPainter(self)
|
|
162
|
+
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
|
163
|
+
background = QColor(self._color)
|
|
164
|
+
background.setAlphaF(self._opacity / 100)
|
|
165
|
+
painter.setPen(Qt.PenStyle.NoPen)
|
|
166
|
+
painter.setBrush(background)
|
|
167
|
+
painter.drawRoundedRect(QRect(self.rect()).adjusted(1, 1, -1, -1), 18, 18)
|
|
168
|
+
painter.setPen(QColor("white"))
|
|
169
|
+
painter.setFont(self.font())
|
|
170
|
+
painter.drawText(self.rect(), Qt.AlignmentFlag.AlignCenter, self._text)
|
prkit/platform.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from PySide6.QtWidgets import QWidget
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def is_wayland() -> bool:
|
|
12
|
+
return (
|
|
13
|
+
bool(os.environ.get("WAYLAND_DISPLAY"))
|
|
14
|
+
or os.environ.get("XDG_SESSION_TYPE", "").lower() == "wayland"
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def make_window_click_through(widget: QWidget) -> None:
|
|
19
|
+
"""Apply native input transparency in addition to Qt's window flags.
|
|
20
|
+
|
|
21
|
+
Every implementation is deliberately best-effort: Qt's own transparent-input
|
|
22
|
+
hint remains active if the native API is unavailable.
|
|
23
|
+
"""
|
|
24
|
+
window_id = int(widget.winId())
|
|
25
|
+
try:
|
|
26
|
+
if sys.platform == "win32":
|
|
27
|
+
_make_windows_click_through(window_id)
|
|
28
|
+
elif sys.platform == "darwin":
|
|
29
|
+
from PySide6.QtGui import QGuiApplication
|
|
30
|
+
|
|
31
|
+
if QGuiApplication.platformName() == "cocoa":
|
|
32
|
+
_make_macos_click_through(window_id)
|
|
33
|
+
elif sys.platform.startswith("linux") and not is_wayland():
|
|
34
|
+
_make_x11_click_through(window_id)
|
|
35
|
+
except (ImportError, OSError, RuntimeError, AttributeError, TypeError):
|
|
36
|
+
return
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def configure_overlay_level(widget: QWidget) -> None:
|
|
40
|
+
"""Keep macOS overlays above apps but below the menu bar and popup menus."""
|
|
41
|
+
if sys.platform != "darwin":
|
|
42
|
+
return
|
|
43
|
+
try:
|
|
44
|
+
import objc
|
|
45
|
+
from AppKit import (
|
|
46
|
+
NSColor,
|
|
47
|
+
NSFloatingWindowLevel,
|
|
48
|
+
NSWindowCollectionBehaviorCanJoinAllSpaces,
|
|
49
|
+
NSWindowCollectionBehaviorFullScreenAuxiliary,
|
|
50
|
+
NSWindowStyleMaskNonactivatingPanel,
|
|
51
|
+
)
|
|
52
|
+
from PySide6.QtGui import QGuiApplication
|
|
53
|
+
|
|
54
|
+
if QGuiApplication.platformName() != "cocoa":
|
|
55
|
+
return
|
|
56
|
+
native_view = objc.objc_object(c_void_p=int(widget.winId()))
|
|
57
|
+
native_window = native_view.window()
|
|
58
|
+
native_window.setLevel_(NSFloatingWindowLevel)
|
|
59
|
+
native_window.setHidesOnDeactivate_(False)
|
|
60
|
+
native_window.setHasShadow_(False)
|
|
61
|
+
native_window.setOpaque_(False)
|
|
62
|
+
native_window.setBackgroundColor_(NSColor.clearColor())
|
|
63
|
+
native_window.setStyleMask_(native_window.styleMask() | NSWindowStyleMaskNonactivatingPanel)
|
|
64
|
+
if native_window.respondsToSelector_("setBecomesKeyOnlyIfNeeded:"):
|
|
65
|
+
native_window.setBecomesKeyOnlyIfNeeded_(True)
|
|
66
|
+
native_window.setCollectionBehavior_(
|
|
67
|
+
NSWindowCollectionBehaviorCanJoinAllSpaces
|
|
68
|
+
| NSWindowCollectionBehaviorFullScreenAuxiliary
|
|
69
|
+
)
|
|
70
|
+
except (ImportError, OSError, RuntimeError, AttributeError, TypeError):
|
|
71
|
+
return
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def keep_window_topmost(widget: QWidget) -> None:
|
|
75
|
+
"""Keep an overlay above other topmost Windows such as the taskbar."""
|
|
76
|
+
if sys.platform != "win32" or not widget.isVisible():
|
|
77
|
+
return
|
|
78
|
+
try:
|
|
79
|
+
import ctypes
|
|
80
|
+
|
|
81
|
+
user32 = ctypes.windll.user32
|
|
82
|
+
hwnd_topmost = -1
|
|
83
|
+
no_size = 0x0001
|
|
84
|
+
no_move = 0x0002
|
|
85
|
+
no_activate = 0x0010
|
|
86
|
+
no_owner_z_order = 0x0200
|
|
87
|
+
user32.SetWindowPos(
|
|
88
|
+
int(widget.winId()),
|
|
89
|
+
hwnd_topmost,
|
|
90
|
+
0,
|
|
91
|
+
0,
|
|
92
|
+
0,
|
|
93
|
+
0,
|
|
94
|
+
no_size | no_move | no_activate | no_owner_z_order,
|
|
95
|
+
)
|
|
96
|
+
except (OSError, AttributeError, TypeError):
|
|
97
|
+
return
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _make_windows_click_through(window_id: int) -> None:
|
|
101
|
+
import ctypes
|
|
102
|
+
|
|
103
|
+
user32 = ctypes.windll.user32
|
|
104
|
+
get_window_long = user32.GetWindowLongPtrW
|
|
105
|
+
set_window_long = user32.SetWindowLongPtrW
|
|
106
|
+
index_extended_style = -20
|
|
107
|
+
transparent = 0x00000020
|
|
108
|
+
layered = 0x00080000
|
|
109
|
+
no_activate = 0x08000000
|
|
110
|
+
tool_window = 0x00000080
|
|
111
|
+
current = get_window_long(window_id, index_extended_style)
|
|
112
|
+
set_window_long(
|
|
113
|
+
window_id,
|
|
114
|
+
index_extended_style,
|
|
115
|
+
current | transparent | layered | no_activate | tool_window,
|
|
116
|
+
)
|
|
117
|
+
hwnd_topmost = -1
|
|
118
|
+
no_size = 0x0001
|
|
119
|
+
no_move = 0x0002
|
|
120
|
+
no_activate_position = 0x0010
|
|
121
|
+
no_owner_z_order = 0x0200
|
|
122
|
+
user32.SetWindowPos(
|
|
123
|
+
window_id,
|
|
124
|
+
hwnd_topmost,
|
|
125
|
+
0,
|
|
126
|
+
0,
|
|
127
|
+
0,
|
|
128
|
+
0,
|
|
129
|
+
no_size | no_move | no_activate_position | no_owner_z_order,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _make_macos_click_through(window_id: int) -> None:
|
|
134
|
+
import objc
|
|
135
|
+
|
|
136
|
+
native_view = objc.objc_object(c_void_p=window_id)
|
|
137
|
+
native_window = native_view.window()
|
|
138
|
+
native_window.setIgnoresMouseEvents_(True)
|
|
139
|
+
native_window.setAcceptsMouseMovedEvents_(False)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _make_x11_click_through(window_id: int) -> None:
|
|
143
|
+
from Xlib import display
|
|
144
|
+
from Xlib.ext import shape
|
|
145
|
+
|
|
146
|
+
connection = display.Display()
|
|
147
|
+
try:
|
|
148
|
+
window = connection.create_resource_object("window", window_id)
|
|
149
|
+
window.shape_rectangles(shape.SO.Set, shape.SK.Input, 0, 0, 0, [])
|
|
150
|
+
connection.sync()
|
|
151
|
+
finally:
|
|
152
|
+
connection.close()
|
prkit/py.typed
ADDED
|
File without changes
|
prkit/settings.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Protocol
|
|
5
|
+
|
|
6
|
+
from PySide6.QtCore import QSettings
|
|
7
|
+
from PySide6.QtGui import QColor
|
|
8
|
+
|
|
9
|
+
MIN_SPOTLIGHT_SIZE = 48
|
|
10
|
+
MAX_SPOTLIGHT_SIZE = 320
|
|
11
|
+
DEFAULT_SPOTLIGHT_SIZE = 96
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def clamp(value: int, minimum: int, maximum: int) -> int:
|
|
15
|
+
return max(minimum, min(maximum, value))
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def valid_color(value: object, fallback: str) -> str:
|
|
19
|
+
color = QColor(str(value))
|
|
20
|
+
return color.name().upper() if color.isValid() else fallback
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(slots=True)
|
|
24
|
+
class SpotlightSettings:
|
|
25
|
+
enabled: bool = False
|
|
26
|
+
color: str = "#FFD54F"
|
|
27
|
+
opacity: int = 45
|
|
28
|
+
size: int = DEFAULT_SPOTLIGHT_SIZE
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(slots=True)
|
|
32
|
+
class KeystrokeSettings:
|
|
33
|
+
enabled: bool = False
|
|
34
|
+
color: str = "#202124"
|
|
35
|
+
opacity: int = 75
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(slots=True)
|
|
39
|
+
class AppSettings:
|
|
40
|
+
spotlight: SpotlightSettings
|
|
41
|
+
keystroke: KeystrokeSettings
|
|
42
|
+
|
|
43
|
+
@classmethod
|
|
44
|
+
def defaults(cls) -> AppSettings:
|
|
45
|
+
return cls(SpotlightSettings(), KeystrokeSettings())
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class SettingsStoreProtocol(Protocol):
|
|
49
|
+
def load(self) -> AppSettings: ...
|
|
50
|
+
|
|
51
|
+
def save(self, settings: AppSettings) -> None: ...
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class SettingsStore:
|
|
55
|
+
def __init__(self, backend: QSettings | None = None) -> None:
|
|
56
|
+
self._backend = backend or QSettings()
|
|
57
|
+
|
|
58
|
+
def load(self) -> AppSettings:
|
|
59
|
+
defaults = AppSettings.defaults()
|
|
60
|
+
backend = self._backend
|
|
61
|
+
spotlight = SpotlightSettings(
|
|
62
|
+
enabled=backend.value("spotlight/enabled", defaults.spotlight.enabled, type=bool),
|
|
63
|
+
color=valid_color(
|
|
64
|
+
backend.value("spotlight/color", defaults.spotlight.color),
|
|
65
|
+
defaults.spotlight.color,
|
|
66
|
+
),
|
|
67
|
+
opacity=clamp(
|
|
68
|
+
backend.value("spotlight/opacity", defaults.spotlight.opacity, type=int), 10, 100
|
|
69
|
+
),
|
|
70
|
+
size=clamp(
|
|
71
|
+
backend.value("spotlight/size", defaults.spotlight.size, type=int),
|
|
72
|
+
MIN_SPOTLIGHT_SIZE,
|
|
73
|
+
MAX_SPOTLIGHT_SIZE,
|
|
74
|
+
),
|
|
75
|
+
)
|
|
76
|
+
keystroke = KeystrokeSettings(
|
|
77
|
+
enabled=backend.value("keystroke/enabled", defaults.keystroke.enabled, type=bool),
|
|
78
|
+
color=valid_color(
|
|
79
|
+
backend.value("keystroke/color", defaults.keystroke.color),
|
|
80
|
+
defaults.keystroke.color,
|
|
81
|
+
),
|
|
82
|
+
opacity=clamp(
|
|
83
|
+
backend.value("keystroke/opacity", defaults.keystroke.opacity, type=int),
|
|
84
|
+
10,
|
|
85
|
+
100,
|
|
86
|
+
),
|
|
87
|
+
)
|
|
88
|
+
return AppSettings(spotlight, keystroke)
|
|
89
|
+
|
|
90
|
+
def save(self, settings: AppSettings) -> None:
|
|
91
|
+
values = {
|
|
92
|
+
"spotlight/enabled": settings.spotlight.enabled,
|
|
93
|
+
"spotlight/color": settings.spotlight.color,
|
|
94
|
+
"spotlight/opacity": settings.spotlight.opacity,
|
|
95
|
+
"spotlight/size": settings.spotlight.size,
|
|
96
|
+
"keystroke/enabled": settings.keystroke.enabled,
|
|
97
|
+
"keystroke/color": settings.keystroke.color,
|
|
98
|
+
"keystroke/opacity": settings.keystroke.opacity,
|
|
99
|
+
}
|
|
100
|
+
for key, value in values.items():
|
|
101
|
+
self._backend.setValue(key, value)
|
|
102
|
+
self._backend.sync()
|
prkit/spotlight.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from typing import Protocol
|
|
5
|
+
|
|
6
|
+
from prkit.overlays import SpotlightOverlay
|
|
7
|
+
from prkit.settings import SpotlightSettings
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class SpotlightBackend(Protocol):
|
|
11
|
+
@property
|
|
12
|
+
def diameter(self) -> int: ...
|
|
13
|
+
|
|
14
|
+
def set_enabled(self, enabled: bool) -> None: ...
|
|
15
|
+
|
|
16
|
+
def set_appearance(self, color: str, opacity: int, diameter: int) -> None: ...
|
|
17
|
+
|
|
18
|
+
def sync_position(self) -> None: ...
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def create_spotlight(settings: SpotlightSettings) -> SpotlightBackend:
|
|
22
|
+
if sys.platform == "win32":
|
|
23
|
+
from prkit.windows_cursor import WindowsCursorSpotlight
|
|
24
|
+
|
|
25
|
+
return WindowsCursorSpotlight(settings)
|
|
26
|
+
return SpotlightOverlay(settings)
|
prkit/tray.py
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
|
|
5
|
+
from PySide6.QtCore import QObject, Qt, Signal
|
|
6
|
+
from PySide6.QtGui import QAction, QColor
|
|
7
|
+
from PySide6.QtWidgets import (
|
|
8
|
+
QCheckBox,
|
|
9
|
+
QColorDialog,
|
|
10
|
+
QFrame,
|
|
11
|
+
QHBoxLayout,
|
|
12
|
+
QLabel,
|
|
13
|
+
QMenu,
|
|
14
|
+
QPushButton,
|
|
15
|
+
QSlider,
|
|
16
|
+
QSystemTrayIcon,
|
|
17
|
+
QVBoxLayout,
|
|
18
|
+
QWidget,
|
|
19
|
+
QWidgetAction,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
from prkit.icon import tray_icon
|
|
23
|
+
from prkit.platform import is_wayland
|
|
24
|
+
from prkit.settings import (
|
|
25
|
+
MAX_SPOTLIGHT_SIZE,
|
|
26
|
+
MIN_SPOTLIGHT_SIZE,
|
|
27
|
+
AppSettings,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ColorButton(QPushButton):
|
|
32
|
+
color_changed = Signal(str)
|
|
33
|
+
|
|
34
|
+
def __init__(self, color: str, parent: QWidget | None = None) -> None:
|
|
35
|
+
super().__init__(parent)
|
|
36
|
+
self._color = color
|
|
37
|
+
self.setFixedSize(42, 24)
|
|
38
|
+
self.setToolTip("選擇顏色")
|
|
39
|
+
self.clicked.connect(self._choose_color)
|
|
40
|
+
self.set_color(color)
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def color(self) -> str:
|
|
44
|
+
return self._color
|
|
45
|
+
|
|
46
|
+
def set_color(self, color: str) -> None:
|
|
47
|
+
self._color = QColor(color).name().upper()
|
|
48
|
+
self.setStyleSheet(
|
|
49
|
+
f"QPushButton {{ background: {self._color}; border: 1px solid #777; "
|
|
50
|
+
"border-radius: 4px; }"
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
def _choose_color(self) -> None:
|
|
54
|
+
chosen = QColorDialog.getColor(QColor(self._color), self, "選擇顏色")
|
|
55
|
+
if chosen.isValid():
|
|
56
|
+
self.set_color(chosen.name())
|
|
57
|
+
self.color_changed.emit(self._color)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class ValueSlider(QWidget):
|
|
61
|
+
value_changed = Signal(int)
|
|
62
|
+
|
|
63
|
+
def __init__(
|
|
64
|
+
self,
|
|
65
|
+
minimum: int,
|
|
66
|
+
maximum: int,
|
|
67
|
+
value: int,
|
|
68
|
+
suffix: str,
|
|
69
|
+
step: int = 1,
|
|
70
|
+
parent: QWidget | None = None,
|
|
71
|
+
) -> None:
|
|
72
|
+
super().__init__(parent)
|
|
73
|
+
self._suffix = suffix
|
|
74
|
+
self.slider = QSlider(Qt.Orientation.Horizontal, self)
|
|
75
|
+
self.slider.setRange(minimum, maximum)
|
|
76
|
+
self.slider.setSingleStep(step)
|
|
77
|
+
self.slider.setPageStep(step)
|
|
78
|
+
self.slider.setValue(value)
|
|
79
|
+
self.value_label = QLabel(self)
|
|
80
|
+
self.value_label.setMinimumWidth(48)
|
|
81
|
+
self.value_label.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
|
|
82
|
+
layout = QHBoxLayout(self)
|
|
83
|
+
layout.setContentsMargins(0, 0, 0, 0)
|
|
84
|
+
layout.setSpacing(8)
|
|
85
|
+
layout.addWidget(self.slider, 1)
|
|
86
|
+
layout.addWidget(self.value_label)
|
|
87
|
+
self.slider.valueChanged.connect(self._on_value_changed)
|
|
88
|
+
self._on_value_changed(value)
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def value(self) -> int:
|
|
92
|
+
return self.slider.value()
|
|
93
|
+
|
|
94
|
+
def _on_value_changed(self, value: int) -> None:
|
|
95
|
+
step = self.slider.singleStep()
|
|
96
|
+
if step > 1 and value % step:
|
|
97
|
+
value = round(value / step) * step
|
|
98
|
+
self.slider.setValue(value)
|
|
99
|
+
return
|
|
100
|
+
self.value_label.setText(f"{value}{self._suffix}")
|
|
101
|
+
self.value_changed.emit(value)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class SettingsPanel(QWidget):
|
|
105
|
+
spotlight_enabled_changed = Signal(bool)
|
|
106
|
+
spotlight_color_changed = Signal(str)
|
|
107
|
+
spotlight_opacity_changed = Signal(int)
|
|
108
|
+
spotlight_size_changed = Signal(int)
|
|
109
|
+
keystroke_enabled_changed = Signal(bool)
|
|
110
|
+
keystroke_color_changed = Signal(str)
|
|
111
|
+
keystroke_opacity_changed = Signal(int)
|
|
112
|
+
|
|
113
|
+
def __init__(self, settings: AppSettings, parent: QWidget | None = None) -> None:
|
|
114
|
+
super().__init__(parent)
|
|
115
|
+
self.setMinimumWidth(310)
|
|
116
|
+
layout = QVBoxLayout(self)
|
|
117
|
+
layout.setContentsMargins(14, 12, 14, 12)
|
|
118
|
+
layout.setSpacing(10)
|
|
119
|
+
|
|
120
|
+
self.spotlight_enabled = QCheckBox("啟用聚光燈游標")
|
|
121
|
+
self.spotlight_enabled.setChecked(settings.spotlight.enabled)
|
|
122
|
+
self.spotlight_color = ColorButton(settings.spotlight.color)
|
|
123
|
+
self.spotlight_opacity = ValueSlider(10, 100, settings.spotlight.opacity, "%")
|
|
124
|
+
self.spotlight_size = ValueSlider(
|
|
125
|
+
MIN_SPOTLIGHT_SIZE,
|
|
126
|
+
MAX_SPOTLIGHT_SIZE,
|
|
127
|
+
settings.spotlight.size,
|
|
128
|
+
" px",
|
|
129
|
+
8,
|
|
130
|
+
)
|
|
131
|
+
layout.addWidget(self._section_title("聚光燈游標"))
|
|
132
|
+
layout.addWidget(self.spotlight_enabled)
|
|
133
|
+
layout.addLayout(self._row("顏色", self.spotlight_color))
|
|
134
|
+
layout.addLayout(self._row("透明度", self.spotlight_opacity))
|
|
135
|
+
layout.addLayout(self._row("圓圈大小", self.spotlight_size))
|
|
136
|
+
layout.addWidget(self._separator())
|
|
137
|
+
|
|
138
|
+
self.keystroke_enabled = QCheckBox("啟用按鍵顯示")
|
|
139
|
+
self.keystroke_enabled.setChecked(settings.keystroke.enabled)
|
|
140
|
+
self.keystroke_color = ColorButton(settings.keystroke.color)
|
|
141
|
+
self.keystroke_opacity = ValueSlider(10, 100, settings.keystroke.opacity, "%")
|
|
142
|
+
layout.addWidget(self._section_title("按鍵顯示"))
|
|
143
|
+
layout.addWidget(self.keystroke_enabled)
|
|
144
|
+
layout.addLayout(self._row("顏色", self.keystroke_color))
|
|
145
|
+
layout.addLayout(self._row("透明度", self.keystroke_opacity))
|
|
146
|
+
|
|
147
|
+
if is_wayland():
|
|
148
|
+
warning = QLabel("⚠ Wayland 不保證支援全域按鍵與覆蓋層")
|
|
149
|
+
warning.setWordWrap(True)
|
|
150
|
+
warning.setStyleSheet("color: #b26a00; padding-top: 6px")
|
|
151
|
+
layout.addWidget(warning)
|
|
152
|
+
|
|
153
|
+
self.spotlight_enabled.toggled.connect(self.spotlight_enabled_changed)
|
|
154
|
+
self.spotlight_color.color_changed.connect(self.spotlight_color_changed)
|
|
155
|
+
self.spotlight_opacity.value_changed.connect(self.spotlight_opacity_changed)
|
|
156
|
+
self.spotlight_size.value_changed.connect(self.spotlight_size_changed)
|
|
157
|
+
self.keystroke_enabled.toggled.connect(self.keystroke_enabled_changed)
|
|
158
|
+
self.keystroke_color.color_changed.connect(self.keystroke_color_changed)
|
|
159
|
+
self.keystroke_opacity.value_changed.connect(self.keystroke_opacity_changed)
|
|
160
|
+
|
|
161
|
+
def set_spotlight_enabled(self, enabled: bool) -> None:
|
|
162
|
+
self.spotlight_enabled.setChecked(enabled)
|
|
163
|
+
|
|
164
|
+
def set_keystroke_enabled(self, enabled: bool) -> None:
|
|
165
|
+
self.keystroke_enabled.setChecked(enabled)
|
|
166
|
+
|
|
167
|
+
@staticmethod
|
|
168
|
+
def _section_title(text: str) -> QLabel:
|
|
169
|
+
label = QLabel(text)
|
|
170
|
+
font = label.font()
|
|
171
|
+
font.setBold(True)
|
|
172
|
+
label.setFont(font)
|
|
173
|
+
return label
|
|
174
|
+
|
|
175
|
+
@staticmethod
|
|
176
|
+
def _separator() -> QFrame:
|
|
177
|
+
frame = QFrame()
|
|
178
|
+
frame.setFrameShape(QFrame.Shape.HLine)
|
|
179
|
+
frame.setFrameShadow(QFrame.Shadow.Sunken)
|
|
180
|
+
return frame
|
|
181
|
+
|
|
182
|
+
@staticmethod
|
|
183
|
+
def _row(label: str, widget: QWidget) -> QHBoxLayout:
|
|
184
|
+
layout = QHBoxLayout()
|
|
185
|
+
layout.setContentsMargins(18, 0, 0, 0)
|
|
186
|
+
title = QLabel(label)
|
|
187
|
+
title.setMinimumWidth(62)
|
|
188
|
+
layout.addWidget(title)
|
|
189
|
+
layout.addWidget(widget, 1)
|
|
190
|
+
return layout
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
class TrayController(QObject):
|
|
194
|
+
exit_requested = Signal()
|
|
195
|
+
|
|
196
|
+
def __init__(self, settings: AppSettings, parent: QObject | None = None) -> None:
|
|
197
|
+
super().__init__(parent)
|
|
198
|
+
self.panel = SettingsPanel(settings)
|
|
199
|
+
self.menu = QMenu()
|
|
200
|
+
panel_action = QWidgetAction(self.menu)
|
|
201
|
+
panel_action.setDefaultWidget(self.panel)
|
|
202
|
+
self.menu.addAction(panel_action)
|
|
203
|
+
self.menu.addSeparator()
|
|
204
|
+
exit_action = QAction("結束", self.menu)
|
|
205
|
+
exit_action.triggered.connect(self.exit_requested)
|
|
206
|
+
self.menu.addAction(exit_action)
|
|
207
|
+
self.tray = QSystemTrayIcon(tray_icon(), self)
|
|
208
|
+
self.tray.setToolTip("簡報瑞士刀")
|
|
209
|
+
self.tray.setContextMenu(self.menu)
|
|
210
|
+
|
|
211
|
+
@staticmethod
|
|
212
|
+
def is_available() -> bool:
|
|
213
|
+
return QSystemTrayIcon.isSystemTrayAvailable()
|
|
214
|
+
|
|
215
|
+
def show(self) -> None:
|
|
216
|
+
self.tray.show()
|
|
217
|
+
|
|
218
|
+
def hide(self) -> None:
|
|
219
|
+
self.menu.hide()
|
|
220
|
+
self.tray.hide()
|
|
221
|
+
|
|
222
|
+
def notify_warning(self, message: str) -> None:
|
|
223
|
+
self.tray.showMessage("簡報瑞士刀", message, QSystemTrayIcon.MessageIcon.Warning, 5000)
|
|
224
|
+
|
|
225
|
+
def connect_settings(
|
|
226
|
+
self,
|
|
227
|
+
*,
|
|
228
|
+
spotlight_enabled: Callable[[bool], None],
|
|
229
|
+
spotlight_color: Callable[[str], None],
|
|
230
|
+
spotlight_opacity: Callable[[int], None],
|
|
231
|
+
spotlight_size: Callable[[int], None],
|
|
232
|
+
keystroke_enabled: Callable[[bool], None],
|
|
233
|
+
keystroke_color: Callable[[str], None],
|
|
234
|
+
keystroke_opacity: Callable[[int], None],
|
|
235
|
+
) -> None:
|
|
236
|
+
self.panel.spotlight_enabled_changed.connect(spotlight_enabled)
|
|
237
|
+
self.panel.spotlight_color_changed.connect(spotlight_color)
|
|
238
|
+
self.panel.spotlight_opacity_changed.connect(spotlight_opacity)
|
|
239
|
+
self.panel.spotlight_size_changed.connect(spotlight_size)
|
|
240
|
+
self.panel.keystroke_enabled_changed.connect(keystroke_enabled)
|
|
241
|
+
self.panel.keystroke_color_changed.connect(keystroke_color)
|
|
242
|
+
self.panel.keystroke_opacity_changed.connect(keystroke_opacity)
|