okami.mom/albedo

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

dawntui: hints shed least important first before losing labels; an empty list has a blank panefedc1be

main
8.1 KiB276 linesraw
1package tui
2
3import (
4	"slices"
5	"strings"
6
7	"charm.land/bubbles/v2/textinput"
8	tea "charm.land/bubbletea/v2"
9	"charm.land/lipgloss/v2"
10	"github.com/charmbracelet/x/ansi"
11)
12
13// listEntry is one entry of a list screen: how it draws, what the filter
14// matches, and what its detail pane says.
15type listEntry struct {
16	key     string // names the row across reloads, so the cursor stays on it
17	section string // rows of one section sit together under its rule
18	lead    string // styled state before the name, padded to the widest
19	name    string // what the filter underlines
20	desc    string // faint after the name, and searched
21	tag     string // styled, at the right edge
22	search  []string
23	detail  func(width int) []string // the pane's lines; nil leaves it blank
24}
25
26type listShown struct {
27	row  int
28	hits []int
29}
30
31// listView is the state every list screen shares: a filter that is always
32// live, the rows it leaves, the cursor, and how they draw. Keys that move
33// or type belong to it; keys that act on a row belong to the screen, which
34// asks for them first. Actions are therefore never bare letters.
35type listView struct {
36	input  textinput.Model
37	rows   []listEntry
38	shown  []listShown
39	Cursor int
40	height int
41	// Empty is said when there are no rows at all.
42	Empty string
43}
44
45// newFilter is a focused search field.
46func newFilter(placeholder string) textinput.Model {
47	input := newField()
48	input.Placeholder = placeholder
49	st := input.Styles()
50	st.Focused.Placeholder, st.Blurred.Placeholder = DefaultStyles.Faint, DefaultStyles.Faint
51	input.SetStyles(st)
52	input.Focus()
53	return input
54}
55
56func newListView(placeholder string) listView {
57	return listView{input: newFilter(placeholder)}
58}
59
60func (l *listView) setSize(width, height int) {
61	l.input.SetWidth(max(1, width-6))
62	l.height = height
63}
64
65// setRows replaces the rows and filters them again, keeping the cursor on
66// its row when that is still shown.
67func (l *listView) setRows(rows []listEntry) {
68	keep := ""
69	if r, ok := l.highlighted(); ok {
70		keep = r.key
71	}
72	l.rows = rows
73	l.refilter(keep)
74}
75
76// refilter ranks the rows against the filter. Rows rank within their
77// section and sections by their best row; an empty filter keeps the order
78// the rows came in.
79func (l *listView) refilter(keep string) {
80	words := searchWords(l.input.Value())
81	type scored struct {
82		shown listShown
83		rank  matchRank
84	}
85	var order []string
86	groups := map[string][]scored{}
87	for i, r := range l.rows {
88		rank, hits, ok := matchFields(words, append([]string{r.name, r.desc}, r.search...)...)
89		if !ok {
90			continue
91		}
92		if _, seen := groups[r.section]; !seen {
93			order = append(order, r.section)
94		}
95		groups[r.section] = append(groups[r.section], scored{listShown{i, hits}, rank})
96	}
97	if len(words) > 0 {
98		for _, g := range groups {
99			slices.SortStableFunc(g, func(a, b scored) int { return b.rank.compare(a.rank) })
100		}
101		slices.SortStableFunc(order, func(a, b string) int { return groups[b][0].rank.compare(groups[a][0].rank) })
102	}
103	l.shown = l.shown[:0]
104	for _, section := range order {
105		for _, s := range groups[section] {
106			l.shown = append(l.shown, s.shown)
107		}
108	}
109	l.Cursor = max(0, slices.IndexFunc(l.shown, func(s listShown) bool { return l.rows[s.row].key == keep }))
110}
111
112func (l listView) highlighted() (listEntry, bool) {
113	if l.Cursor < 0 || l.Cursor >= len(l.shown) {
114		return listEntry{}, false
115	}
116	return l.rows[l.shown[l.Cursor].row], true
117}
118
119func (l *listView) move(by int) {
120	l.Cursor = min(max(l.Cursor+by, 0), max(0, len(l.shown)-1))
121}
122
123// update takes the keys that move through the list and everything else
124// into the filter.
125func (l *listView) update(msg tea.Msg) tea.Cmd {
126	if key, ok := msg.(tea.KeyPressMsg); ok {
127		page := max(1, l.height/2)
128		if by, moves := map[string]int{"up": -1, "ctrl+p": -1, "down": 1, "ctrl+n": 1, "pgup": -page, "pgdown": page}[key.String()]; moves {
129			l.move(by)
130			return nil
131		}
132	}
133	before := l.input.Value()
134	var cmd tea.Cmd
135	l.input, cmd = l.input.Update(msg)
136	if l.input.Value() != before {
137		l.refilter("")
138	}
139	return cmd
140}
141
142func (l listView) filterLine() string { return promptLead() + l.input.View() }
143
144// frame is the screen's layout around this list and the highlighted row's
145// detail.
146func (l listView) frame(title, right, footer string) listFrame {
147	return listFrame{title: title, right: right, filter: l.filterLine(), list: l.list, pane: l.pane, footer: footer}
148}
149
150// list draws the rows under their section rules at exactly width × height.
151func (l listView) list(width, height int) []string {
152	if len(l.shown) == 0 {
153		note := "  no matches"
154		if len(l.rows) == 0 {
155			note = "  " + l.Empty
156		}
157		return scrollWindow([]string{DefaultStyles.Faint.Render(note)}, -1, width, height)
158	}
159	leadW, nameW := 0, 0
160	for _, s := range l.shown {
161		r := l.rows[s.row]
162		leadW = max(leadW, ansi.StringWidth(r.lead))
163		nameW = max(nameW, ansi.StringWidth(r.name))
164	}
165	nameW = min(nameW, max(12, width/3))
166
167	type line struct {
168		text  string
169		shown int // the row it draws, or -1 for a rule
170	}
171	var all []line
172	selectedAt := -1
173	for i, s := range l.shown {
174		if section := l.rows[s.row].section; section != "" && (i == 0 || l.rows[l.shown[i-1].row].section != section) {
175			run := 1
176			for i+run < len(l.shown) && l.rows[l.shown[i+run].row].section == section {
177				run++
178			}
179			if len(all) > 0 && height >= 10 {
180				all = append(all, line{shown: -1})
181			}
182			all = append(all, line{text: sectionRule(section, run, width), shown: -1})
183		}
184		if i == l.Cursor {
185			selectedAt = len(all)
186		}
187		all = append(all, line{shown: i})
188	}
189	return drawWindow(len(all), func(i int) string {
190		if all[i].shown < 0 {
191			return all[i].text
192		}
193		return l.row(l.shown[all[i].shown], all[i].shown == l.Cursor, width, leadW, nameW)
194	}, selectedAt, width, height)
195}
196
197func (l listView) row(s listShown, selected bool, width, leadW, nameW int) string {
198	r := l.rows[s.row]
199	bar, nameStyle := " ", lipgloss.NewStyle()
200	if selected {
201		bar, nameStyle = selectBar(), DefaultStyles.Bold
202	}
203	line := bar + " "
204	if leadW > 0 {
205		line += r.lead + strings.Repeat(" ", leadW-ansi.StringWidth(r.lead)+2)
206	}
207	line += markedCell(r.name, s.hits, nameW, nameStyle)
208	room := width - ansi.StringWidth(line) - 3
209	if r.tag != "" {
210		room -= ansi.StringWidth(r.tag) + 2
211	}
212	if r.desc != "" && room >= 4 {
213		line += DefaultStyles.Faint.Render("  " + ansi.Truncate(r.desc, room, "…"))
214	}
215	if r.tag != "" {
216		line += strings.Repeat(" ", max(2, width-ansi.StringWidth(line)-ansi.StringWidth(r.tag)-1)) + r.tag
217	}
218	if selected {
219		return selectedLine(line, width)
220	}
221	return line
222}
223
224// pane is the highlighted row's detail at exactly width × height.
225func (l listView) pane(width, height int) []string {
226	inner := max(1, width-2)
227	r, ok := l.highlighted()
228	switch {
229	case !ok && len(l.rows) == 0:
230		return paneBox(nil, inner, width, height)
231	case !ok:
232		return paneBox([]string{"", DefaultStyles.Muted.Render("nothing matches"), DefaultStyles.Faint.Render("Try fewer search terms")}, inner, width, height)
233	case r.detail == nil:
234		return paneBox(nil, inner, width, height)
235	}
236	return paneBox(r.detail(inner), inner, width, height)
237}
238
239// paneTitle opens a detail pane: the name in bold, a description under it,
240// and a rule.
241func paneTitle(name, description string, width int) []string {
242	var lines []string
243	for _, l := range svWrap(name, width, 2) {
244		lines = append(lines, DefaultStyles.Bold.Render(l))
245	}
246	for _, l := range svWrap(description, width, 3) {
247		if l != "" {
248			lines = append(lines, DefaultStyles.Muted.Render(l))
249		}
250	}
251	return append(lines, DefaultStyles.Decor.Render(strings.Repeat("─", width)))
252}
253
254// factRows is one labelled fact of a detail pane; a long value wraps under
255// itself.
256func factRows(label, value string, width int) []string {
257	const labelW = 10
258	var lines []string
259	for i, l := range svWrap(value, max(1, width-labelW), 4) {
260		cell := strings.Repeat(" ", labelW)
261		if i == 0 {
262			cell = DefaultStyles.Muted.Render(svCell(label, labelW, false))
263		}
264		lines = append(lines, cell+l)
265	}
266	return lines
267}
268
269// paneNote is faint explanatory text at the foot of a detail pane.
270func paneNote(text string, width int) []string {
271	var lines []string
272	for _, l := range svWrap(text, width, 6) {
273		lines = append(lines, DefaultStyles.Faint.Render(l))
274	}
275	return lines
276}