frontengine 1.0.30__tar.gz → 1.0.32__tar.gz

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.
Files changed (165) hide show
  1. {frontengine-1.0.30 → frontengine-1.0.32}/PKG-INFO +1 -1
  2. {frontengine-1.0.30 → frontengine-1.0.32}/frontengine/show/pet/desktop_pet.py +229 -6
  3. frontengine-1.0.32/frontengine/show/presentation/annotation_overlay.py +153 -0
  4. frontengine-1.0.32/frontengine/show/presentation/cursor_effects.py +166 -0
  5. frontengine-1.0.32/frontengine/show/presentation/keystroke_display.py +161 -0
  6. frontengine-1.0.32/frontengine/show/presentation/magnifier.py +127 -0
  7. frontengine-1.0.32/frontengine/show/screen_care/screen_filter.py +147 -0
  8. {frontengine-1.0.30 → frontengine-1.0.32}/frontengine/ui/main_ui.py +6 -0
  9. {frontengine-1.0.30 → frontengine-1.0.32}/frontengine/ui/page/pet/pet_setting_ui.py +115 -1
  10. frontengine-1.0.32/frontengine/ui/page/presentation/presentation_setting_ui.py +365 -0
  11. frontengine-1.0.32/frontengine/ui/page/screen_care/screen_care_setting_ui.py +295 -0
  12. frontengine-1.0.32/frontengine/utils/break_reminder/break_reminder.py +104 -0
  13. frontengine-1.0.32/frontengine/utils/focus_timer/focus_timer.py +112 -0
  14. frontengine-1.0.32/frontengine/utils/input_watch/input_watch_service.py +130 -0
  15. {frontengine-1.0.30 → frontengine-1.0.32}/frontengine/utils/multi_language/english.py +56 -0
  16. {frontengine-1.0.30 → frontengine-1.0.32}/frontengine/utils/multi_language/traditional_chinese.py +56 -0
  17. frontengine-1.0.32/frontengine/utils/pet_chat/pet_chat_service.py +162 -0
  18. frontengine-1.0.32/frontengine/utils/preset_schedule/__init__.py +0 -0
  19. frontengine-1.0.32/frontengine/utils/redirect_manager/__init__.py +0 -0
  20. frontengine-1.0.32/frontengine/utils/smart_pause/__init__.py +0 -0
  21. frontengine-1.0.32/frontengine/utils/system_stats/__init__.py +0 -0
  22. frontengine-1.0.32/frontengine/utils/text_source/__init__.py +0 -0
  23. frontengine-1.0.32/frontengine/utils/theme_schedule/__init__.py +0 -0
  24. frontengine-1.0.32/frontengine/utils/weather/__init__.py +0 -0
  25. frontengine-1.0.32/frontengine/worker/__init__.py +0 -0
  26. {frontengine-1.0.30 → frontengine-1.0.32}/frontengine.egg-info/PKG-INFO +1 -1
  27. {frontengine-1.0.30 → frontengine-1.0.32}/frontengine.egg-info/SOURCES.txt +19 -0
  28. {frontengine-1.0.30 → frontengine-1.0.32}/pyproject.toml +1 -1
  29. {frontengine-1.0.30 → frontengine-1.0.32}/LICENSE +0 -0
  30. {frontengine-1.0.30 → frontengine-1.0.32}/README.md +0 -0
  31. {frontengine-1.0.30 → frontengine-1.0.32}/frontengine/__init__.py +0 -0
  32. {frontengine-1.0.30 → frontengine-1.0.32}/frontengine/__main__.py +0 -0
  33. {frontengine-1.0.30 → frontengine-1.0.32}/frontengine/show/__init__.py +0 -0
  34. {frontengine-1.0.30 → frontengine-1.0.32}/frontengine/show/base_widget.py +0 -0
  35. {frontengine-1.0.30 → frontengine-1.0.32}/frontengine/show/gif/__init__.py +0 -0
  36. {frontengine-1.0.30 → frontengine-1.0.32}/frontengine/show/gif/paint_gif.py +0 -0
  37. {frontengine-1.0.30 → frontengine-1.0.32}/frontengine/show/image/__init__.py +0 -0
  38. {frontengine-1.0.30 → frontengine-1.0.32}/frontengine/show/image/paint_image.py +0 -0
  39. {frontengine-1.0.30 → frontengine-1.0.32}/frontengine/show/load/__init__.py +0 -0
  40. {frontengine-1.0.30 → frontengine-1.0.32}/frontengine/show/load/load_someone_make_ui.py +0 -0
  41. {frontengine-1.0.30 → frontengine-1.0.32}/frontengine/show/overlay_factory.py +0 -0
  42. {frontengine-1.0.30 → frontengine-1.0.32}/frontengine/show/particle/__init__.py +0 -0
  43. {frontengine-1.0.30 → frontengine-1.0.32}/frontengine/show/particle/particle_ui.py +0 -0
  44. {frontengine-1.0.30 → frontengine-1.0.32}/frontengine/show/pet/__init__.py +0 -0
  45. {frontengine-1.0.30/frontengine/show/scene → frontengine-1.0.32/frontengine/show/presentation}/__init__.py +0 -0
  46. {frontengine-1.0.30/frontengine/show/sound_player → frontengine-1.0.32/frontengine/show/scene}/__init__.py +0 -0
  47. {frontengine-1.0.30 → frontengine-1.0.32}/frontengine/show/scene/extend_graphic_scene.py +0 -0
  48. {frontengine-1.0.30 → frontengine-1.0.32}/frontengine/show/scene/extend_graphic_view.py +0 -0
  49. {frontengine-1.0.30 → frontengine-1.0.32}/frontengine/show/scene/scene.py +0 -0
  50. {frontengine-1.0.30/frontengine/show/text → frontengine-1.0.32/frontengine/show/screen_care}/__init__.py +0 -0
  51. {frontengine-1.0.30/frontengine/show/video → frontengine-1.0.32/frontengine/show/sound_player}/__init__.py +0 -0
  52. {frontengine-1.0.30 → frontengine-1.0.32}/frontengine/show/sound_player/sound_effect.py +0 -0
  53. {frontengine-1.0.30 → frontengine-1.0.32}/frontengine/show/sound_player/sound_player.py +0 -0
  54. {frontengine-1.0.30/frontengine/show/web → frontengine-1.0.32/frontengine/show/text}/__init__.py +0 -0
  55. {frontengine-1.0.30 → frontengine-1.0.32}/frontengine/show/text/draw_text.py +0 -0
  56. {frontengine-1.0.30/frontengine/system_tray → frontengine-1.0.32/frontengine/show/video}/__init__.py +0 -0
  57. {frontengine-1.0.30 → frontengine-1.0.32}/frontengine/show/video/video_player.py +0 -0
  58. {frontengine-1.0.30/frontengine/ui → frontengine-1.0.32/frontengine/show/web}/__init__.py +0 -0
  59. {frontengine-1.0.30 → frontengine-1.0.32}/frontengine/show/web/webview.py +0 -0
  60. {frontengine-1.0.30 → frontengine-1.0.32}/frontengine/show/window_helpers.py +0 -0
  61. {frontengine-1.0.30/frontengine/ui/color → frontengine-1.0.32/frontengine/system_tray}/__init__.py +0 -0
  62. {frontengine-1.0.30 → frontengine-1.0.32}/frontengine/system_tray/extend_system_tray.py +0 -0
  63. {frontengine-1.0.30/frontengine/ui/dialog → frontengine-1.0.32/frontengine/ui}/__init__.py +0 -0
  64. {frontengine-1.0.30/frontengine/ui/menu → frontengine-1.0.32/frontengine/ui/color}/__init__.py +0 -0
  65. {frontengine-1.0.30 → frontengine-1.0.32}/frontengine/ui/color/global_color.py +0 -0
  66. {frontengine-1.0.30/frontengine/ui/page → frontengine-1.0.32/frontengine/ui/dialog}/__init__.py +0 -0
  67. {frontengine-1.0.30 → frontengine-1.0.32}/frontengine/ui/dialog/choose_file_dialog.py +0 -0
  68. {frontengine-1.0.30 → frontengine-1.0.32}/frontengine/ui/dialog/hotkey_settings_dialog.py +0 -0
  69. {frontengine-1.0.30 → frontengine-1.0.32}/frontengine/ui/dialog/save_file_dialog.py +0 -0
  70. {frontengine-1.0.30/frontengine/ui/page/control_center → frontengine-1.0.32/frontengine/ui/menu}/__init__.py +0 -0
  71. {frontengine-1.0.30 → frontengine-1.0.32}/frontengine/ui/menu/help_menu.py +0 -0
  72. {frontengine-1.0.30 → frontengine-1.0.32}/frontengine/ui/menu/how_to_menu.py +0 -0
  73. {frontengine-1.0.30 → frontengine-1.0.32}/frontengine/ui/menu/language_menu.py +0 -0
  74. {frontengine-1.0.30 → frontengine-1.0.32}/frontengine/ui/menu/preset_menu.py +0 -0
  75. {frontengine-1.0.30 → frontengine-1.0.32}/frontengine/ui/menu/settings_menu.py +0 -0
  76. {frontengine-1.0.30/frontengine/ui/page/gif → frontengine-1.0.32/frontengine/ui/page}/__init__.py +0 -0
  77. {frontengine-1.0.30/frontengine/ui/page/image → frontengine-1.0.32/frontengine/ui/page/control_center}/__init__.py +0 -0
  78. {frontengine-1.0.30 → frontengine-1.0.32}/frontengine/ui/page/control_center/control_center_ui.py +0 -0
  79. {frontengine-1.0.30/frontengine/ui/page/particle → frontengine-1.0.32/frontengine/ui/page/gif}/__init__.py +0 -0
  80. {frontengine-1.0.30 → frontengine-1.0.32}/frontengine/ui/page/gif/gif_setting_ui.py +0 -0
  81. {frontengine-1.0.30/frontengine/ui/page/pet → frontengine-1.0.32/frontengine/ui/page/image}/__init__.py +0 -0
  82. {frontengine-1.0.30 → frontengine-1.0.32}/frontengine/ui/page/image/image_setting_ui.py +0 -0
  83. {frontengine-1.0.30/frontengine/ui/page/scene_setting → frontengine-1.0.32/frontengine/ui/page/particle}/__init__.py +0 -0
  84. {frontengine-1.0.30 → frontengine-1.0.32}/frontengine/ui/page/particle/particle_setting_ui.py +0 -0
  85. {frontengine-1.0.30/frontengine/ui/page/scene_setting/scene_page → frontengine-1.0.32/frontengine/ui/page/pet}/__init__.py +0 -0
  86. {frontengine-1.0.30/frontengine/ui/page/sound_player → frontengine-1.0.32/frontengine/ui/page/presentation}/__init__.py +0 -0
  87. {frontengine-1.0.30/frontengine/ui/page/text → frontengine-1.0.32/frontengine/ui/page/scene_setting}/__init__.py +0 -0
  88. {frontengine-1.0.30 → frontengine-1.0.32}/frontengine/ui/page/scene_setting/scene_manager.py +0 -0
  89. {frontengine-1.0.30/frontengine/ui/page/video → frontengine-1.0.32/frontengine/ui/page/scene_setting/scene_page}/__init__.py +0 -0
  90. {frontengine-1.0.30 → frontengine-1.0.32}/frontengine/ui/page/scene_setting/scene_page/base_scene_page.py +0 -0
  91. {frontengine-1.0.30 → frontengine-1.0.32}/frontengine/ui/page/scene_setting/scene_page/gif.py +0 -0
  92. {frontengine-1.0.30 → frontengine-1.0.32}/frontengine/ui/page/scene_setting/scene_page/image.py +0 -0
  93. {frontengine-1.0.30 → frontengine-1.0.32}/frontengine/ui/page/scene_setting/scene_page/registry.py +0 -0
  94. {frontengine-1.0.30 → frontengine-1.0.32}/frontengine/ui/page/scene_setting/scene_page/sound.py +0 -0
  95. {frontengine-1.0.30 → frontengine-1.0.32}/frontengine/ui/page/scene_setting/scene_page/text.py +0 -0
  96. {frontengine-1.0.30 → frontengine-1.0.32}/frontengine/ui/page/scene_setting/scene_page/video.py +0 -0
  97. {frontengine-1.0.30 → frontengine-1.0.32}/frontengine/ui/page/scene_setting/scene_page/web.py +0 -0
  98. {frontengine-1.0.30 → frontengine-1.0.32}/frontengine/ui/page/scene_setting/scene_setting_ui.py +0 -0
  99. {frontengine-1.0.30/frontengine/ui/page/web → frontengine-1.0.32/frontengine/ui/page/screen_care}/__init__.py +0 -0
  100. {frontengine-1.0.30/frontengine/user_setting → frontengine-1.0.32/frontengine/ui/page/sound_player}/__init__.py +0 -0
  101. {frontengine-1.0.30 → frontengine-1.0.32}/frontengine/ui/page/sound_player/sound_player_setting_ui.py +0 -0
  102. {frontengine-1.0.30/frontengine/utils → frontengine-1.0.32/frontengine/ui/page/text}/__init__.py +0 -0
  103. {frontengine-1.0.30 → frontengine-1.0.32}/frontengine/ui/page/text/text_setting_ui.py +0 -0
  104. {frontengine-1.0.30 → frontengine-1.0.32}/frontengine/ui/page/utils.py +0 -0
  105. {frontengine-1.0.30/frontengine/utils/audio_meter → frontengine-1.0.32/frontengine/ui/page/video}/__init__.py +0 -0
  106. {frontengine-1.0.30 → frontengine-1.0.32}/frontengine/ui/page/video/video_setting_ui.py +0 -0
  107. {frontengine-1.0.30/frontengine/utils/browser → frontengine-1.0.32/frontengine/ui/page/web}/__init__.py +0 -0
  108. {frontengine-1.0.30 → frontengine-1.0.32}/frontengine/ui/page/web/web_setting_ui.py +0 -0
  109. {frontengine-1.0.30/frontengine/utils/critical_exit → frontengine-1.0.32/frontengine/user_setting}/__init__.py +0 -0
  110. {frontengine-1.0.30 → frontengine-1.0.32}/frontengine/user_setting/preset_repository.py +0 -0
  111. {frontengine-1.0.30 → frontengine-1.0.32}/frontengine/user_setting/scene_setting.py +0 -0
  112. {frontengine-1.0.30 → frontengine-1.0.32}/frontengine/user_setting/user_setting_file.py +0 -0
  113. {frontengine-1.0.30/frontengine/utils/file → frontengine-1.0.32/frontengine/utils}/__init__.py +0 -0
  114. {frontengine-1.0.30/frontengine/utils/hotkey → frontengine-1.0.32/frontengine/utils/audio_meter}/__init__.py +0 -0
  115. {frontengine-1.0.30 → frontengine-1.0.32}/frontengine/utils/audio_meter/audio_envelope.py +0 -0
  116. {frontengine-1.0.30 → frontengine-1.0.32}/frontengine/utils/audio_meter/screen_audio.py +0 -0
  117. {frontengine-1.0.30 → frontengine-1.0.32}/frontengine/utils/audio_meter/system_audio_meter.py +0 -0
  118. {frontengine-1.0.30/frontengine/utils/json → frontengine-1.0.32/frontengine/utils/break_reminder}/__init__.py +0 -0
  119. {frontengine-1.0.30/frontengine/utils/logging → frontengine-1.0.32/frontengine/utils/browser}/__init__.py +0 -0
  120. {frontengine-1.0.30 → frontengine-1.0.32}/frontengine/utils/browser/browser.py +0 -0
  121. {frontengine-1.0.30/frontengine/utils/multi_language → frontengine-1.0.32/frontengine/utils/critical_exit}/__init__.py +0 -0
  122. {frontengine-1.0.30 → frontengine-1.0.32}/frontengine/utils/critical_exit/check_key_is_press.py +0 -0
  123. {frontengine-1.0.30 → frontengine-1.0.32}/frontengine/utils/critical_exit/critical_exit.py +0 -0
  124. {frontengine-1.0.30 → frontengine-1.0.32}/frontengine/utils/critical_exit/win32_vk.py +0 -0
  125. {frontengine-1.0.30 → frontengine-1.0.32}/frontengine/utils/exception/__init__.py +0 -0
  126. {frontengine-1.0.30 → frontengine-1.0.32}/frontengine/utils/exception/exception_tags.py +0 -0
  127. {frontengine-1.0.30 → frontengine-1.0.32}/frontengine/utils/exception/exceptions.py +0 -0
  128. {frontengine-1.0.30/frontengine/utils/preset_schedule → frontengine-1.0.32/frontengine/utils/file}/__init__.py +0 -0
  129. {frontengine-1.0.30 → frontengine-1.0.32}/frontengine/utils/file/open/__init__.py +0 -0
  130. {frontengine-1.0.30 → frontengine-1.0.32}/frontengine/utils/file/open/open_file.py +0 -0
  131. {frontengine-1.0.30 → frontengine-1.0.32}/frontengine/utils/file/save/__init__.py +0 -0
  132. {frontengine-1.0.30 → frontengine-1.0.32}/frontengine/utils/file/save/save_file.py +0 -0
  133. {frontengine-1.0.30/frontengine/utils/redirect_manager → frontengine-1.0.32/frontengine/utils/focus_timer}/__init__.py +0 -0
  134. {frontengine-1.0.30/frontengine/utils/smart_pause → frontengine-1.0.32/frontengine/utils/hotkey}/__init__.py +0 -0
  135. {frontengine-1.0.30 → frontengine-1.0.32}/frontengine/utils/hotkey/hotkey_service.py +0 -0
  136. {frontengine-1.0.30/frontengine/utils/system_stats → frontengine-1.0.32/frontengine/utils/input_watch}/__init__.py +0 -0
  137. {frontengine-1.0.30/frontengine/utils/text_source → frontengine-1.0.32/frontengine/utils/json}/__init__.py +0 -0
  138. {frontengine-1.0.30 → frontengine-1.0.32}/frontengine/utils/json/json_file.py +0 -0
  139. {frontengine-1.0.30 → frontengine-1.0.32}/frontengine/utils/json/json_repository.py +0 -0
  140. {frontengine-1.0.30 → frontengine-1.0.32}/frontengine/utils/json_format/__init__.py +0 -0
  141. {frontengine-1.0.30 → frontengine-1.0.32}/frontengine/utils/json_format/json_process.py +0 -0
  142. {frontengine-1.0.30/frontengine/utils/theme_schedule → frontengine-1.0.32/frontengine/utils/logging}/__init__.py +0 -0
  143. {frontengine-1.0.30 → frontengine-1.0.32}/frontengine/utils/logging/loggin_instance.py +0 -0
  144. {frontengine-1.0.30/frontengine/utils/weather → frontengine-1.0.32/frontengine/utils/multi_language}/__init__.py +0 -0
  145. {frontengine-1.0.30 → frontengine-1.0.32}/frontengine/utils/multi_language/france.py +0 -0
  146. {frontengine-1.0.30 → frontengine-1.0.32}/frontengine/utils/multi_language/germany.py +0 -0
  147. {frontengine-1.0.30 → frontengine-1.0.32}/frontengine/utils/multi_language/italy.py +0 -0
  148. {frontengine-1.0.30 → frontengine-1.0.32}/frontengine/utils/multi_language/language_wrapper.py +0 -0
  149. {frontengine-1.0.30 → frontengine-1.0.32}/frontengine/utils/multi_language/russian.py +0 -0
  150. {frontengine-1.0.30 → frontengine-1.0.32}/frontengine/utils/multi_language/simplified_chinese.py +0 -0
  151. {frontengine-1.0.30/frontengine/worker → frontengine-1.0.32/frontengine/utils/pet_chat}/__init__.py +0 -0
  152. {frontengine-1.0.30 → frontengine-1.0.32}/frontengine/utils/preset_schedule/preset_schedule_service.py +0 -0
  153. {frontengine-1.0.30 → frontengine-1.0.32}/frontengine/utils/redirect_manager/redirect_manager_class.py +0 -0
  154. {frontengine-1.0.30 → frontengine-1.0.32}/frontengine/utils/smart_pause/smart_pause_service.py +0 -0
  155. {frontengine-1.0.30 → frontengine-1.0.32}/frontengine/utils/system_stats/system_stats.py +0 -0
  156. {frontengine-1.0.30 → frontengine-1.0.32}/frontengine/utils/text_source/text_source.py +0 -0
  157. {frontengine-1.0.30 → frontengine-1.0.32}/frontengine/utils/theme_schedule/theme_schedule_service.py +0 -0
  158. {frontengine-1.0.30 → frontengine-1.0.32}/frontengine/utils/weather/weather_service.py +0 -0
  159. {frontengine-1.0.30 → frontengine-1.0.32}/frontengine/utils/web_url.py +0 -0
  160. {frontengine-1.0.30 → frontengine-1.0.32}/frontengine/worker/qthread_worker.py +0 -0
  161. {frontengine-1.0.30 → frontengine-1.0.32}/frontengine.egg-info/dependency_links.txt +0 -0
  162. {frontengine-1.0.30 → frontengine-1.0.32}/frontengine.egg-info/entry_points.txt +0 -0
  163. {frontengine-1.0.30 → frontengine-1.0.32}/frontengine.egg-info/requires.txt +0 -0
  164. {frontengine-1.0.30 → frontengine-1.0.32}/frontengine.egg-info/top_level.txt +0 -0
  165. {frontengine-1.0.30 → frontengine-1.0.32}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: frontengine
