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 ADDED
@@ -0,0 +1 @@
1
+ __version__ = "1.0.0"
notispf/__main__.py ADDED
@@ -0,0 +1,14 @@
1
+ import sys
2
+ from notispf.app import App
3
+
4
+
5
+ def main():
6
+ if len(sys.argv) < 2:
7
+ print("Usage: notispf <file>")
8
+ sys.exit(1)
9
+ app = App(sys.argv[1])
10
+ app.run()
11
+
12
+
13
+ if __name__ == "__main__":
14
+ main()
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