Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions libs/cmdio/color.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@ package cmdio
import (
"context"
"fmt"
"strings"
"text/template"

"github.com/charmbracelet/lipgloss"
)

// SGR (Select Graphic Rendition) escapes; see
Expand Down Expand Up @@ -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
}
61 changes: 61 additions & 0 deletions libs/cmdio/color_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading