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