3
- Version: 1.0.30
3
+ Version: 1.0.32
4
4
  Summary: FrontEngine is use-define frontview or only use like screen saver
5
5
  Author-email: JE-Chen <jechenmailman@gmail.com>
6
6
  Project-URL: Homepage, https://github.com/Intergration-Automation-Testing/FrontEngine
@@ -1,7 +1,9 @@
1
+ import json
1
2
  import random as _random_module
2
3
  import sys
3
4
  from datetime import datetime, timedelta
4
5
  from pathlib import Path
6
+ from urllib.parse import urlparse
5
7
  from typing import Optional, Tuple
6
8
 
7
9
  from PySide6.QtCore import Qt, QRect, QTimer, QUrl, Signal
@@ -83,6 +85,10 @@ def scan_pet_pack(folder: str) -> dict:
83
85
  return result
84
86
 
85
87
 
88
+ # 可以請寵物幫忙開啟的網址協定 / URL schemes the pet will open for you
89
+ _WEB_SCHEMES = frozenset({"http", "https"})
90
+
91
+
86
92
  # 拖進寵物的檔案分類 / What a file dropped onto the pet means
87
93
  DROP_SPRITE = "sprite" # 圖片或動作包 -> 換一套外觀
88
94
  DROP_FOOD = "food" # 其他檔案 -> 當成食物餵給寵物
