notispf 1.0.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.
- notispf/__init__.py +1 -0
- notispf/__main__.py +14 -0
- notispf/app.py +621 -0
- notispf/buffer.py +173 -0
- notispf/commands/__init__.py +4 -0
- notispf/commands/block_cmds.py +51 -0
- notispf/commands/exclude_cmds.py +38 -0
- notispf/commands/line_cmds.py +88 -0
- notispf/commands/registry.py +66 -0
- notispf/display.py +401 -0
- notispf/find_change.py +186 -0
- notispf/prefix.py +102 -0
- notispf-1.0.0.dist-info/METADATA +229 -0
- notispf-1.0.0.dist-info/RECORD +17 -0
- notispf-1.0.0.dist-info/WHEEL +5 -0
- notispf-1.0.0.dist-info/entry_points.txt +2 -0
- notispf-1.0.0.dist-info/top_level.txt +1 -0
notispf/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "1.0.0"
|
notispf/__main__.py
ADDED
notispf/app.py
ADDED
|
@@ -0,0 +1,621 @@
|
|
|
1
|
+
"""Top-level application controller."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import curses
|
|
4
|
+
import os
|
|
5
|
+
|
|
6
|
+
from notispf.buffer import Buffer
|
|
7
|
+
from notispf.commands.registry import CommandRegistry
|
|
8
|
+
from notispf.commands import line_cmds, block_cmds, exclude_cmds
|
|
9
|
+
from notispf.display import Display, ViewState, TEXT_OFFSET
|
|
10
|
+
from notispf.find_change import FindChangeEngine
|
|
11
|
+
from notispf.prefix import PrefixArea
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _build_registry() -> CommandRegistry:
|
|
15
|
+
r = CommandRegistry()
|
|
16
|
+
line_cmds.register(r)
|
|
17
|
+
block_cmds.register(r)
|
|
18
|
+
exclude_cmds.register(r)
|
|
19
|
+
return r
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class App:
|
|
23
|
+
def __init__(self, filepath: str):
|
|
24
|
+
self.filepath = filepath
|
|
25
|
+
self.registry = _build_registry()
|
|
26
|
+
self.buffer = Buffer(filepath) if os.path.exists(filepath) \
|
|
27
|
+
else self._new_buffer(filepath)
|
|
28
|
+
self.prefix_area = PrefixArea(self.buffer, self.registry)
|
|
29
|
+
self.find_engine = FindChangeEngine(self.buffer)
|
|
30
|
+
self.vs = ViewState(
|
|
31
|
+
top_line=0,
|
|
32
|
+
cursor_line=0,
|
|
33
|
+
cursor_col=0,
|
|
34
|
+
screen_rows=24,
|
|
35
|
+
screen_cols=80,
|
|
36
|
+
)
|
|
37
|
+
self._quit_flag = False
|
|
38
|
+
|
|
39
|
+
def _new_buffer(self, filepath: str) -> Buffer:
|
|
40
|
+
buf = Buffer()
|
|
41
|
+
buf.filepath = filepath
|
|
42
|
+
buf.lines = []
|
|
43
|
+
return buf
|
|
44
|
+
|
|
45
|
+
def run(self) -> None:
|
|
46
|
+
curses.wrapper(self._main)
|
|
47
|
+
|
|
48
|
+
def _main(self, stdscr) -> None:
|
|
49
|
+
self.display = Display(stdscr)
|
|
50
|
+
self._render()
|
|
51
|
+
|
|
52
|
+
while True:
|
|
53
|
+
key = stdscr.getch()
|
|
54
|
+
if self._handle_key(key) or self._quit_flag:
|
|
55
|
+
break
|
|
56
|
+
self._render()
|
|
57
|
+
|
|
58
|
+
def _render(self) -> None:
|
|
59
|
+
rows, cols = self.display.stdscr.getmaxyx()
|
|
60
|
+
self.vs.screen_rows = rows
|
|
61
|
+
self.vs.screen_cols = cols
|
|
62
|
+
self.vs.pending_prefixes = dict(self.prefix_area._pending)
|
|
63
|
+
# Overlay the live input for the current line while the user is typing
|
|
64
|
+
if self.vs.prefix_mode:
|
|
65
|
+
if self.vs.prefix_input:
|
|
66
|
+
self.vs.pending_prefixes[self.vs.cursor_line] = self.vs.prefix_input
|
|
67
|
+
else:
|
|
68
|
+
self.vs.pending_prefixes.pop(self.vs.cursor_line, None)
|
|
69
|
+
if self.prefix_area._open_block:
|
|
70
|
+
self.vs.open_block_line = self.prefix_area._open_block.line_idx
|
|
71
|
+
self.vs.open_block_cmd = self.prefix_area._open_block.cmd_name
|
|
72
|
+
else:
|
|
73
|
+
self.vs.open_block_line = None
|
|
74
|
+
self.vs.open_block_cmd = ""
|
|
75
|
+
self.display.render(self.buffer, self.prefix_area, self.vs)
|
|
76
|
+
|
|
77
|
+
def _handle_key(self, key: int) -> bool:
|
|
78
|
+
"""Return True to quit."""
|
|
79
|
+
vs = self.vs
|
|
80
|
+
|
|
81
|
+
if vs.command_mode:
|
|
82
|
+
return self._handle_command_key(key)
|
|
83
|
+
|
|
84
|
+
if vs.help_mode:
|
|
85
|
+
return self._handle_help_key(key)
|
|
86
|
+
|
|
87
|
+
if vs.prefix_mode:
|
|
88
|
+
return self._handle_prefix_key(key)
|
|
89
|
+
|
|
90
|
+
rows, _ = self.display.stdscr.getmaxyx()
|
|
91
|
+
content_rows = rows - 2 - (1 if vs.show_cols else 0)
|
|
92
|
+
|
|
93
|
+
# Navigation
|
|
94
|
+
if key == curses.KEY_UP:
|
|
95
|
+
self._move_cursor(-1)
|
|
96
|
+
elif key == curses.KEY_DOWN:
|
|
97
|
+
self._move_cursor(1)
|
|
98
|
+
elif key in (curses.KEY_PPAGE, curses.KEY_F7): # Page Up
|
|
99
|
+
self._move_cursor(-content_rows)
|
|
100
|
+
elif key in (curses.KEY_NPAGE, curses.KEY_F8): # Page Down
|
|
101
|
+
self._move_cursor(content_rows)
|
|
102
|
+
elif key == curses.KEY_F10: # Scroll left
|
|
103
|
+
vs.col_offset = max(0, vs.col_offset - (vs.screen_cols - TEXT_OFFSET))
|
|
104
|
+
elif key == curses.KEY_F11: # Scroll right
|
|
105
|
+
vs.col_offset += vs.screen_cols - TEXT_OFFSET
|
|
106
|
+
elif key == curses.KEY_HOME or key == ord('\x01'): # Home / Ctrl-A
|
|
107
|
+
vs.cursor_col = 0
|
|
108
|
+
self._scroll_col_to_cursor()
|
|
109
|
+
elif key == curses.KEY_END or key == ord('\x05'): # End / Ctrl-E
|
|
110
|
+
if self.buffer.lines:
|
|
111
|
+
vs.cursor_col = len(self.buffer.lines[vs.cursor_line].text)
|
|
112
|
+
self._scroll_col_to_cursor()
|
|
113
|
+
elif key == curses.KEY_LEFT:
|
|
114
|
+
vs.cursor_col = max(0, vs.cursor_col - 1)
|
|
115
|
+
self._scroll_col_to_cursor()
|
|
116
|
+
elif key == curses.KEY_RIGHT:
|
|
117
|
+
if self.buffer.lines:
|
|
118
|
+
vs.cursor_col = min(
|
|
119
|
+
len(self.buffer.lines[vs.cursor_line].text),
|
|
120
|
+
vs.cursor_col + 1)
|
|
121
|
+
self._scroll_col_to_cursor()
|
|
122
|
+
|
|
123
|
+
# Enter command mode
|
|
124
|
+
elif key == ord('=') or key == curses.KEY_F6:
|
|
125
|
+
vs.command_mode = True
|
|
126
|
+
vs.command_input = ""
|
|
127
|
+
vs.message = ""
|
|
128
|
+
|
|
129
|
+
# F3 = FILE (save and quit)
|
|
130
|
+
elif key == curses.KEY_F3:
|
|
131
|
+
self._save_and_quit()
|
|
132
|
+
return True
|
|
133
|
+
|
|
134
|
+
# F12 = CANCEL (quit without saving)
|
|
135
|
+
elif key == curses.KEY_F12:
|
|
136
|
+
return True
|
|
137
|
+
|
|
138
|
+
# F5 = save without quit
|
|
139
|
+
elif key == curses.KEY_F5:
|
|
140
|
+
try:
|
|
141
|
+
self.buffer.save_file()
|
|
142
|
+
vs.message = f"File saved: {self.buffer.filepath}"
|
|
143
|
+
except Exception as e:
|
|
144
|
+
vs.message = f"Save error: {e}"
|
|
145
|
+
|
|
146
|
+
# Tab: prefix(N) -> text(N), text(N) -> prefix(N+1)
|
|
147
|
+
elif key == ord('\t'):
|
|
148
|
+
if vs.prefix_mode:
|
|
149
|
+
# Leave prefix area, stay on same line, go to text
|
|
150
|
+
self._stage_current_prefix()
|
|
151
|
+
vs.prefix_mode = False
|
|
152
|
+
vs.prefix_input = ""
|
|
153
|
+
vs.message = ""
|
|
154
|
+
else:
|
|
155
|
+
# Advance to next line and enter its prefix area
|
|
156
|
+
self._move_cursor(1, skip_excluded=False)
|
|
157
|
+
vs.prefix_mode = True
|
|
158
|
+
vs.prefix_input = self.prefix_area._pending.get(vs.cursor_line, "")
|
|
159
|
+
vs.message = "Type prefix command, Enter to execute, Esc to cancel"
|
|
160
|
+
|
|
161
|
+
# Shift+Tab: text(N) -> prefix(N), prefix(N) -> text(N-1)
|
|
162
|
+
elif key == curses.KEY_BTAB:
|
|
163
|
+
if vs.prefix_mode:
|
|
164
|
+
# Leave prefix area, go to previous line's text area
|
|
165
|
+
self._stage_current_prefix()
|
|
166
|
+
vs.prefix_mode = False
|
|
167
|
+
vs.prefix_input = ""
|
|
168
|
+
vs.message = ""
|
|
169
|
+
self._move_cursor(-1, skip_excluded=False)
|
|
170
|
+
else:
|
|
171
|
+
# Enter prefix area of current line
|
|
172
|
+
vs.prefix_mode = True
|
|
173
|
+
vs.prefix_input = self.prefix_area._pending.get(vs.cursor_line, "")
|
|
174
|
+
vs.message = "Type prefix command, Enter to execute, Esc to cancel"
|
|
175
|
+
|
|
176
|
+
# Text editing (Phase 6 — placeholder)
|
|
177
|
+
elif key == curses.KEY_BACKSPACE or key == 127:
|
|
178
|
+
self._backspace()
|
|
179
|
+
elif key == curses.KEY_DC:
|
|
180
|
+
self._delete_char()
|
|
181
|
+
elif key in (curses.KEY_ENTER, ord('\n'), ord('\r')):
|
|
182
|
+
self._enter_key()
|
|
183
|
+
elif 32 <= key <= 126:
|
|
184
|
+
self._insert_char(chr(key))
|
|
185
|
+
|
|
186
|
+
return False
|
|
187
|
+
|
|
188
|
+
def _handle_help_key(self, key: int) -> bool:
|
|
189
|
+
vs = self.vs
|
|
190
|
+
rows, _ = self.display.stdscr.getmaxyx()
|
|
191
|
+
content_rows = rows - 2
|
|
192
|
+
max_scroll = max(0, len(self.display._HELP_LINES) - content_rows)
|
|
193
|
+
if key in (curses.KEY_DOWN, curses.KEY_NPAGE, curses.KEY_F8):
|
|
194
|
+
vs.help_scroll = min(vs.help_scroll + (content_rows if key != curses.KEY_DOWN else 1), max_scroll)
|
|
195
|
+
elif key in (curses.KEY_UP, curses.KEY_PPAGE, curses.KEY_F7):
|
|
196
|
+
vs.help_scroll = max(vs.help_scroll - (content_rows if key != curses.KEY_UP else 1), 0)
|
|
197
|
+
else:
|
|
198
|
+
vs.help_mode = False
|
|
199
|
+
vs.help_scroll = 0
|
|
200
|
+
return False
|
|
201
|
+
|
|
202
|
+
def _handle_command_key(self, key: int) -> bool:
|
|
203
|
+
vs = self.vs
|
|
204
|
+
if key in (curses.KEY_ENTER, ord('\n'), ord('\r')):
|
|
205
|
+
result_msg = self._execute_command(vs.command_input.strip())
|
|
206
|
+
vs.command_mode = False
|
|
207
|
+
vs.command_input = ""
|
|
208
|
+
vs.message = result_msg
|
|
209
|
+
elif key == 27: # Escape
|
|
210
|
+
vs.command_mode = False
|
|
211
|
+
vs.command_input = ""
|
|
212
|
+
vs.message = ""
|
|
213
|
+
elif key == curses.KEY_BACKSPACE or key == 127:
|
|
214
|
+
vs.command_input = vs.command_input[:-1]
|
|
215
|
+
elif 32 <= key <= 126:
|
|
216
|
+
vs.command_input += chr(key)
|
|
217
|
+
elif key == curses.KEY_F3:
|
|
218
|
+
vs.command_mode = False
|
|
219
|
+
self._save_and_quit()
|
|
220
|
+
return True
|
|
221
|
+
return False
|
|
222
|
+
|
|
223
|
+
def _execute_command(self, raw: str) -> str:
|
|
224
|
+
if not raw:
|
|
225
|
+
return ""
|
|
226
|
+
import shlex
|
|
227
|
+
try:
|
|
228
|
+
tokens = shlex.split(raw.upper())
|
|
229
|
+
except ValueError:
|
|
230
|
+
return f"Parse error: {raw}"
|
|
231
|
+
|
|
232
|
+
cmd = tokens[0] if tokens else ""
|
|
233
|
+
|
|
234
|
+
if cmd == "HELP":
|
|
235
|
+
self.vs.help_mode = True
|
|
236
|
+
self.vs.help_scroll = 0
|
|
237
|
+
return ""
|
|
238
|
+
|
|
239
|
+
if cmd == "CLEAR":
|
|
240
|
+
self.vs.highlight_pattern = ""
|
|
241
|
+
return "Highlighting cleared"
|
|
242
|
+
|
|
243
|
+
if cmd == "COLS":
|
|
244
|
+
self.vs.show_cols = not self.vs.show_cols
|
|
245
|
+
return "Column ruler on" if self.vs.show_cols else "Column ruler off"
|
|
246
|
+
|
|
247
|
+
if cmd in ("CANCEL", "QUIT"):
|
|
248
|
+
self._quit_flag = True
|
|
249
|
+
return ""
|
|
250
|
+
|
|
251
|
+
if cmd == "SHOW" and "ALL" in tokens:
|
|
252
|
+
self.buffer.show_all()
|
|
253
|
+
return "All lines shown"
|
|
254
|
+
|
|
255
|
+
if cmd == "COPY":
|
|
256
|
+
try:
|
|
257
|
+
orig_tokens = shlex.split(raw)
|
|
258
|
+
except ValueError:
|
|
259
|
+
return f"Parse error: {raw}"
|
|
260
|
+
if len(orig_tokens) < 2:
|
|
261
|
+
return "Usage: COPY filename"
|
|
262
|
+
dest = orig_tokens[1]
|
|
263
|
+
try:
|
|
264
|
+
saved_filepath = self.buffer.filepath
|
|
265
|
+
saved_modified = self.buffer.modified
|
|
266
|
+
self.buffer.save_file(dest)
|
|
267
|
+
self.buffer.filepath = saved_filepath
|
|
268
|
+
self.buffer.modified = saved_modified
|
|
269
|
+
return f"Copied to: {dest}"
|
|
270
|
+
except Exception as e:
|
|
271
|
+
return f"Copy error: {e}"
|
|
272
|
+
|
|
273
|
+
if cmd in ("SAVE", "FILE"):
|
|
274
|
+
try:
|
|
275
|
+
self.buffer.save_file()
|
|
276
|
+
if cmd == "FILE":
|
|
277
|
+
self._quit_flag = True
|
|
278
|
+
return f"Saved: {self.buffer.filepath}"
|
|
279
|
+
except Exception as e:
|
|
280
|
+
return f"Save error: {e}"
|
|
281
|
+
|
|
282
|
+
if cmd == "EXCLUDE":
|
|
283
|
+
try:
|
|
284
|
+
orig_tokens = shlex.split(raw)
|
|
285
|
+
except ValueError:
|
|
286
|
+
return f"Parse error: {raw}"
|
|
287
|
+
if len(orig_tokens) < 2:
|
|
288
|
+
return 'Usage: EXCLUDE "pattern" [ALL | n]'
|
|
289
|
+
pattern = orig_tokens[1]
|
|
290
|
+
rest = orig_tokens[2:]
|
|
291
|
+
if rest:
|
|
292
|
+
if rest[0].upper() == "ALL":
|
|
293
|
+
limit = None
|
|
294
|
+
else:
|
|
295
|
+
try:
|
|
296
|
+
limit = int(rest[0])
|
|
297
|
+
except ValueError:
|
|
298
|
+
return f"Invalid count: {rest[0]}"
|
|
299
|
+
else:
|
|
300
|
+
limit = 1
|
|
301
|
+
n = self.find_engine.exclude_matching(pattern, limit=limit)
|
|
302
|
+
return f"{n} line(s) excluded" if n else f"Not found: {pattern!r}"
|
|
303
|
+
|
|
304
|
+
if cmd == "DELETE":
|
|
305
|
+
try:
|
|
306
|
+
orig_tokens = shlex.split(raw)
|
|
307
|
+
except ValueError:
|
|
308
|
+
return f"Parse error: {raw}"
|
|
309
|
+
if len(orig_tokens) < 2:
|
|
310
|
+
return 'Usage: DELETE "pattern" [ALL | n] | DELETE X ALL | DELETE NX ALL'
|
|
311
|
+
|
|
312
|
+
qualifier = orig_tokens[1].upper()
|
|
313
|
+
|
|
314
|
+
# DELETE X ALL — delete all excluded lines
|
|
315
|
+
if qualifier == "X":
|
|
316
|
+
n = self.find_engine.delete_excluded()
|
|
317
|
+
return f"{n} excluded line(s) deleted" if n else "No excluded lines"
|
|
318
|
+
|
|
319
|
+
# DELETE NX ALL — delete all non-excluded lines
|
|
320
|
+
if qualifier == "NX":
|
|
321
|
+
n = self.find_engine.delete_non_excluded()
|
|
322
|
+
return f"{n} non-excluded line(s) deleted" if n else "No non-excluded lines"
|
|
323
|
+
|
|
324
|
+
# DELETE "pattern" [ALL | n]
|
|
325
|
+
pattern = orig_tokens[1]
|
|
326
|
+
rest = orig_tokens[2:]
|
|
327
|
+
if rest:
|
|
328
|
+
if rest[0].upper() == "ALL":
|
|
329
|
+
limit = None
|
|
330
|
+
else:
|
|
331
|
+
try:
|
|
332
|
+
limit = int(rest[0])
|
|
333
|
+
except ValueError:
|
|
334
|
+
return f"Invalid count: {rest[0]}"
|
|
335
|
+
else:
|
|
336
|
+
limit = 1 # no qualifier — delete next match only
|
|
337
|
+
n = self.find_engine.delete_matching(pattern, limit=limit)
|
|
338
|
+
return f"{n} line(s) deleted" if n else f"Not found: {pattern!r}"
|
|
339
|
+
|
|
340
|
+
if cmd == "FIND":
|
|
341
|
+
if len(tokens) < 2:
|
|
342
|
+
return "Usage: FIND <text> [column]"
|
|
343
|
+
# Re-parse preserving original case
|
|
344
|
+
try:
|
|
345
|
+
orig_tokens = shlex.split(raw)
|
|
346
|
+
except ValueError:
|
|
347
|
+
return f"Parse error: {raw}"
|
|
348
|
+
pattern = orig_tokens[1] if len(orig_tokens) > 1 else ""
|
|
349
|
+
# Optional column number — last token if it's a positive integer
|
|
350
|
+
find_col = None
|
|
351
|
+
if len(orig_tokens) > 2:
|
|
352
|
+
last = orig_tokens[-1]
|
|
353
|
+
if last.isdigit() and int(last) >= 1:
|
|
354
|
+
find_col = int(last)
|
|
355
|
+
pos = self.find_engine.find_next(pattern, col=find_col)
|
|
356
|
+
col_msg = f" at column {find_col}" if find_col else ""
|
|
357
|
+
if pos:
|
|
358
|
+
self.vs.cursor_line, self.vs.cursor_col = pos
|
|
359
|
+
self._scroll_to_cursor()
|
|
360
|
+
self._scroll_col_to_cursor()
|
|
361
|
+
self.vs.highlight_pattern = pattern
|
|
362
|
+
return f"Found: {pattern!r}{col_msg}"
|
|
363
|
+
self.vs.highlight_pattern = ""
|
|
364
|
+
return f"Not found: {pattern!r}{col_msg}"
|
|
365
|
+
|
|
366
|
+
if cmd == "CHANGE":
|
|
367
|
+
try:
|
|
368
|
+
orig_tokens = shlex.split(raw)
|
|
369
|
+
except ValueError:
|
|
370
|
+
return f"Parse error: {raw}"
|
|
371
|
+
if len(orig_tokens) < 3:
|
|
372
|
+
return 'Usage: CHANGE "old" "new" [ALL] [.lbl1 .lbl2] [column]'
|
|
373
|
+
old, new = orig_tokens[1], orig_tokens[2]
|
|
374
|
+
rest = [t.upper() for t in orig_tokens[3:]]
|
|
375
|
+
# Extract optional column number — any token that is a positive integer
|
|
376
|
+
change_col = None
|
|
377
|
+
rest_no_col = []
|
|
378
|
+
for t in rest:
|
|
379
|
+
if t.isdigit() and int(t) >= 1:
|
|
380
|
+
change_col = int(t)
|
|
381
|
+
else:
|
|
382
|
+
rest_no_col.append(t)
|
|
383
|
+
rest = rest_no_col
|
|
384
|
+
try:
|
|
385
|
+
if "ALL" in rest:
|
|
386
|
+
labels = [t for t in rest if t.startswith(".")]
|
|
387
|
+
if len(labels) >= 2:
|
|
388
|
+
n = self.find_engine.change_in_range(
|
|
389
|
+
old, new, labels[0], labels[1], col=change_col)
|
|
390
|
+
else:
|
|
391
|
+
n = self.find_engine.change_all(old, new, col=change_col)
|
|
392
|
+
if n:
|
|
393
|
+
self.vs.highlight_pattern = new
|
|
394
|
+
return f"{n} change(s) made"
|
|
395
|
+
else:
|
|
396
|
+
labels = [t for t in rest if t.startswith(".")]
|
|
397
|
+
if len(labels) >= 2:
|
|
398
|
+
n = self.find_engine.change_in_range(
|
|
399
|
+
old, new, labels[0], labels[1], col=change_col)
|
|
400
|
+
if n:
|
|
401
|
+
self.vs.highlight_pattern = new
|
|
402
|
+
return f"{n} change(s) made"
|
|
403
|
+
n = self.find_engine.change_next(old, new, col=change_col)
|
|
404
|
+
if n:
|
|
405
|
+
self.vs.highlight_pattern = new
|
|
406
|
+
return f"{n} change(s) made" if n else f"Not found: {old!r}"
|
|
407
|
+
except ValueError as e:
|
|
408
|
+
return str(e)
|
|
409
|
+
|
|
410
|
+
return f"Unknown command: {cmd}"
|
|
411
|
+
|
|
412
|
+
def _handle_prefix_key(self, key: int) -> bool:
|
|
413
|
+
vs = self.vs
|
|
414
|
+
|
|
415
|
+
if key in (curses.KEY_ENTER, ord('\n'), ord('\r')):
|
|
416
|
+
# Save current line's input, then execute all staged prefixes
|
|
417
|
+
self._stage_current_prefix()
|
|
418
|
+
vs.prefix_mode = False
|
|
419
|
+
vs.prefix_input = ""
|
|
420
|
+
vs.message = ""
|
|
421
|
+
self._execute_staged_prefixes()
|
|
422
|
+
|
|
423
|
+
elif key == curses.KEY_UP:
|
|
424
|
+
self._stage_current_prefix()
|
|
425
|
+
self._move_cursor(-1, skip_excluded=False)
|
|
426
|
+
vs.prefix_input = self.prefix_area._pending.get(vs.cursor_line, "")
|
|
427
|
+
|
|
428
|
+
elif key == curses.KEY_DOWN:
|
|
429
|
+
self._stage_current_prefix()
|
|
430
|
+
self._move_cursor(1, skip_excluded=False)
|
|
431
|
+
vs.prefix_input = self.prefix_area._pending.get(vs.cursor_line, "")
|
|
432
|
+
|
|
433
|
+
elif key == 27:
|
|
434
|
+
# Escape: cancel prefix mode and clear all staged prefixes
|
|
435
|
+
vs.prefix_mode = False
|
|
436
|
+
vs.prefix_input = ""
|
|
437
|
+
vs.message = ""
|
|
438
|
+
self.prefix_area._pending.clear()
|
|
439
|
+
self.prefix_area.cancel_open_block()
|
|
440
|
+
|
|
441
|
+
elif key == ord('\t'):
|
|
442
|
+
# Tab: prefix(N) -> text(N)
|
|
443
|
+
self._stage_current_prefix()
|
|
444
|
+
vs.prefix_mode = False
|
|
445
|
+
vs.prefix_input = ""
|
|
446
|
+
vs.message = ""
|
|
447
|
+
|
|
448
|
+
elif key == curses.KEY_BTAB:
|
|
449
|
+
# Shift+Tab: prefix(N) -> text(N-1)
|
|
450
|
+
self._stage_current_prefix()
|
|
451
|
+
vs.prefix_mode = False
|
|
452
|
+
vs.prefix_input = ""
|
|
453
|
+
vs.message = ""
|
|
454
|
+
self._move_cursor(-1, skip_excluded=False)
|
|
455
|
+
|
|
456
|
+
elif key == curses.KEY_BACKSPACE or key == 127:
|
|
457
|
+
vs.prefix_input = vs.prefix_input[:-1]
|
|
458
|
+
|
|
459
|
+
elif 32 <= key <= 126 and len(vs.prefix_input) < 6:
|
|
460
|
+
vs.prefix_input += chr(key)
|
|
461
|
+
|
|
462
|
+
return False
|
|
463
|
+
|
|
464
|
+
def _stage_current_prefix(self) -> None:
|
|
465
|
+
"""Save the current prefix input into the pending dict without executing."""
|
|
466
|
+
vs = self.vs
|
|
467
|
+
raw = vs.prefix_input.strip()
|
|
468
|
+
if raw:
|
|
469
|
+
self.prefix_area._pending[vs.cursor_line] = raw
|
|
470
|
+
else:
|
|
471
|
+
self.prefix_area._pending.pop(vs.cursor_line, None)
|
|
472
|
+
|
|
473
|
+
def _execute_staged_prefixes(self) -> None:
|
|
474
|
+
"""Execute all pending prefix commands.
|
|
475
|
+
|
|
476
|
+
Two passes: source commands (MM, CC, D, I, R, C, M) first so the
|
|
477
|
+
clipboard is populated before paste commands (A, B) run.
|
|
478
|
+
"""
|
|
479
|
+
vs = self.vs
|
|
480
|
+
last_message = ""
|
|
481
|
+
pending = dict(self.prefix_area._pending)
|
|
482
|
+
paste_cmds = {"A", "B", "O", "OO"}
|
|
483
|
+
|
|
484
|
+
# Pass 1: everything except A/B, in line order
|
|
485
|
+
for line_idx in sorted(pending.keys()):
|
|
486
|
+
raw = pending[line_idx]
|
|
487
|
+
cmd_name, _ = self.prefix_area.registry.normalize(raw)
|
|
488
|
+
if cmd_name in paste_cmds:
|
|
489
|
+
continue
|
|
490
|
+
result = self.prefix_area.enter_prefix(line_idx, raw)
|
|
491
|
+
if result is not None:
|
|
492
|
+
if result.message:
|
|
493
|
+
last_message = result.message
|
|
494
|
+
if result.cursor_hint is not None:
|
|
495
|
+
vs.cursor_line = max(0, min(result.cursor_hint, len(self.buffer) - 1))
|
|
496
|
+
self._scroll_to_cursor()
|
|
497
|
+
else:
|
|
498
|
+
last_message = "Waiting for block partner..."
|
|
499
|
+
|
|
500
|
+
# Pass 2: paste commands (A/B), in line order
|
|
501
|
+
for line_idx in sorted(pending.keys()):
|
|
502
|
+
raw = pending[line_idx]
|
|
503
|
+
cmd_name, _ = self.prefix_area.registry.normalize(raw)
|
|
504
|
+
if cmd_name not in paste_cmds:
|
|
505
|
+
continue
|
|
506
|
+
result = self.prefix_area.enter_prefix(line_idx, raw)
|
|
507
|
+
if result is not None:
|
|
508
|
+
if result.message:
|
|
509
|
+
last_message = result.message
|
|
510
|
+
if result.cursor_hint is not None:
|
|
511
|
+
vs.cursor_line = max(0, min(result.cursor_hint, len(self.buffer) - 1))
|
|
512
|
+
self._scroll_to_cursor()
|
|
513
|
+
|
|
514
|
+
vs.message = last_message
|
|
515
|
+
|
|
516
|
+
def _save_and_quit(self) -> None:
|
|
517
|
+
if self.buffer.modified and self.buffer.filepath:
|
|
518
|
+
try:
|
|
519
|
+
self.buffer.save_file()
|
|
520
|
+
except Exception:
|
|
521
|
+
pass
|
|
522
|
+
|
|
523
|
+
# ------------------------------------------------------------------
|
|
524
|
+
# Text editing helpers (basic, Phase 6 will flesh these out)
|
|
525
|
+
# ------------------------------------------------------------------
|
|
526
|
+
|
|
527
|
+
def _scroll_col_to_cursor(self) -> None:
|
|
528
|
+
"""Adjust col_offset so cursor_col is always visible in the text area."""
|
|
529
|
+
vs = self.vs
|
|
530
|
+
text_width = max(1, vs.screen_cols - TEXT_OFFSET)
|
|
531
|
+
if vs.cursor_col < vs.col_offset:
|
|
532
|
+
vs.col_offset = vs.cursor_col
|
|
533
|
+
elif vs.cursor_col >= vs.col_offset + text_width:
|
|
534
|
+
vs.col_offset = vs.cursor_col - text_width + 1
|
|
535
|
+
|
|
536
|
+
def _insert_char(self, ch: str) -> None:
|
|
537
|
+
vs = self.vs
|
|
538
|
+
if not self.buffer.lines:
|
|
539
|
+
self.buffer.lines.append(__import__('notispf.buffer', fromlist=['Line']).Line(text=""))
|
|
540
|
+
line = self.buffer.lines[vs.cursor_line]
|
|
541
|
+
new_text = line.text[:vs.cursor_col] + ch + line.text[vs.cursor_col:]
|
|
542
|
+
self.buffer.replace_line(vs.cursor_line, new_text)
|
|
543
|
+
vs.cursor_col += 1
|
|
544
|
+
self._scroll_col_to_cursor()
|
|
545
|
+
|
|
546
|
+
def _backspace(self) -> None:
|
|
547
|
+
vs = self.vs
|
|
548
|
+
if not self.buffer.lines:
|
|
549
|
+
return
|
|
550
|
+
if vs.cursor_col > 0:
|
|
551
|
+
line = self.buffer.lines[vs.cursor_line]
|
|
552
|
+
new_text = line.text[:vs.cursor_col - 1] + line.text[vs.cursor_col:]
|
|
553
|
+
self.buffer.replace_line(vs.cursor_line, new_text)
|
|
554
|
+
vs.cursor_col -= 1
|
|
555
|
+
self._scroll_col_to_cursor()
|
|
556
|
+
elif vs.cursor_line > 0:
|
|
557
|
+
# Join with previous line
|
|
558
|
+
prev = self.buffer.lines[vs.cursor_line - 1].text
|
|
559
|
+
curr = self.buffer.lines[vs.cursor_line].text
|
|
560
|
+
new_col = len(prev)
|
|
561
|
+
self.buffer.replace_line(vs.cursor_line - 1, prev + curr)
|
|
562
|
+
self.buffer.delete_lines(vs.cursor_line, 1)
|
|
563
|
+
vs.cursor_line -= 1
|
|
564
|
+
vs.cursor_col = new_col
|
|
565
|
+
self._scroll_to_cursor()
|
|
566
|
+
self._scroll_col_to_cursor()
|
|
567
|
+
|
|
568
|
+
def _delete_char(self) -> None:
|
|
569
|
+
vs = self.vs
|
|
570
|
+
if not self.buffer.lines:
|
|
571
|
+
return
|
|
572
|
+
line = self.buffer.lines[vs.cursor_line]
|
|
573
|
+
if vs.cursor_col < len(line.text):
|
|
574
|
+
new_text = line.text[:vs.cursor_col] + line.text[vs.cursor_col + 1:]
|
|
575
|
+
self.buffer.replace_line(vs.cursor_line, new_text)
|
|
576
|
+
elif vs.cursor_line < len(self.buffer) - 1:
|
|
577
|
+
# At end of line — join with next line
|
|
578
|
+
curr = line.text
|
|
579
|
+
nxt = self.buffer.lines[vs.cursor_line + 1].text
|
|
580
|
+
self.buffer.replace_line(vs.cursor_line, curr + nxt)
|
|
581
|
+
self.buffer.delete_lines(vs.cursor_line + 1, 1)
|
|
582
|
+
|
|
583
|
+
def _enter_key(self) -> None:
|
|
584
|
+
vs = self.vs
|
|
585
|
+
if not self.buffer.lines:
|
|
586
|
+
self.buffer.lines.append(__import__('notispf.buffer', fromlist=['Line']).Line(text=""))
|
|
587
|
+
return
|
|
588
|
+
line = self.buffer.lines[vs.cursor_line]
|
|
589
|
+
before = line.text[:vs.cursor_col]
|
|
590
|
+
after = line.text[vs.cursor_col:]
|
|
591
|
+
self.buffer.replace_line(vs.cursor_line, before)
|
|
592
|
+
self.buffer.insert_lines(vs.cursor_line, [after])
|
|
593
|
+
vs.cursor_line += 1
|
|
594
|
+
vs.cursor_col = 0
|
|
595
|
+
self._scroll_to_cursor()
|
|
596
|
+
|
|
597
|
+
# ------------------------------------------------------------------
|
|
598
|
+
# Cursor / scroll helpers
|
|
599
|
+
# ------------------------------------------------------------------
|
|
600
|
+
|
|
601
|
+
def _move_cursor(self, delta: int, skip_excluded: bool = True) -> None:
|
|
602
|
+
vs = self.vs
|
|
603
|
+
buf_len = max(len(self.buffer), 1)
|
|
604
|
+
new_line = max(0, min(vs.cursor_line + delta, buf_len - 1))
|
|
605
|
+
if skip_excluded:
|
|
606
|
+
direction = 1 if delta >= 0 else -1
|
|
607
|
+
new_line = self.buffer.next_visible(new_line, direction)
|
|
608
|
+
vs.cursor_line = new_line
|
|
609
|
+
if self.buffer.lines:
|
|
610
|
+
vs.cursor_col = min(vs.cursor_col,
|
|
611
|
+
len(self.buffer.lines[vs.cursor_line].text))
|
|
612
|
+
self._scroll_to_cursor()
|
|
613
|
+
|
|
614
|
+
def _scroll_to_cursor(self) -> None:
|
|
615
|
+
vs = self.vs
|
|
616
|
+
rows, _ = self.display.stdscr.getmaxyx()
|
|
617
|
+
content_rows = rows - 2 - (1 if vs.show_cols else 0)
|
|
618
|
+
if vs.cursor_line < vs.top_line:
|
|
619
|
+
vs.top_line = vs.cursor_line
|
|
620
|
+
elif vs.cursor_line >= vs.top_line + content_rows:
|
|
621
|
+
vs.top_line = vs.cursor_line - content_rows + 1
|