package main import ( "charm.land/bubbles/v2/textinput" tea "charm.land/bubbletea/v2" "context" "example.com/charm-tui-worked/internal/display" "fmt" "strings" "time" ) type debounceMsg struct { ticket ticket err error } type loadedMsg struct { generation uint64 rows []string err error } func debounceCmd(t ticket) tea.Cmd { return func() tea.Msg { return debounceMsg{ticket: t, err: await(t.ctx, 150*time.Millisecond)} } } func loadCmd(t ticket, search searchFunc) tea.Cmd { // The closure captures values and a service, never the live model. return func() tea.Msg { rows, err := search(t.ctx, t.query) return loadedMsg{generation: t.generation, rows: rows, err: err} } } type model struct { input textinput.Model state searchState search searchFunc initial ticket width, height int } func newModel(ctx context.Context, search searchFunc) *model { input := textinput.New() input.Prompt = "/ " input.Placeholder = "try slow, error, or queue" input.SetVirtualCursor(true) input.SetWidth(55) m := &model{input: input, state: searchState{root: ctx}, search: search, width: 60, height: 12} m.initial = m.state.change("") return m } func (m *model) Init() tea.Cmd { return tea.Batch(m.input.Focus(), debounceCmd(m.initial), tea.RequestBackgroundColor) } func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: m.width = min(72, max(0, msg.Width)) m.height = min(12, max(0, msg.Height)) m.input.SetWidth(max(1, m.width-3)) return m, nil case tea.BackgroundColorMsg: m.input.SetStyles(textinput.DefaultStyles(msg.IsDark())) return m, nil case tea.KeyPressMsg: switch msg.String() { case "esc", "ctrl+c": m.state.stop() return m, tea.Quit case "ctrl+r": return m, debounceCmd(m.state.change(m.input.Value())) } case debounceMsg: // begin also rejects a duplicated current-generation debounce delivery. if !m.state.begin(msg.ticket.generation) { return m, nil } if msg.err != nil { m.state.complete(msg.ticket.generation, nil, msg.err) return m, nil } return m, loadCmd(msg.ticket, m.search) case loadedMsg: m.state.complete(msg.generation, msg.rows, msg.err) return m, nil } before := m.input.Value() var childCmd tea.Cmd m.input, childCmd = m.input.Update(msg) if m.input.Value() != before { t := m.state.change(m.input.Value()) return m, tea.Batch(childCmd, debounceCmd(t)) } return m, childCmd } func (m *model) View() tea.View { if m.width <= 0 || m.height <= 0 { return tea.NewView("") } if m.width < 20 || m.height < 6 { return tea.NewView(display.Line("resize · esc quit", m.width)) } status := "waiting for input" switch m.state.phase { case waiting: status = "waiting for typing to settle" case loading: status = "searching…" case ready: status = fmt.Sprintf("%d matches", len(m.state.rows)) case failed: status = "search failed · ctrl+r retry" } rows := make([]string, 0, len(m.state.rows)) for _, row := range m.state.rows { rows = append(rows, display.SingleLine(row)) } if m.state.phase == ready && len(rows) == 0 { rows = append(rows, "no matches · edit the query") } if m.state.err != nil { rows = append(rows, display.SingleLine(m.state.err.Error())) } // All four chrome rows are explicitly clipped to one line. content := []string{ display.Line("server search · simulated service", m.width), display.Line(m.input.View(), m.width), display.Line(status, m.width), display.FillRows(rows, m.width, display.BodyRows(m.height, 3, 1)), display.Line("esc quit · ctrl+r retry", m.width), } return tea.NewView(strings.Join(content, "\n")) }