Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
1 change: 1 addition & 0 deletions Sources/SwiftTerm/Apple/AppleTerminalView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -568,6 +568,7 @@ extension TerminalView {
urlAttributes = [:]
attributes = [:]
clearCGColorCache()
colorRevision &+= 1

#if os(macOS)
if !isUsingMetalRenderer {
Expand Down
272 changes: 229 additions & 43 deletions Sources/SwiftTerm/Apple/Metal/MetalTerminalRenderer.swift

Large diffs are not rendered by default.

26 changes: 16 additions & 10 deletions Sources/SwiftTerm/Apple/Metal/Shaders.metal
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,12 @@ constant float2 kQuadCorners[6] = {

vertex GlyphOut terminal_text_vertex(uint vid [[vertex_id]],
const device GlyphVertex *vertices [[buffer(0)]],
constant float2 &viewport [[buffer(1)]]) {
constant float2 &viewport [[buffer(1)]],
constant float2 &origin [[buffer(2)]]) {
GlyphVertex v = vertices[vid];
float2 ndc = float2((v.position.x / viewport.x) * 2.0 - 1.0,
(v.position.y / viewport.y) * 2.0 - 1.0);
float2 placed = v.position + origin;
float2 ndc = float2((placed.x / viewport.x) * 2.0 - 1.0,
(placed.y / viewport.y) * 2.0 - 1.0);
GlyphOut out;
out.position = float4(ndc, 0.0, 1.0);
out.texCoord = v.texCoord;
Expand All @@ -51,12 +53,13 @@ vertex GlyphOut terminal_text_vertex(uint vid [[vertex_id]],

vertex GlyphOut terminal_cell_text_vertex(uint vid [[vertex_id]],
const device TextCell *cells [[buffer(0)]],
constant float2 &viewport [[buffer(1)]]) {
constant float2 &viewport [[buffer(1)]],
constant float2 &origin [[buffer(2)]]) {
uint cellIndex = vid / 6;
uint cornerIndex = vid % 6;
TextCell cell = cells[cellIndex];
float2 corner = kQuadCorners[cornerIndex];
float2 position = cell.position + cell.size * corner;
float2 position = cell.position + cell.size * corner + origin;
float2 ndc = float2((position.x / viewport.x) * 2.0 - 1.0,
(position.y / viewport.y) * 2.0 - 1.0);
GlyphOut out;
Expand Down Expand Up @@ -92,10 +95,12 @@ struct ColorOut {

vertex ColorOut terminal_color_vertex(uint vid [[vertex_id]],
const device ColorVertex *vertices [[buffer(0)]],
constant float2 &viewport [[buffer(1)]]) {
constant float2 &viewport [[buffer(1)]],
constant float2 &origin [[buffer(2)]]) {
ColorVertex v = vertices[vid];
float2 ndc = float2((v.position.x / viewport.x) * 2.0 - 1.0,
(v.position.y / viewport.y) * 2.0 - 1.0);
float2 placed = v.position + origin;
float2 ndc = float2((placed.x / viewport.x) * 2.0 - 1.0,
(placed.y / viewport.y) * 2.0 - 1.0);
ColorOut out;
out.position = float4(ndc, 0.0, 1.0);
out.color = v.color;
Expand All @@ -104,12 +109,13 @@ vertex ColorOut terminal_color_vertex(uint vid [[vertex_id]],

vertex ColorOut terminal_cell_color_vertex(uint vid [[vertex_id]],
const device ColorCell *cells [[buffer(0)]],
constant float2 &viewport [[buffer(1)]]) {
constant float2 &viewport [[buffer(1)]],
constant float2 &origin [[buffer(2)]]) {
uint cellIndex = vid / 6;
uint cornerIndex = vid % 6;
ColorCell cell = cells[cellIndex];
float2 corner = kQuadCorners[cornerIndex];
float2 position = cell.position + cell.size * corner;
float2 position = cell.position + cell.size * corner + origin;
float2 ndc = float2((position.x / viewport.x) * 2.0 - 1.0,
(position.y / viewport.y) * 2.0 - 1.0);
ColorOut out;
Expand Down
5 changes: 5 additions & 0 deletions Sources/SwiftTerm/Mac/MacTerminalView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,11 @@ open class TerminalView: NSView, NSTextInputClient, NSUserInterfaceValidations,
var debug: TerminalDebugView?
var pendingDisplay: Bool = false
var textBlinkVisible = true

/// Bumped whenever the colours are replaced, so a cache keyed on
/// appearance can tell that the same line now draws differently. See
/// `colorsChanged` and `CacheSignature`.
var colorRevision = 0
var textBlinkTimer: Timer?
var textBlinkObservers: [(NotificationCenter, NSObjectProtocol)] = []
var textBlinkApplicationActive = true
Expand Down
5 changes: 5 additions & 0 deletions Sources/SwiftTerm/iOS/iOSTerminalView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,11 @@ open class TerminalView: UIScrollView, UITextInputTraits, UIKeyInput, UIScrollVi
var debug: UIView?
var pendingDisplay: Bool = false
var textBlinkVisible = true

/// Bumped whenever the colours are replaced, so a cache keyed on
/// appearance can tell that the same line now draws differently. See
/// `colorsChanged` and `CacheSignature`.
var colorRevision = 0
var textBlinkTimer: Timer?
var textBlinkObservers: [(NotificationCenter, NSObjectProtocol)] = []
var textBlinkApplicationActive = true
Expand Down
162 changes: 162 additions & 0 deletions Tests/SwiftTermTests/MetalRowCacheTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
//
// MetalRowCacheTests.swift
//
// What the row cache is worth when output scrolls — the case a terminal
// spends most of its life in.
//

#if os(macOS) && canImport(MetalKit)
import XCTest
import MetalKit
@testable import SwiftTerm

final class MetalRowCacheTests: XCTestCase {
/// A view and a renderer with no window: `buildDrawData` needs neither, and
/// a window is exactly what a test cannot have.
private func makeRenderer(rows: Int, cols: Int) throws -> (TerminalView, MetalTerminalRenderer) {
guard let device = MTLCreateSystemDefaultDevice() else {
throw XCTSkip("no Metal device on this machine")
}
let view = TerminalView(frame: CGRect(x: 0, y: 0, width: 800, height: 600))
let mtkView = MTKView(frame: view.bounds, device: device)
let renderer = try MetalTerminalRenderer(view: mtkView, terminalView: view)

view.getTerminal().resize(cols: cols, rows: rows)
return (view, renderer)
}

private func feed(_ view: TerminalView, lines: Int) {
for i in 0..<lines {
view.feed(text: "line \(i) — the quick brown fox jumps over the lazy dog\r\n")
}
}

/// Scrolling must not throw the cache away.
///
/// The rows are the same rows; only the place they occupy has changed. This
/// used to rebuild every visible row on every scrolled frame — the vertices
/// were written in screen coordinates, so `yDisp` was part of the cache
/// signature, and the cache was keyed by a row number that stops meaning
/// the same line once the scrollback starts rotating.
func testScrollingKeepsTheRowsItAlreadyBuilt() throws {
let (view, renderer) = try makeRenderer(rows: 24, cols: 80)

// Fill the screen and the scrollback, so that further output rotates
// the circular list rather than merely appending to it.
feed(view, lines: 400)
_ = renderer.buildDrawData(scale: 2)

// One more line: one row changes, the rest scroll.
view.feed(text: "one more line\r\n")
_ = renderer.buildDrawData(scale: 2)

XCTAssertLessThanOrEqual(
renderer.rowsRebuiltLastFrame, 3,
"a one-line scroll rebuilt \(renderer.rowsRebuiltLastFrame) rows of "
+ "\(renderer.rowsRebuiltLastFrame + renderer.rowsReusedLastFrame)")
XCTAssertGreaterThan(renderer.rowsReusedLastFrame, 20)
}

/// A frame in which nothing happened must rebuild nothing at all.
func testAnIdleFrameRebuildsNothing() throws {
let (view, renderer) = try makeRenderer(rows: 24, cols: 80)
feed(view, lines: 100)
_ = renderer.buildDrawData(scale: 2)

_ = renderer.buildDrawData(scale: 2)
XCTAssertEqual(renderer.rowsRebuiltLastFrame, 0)
}

/// The row that changed is the row that is rebuilt — the cache must not
/// hand back stale geometry for a line that was written over.
func testAnEditedRowIsRebuilt() throws {
let (view, renderer) = try makeRenderer(rows: 24, cols: 80)
feed(view, lines: 100)
_ = renderer.buildDrawData(scale: 2)

// Overwrite the current line in place: no scroll, one dirty row.
view.feed(text: "\roverwritten")
_ = renderer.buildDrawData(scale: 2)

XCTAssertGreaterThanOrEqual(renderer.rowsRebuiltLastFrame, 1)
XCTAssertLessThanOrEqual(renderer.rowsRebuiltLastFrame, 3)
}

/// A row is drawn differently when it is selected, and selecting it does
/// not touch the line's contents — so the generation the cache checks does
/// not move. Before the presentation revision, the selected row kept its
/// unselected geometry.
func testSelectingARowRebuildsIt() throws {
let (view, renderer) = try makeRenderer(rows: 24, cols: 80)
feed(view, lines: 40)
_ = renderer.buildDrawData(scale: 2)
_ = renderer.buildDrawData(scale: 2)
XCTAssertEqual(renderer.rowsRebuiltLastFrame, 0, "the second frame should have rebuilt nothing")

view.selection.setSelection(start: Position(col: 0, row: 20),
end: Position(col: 10, row: 20))
_ = renderer.buildDrawData(scale: 2)

XCTAssertGreaterThanOrEqual(renderer.rowsRebuiltLastFrame, 1,
"the selected row has to be built again")
XCTAssertLessThanOrEqual(renderer.rowsRebuiltLastFrame, 2,
"and only that row: \(renderer.rowsRebuiltLastFrame) were")
}

/// The blink phase turns over twice a second and changes nothing about the
/// line's contents. A row with something blinking on it must follow; a row
/// without must not be dragged along with it.
func testBlinkRebuildsOnlyTheBlinkingRow() throws {
let (view, renderer) = try makeRenderer(rows: 24, cols: 80)
feed(view, lines: 20)
view.feed(text: "\u{1b}[5mblinking\u{1b}[0m\r\n")
feed(view, lines: 2)
_ = renderer.buildDrawData(scale: 2)
_ = renderer.buildDrawData(scale: 2)
XCTAssertEqual(renderer.rowsRebuiltLastFrame, 0)

view.textBlinkVisible.toggle()
_ = renderer.buildDrawData(scale: 2)

XCTAssertEqual(renderer.rowsRebuiltLastFrame, 1,
"exactly the blinking row, not the whole screen")
}

/// Replacing the palette changes how every row draws, and moves no line's
/// generation either.
func testChangingTheColoursRebuildsEverything() throws {
let (view, renderer) = try makeRenderer(rows: 24, cols: 80)
feed(view, lines: 40)
_ = renderer.buildDrawData(scale: 2)
_ = renderer.buildDrawData(scale: 2)
XCTAssertEqual(renderer.rowsRebuiltLastFrame, 0)

view.nativeForegroundColor = TTColor.make(red: 0.9, green: 0.2, blue: 0.2, alpha: 1)
_ = renderer.buildDrawData(scale: 2)

XCTAssertGreaterThan(renderer.rowsRebuiltLastFrame, 20,
"a new palette means every visible row")
}

/// What the change is for, in wall-clock terms.
func testScrollingIsCheap() throws {
let (view, renderer) = try makeRenderer(rows: 48, cols: 120)
feed(view, lines: 600)
_ = renderer.buildDrawData(scale: 2)

var rebuilt = 0
let started = Date()
for i in 0..<200 {
// Colour and bold, as an agent's output has: a plain ASCII line is
// not what this costs money on.
view.feed(text: "\u{1b}[1;32mscrolled line \(i)\u{1b}[0m — "
+ "\u{1b}[38;5;244mthe quick brown fox jumps over the lazy dog\u{1b}[0m\r\n")
_ = renderer.buildDrawData(scale: 2)
rebuilt += renderer.rowsRebuiltLastFrame
}
let each = Date().timeIntervalSince(started) / 200 * 1000
print(String(format: "buildDrawData while scrolling: %.3f ms per frame, %.1f rows rebuilt per frame",
each, Double(rebuilt) / 200))
}
}
#endif