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()