@@ -142,6 +148,55 @@ def classify_food(path) -> str:
142
148
  return FOOD_SNACK
143
149
 
144
150
 
151
+ # 動作包設定檔:放在動作包資料夾裡,用來描述這隻寵物的個性與參數
152
+ # Pack manifest: sits inside a pet pack folder describing that pet's traits.
153
+ PACK_MANIFEST_NAME = "pet.json"
154
+ _MANIFEST_NUMBERS = ("size", "speed")
155
+ _MANIFEST_FLAGS = ("climb", "talk", "sit_on_windows")
156
+
157
+
158
+ def read_pet_manifest(folder) -> dict:
159
+ """
160
+ 讀取動作包裡的 pet.json(可選)。只取認得的欄位,型別不符就略過,
161
+ 讀不到或格式錯誤一律回傳空 dict,讓動作包永遠不會弄壞寵物。
162
+
163
+ Read a pack's optional pet.json. Only known, well-typed fields are kept and
164
+ anything unreadable yields {}, so a pack can never break the pet.
165
+ """
166
+ manifest: dict = {}
167
+ try:
168
+ path = Path(folder) / PACK_MANIFEST_NAME
169
+ if not path.is_file():
170
+ return manifest
171
+ with path.open("r", encoding="utf-8") as handle:
172
+ raw = json.load(handle)
173
+ except (OSError, ValueError, TypeError):
174
+ return manifest
175
+ if not isinstance(raw, dict):
176
+ return manifest
177
+ for key in _MANIFEST_NUMBERS:
178
+ try:
179
+ if key in raw:
180
+ manifest[key] = max(1, int(raw[key]))
181
+ except (TypeError, ValueError):
182
+ continue
183
+ for key in _MANIFEST_FLAGS:
184
+ if isinstance(raw.get(key), bool):
185
+ manifest[key] = raw[key]
186
+ if isinstance(raw.get("name"), str):
187
+ manifest["name"] = raw["name"]
188
+ if isinstance(raw.get("sound"), str):
189
+ manifest["sound"] = raw["sound"]
190
+ lines = raw.get("lines")
191
+ if isinstance(lines, dict):
192
+ manifest["lines"] = {
193
+ str(pool): [str(line) for line in values if str(line).strip()]
194
+ for pool, values in lines.items()
195
+ if isinstance(values, list) and values
196
+ }
197
+ return manifest
198
+
199
+
145
200
  def derive_visual_state(dragging: bool, airborne: bool, surface: str, motion_state: str) -> str:
