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 +2 -0
- riched/app.py +433 -0
- riched/bindings.yaml +76 -0
- riched/cli.py +24 -0
- riched/editor.py +96 -0
- riched/keybindings.py +123 -0
- riched/screens.py +284 -0
- riched/settings.py +47 -0
- riched/syntax.py +241 -0
- riched-0.1.0.dist-info/METADATA +72 -0
- riched-0.1.0.dist-info/RECORD +13 -0
- riched-0.1.0.dist-info/WHEEL +4 -0
- riched-0.1.0.dist-info/entry_points.txt +2 -0
riched/__init__.py
ADDED
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))
|