3.7 Input¶
The input subsystem is everything between a host event — a key going down, a
gamepad button, a mouse movement — and the byte a Z80 IN instruction
eventually reads back. Input state is never pushed at the guest: host events
accumulate in a small set of objects, and the guest samples them whenever its
own code gets round to reading port 0xFE, 0x1F, 0x37, one of the Kempston
mouse ports, or one of the NextREGs that expose raw key and pad state. The only
host keys that reach the CPU of their own accord are the emulator function keys
handled by EmuFnKeys below, and they do it through reset and NMI rather than
through the matrix.
The classes fall on either side of that port boundary, and ownership is what
tells them apart rather than the directory they live in. The machine-side
classes — Keyboard, Joystick, KempstonMouse, MembraneStick, IoMode,
Md6ConnectorX2, EmuFnKeys — are members of Emulator, read by the port
handlers Emulator::init() registers. The host-side adapters —
GamepadHost, JoystickDispatcher, MouseDispatcher — are owned by the
frontend instead (SdlApp, and the Qt MainWindow / QtApp), and exist only
to turn SDL events into the state those members hold. Both groups live under
src/input/.
A second, more consequential split runs through the same directory. Some classes are transcriptions of FPGA modules and answer to the VHDL. The rest have no VHDL counterpart at all, because they stand in for a physical adapter the FPGA merely assumes is present — the PS/2 driver, the pad cable — or compensate for an artefact of emulating a frame as an indivisible unit. Knowing which group a class is in tells you what its correctness criterion is: the VHDL, or a documented host policy. Nothing else does.
src/input links SDL3 even in the Qt build, because SDL scancodes are the
project's canonical host-key type. MainWindow::qt_key_to_sdl() converts Qt
keys into them, so both frontends feed Keyboard::set_key() identically and
neither can drift from the other.
The keyboard matrix¶
keyboard.{h,cpp} models the 8 × 5 membrane. matrix_[row] is active-low, row
selection comes from the upper byte of the port address (bit N clear selects
row N), and read_rows() returns the 5-bit AND of every selected row. Bits
7:5 of the port 0xFE byte are composed by the caller in Emulator, not here.
Three layers apply before that row-select AND, in this order:
- Shift hysteresis.
membrane.vhd:178holds the Caps-Shift and Symbol-Shift column state for one extra scan, which delays a release.tick_scan()advances the two-entry history once per membrane scan — 4608 master cycles, which the VHDL annotates as "complete scan every 2.5 scanlines" (zxnext_top_issue2.vhd:1179) — driven fromEmulator::on_scanline()by a cycle accumulator. It used to run once per video frame, which stretched a 165 µs hold to 20 ms and left a released Symbol Shift contaminating the next frame's keystroke: a tap of Symbol Shift followed by a tap ofPtyped"instead ofPRINT. The rate is part of the spec, not an implementation detail. - The extended-key fold, described below.
- The
MembraneStickfold, also below, matchingkeyb_col <= keyb_col_i_q AND membrane_stick_col AND ps2_kbd_col.
Which compounds the hardware synthesises¶
Several ZX keys are compounds — CAPS LOCK is Caps Shift plus 2, and so on —
and the question of who presses the shift half has been got wrong here once,
so it is worth stating precisely.
The sixteen extended keys are folded by the hardware itself. Cursors,
DELETE, EXTEND MODE, CAPS LOCK, GRAPH, TRUE VIDEO, INV VIDEO, BREAK, EDIT and
the four symbol-shifted punctuation keys (; " , .) each set a bit in a
raw 16-bit register, and membrane.vhd:236-240 ANDs that register into the
matrix — both halves of the compound. The digit or letter column and the
Caps-Shift or Symbol-Shift that goes with it are produced by the membrane, not
by whoever is pressing keys.
jnext models that path rather than short-cutting it. set_key() routes these
host keys to set_extended_key(id), which touches only ex_matrix_, and
read_rows() does the fold, deriving the shift half from the same per-index
split the VHDL uses: every index drives Caps Shift except the four punctuation
ones, and EXTEND drives both, which is why it comes out as CS+SS. Going the
long way round buys two behaviours a directly asserted compound cannot express.
NR 0xB0 / 0xB1 read the raw register, so a program can see which extended
key is held; and NR 0x68 bit 4 cancels the fold while leaving that
register reporting normally. Asserting the compound directly — which jnext used
to do — makes both impossible.
Three compounds are synthesised host-side instead, in s_compound[]:
/ → SS+V, - → SS+J, = → SS+L. On hardware these come from the PS/2
keymap's SYMBOL bit (keymaps.vhd, ps2_keyb.vhd:197), and jnext does not
model the PS/2 protocol at all — SDL key events replace it. Both matrix bits
are therefore asserted directly, with no raw register behind them and nothing
to read back.
When the guest sees a key change¶
Hardware has no answer to this: the membrane is scanned continuously
(membrane.vhd:99-155, a free-running row scanner), so a real key change is
asynchronous to the frame. Emulating a frame as an indivisible unit forces a
choice, and it is a host policy with an emulator as its oracle, not a VHDL
transcription.
The policy: a keyboard change made between frames N and N+1 becomes
visible to the guest at the ULA interrupt position of frame N+1 (the
extended keys and NR 0xB0/0xB1 with it), and to no read before it. The
position itself is the VHDL one, (c_int_h, c_int_v) of
zxula_timing.vhd:551 with the per-timing constants: Pentagon (439,319)
(zxula_timing.vhd:155 and zxula_timing.vhd:163), 128K (128,1)
(zxula_timing.vhd:187 and zxula_timing.vhd:199), +3 and the Next's default
timing (126,1) (zxula_timing.vhd:189 and zxula_timing.vhd:199), 48K
(116,0) (zxula_timing.vhd:257 and zxula_timing.vhd:265).
The oracle for the order is FUSE 1.5.7. Its frame-end event handler,
spectrum_frame_event_fn(), runs (the excerpt is its lines 91 to 103 in FUSE's
spectrum.c)
z80_interrupt();
ui_joystick_poll();
timer_estimate_speed();
debugger_add_time_events();
ui_event();
in that order: the interrupt is taken first and host input (ui_event())
lands after it. A program polling the keys therefore cannot act on a key
before its own interrupt routine has run, and a program whose key handler
changes machine state with interrupts enabled (paging a bank that holds music,
say) cannot have the interrupt land in the middle of that response because the
key arrived a few hundred T-states before it. jnext used to apply every key at
its frame boundary, 292 T before the 128K interrupt; a 128K game that pages a
second bank in for ~150-300 T on its first sight of a key then read its IM 2
vector from the wrong bank and jumped to $0000 at the start of play
(msnampac, found 2026-11). The race is real on hardware, where a key lands at
a random phase and the window is hit rarely; jnext made it certain.
How it is built (Keyboard, Emulator::begin_new_frame()):
- Writers (
set_key,set_matrix_bit,set_extended_key, the auto-type tick, the queued levels,release_all_keys) changematrix_/ex_matrix_at once, as before. Every producer is covered: host keys,--delayed-keypress, the phantom typist, the tapeLOAD ""keys, and the debugger's levels and pulses. - The guest reads a separate copy while a frame runs.
latch_guest_view()copies the state at the frame edge, first thing, before the edge's own writers (the debugger drain andtick_auto_type()), so the copy is what the frame that just ended showed the guest.hold_guest_view()is called bybegin_new_frame(). A release event onscheduler_at the interrupt cycle (the same expression as the interrupt request, plus one pixel) callsrelease_guest_view(). Held,read_rows(),tick_scan()(so the Caps/Symbol Shift history latches what the guest saw) and the NR0xB0/0xB1readbacks use the copy. - Which side of the instruction the commit lands on. The interrupt request
and the release both sit in
irq_scheduler_, due at the same cycle, and are applied at the end of the instruction the edge falls in. The next instruction is the ISR's first when the interrupt is accepted there, and it reads the new state, which is FUSE's order (the interrupt entered, then the input). AnINthat begins before the edge has already read the old one.irq_scheduler_is also drained mid-instruction by the reads that callsync_io_devices_to(); the keyboard reads (port0xFE, NR0xB0/0xB1) never do, so a read cannot tell the two drain points apart. The release is deliberately not onscheduler_, the video-row queue the Copper walk splits instruction windows at. With interrupts disabled (or the ULA interrupt off, NR0x22bit 2) the release still fires at the same position: the commit is a raster position, not the interrupt. - Nothing is held outside
run_frame(). No guest instruction runs between frames, so the test or debugger panel that reads between them sees the live state:matrix_row()andlive_extended_keys()(whatinput_state()reports) are always the raw write side. - The copy and its held flag are machine state (the release event is not:
begin_new_frame()schedules it again for every frame, including a replayed one):describe_statecarriesguest_view_held,guest_ex_matrixandguest_matrix_0..7, each with a declared default equal to thereset()value, so a.jnswritten before they existed loads as "nothing held", and a rewind replay of a frame whose boundary snapshot has a pending key shows it at the interrupt, as the original run did. - Joystick and mouse input are not covered: they still change at the frame boundary (decision, 2026-11-05; no program in hand needs it).
Rows HIC-01 to HIC-11 of input_integration_test pin the policy;
input-int-commit-func runs it end to end on an injected program that
reproduces the paging mechanism.
Joysticks¶
joystick.{h,cpp} is the machine side. It decodes NR 0x05 into a
per-connector mode (Sinclair 1/2, Cursor, Kempston 1/2, MD3 left/right, I/O
mode) and composes the port 0x1F and 0x37 reads. Each connector holds a
12-bit raw vector whose layout is zxnext.vhd:3441-3442.
The constraint that shapes everything above it is that only six of those
twelve bits ever reach a port, and two of them conditionally. U/D/L/R and C/B
are readable in Kempston or MD mode; START and A are driven in MD mode only and
hard-tied to 0 in Kempston; MODE/X/Z/Y reach no port under any mode and are
visible only through NR 0xB2. So a host pad's four face buttons are mapped
onto B, C, A and START — the bits a program can actually read — and the MD6
latch bits are bound only to spare controls.
membrane_stick.{h,cpp} (membrane_stick.vhd) is the joystick-to-keyboard
adapter used by the Sinclair and Cursor modes. It is a module of its own,
upstream of the port 0xFE read rather than something inside Keyboard,
exactly as the VHDL decomposes it. Its user-defined keymap is a 64 × 6-bit RAM
reprogrammable through NR 0x28 / 0x29 / 0x2B, initialised from the FPGA's
own keyjoy_64_6.coe.
md6_connector_x2.{h,cpp} is a faithful transcription of the MD6 scan FSM, and
it is not on any live read path: the NR 0xB2 handler reads
Joystick::nr_b2_byte() instead. Nothing feeds it, because detecting a
six-button pad needs a physical select-pulse handshake that a host gamepad
cannot produce. It is a reference model with its own test rows, and fixing a
bug in it changes no emulator behaviour.
iomode.{h,cpp} implements the NR 0x0B pin-7 multiplexer, which can be
static, CTC-toggled, or driven by a UART TX line.
Mouse¶
mouse.{h,cpp} is a Kempston mouse: 8-bit X and Y counters read raw at ports
0xFBDF / 0xFFDF, with active-low buttons and a 4-bit wheel at 0xFADF. The
ports decode on twelve address lines only — A15:A12 are don't-care — so
IN A,(0x2ADF) reaches the mouse exactly as it would on hardware.
Two details sit deliberately outside this class. The NR 0x83 bit 5 gate is
enforced one layer up, in the emulator's port handler. And NR 0x0A
button-reverse and DPI are stored but not applied, because neither has an
in-core VHDL consumer — both are concerns of the host adapter rather than of
the machine.
The host-side layer¶
Nothing below has a VHDL oracle. Each is judged instead by the shape it lands in the machine-side class, plus a stated policy.
-
joystick_dispatcher/gamepad_host/mouse_dispatcherstand in for the physical adapters the FPGA expects to be driving its input pins.GamepadHostowns the SDL controller lifecycle; the two dispatchers translate SDL button, axis, motion and wheel events into the machine-side vectors and counters. Both frontends use the same classes, so behaviour cannot drift between them. -
Which controller drives which connector is policy in
joy_assign.{h,cpp}(pure, no SDL), not an accident of arrival order.GamepadHostkeeps the list of present devices and, whenever a device arrives or leaves, a connector's source changes or an assignment is made, asksresolve_joy_assignment()and opens/closes to match. Per connector: R1 a source other thanSdl(includingJoySource::None) gets no device and a dormant assignment reserves nothing; R2 an assigned id that is present wins and pre-empts whoever holds it; R3 otherwise a connector keeps its current device (sticky: a pad is never moved under the player); R4 otherwise it takes the first untaken device in arrival order, Joy 1 then Joy 2. An assigned id that is absent falls through to R3/R4, which is the fallback, logged once on entry. The identity is the SDL GUID plus an ordinal (<guid>or<guid>#N,joy_source.h): N is the smallest ordinal no present device of that GUID holds, so unplugging pad #1 leaves pad #2 as#2. The ids live in a process-wideiid -> idregistry (GamepadHost::id_registry_), because a cold boot rebuilds the host while SDL and its instance ids carry on: without it, identical pads would be renumbered in arrival order and an assignment would move to the other physical pad. Entries last until SDL stops listing the device (REMOVED, or the purge in the next enumerate/arrival); only a full restart renumbers. The Emulator stores the assignment next tojoy_source_(strings only,set_joystick_device(), one controller per connector, last pick wins), the cold boot carries it, and the Input menu and Preferences build the same choice list fromjoy_choices(). Whenever a device leaves a connector (close_slot()) the dispatcher'srelease_connector()clears everything it held, since nothing will send the release. -
JNEXT_TEST_VIRTUAL_JOYSTICKS(GamepadHost::attach_test_devices_from_env(), called by both frontends right afterSDL_Init) attaches SDL virtual joysticks described asname:vvvv:pppp[:raw];...(vendor/product in hex;rawhas no gamepad mapping) and admits only virtual devices from then on, so the regression rowjoystick-picker-funccan drive the real binaries with no hardware and a physical pad on the host cannot disturb it. Inert when unset. The unit suites get the same isolation fromGamepadHost::set_virtual_only(), because SDL3 has no hint that disables its Linux backends. -
pointer_capture.h(insrc/platform/) decides what the Qt frontend does with each motion event while the pointer is captured — forward the delta, re-centre, or ignore the echo of its own warp. SDL does not use it, because its relative mode does the equivalent natively. Both frontends do use itsrelease_chord()for the release hint: Qt on macOS reads Command as Ctrl, so the chord there is Alt+Cmd. The release gesture itself is configurable:ReleaseFilteris the toolkit-free decision for one of three exclusive methods (ctrl-alt,middle-click,esc), returning per event whether to release and whether to consume it, andrelease_hint()is the one spelling of the gesture for every title, tip, log line and Preferences label. Qt calls the filter fromkeyPressEvent,keyReleaseEventandmousePressEvent; SDL calls it through the templates ofsdl_pointer_release.h, so a suite can drive them through the realSdlInput::poll(). Ctrl+Alt is deliberately not consumed (swallowing Alt would desyncKeyboard::alt_held_); Esc is consumed whole, including SDL repeats and the release, and a focus loss forgets a pending swallow. Its default policy measures every delta from the centre, which needs a warp that lands at once: X11 and Windows. macOS and Wayland get a second policy,DeferredWarpPolicy. On macOSQCursor::setPosis a synthetic event that lands late, or never without the Accessibility permission, so the frontend warps withCGWarpMouseCursorPositioninstead (src/gui/mac_cursor_warp.cpp). On Wayland the warp is a compositor request that may land late or be refused (wp_pointer_warp_v1, Qt 6.10 and later), or does nothing at all (Qt 6.9 and earlier); it staysQCursor::setPos, anddeferred_warp_platform()picks the policy at capture time from the Qt platform name, so X11 (xcb), Windows andoffscreenare unchanged. The deferred policy measures each delta from the last position seen rather than from the centre, and re-centres only once the pointer has drifted a quarter of the viewport away.Two limits are deliberate and pinned by tests. A single event moving more than an eighth of the viewport straight at the centre while a warp is in flight can be mistaken for the warp landing; and a warp that lands only after the pointer has itself come back near the centre is forwarded as a small jump. On Wayland without a working warp the motion stays exact but the pointer is not confined: plain Wayland cannot hold it without the pointer constraints protocol, so it can leave the window, and guest motion stops until it comes back.
-
host_key_latch.h(alsosrc/platform/) holds two policies, and both exist because of the frame boundary. Host key events are delivered betweenrun_frame()calls, so the matrix is sampled once per emulated frame.Latchis the minimum-hold half: a press plus its release arriving in the same gap would be invisible to the guest, so the latch defers such a release until a frame has actually run.Routeris the serialiser, and it is the half that decides when a press may land. Holding a press was never enough on its own: the next key pressed while the first one's release was still deferred used to be asserted on top of it, so the frame sampled a two-key chord the host never had.KEY-SCANrejects a two-key chord, so both characters vanished — and a tap of Symbol Shift followed by a tap ofPdecoded as Symbol-Shift +P, producing the wrong character rather than none. The router therefore queues host events and applies a press only when the queue is empty, no release is deferred, and no non-modifier press is still waiting for a frame. The SDL modifier block 224..231 — the two ZX shifts and host Alt — never arms that last condition, which is what keeps a genuine shift-plus-key chord in a single frame; what separates "shift held as a modifier" from "shift tapped on its own" is the host's own key-up event and nothing else. Autorepeat presses are dropped rather than queued, because SDL forwards them and Qt does not.Real hardware has neither problem — it scans at membrane rate and merges nothing — so neither policy is a fidelity claim about the Next; both are compensation for jnext delivering host input once per frame.
Nothing is lost holds for the matrix, and it is not enough for the guest.
48.rom'sKEYBOARDroutine keeps two key sets (KSTATEat$5C00and$5C04): every interrupt decrements each busy set's counter and frees it at 0 (0x02C6-0x02D6), a new key needs a free set (with none,RET Zat0x02F0throws it away), a new key's counter is set to 5 (LD (HL),$05at0x02F4), and the same key seen again while its set is busy is a repeat (K-REPEAT,0x0310), not a keystroke. The same bytes are in the 128, +3 and Next ROMs. A backlog replayed one keystroke per frame therefore lost the third distinct key and every repeated one inside the ROM, even though each had been shown to a frame. The router now spaces what it schedules:TAP_SPACING_FRAMES(5) after a stretched tap (a press whose release arrived before any frame saw it, so jnext, not the host, picks the length and the next key's time), applied to queued presses only. A press that arrives on an idle router keeps the host's own timing, so games and ordinary typing pay nothing; the price is that a backlog drains at 10 keys/s at 50 FPS. Two taps of the same key in one gap are now two keystrokes. The spacing counts frames (on_tick_end(frames)), never ticks, andattach()andrelease_all()forget it.Host actions are barriers in the same queue. A Quick Save or Quick Load is requested through
Router::on_host_action(fn): it runs once every keystroke typed before it has been shown to a frame and released, and every host event typed after it queues until the frontend callsaction_done().fnonly raises the request (a flag;QtApp::pending_quick_,SdlApp::quick_*_requested_), and the tick services it, so nothing saves or cold-boots from inside the router. The user's order is kept on both sides: the save no longer overtakes the keys before it, and keys typed behind a Quick Load survive its cold boot, becauseattach()keeps the queue when a barrier is up (everything in it was typed after the action, so it belongs to the new machine).release_all()drops the queued keys but keeps queued actions, so a focus loss does not cancel a save the user asked for. After a snapshot load wipes the matrix,Router::reassert()puts back the keys the router holds (the four windowed-frontend load sites all go throughemulator_apply_load_keeping_keys()inemulator_boot.h, so none can forget it), so the guest matrix is exactly what the host holds through the router. Keys typed while a Quick Save/Load is in progress are delivered afterwards, in order.Router::release_all()is the third policy, and it answers a different question: what if the key-up never arrives at all? A Qt popup and a focus change both take the keyboard away mid-keystroke, and the key-up then goes to whatever took it. Opening the File menu with Alt+F swallows theFkey-up and theAltkey-up, which leaves host Alt asserted inKeyboardfor the rest of the session — after which E, G and C resolve to EDIT, GRAPH and CAPS LOCK and those three letters silently stop typing while every other letter still works. With an ordinary letter stranded instead, the ROM's own auto-repeat fills the BASIC line.MainWindow::focusOutEventtherefore reports the loss, the frontend releases everything the sink holds, and the invariant is that jnext never holds a guest key down whose release it cannot observe. The Router tracks what the sink holds separately from what the host holds for this: releasing a key that was never pressed would clear matrix bits a compound key shares —/,-and=each write two bits directly and all three share the Symbol Shift cell. The price is stated rather than hidden — a press not yet shown to a frame is dropped instead of held, which is correct, because the user has moved to a menu. The Router is also where the script host keys live: Alt+1..Alt+8 with no other modifier held call the callbackwire_script_keys()(host_key_wiring.h) installs — the backend'sHost{scriptN}— and the digit's press and release are swallowed, script loaded or not. Alt+9/Alt+0 and every other key are unchanged. One place for both the Qt and the SDL window, so they cannot disagree; the debugger window has its own eightQActions on the same chords, and the debugger keymap refuses them. -
phantom_typist.{h,cpp}typesLOAD ""for you. Loading a tape on a real Spectrum starts with the user typing it and pressing ENTER — or, on a 128K or +3, accepting the boot menu's default entry — and jnext does that itself when a.tapis loaded, so a cold machine goes from reset to loading with no interaction. The hard part is when: BASIC will not accept a keystroke for a good fraction of a second after reset, and how long depends on the machine. This class is a port of FUSE's answer. It watches port0xFEreads, accumulating which of the eight half-rows have been selected, and treats a full sweep of all eight within one frame as proof that the ROM's own scan loop is running. It then waits eight more frames — the first full sweep happens while the copyright message is still printing, before the BASIC editor is reading — and only then queues the per-machine sequence:J, Symbol-Shift +Ptwice and ENTER on a 48K, a bare ENTER on the 128K and +3 where the default menu entry loads a tape itself. It replaced a fixed frame delay that was fragile across the three boots. See 3.8 Media and loaders for which formats arm it. -
emu_fnkeys.{h,cpp}is the odd one out: it is a transcription (emu_fnkeys.vhd), but nothing drives it from a real membrane. The frontends callsimulate_mf_fkey_press()for F3/F5/F6/F7/F8, andEmulator::on_hotkey_*()for F1/F4/F9/F10 — the four that act on the machine itself rather than on the matrix: hard reset, soft reset, Multiface NMI and DivMMC NMI.
Alt is the host namespace, Ctrl is the guest's¶
Every plain Ctrl+key is a real ZX sequence, because Ctrl is Symbol Shift.
jnext therefore puts its host shortcuts on Alt, and gives up binding the Alt
keys as guest shift keys to pay for it. That is a deliberate divergence — real
hardware maps Left Alt to EXTEND MODE and Right Alt to GRAPH
(keymaps.vhd:85-94) — and jnext follows the FUSE / ZEsarUX convention
instead, with EXTEND on Tab and BREAK on Esc. Keyboard treats Alt as a
modifier that is never a ZX key, and latches at press time which table a
scancode resolved through, because the user may well release Alt before the
key.
One consequence matters to anyone touching the GUI. Alt+letter is a single
namespace shared by the Qt QAction shortcuts and the menubar mnemonics,
and Alt+E, Alt+G, Alt+C and Alt+` are reserved for the guest's EDIT,
GRAPH, CAPS LOCK and INV VIDEO. Adding a menu on one of those would silently
kill a guest key, so host_hotkey_test pins the two sets disjoint — see
Testing.
macOS: Command is the host modifier¶
On macOS the same split sits one key over. Qt reports Command as
Key_Control / ControlModifier / Qt::CTRL and the physical Control key as
Key_Meta (jnext does not set AA_MacDontSwapCtrlAndMeta), so
gui/host_shortcuts.h holds the host set for both platforms, with the
platform as a parameter so the macOS branch is unit-tested on Linux
(host_shortcuts_test): sequence() (Command+letter, Command+Q, Command+,;
no Alt+F4), script_sequence(), f1_decision() / f4_decision() (Command for Alt, Option inert, physical Control as Ctrl), and modifier_scancode(),
which maps Command to the Router as LGUI (the guest ignores it) and the
physical Control key to Symbol Shift. CommandChordGate keeps an unbound
Command chord from reaching the guest. The key Router's script chord
(set_script_chord_gui()) and the SDL frontend's quick-snapshot / F1 / F4
decisions (platform/host_modifier.h, QuickChordTracker::set_host_is_gui())
take the host modifier as a parameter whose default is Alt, so every Alt row
keeps its meaning on a Mac build. The debugger keymap's reserved chords follow
the same flag (validate_combo(c, why, host_mod_is_ctrl)). Add a host
shortcut to the host_shortcuts::Action table, never as a literal Qt::ALT
(HS403-27 fails on one).
The one bare non-function host key is Pause, an alternate shortcut on
Machine > Pause (an owner-approved exception to the Alt rule; it is the
conventional emulator pause key). It is safe because Next PS/2 Pause/Break is
"reset ps2 module" (keymaps.vhd:104, ps2_keyb.vhd:245) with no guest meaning,
and jnext models no PS/2. Alt+U stays the primary shortcut (QMenu shows only the
primary, and Mac keyboards have no Pause key). The action does not auto-repeat.
Qt has its own claim on Alt, and jnext declines it. A bare Alt tap normally
moves keyboard focus to the menu bar, where the next letter that is a top-level
mnemonic is swallowed and opens that popup instead of typing. In an ordinary
application that is a convenience; here Alt is part of typing, so the menu bar
is given a QProxyStyle reporting SH_MenuBar_AltKeyNavigation as off
(src/qt/menu_bar_alt_nav_qt.h, installed by both the emulator window and
the debugger window). Alt+letter mnemonics are unaffected — Qt opens those
through the shortcut map, not through this hint — and handle_key()
additionally feeds the guest nothing at all while a Qt popup owns the keyboard.
This is a settled decision, taken 2026-09-25, not a provisional workaround. The reasoning: Alt is simultaneously a guest modifier (EDIT / GRAPH / CAPS LOCK) and this project's entire host-hotkey namespace, so pressing it is an ordinary part of typing here in a way it is not in other applications; every menu remains reachable by Alt+letter and navigable with the arrow keys once open; and the only thing given up is the tap-Alt-then-arrow convenience, which in this application was silently eating keystrokes rather than helping. For an emulator with an overloaded Alt it is a net accessibility improvement, and the user guide says so to anyone who used the old route.