feat(interface): 组件自动聚焦与键盘操作改进

This commit is contained in:
2026-04-22 22:54:25 +08:00
parent c2a1867c49
commit f50c19ba82
24 changed files with 173 additions and 12258 deletions

View File

@@ -51,4 +51,4 @@ class ConfigContext:
return self
def __exit__(self, exc_type, exc_val, exc_tb):
config_var.reset(self._token) # type: ignore
config_var.reset(self._token) # type: ignore

View File

@@ -6,6 +6,11 @@
height: 3;
}
#analysis {
margin: 0;
padding: 0;
}
.repo-list-item {
layout: grid;
grid-size: 1;

View File

@@ -19,8 +19,9 @@ import sys
class AboutScreen(Screen):
BINDINGS = [
("q", "go_back", "返回"),
("z", "go_back", "关于"),
]
SUB_TITLE = "关于"
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)

View File

@@ -6,7 +6,7 @@ from pathlib import Path
from textual.app import ComposeResult
from textual.containers import ScrollableContainer, Horizontal, Vertical
from textual.screen import Screen
from textual.widgets import Button, Footer, Header, Label, ListItem, ListView, Static
from textual.widgets import Button, Footer, Header, Label, ListItem, ListView, Static, Markdown
from textual import events, on
from textual.reactive import reactive
@@ -74,10 +74,11 @@ class DashboardScreen(Screen):
),
id="header",
)
yield ListView(id="repo_list", classes="repo-list") # 单元集选择
from heurams.services.attic import Attic
a = Attic('ana', {'totaltime': 0, 'openpuzzles': 0, 'puzzles_err': 0})
yield Label(f"版本 {version.ver} {version.stage.capitalize()}") # 版本信息
yield Label(f"{round(a.data['totaltime'], 2)} 秒内处理了 {a.data['openpuzzles']} 个谜题, 正确率{'无法求解' if not a.data['openpuzzles'] else ' ' + str(round(100 * (1 - a.data['puzzles_err']/a.data['openpuzzles']), 2)) + '%'}, 平均速度{'无法求解' if not a.data['totaltime'] else ' ' + str(round(a.data['openpuzzles']/a.data['totaltime'], 2)) + '个/s'}", id='analysis') # 版本信息
yield Footer()
@on(events.ScreenResume)
@@ -201,6 +202,5 @@ class DashboardScreen(Screen):
logger.debug(f"event.button.id: {event.button.id}")
if event.button.id.startswith("slaunch_repo_"): # type: ignore
from .preparation import launch
launch(repo=self.repolink[event.button.id.lstrip("slaunch_repo_")], app=self.app, scheduled_num=-1) # type: ignore
launch(repo=self.repolink[event.button.id.removeprefix("slaunch_repo_")], app=self.app, scheduled_num=-1) # type: ignore
# TODO: 这样启动的记忆实例的状态机无法绑定到 PreparationScreen 中

View File

