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