296 lines
11 KiB
Python
296 lines
11 KiB
Python
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() |