summaryrefslogtreecommitdiff
path: root/src/macos
diff options
context:
space:
mode:
Diffstat (limited to 'src/macos')
-rw-r--r--src/macos/Sources/AppDelegate.swift63
-rw-r--r--src/macos/Sources/PardesView.swift237
-rw-r--r--src/macos/icon.pngbin0 -> 1788282 bytes
-rw-r--r--src/macos/icon.swift248
-rw-r--r--src/macos/pardes.h32
5 files changed, 376 insertions, 204 deletions
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<String>()
+
+ 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..<pardes_fallback_font_count() {
+ var length: UInt32 = 0
+ guard let bytes = pardes_fallback_font_name(index, &length), length > 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)..<UniChar(128))
var glyphs = [CGGlyph](repeating: 0, count: 128)
@@ -380,6 +510,20 @@ private struct TaglineMetrics {
UInt16(clamping: row), Float(canvasHeight * scale), cellPixels, bandPixels)) / scale
}
+ /// Where a tagline cell's GLYPH sits, on the compact grid.
+ ///
+ /// Only the text compacts. The colour band behind it was already painted
+ /// pane-wide on the body grid, which is what keeps a tag bar the width of
+ /// its pane; this steps the characters on top of it at the tagline face's
+ /// own pitch, anchored so the compact grid's column zero is the pane's
+ /// physical left edge. The anchor is the core's — the SDL shell reaches the
+ /// identical rule through `pardes.taglineOriginCol`, and pane rects are not
+ /// otherwise on this shell's ABI at all.
+ func glyphX(col: Int, row: Int, bodyCellWidth: CGFloat) -> 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..<rows {
let base = row * cols
let y = CGFloat(row) * cellHeight
@@ -855,9 +1007,29 @@ final class PardesView: NSView {
if color != bgClear {
let bandY = tagline ? y + bandTop : y
let bandHeight = tagline ? taglines.height : cellHeight
- fill(ctx, CGRect(x: CGFloat(start) * cellWidth, y: bandY,
- width: CGFloat(col - start) * cellWidth, height: bandHeight),
- color, 1)
+ if tagline && taglineTwoPass {
+ // The band keeps the BODY grid so it spans its pane
+ // exactly. Anything that is NOT the band — a hovered
+ // word, a selection, the block cursor — belongs to the
+ // cell and has to land under the glyph, which moved to
+ // the narrower grid. Runs are painted left to right and
+ // a compact overlay always sits left of its own run's
+ // right edge, so no later band can cover one.
+ fill(ctx, CGRect(x: CGFloat(start) * cellWidth, y: bandY,
+ width: CGFloat(col - start) * cellWidth, height: bandHeight),
+ taglineBaseBG, 1)
+ if color != taglineBaseBG {
+ fill(ctx, CGRect(
+ x: taglines.glyphX(col: start, row: row, bodyCellWidth: cellWidth),
+ y: bandY,
+ width: CGFloat(col - start) * taglines.width,
+ height: bandHeight), color, 1)
+ }
+ } else {
+ fill(ctx, CGRect(x: CGFloat(start) * cellWidth, y: bandY,
+ width: CGFloat(col - start) * cellWidth, height: bandHeight),
+ color, 1)
+ }
}
start = col
color = next
@@ -933,7 +1105,7 @@ final class PardesView: NSView {
ctx.scaleBy(x: 1, y: -1)
let height = bounds.height
for row in 0..<rows {
- drawRow(ctx, cells, base: row * cols, cols: cols,
+ drawRow(ctx, cells, base: row * cols, cols: cols, row: row,
baseline: height - (CGFloat(row) * cellHeight + metrics.ascent),
taglineTop: taglines.top(row: row, canvasHeight: canvasHeight),
blockCol: blockY == row ? blockX : -1)
@@ -963,9 +1135,17 @@ final class PardesView: NSView {
let caretY = CGFloat(y) * cellHeight
+ (tagline ? taglines.top(row: y, canvasHeight: bounds.height) : 0)
let caretHeight = tagline ? taglines.height : cellHeight
+ // On the compact grid when the cell is, or the caret sits a
+ // growing distance to the left of the character it marks as the
+ // row runs on. The SDL shell puts its cursor bar through the
+ // same layout for the same reason.
+ let caretX = tagline
+ ? taglines.glyphX(col: x, row: y, bodyCellWidth: cellWidth)
+ : CGFloat(x) * cellWidth
+ let caretWidth = max(1, ((tagline ? taglines.width : cellWidth) / 8).rounded(.up))
ctx.setShouldAntialias(false)
- fill(ctx, CGRect(x: CGFloat(x) * cellWidth, y: caretY,
- width: max(1, (cellWidth / 8).rounded(.up)), height: caretHeight), fg, 1)
+ fill(ctx, CGRect(x: caretX, y: caretY,
+ width: caretWidth, height: caretHeight), fg, 1)
}
}
}
@@ -1128,6 +1308,10 @@ final class PardesView: NSView {
_ cells: UnsafePointer<pardes_cell_s>,
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
--- /dev/null
+++ b/src/macos/icon.png
Binary files 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)
- }
-}
+/// 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
-// 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.
+/// 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)")
+ }
+ guard let image = CGImageSourceCreateImageAtIndex(source, 0, nil) else {
+ die("\(url.lastPathComponent) holds no decodable image")
}
- return color
+ 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)
+ // 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
- // 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)
+ // 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) <output-directory>")
+guard arguments.count == 3 else {
+ die("usage: \(URL(fileURLWithPath: arguments.first ?? "icon").lastPathComponent) <source.png> <output-directory>")
}
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);