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