notispf 1.0.2__tar.gz → 1.0.4__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.
Files changed (28) hide show
  1. {notispf-1.0.2 → notispf-1.0.4}/PKG-INFO +44 -10
  2. {notispf-1.0.2 → notispf-1.0.4}/README.md +43 -9
  3. notispf-1.0.4/notispf/__init__.py +1 -0
  4. {notispf-1.0.2 → notispf-1.0.4}/notispf/app.py +88 -10
  5. {notispf-1.0.2 → notispf-1.0.4}/notispf/buffer.py +15 -0
  6. {notispf-1.0.2 → notispf-1.0.4}/notispf/commands/block_cmds.py +6 -0
  7. {notispf-1.0.2 → notispf-1.0.4}/notispf/commands/line_cmds.py +67 -1
  8. {notispf-1.0.2 → notispf-1.0.4}/notispf/display.py +20 -9
  9. {notispf-1.0.2 → notispf-1.0.4}/notispf/find_change.py +10 -1
  10. {notispf-1.0.2 → notispf-1.0.4}/notispf.egg-info/PKG-INFO +44 -10
  11. {notispf-1.0.2 → notispf-1.0.4}/pyproject.toml +1 -1
  12. notispf-1.0.2/notispf/__init__.py +0 -1
  13. {notispf-1.0.2 → notispf-1.0.4}/notispf/__main__.py +0 -0
  14. {notispf-1.0.2 → notispf-1.0.4}/notispf/commands/__init__.py +0 -0
  15. {notispf-1.0.2 → notispf-1.0.4}/notispf/commands/exclude_cmds.py +0 -0
  16. {notispf-1.0.2 → notispf-1.0.4}/notispf/commands/registry.py +0 -0
  17. {notispf-1.0.2 → notispf-1.0.4}/notispf/prefix.py +0 -0
  18. {notispf-1.0.2 → notispf-1.0.4}/notispf.egg-info/SOURCES.txt +0 -0
  19. {notispf-1.0.2 → notispf-1.0.4}/notispf.egg-info/dependency_links.txt +0 -0
  20. {notispf-1.0.2 → notispf-1.0.4}/notispf.egg-info/entry_points.txt +0 -0
  21. {notispf-1.0.2 → notispf-1.0.4}/notispf.egg-info/top_level.txt +0 -0
  22. {notispf-1.0.2 → notispf-1.0.4}/setup.cfg +0 -0
  23. {notispf-1.0.2 → notispf-1.0.4}/tests/test_buffer.py +0 -0
  24. {notispf-1.0.2 → notispf-1.0.4}/tests/test_exclude.py +0 -0
  25. {notispf-1.0.2 → notispf-1.0.4}/tests/test_find_change.py +0 -0
  26. {notispf-1.0.2 → notispf-1.0.4}/tests/test_line_cmds.py +0 -0
  27. {notispf-1.0.2 → notispf-1.0.4}/tests/test_overlay.py +0 -0
  28. {notispf-1.0.2 → notispf-1.0.4}/tests/test_prefix.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: notispf
3
- Version: 1.0.2
3
+ Version: 1.0.4
4
4
  Summary: A terminal text editor inspired by the ISPF editor from z/OS
5
5
  Author-email: mrthock <m64357@gmail.com>
6
6
  License-Expression: MIT
@@ -77,7 +77,7 @@ notispf <file>
77
77
  ## Screen Layout
78
78
 
79
79
  ```
80
- notispf filename.txt Line 1/42
80
+ notispf filename.txt Line 1/42 Col 1
81
81
  000001|This is the first line of your file
82
82
  000002|Second line here
83
83
  000003|Third line
@@ -86,7 +86,7 @@ notispf <file>
86
86
  Type prefix command, Enter to execute, Esc to cancel
87
87
  ```
88
88
 
89
- - **Status bar** (top) — filename, modified flag `[+]`, current line/total
89
+ - **Status bar** (top) — filename, modified flag `[+]`, current line/total, cursor column, `[HEX]` when hex mode is active
90
90
  - **Prefix column** (left, 6 chars) — shows line numbers; type commands here
91
91
  - **Text area** (right of `|`) — edit your file
92
92
  - **Message/command line** (bottom) — status messages and command input
@@ -132,6 +132,13 @@ You can stage commands on multiple lines before pressing Enter — they all exec
132
132
  | `B` | Paste clipboard **before** this line |
133
133
  | `>n` | Indent right n columns (e.g. `>4` adds 4 spaces) |
134
134
  | `<n` | Indent left n columns (removes up to n leading spaces) |
135
+ | `HEX` | Replace this line with its hex representation |
136
+ | `HEXB` | Insert a hex copy of this line below it |
137
+ | `HEXA` | Convert a hex line back to ASCII text |
138
+ | `UC` | Uppercase this line |
139
+ | `UCn` | Uppercase n lines (e.g. `UC3`) |
140
+ | `LC` | Lowercase this line |
141
+ | `LCn` | Lowercase n lines |
135
142
 
136
143
  ### Block Commands
137
144
 
@@ -164,7 +171,7 @@ Use `A` or `B` on a third line to place the clipboard after copying or moving.
164
171
 
165
172
  ## Command Line
166
173
 
167
- Press **`=`** or **`F6`** to open the command line, then type a command and press Enter.
174
+ Press **F6** to open the command line, then type a command and press Enter.
168
175
 
169
176
  ### File Commands
170
177
 
@@ -172,17 +179,37 @@ Press **`=`** or **`F6`** to open the command line, then type a command and pres
172
179
  |---------|--------|
