qubasic 0.1.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.
Files changed (48) hide show
  1. qbasic.py +113 -0
  2. qbasic_core/__init__.py +80 -0
  3. qbasic_core/__main__.py +6 -0
  4. qbasic_core/analysis.py +179 -0
  5. qbasic_core/backend.py +76 -0
  6. qbasic_core/classic.py +419 -0
  7. qbasic_core/control_flow.py +576 -0
  8. qbasic_core/debug.py +327 -0
  9. qbasic_core/demos.py +356 -0
  10. qbasic_core/display.py +274 -0
  11. qbasic_core/engine.py +126 -0
  12. qbasic_core/engine_state.py +109 -0
  13. qbasic_core/errors.py +37 -0
  14. qbasic_core/exec_context.py +24 -0
  15. qbasic_core/executor.py +861 -0
  16. qbasic_core/expression.py +228 -0
  17. qbasic_core/file_io.py +457 -0
  18. qbasic_core/gates.py +284 -0
  19. qbasic_core/help_text.py +167 -0
  20. qbasic_core/io_protocol.py +33 -0
  21. qbasic_core/locc.py +10 -0
  22. qbasic_core/locc_commands.py +221 -0
  23. qbasic_core/locc_display.py +61 -0
  24. qbasic_core/locc_engine.py +195 -0
  25. qbasic_core/locc_execution.py +389 -0
  26. qbasic_core/memory.py +369 -0
  27. qbasic_core/mock_backend.py +66 -0
  28. qbasic_core/noise_mixin.py +96 -0
  29. qbasic_core/parser.py +564 -0
  30. qbasic_core/patterns.py +186 -0
  31. qbasic_core/profiler.py +156 -0
  32. qbasic_core/program_mgmt.py +369 -0
  33. qbasic_core/protocol.py +77 -0
  34. qbasic_core/py.typed +0 -0
  35. qbasic_core/scope.py +74 -0
  36. qbasic_core/screen.py +115 -0
  37. qbasic_core/state_display.py +60 -0
  38. qbasic_core/statements.py +387 -0
  39. qbasic_core/strings.py +107 -0
  40. qbasic_core/subs.py +261 -0
  41. qbasic_core/sweep.py +82 -0
  42. qbasic_core/terminal.py +1697 -0
  43. qubasic-0.1.0.dist-info/METADATA +736 -0
  44. qubasic-0.1.0.dist-info/RECORD +48 -0
  45. qubasic-0.1.0.dist-info/WHEEL +5 -0
  46. qubasic-0.1.0.dist-info/entry_points.txt +2 -0
  47. qubasic-0.1.0.dist-info/licenses/LICENSE +21 -0
  48. qubasic-0.1.0.dist-info/top_level.txt +2 -0
