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/__init__.py +1 -0
- endpaper/__main__.py +4 -0
- endpaper/cli/__init__.py +0 -0
- endpaper/cli/main.py +329 -0
- endpaper/cli/output.py +85 -0
- endpaper/core/__init__.py +97 -0
- endpaper/core/documents.py +187 -0
- endpaper/core/editing.py +101 -0
- endpaper/core/errors.py +21 -0
- endpaper/core/frontmatter.py +89 -0
- endpaper/core/meetings.py +37 -0
- endpaper/core/models.py +137 -0
- endpaper/core/notes.py +55 -0
- endpaper/core/tasks.py +434 -0
- endpaper/core/templates/AGENTS.md.tmpl +62 -0
- endpaper/core/templates/CLAUDE.md.tmpl +8 -0
- endpaper/core/text.py +48 -0
- endpaper/core/workspace.py +87 -0
- endpaper/tui/__init__.py +0 -0
- endpaper/tui/app.py +178 -0
- endpaper/tui/app.tcss +80 -0
- endpaper/tui/command_bar.py +144 -0
- endpaper/tui/discard_dialog.py +24 -0
- endpaper/tui/edit_screen.py +94 -0
- endpaper/tui/list_screen.py +345 -0
- endpaper/tui/preview_screen.py +68 -0
- endpaper/tui/rendering.py +42 -0
- endpaper/tui/status_bar.py +16 -0
- endpaper-0.0.1.dist-info/METADATA +162 -0
- endpaper-0.0.1.dist-info/RECORD +33 -0
- endpaper-0.0.1.dist-info/WHEEL +4 -0
- endpaper-0.0.1.dist-info/entry_points.txt +2 -0
- endpaper-0.0.1.dist-info/licenses/LICENSE +21 -0
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)
|