173
180
  | `SAVE` | Save file |
174
181
  | `FILE` | Save and exit |
175
- | `CANCEL` or `QUIT` | Exit without saving |
182
+ | `CANCEL` or `QUIT` or `CAN` | Exit without saving |
183
+
184
+ ### Undo / Redo
185
+
186
+ | Command | Action |
187
+ |---------|--------|
188
+ | `UNDO` | Undo last change |
189
+ | `REDO` | Redo last undone change |
190
+
191
+ ### Hex Mode
192
+
193
+ | Command | Action |
194
+ |---------|--------|
195
+ | `HEX ON` | Convert entire file to hex (e.g. `Hello` → `48 65 6C 6C 6F`) |
196
+ | `HEX OFF` | Convert hex back to text |
197
+
198
+ `[HEX]` appears in the status bar while hex mode is active. `HEX ON` and `HEX OFF` each count as a single undo step.
176
199
 
177
200
  ### Find and Change
178
201
 
179
202
  ```
180
- FIND "text"
181
- CHANGE "old" "new"
182
- CHANGE "old" "new" ALL
183
- CHANGE "old" "new" ALL .labelA .labelB
203
+ FIND 'text'
204
+ CHANGE 'old' 'new'
205
+ CHANGE 'old' 'new' ALL
206
+ CHANGE 'old' 'new' ALL .labelA .labelB
184
207
  ```
185
208
 
209
+ Both single and double quotes are accepted as delimiters.
210
+
211
+ Aliases: `F` for `FIND`, `C` for `CHANGE`.
212
+
186
213
  - `FIND` — locate next occurrence (case-insensitive by default)
187
214
  - `CHANGE` — replace next occurrence
188
215
  - `CHANGE ... ALL` — replace all occurrences in file
@@ -197,10 +224,17 @@ Labels are used to define ranges for `CHANGE ... ALL .A .B`.
197
224
 
198
225
  | Key | Action |
199
226
  |-----|--------|
227
+ | F1 | Help |
200
228
  | F3 | Save and exit |
201
- | F5 | Save without exiting |
229
+ | F5 | Repeat last FIND (RFIND) |
202
230
  | F6 | Open command line |
231
+ | F7 / Page Up | Scroll up |
232
+ | F8 / Page Down | Scroll down |
233
+ | F10 | Scroll left |
234
+ | F11 | Scroll right |
203
235
  | F12 | Exit without saving |
236
+ | Ctrl+Z | Undo last change |
237
+ | Ctrl+Y | Redo last undone change |
204
238
 
205
239
  ## Contributing
206
240
 
@@ -55,7 +55,7 @@ notispf <file>
55
55
  ## Screen Layout
56
56
 
57
57
  ```
58
- notispf filename.txt Line 1/42
58
+ notispf filename.txt Line 1/42 Col 1
59
59
  000001|This is the first line of your file
60
60
  000002|Second line here
61
61
  000003|Third line
@@ -64,7 +64,7 @@ notispf <file>
64
64
  Type prefix command, Enter to execute, Esc to cancel
65
65
  ```
66
66
 
67
- - **Status bar** (top) — filename, modified flag `[+]`, current line/total
67
+ - **Status bar** (top) — filename, modified flag `[+]`, current line/total, cursor column, `[HEX]` when hex mode is active
68
68
  - **Prefix column** (left, 6 chars) — shows line numbers; type commands here
69
69
  - **Text area** (right of `|`) — edit your file
70
70
  - **Message/command line** (bottom) — status messages and command input
@@ -110,6 +110,13 @@ You can stage commands on multiple lines before pressing Enter — they all exec
110
110
  | `B` | Paste clipboard **before** this line |
111
111
  | `>n` | Indent right n columns (e.g. `>4` adds 4 spaces) |
112
112
  | `<n` | Indent left n columns (removes up to n leading spaces) |
113
+ | `HEX` | Replace this line with its hex representation |
114
+ | `HEXB` | Insert a hex copy of this line below it |
115
+ | `HEXA` | Convert a hex line back to ASCII text |
116
+ | `UC` | Uppercase this line |
117
+ | `UCn` | Uppercase n lines (e.g. `UC3`) |
118
+ | `LC` | Lowercase this line |
119
+ | `LCn` | Lowercase n lines |
113
120
 
114
121
  ### Block Commands
115
122
 
@@ -142,7 +149,7 @@ Use `A` or `B` on a third line to place the clipboard after copying or moving.
142
149
 
143
150
  ## Command Line
144
151
 
145
- Press **`=`** or **`F6`** to open the command line, then type a command and press Enter.
152
+ Press **F6** to open the command line, then type a command and press Enter.
146
153
 
147
154
  ### File Commands
148
155
 
@@ -150,17 +157,37 @@ Press **`=`** or **`F6`** to open the command line, then type a command and pres
150
157
  |---------|--------|
151
158
  | `SAVE` | Save file |
152
159
  | `FILE` | Save and exit |
153
- | `CANCEL` or `QUIT` | Exit without saving |
160
+ | `CANCEL` or `QUIT` or `CAN` | Exit without saving |
161
+
162
+ ### Undo / Redo
163
+
164
+ | Command | Action |
165
+ |---------|--------|
166
+ | `UNDO` | Undo last change |
167
+ | `REDO` | Redo last undone change |
168
+
169
+ ### Hex Mode
170
+
171
+ | Command | Action |
172
+ |---------|--------|
173
+ | `HEX ON` | Convert entire file to hex (e.g. `Hello` → `48 65 6C 6C 6F`) |
174
+ | `HEX OFF` | Convert hex back to text |
175
+
176
+ `[HEX]` appears in the status bar while hex mode is active. `HEX ON` and `HEX OFF` each count as a single undo step.
154
177
 
