summaryrefslogtreecommitdiff
path: root/src/macos/Sources/PardesView.swift
diff options
context:
space:
mode:
Diffstat (limited to 'src/macos/Sources/PardesView.swift')
-rw-r--r--src/macos/Sources/PardesView.swift1097
1 files changed, 956 insertions, 141 deletions
diff --git a/src/macos/Sources/PardesView.swift b/src/macos/Sources/PardesView.swift
index 2dfa4305..3d841a60 100644
--- a/src/macos/Sources/PardesView.swift
+++ b/src/macos/Sources/PardesView.swift
@@ -4,7 +4,17 @@
// This file keeps no model of the screen. The core owns the grid and hands it
// over whole through src/macos/pardes.h, so everything here is translation, and
// the only state worth holding is the font metrics — which are expensive to
-// measure and never change.
+// measure and never change — plus the two things a gesture needs remembered
+// between events: which button a click started with, and how hard it is being
+// pressed.
+//
+// Every NSEvent override decodes and then calls one of the post-decode entry
+// points below (press/release/drag/click/scroll/rotate/typeKey). That split is
+// not decoration: NSTouch, pressure stages and rotation have no public
+// constructors, so a test can never synthesize them, and the only way the
+// trackpad behaviour is reachable by anything but a finger is for the decision
+// to live one call below the event. test/macos_e2e.swift drives exactly those
+// entry points.
//
// Every C constant below is wrapped in an explicit conversion (UInt16(...),
// UInt32(...)) rather than used bare. A macro's imported Swift type is decided
@@ -13,18 +23,71 @@
import AppKit
import CoreText
+/// Posted after every call this view makes into the core, and answered by
+/// AppDelegate.pump(). The core only queues what it was told and does nothing
+/// until it is pumped, so an input without one of these is an input that
+/// visibly did nothing. Declared here, where the posts are.
+let pardesDidInputNotification = Notification.Name("pardesDidInput")
+
+/// A cell, already clamped to the frame the core last rendered.
+struct GridPoint {
+ var col: UInt16
+ var row: UInt16
+}
+
protocol PardesViewDelegate: AnyObject {
func pardesViewDidResize(_ view: PardesView)
func pardesViewRequestsPaste(_ view: PardesView)
}
+/// What a click means, from the fingers resting on the trackpad and the button
+/// stream AppKit chose to deliver it on. Pure on purpose: NSTouch cannot be
+/// constructed, so this is the part of the gesture a test can reach.
+///
+/// acme's three buttons are the whole vocabulary — 1 selects, 2 executes,
+/// 3 looks — and a trackpad has one surface. Two fingers is the gesture macOS
+/// itself spells "secondary", so it is Look; three is the one left over, so it
+/// is Exec, the heavier verb, which is also what a deep press means.
+///
+/// The finger count has to win over the stream, and that is not a preference.
+/// macOS's secondary click is "click or tap with TWO OR MORE fingers": with it
+/// on, a three-finger click is delivered as rightMouseDown exactly like a
+/// two-finger one, and a view that trusts the stream cannot tell them apart —
+/// three fingers silently did Look. Measured on real hardware, which is the
+/// only way this was ever going to be found: `rightMouseDown: resting=3`.
+///
+/// So the stream is only the fallback, for when there are no fingers to count:
+/// a real mouse's right button is Look and its middle button is Exec, and both
+/// arrive with an empty touch set.
+enum Trackpad {
+ static func button(stream: pardes_mouse_button_e, fingers: Int) -> pardes_mouse_button_e {
+ switch fingers {
+ case 2: return PARDES_MOUSE_RIGHT
+ case 3...: return PARDES_MOUSE_MIDDLE
+ // One finger, or none to count: the stream is the answer. A trackpad
+ // single click comes in on the left stream and stays left; a real
+ // mouse's right and middle buttons keep their acme meanings.
+ default: return stream
+ }
+ }
+
+ /// A force click is a deliberate second gesture on top of an ordinary one,
+ /// so it gets the verb that does something rather than the one that
+ /// navigates.
+ static let forceClickButton: pardes_mouse_button_e = PARDES_MOUSE_MIDDLE
+}
+
// Matches bg_default/fg_default in src/gui/gui.zig and DEFAULT_FG/DEFAULT_BG in
// src/web/app.mjs. Three shells render the same core; if these drift, comparing
// a screenshot across backends stops meaning anything.
-private let defaultFG: UInt32 = 0xCC_CC_CC
-private let defaultBG: UInt32 = 0x12_12_12
+let pardesDefaultFG: UInt32 = 0xCC_CC_CC
+let pardesDefaultBG: UInt32 = 0x12_12_12
-private let inputNotification = Notification.Name("pardesDidInput")
+/// 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
@@ -60,18 +123,41 @@ private func decodeColor(_ encoded: UInt32, _ fallback: UInt32) -> UInt32 {
return encoded & UInt32(PARDES_COLOR_RGB_MASK)
}
-/// `block` means the filled cursor sits on this cell.
+/// The four faces, indexed by the two attribute bits that pick one. Also the
+/// glyph cache's first key, which is why it is an ordinal and not four fields.
+private enum Face: Int, CaseIterable {
+ case regular = 0, bold = 1, italic = 2, boldItalic = 3
+
+ init(bold: Bool, italic: Bool) {
+ self = Face(rawValue: (bold ? 1 : 0) | (italic ? 2 : 0))!
+ }
+}
+
+/// 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 ? (defaultBG, defaultFG, 1, false) : (defaultFG, defaultBG, 1, false)
+ return block
+ ? (ground, pardesDefaultFG, 1, false)
+ : (pardesDefaultFG, clearGround ? bgClear : ground, 1, false)
}
- var fg = decodeColor(cell.fg, defaultFG)
- var bg = decodeColor(cell.bg, defaultBG)
+ let bgDefault = cell.bg == UInt32(PARDES_COLOR_DEFAULT)
+ var fg = decodeColor(cell.fg, pardesDefaultFG)
+ 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.
@@ -85,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)
}
@@ -97,6 +186,134 @@ private func advance(_ font: CTFont, _ character: UniChar) -> CGFloat {
return size.width
}
+/// The size the window opens at and Cmd+0 returns to. Named here rather than
+/// passed in because zoomReset has to know it too, and two spellings of one
+/// number is how "actual size" stops being the size it actually opened at.
+let defaultFontSize: CGFloat = 14
+
+/// Everything that changes when the face or its size does, in one value so
+/// that changing either is one assignment and cannot leave half the numbers
+/// describing the old font.
+///
+/// Built at init and again for a `Font` command or a zoom. The glyph caches
+/// belong here for the same reason: a CGGlyph is an index into a particular
+/// face, so carrying one across a font change draws the wrong character
+/// rather than none.
+private struct Metrics {
+ let fonts: [CTFont]
+ let ascent: CGFloat
+ let cellWidth: CGFloat
+ let cellHeight: CGFloat
+ let ruleThickness: CGFloat
+ let underlineOffset: CGFloat
+ /// ASCII is very nearly the whole screen, so its glyphs are resolved once
+ /// per face here and never looked up again.
+ let asciiGlyphs: [[CGGlyph]]
+
+ /// 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`.
+ func variant(_ traits: CTFontSymbolicTraits) -> CTFont {
+ CTFontCreateCopyWithSymbolicTraits(face, size, nil, traits, traits) ?? face
+ }
+ let faces = [face, variant(.traitBold), variant(.traitItalic), variant([.traitBold, .traitItalic])]
+
+ // 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
+ // 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)
+ _ = CTFontGetGlyphsForCharacters(font, &chars, &glyphs, 128)
+ return glyphs
+ }
+ }
+
+ /// The regular cut to build the other three from: the file the core asked
+ /// for, or the system monospace face when it asked for nothing — or when
+ /// what it asked for turned out not to be wearable.
+ private static func face(size: CGFloat, path: String?) -> CTFont {
+ if let path, let picked = Metrics.fromFile(path, size) { return picked }
+ let system = NSFont.monospacedSystemFont(ofSize: size, weight: .regular)
+ // Through the descriptor, not through CTFontCreateWithName(fontName):
+ // the system monospace face has a dot-prefixed internal name that a
+ // by-name lookup can miss entirely, and NSFontDescriptor is toll-free
+ // bridged, so this cannot resolve to a different font than AppKit just
+ // handed us.
+ let face = CTFontCreateWithFontDescriptor(system.fontDescriptor as CTFontDescriptor, size, nil)
+ // The whole layout is a fixed grid, so a proportional face is not a
+ // cosmetic problem, it is a broken screen. "M" and "i" disagreeing on
+ // advance is the cheapest possible proof that we got one.
+ return Metrics.isFixedPitch(face) ? face : CTFontCreateWithName("Menlo" as CFString, size, nil)
+ }
+
+ /// A face out of a font FILE, which is what the core hands over — it found
+ /// the path by walking the font directories itself, so nothing here asks
+ /// CoreText to resolve a name that a different shell might resolve
+ /// differently.
+ ///
+ /// Nil rather than a substitute for anything wrong with the file, because
+ /// the caller's fallback is the face already on screen: a font that cannot
+ /// be measured would otherwise leave a terminal with no way back out.
+ private static func fromFile(_ path: String, _ size: CGFloat) -> CTFont? {
+ let url = URL(fileURLWithPath: path) as CFURL
+ guard let descriptors = CTFontManagerCreateFontDescriptorsFromURL(url) as? [CTFontDescriptor],
+ !descriptors.isEmpty else { return nil }
+ // A .ttc holds a family's four cuts in one file. Take the one with
+ // neither trait set — the regular — because the bold and italic ones
+ // are derived from it below; falling back to the first face keeps a
+ // collection whose cuts are all styled from being unusable.
+ let plain = descriptors.first { descriptor in
+ let traits = CTFontDescriptorCopyAttribute(descriptor, kCTFontTraitsAttribute) as? [CFString: Any]
+ let symbolic = (traits?[kCTFontSymbolicTrait] as? UInt32) ?? 0
+ return symbolic & UInt32(CTFontSymbolicTraits.traitBold.rawValue | CTFontSymbolicTraits.traitItalic.rawValue) == 0
+ }
+ let face = CTFontCreateWithFontDescriptor(plain ?? descriptors[0], size, nil)
+ // The core already filtered for fixed pitch by reading the file's own
+ // advances. This is the same question asked of the face CoreText
+ // actually built, which is the one that will be drawn with.
+ return Metrics.isFixedPitch(face) ? face : nil
+ }
+
+ private static func isFixedPitch(_ face: CTFont) -> Bool {
+ let em = advance(face, 0x4D)
+ return em > 0 && abs(em - advance(face, 0x69)) <= 0.01
+ }
+}
+
private func modifiers(_ flags: NSEvent.ModifierFlags) -> UInt32 {
var mods: UInt32 = 0
if flags.contains(.control) { mods |= UInt32(PARDES_MOD_CTRL) }
@@ -106,59 +323,174 @@ private func modifiers(_ flags: NSEvent.ModifierFlags) -> UInt32 {
}
final class PardesView: NSView {
- let cellWidth: CGFloat
- let cellHeight: CGFloat
weak var delegate: PardesViewDelegate?
- private let regular: CTFont
- private let bold: CTFont
- private let italic: CTFont
- private let boldItalic: CTFont
- private let ascent: CGFloat
- private let ruleThickness: CGFloat
- private let underlineOffset: CGFloat
+ // The face and the numbers off it, replaced whole by `wear`.
+ private var metrics: Metrics
+ /// What `metrics` was built from, so a zoom keeps the face and a font
+ /// change keeps the size.
+ private var fontSize: CGFloat
+ private var fontPath: String?
+
+ var cellWidth: CGFloat { metrics.cellWidth }
+ var cellHeight: CGFloat { metrics.cellHeight }
+
+ // Glyphs the ASCII table above did not answer for. A stored 0 is .notdef,
+ // meaning "this face does not have it", which is a cache hit too — the
+ // CTLine fallback below is far more expensive than the lookup it would
+ // repeat. Keyed by face and codepoint, and thrown away with the face.
+ private var glyphCache: [UInt32: CGGlyph] = [:]
+ // Scratch for one batched run of glyphs. Held rather than made per row so a
+ // full redraw does not allocate 24 times.
+ private var runGlyphs: [CGGlyph] = []
+ private var runPositions: [CGPoint] = []
+
private var reportedCols: UInt16 = 0
private var reportedRows: UInt16 = 0
- init(fontSize: CGFloat) {
- let system = NSFont.monospacedSystemFont(ofSize: fontSize, weight: .regular)
- var face = CTFontCreateWithName(system.fontName as CFString, fontSize, nil)
- // The whole layout is a fixed grid, so a proportional face is not a
- // cosmetic problem, it is a broken screen. Resolving the system monospace
- // font by name is a lookup that can miss; "M" and "i" disagreeing on
- // advance is the cheapest possible proof that it did.
- let em = advance(face, 0x4D)
- if em <= 0 || abs(em - advance(face, 0x69)) > 0.01 {
- face = CTFontCreateWithName("Menlo" as CFString, fontSize, nil)
- }
+ /// 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
- // UNVERIFIED: CTFontSymbolicTraits member spelling (.traitBold/.traitItalic).
- // A face with no italic cut returns nil here, hence the fallback to `face`.
- func variant(_ traits: CTFontSymbolicTraits) -> CTFont {
- CTFontCreateCopyWithSymbolicTraits(face, fontSize, nil, traits, traits) ?? face
- }
- regular = face
- bold = variant(.traitBold)
- italic = variant(.traitItalic)
- boldItalic = variant([.traitBold, .traitItalic])
+ /// 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
- cellWidth = max(1, advance(face, 0x4D))
- ascent = CTFontGetAscent(face)
- cellHeight = max(1, (ascent + CTFontGetDescent(face) + CTFontGetLeading(face)).rounded(.up))
- ruleThickness = max(1, CTFontGetUnderlineThickness(face))
- underlineOffset = CTFontGetUnderlinePosition(face)
+ /// 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
+ // core holding a drag nothing will ever end.
+ private var latchedButton: pardes_mouse_button_e?
+ private var latchedCell: GridPoint?
+ // Force-click stage, reset per press. AppKit repeats stage-2 events for as
+ // long as the finger stays down, and only the transition is the gesture.
+ private var pressureStage: Int = 0
+ // Fingers currently resting on the trackpad, kept from the touch stream.
+ //
+ // mouseDown was originally trusted to carry its own touch set, and on this
+ // hardware it does not always: AppKit routes NSTouch through the four
+ // touchesXxx callbacks, and the touch set hanging off a *mouse* event can
+ // come back empty depending on how the click was produced. Empty reads as
+ // one finger, which is a two-finger Look silently degrading into a select
+ // — the exact failure this was supposed to avoid. So the count is
+ // maintained here and the mouse event's own set is preferred only when it
+ // has something in it.
+ private var restingFingers: Int = 0
+
+ init(fontSize size: CGFloat) {
+ // 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
// 80x24 only so the window has a size to open at; the AppDelegate reads
// gridSize back and boots the core with whatever it actually got.
- super.init(frame: NSRect(x: 0, y: 0, width: cellWidth * 80, height: cellHeight * 24))
+ super.init(frame: NSRect(x: 0, y: 0, width: built.cellWidth * 80, height: built.cellHeight * 24))
+
+ 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.
+ allowedTouchTypes = [.indirect]
+ // Without a pressure configuration the deep-press stages are the
+ // system's business and stage 2 may never be delivered here.
+ // .primaryDeepClick is the one that means "a harder press is a second
+ // gesture", which is what it is being used for.
+ pressureConfiguration = NSPressureConfiguration(pressureBehavior: .primaryDeepClick)
}
required init?(coder: NSCoder) { fatalError("PardesView is built in code, not a nib") }
+ // MARK: - the face
+
+ /// The PostScript name of the face on screen. The only way anything
+ /// outside this file can find out which font is being drawn with — the
+ /// core has no font, so a cell-buffer snapshot cannot see one.
+ var faceName: String { CTFontCopyPostScriptName(metrics.fonts[0]) as String }
+
+ /// Put on a face, or the same face at a different size, and tell the host
+ /// the grid moved under it.
+ ///
+ /// A cell that changed size means a different number of columns fit the
+ /// same window, so this is a resize as far as the core is concerned — and
+ /// the delegate's resize path is already the one that reports both the
+ /// grid and the physical cell the PDF placement reads. Nothing here
+ /// second-guesses a file it could not load: `Metrics` falls back to the
+ /// 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 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)
+ needsDisplay = true
+ }
+
+ /// The file `Font <name>` resolved to, straight from the core.
+ func adoptFont(path: String) {
+ wear(size: fontSize, path: path)
+ }
+
+ /// Cmd+ and Cmd-. Whole points, because the cell is rounded to whole
+ /// points anyway: a tenth-of-a-point step would spend several keystrokes
+ /// landing on the same grid and look like the key had stopped working.
+ /// The range is what stays legible at the bottom and still fits a useful
+ /// number of columns at the top.
+ func zoom(by step: CGFloat) {
+ let next = min(max(fontSize + step, 6), 72)
+ guard next != fontSize else { return }
+ wear(size: next, path: fontPath)
+ }
+
+ func zoomReset() {
+ guard fontSize != defaultFontSize else { return }
+ wear(size: defaultFontSize, path: fontPath)
+ }
+
// 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
+ // of thing that makes an app feel foreign.
+ override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true }
var gridSize: (cols: UInt16, rows: UInt16) {
let cols = min(max((bounds.width / cellWidth).rounded(.down), 1), CGFloat(UInt16.max))
@@ -176,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, defaultBG, 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
@@ -194,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
}
@@ -215,96 +559,282 @@ 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 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)
ctx.scaleBy(x: 1, y: -1)
let height = bounds.height
for row in 0..<rows {
- let base = row * cols
- let baseline = height - (CGFloat(row) * cellHeight + ascent)
- for col in 0..<cols {
- let cell = cells[base + col]
- if cell.flags & UInt8(PARDES_CELL_DEFAULT) != 0 { continue }
- let style = resolve(cell, block: blockY == row && blockX == col)
- drawCell(ctx, cell, style, x: CGFloat(col) * cellWidth, baseline: baseline)
- }
+ drawRow(ctx, cells, base: row * cols, cols: cols,
+ baseline: height - (CGFloat(row) * cellHeight + metrics.ascent),
+ blockCol: blockY == row ? blockX : -1)
}
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), height: cellHeight), fg, 1)
+ width: max(1, (cellWidth / 8).rounded(.up)), height: cellHeight), fg, 1)
+ }
+ }
+ }
+
+ /// 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
+ /// between a full redraw being free and being felt.
+ private func drawRow(
+ _ ctx: CGContext,
+ _ cells: UnsafePointer<pardes_cell_s>,
+ base: Int,
+ cols: Int,
+ baseline: CGFloat,
+ blockCol: Int
+ ) {
+ var runFace = Face.regular
+ var runColor: UInt32 = 0
+ var runAlpha: CGFloat = 1
+ runGlyphs.removeAll(keepingCapacity: true)
+ runPositions.removeAll(keepingCapacity: true)
+
+ func flush() {
+ guard !runGlyphs.isEmpty else { return }
+ setFill(ctx, runColor, runAlpha)
+ CTFontDrawGlyphs(metrics.fonts[runFace.rawValue], runGlyphs, runPositions, runGlyphs.count, ctx)
+ runGlyphs.removeAll(keepingCapacity: true)
+ runPositions.removeAll(keepingCapacity: true)
+ }
+
+ for col in 0..<cols {
+ let cell = cells[base + col]
+ if cell.flags & UInt8(PARDES_CELL_DEFAULT) != 0 { continue }
+ // 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
+ // is a real thing and so is an underlined invisible cell. They are
+ // fills, not glyphs, so they interrupt the run.
+ if cell.attrs >> UInt16(PARDES_ATTR_UL_SHIFT) != 0
+ || cell.attrs & UInt16(PARDES_ATTR_STRIKETHROUGH) != 0 {
+ flush()
+ drawRules(ctx, cell, style, x: x, baseline: baseline)
}
+ guard style.visible else { continue }
+
+ // UNVERIFIED: withUnsafeBytes over an imported C fixed-size array, which
+ // Swift models as an 8-tuple. String(decoding:) substitutes U+FFFD rather
+ // than trapping, and the core has shipped invalid UTF-8 through here
+ // before — the renderer must not be the thing that dies over it. prefix
+ // clamps, so a bogus len cannot walk off the eight bytes either.
+ let text = withUnsafeBytes(of: cell.text) { raw in
+ String(decoding: raw.prefix(Int(cell.len)), as: UTF8.self)
+ }
+ guard !text.isEmpty, text != " " else { continue }
+
+ let face = Face(bold: cell.attrs & UInt16(PARDES_ATTR_BOLD) != 0,
+ italic: cell.attrs & UInt16(PARDES_ATTR_ITALIC) != 0)
+ let units = text.utf16
+ let known = units.count == 1 ? glyph(face, units.first!) : 0
+ if known != 0 {
+ if !runGlyphs.isEmpty
+ && (face != runFace || style.fg != runColor || style.alpha != runAlpha) {
+ flush()
+ }
+ runFace = face
+ runColor = style.fg
+ runAlpha = style.alpha
+ runGlyphs.append(known)
+ runPositions.append(CGPoint(x: x, y: baseline))
+ continue
+ }
+
+ // Emoji, combining marks and anything the face is missing: CTLine finds
+ // a fallback font. The position is set explicitly per cell — this is a
+ // fixed grid, and letting CoreText advance across a row would drift off
+ // it.
+ flush()
+ setFill(ctx, style.fg, style.alpha)
+ let attributed = NSAttributedString(string: text, attributes: [fontAttribute: metrics.fonts[face.rawValue]])
+ ctx.textPosition = CGPoint(x: x, y: baseline)
+ CTLineDraw(CTLineCreateWithAttributedString(attributed as CFAttributedString), ctx)
+ // CTLineDraw leaves the text position at the END of what it drew,
+ // and textPosition IS the translation of the text matrix, which
+ // CTFontDrawGlyphs then applies to every position it is handed. So
+ // one fallback glyph silently displaces the entire rest of the
+ // frame by that glyph's advance, down and to the right — and since
+ // the wrap marker and the em dash take this path, that is most
+ // files. Put it back before anything else draws.
+ ctx.textMatrix = .identity
}
+ flush()
}
- private func drawCell(
+ /// 0 is .notdef, i.e. "this face does not have it" — a real answer, cached
+ /// like any other, because the CTLine fallback it sends the caller to costs
+ /// far more than the lookup it would otherwise repeat every frame.
+ private func glyph(_ face: Face, _ character: UniChar) -> CGGlyph {
+ if character < 128 { return metrics.asciiGlyphs[face.rawValue][Int(character)] }
+ let key = UInt32(face.rawValue) << 16 | UInt32(character)
+ if let cached = glyphCache[key] { return cached }
+ var input = character
+ var found = CGGlyph(0)
+ _ = CTFontGetGlyphsForCharacters(metrics.fonts[face.rawValue], &input, &found, 1)
+ glyphCache[key] = found
+ return found
+ }
+
+ private func drawRules(
_ ctx: CGContext,
_ cell: pardes_cell_s,
_ style: (fg: UInt32, bg: UInt32, alpha: CGFloat, visible: Bool),
x: CGFloat,
baseline: CGFloat
) {
- // Rules before the glyph, and independent of it: an underlined space is a
- // real thing and so is an underlined invisible cell.
let underline = Int(cell.attrs >> PARDES_ATTR_UL_SHIFT) & 7
if underline != Int(PARDES_UL_OFF) {
- let y = baseline + underlineOffset
- fill(ctx, CGRect(x: x, y: y, width: cellWidth, height: ruleThickness), style.fg, style.alpha)
+ let y = baseline + metrics.underlineOffset
+ fill(ctx, CGRect(x: x, y: y, width: cellWidth, height: metrics.ruleThickness), style.fg, style.alpha)
// ponytail: curly, dotted and dashed all come out solid; only double
// earns its second rule. ctx.setLineDash for two of them and a sine
// path for the third is the upgrade, once anyone notices.
if underline == Int(PARDES_UL_DOUBLE) {
- fill(ctx, CGRect(x: x, y: y - ruleThickness * 2, width: cellWidth, height: ruleThickness),
+ fill(ctx, CGRect(x: x, y: y - metrics.ruleThickness * 2, width: cellWidth, height: metrics.ruleThickness),
style.fg, style.alpha)
}
}
if cell.attrs & UInt16(PARDES_ATTR_STRIKETHROUGH) != 0 {
- fill(ctx, CGRect(x: x, y: baseline + ascent * 0.3, width: cellWidth, height: 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)
}
- guard style.visible else { return }
-
- // UNVERIFIED: withUnsafeBytes over an imported C fixed-size array, which
- // Swift models as an 8-tuple. String(decoding:) substitutes U+FFFD rather
- // than trapping, and the core has shipped invalid UTF-8 through here
- // before — the renderer must not be the thing that dies over it. prefix
- // clamps, so a bogus len cannot walk off the eight bytes either.
- let text = withUnsafeBytes(of: cell.text) { raw in
- String(decoding: raw.prefix(Int(cell.len)), as: UTF8.self)
- }
- guard !text.isEmpty, text != " " else { return }
-
- let heavy = cell.attrs & UInt16(PARDES_ATTR_BOLD) != 0
- let slanted = cell.attrs & UInt16(PARDES_ATTR_ITALIC) != 0
- let font = heavy ? (slanted ? boldItalic : bold) : (slanted ? italic : regular)
- setFill(ctx, style.fg, style.alpha)
-
- // ponytail: no glyph cache — a cmap lookup per cell, and a whole CTLine
- // for anything that is not one BMP scalar the face covers. A
- // [UnicodeScalar: CGGlyph] map, then an atlas, is the upgrade path when a
- // full redraw shows up in Instruments.
- let units = text.utf16
- if units.count == 1, var character = units.first {
- var glyph = CGGlyph(0)
- if CTFontGetGlyphsForCharacters(font, &character, &glyph, 1) {
- var position = CGPoint(x: x, y: baseline)
- CTFontDrawGlyphs(font, &glyph, &position, 1, ctx)
- return
- }
- }
- // Emoji, combining marks and anything the face is missing: CTLine finds a
- // fallback font. The position is set explicitly per cell — this is a fixed
- // grid, and letting CoreText advance across a row would drift off it.
- let attributed = NSAttributedString(string: text, attributes: [fontAttribute: font])
- ctx.textPosition = CGPoint(x: x, y: baseline)
- CTLineDraw(CTLineCreateWithAttributedString(attributed as CFAttributedString), ctx)
}
private func setFill(_ ctx: CGContext, _ rgb: UInt32, _ alpha: CGFloat) {
@@ -319,12 +849,87 @@ final class PardesView: NSView {
ctx.fill(rect)
}
+ // MARK: - the core, and the pump
+
+ /// Everything below ends here. Posting the notification in one place is
+ /// what guarantees no entry point can feed the core and forget to ask for
+ /// the tick that performs it.
+ private func fed() {
+ NotificationCenter.default.post(name: pardesDidInputNotification, object: self)
+ }
+
+ func typeKey(_ cp: UInt32, text: String, mods: UInt32) {
+ // The pointer is borrowed for the call and nowhere else, which is the only
+ // thing the header promises about it.
+ text.withCString { pardes_key(cp, $0, text.utf8.count, mods) }
+ fed()
+ }
+
+ // `mods` defaults to none because a synthesized gesture carries no
+ // keyboard state; the NSEvent overrides always pass the real mask. Ctrl is
+ // the one the core actually consults — a left press with it held is
+ // goto-definition — so dropping it here would silently delete a feature.
+ func press(_ button: pardes_mouse_button_e, at cell: GridPoint, mods: UInt32 = 0) {
+ pardes_mouse(button, PARDES_MOUSE_PRESS, cell.col, cell.row, mods)
+ fed()
+ }
+
+ func release(_ button: pardes_mouse_button_e, at cell: GridPoint, mods: UInt32 = 0) {
+ pardes_mouse(button, PARDES_MOUSE_RELEASE, cell.col, cell.row, mods)
+ fed()
+ }
+
+ func drag(_ button: pardes_mouse_button_e, to cell: GridPoint, mods: UInt32 = 0) {
+ pardes_mouse(button, PARDES_MOUSE_DRAG, cell.col, cell.row, mods)
+ fed()
+ }
+
+ func motion(to cell: GridPoint, mods: UInt32 = 0) {
+ pardes_mouse(PARDES_MOUSE_NONE, PARDES_MOUSE_MOTION, cell.col, cell.row, mods)
+ fed()
+ }
+
+ /// A press and its release with nothing in between, which is what every
+ /// synthesized click is: a trackpad gesture we recognised rather than a
+ /// button the user held.
+ func click(_ button: pardes_mouse_button_e, at cell: GridPoint, mods: UInt32 = 0) {
+ press(button, at: cell, mods: mods)
+ release(button, at: cell, mods: mods)
+ }
+
+ /// One discrete wheel notch, as opposed to the continuous travel below.
+ func wheel(_ button: pardes_mouse_button_e, at cell: GridPoint, mods: UInt32 = 0) {
+ pardes_mouse(button, PARDES_MOUSE_PRESS, cell.col, cell.row, mods)
+ fed()
+ }
+
+ func scroll(rows: CGFloat, cols: CGFloat = 0, at cell: GridPoint) {
+ guard rows != 0 || cols != 0 else { return }
+ pardes_scroll(Float(rows), Float(cols), cell.col, cell.row)
+ fed()
+ }
+
+ func rotate(degrees: CGFloat) {
+ guard degrees != 0 else { return }
+ pardes_rotate(Float(degrees))
+ 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) {
let flags = event.modifierFlags
// The ABI has no super bit, so a Command chord cannot be expressed at all.
- // Cmd-V is the paste the delegate owns; every other one is swallowed
+ // Anything the main menu claims never reaches here; the rest is swallowed
// rather than delivered as the bare keystroke the core would insert.
if flags.contains(.command) {
if event.charactersIgnoringModifiers?.lowercased() == "v" {
@@ -376,52 +981,188 @@ final class PardesView: NSView {
// carry text.
let functional = codepoint < 0x20 || codepoint == 0x7F || codepoint >= 0xF0000
let text = functional || control || option ? "" : composed
- // The pointer is borrowed for the call and nowhere else, which is the only
- // thing the header promises about it.
- text.withCString { pardes_key(codepoint, $0, text.utf8.count, modifiers(flags)) }
- NotificationCenter.default.post(name: inputNotification, object: self)
+ // Typing is the moment the pointer stops being interesting and starts
+ // sitting on top of the words. It comes back on the next mouse move.
+ NSCursor.setHiddenUntilMouseMoves(true)
+ typeKey(codepoint, text: text, mods: modifiers(flags))
+ }
+
+ // MARK: - trackpad
+
+ // NSTouch arrives through these four and nowhere else. They are the only
+ // reliable source of "how many fingers are down right now": the touch set
+ // on a mouse event is an accident of how the click was produced, and an
+ // empty one is indistinguishable from one finger.
+ override func touchesBegan(with event: NSEvent) { countTouches(event) }
+ override func touchesMoved(with event: NSEvent) { countTouches(event) }
+ override func touchesEnded(with event: NSEvent) { countTouches(event) }
+ override func touchesCancelled(with event: NSEvent) { countTouches(event) }
+
+ private func countTouches(_ event: NSEvent) {
+ restingFingers = event.touches(matching: .touching, in: nil).count
+ trace("touch: resting=\(restingFingers)")
}
// MARK: - mouse
- override func mouseDown(with event: NSEvent) { send(PARDES_MOUSE_LEFT, PARDES_MOUSE_PRESS, event) }
- override func mouseUp(with event: NSEvent) { send(PARDES_MOUSE_LEFT, PARDES_MOUSE_RELEASE, event) }
- override func mouseDragged(with event: NSEvent) { send(PARDES_MOUSE_LEFT, PARDES_MOUSE_DRAG, event) }
- override func rightMouseDown(with event: NSEvent) { send(PARDES_MOUSE_RIGHT, PARDES_MOUSE_PRESS, event) }
- override func rightMouseUp(with event: NSEvent) { send(PARDES_MOUSE_RIGHT, PARDES_MOUSE_RELEASE, event) }
- override func rightMouseDragged(with event: NSEvent) { send(PARDES_MOUSE_RIGHT, PARDES_MOUSE_DRAG, event) }
+ // All three button streams land in the same three functions, because which
+ // stream a trackpad click arrives on is not something the app gets to know
+ // in advance: with macOS's secondary click on, two AND three fingers both
+ // come in as rightMouseDown. The button is therefore decided once, at the
+ // press, from the fingers plus the stream, and then LATCHED — the core is
+ // tracking a drag keyed by button, and answering a press of 3 with a
+ // release of 1 leaves it holding a sweep nothing will ever end.
+ //
+ // The count itself is maintained by the touchesXxx callbacks above; see
+ // beginClick for why it can only come from there.
+ private func beginClick(_ stream: pardes_mouse_button_e, _ event: NSEvent) {
+ guard let at = cell(for: event) else { return }
+ pressureStage = 0
+ // The count comes from the touch stream and NEVER from the mouse event.
+ // Asking a mouse event for its touches is not merely unreliable, it
+ // raises: -[NSEvent touchesMatchingPhase:inView:] is defined for
+ // gesture and touch events, and on anything else AppKit throws, catches
+ // it inside its own event dispatch, and abandons the rest of this
+ // method. Nothing crashes and nothing is logged — every click just
+ // silently stops working, a plain drag included, while rotation and
+ // scrolling carry on as if the backend were fine. That is exactly how
+ // this presented, and it is why restingFingers exists.
+ let button = Trackpad.button(stream: stream, fingers: restingFingers)
+ trace("press: stream=\(stream.rawValue) fingers=\(restingFingers) -> button=\(button.rawValue)")
+ latchedButton = button
+ latchedCell = at
+ press(button, at: at, mods: modifiers(event.modifierFlags))
+ }
+
+ private func continueClick(_ event: NSEvent) {
+ guard let button = latchedButton, let at = cell(for: event) else { return }
+ latchedCell = at
+ drag(button, to: at, mods: modifiers(event.modifierFlags))
+ }
+
+ private func endClick(_ event: NSEvent) {
+ pressureStage = 0
+ // Already nil when the force click below converted this press: it
+ // released the button itself and there is nothing left to end.
+ guard let button = latchedButton else { return }
+ latchedButton = nil
+ guard let at = cell(for: event) else { return }
+ latchedCell = at
+ release(button, at: at, mods: modifiers(event.modifierFlags))
+ }
+
+ override func mouseDown(with event: NSEvent) { beginClick(PARDES_MOUSE_LEFT, event) }
+ override func mouseDragged(with event: NSEvent) { continueClick(event) }
+ override func mouseUp(with event: NSEvent) { endClick(event) }
+ // Where a two-finger click lands with macOS's own secondary click on, and
+ // where a three-finger one lands too — hence the finger count in
+ // Trackpad.button rather than a hardcoded RIGHT here.
+ override func rightMouseDown(with event: NSEvent) { beginClick(PARDES_MOUSE_RIGHT, event) }
+ override func rightMouseDragged(with event: NSEvent) { continueClick(event) }
+ override func rightMouseUp(with event: NSEvent) { endClick(event) }
// otherMouse covers button 2 and up. acme's vocabulary stops at three, so
// every one of them lands on middle rather than being invented into a fourth.
- override func otherMouseDown(with event: NSEvent) { send(PARDES_MOUSE_MIDDLE, PARDES_MOUSE_PRESS, event) }
- override func otherMouseUp(with event: NSEvent) { send(PARDES_MOUSE_MIDDLE, PARDES_MOUSE_RELEASE, event) }
- override func otherMouseDragged(with event: NSEvent) { send(PARDES_MOUSE_MIDDLE, PARDES_MOUSE_DRAG, event) }
- override func mouseMoved(with event: NSEvent) { send(PARDES_MOUSE_NONE, PARDES_MOUSE_MOTION, event) }
+ override func otherMouseDown(with event: NSEvent) { beginClick(PARDES_MOUSE_MIDDLE, event) }
+ override func otherMouseDragged(with event: NSEvent) { continueClick(event) }
+ override func otherMouseUp(with event: NSEvent) { endClick(event) }
+
+ /// A deep press, which is a second gesture layered on the click already in
+ /// flight. AppKit keeps sending stage-2 events while the finger stays down,
+ /// so only the transition counts.
+ ///
+ /// Whatever button is in flight is released before the middle one goes out:
+ /// a middle press arriving while the core holds a left select-drag is
+ /// acme's 1-2 chord, which is Cut. Releasing first costs a cursor move at
+ /// the click point — which is what clicking there would have done anyway.
+ ///
+ /// Not gated on the press being a LEFT one, which is what stopped this
+ /// working: on a Force Touch trackpad the deep press is just as likely to
+ /// have arrived on the right stream, and an in-flight Look upgraded by
+ /// pressing harder is precisely the gesture. Already-Exec is the only case
+ /// with nothing to do.
+ override func pressureChange(with event: NSEvent) {
+ trace("pressure: stage=\(event.stage) latched=\(String(describing: latchedButton?.rawValue))")
+ guard pressureStage < 2 else { return }
+ pressureStage = event.stage
+ guard event.stage == 2 else { return }
+ guard let at = latchedCell, let current = latchedButton,
+ current != Trackpad.forceClickButton else { return }
+ latchedButton = nil
+ release(current, at: at, mods: modifiers(event.modifierFlags))
+ click(Trackpad.forceClickButton, at: at, mods: modifiers(event.modifierFlags))
+ }
+
+ override func mouseMoved(with event: NSEvent) {
+ guard let at = cell(for: event) else { return }
+ motion(to: at, mods: modifiers(event.modifierFlags))
+ }
override func scrollWheel(with event: NSEvent) {
guard let at = cell(for: event) else { return }
if event.hasPreciseScrollingDeltas {
- // The core scrolls a row at a time, so libpardes accumulates the
- // sub-row travel and spends it as wheel presses — which is why the
+ // The core scrolls a cell at a time, so libpardes accumulates the
+ // sub-cell travel and spends it as wheel presses — which is why the
// cell has to travel with the delta.
- pardes_scroll(Float(-event.scrollingDeltaY / cellHeight), at.col, at.row)
- } else if event.scrollingDeltaY != 0 {
+ scroll(rows: -event.scrollingDeltaY / cellHeight,
+ cols: -event.scrollingDeltaX / cellWidth,
+ at: at)
+ } else {
// AppKit's sign is the opposite of the DOM's: positive deltaY means the
// content moved down, which is a scroll back through history.
- let button = event.scrollingDeltaY > 0 ? PARDES_MOUSE_WHEEL_UP : PARDES_MOUSE_WHEEL_DOWN
- pardes_mouse(button, PARDES_MOUSE_PRESS, at.col, at.row, modifiers(event.modifierFlags))
- } else {
- return
+ if event.scrollingDeltaY != 0 {
+ wheel(event.scrollingDeltaY > 0 ? PARDES_MOUSE_WHEEL_UP : PARDES_MOUSE_WHEEL_DOWN,
+ at: at, mods: modifiers(event.modifierFlags))
+ }
+ if event.scrollingDeltaX != 0 {
+ wheel(event.scrollingDeltaX > 0 ? PARDES_MOUSE_WHEEL_LEFT : PARDES_MOUSE_WHEEL_RIGHT,
+ at: at, mods: modifiers(event.modifierFlags))
+ }
}
- NotificationCenter.default.post(name: inputNotification, object: self)
}
- private func send(_ button: pardes_mouse_button_e, _ kind: pardes_mouse_kind_e, _ event: NSEvent) {
- guard let at = cell(for: event) else { return }
- pardes_mouse(button, kind, at.col, at.row, modifiers(event.modifierFlags))
- NotificationCenter.default.post(name: inputNotification, object: self)
+ /// 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 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 —
+ // 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
+ /// variable the Zig side gates its logger on (src/macos.zig).
+ ///
+ /// This is not scaffolding left behind. Which events a trackpad produces is
+ /// decided by the hardware and by four different System Settings switches
+ /// (secondary click, three-finger drag, force click, "look up"), none of
+ /// which this process can read, and every one of which turns a gesture into
+ /// a different NSEvent or into none at all. When someone reports that
+ /// two-finger Look does nothing, this is the only thing that can answer
+ /// whether AppKit saw two fingers, one, or no click at all.
+ private func trace(_ message: @autoclosure () -> String) {
+ guard PardesView.tracing else { return }
+ FileHandle.standardError.write(Data(("pardes: " + message() + "\n").utf8))
+ }
+
+ private static let tracing = ProcessInfo.processInfo.environment["PARDES_LOG"] != nil
+
+ private func cell(for event: NSEvent) -> GridPoint? {
+ cellAt(convert(event.locationInWindow, from: nil))
}
- private func cell(for event: NSEvent) -> (col: UInt16, row: UInt16)? {
+ func cellAt(_ point: CGPoint) -> GridPoint? {
// Clamp against the frame the core last rendered, not against our own
// metrics: a window that has been resized but not yet ticked would
// otherwise report a column the core has no cell for. Before the first
@@ -429,10 +1170,68 @@ final class PardesView: NSView {
let cols = Int(pardes_frame_cols())
let rows = Int(pardes_frame_rows())
guard cols > 0, rows > 0 else { return nil }
- let point = convert(event.locationInWindow, from: nil)
let col = min(max(Int(point.x / cellWidth), 0), cols - 1)
let row = min(max(Int(point.y / cellHeight), 0), rows - 1)
- return (UInt16(col), UInt16(row))
+ 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
@@ -448,6 +1247,12 @@ final class PardesView: NSView {
userInfo: nil))
}
+ override func resetCursorRects() {
+ // Every cell in this view is text, including the tags. An arrow over a
+ // grid you can sweep and click words in is the wrong affordance.
+ addCursorRect(bounds, cursor: .iBeam)
+ }
+
override func setFrameSize(_ newSize: NSSize) {
super.setFrameSize(newSize)
// AppKit resizes a view many times over one drag and almost all of those
@@ -462,17 +1267,27 @@ 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)
+ }
}
}
// ponytail: no NSTextInputClient, so dead keys and IME composition never reach
// the core — keyDown reads `characters` and that is the whole story. Adopting
// the protocol and routing through interpretKeyEvents is the upgrade when
-// someone needs to type Japanese.
+// someone needs to type Japanese, and it needs the core to be able to render an
+// underlined preedit run first.