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..83a85884 --- /dev/null +++ b/iris-gui/src/macos_native/mod.rs @@ -0,0 +1,281 @@ +//! 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, + } + } +} + +pub use window::end_frame; + +/// 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..54293d8b --- /dev/null +++ b/iris-gui/src/macos_native/window.rs @@ -0,0 +1,272 @@ +//! `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. +//! +//! A closed window isn't dropped straight away; see [`end_frame`]. + +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()) + // 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; + 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) + } +} + +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::*; + + /// 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..e4356bde 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 @@ -3746,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/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..28f4e726 --- /dev/null +++ b/rules/gui/macos-gui-front-end.md @@ -0,0 +1,95 @@ +# 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. + +## 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 + 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.