diff --git a/pkg/utils/formatting.go b/pkg/utils/formatting.go index f058f4f40..080f58f87 100644 --- a/pkg/utils/formatting.go +++ b/pkg/utils/formatting.go @@ -199,6 +199,49 @@ func TruncateWithEllipsis(str string, limit int) string { return truncatedStr + "…" } +// TruncateWithEllipsisInMiddle returns a string, truncated to a certain width, +// with an ellipsis in the middle. Use it where the end of the string is as +// informative as its beginning, e.g. for paths. +func TruncateWithEllipsisInMiddle(str string, limit int) string { + if StringWidth(str) <= limit { + return str + } + if limit <= 2 { + return strings.Repeat(".", limit) + } + + clusters := []string{} + widths := []int{} + graphemes := uniseg.NewGraphemes(str) + for graphemes.Next() { + clusters = append(clusters, graphemes.Str()) + widths = append(widths, graphemes.Width()) + } + + // One column goes to the ellipsis; the rest is split between the two ends, + // with the odd one going to the front. + remaining := limit - 1 + frontLimit := (remaining + 1) / 2 + + front := 0 + frontWidth := 0 + for front < len(clusters) && frontWidth+widths[front] <= frontLimit { + frontWidth += widths[front] + front++ + } + + // Whatever the front didn't use, e.g. because a wide grapheme didn't fit + // into it, is available to the back. + back := len(clusters) + backWidth := 0 + for back > front && backWidth+widths[back-1] <= remaining-frontWidth { + backWidth += widths[back-1] + back-- + } + + return strings.Join(clusters[:front], "") + "…" + strings.Join(clusters[back:], "") +} + func SafeTruncate(str string, limit int) string { if len(str) > limit { return str[0:limit] diff --git a/pkg/utils/formatting_test.go b/pkg/utils/formatting_test.go index bc30fcf25..7de0492ae 100644 --- a/pkg/utils/formatting_test.go +++ b/pkg/utils/formatting_test.go @@ -162,6 +162,38 @@ func TestTruncateWithEllipsis(t *testing.T) { } } +func TestTruncateWithEllipsisInMiddle(t *testing.T) { + type scenario struct { + str string + limit int + expected string + } + + scenarios := []scenario{ + {"hello world !", 0, ""}, + {"hello world !", 1, "."}, + {"hello world !", 2, ".."}, + {"hello world !", 3, "h…!"}, + {"hello world !", 4, "he…!"}, + {"hello world !", 5, "he… !"}, + {"hello world !", 12, "hello …rld !"}, + {"hello world !", 13, "hello world !"}, + {"hello world !", 14, "hello world !"}, + // A wide grapheme that doesn't fit into the front leaves its column to + // the back + {"大大大大", 5, "大…大"}, + {"大大大大", 7, "大…大大"}, + {"大大大大", 8, "大大大大"}, + {"大大大大", 2, ".."}, + {"大大大大", 1, "."}, + {"大大大大", 0, ""}, + } + + for _, s := range scenarios { + assert.EqualValues(t, s.expected, TruncateWithEllipsisInMiddle(s.str, s.limit)) + } +} + func TestRenderDisplayStrings(t *testing.T) { type scenario struct { input [][]string