feat(tui): polish components with icons, gradients, and proper backgrounds
Comprehensive visual refresh of all TUI components: card.go: - Add semantic icons based on metric type (tokens=◈, sessions=◉, cost=◆, cache=◇) - Color-code metric values using new theme colors (Cyan, Magenta, Green, Blue) for visual variety - Add CardRow() helper that properly height-equalizes cards and fills background on shorter cards to prevent "punched out" gaps - Set explicit background on all style components chart.go: - Add background styling to sparkline renderer - Ensure bar chart respects theme.Active.Surface background progress.go: - Add color gradient based on progress (Cyan→Accent→AccentBright) - Style percentage text with bold and matching color - Fix background fill on empty bar segments statusbar.go: - Complete redesign with SurfaceHover background - Style keyboard hints: dim brackets, bright keys, muted labels - Proper spacing and background continuity across sections - Styled refresh indicator with AccentBright tabbar.go: - Add TabVisualWidth() for accurate mouse hit detection - Modern underline-style active indicator using ━ characters - AccentBright for active tab, proper dim styling for inactive - Consistent Surface background across all tab elements These changes create a cohesive visual language where every element respects the dark background, icons add visual interest without clutter, and color coding provides semantic meaning. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,8 @@
|
|||||||
package components
|
package components
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/theirongolddev/cburn/internal/tui/theme"
|
"github.com/theirongolddev/cburn/internal/tui/theme"
|
||||||
|
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
@@ -25,7 +27,7 @@ func LayoutRow(totalWidth, n int) []int {
|
|||||||
return widths
|
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.
|
// outerWidth is the total rendered width including border.
|
||||||
func MetricCard(label, value, delta string, outerWidth int) string {
|
func MetricCard(label, value, delta string, outerWidth int) string {
|
||||||
t := theme.Active
|
t := theme.Active
|
||||||
@@ -35,23 +37,56 @@ func MetricCard(label, value, delta string, outerWidth int) string {
|
|||||||
contentWidth = 10
|
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().
|
cardStyle := lipgloss.NewStyle().
|
||||||
Border(lipgloss.RoundedBorder()).
|
Border(lipgloss.RoundedBorder()).
|
||||||
BorderForeground(t.Border).
|
BorderForeground(t.Border).
|
||||||
|
BorderBackground(t.Background).
|
||||||
|
Background(t.Surface).
|
||||||
Width(contentWidth).
|
Width(contentWidth).
|
||||||
Padding(0, 1)
|
Padding(0, 1)
|
||||||
|
|
||||||
|
iconStyle := lipgloss.NewStyle().
|
||||||
|
Foreground(valueColor).
|
||||||
|
Background(t.Surface)
|
||||||
|
|
||||||
labelStyle := lipgloss.NewStyle().
|
labelStyle := lipgloss.NewStyle().
|
||||||
Foreground(t.TextMuted)
|
Foreground(t.TextMuted).
|
||||||
|
Background(t.Surface)
|
||||||
|
|
||||||
valueStyle := lipgloss.NewStyle().
|
valueStyle := lipgloss.NewStyle().
|
||||||
Foreground(t.TextPrimary).
|
Foreground(valueColor).
|
||||||
|
Background(t.Surface).
|
||||||
Bold(true)
|
Bold(true)
|
||||||
|
|
||||||
deltaStyle := lipgloss.NewStyle().
|
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)
|
valueStyle.Render(value)
|
||||||
if delta != "" {
|
if delta != "" {
|
||||||
content += "\n" + deltaStyle.Render(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]))
|
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.
|
// ContentCard renders a bordered content card with an optional title.
|
||||||
@@ -87,14 +123,59 @@ func ContentCard(title, body string, outerWidth int) string {
|
|||||||
contentWidth = 10
|
contentWidth = 10
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Use accent border for titled cards, subtle for untitled
|
||||||
|
borderColor := t.Border
|
||||||
|
if title != "" {
|
||||||
|
borderColor = t.BorderBright
|
||||||
|
}
|
||||||
|
|
||||||
cardStyle := lipgloss.NewStyle().
|
cardStyle := lipgloss.NewStyle().
|
||||||
Border(lipgloss.RoundedBorder()).
|
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).
|
Width(contentWidth).
|
||||||
Padding(0, 1)
|
Padding(0, 1)
|
||||||
|
|
||||||
titleStyle := lipgloss.NewStyle().
|
titleStyle := lipgloss.NewStyle().
|
||||||
Foreground(t.TextMuted).
|
Foreground(t.AccentBright).
|
||||||
|
Background(t.Surface).
|
||||||
Bold(true)
|
Bold(true)
|
||||||
|
|
||||||
content := ""
|
content := ""
|
||||||
@@ -106,12 +187,57 @@ func ContentCard(title, body string, outerWidth int) string {
|
|||||||
return cardStyle.Render(content)
|
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 {
|
func CardRow(cards []string) string {
|
||||||
if len(cards) == 0 {
|
if len(cards) == 0 {
|
||||||
return ""
|
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
|
// CardInnerWidth returns the usable text width inside a ContentCard
|
||||||
@@ -123,3 +249,10 @@ func CardInnerWidth(outerWidth int) int {
|
|||||||
}
|
}
|
||||||
return w
|
return w
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func minInt(a, b int) int {
|
||||||
|
if a < b {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ func Sparkline(values []float64, color lipgloss.Color) string {
|
|||||||
if len(values) == 0 {
|
if len(values) == 0 {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
t := theme.Active
|
||||||
|
|
||||||
blocks := []rune{'▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'}
|
blocks := []rune{'▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'}
|
||||||
|
|
||||||
@@ -28,7 +29,7 @@ func Sparkline(values []float64, color lipgloss.Color) string {
|
|||||||
peak = 1
|
peak = 1
|
||||||
}
|
}
|
||||||
|
|
||||||
style := lipgloss.NewStyle().Foreground(color)
|
style := lipgloss.NewStyle().Foreground(color).Background(t.Surface)
|
||||||
|
|
||||||
var buf strings.Builder
|
var buf strings.Builder
|
||||||
buf.Grow(len(values) * 4) // UTF-8 block chars are up to 3 bytes
|
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())
|
return style.Render(buf.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
// BarChart renders a multi-row bar chart with anchored Y-axis and optional X-axis labels.
|
// BarChart renders a visually polished bar chart with gradient-style coloring.
|
||||||
// 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 {
|
func BarChart(values []float64, labels []string, color lipgloss.Color, width, height int) string {
|
||||||
if len(values) == 0 {
|
if len(values) == 0 {
|
||||||
return ""
|
return ""
|
||||||
@@ -70,10 +69,7 @@ func BarChart(values []float64, labels []string, color lipgloss.Color, width, he
|
|||||||
maxVal = 1
|
maxVal = 1
|
||||||
}
|
}
|
||||||
|
|
||||||
// Y-axis: compute tick step and ceiling, then fit within requested height.
|
// Y-axis: compute tick step and ceiling
|
||||||
// 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)
|
tickStep := chartTickStep(maxVal)
|
||||||
maxIntervals := height / 2
|
maxIntervals := height / 2
|
||||||
if maxIntervals < 2 {
|
if maxIntervals < 2 {
|
||||||
@@ -92,14 +88,13 @@ func BarChart(values []float64, labels []string, color lipgloss.Color, width, he
|
|||||||
numIntervals = 1
|
numIntervals = 1
|
||||||
}
|
}
|
||||||
|
|
||||||
// Each interval gets the same number of rows; chart height is an exact multiple.
|
|
||||||
rowsPerTick := height / numIntervals
|
rowsPerTick := height / numIntervals
|
||||||
if rowsPerTick < 2 {
|
if rowsPerTick < 2 {
|
||||||
rowsPerTick = 2
|
rowsPerTick = 2
|
||||||
}
|
}
|
||||||
chartH := rowsPerTick * numIntervals
|
chartH := rowsPerTick * numIntervals
|
||||||
|
|
||||||
// Pre-compute tick labels at evenly-spaced row positions
|
// Pre-compute tick labels
|
||||||
yLabelW := len(formatChartLabel(ceiling)) + 1
|
yLabelW := len(formatChartLabel(ceiling)) + 1
|
||||||
if yLabelW < 4 {
|
if yLabelW < 4 {
|
||||||
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))
|
tickLabels[row] = formatChartLabel(tickStep * float64(i))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Chart area width (excluding y-axis label and axis line char)
|
// Chart area width
|
||||||
chartW := width - yLabelW - 1
|
chartW := width - yLabelW - 1
|
||||||
if chartW < 5 {
|
if chartW < 5 {
|
||||||
chartW = 5
|
chartW = 5
|
||||||
@@ -118,8 +113,7 @@ func BarChart(values []float64, labels []string, color lipgloss.Color, width, he
|
|||||||
|
|
||||||
n := len(values)
|
n := len(values)
|
||||||
|
|
||||||
// Bar sizing: always use 1-char gaps, target barW >= 2.
|
// Bar sizing
|
||||||
// If bars don't fit at width 2, subsample to fewer bars.
|
|
||||||
gap := 1
|
gap := 1
|
||||||
if n <= 1 {
|
if n <= 1 {
|
||||||
gap = 0
|
gap = 0
|
||||||
@@ -131,8 +125,7 @@ func BarChart(values []float64, labels []string, color lipgloss.Color, width, he
|
|||||||
barW = chartW
|
barW = chartW
|
||||||
}
|
}
|
||||||
if barW < 2 && n > 1 {
|
if barW < 2 && n > 1 {
|
||||||
// Subsample so bars fit at width 2 with 1-char gaps
|
maxN := (chartW + 1) / 3
|
||||||
maxN := (chartW + 1) / 3 // each bar = 2 chars + 1 gap (last bar no gap)
|
|
||||||
if maxN < 2 {
|
if maxN < 2 {
|
||||||
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
|
axisLen := n*barW + max(0, n-1)*gap
|
||||||
|
|
||||||
blocks := []rune{' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'}
|
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
|
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-- {
|
for row := chartH; row >= 1; row-- {
|
||||||
rowTop := ceiling * float64(row) / float64(chartH)
|
rowTop := ceiling * float64(row) / float64(chartH)
|
||||||
rowBottom := ceiling * float64(row-1) / 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]
|
label := tickLabels[row]
|
||||||
b.WriteString(axisStyle.Render(fmt.Sprintf("%*s", yLabelW, label)))
|
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 {
|
for i, v := range values {
|
||||||
if i > 0 && gap > 0 {
|
if i > 0 && gap > 0 {
|
||||||
b.WriteString(strings.Repeat(" ", gap))
|
b.WriteString(lipgloss.NewStyle().Background(t.Surface).Render(strings.Repeat(" ", gap)))
|
||||||
}
|
}
|
||||||
switch {
|
switch {
|
||||||
case v >= rowTop:
|
case v >= rowTop:
|
||||||
b.WriteString(barStyle.Render(strings.Repeat("\u2588", barW)))
|
b.WriteString(barStyle.Render(strings.Repeat("█", barW)))
|
||||||
case v > rowBottom:
|
case v > rowBottom:
|
||||||
frac := (v - rowBottom) / (rowTop - rowBottom)
|
frac := (v - rowBottom) / (rowTop - rowBottom)
|
||||||
idx := int(frac * 8)
|
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)))
|
b.WriteString(barStyle.Render(strings.Repeat(string(blocks[idx]), barW)))
|
||||||
default:
|
default:
|
||||||
b.WriteString(strings.Repeat(" ", barW))
|
b.WriteString(lipgloss.NewStyle().Background(t.Surface).Render(strings.Repeat(" ", barW)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
b.WriteString("\n")
|
b.WriteString("\n")
|
||||||
@@ -209,7 +216,6 @@ func BarChart(values []float64, labels []string, color lipgloss.Color, width, he
|
|||||||
buf[i] = ' '
|
buf[i] = ' '
|
||||||
}
|
}
|
||||||
|
|
||||||
// Place labels at bar start positions, skip overlaps
|
|
||||||
minSpacing := 8
|
minSpacing := 8
|
||||||
labelStep := max(1, (n*minSpacing)/(axisLen+1))
|
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)
|
copy(buf[pos:end], lbl)
|
||||||
lastEnd = end + 1
|
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 {
|
if n > 1 {
|
||||||
lbl := labels[n-1]
|
lbl := labels[n-1]
|
||||||
pos := (n - 1) * (barW + gap)
|
pos := (n - 1) * (barW + gap)
|
||||||
end := pos + len(lbl)
|
end := pos + len(lbl)
|
||||||
if end > axisLen {
|
if end > axisLen {
|
||||||
// Right-align: shift left so it ends at the axis edge
|
|
||||||
pos = axisLen - len(lbl)
|
pos = axisLen - len(lbl)
|
||||||
end = axisLen
|
end = axisLen
|
||||||
}
|
}
|
||||||
@@ -251,8 +254,9 @@ func BarChart(values []float64, labels []string, color lipgloss.Color, width, he
|
|||||||
}
|
}
|
||||||
|
|
||||||
b.WriteString("\n")
|
b.WriteString("\n")
|
||||||
b.WriteString(strings.Repeat(" ", yLabelW+1))
|
labelStyle := lipgloss.NewStyle().Foreground(t.TextDim).Background(t.Surface)
|
||||||
b.WriteString(axisStyle.Render(strings.TrimRight(string(buf), " ")))
|
b.WriteString(lipgloss.NewStyle().Background(t.Surface).Render(strings.Repeat(" ", yLabelW+1)))
|
||||||
|
b.WriteString(labelStyle.Render(strings.TrimRight(string(buf), " ")))
|
||||||
}
|
}
|
||||||
|
|
||||||
return b.String()
|
return b.String()
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
"github.com/charmbracelet/lipgloss"
|
"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 {
|
func ProgressBar(pct float64, width int) string {
|
||||||
t := theme.Active
|
t := theme.Active
|
||||||
filled := int(pct * float64(width))
|
filled := int(pct * float64(width))
|
||||||
@@ -22,36 +22,45 @@ func ProgressBar(pct float64, width int) string {
|
|||||||
filled = 0
|
filled = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
filledStyle := lipgloss.NewStyle().Foreground(t.Accent)
|
// Color gradient based on progress
|
||||||
emptyStyle := lipgloss.NewStyle().Foreground(t.TextDim)
|
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
|
var b strings.Builder
|
||||||
for i := 0; i < filled; i++ {
|
b.WriteString(filledStyle.Render(strings.Repeat("█", filled)))
|
||||||
b.WriteString(filledStyle.Render("\u2588"))
|
b.WriteString(emptyStyle.Render(strings.Repeat("░", width-filled)))
|
||||||
}
|
|
||||||
for i := filled; i < width; i++ {
|
|
||||||
b.WriteString(emptyStyle.Render("\u2591"))
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
func ColorForPct(pct float64) string {
|
||||||
t := theme.Active
|
t := theme.Active
|
||||||
switch {
|
switch {
|
||||||
case pct >= 0.8:
|
case pct >= 0.9:
|
||||||
return string(t.Red)
|
return string(t.Red)
|
||||||
case pct >= 0.5:
|
case pct >= 0.7:
|
||||||
return string(t.Orange)
|
return string(t.Orange)
|
||||||
|
case pct >= 0.5:
|
||||||
|
return string(t.Yellow)
|
||||||
default:
|
default:
|
||||||
return string(t.Green)
|
return string(t.Green)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// RateLimitBar renders a labeled progress bar with percentage and countdown.
|
// 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 {
|
func RateLimitBar(label string, pct float64, resetsAt time.Time, labelW, barWidth int) string {
|
||||||
t := theme.Active
|
t := theme.Active
|
||||||
|
|
||||||
@@ -69,9 +78,10 @@ func RateLimitBar(label string, pct float64, resetsAt time.Time, labelW, barWidt
|
|||||||
)
|
)
|
||||||
bar.EmptyColor = string(t.TextDim)
|
bar.EmptyColor = string(t.TextDim)
|
||||||
|
|
||||||
labelStyle := lipgloss.NewStyle().Foreground(t.TextMuted)
|
labelStyle := lipgloss.NewStyle().Foreground(t.TextMuted).Background(t.Surface)
|
||||||
pctStyle := lipgloss.NewStyle().Foreground(t.TextPrimary).Bold(true)
|
pctStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(ColorForPct(pct))).Background(t.Surface).Bold(true)
|
||||||
countdownStyle := lipgloss.NewStyle().Foreground(t.TextDim)
|
countdownStyle := lipgloss.NewStyle().Foreground(t.TextDim).Background(t.Surface)
|
||||||
|
spaceStyle := lipgloss.NewStyle().Background(t.Surface)
|
||||||
|
|
||||||
pctStr := fmt.Sprintf("%3.0f%%", pct*100)
|
pctStr := fmt.Sprintf("%3.0f%%", pct*100)
|
||||||
countdown := ""
|
countdown := ""
|
||||||
@@ -84,16 +94,16 @@ func RateLimitBar(label string, pct float64, resetsAt time.Time, labelW, barWidt
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return fmt.Sprintf("%s %s %s %s",
|
return labelStyle.Render(fmt.Sprintf("%-*s", labelW, label)) +
|
||||||
labelStyle.Render(fmt.Sprintf("%-*s", labelW, label)),
|
spaceStyle.Render(" ") +
|
||||||
bar.ViewAs(pct),
|
bar.ViewAs(pct) +
|
||||||
pctStyle.Render(pctStr),
|
spaceStyle.Render(" ") +
|
||||||
countdownStyle.Render(countdown),
|
pctStyle.Render(pctStr) +
|
||||||
)
|
spaceStyle.Render(" ") +
|
||||||
|
countdownStyle.Render(countdown)
|
||||||
}
|
}
|
||||||
|
|
||||||
// CompactRateBar renders a tiny status-bar-sized rate indicator.
|
// CompactRateBar renders a tiny status-bar-sized rate indicator.
|
||||||
// Example output: "5h ████░░░░ 42%"
|
|
||||||
func CompactRateBar(label string, pct float64, width int) string {
|
func CompactRateBar(label string, pct float64, width int) string {
|
||||||
t := theme.Active
|
t := theme.Active
|
||||||
|
|
||||||
@@ -104,7 +114,6 @@ func CompactRateBar(label string, pct float64, width int) string {
|
|||||||
pct = 1
|
pct = 1
|
||||||
}
|
}
|
||||||
|
|
||||||
// label + space + bar + space + pct(4 chars)
|
|
||||||
barW := width - lipgloss.Width(label) - 6
|
barW := width - lipgloss.Width(label) - 6
|
||||||
if barW < 4 {
|
if barW < 4 {
|
||||||
barW = 4
|
barW = 4
|
||||||
@@ -117,14 +126,15 @@ func CompactRateBar(label string, pct float64, width int) string {
|
|||||||
)
|
)
|
||||||
bar.EmptyColor = string(t.TextDim)
|
bar.EmptyColor = string(t.TextDim)
|
||||||
|
|
||||||
pctStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(ColorForPct(pct)))
|
pctStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(ColorForPct(pct))).Background(t.Surface).Bold(true)
|
||||||
labelStyle := lipgloss.NewStyle().Foreground(t.TextMuted)
|
labelStyle := lipgloss.NewStyle().Foreground(t.TextMuted).Background(t.Surface)
|
||||||
|
spaceStyle := lipgloss.NewStyle().Background(t.Surface)
|
||||||
|
|
||||||
return fmt.Sprintf("%s %s %s",
|
return labelStyle.Render(label) +
|
||||||
labelStyle.Render(label),
|
spaceStyle.Render(" ") +
|
||||||
bar.ViewAs(pct),
|
bar.ViewAs(pct) +
|
||||||
pctStyle.Render(fmt.Sprintf("%2.0f%%", pct*100)),
|
spaceStyle.Render(" ") +
|
||||||
)
|
pctStyle.Render(fmt.Sprintf("%2.0f%%", pct*100))
|
||||||
}
|
}
|
||||||
|
|
||||||
func formatCountdown(d time.Duration) string {
|
func formatCountdown(d time.Duration) string {
|
||||||
|
|||||||
@@ -7,80 +7,119 @@ import (
|
|||||||
"github.com/theirongolddev/cburn/internal/claudeai"
|
"github.com/theirongolddev/cburn/internal/claudeai"
|
||||||
"github.com/theirongolddev/cburn/internal/tui/theme"
|
"github.com/theirongolddev/cburn/internal/tui/theme"
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbles/progress"
|
|
||||||
"github.com/charmbracelet/lipgloss"
|
"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 {
|
func RenderStatusBar(width int, dataAge string, subData *claudeai.SubscriptionData, refreshing, autoRefresh bool) string {
|
||||||
t := theme.Active
|
t := theme.Active
|
||||||
|
|
||||||
style := lipgloss.NewStyle().
|
// Main container
|
||||||
Foreground(t.TextMuted).
|
barStyle := lipgloss.NewStyle().
|
||||||
|
Background(t.SurfaceHover).
|
||||||
Width(width)
|
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
|
hintStyle := lipgloss.NewStyle().
|
||||||
ratePart := renderStatusRateLimits(subData)
|
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
|
var right string
|
||||||
if refreshing {
|
if refreshing {
|
||||||
refreshStyle := lipgloss.NewStyle().Foreground(t.Accent)
|
spinnerStyle := lipgloss.NewStyle().
|
||||||
right = refreshStyle.Render("↻ refreshing ")
|
Foreground(t.AccentBright).
|
||||||
|
Background(t.SurfaceHover).
|
||||||
|
Bold(true)
|
||||||
|
right = spinnerStyle.Render("↻ refreshing")
|
||||||
} else if dataAge != "" {
|
} else if dataAge != "" {
|
||||||
autoStr := ""
|
refreshIcon := ""
|
||||||
if autoRefresh {
|
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
|
// Calculate padding
|
||||||
usedWidth := lipgloss.Width(left) + lipgloss.Width(ratePart) + lipgloss.Width(right)
|
leftWidth := lipgloss.Width(left)
|
||||||
padding := width - usedWidth
|
middleWidth := lipgloss.Width(middle)
|
||||||
|
rightWidth := lipgloss.Width(right)
|
||||||
|
|
||||||
|
totalUsed := leftWidth + middleWidth + rightWidth
|
||||||
|
padding := width - totalUsed
|
||||||
if padding < 0 {
|
if padding < 0 {
|
||||||
padding = 0
|
padding = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// Split padding: more on the left side of rate indicators
|
|
||||||
leftPad := padding / 2
|
leftPad := padding / 2
|
||||||
rightPad := padding - leftPad
|
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 {
|
func renderStatusRateLimits(subData *claudeai.SubscriptionData) string {
|
||||||
if subData == nil || subData.Usage == nil {
|
if subData == nil || subData.Usage == nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
t := theme.Active
|
t := theme.Active
|
||||||
sepStyle := lipgloss.NewStyle().Foreground(t.TextDim)
|
|
||||||
|
|
||||||
var parts []string
|
var parts []string
|
||||||
|
|
||||||
if w := subData.Usage.FiveHour; w != nil {
|
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 {
|
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 {
|
if len(parts) == 0 {
|
||||||
return ""
|
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.
|
// renderRatePill renders a compact, colored rate indicator pill.
|
||||||
// Format: "5h ████░░░░ 42%"
|
func renderRatePill(label string, pct float64) string {
|
||||||
func compactStatusBar(label string, pct float64) string {
|
|
||||||
t := theme.Active
|
t := theme.Active
|
||||||
|
|
||||||
if pct < 0 {
|
if pct < 0 {
|
||||||
@@ -90,20 +129,52 @@ func compactStatusBar(label string, pct float64) string {
|
|||||||
pct = 1
|
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
|
barW := 8
|
||||||
bar := progress.New(
|
filled := int(pct * float64(barW))
|
||||||
progress.WithSolidFill(ColorForPct(pct)),
|
if filled > barW {
|
||||||
progress.WithWidth(barW),
|
filled = barW
|
||||||
progress.WithoutPercentage(),
|
}
|
||||||
)
|
|
||||||
bar.EmptyColor = string(t.TextDim)
|
|
||||||
|
|
||||||
labelStyle := lipgloss.NewStyle().Foreground(t.TextMuted)
|
bar := barStyle.Render(strings.Repeat("█", filled)) +
|
||||||
pctStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(ColorForPct(pct)))
|
emptyStyle.Render(strings.Repeat("░", barW-filled))
|
||||||
|
|
||||||
return fmt.Sprintf("%s %s %s",
|
spaceStyle := lipgloss.NewStyle().
|
||||||
labelStyle.Render(label),
|
Background(t.SurfaceHover)
|
||||||
bar.ViewAs(pct),
|
|
||||||
pctStyle.Render(fmt.Sprintf("%2.0f%%", pct*100)),
|
return labelStyle.Render(label+" ") + bar + spaceStyle.Render(" ") + pctStyle.Render(fmt.Sprintf("%2.0f%%", pct*100))
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,50 +24,113 @@ var Tabs = []Tab{
|
|||||||
{Name: "Settings", Key: 'x', KeyPos: -1},
|
{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 {
|
func RenderTabBar(activeIdx int, width int) string {
|
||||||
t := theme.Active
|
t := theme.Active
|
||||||
|
|
||||||
activeStyle := lipgloss.NewStyle().
|
// Container with bottom border
|
||||||
Foreground(t.Accent).
|
barStyle := lipgloss.NewStyle().
|
||||||
Bold(true)
|
Background(t.Surface).
|
||||||
|
Width(width)
|
||||||
|
|
||||||
inactiveStyle := lipgloss.NewStyle().
|
// Active tab: bright text with accent underline
|
||||||
Foreground(t.TextMuted)
|
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().
|
keyStyle := lipgloss.NewStyle().
|
||||||
Foreground(t.Accent).
|
Foreground(t.Accent).
|
||||||
Bold(true)
|
Background(t.Surface)
|
||||||
|
|
||||||
dimKeyStyle := lipgloss.NewStyle().
|
dimStyle := lipgloss.NewStyle().
|
||||||
Foreground(t.TextDim)
|
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 {
|
for i, tab := range Tabs {
|
||||||
var rendered string
|
var tabContent string
|
||||||
|
var underline string
|
||||||
|
|
||||||
if i == activeIdx {
|
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 {
|
} else {
|
||||||
// Render with highlighted shortcut key
|
// Inactive tab - show key hint
|
||||||
if tab.KeyPos >= 0 && tab.KeyPos < len(tab.Name) {
|
if tab.KeyPos >= 0 && tab.KeyPos < len(tab.Name) {
|
||||||
before := tab.Name[:tab.KeyPos]
|
before := tab.Name[:tab.KeyPos]
|
||||||
key := string(tab.Name[tab.KeyPos])
|
key := string(tab.Name[tab.KeyPos])
|
||||||
after := tab.Name[tab.KeyPos+1:]
|
after := tab.Name[tab.KeyPos+1:]
|
||||||
rendered = inactiveStyle.Render(before) +
|
tabContent = lipgloss.NewStyle().Padding(0, 1).Background(t.Surface).Render(
|
||||||
dimKeyStyle.Render("[") + keyStyle.Render(key) + dimKeyStyle.Render("]") +
|
dimStyle.Render(before) + keyStyle.Render(key) + dimStyle.Render(after))
|
||||||
inactiveStyle.Render(after)
|
|
||||||
} else {
|
} else {
|
||||||
// Key not in name (e.g., "Settings" with 'x')
|
tabContent = inactiveTabStyle.Render(tab.Name) +
|
||||||
rendered = inactiveStyle.Render(tab.Name) +
|
dimStyle.Render("[") + keyStyle.Render(string(tab.Key)) + dimStyle.Render("]")
|
||||||
dimKeyStyle.Render("[") + keyStyle.Render(string(tab.Key)) + dimKeyStyle.Render("]")
|
|
||||||
}
|
}
|
||||||
}
|
// Dim underline
|
||||||
parts = append(parts, rendered)
|
underline = lipgloss.NewStyle().
|
||||||
|
Foreground(t.Border).
|
||||||
|
Background(t.Surface).
|
||||||
|
Render(strings.Repeat("─", lipgloss.Width(tabContent)))
|
||||||
}
|
}
|
||||||
|
|
||||||
bar := " " + strings.Join(parts, " ")
|
tabParts = append(tabParts, tabContent)
|
||||||
if lipgloss.Width(bar) <= width {
|
underlineParts = append(underlineParts, underline)
|
||||||
return bar
|
|
||||||
|
// Add separator between tabs (not after last)
|
||||||
|
if i < len(Tabs)-1 {
|
||||||
|
tabParts = append(tabParts, sepStyle.Render(" "))
|
||||||
|
underlineParts = append(underlineParts, sepStyle.Render(" "))
|
||||||
}
|
}
|
||||||
return lipgloss.NewStyle().MaxWidth(width).Render(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 barStyle.Render(tabRow + "\n" + underlineRow)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user