-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathnotes.py
More file actions
257 lines (213 loc) · 8.21 KB
/
Copy pathnotes.py
File metadata and controls
257 lines (213 loc) · 8.21 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
"""Notes: state helpers composition example.
Demonstrates all five state helpers working together:
- ``State`` for nested state management with path-based access
- ``UndoStack`` for reversible edits on title and body
- ``Selection`` for multi-select with toggle
- ``Route`` for stack-based view navigation (/list vs /edit)
- ``query()`` for full-text search across note fields
Run::
python -m plushie run examples.notes:Notes
"""
from __future__ import annotations
from dataclasses import dataclass, replace
from typing import Any
import plushie
from plushie import ui
from plushie.commands import Command
from plushie.data import query
from plushie.events import Click, Input, Toggle
from plushie.route import Route
from plushie.selection import Selection
from plushie.state import State
from plushie.undo import UndoCommand, UndoStack
@dataclass(frozen=True, slots=True)
class NoteData:
"""Undo-tracked editor state for a single note."""
title: str = ""
text: str = ""
@dataclass(slots=True)
class Model:
"""Notes app model.
Uses ``slots=True`` without ``frozen=True`` because ``State`` is
mutable (it tracks revisions internally). The rest of the fields
are immutable dataclasses replaced via ``dataclasses.replace`` on
the outer model... except State which mutates in place.
"""
state: State
selection: Selection
undo: UndoStack
route: Route
def _save_current_edit(model: Model) -> Model:
"""Persist undo buffer contents back into the notes list."""
editing_id = model.state.get(["editing_id"])
if editing_id is None:
return model
current: NoteData = model.undo.current
def update_notes(notes: list[dict[str, Any]]) -> list[dict[str, Any]]:
return [
{**n, "title": current.title, "body": current.text}
if n["id"] == editing_id
else n
for n in notes
]
model.state.update(["notes"], update_notes)
return model
class Notes(plushie.App[Model]):
"""Notes app combining State, Undo, Selection, Route, and Data query."""
def init(self) -> Model:
return Model(
state=State.new(
{
"notes": [],
"next_id": 1,
"search_query": "",
"editing_id": None,
}
),
selection=Selection.new(mode="multi"),
undo=UndoStack.new(NoteData()),
route=Route.new("/list"),
)
def update(self, model: Model, event: object) -> Model | tuple[Model, Command]:
match event:
# -- List view ------------------------------------------------
case Click(id="new_note"):
note_id = model.state.get(["next_id"])
note = {"id": note_id, "title": "", "body": ""}
model.state.update(["notes"], lambda ns: [*ns, note])
model.state.put(["next_id"], note_id + 1)
model.state.put(["editing_id"], note_id)
return replace(
model,
undo=UndoStack.new(NoteData()),
route=model.route.push("/edit"),
)
case Click(id=click_id) if click_id.startswith("note:"):
note_id = int(click_id.removeprefix("note:"))
notes: list[dict[str, Any]] = model.state.get(["notes"])
note = next((n for n in notes if n["id"] == note_id), None)
if note is None:
return model
model.state.put(["editing_id"], note_id)
return replace(
model,
undo=UndoStack.new(
NoteData(title=note["title"], text=note["body"])
),
route=model.route.push("/edit"),
)
case Click(id="delete_selected"):
selected = model.selection.selected()
model.state.update(
["notes"],
lambda ns: [n for n in ns if n["id"] not in selected],
)
return replace(model, selection=model.selection.clear())
case Input(id="search", value=q):
model.state.put(["search_query"], q)
return model
case Toggle(id=tid) if tid.startswith("note_select:"):
note_id = int(tid.removeprefix("note_select:"))
return replace(model, selection=model.selection.toggle(note_id))
# -- Edit view ------------------------------------------------
case Click(id="back"):
model = _save_current_edit(model)
model.state.put(["editing_id"], None)
return replace(model, route=model.route.pop())
case Input(id="title", value=v):
old_title = model.undo.current.title
cmd = UndoCommand(
apply_fn=lambda cur, _v=v: replace(cur, title=_v),
undo_fn=lambda cur, _t=old_title: replace(cur, title=_t),
label="edit title",
)
return replace(model, undo=model.undo.apply_command(cmd))
case Input(id="body", value=v):
old_text = model.undo.current.text
cmd = UndoCommand(
apply_fn=lambda cur, _v=v: replace(cur, text=_v),
undo_fn=lambda cur, _t=old_text: replace(cur, text=_t),
label="edit body",
)
return replace(model, undo=model.undo.apply_command(cmd))
case Click(id="undo"):
return replace(model, undo=model.undo.undo())
case Click(id="redo"):
return replace(model, undo=model.undo.redo())
case _:
return model
def view(self, model: Model) -> dict[str, Any]:
match model.route.current():
case "/list":
return _view_list(model)
case "/edit":
return _view_edit(model)
case _:
return _view_list(model)
def _view_list(model: Model) -> dict[str, Any]:
search_query: str = model.state.get(["search_query"])
notes: list[dict[str, Any]] = model.state.get(["notes"])
if search_query:
# Convert notes dicts to the format query() expects
result = query(notes, search=(["title", "body"], search_query))
filtered = result.entries
else:
filtered = notes
note_rows = [
ui.container(
f"note_row:{note['id']}",
ui.row(
ui.checkbox(
f"note_select:{note['id']}",
model.selection.is_selected(note["id"]),
label=note["title"] or "(untitled)",
),
ui.button(f"note:{note['id']}", "Edit"),
spacing=8,
width="fill",
),
)
for note in filtered
]
return ui.window(
"main",
ui.column(
ui.text("heading", "Notes", size=24),
ui.text_input("search", search_query, placeholder="Search notes..."),
ui.scrollable(
"notes_list",
ui.column(*note_rows, spacing=4, width="fill"),
height="fill",
),
ui.row(
ui.button("new_note", "New Note"),
ui.button("delete_selected", "Delete Selected"),
spacing=8,
),
padding=16,
spacing=12,
width="fill",
),
title="Notes",
)
def _view_edit(model: Model) -> dict[str, Any]:
current: NoteData = model.undo.current
return ui.window(
"main",
ui.column(
ui.row(
ui.button("back", "Back"),
ui.button("undo", "Undo"),
ui.button("redo", "Redo"),
spacing=8,
),
ui.text_input("title", current.title, placeholder="Note title"),
ui.text_editor("body", current.text, width="fill", height="fill"),
padding=16,
spacing=12,
width="fill",
),
title="Edit Note",
)
if __name__ == "__main__":
plushie.run(Notes)