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
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
|
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 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<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)
},
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.
if let wanted = pardes_font_take() {
view.adoptFont(path: String(cString: wanted))
}
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() && !pumpScheduled else { return }
let awaitingPresentation = view.presentationSerial
pumpScheduled = true
DispatchQueue.main.asyncAfter(deadline: .now() + 0.016) {
self.animationFrame(after: awaitingPresentation)
}
}
private func animationFrame(after awaitingPresentation: UInt64) {
pumpScheduled = false
guard view.presentationSerial != awaitingPresentation else {
// Keep the dirty sample pending. AppKit may have coalesced this
// draw or the window may be occluded; retry the clock without
// advancing until draw(_:) supplies the presentation permit.
view.needsDisplay = true
scheduleAnimationFrame()
return
}
_ = pardes_animation_tick()
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 }
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()
}
}
|