frontengine 1.0.31__tar.gz → 1.0.33__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 (171) hide show
  1. {frontengine-1.0.31 → frontengine-1.0.33}/PKG-INFO +1 -1
  2. {frontengine-1.0.31 → frontengine-1.0.33}/frontengine/show/pet/desktop_pet.py +24 -93
  3. frontengine-1.0.33/frontengine/show/presentation/annotation_overlay.py +153 -0
  4. frontengine-1.0.33/frontengine/show/presentation/cursor_effects.py +166 -0
  5. frontengine-1.0.33/frontengine/show/presentation/keystroke_display.py +161 -0
  6. frontengine-1.0.33/frontengine/show/presentation/magnifier.py +127 -0
  7. frontengine-1.0.33/frontengine/show/screen_care/screen_filter.py +147 -0
  8. {frontengine-1.0.31 → frontengine-1.0.33}/frontengine/show/text/draw_text.py +12 -1
  9. {frontengine-1.0.31 → frontengine-1.0.33}/frontengine/ui/main_ui.py +16 -3
  10. {frontengine-1.0.31 → frontengine-1.0.33}/frontengine/ui/menu/preset_menu.py +21 -0
  11. {frontengine-1.0.31 → frontengine-1.0.33}/frontengine/ui/menu/settings_menu.py +37 -0
  12. {frontengine-1.0.31 → frontengine-1.0.33}/frontengine/ui/page/control_center/control_center_ui.py +27 -2
  13. frontengine-1.0.33/frontengine/ui/page/presentation/presentation_setting_ui.py +365 -0
  14. frontengine-1.0.33/frontengine/ui/page/screen_care/screen_care_setting_ui.py +295 -0
  15. {frontengine-1.0.31 → frontengine-1.0.33}/frontengine/user_setting/user_setting_file.py +3 -0
  16. frontengine-1.0.33/frontengine/utils/autostart/autostart_service.py +172 -0
  17. frontengine-1.0.33/frontengine/utils/break_reminder/break_reminder.py +104 -0
  18. frontengine-1.0.33/frontengine/utils/input_watch/input_watch_service.py +130 -0
  19. {frontengine-1.0.31 → frontengine-1.0.33}/frontengine/utils/multi_language/english.py +46 -0
  20. {frontengine-1.0.31 → frontengine-1.0.33}/frontengine/utils/multi_language/traditional_chinese.py +46 -0
  21. frontengine-1.0.33/frontengine/utils/platform_info/platform_info.py +303 -0
  22. frontengine-1.0.33/frontengine/utils/power_mode/__init__.py +0 -0
  23. frontengine-1.0.33/frontengine/utils/power_mode/power_mode.py +29 -0
  24. frontengine-1.0.33/frontengine/utils/preset_schedule/__init__.py +0 -0
  25. frontengine-1.0.33/frontengine/utils/redirect_manager/__init__.py +0 -0
  26. frontengine-1.0.33/frontengine/utils/smart_pause/__init__.py +0 -0
  27. frontengine-1.0.33/frontengine/utils/system_stats/__init__.py +0 -0
  28. frontengine-1.0.33/frontengine/utils/text_source/__init__.py +0 -0
  29. frontengine-1.0.33/frontengine/utils/theme_schedule/__init__.py +0 -0
  30. frontengine-1.0.33/frontengine/utils/weather/__init__.py +0 -0
  31. frontengine-1.0.33/frontengine/worker/__init__.py +0 -0
  32. {frontengine-1.0.31 → frontengine-1.0.33}/frontengine.egg-info/PKG-INFO +1 -1
  33. {frontengine-1.0.31 → frontengine-1.0.33}/frontengine.egg-info/SOURCES.txt +21 -0
  34. {frontengine-1.0.31 → frontengine-1.0.33}/pyproject.toml +1 -1
  35. {frontengine-1.0.31 → frontengine-1.0.33}/LICENSE +0 -0
  36. {frontengine-1.0.31 → frontengine-1.0.33}/README.md +0 -0
  37. {frontengine-1.0.31 → frontengine-1.0.33}/frontengine/__init__.py +0 -0
  38. {frontengine-1.0.31 → frontengine-1.0.33}/frontengine/__main__.py +0 -0
  39. {frontengine-1.0.31 → frontengine-1.0.33}/frontengine/show/__init__.py +0 -0
  40. {frontengine-1.0.31 → frontengine-1.0.33}/frontengine/show/base_widget.py +0 -0
  41. {frontengine-1.0.31 → frontengine-1.0.33}/frontengine/show/gif/__init__.py +0 -0
  42. {frontengine-1.0.31 → frontengine-1.0.33}/frontengine/show/gif/paint_gif.py +0 -0
  43. {frontengine-1.0.31 → frontengine-1.0.33}/frontengine/show/image/__init__.py +0 -0
  44. {frontengine-1.0.31 → frontengine-1.0.33}/frontengine/show/image/paint_image.py +0 -0
  45. {frontengine-1.0.31 → frontengine-1.0.33}/frontengine/show/load/__init__.py +0 -0
  46. {frontengine-1.0.31 → frontengine-1.0.33}/frontengine/show/load/load_someone_make_ui.py +0 -0
  47. {frontengine-1.0.31 → frontengine-1.0.33}/frontengine/show/overlay_factory.py +0 -0
  48. {frontengine-1.0.31 → frontengine-1.0.33}/frontengine/show/particle/__init__.py +0 -0
  49. {frontengine-1.0.31 → frontengine-1.0.33}/frontengine/show/particle/particle_ui.py +0 -0
  50. {frontengine-1.0.31 → frontengine-1.0.33}/frontengine/show/pet/__init__.py +0 -0
  51. {frontengine-1.0.31/frontengine/show/scene → frontengine-1.0.33/frontengine/show/presentation}/__init__.py +0 -0
  52. {frontengine-1.0.31/frontengine/show/sound_player → frontengine-1.0.33/frontengine/show/scene}/__init__.py +0 -0
  53. {frontengine-1.0.31 → frontengine-1.0.33}/frontengine/show/scene/extend_graphic_scene.py +0 -0
  54. {frontengine-1.0.31 → frontengine-1.0.33}/frontengine/show/scene/extend_graphic_view.py +0 -0
  55. {frontengine-1.0.31 → frontengine-1.0.33}/frontengine/show/scene/scene.py +0 -0
  56. {frontengine-1.0.31/frontengine/show/text → frontengine-1.0.33/frontengine/show/screen_care}/__init__.py +0 -0
  57. {frontengine-1.0.31/frontengine/show/video → frontengine-1.0.33/frontengine/show/sound_player}/__init__.py +0 -0
  58. {frontengine-1.0.31 → frontengine-1.0.33}/frontengine/show/sound_player/sound_effect.py +0 -0
  59. {frontengine-1.0.31 → frontengine-1.0.33}/frontengine/show/sound_player/sound_player.py +0 -0
  60. {frontengine-1.0.31/frontengine/show/web → frontengine-1.0.33/frontengine/show/text}/__init__.py +0 -0
  61. {frontengine-1.0.31/frontengine/system_tray → frontengine-1.0.33/frontengine/show/video}/__init__.py +0 -0
  62. {frontengine-1.0.31 → frontengine-1.0.33}/frontengine/show/video/video_player.py +0 -0
  63. {frontengine-1.0.31/frontengine/ui → frontengine-1.0.33/frontengine/show/web}/__init__.py +0 -0
  64. {frontengine-1.0.31 → frontengine-1.0.33}/frontengine/show/web/webview.py +0 -0
  65. {frontengine-1.0.31 → frontengine-1.0.33}/frontengine/show/window_helpers.py +0 -0
  66. {frontengine-1.0.31/frontengine/ui/color → frontengine-1.0.33/frontengine/system_tray}/__init__.py +0 -0
  67. {frontengine-1.0.31 → frontengine-1.0.33}/frontengine/system_tray/extend_system_tray.py +0 -0
  68. {frontengine-1.0.31/frontengine/ui/dialog → frontengine-1.0.33/frontengine/ui}/__init__.py +0 -0
  69. {frontengine-1.0.31/frontengine/ui/menu → frontengine-1.0.33/frontengine/ui/color}/__init__.py +0 -0
  70. {frontengine-1.0.31 → frontengine-1.0.33}/frontengine/ui/color/global_color.py +0 -0
  71. {frontengine-1.0.31/frontengine/ui/page → frontengine-1.0.33/frontengine/ui/dialog}/__init__.py +0 -0
  72. {frontengine-1.0.31 → frontengine-1.0.33}/frontengine/ui/dialog/choose_file_dialog.py +0 -0
  73. {frontengine-1.0.31 → frontengine-1.0.33}/frontengine/ui/dialog/hotkey_settings_dialog.py +0 -0
  74. {frontengine-1.0.31 → frontengine-1.0.33}/frontengine/ui/dialog/save_file_dialog.py +0 -0
  75. {frontengine-1.0.31/frontengine/ui/page/control_center → frontengine-1.0.33/frontengine/ui/menu}/__init__.py +0 -0
  76. {frontengine-1.0.31 → frontengine-1.0.33}/frontengine/ui/menu/help_menu.py +0 -0
  77. {frontengine-1.0.31 → frontengine-1.0.33}/frontengine/ui/menu/how_to_menu.py +0 -0
  78. {frontengine-1.0.31 → frontengine-1.0.33}/frontengine/ui/menu/language_menu.py +0 -0
  79. {frontengine-1.0.31/frontengine/ui/page/gif → frontengine-1.0.33/frontengine/ui/page}/__init__.py +0 -0
  80. {frontengine-1.0.31/frontengine/ui/page/image → frontengine-1.0.33/frontengine/ui/page/control_center}/__init__.py +0 -0
  81. {frontengine-1.0.31/frontengine/ui/page/particle → frontengine-1.0.33/frontengine/ui/page/gif}/__init__.py +0 -0
  82. {frontengine-1.0.31 → frontengine-1.0.33}/frontengine/ui/page/gif/gif_setting_ui.py +0 -0
  83. {frontengine-1.0.31/frontengine/ui/page/pet → frontengine-1.0.33/frontengine/ui/page/image}/__init__.py +0 -0
  84. {frontengine-1.0.31 → frontengine-1.0.33}/frontengine/ui/page/image/image_setting_ui.py +0 -0
  85. {frontengine-1.0.31/frontengine/ui/page/scene_setting → frontengine-1.0.33/frontengine/ui/page/particle}/__init__.py +0 -0
  86. {frontengine-1.0.31 → frontengine-1.0.33}/frontengine/ui/page/particle/particle_setting_ui.py +0 -0
  87. {frontengine-1.0.31/frontengine/ui/page/scene_setting/scene_page → frontengine-1.0.33/frontengine/ui/page/pet}/__init__.py +0 -0
  88. {frontengine-1.0.31 → frontengine-1.0.33}/frontengine/ui/page/pet/pet_setting_ui.py +0 -0
  89. {frontengine-1.0.31/frontengine/ui/page/sound_player → frontengine-1.0.33/frontengine/ui/page/presentation}/__init__.py +0 -0
  90. {frontengine-1.0.31/frontengine/ui/page/text → frontengine-1.0.33/frontengine/ui/page/scene_setting}/__init__.py +0 -0
  91. {frontengine-1.0.31 → frontengine-1.0.33}/frontengine/ui/page/scene_setting/scene_manager.py +0 -0
  92. {frontengine-1.0.31/frontengine/ui/page/video → frontengine-1.0.33/frontengine/ui/page/scene_setting/scene_page}/__init__.py +0 -0
  93. {frontengine-1.0.31 → frontengine-1.0.33}/frontengine/ui/page/scene_setting/scene_page/base_scene_page.py +0 -0
  94. {frontengine-1.0.31 → frontengine-1.0.33}/frontengine/ui/page/scene_setting/scene_page/gif.py +0 -0
  95. {frontengine-1.0.31 → frontengine-1.0.33}/frontengine/ui/page/scene_setting/scene_page/image.py +0 -0
  96. {frontengine-1.0.31 → frontengine-1.0.33}/frontengine/ui/page/scene_setting/scene_page/registry.py +0 -0
  97. {frontengine-1.0.31 → frontengine-1.0.33}/frontengine/ui/page/scene_setting/scene_page/sound.py +0 -0
  98. {frontengine-1.0.31 → frontengine-1.0.33}/frontengine/ui/page/scene_setting/scene_page/text.py +0 -0
  99. {frontengine-1.0.31 → frontengine-1.0.33}/frontengine/ui/page/scene_setting/scene_page/video.py +0 -0
  100. {frontengine-1.0.31 → frontengine-1.0.33}/frontengine/ui/page/scene_setting/scene_page/web.py +0 -0
  101. {frontengine-1.0.31 → frontengine-1.0.33}/frontengine/ui/page/scene_setting/scene_setting_ui.py +0 -0
  102. {frontengine-1.0.31/frontengine/ui/page/web → frontengine-1.0.33/frontengine/ui/page/screen_care}/__init__.py +0 -0
  103. {frontengine-1.0.31/frontengine/user_setting → frontengine-1.0.33/frontengine/ui/page/sound_player}/__init__.py +0 -0
  104. {frontengine-1.0.31 → frontengine-1.0.33}/frontengine/ui/page/sound_player/sound_player_setting_ui.py +0 -0
  105. {frontengine-1.0.31/frontengine/utils → frontengine-1.0.33/frontengine/ui/page/text}/__init__.py +0 -0
  106. {frontengine-1.0.31 → frontengine-1.0.33}/frontengine/ui/page/text/text_setting_ui.py +0 -0
  107. {frontengine-1.0.31 → frontengine-1.0.33}/frontengine/ui/page/utils.py +0 -0
  108. {frontengine-1.0.31/frontengine/utils/audio_meter → frontengine-1.0.33/frontengine/ui/page/video}/__init__.py +0 -0
  109. {frontengine-1.0.31 → frontengine-1.0.33}/frontengine/ui/page/video/video_setting_ui.py +0 -0
  110. {frontengine-1.0.31/frontengine/utils/browser → frontengine-1.0.33/frontengine/ui/page/web}/__init__.py +0 -0
  111. {frontengine-1.0.31 → frontengine-1.0.33}/frontengine/ui/page/web/web_setting_ui.py +0 -0
  112. {frontengine-1.0.31/frontengine/utils/critical_exit → frontengine-1.0.33/frontengine/user_setting}/__init__.py +0 -0
  113. {frontengine-1.0.31 → frontengine-1.0.33}/frontengine/user_setting/preset_repository.py +0 -0
  114. {frontengine-1.0.31 → frontengine-1.0.33}/frontengine/user_setting/scene_setting.py +0 -0
  115. {frontengine-1.0.31/frontengine/utils/file → frontengine-1.0.33/frontengine/utils}/__init__.py +0 -0
  116. {frontengine-1.0.31/frontengine/utils/focus_timer → frontengine-1.0.33/frontengine/utils/audio_meter}/__init__.py +0 -0
  117. {frontengine-1.0.31 → frontengine-1.0.33}/frontengine/utils/audio_meter/audio_envelope.py +0 -0
  118. {frontengine-1.0.31 → frontengine-1.0.33}/frontengine/utils/audio_meter/screen_audio.py +0 -0
  119. {frontengine-1.0.31 → frontengine-1.0.33}/frontengine/utils/audio_meter/system_audio_meter.py +0 -0
  120. {frontengine-1.0.31/frontengine/utils/hotkey → frontengine-1.0.33/frontengine/utils/autostart}/__init__.py +0 -0
  121. {frontengine-1.0.31/frontengine/utils/json → frontengine-1.0.33/frontengine/utils/break_reminder}/__init__.py +0 -0
  122. {frontengine-1.0.31/frontengine/utils/logging → frontengine-1.0.33/frontengine/utils/browser}/__init__.py +0 -0
  123. {frontengine-1.0.31 → frontengine-1.0.33}/frontengine/utils/browser/browser.py +0 -0
  124. {frontengine-1.0.31/frontengine/utils/multi_language → frontengine-1.0.33/frontengine/utils/critical_exit}/__init__.py +0 -0
  125. {frontengine-1.0.31 → frontengine-1.0.33}/frontengine/utils/critical_exit/check_key_is_press.py +0 -0
  126. {frontengine-1.0.31 → frontengine-1.0.33}/frontengine/utils/critical_exit/critical_exit.py +0 -0
  127. {frontengine-1.0.31 → frontengine-1.0.33}/frontengine/utils/critical_exit/win32_vk.py +0 -0
  128. {frontengine-1.0.31 → frontengine-1.0.33}/frontengine/utils/exception/__init__.py +0 -0
  129. {frontengine-1.0.31 → frontengine-1.0.33}/frontengine/utils/exception/exception_tags.py +0 -0
  130. {frontengine-1.0.31 → frontengine-1.0.33}/frontengine/utils/exception/exceptions.py +0 -0
  131. {frontengine-1.0.31/frontengine/utils/pet_chat → frontengine-1.0.33/frontengine/utils/file}/__init__.py +0 -0
  132. {frontengine-1.0.31 → frontengine-1.0.33}/frontengine/utils/file/open/__init__.py +0 -0
  133. {frontengine-1.0.31 → frontengine-1.0.33}/frontengine/utils/file/open/open_file.py +0 -0
  134. {frontengine-1.0.31 → frontengine-1.0.33}/frontengine/utils/file/save/__init__.py +0 -0
  135. {frontengine-1.0.31 → frontengine-1.0.33}/frontengine/utils/file/save/save_file.py +0 -0
  136. {frontengine-1.0.31/frontengine/utils/preset_schedule → frontengine-1.0.33/frontengine/utils/focus_timer}/__init__.py +0 -0
  137. {frontengine-1.0.31 → frontengine-1.0.33}/frontengine/utils/focus_timer/focus_timer.py +0 -0
  138. {frontengine-1.0.31/frontengine/utils/redirect_manager → frontengine-1.0.33/frontengine/utils/hotkey}/__init__.py +0 -0
  139. {frontengine-1.0.31 → frontengine-1.0.33}/frontengine/utils/hotkey/hotkey_service.py +0 -0
  140. {frontengine-1.0.31/frontengine/utils/smart_pause → frontengine-1.0.33/frontengine/utils/input_watch}/__init__.py +0 -0
  141. {frontengine-1.0.31/frontengine/utils/system_stats → frontengine-1.0.33/frontengine/utils/json}/__init__.py +0 -0
  142. {frontengine-1.0.31 → frontengine-1.0.33}/frontengine/utils/json/json_file.py +0 -0
  143. {frontengine-1.0.31 → frontengine-1.0.33}/frontengine/utils/json/json_repository.py +0 -0
  144. {frontengine-1.0.31 → frontengine-1.0.33}/frontengine/utils/json_format/__init__.py +0 -0
  145. {frontengine-1.0.31 → frontengine-1.0.33}/frontengine/utils/json_format/json_process.py +0 -0
  146. {frontengine-1.0.31/frontengine/utils/text_source → frontengine-1.0.33/frontengine/utils/logging}/__init__.py +0 -0
  147. {frontengine-1.0.31 → frontengine-1.0.33}/frontengine/utils/logging/loggin_instance.py +0 -0
  148. {frontengine-1.0.31/frontengine/utils/theme_schedule → frontengine-1.0.33/frontengine/utils/multi_language}/__init__.py +0 -0
  149. {frontengine-1.0.31 → frontengine-1.0.33}/frontengine/utils/multi_language/france.py +0 -0
  150. {frontengine-1.0.31 → frontengine-1.0.33}/frontengine/utils/multi_language/germany.py +0 -0
  151. {frontengine-1.0.31 → frontengine-1.0.33}/frontengine/utils/multi_language/italy.py +0 -0
  152. {frontengine-1.0.31 → frontengine-1.0.33}/frontengine/utils/multi_language/language_wrapper.py +0 -0
  153. {frontengine-1.0.31 → frontengine-1.0.33}/frontengine/utils/multi_language/russian.py +0 -0
  154. {frontengine-1.0.31 → frontengine-1.0.33}/frontengine/utils/multi_language/simplified_chinese.py +0 -0
  155. {frontengine-1.0.31/frontengine/utils/weather → frontengine-1.0.33/frontengine/utils/pet_chat}/__init__.py +0 -0
  156. {frontengine-1.0.31 → frontengine-1.0.33}/frontengine/utils/pet_chat/pet_chat_service.py +0 -0
  157. {frontengine-1.0.31/frontengine/worker → frontengine-1.0.33/frontengine/utils/platform_info}/__init__.py +0 -0
  158. {frontengine-1.0.31 → frontengine-1.0.33}/frontengine/utils/preset_schedule/preset_schedule_service.py +0 -0
  159. {frontengine-1.0.31 → frontengine-1.0.33}/frontengine/utils/redirect_manager/redirect_manager_class.py +0 -0
  160. {frontengine-1.0.31 → frontengine-1.0.33}/frontengine/utils/smart_pause/smart_pause_service.py +0 -0
  161. {frontengine-1.0.31 → frontengine-1.0.33}/frontengine/utils/system_stats/system_stats.py +0 -0
  162. {frontengine-1.0.31 → frontengine-1.0.33}/frontengine/utils/text_source/text_source.py +0 -0
  163. {frontengine-1.0.31 → frontengine-1.0.33}/frontengine/utils/theme_schedule/theme_schedule_service.py +0 -0
  164. {frontengine-1.0.31 → frontengine-1.0.33}/frontengine/utils/weather/weather_service.py +0 -0
  165. {frontengine-1.0.31 → frontengine-1.0.33}/frontengine/utils/web_url.py +0 -0
  166. {frontengine-1.0.31 → frontengine-1.0.33}/frontengine/worker/qthread_worker.py +0 -0
  167. {frontengine-1.0.31 → frontengine-1.0.33}/frontengine.egg-info/dependency_links.txt +0 -0
  168. {frontengine-1.0.31 → frontengine-1.0.33}/frontengine.egg-info/entry_points.txt +0 -0
  169. {frontengine-1.0.31 → frontengine-1.0.33}/frontengine.egg-info/requires.txt +0 -0
  170. {frontengine-1.0.31 → frontengine-1.0.33}/frontengine.egg-info/top_level.txt +0 -0
  171. {frontengine-1.0.31 → frontengine-1.0.33}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: frontengine