146
201
  """依目前情形推導應顯示的視覺狀態 / Derive which sprite state to show."""
147
202
  if dragging:
@@ -1101,9 +1156,21 @@ class DesktopPetWidget(BaseWidget):
1101
1156
  self._hunger = PetHunger(user_setting_dict.get("pet_hunger", 70))
1102
1157
  self._hunger_warned = False
1103
1158
  self._messages = self._load_messages()
1159
+ # 動作包自帶的設定(可含台詞、速度、體型、音效)
1160
+ # Traits shipped inside the pack (lines, speed, size, sound).
1161
+ self.manifest = read_pet_manifest(self.image_path) if self.image_path.is_dir() else {}
1162
+ self._apply_manifest()
1104
1163
  self._volume = max(0.0, min(1.0, float(volume)))
1105
1164
  self._sound: Optional[QSoundEffect] = None
1106
- self._init_sound(sound_path)
1165
+ self._init_sound(self.manifest.get("sound_path", sound_path))
1166
+ # 語音朗讀(可選,缺少 QtTextToSpeech 時自動停用)
1167
+ # Optional spoken lines; disabled automatically without QtTextToSpeech.
1168
+ self._speech = None
1169
+ self._speak = False
1170
+ # AI 對話(可選,需環境變數金鑰;預設關閉)
1171
+ # Optional AI chat; needs an env-var key and is off by default.
1172
+ self._chat_service = None
1173
+ self._pending_chat_reply: Optional[str] = None
1107
1174
  self._sit_on_windows = bool(sit_on_windows)
1108
1175
  self._platform_tick = 0
1109
1176
  # 音訊視覺化:依可注入的音量 provider 脈動 / Audio-reactive pulse
@@ -1152,6 +1219,11 @@ class DesktopPetWidget(BaseWidget):
1152
1219
  language_wrapper.language_word_dict.get("pet_set_reminder", "Set reminder..."), self)
1153
1220
  self.reminder_action.triggered.connect(self._prompt_reminder)
1154
1221
  self.menu.addAction(self.reminder_action)
