Vectrum
Sign In
181 lines · 5.5 KB · Go
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 ""
	}
	start, end := m.menuWindow()

	// 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")
}

// menuWindow is the slice of the list that is on screen. The view draws it
// and a click is resolved against it, so the row under the pointer and the
// row that was drawn are the same row by construction.
func (m *model) menuWindow() (start, end int) {
	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)
	}
	if m.menu.idx >= shown {
		start = m.menu.idx - shown + 1
	}
	return start, minInt(len(m.menu.items), start+shown)
}

// 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
}