summaryrefslogtreecommitdiff
path: root/src/macos/Sources/PardesView.swift
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-08-10 17:23:18 -0300
committerGabriel Schneider <[email protected]>2026-08-11 09:58:59 -0300
commita797a1ab2f648e773f7a1b28d12bf9441b9f13f4 (patch)
tree45db37a3d190425b3ca2453d46c57d2af27cfc93 /src/macos/Sources/PardesView.swift
parenteb11ab331b4e13e2b9e5a673a4c012d22fdd1d9c (diff)
downloadpardes-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/PardesView.swift')
-rw-r--r--src/macos/Sources/PardesView.swift377
1 files changed, 337 insertions, 40 deletions
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)
+ }
}
}