3
- Version: 1.0.31
3
+ Version: 1.0.33
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
@@ -16,6 +16,12 @@ from frontengine.show.window_helpers import apply_overlay_window_flags
16
16
  from frontengine.user_setting.user_setting_file import user_setting_dict
17
17
  from frontengine.utils.audio_meter.audio_envelope import AudioEnvelope
18
18
  from frontengine.utils.logging.loggin_instance import front_engine_logger
19
+ from frontengine.utils.power_mode.power_mode import scaled_interval
20
+ from frontengine.utils.platform_info.platform_info import (
21
+ idle_seconds as platform_idle_seconds,
22
+ read_battery as platform_read_battery,
23
+ standable_windows,
24
+ )
19
25
  from frontengine.utils.multi_language.language_wrapper import language_wrapper
20
26
 
21
27
  # 行為模式 / Behaviour modes
@@ -212,75 +218,17 @@ def derive_visual_state(dragging: bool, airborne: bool, surface: str, motion_sta
212
218
  return STATE_WALK
213
219
 
214
220
 
215
- # 列舉可站立視窗時要略過的外殼視窗類別
216
- # Shell window classes skipped when enumerating standable windows.
217
- _PLATFORM_SKIP_CLASSES = {"WorkerW", "Progman", "Shell_TrayWnd", "Button"}
218
-
219
-
220
221
  def windows_platforms(exclude_hwnds=()) -> list:
221
222
  """
222
- 回傳目前可見頂層視窗的上緣做為可站立平台 (left, right, top_y)。僅 Windows
223
- 有效,其餘平台或發生錯誤時回傳空清單。
224
- Return the top edges of visible top-level windows as standable platforms
225
- (left, right, top_y). Windows only; returns [] elsewhere or on error.
223
+ 回傳目前可站立的視窗上緣 (left, right, top_y);各平台實作見 platform_info。
224
+ Standable window top edges; per-platform implementations live in platform_info.
226
225
  """
227
- if sys.platform != "win32":
228
- return []
229
- try:
230
- import ctypes
231
- from ctypes import wintypes
232
-
233
- user32 = ctypes.windll.user32
234
- exclude = {int(h) for h in exclude_hwnds}
235
- platforms: list = []
236
-
237
- @ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.HWND, wintypes.LPARAM)
238
- def _collect(hwnd, _lparam):
239
- if int(hwnd) in exclude or not user32.IsWindowVisible(hwnd):
240
- return True
241
- rect = wintypes.RECT()
242
- if not user32.GetWindowRect(hwnd, ctypes.byref(rect)):
243
- return True
244
- width = rect.right - rect.left
245
- height = rect.bottom - rect.top
246
- if width < 80 or height < 40:
247
- return True
248
- class_buffer = ctypes.create_unicode_buffer(64)
249
- user32.GetClassNameW(hwnd, class_buffer, 64)
250
- if class_buffer.value in _PLATFORM_SKIP_CLASSES:
251
- return True
252
- platforms.append((rect.left, rect.right, rect.top))
253
- return True
254
-
255
- user32.EnumWindows(_collect, 0)
256
- return platforms
257
- except Exception as error: # pragma: no cover - Win32 boundary
258
- front_engine_logger.warning(f"[windows_platforms] error: {error!r}")
259
- return []
226
+ return standable_windows(exclude_hwnds)
260
227
 