1222
+ self.chat_action = QAction(
1223
+ language_wrapper.language_word_dict.get("pet_chat_menu", "Talk to me..."), self)
1224
+ self.chat_action.triggered.connect(self._prompt_chat)
1225
+ self.chat_action.setVisible(False) # shown only when a chat service is attached
1226
+ self.menu.addAction(self.chat_action)
1155
1227
  self.close_action = QAction(language_wrapper.language_word_dict.get("control_center_close_all", "Close"), self)
1156
1228
  self.close_action.triggered.connect(self.close)
1157
1229
  self.menu.addAction(self.close_action)
@@ -1358,6 +1430,58 @@ class DesktopPetWidget(BaseWidget):
1358
1430
  if self._timeline_start is None:
1359
1431
  self._timeline_start = self._now_provider()
1360
1432
 
1433
+ def _apply_manifest(self) -> None:
1434
+ """套用動作包設定:台詞併入句庫,速度/體型/音效覆寫預設值。"""
1435
+ if not self.manifest:
1436
+ return
1437
+ front_engine_logger.info(f"[DesktopPetWidget] pack manifest | {self.manifest}")
1438
+ for pool, lines in (self.manifest.get("lines") or {}).items():
1439
+ self._messages[pool] = list(lines)
1440
+ if "speed" in self.manifest:
1441
+ self.motion.speed = max(1, int(self.manifest["speed"]))
1442
+ if "size" in self.manifest:
1443
+ self._base_size = max(16, int(self.manifest["size"]))
1444
+ self._apply_growth_size()
1445
+ if "climb" in self.manifest:
1446
+ self.motion.climb = bool(self.manifest["climb"])
1447
+ if "talk" in self.manifest:
1448
+ self._talk = bool(self.manifest["talk"])
1449
+ sound = self.manifest.get("sound")
1450
+ if sound:
1451
+ candidate = self.image_path / sound
1452
+ if candidate.is_file():
1453
+ self.manifest["sound_path"] = str(candidate)
1454
+
1455
+ def set_speech_enabled(self, enabled: bool) -> bool:
1456
+ """
1457
+ 開關語音朗讀;系統缺少 QtTextToSpeech 時回傳 False 並維持關閉。
1458
+ Toggle spoken lines, returning False when QtTextToSpeech is unavailable.
1459
+ """
1460
+ if not enabled:
1461
+ self._speak = False
1462
+ return False
1463
+ if self._speech is None:
1464
+ try:
1465
+ from PySide6.QtTextToSpeech import QTextToSpeech
1466
+
1467
+ self._speech = QTextToSpeech(self)
1468
+ except Exception as error: # pragma: no cover - optional Qt module
1469
+ front_engine_logger.warning(f"[DesktopPetWidget] speech unavailable: {error!r}")
1470
+ self._speech = None
1471
+ self._speak = False
1472
+ return False
1473
+ self._speak = True
1474
+ return True
1475
+
1476
+ def _speak_text(self, text: str) -> None:
1477
+ """把台詞念出來(未啟用或失敗時安靜略過)。"""
1478
+ if not self._speak or self._speech is None or not text:
1479
+ return
1480
+ try:
1481
+ self._speech.say(text)
1482
+ except Exception as error: # pragma: no cover - speech backend guard
1483
+ front_engine_logger.warning(f"[DesktopPetWidget] speech failed: {error!r}")
1484
+
1361
1485
  def _load_messages(self) -> dict:
1362
1486
  d = language_wrapper.language_word_dict
1363
1487
 
@@ -1382,6 +1506,11 @@ class DesktopPetWidget(BaseWidget):
1382
1506
  "away": lines("pet_chatter_away", "Still there?|I'll nap till you're back~|Zzz..."),
1383
1507
  "levelup": lines("pet_chatter_levelup", "Level up!|I'm growing!|We're getting closer!"),
1384
1508
  "costume": lines("pet_chatter_costume", "New look!|How do I look?|Nice fit!"),
1509
+ "fetch": lines("pet_chatter_fetch", "Fetch!|On it!|Opening that for you!"),
1510
+ "focus_start": lines("pet_chatter_focus_start", "Focus time!|Let's concentrate!|I'll wait here."),
1511
+ "focus_break": lines("pet_chatter_focus_break", "Break time!|Stretch a little!|Rest your eyes."),
1512
+ "focus_back": lines("pet_chatter_focus_back", "Back to it!|Round two!|Let's keep going."),
1513
+ "focus_done": lines("pet_chatter_focus_done", "Long break earned!|Great run!|Take a proper rest."),
1385
1514
  "tag": lines("pet_chatter_tag", "You're it!|Caught you!|My turn to chase!"),
1386
1515
  "any": lines("pet_chatter_any", "Hi there!|(^_^)|Keep going!"),
1387
1516
  }
@@ -1489,6 +1618,53 @@ class DesktopPetWidget(BaseWidget):
1489
1618
  return
1490
1619
  self.add_reminder(text.strip(), minutes)
1491
1620
 
1621
+ # --- optional AI chat --------------------------------------------------
1622
+ def set_chat_service(self, service) -> None:
1623
+ """
1624
+ 接上 AI 對話服務(None 表示關閉);服務不可用時右鍵選單不會出現該項目。
1625
+ Attach the AI chat service (None disables it); the menu entry only
1626
+ appears when the service is actually usable.
1627
+ """
1628
+ self._chat_service = service
1629
+ usable = service is not None and bool(getattr(service, "available", lambda: False)())
1630
+ self.chat_action.setVisible(usable)
1631
+
1632
+ def _prompt_chat(self) -> None:
1633
+ """問使用者要說什麼,然後在背景等回覆(不卡 UI)。"""
1634
+ from PySide6.QtWidgets import QInputDialog
1635
+
1636
+ if self._chat_service is None:
1637
+ return
1638
+ title = language_wrapper.language_word_dict.get("pet_chat_menu", "Talk to me...")
1639
+ text, ok = QInputDialog.getText(
1640
+ self, title, language_wrapper.language_word_dict.get("pet_chat_prompt", "Say something:"))
1641
+ if not ok or not text.strip():
1642
+ return
1643
+ self.ask_chat(text.strip())
1644
+
1645
+ def ask_chat(self, message: str) -> None:
1646
+ """送出一句話並在收到回覆時說出來;失敗則說一句抱歉。"""
1647
+ if self._chat_service is None:
1648
+ return
1649
+ self.say(language_wrapper.language_word_dict.get("pet_chat_thinking", "Hmm, let me think..."))
1650
+ self._chat_service.ask_async(message, self.handle_chat_reply)
1651
+
1652
+ def handle_chat_reply(self, reply: Optional[str]) -> None:
1653
+ """處理回覆(可能來自背景執行緒);空回覆時說一句抱歉。"""
1654
+ if reply:
1655
+ self._pending_chat_reply = reply
1656
+ else:
1657
+ self._pending_chat_reply = language_wrapper.language_word_dict.get(
1658
+ "pet_chat_failed", "I couldn't reach my thoughts just now.")
1659
+ # 回覆可能在背景執行緒抵達,透過計時器回到 UI 執行緒再顯示
1660
+ # The reply may arrive off-thread; hop back to the UI thread to show it.
1661
+ QTimer.singleShot(0, self._say_pending_chat_reply)
1662
+
1663
+ def _say_pending_chat_reply(self) -> None:
1664
+ if self._pending_chat_reply:
1665
+ self.say(self._pending_chat_reply)
1666
+ self._pending_chat_reply = None
1667
+
1492
1668
  def _init_sound(self, sound_path: Optional[str]) -> None:
