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, 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 // 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 display-clock chain at a time. Input and pty notifications may all // call pump(), but none advances animation; they only observe this flag and // leave the already-scheduled frame alone. private var pumpScheduled: Bool = false /// The display's own clock, when the OS will lend us one. /// /// The fallback below is a `DispatchQueue.asyncAfter(0.016)` chain re-armed /// AFTER each frame's tick, drain and draw, so its real period is 16 ms plus /// all of that — comfortably slower than 60 Hz, and jittery, and never in /// phase with the refresh. That is what makes an animation look choppy even /// when nothing is dropping frames. A display link fires once per refresh, /// phase-locked to vsync, which is the cadence the picture is actually /// presented at. /// /// Cadence is now only a SMOOTHNESS question: `pardes_animation_tick` /// spends measured elapsed time, so a 120 Hz link does not double-speed an /// animation and a slow one does not halve it. /// Held as `AnyObject` because a stored property cannot carry /// `@available`, and this file still deploys to macOS 13. private var displayLink: AnyObject? // 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() // Initial body size. PardesView owns later Ctrl+/Ctrl- changes and // rebuilds both body and tagline metrics before publishing the resize. view = PardesView(fontSize: defaultFontSize) let want = NSSize(width: 1000, height: 700) window = NSWindow( contentRect: NSRect(origin: .zero, size: want), styleMask: [.titled, .closable, .miniaturizable, .resizable], backing: .buffered, defer: false) window.title = "pardes" // We hold this window strongly for the life of the app. Leaving // isReleasedWhenClosed on would have AppKit release it out from under // that reference the moment the close button is pressed. window.isReleasedWhenClosed = false // 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. window.setContentSize(NSSize( width: (want.width / view.cellWidth).rounded(.down) * view.cellWidth, height: (want.height / view.cellHeight).rounded(.down) * view.cellHeight)) // 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 // empty frame; metrics seeded at 1x would stay wrong forever, because // a scale change moves no bounds and so fires no resize. window.makeKeyAndOrderFront(nil) // AppKit may promote a content view that accepts first responder on its // 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) // 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( userdata: Unmanaged.passUnretained(self).toOpaque(), wakeup: { ud in // A pty reader thread. Every other function in pardes.h is // main-thread-only, so this hop is the entire body — touching // any core state here would be the race the hop exists to // avoid. let host = Unmanaged.fromOpaque(ud!).takeUnretainedValue() DispatchQueue.main.async { host.pump() } }, set_clipboard: { _, text, len in // Main thread, from inside pardes_tick. `text` is borrowed for // the length of the call, so the String has to be a copy. var yank: String = "" if let text = text, len > 0 { let bytes = UnsafeRawBufferPointer(start: UnsafeRawPointer(text), count: len) yank = String(decoding: bytes, as: UTF8.self) } let pasteboard = NSPasteboard.general pasteboard.clearContents() _ = pasteboard.setString(yank, forType: .string) }, read_clipboard: { _ in // Also the main thread, also inside pardes_tick — but this one // answers immediately: NSPasteboard reads are synchronous, so // the paste lands back in the core before this call returns. // No pump, deliberately: the tick that emitted the effect is // still draining and picks up whatever the paste queued. guard let text = NSPasteboard.general.string(forType: .string) else { return } AppDelegate.paste(text) }) // The real grid, never a placeholder: the core holds each shell's // greeting until it has seen a size, so a correction sent afterwards // arrives with the first prompt already wrapped to the wrong width. let grid = view.gridSize let rc = pardes_init(&runtime, grid.cols, grid.rows) if rc != 0 { NSLog("pardes: the core failed to boot at \(grid.cols)x\(grid.rows), pardes_init returned \(rc)") // Not NSApp.terminate — that runs applicationWillTerminate, which // 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: 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, // and pumps. Repeating the size init just saw is safe by design: the // core compares the effective pixel viewport, not the resize event, so // duplicate SIGWINCH-shaped notifications are already a no-op. pardesViewDidResize(view) // That pump also consumes a Font from startup config, if present. Only // now is the face on screen final enough to seed Config's effective // name and point size; later adopts and zooms report from wear(). view.reportEffectiveFont() // 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) { pump() } // Every path into the core ends here. pardes_tick drains the ptys and runs // the effects the core queued, so nothing the user did takes hold until it // runs. private func pump() { // 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 // TaglineSize is host-owned raster state just like Font, but unlike a // body face change it does not resize the grid. Polling the one-byte // core setting keeps startup config and live commands on one path. view.adoptTaglinePercent(pardes_gui_tagline_font_percent()) // 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. var fontSize: UInt16 = 0 if let wanted = pardes_font_take(&fontSize) { view.adoptFont(path: String(cString: wanted), sizeHundredths: fontSize) } if pardes_should_quit() { NSApp.terminate(nil) return } // Input and pty pumps only drain work. A frame-count animation advances // on this 16 ms clock, and only after the sample dirtied above was // actually presented. A burst of input cannot make it run fast, while // an occluded/coalesced AppKit view cannot consume invisible samples. scheduleAnimationFrame() } private func scheduleAnimationFrame() { guard pardes_animating() else { stopDisplayLink() return } if #available(macOS 14.0, *) { // One link for the whole animating run, not one callback armed per // frame: re-arming after the work is what put the period at 16 ms // PLUS the work, and no amount of tuning that constant fixes a // clock that is not the display's. if displayLink == nil { let link = view.displayLink(target: self, selector: #selector(displayLinkFired)) link.add(to: .main, forMode: .common) displayLink = link } return } // macOS 13 has no NSView display link. Keep the old chain, which is now // only a cadence compromise rather than a correctness one — the tick // spends measured time either way. guard !pumpScheduled else { return } let awaitingPresentation = view.presentationSerial pumpScheduled = true DispatchQueue.main.asyncAfter(deadline: .now() + 0.016) { self.pumpScheduled = false self.animationFrame(after: awaitingPresentation) } } private func stopDisplayLink() { if #available(macOS 14.0, *) { (displayLink as? CADisplayLink)?.invalidate() } displayLink = nil } @objc private func displayLinkFired() { guard coreIsUp else { return } animationFrame(after: pendingPresentation) } /// The presentation serial the last dirtied sample is waiting on. Held /// across display-link callbacks because the link, unlike the old chain, /// does not carry it in a closure. private var pendingPresentation: UInt64 = 0 private func animationFrame(after awaitingPresentation: UInt64) { guard view.presentationSerial != awaitingPresentation else { // Keep the dirty sample pending. AppKit may have coalesced this // draw or the window may be occluded; retry on the next refresh // without advancing, until draw(_:) supplies the presentation // permit. On the link that costs ONE refresh; the old chain paid a // fresh 16 ms for it, which is where the visible hitching came from. view.needsDisplay = true if #available(macOS 14.0, *) {} else { scheduleAnimationFrame() } return } _ = pardes_animation_tick() pendingPresentation = view.presentationSerial pump() } func pardesViewDidResize(_ view: PardesView) { let grid = view.gridSize // cell_w/cell_h are physical pixels. The core hands them to the native // PDF placement path, and a point is two pixels on a Retina display, so // passing points would place every page at half size. let scale: CGFloat = window.backingScaleFactor pardes_resize( grid.cols, grid.rows, UInt16((view.cellWidth * scale).rounded()), UInt16((view.cellHeight * scale).rounded())) pardes_row_metrics(UInt16(max(1,(view.cellWidth*scale).rounded())), UInt16(max(1,(view.cellHeight*scale).rounded())), UInt16(max(1,(view.taglineCellWidth*scale).rounded())), UInt16(max(1,(view.taglineBandHeight*scale).rounded()))) pump() } func pardesViewRequestsPaste(_ view: PardesView) { guard let text = NSPasteboard.general.string(forType: .string) else { return } AppDelegate.paste(text) pump() } // Swift lends a temporary NUL-terminated UTF-8 buffer for the duration of // the call, which is exactly as long as the core borrows it. Static, and // pumping is the caller's business: the read_clipboard callback is a C // function pointer that can capture nothing and is already inside a tick, // where the gesture path is not. private static func paste(_ text: String) { pardes_paste(text, text.utf8.count) } // ponytail: one window, no tabs — the core has no multi-window notion, so // the last window closing really is the end of the process. func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { 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() } }