Skip to content

The Tk UI gives a running program its own keyboard

Created: 2026-08-05 Status: Fixed - src/ui/tk_keyboard.py (new), src/ui/tk_ui.py Regression test: tests/regression/ui/test_tk_keyboard.py

The second backend to implement the seam from KEY_INPUT_ROUTING.md, after CURSES_PROGRAM_KEYBOARD.md. What was behind it here was TkIOHandler.input_char, which opened a modal dialog:

simpledialog.askstring("INPUT$ (Single Character)",
                       "Enter a single character:", parent=self.root)

Measured against the previous commit, driving the real UI under Xvfb:

                        before                          after
INPUT$(1)               a modal "type a character        the key that was
                        and press OK" dialog             typed
INPUT$(3)               three of those dialogs           three keys
INKEY$                  "" every time - a polling        the key typed
                        program never saw a key          during the run

The INPUT$ probe on the pre-change tree did not finish: the dialog is modal, nothing in a "press any key" program can answer it, and the run had to be killed. The INKEY$ probe finished with an empty output pane.

Why it blocks, and why that is survivable here

INPUT$ is an expression, evaluated inside execute_statement, so unlike the INPUT statement it cannot suspend the tick and resume - it blocks inside the root.after callback that ticks the interpreter, with Tk's event loop stopped behind it.

The way out is that Tk lets you run the loop by hand: TkKeyboard._pump() calls root.update(), which delivers pending events - including the keypress being waited for - and repaints. So unlike the curses UI, whose event loop genuinely stops, the Tk window stays alive while INPUT$ waits: it repaints, the menus open, and Run > Stop works. That last one matters, because it is the only way to end a blocking read from the UI: it clears self.running, the keyboard reports that as CHR$(3), and INPUT$ turns it into the break it already knows how to do (Break in 20).

Pumping the loop means callbacks can run - including a tick scheduled by whatever the user just clicked. TkIOHandler.input_chars sets backend._waiting_for_program_key for the duration and _execute_tick returns immediately when it sees it, so the interpreter cannot be re-entered underneath a tick that is already inside it.

Keys go to the program first

_on_program_key is bound on the editor text, the immediate entry and the output pane, before their other handlers, and returns 'break' when it takes a key - which stops the rest of the binding chain, including the widget's own class binding that would otherwise type the character into the program listing. Registration order is what puts it first; add='+' keeps the existing handlers.

Ownership is self.running and not self.paused_at_breakpoint. Tk maintains self.running properly - _execute_tick returns early without it - which is the opposite of the curses UI, where it is set only by the debug paths and the gate had to come from the interpreter's PC instead.

The INPUT statement needs no special case here: it sets self.running = False while it waits for the immediate line, so the keys go where they should. There is a test for that, because it is exactly the kind of thing a key filter breaks.

Special keys

Tk reports arrows and function keys by name (event.keysym) with no character. MBASIC has no notion of a named key - a CP/M console is a byte stream, and an arrow is whatever escape sequence the terminal transmits - so KEYSYM_TO_ANSI translates them into the same sequences the POSIX console produces and the Windows path already synthesises from scan codes (_WIN_KEY_TO_ANSI in src/win_console.py).

Two tables answering the same question is exactly the drift this codebase has been bitten by before, so the test asserts they contain the same 22 sequences rather than trusting a comment to keep them honest.

Modifier keysyms are dropped: Shift on its own is not a keypress a program should see.

Verifying

python3 tests/regression/ui/test_tk_keyboard.py

29 checks with a display, 23 without - and 0.6s without, because the eleven that need no window are the ones that cover the queue, the special-key translation, the modifier filtering, the interrupt handling and the flush-once rule. TkKeyboard imports no tkinter, which is what makes that possible.

The four windowed checks are the wiring: INPUT$ receiving a typed key, INKEY$ seeing one typed mid-run, Run > Stop breaking a blocked read, and the INPUT statement still getting its answer. They build the real TkBackend and inject events with event_generate, driving everything from root.after callbacks - a key cannot be sent from the test's own code, because while INPUT$ blocks, the test is not running.

No display is the normal case on a build machine, so that half skips loudly. Locally: xvfb-run -a python3 tests/regression/ui/test_tk_keyboard.py.

Known limitations

A program owns every key while it runs. The menus stay reachable with the mouse and Run > Stop still works, but the editor cannot be typed into during a program - which is right for a BASIC console and surprising for a GUI.

The wait costs a 20ms poll. Tk has no "wait for an event with a timeout", so _pump() sleeps between update() calls. A tighter loop would spin a core for no benefit; a looser one would feel sluggish.

The modal dialog is still there for a TkIOHandler built without a backend, which is the only way to reach it now. It is the honest fallback for a handler with no UI behind it.

The web backend still has no keyboard. It has one now - see WEB_PROGRAM_KEYBOARD.md. It could neither block nor pump, because its keys arrive on the same asyncio loop that runs the interpreter, so it pauses the program instead and resumes it when a key arrives.