From 79ab17488e53fcaf8dd70da8a7a0d60594acc9d3 Mon Sep 17 00:00:00 2001 From: teernisse Date: Thu, 19 Feb 2026 13:02:45 -0500 Subject: [PATCH] feat: add TUI reusable components: metric cards, sparklines, tab bar, and status bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement shared UI components used across the dashboard tabs: - tui/components/card.go: Three components: * MetricCard: bordered card with label (muted), value (bold), and optional delta (dim) — used for the dashboard's top-level KPIs. * MetricCardRow: renders N cards side-by-side using lipgloss horizontal join, auto-calculating card width from available space. * Sparkline: theme-colored Unicode block sparkline (8-level, auto-scaled to series max). Used in Dashboard and Trends tabs. * ProgressBar: filled/empty bar (accent + dim) with percentage label. Used in the Budget tab for plan-relative spend. - tui/components/statusbar.go: Bottom status bar with left-aligned keybinding hints ([f]ilter [?]help [q]uit), current filter info, and right-aligned data age indicator. Padding auto-fills to terminal width. - tui/components/tabbar.go: 10-tab navigation bar split across two rows (Dashboard/Costs/Sessions/Models/Projects on row 1, Trends/Efficiency/Activity/Budget/Settings on row 2). Each inactive tab highlights its keyboard shortcut letter with [bracket] notation. Active tab renders in accent color. Settings uses 'x' as its shortcut (not present in the name, so appended). TabIdxByKey maps key presses to tab indices. Co-Authored-By: Claude Opus 4.6 --- internal/tui/components/card.go | 117 +++++++++++++++++++++++++++ internal/tui/components/statusbar.go | 42 ++++++++++ internal/tui/components/tabbar.go | 88 ++++++++++++++++++++ 3 files changed, 247 insertions(+) create mode 100644 internal/tui/components/card.go create mode 100644 internal/tui/components/statusbar.go create mode 100644 internal/tui/components/tabbar.go diff --git a/internal/tui/components/card.go b/internal/tui/components/card.go new file mode 100644 index 0000000..aa2b8c2 --- /dev/null +++ b/internal/tui/components/card.go @@ -0,0 +1,117 @@ +package components + +import ( + "fmt" + + "cburn/internal/tui/theme" + + "github.com/charmbracelet/lipgloss" +) + +// MetricCard renders a small metric card with label, value, and delta. +func MetricCard(label, value, delta string, width int) string { + t := theme.Active + + cardStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(t.Border). + Width(width). + Padding(0, 1) + + labelStyle := lipgloss.NewStyle(). + Foreground(t.TextMuted) + + valueStyle := lipgloss.NewStyle(). + Foreground(t.TextPrimary). + Bold(true) + + deltaStyle := lipgloss.NewStyle(). + Foreground(t.TextDim) + + content := labelStyle.Render(label) + "\n" + + valueStyle.Render(value) + if delta != "" { + content += "\n" + deltaStyle.Render(delta) + } + + return cardStyle.Render(content) +} + +// MetricCardRow renders a row of metric cards side by side. +func MetricCardRow(cards []struct{ Label, Value, Delta string }, totalWidth int) string { + if len(cards) == 0 { + return "" + } + + cardWidth := (totalWidth - len(cards) - 1) / len(cards) + if cardWidth < 10 { + cardWidth = 10 + } + + var rendered []string + for _, c := range cards { + rendered = append(rendered, MetricCard(c.Label, c.Value, c.Delta, cardWidth)) + } + + return lipgloss.JoinHorizontal(lipgloss.Top, rendered...) +} + +// Sparkline renders a unicode sparkline from values. +func Sparkline(values []float64, color lipgloss.Color) string { + if len(values) == 0 { + return "" + } + + blocks := []rune{'▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'} + + max := values[0] + for _, v := range values[1:] { + if v > max { + max = v + } + } + if max == 0 { + max = 1 + } + + style := lipgloss.NewStyle().Foreground(color) + + var result string + for _, v := range values { + idx := int(v / max * float64(len(blocks)-1)) + if idx >= len(blocks) { + idx = len(blocks) - 1 + } + if idx < 0 { + idx = 0 + } + result += string(blocks[idx]) + } + + return style.Render(result) +} + +// ProgressBar renders a colored progress bar. +func ProgressBar(pct float64, width int) string { + t := theme.Active + filled := int(pct * float64(width)) + if filled > width { + filled = width + } + if filled < 0 { + filled = 0 + } + + filledStyle := lipgloss.NewStyle().Foreground(t.Accent) + emptyStyle := lipgloss.NewStyle().Foreground(t.TextDim) + + bar := "" + for i := 0; i < filled; i++ { + bar += filledStyle.Render("█") + } + for i := filled; i < width; i++ { + bar += emptyStyle.Render("░") + } + + return fmt.Sprintf("%s %.1f%%", bar, pct*100) +} diff --git a/internal/tui/components/statusbar.go b/internal/tui/components/statusbar.go new file mode 100644 index 0000000..28b7246 --- /dev/null +++ b/internal/tui/components/statusbar.go @@ -0,0 +1,42 @@ +package components + +import ( + "fmt" + + "cburn/internal/tui/theme" + + "github.com/charmbracelet/lipgloss" +) + +// RenderStatusBar renders the bottom status bar. +func RenderStatusBar(width int, filterInfo string, dataAge string) string { + t := theme.Active + + style := lipgloss.NewStyle(). + Foreground(t.TextMuted). + Width(width) + + left := " [f]ilter [?]help [q]uit" + right := "" + if dataAge != "" { + right = fmt.Sprintf("Data: %s ", dataAge) + } + + if filterInfo != "" { + left += " " + filterInfo + } + + // Pad middle + padding := width - lipgloss.Width(left) - lipgloss.Width(right) + if padding < 0 { + padding = 0 + } + + bar := left + for i := 0; i < padding; i++ { + bar += " " + } + bar += right + + return style.Render(bar) +} diff --git a/internal/tui/components/tabbar.go b/internal/tui/components/tabbar.go new file mode 100644 index 0000000..815e628 --- /dev/null +++ b/internal/tui/components/tabbar.go @@ -0,0 +1,88 @@ +package components + +import ( + "strings" + + "cburn/internal/tui/theme" + + "github.com/charmbracelet/lipgloss" +) + +// Tab represents a single tab in the tab bar. +type Tab struct { + Name string + Key rune + KeyPos int // position of the shortcut letter in the name (-1 if not in name) +} + +// Tabs defines all available tabs. +var Tabs = []Tab{ + {Name: "Dashboard", Key: 'd', KeyPos: 0}, + {Name: "Costs", Key: 'c', KeyPos: 0}, + {Name: "Sessions", Key: 's', KeyPos: 0}, + {Name: "Models", Key: 'm', KeyPos: 0}, + {Name: "Projects", Key: 'p', KeyPos: 0}, + {Name: "Trends", Key: 't', KeyPos: 0}, + {Name: "Efficiency", Key: 'e', KeyPos: 0}, + {Name: "Activity", Key: 'a', KeyPos: 0}, + {Name: "Budget", Key: 'b', KeyPos: 0}, + {Name: "Settings", Key: 'x', KeyPos: -1}, // x is not in "Settings" +} + +// RenderTabBar renders the tab bar with the given active index. +func RenderTabBar(activeIdx int, width int) string { + t := theme.Active + + activeStyle := lipgloss.NewStyle(). + Foreground(t.Accent). + Bold(true) + + inactiveStyle := lipgloss.NewStyle(). + Foreground(t.TextMuted) + + keyStyle := lipgloss.NewStyle(). + Foreground(t.Accent). + Bold(true) + + dimKeyStyle := lipgloss.NewStyle(). + Foreground(t.TextDim) + + var parts []string + for i, tab := range Tabs { + var rendered string + if i == activeIdx { + rendered = activeStyle.Render(tab.Name) + } else { + // Render with highlighted shortcut key + if tab.KeyPos >= 0 && tab.KeyPos < len(tab.Name) { + before := tab.Name[:tab.KeyPos] + key := string(tab.Name[tab.KeyPos]) + after := tab.Name[tab.KeyPos+1:] + rendered = inactiveStyle.Render(before) + + dimKeyStyle.Render("[") + keyStyle.Render(key) + dimKeyStyle.Render("]") + + inactiveStyle.Render(after) + } else { + // Key not in name (e.g., "Settings" with 'x') + rendered = inactiveStyle.Render(tab.Name) + + dimKeyStyle.Render("[") + keyStyle.Render(string(tab.Key)) + dimKeyStyle.Render("]") + } + } + parts = append(parts, rendered) + } + + // Split into two rows if needed + row1 := strings.Join(parts[:5], " ") + row2 := strings.Join(parts[5:], " ") + + return " " + row1 + "\n " + row2 +} + +// TabIdxByKey returns the tab index for a given key press, or -1. +func TabIdxByKey(key rune) int { + for i, tab := range Tabs { + if tab.Key == key { + return i + } + } + return -1 +}