Skip to content

Unify fullscreen around AppKit native transition#38

Merged
desertkun merged 11 commits into
speccytools:masterfrom
morozov:fullscreen
May 15, 2026
Merged

Unify fullscreen around AppKit native transition#38
desertkun merged 11 commits into
speccytools:masterfrom
morozov:fullscreen

Conversation

@morozov
Copy link
Copy Markdown
Member

@morozov morozov commented May 15, 2026

Problems solved

  1. Cmd+F entered fullscreen via the custom borderless-window path, but every keypress inside it produced a UI beep and was otherwise ignored.
  2. The green traffic-light button entered a different fullscreen mode than Cmd+F: it used AppKit's native transition and never set settings_current.full_screen, so the renderer skipped letterboxing and the emulated display was stretched instead of pillarboxed.
  3. The menu actions (Open, Save As, etc.) were silently disabled in fullscreen, requiring the user to exit fullscreen to use them.
  4. Error alerts from the emulator core (tape errors, disk read failures, network failures) were suppressed in fullscreen, potentially leaving the user with a broken emulator and no indication why.
  5. Esc was intercepted as "exit fullscreen" rather than forwarded to the Spectrum keyboard — burning Caps Shift+1 (EDIT) and blocking mouse-grab release.
  6. Cmd+Q invoked performClose: on the key window instead of NSApp terminate:, so the exit-confirm and unsaved-media check could be bypassed from fullscreen.

Known issues

NSOpenPanel / NSSavePanel (e.g. Cmd+O to open a file) are slow to appear in fullscreen (~3–5 s first open vs ~0.5 s windowed). The bottleneck is inside AppKit's powerbox/Space negotiation. This path was previously unreachable from fullscreen, so it is not a regression; needs a separate fix.

morozov added 11 commits May 14, 2026 13:45
Native fullscreen via the green traffic-light button bypassed the
get_offset() pillarbox math because the gate keyed on a flag that
only the custom Cmd+F path ever set. The 320x240 Spectrum image
was stretched to fit whatever shape the display happened to be.

Drop the gate. get_offset() runs every frame against [self bounds]
and the texture's native dimensions. Windowed mode is a no-op:
contentAspectRatio keeps the view at 4:3 and the function returns
zero margins, so the GL quad is still drawn corner-to-corner.
Set NSWindowCollectionBehaviorFullScreenPrimary on the main window
in awakeFromNib so the green traffic-light button acts as a real
fullscreen toggle, with the standard outward-arrows hover icon and
the slide-into-its-own-Space animation. Without this opt-in the
green button is a zoom widget.

OR into the existing collection behavior rather than overwriting so
any flags AppKit set during NIB instantiation are preserved.
The Fullscreen menu item targeted DisplayOpenGLView's custom
borderless-overlay action. Retarget it to toggleFullScreen: on the
main NSWindow so both gestures invoke the same AppKit transition.
Direct-target the menu item (XIB id 877) rather than relying on
First Responder, so Cmd+F continues to toggle the emulator window
even when a pinned utility window holds key.

Rename the item to "Enter Full Screen". AppKit's NSWindow auto-
toggles the title to "Exit Full Screen" while the window is
fullscreen via validateMenuItem:.

Drop the now-orphaned -fullscreen: wrapper on FuseController. No
XIB targets it and no code calls it.
The Esc-intercept in keyDown: forced the custom borderless overlay
toggle when fullscreen was active. With AppKit native fullscreen
landing, exit is handled by Cmd+F, Cmd+Ctrl+F, and the green
button on hover — Esc on the emulator's keyboard maps to Caps
Shift + 1 (EDIT) and should reach the Spectrum like any other key.

Forwarding to proxy_emulator unconditionally also restores Esc as
the mouse-grab release trigger in fullscreen (input.c uses it).
DisplayOpenGLView's custom -fullscreen: IBAction allocated a
borderless NSWindow, reparented the GL view into it via
setContentView:, and tracked the swap with fullscreenWindow /
windowedWindow ivars. AppKit's native fullscreen now handles all
of this. Drop the IBAction, the ivars, the proxy_emulator
forwarder, and the Emulator-side stub that flipped
settings_current.full_screen.

