candat 0.1.1__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.
- candat/__init__.py +5 -0
- candat/app.py +501 -0
- candat/buffers.py +60 -0
- candat/chords.py +85 -0
- candat/commands.py +54 -0
- candat/dialogs.py +160 -0
- candat/editor.py +419 -0
- candat/help.py +120 -0
- candat/isearch.py +174 -0
- candat/killring.py +42 -0
- candat/preview.py +24 -0
- candat/rlang.py +44 -0
- candat/terminal.py +350 -0
- candat/theme.py +28 -0
- candat-0.1.1.dist-info/METADATA +119 -0
- candat-0.1.1.dist-info/RECORD +19 -0
- candat-0.1.1.dist-info/WHEEL +4 -0
- candat-0.1.1.dist-info/entry_points.txt +3 -0
- candat-0.1.1.dist-info/licenses/LICENSE +21 -0
candat/__init__.py
ADDED
candat/app.py
ADDED
|
@@ -0,0 +1,501 @@
|
|
|
1
|
+
"""Candat: a terminal IDE with emacs keybindings, built on Textual."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from textual import on
|
|
9
|
+
from textual.app import App, ComposeResult
|
|
10
|
+
from textual.binding import Binding
|
|
11
|
+
from textual.containers import Horizontal
|
|
12
|
+
from textual.widgets import (
|
|
13
|
+
DirectoryTree,
|
|
14
|
+
Static,
|
|
15
|
+
TabbedContent,
|
|
16
|
+
TabPane,
|
|
17
|
+
TextArea,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
from .buffers import BufferListScreen
|
|
21
|
+
from .chords import CTRL_C_MAP, CTRL_X_MAP, ChordScreen
|
|
22
|
+
from .commands import CandatCommands
|
|
23
|
+
from .dialogs import ConfirmScreen, PromptScreen
|
|
24
|
+
from .editor import EditorBuffer
|
|
25
|
+
from .help import HelpScreen
|
|
26
|
+
from .killring import KillRing
|
|
27
|
+
from .preview import PREVIEW_CLASSES, PREVIEW_MODES, MarkdownPreview
|
|
28
|
+
from .terminal import TerminalPane
|
|
29
|
+
from .theme import CANDAT_LIGHT
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class StatusBar(Static):
|
|
33
|
+
"""One-line status: buffer name, modified flag, cursor position, language."""
|
|
34
|
+
|
|
35
|
+
def show(self, editor: EditorBuffer | None) -> None:
|
|
36
|
+
if editor is None:
|
|
37
|
+
self.update(" candat")
|
|
38
|
+
return
|
|
39
|
+
modified = "*" if editor.modified else ""
|
|
40
|
+
row, col = editor.cursor_location
|
|
41
|
+
language = editor.language or "text"
|
|
42
|
+
where = str(editor.path) if editor.path else editor.display_name
|
|
43
|
+
self.update(
|
|
44
|
+
f" {where}{modified} Ln {row + 1}, Col {col + 1} {language}"
|
|
45
|
+
" [dim]F1 help[/]"
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class CandatApp(App[None]):
|
|
50
|
+
TITLE = "candat"
|
|
51
|
+
COMMAND_PALETTE_BINDING = "ctrl+shift+p"
|
|
52
|
+
COMMANDS = App.COMMANDS | {CandatCommands}
|
|
53
|
+
|
|
54
|
+
CSS = """
|
|
55
|
+
#workspace {
|
|
56
|
+
height: 1fr;
|
|
57
|
+
}
|
|
58
|
+
DirectoryTree {
|
|
59
|
+
width: 32;
|
|
60
|
+
max-width: 40%;
|
|
61
|
+
border-right: solid $panel;
|
|
62
|
+
background: $surface;
|
|
63
|
+
}
|
|
64
|
+
TabbedContent {
|
|
65
|
+
width: 1fr;
|
|
66
|
+
}
|
|
67
|
+
TabPane {
|
|
68
|
+
padding: 0;
|
|
69
|
+
}
|
|
70
|
+
EditorBuffer {
|
|
71
|
+
border: none;
|
|
72
|
+
width: 1fr;
|
|
73
|
+
}
|
|
74
|
+
MarkdownPreview {
|
|
75
|
+
display: none;
|
|
76
|
+
width: 1fr;
|
|
77
|
+
border-left: solid $panel;
|
|
78
|
+
background: $background;
|
|
79
|
+
padding: 0 1;
|
|
80
|
+
}
|
|
81
|
+
TabPane.-preview-split MarkdownPreview,
|
|
82
|
+
TabPane.-preview-only MarkdownPreview {
|
|
83
|
+
display: block;
|
|
84
|
+
}
|
|
85
|
+
TabPane.-preview-only EditorBuffer {
|
|
86
|
+
display: none;
|
|
87
|
+
}
|
|
88
|
+
StatusBar {
|
|
89
|
+
dock: bottom;
|
|
90
|
+
height: 1;
|
|
91
|
+
background: $panel;
|
|
92
|
+
color: $foreground;
|
|
93
|
+
}
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
BINDINGS = [
|
|
97
|
+
Binding("ctrl+x", "chord_prefix", "C-x", priority=True, show=False),
|
|
98
|
+
Binding("ctrl+g", "keyboard_quit", "C-g", priority=True, show=False),
|
|
99
|
+
# C-c is a prefix (mode commands), never Textual's quit.
|
|
100
|
+
Binding("ctrl+c", "chord_prefix_cc", show=False, priority=True),
|
|
101
|
+
Binding("alt+x", "command_palette", "M-x", show=False),
|
|
102
|
+
Binding("f1", "help", "help", show=False),
|
|
103
|
+
]
|
|
104
|
+
|
|
105
|
+
def __init__(self, paths: list[Path] | None = None) -> None:
|
|
106
|
+
super().__init__()
|
|
107
|
+
self.kill_ring = KillRing()
|
|
108
|
+
self.last_search = ""
|
|
109
|
+
paths = paths or []
|
|
110
|
+
self._root = Path.cwd()
|
|
111
|
+
dirs = [p for p in paths if p.is_dir()]
|
|
112
|
+
if dirs:
|
|
113
|
+
self._root = dirs[0]
|
|
114
|
+
self._files = [p for p in paths if not p.is_dir()]
|
|
115
|
+
self._buffer_count = 0
|
|
116
|
+
|
|
117
|
+
def compose(self) -> ComposeResult:
|
|
118
|
+
with Horizontal(id="workspace"):
|
|
119
|
+
yield DirectoryTree(self._root)
|
|
120
|
+
yield TabbedContent()
|
|
121
|
+
yield TerminalPane()
|
|
122
|
+
yield StatusBar()
|
|
123
|
+
|
|
124
|
+
async def on_mount(self) -> None:
|
|
125
|
+
self.register_theme(CANDAT_LIGHT)
|
|
126
|
+
self.theme = "candat-light"
|
|
127
|
+
if self._files:
|
|
128
|
+
for path in self._files:
|
|
129
|
+
await self._open_path(path)
|
|
130
|
+
else:
|
|
131
|
+
await self._new_buffer()
|
|
132
|
+
|
|
133
|
+
# -- buffer bookkeeping ------------------------------------------------
|
|
134
|
+
|
|
135
|
+
@property
|
|
136
|
+
def tabs(self) -> TabbedContent:
|
|
137
|
+
return self.query_one(TabbedContent)
|
|
138
|
+
|
|
139
|
+
@property
|
|
140
|
+
def active_editor(self) -> EditorBuffer | None:
|
|
141
|
+
pane = self.tabs.active_pane
|
|
142
|
+
if pane is None:
|
|
143
|
+
return None
|
|
144
|
+
return pane.query_one(EditorBuffer)
|
|
145
|
+
|
|
146
|
+
def editors(self) -> list[EditorBuffer]:
|
|
147
|
+
return list(self.query(EditorBuffer))
|
|
148
|
+
|
|
149
|
+
async def _new_buffer(self, path: Path | None = None) -> EditorBuffer:
|
|
150
|
+
self._buffer_count += 1
|
|
151
|
+
pane_id = f"buffer-{self._buffer_count}"
|
|
152
|
+
editor = EditorBuffer(path=None)
|
|
153
|
+
if path is not None and path.exists():
|
|
154
|
+
editor.load(path)
|
|
155
|
+
else:
|
|
156
|
+
editor.path = path
|
|
157
|
+
editor._apply_language()
|
|
158
|
+
pane = TabPane(
|
|
159
|
+
editor.display_name, Horizontal(editor, MarkdownPreview()), id=pane_id
|
|
160
|
+
)
|
|
161
|
+
await self.tabs.add_pane(pane)
|
|
162
|
+
# Linked preview: follow the editor's scroll position.
|
|
163
|
+
self.watch(
|
|
164
|
+
editor, "scroll_y", lambda: self._sync_preview_scroll(editor), init=False
|
|
165
|
+
)
|
|
166
|
+
self.tabs.active = pane_id
|
|
167
|
+
editor.focus()
|
|
168
|
+
if editor.language == "markdown":
|
|
169
|
+
await self._set_preview_mode(pane, "split")
|
|
170
|
+
return editor
|
|
171
|
+
|
|
172
|
+
async def _open_path(self, path: Path) -> None:
|
|
173
|
+
path = path.expanduser().resolve()
|
|
174
|
+
if path.is_dir():
|
|
175
|
+
self.notify(f"{path} is a directory", severity="warning")
|
|
176
|
+
return
|
|
177
|
+
# Already open? Just switch to it.
|
|
178
|
+
for editor in self.editors():
|
|
179
|
+
if editor.path == path:
|
|
180
|
+
pane = self._pane_of(editor)
|
|
181
|
+
if pane is not None and pane.id is not None:
|
|
182
|
+
self.tabs.active = pane.id
|
|
183
|
+
editor.focus()
|
|
184
|
+
return
|
|
185
|
+
# Reuse a pristine untitled buffer instead of stacking new tabs.
|
|
186
|
+
current = self.active_editor
|
|
187
|
+
if current and current.path is None and not current.modified and not current.text:
|
|
188
|
+
if path.exists():
|
|
189
|
+
current.load(path)
|
|
190
|
+
else:
|
|
191
|
+
current.path = path
|
|
192
|
+
current._apply_language()
|
|
193
|
+
self._refresh_tab_label(current)
|
|
194
|
+
current.focus()
|
|
195
|
+
self._refresh_status()
|
|
196
|
+
pane = self._pane_of(current)
|
|
197
|
+
if current.language == "markdown" and pane is not None:
|
|
198
|
+
await self._set_preview_mode(pane, "split")
|
|
199
|
+
return
|
|
200
|
+
await self._new_buffer(path)
|
|
201
|
+
if not path.exists():
|
|
202
|
+
self.notify("(new file)", timeout=2)
|
|
203
|
+
|
|
204
|
+
def _pane_of(self, editor: EditorBuffer) -> TabPane | None:
|
|
205
|
+
for ancestor in editor.ancestors:
|
|
206
|
+
if isinstance(ancestor, TabPane):
|
|
207
|
+
return ancestor
|
|
208
|
+
return None
|
|
209
|
+
|
|
210
|
+
def _refresh_tab_label(self, editor: EditorBuffer) -> None:
|
|
211
|
+
pane = self._pane_of(editor)
|
|
212
|
+
if pane is None or pane.id is None:
|
|
213
|
+
return
|
|
214
|
+
label = editor.display_name + ("*" if editor.modified else "")
|
|
215
|
+
self.tabs.get_tab(pane.id).label = label
|
|
216
|
+
|
|
217
|
+
def _refresh_status(self) -> None:
|
|
218
|
+
self.query_one(StatusBar).show(self.active_editor)
|
|
219
|
+
editor = self.active_editor
|
|
220
|
+
self.sub_title = str(editor.path) if editor and editor.path else ""
|
|
221
|
+
|
|
222
|
+
# -- markdown preview ----------------------------------------------------
|
|
223
|
+
|
|
224
|
+
def _preview_mode(self, pane: TabPane) -> str:
|
|
225
|
+
if pane.has_class("-preview-only"):
|
|
226
|
+
return "only"
|
|
227
|
+
if pane.has_class("-preview-split"):
|
|
228
|
+
return "split"
|
|
229
|
+
return "off"
|
|
230
|
+
|
|
231
|
+
async def _set_preview_mode(self, pane: TabPane, mode: str) -> None:
|
|
232
|
+
pane.remove_class(*PREVIEW_CLASSES.values())
|
|
233
|
+
if mode in PREVIEW_CLASSES:
|
|
234
|
+
pane.add_class(PREVIEW_CLASSES[mode])
|
|
235
|
+
editor = pane.query_one(EditorBuffer)
|
|
236
|
+
preview = pane.query_one(MarkdownPreview)
|
|
237
|
+
if mode != "off":
|
|
238
|
+
await preview.render_text(editor.text)
|
|
239
|
+
if mode == "only":
|
|
240
|
+
preview.focus()
|
|
241
|
+
else:
|
|
242
|
+
editor.focus()
|
|
243
|
+
|
|
244
|
+
def _sync_preview_scroll(self, editor: EditorBuffer) -> None:
|
|
245
|
+
"""Keep the preview scrolled to the same relative position as the
|
|
246
|
+
editor (linked view)."""
|
|
247
|
+
pane = self._pane_of(editor)
|
|
248
|
+
if pane is None or self._preview_mode(pane) != "split":
|
|
249
|
+
return
|
|
250
|
+
if editor.max_scroll_y <= 0:
|
|
251
|
+
return
|
|
252
|
+
preview = pane.query_one(MarkdownPreview)
|
|
253
|
+
fraction = editor.scroll_y / editor.max_scroll_y
|
|
254
|
+
preview.scroll_to(y=fraction * preview.max_scroll_y, animate=False)
|
|
255
|
+
|
|
256
|
+
def _schedule_preview(self, editor: EditorBuffer) -> None:
|
|
257
|
+
"""Debounced live preview refresh while editing markdown."""
|
|
258
|
+
pane = self._pane_of(editor)
|
|
259
|
+
if pane is None or self._preview_mode(pane) == "off":
|
|
260
|
+
return
|
|
261
|
+
if timer := getattr(editor, "_preview_timer", None):
|
|
262
|
+
timer.stop()
|
|
263
|
+
preview = pane.query_one(MarkdownPreview)
|
|
264
|
+
editor._preview_timer = self.set_timer(
|
|
265
|
+
0.3, lambda: preview.render_text(editor.text)
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
def action_toggle_preview(self) -> None:
|
|
269
|
+
editor = self.active_editor
|
|
270
|
+
pane = self._pane_of(editor) if editor else None
|
|
271
|
+
if editor is None or pane is None:
|
|
272
|
+
return
|
|
273
|
+
if editor.language != "markdown":
|
|
274
|
+
self.notify("Not a markdown buffer", severity="warning", timeout=2)
|
|
275
|
+
return
|
|
276
|
+
current = self._preview_mode(pane)
|
|
277
|
+
next_mode = PREVIEW_MODES[
|
|
278
|
+
(PREVIEW_MODES.index(current) + 1) % len(PREVIEW_MODES)
|
|
279
|
+
]
|
|
280
|
+
self.call_later(self._set_preview_mode, pane, next_mode)
|
|
281
|
+
|
|
282
|
+
# -- events --------------------------------------------------------------
|
|
283
|
+
|
|
284
|
+
@on(DirectoryTree.FileSelected)
|
|
285
|
+
async def _tree_file_selected(self, event: DirectoryTree.FileSelected) -> None:
|
|
286
|
+
await self._open_path(event.path)
|
|
287
|
+
|
|
288
|
+
@on(TextArea.Changed)
|
|
289
|
+
def _text_changed(self, event: TextArea.Changed) -> None:
|
|
290
|
+
editor = event.text_area
|
|
291
|
+
if isinstance(editor, EditorBuffer):
|
|
292
|
+
self._refresh_tab_label(editor)
|
|
293
|
+
if editor.language == "markdown":
|
|
294
|
+
self._schedule_preview(editor)
|
|
295
|
+
self._refresh_status()
|
|
296
|
+
|
|
297
|
+
@on(TextArea.SelectionChanged)
|
|
298
|
+
def _selection_changed(self, event: TextArea.SelectionChanged) -> None:
|
|
299
|
+
self._refresh_status()
|
|
300
|
+
|
|
301
|
+
@on(TabbedContent.TabActivated)
|
|
302
|
+
def _tab_activated(self, event: TabbedContent.TabActivated) -> None:
|
|
303
|
+
self._refresh_status()
|
|
304
|
+
|
|
305
|
+
# -- actions (dispatched directly or via C-x chords) ---------------------
|
|
306
|
+
|
|
307
|
+
def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | None:
|
|
308
|
+
if action in ("chord_prefix", "chord_prefix_cc", "keyboard_quit"):
|
|
309
|
+
# While a modal (chord, prompt, confirm) is up, let it see these
|
|
310
|
+
# keys instead of the app's priority bindings.
|
|
311
|
+
if self.screen is not self.screen_stack[0]:
|
|
312
|
+
return False
|
|
313
|
+
# A focused terminal gets C-c and C-g raw (interrupting the shell
|
|
314
|
+
# matters more); C-x stays reserved as the way out.
|
|
315
|
+
if action != "chord_prefix" and isinstance(self.focused, TerminalPane):
|
|
316
|
+
return False
|
|
317
|
+
return True
|
|
318
|
+
|
|
319
|
+
def action_chord_prefix(self) -> None:
|
|
320
|
+
self.push_screen(ChordScreen("C-x", CTRL_X_MAP))
|
|
321
|
+
|
|
322
|
+
def action_chord_prefix_cc(self) -> None:
|
|
323
|
+
self.push_screen(ChordScreen("C-c", CTRL_C_MAP))
|
|
324
|
+
|
|
325
|
+
def action_keyboard_quit(self) -> None:
|
|
326
|
+
"""C-g: cancel whatever is pending — a modal screen or an active mark."""
|
|
327
|
+
if len(self.screen_stack) > 1:
|
|
328
|
+
self.pop_screen()
|
|
329
|
+
return
|
|
330
|
+
editor = self.active_editor
|
|
331
|
+
if editor is not None and editor.mark_active:
|
|
332
|
+
editor.deactivate_mark()
|
|
333
|
+
|
|
334
|
+
def action_exchange_point_and_mark(self) -> None:
|
|
335
|
+
if (editor := self.active_editor) is not None:
|
|
336
|
+
editor.exchange_point_and_mark()
|
|
337
|
+
|
|
338
|
+
def action_mark_whole_buffer(self) -> None:
|
|
339
|
+
if (editor := self.active_editor) is not None:
|
|
340
|
+
editor.mark_whole_buffer()
|
|
341
|
+
|
|
342
|
+
def action_undo_buffer(self) -> None:
|
|
343
|
+
if (editor := self.active_editor) is not None:
|
|
344
|
+
editor.undo()
|
|
345
|
+
|
|
346
|
+
def action_isearch_forward(self) -> None:
|
|
347
|
+
if (editor := self.active_editor) is not None:
|
|
348
|
+
editor.action_isearch_forward()
|
|
349
|
+
|
|
350
|
+
def action_isearch_backward(self) -> None:
|
|
351
|
+
if (editor := self.active_editor) is not None:
|
|
352
|
+
editor.action_isearch_backward()
|
|
353
|
+
|
|
354
|
+
def action_find_file(self) -> None:
|
|
355
|
+
editor = self.active_editor
|
|
356
|
+
base = editor.path.parent if editor and editor.path else self._root
|
|
357
|
+
initial = str(base) + "/"
|
|
358
|
+
|
|
359
|
+
async def opened(result: str | None) -> None:
|
|
360
|
+
if result:
|
|
361
|
+
await self._open_path(Path(result))
|
|
362
|
+
|
|
363
|
+
self.push_screen(
|
|
364
|
+
PromptScreen("Find file:", initial, complete_paths=True), opened
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
def action_save_buffer(self) -> None:
|
|
368
|
+
editor = self.active_editor
|
|
369
|
+
if editor is None:
|
|
370
|
+
return
|
|
371
|
+
if editor.path is None:
|
|
372
|
+
self.action_write_file()
|
|
373
|
+
return
|
|
374
|
+
self._save(editor, None)
|
|
375
|
+
|
|
376
|
+
def action_write_file(self) -> None:
|
|
377
|
+
editor = self.active_editor
|
|
378
|
+
if editor is None:
|
|
379
|
+
return
|
|
380
|
+
base = editor.path if editor.path else self._root
|
|
381
|
+
initial = str(base) if editor.path else str(base) + "/"
|
|
382
|
+
|
|
383
|
+
def written(result: str | None) -> None:
|
|
384
|
+
if result:
|
|
385
|
+
self._save(editor, Path(result).expanduser().resolve())
|
|
386
|
+
|
|
387
|
+
self.push_screen(
|
|
388
|
+
PromptScreen("Write file:", initial, complete_paths=True), written
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
def _save(self, editor: EditorBuffer, path: Path | None) -> None:
|
|
392
|
+
try:
|
|
393
|
+
written = editor.save(path)
|
|
394
|
+
except OSError as error:
|
|
395
|
+
self.notify(f"Save failed: {error}", severity="error")
|
|
396
|
+
return
|
|
397
|
+
self._refresh_tab_label(editor)
|
|
398
|
+
self._refresh_status()
|
|
399
|
+
self.notify(f"Wrote {written}", timeout=2)
|
|
400
|
+
|
|
401
|
+
def action_kill_buffer(self) -> None:
|
|
402
|
+
editor = self.active_editor
|
|
403
|
+
if editor is None:
|
|
404
|
+
return
|
|
405
|
+
|
|
406
|
+
async def maybe_kill(confirmed: bool | None) -> None:
|
|
407
|
+
if confirmed:
|
|
408
|
+
await self._kill(editor)
|
|
409
|
+
|
|
410
|
+
if editor.modified:
|
|
411
|
+
self.push_screen(
|
|
412
|
+
ConfirmScreen(f"{editor.display_name} is modified; kill anyway?"),
|
|
413
|
+
maybe_kill,
|
|
414
|
+
)
|
|
415
|
+
else:
|
|
416
|
+
self.call_later(self._kill, editor)
|
|
417
|
+
|
|
418
|
+
async def _kill(self, editor: EditorBuffer) -> None:
|
|
419
|
+
pane = self._pane_of(editor)
|
|
420
|
+
if pane is not None and pane.id is not None:
|
|
421
|
+
await self.tabs.remove_pane(pane.id)
|
|
422
|
+
if not self.editors():
|
|
423
|
+
await self._new_buffer()
|
|
424
|
+
self._refresh_status()
|
|
425
|
+
|
|
426
|
+
def action_switch_buffer(self) -> None:
|
|
427
|
+
"""C-x b: pick a buffer from a list. The next buffer is preselected,
|
|
428
|
+
so Enter-Enter still cycles like before."""
|
|
429
|
+
panes = list(self.tabs.query(TabPane))
|
|
430
|
+
if not panes:
|
|
431
|
+
return
|
|
432
|
+
entries: list[tuple[str, str]] = []
|
|
433
|
+
active_index = 0
|
|
434
|
+
for index, pane in enumerate(panes):
|
|
435
|
+
editor = pane.query_one(EditorBuffer)
|
|
436
|
+
label = editor.display_name + ("*" if editor.modified else "")
|
|
437
|
+
if editor.path is not None:
|
|
438
|
+
label = f"{label} [dim]{editor.path}[/]"
|
|
439
|
+
entries.append((pane.id or "", label))
|
|
440
|
+
if pane is self.tabs.active_pane:
|
|
441
|
+
active_index = index
|
|
442
|
+
|
|
443
|
+
def switched(pane_id: str | None) -> None:
|
|
444
|
+
if pane_id:
|
|
445
|
+
self.tabs.active = pane_id
|
|
446
|
+
if (editor := self.active_editor) is not None:
|
|
447
|
+
editor.focus()
|
|
448
|
+
|
|
449
|
+
preselect = (active_index + 1) % len(entries)
|
|
450
|
+
self.push_screen(BufferListScreen(entries, preselect), switched)
|
|
451
|
+
|
|
452
|
+
def action_other_window(self) -> None:
|
|
453
|
+
"""C-x o: cycle focus tree -> editor -> terminal (when open)."""
|
|
454
|
+
editor = self.active_editor
|
|
455
|
+
tree = self.query_one(DirectoryTree)
|
|
456
|
+
terminal = self.query_one(TerminalPane)
|
|
457
|
+
ring: list = [tree]
|
|
458
|
+
if editor is not None:
|
|
459
|
+
ring.append(editor)
|
|
460
|
+
if terminal.has_class("-open"):
|
|
461
|
+
ring.append(terminal)
|
|
462
|
+
focused = self.focused
|
|
463
|
+
for index, widget in enumerate(ring):
|
|
464
|
+
if focused is widget or (focused is not None and widget in focused.ancestors_with_self):
|
|
465
|
+
ring[(index + 1) % len(ring)].focus()
|
|
466
|
+
return
|
|
467
|
+
ring[0].focus()
|
|
468
|
+
|
|
469
|
+
def action_help(self) -> None:
|
|
470
|
+
self.push_screen(HelpScreen())
|
|
471
|
+
|
|
472
|
+
def action_toggle_terminal(self) -> None:
|
|
473
|
+
terminal = self.query_one(TerminalPane)
|
|
474
|
+
if terminal.has_class("-open"):
|
|
475
|
+
terminal.remove_class("-open")
|
|
476
|
+
if (editor := self.active_editor) is not None:
|
|
477
|
+
editor.focus()
|
|
478
|
+
else:
|
|
479
|
+
terminal.add_class("-open")
|
|
480
|
+
terminal.spawn()
|
|
481
|
+
terminal.focus()
|
|
482
|
+
|
|
483
|
+
def action_request_quit(self) -> None:
|
|
484
|
+
unsaved = [e.display_name for e in self.editors() if e.modified]
|
|
485
|
+
if not unsaved:
|
|
486
|
+
self.exit()
|
|
487
|
+
return
|
|
488
|
+
|
|
489
|
+
def maybe_quit(confirmed: bool | None) -> None:
|
|
490
|
+
if confirmed:
|
|
491
|
+
self.exit()
|
|
492
|
+
|
|
493
|
+
names = ", ".join(unsaved)
|
|
494
|
+
self.push_screen(
|
|
495
|
+
ConfirmScreen(f"Unsaved: {names}. Quit anyway?"), maybe_quit
|
|
496
|
+
)
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def main() -> None:
|
|
500
|
+
paths = [Path(arg) for arg in sys.argv[1:]]
|
|
501
|
+
CandatApp(paths).run()
|
candat/buffers.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""The buffer list (C-x b): pick an open buffer from a small modal list."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from rich.text import Text
|
|
6
|
+
from textual import events, on
|
|
7
|
+
from textual.app import ComposeResult
|
|
8
|
+
from textual.screen import ModalScreen
|
|
9
|
+
from textual.widgets import OptionList
|
|
10
|
+
from textual.widgets.option_list import Option
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class BufferListScreen(ModalScreen[str | None]):
|
|
14
|
+
"""Shows open buffers; returns the chosen pane id (or None)."""
|
|
15
|
+
|
|
16
|
+
CSS = """
|
|
17
|
+
BufferListScreen {
|
|
18
|
+
align: center middle;
|
|
19
|
+
}
|
|
20
|
+
BufferListScreen OptionList {
|
|
21
|
+
width: 70%;
|
|
22
|
+
max-width: 90;
|
|
23
|
+
max-height: 60%;
|
|
24
|
+
background: $background;
|
|
25
|
+
border: solid $primary;
|
|
26
|
+
padding: 0 1;
|
|
27
|
+
}
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self, buffers: list[tuple[str, str]], preselect: int = 0) -> None:
|
|
31
|
+
"""buffers: (pane_id, label) pairs in tab order."""
|
|
32
|
+
super().__init__()
|
|
33
|
+
self._buffers = buffers
|
|
34
|
+
self._preselect = preselect
|
|
35
|
+
|
|
36
|
+
def compose(self) -> ComposeResult:
|
|
37
|
+
yield OptionList(
|
|
38
|
+
*[
|
|
39
|
+
Option(Text.from_markup(label), id=pane_id)
|
|
40
|
+
for pane_id, label in self._buffers
|
|
41
|
+
]
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
def on_mount(self) -> None:
|
|
45
|
+
option_list = self.query_one(OptionList)
|
|
46
|
+
option_list.highlighted = self._preselect
|
|
47
|
+
option_list.focus()
|
|
48
|
+
|
|
49
|
+
@on(OptionList.OptionSelected)
|
|
50
|
+
def _selected(self, event: OptionList.OptionSelected) -> None:
|
|
51
|
+
self.dismiss(event.option.id)
|
|
52
|
+
|
|
53
|
+
def on_key(self, event: events.Key) -> None:
|
|
54
|
+
if event.key in ("escape", "ctrl+g"):
|
|
55
|
+
event.stop()
|
|
56
|
+
self.dismiss(None)
|
|
57
|
+
elif event.key in ("ctrl+n", "ctrl+p"):
|
|
58
|
+
event.stop()
|
|
59
|
+
option_list = self.query_one(OptionList)
|
|
60
|
+
option_list.action_cursor_down() if event.key == "ctrl+n" else option_list.action_cursor_up()
|
candat/chords.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Emacs-style prefix-key (chord) dispatcher.
|
|
2
|
+
|
|
3
|
+
Pressing a prefix (e.g. C-x) pushes a ChordScreen, which shows the pending
|
|
4
|
+
prefix in a minibuffer-style line, captures exactly one more key, and runs
|
|
5
|
+
the app action mapped to it. C-g or escape cancels, as in emacs.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from textual import events
|
|
11
|
+
from textual.app import ComposeResult
|
|
12
|
+
from textual.screen import ModalScreen
|
|
13
|
+
from textual.widgets import Label
|
|
14
|
+
|
|
15
|
+
# Chord table for the C-x prefix: key -> (app action, description).
|
|
16
|
+
CTRL_X_MAP: dict[str, tuple[str, str]] = {
|
|
17
|
+
"ctrl+f": ("find_file", "find file"),
|
|
18
|
+
"ctrl+s": ("save_buffer", "save buffer"),
|
|
19
|
+
"ctrl+w": ("write_file", "write file (save as)"),
|
|
20
|
+
"ctrl+c": ("request_quit", "quit"),
|
|
21
|
+
"ctrl+x": ("exchange_point_and_mark", "exchange point and mark"),
|
|
22
|
+
"k": ("kill_buffer", "kill buffer"),
|
|
23
|
+
"b": ("switch_buffer", "switch buffer"),
|
|
24
|
+
"o": ("other_window", "other window"),
|
|
25
|
+
"h": ("mark_whole_buffer", "mark whole buffer"),
|
|
26
|
+
"u": ("undo_buffer", "undo"),
|
|
27
|
+
"t": ("toggle_terminal", "toggle terminal"),
|
|
28
|
+
"question_mark": ("help", "help"),
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
# Chord table for the C-c prefix (mode-specific commands, as in emacs).
|
|
32
|
+
CTRL_C_MAP: dict[str, tuple[str, str]] = {
|
|
33
|
+
"ctrl+v": ("toggle_preview", "toggle markdown preview"),
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ChordScreen(ModalScreen[None]):
|
|
38
|
+
"""Captures the key that follows a prefix and dispatches the mapped action."""
|
|
39
|
+
|
|
40
|
+
CSS = """
|
|
41
|
+
ChordScreen {
|
|
42
|
+
align: left bottom;
|
|
43
|
+
background: transparent;
|
|
44
|
+
}
|
|
45
|
+
ChordScreen Label {
|
|
46
|
+
height: 1;
|
|
47
|
+
width: 100%;
|
|
48
|
+
padding: 0 1;
|
|
49
|
+
background: $panel;
|
|
50
|
+
color: $foreground;
|
|
51
|
+
}
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
def __init__(self, prefix: str, chord_map: dict[str, tuple[str, str]]) -> None:
|
|
55
|
+
super().__init__()
|
|
56
|
+
self._prefix = prefix
|
|
57
|
+
self._chord_map = chord_map
|
|
58
|
+
|
|
59
|
+
def compose(self) -> ComposeResult:
|
|
60
|
+
yield Label(f"{self._prefix} -")
|
|
61
|
+
|
|
62
|
+
def on_key(self, event: events.Key) -> None:
|
|
63
|
+
event.stop()
|
|
64
|
+
event.prevent_default()
|
|
65
|
+
if event.key in ("escape", "ctrl+g"):
|
|
66
|
+
self.dismiss()
|
|
67
|
+
return
|
|
68
|
+
entry = self._chord_map.get(event.key)
|
|
69
|
+
self.dismiss()
|
|
70
|
+
if entry is None:
|
|
71
|
+
self.app.notify(
|
|
72
|
+
f"{self._prefix} {pretty_key(event.key)} is undefined",
|
|
73
|
+
severity="warning",
|
|
74
|
+
timeout=2,
|
|
75
|
+
)
|
|
76
|
+
else:
|
|
77
|
+
action, _ = entry
|
|
78
|
+
# Deferred so the action runs after this screen has popped;
|
|
79
|
+
# dismiss() cannot be awaited from the screen's own handler.
|
|
80
|
+
self.app.call_later(self.app.run_action, action)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def pretty_key(key: str) -> str:
|
|
84
|
+
"""Render a Textual key name emacs-style: 'ctrl+f' -> 'C-f'."""
|
|
85
|
+
return key.replace("ctrl+", "C-").replace("shift+", "S-")
|