diff options
| author | Gabriel Schneider <[email protected]> | 2026-08-10 17:23:18 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-08-11 09:58:59 -0300 |
| commit | a797a1ab2f648e773f7a1b28d12bf9441b9f13f4 (patch) | |
| tree | 45db37a3d190425b3ca2453d46c57d2af27cfc93 /src/macos | |
| parent | eb11ab331b4e13e2b9e5a673a4c012d22fdd1d9c (diff) | |
| download | pardes-a797a1ab2f648e773f7a1b28d12bf9441b9f13f4.tar.gz pardes-a797a1ab2f648e773f7a1b28d12bf9441b9f13f4.zip | |
macos: pixel attachments, live theming, and a signed app
The AppKit shell now draws what the core renders, follows the theme without a
relaunch, and builds into something you can hand to someone.
- Pixel attachments. Surface.images was dropped on the floor here, so a PDF
pane showed nothing at all: native_images is now set, pardes_image_s carries
the geometry the core already clipped, and PardesView keeps one CGImage per
(serial, page, revision) so scrolling costs a draw and not a decode. Image
panes get real pixels instead of the petscii fallback.
- Themes take hold live. pardes_tick never advanced the chrome animation, so
every tagline kept the previous theme's colours until the next launch and
the 16 ms re-pump spun for the rest of the session. pardes_theme_bg retires
the hand-agreed #121212 and drives the window background and the titlebar
appearance; a theme with no background of its own now gets a transparent
window over an NSVisualEffectView.
- The cell snaps to whole DEVICE pixels rather than whole points. Monaco
advances 8.4014pt at 14, so ceiling to 9 spaced every column 7.1% wider than
the face was drawn for.
- The dial is one notch per 10 degrees instead of 20, and a release keeps
turning in proportion to how hard it was thrown -- ramping up from zero at
the floor, so a slow twist coasts not a little but not at all.
- A file dropped on the grid is a click plus Look, so it opens beside the pane
it was dropped on. No drop concept was added to the core.
- The titlebar follows the focused pane: proxy icon, filename, and the dirty
dot. File.saved_revision is the watermark that last one needed.
- Config (SPC f c) prints the resolved startup config path.
- build.zig assembles, signs and packages the bundle itself; build-app.sh is
gone. -Dmacos-identity= takes a Developer ID, macos-dmg makes the image, and
the icon is Glenda.
Diffstat (limited to 'src/macos')
| -rw-r--r-- | src/macos/Info.plist | 12 | ||||
| -rw-r--r-- | src/macos/Sources/AppDelegate.swift | 133 | ||||
| -rw-r--r-- | src/macos/Sources/PardesView.swift | 377 | ||||
| -rwxr-xr-x | src/macos/build-app.sh | 94 | ||||
| -rwxr-xr-x | src/macos/build-e2e.sh | 8 | ||||
| -rw-r--r-- | src/macos/icon.swift | 183 | ||||
| -rw-r--r-- | src/macos/pardes.h | 74 |
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); |
