From df42c95fa935d647fe27fad4414630e606951187 Mon Sep 17 00:00:00 2001 From: Kieran Klukas Date: Sat, 1 Aug 2026 02:30:52 -0700 Subject: [PATCH] feat: adopt x/ansi instead of hand rolling escape sequences (#96) also contains the lipgloss width change from #94 --- go.mod | 2 +- integration/overlay.go | 217 +++++++++++++++--------------------- integration/overlay_test.go | 32 ++++++ 3 files changed, 121 insertions(+), 130 deletions(-) diff --git a/go.mod b/go.mod index 47a31d2..ad38dd0 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.25.0 require ( charm.land/lipgloss/v2 v2.0.5 github.com/BurntSushi/toml v1.6.0 + github.com/charmbracelet/x/ansi v0.11.7 github.com/creack/pty v1.1.24 github.com/spf13/cobra v1.10.2 github.com/versenilvis/fuzzy v0.1.0-rc1.2 @@ -17,7 +18,6 @@ require ( require ( github.com/charmbracelet/colorprofile v0.4.3 // indirect github.com/charmbracelet/ultraviolet v0.0.0-20251205161215-1948445e3318 // indirect - github.com/charmbracelet/x/ansi v0.11.7 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect github.com/charmbracelet/x/termios v0.1.1 // indirect github.com/charmbracelet/x/windows v0.2.2 // indirect diff --git a/integration/overlay.go b/integration/overlay.go index 2380ad7..247e5d1 100644 --- a/integration/overlay.go +++ b/integration/overlay.go @@ -10,6 +10,7 @@ import ( "unicode/utf8" "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" "github.com/versenilvis/iris/internal/config" "github.com/versenilvis/iris/internal/logger" "github.com/versenilvis/iris/spec" @@ -369,49 +370,20 @@ func fixedWidth(s string, width int) string { if visualWidth == width { return s } - if visualWidth < width { - return s + strings.Repeat(" ", width-visualWidth) + if visualWidth > width { + s = ansi.Truncate(s, width, "…") } - var sb strings.Builder - currentWidth := 0 - for _, r := range s { - rw := lipgloss.Width(string(r)) - if currentWidth+rw > width-1 { - break - } - sb.WriteRune(r) - currentWidth += rw + if rem := width - lipgloss.Width(s); rem > 0 { + s += strings.Repeat(" ", rem) } - sb.WriteString("…") - rem := width - lipgloss.Width(sb.String()) - if rem > 0 { - sb.WriteString(strings.Repeat(" ", rem)) - } - return sb.String() + return s } func truncateToWidth(s string, maxW int) string { if maxW <= 0 { return "" } - if lipgloss.Width(s) <= maxW { - return s - } - if maxW == 1 { - return "…" - } - var sb strings.Builder - w := 0 - for _, r := range s { - rw := lipgloss.Width(string(r)) - if w+rw > maxW-1 { // leave 1 column for '…' - break - } - sb.WriteRune(r) - w += rw - } - sb.WriteRune('…') - return sb.String() + return ansi.Truncate(s, maxW, "…") } // titledEdge renders one horizontal box edge (top or bottom) with styled @@ -469,7 +441,7 @@ func (o *Overlay) HideGhostTextSync() string { defer o.mu.Unlock() if o.LastGhostLen > 0 { padLen := o.LastGhostLen + 4 - res := "\0337" + strings.Repeat(" ", padLen) + "\0338" + res := ansi.SaveCursor + strings.Repeat(" ", padLen) + ansi.RestoreCursor o.LastGhostLen = 0 return res } @@ -484,7 +456,7 @@ func (o *Overlay) RenderGhostText(buffer string, userNavigated bool, cursorAtEnd if o.LastGhostLen > 0 { padLen := o.LastGhostLen + 4 o.LastGhostLen = 0 - return "\0337" + strings.Repeat(" ", padLen) + "\0338" + return ansi.SaveCursor + strings.Repeat(" ", padLen) + ansi.RestoreCursor } return "" } @@ -504,10 +476,7 @@ func (o *Overlay) RenderGhostText(buffer string, userNavigated bool, cursorAtEnd } if ghostText != "" { - width, _, err := term.GetSize(int(os.Stdout.Fd())) - if err != nil || width <= 0 { - width = 120 - } + width := termWidth() cursorCol := o.PromptLen + lipgloss.Width(buffer) availableCols := width - cursorCol if availableCols <= 0 { @@ -527,7 +496,7 @@ func (o *Overlay) RenderGhostText(buffer string, userNavigated bool, cursorAtEnd padLen += 4 } - s.WriteString("\0337") + s.WriteString(ansi.SaveCursor) if ghostText != "" { styled := lipgloss.NewStyle().Foreground(currentTheme.GhostText).Render(ghostText) s.WriteString(styled) @@ -535,12 +504,32 @@ func (o *Overlay) RenderGhostText(buffer string, userNavigated bool, cursorAtEnd if padLen > 0 { s.WriteString(strings.Repeat(" ", padLen)) } - s.WriteString("\0338") + s.WriteString(ansi.RestoreCursor) o.LastGhostLen = ghostWidth return s.String() } +// termWidth returns the terminal width, falling back to 120 when the size +// can't be determined (e.g. stdout isn't a TTY in tests). +func termWidth() int { + w, _, err := term.GetSize(int(os.Stdout.Fd())) + if err != nil || w <= 0 { + return 120 + } + return w +} + +// sourceTag renders a pill-style source label (" alias ") with inverted +// colors when selected. +func sourceTag(label, bgHex, fgHex string, selected bool) string { + style := lipgloss.NewStyle().Background(lipgloss.Color(bgHex)).Foreground(lipgloss.Color(fgHex)) + if selected { + style = lipgloss.NewStyle().Background(lipgloss.Color(fgHex)).Foreground(lipgloss.Color("#110f18")).Bold(true) + } + return style.Render(" " + label + " ") +} + func renderMatchedTitle(title, typed string, selected bool, w int) string { t := currentTheme textColor := t.Text @@ -561,10 +550,12 @@ func renderMatchedTitle(title, typed string, selected bool, w int) string { return base.Render(display) } - typedRunes := []rune(typed) - displayRunes := []rune(display) - matchLen := min(len(typedRunes), len(displayRunes)) - return match.Render(string(displayRunes[:matchLen])) + base.Render(string(displayRunes[matchLen:])) + // Split by display width, not rune count, so a typed prefix containing + // wide runes (CJK, emoji) highlights exactly the matching cells. + matchW := lipgloss.Width(typed) + highlighted := ansi.Truncate(display, matchW, "") + rest := strings.TrimPrefix(display, highlighted) + return match.Render(highlighted) + base.Render(rest) } func (o *Overlay) Render() string { @@ -584,15 +575,12 @@ func (o *Overlay) draw() string { scrollStyle := lipgloss.NewStyle().Foreground(t.ScrollInfo) var s strings.Builder - s.WriteString("\033[?7l") + s.WriteString(ansi.ResetModeAutoWrap) - typedLen := len([]rune(o.TypedQuery)) + typedLen := lipgloss.Width(o.TypedQuery) targetCol := o.PromptLen + typedLen - width, _, err := term.GetSize(int(os.Stdout.Fd())) - if err != nil || width <= 0 { - width = 120 - } + width := termWidth() boxWidth := config.Get().UI.MaxWidth if boxWidth <= 0 { @@ -613,7 +601,7 @@ func (o *Overlay) draw() string { } logger.Debugf("Overlay draw: pLen=%d, typedLen=%d, targetCol=%d, width=%d", o.PromptLen, typedLen, targetCol, width) - s.WriteString("\0337") + s.WriteString(ansi.SaveCursor) windowSize := min(len(o.Items), maxItems) @@ -642,17 +630,15 @@ func (o *Overlay) draw() string { end := start + windowSize totalLines := windowSize + 2 - for range totalLines { - s.WriteByte('\n') - } - fmt.Fprintf(&s, "\033[%dA", totalLines) + s.WriteString(strings.Repeat("\n", totalLines)) + s.WriteString(ansi.CursorUp(totalLines)) - s.WriteString("\0337") + s.WriteString(ansi.SaveCursor) moveToTarget := func() { s.WriteString("\r") if targetCol > 0 { - fmt.Fprintf(&s, "\033[%dC", targetCol) + s.WriteString(ansi.CursorForward(targetCol)) } } @@ -662,9 +648,9 @@ func (o *Overlay) draw() string { isClassic := style == "classic" || style == "minimal" || style == "minimalist" // top side border with scroll counter - s.WriteString("\0338") - fmt.Fprintf(&s, "\033[%dB", 1) - s.WriteString("\033[2K") + s.WriteString(ansi.RestoreCursor) + s.WriteString(ansi.CursorDown(1)) + s.WriteString(ansi.EraseEntireLine) moveToTarget() scrollInfo := "" @@ -693,9 +679,9 @@ func (o *Overlay) draw() string { titleW = titleW - padGap - descW for i := start; i < end; i++ { - s.WriteString("\0338") - fmt.Fprintf(&s, "\033[%dB", (i-start)+2) - s.WriteString("\033[2K") + s.WriteString(ansi.RestoreCursor) + s.WriteString(ansi.CursorDown((i - start) + 2)) + s.WriteString(ansi.EraseEntireLine) moveToTarget() it := o.Items[i] @@ -740,31 +726,16 @@ func (o *Overlay) draw() string { } else { switch it.Icon { case "alias": - boxStyle := lipgloss.NewStyle().Background(lipgloss.Color("#2a2342")).Foreground(lipgloss.Color("#a277ff")) - if selected { - boxStyle = lipgloss.NewStyle().Background(lipgloss.Color("#a277ff")).Foreground(lipgloss.Color("#110f18")).Bold(true) - } - tag := boxStyle.Render(" alias ") - tw := lipgloss.Width(tag) - rem := max(descW-tw-1, 0) + tag := sourceTag("alias", "#2a2342", "#a277ff", selected) + rem := max(descW-lipgloss.Width(tag)-1, 0) desc = tag + bg.Render(" ") + bg.Foreground(descColor).Render(fixedWidth(it.Desc, rem)) case "history": - boxStyle := lipgloss.NewStyle().Background(lipgloss.Color("#1a2d36")).Foreground(lipgloss.Color("#61ffca")) - if selected { - boxStyle = lipgloss.NewStyle().Background(lipgloss.Color("#61ffca")).Foreground(lipgloss.Color("#110f18")).Bold(true) - } - tag := boxStyle.Render(" history ") - tw := lipgloss.Width(tag) - rem := max(descW-tw, 0) + tag := sourceTag("history", "#1a2d36", "#61ffca", selected) + rem := max(descW-lipgloss.Width(tag), 0) desc = tag + bg.Render(strings.Repeat(" ", rem)) case "system": - boxStyle := lipgloss.NewStyle().Background(lipgloss.Color("#1e1d28")).Foreground(lipgloss.Color("#a277ff")) - if selected { - boxStyle = lipgloss.NewStyle().Background(lipgloss.Color("#a277ff")).Foreground(lipgloss.Color("#110f18")).Bold(true) - } - tag := boxStyle.Render(" system ") - tw := lipgloss.Width(tag) - rem := max(descW-tw, 0) + tag := sourceTag("system", "#1e1d28", "#a277ff", selected) + rem := max(descW-lipgloss.Width(tag), 0) desc = tag + bg.Render(strings.Repeat(" ", rem)) default: desc = bg.Foreground(descColor).Render(fixedWidth(it.Desc, descW)) @@ -791,9 +762,9 @@ func (o *Overlay) draw() string { } // bottom side border with footer shortcut hints - s.WriteString("\0338") - fmt.Fprintf(&s, "\033[%dB", windowSize+2) - s.WriteString("\033[2K") + s.WriteString(ansi.RestoreCursor) + s.WriteString(ansi.CursorDown(windowSize + 2)) + s.WriteString(ansi.EraseEntireLine) moveToTarget() footerInfo := "" @@ -808,27 +779,31 @@ func (o *Overlay) draw() string { s.WriteString(titledEdge("╰", "╯", inner, footerInfo, border, inner-lipgloss.Width(footerInfo)-2)) - s.WriteString("\0338") - s.WriteString("\033[?7h") + s.WriteString(ansi.RestoreCursor) + s.WriteString(ansi.SetModeAutoWrap) return s.String() } +// clearLinesBelow erases n lines below the cursor, leaving the cursor where +// it started. Caller is responsible for wrapping/resetting auto-wrap mode. +func clearLinesBelow(s *strings.Builder, n int) { + s.WriteString(ansi.SaveCursor) + for i := range n { + s.WriteString(ansi.RestoreCursor) + s.WriteString(ansi.CursorDown(i + 1)) + s.WriteString("\r" + ansi.EraseEntireLine) + } + s.WriteString(ansi.RestoreCursor) +} + func (o *Overlay) Clear() string { o.mu.Lock() defer o.mu.Unlock() var s strings.Builder - s.WriteString("\033[?7l") - s.WriteString("\0337") - - for i := range maxItems + 2 { - s.WriteString("\0338") - fmt.Fprintf(&s, "\033[%dB", i+1) - s.WriteString("\r\033[2K") - } - - s.WriteString("\0338") - s.WriteString("\033[?7h") + s.WriteString(ansi.ResetModeAutoWrap) + clearLinesBelow(&s, maxItems+2) + s.WriteString(ansi.SetModeAutoWrap) return s.String() } @@ -848,25 +823,17 @@ func (o *Overlay) HideMenu(query string) string { o.StartIdx = 0 var s strings.Builder - s.WriteString("\033[?7l") + s.WriteString(ansi.ResetModeAutoWrap) if o.LastGhostLen > 0 { - s.WriteString("\0337") + s.WriteString(ansi.SaveCursor) s.WriteString(strings.Repeat(" ", o.LastGhostLen+10)) - s.WriteString("\0338") + s.WriteString(ansi.RestoreCursor) o.LastGhostLen = 0 } - s.WriteString("\0337") - - for i := range maxItems + 2 { - s.WriteString("\0338") - fmt.Fprintf(&s, "\033[%dB", i+1) - s.WriteString("\r\033[2K") - } - - s.WriteString("\0338") - s.WriteString("\033[?7h") + clearLinesBelow(&s, maxItems+2) + s.WriteString(ansi.SetModeAutoWrap) return s.String() } @@ -886,24 +853,16 @@ func (o *Overlay) ClearAndDisable() string { o.StartIdx = 0 var s strings.Builder - s.WriteString("\033[?7l") + s.WriteString(ansi.ResetModeAutoWrap) if o.LastGhostLen > 0 { - s.WriteString("\0337") + s.WriteString(ansi.SaveCursor) s.WriteString(strings.Repeat(" ", o.LastGhostLen+10)) - s.WriteString("\0338") + s.WriteString(ansi.RestoreCursor) o.LastGhostLen = 0 } - s.WriteString("\0337") - - for i := range maxItems + 2 { - s.WriteString("\0338") - fmt.Fprintf(&s, "\033[%dB", i+1) - s.WriteString("\r\033[2K") - } - - s.WriteString("\0338") - s.WriteString("\033[?7h") + clearLinesBelow(&s, maxItems+2) + s.WriteString(ansi.SetModeAutoWrap) return s.String() } diff --git a/integration/overlay_test.go b/integration/overlay_test.go index a0f7fb6..f4b6b5f 100644 --- a/integration/overlay_test.go +++ b/integration/overlay_test.go @@ -4,6 +4,7 @@ import ( "strings" "testing" + "github.com/charmbracelet/x/ansi" "github.com/versenilvis/iris/spec" ) @@ -126,3 +127,34 @@ func TestHideMenu_PreservesTypedQueryForAI(t *testing.T) { t.Fatalf("Expected AI suggestion to be injected into Items[0] and Visible=true") } } + +func TestRenderMatchedTitle_ASCII(t *testing.T) { + out := renderMatchedTitle("git status", "git", false, 40) + stripped := ansi.Strip(out) + if stripped != "git status"+strings.Repeat(" ", 30) { + t.Fatalf("unexpected content: %q", stripped) + } + if !strings.Contains(out, "git") { + t.Fatalf("missing match segment: %q", out) + } +} + +func TestRenderMatchedTitle_WideRunes(t *testing.T) { + out := renderMatchedTitle("日記を表示する", "日記", false, 20) + stripped := ansi.Strip(out) + if !strings.HasPrefix(stripped, "日記を表示する") { + t.Fatalf("content mangled: %q", stripped) + } + // split must occur after 日記 (width 4), not mid-grapheme + if !strings.Contains(stripped, "を") { + t.Fatalf("lost remainder: %q", stripped) + } +} + +func TestRenderMatchedTitle_CaseInsensitive(t *testing.T) { + out := renderMatchedTitle("GitHub CLI", "git", false, 40) + stripped := ansi.Strip(out) + if !strings.HasPrefix(stripped, "GitHub CLI") { + t.Fatalf("case-insensitive match mangled content: %q", stripped) + } +}