okami.mom/albedo

git clone https://git.nkp.pet/okami.mom/albedo

dawntui: sessions search back to substring match per word, listed order1697fee

main
4.4 KiB199 linesraw
1package tui
2
3import (
4	"slices"
5	"strings"
6
7	"charm.land/bubbles/v2/textinput"
8	tea "charm.land/bubbletea/v2"
9	"github.com/charmbracelet/x/ansi"
10)
11
12type PickerItem struct {
13	ID     string
14	Label  string
15	Detail string
16	// Note is what a detail pane says about the item, where a list has one.
17	Note string
18}
19
20type PickerSelectMsg struct {
21	ID string
22}
23
24type PickerCancelMsg struct{}
25
26type PickerModel struct {
27	Title       string
28	Items       []PickerItem
29	Filtered    []PickerItem
30	SearchInput textinput.Model
31	Cursor      int
32	Width       int
33	Height      int
34	WithSearch  bool
35}
36
37func NewPickerModel(title string, items []PickerItem, withSearch bool, initialSelection string) PickerModel {
38	ti := newTextInput()
39	ti.Placeholder = ""
40	ti.Focus()
41	ti.Prompt = ""
42
43	m := PickerModel{
44		Title:       title,
45		Items:       items,
46		WithSearch:  withSearch,
47		SearchInput: ti,
48	}
49	m.applyFilter()
50
51	if initialSelection != "" {
52		if i := slices.IndexFunc(m.Filtered, func(it PickerItem) bool { return it.ID == initialSelection }); i >= 0 {
53			m.Cursor = i
54		}
55	}
56
57	return m
58}
59
60// Highlighted returns the item under the cursor.
61func (m PickerModel) Highlighted() (PickerItem, bool) {
62	if m.Cursor >= 0 && m.Cursor < len(m.Filtered) {
63		return m.Filtered[m.Cursor], true
64	}
65	return PickerItem{}, false
66}
67
68func (m *PickerModel) SetSize(width, height int) {
69	m.Width = width
70	m.Height = height
71	m.SearchInput.SetWidth(max(1, width-8))
72}
73
74// applyFilter keeps the items whose id, label and detail contain every word
75// of the search, in the order they were listed.
76func (m *PickerModel) applyFilter() {
77	item, ok := m.Highlighted()
78	old := ""
79	if ok {
80		old = item.ID
81	}
82	tokens := strings.Fields(strings.ToLower(m.SearchInput.Value()))
83	m.Filtered = nil
84	for _, item := range m.Items {
85		haystack := strings.ToLower(item.ID + " " + item.Label + " " + item.Detail)
86		if !slices.ContainsFunc(tokens, func(token string) bool { return !strings.Contains(haystack, token) }) {
87			m.Filtered = append(m.Filtered, item)
88		}
89	}
90	m.Cursor = max(0, slices.IndexFunc(m.Filtered, func(item PickerItem) bool { return item.ID == old }))
91}
92
93// selectableRows is a bottom-anchored list window with the selected row on
94// the selection surface.
95func selectableRows(lines []string, selected, height, limit, width int) string {
96	if height <= 0 {
97		height = 24
98	}
99	if limit <= 0 {
100		limit = height
101	}
102	available := max(1, min(limit, height-8))
103	first := min(max(0, selected-available+1), max(0, len(lines)-available))
104	if len(lines) == 0 {
105		return DefaultStyles.Faint.Render("no matches")
106	}
107	var b strings.Builder
108	bar := selectBar() + " "
109	for i := first; i < min(len(lines), first+available); i++ {
110		lead := "  "
111		if i == selected {
112			lead = bar
113		}
114		line := lead + lines[i]
115		if width > 0 {
116			line = ansi.Truncate(line, width, "…")
117		}
118		if i == selected {
119			line = selectedLine(line, width)
120		}
121		if i > first {
122			b.WriteByte('\n')
123		}
124		b.WriteString(line)
125	}
126	return b.String()
127}
128
129func pickerRow(item PickerItem) string {
130	line := item.Label
131	if item.Detail != "" {
132		line += DefaultStyles.Faint.Render("  " + item.Detail)
133	}
134	return line
135}
136
137func (m PickerModel) Init() tea.Cmd {
138	if m.WithSearch {
139		return textinput.Blink
140	}
141	return nil
142}
143
144func (m PickerModel) Update(msg tea.Msg) (PickerModel, tea.Cmd) {
145	var cmd tea.Cmd
146
147	switch msg := msg.(type) {
148	case tea.KeyPressMsg:
149		switch msg.String() {
150		case "esc", "ctrl+c", "ctrl+d":
151			return m, func() tea.Msg { return PickerCancelMsg{} }
152		case "enter":
153			if item, ok := m.Highlighted(); ok {
154				return m, func() tea.Msg { return PickerSelectMsg{ID: item.ID} }
155			}
156			return m, nil
157		case "up", "ctrl+p":
158			if m.Cursor > 0 {
159				m.Cursor--
160			}
161			return m, nil
162		case "down", "ctrl+n":
163			if m.Cursor < len(m.Filtered)-1 {
164				m.Cursor++
165			}
166			return m, nil
167		}
168	}
169
170	if m.WithSearch {
171		var tiCmd tea.Cmd
172		m.SearchInput, tiCmd = m.SearchInput.Update(msg)
173		m.applyFilter()
174		cmd = tea.Batch(cmd, tiCmd)
175	}
176
177	return m, cmd
178}
179
180func (m PickerModel) View() string {
181	var b strings.Builder
182	if m.Title != "" {
183		b.WriteString(m.Title)
184		b.WriteByte('\n')
185	}
186	lines := make([]string, len(m.Filtered))
187	for i, item := range m.Filtered {
188		lines[i] = pickerRow(item)
189	}
190	b.WriteString(selectableRows(lines, m.Cursor, m.Height, m.Height, m.Width))
191	b.WriteByte('\n')
192	if m.WithSearch {
193		b.WriteString(promptLead())
194		b.WriteString(m.SearchInput.View())
195	}
196	b.WriteByte('\n')
197	b.WriteString(keyHints(hint{"↑↓", "select"}, hint{"enter", "choose"}, hint{"esc", "cancel"}))
198	return b.String()
199}