messageflight 0.2.4__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.
- message_flight/__init__.py +0 -0
- message_flight/autostart.py +41 -0
- message_flight/config.py +457 -0
- message_flight/demo_notifications.py +12 -0
- message_flight/flight_widget.py +470 -0
- message_flight/i18n.py +370 -0
- message_flight/notification_queue.py +72 -0
- message_flight/notification_worker.py +98 -0
- message_flight/plane_banner.py +359 -0
- message_flight/plane_presets/__init__.py +26 -0
- message_flight/plane_presets/airplane.py +103 -0
- message_flight/plane_presets/base.py +35 -0
- message_flight/plane_presets/bird.py +87 -0
- message_flight/plane_presets/rocket.py +94 -0
- message_flight/plane_presets/ufo.py +73 -0
- message_flight/preset_editor.py +345 -0
- message_flight/settings_dialog.py +266 -0
- message_flight/tray_app.py +280 -0
- message_flight/tts.py +298 -0
- message_flight/tts_manager.py +98 -0
- messageflight-0.2.4.dist-info/METADATA +94 -0
- messageflight-0.2.4.dist-info/RECORD +24 -0
- messageflight-0.2.4.dist-info/WHEEL +4 -0
- messageflight-0.2.4.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
"""Modal settings dialog for editing the 9-color plane/banner palette and the flight mode preset."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
from PyQt6.QtGui import QColor
|
|
7
|
+
from PyQt6.QtWidgets import (
|
|
8
|
+
QComboBox,
|
|
9
|
+
QDialog,
|
|
10
|
+
QDialogButtonBox,
|
|
11
|
+
QFormLayout,
|
|
12
|
+
QHBoxLayout,
|
|
13
|
+
QLabel,
|
|
14
|
+
QLineEdit,
|
|
15
|
+
QPushButton,
|
|
16
|
+
QVBoxLayout,
|
|
17
|
+
QWidget,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
from message_flight.config import (
|
|
21
|
+
DEFAULT_THEME,
|
|
22
|
+
FLIGHT_MODE_NAMES,
|
|
23
|
+
FLIGHT_MODES,
|
|
24
|
+
THEMES,
|
|
25
|
+
VALID_FLY_PATHS,
|
|
26
|
+
AppConfig,
|
|
27
|
+
)
|
|
28
|
+
from message_flight.i18n import LANGUAGES, language_name, tr
|
|
29
|
+
|
|
30
|
+
_COLOR_KEYS: tuple[str, ...] = (
|
|
31
|
+
"plane_color",
|
|
32
|
+
"wing_color",
|
|
33
|
+
"accent_color",
|
|
34
|
+
"decor_color",
|
|
35
|
+
"banner_color",
|
|
36
|
+
"text_color",
|
|
37
|
+
"thruster_outer_color",
|
|
38
|
+
"thruster_middle_color",
|
|
39
|
+
"thruster_inner_color",
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
_PRESET_KEYS: tuple[str, ...] = ("default", "retro", "cyber")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class SettingsDialog(QDialog):
|
|
46
|
+
"""A 9-row form for editing the plane color palette, plus 3 flight-mode + 3 color preset buttons.
|
|
47
|
+
|
|
48
|
+
The dialog does not mutate the live widget directly; the caller is
|
|
49
|
+
expected to call :meth:`get_result` after the dialog is accepted
|
|
50
|
+
and then forward the new colors to ``PlaneBanner.update_colors``.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def __init__(self, initial: AppConfig, parent: Optional[QWidget] = None):
|
|
54
|
+
super().__init__(parent)
|
|
55
|
+
self._language = initial.language
|
|
56
|
+
self.setWindowTitle(tr("settings.title", self._language))
|
|
57
|
+
self.setModal(True)
|
|
58
|
+
|
|
59
|
+
self._current_theme_name = initial.theme_name or DEFAULT_THEME
|
|
60
|
+
self._line_edits: dict[str, QLineEdit] = {}
|
|
61
|
+
self._swatches: dict[str, QLabel] = {}
|
|
62
|
+
self._current_flight_mode: str = initial.flight_mode
|
|
63
|
+
# Copy so external mutations to the AppConfig don't leak in/out.
|
|
64
|
+
self._current_flight_kwargs: dict = dict(initial.flight_kwargs)
|
|
65
|
+
self._flight_mode_buttons: dict[str, QPushButton] = {}
|
|
66
|
+
|
|
67
|
+
root = QVBoxLayout(self)
|
|
68
|
+
|
|
69
|
+
language_row = QHBoxLayout()
|
|
70
|
+
language_row.addWidget(QLabel(tr("settings.language", self._language)))
|
|
71
|
+
self._language_combo = QComboBox()
|
|
72
|
+
for language in LANGUAGES:
|
|
73
|
+
self._language_combo.addItem(language_name(language), language)
|
|
74
|
+
current_language_index = self._language_combo.findData(self._language)
|
|
75
|
+
self._language_combo.setCurrentIndex(max(0, current_language_index))
|
|
76
|
+
language_row.addWidget(self._language_combo)
|
|
77
|
+
language_row.addStretch(1)
|
|
78
|
+
root.addLayout(language_row)
|
|
79
|
+
|
|
80
|
+
# Flight-mode row (Task 06) — sits at the TOP, above the color preset row
|
|
81
|
+
flight_mode_row = QHBoxLayout()
|
|
82
|
+
flight_mode_row.addWidget(QLabel(tr("settings.flight_mode", self._language)))
|
|
83
|
+
for mode_name in FLIGHT_MODE_NAMES:
|
|
84
|
+
btn = QPushButton(mode_name)
|
|
85
|
+
btn.clicked.connect(
|
|
86
|
+
lambda _checked=False, name=mode_name: self._apply_flight_mode(name)
|
|
87
|
+
)
|
|
88
|
+
self._flight_mode_buttons[mode_name] = btn
|
|
89
|
+
flight_mode_row.addWidget(btn)
|
|
90
|
+
flight_mode_row.addStretch(1)
|
|
91
|
+
root.addLayout(flight_mode_row)
|
|
92
|
+
|
|
93
|
+
# Fly-path row
|
|
94
|
+
path_row = QHBoxLayout()
|
|
95
|
+
path_row.addWidget(QLabel(tr("settings.fly_path", self._language)))
|
|
96
|
+
self._path_combo = QComboBox()
|
|
97
|
+
for p in VALID_FLY_PATHS:
|
|
98
|
+
self._path_combo.addItem(p)
|
|
99
|
+
current_path = self._current_flight_kwargs.get("fly_path", "horizontal")
|
|
100
|
+
self._path_combo.setCurrentText(current_path)
|
|
101
|
+
self._path_combo.currentTextChanged.connect(self._on_path_changed)
|
|
102
|
+
path_row.addWidget(self._path_combo)
|
|
103
|
+
path_row.addStretch(1)
|
|
104
|
+
root.addLayout(path_row)
|
|
105
|
+
|
|
106
|
+
# Preset row
|
|
107
|
+
preset_row = QHBoxLayout()
|
|
108
|
+
preset_row.addWidget(QLabel(tr("settings.color_scheme", self._language)))
|
|
109
|
+
for theme_key in _PRESET_KEYS:
|
|
110
|
+
btn = QPushButton(tr(f"settings.preset.{theme_key}", self._language))
|
|
111
|
+
btn.clicked.connect(lambda _checked=False, key=theme_key: self._apply_preset(key))
|
|
112
|
+
preset_row.addWidget(btn)
|
|
113
|
+
preset_row.addStretch(1)
|
|
114
|
+
root.addLayout(preset_row)
|
|
115
|
+
|
|
116
|
+
# 9 color rows
|
|
117
|
+
form = QFormLayout()
|
|
118
|
+
for key in _COLOR_KEYS:
|
|
119
|
+
edit = QLineEdit(self)
|
|
120
|
+
swatch = QLabel(self)
|
|
121
|
+
swatch.setFixedSize(28, 18)
|
|
122
|
+
swatch.setFrameShape(QLabel.Shape.StyledPanel)
|
|
123
|
+
|
|
124
|
+
current = initial.colors.get(key, THEMES[self._current_theme_name].get(key, "#FFFFFF"))
|
|
125
|
+
edit.setText(current)
|
|
126
|
+
self._line_edits[key] = edit
|
|
127
|
+
self._swatches[key] = swatch
|
|
128
|
+
edit.textChanged.connect(lambda text, k=key: self._on_text_changed(k, text))
|
|
129
|
+
|
|
130
|
+
row_widget = QWidget()
|
|
131
|
+
row_layout = QHBoxLayout(row_widget)
|
|
132
|
+
row_layout.setContentsMargins(0, 0, 0, 0)
|
|
133
|
+
row_layout.addWidget(edit)
|
|
134
|
+
row_layout.addWidget(swatch)
|
|
135
|
+
form.addRow(tr(f"settings.color.{key}", self._language), row_widget)
|
|
136
|
+
|
|
137
|
+
root.addLayout(form)
|
|
138
|
+
|
|
139
|
+
# TTS Provider row
|
|
140
|
+
provider_row = QHBoxLayout()
|
|
141
|
+
provider_row.addWidget(QLabel(tr("settings.tts_engine", self._language)))
|
|
142
|
+
self._provider_combo = QComboBox()
|
|
143
|
+
for p in ("sapi", "minimax"):
|
|
144
|
+
self._provider_combo.addItem(p)
|
|
145
|
+
self._provider_combo.setCurrentText(initial.tts_provider)
|
|
146
|
+
self._provider_combo.currentTextChanged.connect(self._on_provider_changed)
|
|
147
|
+
provider_row.addWidget(self._provider_combo)
|
|
148
|
+
provider_row.addStretch(1)
|
|
149
|
+
root.addLayout(provider_row)
|
|
150
|
+
|
|
151
|
+
# API Key (enabled only for minimax)
|
|
152
|
+
self._api_key_label = QLabel(tr("settings.minimax_key", self._language))
|
|
153
|
+
self._api_key_edit = QLineEdit(initial.minimax_subscription_key)
|
|
154
|
+
self._api_key_edit.setPlaceholderText(tr("settings.minimax_key_placeholder", self._language))
|
|
155
|
+
form.addRow(self._api_key_label, self._api_key_edit)
|
|
156
|
+
self._update_api_key_enabled(initial.tts_provider)
|
|
157
|
+
|
|
158
|
+
# OK / Cancel
|
|
159
|
+
self._button_box = QDialogButtonBox(
|
|
160
|
+
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel,
|
|
161
|
+
parent=self,
|
|
162
|
+
)
|
|
163
|
+
self._button_box.accepted.connect(self.accept)
|
|
164
|
+
self._button_box.rejected.connect(self.reject)
|
|
165
|
+
root.addWidget(self._button_box)
|
|
166
|
+
|
|
167
|
+
# Initial render
|
|
168
|
+
self._refresh_all_swatches()
|
|
169
|
+
self._refresh_ok_enabled()
|
|
170
|
+
|
|
171
|
+
# ------------------------------------------------------------------
|
|
172
|
+
# Public API
|
|
173
|
+
# ------------------------------------------------------------------
|
|
174
|
+
|
|
175
|
+
def get_result(self) -> AppConfig:
|
|
176
|
+
"""Return the current dialog state as an :class:`AppConfig`.
|
|
177
|
+
|
|
178
|
+
Safe to call only after the dialog has been accepted (or while
|
|
179
|
+
it is still open and the user is editing). The returned colors
|
|
180
|
+
are normalized via :meth:`QColor.name` so the values match the
|
|
181
|
+
``PlaneBanner.update_colors`` contract.
|
|
182
|
+
"""
|
|
183
|
+
colors: dict[str, str] = {}
|
|
184
|
+
for key in self._line_edits:
|
|
185
|
+
text = self._line_edits[key].text().strip()
|
|
186
|
+
qc = QColor(text)
|
|
187
|
+
colors[key] = qc.name() if qc.isValid() else text
|
|
188
|
+
return AppConfig(
|
|
189
|
+
theme_name=self._current_theme_name,
|
|
190
|
+
colors=colors,
|
|
191
|
+
flight_mode=self._current_flight_mode,
|
|
192
|
+
flight_kwargs=dict(self._current_flight_kwargs),
|
|
193
|
+
tts_provider=self._provider_combo.currentText(),
|
|
194
|
+
minimax_subscription_key=self._api_key_edit.text(),
|
|
195
|
+
language=self._language_combo.currentData(),
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
# ------------------------------------------------------------------
|
|
199
|
+
# Internals
|
|
200
|
+
# ------------------------------------------------------------------
|
|
201
|
+
|
|
202
|
+
def _on_text_changed(self, key: str, text: str) -> None:
|
|
203
|
+
"""Live-validate a single field; update its swatch + OK button."""
|
|
204
|
+
swatch = self._swatches[key]
|
|
205
|
+
qc = QColor(text)
|
|
206
|
+
if qc.isValid():
|
|
207
|
+
swatch.setStyleSheet(f"background-color: {qc.name()};")
|
|
208
|
+
else:
|
|
209
|
+
swatch.setStyleSheet("background-color: #888888;")
|
|
210
|
+
self._refresh_ok_enabled()
|
|
211
|
+
|
|
212
|
+
def _refresh_all_swatches(self) -> None:
|
|
213
|
+
for key, edit in self._line_edits.items():
|
|
214
|
+
self._on_text_changed(key, edit.text())
|
|
215
|
+
|
|
216
|
+
def _refresh_ok_enabled(self) -> None:
|
|
217
|
+
all_valid = all(
|
|
218
|
+
QColor(edit.text()).isValid() for edit in self._line_edits.values()
|
|
219
|
+
)
|
|
220
|
+
ok_btn = self._button_box.button(QDialogButtonBox.StandardButton.Ok)
|
|
221
|
+
if ok_btn is not None:
|
|
222
|
+
ok_btn.setEnabled(all_valid)
|
|
223
|
+
|
|
224
|
+
def _apply_preset(self, theme_key: str) -> None:
|
|
225
|
+
"""Fill all 9 QLineEdits with a preset and refresh swatches."""
|
|
226
|
+
if theme_key not in THEMES:
|
|
227
|
+
return
|
|
228
|
+
self._current_theme_name = theme_key
|
|
229
|
+
for key, value in THEMES[theme_key].items():
|
|
230
|
+
if key in self._line_edits:
|
|
231
|
+
self._line_edits[key].setText(value)
|
|
232
|
+
# _on_text_changed is fired automatically by setText, but the OK
|
|
233
|
+
# button state must be re-evaluated once at the end.
|
|
234
|
+
self._refresh_ok_enabled()
|
|
235
|
+
|
|
236
|
+
def _on_path_changed(self, text: str) -> None:
|
|
237
|
+
"""Update the fly_path inside the current flight kwargs."""
|
|
238
|
+
self._current_flight_kwargs["fly_path"] = text
|
|
239
|
+
|
|
240
|
+
def _on_provider_changed(self, text: str) -> None:
|
|
241
|
+
"""Enable/disable API Key input based on provider selection."""
|
|
242
|
+
self._update_api_key_enabled(text)
|
|
243
|
+
|
|
244
|
+
def _update_api_key_enabled(self, provider: str) -> None:
|
|
245
|
+
"""API Key is only needed for minimax."""
|
|
246
|
+
is_minimax = provider == "minimax"
|
|
247
|
+
self._api_key_label.setEnabled(is_minimax)
|
|
248
|
+
self._api_key_edit.setEnabled(is_minimax)
|
|
249
|
+
|
|
250
|
+
def _apply_flight_mode(self, mode_name: str) -> None:
|
|
251
|
+
"""Switch to a named flight mode preset (flight params only).
|
|
252
|
+
|
|
253
|
+
Looks up the preset in :data:`FLIGHT_MODES` and records the
|
|
254
|
+
mode's flight kwargs in :attr:`_current_flight_kwargs` so a
|
|
255
|
+
subsequent call to :meth:`get_result` returns a complete
|
|
256
|
+
:class:`AppConfig`. Color fields are NOT touched.
|
|
257
|
+
|
|
258
|
+
The live :class:`FlightWidget` is NOT updated here — the new
|
|
259
|
+
flight kwargs take effect on the next application restart (a
|
|
260
|
+
"重启生效" label next to the mode row makes this clear to the
|
|
261
|
+
user).
|
|
262
|
+
"""
|
|
263
|
+
if mode_name not in FLIGHT_MODES:
|
|
264
|
+
return
|
|
265
|
+
self._current_flight_mode = mode_name
|
|
266
|
+
self._current_flight_kwargs = dict(FLIGHT_MODES[mode_name])
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
"""System tray icon, context menu, and application lifecycle."""
|
|
2
|
+
import logging
|
|
3
|
+
import random
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
from PyQt6.QtCore import Qt
|
|
7
|
+
from PyQt6.QtGui import QAction, QColor, QIcon, QPainter, QPainterPath, QPixmap, QPixmapCache
|
|
8
|
+
from PyQt6.QtWidgets import QApplication, QDialog, QMenu, QSystemTrayIcon
|
|
9
|
+
|
|
10
|
+
from message_flight.autostart import is_auto_start_enabled, set_auto_start
|
|
11
|
+
from message_flight.config import is_dnd_active, load_config, save_config
|
|
12
|
+
from message_flight.demo_notifications import NOTIFICATIONS
|
|
13
|
+
from message_flight.flight_widget import FlightWidget
|
|
14
|
+
from message_flight.i18n import tr
|
|
15
|
+
from message_flight.notification_worker import WINSOK_AVAILABLE, NotificationWorker
|
|
16
|
+
from message_flight.preset_editor import PresetEditorWindow
|
|
17
|
+
from message_flight.settings_dialog import SettingsDialog
|
|
18
|
+
from message_flight.tts_manager import TTSManager
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class TrayApplication:
|
|
24
|
+
def __init__(self) -> None:
|
|
25
|
+
QApplication.setHighDpiScaleFactorRoundingPolicy(
|
|
26
|
+
Qt.HighDpiScaleFactorRoundingPolicy.PassThrough
|
|
27
|
+
)
|
|
28
|
+
self.app = QApplication(sys.argv)
|
|
29
|
+
self.app.setQuitOnLastWindowClosed(False)
|
|
30
|
+
# Limit Qt internal pixmap cache to prevent memory growth over long runs
|
|
31
|
+
QPixmapCache.setCacheLimit(1024 * 50) # 50 MB
|
|
32
|
+
|
|
33
|
+
# Load persisted color scheme and hand it to the widget
|
|
34
|
+
self.cfg = load_config()
|
|
35
|
+
self.language = self.cfg.language
|
|
36
|
+
self.widget: FlightWidget = FlightWidget(plane_colors=self.cfg.colors, **self.cfg.flight_kwargs)
|
|
37
|
+
|
|
38
|
+
self.tts = TTSManager(self.cfg)
|
|
39
|
+
|
|
40
|
+
# 系统托盘
|
|
41
|
+
self.tray_icon = QSystemTrayIcon(self._create_tray_icon(), self.app)
|
|
42
|
+
self.tray_icon.setToolTip("MessageFlight")
|
|
43
|
+
|
|
44
|
+
self.menu = QMenu()
|
|
45
|
+
|
|
46
|
+
self.action_show = QAction(tr("tray.show", self.language), self.menu)
|
|
47
|
+
self.action_show.triggered.connect(self._show_widget)
|
|
48
|
+
self.menu.addAction(self.action_show)
|
|
49
|
+
|
|
50
|
+
self.action_pause = QAction(tr("tray.pause", self.language), self.menu)
|
|
51
|
+
self.action_pause.setCheckable(True)
|
|
52
|
+
self.action_pause.triggered.connect(self._toggle_pause)
|
|
53
|
+
self.menu.addAction(self.action_pause)
|
|
54
|
+
|
|
55
|
+
self.action_demo = QAction(tr("tray.demo", self.language), self.menu)
|
|
56
|
+
self.action_demo.triggered.connect(self._send_demo_notification)
|
|
57
|
+
self.menu.addAction(self.action_demo)
|
|
58
|
+
|
|
59
|
+
# 免打扰模式(manual toggle only; scheduled window is read-only here)
|
|
60
|
+
self.action_dnd = QAction(tr("tray.dnd", self.language), self.menu)
|
|
61
|
+
self.action_dnd.setCheckable(True)
|
|
62
|
+
self.action_dnd.setChecked(self.cfg.dnd_enabled)
|
|
63
|
+
self.action_dnd.triggered.connect(self._toggle_dnd)
|
|
64
|
+
self.menu.addAction(self.action_dnd)
|
|
65
|
+
|
|
66
|
+
self.action_settings = QAction(tr("tray.settings", self.language), self.menu)
|
|
67
|
+
self.action_settings.triggered.connect(self._open_settings)
|
|
68
|
+
self.menu.addAction(self.action_settings)
|
|
69
|
+
|
|
70
|
+
self.action_preset_editor = QAction(tr("tray.preset_editor", self.language), self.menu)
|
|
71
|
+
self.action_preset_editor.triggered.connect(self._open_preset_editor)
|
|
72
|
+
self.menu.addAction(self.action_preset_editor)
|
|
73
|
+
|
|
74
|
+
self.menu.addSeparator()
|
|
75
|
+
|
|
76
|
+
# 通知权限状态
|
|
77
|
+
self.action_notif_status = QAction(tr("tray.notification_status.checking", self.language), self.menu)
|
|
78
|
+
self.action_notif_status.setEnabled(False)
|
|
79
|
+
self.menu.addAction(self.action_notif_status)
|
|
80
|
+
|
|
81
|
+
self.menu.addSeparator()
|
|
82
|
+
|
|
83
|
+
self.action_autostart = QAction(tr("tray.autostart", self.language), self.menu)
|
|
84
|
+
self.action_autostart.setCheckable(True)
|
|
85
|
+
self.action_autostart.setChecked(is_auto_start_enabled())
|
|
86
|
+
self.action_autostart.triggered.connect(self._toggle_autostart)
|
|
87
|
+
self.menu.addAction(self.action_autostart)
|
|
88
|
+
|
|
89
|
+
self.menu.addSeparator()
|
|
90
|
+
|
|
91
|
+
self.action_quit = QAction(tr("tray.quit", self.language), self.menu)
|
|
92
|
+
self.action_quit.triggered.connect(self._quit)
|
|
93
|
+
self.menu.addAction(self.action_quit)
|
|
94
|
+
|
|
95
|
+
self.tray_icon.setContextMenu(self.menu)
|
|
96
|
+
self.tray_icon.activated.connect(self._on_tray_activated)
|
|
97
|
+
self.tray_icon.show()
|
|
98
|
+
|
|
99
|
+
# 启动通知监听线程
|
|
100
|
+
self.notifier = None
|
|
101
|
+
if WINSOK_AVAILABLE:
|
|
102
|
+
self.notifier = NotificationWorker()
|
|
103
|
+
self.notifier.notification_received.connect(self._on_real_notification)
|
|
104
|
+
self.notifier.access_status_changed.connect(self._on_access_status)
|
|
105
|
+
self.notifier.start()
|
|
106
|
+
else:
|
|
107
|
+
self.action_notif_status.setText(tr("tray.notification_status.unavailable", self.language))
|
|
108
|
+
|
|
109
|
+
def _create_tray_icon(self) -> QIcon:
|
|
110
|
+
size = 64
|
|
111
|
+
pixmap = QPixmap(size, size)
|
|
112
|
+
pixmap.fill(Qt.GlobalColor.transparent)
|
|
113
|
+
painter = QPainter(pixmap)
|
|
114
|
+
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
|
115
|
+
painter.scale(size / 80, size / 80)
|
|
116
|
+
|
|
117
|
+
c = QColor("#FF69B4")
|
|
118
|
+
painter.setPen(Qt.PenStyle.NoPen)
|
|
119
|
+
painter.setBrush(c)
|
|
120
|
+
painter.drawEllipse(10, 18, 45, 22)
|
|
121
|
+
painter.drawEllipse(48, 19, 14, 20)
|
|
122
|
+
|
|
123
|
+
wing = QColor("#FF1493")
|
|
124
|
+
painter.setBrush(wing)
|
|
125
|
+
wp = QPainterPath()
|
|
126
|
+
wp.moveTo(25, 25)
|
|
127
|
+
wp.lineTo(15, 8)
|
|
128
|
+
wp.lineTo(35, 8)
|
|
129
|
+
wp.lineTo(40, 25)
|
|
130
|
+
wp.closeSubpath()
|
|
131
|
+
painter.drawPath(wp)
|
|
132
|
+
|
|
133
|
+
tp = QPainterPath()
|
|
134
|
+
tp.moveTo(12, 28)
|
|
135
|
+
tp.lineTo(2, 18)
|
|
136
|
+
tp.lineTo(12, 22)
|
|
137
|
+
tp.closeSubpath()
|
|
138
|
+
painter.drawPath(tp)
|
|
139
|
+
|
|
140
|
+
painter.setBrush(QColor("#FFFFFF"))
|
|
141
|
+
painter.drawEllipse(52, 24, 6, 6)
|
|
142
|
+
painter.drawEllipse(38, 24, 5, 5)
|
|
143
|
+
painter.end()
|
|
144
|
+
return QIcon(pixmap)
|
|
145
|
+
|
|
146
|
+
def _show_widget(self):
|
|
147
|
+
self.widget.show()
|
|
148
|
+
self.widget.raise_()
|
|
149
|
+
self.widget.activateWindow()
|
|
150
|
+
|
|
151
|
+
def _refresh_translated_labels(self) -> None:
|
|
152
|
+
self.action_show.setText(tr("tray.show", self.language))
|
|
153
|
+
self.action_pause.setText(
|
|
154
|
+
tr("tray.resume", self.language) if self.action_pause.isChecked() else tr("tray.pause", self.language)
|
|
155
|
+
)
|
|
156
|
+
self.action_demo.setText(tr("tray.demo", self.language))
|
|
157
|
+
self.action_dnd.setText(tr("tray.dnd", self.language))
|
|
158
|
+
self.action_settings.setText(tr("tray.settings", self.language))
|
|
159
|
+
self.action_preset_editor.setText(tr("tray.preset_editor", self.language))
|
|
160
|
+
self.action_autostart.setText(tr("tray.autostart", self.language))
|
|
161
|
+
self.action_quit.setText(tr("tray.quit", self.language))
|
|
162
|
+
|
|
163
|
+
def _toggle_pause(self, checked: bool):
|
|
164
|
+
self.widget.set_paused(checked)
|
|
165
|
+
self.action_pause.setText(
|
|
166
|
+
tr("tray.resume", self.language) if checked else tr("tray.pause", self.language)
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
def _toggle_dnd(self, checked: bool):
|
|
170
|
+
"""Toggle manual Do-Not-Disturb and persist the choice."""
|
|
171
|
+
self.cfg.dnd_enabled = bool(checked)
|
|
172
|
+
try:
|
|
173
|
+
save_config(self.cfg)
|
|
174
|
+
except Exception as e:
|
|
175
|
+
logger.warning("Failed to persist DND toggle: %s", e)
|
|
176
|
+
|
|
177
|
+
def _toggle_autostart(self, checked: bool):
|
|
178
|
+
try:
|
|
179
|
+
set_auto_start(checked)
|
|
180
|
+
except Exception as e:
|
|
181
|
+
self.action_autostart.setChecked(not checked)
|
|
182
|
+
print(f"设置开机自启失败: {e}")
|
|
183
|
+
|
|
184
|
+
def _on_tray_activated(self, reason):
|
|
185
|
+
if reason == QSystemTrayIcon.ActivationReason.DoubleClick:
|
|
186
|
+
self._show_widget()
|
|
187
|
+
|
|
188
|
+
def _on_real_notification(self, app_name: str, text: str):
|
|
189
|
+
"""收到真实系统通知(受 DND 控制;演示通知不受影响)"""
|
|
190
|
+
if is_dnd_active(self.cfg):
|
|
191
|
+
logger.info("[DND] Suppressed real notification from %s", app_name)
|
|
192
|
+
return
|
|
193
|
+
display = f"[{app_name}] {text}"
|
|
194
|
+
# 截断过长的文本
|
|
195
|
+
if len(display) > 80:
|
|
196
|
+
display = display[:77] + "..."
|
|
197
|
+
logger.info("[Real Notification] %s", display)
|
|
198
|
+
self.tts.speak(display)
|
|
199
|
+
self.widget.enqueue_notification(display)
|
|
200
|
+
self._show_widget()
|
|
201
|
+
|
|
202
|
+
def _on_access_status(self, status: int):
|
|
203
|
+
"""通知权限状态更新"""
|
|
204
|
+
labels = {
|
|
205
|
+
0: tr("status.unspecified", self.language),
|
|
206
|
+
1: tr("status.allowed", self.language),
|
|
207
|
+
2: tr("status.denied", self.language),
|
|
208
|
+
}
|
|
209
|
+
self.action_notif_status.setText(
|
|
210
|
+
tr(
|
|
211
|
+
"tray.notification_status",
|
|
212
|
+
self.language,
|
|
213
|
+
label=labels.get(status, tr("status.unknown", self.language)),
|
|
214
|
+
status=status,
|
|
215
|
+
)
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
def _send_demo_notification(self):
|
|
219
|
+
"""Pick a random demo notification, speak it, and fire it on the widget.
|
|
220
|
+
|
|
221
|
+
Demo notifications bypass DND so the user can always test the
|
|
222
|
+
notification path even when real notifications are silenced.
|
|
223
|
+
"""
|
|
224
|
+
text = random.choice(NOTIFICATIONS)
|
|
225
|
+
logger.info("[Demo Notification] %s", text)
|
|
226
|
+
self.tts.speak(text)
|
|
227
|
+
self.widget.enqueue_notification(text)
|
|
228
|
+
|
|
229
|
+
def _open_settings(self):
|
|
230
|
+
"""Open the settings dialog (color scheme + flight mode). On accept, save config and apply changes."""
|
|
231
|
+
dlg = SettingsDialog(load_config(), self.menu)
|
|
232
|
+
if dlg.exec() == QDialog.DialogCode.Accepted:
|
|
233
|
+
new_cfg = dlg.get_result()
|
|
234
|
+
save_config(new_cfg)
|
|
235
|
+
self.cfg = new_cfg
|
|
236
|
+
self.language = new_cfg.language
|
|
237
|
+
self._refresh_translated_labels()
|
|
238
|
+
self.widget.plane.update_colors(**new_cfg.colors)
|
|
239
|
+
self.widget.set_flight_kwargs(**new_cfg.flight_kwargs)
|
|
240
|
+
self.tts.update_config(new_cfg)
|
|
241
|
+
|
|
242
|
+
def _open_preset_editor(self):
|
|
243
|
+
cfg = load_config()
|
|
244
|
+
dlg = PresetEditorWindow(cfg, self.menu)
|
|
245
|
+
if dlg.exec() == QDialog.DialogCode.Accepted:
|
|
246
|
+
preset_key, params_json = dlg.get_result()
|
|
247
|
+
cfg.plane_preset_key = preset_key
|
|
248
|
+
cfg.plane_preset_params_json = params_json
|
|
249
|
+
save_config(cfg)
|
|
250
|
+
self._apply_preset_to_widget(preset_key, params_json)
|
|
251
|
+
|
|
252
|
+
def _apply_preset_to_widget(self, preset_key: str, params_json: str) -> None:
|
|
253
|
+
import dataclasses
|
|
254
|
+
import json
|
|
255
|
+
|
|
256
|
+
from message_flight.plane_presets import get_preset
|
|
257
|
+
preset = get_preset(preset_key)
|
|
258
|
+
if params_json:
|
|
259
|
+
try:
|
|
260
|
+
data = json.loads(params_json)
|
|
261
|
+
default = preset.get_default_params()
|
|
262
|
+
params = dataclasses.replace(
|
|
263
|
+
default,
|
|
264
|
+
**{k: v for k, v in data.items() if hasattr(default, k)},
|
|
265
|
+
)
|
|
266
|
+
except (json.JSONDecodeError, TypeError):
|
|
267
|
+
params = preset.get_default_params()
|
|
268
|
+
else:
|
|
269
|
+
params = preset.get_default_params()
|
|
270
|
+
self.widget.plane.apply_preset(preset, params)
|
|
271
|
+
|
|
272
|
+
def _quit(self):
|
|
273
|
+
if self.notifier:
|
|
274
|
+
self.notifier.stop()
|
|
275
|
+
self.app.quit()
|
|
276
|
+
|
|
277
|
+
def run(self):
|
|
278
|
+
print("MessageFlight started!")
|
|
279
|
+
print("ESC: 隐藏窗口 | 托盘图标: 右键菜单 / 双击显示")
|
|
280
|
+
sys.exit(self.app.exec())
|