Something went wrong. Try again.
small gleam coding and persistent agent daemon with a detachable cli and a canvas ui forked from okami.mom/albedo
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193package tui
import ( "cmp" "slices" "strings"
"charm.land/lipgloss/v2" "github.com/charmbracelet/x/ansi")
// listFrame is the layout every list screen shares: the title rule, an optional// filter, the list beside the highlighted row's detail pane when there is// room, and the footer, all clipped to the terminal once. A screen supplies// the parts and none of the arithmetic.type listFrame struct { title, right string // the title rule's two ends, styled filter string // the search line as drawn; empty for a screen without one list func(width, height int) []string // pane is the detail beside the list; nil for a list alone. pane func(width, height int) []string // paneWidth is the pane's width at a terminal width; nil is sidePane. paneWidth func(width int) int // summary is the detail's one-line stand-in when the pane has no room at all. summary string // footer is the finished bottom rows: hints, status, or a confirmation. footer string}
// Side by side needs this much terminal; a narrower one with this many body// rows stacks the pane under the list instead, which gets the larger part.const ( paneMinWidth = 96 paneMinHeight = 14 stackMinBody = 12)
// inset moves rows drawn in the list's place, a form or a guide, to the// frame's one-cell edge.func inset(lines []string) []string { out := make([]string, len(lines)) for i, l := range lines { out[i] = " " + l } return out}
// guideText is a guide's paragraph wrapped to width in style, indented like// list rows.func guideText(width int, style lipgloss.Style, text string) []string { var out []string for _, l := range svWrap(text, max(1, width-6), 4) { out = append(out, " "+style.Render(l)) } return out}
// guideStep is one numbered step of a guide: its title, then its detail// faint and hung under the title.func guideStep(width int, n, title, detail string) []string { lines := []string{" " + DefaultStyles.Prompt.Render(n) + " " + title} for _, l := range svWrap(detail, max(1, width-9), 3) { lines = append(lines, " "+DefaultStyles.Faint.Render(l)) } return append(lines, "")}
// sidePane is a narrow detail column for facts about the highlighted row.func sidePane(width int) int { return min(max(width/3, 34), 48) }
// halfPane is half the terminal, for previews that read like text.func halfPane(width int) int { return width - width/2 - ansi.StringWidth(svSep()) }
// view draws the frame at exactly the terminal's size or less; an unsized// terminal is 80 by 24.func (f listFrame) view(width, height int) string { width, height = cmp.Or(width, 80), cmp.Or(height, 24) roomy := height >= 12
lines := []string{" " + titleRule(width-1, f.title, f.right)} if roomy { lines = append(lines, "") } if f.filter != "" { lines = append(lines, " "+f.filter) if height >= 9 { lines = append(lines, "") } }
paned := f.pane != nil && width >= paneMinWidth && height >= paneMinHeight var tail []string if roomy { tail = append(tail, "") } tail = append(tail, strings.Split(f.footer, "\n")...) body := max(1, height-len(lines)-len(tail)) stacked := !paned && f.pane != nil && body >= stackMinBody if !paned && !stacked && f.summary != "" && height >= 16 { tail = slices.Insert(tail, len(tail)-strings.Count(f.footer, "\n")-1, " "+f.summary) body = max(1, height-len(lines)-len(tail)) }
if paned { paneW := sidePane(width) if f.paneWidth != nil { paneW = f.paneWidth(width) } list := f.list(width-paneW-ansi.StringWidth(svSep()), body) pane := f.pane(paneW, body) for i := range body { lines = append(lines, list[i]+svSep()+pane[i]) } } else if stacked { // The pane gets the rows a short list leaves blank. paneH := body * 2 / 5 list := f.list(width, body-paneH-1) used := len(list) for used > 1 && strings.TrimSpace(ansi.Strip(list[used-1])) == "" { used-- } paneH = body - used - 1 lines = append(lines, list[:used]...) lines = append(lines, " "+DefaultStyles.Decor.Render(strings.Repeat("─", max(0, width-2)))) for _, line := range f.pane(width-1, paneH) { lines = append(lines, " "+line) } } else { list := f.list(width, body) lines = append(lines, list...) lines = append(lines, make([]string, max(0, body-len(list)))...) } lines = append(lines, tail...)
if len(lines) > height { lines = append(lines[:max(0, height-1)], lines[len(lines)-1]) } for i, line := range lines { lines[i] = ansi.Truncate(line, width, "…") } return strings.Join(lines, "\n")}
// notice is a notice in the colour of what it reports: a warning, or a save// that went through.func notice(text string, warns bool) string { if warns { return DefaultStyles.Warning.Render(text) } return DefaultStyles.Success.Render(text)}
// footerLine is the keys on the left and a status on the right. When both// do not fit, an urgent status (an error, a notice, work in flight) takes// a row of its own above the keys, and any other status gives way to them.// The keys lose their labels only when no three labelled hints fit at all.func footerLine(width int, hints []hint, status string, urgent bool) string { if len(hints) == 0 { return statusRows(width, status) } if status != "" { left := fitHints(hints, width-ansi.StringWidth(status)-3) if left != "" && (urgent || left == " "+keyHints(hints...)) { return left + strings.Repeat(" ", width-ansi.StringWidth(left)-ansi.StringWidth(status)-1) + status } } keys := fitHints(hints, width-1) if keys == "" { bare := make([]hint, len(hints)) for i, h := range hints { bare[i] = hint{key: h.key} if h.key == "" { bare[i].does = h.does } } keys = " " + ansi.Truncate(keyHints(bare...), max(1, width-2), "…") } if urgent && status != "" { return statusRows(width, status) + "\n" + keys } return keys}
// statusRows is a status on rows of its own, wrapped to two at most.func statusRows(width int, status string) string { rows := strings.Split(lipgloss.Wrap(status, max(1, width-2), " /"), "\n") if len(rows) > 2 { rows = rows[:2] rows[1] = ansi.Truncate(rows[1], max(1, width-3), "") + "…" } return " " + strings.Join(rows, "\n ")}