ajhahn.de
← eeco
Go 145 lines
package tui

import (
	"strings"

	"github.com/charmbracelet/lipgloss"
)

// section is a styled command-output block: a horizontal rule carrying
// the title, an optional subtitle on the rule, a body of pre-formatted
// lines (callers preserve their own indent), and an optional footer
// separated from the body by a blank line. The renderer is a thin
// presentation layer over the existing styles palette; it adds no new
// colour and introduces no new write path.
type section struct {
	title    string
	subtitle string
	body     []string
	footer   []string
}

// sectionRow is one entry in a key/value table that flows into the body.
// Callers build rows with rowsToBody before constructing a section so
// the section struct stays narrow.
type sectionRow struct {
	key, value, note string
}

// defaultRuleWidth is the fill cap used when the model has not yet seen
// a WindowSizeMsg (width 0). Terminals usually wrap longer rules cleanly,
// but a hard cap keeps the visual frame bounded on first paint.
const defaultRuleWidth = 60

// renderSection formats s as ordered scrollback lines. A leading blank
// line gives the section breathing room after the echoed prompt; the
// header line is `─── title · subtitle ──…` with the trailing rule
// filling to width. Each body line is emitted verbatim; the footer
// (if any) is prefixed by a single blank line. Pure: callers print the
// result with tea.Println via the existing printLines helper.
func renderSection(width int, st styles, s section) []string {
	out := make([]string, 0, len(s.body)+len(s.footer)+4)
	out = append(out, "")
	out = append(out, renderRule(width, st, s.title, s.subtitle))
	out = append(out, s.body...)
	if len(s.footer) > 0 {
		out = append(out, "")
		out = append(out, s.footer...)
	}
	return out
}

// renderRule builds the section header. The shape is
//
//	─── title · subtitle ──────────────────
//
// with the title in the `key` style and the subtitle (when set) in
// `dim`. The leading three dashes and the trailing fill use `dimmer`
// so the title is the visual anchor. Width 0 falls back to
// defaultRuleWidth.
func renderRule(width int, st styles, title, subtitle string) string {
	w := width
	if w <= 0 {
		w = defaultRuleWidth
	}
	const lead = "─── "
	plain := lead + title
	if subtitle != "" {
		plain += " · " + subtitle
	}
	fillN := w - lipgloss.Width(plain) - 1
	if fillN < 3 {
		fillN = 3
	}
	styled := st.dimmer.Render(lead) + st.key.Render(title)
	if subtitle != "" {
		styled += " " + st.dim.Render("· "+subtitle)
	}
	styled += st.dimmer.Render(" " + strings.Repeat("─", fillN))
	return styled
}

// tableBody flows a key/value/note table into body lines with an
// optional header row + rule separator. The key column is rendered in
// `key` style; the value column in `value` style (readable primary
// foreground); the note column in `dim`. The header cells use
// `tableHeader`; the separator rule uses `dimmer`. Columns pad to the
// widest cell across the header and every row so notes stay vertically
// aligned. Leading two-space indent matches the rest of the body
// convention. An all-empty head omits the header rows entirely.
func tableBody(st styles, head [3]string, rows []sectionRow) []string {
	if len(rows) == 0 {
		return nil
	}
	maxKey := lipgloss.Width(head[0])
	maxVal := lipgloss.Width(head[1])
	maxNote := lipgloss.Width(head[2])
	for _, r := range rows {
		if n := lipgloss.Width(r.key); n > maxKey {
			maxKey = n
		}
		if n := lipgloss.Width(r.value); n > maxVal {
			maxVal = n
		}
		if n := lipgloss.Width(r.note); n > maxNote {
			maxNote = n
		}
	}
	hasHead := head[0] != "" || head[1] != "" || head[2] != ""
	out := make([]string, 0, len(rows)+2)
	if hasHead {
		kPad := strings.Repeat(" ", maxKey-lipgloss.Width(head[0]))
		vPad := strings.Repeat(" ", maxVal-lipgloss.Width(head[1]))
		line := "  " + st.tableHeader.Render(head[0]) + kPad + "  " + st.tableHeader.Render(head[1]) + vPad
		if head[2] != "" {
			line += "  " + st.tableHeader.Render(head[2])
		}
		out = append(out, line)
		sep := "  " + st.dimmer.Render(strings.Repeat("─", maxKey)) +
			"  " + st.dimmer.Render(strings.Repeat("─", maxVal))
		if maxNote > 0 {
			sep += "  " + st.dimmer.Render(strings.Repeat("─", maxNote))
		}
		out = append(out, sep)
	}
	for _, r := range rows {
		kPad := strings.Repeat(" ", maxKey-lipgloss.Width(r.key))
		vPad := strings.Repeat(" ", maxVal-lipgloss.Width(r.value))
		line := "  " + st.key.Render(r.key) + kPad + "  " + st.value.Render(r.value) + vPad
		if r.note != "" {
			line += "  " + st.dim.Render(r.note)
		}
		out = append(out, line)
	}
	return out
}

// renderError produces a single styled line for short failure or
// validation messages: `<title>: <message>` with the title in `warn`
// and the message in default text. Matches the existing inline error
// shape (`"settings: " + err.Error()`) but applies a consistent colour
// across every command surface.
func renderError(st styles, title, message string) []string {
	return []string{st.warn.Render(title+":") + " " + message}
}