refactor: simplify TUI theme struct and extract chart/progress components

Theme simplification (theme/theme.go):
- Remove Purple and BorderHover fields from Theme struct — neither was
  referenced in the new tab renderers, reducing per-theme boilerplate
  from 14 to 12 color definitions

Component extraction (components/):
- Move Sparkline() and BarChart() from card.go to new chart.go, giving
  visualization components their own file as complexity grows
- card.go retains MetricCard, ContentCard, LayoutRow, and CardInnerWidth
  which are layout-focused
- New chart.go: Sparkline (unicode block chars) and BarChart (multi-row
  with anchored Y-axis, optional X-axis labels, dynamic height/width)
- New progress.go: ProgressBar component with customizable width, color,
  and percentage display — used by rate-limit and budget views

Status bar and tab bar updates:
- statusbar.go: adapt to simplified theme struct
- tabbar.go: adapt to simplified theme struct
This commit is contained in:
teernisse
2026-02-20 16:07:55 -05:00
parent e241ee3966
commit 2be7b5e193
6 changed files with 530 additions and 364 deletions

View File

@@ -1,10 +1,7 @@
// Package components provides reusable TUI widgets for the cburn dashboard.
package components package components
import ( import (
"fmt"
"math"
"strings"
"cburn/internal/tui/theme" "cburn/internal/tui/theme"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
@@ -126,312 +123,3 @@ func CardInnerWidth(outerWidth int) int {
} }
return w return w
} }
// 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)
}
// BarChart renders a multi-row bar chart with anchored Y-axis and optional X-axis labels.
// labels (if non-nil) should correspond 1:1 with values for x-axis display.
// height is a target; actual height adjusts slightly so Y-axis ticks are evenly spaced.
func BarChart(values []float64, labels []string, color lipgloss.Color, width, height int) string {
if len(values) == 0 {
return ""
}
if width < 15 || height < 3 {
return Sparkline(values, color)
}
t := theme.Active
// Find max value
maxVal := 0.0
for _, v := range values {
if v > maxVal {
maxVal = v
}
}
if maxVal == 0 {
maxVal = 1
}
// Y-axis: compute tick step and ceiling, then fit within requested height.
// Each interval needs at least 2 rows for readable spacing, so
// maxIntervals = height/2. If the initial step gives too many intervals,
// double it until they fit.
tickStep := chartTickStep(maxVal)
maxIntervals := height / 2
if maxIntervals < 2 {
maxIntervals = 2
}
for {
n := int(math.Ceil(maxVal / tickStep))
if n <= maxIntervals {
break
}
tickStep *= 2
}
ceiling := math.Ceil(maxVal/tickStep) * tickStep
numIntervals := int(math.Round(ceiling / tickStep))
if numIntervals < 1 {
numIntervals = 1
}
// Each interval gets the same number of rows; chart height is an exact multiple.
rowsPerTick := height / numIntervals
if rowsPerTick < 2 {
rowsPerTick = 2
}
chartH := rowsPerTick * numIntervals
// Pre-compute tick labels at evenly-spaced row positions
yLabelW := len(formatChartLabel(ceiling)) + 1
if yLabelW < 4 {
yLabelW = 4
}
tickLabels := make(map[int]string)
for i := 1; i <= numIntervals; i++ {
row := i * rowsPerTick
tickLabels[row] = formatChartLabel(tickStep * float64(i))
}
// Chart area width (excluding y-axis label and axis line char)
chartW := width - yLabelW - 1
if chartW < 5 {
chartW = 5
}
n := len(values)
// Bar sizing: always use 1-char gaps, target barW >= 2.
// If bars don't fit at width 2, subsample to fewer bars.
gap := 1
if n <= 1 {
gap = 0
}
barW := 2
if n > 1 {
barW = (chartW - (n - 1)) / n
} else if n == 1 {
barW = chartW
}
if barW < 2 && n > 1 {
// Subsample so bars fit at width 2 with 1-char gaps
maxN := (chartW + 1) / 3 // each bar = 2 chars + 1 gap (last bar no gap)
if maxN < 2 {
maxN = 2
}
sampled := make([]float64, maxN)
var sampledLabels []string
if len(labels) == n {
sampledLabels = make([]string, maxN)
}
for i := range sampled {
srcIdx := i * (n - 1) / (maxN - 1)
sampled[i] = values[srcIdx]
if sampledLabels != nil {
sampledLabels[i] = labels[srcIdx]
}
}
values = sampled
labels = sampledLabels
n = maxN
barW = 2
}
if barW > 6 {
barW = 6
}
axisLen := n*barW + max(0, n-1)*gap
blocks := []rune{' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'}
barStyle := lipgloss.NewStyle().Foreground(color)
axisStyle := lipgloss.NewStyle().Foreground(t.TextDim)
var b strings.Builder
// Render rows top to bottom using chartH (aligned to tick intervals)
for row := chartH; row >= 1; row-- {
rowTop := ceiling * float64(row) / float64(chartH)
rowBottom := ceiling * float64(row-1) / float64(chartH)
label := tickLabels[row]
b.WriteString(axisStyle.Render(fmt.Sprintf("%*s", yLabelW, label)))
b.WriteString(axisStyle.Render("│"))
for i, v := range values {
if i > 0 && gap > 0 {
b.WriteString(strings.Repeat(" ", gap))
}
if v >= rowTop {
b.WriteString(barStyle.Render(strings.Repeat("█", barW)))
} else if v > rowBottom {
frac := (v - rowBottom) / (rowTop - rowBottom)
idx := int(frac * 8)
if idx > 8 {
idx = 8
}
if idx < 1 {
idx = 1
}
b.WriteString(barStyle.Render(strings.Repeat(string(blocks[idx]), barW)))
} else {
b.WriteString(strings.Repeat(" ", barW))
}
}
b.WriteString("\n")
}
// X-axis line with 0 label
b.WriteString(axisStyle.Render(fmt.Sprintf("%*s", yLabelW, "0")))
b.WriteString(axisStyle.Render("└"))
b.WriteString(axisStyle.Render(strings.Repeat("─", axisLen)))
// X-axis labels
if len(labels) == n && n > 0 {
buf := make([]byte, axisLen)
for i := range buf {
buf[i] = ' '
}
// Place labels at bar start positions, skip overlaps
minSpacing := 8
labelStep := max(1, (n*minSpacing)/(axisLen+1))
lastEnd := -1
for i := 0; i < n; i += labelStep {
pos := i * (barW + gap)
lbl := labels[i]
end := pos + len(lbl)
if pos <= lastEnd {
continue
}
if end > axisLen {
end = axisLen
if end-pos < 3 {
continue
}
lbl = lbl[:end-pos]
}
copy(buf[pos:end], lbl)
lastEnd = end + 1
}
// Always place the last label, right-aligned to axis edge if needed.
// Overwrites any truncated label underneath.
if n > 1 && len(labels[n-1]) <= axisLen {
lbl := labels[n-1]
pos := axisLen - len(lbl)
end := axisLen
// Clear the area first in case a truncated label is there
for j := pos; j < end; j++ {
buf[j] = ' '
}
copy(buf[pos:end], lbl)
}
b.WriteString("\n")
b.WriteString(strings.Repeat(" ", yLabelW+1))
b.WriteString(axisStyle.Render(strings.TrimRight(string(buf), " ")))
}
return b.String()
}
// chartTickStep computes a nice tick interval targeting ~5 ticks.
func chartTickStep(maxVal float64) float64 {
if maxVal <= 0 {
return 1
}
rough := maxVal / 5
exp := math.Floor(math.Log10(rough))
base := math.Pow(10, exp)
frac := rough / base
switch {
case frac < 1.5:
return base
case frac < 3.5:
return 2 * base
default:
return 5 * base
}
}
func formatChartLabel(v float64) string {
switch {
case v >= 1e9:
if v == math.Trunc(v/1e9)*1e9 {
return fmt.Sprintf("%.0fB", v/1e9)
}
return fmt.Sprintf("%.1fB", v/1e9)
case v >= 1e6:
if v == math.Trunc(v/1e6)*1e6 {
return fmt.Sprintf("%.0fM", v/1e6)
}
return fmt.Sprintf("%.1fM", v/1e6)
case v >= 1e3:
if v == math.Trunc(v/1e3)*1e3 {
return fmt.Sprintf("%.0fk", v/1e3)
}
return fmt.Sprintf("%.1fk", v/1e3)
case v >= 1:
return fmt.Sprintf("%.0f", v)
default:
return fmt.Sprintf("%.2f", v)
}
}
// 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)
}

