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.swift562
1 files changed, 487 insertions, 75 deletions
diff --git a/src/macos/Sources/PardesView.swift b/src/macos/Sources/PardesView.swift
index 3d841a60..a22d4d55 100644
--- a/src/macos/Sources/PardesView.swift
+++ b/src/macos/Sources/PardesView.swift
@@ -3,8 +3,8 @@
//
// 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 — plus the two things a gesture needs remembered
+// the only state worth holding is the font metrics — expensive to measure and
+// replaced only by Font/TaglineSize/zoom — plus the gesture state remembered
// between events: which button a click started with, and how hard it is being
// pressed.
//
@@ -195,12 +195,14 @@ let defaultFontSize: CGFloat = 14
/// 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.
+/// Built at init and again for a `Font` command or a zoom. The view clears its
+/// body/tagline glyph caches beside replacing this value: a CGGlyph is an index
+/// into a particular face, so carrying one across a font change is invalid.
private struct Metrics {
let fonts: [CTFont]
+ /// False only when a non-nil requested file could not produce a fixed-pitch
+ /// CoreText face. The caller rejects that request and keeps old Metrics.
+ let acceptedPath: Bool
let ascent: CGFloat
let cellWidth: CGFloat
let cellHeight: CGFloat
@@ -218,7 +220,8 @@ private struct Metrics {
}
init(size: CGFloat, path: String?, scale: CGFloat) {
- let face = Metrics.face(size: size, path: path)
+ let requested = path.flatMap { Metrics.fromFile($0, size) }
+ let face = requested ?? Metrics.defaultFace(size: size)
let scale = max(1, scale)
// UNVERIFIED: CTFontSymbolicTraits member spelling (.traitBold/.traitItalic).
@@ -246,12 +249,16 @@ private struct Metrics {
// UP, because losing a pixel off a descender is clipping.
let snap = Metrics.snap
fonts = faces
+ acceptedPath = path == nil || requested != nil
// 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))
+ let bodyAscent = max(1 / scale, snap(CTFontGetAscent(face), scale, .toNearestOrAwayFromZero))
+ let bodyWidth = max(1 / scale, snap(advance(face, 0x4D), scale, .toNearestOrAwayFromZero))
+ let bodyHeight = max(1 / scale, snap(CTFontGetAscent(face) + CTFontGetDescent(face) + CTFontGetLeading(face), scale, .up))
+ ascent = bodyAscent
+ cellWidth = bodyWidth
+ cellHeight = bodyHeight
ruleThickness = max(1 / scale, snap(CTFontGetUnderlineThickness(face), scale, .toNearestOrAwayFromZero))
underlineOffset = snap(CTFontGetUnderlinePosition(face), scale, .toNearestOrAwayFromZero)
asciiGlyphs = faces.map { font in
@@ -262,11 +269,10 @@ private struct Metrics {
}
}
- /// 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 }
+ /// The regular system face used only when no file was requested. A failed
+ /// request is reported through `acceptedPath`; it never silently replaces
+ /// the face already on screen with this fallback.
+ private static func defaultFace(size: CGFloat) -> CTFont {
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
@@ -314,6 +320,48 @@ private struct Metrics {
}
}
+/// The independently replaceable half of font state. A TaglineSize command
+/// rebuilds only this value and its non-ASCII cache; body metrics and therefore
+/// the core grid never move. `height` is also the painted tag-band height, not
+/// merely a smaller glyph centred inside a body-height colour bar.
+private struct TaglineMetrics {
+ let percent: UInt8
+ let fonts: [CTFont]
+ let baseline: CGFloat
+ let xOffset: CGFloat
+ let top: CGFloat
+ let height: CGFloat
+ let asciiGlyphs: [[CGGlyph]]
+
+ init(body: Metrics, percent: UInt8, scale rawScale: CGFloat) {
+ let scale = max(1, rawScale)
+ func snap(_ value: CGFloat, _ rule: FloatingPointRoundingRule) -> CGFloat {
+ (value * scale).rounded(rule) / scale
+ }
+ self.percent = percent
+ let size = CTFontGetSize(body.fonts[0]) * CGFloat(percent) / 100
+ let faces = body.fonts.map { CTFontCreateCopyWithAttributes($0, size, nil, nil) }
+ fonts = faces
+ let face = faces[Face.regular.rawValue]
+ let measuredHeight = max(1 / scale, snap(
+ CTFontGetAscent(face) + CTFontGetDescent(face) + CTFontGetLeading(face), .up))
+ // Runtime configuration caps the percentage at the body face. Keep the
+ // clamp here too: a malformed/custom host value must not paint a tag
+ // over the body row below it.
+ height = min(body.cellHeight, measuredHeight)
+ top = snap(max(0, (body.cellHeight - height) / 2), .toNearestOrAwayFromZero)
+ let ascent = max(1 / scale, snap(CTFontGetAscent(face), .toNearestOrAwayFromZero))
+ baseline = snap(top + ascent, .toNearestOrAwayFromZero)
+ xOffset = snap(max(0, (body.cellWidth - advance(face, 0x4D)) / 2), .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
+ }
+ }
+}
+
private func modifiers(_ flags: NSEvent.ModifierFlags) -> UInt32 {
var mods: UInt32 = 0
if flags.contains(.control) { mods |= UInt32(PARDES_MOD_CTRL) }
@@ -327,6 +375,7 @@ final class PardesView: NSView {
// The face and the numbers off it, replaced whole by `wear`.
private var metrics: Metrics
+ private var taglines: TaglineMetrics
/// What `metrics` was built from, so a zoom keeps the face and a font
/// change keeps the size.
private var fontSize: CGFloat
@@ -338,13 +387,35 @@ final class PardesView: NSView {
// 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] = [:]
+ // repeat. Keyed by role, face and codepoint, and thrown away with both
+ // body and tagline variants whenever the body face changes.
+ private var bodyGlyphCache: [UInt32: CGGlyph] = [:]
+ private var taglineGlyphCache: [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] = []
+ /// Constructed lazily because the ordinary path is still a direct
+ /// CoreText/CoreGraphics frame. No scene bit and no panel track means no
+ /// bitmap, no Core Image context and no Metal work.
+ private lazy var scenePostprocessor: ScenePostprocessor? = ScenePostprocessor()
+ /// Kernel compilation is permanent for one executable. Runtime allocation
+ /// and device failures get three consecutive attempts before this session
+ /// stops advertising effects its GPU path cannot present.
+ private var postprocessorUnavailable = false
+ private var postprocessRetries = 0
+ /// The scene mapping of the pixels AppKit most recently accepted. Pointer
+ /// events use this snapshot, not a clock that may already be one tick ahead.
+ private var lastPresentedScene: pardes_scene_s?
+ /// Whether the last frame reached the optional Metal owner successfully.
+ /// Ordinary direct draws and a runtime-kernel failure leave this false.
+ private(set) var lastFrameUsedPostprocessor = false
+ /// Monotonic count of frames AppKit actually let this view present. The
+ /// host animation clock uses it as a permit: no new sample is consumed
+ /// while an occluded/coalesced frame is still waiting to be drawn.
+ private(set) var presentationSerial: UInt64 = 0
+
private var reportedCols: UInt16 = 0
private var reportedRows: UInt16 = 0
@@ -379,6 +450,13 @@ final class PardesView: NSView {
// core holding a drag nothing will ever end.
private var latchedButton: pardes_mouse_button_e?
private var latchedCell: GridPoint?
+ /// Last physical point delivered by AppKit and its mapping through the
+ /// accepted scene. Ripple/glitch can move a source cell under a stationary
+ /// hand, so successful scene presentations reconcile this point again.
+ private var pointerPoint: CGPoint?
+ private var pointerCell: GridPoint?
+ private var pointerMapped = false
+ private var pointerModifiers: UInt32 = 0
// 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
@@ -401,6 +479,8 @@ final class PardesView: NSView {
// 1x case, which a bare guess would otherwise leave wrong forever.
let built = Metrics(size: size, path: nil, scale: 2)
metrics = built
+ taglines = TaglineMetrics(
+ body: built, percent: pardes_gui_tagline_font_percent(), scale: 2)
fontSize = size
fontPath = nil
@@ -434,6 +514,7 @@ final class PardesView: NSView {
/// 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 }
+ var effectivePointSize: CGFloat { CTFontGetSize(metrics.fonts[0]) }
/// Put on a face, or the same face at a different size, and tell the host
/// the grid moved under it.
@@ -441,27 +522,68 @@ final class PardesView: NSView {
/// 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?) {
+ /// grid and the physical cell the PDF placement reads. A requested file
+ /// which cannot produce a fixed-pitch face is rejected before assignment,
+ /// leaving the screen exactly as it was rather than substituting a face.
+ private func wear(size: CGFloat, path: String?, resolvesRequest: Bool = false) {
let scale = window?.backingScaleFactor ?? metricsScale
let next = Metrics(size: size, path: path, scale: scale)
+ guard next.acceptedPath else {
+ // The requested file never became the fixed-pitch face the grid
+ // requires. Keep every old metric and resolve the pending request
+ // as rejected rather than claiming the system fallback is it.
+ if resolvesRequest { pardes_font_reject() }
+ return
+ }
// 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)
+ bodyGlyphCache.removeAll(keepingCapacity: true)
+ taglineGlyphCache.removeAll(keepingCapacity: true)
metrics = next
+ taglines = TaglineMetrics(body: next, percent: taglines.percent, scale: scale)
metricsScale = scale
fontSize = size
fontPath = path
delegate?.pardesViewDidResize(self)
needsDisplay = true
+ reportEffectiveFont(resolvesRequest: resolvesRequest)
+ }
+
+ /// Apply the core's live TaglineSize setting without touching body metrics,
+ /// the window grid, or PDF cell geometry. Called every pump; equality makes
+ /// the ordinary path one byte comparison.
+ @discardableResult
+ func adoptTaglinePercent(_ percent: UInt8) -> Bool {
+ guard percent != taglines.percent else { return false }
+ taglines = TaglineMetrics(body: metrics, percent: percent, scale: metricsScale)
+ taglineGlyphCache.removeAll(keepingCapacity: true)
+ needsDisplay = true
+ return true
+ }
+
+ var taglinePercent: UInt8 { taglines.percent }
+ var taglineBandHeight: CGFloat { taglines.height }
+
+ /// Seed runtime Config after init and update it after every successful
+ /// adopt/zoom. Points are the native owner's unit; the cell's backing-pixel
+ /// dimensions continue to travel through pardes_resize separately.
+ func reportEffectiveFont(resolvesRequest: Bool = false) {
+ let hundredths = UInt16(min(
+ max((effectivePointSize * 100).rounded(), 1),
+ CGFloat(UInt16.max)))
+ let name = faceName
+ name.withCString { pointer in
+ if resolvesRequest {
+ _ = pardes_font_ack(pointer, name.utf8.count, hundredths)
+ } else {
+ _ = pardes_font_observe(pointer, name.utf8.count, hundredths)
+ }
+ }
}
/// The file `Font <name>` resolved to, straight from the core.
func adoptFont(path: String) {
- wear(size: fontSize, path: path)
+ wear(size: fontSize, path: path, resolvesRequest: true)
}
/// Cmd+ and Cmd-. Whole points, because the cell is rounded to whole
@@ -501,11 +623,150 @@ final class PardesView: NSView {
// MARK: - drawing
override func draw(_ dirtyRect: NSRect) {
- guard let ctx = NSGraphicsContext.current?.cgContext else { return }
+ guard let destination = NSGraphicsContext.current?.cgContext else { return }
+ lastFrameUsedPostprocessor = false
// ponytail: dirtyRect is ignored. pardes_frame() re-renders the whole grid
// whatever we do, so clipping would save fills and nothing else. Narrow
// the row loops to the dirty band if that ever shows up in a profile.
let count = pardes_frame()
+ let scene = pardes_scene()
+ let trackCount = Int(pardes_frame_panel_tracks())
+ let tracks: UnsafeBufferPointer<pardes_panel_track_s>
+ if trackCount > 0, let list = pardes_frame_panel_track_list() {
+ tracks = UnsafeBufferPointer(start: list, count: trackCount)
+ } else {
+ tracks = UnsafeBufferPointer(start: nil, count: 0)
+ }
+ let wantsPostprocess = scene.flags != 0 || !tracks.isEmpty
+ if wantsPostprocess {
+ if postprocessorUnavailable {
+ pardes_postprocessor_unavailable()
+ } else if let postprocessor = scenePostprocessor {
+ if let sceneContext = postprocessor.renderContext(
+ size: bounds.size, scale: window?.backingScaleFactor ?? metricsScale
+ ) {
+ drawFrame(sceneContext, count: count, hideCursor: !tracks.isEmpty)
+ let needsPrevious = tracks.contains {
+ $0.effect == UInt8(PARDES_PANEL_DISSOLVE)
+ || $0.effect == UInt8(PARDES_PANEL_ASCII)
+ || $0.effect == UInt8(PARDES_PANEL_VERTICAL)
+ }
+ var oldReady = !needsPrevious
+ let previousCells = pardes_frame_previous_cells()
+ let changedCells = pardes_frame_changed_cells()
+ if needsPrevious, let previousCells, changedCells != nil,
+ let oldContext = postprocessor.previousRenderContext(
+ size: bounds.size,
+ scale: window?.backingScaleFactor ?? metricsScale
+ ) {
+ drawFrame(
+ oldContext, count: count, cells: previousCells,
+ frozenImages: presentedImages, hideCursor: true)
+ oldReady = true
+ }
+ let outcome: ScenePostprocessor.CompositeOutcome = oldReady
+ ? postprocessor.composite(
+ scene: scene, tracks: tracks,
+ changedCells: changedCells,
+ changedAttachmentRects: changedAttachmentRects(),
+ gridSize: (
+ cols: Int(pardes_frame_cols()),
+ rows: Int(pardes_frame_rows())),
+ cellSize: CGSize(width: cellWidth, height: cellHeight),
+ ground: themeBG, size: bounds.size, into: destination)
+ : .unavailable
+ switch outcome {
+ case .presented:
+ postprocessRetries = 0
+ lastPresentedScene = scene
+ lastFrameUsedPostprocessor = true
+ acknowledgePresentation(animatedPanels: !tracks.isEmpty)
+ return
+ case .retry:
+ postprocessRetries += 1
+ case .unavailable:
+ postprocessorUnavailable = true
+ }
+ } else {
+ postprocessRetries += 1
+ }
+ // This draw now falls back to canonical content. Scene effects
+ // may retry, but a panel must not resume from a later sample
+ // after its final geometry has already been shown.
+ if !tracks.isEmpty { pardes_panel_animation_failed() }
+ if postprocessRetries >= 3 { postprocessorUnavailable = true }
+ if postprocessorUnavailable { pardes_postprocessor_unavailable() }
+ } else {
+ // The source, Metal device, or fixed kernel set was absent at
+ // lazy construction. None can recover within this process.
+ postprocessorUnavailable = true
+ pardes_postprocessor_unavailable()
+ }
+ } else {
+ postprocessRetries = 0
+ }
+ lastPresentedScene = nil
+ drawFrame(destination, count: count, hideCursor: false)
+ acknowledgePresentation(animatedPanels: false)
+ }
+
+ /// A presentation can materialize a Look target under a stationary mouse
+ /// after AppDelegate already decided whether to run its 60 Hz clock. Wake
+ /// the ordinary pump only for that null->active edge; repeated frame acks
+ /// remain inert and cannot create a redraw loop.
+ private func acknowledgePresentation(animatedPanels: Bool) {
+ // Only a canonical presentation advances the attachment baseline.
+ // During a transition deleted panes keep their cropped CGImages alive
+ // here even after both the core placement and image cache entry vanish.
+ if !animatedPanels { presentedImages = frameImages }
+ presentationSerial &+= 1
+ let animationWoke = pardes_frame_presented(animatedPanels)
+ let pointerMoved = refreshPresentedPointer()
+ guard animationWoke || pointerMoved else { return }
+ DispatchQueue.main.async {
+ NotificationCenter.default.post(name: pardesDidInputNotification, object: self)
+ }
+ }
+
+ /// Re-feed only a changed mapping. Sending motion on every 60 Hz scene
+ /// frame would keep resetting the core's hover debounce forever.
+ private func refreshPresentedPointer() -> Bool {
+ guard let point = pointerPoint else { return false }
+ guard let at = cellAt(point) else {
+ guard pointerMapped else { return false }
+ pointerMapped = false
+ pardes_pointer_leave()
+ return true
+ }
+ let changed: Bool
+ if let previous = pointerCell {
+ changed = !pointerMapped || previous.col != at.col || previous.row != at.row
+ } else {
+ changed = true
+ }
+ pointerMapped = true
+ pointerCell = at
+ guard changed else { return false }
+ if let button = latchedButton {
+ latchedCell = at
+ pardes_mouse(button, PARDES_MOUSE_DRAG, at.col, at.row, pointerModifiers)
+ } else {
+ pardes_mouse(PARDES_MOUSE_NONE, PARDES_MOUSE_MOTION,
+ at.col, at.row, pointerModifiers)
+ }
+ return true
+ }
+
+ /// The canonical CoreText frame, independent of where it lands. Scene
+ /// effects change only the destination: direct view context when off, the
+ /// postprocessor's same-sized retained bitmap when on.
+ private func drawFrame(
+ _ ctx: CGContext,
+ count: UInt32,
+ cells suppliedCells: UnsafePointer<pardes_cell_s>? = nil,
+ frozenImages: [FrozenImage]? = nil,
+ hideCursor: Bool
+ ) {
let cols = Int(pardes_frame_cols())
let rows = Int(pardes_frame_rows())
// The ground, from the core rather than from a constant agreed by hand.
@@ -515,13 +776,19 @@ final class PardesView: NSView {
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 }
+ if frozenImages == nil { frameImages.removeAll(keepingCapacity: true) }
+ guard cols > 0, rows > 0, Int(count) == cols * rows,
+ let cells = suppliedCells ?? pardes_frame_cells()
+ else { return }
// -1 when hidden, which never matches a real cell, so hidden and "bar, so
// not a block" collapse into the same comparison.
- let bar = pardes_cursor_bar()
- let blockX = bar ? -1 : Int(pardes_cursor_x())
- let blockY = bar ? -1 : Int(pardes_cursor_y())
+ // A cursor has no pane/phase record. Match SDL and TTY by hiding it for
+ // the short panel transition instead of guessing whether it should
+ // move, stay fixed, or cover an overlapping opening pane.
+ let bar = !hideCursor && pardes_cursor_bar()
+ let blockX = hideCursor || bar ? -1 : Int(pardes_cursor_x())
+ let blockY = hideCursor || bar ? -1 : Int(pardes_cursor_y())
// Background first, batched into runs of equal color. A full redraw is
// 80x24 cells at the low end and per-cell fills are exactly what makes
@@ -534,6 +801,7 @@ final class PardesView: NSView {
var start = 0
var color = resolve(cells[base], block: blockY == row && blockX == 0,
ground: ground, clearGround: clearGround).bg
+ var tagline = cells[base].flags & UInt8(PARDES_CELL_TAGLINE) != 0
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.
@@ -541,16 +809,21 @@ final class PardesView: NSView {
? .max
: resolve(cells[base + col], block: blockY == row && blockX == col,
ground: ground, clearGround: clearGround).bg
- if next == color { continue }
+ let nextTagline = col < cols
+ && cells[base + col].flags & UInt8(PARDES_CELL_TAGLINE) != 0
+ if next == color && nextTagline == tagline { continue }
// 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),
+ let bandY = tagline ? y + taglines.top : y
+ let bandHeight = tagline ? taglines.height : cellHeight
+ fill(ctx, CGRect(x: CGFloat(start) * cellWidth, y: bandY,
+ width: CGFloat(col - start) * cellWidth, height: bandHeight),
color, 1)
}
start = col
color = next
+ tagline = nextTagline
}
}
@@ -589,18 +862,26 @@ final class PardesView: NSView {
// 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 let frozenImages {
+ drawFrozenImages(ctx, frozenImages)
+ } else {
+ 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,
+ let cell = cells[y * cols + x]
+ let fg = resolve(cell, block: false,
ground: themeBG ?? pardesDefaultBG, clearGround: false).fg
+ let tagline = cell.flags & UInt8(PARDES_CELL_TAGLINE) != 0
+ let caretY = CGFloat(y) * cellHeight + (tagline ? taglines.top : 0)
+ let caretHeight = tagline ? taglines.height : cellHeight
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)
+ fill(ctx, CGRect(x: CGFloat(x) * cellWidth, y: caretY,
+ width: max(1, (cellWidth / 8).rounded(.up)), height: caretHeight), fg, 1)
}
}
}
@@ -614,11 +895,24 @@ final class PardesView: NSView {
let revision: UInt32
}
+ /// A fully-owned attachment placement from the last canonical frame.
+ /// The cropped CGImage retains its provider bytes, unlike pardes_image_s's
+ /// pointer, so a vertical closing tombstone can outlive pane deletion.
+ private struct FrozenImage {
+ let key: ImageKey
+ let image: CGImage
+ let source: CGRect
+ let body: CGRect
+ let destination: CGRect
+ }
+
/// 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 var frameImages: [FrozenImage] = []
+ private var presentedImages: [FrozenImage] = []
private func drawImages(_ ctx: CGContext) {
let count = Int(pardes_frame_images())
@@ -641,9 +935,10 @@ final class PardesView: NSView {
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(
+ let source = CGRect(
x: Int(place.src_x), y: Int(place.src_y),
- width: Int(place.src_w), height: Int(place.src_h)))
+ width: Int(place.src_w), height: Int(place.src_h))
+ guard let crop = full.cropping(to: source)
else { continue }
// The body is the rectangle nothing may paint past. The core has
@@ -657,20 +952,11 @@ final class PardesView: NSView {
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()
+ let frozen = FrozenImage(
+ key: key, image: crop, source: source,
+ body: body, destination: dst)
+ frameImages.append(frozen)
+ drawFrozenImage(ctx, frozen)
}
// Evict what this frame did not place. Scrolling a document past a page
@@ -681,6 +967,52 @@ final class PardesView: NSView {
}
}
+ private func drawFrozenImages(_ ctx: CGContext, _ images: [FrozenImage]) {
+ ctx.setShouldAntialias(true)
+ for image in images { drawFrozenImage(ctx, image) }
+ }
+
+ /// Native attachments do not occupy Cell graphemes, so the core's semantic
+ /// text/style mask cannot see a page appearing, disappearing, rerasterizing
+ /// or moving. Add only those changed placements to the shader mask; an
+ /// identical key and geometry remains immediate like any unchanged cell.
+ private func changedAttachmentRects() -> [CGRect] {
+ var oldByKey: [ImageKey: FrozenImage] = [:]
+ for image in presentedImages { oldByKey[image.key] = image }
+ var changed: [CGRect] = []
+ changed.reserveCapacity(frameImages.count + presentedImages.count)
+ for image in frameImages {
+ guard let old = oldByKey.removeValue(forKey: image.key) else {
+ changed.append(image.destination.intersection(image.body))
+ continue
+ }
+ if old.source != image.source
+ || old.body != image.body || old.destination != image.destination {
+ changed.append(old.destination.intersection(old.body))
+ changed.append(image.destination.intersection(image.body))
+ }
+ }
+ for old in oldByKey.values {
+ changed.append(old.destination.intersection(old.body))
+ }
+ return changed.filter { !$0.isNull && !$0.isEmpty }
+ }
+
+ private func drawFrozenImage(_ ctx: CGContext, _ placed: FrozenImage) {
+ ctx.saveGState()
+ ctx.clip(to: placed.body)
+ // isFlipped gives us a y-down CTM and CGImage draws +y up, so flip
+ // about this destination rather than changing the grid arithmetic.
+ ctx.translateBy(x: placed.destination.minX, y: placed.destination.maxY)
+ ctx.scaleBy(x: 1, y: -1)
+ ctx.interpolationQuality = .high
+ ctx.draw(placed.image, in: CGRect(
+ x: 0, y: 0,
+ width: placed.destination.width,
+ height: placed.destination.height))
+ ctx.restoreGState()
+ }
+
/// 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 }
@@ -716,6 +1048,7 @@ final class PardesView: NSView {
blockCol: Int
) {
var runFace = Face.regular
+ var runTagline = false
var runColor: UInt32 = 0
var runAlpha: CGFloat = 1
runGlyphs.removeAll(keepingCapacity: true)
@@ -724,7 +1057,10 @@ final class PardesView: NSView {
func flush() {
guard !runGlyphs.isEmpty else { return }
setFill(ctx, runColor, runAlpha)
- CTFontDrawGlyphs(metrics.fonts[runFace.rawValue], runGlyphs, runPositions, runGlyphs.count, ctx)
+ let font = runTagline
+ ? taglines.fonts[runFace.rawValue]
+ : metrics.fonts[runFace.rawValue]
+ CTFontDrawGlyphs(font, runGlyphs, runPositions, runGlyphs.count, ctx)
runGlyphs.removeAll(keepingCapacity: true)
runPositions.removeAll(keepingCapacity: true)
}
@@ -732,6 +1068,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 tagline = cell.flags & UInt8(PARDES_CELL_TAGLINE) != 0
+ let cellBaseline = tagline
+ ? baseline + metrics.ascent - taglines.baseline
+ : baseline
// clearGround: false — this pass only reads `fg`, and a glyph is
// never the hole in the ground.
let style = resolve(cell, block: blockCol == col,
@@ -744,7 +1084,7 @@ final class PardesView: NSView {
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)
+ drawRules(ctx, cell, style, x: x, baseline: cellBaseline)
}
guard style.visible else { continue }
@@ -761,17 +1101,20 @@ final class PardesView: NSView {
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
+ let known = units.count == 1 ? glyph(face, units.first!, tagline: tagline) : 0
+ let glyphX = x + (tagline ? taglines.xOffset : 0)
if known != 0 {
if !runGlyphs.isEmpty
- && (face != runFace || style.fg != runColor || style.alpha != runAlpha) {
+ && (face != runFace || tagline != runTagline
+ || style.fg != runColor || style.alpha != runAlpha) {
flush()
}
runFace = face
+ runTagline = tagline
runColor = style.fg
runAlpha = style.alpha
runGlyphs.append(known)
- runPositions.append(CGPoint(x: x, y: baseline))
+ runPositions.append(CGPoint(x: glyphX, y: cellBaseline))
continue
}
@@ -781,8 +1124,9 @@ final class PardesView: NSView {
// 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)
+ let font = tagline ? taglines.fonts[face.rawValue] : metrics.fonts[face.rawValue]
+ let attributed = NSAttributedString(string: text, attributes: [fontAttribute: font])
+ ctx.textPosition = CGPoint(x: glyphX, y: cellBaseline)
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
@@ -799,14 +1143,23 @@ final class PardesView: NSView {
/// 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)] }
+ private func glyph(_ face: Face, _ character: UniChar, tagline: Bool) -> CGGlyph {
+ if character < 128 {
+ let ascii = tagline ? taglines.asciiGlyphs : metrics.asciiGlyphs
+ return ascii[face.rawValue][Int(character)]
+ }
let key = UInt32(face.rawValue) << 16 | UInt32(character)
- if let cached = glyphCache[key] { return cached }
+ if tagline, let cached = taglineGlyphCache[key] { return cached }
+ if !tagline, let cached = bodyGlyphCache[key] { return cached }
var input = character
var found = CGGlyph(0)
- _ = CTFontGetGlyphsForCharacters(metrics.fonts[face.rawValue], &input, &found, 1)
- glyphCache[key] = found
+ let font = tagline ? taglines.fonts[face.rawValue] : metrics.fonts[face.rawValue]
+ _ = CTFontGetGlyphsForCharacters(font, &input, &found, 1)
+ if tagline {
+ taglineGlyphCache[key] = found
+ } else {
+ bodyGlyphCache[key] = found
+ }
return found
}
@@ -858,6 +1211,14 @@ final class PardesView: NSView {
NotificationCenter.default.post(name: pardesDidInputNotification, object: self)
}
+ /// A point inside the NSView can still be outside the CRT's curved tube.
+ /// For input that is the same as leaving the grid: discard a pending hover
+ /// preview, but do not manufacture a clamped click or wheel gesture.
+ private func leftGrid() {
+ pardes_pointer_leave()
+ fed()
+ }
+
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.
@@ -1016,7 +1377,10 @@ final class PardesView: NSView {
// 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 }
+ guard let at = cell(for: event) else {
+ leftGrid()
+ 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
@@ -1035,7 +1399,14 @@ final class PardesView: NSView {
}
private func continueClick(_ event: NSEvent) {
- guard let button = latchedButton, let at = cell(for: event) else { return }
+ guard let button = latchedButton else { return }
+ guard let at = cell(for: event) else {
+ // The drag is still latched, but the curved CRT edge displays no
+ // source cell here. Keep its last real endpoint for mouseUp and
+ // clear hover/seam state without clamping into the grid.
+ leftGrid()
+ return
+ }
latchedCell = at
drag(button, to: at, mods: modifiers(event.modifierFlags))
}
@@ -1046,9 +1417,23 @@ final class PardesView: NSView {
// 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))
+ if let at = cell(for: event) {
+ latchedCell = nil
+ release(button, at: at, mods: modifiers(event.modifierFlags))
+ return
+ }
+ // A release must balance its press even when the hand ends in a black
+ // CRT corner. The last mapped drag point is an observed source cell,
+ // not a clamp of the unmappable release point. Send both state changes
+ // before one pump notification so no frame can observe a held button
+ // whose pointer is already outside the displayed scene.
+ if let last = latchedCell {
+ pardes_mouse(button, PARDES_MOUSE_RELEASE, last.col, last.row,
+ modifiers(event.modifierFlags))
+ }
+ latchedCell = nil
+ pardes_pointer_leave()
+ fed()
}
override func mouseDown(with event: NSEvent) { beginClick(PARDES_MOUSE_LEFT, event) }
@@ -1088,17 +1473,30 @@ final class PardesView: NSView {
guard let at = latchedCell, let current = latchedButton,
current != Trackpad.forceClickButton else { return }
latchedButton = nil
+ latchedCell = 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 }
+ guard let at = cell(for: event) else {
+ leftGrid()
+ return
+ }
motion(to: at, mods: modifiers(event.modifierFlags))
}
+ override func mouseExited(with event: NSEvent) {
+ pointerPoint = nil
+ pointerMapped = false
+ leftGrid()
+ }
+
override func scrollWheel(with event: NSEvent) {
- guard let at = cell(for: event) else { return }
+ guard let at = cell(for: event) else {
+ leftGrid()
+ return
+ }
if event.hasPreciseScrollingDeltas {
// 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
@@ -1159,7 +1557,13 @@ final class PardesView: NSView {
private static let tracing = ProcessInfo.processInfo.environment["PARDES_LOG"] != nil
private func cell(for event: NSEvent) -> GridPoint? {
- cellAt(convert(event.locationInWindow, from: nil))
+ let point = convert(event.locationInWindow, from: nil)
+ pointerPoint = point
+ pointerModifiers = modifiers(event.modifierFlags)
+ let at = cellAt(point)
+ pointerMapped = at != nil
+ if let at { pointerCell = at }
+ return at
}
func cellAt(_ point: CGPoint) -> GridPoint? {
@@ -1170,8 +1574,16 @@ 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 col = min(max(Int(point.x / cellWidth), 0), cols - 1)
- let row = min(max(Int(point.y / cellHeight), 0), rows - 1)
+ let sampled: CGPoint
+ if let scene = lastPresentedScene {
+ guard let mapped = ScenePostprocessor.sourcePoint(point, size: bounds.size, scene: scene)
+ else { return nil }
+ sampled = mapped
+ } else {
+ sampled = point
+ }
+ let col = min(max(Int(sampled.x / cellWidth), 0), cols - 1)
+ let row = min(max(Int(sampled.y / cellHeight), 0), rows - 1)
return GridPoint(col: UInt16(col), row: UInt16(row))
}
@@ -1242,7 +1654,7 @@ final class PardesView: NSView {
// .inVisibleRect keeps the area correct across resizes on its own, which
// is why the rect argument can be anything.
addTrackingArea(NSTrackingArea(rect: .zero,
- options: [.mouseMoved, .inVisibleRect, .activeInKeyWindow],
+ options: [.mouseMoved, .mouseEnteredAndExited, .inVisibleRect, .activeInKeyWindow],
owner: self,
userInfo: nil))
}