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
+88 -129
View File
@@ -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()
}
+32
View File
@@ -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)
}
}