textual-code 0.0.2__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.
@@ -0,0 +1,10 @@
1
+ from textual_code.app import TextualCode
2
+
3
+
4
+ def main():
5
+ app = TextualCode()
6
+ app.run()
7
+
8
+
9
+ if __name__ == "__main__":
10
+ main()
textual_code/app.py ADDED
@@ -0,0 +1,719 @@
1
+ from collections.abc import Iterable
2
+ from pathlib import Path
3
+ from uuid import uuid4
4
+
5
+ from textual import on
6
+ from textual.app import App, ComposeResult, SystemCommand
7
+ from textual.binding import Binding
8
+ from textual.events import Mount
9
+ from textual.message import Message
10
+ from textual.reactive import reactive
11
+ from textual.screen import Screen
12
+ from textual.widgets import (
13
+ Button,
14
+ DirectoryTree,
15
+ Footer,
16
+ Label,
17
+ Static,
18
+ TabbedContent,
19
+ TabPane,
20
+ TextArea,
21
+ )
22
+
23
+ from textual_code.modals import (
24
+ DeleteFileModalResult,
25
+ DeleteFileModalScreen,
26
+ SaveAsModalResult,
27
+ SaveAsModalScreen,
28
+ UnsavedChangeModalResult,
29
+ UnsavedChangeModalScreen,
30
+ UnsavedChangeQuitModalResult,
31
+ UnsavedChangeQuitModalScreen,
32
+ )
33
+ from textual_code.utils import ready_to_handle
34
+
35
+
36
+ class Explorer(Static):
37
+ class FileOpened(Message):
38
+ def __init__(self, path: Path) -> None:
39
+ super().__init__()
40
+ self.path = path.resolve()
41
+
42
+ def __init__(self, *args, **kwargs) -> None:
43
+ super().__init__(*args, **kwargs)
44
+ self.directory_tree: DirectoryTree | None = None
45
+
46
+ def compose(self) -> ComposeResult:
47
+ self.directory_tree = DirectoryTree(Path())
48
+ self.directory_tree.show_root = False
49
+ yield self.directory_tree
50
+
51
+ @on(DirectoryTree.FileSelected)
52
+ def file_selected(self, event: DirectoryTree.FileSelected):
53
+ with ready_to_handle(self, event):
54
+ self.post_message(self.FileOpened(path=event.path.resolve()))
55
+
56
+
57
+ class Sidebar(Static):
58
+ class FileOpened(Message):
59
+ def __init__(self, path: Path) -> None:
60
+ super().__init__()
61
+ self.path = path.resolve()
62
+
63
+ def __init__(self, *args, **kwargs) -> None:
64
+ super().__init__(*args, **kwargs)
65
+ self.tabbed_content: TabbedContent | None = None
66
+ self.explorer_pane: TabPane | None = None
67
+ self.explorer: Explorer | None = None
68
+
69
+ def compose(self) -> ComposeResult:
70
+ self.tabbed_content = TabbedContent()
71
+ self.explorer_pane = TabPane("Explorer")
72
+ self.explorer = Explorer(id="explorer")
73
+
74
+ with self.tabbed_content, self.explorer_pane:
75
+ yield self.explorer
76
+
77
+ @on(Explorer.FileOpened)
78
+ def file_opened(self, event: Explorer.FileOpened):
79
+ with ready_to_handle(self, event):
80
+ self.post_message(self.FileOpened(path=event.path))
81
+
82
+
83
+ class CodeEditorFooter(Static):
84
+ path: reactive[Path | None] = reactive(None)
85
+ language: reactive[str | None] = reactive(None)
86
+
87
+ def __init__(
88
+ self,
89
+ path: Path | None,
90
+ language: str | None,
91
+ *args,
92
+ **kwargs,
93
+ ) -> None:
94
+ super().__init__(*args, **kwargs)
95
+ self.path_view: Label | None = None
96
+ self.language_button: Button | None = None
97
+
98
+ self.set_reactive(CodeEditor.path, path)
99
+ self.set_reactive(CodeEditor.language, language)
100
+
101
+ def compose(self) -> ComposeResult:
102
+ self.path_view = Label(str(self.path) if self.path else "", id="path")
103
+ self.language_button = Button(
104
+ self.language or "plain", variant="default", id="language"
105
+ )
106
+ yield self.path_view
107
+ yield self.language_button
108
+
109
+ def watch_path(self, path: Path | None):
110
+ if self.path_view is not None:
111
+ self.path_view.update(str(path) if path else "")
112
+
113
+ def watch_language(self, language: str | None):
114
+ if self.language_button is not None:
115
+ self.language_button.label = language or "plain"
116
+
117
+
118
+ class CodeEditor(Static):
119
+ pane_id: reactive[str] = reactive("")
120
+ path: reactive[Path | None] = reactive(None)
121
+ initial_text: reactive[str] = reactive("")
122
+ text: reactive[str] = reactive("")
123
+ title: reactive[str] = reactive("...")
124
+ language: reactive[str | None] = reactive(None)
125
+
126
+ LANGUAGE_EXTENSIONS = {
127
+ "py": "python",
128
+ "json": "json",
129
+ "md": "markdown",
130
+ "markdown": "markdown",
131
+ "yaml": "yaml",
132
+ "yml": "yaml",
133
+ "toml": "toml",
134
+ "rs": "rust",
135
+ "html": "html",
136
+ "htm": "html",
137
+ "css": "css",
138
+ "xml": "xml",
139
+ "regex": "regex",
140
+ "sql": "sql",
141
+ "js": "javascript",
142
+ "java": "java",
143
+ "sh": "bash",
144
+ "go": "go",
145
+ }
146
+
147
+ class TitleChanged(Message):
148
+ def __init__(self, pane_id: str, title: str) -> None:
149
+ super().__init__()
150
+ self.pane_id = pane_id
151
+ self.title = title
152
+
153
+ class Saved(Message):
154
+ def __init__(self, pane_id: str) -> None:
155
+ super().__init__()
156
+ self.pane_id = pane_id
157
+
158
+ class SavedAs(Message):
159
+ def __init__(self, pane_id: str, path: Path) -> None:
160
+ super().__init__()
161
+ self.pane_id = pane_id
162
+ self.path = path
163
+
164
+ class Closed(Message):
165
+ def __init__(self, pane_id: str) -> None:
166
+ super().__init__()
167
+ self.pane_id = pane_id
168
+
169
+ class Deleted(Message):
170
+ def __init__(self, pane_id: str) -> None:
171
+ super().__init__()
172
+ self.pane_id = pane_id
173
+
174
+ class FocusRequsted(Message):
175
+ def __init__(self) -> None:
176
+ super().__init__()
177
+
178
+ class SaveRequested(Message):
179
+ def __init__(self) -> None:
180
+ super().__init__()
181
+
182
+ class SaveAsRequested(Message):
183
+ def __init__(self) -> None:
184
+ super().__init__()
185
+
186
+ class CloseRequested(Message):
187
+ def __init__(self) -> None:
188
+ super().__init__()
189
+
190
+ class DeleteRequested(Message):
191
+ def __init__(self) -> None:
192
+ super().__init__()
193
+
194
+ @classmethod
195
+ def generate_pane_id(cls) -> str:
196
+ return f"pane-code-editor-{uuid4().hex}"
197
+
198
+ def __init__(self, pane_id: str, path: Path | None, *args, **kwargs) -> None:
199
+ super().__init__(*args, **kwargs)
200
+ self.editor: TextArea | None = None
201
+ self.footer: CodeEditorFooter | None = None
202
+
203
+ self.set_reactive(CodeEditor.pane_id, pane_id)
204
+ self.set_reactive(CodeEditor.path, path)
205
+ if path is not None:
206
+ try:
207
+ with path.open() as f:
208
+ text = f.read()
209
+ except Exception as e:
210
+ text = ""
211
+ self.notify(f"Error reading file: {e}", severity="error")
212
+ self.set_reactive(CodeEditor.initial_text, text)
213
+ self.set_reactive(CodeEditor.text, text)
214
+
215
+ def compose(self) -> ComposeResult:
216
+ self.editor = TextArea.code_editor(
217
+ text=self.initial_text, language=self.language
218
+ )
219
+ self.footer = CodeEditorFooter(
220
+ path=self.path,
221
+ language=self.language,
222
+ )
223
+ yield self.editor
224
+ yield self.footer
225
+
226
+ @on(Mount)
227
+ def mounted(self, event: Mount):
228
+ # manually trigger the reactive properties to update the UI
229
+ self.watch_path(self.path)
230
+
231
+ def replace_initial_text(self, initial_text: str):
232
+ if self.editor is not None:
233
+ self.editor.replace(
234
+ initial_text,
235
+ self.editor.document.start,
236
+ self.editor.document.end,
237
+ )
238
+
239
+ def compute_title(self) -> str:
240
+ if not self.is_mounted:
241
+ return "..."
242
+ is_changed = False
243
+ if self.text != self.initial_text:
244
+ is_changed = True
245
+ name = "<Untitled>"
246
+ if self.path is not None:
247
+ name = self.path.name
248
+ return f"{name}{'*' if is_changed else ''}"
249
+
250
+ def watch_initial_text(self, initial_text: str):
251
+ self.replace_initial_text(initial_text)
252
+
253
+ def watch_title(self, title: str):
254
+ self.post_message(self.TitleChanged(pane_id=self.pane_id, title=title))
255
+
256
+ def watch_path(self, path: Path | None):
257
+ if self.footer is not None:
258
+ self.footer.path = path
259
+
260
+ if path is None:
261
+ self.language = None
262
+ return
263
+ extension = path.suffix.lstrip(".")
264
+ self.language = self.LANGUAGE_EXTENSIONS.get(extension, None)
265
+
266
+ def watch_language(self, language: str | None):
267
+ if self.editor is not None:
268
+ self.editor.language = language
269
+ if self.footer is not None:
270
+ self.footer.language = language
271
+
272
+ def action_save(self) -> None:
273
+ """
274
+ Save the file.
275
+ Returns True if the file was saved successfully.
276
+ """
277
+ if self.path is None:
278
+ self.action_save_as()
279
+ else:
280
+ try:
281
+ with self.path.open("w") as f:
282
+ f.write(self.text)
283
+ self.initial_text = self.text
284
+ self.notify("File saved", severity="information")
285
+ self.post_message(self.Saved(pane_id=self.pane_id))
286
+ self.post_message(TextualCode.ReloadExplorerRequested())
287
+ return
288
+ except Exception as e:
289
+ self.notify(f"Error saving file: {e}", severity="error")
290
+ return
291
+
292
+ def action_save_as(self) -> None:
293
+ """
294
+ Save the file as a new file.
295
+ Returns True if the file was saved successfully.
296
+ """
297
+
298
+ def do_save_as(result: SaveAsModalResult | None) -> None:
299
+ if result is None or result.is_cancelled:
300
+ return
301
+
302
+ if result.file_path is None:
303
+ self.notify("File path cannot be empty", severity="error")
304
+ return
305
+
306
+ new_path = Path(result.file_path).resolve()
307
+ if new_path.exists():
308
+ self.notify("File already exists", severity="error")
309
+ return
310
+
311
+ try:
312
+ with open(new_path, "w") as f:
313
+ f.write(self.text)
314
+ self.initial_text = self.text
315
+ self.path = new_path
316
+ self.post_message(self.SavedAs(pane_id=self.pane_id, path=new_path))
317
+ self.post_message(TextualCode.ReloadExplorerRequested())
318
+ self.notify(f"File saved: {self.path}", severity="information")
319
+ except Exception as e:
320
+ self.notify(f"Error saving file: {e}", severity="error")
321
+ return
322
+
323
+ self.app.push_screen(SaveAsModalScreen(), do_save_as)
324
+ return
325
+
326
+ def action_close(self) -> None:
327
+ """
328
+ Close the code editor.
329
+ Returns True if the code editor was closed.
330
+ """
331
+
332
+ def do_unsaved_changes(result: UnsavedChangeModalResult | None) -> None:
333
+ if result is None or result.is_cancelled:
334
+ return
335
+
336
+ if result.should_save is None:
337
+ self.notify("Please select an option", severity="error")
338
+ return
339
+
340
+ if result.should_save:
341
+ self.action_save()
342
+ if self.text == self.initial_text:
343
+ self.post_message(self.Closed(pane_id=self.pane_id))
344
+ return
345
+ else:
346
+ # file was not saved, so don't close the editor
347
+ return
348
+ else:
349
+ self.post_message(self.Closed(pane_id=self.pane_id))
350
+ return
351
+
352
+ if self.text != self.initial_text:
353
+ # There are unsaved changes, ask the user if they want to save the changes
354
+ self.app.push_screen(UnsavedChangeModalScreen(), do_unsaved_changes)
355
+ return
356
+
357
+ self.post_message(self.Closed(pane_id=self.pane_id))
358
+ return
359
+
360
+ def action_delete(self) -> None:
361
+ if not self.path:
362
+ self.notify(
363
+ "No file to delete. Please save the file first.", severity="error"
364
+ )
365
+ return
366
+
367
+ def do_delete(result: DeleteFileModalResult | None):
368
+ if not result or result.is_cancelled:
369
+ return
370
+ if not self.path:
371
+ self.notify(
372
+ "No file to delete. Please save the file first.", severity="error"
373
+ )
374
+ return
375
+ if result.should_delete:
376
+ try:
377
+ self.path.unlink()
378
+ self.notify(f"File deleted: {self.path}", severity="information")
379
+ self.post_message(self.Deleted(pane_id=self.pane_id))
380
+ self.post_message(TextualCode.ReloadExplorerRequested())
381
+ except Exception as e:
382
+ self.notify(f"Error deleting file: {e}", severity="error")
383
+
384
+ self.app.push_screen(DeleteFileModalScreen(self.path), do_delete)
385
+
386
+ @on(TextArea.Changed)
387
+ def text_changed(self, event: TextArea.Changed):
388
+ with ready_to_handle(self, event, should_exists=[self.editor]):
389
+ self.text = event.text_area.text
390
+
391
+ @on(FocusRequsted)
392
+ def focus_requested(self, event: FocusRequsted):
393
+ with ready_to_handle(self, event, should_exists=[self.editor]):
394
+ if self.editor is None:
395
+ raise ValueError("TextArea is not mounted")
396
+ self.editor.focus()
397
+
398
+ @on(SaveRequested)
399
+ def save_requested(self, event: SaveRequested):
400
+ with ready_to_handle(self, event, should_exists=[self.editor]):
401
+ self.action_save()
402
+
403
+ @on(SaveAsRequested)
404
+ def save_as_requested(self, event: SaveAsRequested):
405
+ with ready_to_handle(self, event, should_exists=[self.editor]):
406
+ self.action_save_as()
407
+
408
+ @on(CloseRequested)
409
+ def close_requested(self, event: CloseRequested):
410
+ with ready_to_handle(self, event, should_exists=[self.editor]):
411
+ self.action_close()
412
+
413
+ @on(DeleteRequested)
414
+ def delete_requested(self, event: DeleteRequested):
415
+ with ready_to_handle(self, event, should_exists=[self.editor]):
416
+ self.action_delete()
417
+
418
+
419
+ class MainContent(Static):
420
+ BINDINGS = [
421
+ Binding("ctrl+s", "save", "Save"),
422
+ Binding("ctrl+w", "close", "Close tab", priority=True),
423
+ ]
424
+
425
+ class OpenCodeEditorRequested(Message):
426
+ def __init__(self, path: Path | None = None, focus: bool = True) -> None:
427
+ super().__init__()
428
+ self.path = path
429
+ self.focus = focus
430
+
431
+ class CloseCodeEditorRequested(Message):
432
+ def __init__(self, pane_id: str) -> None:
433
+ super().__init__()
434
+ self.pane_id = pane_id
435
+
436
+ def __init__(self, *args, **kwargs) -> None:
437
+ super().__init__(*args, **kwargs)
438
+ self.tabbed_content: TabbedContent | None = None
439
+ self.opened_pane_ids: set[str] = set()
440
+ self.opened_files: dict[Path, str] = {}
441
+
442
+ def compose(self) -> ComposeResult:
443
+ self.tabbed_content = TabbedContent(id="tabs")
444
+ yield self.tabbed_content
445
+
446
+ def is_opened_pane(self, pane_id: str):
447
+ return pane_id in self.opened_pane_ids
448
+
449
+ def _pane_id_from_path(self, path: Path) -> str | None:
450
+ return self.opened_files.get(path, None)
451
+
452
+ async def _open_new_pane(self, pane_id: str, pane: TabPane) -> bool:
453
+ """
454
+ Open a new pane if it is not already opened.
455
+
456
+ Returns True if a new pane was opened.
457
+ """
458
+ if self.is_opened_pane(pane_id):
459
+ return False
460
+ if self.tabbed_content is None:
461
+ raise ValueError("TabbedContent is not mounted")
462
+ await self.tabbed_content.add_pane(pane)
463
+ self.opened_pane_ids.add(pane_id)
464
+ return True
465
+
466
+ def close_pane(self, pane_id: str) -> None:
467
+ if self.tabbed_content is None:
468
+ raise ValueError("TabbedContent is not mounted")
469
+ self.tabbed_content.remove_pane(pane_id)
470
+ self.opened_pane_ids.remove(pane_id)
471
+
472
+ async def add_code_editor_pane(self, path: Path | None = None) -> str:
473
+ """
474
+ Add a new code editor pane.
475
+
476
+ Returns the pane_id.
477
+
478
+ If a path is provided, open the file in the code-editor.
479
+ Otherwise, open a new empty code-editor.
480
+
481
+ If the pane is already opened, it will not be opened again.
482
+ """
483
+
484
+ if path is not None:
485
+ existing_pane_id = self._pane_id_from_path(path)
486
+ if existing_pane_id is None:
487
+ pane_id = CodeEditor.generate_pane_id()
488
+ else:
489
+ pane_id = existing_pane_id
490
+ else:
491
+ pane_id = CodeEditor.generate_pane_id()
492
+
493
+ if self.is_opened_pane(pane_id):
494
+ return pane_id
495
+
496
+ pane = TabPane(
497
+ "...", # temporary title, will be updated later
498
+ CodeEditor(pane_id=pane_id, path=path),
499
+ id=pane_id,
500
+ )
501
+ if path is not None:
502
+ self.opened_files[path] = pane_id
503
+ await self._open_new_pane(pane_id, pane)
504
+ return pane_id
505
+
506
+ def get_active_code_editor(self) -> CodeEditor | None:
507
+ if self.tabbed_content is None:
508
+ raise ValueError("TabbedContent is not mounted")
509
+ active_pane_id = self.tabbed_content.active
510
+ if not active_pane_id:
511
+ return None
512
+ active_pane = self.tabbed_content.get_pane(active_pane_id)
513
+ return active_pane.query_one(CodeEditor)
514
+
515
+ def has_unsaved_pane(self) -> bool:
516
+ if self.tabbed_content is None:
517
+ raise ValueError("TabbedContent is not mounted")
518
+ for pane_id in list(self.opened_pane_ids):
519
+ pane = self.tabbed_content.get_pane(pane_id)
520
+ code_editor = pane.query_one(CodeEditor)
521
+ if code_editor.text != code_editor.initial_text:
522
+ return True
523
+ return False
524
+
525
+ async def action_open_code_editor(
526
+ self, path: Path | None = None, focus: bool = True
527
+ ) -> None:
528
+ if self.tabbed_content is None:
529
+ raise ValueError("TabbedContent is not mounted")
530
+
531
+ pane_id = await self.add_code_editor_pane(path)
532
+ self.tabbed_content.active = pane_id
533
+ if focus:
534
+ self.tabbed_content.get_pane(pane_id).query_one(CodeEditor).post_message(
535
+ CodeEditor.FocusRequsted()
536
+ )
537
+
538
+ def action_close_code_editor(self, pane_id: str) -> None:
539
+ self.close_pane(pane_id)
540
+
541
+ # Remove the file from the opened_files dict
542
+ self.opened_files = {k: v for k, v in self.opened_files.items() if v != pane_id}
543
+
544
+ def action_save(self):
545
+ code_editor = self.get_active_code_editor()
546
+ if code_editor is not None:
547
+ code_editor.post_message(CodeEditor.SaveRequested())
548
+
549
+ def action_close(self):
550
+ code_editor = self.get_active_code_editor()
551
+ if code_editor is not None:
552
+ code_editor.post_message(CodeEditor.CloseRequested())
553
+
554
+ @on(CodeEditor.TitleChanged)
555
+ def code_editor_title_changed(self, event: CodeEditor.TitleChanged):
556
+ with ready_to_handle(self, event, should_exists=[self.tabbed_content]):
557
+ if self.tabbed_content is None:
558
+ raise ValueError("TabbedContent is not mounted")
559
+ if self.is_opened_pane(event.pane_id):
560
+ self.tabbed_content.get_tab(event.pane_id).label = event.title
561
+
562
+ @on(CodeEditor.SavedAs)
563
+ def code_editor_saved_as(self, event: CodeEditor.SavedAs):
564
+ with ready_to_handle(self, event, should_exists=[self.tabbed_content]):
565
+ self.opened_files[event.path] = event.pane_id
566
+
567
+ @on(CodeEditor.Closed)
568
+ def code_editor_closed(self, event: CodeEditor.Closed):
569
+ with ready_to_handle(self, event, should_exists=[self.tabbed_content]):
570
+ self.post_message(self.CloseCodeEditorRequested(pane_id=event.pane_id))
571
+
572
+ @on(CodeEditor.Deleted)
573
+ def code_editor_deleted(self, event: CodeEditor.Deleted):
574
+ with ready_to_handle(self, event, should_exists=[self.tabbed_content]):
575
+ self.post_message(self.CloseCodeEditorRequested(pane_id=event.pane_id))
576
+
577
+ @on(OpenCodeEditorRequested)
578
+ async def open_code_editor_requested(self, event: OpenCodeEditorRequested) -> None:
579
+ with ready_to_handle(self, event, should_exists=[self.tabbed_content]):
580
+ await self.action_open_code_editor(event.path, event.focus)
581
+
582
+ @on(CloseCodeEditorRequested)
583
+ def close_code_editor_requested(self, event: CloseCodeEditorRequested) -> None:
584
+ with ready_to_handle(self, event, should_exists=[self.tabbed_content]):
585
+ self.action_close_code_editor(event.pane_id)
586
+
587
+
588
+ class TextualCode(App):
589
+ class ReloadExplorerRequested(Message):
590
+ def __init__(self) -> None:
591
+ super().__init__()
592
+
593
+ CSS_PATH = "style.tcss"
594
+
595
+ BINDINGS = [Binding("ctrl+n", "new_editor", "New file")]
596
+
597
+ def __init__(self, *args, **kwargs) -> None:
598
+ super().__init__(*args, **kwargs)
599
+ self.sidebar: Sidebar | None = None
600
+ self.main_content: MainContent | None = None
601
+ self.footer: Footer | None = None
602
+
603
+ def compose(self) -> ComposeResult:
604
+ self.sidebar = Sidebar(id="sidebar")
605
+ self.main_content = MainContent(id="main")
606
+ self.footer = Footer()
607
+
608
+ yield self.sidebar
609
+ yield self.main_content
610
+ yield self.footer
611
+
612
+ def get_system_commands(self, screen: Screen) -> Iterable[SystemCommand]:
613
+ yield from super().get_system_commands(screen)
614
+ yield SystemCommand(
615
+ "Reload explorer", "Reload the explorer", self.action_reload_explorer
616
+ )
617
+ yield SystemCommand("Save file", "Save the current file", self.action_save_file)
618
+ yield SystemCommand(
619
+ "Save file as",
620
+ "Save the current file as new file",
621
+ self.action_save_file_as,
622
+ )
623
+ yield SystemCommand(
624
+ "New file", "Open empty code editor", self.action_new_editor
625
+ )
626
+ yield SystemCommand(
627
+ "Close file", "Close the current file", self.action_close_file
628
+ )
629
+ yield SystemCommand(
630
+ "Delete file", "Delete the current file", self.action_delete_file
631
+ )
632
+
633
+ def action_reload_explorer(self) -> None:
634
+ if self.sidebar is None:
635
+ raise ValueError("Sidebar is not mounted")
636
+ if self.sidebar.explorer is None:
637
+ raise ValueError("Explorer is not mounted")
638
+ if self.sidebar.explorer.directory_tree is None:
639
+ raise ValueError("DirectoryTree is not mounted")
640
+
641
+ self.sidebar.explorer.directory_tree.reload()
642
+
643
+ def action_save_file(self) -> None:
644
+ if self.main_content is None:
645
+ raise ValueError("MainContent is not mounted")
646
+
647
+ code_editor = self.main_content.get_active_code_editor()
648
+ if code_editor is not None:
649
+ code_editor.post_message(CodeEditor.SaveRequested())
650
+ else:
651
+ self.notify("No file to save. Please open a file first.", severity="error")
652
+
653
+ def action_save_file_as(self) -> None:
654
+ if self.main_content is None:
655
+ raise ValueError("MainContent is not mounted")
656
+
657
+ code_editor = self.main_content.get_active_code_editor()
658
+ if code_editor is not None:
659
+ code_editor.post_message(CodeEditor.SaveAsRequested())
660
+ else:
661
+ self.notify("No file to save. Please open a file first.", severity="error")
662
+
663
+ def action_new_editor(self) -> None:
664
+ if self.main_content is None:
665
+ raise ValueError("MainContent is not mounted")
666
+
667
+ self.main_content.post_message(
668
+ MainContent.OpenCodeEditorRequested(path=None, focus=True)
669
+ )
670
+
671
+ def action_close_file(self) -> None:
672
+ if self.main_content is None:
673
+ raise ValueError("MainContent is not mounted")
674
+
675
+ code_editor = self.main_content.get_active_code_editor()
676
+ if code_editor is not None:
677
+ code_editor.post_message(CodeEditor.CloseRequested())
678
+ else:
679
+ self.notify("No file to close. Please open a file first.", severity="error")
680
+
681
+ def action_delete_file(self) -> None:
682
+ if self.main_content is None:
683
+ raise ValueError("MainContent is not mounted")
684
+
685
+ code_editor = self.main_content.get_active_code_editor()
686
+ if code_editor is not None:
687
+ code_editor.post_message(CodeEditor.DeleteRequested())
688
+ else:
689
+ self.notify(
690
+ "No file to delete. Please open a file first.", severity="error"
691
+ )
692
+
693
+ def action_quit(self) -> None:
694
+ if self.query_one(MainContent).has_unsaved_pane():
695
+
696
+ def do_force_quit(
697
+ result: UnsavedChangeQuitModalResult | None,
698
+ ) -> None:
699
+ if result is None or not result.should_quit:
700
+ return
701
+ self.exit()
702
+
703
+ self.push_screen(UnsavedChangeQuitModalScreen(), do_force_quit)
704
+ return
705
+ self.exit()
706
+
707
+ @on(Sidebar.FileOpened)
708
+ def file_opened(self, event: Sidebar.FileOpened):
709
+ with ready_to_handle(self, event, should_exists=[self.main_content]):
710
+ if self.main_content is None:
711
+ raise ValueError("MainContent is not mounted")
712
+ self.main_content.post_message(
713
+ MainContent.OpenCodeEditorRequested(path=event.path, focus=True)
714
+ )
715
+
716
+ @on(ReloadExplorerRequested)
717
+ def reload_explorer_requested(self, event: ReloadExplorerRequested):
718
+ with ready_to_handle(self, event, should_exists=[self.sidebar]):
719
+ self.action_reload_explorer()
textual_code/modals.py ADDED
@@ -0,0 +1,123 @@
1
+ from dataclasses import dataclass
2
+ from pathlib import Path
3
+
4
+ from textual import on
5
+ from textual.app import ComposeResult
6
+ from textual.containers import Grid
7
+ from textual.screen import ModalScreen
8
+ from textual.widgets import (
9
+ Button,
10
+ Input,
11
+ Label,
12
+ )
13
+
14
+
15
+ @dataclass
16
+ class SaveAsModalResult:
17
+ is_cancelled: bool
18
+ file_path: str | None
19
+
20
+
21
+ class SaveAsModalScreen(ModalScreen[SaveAsModalResult]):
22
+ def compose(self) -> ComposeResult:
23
+ yield Grid(
24
+ Label("Save As", id="title"),
25
+ Input(placeholder="Enter the file path", id="path"),
26
+ Button("Save", variant="primary", id="save"),
27
+ Button("Cancel", variant="default", id="cancel"),
28
+ id="dialog",
29
+ )
30
+
31
+ @on(Input.Submitted, "#path")
32
+ @on(Button.Pressed, "#save")
33
+ def save(self) -> None:
34
+ self.dismiss(
35
+ SaveAsModalResult(is_cancelled=False, file_path=self.query_one(Input).value)
36
+ )
37
+
38
+ @on(Button.Pressed, "#cancel")
39
+ def cancel(self) -> None:
40
+ self.dismiss(SaveAsModalResult(is_cancelled=True, file_path=None))
41
+
42
+
43
+ @dataclass
44
+ class UnsavedChangeModalResult:
45
+ is_cancelled: bool
46
+ should_save: bool | None
47
+
48
+
49
+ class UnsavedChangeModalScreen(ModalScreen[UnsavedChangeModalResult]):
50
+ def compose(self) -> ComposeResult:
51
+ yield Grid(
52
+ Label("Do you want to save the changes before closing?", id="title"),
53
+ Label("If you don't save, changes will be lost.", id="message"),
54
+ Button("Save", variant="primary", id="save"),
55
+ Button("Don't save", variant="warning", id="dont_save"),
56
+ Button("Cancel", variant="default", id="cancel"),
57
+ id="dialog",
58
+ )
59
+
60
+ @on(Button.Pressed, "#save")
61
+ def save(self) -> None:
62
+ self.dismiss(UnsavedChangeModalResult(is_cancelled=False, should_save=True))
63
+
64
+ @on(Button.Pressed, "#dont_save")
65
+ def dont_save(self) -> None:
66
+ self.dismiss(UnsavedChangeModalResult(is_cancelled=False, should_save=False))
67
+
68
+ @on(Button.Pressed, "#cancel")
69
+ def cancel(self) -> None:
70
+ self.dismiss(UnsavedChangeModalResult(is_cancelled=True, should_save=None))
71
+
72
+
73
+ @dataclass
74
+ class UnsavedChangeQuitModalResult:
75
+ should_quit: bool
76
+
77
+
78
+ class UnsavedChangeQuitModalScreen(ModalScreen[UnsavedChangeQuitModalResult]):
79
+ def compose(self) -> ComposeResult:
80
+ yield Grid(
81
+ Label("Do you want to quit without saving?", id="title"),
82
+ Label("If you don't save, changes will be lost.", id="message"),
83
+ Button("Quit", variant="warning", id="quit"),
84
+ Button("Cancel", variant="default", id="cancel"),
85
+ id="dialog",
86
+ )
87
+
88
+ @on(Button.Pressed, "#quit")
89
+ def quit(self) -> None:
90
+ self.dismiss(UnsavedChangeQuitModalResult(should_quit=True))
91
+
92
+ @on(Button.Pressed, "#cancel")
93
+ def cancel(self) -> None:
94
+ self.dismiss(UnsavedChangeQuitModalResult(should_quit=False))
95
+
96
+
97
+ @dataclass
98
+ class DeleteFileModalResult:
99
+ is_cancelled: bool
100
+ should_delete: bool
101
+
102
+
103
+ class DeleteFileModalScreen(ModalScreen[DeleteFileModalResult]):
104
+ def __init__(self, path: Path) -> None:
105
+ super().__init__()
106
+ self.path = path
107
+
108
+ def compose(self) -> ComposeResult:
109
+ yield Grid(
110
+ Label("Are you sure you want to delete this file?", id="title"),
111
+ Label(str(self.path), id="message"),
112
+ Button("Delete", variant="warning", id="delete"),
113
+ Button("Cancel", variant="default", id="cancel"),
114
+ id="dialog",
115
+ )
116
+
117
+ @on(Button.Pressed, "#delete")
118
+ def delete(self) -> None:
119
+ self.dismiss(DeleteFileModalResult(is_cancelled=False, should_delete=True))
120
+
121
+ @on(Button.Pressed, "#cancel")
122
+ def cancel(self) -> None:
123
+ self.dismiss(DeleteFileModalResult(is_cancelled=True, should_delete=False))
@@ -0,0 +1,160 @@
1
+ #sidebar {
2
+ dock: left;
3
+ width: 20;
4
+ }
5
+
6
+ CodeEditorFooter {
7
+ dock: bottom;
8
+ height: 1;
9
+ margin: 0 1;
10
+ layout: grid;
11
+ grid-size: 2 1;
12
+ grid-gutter: 0 1;
13
+ grid-columns: 1fr auto;
14
+ }
15
+
16
+ CodeEditorFooter #path {
17
+ width: 100%;
18
+ background: $surface;
19
+ }
20
+
21
+ CodeEditorFooter #language {
22
+ text-align: center;
23
+ min-width: 0;
24
+ height: 1;
25
+ padding: 0 1;
26
+ border: none;
27
+ }
28
+
29
+ SaveAsModalScreen {
30
+ align: center middle;
31
+ }
32
+
33
+ SaveAsModalScreen #dialog {
34
+ grid-size: 2 3;
35
+ grid-gutter: 1 2;
36
+ grid-rows: 1fr 3 3;
37
+ padding: 0 1;
38
+ max-width: 60;
39
+ max-height: 11;
40
+ border: thick $background 80%;
41
+ background: $surface;
42
+ }
43
+
44
+ SaveAsModalScreen #title {
45
+ column-span: 2;
46
+ height: 1fr;
47
+ width: 1fr;
48
+ content-align: center middle;
49
+ }
50
+
51
+ SaveAsModalScreen #path {
52
+ column-span: 2;
53
+ height: 1fr;
54
+ width: 1fr;
55
+ content-align: center middle;
56
+ }
57
+
58
+ SaveAsModalScreen Button {
59
+ width: 100%;
60
+ }
61
+
62
+ UnsavedChangeModalScreen {
63
+ align: center middle;
64
+ }
65
+
66
+ UnsavedChangeModalScreen #dialog {
67
+ grid-size: 3 3;
68
+ grid-gutter: 1 2;
69
+ grid-rows: 1fr 1fr 3;
70
+ padding: 0 1;
71
+ max-width: 60;
72
+ max-height: 9;
73
+ border: thick $background 80%;
74
+ background: $surface;
75
+ }
76
+
77
+ UnsavedChangeModalScreen #title {
78
+ column-span: 3;
79
+ height: 1fr;
80
+ width: 1fr;
81
+ content-align: center middle;
82
+ }
83
+
84
+ UnsavedChangeModalScreen #message {
85
+ column-span: 3;
86
+ height: 1fr;
87
+ width: 1fr;
88
+ content-align: center middle;
89
+ }
90
+
91
+ UnsavedChangeModalScreen Button {
92
+ width: 100%;
93
+ }
94
+
95
+
96
+ UnsavedChangeQuitModalScreen {
97
+ align: center middle;
98
+ }
99
+
100
+ UnsavedChangeQuitModalScreen #dialog {
101
+ grid-size: 2 3;
102
+ grid-gutter: 1 2;
103
+ grid-rows: 1fr 1fr 3;
104
+ padding: 0 1;
105
+ max-width: 60;
106
+ max-height: 9;
107
+ border: thick $background 80%;
108
+ background: $surface;
109
+ }
110
+
111
+ UnsavedChangeQuitModalScreen #title {
112
+ column-span: 3;
113
+ height: 1fr;
114
+ width: 1fr;
115
+ content-align: center middle;
116
+ }
117
+
118
+ UnsavedChangeQuitModalScreen #message {
119
+ column-span: 3;
120
+ height: 1fr;
121
+ width: 1fr;
122
+ content-align: center middle;
123
+ }
124
+
125
+ UnsavedChangeQuitModalScreen Button {
126
+ width: 100%;
127
+ }
128
+
129
+ DeleteFileModalScreen {
130
+ align: center middle;
131
+ }
132
+
133
+ DeleteFileModalScreen #dialog {
134
+ grid-size: 2 3;
135
+ grid-gutter: 1 2;
136
+ grid-rows: 1fr 1fr 3;
137
+ padding: 0 1;
138
+ max-width: 60;
139
+ max-height: 9;
140
+ border: thick $background 80%;
141
+ background: $surface;
142
+ }
143
+
144
+ DeleteFileModalScreen #title {
145
+ column-span: 2;
146
+ height: 1fr;
147
+ width: 1fr;
148
+ content-align: center middle;
149
+ }
150
+
151
+ DeleteFileModalScreen #message {
152
+ column-span: 2;
153
+ height: 1fr;
154
+ width: 1fr;
155
+ content-align: center middle;
156
+ }
157
+
158
+ DeleteFileModalScreen Button {
159
+ width: 100%;
160
+ }
textual_code/utils.py ADDED
@@ -0,0 +1,21 @@
1
+ import contextlib
2
+ from collections.abc import Iterable
3
+ from functools import partial
4
+
5
+ from textual.app import App
6
+ from textual.message import Message
7
+ from textual.widget import Widget
8
+
9
+
10
+ @contextlib.contextmanager
11
+ def ready_to_handle(
12
+ target: Widget | App, event: Message, should_exists: Iterable | None = None
13
+ ):
14
+ event.stop()
15
+ is_ready = target.is_attached if isinstance(target, App) else target.is_mounted
16
+ if (not is_ready) or (should_exists is not None and not all(should_exists)):
17
+ # recycle event to be handled later
18
+ callback = partial(target.post_message, event)
19
+ target.set_timer(delay=0.05, callback=callback)
20
+ return
21
+ yield
@@ -0,0 +1,175 @@
1
+ Metadata-Version: 2.4
2
+ Name: textual-code
3
+ Version: 0.0.2
4
+ Summary: Code editor for who don't know how to use vi
5
+ Project-URL: Repository, https://github.com/rishubil/textual-code.git
6
+ Project-URL: Changelog, https://github.com/rishubil/textual-code/blob/master/CHANGELOG.md
7
+ Author-email: Nesswit <rishubil@gmail.com>
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Environment :: Console
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Operating System :: MacOS
13
+ Classifier: Operating System :: Microsoft :: Windows :: Windows 10
14
+ Classifier: Operating System :: Microsoft :: Windows :: Windows 11
15
+ Classifier: Operating System :: POSIX :: Linux
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Topic :: Text Editors
18
+ Requires-Python: >=3.12
19
+ Requires-Dist: textual[syntax]>=1.0.0
20
+ Description-Content-Type: text/markdown
21
+
22
+ # Textual Code
23
+
24
+ Code editor for who don't know how to use vi
25
+
26
+ ![Screenshot](docs/preview.svg)
27
+
28
+ > [!WARNING]
29
+ > This project is in the early stages of development.
30
+ > It is not ready for use yet.
31
+
32
+ ## What is Textual Code?
33
+
34
+ Textual Code is a TUI-based code editor that feels familiar right from the start.
35
+
36
+ You’ve probably had to SSH into a server at some point just to tweak a few lines of code.
37
+ However, vi or Emacs can be overkill for quick fixes, requiring you to remember a whole host of commands for even the simplest changes.
38
+ Furthermore, nano doesn’t always provide enough features for comfortable coding, and setting up a GUI editor on a remote server can be a real hassle.
39
+
40
+ That’s why Textual Code was created.
41
+ You likely use a GUI editor like VS Code or Sublime Text in your day-to-day work, and Textual Code offers a similar experience with no learning curve.
42
+ It behaves much like any other code editor you’re used to.
43
+
44
+ We’re not asking you to switch to Textual Code as your main editor.
45
+ Just remember it’s there when you need to jump onto a server and make a few quick edits.
46
+ It’s that simple.
47
+
48
+ ## Features
49
+
50
+ > [!WARNING]
51
+ > This project is in the early stages of development.
52
+ > the features listed below are not yet implemented or are only partially implemented.
53
+
54
+ - Commonly used shortcuts, such as `Ctrl+S` to save and `Ctrl+F` to search
55
+ - Command palette for quick access to all features, and no need to remember shortcuts
56
+ - Multiple cursors
57
+ - Mouse support
58
+ - Find and replace from workspace
59
+ - Explore files in the sidebar
60
+ - Open files to tabs
61
+ - Syntax highlighting
62
+
63
+ ## TODO
64
+
65
+ - [ ] Explore files
66
+ - [x] Show the files in the sidebar
67
+ - [ ] Open a specific folder from the command palette
68
+ - [ ] Open a specific folder from command arguments
69
+ - [ ] Create
70
+ - [ ] Create a new file from the sidebar
71
+ - [ ] Create a new file from the command palette
72
+ - [ ] Create a new folder from the sidebar
73
+ - [ ] Create a new folder from the command palette
74
+ - [ ] Open file
75
+ - [x] Open a specific file from the sidebar
76
+ - [x] Open files to tabs
77
+ - [x] Open new file from the command palette
78
+ - [x] Open new file from shortcut
79
+ - [ ] Open a specific file from the command palette
80
+ - [ ] Open a specific file from command arguments
81
+ - [ ] Save file
82
+ - [x] Save the current file
83
+ - [x] Save as the current file
84
+ - [x] Save the current file from shortcut
85
+ - [x] Save the current file from the command palette
86
+ - [ ] Save all files
87
+ - [ ] Close file
88
+ - [x] Close the current file
89
+ - [x] Close the current file from shortcut
90
+ - [x] Close the current file from the command palette
91
+ - [x] Ask to save the file before closing
92
+ - [ ] Close all files
93
+ - [ ] Delete
94
+ - [x] Delete the current file
95
+ - [x] Delete the current file from the command palette
96
+ - [ ] Delete a specific file from the sidebar
97
+ - [ ] Delete a specific file from the command palette
98
+ - [ ] Delete a specific folder from the sidebar
99
+ - [ ] Delete a specific folder from the command palette
100
+ - [x] Ask to confirm before deleting
101
+ - [ ] Edit file
102
+ - [x] Basic text editing
103
+ - [ ] Multiple cursors
104
+ - [ ] Code completion
105
+ - [ ] Syntax highlighting
106
+ - [x] Detect the language from the file extension
107
+ - [ ] Change the language
108
+ - [ ] Add more languages
109
+ - [ ] Change Indentation size and style
110
+ - [ ] Change line ending
111
+ - [ ] Change encoding
112
+ - [ ] Show line and column numbers
113
+ - [ ] Goto line and column
114
+ - [ ] Search and replace
115
+ - [ ] Plain Search
116
+ - [ ] Regex search
117
+ - [ ] Replace all
118
+ - [ ] Select all occurrences
119
+ - [ ] In the current file
120
+ - [ ] In all files
121
+ - [ ] Markdown preview
122
+ - [ ] Show the markdown preview
123
+ - [ ] Live preview
124
+ - [ ] Split view
125
+ - [ ] Split the view horizontally
126
+ - [ ] Split the view vertically
127
+ - [ ] Close the split view
128
+ - [ ] Resize the split view
129
+ - [ ] Move the focus to the split view
130
+ - [ ] Move tabs between split views
131
+ - [ ] Sidebar
132
+ - [x] Show the sidebar
133
+ - [ ] Hide the sidebar
134
+ - [ ] Resize the sidebar
135
+ - [ ] Setting
136
+ - [ ] Themes
137
+ - [ ] UI
138
+ - [ ] Syntax highlighting
139
+ - [ ] Shortcuts
140
+ - [ ] Editor
141
+ - [ ] default indentation size and style
142
+ - [ ] default line ending
143
+ - [ ] default encoding
144
+ - [ ] Etc
145
+ - [ ] Support EditorConfig
146
+ - [ ] Release
147
+ - [ ] Package the project
148
+ - [ ] Make the project available on PyPI
149
+
150
+ ## Usage
151
+
152
+ Textual Code is not available on PyPI yet.
153
+
154
+ You can run the code using Docker.
155
+
156
+ ```bash
157
+ docker compose build app
158
+ docker compose run --rm app
159
+ ```
160
+
161
+ ## Development
162
+
163
+ (You need to use devcontainer to run the code)
164
+
165
+ To open the textual console, run the following command:
166
+
167
+ ```bash
168
+ uv run textual console
169
+ ```
170
+
171
+ Then, you can run the following command to run the code:
172
+
173
+ ```bash
174
+ uv run textual run --dev textual_code:main
175
+ ```
@@ -0,0 +1,9 @@
1
+ textual_code/__init__.py,sha256=DJb3Cnen9yom9R6xtUmdN5KvUKX_O8fSjN9qAKyH_PA,133
2
+ textual_code/app.py,sha256=w0Wq741FFFFJT_G9uY0SNpuu-m5OvGrC2UjowkUNvLc,25110
3
+ textual_code/modals.py,sha256=q4XoB8uPL0v46hbPcNUynhbfbiWP3BXGm6tjEGBXnCA,3835
4
+ textual_code/style.tcss,sha256=UIeOUZ99P9XZ8vcGjyofD05W_yHIwwvR0JxOGRWtlRo,2699
5
+ textual_code/utils.py,sha256=1yHjwtiplnu0qOVnYLob1ZhFwpwOhNlTrryCVV9KMxU,679
6
+ textual_code-0.0.2.dist-info/METADATA,sha256=gZHqFEUuPiiQTSAaHqXBh7-cy574kBPB8Sdnv4PfImc,5631
7
+ textual_code-0.0.2.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
8
+ textual_code-0.0.2.dist-info/entry_points.txt,sha256=Y6FWZde4iicIsalkRhe2Xn50zoCU354ShbkBqGlNMAk,51
9
+ textual_code-0.0.2.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.27.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ textual-code = textual_code:main