commit 531971966177c423bdc0446b25f0ce49579eb68f Author: Wang Zhiyu Date: Tue Jul 8 00:48:24 2025 +0800 上传文件至 / diff --git a/main.py b/main.py new file mode 100644 index 0000000..d3ff9c4 --- /dev/null +++ b/main.py @@ -0,0 +1,121 @@ +import os +from pathlib import Path +import memoria +from textual.app import App, ComposeResult +from textual.widgets import Header, Footer, ListView, ListItem, Label, Static, Button +from textual.containers import Container +from textual.screen import Screen + +ver = 0.01 + +# --- MemApp 学习屏幕 --- +class MemAppLearningScreen(Screen): + BINDINGS = [ + ("q", "go_back", "返回"), + ("escape", "quit_app", "退出") + ] + + def __init__(self, file_to_learn: str, history_file: str, name: str | None = None, id: str | None = None, classes: str | None = None) -> None: + super().__init__(name=name, id=id, classes=classes) + self.file_to_learn = file_to_learn + self.history_file = history_file + + def compose(self) -> ComposeResult: + yield Header() + with Container(id="learning_screen_container"): + yield Label(f"正在学习文件: [b]{Path(self.file_to_learn).name}[/b]", classes="learning-info") + yield Label(f"历史文件: [b]{Path(self.history_file).name}[/b]", classes="learning-info") + yield Label("\n[i]点击 '开始记忆' 进入学习模式.[/i]", classes="placeholder-message") + yield Static("\n按 [b]Q[/b] 返回;按 [b]ESC[/b] 退出.", classes="instructions") + yield Button("开始记忆", id="start_memorizing_button", variant="primary", classes="start-button") + yield Static(f"\n以下是全文:\n", classes="read-info") + with open(self.file_to_learn, 'r', encoding='UTF-8') as f: + for i in f.readlines(): + yield Static(' ' + i, classes="read-info") + yield Footer() + + def action_go_back(self): + self.app.pop_screen() + + def action_quit_app(self): + self.app.exit() + + def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "start_memorizing_button": + self.app.push_screen( + memoria.MemScreen( + file_to_learn=self.file_to_learn, + history_file=self.history_file + ) + ) + +# --- 文件选择屏幕 --- +class FileSelectionScreen(Screen): + global ver + def compose(self) -> ComposeResult: + yield Header() + yield Container( + Label(f'欢迎使用 "青鸟" 辅助记忆软件, 版本 {ver}', classes="title-label"), + Label("选择要学习的文件:", classes="title-label"), + ListView(id="file-list", classes="file-list-view") + ) + yield Footer() + + def on_mount(self) -> None: + Path("./history").mkdir(parents=True, exist_ok=True) + Path("./library").mkdir(parents=True, exist_ok=True) + + file_list_widget = self.query_one("#file-list", ListView) + library_path = Path("./library") + txt_files = sorted([f.name for f in library_path.iterdir() if f.suffix == ".txt"]) + + if txt_files: + for filename in txt_files: + file_list_widget.append(ListItem(Label(filename))) + else: + file_list_widget.append(ListItem(Static("在 ./library/ 中未找到任何 .txt 文件。请放置文件后重启应用。"))) + file_list_widget.disabled = True + + def on_list_view_selected(self, event: ListView.Selected) -> None: + if not isinstance(event.item, ListItem): + self.notify("无法选择此项。", severity="error") + return + + selected_label = event.item.query_one(Label) + if "未找到任何 .txt 文件" in str(selected_label.renderable): + self.notify("请先在 `./library/` 目录中放置 .txt 文件。", severity="warning") + return + + selected_filename = str(selected_label.renderable) + file_to_learn_path = Path("./library") / selected_filename + + history_file_name = f"{Path(selected_filename).stem}_history.json" + history_file_path = Path("./history") / history_file_name + + self.notify(f"已选择: {selected_filename}", timeout=2) + + self.app.push_screen(MemAppLearningScreen( + file_to_learn=str(file_to_learn_path), + history_file=str(history_file_path) + )) + + def action_quit_app(self) -> None: + self.app.exit() + +# --- 主 Textual 应用类 --- +class MemAppLauncher(App): + CSS_PATH = "styles.css" + TITLE = '青鸟 辅助记忆程序' + BINDINGS = [("escape", "quit", "退出"), ("d", "toggle_dark", "改变色调")] + SCREENS = { + "file_selection_screen": FileSelectionScreen, + } + + def on_mount(self) -> None: + self.action_toggle_dark() + self.push_screen("file_selection_screen") + +if __name__ == "__main__": + css_path = Path("styles_dashboard.css") + app = MemAppLauncher() + app.run() diff --git a/memoria.py b/memoria.py new file mode 100644 index 0000000..4691539 --- /dev/null +++ b/memoria.py @@ -0,0 +1,296 @@ +import random +import json +import datetime +from pathlib import Path +import cn2an + +from textual.app import App, ComposeResult # App 导入只是为了类型提示,MemScreen 继承自 Screen +from textual.containers import Container +from textual.widgets import Button, Header, Footer, Static, Label +from textual.color import Color +from textual.screen import Screen # 核心:导入 Screen + + +# --- 辅助函数和类 (这些通常会放在 MemScreen 类定义的上方或单独的模块中) --- + +def load_sentences(filename: str = 'test.txt') -> list[str]: + """ + Loads and parses a text file into a list of sentences. + Each sentence is identified by '.', '?', or '!'. + If the file doesn't exist, it creates a default one. + + Args: + filename (str): The path to the text file. + + Returns: + list[str]: A list of sentences extracted from the file. + """ + try: + with open(filename, 'r', encoding='UTF-8') as f: + s = f.read() + sentences = [] + current_sentence = "" + for char in s: + if char in '.?!': + current_sentence += char + stripped_sentence = current_sentence.strip() + if stripped_sentence: + sentences.append(stripped_sentence) + current_sentence = "" + elif char == '\n': + pass + else: + if char == " " and not current_sentence: + pass + else: + current_sentence += char + stripped_sentence = current_sentence.strip() + if stripped_sentence: + sentences.append(stripped_sentence) + return [item for item in sentences if item] + except FileNotFoundError: + with open(filename, 'w', encoding='UTF-8') as f: + f.write("这是第一个句子。这是第二个吗?以及第三个!") + return load_sentences(filename) + +def get_daystamp() -> int: + """ + Returns the number of days since the epoch. + + Returns: + int: The current daystamp. + """ + return round(datetime.datetime.now().timestamp() // 86400) + +class History: + """ + Handles reading and writing the user's progress to a JSON file. + It tracks whether a sentence was remembered (1) or not (0) on a given day. + """ + def __init__(self, path: str = "history.json"): + """ + Initializes the History manager. + + Args: + path (str): The path to the history JSON file. + """ + self.path = Path(path) + self.history_data = self._read_from_file() + + def add(self, key: str, stat: int): + """ + Adds a new entry to the history. + + Args: + key (str): The sentence that was reviewed. + stat (int): The review result (1 for remembered, 0 for not). + """ + if key not in self.history_data: + self.history_data[key] = [] + self.history_data[key].append((get_daystamp(), stat)) + self._save_to_file() + + def _read_from_file(self) -> dict: + """ + Reads the history data from the JSON file. + + Returns: + dict: The loaded history data, or an empty dictionary if the file + does not exist or is invalid. + """ + if not self.path.exists(): + return {} + try: + with self.path.open('r', encoding="UTF-8") as file: + return json.loads(file.read()) + except (json.JSONDecodeError, FileNotFoundError): + return {} + + def _save_to_file(self): + """Saves the current history data to the JSON file.""" + with self.path.open('w', encoding="UTF-8") as file: + json.dump(self.history_data, file, indent=4, ensure_ascii=False) + + +# --- MemScreen 类定义 --- + +class MemScreen(Screen): + """ + 一个 Textual 屏幕,用于帮助用户通过重复记忆句子。 + 句子会分轮次呈现,未记住的句子会在后续轮次中重新出现。 + """ + # CSS_PATH 不再需要在此处定义,应由主 App 全局管理或在主 App 中指定 + BINDINGS = [ + ("d", "toggle_dark", "改变色调"), + ("q", "pop_screen", "返回主菜单") # 绑定到 pop_screen + ] + + def __init__( + self, + file_to_learn: str = 'test.txt', + history_file: str = 'history.json', + name: str | None = None, + id: str | None = None, + classes: str | None = None + ): + """ + 初始化 MemScreen。 + + Args: + file_to_learn (str): 包含要记忆句子的文本文件路径。 + history_file (str): 存储记忆历史的 JSON 文件路径。 + name (str | None): 屏幕的名称。 + id (str | None): 屏幕的 ID。 + classes (str | None): 屏幕的 CSS 类。 + """ + super().__init__(name=name, id=id, classes=classes) + self.file_to_learn = file_to_learn + self.all_sentences = load_sentences(self.file_to_learn) + + self.history = History(history_file) + + random.shuffle(self.all_sentences) + + self.round_number = 1 + self.current_round_sentences = list(self.all_sentences) + self.failed_in_this_round = [] + self.current_index = 0 + + # 如果初始没有句子,则标记为已完成 + self.is_finished = not self.all_sentences + + def compose(self) -> ComposeResult: + """ + 为屏幕的 UI 创建子组件。 + """ + # Header 和 Footer 仍然可以放在屏幕内部,如果每个屏幕都需要独立的 + yield Header(show_clock=True) + with Container(id="main_container"): + yield Label(self._get_round_text(), id="round_label") + yield Label("记住了吗?", id="question") + yield Static(self._get_current_sentence(), id="sentence") + yield Static("", id="feedback") # 用于显示“请重复记忆” + yield Label(self._get_progress_text(), id="progress") + with Container(id="button_container"): + yield Button("记住了", variant="success", id="yes") + yield Button("没记住", variant="error", id="no") + yield Footer() + + def on_mount(self) -> None: + """ + 屏幕首次挂载时调用。 + 检查句子文件是否为空,如果为空则显示消息。 + """ + if self.is_finished: + self._show_finished_screen("文件为空,没有句子可供学习。") + else: + # 确保 UI 在屏幕挂载时正确显示第一个句子 + self._update_ui() + + # --- 内部辅助方法 (私有方法,约定以 `_` 开头) --- + + def _get_round_text(self) -> str: + """返回当前轮次编号的格式化文本。""" + return f"第{cn2an.transform(str(self.round_number), 'an2cn')}轮复习" + + def _get_progress_text(self) -> str: + """返回当前轮次进度的格式化文本。""" + total = len(self.current_round_sentences) + # 确保在总数为0时不会出现除以零或显示“1/0”的情况 + current = min(self.current_index + 1, total) if total > 0 else 0 + return f"进程: {current} / {total}" + + def _get_current_sentence(self) -> str: + """返回当前要显示的句子,或者完成消息。""" + if not self.is_finished: + if self.current_round_sentences: + return self.current_round_sentences[self.current_index] + return "没有句子可供复习。" # 理论上不应该在 is_finished 为 False 时触发 + return "全部完成! ✨" + + def _start_next_round(self) -> None: + """ + 管理记忆过程的下一轮。 + 如果没有句子未记住,则会话完成。 + 否则,新一轮将从之前未记住的句子开始。 + """ + if not self.failed_in_this_round: + self.is_finished = True + self._show_finished_screen("🎉 已完成此文件的记忆 🎉") + else: + self.round_number += 1 + # 使用 set 来移除重复项,如果一个句子在同一轮中多次被标记为“没记住” + self.current_round_sentences = list(set(self.failed_in_this_round)) + random.shuffle(self.current_round_sentences) + self.failed_in_this_round = [] # 清空本轮失败列表,为下一轮做准备 + self.current_index = 0 + self._update_ui() + + def _update_ui(self) -> None: + """ + 用当前数据更新屏幕上所有动态组件。 + """ + self.query_one("#round_label", Label).update(self._get_round_text()) + self.query_one("#sentence", Static).update(self._get_current_sentence()) + self.query_one("#progress", Label).update(self._get_progress_text()) + self.query_one("#feedback", Static).update("") # 清除任何之前的反馈消息 + + def _show_finished_screen(self, message: str) -> None: + """ + 显示最终完成消息并禁用交互按钮。 + + Args: + message (str): 在完成屏幕上显示的消息。 + """ + self.query_one("#question").update(message) + self.query_one("#sentence").update("已经完成记忆任务") + self.query_one("#round_label").display = False # 隐藏轮次信息 + self.query_one("#progress").display = False # 隐藏进度信息 + self.query_one("#yes", Button).disabled = True # 禁用按钮 + self.query_one("#no", Button).disabled = True + + # --- 事件处理方法 (Textual 框架调用) --- + + def on_button_pressed(self, event: Button.Pressed) -> None: + """ + “记住了” (Yes) 和“没记住” (No) 按钮按下的事件处理器。 + 管理句子进展和历史记录。 + """ + if self.is_finished: + # 如果已经完成,则不再处理按钮事件 + return + + current_sentence = self._get_current_sentence() + feedback_label = self.query_one("#feedback", Static) + + if event.button.id == "no": + self.history.add(current_sentence, 0) # 记录为未记住 + if current_sentence not in self.failed_in_this_round: + self.failed_in_this_round.append(current_sentence) # 添加到本轮失败列表 + feedback_label.update("已经加入至复习, 请重复记忆") + return # IMPORTANT: 不会前进到下一个句子,用户需要再次确认 + + # 如果是“记住了”按钮被按下 + self.history.add(current_sentence, 1) # 记录为记住 + feedback_label.update("") # 清除反馈消息 + + self.current_index += 1 # 前进到下一个句子 + + if self.current_index >= len(self.current_round_sentences): + # 当前轮次所有句子都已处理,检查是否需要开始下一轮 + self._start_next_round() + else: + # 继续当前轮次的下一个句子 + self._update_ui() + + # --- 动作方法 (通过 BINDINGS 绑定) --- + + def action_toggle_dark(self) -> None: + """一个切换深色模式的动作。""" + # 访问主应用 (self.app) 的 dark 属性来切换主题 + self.app.action_toggle_dark() + + def action_pop_screen(self) -> None: + """一个弹出当前屏幕并返回到上一个屏幕的动作。""" + # Textual 的 Screen 管理方法,用于从屏幕堆栈中移除当前屏幕 + self.app.pop_screen() \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..a2d31e6 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,43 @@ +aiohappyeyeballs==2.6.1 +aiohttp==3.12.13 +aiohttp-jinja2==1.6 +aiosignal==1.4.0 +annotated-types==0.7.0 +anyio==4.9.0 +attrs==25.3.0 +certifi==2025.6.15 +click==8.2.1 +cn2an==0.5.23 +frozenlist==1.7.0 +h11==0.16.0 +httpcore==1.0.9 +httpx==0.28.1 +idna==3.10 +importlib_metadata==8.7.0 +Jinja2==3.1.6 +linkify-it-py==2.0.3 +markdown-it-py==3.0.0 +MarkupSafe==3.0.2 +mdit-py-plugins==0.4.2 +mdurl==0.1.2 +msgpack==1.1.1 +multidict==6.6.3 +platformdirs==4.3.8 +proces==0.1.7 +propcache==0.3.2 +pydantic==2.11.7 +pydantic_core==2.33.2 +Pygments==2.19.2 +rich==14.0.0 +sniffio==1.3.1 +textual==3.6.0 +textual-dev==1.7.0 +textual-serve==1.1.2 +textual-web==0.4.2 +tomli==2.2.1 +typing-inspection==0.4.1 +typing_extensions==4.14.1 +uc-micro-py==1.0.3 +xdg==6.0.0 +yarl==1.20.1 +zipp==3.23.0 diff --git a/serve.py b/serve.py new file mode 100644 index 0000000..bc69092 --- /dev/null +++ b/serve.py @@ -0,0 +1,3 @@ +from textual_serve.server import Server +server = Server("python3 main.py", title="辅助记忆程序", host="0.0.0.0") +server.serve() \ No newline at end of file diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..c888e86 --- /dev/null +++ b/styles.css @@ -0,0 +1,38 @@ + +Screen { + align: center middle; +} + +#main_container { + align: center middle; + width: 80%; + height: auto; + border: thick $primary-lighten-2; + padding: 2; +} + +#sentence { + content-align: center middle; + width: 100%; + height: 5; + margin-bottom: 2; +} + +#progress { + width: 100%; + content-align: center middle; + margin-bottom: 1; + color: $text-muted; +} + +#button_container { + align-horizontal: center; + width: 100%; + height: auto; +} + +Button { + width: 50%; + margin: 0 1; +} +