diff options
Diffstat (limited to 'src/macos/Sources/AppDelegate.swift')
| -rw-r--r-- | src/macos/Sources/AppDelegate.swift | 179 |
1 files changed, 179 insertions, 0 deletions
diff --git a/src/macos/Sources/AppDelegate.swift b/src/macos/Sources/AppDelegate.swift new file mode 100644 index 00000000..4df5899c --- /dev/null +++ b/src/macos/Sources/AppDelegate.swift @@ -0,0 +1,179 @@ +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<AppDelegate>.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() + } +} |
