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.
- ddgbasic-1.0.0.dist-info/METADATA +473 -0
- ddgbasic-1.0.0.dist-info/RECORD +11 -0
- ddgbasic-1.0.0.dist-info/WHEEL +5 -0
- ddgbasic-1.0.0.dist-info/entry_points.txt +2 -0
- ddgbasic-1.0.0.dist-info/licenses/LICENSE +21 -0
- ddgbasic-1.0.0.dist-info/top_level.txt +5 -0
- ddgbasic.py +14883 -0
- ddgformgen.py +295 -0
- ddgide.py +1277 -0
- ddgreport.py +175 -0
- ddgscreen.py +2205 -0
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()
|