155
178
  ### Find and Change
156
179
 
157
180
  ```
158
- FIND "text"
159
- CHANGE "old" "new"
160
- CHANGE "old" "new" ALL
161
- CHANGE "old" "new" ALL .labelA .labelB
181
+ FIND 'text'
182
+ CHANGE 'old' 'new'
183
+ CHANGE 'old' 'new' ALL
184
+ CHANGE 'old' 'new' ALL .labelA .labelB
162
185
  ```
163
186
 
187
+ Both single and double quotes are accepted as delimiters.
188
+
189
+ Aliases: `F` for `FIND`, `C` for `CHANGE`.
190
+
164
191
  - `FIND` — locate next occurrence (case-insensitive by default)
165
192
  - `CHANGE` — replace next occurrence
166
193
  - `CHANGE ... ALL` — replace all occurrences in file
@@ -175,10 +202,17 @@ Labels are used to define ranges for `CHANGE ... ALL .A .B`.
175
202
 
176
203
  | Key | Action |
177
204
  |-----|--------|
205
+ | F1 | Help |
178
206
  | F3 | Save and exit |
179
- | F5 | Save without exiting |
207
+ | F5 | Repeat last FIND (RFIND) |
180
208
  | F6 | Open command line |
209
+ | F7 / Page Up | Scroll up |
210
+ | F8 / Page Down | Scroll down |
211
+ | F10 | Scroll left |
212
+ | F11 | Scroll right |
181
213
  | F12 | Exit without saving |
214
+ | Ctrl+Z | Undo last change |
215
+ | Ctrl+Y | Redo last undone change |
182
216
 
183
217
  ## Contributing
184
218
 
@@ -0,0 +1 @@
1
+ __version__ = "1.0.4"
@@ -6,6 +6,8 @@ import os
6
6
  from notispf.buffer import Buffer
7
7
  from notispf.commands.registry import CommandRegistry
8
8
  from notispf.commands import line_cmds, block_cmds, exclude_cmds
9
+ from notispf.commands.line_cmds import line_to_hex, hex_to_line
10
+ from notispf.buffer import Line
9
11
  from notispf.display import Display, ViewState, TEXT_OFFSET
10
12
  from notispf.find_change import FindChangeEngine
11
13
  from notispf.prefix import PrefixArea
@@ -79,14 +81,23 @@ class App:
79
81
  vs = self.vs
80
82
 
81
83
  if vs.command_mode:
84
+ self.buffer.end_edit_group()
82
85
  return self._handle_command_key(key)
83
86
 
84
87
  if vs.help_mode:
88
+ self.buffer.end_edit_group()
85
89
  return self._handle_help_key(key)
86
90
 
87
91
  if vs.prefix_mode:
92
+ self.buffer.end_edit_group()
88
93
  return self._handle_prefix_key(key)
89
94
 
95
+ # End any active edit group for non-text-edit keys
96
+ _text_edit_keys = {curses.KEY_BACKSPACE, 127, curses.KEY_DC,
97
+ curses.KEY_ENTER, ord('\n'), ord('\r')}
98
+ if key not in _text_edit_keys and not (32 <= key <= 126):
99
+ self.buffer.end_edit_group()
100
+
90
101
  rows, _ = self.display.stdscr.getmaxyx()
91
102
  content_rows = rows - 2 - (1 if vs.show_cols else 0)
92
103
 
@@ -121,11 +132,17 @@ class App:
121
132
  self._scroll_col_to_cursor()
122
133
 
123
134
  # Enter command mode
124
- elif key == ord('=') or key == curses.KEY_F6:
135
+ elif key == curses.KEY_F6:
125
136
  vs.command_mode = True
126
137
  vs.command_input = ""
127
138
  vs.message = ""
128
139
 
140
+ # F1 = HELP
141
+ elif key == curses.KEY_F1:
142
+ vs.help_mode = True
143
+ vs.help_scroll = 0
144
+ vs.message = ""
145
+
129
146
  # F3 = FILE (save and quit)
130
147
  elif key == curses.KEY_F3:
131
148
  self._save_and_quit()
@@ -135,13 +152,22 @@ class App:
135
152
  elif key == curses.KEY_F12:
136
153
  return True
137
154
 
138
- # F5 = save without quit
155
+ # F5 = RFIND (repeat last find)
139
156
  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}"
157
+ pattern = self.find_engine._last_find
158
+ if not pattern:
159
+ vs.message = "No previous FIND"
160
+ else:
161
+ pos = self.find_engine.find_next(pattern)
162
+ if pos:
163
+ vs.cursor_line, vs.cursor_col = pos
164
+ self._scroll_to_cursor()
165
+ self._scroll_col_to_cursor()
166
+ vs.highlight_pattern = pattern
167
+ vs.message = f"Found: {pattern!r}"
168
+ else:
169
+ vs.highlight_pattern = ""
170
+ vs.message = f"Not found: {pattern!r}"
145
171
 
146
172
  # Tab: prefix(N) -> text(N), text(N) -> prefix(N+1)
147
173
  elif key == ord('\t'):
@@ -173,6 +199,12 @@ class App:
173
199
  vs.prefix_input = self.prefix_area._pending.get(vs.cursor_line, "")