settings_current.full_screen is now read-only — the remaining
readers are removed in follow-up commits.
Thirteen IBActions wrapped their bodies in
if( !settings_current.full_screen ) and silently no-op'd when the
custom borderless overlay was active. The overlay covered the OS
chrome, so an Open or Preferences panel underneath it would have
been invisible — the guard was defensive code for that constraint.

With AppKit native fullscreen the menu bar auto-hides on hover-
reveal and panels render over the fullscreen Space the way macOS
handles them for any fullscreen app. The guards no longer protect
anything; they only prevent the user from doing what Apple's HIG
explicitly directs apps to allow:

  > Don't make people exit full-screen mode to open files, import
  > images, save files, or perform similar interactions.

Remove the wrappers from open:, save_as:, quit:, help:,
showRollbackPane:, showTapeBrowserPane:, showKeyboardPane: (and
its accidental doubled inner check), showLoadBinaryPane:,
showSaveBinaryPane:, showPokeFinderPane:, showPokeMemoryPane:,
showMemoryBrowserPane:, and showPreferencesPane:.

showMemoryBrowserPane: had a pre-existing structural quirk — the
nil-check + alloc was inside the guard but the showWindow: call
was outside, so in custom fullscreen it silently messaged a nil
controller. The simplification puts both inside the same block,
matching every other pane action.
quit: was calling performClose: on the key window and relying on
applicationShouldTerminateAfterLastWindowClosed: YES to chain that
into a real quit. AppKit treats an explicitly closed window as a
user-discard signal and skips restoration for it, so the app would
relaunch windowed even when the user quit while fullscreen and the
system NSQuitAlwaysKeepsWindows setting allowed restoration.

terminate: is the canonical macOS Cmd+Q. It captures restorable
state for visible windows before the app exits.

terminate: also sends close (not performClose:) to each window
during shutdown, which would otherwise bypass windowShouldClose: —
where the "Exit Fuse?" confirm and the unsaved-media check live.
Move that logic into a new applicationShouldTerminate: on
FuseController so it runs on every quit path, and shrink
windowShouldClose: to a shim that calls [NSApp terminate:self] and
returns NO. Cmd+Q and the red traffic-light close button now
re-enter the same handler, so the prompt lives in one place and
never double-fires.
ui_error_specific swallowed every error when settings_current.full_screen
was set, returning 0 without ever calling aqua_verror. That made
runtime errors during fullscreen play invisible: RZX desync, disk
read failures on a mounted image, tape malformed-block during
playback of a tape mounted before fullscreen, Spectranet socket
failures, sound device hot-unplug — all silently dropped.

macOS native fullscreen overlays alerts on the fullscreen Space the
way it handles any other fullscreen app, so the original concern
(panel hidden under the borderless overlay) no longer applies.
Distraction-free is the OS's job via the menu-bar auto-hide; a
malfunction is not the happy path.
cocoadisplay_resize_window read settings_current.full_screen twice
to skip the resize when the window is fullscreen — once on the
caller's thread (worker or main) and again after dispatching to
main. The authoritative fullscreen state lives in NSWindow's
styleMask, which is main-thread-only.

Drop the pre-dispatch check and let the main-thread block be the
sole gate. The cost is one extra cheap dispatch when the resize
is going to be skipped anyway; the win is one fewer read of a
mirrored flag whose only purpose was thread-safe access from
worker code.
Adding a child window without NSWindowCollectionBehaviorFullScreenAuxiliary
to a fullscreen parent forces AppKit to demote the parent out of
fullscreen so the two can coexist on the regular desktop. Result:
opening Preferences from fullscreen kicked the emulator out of
fullscreen rather than overlaying Preferences on the fullscreen
Space.

OR the auxiliary flag into the child's collection behavior in
pinAsChildOf:. The bit is harmless when the parent is windowed and
gives every utility window the right Space-sharing semantics for
free, since they all reach the fullscreen state through this single
choke point.
A user who entered fullscreen with the cursor grabbed used to regain
it on the way out. The behavior went with the custom-fullscreen
IBAction; restore it under the AppKit transition by adding a
windowDidExitFullScreen: hook on DisplayOpenGLView (the window's
existing delegate).

The entry side is intentionally not symmetric. Auto-grabbing on
fullscreen entry would hide the cursor and block the menu-bar-on-
hover reveal that exposes Open, Preferences, and the rest of the
menu in fullscreen.
@desertkun desertkun merged commit 6aba54e into speccytools:master May 15, 2026
1 of 2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants