summaryrefslogtreecommitdiff
path: root/src/macos/Sources
diff options
context:
space:
mode:
Diffstat (limited to 'src/macos/Sources')
-rw-r--r--src/macos/Sources/AppDelegate.swift18
-rw-r--r--src/macos/Sources/PardesView.swift406
2 files changed, 395 insertions, 29 deletions
diff --git a/src/macos/Sources/AppDelegate.swift b/src/macos/Sources/AppDelegate.swift
index cbe9cdc9..3cce7592 100644
--- a/src/macos/Sources/AppDelegate.swift
+++ b/src/macos/Sources/AppDelegate.swift
@@ -310,6 +310,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate, PardesViewDelegate {
file.addItem(withTitle: "Close Window",
action: #selector(NSWindow.performClose(_:)), keyEquivalent: "w")
+ // The workspace tag row's commands, in the place macOS keeps window
+ // commands. The build hands that row to this menu bar (see
+ // docs/macos.md): the words are the same builtins the tag carried,
+ // spelled exactly as a tag Exec would type them, and nothing here is
+ // editable — the menu is the tag's buttons, not the tag.
+ let builtins = submenu("Builtins", of: main)
+ for word in ["Newcol", "Joincol", "Find", "Grep", "Changelog",
+ "Dump", "NextColor", "Debug", "Kill"] {
+ builtins.addItem(command(word, #selector(menuBuiltin(_:)), ""))
+ }
let edit = submenu("Edit", of: main)
// Paste and nothing else. Copy, Cut, Undo and Select All have no
// builtin behind them — the core yanks into the clipboard on its own
@@ -379,13 +389,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate, PardesViewDelegate {
// here would hide half of what Look does.
panel.canChooseDirectories = true
panel.allowsMultipleSelection = true
- panel.resolvesAliases = true
- guard panel.runModal() == .OK else { return }
for url in panel.urls where url.isFileURL {
look(url.path)
}
}
+ /// Any workspace-tag word. The title is the builtin's own spelling, so
+ /// this is the same `run` a tag Exec performs and there is no second
+ /// vocabulary to keep in step.
+ @objc private func menuBuiltin(_ sender: NSMenuItem) { run(sender.title) }
+
+
@objc private func menuNew(_ sender: Any?) { run("New") }
@objc private func menuSave(_ sender: Any?) { run("Save") }
@objc private func menuHelp(_ sender: Any?) { run("Help") }
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) {