diff options
Diffstat (limited to 'src/macos')
| -rw-r--r-- | src/macos/Sources/AppDelegate.swift | 57 | ||||
| -rw-r--r-- | src/macos/Sources/FileWatcher.swift | 122 | ||||
| -rw-r--r-- | src/macos/Sources/PardesView.swift | 562 | ||||
| -rw-r--r-- | src/macos/Sources/ScenePostprocessor.swift | 361 | ||||
| -rwxr-xr-x | src/macos/build-e2e.sh | 9 | ||||
| -rw-r--r-- | src/macos/pardes.h | 126 |
6 files changed, 1137 insertions, 100 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) } +} diff --git a/src/macos/build-e2e.sh b/src/macos/build-e2e.sh index c0f9613c..e9ccebcd 100755 --- a/src/macos/build-e2e.sh +++ b/src/macos/build-e2e.sh @@ -32,6 +32,10 @@ bin="$out/bin/pardes-macos-e2e" command -v swiftc >/dev/null || { echo "swiftc not found (needs macOS + Command Line Tools)" >&2; exit 1; } mkdir -p "$out/bin" +# Bundle.main.resourceURL is the executable directory for this standalone +# harness. Put the same committed source there that the real app installs into +# Contents/Resources, so the shader path is exercised rather than bypassed. +cp "$root/shaders/crt.ci.metal" "$out/bin/crt.ci.metal" # -import-objc-header and -lc++ are the app link's, unchanged, and have to stay # that way: this link exists to exercise the app's link, so anything that @@ -44,9 +48,12 @@ swiftc -O -target "$(uname -m)-apple-macos$minver" \ -import-objc-header "$root/src/macos/pardes.h" \ -o "$bin" \ "$root/src/macos/Sources/PardesView.swift" \ + "$root/src/macos/Sources/ScenePostprocessor.swift" \ + "$root/src/macos/Sources/FileWatcher.swift" \ "$root/src/macos/Sources/AppDelegate.swift" \ "$root/test/macos_e2e.swift" \ "$lib" -lc++ \ - -framework AppKit -framework CoreText -framework CoreGraphics + -framework AppKit -framework CoreText -framework CoreGraphics \ + -framework CoreImage -framework Metal echo "built $bin" diff --git a/src/macos/pardes.h b/src/macos/pardes.h index 9a64501a..cfe83ffa 100644 --- a/src/macos/pardes.h +++ b/src/macos/pardes.h @@ -45,6 +45,8 @@ typedef struct { uint8_t len; // bit 0: the core never painted this cell — draw it as the default cell // (background only), which is what makes a partial redraw cheap. + // bit 1: rasterize text and its colour band with the tagline face's measured + // height. Logical cell geometry stays body-sized; this is a visual role. uint8_t flags; } pardes_cell_s; @@ -73,6 +75,54 @@ typedef struct { #define PARDES_UL_DASHED 5 #define PARDES_CELL_DEFAULT 0x01u +#define PARDES_CELL_TAGLINE 0x02u + +// One coherent snapshot for the full-window shader pass. The bitmask lets the +// host run CRT, ripple and glitch in one kernel instead of stacking filters; +// time/frame advance only on pardes_animation_tick's display clock. +typedef struct { + uint32_t flags; + float time_seconds; + uint32_t frame; +} pardes_scene_s; + +#define PARDES_SCENE_CRT 0x01u +#define PARDES_SCENE_RIPPLE 0x02u +#define PARDES_SCENE_GLITCH 0x04u + +// One backend-neutral pane transition. Boxes are logical cell rectangles; +// the native shader converts them to backing pixels using the same measured +// cell as the renderer. The list is already in paint order: moving first, +// opening next, frozen closing tombstones last, and slot order within a phase. +typedef struct { + float x; + float y; + float w; + float h; +} pardes_panel_box_s; + +typedef struct { + uint32_t serial; + uint8_t pane; + uint8_t phase; + uint8_t effect; + uint8_t reserved0; + uint16_t frame; + uint16_t reserved1; + pardes_panel_box_s from; + pardes_panel_box_s to; +} pardes_panel_track_s; + +#define PARDES_PANEL_OPENING 0u +#define PARDES_PANEL_MOVING 1u +#define PARDES_PANEL_CLOSING 2u + +#define PARDES_PANEL_OFF 0u +#define PARDES_PANEL_SLIDE 1u +#define PARDES_PANEL_ZOOM 2u +#define PARDES_PANEL_DISSOLVE 3u +#define PARDES_PANEL_ASCII 4u +#define PARDES_PANEL_VERTICAL 5u // ---------------------------------------------------------------- input @@ -173,10 +223,15 @@ void pardes_deinit(void); // into the core and use this only to decide whether there was work. bool pardes_tick(void); +// Advance one frame-count animation step. Call only from the scheduled ~60 Hz +// callback, never from ordinary input or pty pumps: those drain work but do +// not represent elapsed display time. Returns whether anything advanced. +bool pardes_animation_tick(void); + // The core asked to exit (the Exit builtin, or the last pane closing). bool pardes_should_quit(void); -// A theme transition is mid-flight and wants ~60 Hz ticks until it settles. +// A theme/pane transition, scene effect, or rotation coast wants ~60 Hz ticks. bool pardes_animating(void); // What to paint where the grid does not: the window background behind the @@ -189,6 +244,25 @@ bool pardes_animating(void); // wear, so the host should go transparent and show its own backdrop. uint32_t pardes_theme_bg(void); +// Pane taglines use this percentage of the current body font size. It is the +// embedded default before init and the live core setting afterwards; the host +// polls it and rebuilds only tagline glyph/metric state. +uint8_t pardes_gui_tagline_font_percent(void); + +// Persistent full-window effect switches and their display-clock time. A zero +// flags word means draw the CoreText frame directly; any nonzero combination +// is one postprocess pass. Snapshot by value, with no borrowed storage. +pardes_scene_s pardes_scene(void); + +// The shared Metal/Core Image pass is unavailable for this session. Clears +// scene effects and future panel transitions so Config reflects what the host +// can actually present and the display clock cannot retry forever. +void pardes_postprocessor_unavailable(void); + +// A transient postprocess failure will be presented as the canonical grid. +// Drop current panel tracks so a later scene retry cannot resume them midway. +void pardes_panel_animation_failed(void); + // ---------------------------------------------------------------- events in // `cp` is a codepoint or one of PARDES_KEY_*; `text`/`len` are the host's @@ -198,6 +272,15 @@ void pardes_key(uint32_t cp, const char *text, size_t len, uint32_t mods); void pardes_paste(const char *text, size_t len); void pardes_mouse(pardes_mouse_button_e button, pardes_mouse_kind_e kind, uint16_t col, uint16_t row, uint32_t mods); +// The pointer left the view. A clamped motion on its final cell is not the +// same event: it would keep hover previews and resize hints alive off-window. +void pardes_pointer_leave(void); + +// Host file watchers call this after their debounce interval. `generation` +// comes from pardes_host_watch_file and makes an event queued before a pane +// replacement inert. The callback only schedules the ordinary main-thread +// pump; file IO and the shared text/PDF reload happen inside pardes_tick. +void pardes_watch_changed(uint8_t pane, uint32_t generation); // Trackpad/precision wheel distance in CELLS, sign following the grid // (positive scrolls down and right). The core has no fractional scroll — it @@ -224,9 +307,10 @@ void pardes_rotate(float degrees); // how hard it was thrown, and the two are the same curve — momentum ramps up // from zero rather than switching on at a threshold. // -// A coast makes pardes_animating true and is spent by pardes_tick, so a host -// that already re-pumps for theme transitions needs no new machinery; one that -// never calls this simply has a dial with no momentum. +// A coast makes pardes_animating true and is spent by +// pardes_animation_tick, so a host that already re-pumps for theme transitions +// needs no new machinery; one that never calls this simply has a dial with no +// momentum. void pardes_rotate_end(void); // Run one builtin command line, exactly as executing the same text in a tag @@ -246,6 +330,11 @@ void pardes_resize(uint16_t cols, uint16_t rows, uint16_t cell_w, // The buffer returned by pardes_frame_cells is valid until the next call. uint32_t pardes_frame(void); const pardes_cell_s *pardes_frame_cells(void); +// Frozen old grid and its byte-per-cell semantic change mask. Both are either +// present for exactly cols*rows cells or NULL together, and are borrowed until +// the next pardes_frame call just like the canonical cells. +const pardes_cell_s *pardes_frame_previous_cells(void); +const uint8_t *pardes_frame_changed_cells(void); uint16_t pardes_frame_cols(void); uint16_t pardes_frame_rows(void); @@ -290,6 +379,16 @@ typedef struct { uint32_t pardes_frame_images(void); const pardes_image_s *pardes_frame_image_list(void); +// Shader transition records for this frame, borrowed until pardes_frame is +// called again. Empty means draw the canonical grid without a panel pass. +uint32_t pardes_frame_panel_tracks(void); +const pardes_panel_track_s *pardes_frame_panel_track_list(void); +// Call only after the host has put this frame on screen. `true` commits the +// borrowed panel-track list above; `false` records a direct canonical fallback. +// Returns true only when remapping a stationary pointer newly started a clock +// the host was not already running. +bool pardes_frame_presented(bool animated_panels); + // The file behind the FOCUSED pane, or NULL when there is none: a terminal, an // output buffer, or nothing focused. PDFs and images count — they are real // paths, and a titlebar proxy icon is about the file, not about who may edit @@ -323,11 +422,24 @@ pardes_haptic_e pardes_take_haptic(void); // file is named after. // // The host loads it, re-measures its cell, and reports the new grid through -// pardes_resize. A file the host cannot load is one to ignore: keep wearing -// the face that works, because a terminal that cannot draw has no way back -// out of it. +// pardes_resize. A file the host cannot load must be rejected explicitly while +// keeping the face that works; otherwise Config would leave the request pending +// forever or claim a fallback was accepted. const char *pardes_font_take(void); +// Boot, zoom and display-scale changes observe the face already on screen; +// this never consumes a pending request. `point_hundredths` is the effective +// body size (14pt => 1400). +bool pardes_font_observe(const char *effective_name, size_t len, + uint16_t point_hundredths); + +// A taken Font request is not effective until the host says what CoreText +// actually accepted. Calling this without a request from pardes_font_take is +// inert, so an unrelated host report cannot consume the request. +bool pardes_font_ack(const char *effective_name, size_t len, + uint16_t point_hundredths); +void pardes_font_reject(void); + #ifdef __cplusplus } #endif |
