riched 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.
riched/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ """Riched package."""
2
+
riched/app.py ADDED
@@ -0,0 +1,433 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Iterable
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ from textual import events
8
+ from textual.app import App, ComposeResult, SystemCommand
9
+ from textual.binding import Binding
10
+ from textual.command import CommandPalette
11
+ from textual.containers import Container, Horizontal
12
+ from textual.keys import format_key
13
+ from textual.screen import Screen
14
+ from textual.widgets import DirectoryTree, Footer, Header, Static, TextArea
15
+
16
+ from .editor import RichedTextArea
17
+ from .keybindings import display_key
18
+ from .screens import KeysHelpScreen, QuickOpenScreen, UnsavedChangesScreen
19
+ from .settings import load_theme, save_theme
20
+ from .syntax import apply_language
21
+
22
+ FILE_TREE_DEFAULT_WIDTH = 30
23
+ FILE_TREE_MIN_WIDTH = 18
24
+ FILE_TREE_MAX_WIDTH = 44
25
+ QUICK_OPEN_SKIP_DIRS = {
26
+ ".git",
27
+ ".venv",
28
+ "__pycache__",
29
+ "build",
30
+ "dist",
31
+ "node_modules",
32
+ }
33
+ KEY_MODIFIER_SYMBOLS = {
34
+ "cmd": "⌘",
35
+ "super": "⌘",
36
+ "ctrl": "⌃",
37
+ "control": "⌃",
38
+ "alt": "⌥",
39
+ "option": "⌥",
40
+ "shift": "⇧",
41
+ }
42
+
43
+
44
+ class RichedDirectoryTree(DirectoryTree):
45
+ """Directory tree with editor-style keyboard affordances."""
46
+
47
+ BINDINGS = [
48
+ *(binding for binding in DirectoryTree.BINDINGS if binding.key != "space"),
49
+ Binding("left", "collapse_cursor", "Collapse folder", show=False),
50
+ Binding("right", "expand_cursor", "Expand folder", show=False),
51
+ Binding("space", "activate_cursor", "Open file or toggle folder", show=False),
52
+ ]
53
+
54
+ def _cursor_dir_node(self) -> Any | None:
55
+ node = self.cursor_node
56
+ if node is None or node.data is None or not node.allow_expand:
57
+ return None
58
+ return node
59
+
60
+ def action_collapse_cursor(self) -> None:
61
+ node = self._cursor_dir_node()
62
+ if node is not None:
63
+ node.collapse()
64
+
65
+ def action_expand_cursor(self) -> None:
66
+ node = self._cursor_dir_node()
67
+ if node is not None:
68
+ node.expand()
69
+
70
+ def action_activate_cursor(self) -> None:
71
+ node = self.cursor_node
72
+ if node is None:
73
+ return
74
+ if node.allow_expand:
75
+ node.toggle()
76
+ return
77
+ self.action_select_cursor()
78
+
79
+
80
+ class FileTreeResizeHandle(Static):
81
+ """Mouse handle for resizing the file tree."""
82
+
83
+ can_focus = False
84
+
85
+ def __init__(self) -> None:
86
+ super().__init__("", id="file-tree-resize-handle")
87
+ self._drag_start_x: int | None = None
88
+ self._drag_start_width: int | None = None
89
+
90
+ def on_mouse_down(self, event: events.MouseDown) -> None:
91
+ if event.button != 1 or event.screen_x is None:
92
+ return
93
+ tree = self.app.query_one("#file-tree", DirectoryTree)
94
+ self._drag_start_x = int(event.screen_x)
95
+ self._drag_start_width = tree.region.width
96
+ self.capture_mouse()
97
+ self.add_class("dragging")
98
+ event.stop()
99
+ event.prevent_default()
100
+
101
+ def on_mouse_move(self, event: events.MouseMove) -> None:
102
+ if self._drag_start_x is None or self._drag_start_width is None:
103
+ return
104
+ if event.screen_x is None:
105
+ return
106
+ delta = int(event.screen_x) - self._drag_start_x
107
+ app = self.app
108
+ if isinstance(app, RichedApp):
109
+ app.set_file_tree_width(self._drag_start_width + delta)
110
+ event.stop()
111
+ event.prevent_default()
112
+
113
+ def on_mouse_up(self, event: events.MouseUp) -> None:
114
+ if self._drag_start_x is None:
115
+ return
116
+ self._drag_start_x = None
117
+ self._drag_start_width = None
118
+ self.release_mouse()
119
+ self.remove_class("dragging")
120
+ event.stop()
121
+ event.prevent_default()
122
+
123
+
124
+ class RichedApp(App):
125
+ """TUI text editor with a project file tree."""
126
+
127
+ CSS = """
128
+ #workspace {
129
+ height: 1fr;
130
+ }
131
+ #file-tree {
132
+ width: 30;
133
+ min-width: 18;
134
+ max-width: 44;
135
+ }
136
+ #file-tree-resize-handle {
137
+ width: 1;
138
+ min-width: 1;
139
+ max-width: 1;
140
+ height: 1fr;
141
+ background: $panel;
142
+ }
143
+ #file-tree-resize-handle:hover,
144
+ #file-tree-resize-handle.dragging {
145
+ background: $accent;
146
+ }
147
+ #editor {
148
+ height: 1fr;
149
+ width: 1fr;
150
+ }
151
+ #editor-slot {
152
+ height: 1fr;
153
+ width: 1fr;
154
+ }
155
+ HeaderIcon {
156
+ display: none;
157
+ }
158
+ """
159
+
160
+ BINDINGS: list[Binding] = []
161
+
162
+ def __init__(
163
+ self,
164
+ path: Path,
165
+ root: Path | None = None,
166
+ ) -> None:
167
+ super().__init__()
168
+ saved_theme = load_theme()
169
+ if saved_theme in self.available_themes:
170
+ self.theme = saved_theme
171
+ self.watch(self, "theme", self._save_theme, init=False)
172
+ initial_path = path.expanduser()
173
+ self._initial_path = None if initial_path.is_dir() else initial_path
174
+ self.path: Path | None = self._initial_path
175
+ self.root = root or Path.cwd()
176
+ self._saved_text = ""
177
+
178
+ def _save_theme(self, theme: str) -> None:
179
+ try:
180
+ save_theme(theme)
181
+ except Exception as exc:
182
+ self.notify(f"Could not save theme: {exc}", severity="warning")
183
+
184
+ def get_system_commands(self, screen: Screen) -> Iterable[SystemCommand]:
185
+ for command in super().get_system_commands(screen):
186
+ if command.title == "Maximize":
187
+ continue
188
+ if command.title == "Keys":
189
+ yield SystemCommand(
190
+ "Keys",
191
+ "Show key bindings",
192
+ self.action_show_keys_popup,
193
+ )
194
+ continue
195
+ yield command
196
+
197
+ def get_key_display(self, binding: Binding) -> str:
198
+ if binding.key_display:
199
+ return binding.key_display
200
+
201
+ key = display_key(binding.key)
202
+ parts = key.split("+")
203
+ base_key = format_key(parts[-1])
204
+ if len(base_key) == 1:
205
+ base_key = base_key.upper()
206
+ else:
207
+ base_key = base_key.title()
208
+ modifiers = "".join(
209
+ KEY_MODIFIER_SYMBOLS.get(part, part) for part in parts[:-1]
210
+ )
211
+ return f"{modifiers}{base_key}"
212
+
213
+ def compose(self) -> ComposeResult:
214
+ yield Header()
215
+ with Horizontal(id="workspace"):
216
+ yield RichedDirectoryTree(str(self.root), id="file-tree")
217
+ yield FileTreeResizeHandle()
218
+ yield Container(id="editor-slot")
219
+ yield Footer()
220
+
221
+ def on_mount(self) -> None:
222
+ self.title = "riched"
223
+ self.set_file_tree_width(FILE_TREE_DEFAULT_WIDTH)
224
+ if self._initial_path is None:
225
+ self.sub_title = ""
226
+ self.query_one("#file-tree", DirectoryTree).focus()
227
+ return
228
+ self._open_path(self._initial_path)
229
+
230
+ def _editor_or_none(self) -> TextArea | None:
231
+ editors = list(self.query("#editor"))
232
+ return editors[0] if editors else None
233
+
234
+ def _file_tree(self) -> DirectoryTree:
235
+ return self.query_one("#file-tree", DirectoryTree)
236
+
237
+ def _file_tree_resize_handle(self) -> Static:
238
+ return self.query_one("#file-tree-resize-handle", Static)
239
+
240
+ def _is_file_tree_visible(self) -> bool:
241
+ return self._file_tree().styles.display != "none"
242
+
243
+ def _show_file_tree(self) -> None:
244
+ self._file_tree().styles.display = "block"
245
+ self._file_tree_resize_handle().styles.display = "block"
246
+
247
+ def _hide_file_tree(self) -> None:
248
+ self._file_tree().styles.display = "none"
249
+ self._file_tree_resize_handle().styles.display = "none"
250
+
251
+ def set_file_tree_width(self, width: int) -> None:
252
+ clamped_width = max(FILE_TREE_MIN_WIDTH, min(FILE_TREE_MAX_WIDTH, width))
253
+ tree = self._file_tree()
254
+ tree.styles.width = clamped_width
255
+ tree.refresh(layout=True)
256
+
257
+ def _get_or_create_editor(self) -> TextArea:
258
+ editor = self._editor_or_none()
259
+ if editor is not None:
260
+ return editor
261
+ editor = RichedTextArea.code_editor(id="editor", theme="css")
262
+ self.query_one("#editor-slot", Container).mount(editor)
263
+ return editor
264
+
265
+ def _close_buffer(self) -> None:
266
+ self.query_one("#editor-slot", Container).remove_children()
267
+ self.path = None
268
+ self.sub_title = ""
269
+ self._saved_text = ""
270
+ self._show_file_tree()
271
+ self._file_tree().focus()
272
+
273
+ def _open_path(self, path: Path) -> None:
274
+ self.path = path.expanduser()
275
+ self.sub_title = str(self.path)
276
+ editor = self._get_or_create_editor()
277
+ if self.path.exists():
278
+ try:
279
+ content = self.path.read_text()
280
+ except Exception as exc:
281
+ self.notify(f"Could not read: {exc}", severity="error")
282
+ content = ""
283
+ else:
284
+ content = ""
285
+ editor.load_text(content)
286
+ self._saved_text = content
287
+ try:
288
+ apply_language(editor, self.path.suffix)
289
+ except Exception as exc:
290
+ self.notify(f"Syntax highlight off: {exc}", severity="warning")
291
+ editor.focus()
292
+
293
+ def _is_dirty(self) -> bool:
294
+ editor = self._editor_or_none()
295
+ return self.path is not None and editor is not None and editor.text != self._saved_text
296
+
297
+ def on_directory_tree_file_selected(
298
+ self, event: DirectoryTree.FileSelected
299
+ ) -> None:
300
+ event.stop()
301
+ selected = Path(event.path)
302
+ if selected == self.path:
303
+ editor = self._editor_or_none()
304
+ if editor is not None:
305
+ editor.focus()
306
+ return
307
+ self._switch_path(selected)
308
+
309
+ def _switch_path(self, selected: Path) -> None:
310
+ if not self._is_dirty():
311
+ self._open_path(selected)
312
+ return
313
+
314
+ def handle(choice: str) -> None:
315
+ if choice == "save":
316
+ self.action_save()
317
+ if not self._is_dirty():
318
+ self._open_path(selected)
319
+ elif choice == "discard":
320
+ self._open_path(selected)
321
+
322
+ self.push_screen(UnsavedChangesScreen(), handle)
323
+
324
+ def _quick_open_files(self) -> list[Path]:
325
+ root = self.root.expanduser()
326
+ files: list[Path] = []
327
+ stack = [root]
328
+ while stack:
329
+ directory = stack.pop()
330
+ try:
331
+ entries = sorted(directory.iterdir(), key=lambda path: path.name.lower())
332
+ except OSError:
333
+ continue
334
+ for entry in entries:
335
+ try:
336
+ if entry.is_dir():
337
+ if entry.name not in QUICK_OPEN_SKIP_DIRS and not entry.is_symlink():
338
+ stack.append(entry)
339
+ elif entry.is_file():
340
+ files.append(entry)
341
+ except OSError:
342
+ continue
343
+ return sorted(files, key=lambda path: path.relative_to(root).as_posix().lower())
344
+
345
+ def action_quick_open(self) -> None:
346
+ def handle(selected: Path | None) -> None:
347
+ if selected is not None:
348
+ self._switch_path(selected)
349
+
350
+ self.push_screen(
351
+ QuickOpenScreen(self.root.expanduser(), self._quick_open_files()),
352
+ handle,
353
+ )
354
+
355
+ def action_toggle_command_palette(self) -> None:
356
+ if CommandPalette.is_open(self):
357
+ self.pop_screen()
358
+ return
359
+ self.action_command_palette()
360
+
361
+ def action_show_keys_popup(self) -> None:
362
+ self.push_screen(KeysHelpScreen())
363
+
364
+ def action_save(self) -> None:
365
+ if self.path is None:
366
+ self.notify("No file open", severity="warning")
367
+ return
368
+ editor = self._editor_or_none()
369
+ if editor is None:
370
+ self.notify("No file open", severity="warning")
371
+ return
372
+ try:
373
+ self.path.write_text(editor.text)
374
+ except Exception as exc:
375
+ self.notify(f"Save failed: {exc}", severity="error")
376
+ return
377
+ self._saved_text = editor.text
378
+ self.notify(f"Saved {self.path}")
379
+
380
+ def action_quit_check(self) -> None:
381
+ if not self._is_dirty():
382
+ self.exit()
383
+ return
384
+
385
+ def handle(choice: str) -> None:
386
+ if choice == "save":
387
+ self.action_save()
388
+ if not self._is_dirty():
389
+ self.exit()
390
+ elif choice == "discard":
391
+ self.exit()
392
+
393
+ self.push_screen(UnsavedChangesScreen(), handle)
394
+
395
+ def action_close_buffer(self) -> None:
396
+ if self.path is None:
397
+ return
398
+ if not self._is_dirty():
399
+ self._close_buffer()
400
+ return
401
+
402
+ def handle(choice: str) -> None:
403
+ if choice == "save":
404
+ self.action_save()
405
+ if not self._is_dirty():
406
+ self._close_buffer()
407
+ elif choice == "discard":
408
+ self._close_buffer()
409
+
410
+ self.push_screen(UnsavedChangesScreen(), handle)
411
+
412
+ def action_toggle_file_tree(self) -> None:
413
+ editor = self._editor_or_none()
414
+ if self._is_file_tree_visible():
415
+ if editor is None:
416
+ return
417
+ self._hide_file_tree()
418
+ editor.focus()
419
+ return
420
+ self._show_file_tree()
421
+
422
+ def action_toggle_file_tree_focus(self) -> None:
423
+ tree = self._file_tree()
424
+ if not self._is_file_tree_visible():
425
+ self._show_file_tree()
426
+ tree.focus()
427
+ return
428
+ editor = self._editor_or_none()
429
+ if tree.has_focus:
430
+ if editor is not None:
431
+ editor.focus()
432
+ return
433
+ tree.focus()
riched/bindings.yaml ADDED
@@ -0,0 +1,76 @@
1
+ app:
2
+ commands:
3
+ - name: save
4
+ description: Save
5
+ key: ctrl+s
6
+ - name: quit_check
7
+ description: Quit
8
+ key: ctrl+q
9
+ - name: close_buffer
10
+ description: Close buffer
11
+ key: ctrl+w
12
+ - name: show_keys_popup
13
+ description: Key bindings
14
+ key: f1
15
+ - name: toggle_file_tree
16
+ description: Toggle file tree
17
+ key: cmd+b,super+b
18
+ - name: quick_open
19
+ description: Quick open
20
+ key: cmd+p,super+p
21
+
22
+ editor:
23
+ - key: alt+up
24
+ action: move_line_up
25
+ description: Move line up
26
+ show: false
27
+ - key: alt+down
28
+ action: move_line_down
29
+ description: Move line down
30
+ show: false
31
+ - key: alt+shift+up,shift+alt+up
32
+ action: copy_line_up
33
+ description: Copy line up
34
+ show: false
35
+ - key: alt+shift+down,shift+alt+down
36
+ action: copy_line_down
37
+ description: Copy line down
38
+ show: false
39
+ - key: alt+backspace
40
+ action: delete_word_left
41
+ description: Delete word left
42
+ show: false
43
+ - key: alt+shift+left,shift+alt+left
44
+ action: cursor_word_left(True)
45
+ description: Select word left
46
+ show: false
47
+ - key: alt+shift+right,shift+alt+right
48
+ action: cursor_word_right(True)
49
+ description: Select word right
50
+ show: false
51
+ - key: super+l,cmd+l
52
+ action: select_line
53
+ description: Select line
54
+ show: false
55
+ - key: shift+super+left,super+shift+left,shift+cmd+left,cmd+shift+left
56
+ action: cursor_line_start(True)
57
+ description: Select to line start
58
+ show: false
59
+ - key: shift+super+right,super+shift+right,shift+cmd+right,cmd+shift+right
60
+ action: cursor_line_end(True)
61
+ description: Select to line end
62
+ show: false
63
+
64
+ screens:
65
+ unsaved_changes:
66
+ - key: escape
67
+ action: cancel
68
+ description: Cancel
69
+ quick_open:
70
+ - key: escape
71
+ action: close
72
+ description: Close
73
+ keys_help:
74
+ - key: escape
75
+ action: close
76
+ description: Close
riched/cli.py ADDED
@@ -0,0 +1,24 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ from pathlib import Path
5
+
6
+ from .app import RichedApp
7
+ from .keybindings import build_bindings
8
+
9
+
10
+ def main() -> int:
11
+ parser = argparse.ArgumentParser(
12
+ prog="riched", description="A minimal Textual TUI text editor."
13
+ )
14
+ parser.add_argument("filename", help="File to open (created on save if missing)")
15
+ args = parser.parse_args()
16
+
17
+ path = Path(args.filename).expanduser()
18
+ root = path if path.is_dir() else Path.cwd()
19
+
20
+ class ConfiguredRichedApp(RichedApp):
21
+ BINDINGS = build_bindings()
22
+
23
+ ConfiguredRichedApp(path, root).run()
24
+ return 0
riched/editor.py ADDED
@@ -0,0 +1,96 @@
1
+ from __future__ import annotations
2
+
3
+ from textual.widgets import TextArea
4
+
5
+ from .keybindings import build_static_bindings
6
+
7
+
8
+ class RichedTextArea(TextArea):
9
+ """TextArea with VS Code-style line-edit shortcuts."""
10
+
11
+ BINDINGS = build_static_bindings("editor")
12
+
13
+ def _clamp_location(self, location: tuple[int, int]) -> tuple[int, int]:
14
+ row, column = location
15
+ row = max(0, min(row, self.document.line_count - 1))
16
+ column = max(0, min(column, len(self.document.get_line(row))))
17
+ return row, column
18
+
19
+ def _clamp_selection(self) -> None:
20
+ start, end = self.selection
21
+ clamped_start = self._clamp_location(start)
22
+ clamped_end = self._clamp_location(end)
23
+ if (clamped_start, clamped_end) != (start, end):
24
+ self.selection = type(self.selection)(clamped_start, clamped_end)
25
+
26
+ def _refresh_size(self) -> None:
27
+ self._clamp_selection()
28
+ super()._refresh_size()
29
+
30
+ def _line_selection_end(self, row: int) -> tuple[int, int]:
31
+ if row + 1 < self.document.line_count:
32
+ return (row + 1, 0)
33
+ return (row, len(self.document.get_line(row)))
34
+
35
+ def _has_whole_line_selection(self) -> bool:
36
+ start, end = self.selection
37
+ if start > end:
38
+ return False
39
+ if start[1] != 0:
40
+ return False
41
+ if end[1] == 0 and end[0] > start[0]:
42
+ return True
43
+ last_row = self.document.line_count - 1
44
+ return end[0] == last_row and end[1] == len(self.document.get_line(last_row))
45
+
46
+ def action_select_line(self) -> None:
47
+ start, end = self.selection
48
+ if self._has_whole_line_selection():
49
+ selection_start = start
50
+ row = min(end[0], self.document.line_count - 1)
51
+ else:
52
+ row, _column = self.cursor_location
53
+ selection_start = (row, 0)
54
+
55
+ selection_end = self._line_selection_end(row)
56
+ self.selection = type(self.selection)(selection_start, selection_end)
57
+
58
+ def action_move_line_down(self) -> None:
59
+ row, col = self.cursor_location
60
+ if row + 1 >= self.document.line_count:
61
+ return
62
+ line_a = self.document.get_line(row)
63
+ line_b = self.document.get_line(row + 1)
64
+ self.replace(
65
+ line_b + "\n" + line_a,
66
+ (row, 0),
67
+ (row + 1, len(line_b)),
68
+ maintain_selection_offset=False,
69
+ )
70
+ self.move_cursor((row + 1, col))
71
+
72
+ def action_move_line_up(self) -> None:
73
+ row, col = self.cursor_location
74
+ if row == 0:
75
+ return
76
+ line_a = self.document.get_line(row - 1)
77
+ line_b = self.document.get_line(row)
78
+ self.replace(
79
+ line_b + "\n" + line_a,
80
+ (row - 1, 0),
81
+ (row, len(line_b)),
82
+ maintain_selection_offset=False,
83
+ )
84
+ self.move_cursor((row - 1, col))
85
+
86
+ def action_copy_line_down(self) -> None:
87
+ row, col = self.cursor_location
88
+ line = self.document.get_line(row)
89
+ self.insert("\n" + line, (row, len(line)), maintain_selection_offset=False)
90
+ self.move_cursor((row + 1, col))
91
+
92
+ def action_copy_line_up(self) -> None:
93
+ row, col = self.cursor_location
94
+ line = self.document.get_line(row)
95
+ self.insert(line + "\n", (row, 0), maintain_selection_offset=False)
96
+ self.move_cursor((row, col))