summaryrefslogtreecommitdiff
path: root/src/macos/Sources
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-08-10 09:58:31 -0300
committerGabriel Schneider <[email protected]>2026-08-11 09:58:59 -0300
commiteb11ab331b4e13e2b9e5a673a4c012d22fdd1d9c (patch)
treecc993ad239451adb6f23645ee5ca001802832ed0 /src/macos/Sources
parent38e9919a9ea9055538409b388d580c4e4c838434 (diff)
downloadpardes-eb11ab331b4e13e2b9e5a673a4c012d22fdd1d9c.tar.gz
pardes-eb11ab331b4e13e2b9e5a673a4c012d22fdd1d9c.zip
macos: the AppKit shell, its icon, and the offscreen e2e harness
Diffstat (limited to 'src/macos/Sources')
-rw-r--r--src/macos/Sources/AppDelegate.swift371
-rw-r--r--src/macos/Sources/PardesView.swift776
2 files changed, 996 insertions, 151 deletions
diff --git a/src/macos/Sources/AppDelegate.swift b/src/macos/Sources/AppDelegate.swift
index 4df5899c..f5ca4080 100644
--- a/src/macos/Sources/AppDelegate.swift
+++ b/src/macos/Sources/AppDelegate.swift
@@ -1,16 +1,16 @@
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.
+// 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 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")
+// 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!
@@ -20,12 +20,35 @@ final class AppDelegate: NSObject, NSApplicationDelegate, PardesViewDelegate {
// 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
+ // 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()
+
// 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)
+ view = PardesView(fontSize: defaultFontSize)
let want = NSSize(width: 1000, height: 700)
window = NSWindow(
@@ -42,14 +65,40 @@ final class AppDelegate: NSObject, NSApplicationDelegate, PardesViewDelegate {
// 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()
+ // 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
+ // The same constant PardesView paints as defaultBG. Two places name it
+ // because two different things draw: 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 this that gap flashes white on
+ // every drag. The day the core exposes its theme background over the
+ // ABI, both should read it instead of agreeing by hand.
+ window.backgroundColor = NSColor(srgbRed: 0x12 / 255, green: 0x12 / 255, blue: 0x12 / 255, alpha: 1)
+ // A dark window with a light-mode titlebar reads as a bug. This also
+ // gets the traffic-light glyphs and the resize cursor right.
+ window.appearance = NSAppearance(named: .darkAqua)
// 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
@@ -60,14 +109,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, PardesViewDelegate {
// 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.
+ // 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(
@@ -104,13 +149,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, PardesViewDelegate {
// 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: didInputNotification, object: nil)
+ 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,
@@ -118,6 +164,236 @@ final class AppDelegate: NSObject, NSApplicationDelegate, PardesViewDelegate {
// core compares the effective pixel viewport, not the resize event, so
// duplicate SIGWINCH-shaped notifications are already a no-op.
pardesViewDidResize(view)
+
+ // 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
}
@objc private func inputArrived(_ notification: Notification) {
@@ -128,7 +404,49 @@ final class AppDelegate: NSObject, NSApplicationDelegate, PardesViewDelegate {
// the effects the core queued, so nothing the user did takes hold until it
// runs.
private func pump() {
- if pardes_tick() { view.needsDisplay = true }
+ // 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
+
+ // 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
@@ -173,6 +491,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate, PardesViewDelegate {
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()
}
diff --git a/src/macos/Sources/PardesView.swift b/src/macos/Sources/PardesView.swift
index 2dfa4305..c7152bd3 100644
--- a/src/macos/Sources/PardesView.swift
+++ b/src/macos/Sources/PardesView.swift
@@ -4,7 +4,17 @@
// This file keeps no model of the screen. The core owns the grid and hands it
// over whole through src/macos/pardes.h, so everything here is translation, and
// the only state worth holding is the font metrics — which are expensive to
-// measure and never change.
+// measure and never change — plus the two things a gesture needs remembered
+// between events: which button a click started with, and how hard it is being
+// pressed.
+//
+// Every NSEvent override decodes and then calls one of the post-decode entry
+// points below (press/release/drag/click/scroll/rotate/typeKey). That split is
+// not decoration: NSTouch, pressure stages and rotation have no public
+// constructors, so a test can never synthesize them, and the only way the
+// trackpad behaviour is reachable by anything but a finger is for the decision
+// to live one call below the event. test/macos_e2e.swift drives exactly those
+// entry points.
//
// Every C constant below is wrapped in an explicit conversion (UInt16(...),
// UInt32(...)) rather than used bare. A macro's imported Swift type is decided
@@ -13,18 +23,65 @@
import AppKit
import CoreText
+/// Posted after every call this view makes into the core, and answered by
+/// AppDelegate.pump(). The core only queues what it was told and does nothing
+/// until it is pumped, so an input without one of these is an input that
+/// visibly did nothing. Declared here, where the posts are.
+let pardesDidInputNotification = Notification.Name("pardesDidInput")
+
+/// A cell, already clamped to the frame the core last rendered.
+struct GridPoint {
+ var col: UInt16
+ var row: UInt16
+}
+
protocol PardesViewDelegate: AnyObject {
func pardesViewDidResize(_ view: PardesView)
func pardesViewRequestsPaste(_ view: PardesView)
}
+/// What a click means, from the fingers resting on the trackpad and the button
+/// stream AppKit chose to deliver it on. Pure on purpose: NSTouch cannot be
+/// constructed, so this is the part of the gesture a test can reach.
+///
+/// acme's three buttons are the whole vocabulary — 1 selects, 2 executes,
+/// 3 looks — and a trackpad has one surface. Two fingers is the gesture macOS
+/// itself spells "secondary", so it is Look; three is the one left over, so it
+/// is Exec, the heavier verb, which is also what a deep press means.
+///
+/// The finger count has to win over the stream, and that is not a preference.
+/// macOS's secondary click is "click or tap with TWO OR MORE fingers": with it
+/// on, a three-finger click is delivered as rightMouseDown exactly like a
+/// two-finger one, and a view that trusts the stream cannot tell them apart —
+/// three fingers silently did Look. Measured on real hardware, which is the
+/// only way this was ever going to be found: `rightMouseDown: resting=3`.
+///
+/// So the stream is only the fallback, for when there are no fingers to count:
+/// a real mouse's right button is Look and its middle button is Exec, and both
+/// arrive with an empty touch set.
+enum Trackpad {
+ static func button(stream: pardes_mouse_button_e, fingers: Int) -> pardes_mouse_button_e {
+ switch fingers {
+ case 2: return PARDES_MOUSE_RIGHT
+ case 3...: return PARDES_MOUSE_MIDDLE
+ // One finger, or none to count: the stream is the answer. A trackpad
+ // single click comes in on the left stream and stays left; a real
+ // mouse's right and middle buttons keep their acme meanings.
+ default: return stream
+ }
+ }
+
+ /// A force click is a deliberate second gesture on top of an ordinary one,
+ /// so it gets the verb that does something rather than the one that
+ /// navigates.
+ static let forceClickButton: pardes_mouse_button_e = PARDES_MOUSE_MIDDLE
+}
+
// Matches bg_default/fg_default in src/gui/gui.zig and DEFAULT_FG/DEFAULT_BG in
// src/web/app.mjs. Three shells render the same core; if these drift, comparing
// a screenshot across backends stops meaning anything.
-private let defaultFG: UInt32 = 0xCC_CC_CC
-private let defaultBG: UInt32 = 0x12_12_12
-
-private let inputNotification = Notification.Name("pardesDidInput")
+let pardesDefaultFG: UInt32 = 0xCC_CC_CC
+let pardesDefaultBG: UInt32 = 0x12_12_12
// UNVERIFIED: kCTFontAttributeName bridged through NSAttributedString.Key. It is
// the same string as .font, but spelling the CoreText key means the value stays
@@ -60,6 +117,16 @@ private func decodeColor(_ encoded: UInt32, _ fallback: UInt32) -> UInt32 {
return encoded & UInt32(PARDES_COLOR_RGB_MASK)
}
+/// The four faces, indexed by the two attribute bits that pick one. Also the
+/// glyph cache's first key, which is why it is an ordinal and not four fields.
+private enum Face: Int, CaseIterable {
+ case regular = 0, bold = 1, italic = 2, boldItalic = 3
+
+ init(bold: Bool, italic: Bool) {
+ self = Face(rawValue: (bold ? 1 : 0) | (italic ? 2 : 0))!
+ }
+}
+
/// `block` means the filled cursor sits on this cell.
private func resolve(
_ cell: pardes_cell_s,
@@ -68,10 +135,12 @@ private func resolve(
// The core never painted this cell, which is most of the screen most of the
// time, so this branch is the one that has to stay cheap.
if cell.flags & UInt8(PARDES_CELL_DEFAULT) != 0 {
- return block ? (defaultBG, defaultFG, 1, false) : (defaultFG, defaultBG, 1, false)
+ return block
+ ? (pardesDefaultBG, pardesDefaultFG, 1, false)
+ : (pardesDefaultFG, pardesDefaultBG, 1, false)
}
- var fg = decodeColor(cell.fg, defaultFG)
- var bg = decodeColor(cell.bg, defaultBG)
+ var fg = decodeColor(cell.fg, pardesDefaultFG)
+ var bg = decodeColor(cell.bg, pardesDefaultBG)
// The block cursor is a second reverse, so a cell that is already reversed
// cancels back to normal underneath it. Same rule as emitInstance in
// src/gui/gui.zig; the two must not drift.
@@ -97,6 +166,113 @@ private func advance(_ font: CTFont, _ character: UniChar) -> CGFloat {
return size.width
}
+/// The size the window opens at and Cmd+0 returns to. Named here rather than
+/// passed in because zoomReset has to know it too, and two spellings of one
+/// number is how "actual size" stops being the size it actually opened at.
+let defaultFontSize: CGFloat = 14
+
+/// Everything that changes when the face or its size does, in one value so
+/// that changing either is one assignment and cannot leave half the numbers
+/// describing the old font.
+///
+/// Built at init and again for a `Font` command or a zoom. The glyph caches
+/// belong here for the same reason: a CGGlyph is an index into a particular
+/// face, so carrying one across a font change draws the wrong character
+/// rather than none.
+private struct Metrics {
+ let fonts: [CTFont]
+ let ascent: CGFloat
+ let cellWidth: CGFloat
+ let cellHeight: CGFloat
+ let ruleThickness: CGFloat
+ let underlineOffset: CGFloat
+ /// ASCII is very nearly the whole screen, so its glyphs are resolved once
+ /// per face here and never looked up again.
+ let asciiGlyphs: [[CGGlyph]]
+
+ init(size: CGFloat, path: String?) {
+ let face = Metrics.face(size: size, path: path)
+
+ // UNVERIFIED: CTFontSymbolicTraits member spelling (.traitBold/.traitItalic).
+ // A face with no italic cut returns nil here, hence the fallback to `face`.
+ func variant(_ traits: CTFontSymbolicTraits) -> CTFont {
+ CTFontCreateCopyWithSymbolicTraits(face, size, nil, traits, traits) ?? face
+ }
+ let faces = [face, variant(.traitBold), variant(.traitItalic), variant([.traitBold, .traitItalic])]
+
+ // Whole points, rounded UP. A fractional cell width puts every column
+ // boundary on a fraction of a pixel, and with antialiasing off — which
+ // the background pass needs, or touching fills seam — the rounding
+ // wobbles by a pixel from column to column. On a screen made of tag
+ // bars and selections that stripe is visible. Rounding up rather than
+ // to nearest because down can clip a glyph, and a slightly airy grid is
+ // not a bug.
+ fonts = faces
+ ascent = CTFontGetAscent(face)
+ cellWidth = max(1, advance(face, 0x4D).rounded(.up))
+ cellHeight = max(1, (CTFontGetAscent(face) + CTFontGetDescent(face) + CTFontGetLeading(face)).rounded(.up))
+ ruleThickness = max(1, CTFontGetUnderlineThickness(face))
+ underlineOffset = CTFontGetUnderlinePosition(face)
+ asciiGlyphs = faces.map { font in
+ var chars = Array(UniChar(0)..<UniChar(128))
+ var glyphs = [CGGlyph](repeating: 0, count: 128)
+ _ = CTFontGetGlyphsForCharacters(font, &chars, &glyphs, 128)
+ return glyphs
+ }
+ }
+
+ /// The regular cut to build the other three from: the file the core asked
+ /// for, or the system monospace face when it asked for nothing — or when
+ /// what it asked for turned out not to be wearable.
+ private static func face(size: CGFloat, path: String?) -> CTFont {
+ if let path, let picked = Metrics.fromFile(path, size) { return picked }
+ let system = NSFont.monospacedSystemFont(ofSize: size, weight: .regular)
+ // Through the descriptor, not through CTFontCreateWithName(fontName):
+ // the system monospace face has a dot-prefixed internal name that a
+ // by-name lookup can miss entirely, and NSFontDescriptor is toll-free
+ // bridged, so this cannot resolve to a different font than AppKit just
+ // handed us.
+ let face = CTFontCreateWithFontDescriptor(system.fontDescriptor as CTFontDescriptor, size, nil)
+ // The whole layout is a fixed grid, so a proportional face is not a
+ // cosmetic problem, it is a broken screen. "M" and "i" disagreeing on
+ // advance is the cheapest possible proof that we got one.
+ return Metrics.isFixedPitch(face) ? face : CTFontCreateWithName("Menlo" as CFString, size, nil)
+ }
+
+ /// A face out of a font FILE, which is what the core hands over — it found
+ /// the path by walking the font directories itself, so nothing here asks
+ /// CoreText to resolve a name that a different shell might resolve
+ /// differently.
+ ///
+ /// Nil rather than a substitute for anything wrong with the file, because
+ /// the caller's fallback is the face already on screen: a font that cannot
+ /// be measured would otherwise leave a terminal with no way back out.
+ private static func fromFile(_ path: String, _ size: CGFloat) -> CTFont? {
+ let url = URL(fileURLWithPath: path) as CFURL
+ guard let descriptors = CTFontManagerCreateFontDescriptorsFromURL(url) as? [CTFontDescriptor],
+ !descriptors.isEmpty else { return nil }
+ // A .ttc holds a family's four cuts in one file. Take the one with
+ // neither trait set — the regular — because the bold and italic ones
+ // are derived from it below; falling back to the first face keeps a
+ // collection whose cuts are all styled from being unusable.
+ let plain = descriptors.first { descriptor in
+ let traits = CTFontDescriptorCopyAttribute(descriptor, kCTFontTraitsAttribute) as? [CFString: Any]
+ let symbolic = (traits?[kCTFontSymbolicTrait] as? UInt32) ?? 0
+ return symbolic & UInt32(CTFontSymbolicTraits.traitBold.rawValue | CTFontSymbolicTraits.traitItalic.rawValue) == 0
+ }
+ let face = CTFontCreateWithFontDescriptor(plain ?? descriptors[0], size, nil)
+ // The core already filtered for fixed pitch by reading the file's own
+ // advances. This is the same question asked of the face CoreText
+ // actually built, which is the one that will be drawn with.
+ return Metrics.isFixedPitch(face) ? face : nil
+ }
+
+ private static func isFixedPitch(_ face: CTFont) -> Bool {
+ let em = advance(face, 0x4D)
+ return em > 0 && abs(em - advance(face, 0x69)) <= 0.01
+ }
+}
+
private func modifiers(_ flags: NSEvent.ModifierFlags) -> UInt32 {
var mods: UInt32 = 0
if flags.contains(.control) { mods |= UInt32(PARDES_MOD_CTRL) }
@@ -106,59 +282,135 @@ private func modifiers(_ flags: NSEvent.ModifierFlags) -> UInt32 {
}
final class PardesView: NSView {
- let cellWidth: CGFloat
- let cellHeight: CGFloat
weak var delegate: PardesViewDelegate?
- private let regular: CTFont
- private let bold: CTFont
- private let italic: CTFont
- private let boldItalic: CTFont
- private let ascent: CGFloat
- private let ruleThickness: CGFloat
- private let underlineOffset: CGFloat
+ // The face and the numbers off it, replaced whole by `wear`.
+ private var metrics: Metrics
+ /// What `metrics` was built from, so a zoom keeps the face and a font
+ /// change keeps the size.
+ private var fontSize: CGFloat
+ private var fontPath: String?
+
+ var cellWidth: CGFloat { metrics.cellWidth }
+ var cellHeight: CGFloat { metrics.cellHeight }
+
+ // Glyphs the ASCII table above did not answer for. A stored 0 is .notdef,
+ // meaning "this face does not have it", which is a cache hit too — the
+ // CTLine fallback below is far more expensive than the lookup it would
+ // repeat. Keyed by face and codepoint, and thrown away with the face.
+ private var glyphCache: [UInt32: CGGlyph] = [:]
+ // Scratch for one batched run of glyphs. Held rather than made per row so a
+ // full redraw does not allocate 24 times.
+ private var runGlyphs: [CGGlyph] = []
+ private var runPositions: [CGPoint] = []
+
private var reportedCols: UInt16 = 0
private var reportedRows: UInt16 = 0
- init(fontSize: CGFloat) {
- let system = NSFont.monospacedSystemFont(ofSize: fontSize, weight: .regular)
- var face = CTFontCreateWithName(system.fontName as CFString, fontSize, nil)
- // The whole layout is a fixed grid, so a proportional face is not a
- // cosmetic problem, it is a broken screen. Resolving the system monospace
- // font by name is a lookup that can miss; "M" and "i" disagreeing on
- // advance is the cheapest possible proof that it did.
- let em = advance(face, 0x4D)
- if em <= 0 || abs(em - advance(face, 0x69)) > 0.01 {
- face = CTFontCreateWithName("Menlo" as CFString, fontSize, nil)
- }
+ // The button a left-stream click actually started with. mouseDown decides
+ // it from the fingers on the trackpad, and mouseDragged/mouseUp must use
+ // the same one: a press of right followed by a release of left leaves the
+ // core holding a drag nothing will ever end.
+ private var latchedButton: pardes_mouse_button_e?
+ private var latchedCell: GridPoint?
+ // Force-click stage, reset per press. AppKit repeats stage-2 events for as
+ // long as the finger stays down, and only the transition is the gesture.
+ private var pressureStage: Int = 0
+ // Fingers currently resting on the trackpad, kept from the touch stream.
+ //
+ // mouseDown was originally trusted to carry its own touch set, and on this
+ // hardware it does not always: AppKit routes NSTouch through the four
+ // touchesXxx callbacks, and the touch set hanging off a *mouse* event can
+ // come back empty depending on how the click was produced. Empty reads as
+ // one finger, which is a two-finger Look silently degrading into a select
+ // — the exact failure this was supposed to avoid. So the count is
+ // maintained here and the mouse event's own set is preferred only when it
+ // has something in it.
+ private var restingFingers: Int = 0
- // UNVERIFIED: CTFontSymbolicTraits member spelling (.traitBold/.traitItalic).
- // A face with no italic cut returns nil here, hence the fallback to `face`.
- func variant(_ traits: CTFontSymbolicTraits) -> CTFont {
- CTFontCreateCopyWithSymbolicTraits(face, fontSize, nil, traits, traits) ?? face
- }
- regular = face
- bold = variant(.traitBold)
- italic = variant(.traitItalic)
- boldItalic = variant([.traitBold, .traitItalic])
-
- cellWidth = max(1, advance(face, 0x4D))
- ascent = CTFontGetAscent(face)
- cellHeight = max(1, (ascent + CTFontGetDescent(face) + CTFontGetLeading(face)).rounded(.up))
- ruleThickness = max(1, CTFontGetUnderlineThickness(face))
- underlineOffset = CTFontGetUnderlinePosition(face)
+ init(fontSize size: CGFloat) {
+ let built = Metrics(size: size, path: nil)
+ metrics = built
+ fontSize = size
+ fontPath = nil
// 80x24 only so the window has a size to open at; the AppDelegate reads
// gridSize back and boots the core with whatever it actually got.
- super.init(frame: NSRect(x: 0, y: 0, width: cellWidth * 80, height: cellHeight * 24))
+ super.init(frame: NSRect(x: 0, y: 0, width: built.cellWidth * 80, height: built.cellHeight * 24))
+
+ runGlyphs.reserveCapacity(256)
+ runPositions.reserveCapacity(256)
+ // Indirect touches are the trackpad's. Without this the touch set is
+ // always empty and every click looks like one finger, which is exactly
+ // the bug that would make two-finger Look silently never fire.
+ allowedTouchTypes = [.indirect]
+ // Without a pressure configuration the deep-press stages are the
+ // system's business and stage 2 may never be delivered here.
+ // .primaryDeepClick is the one that means "a harder press is a second
+ // gesture", which is what it is being used for.
+ pressureConfiguration = NSPressureConfiguration(pressureBehavior: .primaryDeepClick)
}
required init?(coder: NSCoder) { fatalError("PardesView is built in code, not a nib") }
+ // MARK: - the face
+
+ /// The PostScript name of the face on screen. The only way anything
+ /// outside this file can find out which font is being drawn with — the
+ /// core has no font, so a cell-buffer snapshot cannot see one.
+ var faceName: String { CTFontCopyPostScriptName(metrics.fonts[0]) as String }
+
+ /// Put on a face, or the same face at a different size, and tell the host
+ /// the grid moved under it.
+ ///
+ /// A cell that changed size means a different number of columns fit the
+ /// same window, so this is a resize as far as the core is concerned — and
+ /// the delegate's resize path is already the one that reports both the
+ /// grid and the physical cell the PDF placement reads. Nothing here
+ /// second-guesses a file it could not load: `Metrics` falls back to the
+ /// system face, and asking for a font that is not wearable leaves the
+ /// screen exactly as it was rather than blank.
+ private func wear(size: CGFloat, path: String?) {
+ let next = Metrics(size: size, path: path)
+ // A CGGlyph is an index into a particular face. Kept across a change
+ // it would draw a different character, not a missing one.
+ glyphCache.removeAll(keepingCapacity: true)
+ metrics = next
+ fontSize = size
+ fontPath = path
+ delegate?.pardesViewDidResize(self)
+ needsDisplay = true
+ }
+
+ /// The file `Font <name>` resolved to, straight from the core.
+ func adoptFont(path: String) {
+ wear(size: fontSize, path: path)
+ }
+
+ /// Cmd+ and Cmd-. Whole points, because the cell is rounded to whole
+ /// points anyway: a tenth-of-a-point step would spend several keystrokes
+ /// landing on the same grid and look like the key had stopped working.
+ /// The range is what stays legible at the bottom and still fits a useful
+ /// number of columns at the top.
+ func zoom(by step: CGFloat) {
+ let next = min(max(fontSize + step, 6), 72)
+ guard next != fontSize else { return }
+ wear(size: next, path: fontPath)
+ }
+
+ func zoomReset() {
+ guard fontSize != defaultFontSize else { return }
+ wear(size: defaultFontSize, path: fontPath)
+ }
+
// Row 0 at the top, so the drawing arithmetic reads like the grid it is.
override var isFlipped: Bool { true }
override var isOpaque: Bool { true }
override var acceptsFirstResponder: Bool { true }
+ // A click that focuses the window should also land in the grid: this is a
+ // text surface, and having to click twice after switching apps is the kind
+ // of thing that makes an app feel foreign.
+ override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true }
var gridSize: (cols: UInt16, rows: UInt16) {
let cols = min(max((bounds.width / cellWidth).rounded(.down), 1), CGFloat(UInt16.max))
@@ -176,7 +428,7 @@ final class PardesView: NSView {
let count = pardes_frame()
let cols = Int(pardes_frame_cols())
let rows = Int(pardes_frame_rows())
- fill(ctx, bounds, defaultBG, 1)
+ fill(ctx, bounds, pardesDefaultBG, 1)
guard cols > 0, rows > 0, Int(count) == cols * rows, let cells = pardes_frame_cells() else { return }
// -1 when hidden, which never matches a real cell, so hidden and "bar, so
@@ -215,20 +467,20 @@ final class PardesView: NSView {
// line mirrored. Un-flip once for the whole glyph pass and convert each
// baseline into it rather than fighting the text matrix per cell.
ctx.setShouldAntialias(true)
+ // Every glyph sits at an exact multiple of cellWidth, so letting
+ // CoreText place it on a subpixel would blur a grid that is aligned by
+ // construction. Quantizing keeps the rasterizer's own cache hitting.
+ ctx.setShouldSubpixelPositionFonts(false)
+ ctx.setShouldSubpixelQuantizeFonts(true)
ctx.saveGState()
ctx.textMatrix = .identity
ctx.translateBy(x: 0, y: bounds.height)
ctx.scaleBy(x: 1, y: -1)
let height = bounds.height
for row in 0..<rows {
- let base = row * cols
- let baseline = height - (CGFloat(row) * cellHeight + ascent)
- for col in 0..<cols {
- let cell = cells[base + col]
- if cell.flags & UInt8(PARDES_CELL_DEFAULT) != 0 { continue }
- let style = resolve(cell, block: blockY == row && blockX == col)
- drawCell(ctx, cell, style, x: CGFloat(col) * cellWidth, baseline: baseline)
- }
+ drawRow(ctx, cells, base: row * cols, cols: cols,
+ baseline: height - (CGFloat(row) * cellHeight + metrics.ascent),
+ blockCol: blockY == row ? blockX : -1)
}
ctx.restoreGState()
@@ -238,73 +490,140 @@ final class PardesView: NSView {
// gui.zig paints U+258F here. A rect is the same picture without
// asking the font for a glyph it may not carry.
let fg = resolve(cells[y * cols + x], block: false).fg
+ ctx.setShouldAntialias(false)
fill(ctx, CGRect(x: CGFloat(x) * cellWidth, y: CGFloat(y) * cellHeight,
- width: max(1, cellWidth / 8), height: cellHeight), fg, 1)
+ width: max(1, (cellWidth / 8).rounded(.up)), height: cellHeight), fg, 1)
+ }
+ }
+ }
+
+ /// One row of glyphs, batched. Consecutive cells that share a face and a
+ /// colour go to CoreText as a single call with a position array: a row of
+ /// plain text is then one draw instead of eighty, which is the difference
+ /// between a full redraw being free and being felt.
+ private func drawRow(
+ _ ctx: CGContext,
+ _ cells: UnsafePointer<pardes_cell_s>,
+ base: Int,
+ cols: Int,
+ baseline: CGFloat,
+ blockCol: Int
+ ) {
+ var runFace = Face.regular
+ var runColor: UInt32 = 0
+ var runAlpha: CGFloat = 1
+ runGlyphs.removeAll(keepingCapacity: true)
+ runPositions.removeAll(keepingCapacity: true)
+
+ func flush() {
+ guard !runGlyphs.isEmpty else { return }
+ setFill(ctx, runColor, runAlpha)
+ CTFontDrawGlyphs(metrics.fonts[runFace.rawValue], runGlyphs, runPositions, runGlyphs.count, ctx)
+ runGlyphs.removeAll(keepingCapacity: true)
+ runPositions.removeAll(keepingCapacity: true)
+ }
+
+ for col in 0..<cols {
+ let cell = cells[base + col]
+ if cell.flags & UInt8(PARDES_CELL_DEFAULT) != 0 { continue }
+ let style = resolve(cell, block: blockCol == col)
+ let x = CGFloat(col) * cellWidth
+
+ // Rules before the glyph, and independent of it: an underlined space
+ // is a real thing and so is an underlined invisible cell. They are
+ // fills, not glyphs, so they interrupt the run.
+ if cell.attrs >> UInt16(PARDES_ATTR_UL_SHIFT) != 0
+ || cell.attrs & UInt16(PARDES_ATTR_STRIKETHROUGH) != 0 {
+ flush()
+ drawRules(ctx, cell, style, x: x, baseline: baseline)
+ }
+ guard style.visible else { continue }
+
+ // UNVERIFIED: withUnsafeBytes over an imported C fixed-size array, which
+ // Swift models as an 8-tuple. String(decoding:) substitutes U+FFFD rather
+ // than trapping, and the core has shipped invalid UTF-8 through here
+ // before — the renderer must not be the thing that dies over it. prefix
+ // clamps, so a bogus len cannot walk off the eight bytes either.
+ let text = withUnsafeBytes(of: cell.text) { raw in
+ String(decoding: raw.prefix(Int(cell.len)), as: UTF8.self)
+ }
+ guard !text.isEmpty, text != " " else { continue }
+
+ let face = Face(bold: cell.attrs & UInt16(PARDES_ATTR_BOLD) != 0,
+ italic: cell.attrs & UInt16(PARDES_ATTR_ITALIC) != 0)
+ let units = text.utf16
+ let known = units.count == 1 ? glyph(face, units.first!) : 0
+ if known != 0 {
+ if !runGlyphs.isEmpty
+ && (face != runFace || style.fg != runColor || style.alpha != runAlpha) {
+ flush()
+ }
+ runFace = face
+ runColor = style.fg
+ runAlpha = style.alpha
+ runGlyphs.append(known)
+ runPositions.append(CGPoint(x: x, y: baseline))
+ continue
}
+
+ // Emoji, combining marks and anything the face is missing: CTLine finds
+ // a fallback font. The position is set explicitly per cell — this is a
+ // fixed grid, and letting CoreText advance across a row would drift off
+ // it.
+ flush()
+ setFill(ctx, style.fg, style.alpha)
+ let attributed = NSAttributedString(string: text, attributes: [fontAttribute: metrics.fonts[face.rawValue]])
+ ctx.textPosition = CGPoint(x: x, y: baseline)
+ CTLineDraw(CTLineCreateWithAttributedString(attributed as CFAttributedString), ctx)
+ // CTLineDraw leaves the text position at the END of what it drew,
+ // and textPosition IS the translation of the text matrix, which
+ // CTFontDrawGlyphs then applies to every position it is handed. So
+ // one fallback glyph silently displaces the entire rest of the
+ // frame by that glyph's advance, down and to the right — and since
+ // the wrap marker and the em dash take this path, that is most
+ // files. Put it back before anything else draws.
+ ctx.textMatrix = .identity
}
+ flush()
+ }
+
+ /// 0 is .notdef, i.e. "this face does not have it" — a real answer, cached
+ /// like any other, because the CTLine fallback it sends the caller to costs
+ /// far more than the lookup it would otherwise repeat every frame.
+ private func glyph(_ face: Face, _ character: UniChar) -> CGGlyph {
+ if character < 128 { return metrics.asciiGlyphs[face.rawValue][Int(character)] }
+ let key = UInt32(face.rawValue) << 16 | UInt32(character)
+ if let cached = glyphCache[key] { return cached }
+ var input = character
+ var found = CGGlyph(0)
+ _ = CTFontGetGlyphsForCharacters(metrics.fonts[face.rawValue], &input, &found, 1)
+ glyphCache[key] = found
+ return found
}
- private func drawCell(
+ private func drawRules(
_ ctx: CGContext,
_ cell: pardes_cell_s,
_ style: (fg: UInt32, bg: UInt32, alpha: CGFloat, visible: Bool),
x: CGFloat,
baseline: CGFloat
) {
- // Rules before the glyph, and independent of it: an underlined space is a
- // real thing and so is an underlined invisible cell.
let underline = Int(cell.attrs >> PARDES_ATTR_UL_SHIFT) & 7
if underline != Int(PARDES_UL_OFF) {
- let y = baseline + underlineOffset
- fill(ctx, CGRect(x: x, y: y, width: cellWidth, height: ruleThickness), style.fg, style.alpha)
+ let y = baseline + metrics.underlineOffset
+ fill(ctx, CGRect(x: x, y: y, width: cellWidth, height: metrics.ruleThickness), style.fg, style.alpha)
// ponytail: curly, dotted and dashed all come out solid; only double
// earns its second rule. ctx.setLineDash for two of them and a sine
// path for the third is the upgrade, once anyone notices.
if underline == Int(PARDES_UL_DOUBLE) {
- fill(ctx, CGRect(x: x, y: y - ruleThickness * 2, width: cellWidth, height: ruleThickness),
+ fill(ctx, CGRect(x: x, y: y - metrics.ruleThickness * 2, width: cellWidth, height: metrics.ruleThickness),
style.fg, style.alpha)
}
}
if cell.attrs & UInt16(PARDES_ATTR_STRIKETHROUGH) != 0 {
- fill(ctx, CGRect(x: x, y: baseline + ascent * 0.3, width: cellWidth, height: ruleThickness),
+ fill(ctx, CGRect(x: x, y: baseline + metrics.ascent * 0.3, width: cellWidth, height: metrics.ruleThickness),
style.fg, style.alpha)
}
- guard style.visible else { return }
-
- // UNVERIFIED: withUnsafeBytes over an imported C fixed-size array, which
- // Swift models as an 8-tuple. String(decoding:) substitutes U+FFFD rather
- // than trapping, and the core has shipped invalid UTF-8 through here
- // before — the renderer must not be the thing that dies over it. prefix
- // clamps, so a bogus len cannot walk off the eight bytes either.
- let text = withUnsafeBytes(of: cell.text) { raw in
- String(decoding: raw.prefix(Int(cell.len)), as: UTF8.self)
- }
- guard !text.isEmpty, text != " " else { return }
-
- let heavy = cell.attrs & UInt16(PARDES_ATTR_BOLD) != 0
- let slanted = cell.attrs & UInt16(PARDES_ATTR_ITALIC) != 0
- let font = heavy ? (slanted ? boldItalic : bold) : (slanted ? italic : regular)
- setFill(ctx, style.fg, style.alpha)
-
- // ponytail: no glyph cache — a cmap lookup per cell, and a whole CTLine
- // for anything that is not one BMP scalar the face covers. A
- // [UnicodeScalar: CGGlyph] map, then an atlas, is the upgrade path when a
- // full redraw shows up in Instruments.
- let units = text.utf16
- if units.count == 1, var character = units.first {
- var glyph = CGGlyph(0)
- if CTFontGetGlyphsForCharacters(font, &character, &glyph, 1) {
- var position = CGPoint(x: x, y: baseline)
- CTFontDrawGlyphs(font, &glyph, &position, 1, ctx)
- return
- }
- }
- // Emoji, combining marks and anything the face is missing: CTLine finds a
- // fallback font. The position is set explicitly per cell — this is a fixed
- // grid, and letting CoreText advance across a row would drift off it.
- let attributed = NSAttributedString(string: text, attributes: [fontAttribute: font])
- ctx.textPosition = CGPoint(x: x, y: baseline)
- CTLineDraw(CTLineCreateWithAttributedString(attributed as CFAttributedString), ctx)
}
private func setFill(_ ctx: CGContext, _ rgb: UInt32, _ alpha: CGFloat) {
@@ -319,12 +638,78 @@ final class PardesView: NSView {
ctx.fill(rect)
}
+ // MARK: - the core, and the pump
+
+ /// Everything below ends here. Posting the notification in one place is
+ /// what guarantees no entry point can feed the core and forget to ask for
+ /// the tick that performs it.
+ private func fed() {
+ NotificationCenter.default.post(name: pardesDidInputNotification, object: self)
+ }
+
+ func typeKey(_ cp: UInt32, text: String, mods: UInt32) {
+ // The pointer is borrowed for the call and nowhere else, which is the only
+ // thing the header promises about it.
+ text.withCString { pardes_key(cp, $0, text.utf8.count, mods) }
+ fed()
+ }
+
+ // `mods` defaults to none because a synthesized gesture carries no
+ // keyboard state; the NSEvent overrides always pass the real mask. Ctrl is
+ // the one the core actually consults — a left press with it held is
+ // goto-definition — so dropping it here would silently delete a feature.
+ func press(_ button: pardes_mouse_button_e, at cell: GridPoint, mods: UInt32 = 0) {
+ pardes_mouse(button, PARDES_MOUSE_PRESS, cell.col, cell.row, mods)
+ fed()
+ }
+
+ func release(_ button: pardes_mouse_button_e, at cell: GridPoint, mods: UInt32 = 0) {
+ pardes_mouse(button, PARDES_MOUSE_RELEASE, cell.col, cell.row, mods)
+ fed()
+ }
+
+ func drag(_ button: pardes_mouse_button_e, to cell: GridPoint, mods: UInt32 = 0) {
+ pardes_mouse(button, PARDES_MOUSE_DRAG, cell.col, cell.row, mods)
+ fed()
+ }
+
+ func motion(to cell: GridPoint, mods: UInt32 = 0) {
+ pardes_mouse(PARDES_MOUSE_NONE, PARDES_MOUSE_MOTION, cell.col, cell.row, mods)
+ fed()
+ }
+
+ /// A press and its release with nothing in between, which is what every
+ /// synthesized click is: a trackpad gesture we recognised rather than a
+ /// button the user held.
+ func click(_ button: pardes_mouse_button_e, at cell: GridPoint, mods: UInt32 = 0) {
+ press(button, at: cell, mods: mods)
+ release(button, at: cell, mods: mods)
+ }
+
+ /// One discrete wheel notch, as opposed to the continuous travel below.
+ func wheel(_ button: pardes_mouse_button_e, at cell: GridPoint, mods: UInt32 = 0) {
+ pardes_mouse(button, PARDES_MOUSE_PRESS, cell.col, cell.row, mods)
+ fed()
+ }
+
+ func scroll(rows: CGFloat, cols: CGFloat = 0, at cell: GridPoint) {
+ guard rows != 0 || cols != 0 else { return }
+ pardes_scroll(Float(rows), Float(cols), cell.col, cell.row)
+ fed()
+ }
+
+ func rotate(degrees: CGFloat) {
+ guard degrees != 0 else { return }
+ pardes_rotate(Float(degrees))
+ fed()
+ }
+
// MARK: - keyboard
override func keyDown(with event: NSEvent) {
let flags = event.modifierFlags
// The ABI has no super bit, so a Command chord cannot be expressed at all.
- // Cmd-V is the paste the delegate owns; every other one is swallowed
+ // Anything the main menu claims never reaches here; the rest is swallowed
// rather than delivered as the bare keystroke the core would insert.
if flags.contains(.command) {
if event.charactersIgnoringModifiers?.lowercased() == "v" {
@@ -376,52 +761,179 @@ final class PardesView: NSView {
// carry text.
let functional = codepoint < 0x20 || codepoint == 0x7F || codepoint >= 0xF0000
let text = functional || control || option ? "" : composed
- // The pointer is borrowed for the call and nowhere else, which is the only
- // thing the header promises about it.
- text.withCString { pardes_key(codepoint, $0, text.utf8.count, modifiers(flags)) }
- NotificationCenter.default.post(name: inputNotification, object: self)
+ // Typing is the moment the pointer stops being interesting and starts
+ // sitting on top of the words. It comes back on the next mouse move.
+ NSCursor.setHiddenUntilMouseMoves(true)
+ typeKey(codepoint, text: text, mods: modifiers(flags))
+ }
+
+ // MARK: - trackpad
+
+ // NSTouch arrives through these four and nowhere else. They are the only
+ // reliable source of "how many fingers are down right now": the touch set
+ // on a mouse event is an accident of how the click was produced, and an
+ // empty one is indistinguishable from one finger.
+ override func touchesBegan(with event: NSEvent) { countTouches(event) }
+ override func touchesMoved(with event: NSEvent) { countTouches(event) }
+ override func touchesEnded(with event: NSEvent) { countTouches(event) }
+ override func touchesCancelled(with event: NSEvent) { countTouches(event) }
+
+ private func countTouches(_ event: NSEvent) {
+ restingFingers = event.touches(matching: .touching, in: nil).count
+ trace("touch: resting=\(restingFingers)")
}
// MARK: - mouse
- override func mouseDown(with event: NSEvent) { send(PARDES_MOUSE_LEFT, PARDES_MOUSE_PRESS, event) }
- override func mouseUp(with event: NSEvent) { send(PARDES_MOUSE_LEFT, PARDES_MOUSE_RELEASE, event) }
- override func mouseDragged(with event: NSEvent) { send(PARDES_MOUSE_LEFT, PARDES_MOUSE_DRAG, event) }
- override func rightMouseDown(with event: NSEvent) { send(PARDES_MOUSE_RIGHT, PARDES_MOUSE_PRESS, event) }
- override func rightMouseUp(with event: NSEvent) { send(PARDES_MOUSE_RIGHT, PARDES_MOUSE_RELEASE, event) }
- override func rightMouseDragged(with event: NSEvent) { send(PARDES_MOUSE_RIGHT, PARDES_MOUSE_DRAG, event) }
+ // All three button streams land in the same three functions, because which
+ // stream a trackpad click arrives on is not something the app gets to know
+ // in advance: with macOS's secondary click on, two AND three fingers both
+ // come in as rightMouseDown. The button is therefore decided once, at the
+ // press, from the fingers plus the stream, and then LATCHED — the core is
+ // tracking a drag keyed by button, and answering a press of 3 with a
+ // release of 1 leaves it holding a sweep nothing will ever end.
+ //
+ // The count itself is maintained by the touchesXxx callbacks above; see
+ // beginClick for why it can only come from there.
+ private func beginClick(_ stream: pardes_mouse_button_e, _ event: NSEvent) {
+ guard let at = cell(for: event) else { return }
+ pressureStage = 0
+ // The count comes from the touch stream and NEVER from the mouse event.
+ // Asking a mouse event for its touches is not merely unreliable, it
+ // raises: -[NSEvent touchesMatchingPhase:inView:] is defined for
+ // gesture and touch events, and on anything else AppKit throws, catches
+ // it inside its own event dispatch, and abandons the rest of this
+ // method. Nothing crashes and nothing is logged — every click just
+ // silently stops working, a plain drag included, while rotation and
+ // scrolling carry on as if the backend were fine. That is exactly how
+ // this presented, and it is why restingFingers exists.
+ let button = Trackpad.button(stream: stream, fingers: restingFingers)
+ trace("press: stream=\(stream.rawValue) fingers=\(restingFingers) -> button=\(button.rawValue)")
+ latchedButton = button
+ latchedCell = at
+ press(button, at: at, mods: modifiers(event.modifierFlags))
+ }
+
+ private func continueClick(_ event: NSEvent) {
+ guard let button = latchedButton, let at = cell(for: event) else { return }
+ latchedCell = at
+ drag(button, to: at, mods: modifiers(event.modifierFlags))
+ }
+
+ private func endClick(_ event: NSEvent) {
+ pressureStage = 0
+ // Already nil when the force click below converted this press: it
+ // released the button itself and there is nothing left to end.
+ guard let button = latchedButton else { return }
+ latchedButton = nil
+ guard let at = cell(for: event) else { return }
+ latchedCell = at
+ release(button, at: at, mods: modifiers(event.modifierFlags))
+ }
+
+ override func mouseDown(with event: NSEvent) { beginClick(PARDES_MOUSE_LEFT, event) }
+ override func mouseDragged(with event: NSEvent) { continueClick(event) }
+ override func mouseUp(with event: NSEvent) { endClick(event) }
+ // Where a two-finger click lands with macOS's own secondary click on, and
+ // where a three-finger one lands too — hence the finger count in
+ // Trackpad.button rather than a hardcoded RIGHT here.
+ override func rightMouseDown(with event: NSEvent) { beginClick(PARDES_MOUSE_RIGHT, event) }
+ override func rightMouseDragged(with event: NSEvent) { continueClick(event) }
+ override func rightMouseUp(with event: NSEvent) { endClick(event) }
// otherMouse covers button 2 and up. acme's vocabulary stops at three, so
// every one of them lands on middle rather than being invented into a fourth.
- override func otherMouseDown(with event: NSEvent) { send(PARDES_MOUSE_MIDDLE, PARDES_MOUSE_PRESS, event) }
- override func otherMouseUp(with event: NSEvent) { send(PARDES_MOUSE_MIDDLE, PARDES_MOUSE_RELEASE, event) }
- override func otherMouseDragged(with event: NSEvent) { send(PARDES_MOUSE_MIDDLE, PARDES_MOUSE_DRAG, event) }
- override func mouseMoved(with event: NSEvent) { send(PARDES_MOUSE_NONE, PARDES_MOUSE_MOTION, event) }
+ override func otherMouseDown(with event: NSEvent) { beginClick(PARDES_MOUSE_MIDDLE, event) }
+ override func otherMouseDragged(with event: NSEvent) { continueClick(event) }
+ override func otherMouseUp(with event: NSEvent) { endClick(event) }
+
+ /// A deep press, which is a second gesture layered on the click already in
+ /// flight. AppKit keeps sending stage-2 events while the finger stays down,
+ /// so only the transition counts.
+ ///
+ /// Whatever button is in flight is released before the middle one goes out:
+ /// a middle press arriving while the core holds a left select-drag is
+ /// acme's 1-2 chord, which is Cut. Releasing first costs a cursor move at
+ /// the click point — which is what clicking there would have done anyway.
+ ///
+ /// Not gated on the press being a LEFT one, which is what stopped this
+ /// working: on a Force Touch trackpad the deep press is just as likely to
+ /// have arrived on the right stream, and an in-flight Look upgraded by
+ /// pressing harder is precisely the gesture. Already-Exec is the only case
+ /// with nothing to do.
+ override func pressureChange(with event: NSEvent) {
+ trace("pressure: stage=\(event.stage) latched=\(String(describing: latchedButton?.rawValue))")
+ guard pressureStage < 2 else { return }
+ pressureStage = event.stage
+ guard event.stage == 2 else { return }
+ guard let at = latchedCell, let current = latchedButton,
+ current != Trackpad.forceClickButton else { return }
+ latchedButton = nil
+ release(current, at: at, mods: modifiers(event.modifierFlags))
+ click(Trackpad.forceClickButton, at: at, mods: modifiers(event.modifierFlags))
+ }
+
+ override func mouseMoved(with event: NSEvent) {
+ guard let at = cell(for: event) else { return }
+ motion(to: at, mods: modifiers(event.modifierFlags))
+ }
override func scrollWheel(with event: NSEvent) {
guard let at = cell(for: event) else { return }
if event.hasPreciseScrollingDeltas {
- // The core scrolls a row at a time, so libpardes accumulates the
- // sub-row travel and spends it as wheel presses — which is why the
+ // The core scrolls a cell at a time, so libpardes accumulates the
+ // sub-cell travel and spends it as wheel presses — which is why the
// cell has to travel with the delta.
- pardes_scroll(Float(-event.scrollingDeltaY / cellHeight), at.col, at.row)
- } else if event.scrollingDeltaY != 0 {
+ scroll(rows: -event.scrollingDeltaY / cellHeight,
+ cols: -event.scrollingDeltaX / cellWidth,
+ at: at)
+ } else {
// AppKit's sign is the opposite of the DOM's: positive deltaY means the
// content moved down, which is a scroll back through history.
- let button = event.scrollingDeltaY > 0 ? PARDES_MOUSE_WHEEL_UP : PARDES_MOUSE_WHEEL_DOWN
- pardes_mouse(button, PARDES_MOUSE_PRESS, at.col, at.row, modifiers(event.modifierFlags))
- } else {
- return
+ if event.scrollingDeltaY != 0 {
+ wheel(event.scrollingDeltaY > 0 ? PARDES_MOUSE_WHEEL_UP : PARDES_MOUSE_WHEEL_DOWN,
+ at: at, mods: modifiers(event.modifierFlags))
+ }
+ if event.scrollingDeltaX != 0 {
+ wheel(event.scrollingDeltaX > 0 ? PARDES_MOUSE_WHEEL_LEFT : PARDES_MOUSE_WHEEL_RIGHT,
+ at: at, mods: modifiers(event.modifierFlags))
+ }
}
- NotificationCenter.default.post(name: inputNotification, object: self)
}
- private func send(_ button: pardes_mouse_button_e, _ kind: pardes_mouse_kind_e, _ event: NSEvent) {
- guard let at = cell(for: event) else { return }
- pardes_mouse(button, kind, at.col, at.row, modifiers(event.modifierFlags))
- NotificationCenter.default.post(name: inputNotification, object: self)
+ /// Two fingers twisted on the trackpad are the search-step keys: clockwise
+ /// walks forward through the matches, counterclockwise back. It is a dial,
+ /// and n/N is what a dial over a list of hits means. libpardes owns the
+ /// quantizing, exactly as it does for scroll.
+ override func rotate(with event: NSEvent) {
+ trace("rotate: degrees=\(event.rotation) phase=\(event.phase.rawValue)")
+ // A gesture starting drops whatever the last one left banked, so the
+ // first degree of a new twist cannot inherit a nearly-complete notch.
+ if event.phase == .began { pardes_rotate(0) }
+ rotate(degrees: CGFloat(event.rotation))
}
- private func cell(for event: NSEvent) -> (col: UInt16, row: UInt16)? {
+ /// What the trackpad actually delivered, under PARDES_LOG — the same
+ /// variable the Zig side gates its logger on (src/macos.zig).
+ ///
+ /// This is not scaffolding left behind. Which events a trackpad produces is
+ /// decided by the hardware and by four different System Settings switches
+ /// (secondary click, three-finger drag, force click, "look up"), none of
+ /// which this process can read, and every one of which turns a gesture into
+ /// a different NSEvent or into none at all. When someone reports that
+ /// two-finger Look does nothing, this is the only thing that can answer
+ /// whether AppKit saw two fingers, one, or no click at all.
+ private func trace(_ message: @autoclosure () -> String) {
+ guard PardesView.tracing else { return }
+ FileHandle.standardError.write(Data(("pardes: " + message() + "\n").utf8))
+ }
+
+ private static let tracing = ProcessInfo.processInfo.environment["PARDES_LOG"] != nil
+
+ private func cell(for event: NSEvent) -> GridPoint? {
+ cellAt(convert(event.locationInWindow, from: nil))
+ }
+
+ func cellAt(_ point: CGPoint) -> GridPoint? {
// Clamp against the frame the core last rendered, not against our own
// metrics: a window that has been resized but not yet ticked would
// otherwise report a column the core has no cell for. Before the first
@@ -429,10 +941,9 @@ final class PardesView: NSView {
let cols = Int(pardes_frame_cols())
let rows = Int(pardes_frame_rows())
guard cols > 0, rows > 0 else { return nil }
- let point = convert(event.locationInWindow, from: nil)
let col = min(max(Int(point.x / cellWidth), 0), cols - 1)
let row = min(max(Int(point.y / cellHeight), 0), rows - 1)
- return (UInt16(col), UInt16(row))
+ return GridPoint(col: UInt16(col), row: UInt16(row))
}
// MARK: - geometry
@@ -448,6 +959,12 @@ final class PardesView: NSView {
userInfo: nil))
}
+ override func resetCursorRects() {
+ // Every cell in this view is text, including the tags. An arrow over a
+ // grid you can sweep and click words in is the wrong affordance.
+ addCursorRect(bounds, cursor: .iBeam)
+ }
+
override func setFrameSize(_ newSize: NSSize) {
super.setFrameSize(newSize)
// AppKit resizes a view many times over one drag and almost all of those
@@ -475,4 +992,5 @@ final class PardesView: NSView {
// ponytail: no NSTextInputClient, so dead keys and IME composition never reach
// the core — keyDown reads `characters` and that is the whole story. Adopting
// the protocol and routing through interpretKeyEvents is the upgrade when
-// someone needs to type Japanese.
+// someone needs to type Japanese, and it needs the core to be able to render an
+// underlined preedit run first.