package main
import (
"charm.land/bubbles/v2/help"
"charm.land/bubbles/v2/key"
"charm.land/bubbles/v2/textinput"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"example.com/charm-tui-worked/internal/display"
"fmt"
"strings"
)
type model struct {
list selection
input textinput.Model
help help.Model
width, height, top int
confirm *item // Captured target, not a later lookup through a moving cursor.
result outcome
}
type keys struct{ up, down, search, accept, back, quit key.Binding }
func binding(k, label string) key.Binding {
return key.NewBinding(key.WithKeys(k), key.WithHelp(k, label))
}
func (k keys) ShortHelp() []key.Binding {
return []key.Binding{k.up, k.down, k.search, k.accept, k.back, k.quit}
}
func (k keys) FullHelp() [][]key.Binding { return [][]key.Binding{k.ShortHelp()} }
func (m *model) keys() keys {
k := keys{binding("up", "up"), binding("down", "down"), binding("/", "search"), binding("enter", "choose"), binding("esc", "cancel"), binding("q", "quit")}
if m.input.Focused() {
k.up.SetEnabled(false)
k.down.SetEnabled(false)
k.search.SetEnabled(false)
k.quit.SetEnabled(false)
k.accept = binding("enter", "browse")
k.back = binding("esc", "browse")
} else if m.confirm != nil {
k.up.SetEnabled(false)
k.down.SetEnabled(false)
k.search.SetEnabled(false)
k.quit.SetEnabled(false)
k.accept = binding("enter", "confirm")
k.back = binding("esc", "back")
} else {
_, ok := m.list.current()
k.accept.SetEnabled(ok)
}
return k
}
func newModel(items []item) *model {
input := textinput.New()
input.Prompt = "/ "
input.Placeholder = "filter servers"
input.SetVirtualCursor(true) // Avoid real-cursor coordinate translation here.
input.SetWidth(48)
return &model{list: newSelection(items), input: input, help: help.New(), width: 60, height: 12}
}
func (m *model) Init() tea.Cmd { return 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))
m.help.SetWidth(m.width)
m.clampWindow()
return m, nil
case tea.BackgroundColorMsg:
m.input.SetStyles(textinput.DefaultStyles(msg.IsDark()))
m.help.Styles = help.DefaultStyles(msg.IsDark())
return m, nil
case tea.KeyPressMsg:
if msg.String() == "ctrl+c" {
m.result = outcome{Interrupted: true}
return m, tea.Quit
}
// Modal input is consumed exactly once, even when this key closes it.
if m.confirm != nil {
switch msg.String() {
case "esc":
m.confirm = nil
case "enter":
m.result = outcome{Accepted: true, ID: m.confirm.ID}
return m, tea.Quit
}
return m, nil
}
k := m.keys()
// An editor owns printable keys. 'q', '/', '?', 'j' are text here.
if m.input.Focused() {
if key.Matches(msg, k.back, k.accept) {
m.input.Blur()
m.clampWindow()
return m, nil
}
return m, m.updateInput(msg)
}
switch {
case key.Matches(msg, k.quit, k.back):
return m, tea.Quit
case key.Matches(msg, k.search):
cmd := m.input.Focus()
m.clampWindow()
return m, cmd
case key.Matches(msg, k.up):
m.list.move(-1)
case key.Matches(msg, k.down):
m.list.move(1)
case key.Matches(msg, k.accept):
if row, ok := m.list.current(); ok {
m.confirm = &row
}
}
m.clampWindow()
return m, nil
}
// Paste results and cursor messages also need the child update path.
return m, m.updateInput(msg)
}
func (m *model) updateInput(msg tea.Msg) tea.Cmd {
before := m.input.Value()
var cmd tea.Cmd
m.input, cmd = m.input.Update(msg)
if m.input.Value() != before {
m.list.filter(m.input.Value())
m.clampWindow()
}
return cmd // Never lose child effects, including cursor or paste commands.
}
func (m *model) bodyHeight() int {
// Header has exactly two clipped lines; status has one, help is measured.
return display.BodyRows(m.height, 2, 1+lipgloss.Height(m.help.View(m.keys())))
}
func (m *model) clampWindow() {
m.top, _ = display.Window(len(m.list.visible), m.list.cursor, m.top, m.bodyHeight())
}
func (m *model) View() tea.View {
if m.width <= 0 || m.height <= 0 {
return tea.NewView("")
}
if m.width < 20 || m.height < 7 {
return tea.NewView(display.Line("resize · ctrl+c cancel", m.width))
}
title := lipgloss.NewStyle().Bold(true).Render("choose a server")
if m.confirm != nil {
// Modal panel replaces the content; it is not an overlay compositor.
text := "use " + display.SingleLine(m.confirm.Label) + "?"
content := title + "\n" + display.Panel(text, m.width) + "\n" + m.help.View(m.keys())
return tea.NewView(content)
}
height := m.bodyHeight()
lo, hi := display.Window(len(m.list.visible), m.list.cursor, m.top, height)
lines := make([]string, 0, hi-lo)
for i := lo; i < hi; i++ {
row := m.list.items[m.list.visible[i]]
lines = append(lines, display.Row(row.Label, row.Detail, m.width, i == m.list.cursor))
}
if len(m.list.visible) == 0 {
lines = append(lines, "no matches · edit the filter")
}
content := []string{
title, display.Line(m.input.View(), m.width),
display.FillRows(lines, m.width, height),
display.Line(fmt.Sprintf("%d matches", len(m.list.visible)), m.width),
m.help.View(m.keys()),
}
return tea.NewView(strings.Join(content, "\n")) // Deliberately inline.
}