Skip to content
Merged
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,8 @@ can never drift.
- The terminal emulator erases with the current background color, line mode follows
cursor-up redraws and `\r\r\n` output, foreground images honor their DPI, and the
secret scanner recognizes PGP keys, bearer headers, URL credentials, and `:=`/`=>`.
- Preserve complete CJK and emoji characters when terminal insertion shifts them
from their leading cell; split or right-edge-truncated glyphs still clear safely.
- Social cards with image backgrounds export on an opaque matte, and raster PDFs use
point-sized pages.

Expand Down
128 changes: 128 additions & 0 deletions DomainTests/Terminal/TerminalInsertionTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import Testing

@testable import VitrineDomain

@Suite("Terminal wide-character insertion")
struct TerminalInsertionTests {
private let esc = "\u{1B}"

@Test(arguments: ["你", "🚀"], [1, 2])
func insertionBeforeWideHeadPreservesBothCells(glyph: String, count: Int) {
var screen = TerminalScreen(columns: 8, rows: 2)
screen.feed("\(glyph)Z\(esc)[1G\(esc)[\(count)@")

#expect(text(screen) == String(repeating: " ", count: count) + glyph + "Z")
#expect(screen.rows[0][count].content == .grapheme(glyph))
#expect(screen.rows[0][count + 1].content == .continuation)
#expect(screen.cursorCol == 0)
#expect(!screen.pendingWrap)
}

@Test(arguments: ["你", "🚀"])
func insertionBeforeWideHeadInTheMiddlePreservesThePrefix(_ glyph: String) {
var screen = TerminalScreen(columns: 8, rows: 2)
screen.feed("AB\(glyph)Z\(esc)[3G\(esc)[@")
#expect(text(screen) == "AB \(glyph)Z")
#expect(screen.cursorCol == 2)
}

@Test(arguments: ["你", "🚀"])
func insertionInsideContinuationStillClearsTheSplitGlyph(_ glyph: String) {
var screen = TerminalScreen(columns: 8, rows: 2)
screen.feed("\(glyph)Z\(esc)[2G\(esc)[@")
#expect(text(screen) == " Z")
#expect(!screen.rows[0].contains { $0.content == .continuation })
}

@Test(arguments: ["你", "🚀"])
func aWideGlyphThatStillFitsAtTheMarginSurvives(_ glyph: String) {
var screen = TerminalScreen(columns: 4, rows: 2)
screen.feed("A\(glyph)B\(esc)[2G\(esc)[@")
#expect(text(screen) == "A \(glyph)")
#expect(screen.rows[0][2].content == .grapheme(glyph))
#expect(screen.rows[0][3].content == .continuation)
#expect(screen.rows[0].count == 4)
}

@Test(arguments: ["你", "🚀"])
func aWideGlyphTruncatedAtTheMarginLeavesNoOrphan(_ glyph: String) {
var screen = TerminalScreen(columns: 4, rows: 2)
screen.feed("A\(glyph)B\(esc)[2G\(esc)[2@")
#expect(text(screen) == "A")
#expect(screen.rows[0].count == 4)
#expect(screen.rows[0].dropFirst().allSatisfy { $0.content == .grapheme(" ") })
}

@Test(arguments: ["你", "🚀"])
func aWideGlyphTruncatedAtTheMarginErasesWithThePenBackground(_ glyph: String) {
// The orphaned head must erase like the inserted cells, not punch an unstyled
// hole into a colored line.
var screen = TerminalScreen(columns: 4, rows: 2)
screen.feed("A\(glyph)B\(esc)[2G\(esc)[42m\(esc)[2@")
let green = TerminalCell(content: .grapheme(" "), style: ANSIStyle(background: .indexed(2)))
#expect(screen.rows[0].count == 4)
#expect(screen.rows[0][0].content == .grapheme("A"))
#expect(Array(screen.rows[0].dropFirst()) == [green, green, green])
}

@Test(arguments: ["你", "🚀"], [7, 8, 99])
func aCountReachingTheMarginPushesTheWholeGlyphOff(glyph: String, count: Int) {
var screen = TerminalScreen(columns: 8, rows: 2)
screen.feed("\(glyph)Z\(esc)[1G\(esc)[\(count)@")
#expect(text(screen) == "")
#expect(screen.rows[0].count == 8)
#expect(screen.rows[0].allSatisfy { $0 == .blank })
#expect(screen.cursorCol == 0)
}

@Test func repairErasesEveryOrphanedHalfWithThePenBackground() {
// The repair also covers shapes no single operation produces on its own: a
// headless continuation, a head whose continuation was overwritten, and a head
// at the margin. Each orphan erases with the pen fill, never an unstyled blank.
var screen = TerminalScreen(columns: 6, rows: 2)
screen.feed("\(esc)[42m")
let green = TerminalCell(content: .grapheme(" "), style: ANSIStyle(background: .indexed(2)))
let head = TerminalCell(content: .grapheme("你"), style: ANSIStyle())
let tail = TerminalCell(content: .continuation, style: ANSIStyle())
let narrow = TerminalCell(content: .grapheme("A"), style: ANSIStyle())
screen.rows[0] = [tail, head, narrow, head, tail, head]

screen.repairWideClusters(row: 0)

#expect(screen.rows[0] == [green, green, narrow, head, tail, green])
}

@Test func insertionPreservesGlyphStyleAndUsesTheOrdinaryBlankPolicy() {
let setup = "\(esc)[31;44m"
let insert = "\(esc)[0m\(esc)[42m\(esc)[1G\(esc)[@"
var wide = TerminalScreen(columns: 8, rows: 2)
wide.feed("\(setup)你Z\(insert)")
var ordinary = TerminalScreen(columns: 8, rows: 2)
ordinary.feed("\(setup)ABZ\(insert)")

// Wide insertion must share ordinary insertion's fill policy, including BCE
// when supported, while the moved glyph retains its original attributes.
#expect(wide.rows[0][0] == ordinary.rows[0][0])
#expect(wide.rows[0][1].content == .grapheme("你"))
#expect(wide.rows[0][2].content == .continuation)
let glyphStyle = ANSIStyle(foreground: .indexed(1), background: .indexed(4))
#expect(wide.rows[0][1].style == glyphStyle)
#expect(wide.rows[0][2].style == glyphStyle)
#expect(wide.style == ANSIStyle(background: .indexed(2)))
#expect(text(ordinary) == " ABZ")
}

@Test(arguments: ["", "A"])
func insertionPastTheWrittenTailPadsTheCursorCell(_ prefix: String) {
var screen = TerminalScreen(columns: 8, rows: 2)
screen.feed("\(prefix)\(esc)[7G\(esc)[@")
#expect(text(screen) == prefix)
#expect(screen.cursorCol == 6)
#expect(screen.rows[0].count == 8)
#expect(screen.rows[0][6].content == .grapheme(" "))
}

private func text(_ screen: TerminalScreen) -> String {
screen.runs().map(\.text).joined()
}
}
7 changes: 7 additions & 0 deletions RenderingTests/TerminalRoutingTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,13 @@ struct TerminalRoutingTests {
#expect(ANSIRenderer.plainText("\(esc)[32mok\(esc)[0m\nnext") == "ok\nnext")
}

