diff --git a/CHANGELOG.md b/CHANGELOG.md index 19d9ffb7..87e74c14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/DomainTests/Terminal/TerminalInsertionTests.swift b/DomainTests/Terminal/TerminalInsertionTests.swift new file mode 100644 index 00000000..5d111d26 --- /dev/null +++ b/DomainTests/Terminal/TerminalInsertionTests.swift @@ -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() + } +} diff --git a/RenderingTests/TerminalRoutingTests.swift b/RenderingTests/TerminalRoutingTests.swift index 021307b0..3979d34c 100644 --- a/RenderingTests/TerminalRoutingTests.swift +++ b/RenderingTests/TerminalRoutingTests.swift @@ -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 diff --git a/VitrineDomain/Terminal/TerminalScreen+Operations.swift b/VitrineDomain/Terminal/TerminalScreen+Operations.swift index ae414144..300c8876 100644 --- a/VitrineDomain/Terminal/TerminalScreen+Operations.swift +++ b/VitrineDomain/Terminal/TerminalScreen+Operations.swift @@ -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 @@ -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)