summaryrefslogtreecommitdiff
path: root/src/macos/Sources/AppDelegate.swift
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-08-11 15:58:58 -0300
committerGabriel Schneider <[email protected]>2026-08-11 16:26:20 -0300
commit4ca28745d774c232cd31a29c17878f19bbe24cf5 (patch)
treeface852acae5bc347e6bab2bb5cede501e0ce1d3 /src/macos/Sources/AppDelegate.swift
parentdedfdea43f0d6c7151c541284c81027969d89032 (diff)
parent89d93d5e7348304bc7d8a148f9ad9c1beb200459 (diff)
downloadpardes-4ca28745d774c232cd31a29c17878f19bbe24cf5.tar.gz
pardes-4ca28745d774c232cd31a29c17878f19bbe24cf5.zip
merge the macOS app branch: the AppKit shell, pixel attachments, live theming, and mupdf -Djpx
Three commits off 38e9919 (macos-app@upstream) merged into main's ghostty bump. No textual conflicts, and two things the merge needed: - nested.zig asked libc for fstatat. Darwin has it; on linux std.c declares it `void` (glibc hides it behind a versioned symbol std cannot name), so the tty build stopped at 'type void not a function'. statNoFollow keeps fstatat on darwin and asks statx on linux for the same three fields, which is what this file did before the branch generalized it to both platforms. - .DS_Store rode along with a797a1a. Deleted, and .gitignore now says so. linux: snap 86/86, unit-test, image-harness and mupdf-check green. nested.zig also type-checks for aarch64-macos.
Diffstat (limited to 'src/macos/Sources/AppDelegate.swift')
-rw-r--r--src/macos/Sources/AppDelegate.swift484
1 files changed, 461 insertions, 23 deletions
diff --git a/src/macos/Sources/AppDelegate.swift b/src/macos/Sources/AppDelegate.swift
index 4df5899c..cda35ff0 100644
--- a/src/macos/Sources/AppDelegate.swift
+++ b/src/macos/Sources/AppDelegate.swift
@@ -1,31 +1,57 @@
import AppKit
// The macOS host: one window, one view, one core. libpardes owns the state
-// machine, the ptys and every worker thread; this file owns the window and the
-// pump that lets the core move at all.
+// machine, the ptys and every worker thread; this file owns the window, the
+// menu bar, and the pump that lets the core move at all.
//
// PardesView translates events and calls pardes_key / pardes_mouse /
// pardes_scroll itself, but never pardes_tick — the core only queues what it
// was told and does nothing until it is pumped. Rather than grow the view's
-// delegate a third method for "I just fed the core", the view posts this
-// notification after every input call and we answer it with pump(). The string
-// below is the whole contract with PardesView.swift; keep the two in step.
-private let didInputNotification = Notification.Name("pardesDidInput")
+// delegate a third method for "I just fed the core", the view posts
+// pardesDidInputNotification after every input call and we answer it with
+// pump(). That name is declared once, in PardesView.swift, precisely so the two
+// files cannot drift apart on a string literal.
final class AppDelegate: NSObject, NSApplicationDelegate, PardesViewDelegate {
private var window: NSWindow!
private var view: PardesView!
+ /// The blur behind the grid, shown only while the theme declares no
+ /// background of its own. See applyTheme.
+ private var backdrop: NSVisualEffectView!
// One pump chain at a time. pump() re-arms itself while a theme transition
// is in flight and every input pumps as well, so without this a burst of
// keys during a fade would leave one 60 Hz chain per keystroke, all of them
// ticking until the fade ended.
private var pumpScheduled: Bool = false
+ // The core is a singleton with no "is it alive" query, and Finder can hand
+ // us documents before applicationDidFinishLaunching runs. Every entry point
+ // that would call into libpardes from outside the launch sequence checks
+ // this first; opens that arrive early queue below instead of crashing in a
+ // core that has not been constructed.
+ private var coreIsUp: Bool = false
+ private var pendingOpens: [URL] = []
func applicationDidFinishLaunching(_ notification: Notification) {
+ // Captured before the chdir below, because `pardes ./foo.zig` from a
+ // terminal means foo.zig in the shell's directory, not in $HOME.
+ let launchDirectory = FileManager.default.currentDirectoryPath
+
+ // A bundle launched from Finder, the Dock or `open(1)` inherits cwd `/`
+ // — launchd's, not any shell's — so the first pane's shell would start
+ // at the root of the disk and every relative path the user types would
+ // resolve there. A binary run from a terminal inherits that terminal's
+ // directory, which is already what was meant, so only the `/` case is
+ // corrected: anything else is somebody's deliberate choice.
+ if launchDirectory == "/" {
+ FileManager.default.changeCurrentDirectoryPath(NSHomeDirectory())
+ }
+
+ installMainMenu()
+
// ponytail: 14pt, fixed. The SDL shell steps its font on Ctrl+/Ctrl-
// (gui.zig); doing that here means re-measuring the view's metrics and
// pushing a resize behind it, so it waits until the font has to move.
- view = PardesView(fontSize: 14)
+ view = PardesView(fontSize: defaultFontSize)
let want = NSSize(width: 1000, height: 700)
window = NSWindow(
@@ -38,18 +64,67 @@ final class AppDelegate: NSObject, NSApplicationDelegate, PardesViewDelegate {
// isReleasedWhenClosed on would have AppKit release it out from under
// that reference the moment the close button is pressed.
window.isReleasedWhenClosed = false
- window.contentView = view
+ // The grid and the blur are SIBLINGS in a plain container, not parent
+ // and child. A transparent theme has to show something through the
+ // grid, and AppKit will not blur what is behind a window unless an
+ // NSVisualEffectView asks it to — but the backdrop is hidden for every
+ // theme that brings its own background, and hiding a superview hides
+ // its subviews with it. Nested, an opaque theme drew a blank window.
+ let container = NSView(frame: NSRect(origin: .zero, size: want))
+ container.autoresizesSubviews = true
+ backdrop = NSVisualEffectView(frame: container.bounds)
+ // .underWindowBackground is the material meant for exactly this — the
+ // full-window wash behind content, rather than the sidebar/HUD
+ // materials that carry their own tint. .behindWindow is what samples
+ // the desktop instead of the window's own layers.
+ backdrop.material = .underWindowBackground
+ backdrop.blendingMode = .behindWindow
+ // .active, not .followsWindowActiveState: the grid stays readable when
+ // the window is not key, and a backdrop that flattens to grey on focus
+ // loss makes an unfocused pardes look switched off.
+ backdrop.state = .active
+ backdrop.autoresizingMask = [.width, .height]
+ view.frame = container.bounds
+ view.autoresizingMask = [.width, .height]
+ // Order matters: the blur is BEHIND the grid.
+ container.addSubview(backdrop)
+ container.addSubview(view, positioned: .above, relativeTo: backdrop)
+ window.contentView = container
// Trim the content box down to whole cells: a partial column or row is
// dead space the core can never draw into.
- //
- // ponytail: snapped once, at launch — a live drag lands wherever the
- // mouse lets go. window.contentResizeIncrements would snap every
- // resize, at the price of arguing with macOS full-screen tiling.
window.setContentSize(NSSize(
width: (want.width / view.cellWidth).rounded(.down) * view.cellWidth,
height: (want.height / view.cellHeight).rounded(.down) * view.cellHeight))
- window.center()
+ // Autosave after the snap, never before: on a first run there is no
+ // saved frame and the window must still open at the snapped default,
+ // and registering the name first would have AppKit write the unsnapped
+ // 1000x700 out as the remembered geometry.
+ window.setFrameAutosaveName("pardes")
+ // setFrameAutosaveName only arms the saving half; the restore is this
+ // call, and it reports whether there was anything to restore. Both must
+ // happen before pardes_init, because the grid we boot the core with has
+ // to be the grid the window actually ends up at.
+ if !window.setFrameUsingName("pardes") {
+ window.center()
+ }
+ // Resize in whole cells. Increments are measured from the window's
+ // current size rather than from zero, which is why the snap above still
+ // matters: without it every drag would land a half-column short of the
+ // frame, and the view would draw a strip it can never put a glyph in.
+ window.contentResizeIncrements = NSSize(width: view.cellWidth, height: view.cellHeight)
+ // One core per process, so a second tab would be an empty window with
+ // no grid behind it. macOS offers tabs on any titled resizable window
+ // unless told otherwise.
+ window.tabbingMode = .disallowed
+ // What the core is wearing, not a constant agreed by hand — two things
+ // draw and both have to say the same thing: the view fills its own
+ // bounds, and AppKit fills the titlebar and every pixel of a live
+ // resize the view has not caught up with yet. Without that agreement
+ // the gap flashes on every drag. It also owns window.appearance: a dark
+ // window with a light-mode titlebar reads as a bug, and so does the
+ // reverse the moment someone wears `acme`.
+ applyTheme()
// On screen before pardes_init, so that the backingScaleFactor read
// when seeding the cell metrics below is the one of the screen the
// window actually landed on. Drawing before the core exists costs an
@@ -60,14 +135,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, PardesViewDelegate {
// own, but "may" is not something to bet every keystroke on, and there
// is no click-to-focus path here that would recover it.
window.makeFirstResponder(view)
- // ponytail: no main menu, so no Cmd+Q — the close button and the core's
- // Exit builtin are the two ways out. An NSMenu is ten lines, but the
- // moment one exists it also has to decide which Cmd keys the core is
- // allowed to see, and that is a real decision, not boilerplate.
- //
- // UNVERIFIED: activate(ignoringOtherApps:) is deprecated on the 14 SDK
- // in favour of activate(), which does not exist at our 13.0 deployment
- // target. Expect a deprecation warning, not an error.
+ // activate(ignoringOtherApps:) is deprecated in favour of activate() on
+ // the 14 SDK, but activate() does not exist at our 13.0 deployment
+ // target and the deprecation does not fire below it. This is the call
+ // to change the day macos_min_version reaches 14.
NSApp.activate(ignoringOtherApps: true)
var runtime = pardes_runtime_s(
@@ -104,13 +175,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, PardesViewDelegate {
// would call pardes_deinit on a core that never came up.
exit(1)
}
+ coreIsUp = true
// Only now, after init. setContentSize above resized the view, and a
// pardesViewDidResize landing before pardes_init would have pushed a
// resize into a core that did not exist yet.
view.delegate = self
NotificationCenter.default.addObserver(
- self, selector: #selector(inputArrived(_:)), name: didInputNotification, object: nil)
+ self, selector: #selector(inputArrived(_:)), name: pardesDidInputNotification, object: nil)
// pardes_init takes no cell metrics, so the core's PDF placement would
// have none until the user first dragged the window. This seeds them,
@@ -118,6 +190,316 @@ final class AppDelegate: NSObject, NSApplicationDelegate, PardesViewDelegate {
// core compares the effective pixel viewport, not the resize event, so
// duplicate SIGWINCH-shaped notifications are already a no-op.
pardesViewDidResize(view)
+
+ // Positional paths, after the first frame's worth of state exists.
+ // Anything starting with `-` is skipped rather than opened: the core
+ // has no flags yet, and Finder itself passes `-psn_0_...` on some
+ // launch paths, which would otherwise become a Look for a file that
+ // does not exist.
+ for argument in CommandLine.arguments.dropFirst() where !argument.hasPrefix("-") {
+ look(absolutePath(of: argument, relativeTo: launchDirectory))
+ }
+
+ // Documents that Finder handed us before the core existed.
+ let queued = pendingOpens
+ pendingOpens = []
+ for url in queued {
+ look(url.path)
+ }
+ }
+
+ // Finder double-click, Dock drop, `open -a pardes file`, and "Open With".
+ // The URL-array form rather than application(_:openFile:), which has been
+ // deprecated since 10.13 and only ever reports one path at a time.
+ func application(_ application: NSApplication, open urls: [URL]) {
+ // A non-file URL would arrive here only through a URL scheme we do not
+ // register, but Look wants a path and would treat the scheme prefix as
+ // part of one.
+ let files = urls.filter { $0.isFileURL }
+ guard coreIsUp else {
+ // Finder sends these between applicationWillFinishLaunching and
+ // applicationDidFinishLaunching, so they arrive before the core is
+ // constructed and are replayed at the end of launch. They land
+ // after the argv paths, which costs nothing: each path is an
+ // independent Look and only the last one takes focus.
+ pendingOpens.append(contentsOf: files)
+ return
+ }
+ for url in files {
+ look(url.path)
+ }
+ }
+
+ // ---------------------------------------------------------------- menu
+
+ // Built in code rather than loaded from a nib. There is no Xcode project
+ // here (see docs/macos.md), so a MainMenu.xib would be a binary blob nobody
+ // in this tree can edit; the menu is thirty lines of Swift instead.
+ //
+ // Every item that reaches the core goes through run(), which is
+ // pardes_command — the core's own `command` event, the same channel a
+ // pardes nested inside another one speaks over. So a menu item is exactly
+ // the text you could have typed into a tag and executed, and there is no
+ // second vocabulary to keep in step with builtins.zig.
+ //
+ // The cost of having a menu at all is that these chords are now the menu's
+ // and the core can never see them: Cmd+Q, Cmd+H, Opt+Cmd+H, Cmd+O, Cmd+N,
+ // Cmd+S, Cmd+W, Cmd+V, Cmd+M and Cmd+?. AppKit offers a key equivalent to
+ // the main menu before the event ever reaches the key window. Every other
+ // Cmd chord still dies in PardesView, which swallows them because the ABI's
+ // modifier mask has ctrl, alt and shift and no super bit — a Cmd chord
+ // handed to the core would arrive as its unmodified letter and type itself
+ // into the buffer.
+ private func installMainMenu() {
+ let appName = ProcessInfo.processInfo.processName
+ let main = NSMenu()
+
+ // AppKit treats the first top-level item as the application menu
+ // whatever it is titled, and substitutes the bundle name in bold.
+ let app = submenu(appName, of: main)
+ app.addItem(withTitle: "About \(appName)",
+ action: #selector(NSApplication.orderFrontStandardAboutPanel(_:)),
+ keyEquivalent: "")
+ app.addItem(.separator())
+ app.addItem(withTitle: "Hide \(appName)",
+ action: #selector(NSApplication.hide(_:)), keyEquivalent: "h")
+ let hideOthers = app.addItem(withTitle: "Hide Others",
+ action: #selector(NSApplication.hideOtherApplications(_:)),
+ keyEquivalent: "h")
+ hideOthers.keyEquivalentModifierMask = [.command, .option]
+ app.addItem(withTitle: "Show All",
+ action: #selector(NSApplication.unhideAllApplications(_:)), keyEquivalent: "")
+ app.addItem(.separator())
+ app.addItem(withTitle: "Quit \(appName)",
+ action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q")
+
+ let file = submenu("File", of: main)
+ file.addItem(command("Open\u{2026}", #selector(menuOpen(_:)), "o"))
+ file.addItem(command("New", #selector(menuNew(_:)), "n"))
+ file.addItem(command("Save", #selector(menuSave(_:)), "s"))
+ file.addItem(.separator())
+ // performClose: goes to the key window through the responder chain, so
+ // it keeps working if this app ever grows a panel.
+ file.addItem(withTitle: "Close Window",
+ action: #selector(NSWindow.performClose(_:)), keyEquivalent: "w")
+
+ let edit = submenu("Edit", of: main)
+ // Paste and nothing else. Copy, Cut, Undo and Select All have no
+ // builtin behind them — the core yanks into the clipboard on its own
+ // selection gestures and has no undo command to call — and a greyed or
+ // silently dead menu item teaches the user the menu lies. When the core
+ // grows those commands they belong here, spelled as run("...") like
+ // everything else.
+ edit.addItem(command("Paste", #selector(menuPaste(_:)), "v"))
+
+ // The only menu whose items are not core commands: the font size is a
+ // property of this shell and nothing else. `Font` picks the FACE and
+ // lives in the topbar with the other builtins; the size is where the
+ // window is, so it is where macOS keeps it.
+ let viewMenu = submenu("View", of: main)
+ // Cmd+= rather than Cmd++: the key is unshifted `=` on every layout
+ // that has a `+` above it, and AppKit matches the character, not the
+ // engraving. The item is titled with the plus users look for.
+ viewMenu.addItem(command("Zoom In", #selector(menuZoomIn(_:)), "="))
+ viewMenu.addItem(command("Zoom Out", #selector(menuZoomOut(_:)), "-"))
+ viewMenu.addItem(command("Actual Size", #selector(menuZoomReset(_:)), "0"))
+
+ let windowMenu = submenu("Window", of: main)
+ windowMenu.addItem(withTitle: "Minimize",
+ action: #selector(NSWindow.performMiniaturize(_:)), keyEquivalent: "m")
+ windowMenu.addItem(withTitle: "Zoom", action: #selector(NSWindow.performZoom(_:)), keyEquivalent: "")
+
+ let help = submenu("Help", of: main)
+ // Cmd+? is the system-wide help chord; AppKit draws it as the shift of
+ // Cmd+/ without being told about the shift.
+ help.addItem(command("\(appName) Help", #selector(menuHelp(_:)), "?"))
+ help.addItem(command("Tutorial", #selector(menuTutor(_:)), ""))
+
+ NSApp.mainMenu = main
+ // Handing AppKit these two makes it keep the window list up to date and
+ // put the Help search field at the top of the Help menu. Both must come
+ // after mainMenu is assigned, or AppKit has nothing to attach them to.
+ NSApp.windowsMenu = windowMenu
+ NSApp.helpMenu = help
+ }
+
+ private func submenu(_ title: String, of parent: NSMenu) -> NSMenu {
+ let holder = NSMenuItem(title: title, action: nil, keyEquivalent: "")
+ // The submenu's own title is what AppKit shows in the menu bar for the
+ // Window and Help menus, which it looks up by title rather than by the
+ // item that holds them.
+ let menu = NSMenu(title: title)
+ holder.submenu = menu
+ parent.addItem(holder)
+ return menu
+ }
+
+ // An item that runs one of our own actions. Explicitly targeted at self
+ // rather than left to the responder chain: PardesView is the first
+ // responder and answers none of these, and an untargeted item that finds no
+ // handler renders greyed out.
+ private func command(_ title: String, _ action: Selector, _ key: String) -> NSMenuItem {
+ let item = NSMenuItem(title: title, action: action, keyEquivalent: key)
+ item.target = self
+ return item
+ }
+
+ @objc private func menuOpen(_ sender: Any?) {
+ let panel = NSOpenPanel()
+ panel.canChooseFiles = true
+ // Directories are first-class Look targets — the core opens one as a
+ // directory pane, which is how you navigate in acme — so refusing them
+ // here would hide half of what Look does.
+ panel.canChooseDirectories = true
+ panel.allowsMultipleSelection = true
+ panel.resolvesAliases = true
+ guard panel.runModal() == .OK else { return }
+ for url in panel.urls where url.isFileURL {
+ look(url.path)
+ }
+ }
+
+ @objc private func menuNew(_ sender: Any?) { run("New") }
+ @objc private func menuSave(_ sender: Any?) { run("Save") }
+ @objc private func menuHelp(_ sender: Any?) { run("Help") }
+ @objc private func menuTutor(_ sender: Any?) { run("Tutor") }
+
+ // Deliberately the view's paste path and not a second one: Cmd+V from the
+ // menu and Cmd+V in the view must put the same bytes in through
+ // pardes_paste, or the two would diverge the first time either grows a
+ // filter.
+ @objc private func menuPaste(_ sender: Any?) {
+ guard coreIsUp else { return }
+ pardesViewRequestsPaste(view)
+ }
+
+ // Zoom does not go through the core at all: it changes the cell, the view
+ // reports the new grid, and the core reflows to it exactly as it does for
+ // a window drag. Guarded on coreIsUp only because the resize callback it
+ // triggers calls into libpardes.
+ @objc private func menuZoomIn(_ sender: Any?) {
+ guard coreIsUp else { return }
+ view.zoom(by: 1)
+ }
+
+ @objc private func menuZoomOut(_ sender: Any?) {
+ guard coreIsUp else { return }
+ view.zoom(by: -1)
+ }
+
+ @objc private func menuZoomReset(_ sender: Any?) {
+ guard coreIsUp else { return }
+ view.zoomReset()
+ }
+
+ // ---------------------------------------------------------------- core
+
+ // One builtin command line, run as if it had been typed into a tag and
+ // executed. `command` is bridged to a temporary NUL-terminated UTF-8 buffer
+ // that lives exactly as long as the call, which is exactly as long as the
+ // core borrows it.
+ private func run(_ command: String) {
+ guard coreIsUp else { return }
+ pardes_command(command, command.utf8.count)
+ pump()
+ }
+
+ // Look's operand is the whole tail of the line (executeBuiltinLine in
+ // src/pardes.zig splits on the first space and trims the rest), so a path
+ // with spaces in it needs no quoting and must not get any — quotes would
+ // become part of the filename.
+ private func look(_ path: String) { run("Look \(path)") }
+
+ // argv paths are whatever the shell handed us. The core resolves a relative
+ // Look against the pane's directory, not the process's, so `pardes
+ // ./foo.zig` would open the wrong foo.zig — or nothing — unless it is made
+ // absolute here against the directory the process was launched from.
+ private func absolutePath(of argument: String, relativeTo directory: String) -> String {
+ let expanded = (argument as NSString).expandingTildeInPath
+ guard !(expanded as NSString).isAbsolutePath else {
+ return (expanded as NSString).standardizingPath
+ }
+ return ((directory as NSString).appendingPathComponent(expanded) as NSString).standardizingPath
+ }
+
+ /// Dress the WINDOW in what the core is wearing: the background AppKit
+ /// paints where the view does not (the titlebar, and the strip a live
+ /// resize outruns), and the blur behind a theme that brings no background
+ /// of its own.
+ ///
+ /// Cheap enough to call every pump: the view compares before it dirties
+ /// itself, and the window properties are written only when the answer
+ /// moved. `dressed` is what makes the FIRST call unconditional — a boot
+ /// theme whose background happened to equal the view's starting guess
+ /// would otherwise leave the window in AppKit's default clothes forever.
+ private var dressed = false
+
+ private func applyTheme() {
+ let changed = view.adoptThemeBG(pardes_theme_bg())
+ guard changed || !dressed else { return }
+ dressed = true
+ if let rgb = view.themeBG {
+ backdrop.isHidden = true
+ window.isOpaque = true
+ window.backgroundColor = NSColor(
+ srgbRed: CGFloat((rgb >> 16) & 0xFF) / 255,
+ green: CGFloat((rgb >> 8) & 0xFF) / 255,
+ blue: CGFloat(rgb & 0xFF) / 255,
+ alpha: 1)
+ // ...and follow the theme into light mode, so the titlebar, the
+ // traffic lights and the resize cursor stop belonging to a
+ // different application than the grid under them. Luminance off
+ // the same sRGB channels the grid is drawn with.
+ let luma = (0.2126 * CGFloat((rgb >> 16) & 0xFF)
+ + 0.7152 * CGFloat((rgb >> 8) & 0xFF)
+ + 0.0722 * CGFloat(rgb & 0xFF)) / 255
+ window.appearance = NSAppearance(named: luma > 0.5 ? .aqua : .darkAqua)
+ } else {
+ // Transparent: the window stops painting anything of its own and
+ // the blur takes over. isOpaque false is what lets the desktop
+ // reach the backdrop at all — a titled window is opaque by default
+ // and would composite over it.
+ backdrop.isHidden = false
+ window.isOpaque = false
+ window.backgroundColor = .clear
+ window.appearance = NSAppearance(named: .darkAqua)
+ }
+ }
+
+ /// The focused pane, in the titlebar: the proxy icon macOS lets you drag
+ /// and Cmd-click for the path, and the dot in the close button that means
+ /// unsaved.
+ ///
+ /// A pure read-out — the window says what the core already decided, and
+ /// nothing here can change it. There is no document ARCHITECTURE behind it
+ /// and deliberately so: no NSDocument, no save panel, no "do you want to
+ /// save" on close. Save is a builtin, the pane's tag says so, and this is
+ /// the same two facts spelled where a Mac user looks for them.
+ ///
+ /// Cached, because setting representedURL makes AppKit hit the filesystem
+ /// for the icon and this runs on every pump.
+ private var shownPath: String?
+ private var shownDirty = false
+
+ private func applyDocument() {
+ let path = pardes_active_path().map { String(cString: $0) }
+ if path != shownPath {
+ shownPath = path
+ // A terminal or an output buffer is not a document: no path means
+ // no proxy icon, rather than a stale one from the last file pane.
+ window.representedURL = path.map { URL(fileURLWithPath: $0) }
+ // ...and the title goes with it. A proxy icon beside a title that
+ // names something else reads as a bug, and AppKit will not fill the
+ // title in for us while `title` has been set by hand. The window is
+ // a SESSION and not a document, so it falls back to the app's own
+ // name the moment focus lands somewhere with no file behind it.
+ window.title = path.map { ($0 as NSString).lastPathComponent } ?? "pardes"
+ }
+ let dirty = pardes_active_dirty()
+ if dirty != shownDirty {
+ shownDirty = dirty
+ window.isDocumentEdited = dirty
+ }
}
@objc private func inputArrived(_ notification: Notification) {
@@ -128,7 +510,54 @@ final class AppDelegate: NSObject, NSApplicationDelegate, PardesViewDelegate {
// the effects the core queued, so nothing the user did takes hold until it
// runs.
private func pump() {
- if pardes_tick() { view.needsDisplay = true }
+ // Unconditionally dirty, and NOT `if pardes_tick()`. That return value
+ // answers "did I do any IO" — it is true when a pty produced bytes or
+ // an effect was performed, and false for everything the core changes on
+ // its own. A cursor moved with j, a selection, a mode change and a
+ // scroll all queue nothing and perform nothing, so gating the repaint
+ // on it leaves the screen showing the state before the keystroke until
+ // some unrelated event happens to force a frame. Measured: four `j`
+ // presses in a file pane produced a byte-identical screenshot.
+ //
+ // The waste is bounded and small. AppKit coalesces needsDisplay within
+ // a runloop pass, so a burst of pty output is still one frame, and an
+ // idle wakeup cannot happen — a reader only wakes the host after it has
+ // read bytes.
+ _ = pardes_tick()
+ view.needsDisplay = true
+ // After the tick, because a `Theme` command runs inside it and the
+ // window has to follow the grid in the same frame rather than at the
+ // next launch.
+ applyTheme()
+ applyDocument()
+
+ // Two verbs, two patterns, because they are two different answers:
+ // Exec did something, so it gets .generic, the definite tap of a
+ // committed action; Look went somewhere, so it gets .alignment, the
+ // lighter detent AppKit uses when a dragged guide snaps into place.
+ // Same distinction the core draws in Haptic (src/pardes.zig).
+ //
+ // No capability check on purpose. perform() is a silent no-op on a Mac
+ // with no Force Touch trackpad and when the user has turned feedback
+ // off in System Settings, so a check here would only be a second place
+ // to get the answer wrong — and it would be wrong the moment an
+ // external trackpad is plugged in mid-session.
+ let pulse = pardes_take_haptic()
+ if pulse == PARDES_HAPTIC_EXEC {
+ NSHapticFeedbackManager.defaultPerformer.perform(.generic, performanceTime: .now)
+ } else if pulse == PARDES_HAPTIC_LOOK {
+ NSHapticFeedbackManager.defaultPerformer.perform(.alignment, performanceTime: .now)
+ }
+
+ // Beside the haptic, and for the same reason: the `Font` builtin ran
+ // synchronously inside whatever input reached the core, so its answer
+ // is already waiting by the time the tick returns. The view re-measures
+ // and calls back through pardesViewDidResize, so the grid the core is
+ // holding follows the cell that just changed size.
+ if let wanted = pardes_font_take() {
+ view.adoptFont(path: String(cString: wanted))
+ }
+
if pardes_should_quit() {
NSApp.terminate(nil)
return
@@ -173,6 +602,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate, PardesViewDelegate {
return true
}
+ // Nothing in this app's UI state can be restored: the window is one view
+ // over a core that has to replay its own session, and the frame is already
+ // handled by setFrameAutosaveName. Answering true opts into the secure
+ // coder AppKit wants; leaving the method out makes macOS 14 and later log a
+ // deprecation warning on every launch.
+ func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool {
+ return true
+ }
+
func applicationWillTerminate(_ notification: Notification) {
pardes_deinit()
}