mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-07 19:56:18 -04:00
UI Refresh
This commit is contained in:
@@ -8,6 +8,7 @@ import (
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/AmanTahiliani/box-box/internal/models"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
@@ -35,7 +36,6 @@ func formatGap(v interface{}) string {
|
||||
case string:
|
||||
return val
|
||||
case []interface{}:
|
||||
// Qualifying: return best Q time
|
||||
if len(val) == 0 {
|
||||
return "--"
|
||||
}
|
||||
@@ -56,7 +56,6 @@ func formatDuration(v interface{}) string {
|
||||
case float64:
|
||||
return formatSeconds(val)
|
||||
case []interface{}:
|
||||
// Qualifying: return best Q time
|
||||
if len(val) == 0 {
|
||||
return "--"
|
||||
}
|
||||
@@ -69,7 +68,6 @@ func formatDuration(v interface{}) string {
|
||||
}
|
||||
|
||||
// hexToStyle creates a lipgloss.Style with the given hex color as the foreground.
|
||||
// The hex string may or may not have a leading '#'.
|
||||
func hexToStyle(hex string) lipgloss.Style {
|
||||
if hex == "" {
|
||||
return lipgloss.NewStyle()
|
||||
@@ -80,6 +78,17 @@ func hexToStyle(hex string) lipgloss.Style {
|
||||
return lipgloss.NewStyle().Foreground(lipgloss.Color(hex))
|
||||
}
|
||||
|
||||
// hexToBgStyle creates a lipgloss.Style with the given hex color as the background.
|
||||
func hexToBgStyle(hex string) lipgloss.Style {
|
||||
if hex == "" {
|
||||
return lipgloss.NewStyle()
|
||||
}
|
||||
if !strings.HasPrefix(hex, "#") {
|
||||
hex = "#" + hex
|
||||
}
|
||||
return lipgloss.NewStyle().Background(lipgloss.Color(hex)).Foreground(lipgloss.Color(colorWhite))
|
||||
}
|
||||
|
||||
// sparkline generates a unicode block chart for lap times.
|
||||
// Pit laps (nil duration) are rendered as spaces.
|
||||
func sparkline(laps []models.Lap, width int) string {
|
||||
@@ -87,7 +96,7 @@ func sparkline(laps []models.Lap, width int) string {
|
||||
blockRunes := []rune(blocks)
|
||||
|
||||
if len(laps) == 0 {
|
||||
return strings.Repeat(" ", width)
|
||||
return styleMuted.Render(strings.Repeat("·", width))
|
||||
}
|
||||
|
||||
// Collect valid lap durations
|
||||
@@ -120,7 +129,7 @@ func sparkline(laps []models.Lap, width int) string {
|
||||
break
|
||||
}
|
||||
if d < 0 {
|
||||
sb.WriteRune(' ')
|
||||
sb.WriteString(styleMuted.Render("·"))
|
||||
} else {
|
||||
norm := (d - minDur) / rng
|
||||
// Invert: fast laps = tall bar (higher index)
|
||||
@@ -131,16 +140,26 @@ func sparkline(laps []models.Lap, width int) string {
|
||||
if idx >= len(blockRunes) {
|
||||
idx = len(blockRunes) - 1
|
||||
}
|
||||
sb.WriteRune(blockRunes[idx])
|
||||
// Color based on performance: fast=green, mid=yellow, slow=red
|
||||
var blockStyle lipgloss.Style
|
||||
switch {
|
||||
case norm < 0.25:
|
||||
blockStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(colorGreen))
|
||||
case norm < 0.5:
|
||||
blockStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(colorYellow))
|
||||
case norm < 0.75:
|
||||
blockStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(colorOrange))
|
||||
default:
|
||||
blockStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(colorF1Red))
|
||||
}
|
||||
sb.WriteString(blockStyle.Render(string(blockRunes[idx])))
|
||||
}
|
||||
count++
|
||||
}
|
||||
|
||||
result := sb.String()
|
||||
resultLen := utf8.RuneCountInString(result)
|
||||
if resultLen < width {
|
||||
result += strings.Repeat(" ", width-resultLen)
|
||||
}
|
||||
resultLen := utf8.RuneCountInString(lipgloss.NewStyle().Render(result))
|
||||
_ = resultLen
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -169,6 +188,24 @@ func tyreStyle(c models.TyreCompound) lipgloss.Style {
|
||||
}
|
||||
}
|
||||
|
||||
// tyreBgStyle returns a lipgloss.Style with background for tyre compound.
|
||||
func tyreBgStyle(c models.TyreCompound) lipgloss.Style {
|
||||
switch c {
|
||||
case models.CompoundSoft:
|
||||
return lipgloss.NewStyle().Background(lipgloss.Color(colorSoft)).Foreground(lipgloss.Color("#000000")).Bold(true)
|
||||
case models.CompoundMedium:
|
||||
return lipgloss.NewStyle().Background(lipgloss.Color(colorMedium)).Foreground(lipgloss.Color("#000000")).Bold(true)
|
||||
case models.CompoundHard:
|
||||
return lipgloss.NewStyle().Background(lipgloss.Color(colorHard)).Foreground(lipgloss.Color("#000000")).Bold(true)
|
||||
case models.CompoundIntermediate:
|
||||
return lipgloss.NewStyle().Background(lipgloss.Color(colorInter)).Foreground(lipgloss.Color("#000000")).Bold(true)
|
||||
case models.CompoundWet:
|
||||
return lipgloss.NewStyle().Background(lipgloss.Color(colorWet)).Foreground(lipgloss.Color(colorWhite)).Bold(true)
|
||||
default:
|
||||
return lipgloss.NewStyle().Background(lipgloss.Color(colorSurface2)).Foreground(lipgloss.Color(colorMuted))
|
||||
}
|
||||
}
|
||||
|
||||
// tyreAbbrev returns a single-letter abbreviation for a tyre compound.
|
||||
func tyreAbbrev(c models.TyreCompound) string {
|
||||
switch c {
|
||||
@@ -187,25 +224,72 @@ func tyreAbbrev(c models.TyreCompound) string {
|
||||
}
|
||||
}
|
||||
|
||||
// renderDelta returns a colored ▲N/▼N/= string for position change.
|
||||
// renderDelta returns a colored position change indicator, always 2 visible columns wide.
|
||||
func renderDelta(current, start int) string {
|
||||
diff := start - current // positive = gained positions
|
||||
switch {
|
||||
case diff > 0:
|
||||
return styleDeltaUp.Render(fmt.Sprintf("▲%d", diff))
|
||||
s := fmt.Sprintf("▲%d", diff)
|
||||
return padRightVisible(styleDeltaUp.Render(s), 2)
|
||||
case diff < 0:
|
||||
return styleDeltaDown.Render(fmt.Sprintf("▼%d", -diff))
|
||||
s := fmt.Sprintf("▼%d", -diff)
|
||||
return padRightVisible(styleDeltaDown.Render(s), 2)
|
||||
default:
|
||||
return styleDeltaEqual.Render("=")
|
||||
return padRightVisible(styleDeltaEqual.Render("─"), 2)
|
||||
}
|
||||
}
|
||||
|
||||
// renderPosition formats a position with podium coloring.
|
||||
func renderPosition(pos int) string {
|
||||
s := fmt.Sprintf("%d", pos)
|
||||
switch pos {
|
||||
case 1:
|
||||
return stylePositionFirst.Render(s)
|
||||
case 2:
|
||||
return stylePositionSecond.Render(s)
|
||||
case 3:
|
||||
return stylePositionThird.Render(s)
|
||||
default:
|
||||
return s
|
||||
}
|
||||
}
|
||||
|
||||
// renderPointsBar draws a horizontal progress bar for points.
|
||||
func renderPointsBar(points, maxPoints float64, width int, color string) string {
|
||||
if maxPoints == 0 || width <= 0 {
|
||||
return ""
|
||||
}
|
||||
ratio := points / maxPoints
|
||||
filled := int(ratio * float64(width))
|
||||
if filled > width {
|
||||
filled = width
|
||||
}
|
||||
if filled < 0 {
|
||||
filled = 0
|
||||
}
|
||||
|
||||
barStyle := lipgloss.NewStyle().
|
||||
Background(lipgloss.Color(color)).
|
||||
Foreground(lipgloss.Color(color))
|
||||
emptyStyle := lipgloss.NewStyle().
|
||||
Background(lipgloss.Color(colorSurface2)).
|
||||
Foreground(lipgloss.Color(colorSurface2))
|
||||
|
||||
bar := ""
|
||||
if filled > 0 {
|
||||
bar += barStyle.Render(strings.Repeat("━", filled))
|
||||
}
|
||||
remaining := width - filled
|
||||
if remaining > 0 {
|
||||
bar += emptyStyle.Render(strings.Repeat("━", remaining))
|
||||
}
|
||||
return bar
|
||||
}
|
||||
|
||||
// meetingStatus returns a status indicator for a meeting.
|
||||
// isNext should be true only for the first upcoming meeting.
|
||||
func meetingStatus(m models.Meeting, now time.Time, isNext bool) string {
|
||||
end, err := time.Parse(time.RFC3339, m.DateEnd)
|
||||
if err != nil {
|
||||
// Fallback: try date-only parsing
|
||||
end, err = time.Parse("2006-01-02", m.DateEnd[:min(len(m.DateEnd), 10)])
|
||||
if err != nil {
|
||||
return " "
|
||||
@@ -217,9 +301,9 @@ func meetingStatus(m models.Meeting, now time.Time, isNext bool) string {
|
||||
return stylePast.Render("✓")
|
||||
}
|
||||
if isNext {
|
||||
return styleNext.Render("◎")
|
||||
return styleNext.Render("●")
|
||||
}
|
||||
return " "
|
||||
return styleFuture.Render("○")
|
||||
}
|
||||
|
||||
// truncate shortens a string to max runes, appending "…" if truncated.
|
||||
@@ -234,7 +318,8 @@ func truncate(s string, max int) string {
|
||||
return string(runes[:max-1]) + "…"
|
||||
}
|
||||
|
||||
// padRight pads or truncates a string to exactly width runes.
|
||||
// padRight pads or truncates a plain string to exactly width runes.
|
||||
// Only use this for strings that contain no ANSI escape codes.
|
||||
func padRight(s string, width int) string {
|
||||
runes := []rune(s)
|
||||
if len(runes) >= width {
|
||||
@@ -243,7 +328,8 @@ func padRight(s string, width int) string {
|
||||
return s + strings.Repeat(" ", width-len(runes))
|
||||
}
|
||||
|
||||
// padLeft left-pads a string to exactly width runes.
|
||||
// padLeft left-pads a plain string to exactly width runes.
|
||||
// Only use this for strings that contain no ANSI escape codes.
|
||||
func padLeft(s string, width int) string {
|
||||
runes := []rune(s)
|
||||
if len(runes) >= width {
|
||||
@@ -252,6 +338,26 @@ func padLeft(s string, width int) string {
|
||||
return strings.Repeat(" ", width-len(runes)) + s
|
||||
}
|
||||
|
||||
// padRightVisible pads an ANSI-styled string to exactly width visible columns.
|
||||
// Uses lipgloss.Width to measure the visible display width, ignoring escape codes.
|
||||
func padRightVisible(s string, width int) string {
|
||||
vis := lipgloss.Width(s)
|
||||
if vis >= width {
|
||||
return s
|
||||
}
|
||||
return s + strings.Repeat(" ", width-vis)
|
||||
}
|
||||
|
||||
// padLeftVisible left-pads an ANSI-styled string to exactly width visible columns.
|
||||
// Uses lipgloss.Width to measure the visible display width, ignoring escape codes.
|
||||
func padLeftVisible(s string, width int) string {
|
||||
vis := lipgloss.Width(s)
|
||||
if vis >= width {
|
||||
return s
|
||||
}
|
||||
return strings.Repeat(" ", width-vis) + s
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
@@ -266,21 +372,90 @@ func max(a, b int) int {
|
||||
return b
|
||||
}
|
||||
|
||||
// countryFlag converts a country flag URL or code to an emoji flag.
|
||||
// OpenF1 provides flag URLs; we do a best-effort mapping from country_code.
|
||||
// countryFlag converts a country code to an emoji flag.
|
||||
func countryFlag(countryCode string) string {
|
||||
// Convert ISO 3166-1 alpha-2 to emoji regional indicators
|
||||
code := strings.ToUpper(countryCode)
|
||||
if len(code) != 2 {
|
||||
return " "
|
||||
}
|
||||
// Each letter maps to a regional indicator symbol (U+1F1E6 = 'A')
|
||||
r1 := rune(0x1F1E6 + int(code[0]-'A'))
|
||||
r2 := rune(0x1F1E6 + int(code[1]-'A'))
|
||||
return string(r1) + string(r2)
|
||||
}
|
||||
|
||||
// helpBar renders a horizontal help bar for a set of key hints.
|
||||
// helpBar renders a styled help bar with key/description pairs.
|
||||
// Format: "key:description" pairs separated by spaces in the output.
|
||||
func helpBar(hints ...string) string {
|
||||
return styleHelp.Render(strings.Join(hints, " "))
|
||||
var parts []string
|
||||
for _, h := range hints {
|
||||
// Split on first space: "key description"
|
||||
idx := strings.Index(h, " ")
|
||||
if idx > 0 {
|
||||
key := h[:idx]
|
||||
desc := h[idx+1:]
|
||||
parts = append(parts, styleHelpKey.Render(key)+" "+styleHelpDesc.Render(desc))
|
||||
} else {
|
||||
parts = append(parts, styleHelpDesc.Render(h))
|
||||
}
|
||||
}
|
||||
bar := strings.Join(parts, styleMuted.Render(" │ "))
|
||||
return styleHelp.Render(bar)
|
||||
}
|
||||
|
||||
// divider renders a subtle horizontal divider line.
|
||||
func divider(width int) string {
|
||||
if width <= 0 {
|
||||
width = 40
|
||||
}
|
||||
return styleMuted.Render(strings.Repeat("─", width))
|
||||
}
|
||||
|
||||
// matchKey checks if a KeyMsg matches a binding.
|
||||
func matchKey(msg tea.KeyMsg, binding interface{ Keys() []string }) bool {
|
||||
for _, k := range binding.Keys() {
|
||||
if msg.String() == k {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// teamColorFromName returns the known team color hex for a team name, or a fallback.
|
||||
func teamColorFromName(teamName string) string {
|
||||
name := strings.ToLower(teamName)
|
||||
switch {
|
||||
case strings.Contains(name, "red bull") && !strings.Contains(name, "racing bulls"):
|
||||
return colorRedbull
|
||||
case strings.Contains(name, "mercedes"):
|
||||
return colorMercedes
|
||||
case strings.Contains(name, "ferrari"):
|
||||
return colorFerrari
|
||||
case strings.Contains(name, "mclaren"):
|
||||
return colorMclaren
|
||||
case strings.Contains(name, "aston martin"):
|
||||
return colorAstonMartin
|
||||
case strings.Contains(name, "alpine"):
|
||||
return colorAlpine
|
||||
case strings.Contains(name, "williams"):
|
||||
return colorWilliams
|
||||
case strings.Contains(name, "haas"):
|
||||
return colorHaas
|
||||
case strings.Contains(name, "racing bulls"), strings.Contains(name, "alphatauri"), strings.Contains(name, "rb "):
|
||||
return colorRB
|
||||
case strings.Contains(name, "sauber"), strings.Contains(name, "alfa romeo"), strings.Contains(name, "stake"):
|
||||
return colorSauber
|
||||
default:
|
||||
return colorMuted
|
||||
}
|
||||
}
|
||||
|
||||
// teamColorBar renders a thin colored bar (e.g., "█") for team identity.
|
||||
func teamColorBar(teamColor string) string {
|
||||
if teamColor == "" {
|
||||
teamColor = colorMuted
|
||||
}
|
||||
if !strings.HasPrefix(teamColor, "#") {
|
||||
teamColor = "#" + teamColor
|
||||
}
|
||||
return lipgloss.NewStyle().Foreground(lipgloss.Color(teamColor)).Render("┃")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user