Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 75 additions & 4 deletions Sources/SwiftTerm/Apple/AppleTerminalView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -133,12 +133,63 @@ extension TerminalView {
}
}

/// Identity of a shaped line: everything the typeset result depends on. A hit
/// means the CoreText work can be skipped entirely.
struct ShapedLineKey: Hashable {
let row: Int
let line: ObjectIdentifier
/// Content digest, not the write counter: a TUI repainting identical text must hit.
let contentHash: Int
let cols: Int
let selection: Range<Int>?
/// Bumped for global style changes (font, palette, hovered link).
let epoch: UInt64
}

/// A line's built attributed string together with its shaped CTLines.
struct ShapedLine {
let info: ViewLineInfo
let prepared: [(segment: ViewLineSegment, ctLine: CTLine, runs: [CTRun])]
}

/// Cap the cache so a long-lived view cannot accumulate stale generations; the
/// visible rows re-shape once after a flush, which is imperceptible.
static let shapedLineCacheLimit = 2048

/// Diagnostic counters for the shaped-line cache. Set SWIFTTERM_SHAPED_CACHE_STATS=1
/// to have hit/miss totals written to stderr every 5 s; otherwise this is a single
/// static bool test per drawn row.
static let shapedCacheStatsEnabled =
ProcessInfo.processInfo.environment["SWIFTTERM_SHAPED_CACHE_STATS"] == "1"

func noteShapedCache (hit: Bool) {
guard Self.shapedCacheStatsEnabled else { return }
if hit { shapedCacheHits &+= 1 } else { shapedCacheMisses &+= 1 }
let now = Date().timeIntervalSince1970
if now - shapedCacheLastReport >= 5 {
shapedCacheLastReport = now
let total = shapedCacheHits + shapedCacheMisses
let pct = total == 0 ? 0 : Double(shapedCacheHits) / Double(total) * 100
let msg = "shaped-cache: \(shapedCacheHits) hits / \(shapedCacheMisses) misses "
+ "(\(String(format: "%.1f", pct))% hit), \(shapedCacheEvictions) flushes, "
+ "\(shapedLineCache.count) entries\n"
FileHandle.standardError.write(Data(msg.utf8))
shapedCacheHits = 0; shapedCacheMisses = 0; shapedCacheEvictions = 0
}
}

func bumpShapedLineEpoch () {
shapedLineEpoch &+= 1
shapedLineCache.removeAll (keepingCapacity: true)
}

func resetCaches ()
{
self.attributes = [:]
self.urlAttributes = [:]
self.colors = Array(repeating: nil, count: 256)
self.trueColors = [:]
bumpShapedLineEpoch ()
}

