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
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
|