package tui
import (
"fmt"
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
// menuMaxRows caps the completion list. Past about eight rows the menu is
// eating the transcript, and a list that long is faster to filter than to
// scroll anyway.
const menuMaxRows = 8
// menu is the slash-command completion list under the prompt. It is closed
// when it holds no items; there is no separate flag to disagree with that.
type menu struct {
items []command
idx int
// dismissed is the prompt text the menu was last closed on. Accepting a
// completion leaves "/clear" in the box, which is exactly the text that
// would re-open the menu, so closing remembers what it closed on and
// stays shut until something else is typed.
dismissed string
}
func (m *model) menuOpen() bool { return len(m.menu.items) > 0 }
// menuQuery reports what has been typed after the slash, and whether a
// command is being typed at all. A slash anywhere but the start is prose; a
// space means the name is settled and what follows is its arguments.
func menuQuery(value string) (string, bool) {
if !strings.HasPrefix(value, "/") {
return "", false
}
rest := value[1:]
if strings.ContainsAny(rest, " \t\n") {
return "", false
}
return strings.ToLower(rest), true
}
// refreshMenu recomputes the list from what is in the prompt box. It runs
// after every keystroke the textarea sees, so the list follows the text
// rather than being opened and closed by particular keys.
func (m *model) refreshMenu() {
value := m.input.Value()
typed, typing := menuQuery(value)
if !typing || value == m.menu.dismissed || m.phase != phaseIdle {
m.menu.items, m.menu.idx = nil, 0
return
}
// Keep the highlight on the command it was on while the list narrows
// around it; otherwise start at the top.
var was string
if m.menuOpen() && m.menu.idx < len(m.menu.items) {
was = m.menu.items[m.menu.idx].name
}
m.menu.items, m.menu.idx = matchCommands(m.commands(), typed), 0
for i, c := range m.menu.items {
if c.name == was {
m.menu.idx = i
}
}
}
func (m *model) closeMenu() {
m.menu.items, m.menu.idx = nil, 0
m.menu.dismissed = m.input.Value()
}
// onMenuKey handles the keys that belong to an open menu, and reports whether
// it took the key. Everything it does not take falls through to the prompt,
// which is what keeps typing a filter rather than a command of its own.
func (m *model) onMenuKey(msg tea.KeyMsg) (tea.Cmd, bool) {
n := len(m.menu.items)
switch msg.String() {
case "up", "ctrl+p":
m.menu.idx = (m.menu.idx - 1 + n) % n
return nil, true
case "down", "ctrl+n":
m.menu.idx = (m.menu.idx + 1) % n
return nil, true
case "tab", "enter":
m.acceptCompletion()
return nil, true
case "esc":
m.closeMenu()
return nil, true
}
return nil, false
}
// acceptCompletion puts the highlighted command in the prompt box. It does
// not send it: a command that takes arguments is half-typed at this point, and
// one that does not is one more enter away.
func (m *model) acceptCompletion() {
c := m.menu.items[m.menu.idx]
text := "/" + c.name
if c.args != "" {
text += " "
}
m.input.SetValue(text)
m.input.CursorEnd()
m.closeMenu()
m.growInput()
}
// menuRows is how many lines the menu may take: what is left after the rest of
// the bottom chrome and one row of transcript, capped.
func (m *model) menuRows() int {
// activity line, the prompt box's three, the status line, the mode line,
// and a row of transcript to keep the menu from swallowing the screen.
room := m.height - 6 - m.input.Height()
return maxInt(1, minInt(menuMaxRows, room))
}
// menuView renders the list under the prompt: the highlighted row in coral
// with its description beside it, the rest dimmed.
func (m *model) menuView() string {
if !m.menuOpen() {
return ""
}
rows := m.menuRows()
shown := rows
if len(m.menu.items) > rows {
// The last row goes to the count of what did not fit.
shown = maxInt(1, rows-1)
}
start := 0
if m.menu.idx >= shown {
start = m.menu.idx - shown + 1
}
end := minInt(len(m.menu.items), start+shown)
// One column for the names, so the descriptions line up. A long skill
// name is left to overflow its column rather than being truncated: the
// name is the thing you are picking, and half of it is no use.
width := 0
for _, c := range m.menu.items[start:end] {
width = maxInt(width, lipgloss.Width("/"+c.name))
}
width = minInt(width, maxInt(12, m.width/3))
var b strings.Builder
for i := start; i < end; i++ {
c := m.menu.items[i]
name := pad("/"+c.name, width)
desc := c.desc
if c.tag != "" {
desc += " (" + c.tag + ")"
}
var line string
if i == m.menu.idx {
line = styKey.Render("❯ "+name) + " " + styText.Render(desc)
} else {
line = " " + styDim.Render(name) + " " + styFaint.Render(desc)
}
b.WriteString(truncateStyled(" "+line, maxInt(1, m.width)) + "\n")
}
if n := len(m.menu.items) - (end - start); n > 0 {
b.WriteString(truncateStyled(" "+styFaint.Render(fmt.Sprintf("… +%d more", n)), maxInt(1, m.width)) + "\n")
}
return strings.TrimRight(b.String(), "\n")
}
// pad widens s to width cells, leaving anything already wider alone.
func pad(s string, width int) string {
if n := width - lipgloss.Width(s); n > 0 {
return s + strings.Repeat(" ", n)
}
return s
}