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.
- jim_editor-0.1.0/PKG-INFO +22 -0
- jim_editor-0.1.0/pyproject.toml +55 -0
- jim_editor-0.1.0/setup.cfg +4 -0
- jim_editor-0.1.0/src/jim_editor/__init__.py +5 -0
- jim_editor-0.1.0/src/jim_editor/__main__.py +4 -0
- jim_editor-0.1.0/src/jim_editor/app.py +1057 -0
- jim_editor-0.1.0/src/jim_editor.egg-info/PKG-INFO +22 -0
- jim_editor-0.1.0/src/jim_editor.egg-info/SOURCES.txt +11 -0
- jim_editor-0.1.0/src/jim_editor.egg-info/dependency_links.txt +1 -0
- jim_editor-0.1.0/src/jim_editor.egg-info/entry_points.txt +2 -0
- jim_editor-0.1.0/src/jim_editor.egg-info/requires.txt +17 -0
- jim_editor-0.1.0/src/jim_editor.egg-info/top_level.txt +1 -0
- jim_editor-0.1.0/tests/test_app.py +108 -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,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,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 @@
|
|
|
1
|
+
|
|
@@ -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)
|