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 ADDED
@@ -0,0 +1,5 @@
1
+ """Candat: a terminal IDE with emacs keybindings."""
2
+
3
+ from .app import CandatApp, main
4
+
5
+ __all__ = ["CandatApp", "main"]
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-")