diff --git a/AGENTS.md b/AGENTS.md index 118bf29..74e97ce 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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**: diff --git a/README.md b/README.md index b3ff96f..676378d 100644 --- a/README.md +++ b/README.md @@ -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. +// `tabWidth` must be a positive, non-zero value. +let tabAwareDisplayWidth = DisplayWidth(tabWidth: 4) +tabAwareDisplayWidth("a\tb") // 5 +``` + +`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 diff --git a/Sources/DisplayWidth/DisplayWidth.swift b/Sources/DisplayWidth/DisplayWidth.swift index fe88f31..78c6c2a 100644 --- a/Sources/DisplayWidth/DisplayWidth.swift +++ b/Sources/DisplayWidth/DisplayWidth.swift @@ -1,11 +1,28 @@ 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 + ) { + 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) @@ -13,6 +30,31 @@ public struct DisplayWidth: Hashable, Sendable { 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, @@ -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 + } } diff --git a/Tests/DisplayWidthTests/DisplayWidthTests.swift b/Tests/DisplayWidthTests/DisplayWidthTests.swift index 402500b..01b76e6 100644 --- a/Tests/DisplayWidthTests/DisplayWidthTests.swift +++ b/Tests/DisplayWidthTests/DisplayWidthTests.swift @@ -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) + #expect(displayWidth(Unicode.Scalar(0x001B)!) == 0) + #expect(displayWidth("\u{001B}") == 0) +}