The web UI gives a running program its own keyboard¶
Created: 2026-08-05
Status: Fixed - src/ui/web/web_keyboard.py (new),
src/ui/web/nicegui_backend.py, src/iohandler/base.py, src/interpreter.py
Regression test: tests/regression/ui/test_web_keyboard.py
The last backend behind the seam from KEY_INPUT_ROUTING.md, after CURSES_PROGRAM_KEYBOARD.md and TK_PROGRAM_KEYBOARD.md. What was there:
def input_char(self, blocking: bool = True) -> str:
"""Get single character (not implemented for web)."""
return ""
So INKEY$ never saw a key, and INPUT$ returned an empty string without
waiting - which is worse than it looks, because a program then does ASC(A$)
on it and dies with "Illegal function call" instead of pausing for input.
The constraint that makes this backend different¶
The other two solve waiting by waiting. The curses UI reads its screen directly while the event loop is stopped; the Tk UI pumps its loop by hand. Neither is available here, and the reason is not style: a keypress arrives from the browser over a websocket served by the same asyncio loop that ticks the interpreter. Blocking that loop for a key means the key can never arrive. It is not slow - it is a deadlock, and it takes every other session on the server with it.
There is no third thread to move to either, not without making every UI update from the interpreter cross a thread boundary that nicegui does not expect.
So the web keyboard does not wait. It raises.
Not-yet, instead of waiting¶
WebKeyboard.input_chars() raises KeyInputPending (new, in
src/iohandler/base.py) when the queue is short. The interpreter treats it as
"not yet":
except KeyInputPending:
self.state.waiting_for_key = True
return self.state
The PC is left exactly where it is, so when the backend ticks again the same
statement runs from the start - and by then a key is queued. _execute_tick
sees state.waiting_for_key, cancels the tick timer rather than re-running the
statement every 10ms, and WebKeyboard's on_key callback starts the timer
again when a key actually arrives. In this UI a keypress is the only event that
can change the answer, so it is the only thing that needs to wake the program.
Re-running a statement is not a new idea here: it is exactly what CONT does
after a Ctrl+C break in INPUT$, which
INPUT_DOLLAR_RAW_READ.md already relies on and
tests.
Two rules fall out of it, and both are silent when broken, so both are tested:
- A single read consumes nothing on the way out.
INPUT$(3)with two keys queued takes neither and raises, or the retry would read the third character into a variable missing the first two. Take all of them or none. INKEY$must never raise. "No key pending" is an answer it can always give, and a polling loop that paused the program would never poll again.
A Ctrl+C in the queue ends a short read immediately rather than waiting for the
rest - the user asked to interrupt, and INPUT$ turns CHR$(3) into a break.
A statement can read more than once¶
Taking all or nothing is enough for one read per statement. It is not enough for two:
10 X$=INPUT$(1)+INPUT$(1)
The first read succeeds and takes a character. The second raises. The retry
runs the first read again and takes another. Measured before the fix: four
keys queued, four keys consumed, line 20 never reached - the program ate every
key it was given and never finished. 10 X$=INKEY$+INPUT$(2) did the same.
So an attempt is bracketed. KeyReadTransaction in src/iohandler/base.py
adds two methods and a flag; the interpreter calls them around each statement:
deferring = getattr(self.io, 'defers_key_reads', False)
if deferring:
self.io.begin_key_transaction()
try:
self.execute_statement(stmt)
except KeyInputPending:
if deferring:
self.io.rollback_key_transaction()
WebKeyboard records what each attempt reads - including INKEY$, which never
pauses but does consume - and puts it back at the front of the queue, in order.
The retry then starts from exactly the state the abandoned attempt did.
defers_key_reads is what keeps this off everywhere else. A terminal handler
blocks instead of raising, so it never needs a rollback - and could not do one
anyway, since its characters came out of the kernel and cannot be pushed back.
The CLI, curses and Tk paths do not execute a line of this.
Browser keys¶
ui.keyboard(on_key=..., ignore=[]) at the top level of the page, not on a
pane: while a program runs it owns the console, the way it does on a real
terminal. ignore=[] is what lets it hear keys typed with the editor or the
immediate line focused; _program_owns_keyboard is what stops it stealing
them - it is False when nothing is running, at a breakpoint, or while the
INPUT statement's inline field is waiting for its answer.
Only keydown is taken. The browser reports press and release, and a program
polling INKEY$ would otherwise see every key twice.
Translation:
- A one-character
KeyboardEvent.keyis itself. - Ctrl+letter becomes the control character - a browser will not send
CHR$(3)on its own, so without this a web user could not interrupt anINPUT$at all. EnterisCHR$(13), as a CP/M console sends;Tab,BackspaceandEscapelikewise.- Arrows and F-keys become the escape sequences a terminal transmits, from
BROWSER_KEY_TO_ANSI. That is now the third table answering that question, after Tk'sKEYSYM_TO_ANSIand the Windows scan-code table, so the test asserts all three hold the same 22 sequences rather than trusting three comments to stay in step. - Anything else -
Shift,Meta, dead keys - is not a keypress a program should see.
Immediate mode¶
PRINT INPUT$(1) typed at the web UI's immediate line used to reach
ConsoleKeyboardMixin, which reads the server's terminal: a browser user
could block the machine running the server on a keypress nobody was there to
type. The immediate handler now gets the session's keyboard too, so at worst
the read pauses.
Verifying¶
python3 tests/regression/ui/test_web_keyboard.py
41 checks, no browser and no nicegui import: the queue and the pause/resume
contract are the substance, and both are reachable without either. Ten cover
WebKeyboard (translation, Ctrl+letter, the three-table agreement, consuming
nothing when it raises, INKEY$ never raising, the wake-up callback); four
drive the real interpreter through a program that pauses, resumes, and reads
INPUT$(3) fed one key at a time; two build a bare backend to check who owns
the keyboard.
python3 tests/playwright/test_web_keyboard_browser.py
6 more in a real browser, for the part only a browser can show: nicegui's
ui.keyboard delivering the event shape this code expects, and the tick timer
being cancelled when a program parks and recreated when a key arrives. It runs
the server, presses q at a paused INPUT$ and z at a polling INKEY$, and
reads the output pane. About 20 seconds, so it lives in tests/playwright/
rather than under tests/regression/, whose runner allows a test 30 - and
discards its output when one runs over. Needs pip install nicegui playwright
and playwright install chromium; skips with exit 2 if any of those is
missing.
Known limitations¶
A statement that pauses runs again from its start - but everything a BASIC program can see is now put back, and the caveat originally recorded here was wrong about what that even was:
- Output does not duplicate.
PRINT "A";INPUT$(1)printsAonce. Measured:execute_printevaluates its expressions into a list and writes at the end, so an attempt that pauses mid-expression has written nothing. - Keyboard reads do not duplicate, since the transaction above.
RNDandINPUT$(n,#f)do not either, since src/statement_attempt.py - the generator is rewound and the file is seeked back.
What is left is invisible to a program: EOF sets a flag at end of file, and
setting it twice is the same as setting it once.
Terminals cannot roll back. The same double-read shape exists in the CLI
after a Ctrl+C break: X$=INPUT$(1)+INPUT$(1), interrupted after one
character, loses that character when CONT re-runs the statement. Its
characters came from the kernel and there is nowhere to put them back. The web
can only do better because its keys are already in a queue of its own.
Nothing stops a program that never reads a key. The curses and Tk UIs have
a stop key that reaches a blocked read. Here a program parked on
waiting_for_key is not blocking anything - the UI is fully responsive and Stop
works normally - but a program spinning in a tight loop without reading is
still only interruptible through the menu.