Skip to content

iris-gui: send physical key positions, not layout-translated keys. fi… - #73

Merged
techomancer merged 1 commit into
techomancer:mainfrom
danifunker:fix-keyboard-layouts
Aug 4, 2026
Merged

iris-gui: send physical key positions, not layout-translated keys. fi…#73
techomancer merged 1 commit into
techomancer:mainfrom
danifunker:fix-keyboard-layouts

Conversation

@danifunker

Copy link
Copy Markdown
Contributor

…xes #72

The guest does its own keyboard layout translation — the SGI PROM reads keybd= (setenv keybd de_CH) and IRIX layers X11 keymaps on top. "keybd" appears nowhere in the iris source: iris must feed the guest raw scancodes for physical key positions and let the guest apply the layout.

src/ui.rs (the CLI) always did that, via KeyEvent::physical_key. iris-gui did not. It read egui::Event::Key { key, .. } — the logical key the host OS already produced from the host's layout — and reverse-mapped it to a KeyCode assuming a US keyboard. The layout therefore got applied twice, and what reached the screen was

guest_layout(US_position_labelled_with(host_layout(key_pressed)))

which predicts every symptom in the issue exactly. On a German host:

pressed        egui logical Key   sent as          guest showed
- (at US /)    Minus              US Minus pos     ß
Shift-7 = /    Slash              US Slash pos     _
Shift-ß = ?    Questionmark       US Slash pos     _
Shift-, = ;    Semicolon          US Semicolon     Ö
Shift-. = :    Colon              US Semicolon     Ö
Shift-0 = =    Equals             US Equal pos     `
Z (at US Y)    Z                  US Z pos         y

It also explains the part of the report that looked self-contradictory — umlauts working while ASCII punctuation broke. egui-winit builds the event as key: logical_key.or(physical_key), and egui::Key has no variant for ä ö ü ß, so those keys fell through to the physical key and landed correctly by accident. Only characters egui can name got relocated.

Prefer physical_key, which egui has carried on Event::Key all along and which this code was discarding with ...

The ISO 102nd key (< > |, left of Z on every European keyboard) was a second, independent bug: KeyCode::IntlBackslash had no entry in any scancode set, so it was dead in the CLI too. Added to all three — set 1 0x56, set 2 0x61, set 3 0x13. Set 3 is the one IRIX drives the keyboard in; 0x13 cross-checks against Linux's atkbd_set3_keycode[0x13] == KEY_102ND and lands in the leftmost column between LCtrl 0x11, LShift 0x12 and CapsLock 0x14. Note set 2's 0x61 must not be reused for set 3, where it is Left.

egui 0.29 -> 0.35

Load-bearing for the above, not cosmetic. 0.29 could not express two things no amount of iris-side code could recover:

  • Key::IntlBackslash did not exist, and egui::Key had no Less/Greater, so both logical_key and physical_key were None and no event at all was emitted for the ISO key.
  • egui::Modifiers has a single alt with no left/right split and modifier keys produced no Key event, so input.rs could only ever send AltLeft. That killed the entire AltGr level on DE/de_CH — @ \ | { } [ ] ~ €. de_CH's \ is AltGr+<, hit by both gaps at once.

0.35 adds Key::IntlBackslash and discrete ShiftLeft/Right, ControlLeft/Right, AltLeft/Right, SuperLeft/Right. pump() now forwards modifiers as real press/release key events and tracks held_mods: Vec; the egui::Modifiers-diff synthesis is gone, which is also how src/ui.rs has always worked.

Port was 53 errors, all mechanical: 41 were ui.close_menu() -> ui.close(). The rest: SidePanel/TopBottomPanel unified into egui::Panel, panels take a &mut Ui instead of a &Context, App::update -> App::ui, ctx.screen_rect() -> ctx.viewport_rect(), Frame::none() -> Frame::new(), Margin::symmetric f32 -> i8, Image::rounding -> corner_radius, push_id needs AsIdSalt (Hash + Debug). The old update() body survives unchanged behind let ctx = &ui.ctx().clone().

Two-key release chord

Now possible because 0.35 distinguishes left from right: hold left Ctrl+Alt (left Option+Cmd on macOS) and press nothing else. It fires on key-up and only if chord_consumed is false — no other key pressed while held — which is what preserves Ctrl+Alt+F11. Ctrl+Alt+Esc stays as an explicit fallback. The UI hint reads input::RELEASE_HINT so it cannot drift from the real chord.

winit 0.29 -> 0.30: one winit, so the App Store patch actually covers it ----------------------------------------------------------------------- Found while verifying the above: the private SkyLight symbol was back in the linked binary.

nm -u target/release/iris-gui | grep CGS
_CGSMainConnectionID
_CGSSetWindowBackgroundBlurRadius

Pre-existing — the IRIS.app bundle from Jul 2 has it too, and it is unaffected by the egui bump either way. The vendored third_party/ winit-0.30.13 stub was intact and doing its job; the import came from iris's own winit 0.29, which [patch.crates-io] never covered because that patch only matches the 0.30.x requirement. Only two crates in the registry declare the symbol, and the binary embedded source paths for both — including winit-0.29.15/src/platform_impl/macos/window.rs, where the call lives.

Merely depending on winit 0.29 links its macOS backend; reachability is not enough to strip it. Commenting out pub mod ui; and rebuilding does not remove the symbol — winit's macOS backend registers Obj-C classes via declare_class!, which emits #[used] statics that survive dead-stripping. The original patch's assumption, that the 0.29 copy "creates no window in iris-gui, so its blur code is dead-stripped", was wrong.

Fixed by removing the second winit rather than adding a second patch: winit 0.29 -> 0.30, glutin 0.31 -> 0.32, glutin-winit 0.4 -> 0.5, raw-window-handle 0.5 -> 0.6. Cheap because KeyCode is byte-identical across winit 0.29/0.30 (ps2.rs and all keyboard code needed no changes) and EventLoop::run(closure) still exists in 0.30, so src/ui.rs did not need an ApplicationHandler rewrite. Eight mechanical edits in ui.rs and headless_gl.rs: WindowBuilder -> WindowAttributes::default(), .with_window_builder() -> .with_window_attributes(), and rwh 0.6 making raw_window_handle() / build_surface_attributes() return Result.

Cargo.lock now holds exactly one winit block, and it is the path override. Verified clean on ./scripts/build-macos.sh appstore and on the signed sandboxed bundle, with _CGShieldingWindowLevel (public) still present as a control that nm is reading the binary.

Taking winit from git master instead is not an option yet, and fails dangerously: master has the fix behind a private-apple-apis feature but is versioned 0.31.0-beta.2, which does not satisfy egui-winit 0.35's ^0.30.13, so cargo only warns "patch was not used in the crate graph" and silently links the unpatched registry crate. The v0.30.x branch is version-compatible but the fix was never backported there. Documented in rules/macos/appstore-private-api.md.

Not covered here: no runtime testing against IRIX — this is verified at compile and symbol level only. flexi0n's "de_CH umlauts do not work" is the one reported symptom the host-side model does not explain and should be re-tested. Still no CI gate for the nm check; that belongs in appstore.yml on main. Five deprecation warnings remain (HasRawWindowHandle, EventLoop::run), left alone deliberately.

 techomancer#72

The guest does its own keyboard layout translation — the SGI PROM reads
keybd= (setenv keybd de_CH) and IRIX layers X11 keymaps on top. "keybd"
appears nowhere in the iris source: iris must feed the guest raw scancodes
for physical key positions and let the guest apply the layout.

src/ui.rs (the CLI) always did that, via KeyEvent::physical_key. iris-gui
did not. It read egui::Event::Key { key, .. } — the *logical* key the host
OS already produced from the host's layout — and reverse-mapped it to a
KeyCode assuming a US keyboard. The layout therefore got applied twice, and
what reached the screen was

    guest_layout(US_position_labelled_with(host_layout(key_pressed)))

which predicts every symptom in the issue exactly. On a German host:

    pressed        egui logical Key   sent as          guest showed
    - (at US /)    Minus              US Minus pos     ß
    Shift-7 = /    Slash              US Slash pos     _
    Shift-ß = ?    Questionmark       US Slash pos     _
    Shift-, = ;    Semicolon          US Semicolon     Ö
    Shift-. = :    Colon              US Semicolon     Ö
    Shift-0 = =    Equals             US Equal pos     `
    Z (at US Y)    Z                  US Z pos         y