@Test(arguments: ["你", "🚀"])
func rendererPreservesWideGlyphsShiftedOnTheAlternateScreen(_ glyph: String) {
let tui = "\(esc)[?1049h\(glyph)Z\(esc)[1G\(esc)[@\(esc)[?1049lshell$ "
#expect(TerminalScreen.usesScreenAddressing(tui))
#expect(ANSIRenderer.plainText(tui, columns: 8) == " \(glyph)Z")
}

@Test func explicitColumnsOverrideInferenceInGridMode() {
// `--terminal-width` flows through ANSIRenderer to the grid emulator. Absolute
// positioning puts the stream in grid mode (a lone ED no longer does — that is
Expand Down
17 changes: 12 additions & 5 deletions VitrineDomain/Terminal/TerminalScreen+Operations.swift
Original file line number Diff line number Diff line change
Expand Up @@ -111,22 +111,25 @@ extension TerminalScreen {
}

/// Repairs a row after an operation shifts or truncates cells so a wide head is always
/// immediately followed by its continuation, and a continuation is never headless.
/// immediately followed by its continuation, and a continuation is never headless. An
/// orphaned half erases with the pen background, like the cells the operation itself
/// left behind, so a right-margin truncation leaves no unstyled hole in a colored line.
mutating func repairWideClusters(row: Int) {
guard rows.indices.contains(row), !rows[row].isEmpty else { return }
let fill = erasedCell
var col = 0
while col < rows[row].count {
if case .continuation = rows[row][col].content {
if col == 0 || cellDisplayWidth(rows[row][col - 1]) != 2 {
rows[row][col] = .blank
rows[row][col] = fill
}
} else if cellDisplayWidth(rows[row][col]) == 2 {
if col + 1 >= rows[row].count {
rows[row][col] = .blank
rows[row][col] = fill
} else if case .continuation = rows[row][col + 1].content {
col += 1
} else {
rows[row][col] = .blank
rows[row][col] = fill
}
}
col += 1
Expand Down Expand Up @@ -243,7 +246,11 @@ extension TerminalScreen {
let n = min(max(1, count), columns - cursorCol)
guard n > 0 else { return }
padRow(cursorRow, to: cursorCol)
blankWideCluster(row: cursorRow, col: cursorCol)
// Inserting inside a wide glyph splits it; inserting at its head shifts both
// cells together, so the glyph must survive unless the margin truncates it.
if case .continuation = rows[cursorRow][cursorCol].content {
blankWideCluster(row: cursorRow, col: cursorCol)
}
rows[cursorRow].insert(contentsOf: Array(repeating: erasedCell, count: n), at: cursorCol)
if rows[cursorRow].count > columns {
rows[cursorRow].removeLast(rows[cursorRow].count - columns)
Expand Down
Loading