ajhahn.de
← eeco
Go 141 lines
package tui

import (
	"fmt"
	"strings"
)

// palette is the slash-command dropdown state. Typing "/" opens an instant,
// navigable list of the slash commands, each with its one-line purpose —
// the terminal command palette the operator targeted. The open/closed
// state is derived from the current input (see paletteOpen), so the cursor
// is the only stored palette state.
type palette struct {
	cursor int // selected row within the current filtered set
}

// paletteMaxRows caps the visible dropdown height; the remainder is folded
// into a "+N more" line. Nine commands rarely overflow, so this is mostly
// defensive against a future-growing commandIndex.
const paletteMaxRows = 8

// paletteOpen reports whether the slash-command palette should show. It is
// open exactly while the input is a command token still being typed: a
// leading slash with no space yet. This reuses the command-vs-argument
// boundary complete() already relies on (commands.go) — "/" and "/me" open;
// "/run " (a committed command plus a space) closes into argument entry;
// empty or plain text never opens. A space, newline, or tab in the value is
// free text (a multi-line draft is never a command token), so any of them
// closes the palette.
func (m model) paletteOpen() bool {
	v := m.ta.Value()
	return strings.HasPrefix(v, "/") && !strings.ContainsAny(v, " \n\t")
}

// paletteItems returns the commandIndex rows whose name matches the current
// input token by prefix — the same rule completeToken uses (commands.go).
// commandIndex is the single source of truth and already sorted, so the
// rows need no further ordering.
func (m model) paletteItems() []cmdEntry {
	v := m.ta.Value()
	out := make([]cmdEntry, 0, len(commandIndex))
	for _, e := range commandIndex {
		if strings.HasPrefix(e.name, v) {
			out = append(out, e)
		}
	}
	return out
}

// clampPaletteCursor keeps pal.cursor within [0, n-1]; an empty set parks it
// at 0. Callers clamp after a cursor move so the highlight never points past
// the visible rows.
func (m *model) clampPaletteCursor(n int) {
	if n <= 0 || m.pal.cursor < 0 {
		m.pal.cursor = 0
		return
	}
	if m.pal.cursor > n-1 {
		m.pal.cursor = n - 1
	}
}

// acceptPalette commits the highlighted row: it fills the input with the
// command name plus a trailing space (which trips the open predicate and
// closes the palette) and moves the cursor to the end. It never submits —
// the subsequent Enter does. An empty filtered set is a no-op.
func (m model) acceptPalette(items []cmdEntry) model {
	if len(items) == 0 {
		return m
	}
	idx := m.pal.cursor
	if idx < 0 || idx >= len(items) {
		idx = 0
	}
	m.ta.SetValue(items[idx].name + " ")
	m.ta.CursorEnd()
	m.pal.cursor = 0
	return m
}

// renderPalette formats the dropdown as a single multi-line string (no
// trailing newline; the caller frames it). The selected row carries a brand
// "› " marker and a brand-coloured name; the rest use the blue command-name
// style. Names pad to a column so purposes align. Only the purpose is
// width-clipped (it is the variable-length part), which keeps the per-segment
// styling intact under colour. An empty set renders a single dim "no match"
// row. No new colours: brand/key/dim are reused from style.go.
func renderPalette(items []cmdEntry, cursor, width int, st styles) string {
	if len(items) == 0 {
		return "  " + st.dim.Render("no match")
	}
	nameCol := 0
	for _, e := range items {
		if n := len(e.name); n > nameCol {
			nameCol = n
		}
	}
	if cursor < 0 {
		cursor = 0
	}
	if cursor > len(items)-1 {
		cursor = len(items) - 1
	}
	// Scroll a fixed-height window so the highlighted row is always shown:
	// without this the selected item could fall into the hidden overflow
	// (e.g. cursor on the 9th of 9 commands with an 8-row cap).
	start := 0
	if cursor >= paletteMaxRows {
		start = cursor - paletteMaxRows + 1
	}
	end := start + paletteMaxRows
	if end > len(items) {
		end = len(items)
	}
	shown := items[start:end]
	rows := make([]string, 0, len(shown)+1)
	for i, e := range shown {
		marker := "  "
		nameStyle := st.key
		if start+i == cursor {
			marker = st.brand.Render("› ")
			nameStyle = st.brand
		}
		pad := strings.Repeat(" ", nameCol-len(e.name))
		purpose := e.purpose
		if width > 0 {
			// Columns before the purpose: 2 (marker) + nameCol + 2 (gap).
			avail := width - 2 - nameCol - 2
			if avail < 0 {
				avail = 0
			}
			purpose = truncate(purpose, avail)
		}
		rows = append(rows, marker+nameStyle.Render(e.name)+pad+"  "+st.dim.Render(purpose))
	}
	if hidden := len(items) - len(shown); hidden > 0 {
		rows = append(rows, "  "+st.dim.Render(fmt.Sprintf("+%d more", hidden)))
	}
	return strings.Join(rows, "\n")
}