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 ADDED
@@ -0,0 +1,3 @@
1
+ """tui-notes: A terminal-based post-it notes application."""
2
+
3
+ __version__ = "0.1.0"
tui_notes/__main__.py ADDED
@@ -0,0 +1,13 @@
1
+ """Entry point for tui-notes application."""
2
+
3
+ from tui_notes.app import NotesApp
4
+
5
+
6
+ def main() -> None:
7
+ """Run the tui-notes application."""
8
+ app = NotesApp()
9
+ app.run()
10
+
11
+
12
+ if __name__ == "__main__":
13
+ main()
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,6 @@
1
+ """Widget components for tui-notes."""
2
+
3
+ from tui_notes.widgets.empty_slot import EmptySlot
4
+ from tui_notes.widgets.post_it import PostIt
5
+
6
+ __all__ = ["EmptySlot", "PostIt"]
@@ -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
+ ![Python](https://img.shields.io/badge/Python-3.9%2B-blue)
35
+ ![License](https://img.shields.io/badge/License-MIT-green)
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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.28.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ tui-notes = tui_notes.__main__:main
@@ -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.