jvim 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
jets/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """jets: JSON Editor in Textual."""
2
+
3
+ from .editor import EditorMode, JsonEditor
4
+
5
+ __all__ = ["EditorMode", "JsonEditor"]
6
+ __version__ = "0.1.0"
jets/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Allow running with `python -m jets`."""
2
+
3
+ from .app import main
4
+
5
+ main()
jets/app.py ADDED
@@ -0,0 +1,341 @@
1
+ """Demo application for the JSON editor."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ from textual.app import App, ComposeResult
11
+ from textual.containers import Horizontal, Vertical
12
+ from textual.widgets import Button, Header, Static
13
+
14
+ from .editor import JsonEditor
15
+
16
+ # Data directory path
17
+ _DATA_DIR = Path(__file__).parent / "data"
18
+
19
+
20
+ def _load_data(filename: str) -> str:
21
+ """Load content from data directory."""
22
+ return (_DATA_DIR / filename).read_text(encoding="utf-8")
23
+
24
+
25
+ class JsonEditorApp(App):
26
+ """TUI app that wraps the JsonEditor widget."""
27
+
28
+ CSS_PATH = "app.tcss"
29
+ TITLE = "JSON Editor"
30
+ BINDINGS = []
31
+ ENABLE_COMMAND_PALETTE = False
32
+
33
+ def __init__(
34
+ self,
35
+ file_path: str = "",
36
+ initial_content: str = "",
37
+ read_only: bool = False,
38
+ jsonl: bool = False,
39
+ **kwargs,
40
+ ) -> None:
41
+ super().__init__(**kwargs)
42
+ self.file_path = file_path
43
+ self.initial_content = initial_content
44
+ self.read_only = read_only
45
+ self.jsonl = jsonl
46
+ # Embedded edit state - stack of (row, col_start, col_end, parent_content, original_content)
47
+ self._ej_stack: list[tuple[int, int, int, str, str]] = []
48
+ self._main_was_read_only: bool = False
49
+
50
+ def compose(self) -> ComposeResult:
51
+ yield Header(show_clock=True)
52
+ yield JsonEditor(
53
+ self.initial_content,
54
+ read_only=self.read_only,
55
+ jsonl=self.jsonl,
56
+ id="editor",
57
+ )
58
+ with Vertical(id="help-panel"):
59
+ with Horizontal(id="help-header"):
60
+ yield Static("[b]Help[/b]", id="help-title")
61
+ yield Button("\u2715", id="help-close", variant="error")
62
+ yield JsonEditor(_load_data("help.json"), read_only=True, id="help-editor")
63
+ with Vertical(id="ej-panel"):
64
+ with Horizontal(id="ej-header"):
65
+ yield Static("[b]Edit Embedded JSON[/b]", id="ej-title")
66
+ yield Button("\u2715", id="ej-close", variant="error")
67
+ yield JsonEditor("", id="ej-editor")
68
+
69
+ def on_mount(self) -> None:
70
+ self._update_title()
71
+ self.query_one("#editor").focus()
72
+
73
+ def _update_title(self) -> None:
74
+ ro = " [RO]" if self.read_only else ""
75
+ if self.file_path:
76
+ self.sub_title = self.file_path + ro
77
+ else:
78
+ self.sub_title = "[new]" + ro
79
+
80
+ # -- Event handlers ----------------------------------------------------
81
+
82
+ def _is_help_editor_focused(self) -> bool:
83
+ focused = self.focused
84
+ return focused is not None and focused.id == "help-editor"
85
+
86
+ def _is_ej_editor_focused(self) -> bool:
87
+ focused = self.focused
88
+ return focused is not None and focused.id == "ej-editor"
89
+
90
+ def on_key(self) -> None:
91
+ """Update ej title on key press to reflect modified state."""
92
+ if self._is_ej_editor_focused() and self._ej_stack:
93
+ self._update_ej_title()
94
+
95
+ def on_json_editor_quit(self, event: JsonEditor.Quit) -> None:
96
+ if self._is_help_editor_focused():
97
+ self.query_one("#help-panel").remove_class("visible")
98
+ self.query_one("#editor").focus()
99
+ elif self._is_ej_editor_focused():
100
+ if self._ej_has_unsaved_changes():
101
+ self.notify("Unsaved changes! Use :w to save or :q! to discard", severity="warning")
102
+ else:
103
+ self._close_ej_panel()
104
+ else:
105
+ self.exit()
106
+
107
+ def on_json_editor_force_quit(self, event: JsonEditor.ForceQuit) -> None:
108
+ """Handle :q! to discard changes."""
109
+ if self._is_help_editor_focused():
110
+ self.query_one("#help-panel").remove_class("visible")
111
+ self.query_one("#editor").focus()
112
+ elif self._is_ej_editor_focused():
113
+ self._close_ej_panel()
114
+ else:
115
+ self.exit()
116
+
117
+ def on_json_editor_json_validated(
118
+ self, event: JsonEditor.JsonValidated
119
+ ) -> None:
120
+ if event.valid:
121
+ self.notify("JSON is valid", severity="information")
122
+ else:
123
+ self.notify(f"Invalid JSON: {event.error}", severity="error", timeout=6)
124
+
125
+ def on_json_editor_file_save_requested(
126
+ self, event: JsonEditor.FileSaveRequested
127
+ ) -> None:
128
+ # Help editor is read-only, so this shouldn't happen, but just in case
129
+ if self._is_help_editor_focused():
130
+ return
131
+
132
+ # EJ editor: update parent and close/pop panel
133
+ if self._is_ej_editor_focused() and self._ej_stack:
134
+ row, col_start, col_end, prev_content, _ = self._ej_stack.pop()
135
+ # Minify the JSON to a single line
136
+ try:
137
+ parsed = json.loads(event.content)
138
+ minified = json.dumps(parsed, ensure_ascii=False)
139
+ except json.JSONDecodeError:
140
+ self.notify("Invalid JSON", severity="error")
141
+ # Restore the popped entry with current content as new original
142
+ self._ej_stack.append((row, col_start, col_end, prev_content, event.content))
143
+ return
144
+
145
+ if self._ej_stack:
146
+ # Update previous ej content and show it
147
+ ej_editor = self.query_one("#ej-editor", JsonEditor)
148
+ lines = prev_content.split("\n")
149
+ line = lines[row]
150
+ escaped = json.dumps(minified, ensure_ascii=False)
151
+ lines[row] = line[:col_start] + escaped + line[col_end:]
152
+ new_content = "\n".join(lines)
153
+ ej_editor.set_content(new_content)
154
+ # Keep original content unchanged so modified indicator stays
155
+ self._update_ej_title()
156
+ self.notify("Embedded JSON updated", severity="information")
157
+ else:
158
+ # Update main editor and restore its read-only state
159
+ main_editor = self.query_one("#editor", JsonEditor)
160
+ main_editor.read_only = self._main_was_read_only
161
+ main_editor.update_embedded_string(row, col_start, col_end, minified)
162
+ self.notify("Embedded JSON updated", severity="information")
163
+ if event.quit_after:
164
+ self.query_one("#ej-panel").remove_class("visible")
165
+ main_editor.focus()
166
+ return
167
+
168
+ target = event.file_path or self.file_path
169
+ if not target:
170
+ self.notify("No file name — use :w <file>", severity="warning")
171
+ return
172
+
173
+ try:
174
+ path = Path(target)
175
+ path.parent.mkdir(parents=True, exist_ok=True)
176
+ path.write_text(event.content, encoding="utf-8")
177
+ self.file_path = str(path)
178
+ self._update_title()
179
+ self.notify(f"Saved: {self.file_path}", severity="information")
180
+ if event.quit_after:
181
+ self.exit()
182
+ except OSError as exc:
183
+ self.notify(f"Save failed: {exc}", severity="error", timeout=6)
184
+
185
+ def on_json_editor_file_open_requested(
186
+ self, event: JsonEditor.FileOpenRequested
187
+ ) -> None:
188
+ target = event.file_path
189
+ try:
190
+ content = Path(target).read_text(encoding="utf-8")
191
+ except FileNotFoundError:
192
+ self.notify(f"File not found: {target}", severity="error", timeout=6)
193
+ return
194
+ except OSError as exc:
195
+ self.notify(f"Cannot open: {exc}", severity="error", timeout=6)
196
+ return
197
+
198
+ editor = self.query_one("#editor", JsonEditor)
199
+ editor.set_content(content)
200
+ self.jsonl = target.lower().endswith(".jsonl")
201
+ editor.jsonl = self.jsonl
202
+ self.file_path = target
203
+ self._update_title()
204
+ self.notify(f"Opened: {target}", severity="information")
205
+
206
+ def on_json_editor_help_toggle_requested(self) -> None:
207
+ help_panel = self.query_one("#help-panel")
208
+ help_panel.toggle_class("visible")
209
+ if help_panel.has_class("visible"):
210
+ self.query_one("#help-editor").focus()
211
+ else:
212
+ self.query_one("#editor").focus()
213
+
214
+ def on_button_pressed(self, event: Button.Pressed) -> None:
215
+ if event.button.id == "help-close":
216
+ self.query_one("#help-panel").remove_class("visible")
217
+ self.query_one("#editor").focus()
218
+ elif event.button.id == "ej-close":
219
+ self._close_ej_panel()
220
+
221
+ def _ej_has_unsaved_changes(self) -> bool:
222
+ """Check if current ej content differs from original."""
223
+ if not self._ej_stack:
224
+ return False
225
+ ej_editor = self.query_one("#ej-editor", JsonEditor)
226
+ current = ej_editor.get_content()
227
+ _, _, _, _, original = self._ej_stack[-1]
228
+ return current != original
229
+
230
+ def _update_ej_title(self) -> None:
231
+ """Update ej panel title with current nesting level and modified indicator."""
232
+ level = len(self._ej_stack)
233
+ title = self.query_one("#ej-title", Static)
234
+ modified = " [+]" if self._ej_has_unsaved_changes() else ""
235
+ title.update(f"[b]Edit Embedded JSON[/b] [dim](level {level}){modified}[/dim]")
236
+
237
+ def _close_ej_panel(self) -> None:
238
+ """Close or pop one level of ej editing."""
239
+ if not self._ej_stack:
240
+ self.query_one("#ej-panel").remove_class("visible")
241
+ main_editor = self.query_one("#editor", JsonEditor)
242
+ main_editor.read_only = self._main_was_read_only
243
+ main_editor.focus()
244
+ return
245
+
246
+ # Pop current level and get content to restore
247
+ _, _, _, restore_content, _ = self._ej_stack.pop()
248
+
249
+ if self._ej_stack:
250
+ # Restore previous level content
251
+ ej_editor = self.query_one("#ej-editor", JsonEditor)
252
+ ej_editor.set_content(restore_content)
253
+ self._update_ej_title()
254
+ else:
255
+ # No more levels, close panel and restore main editor state
256
+ self.query_one("#ej-panel").remove_class("visible")
257
+ main_editor = self.query_one("#editor", JsonEditor)
258
+ main_editor.read_only = self._main_was_read_only
259
+ main_editor.focus()
260
+
261
+ def on_json_editor_embedded_edit_requested(
262
+ self, event: JsonEditor.EmbeddedEditRequested
263
+ ) -> None:
264
+ ej_editor = self.query_one("#ej-editor", JsonEditor)
265
+
266
+ if self._is_ej_editor_focused():
267
+ # Nested ej from ej panel - push to stack
268
+ current_content = ej_editor.get_content()
269
+ self._ej_stack.append((
270
+ event.source_row,
271
+ event.source_col_start,
272
+ event.source_col_end,
273
+ current_content,
274
+ event.content, # original content for change detection
275
+ ))
276
+ else:
277
+ # From main editor - reset stack to level 1
278
+ main_editor = self.query_one("#editor", JsonEditor)
279
+ self._main_was_read_only = main_editor.read_only
280
+ main_editor.read_only = True
281
+ self._ej_stack = [(
282
+ event.source_row,
283
+ event.source_col_start,
284
+ event.source_col_end,
285
+ "", # No previous ej content
286
+ event.content, # original content for change detection
287
+ )]
288
+
289
+ # Set content and show panel
290
+ ej_editor.set_content(event.content)
291
+ self._update_ej_title()
292
+ self.query_one("#ej-panel").add_class("visible")
293
+ ej_editor.focus()
294
+
295
+
296
+ def main() -> None:
297
+ parser = argparse.ArgumentParser(
298
+ prog="vj",
299
+ description="JSON Editor in Textual",
300
+ )
301
+ parser.add_argument(
302
+ "file",
303
+ nargs="?",
304
+ default="",
305
+ help="JSON file to open",
306
+ )
307
+ parser.add_argument(
308
+ "-R", "--read-only",
309
+ action="store_true",
310
+ default=False,
311
+ help="open in read-only mode",
312
+ )
313
+ args = parser.parse_args()
314
+
315
+ file_path: str = args.file
316
+ initial_content: str = _load_data("sample.json")
317
+ jsonl: bool = file_path.lower().endswith(".jsonl") if file_path else False
318
+
319
+ if file_path:
320
+ path = Path(file_path)
321
+ try:
322
+ if path.exists():
323
+ initial_content = path.read_text(encoding="utf-8")
324
+ else:
325
+ # New file — start with empty object / empty line
326
+ initial_content = "" if jsonl else "{}"
327
+ except PermissionError as exc:
328
+ print(f"vj: {exc}", file=sys.stderr)
329
+ sys.exit(1)
330
+
331
+ app = JsonEditorApp(
332
+ file_path=file_path,
333
+ initial_content=initial_content,
334
+ read_only=args.read_only,
335
+ jsonl=jsonl,
336
+ )
337
+ app.run()
338
+
339
+
340
+ if __name__ == "__main__":
341
+ main()
jets/app.tcss ADDED
@@ -0,0 +1,73 @@
1
+ Screen {
2
+ layout: vertical;
3
+ }
4
+ #editor {
5
+ width: 1fr;
6
+ height: 1fr;
7
+ }
8
+ #help-panel {
9
+ height: 20%;
10
+ display: none;
11
+ }
12
+ #help-panel.visible {
13
+ display: block;
14
+ }
15
+ #help-header {
16
+ height: auto;
17
+ padding: 0 1;
18
+ background: $accent;
19
+ }
20
+ #help-title {
21
+ width: 1fr;
22
+ padding: 0;
23
+ }
24
+ #help-close {
25
+ min-width: 3;
26
+ width: 3;
27
+ height: 1;
28
+ padding: 0;
29
+ margin: 0;
30
+ border: none;
31
+ background: transparent;
32
+ color: $text;
33
+ }
34
+ #help-close:hover {
35
+ background: $error;
36
+ color: $text;
37
+ }
38
+ #help-editor {
39
+ height: 1fr;
40
+ }
41
+ #ej-panel {
42
+ height: 50%;
43
+ display: none;
44
+ }
45
+ #ej-panel.visible {
46
+ display: block;
47
+ }
48
+ #ej-header {
49
+ height: auto;
50
+ padding: 0 1;
51
+ background: $warning;
52
+ }
53
+ #ej-title {
54
+ width: 1fr;
55
+ padding: 0;
56
+ }
57
+ #ej-close {
58
+ min-width: 3;
59
+ width: 3;
60
+ height: 1;
61
+ padding: 0;
62
+ margin: 0;
63
+ border: none;
64
+ background: transparent;
65
+ color: $text;
66
+ }
67
+ #ej-close:hover {
68
+ background: $error;
69
+ color: $text;
70
+ }
71
+ #ej-editor {
72
+ height: 1fr;
73
+ }
jets/data/help.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "Movement": {
3
+ "h j k l": "left/down/up/right",
4
+ "w b": "word forward/backward",
5
+ "0 $ ^": "line start/end/first char",
6
+ "gg G": "file start/end",
7
+ "%": "jump to matching bracket",
8
+ "PgUp PgDn": "page up/down",
9
+ "Ctrl+d/u": "half page down/up"
10
+ },
11
+ "Search": {
12
+ "/": "search forward",
13
+ "?": "search backward",
14
+ "n": "next match",
15
+ "N": "previous match",
16
+ "Up/Down": "search history",
17
+ "\\c \\C": "case insensitive/sensitive",
18
+ "/$. /$[": "JSONPath search (auto-detect)",
19
+ "\\j": "JSONPath suffix for other patterns"
20
+ },
21
+ "Insert Mode": {
22
+ "i I": "insert at cursor/line start",
23
+ "a A": "append after cursor/line end",
24
+ "o O": "open line below/above"
25
+ },
26
+ "Editing": {
27
+ "x": "delete char",
28
+ "dd": "delete line",
29
+ "dw d$": "delete word/to end",
30
+ "cw cc": "change word/line",
31
+ "r{c}": "replace char",
32
+ "J": "join lines",
33
+ "yy p P": "yank/paste after/before",
34
+ "u": "undo",
35
+ "Ctrl+r": "redo",
36
+ ".": "repeat last edit",
37
+ "ej": "edit embedded JSON string"
38
+ },
39
+ "Commands": {
40
+ ":w": "save",
41
+ ":w {file}": "save as",
42
+ ":e {file}": "open file",
43
+ ":fmt": "format JSON",
44
+ ":q": "quit (confirm if changed)",
45
+ ":q!": "quit (discard changes)",
46
+ ":wq": "save and quit",
47
+ ":help": "toggle this help"
48
+ }
49
+ }
jets/data/sample.json ADDED
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "json-editor",
3
+ "version": "1.0.0",
4
+ "description": "A modal JSON editor built with Textual",
5
+ "features": [
6
+ "normal mode",
7
+ "insert mode",
8
+ "command mode",
9
+ "syntax highlighting",
10
+ "json validation",
11
+ "bracket matching"
12
+ ],
13
+ "config": {
14
+ "theme": "dark",
15
+ "indent_size": 4,
16
+ "auto_format": true,
17
+ "max_undo": 200,
18
+ "nested": {
19
+ "deep": {
20
+ "value": null
21
+ }
22
+ }
23
+ },
24
+ "scores": [100, 200, 300]
25
+ }