summaryrefslogtreecommitdiff
path: root/src/macos/Sources
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-08-16 15:49:12 -0300
committerGabriel Schneider <[email protected]>2026-08-18 23:44:42 -0300
commit1551e409c31992437cb2fa864f576d45c8433801 (patch)
treee2fae8451f87b735a1360c7c2e383fdc40165789 /src/macos/Sources
parentbe2a9957708cbf0c478ca861c4a1f0f227bbfe10 (diff)
downloadpardes-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')
-rw-r--r--src/macos/Sources/AppDelegate.swift57
-rw-r--r--src/macos/Sources/FileWatcher.swift122
-rw-r--r--src/macos/Sources/PardesView.swift562
-rw-r--r--src/macos/Sources/ScenePostprocessor.swift361
4 files changed, 1010 insertions, 92 deletions
diff --git a/src/macos/Sources/AppDelegate.swift b/src/macos/Sources/AppDelegate.swift
index 077367a4..0cecd5da 100644
--- a/src/macos/Sources/AppDelegate.swift
+++ b/src/macos/Sources/AppDelegate.swift
@@ -18,10 +18,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, PardesViewDelegate {
/// The blur behind the grid, shown only while the theme declares no
/// background of its own. See applyTheme.
private var backdrop: NSVisualEffectView!
- // One pump chain at a time. pump() re-arms itself while a theme transition
- // is in flight and every input pumps as well, so without this a burst of
- // keys during a fade would leave one 60 Hz chain per keystroke, all of them
- // ticking until the fade ended.
+ // One display-clock chain at a time. Input and pty notifications may all
+ // call pump(), but none advances animation; they only observe this flag and
+ // leave the already-scheduled frame alone.
private var pumpScheduled: Bool = false
// The core is a singleton with no "is it alive" query, and Finder can hand
// us documents before applicationDidFinishLaunching runs. Every entry point
@@ -48,9 +47,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, PardesViewDelegate {
installMainMenu()
- // ponytail: 14pt, fixed. The SDL shell steps its font on Ctrl+/Ctrl-
- // (gui.zig); doing that here means re-measuring the view's metrics and
- // pushing a resize behind it, so it waits until the font has to move.
+ // Initial body size. PardesView owns later Ctrl+/Ctrl- changes and
+ // rebuilds both body and tagline metrics before publishing the resize.
view = PardesView(fontSize: defaultFontSize)
let want = NSSize(width: 1000, height: 700)
@@ -199,6 +197,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, PardesViewDelegate {
// core compares the effective pixel viewport, not the resize event, so
// duplicate SIGWINCH-shaped notifications are already a no-op.
pardesViewDidResize(view)
+ // That pump also consumes a Font from startup config, if present. Only
+ // now is the face on screen final enough to seed Config's effective
+ // name and point size; later adopts and zooms report from wear().
+ view.reportEffectiveFont()
// Positional paths, after the first frame's worth of state exists.
// Anything starting with `-` is skipped rather than opened: the core
@@ -534,6 +536,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, PardesViewDelegate {
// read bytes.
_ = pardes_tick()
view.needsDisplay = true
+ // TaglineSize is host-owned raster state just like Font, but unlike a
+ // body face change it does not resize the grid. Polling the one-byte
+ // core setting keeps startup config and live commands on one path.
+ view.adoptTaglinePercent(pardes_gui_tagline_font_percent())
// After the tick, because a `Theme` command runs inside it and the
// window has to follow the grid in the same frame rather than at the
// next launch.
@@ -571,19 +577,36 @@ final class AppDelegate: NSObject, NSApplicationDelegate, PardesViewDelegate {
NSApp.terminate(nil)
return
}
- // A theme transition is the only thing that moves on its own, and it is
- // ten 16 ms steps (animation.zig). Nothing else re-arms this, which is
- // the point of an event-driven host: an idle pardes costs no CPU, where
- // the SDL shell spins.
- if pardes_animating() && !pumpScheduled {
- pumpScheduled = true
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.016) {
- self.pumpScheduled = false
- self.pump()
- }
+ // Input and pty pumps only drain work. A frame-count animation advances
+ // on this 16 ms clock, and only after the sample dirtied above was
+ // actually presented. A burst of input cannot make it run fast, while
+ // an occluded/coalesced AppKit view cannot consume invisible samples.
+ scheduleAnimationFrame()
+ }
+
+ private func scheduleAnimationFrame() {
+ guard pardes_animating() && !pumpScheduled else { return }
+ let awaitingPresentation = view.presentationSerial
+ pumpScheduled = true
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.016) {
+ self.animationFrame(after: awaitingPresentation)
}
}
+ private func animationFrame(after awaitingPresentation: UInt64) {
+ pumpScheduled = false
+ guard view.presentationSerial != awaitingPresentation else {
+ // Keep the dirty sample pending. AppKit may have coalesced this
+ // draw or the window may be occluded; retry the clock without
+ // advancing until draw(_:) supplies the presentation permit.
+ view.needsDisplay = true
+ scheduleAnimationFrame()
+ return
+ }
+ _ = pardes_animation_tick()
+ pump()
+ }
+
func pardesViewDidResize(_ view: PardesView) {
let grid = view.gridSize
// cell_w/cell_h are physical pixels. The core hands them to the native
diff --git a/src/macos/Sources/FileWatcher.swift b/src/macos/Sources/FileWatcher.swift
new file mode 100644
index 00000000..1fee2fd8
--- /dev/null
+++ b/src/macos/Sources/FileWatcher.swift
@@ -0,0 +1,122 @@
+// Per-path live reload for the native app. One source watches the file for
+// in-place writes; another watches its parent because editors normally save by
+// renaming a new inode over the old one. The file source is rearmed after a
+// quiet edge so it follows that replacement rather than the unlinked old inode.
+
+import Darwin
+import Dispatch
+import Foundation
+
+private final class WatchedPath {
+ let pane: UInt8
+ let generation: UInt32
+ private let path: String
+ private let directorySource: DispatchSourceFileSystemObject
+ private var fileSource: DispatchSourceFileSystemObject?
+ private var active = true
+ private var eventSerial: UInt64 = 0
+
+ init?(pane: UInt8, generation: UInt32, path: String) {
+ let directory = URL(fileURLWithPath: path)
+ .deletingLastPathComponent().path
+ let descriptor = directory.withCString {
+ Darwin.open($0, O_EVTONLY | O_CLOEXEC)
+ }
+ guard descriptor >= 0 else { return nil }
+
+ self.pane = pane
+ self.generation = generation
+ self.path = path
+ directorySource = DispatchSource.makeFileSystemObjectSource(
+ fileDescriptor: descriptor,
+ eventMask: [.write, .delete, .rename, .extend, .attrib, .link, .revoke],
+ queue: .main)
+ directorySource.setCancelHandler { _ = Darwin.close(descriptor) }
+ directorySource.setEventHandler { [weak self] in self?.pathChanged() }
+ reopenFileSource()
+ directorySource.activate()
+ }
+
+ /// The directory source catches rename-over saves, while the file source
+ /// catches programs which truncate/write the existing inode. Reopen the
+ /// latter after every quiet edge because an atomic save leaves it attached
+ /// to the unlinked old inode forever otherwise.
+ private func reopenFileSource() {
+ if let old = fileSource { old.cancel() }
+ fileSource = nil
+ guard active else { return }
+ let descriptor = path.withCString { Darwin.open($0, O_EVTONLY | O_CLOEXEC) }
+ guard descriptor >= 0 else { return }
+ let next = DispatchSource.makeFileSystemObjectSource(
+ fileDescriptor: descriptor,
+ eventMask: [.write, .delete, .rename, .extend, .attrib, .link, .revoke],
+ queue: .main)
+ next.setCancelHandler { _ = Darwin.close(descriptor) }
+ next.setEventHandler { [weak self] in self?.pathChanged() }
+ fileSource = next
+ next.activate()
+ }
+
+ /// Vnode sources can emit several edges for one save. Reset a small quiet
+ /// period on every edge; generation plus exact Zig-side identity checks
+ /// remain authoritative, so this delay is latency control rather than
+ /// correctness state.
+ private func pathChanged() {
+ guard active else { return }
+ eventSerial &+= 1
+ let scheduled = eventSerial
+ DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(45)) { [weak self] in
+ guard let self, self.active, self.eventSerial == scheduled else { return }
+ self.reopenFileSource()
+ pardes_watch_changed(self.pane, self.generation)
+ }
+ }
+
+ func cancel() {
+ guard active else { return }
+ active = false
+ eventSerial &+= 1
+ if let old = fileSource { old.cancel() }
+ fileSource = nil
+ directorySource.cancel()
+ }
+
+ deinit { cancel() }
+}
+
+private final class FileWatchRegistry {
+ static let shared = FileWatchRegistry()
+ private var slots: [UInt8: WatchedPath] = [:]
+
+ func set(pane: UInt8, generation: UInt32, path: String?) {
+ if let old = slots.removeValue(forKey: pane) { old.cancel() }
+ guard let path,
+ let watched = WatchedPath(
+ pane: pane, generation: generation, path: path)
+ else { return }
+ slots[pane] = watched
+ }
+}
+
+/// Called only by libpardes's main-thread effect drain. A nil pointer stops the
+/// slot. The byte count avoids a second sentinel copy in Zig; Foundation paths
+/// are Unicode, so malformed UTF-8 is rejected rather than watched under a
+/// replacement spelling that could name a different file.
+@_cdecl("pardes_host_watch_file")
+func pardesHostWatchFile(
+ _ pane: UInt8,
+ _ generation: UInt32,
+ _ path: UnsafePointer<UInt8>?,
+ _ pathLength: Int
+) {
+ guard let path, pathLength > 0 else {
+ FileWatchRegistry.shared.set(pane: pane, generation: generation, path: nil)
+ return
+ }
+ let bytes = UnsafeBufferPointer(start: path, count: pathLength)
+ guard let value = String(bytes: bytes, encoding: .utf8) else {
+ FileWatchRegistry.shared.set(pane: pane, generation: generation, path: nil)
+ return
+ }
+ FileWatchRegistry.shared.set(pane: pane, generation: generation, path: value)
+}
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))
}
diff --git a/src/macos/Sources/ScenePostprocessor.swift b/src/macos/Sources/ScenePostprocessor.swift
new file mode 100644
index 00000000..cf7892b3
--- /dev/null
+++ b/src/macos/Sources/ScenePostprocessor.swift
@@ -0,0 +1,361 @@
+// The one GPU owner for panel transitions and persistent scene effects.
+// CoreText remains the renderer: while either exists PardesView draws its
+// complete ordinary frame into this retained bitmap, then Core Image runs the
+// standalone shaders/crt.ci.metal kernels over it. With neither, the view
+// bypasses this object entirely.
+
+import CoreGraphics
+import CoreImage
+import Darwin
+import Metal
+
+final class ScenePostprocessor {
+ enum CompositeOutcome {
+ case presented
+ case retry
+ case unavailable
+ }
+
+ private let colorSpace: CGColorSpace
+ private let sceneKernel: CIKernel
+ private let panelClearKernel: CIKernel
+ private let panelKernel: CIKernel
+ private let context: CIContext
+ private var raster: CGContext?
+ private var previousRaster: CGContext?
+ private var rasterWidth = 0
+ private var rasterHeight = 0
+ private var rasterScale: CGFloat = 0
+
+ init?() {
+ let colorSpace = CGColorSpace(name: CGColorSpace.sRGB)
+ ?? CGColorSpaceCreateDeviceRGB()
+ guard let source = ScenePostprocessor.kernelSource(),
+ let device = MTLCreateSystemDefaultDevice(),
+ let made = try? CIKernel.kernels(withMetalString: source),
+ let sceneKernel = made.first(where: { $0.name == "pardesScene" }),
+ let panelClearKernel = made.first(where: { $0.name == "pardesPanelClear" }),
+ let panelKernel = made.first(where: { $0.name == "pardesPanel" })
+ else { return nil }
+ self.colorSpace = colorSpace
+ self.sceneKernel = sceneKernel
+ self.panelClearKernel = panelClearKernel
+ self.panelKernel = panelKernel
+ self.context = CIContext(mtlDevice: device, options: [
+ .workingColorSpace: colorSpace,
+ .outputColorSpace: colorSpace,
+ .cacheIntermediates: false,
+ ])
+ }
+
+ private static func kernelSource() -> String? {
+ var candidates: [URL] = []
+ if let resources = Bundle.main.resourceURL {
+ candidates.append(resources.appendingPathComponent("crt.ci.metal"))
+ }
+ // An unbundled e2e executable has no Contents/Resources. Its build
+ // puts the asset next to the binary, and this explicit candidate keeps
+ // that dev loop independent of Bundle's treatment of command tools.
+ if let executable = Bundle.main.executableURL {
+ candidates.append(executable.deletingLastPathComponent()
+ .appendingPathComponent("crt.ci.metal"))
+ }
+ for url in candidates {
+ if let source = try? String(contentsOf: url, encoding: .utf8) {
+ return source
+ }
+ }
+ return nil
+ }
+
+ /// A body-font-sized, top-left-origin CGContext matching the view's own
+ /// coordinate system. It is retained across frames; only a resize or a
+ /// backing-scale move reallocates the pixel storage.
+ func renderContext(size: CGSize, scale: CGFloat) -> CGContext? {
+ let scale = max(1, scale)
+ let width = max(1, Int((size.width * scale).rounded(.up)))
+ let height = max(1, Int((size.height * scale).rounded(.up)))
+ if raster == nil || width != rasterWidth || height != rasterHeight || scale != rasterScale {
+ let info = CGBitmapInfo.byteOrder32Big.rawValue
+ | CGImageAlphaInfo.premultipliedLast.rawValue
+ guard let next = CGContext(
+ data: nil, width: width, height: height,
+ bitsPerComponent: 8, bytesPerRow: width * 4,
+ space: colorSpace, bitmapInfo: info)
+ else { return nil }
+ next.translateBy(x: 0, y: CGFloat(height))
+ next.scaleBy(x: scale, y: -scale)
+ raster = next
+ // The old and new snapshots must have identical extents. Lazily
+ // rebuild the old owner only if an effect actually needs it.
+ previousRaster = nil
+ rasterWidth = width
+ rasterHeight = height
+ rasterScale = scale
+ }
+ return raster
+ }
+
+ /// A second complete CoreText raster for the frozen old grid. It is
+ /// intentionally a peer of `raster`, not a collection of per-pane image
+ /// pointers: deleted PDF/image panes have already released their core
+ /// bytes, while PardesView's frozen CGImages remain safe to draw here.
+ func previousRenderContext(size: CGSize, scale: CGFloat) -> CGContext? {
+ guard renderContext(size: size, scale: scale) != nil else { return nil }
+ if previousRaster == nil {
+ let info = CGBitmapInfo.byteOrder32Big.rawValue
+ | CGImageAlphaInfo.premultipliedLast.rawValue
+ guard let next = CGContext(
+ data: nil, width: rasterWidth, height: rasterHeight,
+ bitsPerComponent: 8, bytesPerRow: rasterWidth * 4,
+ space: colorSpace, bitmapInfo: info)
+ else { return nil }
+ next.translateBy(x: 0, y: CGFloat(rasterHeight))
+ next.scaleBy(x: rasterScale, y: -rasterScale)
+ previousRaster = next
+ }
+ return previousRaster
+ }
+
+ /// Run the one scene kernel and copy its result into the flipped NSView
+ /// context. Allocation/device failures may be retried briefly. A kernel
+ /// rejecting its fixed argument layout is permanent for this executable,
+ /// so the caller can turn the advertised effects back off instead of
+ /// silently spinning a display clock forever.
+ func composite(
+ scene: pardes_scene_s,
+ tracks: UnsafeBufferPointer<pardes_panel_track_s>,
+ changedCells: UnsafePointer<UInt8>?,
+ changedAttachmentRects: [CGRect],
+ gridSize: (cols: Int, rows: Int),
+ cellSize: CGSize,
+ ground: UInt32?,
+ size: CGSize,
+ into destination: CGContext
+ ) -> CompositeOutcome {
+ guard let raster, let sourceImage = raster.makeImage() else { return .retry }
+ let extent = CGRect(x: 0, y: 0, width: rasterWidth, height: rasterHeight)
+ let current = CIImage(cgImage: sourceImage, options: [.colorSpace: colorSpace])
+ let old: CIImage
+ if let previousImage = previousRaster?.makeImage() {
+ old = CIImage(cgImage: previousImage, options: [.colorSpace: colorSpace])
+ } else {
+ old = current
+ }
+ let mask = changeMask(
+ changedCells, attachmentRects: changedAttachmentRects,
+ gridSize: gridSize, cellSize: cellSize) ??
+ CIImage(color: CIColor(red: 0, green: 0, blue: 0, alpha: 1))
+ .cropped(to: CGRect(x: 0, y: 0, width: 1, height: 1))
+ var filtered = current
+
+ // Geometric targets are prepared before any presented panel is drawn.
+ // Horizontal slide/zoom use the theme ground; a vertical opener uses
+ // the exact old frame inside its fixed clip. Data effects and closing
+ // tombstones leave the canonical current frame untouched underneath.
+ let clear = ground.map { encoded in
+ CIColor(
+ red: CGFloat((encoded >> 16) & 0xFF) / 255,
+ green: CGFloat((encoded >> 8) & 0xFF) / 255,
+ blue: CGFloat(encoded & 0xFF) / 255,
+ alpha: 1)
+ } ?? CIColor(red: 0, green: 0, blue: 0, alpha: 0)
+ for track in tracks {
+ let content = track.phase == UInt8(PARDES_PANEL_CLOSING) ? track.from : track.to
+ guard let next = panelClearKernel.apply(
+ extent: extent,
+ roiCallback: { _, _ in extent },
+ arguments: [
+ filtered, old,
+ pixelBox(content, cellSize: cellSize), clear,
+ Float(track.effect), Float(track.phase),
+ ])
+ else { return .unavailable }
+ filtered = next
+ }
+
+ // libpardes orders moving, opening, then closing tombstones. Each pass
+ // samples immutable old/new rasters and composes over the prior pass,
+ // so a deleted pane can safely outlive its core Pane allocation.
+ let pixelCell = CIVector(
+ x: cellSize.width * rasterScale,
+ y: cellSize.height * rasterScale)
+ for track in tracks {
+ let content = track.phase == UInt8(PARDES_PANEL_CLOSING) ? track.from : track.to
+ guard let next = panelKernel.apply(
+ extent: extent,
+ roiCallback: { _, _ in extent },
+ arguments: [
+ current, old, mask, filtered,
+ pixelBox(content, cellSize: cellSize),
+ pixelBox(track.from, cellSize: cellSize),
+ pixelBox(track.to, cellSize: cellSize),
+ Float(track.effect), Float(track.phase), Float(track.frame),
+ CIVector(
+ x: CGFloat(track.serial & 0xFFFF),
+ y: CGFloat(track.serial >> 16)),
+ pixelCell,
+ CIVector(
+ x: 0,
+ y: extent.height - CGFloat(gridSize.rows) * pixelCell.y),
+ ])
+ else { return .unavailable }
+ filtered = next
+ }
+
+ if scene.flags != 0 {
+ let crt: Float = scene.flags & UInt32(PARDES_SCENE_CRT) != 0 ? 1 : 0
+ let ripple: Float = scene.flags & UInt32(PARDES_SCENE_RIPPLE) != 0 ? 1 : 0
+ let glitch: Float = scene.flags & UInt32(PARDES_SCENE_GLITCH) != 0 ? 1 : 0
+ // SDL samples the scene with CLAMP_TO_EDGE. A finite Core Image
+ // instead supplies transparent black outside its extent, which
+ // would make ripple/glitch tear dark seams at the window edge.
+ // Clamp the input only; the kernel still emits true black before
+ // sampling when a destination lies outside the CRT tube.
+ let sceneInput = filtered.clampedToExtent()
+ guard let next = sceneKernel.apply(
+ extent: extent,
+ roiCallback: { _, _ in extent },
+ arguments: [
+ sceneInput, scene.time_seconds, Float(scene.frame),
+ crt, ripple, glitch,
+ CIVector(x: extent.width, y: extent.height),
+ CIVector(x: extent.minX, y: extent.minY),
+ ])
+ else { return .unavailable }
+ filtered = next
+ }
+
+ guard let presented = context.createCGImage(filtered, from: extent) else { return .retry }
+
+ destination.clear(CGRect(origin: .zero, size: size))
+ destination.saveGState()
+ destination.translateBy(x: 0, y: size.height)
+ destination.scaleBy(x: 1, y: -1)
+ destination.interpolationQuality = .none
+ destination.draw(presented, in: CGRect(origin: .zero, size: size))
+ destination.restoreGState()
+ return .presented
+ }
+
+ /// Byte-per-cell mask from the core. Zig expands true to 255 so the image
+ /// is a literal normalized 0/1 texture; sampling cell centres below keeps
+ /// adjacent values from interpolating at pane/cell boundaries.
+ private func changeMask(
+ _ cells: UnsafePointer<UInt8>?,
+ attachmentRects: [CGRect],
+ gridSize: (cols: Int, rows: Int),
+ cellSize: CGSize
+ ) -> CIImage? {
+ let count = gridSize.cols * gridSize.rows
+ guard count > 0, let cells, cellSize.width > 0, cellSize.height > 0 else { return nil }
+ var bytes = Array(UnsafeBufferPointer(start: cells, count: count))
+ for rect in attachmentRects where !rect.isNull && !rect.isEmpty {
+ let x0 = max(0, Int((rect.minX / cellSize.width).rounded(.down)))
+ let x1 = min(gridSize.cols, Int((rect.maxX / cellSize.width).rounded(.up)))
+ let y0 = max(0, Int((rect.minY / cellSize.height).rounded(.down)))
+ let y1 = min(gridSize.rows, Int((rect.maxY / cellSize.height).rounded(.up)))
+ guard x0 < x1, y0 < y1 else { continue }
+ for row in y0..<y1 {
+ for col in x0..<x1 { bytes[row * gridSize.cols + col] = 255 }
+ }
+ }
+ return bytes.withUnsafeBufferPointer { buffer in
+ guard let base = buffer.baseAddress,
+ let data = CFDataCreate(nil, base, count),
+ let provider = CGDataProvider(data: data),
+ let gray = CGColorSpace(name: CGColorSpace.linearGray),
+ let image = CGImage(
+ width: gridSize.cols, height: gridSize.rows,
+ bitsPerComponent: 8, bitsPerPixel: 8,
+ bytesPerRow: gridSize.cols, space: gray,
+ bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.none.rawValue),
+ provider: provider, decode: nil,
+ shouldInterpolate: false, intent: .defaultIntent)
+ else { return nil }
+ return CIImage(cgImage: image, options: [.colorSpace: gray])
+ }
+ }
+
+ /// Convert the core's top-left logical-cell box to Core Image's
+ /// bottom-left backing-pixel rectangle.
+ private func pixelBox(_ box: pardes_panel_box_s, cellSize: CGSize) -> CIVector {
+ let cellW = cellSize.width * rasterScale
+ let cellH = cellSize.height * rasterScale
+ let x = CGFloat(box.x) * cellW
+ let w = CGFloat(box.w) * cellW
+ let h = CGFloat(box.h) * cellH
+ let y = CGFloat(rasterHeight) - (CGFloat(box.y) + CGFloat(box.h)) * cellH
+ return CIVector(x: x, y: y, z: w, w: h)
+ }
+
+ /// The scene kernel is a destination-to-source mapping. Pointer input must
+ /// apply that same mapping before the grid and its panel tracks see a cell,
+ /// otherwise a visible word under ripple/glitch is not the word Look gets.
+ static func sourcePoint(
+ _ point: CGPoint,
+ size: CGSize,
+ scene: pardes_scene_s
+ ) -> CGPoint? {
+ guard size.width > 0, size.height > 0 else { return nil }
+ if scene.flags == 0 { return point }
+
+ var uvX = Float(point.x / size.width)
+ var uvY = 1 - Float(point.y / size.height) // Core Image is bottom-up
+ var ccX = uvX * 2 - 1
+ var ccY = uvY * 2 - 1
+ let crt: Float = scene.flags & UInt32(PARDES_SCENE_CRT) != 0 ? 1 : 0
+ let radius2 = ccX * ccX + ccY * ccY
+ let barrel = 1 + 0.016 * radius2 * crt
+ ccX *= barrel
+ ccY *= barrel
+ uvX = ccX * 0.5 + 0.5
+ uvY = ccY * 0.5 + 0.5
+ // Only CRT has a black tube. Ripple/glitch without CRT sample through
+ // the texture's clamp-to-edge policy and remain clickable there.
+ if crt > 0.5 && min(min(uvX, uvY), min(1 - uvX, 1 - uvY)) <= 0 { return nil }
+
+ let time = scene.time_seconds
+ if scene.flags & UInt32(PARDES_SCENE_RIPPLE) != 0 {
+ let centeredX = uvX - 0.5
+ let centeredY = uvY - 0.5
+ let radius = sqrtf(centeredX * centeredX + centeredY * centeredY)
+ let envelope = 1 - smoothstep(0.04, 0.72, radius)
+ let wave = sinf(radius * 42 - time * 5.2)
+ let travel = wave * envelope * 0.0032 / max(radius, 0.00001)
+ uvX += centeredX * travel
+ uvY += centeredY * travel
+ }
+ if scene.flags & UInt32(PARDES_SCENE_GLITCH) != 0 {
+ let band = floorf(uvY * 96)
+ let seed = noise(band, floorf(time * 13))
+ let tear = smoothstep(0.955, 1, seed)
+ uvX += (noise(seed, band) - 0.5) * tear * 0.020
+ }
+ uvX = min(max(uvX, 0), max(0, 1 - 1 / Float(size.width)))
+ uvY = min(max(uvY, 0), max(0, 1 - 1 / Float(size.height)))
+ return CGPoint(
+ x: CGFloat(uvX) * size.width,
+ y: CGFloat(1 - uvY) * size.height)
+ }
+
+ private static func smoothstep(_ edge0: Float, _ edge1: Float, _ x: Float) -> Float {
+ let t = min(max((x - edge0) / (edge1 - edge0), 0), 1)
+ return t * t * (3 - 2 * t)
+ }
+
+ /// Scalar spelling of pardesNoise in crt.ci.metal. Float arithmetic is
+ /// deliberate: the shader's band decision must not move at a Double round.
+ private static func noise(_ x: Float, _ y: Float) -> Float {
+ var qx = fract(x * 0.1031)
+ var qy = fract(y * 0.1031)
+ var qz = fract(x * 0.1031)
+ let d = qx * (qy + 33.33) + qy * (qz + 33.33) + qz * (qx + 33.33)
+ qx += d
+ qy += d
+ qz += d
+ return fract((qx + qy) * qz)
+ }
+
+ private static func fract(_ x: Float) -> Float { x - floorf(x) }
+}