Skip to content

Commit 4fca65e

Browse files
authored
Improve word wrapping (#327)
Issue #251 Improve word wrapping
1 parent 6feb427 commit 4fca65e

File tree

3 files changed

+303
-5
lines changed

3 files changed

+303
-5
lines changed
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
/// A description
2+
extension String {
3+
/// Wraps text to fit within specified column width
4+
///
5+
/// This method reformats the string to ensure each line fits within the specified column width,
6+
/// attempting to break at spaces when possible to avoid splitting words.
7+
///
8+
/// - Parameters:
9+
/// - columns: Maximum width (in characters) for each line
10+
/// - wrappingIndent: Number of spaces to add at the beginning of each wrapped line (not the first line)
11+
///
12+
/// - Returns: A new string with appropriate line breaks to maintain the specified column width
13+
func wrapText(to columns: Int, wrappingIndent: Int = 0) -> String {
14+
let effectiveColumns = columns - wrappingIndent
15+
guard effectiveColumns > 0 else { return self }
16+
17+
var result: [Substring] = []
18+
var currentIndex = self.startIndex
19+
20+
while currentIndex < self.endIndex {
21+
let nextChunk = self[currentIndex...].prefix(effectiveColumns)
22+
23+
// Handle line breaks in the current chunk
24+
if let lastLineBreak = nextChunk.lastIndex(of: "\n") {
25+
result.append(
26+
contentsOf: self[currentIndex..<lastLineBreak].split(
27+
separator: "\n", omittingEmptySubsequences: false
28+
))
29+
currentIndex = self.index(after: lastLineBreak)
30+
continue
31+
}
32+
33+
// We've reached the end of the string
34+
if nextChunk.endIndex == self.endIndex {
35+
result.append(self[currentIndex...])
36+
break
37+
}
38+
39+
// Try to break at the last space within the column limit
40+
if let lastSpace = nextChunk.lastIndex(of: " ") {
41+
result.append(self[currentIndex..<lastSpace])
42+
currentIndex = self.index(after: lastSpace)
43+
continue
44+
}
45+
46+
// If no space in the chunk, find the next space after column limit
47+
if let nextSpace = self[currentIndex...].firstIndex(of: " ") {
48+
result.append(self[currentIndex..<nextSpace])
49+
currentIndex = self.index(after: nextSpace)
50+
continue
51+
}
52+
53+
// No spaces left in the string - add the rest and finish
54+
result.append(self[currentIndex...])
55+
break
56+
}
57+
58+
// Apply indentation to wrapped lines and join them
59+
return
60+
result
61+
.map { $0.isEmpty ? $0 : String(repeating: " ", count: wrappingIndent) + $0 }
62+
.joined(separator: "\n")
63+
}
64+
}

Sources/SwiftlyCore/SwiftlyCore.swift

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -53,15 +53,38 @@ public struct SwiftlyCoreContext: Sendable {
5353
/// Pass the provided string to the set output handler if any.
5454
/// If no output handler has been set, just print to stdout.
5555
public func print(_ string: String = "", terminator: String? = nil) async {
56+
// Get terminal size or use default width
57+
let terminalWidth = self.getTerminalWidth()
58+
let wrappedString = string.isEmpty ? string : string.wrapText(to: terminalWidth)
59+
5660
guard let handler = self.outputHandler else {
5761
if let terminator {
58-
Swift.print(string, terminator: terminator)
62+
Swift.print(wrappedString, terminator: terminator)
5963
} else {
60-
Swift.print(string)
64+
Swift.print(wrappedString)
6165
}
6266
return
6367
}
64-
await handler.handleOutputLine(string + (terminator ?? ""))
68+
await handler.handleOutputLine(wrappedString + (terminator ?? ""))
69+
}
70+
71+
/// Detects the terminal width in columns
72+
private func getTerminalWidth() -> Int {
73+
#if os(macOS) || os(Linux)
74+
var size = winsize()
75+
#if os(OpenBSD)
76+
// TIOCGWINSZ is a complex macro, so we need the flattened value.
77+
let tiocgwinsz = UInt(0x4008_7468)
78+
let result = ioctl(STDOUT_FILENO, tiocgwinsz, &size)
79+
#else
80+
let result = ioctl(STDOUT_FILENO, UInt(TIOCGWINSZ), &size)
81+
#endif
82+
83+
if result == 0 && Int(size.ws_col) > 0 {
84+
return Int(size.ws_col)
85+
}
86+
#endif
87+
return 80 // Default width if terminal size detection fails
6588
}
6689

6790
public func readLine(prompt: String) async -> String? {
@@ -81,10 +104,13 @@ public struct SwiftlyCoreContext: Sendable {
81104
}
82105

83106
while true {
84-
let answer = (await self.readLine(prompt: "Proceed? \(options)") ?? (defaultBehavior ? "y" : "n")).lowercased()
107+
let answer =
108+
(await self.readLine(prompt: "Proceed? \(options)")
109+
?? (defaultBehavior ? "y" : "n")).lowercased()
85110

86111
guard ["y", "n", ""].contains(answer) else {
87-
await self.print("Please input either \"y\" or \"n\", or press ENTER to use the default.")
112+
await self.print(
113+
"Please input either \"y\" or \"n\", or press ENTER to use the default.")
88114
continue
89115
}
90116

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
@testable import SwiftlyCore
2+
import Testing
3+
import XCTest
4+
5+
@Suite struct StringExtensionsTests {
6+
@Test("Basic text wrapping at column width")
7+
func testBasicWrapping() {
8+
let input = "This is a simple test string that should be wrapped at the specified width."
9+
let expected = """
10+
This is a
11+
simple test
12+
string that
13+
should be
14+
wrapped at
15+
the
16+
specified
17+
width.
18+
"""
19+
20+
XCTAssertEqual(input.wrapText(to: 10), expected)
21+
}
22+
23+
@Test("Preserve existing line breaks")
24+
func testPreserveLineBreaks() {
25+
let input = "First line\nSecond line\nThird line"
26+
let expected = "First line\nSecond line\nThird line"
27+
28+
XCTAssertEqual(input.wrapText(to: 20), expected)
29+
}
30+
31+
@Test("Combine wrapping with existing line breaks")
32+
func testCombineWrappingAndLineBreaks() {
33+
let input = "Short line\nThis is a very long line that needs to be wrapped\nAnother short line"
34+
let expected = """
35+
Short line
36+
This is a very
37+
long line that
38+
needs to be
39+
wrapped
40+
Another short line
41+
"""
42+
43+
XCTAssertEqual(input.wrapText(to: 15), expected)
44+
}
45+
46+
@Test("Words longer than column width")
47+
func testLongWords() {
48+
let input = "This has a supercalifragilisticexpialidocious word"
49+
let expected = """
50+
This has a
51+
supercalifragilisticexpialidocious
52+
word
53+
"""
54+
55+
XCTAssertEqual(input.wrapText(to: 10), expected)
56+
}
57+
58+
@Test("Text with no spaces")
59+
func testNoSpaces() {
60+
let input = "ThisIsALongStringWithNoSpaces"
61+
let expected = "ThisIsALongStringWithNoSpaces"
62+
63+
XCTAssertEqual(input.wrapText(to: 10), expected)
64+
}
65+
66+
@Test("Empty string")
67+
func testEmptyString() {
68+
let input = ""
69+
let expected = ""
70+
71+
XCTAssertEqual(input.wrapText(to: 10), expected)
72+
}
73+
74+
@Test("Single character")
75+
func testSingleCharacter() {
76+
let input = "X"
77+
let expected = "X"
78+
79+
XCTAssertEqual(input.wrapText(to: 10), expected)
80+
}
81+
82+
@Test("Single line not exceeding width")
83+
func testSingleLineNoWrapping() {
84+
let input = "Short text"
85+
let expected = "Short text"
86+
87+
XCTAssertEqual(input.wrapText(to: 10), expected)
88+
}
89+
90+
@Test("Wrapping with indentation")
91+
func testWrappingWithIndent() {
92+
let input = "This is text that should be wrapped with indentation on new lines."
93+
let expected = """
94+
This is
95+
text that
96+
should be
97+
wrapped
98+
with
99+
indentation
100+
on new
101+
lines.
102+
"""
103+
104+
XCTAssertEqual(input.wrapText(to: 10, wrappingIndent: 2), expected)
105+
}
106+
107+
@Test("Zero or negative column width")
108+
func testZeroOrNegativeWidth() {
109+
let input = "This should not be wrapped"
110+
111+
XCTAssertEqual(input.wrapText(to: 0), input)
112+
XCTAssertEqual(input.wrapText(to: -5), input)
113+
}
114+
115+
@Test("Very narrow column width")
116+
func testVeryNarrowWidth() {
117+
let input = "A B C"
118+
let expected = "A\nB\nC"
119+
120+
XCTAssertEqual(input.wrapText(to: 1), expected)
121+
}
122+
123+
@Test("Special characters")
124+
func testSpecialCharacters() {
125+
let input = "Special !@#$%^&*() chars"
126+
let expected = """
127+
Special
128+
!@#$%^&*()
129+
chars
130+
"""
131+
132+
XCTAssertEqual(input.wrapText(to: 10), expected)
133+
}
134+
135+
@Test("Unicode characters")
136+
func testUnicodeCharacters() {
137+
let input = "Unicode: 你好世界 😀🚀🌍"
138+
let expected = """
139+
Unicode: 你好世界
140+
😀🚀🌍
141+
"""
142+
143+
XCTAssertEqual(input.wrapText(to: 15), expected)
144+
}
145+
146+
@Test("Irregular spacing")
147+
func testIrregularSpacing() {
148+
let input = "Words with irregular spacing"
149+
let expected = """
150+
Words with
151+
irregular
152+
spacing
153+
"""
154+
155+
XCTAssertEqual(input.wrapText(to: 10), expected)
156+
}
157+
158+
@Test("Tab characters")
159+
func testTabCharacters() {
160+
let input = "Text\twith\ttabs"
161+
let expected = """
162+
Text\twith
163+
\ttabs
164+
"""
165+
166+
XCTAssertEqual(input.wrapText(to: 10), expected)
167+
}
168+
169+
@Test("Trailing spaces")
170+
func testTrailingSpaces() {
171+
let input = "Text with trailing spaces "
172+
let expected = """
173+
Text with
174+
trailing
175+
spaces
176+
"""
177+
178+
XCTAssertEqual(input.wrapText(to: 10), expected)
179+
}
180+
181+
@Test("Leading spaces")
182+
func testLeadingSpaces() {
183+
let input = " Leading spaces with text"
184+
let expected = """
185+
Leading
186+
spaces with
187+
text
188+
"""
189+
190+
XCTAssertEqual(input.wrapText(to: 10), expected)
191+
}
192+
193+
@Test("Multiple consecutive newlines")
194+
func testMultipleNewlines() {
195+
let input = "First\n\nSecond\n\n\nThird"
196+
let expected = "First\n\nSecond\n\n\nThird"
197+
198+
XCTAssertEqual(input.wrapText(to: 10), expected)
199+
}
200+
201+
@Test("Edge case - exactly at column width")
202+
func testExactColumnWidth() {
203+
let input = "1234567890 abcdefghij"
204+
let expected = "1234567890\nabcdefghij"
205+
206+
XCTAssertEqual(input.wrapText(to: 10), expected)
207+
}
208+
}

0 commit comments

Comments
 (0)