From ea82216dc9e0f9b9a4be0583300a2922b8f4563a Mon Sep 17 00:00:00 2001 From: VERSE Date: Fri, 3 Jul 2026 11:50:35 +0700 Subject: [PATCH] style(ui): redesign new menu (#18) - 2 mode classic (the old one) and modern a2a17cab3a4bdf27abd2a956f9d53a0c4a685909 - add icon 3a11c7a6beb1dcf11e89977f380376fdea9fba89 - fix some bugs --- commands/core/lookup.go | 14 +- commands/core/spec.go | 2 + config/config.go | 8 +- config/defaults.go | 2 + integration/icons.go | 54 +++++++ integration/overlay.go | 291 ++++++++++++++++++++++++++++++-------- root/config_cmd.go | 6 + root/init.go | 6 + root/suggestions.go | 12 +- root/wrapper.go | 44 +++++- tests/core/lookup_test.go | 2 +- 11 files changed, 365 insertions(+), 76 deletions(-) create mode 100644 integration/icons.go diff --git a/commands/core/lookup.go b/commands/core/lookup.go index 26abb8a..5291855 100644 --- a/commands/core/lookup.go +++ b/commands/core/lookup.go @@ -47,13 +47,13 @@ func Lookup(input string) []Suggestion { shellAliasesMu.RUnlock() if input == "" { - return nil + return topLevelSuggestions("", aliases) } tokens := Tokenize(input) if len(tokens) == 1 && tokens[0] == "" { - return nil + return topLevelSuggestions("", aliases) } // NOTE: remember that wrapper already check if we type nothing, but I just want to make sure // in the future, maybe we will write unit test or using Lookup in another module @@ -277,7 +277,7 @@ func topLevelSuggestions(query string, aliases map[string]string) []Suggestion { for name, target := range aliases { if !seen[name] && (query == "" || HasPrefix(name, query)) { results = append(results, Suggestion{ - Cmd: target, Desc: "alias: " + name, Icon: "root", + Cmd: name, Desc: target, Icon: "alias", }) seen[name] = true } @@ -299,7 +299,11 @@ func topLevelSuggestions(query string, aliases map[string]string) []Suggestion { } } if match { - results = append(results, Suggestion{Cmd: name, Desc: spec.Description, Icon: name}) + icon := spec.Icon + if icon == "" { + icon = name + } + results = append(results, Suggestion{Cmd: name, Desc: spec.Description, Icon: icon}) seen[name] = true } } @@ -307,7 +311,7 @@ func topLevelSuggestions(query string, aliases map[string]string) []Suggestion { for name := range pathCmds { if !seen[name] && (query == "" || HasPrefix(name, query)) { results = append(results, Suggestion{ - Cmd: name, Desc: "system command", Icon: "root", + Cmd: name, Desc: "system command", Icon: "system", }) } } diff --git a/commands/core/spec.go b/commands/core/spec.go index 1a4d345..c81fa8b 100644 --- a/commands/core/spec.go +++ b/commands/core/spec.go @@ -7,6 +7,7 @@ type Spec struct { Name string Aliases []string Description string + Icon string Subcommands []Subcommand Options []Option Generator GeneratorFunc @@ -18,6 +19,7 @@ type Subcommand struct { Name string Aliases []string Description string + Icon string Subcommands []Subcommand Options []Option Generator GeneratorFunc diff --git a/config/config.go b/config/config.go index d7999f0..6031805 100644 --- a/config/config.go +++ b/config/config.go @@ -39,9 +39,11 @@ type CoreConfig struct { } type UIConfig struct { - GhostText bool `toml:"ghost-text"` - MaxSuggestions int `toml:"max-suggestions"` - MaxHeight int `toml:"max-height"` + Style string `toml:"style"` + GhostText bool `toml:"ghost-text"` + MaxSuggestions int `toml:"max-suggestions"` + MaxHeight int `toml:"max-height"` + NerdFonts bool `toml:"nerd-fonts"` } type GitConfig struct { diff --git a/config/defaults.go b/config/defaults.go index cd7bfd4..81fbced 100644 --- a/config/defaults.go +++ b/config/defaults.go @@ -11,9 +11,11 @@ func DefaultConfig() *Config { Debug: false, }, UI: UIConfig{ + Style: "modern", GhostText: true, MaxSuggestions: 100, MaxHeight: 15, + NerdFonts: true, }, Git: GitConfig{ FilterActiveBranch: true, diff --git a/integration/icons.go b/integration/icons.go new file mode 100644 index 0000000..76b4197 --- /dev/null +++ b/integration/icons.go @@ -0,0 +1,54 @@ +package integration + +import "strings" + +var iconMap = map[string]string{ + "git": "󰊢", + "docker": "", + "docker-compose": "", + "go": "", + "golang": "", + "python": "", + "python3": "", + "pip": "", + "node": "", + "npm": "", + "npx": "", + "bun": "", + "yarn": "", + "rust": "", + "cargo": "", + "java": "", + "mvn": "", + "gradle": "", + "nvim": "", + "vim": "", + "vi": "", + "cd": "", + "ls": "", + "eza": "", + "tree": "", + "pwd": "", + "cat": "", + "less": "", + "more": "", + "bat": "", + "grep": "", + "ripgrep": "", + "find": "", + "alias": "", + "history": "", + "system": "", + "root": "", +} + +func lookupIcon(key string) string { + key = strings.ToLower(strings.TrimSpace(key)) + if icon, ok := iconMap[key]; ok { + return icon + } + if len(key) > 0 && key[0] >= '0' && key[0] <= '9' { + return "" + } + return "❯" +} diff --git a/integration/overlay.go b/integration/overlay.go index d016a5a..46f3cfd 100644 --- a/integration/overlay.go +++ b/integration/overlay.go @@ -10,12 +10,13 @@ import ( "github.com/charmbracelet/lipgloss" "github.com/versenilvis/iris/commands/core" + "github.com/versenilvis/iris/config" "github.com/versenilvis/iris/logger" "golang.org/x/term" ) const ( - boxWidth = 72 + boxWidth = 76 // total visual width, corners included maxItems = 6 ) @@ -130,6 +131,36 @@ func ComputeCursorCol(data []byte) int { return col } +type Theme struct { + Border lipgloss.Color + Accent lipgloss.Color + Muted lipgloss.Color + Text lipgloss.Color + TextSel lipgloss.Color + Match lipgloss.Color + Desc lipgloss.Color + DescSel lipgloss.Color + SelBg lipgloss.Color + ScrollInfo lipgloss.Color +} + +var currentTheme = Theme{ + Border: lipgloss.Color("#a277ff"), + Accent: lipgloss.Color("#61ffca"), + Muted: lipgloss.Color("#6d6a7f"), + Text: lipgloss.Color("#edecee"), + TextSel: lipgloss.Color("#ffffff"), + Match: lipgloss.Color("#61ffca"), + Desc: lipgloss.Color("#9692a8"), + DescSel: lipgloss.Color("#edecee"), + SelBg: lipgloss.Color("#3d375e"), + ScrollInfo: lipgloss.Color("#a277ff"), +} + +func SetTheme(t Theme) { + currentTheme = t +} + type Overlay struct { mu sync.Mutex Visible bool @@ -151,34 +182,13 @@ func (o *Overlay) SetPromptLen(l int) { } } -var ( - selBgColor = lipgloss.Color("#44475A") - - iconStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#BD93F9")) - titleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#F8F8F2")) - descStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#6272A4")) - - iconSel = iconStyle.Background(selBgColor) - titleSel = titleStyle.Background(selBgColor) - descSel = descStyle.Background(selBgColor) - padSel = lipgloss.NewStyle().Background(selBgColor) - - borderColor = lipgloss.Color("#6272A4") - borderStyle = lipgloss.NewStyle().Foreground(borderColor) -) - func NewOverlay() *Overlay { - return &Overlay{ - Visible: false, - Cursor: 0, - StartIdx: 0, - } + return &Overlay{Visible: false, Cursor: 0, StartIdx: 0} } func (o *Overlay) UpdateItems(items []core.Suggestion) { o.mu.Lock() defer o.mu.Unlock() - o.Items = items o.Visible = len(o.Items) > 0 o.Cursor = 0 @@ -186,8 +196,14 @@ func (o *Overlay) UpdateItems(items []core.Suggestion) { } func fixedWidth(s string, width int) string { + if width <= 0 { + return "" + } runes := []rune(s) if len(runes) > width { + if width == 1 { + return "…" + } return string(runes[:width-1]) + "…" } if len(runes) < width { @@ -205,7 +221,6 @@ func (o *Overlay) RenderGhostText(buffer string, userNavigated bool) string { } var s strings.Builder - ghostText := "" if !userNavigated && buffer != "" { topCmd := o.Items[0].Cmd @@ -218,13 +233,10 @@ func (o *Overlay) RenderGhostText(buffer string, userNavigated bool) string { if padLen < 0 { padLen = 0 } - - // add extra padding to erase any stray characters left by fast backspaces - // before the debounce timer fired, 10 spaces is safe and won't hit right prompts padLen += 10 if ghostText != "" || padLen > 0 { - s.WriteString("\0337") // save cursor at prompt + s.WriteString("\0337") if ghostText != "" { s.WriteString("\033[90m") s.WriteString(ghostText) @@ -233,13 +245,40 @@ func (o *Overlay) RenderGhostText(buffer string, userNavigated bool) string { if padLen > 0 { s.WriteString(strings.Repeat(" ", padLen)) } - s.WriteString("\0338") // restore cursor back to prompt + s.WriteString("\0338") o.LastGhostLen = len(ghostText) } return s.String() } +func renderMatchedTitle(title, typed string, selected bool, w int) string { + t := currentTheme + textColor := t.Text + if selected { + textColor = t.TextSel + } + + base := lipgloss.NewStyle().Foreground(textColor) + match := lipgloss.NewStyle().Foreground(t.Match).Bold(true) + if selected { + base = base.Background(t.SelBg) + match = match.Background(t.SelBg) + } + + display := fixedWidth(title, w) + + if typed == "" || !strings.HasPrefix(strings.ToLower(display), strings.ToLower(typed)) { + return base.Render(display) + } + + matchLen := len(typed) + if matchLen > len(display) { + matchLen = len(display) + } + return match.Render(display[:matchLen]) + base.Render(display[matchLen:]) +} + func (o *Overlay) Render() string { return o.draw() } @@ -252,6 +291,10 @@ func (o *Overlay) draw() string { return "" } + t := currentTheme + border := lipgloss.NewStyle().Foreground(t.Border) + scrollStyle := lipgloss.NewStyle().Foreground(t.ScrollInfo) + var s strings.Builder s.WriteString("\033[?7l") @@ -278,7 +321,6 @@ func (o *Overlay) draw() string { } scrolloffUp := 1 - scrolloffDown := 0 if windowSize <= 3 { scrolloffUp = 0 } @@ -286,8 +328,8 @@ func (o *Overlay) draw() string { if o.Cursor < o.StartIdx+scrolloffUp { o.StartIdx = o.Cursor - scrolloffUp } - if o.Cursor >= o.StartIdx+windowSize-scrolloffDown { - o.StartIdx = o.Cursor - windowSize + scrolloffDown + 1 + if o.Cursor >= o.StartIdx+windowSize { + o.StartIdx = o.Cursor - windowSize + 1 } if o.StartIdx < 0 { o.StartIdx = 0 @@ -317,6 +359,12 @@ func (o *Overlay) draw() string { } } + inner := boxWidth - 2 // width between the two border pipes/corners + + style := strings.ToLower(config.Get().UI.Style) + 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") @@ -326,14 +374,37 @@ func (o *Overlay) draw() string { if len(o.Items) > windowSize { scrollInfo = fmt.Sprintf(" %d/%d ", o.Cursor+1, len(o.Items)) } + leftDash := 3 + if isClassic && scrollInfo != "" { + leftDash = (inner - len(scrollInfo)) / 2 + } + rightDash := inner - leftDash - len(scrollInfo) + if scrollInfo == "" { + leftDash = 0 + rightDash = inner + } + fmt.Fprintf(&s, "%s%s%s%s%s", + border.Render("╭"), + border.Render(strings.Repeat("─", leftDash)), + scrollStyle.Render(scrollInfo), + border.Render(strings.Repeat("─", rightDash)), + border.Render("╮"), + ) - borderWidth := boxWidth - len(scrollInfo) - topBorder := "╭" + strings.Repeat("─", borderWidth/2) + scrollInfo + strings.Repeat("─", boxWidth-borderWidth/2-len(scrollInfo)) + "╮" - s.WriteString(borderStyle.Render(topBorder)) - - iconW := 6 - descW := 22 - titleW := boxWidth - iconW - descW - 3 + // left and right side border with item rows + descW := 24 + padGap := 2 + markerW := 1 + iconW := 2 + if isClassic { + iconW = 0 + } + sidePad := 1 + titleW := inner - sidePad*2 - markerW - 1 - iconW + if iconW > 0 { + titleW-- + } + titleW = titleW - padGap - descW for i := start; i < end; i++ { s.WriteString("\0338") @@ -342,33 +413,139 @@ func (o *Overlay) draw() string { moveToTarget() it := o.Items[i] - rawIcon := fixedWidth(it.Icon, iconW) - rawTitle := fixedWidth(it.Cmd, titleW) - rawDesc := fixedWidth(it.Desc, descW) + selected := i == o.Cursor - left := borderStyle.Render("│") - right := borderStyle.Render("│") + left := border.Render("│") + right := border.Render("│") - if i == o.Cursor { - icon := iconSel.Render(" " + rawIcon + " ") - title := titleSel.Render(rawTitle) - pad := padSel.Render(" ") - desc := descSel.Render(rawDesc) - fmt.Fprintf(&s, "%s%s%s%s%s%s", left, icon, pad, title, desc, right) - } else { - icon := iconStyle.Render(" " + rawIcon + " ") - title := titleStyle.Render(rawTitle) - desc := descStyle.Render(rawDesc) - fmt.Fprintf(&s, "%s%s %s%s%s", left, icon, title, desc, right) + bg := lipgloss.NewStyle() + if selected { + bg = bg.Background(t.SelBg) } + + marker := " " + markerStyle := bg.Foreground(t.Muted) + if selected { + marker = "▶" + markerStyle = bg.Foreground(t.Accent).Bold(true) + } + + iconGlyph := lookupIcon(it.Icon) + iconColor := t.Muted + if selected { + iconColor = t.Accent + } + iconStr := bg.Foreground(iconColor).Render(fixedWidth(iconGlyph, iconW)) + + title := renderMatchedTitle(it.Cmd, o.TypedQuery, selected, titleW) + + descColor := t.Desc + if selected { + descColor = t.DescSel + } + + var desc string + if isClassic { + if it.Icon == "alias" { + desc = bg.Foreground(descColor).Render(fixedWidth("alias: "+it.Desc, descW)) + } else { + desc = bg.Foreground(descColor).Render(fixedWidth(it.Desc, descW)) + } + } 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 := descW - tw - 1 + if rem < 0 { + rem = 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 := descW - tw + if rem < 0 { + rem = 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 := descW - tw + if rem < 0 { + rem = 0 + } + desc = tag + bg.Render(strings.Repeat(" ", rem)) + default: + desc = bg.Foreground(descColor).Render(fixedWidth(it.Desc, descW)) + } + } + + iconSection := "" + if iconW > 0 { + iconSection = iconStr + bg.Render(" ") + } + + fmt.Fprintf(&s, "%s%s%s%s%s%s%s%s%s%s", + left, + bg.Render(" "), + markerStyle.Render(marker), + bg.Render(" "), + iconSection, + title, + bg.Render(strings.Repeat(" ", padGap)), + desc, + bg.Render(" "), + right, + ) } + // bottom side border with footer shortcut hints s.WriteString("\0338") fmt.Fprintf(&s, "\033[%dB", windowSize+2) s.WriteString("\033[2K") moveToTarget() - bottomBorder := "╰" + strings.Repeat("─", boxWidth) + "╯" - s.WriteString(borderStyle.Render(bottomBorder)) + + footerInfo := "" + if !isClassic { + keyStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#a277ff")).Bold(true) + tabKey := keyStyle.Render("") + ctrlRKey := keyStyle.Render("") + acceptText := lipgloss.NewStyle().Foreground(t.ScrollInfo).Render(" Accept") + modeText := lipgloss.NewStyle().Foreground(t.ScrollInfo).Render(" Mode") + footerInfo = fmt.Sprintf(" %s%s • %s%s ", tabKey, acceptText, ctrlRKey, modeText) + } + + footerRunes := lipgloss.Width(footerInfo) + rightDash = 2 + leftDash = inner - footerRunes - rightDash + if footerInfo == "" { + leftDash = 0 + rightDash = inner + } + if leftDash < 0 { + leftDash = 0 + } + fmt.Fprintf(&s, "%s%s%s%s%s", + border.Render("╰"), + border.Render(strings.Repeat("─", leftDash)), + footerInfo, + border.Render(strings.Repeat("─", rightDash)), + border.Render("╯"), + ) s.WriteString("\0338") s.WriteString("\033[?7h") diff --git a/root/config_cmd.go b/root/config_cmd.go index e34b573..7ba1d47 100644 --- a/root/config_cmd.go +++ b/root/config_cmd.go @@ -51,6 +51,12 @@ mode = "last" debug = false [ui] +# visual style: "modern" (icons, category pills, shortcut footer) or "classic" (minimalist, centered number, no icons) +style = "modern" + +# enable Nerd Fonts icons in overlay menu +nerd-fonts = true + # enable inline ghost text ghost-text = true diff --git a/root/init.go b/root/init.go index 93a81d8..386126d 100644 --- a/root/init.go +++ b/root/init.go @@ -175,6 +175,12 @@ mode = "last" debug = false [ui] +# visual style: "modern" (icons, category pills, shortcut footer) or "classic" (minimalist, centered number, no icons) +style = "modern" + +# enable Nerd Fonts icons in overlay menu +nerd-fonts = true + # enable inline ghost text ghost-text = true diff --git a/root/suggestions.go b/root/suggestions.go index 88c342d..0b89594 100644 --- a/root/suggestions.go +++ b/root/suggestions.go @@ -1,7 +1,6 @@ package root import ( - "fmt" "strings" "github.com/versenilvis/iris/commands/core" @@ -17,11 +16,8 @@ func MergeResults(query string, mode string) []core.Suggestion { deduped := []core.Suggestion{} // always call lookup to scan aliases and get spec suggestions - var cmdResults []core.Suggestion - if query != "" { - logger.Debugf("Merge Calling Lookup for '%s'", query) - cmdResults = core.Lookup(query) - } + logger.Debugf("Merge Calling Lookup for '%s'", query) + cmdResults := core.Lookup(query) // search history if in history mode var histResults []integration.HistResult @@ -53,8 +49,8 @@ func MergeResults(query string, mode string) []core.Suggestion { for _, h := range histResults { addSuggestion(core.Suggestion{ Cmd: h.Cmd, - Desc: " history", - Icon: fmt.Sprintf("%d", h.ID), + Desc: "history", + Icon: "history", }) } for _, s := range cmdResults { diff --git a/root/wrapper.go b/root/wrapper.go index 5afbf9e..15e6f94 100644 --- a/root/wrapper.go +++ b/root/wrapper.go @@ -323,6 +323,15 @@ func runWrapper() { continue } + if query == "" { + bufferMu.Lock() + naiveBuffer = "" + cursorOffset = 0 + bufferMu.Unlock() + writeStdout([]byte(overlay.ClearAndDisable())) + continue + } + // sync local buffer with actual command line bufferMu.Lock() naiveBuffer = query @@ -388,7 +397,7 @@ func runWrapper() { var b strings.Builder if !navCopy { - if bufCopy == "" { + if bufCopy == "" && !overlay.Visible { writeStdout([]byte(overlay.ClearAndDisable())) return } @@ -722,6 +731,7 @@ func runWrapper() { } userNavigated.Store(false) overlay.UserNavigated = false + overlay.Visible = true shouldOverlayDraw = true // enter: enter behavior is a bit different from tab suggestions in code editor // I want it to execute the command anyway and ignore the suggestions @@ -808,7 +818,8 @@ func runWrapper() { case 127, 0x08: // backspace: remove character bufferMu.Lock() - if len(naiveBuffer) > 0 { + wasEmpty := len(naiveBuffer) == 0 + if !wasEmpty { runes := []rune(naiveBuffer) if cursorOffset <= 0 { if len(runes) > 0 { @@ -825,11 +836,26 @@ func runWrapper() { } } } + isEmptyNow := len(naiveBuffer) == 0 bufferMu.Unlock() + + if wasEmpty { + if overlay.Visible { + writeStdout([]byte(overlay.ClearAndDisable())) + } + userNavigated.Store(false) + continue + } + if isEmptyNow { + writeStdout([]byte(overlay.ClearAndDisable())) + userNavigated.Store(false) + continue + } shouldOverlayDraw = true userNavigated.Store(false) case 0x17: // ctrl+w: delete the last word in the buffer bufferMu.Lock() + wasEmpty := len(naiveBuffer) == 0 trimBuf := strings.TrimRight(naiveBuffer, " ") lastSpace := strings.LastIndex(trimBuf, " ") if lastSpace >= 0 { @@ -838,7 +864,21 @@ func runWrapper() { naiveBuffer = "" } cursorOffset = 0 + isEmptyNow := len(naiveBuffer) == 0 bufferMu.Unlock() + + if wasEmpty { + if overlay.Visible { + writeStdout([]byte(overlay.ClearAndDisable())) + } + userNavigated.Store(false) + continue + } + if isEmptyNow { + writeStdout([]byte(overlay.ClearAndDisable())) + userNavigated.Store(false) + continue + } shouldOverlayDraw = true userNavigated.Store(false) case 0x0c: // ctrl+l: clear screen but keep buffer and redraw menu diff --git a/tests/core/lookup_test.go b/tests/core/lookup_test.go index 80c0d8a..5506f7c 100644 --- a/tests/core/lookup_test.go +++ b/tests/core/lookup_test.go @@ -51,7 +51,7 @@ func TestLookup(t *testing.T) { if tt.mustContain != "" { found := false for _, r := range results { - if strings.Contains(r.Cmd, tt.mustContain) { + if strings.Contains(r.Cmd, tt.mustContain) || strings.Contains(r.Desc, tt.mustContain) { found = true break }