tui-notes 0.1.0__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.
- tui_notes/__init__.py +3 -0
- tui_notes/__main__.py +13 -0
- tui_notes/app.py +457 -0
- tui_notes/constants.py +20 -0
- tui_notes/screens/__init__.py +8 -0
- tui_notes/screens/color_picker.py +38 -0
- tui_notes/screens/confirm.py +40 -0
- tui_notes/screens/edit_post_it.py +51 -0
- tui_notes/screens/help.py +61 -0
- tui_notes/storage.py +104 -0
- tui_notes/style.tcss +224 -0
- tui_notes/widgets/__init__.py +6 -0
- tui_notes/widgets/empty_slot.py +18 -0
- tui_notes/widgets/post_it.py +103 -0
- tui_notes-0.1.0.dist-info/METADATA +119 -0
- tui_notes-0.1.0.dist-info/RECORD +19 -0
- tui_notes-0.1.0.dist-info/WHEEL +4 -0
- tui_notes-0.1.0.dist-info/entry_points.txt +2 -0
- tui_notes-0.1.0.dist-info/licenses/LICENSE +21 -0
tui_notes/__init__.py
ADDED
tui_notes/__main__.py
ADDED
tui_notes/app.py
ADDED
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
"""Main application module for tui-notes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from textual.app import App, ComposeResult
|
|
9
|
+
from textual.binding import Binding
|
|
10
|
+
from textual.containers import Grid
|
|
11
|
+
from textual.events import Key
|
|
12
|
+
from textual.reactive import reactive
|
|
13
|
+
from textual.widgets import Footer, Header
|
|
14
|
+
|
|
15
|
+
from tui_notes.constants import GRID_COLUMNS, MAX_NOTES
|
|
16
|
+
from tui_notes.screens import ColorPickerScreen, ConfirmScreen, EditPostItScreen, HelpScreen
|
|
17
|
+
from tui_notes.storage import load_notes, save_notes
|
|
18
|
+
from tui_notes.widgets import EmptySlot, PostIt
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class NotesApp(App):
|
|
22
|
+
"""A TUI application for managing post-it notes in a 3x3 grid."""
|
|
23
|
+
|
|
24
|
+
CSS_PATH = "style.tcss"
|
|
25
|
+
TITLE = "TUI Notes"
|
|
26
|
+
|
|
27
|
+
BINDINGS = [
|
|
28
|
+
Binding("q", "quit", "Quit"),
|
|
29
|
+
Binding("a", "add_note", "Add"),
|
|
30
|
+
Binding("d", "delete_note", "Delete"),
|
|
31
|
+
Binding("e", "edit_note", "Edit"),
|
|
32
|
+
Binding("enter", "edit_note", "Edit", show=False),
|
|
33
|
+
Binding("c", "change_color", "Color"),
|
|
34
|
+
Binding("m", "toggle_move", "Move"),
|
|
35
|
+
Binding("escape", "cancel_move", "Cancel", show=False),
|
|
36
|
+
Binding("ctrl+s", "save", "Save"),
|
|
37
|
+
Binding("ctrl+r", "reload", "Reload"),
|
|
38
|
+
Binding("ctrl+e", "export", "Export"),
|
|
39
|
+
Binding("question_mark", "help", "Help"),
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
move_mode: reactive[bool] = reactive(False)
|
|
43
|
+
_moving_post_it: PostIt | None = None
|
|
44
|
+
_note_counter: int = 0
|
|
45
|
+
|
|
46
|
+
# ── Lifecycle ───────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
def compose(self) -> ComposeResult:
|
|
49
|
+
"""Compose the application layout with header, grid, and footer."""
|
|
50
|
+
yield Header()
|
|
51
|
+
yield Grid(
|
|
52
|
+
*[EmptySlot() for _ in range(MAX_NOTES)],
|
|
53
|
+
id="notes-grid",
|
|
54
|
+
)
|
|
55
|
+
yield Footer()
|
|
56
|
+
|
|
57
|
+
def on_mount(self) -> None:
|
|
58
|
+
"""Load saved notes from disk and focus the first slot."""
|
|
59
|
+
self._load_from_disk()
|
|
60
|
+
self._focus_first()
|
|
61
|
+
|
|
62
|
+
# ── Grid helpers ────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
def _get_grid(self) -> Grid:
|
|
65
|
+
"""Return the notes grid widget.
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
The Grid container holding all post-its and empty slots.
|
|
69
|
+
"""
|
|
70
|
+
return self.query_one("#notes-grid", Grid)
|
|
71
|
+
|
|
72
|
+
def _get_post_its(self) -> list[PostIt]:
|
|
73
|
+
"""Return all active post-it widgets in DOM order.
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
List of PostIt widgets currently in the grid.
|
|
77
|
+
"""
|
|
78
|
+
return list(self.query(PostIt))
|
|
79
|
+
|
|
80
|
+
def _focus_first(self) -> None:
|
|
81
|
+
"""Focus the first child in the grid."""
|
|
82
|
+
grid = self._get_grid()
|
|
83
|
+
if grid.children:
|
|
84
|
+
grid.children[0].focus()
|
|
85
|
+
|
|
86
|
+
# ── Persistence ─────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
def _save_to_disk(self) -> None:
|
|
89
|
+
"""Serialize all post-its and save to the JSON file."""
|
|
90
|
+
grid = self._get_grid()
|
|
91
|
+
children = list(grid.children)
|
|
92
|
+
data: list[dict[str, Any]] = [
|
|
93
|
+
child.to_dict(grid_index=idx)
|
|
94
|
+
for idx, child in enumerate(children)
|
|
95
|
+
if isinstance(child, PostIt)
|
|
96
|
+
]
|
|
97
|
+
try:
|
|
98
|
+
save_notes(data)
|
|
99
|
+
except OSError as exc:
|
|
100
|
+
self.notify(f"Save error: {exc}", severity="error")
|
|
101
|
+
|
|
102
|
+
def _load_from_disk(self) -> None:
|
|
103
|
+
"""Load post-its from disk and place them at their saved positions."""
|
|
104
|
+
notes = load_notes()
|
|
105
|
+
if not notes:
|
|
106
|
+
return
|
|
107
|
+
|
|
108
|
+
grid = self._get_grid()
|
|
109
|
+
children = list(grid.children)
|
|
110
|
+
|
|
111
|
+
for note in notes:
|
|
112
|
+
pos = note["position"]
|
|
113
|
+
if (
|
|
114
|
+
0 <= pos < MAX_NOTES
|
|
115
|
+
and pos < len(children)
|
|
116
|
+
and isinstance(children[pos], EmptySlot)
|
|
117
|
+
):
|
|
118
|
+
post_it = PostIt.from_dict(note)
|
|
119
|
+
grid.mount(post_it, before=children[pos])
|
|
120
|
+
children[pos].remove()
|
|
121
|
+
self._note_counter += 1
|
|
122
|
+
|
|
123
|
+
def action_save(self) -> None:
|
|
124
|
+
"""Save notes to disk manually."""
|
|
125
|
+
self._save_to_disk()
|
|
126
|
+
self.notify("Notes saved!")
|
|
127
|
+
|
|
128
|
+
def action_reload(self) -> None:
|
|
129
|
+
"""Reload all notes from disk, discarding unsaved changes."""
|
|
130
|
+
grid = self._get_grid()
|
|
131
|
+
for child in list(grid.children):
|
|
132
|
+
child.remove()
|
|
133
|
+
for _ in range(MAX_NOTES):
|
|
134
|
+
grid.mount(EmptySlot())
|
|
135
|
+
self._note_counter = 0
|
|
136
|
+
self._load_from_disk()
|
|
137
|
+
self.set_timer(0.1, self._focus_first)
|
|
138
|
+
self.notify("Notes reloaded!")
|
|
139
|
+
|
|
140
|
+
# ── CRUD actions ────────────────────────────────────────────
|
|
141
|
+
|
|
142
|
+
def action_add_note(self) -> None:
|
|
143
|
+
"""Add a new post-it at the focused position (or next available)."""
|
|
144
|
+
post_its = self._get_post_its()
|
|
145
|
+
if len(post_its) >= MAX_NOTES:
|
|
146
|
+
self.notify("Grid is full! Remove a note first.", severity="warning")
|
|
147
|
+
return
|
|
148
|
+
|
|
149
|
+
focused = self.focused
|
|
150
|
+
grid = self._get_grid()
|
|
151
|
+
children = list(grid.children)
|
|
152
|
+
|
|
153
|
+
idx = children.index(focused) if isinstance(focused, EmptySlot) else len(post_its)
|
|
154
|
+
|
|
155
|
+
self._note_counter += 1
|
|
156
|
+
new_post_it = PostIt(idx, title=f"Note {self._note_counter}")
|
|
157
|
+
|
|
158
|
+
if isinstance(focused, EmptySlot):
|
|
159
|
+
grid.mount(new_post_it, before=focused)
|
|
160
|
+
focused.remove()
|
|
161
|
+
else:
|
|
162
|
+
empty_slots = list(self.query("EmptySlot"))
|
|
163
|
+
if empty_slots:
|
|
164
|
+
empty_slots[-1].remove()
|
|
165
|
+
grid.mount(new_post_it)
|
|
166
|
+
|
|
167
|
+
new_post_it.focus()
|
|
168
|
+
self.notify(f"Added: {new_post_it.title}")
|
|
169
|
+
self._save_to_disk()
|
|
170
|
+
|
|
171
|
+
def action_delete_note(self) -> None:
|
|
172
|
+
"""Delete the focused post-it after user confirmation."""
|
|
173
|
+
focused = self.focused
|
|
174
|
+
if not isinstance(focused, PostIt):
|
|
175
|
+
self.notify("Select a note to delete.", severity="warning")
|
|
176
|
+
return
|
|
177
|
+
|
|
178
|
+
self.push_screen(
|
|
179
|
+
ConfirmScreen(f"Delete '{focused.title}'?"),
|
|
180
|
+
callback=lambda confirmed: self._do_delete(focused, confirmed),
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
def _do_delete(self, post_it: PostIt, confirmed: bool) -> None:
|
|
184
|
+
"""Replace the confirmed post-it with an empty slot in-place.
|
|
185
|
+
|
|
186
|
+
Args:
|
|
187
|
+
post_it: The PostIt widget to remove.
|
|
188
|
+
confirmed: Whether the user confirmed the deletion.
|
|
189
|
+
"""
|
|
190
|
+
if not confirmed:
|
|
191
|
+
return
|
|
192
|
+
|
|
193
|
+
title = post_it.title
|
|
194
|
+
self._note_counter -= 1
|
|
195
|
+
grid = self._get_grid()
|
|
196
|
+
empty = EmptySlot()
|
|
197
|
+
grid.mount(empty, before=post_it)
|
|
198
|
+
post_it.remove()
|
|
199
|
+
empty.focus()
|
|
200
|
+
self.notify(f"Deleted: {title}")
|
|
201
|
+
self._save_to_disk()
|
|
202
|
+
|
|
203
|
+
def action_edit_note(self) -> None:
|
|
204
|
+
"""Open the edit modal for the focused post-it."""
|
|
205
|
+
focused = self.focused
|
|
206
|
+
if isinstance(focused, PostIt):
|
|
207
|
+
self.push_screen(
|
|
208
|
+
EditPostItScreen(focused.title, focused.content),
|
|
209
|
+
callback=lambda result: self._apply_edit(focused, result),
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
def _apply_edit(self, post_it: PostIt, result: dict[str, str] | None) -> None:
|
|
213
|
+
"""Apply edits returned from the edit modal.
|
|
214
|
+
|
|
215
|
+
Args:
|
|
216
|
+
post_it: The PostIt widget being edited.
|
|
217
|
+
result: Dict with 'title' and 'content' keys, or None if cancelled.
|
|
218
|
+
"""
|
|
219
|
+
if result is not None:
|
|
220
|
+
post_it.title = result["title"]
|
|
221
|
+
post_it.content = result["content"]
|
|
222
|
+
self._save_to_disk()
|
|
223
|
+
|
|
224
|
+
# ── Color ───────────────────────────────────────────────────
|
|
225
|
+
|
|
226
|
+
def action_change_color(self) -> None:
|
|
227
|
+
"""Open the color picker for the focused post-it."""
|
|
228
|
+
focused = self.focused
|
|
229
|
+
if not isinstance(focused, PostIt):
|
|
230
|
+
self.notify("Select a note to change color.", severity="warning")
|
|
231
|
+
return
|
|
232
|
+
self.push_screen(
|
|
233
|
+
ColorPickerScreen(),
|
|
234
|
+
callback=lambda idx: self._apply_color(focused, idx),
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
def _apply_color(self, post_it: PostIt, color_idx: int | None) -> None:
|
|
238
|
+
"""Apply the selected color to a post-it.
|
|
239
|
+
|
|
240
|
+
Args:
|
|
241
|
+
post_it: The PostIt widget to recolor.
|
|
242
|
+
color_idx: Selected palette index, or None if cancelled.
|
|
243
|
+
"""
|
|
244
|
+
if color_idx is not None:
|
|
245
|
+
post_it.color_index = color_idx
|
|
246
|
+
self._save_to_disk()
|
|
247
|
+
|
|
248
|
+
# ── Export ──────────────────────────────────────────────────
|
|
249
|
+
|
|
250
|
+
def action_export(self) -> None:
|
|
251
|
+
"""Export all post-its to a Markdown file in the user's home directory."""
|
|
252
|
+
post_its = self._get_post_its()
|
|
253
|
+
if not post_its:
|
|
254
|
+
self.notify("No notes to export.", severity="warning")
|
|
255
|
+
return
|
|
256
|
+
|
|
257
|
+
lines = ["# TUI Notes Export\n"]
|
|
258
|
+
for post_it in post_its:
|
|
259
|
+
lines.append(f"## {post_it.title}\n")
|
|
260
|
+
if post_it.content:
|
|
261
|
+
lines.append(f"{post_it.content}\n")
|
|
262
|
+
lines.append("")
|
|
263
|
+
|
|
264
|
+
export_path = Path.home() / "tui-notes-export.md"
|
|
265
|
+
try:
|
|
266
|
+
export_path.write_text("\n".join(lines), encoding="utf-8")
|
|
267
|
+
self.notify(f"Exported to {export_path}")
|
|
268
|
+
except OSError as exc:
|
|
269
|
+
self.notify(f"Export error: {exc}", severity="error")
|
|
270
|
+
|
|
271
|
+
# ── Help ────────────────────────────────────────────────────
|
|
272
|
+
|
|
273
|
+
def action_help(self) -> None:
|
|
274
|
+
"""Show the help screen with all keyboard shortcuts."""
|
|
275
|
+
self.push_screen(HelpScreen())
|
|
276
|
+
|
|
277
|
+
# ── Move mode ───────────────────────────────────────────────
|
|
278
|
+
|
|
279
|
+
def watch_move_mode(self, active: bool) -> None:
|
|
280
|
+
"""Update the subtitle bar when move mode toggles.
|
|
281
|
+
|
|
282
|
+
Args:
|
|
283
|
+
active: Whether move mode is now active.
|
|
284
|
+
"""
|
|
285
|
+
self.sub_title = "MOVE MODE - Arrows to move, Enter/Esc to exit" if active else ""
|
|
286
|
+
|
|
287
|
+
def action_toggle_move(self) -> None:
|
|
288
|
+
"""Enter or exit move mode for the focused post-it."""
|
|
289
|
+
focused = self.focused
|
|
290
|
+
if not isinstance(focused, PostIt):
|
|
291
|
+
self.notify("Select a note to move.", severity="warning")
|
|
292
|
+
return
|
|
293
|
+
|
|
294
|
+
if self.move_mode:
|
|
295
|
+
self._exit_move_mode()
|
|
296
|
+
else:
|
|
297
|
+
self._moving_post_it = focused
|
|
298
|
+
focused.add_class("moving")
|
|
299
|
+
self.move_mode = True
|
|
300
|
+
|
|
301
|
+
def action_cancel_move(self) -> None:
|
|
302
|
+
"""Cancel move mode via Escape."""
|
|
303
|
+
if self.move_mode:
|
|
304
|
+
self._exit_move_mode()
|
|
305
|
+
|
|
306
|
+
def _exit_move_mode(self) -> None:
|
|
307
|
+
"""Exit move mode and remove visual indicators."""
|
|
308
|
+
if self._moving_post_it:
|
|
309
|
+
self._moving_post_it.remove_class("moving")
|
|
310
|
+
self._moving_post_it.focus()
|
|
311
|
+
self._moving_post_it = None
|
|
312
|
+
self.move_mode = False
|
|
313
|
+
|
|
314
|
+
# ── Navigation ──────────────────────────────────────────────
|
|
315
|
+
|
|
316
|
+
@staticmethod
|
|
317
|
+
def _calc_target_idx(key: str, current_idx: int, total: int) -> int | None:
|
|
318
|
+
"""Calculate the target grid index for an arrow-key press.
|
|
319
|
+
|
|
320
|
+
Args:
|
|
321
|
+
key: The key name ('up', 'down', 'left', 'right').
|
|
322
|
+
current_idx: Current position in the flat grid.
|
|
323
|
+
total: Total number of grid children.
|
|
324
|
+
|
|
325
|
+
Returns:
|
|
326
|
+
Target index, or None if the move is out of bounds.
|
|
327
|
+
"""
|
|
328
|
+
if key == "up" and current_idx >= GRID_COLUMNS:
|
|
329
|
+
return current_idx - GRID_COLUMNS
|
|
330
|
+
if key == "down" and current_idx + GRID_COLUMNS < total:
|
|
331
|
+
return current_idx + GRID_COLUMNS
|
|
332
|
+
if key == "left" and current_idx % GRID_COLUMNS > 0:
|
|
333
|
+
return current_idx - 1
|
|
334
|
+
if (
|
|
335
|
+
key == "right"
|
|
336
|
+
and current_idx % GRID_COLUMNS < GRID_COLUMNS - 1
|
|
337
|
+
and current_idx + 1 < total
|
|
338
|
+
):
|
|
339
|
+
return current_idx + 1
|
|
340
|
+
return None
|
|
341
|
+
|
|
342
|
+
def on_key(self, event: Key) -> None:
|
|
343
|
+
"""Handle arrow keys for grid navigation and move mode.
|
|
344
|
+
|
|
345
|
+
Args:
|
|
346
|
+
event: The key event from Textual.
|
|
347
|
+
"""
|
|
348
|
+
if event.key not in ("up", "down", "left", "right", "enter", "escape"):
|
|
349
|
+
return
|
|
350
|
+
|
|
351
|
+
if self.move_mode and self._moving_post_it:
|
|
352
|
+
if event.key in ("enter", "escape"):
|
|
353
|
+
self._exit_move_mode()
|
|
354
|
+
event.prevent_default()
|
|
355
|
+
return
|
|
356
|
+
|
|
357
|
+
grid = self._get_grid()
|
|
358
|
+
children = list(grid.children)
|
|
359
|
+
current_idx = children.index(self._moving_post_it)
|
|
360
|
+
target_idx = self._calc_target_idx(event.key, current_idx, len(children))
|
|
361
|
+
|
|
362
|
+
if target_idx is not None:
|
|
363
|
+
self._swap_positions(current_idx, target_idx)
|
|
364
|
+
event.prevent_default()
|
|
365
|
+
return
|
|
366
|
+
|
|
367
|
+
if event.key in ("up", "down", "left", "right"):
|
|
368
|
+
grid = self._get_grid()
|
|
369
|
+
children = list(grid.children)
|
|
370
|
+
focused = self.focused
|
|
371
|
+
if focused not in children:
|
|
372
|
+
return
|
|
373
|
+
current_idx = children.index(focused)
|
|
374
|
+
target_idx = self._calc_target_idx(event.key, current_idx, len(children))
|
|
375
|
+
|
|
376
|
+
if target_idx is not None:
|
|
377
|
+
children[target_idx].focus()
|
|
378
|
+
event.prevent_default()
|
|
379
|
+
|
|
380
|
+
# ── Swap logic ──────────────────────────────────────────────
|
|
381
|
+
|
|
382
|
+
def _swap_positions(self, idx_a: int, idx_b: int) -> None:
|
|
383
|
+
"""Swap two grid positions. Supports PostIt↔PostIt and PostIt↔EmptySlot.
|
|
384
|
+
|
|
385
|
+
Args:
|
|
386
|
+
idx_a: First grid index.
|
|
387
|
+
idx_b: Second grid index.
|
|
388
|
+
"""
|
|
389
|
+
grid = self._get_grid()
|
|
390
|
+
children = list(grid.children)
|
|
391
|
+
|
|
392
|
+
if idx_a >= len(children) or idx_b >= len(children):
|
|
393
|
+
return
|
|
394
|
+
|
|
395
|
+
widget_a = children[idx_a]
|
|
396
|
+
widget_b = children[idx_b]
|
|
397
|
+
|
|
398
|
+
if isinstance(widget_a, PostIt) and isinstance(widget_b, PostIt):
|
|
399
|
+
self._swap_post_its(widget_a, widget_b)
|
|
400
|
+
elif isinstance(widget_a, PostIt) and isinstance(widget_b, EmptySlot):
|
|
401
|
+
self._move_to_empty(widget_a, widget_b, idx_b, grid)
|
|
402
|
+
elif isinstance(widget_a, EmptySlot) and isinstance(widget_b, PostIt):
|
|
403
|
+
self._move_to_empty(widget_b, widget_a, idx_a, grid)
|
|
404
|
+
|
|
405
|
+
self._save_to_disk()
|
|
406
|
+
|
|
407
|
+
def _swap_post_its(self, widget_a: PostIt, widget_b: PostIt) -> None:
|
|
408
|
+
"""Swap data between two PostIt widgets and update move-mode tracking.
|
|
409
|
+
|
|
410
|
+
Args:
|
|
411
|
+
widget_a: First post-it.
|
|
412
|
+
widget_b: Second post-it.
|
|
413
|
+
"""
|
|
414
|
+
widget_a.title, widget_b.title = widget_b.title, widget_a.title
|
|
415
|
+
widget_a.content, widget_b.content = widget_b.content, widget_a.content
|
|
416
|
+
widget_a.color_index, widget_b.color_index = widget_b.color_index, widget_a.color_index
|
|
417
|
+
|
|
418
|
+
if self._moving_post_it is widget_a:
|
|
419
|
+
self._transfer_moving_class(widget_a, widget_b)
|
|
420
|
+
elif self._moving_post_it is widget_b:
|
|
421
|
+
self._transfer_moving_class(widget_b, widget_a)
|
|
422
|
+
|
|
423
|
+
def _move_to_empty(
|
|
424
|
+
self, post_it: PostIt, empty: EmptySlot, target_idx: int, grid: Grid
|
|
425
|
+
) -> None:
|
|
426
|
+
"""Move a PostIt to an EmptySlot position, leaving an EmptySlot behind.
|
|
427
|
+
|
|
428
|
+
Args:
|
|
429
|
+
post_it: The post-it being moved.
|
|
430
|
+
empty: The empty slot at the target position.
|
|
431
|
+
target_idx: The grid index of the target position.
|
|
432
|
+
grid: The grid container.
|
|
433
|
+
"""
|
|
434
|
+
new_post = PostIt.from_dict(post_it.to_dict(grid_index=target_idx))
|
|
435
|
+
new_empty = EmptySlot()
|
|
436
|
+
|
|
437
|
+
grid.mount(new_post, before=empty)
|
|
438
|
+
empty.remove()
|
|
439
|
+
grid.mount(new_empty, before=post_it)
|
|
440
|
+
post_it.remove()
|
|
441
|
+
|
|
442
|
+
if self._moving_post_it is post_it:
|
|
443
|
+
self._moving_post_it = new_post
|
|
444
|
+
new_post.add_class("moving")
|
|
445
|
+
new_post.focus()
|
|
446
|
+
|
|
447
|
+
def _transfer_moving_class(self, from_widget: PostIt, to_widget: PostIt) -> None:
|
|
448
|
+
"""Transfer the 'moving' visual class from one post-it to another.
|
|
449
|
+
|
|
450
|
+
Args:
|
|
451
|
+
from_widget: The post-it losing the moving state.
|
|
452
|
+
to_widget: The post-it gaining the moving state.
|
|
453
|
+
"""
|
|
454
|
+
from_widget.remove_class("moving")
|
|
455
|
+
self._moving_post_it = to_widget
|
|
456
|
+
to_widget.add_class("moving")
|
|
457
|
+
to_widget.focus()
|
tui_notes/constants.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Shared constants for tui-notes."""
|
|
2
|
+
|
|
3
|
+
MAX_NOTES: int = 9
|
|
4
|
+
"""Maximum number of post-its in the grid."""
|
|
5
|
+
|
|
6
|
+
GRID_COLUMNS: int = 3
|
|
7
|
+
"""Number of columns in the grid layout."""
|
|
8
|
+
|
|
9
|
+
COLORS: list[tuple[str, str]] = [
|
|
10
|
+
("Yellow", "post-it-0"),
|
|
11
|
+
("Green", "post-it-1"),
|
|
12
|
+
("Blue", "post-it-2"),
|
|
13
|
+
("Pink", "post-it-3"),
|
|
14
|
+
("Orange", "post-it-4"),
|
|
15
|
+
("Purple", "post-it-5"),
|
|
16
|
+
]
|
|
17
|
+
"""Available post-it colors as (display_name, css_class) tuples."""
|
|
18
|
+
|
|
19
|
+
NUM_COLORS: int = len(COLORS)
|
|
20
|
+
"""Total number of available colors."""
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Screen components for tui-notes."""
|
|
2
|
+
|
|
3
|
+
from tui_notes.screens.color_picker import ColorPickerScreen
|
|
4
|
+
from tui_notes.screens.confirm import ConfirmScreen
|
|
5
|
+
from tui_notes.screens.edit_post_it import EditPostItScreen
|
|
6
|
+
from tui_notes.screens.help import HelpScreen
|
|
7
|
+
|
|
8
|
+
__all__ = ["ColorPickerScreen", "ConfirmScreen", "EditPostItScreen", "HelpScreen"]
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Color picker modal screen."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from textual.app import ComposeResult
|
|
6
|
+
from textual.binding import Binding
|
|
7
|
+
from textual.containers import Container, Horizontal
|
|
8
|
+
from textual.screen import ModalScreen
|
|
9
|
+
from textual.widgets import Button, Static
|
|
10
|
+
|
|
11
|
+
from tui_notes.constants import COLORS
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ColorPickerScreen(ModalScreen[int | None]):
|
|
15
|
+
"""Modal screen for selecting a post-it color from the palette."""
|
|
16
|
+
|
|
17
|
+
BINDINGS = [
|
|
18
|
+
Binding("escape", "cancel", "Cancel"),
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
def compose(self) -> ComposeResult:
|
|
22
|
+
"""Compose the color picker layout with one button per color."""
|
|
23
|
+
with Container(id="color-modal"):
|
|
24
|
+
yield Static("Choose a color", id="color-modal-title")
|
|
25
|
+
with Horizontal(id="color-options"):
|
|
26
|
+
for i, (name, css_class) in enumerate(COLORS):
|
|
27
|
+
yield Button(name, id=f"color-{i}", classes=f"color-btn {css_class}")
|
|
28
|
+
|
|
29
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
30
|
+
"""Dismiss with the selected color index."""
|
|
31
|
+
btn_id = event.button.id or ""
|
|
32
|
+
if btn_id.startswith("color-"):
|
|
33
|
+
idx = int(btn_id.split("-")[1])
|
|
34
|
+
self.dismiss(idx)
|
|
35
|
+
|
|
36
|
+
def action_cancel(self) -> None:
|
|
37
|
+
"""Dismiss without selecting a color."""
|
|
38
|
+
self.dismiss(None)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Confirmation modal screen."""
|
|
2
|
+
|
|
3
|
+
from textual.app import ComposeResult
|
|
4
|
+
from textual.binding import Binding
|
|
5
|
+
from textual.containers import Container, Horizontal
|
|
6
|
+
from textual.screen import ModalScreen
|
|
7
|
+
from textual.widgets import Button, Static
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ConfirmScreen(ModalScreen[bool]):
|
|
11
|
+
"""Modal screen that asks for yes/no confirmation."""
|
|
12
|
+
|
|
13
|
+
BINDINGS = [
|
|
14
|
+
Binding("escape", "cancel", "Cancel"),
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
def __init__(self, message: str) -> None:
|
|
18
|
+
"""Initialize with a confirmation message.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
message: The question to display.
|
|
22
|
+
"""
|
|
23
|
+
super().__init__()
|
|
24
|
+
self._message = message
|
|
25
|
+
|
|
26
|
+
def compose(self) -> ComposeResult:
|
|
27
|
+
"""Compose the confirmation dialog layout."""
|
|
28
|
+
with Container(id="confirm-modal"):
|
|
29
|
+
yield Static(self._message, id="confirm-message")
|
|
30
|
+
with Horizontal(id="confirm-buttons"):
|
|
31
|
+
yield Button("Yes", variant="error", id="confirm-yes")
|
|
32
|
+
yield Button("No", variant="default", id="confirm-no")
|
|
33
|
+
|
|
34
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
35
|
+
"""Dismiss with True for Yes, False for No."""
|
|
36
|
+
self.dismiss(event.button.id == "confirm-yes")
|
|
37
|
+
|
|
38
|
+
def action_cancel(self) -> None:
|
|
39
|
+
"""Dismiss as cancelled (False)."""
|
|
40
|
+
self.dismiss(False)
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Edit post-it modal screen."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from textual.app import ComposeResult
|
|
6
|
+
from textual.binding import Binding
|
|
7
|
+
from textual.containers import Container, Horizontal
|
|
8
|
+
from textual.screen import ModalScreen
|
|
9
|
+
from textual.widgets import Button, Input, Static, TextArea
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class EditPostItScreen(ModalScreen[dict | None]):
|
|
13
|
+
"""Modal screen for editing a post-it note's title and content."""
|
|
14
|
+
|
|
15
|
+
BINDINGS = [
|
|
16
|
+
Binding("escape", "cancel", "Cancel"),
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
def __init__(self, title: str, content: str) -> None:
|
|
20
|
+
"""Initialize with the current note values.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
title: Current note title.
|
|
24
|
+
content: Current note content.
|
|
25
|
+
"""
|
|
26
|
+
super().__init__()
|
|
27
|
+
self._edit_title = title
|
|
28
|
+
self._edit_content = content
|
|
29
|
+
|
|
30
|
+
def compose(self) -> ComposeResult:
|
|
31
|
+
"""Compose the edit modal layout."""
|
|
32
|
+
with Container(id="edit-modal"):
|
|
33
|
+
yield Static("Edit Note", id="edit-modal-title")
|
|
34
|
+
yield Input(value=self._edit_title, placeholder="Title", id="edit-title-input")
|
|
35
|
+
yield TextArea(self._edit_content, id="edit-content-input")
|
|
36
|
+
with Horizontal(id="edit-buttons"):
|
|
37
|
+
yield Button("Save", variant="primary", id="edit-save")
|
|
38
|
+
yield Button("Cancel", variant="default", id="edit-cancel")
|
|
39
|
+
|
|
40
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
41
|
+
"""Handle Save or Cancel button clicks."""
|
|
42
|
+
if event.button.id == "edit-save":
|
|
43
|
+
title = self.query_one("#edit-title-input", Input).value
|
|
44
|
+
content = self.query_one("#edit-content-input", TextArea).text
|
|
45
|
+
self.dismiss({"title": title, "content": content})
|
|
46
|
+
else:
|
|
47
|
+
self.dismiss(None)
|
|
48
|
+
|
|
49
|
+
def action_cancel(self) -> None:
|
|
50
|
+
"""Dismiss the modal without saving."""
|
|
51
|
+
self.dismiss(None)
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Help modal screen."""
|
|
2
|
+
|
|
3
|
+
from textual.app import ComposeResult
|
|
4
|
+
from textual.binding import Binding
|
|
5
|
+
from textual.containers import Container
|
|
6
|
+
from textual.events import Key
|
|
7
|
+
from textual.screen import ModalScreen
|
|
8
|
+
from textual.widgets import Static
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class HelpScreen(ModalScreen[None]):
|
|
12
|
+
"""Modal screen showing all keyboard shortcuts."""
|
|
13
|
+
|
|
14
|
+
BINDINGS = [
|
|
15
|
+
Binding("escape", "close", "Close"),
|
|
16
|
+
Binding("question_mark", "close", "Close", show=False),
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
HELP_TEXT = """\
|
|
20
|
+
╔══════════════════════════════════════╗
|
|
21
|
+
║ TUI Notes - Help ║
|
|
22
|
+
╠══════════════════════════════════════╣
|
|
23
|
+
║ ║
|
|
24
|
+
║ Navigation ║
|
|
25
|
+
║ ← ↑ ↓ → Move between slots ║
|
|
26
|
+
║ ║
|
|
27
|
+
║ Notes ║
|
|
28
|
+
║ a Add note at position ║
|
|
29
|
+
║ e / Enter Edit selected note ║
|
|
30
|
+
║ d Delete selected note ║
|
|
31
|
+
║ c Change note color ║
|
|
32
|
+
║ ║
|
|
33
|
+
║ Organization ║
|
|
34
|
+
║ m Move mode (swap notes) ║
|
|
35
|
+
║ Escape Cancel move mode ║
|
|
36
|
+
║ ║
|
|
37
|
+
║ File ║
|
|
38
|
+
║ Ctrl+S Save notes ║
|
|
39
|
+
║ Ctrl+R Reload notes ║
|
|
40
|
+
║ Ctrl+E Export to markdown ║
|
|
41
|
+
║ ║
|
|
42
|
+
║ Other ║
|
|
43
|
+
║ ? Show this help ║
|
|
44
|
+
║ q Quit ║
|
|
45
|
+
║ ║
|
|
46
|
+
╚══════════════════════════════════════╝\
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def compose(self) -> ComposeResult:
|
|
50
|
+
"""Compose the help screen layout."""
|
|
51
|
+
with Container(id="help-modal"):
|
|
52
|
+
yield Static(self.HELP_TEXT, id="help-text")
|
|
53
|
+
yield Static("Press Escape or ? to close", id="help-close-hint")
|
|
54
|
+
|
|
55
|
+
def action_close(self) -> None:
|
|
56
|
+
"""Dismiss the help screen."""
|
|
57
|
+
self.dismiss(None)
|
|
58
|
+
|
|
59
|
+
def on_key(self, event: Key) -> None:
|
|
60
|
+
"""Dismiss on any key press."""
|
|
61
|
+
self.dismiss(None)
|
tui_notes/storage.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Persistence layer for tui-notes. Saves/loads post-its as JSON."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import platform
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _get_data_dir() -> Path:
|
|
11
|
+
"""Return the platform-specific data directory for tui-notes.
|
|
12
|
+
|
|
13
|
+
Returns:
|
|
14
|
+
Path to the tui-notes configuration directory.
|
|
15
|
+
"""
|
|
16
|
+
system = platform.system()
|
|
17
|
+
if system == "Windows":
|
|
18
|
+
base = Path.home() / "AppData" / "Roaming"
|
|
19
|
+
elif system == "Darwin":
|
|
20
|
+
base = Path.home() / "Library" / "Application Support"
|
|
21
|
+
else:
|
|
22
|
+
xdg_config = os.environ.get("XDG_CONFIG_HOME")
|
|
23
|
+
base = Path(xdg_config) if xdg_config else Path.home() / ".config"
|
|
24
|
+
return base / "tui-notes"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _get_data_file() -> Path:
|
|
28
|
+
"""Return the path to the notes JSON file.
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
Path to notes.json inside the data directory.
|
|
32
|
+
"""
|
|
33
|
+
return _get_data_dir() / "notes.json"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def save_notes(post_its: list[dict[str, Any]]) -> None:
|
|
37
|
+
"""Save post-its to JSON file using atomic write.
|
|
38
|
+
|
|
39
|
+
Writes to a temporary file first, then atomically replaces the
|
|
40
|
+
target file to prevent data corruption on failures.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
post_its: List of dicts with keys: position, title, content, color_index.
|
|
44
|
+
|
|
45
|
+
Raises:
|
|
46
|
+
OSError: If the file cannot be written.
|
|
47
|
+
"""
|
|
48
|
+
data_dir = _get_data_dir()
|
|
49
|
+
data_dir.mkdir(parents=True, exist_ok=True)
|
|
50
|
+
|
|
51
|
+
data = {"version": "1.0", "post_its": post_its}
|
|
52
|
+
|
|
53
|
+
tmp_file = _get_data_file().with_suffix(".tmp")
|
|
54
|
+
try:
|
|
55
|
+
tmp_file.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
|
|
56
|
+
tmp_file.replace(_get_data_file())
|
|
57
|
+
except OSError:
|
|
58
|
+
tmp_file.unlink(missing_ok=True)
|
|
59
|
+
raise
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _validate_note(note: Any) -> dict[str, Any] | None:
|
|
63
|
+
"""Validate and normalize a single note dictionary.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
note: Raw note data from JSON.
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
Normalized note dict, or None if invalid.
|
|
70
|
+
"""
|
|
71
|
+
if not isinstance(note, dict):
|
|
72
|
+
return None
|
|
73
|
+
if "position" not in note or "title" not in note:
|
|
74
|
+
return None
|
|
75
|
+
return {
|
|
76
|
+
"position": int(note["position"]),
|
|
77
|
+
"title": str(note["title"]),
|
|
78
|
+
"content": str(note.get("content", "")),
|
|
79
|
+
"color_index": int(note.get("color_index", note["position"] % 6)),
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def load_notes() -> list[dict[str, Any]]:
|
|
84
|
+
"""Load post-its from JSON file.
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
List of validated note dicts. Empty list if file doesn't
|
|
88
|
+
exist, is corrupted, or contains no valid notes.
|
|
89
|
+
"""
|
|
90
|
+
data_file = _get_data_file()
|
|
91
|
+
if not data_file.exists():
|
|
92
|
+
return []
|
|
93
|
+
|
|
94
|
+
try:
|
|
95
|
+
raw = data_file.read_text(encoding="utf-8")
|
|
96
|
+
data = json.loads(raw)
|
|
97
|
+
if not isinstance(data, dict) or "post_its" not in data:
|
|
98
|
+
return []
|
|
99
|
+
notes = data["post_its"]
|
|
100
|
+
if not isinstance(notes, list):
|
|
101
|
+
return []
|
|
102
|
+
return [v for note in notes if (v := _validate_note(note)) is not None]
|
|
103
|
+
except (json.JSONDecodeError, OSError, ValueError):
|
|
104
|
+
return []
|
tui_notes/style.tcss
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/* Main application styles */
|
|
2
|
+
|
|
3
|
+
Screen {
|
|
4
|
+
background: $surface;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
#notes-grid {
|
|
8
|
+
grid-size: 3 3;
|
|
9
|
+
grid-gutter: 1;
|
|
10
|
+
padding: 1;
|
|
11
|
+
height: 1fr;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
PostIt {
|
|
15
|
+
height: 100%;
|
|
16
|
+
width: 100%;
|
|
17
|
+
padding: 0 1;
|
|
18
|
+
border: solid #888;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/* Yellow */
|
|
22
|
+
.post-it-0 {
|
|
23
|
+
background: #fff59d;
|
|
24
|
+
border: solid #fbc02d;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/* Green */
|
|
28
|
+
.post-it-1 {
|
|
29
|
+
background: #a5d6a7;
|
|
30
|
+
border: solid #388e3c;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/* Blue */
|
|
34
|
+
.post-it-2 {
|
|
35
|
+
background: #90caf9;
|
|
36
|
+
border: solid #1976d2;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/* Pink */
|
|
40
|
+
.post-it-3 {
|
|
41
|
+
background: #f48fb1;
|
|
42
|
+
border: solid #c2185b;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/* Orange */
|
|
46
|
+
.post-it-4 {
|
|
47
|
+
background: #ffcc80;
|
|
48
|
+
border: solid #f57c00;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/* Purple */
|
|
52
|
+
.post-it-5 {
|
|
53
|
+
background: #ce93d8;
|
|
54
|
+
border: solid #7b1fa2;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
.post-it-title {
|
|
58
|
+
text-style: bold;
|
|
59
|
+
color: #212121;
|
|
60
|
+
height: auto;
|
|
61
|
+
padding: 0 0 1 0;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
.post-it-content {
|
|
65
|
+
color: #212121;
|
|
66
|
+
height: 1fr;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/* Focus indicator */
|
|
70
|
+
PostIt:focus {
|
|
71
|
+
border: double #fff;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/* Moving indicator */
|
|
75
|
+
PostIt.moving {
|
|
76
|
+
border: double #ff5722;
|
|
77
|
+
opacity: 0.9;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/* Edit Modal */
|
|
81
|
+
#edit-modal {
|
|
82
|
+
width: 60;
|
|
83
|
+
height: 24;
|
|
84
|
+
background: $surface;
|
|
85
|
+
border: thick $primary;
|
|
86
|
+
padding: 1 2;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
#edit-modal-title {
|
|
90
|
+
text-style: bold;
|
|
91
|
+
text-align: center;
|
|
92
|
+
width: 100%;
|
|
93
|
+
height: auto;
|
|
94
|
+
padding: 0 0 1 0;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
#edit-title-input {
|
|
98
|
+
width: 100%;
|
|
99
|
+
margin: 0 0 1 0;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
#edit-content-input {
|
|
103
|
+
width: 100%;
|
|
104
|
+
height: 1fr;
|
|
105
|
+
margin: 0 0 1 0;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
#edit-buttons {
|
|
109
|
+
width: 100%;
|
|
110
|
+
height: auto;
|
|
111
|
+
align-horizontal: right;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
#edit-buttons Button {
|
|
115
|
+
margin: 0 0 0 1;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
EditPostItScreen {
|
|
119
|
+
align: center middle;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/* Empty slot placeholder */
|
|
123
|
+
.empty-slot {
|
|
124
|
+
height: 100%;
|
|
125
|
+
width: 100%;
|
|
126
|
+
padding: 0 1;
|
|
127
|
+
border: dashed #555;
|
|
128
|
+
color: #666;
|
|
129
|
+
content-align: center middle;
|
|
130
|
+
text-style: italic;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
.empty-slot:focus {
|
|
134
|
+
border: dashed #aaa;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/* Confirm Modal */
|
|
138
|
+
#confirm-modal {
|
|
139
|
+
width: 50;
|
|
140
|
+
height: 10;
|
|
141
|
+
background: $surface;
|
|
142
|
+
border: thick $error;
|
|
143
|
+
padding: 1 2;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
#confirm-message {
|
|
147
|
+
text-align: center;
|
|
148
|
+
width: 100%;
|
|
149
|
+
height: auto;
|
|
150
|
+
padding: 1 0;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
#confirm-buttons {
|
|
154
|
+
width: 100%;
|
|
155
|
+
height: auto;
|
|
156
|
+
align-horizontal: center;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
#confirm-buttons Button {
|
|
160
|
+
margin: 0 1;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
ConfirmScreen {
|
|
164
|
+
align: center middle;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/* Help Modal */
|
|
168
|
+
HelpScreen {
|
|
169
|
+
align: center middle;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
#help-modal {
|
|
173
|
+
width: 46;
|
|
174
|
+
height: auto;
|
|
175
|
+
background: $surface;
|
|
176
|
+
border: thick $primary;
|
|
177
|
+
padding: 1 2;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
#help-text {
|
|
181
|
+
width: 100%;
|
|
182
|
+
height: auto;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
#help-close-hint {
|
|
186
|
+
text-align: center;
|
|
187
|
+
color: $text-muted;
|
|
188
|
+
width: 100%;
|
|
189
|
+
height: auto;
|
|
190
|
+
padding: 1 0 0 0;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/* Color Picker Modal */
|
|
194
|
+
ColorPickerScreen {
|
|
195
|
+
align: center middle;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
#color-modal {
|
|
199
|
+
width: 76;
|
|
200
|
+
height: auto;
|
|
201
|
+
background: $surface;
|
|
202
|
+
border: thick $primary;
|
|
203
|
+
padding: 1 2;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
#color-modal-title {
|
|
207
|
+
text-style: bold;
|
|
208
|
+
text-align: center;
|
|
209
|
+
width: 100%;
|
|
210
|
+
height: auto;
|
|
211
|
+
padding: 0 0 1 0;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
#color-options {
|
|
215
|
+
width: 100%;
|
|
216
|
+
height: auto;
|
|
217
|
+
align-horizontal: center;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
.color-btn {
|
|
221
|
+
margin: 0 1;
|
|
222
|
+
min-width: 8;
|
|
223
|
+
color: #212121;
|
|
224
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Empty slot placeholder widget."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from textual.widgets import Static
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class EmptySlot(Static, can_focus=True):
|
|
9
|
+
"""Placeholder widget for an empty position in the post-it grid."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, **kwargs: Any) -> None:
|
|
12
|
+
"""Initialize an empty slot with placeholder text.
|
|
13
|
+
|
|
14
|
+
Args:
|
|
15
|
+
**kwargs: Additional keyword arguments passed to Static.
|
|
16
|
+
"""
|
|
17
|
+
super().__init__("Press 'a' to add a note", **kwargs)
|
|
18
|
+
self.add_class("empty-slot")
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Post-it note widget."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from textual.app import ComposeResult
|
|
8
|
+
from textual.containers import Container
|
|
9
|
+
from textual.reactive import reactive
|
|
10
|
+
from textual.widgets import Static
|
|
11
|
+
|
|
12
|
+
from tui_notes.constants import NUM_COLORS
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class PostIt(Container, can_focus=True):
|
|
16
|
+
"""A single post-it note widget with reactive title, content, and color."""
|
|
17
|
+
|
|
18
|
+
title: reactive[str] = reactive("")
|
|
19
|
+
content: reactive[str] = reactive("")
|
|
20
|
+
position: reactive[int] = reactive(0)
|
|
21
|
+
color_index: reactive[int] = reactive(0)
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
position: int,
|
|
26
|
+
title: str = "",
|
|
27
|
+
content: str = "",
|
|
28
|
+
color_index: int | None = None,
|
|
29
|
+
**kwargs: Any,
|
|
30
|
+
) -> None:
|
|
31
|
+
"""Initialize a post-it note.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
position: Grid position index (0-8).
|
|
35
|
+
title: Note title. Defaults to 'Note {position + 1}'.
|
|
36
|
+
content: Note body text.
|
|
37
|
+
color_index: Color palette index (0-5). Defaults to position % 6.
|
|
38
|
+
**kwargs: Additional keyword arguments passed to Container.
|
|
39
|
+
"""
|
|
40
|
+
super().__init__(**kwargs)
|
|
41
|
+
self.position = position
|
|
42
|
+
self.title = title or f"Note {position + 1}"
|
|
43
|
+
self.content = content
|
|
44
|
+
self.color_index = color_index if color_index is not None else position % NUM_COLORS
|
|
45
|
+
self.add_class(f"post-it-{self.color_index}")
|
|
46
|
+
|
|
47
|
+
def compose(self) -> ComposeResult:
|
|
48
|
+
"""Compose the post-it layout with title and content labels."""
|
|
49
|
+
yield Static(self.title, classes="post-it-title")
|
|
50
|
+
yield Static(self.content or "( empty )", classes="post-it-content")
|
|
51
|
+
|
|
52
|
+
def watch_title(self, new_title: str) -> None:
|
|
53
|
+
"""Update the title label when the reactive property changes."""
|
|
54
|
+
try:
|
|
55
|
+
self.query_one(".post-it-title", Static).update(new_title)
|
|
56
|
+
except Exception: # noqa: BLE001 - widget may not be composed yet
|
|
57
|
+
pass
|
|
58
|
+
|
|
59
|
+
def watch_content(self, new_content: str) -> None:
|
|
60
|
+
"""Update the content label when the reactive property changes."""
|
|
61
|
+
try:
|
|
62
|
+
self.query_one(".post-it-content", Static).update(new_content or "( empty )")
|
|
63
|
+
except Exception: # noqa: BLE001 - widget may not be composed yet
|
|
64
|
+
pass
|
|
65
|
+
|
|
66
|
+
def watch_color_index(self, new_color: int) -> None:
|
|
67
|
+
"""Swap CSS color class when the color index changes."""
|
|
68
|
+
for i in range(NUM_COLORS):
|
|
69
|
+
self.remove_class(f"post-it-{i}")
|
|
70
|
+
self.add_class(f"post-it-{new_color}")
|
|
71
|
+
|
|
72
|
+
def to_dict(self, grid_index: int | None = None) -> dict[str, Any]:
|
|
73
|
+
"""Serialize this post-it to a dictionary for persistence.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
grid_index: Override position with actual grid index. If None, uses self.position.
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
Dictionary with position, title, content, and color_index keys.
|
|
80
|
+
"""
|
|
81
|
+
return {
|
|
82
|
+
"position": grid_index if grid_index is not None else self.position,
|
|
83
|
+
"title": self.title,
|
|
84
|
+
"content": self.content,
|
|
85
|
+
"color_index": self.color_index,
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
@classmethod
|
|
89
|
+
def from_dict(cls, data: dict[str, Any]) -> "PostIt":
|
|
90
|
+
"""Create a PostIt instance from a persistence dictionary.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
data: Dictionary with position, title, content, and optional color_index.
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
A new PostIt instance.
|
|
97
|
+
"""
|
|
98
|
+
return cls(
|
|
99
|
+
position=data["position"],
|
|
100
|
+
title=data["title"],
|
|
101
|
+
content=data.get("content", ""),
|
|
102
|
+
color_index=data.get("color_index"),
|
|
103
|
+
)
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tui-notes
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A terminal-based post-it notes application with a 3x3 grid layout
|
|
5
|
+
Project-URL: Homepage, https://github.com/Douglas019BR/tui-notes
|
|
6
|
+
Project-URL: Repository, https://github.com/Douglas019BR/tui-notes
|
|
7
|
+
Author: Douglas
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: notes,post-it,terminal,textual,tui
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Topic :: Utilities
|
|
22
|
+
Requires-Python: >=3.9
|
|
23
|
+
Requires-Dist: textual>=0.50.0
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: pytest-asyncio>=0.21.0; extra == 'dev'
|
|
26
|
+
Requires-Dist: pytest>=7.0.0; extra == 'dev'
|
|
27
|
+
Requires-Dist: textual-dev>=1.0.0; extra == 'dev'
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
# TUI Notes 📝
|
|
31
|
+
|
|
32
|
+
A terminal-based post-it notes application — organize your thoughts in a 3×3 grid, right from the command line.
|
|
33
|
+
|
|
34
|
+

|
|
35
|
+

|
|
36
|
+
|
|
37
|
+
## Features
|
|
38
|
+
|
|
39
|
+
- **3×3 Post-it Grid** — Up to 9 notes displayed simultaneously
|
|
40
|
+
- **6 Colors** — Yellow, Green, Blue, Pink, Orange, Purple (press `c` to change)
|
|
41
|
+
- **Persistent Storage** — Auto-saves to `~/.config/tui-notes/notes.json`
|
|
42
|
+
- **Move Mode** — Rearrange notes freely, even to empty slots
|
|
43
|
+
- **Export** — Export all notes to Markdown (`Ctrl+E`)
|
|
44
|
+
- **Keyboard-driven** — Full operation without mouse
|
|
45
|
+
|
|
46
|
+
## Installation
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
# Clone and install
|
|
50
|
+
git clone https://github.com/douglas/tui-notes.git
|
|
51
|
+
cd tui-notes
|
|
52
|
+
pip install -e .
|
|
53
|
+
|
|
54
|
+
# Or with pipx (recommended for CLI tools)
|
|
55
|
+
pipx install .
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Usage
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
tui-notes
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Keyboard Shortcuts
|
|
65
|
+
|
|
66
|
+
| Key | Action |
|
|
67
|
+
|-----|--------|
|
|
68
|
+
| `a` | Add note at selected position |
|
|
69
|
+
| `e` / `Enter` | Edit selected note |
|
|
70
|
+
| `d` | Delete selected note (with confirmation) |
|
|
71
|
+
| `c` | Change note color |
|
|
72
|
+
| `m` | Enter Move mode (swap/reorder) |
|
|
73
|
+
| `←` `↑` `↓` `→` | Navigate between slots |
|
|
74
|
+
| `Ctrl+S` | Save notes manually |
|
|
75
|
+
| `Ctrl+R` | Reload notes from disk |
|
|
76
|
+
| `Ctrl+E` | Export to `~/tui-notes-export.md` |
|
|
77
|
+
| `?` | Show help screen |
|
|
78
|
+
| `Escape` | Cancel move mode |
|
|
79
|
+
| `q` | Quit |
|
|
80
|
+
|
|
81
|
+
## Data Storage
|
|
82
|
+
|
|
83
|
+
Notes are stored as JSON in the platform-specific config directory:
|
|
84
|
+
|
|
85
|
+
| Platform | Path |
|
|
86
|
+
|----------|------|
|
|
87
|
+
| Linux | `~/.config/tui-notes/notes.json` |
|
|
88
|
+
| macOS | `~/Library/Application Support/tui-notes/notes.json` |
|
|
89
|
+
| Windows | `%APPDATA%/tui-notes/notes.json` |
|
|
90
|
+
|
|
91
|
+
## Development
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
# Setup
|
|
95
|
+
python -m venv venv
|
|
96
|
+
source venv/bin/activate
|
|
97
|
+
pip install -e .
|
|
98
|
+
pip install -r requirements-dev.txt
|
|
99
|
+
|
|
100
|
+
# Run
|
|
101
|
+
python -m tui_notes
|
|
102
|
+
|
|
103
|
+
# Tests
|
|
104
|
+
pytest
|
|
105
|
+
|
|
106
|
+
# Linting
|
|
107
|
+
black tui_notes/ tests/
|
|
108
|
+
isort tui_notes/ tests/
|
|
109
|
+
pylint tui_notes/
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## Requirements
|
|
113
|
+
|
|
114
|
+
- Python 3.9+
|
|
115
|
+
- Terminal with color support
|
|
116
|
+
|
|
117
|
+
## License
|
|
118
|
+
|
|
119
|
+
MIT — see [LICENSE](LICENSE) file.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
tui_notes/__init__.py,sha256=1hQttvKLgFR_tAvCTz5fj3iPhVBfj3uEPaFUmPTobyk,84
|
|
2
|
+
tui_notes/__main__.py,sha256=3QV3suxWxyY1Yc_U_yoxUIIBU9xlgLWIByAQXB0tI14,219
|
|
3
|
+
tui_notes/app.py,sha256=fckExxpe2yFfG6WLmXKtZ0W1RPDBVJBxNU2loB4OIWs,17001
|
|
4
|
+
tui_notes/constants.py,sha256=eYUgw0xAGU7yZVMI1hE-etGWo7dNmeSk5h5VKlqEnY8,516
|
|
5
|
+
tui_notes/storage.py,sha256=dQKKRJ_O-sOPmVxMQmYQ1igjGYFwBdktnKkmlqgZatU,3027
|
|
6
|
+
tui_notes/style.tcss,sha256=m_O-wIiA_CN_Mk5K8VNshL26L8yBiSvLode5eMdvzt4,3128
|
|
7
|
+
tui_notes/screens/__init__.py,sha256=cu87bksor5Mlam0t2cDG8pvFd6RqErAtv_WaPI3LPEY,343
|
|
8
|
+
tui_notes/screens/color_picker.py,sha256=HOoBrdblN66Y307OO6rdfJTAvqR21Ife1i2hhIv4gDE,1323
|
|
9
|
+
tui_notes/screens/confirm.py,sha256=Gg-SDlXQhz5If-fo2LX6pvSOJ7xBHaYf3ThAcv2zuHk,1323
|
|
10
|
+
tui_notes/screens/edit_post_it.py,sha256=0nb0d8BXP0UeP2LR9WpCzUHh3inRgqjfSVMQ-sBWGMo,1879
|
|
11
|
+
tui_notes/screens/help.py,sha256=q1uxQrWpR9kV_8D2ueLxu68YFE3NlgEjqSP9dMulDYo,2426
|
|
12
|
+
tui_notes/widgets/__init__.py,sha256=sp6pZ1wVKqaTIPGjWMV-lsTAdfaD8lvJqUWZVD33raE,171
|
|
13
|
+
tui_notes/widgets/empty_slot.py,sha256=_zrCcYh_5BQTL6_PDkW-3XVeLNFVGB03D2zBAqA3mqY,514
|
|
14
|
+
tui_notes/widgets/post_it.py,sha256=Z0rT8vfizD8j3O6BLljtp7gQZ1M_bd4pqsU6tFkg1Ao,3640
|
|
15
|
+
tui_notes-0.1.0.dist-info/METADATA,sha256=D7yNltJNEcIbboQhK8F-lyvAZHLJgMqi3kbJGgNU9w4,3182
|
|
16
|
+
tui_notes-0.1.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
17
|
+
tui_notes-0.1.0.dist-info/entry_points.txt,sha256=clRZAS4oLB4kpcpT_wX-KrGJ4Cs-8c7cMNlzpkyN2Yc,54
|
|
18
|
+
tui_notes-0.1.0.dist-info/licenses/LICENSE,sha256=RDTcpOFpOBHhc5B8W82fNXED63zMXBRBEOq7jk7MZvg,1074
|
|
19
|
+
tui_notes-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Douglas Sermarini
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|