frontengine 1.0.30__tar.gz → 1.0.31__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.
Potentially problematic release.
This version of frontengine might be problematic. Click here for more details.
- {frontengine-1.0.30 → frontengine-1.0.31}/PKG-INFO +1 -1
- {frontengine-1.0.30 → frontengine-1.0.31}/frontengine/show/pet/desktop_pet.py +229 -6
- {frontengine-1.0.30 → frontengine-1.0.31}/frontengine/ui/page/pet/pet_setting_ui.py +115 -1
- frontengine-1.0.31/frontengine/utils/focus_timer/focus_timer.py +112 -0
- {frontengine-1.0.30 → frontengine-1.0.31}/frontengine/utils/multi_language/english.py +15 -0
- {frontengine-1.0.30 → frontengine-1.0.31}/frontengine/utils/multi_language/traditional_chinese.py +15 -0
- frontengine-1.0.31/frontengine/utils/pet_chat/pet_chat_service.py +162 -0
- frontengine-1.0.31/frontengine/utils/weather/__init__.py +0 -0
- frontengine-1.0.31/frontengine/worker/__init__.py +0 -0
- {frontengine-1.0.30 → frontengine-1.0.31}/frontengine.egg-info/PKG-INFO +1 -1
- {frontengine-1.0.30 → frontengine-1.0.31}/frontengine.egg-info/SOURCES.txt +4 -0
- {frontengine-1.0.30 → frontengine-1.0.31}/pyproject.toml +1 -1
- {frontengine-1.0.30 → frontengine-1.0.31}/LICENSE +0 -0
- {frontengine-1.0.30 → frontengine-1.0.31}/README.md +0 -0
- {frontengine-1.0.30 → frontengine-1.0.31}/frontengine/__init__.py +0 -0
- {frontengine-1.0.30 → frontengine-1.0.31}/frontengine/__main__.py +0 -0
- {frontengine-1.0.30 → frontengine-1.0.31}/frontengine/show/__init__.py +0 -0
- {frontengine-1.0.30 → frontengine-1.0.31}/frontengine/show/base_widget.py +0 -0
- {frontengine-1.0.30 → frontengine-1.0.31}/frontengine/show/gif/__init__.py +0 -0
- {frontengine-1.0.30 → frontengine-1.0.31}/frontengine/show/gif/paint_gif.py +0 -0
- {frontengine-1.0.30 → frontengine-1.0.31}/frontengine/show/image/__init__.py +0 -0
- {frontengine-1.0.30 → frontengine-1.0.31}/frontengine/show/image/paint_image.py +0 -0
- {frontengine-1.0.30 → frontengine-1.0.31}/frontengine/show/load/__init__.py +0 -0
- {frontengine-1.0.30 → frontengine-1.0.31}/frontengine/show/load/load_someone_make_ui.py +0 -0
- {frontengine-1.0.30 → frontengine-1.0.31}/frontengine/show/overlay_factory.py +0 -0
- {frontengine-1.0.30 → frontengine-1.0.31}/frontengine/show/particle/__init__.py +0 -0
- {frontengine-1.0.30 → frontengine-1.0.31}/frontengine/show/particle/particle_ui.py +0 -0
- {frontengine-1.0.30 → frontengine-1.0.31}/frontengine/show/pet/__init__.py +0 -0
- {frontengine-1.0.30 → frontengine-1.0.31}/frontengine/show/scene/__init__.py +0 -0
- {frontengine-1.0.30 → frontengine-1.0.31}/frontengine/show/scene/extend_graphic_scene.py +0 -0
- {frontengine-1.0.30 → frontengine-1.0.31}/frontengine/show/scene/extend_graphic_view.py +0 -0
- {frontengine-1.0.30 → frontengine-1.0.31}/frontengine/show/scene/scene.py +0 -0
- {frontengine-1.0.30 → frontengine-1.0.31}/frontengine/show/sound_player/__init__.py +0 -0
- {frontengine-1.0.30 → frontengine-1.0.31}/frontengine/show/sound_player/sound_effect.py +0 -0
- {frontengine-1.0.30 → frontengine-1.0.31}/frontengine/show/sound_player/sound_player.py +0 -0
- {frontengine-1.0.30 → frontengine-1.0.31}/frontengine/show/text/__init__.py +0 -0
- {frontengine-1.0.30 → frontengine-1.0.31}/frontengine/show/text/draw_text.py +0 -0
- {frontengine-1.0.30 → frontengine-1.0.31}/frontengine/show/video/__init__.py +0 -0
- {frontengine-1.0.30 → frontengine-1.0.31}/frontengine/show/video/video_player.py +0 -0
- {frontengine-1.0.30 → frontengine-1.0.31}/frontengine/show/web/__init__.py +0 -0
- {frontengine-1.0.30 → frontengine-1.0.31}/frontengine/show/web/webview.py +0 -0
- {frontengine-1.0.30 → frontengine-1.0.31}/frontengine/show/window_helpers.py +0 -0
- {frontengine-1.0.30 → frontengine-1.0.31}/frontengine/system_tray/__init__.py +0 -0
- {frontengine-1.0.30 → frontengine-1.0.31}/frontengine/system_tray/extend_system_tray.py +0 -0
- {frontengine-1.0.30 → frontengine-1.0.31}/frontengine/ui/__init__.py +0 -0
- {frontengine-1.0.30 → frontengine-1.0.31}/frontengine/ui/color/__init__.py +0 -0
- {frontengine-1.0.30 → frontengine-1.0.31}/frontengine/ui/color/global_color.py +0 -0
- {frontengine-1.0.30 → frontengine-1.0.31}/frontengine/ui/dialog/__init__.py +0 -0
- {frontengine-1.0.30 → frontengine-1.0.31}/frontengine/ui/dialog/choose_file_dialog.py +0 -0
- {frontengine-1.0.30 → frontengine-1.0.31}/frontengine/ui/dialog/hotkey_settings_dialog.py +0 -0
- {frontengine-1.0.30 → frontengine-1.0.31}/frontengine/ui/dialog/save_file_dialog.py +0 -0
- {frontengine-1.0.30 → frontengine-1.0.31}/frontengine/ui/main_ui.py +0 -0
- {frontengine-1.0.30 → frontengine-1.0.31}/frontengine/ui/menu/__init__.py +0 -0
- {frontengine-1.0.30 → frontengine-1.0.31}/frontengine/ui/menu/help_menu.py +0 -0
- {frontengine-1.0.30 → frontengine-1.0.31}/frontengine/ui/menu/how_to_menu.py +0 -0
- {frontengine-1.0.30 → frontengine-1.0.31}/frontengine/ui/menu/language_menu.py +0 -0
- {frontengine-1.0.30 → frontengine-1.0.31}/frontengine/ui/menu/preset_menu.py +0 -0
- {frontengine-1.0.30 → frontengine-1.0.31}/frontengine/ui/menu/settings_menu.py +0 -0
- {frontengine-1.0.30 → frontengine-1.0.31}/frontengine/ui/page/__init__.py +0 -0
- {frontengine-1.0.30 → frontengine-1.0.31}/frontengine/ui/page/control_center/__init__.py +0 -0
- {frontengine-1.0.30 → frontengine-1.0.31}/frontengine/ui/page/control_center/control_center_ui.py +0 -0
- {frontengine-1.0.30 → frontengine-1.0.31}/frontengine/ui/page/gif/__init__.py +0 -0
- {frontengine-1.0.30 → frontengine-1.0.31}/frontengine/ui/page/gif/gif_setting_ui.py +0 -0
- {frontengine-1.0.30 → frontengine-1.0.31}/frontengine/ui/page/image/__init__.py +0 -0
- {frontengine-1.0.30 → frontengine-1.0.31}/frontengine/ui/page/image/image_setting_ui.py +0 -0
- {frontengine-1.0.30 → frontengine-1.0.31}/frontengine/ui/page/particle/__init__.py +0 -0
- {frontengine-1.0.30 → frontengine-1.0.31}/frontengine/ui/page/particle/particle_setting_ui.py +0 -0
- {frontengine-1.0.30 → frontengine-1.0.31}/frontengine/ui/page/pet/__init__.py +0 -0
- {frontengine-1.0.30 → frontengine-1.0.31}/frontengine/ui/page/scene_setting/__init__.py +0 -0
- {frontengine-1.0.30 → frontengine-1.0.31}/frontengine/ui/page/scene_setting/scene_manager.py +0 -0
- {frontengine-1.0.30 → frontengine-1.0.31}/frontengine/ui/page/scene_setting/scene_page/__init__.py +0 -0
- {frontengine-1.0.30 → frontengine-1.0.31}/frontengine/ui/page/scene_setting/scene_page/base_scene_page.py +0 -0
- {frontengine-1.0.30 → frontengine-1.0.31}/frontengine/ui/page/scene_setting/scene_page/gif.py +0 -0
- {frontengine-1.0.30 → frontengine-1.0.31}/frontengine/ui/page/scene_setting/scene_page/image.py +0 -0
- {frontengine-1.0.30 → frontengine-1.0.31}/frontengine/ui/page/scene_setting/scene_page/registry.py +0 -0
- {frontengine-1.0.30 → frontengine-1.0.31}/frontengine/ui/page/scene_setting/scene_page/sound.py +0 -0
- {frontengine-1.0.30 → frontengine-1.0.31}/frontengine/ui/page/scene_setting/scene_page/text.py +0 -0
- {frontengine-1.0.30 → frontengine-1.0.31}/frontengine/ui/page/scene_setting/scene_page/video.py +0 -0
- {frontengine-1.0.30 → frontengine-1.0.31}/frontengine/ui/page/scene_setting/scene_page/web.py +0 -0
- {frontengine-1.0.30 → frontengine-1.0.31}/frontengine/ui/page/scene_setting/scene_setting_ui.py +0 -0
- {frontengine-1.0.30 → frontengine-1.0.31}/frontengine/ui/page/sound_player/__init__.py +0 -0
- {frontengine-1.0.30 → frontengine-1.0.31}/frontengine/ui/page/sound_player/sound_player_setting_ui.py +0 -0
- {frontengine-1.0.30 → frontengine-1.0.31}/frontengine/ui/page/text/__init__.py +0 -0
- {frontengine-1.0.30 → frontengine-1.0.31}/frontengine/ui/page/text/text_setting_ui.py +0 -0
- {frontengine-1.0.30 → frontengine-1.0.31}/frontengine/ui/page/utils.py +0 -0
- {frontengine-1.0.30 → frontengine-1.0.31}/frontengine/ui/page/video/__init__.py +0 -0
- {frontengine-1.0.30 → frontengine-1.0.31}/frontengine/ui/page/video/video_setting_ui.py +0 -0
- {frontengine-1.0.30 → frontengine-1.0.31}/frontengine/ui/page/web/__init__.py +0 -0
- {frontengine-1.0.30 → frontengine-1.0.31}/frontengine/ui/page/web/web_setting_ui.py +0 -0
- {frontengine-1.0.30 → frontengine-1.0.31}/frontengine/user_setting/__init__.py +0 -0
- {frontengine-1.0.30 → frontengine-1.0.31}/frontengine/user_setting/preset_repository.py +0 -0
- {frontengine-1.0.30 → frontengine-1.0.31}/frontengine/user_setting/scene_setting.py +0 -0
- {frontengine-1.0.30 → frontengine-1.0.31}/frontengine/user_setting/user_setting_file.py +0 -0
- {frontengine-1.0.30 → frontengine-1.0.31}/frontengine/utils/__init__.py +0 -0
- {frontengine-1.0.30 → frontengine-1.0.31}/frontengine/utils/audio_meter/__init__.py +0 -0
- {frontengine-1.0.30 → frontengine-1.0.31}/frontengine/utils/audio_meter/audio_envelope.py +0 -0
- {frontengine-1.0.30 → frontengine-1.0.31}/frontengine/utils/audio_meter/screen_audio.py +0 -0
- {frontengine-1.0.30 → frontengine-1.0.31}/frontengine/utils/audio_meter/system_audio_meter.py +0 -0
- {frontengine-1.0.30 → frontengine-1.0.31}/frontengine/utils/browser/__init__.py +0 -0
- {frontengine-1.0.30 → frontengine-1.0.31}/frontengine/utils/browser/browser.py +0 -0
- {frontengine-1.0.30 → frontengine-1.0.31}/frontengine/utils/critical_exit/__init__.py +0 -0
- {frontengine-1.0.30 → frontengine-1.0.31}/frontengine/utils/critical_exit/check_key_is_press.py +0 -0
- {frontengine-1.0.30 → frontengine-1.0.31}/frontengine/utils/critical_exit/critical_exit.py +0 -0
- {frontengine-1.0.30 → frontengine-1.0.31}/frontengine/utils/critical_exit/win32_vk.py +0 -0
- {frontengine-1.0.30 → frontengine-1.0.31}/frontengine/utils/exception/__init__.py +0 -0
- {frontengine-1.0.30 → frontengine-1.0.31}/frontengine/utils/exception/exception_tags.py +0 -0
- {frontengine-1.0.30 → frontengine-1.0.31}/frontengine/utils/exception/exceptions.py +0 -0
- {frontengine-1.0.30 → frontengine-1.0.31}/frontengine/utils/file/__init__.py +0 -0
- {frontengine-1.0.30 → frontengine-1.0.31}/frontengine/utils/file/open/__init__.py +0 -0
- {frontengine-1.0.30 → frontengine-1.0.31}/frontengine/utils/file/open/open_file.py +0 -0
- {frontengine-1.0.30 → frontengine-1.0.31}/frontengine/utils/file/save/__init__.py +0 -0
- {frontengine-1.0.30 → frontengine-1.0.31}/frontengine/utils/file/save/save_file.py +0 -0
- {frontengine-1.0.30/frontengine/utils/hotkey → frontengine-1.0.31/frontengine/utils/focus_timer}/__init__.py +0 -0
- {frontengine-1.0.30/frontengine/utils/json → frontengine-1.0.31/frontengine/utils/hotkey}/__init__.py +0 -0
- {frontengine-1.0.30 → frontengine-1.0.31}/frontengine/utils/hotkey/hotkey_service.py +0 -0
- {frontengine-1.0.30/frontengine/utils/logging → frontengine-1.0.31/frontengine/utils/json}/__init__.py +0 -0
- {frontengine-1.0.30 → frontengine-1.0.31}/frontengine/utils/json/json_file.py +0 -0
- {frontengine-1.0.30 → frontengine-1.0.31}/frontengine/utils/json/json_repository.py +0 -0
- {frontengine-1.0.30 → frontengine-1.0.31}/frontengine/utils/json_format/__init__.py +0 -0
- {frontengine-1.0.30 → frontengine-1.0.31}/frontengine/utils/json_format/json_process.py +0 -0
- {frontengine-1.0.30/frontengine/utils/multi_language → frontengine-1.0.31/frontengine/utils/logging}/__init__.py +0 -0
- {frontengine-1.0.30 → frontengine-1.0.31}/frontengine/utils/logging/loggin_instance.py +0 -0
- {frontengine-1.0.30/frontengine/utils/preset_schedule → frontengine-1.0.31/frontengine/utils/multi_language}/__init__.py +0 -0
- {frontengine-1.0.30 → frontengine-1.0.31}/frontengine/utils/multi_language/france.py +0 -0
- {frontengine-1.0.30 → frontengine-1.0.31}/frontengine/utils/multi_language/germany.py +0 -0
- {frontengine-1.0.30 → frontengine-1.0.31}/frontengine/utils/multi_language/italy.py +0 -0
- {frontengine-1.0.30 → frontengine-1.0.31}/frontengine/utils/multi_language/language_wrapper.py +0 -0
- {frontengine-1.0.30 → frontengine-1.0.31}/frontengine/utils/multi_language/russian.py +0 -0
- {frontengine-1.0.30 → frontengine-1.0.31}/frontengine/utils/multi_language/simplified_chinese.py +0 -0
- {frontengine-1.0.30/frontengine/utils/redirect_manager → frontengine-1.0.31/frontengine/utils/pet_chat}/__init__.py +0 -0
- {frontengine-1.0.30/frontengine/utils/smart_pause → frontengine-1.0.31/frontengine/utils/preset_schedule}/__init__.py +0 -0
- {frontengine-1.0.30 → frontengine-1.0.31}/frontengine/utils/preset_schedule/preset_schedule_service.py +0 -0
- {frontengine-1.0.30/frontengine/utils/system_stats → frontengine-1.0.31/frontengine/utils/redirect_manager}/__init__.py +0 -0
- {frontengine-1.0.30 → frontengine-1.0.31}/frontengine/utils/redirect_manager/redirect_manager_class.py +0 -0
- {frontengine-1.0.30/frontengine/utils/text_source → frontengine-1.0.31/frontengine/utils/smart_pause}/__init__.py +0 -0
- {frontengine-1.0.30 → frontengine-1.0.31}/frontengine/utils/smart_pause/smart_pause_service.py +0 -0
- {frontengine-1.0.30/frontengine/utils/theme_schedule → frontengine-1.0.31/frontengine/utils/system_stats}/__init__.py +0 -0
- {frontengine-1.0.30 → frontengine-1.0.31}/frontengine/utils/system_stats/system_stats.py +0 -0
- {frontengine-1.0.30/frontengine/utils/weather → frontengine-1.0.31/frontengine/utils/text_source}/__init__.py +0 -0
- {frontengine-1.0.30 → frontengine-1.0.31}/frontengine/utils/text_source/text_source.py +0 -0
- {frontengine-1.0.30/frontengine/worker → frontengine-1.0.31/frontengine/utils/theme_schedule}/__init__.py +0 -0
- {frontengine-1.0.30 → frontengine-1.0.31}/frontengine/utils/theme_schedule/theme_schedule_service.py +0 -0
- {frontengine-1.0.30 → frontengine-1.0.31}/frontengine/utils/weather/weather_service.py +0 -0
- {frontengine-1.0.30 → frontengine-1.0.31}/frontengine/utils/web_url.py +0 -0
- {frontengine-1.0.30 → frontengine-1.0.31}/frontengine/worker/qthread_worker.py +0 -0
- {frontengine-1.0.30 → frontengine-1.0.31}/frontengine.egg-info/dependency_links.txt +0 -0
- {frontengine-1.0.30 → frontengine-1.0.31}/frontengine.egg-info/entry_points.txt +0 -0
- {frontengine-1.0.30 → frontengine-1.0.31}/frontengine.egg-info/requires.txt +0 -0
- {frontengine-1.0.30 → frontengine-1.0.31}/frontengine.egg-info/top_level.txt +0 -0
- {frontengine-1.0.30 → frontengine-1.0.31}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: frontengine
|
|
3
|
-
Version: 1.0.
|
|
3
|
+
Version: 1.0.31
|
|
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.
|
|
1898
|
+
if self._accepts_drop(event.mimeData()):
|
|
1693
1899
|
event.acceptProposedAction()
|
|
1694
1900
|
|
|
1695
1901
|
def dragMoveEvent(self, event) -> None:
|
|
1696
|
-
if self.
|
|
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
|
|
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
|
|
|
@@ -19,6 +19,10 @@ from frontengine.ui.page.utils import (
|
|
|
19
19
|
)
|
|
20
20
|
from frontengine.user_setting.user_setting_file import add_recent_file
|
|
21
21
|
from frontengine.utils.audio_meter.screen_audio import audio_level_provider_for_screen
|
|
22
|
+
from frontengine.utils.pet_chat.pet_chat_service import PetChatService
|
|
23
|
+
from frontengine.utils.focus_timer.focus_timer import (
|
|
24
|
+
PHASE_BREAK, PHASE_FOCUS, PHASE_LONG_BREAK, FocusTimer,
|
|
25
|
+
)
|
|
22
26
|
from frontengine.utils.logging.loggin_instance import front_engine_logger
|
|
23
27
|
from frontengine.utils.multi_language.language_wrapper import language_wrapper
|
|
24
28
|
|
|
@@ -42,6 +46,14 @@ class PetSettingUI(QWidget):
|
|
|
42
46
|
self.tag_game = PetTagGame()
|
|
43
47
|
self.tag_timer = QTimer(self)
|
|
44
48
|
self.tag_timer.timeout.connect(self._tick_tag_game)
|
|
49
|
+
# 專注計時:寵物在每個階段開始時說一句,並在旁邊陪著
|
|
50
|
+
# Focus timer: the pet announces each phase and keeps you company.
|
|
51
|
+
self.focus_timer = FocusTimer()
|
|
52
|
+
self.focus_tick_timer = QTimer(self)
|
|
53
|
+
self.focus_tick_timer.timeout.connect(self._tick_focus_timer)
|
|
54
|
+
# AI 對話服務(懶建立,沒有金鑰就自動不可用)
|
|
55
|
+
# Lazily built chat service; reports itself unavailable without a key.
|
|
56
|
+
self._chat_service = None
|
|
45
57
|
|
|
46
58
|
# Choose file
|
|
47
59
|
self.choose_file_button = QPushButton(
|
|
@@ -90,6 +102,27 @@ class PetSettingUI(QWidget):
|
|
|
90
102
|
self.tag_checkbox = QCheckBox(
|
|
91
103
|
language_wrapper.language_word_dict.get("pet_tag_label", "Play tag with each other"))
|
|
92
104
|
self.tag_checkbox.toggled.connect(self._on_tag_toggled)
|
|
105
|
+
self.speech_checkbox = QCheckBox(
|
|
106
|
+
language_wrapper.language_word_dict.get("pet_speech_label", "Speak out loud"))
|
|
107
|
+
self.chat_checkbox = QCheckBox(
|
|
108
|
+
language_wrapper.language_word_dict.get("pet_chat_label", "AI chat (needs API key)"))
|
|
109
|
+
self.chat_checkbox.setToolTip(
|
|
110
|
+
language_wrapper.language_word_dict.get(
|
|
111
|
+
"pet_chat_hint",
|
|
112
|
+
"Reads the ANTHROPIC_API_KEY environment variable; the key is never stored."))
|
|
113
|
+
|
|
114
|
+
# Focus timer (pomodoro) controls
|
|
115
|
+
self.focus_label = QLabel(
|
|
116
|
+
language_wrapper.language_word_dict.get("pet_focus_label", "Focus timer (min)"))
|
|
117
|
+
self.focus_minutes_combobox = QComboBox()
|
|
118
|
+
self.focus_minutes_combobox.addItems(["15", "20", "25", "30", "45", "50"])
|
|
119
|
+
self.focus_minutes_combobox.setCurrentText("25")
|
|
120
|
+
self.break_minutes_combobox = QComboBox()
|
|
121
|
+
self.break_minutes_combobox.addItems(["3", "5", "10", "15"])
|
|
122
|
+
self.break_minutes_combobox.setCurrentText("5")
|
|
123
|
+
self.focus_button = QPushButton(
|
|
124
|
+
language_wrapper.language_word_dict.get("pet_focus_start", "Start focus"))
|
|
125
|
+
self.focus_button.clicked.connect(self.toggle_focus_session)
|
|
93
126
|
|
|
94
127
|
# Target monitor + sound volume
|
|
95
128
|
self.target_monitor_label = QLabel(
|
|
@@ -144,7 +177,13 @@ class PetSettingUI(QWidget):
|
|
|
144
177
|
self.grid_layout.addWidget(self.volume_combobox, 7, 2)
|
|
145
178
|
self.grid_layout.addWidget(self.audio_react_checkbox, 7, 0)
|
|
146
179
|
self.grid_layout.addWidget(self.tag_checkbox, 7, 1)
|
|
147
|
-
self.grid_layout.addWidget(self.
|
|
180
|
+
self.grid_layout.addWidget(self.speech_checkbox, 7, 2)
|
|
181
|
+
self.grid_layout.addWidget(self.focus_label, 8, 0)
|
|
182
|
+
self.grid_layout.addWidget(self.focus_minutes_combobox, 8, 1)
|
|
183
|
+
self.grid_layout.addWidget(self.break_minutes_combobox, 8, 2)
|
|
184
|
+
self.grid_layout.addWidget(self.focus_button, 9, 0)
|
|
185
|
+
self.grid_layout.addWidget(self.chat_checkbox, 9, 1)
|
|
186
|
+
self.grid_layout.addWidget(self.drop_hint_label, 10, 0, 1, 3)
|
|
148
187
|
|
|
149
188
|
def _spawn_pet(self) -> None:
|
|
150
189
|
"""建立、顯示並開始移動一隻寵物(供 Start 與右鍵複製共用)。"""
|
|
@@ -164,6 +203,10 @@ class PetSettingUI(QWidget):
|
|
|
164
203
|
)
|
|
165
204
|
pet.clone_requested.connect(self._spawn_pet)
|
|
166
205
|
pet.set_peers_provider(lambda me=pet: self._peer_centers(me))
|
|
206
|
+
if self.speech_checkbox.isChecked():
|
|
207
|
+
pet.set_speech_enabled(True)
|
|
208
|
+
if self.chat_checkbox.isChecked():
|
|
209
|
+
pet.set_chat_service(self.chat_service())
|
|
167
210
|
geometry = self._target_geometry()
|
|
168
211
|
if self.audio_react_checkbox.isChecked():
|
|
169
212
|
# 跟隨寵物所在螢幕的音源(比對不到就用系統預設輸出裝置)
|
|
@@ -179,6 +222,63 @@ class PetSettingUI(QWidget):
|
|
|
179
222
|
bounds = geometry[0]
|
|
180
223
|
pet.start_moving((bounds.left(), bounds.top(), bounds.right(), bounds.bottom()))
|
|
181
224
|
|
|
225
|
+
def chat_service(self) -> PetChatService:
|
|
226
|
+
"""共用的 AI 對話服務(懶建立;沒有金鑰時它自己會回報不可用)。"""
|
|
227
|
+
if self._chat_service is None:
|
|
228
|
+
self._chat_service = PetChatService()
|
|
229
|
+
return self._chat_service
|
|
230
|
+
|
|
231
|
+
# --- focus timer -----------------------------------------------------
|
|
232
|
+
FOCUS_TICK_MS = 1000
|
|
233
|
+
|
|
234
|
+
def toggle_focus_session(self) -> None:
|
|
235
|
+
"""開始或停止一次專注(沒有寵物時仍可計時,只是沒人陪你)。"""
|
|
236
|
+
if self.focus_timer.running:
|
|
237
|
+
self.stop_focus_session()
|
|
238
|
+
else:
|
|
239
|
+
self.start_focus_session()
|
|
240
|
+
|
|
241
|
+
def start_focus_session(self) -> None:
|
|
242
|
+
"""依目前設定開始專注,並讓寵物宣布 / Start focusing; the pet announces it."""
|
|
243
|
+
self.focus_timer.focus_minutes = int(self.focus_minutes_combobox.currentText())
|
|
244
|
+
self.focus_timer.break_minutes = int(self.break_minutes_combobox.currentText())
|
|
245
|
+
self.focus_timer.start()
|
|
246
|
+
front_engine_logger.info(
|
|
247
|
+
f"[PetSettingUI] focus started | focus={self.focus_timer.focus_minutes}, "
|
|
248
|
+
f"break={self.focus_timer.break_minutes}"
|
|
249
|
+
)
|
|
250
|
+
self.focus_tick_timer.start(self.FOCUS_TICK_MS)
|
|
251
|
+
self._announce_focus("focus_start")
|
|
252
|
+
self.focus_button.setText(
|
|
253
|
+
language_wrapper.language_word_dict.get("pet_focus_stop", "Stop focus"))
|
|
254
|
+
|
|
255
|
+
def stop_focus_session(self) -> None:
|
|
256
|
+
"""停止專注 / Stop the focus session."""
|
|
257
|
+
front_engine_logger.info("[PetSettingUI] focus stopped")
|
|
258
|
+
self.focus_timer.stop()
|
|
259
|
+
self.focus_tick_timer.stop()
|
|
260
|
+
self.focus_button.setText(
|
|
261
|
+
language_wrapper.language_word_dict.get("pet_focus_start", "Start focus"))
|
|
262
|
+
|
|
263
|
+
def _tick_focus_timer(self) -> None:
|
|
264
|
+
"""每秒推進一次;換階段時讓寵物說話。"""
|
|
265
|
+
phase = self.focus_timer.tick()
|
|
266
|
+
if phase is None:
|
|
267
|
+
return
|
|
268
|
+
if phase == PHASE_FOCUS:
|
|
269
|
+
self._announce_focus("focus_back")
|
|
270
|
+
elif phase == PHASE_BREAK:
|
|
271
|
+
self._announce_focus("focus_break")
|
|
272
|
+
elif phase == PHASE_LONG_BREAK:
|
|
273
|
+
self._announce_focus("focus_done")
|
|
274
|
+
|
|
275
|
+
def _announce_focus(self, pool_name: str) -> None:
|
|
276
|
+
"""讓所有寵物說出該階段的台詞(沒有寵物就安靜略過)。"""
|
|
277
|
+
for pet in self._alive_pets():
|
|
278
|
+
pool = pet._messages.get(pool_name) or []
|
|
279
|
+
if pool:
|
|
280
|
+
pet.say(pool[0])
|
|
281
|
+
|
|
182
282
|
def _target_geometry(self):
|
|
183
283
|
"""回傳 (available_geometry, screen) 依所選目標螢幕,否則主螢幕。"""
|
|
184
284
|
screens = QGuiApplication.screens()
|
|
@@ -325,6 +425,10 @@ class PetSettingUI(QWidget):
|
|
|
325
425
|
"volume": self.volume_combobox.currentText(),
|
|
326
426
|
"audio_react": self.audio_react_checkbox.isChecked(),
|
|
327
427
|
"tag": self.tag_checkbox.isChecked(),
|
|
428
|
+
"speech": self.speech_checkbox.isChecked(),
|
|
429
|
+
"chat": self.chat_checkbox.isChecked(),
|
|
430
|
+
"focus_minutes": self.focus_minutes_combobox.currentText(),
|
|
431
|
+
"break_minutes": self.break_minutes_combobox.currentText(),
|
|
328
432
|
}
|
|
329
433
|
|
|
330
434
|
def set_state(self, state: dict) -> None:
|
|
@@ -364,3 +468,13 @@ class PetSettingUI(QWidget):
|
|
|
364
468
|
self.audio_react_checkbox.setChecked(bool(state["audio_react"]))
|
|
365
469
|
if "tag" in state:
|
|
366
470
|
self.tag_checkbox.setChecked(bool(state["tag"]))
|
|
471
|
+
if "speech" in state:
|
|
472
|
+
self.speech_checkbox.setChecked(bool(state["speech"]))
|
|
473
|
+
if "chat" in state:
|
|
474
|
+
self.chat_checkbox.setChecked(bool(state["chat"]))
|
|
475
|
+
for combobox, key in ((self.focus_minutes_combobox, "focus_minutes"),
|
|
476
|
+
(self.break_minutes_combobox, "break_minutes")):
|
|
477
|
+
if state.get(key) is not None:
|
|
478
|
+
index = combobox.findText(str(state[key]))
|
|
479
|
+
if index >= 0:
|
|
480
|
+
combobox.setCurrentIndex(index)
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""
|
|
2
|
+
番茄鐘/專注計時的純狀態機:專注 -> 休息 -> 專注…,每完成幾輪給一次長休息。
|
|
3
|
+
時間由注入的 now_provider 提供,因此完全可測試、不依賴 Qt。
|
|
4
|
+
|
|
5
|
+
A pure focus-timer (pomodoro) state machine: focus -> break -> focus…, with a
|
|
6
|
+
long break every few rounds. Time comes from an injected `now_provider`, so it
|
|
7
|
+
is fully testable and Qt-free.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from datetime import datetime, timedelta
|
|
12
|
+
from typing import Callable, Optional
|
|
13
|
+
|
|
14
|
+
PHASE_IDLE = "idle"
|
|
15
|
+
PHASE_FOCUS = "focus"
|
|
16
|
+
PHASE_BREAK = "break"
|
|
17
|
+
PHASE_LONG_BREAK = "long_break"
|
|
18
|
+
|
|
19
|
+
PHASES = (PHASE_IDLE, PHASE_FOCUS, PHASE_BREAK, PHASE_LONG_BREAK)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def clamp_minutes(value, fallback: int, minimum: int = 1, maximum: int = 180) -> int:
|
|
23
|
+
"""把分鐘數夾到合理範圍;無法解析時用 fallback。"""
|
|
24
|
+
try:
|
|
25
|
+
return max(minimum, min(maximum, int(float(value))))
|
|
26
|
+
except (TypeError, ValueError):
|
|
27
|
+
return fallback
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class FocusTimer:
|
|
31
|
+
"""
|
|
32
|
+
專注計時器:start() 進入專注,tick() 在時間到時自動換到下一個階段並回傳新階段
|
|
33
|
+
(沒換階段回傳 None)。完成 rounds 輪專注後給一次長休息。
|
|
34
|
+
|
|
35
|
+
Focus timer: start() begins a focus phase; tick() advances to the next phase
|
|
36
|
+
when the current one runs out and returns the new phase (None when nothing
|
|
37
|
+
changed). A long break follows every `rounds` focus phases.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(self, focus_minutes: int = 25, break_minutes: int = 5,
|
|
41
|
+
long_break_minutes: int = 15, rounds: int = 4,
|
|
42
|
+
now_provider: Callable[[], datetime] = datetime.now) -> None:
|
|
43
|
+
self.focus_minutes = clamp_minutes(focus_minutes, 25)
|
|
44
|
+
self.break_minutes = clamp_minutes(break_minutes, 5)
|
|
45
|
+
self.long_break_minutes = clamp_minutes(long_break_minutes, 15)
|
|
46
|
+
try:
|
|
47
|
+
self.rounds = max(1, int(rounds))
|
|
48
|
+
except (TypeError, ValueError):
|
|
49
|
+
self.rounds = 4
|
|
50
|
+
self._now = now_provider
|
|
51
|
+
self.phase = PHASE_IDLE
|
|
52
|
+
self.completed_focus = 0
|
|
53
|
+
self._phase_ends: Optional[datetime] = None
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def running(self) -> bool:
|
|
57
|
+
return self.phase != PHASE_IDLE
|
|
58
|
+
|
|
59
|
+
def phase_minutes(self, phase: str) -> int:
|
|
60
|
+
"""該階段的長度(分鐘)。"""
|
|
61
|
+
if phase == PHASE_FOCUS:
|
|
62
|
+
return self.focus_minutes
|
|
63
|
+
if phase == PHASE_BREAK:
|
|
64
|
+
return self.break_minutes
|
|
65
|
+
if phase == PHASE_LONG_BREAK:
|
|
66
|
+
return self.long_break_minutes
|
|
67
|
+
return 0
|
|
68
|
+
|
|
69
|
+
def start(self) -> str:
|
|
70
|
+
"""開始一次專注(重設輪數)/ Begin a focus phase, resetting the round count."""
|
|
71
|
+
self.completed_focus = 0
|
|
72
|
+
self._enter(PHASE_FOCUS)
|
|
73
|
+
return self.phase
|
|
74
|
+
|
|
75
|
+
def stop(self) -> None:
|
|
76
|
+
"""停止計時,回到閒置 / Stop and go back to idle."""
|
|
77
|
+
self.phase = PHASE_IDLE
|
|
78
|
+
self._phase_ends = None
|
|
79
|
+
|
|
80
|
+
def _enter(self, phase: str) -> None:
|
|
81
|
+
self.phase = phase
|
|
82
|
+
minutes = self.phase_minutes(phase)
|
|
83
|
+
self._phase_ends = self._now() + timedelta(minutes=minutes) if minutes else None
|
|
84
|
+
|
|
85
|
+
def remaining_seconds(self) -> float:
|
|
86
|
+
"""本階段剩餘秒數;閒置時為 0。"""
|
|
87
|
+
if self._phase_ends is None:
|
|
88
|
+
return 0.0
|
|
89
|
+
return max(0.0, (self._phase_ends - self._now()).total_seconds())
|
|
90
|
+
|
|
91
|
+
def next_phase(self) -> str:
|
|
92
|
+
"""算出目前階段結束後應該進入哪個階段(不改變狀態)。"""
|
|
93
|
+
if self.phase != PHASE_FOCUS:
|
|
94
|
+
return PHASE_FOCUS
|
|
95
|
+
if (self.completed_focus + 1) % self.rounds == 0:
|
|
96
|
+
return PHASE_LONG_BREAK
|
|
97
|
+
return PHASE_BREAK
|
|
98
|
+
|
|
99
|
+
def tick(self) -> Optional[str]:
|
|
100
|
+
"""
|
|
101
|
+
推進計時:時間未到回傳 None,時間到則切換到下一階段並回傳其名稱。
|
|
102
|
+
Advance the timer, returning the new phase when one just ended.
|
|
103
|
+
"""
|
|
104
|
+
if not self.running or self._phase_ends is None:
|
|
105
|
+
return None
|
|
106
|
+
if self._now() < self._phase_ends:
|
|
107
|
+
return None
|
|
108
|
+
upcoming = self.next_phase()
|
|
109
|
+
if self.phase == PHASE_FOCUS:
|
|
110
|
+
self.completed_focus += 1
|
|
111
|
+
self._enter(upcoming)
|
|
112
|
+
return upcoming
|
|
@@ -271,6 +271,21 @@ english_word_dict = {
|
|
|
271
271
|
"pet_chatter_fed_hard": "Too hard to chew!|Ouch, my teeth!|That is not food!|Yuck (>_<)",
|
|
272
272
|
"pet_chatter_away": "Still there?|I'll nap till you're back~|Zzz...|Take your time!",
|
|
273
273
|
"pet_chatter_levelup": "Level up!|I'm growing!|We're getting closer!|Yay, thank you!",
|
|
274
|
+
"pet_speech_label": "Speak out loud",
|
|
275
|
+
"pet_focus_label": "Focus timer (min)",
|
|
276
|
+
"pet_focus_start": "Start focus",
|
|
277
|
+
"pet_focus_stop": "Stop focus",
|
|
278
|
+
"pet_chatter_fetch": "Fetch!|On it!|Opening that for you!|Leave it to me!",
|
|
279
|
+
"pet_chatter_focus_start": "Focus time!|Let's concentrate!|I'll wait right here.|You've got this!",
|
|
280
|
+
"pet_chatter_focus_break": "Break time!|Stretch a little!|Rest your eyes.|Water break?",
|
|
281
|
+
"pet_chatter_focus_back": "Back to it!|Round two!|Let's keep going.|Ready when you are.",
|
|
282
|
+
"pet_chatter_focus_done": "Long break earned!|Great run!|Take a proper rest.|Well done!",
|
|
283
|
+
"pet_chat_label": "AI chat (needs API key)",
|
|
284
|
+
"pet_chat_hint": "Reads the ANTHROPIC_API_KEY environment variable; the key is never stored.",
|
|
285
|
+
"pet_chat_menu": "Talk to me...",
|
|
286
|
+
"pet_chat_prompt": "Say something:",
|
|
287
|
+
"pet_chat_thinking": "Hmm, let me think...",
|
|
288
|
+
"pet_chat_failed": "I couldn't reach my thoughts just now.",
|
|
274
289
|
"pet_chatter_costume": "New look!|How do I look?|Nice fit!|I like this one!",
|
|
275
290
|
"pet_chatter_tag": "You're it!|Caught you!|My turn to chase!|Hey, no fair!",
|
|
276
291
|
"pet_tag_label": "Play tag with each other",
|
{frontengine-1.0.30 → frontengine-1.0.31}/frontengine/utils/multi_language/traditional_chinese.py
RENAMED
|
@@ -272,6 +272,21 @@ traditional_chinese_word_dict = {
|
|
|
272
272
|
"pet_chatter_fed_hard": "太硬了咬不動!|我的牙齒啊!|這不是食物吧!|嘔 (>_<)",
|
|
273
273
|
"pet_chatter_away": "還在嗎?|我先打個盹等你回來~|Zzz…|慢慢來沒關係!",
|
|
274
274
|
"pet_chatter_levelup": "升級了!|我長大了!|我們更親近了!|耶,謝謝你!",
|
|
275
|
+
"pet_speech_label": "把台詞念出來",
|
|
276
|
+
"pet_focus_label": "專注計時(分鐘)",
|
|
277
|
+
"pet_focus_start": "開始專注",
|
|
278
|
+
"pet_focus_stop": "停止專注",
|
|
279
|
+
"pet_chatter_fetch": "我去開!|交給我!|馬上幫你打開!|收到!",
|
|
280
|
+
"pet_chatter_focus_start": "專注時間到!|一起集中精神!|我在旁邊陪你。|你可以的!",
|
|
281
|
+
"pet_chatter_focus_break": "休息一下!|起來動一動!|讓眼睛休息。|喝點水吧?",
|
|
282
|
+
"pet_chatter_focus_back": "回到工作囉!|第二回合!|繼續加油。|準備好就開始。",
|
|
283
|
+
"pet_chatter_focus_done": "可以放長假囉!|這輪很棒!|好好休息一下。|辛苦了!",
|
|
284
|
+
"pet_chat_label": "AI 對話(需要 API 金鑰)",
|
|
285
|
+
"pet_chat_hint": "讀取環境變數 ANTHROPIC_API_KEY;金鑰不會被儲存。",
|
|
286
|
+
"pet_chat_menu": "跟我說話…",
|
|
287
|
+
"pet_chat_prompt": "想說什麼:",
|
|
288
|
+
"pet_chat_thinking": "嗯…讓我想一下…",
|
|
289
|
+
"pet_chat_failed": "我現在想不出話來耶。",
|
|
275
290
|
"pet_chatter_costume": "換新裝!|好看嗎?|很合身耶!|我喜歡這套!",
|
|
276
291
|
"pet_chatter_tag": "換你當鬼!|抓到你了!|輪到我追了!|喂,不公平啦!",
|
|
277
292
|
"pet_tag_label": "寵物玩鬼抓人",
|