Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
34 changes: 34 additions & 0 deletions iris-gui-README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 |
Expand Down Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions iris-gui/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down
9 changes: 9 additions & 0 deletions iris-gui/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
}
7 changes: 6 additions & 1 deletion iris-gui/src/dialogs/create_disk.rs
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
7 changes: 6 additions & 1 deletion iris-gui/src/dialogs/new_machine.rs
Original file line number Diff line number Diff line change
@@ -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;

Expand Down
269 changes: 269 additions & 0 deletions iris-gui/src/macos_native/menubar.rs
Original file line number Diff line number Diff line change
@@ -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<Vec<Action>> = Mutex::new(Vec::new());
/// Actions the user has picked but the app hasn't applied yet.
static PICKED: Mutex<Vec<Action>> = Mutex::new(Vec::new());
/// The egui context, so a menu click can wake a window that is otherwise idle.
static CTX: OnceLock<eframe::egui::Context> = 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<Option<Retained<MenuTarget>>> = 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<Action> {
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<MenuTarget> {
let _ = mtm;
let mut slot = TARGET.lock();
if slot.is_none() {
let this = MenuTarget::alloc().set_ivars(());
let obj: Retained<MenuTarget> = unsafe { msg_send![super(this), init] };
*slot = Some(obj);
}
slot.as_ref().unwrap().clone()
}

fn build_items(
menu: &NSMenu,
mtm: MainThreadMarker,
target: &Retained<MenuTarget>,
actions: &mut Vec<Action>,
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<NSMenuItem> {
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<MenuTarget>,
actions: &mut Vec<Action>,
title: &str,
action: Option<Action>,
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);
}
Loading
Loading