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.
ddgide.py ADDED
@@ -0,0 +1,1277 @@
1
+ """
2
+ The DDGBasic IDE: a full-screen editor in the style of QBasic, started with
3
+ `ddgbasic --ide [file.bas]`.
4
+
5
+ It edits one program with syntax colouring, runs it with F5 (the program gets the
6
+ whole terminal; the IDE comes back when it ends and moves the cursor to the line of
7
+ an error), shows the built-in HELP for the keyword under the cursor with F1, and has
8
+ a menu bar (F10, or Alt and a menu's first letter) for files, editing, running,
9
+ colours and help. Under the editor, the Immediate window (F6) runs one line at a
10
+ time in a session of its own, as the REPL does. It is drawn with the same screen
11
+ layer as the text-screen statements (ddgscreen.py).
12
+ """
13
+ import io
14
+ import os
15
+ import re
16
+ import signal
17
+ import subprocess
18
+ import sys
19
+
20
+ from ddgscreen import SCREEN, ESC, ENTER, TAB, BACKSPACE, bell
21
+
22
+ # SQL words coloured like DDGBasic's own keywords
23
+ _SQL_WORDS = {
24
+ 'SELECT', 'FROM', 'WHERE', 'GROUP', 'BY', 'ORDER', 'HAVING', 'INSERT', 'INTO', 'VALUES', 'UPDATE',
25
+ 'SET', 'DELETE', 'CREATE', 'TABLE', 'DROP', 'ALTER', 'ADD', 'COLUMN', 'JOIN', 'LEFT', 'RIGHT',
26
+ 'INNER', 'OUTER', 'ON', 'AS', 'AND', 'OR', 'NOT', 'NULL', 'IS', 'LIKE', 'IN', 'DISTINCT', 'LIMIT',
27
+ 'OFFSET', 'CASE', 'WHEN', 'THEN', 'ELSE', 'END', 'PRIMARY', 'KEY', 'FOREIGN', 'REFERENCES', 'CHECK',
28
+ 'UNIQUE', 'DEFAULT', 'INDEX', 'VIEW', 'TRIGGER', 'BEGIN', 'COMMIT', 'ROLLBACK', 'WITH', 'UNION',
29
+ 'ALL', 'EXISTS', 'BETWEEN', 'ASC', 'DESC', 'INTEGER', 'TEXT', 'REAL', 'ATTACH', 'DETACH', 'COUNT',
30
+ 'SUM', 'AVG', 'MIN', 'MAX', 'COALESCE', 'IF', 'REPLACE', 'PRAGMA', 'TRANSACTION',
31
+ }
32
+ # BASIC words that are not HELP topics of their own
33
+ _BASIC_WORDS = {
34
+ 'THEN', 'ELSE', 'ELSEIF', 'END', 'NEXT', 'TO', 'STEP', 'IN', 'EACH', 'WEND', 'LOOP', 'UNTIL',
35
+ 'WHILE', 'DO', 'FOR', 'IF', 'GOSUB', 'RETURN', 'GOTO', 'FUNCTION', 'AND', 'OR', 'NOT', 'AS',
36
+ 'REM', 'SAY', 'GET', 'PICTURE', 'VALID', 'WHEN', 'MESSAGE', 'HINT', 'LOOKUP', 'CHECKBOX', 'LIST',
37
+ 'BUTTON', 'CANCEL', 'AT', 'TITLE', 'EDIT', 'KEY', 'DETAIL', 'FREEZE', 'FORMAT', 'COLOR', 'EXIT',
38
+ 'INTO', 'CALL', 'OFF', 'ON', 'OPTIONS', 'TRUNCATE', 'APPEND', 'UPSERT', 'IGNORE', 'USING', 'RECORD',
39
+ 'SHADOW', 'DOUBLE', 'TOTAL', 'PAGE', 'VIEW', 'FILE', 'OUTPUT', 'INPUT', 'LINE', 'TRANSFORM',
40
+ 'VALIDATE', 'WITH', 'ROW', 'ENGINE', 'SQLITE', 'DUCKDB', 'MOUSE', 'THEME', 'AUTOADVANCE', 'CONFIRM',
41
+ }
42
+
43
+ # F1 on these words shows the help of the statement they belong to
44
+ _HELP_ALIASES = {
45
+ 'SAY': '@', 'GET': '@', 'PICTURE': '@', 'VALID': '@', 'MESSAGE': '@', 'HINT': '@', 'LOOKUP': '@',
46
+ 'CHECKBOX': '@', 'BUTTON': '@', 'CANCEL': '@', 'THEN': 'IF', 'ELSE': 'IF', 'ELSEIF': 'IF',
47
+ 'NEXT': 'FOR', 'STEP': 'FOR', 'TO': 'FOR', 'EACH': 'FOR EACH', 'WEND': 'WHILE', 'LOOP': 'DO',
48
+ 'UNTIL': 'DO', 'RETURN': 'GOSUB', 'DETAIL': 'BROWSE', 'FREEZE': 'BROWSE', 'RECORD': 'EDIT RECORD',
49
+ }
50
+
51
+ # Colour schemes: (background, text, keyword, string, number, comment, frame, bar fg, bar bg)
52
+ _SCHEMES = {
53
+ 'blue': dict(bg=1, text=7, keyword=15, string=14, number=13, comment=11, frame=7, bar_fg=0, bar_bg=7),
54
+ 'black': dict(bg=0, text=7, keyword=15, string=14, number=11, comment=10, frame=7, bar_fg=0, bar_bg=7),
55
+ }
56
+
57
+ _HELP_KEYS = """F1 / Shift-F1 help on the word under the cursor / all help topics
58
+ F5 run the program (it is saved first)
59
+ F6 move between the editor and the Immediate window
60
+ F9 set or clear a breakpoint on the cursor's line
61
+ F8 step: run the program a line at a time (it starts paused on line 1)
62
+ While paused F5 continues, F8 runs the next line, F4 shows the program's screen,
63
+ Run > Stop ends the program; the Immediate window sees its variables
64
+ Ctrl-C pauses a program run with breakpoints or F8
65
+ F10, Alt+letter the menu bar (Alt-F File, Alt-E Edit, Alt-R Run, Alt-O Options, Alt-H Help)
66
+ Ctrl-S save Ctrl-O open
67
+ Ctrl-N new program Alt-X exit
68
+ Ctrl-Z undo Ctrl-Y delete the line
69
+ Ctrl-D duplicate the line Ctrl-G go to a line
70
+ Ctrl-F find F3 find the next one
71
+ Tab / Shift-Tab indent / unindent Ins insert or overwrite
72
+ Home / End start / end of the line Ctrl-Home / Ctrl-End start / end of the program
73
+ PgUp / PgDn a screen up / down Ctrl-Left / Ctrl-Right a word left / right
74
+ The mouse click to move the cursor, open a menu or go to a window; the wheel scrolls
75
+ Immediate window type a statement and press Enter: it runs at once (? x is PRINT x);
76
+ Up/Down recall earlier lines, PgUp/PgDn scroll, CLS clears, Esc goes back
77
+ (Options > Mouse off gives the terminal its own text selection back)"""
78
+
79
+
80
+ def error_location(errors):
81
+ """The last error a program printed, as (message, line, column), or None. Runtime
82
+ errors read "Runtime Error: ... at line 12, col 5." and lexer errors
83
+ "Error: ... at line 2, col 5"."""
84
+ found = re.findall(r'(?:Runtime Error|Error): (.*?) at line (\d+), col (\d+)', errors)
85
+ if not found:
86
+ return None
87
+ message, line, col = found[-1]
88
+ return message, int(line), int(col)
89
+
90
+
91
+ class DebugStop(BaseException):
92
+ """Run > Stop while a program is paused: ends the debug run. A BaseException, like
93
+ KeyboardInterrupt, so the program's own error handling (ON ERROR, the interpreter's
94
+ error reporting) lets it through."""
95
+
96
+
97
+ class Editor:
98
+ IMMEDIATE_HEIGHT = 6 # the Immediate window's rows, its input line included
99
+
100
+ def __init__(self, path, runner, help_topics, get_help, new_session=None, new_program=None):
101
+ self.runner = runner # the command that runs a .bas file
102
+ self.new_session = new_session # makes the interpreter of the Immediate window
103
+ self.new_program = new_program # new_program(source, folder): an interpreter to debug
104
+ self.help_topics = help_topics # HELP topic names
105
+ self.get_help = get_help # get_help(topic) -> {'desc', 'syntax', 'examples'} or None
106
+ self.keywords = set(_SQL_WORDS) | _BASIC_WORDS
107
+ for topic in help_topics:
108
+ for word in topic.split():
109
+ if re.fullmatch(r'[A-Z_][A-Z0-9_]*\$?', word):
110
+ self.keywords.add(word)
111
+ self.scheme = _SCHEMES['blue']
112
+ self.path = None
113
+ self.lines = ['']
114
+ self.newline = '\r\n' if os.name == 'nt' else '\n'
115
+ self.row = self.col = 0 # the cursor, in the text
116
+ self.top = self.left = 0 # the first line and column shown
117
+ self.modified = False
118
+ self.insert = True
119
+ self.undo = []
120
+ self.last_edit = None # (kind, row): typing on one line is one undo step
121
+ self.find_text = ''
122
+ self.message = ''
123
+ # The Immediate window
124
+ self.session = None # its interpreter, made on first use
125
+ self.show_immediate = new_session is not None
126
+ self.focus = 'editor' # or 'immediate'
127
+ self.imm_lines = [] # what it has shown: typed lines and their output
128
+ self.imm_text, self.imm_pos = '', 0 # the line being typed
129
+ self.imm_history, self.imm_recall = [], None
130
+ self.imm_scroll = 0 # lines scrolled back from the newest
131
+ # The debugger
132
+ self.breakpoints = set() # line indexes (from 0)
133
+ self.program = None # the interpreter of a debug run
134
+ self.paused_row = None # the line it is paused on
135
+ self.debug_mode = 'run' # 'run' to the next breakpoint, or 'step'
136
+ self.pause_requested = False # Ctrl-C during a debug run
137
+ if path:
138
+ self.load(path)
139
+
140
+ # --- files ---------------------------------------------------------------------
141
+ def load(self, path):
142
+ self.path = os.path.abspath(path)
143
+ if os.path.exists(self.path):
144
+ with open(self.path, encoding='utf-8', errors='replace', newline='') as f:
145
+ text = f.read()
146
+ self.newline = '\r\n' if '\r\n' in text else '\n'
147
+ self.lines = [line.expandtabs(4) for line in text.replace('\r\n', '\n').split('\n')]
148
+ if len(self.lines) > 1 and self.lines[-1] == '':
149
+ self.lines.pop() # the file's last newline
150
+ self.message = f'{os.path.basename(self.path)}: {len(self.lines)} lines'
151
+ else:
152
+ self.lines = ['']
153
+ self.message = f'{os.path.basename(self.path)} is a new file'
154
+ self.row = self.col = self.top = self.left = 0
155
+ self.modified = False
156
+ self.undo = []
157
+ self.breakpoints = set()
158
+
159
+ def save(self, path=None):
160
+ path = path or self.path
161
+ if not path:
162
+ return self.save_as()
163
+ try:
164
+ with open(path, 'w', encoding='utf-8', newline='') as f:
165
+ f.write(self.newline.join(self.lines) + self.newline)
166
+ except OSError as e:
167
+ SCREEN.alert(f'Not saved;{e}', ['OK'])
168
+ return False
169
+ self.path = os.path.abspath(path)
170
+ self.modified = False
171
+ self.message = f'Saved {os.path.basename(self.path)}'
172
+ return True
173
+
174
+ def save_as(self):
175
+ default = os.path.basename(self.path) if self.path else 'program.bas'
176
+ name = self.prompt('Save As', 'File name:', default)
177
+ if not name:
178
+ return False
179
+ if not os.path.splitext(name)[1]:
180
+ name += '.bas'
181
+ folder = os.path.dirname(self.path) if self.path else os.getcwd()
182
+ path = os.path.abspath(os.path.join(folder, os.path.expanduser(name)))
183
+ if os.path.exists(path) and path != self.path:
184
+ if SCREEN.alert(f'{os.path.basename(path)} already exists.;Replace it?', ['No', 'Yes']) != 2:
185
+ return False
186
+ return self.save(path)
187
+
188
+ def keep_changes(self):
189
+ """Before the program is closed: True to go on (saved or discarded), False to stay."""
190
+ if not self.modified:
191
+ return True
192
+ name = os.path.basename(self.path) if self.path else 'Untitled'
193
+ choice = SCREEN.alert(f'Save the changes to {name}?', ['Yes', 'No', 'Cancel'])
194
+ if choice == 1:
195
+ return self.save()
196
+ return choice == 2
197
+
198
+ def open_file(self):
199
+ if not self.keep_changes():
200
+ return
201
+ folder = os.path.dirname(self.path) if self.path else os.getcwd()
202
+ names = sorted((n for n in os.listdir(folder) if n.lower().endswith('.bas')), key=str.lower)
203
+ rows = [['(type a name)', '', '']]
204
+ for name in names:
205
+ full = os.path.join(folder, name)
206
+ try:
207
+ with open(full, encoding='utf-8', errors='replace') as f:
208
+ count = sum(1 for _ in f)
209
+ except OSError:
210
+ count = ''
211
+ rows.append([name, count, os.path.getsize(full)])
212
+ rows_on, cols_on = SCREEN.size()
213
+ chosen = SCREEN.browse(['File', 'Lines', 'Bytes'], rows, (3, 8, rows_on - 3, cols_on - 8),
214
+ f'Open: {folder}', shadow=True)
215
+ if chosen < 0:
216
+ return
217
+ if chosen == 0:
218
+ name = self.prompt('Open', 'File name:', '')
219
+ if not name:
220
+ return
221
+ path = os.path.join(folder, os.path.expanduser(name))
222
+ else:
223
+ path = os.path.join(folder, rows[chosen][0])
224
+ self.load(path)
225
+
226
+ def new_file(self):
227
+ if not self.keep_changes():
228
+ return
229
+ self.path = None
230
+ self.lines = ['']
231
+ self.row = self.col = self.top = self.left = 0
232
+ self.modified = False
233
+ self.undo = []
234
+ self.message = 'New program'
235
+
236
+ # --- small dialogs -------------------------------------------------------------------
237
+ def prompt(self, title, label, default=''):
238
+ """A one-line input box. Returns the text, or None for Esc."""
239
+ rows, cols = SCREEN.size()
240
+ width = min(60, cols - 6)
241
+ top, left = rows // 2 - 2, (cols - width) // 2
242
+ under = SCREEN.popup(top, left, top + 4, left + width, title, double=True)
243
+ text, pos = default, len(default)
244
+ field = width - len(label) - 5
245
+ try:
246
+ while True:
247
+ shown = text[max(0, pos - field + 1):][:field]
248
+ SCREEN._w(SCREEN._put(top + 2, left + 2, label + ' ', SCREEN.fg, SCREEN.bg) +
249
+ SCREEN._put(top + 2, left + 3 + len(label), shown.ljust(field), 0, 7) +
250
+ f'\x1b[{top + 2};{left + 3 + len(label) + min(pos, field - 1)}H\x1b[?25h')
251
+ key = SCREEN.keys.read_key()
252
+ if key in (None, ESC):
253
+ return None
254
+ if key == ENTER:
255
+ return text.strip()
256
+ if key == BACKSPACE and pos > 0:
257
+ text, pos = text[:pos - 1] + text[pos:], pos - 1
258
+ elif key == 'DEL':
259
+ text = text[:pos] + text[pos + 1:]
260
+ elif key == 'LEFT':
261
+ pos = max(0, pos - 1)
262
+ elif key == 'RIGHT':
263
+ pos = min(len(text), pos + 1)
264
+ elif key == 'HOME':
265
+ pos = 0
266
+ elif key == 'END':
267
+ pos = len(text)
268
+ elif key and len(key) == 1 and key.isprintable():
269
+ text, pos = text[:pos] + key + text[pos:], pos + 1
270
+ finally:
271
+ SCREEN.restore_saved(under)
272
+
273
+ # --- help ---------------------------------------------------------------------------
274
+ def word_at_cursor(self):
275
+ line = self.lines[self.row]
276
+ for m in re.finditer(r'[A-Za-z_@][A-Za-z0-9_]*\$?|@', line):
277
+ if m.start() <= self.col <= m.end():
278
+ return m.start(), m.group()
279
+ return None, ''
280
+
281
+ def help_topic_at_cursor(self, line=None, col=None):
282
+ """The HELP topic for the word under the cursor (of the editor, or of the given line
283
+ and column), trying two-word topics (ON KEY, SET THEME, MAKE FORM) with the word
284
+ before and after it too."""
285
+ if line is None:
286
+ line, col = self.lines[self.row], self.col
287
+ start, word = None, ''
288
+ for m in re.finditer(r'[A-Za-z_@][A-Za-z0-9_]*\$?|@', line):
289
+ if m.start() <= col <= m.end():
290
+ start, word = m.start(), m.group()
291
+ break
292
+ if not word:
293
+ return None
294
+ words = [(m.start(), m.group().upper()) for m in re.finditer(r'[A-Za-z_@][A-Za-z0-9_]*\$?|@', line)]
295
+ i = next((n for n, (s, _) in enumerate(words) if s == start), None)
296
+ topics = set(self.help_topics)
297
+ if i is not None:
298
+ for a, b in ((i - 1, i), (i, i + 1)):
299
+ if 0 <= a and b < len(words) and f'{words[a][1]} {words[b][1]}' in topics:
300
+ return f'{words[a][1]} {words[b][1]}'
301
+ upper = word.upper()
302
+ if upper in topics:
303
+ return upper
304
+ if upper in _HELP_ALIASES and _HELP_ALIASES[upper] in topics:
305
+ return _HELP_ALIASES[upper]
306
+ if upper.endswith('$') and upper[:-1] in topics:
307
+ return upper[:-1]
308
+ if upper + '$' in topics:
309
+ return upper + '$'
310
+ return None
311
+
312
+ def show_help(self, topic):
313
+ info = self.get_help(topic)
314
+ if not info:
315
+ SCREEN.alert(f'There is no help for {topic}.', ['OK'])
316
+ return
317
+ text = f"{topic}\n\n{info['desc']}\n\nSyntax:\n " + info['syntax'].replace('\n', '\n ')
318
+ if info.get('examples'):
319
+ text += '\n\nExamples:\n' + '\n'.join(' ' + e.replace('\n', '\n ') for e in info['examples'])
320
+ rows, cols = SCREEN.size()
321
+ SCREEN.view(self.wrap(text, cols - 10), (3, 4, rows - 3, cols - 3), f'Help: {topic} (Esc closes)')
322
+
323
+ def help_topics_list(self):
324
+ rows, cols = SCREEN.size()
325
+ topics = sorted(self.help_topics)
326
+ chosen = SCREEN.browse(['Topic (type to find)'], [[t] for t in topics], (3, 10, rows - 3, cols - 10),
327
+ 'Help topics', shadow=True)
328
+ if chosen >= 0:
329
+ self.show_help(topics[chosen])
330
+
331
+ @staticmethod
332
+ def wrap(text, width):
333
+ out = []
334
+ for line in text.split('\n'):
335
+ indent = len(line) - len(line.lstrip())
336
+ while len(line) > width:
337
+ cut = line.rfind(' ', indent + 1, width)
338
+ cut = cut if cut > indent else width
339
+ out.append(line[:cut])
340
+ line = ' ' * indent + line[cut:].lstrip()
341
+ out.append(line)
342
+ return '\n'.join(out)
343
+
344
+ # --- running ----------------------------------------------------------------------
345
+ def run(self):
346
+ if not self.path or self.modified:
347
+ if not self.save():
348
+ return
349
+ SCREEN.restore() # the program gets a normal terminal
350
+ sys.stdout.write('\x1b[0m\x1b[2J\x1b[H')
351
+ sys.stdout.flush()
352
+ folder, name = os.path.split(self.path)
353
+ try:
354
+ # The program runs in its own folder, so its relative file names work; its
355
+ # errors are collected to find the line, and shown after it ends.
356
+ done = subprocess.run(self.runner + [name], cwd=folder, stderr=subprocess.PIPE)
357
+ errors = done.stderr.decode('utf-8', errors='replace')
358
+ status = done.returncode
359
+ except KeyboardInterrupt:
360
+ errors, status = '', 130
361
+ except OSError as e:
362
+ errors, status = f'Could not run the program: {e}\n', 1
363
+ sys.stdout.write('\x1b[0m\x1b[?25h')
364
+ if errors:
365
+ sys.stdout.write(errors if errors.endswith('\n') else errors + '\n')
366
+ outcome = 'finished' if status == 0 else ('stopped (Ctrl-C)' if status == 130 else 'stopped with an error')
367
+ sys.stdout.write(f'\n The program {outcome}. Press any key to return to the IDE.')
368
+ sys.stdout.flush()
369
+ SCREEN.keys.read_key()
370
+ self.repaint()
371
+ # Go to the error
372
+ found = error_location(errors) if status != 0 else None
373
+ if found:
374
+ text, line, col = found
375
+ self.row = max(0, min(line - 1, len(self.lines) - 1))
376
+ self.col = max(0, min(col - 1, len(self.lines[self.row])))
377
+ self.scroll_to_cursor()
378
+ self.draw()
379
+ SCREEN.alert(f'Line {line}:;{text[:200]}', ['OK'])
380
+ self.message = f'Error on line {line}: {text[:120]}'
381
+
382
+ # --- the debugger: breakpoints, stepping --------------------------------------------
383
+ def toggle_breakpoint(self):
384
+ if self.row in self.breakpoints:
385
+ self.breakpoints.discard(self.row)
386
+ self.message = f'Breakpoint on line {self.row + 1} cleared'
387
+ else:
388
+ self.breakpoints.add(self.row)
389
+ self.message = f'Breakpoint on line {self.row + 1}: F5 runs to it'
390
+
391
+ def debug(self, step):
392
+ """Runs the program inside the IDE, stopping at breakpoints (and, with step, on its
393
+ first line). The program uses the whole screen; at a stop the IDE comes back with
394
+ the line highlighted. The editor's text is run as it is, saved or not."""
395
+ if self.new_program is None:
396
+ bell()
397
+ return
398
+ folder = os.path.dirname(self.path) if self.path else os.getcwd()
399
+ source = '\n'.join(self.lines) + '\n'
400
+ try:
401
+ program = self.new_program(source, folder)
402
+ except Exception as e: # the lexer refused the program
403
+ found = error_location(f'Error: {e}')
404
+ self.message = f'Error: {e}'.split('\n')[0][:150]
405
+ if found:
406
+ self.row = max(0, min(found[1] - 1, len(self.lines) - 1))
407
+ self.col = max(0, found[2] - 1)
408
+ return
409
+ self.program, self.debug_mode, self.pause_requested = program, 'step' if step else 'run', False
410
+ program.line_hook = self.on_line
411
+ ide_colours = (SCREEN.fg, SCREEN.bg, SCREEN.field_fg, SCREEN.field_bg)
412
+ mouse, hotkeys, hotkey_call = SCREEN.keys.mouse_wanted, dict(SCREEN.keys.hotkeys), SCREEN.hotkey_call
413
+ errors = io.StringIO()
414
+
415
+ class Tee: # errors go to the screen and are kept
416
+ def write(self, text):
417
+ errors.write(text)
418
+ return saved_stderr.write(text)
419
+
420
+ def flush(self):
421
+ saved_stderr.flush()
422
+
423
+ saved_stderr, here = sys.stderr, os.getcwd()
424
+ previous_sigint = signal.getsignal(signal.SIGINT)
425
+
426
+ def on_ctrl_c(signum, frame):
427
+ self.pause_requested = True # pause before the next line
428
+
429
+ self.start_program_screen()
430
+ status = None
431
+ try:
432
+ signal.signal(signal.SIGINT, on_ctrl_c)
433
+ sys.stderr = Tee()
434
+ os.chdir(folder)
435
+ program.run()
436
+ status = program.exit_code
437
+ except DebugStop:
438
+ status = 'stopped'
439
+ except KeyboardInterrupt:
440
+ status = 130
441
+ except Exception as e:
442
+ sys.stderr.write(f'Error: {e}\n')
443
+ status = 1
444
+ finally:
445
+ signal.signal(signal.SIGINT, previous_sigint)
446
+ sys.stderr = saved_stderr
447
+ os.chdir(here)
448
+ try:
449
+ program.close()
450
+ except Exception:
451
+ pass
452
+ self.program, self.paused_row = None, None
453
+ SCREEN.keys.hotkeys, SCREEN.hotkey_call = hotkeys, hotkey_call # the program's ON KEYs end with it
454
+ SCREEN.keys.set_mouse(mouse)
455
+ SCREEN.gets = []
456
+ SCREEN.fg, SCREEN.bg, SCREEN.field_fg, SCREEN.field_bg = ide_colours
457
+ if status != 'stopped':
458
+ SCREEN.keys.raw_off()
459
+ outcome = 'finished' if status == 0 else ('stopped (Ctrl-C)' if status == 130 else 'stopped with an error')
460
+ sys.stdout.write(f'\x1b[0m\x1b[?25h\n The program {outcome}. Press any key to return to the IDE.')
461
+ sys.stdout.flush()
462
+ SCREEN.keys.read_key()
463
+ self.repaint()
464
+ found = error_location(errors.getvalue()) if status not in (0, 'stopped') else None
465
+ if found:
466
+ text, line, col = found
467
+ self.row = max(0, min(line - 1, len(self.lines) - 1))
468
+ self.col = max(0, min(col - 1, len(self.lines[self.row])))
469
+ self.scroll_to_cursor()
470
+ self.draw()
471
+ SCREEN.alert(f'Line {line}:;{text[:200]}', ['OK'])
472
+ self.message = f'Error on line {line}: {text[:120]}'
473
+ elif status == 'stopped':
474
+ self.message = 'The program was stopped'
475
+
476
+ def start_program_screen(self):
477
+ """A clear screen in the default colours for the program, as when it runs on its own."""
478
+ SCREEN.color(7, 0, 0, 7)
479
+ SCREEN.keys.raw_off()
480
+ SCREEN.cells = SCREEN._blank(SCREEN.rows, SCREEN.cols, 7, 0)
481
+ SCREEN.cur_row = SCREEN.cur_col = 1
482
+ sys.stdout.write('\x1b[0m\x1b[2J\x1b[H\x1b[?25h')
483
+ sys.stdout.flush()
484
+
485
+ def on_line(self, program, line):
486
+ """The interpreter's line hook: pauses at a breakpoint, when stepping, or after Ctrl-C."""
487
+ row = line - 1
488
+ if self.debug_mode == 'step' or row in self.breakpoints or self.pause_requested:
489
+ self.pause_requested = False
490
+ self.pause(row)
491
+
492
+ def pause(self, row):
493
+ """The program stops before line row: the IDE comes back with the line highlighted,
494
+ until F5 (continue), F8 (step) or Run > Stop."""
495
+ program_screen = SCREEN.save()
496
+ program_colours = (SCREEN.fg, SCREEN.bg, SCREEN.field_fg, SCREEN.field_bg)
497
+ program_gets, program_hotkeys = SCREEN.gets, dict(SCREEN.keys.hotkeys)
498
+ SCREEN.gets, SCREEN.keys.hotkeys = [], {}
499
+ SCREEN.color(15, self.scheme['bg'], self.scheme['bar_fg'], self.scheme['bar_bg'])
500
+ self.paused_row, self.row, self.col, self.focus = row, row, 0, 'editor'
501
+ self.message = f'Paused before line {row + 1}: F8=step F5=continue F4=output Run>Stop'
502
+ self.repaint()
503
+ try:
504
+ while True:
505
+ self.draw()
506
+ try:
507
+ key = SCREEN.keys.read_key()
508
+ except KeyboardInterrupt:
509
+ continue
510
+ if key is None:
511
+ raise DebugStop()
512
+ if key in ('F5', 'F8'):
513
+ self.debug_mode = 'step' if key == 'F8' else 'run'
514
+ break
515
+ if key == 'F4':
516
+ self.show_output(program_screen)
517
+ continue
518
+ result = self.handle_key(key)
519
+ if result in ('continue', 'step'):
520
+ self.debug_mode = 'step' if result == 'step' else 'run'
521
+ break
522
+ if result in ('stop', 'quit'):
523
+ raise DebugStop()
524
+ finally:
525
+ self.paused_row = None
526
+ SCREEN.gets, SCREEN.keys.hotkeys = program_gets, program_hotkeys
527
+ SCREEN.fg, SCREEN.bg, SCREEN.field_fg, SCREEN.field_bg = program_colours
528
+ # Back to the program: its screen as it left it
529
+ SCREEN.keys.raw_off()
530
+ sys.stdout.write('\x1b[0m\x1b[2J')
531
+ SCREEN.restore_saved(program_screen)
532
+ SCREEN._w(SCREEN._normal())
533
+
534
+ def show_output(self, program_screen):
535
+ """F4 while paused: the program's screen, until a key is pressed."""
536
+ sys.stdout.write('\x1b[0m\x1b[2J')
537
+ SCREEN.restore_saved(program_screen)
538
+ SCREEN._w('\x1b[?25l')
539
+ SCREEN.keys.read_key()
540
+ self.repaint()
541
+
542
+ # --- editing ---------------------------------------------------------------------
543
+ def remember(self, kind):
544
+ """Saves an undo point before a change, unless it continues the last one
545
+ (typing on the same line is one step)."""
546
+ if kind in ('type', 'delete') and self.last_edit == (kind, self.row):
547
+ return
548
+ self.undo.append((list(self.lines), self.row, self.col, set(self.breakpoints)))
549
+ if len(self.undo) > 500:
550
+ self.undo.pop(0)
551
+ self.last_edit = (kind, self.row)
552
+ self.modified = True
553
+
554
+ def do_undo(self):
555
+ if not self.undo:
556
+ bell()
557
+ self.message = 'Nothing to undo'
558
+ return
559
+ self.lines, self.row, self.col, self.breakpoints = self.undo.pop()
560
+ self.last_edit = None
561
+ self.modified = True
562
+
563
+ def line_inserted(self, at):
564
+ """A line was inserted at index at: the breakpoints below it move down."""
565
+ self.breakpoints = {b + 1 if b >= at else b for b in self.breakpoints}
566
+
567
+ def line_removed(self, at):
568
+ """The line at index at was removed: its breakpoint goes, the ones below move up."""
569
+ self.breakpoints = {b - 1 if b > at else b for b in self.breakpoints if b != at}
570
+
571
+ def type_char(self, ch):
572
+ self.remember('type')
573
+ line = self.lines[self.row]
574
+ if self.insert or self.col >= len(line):
575
+ self.lines[self.row] = line[:self.col] + ch + line[self.col:]
576
+ else:
577
+ self.lines[self.row] = line[:self.col] + ch + line[self.col + 1:]
578
+ self.col += 1
579
+
580
+ def enter(self):
581
+ self.remember('enter')
582
+ line = self.lines[self.row]
583
+ indent = len(line) - len(line.lstrip(' '))
584
+ indent = min(indent, self.col)
585
+ self.lines[self.row] = line[:self.col]
586
+ self.lines.insert(self.row + 1, ' ' * indent + line[self.col:].lstrip(' ') if self.col < len(line) else ' ' * indent)
587
+ # Enter at the start of a line pushes the line (and its breakpoint) down
588
+ self.line_inserted(self.row if self.col == 0 and line.strip() else self.row + 1)
589
+ self.row, self.col = self.row + 1, indent
590
+
591
+ def backspace(self):
592
+ if self.col > 0:
593
+ self.remember('delete')
594
+ line = self.lines[self.row]
595
+ # In leading spaces, go back to the previous indent stop
596
+ if line[:self.col].strip() == '' and self.col % 4 == 0 and self.col >= 4:
597
+ self.lines[self.row] = line[:self.col - 4] + line[self.col:]
598
+ self.col -= 4
599
+ else:
600
+ self.lines[self.row] = line[:self.col - 1] + line[self.col:]
601
+ self.col -= 1
602
+ elif self.row > 0:
603
+ self.remember('join')
604
+ self.col = len(self.lines[self.row - 1])
605
+ self.lines[self.row - 1] += self.lines.pop(self.row)
606
+ self.line_removed(self.row)
607
+ self.row -= 1
608
+
609
+ def delete(self):
610
+ line = self.lines[self.row]
611
+ if self.col < len(line):
612
+ self.remember('delete')
613
+ self.lines[self.row] = line[:self.col] + line[self.col + 1:]
614
+ elif self.row + 1 < len(self.lines):
615
+ self.remember('join')
616
+ self.lines[self.row] = line.ljust(self.col) + self.lines.pop(self.row + 1)
617
+ self.line_removed(self.row + 1)
618
+
619
+ def tab(self, back=False):
620
+ self.remember('indent')
621
+ line = self.lines[self.row]
622
+ if back:
623
+ spaces = min(4, len(line) - len(line.lstrip(' ')))
624
+ self.lines[self.row] = line[spaces:]
625
+ self.col = max(0, self.col - spaces)
626
+ else:
627
+ n = 4 - self.col % 4
628
+ self.lines[self.row] = line[:self.col] + ' ' * n + line[self.col:]
629
+ self.col += n
630
+
631
+ def delete_line(self):
632
+ self.remember('line')
633
+ if len(self.lines) == 1:
634
+ self.lines = ['']
635
+ self.breakpoints = set()
636
+ else:
637
+ self.lines.pop(self.row)
638
+ self.line_removed(self.row)
639
+ self.row = min(self.row, len(self.lines) - 1)
640
+ self.col = min(self.col, len(self.lines[self.row]))
641
+
642
+ def duplicate_line(self):
643
+ self.remember('line')
644
+ self.lines.insert(self.row + 1, self.lines[self.row])
645
+ self.line_inserted(self.row + 1)
646
+ self.row += 1
647
+
648
+ def find(self, again=False):
649
+ if not again or not self.find_text:
650
+ text = self.prompt('Find', 'Find:', self.find_text)
651
+ if not text:
652
+ return
653
+ self.find_text = text
654
+ want = self.find_text.lower()
655
+ n = len(self.lines)
656
+ for k in range(n + 1):
657
+ r = (self.row + k) % n
658
+ start = self.col + 1 if k == 0 else 0
659
+ at = self.lines[r].lower().find(want, start) if k < n or start == 0 else -1
660
+ if at >= 0 and not (k == n and at >= self.col + 1):
661
+ self.row, self.col = r, at
662
+ self.message = f'Found "{self.find_text}"'
663
+ return
664
+ bell()
665
+ self.message = f'"{self.find_text}" was not found'
666
+
667
+ def go_to_line(self):
668
+ text = self.prompt('Go to line', 'Line number:', str(self.row + 1))
669
+ if text and text.isdigit():
670
+ self.row = max(0, min(int(text) - 1, len(self.lines) - 1))
671
+ self.col = 0
672
+
673
+ def word_move(self, direction):
674
+ line = self.lines[self.row]
675
+ if direction < 0:
676
+ if self.col == 0 and self.row > 0:
677
+ self.row -= 1
678
+ self.col = len(self.lines[self.row])
679
+ return
680
+ c = self.col
681
+ while c > 0 and not line[c - 1].isalnum():
682
+ c -= 1
683
+ while c > 0 and line[c - 1].isalnum():
684
+ c -= 1
685
+ self.col = c
686
+ else:
687
+ if self.col >= len(line) and self.row + 1 < len(self.lines):
688
+ self.row += 1
689
+ self.col = 0
690
+ return
691
+ c = self.col
692
+ while c < len(line) and line[c].isalnum():
693
+ c += 1
694
+ while c < len(line) and not line[c].isalnum():
695
+ c += 1
696
+ self.col = c
697
+
698
+ # --- the Immediate window -------------------------------------------------------------
699
+ def execute_immediate(self, text):
700
+ """Runs one line in the Immediate window's session and adds it and its output to the
701
+ window. ? x is PRINT x; CLS clears the window. Returns the output lines."""
702
+ line = text.strip()
703
+ if not line:
704
+ return []
705
+ if line.upper() == 'CLS':
706
+ self.imm_lines, self.imm_scroll = [], 0
707
+ return []
708
+ if re.match(r'(?i)(LINE\s+)?INPUT\b(?!\s*#)', line):
709
+ out = ["INPUT can't be used here: the Immediate window has no keyboard prompt."]
710
+ else:
711
+ code = 'PRINT ' + line[1:] if line.startswith('?') else line
712
+ paused = self.program if self.paused_row is not None else None
713
+ if paused is None and self.session is None:
714
+ self.session = self.new_session()
715
+ session = paused or self.session
716
+ if paused is not None:
717
+ # repl_execute replaces the token stream: keep the paused program's place
718
+ kept = (paused.tokens, paused.token_index, paused.labels, paused.jump_map,
719
+ list(paused.gosub_stack), paused.program_terminated, paused.line_hook)
720
+ paused.line_hook = None
721
+ buffer, saved = io.StringIO(), (sys.stdout, sys.stderr)
722
+ here = os.getcwd()
723
+ colours = (SCREEN.fg, SCREEN.bg, SCREEN.field_fg, SCREEN.field_bg)
724
+ try:
725
+ sys.stdout = sys.stderr = buffer # what the line prints goes to the window
726
+ if self.path:
727
+ os.chdir(os.path.dirname(self.path)) # file names are relative to the program
728
+ session.repl_execute(code)
729
+ except KeyboardInterrupt:
730
+ buffer.write('Interrupted.\n')
731
+ finally:
732
+ sys.stdout, sys.stderr = saved
733
+ if paused is not None:
734
+ (paused.tokens, paused.token_index, paused.labels, paused.jump_map,
735
+ paused.gosub_stack, paused.program_terminated, paused.line_hook) = kept
736
+ os.chdir(here)
737
+ SCREEN.fg, SCREEN.bg, SCREEN.field_fg, SCREEN.field_bg = colours # a COLOR in the line
738
+ SCREEN.gets = [] # a GET in the line
739
+ # Screen statements in the line drew with escape codes; keep only their text
740
+ text_out = re.sub(r'\x1b\[[0-9;?<]*[A-Za-z]', '', buffer.getvalue()).replace('\r', '')
741
+ out = [l.expandtabs(8) for l in text_out.rstrip('\n').split('\n')] if text_out.strip() else []
742
+ # An error: just its message (its position in a one-line program means nothing here)
743
+ for n, l in enumerate(out):
744
+ if l.startswith('REPL Error: '):
745
+ message = re.sub(r' at line \d+, col \d+\.?$', '', l[len('REPL Error: '):])
746
+ out = out[:n] + ['Error: ' + message]
747
+ break
748
+ self.imm_lines.extend(['> ' + line] + out)
749
+ del self.imm_lines[:-1000]
750
+ if not self.imm_history or self.imm_history[-1] != line:
751
+ self.imm_history.append(line)
752
+ self.imm_recall, self.imm_scroll = None, 0
753
+ return out
754
+
755
+ def immediate_key(self, key):
756
+ """A key typed in the Immediate window."""
757
+ text, pos = self.imm_text, self.imm_pos
758
+ if key == ENTER:
759
+ self.execute_immediate(text)
760
+ self.imm_text, self.imm_pos = '', 0
761
+ self.repaint() # in case the line drew on the screen
762
+ return
763
+ if key in (ESC, 'F6'):
764
+ self.focus = 'editor'
765
+ elif key == BACKSPACE and pos > 0:
766
+ self.imm_text, self.imm_pos = text[:pos - 1] + text[pos:], pos - 1
767
+ elif key == 'DEL':
768
+ self.imm_text = text[:pos] + text[pos + 1:]
769
+ elif key == 'LEFT':
770
+ self.imm_pos = max(0, pos - 1)
771
+ elif key == 'RIGHT':
772
+ self.imm_pos = min(len(text), pos + 1)
773
+ elif key == 'HOME':
774
+ self.imm_pos = 0
775
+ elif key == 'END':
776
+ self.imm_pos = len(text)
777
+ elif key in ('UP', 'DOWN') and self.imm_history:
778
+ n = len(self.imm_history)
779
+ i = self.imm_recall if self.imm_recall is not None else n
780
+ i = max(0, i - 1) if key == 'UP' else i + 1
781
+ if i >= n:
782
+ self.imm_recall, self.imm_text = None, ''
783
+ else:
784
+ self.imm_recall, self.imm_text = i, self.imm_history[i]
785
+ self.imm_pos = len(self.imm_text)
786
+ elif key in ('PGUP', 'PGDN', 'WHEELUP', 'WHEELDOWN'):
787
+ step = self.IMMEDIATE_HEIGHT - 1 if key in ('PGUP', 'PGDN') else 3
788
+ back = key in ('PGUP', 'WHEELUP')
789
+ most = max(0, len(self.imm_lines) - (self.IMMEDIATE_HEIGHT - 1))
790
+ self.imm_scroll = max(0, min(most, self.imm_scroll + (step if back else -step)))
791
+ elif len(key) == 1 and key.isprintable():
792
+ self.imm_text, self.imm_pos = text[:pos] + key + text[pos:], pos + 1
793
+ else:
794
+ bell()
795
+
796
+ # --- drawing ---------------------------------------------------------------------
797
+ def area(self):
798
+ rows, cols = SCREEN.size()
799
+ last = rows - 2 - (self.IMMEDIATE_HEIGHT + 1 if self.show_immediate else 0)
800
+ return 3, last, cols - 2 # first and last text row, text width
801
+
802
+ def draw_immediate(self, rows, cols):
803
+ """The Immediate window: a title line, the newest lines, and the line being typed."""
804
+ s = self.scheme
805
+ first, last, width = self.area()
806
+ title = ' Immediate ' if self.focus != 'immediate' else ' Immediate (F6 or Esc: back to the editor) '
807
+ bar = '─' * (cols - 2)
808
+ start = max(0, (cols - 2 - len(title)) // 2)
809
+ bar = bar[:start] + title + bar[start + len(title):]
810
+ out = [SCREEN._put(last + 1, 1, '├' + bar[:cols - 2] + '┤', s['frame'], s['bg'])]
811
+ shown = self.IMMEDIATE_HEIGHT - 1
812
+ end = len(self.imm_lines) - self.imm_scroll
813
+ lines = self.imm_lines[max(0, end - shown):end]
814
+ lines = [''] * (shown - len(lines)) + lines
815
+ for n, text in enumerate(lines):
816
+ row = last + 2 + n
817
+ colour = s['keyword'] if text.startswith('> ') else s['text']
818
+ out.append(SCREEN._put(row, 1, '│', s['frame'], s['bg']))
819
+ out.append(SCREEN._put(row, 2, text[:width].ljust(width), colour, s['bg']))
820
+ out.append(SCREEN._put(row, cols, '│', s['frame'], s['bg']))
821
+ row = last + 2 + shown
822
+ typed = '> ' + self.imm_text
823
+ offset = max(0, len(typed) - width + 1) if self.imm_pos + 2 >= width else 0
824
+ out.append(SCREEN._put(row, 1, '│', s['frame'], s['bg']))
825
+ out.append(SCREEN._put(row, 2, typed[offset:offset + width].ljust(width), s['keyword'], s['bg']))
826
+ out.append(SCREEN._put(row, cols, '│', s['frame'], s['bg']))
827
+ return ''.join(out), row, 2 + 2 + self.imm_pos - offset
828
+
829
+ def scroll_to_cursor(self):
830
+ first, last, width = self.area()
831
+ height = last - first + 1
832
+ if self.row < self.top:
833
+ self.top = self.row
834
+ elif self.row >= self.top + height:
835
+ self.top = self.row - height + 1
836
+ if self.col < self.left:
837
+ self.left = max(0, self.col - 8)
838
+ elif self.col >= self.left + width:
839
+ self.left = self.col - width + 8
840
+
841
+ def colours_of(self, line):
842
+ """The colour of each character of a line."""
843
+ s = self.scheme
844
+ out = [s['text']] * len(line)
845
+
846
+ def paint(a, b, colour):
847
+ for i in range(a, min(b, len(line))):
848
+ out[i] = colour
849
+
850
+ i, statement_start = 0, True
851
+ while i < len(line):
852
+ ch = line[i]
853
+ if ch in '"`\'':
854
+ j = i + 1
855
+ while j < len(line):
856
+ if line[j] == ch:
857
+ if ch == '"' and j + 1 < len(line) and line[j + 1] == '"':
858
+ j += 2
859
+ continue
860
+ break
861
+ j += 1
862
+ paint(i, j + 1, s['string'])
863
+ i, statement_start = j + 1, False
864
+ continue
865
+ if line.startswith('--', i):
866
+ paint(i, len(line), s['comment'])
867
+ break
868
+ m = re.match(r'[A-Za-z_][A-Za-z0-9_]*\$?', line[i:])
869
+ if m:
870
+ word = m.group()
871
+ if word.upper() == 'REM' and statement_start:
872
+ paint(i, len(line), s['comment'])
873
+ break
874
+ # A word after a . is a field (c.name), not a keyword
875
+ if word.upper() in self.keywords and not (i > 0 and line[i - 1] == '.'):
876
+ paint(i, i + len(word), s['keyword'])
877
+ i += len(word)
878
+ statement_start = False
879
+ continue
880
+ m = re.match(r'\d+(\.\d+)?([eE][+-]?\d+)?|\.\d+', line[i:])
881
+ if m:
882
+ paint(i, i + len(m.group()), s['number'])
883
+ i += len(m.group())
884
+ statement_start = False
885
+ continue
886
+ if ch == ':':
887
+ statement_start = True
888
+ elif not ch.isspace():
889
+ statement_start = False
890
+ i += 1
891
+ return out
892
+
893
+ def draw_line(self, screen_row, text_row, width):
894
+ s = self.scheme
895
+ if text_row >= len(self.lines):
896
+ return SCREEN._put(screen_row, 2, ' ' * width, s['text'], s['bg'])
897
+ line = self.lines[text_row]
898
+ shown = line[self.left:self.left + width]
899
+ if text_row == self.paused_row: # the line the program is paused before
900
+ return SCREEN._put(screen_row, 2, shown.ljust(width), 0, 7)
901
+ if text_row in self.breakpoints:
902
+ return SCREEN._put(screen_row, 2, shown.ljust(width), 15, 4)
903
+ colours = self.colours_of(line)[self.left:self.left + width]
904
+ out, start = [], 0
905
+ for k in range(1, len(shown) + 1):
906
+ if k == len(shown) or colours[k] != colours[start]:
907
+ out.append(SCREEN._put(screen_row, 2 + start, shown[start:k], colours[start], s['bg']))
908
+ start = k
909
+ if len(shown) < width:
910
+ out.append(SCREEN._put(screen_row, 2 + len(shown), ' ' * (width - len(shown)), s['text'], s['bg']))
911
+ return ''.join(out)
912
+
913
+ def title(self):
914
+ name = os.path.basename(self.path) if self.path else 'Untitled'
915
+ return name + (' *' if self.modified else '')
916
+
917
+ def draw_menu_bar(self):
918
+ s = self.scheme
919
+ rows, cols = SCREEN.size()
920
+ out = [SCREEN._put(1, 1, ' ' * cols, s['bar_fg'], s['bar_bg'])]
921
+ x = 2
922
+ for name in ('File', 'Edit', 'Run', 'Options', 'Help'):
923
+ out.append(SCREEN._put(1, x, ' ' + name + ' ', s['bar_fg'], s['bar_bg']))
924
+ x += len(name) + 3
925
+ return ''.join(out)
926
+
927
+ def draw(self):
928
+ s = self.scheme
929
+ rows, cols = SCREEN.size()
930
+ first, last, width = self.area()
931
+ self.scroll_to_cursor()
932
+ out = [self.draw_menu_bar()]
933
+ title = f' {self.title()} '
934
+ bar = '─' * (cols - 2)
935
+ start = max(0, (cols - 2 - len(title)) // 2)
936
+ bar = bar[:start] + title + bar[start + len(title):]
937
+ out.append(SCREEN._put(2, 1, '┌' + bar[:cols - 2] + '┐', s['frame'], s['bg']))
938
+ for n, screen_row in enumerate(range(first, last + 1)):
939
+ out.append(SCREEN._put(screen_row, 1, '│', s['frame'], s['bg']))
940
+ out.append(self.draw_line(screen_row, self.top + n, width))
941
+ out.append(SCREEN._put(screen_row, cols, '│', s['frame'], s['bg']))
942
+ cursor_row = first + self.row - self.top
943
+ cursor_col = 2 + self.col - self.left
944
+ if self.show_immediate:
945
+ pane, input_row, input_col = self.draw_immediate(rows, cols)
946
+ out.append(pane)
947
+ if self.focus == 'immediate':
948
+ cursor_row, cursor_col = input_row, input_col
949
+ out.append(SCREEN._put(rows - 1, 1, '└' + '─' * (cols - 2) + '┘', s['frame'], s['bg']))
950
+ position = f'Ln {self.row + 1} Col {self.col + 1} {"Ins" if self.insert else "Ovr"} '
951
+ if self.focus == 'immediate':
952
+ hint = 'Enter runs the line ? x prints x Up=recall F6=editor'
953
+ else:
954
+ hint = 'F1=Help F5=Run F6=Immediate F10=Menu Ctrl-S=Save Alt-X=Exit'
955
+ left = ' ' + (self.message or hint)
956
+ status = left[:cols - len(position) - 1].ljust(cols - len(position) - 1) + position
957
+ out.append(SCREEN._put(rows, 1, status[:cols - 1], s['bar_fg'], s['bar_bg']))
958
+ SCREEN._w(''.join(out) + SCREEN._normal() + f'\x1b[{cursor_row};{cursor_col}H\x1b[?25h')
959
+
960
+ def set_scheme(self, name):
961
+ """The editor's colours; dialogs, menus and help windows follow the background."""
962
+ self.scheme = _SCHEMES[name]
963
+ SCREEN.color(15, self.scheme['bg'], self.scheme['bar_fg'], self.scheme['bar_bg'])
964
+ self.repaint()
965
+
966
+ def repaint(self):
967
+ SCREEN._w('\x1b[0m\x1b[2J')
968
+ SCREEN.rows, SCREEN.cols = SCREEN.size()
969
+ SCREEN.cells = SCREEN._blank(SCREEN.rows, SCREEN.cols, 7, 0)
970
+ self.draw()
971
+
972
+ # --- the menu bar ----------------------------------------------------------------------
973
+ MENUS = [
974
+ ('File', ['New Ctrl-N', 'Open... Ctrl-O', 'Save Ctrl-S',
975
+ 'Save As...', '-', 'Exit Alt-X']),
976
+ ('Edit', ['Undo Ctrl-Z', '-', 'Delete line Ctrl-Y', 'Duplicate line Ctrl-D', '-',
977
+ 'Find... Ctrl-F', 'Find next F3', 'Go to line... Ctrl-G']),
978
+ ('Run', ['Start/Continue F5', 'Step F8', 'Breakpoint F9', 'Clear breakpoints',
979
+ 'Stop', '-', 'Output screen F4']),
980
+ ('Options', ['Blue screen', 'Black screen', '-', 'Mouse on', 'Mouse off', '-', 'Immediate window F6']),
981
+ ('Help', ['Help on word F1', 'Topics... Shift-F1', 'Keys', '-', 'About']),
982
+ ]
983
+
984
+ @staticmethod
985
+ def command_name(menu, item):
986
+ """The command of a menu item: 'Run.Start/Continue' for Run's 'Start/Continue F5'
987
+ (the shortcut at the end is not part of it)."""
988
+ item = re.sub(r'\s+(F\d+|(Ctrl|Alt|Shift)-\S+)$', '', item.strip())
989
+ return f'{menu}.{item.strip()}'
990
+
991
+ def menu(self, first_letter=None):
992
+ if first_letter:
993
+ SCREEN.keys.pending.insert(0, first_letter)
994
+ choice = SCREEN.menubar(self.MENUS, leave_easily=True)
995
+ if not choice:
996
+ return None
997
+ menu, item = choice.split('.', 1)
998
+ return self.command_name(menu, item)
999
+
1000
+ def command(self, name):
1001
+ """Runs a menu command; returns False to leave the IDE."""
1002
+ if name == 'File.New':
1003
+ self.new_file()
1004
+ elif name == 'File.Open...':
1005
+ self.open_file()
1006
+ elif name == 'File.Save':
1007
+ self.save()
1008
+ elif name == 'File.Save As...':
1009
+ self.save_as()
1010
+ elif name == 'File.Exit':
1011
+ if self.paused_row is not None:
1012
+ return 'stop' # leave the paused program first
1013
+ return 'quit' if self.keep_changes() else True
1014
+ elif name == 'Edit.Undo':
1015
+ self.do_undo()
1016
+ elif name == 'Edit.Delete line':
1017
+ self.delete_line()
1018
+ elif name == 'Edit.Duplicate line':
1019
+ self.duplicate_line()
1020
+ elif name == 'Edit.Find...':
1021
+ self.find()
1022
+ elif name == 'Edit.Find next':
1023
+ self.find(again=True)
1024
+ elif name == 'Edit.Go to line...':
1025
+ self.go_to_line()
1026
+ elif name == 'Run.Start/Continue':
1027
+ if self.paused_row is not None:
1028
+ return 'continue'
1029
+ if self.breakpoints:
1030
+ self.debug(step=False) # breakpoints: run inside the IDE to stop at them
1031
+ else:
1032
+ self.run()
1033
+ elif name == 'Run.Step':
1034
+ if self.paused_row is not None:
1035
+ return 'step'
1036
+ self.debug(step=True)
1037
+ elif name == 'Run.Breakpoint':
1038
+ self.toggle_breakpoint()
1039
+ elif name == 'Run.Clear breakpoints':
1040
+ self.breakpoints = set()
1041
+ self.message = 'All breakpoints cleared'
1042
+ elif name == 'Run.Stop':
1043
+ if self.paused_row is None:
1044
+ bell()
1045
+ self.message = 'No program is paused'
1046
+ else:
1047
+ return 'stop'
1048
+ elif name == 'Run.Output screen':
1049
+ bell()
1050
+ self.message = 'F4 shows the program\'s screen while it is paused'
1051
+ elif name == 'Options.Blue screen':
1052
+ self.set_scheme('blue')
1053
+ elif name == 'Options.Black screen':
1054
+ self.set_scheme('black')
1055
+ elif name == 'Options.Immediate window':
1056
+ if self.new_session is None:
1057
+ bell()
1058
+ elif self.show_immediate and self.focus == 'editor':
1059
+ self.show_immediate = False # hide it, giving the editor the room
1060
+ else:
1061
+ self.show_immediate, self.focus = True, 'immediate'
1062
+ self.repaint()
1063
+ elif name in ('Options.Mouse on', 'Options.Mouse off'):
1064
+ SCREEN.keys.set_mouse(name.endswith('on'))
1065
+ self.message = 'The mouse is ' + ('on' if name.endswith('on') else 'off')
1066
+ elif name == 'Help.Help on word':
1067
+ topic = self.help_topic_at_cursor(self.imm_text, self.imm_pos) if self.focus == 'immediate' \
1068
+ else self.help_topic_at_cursor()
1069
+ if topic:
1070
+ self.show_help(topic)
1071
+ else:
1072
+ self.help_topics_list()
1073
+ elif name == 'Help.Topics...':
1074
+ self.help_topics_list()
1075
+ elif name == 'Help.Keys':
1076
+ rows, cols = SCREEN.size()
1077
+ SCREEN.view(_HELP_KEYS, (4, 3, rows - 4, cols - 2), 'Keys (Esc closes)')
1078
+ elif name == 'Help.About':
1079
+ SCREEN.alert('DDGBasic IDE;A BASIC with SQLite and DuckDB built in.;Press F1 on any keyword for its help.',
1080
+ ['OK'])
1081
+ return True
1082
+
1083
+ # Every command a menu item or a key can give (tests/ide_check.py checks the menus against it)
1084
+ COMMANDS = {
1085
+ 'File.New', 'File.Open...', 'File.Save', 'File.Save As...', 'File.Exit',
1086
+ 'Edit.Undo', 'Edit.Delete line', 'Edit.Duplicate line', 'Edit.Find...', 'Edit.Find next',
1087
+ 'Edit.Go to line...', 'Run.Start/Continue', 'Run.Step', 'Run.Breakpoint', 'Run.Clear breakpoints',
1088
+ 'Run.Stop', 'Run.Output screen', 'Options.Blue screen', 'Options.Black screen', 'Options.Mouse on',
1089
+ 'Options.Mouse off', 'Options.Immediate window', 'Help.Help on word', 'Help.Topics...', 'Help.Keys',
1090
+ 'Help.About',
1091
+ }
1092
+
1093
+ # --- the main loop ---------------------------------------------------------------------
1094
+ KEY_COMMANDS = {
1095
+ '\x0e': 'File.New', '\x0f': 'File.Open...', '\x13': 'File.Save', '\x1a': 'Edit.Undo',
1096
+ '\x19': 'Edit.Delete line', '\x04': 'Edit.Duplicate line', '\x06': 'Edit.Find...',
1097
+ 'F3': 'Edit.Find next', '\x07': 'Edit.Go to line...', 'F5': 'Run.Start/Continue', 'F8': 'Run.Step',
1098
+ 'F9': 'Run.Breakpoint', 'F1': 'Help.Help on word',
1099
+ 'ALT-X': 'File.Exit', '\x11': 'File.Exit',
1100
+ }
1101
+
1102
+ # Commands that change the text, refused while a program is paused
1103
+ EDIT_COMMANDS = ('Edit.Undo', 'Edit.Delete line', 'Edit.Duplicate line', 'File.New', 'File.Open...')
1104
+
1105
+ def loop(self):
1106
+ SCREEN.keys.block_suspend = True
1107
+ SCREEN.keys.raw_off() # take the new setting on the next key
1108
+ self.set_scheme('blue')
1109
+ while True:
1110
+ self.draw()
1111
+ try:
1112
+ key = SCREEN.keys.read_key()
1113
+ except KeyboardInterrupt:
1114
+ self.message = 'Ctrl-C does nothing here: Alt-X (or File > Exit) leaves the IDE'
1115
+ continue
1116
+ if key is None:
1117
+ return
1118
+ if self.handle_key(key) == 'quit':
1119
+ return
1120
+
1121
+ def handle_key(self, key):
1122
+ """One key, in the editor or the Immediate window. Returns 'quit' to leave the IDE,
1123
+ or, while a program is paused, 'continue', 'step' or 'stop'."""
1124
+ if key == '':
1125
+ return None
1126
+ paused = self.paused_row is not None
1127
+ if not paused or not self.message.startswith('Paused'):
1128
+ self.message = ''
1129
+ name = self.KEY_COMMANDS.get(key)
1130
+ if key == 'F10' or (key.startswith('ALT-') and key != 'ALT-X'):
1131
+ letter = key[4:] if key.startswith('ALT-') else None
1132
+ name = self.menu(letter)
1133
+ if name is None:
1134
+ return None
1135
+ if key == 'SHIFT-F1':
1136
+ name = 'Help.Topics...'
1137
+ if key == 'F6' and self.new_session is not None:
1138
+ if self.focus == 'editor':
1139
+ self.show_immediate, self.focus = True, 'immediate'
1140
+ self.repaint()
1141
+ else:
1142
+ self.focus = 'editor'
1143
+ return None
1144
+ if paused and name in self.EDIT_COMMANDS:
1145
+ bell()
1146
+ self.message = 'The program is paused: Run > Stop (or F5 to the end) before changing it'
1147
+ return None
1148
+ if self.focus == 'immediate' and name in ('Edit.Undo', 'Edit.Delete line', 'Edit.Duplicate line',
1149
+ 'Edit.Go to line...', 'Run.Breakpoint'):
1150
+ bell() # editor commands: not while typing in the Immediate window
1151
+ return None
1152
+ if name:
1153
+ result = self.command(name)
1154
+ if result in ('quit', 'continue', 'step', 'stop'):
1155
+ return result
1156
+ return None
1157
+ first, last, width = self.area()
1158
+ if key in ('CLICK', 'WHEELUP', 'WHEELDOWN') and self.show_immediate:
1159
+ r = SCREEN.keys.mouse_row
1160
+ in_pane = last + 1 < r < SCREEN.size()[0] - 1
1161
+ if key == 'CLICK' and in_pane:
1162
+ self.focus = 'immediate'
1163
+ return None
1164
+ if key == 'CLICK' and first <= r <= last:
1165
+ self.focus = 'editor'
1166
+ if key != 'CLICK' and in_pane:
1167
+ self.immediate_key(key)
1168
+ return None
1169
+ if self.focus == 'immediate' and key != 'CLICK':
1170
+ self.immediate_key(key)
1171
+ return None
1172
+ if paused and (key in (ENTER, BACKSPACE, 'DEL', TAB, 'BTAB') or (len(key) == 1 and key.isprintable())):
1173
+ bell()
1174
+ self.message = 'The program is paused: Run > Stop (or F5 to the end) before changing it'
1175
+ return None
1176
+ height = last - first + 1
1177
+ line = self.lines[self.row]
1178
+ if key in ('UP', 'DOWN', 'PGUP', 'PGDN', 'LEFT', 'RIGHT', 'HOME', 'END', 'CTRL-HOME',
1179
+ 'CTRL-END', 'CTRL-LEFT', 'CTRL-RIGHT', 'CLICK', 'WHEELUP', 'WHEELDOWN'):
1180
+ self.last_edit = None
1181
+ if key == 'UP':
1182
+ self.row = max(0, self.row - 1)
1183
+ elif key == 'DOWN':
1184
+ self.row = min(len(self.lines) - 1, self.row + 1)
1185
+ elif key == 'PGUP':
1186
+ self.row = max(0, self.row - height)
1187
+ self.top = max(0, self.top - height)
1188
+ elif key == 'PGDN':
1189
+ self.row = min(len(self.lines) - 1, self.row + height)
1190
+ self.top = min(max(0, len(self.lines) - height), self.top + height)
1191
+ elif key == 'LEFT':
1192
+ if self.col > 0:
1193
+ self.col -= 1
1194
+ elif self.row > 0:
1195
+ self.row -= 1
1196
+ self.col = len(self.lines[self.row])
1197
+ elif key == 'RIGHT':
1198
+ if self.col < len(line):
1199
+ self.col += 1
1200
+ elif self.row + 1 < len(self.lines):
1201
+ self.row, self.col = self.row + 1, 0
1202
+ elif key == 'HOME':
1203
+ indent = len(line) - len(line.lstrip(' '))
1204
+ self.col = 0 if self.col == indent else indent
1205
+ elif key == 'END':
1206
+ self.col = len(line)
1207
+ elif key == 'CTRL-HOME':
1208
+ self.row = self.col = 0
1209
+ elif key == 'CTRL-END':
1210
+ self.row = len(self.lines) - 1
1211
+ self.col = len(self.lines[self.row])
1212
+ elif key in ('CTRL-LEFT', 'CTRL-RIGHT'):
1213
+ self.word_move(-1 if key == 'CTRL-LEFT' else 1)
1214
+ elif key == 'CLICK':
1215
+ r, c = SCREEN.keys.mouse_row, SCREEN.keys.mouse_col
1216
+ if first <= r <= last:
1217
+ self.row = min(len(self.lines) - 1, self.top + r - first)
1218
+ self.col = max(0, self.left + c - 2)
1219
+ elif r == 1:
1220
+ # A click on the menu bar: hand the same click to the menu bar, which opens
1221
+ # the menu under it
1222
+ SCREEN.keys.pending[0:0] = list(f'\x1b[<0;{c};1M')
1223
+ name = self.menu()
1224
+ if name:
1225
+ result = self.command(name)
1226
+ if result in ('quit', 'continue', 'step', 'stop'):
1227
+ return result
1228
+ elif key in ('WHEELUP', 'WHEELDOWN'):
1229
+ step = -3 if key == 'WHEELUP' else 3
1230
+ self.top = max(0, min(self.top + step, max(0, len(self.lines) - height)))
1231
+ self.row = max(self.top, min(self.row, self.top + height - 1, len(self.lines) - 1))
1232
+ elif key == 'INS':
1233
+ self.insert = not self.insert
1234
+ elif key == ENTER:
1235
+ self.enter()
1236
+ elif key == BACKSPACE:
1237
+ self.backspace()
1238
+ elif key == 'DEL':
1239
+ self.delete()
1240
+ elif key == TAB:
1241
+ self.tab()
1242
+ elif key == 'BTAB':
1243
+ self.tab(back=True)
1244
+ elif len(key) == 1 and key.isprintable():
1245
+ self.type_char(key)
1246
+ elif key == ESC:
1247
+ pass
1248
+ else:
1249
+ bell()
1250
+ self.col = min(self.col, len(self.lines[self.row]))
1251
+ return None
1252
+
1253
+
1254
+ def run_ide(path, runner, help_topics, get_help, new_session=None, new_program=None):
1255
+ """Opens the IDE on path (or a new program) and returns when the user leaves it.
1256
+ new_session() makes the interpreter of the Immediate window; new_program(source,
1257
+ folder) makes one for a debug run."""
1258
+ if not sys.stdin.isatty() or not sys.stdout.isatty():
1259
+ print("The IDE needs an interactive terminal.", file=sys.stderr)
1260
+ return 1
1261
+ editor = Editor(path, runner, help_topics, get_help, new_session, new_program)
1262
+ mouse_before = SCREEN.keys.mouse_wanted
1263
+ SCREEN.keys.set_mouse(True) # off by default on Windows; the IDE wants clicks
1264
+ try:
1265
+ editor.loop()
1266
+ finally:
1267
+ SCREEN.keys.set_mouse(mouse_before)
1268
+ SCREEN.keys.block_suspend = False
1269
+ if editor.session is not None:
1270
+ try:
1271
+ editor.session.close()
1272
+ except Exception:
1273
+ pass
1274
+ SCREEN.restore()
1275
+ sys.stdout.write('\x1b[0m\x1b[2J\x1b[H')
1276
+ sys.stdout.flush()
1277
+ return 0