From 4fc810fb84c607a1c3b5035fc9c31063e3e59500 Mon Sep 17 00:00:00 2001 From: Pieter Noordhuis Date: Mon, 1 Jun 2026 14:27:21 +0200 Subject: [PATCH] cmdio: add Width, PadRight, and PadLeft helpers for column alignment Adds Width, PadRight, and PadLeft to libs/cmdio for aligning columns of colored text. Width returns the visible terminal-cell width, ignoring ANSI color escapes and counting wide glyphs (CJK, emoji, fullwidth Latin) as two cells; the Pad* helpers pad to a visible width, measuring the rendered string so already-colored cells stay aligned. This lets commands print color-per-cell tables without the "measure the uncolored string, pad by hand" dance that misaligns rows on any escape sequence or wide glyph -- something text/tabwriter can't fix, since it counts runes. Implemented via lipgloss.Width, already a direct dependency. Co-authored-by: Isaac --- libs/cmdio/color.go | 31 ++++++++++++++++++++ libs/cmdio/color_test.go | 61 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+) diff --git a/libs/cmdio/color.go b/libs/cmdio/color.go index 6ad00834a4a..60fb3a15fa0 100644 --- a/libs/cmdio/color.go +++ b/libs/cmdio/color.go @@ -3,7 +3,10 @@ package cmdio import ( "context" "fmt" + "strings" "text/template" + + "github.com/charmbracelet/lipgloss" ) // SGR (Select Graphic Rendition) escapes; see @@ -107,3 +110,31 @@ func templateColor(ctx context.Context, code string) func(string, ...any) string return render(ctx, code, msg) } } + +// Width returns the visible cell width of s. ANSI color escapes (such as those +// emitted by the helpers above) are ignored, and wide glyphs like CJK +// characters and emoji are counted as two cells. Use this instead of len() or +// utf8.RuneCountInString when aligning columns of rendered text. +func Width(s string) int { + return lipgloss.Width(s) +} + +// PadRight returns s padded with trailing spaces to a visible width of n (see +// Width). Because it measures the rendered string, cells already wrapped by the +// color helpers stay aligned. Strings at or beyond width n are returned as-is. +func PadRight(s string, n int) string { + if pad := n - Width(s); pad > 0 { + return s + strings.Repeat(" ", pad) + } + return s +} + +// PadLeft returns s padded with leading spaces to a visible width of n (see +// Width), right-aligning the rendered content. Strings at or beyond width n are +// returned as-is. +func PadLeft(s string, n int) string { + if pad := n - Width(s); pad > 0 { + return strings.Repeat(" ", pad) + s + } + return s +} diff --git a/libs/cmdio/color_test.go b/libs/cmdio/color_test.go index fbbd4cf700e..9e51dd98bb9 100644 --- a/libs/cmdio/color_test.go +++ b/libs/cmdio/color_test.go @@ -65,6 +65,67 @@ func TestColorHelpersDoNotPanicWithoutCmdIO(t *testing.T) { assert.Equal(t, "label: ", cmdio.Cyan(ctx, "label: ")) } +func TestWidth(t *testing.T) { + cases := []struct { + name string + in string + want int + }{ + {"empty", "", 0}, + {"ascii", "hello", 5}, + {"ansi wrapped", "\x1b[1mhello\x1b[0m", 5}, + {"nested ansi", "\x1b[1m\x1b[36mid\x1b[0m", 2}, + {"fullwidth latin", "NAME", 8}, + {"emoji", "🚀", 2}, + {"ansi wrapped fullwidth", "\x1b[36mCLI\x1b[0m", 6}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + assert.Equal(t, c.want, cmdio.Width(c.in)) + }) + } +} + +func TestPadRight(t *testing.T) { + cases := []struct { + name string + in string + n int + want string + }{ + {"pads ascii", "hi", 5, "hi "}, + {"exact width unchanged", "hello", 5, "hello"}, + {"over width unchanged", "toolong", 3, "toolong"}, + {"measures past ansi", "\x1b[1mhi\x1b[0m", 5, "\x1b[1mhi\x1b[0m "}, + {"counts wide glyphs", "CLI", 8, "CLI "}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + assert.Equal(t, c.want, cmdio.PadRight(c.in, c.n)) + }) + } +} + +func TestPadLeft(t *testing.T) { + cases := []struct { + name string + in string + n int + want string + }{ + {"pads ascii", "hi", 5, " hi"}, + {"exact width unchanged", "hello", 5, "hello"}, + {"over width unchanged", "toolong", 3, "toolong"}, + {"measures past ansi", "\x1b[1mhi\x1b[0m", 5, " \x1b[1mhi\x1b[0m"}, + {"counts wide glyphs", "CLI", 8, " CLI"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + assert.Equal(t, c.want, cmdio.PadLeft(c.in, c.n)) + }) + } +} + func TestRenderFuncMap(t *testing.T) { ctx := ttyContext(t) fm := cmdio.RenderFuncMap(ctx)