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.
@@ -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())