From 3f2d6f43199d0e230490396deb50f8dc49c7b8b0 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Tue, 1 Sep 2026 14:34:19 -0300 Subject: hosts: the effects three shells kept a copy of become one, and the mac's own bugs go with them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nine read-only scouts compared every host-side concern across `src/macos.zig`, `src/tty/tty.zig`, `src/gui/gui.zig` and `src/detached/server.zig`. What they found was not a style problem: each duplicated body had drifted, and in every case the drift WAS a bug the users of that shell could see. So the fixes and the deduplication are the same change. **One PATH, adopted before the first fork.** LaunchServices hands a bundle launchd's environment, whose `PATH` is `/usr/bin:/bin:/usr/sbin:/sbin`. Every pty shell, `|` filter and language server the app forked inherited it, so `yazi` in `/opt/homebrew/bin` was absent from a Dock launch and present in the identical binary run from a terminal — the "it worked briefly" window was simply the sessions started from a shell. `shell_bin.adoptSystemPath` composes `/etc/paths` then `/etc/paths.d/*` in the order `path_helper` reads them, deduplicating on first occurrence, and runs once at startup in all four native hosts. It APPENDS: an entry already present keeps its position, so running it over a real session cannot demote a mise shim behind `/usr/bin` and silently change which `node` runs. A `PATH` that was configured is left byte-for-byte alone; only one nobody configured is repaired. `prepareForFork` folds that adoption together with the prompt-rc staging and the `BASH_SILENCE_DEPRECATION_WARNING` setenv the five hand-copied prefork sites had between them — `server.zig` had none of it, which is why every detached pane opened with Apple's zsh banner. **The LSP protocol client never worked on macOS.** It opened its control socket with `libc.SOCK.CLOEXEC`; Zig defines that constant for Linux and Darwin answers `socketpair` with `EPROTONOSUPPORT`, so the call failed before any fork, `ensure` returned `error.NoServer`, and every row in the spec table — rust-analyzer, clangd, gopls — was unreachable in every macOS build. The in-process ZLS backend kept answering, which is what made it read as "only Zig is supported". It is a plain socket plus `fcntl(FD_CLOEXEC)` now, the route `fuse.zig:943` and `nested.zig:95` already took for the same reason. The snapshot suite that covered this path had never run natively on a Mac: the harness targets defaulted to x86_64-linux. **One LSP host worker.** `src/lsp_host.zig` is the snapshot, the worker body and the job lifetime that `tty.zig` and `gui.zig` carried verbatim — `gui.zig` said so in a comment — and that `macos.zig` did not carry at all: `lsp` and `pipe` were absent from its `Host.VTable`, so the core answered its own empty answer, `SPC l i` rendered a blank panel and a `|` filter silently did nothing. All three shells share the module, and the AppKit host implements both effects. Its status sink is now REGISTERED as well as defined, so unsolicited server news reaches the message row instead of nowhere. **The animation clock measures time.** `pardes_animation_tick` advanced one scene frame per callback and published `frame_count / 60`, so scene time was a count of callbacks rather than elapsed seconds — and `AppDelegate` re-armed `asyncAfter(.now() + 0.016)` only after the previous frame's work had finished, making the true period 16 ms plus all of it. Motion ran at about three quarters of wall clock and unevenly. The tick now spends measured monotonic time in whole `frame_ns` steps and banks the remainder, so a late callback advances two frames instead of stretching one; `spendTickTime` is that arithmetic as a pure function with its own tests and no display attached. On macOS 14+ the animating run is one `CADisplayLink` phase-locked to vsync rather than a chain rebuilt after every frame; macOS 13 keeps the old chain. **Three more single definitions.** `panel_animation.paintOrder` is the moving-then-opening-then-closing composite order as a rule the core applies once in `Pardes.render` — `macos.zig` was re-sorting an already-sorted list. `selection_pipe.Tasks` is the bounded in-flight pipe table `tty.zig` and `gui.zig` each declared. `boxContains` was a fourth copy of the half-open cell test and is now an alias of `Box.contains`. **A filtered terminal stops asking libm per cell.** `Filter`'s legibility stage called `RGB.contrast` for every painted cell, and that ends in `std.math.pow` up to six times, re-deriving a ratio against a background that had not moved; the existing memo cache covered the palette reduction beside it and never this. The indexed path's input is a `u8`, so all 256 answers are enumerated once per pass — after the default roles are fixed, before the first cell is read — and what a cell names becomes an array index. Only truecolour still reduces. ReleaseFast, 190x56, Tracy: recolour 3.09 ms -> 0.130 ms, frame 3.37 ms -> 0.299 ms. The comptime luminance table is pinned to `RGB.luminance` and `RGB.contrast` by exact-equality test over every channel value and all 65 536 palette pairs, because the decision is a threshold comparison where one ULP is a different colour. A `filterInit` Tracy zone records the part that is still per-pass: 2.9 us warm against a 117 us pass, which is the measurement that says not to cache it across frames. Released as 0.0.2. `build.zig.zon` carries the version into `pardes --version` and into the `Changelog` pane through `@embedFile`, so the entries above open a `## 0.0.2` section and `## 0.0.1` closes with the tagline work of the parent commit. Two bugs here were mine, caught by review rather than by me: a double free in the macOS pipe drain arm (`Msg.free` already owns the response) that segfaulted the app on the first `|`, and a proposed `getRowAndCell` optimisation that targeted 2 of 43 draw samples while the contrast math beside it took 12 — and would not have compiled. The profile that justified it was a Debug build, which `build.zig:1160` already documents as ~5x slower than release. Native and -Dplatform=macos suites: 0 failures. All targets build with Tracy on and off; the shipped release binary contains no `___tracy_emit_zone_begin`. App reinstalled, signature verified, dmg regenerated, launched with 0 crash reports; installed binaries verified byte-identical to a fresh build. --- src/macos/Sources/AppDelegate.swift | 63 ++++++++- src/macos/Sources/PardesView.swift | 237 ++++++++++++++++++++++++++++++--- src/macos/icon.png | Bin 0 -> 1788282 bytes src/macos/icon.swift | 252 ++++++++++-------------------------- src/macos/pardes.h | 32 +++++ 5 files changed, 378 insertions(+), 206 deletions(-) create mode 100644 src/macos/icon.png (limited to 'src/macos') diff --git a/src/macos/Sources/AppDelegate.swift b/src/macos/Sources/AppDelegate.swift index 0cecd5da..2d411873 100644 --- a/src/macos/Sources/AppDelegate.swift +++ b/src/macos/Sources/AppDelegate.swift @@ -22,6 +22,22 @@ final class AppDelegate: NSObject, NSApplicationDelegate, PardesViewDelegate { // call pump(), but none advances animation; they only observe this flag and // leave the already-scheduled frame alone. private var pumpScheduled: Bool = false + /// The display's own clock, when the OS will lend us one. + /// + /// The fallback below is a `DispatchQueue.asyncAfter(0.016)` chain re-armed + /// AFTER each frame's tick, drain and draw, so its real period is 16 ms plus + /// all of that — comfortably slower than 60 Hz, and jittery, and never in + /// phase with the refresh. That is what makes an animation look choppy even + /// when nothing is dropping frames. A display link fires once per refresh, + /// phase-locked to vsync, which is the cadence the picture is actually + /// presented at. + /// + /// Cadence is now only a SMOOTHNESS question: `pardes_animation_tick` + /// spends measured elapsed time, so a 120 Hz link does not double-speed an + /// animation and a slow one does not halve it. + /// Held as `AnyObject` because a stored property cannot carry + /// `@available`, and this file still deploys to macOS 13. + private var displayLink: AnyObject? // 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 @@ -585,25 +601,62 @@ final class AppDelegate: NSObject, NSApplicationDelegate, PardesViewDelegate { } private func scheduleAnimationFrame() { - guard pardes_animating() && !pumpScheduled else { return } + guard pardes_animating() else { + stopDisplayLink() + return + } + if #available(macOS 14.0, *) { + // One link for the whole animating run, not one callback armed per + // frame: re-arming after the work is what put the period at 16 ms + // PLUS the work, and no amount of tuning that constant fixes a + // clock that is not the display's. + if displayLink == nil { + let link = view.displayLink(target: self, selector: #selector(displayLinkFired)) + link.add(to: .main, forMode: .common) + displayLink = link + } + return + } + // macOS 13 has no NSView display link. Keep the old chain, which is now + // only a cadence compromise rather than a correctness one — the tick + // spends measured time either way. + guard !pumpScheduled else { return } let awaitingPresentation = view.presentationSerial pumpScheduled = true DispatchQueue.main.asyncAfter(deadline: .now() + 0.016) { + self.pumpScheduled = false self.animationFrame(after: awaitingPresentation) } } + private func stopDisplayLink() { + if #available(macOS 14.0, *) { (displayLink as? CADisplayLink)?.invalidate() } + displayLink = nil + } + + @objc private func displayLinkFired() { + guard coreIsUp else { return } + animationFrame(after: pendingPresentation) + } + + /// The presentation serial the last dirtied sample is waiting on. Held + /// across display-link callbacks because the link, unlike the old chain, + /// does not carry it in a closure. + private var pendingPresentation: UInt64 = 0 + private func animationFrame(after awaitingPresentation: UInt64) { - pumpScheduled = false guard view.presentationSerial != awaitingPresentation else { // Keep the dirty sample pending. AppKit may have coalesced this - // draw or the window may be occluded; retry the clock without - // advancing until draw(_:) supplies the presentation permit. + // draw or the window may be occluded; retry on the next refresh + // without advancing, until draw(_:) supplies the presentation + // permit. On the link that costs ONE refresh; the old chain paid a + // fresh 16 ms for it, which is where the visible hitching came from. view.needsDisplay = true - scheduleAnimationFrame() + if #available(macOS 14.0, *) {} else { scheduleAnimationFrame() } return } _ = pardes_animation_tick() + pendingPresentation = view.presentationSerial pump() } diff --git a/src/macos/Sources/PardesView.swift b/src/macos/Sources/PardesView.swift index 10c7fd8b..3f5f1346 100644 --- a/src/macos/Sources/PardesView.swift +++ b/src/macos/Sources/PardesView.swift @@ -191,6 +191,125 @@ private func advance(_ font: CTFont, _ character: UniChar) -> CGFloat { /// number is how "actual size" stops being the size it actually opened at. let defaultFontSize: CGFloat = 14 +/// The faces CoreText should reach for when the grid face has no glyph. +/// +/// The SDL shell loads a chain of fallback faces itself and rasterizes from +/// whichever one has the codepoint. This shell draws its non-ASCII through +/// CTLine, which already walks a cascade — but the SYSTEM cascade, and the +/// system cascade has never heard of a Nerd Font. Measured on a machine with +/// Mononoki Nerd Font installed: U+E0B0, U+E5FF, U+E700 and U+F015 all resolved +/// to `LastResort`, which is the tofu box. Braille, emoji and CJK resolved +/// fine, which is exactly why this went unnoticed — everything Unicode has an +/// opinion about already worked, and only the Private Use Area did not. +/// +/// Two sources, in this order: +/// +/// 1. `fonts.fallback_names` over the C ABI, so the PREFERENCE ORDER is the +/// one the SDL shell uses and lives in one file. +/// 2. Installed Nerd Font families, found by name and then CONFIRMED BY +/// COVERAGE. Both halves are load-bearing. Coverage alone is not enough: a +/// scan of all 250 families here put `Hannotate TC` and `HanziPen TC` at +/// 4 of 6 probes, because CJK faces map the PUA for their own purposes and +/// would answer a powerline request with an unrelated ideograph. A name +/// alone is not enough either, because "Nerd Font" in a family name is a +/// convention, not a guarantee. So the name decides what a codepoint MEANS +/// and coverage decides whether the face can actually draw it. +/// +/// Computed once. The result is a list of descriptors, which carry no size, so +/// a zoom or a `Font` command reuses it; only installing a font invalidates it, +/// and that is a relaunch. +private enum FontFallbacks { + /// Representative codepoints, one per Nerd Font block that matters: + /// powerline separators, Seti file icons, devicons, Font Awesome. A face + /// answering all four is patched; a face answering one is a coincidence. + private static let nerdProbes: [UInt32] = [0xE0B0, 0xE5FF, 0xE700, 0xF015] + + static let descriptors: [CTFontDescriptor] = build() + + private static func build() -> [CTFontDescriptor] { + var out: [CTFontDescriptor] = [] + var seen = Set() + + func take(_ family: String, _ descriptor: CTFontDescriptor) { + guard seen.insert(family).inserted else { return } + out.append(descriptor) + } + + // 1. The shared preference order. + for index in 0.. 0 else { continue } + let name = String(decoding: UnsafeRawBufferPointer(start: bytes, count: Int(length)), as: UTF8.self) + guard let found = resolve(name) else { continue } + take(found.family, found.descriptor) + } + + // 2. Nerd Fonts that are installed AND cover the blocks. `Mono` cuts + // first: this is a fixed grid, and the propo/variable cuts of the + // same family are drawn to different advances. + let families = (CTFontManagerCopyAvailableFontFamilyNames() as? [String]) ?? [] + let nerd = families.filter { $0.range(of: "nerd font", options: .caseInsensitive) != nil } + for family in nerd.sorted(by: { rank($0) < rank($1) }) { + let descriptor = CTFontDescriptorCreateWithAttributes( + [kCTFontFamilyNameAttribute: family] as CFDictionary) + guard coverage(descriptor, nerdProbes) == nerdProbes.count else { continue } + take(family, descriptor) + } + return out + } + + /// "Mono" before "Propo" before the proportional cut. + private static func rank(_ family: String) -> Int { + if family.range(of: "nerd font mono", options: .caseInsensitive) != nil { return 0 } + if family.range(of: "nerd font propo", options: .caseInsensitive) != nil { return 2 } + return 1 + } + + /// A descriptor that really is the font asked for. CoreText SUBSTITUTES + /// rather than failing — asking it for an uninstalled `SymbolsNerdFont- + /// Regular` hands back Helvetica, and a cascade seeded with Helvetica is a + /// cascade that answers every missing glyph with the wrong one. + private static func resolve(_ name: String) -> (family: String, descriptor: CTFontDescriptor)? { + for attribute in [kCTFontNameAttribute, kCTFontFamilyNameAttribute] { + let query = CTFontDescriptorCreateWithAttributes([attribute: name] as CFDictionary) + guard let match = CTFontDescriptorCreateMatchingFontDescriptor(query, nil) else { continue } + let postScript = CTFontDescriptorCopyAttribute(match, kCTFontNameAttribute) as? String ?? "" + let family = CTFontDescriptorCopyAttribute(match, kCTFontFamilyNameAttribute) as? String ?? "" + guard postScript.compare(name, options: .caseInsensitive) == .orderedSame + || family.compare(name, options: .caseInsensitive) == .orderedSame + else { continue } + return (family.isEmpty ? postScript : family, match) + } + return nil + } + + /// How many of `codepoints` this face can actually draw. Size is irrelevant + /// to coverage, so the probe face is built at a nominal one. + private static func coverage(_ descriptor: CTFontDescriptor, _ codepoints: [UInt32]) -> Int { + let font = CTFontCreateWithFontDescriptor(descriptor, 12, nil) + var hits = 0 + for codepoint in codepoints { + guard let scalar = UnicodeScalar(codepoint) else { continue } + var units = Array(String(scalar).utf16) + var glyphs = [CGGlyph](repeating: 0, count: units.count) + if CTFontGetGlyphsForCharacters(font, &units, &glyphs, units.count) { hits += 1 } + } + return hits + } + + /// `face` with the chain attached. Every face pardes draws with goes through + /// here exactly once, at the base: `CTFontCreateCopyWithSymbolicTraits` and + /// `CTFontCreateCopyWithAttributes` both carry the cascade into the copy, so + /// the bold/italic cuts and the smaller tagline cuts inherit it. + static func attach(to face: CTFont, size: CGFloat) -> CTFont { + guard !descriptors.isEmpty else { return face } + let descriptor = CTFontDescriptorCreateCopyWithAttributes( + CTFontCopyFontDescriptor(face), + [kCTFontCascadeListAttribute: descriptors] as CFDictionary) + return CTFontCreateWithFontDescriptor(descriptor, size, nil) + } +} + /// 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. @@ -221,7 +340,12 @@ private struct Metrics { init(size: CGFloat, path: String?, scale: CGFloat) { let requested = path.flatMap { Metrics.fromFile($0, size) } - let face = requested ?? Metrics.defaultFace(size: size) + // Attached ONCE, at the base: the trait and size copies below inherit + // the cascade, so every cut and the tagline's smaller cuts reach the + // same fallbacks. Metrics is measured from `face` too, and a cascade + // changes no metric — CoreText measures the primary face and only + // consults the chain for a codepoint it lacks. + let face = FontFallbacks.attach(to: requested ?? Metrics.defaultFace(size: size), size: size) let scale = max(1, scale) // UNVERIFIED: CTFontSymbolicTraits member spelling (.traitBold/.traitItalic). @@ -331,7 +455,13 @@ private struct TaglineMetrics { /// band's top offset the way it used to be: that offset is per-row now, and /// a baseline carrying one row's offset would pin every band back to centre. let ascent: CGFloat - let xOffset: CGFloat + /// The tagline face's OWN advance, clamped to the body cell it sits in. + /// A tag row steps by THIS, not by the body cell width: the band behind it + /// is still pane-wide on the body grid, but the text on top of it tracks at + /// the smaller face's own pitch. Centring a smaller glyph inside a + /// body-width cell instead — which is what this shell used to do — leaves + /// the tag text visibly looser than the same session in an SDL window. + let width: CGFloat let height: CGFloat let asciiGlyphs: [[CGGlyph]] /// Kept so the band rules below can work in the physical pixels the core @@ -358,7 +488,7 @@ private struct TaglineMetrics { // over the body row below it. height = min(body.cellHeight, measuredHeight) ascent = max(1 / scale, snap(CTFontGetAscent(face), .toNearestOrAwayFromZero)) - xOffset = snap(max(0, (body.cellWidth - advance(face, 0x4D)) / 2), .toNearestOrAwayFromZero) + width = min(body.cellWidth, max(1 / scale, snap(advance(face, 0x4D), .toNearestOrAwayFromZero))) asciiGlyphs = faces.map { font in var chars = Array(UniChar(0).. CGFloat { + let origin = CGFloat(pardes_tagline_origin_col(UInt16(clamping: col), UInt16(clamping: row))) + return origin * bodyCellWidth + (CGFloat(col) - origin) * width + } + /// Thickness of the rule joining the topbar band to the first pane-tag band, /// zero when the two are meant to join directly. var borderThickness: CGFloat { @@ -830,6 +974,14 @@ final class PardesView: NSView { // Band geometry is stated against the whole canvas: the last row's rule // depends on where the window edge is, not just on the row index. let canvasHeight = bounds.height + // The tag band's own colour, fetched once per frame. A compact tag row + // is painted in TWO passes — pane-wide band on the body grid, then each + // cell's own background on the narrower grid its glyph uses — which is + // what the SDL shell spends its second quad per tagline cell on. Before + // there is a session to ask there is no base and the single body-grid + // fill below is all there is. + let taglineBaseBG = pardes_tagline_bg() + let taglineTwoPass = taglineBaseBG != UInt32(PARDES_COLOR_DEFAULT) for row in 0.., base: Int, cols: Int, + /// This row's grid index. Needed per cell rather than per row for the + /// compact tagline anchor: a column split puts two panes' tags side by + /// side on ONE row, so the origin is a question about the cell. + row: Int, baseline: CGFloat, /// Where this row's tagline band starts, from the row's top. Passed in /// rather than recomputed per cell: it is one answer per row, and the @@ -1164,7 +1348,14 @@ final class PardesView: NSView { // never the hole in the ground. let style = resolve(cell, block: blockCol == col, ground: themeBG ?? pardesDefaultBG, clearGround: false) - let x = CGFloat(col) * cellWidth + // Tagline cells step on the smaller face's own pitch, anchored at + // their pane; everything else on the body grid. The rules go with + // the glyph, not with the body cell, or an underlined tag word ends + // up underlining its neighbour. + let x = tagline + ? taglines.glyphX(col: col, row: row, bodyCellWidth: cellWidth) + : CGFloat(col) * cellWidth + let advanceWidth = tagline ? taglines.width : 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 @@ -1172,7 +1363,7 @@ final class PardesView: NSView { if cell.attrs >> UInt16(PARDES_ATTR_UL_SHIFT) != 0 || cell.attrs & UInt16(PARDES_ATTR_STRIKETHROUGH) != 0 { flush() - drawRules(ctx, cell, style, x: x, baseline: cellBaseline) + drawRules(ctx, cell, style, x: x, width: advanceWidth, baseline: cellBaseline) } guard style.visible else { continue } @@ -1190,7 +1381,6 @@ final class PardesView: NSView { italic: cell.attrs & UInt16(PARDES_ATTR_ITALIC) != 0) let units = text.utf16 let known = units.count == 1 ? glyph(face, units.first!, tagline: tagline) : 0 - let glyphX = x + (tagline ? taglines.xOffset : 0) if known != 0 { if !runGlyphs.isEmpty && (face != runFace || tagline != runTagline @@ -1202,7 +1392,7 @@ final class PardesView: NSView { runColor = style.fg runAlpha = style.alpha runGlyphs.append(known) - runPositions.append(CGPoint(x: glyphX, y: cellBaseline)) + runPositions.append(CGPoint(x: x, y: cellBaseline)) continue } @@ -1214,7 +1404,7 @@ final class PardesView: NSView { setFill(ctx, style.fg, style.alpha) let font = tagline ? taglines.fonts[face.rawValue] : metrics.fonts[face.rawValue] let attributed = NSAttributedString(string: text, attributes: [fontAttribute: font]) - ctx.textPosition = CGPoint(x: glyphX, y: cellBaseline) + ctx.textPosition = CGPoint(x: x, y: cellBaseline) 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 @@ -1256,24 +1446,28 @@ final class PardesView: NSView { _ cell: pardes_cell_s, _ style: (fg: UInt32, bg: UInt32, alpha: CGFloat, visible: Bool), x: CGFloat, + /// The cell's advance: the body cell, or the narrower tagline one. A + /// rule is as wide as the character it belongs to, and on a tag row + /// that stopped being the body cell when the text compacted. + width: CGFloat, baseline: CGFloat ) { let underline = Int(cell.attrs >> PARDES_ATTR_UL_SHIFT) & 7 if underline != Int(PARDES_UL_OFF) { let y = baseline + metrics.underlineOffset - fill(ctx, CGRect(x: x, y: y, width: cellWidth, height: metrics.ruleThickness), style.fg, style.alpha) + fill(ctx, CGRect(x: x, y: y, width: width, 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 - metrics.ruleThickness * 2, width: cellWidth, height: metrics.ruleThickness), + fill(ctx, CGRect(x: x, y: y - metrics.ruleThickness * 2, width: width, height: metrics.ruleThickness), style.fg, style.alpha) } } if cell.attrs & UInt16(PARDES_ATTR_STRIKETHROUGH) != 0 { // Rounded like every other rule offset: a third of the ascent is a // fraction, and a fractional one-pixel bar is a two-pixel smear. - fill(ctx, CGRect(x: x, y: baseline + (metrics.ascent * 0.3).rounded(), width: cellWidth, height: metrics.ruleThickness), + fill(ctx, CGRect(x: x, y: baseline + (metrics.ascent * 0.3).rounded(), width: width, height: metrics.ruleThickness), style.fg, style.alpha) } } @@ -1670,8 +1864,15 @@ final class PardesView: NSView { } else { sampled = point } - let col = min(max(Int(sampled.x / cellWidth), 0), cols - 1) let row = min(max(Int(sampled.y / cellHeight), 0), rows - 1) + // The column comes from the core, because a tag row's glyphs step at + // the tagline face's narrower pitch and a body-pitch click drifts one + // word further right for every word along the row. The SDL shell asks + // the identical rule (`pardes.gridColAt`). Points on both sides: only + // the ratio of x to the two widths is read. + let col = min(max(Int(pardes_grid_col_at( + Float(sampled.x), UInt16(clamping: row), + Float(cellWidth), Float(taglines.width))), 0), cols - 1) return GridPoint(col: UInt16(col), row: UInt16(row)) } diff --git a/src/macos/icon.png b/src/macos/icon.png new file mode 100644 index 00000000..9889c348 Binary files /dev/null and b/src/macos/icon.png differ diff --git a/src/macos/icon.swift b/src/macos/icon.swift index 5c7dd881..2f45220f 100644 --- a/src/macos/icon.swift +++ b/src/macos/icon.swift @@ -1,22 +1,29 @@ -// Draws pardes.app's icon at build time and hands the result to iconutil. +// Cuts pardes.app's icon out of a committed drawing and hands the result to +// iconutil. // -// The mark is GLENDA, the Plan 9 rabbit — pardes is an acme, and acme is -// Plan 9's, so the bunny is the lineage stated in one shape. She is drawn out -// of the terminal's own palette rather than traced from a bitmap: the ground -// is defaultBG, the strip she sits under is the tag bar, and she herself is -// defaultFG. That is also why this is generated instead of committed — 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. +// The mark is still GLENDA, the Plan 9 rabbit — pardes is an acme, and acme is +// Plan 9's, so the bunny is the lineage stated in one shape. What changed is +// where she comes from: she used to be drawn here out of the terminal's own +// palette, four ellipses and a tag bar, and she is now `icon.png` beside this +// file. A drawing is not derivable from a palette, so the old argument for +// generating her ("a checked-in .icns stops matching the app the first time a +// colour moves") no longer applies to the artwork — but it still applies to +// the ICNS, which is why this file did not become a committed binary. What is +// committed is the source picture, in one format, reviewable as an image; what +// is generated is the ten-size container macOS actually reads. +// +// So the work here is no longer drawing. It is the part a designer's PNG never +// has: Apple's icon grid, the rounded-square mask, and ten exact sizes. // // build.zig compiles this file alone into a cached binary and runs it with the -// bundle's Resources directory 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. +// source PNG as argv[1] and the bundle's Resources directory as argv[2]; +// 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. // // 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. +// 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 @@ -32,29 +39,6 @@ func die(_ message: String) -> Never { 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 @@ -63,98 +47,35 @@ let cursor = RGB(0xFC_E9_4F) // ansi16[11], bright yellow let squareFraction: CGFloat = 0.8047 let cornerFraction: CGFloat = 0.2237 -// GLENDA, as ellipses. Four for the silhouette, filled as ONE path so the -// overlaps vanish under nonzero winding and she is a single shape rather than -// four stuck together, then two eyes and a nose punched back out in the -// ground colour. -// -// Ellipses and not a traced outline for the reason everything else here is a -// fraction: the mark has to survive being twelve pixels across. An outlined -// drawing at that size is a grey smudge with a lighter grey inside it, whereas -// a silhouette is still a rabbit — the two ears are the whole recognition, and -// they are the two shapes that reach furthest from the mass. -let tagHeight: CGFloat = 0.165 - -/// Her box: the body square under the tag bar, inset so the ears are not -/// welded to the strip and the haunch is not welded to the bottom corners. -let stageTop: CGFloat = 0.250 -let stageBottom: CGFloat = 0.950 -let stageInset: CGFloat = 0.135 - -/// One ellipse of her, in fractions of that box: centre, radii, and a tilt in -/// degrees about its own centre. Fractions rather than points because the same -/// numbers have to describe the mark at 16 pixels and at 1024. -struct Blob { - let cx: CGFloat - let cy: CGFloat - let rx: CGFloat - let ry: CGFloat - let tilt: CGFloat - - init(_ cx: CGFloat, _ cy: CGFloat, _ rx: CGFloat, _ ry: CGFloat, tilt: CGFloat = 0) { - self.cx = cx - self.cy = cy - self.rx = rx - self.ry = ry - self.tilt = tilt - } - - func path(in stage: CGRect) -> CGPath { - let box = CGRect( - x: -stage.width * rx, y: -stage.height * ry, - width: stage.width * rx * 2, height: stage.height * ry * 2) - var placement = CGAffineTransform( - translationX: stage.minX + stage.width * cx, - y: stage.minY + stage.height * cy - ).rotated(by: tilt * .pi / 180) - return CGPath(ellipseIn: box, transform: &placement) - } -} - -// The ears overlap the head and the head overlaps the haunch on purpose: each -// pair has to still intersect after rounding at 16 pixels, or she comes apart -// into floating pieces at exactly the size nobody would look twice at. -let silhouette: [Blob] = [ - Blob(0.325, 0.150, 0.080, 0.200, tilt: -12), // left ear - Blob(0.675, 0.150, 0.080, 0.200, tilt: 12), // right ear - Blob(0.500, 0.490, 0.245, 0.212), // head - Blob(0.500, 0.785, 0.268, 0.215), // haunch -] - -// Set wide and low in the head, which is the whole of her expression. Rounder -// than a dot and smaller than the classic drawing's, because a big oval eye -// closes up into a grey blur two sizes down. -let eyes: [Blob] = [ - Blob(0.393, 0.468, 0.056, 0.070), - Blob(0.607, 0.468, 0.056, 0.070), -] - -/// Wider than it is tall, sitting just under the eyes: the one shape that says -/// rabbit rather than cat. Punched in the ground colour like the eyes. -let nose = Blob(0.500, 0.605, 0.045, 0.030) - -// ...and the block cursor, parked at the end of the tag bar. The palette's -// last entry, and the only warm thing in the icon: pardes is still an acme, -// and this is the two pixels that say so above her head. -let cursorWidth: CGFloat = 0.072 -let cursorRightPad: CGFloat = 0.120 - -/// 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. +/// Where the crop comes off when the source is not square. +/// +/// The drawing is 1204x1306 — taller than wide — so filling a square throws +/// away 8% of its height, and WHICH 8% is the whole decision. Anchoring the +/// top keeps the sun, which is the only warm thing in the picture and sits in +/// the top-right corner, and spends the loss on the bottom band of grass, +/// which is texture and repeats. Anchoring the centre would clip the sun's +/// rays to buy back grass, which is the trade backwards. 0 is top, 1 is +/// bottom; a square source ignores this entirely. +let cropAnchor: CGFloat = 0 + +/// sRGB in the bitmap, so the paper white in the drawing is the paper white in +/// the icon rather than whatever DeviceRGB would make of it. 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") +func loadSource(_ url: URL) -> CGImage { + guard let source = CGImageSourceCreateWithURL(url as CFURL, nil) else { + die("cannot read \(url.path)") } - return color + guard let image = CGImageSourceCreateImageAtIndex(source, 0, nil) else { + die("\(url.lastPathComponent) holds no decodable image") + } + return image } -func renderIcon(pixels: Int) -> CGImage { +func renderIcon(_ art: CGImage, pixels: Int) -> CGImage { guard let ctx = CGContext( data: nil, width: pixels, height: pixels, @@ -162,11 +83,6 @@ func renderIcon(pixels: Int) -> CGImage { 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. @@ -176,63 +92,32 @@ func renderIcon(pixels: Int) -> CGImage { 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)) - let tagRect = CGRect( - x: body.minX, y: body.minY, - width: side, height: max(1, (side * tagHeight).rounded())) - ctx.fill(tagRect) - - // The block cursor at the end of it. Inside the clip and inset from the - // corner so the rounding never clips a corner off the block itself. - ctx.setFillColor(cgColor(cursor)) - ctx.fill( - CGRect( - x: (body.maxX - side * (cursorRightPad + cursorWidth)).rounded(), - y: (tagRect.minY + tagRect.height * 0.24).rounded(), - width: max(1, (side * cursorWidth).rounded()), - height: max(1, (tagRect.height * 0.52).rounded()))) - ctx.restoreGState() - - // Glenda. One fill for the whole silhouette so the four ellipses union - // instead of seaming, then the eyes and the nose over the top of her. - let stage = CGRect( - x: body.minX + side * stageInset, - y: body.minY + side * stageTop, - width: side * (1 - 2 * stageInset), - height: side * (stageBottom - stageTop)) - - ctx.setFillColor(cgColor(text)) - for blob in silhouette { ctx.addPath(blob.path(in: stage)) } - ctx.fillPath(using: .winding) - - // The ground colour rather than black: her eyes and nose are HOLES in her, - // and a hole darker than what is behind it reads as paint. One fill for all - // three, so they can never disagree about which colour a hole is. - ctx.setFillColor(cgColor(bodyTop)) - for hole in eyes + [nose] { ctx.addPath(hole.path(in: stage)) } - ctx.fillPath(using: .winding) + // Aspect FILL, not fit. Fitting would letterbox the rounded square with a + // flat band beside textured paper, and the seam between the two is visible + // at every size the band is wide enough to see. Filling overflows the clip + // instead, and the clip is already exact. + let artWidth = CGFloat(art.width) + let artHeight = CGFloat(art.height) + guard artWidth > 0, artHeight > 0 else { die("source image is empty") } + let scale = max(side / artWidth, side / artHeight) + let drawWidth = artWidth * scale + let drawHeight = artHeight * scale + + // CoreGraphics is bottom-left origin, so `cropAnchor` 0 (the TOP of the + // picture) means the drawing's top edge meets the body's top edge and the + // overflow hangs off the bottom, into the clip. + let overflowY = drawHeight - side + let overflowX = drawWidth - side + ctx.interpolationQuality = .high + ctx.draw( + art, + in: CGRect( + x: body.minX - overflowX / 2, + y: body.maxY - drawHeight + overflowY * cropAnchor, + width: drawWidth, height: drawHeight)) guard let image = ctx.makeImage() else { die("CGContext.makeImage failed at \(pixels)") } return image @@ -266,12 +151,13 @@ let variants: [(name: String, pixels: Int)] = [ ] let arguments = CommandLine.arguments -guard arguments.count == 2 else { - die("usage: \(URL(fileURLWithPath: arguments.first ?? "icon").lastPathComponent) ") +guard arguments.count == 3 else { + die("usage: \(URL(fileURLWithPath: arguments.first ?? "icon").lastPathComponent) ") } let files = FileManager.default -let outputDir = URL(fileURLWithPath: arguments[1], isDirectory: true) +let art = loadSource(URL(fileURLWithPath: arguments[1])) +let outputDir = URL(fileURLWithPath: arguments[2], isDirectory: true) let output = outputDir.appendingPathComponent("pardes.icns") // A fixed scratch path, cleared before use rather than a unique one: a run that @@ -289,7 +175,7 @@ do { } for variant in variants { - writePNG(renderIcon(pixels: variant.pixels), to: iconset.appendingPathComponent(variant.name)) + writePNG(renderIcon(art, pixels: variant.pixels), to: iconset.appendingPathComponent(variant.name)) } let iconutil = Process() diff --git a/src/macos/pardes.h b/src/macos/pardes.h index 8b798df6..c28b3514 100644 --- a/src/macos/pardes.h +++ b/src/macos/pardes.h @@ -269,6 +269,38 @@ uint32_t pardes_tagline_band_offset(uint16_t row, float canvas_h, uint32_t cell_ uint32_t tagline_h); uint32_t pardes_topbar_pane_border_px(uint32_t cell_h, uint32_t tagline_h); +// The column a compact tagline band anchors at: the pane's left edge, so a tag +// row advances on the tagline face's own narrower pitch instead of centring a +// smaller glyph inside every body-width cell. CELLS, not pixels — the host +// knows both widths — and fractional, because an animating panel's origin is. +// +// glyph_x = origin * body_cell_w + (col - origin) * tagline_cell_w +float pardes_tagline_origin_col(uint16_t col, uint16_t row); + +// The inverse, for the pointer: which grid column `x` falls in on `row`, given +// that a tag row's glyphs step at the narrower pitch. Compacting the text +// without compacting this makes a click drift one word further right for every +// word along the row. `x` and both widths must share a unit; only the ratio is +// read, so a host measuring in points passes points. +uint16_t pardes_grid_col_at(float x, uint16_t row, float body_w, float tagline_w); + +// The tag band's own background, the base a compact tag row is painted on: the +// pane-wide band goes down in THIS colour on the body grid, then each cell's +// own background on the narrower grid its glyph uses. One pass would put a +// highlighted word's box on body pitch and its letters on tagline pitch. +// PARDES_COLOR_DEFAULT before there is a session to ask. +uint32_t pardes_tagline_bg(void); + +// The fallback font PREFERENCE ORDER, shared with the SDL shell. Only the order +// travels: resolving a name is the host's business, because SDL matches font +// FILE STEMS while walking the font directories and CoreText matches PostScript +// and family names, which for one face are routinely different strings. +// +// `pardes_fallback_font_name` returns a borrowed, NOT NUL-terminated pointer +// and writes its byte length through `len`; NULL past the end. +uint32_t pardes_fallback_font_count(void); +const char *pardes_fallback_font_name(uint32_t index, uint32_t *len); + // Colour of that rule: a compiled override, else the theme's scrollbar track. // PARDES_COLOR_DEFAULT before there is a session to ask — do not draw it then. uint32_t pardes_topbar_pane_border_rgb(void); -- cgit v1.3