@@ -16,12 +16,10 @@ from heurams.kernel.reactor import *
from heurams.services.favorite_service import favorite_manager
from heurams.services.logger import get_logger
from .. import shim
logger = get_logger(__name__)
class MemScreen(Screen):
BINDINGS = [
("q", "go_back", "返回"),
@@ -29,6 +27,7 @@ class MemScreen(Screen):
("d", "toggle_dark", ""),
("v", "play_voice", "朗读"),
("*", "toggle_favorite", "收藏"),
("r", "resume_mark"),
("n", "block_prompt"),
("s", "block_prompt"),
("z", "block_prompt"),
@@ -60,10 +59,13 @@ class MemScreen(Screen):
@on(events.ScreenResume)
def post_active(self, event):
from heurams.interface import shim
shim.set_term_title(f"{self.app.TITLE} - {self.SUB_TITLE}")
def compose(self) -> ComposeResult:
import time
from heurams.services.attic import Attic
a = Attic('ana', {'openqueue': 0})
a.data['openqueue'] += 1
if config_var.get()['interface']['global']['show_header']:
yield Header(show_clock=config_var.get()['interface']['global']['clock_on_header'])
with ScrollableContainer():
@@ -78,6 +80,10 @@ class MemScreen(Screen):
def on_mount(self):
self.expander = self.procession.get_expander()
from heurams.services.attic import Attic
import time
a = Attic('ana', {'last': time.time()})
a.data['last'] = time.time()
self.mount_puzzle()
self.update_display()
@@ -107,6 +113,7 @@ class MemScreen(Screen):
def mount_puzzle(self):
"""挂载当前谜题组件"""
from heurams.services.attic import Attic
if self.procession.route == RouterState.FINISHED:
self.mount_finished_widget()
return
@@ -117,6 +124,8 @@ class MemScreen(Screen):
def mount_finished_widget(self):
"""挂载已完成组件"""
a = Attic('ana', {'finished': 0})
a.data['finished'] += 1
container = self.query_one("#puzzle_container")
for i in container.children:
i.remove()
@@ -168,6 +177,13 @@ class MemScreen(Screen):
if self.expander.state == "retronly":
self.forward_atom(self.expander.get_quality())
self.update_state()
from heurams.services.attic import Attic
a = Attic('ana', {'openpuzzles': 0})
a = Attic('ana', {'totaltime': 0})
a.data['openpuzzles'] += 1
import time
a.data['totaltime'] += time.time() - a.data['last']
a.data['last'] = time.time()
self.mount_puzzle()
self.update_display()
@@ -187,6 +203,8 @@ class MemScreen(Screen):
logger.debug(f"Quality: {quality}")
self.atom_reporter(quality)
if quality <= 3:
a = Attic('ana', {'puzzles_err': 0})
a.data['puzzles_err'] += 1
self.procession.append()
self.update_state() # 刷新状态
self.procession.forward(1)
@@ -241,3 +259,11 @@ class MemScreen(Screen):
def action_block_prompt(self):
self.app.notify("功能在记忆界面中不可用, 完成或返回后再试", severity="error")
def action_resume_mark(self):
from heurams.services.attic import Attic
import time
a = Attic('ana')
l = a.data['last']
a.data['last'] = time.time()
self.app.notify(f"时间恢复已修正: {l} -> {a.data['last']}")

View File

@@ -51,7 +51,9 @@ class PreparationScreen(Screen):
shim.set_term_title(f"{self.app.TITLE} - {self.SUB_TITLE}")
def compose(self) -> ComposeResult:
from heurams.services.attic import Attic
a = Attic('ana', {'openpre': 0})
a.data['openpre'] += 1
if config_var.get()['interface']['global']['show_header']:
yield Header(show_clock=config_var.get()['interface']['global']['clock_on_header'])
with ScrollableContainer(id="main_container"):

View File

@@ -32,6 +32,7 @@ class SettingScreen(Screen):
SUB_TITLE = "设置"
BINDINGS = [
("q", "go_back", "返回"),
("s", "go_back", "设置"),
]
CSS_PATH = rootdir / "interface" / "css" / "screens" / "setting.tcss"

View File

@@ -5,6 +5,7 @@ from typing import TypedDict
from textual.containers import ScrollableContainer
from textual.widget import Widget
from textual.widgets import Button, Label, Markdown
from textual.events import Key
import heurams.kernel.particles as pt
import heurams.kernel.puzzles as pz
@@ -50,6 +51,7 @@ class ClozePuzzle(BasePuzzleWidget):
self.hashtable = {}
self.alia = alia
self._load()
self.btn_shortcuts = {}
self.hashmap = dict()
def _load(self):
@@ -67,15 +69,24 @@ class ClozePuzzle(BasePuzzleWidget):
yield Label(self.puzzle.wording, id="sentence")
yield Markdown(f"> {self.listprint(self.inputlist)}", id="inputpreview")
# 渲染当前问题的选项
with ScrollableContainer(id="btn-container"):
with ScrollableContainer(id="btn-container") as s:
c = 0
for i in self.ans:
h = str(hash(i))
if hash(i) in self.hashmap.keys():
continue
c += 1
self.hashmap[h] = i
btnid = f"sel000-{h}"
logger.debug(f"建立按钮 {btnid}")
yield Button(i, id=f"{btnid}")
self.btn_shortcuts[f'{c}'] = btnid
yield Button(f'[{c}] {i}', id=f"{btnid}")
s.focus()
yield Button("退格", id="delete")
self.btn_shortcuts[f'0'] = 'delete'
self.btn_shortcuts[f'backspace'] = 'delete'
self.btn_shortcuts[f'delete'] = 'delete'
def listprint(self, lst):
s = ""
@@ -117,3 +128,10 @@ class ClozePuzzle(BasePuzzleWidget):
pass
else:
self.atom.minimize(rating)
def on_key(self, event: Key) -> None:
self.notify(event.key)
if event.key in self.btn_shortcuts:
btn_id = self.btn_shortcuts.get(event.key)
btn_id = '#' + btn_id
self.query_one(btn_id, Button).press()

View File

@@ -9,7 +9,7 @@ import heurams.kernel.particles as pt
import heurams.kernel.puzzles as pz
from heurams.services.hasher import hash
from heurams.services.logger import get_logger
from textual.events import Key
from .base_puzzle_widget import BasePuzzleWidget
logger = get_logger(__name__)
@@ -51,6 +51,7 @@ class MCQPuzzle(BasePuzzleWidget):
self.hashmap = dict()
self.cursor = 0
self.atom = atom
self.btn_shortcuts = {}
self._load()
def _load(self):
@@ -75,14 +76,24 @@ class MCQPuzzle(BasePuzzleWidget):
yield Label(f"当前输入: {self.inputlist}", id="inputpreview")
# 渲染当前问题的选项
with ScrollableContainer(id="btn-container"):
c = 0
with ScrollableContainer(id="btn-container") as s:
for i in current_options:
self.hashmap[str(hash(i))] = i
btnid = f"sel{str(self.cursor).zfill(3)}-{hash(i)}"
if i in [' ', '']:
continue
c += 1
h = str(hash(i))
self.hashmap[h] = i
btnid = f"sel{str(self.cursor).zfill(3)}-{h}"
logger.debug(f"建立按钮 {btnid}")
yield Button(i, id=f"{btnid}")
self.btn_shortcuts[f'{c}'] = f'{btnid}'
yield Button(f'[{c}] ' + i, id=f"{btnid}")
s.focus()
yield Button("退格", id="delete")
yield Button("退格", id="delete")
self.btn_shortcuts['0'] = f'delete'
self.btn_shortcuts['delete'] = f'delete'
self.btn_shortcuts['backspace'] = f'delete'
def update_display(self, error=0):
# 更新预览标签
@@ -140,20 +151,25 @@ class MCQPuzzle(BasePuzzleWidget):
for child in container.children
if hasattr(child, "id") and child.id and child.id.startswith("sel")
]
container.focus()
for button in buttons_to_remove:
logger.info(button)
container.remove_children("#" + button.id) # type: ignore
# 添加当前题目的选项按钮
c = 0
current_question_index = len(self.inputlist)
if current_question_index < len(self.puzzle.options):
current_options = self.puzzle.options[current_question_index]
for option in current_options:
if option in ['', ' ']:
continue
c += 1
button_id = f"sel{str(self.cursor).zfill(3)}-{hash(option)}"
if button_id not in self.hashmap:
self.hashmap[button_id[7:]] = option
new_button = Button(option, id=button_id)
new_button = Button(f"[{c}] " + option, id=button_id)
self.btn_shortcuts[f"{c}"] = button_id
container.mount(new_button)
def handler(self, rating):
@@ -161,3 +177,10 @@ class MCQPuzzle(BasePuzzleWidget):
pass
else:
self.atom.minimize(rating)
def on_key(self, event: Key) -> None:
self.notify(event.key)
if event.key in self.btn_shortcuts:
btn_id = self.btn_shortcuts.get(event.key)
btn_id = '#' + btn_id
self.query_one(btn_id, Button).press()