1493
1669
  if not sound_path:
1494
1670
  return
@@ -1612,6 +1788,7 @@ class DesktopPetWidget(BaseWidget):
1612
1788
  text = pick_message(datetime.now().hour, self._chatter_rng, self._messages, mood=mood)
1613
1789
  if text:
1614
1790
  self._bubble.show_message(text, self)
1791
+ self._speak_text(text)
1615
1792
 
1616
1793
  def _on_tick(self) -> None:
1617
1794
  if self._dragging:
@@ -1688,26 +1865,72 @@ class DesktopPetWidget(BaseWidget):
1688
1865
  return local_path
1689
1866
  return None
1690
1867
 
1868
+ @staticmethod
1869
+ def dropped_link(mime_data) -> Optional[str]:
1870
+ """
1871
+ 從拖曳資料取出網址(拖連結到寵物身上請牠幫忙開啟);沒有則回傳 None。
1872
+ Pull an http(s) link out of a drop so the pet can open it for you.
1873
+ """
1874
+ if mime_data is None:
1875
+ return None
1876
+ candidates = []
1877
+ if mime_data.hasUrls():
1878
+ candidates += [url.toString() for url in mime_data.urls() if not url.isLocalFile()]
1879
+ if mime_data.hasText():
1880
+ candidates.append(mime_data.text())
1881
+ for candidate in candidates:
1882
+ text = str(candidate or "").strip()
1883
+ # 用網址解析判斷通訊協定,比字串開頭比對穩,也不必在程式碼裡寫死協定前綴。
1884
+ # Parse the URL instead of prefix-matching: sturdier, and no scheme
1885
+ # literals baked into the source.
1886
+ try:
1887
+ scheme = urlparse(text).scheme.lower()
1888
+ except ValueError:
1889
+ continue
1890
+ if scheme in _WEB_SCHEMES:
1891
+ return text
1892
+ return None
1893
+
1894
+ def _accepts_drop(self, mime_data) -> bool:
1895
+ return self.dropped_path(mime_data) is not None or self.dropped_link(mime_data) is not None
1896
+
1691
1897
  def dragEnterEvent(self, event) -> None:
1692
- if self.dropped_path(event.mimeData()) is not None:
1898
+ if self._accepts_drop(event.mimeData()):
1693
1899
  event.acceptProposedAction()
1694
1900
 
1695
1901
  def dragMoveEvent(self, event) -> None:
1696
- if self.dropped_path(event.mimeData()) is not None:
1902
+ if self._accepts_drop(event.mimeData()):
1697
1903
  event.acceptProposedAction()
1698
1904
 
1699
1905
  def dropEvent(self, event) -> None:
1700
1906
  path = self.dropped_path(event.mimeData())
1701
- if path is None:
1907
+ link = self.dropped_link(event.mimeData()) if path is None else None
1908
+ if path is None and link is None:
1702
1909
  return
1703
- front_engine_logger.info(f"[DesktopPetWidget] dropEvent | path={path}")
1910
+ front_engine_logger.info(f"[DesktopPetWidget] dropEvent | path={path}, link={link}")
1704
1911
  self.motion.wake()
1705
- if classify_drop(path) == DROP_SPRITE:
1912
+ if link is not None:
1913
+ self.fetch_link(link)
1914
+ elif classify_drop(path) == DROP_SPRITE:
1706
1915
  self.change_sprite(path)
1707
1916
  else:
1708
1917
  self.feed(classify_food(path))
1709
1918
  event.acceptProposedAction()
1710
1919
 
1920
+ def fetch_link(self, url: str) -> None:
1921
+ """把拖進來的連結叼去瀏覽器開啟,並說一句 / Fetch a dropped link in the browser."""
1922
+ from frontengine.utils.browser.browser import open_browser
1923
+
1924
+ front_engine_logger.info(f"[DesktopPetWidget] fetch_link | url={url}")
1925
+ self._gain_affection(1)
1926
+ pool = self._messages.get("fetch") or []
1927
+ if pool:
1928
+ self.say(pool[self._chatter_rng.randrange(len(pool))])
1929
+ try:
1930
+ open_browser(url)
1931
+ except Exception as error: # pragma: no cover - browser boundary
1932
+ front_engine_logger.warning(f"[DesktopPetWidget] open_browser failed: {error!r}")
1933
+
1711
1934
  def contextMenuEvent(self, event) -> None:
1712
1935
  self.menu.popup(event.globalPos())
1713
1936
 
