summaryrefslogtreecommitdiff
path: root/src/macos/Sources/AppDelegate.swift
diff options
context:
space:
mode:
Diffstat (limited to 'src/macos/Sources/AppDelegate.swift')
-rw-r--r--src/macos/Sources/AppDelegate.swift179
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()
+ }
+}