feat: add TUI reusable components: metric cards, sparklines, tab bar, and status bar
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 <noreply@anthropic.com>
This commit is contained in:
117
internal/tui/components/card.go
Normal file
117
internal/tui/components/card.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
42
internal/tui/components/statusbar.go
Normal file
42
internal/tui/components/statusbar.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
88
internal/tui/components/tabbar.go
Normal file
88
internal/tui/components/tabbar.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user