package panes import ( "fmt" "strings" "unicode/utf8" "charm.land/bubbles/v2/textinput" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" "github.com/charmbracelet/x/ansi" "git.kebcloud.no/keb/lazysecrets/internal/match" "git.kebcloud.no/keb/lazysecrets/internal/ui/styles" ) // Item adapts a value for display in a List. type Item[T any] struct { ID func(T) string // stable identity, used to detect selection changes Filter func(T) string // text matched by the filter, and highlighted // FilterExtra, if set, is matched too but not shown highlighted, for // text the row doesn't show (e.g. the provider of a grouped source). FilterExtra func(T) string Render func(st styles.Styles, v T, width int) string } // List is a scrollable, filterable list pane with loading and error states. type List[T any] struct { Title string // Tabs, when there are several, are shown after the title; Tab is the // active one. Tabs []string Tab int Empty string // shown when loaded with no items Loading bool Err error // Header lines (a logo) go above the items, when the pane has room // for them and there's no filter. Header []string item Item[T] items []T visible []int // indices into items that match the filter, best first hits [][]int // per visible item: matched rune indices in its Filter text cursor int // index into visible offset int // first visible row w, h int // outer size including border input textinput.Model filtering bool } func NewList[T any](title string, item Item[T]) List[T] { in := textinput.New() in.Prompt = "/" return List[T]{Title: title, Empty: "nothing here", item: item, input: in} } // SetRender changes how items are drawn, e.g. when the pane switches to // showing versions. func (l *List[T]) SetRender(render func(st styles.Styles, v T, width int) string) { l.item.Render = render } // SetItems replaces the contents, keeping the cursor on the same item if it // is still present. func (l *List[T]) SetItems(items []T) { prev := l.SelectedID() l.items = items l.Loading = false l.Err = nil l.applyFilter(prev) } // Reset clears items, error, filter and cursor. func (l *List[T]) Reset() { l.items, l.visible = nil, nil l.cursor, l.offset = 0, 0 l.Loading, l.Err = false, nil l.filtering = false l.input.Reset() } func (l *List[T]) SetError(err error) { l.Reset() l.Err = err } // Refresh marks the pane as loading while keeping the current items and // selection on screen until SetItems replaces them. func (l *List[T]) Refresh() { l.Loading = true l.Err = nil } func (l *List[T]) SetLoading() { l.Reset() l.Loading = true } func (l List[T]) Selected() (T, bool) { if l.cursor < 0 || l.cursor >= len(l.visible) { var zero T return zero, false } return l.items[l.visible[l.cursor]], true } // SelectedID is "" when nothing is selected. func (l List[T]) SelectedID() string { v, ok := l.Selected() if !ok { return "" } return l.item.ID(v) } func (l List[T]) Len() int { return len(l.visible) } // Width is the pane's outer width. func (l List[T]) Width() int { return l.w } // Items returns all items, ignoring the filter. func (l List[T]) Items() []T { return l.items } // Select moves the cursor to the item with the given ID, if visible, and // reports whether it was. func (l *List[T]) Select(id string) bool { return l.SelectWhere(func(v T) bool { return l.item.ID(v) == id }) } // SelectWhere moves the cursor to the first visible item matching f. func (l *List[T]) SelectWhere(f func(T) bool) bool { for i, idx := range l.visible { if f(l.items[idx]) { l.cursor = i l.scroll() return true } } return false } func (l *List[T]) Move(delta int) { l.cursor = max(0, min(l.cursor+delta, len(l.visible)-1)) l.scroll() } func (l *List[T]) Top() { l.Move(-len(l.visible)) } func (l *List[T]) Bottom() { l.Move(len(l.visible)) } // SetSize sets the outer size of the pane, including its border. func (l *List[T]) SetSize(w, h int) { l.w, l.h = w, h l.input.SetWidth(max(1, w-4)) l.scroll() } // rows is the number of item rows that fit in the pane. func (l List[T]) rows() int { r := l.h - 2 if l.filtering { r-- } if l.headerShown() { r -= len(l.Header) + 1 } return max(0, r) } // headerMinRows is how many item rows the header leaves at least. const headerMinRows = 5 // headerShown reports whether Header fits: as wide as the pane allows, // leaving headerMinRows, a blank line under it, and no filter. func (l List[T]) headerShown() bool { if len(l.Header) == 0 || l.filtering || l.input.Value() != "" { return false } for _, line := range l.Header { if ansi.StringWidth(line) > l.w-3 { return false } } return l.h-2-len(l.Header)-1 >= headerMinRows } // Filtering reports whether the filter input has focus. func (l List[T]) Filtering() bool { return l.filtering } // HasFilter reports whether a filter query is applied. func (l List[T]) HasFilter() bool { return l.input.Value() != "" } func (l *List[T]) StartFilter() tea.Cmd { l.filtering = true l.scroll() return l.input.Focus() } // AcceptFilter leaves filter mode, keeping the query applied. func (l *List[T]) AcceptFilter() { l.filtering = false l.input.Blur() } // ClearFilter leaves filter mode and removes the query. func (l *List[T]) ClearFilter() { l.filtering = false l.input.Blur() prev := l.SelectedID() l.input.Reset() l.applyFilter(prev) } // UpdateFilter forwards a message to the filter input and re-filters. func (l *List[T]) UpdateFilter(msg tea.Msg) tea.Cmd { var cmd tea.Cmd prev := l.SelectedID() l.input, cmd = l.input.Update(msg) l.applyFilter(prev) return cmd } // applyFilter recomputes the visible items and puts the cursor back on the // item with ID keep if it is still visible. func (l *List[T]) applyFilter(keep string) { l.visible, l.hits = l.visible[:0], nil if q := l.input.Value(); q == "" { for i := range l.items { l.visible = append(l.visible, i) } } else { // The row's own text is the name; FilterExtra (a note, a provider) // is context, matched too but ranked lower. items := make([]match.Item, len(l.items)) for i, v := range l.items { text := l.item.Filter(v) items[i] = match.Item{Text: text, NameEnd: utf8.RuneCountInString(text)} if l.item.FilterExtra != nil { if extra := l.item.FilterExtra(v); extra != "" { items[i].Text += " " + extra } } } for _, h := range match.Rank(match.Parse(q), items) { var hits []int // the ones in the text the row shows for _, p := range h.Pos { if p < items[h.Index].NameEnd { hits = append(hits, p) } } l.visible = append(l.visible, h.Index) l.hits = append(l.hits, hits) } } l.cursor = max(0, min(l.cursor, len(l.visible)-1)) if keep != "" { l.Select(keep) } l.scroll() } // MarkMatches styles the characters at hits (rune indexes in the text // shown, without styling) in a styled string, keeping its own colours. func MarkMatches(styled string, hits []int, style lipgloss.Style) string { if len(hits) == 0 { return styled } return lipgloss.StyleRanges(styled, matchRanges(ansi.Strip(styled), hits, style)...) } // matchRanges turns matched rune indexes in plain into ranges of screen // cells, merging neighbours, for lipgloss.StyleRanges. func matchRanges(plain string, hits []int, style lipgloss.Style) []lipgloss.Range { runes := []rune(plain) cell := func(i int) int { return ansi.StringWidth(string(runes[:min(i, len(runes))])) } var out []lipgloss.Range for i := 0; i < len(hits); { j := i for j+1 < len(hits) && hits[j+1] == hits[j]+1 { j++ } if hits[i] < len(runes) { out = append(out, lipgloss.NewRange(cell(hits[i]), cell(hits[j]+1), style)) } i = j + 1 } return out } // RuneIndexes converts byte offsets in s to rune offsets. func RuneIndexes(s string, bytes []int) []int { out := make([]int, 0, len(bytes)) for _, b := range bytes { out = append(out, utf8.RuneCountInString(s[:b])) } return out } // highlight returns the rune indexes to highlight in a row's plain text: // the filter hits, shifted to where the Filter text appears in the row. // It returns nil if the row doesn't contain the Filter text verbatim. func (l List[T]) highlight(i int, plain string) []int { if i >= len(l.hits) { return nil } off := strings.Index(plain, l.item.Filter(l.items[l.visible[i]])) if off < 0 { return nil } shift := utf8.RuneCountInString(plain[:off]) out := make([]int, len(l.hits[i])) for j, h := range l.hits[i] { out[j] = h + shift } return out } // View renders the pane at the size given to SetSize. spin is the current // spinner frame. func (l List[T]) View(st styles.Styles, focused bool, spin string) string { rows := l.rows() inner := max(0, l.w-2) ts := TitleStyle(st, focused) title := ts.Render(l.Title) if len(l.Tabs) > 1 { for i, tab := range l.Tabs { sep := " - " if i == 0 { sep = " " } if i == l.Tab { title += ts.Render(sep) + ts.Underline(true).Render(tab) } else { title += ts.Render(sep) + st.Muted.Render(tab) } } } if q := l.input.Value(); q != "" && !l.filtering { title += ts.Render(" /" + q) } if l.Loading && len(l.items) > 0 { title += ts.Render(" " + spin) } var body []string if l.filtering { body = append(body, l.input.View()) } if l.headerShown() { for _, line := range l.Header { body = append(body, st.Notice.Render(" "+line)) } body = append(body, "") } switch { case l.Loading && len(l.items) == 0: body = append(body, " "+spin+" loading…") case l.Err != nil: for _, line := range strings.Split(ansi.Wrap(l.Err.Error(), inner-1, " -/"), "\n") { body = append(body, st.Error.Render(" "+line)) } case len(l.visible) == 0 && len(l.items) > 0: body = append(body, st.Muted.Render(" no matches")) case len(l.visible) == 0: body = append(body, st.Muted.Render(" "+l.Empty)) default: for i := l.offset; i < len(l.visible) && i < l.offset+rows; i++ { line := l.item.Render(st, l.items[l.visible[i]], inner-1) plain := ansi.Strip(line) hits := l.highlight(i, plain) if i == l.cursor { base := st.SelectedBlurred if focused { base = st.Selected } text := " " + line if len(hits) > 0 { for j := range hits { hits[j]++ // leading space } text = lipgloss.StyleRanges(text, matchRanges(" "+plain, hits, st.Match)...) } line = Highlight(ansi.Truncate(text, inner, "…"), base, inner) } else if len(hits) > 0 { // Style just the matches, keeping the row's own colours. line = " " + lipgloss.StyleRanges(line, matchRanges(plain, hits, st.Match)...) } else { line = " " + line } body = append(body, line) } } counter := "" if len(l.visible) > 0 { counter = fmt.Sprintf("%d of %d", l.cursor+1, len(l.visible)) } return BoxCounted(st, title, "", counter, body, l.w, l.h, focused) } // scroll keeps the cursor within the visible window. func (l *List[T]) scroll() { rows := l.rows() if rows <= 0 { return } if l.cursor < l.offset { l.offset = l.cursor } if l.cursor >= l.offset+rows { l.offset = l.cursor - rows + 1 } l.offset = max(0, min(l.offset, len(l.visible)-rows)) }