diff --git a/internal/tui/components/card.go b/internal/tui/components/card.go index a9038b7..755d366 100644 --- a/internal/tui/components/card.go +++ b/internal/tui/components/card.go @@ -1,10 +1,7 @@ +// Package components provides reusable TUI widgets for the cburn dashboard. package components import ( - "fmt" - "math" - "strings" - "cburn/internal/tui/theme" "github.com/charmbracelet/lipgloss" @@ -126,312 +123,3 @@ func CardInnerWidth(outerWidth int) int { } 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) -} diff --git a/internal/tui/components/chart.go b/internal/tui/components/chart.go new file mode 100644 index 0000000..bdab429 --- /dev/null +++ b/internal/tui/components/chart.go @@ -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) + } +} diff --git a/internal/tui/components/progress.go b/internal/tui/components/progress.go new file mode 100644 index 0000000..55e3b3e --- /dev/null +++ b/internal/tui/components/progress.go @@ -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) +} diff --git a/internal/tui/components/statusbar.go b/internal/tui/components/statusbar.go index 7dabbc0..2b32b86 100644 --- a/internal/tui/components/statusbar.go +++ b/internal/tui/components/statusbar.go @@ -2,14 +2,17 @@ package components import ( "fmt" + "strings" + "cburn/internal/claudeai" "cburn/internal/tui/theme" + "github.com/charmbracelet/bubbles/progress" "github.com/charmbracelet/lipgloss" ) -// RenderStatusBar renders the bottom status bar. -func RenderStatusBar(width int, dataAge string) string { +// RenderStatusBar renders the bottom status bar with optional rate limit indicators. +func RenderStatusBar(width int, dataAge string, subData *claudeai.SubscriptionData) string { t := theme.Active style := lipgloss.NewStyle(). @@ -17,22 +20,82 @@ func RenderStatusBar(width int, dataAge string) string { Width(width) left := " [?]help [q]uit" + + // Build rate limit indicators for the middle section + ratePart := renderStatusRateLimits(subData) + right := "" if dataAge != "" { right = fmt.Sprintf("Data: %s ", dataAge) } - // Pad middle - padding := width - lipgloss.Width(left) - lipgloss.Width(right) + // Layout: left + ratePart + right, with padding distributed + usedWidth := lipgloss.Width(left) + lipgloss.Width(ratePart) + lipgloss.Width(right) + padding := width - usedWidth if padding < 0 { padding = 0 } - bar := left - for i := 0; i < padding; i++ { - bar += " " - } - bar += right + // Split padding: more on the left side of rate indicators + leftPad := padding / 2 + rightPad := padding - leftPad + + bar := left + strings.Repeat(" ", leftPad) + ratePart + strings.Repeat(" ", rightPad) + right 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)), + ) +} diff --git a/internal/tui/components/tabbar.go b/internal/tui/components/tabbar.go index 8cbd770..63b943f 100644 --- a/internal/tui/components/tabbar.go +++ b/internal/tui/components/tabbar.go @@ -10,23 +10,18 @@ import ( // 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) + 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: "Overview", Key: 'o', 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" + {Name: "Breakdown", Key: 'b', KeyPos: 0}, + {Name: "Settings", Key: 'x', KeyPos: -1}, } // RenderTabBar renders the tab bar with the given active index. @@ -47,7 +42,7 @@ func RenderTabBar(activeIdx int, width int) string { dimKeyStyle := lipgloss.NewStyle(). Foreground(t.TextDim) - var parts []string + parts := make([]string, 0, len(Tabs)) for i, tab := range Tabs { var rendered string if i == activeIdx { @@ -70,25 +65,9 @@ func RenderTabBar(activeIdx int, width int) string { parts = append(parts, rendered) } - // Single row if all tabs fit - full := " " + strings.Join(parts, " ") - if lipgloss.Width(full) <= width { - return full + bar := " " + strings.Join(parts, " ") + if lipgloss.Width(bar) <= width { + return 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 + return lipgloss.NewStyle().MaxWidth(width).Render(bar) } diff --git a/internal/tui/theme/theme.go b/internal/tui/theme/theme.go index 337409d..a88a97c 100644 --- a/internal/tui/theme/theme.go +++ b/internal/tui/theme/theme.go @@ -1,3 +1,4 @@ +// Package theme defines color themes for the cburn TUI dashboard. package theme import "github.com/charmbracelet/lipgloss" @@ -8,7 +9,6 @@ type Theme struct { Background lipgloss.Color Surface lipgloss.Color Border lipgloss.Color - BorderHover lipgloss.Color TextDim lipgloss.Color TextMuted lipgloss.Color TextPrimary lipgloss.Color @@ -17,7 +17,6 @@ type Theme struct { Orange lipgloss.Color Red lipgloss.Color Blue lipgloss.Color - Purple lipgloss.Color Yellow lipgloss.Color } @@ -30,7 +29,6 @@ var FlexokiDark = Theme{ Background: lipgloss.Color("#100F0F"), Surface: lipgloss.Color("#1C1B1A"), Border: lipgloss.Color("#282726"), - BorderHover: lipgloss.Color("#343331"), TextDim: lipgloss.Color("#575653"), TextMuted: lipgloss.Color("#6F6E69"), TextPrimary: lipgloss.Color("#FFFCF0"), @@ -39,7 +37,6 @@ var FlexokiDark = Theme{ Orange: lipgloss.Color("#DA702C"), Red: lipgloss.Color("#D14D41"), Blue: lipgloss.Color("#4385BE"), - Purple: lipgloss.Color("#8B7EC8"), Yellow: lipgloss.Color("#D0A215"), } @@ -49,7 +46,6 @@ var CatppuccinMocha = Theme{ Background: lipgloss.Color("#1E1E2E"), Surface: lipgloss.Color("#313244"), Border: lipgloss.Color("#45475A"), - BorderHover: lipgloss.Color("#585B70"), TextDim: lipgloss.Color("#6C7086"), TextMuted: lipgloss.Color("#A6ADC8"), TextPrimary: lipgloss.Color("#CDD6F4"), @@ -58,7 +54,6 @@ var CatppuccinMocha = Theme{ Orange: lipgloss.Color("#FAB387"), Red: lipgloss.Color("#F38BA8"), Blue: lipgloss.Color("#89B4FA"), - Purple: lipgloss.Color("#CBA6F7"), Yellow: lipgloss.Color("#F9E2AF"), } @@ -68,7 +63,6 @@ var TokyoNight = Theme{ Background: lipgloss.Color("#1A1B26"), Surface: lipgloss.Color("#24283B"), Border: lipgloss.Color("#414868"), - BorderHover: lipgloss.Color("#565F89"), TextDim: lipgloss.Color("#565F89"), TextMuted: lipgloss.Color("#A9B1D6"), TextPrimary: lipgloss.Color("#C0CAF5"), @@ -77,7 +71,6 @@ var TokyoNight = Theme{ Orange: lipgloss.Color("#FF9E64"), Red: lipgloss.Color("#F7768E"), Blue: lipgloss.Color("#7AA2F7"), - Purple: lipgloss.Color("#BB9AF7"), Yellow: lipgloss.Color("#E0AF68"), } @@ -87,7 +80,6 @@ var Terminal = Theme{ Background: lipgloss.Color("0"), Surface: lipgloss.Color("0"), Border: lipgloss.Color("8"), - BorderHover: lipgloss.Color("7"), TextDim: lipgloss.Color("8"), TextMuted: lipgloss.Color("7"), TextPrimary: lipgloss.Color("15"), @@ -96,7 +88,6 @@ var Terminal = Theme{ Orange: lipgloss.Color("3"), Red: lipgloss.Color("1"), Blue: lipgloss.Color("4"), - Purple: lipgloss.Color("5"), Yellow: lipgloss.Color("3"), }