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/Sources | |
| 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/Sources')
| -rw-r--r-- | src/macos/Sources/AppDelegate.swift | 133 | ||||
| -rw-r--r-- | src/macos/Sources/PardesView.swift | 377 |
2 files changed, 459 insertions, 51 deletions
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) + } } } |
