summaryrefslogtreecommitdiff
path: root/src/macos
diff options
context:
space:
mode:
Diffstat (limited to 'src/macos')
-rw-r--r--src/macos/Info.plist12
-rw-r--r--src/macos/Sources/AppDelegate.swift133
-rw-r--r--src/macos/Sources/PardesView.swift377
-rwxr-xr-xsrc/macos/build-app.sh94
-rwxr-xr-xsrc/macos/build-e2e.sh8
-rw-r--r--src/macos/icon.swift183
-rw-r--r--src/macos/pardes.h74
7 files changed, 654 insertions, 227 deletions
diff --git a/src/macos/Info.plist b/src/macos/Info.plist
index 665d54ca..3d526242 100644
--- a/src/macos/Info.plist
+++ b/src/macos/Info.plist
@@ -17,16 +17,16 @@
<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. -->
+ Contents/Resources, where build.zig installs the pardes.icns
+ src/macos/icon.swift draws. 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. -->
+ <!-- build.zig stamps this from macos_min_version, which is the single
+ source of truth, into a copy of this file; the key must exist for
+ plutil's -replace to have something to replace. -->
<key>LSMinimumSystemVersion</key>
<string>13.0</string>
<key>LSApplicationCategoryType</key>
diff --git a/src/macos/Sources/AppDelegate.swift b/src/macos/Sources/AppDelegate.swift
index f5ca4080..cda35ff0 100644
--- a/src/macos/Sources/AppDelegate.swift
+++ b/src/macos/Sources/AppDelegate.swift
@@ -15,6 +15,9 @@ import AppKit
final class AppDelegate: NSObject, NSApplicationDelegate, PardesViewDelegate {
private var window: NSWindow!
private var view: PardesView!
+ /// The blur behind the grid, shown only while the theme declares no
+ /// background of its own. See applyTheme.
+ private var backdrop: NSVisualEffectView!
// One pump chain at a time. pump() re-arms itself while a theme transition
// is in flight and every input pumps as well, so without this a burst of
// keys during a fade would leave one 60 Hz chain per keystroke, all of them
@@ -61,7 +64,32 @@ final class AppDelegate: NSObject, NSApplicationDelegate, PardesViewDelegate {
// isReleasedWhenClosed on would have AppKit release it out from under
// that reference the moment the close button is pressed.
window.isReleasedWhenClosed = false
- window.contentView = view
+ // The grid and the blur are SIBLINGS in a plain container, not parent
+ // and child. A transparent theme has to show something through the
+ // grid, and AppKit will not blur what is behind a window unless an
+ // NSVisualEffectView asks it to — but the backdrop is hidden for every
+ // theme that brings its own background, and hiding a superview hides
+ // its subviews with it. Nested, an opaque theme drew a blank window.
+ let container = NSView(frame: NSRect(origin: .zero, size: want))
+ container.autoresizesSubviews = true
+ backdrop = NSVisualEffectView(frame: container.bounds)
+ // .underWindowBackground is the material meant for exactly this — the
+ // full-window wash behind content, rather than the sidebar/HUD
+ // materials that carry their own tint. .behindWindow is what samples
+ // the desktop instead of the window's own layers.
+ backdrop.material = .underWindowBackground
+ backdrop.blendingMode = .behindWindow
+ // .active, not .followsWindowActiveState: the grid stays readable when
+ // the window is not key, and a backdrop that flattens to grey on focus
+ // loss makes an unfocused pardes look switched off.
+ backdrop.state = .active
+ backdrop.autoresizingMask = [.width, .height]
+ view.frame = container.bounds
+ view.autoresizingMask = [.width, .height]
+ // Order matters: the blur is BEHIND the grid.
+ container.addSubview(backdrop)
+ container.addSubview(view, positioned: .above, relativeTo: backdrop)
+ window.contentView = container
// Trim the content box down to whole cells: a partial column or row is
// dead space the core can never draw into.
@@ -89,16 +117,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, PardesViewDelegate {
// 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)
+ // What the core is wearing, not a constant agreed by hand — two things
+ // draw and both have to say the same thing: the view fills its own
+ // bounds, and AppKit fills the titlebar and every pixel of a live
+ // resize the view has not caught up with yet. Without that agreement
+ // the gap flashes on every drag. It also owns window.appearance: a dark
+ // window with a light-mode titlebar reads as a bug, and so does the
+ // reverse the moment someone wears `acme`.
+ applyTheme()
// On screen before pardes_init, so that the backingScaleFactor read
// when seeding the cell metrics below is the one of the screen the
// window actually landed on. Drawing before the core exists costs an
@@ -396,6 +422,86 @@ final class AppDelegate: NSObject, NSApplicationDelegate, PardesViewDelegate {
return ((directory as NSString).appendingPathComponent(expanded) as NSString).standardizingPath
}
+ /// Dress the WINDOW in what the core is wearing: the background AppKit
+ /// paints where the view does not (the titlebar, and the strip a live
+ /// resize outruns), and the blur behind a theme that brings no background
+ /// of its own.
+ ///
+ /// Cheap enough to call every pump: the view compares before it dirties
+ /// itself, and the window properties are written only when the answer
+ /// moved. `dressed` is what makes the FIRST call unconditional — a boot
+ /// theme whose background happened to equal the view's starting guess
+ /// would otherwise leave the window in AppKit's default clothes forever.
+ private var dressed = false
+
+ private func applyTheme() {
+ let changed = view.adoptThemeBG(pardes_theme_bg())
+ guard changed || !dressed else { return }
+ dressed = true
+ if let rgb = view.themeBG {
+ backdrop.isHidden = true
+ window.isOpaque = true
+ window.backgroundColor = NSColor(
+ srgbRed: CGFloat((rgb >> 16) & 0xFF) / 255,
+ green: CGFloat((rgb >> 8) & 0xFF) / 255,
+ blue: CGFloat(rgb & 0xFF) / 255,
+ alpha: 1)
+ // ...and follow the theme into light mode, so the titlebar, the
+ // traffic lights and the resize cursor stop belonging to a
+ // different application than the grid under them. Luminance off
+ // the same sRGB channels the grid is drawn with.
+ let luma = (0.2126 * CGFloat((rgb >> 16) & 0xFF)
+ + 0.7152 * CGFloat((rgb >> 8) & 0xFF)
+ + 0.0722 * CGFloat(rgb & 0xFF)) / 255
+ window.appearance = NSAppearance(named: luma > 0.5 ? .aqua : .darkAqua)
+ } else {
+ // Transparent: the window stops painting anything of its own and
+ // the blur takes over. isOpaque false is what lets the desktop
+ // reach the backdrop at all — a titled window is opaque by default
+ // and would composite over it.
+ backdrop.isHidden = false
+ window.isOpaque = false
+ window.backgroundColor = .clear
+ window.appearance = NSAppearance(named: .darkAqua)
+ }
+ }
+
+ /// The focused pane, in the titlebar: the proxy icon macOS lets you drag
+ /// and Cmd-click for the path, and the dot in the close button that means
+ /// unsaved.
+ ///
+ /// A pure read-out — the window says what the core already decided, and
+ /// nothing here can change it. There is no document ARCHITECTURE behind it
+ /// and deliberately so: no NSDocument, no save panel, no "do you want to
+ /// save" on close. Save is a builtin, the pane's tag says so, and this is
+ /// the same two facts spelled where a Mac user looks for them.
+ ///
+ /// Cached, because setting representedURL makes AppKit hit the filesystem
+ /// for the icon and this runs on every pump.
+ private var shownPath: String?
+ private var shownDirty = false
+
+ private func applyDocument() {
+ let path = pardes_active_path().map { String(cString: $0) }
+ if path != shownPath {
+ shownPath = path
+ // A terminal or an output buffer is not a document: no path means
+ // no proxy icon, rather than a stale one from the last file pane.
+ window.representedURL = path.map { URL(fileURLWithPath: $0) }
+ // ...and the title goes with it. A proxy icon beside a title that
+ // names something else reads as a bug, and AppKit will not fill the
+ // title in for us while `title` has been set by hand. The window is
+ // a SESSION and not a document, so it falls back to the app's own
+ // name the moment focus lands somewhere with no file behind it.
+ window.title = path.map { ($0 as NSString).lastPathComponent } ?? "pardes"
+ }
+ let dirty = pardes_active_dirty()
+ if dirty != shownDirty {
+ shownDirty = dirty
+ window.isDocumentEdited = dirty
+ }
+ }
+
@objc private func inputArrived(_ notification: Notification) {
pump()
}
@@ -419,6 +525,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, PardesViewDelegate {
// read bytes.
_ = pardes_tick()
view.needsDisplay = true
+ // After the tick, because a `Theme` command runs inside it and the
+ // window has to follow the grid in the same frame rather than at the
+ // next launch.
+ applyTheme()
+ applyDocument()
// Two verbs, two patterns, because they are two different answers:
// Exec did something, so it gets .generic, the definite tap of a
diff --git a/src/macos/Sources/PardesView.swift b/src/macos/Sources/PardesView.swift
index c7152bd3..3d841a60 100644
--- a/src/macos/Sources/PardesView.swift
+++ b/src/macos/Sources/PardesView.swift
@@ -83,6 +83,12 @@ enum Trackpad {
let pardesDefaultFG: UInt32 = 0xCC_CC_CC
let pardesDefaultBG: UInt32 = 0x12_12_12
+/// Pinned sRGB for rasterized attachments, so a PDF page's bytes mean the same
+/// thing here as they do in the SDL shell. DeviceRGB is the fallback rather
+/// than a crash: a machine with no sRGB profile is not a reason to stop
+/// drawing pages.
+private let sRGB: CGColorSpace = CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB()
+
// UNVERIFIED: kCTFontAttributeName bridged through NSAttributedString.Key. It is
// the same string as .font, but spelling the CoreText key means the value stays
// a CTFont instead of being bridged to NSFont on the way in.
@@ -127,20 +133,31 @@ private enum Face: Int, CaseIterable {
}
}
-/// `block` means the filled cursor sits on this cell.
+/// A background run that must not be painted at all, so the window's own
+/// backdrop shows through. Outside the 24-bit RGB range, so it can never
+/// collide with a real colour, and distinct from the `.max` the run loop
+/// flushes on.
+let bgClear: UInt32 = 0x0100_0000
+
+/// `block` means the filled cursor sits on this cell. `ground` is what a
+/// DEFAULT background resolves to, and `clearGround` asks for those cells to
+/// come back as `bgClear` instead of a colour.
private func resolve(
_ cell: pardes_cell_s,
- block: Bool
+ block: Bool,
+ ground: UInt32,
+ clearGround: Bool
) -> (fg: UInt32, bg: UInt32, alpha: CGFloat, visible: Bool) {
// 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
- ? (pardesDefaultBG, pardesDefaultFG, 1, false)
- : (pardesDefaultFG, pardesDefaultBG, 1, false)
+ ? (ground, pardesDefaultFG, 1, false)
+ : (pardesDefaultFG, clearGround ? bgClear : ground, 1, false)
}
+ let bgDefault = cell.bg == UInt32(PARDES_COLOR_DEFAULT)
var fg = decodeColor(cell.fg, pardesDefaultFG)
- var bg = decodeColor(cell.bg, pardesDefaultBG)
+ var bg = decodeColor(cell.bg, ground)
// 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.
@@ -154,6 +171,9 @@ private func resolve(
// The other shells scale the channels by 6/10. Over a dark background alpha
// lands in the same place and costs one blend instead of three multiplies.
let alpha: CGFloat = cell.attrs & UInt16(PARDES_ATTR_DIM) != 0 ? 0.6 : 1
+ // Only an UNREVERSED default background is the ground. A reverse puts the
+ // text colour there, and text is a real colour that paints.
+ if clearGround && bgDefault && !reverse { bg = bgClear }
return (fg, bg, alpha, visible)
}
@@ -190,8 +210,16 @@ private struct Metrics {
/// per face here and never looked up again.
let asciiGlyphs: [[CGGlyph]]
- init(size: CGFloat, path: String?) {
+ /// Round `v` onto the backing grid: `scale` is the display's
+ /// backingScaleFactor, so at 2x this lands on half-points, which are whole
+ /// device pixels.
+ private static func snap(_ v: CGFloat, _ scale: CGFloat, _ rule: FloatingPointRoundingRule) -> CGFloat {
+ (v * scale).rounded(rule) / scale
+ }
+
+ init(size: CGFloat, path: String?, scale: CGFloat) {
let face = Metrics.face(size: size, path: path)
+ let scale = max(1, scale)
// UNVERIFIED: CTFontSymbolicTraits member spelling (.traitBold/.traitItalic).
// A face with no italic cut returns nil here, hence the fallback to `face`.
@@ -200,19 +228,32 @@ private struct Metrics {
}
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.
+ // The grid has to land on WHOLE DEVICE PIXELS, and that is the whole
+ // constraint — a fractional column boundary makes the background pass
+ // (which runs with antialiasing off, or touching fills seam) wobble by
+ // a pixel from column to column, and on a screen made of tag bars and
+ // selections that stripe is visible.
+ //
+ // Whole POINTS is how that used to be spelled, and on a Retina display
+ // it asks for twice what it needs: half a point IS a whole pixel at 2x.
+ // The difference is not academic — Monaco advances 8.4014pt at 14, so
+ // ceiling to 9 spaced every column 7.1% wider than the face was drawn
+ // for, which is loose, washed-out text that reads as bad rendering.
+ // Snapped to the backing grid it is 8.5, i.e. +1.2%.
+ //
+ // Width rounds to NEAREST — a monospace glyph is drawn to fit its own
+ // advance, so the half-pixel either way is slack — while height rounds
+ // UP, because losing a pixel off a descender is clipping.
+ let snap = Metrics.snap
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)
+ // The ascent lands on a pixel for a second reason: it is the baseline's
+ // offset inside the cell, so the rules hung off it are whole-pixel
+ // fills rather than one-pixel bars smeared across two rows.
+ ascent = max(1 / scale, snap(CTFontGetAscent(face), scale, .toNearestOrAwayFromZero))
+ cellWidth = max(1 / scale, snap(advance(face, 0x4D), scale, .toNearestOrAwayFromZero))
+ cellHeight = max(1 / scale, snap(CTFontGetAscent(face) + CTFontGetDescent(face) + CTFontGetLeading(face), scale, .up))
+ ruleThickness = max(1 / scale, snap(CTFontGetUnderlineThickness(face), scale, .toNearestOrAwayFromZero))
+ underlineOffset = snap(CTFontGetUnderlinePosition(face), scale, .toNearestOrAwayFromZero)
asciiGlyphs = faces.map { font in
var chars = Array(UniChar(0)..<UniChar(128))
var glyphs = [CGGlyph](repeating: 0, count: 128)
@@ -307,6 +348,31 @@ final class PardesView: NSView {
private var reportedCols: UInt16 = 0
private var reportedRows: UInt16 = 0
+ /// The backingScaleFactor `metrics` was snapped to, so a move between a
+ /// Retina and a 1x display re-measures the cell instead of leaving the grid
+ /// aligned to the other screen's pixels.
+ private var metricsScale: CGFloat = 2
+
+ /// The active theme's own background, or nil when it declares none — the
+ /// `*_transparent` themes and the curated `dark`. Nil is not a colour to
+ /// substitute but a decision: the ground stops being painted at all, the
+ /// view stops being opaque, and AppDelegate's NSVisualEffectView shows
+ /// through it. Read off pardes_theme_bg() once per pump, which is also
+ /// what makes a `Theme` command take hold without a relaunch.
+ private(set) var themeBG: UInt32? = pardesDefaultBG
+
+ /// Adopt what the core is wearing. Returns whether anything moved, so the
+ /// host only reconfigures the window when it has to.
+ @discardableResult
+ func adoptThemeBG(_ encoded: UInt32) -> Bool {
+ let wanted: UInt32? =
+ encoded == UInt32(PARDES_COLOR_DEFAULT) ? nil : encoded & UInt32(PARDES_COLOR_RGB_MASK)
+ guard wanted != themeBG else { return false }
+ themeBG = wanted
+ needsDisplay = true
+ return true
+ }
+
// 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
@@ -329,7 +395,11 @@ final class PardesView: NSView {
private var restingFingers: Int = 0
init(fontSize size: CGFloat) {
- let built = Metrics(size: size, path: nil)
+ // No window yet, so no backing scale to ask for: 2x is the guess every
+ // Mac shipped this decade would give, and viewDidChangeBackingProperties
+ // below re-measures the moment there is a real answer — including the
+ // 1x case, which a bare guess would otherwise leave wrong forever.
+ let built = Metrics(size: size, path: nil, scale: 2)
metrics = built
fontSize = size
fontPath = nil
@@ -340,6 +410,11 @@ final class PardesView: NSView {
runGlyphs.reserveCapacity(256)
runPositions.reserveCapacity(256)
+ // Files dropped ON the grid. Finder and the Dock already reach the app
+ // through application(_:open:), but that path cannot say WHERE — and
+ // where is the whole difference between "a file opened somewhere" and
+ // acme's "a file opened next to the pane I pointed at".
+ registerForDraggedTypes([.fileURL])
// 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.
@@ -371,11 +446,13 @@ final class PardesView: NSView {
/// 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)
+ let scale = window?.backingScaleFactor ?? metricsScale
+ let next = Metrics(size: size, path: path, scale: scale)
// 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
+ metricsScale = scale
fontSize = size
fontPath = path
delegate?.pardesViewDidResize(self)
@@ -405,7 +482,10 @@ final class PardesView: NSView {
// 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 }
+ // Opaque only while the theme brings its own background. A transparent
+ // theme has none, and an opaque view over a visual-effect backdrop is a
+ // grey rectangle where the blur should be.
+ override var isOpaque: Bool { themeBG != nil }
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
@@ -428,7 +508,13 @@ final class PardesView: NSView {
let count = pardes_frame()
let cols = Int(pardes_frame_cols())
let rows = Int(pardes_frame_rows())
- fill(ctx, bounds, pardesDefaultBG, 1)
+ // The ground, from the core rather than from a constant agreed by hand.
+ // A transparent theme has none: CLEAR rather than fill, because AppKit
+ // does not blank a non-opaque view and last frame's pixels would
+ // otherwise pile up on themselves.
+ let ground = themeBG ?? pardesDefaultBG
+ let clearGround = themeBG == nil
+ if clearGround { ctx.clear(bounds) } else { fill(ctx, bounds, ground, 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
@@ -446,17 +532,23 @@ final class PardesView: NSView {
let base = row * cols
let y = CGFloat(row) * cellHeight
var start = 0
- var color = resolve(cells[base], block: blockY == row && blockX == 0).bg
+ var color = resolve(cells[base], block: blockY == row && blockX == 0,
+ ground: ground, clearGround: clearGround).bg
for col in 1...cols {
// A real color is 24 bits, so .max is a sentinel that cannot
// compare equal and therefore always flushes the last run.
let next: UInt32 = col == cols
? .max
- : resolve(cells[base + col], block: blockY == row && blockX == col).bg
+ : resolve(cells[base + col], block: blockY == row && blockX == col,
+ ground: ground, clearGround: clearGround).bg
if next == color { continue }
- fill(ctx, CGRect(x: CGFloat(start) * cellWidth, y: y,
- width: CGFloat(col - start) * cellWidth, height: cellHeight),
- color, 1)
+ // bgClear runs are the ground showing through, and the ground is
+ // already clear — painting them would be painting the hole shut.
+ if color != bgClear {
+ fill(ctx, CGRect(x: CGFloat(start) * cellWidth, y: y,
+ width: CGFloat(col - start) * cellWidth, height: cellHeight),
+ color, 1)
+ }
start = col
color = next
}
@@ -467,11 +559,19 @@ 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.
+ // Every glyph sits at an exact multiple of cellWidth and the ascent is
+ // whole points (see Metrics), so every baseline is already on a pixel:
+ // letting CoreText place a glyph 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)
+ // Grayscale antialiasing, never LCD subpixel. Smoothing needs to know
+ // the colour behind the glyph, which over a transparent theme's
+ // backdrop it cannot — the result is coloured fringing that reads as
+ // blur. macOS has defaulted this off since 10.14, but the user can turn
+ // it back on globally and it is not their call to make for this grid.
+ ctx.setShouldSmoothFonts(false)
ctx.saveGState()
ctx.textMatrix = .identity
ctx.translateBy(x: 0, y: bounds.height)
@@ -484,12 +584,20 @@ final class PardesView: NSView {
}
ctx.restoreGState()
+ // Pixel attachments over the grid: rasterized PDF pages, and image
+ // panes' own pixels. After the glyphs, the way the SDL shell draws them
+ // after its cells — a PDF pane's cells are blank, so the order only
+ // matters for the tag row an attachment must never reach, and the clip
+ // below is what keeps it off.
+ drawImages(ctx)
+
if bar {
let x = Int(pardes_cursor_x()), y = Int(pardes_cursor_y())
if x >= 0, y >= 0, x < cols, y < rows {
// 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
+ let fg = resolve(cells[y * cols + x], block: false,
+ ground: themeBG ?? pardesDefaultBG, clearGround: false).fg
ctx.setShouldAntialias(false)
fill(ctx, CGRect(x: CGFloat(x) * cellWidth, y: CGFloat(y) * cellHeight,
width: max(1, (cellWidth / 8).rounded(.up)), height: cellHeight), fg, 1)
@@ -497,6 +605,104 @@ final class PardesView: NSView {
}
}
+ /// What identifies a decoded raster: the pane's lifetime, the page, and the
+ /// generation MuPDF last rendered. Panning, zooming to fit and scrolling
+ /// deliberately move none of them, so the CGImage survives all three.
+ private struct ImageKey: Hashable {
+ let serial: UInt32
+ let page: UInt32
+ let revision: UInt32
+ }
+
+ /// Rasterized attachments, decoded once each. The bytes the core lends are
+ /// only valid until the next `pardes_frame`, so the CGImage owns a COPY —
+ /// which is exactly why the cache has to be keyed well enough that the copy
+ /// happens when the pixels change and never on an ordinary scroll.
+ private var imageCache: [ImageKey: CGImage] = [:]
+
+ private func drawImages(_ ctx: CGContext) {
+ let count = Int(pardes_frame_images())
+ guard count > 0, let list = pardes_frame_image_list() else {
+ // Nothing on screen owns pixels any more: the pages a closed pane
+ // rendered would otherwise sit in here for the rest of the session.
+ if !imageCache.isEmpty { imageCache.removeAll(keepingCapacity: true) }
+ return
+ }
+
+ // The core computed every rectangle in PHYSICAL pixels, because that is
+ // what pardes_resize handed it. The view draws in points.
+ let scale = max(1, metricsScale)
+ var live = Set<ImageKey>()
+ live.reserveCapacity(count)
+
+ ctx.setShouldAntialias(true)
+ for i in 0..<count {
+ let place = list[i]
+ let key = ImageKey(serial: place.serial, page: place.page, revision: place.revision)
+ live.insert(key)
+ guard let full = image(for: place, key: key) else { continue }
+ guard let crop = full.cropping(to: CGRect(
+ x: Int(place.src_x), y: Int(place.src_y),
+ width: Int(place.src_w), height: Int(place.src_h)))
+ else { continue }
+
+ // The body is the rectangle nothing may paint past. The core has
+ // already clipped the geometry to the viewport, but a tagline is
+ // not the viewport — a page one pixel too tall would sit on it.
+ let body = CGRect(
+ x: CGFloat(place.cell_x) * cellWidth, y: CGFloat(place.cell_y) * cellHeight,
+ width: CGFloat(place.cell_w) * cellWidth, height: CGFloat(place.cell_h) * cellHeight)
+ let dst = CGRect(
+ x: body.minX + CGFloat(place.dst_x) / scale,
+ y: body.minY + (CGFloat(place.dst_y) + CGFloat(place.offset_y)) / scale,
+ width: CGFloat(place.dst_w) / scale,
+ height: CGFloat(place.dst_h) / scale)
+
+ ctx.saveGState()
+ ctx.clip(to: body)
+ // isFlipped gives us a y-down CTM and CGImage draws +y up, so a
+ // plain ctx.draw would land every page upside down. Flip about the
+ // destination rather than about the view, so the arithmetic above
+ // stays in the grid's own coordinates.
+ ctx.translateBy(x: dst.minX, y: dst.maxY)
+ ctx.scaleBy(x: 1, y: -1)
+ // A page is resampled whenever fit or zoom disagrees with the
+ // raster MuPDF last produced; nearest-neighbour text is unreadable.
+ ctx.interpolationQuality = .high
+ ctx.draw(crop, in: CGRect(x: 0, y: 0, width: dst.width, height: dst.height))
+ ctx.restoreGState()
+ }
+
+ // Evict what this frame did not place. Scrolling a document past a page
+ // is the common case, and holding every page a session ever showed is
+ // how a PDF viewer ends up owning a gigabyte of decoded bitmaps.
+ if imageCache.count > live.count {
+ imageCache = imageCache.filter { live.contains($0.key) }
+ }
+ }
+
+ /// The decoded raster for one attachment, made once per generation.
+ private func image(for place: pardes_image_s, key: ImageKey) -> CGImage? {
+ if let cached = imageCache[key] { return cached }
+ let bytes = Int(place.iw) * Int(place.ih) * 4
+ guard bytes > 0, let rgba = place.rgba else { return nil }
+ // Copied, not referenced: the core lends these bytes until the next
+ // pardes_frame and this image outlives many of them.
+ guard let data = CFDataCreate(nil, rgba, bytes),
+ let provider = CGDataProvider(data: data)
+ else { return nil }
+ // Straight alpha, R,G,B,A in memory — the same bytes the SDL shell
+ // uploads as R8G8B8A8_UNORM and blends with ONE_MINUS_SRC_ALPHA.
+ let made = CGImage(
+ width: Int(place.iw), height: Int(place.ih),
+ bitsPerComponent: 8, bitsPerPixel: 32, bytesPerRow: Int(place.iw) * 4,
+ space: sRGB,
+ bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.last.rawValue | CGBitmapInfo.byteOrder32Big.rawValue),
+ provider: provider, decode: nil, shouldInterpolate: true, intent: .defaultIntent)
+ if let made { imageCache[key] = made }
+ return made
+ }
+
/// 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
@@ -526,7 +732,10 @@ final class PardesView: NSView {
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)
+ // clearGround: false — this pass only reads `fg`, and a glyph is
+ // never the hole in the ground.
+ let style = resolve(cell, block: blockCol == col,
+ ground: themeBG ?? pardesDefaultBG, clearGround: false)
let x = CGFloat(col) * cellWidth
// Rules before the glyph, and independent of it: an underlined space
@@ -621,7 +830,9 @@ final class PardesView: NSView {
}
}
if cell.attrs & UInt16(PARDES_ATTR_STRIKETHROUGH) != 0 {
- fill(ctx, CGRect(x: x, y: baseline + metrics.ascent * 0.3, width: cellWidth, height: metrics.ruleThickness),
+ // 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),
style.fg, style.alpha)
}
}
@@ -704,6 +915,15 @@ final class PardesView: NSView {
fed()
}
+ /// The fingers came off the trackpad. A post-decode entry point of its own
+ /// so the e2e harness can throw the dial: NSEvent phases have no public
+ /// constructor, and a fling nothing can synthesize is a fling nothing can
+ /// assert.
+ func rotateEnd() {
+ pardes_rotate_end()
+ fed()
+ }
+
// MARK: - keyboard
override func keyDown(with event: NSEvent) {
@@ -903,13 +1123,22 @@ final class PardesView: NSView {
/// 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.
+ /// quantizing and the momentum, exactly as it owns the scroll accumulator.
+ ///
+ /// AppKit gives rotation no momentum phase of its own — `momentumPhase` is
+ /// scroll's alone — so the fling is measured from the release speed on the
+ /// Zig side rather than handed to us. All this has to get right is telling
+ /// it where the gesture starts and stops.
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.
+ // first degree of a new twist cannot inherit a nearly-complete notch —
+ // and stops a fling still coasting, because a finger back down is how
+ // a hand catches a dial.
if event.phase == .began { pardes_rotate(0) }
rotate(degrees: CGFloat(event.rotation))
+ // .cancelled too: a gesture the system took away should not fling.
+ if event.phase == .ended || event.phase == .cancelled { rotateEnd() }
}
/// What the trackpad actually delivered, under PARDES_LOG — the same
@@ -946,6 +1175,65 @@ final class PardesView: NSView {
return GridPoint(col: UInt16(col), row: UInt16(row))
}
+ // MARK: - files dropped on the grid
+
+ /// A drop is a CLICK followed by `Look`, and that is the whole definition.
+ ///
+ /// The core has no notion of a drop and is not being given one: the pointer
+ /// lands where it landed, which focuses that pane exactly as a left click
+ /// there would, and then the ordinary `Look` builtin runs in it — so the
+ /// document opens beside the pane you pointed at rather than beside
+ /// whichever one happened to be focused. Drop on a tag and you clicked a
+ /// tag; there is no case to special-case, and nothing here the hand could
+ /// not have done itself.
+ override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation {
+ // AppKit reuses this answer for draggingUpdated when that is not
+ // implemented, so the cursor stays right for the whole drag.
+ droppedFiles(sender).isEmpty ? [] : .copy
+ }
+
+ override func performDragOperation(_ sender: NSDraggingInfo) -> Bool {
+ let paths = droppedFiles(sender)
+ guard !paths.isEmpty else { return false }
+ drop(paths, at: cellAt(convert(sender.draggingLocation, from: nil)))
+ return true
+ }
+
+ /// The drop, decoded: paths and a cell, nothing AppKit left in it.
+ ///
+ /// Split out for the reason every gesture here is — `NSDraggingInfo` is a
+ /// protocol with a dozen members and no public conformer, so a test that
+ /// had to build one would be testing its own stub. The decision lives one
+ /// call below the event, and `drop` in test/macos_e2e.swift drives exactly
+ /// this.
+ ///
+ /// A nil cell is a drop before the first frame, which has no grid to point
+ /// at: the files still open, they just open where focus already was.
+ func drop(_ paths: [String], at target: GridPoint?) {
+ if let target {
+ press(PARDES_MOUSE_LEFT, at: target)
+ release(PARDES_MOUSE_LEFT, at: target)
+ }
+ // Whole tail, unquoted: executeBuiltinLine takes everything after the
+ // first word as the argument, so a path with spaces in it needs no
+ // escaping and would in fact break under any.
+ for path in paths {
+ let line = "Look \(path)"
+ line.withCString { pardes_command($0, line.utf8.count) }
+ }
+ fed()
+ }
+
+ /// File paths on the drag pasteboard, in order. Empty for anything else,
+ /// which is also how draggingEntered decides whether to accept at all.
+ private func droppedFiles(_ sender: NSDraggingInfo) -> [String] {
+ let options: [NSPasteboard.ReadingOptionKey: Any] = [.urlReadingFileURLsOnly: true]
+ guard let urls = sender.draggingPasteboard.readObjects(
+ forClasses: [NSURL.self], options: options) as? [URL]
+ else { return [] }
+ return urls.map(\.path)
+ }
+
// MARK: - geometry
override func updateTrackingAreas() {
@@ -979,13 +1267,22 @@ final class PardesView: NSView {
// Dragging the window between a Retina display and a 1x one changes the
// backing scale without moving a single bound, so setFrameSize above never
- // fires and the physical cell metrics the core uses to place PDF pages stay
- // at the old scale forever. This is the only notification of it. (Ghostty
- // hooks the same one, and additionally re-fires from the window's
- // didChangeScreen notification, which AppKit does not always pair with it.)
+ // fires. This is the only notification of it. (Ghostty hooks the same one,
+ // and additionally re-fires from the window's didChangeScreen
+ // notification, which AppKit does not always pair with it.)
+ //
+ // TWO things depend on the scale: the physical cell metrics the core uses
+ // to place PDF pages, and the cell itself, which is snapped to whole
+ // DEVICE pixels (see Metrics) and is therefore aligned to the display it
+ // was measured on. Re-measuring reports the resize on its own, so the
+ // delegate call is the else-branch and not an extra one.
override func viewDidChangeBackingProperties() {
super.viewDidChangeBackingProperties()
- delegate?.pardesViewDidResize(self)
+ if let scale = window?.backingScaleFactor, scale != metricsScale {
+ wear(size: fontSize, path: fontPath)
+ } else {
+ delegate?.pardesViewDidResize(self)
+ }
}
}
diff --git a/src/macos/build-app.sh b/src/macos/build-app.sh
deleted file mode 100755
index a54e76b8..00000000
--- a/src/macos/build-app.sh
+++ /dev/null
@@ -1,94 +0,0 @@
-#!/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, 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
-# pbxproj would be a second build system to keep in step for no gain at this
-# stage. What Xcode buys — an xcframework of universal slices, codesigning,
-# notarization, a DMG — is distribution machinery; see docs/macos.md for the
-# upgrade path when that day comes.
-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; }
-
-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
-# sync. A module map is what an xcframework needs, and there isn't one.
-#
-# -lc++ because ghostty-vt pulls in simdutf and highway, which are C++. The Zig
-# side bundles compiler_rt/ubsan_rt into the archive (see build.zig), so the
-# C++ runtime is the only thing left for this link to supply.
-# -target is not optional. Without it swiftc uses the host triple, so
-# LC_BUILD_VERSION records whatever macOS built the thing and dyld refuses to
-# 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 "$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
-
-# 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
index 7e0d2233..c0f9613c 100755
--- a/src/macos/build-e2e.sh
+++ b/src/macos/build-e2e.sh
@@ -21,9 +21,9 @@ 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.
+# default is only for a by-hand run. Same string the app's own link uses, 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"
@@ -33,7 +33,7 @@ command -v swiftc >/dev/null || { echo "swiftc not found (needs macOS + Command
mkdir -p "$out/bin"
-# -import-objc-header and -lc++ are build-app.sh's, unchanged, and have to stay
+# -import-objc-header and -lc++ are the app link'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.
#
diff --git a/src/macos/icon.swift b/src/macos/icon.swift
index b5de1838..2e775660 100644
--- a/src/macos/icon.swift
+++ b/src/macos/icon.swift
@@ -1,15 +1,17 @@
// 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
+// 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.
//
-// 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.
+// 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.
//
// 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
@@ -23,7 +25,8 @@ 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.
+/// and build.zig has this exit code waiting: a bundle whose icon did not draw
+/// should not have built.
func die(_ message: String) -> Never {
fputs("icon.swift: \(message)\n", stderr)
exit(1)
@@ -60,30 +63,80 @@ let cursor = RGB(0xFC_E9_4F) // ansi16[11], bright yellow
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.
+// 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 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
-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
+
+/// 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,
@@ -100,18 +153,6 @@ func cgColor(_ rgb: RGB, alpha: CGFloat = 1) -> CGColor {
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(
@@ -157,40 +198,40 @@ func renderIcon(pixels: Int) -> CGImage {
// 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.minX, y: body.minY,
- width: side, height: max(1, (side * tagHeight).rounded())))
+ 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()
- // 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)))
+ // 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, alpha: liveLineAlpha))
- ctx.fill(
- snap(
- CGRect(
- x: leftEdge, y: body.minY + side * liveLineTop,
- width: side * liveLineWidth, height: side * lineHeight)))
+ ctx.setFillColor(cgColor(text))
+ for blob in silhouette { ctx.addPath(blob.path(in: stage)) }
+ ctx.fillPath(using: .winding)
- // 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)))
+ // 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)
guard let image = ctx.makeImage() else { die("CGContext.makeImage failed at \(pixels)") }
return image
diff --git a/src/macos/pardes.h b/src/macos/pardes.h
index 62ddbae5..1591130e 100644
--- a/src/macos/pardes.h
+++ b/src/macos/pardes.h
@@ -174,6 +174,16 @@ bool pardes_should_quit(void);
// A theme transition is mid-flight and wants ~60 Hz ticks until it settles.
bool pardes_animating(void);
+// What to paint where the grid does not: the window background behind the
+// titlebar and behind a live resize the view has not caught up with. The
+// theme's own background, so it changes the instant the theme does — chrome
+// (taglines, the move box) fades instead, which is why this is not it.
+//
+// PARDES_COLOR_DEFAULT means the theme declares NO background of its own. A
+// terminal wears whatever it was already wearing; a window has nothing to
+// wear, so the host should go transparent and show its own backdrop.
+uint32_t pardes_theme_bg(void);
+
// ---------------------------------------------------------------- events in
// `cp` is a codepoint or one of PARDES_KEY_*; `text`/`len` are the host's
@@ -200,9 +210,20 @@ void pardes_scroll(float delta_rows, float delta_cols, uint16_t col,
// 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.
+// per-event delta; pass 0 at gesture start to drop a stale remainder and to
+// stop a fling still coasting.
void pardes_rotate(float degrees);
+// The fingers lifted. How fast they were moving decides everything: a slow
+// twist stops exactly where it was put, a flick keeps turning in proportion to
+// how hard it was thrown, and the two are the same curve — momentum ramps up
+// from zero rather than switching on at a threshold.
+//
+// A coast makes pardes_animating true and is spent by pardes_tick, so a host
+// that already re-pumps for theme transitions needs no new machinery; one that
+// never calls this simply has a dial with no momentum.
+void pardes_rotate_end(void);
+
// 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
@@ -223,6 +244,57 @@ const pardes_cell_s *pardes_frame_cells(void);
uint16_t pardes_frame_cols(void);
uint16_t pardes_frame_rows(void);
+// One rasterized pixel attachment: a PDF page, or an image pane's pixels.
+//
+// Geometry is in PHYSICAL PIXELS, the space pardes_resize's cell_w/cell_h put
+// the core in. `cell_x`/`cell_y` are the pane body's origin in CELLS and the
+// only thing to multiply out; `dst_*` is relative to that origin and `src_*`
+// is the crop of the raster to take. Both are already clipped to the viewport,
+// so a continuous-scroll page needs no overflow clip of its own — but the body
+// (`cell_w` x `cell_h` cells) is still the rectangle nothing may paint past.
+//
+// `serial`, `page` and `revision` together are the cache key: a host holds its
+// decoded texture while all three hold still, and panning, fit and scrolling
+// deliberately do not move them.
+typedef struct {
+ uint32_t serial;
+ uint32_t page;
+ uint32_t revision;
+ uint16_t cell_x;
+ uint16_t cell_y;
+ uint16_t cell_w;
+ uint16_t cell_h;
+ uint32_t dst_x;
+ uint32_t dst_y;
+ uint32_t dst_w;
+ uint32_t dst_h;
+ uint32_t src_x;
+ uint32_t src_y;
+ uint32_t src_w;
+ uint32_t src_h;
+ // subpixel vertical displacement a proportional wheel kept
+ float offset_y;
+ uint32_t iw;
+ uint32_t ih;
+ // iw * ih * 4 bytes, RGBA8, borrowed until the next pardes_frame
+ const uint8_t *rgba;
+} pardes_image_s;
+
+// This frame's attachments, in paint order. Ask after pardes_frame; both are
+// valid until the next one, exactly like the cell buffer.
+uint32_t pardes_frame_images(void);
+const pardes_image_s *pardes_frame_image_list(void);
+
+// The file behind the FOCUSED pane, or NULL when there is none: a terminal, an
+// output buffer, or nothing focused. PDFs and images count — they are real
+// paths, and a titlebar proxy icon is about the file, not about who may edit
+// it. Borrowed until the next call, like pardes_font_take.
+const char *pardes_active_path(void);
+
+// ...and whether that pane holds edits which are not on disk. Always false for
+// anything with no buffer to save, PDFs and images included.
+bool pardes_active_dirty(void);
+
// -1 when the cursor is hidden. `bar` asks for a thin insert-mode caret.
int32_t pardes_cursor_x(void);
int32_t pardes_cursor_y(void);