jim_editor 0.1.0__tar.gz

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,22 @@
1
+ Metadata-Version: 2.4
2
+ Name: jim_editor
3
+ Version: 0.1.0
4
+ Summary: A fast, simple terminal text editor
5
+ Requires-Python: >=3.14
6
+ Requires-Dist: pyperclip>=1.9
7
+ Requires-Dist: textual>=8.2
8
+ Requires-Dist: tree-sitter>=0.23
9
+ Requires-Dist: tree-sitter-python
10
+ Requires-Dist: tree-sitter-javascript
11
+ Requires-Dist: tree-sitter-yaml
12
+ Requires-Dist: tree-sitter-html
13
+ Requires-Dist: tree-sitter-go
14
+ Requires-Dist: tree-sitter-rust
15
+ Requires-Dist: tree-sitter-json
16
+ Requires-Dist: tree-sitter-toml
17
+ Requires-Dist: tree-sitter-bash
18
+ Requires-Dist: tree-sitter-xml
19
+ Requires-Dist: tree-sitter-css
20
+ Requires-Dist: tree-sitter-java
21
+ Requires-Dist: tree-sitter-markdown
22
+ Requires-Dist: tree-sitter-sql
@@ -0,0 +1,55 @@
1
+ [build-system]
2
+ requires = ["setuptools>=69", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "jim_editor"
7
+ version = "0.1.0"
8
+ description = "A fast, simple terminal text editor"
9
+ requires-python = ">=3.14"
10
+ dependencies = [
11
+ "pyperclip>=1.9",
12
+ "textual>=8.2",
13
+ "tree-sitter>=0.23",
14
+ "tree-sitter-python",
15
+ "tree-sitter-javascript",
16
+ "tree-sitter-yaml",
17
+ "tree-sitter-html",
18
+ "tree-sitter-go",
19
+ "tree-sitter-rust",
20
+ "tree-sitter-json",
21
+ "tree-sitter-toml",
22
+ "tree-sitter-bash",
23
+ "tree-sitter-xml",
24
+ "tree-sitter-css",
25
+ "tree-sitter-java",
26
+ "tree-sitter-markdown",
27
+ "tree-sitter-sql",
28
+ ]
29
+
30
+ [project.scripts]
31
+ jim_editor = "jim_editor.__main__:main"
32
+
33
+ [dependency-groups]
34
+ dev = [
35
+ "mkdocs-material>=9",
36
+ "mike>=2",
37
+ "pytest>=8",
38
+ "ruff>=0.9",
39
+ ]
40
+
41
+ [tool.setuptools]
42
+ package-dir = {"" = "src"}
43
+
44
+ [tool.setuptools.packages.find]
45
+ where = ["src"]
46
+
47
+ [tool.ruff]
48
+ target-version = "py314"
49
+ line-length = 100
50
+
51
+ [tool.ruff.lint]
52
+ select = ["E", "F", "I"]
53
+
54
+ [tool.pytest.ini_options]
55
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,5 @@
1
+ """jim editor package."""
2
+
3
+ __all__ = ["__version__"]
4
+
5
+ __version__ = "0.1.0"
@@ -0,0 +1,4 @@
1
+ from .app import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
@@ -0,0 +1,1057 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import os
5
+ import re
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from tempfile import NamedTemporaryFile
9
+
10
+ import pyperclip
11
+ from textual.app import App, ComposeResult
12
+ from textual.binding import Binding
13
+ from textual.containers import Horizontal, Vertical
14
+ from textual.screen import ModalScreen
15
+ from textual.widgets import (
16
+ Button,
17
+ Checkbox,
18
+ DirectoryTree,
19
+ Input,
20
+ Static,
21
+ TabbedContent,
22
+ TabPane,
23
+ TextArea,
24
+ )
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class SearchRequest:
29
+ query: str
30
+ regex: bool
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class SearchMatch:
35
+ start: tuple[int, int]
36
+ end: tuple[int, int]
37
+
38
+
39
+ SEARCH_HIGHLIGHT_NAME = "keyword"
40
+
41
+
42
+ def detect_newline(text: str) -> str:
43
+ if "\r\n" in text:
44
+ return "\r\n"
45
+ if "\r" in text:
46
+ return "\r"
47
+ return "\n"
48
+
49
+
50
+ def normalize_newlines(text: str) -> str:
51
+ return text.replace("\r\n", "\n").replace("\r", "\n")
52
+
53
+
54
+ def end_location(text: str) -> tuple[int, int]:
55
+ lines = text.split("\n")
56
+ return len(lines) - 1, len(lines[-1])
57
+
58
+
59
+ def location_to_offset(text: str, location: tuple[int, int]) -> int:
60
+ row, column = location
61
+ lines = text.split("\n")
62
+ row = max(0, min(row, len(lines) - 1))
63
+ column = max(0, column)
64
+ offset = 0
65
+ for line in lines[:row]:
66
+ offset += len(line) + 1
67
+ return min(offset + min(column, len(lines[row])), len(text))
68
+
69
+
70
+ def offset_to_location(text: str, offset: int) -> tuple[int, int]:
71
+ offset = max(0, min(offset, len(text)))
72
+ row = 0
73
+ column = 0
74
+ for character in text[:offset]:
75
+ if character == "\n":
76
+ row += 1
77
+ column = 0
78
+ else:
79
+ column += 1
80
+ return row, column
81
+
82
+
83
+ def goto_line_location(text: str, line_number: int) -> tuple[int, int]:
84
+ if line_number <= 1:
85
+ return 0, 0
86
+ lines = text.split("\n")
87
+ if line_number > len(lines):
88
+ return end_location(text)
89
+ return line_number - 1, 0
90
+
91
+
92
+ def search_document(
93
+ text: str,
94
+ query: str,
95
+ *,
96
+ regex: bool = False,
97
+ start_location: tuple[int, int] = (0, 0),
98
+ ) -> SearchMatch | None:
99
+ matches = find_search_matches(text, query, regex=regex)
100
+ if not matches:
101
+ return None
102
+ return find_next_search_match(text, matches, start_location)
103
+
104
+
105
+ def find_search_matches(text: str, query: str, *, regex: bool = False) -> list[SearchMatch]:
106
+ if not query:
107
+ return []
108
+
109
+ matches: list[SearchMatch] = []
110
+ if regex:
111
+ pattern = re.compile(query, re.MULTILINE)
112
+ for match in pattern.finditer(text):
113
+ if match.start() == match.end():
114
+ continue
115
+ matches.append(
116
+ SearchMatch(
117
+ start=offset_to_location(text, match.start()),
118
+ end=offset_to_location(text, match.end()),
119
+ ),
120
+ )
121
+ else:
122
+ query_length = len(query)
123
+ search_start = 0
124
+ while True:
125
+ match_start = text.find(query, search_start)
126
+ if match_start == -1:
127
+ break
128
+ matches.append(
129
+ SearchMatch(
130
+ start=offset_to_location(text, match_start),
131
+ end=offset_to_location(text, match_start + query_length),
132
+ ),
133
+ )
134
+ search_start = match_start + max(query_length, 1)
135
+
136
+ return matches
137
+
138
+
139
+ def find_next_search_match(
140
+ text: str,
141
+ matches: list[SearchMatch],
142
+ start_location: tuple[int, int],
143
+ ) -> SearchMatch | None:
144
+ if not matches:
145
+ return None
146
+
147
+ start_offset = location_to_offset(text, start_location)
148
+ for match in matches:
149
+ if location_to_offset(text, match.start) >= start_offset:
150
+ return match
151
+ return matches[0]
152
+
153
+
154
+ def find_previous_search_match(
155
+ text: str,
156
+ matches: list[SearchMatch],
157
+ start_location: tuple[int, int],
158
+ ) -> SearchMatch | None:
159
+ if not matches:
160
+ return None
161
+
162
+ start_offset = location_to_offset(text, start_location)
163
+ for match in reversed(matches):
164
+ if location_to_offset(text, match.end) <= start_offset:
165
+ return match
166
+ return matches[-1]
167
+
168
+
169
+ def load_document(path: Path) -> tuple[str, str]:
170
+ if not path.exists():
171
+ return "", "\n"
172
+
173
+ with path.open("r", encoding="utf-8", newline="") as file:
174
+ try:
175
+ raw_text = file.read()
176
+ except UnicodeDecodeError:
177
+ raise OSError(f"{path.name}: not a text file (binary or non-UTF-8 encoding)")
178
+
179
+ return normalize_newlines(raw_text), detect_newline(raw_text)
180
+
181
+
182
+ def save_document(path: Path, text: str, newline: str) -> None:
183
+ path.parent.mkdir(parents=True, exist_ok=True)
184
+ payload = text if newline == "\n" else text.replace("\n", newline)
185
+
186
+ with NamedTemporaryFile(
187
+ "w",
188
+ encoding="utf-8",
189
+ newline="",
190
+ delete=False,
191
+ dir=path.parent,
192
+ ) as temp_file:
193
+ temp_file.write(payload)
194
+ temp_file.flush()
195
+ os.fsync(temp_file.fileno())
196
+ temp_path = Path(temp_file.name)
197
+
198
+ temp_path.replace(path)
199
+
200
+
201
+ def language_for_path(path: Path | None) -> str | None:
202
+ if path is None:
203
+ return "python"
204
+
205
+ language_map = {
206
+ ".bash": "bash",
207
+ ".c": "c",
208
+ ".css": "css",
209
+ ".go": "go",
210
+ ".htm": "html",
211
+ ".html": "html",
212
+ ".java": "java",
213
+ ".js": "javascript",
214
+ ".json": "json",
215
+ ".jsx": "javascript",
216
+ ".md": "markdown",
217
+ ".py": "python",
218
+ ".rs": "rust",
219
+ ".sh": "bash",
220
+ ".sql": "sql",
221
+ ".toml": "toml",
222
+ ".ts": "javascript",
223
+ ".tsx": "javascript",
224
+ ".xml": "xml",
225
+ ".yaml": "yaml",
226
+ ".yml": "yaml",
227
+ }
228
+ return language_map.get(path.suffix.lower())
229
+
230
+
231
+ def format_shortcuts() -> str:
232
+ return "\n".join(
233
+ [
234
+ "[b]jim shortcuts[/b]",
235
+ "",
236
+ "F1 help",
237
+ "Ctrl+S save",
238
+ "Ctrl+Q quit",
239
+ "Ctrl+F search",
240
+ "Ctrl+G search next",
241
+ "Ctrl+R search previous",
242
+ "Ctrl+L goto line",
243
+ "Ctrl+N new buffer",
244
+ "Ctrl+O open file browser",
245
+ "Ctrl+B close buffer",
246
+ "Ctrl+PgDn next buffer",
247
+ "Ctrl+PgUp previous buffer",
248
+ "Ctrl+Space / Ctrl+@ toggle mark mode",
249
+ "Ctrl+A start of line",
250
+ "Ctrl+E end of line",
251
+ "Ctrl+Home start of file",
252
+ "Ctrl+End end of file",
253
+ "Ctrl+C copy",
254
+ "Ctrl+X cut",
255
+ "Ctrl+V paste",
256
+ "Arrow keys move cursor",
257
+ "Tab insert tab",
258
+ "Backspace delete left",
259
+ "Delete delete right",
260
+ "Esc close help/search dialogs",
261
+ ],
262
+ )
263
+
264
+
265
+ class SearchDialog(ModalScreen[SearchRequest | None]):
266
+ BINDINGS = [Binding("escape", "cancel", "Cancel", show=False)]
267
+
268
+ def compose(self) -> ComposeResult:
269
+ yield Vertical(
270
+ Static("Search file", classes="dialog-title"),
271
+ Input(id="query", placeholder="Search text or regex"),
272
+ Checkbox("Regex", value=False, id="regex"),
273
+ Horizontal(
274
+ Button("Search", id="search", variant="primary"),
275
+ Button("Cancel", id="cancel"),
276
+ ),
277
+ classes="dialog",
278
+ )
279
+
280
+ def on_mount(self) -> None:
281
+ self.query_one(Input).focus()
282
+
283
+ def action_cancel(self) -> None:
284
+ self.dismiss(None)
285
+
286
+ def on_input_submitted(self, _: Input.Submitted) -> None:
287
+ self._submit()
288
+
289
+ def on_button_pressed(self, event: Button.Pressed) -> None:
290
+ if event.button.id == "search":
291
+ self._submit()
292
+ else:
293
+ self.dismiss(None)
294
+
295
+ def _submit(self) -> None:
296
+ query = self.query_one(Input).value.strip()
297
+ regex = self.query_one("#regex", Checkbox).value
298
+ self.dismiss(SearchRequest(query=query, regex=regex))
299
+
300
+
301
+ class GotoLineDialog(ModalScreen[int | None]):
302
+ BINDINGS = [Binding("escape", "cancel", "Cancel", show=False)]
303
+
304
+ def compose(self) -> ComposeResult:
305
+ yield Vertical(
306
+ Static("Go to line", classes="dialog-title"),
307
+ Input(id="line", placeholder="Line number", restrict="0123456789"),
308
+ Horizontal(
309
+ Button("Go", id="go", variant="primary"),
310
+ Button("Cancel", id="cancel"),
311
+ ),
312
+ classes="dialog",
313
+ )
314
+
315
+ def on_mount(self) -> None:
316
+ self.query_one(Input).focus()
317
+
318
+ def action_cancel(self) -> None:
319
+ self.dismiss(None)
320
+
321
+ def on_input_submitted(self, _: Input.Submitted) -> None:
322
+ self._submit()
323
+
324
+ def on_button_pressed(self, event: Button.Pressed) -> None:
325
+ if event.button.id == "go":
326
+ self._submit()
327
+ else:
328
+ self.dismiss(None)
329
+
330
+ def _submit(self) -> None:
331
+ value = self.query_one(Input).value.strip()
332
+ try:
333
+ self.dismiss(int(value))
334
+ except ValueError:
335
+ self.dismiss(None)
336
+
337
+
338
+ class PathDialog(ModalScreen[str | None]):
339
+ BINDINGS = [Binding("escape", "cancel", "Cancel", show=False)]
340
+
341
+ def __init__(self, title: str, placeholder: str, *, confirm_label: str = "Open") -> None:
342
+ super().__init__()
343
+ self._title = title
344
+ self._placeholder = placeholder
345
+ self._confirm_label = confirm_label
346
+
347
+ def compose(self) -> ComposeResult:
348
+ yield Vertical(
349
+ Static(self._title, classes="dialog-title"),
350
+ Input(id="path", placeholder=self._placeholder),
351
+ Horizontal(
352
+ Button(self._confirm_label, id="open", variant="primary"),
353
+ Button("Cancel", id="cancel"),
354
+ ),
355
+ classes="dialog",
356
+ )
357
+
358
+ def on_mount(self) -> None:
359
+ self.query_one(Input).focus()
360
+
361
+ def action_cancel(self) -> None:
362
+ self.dismiss(None)
363
+
364
+ def on_input_submitted(self, _: Input.Submitted) -> None:
365
+ self._submit()
366
+
367
+ def on_button_pressed(self, event: Button.Pressed) -> None:
368
+ if event.button.id == "open":
369
+ self._submit()
370
+ else:
371
+ self.dismiss(None)
372
+
373
+ def _submit(self) -> None:
374
+ value = self.query_one(Input).value.strip()
375
+ self.dismiss(value or None)
376
+
377
+
378
+ class FileBrowserDialog(ModalScreen[Path | None]):
379
+ BINDINGS = [Binding("escape", "cancel", "Cancel", show=False)]
380
+
381
+ def __init__(self, start_path: Path) -> None:
382
+ super().__init__()
383
+ self._start_path = start_path
384
+ self._selected_path: Path | None = None
385
+ self._status: Static | None = None
386
+
387
+ def compose(self) -> ComposeResult:
388
+ yield Vertical(
389
+ Static(f"Open file from {self._start_path}", classes="dialog-title"),
390
+ DirectoryTree(self._start_path, id="file-browser"),
391
+ Static(
392
+ "Select a file to open. Use arrows to navigate; Enter expands folders.",
393
+ id="browser-status",
394
+ ),
395
+ Horizontal(
396
+ Button("Open", id="open", variant="primary"),
397
+ Button("Cancel", id="cancel"),
398
+ ),
399
+ classes="dialog file-browser-dialog",
400
+ )
401
+
402
+ def on_mount(self) -> None:
403
+ self._status = self.query_one("#browser-status", Static)
404
+ self.query_one(DirectoryTree).focus()
405
+
406
+ def action_cancel(self) -> None:
407
+ self.dismiss(None)
408
+
409
+ def on_directory_tree_file_selected(self, event: DirectoryTree.FileSelected) -> None:
410
+ self._selected_path = event.path
411
+ self.dismiss(event.path)
412
+
413
+ def on_directory_tree_directory_selected(self, event: DirectoryTree.DirectorySelected) -> None:
414
+ self._selected_path = event.path
415
+ if self._status is not None:
416
+ self._status.update(f"Selected folder: {event.path}")
417
+
418
+ def on_button_pressed(self, event: Button.Pressed) -> None:
419
+ if event.button.id == "open":
420
+ self.dismiss(self._selected_path)
421
+ else:
422
+ self.dismiss(None)
423
+
424
+
425
+ class HelpDialog(ModalScreen[None]):
426
+ BINDINGS = [Binding("escape", "dismiss_help", "Close", show=False)]
427
+
428
+ def compose(self) -> ComposeResult:
429
+ yield Vertical(
430
+ Static("Help", classes="dialog-title"),
431
+ Static(format_shortcuts(), id="help-body"),
432
+ Button("Close", id="close", variant="primary"),
433
+ classes="dialog help-dialog",
434
+ )
435
+
436
+ def on_mount(self) -> None:
437
+ self.query_one(Button).focus()
438
+
439
+ def action_dismiss_help(self) -> None:
440
+ self.dismiss(None)
441
+
442
+ def on_button_pressed(self, _: Button.Pressed) -> None:
443
+ self.dismiss(None)
444
+
445
+
446
+ class ConfirmDialog(ModalScreen[bool]):
447
+ BINDINGS = [Binding("escape", "cancel", "Cancel", show=False)]
448
+
449
+ def __init__(self, message: str) -> None:
450
+ super().__init__()
451
+ self._message = message
452
+
453
+ def compose(self) -> ComposeResult:
454
+ yield Vertical(
455
+ Static(self._message, classes="dialog-title"),
456
+ Horizontal(
457
+ Button("Yes", id="yes", variant="warning"),
458
+ Button("No", id="no", variant="primary"),
459
+ ),
460
+ classes="dialog",
461
+ )
462
+
463
+ def on_mount(self) -> None:
464
+ self.query_one("#no", Button).focus()
465
+
466
+ def action_cancel(self) -> None:
467
+ self.dismiss(False)
468
+
469
+ def on_button_pressed(self, event: Button.Pressed) -> None:
470
+ self.dismiss(event.button.id == "yes")
471
+
472
+
473
+ class JimTextArea(TextArea):
474
+ BINDINGS = TextArea.BINDINGS + [
475
+ Binding("ctrl+@", "toggle_mark_mode", "Toggle mark mode", show=True),
476
+ Binding("ctrl+space", "toggle_mark_mode", "Toggle mark mode", show=False),
477
+ Binding("ctrl+home", "document_start", "Start of file", show=True),
478
+ Binding("ctrl+end", "document_end", "End of file", show=True),
479
+ ]
480
+
481
+ def __init__(self, *args, **kwargs) -> None:
482
+ self._search_query = ""
483
+ self._search_regex = False
484
+ super().__init__(*args, **kwargs)
485
+
486
+ def _build_highlight_map(self) -> None:
487
+ super()._build_highlight_map()
488
+ if not getattr(self, "_search_query", ""):
489
+ return
490
+
491
+ for match in find_search_matches(self.text, self._search_query, regex=self._search_regex):
492
+ self._add_search_highlight(match)
493
+
494
+ def _add_search_highlight(self, match: SearchMatch) -> None:
495
+ highlights = self._highlights
496
+ start_row, start_column = match.start
497
+ end_row, end_column = match.end
498
+ if start_row == end_row:
499
+ highlights[start_row].append((start_column, end_column, SEARCH_HIGHLIGHT_NAME))
500
+ return
501
+
502
+ highlights[start_row].append((start_column, None, SEARCH_HIGHLIGHT_NAME))
503
+ for row in range(start_row + 1, end_row):
504
+ highlights[row].append((0, None, SEARCH_HIGHLIGHT_NAME))
505
+ highlights[end_row].append((0, end_column, SEARCH_HIGHLIGHT_NAME))
506
+
507
+ def set_search(self, query: str, regex: bool) -> None:
508
+ self._search_query = query
509
+ self._search_regex = regex
510
+ self._build_highlight_map()
511
+ self.refresh()
512
+
513
+ def clear_search(self) -> None:
514
+ self.set_search("", False)
515
+
516
+ def action_toggle_mark_mode(self) -> None:
517
+ app = self.app
518
+ if isinstance(app, JimApp):
519
+ app.toggle_mark_mode()
520
+
521
+ def action_cursor_left(self, select: bool = False) -> None:
522
+ super().action_cursor_left(select=select or self._mark_mode())
523
+
524
+ def action_cursor_right(self, select: bool = False) -> None:
525
+ super().action_cursor_right(select=select or self._mark_mode())
526
+
527
+ def action_cursor_up(self, select: bool = False) -> None:
528
+ super().action_cursor_up(select=select or self._mark_mode())
529
+
530
+ def action_cursor_down(self, select: bool = False) -> None:
531
+ super().action_cursor_down(select=select or self._mark_mode())
532
+
533
+ def action_cursor_line_start(self, select: bool = False) -> None:
534
+ if not self._has_cursor:
535
+ self.scroll_home()
536
+ return
537
+ target = self.get_cursor_line_start_location(smart_home=True)
538
+ self.move_cursor(target, select=select or self._mark_mode())
539
+
540
+ def action_cursor_line_end(self, select: bool = False) -> None:
541
+ if not self._has_cursor:
542
+ self.scroll_end()
543
+ return
544
+ target = self.get_cursor_line_end_location()
545
+ self.move_cursor(target, select=select or self._mark_mode())
546
+
547
+ def action_cursor_page_up(self, select: bool = False) -> None:
548
+ if not self.show_cursor:
549
+ self.scroll_page_up()
550
+ return
551
+ height = self.content_size.height
552
+ _, cursor_location = self.selection
553
+ target = self.navigator.get_location_at_y_offset(cursor_location, -height)
554
+ self.scroll_relative(y=-height, animate=False)
555
+ self.move_cursor(target, select=select or self._mark_mode())
556
+
557
+ def action_cursor_page_down(self, select: bool = False) -> None:
558
+ if not self.show_cursor:
559
+ self.scroll_page_down()
560
+ return
561
+ height = self.content_size.height
562
+ _, cursor_location = self.selection
563
+ target = self.navigator.get_location_at_y_offset(cursor_location, height)
564
+ self.scroll_relative(y=height, animate=False)
565
+ self.move_cursor(target, select=select or self._mark_mode())
566
+
567
+ def action_document_start(self, select: bool = False) -> None:
568
+ self.move_cursor((0, 0), select=select or self._mark_mode())
569
+
570
+ def action_document_end(self, select: bool = False) -> None:
571
+ self.move_cursor(end_location(self.text), select=select or self._mark_mode())
572
+
573
+ def _mark_mode(self) -> bool:
574
+ app = self.app
575
+ return isinstance(app, JimApp) and app.mark_mode
576
+
577
+
578
+ class BufferTab(TabPane):
579
+ def __init__(
580
+ self,
581
+ *,
582
+ title: str,
583
+ text: str,
584
+ file_path: Path | None,
585
+ newline: str,
586
+ language: str | None,
587
+ pane_id: str,
588
+ ) -> None:
589
+ editor = JimTextArea(
590
+ text,
591
+ language=language,
592
+ id=f"editor-{pane_id}",
593
+ soft_wrap=False,
594
+ tab_behavior="indent",
595
+ show_line_numbers=True,
596
+ highlight_cursor_line=True,
597
+ compact=True,
598
+ )
599
+ super().__init__(title, editor, id=pane_id)
600
+ self.file_path = file_path
601
+ self.newline = newline
602
+ self.saved_text = text
603
+ self._language = language
604
+
605
+ @property
606
+ def editor(self) -> JimTextArea:
607
+ return self.query_one(JimTextArea)
608
+
609
+
610
+ class JimApp(App[None]):
611
+ CSS = """
612
+ Screen {
613
+ layout: vertical;
614
+ }
615
+
616
+ #buffers {
617
+ width: 1fr;
618
+ height: 1fr;
619
+ }
620
+
621
+ #status {
622
+ height: auto;
623
+ border-top: solid $accent;
624
+ padding: 0 1;
625
+ background: $panel;
626
+ }
627
+
628
+ .dialog {
629
+ width: 70;
630
+ padding: 1 2;
631
+ border: round $accent;
632
+ background: $surface;
633
+ }
634
+
635
+ .help-dialog {
636
+ height: auto;
637
+ }
638
+
639
+ .dialog-title {
640
+ padding-bottom: 1;
641
+ text-style: bold;
642
+ }
643
+
644
+ #help-body {
645
+ height: auto;
646
+ padding-bottom: 1;
647
+ }
648
+ """
649
+
650
+ BINDINGS = [
651
+ Binding("ctrl+s", "save_file", "Save", show=True),
652
+ Binding("ctrl+q", "safe_quit", "Quit", show=True),
653
+ Binding("ctrl+f", "search", "Search", show=True),
654
+ Binding("ctrl+g", "search_next", "Search next", show=True),
655
+ Binding("ctrl+r", "search_previous", "Search previous", show=True),
656
+ Binding("ctrl+l", "goto_line", "Go to line", show=True),
657
+ Binding("ctrl+n", "new_buffer", "New buffer", show=True),
658
+ Binding("ctrl+o", "open_file", "Open file", show=True),
659
+ Binding("ctrl+b", "close_buffer", "Close buffer", show=True),
660
+ Binding("f1", "show_help", "Help", show=True),
661
+ Binding("ctrl+pagedown", "next_buffer", "Next buffer", show=True),
662
+ Binding("ctrl+pageup", "previous_buffer", "Previous buffer", show=True),
663
+ ]
664
+
665
+ def __init__(self, file_path: Path | None = None) -> None:
666
+ super().__init__()
667
+ self.start_path = file_path.expanduser() if file_path is not None else None
668
+ self.status_message = "Ready"
669
+ self.mark_mode = False
670
+ self._clipboard_text = ""
671
+ self._buffers: TabbedContent | None = None
672
+ self._status: Static | None = None
673
+ self._buffer_serial = 0
674
+
675
+ def compose(self) -> ComposeResult:
676
+ yield TabbedContent(id="buffers")
677
+ yield Static("", id="status")
678
+
679
+ def on_mount(self) -> None:
680
+ self._buffers = self.query_one(TabbedContent)
681
+ self._status = self.query_one("#status", Static)
682
+
683
+ if self.start_path is not None:
684
+ self.open_buffer(self.start_path)
685
+ else:
686
+ self.open_buffer(None)
687
+
688
+ self.refresh_status()
689
+
690
+ def on_tabbed_content_tab_activated(self, _: TabbedContent.TabActivated) -> None:
691
+ self.focus_active_editor()
692
+ self.refresh_status()
693
+
694
+ def on_text_area_changed(self, _: TextArea.Changed) -> None:
695
+ self.refresh_status()
696
+
697
+ def on_text_area_selection_changed(self, _: TextArea.SelectionChanged) -> None:
698
+ self.refresh_status()
699
+
700
+ @property
701
+ def clipboard(self) -> str:
702
+ try:
703
+ return pyperclip.paste()
704
+ except pyperclip.PyperclipException:
705
+ return self._clipboard_text
706
+
707
+ def copy_to_clipboard(self, text: str) -> None:
708
+ self._clipboard_text = text
709
+ try:
710
+ pyperclip.copy(text)
711
+ except pyperclip.PyperclipException:
712
+ try:
713
+ super().copy_to_clipboard(text)
714
+ except Exception:
715
+ pass
716
+ self.status_message = "Clipboard unavailable; kept a local copy"
717
+ else:
718
+ self.status_message = "Copied to system clipboard"
719
+ self.refresh_status()
720
+
721
+ def active_pane(self) -> BufferTab | None:
722
+ buffers = self._buffers
723
+ if buffers is None:
724
+ return None
725
+ active = buffers.active_pane
726
+ return active if isinstance(active, BufferTab) else None
727
+
728
+ def active_editor(self) -> JimTextArea | None:
729
+ pane = self.active_pane()
730
+ return pane.editor if pane is not None else None
731
+
732
+ def focus_active_editor(self) -> None:
733
+ editor = self.active_editor()
734
+ if editor is not None:
735
+ editor.focus()
736
+
737
+ def buffer_label(self, path: Path | None) -> str:
738
+ if path is None:
739
+ return f"Untitled {self._buffer_serial + 1}"
740
+ return path.name
741
+
742
+ def open_buffer(self, path: Path | None) -> None:
743
+ buffers = self._buffers
744
+ if buffers is None:
745
+ return
746
+
747
+ if path is not None:
748
+ for pane in self.query(BufferTab):
749
+ if pane.file_path == path:
750
+ buffers.active = pane.id or ""
751
+ self.status_message = f"Focused {path}"
752
+ self.focus_active_editor()
753
+ return
754
+
755
+ if path is None:
756
+ text = ""
757
+ newline = "\n"
758
+ language = "python"
759
+ resolved_path = None
760
+ else:
761
+ try:
762
+ text, newline = load_document(path)
763
+ except OSError as error:
764
+ self.status_message = f"Cannot open: {error}"
765
+ self.refresh_status()
766
+ return
767
+ language = language_for_path(path)
768
+ resolved_path = path
769
+
770
+ pane_id = f"buffer-{self._buffer_serial}"
771
+ self._buffer_serial += 1
772
+ pane = BufferTab(
773
+ title=self.buffer_label(path),
774
+ text=text,
775
+ file_path=resolved_path,
776
+ newline=newline,
777
+ language=language,
778
+ pane_id=pane_id,
779
+ )
780
+ buffers.add_pane(pane)
781
+ buffers.active = pane_id
782
+ self.status_message = f"Opened {path}" if path is not None else "New buffer"
783
+ self.focus_active_editor()
784
+
785
+ def close_active_buffer(self) -> None:
786
+ buffers = self._buffers
787
+ pane = self.active_pane()
788
+ if buffers is None or pane is None:
789
+ return
790
+ if buffers.tab_count <= 1:
791
+ self.status_message = "At least one buffer must remain open"
792
+ self.refresh_status()
793
+ return
794
+
795
+ next_panes = [candidate for candidate in self.query(BufferTab) if candidate is not pane]
796
+ next_active = next_panes[0] if next_panes else None
797
+ pane.remove()
798
+ if next_active is not None:
799
+ buffers.active = next_active.id or ""
800
+ self.focus_active_editor()
801
+ self.status_message = "Closed buffer"
802
+
803
+ def toggle_mark_mode(self) -> None:
804
+ self.mark_mode = not self.mark_mode
805
+ self.status_message = "Mark mode on" if self.mark_mode else "Mark mode off"
806
+ self.refresh_status()
807
+
808
+ def action_show_help(self) -> None:
809
+ self.push_screen(HelpDialog())
810
+
811
+ def action_search(self) -> None:
812
+ self.push_screen(SearchDialog(), self._apply_search)
813
+
814
+ def action_search_next(self) -> None:
815
+ editor = self.active_editor()
816
+ if editor is None or not editor._search_query:
817
+ self.status_message = "No previous search to repeat"
818
+ self.refresh_status()
819
+ return
820
+
821
+ self._jump_to_search_match(forward=True)
822
+
823
+ def action_search_previous(self) -> None:
824
+ editor = self.active_editor()
825
+ if editor is None or not editor._search_query:
826
+ self.status_message = "No previous search to repeat"
827
+ self.refresh_status()
828
+ return
829
+
830
+ self._jump_to_search_match(forward=False)
831
+
832
+ def action_goto_line(self) -> None:
833
+ self.push_screen(GotoLineDialog(), self._apply_goto_line)
834
+
835
+ def action_new_buffer(self) -> None:
836
+ self.open_buffer(None)
837
+ self.refresh_status()
838
+
839
+ def action_open_file(self) -> None:
840
+ self.push_screen(FileBrowserDialog(Path.cwd()), self._apply_open_file)
841
+
842
+ def action_close_buffer(self) -> None:
843
+ pane = self.active_pane()
844
+ editor = self.active_editor()
845
+ if pane is not None and editor is not None and editor.text != pane.saved_text:
846
+ self.push_screen(
847
+ ConfirmDialog("This buffer has unsaved changes. Close anyway?"),
848
+ self._apply_close_confirm,
849
+ )
850
+ else:
851
+ self.close_active_buffer()
852
+ self.refresh_status()
853
+
854
+ def _apply_close_confirm(self, confirmed: bool) -> None:
855
+ if confirmed:
856
+ self.close_active_buffer()
857
+ self.refresh_status()
858
+
859
+ def action_safe_quit(self) -> None:
860
+ dirty = [p for p in self.query(BufferTab) if p.editor.text != p.saved_text]
861
+ if dirty:
862
+ count = len(dirty)
863
+ noun = "buffer" if count == 1 else "buffers"
864
+ self.push_screen(
865
+ ConfirmDialog(f"{count} {noun} with unsaved changes. Quit anyway?"),
866
+ self._apply_quit_confirm,
867
+ )
868
+ else:
869
+ self.exit()
870
+
871
+ def _apply_quit_confirm(self, confirmed: bool) -> None:
872
+ if confirmed:
873
+ self.exit()
874
+
875
+ def action_next_buffer(self) -> None:
876
+ self._cycle_buffer(1)
877
+
878
+ def action_previous_buffer(self) -> None:
879
+ self._cycle_buffer(-1)
880
+
881
+ def action_save_file(self) -> None:
882
+ pane = self.active_pane()
883
+ editor = self.active_editor()
884
+ if pane is None or editor is None:
885
+ return
886
+ if pane.file_path is None:
887
+ self.push_screen(
888
+ PathDialog("Save as", "Enter file path", confirm_label="Save"),
889
+ self._apply_save_as,
890
+ )
891
+ return
892
+ self._do_save(pane, editor)
893
+
894
+ def _do_save(self, pane: BufferTab, editor: JimTextArea) -> None:
895
+ try:
896
+ save_document(pane.file_path, editor.text, pane.newline)
897
+ except OSError as error:
898
+ self.status_message = f"Save failed: {error}"
899
+ else:
900
+ pane.saved_text = editor.text
901
+ self.status_message = f"Saved {pane.file_path}"
902
+ self.refresh_status()
903
+
904
+ def _apply_save_as(self, value: str | None) -> None:
905
+ if value is None:
906
+ self.status_message = "Save cancelled"
907
+ self.refresh_status()
908
+ return
909
+ pane = self.active_pane()
910
+ editor = self.active_editor()
911
+ if pane is None or editor is None:
912
+ return
913
+ path = Path(value).expanduser()
914
+ try:
915
+ save_document(path, editor.text, pane.newline)
916
+ except OSError as error:
917
+ self.status_message = f"Save failed: {error}"
918
+ else:
919
+ pane.file_path = path
920
+ pane.saved_text = editor.text
921
+ self.status_message = f"Saved {path}"
922
+ self.refresh_status()
923
+
924
+ def _jump_to_search_match(self, *, forward: bool) -> None:
925
+ editor = self.active_editor()
926
+ if editor is None or not editor._search_query:
927
+ return
928
+
929
+ matches = find_search_matches(editor.text, editor._search_query, regex=editor._search_regex)
930
+ if not matches:
931
+ self.status_message = f"No match for {editor._search_query!r}"
932
+ self.refresh_status()
933
+ return
934
+
935
+ current_location = editor.cursor_location
936
+ match = (
937
+ find_next_search_match(editor.text, matches, current_location)
938
+ if forward
939
+ else find_previous_search_match(editor.text, matches, current_location)
940
+ )
941
+ if match is None:
942
+ self.status_message = f"No match for {editor._search_query!r}"
943
+ self.refresh_status()
944
+ return
945
+
946
+ editor.move_cursor(match.start)
947
+ editor.move_cursor(match.end, select=True)
948
+ self.status_message = f"Found {editor._search_query!r}"
949
+ self.refresh_status()
950
+
951
+ def _apply_search(self, result: SearchRequest | None) -> None:
952
+ if result is None:
953
+ self.status_message = "Search cancelled"
954
+ self.refresh_status()
955
+ return
956
+ pane = self.active_pane()
957
+ editor = self.active_editor()
958
+ if pane is None:
959
+ return
960
+ if editor is not None:
961
+ editor.set_search(result.query, result.regex)
962
+ self._run_search(result.query, result.regex)
963
+
964
+ def _run_search(self, query: str, regex: bool) -> None:
965
+ editor = self.active_editor()
966
+ if editor is None:
967
+ return
968
+
969
+ try:
970
+ match = search_document(
971
+ editor.text,
972
+ query,
973
+ regex=regex,
974
+ start_location=editor.cursor_location,
975
+ )
976
+ except re.error as error:
977
+ self.status_message = f"Invalid regex: {error}"
978
+ self.refresh_status()
979
+ return
980
+
981
+ if match is None:
982
+ self.status_message = f"No match for {query!r}"
983
+ self.refresh_status()
984
+ return
985
+
986
+ editor.move_cursor(match.start)
987
+ editor.move_cursor(match.end, select=True)
988
+ self.status_message = f"Found {query!r}"
989
+ self.refresh_status()
990
+
991
+ def _apply_goto_line(self, line_number: int | None) -> None:
992
+ if line_number is None:
993
+ self.status_message = "Goto cancelled"
994
+ self.refresh_status()
995
+ return
996
+ editor = self.active_editor()
997
+ if editor is None:
998
+ return
999
+ editor.move_cursor(goto_line_location(editor.text, line_number))
1000
+ self.status_message = f"Moved to line {line_number}"
1001
+ self.refresh_status()
1002
+
1003
+ def _apply_open_file(self, value: Path | None) -> None:
1004
+ if value is None:
1005
+ self.status_message = "Open cancelled"
1006
+ self.refresh_status()
1007
+ return
1008
+ self.open_buffer(value.expanduser())
1009
+ self.refresh_status()
1010
+
1011
+ def _cycle_buffer(self, step: int) -> None:
1012
+ panes = list(self.query(BufferTab))
1013
+ if len(panes) <= 1:
1014
+ return
1015
+ active = self.active_pane()
1016
+ if active is None:
1017
+ return
1018
+ index = panes.index(active)
1019
+ next_pane = panes[(index + step) % len(panes)]
1020
+ buffers = self._buffers
1021
+ if buffers is not None:
1022
+ buffers.active = next_pane.id or ""
1023
+ self.focus_active_editor()
1024
+ self.refresh_status()
1025
+
1026
+ def refresh_status(self) -> None:
1027
+ status = self._status
1028
+ if status is not None:
1029
+ status.update(self.build_status_line())
1030
+
1031
+ def build_status_line(self) -> str:
1032
+ pane = self.active_pane()
1033
+ editor = self.active_editor()
1034
+ if pane is None or editor is None:
1035
+ return f"{self.status_message} | Help F1"
1036
+
1037
+ row, column = editor.cursor_location
1038
+ selection_text = editor.selected_text
1039
+ selection_state = "on" if self.mark_mode else "off"
1040
+ selection_label = f"{len(selection_text)} chars" if selection_text else "none"
1041
+ dirty_state = "modified" if editor.text != pane.saved_text else "saved"
1042
+ newline_label = {"\n": "LF", "\r\n": "CRLF", "\r": "CR"}.get(pane.newline, "LF")
1043
+ file_label = str(pane.file_path) if pane.file_path is not None else "Untitled"
1044
+ return (
1045
+ f"{file_label} | Ln {row + 1}, Col {column + 1} | "
1046
+ f"mark {selection_state} | sel {selection_label} | {dirty_state} | {newline_label} | "
1047
+ f"{self.status_message} | Help F1"
1048
+ )
1049
+
1050
+
1051
+ def main(argv: list[str] | None = None) -> int:
1052
+ parser = argparse.ArgumentParser(prog="jim", description="A small terminal text editor")
1053
+ parser.add_argument("path", nargs="?", type=Path, help="file to edit")
1054
+ parsed_args = parser.parse_args(argv)
1055
+
1056
+ JimApp(parsed_args.path).run()
1057
+ return 0
@@ -0,0 +1,22 @@
1
+ Metadata-Version: 2.4
2
+ Name: jim_editor
3
+ Version: 0.1.0
4
+ Summary: A fast, simple terminal text editor
5
+ Requires-Python: >=3.14
6
+ Requires-Dist: pyperclip>=1.9
7
+ Requires-Dist: textual>=8.2
8
+ Requires-Dist: tree-sitter>=0.23
9
+ Requires-Dist: tree-sitter-python
10
+ Requires-Dist: tree-sitter-javascript
11
+ Requires-Dist: tree-sitter-yaml
12
+ Requires-Dist: tree-sitter-html
13
+ Requires-Dist: tree-sitter-go
14
+ Requires-Dist: tree-sitter-rust
15
+ Requires-Dist: tree-sitter-json
16
+ Requires-Dist: tree-sitter-toml
17
+ Requires-Dist: tree-sitter-bash
18
+ Requires-Dist: tree-sitter-xml
19
+ Requires-Dist: tree-sitter-css
20
+ Requires-Dist: tree-sitter-java
21
+ Requires-Dist: tree-sitter-markdown
22
+ Requires-Dist: tree-sitter-sql
@@ -0,0 +1,11 @@
1
+ pyproject.toml
2
+ src/jim_editor/__init__.py
3
+ src/jim_editor/__main__.py
4
+ src/jim_editor/app.py
5
+ src/jim_editor.egg-info/PKG-INFO
6
+ src/jim_editor.egg-info/SOURCES.txt
7
+ src/jim_editor.egg-info/dependency_links.txt
8
+ src/jim_editor.egg-info/entry_points.txt
9
+ src/jim_editor.egg-info/requires.txt
10
+ src/jim_editor.egg-info/top_level.txt
11
+ tests/test_app.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ jim_editor = jim_editor.__main__:main
@@ -0,0 +1,17 @@
1
+ pyperclip>=1.9
2
+ textual>=8.2
3
+ tree-sitter>=0.23
4
+ tree-sitter-python
5
+ tree-sitter-javascript
6
+ tree-sitter-yaml
7
+ tree-sitter-html
8
+ tree-sitter-go
9
+ tree-sitter-rust
10
+ tree-sitter-json
11
+ tree-sitter-toml
12
+ tree-sitter-bash
13
+ tree-sitter-xml
14
+ tree-sitter-css
15
+ tree-sitter-java
16
+ tree-sitter-markdown
17
+ tree-sitter-sql
@@ -0,0 +1 @@
1
+ jim_editor
@@ -0,0 +1,108 @@
1
+ from pathlib import Path
2
+
3
+ from jim_editor.app import (
4
+ detect_newline,
5
+ end_location,
6
+ find_next_search_match,
7
+ find_previous_search_match,
8
+ find_search_matches,
9
+ format_shortcuts,
10
+ goto_line_location,
11
+ load_document,
12
+ normalize_newlines,
13
+ save_document,
14
+ search_document,
15
+ )
16
+
17
+
18
+ def test_end_location_handles_empty_and_trailing_newline() -> None:
19
+ assert end_location("") == (0, 0)
20
+ assert end_location("hello") == (0, 5)
21
+ assert end_location("hello\n") == (1, 0)
22
+
23
+
24
+ def test_newline_helpers_normalize_and_detect() -> None:
25
+ assert normalize_newlines("a\r\nb\rc") == "a\nb\nc"
26
+ assert detect_newline("a\r\nb") == "\r\n"
27
+ assert detect_newline("a\rb") == "\r"
28
+ assert detect_newline("a\nb") == "\n"
29
+
30
+
31
+ def test_document_round_trip_preserves_newline_style(tmp_path: Path) -> None:
32
+ path = tmp_path / "sample.txt"
33
+ path.write_bytes(b"line1\r\nline2\r\n")
34
+
35
+ text, newline = load_document(path)
36
+ assert text == "line1\nline2\n"
37
+ assert newline == "\r\n"
38
+
39
+ save_document(path, text, newline)
40
+ assert path.read_bytes() == b"line1\r\nline2\r\n"
41
+
42
+
43
+ def test_format_shortcuts_includes_required_actions() -> None:
44
+ cheatsheet = format_shortcuts()
45
+ required = [
46
+ "Ctrl+S",
47
+ "Ctrl+Q",
48
+ "Ctrl+F",
49
+ "Ctrl+L",
50
+ "Ctrl+O",
51
+ "Ctrl+N",
52
+ "Ctrl+B",
53
+ "Ctrl+Home",
54
+ "Ctrl+End",
55
+ "Ctrl+R",
56
+ "Ctrl+PgDn",
57
+ "Ctrl+PgUp",
58
+ ]
59
+ for label in required:
60
+ assert label in cheatsheet
61
+
62
+ non_empty_lines = [line for line in cheatsheet.splitlines() if line.strip()]
63
+ assert len(non_empty_lines) == len(set(non_empty_lines))
64
+
65
+
66
+ def test_language_for_path_maps_major_languages() -> None:
67
+ from jim_editor.app import language_for_path
68
+
69
+ assert language_for_path(Path("demo.py")) == "python"
70
+ assert language_for_path(Path("demo.js")) == "javascript"
71
+ assert language_for_path(Path("demo.ts")) == "javascript"
72
+ assert language_for_path(Path("demo.tsx")) == "javascript"
73
+ assert language_for_path(Path("demo.jsx")) == "javascript"
74
+ assert language_for_path(Path("demo.yaml")) == "yaml"
75
+ assert language_for_path(Path("demo.html")) == "html"
76
+ assert language_for_path(Path("demo.go")) == "go"
77
+ assert language_for_path(None) == "python"
78
+ assert language_for_path(Path("demo.unknown")) is None
79
+
80
+
81
+ def test_search_document_supports_plain_and_regex() -> None:
82
+ plain = search_document("alpha beta alpha", "beta")
83
+ assert plain is not None
84
+ assert plain.start == (0, 6)
85
+ assert plain.end == (0, 10)
86
+
87
+ regex = search_document("value=12\nvalue=99", r"value=\d+", regex=True, start_location=(1, 0))
88
+ assert regex is not None
89
+ assert regex.start == (1, 0)
90
+ assert regex.end == (1, 8)
91
+
92
+
93
+ def test_find_search_matches_supports_forward_and_reverse_navigation() -> None:
94
+ text = "alpha beta alpha beta"
95
+ matches = find_search_matches(text, "beta")
96
+ assert len(matches) == 2
97
+
98
+ assert find_next_search_match(text, matches, (0, 0)) == matches[0]
99
+ assert find_next_search_match(text, matches, (0, 12)) == matches[1]
100
+ assert find_previous_search_match(text, matches, (0, 12)) == matches[0]
101
+ assert find_previous_search_match(text, matches, (0, 0)) == matches[-1]
102
+
103
+
104
+ def test_goto_line_location_clamps_to_file_bounds() -> None:
105
+ text = "one\ntwo\nthree"
106
+ assert goto_line_location(text, 1) == (0, 0)
107
+ assert goto_line_location(text, 2) == (1, 0)
108
+ assert goto_line_location(text, 99) == (2, 5)