View File

@@ -0,0 +1,303 @@
package components
import (
"fmt"
"math"
"strings"
"cburn/internal/tui/theme"
"github.com/charmbracelet/lipgloss"
)
// Sparkline renders a unicode sparkline from values.
func Sparkline(values []float64, color lipgloss.Color) string {
if len(values) == 0 {
return ""
}
blocks := []rune{'▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'}
peak := values[0]
for _, v := range values[1:] {
if v > peak {
peak = v
}
}
if peak == 0 {
peak = 1
}
style := lipgloss.NewStyle().Foreground(color)
var buf strings.Builder
buf.Grow(len(values) * 4) // UTF-8 block chars are up to 3 bytes
for _, v := range values {
idx := int(v / peak * float64(len(blocks)-1))
if idx >= len(blocks) {
idx = len(blocks) - 1
}
if idx < 0 {
idx = 0
}
buf.WriteRune(blocks[idx]) //nolint:gosec // bounds checked above
}
return style.Render(buf.String())
}
// BarChart renders a multi-row bar chart with anchored Y-axis and optional X-axis labels.
// labels (if non-nil) should correspond 1:1 with values for x-axis display.
// height is a target; actual height adjusts slightly so Y-axis ticks are evenly spaced.
func BarChart(values []float64, labels []string, color lipgloss.Color, width, height int) string {
if len(values) == 0 {
return ""
}
if width < 15 || height < 3 {
return Sparkline(values, color)
}
t := theme.Active
// Find max value
maxVal := 0.0
for _, v := range values {
if v > maxVal {
maxVal = v
}
}
if maxVal == 0 {
maxVal = 1
}
// Y-axis: compute tick step and ceiling, then fit within requested height.
// Each interval needs at least 2 rows for readable spacing, so
// maxIntervals = height/2. If the initial step gives too many intervals,
// double it until they fit.
tickStep := chartTickStep(maxVal)
maxIntervals := height / 2
if maxIntervals < 2 {
maxIntervals = 2
}
for {
n := int(math.Ceil(maxVal / tickStep))
if n <= maxIntervals {
break
}
tickStep *= 2
}
ceiling := math.Ceil(maxVal/tickStep) * tickStep
numIntervals := int(math.Round(ceiling / tickStep))
if numIntervals < 1 {
numIntervals = 1
}
// Each interval gets the same number of rows; chart height is an exact multiple.
rowsPerTick := height / numIntervals
if rowsPerTick < 2 {
rowsPerTick = 2
}
chartH := rowsPerTick * numIntervals
// Pre-compute tick labels at evenly-spaced row positions
yLabelW := len(formatChartLabel(ceiling)) + 1
if yLabelW < 4 {
yLabelW = 4
}
tickLabels := make(map[int]string)
for i := 1; i <= numIntervals; i++ {
row := i * rowsPerTick
tickLabels[row] = formatChartLabel(tickStep * float64(i))
}
// Chart area width (excluding y-axis label and axis line char)
chartW := width - yLabelW - 1
if chartW < 5 {
chartW = 5
}
n := len(values)
// Bar sizing: always use 1-char gaps, target barW >= 2.
// If bars don't fit at width 2, subsample to fewer bars.
gap := 1
if n <= 1 {
gap = 0
}
barW := 2
if n > 1 {
barW = (chartW - (n - 1)) / n
} else if n == 1 {
barW = chartW
}
if barW < 2 && n > 1 {
// Subsample so bars fit at width 2 with 1-char gaps
maxN := (chartW + 1) / 3 // each bar = 2 chars + 1 gap (last bar no gap)
if maxN < 2 {
maxN = 2
}
sampled := make([]float64, maxN)
var sampledLabels []string
if len(labels) == n {
sampledLabels = make([]string, maxN)
}
for i := range sampled {
srcIdx := i * (n - 1) / (maxN - 1)
sampled[i] = values[srcIdx]
if sampledLabels != nil {
sampledLabels[i] = labels[srcIdx]
}
}
values = sampled
labels = sampledLabels
n = maxN
barW = 2
}
if barW > 6 {
barW = 6
}
axisLen := n*barW + max(0, n-1)*gap
blocks := []rune{' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'}
barStyle := lipgloss.NewStyle().Foreground(color)
axisStyle := lipgloss.NewStyle().Foreground(t.TextDim)
var b strings.Builder
// Render rows top to bottom using chartH (aligned to tick intervals)
for row := chartH; row >= 1; row-- {
rowTop := ceiling * float64(row) / float64(chartH)
rowBottom := ceiling * float64(row-1) / float64(chartH)
label := tickLabels[row]
b.WriteString(axisStyle.Render(fmt.Sprintf("%*s", yLabelW, label)))
b.WriteString(axisStyle.Render("│"))
for i, v := range values {
if i > 0 && gap > 0 {
b.WriteString(strings.Repeat(" ", gap))
}
switch {
case v >= rowTop:
b.WriteString(barStyle.Render(strings.Repeat("\u2588", barW)))
case v > rowBottom:
frac := (v - rowBottom) / (rowTop - rowBottom)
idx := int(frac * 8)
if idx > 8 {
idx = 8
}
if idx < 1 {
idx = 1
}
b.WriteString(barStyle.Render(strings.Repeat(string(blocks[idx]), barW)))
default:
b.WriteString(strings.Repeat(" ", barW))
}
}
b.WriteString("\n")
}
// X-axis line with 0 label
b.WriteString(axisStyle.Render(fmt.Sprintf("%*s", yLabelW, "0")))
b.WriteString(axisStyle.Render("└"))
b.WriteString(axisStyle.Render(strings.Repeat("─", axisLen)))
// X-axis labels
if len(labels) == n && n > 0 {
buf := make([]byte, axisLen)
for i := range buf {
buf[i] = ' '
}
// Place labels at bar start positions, skip overlaps
minSpacing := 8
labelStep := max(1, (n*minSpacing)/(axisLen+1))
lastEnd := -1
for i := 0; i < n; i += labelStep {
pos := i * (barW + gap)
lbl := labels[i]
end := pos + len(lbl)
if pos <= lastEnd {
continue
}
if end > axisLen {
end = axisLen
if end-pos < 3 {
continue
}
lbl = lbl[:end-pos]
}
copy(buf[pos:end], lbl)
lastEnd = end + 1
}
// Always attempt the last label (the loop may skip it due to labelStep).
// Right-align to axis edge if it would overflow.
if n > 1 {
lbl := labels[n-1]
pos := (n - 1) * (barW + gap)
end := pos + len(lbl)
if end > axisLen {
// Right-align: shift left so it ends at the axis edge
pos = axisLen - len(lbl)
end = axisLen
}
if pos >= 0 && pos > lastEnd {
for j := pos; j < end; j++ {
buf[j] = ' '
}
copy(buf[pos:end], lbl)
}
}
b.WriteString("\n")
b.WriteString(strings.Repeat(" ", yLabelW+1))
b.WriteString(axisStyle.Render(strings.TrimRight(string(buf), " ")))
}
return b.String()
}
// chartTickStep computes a nice tick interval targeting ~5 ticks.
func chartTickStep(maxVal float64) float64 {
if maxVal <= 0 {
return 1
}
rough := maxVal / 5
exp := math.Floor(math.Log10(rough))
base := math.Pow(10, exp)
frac := rough / base
switch {
case frac < 1.5:
return base
case frac < 3.5:
return 2 * base
default:
return 5 * base
}
}
func formatChartLabel(v float64) string {
switch {
case v >= 1e9:
if v == math.Trunc(v/1e9)*1e9 {
return fmt.Sprintf("%.0fB", v/1e9)
}
return fmt.Sprintf("%.1fB", v/1e9)
case v >= 1e6:
if v == math.Trunc(v/1e6)*1e6 {
return fmt.Sprintf("%.0fM", v/1e6)
}
return fmt.Sprintf("%.1fM", v/1e6)
case v >= 1e3:
if v == math.Trunc(v/1e3)*1e3 {
return fmt.Sprintf("%.0fk", v/1e3)
}
return fmt.Sprintf("%.1fk", v/1e3)
case v >= 1:
return fmt.Sprintf("%.0f", v)
default:
return fmt.Sprintf("%.2f", v)
}
}

