From 6e4de439fa0370fcd81853ec8fee2e3ed03ff252 Mon Sep 17 00:00:00 2001 From: iblowmymind <28228415+iblowmymind@users.noreply.github.com> Date: Tue, 22 Sep 2026 16:55:27 +0300 Subject: [PATCH 1/2] iris-gui: optional native macOS front-end (--features macos-gui) Rebuilds the mac-gui branch as an opt-in second layout on top of main. With the feature on, macOS gets the system menu bar, the configuration editor and every dialog in their own OS windows, and the run state in the window title. The default sidebar layout is unchanged and remains what every build gets; the feature is ignored off macOS. The backend lives in src/macos_native/. Dialogs are shared unchanged: in the native build they import an egui with Window swapped for an OS-window stand-in. main.rs has only a few cfg(native_mac) hooks. objc2-app-kit is an optional dependency, enabled only by the feature, with default features off. --- CHANGELOG.md | 5 + iris-gui-README.md | 34 ++ iris-gui/Cargo.toml | 12 + iris-gui/build.rs | 9 + iris-gui/src/dialogs/create_disk.rs | 7 +- iris-gui/src/dialogs/new_machine.rs | 7 +- iris-gui/src/macos_native/menubar.rs | 269 +++++++++ iris-gui/src/macos_native/menus.rs | 809 +++++++++++++++++++++++++++ iris-gui/src/macos_native/mod.rs | 279 +++++++++ iris-gui/src/macos_native/window.rs | 214 +++++++ iris-gui/src/main.rs | 30 +- iris-gui/src/scsi_menu.rs | 4 +- rules/gui/macos-gui-front-end.md | 76 +++ 13 files changed, 1750 insertions(+), 5 deletions(-) create mode 100644 iris-gui/src/macos_native/menubar.rs create mode 100644 iris-gui/src/macos_native/menus.rs create mode 100644 iris-gui/src/macos_native/mod.rs create mode 100644 iris-gui/src/macos_native/window.rs create mode 100644 rules/gui/macos-gui-front-end.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 8702bd12..8edc1c57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -125,6 +125,11 @@ is easiest to understand by reading the commit. ### iris-gui +- **Optional native macOS front-end** (`--features macos-gui`): menus in the + system menu bar, the configuration editor and every dialog in their own OS + windows, and the run state in the window title, so the main window holds only + the display. Off by default and ignored off macOS; the default layout is + unchanged. See `rules/gui/macos-gui-front-end.md`. - **Graphics board picker** in Configuration → General: Newport, GR2 XZ or GR2 Extreme (Indigo2 only). Picking a GR2 board resets heads, resolution and `[impact]` to values `validate()` accepts; the Newport heads control is diff --git a/iris-gui-README.md b/iris-gui-README.md index 1953b27b..e03e7d62 100644 --- a/iris-gui-README.md +++ b/iris-gui-README.md @@ -42,6 +42,7 @@ emulation will be noticeably slow. | `premiere` | `iris/lightning` + `iris/idle-pause` for maximum in-process speed | | `bundled` | Distributed build: hides the iris.toml import/export items. Set by the Release workflow | | `appstore` | Mac App Store build: implies `bundled`, hides the CI tab, enables security-scoped bookmarks and folder grants | +| `macos-gui` | Native macOS front-end: system menu bar, dialogs in OS windows, status in the window title (see [Native macOS front-end](#native-macos-front-end)). Ignored off macOS | | `r5k` | Vestigial. The CPU is a runtime setting (Machine menu / General tab) | Core features that change how the executor is built pass straight through to @@ -116,6 +117,34 @@ The control column holds, top to bottom: the drop-down menus, the capture button and hint, the configuration editor, and a status footer (run state, machine name, MIPS readout, and the **NET** light for the internal network). +### Native macOS front-end + +Building with `--features macos-gui` swaps the layout on macOS. The +default layout above is untouched, and on other platforms the feature does +nothing. + +``` +cargo run -p iris-gui --release --features macos-gui +``` + +- The File / Machine / Memory / SCSI / View / Help menus are in the **system + menu bar**, and the window holds only the emulator screen (or the welcome + panel while stopped). Save and restore state use fixed slots (snap1–4), + since a menu has nowhere to type a name. Help → About IRIS lists the build + features. +- **File → Configuration…** (⌘,) opens the configuration editor in a + **separate window**. Every dialog is also its own window, so none of them + ever covers the picture. +- The **window title** carries the status footer: machine name, run state, + MIPS, networking, on-screen scale, capture state and the latest + notification. +- Extra shortcuts: ⌘R / ⇧⌘R start / stop, ⌘K capture, ⌘F fullscreen, ⌘N new + machine, ⌘Q quit (through the same close handling as closing the window, so + pending CHD changes are still folded back). + +The implementation is in `src/macos_native/`. See +`rules/gui/macos-gui-front-end.md`. + ### Menus | Menu | Contents | @@ -334,6 +363,11 @@ iris/ ├── filedialog.rs where file dialogs open ├── macos_sandbox.rs security-scoped bookmarks (App Store) ├── single_instance.rs previous-instance reclaim + ├── macos_native/ optional native macOS front-end (`macos-gui`) + │ ├── mod.rs hooks called from main.rs, config/About windows, title + │ ├── menus.rs menu model + action dispatcher + │ ├── menubar.rs AppKit NSMenu glue + │ └── window.rs egui::Window stand-in that opens OS windows └── dialogs/ ├── new_machine.rs Startup "New machine" dialog └── create_disk.rs Blank-HDD-image creator diff --git a/iris-gui/Cargo.toml b/iris-gui/Cargo.toml index 25c61281..edd5861d 100644 --- a/iris-gui/Cargo.toml +++ b/iris-gui/Cargo.toml @@ -89,6 +89,13 @@ premiere = ["iris/lightning", "iris/idle-pause"] # `ultra64` field on iris's Config only exists with the core feature on). # Build with: cargo build -p iris-gui --features ultra64 ultra64 = ["iris/ultra64"] +# Native macOS front-end: menus in the system menu bar, dialogs and the +# configuration editor in their own OS windows, and the run state in the window +# title, so the main window holds only the emulated display. An alternative to +# the default in-window sidebar, not a replacement; off by default. Ignored on +# other platforms, which always use the sidebar. See src/macos_native/. +# Build with: cargo build -p iris-gui --features macos-gui +macos-gui = ["dep:objc2-app-kit"] [dependencies] # Group A (additive) features are always on for iris-gui so the user can enable @@ -129,6 +136,11 @@ objc2 = "0.6" objc2-foundation = { version = "0.3", features = [ "NSURL", "NSData", "NSError", "NSString", "NSArray", ] } +# AppKit menu bar and window tabbing for the `macos-gui` front-end only. +objc2-app-kit = { version = "0.3", optional = true, default-features = false, features = [ + "std", "NSApplication", "NSCell", "NSEvent", "NSMenu", "NSMenuItem", "NSResponder", + "NSWindow", +] } [[bin]] name = "iris-gui" diff --git a/iris-gui/build.rs b/iris-gui/build.rs index 1a45e5ec..5f51d8f8 100644 --- a/iris-gui/build.rs +++ b/iris-gui/build.rs @@ -23,4 +23,13 @@ fn main() { }; println!("cargo:rustc-env=APP_VERSION={}", full_version); + + // `native_mac`: the `macos-gui` front-end is compiled in. The feature is + // a no-op off macOS, so gate on the target here once instead of repeating + // `all(target_os = "macos", feature = "macos-gui")` at every use. + println!("cargo::rustc-check-cfg=cfg(native_mac)"); + let macos = std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("macos"); + if macos && std::env::var_os("CARGO_FEATURE_MACOS_GUI").is_some() { + println!("cargo:rustc-cfg=native_mac"); + } } diff --git a/iris-gui/src/dialogs/create_disk.rs b/iris-gui/src/dialogs/create_disk.rs index 97a4b3c3..b9ea348e 100644 --- a/iris-gui/src/dialogs/create_disk.rs +++ b/iris-gui/src/dialogs/create_disk.rs @@ -1,4 +1,9 @@ -use eframe::egui::{self, Color32, Grid, RichText, Slider, TextEdit}; +#[cfg(not(native_mac))] +use eframe::egui; +// The native macOS front-end swaps `egui::Window` for OS windows. +#[cfg(native_mac)] +use crate::macos_native::egui; +use egui::{Color32, Grid, RichText, Slider, TextEdit}; use std::path::PathBuf; /// Modal that creates a blank zero-filled disk image for a chosen SCSI ID. diff --git a/iris-gui/src/dialogs/new_machine.rs b/iris-gui/src/dialogs/new_machine.rs index c78de473..d7ab5fb9 100644 --- a/iris-gui/src/dialogs/new_machine.rs +++ b/iris-gui/src/dialogs/new_machine.rs @@ -1,4 +1,9 @@ -use eframe::egui::{self, Color32, ComboBox, Grid, RichText, TextEdit}; +#[cfg(not(native_mac))] +use eframe::egui; +// The native macOS front-end swaps `egui::Window` for OS windows. +#[cfg(native_mac)] +use crate::macos_native::egui; +use egui::{Color32, ComboBox, Grid, RichText, TextEdit}; use iris::config::{CpuModel, MachineConfig, MachineProfile, ScsiDeviceConfig, VALID_BANK_SIZES}; use iris::vc2_timings::NewportResolution; diff --git a/iris-gui/src/macos_native/menubar.rs b/iris-gui/src/macos_native/menubar.rs new file mode 100644 index 00000000..91f48e48 --- /dev/null +++ b/iris-gui/src/macos_native/menubar.rs @@ -0,0 +1,269 @@ +//! The system menu bar: [`super::menus`] rendered as a real `NSMenu`. +//! +//! An `NSMenuItem` can't carry a Rust closure, so each item gets a *tag*, an +//! index into [`ACTIONS`] (the action table for the menu as last built). A click +//! resolves the tag and queues the [`Action`]. The app drains the queue on its +//! next frame and applies it. By then the menu has closed, which is also what +//! lets a menu item open a file dialog. +//! +//! Everything here runs on the main thread. AppKit requires it, and the click +//! callback comes from the menu's own tracking loop, which runs inside winit's +//! event loop on that same thread. +//! +//! winit is on objc2 0.5 / objc2-app-kit 0.2 while this crate uses 0.6 / 0.3. +//! Two versions in one graph are fine: both bind the same runtime. Just never +//! hand a *typed* object from one to the other. + +use super::menus::{Action, Item, Menu}; +use objc2::rc::Retained; +use objc2::runtime::{NSObject, NSObjectProtocol}; +use objc2::{define_class, msg_send, sel, AnyThread, MainThreadMarker}; +use objc2_app_kit::{ + NSApplication, NSControlStateValueOff, NSControlStateValueOn, NSEventModifierFlags, NSMenu, + NSMenuItem, +}; +use objc2_foundation::NSString; +use parking_lot::Mutex; +use std::sync::OnceLock; + +/// Actions of the menu as last built, indexed by an item's tag. +static ACTIONS: Mutex> = Mutex::new(Vec::new()); +/// Actions the user has picked but the app hasn't applied yet. +static PICKED: Mutex> = Mutex::new(Vec::new()); +/// The egui context, so a menu click can wake a window that is otherwise idle. +static CTX: OnceLock = OnceLock::new(); + +// The object every menu item targets. Ivar-less: all the state it needs is in +// the statics above, which is simpler than threading a pointer through AppKit +// and just as correct — there is only ever one menu bar. +define_class!( + // SAFETY: NSObject has no subclassing requirements, and MenuTarget has no + // Drop implementation. + #[unsafe(super(NSObject))] + #[name = "IrisMenuTarget"] + struct MenuTarget; + + impl MenuTarget { + #[unsafe(method(irisMenuAction:))] + fn iris_menu_action(&self, sender: &NSMenuItem) { + let tag = sender.tag(); + if tag >= 0 { + if let Some(action) = ACTIONS.lock().get(tag as usize).cloned() { + PICKED.lock().push(action); + } + } + // The main window may be idle (nothing running, no animation), and + // the menu click is not an egui event, so ask for a frame in which + // the queued action can be applied. + if let Some(ctx) = CTX.get() { + ctx.request_repaint(); + } + } + } + + unsafe impl NSObjectProtocol for MenuTarget {} +); + +/// The one target instance, kept alive for the process's lifetime (menu items +/// hold an unretained pointer to their target). +static TARGET: Mutex>> = Mutex::new(None); + +/// Keep tool windows separate from the emulator, including in fullscreen. +/// AppKit otherwise groups a newly opened configuration window into the +/// fullscreen window's tab group, replacing its requested content size. +pub fn disable_automatic_window_tabbing() { + if let Some(mtm) = MainThreadMarker::new() { + objc2_app_kit::NSWindow::setAllowsAutomaticWindowTabbing(false, mtm); + } +} + +/// Remember the egui context so a menu click can request a repaint. +pub fn install(ctx: &eframe::egui::Context) { + let _ = CTX.set(ctx.clone()); +} + +/// Everything the user has picked since the last call. +pub fn take_actions() -> Vec { + std::mem::take(&mut *PICKED.lock()) +} + +/// Replace the system menu bar with `menus`. +/// +/// Rebuilds from scratch — cheap at the handful of times a second the app +/// actually calls it (only when the model changes), and much less error-prone +/// than diffing a live `NSMenu`. Safe to call at any point *between* menu +/// interactions: while a menu is open, AppKit's tracking loop blocks the event +/// loop this is called from, so a rebuild can never land mid-click. +pub fn rebuild(menus: &[Menu]) { + let Some(mtm) = MainThreadMarker::new() else { return }; + let app = NSApplication::sharedApplication(mtm); + + let mut actions = Vec::new(); + let target = target(mtm); + + let bar = NSMenu::new(mtm); + bar.setAutoenablesItems(false); + + // The application menu. Its title is ignored by AppKit (the app's name from + // the bundle is used), but the item must exist and be first. + let app_item = NSMenuItem::new(mtm); + let app_menu = NSMenu::new(mtm); + app_menu.setAutoenablesItems(false); + add_item(&app_menu, mtm, &target, &mut actions, "About IRIS", Some(Action::About), true, false, None); + app_menu.addItem(&NSMenuItem::separatorItem(mtm)); + let services = NSMenu::new(mtm); + let services_item = plain_item(mtm, "Services"); + services_item.setSubmenu(Some(&services)); + app_menu.addItem(&services_item); + app.setServicesMenu(Some(&services)); + app_menu.addItem(&NSMenuItem::separatorItem(mtm)); + // Standard responder-chain actions: target `nil` sends them up to NSApp. + system_item(&app_menu, mtm, "Hide IRIS", sel!(hide:), Some(("h", false))); + system_item(&app_menu, mtm, "Hide Others", sel!(hideOtherApplications:), None); + system_item(&app_menu, mtm, "Show All", sel!(unhideAllApplications:), None); + app_menu.addItem(&NSMenuItem::separatorItem(mtm)); + // Our own Quit rather than `terminate:`, so quitting through the menu goes + // through the app's close handling (which folds pending CHD changes back + // into their disks) instead of tearing the process down underneath it. + add_item(&app_menu, mtm, &target, &mut actions, "Quit IRIS", Some(Action::Quit), true, false, Some(("q", false))); + app_item.setSubmenu(Some(&app_menu)); + bar.addItem(&app_item); + + for menu in menus { + let item = plain_item(mtm, &menu.title); + let sub = NSMenu::new(mtm); + sub.setAutoenablesItems(false); + // NSMenu takes its *title* from the menu, not the item, for the bar. + sub.setTitle(&NSString::from_str(&menu.title)); + build_items(&sub, mtm, &target, &mut actions, &menu.items); + item.setSubmenu(Some(&sub)); + bar.addItem(&item); + } + + *ACTIONS.lock() = actions; + app.setMainMenu(Some(&bar)); +} + +fn target(mtm: MainThreadMarker) -> Retained { + let _ = mtm; + let mut slot = TARGET.lock(); + if slot.is_none() { + let this = MenuTarget::alloc().set_ivars(()); + let obj: Retained = unsafe { msg_send![super(this), init] }; + *slot = Some(obj); + } + slot.as_ref().unwrap().clone() +} + +fn build_items( + menu: &NSMenu, + mtm: MainThreadMarker, + target: &Retained, + actions: &mut Vec, + items: &[Item], +) { + for item in items { + match item { + Item::Separator => menu.addItem(&NSMenuItem::separatorItem(mtm)), + Item::Info(text) => { + // A disabled item with no action: the menu's way of saying + // something without offering to do anything. + let it = plain_item(mtm, text); + it.setEnabled(false); + menu.addItem(&it); + } + Item::Sub { label, items } => { + let it = plain_item(mtm, label); + let sub = NSMenu::new(mtm); + sub.setAutoenablesItems(false); + sub.setTitle(&NSString::from_str(label)); + build_items(&sub, mtm, target, actions, items); + it.setSubmenu(Some(&sub)); + menu.addItem(&it); + } + Item::Action { label, action, enabled, checked, accel } => { + let key = accel.map(|a| (a.key.to_string(), a.shift)); + add_item( + menu, + mtm, + target, + actions, + label, + Some(action.clone()), + *enabled, + *checked, + key.as_ref().map(|(k, shift)| (k.as_str(), *shift)), + ); + } + } + } +} + +/// A menu item with no action attached yet (a submenu holder, or a label). +fn plain_item(mtm: MainThreadMarker, title: &str) -> Retained { + let item = NSMenuItem::new(mtm); + item.setTitle(&NSString::from_str(title)); + item +} + +#[allow(clippy::too_many_arguments)] +fn add_item( + menu: &NSMenu, + mtm: MainThreadMarker, + target: &Retained, + actions: &mut Vec, + title: &str, + action: Option, + enabled: bool, + checked: bool, + key: Option<(&str, bool)>, +) { + let item = plain_item(mtm, title); + if let Some(action) = action { + item.setTag(actions.len() as isize); + actions.push(action); + // SAFETY: `target` outlives the menu (it is kept in a static), and + // `irisMenuAction:` is defined on it with exactly this signature. + unsafe { + item.setTarget(Some(target)); + item.setAction(Some(sel!(irisMenuAction:))); + } + } + item.setEnabled(enabled); + item.setState(if checked { NSControlStateValueOn } else { NSControlStateValueOff }); + if let Some((k, shift)) = key { + item.setKeyEquivalent(&NSString::from_str(k)); + let mut mask = NSEventModifierFlags::Command; + if shift { + mask |= NSEventModifierFlags::Shift; + } + item.setKeyEquivalentModifierMask(mask); + } + menu.addItem(&item); +} + +/// An item wired to a standard AppKit selector, dispatched up the responder +/// chain (`target: nil`) the way the system menus do it. +fn system_item( + menu: &NSMenu, + mtm: MainThreadMarker, + title: &str, + sel: objc2::runtime::Sel, + key: Option<(&str, bool)>, +) { + let item = plain_item(mtm, title); + // SAFETY: these are AppKit's own selectors, taking a single sender. + unsafe { + item.setTarget(None); + item.setAction(Some(sel)); + } + if let Some((k, shift)) = key { + item.setKeyEquivalent(&NSString::from_str(k)); + let mut mask = NSEventModifierFlags::Command; + if shift { + mask |= NSEventModifierFlags::Shift; + } + item.setKeyEquivalentModifierMask(mask); + } + menu.addItem(&item); +} diff --git a/iris-gui/src/macos_native/menus.rs b/iris-gui/src/macos_native/menus.rs new file mode 100644 index 00000000..454fa11c --- /dev/null +++ b/iris-gui/src/macos_native/menus.rs @@ -0,0 +1,809 @@ +//! Menu model and dispatcher for the native macOS menu bar. +//! +//! The menus hold what the classic sidebar's File, Machine, Memory, SCSI, +//! View and Help menus hold. A feature added to one should normally be added to +//! the other too. + +use crate::settings; +use crate::App; +use eframe::egui; +use iris::config::CpuModel; + +/// Everything the menus can ask the app to do. +/// +/// Deliberately data-only: a native `NSMenuItem` can't hold a closure, so a +/// click resolves to one of these and is applied later on the UI thread, after +/// the menu has closed. That also means a file picker opened by a menu item +/// (`AttachHdd` and friends) runs *after* the menu is gone, rather than +/// underneath it. +#[derive(Clone, Debug, PartialEq)] +pub enum Action { + // --- File --- + NewMachine, + SwitchMachine(String), + RenameMachine, + DeleteMachine, + ImportToml, + ExportToml, + PrepareForPremiere, + GrantDiskFolder, + RevealFolder(String), + RevokeFolder(String), + Quit, + + // --- Machine --- + Start, + Stop, + Reset, + ResetNvram, + SetCpu(CpuModel), + SaveState(String), + RestoreState(String), + Screenshot, + SerialConsole, + ToggleCapture, + + // --- Memory --- + SetRam(u32), + SetBank(usize, u32), + + // --- SCSI --- + Scsi(ScsiCmd), + CowCommit { base: String, chd: bool }, + CowDiscard { id: u8, base: String, chd: bool }, + + // --- View --- + ToggleFullscreen, + SetVmScale(f32), + SetUiScale(f32), + ShowConfig, + + // --- Help --- + NetCheck, + CameraTest, + HelpInfo, + NfsHelp, + Ultra64Help, + License, + Privacy, + About, + OpenUrl(&'static str), +} + +/// Save-state slots offered by the Machine menu. +const SAVE_SLOTS: [&str; 4] = ["snap1", "snap2", "snap3", "snap4"]; + +/// A SCSI bus operation. The `Pick*` variants open a file dialog when applied. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ScsiCmd { + PickHdd(u8), + AttachEmptyCdrom(u8), + PickCdromWithDisc(u8), + PickDisc(u8), + Eject(u8), + Remount(u8), + Detach(u8), + CreateBlank(u8), + ToggleOverlay(u8), +} + +/// A keyboard equivalent. Command on macOS, Ctrl elsewhere — the modifier is +/// implied, since a menu accelerator that isn't the platform's own command key +/// would collide with keystrokes meant for the guest. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Accel { + pub key: char, + pub shift: bool, +} + +/// One row of a menu. +#[derive(Clone, Debug, PartialEq)] +pub enum Item { + Action { + label: String, + action: Action, + enabled: bool, + checked: bool, + accel: Option, + }, + Sub { + label: String, + items: Vec, + }, + /// Non-clickable text: a section heading, or a line of explanation. + Info(String), + Separator, +} + +/// A top-level menu. +#[derive(Clone, Debug, PartialEq)] +pub struct Menu { + pub title: String, + pub items: Vec, +} + +/// A clickable item. Chain `.off()` / `.checked()` / `.accel()` to refine it. +pub fn act(label: impl Into, action: Action) -> Item { + Item::Action { + label: label.into(), + action, + enabled: true, + checked: false, + accel: None, + } +} + +impl Item { + /// Enable only when `yes`. + pub fn enabled_if(mut self, yes: bool) -> Self { + if let Item::Action { enabled, .. } = &mut self { + *enabled = yes; + } + self + } + pub fn checked_if(mut self, yes: bool) -> Self { + if let Item::Action { checked, .. } = &mut self { + *checked = yes; + } + self + } + pub fn accel(mut self, key: char) -> Self { + if let Item::Action { accel, .. } = &mut self { + *accel = Some(Accel { key, shift: false }); + } + self + } + pub fn accel_shift(mut self, key: char) -> Self { + if let Item::Action { accel, .. } = &mut self { + *accel = Some(Accel { key, shift: true }); + } + self + } +} + +/// "1×", "1.25×" — trailing zeros dropped so the common whole steps read clean. +pub fn scale_label(s: f32) -> String { + if (s - s.round()).abs() < 0.005 { + format!("{:.0}\u{00d7}", s.round()) + } else { + format!("{s}\u{00d7}") + } +} + +/// Steps of a scale slider, as menu-friendly discrete choices. +fn scale_steps(min: f32, max: f32, step: f32) -> Vec { + let mut out = Vec::new(); + let n = ((max - min) / step).round() as i32; + for i in 0..=n { + out.push(min + step * i as f32); + } + out +} + +fn same_scale(a: f32, b: f32) -> bool { + (a - b).abs() < 0.01 +} + +impl App { + /// Build the whole menu tree from current state. + /// + /// Cheap enough to call a few times a second (see the rebuild throttle in + /// `refresh_menus`), but *not* every frame: naming a SCSI disk stats its image + /// file to report the size. + pub fn build_menus(&self) -> Vec { + vec![ + self.file_menu(), + self.machine_menu(), + self.memory_menu(), + self.scsi_menu(), + self.view_menu(), + self.help_menu(), + ] + } + + fn file_menu(&self) -> Menu { + let mut items = vec![act("New machine\u{2026}", Action::NewMachine).accel('n')]; + + let mut machines: Vec = Vec::new(); + if self.prefs.machines.is_empty() { + machines.push(Item::Info("(no saved machines yet)".into())); + } + for name in self.prefs.machines.keys() { + let active = self.prefs.active_machine.as_deref() == Some(name.as_str()); + machines.push( + act(name.clone(), Action::SwitchMachine(name.clone())).checked_if(active), + ); + } + items.push(Item::Sub { label: "Switch to machine".into(), items: machines }); + + let has_active = self.prefs.active_machine.is_some(); + items.push(act("Rename current\u{2026}", Action::RenameMachine).enabled_if(has_active)); + items.push(act("Delete current machine", Action::DeleteMachine).enabled_if(has_active)); + + items.push(Item::Separator); + items.push(act("Configuration\u{2026}", Action::ShowConfig).accel(',')); + + // iris.toml import/export is a source-build affordance for users who + // also run the standalone `iris` CLI; the GUI's own gui.json machine + // store is the system of record. Hidden in pre-compiled / App Store + // builds (the `bundled` feature). See iris-gui Cargo.toml. + if !cfg!(feature = "bundled") { + items.push(act("Import iris.toml\u{2026}", Action::ImportToml)); + items.push(act("Export current to iris.toml\u{2026}", Action::ExportToml)); + items.push(act("Prepare for premiere\u{2026}", Action::PrepareForPremiere)); + } + + // App Store sandbox: grant a whole folder (recursive) so the disk-sync + // fold — which writes a temp beside the base and renames over it — + // works, and so a disk image / NFS shared subfolder under it is covered + // by one grant. Hidden elsewhere. + if cfg!(feature = "appstore") { + items.push(Item::Separator); + items.push(act("Grant a disk folder\u{2026}", Action::GrantDiskFolder)); + if self.prefs.disk_folders.is_empty() { + items.push(Item::Info("(no folders granted yet)".into())); + } + for f in &self.prefs.disk_folders { + // Live access state: a grant can lapse mid-session, and the + // only fix is to re-grant, so say which it is. + let live = crate::folder_accessible(f); + let state = if live { "granted" } else { "no access \u{2014} re-grant" }; + items.push(Item::Sub { + label: format!("{f} ({state})"), + items: vec![ + act("Reveal in file manager", Action::RevealFolder(f.clone())), + act("Revoke", Action::RevokeFolder(f.clone())), + ], + }); + } + } + + items.push(Item::Separator); + items.push(act("Quit", Action::Quit).accel('q')); + Menu { title: "File".into(), items } + } + + fn machine_menu(&self) -> Menu { + let running = self.emu.is_running(); + let mut items = vec![ + act("Start", Action::Start).enabled_if(!running).accel('r'), + act("Stop", Action::Stop).enabled_if(running).accel_shift('r'), + act("Reset", Action::Reset).enabled_if(running), + act("Reset NVRAM (fresh PRAM)", Action::ResetNvram).enabled_if(!running), + Item::Separator, + ]; + + // Chosen at Machine::new, so an edit while running is pending, not live. + let mut cpus: Vec = CpuModel::ALL + .iter() + .map(|&c| { + act(c.label(), Action::SetCpu(c)) + .checked_if(self.cfg.machine.cpu == c) + .enabled_if(!running) + }) + .collect(); + if running { + cpus.push(Item::Separator); + match self.started_cpu { + Some(started) if started != self.cfg.machine.cpu => cpus.push(Item::Info( + format!("Running: {} \u{2014} Stop to apply edits", started.label()), + )), + Some(started) => cpus.push(Item::Info(format!("Running: {}", started.label()))), + None => {} + } + } + items.push(Item::Sub { + label: format!("Processor: {}", self.cfg.machine.cpu.label()), + items: cpus, + }); + + items.push(Item::Separator); + // Named slots rather than a typed-in name: a menu has nowhere to type, + // and a fixed set of slots is what a save state is used as anyway. + let slots = |kind: fn(String) -> Action| -> Vec { + SAVE_SLOTS + .iter() + .map(|name| act(*name, kind(name.to_string())).enabled_if(running)) + .collect() + }; + items.push(Item::Sub { label: "Save state".into(), items: slots(Action::SaveState) }); + items.push(Item::Sub { label: "Restore state".into(), items: slots(Action::RestoreState) }); + items.push(Item::Separator); + items.push(act("Screenshot\u{2026}", Action::Screenshot).enabled_if(running)); + items.push(act("Serial console\u{2026}", Action::SerialConsole).enabled_if(running)); + items.push(Item::Separator); + items.push( + act( + if self.input_state.captured { + format!("Release mouse & keyboard ({})", crate::input::RELEASE_HINT) + } else { + "Capture mouse & keyboard".into() + }, + Action::ToggleCapture, + ) + .enabled_if(running) + .accel('k'), + ); + Menu { title: "Machine".into(), items } + } + + fn memory_menu(&self) -> Menu { + let running = self.emu.is_running(); + let mut items = vec![Item::Info(format!( + "Config: {}", + crate::ram_summary(&self.cfg.banks) + ))]; + if running { + if let Some(started) = self.started_banks { + if started != self.cfg.banks { + items.push(Item::Info(format!( + "Running: {} \u{2014} Stop to apply edits", + crate::ram_summary(&started) + ))); + } else { + items.push(Item::Info(format!("Running: {}", crate::ram_summary(&started)))); + } + } + items.push(Item::Info("RAM changes apply after Stop \u{2192} Start".into())); + } else { + items.push(Item::Info("Applied at next Start".into())); + } + items.push(Item::Separator); + items.push(Item::Info("Quick presets (auto-distributed)".into())); + for &p in crate::RAM_PRESETS { + items.push( + act(format!("{p} MB"), Action::SetRam(p)) + .enabled_if(!running) + .checked_if(self.cfg.banks == crate::distribute_ram(p)), + ); + } + items.push(Item::Separator); + for i in 0..4 { + let banks: Vec = iris::config::VALID_BANK_SIZES + .iter() + .map(|&sz| { + act(format!("{sz} MB"), Action::SetBank(i, sz)) + .enabled_if(!running) + .checked_if(self.cfg.banks[i] == sz) + }) + .collect(); + items.push(Item::Sub { + label: format!("Bank {i}: {} MB", self.cfg.banks[i]), + items: banks, + }); + } + Menu { title: "Memory".into(), items } + } + + fn scsi_menu(&self) -> Menu { + let mut items = Vec::new(); + for id in 1u8..=7 { + let dev = self.cfg.scsi.get(&id); + let label = crate::scsi_menu::render_label(id, dev); + let sub = match dev { + None => vec![ + act("Attach HDD\u{2026}", Action::Scsi(ScsiCmd::PickHdd(id))), + // 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. + act("Attach CD-ROM drive (empty)", Action::Scsi(ScsiCmd::AttachEmptyCdrom(id))), + act("Attach CD-ROM with disc\u{2026}", Action::Scsi(ScsiCmd::PickCdromWithDisc(id))), + act("Create blank HDD image\u{2026}", Action::Scsi(ScsiCmd::CreateBlank(id))), + ], + Some(d) if d.is_daynaport() => vec![ + Item::Info("DaynaPort SCSI/Link (Ethernet).".into()), + Item::Info("Configure its MAC and subnet on the Disks tab.".into()), + Item::Separator, + act("Detach DaynaPort", Action::Scsi(ScsiCmd::Detach(id))), + ], + Some(d) if d.is_cdrom() => { + let has_media = + !d.path.is_empty() && std::path::Path::new(&d.path).exists(); + let mut v = Vec::new(); + if has_media { + v.push(act("Eject (tray empty)", Action::Scsi(ScsiCmd::Eject(id)))); + } + v.push(act( + if has_media { "Swap disc\u{2026}" } else { "Insert disc\u{2026}" }, + Action::Scsi(ScsiCmd::PickDisc(id)), + )); + if has_media { + v.push(act("Mount /CDROM in IRIX\u{2026}", Action::Scsi(ScsiCmd::Remount(id)))); + } + v.push(Item::Separator); + v.push(act("Detach CD-ROM drive", Action::Scsi(ScsiCmd::Detach(id)))); + v + } + Some(d) => vec![ + act( + if d.overlay { + "Disable COW overlay" + } else { + "Enable COW overlay (writes \u{2192} .overlay)" + }, + Action::Scsi(ScsiCmd::ToggleOverlay(id)), + ), + act("Replace image\u{2026}", Action::Scsi(ScsiCmd::PickHdd(id))), + Item::Separator, + act("Detach hard drive", Action::Scsi(ScsiCmd::Detach(id))), + ], + }; + items.push(Item::Sub { label, items: sub }); + } + items.push(Item::Separator); + items.push(Item::Info( + "CD-ROM: prefer SCSI #4. Insert/Swap hot-loads media and remounts".into(), + )); + items.push(Item::Info( + "/CDROM (console shell must be active). New drives need Stop\u{2192}Start.".into(), + )); + + // Copy-on-write overlays with something in them: commit or roll back. + let cow = self.cow_entries(); + if !cow.is_empty() { + items.push(Item::Separator); + items.push(Item::Info("Copy-on-write changes".into())); + if self.emu.is_running() { + items.push(Item::Info("Stop the machine to commit or roll back.".into())); + } else { + for (id, base, is_chd) in cow { + let name = std::path::Path::new(&base) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or(&base) + .to_string(); + items.push(Item::Sub { + label: format!("SCSI {id}: {name}"), + items: vec![ + act( + "Commit changes to disk", + Action::CowCommit { base: base.clone(), chd: is_chd }, + ), + act( + "Discard changes (roll back)", + Action::CowDiscard { id, base: base.clone(), chd: is_chd }, + ), + ], + }); + } + } + } + Menu { title: "SCSI".into(), items } + } + + fn view_menu(&self) -> Menu { + let mut items = vec![ + act( + if self.fullscreen { "Exit fullscreen" } else { "Fullscreen" }, + Action::ToggleFullscreen, + ) + .accel('f'), + Item::Separator, + ]; + + // The emulated display is drawn at a whole number of device pixels per + // emulated pixel wherever it can be, so these are exact: 1× is the + // guest's own resolution, one emulated pixel per logical point. + let vm: Vec = scale_steps( + settings::VM_SCALE_MIN, + settings::VM_SCALE_MAX, + settings::VM_SCALE_STEP as f32, + ) + .into_iter() + .map(|s| { + act(scale_label(s), Action::SetVmScale(s)) + .checked_if(same_scale(self.prefs.vm_scale, s)) + }) + .collect(); + items.push(Item::Sub { + label: format!("Emulator scale: {}", scale_label(self.prefs.vm_scale)), + items: vm, + }); + + let ui_steps: Vec = scale_steps(settings::UI_SCALE_MIN, settings::UI_SCALE_MAX, 0.25) + .into_iter() + .map(|s| { + act(scale_label(s), Action::SetUiScale(s)) + .checked_if(same_scale(self.prefs.ui_scale, s)) + }) + .collect(); + items.push(Item::Sub { + label: format!("Menu & dialog scale: {}", scale_label(self.prefs.ui_scale)), + items: ui_steps, + }); + + Menu { title: "View".into(), items } + } + + fn help_menu(&self) -> Menu { + let running = self.emu.is_running(); + let mut items = vec![ + Item::Info("Diagnostics".into()), + act("Test camera\u{2026}", Action::CameraTest).enabled_if(running), + act("Serial console\u{2026}", Action::SerialConsole).enabled_if(running), + act("Check networking\u{2026}", Action::NetCheck).enabled_if(running), + Item::Separator, + act("How camera & networking work\u{2026}", Action::HelpInfo), + act("Mount the shared folder in IRIX\u{2026}", Action::NfsHelp), + ]; + // N64 dev board getting-started guide. Only in builds that carry the + // board (source builds with --features ultra64), and never in App Store + // builds, where it can't run anyway. + if cfg!(all(feature = "ultra64", not(feature = "appstore"))) { + items.push(act("N64 development board (Ultra64)\u{2026}", Action::Ultra64Help)); + } + items.push(Item::Separator); + items.push(act("Licenses\u{2026}", Action::License)); + items.push(act("Privacy policy\u{2026}", Action::Privacy)); + items.push(Item::Separator); + items.push(act( + "IRIS on GitHub (upstream)", + Action::OpenUrl("https://github.com/techomancer/iris"), + )); + items.push(act( + "This fork on GitHub", + Action::OpenUrl("https://github.com/danifunker/iris"), + )); + items.push(Item::Separator); + items.push(act("About IRIS", Action::About)); + Menu { title: "Help".into(), items } + } +} + +impl App { + /// Disks that currently have a copy-on-write overlay on disk: + /// `(scsi id, base image path, is a CHD)`. + pub fn cow_entries(&self) -> Vec<(u8, String, bool)> { + let mut entries = Vec::new(); + for (&id, dev) in &self.cfg.scsi { + if dev.cdrom || dev.scratch || dev.path.trim().is_empty() { + continue; + } + let is_chd = iris::chd_disk::is_chd(&dev.path); + let has_overlay = if is_chd { + iris::chd_disk::diff_path_for(std::path::Path::new(&dev.path)).exists() + } else if dev.overlay { + std::path::Path::new(&format!("{}.overlay", dev.path)).exists() + } else { + false + }; + if has_overlay { + entries.push((id, dev.path.clone(), is_chd)); + } + } + entries + } + + /// The one place a chosen menu item takes effect. + pub fn apply_menu_action(&mut self, action: Action, ctx: &egui::Context) { + use crate::handle::Cmd; + use egui::ViewportCommand; + match action { + // --- File --- + Action::NewMachine => self.new_machine.open(), + Action::SwitchMachine(name) => self.switch_to(&name), + Action::RenameMachine => { + // Opens the rename dialog seeded with the current name. + self.rename_buffer = self.prefs.active_machine.clone(); + } + Action::DeleteMachine => { + if let Some(name) = self.prefs.active_machine.clone() { + self.prefs.machines.remove(&name); + self.prefs.active_machine = self.prefs.machines.keys().next().cloned(); + if let Some(next) = self.prefs.active_machine.clone() { + self.cfg = self.prefs.machines[&next].clone(); + } else { + self.cfg = iris::config::MachineConfig::default(); + self.new_machine.open(); + } + let _ = self.prefs.save(); + self.toast(format!("deleted '{name}'")); + } + } + Action::ImportToml => { + if let Some(path) = + crate::native_open_dialog("Import iris.toml", &[("TOML", &["toml"])]) + { + let cfg = iris::config::MachineConfig::load_toml(&path.to_string_lossy()); + let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("imported"); + let name = self.prefs.unique_name(stem); + self.prefs.machines.insert(name.clone(), cfg.clone()); + self.prefs.active_machine = Some(name.clone()); + self.cfg = cfg; + self.cfg_path = Some(path); + self.flush_machine(); + self.toast(format!("imported as '{name}'")); + } + } + Action::ExportToml => { + if let Some(path) = + crate::native_save_dialog("Export iris.toml", &[("TOML", &["toml"])]) + { + self.save_config(path); + } + } + Action::PrepareForPremiere => self.prepare_for_premiere(), + Action::GrantDiskFolder => self.grant_disk_folder(), + Action::RevealFolder(f) => crate::config_ui::reveal_in_file_manager(&f), + Action::RevokeFolder(f) => { + self.prefs.disk_folders.retain(|x| x != &f); + self.prefs.bookmarks.remove(&f); + let _ = self.prefs.save(); + } + Action::Quit => { + if self.cfg_dirty { + self.flush_machine(); + } + ctx.send_viewport_cmd(ViewportCommand::Close); + } + + // --- Machine --- + Action::Start => self.start_emulator(), + Action::Stop => self.request_stop(), + Action::Reset => { + self.emu.send(Cmd::Stop); + self.start_emulator(); + } + Action::ResetNvram => match settings::reset_nvram(&self.cfg.nvram) { + Ok(()) => { + let seed = self.prefs.active_machine.as_deref().unwrap_or("indy"); + let mac = settings::generate_mac_bytes(seed); + let _ = settings::write_nvram_mac(&self.cfg.nvram, mac); + self.toast(format!("NVRAM reset \u{2014} new MAC {}", settings::mac_to_string(mac))); + } + Err(e) => self.toast(format!("NVRAM reset failed: {e}")), + }, + Action::SetCpu(c) => { + self.cfg.machine.cpu = c; + self.mark_dirty(); + self.toast(format!("{} \u{2014} applies at next Start", c.label())); + } + Action::SaveState(slot) => self.emu.send(Cmd::SaveState(slot)), + Action::RestoreState(slot) => self.emu.send(Cmd::RestoreState(slot)), + Action::Screenshot => { + if let Some(p) = crate::native_save_dialog("Save screenshot", &[("PNG", &["png"])]) { + self.emu.send(Cmd::Screenshot(p)); + } + } + Action::SerialConsole => self.open_serial_console(), + Action::ToggleCapture => { + if self.input_state.captured { + crate::input::force_release(ctx, &mut self.input_state); + } else { + crate::input::engage_capture(ctx, &mut self.input_state); + } + } + + // --- Memory --- + Action::SetRam(mb) => { + self.cfg.banks = crate::distribute_ram(mb); + self.mark_dirty(); + self.toast(format!( + "RAM set to {} ({:?})", + crate::ram_summary(&self.cfg.banks), + self.cfg.banks + )); + } + Action::SetBank(i, sz) => { + self.cfg.banks[i] = sz; + self.mark_dirty(); + } + + // --- SCSI --- + Action::Scsi(cmd) => self.apply_scsi(cmd), + Action::CowCommit { base, chd } => { + if chd { + // A CHD commit recompresses — show the progress modal. + self.syncing = Some(crate::SyncJob { disk: 0, total: 1, fraction: 0.0 }); + } + self.emu.send(Cmd::CowCommit { base, chd }); + } + Action::CowDiscard { id, base, chd } => { + // Destructive — confirm before discarding. + self.cow_discard_confirm = Some(crate::CowDiscard { id, base, chd }); + } + + // --- View --- + Action::ToggleFullscreen => { + self.toggle_fullscreen(ctx); + } + Action::SetVmScale(s) => { + self.prefs.vm_scale = s; + self.pending_fb_snap = true; + let _ = self.prefs.save(); + } + Action::SetUiScale(s) => { + self.prefs.ui_scale = s; + ctx.set_zoom_factor(s); + // Re-fit the window so bigger/smaller controls grow the window + // rather than squeezing the picture. + self.pending_fb_snap = true; + let _ = self.prefs.save(); + } + Action::ShowConfig => self.show_config_editor = true, + + // --- Help --- + Action::NetCheck => self.show_net_check = true, + Action::CameraTest => self.open_camera_test(), + Action::HelpInfo => self.show_help_info = true, + Action::NfsHelp => self.show_nfs_help = true, + Action::Ultra64Help => { + #[cfg(feature = "ultra64")] + { + self.show_ultra64_help = true; + } + } + Action::License => self.show_license = true, + Action::Privacy => self.show_privacy = true, + Action::About => self.native.show_about = true, + Action::OpenUrl(url) => open_url(url), + } + } + + fn apply_scsi(&mut self, cmd: ScsiCmd) { + use crate::handle::Cmd; + use crate::scsi_menu::{self as sm, ScsiAction}; + // The picker runs here rather than inside the menu, so it opens after + // the menu has closed instead of underneath it. + let picked = match cmd { + ScsiCmd::PickHdd(id) => { + let cur = self.cfg.scsi.get(&id).map(|d| d.path.clone()).unwrap_or_default(); + let title = if cur.is_empty() { "Attach HDD" } else { "Replace HDD image" }; + sm::pick_disk(title, &cur).map(|path| ScsiAction::AttachHdd { id, path }) + } + ScsiCmd::AttachEmptyCdrom(id) => Some(ScsiAction::AttachEmptyCdrom { id }), + ScsiCmd::PickCdromWithDisc(id) => sm::pick_iso("Attach CD-ROM with disc", "") + .map(|path| ScsiAction::AttachCdromWithDisc { id, path }), + ScsiCmd::PickDisc(id) => { + let cur = self.cfg.scsi.get(&id).map(|d| d.path.clone()).unwrap_or_default(); + sm::pick_iso("Insert disc", &cur).map(|path| ScsiAction::InsertDisc { id, path }) + } + ScsiCmd::Eject(id) => Some(ScsiAction::Eject { id }), + ScsiCmd::Detach(id) => Some(ScsiAction::Detach { id }), + ScsiCmd::ToggleOverlay(id) => Some(ScsiAction::ToggleOverlay { id }), + ScsiCmd::CreateBlank(id) => { + self.create_disk.open_for(id); + None + } + ScsiCmd::Remount(id) => { + if self.emu.is_running() { + self.emu.send(Cmd::RemountCdrom { id }); + self.toast(format!( + "SCSI #{id}: remount sent \u{2014} keep a shell focused on the console" + )); + } else { + self.toast("Mount /CDROM: start the VM first"); + } + None + } + }; + let Some(action) = picked else { return }; + + // Media changes reach a running machine live; a new *drive* needs a + // Stop → Start, since the SCSI bus is built at Machine::new. + let live = match &action { + ScsiAction::InsertDisc { id, path } | ScsiAction::AttachCdromWithDisc { id, path } => { + Some(Cmd::LoadDisc { id: *id, path: path.clone(), remount: true }) + } + ScsiAction::Eject { id } => Some(Cmd::EjectCdrom { id: *id }), + _ => None, + }; + let was_insert = matches!(action, ScsiAction::InsertDisc { .. }); + if let Some(msg) = sm::apply(&mut self.cfg, action) { + self.mark_dirty(); + self.toast(msg); + } + match (live, self.emu.is_running()) { + (Some(cmd), true) => self.emu.send(cmd), + (Some(_), false) if was_insert => { + self.toast("Disc saved \u{2014} Stop\u{2192}Start to load into SCSI drive") + } + _ => {} + } + } +} + +/// Open `url` in the user's browser. +fn open_url(url: &str) { + let _ = std::process::Command::new("open").arg(url).spawn(); +} diff --git a/iris-gui/src/macos_native/mod.rs b/iris-gui/src/macos_native/mod.rs new file mode 100644 index 00000000..2fdd4a8e --- /dev/null +++ b/iris-gui/src/macos_native/mod.rs @@ -0,0 +1,279 @@ +//! Native macOS front-end, compiled in with `--features macos-gui` +//! (`cfg(native_mac)`; see build.rs). +//! +//! The default iris-gui layout puts a control column, the configuration editor +//! and a status footer in the same window as the emulated display. This backend +//! moves each of them out of that window: +//! +//! - the menus go in the system menu bar ([`menus`], [`menubar`]); +//! - the configuration editor and every dialog get OS windows of their own +//! ([`window`]); +//! - the run state goes in the window title ([`App::window_title`]). +//! +//! That leaves the main window with nothing but the guest's display. The app +//! logic (emulator lifecycle, dialogs, framebuffer) is shared with the default +//! layout. `main.rs` calls in here from a few `cfg(native_mac)` hooks. + +mod menubar; +mod menus; +mod window; + +use crate::handle::NetState; +use crate::{input, App}; +use eframe::egui::{self as e, RichText, ViewportCommand}; +use std::time::{Duration, Instant}; + +/// `eframe::egui` with [`window::Window`] in place of `egui::Window`. The files +/// that draw dialogs import this as `egui` in the native build, so their +/// windows become OS windows with no change to the dialog code. +pub mod egui { + pub use super::window::Window; + pub use eframe::egui::*; +} + +/// Backend state kept on the [`App`]. +pub struct State { + /// The menu model as last built, and when it was built. It is rebuilt a + /// few times a second, not every frame: naming a SCSI slot stats its image + /// file, and each change hands AppKit a whole new `NSMenu`. + menus: Vec, + menus_built: Instant, + /// The last title pushed to the OS, and when. + title: String, + title_at: Instant, + /// Whether Help → About IRIS is open. + show_about: bool, +} + +impl Default for State { + fn default() -> Self { + // Far enough in the past that the first frame builds both. + let long_ago = Instant::now() - Duration::from_secs(60); + Self { + menus: Vec::new(), + menus_built: long_ago, + title: String::new(), + title_at: long_ago, + show_about: false, + } + } +} + +/// Runs in `main` before the event loop starts. +pub fn before_launch() { + menubar::disable_automatic_window_tabbing(); +} + +impl App { + /// Per-frame work, done where the default layout draws its side panels: + /// track the fullscreen state, keep the menu bar current, apply what was + /// picked from it, and update the title. + pub(crate) fn native_frame(&mut self, ctx: &e::Context) { + // The green button and Escape change fullscreen without going through + // our own actions, so take the window's reported state as the truth. + if let Some(fullscreen) = ctx.input(|i| i.viewport().fullscreen) { + if self.fullscreen != fullscreen { + self.fullscreen = fullscreen; + self.invalidate_menus(); + } + } + self.refresh_menus(ctx); + for action in menubar::take_actions() { + self.apply_menu_action(action, ctx); + } + self.sync_window_title(ctx); + } + + /// The windows only this backend has. Called after the central panel, at + /// the top level of the frame: an OS window must not be opened from inside + /// another one's body. + pub(crate) fn native_windows(&mut self, ctx: &e::Context) { + self.config_editor_window(ctx); + self.about_window(ctx); + } + + /// The main window's background. While a machine runs, the window holds + /// only the emulated display, so paint the letterbox around it black, as + /// a monitor would be, whatever the UI theme. The welcome panel shown while + /// stopped keeps the theme's background. + pub(crate) fn native_central_frame(&self, frame: e::Frame) -> e::Frame { + if self.emu.is_running() { + frame.fill(e::Color32::BLACK) + } else { + frame + } + } + + /// The welcome panel, inset from the window edge. The central panel has + /// no margin (so the framebuffer can reach the edges), and in the default + /// layout the sidebar is what keeps the panel off the window's left edge. + pub(crate) fn native_welcome_panel(&mut self, ui: &mut e::Ui) { + e::Frame::new() + .inner_margin(e::Margin::symmetric(14, 10)) + .show(ui, |ui| self.welcome_panel(ui)); + } + + fn invalidate_menus(&mut self) { + self.native.menus_built = Instant::now() - Duration::from_secs(1); + } + + fn toggle_fullscreen(&mut self, ctx: &e::Context) { + self.fullscreen = !ctx.input(|i| i.viewport().fullscreen).unwrap_or(self.fullscreen); + ctx.send_viewport_cmd_to(e::ViewportId::ROOT, ViewportCommand::Fullscreen(self.fullscreen)); + self.invalidate_menus(); + } + + /// Rebuild the menu model if it may be stale, and give it to AppKit only if + /// it changed. A fifth of a second is well under "the menu looks out of + /// date" and well over "every frame". + fn refresh_menus(&mut self, ctx: &e::Context) { + menubar::install(ctx); + if self.native.menus_built.elapsed() < Duration::from_millis(200) { + return; + } + self.native.menus_built = Instant::now(); + let menus = self.build_menus(); + if menus != self.native.menus { + self.native.menus = menus; + menubar::rebuild(&self.native.menus); + } + } + + /// The tabbed configuration editor, in its own window so it can sit beside + /// the display (or on another monitor) instead of sharing its window. + fn config_editor_window(&mut self, ctx: &e::Context) { + if !self.show_config_editor { + return; + } + let machine = self.prefs.active_machine.as_deref().unwrap_or("default").to_string(); + let mut open = true; + window::Window::new(format!("IRIS configuration \u{2014} {machine}")) + .open(&mut open) + .resizable(true) + .default_width(620.0) + .default_height(760.0) + .show(ctx, |ui| { + e::ScrollArea::vertical().show(ui, |ui| self.central_tabs(ui)); + }); + if !open { + self.show_config_editor = false; + } + } + + /// Help → About IRIS: the version, and what this build has compiled in. + /// (The default layout lists this at the bottom of its Help menu.) + fn about_window(&mut self, ctx: &e::Context) { + let mut open = self.native.show_about; + window::Window::new("About IRIS").open(&mut open).resizable(false).show(ctx, |ui| { + ui.set_max_width(380.0); + ui.heading("IRIS"); + ui.label("SGI Indy (MIPS R4400) emulator"); + ui.label(format!("Version {}", env!("APP_VERSION"))); + ui.add_space(8.0); + ui.label(RichText::new("Authors").strong()); + ui.label("Original: techomancer"); + ui.label("iris-gui fork: Dani Sarfati (danifunker)"); + ui.add_space(8.0); + ui.label(RichText::new("Build features").strong()); + use iris::build_features as bf; + let on = |b: bool| if b { "on" } else { "off" }; + ui.label(format!(" chd: {}", on(bf::CHD))); + ui.label(format!(" camera: {}", on(bf::CAMERA))); + // rex-jit is a compile-time feature, but the App Store build forces + // the interpreter at runtime via IRIS_NO_JIT. Report what is + // actually running. + let jit_off = std::env::var_os("IRIS_NO_JIT").is_some(); + let jit = if !bf::REX_JIT { "off" } else if jit_off { "off (sandbox)" } else { "on" }; + ui.label(format!(" rex-jit: {jit}")); + ui.label(format!(" lightning: {}", if bf::LIGHTNING { "on (no debug)" } else { "off" })); + ui.label(format!(" ultra64: {}", on(bf::ULTRA64))); + }); + self.native.show_about = open; + } + + /// The window title, which carries the state the default layout shows in + /// its status footer: machine, run state, speed, networking, on-screen + /// scale, the capture hint and the latest notification. + fn window_title(&self) -> String { + use iris::config::NetMode; + let mut parts: Vec = Vec::new(); + let name = self.prefs.active_machine.as_deref().unwrap_or("(unsaved)"); + parts.push(format!("{name}{}", if self.cfg_dirty { " *" } else { "" })); + + let running = self.emu.is_running(); + let halted = running && self.emu.status.cpu_halted; + parts.push( + if running && self.emu.status.cpu_stopped { + "powered off" + } else if halted { + "halted \u{2014} safe to stop" + } else if self.emu.status.in_prom { + "PROM" + } else if running { + "IRIX running" + } else { + "stopped" + } + .to_string(), + ); + if running && !halted { + // Instructions per wall-clock second on the host: real emulation + // speed, not the PROM's inventory "MHz". + parts.push(format!("{:.0} MIPS", self.emu.status.mips)); + } + if running { + // The backend is latched at Start; "idle" means the running guest + // has produced no IP traffic yet. + let backend = match self.launched_net.as_ref() { + Some((NetMode::Pcap, iface)) => { + let iface = iface.as_deref().filter(|s| !s.is_empty()).unwrap_or("auto"); + format!("PCAP {iface}") + } + _ => "NAT".to_string(), + }; + let live = match self.emu.net_state() { + NetState::Active => "", + NetState::Idle => " idle", + NetState::Off => " off", + }; + parts.push(format!("net {backend}{live}")); + if self.fb_scale > 0.0 { + // "filtered": not a whole device-pixel multiple, so smoothed. + let filtered = if self.fb_nearest { "" } else { " filtered" }; + parts.push(format!("{}{filtered}", menus::scale_label(self.fb_scale))); + } + } + if self.input_state.captured { + parts.push(format!("captured \u{2014} {} to release", input::RELEASE_HINT)); + } + if let Some((msg, when)) = &self.toast { + if when.elapsed().as_secs() < 5 { + parts.push(msg.clone()); + } + } + format!("IRIS \u{2014} {}", parts.join(" \u{00b7} ")) + } + + /// Push the title at about 4 Hz, and only when it changed. Most of its + /// fields move constantly (MIPS especially), and retitling every frame is + /// wasted work and visibly jittery. + fn sync_window_title(&mut self, ctx: &e::Context) { + // The default layout expires the toast in its status footer. + if self.toast.as_ref().is_some_and(|(_, when)| when.elapsed().as_secs() >= 5) { + self.toast = None; + } + if self.native.title_at.elapsed() < Duration::from_millis(250) { + return; + } + self.native.title_at = Instant::now(); + let title = self.window_title(); + if title != self.native.title { + self.native.title = title.clone(); + ctx.send_viewport_cmd_to(e::ViewportId::ROOT, ViewportCommand::Title(title)); + } + // A stopped machine asks for no frames, so wake up to expire the toast. + if self.toast.is_some() { + ctx.request_repaint_after(Duration::from_millis(250)); + } + } +} diff --git a/iris-gui/src/macos_native/window.rs b/iris-gui/src/macos_native/window.rs new file mode 100644 index 00000000..1f6f277e --- /dev/null +++ b/iris-gui/src/macos_native/window.rs @@ -0,0 +1,214 @@ +//! `egui::Window` stand-in that opens a real OS window. +//! +//! The dialogs in `main.rs` and `dialogs/` are written against `egui::Window`. +//! In the native build those files import `crate::macos_native::egui` instead of +//! `eframe::egui`, and that module swaps in this [`Window`]. So every dialog +//! becomes a separate macOS window without its code changing, and the classic +//! build keeps drawing them as in-window egui windows. +//! +//! Only the builder methods those call sites use are provided. If a new dialog +//! needs another method, add it here as well, or the native build won't compile. +//! +//! Each window is an immediate viewport. It is drawn inside the parent's pass, +//! so the body can borrow app state the way an `egui::Window` body does. Don't +//! nest them: showing one from inside another's body is not supported. + +use eframe::egui::{ + self, Context, InnerResponse, Pos2, Ui, Vec2, ViewportBuilder, ViewportClass, ViewportId, + WidgetText, +}; + +/// Size used for a non-resizable window before its content has been measured. +const UNMEASURED: Vec2 = Vec2::new(420.0, 180.0); +/// Size used for a resizable window that doesn't give a default. +const RESIZABLE_DEFAULT: Vec2 = Vec2::new(560.0, 440.0); +const MARGIN: i8 = 12; + +pub struct Window<'open> { + title: String, + open: Option<&'open mut bool>, + resizable: bool, + default_size: Vec2, +} + +/// Remembered between passes, keyed by the window's viewport id. +#[derive(Clone, Copy, Default)] +struct Placement { + /// Measured content size of a non-resizable window. + content: Option, + /// Centre point picked when the window opened, from the main window's position. + centre: Option, + /// The parent pass this window was last shown in. + last_pass: Option, +} + +impl<'open> Window<'open> { + pub fn new(title: impl Into) -> Self { + Self { + title: title.into().text().to_owned(), + open: None, + // egui::Window's default. + resizable: true, + default_size: Vec2::splat(f32::NAN), + } + } + + /// Cleared when the user closes the window. A window without it has no + /// close button and is dismissed only from its own buttons, just as the + /// in-window version has no ×. + pub fn open(mut self, open: &'open mut bool) -> Self { + self.open = Some(open); + self + } + + /// A non-resizable window sizes itself to its content. + pub fn resizable(mut self, resizable: bool) -> Self { + self.resizable = resizable; + self + } + + pub fn default_width(mut self, width: f32) -> Self { + self.default_size.x = width; + self + } + + pub fn default_height(mut self, height: f32) -> Self { + self.default_size.y = height; + self + } + + /// OS windows can't collapse, so this is ignored. + pub fn collapsible(self, _collapsible: bool) -> Self { + self + } + + /// Ignored: every window opens centred over the main window. + pub fn anchor(self, _align: egui::Align2, _offset: impl Into) -> Self { + self + } + + pub fn show( + self, + ctx: &Context, + add_contents: impl FnOnce(&mut Ui) -> R, + ) -> Option>> { + let Self { title, open, resizable, default_size } = self; + if open.as_deref() == Some(&false) { + return None; + } + + let viewport_id = ViewportId::from_hash_of(("iris-native-window", &title)); + let state_id = egui::Id::new(viewport_id); + let pass = ctx.cumulative_pass_nr(); + let mut place: Placement = ctx.data(|d| d.get_temp(state_id)).unwrap_or_default(); + + // Not shown in the previous pass means the window was closed and is + // opening again. Centre it over the main window's current position. + let reopened = place.last_pass.is_none_or(|p| p + 1 < pass); + if reopened { + place.centre = ctx.input(|i| i.viewport().outer_rect).map(|r| r.center()); + } + + let size = if resizable { + let or = |v: f32, d: f32| if v.is_nan() { d } else { v }; + Vec2::new(or(default_size.x, RESIZABLE_DEFAULT.x), or(default_size.y, RESIZABLE_DEFAULT.y)) + } else { + place.content.unwrap_or(UNMEASURED) + }; + + // Resizable windows keep a fixed size and position in the builder, so + // the user's resize or move sticks. For the others, egui sees the new + // size when the content is measured and resizes the window, keeping it + // centred as the in-window CENTER_CENTER anchor did. + let mut builder = ViewportBuilder::default() + .with_title(title.as_str()) + .with_inner_size(size) + .with_resizable(resizable) + .with_maximize_button(resizable) + .with_minimize_button(false) + .with_close_button(open.is_some()); + if let Some(centre) = place.centre { + builder = builder.with_position(centre - size / 2.0); + } + + let mut body = Some(add_contents); + let mut close_requested = false; + let mut measured = None; + let shown = ctx.show_viewport_immediate(viewport_id, builder, |ui, class| { + let mut run = |ui: &mut Ui| body.take().map(|body| body(ui)); + if class == ViewportClass::EmbeddedWindow { + // The backend can't open another window, so egui has already + // wrapped this viewport in an egui::Window. + let inner = run(ui); + return InnerResponse::new(inner, ui.response()); + } + close_requested |= ui.input(|i| i.viewport().close_requested()); + let frame = egui::Frame::central_panel(ui.style()).inner_margin(MARGIN); + if resizable { + egui::CentralPanel::default().frame(frame).show(ui, run) + } else { + // The panel only paints the window background. The content goes + // in an unconstrained area, so it takes its natural size (which + // is then measured) rather than the window's current size. + egui::CentralPanel::default().show(ui, |_| {}); + let area = egui::Area::new(state_id.with("content")) + .fixed_pos(Pos2::ZERO) + .constrain(false) + .show(ui.ctx(), |ui| frame.show(ui, run).inner); + measured = Some(area.response.rect.size()); + area + } + }); + + if let Some(m) = measured.filter(|m| m.x >= 1.0 && m.y >= 1.0) { + place.content = Some(m.ceil()); + } + place.last_pass = Some(pass); + ctx.data_mut(|d| d.insert_temp(state_id, place)); + + if close_requested { + if let Some(open) = open { + *open = false; + } + } + Some(shown) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Without a multi-viewport backend (as in a headless test) the window + /// falls back to an embedded egui window, and the body still runs. + #[test] + fn falls_back_to_embedded_without_viewports() { + let ctx = Context::default(); + let mut drawn = false; + let mut open = true; + let mut output = ctx.run_ui(egui::RawInput::default(), |ui| { + Window::new("Test").open(&mut open).resizable(false).show(ui.ctx(), |ui| { + drawn = true; + ui.label("content"); + }); + }); + // Headless: no renderer consumes the font texture upload. + output.textures_delta.clear(); + assert!(drawn); + assert!(open); + } + + #[test] + fn closed_window_is_not_drawn() { + let ctx = Context::default(); + let mut open = false; + let mut drawn = false; + let mut output = ctx.run_ui(egui::RawInput::default(), |ui| { + let shown = Window::new("Test").open(&mut open).show(ui.ctx(), |_| drawn = true); + assert!(shown.is_none()); + }); + // Headless: no renderer consumes the font texture upload. + output.textures_delta.clear(); + assert!(!drawn); + } +} diff --git a/iris-gui/src/main.rs b/iris-gui/src/main.rs index 32bde775..eb60d2a8 100644 --- a/iris-gui/src/main.rs +++ b/iris-gui/src/main.rs @@ -9,6 +9,8 @@ mod dialogs; mod framebuffer; mod handle; mod input; +#[cfg(native_mac)] +mod macos_native; mod macos_sandbox; mod netfix; mod netplan; @@ -23,7 +25,11 @@ use config_ui::{cfg_to_toml, show_tab, ConfigAction, MemoryUiContext, Tab}; use dialogs::create_disk::CreateDiskDialog; use dialogs::new_machine::{distribute_ram, NewMachineDialog}; use ram::{ram_summary, RAM_PRESETS}; +#[cfg(not(native_mac))] use eframe::egui; +// The native macOS front-end swaps `egui::Window` for OS windows. +#[cfg(native_mac)] +use macos_native::egui; use egui::{Color32, RichText, ViewportCommand}; use handle::{Cmd, EmulatorHandle, Evt, NetState}; use iris::config::MachineConfig; @@ -133,6 +139,8 @@ fn main() -> eframe::Result<()> { // copy that would otherwise keep the monitor/serial ports bound) and claim // the single-instance lock for ourselves. single_instance::acquire(); + #[cfg(native_mac)] + macos_native::before_launch(); let prefs = GuiSettings::load(); // Re-acquire macOS sandbox access to previously user-selected files (disk // images, PROM, ISOs, …) before any machine can open them. No-op elsewhere. @@ -335,6 +343,9 @@ struct App { /// (reset to None on Stop). Used by the running status footer to show "net: /// PCAP → eth0" or "net: NAT" so the user can verify which backend is active. launched_net: Option<(iris::config::NetMode, Option)>, + /// State of the native macOS front-end (menu bar, window title). + #[cfg(native_mac)] + native: macos_native::State, } /// Progress of the exit-time "Synchronizing disks…" step. @@ -451,6 +462,7 @@ struct ChdGrantModal { disks: Vec, } +#[cfg_attr(native_mac, allow(dead_code))] impl App { fn new(mut prefs: GuiSettings) -> Self { // Resolution order on startup: @@ -556,6 +568,8 @@ impl App { cow_discard_confirm: None, pcap_ifaces: None, launched_net: None, + #[cfg(native_mac)] + native: Default::default(), } } @@ -3274,6 +3288,7 @@ 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. + #[cfg(not(native_mac))] egui::Panel::left("control_panel") .resizable(false) .exact_size(186.0) @@ -3286,11 +3301,16 @@ 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. + #[cfg(not(native_mac))] let mut config_in_side_panel = self.show_config_editor && self.emu.is_running(); + #[cfg(not(native_mac))] egui::Panel::right("config_editor") .resizable(true) .default_size(420.0) .show_collapsible(ui, &mut config_in_side_panel, |ui| self.config_editor_panel(ui)); + // Native macOS: menu bar and window title in place of the side panels. + #[cfg(native_mac)] + self.native_frame(ctx); // Zero the central panel's inner margin so the emulated display reaches // the window edges — every reclaimed pixel makes the (tall, 5:4) picture @@ -3298,8 +3318,11 @@ impl eframe::App for App { // letterbox bars stay black. let central_frame = egui::Frame::central_panel(ui.style()) .inner_margin(egui::Margin::ZERO); + #[cfg(native_mac)] + let central_frame = self.native_central_frame(central_frame); egui::CentralPanel::default().frame(central_frame).show(ui, |ui| { - if self.show_config_editor && !self.emu.is_running() { + // (The native macOS front-end has the editor in a window of its own.) + if !cfg!(native_mac) && 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). @@ -3331,9 +3354,14 @@ impl eframe::App for App { // Emulator not running: make sure a leftover mouse capture is // released so the host cursor isn't stuck hidden/locked. input::force_release(ui.ctx(), &mut self.input_state); + #[cfg(not(native_mac))] self.welcome_panel(ui); + #[cfg(native_mac)] + self.native_welcome_panel(ui); } }); + #[cfg(native_mac)] + self.native_windows(ctx); // Rename-machine modal: a text box + OK/Cancel. The buffer is App state // (`rename_buffer`), so typed input persists across frames — a text box diff --git a/iris-gui/src/scsi_menu.rs b/iris-gui/src/scsi_menu.rs index 132d80ec..96d37f86 100644 --- a/iris-gui/src/scsi_menu.rs +++ b/iris-gui/src/scsi_menu.rs @@ -122,7 +122,7 @@ pub fn draw(ui: &mut Ui, cfg: &MachineConfig) -> ScsiAction { action } -fn render_label(id: u8, dev: Option<&ScsiDeviceConfig>) -> String { +pub(crate) fn render_label(id: u8, dev: Option<&ScsiDeviceConfig>) -> String { match dev { None => format!("SCSI #{id}: (empty)"), Some(d) if d.is_daynaport() => format!("SCSI #{id}: DaynaPort (Ethernet)"), @@ -162,7 +162,7 @@ fn dialog_at(title: &str, cur: &str) -> rfd::FileDialog { crate::filedialog::Purpose::Open) } -fn pick_disk(title: &str, cur: &str) -> Option { +pub(crate) fn pick_disk(title: &str, cur: &str) -> Option { dialog_at(title, cur) .add_filter("Disk images", &["raw", "img", "chd"]) .add_filter("All", &["*"]) diff --git a/rules/gui/macos-gui-front-end.md b/rules/gui/macos-gui-front-end.md new file mode 100644 index 00000000..be334266 --- /dev/null +++ b/rules/gui/macos-gui-front-end.md @@ -0,0 +1,76 @@ +# Native macOS front-end (`--features macos-gui`) + +An optional second layout for iris-gui on macOS: system menu bar, a separate OS +window for every dialog and for the configuration editor, and the status in the +window title. It lives in `iris-gui/src/macos_native/`. The default sidebar +layout is unchanged and is still what every build gets unless the feature is +on. + +## Keep it opt-in + +- `build.rs` turns the feature into `cfg(native_mac)`, and only on macOS. + Enabling it elsewhere does nothing, so `--all-features` still builds on Linux + and Windows. Gate code on `native_mac`, not on the feature name. +- `main.rs` knows about the backend only through a few `cfg(native_mac)` hooks: + the `egui` import, a `native` field on `App`, `before_launch()` in `main`, + `native_frame()` in place of the side panels, and `native_windows()` after the + central panel. The sidebar methods are `allow(dead_code)` in the native build. + Keep new backend code in `macos_native/` and don't turn the sidebar code into + a shared abstraction. + +## Dialogs become OS windows through a swapped `egui::Window` + +In the native build, `main.rs` and `dialogs/*.rs` import +`crate::macos_native::egui`. That is `eframe::egui::*` with `Window` replaced by +`macos_native::window::Window`, which opens an immediate viewport. So the dialog +code is shared unchanged. + +- The stand-in implements only the builder methods the call sites use (`new`, + `open`, `resizable`, `default_width`/`height`, `collapsible`, `anchor`, + `show`). If a dialog adds another `egui::Window` method, the native build + fails to compile until the method is added there too. Check both builds. +- A new file that draws an `egui::Window` needs the same cfg'd import, or its + window stays inside the main window in the native build. +- **Don't nest them.** Showing one of these windows from inside another one's + body is not supported. The config editor (itself an OS window) therefore only + *sets flags* (`net_sanity_modal`, `confirm_embedded_prom`), and `App::ui` + draws those modals at the top level. +- Non-resizable windows size themselves: the content goes in an unconstrained + `egui::Area`, its size is measured, and the builder's `inner_size` follows it, + so egui resizes the OS window. A window with `.open(..)` gets a close button + that clears the flag. One without it has no close button, like the in-window + modal it replaces. +- A window is centred over the main window each time it opens. If it wasn't + shown in the previous pass, it counts as reopened. + +## Menu bar (menubar.rs) + +- An `NSMenuItem` can't hold a closure. Items carry a *tag* that indexes the + action table. A click queues the `Action`, which is applied on the next frame, + after the menu has closed. That is why a menu item can safely open a file + dialog. +- The model is rebuilt at most every 200 ms and handed to AppKit only when it + changed. Naming a SCSI slot stats its image file. +- Call `setAutoenablesItems(false)` on every `NSMenu`, or AppKit ignores + `setEnabled(false)`. +- We replace winit's whole menu bar, including its `terminate:` Quit. Our Quit + goes through `ViewportCommand::Close`, so the exit-time CHD fold still runs + (see `cmd-q-bypasses-close-intercept-fold-on-poweroff.md`). +- winit uses objc2 0.5 / objc2-app-kit 0.2, and this crate uses 0.6 / 0.3. That + is fine as long as no typed object crosses between them. +- `NSWindow.allowsAutomaticWindowTabbing = false` is set before launch. + Otherwise AppKit tabs the config window into a fullscreen main window. + +## Status in the title + +`window_title()` is pushed at about 4 Hz, and only when the text changed. The +native build expires the toast there, since the status footer that normally +does it isn't drawn. + +## Verifying + +`cargo test -p iris-gui --features macos-gui` covers the window stand-in's +headless fallback. When driving the dev binary with computer-use, it is a bare +`iris-gui` process, not the installed `IRIS.app`, so screenshots filter it out. +Use `osascript` (System Events → process "iris-gui") to click menu items and +`screencapture` to look. From 9dbd52bf10e44051fe5e1d49180efbb7d5d15b88 Mon Sep 17 00:00:00 2001 From: iblowmymind <28228415+iblowmymind@users.noreply.github.com> Date: Tue, 22 Sep 2026 17:28:41 +0300 Subject: [PATCH 2/2] iris-gui: fix crash after closing a window while fullscreen on another Space With the main window fullscreen on its own Space, closing the Configuration window from the desktop Space and then returning to IRIS aborted in glutin ("context to have a current view"). All windows share one GL context. While the main window is occluded, eframe still runs its UI for a visible child window but skips painting the main window, so the context stays attached to the child's view. Dropping the child frees that view; the main window's next paint then hits a nil view in glutin's is_view_current. macos_native::end_frame now keeps a closed window alive, hidden, until the main window has been painted since, and only then drops it. --- iris-gui/src/macos_native/mod.rs | 2 + iris-gui/src/macos_native/window.rs | 60 ++++++++++++++++++++++++++++- iris-gui/src/main.rs | 4 ++ rules/gui/macos-gui-front-end.md | 19 +++++++++ 4 files changed, 84 insertions(+), 1 deletion(-) diff --git a/iris-gui/src/macos_native/mod.rs b/iris-gui/src/macos_native/mod.rs index 2fdd4a8e..83a85884 100644 --- a/iris-gui/src/macos_native/mod.rs +++ b/iris-gui/src/macos_native/mod.rs @@ -59,6 +59,8 @@ impl Default for State { } } +pub use window::end_frame; + /// Runs in `main` before the event loop starts. pub fn before_launch() { menubar::disable_automatic_window_tabbing(); diff --git a/iris-gui/src/macos_native/window.rs b/iris-gui/src/macos_native/window.rs index 1f6f277e..54293d8b 100644 --- a/iris-gui/src/macos_native/window.rs +++ b/iris-gui/src/macos_native/window.rs @@ -12,6 +12,8 @@ //! Each window is an immediate viewport. It is drawn inside the parent's pass, //! so the body can borrow app state the way an `egui::Window` body does. Don't //! nest them: showing one from inside another's body is not supported. +//! +//! A closed window isn't dropped straight away; see [`end_frame`]. use eframe::egui::{ self, Context, InnerResponse, Pos2, Ui, Vec2, ViewportBuilder, ViewportClass, ViewportId, @@ -126,11 +128,19 @@ impl<'open> Window<'open> { .with_resizable(resizable) .with_maximize_button(resizable) .with_minimize_button(false) - .with_close_button(open.is_some()); + .with_close_button(open.is_some()) + // Explicit, so reopening a window that `end_frame` hid shows it. + .with_visible(true); if let Some(centre) = place.centre { builder = builder.with_position(centre - size / 2.0); } + ctx.data_mut(|d| { + d.get_temp_mut_or_default::(egui::Id::new(REGISTRY)) + .shown + .push((viewport_id, builder.clone())) + }); + let mut body = Some(add_contents); let mut close_requested = false; let mut measured = None; @@ -175,6 +185,54 @@ impl<'open> Window<'open> { } } +const REGISTRY: &str = "iris-native-window-registry"; + +/// Which windows exist, for [`end_frame`]. +#[derive(Clone, Default)] +struct Registry { + /// Shown by [`Window::show`] during this pass. + shown: Vec<(ViewportId, ViewportBuilder)>, + /// Every window that existed at the end of the last pass: the shown ones + /// plus the hidden ones still waiting to be dropped. + alive: Vec<(ViewportId, ViewportBuilder)>, + /// Whether the main window was painted in the last pass. + root_painted: bool, +} + +/// Call once at the end of every main-window pass, after all windows have +/// been shown. +/// +/// Guards against a crash in eframe's glow backend. All windows share one GL +/// context. While the main window is occluded (fullscreen on another Space, +/// or minimized), eframe still runs its UI for a visible child window but +/// skips painting the main window itself. So the child is the last thing drawn +/// and the context is left attached to the child's view. If the child is +/// dropped then, its view is freed, the context's view becomes nil, and the +/// main window's next paint panics in glutin's `is_view_current`. +/// +/// So a window that stops being shown is kept alive, hidden, until the main +/// window has been painted since. Only then is it dropped. +pub fn end_frame(ctx: &Context) { + let root_visible = ctx.input(|i| i.viewport().visible()).unwrap_or(true); + let mut reg: Registry = + ctx.data_mut(|d| std::mem::take(d.get_temp_mut_or_default(egui::Id::new(REGISTRY)))); + + let mut alive = std::mem::take(&mut reg.shown); + for (id, builder) in reg.alive { + if alive.iter().any(|(shown, _)| *shown == id) || reg.root_painted { + // Still open, or safe to drop: the main window's paint in the last + // pass moved the context back to the main window's view. + continue; + } + let builder = builder.with_visible(false); + ctx.show_viewport_immediate(id, builder.clone(), |_, _| {}); + alive.push((id, builder)); + } + reg.alive = alive; + reg.root_painted = root_visible; + ctx.data_mut(|d| d.insert_temp(egui::Id::new(REGISTRY), reg)); +} + #[cfg(test)] mod tests { use super::*; diff --git a/iris-gui/src/main.rs b/iris-gui/src/main.rs index eb60d2a8..e4356bde 100644 --- a/iris-gui/src/main.rs +++ b/iris-gui/src/main.rs @@ -3774,6 +3774,10 @@ impl eframe::App for App { ctx.request_repaint(); } } + + // Native macOS: closed OS windows are dropped only once it's safe. + #[cfg(native_mac)] + macos_native::end_frame(ctx); } fn on_exit(&mut self, _gl: Option<&eframe::glow::Context>) { diff --git a/rules/gui/macos-gui-front-end.md b/rules/gui/macos-gui-front-end.md index be334266..28f4e726 100644 --- a/rules/gui/macos-gui-front-end.md +++ b/rules/gui/macos-gui-front-end.md @@ -43,6 +43,25 @@ code is shared unchanged. - A window is centred over the main window each time it opens. If it wasn't shown in the previous pass, it counts as reopened. +## A closed window must outlive the main window's next paint + +All windows share one GL context, which eframe moves to whichever window it +paints. While the main window is occluded (fullscreen on another Space, or +minimized), eframe still runs the main window's UI for a visible child window +(`is_viewport_or_descendant_visible` in `glow_integration.rs`) but skips +*painting* the main window. That leaves the context attached to the child's view. +If the child is dropped then, the view is freed and the context's view becomes +nil. On the main window's next paint, glutin's CGL `is_view_current` does +`view().expect("context to have a current view")` and aborts the app. + +Repro: open Configuration, fullscreen the main window, go back to the desktop +Space, close Configuration, then click IRIS in the Dock. + +`window::end_frame` (called at the very end of `App::ui`) handles this. A window +that stops being shown is kept alive, hidden, until the main window has been +painted in a previous pass, and only then is it dropped. This is an eframe 0.36 +/ glutin 0.32 bug; the guard can go once upstream handles a nil view. + ## Menu bar (menubar.rs) - An `NSMenuItem` can't hold a closure. Items carry a *tag* that indexes the