feat: adopt x/ansi instead of hand rolling escape sequences (#96)

also contains the lipgloss width change from #94
This commit is contained in:
Kieran Klukas
2026-08-01 16:30:52 +07:00
committed by GitHub
parent efc49bacfe
commit df42c95fa9
3 changed files with 121 additions and 130 deletions
+1 -1
View File
@@ -5,6 +5,7 @@ go 1.25.0
require ( require (
charm.land/lipgloss/v2 v2.0.5 charm.land/lipgloss/v2 v2.0.5
github.com/BurntSushi/toml v1.6.0 github.com/BurntSushi/toml v1.6.0
github.com/charmbracelet/x/ansi v0.11.7
github.com/creack/pty v1.1.24 github.com/creack/pty v1.1.24
github.com/spf13/cobra v1.10.2 github.com/spf13/cobra v1.10.2
github.com/versenilvis/fuzzy v0.1.0-rc1.2 github.com/versenilvis/fuzzy v0.1.0-rc1.2
@@ -17,7 +18,6 @@ require (
require ( require (
github.com/charmbracelet/colorprofile v0.4.3 // indirect github.com/charmbracelet/colorprofile v0.4.3 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20251205161215-1948445e3318 // 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/term v0.2.2 // indirect
github.com/charmbracelet/x/termios v0.1.1 // indirect github.com/charmbracelet/x/termios v0.1.1 // indirect
github.com/charmbracelet/x/windows v0.2.2 // indirect github.com/charmbracelet/x/windows v0.2.2 // indirect
+88 -129
View File
@@ -10,6 +10,7 @@ import (
"unicode/utf8" "unicode/utf8"
"charm.land/lipgloss/v2" "charm.land/lipgloss/v2"
"github.com/charmbracelet/x/ansi"
"github.com/versenilvis/iris/internal/config" "github.com/versenilvis/iris/internal/config"
"github.com/versenilvis/iris/internal/logger" "github.com/versenilvis/iris/internal/logger"
"github.com/versenilvis/iris/spec" "github.com/versenilvis/iris/spec"
@@ -369,49 +370,20 @@ func fixedWidth(s string, width int) string {
if visualWidth == width { if visualWidth == width {
return s return s
} }
if visualWidth < width { if visualWidth > width {
return s + strings.Repeat(" ", width-visualWidth) s = ansi.Truncate(s, width, "…")
} }
var sb strings.Builder if rem := width - lipgloss.Width(s); rem > 0 {
currentWidth := 0 s += strings.Repeat(" ", rem)
for _, r := range s {
rw := lipgloss.Width(string(r))
if currentWidth+rw > width-1 {
break
}
sb.WriteRune(r)
currentWidth += rw
} }
sb.WriteString("…") return s
rem := width - lipgloss.Width(sb.String())
if rem > 0 {
sb.WriteString(strings.Repeat(" ", rem))
}
return sb.String()
} }
func truncateToWidth(s string, maxW int) string { func truncateToWidth(s string, maxW int) string {
if maxW <= 0 { if maxW <= 0 {
return "" return ""
} }
if lipgloss.Width(s) <= maxW { return ansi.Truncate(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()
} }
// titledEdge renders one horizontal box edge (top or bottom) with styled // titledEdge renders one horizontal box edge (top or bottom) with styled
@@ -469,7 +441,7 @@ func (o *Overlay) HideGhostTextSync() string {
defer o.mu.Unlock() defer o.mu.Unlock()
if o.LastGhostLen > 0 { if o.LastGhostLen > 0 {
padLen := o.LastGhostLen + 4 padLen := o.LastGhostLen + 4
res := "\0337" + strings.Repeat(" ", padLen) + "\0338" res := ansi.SaveCursor + strings.Repeat(" ", padLen) + ansi.RestoreCursor
o.LastGhostLen = 0 o.LastGhostLen = 0
return res return res
} }
@@ -484,7 +456,7 @@ func (o *Overlay) RenderGhostText(buffer string, userNavigated bool, cursorAtEnd
if o.LastGhostLen > 0 { if o.LastGhostLen > 0 {
padLen := o.LastGhostLen + 4 padLen := o.LastGhostLen + 4
o.LastGhostLen = 0 o.LastGhostLen = 0
return "\0337" + strings.Repeat(" ", padLen) + "\0338" return ansi.SaveCursor + strings.Repeat(" ", padLen) + ansi.RestoreCursor
} }
return "" return ""
} }
@@ -504,10 +476,7 @@ func (o *Overlay) RenderGhostText(buffer string, userNavigated bool, cursorAtEnd
} }
if ghostText != "" { if ghostText != "" {
width, _, err := term.GetSize(int(os.Stdout.Fd())) width := termWidth()
if err != nil || width <= 0 {
width = 120
}
cursorCol := o.PromptLen + lipgloss.Width(buffer) cursorCol := o.PromptLen + lipgloss.Width(buffer)
availableCols := width - cursorCol availableCols := width - cursorCol
if availableCols <= 0 { if availableCols <= 0 {
@@ -527,7 +496,7 @@ func (o *Overlay) RenderGhostText(buffer string, userNavigated bool, cursorAtEnd
padLen += 4 padLen += 4
} }
s.WriteString("\0337") s.WriteString(ansi.SaveCursor)
if ghostText != "" { if ghostText != "" {
styled := lipgloss.NewStyle().Foreground(currentTheme.GhostText).Render(ghostText) styled := lipgloss.NewStyle().Foreground(currentTheme.GhostText).Render(ghostText)
s.WriteString(styled) s.WriteString(styled)
@@ -535,12 +504,32 @@ func (o *Overlay) RenderGhostText(buffer string, userNavigated bool, cursorAtEnd
if padLen > 0 { if padLen > 0 {
s.WriteString(strings.Repeat(" ", padLen)) s.WriteString(strings.Repeat(" ", padLen))
} }
s.WriteString("\0338") s.WriteString(ansi.RestoreCursor)
o.LastGhostLen = ghostWidth o.LastGhostLen = ghostWidth
return s.String() 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 { func renderMatchedTitle(title, typed string, selected bool, w int) string {
t := currentTheme t := currentTheme
textColor := t.Text textColor := t.Text
@@ -561,10 +550,12 @@ func renderMatchedTitle(title, typed string, selected bool, w int) string {
return base.Render(display) return base.Render(display)
} }
typedRunes := []rune(typed) // Split by display width, not rune count, so a typed prefix containing
displayRunes := []rune(display) // wide runes (CJK, emoji) highlights exactly the matching cells.
matchLen := min(len(typedRunes), len(displayRunes)) matchW := lipgloss.Width(typed)
return match.Render(string(displayRunes[:matchLen])) + base.Render(string(displayRunes[matchLen:])) highlighted := ansi.Truncate(display, matchW, "")
rest := strings.TrimPrefix(display, highlighted)
return match.Render(highlighted) + base.Render(rest)
} }
func (o *Overlay) Render() string { func (o *Overlay) Render() string {
@@ -584,15 +575,12 @@ func (o *Overlay) draw() string {
scrollStyle := lipgloss.NewStyle().Foreground(t.ScrollInfo) scrollStyle := lipgloss.NewStyle().Foreground(t.ScrollInfo)
var s strings.Builder 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 targetCol := o.PromptLen + typedLen
width, _, err := term.GetSize(int(os.Stdout.Fd())) width := termWidth()
if err != nil || width <= 0 {
width = 120
}
boxWidth := config.Get().UI.MaxWidth boxWidth := config.Get().UI.MaxWidth
if boxWidth <= 0 { 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) 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) windowSize := min(len(o.Items), maxItems)
@@ -642,17 +630,15 @@ func (o *Overlay) draw() string {
end := start + windowSize end := start + windowSize
totalLines := windowSize + 2 totalLines := windowSize + 2
for range totalLines { s.WriteString(strings.Repeat("\n", totalLines))
s.WriteByte('\n') s.WriteString(ansi.CursorUp(totalLines))
}
fmt.Fprintf(&s, "\033[%dA", totalLines)
s.WriteString("\0337") s.WriteString(ansi.SaveCursor)
moveToTarget := func() { moveToTarget := func() {
s.WriteString("\r") s.WriteString("\r")
if targetCol > 0 { 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" isClassic := style == "classic" || style == "minimal" || style == "minimalist"
// top side border with scroll counter // top side border with scroll counter
s.WriteString("\0338") s.WriteString(ansi.RestoreCursor)
fmt.Fprintf(&s, "\033[%dB", 1) s.WriteString(ansi.CursorDown(1))
s.WriteString("\033[2K") s.WriteString(ansi.EraseEntireLine)
moveToTarget() moveToTarget()
scrollInfo := "" scrollInfo := ""
@@ -693,9 +679,9 @@ func (o *Overlay) draw() string {
titleW = titleW - padGap - descW titleW = titleW - padGap - descW
for i := start; i < end; i++ { for i := start; i < end; i++ {
s.WriteString("\0338") s.WriteString(ansi.RestoreCursor)
fmt.Fprintf(&s, "\033[%dB", (i-start)+2) s.WriteString(ansi.CursorDown((i - start) + 2))
s.WriteString("\033[2K") s.WriteString(ansi.EraseEntireLine)
moveToTarget() moveToTarget()
it := o.Items[i] it := o.Items[i]
@@ -740,31 +726,16 @@ func (o *Overlay) draw() string {
} else { } else {
switch it.Icon { switch it.Icon {
case "alias": case "alias":
boxStyle := lipgloss.NewStyle().Background(lipgloss.Color("#2a2342")).Foreground(lipgloss.Color("#a277ff")) tag := sourceTag("alias", "#2a2342", "#a277ff", selected)
if selected { rem := max(descW-lipgloss.Width(tag)-1, 0)
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)
desc = tag + bg.Render(" ") + bg.Foreground(descColor).Render(fixedWidth(it.Desc, rem)) desc = tag + bg.Render(" ") + bg.Foreground(descColor).Render(fixedWidth(it.Desc, rem))
case "history": case "history":
boxStyle := lipgloss.NewStyle().Background(lipgloss.Color("#1a2d36")).Foreground(lipgloss.Color("#61ffca")) tag := sourceTag("history", "#1a2d36", "#61ffca", selected)
if selected { rem := max(descW-lipgloss.Width(tag), 0)
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)
desc = tag + bg.Render(strings.Repeat(" ", rem)) desc = tag + bg.Render(strings.Repeat(" ", rem))
case "system": case "system":
boxStyle := lipgloss.NewStyle().Background(lipgloss.Color("#1e1d28")).Foreground(lipgloss.Color("#a277ff")) tag := sourceTag("system", "#1e1d28", "#a277ff", selected)
if selected { rem := max(descW-lipgloss.Width(tag), 0)
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)
desc = tag + bg.Render(strings.Repeat(" ", rem)) desc = tag + bg.Render(strings.Repeat(" ", rem))
default: default:
desc = bg.Foreground(descColor).Render(fixedWidth(it.Desc, descW)) 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 // bottom side border with footer shortcut hints
s.WriteString("\0338") s.WriteString(ansi.RestoreCursor)
fmt.Fprintf(&s, "\033[%dB", windowSize+2) s.WriteString(ansi.CursorDown(windowSize + 2))
s.WriteString("\033[2K") s.WriteString(ansi.EraseEntireLine)
moveToTarget() moveToTarget()
footerInfo := "" footerInfo := ""
@@ -808,27 +779,31 @@ func (o *Overlay) draw() string {
s.WriteString(titledEdge("╰", "╯", inner, footerInfo, border, inner-lipgloss.Width(footerInfo)-2)) s.WriteString(titledEdge("╰", "╯", inner, footerInfo, border, inner-lipgloss.Width(footerInfo)-2))
s.WriteString("\0338") s.WriteString(ansi.RestoreCursor)
s.WriteString("\033[?7h") s.WriteString(ansi.SetModeAutoWrap)
return s.String() 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 { func (o *Overlay) Clear() string {
o.mu.Lock() o.mu.Lock()
defer o.mu.Unlock() defer o.mu.Unlock()
var s strings.Builder var s strings.Builder
s.WriteString("\033[?7l") s.WriteString(ansi.ResetModeAutoWrap)
s.WriteString("\0337") clearLinesBelow(&s, maxItems+2)
s.WriteString(ansi.SetModeAutoWrap)
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")
return s.String() return s.String()
} }
@@ -848,25 +823,17 @@ func (o *Overlay) HideMenu(query string) string {
o.StartIdx = 0 o.StartIdx = 0
var s strings.Builder var s strings.Builder
s.WriteString("\033[?7l") s.WriteString(ansi.ResetModeAutoWrap)
if o.LastGhostLen > 0 { if o.LastGhostLen > 0 {
s.WriteString("\0337") s.WriteString(ansi.SaveCursor)
s.WriteString(strings.Repeat(" ", o.LastGhostLen+10)) s.WriteString(strings.Repeat(" ", o.LastGhostLen+10))
s.WriteString("\0338") s.WriteString(ansi.RestoreCursor)
o.LastGhostLen = 0 o.LastGhostLen = 0
} }
s.WriteString("\0337") clearLinesBelow(&s, maxItems+2)
s.WriteString(ansi.SetModeAutoWrap)
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")
return s.String() return s.String()
} }
@@ -886,24 +853,16 @@ func (o *Overlay) ClearAndDisable() string {
o.StartIdx = 0 o.StartIdx = 0
var s strings.Builder var s strings.Builder
s.WriteString("\033[?7l") s.WriteString(ansi.ResetModeAutoWrap)
if o.LastGhostLen > 0 { if o.LastGhostLen > 0 {
s.WriteString("\0337") s.WriteString(ansi.SaveCursor)
s.WriteString(strings.Repeat(" ", o.LastGhostLen+10)) s.WriteString(strings.Repeat(" ", o.LastGhostLen+10))
s.WriteString("\0338") s.WriteString(ansi.RestoreCursor)
o.LastGhostLen = 0 o.LastGhostLen = 0
} }
s.WriteString("\0337") clearLinesBelow(&s, maxItems+2)
s.WriteString(ansi.SetModeAutoWrap)
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")
return s.String() return s.String()
} }
+32
View File
@@ -4,6 +4,7 @@ import (
"strings" "strings"
"testing" "testing"
"github.com/charmbracelet/x/ansi"
"github.com/versenilvis/iris/spec" "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") 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)
}
}