Skip to content

Commit b83cea3

Browse files
committed
Optimize string truncation
Signed-off-by: David Gageot <david.gageot@docker.com>
1 parent 6ecdc94 commit b83cea3

File tree

5 files changed

+589
-92
lines changed

5 files changed

+589
-92
lines changed

pkg/tui/components/toolcommon/common.go

Lines changed: 0 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -177,84 +177,6 @@ func RenderTool(msg *types.Message, inProgress spinner.Spinner, args, result str
177177
return styles.RenderComposite(styles.ToolMessageStyle.Width(width), content)
178178
}
179179

180-
// WrapLines wraps text to fit within the given width.
181-
// Each line that exceeds the width is split at rune boundaries.
182-
func WrapLines(text string, width int) []string {
183-
if width <= 0 {
184-
return strings.Split(text, "\n")
185-
}
186-
187-
var lines []string
188-
for line := range strings.SplitSeq(text, "\n") {
189-
for lipgloss.Width(line) > width {
190-
breakPoint := findBreakPoint(line, width)
191-
runes := []rune(line)
192-
lines = append(lines, string(runes[:breakPoint]))
193-
line = string(runes[breakPoint:])
194-
}
195-
lines = append(lines, line)
196-
}
197-
return lines
198-
}
199-
200-
// wrapTextWithIndent wraps text where the first line has a different available width.
201-
// Subsequent lines are indented to align with the tool name badge.
202-
// If text starts with a newline, it's considered pre-formatted and no indent is added.
203-
func wrapTextWithIndent(text string, firstLineWidth, subsequentLineWidth int) string {
204-
if firstLineWidth <= 0 || subsequentLineWidth <= 0 {
205-
return text
206-
}
207-
208-
// Pre-formatted text (starts with newline) doesn't need additional indentation
209-
if strings.HasPrefix(text, "\n") {
210-
return text
211-
}
212-
213-
indent := strings.Repeat(" ", styles.ToolCompletedIcon.GetMarginLeft())
214-
var resultLines []string
215-
isFirstLine := true
216-
217-
for inputLine := range strings.SplitSeq(text, "\n") {
218-
line := inputLine
219-
for line != "" {
220-
width := subsequentLineWidth
221-
prefix := indent
222-
if isFirstLine {
223-
width = firstLineWidth
224-
prefix = ""
225-
}
226-
227-
if lipgloss.Width(line) <= width {
228-
resultLines = append(resultLines, prefix+line)
229-
break
230-
}
231-
232-
// Find break point that fits within width
233-
breakPoint := findBreakPoint(line, width)
234-
runes := []rune(line)
235-
resultLines = append(resultLines, prefix+string(runes[:breakPoint]))
236-
line = string(runes[breakPoint:])
237-
isFirstLine = false
238-
}
239-
if inputLine == "" {
240-
resultLines = append(resultLines, indent)
241-
}
242-
isFirstLine = false
243-
}
244-
245-
return strings.Join(resultLines, "\n")
246-
}
247-
248-
// findBreakPoint finds the maximum number of runes that fit within the given width.
249-
func findBreakPoint(line string, width int) int {
250-
runes := []rune(line)
251-
breakPoint := len(runes)
252-
for breakPoint > 0 && lipgloss.Width(string(runes[:breakPoint])) > width {
253-
breakPoint--
254-
}
255-
return max(breakPoint, 1) // At least one rune per line
256-
}
257-
258180
// ShortenPath replaces home directory with ~ for cleaner display.
259181
func ShortenPath(path string) string {
260182
if path == "" {
@@ -266,17 +188,3 @@ func ShortenPath(path string) string {
266188
}
267189
return path
268190
}
269-
270-
// TruncateText truncates text to fit within maxWidth, adding an ellipsis if needed.
271-
// Uses lipgloss.Width for proper Unicode handling.
272-
func TruncateText(text string, maxWidth int) string {
273-
if lipgloss.Width(text) <= maxWidth {
274-
return text
275-
}
276-
// Truncate by runes to handle Unicode properly
277-
runes := []rune(text)
278-
for lipgloss.Width(string(runes)) > maxWidth-1 && len(runes) > 0 {
279-
runes = runes[:len(runes)-1]
280-
}
281-
return string(runes) + "…"
282-
}

pkg/tui/components/toolcommon/common_test.go

Lines changed: 277 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,43 @@ func TestParsePartialArgs(t *testing.T) {
182182
}
183183
}
184184

