endpaper 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
endpaper/tui/app.py ADDED
@@ -0,0 +1,178 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime
4
+ from pathlib import Path
5
+
6
+ from textual.app import App
7
+ from textual.binding import Binding
8
+
9
+ from endpaper.core.documents import _read_document, match_document
10
+ from endpaper.core.errors import EndpaperError, UsageError
11
+ from endpaper.core.meetings import create_meeting, scan_meetings
12
+ from endpaper.core.models import DailyNote, Document, ScanWarning, Task, TaskFilter, Workspace
13
+ from endpaper.core.notes import create_note, open_daily_note, scan_notes
14
+ from endpaper.core.tasks import add_task, filter_tasks, load_tasks, match_task, set_task_state
15
+
16
+
17
+ class EndpaperApp(App[None]):
18
+ CSS_PATH = "app.tcss"
19
+ BINDINGS = [
20
+ Binding("ctrl+q", "quit", "Quit", show=True, priority=True),
21
+ ]
22
+
23
+ def __init__(self, workspace: Workspace) -> None:
24
+ super().__init__()
25
+ self.workspace = workspace
26
+ self.documents: dict[str, list[Document]] = {"meetings": [], "notes": []}
27
+ self.warnings: dict[str, list[ScanWarning]] = {"meetings": [], "notes": [], "tasks": []}
28
+ self.active: str = "meetings"
29
+ self.visible_documents: list[Document] = []
30
+ self.last_create_error: str | None = None
31
+ self._filter_query: str = ""
32
+ self.tasks: list[Task] = []
33
+ self.visible_tasks: list[Task] = []
34
+ self.show_done: bool = False
35
+ self.last_task_error: str | None = None
36
+ self._task_filter_query: str = ""
37
+
38
+ def on_mount(self) -> None:
39
+ self.documents["meetings"], self.warnings["meetings"] = scan_meetings(self.workspace)
40
+ self.documents["notes"], self.warnings["notes"] = scan_notes(self.workspace)
41
+ self.tasks, self.warnings["tasks"] = load_tasks(self.workspace)
42
+ self.visible_documents = list(self.documents[self.active])
43
+ self._refresh_visible_tasks()
44
+
45
+ from endpaper.tui.list_screen import ListScreen
46
+
47
+ self.push_screen(ListScreen())
48
+
49
+ # --- feature 001 compatibility aliases: back when there was only one
50
+ # collection, these were plain attributes. Kept as properties so the
51
+ # existing meeting-only tests keep working unedited.
52
+ @property
53
+ def meetings(self) -> list[Document]:
54
+ return self.documents["meetings"]
55
+
56
+ @property
57
+ def visible_meetings(self) -> list[Document]:
58
+ return self.visible_documents
59
+
60
+ def apply_filter(self, query: str) -> None:
61
+ if self.active == "tasks":
62
+ self._task_filter_query = query
63
+ self._refresh_visible_tasks()
64
+ return
65
+ self._filter_query = query
66
+ active_documents = self.documents[self.active]
67
+ if query:
68
+ self.visible_documents = [d for d in active_documents if match_document(d, query)]
69
+ else:
70
+ self.visible_documents = list(active_documents)
71
+
72
+ def refresh_document(self, path: Path) -> None:
73
+ """Re-parse only the one file that changed, in place, preserving list order.
74
+ Never rescans the workspace (FR-021, FR-022)."""
75
+ new_document = _read_document(path)
76
+ for collection in ("meetings", "notes"):
77
+ docs = self.documents[collection]
78
+ index = next((i for i, d in enumerate(docs) if d.path == path), None)
79
+ if index is None:
80
+ continue
81
+ if new_document is not None:
82
+ docs[index] = new_document
83
+ else:
84
+ del docs[index]
85
+ if collection == self.active:
86
+ self._sync_visible(path, new_document)
87
+ return
88
+
89
+ def _sync_visible(self, path: Path, new_document: Document | None) -> None:
90
+ index = next((i for i, d in enumerate(self.visible_documents) if d.path == path), None)
91
+ if index is None:
92
+ return
93
+ still_matches = new_document is not None and (
94
+ not self._filter_query or match_document(new_document, self._filter_query)
95
+ )
96
+ if still_matches:
97
+ assert new_document is not None
98
+ self.visible_documents[index] = new_document
99
+ else:
100
+ del self.visible_documents[index]
101
+
102
+ def switch_collection(self, name: str) -> None:
103
+ self.active = name
104
+ if name == "tasks":
105
+ self._refresh_visible_tasks()
106
+ else:
107
+ self.visible_documents = list(self.documents[self.active])
108
+
109
+ def _refresh_visible_tasks(self) -> None:
110
+ task_filter = TaskFilter(include_done=self.show_done)
111
+ filtered = filter_tasks(self.tasks, task_filter)
112
+ if self._task_filter_query:
113
+ filtered = [t for t in filtered if match_task(t, self._task_filter_query)]
114
+ self.visible_tasks = filtered
115
+
116
+ def create_meeting_and_track(self, description: str, type: str) -> Document | None:
117
+ try:
118
+ meeting = create_meeting(self.workspace, description, type=type)
119
+ except UsageError as exc:
120
+ self.last_create_error = str(exc)
121
+ return None
122
+ self.last_create_error = None
123
+ self.documents["meetings"].insert(0, meeting)
124
+ # Land back on the collection just created into, so escaping the preview
125
+ # shows it -- a create is always followed by "let me see that".
126
+ self.active = "meetings"
127
+ self.visible_documents = list(self.documents["meetings"])
128
+ return meeting
129
+
130
+ def create_note_and_track(self, description: str, type: str) -> Document | None:
131
+ try:
132
+ note = create_note(self.workspace, description, type=type)
133
+ except UsageError as exc:
134
+ self.last_create_error = str(exc)
135
+ return None
136
+ self.last_create_error = None
137
+ self.documents["notes"].insert(0, note)
138
+ self.active = "notes"
139
+ self.visible_documents = list(self.documents["notes"])
140
+ return note
141
+
142
+ def open_daily_note_and_track(self, *, now: datetime | None = None) -> DailyNote:
143
+ daily = open_daily_note(self.workspace, now=now)
144
+ if daily.created and daily.document is not None:
145
+ self.documents["notes"].insert(0, daily.document)
146
+ self.active = "notes"
147
+ self.visible_documents = list(self.documents["notes"])
148
+ return daily
149
+
150
+ def add_task_and_track(self, description: str, type: str) -> Task | None:
151
+ try:
152
+ task = add_task(self.workspace, description, type=type)
153
+ except UsageError as exc:
154
+ self.last_create_error = str(exc)
155
+ return None
156
+ self.last_create_error = None
157
+ self.tasks.append(task)
158
+ self.active = "tasks"
159
+ self._task_filter_query = ""
160
+ self._refresh_visible_tasks()
161
+ return task
162
+
163
+ def toggle_task_and_track(self, task_id: str) -> None:
164
+ current = next((t for t in self.tasks if t.id == task_id), None)
165
+ try:
166
+ updated = set_task_state(
167
+ self.workspace, task_id, done=not (current.done if current else False)
168
+ )
169
+ except EndpaperError as exc:
170
+ self.last_task_error = str(exc)
171
+ return
172
+ self.last_task_error = None
173
+ self.tasks = [updated if t.id == task_id else t for t in self.tasks]
174
+ self._refresh_visible_tasks()
175
+
176
+ def toggle_show_done(self) -> None:
177
+ self.show_done = not self.show_done
178
+ self._refresh_visible_tasks()
endpaper/tui/app.tcss ADDED
@@ -0,0 +1,80 @@
1
+ Screen {
2
+ layout: vertical;
3
+ }
4
+
5
+ #body {
6
+ height: 1fr;
7
+ layout: horizontal;
8
+ }
9
+
10
+ #menu-pane {
11
+ width: 14;
12
+ border-right: solid $accent;
13
+ }
14
+
15
+ #list-pane {
16
+ width: 2fr;
17
+ border-right: solid $accent;
18
+ }
19
+
20
+ #preview-pane {
21
+ width: 3fr;
22
+ padding: 0 1;
23
+ }
24
+
25
+ #collection-menu {
26
+ height: 1fr;
27
+ }
28
+
29
+ #meeting-list {
30
+ height: 1fr;
31
+ }
32
+
33
+ #bottom-bar {
34
+ dock: bottom;
35
+ height: auto;
36
+ }
37
+
38
+ CommandBar {
39
+ height: 1;
40
+ display: none;
41
+ }
42
+
43
+ CommandBar Input {
44
+ height: 1;
45
+ border: none;
46
+ padding: 0;
47
+ }
48
+
49
+ StatusBar {
50
+ height: 1;
51
+ background: $panel;
52
+ }
53
+
54
+ #editor {
55
+ height: 1fr;
56
+ width: 1fr;
57
+ }
58
+
59
+ #discard-dialog {
60
+ width: auto;
61
+ height: auto;
62
+ padding: 1 2;
63
+ background: $panel;
64
+ border: thick $accent;
65
+ align: center middle;
66
+ }
67
+
68
+ #discard-buttons {
69
+ height: auto;
70
+ align: center middle;
71
+ padding-top: 1;
72
+ }
73
+
74
+ #discard-buttons Button {
75
+ margin: 0 1;
76
+ }
77
+
78
+ DiscardDialog {
79
+ align: center middle;
80
+ }
@@ -0,0 +1,144 @@
1
+ from __future__ import annotations
2
+
3
+ from textual import on
4
+ from textual.app import ComposeResult
5
+ from textual.binding import Binding
6
+ from textual.message import Message
7
+ from textual.widgets import Input, Static
8
+
9
+ VERBS = {"meeting", "meetings", "note", "notes", "task", "tasks", "init"}
10
+
11
+
12
+ def _normalize(text: str) -> str:
13
+ """Tolerate a literal leading '/' in the typed text.
14
+
15
+ The bar is opened by pressing '/', which is not inserted into the input --
16
+ but users naturally retype it anyway (the footer hint reads "/ filter or
17
+ command", and it's the universal command-prefix convention), so a leading
18
+ '/' must not be treated as part of the query/verb.
19
+ """
20
+ return text[1:] if text.startswith("/") else text
21
+
22
+
23
+ def resolve_mode(text: str) -> tuple[str, str]:
24
+ """Return (mode, first_token). mode is 'filter' or 'command'.
25
+
26
+ A leading space is an escape hatch that forces filter mode (research.md R4).
27
+ """
28
+ if text.startswith(" "):
29
+ return "filter", ""
30
+ text = _normalize(text)
31
+ stripped = text.lstrip()
32
+ if not stripped:
33
+ return "filter", ""
34
+ first_token = stripped.split(None, 1)[0]
35
+ stem = first_token.split(".", 1)[0].lower()
36
+ if stem in VERBS:
37
+ return "command", first_token
38
+ return "filter", ""
39
+
40
+
41
+ class CommandBar(Static):
42
+ BINDINGS = [Binding("escape", "cancel", "Cancel", show=False)]
43
+
44
+ class ModeChanged(Message):
45
+ def __init__(self, mode: str, verb: str) -> None:
46
+ self.mode = mode
47
+ self.verb = verb
48
+ super().__init__()
49
+
50
+ class FilterChanged(Message):
51
+ def __init__(self, query: str) -> None:
52
+ self.query = query
53
+ super().__init__()
54
+
55
+ class CreateRequested(Message):
56
+ def __init__(self, kind: str, description: str, type: str) -> None:
57
+ self.kind = kind # "meeting" | "note" | "task"
58
+ self.description = description
59
+ self.type = type
60
+ super().__init__()
61
+
62
+ class DailyRequested(Message):
63
+ pass
64
+
65
+ class CollectionRequested(Message):
66
+ def __init__(self, name: str) -> None:
67
+ self.name = name
68
+ super().__init__()
69
+
70
+ class BarError(Message):
71
+ def __init__(self, message: str) -> None:
72
+ self.message = message
73
+ super().__init__()
74
+
75
+ class ClearRequested(Message):
76
+ pass
77
+
78
+ class Closed(Message):
79
+ pass
80
+
81
+ def compose(self) -> ComposeResult:
82
+ yield Input(placeholder="/ filter or command", id="bar-input")
83
+
84
+ def open(self) -> None:
85
+ self.display = True
86
+ bar = self.query_one(Input)
87
+ bar.value = ""
88
+ bar.focus()
89
+ self.post_message(self.ModeChanged("filter", ""))
90
+
91
+ def close(self) -> None:
92
+ self.display = False
93
+ self.post_message(self.Closed())
94
+
95
+ def action_cancel(self) -> None:
96
+ self.query_one(Input).value = ""
97
+ self.post_message(self.ClearRequested())
98
+ self.close()
99
+
100
+ @on(Input.Changed, "#bar-input")
101
+ def _on_changed(self, event: Input.Changed) -> None:
102
+ mode, verb = resolve_mode(event.value)
103
+ self.post_message(self.ModeChanged(mode, verb))
104
+ if mode == "filter":
105
+ # A leading space is the explicit escape hatch for a literal filter
106
+ # (e.g. the literal word "meetings"); anywhere else, a leading '/' is
107
+ # the retyped activation key, not part of the query.
108
+ query = event.value[1:] if event.value.startswith(" ") else _normalize(event.value)
109
+ self.post_message(self.FilterChanged(query))
110
+
111
+ @on(Input.Submitted, "#bar-input")
112
+ def _on_submitted(self, event: Input.Submitted) -> None:
113
+ mode, verb_token = resolve_mode(event.value)
114
+ if mode == "command":
115
+ self._run_command(event.value, verb_token)
116
+ self.close()
117
+
118
+ def _run_command(self, text: str, verb_token: str) -> None:
119
+ text = _normalize(text)
120
+ rest = text[len(verb_token) :].lstrip()
121
+ stem, _, type_part = verb_token.partition(".")
122
+ stem = stem.lower()
123
+ if stem == "meeting":
124
+ self.post_message(self.CreateRequested("meeting", rest, type_part))
125
+ elif stem == "meetings":
126
+ self.post_message(self.CollectionRequested("meetings"))
127
+ elif stem == "note":
128
+ if not rest:
129
+ if type_part:
130
+ self.post_message(self.BarError(f"note.{type_part} needs a description"))
131
+ else:
132
+ self.post_message(self.DailyRequested())
133
+ else:
134
+ self.post_message(self.CreateRequested("note", rest, type_part))
135
+ elif stem == "notes":
136
+ self.post_message(self.CollectionRequested("notes"))
137
+ elif stem == "task":
138
+ if not rest:
139
+ self.post_message(self.BarError("task needs a description"))
140
+ else:
141
+ self.post_message(self.CreateRequested("task", rest, type_part))
142
+ elif stem == "tasks":
143
+ self.post_message(self.CollectionRequested("tasks"))
144
+ # "init" is a registered verb for future features; no TUI action this feature.
@@ -0,0 +1,24 @@
1
+ from __future__ import annotations
2
+
3
+ from textual import on
4
+ from textual.app import ComposeResult
5
+ from textual.containers import Horizontal, Vertical
6
+ from textual.screen import ModalScreen
7
+ from textual.widgets import Button, Label
8
+
9
+
10
+ class DiscardDialog(ModalScreen[bool]):
11
+ def compose(self) -> ComposeResult:
12
+ with Vertical(id="discard-dialog"):
13
+ yield Label("Discard unsaved changes?")
14
+ with Horizontal(id="discard-buttons"):
15
+ yield Button("Discard", id="discard", variant="error")
16
+ yield Button("Cancel", id="cancel", variant="primary")
17
+
18
+ @on(Button.Pressed, "#discard")
19
+ def _on_discard(self) -> None:
20
+ self.dismiss(True)
21
+
22
+ @on(Button.Pressed, "#cancel")
23
+ def _on_cancel(self) -> None:
24
+ self.dismiss(False)
@@ -0,0 +1,94 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import replace
4
+
5
+ from textual.app import ComposeResult
6
+ from textual.binding import Binding
7
+ from textual.containers import Vertical
8
+ from textual.screen import Screen
9
+ from textual.widgets import TextArea
10
+
11
+ from endpaper.core.editing import save_buffer
12
+ from endpaper.core.models import EditableFile
13
+ from endpaper.tui.discard_dialog import DiscardDialog
14
+ from endpaper.tui.status_bar import EDIT_HELP, StatusBar
15
+
16
+
17
+ class EditScreen(Screen[None]):
18
+ # TextArea itself binds ctrl+o is unbound but ctrl+s/ctrl+x are (cut); `priority=True`
19
+ # means these are checked app-down-to-focused-widget, before TextArea's own
20
+ # bindings, so they aren't shadowed by the focused editor (e.g. TextArea's
21
+ # built-in ctrl+x -> cut).
22
+ BINDINGS = [
23
+ Binding("ctrl+o", "save", "Save", show=True, priority=True),
24
+ Binding("ctrl+s", "save", "Save", show=False, priority=True),
25
+ Binding("ctrl+x", "save_and_close", "Save & close", show=True, priority=True),
26
+ Binding("escape", "close", "Discard", show=True),
27
+ ]
28
+
29
+ def __init__(self, file: EditableFile) -> None:
30
+ super().__init__()
31
+ self.file = file
32
+ self.original_text = file.text
33
+
34
+ @property
35
+ def is_dirty(self) -> bool:
36
+ return self.query_one("#editor", TextArea).text != self.original_text
37
+
38
+ def compose(self) -> ComposeResult:
39
+ yield TextArea(self.file.text, show_line_numbers=True, id="editor")
40
+ with Vertical(id="bottom-bar"):
41
+ yield StatusBar(EDIT_HELP, id="status-bar")
42
+
43
+ def on_mount(self) -> None:
44
+ self.query_one("#editor", TextArea).focus()
45
+
46
+ def _render_status(self, note: str | None = None) -> None:
47
+ status = self.query_one(StatusBar)
48
+ status.update(f"⚠ {note} {EDIT_HELP}" if note else EDIT_HELP)
49
+
50
+ def _save(self) -> bool:
51
+ editor = self.query_one("#editor", TextArea)
52
+ result = save_buffer(self.file.path, editor.text, self.file)
53
+ if not result.ok:
54
+ self._render_status(result.message)
55
+ return False
56
+
57
+ if result.saved_text != editor.text:
58
+ # The stamp changed the `updated:` line -- sync the buffer to match
59
+ # what actually landed on disk, or the widget would read as dirty
60
+ # the instant it saved (whenever the new timestamp differs from
61
+ # what's still displayed). `load_text` resets cursor/selection, so
62
+ # capture and restore it around the reload (FR-014).
63
+ cursor = editor.cursor_location
64
+ editor.text = result.saved_text
65
+ editor.cursor_location = cursor
66
+
67
+ self.original_text = result.saved_text
68
+ self.file = replace(self.file, text=result.saved_text)
69
+ self.app.refresh_document(self.file.path) # type: ignore[attr-defined]
70
+ if not result.stamped:
71
+ self._render_status("frontmatter's updated: field could not be found; saved as typed")
72
+ else:
73
+ self._render_status(None)
74
+ return True
75
+
76
+ def action_save(self) -> None:
77
+ self._save()
78
+
79
+ def action_save_and_close(self) -> None:
80
+ if self._save():
81
+ self.app.pop_screen()
82
+
83
+ def action_close(self) -> None:
84
+ if not self.is_dirty:
85
+ self.app.pop_screen()
86
+ return
87
+
88
+ def _handle_dismiss(discard: bool | None) -> None:
89
+ if discard:
90
+ self.app.pop_screen()
91
+ else:
92
+ self.query_one("#editor", TextArea).focus()
93
+
94
+ self.app.push_screen(DiscardDialog(), _handle_dismiss)