174
200
  vs.message = "Type prefix command, Enter to execute, Esc to cancel"
175
201
 
202
+ # Undo / Redo
203
+ elif key == ord('\x1a'): # Ctrl+Z
204
+ vs.message = "Undone" if self.buffer.undo() else "Nothing to undo"
205
+ elif key == ord('\x19'): # Ctrl+Y
206
+ vs.message = "Redone" if self.buffer.redo() else "Nothing to redo"
207
+
176
208
  # Text editing (Phase 6 — placeholder)
177
209
  elif key == curses.KEY_BACKSPACE or key == 127:
178
210
  self._backspace()
@@ -231,6 +263,48 @@ class App:
231
263
 
232
264
  cmd = tokens[0] if tokens else ""
233
265
 
266
+ _aliases = {"F": "FIND", "C": "CHANGE", "CAN": "CANCEL"}
267
+ cmd = _aliases.get(cmd, cmd)
268
+
269
+ if cmd == "UNDO":
270
+ return "Undone" if self.buffer.undo() else "Nothing to undo"
271
+
272
+ if cmd == "REDO":
273
+ return "Redone" if self.buffer.redo() else "Nothing to redo"
274
+
275
+ if cmd == "HEX":
276
+ sub = tokens[1] if len(tokens) > 1 else ""
277
+ if sub == "ON":
278
+ if self.vs.hex_mode:
279
+ return "Already in HEX mode"
280
+ self.buffer._snapshot()
281
+ for i, line in enumerate(self.buffer.lines):
282
+ self.buffer.lines[i] = Line(
283
+ text=line_to_hex(line.text), label=line.label, modified=True)
284
+ self.buffer.modified = True
285
+ self.vs.hex_mode = True
286
+ return "HEX ON — use HEX OFF to restore"
287
+ elif sub == "OFF":
288
+ if not self.vs.hex_mode:
289
+ return "Not in HEX mode"
290
+ new_lines = []
291
+ bad = []
292
+ for i, line in enumerate(self.buffer.lines):
293
+ try:
294
+ new_lines.append(Line(
295
+ text=hex_to_line(line.text), label=line.label, modified=True))
296
+ except ValueError:
297
+ bad.append(i + 1)
298
+ if bad:
299
+ return f"Invalid hex on line(s): {', '.join(str(n) for n in bad[:5])}"
300
+ self.buffer._snapshot()
301
+ self.buffer.lines = new_lines
302
+ self.buffer.modified = True
303
+ self.vs.hex_mode = False
304
+ return "HEX OFF"
305
+ else:
306
+ return "Usage: HEX ON | HEX OFF"
307
+
234
308
  if cmd == "HELP":
235
309
  self.vs.help_mode = True
236
310
  self.vs.help_scroll = 0
@@ -285,7 +359,7 @@ class App:
285
359
  except ValueError:
286
360
  return f"Parse error: {raw}"
287
361
  if len(orig_tokens) < 2:
288
- return 'Usage: EXCLUDE "pattern" [ALL | n]'
362
+ return "Usage: EXCLUDE 'pattern' [ALL | n]"
289
363
  pattern = orig_tokens[1]
290
364
  rest = orig_tokens[2:]
291
365
  if rest:
@@ -307,7 +381,7 @@ class App:
307
381
  except ValueError:
308
382
  return f"Parse error: {raw}"
309
383
  if len(orig_tokens) < 2:
310
- return 'Usage: DELETE "pattern" [ALL | n] | DELETE X ALL | DELETE NX ALL'
384
+ return "Usage: DELETE 'pattern' [ALL | n] | DELETE X ALL | DELETE NX ALL"
311
385
 
312
386
  qualifier = orig_tokens[1].upper()
313
387
 
@@ -369,7 +443,7 @@ class App:
369
443
  except ValueError:
370
444
  return f"Parse error: {raw}"
371
445
  if len(orig_tokens) < 3:
372
- return 'Usage: CHANGE "old" "new" [ALL] [.lbl1 .lbl2] [column]'
446
+ return "Usage: CHANGE 'old' 'new' [ALL] [.lbl1 .lbl2] [column]"
373
447
  old, new = orig_tokens[1], orig_tokens[2]
374
448
  rest = [t.upper() for t in orig_tokens[3:]]
375
449
  # Extract optional column number — any token that is a positive integer
@@ -534,6 +608,7 @@ class App:
534
608
  vs.col_offset = vs.cursor_col - text_width + 1
535
609
 
536
610
  def _insert_char(self, ch: str) -> None:
611
+ self.buffer.begin_edit_group()
537
612
  vs = self.vs
538
613
  if not self.buffer.lines:
539
614
  self.buffer.lines.append(__import__('notispf.buffer', fromlist=['Line']).Line(text=""))
@@ -544,6 +619,7 @@ class App:
544
619
  self._scroll_col_to_cursor()
545
620
 
546
621
  def _backspace(self) -> None:
622
+ self.buffer.begin_edit_group()
547
623
  vs = self.vs
548
624
  if not self.buffer.lines:
549
625
  return
@@ -566,6 +642,7 @@ class App:
566
642
  self._scroll_col_to_cursor()
567
643
 
568
644
  def _delete_char(self) -> None:
645
+ self.buffer.begin_edit_group()
569
646
  vs = self.vs
570
647
  if not self.buffer.lines:
571
648
  return
@@ -581,6 +658,7 @@ class App:
581
658
  self.buffer.delete_lines(vs.cursor_line + 1, 1)
582
659
 