@@ -0,0 +1,369 @@
1
+ """QBASIC program management — AUTO, EDIT, COPY, MOVE, FIND, REPLACE, slots, CHECKSUM, CHAIN, MERGE, LIST+."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ import hashlib
8
+ from typing import Any
9
+
10
+ from qbasic_core.engine import RE_CHAIN, RE_MERGE, RE_DEF_BEGIN
11
+
12
+
13
+ class ProgramMgmtMixin:
14
+ """Program management commands for QBasicTerminal.
15
+
16
+ Requires: TerminalProtocol — uses self.program, self.variables,
17
+ self.num_qubits, self.shots, self.process(), self.cmd_new(),
18
+ self._sanitize_path().
19
+ """
20
+
21
+ def _init_program_mgmt(self) -> None:
22
+ self._program_slots: dict[int, dict[int, str]] = {}
23
+ self._current_slot: int = 0
24
+ self._auto_mode: bool = False
25
+ self._auto_line: int = 10
26
+ self._auto_step: int = 10
27
+
28
+ # ── AUTO ───────────────────────────────────────────────────────────
29
+
30
+ def cmd_auto(self, rest: str = '') -> None:
31
+ """AUTO [start][, step] — auto-generate line numbers."""
32
+ parts = rest.replace(',', ' ').split()
33
+ if parts:
34
+ self._auto_line = int(parts[0])
35
+ if len(parts) > 1:
36
+ self._auto_step = int(parts[1])
37
+ self._auto_mode = True
38
+ self.io.writeln(f"AUTO {self._auto_line}, {self._auto_step} — type . to stop")
39
+ while self._auto_mode:
40
+ try:
41
+ line = self.io.read_line(f'{self._auto_line} ').rstrip()
42
+ if line == '.':
43
+ self._auto_mode = False
44
+ break
45
+ if line:
46
+ self.process(f'{self._auto_line} {line}')
47
+ self._auto_line += self._auto_step
48
+ except (KeyboardInterrupt, EOFError):
49
+ self._auto_mode = False
50
+ self.io.writeln('')
51
+ break
52
+
53
+ # ── EDIT ───────────────────────────────────────────────────────────
54
+
55
+ def cmd_edit(self, rest: str) -> None:
56
+ """EDIT <line> — edit a specific line."""
57
+ if not rest.strip():
58
+ self.io.writeln("?USAGE: EDIT <line>")
59
+ return
60
+ num = int(rest.strip())
61
+ if num not in self.program:
62
+ self.io.writeln(f"?LINE {num} NOT FOUND")
63
+ return
64
+ current = self.program[num]
65
+ self.io.writeln(f" {num} {current}")
66
+ try:
67
+ new_content = self.io.read_line(f'{num} ').rstrip()
68
+ if new_content:
69
+ self.program[num] = new_content
70
+ self.io.writeln(f" UPDATED {num}")
71
+ else:
72
+ self.io.writeln(" (unchanged)")
73
+ except (KeyboardInterrupt, EOFError):
74
+ self.io.writeln("\n (cancelled)")
75
+
76
+ # ── LIST enhancements ──────────────────────────────────────────────
77
+
78
+ def cmd_list_subs(self) -> None:
79
+ """LIST SUBS — list all SUB/FUNCTION definitions."""
80
+ found = False
81
+ for ln in sorted(self.program.keys()):
82
+ stmt = self.program[ln].strip().upper()
83
+ if stmt.startswith('SUB ') or stmt.startswith('FUNCTION '):
84
+ self.io.writeln(f" {ln:5d} {self.program[ln]}")
85
+ found = True
86
+ if self.subroutines:
87
+ for name, sub in self.subroutines.items():
88
+ if isinstance(sub, dict):
89
+ params = f"({', '.join(sub['params'])})" if sub['params'] else ""
90
+ self.io.writeln(f" DEF {name}{params} ({len(sub['body'])} gates)")
91
+ found = True
92
+ if not found:
93
+ self.io.writeln(" No subroutines defined")
94
+
95
+ def cmd_list_vars(self) -> None:
96
+ """LIST VARS — alias for VARS with types."""
97
+ if not self.variables:
98
+ self.io.writeln(" No variables")
99
+ return
100
+ for name, val in sorted(self.variables.items()):
101
+ typ = type(val).__name__
102
+ self.io.writeln(f" {name} = {val} ({typ})")
103
+
104
+ def cmd_list_arrays(self) -> None:
105
+ """LIST ARRAYS — list all arrays with sizes."""
106
+ if not self.arrays:
107
+ self.io.writeln(" No arrays")
108
+ return
109
+ for name, data in sorted(self.arrays.items()):
110
+ if isinstance(data, list):
111
+ self.io.writeln(f" {name}({len(data)}) = {data[:5]}{'...' if len(data) > 5 else ''}")
112
+ elif isinstance(data, dict):
113
+ self.io.writeln(f" {name}({len(data)} dims)")
114
+
115
+ # ── Program slots (BANK) ──────────────────────────────────────────
116
+
117
+ def cmd_bank(self, rest: str) -> None:
118
+ """BANK <n> — switch to program slot n."""
119
+ if not rest.strip():
120
+ self.io.writeln(f" Current slot: {self._current_slot}")
121
+ self.io.writeln(f" Slots in use: {sorted(self._program_slots.keys())}")
122
+ return
123
+ slot = int(rest.strip())
124
+ # Save current
125
+ self._program_slots[self._current_slot] = dict(self.program)
126
+ # Load target
127
+ self._current_slot = slot
128
+ if slot in self._program_slots:
129
+ self.program = dict(self._program_slots[slot])
130
+ self.io.writeln(f"BANK {slot} ({len(self.program)} lines)")
131
+ else:
132
+ self.program = {}
133
+ self.io.writeln(f"BANK {slot} (empty)")
134
+
135
+ # ── COPY / MOVE ────────────────────────────────────────────────────
136
+
137
+ def cmd_copy(self, rest: str) -> None:
138
+ """COPY <start>-<end> TO <dest> — copy line range."""
139
+ m = self._parse_range_to(rest)
140
+ if not m:
141
+ self.io.writeln("?USAGE: COPY <start>-<end> TO <dest>")
142
+ return
143
+ start, end, dest = m
144
+ offset = dest - start
145
+ for ln in sorted(self.program.keys()):
146
+ if start <= ln <= end:
147
+ self.program[ln + offset] = self.program[ln]
148
+ self.io.writeln(f"COPIED {start}-{end} TO {dest}-{end + offset}")
149
+
150
+ def cmd_move(self, rest: str) -> None:
151
+ """MOVE <start>-<end> TO <dest> — move line range."""
152
+ m = self._parse_range_to(rest)
153
+ if not m:
154
+ self.io.writeln("?USAGE: MOVE <start>-<end> TO <dest>")
155
+ return
156
+ start, end, dest = m
157
+ offset = dest - start
158
+ lines = {ln: self.program[ln] for ln in sorted(self.program.keys())
159
+ if start <= ln <= end}
160
+ for ln in lines:
161
+ del self.program[ln]
162
+ for ln, stmt in lines.items():
163
+ self.program[ln + offset] = stmt
164
+ self.io.writeln(f"MOVED {start}-{end} TO {dest}-{end + offset}")
165
+
166
+ @staticmethod
167
+ def _parse_range_to(rest: str) -> tuple[int, int, int] | None:
168
+ m = re.match(r'(\d+)\s*-\s*(\d+)\s+TO\s+(\d+)', rest, re.IGNORECASE)
169
+ if not m:
170
+ return None
171
+ return int(m.group(1)), int(m.group(2)), int(m.group(3))
172
+
173
+ # ── FIND / REPLACE ─────────────────────────────────────────────────
174
+
175
+ def cmd_find(self, rest: str) -> None:
176
+ """FIND "text" — search program lines."""
177
+ text = rest.strip().strip('"').strip("'")
178
+ if not text:
179
+ self.io.writeln("?USAGE: FIND \"text\"")
180
+ return
181
+ found = 0
182
+ for ln in sorted(self.program.keys()):
183
+ if text.upper() in self.program[ln].upper():
184
+ self.io.writeln(f" {ln:5d} {self.program[ln]}")
185
+ found += 1
186
+ self.io.writeln(f" {found} match(es)")
187
+
188
+ def cmd_replace(self, rest: str) -> None:
189
+ """REPLACE "old" WITH "new" — find and replace in program."""
190
+ m = re.match(r'"([^"]+)"\s+WITH\s+"([^"]*)"', rest, re.IGNORECASE)
191
+ if not m:
192
+ self.io.writeln('?USAGE: REPLACE "old" WITH "new"')
193
+ return
194
+ old, new = m.group(1), m.group(2)
195
+ count = 0
196
+ for ln in sorted(self.program.keys()):
197
+ if old in self.program[ln]:
198
+ self.program[ln] = self.program[ln].replace(old, new)
199
+ self.io.writeln(f" {ln:5d} {self.program[ln]}")
200
+ count += 1
201
+ self.io.writeln(f" {count} replacement(s)")
202
+
203
+ # ── CHECKSUM ───────────────────────────────────────────────────────
204
+
205
+ def cmd_checksum(self) -> None:
206
+ """CHECKSUM — hash of program listing for verification."""
207
+ if not self.program:
208
+ self.io.writeln("?EMPTY PROGRAM")
209
+ return
210
+ content = '\n'.join(f'{ln} {self.program[ln]}'
211
+ for ln in sorted(self.program.keys()))
212
+ h = hashlib.md5(content.encode()).hexdigest()[:8].upper()
213
+ self.io.writeln(f"CHECKSUM: {h} ({len(self.program)} lines)")
214
+
215
+ # ── CHAIN / MERGE ──────────────────────────────────────────────────
216
+
217
+ @staticmethod
218
+ def _load_lines_with_defs(lines: list[str], process_fn) -> int:
219
+ """Iterate stripped lines, handling DEF BEGIN...DEF END blocks.
220
+
221
+ Skips blank lines and # comments. For each DEF BEGIN block, collects
222
+ the body lines and calls process_fn with a synthesized single-line DEF
223
+ command. For all other lines, calls process_fn directly.
224
+
225
+ Returns the count of logical lines/blocks processed (excluding skips).
226
+ """
227
+ count = 0
228
+ i = 0
229
+ while i < len(lines):
230
+ line = lines[i].strip()
231
+ if not line or line.startswith('#'):
232
+ i += 1
233
+ continue
234
+ if re.match(r'DEF\s+BEGIN\s+', line, re.IGNORECASE):
235
+ dm = RE_DEF_BEGIN.match(line)
236
+ if dm:
237
+ name = dm.group(1).upper()
238
+ params = [p.strip() for p in dm.group(2).split(',')] if dm.group(2) else []
239
+ body = []
240
+ i += 1
241
+ while i < len(lines):
242
+ bl = lines[i].strip()
243
+ if bl.upper() in ('DEF END', 'END'):
244
+ break
245
+ if bl and not bl.startswith('#'):
246
+ body.append(bl)
247
+ i += 1
248
+ body_str = ' : '.join(body)
249
+ param_str = f"({', '.join(params)})" if params else ""
250
+ process_fn(f"DEF {name}{param_str} = {body_str}")
251
+ count += 1
252
+ else:
253
+ process_fn(line)
254
+ count += 1
255
+ i += 1
256
+ return count
257
+
258
+ def cmd_chain(self, rest: str) -> None:
259
+ """CHAIN "file" — load and run a program, preserving variables."""
260
+ m = RE_CHAIN.match(f"CHAIN {rest}")
261
+ if not m:
262
+ self.io.writeln('?USAGE: CHAIN "filename"')
263
+ return
264
+ path = m.group(1).strip()
265
+ try:
266
+ path = self._sanitize_path(path)
267
+ except ValueError as e:
268
+ self.io.writeln(f"?CHAIN ERROR: {e}")
269
+ return
270
+ if not path.endswith('.qb'):
271
+ path += '.qb'
272
+ if not os.path.isfile(path):
273
+ self.io.writeln(f"?FILE NOT FOUND: {path}")
274
+ return
275
+ saved_vars = dict(self.variables)
276
+ self.program.clear()
277
+ with open(path, 'r', encoding='utf-8') as f:
278
+ lines = [l.rstrip('\n\r') for l in f.readlines()]
279
+ self._load_lines_with_defs(
280
+ lines, lambda line: self.process(line, track_undo=False))
281
+ self.variables.update(saved_vars)
282
+ self.io.writeln(f"CHAINED {path}")
283
+ if self.program:
284
+ self.cmd_run()
285
+
286
+ def cmd_merge(self, rest: str) -> None:
287
+ """MERGE "file" — merge lines from another file without clearing."""
288
+ m = RE_MERGE.match(f"MERGE {rest}")
289
+ if not m:
290
+ self.io.writeln('?USAGE: MERGE "filename"')
291
+ return
292
+ path = m.group(1).strip()
293
+ try:
294
+ path = self._sanitize_path(path)
295
+ except ValueError as e:
296
+ self.io.writeln(f"?MERGE ERROR: {e}")
297
+ return
298
+ if not path.endswith('.qb'):
299
+ path += '.qb'
300
+ if not os.path.isfile(path):
301
+ self.io.writeln(f"?FILE NOT FOUND: {path}")
302
+ return
303
+ with open(path, 'r', encoding='utf-8') as f:
304
+ lines = [l.rstrip('\n\r') for l in f.readlines()]
305
+ count = self._load_lines_with_defs(
306
+ lines, lambda line: self.process(line, track_undo=False))
307
+ self.io.writeln(f"MERGED {path} ({count} lines)")
308
+
309
+ # ── Introspection ─────────────────────────────────────────────────
310
+
311
+ def cmd_defs(self) -> None:
312
+ """List all defined subroutines."""
313
+ found = False
314
+ if self.subroutines:
315
+ for name, sub in self.subroutines.items():
316
+ if isinstance(sub, list):
317
+ self.io.writeln(f" {name} = {' : '.join(sub)}")
318
+ else:
319
+ params = f"({', '.join(sub['params'])})" if sub['params'] else ""
320
+ self.io.writeln(f" {name}{params} = {' : '.join(sub['body'])}")
321
+ found = True
322
+ # Also show SUB/FUNCTION blocks in the program
323
+ from qbasic_core.engine import RE_SUB, RE_FUNCTION
324
+ for ln in sorted(self.program.keys()):
325
+ stmt = self.program[ln].strip()
326
+ m = RE_SUB.match(stmt)
327
+ if m:
328
+ self.io.writeln(f" SUB {m.group(1).upper()}({m.group(2) or ''}) at line {ln}")
329
+ found = True
330
+ m = RE_FUNCTION.match(stmt)
331
+ if m:
332
+ self.io.writeln(f" FUNCTION {m.group(1).upper()}({m.group(2) or ''}) at line {ln}")
333
+ found = True
334
+ if not found:
335
+ self.io.writeln("NO SUBROUTINES DEFINED")
336
+
337
+ def cmd_regs(self) -> None:
338
+ """List all named registers with their qubit ranges."""
339
+ if not self.registers:
340
+ self.io.writeln("NO REGISTERS DEFINED")
341
+ return
342
+ for name, (start, size) in self.registers.items():
343
+ self.io.writeln(f" {name}[0:{size}] -> qubits {start}-{start+size-1}")
344
+
345
+ def cmd_vars(self) -> None:
346
+ """List all variables and their current values."""
347
+ if not self.variables:
348
+ self.io.writeln("NO VARIABLES SET")
349
+ return
350
+ for name, val in self.variables.items():
351
+ self.io.writeln(f" {name} = {val}")
352
+
353
+ def cmd_clear(self, rest: str) -> None:
354
+ """CLEAR var — remove a variable. CLEAR ARRAYS — clear all arrays."""
355
+ name = rest.strip()
356
+ if not name:
357
+ self.io.writeln("?USAGE: CLEAR <var> or CLEAR ARRAYS")
358
+ return
359
+ if name.upper() == 'ARRAYS':
360
+ self.arrays.clear()
361
+ self.io.writeln("ARRAYS CLEARED")
362
+ elif name in self.variables:
363
+ del self.variables[name]
364
+ self.io.writeln(f"CLEARED {name}")
365
+ elif name in self.arrays:
366
+ del self.arrays[name]
367
+ self.io.writeln(f"CLEARED array {name}")
368
+ else:
369
+ self.io.writeln(f"?{name} NOT FOUND")
@@ -0,0 +1,77 @@
1
+ """Mixin contract — documents what QBasicTerminal provides to its mixins.
2
+
3
+ Mixins (ExpressionMixin, DisplayMixin, LOCCMixin, ControlFlowMixin, DemoMixin)
4
+ rely on attributes and methods defined on QBasicTerminal. This Protocol
5
+ formalizes that contract so that type checkers and future refactors can verify
6
+ it is not broken.
7
+
8
+ Usage:
9
+ from qbasic_core.protocol import TerminalProtocol
10
+
11
+ Mixins should document ``Requires: TerminalProtocol`` in their class docstring.
12
+ QBasicTerminal implicitly satisfies this protocol by construction.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from collections import OrderedDict
18
+ from typing import Any, Protocol, runtime_checkable
19
+
20
+ import numpy as np
21
+
22
+
23
+ @runtime_checkable
24
+ class TerminalProtocol(Protocol):
25
+ """Attributes and methods that mixins expect on the host terminal."""
26
+
27
+ # ── State ──────────────────────────────────────────────────────────
28
+
29
+ program: dict[int, str]
30
+ num_qubits: int
31
+ shots: int
32
+ subroutines: dict[str, Any]
33
+ registers: OrderedDict[str, tuple[int, int]]
34
+ variables: dict[str, Any]
35
+ arrays: dict[str, list[float]]
36
+ last_counts: dict[str, int] | None
37
+ last_sv: np.ndarray | None
38
+ last_circuit: Any | None
39
+ step_mode: bool
40
+ sim_method: str
41
+ sim_device: str
42
+ locc: Any | None
43
+ locc_mode: bool
44
+
45
+ _undo_stack: list[dict[int, str]]
46
+ _gosub_stack: list[int]
47
+ _custom_gates: dict[str, np.ndarray]
48
+ _noise_model: Any | None
49
+ _max_iterations: int
50
+ _include_depth: int
51
+ _parsed: dict[int, Any]
52
+ _circuit_cache_key: Any | None
53
+ _circuit_cache: Any | None
54
+ io: Any # IOPort
55
+
56
+ # ── Methods mixins call on the host ────────────────────────────────
57
+
58
+ def _get_parsed(self, line_num: int) -> Any: ...
59
+ def eval_expr(self, expr: str) -> float: ...
60
+ def _safe_eval(self, expr: str, extra_ns: dict[str, Any] | None = None) -> Any: ...
61
+ def _eval_with_vars(self, expr: str, run_vars: dict[str, Any]) -> float: ...
62
+ def _eval_condition(self, cond: str, run_vars: dict[str, Any]) -> bool: ...
63
+ def _gate_info(self, name: str) -> tuple[int, int] | None: ...
64
+ def _resolve_qubit(self, arg: str) -> int: ...
65
+ def _substitute_vars(self, stmt: str, run_vars: dict[str, Any]) -> str: ...
66
+ def _expand_statement(self, stmt: str) -> list[str]: ...
67
+ def _tokenize_gate(self, stmt: str) -> list[str]: ...
68
+ def _parse_syndrome(self, stmt: str, run_vars: dict[str, Any]) -> tuple[str, list[int], str] | None: ...
69
+ def _split_colon_stmts(self, stmt: str) -> list[str]: ...
70
+ def _print_statevector(self, sv: np.ndarray, n_qubits: int | None = None) -> None: ...
71
+ def _print_bloch_single(self, sv: np.ndarray, qubit: int, n_qubits: int | None = None) -> None: ...
72
+ def print_histogram(self, counts: dict[str, int]) -> None: ...
73
+ def cmd_new(self, *, silent: bool = False) -> None: ...
74
+ def cmd_run(self) -> None: ...
75
+ def cmd_list(self, rest: str = '') -> None: ...
76
+ def cmd_locc(self, rest: str) -> None: ...
77
+ def process(self, line: str) -> None: ...
qbasic_core/py.typed ADDED
File without changes
qbasic_core/scope.py ADDED
@@ -0,0 +1,74 @@
1
+ """QBASIC variable scope — unified layered scope model."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+
8
+ class Scope:
9
+ """Layered variable scope: persistent + runtime.
10
+
11
+ Reads check runtime first, then persistent.
12
+ Writes mirror to both for backward compatibility with code
13
+ that reads self.variables directly.
14
+ """
15
+
16
+ def __init__(self, persistent: dict[str, Any]):
17
+ self._persistent = persistent
18
+ self._runtime: dict[str, Any] = {}
19
+
20
+ def get(self, name: str, default: Any = None) -> Any:
21
+ if name in self._runtime:
22
+ return self._runtime[name]
23
+ return self._persistent.get(name, default)
24
+
25
+ def __contains__(self, name: str) -> bool:
26
+ return name in self._runtime or name in self._persistent
27
+
28
+ def __getitem__(self, name: str) -> Any:
29
+ if name in self._runtime:
30
+ return self._runtime[name]
31
+ return self._persistent[name]
32
+
33
+ def __setitem__(self, name: str, value: Any) -> None:
34
+ self._runtime[name] = value
35
+ self._persistent[name] = value
36
+
37
+ def keys(self):
38
+ return set(self._persistent.keys()) | set(self._runtime.keys())
39
+
40
+ def items(self):
41
+ merged = {**self._persistent, **self._runtime}
42
+ return merged.items()
43
+
44
+ def values(self):
45
+ merged = {**self._persistent, **self._runtime}
46
+ return merged.values()
47
+
48
+ def update(self, other):
49
+ if isinstance(other, dict):
50
+ for k, v in other.items():
51
+ self[k] = v
52
+ elif isinstance(other, Scope):
53
+ for k, v in other.items():
54
+ self[k] = v
55
+
56
+ def as_dict(self) -> dict[str, Any]:
57
+ """Merged view for expression evaluation."""
58
+ return {**self._persistent, **self._runtime}
59
+
60
+ def __delitem__(self, name: str) -> None:
61
+ self._runtime.pop(name, None)
62
+ self._persistent.pop(name, None)
63
+
64
+ def pop(self, name: str, *default):
65
+ # Check runtime first, then persistent, before falling back to default.
66
+ if name in self._runtime:
67
+ val = self._runtime.pop(name)
68
+ self._persistent.pop(name, None)
69
+ return val
70
+ if name in self._persistent:
71
+ return self._persistent.pop(name)
72
+ if default:
73
+ return default[0]
74
+ raise KeyError(name)
qbasic_core/screen.py ADDED
@@ -0,0 +1,115 @@
1
+ """QBASIC screen control — SCREEN, COLOR, CLS, LOCATE, PLAY, prompt."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+
8
+ class ScreenMixin:
9
+ """Screen control commands for QBasicTerminal.
10
+
11
+ Requires: TerminalProtocol — uses self._screen_mode, self.last_counts,
12
+ self.last_sv, self.last_circuit, self.print_histogram(),
13
+ self._print_statevector(), self._print_bloch_single(),
14
+ self.cmd_density(), self.cmd_circuit().
15
+ """
16
+
17
+ def _init_screen(self) -> None:
18
+ self._color_fg: str = ''
19
+ self._color_bg: str = ''
20
+ self._prompt: str = '] '
21
+
22
+ def cmd_screen(self, rest: str) -> None:
23
+ """SCREEN mode — set display mode.
24
+ 0=text 1=histogram 2=statevector 3=Bloch 4=density 5=circuit"""
25
+ if not rest.strip():
26
+ names = {0: 'text', 1: 'histogram', 2: 'statevector',
27
+ 3: 'Bloch', 4: 'density', 5: 'circuit'}
28
+ self.io.writeln(f"SCREEN = {self._screen_mode} ({names.get(self._screen_mode, '?')})")
29
+ return
30
+ mode = int(rest.strip())
31
+ if mode < 0 or mode > 5:
32
+ self.io.writeln("?SCREEN 0-5")
33
+ return
34
+ self._screen_mode = mode
35
+ names = {0: 'text', 1: 'histogram', 2: 'statevector',
36
+ 3: 'Bloch', 4: 'density', 5: 'circuit'}
37
+ self.io.writeln(f"SCREEN {mode} ({names[mode]})")
38
+
39
+ def _auto_display(self) -> None:
40
+ """Display results using current SCREEN mode after RUN."""
41
+ mode = self._screen_mode
42
+ if mode <= 1:
43
+ return # text/histogram mode: histogram already shown by cmd_run
44
+ if mode == 2:
45
+ if self.last_sv is not None:
46
+ self._print_statevector(self.last_sv)
47
+ elif mode == 3:
48
+ if self.last_sv is not None:
49
+ from qbasic_core.engine import MAX_BLOCH_DISPLAY
50
+ for q in range(min(self.num_qubits, MAX_BLOCH_DISPLAY)):
51
+ self._print_bloch_single(self.last_sv, q)
52
+ self.io.writeln('')
53
+ elif mode == 4:
54
+ self.cmd_density()
55
+ elif mode == 5:
56
+ self.cmd_circuit()
57
+
58
+ def cmd_color(self, rest: str) -> None:
59
+ """COLOR foreground[, background] — set terminal colors."""
60
+ from qbasic_core.engine import RE_COLOR
61
+ m = RE_COLOR.match(f"COLOR {rest}")
62
+ if not m:
63
+ self.io.writeln("?USAGE: COLOR <fg>[, <bg>]")
64
+ return
65
+ self._color_fg = m.group(1).lower()
66
+ self._color_bg = m.group(2).lower() if m.group(2) else ''
67
+ # Apply via ANSI if Rich is not available
68
+ colors = {
69
+ 'black': 30, 'red': 31, 'green': 32, 'yellow': 33,
70
+ 'blue': 34, 'magenta': 35, 'cyan': 36, 'white': 37,
71
+ }
72
+ fg_code = colors.get(self._color_fg, 37)
73
+ if self._color_bg:
74
+ bg_code = colors.get(self._color_bg, 40) + 10
75
+ self.io.write(f"\033[{fg_code};{bg_code}m")
76
+ else:
77
+ self.io.write(f"\033[{fg_code}m")
78
+ self.io.writeln(f"COLOR {self._color_fg}" + (f", {self._color_bg}" if self._color_bg else ""))
79
+
80
+ def cmd_cls(self) -> None:
81
+ """CLS — clear screen."""
82
+ self.io.write('\033[2J\033[H')
83
+
84
+ def cmd_locate(self, rest: str) -> None:
85
+ """LOCATE row, col — position cursor."""
86
+ from qbasic_core.engine import RE_LOCATE
87
+ m = RE_LOCATE.match(f"LOCATE {rest}")
88
+ if not m:
89
+ self.io.writeln("?USAGE: LOCATE <row>, <col>")
90
+ return
91
+ row, col = int(m.group(1)), int(m.group(2))
92
+ self.io.write(f"\033[{row};{col}H")
93
+
94
+ def cmd_play(self, rest: str = '') -> None:
95
+ """PLAY — terminal bell/beep."""
96
+ count = 1
97
+ if rest.strip():
98
+ try:
99
+ count = int(rest.strip())
100
+ except ValueError:
101
+ pass
102
+ for _ in range(count):
103
+ self.io.write('\a')
104
+
105
+ def cmd_prompt(self, rest: str) -> None:
106
+ """PROMPT <string> — set the REPL prompt."""
107
+ if not rest.strip():
108
+ self.io.writeln(f"PROMPT = {self._prompt!r}")
109
+ return
110
+ text = rest.strip()
111
+ if (text.startswith('"') and text.endswith('"')) or \
112
+ (text.startswith("'") and text.endswith("'")):
113
+ text = text[1:-1]
114
+ self._prompt = text
115
+ self.io.writeln(f"PROMPT = {self._prompt!r}")