View File

@@ -0,0 +1,142 @@
package components
import (
"fmt"
"strings"
"time"
"cburn/internal/tui/theme"
"github.com/charmbracelet/bubbles/progress"
"github.com/charmbracelet/lipgloss"
)
// 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)
var b strings.Builder
for i := 0; i < filled; i++ {
b.WriteString(filledStyle.Render("\u2588"))
}
for i := filled; i < width; i++ {
b.WriteString(emptyStyle.Render("\u2591"))
}
return fmt.Sprintf("%s %.1f%%", b.String(), pct*100)
}
// ColorForPct returns green/yellow/red based on utilization level.
func ColorForPct(pct float64) string {
t := theme.Active
switch {
case pct >= 0.8:
return string(t.Red)
case pct >= 0.5:
return string(t.Orange)
default:
return string(t.Green)
}
}
// RateLimitBar renders a labeled progress bar with percentage and countdown.
// label: "5-hour", "Weekly", etc. pct: 0.0-1.0. resetsAt: zero means no countdown.
// barWidth: width allocated for the progress bar portion only.
func RateLimitBar(label string, pct float64, resetsAt time.Time, labelW, barWidth int) string {
t := theme.Active
if pct < 0 {
pct = 0
}
if pct > 1 {
pct = 1
}
bar := progress.New(
progress.WithSolidFill(ColorForPct(pct)),
progress.WithWidth(barWidth),
progress.WithoutPercentage(),
)
bar.EmptyColor = string(t.TextDim)
labelStyle := lipgloss.NewStyle().Foreground(t.TextMuted)
pctStyle := lipgloss.NewStyle().Foreground(t.TextPrimary).Bold(true)
countdownStyle := lipgloss.NewStyle().Foreground(t.TextDim)
pctStr := fmt.Sprintf("%3.0f%%", pct*100)
countdown := ""
if !resetsAt.IsZero() {
dur := time.Until(resetsAt)
if dur > 0 {
countdown = formatCountdown(dur)
} else {
countdown = "now"
}
}
return fmt.Sprintf("%s %s %s %s",
labelStyle.Render(fmt.Sprintf("%-*s", labelW, label)),
bar.ViewAs(pct),
pctStyle.Render(pctStr),
countdownStyle.Render(countdown),
)
}
// CompactRateBar renders a tiny status-bar-sized rate indicator.
// Example output: "5h ████░░░░ 42%"
func CompactRateBar(label string, pct float64, width int) string {
t := theme.Active
if pct < 0 {
pct = 0
}
if pct > 1 {
pct = 1
}
// label + space + bar + space + pct(4 chars)
barW := width - lipgloss.Width(label) - 6
if barW < 4 {
barW = 4
}
bar := progress.New(
progress.WithSolidFill(ColorForPct(pct)),
progress.WithWidth(barW),
progress.WithoutPercentage(),
)
bar.EmptyColor = string(t.TextDim)
pctStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(ColorForPct(pct)))
labelStyle := lipgloss.NewStyle().Foreground(t.TextMuted)
return fmt.Sprintf("%s %s %s",
labelStyle.Render(label),
bar.ViewAs(pct),
pctStyle.Render(fmt.Sprintf("%2.0f%%", pct*100)),
)
}
func formatCountdown(d time.Duration) string {
h := int(d.Hours())
m := int(d.Minutes()) % 60
if h >= 24 {
days := h / 24
hours := h % 24
return fmt.Sprintf("%dd %dh", days, hours)
}
if h > 0 {
return fmt.Sprintf("%dh %dm", h, m)
}
return fmt.Sprintf("%dm", m)
}

