Vectrum
Sign In
403 lines · 12.6 KB · Go
package tui

// Drawing the setup wizard. Every function here is a pure function of the
// wizard's state and its frame counter: nothing in this file decides anything,
// which is what lets a test render any frame of any step on demand.

import (
	"fmt"
	"strings"

	"github.com/charmbracelet/lipgloss"

	"codey/internal/config"
)

// ---- view ---------------------------------------------------------------

func (w *wizard) View() string {
	if w.width == 0 || w.height == 0 {
		return ""
	}
	switch w.anim {
	case animSlide:
		return slide(w.from, w.screen(), w.width, w.height, progress(w.frame, slideFrames))
	default:
		return w.screen()
	}
}

// cardWidth is the width of the content column. It is capped well below a wide
// terminal: a question set in 200 columns is a question nobody reads.
func (w *wizard) cardWidth() int { return minInt(74, maxInt(40, w.width-8)) }

// screen composes one full frame: the whole terminal, so that a slide can cut
// it at any column.
//
// Every line is squared off to the card width on the way out — clamped if it
// overruns, padded if it falls short.
//
// The padding is not cosmetic. lipgloss.Place centres each line independently,
// so a card of ragged lines comes out as a centred blob with every row on its
// own axis: a left-aligned list of options would not line up with itself.
// Padding first makes the card one rectangle, which Place then centres as a
// unit, and the layout inside it survives.
func (w *wizard) screen() string {
	width := w.cardWidth()
	lines := strings.Split(w.card(), "\n")
	for i, line := range lines {
		line = truncateStyled(line, width)
		if pad := width - lipgloss.Width(line); pad > 0 {
			line += strings.Repeat(" ", pad)
		}
		lines[i] = line
	}
	return lipgloss.Place(w.width, w.height, lipgloss.Center, lipgloss.Center, strings.Join(lines, "\n"))
}

func (w *wizard) card() string {
	if w.step == stepDone {
		return w.doneCard()
	}
	if w.step == stepIntro {
		return w.introCard()
	}

	inner := w.cardWidth()
	var b strings.Builder
	b.WriteString(w.header(inner) + "\n\n")
	b.WriteString(w.rail(inner) + "\n\n")
	b.WriteString(styText.Render(w.question()) + "\n\n")
	b.WriteString(w.list(inner))
	b.WriteString("\n" + w.footer(inner))
	return b.String()
}

// header is the mark and the word "setup", present on every step so the screen
// always says what it is.
func (w *wizard) header(width int) string {
	left := styHeading.Render("codey") + styDim.Render("  setup")
	right := styFaint.Render(fmt.Sprintf("step %d of %d", int(w.step), railSteps))
	return justify(left, right, width)
}

// justify puts left at the left edge and right at the right one.
//
// It is spread without the leading space. The chat's status bar wants that
// margin; here every line starts at the same column by construction, and one
// line indented a cell further than its neighbours is exactly the kind of
// wrongness that is hard to see and impossible to unsee.
func justify(left, right string, width int) string {
	width = maxInt(width, 1)
	gap := width - lipgloss.Width(left) - lipgloss.Width(right)
	if gap < 1 {
		return truncateStyled(left, width)
	}
	return left + strings.Repeat(" ", gap) + right
}

// railPos is where the marker sits, as a float so it can be between nodes
// during a transition.
func (w *wizard) railPos() float64 {
	if w.step == stepDone {
		return railSteps - 1
	}
	return float64(maxInt(0, int(w.step)-1))
}

// rail is the progress indicator: four nodes, lit by how close the marker is,
// with the segments between them filling as it travels. During a slide the
// marker eases from where it was to where it is going, so the rail moves with
// the panel rather than jumping ahead of it.
func (w *wizard) rail(width int) string {
	pos := w.railPos()
	if w.anim == animSlide {
		pos = w.railFrom + (pos-w.railFrom)*ease(progress(w.frame, slideFrames))
	}

	// The dashes between the nodes, sized so the rail spans the card exactly.
	// The remainder is handed out one cell at a time to the leftmost segments
	// rather than dropped, which is what left the rail a few cells short of
	// everything it was meant to line up with.
	gaps := railSteps - 1
	seg, extra := maxInt(3, (width-railSteps)/gaps), (width-railSteps)%gaps

	var b strings.Builder
	for i := 0; i < railSteps; i++ {
		lit := clamp01(1 - absFloat(pos-float64(i)))
		glyph := "◇"
		if lit > 0.5 {
			glyph = "◆"
		}
		b.WriteString(lipgloss.NewStyle().Foreground(blend(colFaint, colCoral, lit)).Render(glyph))

		if i == railSteps-1 {
			break
		}
		n := seg
		if i < extra {
			n++
		}
		// The segment fills left to right as the marker crosses it. Each run
		// is written only when it has something in it: rendering an empty
		// string still emits the colour escapes around it, which is invisible
		// on screen but makes every rail assertion in a test a guessing game.
		filled := int(clamp01(pos-float64(i)) * float64(n))
		if filled > 0 {
			b.WriteString(styCoral.Render(strings.Repeat("━", filled)))
		}
		if rest := n - filled; rest > 0 {
			b.WriteString(styFaint.Render(strings.Repeat("─", rest)))
		}
	}
	return b.String()
}

