上传文件至 /
This commit is contained in:
commit
5319719661
121
main.py
Normal file
121
main.py
Normal file
@ -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()
|
296
memoria.py
Normal file
296
memoria.py
Normal file
@ -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()
|
43
requirements.txt
Normal file
43
requirements.txt
Normal file
@ -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
|
3
serve.py
Normal file
3
serve.py
Normal file
@ -0,0 +1,3 @@
|
||||
from textual_serve.server import Server
|
||||
server = Server("python3 main.py", title="辅助记忆程序", host="0.0.0.0")
|
||||
server.serve()
|
38
styles.css
Normal file
38
styles.css
Normal file
@ -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;
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user