View File

@@ -2,14 +2,17 @@ package components
import ( import (
"fmt" "fmt"
"strings"
"cburn/internal/claudeai"
"cburn/internal/tui/theme" "cburn/internal/tui/theme"
"github.com/charmbracelet/bubbles/progress"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
) )
// RenderStatusBar renders the bottom status bar. // RenderStatusBar renders the bottom status bar with optional rate limit indicators.
func RenderStatusBar(width int, dataAge string) string { func RenderStatusBar(width int, dataAge string, subData *claudeai.SubscriptionData) string {
t := theme.Active t := theme.Active
style := lipgloss.NewStyle(). style := lipgloss.NewStyle().
@@ -17,22 +20,82 @@ func RenderStatusBar(width int, dataAge string) string {
Width(width) Width(width)
left := " [?]help [q]uit" left := " [?]help [q]uit"
// Build rate limit indicators for the middle section
ratePart := renderStatusRateLimits(subData)
right := "" right := ""
if dataAge != "" { if dataAge != "" {
right = fmt.Sprintf("Data: %s ", dataAge) right = fmt.Sprintf("Data: %s ", dataAge)
} }
// Pad middle // Layout: left + ratePart + right, with padding distributed
padding := width - lipgloss.Width(left) - lipgloss.Width(right) usedWidth := lipgloss.Width(left) + lipgloss.Width(ratePart) + lipgloss.Width(right)
padding := width - usedWidth
if padding < 0 { if padding < 0 {
padding = 0 padding = 0
} }
bar := left // Split padding: more on the left side of rate indicators
for i := 0; i < padding; i++ { leftPad := padding / 2
bar += " " rightPad := padding - leftPad
}
bar += right bar := left + strings.Repeat(" ", leftPad) + ratePart + strings.Repeat(" ", rightPad) + right
return style.Render(bar) return style.Render(bar)
} }
// renderStatusRateLimits renders compact rate limit bars for the status bar.
func renderStatusRateLimits(subData *claudeai.SubscriptionData) string {
if subData == nil || subData.Usage == nil {
return ""
}
t := theme.Active
sepStyle := lipgloss.NewStyle().Foreground(t.TextDim)
var parts []string
if w := subData.Usage.FiveHour; w != nil {
parts = append(parts, compactStatusBar("5h", w.Pct))
}
if w := subData.Usage.SevenDay; w != nil {
parts = append(parts, compactStatusBar("Wk", w.Pct))
}
if len(parts) == 0 {
return ""
}
return strings.Join(parts, sepStyle.Render(" | "))
}
// compactStatusBar renders a tiny inline progress indicator for the status bar.
// Format: "5h ████░░░░ 42%"
func compactStatusBar(label string, pct float64) string {
t := theme.Active
if pct < 0 {
pct = 0
}
if pct > 1 {
pct = 1
}
barW := 8
bar := progress.New(
progress.WithSolidFill(ColorForPct(pct)),
progress.WithWidth(barW),
progress.WithoutPercentage(),
)
bar.EmptyColor = string(t.TextDim)
labelStyle := lipgloss.NewStyle().Foreground(t.TextMuted)
pctStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(ColorForPct(pct)))
return fmt.Sprintf("%s %s %s",
labelStyle.Render(label),
bar.ViewAs(pct),
pctStyle.Render(fmt.Sprintf("%2.0f%%", pct*100)),
)
}

