package cli import ( "strings" "github.com/charmbracelet/lipgloss" "github.com/charmbracelet/x/ansi" ) // The frame every full-screen llmgw screen is drawn in: a title bar, a // sidebar of steps or actions on the left, the content in a bordered panel on // the right, the keys along the bottom — and a dialog over it all when one is // open. One frame, so the setup and the console read as one program. // // Keyboard only: these run on serial consoles and IPMI as often as in a // desktop terminal. // navItem is one line of the sidebar. type navItem struct { Label string // State is "done", "current", "todo" for steps; "" for menu entries, // where Current marks the one focused. State string Current bool // Dim greys an entry that cannot be chosen now. Dim bool // Heading is a title between groups of entries, not an entry. Heading bool } type frame struct { Width, Height int Title string // left of the title bar Where string // right of the title bar: the machine NavTitle string Nav []navItem PanelTitle string Body string // Scroll is how many of the body's lines are scrolled off the top. Scroll int Keys string // Dialog, when set, is drawn over the middle of the screen. Dialog string DialogTitle string } var ( fBar = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.AdaptiveColor{Light: "#FFFFFF", Dark: "#0B1B1B"}).Background(tuiAccent) fBorder = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(tuiDim) fFocus = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(tuiAccent) fKeys = lipgloss.NewStyle().Foreground(tuiDim) fSelected = lipgloss.NewStyle().Bold(true).Foreground(tuiAccent) fDialog = lipgloss.NewStyle().Border(lipgloss.DoubleBorder()).BorderForeground(tuiAccent).Padding(1, 2) ) const navWidth = 24 // render draws the whole screen, exactly Width by Height. func (f frame) render() string { w, h := max(f.Width, 60), max(f.Height, 16) title := " " + f.Title right := f.Where + " " gap := max(1, w-ansi.StringWidth(title)-ansi.StringWidth(right)) bar := fBar.Render(title + strings.Repeat(" ", gap) + right) // Panels fill what the bars leave: one line each above and below. ph := h - 2 inner := ph - 2 // borders var navLines []string cur := 0 for _, it := range f.Nav { mark := " " label := it.Label if it.Heading { navLines = append(navLines, sDim.Render(label)) continue } if it.Current || it.State == "current" { cur = len(navLines) } switch { case it.State == "done": mark = sOK.Render("✓ ") case it.State == "current": mark = fSelected.Render("› ") label = fSelected.Render(label) case it.Current: mark = fSelected.Render("› ") label = fSelected.Render(label) case it.Dim || it.State == "todo": label = sDim.Render(label) } navLines = append(navLines, truncateWidth(mark+label, navWidth-2)) } // Taller than the screen: the part around the current entry, with a mark // at an edge that has more beyond it. if len(navLines) > inner { total := len(navLines) top := min(max(0, cur-inner/2), total-inner) navLines = append([]string{}, navLines[top:top+inner]...) if top > 0 { navLines[0] = sDim.Render(" ↑ more") } if top+inner < total { navLines[inner-1] = sDim.Render(" ↓ more") } } left := box(fBorder, f.NavTitle, strings.Join(navLines, "\n"), navWidth, inner) pw := w - navWidth - 2 - 2 lines := strings.Split(strings.TrimRight(f.Body, "\n"), "\n") scroll := min(max(f.Scroll, 0), max(0, len(lines)-inner)) visible := lines[scroll:] if len(visible) > inner { visible = visible[:inner] // Said in the last line, where it is seen. visible[inner-1] = sDim.Render(" ↓ more — PgDn") } for i := range visible { visible[i] = truncateWidth(visible[i], pw) } panelTitle := f.PanelTitle if scroll > 0 { panelTitle += " ↑" } main := box(fFocus, panelTitle, strings.Join(visible, "\n"), pw, inner) keys := fKeys.Render(" " + truncateWidth(f.Keys, w-2)) screen := bar + "\n" + lipgloss.JoinHorizontal(lipgloss.Top, left, main) + "\n" + keys if f.Dialog != "" { dw := min(w-8, max(50, maxLineWidth(f.Dialog)+6)) d := fDialog.Width(dw).Render(f.Dialog) if f.DialogTitle != "" { d = titled(d, f.DialogTitle) } screen = overlay(screen, d, w, h) } return screen } // box draws content in a border of the given inner size, with a title set // into the top edge. func box(style lipgloss.Style, title, content string, width, height int) string { b := style.Width(width).Height(height).Render(content) if title == "" { return b } return titled(b, title) } // titled sets " title " into a box's top border, after its corner. func titled(b, title string) string { lines := strings.Split(b, "\n") if len(lines) == 0 { return b } top := lines[0] t := " " + title + " " tw := ansi.StringWidth(t) if ansi.StringWidth(top) < tw+4 { return b } lines[0] = ansi.Truncate(top, 2, "") + sTitle.Render(t) + ansi.TruncateLeft(top, 2+tw, "") return strings.Join(lines, "\n") } // overlay draws top over the middle of base, both w by h. func overlay(base, top string, w, h int) string { bl := strings.Split(base, "\n") tl := strings.Split(top, "\n") tw := maxLineWidth(top) x := max(0, (w-tw)/2) y := max(0, (h-len(tl))/2) for i, l := range tl { row := y + i if row >= len(bl) { break } under := bl[row] left := ansi.Truncate(under, x, "") if pad := x - ansi.StringWidth(left); pad > 0 { left += strings.Repeat(" ", pad) } right := ansi.TruncateLeft(under, x+ansi.StringWidth(l), "") bl[row] = left + l + right } return strings.Join(bl, "\n") } func maxLineWidth(s string) int { n := 0 for _, l := range strings.Split(s, "\n") { n = max(n, ansi.StringWidth(l)) } return n } // truncateWidth cuts a line, colours and all, to w cells. func truncateWidth(s string, w int) string { if ansi.StringWidth(s) <= w { return s } return ansi.Truncate(s, max(w-1, 0), "…") } // table draws rows under a header in columns as wide as they need, cut to // width; the selected row is shown reversed when the panel has the focus. func table(headers []string, rows [][]string, selected int, width int, focused bool) string { widths := make([]int, len(headers)) for i, h := range headers { widths[i] = ansi.StringWidth(h) } for _, r := range rows { for i := range headers { if i < len(r) { widths[i] = min(max(widths[i], ansi.StringWidth(r[i])), 44) } } } line := func(cells []string) string { var b strings.Builder for i := range headers { c := "" if i < len(cells) { c = truncateWidth(cells[i], widths[i]) } b.WriteString(c) if i < len(headers)-1 { b.WriteString(strings.Repeat(" ", widths[i]-ansi.StringWidth(c)+2)) } } return truncateWidth(b.String(), width) } var b strings.Builder b.WriteString(" " + lipgloss.NewStyle().Bold(true).Foreground(tuiDim).Render(line(headers)) + "\n") for i, r := range rows { l := line(r) switch { case i == selected && focused: pad := max(0, width-ansi.StringWidth(ansi.Strip(l))) l = fBar.Render(ansi.Strip(l) + strings.Repeat(" ", pad)) case i == selected: l = lipgloss.NewStyle().Bold(true).Render(l) } b.WriteString(" " + l + "\n") } if len(rows) == 0 { b.WriteString(" " + sDim.Render("nothing yet") + "\n") } return b.String() }