261
228
 
262
229
  def idle_seconds():
263
- """
264
- 回傳使用者未操作的秒數;無法取得時回傳 None。僅 Windows 實作。
265
- Seconds since the last user input, or None when unavailable (Windows only).
266
- """
267
- if sys.platform != "win32":
268
- return None
269
- try:
270
- import ctypes
271
-
272
- class _LASTINPUTINFO(ctypes.Structure):
273
- _fields_ = [("cbSize", ctypes.c_uint), ("dwTime", ctypes.c_uint)]
274
-
275
- info = _LASTINPUTINFO()
276
- info.cbSize = ctypes.sizeof(info)
277
- if not ctypes.windll.user32.GetLastInputInfo(ctypes.byref(info)):
278
- return None
279
- elapsed_ms = ctypes.windll.kernel32.GetTickCount() - info.dwTime
280
- return max(0.0, elapsed_ms / 1000.0)
281
- except Exception as error: # pragma: no cover - Win32 boundary
282
- front_engine_logger.warning(f"[idle_seconds] error: {error!r}")
283
- return None
230
+ """使用者未操作的秒數;取不到回傳 None(跨平台,見 platform_info)。"""
231
+ return platform_idle_seconds()
284
232
 
285
233
 
286
234
  class PetTimeline:
@@ -335,35 +283,8 @@ def pop_due_reminders(now, reminders):
335
283
 
