From 18b6cc6b1221140bef4ad391b1e820f3e4a8cb63 Mon Sep 17 00:00:00 2001 From: Tensor <41941359+TensorFu@users.noreply.github.com> Date: Wed, 4 Jun 2025 20:04:17 +0800 Subject: [PATCH] Add textual-based TUI --- README.md | 11 +++++ ebhs_tui.py | 131 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 142 insertions(+) create mode 100644 ebhs_tui.py diff --git a/README.md b/README.md index 5ee065e..89177c0 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,17 @@ 简单terminal**界面** +## Textual 界面 + +新增 `ebhs_tui.py`,使用 [Textual](https://github.com/Textualize/textual) 提供基于终端的图形界面。运行: + +```bash +pip install textual rich +python ebhs_tui.py +``` + +数据将保存到 `ebhs_data.json` 文件中,界面中可以录入学习内容、查看待复习条目并完成复习。 + **注意** mysql必须多预留一列log* --------------------------------------------------- diff --git a/ebhs_tui.py b/ebhs_tui.py new file mode 100644 index 0000000..1dd466c --- /dev/null +++ b/ebhs_tui.py @@ -0,0 +1,131 @@ +import json +import os +import datetime +from typing import List, Dict + +from textual.app import App, ComposeResult +from textual.widgets import Header, Footer, Button, Static, Input, DataTable +from textual.containers import Horizontal + +DATA_FILE = "ebhs_data.json" + +# Interval minutes for each stage of Ebbinghaus curve +STAGE_MINUTES = [ + 0, + 5, + 30, + 12 * 60, + 24 * 60, + 2 * 24 * 60, + 4 * 24 * 60, + 7 * 24 * 60, + 15 * 24 * 60, +] + + +def load_data() -> List[Dict]: + if not os.path.exists(DATA_FILE): + return [] + with open(DATA_FILE, "r", encoding="utf-8") as f: + return json.load(f) + + +def save_data(data: List[Dict]): + with open(DATA_FILE, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=2) + + +def add_content(content: str): + data = load_data() + new_id = max([item["id"] for item in data], default=0) + 1 + now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") + data.append({"id": new_id, "content": content, "stage": 0, "logs": [now]}) + save_data(data) + + +def get_due_items() -> List[Dict]: + data = load_data() + now = datetime.datetime.now() + due = [] + for item in data: + stage = int(item.get("stage", 0)) + if stage >= len(STAGE_MINUTES): + stage = len(STAGE_MINUTES) - 1 + last_time = datetime.datetime.strptime(item["logs"][-1], "%Y-%m-%d %H:%M:%S") + delta_minutes = (now - last_time).total_seconds() / 60 + if delta_minutes >= STAGE_MINUTES[stage]: + due.append(item) + return due + + +def mark_reviewed(item_id: int): + data = load_data() + for item in data: + if item["id"] == item_id: + item["stage"] = int(item.get("stage", 0)) + 1 + now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") + item.setdefault("logs", []).append(now) + break + save_data(data) + + +class Menu(Static): + def compose(self) -> ComposeResult: + yield Button("Add New", id="add") + yield Button("Show Due", id="show") + yield Button("Review First Due", id="review") + yield Button("Quit", id="quit") + + +class EbhsApp(App): + CSS_PATH = None + + def compose(self) -> ComposeResult: + yield Header() + self.menu = Menu() + yield self.menu + self.output = DataTable(id="output") + yield self.output + yield Footer() + + async def on_button_pressed(self, event: Button.Pressed) -> None: + button_id = event.button.id + if button_id == "add": + await self.action_add_new() + elif button_id == "show": + await self.action_show_due() + elif button_id == "review": + await self.action_review_first() + elif button_id == "quit": + self.exit() + + async def action_add_new(self): + """Prompt user for new content and add it to the data store.""" + self.input = Input(placeholder="New content", id="input") + await self.mount(self.input) + await self.input.focus() + + async def on_input_submitted(self, event: Input.Submitted) -> None: + if event.input.id == "input": + content = event.value.strip() + await event.input.remove() + if content: + add_content(content) + await self.action_show_due() + + async def action_show_due(self): + self.output.clear(columns=True) + self.output.add_column("ID") + self.output.add_column("Content") + for item in get_due_items(): + self.output.add_row(str(item["id"]), item["content"]) + + async def action_review_first(self): + due = get_due_items() + if due: + mark_reviewed(due[0]["id"]) + await self.action_show_due() + + +if __name__ == "__main__": + EbhsApp().run()