diff options
| author | Gabriel Schneider <[email protected]> | 2026-08-16 15:49:12 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-08-18 23:44:42 -0300 |
| commit | 1551e409c31992437cb2fa864f576d45c8433801 (patch) | |
| tree | e2fae8451f87b735a1360c7c2e383fdc40165789 /src/macos/Sources/PardesView.swift | |
| parent | be2a9957708cbf0c478ca861c4a1f0f227bbfe10 (diff) | |
| download | pardes-1551e409c31992437cb2fa864f576d45c8433801.tar.gz pardes-1551e409c31992437cb2fa864f576d45c8433801.zip | |
big slow change: prebuilt shaders (SPIR-V/Metal), core gui reflow, docs, web + snapshot refresh
Diffstat (limited to 'src/macos/Sources/PardesView.swift')
| -rw-r--r-- | src/macos/Sources/PardesView.swift | 562 |
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)) } |