185+
func BenchmarkWrapLines(b *testing.B) {
186+
shortLine := "hello world"
187+
mediumLine := "This is a medium length string that will need wrapping for testing purposes."
188+
longLine := "This is a very long line that contains many characters and will need to be wrapped multiple times when displayed in a terminal with limited width."
189+
multiLine := "Line one here\nLine two is a bit longer and might wrap\nLine three\nLine four is the longest line in this test case"
190+
191+
b.Run("short_no_wrap", func(b *testing.B) {
192+
for b.Loop() {
193+
WrapLines(shortLine, 80)
194+
}
195+
})
196+
197+
b.Run("short_wrap", func(b *testing.B) {
198+
for b.Loop() {
199+
WrapLines(shortLine, 5)
200+
}
201+
})
202+
203+
b.Run("medium", func(b *testing.B) {
204+
for b.Loop() {
205+
WrapLines(mediumLine, 30)
206+
}
207+
})
208+
209+
b.Run("long", func(b *testing.B) {
210+
for b.Loop() {
211+
WrapLines(longLine, 40)
212+
}
213+
})
214+
215+
b.Run("multiline", func(b *testing.B) {
216+
for b.Loop() {
217+
WrapLines(multiLine, 25)
218+
}
219+
})
220+
}
221+
185222
func TestWrapLines(t *testing.T) {
186223
tests := []struct {
187224
name string
@@ -340,3 +377,243 @@ func TestWrapLines(t *testing.T) {
340377
})
341378
}
342379
}
380+
381+
func TestTruncateText(t *testing.T) {
382+
tests := []struct {
383+
name string
384+
text string
385+
maxWidth int
386+
expected string
387+
}{
388+
// Basic cases
389+
{
390+
name: "text within width",
391+
text: "hello",
392+
maxWidth: 10,
393+
expected: "hello",
394+
},
395+
{
396+
name: "text exactly at width",
397+
text: "hello",
398+
maxWidth: 5,
399+
expected: "hello",
400+
},
401+
{
402+
name: "text needs truncation",
403+
text: "hello world",
404+
maxWidth: 8,
405+
expected: "hello w…",
406+
},
407+
{
408+
name: "truncate to minimum",
409+
text: "hello",
410+
maxWidth: 2,
411+
expected: "h…",
412+
},
413+
414+
// Edge cases
415+
{
416+
name: "empty string",
417+
text: "",
418+
maxWidth: 10,
419+
expected: "",
420+
},
421+
{
422+
name: "width of 1 returns ellipsis only",
423+
text: "hello",
424+
maxWidth: 1,
425+
expected: "…",
426+
},
427+
{
428+
name: "zero width",
429+
text: "hello",
430+
maxWidth: 0,
431+
expected: "",
432+
},
433+
{
434+
name: "negative width",
435+
text: "hello",
436+
maxWidth: -5,
437+
expected: "",
438+
},
439+
{
440+
name: "single character fits",
441+
text: "a",
442+
maxWidth: 1,
443+
expected: "a",
444+
},
445+
{
446+
name: "single character with larger width",
447+
text: "a",
448+
maxWidth: 10,
449+
expected: "a",
450+
},
451+
452+
// Unicode handling
453+
{
454+
name: "unicode within width",
455+
text: "héllo",
456+
maxWidth: 10,
457+
expected: "héllo",
458+
},
459+
{
460+
name: "unicode needs truncation",
461+
text: "héllo wörld",
462+
maxWidth: 8,
463+
expected: "héllo w…",
464+
},
465+
{
466+
name: "wide characters (CJK)",
467+
text: "你好世界",
468+
maxWidth: 5,
469+
expected: "你好…",
470+
},
471+
{
472+
name: "mixed ASCII and wide chars",
473+
text: "hello你好",
474+
maxWidth: 8,
475+
expected: "hello你…",
476+
},
477+
478+
// Special characters
479+
{
480+
name: "text with newlines",
481+
text: "hello\nworld",
482+
maxWidth: 8,
483+
expected: "hello\nworld",
484+
},
485+
}
486+
487+
for _, tt := range tests {
488+
t.Run(tt.name, func(t *testing.T) {
489+
t.Parallel()
490+
491+
result := TruncateText(tt.text, tt.maxWidth)
492+
assert.Equal(t, tt.expected, result)
493+
})
494+
}
495+
}
496+
497+
func BenchmarkTruncateText(b *testing.B) {
498+
// Test with various string lengths to demonstrate O(n) vs O(n²) improvement
499+
shortText := "hello world"
500+
mediumText := "This is a medium length string that needs truncation for testing purposes."
501+
longText := "This is a very long line that contains many characters and will need to be truncated. " +
502+
"It continues on and on with more and more text to really stress test the truncation algorithm. " +
503+
"We want to make sure the O(n) complexity improvement is significant for longer strings."
504+
505+
b.Run("short", func(b *testing.B) {
506+
for b.Loop() {
507+
TruncateText(shortText, 8)
508+
}
509+
})
510+
511+
b.Run("medium", func(b *testing.B) {
512+
for b.Loop() {
513+
TruncateText(mediumText, 30)
514+
}
515+
})
516+
517+
b.Run("long", func(b *testing.B) {
518+
for b.Loop() {
519+
TruncateText(longText, 50)
520+
}
521+
})
522+
523+
b.Run("no_truncation_needed", func(b *testing.B) {
524+
for b.Loop() {
525+
TruncateText(shortText, 100)
526+
}
527+
})
528+
}
529+
530+
func TestRuneWidth(t *testing.T) {
531+
tests := []struct {
532+
name string
533+
r rune
534+
expected int
535+
}{
536+
// ASCII
537+
{"space", ' ', 1},
538+
{"letter", 'a', 1},
539+
{"digit", '5', 1},
540+
{"tilde", '~', 1},
541+
542+
// Control characters
543+
{"null", '\x00', 0},
544+
{"tab", '\t', 0},
545+
{"newline", '\n', 0},
546+
{"carriage_return", '\r', 0},
547+
{"escape", '\x1b', 0},
548+
{"del", '\x7f', 0},
549+
550+
// C1 control characters
551+
{"c1_start", '\x80', 0},
552+
{"c1_end", '\x9f', 0},
553+
554+
// Latin-1 Supplement
555+
{"nbsp", '\xa0', 1},
556+
{"latin_e_acute", 'é', 1},
557+
{"latin_n_tilde", 'ñ', 1},
558+
{"latin_u_umlaut", 'ü', 1},
559+
560+
// Latin Extended
561+
{"latin_ext_a", 'ā', 1},
562+
{"latin_ext_b", 'ƀ', 1},
563+
564+
// CJK (double width)
565+
{"cjk_chinese", '你', 2},
566+
{"cjk_japanese", 'あ', 2},
567+
{"cjk_korean", '한', 2},
568+
569+
// Emoji (typically double width)
570+
{"emoji_globe", '🌍', 2},
571+
}
572+
573+
for _, tt := range tests {
574+
t.Run(tt.name, func(t *testing.T) {
575+
t.Parallel()
576+
result := runeWidth(tt.r)
577+
assert.Equal(t, tt.expected, result, "rune %q (U+%04X)", tt.r, tt.r)
578+
})
579+
}
580+
}
581+
582+
func BenchmarkRuneWidth(b *testing.B) {
583+
asciiRunes := []rune("hello world this is a test string with only ascii")
584+
latin1Runes := []rune("héllo wörld naïve café résumé über señor")
585+
mixedRunes := []rune("hello 你好 world 世界 test テスト")
586+
cjkRunes := []rune("你好世界这是一个测试")
587+
588+
b.Run("ascii", func(b *testing.B) {
589+
for b.Loop() {
590+
for _, r := range asciiRunes {
591+
_ = runeWidth(r)
592+
}
593+
}
594+
})
595+
596+
b.Run("latin1", func(b *testing.B) {
597+
for b.Loop() {
598+
for _, r := range latin1Runes {
599+
_ = runeWidth(r)
600+
}
601+
}
602+
})
603+
604+
b.Run("mixed", func(b *testing.B) {
605+
for b.Loop() {
606+
for _, r := range mixedRunes {
607+
_ = runeWidth(r)
608+
}
609+
}
610+
})
611+
612+
b.Run("cjk", func(b *testing.B) {
613+
for b.Loop() {
614+
for _, r := range cjkRunes {
615+
_ = runeWidth(r)
616+
}
617+
}
618+
})
619+
}

0 commit comments

Comments
 (0)