583
660
  def _enter_key(self) -> None:
661
+ self.buffer.begin_edit_group()
584
662
  vs = self.vs
585
663
  if not self.buffer.lines:
586
664
  self.buffer.lines.append(__import__('notispf.buffer', fromlist=['Line']).Line(text=""))
@@ -19,6 +19,7 @@ class Buffer:
19
19
  self._undo_stack: list[list[Line]] = []
20
20
  self._redo_stack: list[list[Line]] = []
21
21
  self._clipboard: list[str] = []
22
+ self._grouping: bool = False # True while coalescing text edits
22
23
 
23
24
  if filepath:
24
25
  self.load_file(filepath)
@@ -34,6 +35,7 @@ class Buffer:
34
35
  self.modified = False
35
36
  self._undo_stack.clear()
36
37
  self._redo_stack.clear()
38
+ self._grouping = False
37
39
 
38
40
  def save_file(self, filepath: str | None = None) -> None:
39
41
  target = filepath or self.filepath
@@ -50,9 +52,22 @@ class Buffer:
50
52
  #-------------------------------------------------------------------
51
53
 
52
54
  def _snapshot(self) -> None:
55
+ if self._grouping:
56
+ return
53
57
  self._undo_stack.append(copy.deepcopy(self.lines))
54
58
  self._redo_stack.clear()
55
59
 
60
+ def begin_edit_group(self) -> None:
61
+ """Start a coalesced edit group. Takes one snapshot; subsequent mutations share it."""
62
+ if not self._grouping:
63
+ self._undo_stack.append(copy.deepcopy(self.lines))
64
+ self._redo_stack.clear()
65
+ self._grouping = True
66
+
67
+ def end_edit_group(self) -> None:
68
+ """End the coalesced edit group so the next mutation gets its own snapshot."""
69
+ self._grouping = False
70
+
56
71
  def undo(self) -> bool:
57
72
  if not self._undo_stack:
58
73
  return False
