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. // // 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") final class AppDelegate: NSObject, NSApplicationDelegate, PardesViewDelegate { private var window: NSWindow! private var view: PardesView! // 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 func applicationDidFinishLaunching(_ notification: Notification) { // 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) 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 window.contentView = view // 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() // 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) // 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. 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) }) // 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) } // 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) // 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) } @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() { if pardes_tick() { view.needsDisplay = true } if pardes_should_quit() { NSApp.terminate(nil) return } // A theme transition is the only thing that moves on its own, and it is // ten 16 ms steps (animation.zig). Nothing else re-arms this, which is // the point of an event-driven host: an idle pardes costs no CPU, where // the SDL shell spins. if pardes_animating() && !pumpScheduled { pumpScheduled = true DispatchQueue.main.asyncAfter(deadline: .now() + 0.016) { self.pumpScheduled = false self.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())) pump() } func pardesViewRequestsPaste(_ view: PardesView) { guard let text = NSPasteboard.general.string(forType: .string) else { return } // 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. pardes_paste(text, text.utf8.count) pump() } // 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 } func applicationWillTerminate(_ notification: Notification) { pardes_deinit() } }