summaryrefslogtreecommitdiff
path: root/src/macos/Sources
diff options
context:
space:
mode:
Diffstat (limited to 'src/macos/Sources')
-rw-r--r--src/macos/Sources/AppDelegate.swift133
-rw-r--r--src/macos/Sources/PardesView.swift377
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)
+ }
}
}