diff --git a/README.md b/README.md index 2db52f290..97623621d 100644 --- a/README.md +++ b/README.md @@ -107,7 +107,27 @@ connecting to a remote system is with SSH. ## Shared Code between MacOS and iOS -The iOS and UIKit code share a lot of the code, that code lives under the Apple directory. +The iOS, visionOS, and UIKit code share most of their implementation with macOS; everything that both platforms use lives under `Sources/SwiftTerm/Apple`. + +### Rendering Strategies + +`TerminalView` (macOS, iOS, and visionOS) ships with two rendering implementations: `.cached` (default) and `.legacy`. +Call `setRenderingStrategy(_:resetCache:)` on the main actor to toggle the renderer per view—even mid-session. +The cached strategy reuses CTLine output and records cache metrics, while the legacy strategy always rebuilds lines so you can bisect rendering bugs or profile without cache effects. +You can query the current mode via the read-only `terminalView.renderingStrategy` property. + +```swift +terminalView.setRenderingStrategy(.legacy) // fall back to upstream behavior +terminalView.setRenderingStrategy(.cached) // opt back into the cache (default) +terminalView.setRenderingStrategy(.cached, resetCache: false) // advanced: keep cache if you already invalidated rows +``` + +Leave `resetCache` as `true` unless you have a custom invalidation pipeline; it clears stale line layouts so the next frame fully redraws with the newly selected renderer. + +To inspect cache hit/miss counters, either sample `terminalView.lineLayoutCacheMetricsSnapshot()`, enable the macOS debug HUD (View ▸ Show Debug Overlay in the demo app), or watch the OSLog stream (e.g. `log stream --predicate 'subsystem == "SwiftTerm.TerminalView" AND category == "LineLayoutCache"'`). + ++ `.cached`: ship this in production for the best throughput; monitor metrics to ensure hit rates stay high. ++ `.legacy`: switch temporarily while investigating visual/parity regressions or when comparing performance to the pre-cache engine. ## Using SSH The core library currently does not provide a convenient way to connect to SSH, purely diff --git a/Sources/SwiftTerm/Apple/AppleTerminalView.swift b/Sources/SwiftTerm/Apple/AppleTerminalView.swift index 8f7a098d8..ecb0b2d83 100644 --- a/Sources/SwiftTerm/Apple/AppleTerminalView.swift +++ b/Sources/SwiftTerm/Apple/AppleTerminalView.swift @@ -13,6 +13,7 @@ import CoreText import ImageIO #endif import SwiftUI +import os.log #if os(iOS) || os(visionOS) import UIKit @@ -32,6 +33,11 @@ typealias TTBezierPath = NSBezierPath public typealias TTImage = NSImage #endif +public enum RenderingStrategy { + case legacy + case cached +} + /// A rendered fragment that starts at a specific column and contains a run of /// characters that all occupy the same number of columns. struct ViewLineSegment { @@ -54,6 +60,140 @@ struct ViewLineInfo { var kittyPlaceholders: [KittyPlaceholderCell] } +struct LineCacheEntry { + let generation: UInt64 + let lineInfo: ViewLineInfo + let ctLines: [CTLine] +} + +struct LineRenderOutput { + let lineInfo: ViewLineInfo + let ctLines: [CTLine] +} + +struct LineLayoutCacheMetrics: Sendable { + enum InvalidationReason: String { + case viewport + case scrollInvariant + case selection + case explicit + case bufferTrim + } + + private(set) var hits: UInt64 = 0 + private(set) var misses: UInt64 = 0 + private(set) var totalInvalidatedRows: UInt64 = 0 + private(set) var resetCount: UInt64 = 0 + private(set) var viewportInvalidations: UInt64 = 0 + private(set) var scrollInvariantInvalidations: UInt64 = 0 + private(set) var selectionInvalidations: UInt64 = 0 + private(set) var explicitInvalidations: UInt64 = 0 + private(set) var bufferTrimInvalidations: UInt64 = 0 + private(set) var lastViewportRange: ClosedRange? + private(set) var lastScrollInvariantRange: ClosedRange? + private(set) var lastSelectionRange: ClosedRange? + private(set) var totalMissDuration: TimeInterval = 0 + private(set) var maxMissDuration: TimeInterval = 0 + private(set) var lastMissDuration: TimeInterval = 0 + + mutating func recordHit() { + hits &+= 1 + } + + mutating func recordMiss(duration: Duration? = nil) { + misses &+= 1 + if let duration { + let seconds = duration.secondsValue + totalMissDuration += seconds + lastMissDuration = seconds + if seconds > maxMissDuration { + maxMissDuration = seconds + } + } + } + + mutating func resetLookupCounters() { + hits = 0 + misses = 0 + totalMissDuration = 0 + maxMissDuration = 0 + lastMissDuration = 0 + } + + mutating func recordReset() { + resetCount &+= 1 + resetLookupCounters() + totalInvalidatedRows = 0 + viewportInvalidations = 0 + scrollInvariantInvalidations = 0 + selectionInvalidations = 0 + explicitInvalidations = 0 + bufferTrimInvalidations = 0 + lastViewportRange = nil + lastScrollInvariantRange = nil + lastSelectionRange = nil + } + + mutating func recordInvalidation(reason: InvalidationReason, + range: ClosedRange, + removedRows: Int) { + let rowDelta = UInt64(max(removedRows, 0)) + totalInvalidatedRows &+= rowDelta + switch reason { + case .viewport: + viewportInvalidations &+= 1 + lastViewportRange = range + case .scrollInvariant: + scrollInvariantInvalidations &+= 1 + lastScrollInvariantRange = range + case .selection: + selectionInvalidations &+= 1 + lastSelectionRange = range + case .explicit: + explicitInvalidations &+= 1 + case .bufferTrim: + bufferTrimInvalidations &+= 1 + } + } + + mutating func recordTrimRemoval(range: ClosedRange, removedRows: Int) { + recordInvalidation(reason: .bufferTrim, range: range, removedRows: removedRows) + } + + var hitRate: Double? { + let total = hits + misses + guard total > 0 else { return nil } + return Double(hits) / Double(total) + } + + var averageMissDurationSeconds: TimeInterval? { + guard misses > 0 else { return nil } + return totalMissDuration / Double(misses) + } + + var lastMissDurationMilliseconds: Double? { + lastMissDuration > 0 ? lastMissDuration * 1_000 : nil + } + + var maxMissDurationMilliseconds: Double? { + maxMissDuration > 0 ? maxMissDuration * 1_000 : nil + } + + var averageMissDurationMilliseconds: Double? { + averageMissDurationSeconds.map { $0 * 1_000 } + } +} + +private let lineLayoutCacheLog = OSLog(subsystem: "SwiftTerm.TerminalView", category: "LineLayoutCache") + +private extension Duration { + var secondsValue: TimeInterval { + let components = self.components + let attosecondsPerSecond = 1_000_000_000_000_000_000.0 + return TimeInterval(components.seconds) + TimeInterval(components.attoseconds) / attosecondsPerSecond + } +} + extension TerminalView { typealias CellDimension = CGSize @@ -63,6 +203,7 @@ extension TerminalView { self.urlAttributes = [:] self.colors = Array(repeating: nil, count: 256) self.trueColors = [:] + resetLineLayoutCache() } // This is invoked when the font changes to recompute state @@ -87,6 +228,27 @@ extension TerminalView { public var caretFrame: CGRect { return caretView?.frame ?? CGRect.zero } + + /// Updates which rendering pipeline this view uses when drawing. + /// New `TerminalView` instances default to `.cached` for best performance. + /// Use the read-only `renderingStrategy` property if you need to inspect the current mode. + /// - Parameters: + /// - strategy: Renderer to apply to future draws. + /// - resetCache: Pass `true` (default) to purge cached line layouts before redrawing. Set to + /// `false` only if you have your own invalidation pathway and want to avoid a full flush. + @MainActor + public func setRenderingStrategy(_ strategy: RenderingStrategy, resetCache: Bool = true) { + precondition(Thread.isMainThread, "setRenderingStrategy must be invoked on the main thread") + guard renderingStrategy != strategy else { + return + } + renderingStrategy = strategy + if resetCache { + resetLineLayoutCache() + } + terminal?.clearUpdateRange() + queuePendingDisplay() + } func setupOptions(width: CGFloat, height: CGFloat) { @@ -143,6 +305,7 @@ extension TerminalView { if newCols != terminal.cols || newRows != terminal.rows { selection.active = false terminal.resize (cols: newCols, rows: newRows) + resetLineLayoutCache() // These used to be outside accessibility.invalidate () @@ -234,10 +397,194 @@ extension TerminalView { { urlAttributes = [:] attributes = [:] + resetLineLayoutCache() terminal.updateFullScreen () queuePendingDisplay() } + + func resetLineLayoutCache() { + lineLayoutCache.removeAll(keepingCapacity: false) + lineLayoutCacheGeneration &+= 1 + cachedSelectionRange = nil + lineLayoutCacheMetrics.recordReset() + if let terminal { + lineLayoutCacheMaxValidRow = terminal.displayBuffer.lines.count - 1 + } else { + lineLayoutCacheMaxValidRow = -1 + } + } + + func cachedLineInfo(bufferRow: Int, line: BufferLine, cols: Int) -> ViewLineInfo { + if let entry = lineLayoutCache[bufferRow], entry.generation == lineLayoutCacheGeneration { + lineLayoutCacheMetrics.recordHit() + return entry.lineInfo + } + let start = ContinuousClock.now + let info = buildAttributedString(row: bufferRow, line: line, cols: cols) + let duration = start.duration(to: .now) + let ctLines = info.segments.map { CTLineCreateWithAttributedString($0.attributedString) } + lineLayoutCacheMetrics.recordMiss(duration: duration) + lineLayoutCache[bufferRow] = LineCacheEntry(generation: lineLayoutCacheGeneration, lineInfo: info, ctLines: ctLines) + return info + } + + func lineRenderOutput(row: Int, bufferRow: Int, line: BufferLine, cols: Int) -> LineRenderOutput { + switch renderingStrategy { + case .cached: + return cachedLineRenderOutput(bufferRow: bufferRow, line: line, cols: cols) + case .legacy: + return legacyLineRenderOutput(bufferRow: bufferRow, line: line, cols: cols) + } + } + + private func cachedLineRenderOutput(bufferRow: Int, line: BufferLine, cols: Int) -> LineRenderOutput { + let lineInfo = cachedLineInfo(bufferRow: bufferRow, line: line, cols: cols) + if let entry = cachedLineLayoutEntry(bufferRow: bufferRow), + entry.ctLines.count == lineInfo.segments.count { + return LineRenderOutput(lineInfo: lineInfo, ctLines: entry.ctLines) + } + let fallback = lineInfo.segments.map { CTLineCreateWithAttributedString($0.attributedString) } + #if DEBUG + assertionFailure("Line cache CTLine count mismatch for buffer row \(bufferRow)") + #endif + lineLayoutCache[bufferRow] = LineCacheEntry(generation: lineLayoutCacheGeneration, + lineInfo: lineInfo, + ctLines: fallback) + return LineRenderOutput(lineInfo: lineInfo, ctLines: fallback) + } + + private func legacyLineRenderOutput(bufferRow: Int, line: BufferLine, cols: Int) -> LineRenderOutput { + let lineInfo = buildAttributedString(row: bufferRow, line: line, cols: cols) + let ctLines = lineInfo.segments.map { CTLineCreateWithAttributedString($0.attributedString) } + return LineRenderOutput(lineInfo: lineInfo, ctLines: ctLines) + } + + func cachedLineLayoutEntry(bufferRow: Int) -> LineCacheEntry? { + guard let entry = lineLayoutCache[bufferRow], entry.generation == lineLayoutCacheGeneration else { + return nil + } + return entry + } + + func clampLineLayoutCacheToBufferBounds(maxRow: Int) { + if maxRow < 0 { + if !lineLayoutCache.isEmpty { + let removedEntries = lineLayoutCache.count + lineLayoutCache.removeAll(keepingCapacity: false) + lineLayoutCacheMetrics.recordTrimRemoval(range: 0...0, removedRows: removedEntries) + } + lineLayoutCacheMaxValidRow = -1 + return + } + let staleKeys = lineLayoutCache.keys.filter { $0 < 0 || $0 > maxRow } + for key in staleKeys { + lineLayoutCache.removeValue(forKey: key) + } + if !staleKeys.isEmpty { + lineLayoutCacheMetrics.recordTrimRemoval(range: 0...maxRow, removedRows: staleKeys.count) + } + lineLayoutCacheMaxValidRow = maxRow + } + + func invalidateLineLayoutCache(bufferRows range: ClosedRange, + reason: LineLayoutCacheMetrics.InvalidationReason = .explicit) { + if let terminal { + let buffer = terminal.displayBuffer + let currentMaxRow = buffer.lines.count - 1 + if lineLayoutCacheMaxValidRow == -1 { + lineLayoutCacheMaxValidRow = currentMaxRow + } else if currentMaxRow < lineLayoutCacheMaxValidRow { + clampLineLayoutCacheToBufferBounds(maxRow: currentMaxRow) + } else if currentMaxRow > lineLayoutCacheMaxValidRow { + lineLayoutCacheMaxValidRow = currentMaxRow + } + } + guard !lineLayoutCache.isEmpty else { + return + } + let lower = max(range.lowerBound, 0) + let upper = max(lower, range.upperBound) + let rowsToRemove = lineLayoutCache.keys.filter { key in + key >= lower && key <= upper + } + for row in rowsToRemove { + lineLayoutCache.removeValue(forKey: row) + } + lineLayoutCacheMetrics.recordInvalidation(reason: reason, + range: lower...upper, + removedRows: rowsToRemove.count) + } + + func invalidateViewportLineCache(range: ClosedRange, buffer: Buffer) { + guard range.lowerBound <= range.upperBound else { + return + } + let lower = buffer.yDisp + range.lowerBound + let upper = buffer.yDisp + range.upperBound + invalidateLineLayoutCache(bufferRows: lower...upper, reason: .viewport) + } + + func invalidateSelectionLineCache() { + var rangesToInvalidate: [ClosedRange] = [] + if let previousSelection = cachedSelectionRange { + rangesToInvalidate.append(previousSelection) + cachedSelectionRange = nil + } + + guard let selection, selection.active else { + for range in rangesToInvalidate { + invalidateLineLayoutCache(bufferRows: range, reason: .selection) + } + return + } + + let lower = min(selection.start.row, selection.end.row) + let upper = max(selection.start.row, selection.end.row) + let currentSelection = lower...upper + cachedSelectionRange = currentSelection + rangesToInvalidate.append(currentSelection) + + for range in rangesToInvalidate { + invalidateLineLayoutCache(bufferRows: range, reason: .selection) + } + } + + func resetLineLayoutCacheStats() { + lineLayoutCacheMetrics.resetLookupCounters() + } + + func lineLayoutCacheStats() -> (hits: Int, misses: Int) { + let snapshot = lineLayoutCacheMetricsSnapshot() + return (hits: Int(snapshot.hits), misses: Int(snapshot.misses)) + } + + func lineLayoutCacheMetricsSnapshot() -> LineLayoutCacheMetrics { + lineLayoutCacheMetrics + } + + func logLineLayoutCacheMetrics(context: String) { + let snapshot = lineLayoutCacheMetricsSnapshot() + let hitRatePercent = snapshot.hitRate.map { $0 * 100.0 } ?? -1 + let avgMissMs = snapshot.averageMissDurationMilliseconds ?? -1 + let maxMissMs = snapshot.maxMissDurationMilliseconds ?? -1 + let lastMissMs = snapshot.lastMissDurationMilliseconds ?? -1 + os_log("LineLayoutCache[%{public}@] hits=%{public}llu misses=%{public}llu hitRate=%{public}.2f invalidatedRows=%{public}llu resets=%{public}llu viewportInvalidations=%{public}llu scrollInvariantInvalidations=%{public}llu selectionInvalidations=%{public}llu avgMissMs=%{public}.3f maxMissMs=%{public}.3f lastMissMs=%{public}.3f", + log: lineLayoutCacheLog, + type: .info, + context, + snapshot.hits, + snapshot.misses, + hitRatePercent, + snapshot.totalInvalidatedRows, + snapshot.resetCount, + snapshot.viewportInvalidations, + snapshot.scrollInvariantInvalidations, + snapshot.selectionInvalidations, + avgMissMs, + maxMissMs, + lastMissMs) + } public func hostCurrentDirectoryUpdated (source: Terminal) { @@ -812,7 +1159,11 @@ extension TerminalView { } #endif let line = displayBuffer.lines [row] - let lineInfo = buildAttributedString(row: row, line: line, cols: displayBuffer.cols) + let renderOutput = lineRenderOutput(row: row, + bufferRow: row, + line: line, + cols: displayBuffer.cols) + let lineInfo = renderOutput.lineInfo let rowBase = lineOrigin.y + cellDimension.height var underTextImages: [AppleImage] = [] var overTextKittyImages: [AppleImage] = [] @@ -844,12 +1195,14 @@ extension TerminalView { overTextKittyImages.sort(by: sortKitty) } - for segment in lineInfo.segments { + for (segmentIndex, segment) in lineInfo.segments.enumerated() { guard segment.attributedString.length > 0 else { continue } - let ctline = CTLineCreateWithAttributedString(segment.attributedString) + let ctline = segmentIndex < renderOutput.ctLines.count ? + renderOutput.ctLines[segmentIndex] : + CTLineCreateWithAttributedString(segment.attributedString) guard let runs = CTLineGetGlyphRuns(ctline) as? [CTRun] else { continue } @@ -1143,10 +1496,11 @@ extension TerminalView { func updateDisplay (notifyAccessibility: Bool) { updateCursorPosition() + let displayBuffer = terminal.displayBuffer + let scrollInvariantRange = terminal.getScrollInvariantUpdateRange() guard let (rowStart, rowEnd) = terminal.getUpdateRange () else { if notifyUpdateChanges { - let buffer = terminal.displayBuffer - let y = buffer.yDisp+buffer.y + let y = displayBuffer.yDisp+displayBuffer.y terminalDelegate?.rangeChanged (source: self, startY: y, endY: y) } return @@ -1155,6 +1509,11 @@ extension TerminalView { terminalDelegate?.rangeChanged (source: self, startY: rowStart, endY: rowEnd) } + invalidateViewportLineCache(range: rowStart...rowEnd, buffer: displayBuffer) + if let scrollInvariantRange { + invalidateLineLayoutCache(bufferRows: scrollInvariantRange.startY...scrollInvariantRange.endY, + reason: .scrollInvariant) + } terminal.clearUpdateRange () #if os(macOS) diff --git a/Sources/SwiftTerm/Mac/MacDebugView.swift b/Sources/SwiftTerm/Mac/MacDebugView.swift index e4f58476b..b22e1513b 100644 --- a/Sources/SwiftTerm/Mac/MacDebugView.swift +++ b/Sources/SwiftTerm/Mac/MacDebugView.swift @@ -25,7 +25,14 @@ public class TerminalDebugView: NSView { public func update () { setNeedsDisplay(frame) - dbg.stringValue = "x: \(terminal.buffer.x) y: \(terminal.buffer.y) yDisp: \(terminal.buffer.yDisp) yBase: \(terminal.buffer.yBase) clc: \(terminal.buffer.lines.getArray().count) startIndex: \(terminal.buffer.lines.getStartIndex())" + let metrics = terminalView.lineLayoutCacheMetricsSnapshot() + let hitRateString: String + if let hitRate = metrics.hitRate { + hitRateString = String(format: "%.1f%%", hitRate * 100.0) + } else { + hitRateString = "n/a" + } + dbg.stringValue = "x: \(terminal.buffer.x) y: \(terminal.buffer.y) yDisp: \(terminal.buffer.yDisp) yBase: \(terminal.buffer.yBase) clc: \(terminal.buffer.lines.getArray().count) startIndex: \(terminal.buffer.lines.getStartIndex()) cache hits: \(metrics.hits) misses: \(metrics.misses) hitRate: \(hitRateString) invalidRows: \(metrics.totalInvalidatedRows) resets: \(metrics.resetCount)" } public init (frame: CGRect, terminal: TerminalView) diff --git a/Sources/SwiftTerm/Mac/MacTerminalView.swift b/Sources/SwiftTerm/Mac/MacTerminalView.swift index b1c4cbd12..4481f229f 100644 --- a/Sources/SwiftTerm/Mac/MacTerminalView.swift +++ b/Sources/SwiftTerm/Mac/MacTerminalView.swift @@ -107,6 +107,12 @@ open class TerminalView: NSView, NSTextInputClient, NSUserInterfaceValidations, // Cache for the colors in the 0..255 range var colors: [NSColor?] = Array(repeating: nil, count: 256) var trueColors: [Attribute.Color:NSColor] = [:] + var lineLayoutCache: [Int: LineCacheEntry] = [:] + var lineLayoutCacheGeneration: UInt64 = 0 + var cachedSelectionRange: ClosedRange? + var lineLayoutCacheMetrics = LineLayoutCacheMetrics() + var lineLayoutCacheMaxValidRow: Int = -1 + public internal(set) var renderingStrategy: RenderingStrategy = .cached var transparent = TTColor.transparent () var isBigSur = true @@ -330,6 +336,7 @@ open class TerminalView: NSView, NSTextInputClient, NSUserInterfaceValidations, } open func bufferActivated(source: Terminal) { + resetLineLayoutCache() updateScroller () } @@ -374,6 +381,7 @@ open class TerminalView: NSView, NSTextInputClient, NSUserInterfaceValidations, func updateDebugDisplay() { debug?.update() + logLineLayoutCacheMetrics(context: "macOS") } func updateScroller () @@ -406,7 +414,7 @@ open class TerminalView: NSView, NSTextInputClient, NSUserInterfaceValidations, NSGraphicsContext.current?.cgContext } - override public func draw (_ dirtyRect: NSRect) { + override open func draw (_ dirtyRect: NSRect) { guard let currentContext = getCurrentGraphicsContext() else { return } @@ -849,6 +857,7 @@ open class TerminalView: NSView, NSTextInputClient, NSUserInterfaceValidations, } open func selectionChanged(source: Terminal) { + invalidateSelectionLineCache() needsDisplay = true } diff --git a/Sources/SwiftTerm/iOS/iOSTerminalView.swift b/Sources/SwiftTerm/iOS/iOSTerminalView.swift index 2c573559b..779d9db50 100644 --- a/Sources/SwiftTerm/iOS/iOSTerminalView.swift +++ b/Sources/SwiftTerm/iOS/iOSTerminalView.swift @@ -166,6 +166,12 @@ open class TerminalView: UIScrollView, UITextInputTraits, UIKeyInput, UIScrollVi // Cache for the colors in the 0..255 range var colors: [UIColor?] = Array(repeating: nil, count: 256) var trueColors: [Attribute.Color:UIColor] = [:] + var lineLayoutCache: [Int: LineCacheEntry] = [:] + var lineLayoutCacheGeneration: UInt64 = 0 + var cachedSelectionRange: ClosedRange? + var lineLayoutCacheMetrics = LineLayoutCacheMetrics() + var lineLayoutCacheMaxValidRow: Int = -1 + public internal(set) var renderingStrategy: RenderingStrategy = .cached var transparent = TTColor.transparent () // UITextInput support starts @@ -899,6 +905,7 @@ open class TerminalView: UIScrollView, UITextInputTraits, UIKeyInput, UIScrollVi var lineLeading: CGFloat = 0 open func bufferActivated(source: Terminal) { + resetLineLayoutCache() updateScroller () } @@ -925,6 +932,7 @@ open class TerminalView: UIScrollView, UITextInputTraits, UIKeyInput, UIScrollVi func updateDebugDisplay () { + logLineLayoutCacheMetrics(context: "iOS") } func scale (image: UIImage, size: CGSize) -> UIImage { @@ -1010,7 +1018,7 @@ open class TerminalView: UIScrollView, UITextInputTraits, UIKeyInput, UIScrollVi #endif } - override public func draw (_ dirtyRect: CGRect) { + override open func draw (_ dirtyRect: CGRect) { guard let context = getCurrentGraphicsContext() else { return } @@ -1536,6 +1544,7 @@ open class TerminalView: UIScrollView, UITextInputTraits, UIKeyInput, UIScrollVi } open func selectionChanged(source: Terminal) { + invalidateSelectionLineCache() if pendingSelectionChanged { return } diff --git a/TerminalApp/iOSTerminal.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/TerminalApp/iOSTerminal.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved deleted file mode 100644 index 4441db6a9..000000000 --- a/TerminalApp/iOSTerminal.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ /dev/null @@ -1,85 +0,0 @@ -{ - "pins" : [ - { - "identity" : "swift-argument-parser", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-argument-parser", - "state" : { - "revision" : "309a47b2b1d9b5e991f36961c983ecec72275be3", - "version" : "1.6.1" - } - }, - { - "identity" : "swift-asn1", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-asn1.git", - "state" : { - "revision" : "810496cf121e525d660cd0ea89a758740476b85f", - "version" : "1.5.1" - } - }, - { - "identity" : "swift-atomics", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-atomics.git", - "state" : { - "revision" : "b601256eab081c0f92f059e12818ac1d4f178ff7", - "version" : "1.3.0" - } - }, - { - "identity" : "swift-collections", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-collections.git", - "state" : { - "revision" : "7b847a3b7008b2dc2f47ca3110d8c782fb2e5c7e", - "version" : "1.3.0" - } - }, - { - "identity" : "swift-crypto", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-crypto.git", - "state" : { - "revision" : "95ba0316a9b733e92bb6b071255ff46263bbe7dc", - "version" : "3.15.1" - } - }, - { - "identity" : "swift-nio", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-nio.git", - "state" : { - "revision" : "4a9a97111099376854a7f8f0f9f88b9d61f52eff", - "version" : "2.92.2" - } - }, - { - "identity" : "swift-nio-ssh", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-nio-ssh", - "state" : { - "revision" : "8f33cac67309a13aecc0a4d95044543549b20ffb", - "version" : "0.12.0" - } - }, - { - "identity" : "swift-subprocess", - "kind" : "remoteSourceControl", - "location" : "https://github.com/swiftlang/swift-subprocess", - "state" : { - "revision" : "426790f3f24afa60b418450da0afaa20a8b3bdd4" - } - }, - { - "identity" : "swift-system", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-system", - "state" : { - "revision" : "61e4ca4b81b9e09e2ec863b00c340eb13497dac6", - "version" : "1.5.0" - } - } - ], - "version" : 2 -} diff --git a/Tests/SwiftTermTests/LineLayoutCacheTests.swift b/Tests/SwiftTermTests/LineLayoutCacheTests.swift new file mode 100644 index 000000000..e1e36702b --- /dev/null +++ b/Tests/SwiftTermTests/LineLayoutCacheTests.swift @@ -0,0 +1,504 @@ +#if canImport(AppKit) +import AppKit +import CoreText +import Testing +@testable import SwiftTerm + +@MainActor +final class LineLayoutCacheTests { + private func makeView(cols: Int = 40, rows: Int = 10) -> TerminalView { + let width = CGFloat(cols) * 8 + let height = CGFloat(rows) * 16 + let view = TerminalView(frame: CGRect(x: 0, y: 0, width: width, height: height)) + view.resize(cols: cols, rows: rows) + return view + } + + private func makeBitmapContext(for view: TerminalView) -> CGContext? { + CGContext(data: nil, + width: Int(view.bounds.width), + height: Int(view.bounds.height), + bitsPerComponent: 8, + bytesPerRow: 0, + space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue) + } + + private func withBitmapContext(for view: TerminalView, + perform body: (CGContext) -> Void) { + guard let context = makeBitmapContext(for: view) else { + Issue.record("Failed to create CGContext for rendering test") + return + } + NSGraphicsContext.saveGraphicsState() + let graphicsContext = NSGraphicsContext(cgContext: context, flipped: true) + NSGraphicsContext.current = graphicsContext + defer { NSGraphicsContext.restoreGraphicsState() } + body(context) + } + + private func installCachedLine( + in view: TerminalView, + viewportRow: Int = 0, + text: String = "cache", + bufferRowOverride: Int? = nil + ) -> (line: BufferLine, bufferRow: Int, viewportRow: Int) { + let displayBuffer = view.terminal.displayBuffer + let bufferRow = bufferRowOverride ?? (displayBuffer.yDisp + viewportRow) + let cells = text.map { TerminalTestHarness.BufferCell($0) } + let line = TerminalTestHarness.makeBufferLine(columns: view.terminal.cols, cells: cells) + displayBuffer.lines[bufferRow] = line + _ = view.cachedLineInfo(bufferRow: bufferRow, line: line, cols: displayBuffer.cols) + return (line, bufferRow, viewportRow) + } + + @Test func testCachedLineInfoStoresEntriesPerLine() { + let view = makeView() + let (line, bufferRow, _) = installCachedLine(in: view) + let key = bufferRow + + guard let entry = view.lineLayoutCache[key] else { + Issue.record("Expected cached entry for BufferLine after rendering") + return + } + #expect(entry.generation == view.lineLayoutCacheGeneration) + + let info = view.cachedLineInfo(bufferRow: bufferRow, line: line, cols: view.terminal.displayBuffer.cols) + #expect(info.segments.count == entry.lineInfo.segments.count) + #expect(view.lineLayoutCache.count == 1) + } + + @Test func testViewportInvalidationAccountsForScrollbackOffset() { + let view = makeView(rows: 20) + view.terminal.displayBuffer.yDisp = 5 + let (_, bufferRow, viewportRow) = installCachedLine(in: view, viewportRow: 2, text: "scroll") + let key = bufferRow + #expect(view.lineLayoutCache[key] != nil) + + view.invalidateViewportLineCache(range: viewportRow...viewportRow, buffer: view.terminal.displayBuffer) + #expect(view.lineLayoutCache[key] == nil) + } + + @Test func testSelectionChangeInvalidatesCachedRows() { + let view = makeView(rows: 8) + let (_, bufferRow, _) = installCachedLine(in: view, text: "select") + let key = bufferRow + let start = Position(col: 0, row: bufferRow) + let end = Position(col: 3, row: bufferRow) + view.selection?.setSelection(start: start, end: end) + + view.invalidateSelectionLineCache() + #expect(view.lineLayoutCache[key] == nil) + } + + @Test func testSelectionChangeInvalidatesMultipleRows() { + let view = makeView(rows: 8) + let first = installCachedLine(in: view, viewportRow: 0, text: "r0") + let second = installCachedLine(in: view, viewportRow: 1, text: "r1") + let firstKey = first.bufferRow + let secondKey = second.bufferRow + view.selection?.setSelection(start: Position(col: 0, row: first.bufferRow), + end: Position(col: 0, row: second.bufferRow)) + + view.selectionChanged(source: view.terminal) + + #expect(view.lineLayoutCache[firstKey] == nil) + #expect(view.lineLayoutCache[secondKey] == nil) + } + + @Test func testSelectionChangeHandlesReversedRows() { + let view = makeView(rows: 8) + let first = installCachedLine(in: view, viewportRow: 0, text: "r0") + let second = installCachedLine(in: view, viewportRow: 1, text: "r1") + let firstKey = first.bufferRow + let secondKey = second.bufferRow + view.selection?.setSelection(start: Position(col: 0, row: second.bufferRow), + end: Position(col: 0, row: first.bufferRow)) + + view.selectionChanged(source: view.terminal) + + #expect(view.lineLayoutCache[firstKey] == nil) + #expect(view.lineLayoutCache[secondKey] == nil) + } + + @Test func testSelectionChangeNoopWhenSelectionInactive() { + let view = makeView(rows: 6) + let (_, bufferRow, _) = installCachedLine(in: view, text: "inactive") + let key = bufferRow + view.selection?.active = false + + view.selectionChanged(source: view.terminal) + + #expect(view.lineLayoutCache[key] != nil) + } + + @Test func testSelectionChangeInvalidatesPreviousSelectionRange() { + let view = makeView(rows: 8) + let first = installCachedLine(in: view, viewportRow: 0, text: "r0") + let second = installCachedLine(in: view, viewportRow: 1, text: "r1") + let firstKey = first.bufferRow + let secondKey = second.bufferRow + + view.selection?.setSelection(start: Position(col: 0, row: first.bufferRow), + end: Position(col: 0, row: first.bufferRow)) + view.selectionChanged(source: view.terminal) + #expect(view.lineLayoutCache[firstKey] == nil) + #expect(view.lineLayoutCache[secondKey] != nil) + + _ = view.cachedLineInfo(bufferRow: first.bufferRow, line: first.line, cols: view.terminal.displayBuffer.cols) + _ = view.cachedLineInfo(bufferRow: second.bufferRow, line: second.line, cols: view.terminal.displayBuffer.cols) + #expect(view.lineLayoutCache[firstKey] != nil) + #expect(view.lineLayoutCache[secondKey] != nil) + + view.selection?.setSelection(start: Position(col: 0, row: second.bufferRow), + end: Position(col: 0, row: second.bufferRow)) + view.selectionChanged(source: view.terminal) + + #expect(view.lineLayoutCache[firstKey] == nil) + #expect(view.lineLayoutCache[secondKey] == nil) + } + + @Test func testSelectionClearingInvalidatesPreviousSelectionRange() { + let view = makeView(rows: 8) + let (line, bufferRow, _) = installCachedLine(in: view, text: "sel") + let key = bufferRow + + view.selection?.setSelection(start: Position(col: 0, row: bufferRow), + end: Position(col: 0, row: bufferRow)) + view.selectionChanged(source: view.terminal) + #expect(view.lineLayoutCache[key] == nil) + + _ = view.cachedLineInfo(bufferRow: bufferRow, line: line, cols: view.terminal.displayBuffer.cols) + #expect(view.lineLayoutCache[key] != nil) + + view.selectNone() + view.selectionChanged(source: view.terminal) + + #expect(view.lineLayoutCache[key] == nil) + } + + @Test func testResetLineLayoutCacheClearsEntriesAndBumpsGeneration() { + let view = makeView(rows: 6) + let (_, bufferRow, _) = installCachedLine(in: view, text: "reset") + let key = bufferRow + let previousGeneration = view.lineLayoutCacheGeneration + + view.resetLineLayoutCache() + + #expect(view.lineLayoutCache[key] == nil) + #expect(view.lineLayoutCache.isEmpty) + #expect(view.lineLayoutCacheGeneration != previousGeneration) + } + + @Test func testStaleGenerationTriggersCacheMiss() { + let view = makeView(rows: 4) + let (line, bufferRow, _) = installCachedLine(in: view, text: "fresh") + let key = bufferRow + #expect(view.lineLayoutCache[key] != nil) + + view.resetLineLayoutCache() + let staleInfo = ViewLineInfo(segments: [], images: nil, kittyPlaceholders: []) + let staleGeneration = view.lineLayoutCacheGeneration &- 1 + view.lineLayoutCache[key] = LineCacheEntry(generation: staleGeneration, lineInfo: staleInfo, ctLines: []) + + let info = view.cachedLineInfo(bufferRow: bufferRow, line: line, cols: view.terminal.displayBuffer.cols) + #expect(!info.segments.isEmpty) + #expect(view.lineLayoutCache[key]?.generation == view.lineLayoutCacheGeneration) + } + + @Test func testUpdateDisplayInvalidatesViewportAndScrollInvariantLines() { + let view = makeView(rows: 5) + for i in 0..<20 { + view.terminal.feed(text: "row\(i)\r\n") + } + let displayBuffer = view.terminal.displayBuffer + let visibleBaseRow = displayBuffer.yDisp + #expect(visibleBaseRow > 0) + + let (_, visibleBufferRow, _) = installCachedLine(in: view, viewportRow: 0, text: "visible") + let visibleKey = visibleBufferRow + + let offscreenRow = max(0, visibleBaseRow - 1) + #expect(offscreenRow < visibleBaseRow) + let (_, offscreenBufferRow, _) = installCachedLine(in: view, viewportRow: 0, text: "offscreen", bufferRowOverride: offscreenRow) + let offscreenKey = offscreenBufferRow + + view.terminal.refreshStart = 0 + view.terminal.refreshEnd = 0 + view.terminal.scrollInvariantRefreshStart = offscreenRow + view.terminal.scrollInvariantRefreshEnd = offscreenRow + + view.updateDisplay(notifyAccessibility: false) + + #expect(view.lineLayoutCache[visibleKey] == nil) + #expect(view.lineLayoutCache[offscreenKey] == nil) + } + + @Test func testUpdateDisplayInvalidatesViewportWhenScrollInvariantNil() { + let view = makeView(rows: 6) + let (_, bufferRow, _) = installCachedLine(in: view, viewportRow: 0, text: "dirty") + let key = bufferRow + + view.terminal.refreshStart = 0 + view.terminal.refreshEnd = 0 + + view.updateDisplay(notifyAccessibility: false) + + #expect(view.lineLayoutCache[key] == nil) + } + + @Test func testViewportInvalidationIgnoresOutOfBoundsRange() { + let view = makeView(rows: 6) + let (_, bufferRow, viewportRow) = installCachedLine(in: view, viewportRow: 0, text: "stable") + let key = bufferRow + let buffer = view.terminal.displayBuffer + + view.invalidateViewportLineCache(range: (viewportRow + 100)...(viewportRow + 101), buffer: buffer) + + #expect(view.lineLayoutCache[key] != nil) + } + + @Test func testViewportInvalidationClampsNegativeRange() { + let view = makeView(rows: 6) + let (_, bufferRow, viewportRow) = installCachedLine(in: view, viewportRow: 0, text: "neg") + let key = bufferRow + view.invalidateViewportLineCache(range: -3...viewportRow, buffer: view.terminal.displayBuffer) + #expect(view.lineLayoutCache[key] == nil) + } + + @Test func testSelectionChangedDelegateClearsCache() { + let view = makeView(rows: 6) + let (_, bufferRow, _) = installCachedLine(in: view, text: "delegate") + let key = bufferRow + let start = Position(col: 0, row: bufferRow) + let end = Position(col: 2, row: bufferRow) + view.selection?.setSelection(start: start, end: end) + + view.selectionChanged(source: view.terminal) + + #expect(view.lineLayoutCache[key] == nil) + } + + @Test func testBufferActivatedDelegateResetsCache() { + let view = makeView(rows: 6) + let (_, bufferRow, _) = installCachedLine(in: view, text: "buffer") + let key = bufferRow + #expect(view.lineLayoutCache[key] != nil) + + view.bufferActivated(source: view.terminal) + + #expect(view.lineLayoutCache.isEmpty) + } + + @Test func testProcessSizeChangeResetsCache() { + let view = makeView(rows: 6) + let (_, bufferRow, _) = installCachedLine(in: view, text: "resize") + let key = bufferRow + let originalSize = view.bounds.size + let newSize = CGSize(width: originalSize.width, height: originalSize.height + view.cellDimension.height) + + _ = view.processSizeChange(newSize: newSize) + + #expect(view.lineLayoutCache[key] == nil) + #expect(view.lineLayoutCache.isEmpty) + } + + @Test func testColorsChangedResetsCache() { + let view = makeView(rows: 4) + let (_, bufferRow, _) = installCachedLine(in: view, text: "colors") + let key = bufferRow + #expect(view.lineLayoutCache[key] != nil) + + view.colorsChanged() + + #expect(view.lineLayoutCache.isEmpty) + } + + @Test func testMultiLineBufferInvalidationRemovesAllRows() { + let view = makeView(rows: 8) + let first = installCachedLine(in: view, viewportRow: 0, text: "row0") + let second = installCachedLine(in: view, viewportRow: 1, text: "row1") + + view.invalidateLineLayoutCache(bufferRows: first.bufferRow...second.bufferRow) + + #expect(view.lineLayoutCache[first.bufferRow] == nil) + #expect(view.lineLayoutCache[second.bufferRow] == nil) + } + + @Test func testInvalidateLineLayoutCacheClampsRequestedRange() { + let view = makeView(rows: 6) + let first = installCachedLine(in: view, viewportRow: 0, text: "row0") + let second = installCachedLine(in: view, viewportRow: 1, text: "row1") + + view.invalidateLineLayoutCache(bufferRows: (first.bufferRow - 10)...(second.bufferRow + 10)) + + #expect(view.lineLayoutCache.isEmpty) + } + + @Test func testInvalidateLineLayoutCacheNoopWhenCacheIsEmpty() { + let view = makeView(rows: 4) + view.lineLayoutCache.removeAll() + + view.invalidateLineLayoutCache(bufferRows: -5...5) + + #expect(view.lineLayoutCache.isEmpty) + } + + @Test func testInvalidateLineLayoutCacheHandlesEmptyBuffer() { + let view = makeView(rows: 4) + let (_, bufferRow, _) = installCachedLine(in: view, text: "rows") + let key = bufferRow + view.terminal.displayBuffer.lines.count = 0 + + view.invalidateLineLayoutCache(bufferRows: bufferRow...bufferRow) + + #expect(view.lineLayoutCache[key] == nil) + } + + @Test func testLineLayoutCacheMetricsCaptureViewportInvalidationRanges() { + let view = makeView(rows: 4) + let (_, _, viewportRow) = installCachedLine(in: view, viewportRow: 0, text: "metrics") + let buffer = view.terminal.displayBuffer + + view.invalidateViewportLineCache(range: viewportRow...viewportRow, buffer: buffer) + let snapshot = view.lineLayoutCacheMetricsSnapshot() + #expect(snapshot.viewportInvalidations == 1) + let expectedRow = buffer.yDisp + viewportRow + #expect(snapshot.lastViewportRange == expectedRow...expectedRow) + } + + @Test func testLineLayoutCacheMetricsCaptureSelectionInvalidations() { + let view = makeView(rows: 4) + let (_, bufferRow, _) = installCachedLine(in: view, viewportRow: 0, text: "selMetrics") + view.selection?.setSelection(start: Position(col: 0, row: bufferRow), + end: Position(col: 0, row: bufferRow)) + + view.invalidateSelectionLineCache() + let snapshot = view.lineLayoutCacheMetricsSnapshot() + #expect(snapshot.selectionInvalidations == 1) + #expect(snapshot.lastSelectionRange == bufferRow...bufferRow) + } + + @Test func testLineLayoutCacheMetricsReportHitRate() { + let view = makeView(rows: 4) + let (line, bufferRow, _) = installCachedLine(in: view, viewportRow: 0, text: "rate") + + view.resetLineLayoutCacheStats() + view.resetLineLayoutCache() + _ = view.cachedLineInfo(bufferRow: bufferRow, line: line, cols: view.terminal.displayBuffer.cols) + _ = view.cachedLineInfo(bufferRow: bufferRow, line: line, cols: view.terminal.displayBuffer.cols) + + let snapshot = view.lineLayoutCacheMetricsSnapshot() + #expect(snapshot.misses == 1) + #expect(snapshot.hits == 1) + guard let hitRate = snapshot.hitRate else { + Issue.record("Expected hit rate to be available after lookups") + return + } + #expect(hitRate == 0.5) + } + + @Test func testSteadyStateRenderingMaintainsHighCacheHitRate() { + let view = makeView(cols: 80, rows: 24) + for i in 0..<240 { + view.terminal.feed(text: "render\(i)\r\n") + } + let dirtyRect = CGRect(x: 0, y: 0, width: view.bounds.width, height: view.bounds.height) + view.resetLineLayoutCache() + view.resetLineLayoutCacheStats() + + withBitmapContext(for: view) { context in + let iterations = 30 + for _ in 0.. 0.9) + #expect(snapshot.hits > snapshot.misses * 5) + } + + @Test func testCachedLineInfoPrecomputesCTLinesForSegments() { + let view = makeView(rows: 4) + let (line, bufferRow, _) = installCachedLine(in: view, text: "ctline") + + let info = view.cachedLineInfo(bufferRow: bufferRow, line: line, cols: view.terminal.displayBuffer.cols) + + guard let entry = view.cachedLineLayoutEntry(bufferRow: bufferRow) else { + Issue.record("Expected cached entry with CTLines") + return + } + #expect(entry.ctLines.count == info.segments.count) + for (index, ctline) in entry.ctLines.enumerated() { + let glyphCount = CTLineGetGlyphCount(ctline) + let segment = info.segments[index] + #expect(glyphCount > 0 || segment.attributedString.length == 0) + } + } + +#if DEBUG + @Test func testCachedLineInfoRecordsHitsAndMisses() { + let view = makeView(rows: 4) + view.resetLineLayoutCacheStats() + let displayBuffer = view.terminal.displayBuffer + let line = TerminalTestHarness.makeBufferLine(columns: view.terminal.cols, cells: "stats".map { TerminalTestHarness.BufferCell($0) }) + let bufferRow = displayBuffer.yDisp + displayBuffer.lines[bufferRow] = line + + view.lineLayoutCache.removeAll() + var stats = view.lineLayoutCacheStats() + #expect(stats.hits == 0) + #expect(stats.misses == 0) + + _ = view.cachedLineInfo(bufferRow: bufferRow, line: line, cols: displayBuffer.cols) + stats = view.lineLayoutCacheStats() + #expect(stats.misses == 1) + #expect(stats.hits == 0) + + _ = view.cachedLineInfo(bufferRow: bufferRow, line: line, cols: displayBuffer.cols) + stats = view.lineLayoutCacheStats() + #expect(stats.misses == 1) + #expect(stats.hits == 1) + } + + @Test func testDrawTerminalContentsReportsCacheHitsOnSecondPass() { + let view = makeView(rows: 4) + view.resetLineLayoutCache() + view.resetLineLayoutCacheStats() + let displayBuffer = view.terminal.displayBuffer + let line = TerminalTestHarness.makeBufferLine(columns: view.terminal.cols, cells: "draw".map { TerminalTestHarness.BufferCell($0) }) + displayBuffer.lines[displayBuffer.yDisp] = line + let dirtyRect = CGRect(x: 0, y: 0, width: view.bounds.width, height: view.cellDimension.height) + + withBitmapContext(for: view) { context in + view.drawTerminalContents(dirtyRect: dirtyRect, context: context, bufferOffset: view.terminal.displayBuffer.yDisp) + var stats = view.lineLayoutCacheStats() + #expect(stats.misses == 1) + #expect(stats.hits == 0) + + view.drawTerminalContents(dirtyRect: dirtyRect, context: context, bufferOffset: view.terminal.displayBuffer.yDisp) + stats = view.lineLayoutCacheStats() + #expect(stats.misses == 1) + #expect(stats.hits >= 1) + } + } +#endif + @Test func testInvalidateLineLayoutCacheIgnoresNonOverlappingRange() { + let view = makeView(rows: 6) + let (_, bufferRow, _) = installCachedLine(in: view, text: "stay") + let key = bufferRow + let high = bufferRow + 200 + view.invalidateLineLayoutCache(bufferRows: high...high + 5) + + #expect(view.lineLayoutCache[key] != nil) + } + +} +#endif diff --git a/Tests/SwiftTermTests/RenderingStrategyTests.swift b/Tests/SwiftTermTests/RenderingStrategyTests.swift new file mode 100644 index 000000000..e46c36d7a --- /dev/null +++ b/Tests/SwiftTermTests/RenderingStrategyTests.swift @@ -0,0 +1,153 @@ +#if canImport(AppKit) +import AppKit +import Testing +@testable import SwiftTerm + +@MainActor +final class RenderingStrategyTests { + private func makeView(cols: Int = 40, rows: Int = 10) -> TerminalView { + let width = CGFloat(cols) * 8 + let height = CGFloat(rows) * 16 + let view = TerminalView(frame: CGRect(x: 0, y: 0, width: width, height: height)) + view.resize(cols: cols, rows: rows) + return view + } + + private func installLine(in view: TerminalView, + cells: [TerminalTestHarness.BufferCell], + viewportRow: Int = 0) -> (line: BufferLine, bufferRow: Int) { + let displayBuffer = view.terminal.displayBuffer + let bufferRow = displayBuffer.yDisp + viewportRow + let line = TerminalTestHarness.makeBufferLine(columns: displayBuffer.cols, cells: cells) + displayBuffer.lines[bufferRow] = line + return (line, bufferRow) + } + + private func renderOutput(for view: TerminalView, + line: BufferLine, + bufferRow: Int, + strategy: RenderingStrategy) -> LineRenderOutput { + view.setRenderingStrategy(strategy) + return view.lineRenderOutput(row: bufferRow, + bufferRow: bufferRow, + line: line, + cols: view.terminal.displayBuffer.cols) + } + + private func attributeRuns(for attributedString: NSAttributedString) -> [(range: NSRange, attributes: NSDictionary)] { + var runs: [(range: NSRange, attributes: NSDictionary)] = [] + attributedString.enumerateAttributes(in: NSRange(location: 0, length: attributedString.length)) { attributes, range, _ in + runs.append((range, attributes as NSDictionary)) + } + return runs + } + + private func assertAttributedStringsEqual(_ lhs: NSAttributedString, + _ rhs: NSAttributedString, + segmentIndex: Int) { + #expect(lhs.length == rhs.length, "Segment \(segmentIndex) length mismatch") + #expect(lhs.string == rhs.string, "Segment \(segmentIndex) string mismatch") + + let lhsRuns = attributeRuns(for: lhs) + let rhsRuns = attributeRuns(for: rhs) + #expect(lhsRuns.count == rhsRuns.count, "Segment \(segmentIndex) attribute run count mismatch") + for (runIndex, (left, right)) in zip(lhsRuns, rhsRuns).enumerated() { + #expect(NSEqualRanges(left.range, right.range), "Segment \(segmentIndex) range mismatch at run \(runIndex)") + #expect(left.attributes.isEqual(right.attributes), "Segment \(segmentIndex) attributes mismatch at run \(runIndex)") + } + } + + private func assertPlaceholdersEqual(_ lhs: [KittyPlaceholderCell], + _ rhs: [KittyPlaceholderCell]) { + #expect(lhs.count == rhs.count, "Placeholder count mismatch") + for (index, pair) in zip(lhs, rhs).enumerated() { + let (left, right) = pair + #expect(left.row == right.row, "Placeholder \(index) row mismatch") + #expect(left.col == right.col, "Placeholder \(index) col mismatch") + #expect(left.imageId == right.imageId, "Placeholder \(index) image mismatch") + #expect(left.placementId == right.placementId, "Placeholder \(index) placement mismatch") + #expect(left.placeholderRow == right.placeholderRow, "Placeholder \(index) placeholder row mismatch") + #expect(left.placeholderCol == right.placeholderCol, "Placeholder \(index) placeholder col mismatch") + #expect(left.msb == right.msb, "Placeholder \(index) msb mismatch") + } + } + + private func assertLineInfoEqual(_ lhs: ViewLineInfo, + _ rhs: ViewLineInfo) { + #expect(lhs.segments.count == rhs.segments.count, "Segment count mismatch") + for (index, pair) in zip(lhs.segments, rhs.segments).enumerated() { + let (legacy, cached) = pair + #expect(legacy.column == cached.column, "Segment \(index) column mismatch") + #expect(legacy.columnWidth == cached.columnWidth, "Segment \(index) width mismatch") + #expect(legacy.characterCount == cached.characterCount, "Segment \(index) character count mismatch") + assertAttributedStringsEqual(legacy.attributedString, cached.attributedString, segmentIndex: index) + } + + switch (lhs.images, rhs.images) { + case (nil, nil): + break + case let (left?, right?): + #expect(left.count == right.count, "Image count mismatch") + default: + Issue.record("Image presence mismatch between rendering strategies") + } + + assertPlaceholdersEqual(lhs.kittyPlaceholders, rhs.kittyPlaceholders) + } + + @Test + func testLegacyAndCachedRenderersProduceEquivalentSegmentsForSelectionAndUrls() { + let view = makeView(cols: 16, rows: 4) + let baseAttr = Attribute(fg: .ansi256(code: 34), bg: .defaultColor, style: .none) + let selectionAttr = Attribute(fg: .trueColor(red: 10, green: 200, blue: 90), + bg: .defaultInvertedColor, + style: [.underline, .italic], + underlineColor: .ansi256(code: 196)) + let wideAttr = Attribute(fg: .ansi256(code: 40), + bg: .defaultColor, + style: [.bold], + underlineColor: .trueColor(red: 128, green: 32, blue: 200)) + + guard let urlAtom = TinyAtom.lookup(value: "https://swift.org") else { + Issue.record("Failed to allocate TinyAtom for test payload") + return + } + + let cells: [TerminalTestHarness.BufferCell] = [ + .init("H", attribute: baseAttr), + .init("e", attribute: baseAttr), + .init("語", attribute: wideAttr, width: 2, payload: urlAtom), + .init("l", attribute: selectionAttr), + .init("l", attribute: selectionAttr), + .init("o", attribute: selectionAttr) + ] + + let (line, bufferRow) = installLine(in: view, cells: cells) + view.selection.setSelection(start: Position(col: 2, row: bufferRow), + end: Position(col: 5, row: bufferRow)) + + let legacy = renderOutput(for: view, line: line, bufferRow: bufferRow, strategy: .legacy) + let cached = renderOutput(for: view, line: line, bufferRow: bufferRow, strategy: .cached) + + assertLineInfoEqual(legacy.lineInfo, cached.lineInfo) + #expect(legacy.ctLines.count == cached.ctLines.count, "CTLine count mismatch") + } + + @Test + func testLegacyAndCachedRenderersAgreeOnKittyPlaceholders() { + let view = makeView(cols: 12, rows: 4) + let placeholderSequence = "\u{10EEEE}\u{0305}\u{0306}AB" + view.terminal.feed(text: placeholderSequence) + + let displayBuffer = view.terminal.displayBuffer + let bufferRow = displayBuffer.yDisp + let line = displayBuffer.lines[bufferRow] + + let legacy = renderOutput(for: view, line: line, bufferRow: bufferRow, strategy: .legacy) + let cached = renderOutput(for: view, line: line, bufferRow: bufferRow, strategy: .cached) + + assertLineInfoEqual(legacy.lineInfo, cached.lineInfo) + #expect(!legacy.lineInfo.kittyPlaceholders.isEmpty, "Expected kitty placeholders in legacy output") + } +} +#endif diff --git a/Tests/SwiftTermTests/TerminalTestHarness.swift b/Tests/SwiftTermTests/TerminalTestHarness.swift index 47a8f260c..19de8db95 100644 --- a/Tests/SwiftTermTests/TerminalTestHarness.swift +++ b/Tests/SwiftTermTests/TerminalTestHarness.swift @@ -21,6 +21,22 @@ final class TerminalTestDelegate: TerminalDelegate { } enum TerminalTestHarness { + struct BufferCell { + let character: Character + let attribute: Attribute + let width: Int + let payload: TinyAtom? + + init(_ character: Character, + attribute: Attribute = .empty, + width: Int = 1, + payload: TinyAtom? = nil) { + self.character = character + self.attribute = attribute + self.width = width + self.payload = payload + } + } static func makeTerminal(cols: Int = 80, rows: Int = 24, scrollback: Int = 0) -> (terminal: Terminal, delegate: TerminalTestDelegate) { let delegate = TerminalTestDelegate() let options = TerminalOptions(cols: cols, rows: rows, scrollback: scrollback) @@ -28,6 +44,25 @@ enum TerminalTestHarness { return (terminal, delegate) } + static func makeBufferLine(columns: Int, cells: [BufferCell], fill: CharData = .Null) -> BufferLine { + let line = BufferLine(cols: columns, fillData: fill) + var columnIndex = 0 + for cell in cells { + guard columnIndex < columns else { break } + guard let scalar = cell.character.unicodeScalars.first else { + columnIndex += cell.width + continue + } + var charData = CharData(attribute: cell.attribute, scalar: scalar, size: Int8(cell.width)) + if let payload = cell.payload { + charData.setPayload(atom: payload) + } + line[columnIndex] = charData + columnIndex += cell.width + } + return line + } + static func visibleLinesText(buffer: Buffer, terminal: Terminal? = nil, trimRight: Bool = true) -> [String] { let start = buffer.yDisp let end = min(buffer.yDisp + buffer.rows, buffer.lines.count) diff --git a/Tests/SwiftTermTests/TerminalUpdateRangeTests.swift b/Tests/SwiftTermTests/TerminalUpdateRangeTests.swift new file mode 100644 index 000000000..c24c032ca --- /dev/null +++ b/Tests/SwiftTermTests/TerminalUpdateRangeTests.swift @@ -0,0 +1,121 @@ +import Testing +@testable import SwiftTerm + +struct TerminalUpdateRangeTests { + @Test func testSingleLineUpdateMarksExactRow() { + let (terminal, _) = TerminalTestHarness.makeTerminal(cols: 10, rows: 5) + terminal.clearUpdateRange() + + terminal.updateRange(2) + + guard let range = terminal.getUpdateRange() else { + Issue.record("Expected update range after calling updateRange") + return + } + + #expect(range.startY == 2) + #expect(range.endY == 2) + } + + @Test func testMultipleDiscreteUpdatesMergeBounds() { + let (terminal, _) = TerminalTestHarness.makeTerminal(cols: 20, rows: 10) + terminal.clearUpdateRange() + + terminal.updateRange(1) + terminal.updateRange(8) + + guard let range = terminal.getUpdateRange() else { + Issue.record("Expected merged update range") + return + } + + #expect(range.startY == 1) + #expect(range.endY == 8) + } + + @Test func testRangeClearsAfterRead() { + let (terminal, _) = TerminalTestHarness.makeTerminal(cols: 10, rows: 5) + terminal.clearUpdateRange() + + terminal.updateRange(3) + + guard terminal.getUpdateRange() != nil else { + Issue.record("Expected range before clearing") + return + } + + // Fetching the range should not clear it automatically. + #expect(terminal.getUpdateRange() != nil) + + terminal.clearUpdateRange() + #expect(terminal.getUpdateRange() == nil) + } + + @Test func testScrollingSeparatesInvariantRange() { + let (terminal, _) = TerminalTestHarness.makeTerminal(cols: 10, rows: 10) + terminal.clearUpdateRange() + + terminal.buffer.yDisp = 12 + terminal.updateRange(1) + + guard let viewportRange = terminal.getUpdateRange(), + let invariantRange = terminal.getScrollInvariantUpdateRange() else { + Issue.record("Expected both viewport and invariant ranges") + return + } + + #expect(viewportRange.startY == 1) + #expect(viewportRange.endY == 1) + #expect(invariantRange.startY == 13) + #expect(invariantRange.endY == 13) + } + + @Test func testScrollingUpdatesDoNotAffectInvariantRange() { + let (terminal, _) = TerminalTestHarness.makeTerminal(cols: 10, rows: 5) + terminal.clearUpdateRange() + + terminal.updateRange(0, scrolling: true) + + #expect(terminal.getUpdateRange() != nil) + #expect(terminal.getScrollInvariantUpdateRange() == nil) + } + + @Test func testFeedingTextMarksDirtyViewportRows() { + let (terminal, _) = TerminalTestHarness.makeTerminal(cols: 10, rows: 3) + terminal.clearUpdateRange() + + terminal.feed(text: "abc") + + guard let range = terminal.getUpdateRange() else { + Issue.record("Expected dirty range after feeding text") + return + } + + #expect(range.startY == 0) + #expect(range.endY == 0) + } + + @Test func testFeedingScrollProducesScrollInvariantRange() { + let (terminal, _) = TerminalTestHarness.makeTerminal(cols: 10, rows: 2, scrollback: 10) + terminal.clearUpdateRange() + + for i in 0..<10 { + terminal.feed(text: "line\(i)\r\n") + } + + guard let viewportRange = terminal.getUpdateRange() else { + Issue.record("Expected viewport range after scroll-producing feed") + return + } + #expect(viewportRange.startY == 0) + #expect(viewportRange.endY == terminal.rows - 1) + + guard let invariant = terminal.getScrollInvariantUpdateRange() else { + Issue.record("Expected scroll-invariant range after scroll-producing feed") + return + } + + #expect(terminal.buffer.yBase > 0) + #expect(invariant.endY >= terminal.buffer.yDisp) + } +} diff --git a/Tests/SwiftTermTests/ViewLineInfoTests.swift b/Tests/SwiftTermTests/ViewLineInfoTests.swift new file mode 100644 index 000000000..f0582e281 --- /dev/null +++ b/Tests/SwiftTermTests/ViewLineInfoTests.swift @@ -0,0 +1,153 @@ +#if canImport(AppKit) +import AppKit +import Testing +@testable import SwiftTerm + +@MainActor +final class ViewLineInfoTests { + private func makeView(cols: Int = 40, rows: Int = 10) -> TerminalView { + let width = CGFloat(cols) * 8 + let height = CGFloat(rows) * 16 + let view = TerminalView(frame: CGRect(x: 0, y: 0, width: width, height: height)) + view.resize(cols: cols, rows: rows) + return view + } + + private func lineInfo(for view: TerminalView, row: Int) -> ViewLineInfo { + let displayBuffer = view.terminal.displayBuffer + let bufferRow = displayBuffer.yDisp + row + let line = displayBuffer.lines[bufferRow] + return view.buildAttributedString(row: bufferRow, line: line, cols: displayBuffer.cols) + } + + private func installLine(_ line: BufferLine, in view: TerminalView, row: Int = 0) { + let displayBuffer = view.terminal.displayBuffer + let bufferRow = displayBuffer.yDisp + row + displayBuffer.lines[bufferRow] = line + } + + @Test func testSegmentsRespectWideCharacters() { + let view = makeView(cols: 8, rows: 2) + view.terminal.feed(text: "語!") + + let info = lineInfo(for: view, row: 0) + guard let firstSegment = info.segments.first else { + Issue.record("Expected at least one segment") + return + } + + #expect(firstSegment.columnWidth == 2) + #expect(firstSegment.columnSpan == 2) + } + + @Test func testMixedWidthsProduceDistinctSegments() { + let view = makeView(cols: 8, rows: 1) + let attr = Attribute(fg: .defaultColor, bg: .defaultInvertedColor, style: .none) + let line = TerminalTestHarness.makeBufferLine(columns: view.terminal.cols, cells: [ + TerminalTestHarness.BufferCell("A", attribute: attr), + TerminalTestHarness.BufferCell("語", attribute: attr, width: 2), + TerminalTestHarness.BufferCell("B", attribute: attr) + ]) + installLine(line, in: view) + + let segments = lineInfo(for: view, row: 0).segments + #expect(segments.count == 3) + #expect(segments[0].column == 0) + #expect(segments[1].column == 1) + #expect(segments[1].columnWidth == 2) + #expect(segments[2].column == 3) + } + + @Test func testSelectionAddsBackgroundAttribute() { + let view = makeView(cols: 12, rows: 2) + view.terminal.feed(text: "hello world") + + view.selection?.setSelection(start: Position(col: 0, row: view.terminal.displayBuffer.yDisp), + end: Position(col: 4, row: view.terminal.displayBuffer.yDisp)) + + let info = lineInfo(for: view, row: 0) + let highlighted = info.segments.contains { segment in + var found = false + segment.attributedString.enumerateAttributes(in: NSRange(location: 0, length: segment.attributedString.length)) { attributes, _, stop in + if attributes.keys.contains(.selectionBackgroundColor) { + found = true + stop.pointee = true + } + } + return found + } + + #expect(highlighted) + } + + @Test func testUrlAttributesPreserved() { + let view = makeView(cols: 6, rows: 1) + let attr = Attribute(fg: .ansi256(code: 2), bg: .defaultInvertedColor, style: .none) + guard let atom = TinyAtom.lookup(value: "https://example.com") else { + Issue.record("Unable to allocate TinyAtom for url payload") + return + } + let line = TerminalTestHarness.makeBufferLine(columns: view.terminal.cols, cells: [ + TerminalTestHarness.BufferCell("語", attribute: attr, width: 2, payload: atom), + TerminalTestHarness.BufferCell("i", attribute: attr) + ]) + installLine(line, in: view) + + let info = lineInfo(for: view, row: 0) + #expect(info.segments.count == 2) + guard let urlSegment = info.segments.first, + info.segments.count > 1 else { + Issue.record("Expected url and non-url segments") + return + } + let nonUrlSegment = info.segments[1] + + let urlAttributes = urlSegment.attributedString.attributes(at: 0, effectiveRange: nil) + let underlineStyle = urlAttributes[.underlineStyle] as? Int ?? 0 + #expect(underlineStyle & NSUnderlineStyle.patternDash.rawValue != 0) + + let nonUrlAttributes = nonUrlSegment.attributedString.attributes(at: 0, effectiveRange: nil) + let secondaryStyle = nonUrlAttributes[.underlineStyle] as? Int ?? 0 + #expect(secondaryStyle == 0) + } + + @Test func testKittyPlaceholderCollection() { + let view = makeView(cols: 10, rows: 2) + view.terminal.feed(text: "\u{10EEEE}\u{0305}\u{0305}") + + let info = lineInfo(for: view, row: 0) + #expect(info.kittyPlaceholders.count == 1) + guard let placeholder = info.kittyPlaceholders.first else { + Issue.record("Expected kitty placeholder entry") + return + } + #expect(placeholder.row == view.terminal.displayBuffer.yDisp) + #expect(placeholder.placeholderRow == 0) + #expect(placeholder.placeholderCol == 0) + } + + @Test func testKittyPlaceholderHonorsEncodedCoordinates() { + let view = makeView(cols: 10, rows: 2) + let rowScalar: UInt32 = 0x030D + let colScalar: UInt32 = 0x030E + let placeholderScalar: UInt32 = 0x10EEEE + let text = String(UnicodeScalar(placeholderScalar)!) + + String(UnicodeScalar(rowScalar)!) + + String(UnicodeScalar(colScalar)!) + view.terminal.feed(text: text) + + let info = lineInfo(for: view, row: 0) + guard let placeholder = info.kittyPlaceholders.first else { + Issue.record("Expected kitty placeholder entry with explicit coordinates") + return + } + + let expectedRow = KittyPlaceholder.diacriticIndex[rowScalar] ?? -1 + let expectedCol = KittyPlaceholder.diacriticIndex[colScalar] ?? -1 + #expect(expectedRow >= 0) + #expect(expectedCol >= 0) + #expect(placeholder.placeholderRow == expectedRow) + #expect(placeholder.placeholderCol == expectedCol) + } +} +#endif