QingNiao/memoria.py
2025-07-08 00:48:24 +08:00

296 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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