It also explains the part of the report that looked self-contradictory —
umlauts working while ASCII punctuation broke. egui-winit builds the event
as `key: logical_key.or(physical_key)`, and egui::Key has no variant for
ä ö ü ß, so those keys fell through to the *physical* key and landed
correctly by accident. Only characters egui can name got relocated.

Prefer physical_key, which egui has carried on Event::Key all along and
which this code was discarding with `..`.

The ISO 102nd key (< > |, left of Z on every European keyboard) was a
second, independent bug: KeyCode::IntlBackslash had no entry in any
scancode set, so it was dead in the CLI too. Added to all three — set 1
0x56, set 2 0x61, set 3 0x13. Set 3 is the one IRIX drives the keyboard in;
0x13 cross-checks against Linux's atkbd_set3_keycode[0x13] == KEY_102ND and
lands in the leftmost column between LCtrl 0x11, LShift 0x12 and CapsLock
0x14. Note set 2's 0x61 must not be reused for set 3, where it is Left.

egui 0.29 -> 0.35
-----------------
Load-bearing for the above, not cosmetic. 0.29 could not express two things
no amount of iris-side code could recover:

  - Key::IntlBackslash did not exist, and egui::Key had no Less/Greater, so
    both logical_key and physical_key were None and *no event at all* was
    emitted for the ISO key.
  - egui::Modifiers has a single `alt` with no left/right split and modifier
    keys produced no Key event, so input.rs could only ever send AltLeft.
    That killed the entire AltGr level on DE/de_CH — @ \ | { } [ ] ~ €.
    de_CH's \ is AltGr+<, hit by both gaps at once.