func (w *wizard) question() string {
	switch w.step {
	case stepModel:
		return "Which model should the agent use?"
	case stepEffort:
		return "How hard should it think?"
	case stepApprovals:
		return "Before it changes a file or runs a command?"
	case stepWebSearch:
		return "Should it be able to search the web?"
	case stepTheme:
		return "How should it look?"
	}
	return ""
}

// list renders the options, with the highlighted one carrying a breathing
// marker and its own sentence of explanation.
func (w *wizard) list(width int) string {
	if w.typing {
		return w.customEntry(width)
	}
	opts := w.options()
	if len(opts) == 0 {
		return styDim.Render("  " + w.spinnerWord() + " asking " + endpointLabel(w.client) + " what it serves…")
	}

	// A long catalogue is windowed rather than allowed to push the footer off
	// the screen.
	visible := maxInt(3, minInt(len(opts), w.height-14))
	first := 0
	if w.cursor >= visible {
		first = w.cursor - visible + 1
	}

	var b strings.Builder
	if w.modelsErr != nil && w.step == stepModel {
		b.WriteString(styWarn.Render("  ⚠ ") +
			styDim.Render("couldn't reach "+endpointLabel(w.client)+" — showing the models this build knows") + "\n\n")
	}

	for i := first; i < minInt(len(opts), first+visible); i++ {
		o := opts[i]
		selected := i == w.cursor

		marker := styFaint.Render("  ◇ ")
		label := styDim.Render(o.label)
		if selected {
			// The marker breathes between faint and the accent, so the eye
			// can find the cursor on a screen it has just been dropped into.
			lit := 0.45 + 0.55*triangle(w.frame, breathPeriod)
			if w.still() {
				lit = 1
			}
			marker = lipgloss.NewStyle().Foreground(blend(colFaint, colCoral, lit)).Render("▌ ◆ ")
			label = styText.Bold(true).Render(o.label)
		}

		line := marker + label
		if o.note != "" {
			line = justify(line, styFaint.Render(o.note), width)
		}
		b.WriteString(truncateStyled(line, width) + "\n")
	}
	if rest := len(opts) - (first + visible); rest > 0 {
		b.WriteString(styFaint.Render(fmt.Sprintf("    … %d more", rest)) + "\n")
	}

	if d := opts[w.cursor].desc; d != "" {
		// Prose, so it wraps rather than being cut: the sentence explaining an
		// option is the one thing on the screen you actually have to read.
		b.WriteString("\n")
		for _, line := range strings.Split(styDim.Width(width-4).Render(d), "\n") {
			b.WriteString("    " + line + "\n")
		}
	}
	return b.String()
}

func (w *wizard) customEntry(width int) string {
	hint := "  The id the endpoint expects, not a display name."
	if w.step == stepWebSearch {
		hint = "  {{query}} and {{count}} are filled in. No shell: the query is one argument."
	}
	var b strings.Builder
	b.WriteString(styDim.Render(hint) + "\n\n")
	b.WriteString(styBoxFocus.Width(width-4).Render(w.custom.View()) + "\n")
	return b.String()
}

// spinnerWord is the catalogue's own small animation, so a slow endpoint does
// not look like a hang.
func (w *wizard) spinnerWord() string {
	if w.still() {
		return "·"
	}
	return styCoral.Render(pulse.Frames[(w.frame/4)%len(pulse.Frames)])
}

func (w *wizard) footer(width int) string {
	var keys string
	switch {
	case w.typing:
		keys = hint("enter") + " use it   " + hint("esc") + " back to the list"
	default:
		keys = hint("↑↓") + " move   " + hint("enter") + " next   " + hint("esc") + " back   " + hint("ctrl+c") + " cancel"
	}
	return "\n" + truncateStyled(styFaint.Render(keys), width)
}

// hint renders a key name in the footer's key-then-meaning grammar.
func hint(k string) string { return styKey.Render(k) }

// ---- intro --------------------------------------------------------------

