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,470 @@
1
+ """Main flight widget that animates the plane across the screen."""
2
+ import random
3
+ from typing import Any, Dict, Optional
4
+
5
+ from PyQt6.QtCore import QEasingCurve, QPoint, QPropertyAnimation, Qt, QTimer
6
+ from PyQt6.QtWidgets import QApplication, QWidget
7
+
8
+ from message_flight.demo_notifications import NOTIFICATIONS
9
+ from message_flight.notification_queue import NotificationQueue
10
+ from message_flight.plane_banner import PlaneBanner
11
+
12
+ _VALID_FLY_PATHS = (
13
+ "horizontal",
14
+ "vertical_pong",
15
+ "zigzag_top_down",
16
+ "zigzag_bottom_up",
17
+ "around",
18
+ )
19
+
20
+
21
+ class FlightWidget(QWidget):
22
+ def __init__(
23
+ self,
24
+ *,
25
+ float_duration_ms: int = 1500,
26
+ fly_duration_ms: int = 8000,
27
+ fly_loop_count: int = -1,
28
+ fly_bounce: bool = False,
29
+ fly_path: str = "horizontal",
30
+ initial_y_ratio: float = 0.25,
31
+ re_flight_y_ratio: float = 0.2,
32
+ re_flight_x_ratio: float = 0.5,
33
+ vertical_jitter: int = 100,
34
+ re_flight_jitter: int = 120,
35
+ re_flight_jitter_min_ratio: float = -1.0,
36
+ notification_interval_ms: int = 5000,
37
+ notification_queue_max_size: int = 20,
38
+ plane_colors: Optional[Dict[str, Any]] = None,
39
+ ) -> None:
40
+ super().__init__()
41
+ self.setWindowFlags(
42
+ Qt.WindowType.FramelessWindowHint
43
+ | Qt.WindowType.WindowStaysOnTopHint
44
+ | Qt.WindowType.Tool
45
+ )
46
+ self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
47
+ # Note: WA_TransparentForMouseEvents removed to allow plane interaction
48
+
49
+ primary_screen = QApplication.primaryScreen()
50
+ if primary_screen is None:
51
+ raise RuntimeError("No primary screen available")
52
+ screen = primary_screen.geometry()
53
+ self.screen_w = screen.width()
54
+ self.screen_h = screen.height()
55
+ self.setGeometry(0, 0, self.screen_w, self.screen_h)
56
+
57
+ self.plane: PlaneBanner
58
+ if plane_colors is None:
59
+ self.plane = PlaneBanner(self)
60
+ else:
61
+ self.plane = PlaneBanner(self, **plane_colors)
62
+ self.plane.set_text(NOTIFICATIONS[0])
63
+
64
+ # 飞行行为配置
65
+ self._float_duration_ms = int(float_duration_ms)
66
+ self._fly_duration_ms = int(fly_duration_ms)
67
+ self._fly_loop_count = int(fly_loop_count)
68
+ self._fly_bounce = bool(fly_bounce)
69
+ self._fly_path = fly_path
70
+ self._initial_y_ratio = float(initial_y_ratio)
71
+ self._re_flight_y_ratio = float(re_flight_y_ratio)
72
+ self._re_flight_x_ratio = max(0.0, min(1.0, float(re_flight_x_ratio)))
73
+ self._vertical_jitter = int(vertical_jitter)
74
+ self._re_flight_jitter = int(re_flight_jitter)
75
+ self._re_flight_jitter_min_ratio = float(re_flight_jitter_min_ratio)
76
+ self._notification_interval_ms = int(notification_interval_ms)
77
+
78
+ if self._fly_path not in _VALID_FLY_PATHS:
79
+ raise ValueError(
80
+ f"fly_path must be one of {_VALID_FLY_PATHS}, got {self._fly_path!r}"
81
+ )
82
+
83
+ # 飞行状态
84
+ self._fly_count = 0
85
+ self._fly_direction = 1 # 1 = left→right, -1 = right→left
86
+ self._pong_direction = 1 # 1 = down, -1 = up
87
+ self._fly_stopped = False
88
+
89
+ # zigzag / around 状态
90
+ self._zigzag_row = 0
91
+ self._zigzag_direction = 1 # 1 = left→right, -1 = right→left
92
+ self._around_step = 0
93
+
94
+ # 通知队列
95
+ self._notification_queue = NotificationQueue(max_size=notification_queue_max_size)
96
+ self._last_dropped_count = 0
97
+
98
+ start_y = self._compute_start_y()
99
+ self.plane.move(-self.plane.width(), start_y)
100
+
101
+ self._setup_float_animation()
102
+ self._setup_fly_animation()
103
+
104
+ self.msg_index = 0
105
+ # 禁用自动轮播演示消息(只在用户点击"发送演示通知"时切换)
106
+ self.timer = QTimer(self)
107
+ self.timer.timeout.connect(self._next_message)
108
+ # 不再自动启动 timer
109
+ # self.timer.start(self._notification_interval_ms)
110
+
111
+ self._paused = False
112
+
113
+ def _compute_start_y(self) -> int:
114
+ """根据 initial_y_ratio + vertical_jitter 算 y 坐标。"""
115
+ base = int(self.screen_h * self._initial_y_ratio)
116
+ return base + random.randint(-self._vertical_jitter, self._vertical_jitter)
117
+
118
+ def _setup_float_animation(self):
119
+ self.float_anim = QPropertyAnimation(self.plane, b"plane_offset")
120
+ self.float_anim.setDuration(self._float_duration_ms)
121
+ self.float_anim.setStartValue(0.0)
122
+ self.float_anim.setEndValue(1.0)
123
+ self.float_anim.setEasingCurve(QEasingCurve.Type.InOutSine)
124
+ self.float_anim.setLoopCount(-1)
125
+ self.float_anim.start()
126
+
127
+ def _setup_fly_animation(self):
128
+ if self._fly_path == "vertical_pong":
129
+ self._setup_vertical_pong()
130
+ return
131
+ if self._fly_path == "zigzag_top_down":
132
+ self._setup_zigzag_top_down()
133
+ return
134
+ if self._fly_path == "zigzag_bottom_up":
135
+ self._setup_zigzag_bottom_up()
136
+ return
137
+ if self._fly_path == "around":
138
+ self._setup_around()
139
+ return
140
+
141
+ start_y = self._compute_start_y()
142
+ end_y = start_y + random.randint(-30, 30)
143
+ self.fly_anim = QPropertyAnimation(self.plane, b"pos")
144
+ self.fly_anim.setDuration(self._fly_duration_ms)
145
+ self.fly_anim.setStartValue(QPoint(-self.plane.width(), start_y))
146
+ self.fly_anim.setEndValue(QPoint(self.screen_w + 50, end_y))
147
+ self.fly_anim.setEasingCurve(QEasingCurve.Type.Linear)
148
+ self.fly_anim.finished.connect(self._on_fly_finished)
149
+ self.fly_anim.start()
150
+
151
+ def _setup_vertical_pong(self):
152
+ start_x = int(self.screen_w * self._re_flight_x_ratio) + random.randint(-100, 100)
153
+ start_y = -self.plane.height()
154
+ end_y = self.screen_h + 50
155
+ self.fly_anim = QPropertyAnimation(self.plane, b"pos")
156
+ self.fly_anim.setDuration(self._fly_duration_ms)
157
+ self.fly_anim.setStartValue(QPoint(start_x, start_y))
158
+ self.fly_anim.setEndValue(QPoint(start_x, end_y))
159
+ self.fly_anim.setEasingCurve(QEasingCurve.Type.Linear)
160
+ self.fly_anim.finished.connect(self._on_fly_finished)
161
+ self.fly_anim.start()
162
+
163
+ def _setup_zigzag_top_down(self):
164
+ self._zigzag_row = 0
165
+ self._zigzag_direction = 1
166
+ start_y = 0
167
+ end_y = start_y
168
+ duration = int(self._fly_duration_ms * 0.6)
169
+ self.fly_anim = QPropertyAnimation(self.plane, b"pos")
170
+ self.fly_anim.setDuration(duration)
171
+ self.fly_anim.setStartValue(QPoint(-self.plane.width(), start_y))
172
+ self.fly_anim.setEndValue(QPoint(self.screen_w + 50, end_y))
173
+ self.fly_anim.setEasingCurve(QEasingCurve.Type.Linear)
174
+ self.fly_anim.finished.connect(self._on_fly_finished)
175
+ self.fly_anim.start()
176
+
177
+ def _setup_zigzag_bottom_up(self):
178
+ self._zigzag_row = 0
179
+ self._zigzag_direction = 1
180
+ start_y = self.screen_h - self.plane.height()
181
+ end_y = start_y
182
+ duration = int(self._fly_duration_ms * 0.6)
183
+ self.fly_anim = QPropertyAnimation(self.plane, b"pos")
184
+ self.fly_anim.setDuration(duration)
185
+ self.fly_anim.setStartValue(QPoint(-self.plane.width(), start_y))
186
+ self.fly_anim.setEndValue(QPoint(self.screen_w + 50, end_y))
187
+ self.fly_anim.setEasingCurve(QEasingCurve.Type.Linear)
188
+ self.fly_anim.finished.connect(self._on_fly_finished)
189
+ self.fly_anim.start()
190
+
191
+ def _setup_around(self):
192
+ self._around_step = 0
193
+ margin = 20
194
+ start_y = int(self.screen_h * self._initial_y_ratio)
195
+ self.fly_anim = QPropertyAnimation(self.plane, b"pos")
196
+ self.fly_anim.setDuration(int(self._fly_duration_ms * 0.5))
197
+ self.fly_anim.setStartValue(QPoint(-self.plane.width(), start_y))
198
+ self.fly_anim.setEndValue(QPoint(self.screen_w + margin, start_y))
199
+ self.fly_anim.setEasingCurve(QEasingCurve.Type.Linear)
200
+ self.fly_anim.finished.connect(self._on_fly_finished)
201
+ self.fly_anim.start()
202
+
203
+ def _on_fly_finished(self):
204
+ if self._fly_stopped:
205
+ return
206
+
207
+ self._fly_count += 1
208
+ if 0 < self._fly_loop_count <= self._fly_count:
209
+ # 达到循环次数,飞机停在最后一次结束位置
210
+ self.fly_anim.stop()
211
+ self._fly_stopped = True
212
+ return
213
+
214
+ # 队列里有等待显示的通知:切换横幅文字,但保持当前飞行轨迹不重置
215
+ next_text = self._notification_queue.dequeue()
216
+ if next_text is not None:
217
+ self.plane.set_text(next_text)
218
+
219
+ if self._fly_path == "vertical_pong":
220
+ self._on_vertical_pong_finished()
221
+ return
222
+ if self._fly_path in ("zigzag_top_down", "zigzag_bottom_up"):
223
+ self._on_zigzag_finished()
224
+ return
225
+ if self._fly_path == "around":
226
+ self._on_around_finished()
227
+ return
228
+
229
+ start_y_base = int(self.screen_h * self._re_flight_y_ratio)
230
+ start_y = start_y_base + random.randint(
231
+ int(self._re_flight_jitter * self._re_flight_jitter_min_ratio),
232
+ self._re_flight_jitter,
233
+ )
234
+
235
+ if self._fly_bounce:
236
+ # 来回飞:切换方向,从对面进入
237
+ if self._fly_direction == 1:
238
+ new_start_x = self.screen_w + 50
239
+ new_end_x = -self.plane.width()
240
+ self._fly_direction = -1
241
+ else:
242
+ new_start_x = -self.plane.width()
243
+ new_end_x = self.screen_w + 50
244
+ self._fly_direction = 1
245
+ self.plane.set_facing_direction(self._fly_direction)
246
+ end_y = start_y + random.randint(-30, 30)
247
+ self.plane.move(new_start_x, start_y)
248
+ self.fly_anim.setStartValue(QPoint(new_start_x, start_y))
249
+ self.fly_anim.setEndValue(QPoint(new_end_x, end_y))
250
+ self.fly_anim.start()
251
+ else:
252
+ # 单向飞:从左到右,循环
253
+ end_y = start_y + random.randint(-30, 30)
254
+ self.fly_anim.setStartValue(QPoint(-self.plane.width(), start_y))
255
+ self.fly_anim.setEndValue(QPoint(self.screen_w + 50, end_y))
256
+ self.fly_anim.start()
257
+
258
+ def _on_vertical_pong_finished(self):
259
+ current_pos = self.plane.pos()
260
+ new_x = current_pos.x() + random.randint(30, 80)
261
+
262
+ if new_x > self.screen_w + 100:
263
+ if self._fly_bounce and not self._fly_stopped:
264
+ new_x = int(self.screen_w * self._re_flight_x_ratio) + random.randint(-100, 100)
265
+ self.plane.move(new_x, -self.plane.height())
266
+ self.fly_anim.setStartValue(QPoint(new_x, -self.plane.height()))
267
+ self.fly_anim.setEndValue(QPoint(new_x, self.screen_h + 50))
268
+ self.fly_anim.start()
269
+ else:
270
+ self.fly_anim.stop()
271
+ self._fly_stopped = True
272
+ return
273
+
274
+ if self._pong_direction == 1: # was going down
275
+ self._pong_direction = -1
276
+ self.plane.move(new_x, self.screen_h + 50)
277
+ self.fly_anim.setStartValue(QPoint(new_x, self.screen_h + 50))
278
+ self.fly_anim.setEndValue(QPoint(new_x, -self.plane.height()))
279
+ else: # was going up
280
+ self._pong_direction = 1
281
+ self.plane.move(new_x, -self.plane.height())
282
+ self.fly_anim.setStartValue(QPoint(new_x, -self.plane.height()))
283
+ self.fly_anim.setEndValue(QPoint(new_x, self.screen_h + 50))
284
+ self.fly_anim.start()
285
+
286
+ def _on_zigzag_finished(self):
287
+ row_height = max(80, self.screen_h // 4)
288
+ self._zigzag_row += 1
289
+ self._zigzag_direction *= -1
290
+
291
+ going_down = self._fly_path == "zigzag_top_down"
292
+ current_y = self._zigzag_row * row_height
293
+ if not going_down:
294
+ current_y = self.screen_h - self.plane.height() - (self._zigzag_row * row_height)
295
+
296
+ if going_down and current_y > self.screen_h:
297
+ if self._fly_bounce:
298
+ self._zigzag_row = 0
299
+ self._zigzag_direction = 1
300
+ current_y = 0
301
+ else:
302
+ self.fly_anim.stop()
303
+ self._fly_stopped = True
304
+ return
305
+ if not going_down and current_y < -self.plane.height():
306
+ if self._fly_bounce:
307
+ self._zigzag_row = 0
308
+ self._zigzag_direction = 1
309
+ current_y = self.screen_h - self.plane.height()
310
+ else:
311
+ self.fly_anim.stop()
312
+ self._fly_stopped = True
313
+ return
314
+
315
+ duration = int(self._fly_duration_ms * 0.6)
316
+ if self._zigzag_direction == 1: # left → right
317
+ start_x = -self.plane.width()
318
+ end_x = self.screen_w + 50
319
+ self.plane.set_facing_direction(1)
320
+ else: # right → left
321
+ start_x = self.screen_w + 50
322
+ end_x = -self.plane.width()
323
+ self.plane.set_facing_direction(-1)
324
+
325
+ self.plane.move(start_x, current_y)
326
+ self.fly_anim.setDuration(duration)
327
+ self.fly_anim.setStartValue(QPoint(start_x, current_y))
328
+ self.fly_anim.setEndValue(QPoint(end_x, current_y))
329
+ self.fly_anim.start()
330
+
331
+ def _on_around_finished(self):
332
+ margin = 20
333
+ self._around_step = (self._around_step + 1) % 4
334
+
335
+ if self._around_step == 0:
336
+ # 回到起点:左中 → 右中
337
+ start_y = int(self.screen_h * self._initial_y_ratio)
338
+ self.plane.move(-self.plane.width(), start_y)
339
+ self.fly_anim.setDuration(int(self._fly_duration_ms * 0.5))
340
+ self.fly_anim.setStartValue(QPoint(-self.plane.width(), start_y))
341
+ self.fly_anim.setEndValue(QPoint(self.screen_w + margin, start_y))
342
+ self.plane.set_facing_direction(1)
343
+ elif self._around_step == 1:
344
+ # 右中 → 右下
345
+ start_x = self.screen_w + margin
346
+ start_y = int(self.screen_h * self._initial_y_ratio)
347
+ end_y = self.screen_h + margin
348
+ self.plane.move(start_x, start_y)
349
+ self.fly_anim.setDuration(int(self._fly_duration_ms * 0.3))
350
+ self.fly_anim.setStartValue(QPoint(start_x, start_y))
351
+ self.fly_anim.setEndValue(QPoint(start_x, end_y))
352
+ self.plane.set_facing_direction(1)
353
+ elif self._around_step == 2:
354
+ # 右下 → 左下
355
+ start_y = self.screen_h + margin
356
+ self.plane.move(self.screen_w + margin, start_y)
357
+ self.fly_anim.setDuration(int(self._fly_duration_ms * 0.5))
358
+ self.fly_anim.setStartValue(QPoint(self.screen_w + margin, start_y))
359
+ self.fly_anim.setEndValue(QPoint(-self.plane.width(), start_y))
360
+ self.plane.set_facing_direction(-1)
361
+ elif self._around_step == 3:
362
+ # 左下 → 左上
363
+ start_x = -self.plane.width()
364
+ start_y = self.screen_h + margin
365
+ end_y = -self.plane.height()
366
+ self.plane.move(start_x, start_y)
367
+ self.fly_anim.setDuration(int(self._fly_duration_ms * 0.3))
368
+ self.fly_anim.setStartValue(QPoint(start_x, start_y))
369
+ self.fly_anim.setEndValue(QPoint(start_x, end_y))
370
+ self.plane.set_facing_direction(-1)
371
+ self.fly_anim.start()
372
+
373
+ def _next_message(self):
374
+ self.msg_index = (self.msg_index + 1) % len(NOTIFICATIONS)
375
+ self.plane.set_text(NOTIFICATIONS[self.msg_index])
376
+
377
+ def keyPressEvent(self, event):
378
+ if event.key() == Qt.Key.Key_Escape:
379
+ self.hide()
380
+
381
+ def set_flight_kwargs(self, **kwargs) -> None:
382
+ """热更新飞行参数,无需重启应用。"""
383
+ if "fly_path" in kwargs:
384
+ self._fly_path = kwargs["fly_path"]
385
+ if "fly_loop_count" in kwargs:
386
+ self._fly_loop_count = int(kwargs["fly_loop_count"])
387
+ if "fly_bounce" in kwargs:
388
+ self._fly_bounce = bool(kwargs["fly_bounce"])
389
+ if "fly_duration_ms" in kwargs:
390
+ self._fly_duration_ms = int(kwargs["fly_duration_ms"])
391
+ if "float_duration_ms" in kwargs:
392
+ self._float_duration_ms = int(kwargs["float_duration_ms"])
393
+ if "notification_interval_ms" in kwargs:
394
+ self._notification_interval_ms = int(kwargs["notification_interval_ms"])
395
+ self.timer.setInterval(self._notification_interval_ms)
396
+ if "vertical_jitter" in kwargs:
397
+ self._vertical_jitter = int(kwargs["vertical_jitter"])
398
+ if "re_flight_y_ratio" in kwargs:
399
+ self._re_flight_y_ratio = float(kwargs["re_flight_y_ratio"])
400
+ if "re_flight_x_ratio" in kwargs:
401
+ self._re_flight_x_ratio = max(0.0, min(1.0, float(kwargs["re_flight_x_ratio"])))
402
+ if "re_flight_jitter" in kwargs:
403
+ self._re_flight_jitter = int(kwargs["re_flight_jitter"])
404
+
405
+ # 重置飞行状态,用新参数重新开始
406
+ self._fly_count = 0
407
+ self._fly_direction = 1
408
+ self._pong_direction = 1
409
+ self._zigzag_row = 0
410
+ self._zigzag_direction = 1
411
+ self._around_step = 0
412
+ self.plane.set_facing_direction(1)
413
+ self._fly_stopped = False
414
+ if hasattr(self, "fly_anim"):
415
+ self.fly_anim.stop()
416
+ if hasattr(self, "float_anim"):
417
+ self.float_anim.stop()
418
+ self._setup_float_animation()
419
+ self._setup_fly_animation()
420
+
421
+ def set_paused(self, paused: bool):
422
+ self._paused = paused
423
+ if paused:
424
+ self.fly_anim.pause()
425
+ self.float_anim.pause()
426
+ self.timer.stop()
427
+ else:
428
+ self.fly_anim.resume()
429
+ self.float_anim.resume()
430
+ # timer 不再自动启动
431
+ # self.timer.start()
432
+
433
+ def is_paused(self):
434
+ return self._paused
435
+
436
+ @property
437
+ def notification_queue(self) -> NotificationQueue:
438
+ """暴露队列以便外部代码(设置对话框、测试)查询或清空。"""
439
+ return self._notification_queue
440
+
441
+ def enqueue_notification(self, text: str) -> None:
442
+ """入队一条通知,必要时立即显示(队列之前为空时)。
443
+
444
+ 队列非空时不立即显示,避免打断当前动画;这些通知会在
445
+ :meth:`_on_fly_finished` 中按 FIFO 顺序逐条显示。
446
+ """
447
+ was_empty = self._notification_queue.is_empty()
448
+ dropped = self._notification_queue.enqueue(text)
449
+ if dropped is not None:
450
+ self._last_dropped_count += 1
451
+ if was_empty:
452
+ next_text = self._notification_queue.dequeue()
453
+ if next_text is not None:
454
+ self.show_notification(next_text)
455
+
456
+ def show_notification(self, text: str):
457
+ """显示一条真实通知,并重置飞行动画按当前路径模式飞出"""
458
+ self.plane.set_text(text)
459
+ self.fly_anim.stop()
460
+ # 重置飞行状态
461
+ self._fly_direction = 1
462
+ self._pong_direction = 1
463
+ self._zigzag_row = 0
464
+ self._zigzag_direction = 1
465
+ self._around_step = 0
466
+ self._fly_count = 0
467
+ self._fly_stopped = False
468
+ # 根据当前路径模式重新初始化飞行动画
469
+ self._setup_fly_animation()
470
+ self.timer.stop()