okami.mom/albedo

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

dawntui: page_view docs on listFrame and listView, always-live filter, shared confirm, ctrl+r retry, gallery of every page state2ecf203

main
8.0 KiB277 linesraw
1package tui
2
3import (
4	"cmp"
5	"fmt"
6	"slices"
7	"strings"
8
9	"charm.land/lipgloss/v2"
10	"github.com/charmbracelet/x/ansi"
11)
12
13// setDoc takes a document and its rows, keeping the cursor on the row it was on.
14func (m *PageViewModel) setDoc(doc *PageDocument) {
15	m.Doc = doc
16	var rows []listEntry
17	if doc != nil {
18		rows = make([]listEntry, len(doc.Rows))
19		for i, row := range doc.Rows {
20			rows[i] = pageEntry(row)
21		}
22	}
23	m.setRows(rows)
24}
25
26// focusRow moves the cursor to row id when the list shows it.
27func (m *PageViewModel) focusRow(id string) {
28	if i := slices.IndexFunc(m.shown, func(s listShown) bool { return m.rows[s.row].key == id }); i >= 0 {
29		m.Cursor = i
30	}
31}
32
33// pageEntry is how a row reads in the list: its tone dot in front, its text,
34// its quiet #id at the edge, grouped under its badge.
35func pageEntry(row PageRow) listEntry {
36	tag := ""
37	if id := rowID(row); id != "" {
38		tag = DefaultStyles.Faint.Render(id)
39	}
40	return listEntry{
41		key:     row.ID,
42		section: row.Badge,
43		lead:    toneGlyph(row.Tone),
44		name:    row.Text,
45		tag:     tag,
46		search:  []string{row.ID, row.Badge},
47		detail:  func(width int) []string { return pageDetail(row, width) },
48	}
49}
50
51// toneGlyph is the dot a row wears for its tone; plain rows get a quiet one.
52func toneGlyph(tone PageTone) string {
53	switch tone {
54	case ToneWarning, ToneActive:
55		return toneStyle(tone).Render("●")
56	}
57	return DefaultStyles.Faint.Render("·")
58}
59
60// pageDetail is a row's pane: its text over its badge and id, then its
61// detail paragraphs wrapped to the pane.
62func pageDetail(row PageRow, width int) []string {
63	meta := row.Badge
64	if id := rowID(row); id != "" {
65		meta += " · " + id
66	}
67	lines := paneTitle(row.Text, meta, width)
68	for paragraph := range strings.SplitSeq(row.Detail, "\n\n") {
69		if paragraph = strings.TrimSpace(paragraph); paragraph != "" {
70			lines = append(lines, "")
71			lines = append(lines, strings.Split(ansi.Wrap(paragraph, width, " -"), "\n")...)
72		}
73	}
74	return lines
75}
76
77// hasDetail reports whether any row has a detail, the only case the pane shows.
78func (m PageViewModel) hasDetail() bool {
79	return m.Doc != nil && slices.ContainsFunc(m.Doc.Rows, func(r PageRow) bool { return r.Detail != "" })
80}
81
82// emptyText is what the list says when it has no rows.
83func (m PageViewModel) emptyText() string {
84	switch {
85	case m.Doc != nil:
86		return m.Doc.Empty
87	case m.Error != "":
88		return strings.TrimPrefix(m.Command, "/") + " did not load"
89	}
90	return "loading " + strings.TrimPrefix(m.Command, "/") + "…"
91}
92
93// toneStyle is the color a badge wears for its tone.
94func toneStyle(tone PageTone) lipgloss.Style {
95	switch tone {
96	case ToneActive:
97		return DefaultStyles.Success
98	case ToneWarning:
99		return DefaultStyles.Warning
100	case ToneMuted:
101		return DefaultStyles.Faint
102	}
103	return DefaultStyles.Muted
104}
105
106// rowID is the quiet #id a row shows, unless its id is its text.
107func rowID(row PageRow) string {
108	if row.ID != row.Text {
109		return "#" + row.ID
110	}
111	return ""
112}
113
114// pageNotice picks the wording for a finished action: the daemon's message
115// when it sent one, else the action's label.
116func pageNotice(msg pageActionExecutedMsg) string {
117	notice := fmt.Sprintf("%s completed", msg.Action.Label)
118	if msg.Message != "" {
119		return msg.Message
120	}
121	return notice
122}
123
124// View draws the rows beside the highlighted row's detail; a prompt or a
125// question takes the footer, and the filter steps aside while one is open.
126func (m PageViewModel) View() string {
127	width := cmp.Or(m.Width, 80)
128	lv := m.listView
129	lv.Empty = m.emptyText()
130	right := ""
131	if m.Doc != nil {
132		right = DefaultStyles.Faint.Render(m.Doc.Summary)
133	}
134	f := lv.frame(brand("albedo")+" "+DefaultStyles.Muted.Render(m.Command), right, m.footer(width))
135	if m.Mode != modeBrowse {
136		f.filter = ""
137	}
138	if !m.hasDetail() {
139		f.pane = nil
140	}
141	return f.view(m.Width, m.Height)
142}
143
144// prompt is the line above the keys where an action asks for what it needs,
145// blank while browsing or asking a question.
146func (m PageViewModel) prompt() string {
147	if m.Mode == modeActions {
148		labels := make([]string, len(m.Doc.Actions))
149		for i, action := range m.Doc.Actions {
150			labels[i] = action.Label
151			if i == m.ActionIndex {
152				labels[i] = "[" + action.Label + "]"
153			}
154		}
155		return " Actions: " + strings.Join(labels, " · ")
156	}
157	act := m.CurrentAction
158	if act == nil {
159		return ""
160	}
161	target := ""
162	if row := m.currentRow(); act.Row && row != nil {
163		if id := rowID(*row); id != "" {
164			target = " " + id
165		}
166	}
167	switch m.Mode {
168	case modeChoice:
169		var options []string
170		for i, opt := range m.fieldChoices() {
171			label := " " + opt + " "
172			if i == m.ChoiceIndex {
173				label = selectedLine(label, 0)
174			}
175			options = append(options, label)
176		}
177		return " " + DefaultStyles.Prompt.Render(act.Label+target) + " " + promptLead() + strings.Join(options, "")
178	case modeText:
179		field := cmp.Or(m.currentField().Label, act.Label)
180		return " " + DefaultStyles.Prompt.Render(act.Label+target+" · "+field) + " " + promptLead() + m.TextInput.View()
181	}
182	return ""
183}
184
185// footer is the prompt or question above the keys and one status: a failure,
186// a notice, or work in flight.
187func (m PageViewModel) footer(width int) string {
188	if m.Mode == modeConfirm {
189		return m.confirm.footer(width, m.Error)
190	}
191	status, urgent := m.status()
192	line := footerLine(width, m.hints(), status, urgent)
193	if prompt := m.prompt(); prompt != "" {
194		return prompt + "\n" + line
195	}
196	return line
197}
198
199// status is the footer's state beside the keys; an urgent one takes a row of
200// its own when the keys do not leave room for it.
201func (m PageViewModel) status() (string, bool) {
202	switch {
203	case m.Error != "":
204		return DefaultStyles.Error.Render(m.Error), true
205	case m.Busy:
206		return DefaultStyles.Busy.Render("working…"), true
207	case m.Notice != "":
208		return DefaultStyles.Warning.Render(m.Notice), true
209	}
210	return "", false
211}
212
213// hints is the keys for the mode the page is in, most important first.
214func (m PageViewModel) hints() []hint {
215	switch {
216	case m.Busy:
217		return []hint{{"esc", "back"}}
218	case m.Doc == nil:
219		return []hint{{"ctrl+r", "retry"}, {"esc", "back"}}
220	case m.Mode == modeText:
221		return []hint{{"enter", "save"}, {"esc", "cancel"}}
222	case m.Mode == modeChoice:
223		return []hint{{"←→", "choose"}, {"enter", "apply"}, {"esc", "cancel"}}
224	case m.Mode == modeActions:
225		return []hint{{"↑↓", "action"}, {"enter", "choose"}, {"esc", "cancel"}}
226	}
227	return m.browseHints()
228}
229
230// browseHints lists the row's chords with their labels, then the menu,
231// refresh, and esc last.
232func (m PageViewModel) browseHints() []hint {
233	var hints []hint
234	if len(m.rows) > 1 {
235		hints = append(hints, hint{"↑↓", "move"})
236	}
237	for _, act := range m.Doc.Actions {
238		if act.Key != "" && (!act.Row || m.currentRow() != nil) {
239			hints = append(hints, hint{act.Key, act.Label})
240		}
241	}
242	if len(m.Doc.Actions) > 0 {
243		hints = append(hints, hint{"ctrl+a", "actions"})
244	}
245	return append(hints, hint{"ctrl+r", "refresh"}, hint{"esc", "back"})
246}
247
248// fitHints is " " and the hints in room columns. A screen lists its hints
249// most important first and esc last. What the screen already says goes
250// first: moving, then the least important hints one at a time from the end,
251// always keeping esc, and only when three labelled hints no longer fit,
252// every label but the keys. Empty when not even the keys fit.
253func fitHints(hints []hint, room int) string {
254	keysOnly := make([]hint, len(hints))
255	for i, h := range hints {
256		keysOnly[i] = hint{key: h.key}
257		if h.key == "" {
258			keysOnly[i].does = h.does
259		}
260	}
261	still := slices.DeleteFunc(slices.Clone(hints), func(h hint) bool { return h.key == "↑↓" })
262	candidates := [][]hint{hints, still}
263	leaving := slices.IndexFunc(still, func(h hint) bool { return h.key == "esc" })
264	if leaving >= 0 {
265		staying := slices.Delete(slices.Clone(still), leaving, leaving+1)
266		for n := len(staying) - 1; n >= min(2, len(staying)); n-- {
267			candidates = append(candidates, append(slices.Clone(staying[:n]), still[leaving]))
268		}
269	}
270	candidates = append(candidates, keysOnly)
271	for _, candidate := range candidates {
272		if line := " " + keyHints(candidate...); len(candidate) > 0 && ansi.StringWidth(line) <= room {
273			return line
274		}
275	}
276	return ""
277}