feat: adopt x/ansi instead of hand rolling escape sequences (#96)
also contains the lipgloss width change from #94
This commit is contained in:
+88
-129
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user