diff --git a/internal/tui/components/card.go b/internal/tui/components/card.go index d7fa125..d6dfb52 100644 --- a/internal/tui/components/card.go +++ b/internal/tui/components/card.go @@ -2,6 +2,8 @@ package components import ( + "strings" + "github.com/theirongolddev/cburn/internal/tui/theme" "github.com/charmbracelet/lipgloss" @@ -25,7 +27,7 @@ func LayoutRow(totalWidth, n int) []int { return widths } -// MetricCard renders a small metric card with label, value, and delta. +// MetricCard renders a visually striking metric card with icon, colored value, and delta. // outerWidth is the total rendered width including border. func MetricCard(label, value, delta string, outerWidth int) string { t := theme.Active @@ -35,23 +37,56 @@ func MetricCard(label, value, delta string, outerWidth int) string { contentWidth = 10 } + // Determine accent color based on label for variety + var valueColor lipgloss.Color + var icon string + switch { + case strings.Contains(strings.ToLower(label), "token"): + valueColor = t.Cyan + icon = "◈" + case strings.Contains(strings.ToLower(label), "session"): + valueColor = t.Magenta + icon = "◉" + case strings.Contains(strings.ToLower(label), "cost"): + valueColor = t.Green + icon = "◆" + case strings.Contains(strings.ToLower(label), "cache"): + valueColor = t.Blue + icon = "◇" + default: + valueColor = t.Accent + icon = "●" + } + cardStyle := lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(t.Border). + BorderBackground(t.Background). + Background(t.Surface). Width(contentWidth). Padding(0, 1) + iconStyle := lipgloss.NewStyle(). + Foreground(valueColor). + Background(t.Surface) + labelStyle := lipgloss.NewStyle(). - Foreground(t.TextMuted) + Foreground(t.TextMuted). + Background(t.Surface) valueStyle := lipgloss.NewStyle(). - Foreground(t.TextPrimary). + Foreground(valueColor). + Background(t.Surface). Bold(true) deltaStyle := lipgloss.NewStyle(). - Foreground(t.TextDim) + Foreground(t.TextDim). + Background(t.Surface) + spaceStyle := lipgloss.NewStyle(). + Background(t.Surface) - content := labelStyle.Render(label) + "\n" + + // Build content with icon + content := iconStyle.Render(icon) + spaceStyle.Render(" ") + labelStyle.Render(label) + "\n" + valueStyle.Render(value) if delta != "" { content += "\n" + deltaStyle.Render(delta) @@ -74,7 +109,8 @@ func MetricCardRow(cards []struct{ Label, Value, Delta string }, totalWidth int) rendered = append(rendered, MetricCard(c.Label, c.Value, c.Delta, widths[i])) } - return lipgloss.JoinHorizontal(lipgloss.Top, rendered...) + // Use CardRow instead of JoinHorizontal to ensure proper background fill + return CardRow(rendered) } // ContentCard renders a bordered content card with an optional title. @@ -87,14 +123,59 @@ func ContentCard(title, body string, outerWidth int) string { contentWidth = 10 } + // Use accent border for titled cards, subtle for untitled + borderColor := t.Border + if title != "" { + borderColor = t.BorderBright + } + cardStyle := lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). - BorderForeground(t.Border). + BorderForeground(borderColor). + BorderBackground(t.Background). + Background(t.Surface). + Width(contentWidth). + Padding(0, 1) + + // Title with accent color and underline effect + titleStyle := lipgloss.NewStyle(). + Foreground(t.Accent). + Background(t.Surface). + Bold(true) + + content := "" + if title != "" { + // Title with subtle separator + titleLine := titleStyle.Render(title) + separatorStyle := lipgloss.NewStyle().Foreground(t.Border).Background(t.Surface) + separator := separatorStyle.Render(strings.Repeat("─", minInt(len(title)+2, contentWidth-2))) + content = titleLine + "\n" + separator + "\n" + } + content += body + + return cardStyle.Render(content) +} + +// PanelCard renders a full-width panel with prominent styling - used for main chart areas. +func PanelCard(title, body string, outerWidth int) string { + t := theme.Active + + contentWidth := outerWidth - 2 + if contentWidth < 10 { + contentWidth = 10 + } + + cardStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(t.BorderAccent). + BorderBackground(t.Background). + Background(t.Surface). Width(contentWidth). Padding(0, 1) titleStyle := lipgloss.NewStyle(). - Foreground(t.TextMuted). + Foreground(t.AccentBright). + Background(t.Surface). Bold(true) content := "" @@ -106,12 +187,57 @@ func ContentCard(title, body string, outerWidth int) string { return cardStyle.Render(content) } -// CardRow joins pre-rendered card strings horizontally. +// CardRow joins pre-rendered card strings horizontally with matched heights. +// This manually joins cards line-by-line to ensure shorter cards are padded +// with proper background fill, avoiding black square artifacts. func CardRow(cards []string) string { if len(cards) == 0 { return "" } - return lipgloss.JoinHorizontal(lipgloss.Top, cards...) + + t := theme.Active + + // Split each card into lines and track widths + cardLines := make([][]string, len(cards)) + cardWidths := make([]int, len(cards)) + maxHeight := 0 + + for i, card := range cards { + lines := strings.Split(card, "\n") + cardLines[i] = lines + if len(lines) > maxHeight { + maxHeight = len(lines) + } + // Determine card width from the first line (cards have consistent width) + if len(lines) > 0 { + cardWidths[i] = lipgloss.Width(lines[0]) + } + } + + // Build background-filled padding style + bgStyle := lipgloss.NewStyle().Background(t.Background) + + // Pad shorter cards with background-filled lines + for i := range cardLines { + for len(cardLines[i]) < maxHeight { + // Add a line of spaces with the proper background + padding := bgStyle.Render(strings.Repeat(" ", cardWidths[i])) + cardLines[i] = append(cardLines[i], padding) + } + } + + // Join cards line by line + var result strings.Builder + for row := 0; row < maxHeight; row++ { + for i := range cardLines { + result.WriteString(cardLines[i][row]) + } + if row < maxHeight-1 { + result.WriteString("\n") + } + } + + return result.String() } // CardInnerWidth returns the usable text width inside a ContentCard @@ -123,3 +249,10 @@ func CardInnerWidth(outerWidth int) int { } return w } + +func minInt(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/internal/tui/components/chart.go b/internal/tui/components/chart.go index 2981f62..92c9cdd 100644 --- a/internal/tui/components/chart.go +++ b/internal/tui/components/chart.go @@ -15,6 +15,7 @@ func Sparkline(values []float64, color lipgloss.Color) string { if len(values) == 0 { return "" } + t := theme.Active blocks := []rune{'▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'} @@ -28,7 +29,7 @@ func Sparkline(values []float64, color lipgloss.Color) string { peak = 1 } - style := lipgloss.NewStyle().Foreground(color) + style := lipgloss.NewStyle().Foreground(color).Background(t.Surface) var buf strings.Builder buf.Grow(len(values) * 4) // UTF-8 block chars are up to 3 bytes @@ -46,9 +47,7 @@ func Sparkline(values []float64, color lipgloss.Color) string { 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. +// BarChart renders a visually polished bar chart with gradient-style coloring. func BarChart(values []float64, labels []string, color lipgloss.Color, width, height int) string { if len(values) == 0 { return "" @@ -70,10 +69,7 @@ func BarChart(values []float64, labels []string, color lipgloss.Color, width, he 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. + // Y-axis: compute tick step and ceiling tickStep := chartTickStep(maxVal) maxIntervals := height / 2 if maxIntervals < 2 { @@ -92,14 +88,13 @@ func BarChart(values []float64, labels []string, color lipgloss.Color, width, he 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 + // Pre-compute tick labels yLabelW := len(formatChartLabel(ceiling)) + 1 if yLabelW < 4 { yLabelW = 4 @@ -110,7 +105,7 @@ func BarChart(values []float64, labels []string, color lipgloss.Color, width, he tickLabels[row] = formatChartLabel(tickStep * float64(i)) } - // Chart area width (excluding y-axis label and axis line char) + // Chart area width chartW := width - yLabelW - 1 if chartW < 5 { chartW = 5 @@ -118,8 +113,7 @@ func BarChart(values []float64, labels []string, color lipgloss.Color, width, he 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. + // Bar sizing gap := 1 if n <= 1 { gap = 0 @@ -131,8 +125,7 @@ func BarChart(values []float64, labels []string, color lipgloss.Color, width, he 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) + maxN := (chartW + 1) / 3 if maxN < 2 { maxN = 2 } @@ -159,15 +152,29 @@ func BarChart(values []float64, labels []string, color lipgloss.Color, width, he axisLen := n*barW + max(0, n-1)*gap blocks := []rune{' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'} - barStyle := lipgloss.NewStyle().Foreground(color) - axisStyle := lipgloss.NewStyle().Foreground(t.TextDim) + + // Multi-color gradient for bars based on height + axisStyle := lipgloss.NewStyle().Foreground(t.TextDim).Background(t.Surface) var b strings.Builder - // Render rows top to bottom using chartH (aligned to tick intervals) + // Render rows top to bottom for row := chartH; row >= 1; row-- { rowTop := ceiling * float64(row) / float64(chartH) rowBottom := ceiling * float64(row-1) / float64(chartH) + rowPct := float64(row) / float64(chartH) // How high in the chart (0=bottom, 1=top) + + // Choose bar color based on row height (gradient effect) + var barColor lipgloss.Color + switch { + case rowPct > 0.8: + barColor = t.AccentBright + case rowPct > 0.5: + barColor = color + default: + barColor = t.Accent + } + barStyle := lipgloss.NewStyle().Foreground(barColor).Background(t.Surface) label := tickLabels[row] b.WriteString(axisStyle.Render(fmt.Sprintf("%*s", yLabelW, label))) @@ -175,11 +182,11 @@ func BarChart(values []float64, labels []string, color lipgloss.Color, width, he for i, v := range values { if i > 0 && gap > 0 { - b.WriteString(strings.Repeat(" ", gap)) + b.WriteString(lipgloss.NewStyle().Background(t.Surface).Render(strings.Repeat(" ", gap))) } switch { case v >= rowTop: - b.WriteString(barStyle.Render(strings.Repeat("\u2588", barW))) + b.WriteString(barStyle.Render(strings.Repeat("█", barW))) case v > rowBottom: frac := (v - rowBottom) / (rowTop - rowBottom) idx := int(frac * 8) @@ -191,7 +198,7 @@ func BarChart(values []float64, labels []string, color lipgloss.Color, width, he } b.WriteString(barStyle.Render(strings.Repeat(string(blocks[idx]), barW))) default: - b.WriteString(strings.Repeat(" ", barW)) + b.WriteString(lipgloss.NewStyle().Background(t.Surface).Render(strings.Repeat(" ", barW))) } } b.WriteString("\n") @@ -209,7 +216,6 @@ func BarChart(values []float64, labels []string, color lipgloss.Color, width, he buf[i] = ' ' } - // Place labels at bar start positions, skip overlaps minSpacing := 8 labelStep := max(1, (n*minSpacing)/(axisLen+1)) @@ -231,14 +237,11 @@ func BarChart(values []float64, labels []string, color lipgloss.Color, width, he 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 } @@ -251,8 +254,9 @@ func BarChart(values []float64, labels []string, color lipgloss.Color, width, he } b.WriteString("\n") - b.WriteString(strings.Repeat(" ", yLabelW+1)) - b.WriteString(axisStyle.Render(strings.TrimRight(string(buf), " "))) + labelStyle := lipgloss.NewStyle().Foreground(t.TextDim).Background(t.Surface) + b.WriteString(lipgloss.NewStyle().Background(t.Surface).Render(strings.Repeat(" ", yLabelW+1))) + b.WriteString(labelStyle.Render(strings.TrimRight(string(buf), " "))) } return b.String() diff --git a/internal/tui/components/progress.go b/internal/tui/components/progress.go index 39ece5b..488dafa 100644 --- a/internal/tui/components/progress.go +++ b/internal/tui/components/progress.go @@ -11,7 +11,7 @@ import ( "github.com/charmbracelet/lipgloss" ) -// ProgressBar renders a colored progress bar. +// ProgressBar renders a visually appealing progress bar with percentage. func ProgressBar(pct float64, width int) string { t := theme.Active filled := int(pct * float64(width)) @@ -22,36 +22,45 @@ func ProgressBar(pct float64, width int) string { filled = 0 } - filledStyle := lipgloss.NewStyle().Foreground(t.Accent) - emptyStyle := lipgloss.NewStyle().Foreground(t.TextDim) + // Color gradient based on progress + var barColor lipgloss.Color + switch { + case pct >= 0.8: + barColor = t.AccentBright + case pct >= 0.5: + barColor = t.Accent + default: + barColor = t.Cyan + } + + filledStyle := lipgloss.NewStyle().Foreground(barColor).Background(t.Surface) + emptyStyle := lipgloss.NewStyle().Foreground(t.TextDim).Background(t.Surface) + pctStyle := lipgloss.NewStyle().Foreground(barColor).Background(t.Surface).Bold(true) + spaceStyle := lipgloss.NewStyle().Background(t.Surface) 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")) - } + b.WriteString(filledStyle.Render(strings.Repeat("█", filled))) + b.WriteString(emptyStyle.Render(strings.Repeat("░", width-filled))) - return fmt.Sprintf("%s %.1f%%", b.String(), pct*100) + return b.String() + spaceStyle.Render(" ") + pctStyle.Render(fmt.Sprintf("%.0f%%", pct*100)) } -// ColorForPct returns green/yellow/red based on utilization level. +// ColorForPct returns green/yellow/orange/red based on utilization level. func ColorForPct(pct float64) string { t := theme.Active switch { - case pct >= 0.8: + case pct >= 0.9: return string(t.Red) - case pct >= 0.5: + case pct >= 0.7: return string(t.Orange) + case pct >= 0.5: + return string(t.Yellow) 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 @@ -69,9 +78,10 @@ func RateLimitBar(label string, pct float64, resetsAt time.Time, labelW, barWidt ) 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) + labelStyle := lipgloss.NewStyle().Foreground(t.TextMuted).Background(t.Surface) + pctStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(ColorForPct(pct))).Background(t.Surface).Bold(true) + countdownStyle := lipgloss.NewStyle().Foreground(t.TextDim).Background(t.Surface) + spaceStyle := lipgloss.NewStyle().Background(t.Surface) pctStr := fmt.Sprintf("%3.0f%%", pct*100) countdown := "" @@ -84,16 +94,16 @@ func RateLimitBar(label string, pct float64, resetsAt time.Time, labelW, barWidt } } - return fmt.Sprintf("%s %s %s %s", - labelStyle.Render(fmt.Sprintf("%-*s", labelW, label)), - bar.ViewAs(pct), - pctStyle.Render(pctStr), - countdownStyle.Render(countdown), - ) + return labelStyle.Render(fmt.Sprintf("%-*s", labelW, label)) + + spaceStyle.Render(" ") + + bar.ViewAs(pct) + + spaceStyle.Render(" ") + + pctStyle.Render(pctStr) + + spaceStyle.Render(" ") + + 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 @@ -104,7 +114,6 @@ func CompactRateBar(label string, pct float64, width int) string { pct = 1 } - // label + space + bar + space + pct(4 chars) barW := width - lipgloss.Width(label) - 6 if barW < 4 { barW = 4 @@ -117,14 +126,15 @@ func CompactRateBar(label string, pct float64, width int) string { ) bar.EmptyColor = string(t.TextDim) - pctStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(ColorForPct(pct))) - labelStyle := lipgloss.NewStyle().Foreground(t.TextMuted) + pctStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(ColorForPct(pct))).Background(t.Surface).Bold(true) + labelStyle := lipgloss.NewStyle().Foreground(t.TextMuted).Background(t.Surface) + spaceStyle := lipgloss.NewStyle().Background(t.Surface) - return fmt.Sprintf("%s %s %s", - labelStyle.Render(label), - bar.ViewAs(pct), - pctStyle.Render(fmt.Sprintf("%2.0f%%", pct*100)), - ) + return labelStyle.Render(label) + + spaceStyle.Render(" ") + + bar.ViewAs(pct) + + spaceStyle.Render(" ") + + pctStyle.Render(fmt.Sprintf("%2.0f%%", pct*100)) } func formatCountdown(d time.Duration) string { diff --git a/internal/tui/components/statusbar.go b/internal/tui/components/statusbar.go index 9323deb..2b4ce3d 100644 --- a/internal/tui/components/statusbar.go +++ b/internal/tui/components/statusbar.go @@ -7,80 +7,119 @@ import ( "github.com/theirongolddev/cburn/internal/claudeai" "github.com/theirongolddev/cburn/internal/tui/theme" - "github.com/charmbracelet/bubbles/progress" "github.com/charmbracelet/lipgloss" ) -// RenderStatusBar renders the bottom status bar with optional rate limit indicators. +// RenderStatusBar renders a polished bottom status bar with rate limits and controls. func RenderStatusBar(width int, dataAge string, subData *claudeai.SubscriptionData, refreshing, autoRefresh bool) string { t := theme.Active - style := lipgloss.NewStyle(). - Foreground(t.TextMuted). + // Main container + barStyle := lipgloss.NewStyle(). + Background(t.SurfaceHover). Width(width) - left := " [?]help [r]efresh [q]uit" + // Build left section: keyboard hints + keyStyle := lipgloss.NewStyle(). + Foreground(t.AccentBright). + Background(t.SurfaceHover). + Bold(true) - // Build rate limit indicators for the middle section - ratePart := renderStatusRateLimits(subData) + hintStyle := lipgloss.NewStyle(). + Foreground(t.TextMuted). + Background(t.SurfaceHover) - // Build right side with refresh status + bracketStyle := lipgloss.NewStyle(). + Foreground(t.TextDim). + Background(t.SurfaceHover) + spaceStyle := lipgloss.NewStyle(). + Background(t.SurfaceHover) + + left := spaceStyle.Render(" ") + + bracketStyle.Render("[") + keyStyle.Render("?") + bracketStyle.Render("]") + hintStyle.Render("help") + spaceStyle.Render(" ") + + bracketStyle.Render("[") + keyStyle.Render("r") + bracketStyle.Render("]") + hintStyle.Render("efresh") + spaceStyle.Render(" ") + + bracketStyle.Render("[") + keyStyle.Render("q") + bracketStyle.Render("]") + hintStyle.Render("uit") + + // Build middle section: rate limit indicators + middle := renderStatusRateLimits(subData) + + // Build right section: refresh status var right string if refreshing { - refreshStyle := lipgloss.NewStyle().Foreground(t.Accent) - right = refreshStyle.Render("↻ refreshing ") + spinnerStyle := lipgloss.NewStyle(). + Foreground(t.AccentBright). + Background(t.SurfaceHover). + Bold(true) + right = spinnerStyle.Render("↻ refreshing") } else if dataAge != "" { - autoStr := "" + refreshIcon := "" if autoRefresh { - autoStr = "↻ " + refreshIcon = lipgloss.NewStyle(). + Foreground(t.Green). + Background(t.SurfaceHover). + Render("↻ ") } - right = fmt.Sprintf("%sData: %s ", autoStr, dataAge) + dataStyle := lipgloss.NewStyle(). + Foreground(t.TextMuted). + Background(t.SurfaceHover) + right = refreshIcon + dataStyle.Render("Data: "+dataAge) } + right += spaceStyle.Render(" ") - // Layout: left + ratePart + right, with padding distributed - usedWidth := lipgloss.Width(left) + lipgloss.Width(ratePart) + lipgloss.Width(right) - padding := width - usedWidth + // Calculate padding + leftWidth := lipgloss.Width(left) + middleWidth := lipgloss.Width(middle) + rightWidth := lipgloss.Width(right) + + totalUsed := leftWidth + middleWidth + rightWidth + padding := width - totalUsed if padding < 0 { padding = 0 } - // 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 + paddingStyle := lipgloss.NewStyle().Background(t.SurfaceHover) + bar := left + + paddingStyle.Render(strings.Repeat(" ", leftPad)) + + middle + + paddingStyle.Render(strings.Repeat(" ", rightPad)) + + right - return style.Render(bar) + return barStyle.Render(bar) } -// renderStatusRateLimits renders compact rate limit bars for the status bar. +// renderStatusRateLimits renders compact rate limit pills 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)) + parts = append(parts, renderRatePill("5h", w.Pct)) } if w := subData.Usage.SevenDay; w != nil { - parts = append(parts, compactStatusBar("Wk", w.Pct)) + parts = append(parts, renderRatePill("Wk", w.Pct)) } if len(parts) == 0 { return "" } - return strings.Join(parts, sepStyle.Render(" | ")) + sepStyle := lipgloss.NewStyle(). + Foreground(t.TextDim). + Background(t.SurfaceHover) + + 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 { +// renderRatePill renders a compact, colored rate indicator pill. +func renderRatePill(label string, pct float64) string { t := theme.Active if pct < 0 { @@ -90,20 +129,52 @@ func compactStatusBar(label string, pct float64) string { pct = 1 } + // Choose color based on usage level + var barColor, pctColor lipgloss.Color + switch { + case pct >= 0.9: + barColor = t.Red + pctColor = t.Red + case pct >= 0.7: + barColor = t.Orange + pctColor = t.Orange + case pct >= 0.5: + barColor = t.Yellow + pctColor = t.Yellow + default: + barColor = t.Green + pctColor = t.Green + } + + labelStyle := lipgloss.NewStyle(). + Foreground(t.TextMuted). + Background(t.SurfaceHover) + + barStyle := lipgloss.NewStyle(). + Foreground(barColor). + Background(t.SurfaceHover) + + emptyStyle := lipgloss.NewStyle(). + Foreground(t.TextDim). + Background(t.SurfaceHover) + + pctStyle := lipgloss.NewStyle(). + Foreground(pctColor). + Background(t.SurfaceHover). + Bold(true) + + // Render mini bar (8 chars) barW := 8 - bar := progress.New( - progress.WithSolidFill(ColorForPct(pct)), - progress.WithWidth(barW), - progress.WithoutPercentage(), - ) - bar.EmptyColor = string(t.TextDim) + filled := int(pct * float64(barW)) + if filled > barW { + filled = barW + } - labelStyle := lipgloss.NewStyle().Foreground(t.TextMuted) - pctStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(ColorForPct(pct))) + bar := barStyle.Render(strings.Repeat("█", filled)) + + emptyStyle.Render(strings.Repeat("░", barW-filled)) - return fmt.Sprintf("%s %s %s", - labelStyle.Render(label), - bar.ViewAs(pct), - pctStyle.Render(fmt.Sprintf("%2.0f%%", pct*100)), - ) + spaceStyle := lipgloss.NewStyle(). + Background(t.SurfaceHover) + + return labelStyle.Render(label+" ") + bar + spaceStyle.Render(" ") + pctStyle.Render(fmt.Sprintf("%2.0f%%", pct*100)) } diff --git a/internal/tui/components/tabbar.go b/internal/tui/components/tabbar.go index 3fee4fd..d73336f 100644 --- a/internal/tui/components/tabbar.go +++ b/internal/tui/components/tabbar.go @@ -24,50 +24,113 @@ var Tabs = []Tab{ {Name: "Settings", Key: 'x', KeyPos: -1}, } -// RenderTabBar renders the tab bar with the given active index. +// TabVisualWidth returns the rendered visual width of a tab. +// This must match RenderTabBar's rendering logic exactly for mouse hit detection. +func TabVisualWidth(tab Tab, isActive bool) int { + // Active tabs: just the name with padding (1 on each side) + if isActive { + return len(tab.Name) + 2 + } + + // Inactive tabs: name with padding, plus "[k]" suffix if shortcut not in name + w := len(tab.Name) + 2 + if tab.KeyPos < 0 { + w += 3 // "[k]" + } + return w +} + +// RenderTabBar renders a modern tab bar with underline-style active indicator. func RenderTabBar(activeIdx int, width int) string { t := theme.Active - activeStyle := lipgloss.NewStyle(). - Foreground(t.Accent). - Bold(true) + // Container with bottom border + barStyle := lipgloss.NewStyle(). + Background(t.Surface). + Width(width) - inactiveStyle := lipgloss.NewStyle(). - Foreground(t.TextMuted) + // Active tab: bright text with accent underline + activeTabStyle := lipgloss.NewStyle(). + Foreground(t.AccentBright). + Background(t.Surface). + Bold(true). + Padding(0, 1) + // Inactive tab: muted text + inactiveTabStyle := lipgloss.NewStyle(). + Foreground(t.TextMuted). + Background(t.Surface). + Padding(0, 1) + + // Key highlight style keyStyle := lipgloss.NewStyle(). Foreground(t.Accent). - Bold(true) + Background(t.Surface) - dimKeyStyle := lipgloss.NewStyle(). - Foreground(t.TextDim) + dimStyle := lipgloss.NewStyle(). + Foreground(t.TextDim). + Background(t.Surface) + + // Separator between tabs + sepStyle := lipgloss.NewStyle(). + Foreground(t.Border). + Background(t.Surface) + + var tabParts []string + var underlineParts []string - parts := make([]string, 0, len(Tabs)) for i, tab := range Tabs { - var rendered string + var tabContent string + var underline string + if i == activeIdx { - rendered = activeStyle.Render(tab.Name) + // Active tab - full name, bright + tabContent = activeTabStyle.Render(tab.Name) + // Accent underline + underline = lipgloss.NewStyle(). + Foreground(t.AccentBright). + Background(t.Surface). + Render(strings.Repeat("━", lipgloss.Width(tabContent))) } else { - // Render with highlighted shortcut key + // Inactive tab - show key hint 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) + tabContent = lipgloss.NewStyle().Padding(0, 1).Background(t.Surface).Render( + dimStyle.Render(before) + keyStyle.Render(key) + dimStyle.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("]") + tabContent = inactiveTabStyle.Render(tab.Name) + + dimStyle.Render("[") + keyStyle.Render(string(tab.Key)) + dimStyle.Render("]") } + // Dim underline + underline = lipgloss.NewStyle(). + Foreground(t.Border). + Background(t.Surface). + Render(strings.Repeat("─", lipgloss.Width(tabContent))) + } + + tabParts = append(tabParts, tabContent) + underlineParts = append(underlineParts, underline) + + // Add separator between tabs (not after last) + if i < len(Tabs)-1 { + tabParts = append(tabParts, sepStyle.Render(" ")) + underlineParts = append(underlineParts, sepStyle.Render(" ")) } - parts = append(parts, rendered) } - bar := " " + strings.Join(parts, " ") - if lipgloss.Width(bar) <= width { - return bar + // Combine tab row and underline row + tabRow := strings.Join(tabParts, "") + underlineRow := strings.Join(underlineParts, "") + + // Fill remaining width with border + tabRowWidth := lipgloss.Width(tabRow) + if tabRowWidth < width { + padding := width - tabRowWidth + tabRow += lipgloss.NewStyle().Background(t.Surface).Render(strings.Repeat(" ", padding)) + underlineRow += lipgloss.NewStyle().Foreground(t.Border).Background(t.Surface).Render(strings.Repeat("─", padding)) } - return lipgloss.NewStyle().MaxWidth(width).Render(bar) + + return barStyle.Render(tabRow + "\n" + underlineRow) }