336
284
 
337
285
  def read_battery():
338
- """
339
- 回傳 (電量百分比, 是否充電中);無法取得時回傳 None。僅 Windows 實作。
340
- Return (percent, charging) or None when unavailable. Windows only.
341
- """
342
- if sys.platform != "win32":
343
- return None
344
- try:
345
- import ctypes
346
-
347
- class _SPS(ctypes.Structure):
348
- _fields_ = [
349
- ("ACLineStatus", ctypes.c_byte),
350
- ("BatteryFlag", ctypes.c_byte),
351
- ("BatteryLifePercent", ctypes.c_byte),
352
- ("SystemStatusFlag", ctypes.c_byte),
353
- ("BatteryLifeTime", ctypes.c_ulong),
354
- ("BatteryFullLifeTime", ctypes.c_ulong),
355
- ]
356
-
357
- status = _SPS()
358
- if not ctypes.windll.kernel32.GetSystemPowerStatus(ctypes.byref(status)):
359
- return None
360
- percent = status.BatteryLifePercent & 0xFF
361
- if percent == 255: # unknown / no battery
362
- return None
363
- return (int(percent), status.ACLineStatus == 1)
364
- except Exception as error: # pragma: no cover - Win32 boundary
365
- front_engine_logger.warning(f"[read_battery] error: {error!r}")
366
- return None
286
+ """回傳 (電量百分比, 是否充電中);沒有電池或取不到回傳 None(跨平台)。"""
287
+ return platform_read_battery()
367
288
 