0.35 adds Key::IntlBackslash and discrete ShiftLeft/Right, ControlLeft/Right,
AltLeft/Right, SuperLeft/Right. pump() now forwards modifiers as real
press/release key events and tracks held_mods: Vec<KeyCode>; the
egui::Modifiers-diff synthesis is gone, which is also how src/ui.rs has
always worked.

Port was 53 errors, all mechanical: 41 were ui.close_menu() -> ui.close().
The rest: SidePanel/TopBottomPanel unified into egui::Panel, panels take a
&mut Ui instead of a &Context, App::update -> App::ui, ctx.screen_rect() ->
ctx.viewport_rect(), Frame::none() -> Frame::new(), Margin::symmetric f32 ->
i8, Image::rounding -> corner_radius, push_id needs AsIdSalt (Hash + Debug).
The old update() body survives unchanged behind `let ctx = &ui.ctx().clone()`.

Two-key release chord
---------------------
Now possible because 0.35 distinguishes left from right: hold left Ctrl+Alt
(left Option+Cmd on macOS) and press nothing else. It fires on key-up and
only if chord_consumed is false — no other key pressed while held — which is
what preserves Ctrl+Alt+F11. Ctrl+Alt+Esc stays as an explicit fallback. The
UI hint reads input::RELEASE_HINT so it cannot drift from the real chord.

winit 0.29 -> 0.30: one winit, so the App Store patch actually covers it
-----------------------------------------------------------------------
Found while verifying the above: the private SkyLight symbol was back in the
linked binary.

    nm -u target/release/iris-gui | grep CGS
    _CGSMainConnectionID
    _CGSSetWindowBackgroundBlurRadius

Pre-existing — the IRIS.app bundle from Jul 2 has it too, and it is
unaffected by the egui bump either way. The vendored third_party/
winit-0.30.13 stub was intact and doing its job; the import came from iris's
*own* winit 0.29, which [patch.crates-io] never covered because that patch
only matches the 0.30.x requirement. Only two crates in the registry declare
the symbol, and the binary embedded source paths for both — including
winit-0.29.15/src/platform_impl/macos/window.rs, where the call lives.

Merely depending on winit 0.29 links its macOS backend; reachability is not
enough to strip it. Commenting out `pub mod ui;` and rebuilding does not
remove the symbol — winit's macOS backend registers Obj-C classes via
declare_class!, which emits #[used] statics that survive dead-stripping. The
original patch's assumption, that the 0.29 copy "creates no window in
iris-gui, so its blur code is dead-stripped", was wrong.

Fixed by removing the second winit rather than adding a second patch: winit
0.29 -> 0.30, glutin 0.31 -> 0.32, glutin-winit 0.4 -> 0.5,
raw-window-handle 0.5 -> 0.6. Cheap because KeyCode is byte-identical across
winit 0.29/0.30 (ps2.rs and all keyboard code needed no changes) and
EventLoop::run(closure) still exists in 0.30, so src/ui.rs did not need an
ApplicationHandler rewrite. Eight mechanical edits in ui.rs and
headless_gl.rs: WindowBuilder -> WindowAttributes::default(),
.with_window_builder() -> .with_window_attributes(), and rwh 0.6 making
raw_window_handle() / build_surface_attributes() return Result.

Cargo.lock now holds exactly one winit block, and it is the path override.
Verified clean on ./scripts/build-macos.sh appstore and on the signed
sandboxed bundle, with _CGShieldingWindowLevel (public) still present as a
control that nm is reading the binary.

Taking winit from git master instead is not an option yet, and fails
dangerously: master has the fix behind a private-apple-apis feature but is
versioned 0.31.0-beta.2, which does not satisfy egui-winit 0.35's ^0.30.13,
so cargo only warns "patch was not used in the crate graph" and silently
links the unpatched registry crate. The v0.30.x branch is version-compatible
but the fix was never backported there. Documented in
rules/macos/appstore-private-api.md.

Not covered here: no runtime testing against IRIX — this is verified at
compile and symbol level only. flexi0n's "de_CH umlauts do not work" is the
one reported symptom the host-side model does not explain and should be
re-tested. Still no CI gate for the nm check; that belongs in appstore.yml
on main. Five deprecation warnings remain (HasRawWindowHandle,
EventLoop::run), left alone deliberately.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@danifunker
danifunker marked this pull request as ready for review August 3, 2026 18:53
@techomancer
techomancer merged commit 6bbb609 into techomancer:main Aug 4, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants