Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
4 changes: 3 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@ A portable/cross-platform Swift implementation of `wcwidth(3)` that calculates d

**Main Public API** (`DisplayWidth.swift`):
- Callable struct with `callAsFunction` for String/Character/UnicodeScalar
- Configuration: `treatAmbiguousAsFullWidth` (default: false)
- Configuration: `treatAmbiguousAsFullWidth`, `stripsANSI`, `tabWidth`
- `stripsANSI` and `tabWidth` apply only to string measurement
- `tabWidth` must be nil or greater than zero
- Single-scalar fast path, complex grapheme cluster handling for emojis/combining marks

**Width Calculation Flow**:
Expand Down
28 changes: 22 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,17 +43,33 @@ Then:
import DisplayWidth

// call as function
let displayWidth = DisplayWidth()
displayWidth("A") // 1
displayWidth("あ") // 2
displayWidth("👩‍💻") // 2
displayWidth("e\u{0301}") // 1 (e + combining acute)
let baseDisplayWidth = DisplayWidth()
baseDisplayWidth("A") // 1
baseDisplayWidth("あ") // 2
baseDisplayWidth("👩‍💻") // 2
baseDisplayWidth("e\u{0301}") // 1 (e + combining acute)

// If your environment treat ambiguous chars as full-width,
// you can set this option.
let displayWidth = DisplayWidth(treatAmbiguousAsFullWidth: true)
let ambiguousWidth = DisplayWidth(treatAmbiguousAsFullWidth: true)
```

```swift
import DisplayWidth

// ANSI escape sequences can be ignored during string measurement.
let ansiAwareDisplayWidth = DisplayWidth(stripsANSI: true)
ansiAwareDisplayWidth("\u{001B}[31mhello\u{001B}[0m") // 5

// Tabs can advance to real tab stops when measuring strings.
Comment thread
ainame marked this conversation as resolved.
// `tabWidth` must be a positive, non-zero value.
let tabAwareDisplayWidth = DisplayWidth(tabWidth: 4)
tabAwareDisplayWidth("a\tb") // 5
```
Comment thread
ainame marked this conversation as resolved.

`stripsANSI` and `tabWidth` affect string measurement only. Character and scalar
measurement keep their existing behavior.

## Links

* https://man7.org/linux/man-pages/man3/wcwidth.3.html
Expand Down
102 changes: 100 additions & 2 deletions Sources/DisplayWidth/DisplayWidth.swift
Original file line number Diff line number Diff line change
@@ -1,18 +1,60 @@
public struct DisplayWidth: Hashable, Sendable {
private let treatAmbiguousAsFullWidth: Bool

public init(treatAmbiguousAsFullWidth: Bool = false) {
private let stripsANSI: Bool
private let tabWidth: Int?

public init(
treatAmbiguousAsFullWidth: Bool = false,
stripsANSI: Bool = false,
tabWidth: Int? = nil
) {
Comment thread
ainame marked this conversation as resolved.
precondition(tabWidth == nil || tabWidth! > 0, "tabWidth must be nil or greater than zero")
self.treatAmbiguousAsFullWidth = treatAmbiguousAsFullWidth
self.stripsANSI = stripsANSI
self.tabWidth = tabWidth
}

public func callAsFunction(_ string: String) -> Int {
if !stripsANSI, tabWidth == nil {
return measurePlainString(string)
}

return measureProcessedString(string)
}

private func measurePlainString(_ string: String) -> Int {
var totalWidth = 0
for character in string {
totalWidth += callAsFunction(character)
}
return totalWidth
}

private func measureProcessedString(_ string: String) -> Int {
var totalWidth = 0
var index = string.startIndex

while index < string.endIndex {
if stripsANSI, let nextIndex = skipANSIEscapeSequence(in: string, from: index) {
index = nextIndex
continue
}

let character = string[index]
if let tabWidth, character == "\t" {
let remainder = totalWidth % tabWidth
totalWidth += remainder == 0 ? tabWidth : tabWidth - remainder
index = string.index(after: index)
continue
}

totalWidth += callAsFunction(character)
index = string.index(after: index)
}

return totalWidth
}

public func callAsFunction(_ character: Character) -> Int {
// Fast path for single-scalar characters
if character.unicodeScalars.count == 1,
Expand Down Expand Up @@ -145,4 +187,60 @@ public struct DisplayWidth: Hashable, Sendable {
codePoint == 0xFE0F // Emoji variation selector
}
}

private func skipANSIEscapeSequence(in string: String, from start: String.Index) -> String.Index? {
guard string[start] == "\u{001B}" else {
return nil
}

let introducerIndex = string.index(after: start)
guard introducerIndex < string.endIndex else {
return nil
}

switch string[introducerIndex] {
case "[":
return skipControlSequenceIntroducer(in: string, from: string.index(after: introducerIndex))
case "]", "_":
return skipStringTerminatedEscape(in: string, from: string.index(after: introducerIndex))
default:
return nil
}
}

private func skipControlSequenceIntroducer(in string: String, from start: String.Index) -> String.Index? {
var index = start

while index < string.endIndex {
let scalar = string[index].unicodeScalars.first!.value
if scalar >= 0x40 && scalar <= 0x7E {
return string.index(after: index)
}
index = string.index(after: index)
}

return nil
}

private func skipStringTerminatedEscape(in string: String, from start: String.Index) -> String.Index? {
var index = start

while index < string.endIndex {
let character = string[index]
if character == "\u{0007}" {
return string.index(after: index)
}

if character == "\u{001B}" {
let next = string.index(after: index)
if next < string.endIndex, string[next] == "\\" {
return string.index(after: next)
}
}

index = string.index(after: index)
}

return nil
}
}
58 changes: 58 additions & 0 deletions Tests/DisplayWidthTests/DisplayWidthTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -247,3 +247,61 @@ func tsunodatahiro() async throws {
// Strings with mixed widths
#expect(displayWidth("Hello 世界 🌍") == 13) // "Hello" (5) + " " (1) + "世界" (4) + " " (1) + "🌍" (2) = 13
}

@Test func testDefaultInitializerRemainsBackwardCompatible() throws {
let displayWidth = DisplayWidth()
#expect(displayWidth("A\tB") == 2)
#expect(displayWidth("\u{001B}[31mhello\u{001B}[0m") == 12)
}

@Test func testANSIStringProcessing() throws {
let raw = "\u{001B}[31mhello\u{001B}[0m"
#expect(DisplayWidth(stripsANSI: false)(raw) == 12)
#expect(DisplayWidth(stripsANSI: true)(raw) == 5)
}

@Test func testANSIStringProcessingSupportsOSCAndAPC() throws {
let oscBEL = "\u{001B}]133;A\u{0007}hello\u{001B}]133;B\u{0007}"
let oscST = "\u{001B}]133;A\u{001B}\\hello\u{001B}]133;B\u{001B}\\"
let apcBEL = "\u{001B}_Ga=T,f=100;AAAA\u{0007}hello"
let apcST = "\u{001B}_Ga=T,f=100;AAAA\u{001B}\\hello"
let displayWidth = DisplayWidth(stripsANSI: true)

#expect(displayWidth(oscBEL) == 5)
#expect(displayWidth(oscST) == 5)
#expect(displayWidth(apcBEL) == 5)
#expect(displayWidth(apcST) == 5)
}

@Test func testANSIStringProcessingHandlesEmbeddedKittyAndOSCImagePayloads() throws {
let kitty = "\u{001B}_Ga=T,f=100;AAAA\u{001B}\\"
let iterm = "\u{001B}]1337;File=inline=1:AAAA\u{0007}"
let input = "ab" + kitty + "cd" + iterm + "ef"

#expect(DisplayWidth(stripsANSI: true)(input) == 6)
}

@Test func testMalformedANSISequenceDoesNotHangAndFallsBackToText() throws {
let malformed = "\u{001B}]133;Ahello"
#expect(DisplayWidth(stripsANSI: true)(malformed) == 11)
}

@Test func testTabWidthUsesTabStops() throws {
let displayWidth = DisplayWidth(tabWidth: 4)
#expect(displayWidth("a\tb") == 5)
#expect(displayWidth("abcd\tb") == 9)
#expect(displayWidth("ab\t中") == 6)
}

@Test func testStringProcessingMixedWithWideCharacters() throws {
let displayWidth = DisplayWidth(stripsANSI: true, tabWidth: 4)
let input = "\u{001B}[31m界\t👩‍💻\u{001B}[0m"
#expect(displayWidth(input) == 6)
}

@Test func testStringProcessingDoesNotAffectScalarOrCharacterMeasurement() throws {
let displayWidth = DisplayWidth(stripsANSI: true, tabWidth: 4)
#expect(displayWidth("\t" as Character) == 0)
Comment thread
ainame marked this conversation as resolved.
#expect(displayWidth(Unicode.Scalar(0x001B)!) == 0)
#expect(displayWidth("\u{001B}") == 0)
}