View File

@@ -49,7 +49,7 @@ class Recognition(BasePuzzleWidget):
def compose(self):
from heurams.context import config_var
autovoice = config_var.get()["interface"]["widgets"]["autovoice"]
autovoice = config_var.get()["interface"]["widgets"]['recognition']["autovoice"]
if autovoice:
self.screen.action_play_voice() # type: ignore
cfg: RecognitionConfig = self.atom.registry["nucleon"]["puzzles"][self.alia]
@@ -96,8 +96,9 @@ class Recognition(BasePuzzleWidget):
if isinstance(item, str):
yield Markdown(item)
with Center():
yield Button("我已知晓", id="ok")
with Center() as c:
with Button("我已知晓", id="ok") as b:
b.focus()
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "ok":

View File

@@ -72,7 +72,7 @@ class MCQPuzzle(BasePuzzle):
# 确保至少有4个干扰项
while len(self.jammer) < 4:
self.jammer.append(" " * (4 - len(self.jammer)))
self.jammer.append("")
unique_jammers = set(jammer + list(self.mapping.values()))

View File

@@ -0,0 +1,54 @@
# Attic 服务
import pickle as pkl
from heurams.services.logger import get_logger
from heurams.context import config_var
from pathlib import Path
from heurams.services.hasher import get_md5
import atexit
from heurams.services import timer
from heurams.services.exceptions import WTFException
logger = get_logger(__name__)
def singleton(cls):
instances = {}
def get_instance(ident, default):
key = ident
if key not in instances:
instances[key] = cls(ident)
instances[key].patch_dict(default)
return instances[key]
return get_instance
atticdir = Path(config_var.get()['global']['paths']['misc']) / 'attics'
atticdir.mkdir(parents=True, exist_ok=True)
@singleton
class Attic:
def __init__(self, ident, default:dict={}):
self.ident = ident
self.ident = self.ident.replace('<DAYSTAMP>', str(timer.get_daystamp()))
self.ident = self.ident.replace('<TIMESTAMP>', str(timer.get_timestamp()))
if '<' in ident or '>' in ident:
raise WTFException
#self.ident = get_md5(self.ident)
self.pklpath = atticdir / f'{self.ident}.pkl'
atexit.register(self.save)
self.data = default
if self.pklpath.exists():
try:
self.load()
return
except:
self.pklpath.unlink(missing_ok=True)
self.pklpath.touch(exist_ok=True)
def patch_dict(self, dct):
self.data.update({k: v for k, v in dct.items() if k not in self.data})
def save(self):
with open(atticdir / f'{self.ident}.pkl', 'wb') as f:
pkl.dump(self.data, f)
def load(self):
with open(atticdir / f'{self.ident}.pkl', 'rb') as f:
self.data.update(dict(pkl.load(f)))

View File

@@ -62,8 +62,7 @@ class FavoriteManager:
def _get_file_path(self) -> Path:
"""获取收藏文件路径"""
config_path = Path(config_var.get()["global"]["paths"]["data"])
fav_path = config_path / "global" / "favorites.json"
fav_path = Path(config_var.get()["global"]["paths"]["misc"]) / "favorites.json5"
fav_path.parent.mkdir(parents=True, exist_ok=True)
return fav_path

View File

@@ -14,7 +14,8 @@ def get_md5(text):
def hash(text):
logger.debug(f"计算MD5-时间复合哈希, 输入`{text}`")
result = hashlib.md5(f"{text}{random.randint(0,1000)}".encode("utf-8")).hexdigest()
logger.debug("哈希结果: %s...", result[:8])
return result
#logger.debug(f"计算MD5-时间复合哈希, 输入`{text}`")
#result = hashlib.md5(f"{text}{random.randint(0,1000)}".encode("utf-8")).hexdigest()
#logger.debug("哈希结果: %s...", result[:8])
#return result
return get_md5(text)