okami.mom/albedo

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

dawnadd charm tui skillf7fcae1

main
5.3 KiB179 linesraw
1package main
2
3import (
4	"charm.land/bubbles/v2/help"
5	"charm.land/bubbles/v2/key"
6	"charm.land/bubbles/v2/textinput"
7	tea "charm.land/bubbletea/v2"
8	"charm.land/lipgloss/v2"
9	"example.com/charm-tui-worked/internal/display"
10	"fmt"
11	"strings"
12)
13
14type model struct {
15	list               selection
16	input              textinput.Model
17	help               help.Model
18	width, height, top int
19	confirm            *item // Captured target, not a later lookup through a moving cursor.
20	result             outcome
21}
22
23type keys struct{ up, down, search, accept, back, quit key.Binding }
24
25func binding(k, label string) key.Binding {
26	return key.NewBinding(key.WithKeys(k), key.WithHelp(k, label))
27}
28func (k keys) ShortHelp() []key.Binding {
29	return []key.Binding{k.up, k.down, k.search, k.accept, k.back, k.quit}
30}
31func (k keys) FullHelp() [][]key.Binding { return [][]key.Binding{k.ShortHelp()} }
32
33func (m *model) keys() keys {
34	k := keys{binding("up", "up"), binding("down", "down"), binding("/", "search"), binding("enter", "choose"), binding("esc", "cancel"), binding("q", "quit")}
35	if m.input.Focused() {
36		k.up.SetEnabled(false)
37		k.down.SetEnabled(false)
38		k.search.SetEnabled(false)
39		k.quit.SetEnabled(false)
40		k.accept = binding("enter", "browse")
41		k.back = binding("esc", "browse")
42	} else if m.confirm != nil {
43		k.up.SetEnabled(false)
44		k.down.SetEnabled(false)
45		k.search.SetEnabled(false)
46		k.quit.SetEnabled(false)
47		k.accept = binding("enter", "confirm")
48		k.back = binding("esc", "back")
49	} else {
50		_, ok := m.list.current()
51		k.accept.SetEnabled(ok)
52	}
53	return k
54}
55
56func newModel(items []item) *model {
57	input := textinput.New()
58	input.Prompt = "/ "
59	input.Placeholder = "filter servers"
60	input.SetVirtualCursor(true) // Avoid real-cursor coordinate translation here.
61	input.SetWidth(48)
62	return &model{list: newSelection(items), input: input, help: help.New(), width: 60, height: 12}
63}
64
65func (m *model) Init() tea.Cmd { return tea.RequestBackgroundColor }
66
67func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
68	switch msg := msg.(type) {
69	case tea.WindowSizeMsg:
70		m.width = min(72, max(0, msg.Width))
71		m.height = min(12, max(0, msg.Height))
72		m.input.SetWidth(max(1, m.width-3))
73		m.help.SetWidth(m.width)
74		m.clampWindow()
75		return m, nil
76	case tea.BackgroundColorMsg:
77		m.input.SetStyles(textinput.DefaultStyles(msg.IsDark()))
78		m.help.Styles = help.DefaultStyles(msg.IsDark())
79		return m, nil
80	case tea.KeyPressMsg:
81		if msg.String() == "ctrl+c" {
82			m.result = outcome{Interrupted: true}
83			return m, tea.Quit
84		}
85		// Modal input is consumed exactly once, even when this key closes it.
86		if m.confirm != nil {
87			switch msg.String() {
88			case "esc":
89				m.confirm = nil
90			case "enter":
91				m.result = outcome{Accepted: true, ID: m.confirm.ID}
92				return m, tea.Quit
93			}
94			return m, nil
95		}
96		k := m.keys()
97		// An editor owns printable keys. 'q', '/', '?', 'j' are text here.
98		if m.input.Focused() {
99			if key.Matches(msg, k.back, k.accept) {
100				m.input.Blur()
101				m.clampWindow()
102				return m, nil
103			}
104			return m, m.updateInput(msg)
105		}
106		switch {
107		case key.Matches(msg, k.quit, k.back):
108			return m, tea.Quit
109		case key.Matches(msg, k.search):
110			cmd := m.input.Focus()
111			m.clampWindow()
112			return m, cmd
113		case key.Matches(msg, k.up):
114			m.list.move(-1)
115		case key.Matches(msg, k.down):
116			m.list.move(1)
117		case key.Matches(msg, k.accept):
118			if row, ok := m.list.current(); ok {
119				m.confirm = &row
120			}
121		}
122		m.clampWindow()
123		return m, nil
124	}
125	// Paste results and cursor messages also need the child update path.
126	return m, m.updateInput(msg)
127}
128
129func (m *model) updateInput(msg tea.Msg) tea.Cmd {
130	before := m.input.Value()
131	var cmd tea.Cmd
132	m.input, cmd = m.input.Update(msg)
133	if m.input.Value() != before {
134		m.list.filter(m.input.Value())
135		m.clampWindow()
136	}
137	return cmd // Never lose child effects, including cursor or paste commands.
138}
139
140func (m *model) bodyHeight() int {
141	// Header has exactly two clipped lines; status has one, help is measured.
142	return display.BodyRows(m.height, 2, 1+lipgloss.Height(m.help.View(m.keys())))
143}
144func (m *model) clampWindow() {
145	m.top, _ = display.Window(len(m.list.visible), m.list.cursor, m.top, m.bodyHeight())
146}
147
148func (m *model) View() tea.View {
149	if m.width <= 0 || m.height <= 0 {
150		return tea.NewView("")
151	}
152	if m.width < 20 || m.height < 7 {
153		return tea.NewView(display.Line("resize · ctrl+c cancel", m.width))
154	}
155	title := lipgloss.NewStyle().Bold(true).Render("choose a server")
156	if m.confirm != nil {
157		// Modal panel replaces the content; it is not an overlay compositor.
158		text := "use " + display.SingleLine(m.confirm.Label) + "?"
159		content := title + "\n" + display.Panel(text, m.width) + "\n" + m.help.View(m.keys())
160		return tea.NewView(content)
161	}
162	height := m.bodyHeight()
163	lo, hi := display.Window(len(m.list.visible), m.list.cursor, m.top, height)
164	lines := make([]string, 0, hi-lo)
165	for i := lo; i < hi; i++ {
166		row := m.list.items[m.list.visible[i]]
167		lines = append(lines, display.Row(row.Label, row.Detail, m.width, i == m.list.cursor))
168	}
169	if len(m.list.visible) == 0 {
170		lines = append(lines, "no matches · edit the filter")
171	}
172	content := []string{
173		title, display.Line(m.input.View(), m.width),
174		display.FillRows(lines, m.width, height),
175		display.Line(fmt.Sprintf("%d matches", len(m.list.visible)), m.width),
176		m.help.View(m.keys()),
177	}
178	return tea.NewView(strings.Join(content, "\n")) // Deliberately inline.
179}