diff options
| author | Gabriel Schneider <[email protected]> | 2026-08-10 09:58:31 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-08-11 09:58:59 -0300 |
| commit | eb11ab331b4e13e2b9e5a673a4c012d22fdd1d9c (patch) | |
| tree | cc993ad239451adb6f23645ee5ca001802832ed0 /src/macos | |
| parent | 38e9919a9ea9055538409b388d580c4e4c838434 (diff) | |
| download | pardes-eb11ab331b4e13e2b9e5a673a4c012d22fdd1d9c.tar.gz pardes-eb11ab331b4e13e2b9e5a673a4c012d22fdd1d9c.zip | |
macos: the AppKit shell, its icon, and the offscreen e2e harness
Diffstat (limited to 'src/macos')
| -rw-r--r-- | src/macos/Info.plist | 56 | ||||
| -rw-r--r-- | src/macos/Sources/AppDelegate.swift | 371 | ||||
| -rw-r--r-- | src/macos/Sources/PardesView.swift | 776 | ||||
| -rwxr-xr-x | src/macos/build-app.sh | 55 | ||||
| -rwxr-xr-x | src/macos/build-e2e.sh | 52 | ||||
| -rw-r--r-- | src/macos/icon.swift | 266 | ||||
| -rw-r--r-- | src/macos/pardes.h | 74 |
7 files changed, 1486 insertions, 164 deletions
diff --git a/src/macos/Info.plist b/src/macos/Info.plist index 9e8f65f7..665d54ca 100644 --- a/src/macos/Info.plist +++ b/src/macos/Info.plist @@ -10,11 +10,67 @@ <string>pardes</string> <key>CFBundlePackageType</key> <string>APPL</string> + <!-- The version the About panel shows, and the build Launch Services + compares when two copies of the bundle are on disk. --> + <key>CFBundleShortVersionString</key> + <string>0.1.0</string> + <key>CFBundleVersion</key> + <string>1</string> + <!-- No extension: Launch Services appends .icns and looks in + Contents/Resources, where build-app.sh emits pardes.icns. Without this + key the Dock shows the blank generic app even though the icon is + sitting right there in the bundle. --> + <key>CFBundleIconFile</key> + <string>pardes</string> <key>NSHighResolutionCapable</key> <true/> + <!-- build-app.sh overwrites this from macos_min_version in build.zig, which + is the single source of truth; the key must exist for PlistBuddy's Set + to have something to set. --> <key>LSMinimumSystemVersion</key> <string>13.0</string> + <key>LSApplicationCategoryType</key> + <string>public.app-category.developer-tools</string> <key>NSPrincipalClass</key> <string>NSApplication</string> + <!-- Both false, and neither is boilerplate: there are live ptys with child + processes attached and unsaved buffers with no autosave behind them, so + a process macOS killed or quietly relaunched would lose work and orphan + shells. --> + <key>NSSupportsAutomaticTermination</key> + <false/> + <key>NSSupportsSuddenTermination</key> + <false/> + <!-- What Finder's "Open With" offers pardes for. Editor rather than Viewer + because Save is a builtin: pardes writes the files it opens. A folder + is a legitimate target too — Look on a directory opens a directory + pane, which is how you navigate in acme. --> + <key>CFBundleDocumentTypes</key> + <array> + <dict> + <key>CFBundleTypeName</key> + <string>Text Document</string> + <key>CFBundleTypeRole</key> + <string>Editor</string> + <key>LSHandlerRank</key> + <string>Alternate</string> + <key>LSItemContentTypes</key> + <array> + <string>public.plain-text</string> + </array> + </dict> + <dict> + <key>CFBundleTypeName</key> + <string>Folder</string> + <key>CFBundleTypeRole</key> + <string>Editor</string> + <key>LSHandlerRank</key> + <string>Alternate</string> + <key>LSItemContentTypes</key> + <array> + <string>public.folder</string> + </array> + </dict> + </array> </dict> </plist> 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. diff --git a/src/macos/build-app.sh b/src/macos/build-app.sh index 6734f8d3..a54e76b8 100755 --- a/src/macos/build-app.sh +++ b/src/macos/build-app.sh @@ -1,7 +1,8 @@ #!/bin/sh # Assemble pardes.app from libpardes.a and the Swift sources. Run it through # `zig build macos-app -Dplatform=macos`, or by hand with the install prefix as -# $1 once `zig build -Dplatform=macos` has produced the library. +# $1, the deployment target as $2 and the Zig optimize mode as $3 once +# `zig build -Dplatform=macos` has produced the library. # # There is no Xcode project on purpose. An .app is a directory with a plist and # a binary in it, swiftc ships with the Command Line Tools, and a hand-written @@ -13,8 +14,28 @@ set -eu root=$(cd "$(dirname "$0")/../.." && pwd) out=${1:-"$root/zig-out"} +# Keep in step with macos_min_version in build.zig, which passes it in. The +# default is only for a by-hand run. +minver=${2:-13.0} +# Both swiftc invocations below take the same triple; the note above the app +# link is why -target is not optional for either of them. +target="$(uname -m)-apple-macos$minver" app="$out/pardes.app" lib="$out/lib/libpardes.a" +# The two halves of this program are compiled by two compilers, and before this +# only one of them was told anything: swiftc was hardcoded to -O while the Zig +# core followed -Doptimize, so the ordinary `zig build macos-app` produced an +# optimized shell around a DEBUG core. It does not read as "I built Debug", it +# reads as "the mac backend is slow" — measured on this machine, one frame at +# 190x56 cost 4.5 ms with a Debug core and 0.88 ms with a ReleaseFast one, and +# 4.1 ms of that 4.5 was pardes_frame alone. So the mode travels, both halves +# agree, and a slow bundle says why. +zigmode=${3:-Debug} +case $zigmode in +Debug) swiftmode=-Onone ;; +ReleaseSmall) swiftmode=-Osize ;; +*) swiftmode=-O ;; +esac [ -f "$lib" ] || { echo "missing $lib — run: zig build -Dplatform=macos" >&2; exit 1; } command -v swiftc >/dev/null || { echo "swiftc not found (needs macOS + Command Line Tools)" >&2; exit 1; } @@ -22,6 +43,26 @@ command -v swiftc >/dev/null || { echo "swiftc not found (needs macOS + Command rm -rf "$app" mkdir -p "$app/Contents/MacOS" "$app/Contents/Resources" cp "$root/src/macos/Info.plist" "$app/Contents/Info.plist" +# One version, three consumers: the plist's claim is set from the same string +# the link below enforces, so a bumped deployment target cannot leave a stale +# LSMinimumSystemVersion behind. +/usr/libexec/PlistBuddy -c "Set :LSMinimumSystemVersion $minver" "$app/Contents/Info.plist" >/dev/null + +# The icon is generated rather than committed. The mark is drawn out of the +# same palette PardesView.swift renders cells with — #121212 body, the tag bar +# and the block cursor straight from ansi16 — so a colour that moves there +# moves here on the next build, instead of a binary blob sitting in the tree +# quietly disagreeing with the app it ships in. Info.plist's CFBundleIconFile +# names the pardes.icns this drops into Resources; without both halves the Dock +# falls back to the generic blank page. +# +# The trap covers the compiled generator; the generator clears its own scratch +# iconset. set -eu means a failure here takes the whole build down, which is +# the point: a bundle that ships a blank icon should not have built. +icontmp=$(mktemp -d) +trap 'rm -rf "$icontmp"' EXIT +swiftc -O -target "$target" -o "$icontmp/pardes-icon" "$root/src/macos/icon.swift" +"$icontmp/pardes-icon" "$app/Contents/Resources" # -import-objc-header rather than a module map: the header is consumed straight # from the source tree, so there is nothing to stage and nothing to keep in @@ -35,11 +76,19 @@ cp "$root/src/macos/Info.plist" "$app/Contents/Info.plist" # launch it on anything older — the Info.plist's LSMinimumSystemVersion is a # claim, not the enforcement. It is also what turns on the availability # diagnostics that catch a post-13 API before a user does. -swiftc -O -target "$(uname -m)-apple-macos13.0" \ +swiftc "$swiftmode" -target "$target" \ -import-objc-header "$root/src/macos/pardes.h" \ -o "$app/Contents/MacOS/pardes" \ "$root"/src/macos/Sources/*.swift \ "$lib" -lc++ \ -framework AppKit -framework CoreText -framework CoreGraphics -echo "built $app" +# An .app whose mtime never moves is an .app Launch Services keeps serving from +# its cache, icon and plist and all. +touch "$app" + +if [ "$zigmode" = Debug ]; then + echo "built $app (Debug core — rebuild with -Doptimize=ReleaseFast to use it)" +else + echo "built $app" +fi diff --git a/src/macos/build-e2e.sh b/src/macos/build-e2e.sh new file mode 100755 index 00000000..7e0d2233 --- /dev/null +++ b/src/macos/build-e2e.sh @@ -0,0 +1,52 @@ +#!/bin/sh +# Link the offscreen end-to-end harness: the app's own Swift shell, plus +# test/macos_e2e.swift as the entry point, against the same libpardes.a. Run it +# through `zig build macos-e2e -Dplatform=macos`, or by hand with the install +# prefix as $1 and the deployment target as $2. +# +# A SECOND BINARY rather than a `--e2e` flag on the app, for two reasons. +# +# Test scaffolding does not ship inside the product. A flag would put the script +# interpreter, the /tmp world-builder and the golden differ into the thing a +# user launches, and would give the app a mode in which it rewrites files under +# /tmp and calls exit() — none of which anyone should be one argv typo away from. +# +# And src/macos/Sources/main.swift holds top-level code, which IS an entry +# point: a module cannot contain both top-level statements and a @main type, so +# the harness could not join that link even if the first reason went away. Every +# other Swift file the app builds from is compiled here, so this link is also +# what proves the shell still compiles as a library rather than as an app. +set -eu + +root=$(cd "$(dirname "$0")/../.." && pwd) +out=${1:-"$root/zig-out"} +# Keep in step with macos_min_version in build.zig, which passes it in. The +# default is only for a by-hand run. Same string as build-app.sh, and for the +# same reason: -target is what decides LC_BUILD_VERSION and turns on the +# availability diagnostics. +minver=${2:-13.0} +lib="$out/lib/libpardes.a" +bin="$out/bin/pardes-macos-e2e" + +[ -f "$lib" ] || { echo "missing $lib — run: zig build -Dplatform=macos" >&2; exit 1; } +command -v swiftc >/dev/null || { echo "swiftc not found (needs macOS + Command Line Tools)" >&2; exit 1; } + +mkdir -p "$out/bin" + +# -import-objc-header and -lc++ are build-app.sh's, unchanged, and have to stay +# that way: this link exists to exercise the app's link, so anything that +# differs here is something the harness cannot vouch for. +# +# -O for the same reason. A debug build of the CoreText pass and the effect +# drain settles on different timings than a user sees, and `stable` waits on +# exactly those timings. +swiftc -O -target "$(uname -m)-apple-macos$minver" \ + -import-objc-header "$root/src/macos/pardes.h" \ + -o "$bin" \ + "$root/src/macos/Sources/PardesView.swift" \ + "$root/src/macos/Sources/AppDelegate.swift" \ + "$root/test/macos_e2e.swift" \ + "$lib" -lc++ \ + -framework AppKit -framework CoreText -framework CoreGraphics + +echo "built $bin" diff --git a/src/macos/icon.swift b/src/macos/icon.swift new file mode 100644 index 00000000..b5de1838 --- /dev/null +++ b/src/macos/icon.swift @@ -0,0 +1,266 @@ +// Draws pardes.app's icon at build time and hands the result to iconutil. +// +// It is generated instead of committed because the mark IS the palette: the +// body is defaultBG, the tag bar and the block cursor are entries out of the +// same ansi16 table src/macos/Sources/PardesView.swift paints cells with. A +// checked-in .icns is a binary blob that stops matching the app the first time +// one of those colours moves, silently, with nothing in a diff to catch it. +// +// src/macos/build-app.sh compiles this file alone into a temporary binary and +// runs it with the bundle's Contents/Resources as argv[1]; Info.plist's +// CFBundleIconFile names the pardes.icns that comes out. Compiled alone is also +// what makes top-level code legal here: one file, one module, its own binary. +// +// Byte-identical output for byte-identical input is a requirement, not a +// nicety — an icns that churns on every build is a bundle that churns on every +// build, and Launch Services notices. Hence a pinned sRGB colour space, integer +// geometry, and nothing read from the clock or the environment. + +import CoreGraphics +import Foundation +import ImageIO +import UniformTypeIdentifiers + +/// Every failure path lands here. A build that ships the generic blank-page +/// icon looks like an app nobody finished, so half-drawn is worse than absent +/// and the script has `set -e` waiting for the exit code. +func die(_ message: String) -> Never { + fputs("icon.swift: \(message)\n", stderr) + exit(1) +} + +struct RGB { + let red: CGFloat + let green: CGFloat + let blue: CGFloat + + init(_ hex: UInt32) { + red = CGFloat((hex >> 16) & 0xFF) / 255 + green = CGFloat((hex >> 8) & 0xFF) / 255 + blue = CGFloat(hex & 0xFF) / 255 + } + + func components(_ alpha: CGFloat) -> [CGFloat] { [red, green, blue, alpha] } +} + +// Straight out of PardesView.swift. If those move these move, because the icon +// is a picture of the running program and a stale picture is worse than none: +// it looks deliberate. +let bodyTop = RGB(0x12_12_12) // defaultBG +let bodyBottom = RGB(0x0A_0A_0A) // defaultBG, shaded +let tagBar = RGB(0x34_65_A4) // ansi16[4], the muted blue +let text = RGB(0xCC_CC_CC) // defaultFG +let cursor = RGB(0xFC_E9_4F) // ansi16[11], bright yellow + +// Apple's icon grid rather than the whole square: the artwork is a rounded +// square floating in a transparent margin, 824 of 1024 with a 185.4 corner +// radius in the template — 80.47% of the canvas, and 22.37% of the SQUARE, not +// of the canvas, which would over-round it by a quarter. Filling the canvas +// edge to edge is the loudest tell that an app was not built on a Mac. +let squareFraction: CGFloat = 0.8047 +let cornerFraction: CGFloat = 0.2237 + +// The composition, as fractions of the body square. It has to survive being +// twelve pixels across, so it is four bands and nothing else: the full-width +// tag bar, a dim line for the pane that does not hold the keyboard, a gutter +// wide enough to read as the boundary between panes, then the focused pane's +// line with the block cursor parked at its end. Three shapes under the bar is +// the entire budget; a fourth is grey mush at 16 pixels. +let tagHeight: CGFloat = 0.165 +let sidePad: CGFloat = 0.145 +let lineHeight: CGFloat = 0.085 +let dimLineTop: CGFloat = 0.355 +let dimLineWidth: CGFloat = 0.505 +let dimLineAlpha: CGFloat = 0.52 +let liveLineTop: CGFloat = 0.645 +let liveLineWidth: CGFloat = 0.300 +let liveLineAlpha: CGFloat = 0.72 +let cursorLeft: CGFloat = 0.515 +// A cell, not a square: a block cursor covers a whole character box, so it is +// wider than the gap before it and well over twice the height of the ink it +// sits on. The exact numbers are also the ones that survive rounding — 0.205 +// of a twelve-pixel body spans 2.46 pixels, which lands on two rows wherever +// the top edge falls, whereas anything near 0.155 collapses to one row for +// half the possible alignments and the block turns into a dash. +let cursorWidth: CGFloat = 0.130 +let cursorHeight: CGFloat = 0.205 + +/// sRGB in the bitmap and sRGB in every colour put into it. `setFillColor(red: +/// green:blue:alpha:)` speaks DeviceRGB, which is a colour match on the way in, +/// and #121212 would stop being #121212. +func sRGB() -> CGColorSpace { + guard let space = CGColorSpace(name: CGColorSpace.sRGB) else { die("sRGB colour space unavailable") } + return space +} + +func cgColor(_ rgb: RGB, alpha: CGFloat = 1) -> CGColor { + guard let color = CGColor(colorSpace: sRGB(), components: rgb.components(alpha)) else { + die("CGColor from sRGB components failed") + } + return color +} + +/// Whole device pixels, or the 16pt render turns each one-pixel bar into two +/// rows of half-lit grey and the whole mark goes soft. Rounding the edges +/// rather than the size is what keeps the gaps between bands even. +func snap(_ rect: CGRect) -> CGRect { + let left = rect.minX.rounded() + let top = rect.minY.rounded() + return CGRect( + x: left, y: top, + width: max(1, rect.maxX.rounded() - left), + height: max(1, rect.maxY.rounded() - top)) +} + +func renderIcon(pixels: Int) -> CGImage { + guard + let ctx = CGContext( + data: nil, width: pixels, height: pixels, + bitsPerComponent: 8, bytesPerRow: 0, space: sRGB(), + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue) + else { die("CGContext \(pixels)x\(pixels) failed") } + + // Top-left origin, so the constants above read in the order the picture + // does. The scale stays ±1, which is what lets snap() round in user space. + ctx.translateBy(x: 0, y: CGFloat(pixels)) + ctx.scaleBy(x: 1, y: -1) + + // Round the MARGIN and derive the square from it. Rounding the square + // instead leaves an odd remainder to split, and at 16 pixels the artwork + // lands a pixel off centre. At 1024 this is Apple's 100/824/100 exactly. + let canvas = CGFloat(pixels) + let inset = (canvas * (1 - squareFraction) / 2).rounded() + let side = canvas - 2 * inset + let body = CGRect(x: inset, y: inset, width: side, height: side) + let corner = side * cornerFraction + + ctx.saveGState() + ctx.addPath(CGPath(roundedRect: body, cornerWidth: corner, cornerHeight: corner, transform: nil)) + ctx.clip() + + // The only gradient in the icon, and it earns its place: a flat near-black + // square reads as a hole punched in the Dock rather than as an object. + let stops = bodyTop.components(1) + bodyBottom.components(1) + let locations: [CGFloat] = [0, 1] + guard + let gradient = CGGradient( + colorSpace: sRGB(), colorComponents: stops, locations: locations, count: 2) + else { die("CGGradient failed") } + ctx.drawLinearGradient( + gradient, + start: CGPoint(x: body.midX, y: body.minY), + end: CGPoint(x: body.midX, y: body.maxY), + options: []) + + // Full bleed, and still inside the clip so its top corners round with the + // body. src/pardes.zig fills row 0 across the whole width the same way; + // that strip is the silhouette of an acme screen and it is the one thing + // that has to survive being two pixels tall. + ctx.setFillColor(cgColor(tagBar)) + ctx.fill( + CGRect( + x: body.minX, y: body.minY, + width: side, height: max(1, (side * tagHeight).rounded()))) + ctx.restoreGState() + + // Two panes. The gap between the lines is deliberately the widest space in + // the icon: it is the pane boundary, and proximity is the only way to say + // so without spending the fourth shape on a divider. + let leftEdge = body.minX + side * sidePad + ctx.setFillColor(cgColor(text, alpha: dimLineAlpha)) + ctx.fill( + snap( + CGRect( + x: leftEdge, y: body.minY + side * dimLineTop, + width: side * dimLineWidth, height: side * lineHeight))) + + ctx.setFillColor(cgColor(text, alpha: liveLineAlpha)) + ctx.fill( + snap( + CGRect( + x: leftEdge, y: body.minY + side * liveLineTop, + width: side * liveLineWidth, height: side * lineHeight))) + + // Centred on the line it follows and taller than it, the way a block cursor + // covers a whole cell rather than the ink in it. Full-strength yellow + // against the blue is what tells you at a glance which pane has focus. + let cursorTop = liveLineTop + (lineHeight - cursorHeight) / 2 + ctx.setFillColor(cgColor(cursor)) + ctx.fill( + snap( + CGRect( + x: body.minX + side * cursorLeft, y: body.minY + side * cursorTop, + width: side * cursorWidth, height: side * cursorHeight))) + + guard let image = ctx.makeImage() else { die("CGContext.makeImage failed at \(pixels)") } + return image +} + +func writePNG(_ image: CGImage, to url: URL) { + guard + let sink = CGImageDestinationCreateWithURL( + url as CFURL, UTType.png.identifier as CFString, 1, nil) + else { die("cannot open \(url.path) for writing") } + CGImageDestinationAddImage(sink, image, nil) + guard CGImageDestinationFinalize(sink) else { die("encoding \(url.lastPathComponent) failed") } +} + +// The ten names iconutil demands, spelled out rather than derived: the set is +// fixed by the tool, and a loop that generated them would be a loop to read +// before believing the list. 32, 256 and 512 appear twice under two names and +// are simply drawn twice — a third of a megapixel, against the clarity of not +// caching anything in a build tool. +let variants: [(name: String, pixels: Int)] = [ + ("icon_16x16.png", 16), + ("[email protected]", 32), + ("icon_32x32.png", 32), + ("[email protected]", 64), + ("icon_128x128.png", 128), + ("[email protected]", 256), + ("icon_256x256.png", 256), + ("[email protected]", 512), + ("icon_512x512.png", 512), + ("[email protected]", 1024), +] + +let arguments = CommandLine.arguments +guard arguments.count == 2 else { + die("usage: \(URL(fileURLWithPath: arguments.first ?? "icon").lastPathComponent) <output-directory>") +} + +let files = FileManager.default +let outputDir = URL(fileURLWithPath: arguments[1], isDirectory: true) +let output = outputDir.appendingPathComponent("pardes.icns") + +// A fixed scratch path, cleared before use rather than a unique one: a run that +// died half way leaves a partial iconset behind, and iconutil would happily +// fold the stale sizes into the next icns without a word. +let scratch = files.temporaryDirectory.appendingPathComponent("pardes-icon", isDirectory: true) +let iconset = scratch.appendingPathComponent("pardes.iconset", isDirectory: true) + +try? files.removeItem(at: scratch) +do { + try files.createDirectory(at: iconset, withIntermediateDirectories: true) + try files.createDirectory(at: outputDir, withIntermediateDirectories: true) +} catch { + die("cannot create \(iconset.path): \(error.localizedDescription)") +} + +for variant in variants { + writePNG(renderIcon(pixels: variant.pixels), to: iconset.appendingPathComponent(variant.name)) +} + +let iconutil = Process() +iconutil.executableURL = URL(fileURLWithPath: "/usr/bin/iconutil") +iconutil.arguments = ["-c", "icns", "-o", output.path, iconset.path] +do { + try iconutil.run() +} catch { + die("cannot run /usr/bin/iconutil: \(error.localizedDescription)") +} +iconutil.waitUntilExit() +guard iconutil.terminationStatus == 0 else { + die("iconutil exited \(iconutil.terminationStatus) over \(iconset.path)") +} + +try? files.removeItem(at: scratch) diff --git a/src/macos/pardes.h b/src/macos/pardes.h index e0d6fa32..62ddbae5 100644 --- a/src/macos/pardes.h +++ b/src/macos/pardes.h @@ -118,6 +118,15 @@ typedef enum { PARDES_MOUSE_DRAG = 3, } pardes_mouse_kind_e; +// What the core just did, for a shell that can answer with something the hand +// feels. Taken with pardes_take_haptic once per pump; the two acme verbs are +// distinguished because they deserve distinct taps. +typedef enum { + PARDES_HAPTIC_NONE = 0, + PARDES_HAPTIC_EXEC = 1, + PARDES_HAPTIC_LOOK = 2, +} pardes_haptic_e; + // ---------------------------------------------------------------- runtime // What the host lends the core. Two callbacks, because everything else the @@ -149,8 +158,14 @@ int pardes_init(const pardes_runtime_s *runtime, uint16_t cols, uint16_t rows); void pardes_deinit(void); // Drain pty output into the core and perform the effects it queued. Call after -// every input function and on every wakeup. Returns true if anything changed -// and the host should mark its view dirty. +// every input function and on every wakeup. +// +// The return value is whether this tick did any IO — bytes arrived from a pty, +// or an effect was performed. It is NOT a repaint signal, and a host that uses +// it as one shows a stale screen: moving the cursor, extending a selection, +// changing mode and scrolling all mutate the grid while queueing nothing and +// performing nothing, so they tick false. Mark the view dirty after any call +// into the core and use this only to decide whether there was work. bool pardes_tick(void); // The core asked to exit (the Exit builtin, or the last pane closing). @@ -169,14 +184,30 @@ void pardes_paste(const char *text, size_t len); void pardes_mouse(pardes_mouse_button_e button, pardes_mouse_kind_e kind, uint16_t col, uint16_t row, uint32_t mods); -// Trackpad/precision wheel distance in rows, sign following the grid (positive -// scrolls down). The core has no fractional scroll — it moves a row at a time — -// so libpardes accumulates here and emits whole-row wheel presses, keeping the -// remainder. Both other shells do this same accumulation host-side (stepScroll -// in gui.zig, the drain loop in web/app.mjs); it lives in Zig here so the Swift -// side stays a translator. A discrete wheel notch should go through -// pardes_mouse instead. -void pardes_scroll(float delta_rows, uint16_t col, uint16_t row); +// Trackpad/precision wheel distance in CELLS, sign following the grid +// (positive scrolls down and right). The core has no fractional scroll — it +// moves a row or a column at a time — so libpardes accumulates here and emits +// whole wheel presses, keeping the remainder. Both other shells do this same +// accumulation host-side (stepScroll in gui.zig, the drain loop in +// web/app.mjs); it lives in Zig here so the Swift side stays a translator, and +// so the quantizer is unit-tested on a machine with no trackpad. A discrete +// wheel notch should go through pardes_mouse instead. +void pardes_scroll(float delta_rows, float delta_cols, uint16_t col, + uint16_t row); + +// A two-finger trackpad rotation, in degrees since the last call, positive +// counterclockwise (AppKit's sign, unchanged). The core has no rotation: this +// is spent as the search-step keys, clockwise `n` and counterclockwise `N`, a +// notch at a time with the remainder kept — the same accumulate-and-spend +// shape as pardes_scroll, and in Zig for the same reason. Feed it the raw +// per-event delta; pass 0 at gesture start to drop a stale remainder. +void pardes_rotate(float degrees); + +// Run one builtin command line, exactly as executing the same text in a tag +// would. This is the core's own `command` event, which is how a nested pardes +// talks to its host; here it is what a menu item is made of, and what opens +// the file named on argv or dropped on the Dock icon (`Look <path>`). +void pardes_command(const char *text, size_t len); // `cell_w`/`cell_h` are one cell in physical pixels, which only the native PDF // placement path reads. Pass the backing-store size, not points. @@ -197,6 +228,29 @@ int32_t pardes_cursor_x(void); int32_t pardes_cursor_y(void); bool pardes_cursor_bar(void); +// The Look or Exec the core performed since this was last asked, and clears +// it. Call it once per pump, after pardes_tick — the input functions run the +// dispatch synchronously, so a gesture's pulse is already waiting by the time +// its tick returns. PARDES_HAPTIC_NONE means nothing to feel. +pardes_haptic_e pardes_take_haptic(void); + +// The font file the `Font` builtin asked for since this was last called, and +// clears it; NULL when nothing was asked. Ask once per pump, beside the haptic +// above. The string is a NUL-terminated absolute path owned by libpardes and +// valid until the next call. +// +// A path rather than a family name: the core found the file by walking the +// font directories itself, so both shells agree on which faces exist and +// neither has to ask its platform to resolve a name it might resolve +// differently. A `.ttc` collection names its first face, which is the cut the +// file is named after. +// +// The host loads it, re-measures its cell, and reports the new grid through +// pardes_resize. A file the host cannot load is one to ignore: keep wearing +// the face that works, because a terminal that cannot draw has no way back +// out of it. +const char *pardes_font_take(void); + #ifdef __cplusplus } #endif |