368
289
 
369
290
  def nearest_peer(center, peers):
@@ -1147,6 +1068,9 @@ class DesktopPetWidget(BaseWidget):
1147
1068
 
1148
1069
  self._timer = QTimer(self)
1149
1070
  self._timer.timeout.connect(self._on_tick)
1071
+ # 省電模式與原始更新間隔 / Low-power state and the base tick interval
1072
+ self._low_power = False
1073
+ self._tick_interval_ms = 33
1150
1074
 
1151
1075
  # Speech bubbles / chatter / mood
1152
1076
  self._talk = bool(talk)
@@ -1417,6 +1341,12 @@ class DesktopPetWidget(BaseWidget):
1417
1341
  painter.drawPixmap(
1418
1342
  QRect((self.width() - width) // 2, self.height() - height, width, height), pixmap)
1419
1343
 
1344
+ def set_low_power(self, enabled: bool) -> None:
1345
+ """省電模式:把移動更新拉慢(畫面較不順,但省電)。"""
1346
+ self._low_power = bool(enabled)
1347
+ if self._timer.isActive():
1348
+ self._timer.start(scaled_interval(self._tick_interval_ms, self._low_power))
1349
+
1420
1350
  def start_moving(self, bounds: Tuple[int, int, int, int], interval_ms: int = 33) -> None:
1421
1351
  front_engine_logger.info(f"[DesktopPetWidget] start_moving | bounds={bounds}")
1422
1352
  left, top, right, bottom = bounds
@@ -1424,7 +1354,8 @@ class DesktopPetWidget(BaseWidget):
1424
1354
  self.motion.x = float(left)
1425
1355
  self.motion.y = float(bottom - self.pet_size)
1426
1356
  self.move(int(self.motion.x), int(self.motion.y))
1427
- self._timer.start(max(10, int(interval_ms)))
1357
+ self._tick_interval_ms = max(10, int(interval_ms))
1358
+ self._timer.start(scaled_interval(self._tick_interval_ms, self._low_power))
1428
1359
  self._schedule_chatter()
1429
1360
  self._reminder_timer.start(10000)
1430
1361
  if self._timeline_start is None:
@@ -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)
@@ -0,0 +1,161 @@
1
+ """
2
+ 按鍵顯示:把剛按下的按鍵顯示在畫面角落,做教學影片或直播時觀眾才知道你按了什麼。
3
+ PowerToys 至今沒有內建這個功能,而本專案本來就有全域鍵盤監聽(pynput),
4
+ 所以只需要一層顯示。按鍵格式化是純函式,可獨立測試。
5
+
6
+ Keystroke display: show what you just pressed in a screen corner, so viewers of
7
+ a tutorial or stream can follow along. PowerToys still has no built-in version;
8
+ this project already listens for global hotkeys, so only the display is new.
9
+ The formatting is pure and independently testable.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import time
14
+ from typing import List, Tuple
15
+
16
+ from PySide6.QtCore import QRect, QTimer, Qt
17
+ from PySide6.QtGui import QColor, QFont, QFontMetrics, QPainter
18
+
19
+ from frontengine.show.base_widget import BaseWidget
20
+ from frontengine.utils.logging.loggin_instance import front_engine_logger
21
+
22
+ DEFAULT_HOLD_SECONDS = 2.0
23
+ MAX_KEYS_SHOWN = 6
24
+
25
+ # 修飾鍵的顯示名稱 / How modifier keys are shown
26
+ _MODIFIER_NAMES = {
27
+ "ctrl": "Ctrl", "ctrl_l": "Ctrl", "ctrl_r": "Ctrl",
28
+ "alt": "Alt", "alt_l": "Alt", "alt_r": "Alt", "alt_gr": "AltGr",
29
+ "shift": "Shift", "shift_l": "Shift", "shift_r": "Shift",
30
+ "cmd": "Win", "cmd_l": "Win", "cmd_r": "Win",
31
+ }
32
+ # 特殊鍵的顯示名稱 / Friendlier names for special keys
33
+ _SPECIAL_NAMES = {
34
+ "space": "Space", "enter": "Enter", "return": "Enter", "tab": "Tab",
35
+ "backspace": "Backspace", "delete": "Del", "esc": "Esc", "escape": "Esc",
36
+ "up": "↑", "down": "↓", "left": "←", "right": "→",
37
+ "page_up": "PgUp", "page_down": "PgDn", "home": "Home", "end": "End",
38
+ }
39
+
40
+
41
+ def format_key(name) -> str:
42
+ """
43
+ 把按鍵名稱轉成畫面上好讀的字(修飾鍵、方向鍵、功能鍵各有寫法)。
44
+ Turn a key name into something readable on screen.
45
+ """
46
+ text = str(name or "").strip().strip("'")
47
+ if not text:
48
+ return ""
49
+ lowered = text.lower()
50
+ if lowered in _MODIFIER_NAMES:
51
+ return _MODIFIER_NAMES[lowered]
52
+ if lowered in _SPECIAL_NAMES:
53
+ return _SPECIAL_NAMES[lowered]
54
+ if lowered.startswith("key."):
55
+ return format_key(text[4:])
56
+ if len(text) == 1:
57
+ return text.upper()
58
+ if lowered.startswith("f") and lowered[1:].isdigit():
59
+ return text.upper()
60
+ return text.replace("_", " ").title()
61
+
62
+
63
+ def format_combo(keys) -> str:
64
+ """把同時按著的按鍵組成 `Ctrl + Shift + S` 這種字串(去重、保留順序)。"""
65
+ parts: List[str] = []
66
+ for key in keys or ():
67
+ formatted = format_key(key)
68
+ if formatted and formatted not in parts:
69
+ parts.append(formatted)
70
+ return " + ".join(parts)
71
+
72
+
73
+ def visible_keys(entries, now: float, hold_seconds: float = DEFAULT_HOLD_SECONDS,
74
+ limit: int = MAX_KEYS_SHOWN) -> List[str]:
75
+ """
76
+ 從 (文字, 時間) 清單裡挑出還沒過期的按鍵,最多顯示 limit 個(最新在後)。
77
+ The still-fresh entries from a list of (text, timestamp) pairs.
78
+ """
79
+ fresh = [text for text, stamp in entries or () if now - stamp <= max(0.1, float(hold_seconds))]
80
+ return fresh[-max(1, int(limit)):]
81
+
82
+
83
+ class KeystrokeDisplayWidget(BaseWidget):
84
+ """
85
+ 按鍵顯示層:把最近按下的按鍵排成一列顯示,過幾秒自動淡出。點擊穿透。
86
+ 按鍵來源由外部推入(`push_keys`),因此不需要在這裡自己開監聽器。
87
+ """
88
+
89
+ REFRESH_MS = 100
90
+ PADDING = 12
91
+
92
+ def __init__(self, hold_seconds: float = DEFAULT_HOLD_SECONDS, font_size: int = 28,
93
+ color: str = "#ffffff", background: str = "#000000") -> None:
94
+ front_engine_logger.info(f"[KeystrokeDisplayWidget] Init | hold={hold_seconds}s")
95
+ super().__init__()
96
+ self.opacity = 1.0
97
+ self.hold_seconds = max(0.2, float(hold_seconds))
98
+ self.text_color = QColor(color) if QColor(color).isValid() else QColor("#ffffff")
99
+ self.background_color = QColor(background) if QColor(background).isValid() else QColor("#000000")
100
+ self.font_size = max(8, int(font_size))
101
+ self.entries: List[Tuple[str, float]] = []
102
+ self._now = time.monotonic
103
+ self._timer = QTimer(self)
104
+ self._timer.timeout.connect(self._expire)
105
+
106
+ def set_clock(self, clock) -> None:
107
+ """注入時間來源(測試用)。"""
108
+ self._now = clock
109
+
110
+ def start(self, interval_ms: int = REFRESH_MS) -> None:
111
+ """開始定期清掉過期的按鍵。"""
112
+ self._timer.start(max(30, int(interval_ms)))
113
+
114
+ def push_keys(self, keys) -> None:
115
+ """
116
+ 推入一次按鍵事件(單一鍵或組合鍵);空的輸入忽略。
117
+ Record one keypress (single key or combination); blanks are ignored.
118
+ """
119
+ text = format_combo(keys if isinstance(keys, (list, tuple, set)) else [keys])
120
+ if not text:
121
+ return
122
+ self.entries.append((text, self._now()))
123
+ self.entries = self.entries[-(MAX_KEYS_SHOWN * 2):]
124
+ self.update()
125
+
126
+ def current_text(self) -> str:
127
+ """目前該顯示的字串。"""
128
+ return " ".join(visible_keys(self.entries, self._now(), self.hold_seconds))
129
+
130
+ def _expire(self) -> None:
131
+ before = len(visible_keys(self.entries, self._now(), self.hold_seconds))
132
+ if before != len(self.entries):
133
+ self.entries = [
134
+ (text, stamp) for text, stamp in self.entries
135
+ if self._now() - stamp <= self.hold_seconds
136
+ ]
137
+ self.update()
138
+
139
+ def draw_content(self, painter: QPainter) -> None:
140
+ text = self.current_text()
141
+ if not text:
142
+ return
143
+ font = QFont(self.font().family(), self.font_size)
144
+ painter.setFont(font)
145
+ metrics = QFontMetrics(font)
146
+ width = metrics.horizontalAdvance(text) + self.PADDING * 2
147
+ height = metrics.height() + self.PADDING
148
+ box = QRect(max(0, (self.width() - width) // 2),
149
+ max(0, self.height() - height - self.PADDING), width, height)
150
+ background = QColor(self.background_color)
151
+ background.setAlpha(170)
152
+ painter.setPen(Qt.PenStyle.NoPen)
153
+ painter.setBrush(background)
154
+ painter.drawRoundedRect(box, 8, 8)
155
+ painter.setPen(self.text_color)
156
+ painter.drawText(box, int(Qt.AlignmentFlag.AlignCenter), text)
157
+
158
+ def closeEvent(self, event) -> None:
159
+ if self._timer.isActive():
160
+ self._timer.stop()
161
+ super().closeEvent(event)