@@ -37,25 +37,31 @@ def cmd_overlay_block(buffer: Buffer, start_idx: int, end_idx: int, numeric_arg:
37
37
  clipboard = buffer.pop_clipboard()
38
38
  if not clipboard:
39
39
  return EditorResult(success=False, message="Nothing in clipboard")
40
+ buffer.begin_edit_group()
40
41
  for i, dest_idx in enumerate(range(start_idx, end_idx + 1)):
41
42
  src = clipboard[i % len(clipboard)]
42
43
  buffer.replace_line(dest_idx, overlay_text(src, buffer.lines[dest_idx].text))
44
+ buffer.end_edit_group()
43
45
  return EditorResult(success=True, cursor_hint=start_idx)
44
46
 
45
47
 
46
48
  def cmd_indent_right_block(buffer: Buffer, start_idx: int, end_idx: int, numeric_arg: int = 1) -> EditorResult:
47
49
  """Shift all lines in block right by numeric_arg columns."""
50
+ buffer.begin_edit_group()
48
51
  for i in range(start_idx, end_idx + 1):
49
52
  buffer.replace_line(i, " " * numeric_arg + buffer.lines[i].text)
53
+ buffer.end_edit_group()
50
54
  return EditorResult(success=True)
51
55
 
52
56
 
53
57
  def cmd_indent_left_block(buffer: Buffer, start_idx: int, end_idx: int, numeric_arg: int = 1) -> EditorResult:
54
58
  """Shift all lines in block left by numeric_arg columns, removing up to that many leading spaces."""
59
+ buffer.begin_edit_group()
55
60
  for i in range(start_idx, end_idx + 1):
56
61
  text = buffer.lines[i].text
57
62
  leading = len(text) - len(text.lstrip(" "))
58
63
  buffer.replace_line(i, text[min(numeric_arg, leading):])
64
+ buffer.end_edit_group()
59
65
  return EditorResult(success=True)
60
66
 
61
67
 
@@ -1,9 +1,26 @@
1
- """Single-line prefix command implementations: D, I, R, C, M, A, B."""
1
+ """Single-line prefix command implementations: D, I, R, C, M, A, B, HEX, HEXB."""
2
2
  from __future__ import annotations
3
3
  from notispf.buffer import Buffer
4
4
  from notispf.commands.registry import CommandRegistry, CommandSpec, EditorResult
5
5
 
6
6
 
7
+ # ---------------------------------------------------------------------------
8
+ # Hex utilities (used by prefix commands and HEX ON/OFF command line)
9
+ # ---------------------------------------------------------------------------
10
+
11
+ def line_to_hex(text: str) -> str:
12
+ """Convert a text line to space-separated uppercase hex bytes."""
13
+ return ' '.join(f'{ord(c):02X}' for c in text)
14
+
15
+
16
+ def hex_to_line(hex_str: str) -> str:
17
+ """Convert a space-separated hex string back to text. Raises ValueError on bad input."""
18
+ tokens = hex_str.split()
19
+ if not tokens:
20
+ return ''
21
+ return ''.join(chr(int(t, 16)) for t in tokens)
22
+
23
+
7
24
  def cmd_delete(buffer: Buffer, line_idx: int, count: int) -> EditorResult:
8
25
  if line_idx >= len(buffer):
9
26
  return EditorResult(success=False, message="Invalid line")
@@ -68,12 +85,14 @@ def cmd_overlay(buffer: Buffer, line_idx: int, count: int) -> EditorResult:
68
85
  clipboard = buffer.pop_clipboard()
69
86
  if not clipboard:
70
87
  return EditorResult(success=False, message="Nothing in clipboard")
88
+ buffer.begin_edit_group()
71
89
  for i in range(count):
72
90
  dest_idx = line_idx + i
73
91
  if dest_idx >= len(buffer):
74
92
  break
75
93
  src = clipboard[i % len(clipboard)]
76
94
  buffer.replace_line(dest_idx, overlay_text(src, buffer.lines[dest_idx].text))
95
+ buffer.end_edit_group()
77
96
  return EditorResult(success=True, cursor_hint=line_idx)
78
97
 
79
98
 
@@ -91,6 +110,48 @@ def cmd_indent_left(buffer: Buffer, line_idx: int, count: int) -> EditorResult:
91
110
  return EditorResult(success=True)
92
111
 
93
112
 
113
+ def cmd_hex(buffer: Buffer, line_idx: int, count: int) -> EditorResult:
114
+ """HEX — replace this line with its hex representation."""
115
+ text = buffer.lines[line_idx].text
116
+ buffer.replace_line(line_idx, line_to_hex(text))
117
+ return EditorResult(success=True)
118
+
119
+
120
+ def cmd_hex_below(buffer: Buffer, line_idx: int, count: int) -> EditorResult:
121
+ """HEXB — insert a hex copy of this line below it."""
122
+ text = buffer.lines[line_idx].text
123
+ buffer.insert_lines(line_idx, [line_to_hex(text)])
124
+ return EditorResult(success=True, cursor_hint=line_idx)
125
+
126
+
127
+ def cmd_uppercase(buffer: Buffer, line_idx: int, count: int) -> EditorResult:
128
+ """UC — uppercase this line (or n lines)."""
129
+ buffer.begin_edit_group()
130
+ for i in range(line_idx, min(line_idx + count, len(buffer))):
131
+ buffer.replace_line(i, buffer.lines[i].text.upper())
132
+ buffer.end_edit_group()
133
+ return EditorResult(success=True)
134
+
135
+
136
+ def cmd_lowercase(buffer: Buffer, line_idx: int, count: int) -> EditorResult:
137
+ """LC — lowercase this line (or n lines)."""
138
+ buffer.begin_edit_group()
139
+ for i in range(line_idx, min(line_idx + count, len(buffer))):
140
+ buffer.replace_line(i, buffer.lines[i].text.lower())
141
+ buffer.end_edit_group()
142
+ return EditorResult(success=True)
143
+
144
+
145
+ def cmd_hex_to_ascii(buffer: Buffer, line_idx: int, count: int) -> EditorResult:
146
+ """HEXA — convert a hex line back to its ASCII text representation."""
147
+ text = buffer.lines[line_idx].text
148
+ try:
149
+ buffer.replace_line(line_idx, hex_to_line(text))
150
+ except ValueError:
151
+ return EditorResult(success=False, message="HEXA: line is not valid hex")
152
+ return EditorResult(success=True)
153
+
154
+
94
155
  def register(registry: CommandRegistry) -> None:
95
156
  registry.register_line_cmd(CommandSpec("D", cmd_delete, description="Delete line(s)"))
96
157
  registry.register_line_cmd(CommandSpec("I", cmd_insert, description="Insert blank line(s)"))
@@ -102,3 +163,8 @@ def register(registry: CommandRegistry) -> None:
102
163
  registry.register_line_cmd(CommandSpec("O", cmd_overlay, description="Overlay clipboard onto this line"))
103
164
  registry.register_line_cmd(CommandSpec(">", cmd_indent_right, description="Indent line right n columns"))
104
165
  registry.register_line_cmd(CommandSpec("<", cmd_indent_left, description="Indent line left n columns"))
166
+ registry.register_line_cmd(CommandSpec("HEX", cmd_hex, description="Replace line with hex representation"))
167
+ registry.register_line_cmd(CommandSpec("HEXB", cmd_hex_below, description="Insert hex copy of line below"))
168
+ registry.register_line_cmd(CommandSpec("HEXA", cmd_hex_to_ascii, description="Convert hex line back to ASCII"))
169
+ registry.register_line_cmd(CommandSpec("UC", cmd_uppercase, description="Uppercase line(s)"))
170
+ registry.register_line_cmd(CommandSpec("LC", cmd_lowercase, description="Lowercase line(s)"))
@@ -37,6 +37,7 @@ class ViewState:
37
37
  highlight_pattern: str = "" # pattern to highlight (empty = none)
38
38
  help_mode: bool = False # True when help screen is visible
39
39
  help_scroll: int = 0 # top line of help screen
40
+ hex_mode: bool = False # True when HEX ON is active
40
41
 
41
42
 
42
43
  # Layout constants
@@ -93,8 +94,9 @@ class Display:
93
94
  def _render_status(self, buffer, vs: ViewState, cols: int) -> None:
94
95
  filename = buffer.filepath or "[No File]"
95
96
  modified = " [+]" if buffer.modified else ""
96
- position = f" Line {vs.cursor_line + 1}/{len(buffer)}"
97
- left = f" notispf {filename}{modified}"
97
+ hex_ind = " [HEX]" if vs.hex_mode else ""
98
+ position = f" Line {vs.cursor_line + 1}/{len(buffer)} Col {vs.cursor_col + 1}"
99
+ left = f" notispf {filename}{modified}{hex_ind}"
98
100
  right = position + " "
99
101
  padding = cols - len(left) - len(right)
100
102
  if padding < 0:
@@ -270,19 +272,23 @@ class Display:
270
272
  _HELP_LINES = [
271
273
  " notispf — Help Press any key to exit",
272
274
  "",
273
- " COMMAND LINE (press = or F6 to open)",
275
+ " COMMAND LINE (press F6 to open)",
274
276
  " " + "─" * 60,
275
277
  " SAVE Save file",
276
278
  " FILE Save and exit",
277
279
  " CANCEL / QUIT Exit without saving",
278
280
  " COPY filename Copy buffer to another file",
279
- " FIND \"pat\" [col] Find next occurrence (col = start column)",
280
- " CHANGE \"o\" \"n\" [opts] Change text (opts: ALL, col, .lbl .lbl)",
281
- " EXCLUDE \"pat\" [ALL|n] Exclude matching lines from view",
281
+ " FIND 'pat' [col] Find next occurrence (col = start column)",
282
+ " CHANGE 'o' 'n' [opts] Change text (opts: ALL, col, .lbl .lbl)",
283
+ " EXCLUDE 'pat' [ALL|n] Exclude matching lines from view",
282
284
  " SHOW ALL Un-exclude all lines",
283
- " DELETE \"pat\" [ALL|n] Delete lines matching pattern",
285
+ " DELETE 'pat' [ALL|n] Delete lines matching pattern",
284
286
  " DELETE X ALL Delete all excluded lines",
285
287
  " DELETE NX ALL Delete all non-excluded lines",
288
+ " UNDO Undo last change",
289
+ " REDO Redo last undone change",
290
+ " HEX ON Convert entire file to hex display",
291
+ " HEX OFF Convert hex display back to text",
286
292
  " COLS Toggle column ruler",
287
293
  " CLEAR Clear search/change highlighting",
288
294
  " HELP Show this screen",
@@ -301,11 +307,16 @@ class Display:
301
307
  " S / Sn Show (un-exclude) SS Show block",
302
308
  " >n Indent right n cols >>n Indent block right n cols",
303
309
  " <n Indent left n cols <<n Indent block left n cols",
310
+ " HEX Replace line with hex HEXB Insert hex copy below",
311
+ " HEXA Convert hex line to ASCII",
312
+ " UC / UCn Uppercase line(s) LC / LCn Lowercase line(s)",
304
313
  "",
305
314
  " FUNCTION KEYS",
306
315
  " " + "─" * 60,
307
- " F3 Save and exit F5 Save",
308
- " F6 / = Open command line F12 Exit without saving",
316
+ " F1 Help F3 Save and exit",
317
+ " F5 Repeat last FIND",
318
+ " F6 Open command line F12 Exit without saving",
319
+ " Ctrl+Z Undo Ctrl+Y Redo",
309
320
  " F7 / PgUp Scroll up F8/PgDn Scroll down",
310
321
  " F10 Scroll left F11 Scroll right",
311
322
  " Tab Move to prefix area",
@@ -64,6 +64,7 @@ class FindChangeEngine:
64
64
  needle = old if case_sensitive else old.lower()
65
65
  required_col = (col - 1) if col is not None else None
66
66
 
67
+ self.buffer.begin_edit_group()
67
68
  for i, line in enumerate(self.buffer.lines):
68
69
  if line.excluded:
69
70
  continue
@@ -80,6 +81,7 @@ class FindChangeEngine:
80
81
  else text.replace(old, new)
81
82
  self.buffer.replace_line(i, new_text)
82
83
  count += haystack.count(needle)
84
+ self.buffer.end_edit_group()
83
85
  return count
84
86
 
85
87
  def change_in_range(self, old: str, new: str,
@@ -99,6 +101,7 @@ class FindChangeEngine:
99
101
  needle = old if case_sensitive else old.lower()
100
102
  required_col = (col - 1) if col is not None else None
101
103
 
104
+ self.buffer.begin_edit_group()
102
105
  for i in range(start_idx, end_idx + 1):
103
106
  if self.buffer.lines[i].excluded:
104
107
  continue
@@ -115,6 +118,7 @@ class FindChangeEngine:
115
118
  else text.replace(old, new)
116
119
  self.buffer.replace_line(i, new_text)
117
120
  count += haystack.count(needle)
121
+ self.buffer.end_edit_group()
118
122
  return count
119
123
 
120
124
 
@@ -138,15 +142,19 @@ class FindChangeEngine:
138
142
  def delete_excluded(self) -> int:
139
143
  """Delete all excluded lines. Returns count of lines deleted."""
140
144
  to_delete = [i for i, line in enumerate(self.buffer.lines) if line.excluded]
145
+ self.buffer.begin_edit_group()
141
146
  for i in reversed(to_delete):
142
147
  self.buffer.delete_lines(i, 1)
148
+ self.buffer.end_edit_group()
143
149
  return len(to_delete)
144
150
 
145
151
  def delete_non_excluded(self) -> int:
146
152
  """Delete all non-excluded lines. Returns count of lines deleted."""
147
153
  to_delete = [i for i, line in enumerate(self.buffer.lines) if not line.excluded]
154
+ self.buffer.begin_edit_group()
148
155
  for i in reversed(to_delete):
149
156
  self.buffer.delete_lines(i, 1)
157
+ self.buffer.end_edit_group()
150
158
  return len(to_delete)
151
159
 
152
160
  def delete_matching(self, pattern: str, limit: int | None = None,
@@ -165,9 +173,10 @@ class FindChangeEngine:
165
173
  break
166
174
 
167
175
  # Delete in reverse order so indices stay valid
176
+ self.buffer.begin_edit_group()
168
177
  for i in reversed(to_delete):
169
178
  self.buffer.delete_lines(i, 1)
170
-
179
+ self.buffer.end_edit_group()
171
180
  return len(to_delete)
172
181
 
173
182
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: notispf
3
- Version: 1.0.2
3
+ Version: 1.0.4
4
4
  Summary: A terminal text editor inspired by the ISPF editor from z/OS
5
5
  Author-email: mrthock <m64357@gmail.com>
6
6
  License-Expression: MIT
@@ -77,7 +77,7 @@ notispf <file>
77
77
  ## Screen Layout
78
78
 
79
79
  ```
80
- notispf filename.txt Line 1/42
80
+ notispf filename.txt Line 1/42 Col 1
81
81
  000001|This is the first line of your file
82
82
  000002|Second line here
83
83
  000003|Third line
@@ -86,7 +86,7 @@ notispf <file>
86
86
  Type prefix command, Enter to execute, Esc to cancel
87
87
  ```
88
88
 
89
- - **Status bar** (top) — filename, modified flag `[+]`, current line/total
89
+ - **Status bar** (top) — filename, modified flag `[+]`, current line/total, cursor column, `[HEX]` when hex mode is active
90
90
  - **Prefix column** (left, 6 chars) — shows line numbers; type commands here
91
91
  - **Text area** (right of `|`) — edit your file
92
92
  - **Message/command line** (bottom) — status messages and command input
@@ -132,6 +132,13 @@ You can stage commands on multiple lines before pressing Enter — they all exec
132
132
  | `B` | Paste clipboard **before** this line |
133
133
  | `>n` | Indent right n columns (e.g. `>4` adds 4 spaces) |
134
134
  | `<n` | Indent left n columns (removes up to n leading spaces) |
135
+ | `HEX` | Replace this line with its hex representation |
136
+ | `HEXB` | Insert a hex copy of this line below it |
137
+ | `HEXA` | Convert a hex line back to ASCII text |
138
+ | `UC` | Uppercase this line |
139
+ | `UCn` | Uppercase n lines (e.g. `UC3`) |
140
+ | `LC` | Lowercase this line |
141
+ | `LCn` | Lowercase n lines |
135
142
 
136
143
  ### Block Commands
137
144
 
@@ -164,7 +171,7 @@ Use `A` or `B` on a third line to place the clipboard after copying or moving.
164
171
 
165
172
  ## Command Line
166
173
 
167
- Press **`=`** or **`F6`** to open the command line, then type a command and press Enter.
174
+ Press **F6** to open the command line, then type a command and press Enter.
168
175
 
169
176
  ### File Commands
170
177
 
@@ -172,17 +179,37 @@ Press **`=`** or **`F6`** to open the command line, then type a command and pres
172
179
  |---------|--------|
173
180
  | `SAVE` | Save file |
174
181
  | `FILE` | Save and exit |
175
- | `CANCEL` or `QUIT` | Exit without saving |
182
+ | `CANCEL` or `QUIT` or `CAN` | Exit without saving |
183
+
184
+ ### Undo / Redo
185
+
186
+ | Command | Action |
187
+ |---------|--------|
188
+ | `UNDO` | Undo last change |
189
+ | `REDO` | Redo last undone change |
190
+
191
+ ### Hex Mode
192
+
193
+ | Command | Action |
194
+ |---------|--------|
195
+ | `HEX ON` | Convert entire file to hex (e.g. `Hello` → `48 65 6C 6C 6F`) |
196
+ | `HEX OFF` | Convert hex back to text |
197
+
198
+ `[HEX]` appears in the status bar while hex mode is active. `HEX ON` and `HEX OFF` each count as a single undo step.
176
199
 
177
200
  ### Find and Change
178
201
 
179
202
  ```
180
- FIND "text"
181
- CHANGE "old" "new"
182
- CHANGE "old" "new" ALL
183
- CHANGE "old" "new" ALL .labelA .labelB
203
+ FIND 'text'
204
+ CHANGE 'old' 'new'
205
+ CHANGE 'old' 'new' ALL
206
+ CHANGE 'old' 'new' ALL .labelA .labelB
184
207
  ```
185
208
 
209
+ Both single and double quotes are accepted as delimiters.
210
+
211
+ Aliases: `F` for `FIND`, `C` for `CHANGE`.
212
+
186
213
  - `FIND` — locate next occurrence (case-insensitive by default)
187
214
  - `CHANGE` — replace next occurrence
188
215
  - `CHANGE ... ALL` — replace all occurrences in file
@@ -197,10 +224,17 @@ Labels are used to define ranges for `CHANGE ... ALL .A .B`.
197
224
 
198
225
  | Key | Action |
199
226
  |-----|--------|
227
+ | F1 | Help |
200
228
  | F3 | Save and exit |
201
- | F5 | Save without exiting |
229
+ | F5 | Repeat last FIND (RFIND) |
202
230
  | F6 | Open command line |
231
+ | F7 / Page Up | Scroll up |
232
+ | F8 / Page Down | Scroll down |
233
+ | F10 | Scroll left |
234
+ | F11 | Scroll right |
203
235
  | F12 | Exit without saving |
236
+ | Ctrl+Z | Undo last change |
237
+ | Ctrl+Y | Redo last undone change |
204
238
 
205
239
  ## Contributing
206
240
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "notispf"
7
- version = "1.0.2"
7
+ version = "1.0.4"
8
8
  description = "A terminal text editor inspired by the ISPF editor from z/OS"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -1 +0,0 @@
1
- __version__ = "1.0.0"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes