From ebbdecabedf70666dc6adc2b87bbd3849ca82446 Mon Sep 17 00:00:00 2001 From: Dani Sarfati Date: Mon, 3 Aug 2026 12:40:40 -0400 Subject: [PATCH] iris-gui: send physical key positions, not layout-translated keys. fixes #72 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. Co-Authored-By: Claude Opus 5 (1M context) --- Cargo.toml | 8 +- iris-gui/Cargo.toml | 6 +- iris-gui/src/config_ui.rs | 12 +- iris-gui/src/input.rs | 144 +++++++++--------- iris-gui/src/main.rs | 98 ++++++------ iris-gui/src/scsi_menu.rs | 22 +-- rules/gui/gui_mouse_integration.md | 8 +- ...egui-steals-tab-arrows-esc-and-ctrl-cxv.md | 12 +- ...d-layout-send-physical-keys-not-logical.md | 111 ++++++++++++++ rules/irix/keyboard-issues.md | 10 ++ rules/macos/appstore-private-api.md | 117 ++++++++++++++ src/headless_gl.rs | 10 +- src/ps2.rs | 6 + src/ui.rs | 10 +- 14 files changed, 415 insertions(+), 159 deletions(-) create mode 100644 rules/gui/keyboard-layout-send-physical-keys-not-logical.md diff --git a/Cargo.toml b/Cargo.toml index e5f4fde..c742275 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -98,11 +98,11 @@ ultra64 = ["dep:shared_memory", "dep:raw_sync"] clap = { version = "4", features = ["derive"] } log = "0.4" env_logger = "0.10" -winit = "0.29" -glutin = "0.31" -glutin-winit = "0.4" +winit = "0.30" +glutin = "0.32" +glutin-winit = "0.5" glow = "0.13" -raw-window-handle = "0.5" +raw-window-handle = "0.6" rtrb = "0.3" socket2 = { version = "0.5", features = ["all"] } crossbeam-utils = "0.8" diff --git a/iris-gui/Cargo.toml b/iris-gui/Cargo.toml index 9db888b..c58057b 100644 --- a/iris-gui/Cargo.toml +++ b/iris-gui/Cargo.toml @@ -86,13 +86,13 @@ premiere = ["iris/lightning", "iris/idle-pause"] # ultra64 GIO device + POSIX shm bridge are only created when the user enables # the board on the General tab. The toggle itself is hidden in App Store builds. iris = { path = "..", features = ["chd", "camera", "jit", "rex-jit", "ultra64"] } -eframe = { version = "0.29", default-features = false, features = ["default_fonts", "glow", "wayland", "x11"] } -egui = "0.29" +eframe = { version = "0.35", default-features = false, features = ["default_fonts", "glow", "wayland", "x11"] } +egui = "0.35" crossbeam-channel = "0.5" parking_lot = "0.12" # Match iris's winit version — Ps2Controller::push_kb takes # winit::keyboard::KeyCode. -winit = "0.29" +winit = "0.30" png = "0.17" rfd = { version = "0.15", default-features = false, features = ["xdg-portal", "async-std"] } serde = { version = "1", features = ["derive"] } diff --git a/iris-gui/src/config_ui.rs b/iris-gui/src/config_ui.rs index c17dfce..c6b3390 100644 --- a/iris-gui/src/config_ui.rs +++ b/iris-gui/src/config_ui.rs @@ -972,25 +972,25 @@ fn show_network( .clicked() { add = Some(PortForwardConfig { proto: ForwardProto::Tcp, host_port: 2323, guest_port: 23, bind: ForwardBind::Localhost }); - ui.close_menu(); + ui.close(); } if ui.add_enabled(!has_port(21), egui::Button::new("FTP (host 2121 to guest 21)")) .on_hover_text("Reach the guest's FTP server. Forwards the control port; file transfer also needs the data channel (see docs).") .clicked() { add = Some(PortForwardConfig { proto: ForwardProto::Tcp, host_port: 2121, guest_port: 21, bind: ForwardBind::Localhost }); - ui.close_menu(); + ui.close(); } if ui.add_enabled(!has_port(177), egui::Button::new("XDMCP (host 11177 to guest 177, UDP)")) .on_hover_text("Remote X login: an X server XDMCP-queries the guest's xdm. Binds all interfaces (LAN X servers OK). Stock X servers use UDP 177 — redirect 177→11177 on the X-server host, or use a chooser that accepts host:port.") .clicked() { add = Some(PortForwardConfig { proto: ForwardProto::Udp, host_port: 11177, guest_port: 177, bind: ForwardBind::Any }); - ui.close_menu(); + ui.close(); } if ui.button("Custom (empty row)").clicked() { add = Some(PortForwardConfig { proto: ForwardProto::Tcp, host_port: 0, guest_port: 0, bind: ForwardBind::Localhost }); - ui.close_menu(); + ui.close(); } }); if let Some(pf) = add { cfg.port_forward.push(pf); out.changed = true; out.forwards_changed = true; } @@ -1594,7 +1594,7 @@ struct PathEdit { /// A TextEdit + 📁 Browse button that updates `value` in place. See [`PathEdit`]. fn path_row( ui: &mut Ui, - id: impl std::hash::Hash, + id: impl std::hash::Hash + std::fmt::Debug, // egui 0.35 push_id needs AsIdSalt (Hash + Debug) value: &mut String, mode: Pick, filters: &[(&str, &[&str])], @@ -1649,7 +1649,7 @@ fn path_row( /// the user can clear by emptying the text. fn path_row_opt( ui: &mut Ui, - id: impl std::hash::Hash, + id: impl std::hash::Hash + std::fmt::Debug, value: &mut Option, mode: Pick, filters: &[(&str, &[&str])], diff --git a/iris-gui/src/input.rs b/iris-gui/src/input.rs index 878e429..0528e19 100644 --- a/iris-gui/src/input.rs +++ b/iris-gui/src/input.rs @@ -10,9 +10,7 @@ //! and lock it in place (`CursorGrab::Locked`); raw motion then arrives as //! `egui::Event::MouseMoved` deltas (eframe forwards `DeviceEvent::MouseMotion` //! regardless of grab), which we feed straight to the guest. Only the guest's -//! own pointer is visible, so there is nothing to misalign. **Ctrl+Alt+Esc -//! releases** (Alt is the Option key on macOS); a chord rather than bare Esc -//! so plain Esc still reaches the guest. +//! own pointer is visible, so there is nothing to misalign. `RELEASE_CHORD` releases. //! //! While captured we also forward keyboard input to the guest; while *not* //! captured we forward nothing, so menu clicks and typing into the config @@ -24,12 +22,15 @@ //! hit-testing already routed clicks on open menus / popups to those widgets, //! so navigating menus over the display never gets "eaten" into a capture. -use egui::{CursorGrab, Event, Key, Modifiers, MouseWheelUnit, PointerButton, ViewportCommand}; +use egui::{CursorGrab, Event, Key, MouseWheelUnit, PointerButton, ViewportCommand}; use iris::ps2::Ps2Controller; use winit::keyboard::KeyCode; pub struct InputState { - last_mods: Modifiers, + /// Modifier keys currently held down in the guest, so they can be lifted on release. + held_mods: Vec, + /// Set when another key is pressed while the release chord is held, so the chord sends its modifiers to the guest instead of releasing capture. + chord_consumed: bool, last_buttons: u8, // bit0=L, bit1=R, bit2=M, bit3=B4, bit4=B5 /// True while the host cursor is grabbed and input is routed to the guest. pub captured: bool, @@ -42,10 +43,27 @@ pub struct InputState { impl Default for InputState { fn default() -> Self { - Self { last_mods: Modifiers::NONE, last_buttons: 0, captured: false, unfocused_since: None } + Self { held_mods: Vec::new(), chord_consumed: false, last_buttons: 0, captured: false, unfocused_since: None } } } +/// Hold these two together (and press nothing else) to release capture. +#[cfg(target_os = "macos")] +const RELEASE_CHORD: [KeyCode; 2] = [KeyCode::AltLeft, KeyCode::SuperLeft]; +#[cfg(not(target_os = "macos"))] +const RELEASE_CHORD: [KeyCode; 2] = [KeyCode::ControlLeft, KeyCode::AltLeft]; + +/// Human-readable name of `RELEASE_CHORD`, for the capture hint in the control column. +#[cfg(target_os = "macos")] +pub const RELEASE_HINT: &str = "Left Option+Cmd"; +#[cfg(not(target_os = "macos"))] +pub const RELEASE_HINT: &str = "Left Ctrl+Alt"; + +fn is_modifier(kc: KeyCode) -> bool { + matches!(kc, KeyCode::ShiftLeft | KeyCode::ShiftRight | KeyCode::ControlLeft | KeyCode::ControlRight + | KeyCode::AltLeft | KeyCode::AltRight | KeyCode::SuperLeft | KeyCode::SuperRight) +} + /// How long the window must stay unfocused before a capture is released. Long /// enough to ride out macOS's transient focus flicker, short enough that a real /// alt-tab frees the cursor promptly. @@ -62,7 +80,6 @@ pub fn pump(ctx: &egui::Context, fb_clicked: bool, ps2: &Ps2Controller, state: & let mut dy = 0.0f32; let mut dz = 0.0f32; let mut buttons = state.last_buttons; - let mut mods = state.last_mods; let mut keys: Vec<(KeyCode, bool)> = Vec::new(); let mut f11_to_guest = false; @@ -76,31 +93,23 @@ pub fn pump(ctx: &egui::Context, fb_clicked: bool, ps2: &Ps2Controller, state: & return; } - // Captured. Ctrl+Alt+Esc (Alt == Option on macOS) is the release chord; - // a real focus loss (alt-tab) also releases, but only after a grace - // period (decided below) so a one-frame `focused=false` flicker doesn't - // drop capture while you're typing. A chord rather than bare Esc lets - // plain Esc reach the guest. Keep reading events even on a flicker frame - // so typing keeps flowing to the guest. + // Captured. RELEASE_CHORD releases (see below); Ctrl+Alt+Esc still works as an explicit fallback, and a real focus loss (alt-tab) releases after a grace period so a one-frame flicker doesn't drop capture mid-typing. esc_chord = i.key_pressed(Key::Escape) && i.modifiers.ctrl && i.modifiers.alt; focused = i.focused; - mods = i.modifiers; - for ev in &i.events { match ev { // Raw relative motion (eframe → DeviceEvent::MouseMotion). Event::MouseMoved(d) => { dx += d.x; dy += d.y; } - Event::Key { key, pressed, repeat, .. } => { - if *key == Key::F11 { - // Plain F11 is the GUI's fullscreen toggle and is never - // forwarded. Ctrl+Alt+F11 is the escape hatch that delivers - // a real F11 to IRIX — recorded here on the press edge and - // sent (as a bare F11) after the modifier diff below. + // Prefer the physical position: `key` is already layout-translated by the host, and the guest applies its own `keybd=` layout on top, which mangles every non-US layout. + Event::Key { key, physical_key, pressed, repeat, .. } => { + let k = physical_key.unwrap_or(*key); + if k == Key::F11 { + // Plain F11 is the GUI's fullscreen toggle; Ctrl+Alt+F11 is the escape hatch that sends a bare F11 to IRIX. if *pressed && !*repeat && i.modifiers.ctrl && i.modifiers.alt { f11_to_guest = true; } - } else if let Some(kc) = map_key(*key) { + } else if let Some(kc) = map_key(k) { keys.push((kc, *pressed)); } } @@ -164,39 +173,38 @@ pub fn pump(ctx: &egui::Context, fb_clicked: bool, ps2: &Ps2Controller, state: & return; } - // ---- modifiers: diff previous → current, synth press/release. ---- - let m = mods; - if m.shift && !state.last_mods.shift { ps2.push_kb(KeyCode::ShiftLeft, true); } - if !m.shift && state.last_mods.shift { ps2.push_kb(KeyCode::ShiftLeft, false); } - if m.ctrl && !state.last_mods.ctrl { ps2.push_kb(KeyCode::ControlLeft, true); } - if !m.ctrl && state.last_mods.ctrl { ps2.push_kb(KeyCode::ControlLeft, false); } - if m.alt && !state.last_mods.alt { ps2.push_kb(KeyCode::AltLeft, true); } - if !m.alt && state.last_mods.alt { ps2.push_kb(KeyCode::AltLeft, false); } - if m.mac_cmd && !state.last_mods.mac_cmd { ps2.push_kb(KeyCode::SuperLeft, true); } - if !m.mac_cmd && state.last_mods.mac_cmd { ps2.push_kb(KeyCode::SuperLeft, false); } - state.last_mods = m; - - // ---- key events ---- - for (kc, pressed) in keys { ps2.push_kb(kc, pressed); } + // Modifiers arrive as real L/R key events (egui 0.35), so forward them like any other key and track what's held. + if f11_to_guest { state.chord_consumed = true; } + let mut chord_release = false; + for (kc, pressed) in keys { + let was_chord = RELEASE_CHORD.iter().all(|k| state.held_mods.contains(k)); + if is_modifier(kc) { + if pressed { + if !state.held_mods.contains(&kc) { state.held_mods.push(kc); } + } else { + state.held_mods.retain(|k| *k != kc); + } + } else if pressed && was_chord { + state.chord_consumed = true; + } + let now_chord = RELEASE_CHORD.iter().all(|k| state.held_mods.contains(k)); + if now_chord && !was_chord { state.chord_consumed = false; } + if was_chord && !now_chord && !state.chord_consumed { chord_release = true; } + ps2.push_kb(kc, pressed); + } - // Ctrl+Alt+F11 → a *bare* F11 to the guest. Plain F11 is swallowed by the - // GUI's fullscreen toggle, so this chord is the only path for F11 into IRIX. - // The modifier diff above has left the chord's Ctrl+Alt (and any Shift/Cmd) - // pressed in the guest, so lift whatever is held, tap F11, then re-press — - // IRIX sees an unmodified F11. `state.last_mods` is left untouched, so the - // next frame's diff stays consistent (no spurious modifier press/release). + // Ctrl+Alt+F11 → a *bare* F11 to the guest: plain F11 is the GUI's fullscreen toggle, so this chord is the only path for F11 into IRIX. if f11_to_guest { - let held = state.last_mods; - if held.shift { ps2.push_kb(KeyCode::ShiftLeft, false); } - if held.ctrl { ps2.push_kb(KeyCode::ControlLeft, false); } - if held.alt { ps2.push_kb(KeyCode::AltLeft, false); } - if held.mac_cmd { ps2.push_kb(KeyCode::SuperLeft, false); } + let held = state.held_mods.clone(); + for kc in &held { ps2.push_kb(*kc, false); } ps2.push_kb(KeyCode::F11, true); ps2.push_kb(KeyCode::F11, false); - if held.shift { ps2.push_kb(KeyCode::ShiftLeft, true); } - if held.ctrl { ps2.push_kb(KeyCode::ControlLeft, true); } - if held.alt { ps2.push_kb(KeyCode::AltLeft, true); } - if held.mac_cmd { ps2.push_kb(KeyCode::SuperLeft, true); } + for kc in &held { ps2.push_kb(*kc, true); } + } + + if chord_release { + release_capture(ctx, ps2, state); + return; } // ---- mouse: raw per-frame delta + button diff + scroll. ---- @@ -241,9 +249,9 @@ fn grab_mode() -> CursorGrab { pub fn engage_capture(ctx: &egui::Context, state: &mut InputState) { if state.captured { return; } state.captured = true; - // Anchor modifier/button state so we don't synth a spurious press for a - // key/button already held at capture time. - state.last_mods = ctx.input(|i| i.modifiers); + // Anchor modifier/button state so we don't leave a stale key held from before capture. + state.held_mods.clear(); + state.chord_consumed = false; state.last_buttons = 0; state.unfocused_since = None; ctx.send_viewport_cmd(ViewportCommand::CursorVisible(false)); @@ -256,12 +264,9 @@ pub fn engage_capture(ctx: &egui::Context, state: &mut InputState) { /// stops while the framebuffer still had the grab. pub fn release_capture(ctx: &egui::Context, ps2: &Ps2Controller, state: &mut InputState) { if !state.captured { return; } - if state.last_mods.shift { ps2.push_kb(KeyCode::ShiftLeft, false); } - if state.last_mods.ctrl { ps2.push_kb(KeyCode::ControlLeft, false); } - if state.last_mods.alt { ps2.push_kb(KeyCode::AltLeft, false); } - if state.last_mods.mac_cmd { ps2.push_kb(KeyCode::SuperLeft, false); } + for kc in state.held_mods.drain(..) { ps2.push_kb(kc, false); } state.captured = false; - state.last_mods = Modifiers::NONE; + state.chord_consumed = false; state.last_buttons = 0; state.unfocused_since = None; ctx.send_viewport_cmd(ViewportCommand::CursorVisible(true)); @@ -274,7 +279,8 @@ pub fn release_capture(ctx: &egui::Context, ps2: &Ps2Controller, state: &mut Inp pub fn force_release(ctx: &egui::Context, state: &mut InputState) { if !state.captured { return; } state.captured = false; - state.last_mods = Modifiers::NONE; + state.held_mods.clear(); + state.chord_consumed = false; state.last_buttons = 0; state.unfocused_since = None; ctx.send_viewport_cmd(ViewportCommand::CursorVisible(true)); @@ -282,9 +288,7 @@ pub fn force_release(ctx: &egui::Context, state: &mut InputState) { } -/// egui::Key → winit::keyboard::KeyCode. Returns None for keys iris's -/// scancode mapper doesn't recognise (we just drop them rather than -/// inventing a fallback). +/// egui::Key (a physical position, per the caller) → winit KeyCode; None for keys iris's scancode mapper doesn't recognise. fn map_key(k: Key) -> Option { Some(match k { // Letters @@ -333,13 +337,17 @@ fn map_key(k: Key) -> Option { Key::OpenBracket => KeyCode::BracketLeft, Key::CloseBracket => KeyCode::BracketRight, Key::Backtick => KeyCode::Backquote, - // egui reports the *shifted* symbol as its own Key; these two share a - // physical key with Backslash/Slash (Shift is sent separately, so the - // guest forms '|' and '?'). Without them those keys send nothing. + // Only reachable on the logical fallback (no physical_key): shifted symbols sharing a key with Backslash/Slash. Key::Pipe => KeyCode::Backslash, Key::Questionmark => KeyCode::Slash, - // F-keys. F11 is reserved by the GUI (fullscreen toggle), so it isn't - // forwarded; iris's PS/2 scancode set stops at F12, so F13+ are dropped. + // ISO 102nd key (< > |), left of Z on European keyboards — new in egui 0.35. + Key::IntlBackslash => KeyCode::IntlBackslash, + // Modifiers as real L/R keys (egui 0.35); AltRight is AltGr, which the guest needs for @ \ | { } [ ] ~ on DE/de_CH. + Key::ShiftLeft => KeyCode::ShiftLeft, Key::ShiftRight => KeyCode::ShiftRight, + Key::ControlLeft => KeyCode::ControlLeft, Key::ControlRight => KeyCode::ControlRight, + Key::AltLeft => KeyCode::AltLeft, Key::AltRight => KeyCode::AltRight, + Key::SuperLeft => KeyCode::SuperLeft, Key::SuperRight => KeyCode::SuperRight, + // F11 is reserved by the GUI (fullscreen); iris's scancode sets stop at F12, so F13+ are dropped. Key::F1 => KeyCode::F1, Key::F2 => KeyCode::F2, Key::F3 => KeyCode::F3, Key::F4 => KeyCode::F4, Key::F5 => KeyCode::F5, Key::F6 => KeyCode::F6, Key::F7 => KeyCode::F7, Key::F8 => KeyCode::F8, Key::F9 => KeyCode::F9, diff --git a/iris-gui/src/main.rs b/iris-gui/src/main.rs index da6012f..b63e322 100644 --- a/iris-gui/src/main.rs +++ b/iris-gui/src/main.rs @@ -926,7 +926,7 @@ impl App { self.syncing = Some(SyncJob { disk: 0, total: 1, fraction: 0.0 }); } self.emu.send(Cmd::CowCommit { base: base.clone(), chd: is_chd }); - ui.close_menu(); + ui.close(); } if ui.button(" ↩ Discard changes (roll back)") .on_hover_text("Throw away this session's overlay and revert to the disk as it was") @@ -934,7 +934,7 @@ impl App { { // Destructive — confirm before discarding. self.cow_discard_confirm = Some(CowDiscard { id, base: base.clone(), chd: is_chd }); - ui.close_menu(); + ui.close(); } } } @@ -1025,7 +1025,7 @@ impl App { ui.set_min_width(220.0); if ui.button("New machine…").clicked() { self.new_machine.open(); - ui.close_menu(); + ui.close(); } ui.menu_button("Switch to machine", |ui| { ui.set_min_width(200.0); @@ -1041,7 +1041,7 @@ impl App { let is_active = active.as_deref() == Some(name.as_str()); if ui.selectable_label(is_active, name.as_str()).clicked() { want_switch = Some(name); - ui.close_menu(); + ui.close(); } } if let Some(n) = want_switch { self.switch_to(&n); } @@ -1055,7 +1055,7 @@ impl App { // text box inside the menu can't work — the menu closure // re-runs each frame and would reset the buffer.) self.rename_buffer = self.prefs.active_machine.clone(); - ui.close_menu(); + ui.close(); } let active = self.prefs.active_machine.clone(); if ui.add_enabled(active.is_some(), egui::Button::new("Delete current machine")).clicked() { @@ -1071,7 +1071,7 @@ impl App { let _ = self.prefs.save(); self.toast(format!("deleted '{name}'")); } - ui.close_menu(); + ui.close(); } // iris.toml import/export is a source-build affordance for users // who also run the standalone `iris` CLI; the GUI's own gui.json @@ -1091,17 +1091,17 @@ impl App { self.flush_machine(); self.toast(format!("imported as '{name}'")); } - ui.close_menu(); + ui.close(); } if ui.button("Export current to iris.toml…").clicked() { if let Some(path) = native_save_dialog("Export iris.toml", &[("TOML", &["toml"])]) { self.save_config(path); } - ui.close_menu(); + ui.close(); } if ui.button("Prepare for premiere…").clicked() { self.prepare_for_premiere(); - ui.close_menu(); + ui.close(); } } // App Store sandbox: grant a whole folder (recursive) so the @@ -1118,7 +1118,7 @@ impl App { .clicked() { self.grant_disk_folder(); - ui.close_menu(); + ui.close(); } let folders = self.prefs.disk_folders.clone(); if folders.is_empty() { @@ -1159,16 +1159,16 @@ impl App { let running = self.emu.is_running(); if ui.add_enabled(!running, egui::Button::new("Start")).clicked() { self.start_emulator(); - ui.close_menu(); + ui.close(); } if ui.add_enabled(running, egui::Button::new("Stop")).clicked() { self.request_stop(); - ui.close_menu(); + ui.close(); } if ui.add_enabled(running, egui::Button::new("Reset")).clicked() { self.emu.send(Cmd::Stop); self.start_emulator(); - ui.close_menu(); + ui.close(); } if ui.add_enabled(!running, egui::Button::new("Reset NVRAM (fresh PRAM)")) .on_hover_text(format!( @@ -1185,7 +1185,7 @@ impl App { } Err(e) => self.toast(format!("NVRAM reset failed: {e}")), } - ui.close_menu(); + ui.close(); } ui.separator(); ui.horizontal(|ui| { @@ -1207,14 +1207,14 @@ impl App { if let Some(p) = native_save_dialog("Save screenshot", &[("PNG", &["png"])]) { self.emu.send(Cmd::Screenshot(p)); } - ui.close_menu(); + ui.close(); } if ui.add_enabled(running, egui::Button::new("Serial console…")) .on_hover_text("View the IRIX serial console (ttyd1) over the loopback serial server") .clicked() { self.open_serial_console(); - ui.close_menu(); + ui.close(); } }); ui.menu_button("Memory ▶", |ui| { @@ -1255,7 +1255,7 @@ impl App { self.cfg.banks = distribute_ram(p); self.mark_dirty(); self.toast(format!("RAM set to {} ({:?})", ram_summary(&self.cfg.banks), self.cfg.banks)); - ui.close_menu(); + ui.close(); } } ui.separator(); @@ -1270,7 +1270,7 @@ impl App { { self.cfg.banks[i] = sz; self.mark_dirty(); - ui.close_menu(); + ui.close(); } } }); @@ -1344,7 +1344,7 @@ impl App { if ui.button(if self.fullscreen { "Exit fullscreen (F11)" } else { "Fullscreen (F11)" }).clicked() { self.fullscreen = !self.fullscreen; ctx.send_viewport_cmd(ViewportCommand::Fullscreen(self.fullscreen)); - ui.close_menu(); + ui.close(); } ui.horizontal(|ui| { ui.label("UI scale"); @@ -1388,7 +1388,7 @@ impl App { .clicked() { self.open_camera_test(); - ui.close_menu(); + ui.close(); } if ui.add_enabled(running, egui::Button::new("Serial console…")) .on_hover_text("Connect to the emulator's loopback serial server (127.0.0.1:8881)") @@ -1396,18 +1396,18 @@ impl App { .clicked() { self.open_serial_console(); - ui.close_menu(); + ui.close(); } if ui.button("ℹ How camera & networking work…").clicked() { self.show_help_info = true; - ui.close_menu(); + ui.close(); } if ui.button("📂 Mount the shared folder in IRIX…") .on_hover_text("The exact mount command for the NFS share") .clicked() { self.show_nfs_help = true; - ui.close_menu(); + ui.close(); } // N64 dev board getting-started guide. Hidden in App Store // builds, where the board can't run (sandbox blocks the POSIX @@ -1418,7 +1418,7 @@ impl App { .clicked() { self.show_ultra64_help = true; - ui.close_menu(); + ui.close(); } ui.separator(); ui.label(RichText::new("Legal").strong()); @@ -1427,11 +1427,11 @@ impl App { .clicked() { self.show_license = true; - ui.close_menu(); + ui.close(); } if ui.button("Privacy policy…").clicked() { self.show_privacy = true; - ui.close_menu(); + ui.close(); } ui.separator(); ui.label(RichText::new("Authors").strong()); @@ -1523,15 +1523,11 @@ impl App { } } - /// Mouse/keyboard capture state for the control column, sitting between the - /// config controls and the status footer. Only the *capture* action is a - /// button — releasing stays the Ctrl+Alt+Esc hotkey, because while captured - /// the host pointer is grabbed by the guest and can't click anything. - /// Caller renders this only while the machine is running. + /// Capture state for the control column; only *capture* is a button, since while captured the grabbed pointer can't click anything. fn capture_controls(&mut self, ui: &mut egui::Ui, ctx: &egui::Context) { if self.input_state.captured { ui.label(RichText::new("Mouse/Keyboard Captured").color(Color32::LIGHT_GREEN)); - ui.label(RichText::new("To disable: Ctrl+Alt+Esc").weak()); + ui.label(RichText::new(format!("To disable: {}", input::RELEASE_HINT)).weak()); ui.label(RichText::new("Send F11 to IRIX: Ctrl+Alt+F11").weak()); } else { ui.label(RichText::new("Mouse/Keyboard Capture Disabled").weak()); @@ -1804,10 +1800,8 @@ impl App { /// footer readout reports the scale actually achieved. fn snap_window_to_fb(ctx: &egui::Context, fb_px: egui::Vec2, central_avail: egui::Vec2, vm_scale: f32) { if fb_px.x < 1.0 || fb_px.y < 1.0 { return; } - // egui-winit reports screen_rect / available_size / monitor_size *and* - // interprets ViewportCommand::InnerSize all in the same (zoom-scaled) - // point space, so chrome math stays in egui points. - let screen = ctx.screen_rect().size(); + // egui-winit reports viewport_rect / available_size / monitor_size and interprets ViewportCommand::InnerSize in the same (zoom-scaled) point space, so chrome math stays in egui points. + let screen = ctx.viewport_rect().size(); let chrome_w = (screen.x - central_avail.x).max(0.0); let chrome_h = (screen.y - central_avail.y).max(0.0); let zoom = ctx.zoom_factor().max(0.1); @@ -2325,7 +2319,7 @@ impl App { // it at ~4:3 by drawing into a fixed-size rect. ui.add(egui::Image::new(&*tex) .fit_to_exact_size(egui::vec2(480.0, 360.0)) - .rounding(4.0)); + .corner_radius(4.0)); } else { ui.add_space(110.0); ui.label("Starting capture…"); @@ -3041,8 +3035,8 @@ impl App { /// bottom. This replaces the old top menu bar + toolbar + bottom status bar, /// freeing vertical space for the (tall, 5:4) emulated display. fn control_panel(&mut self, ui: &mut egui::Ui, ctx: &egui::Context) { - egui::TopBottomPanel::bottom("ctl_status") - .show_inside(ui, |ui| self.status_block(ui)); + egui::Panel::bottom("ctl_status") + .show(ui, |ui| self.status_block(ui)); egui::ScrollArea::vertical().show(ui, |ui| { ui.add_space(4.0); @@ -3062,7 +3056,9 @@ impl App { } impl eframe::App for App { - fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + // egui 0.35 replaced `App::update(ctx)` with `App::ui(ui)`; panels now attach to a `Ui`. + fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { + let ctx = &ui.ctx().clone(); self.handle_events(ctx); self.maybe_autosave(); @@ -3129,10 +3125,10 @@ impl eframe::App for App { // The control column lives on the left, always visible (even in // fullscreen) — the VM screen sits to its right and never hides it. - egui::SidePanel::left("control_panel") + egui::Panel::left("control_panel") .resizable(false) - .exact_width(186.0) - .show(ctx, |ui| self.control_panel(ui, ctx)); + .exact_size(186.0) + .show(ui, |ui| self.control_panel(ui, ctx)); self.network_check_window(ctx); // Config editor placement depends on whether a machine is running: @@ -3141,26 +3137,26 @@ impl eframe::App for App { // - IDLE: it takes the WHOLE central area instead (below), hiding the // welcome/info screen — no cramped split when there's nothing to // watch. The toolbar's "Edit config…" toggle drives both. - let config_in_side_panel = self.show_config_editor && self.emu.is_running(); - egui::SidePanel::right("config_editor") + let mut config_in_side_panel = self.show_config_editor && self.emu.is_running(); + egui::Panel::right("config_editor") .resizable(true) - .default_width(420.0) - .show_animated(ctx, config_in_side_panel, |ui| self.config_editor_panel(ui)); + .default_size(420.0) + .show_collapsible(ui, &mut config_in_side_panel, |ui| self.config_editor_panel(ui)); // Zero the central panel's inner margin so the emulated display reaches // the window edges — every reclaimed pixel makes the (tall, 5:4) picture // a little bigger. Keep the dark panel fill so the aspect-ratio // letterbox bars stay black. - let central_frame = egui::Frame::central_panel(&ctx.style()) + let central_frame = egui::Frame::central_panel(ui.style()) .inner_margin(egui::Margin::ZERO); - egui::CentralPanel::default().frame(central_frame).show(ctx, |ui| { + egui::CentralPanel::default().frame(central_frame).show(ui, |ui| { if self.show_config_editor && !self.emu.is_running() { // Idle + editing: config fills the whole pane (welcome hidden). // A small margin gives it breathing room (the central frame is // edge-to-edge for the framebuffer). input::force_release(ui.ctx(), &mut self.input_state); - egui::Frame::none() - .inner_margin(egui::Margin::symmetric(10.0, 8.0)) + egui::Frame::new() + .inner_margin(egui::Margin::symmetric(10, 8)) .show(ui, |ui| self.config_editor_panel(ui)); } else if self.emu.is_running() { self.framebuffer_panel(ui); diff --git a/iris-gui/src/scsi_menu.rs b/iris-gui/src/scsi_menu.rs index f3530c4..0c2c03f 100644 --- a/iris-gui/src/scsi_menu.rs +++ b/iris-gui/src/scsi_menu.rs @@ -32,24 +32,24 @@ pub fn draw(ui: &mut Ui, cfg: &MachineConfig) -> ScsiAction { if let Some(p) = pick_disk("Attach HDD") { action = ScsiAction::AttachHdd { id, path: p }; } - ui.close_menu(); + ui.close(); } // Attaching a CD-ROM gives an empty drive by default; the // user loads media afterwards via "Insert disc…". Mirrors // real hardware and avoids an upfront file prompt. if ui.button("Attach CD-ROM drive (empty)").clicked() { action = ScsiAction::AttachEmptyCdrom { id }; - ui.close_menu(); + ui.close(); } if ui.button("Attach CD-ROM with disc…").clicked() { if let Some(p) = pick_iso("Attach CD-ROM with disc") { action = ScsiAction::AttachCdromWithDisc { id, path: p }; } - ui.close_menu(); + ui.close(); } if ui.button("Create blank HDD image…").clicked() { action = ScsiAction::CreateBlank { id }; - ui.close_menu(); + ui.close(); } } Some(d) if d.cdrom => { @@ -57,7 +57,7 @@ pub fn draw(ui: &mut Ui, cfg: &MachineConfig) -> ScsiAction { if has_media { if ui.button("Eject (tray empty)").clicked() { action = ScsiAction::Eject { id }; - ui.close_menu(); + ui.close(); } } let insert_label = if has_media { "Swap disc…" } else { "Insert disc…" }; @@ -65,18 +65,18 @@ pub fn draw(ui: &mut Ui, cfg: &MachineConfig) -> ScsiAction { if let Some(p) = pick_iso("Insert disc") { action = ScsiAction::InsertDisc { id, path: p }; } - ui.close_menu(); + ui.close(); } if has_media { if ui.button("Mount /CDROM in IRIX…").clicked() { action = ScsiAction::RemountInIrix { id }; - ui.close_menu(); + ui.close(); } } ui.separator(); if ui.button("Detach CD-ROM drive").clicked() { action = ScsiAction::Detach { id }; - ui.close_menu(); + ui.close(); } } Some(d) => { @@ -88,18 +88,18 @@ pub fn draw(ui: &mut Ui, cfg: &MachineConfig) -> ScsiAction { }; if ui.button(overlay_label).clicked() { action = ScsiAction::ToggleOverlay { id }; - ui.close_menu(); + ui.close(); } if ui.button("Replace image…").clicked() { if let Some(p) = pick_disk("Replace HDD image") { action = ScsiAction::AttachHdd { id, path: p }; } - ui.close_menu(); + ui.close(); } ui.separator(); if ui.button("Detach hard drive").clicked() { action = ScsiAction::Detach { id }; - ui.close_menu(); + ui.close(); } } } diff --git a/rules/gui/gui_mouse_integration.md b/rules/gui/gui_mouse_integration.md index 16e4669..ee24fdb 100644 --- a/rules/gui/gui_mouse_integration.md +++ b/rules/gui/gui_mouse_integration.md @@ -23,8 +23,12 @@ So iris-gui uses the standard emulator model (mirroring `src/ui.rs`): (`eframe .../glow_integration.rs` → `egui_winit::on_mouse_motion`). We read those deltas and feed them straight to the PS/2 controller — natural 1:1 feel, no scaling, no warp-to-center, no edge-piling. -- **Ctrl+Alt+Esc (or focus loss) releases** — Alt is the Option key on macOS; - a chord so plain Esc still reaches the guest. Input is gated on capture: while captured, +- **Left Ctrl+Alt — left Option+Cmd on macOS — (or focus loss) releases.** Hold + both and press nothing else; it fires on key-up so Ctrl+Alt+F11 still works. + Ctrl+Alt+Esc remains as an explicit fallback. Needs egui ≥ 0.35 for discrete + left/right modifier keys — see + [keyboard-layout-send-physical-keys-not-logical.md](keyboard-layout-send-physical-keys-not-logical.md). + Input is gated on capture: while captured, keyboard + mouse go to the guest; while not, they stay with egui (so menu clicks and config-side-panel typing don't leak into IRIX). diff --git a/rules/gui/keyboard-capture-egui-steals-tab-arrows-esc-and-ctrl-cxv.md b/rules/gui/keyboard-capture-egui-steals-tab-arrows-esc-and-ctrl-cxv.md index 8cc3172..f486ff8 100644 --- a/rules/gui/keyboard-capture-egui-steals-tab-arrows-esc-and-ctrl-cxv.md +++ b/rules/gui/keyboard-capture-egui-steals-tab-arrows-esc-and-ctrl-cxv.md @@ -77,10 +77,9 @@ forward it — otherwise it would both toggle fullscreen *and* reach the guest. fullscreen handler is gated with `!(ctrl && alt)`, and `pump` detects the chord on the press edge and sends a **bare** F11. Because the modifier diff has already pressed the chord's Ctrl+Alt in the guest, `pump` lifts whatever modifiers are -held (`state.last_mods`), taps F11, then re-presses them — so IRIX sees an -unmodified F11 — and leaves `last_mods` untouched so the next frame's diff stays -consistent. The hint lives on the capture status block (`capture_controls`) -alongside the Ctrl+Alt+Esc release hint. +held (`state.held_mods`), taps F11, then re-presses them — so IRIX sees an +unmodified F11. The hint lives on the capture status block (`capture_controls`) +alongside the release-chord hint (`input::RELEASE_HINT`). ## What is NOT fixable at this layer @@ -89,3 +88,8 @@ numpad keys reach the guest only as their main-row equivalents, and there are no egui keys for NumLock/ScrollLock/CapsLock/PrintScreen/ContextMenu — even though `ps2.rs` has scancodes for some. Distinguishing them would require reading raw winit `KeyEvent.physical_key`/`location` instead of egui events. + +Re-verified against **egui 0.35** (2026-08-03): still true. 0.35 *did* add +`Key::IntlBackslash` and discrete `ShiftLeft/Right`, `ControlLeft/Right`, +`AltLeft/Right`, `SuperLeft/Right` (all now mapped), but no numpad and none of +the lock/menu keys. `map_key` in `input.rs` covers everything egui can express. diff --git a/rules/gui/keyboard-layout-send-physical-keys-not-logical.md b/rules/gui/keyboard-layout-send-physical-keys-not-logical.md new file mode 100644 index 0000000..c1d9b41 --- /dev/null +++ b/rules/gui/keyboard-layout-send-physical-keys-not-logical.md @@ -0,0 +1,111 @@ +# Non-US keyboard layouts: send physical key positions, never logical keys + +The guest does its own layout translation — the SGI PROM reads `keybd=` (e.g. +`setenv keybd de_CH`, then `rtc save`) 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. + +## The bug (fixed, issue #72) + +`src/ui.rs` (CLI) always did this right — it uses `KeyEvent::physical_key`. + +`iris-gui/src/input.rs` did not: it read `egui::Event::Key { key, .. }`, which is +the **logical** key the host OS already produced from the host's layout, then +reverse-mapped it to a `KeyCode` assuming a US keyboard. That applies the layout +twice: what you see is `guest_layout(US_position_labelled_with(host_layout(key)))`. + +On a German host + `keybd=de`, that predicted every symptom in #72 exactly: + +| Pressed | egui logical `Key` | sent as | guest showed | +|---|---|---|---| +| `-` (at US `/`) | `Minus` | US `Minus` | `ß` | +| Shift-7 = `/` | `Slash` | US `Slash` | `_` | +| Shift-, = `;` | `Semicolon` | US `Semicolon` | `Ö` | +| Shift-0 = `=` | `Equals` | US `Equal` | `` ` `` | +| `Z` (at US `Y`) | `Z` | US `Z` | `y` | + +**Fix:** prefer `physical_key` (`Event::Key` carries it), fall back to `key`. + +### Why umlauts "worked" while ASCII punctuation didn't + +Not a contradiction — it's the giveaway. egui-winit builds the event as +`key: logical_key.or(physical_key)` (egui-winit `src/lib.rs`). `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. If a bug +report says "umlauts fine, `/` and `;` broken", this is the cause. + +## The ISO 102nd key (`< > |`) + +Separate, independent bug: `KeyCode::IntlBackslash` — the extra key left of `Z` +on every European ISO keyboard — had no entry in any scancode set, so it was dead +in the CLI too. Added: + +| Set | Code | Cross-check | +|---|---|---| +| 1 | `0x56` | standard `KEY_102ND` | +| 2 | `0x61` | standard; arrows are `E0`-prefixed in set 2, so `0x61` is free | +| 3 | `0x13` | Linux `atkbd_set3_keycode[0x13] == KEY_102ND (86)`; sits in the leftmost column between `LCtrl 0x11`, `LShift 0x12`, `CapsLock 0x14` | + +Do **not** reuse set 2's `0x61` for set 3 — in set 3 that is the Left arrow. +IRIX drives the keyboard in **set 3**, so set 3 is the one that matters. + +## Why iris-gui is on egui 0.35 (do not downgrade) + +**The 0.29 → 0.35 bump is load-bearing for keyboard correctness, not cosmetic.** +On **0.29** egui-winit threw away information no iris-side code could recover: + +- **`IntlBackslash` is unrepresentable.** `key_from_key_code` has no arm for it and + `egui::Key` has no `Less`/`Greater`, so *both* `logical_key` and `physical_key` + are `None` and **no event is emitted at all**. `<` `>` do nothing in the GUI. +- **AltGr is unreachable.** `egui::Modifiers` has only `alt`, no left/right split, + and modifier keys produce no `Key` event, so `input.rs` can only ever send + `AltLeft`. On DE/de_CH that kills the whole AltGr level (`@ \ | { } [ ] ~ €`). + de_CH `\` is AltGr+`<` — hit by both gaps at once. +- **Numpad is collapsed** into the main row (`Numpad0-9→Num0-9`, + `NumpadDivide→Slash`, `NumpadEnter→Enter`), so `KP_*` keysyms never reach X11. + +eframe 0.29 exposes no raw winit window-event hook (`window_event` is +`pub(crate)`; `raw_input_hook` only sees already-converted `egui::Event`). + +**egui 0.35.0 closed all three** — this is why we bumped. `Key::IntlBackslash` +exists, and `key_from_key_code` gained discrete `ShiftLeft/Right`, +`ControlLeft/Right`, `AltLeft/Right`, `SuperLeft/Right` arms, so `pump()` now +forwards modifiers as **real press/release key events** and the +`egui::Modifiers`-diff synthesis is gone. `InputState` tracks `held_mods: +Vec` instead of `last_mods: Modifiers`. + +That also enabled the **two-key release chord** (`RELEASE_CHORD`): 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 — i.e. no other key was pressed while the +chord was held — which is what preserves Ctrl+Alt+F11. Ctrl+Alt+Esc is kept as an +explicit fallback. Impossible on 0.29: `egui::Modifiers` has no left/right split. + +**The Mac App Store winit patch is *not* a blocker.** egui 0.35 pins +`winit = "0.30.13"` — the exact version vendored at `third_party/winit-0.30.13`, +so `[patch.crates-io]` still applies (verify: the `winit 0.30.13` block in +`Cargo.lock` has **no `source` line**). +See [../macos/appstore-private-api.md](../macos/appstore-private-api.md). + +### 0.29 → 0.35 port notes (53 errors, all mechanical) + +| Old (0.29) | New (0.35) | +|---|---| +| `ui.close_menu()` | `ui.close()` (41 of the 53) | +| `SidePanel::left/right`, `TopBottomPanel::bottom` | unified `egui::Panel::left/right/bottom` | +| `.exact_width()` / `.default_width()` | `.exact_size()` / `.default_size()` | +| `.show(ctx, …)` on panels | `.show(ui, …)` | +| `.show_animated(ctx, bool, …)` | `.show_collapsible(ui, &mut bool, …)` | +| `App::update(&mut self, ctx, frame)` | `App::ui(&mut self, ui, frame)` | +| `ctx.screen_rect()` | `ctx.viewport_rect()` | +| `ctx.style()` | `ui.style()` | +| `Frame::none()` | `Frame::new()` | +| `Margin::symmetric(f32, f32)` | `Margin::symmetric(i8, i8)` | +| `Image::rounding()` | `Image::corner_radius()` | +| `push_id(impl Hash)` | needs `AsIdSalt` = `Hash + Debug` | + +`App::ui` gives a `&mut Ui`, not a `&Context`. Keep the old body working with +`let ctx = &ui.ctx().clone();` at the top (temporary lifetime extension), so the +`&Context` call sites are unchanged and panels take `ui`. + +iris-gui depends on no third-party egui plugin crates, so nothing else gates the +bump. diff --git a/rules/irix/keyboard-issues.md b/rules/irix/keyboard-issues.md index 9ff5cf2..738ed51 100644 --- a/rules/irix/keyboard-issues.md +++ b/rules/irix/keyboard-issues.md @@ -18,3 +18,13 @@ release without a matching press. **Status:** Pre-existing emulator issue, not introduced by any recent changes. Proper fix would require filtering or suppressing stale modifier key events in the UI event handler when focus is regained. + +**Possibly addressed in iris-gui (2026-08-03, UNVERIFIED).** `pump()` no longer +synthesises modifiers from `egui::Modifiers` diffs; it forwards real +press/release key events and tracks `held_mods`, and `release_capture()` lifts +exactly what it recorded as pressed — so an orphan release without a matching +press should no longer be generated on focus loss. **Not tested against IRIX X11** +— if you can reproduce the original alt-tab corruption, check whether it still +happens before spending time here. Note this applies to iris-gui only; the CLI +(`src/ui.rs`) always forwarded real key events. +See [../gui/keyboard-layout-send-physical-keys-not-logical.md](../gui/keyboard-layout-send-physical-keys-not-logical.md). diff --git a/rules/macos/appstore-private-api.md b/rules/macos/appstore-private-api.md index eb92367..e8da18b 100644 --- a/rules/macos/appstore-private-api.md +++ b/rules/macos/appstore-private-api.md @@ -47,6 +47,123 @@ you'd then have to unify on a single winit version before patching. **When bumping eframe/winit:** re-vendor the matching winit version, re-apply the two-edit patch, and re-run the `nm -u` check before submitting. +## ⚠️ CORRECTION (2026-08-03): the symbol is STILL PRESENT — winit **0.29** also has it + +The claim above that iris's own `winit 0.29` copy "creates no window inside +iris-gui, so its `set_blur` is dead-stripped" **does not hold**. Measured on a +plain `cargo build --release -p iris-gui` (profile `lto = "fat"`): + +``` +$ nm -u target/release/iris-gui | grep -i CGS +_CGSMainConnectionID +_CGSSetWindowBackgroundBlurRadius +``` + +**This is pre-existing, not caused by the egui 0.29 → 0.35 bump** — the +`IRIS.app` bundle built 2026-07-02 (long before) has both symbols too. Patching +only the 0.30 copy is not sufficient. + +**Source: CONFIRMED — the unpatched `winit 0.29.15`.** The vendored +`third_party/winit-0.30.13/` stub is intact and doing its job; the import comes +from iris's *own* winit 0.29 dependency, which `[patch.crates-io]` never touched +(it only matches the `0.30.x` requirement). Only two crates in the whole registry +declare the symbol — `winit-0.29.15` and `winit-0.30.13` — and the binary embeds +source paths for **both**: + +``` +$ strings -a target/aarch64-apple-darwin/release/iris-gui \ + | grep -oE "winit-0\.29\.[0-9]+|third_party/winit-0\.30\.13" | sort | uniq -c + 12 third_party/winit-0.30.13 <- patched, clean + 24 winit-0.29.15 <- UNPATCHED registry copy +``` + +…including `…/winit-0.29.15/src/platform_impl/macos/window.rs`, which is exactly +where the call lives (`window.rs:597`, extern at `ffi.rs:120`). + +**Merely depending on winit 0.29 links its macOS backend — reachability is not +enough to strip it.** Commenting out `pub mod ui;` in `src/lib.rs` and rebuilding +does **not** remove the symbol (measured). winit's macOS backend registers +Objective-C classes via `declare_class!`, which emits `#[used]` statics that +survive dead-stripping regardless of whether any Rust code calls into them. The +Jun-16 commit's assumption — "creates no window in iris-gui, so its blur code is +dead-stripped" — is therefore wrong. + +⚠️ **Do not attribute this by `nm`-ing rlibs in `target/release/deps/`.** The +release profile is `lto = "fat"`, so rlibs hold LLVM bitcode, not machine code — +`nm` reports "no symbols" or errors with `Unknown attribute kind`, which reads +like a clean result and proves nothing. Use `strings -a` on the linked binary +(the profile sets `debug = 1`, so source paths are embedded), or bisect by +removing a dependency and re-linking. + +### FIXED (2026-08-03): unify the graph on ONE winit + +The fix was not a new patch — it was removing the *second* winit so the existing +patch covers everything. iris's own deps moved 0.29 → 0.30.13: + +| dep | was | now | +|---|---|---| +| `winit` (root + iris-gui) | 0.29 | 0.30 | +| `glutin` | 0.31 | 0.32 | +| `glutin-winit` | 0.4 | 0.5 | +| `raw-window-handle` | 0.5 | 0.6 | + +Cheaper than it looks, because two things did **not** break: +- **`KeyCode` is byte-identical between winit 0.29 and 0.30** — `ps2.rs`, + `push_kb` and all keyboard code needed zero changes. +- **`EventLoop::run(closure)` still exists in 0.30** (deprecated, not removed) — + `src/ui.rs` did *not* need an `ApplicationHandler` rewrite. + +Only 8 mechanical edits in `src/ui.rs` + `src/headless_gl.rs`: +`WindowBuilder` → `WindowAttributes::default()`, `.with_window_builder()` → +`.with_window_attributes()`, and rwh 0.6 made `raw_window_handle()` / +`build_surface_attributes()` return `Result`. + +Verified clean on the real pipeline build **and** the signed bundle: +``` +$ ./scripts/build-macos.sh appstore +$ nm -u IRIS.app/Contents/MacOS/iris-gui | grep CGS -> (nothing) +$ strings -a IRIS.app/Contents/MacOS/iris-gui | grep -oE "winit-0\.29\.[0-9]+|third_party/winit-0\.30\.13" | sort | uniq -c + 12 third_party/winit-0.30.13 # and NO winit-0.29 +``` +Control: `_CGShieldingWindowLevel` (public) is still present, so `nm` is working. + +## Can we drop the vendored copy and take winit from git? NOT YET — both doors shut + +The upstream fix **is** in winit **master** — `winit-appkit/src/window_delegate.rs` +gates the call behind `#[cfg(feature = "private-apple-apis")]`, off by default. +But it is unusable here, and both alternatives were measured, not assumed: + +| source | version | has fix? | usable? | +|---|---|---|---| +| crates.io `0.30.13` | 0.30.13 | ✗ | ✓ (what we vendor + stub) | +| crates.io `0.31.0-beta.1/2` | 0.31.0-beta | ✗ (no `private-apple-apis` in published features) | — | +| git `master` | **0.31.0-beta.2** | ✓ | ✗ **version mismatch** | +| git `v0.30.x` branch | 0.30.13 | ✗ **not backported** (still calls it unconditionally) | — | + +egui-winit 0.35 requires `winit = "0.30.13"` (`^0.30.13`), which `0.31.0-beta.2` +does not satisfy. `[patch.crates-io] winit = { git = "…", branch = "master" }` +fails **silently and dangerously**: + +``` +warning: patch `winit v0.31.0-beta.2 (git master)` was not used in the crate graph + Adding winit v0.30.13 <- falls back to the UNPATCHED registry copy +``` + +i.e. the git patch is ignored and the private symbol comes straight back. Do not +use it. Our vendored stub already produces exactly what master's default does +(no `CGS*` import), so there is nothing to gain until **egui/eframe bumps to a +winit 0.31 release** — at that point switch to upstream, leave +`private-apple-apis` off, delete `third_party/winit-0.30.13/` and the `[patch]`, +and re-run the `nm -u` check. + +**Reproduce the check:** `./scripts/build-macos.sh appstore` then +`nm -u target/aarch64-apple-darwin/release/iris-gui | grep CGS`. A plain +`cargo build --release -p iris-gui` reproduces it too — both were measured. +There is still **no CI gate**; add one to the appstore workflow. + +**No CI gate exists for this** — there is no `nm -u` check in `.github/workflows/` +or `scripts/`. Add one to the appstore workflow so a regression can't ship. + ## Upstream status (don't file a new bug — already tracked) - winit issue **#4205** "_CGSSetWindowBackgroundBlurRadius non-public or diff --git a/src/headless_gl.rs b/src/headless_gl.rs index 31ea4c9..1ac28b3 100644 --- a/src/headless_gl.rs +++ b/src/headless_gl.rs @@ -13,7 +13,7 @@ use glutin::surface::{GlSurface, Surface, SwapInterval, WindowSurface}; use glutin_winit::{DisplayBuilder, GlWindow}; use raw_window_handle::HasRawWindowHandle; use winit::event_loop::EventLoop; -use winit::window::WindowBuilder; +use winit::window::WindowAttributes; /// Hidden 1×1 window + GL context for offscreen GlCompositor use. pub struct HeadlessGl { @@ -28,7 +28,7 @@ impl HeadlessGl { /// Create a hidden GL context. Returns None if the platform cannot init GL. pub fn new() -> Option { let event_loop = EventLoop::new().ok()?; - let window_builder = WindowBuilder::new() + let window_builder = WindowAttributes::default() .with_title("iris-headless-gl") .with_visible(false) .with_inner_size(winit::dpi::LogicalSize::new(1u32, 1u32)); @@ -37,7 +37,7 @@ impl HeadlessGl { .with_alpha_size(8) .with_transparency(true); - let display_builder = DisplayBuilder::new().with_window_builder(Some(window_builder)); + let display_builder = DisplayBuilder::new().with_window_attributes(Some(window_builder)); let (window, gl_config) = display_builder .build(&event_loop, template, |configs| { configs.reduce(|accum, config| { @@ -47,7 +47,7 @@ impl HeadlessGl { .ok()?; let window = window?; - let raw_window_handle = window.raw_window_handle(); + let raw_window_handle = window.raw_window_handle().ok()?; let gl_display = gl_config.display(); let context_attributes = ContextAttributesBuilder::new().build(Some(raw_window_handle)); @@ -55,7 +55,7 @@ impl HeadlessGl { gl_display.create_context(&gl_config, &context_attributes).ok()? }; - let attrs = window.build_surface_attributes(Default::default()); + let attrs = window.build_surface_attributes(Default::default()).ok()?; let gl_surface = unsafe { gl_display.create_window_surface(&gl_config, &attrs).ok()? }; diff --git a/src/ps2.rs b/src/ps2.rs index 2d1d95d..ed25a4e 100644 --- a/src/ps2.rs +++ b/src/ps2.rs @@ -465,6 +465,7 @@ impl Ps2Controller { KeyCode::BracketLeft => Some(ScancodeSet2::LBracket), KeyCode::BracketRight => Some(ScancodeSet2::RBracket), KeyCode::Backslash => Some(ScancodeSet2::Backslash), + KeyCode::IntlBackslash => Some(ScancodeSet2::IntlBackslash), KeyCode::Semicolon => Some(ScancodeSet2::Semicolon), KeyCode::Quote => Some(ScancodeSet2::Quote), KeyCode::Comma => Some(ScancodeSet2::Comma), @@ -572,6 +573,7 @@ impl Ps2Controller { KeyCode::BracketLeft => Some(ScancodeSet1::LBracket), KeyCode::BracketRight => Some(ScancodeSet1::RBracket), KeyCode::Backslash => Some(ScancodeSet1::Backslash), + KeyCode::IntlBackslash => Some(ScancodeSet1::IntlBackslash), KeyCode::Semicolon => Some(ScancodeSet1::Semicolon), KeyCode::Quote => Some(ScancodeSet1::Quote), KeyCode::Comma => Some(ScancodeSet1::Comma), @@ -679,6 +681,7 @@ impl Ps2Controller { KeyCode::BracketLeft => Some(ScancodeSet3::LBracket), KeyCode::BracketRight => Some(ScancodeSet3::RBracket), KeyCode::Backslash => Some(ScancodeSet3::Backslash), + KeyCode::IntlBackslash => Some(ScancodeSet3::IntlBackslash), KeyCode::Semicolon => Some(ScancodeSet3::Semicolon), KeyCode::Quote => Some(ScancodeSet3::Quote), KeyCode::Comma => Some(ScancodeSet3::Comma), @@ -1176,6 +1179,7 @@ pub enum ScancodeSet1 { Keypad3 = 0x51, Keypad0 = 0x52, KeypadPeriod = 0x53, + IntlBackslash = 0x56, // ISO 102nd key (< > |), left of Z on European keyboards F11 = 0x57, F12 = 0x58, @@ -1267,6 +1271,7 @@ pub enum ScancodeSet2 { Enter = 0x5A, RBracket = 0x5B, Backslash = 0x5D, + IntlBackslash = 0x61, // ISO 102nd key (< > |), left of Z on European keyboards Backspace = 0x66, Keypad1 = 0x69, Keypad4 = 0x6B, @@ -1351,6 +1356,7 @@ pub enum ScancodeSet3 { Minus = 0x4E, Equals = 0x55, Backslash = 0x5C, + IntlBackslash = 0x13, // ISO 102nd key (< > |), left of Z on European keyboards Backspace = 0x66, Space = 0x29, Tab = 0x0D, diff --git a/src/ui.rs b/src/ui.rs index 5218e0a..cbf6549 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -5,7 +5,7 @@ use winit::{ event::{ElementState, Event, KeyEvent, WindowEvent, MouseButton}, event_loop::{ControlFlow, EventLoop}, keyboard::{KeyCode, PhysicalKey}, - window::{Window, WindowBuilder}, + window::{Window, WindowAttributes}, }; use glow::HasContext; use crate::ps2::Ps2Controller; @@ -96,7 +96,7 @@ impl GlRenderer { let not_current_gl_context = self.not_current_context.take() .expect("GL context missing — init_gl() called more than once"); - let attrs = self.window.build_surface_attributes(Default::default()); + let attrs = self.window.build_surface_attributes(Default::default()).expect("surface attributes"); let gl_surface = unsafe { gl_display .create_window_surface(&self.gl_config, &attrs) @@ -607,7 +607,7 @@ impl Ui { // via resize() once the PROM/IRIX programs its actual mode. let w = 1280 * scale; let h = (1024 + STATUS_BAR_HEIGHT as u32) * scale; - let window_builder = WindowBuilder::new() + let window_builder = WindowAttributes::default() .with_title(crate::machine::emulator_name()) .with_resizable(true) .with_inner_size(winit::dpi::PhysicalSize::new(w, h)); @@ -616,7 +616,7 @@ impl Ui { .with_alpha_size(8) .with_transparency(false); - let display_builder = DisplayBuilder::new().with_window_builder(Some(window_builder)); + let display_builder = DisplayBuilder::new().with_window_attributes(Some(window_builder)); let (window, gl_config) = display_builder .build(event_loop, template, |configs| { @@ -634,7 +634,7 @@ impl Ui { // that created the window/display above. The refresh thread only // makes it current later (in GlRenderer::init_gl); it never calls // create_context itself. See the not_current_context field comment. - let raw_window_handle = window.raw_window_handle(); + let raw_window_handle = window.raw_window_handle().expect("no raw window handle"); let gl_display = gl_config.display(); // Try, in order: (1) explicit GL 3.2 core — what GlCompositor and the