gitlink-cli/cmd/interactive/palette.go

306 lines
7.5 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package interactive
import (
"fmt"
"sort"
"strings"
"github.com/charmbracelet/bubbles/list"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
// paletteLevel tracks which level of the palette we are browsing.
type paletteLevel int
const (
levelGroup paletteLevel = iota // browsing command groups
levelCommand // browsing commands within a group
)
// PaletteResult holds the user's selection from the palette.
type PaletteResult struct {
Group string
Shortcut *common.Shortcut
}
// commandItem implements list.Item and list.DefaultItem.
type commandItem struct {
title string
description string
shortcut *common.Shortcut // nil for group items
group string // empty for group items
}
func (i commandItem) Title() string { return i.title }
func (i commandItem) Description() string { return i.description }
func (i commandItem) FilterValue() string { return i.title + " " + i.description }
// paletteModel is the command palette sub-component.
type paletteModel struct {
level paletteLevel
search textinput.Model
list list.Model
groups map[string][]*common.Shortcut
descs map[string]string
groupKeys []string
selected string // currently selected group name (when levelCommand)
result *PaletteResult
quitting bool
width int
height int
}
var paletteTitleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12"))
var selectedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("10")).Bold(true)
var paletteHelpStyle = lipgloss.NewStyle().Faint(true)
// newPaletteModel creates a new palette model.
func newPaletteModel(groups map[string][]*common.Shortcut, descs map[string]string, width, height int) paletteModel {
search := textinput.New()
search.Prompt = "> "
search.Placeholder = "Search..."
search.Focus()
search.Width = width - 4
// Sort group keys for deterministic ordering
keys := make([]string, 0, len(groups))
for k := range groups {
keys = append(keys, k)
}
sort.Strings(keys)
// Build group items
items := make([]list.Item, 0, len(keys))
for _, k := range keys {
desc := descs[k]
items = append(items, commandItem{
title: k,
description: desc,
})
}
delegate := list.NewDefaultDelegate()
l := list.New(items, delegate, width, height-4)
l.SetShowTitle(false)
l.SetShowStatusBar(false)
l.SetShowHelp(false)
l.SetShowFilter(false)
l.SetFilteringEnabled(false)
return paletteModel{
level: levelGroup,
search: search,
list: l,
groups: groups,
descs: descs,
groupKeys: keys,
width: width,
height: height,
}
}
// switchToCommands switches the list to show commands for the given group.
func (m *paletteModel) switchToCommands(group string) {
m.selected = group
m.level = levelCommand
shortcuts := m.groups[group]
items := make([]list.Item, 0, len(shortcuts))
for _, s := range shortcuts {
items = append(items, commandItem{
title: "+" + s.Name,
description: s.Description,
shortcut: s,
group: group,
})
}
m.list.SetItems(items)
m.list.ResetSelected()
m.search.SetValue("")
}
// backToGroups returns to the group list.
func (m *paletteModel) backToGroups() {
m.level = levelGroup
m.selected = ""
items := make([]list.Item, 0, len(m.groupKeys))
for _, k := range m.groupKeys {
items = append(items, commandItem{
title: k,
description: m.descs[k],
})
}
m.list.SetItems(items)
m.list.ResetSelected()
m.search.SetValue("")
}
// filterItems filters the current list items by the given query.
func (m *paletteModel) filterItems(query string) {
var items []list.Item
if m.level == levelGroup {
for _, k := range m.groupKeys {
desc := m.descs[k]
if matchesQuery(k, desc, query) {
items = append(items, commandItem{
title: k,
description: desc,
})
}
}
} else {
shortcuts := m.groups[m.selected]
for _, s := range shortcuts {
name := "+" + s.Name
if matchesQuery(name, s.Description, query) {
items = append(items, commandItem{
title: name,
description: s.Description,
shortcut: s,
group: m.selected,
})
}
}
}
if items == nil {
items = []list.Item{}
}
m.list.SetItems(items)
m.list.ResetSelected()
}
// matchesQuery checks if the query is a subsequence (fzy-style fuzzy match)
// of either the title or the description, case-insensitively. An empty query
// matches everything.
func matchesQuery(title, description, query string) bool {
return subsequenceMatch(query, title) || subsequenceMatch(query, description)
}
// subsequenceMatch returns true if every character of query appears in target
// in the same order (not necessarily contiguously), ignoring case. An empty
// query always matches.
func subsequenceMatch(query, target string) bool {
query = strings.ToLower(query)
target = strings.ToLower(target)
if query == "" {
return true
}
i := 0
for j := 0; j < len(target) && i < len(query); j++ {
if target[j] == query[i] {
i++
}
}
return i == len(query)
}
// Update handles messages for the palette.
func (m paletteModel) Update(msg tea.Msg) (paletteModel, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.Type {
case tea.KeyEsc:
if m.level == levelCommand {
m.backToGroups()
return m, nil
}
// levelGroup: signal the outer REPL to return to stateInput, not quit
m.quitting = true
return m, nil
case tea.KeyEnter:
selected := m.list.SelectedItem()
if selected == nil {
return m, nil
}
item := selected.(commandItem)
if m.level == levelGroup {
m.switchToCommands(item.title)
return m, nil
}
// levelCommand: set result
m.result = &PaletteResult{
Group: m.selected,
Shortcut: item.shortcut,
}
return m, nil
case tea.KeyUp, tea.KeyDown:
var cmd tea.Cmd
m.list, cmd = m.list.Update(msg)
return m, cmd
default:
// Forward to search input
var cmd tea.Cmd
m.search, cmd = m.search.Update(msg)
m.filterItems(m.search.Value())
return m, cmd
}
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
m.list.SetWidth(msg.Width)
m.list.SetHeight(msg.Height - 4)
m.search.Width = msg.Width - 4
}
return m, nil
}
// View renders the palette.
func (m paletteModel) View() string {
if m.quitting {
return ""
}
var title string
if m.level == levelGroup {
title = paletteTitleStyle.Render("Command Palette — Select a group")
} else {
title = paletteTitleStyle.Render(fmt.Sprintf("Command Palette — %s", m.selected))
}
searchView := m.search.View()
// Render list items manually for better control
var items strings.Builder
listItems := m.list.Items()
idx := m.list.Index()
for i, item := range listItems {
ci := item.(commandItem)
if i == idx {
items.WriteString(selectedStyle.Render(fmt.Sprintf(" %s", ci.title)))
if ci.description != "" {
items.WriteString(" ")
items.WriteString(paletteHelpStyle.Render(ci.description))
}
} else {
items.WriteString(fmt.Sprintf(" %s", ci.title))
if ci.description != "" {
items.WriteString(" ")
items.WriteString(paletteHelpStyle.Render(ci.description))
}
}
items.WriteString("\n")
}
if len(listItems) == 0 {
items.WriteString(paletteHelpStyle.Render(" No matches found"))
items.WriteString("\n")
}
help := paletteHelpStyle.Render("Enter select · Esc back · ↑↓ navigate · type to search")
return fmt.Sprintf("%s\n%s\n%s\n%s", title, searchView, items.String(), help)
}