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.
- textual_code/__init__.py +10 -0
- textual_code/app.py +719 -0
- textual_code/modals.py +123 -0
- textual_code/style.tcss +160 -0
- textual_code/utils.py +21 -0
- textual_code-0.0.2.dist-info/METADATA +175 -0
- textual_code-0.0.2.dist-info/RECORD +9 -0
- textual_code-0.0.2.dist-info/WHEEL +4 -0
- textual_code-0.0.2.dist-info/entry_points.txt +2 -0
textual_code/__init__.py
ADDED
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))
|
textual_code/style.tcss
ADDED
|
@@ -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
|
+

|
|
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,,
|