diff options
Diffstat (limited to 'src/macos/Sources/PardesView.swift')
| -rw-r--r-- | src/macos/Sources/PardesView.swift | 406 |
1 files changed, 379 insertions, 27 deletions
diff --git a/src/macos/Sources/PardesView.swift b/src/macos/Sources/PardesView.swift index 7981cf46..5ad09c38 100644 --- a/src/macos/Sources/PardesView.swift +++ b/src/macos/Sources/PardesView.swift @@ -433,7 +433,26 @@ private struct Metrics { let symbolic = (traits?[kCTFontSymbolicTrait] as? UInt32) ?? 0 return symbolic & UInt32(CTFontSymbolicTraits.traitBold.rawValue | CTFontSymbolicTraits.traitItalic.rawValue) == 0 } - let face = CTFontCreateWithFontDescriptor(plain ?? descriptors[0], size, nil) + let chosen = CTFontCreateWithFontDescriptor(plain ?? descriptors[0], size, nil) + // A variable face's descriptor carries no variation, and CoreText + // picks whichever named instance it likes for it — Maple Mono's + // variable file comes up Thin. Pin every axis to its own default: + // the face the file itself calls regular. + var face = chosen + if let axes = CTFontCopyVariationAxes(face) as? [[CFString: Any]] { + var defaults: [CFNumber: Any] = [:] + for axis in axes { + guard let tag = axis[kCTFontVariationAxisIdentifierKey], + let value = axis[kCTFontVariationAxisDefaultValueKey] else { continue } + defaults[tag as! CFNumber] = value + } + if !defaults.isEmpty { + let descriptor = CTFontDescriptorCreateCopyWithAttributes( + CTFontCopyFontDescriptor(face), + [kCTFontVariationAttribute: defaults] as CFDictionary) + face = CTFontCreateWithFontDescriptor(descriptor, size, nil) + } + } // The core already filtered for fixed pitch by reading the file's own // advances. This is the same question asked of the face CoreText // actually built, which is the one that will be drawn with. @@ -577,7 +596,14 @@ final class PardesView: NSView { // full redraw does not allocate 24 times. private var runGlyphs: [CGGlyph] = [] private var runPositions: [CGPoint] = [] - + /// Ligature shaping, keyed by face and text. A sequence ligates when + /// shaping it produces fewer glyphs than it has characters — the only + /// answer CoreText gives without interrogating the font's own features. + /// Cleared with the glyph caches, because it belongs to one face. + private var ligatureShaped: [String: Bool] = [:] + /// Columns the current row has already consumed as part of a ligature + /// drawn further left. One row's worth at a time. + private var ligatureCovered: Set<Int> = [] /// 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. @@ -748,6 +774,7 @@ final class PardesView: NSView { bodyGlyphCache.removeAll(keepingCapacity: true) taglineGlyphCache.removeAll(keepingCapacity: true) gripGlyphCache.removeAll(keepingCapacity: true) + ligatureShaped.removeAll(keepingCapacity: true) metrics = next taglines = TaglineMetrics(body: next, percent: taglines.percent, scale: scale) metricsScale = scale @@ -844,12 +871,12 @@ final class PardesView: NSView { // 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 nextLinkPointer = pardes_pointer_shape() == 1 - if nextLinkPointer != linkPointer { - linkPointer = nextLinkPointer + let nextPointer = pointerShape() + if nextPointer != pointer { + pointer = nextPointer window?.invalidateCursorRects(for: self) if let window, bounds.contains(convert(window.mouseLocationOutsideOfEventStream, from: nil)) { - (linkPointer ? NSCursor.pointingHand : NSCursor.iBeam).set() + cursorFor(pointer).set() } } let scene = pardes_scene() @@ -1178,10 +1205,13 @@ final class PardesView: NSView { drawTagLayers(ctx, hideCursor:hideCursor, canonical:cells, cols:cols, rows:rows) } else { for layer in presentedTagLayers { - drawTagGrip(ctx, values:layer.values, canonical:cells, cols:cols, rows:rows) - layer.cells.withUnsafeBufferPointer { cells in - if let base = cells.baseAddress { drawTagLayer(ctx, values:layer.values, cells:base, hideCursor:true) } + layer.cells.withUnsafeBufferPointer { tagCells in + if let base = tagCells.baseAddress { + drawTagLayer(ctx, values: layer.values, cells: base, hideCursor: true, + canonical: cells, cols: cols) + } } + drawTagGrip(ctx, values: layer.values, canonical: cells, cols: cols, rows: rows) } for layer in presentedBodyLayers { layer.cells.withUnsafeBufferPointer { cells in @@ -1190,6 +1220,7 @@ final class PardesView: NSView { } drawTagRules(ctx, layers: presentedTagLayers.map { $0.values }) } + drawHoverGlass(ctx, cells: cells, cols: cols, rows: rows) } private var presentedTagLayers: [FrozenBodyLayer] = [] @@ -1212,16 +1243,22 @@ final class PardesView: NSView { let values = (0...12).map { Int(pardes_tag_layer_value(index, UInt32($0))) } if values[0] == 0 { continue } layers.append(values) - drawTagGrip(ctx, values:values, canonical:canonical, cols:cols, rows:rows) - if let cells = pardes_tag_layer_cells(index) { drawTagLayer(ctx, values: values, cells: cells, hideCursor: hideCursor) } + // The layer's full-row fill must land before the grip: the row + // background now spans the anchor columns, and the grip glyph + // and the anchor fills have to come back on top of it. + if let cells = pardes_tag_layer_cells(index) { + drawTagLayer(ctx, values: values, cells: cells, hideCursor: hideCursor, + canonical: canonical, cols: cols) + } + drawTagGrip(ctx, values: values, canonical: canonical, cols: cols, rows: rows) } drawTagRules(ctx, layers: layers) } /// SDL's chrome overlay is drawn after its compact layers. Keep these - /// one-pixel rules above the full-row tag backgrounds as well. + /// one-point rules above the full-row tag backgrounds as well. private func drawTagRules(_ ctx: CGContext, layers: [[Int]]) { - let pixel = 1 / metricsScale + let pixel = CGFloat(1) for values in layers where values[1] != 0 { let kind = values[1] let bottom = kind == 2 && values[11] != 0 @@ -1252,15 +1289,17 @@ final class PardesView: NSView { } private func drawTagGrip(_ ctx: CGContext, values: [Int], canonical: UnsafePointer<pardes_cell_s>, cols: Int, rows: Int) { - let width = Int(pardes_grip_columns()) let x = values[4] - Int(pardes_tag_text_inset()), y = values[5] if values[1] != 2 || x < 0 || x >= cols || y < 0 || y >= rows { return } var cell = canonical[y * cols + x] let text = withUnsafeBytes(of: &cell.text) { String(decoding: $0.prefix(Int(cell.len)), as: UTF8.self) } if text.isEmpty { return } let style = resolve(cell, block:false, ground:themeBG ?? pardesDefaultBG, clearGround:false) - let centerX = (CGFloat(x) + CGFloat(width) / 2) * cellWidth - let centerY = CGFloat(y) * cellHeight + taglines.top(row:y, canvasHeight:bounds.height) + taglines.height / 2 + // The box drawTagLayer filled is this mark's ground, so the mark + // centres on that same square rather than on the grip columns: one + // rect measures both and the two cannot drift apart. + guard let square = anchorSquare(values: values) else { return } + let centerX = square.midX, centerY = square.midY if text != " " { let face = Face(bold: cell.attrs & UInt16(PARDES_ATTR_BOLD) != 0, italic: cell.attrs & UInt16(PARDES_ATTR_ITALIC) != 0) @@ -1268,9 +1307,7 @@ final class PardesView: NSView { let originX = ((centerX - measured.ink.midX) * metricsScale).rounded() / metricsScale let baseline = ((bounds.height - centerY - measured.ink.midY) * metricsScale).rounded() / metricsScale ctx.saveGState() - ctx.clip(to: CGRect(x: CGFloat(x) * cellWidth, - y: CGFloat(y) * cellHeight + taglines.top(row:y, canvasHeight:bounds.height), - width: CGFloat(width) * cellWidth, height: taglines.height)) + ctx.clip(to: square) ctx.translateBy(x: 0, y: bounds.height) ctx.scaleBy(x: 1, y: -1) ctx.textMatrix = .identity @@ -1286,16 +1323,36 @@ final class PardesView: NSView { fill(ctx, CGRect(x:floor(centerX-halfWidth), y:floor(centerY+1), width:2*halfWidth, height:1), style.fg, 1) } - private func drawTagLayer(_ ctx: CGContext, values: [Int], cells: UnsafePointer<pardes_cell_s>, hideCursor: Bool) { + private func drawTagLayer(_ ctx: CGContext, values: [Int], cells: UnsafePointer<pardes_cell_s>, + hideCursor: Bool, canonical: UnsafePointer<pardes_cell_s>, cols: Int) { let left = CGFloat(values[4]) * cellWidth let y = CGFloat(values[5]) * cellHeight + taglines.top(row: values[5], canvasHeight: bounds.height) // SDL gives the tag a full body-row background; only its glyphs - // and per-cell highlights use the compact band. - let viewport = CGRect(x: left, y: CGFloat(values[5]) * cellHeight, - width: CGFloat(values[6]) * cellWidth, height: cellHeight) + // and per-cell highlights use the compact band. The band spans the + // row's whole extent, not just its text: the grip and the mode box + // live in the inset columns to the left of `values[4]`, and their + // own fills cover only the compact band — a row background that + // stops at the text leaves holes of ground under those anchors. + // The rule in drawTagRules spans exactly this left-to-right range. + let rowLeft = CGFloat(max(0, values[4] - Int(pardes_tag_text_inset()))) * cellWidth + let rowRight = CGFloat(values[4] + values[6]) * cellWidth + let viewport = CGRect(x: rowLeft, y: CGFloat(values[5]) * cellHeight, + width: rowRight - rowLeft, height: cellHeight) ctx.saveGState() ctx.clip(to: viewport) fillBackground(ctx, viewport, UInt32(values[10]), backgroundAlpha) + // The anchor (grip, mode box) lives in the inset columns that fill + // just covered, so its own box goes back on top: one solid fill in + // the box colour the anchor cell wears, with drawTagGrip's mark + // drawn over it afterwards. + if let square = anchorSquare(values: values) { + let anchorX = max(0, values[4] - Int(pardes_tag_text_inset())) + let cell = canonical[values[5] * cols + anchorX] + let style = resolve(cell, block: false, + ground: UInt32(values[10]), clearGround: false) + if style.visible { fillBackground(ctx, square.integral, style.bg, backgroundAlpha) } + } + for pass in 0...1 { for col in 0..<values[0] { var cell = cells[col] @@ -1331,6 +1388,184 @@ final class PardesView: NSView { ctx.restoreGState() } + /// The anchor square: one box drawn from the tag band's height — square, + /// at the row's left edge, inset by the same amount on both axes — + /// shared by the fill in drawTagLayer and the glyph in drawTagGrip so + /// the two can never disagree. + private func anchorSquare(values: [Int]) -> CGRect? { + let anchorX = max(0, values[4] - Int(pardes_tag_text_inset())) + guard anchorX < values[4] else { return nil } + let pad = (cellHeight - taglines.height) / 2 + return CGRect(x: CGFloat(anchorX) * cellWidth + pad, + y: CGFloat(values[5]) * cellHeight + taglines.top(row: values[5], canvasHeight: bounds.height), + width: taglines.height, height: taglines.height) + } + + /// The look-hover affordance as liquid glass: the core marks the word's + /// cells (PARDES_CELL_HOVER), and this composes a real material over the + /// run — the drawn frame snapshot refracted through CoreImage (a small + /// blur, then a lens scale about the pill's centre, which is what makes + /// the edges swim), the theme's selection colour as a faint tint, a + /// specular sheen from the top edge, and a light rim — with the word's + /// own glyphs drawn back on top, crisp, the way text sits on a real + /// material rather than behind one. + private func drawHoverGlass(_ ctx: CGContext, cells: UnsafePointer<pardes_cell_s>, cols: Int, rows: Int) { + var drew = false + for row in 0..<rows { + var start = -1 + var tint: UInt32 = 0 + for col in 0...cols { + let hover = col < cols + && cells[row * cols + col].flags & UInt8(PARDES_CELL_HOVER) != 0 + if hover, start < 0 { + start = col + tint = cells[row * cols + col].bg + } + if !hover, start >= 0 { + let rect = CGRect(x: CGFloat(start) * cellWidth, y: CGFloat(row) * cellHeight, + width: CGFloat(col - start) * cellWidth, height: cellHeight) + drawLiquidGlass(ctx, rect, tint: tint) + redrawHoverRun(ctx, cells, cols: cols, row: row, start: start, end: col) + drew = true + start = -1 + } + } + } + // No affordance this frame: the held snapshot describes a frame + // that no longer exists, and the next hover must take a fresh one. + if !drew { glassShot = nil } + } + + /// A CoreImage context for the glass refraction, created once. + private lazy var glassContext = CIContext() + /// The snapshot behind the current pill. Refraction reads the already + /// drawn frame, and re-reading it every pump would blur a moving pill + /// out of its own budget, so the shot is taken when the rect changes. + private var glassShot: (rect: CGRect, image: CGImage)? + + private func drawLiquidGlass(_ ctx: CGContext, _ rect: CGRect, tint: UInt32) { + let radius = min(rect.height / 4, 6) + let pill = CGPath(roundedRect: rect.insetBy(dx: 1, dy: 1.5), + cornerWidth: radius, cornerHeight: radius, transform: nil) + // The refraction: the frame as drawn, blurred a little and scaled + // about the pill's centre, so the rim bends what surrounds it. The + // shot extends past the pill for exactly this — the lens needs + // pixels from outside the glass to pull in. + let lens = rect.insetBy(dx: -10, dy: -10) + if refract(ctx, pill: pill, rect: rect, lens: lens, tint: tint) { + // Specular: light caught at the top edge, fading as it falls. + ctx.saveGState() + ctx.addPath(pill) + ctx.clip() + let shine = [CGColor(srgbRed: 1, green: 1, blue: 1, alpha: 0.22), + CGColor(srgbRed: 1, green: 1, blue: 1, alpha: 0.0)] as CFArray + if let gradient = CGGradient(colorsSpace: CGColorSpace(name: CGColorSpace.sRGB), + colors: shine, locations: [0, 0.55]) { + ctx.drawLinearGradient(gradient, + start: CGPoint(x: rect.minX, y: rect.minY), + end: CGPoint(x: rect.minX, y: rect.maxY), options: []) + } + ctx.restoreGState() + } else { + // No refraction without a snapshot: the tint alone, faint. + ctx.saveGState() + ctx.addPath(pill) + ctx.clip() + ctx.setFillColor(CGColor(srgbRed: CGFloat((tint >> 16) & 0xFF) / 255, + green: CGFloat((tint >> 8) & 0xFF) / 255, + blue: CGFloat(tint & 0xFF) / 255, + alpha: 0.18)) + ctx.fill(rect) + ctx.restoreGState() + } + // The lens edge: a light rim and a darker bottom hairline, the way a + // real material catches its own edge. + ctx.addPath(pill) + ctx.setStrokeColor(CGColor(srgbRed: 1, green: 1, blue: 1, alpha: 0.35)) + ctx.setLineWidth(1) + ctx.strokePath() + ctx.addPath(pill) + ctx.setStrokeColor(CGColor(srgbRed: 0, green: 0, blue: 0, alpha: 0.25)) + ctx.setLineWidth(0.5) + ctx.clip() + ctx.stroke(CGRect(x: rect.minX, y: rect.maxY - 2, width: rect.width, height: 1.5)) + } + + /// True when the refraction composited. The snapshot is taken when the + /// pill moves and reused while it sits still. + private func refract(_ ctx: CGContext, pill: CGPath, rect: CGRect, lens: CGRect, tint: UInt32) -> Bool { + guard let shot = snapshotFor(ctx, lens: lens) else { return false } + var image = CIImage(cgImage: shot) + // The blur is grown slightly past the crop and the whole thing is + // pulled toward the centre by a few percent: the rim bends what + // surrounds it, which is the refraction the eye reads as glass. + let blur = image.applyingGaussianBlur(sigma: 2 * metricsScale) + let grown = blur.extent.insetBy(dx: -8 * metricsScale, dy: -8 * metricsScale) + let centre = CGPoint(x: grown.midX, y: grown.midY) + let scale = 1.06 + let lensTransform = CGAffineTransform(translationX: centre.x * (1 - scale), + y: centre.y * (1 - scale)).scaledBy(x: scale, y: scale) + image = blur.transformed(by: lensTransform) + guard let filtered = glassContext.createCGImage(image, from: grown) else { return false } + ctx.saveGState() + ctx.addPath(pill) + ctx.clip() + ctx.draw(filtered, in: rect) + // The glass's own body colour: the theme's selection, faint — the + // refraction and the sheen carry the material, not opacity. + ctx.setFillColor(CGColor(srgbRed: CGFloat((tint >> 16) & 0xFF) / 255, + green: CGFloat((tint >> 8) & 0xFF) / 255, + blue: CGFloat(tint & 0xFF) / 255, + alpha: 0.15)) + ctx.fill(rect) + ctx.restoreGState() + return true + } + + private func snapshotFor(_ ctx: CGContext, lens: CGRect) -> CGImage? { + if let held = glassShot, held.rect == lens { return held.image } + guard let whole = ctx.makeImage() else { return nil } + // makeImage hands back backing pixels; the lens is in view points. + let region = CGRect(x: lens.minX * metricsScale, y: lens.minY * metricsScale, + width: lens.width * metricsScale, height: lens.height * metricsScale) + guard let cropped = whole.cropping(to: region) else { return nil } + glassShot = (lens, cropped) + return cropped + } + + /// The hovered word's glyphs, back on top of the material, crisp: text + /// sits on liquid glass, not behind it. + private func redrawHoverRun(_ ctx: CGContext, _ cells: UnsafePointer<pardes_cell_s>, + cols: Int, row: Int, start: Int, end: Int) { + ctx.saveGState() + ctx.textMatrix = .identity + ctx.translateBy(x: 0, y: bounds.height) + ctx.scaleBy(x: 1, y: -1) + let baseline = bounds.height - (CGFloat(row) * cellHeight + metrics.ascent) + for col in start..<end { + let cell = cells[row * cols + col] + guard cell.flags & UInt8(PARDES_CELL_DEFAULT) == 0 else { continue } + let text = withUnsafeBytes(of: cell.text) { raw in + String(decoding: raw.prefix(Int(cell.len)), as: UTF8.self) + } + guard !text.isEmpty, text != " " else { continue } + let style = resolve(cell, block: false, + ground: themeBG ?? pardesDefaultBG, clearGround: false) + guard style.visible else { continue } + setFill(ctx, style.fg, style.alpha) + ctx.textPosition = CGPoint(x: CGFloat(col) * cellWidth, y: baseline) + let attributed = NSAttributedString(string: text, attributes: [ + fontAttribute: metrics.fonts[Face(bold: cell.attrs & UInt16(PARDES_ATTR_BOLD) != 0, + italic: cell.attrs & UInt16(PARDES_ATTR_ITALIC) != 0).rawValue], + NSAttributedString.Key(kCTForegroundColorFromContextAttributeName as String): true]) + CTLineDraw(CTLineCreateWithAttributedString(attributed as CFAttributedString), ctx) + } + ctx.textMatrix = .identity + ctx.restoreGState() + } + + + private struct FrozenBodyLayer { let values: [Int] let cells: [pardes_cell_s] @@ -1626,6 +1861,7 @@ final class PardesView: NSView { var runAlpha: CGFloat = 1 runGlyphs.removeAll(keepingCapacity: true) runPositions.removeAll(keepingCapacity: true) + ligatureCovered.removeAll(keepingCapacity: true) func flush() { guard !runGlyphs.isEmpty else { return } @@ -1640,6 +1876,7 @@ final class PardesView: NSView { for col in 0..<cols { let cell = cells[base + col] + if ligatureCovered.contains(col) { continue } if cell.flags & UInt8(PARDES_CELL_DEFAULT) != 0 { continue } let tagline = cell.flags & UInt8(PARDES_CELL_TAGLINE) != 0 let cellBaseline = tagline @@ -1687,6 +1924,24 @@ final class PardesView: NSView { let units = text.utf16 let known = units.count == 1 ? glyph(face, units.first!, tagline: tagline) : 0 if known != 0 { + // Ligatures: consecutive cells shaped together when the face + // collapses them into fewer glyphs. A monospace coding + // ligature advances by the same whole cells its characters + // did, so the grid cannot drift and only the ink changes. + if !tagline, let ligature = ligatureSpan(cells, base: base, cols: cols, col: col, + face: face, fg: style.fg, alpha: style.alpha, + blockCol: blockCol, paneGripCells: paneGripCells) { + flush() + setFill(ctx, style.fg, style.alpha) + let attributed = NSAttributedString(string: ligature.text, + attributes: [fontAttribute: metrics.fonts[face.rawValue], + NSAttributedString.Key(kCTForegroundColorFromContextAttributeName as String): true]) + ctx.textPosition = CGPoint(x: x, y: cellBaseline) + CTLineDraw(CTLineCreateWithAttributedString(attributed as CFAttributedString), ctx) + ctx.textMatrix = .identity + for covered in 1..<ligature.covered { ligatureCovered.insert(col + covered) } + continue + } if !runGlyphs.isEmpty && (face != runFace || tagline != runTagline || style.fg != runColor || style.alpha != runAlpha) { @@ -1747,6 +2002,73 @@ final class PardesView: NSView { return found } + /// Ligature-prone character heads: a coding ligature always begins with + /// one of these, and refusing everything else keeps the per-frame cost of + /// ligature detection a set lookup rather than a shape. + private static let ligatureHeads: Set<Character> = [ + "<", ">", "=", "!", "&", "|", "-", "/", ":", ".", "#", "*", "+", "~", "^", "%", + ] + + private func ligates(_ face: Face, _ text: String) -> Bool { + let key = "\(face.rawValue):\(text)" + if let cached = ligatureShaped[key] { return cached } + let attributed = NSAttributedString(string: text, attributes: [fontAttribute: metrics.fonts[face.rawValue], + NSAttributedString.Key(kCTForegroundColorFromContextAttributeName as String): true]) + let shaped = CTLineCreateWithAttributedString(attributed as CFAttributedString) + var glyphs: [CGGlyph] = [] + for run in (CTLineGetGlyphRuns(shaped) as? [CTRun]) ?? [] { + let count = CTRunGetGlyphCount(run) + var runGlyphs = [CGGlyph](repeating: 0, count: count) + CTRunGetGlyphs(run, CFRange(location: 0, length: count), &runGlyphs) + glyphs.append(contentsOf: runGlyphs) + } + // A ligature is any divergence from per-character mapping. A font may + // collapse the pair into one glyph (JetBrains Mono) or swap each + // character for a joining half kept at the same count (Maple Mono) — + // either way the sequence must be shaped, not mapped. + let singles = text.utf16.map { glyph(face, $0, tagline: false) } + let result = glyphs.count != singles.count + || zip(glyphs, singles).contains { $0 != $1 } + ligatureShaped[key] = result + return result + } + + /// When the cells at `col` ligate under `face`, the text to shape and how + /// many cells the ligature covers. Nil when they do not. Ligature cells + /// must carry the same face, colour and alpha and no rules of their own — + /// a ligature is one glyph of one style, not a style transition. + private func ligatureSpan(_ cells: UnsafePointer<pardes_cell_s>, base: Int, cols: Int, col: Int, + face: Face, fg: UInt32, alpha: CGFloat, + blockCol: Int, paneGripCells: Set<Int>) -> (text: String, covered: Int)? { + func eligible(_ index: Int) -> String? { + let cell = cells[base + index] + if cell.flags & UInt8(PARDES_CELL_DEFAULT) != 0 { return nil } + if cell.flags & UInt8(PARDES_CELL_TAGLINE) != 0 { return nil } + if cell.attrs >> UInt16(PARDES_ATTR_UL_SHIFT) != 0 + || cell.attrs & UInt16(PARDES_ATTR_STRIKETHROUGH) != 0 { return nil } + if paneGripCells.contains(base + index) { return nil } + let text = withUnsafeBytes(of: cell.text) { raw in + String(decoding: raw.prefix(Int(cell.len)), as: UTF8.self) + } + guard text.utf16.count == 1 else { return nil } + let other = resolve(cell, block: blockCol == index, + ground: themeBG ?? pardesDefaultBG, clearGround: false) + guard other.visible, other.fg == fg, other.alpha == alpha, + Face(bold: cell.attrs & UInt16(PARDES_ATTR_BOLD) != 0, + italic: cell.attrs & UInt16(PARDES_ATTR_ITALIC) != 0) == face else { return nil } + return text + } + guard let head = eligible(col), PardesView.ligatureHeads.contains(head.first!) else { return nil } + guard col + 1 < cols, let second = eligible(col + 1) else { return nil } + let pair = head + second + if col + 2 < cols, let third = eligible(col + 2) { + let triple = pair + third + if ligates(face, triple) { return (triple, 3) } + } + if ligates(face, pair) { return (pair, 2) } + return nil + } + private func drawRules( _ ctx: CGContext, _ cell: pardes_cell_s, @@ -1921,6 +2243,13 @@ final class PardesView: NSView { override func keyDown(with event: NSEvent) { let flags = event.modifierFlags + // Same reason as the trackpad trace below: whether AppKit delivered a + // keystroke at all is not something this process can otherwise answer, + // and "Escape does nothing" has two entirely different causes + // depending on whether this line prints. + trace("key: code=\(event.keyCode) mods=\(flags.rawValue) chars=" + + (event.charactersIgnoringModifiers?.unicodeScalars + .map { String($0.value, radix: 16) }.joined(separator: ",") ?? "-")) // The ABI has no super bit, so a Command chord cannot be expressed at all. // Anything the main menu claims never reaches here; the rest is swallowed // rather than delivered as the bare keystroke the core would insert. @@ -2328,12 +2657,35 @@ final class PardesView: NSView { userInfo: nil)) } - private var linkPointer = false + private enum Pointer: Equatable { case text, link, target } + private var pointer: Pointer = .text + + /// The core's pointer_shape, in this shell's cursor vocabulary: a link + /// is a PDF hyperlink (the hand), a target is a word the core's own + /// Look-hover plumbing validated as a real Look or Exec operand, and + /// anything else is text — the I-beam. + private func pointerShape() -> Pointer { + switch pardes_pointer_shape() { + case 1: return .link + case 2: return .target + default: return .text + } + } + + private func cursorFor(_ shape: Pointer) -> NSCursor { + switch shape { + case .link: return .pointingHand + case .target: return .arrow + case .text: return .iBeam + } + } override func resetCursorRects() { - // Every cell in this view is text, including the tags. An arrow over a - // grid you can sweep and click words in is the wrong affordance. - addCursorRect(bounds, cursor: linkPointer ? .pointingHand : .iBeam) + // Every cell in this view is text, including the tags. An arrow over + // plain text is the wrong affordance — but a word the core has + // validated as a Look or Exec target earns it: middle- and + // right-clicking there acts, and the cursor says so before it does. + addCursorRect(bounds, cursor: cursorFor(pointer)) } override func setFrameSize(_ newSize: NSSize) { |