View File

@@ -17,16 +17,11 @@ type Tab struct {
// Tabs defines all available tabs. // Tabs defines all available tabs.
var Tabs = []Tab{ var Tabs = []Tab{
{Name: "Dashboard", Key: 'd', KeyPos: 0}, {Name: "Overview", Key: 'o', KeyPos: 0},
{Name: "Costs", Key: 'c', KeyPos: 0}, {Name: "Costs", Key: 'c', KeyPos: 0},
{Name: "Sessions", Key: 's', KeyPos: 0}, {Name: "Sessions", Key: 's', KeyPos: 0},
{Name: "Models", Key: 'm', KeyPos: 0}, {Name: "Breakdown", Key: 'b', KeyPos: 0},
{Name: "Projects", Key: 'p', KeyPos: 0}, {Name: "Settings", Key: 'x', KeyPos: -1},
{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. // RenderTabBar renders the tab bar with the given active index.
@@ -47,7 +42,7 @@ func RenderTabBar(activeIdx int, width int) string {
dimKeyStyle := lipgloss.NewStyle(). dimKeyStyle := lipgloss.NewStyle().
Foreground(t.TextDim) Foreground(t.TextDim)
var parts []string parts := make([]string, 0, len(Tabs))
for i, tab := range Tabs { for i, tab := range Tabs {
var rendered string var rendered string
if i == activeIdx { if i == activeIdx {
@@ -70,25 +65,9 @@ func RenderTabBar(activeIdx int, width int) string {
parts = append(parts, rendered) parts = append(parts, rendered)
} }
// Single row if all tabs fit bar := " " + strings.Join(parts, " ")
full := " " + strings.Join(parts, " ") if lipgloss.Width(bar) <= width {
if lipgloss.Width(full) <= width { return bar
return full
} }
return lipgloss.NewStyle().MaxWidth(width).Render(bar)
// Fall back to two rows
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
} }

View File

@@ -1,3 +1,4 @@
// Package theme defines color themes for the cburn TUI dashboard.
package theme package theme
import "github.com/charmbracelet/lipgloss" import "github.com/charmbracelet/lipgloss"
@@ -8,7 +9,6 @@ type Theme struct {
Background lipgloss.Color Background lipgloss.Color
Surface lipgloss.Color Surface lipgloss.Color
Border lipgloss.Color Border lipgloss.Color
BorderHover lipgloss.Color
TextDim lipgloss.Color TextDim lipgloss.Color
TextMuted lipgloss.Color TextMuted lipgloss.Color
TextPrimary lipgloss.Color TextPrimary lipgloss.Color
@@ -17,7 +17,6 @@ type Theme struct {
Orange lipgloss.Color Orange lipgloss.Color
Red lipgloss.Color Red lipgloss.Color
Blue lipgloss.Color Blue lipgloss.Color
Purple lipgloss.Color
Yellow lipgloss.Color Yellow lipgloss.Color
} }
@@ -30,7 +29,6 @@ var FlexokiDark = Theme{
Background: lipgloss.Color("#100F0F"), Background: lipgloss.Color("#100F0F"),
Surface: lipgloss.Color("#1C1B1A"), Surface: lipgloss.Color("#1C1B1A"),
Border: lipgloss.Color("#282726"), Border: lipgloss.Color("#282726"),
BorderHover: lipgloss.Color("#343331"),
TextDim: lipgloss.Color("#575653"), TextDim: lipgloss.Color("#575653"),
TextMuted: lipgloss.Color("#6F6E69"), TextMuted: lipgloss.Color("#6F6E69"),
TextPrimary: lipgloss.Color("#FFFCF0"), TextPrimary: lipgloss.Color("#FFFCF0"),
@@ -39,7 +37,6 @@ var FlexokiDark = Theme{
Orange: lipgloss.Color("#DA702C"), Orange: lipgloss.Color("#DA702C"),
Red: lipgloss.Color("#D14D41"), Red: lipgloss.Color("#D14D41"),
Blue: lipgloss.Color("#4385BE"), Blue: lipgloss.Color("#4385BE"),
Purple: lipgloss.Color("#8B7EC8"),
Yellow: lipgloss.Color("#D0A215"), Yellow: lipgloss.Color("#D0A215"),
} }
@@ -49,7 +46,6 @@ var CatppuccinMocha = Theme{
Background: lipgloss.Color("#1E1E2E"), Background: lipgloss.Color("#1E1E2E"),
Surface: lipgloss.Color("#313244"), Surface: lipgloss.Color("#313244"),
Border: lipgloss.Color("#45475A"), Border: lipgloss.Color("#45475A"),
BorderHover: lipgloss.Color("#585B70"),
TextDim: lipgloss.Color("#6C7086"), TextDim: lipgloss.Color("#6C7086"),
TextMuted: lipgloss.Color("#A6ADC8"), TextMuted: lipgloss.Color("#A6ADC8"),
TextPrimary: lipgloss.Color("#CDD6F4"), TextPrimary: lipgloss.Color("#CDD6F4"),
@@ -58,7 +54,6 @@ var CatppuccinMocha = Theme{
Orange: lipgloss.Color("#FAB387"), Orange: lipgloss.Color("#FAB387"),
Red: lipgloss.Color("#F38BA8"), Red: lipgloss.Color("#F38BA8"),
Blue: lipgloss.Color("#89B4FA"), Blue: lipgloss.Color("#89B4FA"),
Purple: lipgloss.Color("#CBA6F7"),
Yellow: lipgloss.Color("#F9E2AF"), Yellow: lipgloss.Color("#F9E2AF"),
} }
@@ -68,7 +63,6 @@ var TokyoNight = Theme{
Background: lipgloss.Color("#1A1B26"), Background: lipgloss.Color("#1A1B26"),
Surface: lipgloss.Color("#24283B"), Surface: lipgloss.Color("#24283B"),
Border: lipgloss.Color("#414868"), Border: lipgloss.Color("#414868"),
BorderHover: lipgloss.Color("#565F89"),
TextDim: lipgloss.Color("#565F89"), TextDim: lipgloss.Color("#565F89"),
TextMuted: lipgloss.Color("#A9B1D6"), TextMuted: lipgloss.Color("#A9B1D6"),
TextPrimary: lipgloss.Color("#C0CAF5"), TextPrimary: lipgloss.Color("#C0CAF5"),
@@ -77,7 +71,6 @@ var TokyoNight = Theme{
Orange: lipgloss.Color("#FF9E64"), Orange: lipgloss.Color("#FF9E64"),
Red: lipgloss.Color("#F7768E"), Red: lipgloss.Color("#F7768E"),
Blue: lipgloss.Color("#7AA2F7"), Blue: lipgloss.Color("#7AA2F7"),
Purple: lipgloss.Color("#BB9AF7"),
Yellow: lipgloss.Color("#E0AF68"), Yellow: lipgloss.Color("#E0AF68"),
} }
@@ -87,7 +80,6 @@ var Terminal = Theme{
Background: lipgloss.Color("0"), Background: lipgloss.Color("0"),
Surface: lipgloss.Color("0"), Surface: lipgloss.Color("0"),
Border: lipgloss.Color("8"), Border: lipgloss.Color("8"),
BorderHover: lipgloss.Color("7"),
TextDim: lipgloss.Color("8"), TextDim: lipgloss.Color("8"),
TextMuted: lipgloss.Color("7"), TextMuted: lipgloss.Color("7"),
TextPrimary: lipgloss.Color("15"), TextPrimary: lipgloss.Color("15"),
@@ -96,7 +88,6 @@ var Terminal = Theme{
Orange: lipgloss.Color("3"), Orange: lipgloss.Color("3"),
Red: lipgloss.Color("1"), Red: lipgloss.Color("1"),
Blue: lipgloss.Color("4"), Blue: lipgloss.Color("4"),
Purple: lipgloss.Color("5"),
Yellow: lipgloss.Color("3"), Yellow: lipgloss.Color("3"),
} }