1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
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()
}
}
|