ddgbasic 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.
ddgscreen.py ADDED
@@ -0,0 +1,2205 @@
1
+ """
2
+ Text-mode screen support for DDGBasic, in the style of 1990s BASIC and xBase:
3
+ cursor positioning, colours, boxes, @ SAY/GET entry fields edited with READ,
4
+ light-bar menus and a scrolling query browser.
5
+
6
+ Everything is drawn with ANSI escape sequences written to stdout, so PRINT
7
+ keeps working in between. Keys are read one at a time from the terminal
8
+ (cbreak mode on macOS/Linux, msvcrt on Windows). When stdin is not a
9
+ terminal, keys are read from it as a stream, so scripts can be tested with
10
+ scripted keystrokes.
11
+
12
+ Rows and columns are 1-based: (1, 1) is the top-left corner, as in LOCATE.
13
+ """
14
+ import atexit
15
+ import json
16
+ from datetime import datetime
17
+ import os
18
+ import re
19
+ import shutil
20
+ import signal
21
+ import sys
22
+ import time
23
+
24
+ # QBasic colour numbers 0-15 -> ANSI colour (0-7); 8-15 are the bright versions
25
+ _ANSI = [0, 4, 2, 6, 1, 5, 3, 7]
26
+
27
+ # Key names returned for keys that are not characters
28
+ _CSI_KEYS = {
29
+ 'A': 'UP', 'B': 'DOWN', 'C': 'RIGHT', 'D': 'LEFT', 'H': 'HOME', 'F': 'END', 'Z': 'BTAB',
30
+ '1~': 'HOME', '7~': 'HOME', '4~': 'END', '8~': 'END', '2~': 'INS', '3~': 'DEL',
31
+ '5~': 'PGUP', '6~': 'PGDN',
32
+ '11~': 'F1', '12~': 'F2', '13~': 'F3', '14~': 'F4', '15~': 'F5', '17~': 'F6',
33
+ '18~': 'F7', '19~': 'F8', '20~': 'F9', '21~': 'F10', '23~': 'F11', '24~': 'F12',
34
+ '1;5H': 'CTRL-HOME', '1;5F': 'CTRL-END', '1;5C': 'CTRL-RIGHT', '1;5D': 'CTRL-LEFT', '1;2P': 'SHIFT-F1',
35
+ }
36
+ _SS3_KEYS = {'P': 'F1', 'Q': 'F2', 'R': 'F3', 'S': 'F4', 'A': 'UP', 'B': 'DOWN',
37
+ 'C': 'RIGHT', 'D': 'LEFT', 'H': 'HOME', 'F': 'END'}
38
+ _WIN_KEYS = {'H': 'UP', 'P': 'DOWN', 'K': 'LEFT', 'M': 'RIGHT', 'G': 'HOME', 'O': 'END',
39
+ 'R': 'INS', 'S': 'DEL', 'I': 'PGUP', 'Q': 'PGDN', '\x0f': 'BTAB',
40
+ ';': 'F1', '<': 'F2', '=': 'F3', '>': 'F4', '?': 'F5', '@': 'F6',
41
+ 'A': 'F7', 'B': 'F8', 'C': 'F9', 'D': 'F10', '\x85': 'F11', '\x86': 'F12',
42
+ 'w': 'CTRL-HOME', 'u': 'CTRL-END', 't': 'CTRL-RIGHT', 's': 'CTRL-LEFT', 'T': 'SHIFT-F1'}
43
+
44
+ ENTER, ESC, TAB, BACKSPACE = '\r', '\x1b', '\t', '\x08'
45
+
46
+ SAVED_PREFIX = '\x00SCREEN:' # marks a string made by SAVESCREEN
47
+
48
+ # SET THEME: (text fg, text bg, field fg, field bg), the four values of COLOR
49
+ THEMES = {
50
+ 'DEFAULT': (7, 0, 0, 7), # the terminal's look: white on black, black-on-white fields
51
+ 'CLASSIC': (15, 1, 0, 3), # the blue screen of 1990s business software
52
+ 'NORTON': (11, 1, 0, 3), # light cyan on blue, as in file managers of the time
53
+ 'GREEN': (10, 0, 0, 10), # a green-screen terminal
54
+ 'AMBER': (14, 0, 0, 6), # an amber monitor
55
+ 'PAPER': (0, 7, 15, 1), # black on white, white-on-blue fields
56
+ 'MONO': (15, 0, 0, 15), # bright white on black, no colour at all
57
+ }
58
+
59
+
60
+ class ScreenError(Exception):
61
+ pass
62
+
63
+
64
+ _KEY_NAMES = {'ENTER': '\r', 'ESC': '\x1b', 'TAB': '\t', 'BACKSPACE': '\x08', 'SPACE': ' '}
65
+ _NAMED_KEYS = {'UP', 'DOWN', 'LEFT', 'RIGHT', 'HOME', 'END', 'PGUP', 'PGDN', 'INS', 'DEL', 'BTAB',
66
+ 'CLICK', 'RCLICK', 'MCLICK', 'WHEELUP', 'WHEELDOWN',
67
+ 'CTRL-HOME', 'CTRL-END', 'CTRL-LEFT', 'CTRL-RIGHT', 'SHIFT-F1'} | {f'F{n}' for n in range(1, 13)}
68
+
69
+
70
+ def key_from_name(name):
71
+ """The key ON KEY means by a name: F1-F12, UP, DEL, ..., CTRL-A..CTRL-Z, ALT-A..ALT-Z,
72
+ ENTER, ESC, TAB, SPACE, or a single character."""
73
+ text = str(name)
74
+ upper = text.upper().replace('+', '-')
75
+ if upper in _NAMED_KEYS:
76
+ return upper
77
+ if upper in _KEY_NAMES:
78
+ return _KEY_NAMES[upper]
79
+ if upper.startswith('CTRL-') and len(upper) == 6 and 'A' <= upper[5] <= 'Z':
80
+ return chr(ord(upper[5]) - 64)
81
+ if upper.startswith('ALT-') and len(upper) == 5:
82
+ return upper
83
+ if len(text) == 1:
84
+ return text
85
+ raise ScreenError(f"Unknown key name '{text}'. Use F1-F12, CTRL-X, ALT-X, UP, DEL, ENTER, ESC or one character.")
86
+
87
+
88
+ class KeyReader:
89
+ """Reads single keys. Characters come back as themselves (Enter is CHR$(13),
90
+ Esc CHR$(27), Tab CHR$(9), Backspace CHR$(8)); other keys as names such as
91
+ 'UP', 'PGDN' or 'F1'."""
92
+
93
+ def __init__(self):
94
+ self.pending = []
95
+ self.is_tty = sys.stdin.isatty()
96
+ self.windows = os.name == 'nt'
97
+ self._saved_mode = None
98
+ # Mouse: on by default except on Windows, where it needs the console's VT input mode
99
+ # (SET MOUSE ON switches it on there). Clicks arrive as keys named CLICK, RCLICK,
100
+ # MCLICK, WHEELUP and WHEELDOWN, with the position in mouse_row/mouse_col.
101
+ self.mouse_wanted = not self.windows
102
+ self._mouse_on = False
103
+ self._win_saved_mode = None
104
+ self.mouse_row = self.mouse_col = 0
105
+ # ON KEY: key name -> handler; on_hotkey(key) runs it (set by the Screen)
106
+ self.hotkeys = {}
107
+ self.on_hotkey = None
108
+ self._in_hotkey = False
109
+ # Terminal resize: on_resize() redraws; it runs while waiting for a key (SIGWINCH on
110
+ # macOS/Linux, a size check while polling on Windows), never in the middle of output
111
+ self.on_resize = None
112
+ self.block_suspend = False # True: Ctrl-Z reaches the program instead of suspending it
113
+ self.waiting = False
114
+ self.resized = False
115
+ self._win_size = None
116
+
117
+ # --- mouse -----------------------------------------------------------
118
+ _MOUSE_ON, _MOUSE_OFF = '\x1b[?1000h\x1b[?1006h', '\x1b[?1000l\x1b[?1006l'
119
+
120
+ def _mouse_reporting(self, on):
121
+ if not self.is_tty or on == self._mouse_on:
122
+ return
123
+ if self.windows:
124
+ try:
125
+ import ctypes
126
+ k32 = ctypes.windll.kernel32
127
+ handle = k32.GetStdHandle(-10) # STD_INPUT_HANDLE
128
+ if on:
129
+ mode = ctypes.c_uint32()
130
+ if not k32.GetConsoleMode(handle, ctypes.byref(mode)):
131
+ return
132
+ self._win_saved_mode = mode.value
133
+ # VT input (0x0200), mouse input (0x0010) and extended flags (0x0080), without
134
+ # quick-edit (0x0040), which would take clicks for selecting text
135
+ k32.SetConsoleMode(handle, (mode.value | 0x0200 | 0x0080 | 0x0010) & ~0x0040)
136
+ elif self._win_saved_mode is not None:
137
+ k32.SetConsoleMode(handle, self._win_saved_mode)
138
+ self._win_saved_mode = None
139
+ except Exception:
140
+ return
141
+ sys.stdout.write(self._MOUSE_ON if on else self._MOUSE_OFF)
142
+ sys.stdout.flush()
143
+ self._mouse_on = on
144
+
145
+ def set_mouse(self, on):
146
+ """SET MOUSE ON/OFF."""
147
+ self.mouse_wanted = on
148
+ if not on or self._saved_mode is not None or self.windows:
149
+ self._mouse_reporting(on)
150
+
151
+ # --- terminal mode (macOS/Linux) -----------------------------------
152
+ def raw_on(self):
153
+ """Stop the terminal echoing and buffering keys until raw_off()."""
154
+ if self.is_tty and not self.windows and self._saved_mode is None:
155
+ import termios
156
+ fd = sys.stdin.fileno()
157
+ self._saved_mode = termios.tcgetattr(fd)
158
+ mode = termios.tcgetattr(fd)
159
+ mode[3] &= ~(termios.ECHO | termios.ICANON) # no echo, a key at a time
160
+ mode[0] &= ~termios.IXON # Ctrl-S / Ctrl-Q reach the program
161
+ mode[6][termios.VMIN], mode[6][termios.VTIME] = 1, 0
162
+ # Let Ctrl-Y, Ctrl-V and Ctrl-O through (on macOS Ctrl-Y would suspend the program).
163
+ # Ctrl-C still stops it.
164
+ try:
165
+ disabled = os.fpathconf(fd, 'PC_VDISABLE')
166
+ except (OSError, ValueError):
167
+ disabled = 0
168
+ names = ('VDSUSP', 'VLNEXT', 'VDISCARD') + (('VSUSP',) if self.block_suspend else ())
169
+ slots = [getattr(termios, n) for n in names if hasattr(termios, n)]
170
+ if sys.platform == 'darwin' and not hasattr(termios, 'VDSUSP'):
171
+ slots.append(11) # VDSUSP in <sys/termios.h>
172
+ for slot in slots:
173
+ mode[6][slot] = bytes([disabled]) if isinstance(mode[6][slot], bytes) else disabled
174
+ termios.tcsetattr(fd, termios.TCSANOW, mode) # TCSAFLUSH would drop keys typed ahead
175
+ if self.mouse_wanted:
176
+ self._mouse_reporting(True)
177
+
178
+ def raw_off(self):
179
+ self._mouse_reporting(False)
180
+ if self._saved_mode is not None:
181
+ import termios
182
+ termios.tcsetattr(sys.stdin.fileno(), termios.TCSADRAIN, self._saved_mode)
183
+ self._saved_mode = None
184
+
185
+ # --- reading -------------------------------------------------------
186
+ def _read_char(self, timeout=None):
187
+ """One character, or None when the timeout passes or input has ended."""
188
+ if self.pending:
189
+ return self.pending.pop(0)
190
+ if not self.is_tty:
191
+ ch = sys.stdin.read(1)
192
+ return ch or None
193
+ if self.windows:
194
+ import msvcrt
195
+ if self.mouse_wanted:
196
+ self._mouse_reporting(True)
197
+ deadline = None if timeout is None else time.time() + timeout
198
+ checked = 0.0
199
+ while not msvcrt.kbhit():
200
+ if deadline is not None and time.time() >= deadline:
201
+ return None
202
+ if self.on_resize and time.time() - checked > 0.25: # no SIGWINCH on Windows
203
+ checked = time.time()
204
+ size = shutil.get_terminal_size((80, 24))
205
+ if self._win_size is not None and size != self._win_size:
206
+ self.on_resize()
207
+ self._win_size = size
208
+ time.sleep(0.01)
209
+ return msvcrt.getwch()
210
+ import select
211
+ self.raw_on()
212
+ fd = sys.stdin.fileno()
213
+ if self.resized and self.on_resize:
214
+ self.resized = False
215
+ self.on_resize()
216
+ self.waiting = True
217
+ try:
218
+ ready, _, _ = select.select([fd], [], [], timeout)
219
+ finally:
220
+ self.waiting = False
221
+ if not ready:
222
+ return None
223
+ first = os.read(fd, 1)
224
+ if not first:
225
+ return None
226
+ # Complete a UTF-8 character
227
+ extra = 0
228
+ if first[0] >= 0xF0: extra = 3
229
+ elif first[0] >= 0xE0: extra = 2
230
+ elif first[0] >= 0xC0: extra = 1
231
+ data = first + (os.read(fd, extra) if extra else b'')
232
+ return data.decode('utf-8', errors='replace')
233
+
234
+ def read_key(self, timeout=None):
235
+ """The next key, waiting up to timeout seconds (None waits forever).
236
+ Returns '' if no key arrives in time, or None when input has ended.
237
+ A key bound with ON KEY runs its handler instead of being returned."""
238
+ sys.stdout.flush() # whatever was printed is on screen before the wait
239
+ while True:
240
+ key = self._read_key_once(timeout)
241
+ if key and key in self.hotkeys and self.on_hotkey and not self._in_hotkey:
242
+ self._in_hotkey = True
243
+ try:
244
+ self.on_hotkey(key)
245
+ finally:
246
+ self._in_hotkey = False
247
+ if timeout is not None:
248
+ return '' # a polling INKEY$ sees no key
249
+ continue
250
+ return key
251
+
252
+ def _read_key_once(self, timeout=None):
253
+ ch = self._read_char(timeout)
254
+ if ch is None:
255
+ return '' if (timeout is not None and self.is_tty) else None
256
+ if self.windows and self.is_tty and ch in ('\x00', '\xe0'):
257
+ # An arrow or function key: a prefix, then its code. The C runtime holds the code
258
+ # back after handing over the prefix, where msvcrt.kbhit() can't see it, so it is
259
+ # fetched with getwch() directly; waiting on kbhit() (as _read_char does) would
260
+ # lose it and swallow the next key press.
261
+ import msvcrt
262
+ return _WIN_KEYS.get(msvcrt.getwch(), '')
263
+ if ch == '\x1b':
264
+ nxt = self._read_char(0.05)
265
+ if nxt == '[':
266
+ seq = ''
267
+ while True:
268
+ c = self._read_char(0.05)
269
+ if c is None:
270
+ break
271
+ seq += c
272
+ if '@' <= c <= '~':
273
+ break
274
+ if seq.startswith('<') and seq[-1:] in ('M', 'm'):
275
+ return self._mouse_key(seq, timeout)
276
+ return _CSI_KEYS.get(seq, '')
277
+ if nxt == 'O':
278
+ return _SS3_KEYS.get(self._read_char(0.05) or '', '')
279
+ if nxt is not None and self.is_tty and len(nxt) == 1 and nxt.isprintable():
280
+ return 'ALT-' + nxt.upper() # a terminal sends Alt-letter as Esc + letter
281
+ if nxt is not None:
282
+ self.pending.insert(0, nxt)
283
+ return ESC
284
+ if ch in ('\n', '\r'):
285
+ return ENTER
286
+ if ch in ('\x7f', '\x08'):
287
+ return BACKSPACE
288
+ return ch
289
+
290
+ def _mouse_key(self, seq, timeout):
291
+ """Decodes an SGR mouse report, ESC [ < button ; col ; row M (press) or m (release).
292
+ Presses and wheel turns become keys; releases and movement are skipped."""
293
+ try:
294
+ button, col, row = (int(n) for n in seq[1:-1].split(';'))
295
+ except ValueError:
296
+ return ''
297
+ if seq[-1] == 'm' or button & 32: # a release, or movement with a button held
298
+ return self._read_key_once(timeout)
299
+ self.mouse_row, self.mouse_col = row, col
300
+ if button & 64:
301
+ return 'WHEELDOWN' if button & 1 else 'WHEELUP'
302
+ return {0: 'CLICK', 1: 'MCLICK', 2: 'RCLICK'}.get(button & 3, '')
303
+
304
+
305
+ class Field:
306
+ """One GET entry field."""
307
+
308
+ def __init__(self, row, col, var_name, value, picture, valid, numeric):
309
+ self.row, self.col = row, col
310
+ self.var_name, self.valid = var_name, valid
311
+ self.numeric = numeric
312
+ self.upper = self.blank_zero = self.raw = self.clear_first = self.is_date = False
313
+ width = None
314
+ template = picture or ''
315
+ if template.startswith('@'):
316
+ funcs, _, template = template[1:].partition(' ')
317
+ funcs = funcs.upper()
318
+ self.upper = '!' in funcs
319
+ self.blank_zero = 'Z' in funcs # @Z: show nothing for zero
320
+ self.raw = 'R' in funcs # @R: the template's fixed characters are not stored
321
+ self.clear_first = 'K' in funcs # @K: the first key typed clears the field
322
+ self.is_date = 'D' in funcs and not numeric
323
+ m = re.search(r'S(\d+)', funcs)
324
+ if m:
325
+ width = int(m.group(1))
326
+ if self.is_date:
327
+ template = template or '9999-99-99'
328
+ # A numeric field with a mask such as 99,999.99 is typed like a calculator
329
+ self.calc = numeric and bool(re.fullmatch(r'[9#,.]+', template or ''))
330
+ if self.calc:
331
+ self.decimals = len(template.split('.', 1)[1]) if '.' in template else 0
332
+ self.commas = ',' in template
333
+ self.typed = None
334
+ self.template = '' if numeric else template
335
+ self.mask = template if numeric else ''
336
+ if self.template:
337
+ width = len(self.template)
338
+ elif width is None:
339
+ width = len(template) if (numeric and template) else (10 if numeric else max(len(value), 20))
340
+ self.width = width
341
+ self.buffer = self._initial_buffer(value)
342
+
343
+ def _editable(self, i):
344
+ return not self.template or self.template[i] in '9#ANX!'
345
+
346
+ def _accepts(self, i, ch):
347
+ if self.numeric:
348
+ return ch.isdigit() or ch in '.-+,'
349
+ if not self.template:
350
+ return ch.isprintable()
351
+ m = self.template[i]
352
+ if m == '9': return ch.isdigit()
353
+ if m == '#': return ch.isdigit() or ch in ' +-'
354
+ if m == 'A': return ch.isalpha()
355
+ if m == 'N': return ch.isalnum()
356
+ return ch.isprintable()
357
+
358
+ def _calc_text(self, number):
359
+ """A calculator field's value, formatted with its mask and right-aligned."""
360
+ if self.blank_zero and not number:
361
+ return ' ' * self.width
362
+ text = f"{number:,.{self.decimals}f}" if self.commas else f"{number:.{self.decimals}f}"
363
+ return ('*' * self.width) if len(text) > self.width else text.rjust(self.width)
364
+
365
+ def _initial_buffer(self, value):
366
+ if self.calc:
367
+ self.typed = None
368
+ try:
369
+ self.number = float(value) if value not in (None, '') else 0.0
370
+ except (TypeError, ValueError):
371
+ self.number = 0.0
372
+ return list(self._calc_text(self.number))
373
+ if self.numeric:
374
+ if self.blank_zero and not value:
375
+ return [' '] * self.width
376
+ text = format_number(value)
377
+ return list(text[:self.width].ljust(self.width))
378
+ if self.is_date and hasattr(value, 'isoformat'):
379
+ value = value.isoformat()
380
+ text = str(value)
381
+ if self.is_date:
382
+ text = text[:10]
383
+ if not self.template:
384
+ return list(text[:self.width].ljust(self.width))
385
+ if len(text) == len(self.template):
386
+ return list(text)
387
+ chars = iter(c for c in text)
388
+ return [next(chars, ' ') if self._editable(i) else self.template[i] for i in range(self.width)]
389
+
390
+ def text(self):
391
+ """The field's value as it should be stored."""
392
+ raw = ''.join(self.buffer)
393
+ if self.template and all(raw[i] == ' ' for i in range(self.width) if self._editable(i)):
394
+ return ''
395
+ if self.raw and self.template:
396
+ return ''.join(raw[i] for i in range(self.width) if self._editable(i)).rstrip()
397
+ return raw.rstrip()
398
+
399
+ def clear(self):
400
+ """Empties the field (Ctrl-Y, and the first key in a numeric or @K field)."""
401
+ if self.calc:
402
+ self.typed = ''
403
+ self.buffer = [' '] * self.width
404
+ else:
405
+ self.buffer = [' ' if self._editable(i) else self.template[i] for i in range(self.width)]
406
+
407
+ def value(self):
408
+ """The typed value, converted for a numeric field. Raises ValueError."""
409
+ if self.calc:
410
+ if self.typed is None:
411
+ return self.number
412
+ typed = self.typed.strip()
413
+ return float(typed) if typed not in ('', '-', '.', '-.') else 0.0
414
+ text = self.text()
415
+ if self.is_date:
416
+ if text:
417
+ datetime.strptime(text, '%Y-%m-%d') # raises ValueError for an impossible date
418
+ return text
419
+ if not self.numeric:
420
+ return text
421
+ text = text.replace(',', '').strip()
422
+ return float(text) if text else 0.0
423
+
424
+ def first_pos(self):
425
+ if self.calc:
426
+ return self.width - 1 # the cursor sits at the right, as on a calculator
427
+ return next((i for i in range(self.width) if self._editable(i)), 0)
428
+
429
+ def _step(self, pos, direction):
430
+ i = pos + direction
431
+ while 0 <= i < self.width and not self._editable(i):
432
+ i += direction
433
+ return i if 0 <= i < self.width else pos
434
+
435
+ def last_pos(self):
436
+ return next((i for i in range(self.width - 1, -1, -1) if self._editable(i)), self.width - 1)
437
+
438
+ def _calc_key(self, key):
439
+ """Calculator-style typing: digits build the number, '.' starts the decimals,
440
+ '-' changes the sign, Backspace removes the last character."""
441
+ typed = self.typed if self.typed is not None else ''
442
+ if key in ('\x19', 'DEL'):
443
+ typed = ''
444
+ elif key == BACKSPACE:
445
+ typed = typed[:-1]
446
+ elif len(key) == 1 and key.isdigit():
447
+ candidate = typed + key
448
+ if '.' in typed and len(typed.split('.', 1)[1]) >= self.decimals:
449
+ bell() # no more decimals than the mask has
450
+ elif '*' in self._calc_text(float(candidate.replace('-', '') or 0)):
451
+ bell() # the number would no longer fit the mask
452
+ else:
453
+ typed = candidate
454
+ elif key == '.' and self.decimals and '.' not in typed:
455
+ typed += '.'
456
+ elif key == '-':
457
+ typed = typed[1:] if typed.startswith('-') else '-' + typed
458
+ elif len(key) == 1 and key.isprintable():
459
+ bell()
460
+ self.typed = typed
461
+ self.buffer = list(typed.rjust(self.width)[-self.width:])
462
+ return self.width - 1
463
+
464
+ def edit_key(self, key, pos, insert):
465
+ """Applies an editing key; returns the new cursor position. Sets self.filled when
466
+ a character was typed into the field's last position (for SET AUTOADVANCE)."""
467
+ self.filled = False
468
+ if self.calc:
469
+ return self._calc_key(key)
470
+ if key == 'LEFT':
471
+ return self._step(pos, -1)
472
+ if key == 'RIGHT':
473
+ return self._step(pos, 1)
474
+ if key == 'HOME':
475
+ return self.first_pos()
476
+ if key == 'END':
477
+ end = len(''.join(self.buffer).rstrip())
478
+ return min(end, self.width - 1)
479
+ if key == '\x19': # Ctrl-Y clears the field
480
+ self.buffer = [' ' if self._editable(i) else self.template[i] for i in range(self.width)]
481
+ return self.first_pos()
482
+ if key == BACKSPACE:
483
+ prev = self._step(pos, -1)
484
+ if prev == pos and (pos == self.first_pos()):
485
+ return pos
486
+ pos = prev
487
+ key = 'DEL'
488
+ if key == 'DEL':
489
+ if self.template:
490
+ self.buffer[pos] = ' '
491
+ else:
492
+ del self.buffer[pos]
493
+ self.buffer.append(' ')
494
+ return pos
495
+ if len(key) == 1 and key.isprintable():
496
+ if not self._accepts(pos, key):
497
+ bell()
498
+ return pos
499
+ if self.upper or (self.template and self.template[pos] == '!'):
500
+ key = key.upper()
501
+ if insert and not self.template:
502
+ if self.buffer[-1] != ' ':
503
+ bell()
504
+ return pos
505
+ self.buffer.insert(pos, key)
506
+ self.buffer.pop()
507
+ else:
508
+ self.buffer[pos] = key
509
+ self.filled = pos == self.last_pos()
510
+ return self._step(pos, 1) if pos < self.width - 1 else pos
511
+ return pos
512
+
513
+
514
+ class CheckBox(Field):
515
+ """GET ... CHECKBOX: [X] or [ ]. Space (or a click) toggles it; Y/N/X/1/0 set it."""
516
+ is_check = True
517
+
518
+ def __init__(self, row, col, var_name, value, valid, as_text):
519
+ self.row, self.col, self.var_name, self.valid = row, col, var_name, valid
520
+ self.numeric = self.calc = self.is_date = self.clear_first = self.raw = False
521
+ self.upper = self.blank_zero = False
522
+ self.template, self.width, self.as_text = '[X]', 3, as_text
523
+ self.buffer = self._initial_buffer(value)
524
+
525
+ def _editable(self, i):
526
+ return i == 1
527
+
528
+ def _initial_buffer(self, value):
529
+ if isinstance(value, str):
530
+ on = value.strip().upper() in ('Y', 'YES', 'T', 'TRUE', 'X', '1', '-1')
531
+ else:
532
+ on = bool(value)
533
+ return list('[X]' if on else '[ ]')
534
+
535
+ def text(self):
536
+ return 'Y' if self.buffer[1] == 'X' else 'N'
537
+
538
+ def value(self):
539
+ on = self.buffer[1] == 'X'
540
+ return ('Y' if on else 'N') if self.as_text else (-1.0 if on else 0.0)
541
+
542
+ def edit_key(self, key, pos, insert):
543
+ self.filled = False
544
+ if key == ' ':
545
+ self.buffer[1] = ' ' if self.buffer[1] == 'X' else 'X'
546
+ elif len(key) == 1 and key.upper() in ('Y', 'X', '1', 'T'):
547
+ self.buffer[1] = 'X'
548
+ elif len(key) == 1 and key.upper() in ('N', '0', 'F'):
549
+ self.buffer[1] = ' '
550
+ elif len(key) == 1 and key.isprintable():
551
+ bell()
552
+ return 1
553
+
554
+
555
+ class ListField(Field):
556
+ """GET ... LIST: a drop-down. Space, F2 or a click opens the list; a letter jumps to the
557
+ next item that starts with it."""
558
+ is_list = True
559
+
560
+ def __init__(self, row, col, var_name, value, valid, items):
561
+ self.row, self.col, self.var_name, self.valid = row, col, var_name, valid
562
+ self.numeric = self.calc = self.is_date = self.clear_first = self.raw = False
563
+ self.upper = self.blank_zero = False
564
+ self.items = [str(i) for i in items]
565
+ if not self.items:
566
+ raise ScreenError("GET ... LIST needs at least one item.")
567
+ self.inner = max([len(i) for i in self.items] + [len(str(value))])
568
+ self.template, self.width = '', self.inner + 2
569
+ self.buffer = self._initial_buffer(value)
570
+
571
+ def _editable(self, i):
572
+ return i == 0
573
+
574
+ def _initial_buffer(self, value):
575
+ self.current = '' if value is None else str(value)
576
+ return list(self.current.ljust(self.inner) + ' ▼')
577
+
578
+ def set(self, value):
579
+ self.buffer = self._initial_buffer(value)
580
+
581
+ def text(self):
582
+ return self.current
583
+
584
+ def value(self):
585
+ return self.current
586
+
587
+ def edit_key(self, key, pos, insert):
588
+ self.filled = False
589
+ if len(key) == 1 and key.isprintable() and key != ' ':
590
+ start = self.items.index(self.current) + 1 if self.current in self.items else 0
591
+ for k in range(len(self.items)):
592
+ item = self.items[(start + k) % len(self.items)]
593
+ if item[:1].upper() == key.upper():
594
+ self.set(item)
595
+ return 0
596
+ bell()
597
+ elif key in ('\x19', 'DEL'):
598
+ self.set('')
599
+ return 0
600
+
601
+
602
+ class Button:
603
+ """A [ label ] button among the GET fields. READ ends when it is pressed."""
604
+ is_button = True
605
+
606
+ def __init__(self, row, col, label, cancel=False):
607
+ self.row, self.col, self.label, self.cancel = row, col, str(label), cancel
608
+ self.when = None
609
+ self.width = len(self.label) + 4
610
+
611
+ def first_pos(self):
612
+ return 0
613
+
614
+
615
+ def format_number(value):
616
+ """A number as PRINT shows it, without the leading space."""
617
+ try:
618
+ f = float(value)
619
+ except (TypeError, ValueError):
620
+ return str(value)
621
+ return str(int(f)) if f == int(f) and abs(f) < 1e15 else repr(f)
622
+
623
+
624
+ def bell():
625
+ sys.stdout.write('\a')
626
+ sys.stdout.flush()
627
+
628
+
629
+ class Screen:
630
+ def __init__(self):
631
+ self.keys = KeyReader()
632
+ self.fg, self.bg = 7, 0
633
+ self.field_fg, self.field_bg = 0, 7
634
+ self.gets = []
635
+ self.last_key = ''
636
+ self.used = False
637
+ self.auto_advance = False # SET AUTOADVANCE ON: filling a field moves to the next one
638
+ self.last_button = '' # BUTTON$: the label of the button that ended the last READ
639
+ self.hotkey_call = None # set by the interpreter: runs the function bound to a key
640
+ self.keys.on_hotkey = self._run_hotkey
641
+ self.shadows = True # SET SHADOW OFF: pop-ups without a drop shadow
642
+ # Whether writing the bottom-right cell leaves the cursor there (as xterm, macOS and
643
+ # Linux terminals do) rather than scrolling the screen up a line. The Windows console
644
+ # does it only with DISABLE_NEWLINE_AUTO_RETURN, which _activate turns on.
645
+ self.deferred_wrap = os.name != 'nt'
646
+ self.progress_box = None # PROGRESS: (area, what it covered, last drawn bar)
647
+ self.keys.on_resize = self._resized
648
+ if hasattr(signal, 'SIGWINCH') and self.keys.is_tty:
649
+ try:
650
+ signal.signal(signal.SIGWINCH, self._on_sigwinch)
651
+ except ValueError: # not the main thread
652
+ pass
653
+ # A copy of what is on the screen, one (char, fg, bg) cell per position, so that
654
+ # SAVESCREEN, MENU and BROWSE can put back what they cover (ANSI can't read it back)
655
+ self.rows, self.cols = self.size()
656
+ self.cells = self._blank(self.rows, self.cols, 7, 0)
657
+ self.cur_row, self.cur_col = 1, 1
658
+
659
+ def _on_sigwinch(self, signum, frame):
660
+ if self.keys.waiting: # blocked reading a key: safe to redraw now
661
+ self._resized()
662
+ else:
663
+ self.keys.resized = True
664
+
665
+ def _resized(self):
666
+ """The terminal changed size: keep the screen copy that still fits, and redraw it all,
667
+ since terminals shuffle or wrap what was there."""
668
+ rows, cols = self.size()
669
+ if not self.used or (rows, cols) == (self.rows, self.cols):
670
+ self.rows, self.cols = rows, cols
671
+ return
672
+ cells = self._blank(rows, cols, self.fg, self.bg)
673
+ for r in range(min(rows, self.rows)):
674
+ cells[r][:min(cols, self.cols)] = self.cells[r][:min(cols, self.cols)]
675
+ self.rows, self.cols = rows, cols
676
+ self.cells = [[(' ', self.fg, self.bg)] * cols for _ in range(rows)]
677
+ row, col = min(self.cur_row, rows), min(self.cur_col, cols)
678
+ self._w('\x1b[0m\x1b[2J' + self._draw_cells(1, 1, cells) + self._normal() + f'\x1b[{row};{col}H')
679
+
680
+ def _run_hotkey(self, key):
681
+ """Runs an ON KEY handler, then puts back the screen it may have drawn over."""
682
+ if not self.hotkey_call:
683
+ return
684
+ under = self.save() if self.used else None
685
+ try:
686
+ self.hotkey_call(self.keys.hotkeys[key])
687
+ finally:
688
+ if under:
689
+ self.restore_saved(under)
690
+
691
+ # --- output helpers ------------------------------------------------
692
+ def _activate(self):
693
+ if self.used:
694
+ return
695
+ self.used = True
696
+ if os.name == 'nt':
697
+ try: # let the Windows console interpret ANSI sequences
698
+ import ctypes
699
+ k32 = ctypes.windll.kernel32
700
+ handle = k32.GetStdHandle(-11)
701
+ mode = ctypes.c_uint32()
702
+ if k32.GetConsoleMode(handle, ctypes.byref(mode)):
703
+ # 0x0004: interpret ANSI sequences. 0x0008 (DISABLE_NEWLINE_AUTO_RETURN):
704
+ # writing the last column doesn't move the cursor on, so drawing the
705
+ # bottom-right cell (a hint line, a full-screen box) doesn't scroll the
706
+ # screen up. Older consoles refuse 0x0008; _put then skips that cell.
707
+ if k32.SetConsoleMode(handle, mode.value | 0x0004 | 0x0008):
708
+ self.deferred_wrap = True
709
+ else:
710
+ k32.SetConsoleMode(handle, mode.value | 0x0004)
711
+ except Exception:
712
+ pass
713
+ atexit.register(self.restore)
714
+
715
+ def restore(self):
716
+ """Puts the terminal back as it was: normal colours, cursor shown, keys echoed."""
717
+ self.keys.raw_off()
718
+ if self.used:
719
+ sys.stdout.write('\x1b[0m\x1b[?25h')
720
+ sys.stdout.flush()
721
+
722
+ def _w(self, text):
723
+ self._activate()
724
+ sys.stdout.write(text)
725
+ sys.stdout.flush()
726
+
727
+ @staticmethod
728
+ def _sgr(fg, bg):
729
+ fg_code = (90 if fg >= 8 else 30) + _ANSI[fg % 8]
730
+ bg_code = (100 if bg >= 8 else 40) + _ANSI[bg % 8]
731
+ return f'\x1b[0;{fg_code};{bg_code}m'
732
+
733
+ def _normal(self):
734
+ return self._sgr(self.fg, self.bg)
735
+
736
+ def _field_colour(self):
737
+ return self._sgr(self.field_fg, self.field_bg)
738
+
739
+ @staticmethod
740
+ def size():
741
+ cols, rows = shutil.get_terminal_size((80, 24))
742
+ return rows, cols
743
+
744
+ @staticmethod
745
+ def _blank(rows, cols, fg, bg):
746
+ return [[(' ', fg, bg) for _ in range(cols)] for _ in range(rows)]
747
+
748
+ def _put(self, row, col, text, fg, bg):
749
+ """Records text in the screen copy and returns the ANSI output that draws it."""
750
+ r = row - 1
751
+ if 0 <= r < self.rows:
752
+ line = self.cells[r]
753
+ for i, ch in enumerate(text):
754
+ c = col - 1 + i
755
+ if 0 <= c < self.cols:
756
+ line[c] = (ch, fg, bg)
757
+ self.cur_row, self.cur_col = row, col + len(text)
758
+ if not self.deferred_wrap and row == self.rows and col - 1 + len(text) >= self.cols:
759
+ # The bottom-right cell would scroll the screen, so it isn't drawn. The screen
760
+ # copy above still has it, so SAVESCREEN and RESTSCREEN see the same contents
761
+ text = text[:max(0, self.cols - col)]
762
+ return f'\x1b[{row};{col}H' + self._sgr(fg, bg) + text
763
+
764
+ def track(self, text, emit=True):
765
+ """PRINT and INPUT text written at the cursor: kept in the screen copy, following the
766
+ terminal's newlines, tab stops, line wrap and scrolling. emit=False only records it."""
767
+ for ch in text:
768
+ if ch == '\n':
769
+ self._newline()
770
+ elif ch == '\r':
771
+ self.cur_col = 1
772
+ elif ch == '\t':
773
+ self.cur_col = min(((self.cur_col - 1) // 8 + 1) * 8 + 1, self.cols)
774
+ elif ch == '\a' or not ch.isprintable():
775
+ continue
776
+ else:
777
+ if self.cur_col > self.cols:
778
+ self._newline()
779
+ if 1 <= self.cur_row <= self.rows:
780
+ self.cells[self.cur_row - 1][self.cur_col - 1] = (ch, self.fg, self.bg)
781
+ self.cur_col += 1
782
+ if emit:
783
+ self._w(text)
784
+
785
+ def _newline(self):
786
+ self.cur_col = 1
787
+ if self.cur_row >= self.rows:
788
+ self.cells.pop(0)
789
+ self.cells.append([(' ', self.fg, self.bg) for _ in range(self.cols)])
790
+ else:
791
+ self.cur_row += 1
792
+
793
+ def _draw_cells(self, r1, c1, block):
794
+ """ANSI output for a block of cells with its top-left corner at r1, c1."""
795
+ out = []
796
+ for n, line in enumerate(block):
797
+ row = r1 + n - 1
798
+ if 0 <= row < self.rows:
799
+ for i, cell in enumerate(line):
800
+ if 0 <= c1 - 1 + i < self.cols:
801
+ self.cells[row][c1 - 1 + i] = cell
802
+ if not self.deferred_wrap and r1 + n == self.rows and c1 - 1 + len(line) >= self.cols:
803
+ line = line[:max(0, self.cols - c1)] # as in _put: not the bottom-right cell
804
+ out.append(f'\x1b[{r1 + n};{c1}H')
805
+ colour = None
806
+ for ch, fg, bg in line:
807
+ if (fg, bg) != colour:
808
+ out.append(self._sgr(fg, bg))
809
+ colour = (fg, bg)
810
+ out.append(ch)
811
+ return ''.join(out)
812
+
813
+ def save(self, r1=None, c1=None, r2=None, c2=None):
814
+ """SAVESCREEN: the area's contents and the cursor, as a string RESTSCREEN can put back."""
815
+ r1, c1 = r1 or 1, c1 or 1
816
+ r2, c2 = r2 or self.rows, c2 or self.cols
817
+ r1, c1 = max(1, r1), max(1, c1)
818
+ r2, c2 = min(self.rows, r2), min(self.cols, c2)
819
+ if r2 < r1 or c2 < c1:
820
+ raise ScreenError("SAVESCREEN area is empty.")
821
+ block = [self.cells[r][c1 - 1:c2] for r in range(r1 - 1, r2)]
822
+ return SAVED_PREFIX + json.dumps({'at': [r1, c1], 'cursor': [self.cur_row, self.cur_col],
823
+ 'cells': [[list(cell) for cell in line] for line in block]},
824
+ ensure_ascii=False, separators=(',', ':'))
825
+
826
+ def restore_saved(self, saved):
827
+ """RESTSCREEN: puts back an area saved by SAVESCREEN, and the cursor."""
828
+ if not isinstance(saved, str) or not saved.startswith(SAVED_PREFIX):
829
+ raise ScreenError("RESTSCREEN needs a value returned by SAVESCREEN.")
830
+ try:
831
+ data = json.loads(saved[len(SAVED_PREFIX):])
832
+ r1, c1 = data['at']
833
+ block = [[tuple(cell) for cell in line] for line in data['cells']]
834
+ except (ValueError, KeyError, TypeError):
835
+ raise ScreenError("RESTSCREEN needs a value returned by SAVESCREEN.")
836
+ row, col = data.get('cursor', [r1, c1])
837
+ self._w(self._draw_cells(r1, c1, block) + self._normal() + f'\x1b[{row};{col}H')
838
+ self.cur_row, self.cur_col = row, col
839
+
840
+ # --- statements ----------------------------------------------------
841
+ def color(self, fg=None, bg=None, field_fg=None, field_bg=None):
842
+ for v in (fg, bg, field_fg, field_bg):
843
+ if v is not None and not 0 <= v <= 15:
844
+ raise ScreenError("COLOR values must be 0 to 15.")
845
+ if fg is not None: self.fg = fg
846
+ if bg is not None: self.bg = bg
847
+ if field_fg is not None: self.field_fg = field_fg
848
+ if field_bg is not None: self.field_bg = field_bg
849
+ self._w(self._normal())
850
+
851
+ def theme(self, name):
852
+ """SET THEME: sets the four colours of COLOR from a named theme."""
853
+ colours = THEMES.get(str(name).strip().upper())
854
+ if colours is None:
855
+ raise ScreenError(f"There is no theme called {name}. The themes are: " +
856
+ ', '.join(t.lower() for t in THEMES) + '.')
857
+ self.color(*colours)
858
+
859
+ def cls(self):
860
+ self.gets = []
861
+ self.rows, self.cols = self.size() # the terminal may have been resized
862
+ self.cells = self._blank(self.rows, self.cols, self.fg, self.bg)
863
+ self.cur_row, self.cur_col = 1, 1
864
+ self._w(self._normal() + '\x1b[2J\x1b[H')
865
+
866
+ def locate(self, row, col):
867
+ self.cur_row, self.cur_col = max(1, row), max(1, col)
868
+ self._w(f'\x1b[{self.cur_row};{self.cur_col}H')
869
+
870
+ def say(self, row, col, text):
871
+ self._w(self._put(row, col, text, self.fg, self.bg))
872
+
873
+ def box(self, r1, c1, r2, c2, title='', double=False):
874
+ if r2 - r1 < 1 or c2 - c1 < 1:
875
+ raise ScreenError("BOX needs a bottom-right corner below and to the right of the top-left corner.")
876
+ tl, tr, bl, br, h, v = ('╔', '╗', '╚', '╝', '═', '║') if double else ('┌', '┐', '└', '┘', '─', '│')
877
+ inner = c2 - c1 - 1
878
+ top = h * inner
879
+ if title:
880
+ label = f' {title} '[:inner]
881
+ start = (inner - len(label)) // 2
882
+ top = top[:start] + label + top[start + len(label):]
883
+ out = [self._put(r1, c1, tl + top + tr, self.fg, self.bg)]
884
+ for r in range(r1 + 1, r2):
885
+ out.append(self._put(r, c1, v + ' ' * inner + v, self.fg, self.bg))
886
+ out.append(self._put(r2, c1, bl + h * inner + br, self.fg, self.bg))
887
+ self._w(''.join(out))
888
+
889
+ def shadow(self, r1, c1, r2, c2):
890
+ """Darkens what is under the box's drop shadow: two columns to its right and the row
891
+ below it, offset by one row and two columns, as a light falls from the top left."""
892
+ out = []
893
+ for r in range(r1 + 1, r2 + 2):
894
+ first = c2 + 1 if r <= r2 else c1 + 2
895
+ for c in range(first, c2 + 3):
896
+ if 1 <= r <= self.rows and 1 <= c <= self.cols:
897
+ ch = self.cells[r - 1][c - 1][0]
898
+ out.append(self._put(r, c, ch, 8, 0))
899
+ self._w(''.join(out) + self._normal())
900
+
901
+ def popup(self, r1, c1, r2, c2, title='', double=False, shadow=None):
902
+ """Draws a pop-up box, with a shadow unless SET SHADOW OFF, and returns what it
903
+ covered (shadow included) for restore_saved."""
904
+ shadow = self.shadows if shadow is None else shadow
905
+ under = self.save(r1, c1, min(r2 + (1 if shadow else 0), self.rows), min(c2 + (2 if shadow else 0), self.cols))
906
+ self.box(r1, c1, r2, c2, title, double)
907
+ if shadow:
908
+ self.shadow(r1, c1, r2, c2)
909
+ return under
910
+
911
+ # --- PROGRESS ------------------------------------------------------
912
+ def progress(self, done, total, title=''):
913
+ """PROGRESS: a centred box with a bar and a percentage, drawn when first used and
914
+ updated in place (only when what it shows changes, so a tight loop stays fast)."""
915
+ fraction = 0.0 if not total else max(0.0, min(1.0, done / total))
916
+ cursor = (self.cur_row, self.cur_col) # PRINT carries on where it was
917
+ if self.progress_box is None:
918
+ inner = max(20, min(50, self.cols - 6))
919
+ top = max(1, (self.rows - 5) // 2 + 1)
920
+ left = max(1, (self.cols - inner - 2) // 2 + 1)
921
+ area = (top, left, top + 4, left + inner + 1)
922
+ self.progress_box = [area, self.popup(*area, title=title), None]
923
+ area, under, last = self.progress_box
924
+ top, left, bottom, right = area
925
+ width = right - left - 8 # room for ' 100% '
926
+ filled = int(fraction * width + 1e-9)
927
+ shown = (filled, int(fraction * 100 + 1e-9), title)
928
+ if shown != last:
929
+ self.progress_box[2] = shown
930
+ if last and last[2] != title:
931
+ self.box(top, left, bottom, right, title)
932
+ bar = ' ' + '█' * filled + '░' * (width - filled) + f'{shown[1]:4d}%'
933
+ self._w(self._put(top + 2, left + 1, bar.ljust(right - left - 1), self.fg, self.bg) + self._normal())
934
+ self.cur_row, self.cur_col = cursor
935
+ self._w(f'\x1b[{cursor[0]};{cursor[1]}H')
936
+
937
+ def progress_off(self):
938
+ """PROGRESS OFF: removes the box and puts back what it covered."""
939
+ if self.progress_box:
940
+ row, col = self.cur_row, self.cur_col
941
+ self.restore_saved(self.progress_box[1])
942
+ self.progress_box = None
943
+ self.cur_row, self.cur_col = row, col
944
+ self._w(f'\x1b[{row};{col}H')
945
+
946
+ # --- GET / READ ----------------------------------------------------
947
+ def add_get(self, row, col, var_name, value, picture, valid, numeric, target=None,
948
+ message=None, when=None, lookup=None, kind=None, items=None, hint=None):
949
+ if kind == 'check':
950
+ field = CheckBox(row, col, var_name, value, valid, isinstance(value, str) or var_name.endswith('$'))
951
+ elif kind == 'list':
952
+ field = ListField(row, col, var_name, value, valid, items or [])
953
+ else:
954
+ field = Field(row, col, var_name, value, picture, valid, numeric)
955
+ field.target = target # where READ stores the value (the interpreter's description)
956
+ field.message, field.when, field.lookup = message, when, lookup
957
+ field.hint = hint
958
+ self.gets.append(field)
959
+ self._draw_field(field)
960
+ return field
961
+
962
+ def add_button(self, row, col, label, cancel=False):
963
+ button = Button(row, col, label, cancel)
964
+ self.gets.append(button)
965
+ self._draw_button(button, False)
966
+ return button
967
+
968
+ def _draw_button(self, button, focused):
969
+ fg, bg = (self.field_fg, self.field_bg) if focused else (self.fg, self.bg)
970
+ self._w(self._put(button.row, button.col, f'[ {button.label} ]', fg, bg) + self._normal())
971
+
972
+ def _draw_field(self, field, cursor=None):
973
+ out = self._put(field.row, field.col, ''.join(field.buffer), self.field_fg, self.field_bg)
974
+ if cursor is not None:
975
+ out += f'\x1b[{field.row};{field.col + cursor}H'
976
+ self._w(out + ('' if cursor is not None else self._normal()))
977
+
978
+ def read(self, assign, check_valid, current_values, check_when=None, lookup=None):
979
+ """Edits the pending GET fields. assign(field, value) stores a field's value,
980
+ check_valid(field) evaluates its VALID condition, check_when(field) its WHEN
981
+ condition, and current_values(fields) returns their values. lookup(field) returns
982
+ (columns, rows) for a field with LOOKUP. Esc puts back the values the fields had
983
+ when READ started. Returns the key that ended it."""
984
+ fields, self.gets = self.gets, []
985
+ self.last_button = ''
986
+ if not fields:
987
+ return ''
988
+ data_fields = [f for f in fields if not getattr(f, 'is_button', False)]
989
+ saved = current_values(data_fields)
990
+
991
+ def put_back():
992
+ """Esc or a CANCEL button: every field gets the value it had when READ started."""
993
+ for f, value in zip(data_fields, saved):
994
+ assign(f, value)
995
+ f.buffer = f._initial_buffer(value)
996
+ self._draw_field(f)
997
+
998
+ def allowed(i):
999
+ f = fields[i]
1000
+ return getattr(f, 'when', None) is None or check_when is None or check_when(f)
1001
+
1002
+ def step(i, direction):
1003
+ """The next field in a direction whose WHEN allows it, or None."""
1004
+ i += direction
1005
+ while 0 <= i < len(fields):
1006
+ if allowed(i):
1007
+ return i
1008
+ i += direction
1009
+ return None
1010
+
1011
+ def hit():
1012
+ """The field or button under the mouse, or None."""
1013
+ r, c = self.keys.mouse_row, self.keys.mouse_col
1014
+ for j, f in enumerate(fields):
1015
+ if f.row == r and f.col <= c < f.col + f.width and allowed(j):
1016
+ return j
1017
+ return None
1018
+
1019
+ def click_pos(f):
1020
+ """The cursor position for a click inside a field (an editable position)."""
1021
+ if getattr(f, 'is_button', False):
1022
+ return 0
1023
+ p = max(0, min(self.keys.mouse_col - f.col, f.width - 1))
1024
+ while p < f.width and not f._editable(p):
1025
+ p += 1
1026
+ return p if p < f.width else f.first_pos()
1027
+
1028
+ index = step(-1, 1)
1029
+ if index is None: # every field is switched off by WHEN
1030
+ self.last_key = ENTER
1031
+ return ENTER
1032
+ insert = True
1033
+ pos = fields[index].first_pos()
1034
+ fresh = True # the first key typed into a numeric field replaces its value
1035
+ message_under = None
1036
+ # HINT: the current field's hint is shown on the bottom line; what was there comes back
1037
+ # for a field without one, and when READ ends
1038
+ hint_under = self.save(self.rows, 1, self.rows, self.cols) \
1039
+ if any(getattr(f, 'hint', None) for f in fields) else None
1040
+ hint_shown = None
1041
+ self._w('\x1b[?25h')
1042
+ try:
1043
+ while True:
1044
+ field = fields[index]
1045
+ if hint_under and getattr(field, 'hint', None) != hint_shown:
1046
+ hint_shown = getattr(field, 'hint', None)
1047
+ if hint_shown:
1048
+ self._w(self._put(self.rows, 1, (' ' + hint_shown)[:self.cols].ljust(self.cols),
1049
+ self.fg, self.bg) + self._normal())
1050
+ else:
1051
+ self.restore_saved(hint_under)
1052
+ if getattr(field, 'is_button', False):
1053
+ # A button: Enter or Space presses it; Tab, arrows and Shift-Tab move on
1054
+ self._w('\x1b[?25l')
1055
+ self._draw_button(field, True)
1056
+ key = self.keys.read_key()
1057
+ if key is None:
1058
+ key = ESC
1059
+ if message_under:
1060
+ self.restore_saved(message_under)
1061
+ message_under = None
1062
+ self.last_key = key
1063
+ self._draw_button(field, False)
1064
+ self._w('\x1b[?25h')
1065
+ if key == 'CLICK':
1066
+ j = hit()
1067
+ if j is None:
1068
+ continue
1069
+ if j != index: # another field or button: go there (and press a button)
1070
+ index, pos, fresh = j, click_pos(fields[j]), True
1071
+ if getattr(fields[j], 'is_button', False):
1072
+ self.keys.pending.insert(0, '\r')
1073
+ elif getattr(fields[j], 'is_check', False) or getattr(fields[j], 'is_list', False):
1074
+ self.keys.pending.insert(0, ' ') # toggle the box / open the list
1075
+ continue
1076
+ key = ENTER # a click on this button presses it
1077
+ if key == ESC or (key in (ENTER, ' ') and field.cancel):
1078
+ put_back()
1079
+ if key != ESC:
1080
+ self.last_button, self.last_key = field.label, ENTER
1081
+ return self.last_key
1082
+ if key in (ENTER, ' '):
1083
+ self.last_button, self.last_key = field.label, ENTER
1084
+ return ENTER
1085
+ if key in (TAB, 'DOWN', 'RIGHT'):
1086
+ nxt = step(index, 1)
1087
+ index = nxt if nxt is not None else step(-1, 1) # wrap round to the first field
1088
+ elif key in ('BTAB', 'UP', 'LEFT'):
1089
+ nxt = step(index, -1)
1090
+ index = nxt if nxt is not None else index
1091
+ elif len(key) == 1 and key.isprintable():
1092
+ bell()
1093
+ pos = fields[index].first_pos()
1094
+ fresh = True
1095
+ continue
1096
+ self._draw_field(field, pos)
1097
+ key = self.keys.read_key()
1098
+ if key is None: # input ended: give up as if Esc was pressed
1099
+ key = ESC
1100
+ if message_under: # a VALID message stays up until the next key
1101
+ self.restore_saved(message_under)
1102
+ message_under = None
1103
+ self.last_key = key
1104
+ if key == ESC:
1105
+ put_back()
1106
+ return key
1107
+ click_target = None
1108
+ if key == 'CLICK':
1109
+ j = hit()
1110
+ if j is None:
1111
+ continue
1112
+ if j == index: # inside this field: move the cursor
1113
+ if getattr(field, 'is_check', False) or getattr(field, 'is_list', False):
1114
+ key = ' ' # a click toggles a check box or opens a list
1115
+ else:
1116
+ pos = click_pos(field)
1117
+ continue
1118
+ else:
1119
+ click_target, key = j, TAB # leave this field with the usual checks first
1120
+ if getattr(field, 'is_list', False) and key in (' ', 'F2'):
1121
+ self._choose_from_list(field)
1122
+ continue
1123
+ if key == 'F2' and getattr(field, 'lookup', None) is not None and lookup:
1124
+ if self._pick(field, lookup) is not None:
1125
+ pos = field.first_pos()
1126
+ continue
1127
+ if key in (ENTER, TAB, 'DOWN', 'UP', 'BTAB', 'PGDN', 'PGUP', '\x17'):
1128
+ try:
1129
+ value = field.value()
1130
+ except ValueError:
1131
+ bell()
1132
+ if getattr(field, 'is_date', False):
1133
+ message_under = self._show_message("Not a valid date: type it as YYYY-MM-DD")
1134
+ continue
1135
+ if getattr(field, 'lookup', None) is not None and lookup and str(value).strip() \
1136
+ and not self._in_lookup(field, value, lookup):
1137
+ # Not an allowed value: offer the list, starting at the closest match
1138
+ if self._pick(field, lookup, value) is None:
1139
+ bell()
1140
+ continue
1141
+ value = field.value()
1142
+ assign(field, value)
1143
+ if getattr(field, 'calc', False):
1144
+ field.buffer = field._initial_buffer(value) # show it formatted with the mask
1145
+ if field.valid is not None and not check_valid(field):
1146
+ bell()
1147
+ if getattr(field, 'message', None):
1148
+ message_under = self._show_message(field.message)
1149
+ continue
1150
+ self._draw_field(field)
1151
+ if key in ('PGDN', 'PGUP', '\x17'):
1152
+ return key
1153
+ if click_target is not None:
1154
+ index, pos, fresh = click_target, click_pos(fields[click_target]), True
1155
+ if getattr(fields[index], 'is_button', False):
1156
+ self.keys.pending.insert(0, '\r') # a click on a button presses it
1157
+ elif getattr(fields[index], 'is_check', False) or getattr(fields[index], 'is_list', False):
1158
+ self.keys.pending.insert(0, ' ') # toggle the box / open the list
1159
+ continue
1160
+ nxt = step(index, -1 if key in ('UP', 'BTAB') else 1)
1161
+ if nxt is None:
1162
+ if key in (ENTER, TAB, 'DOWN'):
1163
+ return key # past the last field
1164
+ nxt = index # already at the first field
1165
+ index = nxt
1166
+ pos = fields[index].first_pos()
1167
+ fresh = True
1168
+ continue
1169
+ if key == 'INS':
1170
+ insert = not insert
1171
+ continue
1172
+ if fresh and (field.numeric or field.clear_first) and len(key) == 1 and key.isprintable():
1173
+ field.clear() # the first key typed replaces the old value
1174
+ pos = field.first_pos()
1175
+ fresh = False
1176
+ pos = field.edit_key(key, pos, insert)
1177
+ if self.auto_advance and getattr(field, 'filled', False):
1178
+ # The field is full: go on as if Enter had been pressed
1179
+ self.keys.pending.insert(0, ENTER)
1180
+ finally:
1181
+ if message_under:
1182
+ self.restore_saved(message_under)
1183
+ if hint_under and hint_shown:
1184
+ self.restore_saved(hint_under)
1185
+ self._w(self._normal())
1186
+
1187
+ def _choose_from_list(self, field):
1188
+ """Opens a LIST field's drop-down under it; the chosen item becomes the value."""
1189
+ items = field.items
1190
+ if len(items) <= 10 and field.row + len(items) + 2 <= self.rows:
1191
+ n = self.menu(field.row + 1, field.col, items)
1192
+ if n:
1193
+ field.set(items[n - 1])
1194
+ else:
1195
+ self._pick(field, lambda f: (['Choose'], [[i] for i in f.items]), field.current)
1196
+ self._draw_field(field)
1197
+
1198
+ def _show_message(self, text):
1199
+ """Shows a message on the bottom line; returns what it covered, for restore_saved."""
1200
+ under = self.save(self.rows, 1, self.rows, self.cols)
1201
+ self._w(self._put(self.rows, 1, (' ' + str(text))[:self.cols].ljust(self.cols), self.field_fg, self.field_bg)
1202
+ + self._normal())
1203
+ return under
1204
+
1205
+ @staticmethod
1206
+ def _matches(a, b):
1207
+ return str(a).strip().upper() == str(b).strip().upper()
1208
+
1209
+ def _in_lookup(self, field, value, lookup):
1210
+ columns, rows = lookup(field)
1211
+ if isinstance(value, float) and value == int(value):
1212
+ value = int(value)
1213
+ return any(self._matches(row[0], value) for row in rows)
1214
+
1215
+ def _pick(self, field, lookup, typed=''):
1216
+ """Opens the LOOKUP list under the field. Fills the field with the first column of
1217
+ the chosen row and returns that value, or returns None for Esc."""
1218
+ columns, rows = lookup(field)
1219
+ if not rows:
1220
+ bell()
1221
+ return None
1222
+ typed = str(typed).strip().upper()
1223
+ start = next((i for i, r in enumerate(rows) if typed and str(r[0]).upper().startswith(typed)), 0)
1224
+ width = min(self.cols, max(24, 4 + sum(min(30, max(len(str(c)), max(len(str(r[i])) for r in rows))) + 3
1225
+ for i, c in enumerate(columns))))
1226
+ height = min(len(rows), 10) + 5
1227
+ top = field.row + 1 if field.row + height <= self.rows else max(1, field.row - height)
1228
+ left = max(1, min(field.col, self.cols - width + 1))
1229
+ chosen = self.browse(columns, [list(r) for r in rows], (top, left, top + height - 1, left + width - 1),
1230
+ 'Choose', start=start, shadow=True)
1231
+ if chosen < 0:
1232
+ return None
1233
+ value = rows[chosen][0]
1234
+ if isinstance(value, float) and value == int(value):
1235
+ value = int(value)
1236
+ field.buffer = field._initial_buffer('' if value is None else value)
1237
+ self._draw_field(field)
1238
+ return value
1239
+
1240
+ # --- EDIT TEXT (memo editor) ----------------------------------------
1241
+ def edit_text(self, text, area=None, title=''):
1242
+ """A boxed multi-line editor. Returns the edited text (lines joined with CHR$(10)),
1243
+ or None when the user cancels. Ctrl-W or F10 saves; Esc cancels, asking first if
1244
+ anything was changed. Long lines scroll sideways."""
1245
+ r1, c1, r2, c2 = area or (3, 5, max(8, self.rows - 2), max(30, self.cols - 4))
1246
+ if r2 - r1 < 3 or c2 - c1 < 12:
1247
+ raise ScreenError("EDIT TEXT area is too small.")
1248
+ lines = str(text).replace('\r\n', '\n').replace('\r', '\n').split('\n')
1249
+ original = list(lines)
1250
+ height, width = r2 - r1 - 1, c2 - c1 - 1
1251
+ row = col = top = left = 0
1252
+ insert = True
1253
+ underneath = self.save(r1, c1, r2, c2)
1254
+ self.box(r1, c1, r2, c2, title)
1255
+ self._w('\x1b[?25h')
1256
+
1257
+ def draw():
1258
+ out = []
1259
+ for n in range(height):
1260
+ i = top + n
1261
+ piece = lines[i][left:left + width] if i < len(lines) else ''
1262
+ out.append(self._put(r1 + 1 + n, c1 + 1, piece.ljust(width), self.fg, self.bg))
1263
+ status = f' Ln {row + 1}, Col {col + 1} Ctrl-W=save Esc=cancel '
1264
+ border = '─' * (c2 - c1 - 1)
1265
+ if len(status) <= len(border) - 2:
1266
+ border = border[:1] + status + border[1 + len(status):]
1267
+ out.append(self._put(r2, c1 + 1, border, self.fg, self.bg))
1268
+ out.append(self._normal() + f'\x1b[{r1 + 1 + row - top};{c1 + 1 + col - left}H')
1269
+ self._w(''.join(out))
1270
+
1271
+ try:
1272
+ while True:
1273
+ # keep the cursor in view
1274
+ top = min(max(top, row - height + 1), row)
1275
+ left = min(max(left, col - width + 1), col)
1276
+ draw()
1277
+ key = self.keys.read_key()
1278
+ if key is None:
1279
+ key = ESC
1280
+ self.last_key = key
1281
+ if key == 'CLICK': # put the cursor where the click was
1282
+ r, c = self.keys.mouse_row, self.keys.mouse_col
1283
+ if r1 < r < r2 and c1 < c < c2:
1284
+ row = min(top + r - r1 - 1, len(lines) - 1)
1285
+ col = min(left + c - c1 - 1, len(lines[row]))
1286
+ continue
1287
+ if key in ('WHEELDOWN', 'WHEELUP'):
1288
+ row = max(0, min(len(lines) - 1, row + (3 if key == 'WHEELDOWN' else -3)))
1289
+ col = min(col, len(lines[row]))
1290
+ continue
1291
+ line = lines[row]
1292
+ if key in ('\x17', 'F10'): # Ctrl-W / F10: save
1293
+ return '\n'.join(lines)
1294
+ if key == ESC:
1295
+ if lines == original or self.alert("Discard your changes?", ["No", "Yes"]) == 2:
1296
+ self.last_key = ESC
1297
+ return None
1298
+ self._w('\x1b[?25h')
1299
+ continue
1300
+ if key == ENTER:
1301
+ lines[row:row + 1] = [line[:col], line[col:]]
1302
+ row, col = row + 1, 0
1303
+ elif key == BACKSPACE:
1304
+ if col > 0:
1305
+ lines[row] = line[:col - 1] + line[col:]
1306
+ col -= 1
1307
+ elif row > 0:
1308
+ col = len(lines[row - 1])
1309
+ lines[row - 1:row + 1] = [lines[row - 1] + line]
1310
+ row -= 1
1311
+ elif key == 'DEL':
1312
+ if col < len(line):
1313
+ lines[row] = line[:col] + line[col + 1:]
1314
+ elif row < len(lines) - 1:
1315
+ lines[row:row + 2] = [line + lines[row + 1]]
1316
+ elif key == '\x19': # Ctrl-Y: delete the line
1317
+ if len(lines) > 1:
1318
+ del lines[row]
1319
+ row = min(row, len(lines) - 1)
1320
+ else:
1321
+ lines[0] = ''
1322
+ col = min(col, len(lines[row]))
1323
+ elif key == TAB:
1324
+ spaces = 4 - col % 4
1325
+ lines[row] = line[:col] + ' ' * spaces + line[col:]
1326
+ col += spaces
1327
+ elif key == 'INS':
1328
+ insert = not insert
1329
+ elif key == 'LEFT':
1330
+ if col > 0:
1331
+ col -= 1
1332
+ elif row > 0:
1333
+ row -= 1
1334
+ col = len(lines[row])
1335
+ elif key == 'RIGHT':
1336
+ if col < len(line):
1337
+ col += 1
1338
+ elif row < len(lines) - 1:
1339
+ row, col = row + 1, 0
1340
+ elif key in ('UP', 'DOWN', 'PGUP', 'PGDN'):
1341
+ step = {'UP': -1, 'DOWN': 1, 'PGUP': -height, 'PGDN': height}[key]
1342
+ row = max(0, min(len(lines) - 1, row + step))
1343
+ col = min(col, len(lines[row]))
1344
+ elif key == 'HOME':
1345
+ col = 0
1346
+ elif key == 'END':
1347
+ col = len(line)
1348
+ elif len(key) == 1 and key.isprintable():
1349
+ if insert or col >= len(line):
1350
+ lines[row] = line[:col] + key + line[col:]
1351
+ else:
1352
+ lines[row] = line[:col] + key + line[col + 1:]
1353
+ col += 1
1354
+ finally:
1355
+ self.restore_saved(underneath)
1356
+ self._w('\x1b[?25h')
1357
+
1358
+ # --- VIEW (text viewer) --------------------------------------------
1359
+ def view(self, text, area=None, title=''):
1360
+ """A read-only, scrolling box for text. Arrows, PgUp/PgDn and Home/End move; '/' or
1361
+ Ctrl-F searches (ignoring case) and 'n' or F3 finds the next match; Esc or 'q' closes."""
1362
+ r1, c1, r2, c2 = area or (1, 1, self.rows, self.cols)
1363
+ if r2 - r1 < 3 or c2 - c1 < 12:
1364
+ raise ScreenError("VIEW area is too small.")
1365
+ lines = str(text).replace('\r\n', '\n').replace('\r', '\n').split('\n')
1366
+ if len(lines) > 1 and lines[-1] == '':
1367
+ lines.pop() # a final newline doesn't make an extra line
1368
+ lines = [line.expandtabs(8) for line in lines]
1369
+ height, width = r2 - r1 - 1, c2 - c1 - 1
1370
+ widest = max((len(line) for line in lines), default=0)
1371
+ top = left = 0
1372
+ found, search = None, ''
1373
+ message = ''
1374
+ underneath = self.save(r1, c1, r2, c2)
1375
+ self.box(r1, c1, r2, c2, title)
1376
+ self._w('\x1b[?25l')
1377
+
1378
+ def find(start):
1379
+ needle = search.lower()
1380
+ for k in range(len(lines)):
1381
+ i = (start + k) % len(lines)
1382
+ if needle in lines[i].lower():
1383
+ return i
1384
+ return None
1385
+
1386
+ def prompt(label):
1387
+ """Reads a line of text on the status line; returns it, or None for Esc."""
1388
+ typed = ''
1389
+ while True:
1390
+ status = f' {label}{typed}_'
1391
+ self._w(self._put(r2, c1 + 1, status[:width].ljust(width, '─'), self.field_fg, self.field_bg) + self._normal())
1392
+ key = self.keys.read_key()
1393
+ if key is None or key == ESC:
1394
+ return None
1395
+ if key == ENTER:
1396
+ return typed
1397
+ if key == BACKSPACE:
1398
+ typed = typed[:-1]
1399
+ elif len(key) == 1 and key.isprintable():
1400
+ typed += key
1401
+
1402
+ try:
1403
+ while True:
1404
+ top = max(0, min(top, len(lines) - height))
1405
+ left = max(0, min(left, max(0, widest - width)))
1406
+ out = []
1407
+ for n in range(height):
1408
+ i = top + n
1409
+ piece = lines[i][left:left + width] if i < len(lines) else ''
1410
+ fg, bg = (self.field_fg, self.field_bg) if i == found else (self.fg, self.bg)
1411
+ out.append(self._put(r1 + 1 + n, c1 + 1, piece.ljust(width), fg, bg))
1412
+ last = min(len(lines), top + height)
1413
+ status = message or f' {top + 1}-{last} of {len(lines)} /=find n=next Esc=close '
1414
+ message = ''
1415
+ border = '─' * width
1416
+ status = status[:width - 2]
1417
+ border = border[:1] + status + border[1 + len(status):]
1418
+ out.append(self._put(r2, c1 + 1, border, self.fg, self.bg))
1419
+ self._w(''.join(out) + self._normal())
1420
+ key = self.keys.read_key()
1421
+ if key is None:
1422
+ key = ESC
1423
+ self.last_key = key
1424
+ if key in (ESC, 'q', 'Q'):
1425
+ return
1426
+ if key in ('WHEELDOWN', 'WHEELUP'):
1427
+ top += 3 if key == 'WHEELDOWN' else -3
1428
+ continue
1429
+ if key in ('DOWN', ENTER): top += 1
1430
+ elif key == 'UP': top -= 1
1431
+ elif key in ('PGDN', ' '): top += height
1432
+ elif key == 'PGUP': top -= height
1433
+ elif key == 'HOME': top, left = 0, 0
1434
+ elif key == 'END': top = len(lines)
1435
+ elif key == 'RIGHT': left += 8
1436
+ elif key == 'LEFT': left -= 8
1437
+ elif key in ('/', '\x06'): # '/' or Ctrl-F: search
1438
+ typed = prompt('Find: ')
1439
+ if typed:
1440
+ search = typed
1441
+ found = find(top)
1442
+ if found is None:
1443
+ bell()
1444
+ message = f' Not found: {search} '
1445
+ else:
1446
+ top = found if not (top <= found < top + height) else top
1447
+ elif key in ('n', 'N', 'F3') and search:
1448
+ nxt = find((found if found is not None else top) + 1)
1449
+ if nxt is None:
1450
+ bell()
1451
+ message = f' Not found: {search} '
1452
+ else:
1453
+ found = nxt
1454
+ if not (top <= found < top + height):
1455
+ top = found
1456
+ finally:
1457
+ self.restore_saved(underneath)
1458
+ self._w('\x1b[?25h')
1459
+
1460
+ # --- MENUBAR -------------------------------------------------------
1461
+ def menubar(self, menus, row=1, leave_easily=False):
1462
+ """A menu bar across the screen with pull-down menus. menus is a list of
1463
+ (title, [items]); an item '-' is a separator. Returns "Title.Item", "Title" for
1464
+ a title with no items, or "" for Esc. The bar stays drawn; the pull-downs don't."""
1465
+ if not menus:
1466
+ raise ScreenError("MENUBAR needs at least one menu.")
1467
+ positions, x = [], 2
1468
+ for title, _ in menus:
1469
+ positions.append(x)
1470
+ x += len(title) + 3
1471
+ current, opened, item, first = 0, False, 0, 0 # first: the top item of a scrolled pull-down
1472
+ underneath = None
1473
+ cursor = (self.cur_row, self.cur_col) # put back afterwards, so PRINT carries on where it was
1474
+
1475
+ def selectable(items, i):
1476
+ return items[i].strip() != '-'
1477
+
1478
+ def first_item(items):
1479
+ return next((i for i in range(len(items)) if selectable(items, i)), 0)
1480
+
1481
+ def draw_bar():
1482
+ out = [self._put(row, 1, ' ' * self.cols, self.field_fg, self.field_bg)]
1483
+ for n, (title, _) in enumerate(menus):
1484
+ fg, bg = (self.fg, self.bg) if n == current else (self.field_fg, self.field_bg)
1485
+ out.append(self._put(row, positions[n] - 1, f' {title} ', fg, bg))
1486
+ self._w(''.join(out) + self._normal())
1487
+
1488
+ def dropdown_area(n):
1489
+ items = menus[n][1]
1490
+ width = max(len(i) for i in items) + 2
1491
+ left = max(1, min(positions[n] - 1, self.cols - width - 1))
1492
+ height = max(1, min(len(items), self.rows - row - 2 - (1 if self.shadows else 0)))
1493
+ return row + 1, left, row + 2 + height, left + width + 1
1494
+
1495
+ def draw_dropdown():
1496
+ nonlocal first
1497
+ items = menus[current][1]
1498
+ top, left, bottom, right = dropdown_area(current)
1499
+ height = bottom - top - 1
1500
+ first = min(max(first, item - height + 1), item)
1501
+ self.box(top, left, bottom, right)
1502
+ if self.shadows:
1503
+ self.shadow(top, left, bottom, right)
1504
+ width = right - left - 1
1505
+ out = []
1506
+ for n in range(first, first + height):
1507
+ text = items[n]
1508
+ if not selectable(items, n):
1509
+ out.append(self._put(top + 1 + n - first, left, '├' + '─' * width + '┤', self.fg, self.bg))
1510
+ continue
1511
+ fg, bg = (self.field_fg, self.field_bg) if n == item else (self.fg, self.bg)
1512
+ out.append(self._put(top + 1 + n - first, left + 1, (' ' + text).ljust(width), fg, bg))
1513
+ if height < len(items): # arrows on the border show there is more
1514
+ out.append(self._put(top, right - 1, '▲' if first > 0 else '─', self.fg, self.bg))
1515
+ out.append(self._put(bottom, right - 1, '▼' if first + height < len(items) else '─', self.fg, self.bg))
1516
+ self._w(''.join(out) + self._normal())
1517
+
1518
+ def open_menu():
1519
+ nonlocal opened, item, underneath, first
1520
+ close_menu()
1521
+ if menus[current][1]:
1522
+ top, left, bottom, right = dropdown_area(current)
1523
+ shade = 1 if self.shadows else 0
1524
+ underneath = self.save(top, left, min(bottom + shade, self.rows), min(right + 2 * shade, self.cols))
1525
+ opened, item, first = True, first_item(menus[current][1]), 0
1526
+ else:
1527
+ opened = False
1528
+
1529
+ def close_menu():
1530
+ nonlocal opened, underneath
1531
+ if underneath:
1532
+ self.restore_saved(underneath)
1533
+ underneath = None
1534
+ opened = False
1535
+
1536
+ def move(items, i, direction):
1537
+ for _ in range(len(items)):
1538
+ i = (i + direction) % len(items)
1539
+ if selectable(items, i):
1540
+ return i
1541
+ return i
1542
+
1543
+ self._w('\x1b[?25l')
1544
+ try:
1545
+ while True:
1546
+ draw_bar()
1547
+ if opened:
1548
+ draw_dropdown()
1549
+ key = self.keys.read_key()
1550
+ if key is None:
1551
+ key = ESC
1552
+ self.last_key = key
1553
+ if key == 'CLICK':
1554
+ r, c = self.keys.mouse_row, self.keys.mouse_col
1555
+ if r == row: # on the bar: open (or close) that menu
1556
+ for n, (title_n, items_n) in enumerate(menus):
1557
+ if positions[n] - 1 <= c < positions[n] + len(title_n) + 1:
1558
+ if not items_n:
1559
+ return title_n
1560
+ if opened and n == current:
1561
+ close_menu()
1562
+ else:
1563
+ current = n
1564
+ open_menu()
1565
+ break
1566
+ continue
1567
+ if opened:
1568
+ top, left, bottom, right = dropdown_area(current)
1569
+ if top < r < bottom and left < c < right:
1570
+ k = r - top - 1 + first
1571
+ if selectable(menus[current][1], k):
1572
+ return f'{menus[current][0]}.{menus[current][1][k].strip()}'
1573
+ continue
1574
+ close_menu() # a click elsewhere closes the pull-down
1575
+ if leave_easily:
1576
+ return '' # (the IDE: and gives the keys back)
1577
+ continue
1578
+ if key in ('WHEELDOWN', 'WHEELUP'):
1579
+ if not opened:
1580
+ continue
1581
+ key = 'DOWN' if key == 'WHEELDOWN' else 'UP'
1582
+ title, items = menus[current]
1583
+ if not opened:
1584
+ if key == ESC:
1585
+ return ''
1586
+ if key in ('RIGHT', TAB):
1587
+ current = (current + 1) % len(menus)
1588
+ elif key in ('LEFT', 'BTAB'):
1589
+ current = (current - 1) % len(menus)
1590
+ elif key in (ENTER, 'DOWN'):
1591
+ if not items:
1592
+ return title
1593
+ open_menu()
1594
+ elif len(key) == 1:
1595
+ hits = [n for n, (t, _) in enumerate(menus) if t.strip()[:1].upper() == key.upper()]
1596
+ if hits:
1597
+ current = hits[0]
1598
+ if not menus[current][1]:
1599
+ return menus[current][0]
1600
+ open_menu()
1601
+ continue
1602
+ if key == ESC:
1603
+ close_menu()
1604
+ if leave_easily:
1605
+ return '' # the IDE: Esc goes straight back to the editor
1606
+ elif key == 'DOWN':
1607
+ item = move(items, item, 1)
1608
+ elif key == 'UP':
1609
+ item = move(items, item, -1)
1610
+ elif key == 'HOME':
1611
+ item = first_item(items)
1612
+ elif key == 'END':
1613
+ item = move(items, 0, -1)
1614
+ elif key in ('RIGHT', 'LEFT', TAB, 'BTAB'):
1615
+ current = (current + (1 if key in ('RIGHT', TAB) else -1)) % len(menus)
1616
+ open_menu()
1617
+ elif key == ENTER:
1618
+ return f'{title}.{items[item].strip()}'
1619
+ elif len(key) == 1:
1620
+ hits = [n for n, text in enumerate(items)
1621
+ if selectable(items, n) and text.strip()[:1].upper() == key.upper()]
1622
+ if hits:
1623
+ return f'{title}.{items[hits[0]].strip()}'
1624
+ finally:
1625
+ close_menu()
1626
+ draw_bar_final = [self._put(row, 1, ' ' * self.cols, self.field_fg, self.field_bg)]
1627
+ for n, (title, _) in enumerate(menus):
1628
+ draw_bar_final.append(self._put(row, positions[n] - 1, f' {title} ', self.field_fg, self.field_bg))
1629
+ self.cur_row, self.cur_col = cursor
1630
+ self._w(''.join(draw_bar_final) + self._normal() + f'\x1b[{cursor[0]};{cursor[1]}H' + '\x1b[?25h')
1631
+
1632
+ # --- ALERT ---------------------------------------------------------
1633
+ def alert(self, message, buttons):
1634
+ """A centred message box with buttons. Returns the chosen button (from 1), or 0 for Esc.
1635
+ A ';' in the message starts a new line."""
1636
+ buttons = [str(b) for b in buttons] or ['OK']
1637
+ lines = str(message).split(';')
1638
+ labels = [f' {b} ' for b in buttons]
1639
+ bar = ' '.join(labels)
1640
+ inner = min(self.cols - 2, max(max(len(line) for line in lines), len(bar), 16) + 4)
1641
+ height = len(lines) + 4
1642
+ top = max(1, (self.rows - height) // 2 + 1)
1643
+ left = max(1, (self.cols - inner - 2) // 2 + 1)
1644
+ bottom, right = top + height - 1, left + inner + 1
1645
+ underneath = self.popup(top, left, bottom, right, double=True)
1646
+ for n, line in enumerate(lines):
1647
+ self._w(self._put(top + 1 + n, left + 1, line.strip()[:inner].center(inner), self.fg, self.bg))
1648
+ choice = 0
1649
+ self._w('\x1b[?25l')
1650
+ try:
1651
+ while True:
1652
+ col = left + 1 + (inner - len(bar)) // 2
1653
+ out, spans = [], []
1654
+ for n, label in enumerate(labels):
1655
+ fg, bg = (self.field_fg, self.field_bg) if n == choice else (self.fg, self.bg)
1656
+ out.append(self._put(bottom - 1, col, label, fg, bg))
1657
+ spans.append((col, col + len(label)))
1658
+ col += len(label) + 2
1659
+ self._w(''.join(out) + self._normal())
1660
+ key = self.keys.read_key()
1661
+ if key is None:
1662
+ key = ESC
1663
+ self.last_key = key
1664
+ if key == 'CLICK':
1665
+ if self.keys.mouse_row == bottom - 1:
1666
+ for n, (start, end) in enumerate(spans):
1667
+ if start <= self.keys.mouse_col < end:
1668
+ return n + 1 # the button clicked
1669
+ continue
1670
+ if key == ESC:
1671
+ return 0
1672
+ if key == ENTER:
1673
+ return choice + 1
1674
+ if key in ('RIGHT', TAB, 'DOWN'):
1675
+ choice = (choice + 1) % len(labels)
1676
+ elif key in ('LEFT', 'BTAB', 'UP'):
1677
+ choice = (choice - 1) % len(labels)
1678
+ elif len(key) == 1:
1679
+ hits = [n for n, b in enumerate(buttons) if b.strip()[:1].upper() == key.upper()]
1680
+ if hits:
1681
+ return hits[0] + 1
1682
+ finally:
1683
+ self.restore_saved(underneath)
1684
+ self._w('\x1b[?25h')
1685
+
1686
+ # --- MENU ----------------------------------------------------------
1687
+ def menu(self, row, col, items):
1688
+ """A boxed light-bar menu. Returns the chosen item number (1-based), or 0 for Esc."""
1689
+ if not items:
1690
+ raise ScreenError("MENU needs at least one item.")
1691
+ items = [str(i) for i in items]
1692
+ width = max(len(i) for i in items) + 2
1693
+ # A menu taller than the room below it moves up; one taller than the screen scrolls
1694
+ below = 1 if self.shadows else 0
1695
+ if row + len(items) + 1 + below > self.rows:
1696
+ row = max(1, self.rows - len(items) - 1 - below)
1697
+ height = max(1, min(len(items), self.rows - row - 1 - below))
1698
+ underneath = self.popup(row, col, row + height + 1, col + width + 1)
1699
+ choice, first = 0, 0
1700
+ self._w('\x1b[?25l')
1701
+ try:
1702
+ while True:
1703
+ first = min(max(first, choice - height + 1), choice)
1704
+ for n in range(height):
1705
+ i = first + n
1706
+ fg, bg = (self.field_fg, self.field_bg) if i == choice else (self.fg, self.bg)
1707
+ self._w(self._put(row + 1 + n, col + 1, ' ' + items[i].ljust(width - 1), fg, bg) + self._normal())
1708
+ if height < len(items): # arrows on the border show there is more
1709
+ self._w(self._put(row, col + width, '▲' if first > 0 else '─', self.fg, self.bg) +
1710
+ self._put(row + height + 1, col + width, '▼' if first + height < len(items) else '─',
1711
+ self.fg, self.bg) + self._normal())
1712
+ key = self.keys.read_key()
1713
+ if key is None:
1714
+ key = ESC
1715
+ self.last_key = key
1716
+ if key == 'CLICK':
1717
+ r, c = self.keys.mouse_row, self.keys.mouse_col
1718
+ if row < r <= row + height and col < c <= col + width:
1719
+ return first + r - row # the item clicked
1720
+ if not (row <= r <= row + height + 1 and col <= c <= col + width + 1):
1721
+ return 0 # a click outside the menu cancels it
1722
+ continue
1723
+ if key == 'WHEELDOWN':
1724
+ key = 'DOWN'
1725
+ elif key == 'WHEELUP':
1726
+ key = 'UP'
1727
+ if key == ESC:
1728
+ return 0
1729
+ if key == ENTER:
1730
+ return choice + 1
1731
+ if key in ('DOWN', 'RIGHT', TAB):
1732
+ choice = (choice + 1) % len(items)
1733
+ elif key in ('UP', 'LEFT', 'BTAB'):
1734
+ choice = (choice - 1) % len(items)
1735
+ elif key == 'HOME':
1736
+ choice = 0
1737
+ elif key == 'END':
1738
+ choice = len(items) - 1
1739
+ elif key == 'PGUP':
1740
+ choice = max(0, choice - height) if height < len(items) else 0
1741
+ elif key == 'PGDN':
1742
+ choice = min(len(items) - 1, choice + height) if height < len(items) else len(items) - 1
1743
+ elif len(key) == 1:
1744
+ hits = [n for n, item in enumerate(items) if item.strip()[:1].upper() == key.upper()]
1745
+ if hits:
1746
+ return hits[0] + 1
1747
+ finally:
1748
+ self.restore_saved(underneath) # put back what the menu covered
1749
+ self._w('\x1b[?25h')
1750
+
1751
+ # --- BROWSE --------------------------------------------------------
1752
+ def browse(self, columns, rows, area=None, title='', save=None, hidden=(), locked=(), add=None, delete=None,
1753
+ start=0, frozen=0, formats=None, color_col=None, shadow=False,
1754
+ on_move=None, switch=False, keep=False, draw_only=False, bar=True, state=None, keys_hint=None,
1755
+ exit_keys=(), can_edit=None):
1756
+ """A scrolling grid. Returns the index of the chosen row, or -1 for Esc.
1757
+
1758
+ With save, the grid is editable: Left/Right move between cells, Enter (or typing)
1759
+ edits the current cell, and save(row_index, col_index, value) stores the new value,
1760
+ returning an error message or None. Columns in hidden are not shown; columns in
1761
+ locked can't be edited. rows is updated in place with the saved values.
1762
+
1763
+ With add, Ins calls add(), which inserts a row, reloads rows in place and returns the
1764
+ index of the new row (or None if it isn't known), or False when cancelled. With delete,
1765
+ Del asks for confirmation and calls delete(row_index), which returns an error or None.
1766
+
1767
+ A click on a heading (or F3) sorts by that column; again reverses it. rows is sorted in
1768
+ place, or, if it has sort_by(col_index, descending, numeric), asked to sort itself.
1769
+ Without save, typing jumps to the next row whose sort column (or first column) starts
1770
+ with what was typed. frozen columns stay at the left when the others scroll. formats
1771
+ maps a column index to a function giving its text (shown right-aligned). color_col is
1772
+ the index of a column holding each row's text colour (0-15).
1773
+
1774
+ For a master-detail screen (two grids taking turns): on_move(index) is called when the
1775
+ current row changes; with switch, F6 (and Tab when not editing) or a click outside the
1776
+ grid ends it with last_key set to that key; keep leaves the grid drawn instead of
1777
+ putting back what it covered; draw_only draws it and returns; bar=False draws no bar;
1778
+ state, a dict, keeps the position and sort between turns. keys_hint replaces
1779
+ "Enter=select Esc=cancel" on the status line; 'back' marks a detail grid, whose Esc
1780
+ goes back to the master. A key in exit_keys (EXIT ON) ends the grid, returning the
1781
+ current row, with last_key set to it, so the program can act on it. can_edit()
1782
+ (EDIT WHEN), asked once as the grid is shown, returns None or why the rows can't be
1783
+ changed now: editing, Ins and Del are then refused with that message."""
1784
+ screen_rows, screen_cols = self.size()
1785
+ r1, c1, r2, c2 = area or (1, 1, screen_rows, screen_cols)
1786
+ if r2 - r1 < 4 or c2 - c1 < 10:
1787
+ raise ScreenError("BROWSE area is too small.")
1788
+ visible = [i for i in range(len(columns)) if i not in hidden]
1789
+ if not visible:
1790
+ raise ScreenError("BROWSE has no columns to show.")
1791
+ formats = formats or {}
1792
+ frozen = max(0, min(frozen, len(visible) - 1))
1793
+
1794
+ def show(v, ci):
1795
+ if v is None:
1796
+ return ''
1797
+ if ci in formats:
1798
+ return formats[ci](v)
1799
+ return format_number(v) if isinstance(v, (int, float)) and not isinstance(v, bool) else str(v)
1800
+
1801
+ # Rows are formatted only when they are shown, so rows can be a lazy, paged list
1802
+ # (the interpreter passes one for large queries). Column widths start from the
1803
+ # first rows and grow when a wider value scrolls into view.
1804
+ class Cells:
1805
+ def __init__(self):
1806
+ self.cache = {}
1807
+
1808
+ def __len__(self):
1809
+ return len(rows)
1810
+
1811
+ def __bool__(self):
1812
+ return len(rows) > 0
1813
+
1814
+ def __getitem__(self, i):
1815
+ if i not in self.cache:
1816
+ self.cache[i] = [show(v, k) for k, v in enumerate(rows[i])]
1817
+ return self.cache[i]
1818
+
1819
+ def pop(self, i):
1820
+ self.cache.clear()
1821
+
1822
+ def widen(i):
1823
+ for k, text in enumerate(cells[i][:len(widths)]):
1824
+ widths[k] = min(30, max(widths[k], len(text)))
1825
+
1826
+ def layout():
1827
+ cells = Cells()
1828
+ widths = [min(30, len(str(c))) for c in columns]
1829
+ for i in range(min(len(rows), 200)):
1830
+ for k, text in enumerate(cells[i][:len(widths)]):
1831
+ widths[k] = min(30, max(widths[k], len(text)))
1832
+ if sort_col is not None: # room for the heading's sort arrow
1833
+ widths[sort_col] = max(widths[sort_col], min(30, len(str(columns[sort_col])) + 1))
1834
+ return cells, widths
1835
+
1836
+ sort_col, sort_desc = None, False # column index of the sort, if any
1837
+ state = {} if state is None else state
1838
+ if state:
1839
+ sort_col, sort_desc = state.get('sort_col'), state.get('sort_desc', False)
1840
+ cells, widths = layout()
1841
+ page = r2 - r1 - 4 # rows between the header line and the status line
1842
+ inner = c2 - c1 - 1
1843
+ top, current, cur_col = 0, 0, 0 # cur_col indexes visible[]
1844
+ first_col = frozen # the first scrolling column (index into visible[])
1845
+ typed, typed_at = '', 0.0 # type-ahead
1846
+ if 0 < start < len(rows):
1847
+ current = start
1848
+ top = max(0, start - (page - 1))
1849
+ if state:
1850
+ top, current = state.get('top', 0), state.get('current', 0)
1851
+ cur_col, first_col = state.get('cur_col', 0), state.get('first_col', frozen)
1852
+ current = max(0, min(current, len(rows) - 1))
1853
+ notified = None # the row on_move last heard about
1854
+ refusal = can_edit() if (save and can_edit) else None # EDIT WHEN: why the rows are read-only
1855
+ message = ''
1856
+ if keep or draw_only:
1857
+ underneath = None
1858
+ self.box(r1, c1, r2, c2, title)
1859
+ else:
1860
+ underneath = self.popup(r1, c1, r2, c2, title, shadow=shadow and self.shadows)
1861
+ self._w('\x1b[?25l')
1862
+
1863
+ def shown():
1864
+ """Indexes into visible[] of the columns drawn, left to right."""
1865
+ return list(range(frozen)) + list(range(first_col, len(visible)))
1866
+
1867
+ def line(values, heading=False):
1868
+ parts = []
1869
+ for n in shown():
1870
+ i = visible[n]
1871
+ text = str(values[i])[:widths[i]]
1872
+ if heading and i == sort_col:
1873
+ text = (text[:widths[i] - 1] + ('▼' if sort_desc else '▲'))
1874
+ parts.append(text.rjust(widths[i]) if i in formats and not heading else text.ljust(widths[i]))
1875
+ if frozen and n == frozen - 1:
1876
+ parts[-1] += ' ║'
1877
+ elif n != len(visible) - 1:
1878
+ parts[-1] += ' │'
1879
+ return (' ' + ' '.join(parts))[:inner].ljust(inner)
1880
+
1881
+ def cell_offset(n):
1882
+ """Column within the grid where visible column n starts."""
1883
+ offset = 1
1884
+ for k in shown():
1885
+ if k == n:
1886
+ return offset
1887
+ offset += widths[visible[k]] + 3
1888
+ return offset
1889
+
1890
+ def column_at(c):
1891
+ """Index into visible[] of the column under screen column c, or None."""
1892
+ x = c - (c1 + 1)
1893
+ columns_shown = shown()
1894
+ for k in columns_shown:
1895
+ if x < cell_offset(k) + widths[visible[k]] + 2:
1896
+ return k
1897
+ return columns_shown[-1] # the last column runs to the edge of the box
1898
+
1899
+ def numeric_column(ci):
1900
+ return any(isinstance(rows[i][ci], (int, float)) and not isinstance(rows[i][ci], bool)
1901
+ for i in range(min(len(rows), 200)))
1902
+
1903
+ def sort_key(v, numeric):
1904
+ """The order the grid sorts in: blanks first, then numbers or text (ignoring case)."""
1905
+ if v is None:
1906
+ return (0, 0)
1907
+ if numeric:
1908
+ if isinstance(v, (int, float)) and not isinstance(v, bool):
1909
+ return (1, float(v))
1910
+ return (2, str(v).lower())
1911
+ return (1, str(v).lower())
1912
+
1913
+ def sort_by(ci):
1914
+ nonlocal sort_col, sort_desc, cells, widths, current, top, message
1915
+ desc = (not sort_desc) if ci == sort_col else False
1916
+ numeric = numeric_column(ci)
1917
+ if hasattr(rows, 'sort_by'):
1918
+ error = rows.sort_by(ci, desc, numeric)
1919
+ if error:
1920
+ bell()
1921
+ message = 'Cannot sort: ' + error
1922
+ return
1923
+ else:
1924
+ rows.sort(key=lambda r: sort_key(r[ci], numeric), reverse=desc)
1925
+ sort_col, sort_desc = ci, desc
1926
+ cells, widths = layout()
1927
+ current, top = 0, 0
1928
+
1929
+ def find(text):
1930
+ """The row type-ahead jumps to: the next row whose search column starts with text."""
1931
+ ci = sort_col if sort_col is not None else visible[0]
1932
+ if not cells:
1933
+ return None
1934
+ if sort_col is not None and not sort_desc:
1935
+ # sorted ascending: a binary search, so even a paged million-row grid answers at once
1936
+ numeric = numeric_column(ci)
1937
+ if numeric:
1938
+ try:
1939
+ want = (1, float(text))
1940
+ except ValueError:
1941
+ return None
1942
+ else:
1943
+ want = (1, text.lower())
1944
+ lo, hi = 0, len(rows)
1945
+ if hasattr(rows, 'position'): # a paged grid counts in the database instead
1946
+ lo = hi = rows.position(want[1])
1947
+ while lo < hi:
1948
+ mid = (lo + hi) // 2
1949
+ if sort_key(rows[mid][ci], numeric) < want:
1950
+ lo = mid + 1
1951
+ else:
1952
+ hi = mid
1953
+ if lo >= len(rows):
1954
+ return None
1955
+ return lo if numeric or str(rows[lo][ci]).lower().startswith(text.lower()) else None
1956
+ first = current if len(text) > 1 else current + 1
1957
+ for k in range(len(rows)):
1958
+ i = (first + k) % len(rows)
1959
+ if cells[i][ci].lower().startswith(text.lower()):
1960
+ return i
1961
+ return None
1962
+
1963
+ def edit_cell(screen_row, n, first_key):
1964
+ ci = visible[n]
1965
+ raw = rows[current][ci]
1966
+ numeric = isinstance(raw, (int, float)) and not isinstance(raw, bool)
1967
+ offset = cell_offset(n)
1968
+ width = max(1, min(max(widths[ci], len(show(raw, None)), 10), inner - offset))
1969
+ field = Field(screen_row, c1 + 1 + offset, '', '' if raw is None else raw, f'@S{width}', None, numeric)
1970
+ pos, insert = field.first_pos(), True
1971
+ if first_key: # typing starts a new value, as in a spreadsheet
1972
+ field.buffer = [' '] * field.width
1973
+ pos = field.edit_key(first_key, 0, insert)
1974
+ self._w('\x1b[?25h')
1975
+ try:
1976
+ while True:
1977
+ self._draw_field(field, pos)
1978
+ key = self.keys.read_key()
1979
+ if key is None:
1980
+ key = ESC
1981
+ if key == ESC:
1982
+ return None, key
1983
+ if key in (ENTER, TAB, 'UP', 'DOWN'):
1984
+ if numeric and not field.text().strip():
1985
+ return ('', None), key
1986
+ try:
1987
+ value = field.value()
1988
+ except ValueError:
1989
+ bell()
1990
+ continue
1991
+ if numeric and value == int(value):
1992
+ value = int(value)
1993
+ return ('', value), key
1994
+ if key == 'INS':
1995
+ insert = not insert
1996
+ continue
1997
+ pos = field.edit_key(key, pos, insert)
1998
+ finally:
1999
+ self._w('\x1b[?25l')
2000
+
2001
+ def row_colour(i):
2002
+ if color_col is None or i >= len(rows):
2003
+ return self.fg
2004
+ try:
2005
+ colour = int(rows[i][color_col])
2006
+ except (TypeError, ValueError):
2007
+ return self.fg
2008
+ return colour if 0 <= colour <= 15 else self.fg
2009
+
2010
+ try:
2011
+ while True:
2012
+ # keep the current cell in view
2013
+ if save and cur_col >= frozen:
2014
+ first_col = min(first_col, cur_col)
2015
+ while first_col < cur_col and cell_offset(cur_col) + widths[visible[cur_col]] > inner:
2016
+ first_col += 1
2017
+ out = [self._put(r1 + 1, c1 + 1, line(columns, heading=True), self.fg, self.bg),
2018
+ self._put(r1 + 2, c1 + 1, '─' * inner, self.fg, self.bg)]
2019
+ for n in range(page):
2020
+ if top + n < len(cells):
2021
+ widen(top + n)
2022
+ for n in range(page):
2023
+ i = top + n
2024
+ fg, bg = (self.field_fg, self.field_bg) if i == current and bar else (row_colour(i), self.bg)
2025
+ text = line(cells[i]) if i < len(cells) else ' ' * inner
2026
+ out.append(self._put(r1 + 3 + n, c1 + 1, text, fg, bg))
2027
+ if save and bar and i == current: # the current cell in reverse
2028
+ off = cell_offset(cur_col)
2029
+ ci = visible[cur_col]
2030
+ cell_text = text[off:off + widths[ci]]
2031
+ out.append(self._put(r1 + 3 + n, c1 + 1 + off, cell_text, self.field_bg, self.field_fg))
2032
+ if message:
2033
+ status = ' ' + message
2034
+ elif typed:
2035
+ status = f' {current + 1 if cells else 0} of {len(cells)} Find: {typed}'
2036
+ elif save and refusal:
2037
+ status = f' {current + 1 if cells else 0} of {len(cells)} {refusal} F3=sort Esc=done'
2038
+ elif save:
2039
+ extra = (' Ins=add' if add else '') + (' Del=delete' if delete else '')
2040
+ status = f' {current + 1 if cells else 0} of {len(cells)} Enter=edit{extra} F3=sort Esc=done'
2041
+ else:
2042
+ hint = 'Enter=back Esc=back' if keys_hint == 'back' else keys_hint or 'Enter=select Esc=cancel'
2043
+ status = f' {current + 1 if cells else 0} of {len(cells)} {hint} ←→=columns F3=sort'
2044
+ if switch and not message and not typed:
2045
+ status = status.replace(' F3=sort', ' F3=sort ' + ('F6' if save else 'Tab') + '=switch')
2046
+ if keys_hint == 'back': # a detail grid: Esc (and Enter) go back to the master
2047
+ status = status.replace('Esc=done', 'Esc=back')
2048
+ if draw_only: # the grid without the keys shows no key hints
2049
+ status = f' {current + 1 if cells else 0} of {len(cells)}'
2050
+ message = ''
2051
+ out.append(self._put(r2 - 1, c1 + 1, status[:inner].ljust(inner), self.fg, self.bg))
2052
+ self._w(''.join(out) + self._normal())
2053
+ state.update(top=top, current=current, cur_col=cur_col, first_col=first_col,
2054
+ sort_col=sort_col, sort_desc=sort_desc)
2055
+ if on_move and current != notified and not draw_only:
2056
+ notified = current
2057
+ on_move(current if cells else -1)
2058
+ if draw_only:
2059
+ return current
2060
+ key = self.keys.read_key()
2061
+ if key is None:
2062
+ key = ESC
2063
+ self.last_key = key
2064
+ if key in exit_keys:
2065
+ return current # EXIT ON: the program handles this key
2066
+ if switch and (key == 'F6' or (key == TAB and not save) or
2067
+ (key == 'CLICK' and not (r1 <= self.keys.mouse_row <= r2 and
2068
+ c1 <= self.keys.mouse_col <= c2))):
2069
+ return current # the other grid's turn
2070
+
2071
+ # type-ahead (when not editing): printable keys search, Backspace takes one back
2072
+ if not save and ((len(key) == 1 and key.isprintable()) or (key == BACKSPACE and typed)):
2073
+ now = time.monotonic()
2074
+ if now - typed_at > 1.5:
2075
+ typed = ''
2076
+ typed_at = now
2077
+ wanted = typed[:-1] if key == BACKSPACE else typed + key
2078
+ found = find(wanted) if wanted else current
2079
+ if found is None:
2080
+ bell()
2081
+ else:
2082
+ typed, current = wanted, found
2083
+ if current < top or current >= top + page:
2084
+ top = max(0, current - page // 2)
2085
+ continue
2086
+ typed = ''
2087
+
2088
+ if key == 'CLICK':
2089
+ r, c = self.keys.mouse_row, self.keys.mouse_col
2090
+ if r == r1 + 1 and c1 < c < c2: # a heading: sort by it
2091
+ n = column_at(c)
2092
+ if n is not None:
2093
+ sort_by(visible[n])
2094
+ continue
2095
+ n, i = r - (r1 + 3), top + r - (r1 + 3)
2096
+ if not (0 <= n < page and i < len(cells) and c1 < c < c2):
2097
+ continue # not on a row
2098
+ new_col = cur_col
2099
+ if save: # which cell: column offsets as drawn
2100
+ k = column_at(c)
2101
+ new_col = cur_col if k is None else k
2102
+ if i == current and new_col == cur_col:
2103
+ key = ENTER # a click on the selected row (or cell) chooses (edits) it
2104
+ else:
2105
+ current, cur_col, key = i, new_col, ''
2106
+ elif key in ('WHEELDOWN', 'WHEELUP'):
2107
+ current += 3 if key == 'WHEELDOWN' else -3
2108
+ current = min(max(current, 0), max(len(cells) - 1, 0))
2109
+ key = ''
2110
+ if key == ESC:
2111
+ return -1
2112
+ if key == 'F3':
2113
+ sort_by(visible[cur_col] if save else visible[first_col if first_col < len(visible) else 0])
2114
+ continue
2115
+ if refusal and (key in ('INS', 'DEL', ENTER, 'F2') or (len(key) == 1 and key.isprintable())):
2116
+ bell() # EDIT WHEN: these rows can't be changed now
2117
+ message = refusal
2118
+ continue
2119
+ if save and add and key == 'INS':
2120
+ new_index = add()
2121
+ if new_index is not False:
2122
+ new_row = rows[new_index] if new_index is not None else None
2123
+ if sort_col is not None and not hasattr(rows, 'sort_by'):
2124
+ numeric = numeric_column(sort_col)
2125
+ rows.sort(key=lambda r: sort_key(r[sort_col], numeric), reverse=sort_desc)
2126
+ new_index = next((k for k, r in enumerate(rows) if r is new_row), None)
2127
+ cells, widths = layout()
2128
+ if new_index is not None:
2129
+ current = new_index
2130
+ current = min(current, len(cells) - 1)
2131
+ cur_col = 0
2132
+ continue
2133
+ if save and delete and key == 'DEL' and cells:
2134
+ self._w(self._put(r2 - 1, c1 + 1, ' Delete this row? (Y/N)'[:inner].ljust(inner), self.fg, self.bg))
2135
+ answer = self.keys.read_key()
2136
+ if answer and answer.upper() == 'Y':
2137
+ error = delete(current)
2138
+ if error:
2139
+ bell()
2140
+ message = 'Not deleted: ' + error
2141
+ else:
2142
+ rows.pop(current)
2143
+ cells.pop(current)
2144
+ current = max(0, min(current, len(cells) - 1))
2145
+ continue
2146
+ if save and cells and (key in (ENTER, 'F2') or (len(key) == 1 and key.isprintable())):
2147
+ ci = visible[cur_col]
2148
+ if ci in locked:
2149
+ bell()
2150
+ message = locked[ci] if isinstance(locked, dict) else \
2151
+ f'{columns[ci]} is the key and cannot be changed.'
2152
+ continue
2153
+ result, end_key = edit_cell(r1 + 3 + current - top, cur_col, None if key in (ENTER, 'F2') else key)
2154
+ if result is not None:
2155
+ error = save(current, ci, result[1])
2156
+ if error:
2157
+ bell()
2158
+ message = 'Not saved: ' + error
2159
+ else:
2160
+ rows[current][ci] = result[1]
2161
+ cells[current][ci] = show(result[1], ci)
2162
+ widths[ci] = min(30, max(widths[ci], len(cells[current][ci])))
2163
+ if end_key == TAB: key = 'RIGHT'
2164
+ elif end_key in ('UP', 'DOWN'): key = end_key
2165
+ if key in (ENTER, 'F2') or (len(key) == 1 and key not in (TAB,)):
2166
+ continue
2167
+ elif key == ENTER and cells:
2168
+ return current
2169
+ if key == 'DOWN': current = min(current + 1, len(cells) - 1)
2170
+ elif key == 'UP': current = max(current - 1, 0)
2171
+ elif key == 'PGDN': current = min(current + page, len(cells) - 1)
2172
+ elif key == 'PGUP': current = max(current - page, 0)
2173
+ elif key == 'HOME': current = 0
2174
+ elif key == 'END': current = len(cells) - 1
2175
+ elif save and key in ('RIGHT', TAB): cur_col = min(cur_col + 1, len(visible) - 1)
2176
+ elif save and key in ('LEFT', 'BTAB'): cur_col = max(cur_col - 1, 0)
2177
+ elif key == 'RIGHT': first_col = min(first_col + 1, len(visible) - 1)
2178
+ elif key == 'LEFT': first_col = max(first_col - 1, frozen)
2179
+ current = max(current, 0)
2180
+ if current < top: top = current
2181
+ elif current >= top + page: top = current - page + 1
2182
+ finally:
2183
+ if underneath:
2184
+ self.restore_saved(underneath) # put back what the grid covered
2185
+ self._w('\x1b[?25h')
2186
+
2187
+ # --- INKEY$ --------------------------------------------------------
2188
+ def inkey(self, wait=None):
2189
+ """INKEY$: no argument returns a waiting key or ''. INKEY$(0) waits for a key;
2190
+ INKEY$(n) waits up to n seconds."""
2191
+ if wait is None:
2192
+ timeout = 0
2193
+ elif wait <= 0:
2194
+ timeout = None
2195
+ else:
2196
+ timeout = wait
2197
+ key = self.keys.read_key(timeout)
2198
+ key = key or ''
2199
+ if key:
2200
+ self.last_key = key
2201
+ return key
2202
+
2203
+
2204
+ # One screen per process, shared by RUN and CHAIN programs
2205
+ SCREEN = Screen()