// This is invoked when the font changes to recompute state
Expand Down Expand Up @@ -392,6 +443,7 @@ extension TerminalView {
{
urlAttributes = [:]
attributes = [:]
bumpShapedLineEpoch ()

terminal.updateFullScreen ()
queuePendingDisplay()
Expand Down Expand Up @@ -1424,7 +1476,16 @@ extension TerminalView {
}
#endif
let line = displayBuffer.lines [row]
let lineInfo = buildAttributedString(row: row, line: line, cols: displayBuffer.cols)
let shapedKey = ShapedLineKey(row: row,
line: ObjectIdentifier(line),
contentHash: line.contentHash,
cols: displayBuffer.cols,
selection: selectedColumnsRange(row: row, cols: displayBuffer.cols),
epoch: shapedLineEpoch)
let cachedShapedLine = shapedLineCache[shapedKey]
noteShapedCache(hit: cachedShapedLine != nil)
let lineInfo = cachedShapedLine?.info
?? buildAttributedString(row: row, line: line, cols: displayBuffer.cols)
let rowBase = lineOrigin.y + cellDimension.height
var underTextImages: [AppleImage] = []
var overTextKittyImages: [AppleImage] = []
Expand Down Expand Up @@ -1456,14 +1517,24 @@ extension TerminalView {
overTextKittyImages.sort(by: sortKitty)
}

// Pre-create CTLines and runs once per row to avoid duplicate creation
let preparedSegments: [(segment: ViewLineSegment, ctLine: CTLine, runs: [CTRun])] =
lineInfo.segments.compactMap { segment in
// Shape the line's segments once, then reuse them for every later redraw
// of the same content (see `shapedLineCache`).
let preparedSegments: [(segment: ViewLineSegment, ctLine: CTLine, runs: [CTRun])]
if let cachedShapedLine {
preparedSegments = cachedShapedLine.prepared
} else {
preparedSegments = lineInfo.segments.compactMap { segment in
guard segment.attributedString.length > 0 else { return nil }
let ctLine = CTLineCreateWithAttributedString(segment.attributedString)
guard let runs = CTLineGetGlyphRuns(ctLine) as? [CTRun] else { return nil }
return (segment, ctLine, runs)
}
if shapedLineCache.count >= Self.shapedLineCacheLimit {
shapedLineCache.removeAll (keepingCapacity: true)
shapedCacheEvictions &+= 1
}
shapedLineCache[shapedKey] = ShapedLine(info: lineInfo, prepared: preparedSegments)
}

// Background fill loop — uses cached CTLines
context.saveGState()
Expand Down
33 changes: 31 additions & 2 deletions Sources/SwiftTerm/BufferLine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import Foundation
/// BufferLines represents a single line of text displayed on the terminal

public final class BufferLine: CustomDebugStringConvertible {
public enum RenderLineMode {
public enum RenderLineMode: Hashable {
/// Render each character using a single cell
case single
/// Render character using two cells
Expand Down Expand Up @@ -39,7 +39,36 @@ public final class BufferLine: CustomDebugStringConvertible {
public private(set) var generation: UInt64 = 0

@inline(__always)
private func bump() { generation &+= 1 }
private func bump() { generation &+= 1; cachedContentHash = nil }

/// Lazily computed digest of everything that affects how this line draws.
private var cachedContentHash: Int? = nil

/// A hash of the line's *rendered content* — the cells (rune, width, attribute),
/// plus `isWrapped`, `renderMode` and image presence.
///
/// `generation` counts **writes**, not changes, so a full-screen TUI that repaints
/// the same text every frame bumps it on every line and defeats any renderer cache
/// (measured: a 10 Hz identical repaint gave a 2.5% hit rate). Comparing content
/// instead turns those redundant repaints into hits; recomputing costs one pass over
/// the cells, which is orders of magnitude cheaper than re-shaping the line.
public var contentHash: Int {
if let cachedContentHash { return cachedContentHash }
var hasher = Hasher()
hasher.combine(isWrapped)
hasher.combine(renderMode)
hasher.combine(images?.count ?? 0)
hasher.combine(dataSize)
for i in 0..<dataSize {
let cd = data[i]
hasher.combine(cd.code)
hasher.combine(cd.width)
hasher.combine(cd.attribute)
}
let h = hasher.finalize()
cachedContentHash = h
return h
}

public init (cols: Int, fillData: CharData? = nil, isWrapped: Bool = false)
{
Expand Down
19 changes: 18 additions & 1 deletion Sources/SwiftTerm/Mac/MacTerminalView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,16 @@ open class TerminalView: NSView, NSTextInputClient, NSUserInterfaceValidations,

// Attribute dictionary, maps a console attribute (color, flags) to the corresponding dictionary
// of attributes for an NSAttributedString
/// Shaped-line cache: skips CoreText typesetting for lines whose content,
/// selection and style are unchanged since the last draw. See
/// `AppleTerminalView`'s `ShapedLineKey` / `bumpShapedLineEpoch`.
var shapedLineCache: [TerminalView.ShapedLineKey: TerminalView.ShapedLine] = [:]
/// Invalidates every cached shaped line when a global style input changes.
var shapedLineEpoch: UInt64 = 0
var shapedCacheHits = 0
var shapedCacheMisses = 0
var shapedCacheEvictions = 0
var shapedCacheLastReport: TimeInterval = 0
var attributes: [Attribute: [NSAttributedString.Key:Any]] = [:]
var urlAttributes: [Attribute: [NSAttributedString.Key:Any]] = [:]

Expand Down Expand Up @@ -899,7 +909,14 @@ open class TerminalView: NSView, NSTextInputClient, NSUserInterfaceValidations,
}
}

var linkHighlightRange: [Terminal.LinkMatch.RowRange]?
/// Hovering a link changes how affected rows are drawn, so a change here has to
/// invalidate the shaped-line cache (only on a real change — the mouse moving
/// within one link must not thrash it).
var linkHighlightRange: [Terminal.LinkMatch.RowRange]? {
didSet {
if oldValue != linkHighlightRange { bumpShapedLineEpoch () }
}
}

/**
* If set to true, this will call the TerminalViewDelegate's rangeChanged method
Expand Down
10 changes: 10 additions & 0 deletions Sources/SwiftTerm/iOS/iOSTerminalView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,16 @@ open class TerminalView: UIScrollView, UITextInputTraits, UIKeyInput, UIScrollVi

// Attribute dictionary, maps a console attribute (color, flags) to the corresponding dictionary
// of attributes for an NSAttributedString
/// Shaped-line cache: skips CoreText typesetting for lines whose content,
/// selection and style are unchanged since the last draw. See
/// `AppleTerminalView`'s `ShapedLineKey` / `bumpShapedLineEpoch`.
var shapedLineCache: [TerminalView.ShapedLineKey: TerminalView.ShapedLine] = [:]
/// Invalidates every cached shaped line when a global style input changes.
var shapedLineEpoch: UInt64 = 0
var shapedCacheHits = 0
var shapedCacheMisses = 0
var shapedCacheEvictions = 0
var shapedCacheLastReport: TimeInterval = 0
var attributes: [Attribute: [NSAttributedString.Key:Any]] = [:]
var urlAttributes: [Attribute: [NSAttributedString.Key:Any]] = [:]

Expand Down