// introCard is the first thing a new install shows: the mark assembling, the
// name igniting, and one line saying what is about to happen.
func (w *wizard) introCard() string {
	t := progress(w.frame, introFrames)

	var b strings.Builder

	// The mascot assembles a row at a time, top down.
	rows := len(mascot)
	shown := rows
	if !w.still() {
		shown = minInt(rows, int(t*float64(introFrames)/4)+1)
	}
	for i := 0; i < rows; i++ {
		line := strings.Repeat(" ", lipgloss.Width(mascot[i]))
		if i < shown {
			// Each row fades in over its first few frames rather than
			// appearing at full strength.
			age := clamp01((t*float64(introFrames) - float64(i*4)) / 6)
			line = lipgloss.NewStyle().Foreground(blend(colFaint, colCoral, age)).Render(mascot[i])
		}
		b.WriteString(line + "\n")
	}
	b.WriteString("\n")

	// "codey" ignites left to right once the mark is up.
	titleT := clamp01((t*float64(introFrames) - 6) / 18)
	b.WriteString(ignite("codey", colFaint, colCoral, titleT, true) + "\n")

	// The tagline types itself underneath. The short form is not a truncation
	// of the long one: a cut sentence reads as a bug, where a shorter sentence
	// reads as a sentence.
	tagline := "let's pick your defaults — four questions, about a minute"
	if w.cardWidth() < len(tagline)+4 {
		tagline = "four questions, about a minute"
	}
	typed := tagline
	if !w.still() {
		typed = reveal(tagline, int(clamp01((t*float64(introFrames)-18)/22)*float64(len(tagline))))
	}
	b.WriteString(styDim.Render(typed) + "\n\n")

	// The invitation appears only once the rest has settled, and breathes.
	if w.anim == animNone || w.still() {
		lit := 0.4 + 0.6*triangle(w.frame, breathPeriod)
		if w.still() {
			lit = 1
		}
		prompt := lipgloss.NewStyle().Foreground(blend(colFaint, colText, lit)).Render("press enter to begin")
		b.WriteString(prompt + styFaint.Render("   ctrl+c to skip") + "\n")
	} else {
		b.WriteString("\n")
	}
	return b.String()
}

// ---- done ---------------------------------------------------------------

// doneCard is the closing flourish: the four answers fly in, a bar sweeps
// across, and a check lands. Then the wizard hands the screen to the chat.
func (w *wizard) doneCard() string {
	inner := w.cardWidth()
	f := float64(w.frame)
	if w.still() {
		f = doneFrames
	}

	rows := [][2]string{
		{"model", prettyModel(w.cfg.Model)},
		{"effort", w.cfg.Effort},
		{"approvals", w.cfg.Approvals},
		{"theme", w.cfg.Theme},
	}

	var b strings.Builder
	b.WriteString(styHeading.Render("codey") + styDim.Render("  setup") + "\n\n")

	for i, r := range rows {
		// Staggered: each row starts three frames after the one above, so
		// they arrive as a cascade rather than as a block.
		t := easeOut(clamp01((f - float64(i*3)) / 10))
		if t == 0 {
			b.WriteString("\n")
			continue
		}
		// Flying in from the right: the offset shrinks to nothing.
		pad := int((1 - t) * 18)
		line := styDim.Render(fmt.Sprintf("%-11s", r[0])) + styText.Bold(true).Render(r[1])
		b.WriteString(truncateStyled(strings.Repeat(" ", pad)+line, inner) + "\n")
	}

	b.WriteString("\n")
	b.WriteString(sweep(inner, clamp01((f-12)/18), colCoral) + "\n\n")

	if f >= 30 {
		// The check pops: it overshoots its size for a few frames. There is
		// only one glyph to scale, so the overshoot is spacing around it.
		gap := 0
		if f < 34 {
			gap = 1
		}
		pad := strings.Repeat(" ", gap)
		b.WriteString(styOK.Bold(true).Render("✓") + pad + " " +
			styText.Render("you're all set") + "\n")
		// The path is the first thing to go when there is no room for it:
		// what matters is that setup can be reopened, not where it is kept.
		tail := "saved to " + configPathLabel() + " · /setup to change it"
		if inner < len(tail)+4 {
			tail = "/setup to change it"
		}
		b.WriteString(styFaint.Render(tail) + "\n")
	} else {
		b.WriteString("\n\n")
	}
	return b.String()
}

// configPathLabel names the file the answers land in, shortened the way the
// welcome block shortens the working root.
func configPathLabel() string {
	p, err := config.Path()
	if err != nil {
		return "the config file"
	}
	return shortenHome(p)
}

func absFloat(f float64) float64 {
	if f < 0 {
		return -f
	}
	return f
}