@@ -0,0 +1,153 @@
1
+ """
2
+ 螢幕塗鴉層:在畫面上用筆或螢光筆畫線、清除、復原。畫圖時需要接收滑鼠,
3
+ 所以它不是點擊穿透的;關閉塗鴉即恢復原本的操作。
4
+
5
+ A screen annotation layer: draw over the screen with a pen or highlighter,
6
+ undo, and clear. It has to take the mouse while you draw, so unlike the other
7
+ overlays it is not click-through — turn it off and the screen is yours again.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from typing import List, Optional, Tuple
12
+
13
+ from PySide6.QtCore import QPoint, Qt
14
+ from PySide6.QtGui import QColor, QPainter, QPen
15
+
16
+ from frontengine.show.base_widget import BaseWidget
17
+ from frontengine.show.window_helpers import apply_overlay_window_flags
18
+ from frontengine.utils.logging.loggin_instance import front_engine_logger
19
+
20
+ TOOL_PEN = "pen"
21
+ TOOL_HIGHLIGHTER = "highlighter"
22
+ TOOL_ERASER = "eraser"
23
+ TOOLS = (TOOL_PEN, TOOL_HIGHLIGHTER, TOOL_ERASER)
24
+
25
+ PEN_COLORS = (
26
+ ("Red", "#ff3b30"), ("Yellow", "#ffcc00"), ("Green", "#34c759"),
27
+ ("Blue", "#0a84ff"), ("Black", "#000000"), ("White", "#ffffff"),
28
+ )
29
+ DEFAULT_PEN_COLOR = "#ff3b30"
30
+ MIN_WIDTH = 1
31
+ MAX_WIDTH = 40
32
+
33
+
34
+ def clamp_width(value, fallback: int = 4) -> int:
35
+ """把筆寬夾在可用範圍。"""
36
+ try:
37
+ return max(MIN_WIDTH, min(MAX_WIDTH, int(value)))
38
+ except (TypeError, ValueError):
39
+ return fallback
40
+
41
+
42
+ def stroke_style(tool: str, color: str, width: int) -> Tuple[QColor, int]:
43
+ """
44
+ 依工具決定實際的顏色與線寬:螢光筆是半透明的粗線,橡皮擦畫得更粗。
45
+ Per-tool colour and width: a highlighter is a wide translucent stroke, an
46
+ eraser is wider still.
47
+ """
48
+ pen_color = QColor(color)
49
+ if not pen_color.isValid():
50
+ pen_color = QColor(DEFAULT_PEN_COLOR)
51
+ stroke_width = clamp_width(width)
52
+ if tool == TOOL_HIGHLIGHTER:
53
+ pen_color.setAlpha(90)
54
+ stroke_width = clamp_width(stroke_width * 4)
55
+ elif tool == TOOL_ERASER:
56
+ stroke_width = clamp_width(stroke_width * 5)
57
+ return (pen_color, stroke_width)
58
+
59
+
60
+ class AnnotationOverlay(BaseWidget):
61
+ """
62
+ 塗鴉層:滑鼠拖曳畫線,支援筆/螢光筆/橡皮擦、復原與全部清除。
63
+ 每一筆記成 (工具, 顏色, 線寬, 點清單),因此復原與重繪都很單純。
64
+ """
65
+
66
+ def __init__(self, color: str = DEFAULT_PEN_COLOR, width: int = 4, tool: str = TOOL_PEN) -> None:
67
+ front_engine_logger.info(f"[AnnotationOverlay] Init | tool={tool}, color={color}, width={width}")
68
+ super().__init__()
69
+ self.opacity = 1.0
70
+ self.tool = tool if tool in TOOLS else TOOL_PEN
71
+ self.pen_color = color
72
+ self.pen_width = clamp_width(width)
73
+ self.strokes: List[dict] = []
74
+ self._current: Optional[dict] = None
75
+ self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
76
+
77
+ def set_annotation_window_flag(self) -> None:
78
+ """塗鴉需要接收滑鼠,所以不使用點擊穿透。"""
79
+ apply_overlay_window_flags(self, show_on_bottom=False, allow_input=True)
80
+
81
+ def set_tool(self, tool: str) -> None:
82
+ if tool in TOOLS:
83
+ self.tool = tool
84
+
85
+ def set_pen(self, color: Optional[str] = None, width: Optional[int] = None) -> None:
86
+ if color is not None:
87
+ self.pen_color = color
88
+ if width is not None:
89
+ self.pen_width = clamp_width(width)
90
+
91
+ def clear(self) -> None:
92
+ """清掉所有筆畫 / Wipe every stroke."""
93
+ self.strokes = []
94
+ self._current = None
95
+ self.update()
96
+
97
+ def undo(self) -> bool:
98
+ """收回最後一筆;沒有筆畫可收時回傳 False。"""
99
+ if not self.strokes:
100
+ return False
101
+ self.strokes.pop()
102
+ self.update()
103
+ return True
104
+
105
+ # --- drawing ---------------------------------------------------------
106
+ def begin_stroke(self, point: QPoint) -> None:
107
+ self._current = {
108
+ "tool": self.tool, "color": self.pen_color, "width": self.pen_width, "points": [point],
109
+ }
110
+ self.strokes.append(self._current)
111
+
112
+ def extend_stroke(self, point: QPoint) -> None:
113
+ if self._current is not None:
114
+ self._current["points"].append(point)
115
+ self.update()
116
+
117
+ def end_stroke(self) -> None:
118
+ # 只有一個點的筆畫畫不出線,直接丟掉
119
+ # A single-point stroke draws nothing; drop it.
120
+ if self._current is not None and len(self._current["points"]) < 2:
121
+ self.strokes.remove(self._current)
122
+ self.update()
123
+ self._current = None
124
+
125
+ def mousePressEvent(self, event) -> None:
126
+ if event.button() == Qt.MouseButton.LeftButton:
127
+ self.begin_stroke(event.position().toPoint())
128
+ super().mousePressEvent(event)
129
+
130
+ def mouseMoveEvent(self, event) -> None:
131
+ self.extend_stroke(event.position().toPoint())
132
+ super().mouseMoveEvent(event)
133
+
134
+ def mouseReleaseEvent(self, event) -> None:
135
+ self.end_stroke()
136
+ super().mouseReleaseEvent(event)
137
+
138
+ def draw_content(self, painter: QPainter) -> None:
139
+ painter.setRenderHint(QPainter.RenderHint.Antialiasing)
140
+ for stroke in self.strokes:
141
+ points = stroke["points"]
142
+ if len(points) < 2:
143
+ continue
144
+ color, width = stroke_style(stroke["tool"], stroke["color"], stroke["width"])
145
+ if stroke["tool"] == TOOL_ERASER:
146
+ painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_Clear)
147
+ else:
148
+ painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_SourceOver)
149
+ painter.setPen(QPen(color, width, Qt.PenStyle.SolidLine,
150
+ Qt.PenCapStyle.RoundCap, Qt.PenJoinStyle.RoundJoin))
151
+ for start, end in zip(points, points[1:]):
152
+ painter.drawLine(start, end)
153
+ painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_SourceOver)
@@ -0,0 +1,166 @@
1
+ """
2
+ 游標強調層:在游標周圍畫一圈亮環、點擊時擴散漣漪,或把游標以外的區域壓暗
3
+ (聚光燈)。教學、簡報與錄影時讓觀眾看得見滑鼠在哪。點擊穿透,不影響操作。
4
+
5
+ Cursor emphasis: a ring around the pointer, a ripple when you click, or a
6
+ spotlight that dims everything except a circle around the cursor — so an
7
+ audience can follow the mouse during a demo. Click-through throughout.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from typing import List, Optional
12
+
13
+ from PySide6.QtCore import QPoint, QTimer, Qt
14
+ from PySide6.QtGui import QColor, QCursor, QPainter, QRadialGradient
15
+
16
+ from frontengine.show.base_widget import BaseWidget
17
+ from frontengine.utils.logging.loggin_instance import front_engine_logger
18
+
19
+ DEFAULT_RING_COLOR = "#ffcc00"
20
+ DEFAULT_RING_RADIUS = 28
21
+ DEFAULT_SPOTLIGHT_RADIUS = 160
22
+ RIPPLE_STEPS = 12
23
+ _MIN_RADIUS = 8
24
+ _MAX_RADIUS = 600
25
+
26
+
27
+ def clamp_radius(value, fallback: int = DEFAULT_RING_RADIUS) -> int:
28
+ """把半徑夾在看得見又不至於蓋滿螢幕的範圍。"""
29
+ try:
30
+ return max(_MIN_RADIUS, min(_MAX_RADIUS, int(value)))
31
+ except (TypeError, ValueError):
32
+ return fallback
33
+
34
+
35
+ def ripple_radius(step: int, base_radius: int, steps: int = RIPPLE_STEPS) -> int:
36
+ """漣漪第 step 幀的半徑:從基準半徑往外擴散一倍。"""
37
+ total = max(1, int(steps))
38
+ progress = min(max(int(step), 0), total) / total
39
+ return int(clamp_radius(base_radius) * (1.0 + progress))
40
+
41
+
42
+ def ripple_alpha(step: int, steps: int = RIPPLE_STEPS) -> int:
43
+ """漣漪第 step 幀的透明度:越擴散越淡,最後消失。"""
44
+ total = max(1, int(steps))
45
+ progress = min(max(int(step), 0), total) / total
46
+ return max(0, int(200 * (1.0 - progress)))
47
+
48
+
49
+ class CursorEffectWidget(BaseWidget):
50
+ """
51
+ 游標效果層:亮環、點擊漣漪、聚光燈可各自開關,全部跟著游標移動。
52
+ 位置與點擊事件由外部注入(正式路徑接系統游標與全域滑鼠監聽),方便測試。
53
+ """
54
+
55
+ REFRESH_MS = 16
56
+
57
+ def __init__(self, ring: bool = True, ripple: bool = True, spotlight: bool = False,
58
+ color: str = DEFAULT_RING_COLOR, radius: int = DEFAULT_RING_RADIUS,
59
+ spotlight_radius: int = DEFAULT_SPOTLIGHT_RADIUS, dim: int = 55) -> None:
60
+ front_engine_logger.info(
61
+ f"[CursorEffectWidget] Init | ring={ring}, ripple={ripple}, spotlight={spotlight}")
62
+ super().__init__()
63
+ self.opacity = 1.0
64
+ self.show_ring = bool(ring)
65
+ self.show_ripple = bool(ripple)
66
+ self.show_spotlight = bool(spotlight)
67
+ self.color = QColor(color) if QColor(color).isValid() else QColor(DEFAULT_RING_COLOR)
68
+ self.radius = clamp_radius(radius)
69
+ self.spotlight_radius = clamp_radius(spotlight_radius, DEFAULT_SPOTLIGHT_RADIUS)
70
+ self.dim = max(0, min(90, int(dim)))
71
+ self.cursor_point = QPoint(0, 0)
72
+ self.ripples: List[dict] = []
73
+ self._cursor_provider = QCursor.pos
74
+ self._timer = QTimer(self)
75
+ self._timer.timeout.connect(self.tick)
76
+
77
+ def set_cursor_provider(self, provider) -> None:
78
+ """注入游標位置來源(測試用)。"""
79
+ self._cursor_provider = provider
80
+
81
+ def set_effects(self, ring: Optional[bool] = None, ripple: Optional[bool] = None,
82
+ spotlight: Optional[bool] = None) -> None:
83
+ """開關個別效果。"""
84
+ if ring is not None:
85
+ self.show_ring = bool(ring)
86
+ if ripple is not None:
87
+ self.show_ripple = bool(ripple)
88
+ if spotlight is not None:
89
+ self.show_spotlight = bool(spotlight)
90
+ self.update()
91
+
92
+ def set_style(self, color: Optional[str] = None, radius: Optional[int] = None,
93
+ spotlight_radius: Optional[int] = None, dim: Optional[int] = None) -> None:
94
+ """調整顏色、半徑與壓暗程度。"""
95
+ if color is not None:
96
+ candidate = QColor(color)
97
+ if candidate.isValid():
98
+ self.color = candidate
99
+ if radius is not None:
100
+ self.radius = clamp_radius(radius)
101
+ if spotlight_radius is not None:
102
+ self.spotlight_radius = clamp_radius(spotlight_radius, DEFAULT_SPOTLIGHT_RADIUS)
103
+ if dim is not None:
104
+ self.dim = max(0, min(90, int(dim)))
105
+ self.update()
106
+
107
+ def start_following(self, interval_ms: int = REFRESH_MS) -> None:
108
+ """開始跟隨游標。"""
109
+ self.tick()
110
+ self._timer.start(max(8, int(interval_ms)))
111
+
112
+ def add_ripple(self, point: Optional[QPoint] = None) -> None:
113
+ """在指定位置(預設為目前游標)加一圈點擊漣漪。"""
114
+ self.ripples.append({"point": QPoint(point or self.cursor_point), "step": 0})
115
+ self.update()
116
+
117
+ def tick(self) -> None:
118
+ """更新游標位置並推進漣漪動畫。"""
119
+ try:
120
+ position = self._cursor_provider()
121
+ except Exception: # pragma: no cover - defensive around injected providers
122
+ return
123
+ self.cursor_point = QPoint(int(position.x()) - self.x(), int(position.y()) - self.y())
124
+ for ripple in list(self.ripples):
125
+ ripple["step"] += 1
126
+ if ripple["step"] > RIPPLE_STEPS:
127
+ self.ripples.remove(ripple)
128
+ self.update()
129
+
130
+ def draw_content(self, painter: QPainter) -> None:
131
+ painter.setRenderHint(QPainter.RenderHint.Antialiasing)
132
+ if self.show_spotlight:
133
+ self._draw_spotlight(painter)
134
+ if self.show_ring:
135
+ ring_color = QColor(self.color)
136
+ ring_color.setAlpha(120)
137
+ painter.setBrush(ring_color)
138
+ painter.setPen(Qt.PenStyle.NoPen)
139
+ painter.drawEllipse(self.cursor_point, self.radius, self.radius)
140
+ if self.show_ripple:
141
+ for ripple in self.ripples:
142
+ color = QColor(self.color)
143
+ color.setAlpha(ripple_alpha(ripple["step"]))
144
+ painter.setBrush(Qt.BrushStyle.NoBrush)
145
+ painter.setPen(color)
146
+ radius = ripple_radius(ripple["step"], self.radius)
147
+ painter.drawEllipse(ripple["point"], radius, radius)
148
+
149
+ def _draw_spotlight(self, painter: QPainter) -> None:
150
+ """把游標以外壓暗,中心保持透明,邊緣柔和過渡。"""
151
+ shade = QColor(0, 0, 0, int(255 * self.dim / 100.0))
152
+ gradient = QRadialGradient(self.cursor_point, self.spotlight_radius)
153
+ gradient.setColorAt(0.0, QColor(0, 0, 0, 0))
154
+ gradient.setColorAt(0.75, QColor(0, 0, 0, 0))
155
+ gradient.setColorAt(1.0, shade)
156
+ painter.fillRect(self.rect(), shade)
157
+ painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_Source)
158
+ painter.setBrush(gradient)
159
+ painter.setPen(Qt.PenStyle.NoPen)
160
+ painter.drawEllipse(self.cursor_point, self.spotlight_radius, self.spotlight_radius)
161
+ painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_SourceOver)
162
+
163
+ def closeEvent(self, event) -> None:
164
+ if self._timer.isActive():
165
+ self._timer.stop()
166
+ super().closeEvent(event)