Skip to content

Commit 79af76d

Browse files
Test suites (#429)
Port from XCTest to Swift Test, new tests, lots of fixes. First, this is a port from XCTest to Swift Test, but once I started using this in parallel mode various crashes would show up, which turned out to be that CharData was not thread-safe as it had shared static state. So I made this code thread safe, so now you can run various Headless terminals (for tests or whatever you want in multiple threads). This did change the API for CharData and how it is consumed, rather than having CharData own these static tables, the tables are owned by the Terminal, which means that you no longer create CharData() yourself, you have to create them and update them via the Terminal API. Second, we now have various tests imported from Ghostty, just to give me more peace of mind, they uncovered a couple of parsing limitations on extended color parsing, and it now uses colon separator counts to distinguish colorspace and non-colorspace ones Then ported some Xterm.js tests, in particular the reflow ones, after someone had mentioned they struggled with Reflow and some Claude agent, but never got any details - but this test showed that we had an issue, with wide‑char reflow behavior, adjusted to match the xterm.js tests, fixed the trimmed length math, wide‑char placeholders, and cleanup during unwrap. The above regressed the nice work on wcwidth that took place in main, so also fixed that regression, since we are now using the new CharData API, we have to use getCharacter(for:) for the VS base check so combined graphemes so they do not get treated as invalid scalars in Terminal. No regressions on ucs-detect, which makes me very happy.
1 parent 5e9b2e3 commit 79af76d

36 files changed

Lines changed: 2188 additions & 493 deletions

Sources/SwiftTerm/Apple/AppleTerminalView.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -549,7 +549,7 @@ extension TerminalView {
549549
currentAttributes[.selectionBackgroundColor] = selectedTextBackgroundColor
550550
}
551551

552-
let character = ch.code == 0 ? " " : ch.getCharacter()
552+
let character = ch.code == 0 ? " " : terminal.getCharacter(for: ch)
553553
if let placeholder = KittyPlaceholderDecoder.decode(character: character,
554554
attribute: attr,
555555
row: row,

Sources/SwiftTerm/Buffer.swift

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -258,7 +258,7 @@ public final class Buffer {
258258
public func getNullCell (attribute: Attribute? = nil) -> CharData
259259
{
260260
let fgbg = attribute == nil ? Attribute.empty : attribute!.justColor ()
261-
return CharData(attribute: fgbg, char: " ", size: 1)
261+
return CharData(attribute: fgbg, scalar: UnicodeScalar(32)!, size: 1)
262262
}
263263

264264
public func getBlankLine (attribute: Attribute, isWrapped: Bool = false) -> BufferLine
@@ -496,10 +496,10 @@ public final class Buffer {
496496
}
497497
}
498498

499-
func translateBufferLineToString (lineIndex: Int, trimRight: Bool, startCol: Int = 0, endCol: Int = -1, skipNullCellsFollowingWide: Bool = false) -> String
499+
func translateBufferLineToString (lineIndex: Int, trimRight: Bool, startCol: Int = 0, endCol: Int = -1, skipNullCellsFollowingWide: Bool = false, characterProvider: ((CharData) -> Character)? = nil) -> String
500500
{
501501
let line = _lines [lineIndex]
502-
return line.translateToString(trimRight: trimRight, startCol: startCol, endCol: endCol, skipNullCellsFollowingWide: skipNullCellsFollowingWide)
502+
return line.translateToString(trimRight: trimRight, startCol: startCol, endCol: endCol, skipNullCellsFollowingWide: skipNullCellsFollowingWide, characterProvider: characterProvider)
503503
}
504504

505505
func setupTabStops (index: Int = -1, tabStopWidth: Int)
@@ -673,7 +673,7 @@ public final class Buffer {
673673
wrappedLines [destLineIndex].copyFrom (wrappedLines [destLineIndex - 1], srcCol: newCols - 1, dstCol: destCol, len: 1)
674674
destCol += 1
675675
// Null out the end of the last row
676-
wrappedLines [destLineIndex - 1].replaceCells (start: newCols - 1, end: 1, fillData: nullChar)
676+
wrappedLines [destLineIndex - 1].replaceCells (start: newCols - 1, end: newCols, fillData: nullChar)
677677
}
678678
}
679679
}
@@ -1088,13 +1088,13 @@ public final class Buffer {
10881088
if wraparound {
10891089
_x = marginMode ? marginLeft : 0
10901090

1091-
if _y >= scrollBottom {
1091+
if _y >= _scrollBottom {
10921092
scroll (true)
10931093
} else {
10941094
// The line already exists (eg. the initial viewport), mark it as a
10951095
// wrapped line
10961096
_y += 1
1097-
lines [y].isWrapped = true
1097+
_lines [y].isWrapped = true
10981098
}
10991099
// row changed, get it again
11001100
} else {
@@ -1110,6 +1110,7 @@ public final class Buffer {
11101110
let bufferRow = _lines[_y+_yBase]
11111111
var empty = CharData.Null
11121112
empty.attribute = curAttr
1113+
let wideEmpty = CharData(attribute: curAttr, scalar: UnicodeScalar(0)!, size: 0)
11131114
// insert mode: move characters to right
11141115
if insertMode {
11151116
// right shift cells according to the width
@@ -1137,7 +1138,7 @@ public final class Buffer {
11371138
if chWidth > 0 {
11381139
chWidth -= 1
11391140
while chWidth != 0 && _x < _cols {
1140-
bufferRow [_x] = empty
1141+
bufferRow [_x] = wideEmpty
11411142
_x += 1
11421143
chWidth -= 1
11431144
}

Sources/SwiftTerm/BufferLine.swift

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -227,7 +227,7 @@ public final class BufferLine: CustomDebugStringConvertible {
227227
{
228228
for i in (0..<dataSize).reversed() {
229229
if data [i].code != 0 {
230-
return i + 1
230+
return i + Int(data[i].width)
231231
}
232232
}
233233
return 0
@@ -250,7 +250,7 @@ public final class BufferLine: CustomDebugStringConvertible {
250250
/// - Parameter startCol: the starting column to copy the data from, defaults toe zero if not provided
251251
/// - Parameter endCol: the end column (not included) to consume. If the value -1, this copies all the way to the end
252252
/// - Returns: a string containing the contents of the BufferLine from [startCol..<endCol]
253-
public func translateToString (trimRight: Bool = false, startCol: Int = 0, endCol: Int = -1, skipNullCellsFollowingWide: Bool = false) -> String
253+
public func translateToString (trimRight: Bool = false, startCol: Int = 0, endCol: Int = -1, skipNullCellsFollowingWide: Bool = false, characterProvider: ((CharData) -> Character)? = nil) -> String
254254
{
255255
var ec = endCol == -1 ? dataSize : endCol
256256
if trimRight {
@@ -260,7 +260,8 @@ public final class BufferLine: CustomDebugStringConvertible {
260260
if !skipNullCellsFollowingWide {
261261
var result = ""
262262
for i in startCol..<limit {
263-
result.append (data [i].getCharacter ())
263+
let character = characterProvider?(data [i]) ?? data [i].getCharacter ()
264+
result.append (character)
264265
}
265266
return result
266267
}
@@ -272,7 +273,8 @@ public final class BufferLine: CustomDebugStringConvertible {
272273
continue
273274
}
274275
let cell = data [idx]
275-
result.append (cell.getCharacter ())
276+
let character = characterProvider?(cell) ?? cell.getCharacter ()
277+
result.append (character)
276278
if cell.width == 2 {
277279
let nextIndex = idx + 1
278280
if nextIndex < limit && data [nextIndex].code == 0 {

Sources/SwiftTerm/CharData.swift

Lines changed: 15 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -219,7 +219,7 @@ public struct TinyAtom {
219219
* This uses an Int32 to store the value, if the value can not be encoded as a single Unicode.Scalar,
220220
* then an index is stored that is looked up in parallel, so that full grapheme clusters can be tracked.
221221
*
222-
* Use the `getCharacter` function to get the stored Character, and use the `attribute` property
222+
* Use the `getCharacter` function to get simple runes, and use `Terminal.getCharacter(for:)` for extended graphemes. Use the `attribute` property
223223
* to retrieve the color and other character attributes. The `width` property contains the number of
224224
* columns used by the `Character` stored in this `CharData` on the screen.
225225
*
@@ -238,15 +238,7 @@ public struct CharData: CustomDebugStringConvertible {
238238
}
239239

240240
static let maxRune = 1 << 22
241-
242-
// Contains the character to index mapping
243-
static var charToIndexMap: [Character:Int32] = [:]
244-
245-
// Contains the index to character mapping, could be a plain array
246-
static var indexToCharMap: [Int32: Character] = [:]
247-
static var lastCharIndex: Int32 = (1 << 22)+1
248-
249-
241+
250242
static let defaultAttr = Attribute(fg: .defaultColor, bg: .defaultColor, style: .none)
251243
static let invertedAttr = Attribute(fg: .defaultInvertedColor, bg: .defaultInvertedColor, style: .none)
252244

@@ -264,48 +256,28 @@ public struct CharData: CustomDebugStringConvertible {
264256
/// The color and character attributes for the cell
265257
public var attribute: Attribute
266258

267-
/// Initializes a new instance of the CharData structure with the provided attribute, character and the dimension
259+
/// Initializes a new instance of the CharData structure with the provided attribute and code.
260+
/// Use `Terminal.makeCharData` for Character-based construction.
268261
/// - Parameter attribute: an attribute containing the color and style attributes for the cell
269-
/// - Parameter char: the character that will be stored in this cell
262+
/// - Parameter code: the character code that will be stored in this cell
270263
/// - Parameter size: the number of columns used by the `Character` stored in this `CharData` on the screen.
271-
init (attribute: Attribute, char: Character, size: Int8 = 1)
264+
init (attribute: Attribute, code: Int32, size: Int8 = 1)
272265
{
273266
self.attribute = attribute
274-
if let acode = char.asciiValue {
275-
code = Int32(acode)
276-
} else if char.utf16.count == 1 {
277-
code = Int32 (char.utf16.first!)
278-
} else {
279-
if let existingIdx = CharData.charToIndexMap [char] {
280-
code = existingIdx
281-
} else {
282-
CharData.charToIndexMap [char] = CharData.lastCharIndex
283-
CharData.indexToCharMap [CharData.lastCharIndex] = char
284-
code = CharData.lastCharIndex
285-
CharData.lastCharIndex = CharData.lastCharIndex + 1
286-
}
287-
}
288-
width = Int8 (size)
267+
self.code = code
268+
width = size
289269
payload = TinyAtom.empty
290270
unused = 0
291271
}
292272

293273
init (attribute: Attribute, scalar: UnicodeScalar, size: Int8 = 1) {
294-
self.attribute = attribute
295-
code = Int32(scalar.value)
296-
width = Int8 (size)
297-
payload = TinyAtom.empty
298-
unused = 0
274+
self.init(attribute: attribute, code: Int32(scalar.value), size: size)
299275
}
300276

301277
// Empty cell sets the code to zero
302278
init (attribute: Attribute)
303279
{
304-
self.attribute = attribute
305-
code = 0
306-
width = 1
307-
payload = TinyAtom.empty
308-
unused = 0
280+
self.init(attribute: attribute, code: 0, size: 1)
309281
}
310282

311283
public var isSimpleRune: Bool {
@@ -334,34 +306,19 @@ public struct CharData: CustomDebugStringConvertible {
334306
/// The `Null` character can be used when filling up parts of the screeb
335307
public static var Null : CharData = CharData (attribute: defaultAttr)
336308

337-
/// Updates the contents of this CharData with a new character.
338-
/// - Parameter char: the new character that will be stored
309+
/// Updates the contents of this CharData with a new code.
310+
/// - Parameter code: the new character code that will be stored
339311
/// - Paramerter size: the number of fixed sized columns this character will take on the screen
340-
mutating public func setValue (char: Character, size: Int32)
312+
public mutating func setValue (code: Int32, size: Int32)
341313
{
342-
if char.utf16.count == 1 {
343-
self.code = Int32 (char.utf16.first!)
344-
} else {
345-
if let existingIdx = CharData.charToIndexMap [char] {
346-
code = existingIdx
347-
} else {
348-
CharData.charToIndexMap [char] = CharData.lastCharIndex
349-
CharData.indexToCharMap [CharData.lastCharIndex] = char
350-
code = CharData.lastCharIndex
351-
CharData.lastCharIndex = CharData.lastCharIndex + 1
352-
}
353-
}
314+
self.code = code
354315
width = Int8 (size)
355316
}
356317

357318
/// Use this method to retrieve the Character stored in the CharData
319+
/// For extended grapheme clusters use `Terminal.getCharacter(for:)`.
358320
public func getCharacter () -> Character
359321
{
360-
if code > CharData.maxRune {
361-
// This is an invariant - no code can be stored without the equivalent being tracked, but for the sake
362-
// of not having a "!" return a space.
363-
return CharData.indexToCharMap [code] ?? " "
364-
}
365322
if let c = Unicode.Scalar (UInt32 (code)) {
366323
return Character(c)
367324
} else {

Sources/SwiftTerm/Mac/MacCaretView.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,9 @@ class CaretView: NSView, CALayerDelegate {
3737
}
3838

3939
func setText (ch: CharData) {
40+
let character = terminal?.terminal.getCharacter(for: ch) ?? " "
4041
let res = NSAttributedString (
41-
string: String (ch.getCharacter()),
42+
string: String (character),
4243
attributes: terminal?.getAttributedValue(ch.attribute, usingFg: caretColor, andBg: caretTextColor ?? terminal?.nativeForegroundColor ?? NSColor.black))
4344
ctline = CTLineCreateWithAttributedString(res)
4445

Sources/SwiftTerm/Mac/MacDebugView.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ public class TerminalDebugView: NSView {
8181
attr = ch.attribute
8282
}
8383
}
84-
str.append(ch.code == 0 ? " " : ch.getCharacter())
84+
str.append(ch.code == 0 ? " " : terminal.getCharacter(for: ch))
8585
}
8686
} else {
8787
str = "<empty>"

Sources/SwiftTerm/SelectionService.swift

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -314,6 +314,12 @@ class SelectionService: CustomDebugStringConvertible {
314314
setActiveAndNotify()
315315
}
316316

317+
private func character (at position: Position, in buffer: Buffer) -> Character
318+
{
319+
let cell = buffer.getChar (atBufferRelative: position)
320+
return terminal.getCharacter (for: cell)
321+
}
322+
317323
/**
318324
* Performs a simple "word" selection based on a function that determines inclussion into the group
319325
*/
@@ -323,7 +329,7 @@ class SelectionService: CustomDebugStringConvertible {
323329
var colScan = position.col
324330
var left = colScan
325331
while colScan >= 0 {
326-
let ch = buffer.getChar(atBufferRelative: Position (col: colScan, row: position.row)).getCharacter()
332+
let ch = character (at: Position (col: colScan, row: position.row), in: buffer)
327333
if !includeFunc (ch) {
328334
break
329335
}
@@ -336,7 +342,7 @@ class SelectionService: CustomDebugStringConvertible {
336342
var right = colScan
337343
let limit = terminal.cols
338344
while colScan < limit {
339-
let ch = buffer.getChar(atBufferRelative: Position (col: colScan, row: position.row)).getCharacter()
345+
let ch = character (at: Position (col: colScan, row: position.row), in: buffer)
340346
if !includeFunc (ch) {
341347
break
342348
}
@@ -365,7 +371,7 @@ class SelectionService: CustomDebugStringConvertible {
365371
for line in position.row..<maxRow {
366372
for col in startCol..<terminal.cols {
367373
let p = Position(col: col, row: line)
368-
let ch = buffer.getChar (atBufferRelative: p).getCharacter ()
374+
let ch = character (at: p, in: buffer)
369375

370376
if ch == "(" {
371377
wait.append (")")
@@ -403,7 +409,7 @@ class SelectionService: CustomDebugStringConvertible {
403409
for line in (0...position.row).reversed() {
404410
for col in (0...startCol).reversed() {
405411
let p = Position(col: col, row: line)
406-
let ch = buffer.getChar (atBufferRelative: p).getCharacter ()
412+
let ch = character (at: p, in: buffer)
407413

408414
if ch == ")" {
409415
wait.append ("(")
@@ -434,7 +440,7 @@ class SelectionService: CustomDebugStringConvertible {
434440
* Extends a position to the nearest word boundary based on the character at that position
435441
*/
436442
func extendToWordBoundary(position: Position, in buffer: Buffer, direction: Int) -> Position {
437-
let ch = buffer.getChar(atBufferRelative: position).getCharacter()
443+
let ch = character (at: position, in: buffer)
438444
var includeFunc: (Character) -> Bool
439445

440446
switch ch {
@@ -453,7 +459,7 @@ class SelectionService: CustomDebugStringConvertible {
453459
// Extend backward
454460
var col = position.col
455461
while col >= 0 {
456-
let testCh = buffer.getChar(atBufferRelative: Position(col: col, row: position.row)).getCharacter()
462+
let testCh = character (at: Position(col: col, row: position.row), in: buffer)
457463
if !includeFunc(testCh) {
458464
break
459465
}
@@ -464,7 +470,7 @@ class SelectionService: CustomDebugStringConvertible {
464470
// Extend forward
465471
var col = position.col
466472
while col < terminal.cols {
467-
let testCh = buffer.getChar(atBufferRelative: Position(col: col, row: position.row)).getCharacter()
473+
let testCh = character (at: Position(col: col, row: position.row), in: buffer)
468474
if !includeFunc(testCh) {
469475
break
470476
}
@@ -486,7 +492,7 @@ class SelectionService: CustomDebugStringConvertible {
486492
// row: max (min (uncheckedPosition.row, buffer.rows-1+buffer.yDisp), buffer.yDisp))
487493
let position = Position (col: (min (terminal.cols, max (uncheckedPosition.col, 0))),
488494
row: (max (uncheckedPosition.row, 0)))
489-
switch buffer.getChar(atBufferRelative: position).getCharacter() {
495+
switch character (at: position, in: buffer) {
490496
case Character(UnicodeScalar(0)):
491497
simpleScanSelection (from: position, in: buffer) { ch in ch == nullChar }
492498
case " ":

0 commit comments

Comments
 (0)