From 40b7bcb79efa30d10baec5a16f31c4ba31503124 Mon Sep 17 00:00:00 2001 From: Johnny IV Young Ospino Date: Wed, 7 Oct 2026 23:01:01 -0500 Subject: [PATCH 1/3] fix(terminal): preserve wide glyphs inserted at their leading cell --- CHANGELOG.md | 2 + .../Terminal/TerminalInsertionTests.swift | 89 +++++++++++++++++++ RenderingTests/TerminalRoutingTests.swift | 7 ++ .../Terminal/TerminalScreen+Operations.swift | 6 +- 4 files changed, 103 insertions(+), 1 deletion(-) create mode 100644 DomainTests/Terminal/TerminalInsertionTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index e7ab185a..d709563b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,6 +53,8 @@ can never drift. ### Fixed +- Preserve complete CJK and emoji characters when terminal insertion shifts them + from their leading cell; split or right-edge-truncated glyphs still clear safely. - Align first-capture guidance with the optional global shortcut, explain menu-shortcut conflicts, and distinguish local rendering from Direct network features in English and Spanish. diff --git a/DomainTests/Terminal/TerminalInsertionTests.swift b/DomainTests/Terminal/TerminalInsertionTests.swift new file mode 100644 index 00000000..df41ee23 --- /dev/null +++ b/DomainTests/Terminal/TerminalInsertionTests.swift @@ -0,0 +1,89 @@ +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 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 697b941b..907af0e4 100644 --- a/VitrineDomain/Terminal/TerminalScreen+Operations.swift +++ b/VitrineDomain/Terminal/TerminalScreen+Operations.swift @@ -229,7 +229,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: .blank, count: n), at: cursorCol) if rows[cursorRow].count > columns { rows[cursorRow].removeLast(rows[cursorRow].count - columns) From ae83640add29559f29d2f87e98a818caa33e78e4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 21:13:50 +0000 Subject: [PATCH 2/3] fix(terminal): erase orphaned wide halves with the pen background - Repair truncated or split wide clusters with the current erase fill instead of an unstyled blank, so a right-margin truncation under background-color erase leaves no hole in a colored line. - Cover the colored truncation case and insertion counts that reach or exceed the margin at a wide head. --- .../Terminal/TerminalInsertionTests.swift | 22 +++++++++++++++++++ .../Terminal/TerminalScreen+Operations.swift | 11 ++++++---- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/DomainTests/Terminal/TerminalInsertionTests.swift b/DomainTests/Terminal/TerminalInsertionTests.swift index df41ee23..04a28e12 100644 --- a/DomainTests/Terminal/TerminalInsertionTests.swift +++ b/DomainTests/Terminal/TerminalInsertionTests.swift @@ -53,6 +53,28 @@ struct TerminalInsertionTests { #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 insertionPreservesGlyphStyleAndUsesTheOrdinaryBlankPolicy() { let setup = "\(esc)[31;44m" let insert = "\(esc)[0m\(esc)[42m\(esc)[1G\(esc)[@" diff --git a/VitrineDomain/Terminal/TerminalScreen+Operations.swift b/VitrineDomain/Terminal/TerminalScreen+Operations.swift index 61f8fa01..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 From b953d7c3aadac02359b5db1b4a2cffde20e93137 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 21:50:21 +0000 Subject: [PATCH 3/3] test(terminal): exercise every orphaned wide half the repair erases - Build a headless continuation, a head whose continuation was overwritten, and a head at the margin by hand, so each repair branch runs under a colored pen and the changed lines carry coverage. --- .../Terminal/TerminalInsertionTests.swift | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/DomainTests/Terminal/TerminalInsertionTests.swift b/DomainTests/Terminal/TerminalInsertionTests.swift index 04a28e12..5d111d26 100644 --- a/DomainTests/Terminal/TerminalInsertionTests.swift +++ b/DomainTests/Terminal/TerminalInsertionTests.swift @@ -75,6 +75,23 @@ struct TerminalInsertionTests { #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)[@"