mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-08 04:06:18 -04:00
UI Refresh
This commit is contained in:
2
go.mod
2
go.mod
@@ -3,6 +3,7 @@ module github.com/AmanTahiliani/box-box
|
|||||||
go 1.25.6
|
go 1.25.6
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/atotto/clipboard v0.1.4 // indirect
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||||
github.com/charmbracelet/bubbles v1.0.0 // indirect
|
github.com/charmbracelet/bubbles v1.0.0 // indirect
|
||||||
github.com/charmbracelet/bubbletea v1.3.10 // indirect
|
github.com/charmbracelet/bubbletea v1.3.10 // indirect
|
||||||
@@ -23,6 +24,7 @@ require (
|
|||||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||||
github.com/muesli/termenv v0.16.0 // indirect
|
github.com/muesli/termenv v0.16.0 // indirect
|
||||||
github.com/rivo/uniseg v0.4.7 // indirect
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
|
github.com/sahilm/fuzzy v0.1.1 // indirect
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||||
golang.org/x/sys v0.38.0 // indirect
|
golang.org/x/sys v0.38.0 // indirect
|
||||||
golang.org/x/text v0.3.8 // indirect
|
golang.org/x/text v0.3.8 // indirect
|
||||||
|
|||||||
4
go.sum
4
go.sum
@@ -1,3 +1,5 @@
|
|||||||
|
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||||
|
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||||
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
|
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
|
||||||
@@ -38,6 +40,8 @@ github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc
|
|||||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||||
|
github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
|
||||||
|
github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
|||||||
@@ -5,12 +5,33 @@ import (
|
|||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// CacheStats tracks cache hit/miss statistics.
|
||||||
|
type CacheStats struct {
|
||||||
|
Hits int64
|
||||||
|
Misses int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// FileCache implements a file-backed HTTP response cache with TTL expiry.
|
||||||
type FileCache struct {
|
type FileCache struct {
|
||||||
dir string
|
dir string
|
||||||
|
stats CacheStats
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Default TTL values.
|
||||||
|
const (
|
||||||
|
// CacheTTLShort is for current-season, frequently changing data (meetings, sessions, results).
|
||||||
|
CacheTTLShort = 15 * time.Minute
|
||||||
|
// CacheTTLMedium is for semi-stable data (championship standings, driver lists).
|
||||||
|
CacheTTLMedium = 1 * time.Hour
|
||||||
|
// CacheTTLLong is for historical data that rarely changes (past season data).
|
||||||
|
CacheTTLLong = 24 * time.Hour
|
||||||
|
)
|
||||||
|
|
||||||
func NewFileCache() *FileCache {
|
func NewFileCache() *FileCache {
|
||||||
var cacheDir string
|
var cacheDir string
|
||||||
userCacheDir, err := os.UserCacheDir()
|
userCacheDir, err := os.UserCacheDir()
|
||||||
@@ -35,13 +56,56 @@ func (c *FileCache) getCachePath(key string) string {
|
|||||||
return filepath.Join(c.dir, filename)
|
return filepath.Join(c.dir, filename)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get retrieves data from the cache. Returns nil, false if not found.
|
// ttlForURL determines the appropriate TTL based on the URL pattern.
|
||||||
|
func ttlForURL(url string) time.Duration {
|
||||||
|
// Historical data (specific year queries for past years)
|
||||||
|
if strings.Contains(url, "year=2023") || strings.Contains(url, "year=2024") {
|
||||||
|
return CacheTTLLong
|
||||||
|
}
|
||||||
|
|
||||||
|
// Frequently changing endpoints
|
||||||
|
if strings.Contains(url, "/position") ||
|
||||||
|
strings.Contains(url, "/intervals") ||
|
||||||
|
strings.Contains(url, "/car_data") ||
|
||||||
|
strings.Contains(url, "/location") {
|
||||||
|
return CacheTTLShort
|
||||||
|
}
|
||||||
|
|
||||||
|
// Semi-stable data
|
||||||
|
if strings.Contains(url, "/championship") ||
|
||||||
|
strings.Contains(url, "/drivers") {
|
||||||
|
return CacheTTLMedium
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: medium TTL for everything else
|
||||||
|
return CacheTTLMedium
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get retrieves data from the cache. Returns nil, false if not found or expired.
|
||||||
func (c *FileCache) Get(key string) ([]byte, bool) {
|
func (c *FileCache) Get(key string) ([]byte, bool) {
|
||||||
path := c.getCachePath(key)
|
path := c.getCachePath(key)
|
||||||
data, err := os.ReadFile(path)
|
info, err := os.Stat(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
atomic.AddInt64(&c.stats.Misses, 1)
|
||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check TTL based on file modification time
|
||||||
|
ttl := ttlForURL(key)
|
||||||
|
if time.Since(info.ModTime()) > ttl {
|
||||||
|
// Expired — remove the stale file
|
||||||
|
_ = os.Remove(path)
|
||||||
|
atomic.AddInt64(&c.stats.Misses, 1)
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
atomic.AddInt64(&c.stats.Misses, 1)
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
atomic.AddInt64(&c.stats.Hits, 1)
|
||||||
return data, true
|
return data, true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,3 +114,45 @@ func (c *FileCache) Set(key string, data []byte) error {
|
|||||||
path := c.getCachePath(key)
|
path := c.getCachePath(key)
|
||||||
return os.WriteFile(path, data, 0644)
|
return os.WriteFile(path, data, 0644)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stats returns current cache hit/miss stats.
|
||||||
|
func (c *FileCache) Stats() CacheStats {
|
||||||
|
return CacheStats{
|
||||||
|
Hits: atomic.LoadInt64(&c.stats.Hits),
|
||||||
|
Misses: atomic.LoadInt64(&c.stats.Misses),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear removes all cached files.
|
||||||
|
func (c *FileCache) Clear() error {
|
||||||
|
entries, err := os.ReadDir(c.dir)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, entry := range entries {
|
||||||
|
if strings.HasSuffix(entry.Name(), ".json") {
|
||||||
|
_ = os.Remove(filepath.Join(c.dir, entry.Name()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Size returns the number of cached files and total size in bytes.
|
||||||
|
func (c *FileCache) Size() (int, int64) {
|
||||||
|
entries, err := os.ReadDir(c.dir)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0
|
||||||
|
}
|
||||||
|
count := 0
|
||||||
|
var totalSize int64
|
||||||
|
for _, entry := range entries {
|
||||||
|
if strings.HasSuffix(entry.Name(), ".json") {
|
||||||
|
count++
|
||||||
|
info, err := entry.Info()
|
||||||
|
if err == nil {
|
||||||
|
totalSize += info.Size()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count, totalSize
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,3 +18,13 @@ func NewOpenF1Client(url string, timeout time.Duration) *OpenF1Client {
|
|||||||
cache: NewFileCache(),
|
cache: NewFileCache(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CacheStats returns the cache hit/miss statistics.
|
||||||
|
func (c *OpenF1Client) CacheStats() CacheStats {
|
||||||
|
return c.cache.Stats()
|
||||||
|
}
|
||||||
|
|
||||||
|
// CacheSize returns the number of cached entries and total size in bytes.
|
||||||
|
func (c *OpenF1Client) CacheSize() (int, int64) {
|
||||||
|
return c.cache.Size()
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,8 +3,10 @@ package ui
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/AmanTahiliani/box-box/internal/api"
|
"github.com/AmanTahiliani/box-box/internal/api"
|
||||||
|
"github.com/AmanTahiliani/box-box/internal/models"
|
||||||
"github.com/charmbracelet/bubbles/spinner"
|
"github.com/charmbracelet/bubbles/spinner"
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
@@ -19,7 +21,11 @@ const (
|
|||||||
tabDriver tabIndex = 3
|
tabDriver tabIndex = 3
|
||||||
)
|
)
|
||||||
|
|
||||||
var tabNames = []string{"1 Standings", "2 Calendar", "3 Race", "4 Drivers"}
|
var tabNames = []string{"Standings", "Calendar", "Race", "Drivers"}
|
||||||
|
var tabIcons = []string{"🏆", "📅", "🏁", "👤"}
|
||||||
|
|
||||||
|
// splashDoneMsg is sent after the splash screen duration has elapsed.
|
||||||
|
type splashDoneMsg struct{}
|
||||||
|
|
||||||
// AppModel is the root Bubble Tea model.
|
// AppModel is the root Bubble Tea model.
|
||||||
type AppModel struct {
|
type AppModel struct {
|
||||||
@@ -34,11 +40,21 @@ type AppModel struct {
|
|||||||
calendar CalendarModel
|
calendar CalendarModel
|
||||||
raceDetail RaceDetailModel
|
raceDetail RaceDetailModel
|
||||||
driver DriverModel
|
driver DriverModel
|
||||||
|
|
||||||
|
meetings []models.Meeting
|
||||||
|
|
||||||
|
// Splash screen state
|
||||||
|
showSplash bool
|
||||||
|
splashSpinner spinner.Model
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAppModel creates the root model and wires sub-models.
|
|
||||||
func NewAppModel(client *api.OpenF1Client) AppModel {
|
func NewAppModel(client *api.OpenF1Client) AppModel {
|
||||||
year := 2025
|
year := 2025
|
||||||
|
|
||||||
|
sp := spinner.New()
|
||||||
|
sp.Spinner = spinner.Points
|
||||||
|
sp.Style = lipgloss.NewStyle().Foreground(lipgloss.Color(colorF1Red))
|
||||||
|
|
||||||
return AppModel{
|
return AppModel{
|
||||||
client: client,
|
client: client,
|
||||||
activeTab: tabStandings,
|
activeTab: tabStandings,
|
||||||
@@ -47,15 +63,25 @@ func NewAppModel(client *api.OpenF1Client) AppModel {
|
|||||||
calendar: NewCalendarModel(client, year),
|
calendar: NewCalendarModel(client, year),
|
||||||
raceDetail: NewRaceDetailModel(client),
|
raceDetail: NewRaceDetailModel(client),
|
||||||
driver: NewDriverModel(client),
|
driver: NewDriverModel(client),
|
||||||
|
showSplash: true,
|
||||||
|
splashSpinner: sp,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func splashTimer() tea.Cmd {
|
||||||
|
return tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
|
||||||
|
return splashDoneMsg{}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func (m AppModel) Init() tea.Cmd {
|
func (m AppModel) Init() tea.Cmd {
|
||||||
return tea.Batch(
|
return tea.Batch(
|
||||||
m.standings.Init(),
|
m.standings.Init(),
|
||||||
m.calendar.Init(),
|
m.calendar.Init(),
|
||||||
m.raceDetail.Init(),
|
m.raceDetail.Init(),
|
||||||
m.driver.Init(),
|
m.driver.Init(),
|
||||||
|
m.splashSpinner.Tick,
|
||||||
|
splashTimer(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,46 +92,76 @@ func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
case tea.WindowSizeMsg:
|
case tea.WindowSizeMsg:
|
||||||
m.width = msg.Width
|
m.width = msg.Width
|
||||||
m.height = msg.Height
|
m.height = msg.Height
|
||||||
contentHeight := m.height - 3 // tab bar + help
|
contentHeight := m.height - 5 // tab bar(2) + status bar + help + spacing
|
||||||
m.raceDetail.SetSize(m.width-4, contentHeight)
|
m.raceDetail.SetSize(m.width-4, contentHeight)
|
||||||
|
var cmd1, cmd2, cmd3, cmd4 tea.Cmd
|
||||||
|
m.standings, cmd1 = m.standings.Update(msg)
|
||||||
|
m.calendar, cmd2 = m.calendar.Update(msg)
|
||||||
|
m.raceDetail, cmd3 = m.raceDetail.Update(msg)
|
||||||
|
m.driver, cmd4 = m.driver.Update(msg)
|
||||||
|
return m, tea.Batch(cmd1, cmd2, cmd3, cmd4)
|
||||||
|
|
||||||
|
case splashDoneMsg:
|
||||||
|
m.showSplash = false
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
case tea.KeyMsg:
|
case tea.KeyMsg:
|
||||||
|
// Any key press during splash dismisses it
|
||||||
|
if m.showSplash {
|
||||||
|
m.showSplash = false
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
// Global quit
|
// Global quit
|
||||||
if matchKey(msg, GlobalKeys.Quit) {
|
if matchKey(msg, GlobalKeys.Quit) {
|
||||||
return m, tea.Quit
|
return m, tea.Quit
|
||||||
}
|
}
|
||||||
// Tab switching
|
// Tab switching
|
||||||
switch msg.String() {
|
switch {
|
||||||
case "1":
|
case matchKey(msg, GlobalKeys.Tab1):
|
||||||
m.activeTab = tabStandings
|
m.activeTab = tabStandings
|
||||||
return m, nil
|
return m, nil
|
||||||
case "2":
|
case matchKey(msg, GlobalKeys.Tab2):
|
||||||
m.activeTab = tabCalendar
|
m.activeTab = tabCalendar
|
||||||
return m, nil
|
return m, nil
|
||||||
case "3":
|
case matchKey(msg, GlobalKeys.Tab3):
|
||||||
m.activeTab = tabRaceDetail
|
m.activeTab = tabRaceDetail
|
||||||
return m, nil
|
return m, nil
|
||||||
case "4":
|
case matchKey(msg, GlobalKeys.Tab4):
|
||||||
m.activeTab = tabDriver
|
m.activeTab = tabDriver
|
||||||
var cmd tea.Cmd
|
var cmd tea.Cmd
|
||||||
m.driver, cmd = m.driver.TriggerLoad()
|
m.driver, cmd = m.driver.TriggerLoad()
|
||||||
cmds = append(cmds, cmd)
|
cmds = append(cmds, cmd)
|
||||||
return m, tea.Batch(cmds...)
|
return m, tea.Batch(cmds...)
|
||||||
case "b":
|
case matchKey(msg, GlobalKeys.NextTab):
|
||||||
// Back from race detail → calendar
|
m.activeTab = (m.activeTab + 1) % 4
|
||||||
|
if m.activeTab == tabDriver {
|
||||||
|
var cmd tea.Cmd
|
||||||
|
m.driver, cmd = m.driver.TriggerLoad()
|
||||||
|
cmds = append(cmds, cmd)
|
||||||
|
}
|
||||||
|
return m, tea.Batch(cmds...)
|
||||||
|
case matchKey(msg, GlobalKeys.PrevTab):
|
||||||
|
m.activeTab = (m.activeTab - 1 + 4) % 4
|
||||||
|
if m.activeTab == tabDriver {
|
||||||
|
var cmd tea.Cmd
|
||||||
|
m.driver, cmd = m.driver.TriggerLoad()
|
||||||
|
cmds = append(cmds, cmd)
|
||||||
|
}
|
||||||
|
return m, tea.Batch(cmds...)
|
||||||
|
case matchKey(msg, GlobalKeys.Back):
|
||||||
|
// Back from race detail -> calendar
|
||||||
if m.activeTab == tabRaceDetail {
|
if m.activeTab == tabRaceDetail {
|
||||||
m.activeTab = tabCalendar
|
m.activeTab = tabCalendar
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
case "y":
|
case matchKey(msg, GlobalKeys.Year):
|
||||||
// Cycle years: 2025 -> 2023 -> 2024 -> 2025
|
// Cycle years: 2025 -> 2023 -> 2024 -> 2025
|
||||||
if m.year == 2025 {
|
if m.year == 2025 {
|
||||||
m.year = 2023
|
m.year = 2023
|
||||||
} else {
|
} else {
|
||||||
m.year++
|
m.year++
|
||||||
}
|
}
|
||||||
// Update sub-models and re-trigger fetches
|
|
||||||
m.calendar.year = m.year
|
m.calendar.year = m.year
|
||||||
m.calendar.loading = true
|
m.calendar.loading = true
|
||||||
m.standings.year = m.year
|
m.standings.year = m.year
|
||||||
@@ -118,24 +174,20 @@ func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case meetingSelectedMsg:
|
case meetingSelectedMsg:
|
||||||
// Switch to race detail tab and forward the message
|
|
||||||
m.activeTab = tabRaceDetail
|
m.activeTab = tabRaceDetail
|
||||||
var cmd tea.Cmd
|
var cmd tea.Cmd
|
||||||
m.raceDetail, cmd = m.raceDetail.Update(msg)
|
m.raceDetail, cmd = m.raceDetail.Update(msg)
|
||||||
cmds = append(cmds, cmd)
|
cmds = append(cmds, cmd)
|
||||||
// Also forward to driver model for session key tracking
|
|
||||||
m.driver, _ = m.driver.Update(msg)
|
m.driver, _ = m.driver.Update(msg)
|
||||||
return m, tea.Batch(cmds...)
|
return m, tea.Batch(cmds...)
|
||||||
|
|
||||||
case sessionsLoadedMsg:
|
case sessionsLoadedMsg:
|
||||||
// Forward to raceDetail and driver
|
|
||||||
var cmd1, cmd2 tea.Cmd
|
var cmd1, cmd2 tea.Cmd
|
||||||
m.raceDetail, cmd1 = m.raceDetail.Update(msg)
|
m.raceDetail, cmd1 = m.raceDetail.Update(msg)
|
||||||
m.driver, cmd2 = m.driver.Update(msg)
|
m.driver, cmd2 = m.driver.Update(msg)
|
||||||
cmds = append(cmds, cmd1, cmd2)
|
cmds = append(cmds, cmd1, cmd2)
|
||||||
return m, tea.Batch(cmds...)
|
return m, tea.Batch(cmds...)
|
||||||
|
|
||||||
// Route all loaded messages to the appropriate sub-models
|
|
||||||
case driverChampionshipLoadedMsg:
|
case driverChampionshipLoadedMsg:
|
||||||
var cmd tea.Cmd
|
var cmd tea.Cmd
|
||||||
m.standings, cmd = m.standings.Update(msg)
|
m.standings, cmd = m.standings.Update(msg)
|
||||||
@@ -155,6 +207,9 @@ func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
return m, tea.Batch(cmds...)
|
return m, tea.Batch(cmds...)
|
||||||
|
|
||||||
case meetingsLoadedMsg:
|
case meetingsLoadedMsg:
|
||||||
|
if msg.err == nil {
|
||||||
|
m.meetings = msg.meetings
|
||||||
|
}
|
||||||
var cmd tea.Cmd
|
var cmd tea.Cmd
|
||||||
m.calendar, cmd = m.calendar.Update(msg)
|
m.calendar, cmd = m.calendar.Update(msg)
|
||||||
cmds = append(cmds, cmd)
|
cmds = append(cmds, cmd)
|
||||||
@@ -184,6 +239,12 @@ func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
cmds = append(cmds, cmd)
|
cmds = append(cmds, cmd)
|
||||||
return m, tea.Batch(cmds...)
|
return m, tea.Batch(cmds...)
|
||||||
|
|
||||||
|
case overtakesLoadedMsg:
|
||||||
|
var cmd tea.Cmd
|
||||||
|
m.raceDetail, cmd = m.raceDetail.Update(msg)
|
||||||
|
cmds = append(cmds, cmd)
|
||||||
|
return m, tea.Batch(cmds...)
|
||||||
|
|
||||||
case driverListLoadedMsg:
|
case driverListLoadedMsg:
|
||||||
var cmd tea.Cmd
|
var cmd tea.Cmd
|
||||||
m.driver, cmd = m.driver.Update(msg)
|
m.driver, cmd = m.driver.Update(msg)
|
||||||
@@ -208,8 +269,24 @@ func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
cmds = append(cmds, cmd)
|
cmds = append(cmds, cmd)
|
||||||
return m, tea.Batch(cmds...)
|
return m, tea.Batch(cmds...)
|
||||||
|
|
||||||
|
case driverPositionsLoadedMsg:
|
||||||
|
var cmd tea.Cmd
|
||||||
|
m.driver, cmd = m.driver.Update(msg)
|
||||||
|
cmds = append(cmds, cmd)
|
||||||
|
return m, tea.Batch(cmds...)
|
||||||
|
|
||||||
|
case driverTeamRadioLoadedMsg:
|
||||||
|
var cmd tea.Cmd
|
||||||
|
m.driver, cmd = m.driver.Update(msg)
|
||||||
|
cmds = append(cmds, cmd)
|
||||||
|
return m, tea.Batch(cmds...)
|
||||||
|
|
||||||
case spinner.TickMsg:
|
case spinner.TickMsg:
|
||||||
// Forward spinner ticks to all sub-models
|
if m.showSplash {
|
||||||
|
var cmd tea.Cmd
|
||||||
|
m.splashSpinner, cmd = m.splashSpinner.Update(msg)
|
||||||
|
cmds = append(cmds, cmd)
|
||||||
|
}
|
||||||
var cmd1, cmd2, cmd3, cmd4 tea.Cmd
|
var cmd1, cmd2, cmd3, cmd4 tea.Cmd
|
||||||
m.standings, cmd1 = m.standings.Update(msg)
|
m.standings, cmd1 = m.standings.Update(msg)
|
||||||
m.calendar, cmd2 = m.calendar.Update(msg)
|
m.calendar, cmd2 = m.calendar.Update(msg)
|
||||||
@@ -243,8 +320,20 @@ func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m AppModel) View() string {
|
func (m AppModel) View() string {
|
||||||
|
if m.showSplash {
|
||||||
|
return m.renderSplash()
|
||||||
|
}
|
||||||
|
|
||||||
|
w := m.width
|
||||||
|
if w < 40 {
|
||||||
|
w = 40
|
||||||
|
}
|
||||||
|
|
||||||
|
// Red accent stripe at the very top (F1 style)
|
||||||
|
stripe := styleTabStripe.Render(strings.Repeat("▔", w))
|
||||||
|
|
||||||
// Tab bar
|
// Tab bar
|
||||||
tabs := renderTabBar(m.activeTab, m.width)
|
tabs := renderTabBar(m.activeTab, m.year, w)
|
||||||
|
|
||||||
// Content area
|
// Content area
|
||||||
var content string
|
var content string
|
||||||
@@ -259,24 +348,178 @@ func (m AppModel) View() string {
|
|||||||
content = m.driver.View()
|
content = m.driver.View()
|
||||||
}
|
}
|
||||||
|
|
||||||
return tabs + "\n" + content
|
statusBar := m.renderStatusBar(w)
|
||||||
|
|
||||||
|
// Calculate how much vertical space is available for content
|
||||||
|
usedLines := 1 + 1 + 1 + 1 // stripe + tab bar + gap + status bar
|
||||||
|
contentHeight := m.height - usedLines
|
||||||
|
if contentHeight < 5 {
|
||||||
|
contentHeight = 5
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trim content to fit available height
|
||||||
|
contentLines := strings.Split(content, "\n")
|
||||||
|
if len(contentLines) > contentHeight {
|
||||||
|
contentLines = contentLines[:contentHeight]
|
||||||
|
}
|
||||||
|
content = strings.Join(contentLines, "\n")
|
||||||
|
|
||||||
|
return stripe + "\n" + tabs + "\n" + content + "\n" + statusBar
|
||||||
}
|
}
|
||||||
|
|
||||||
func renderTabBar(active tabIndex, width int) string {
|
func renderTabBar(active tabIndex, year int, width int) string {
|
||||||
|
// Build the logo
|
||||||
|
logo := lipgloss.NewStyle().
|
||||||
|
Bold(true).
|
||||||
|
Foreground(lipgloss.Color(colorF1Red)).
|
||||||
|
Background(lipgloss.Color(colorSurface0)).
|
||||||
|
Padding(0, 1).
|
||||||
|
Render("F1")
|
||||||
|
|
||||||
|
// Build tabs
|
||||||
var tabs []string
|
var tabs []string
|
||||||
for i, name := range tabNames {
|
for i, name := range tabNames {
|
||||||
|
label := fmt.Sprintf(" %s %d %s ", tabIcons[i], i+1, name)
|
||||||
if tabIndex(i) == active {
|
if tabIndex(i) == active {
|
||||||
tabs = append(tabs, styleActiveTab.Render(name))
|
tabs = append(tabs, styleActiveTab.Render(label))
|
||||||
} else {
|
} else {
|
||||||
tabs = append(tabs, styleInactiveTab.Render(name))
|
tabs = append(tabs, styleInactiveTab.Render(label))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
bar := strings.Join(tabs, "")
|
// Year badge
|
||||||
// Pad remaining width
|
yearBadge := styleYearBadge.Render(fmt.Sprintf(" %d ", year))
|
||||||
barWidth := lipgloss.Width(bar)
|
|
||||||
if barWidth < width {
|
// Assemble: logo + tabs + spacer + year
|
||||||
bar += strings.Repeat(" ", width-barWidth)
|
left := logo + strings.Join(tabs, "")
|
||||||
|
leftWidth := lipgloss.Width(left)
|
||||||
|
yearWidth := lipgloss.Width(yearBadge)
|
||||||
|
spacerWidth := width - leftWidth - yearWidth
|
||||||
|
if spacerWidth < 0 {
|
||||||
|
spacerWidth = 0
|
||||||
}
|
}
|
||||||
return styleTabBar.Render(fmt.Sprintf("%s", bar))
|
spacer := lipgloss.NewStyle().
|
||||||
|
Background(lipgloss.Color(colorSurface0)).
|
||||||
|
Render(strings.Repeat(" ", spacerWidth))
|
||||||
|
|
||||||
|
bar := left + spacer + yearBadge
|
||||||
|
return styleTabBar.Render(bar)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m AppModel) renderStatusBar(width int) string {
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
// Left side: brand
|
||||||
|
leftParts := []string{
|
||||||
|
lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color(colorF1Red)).
|
||||||
|
Bold(true).
|
||||||
|
Render("BOX-BOX"),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Next race countdown
|
||||||
|
var nextMeeting *models.Meeting
|
||||||
|
for i := range m.meetings {
|
||||||
|
start, err := time.Parse(time.RFC3339, m.meetings[i].DateStart)
|
||||||
|
if err != nil {
|
||||||
|
start, _ = time.Parse("2006-01-02", m.meetings[i].DateStart[:min(len(m.meetings[i].DateStart), 10)])
|
||||||
|
}
|
||||||
|
if start.After(now) {
|
||||||
|
nextMeeting = &m.meetings[i]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if nextMeeting != nil {
|
||||||
|
start, err := time.Parse(time.RFC3339, nextMeeting.DateStart)
|
||||||
|
if err == nil {
|
||||||
|
diff := start.Sub(now)
|
||||||
|
days := int(diff.Hours() / 24)
|
||||||
|
hours := int(diff.Hours()) % 24
|
||||||
|
|
||||||
|
flag := countryFlag(nextMeeting.CountryCode)
|
||||||
|
raceName := styleStatusValue.Render(nextMeeting.MeetingName)
|
||||||
|
countdown := styleCountdown.Render(fmt.Sprintf("%dd %dh", days, hours))
|
||||||
|
|
||||||
|
leftParts = append(leftParts,
|
||||||
|
styleStatusLabel.Render("│"),
|
||||||
|
styleStatusLabel.Render("NEXT"),
|
||||||
|
flag+" "+raceName,
|
||||||
|
styleStatusLabel.Render("in"),
|
||||||
|
countdown,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
left := strings.Join(leftParts, " ")
|
||||||
|
|
||||||
|
// Right side: cache stats + navigation hints
|
||||||
|
cacheStats := m.client.CacheStats()
|
||||||
|
cacheInfo := styleMuted.Render(fmt.Sprintf("cache %d/%d", cacheStats.Hits, cacheStats.Hits+cacheStats.Misses))
|
||||||
|
right := cacheInfo + " " + styleMuted.Render("1-4 tabs · y year · q quit")
|
||||||
|
|
||||||
|
leftW := lipgloss.Width(left)
|
||||||
|
rightW := lipgloss.Width(right)
|
||||||
|
spacerW := width - leftW - rightW - 2
|
||||||
|
if spacerW < 0 {
|
||||||
|
spacerW = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
bar := " " + left + strings.Repeat(" ", spacerW) + right + " "
|
||||||
|
return styleStatusBar.Width(width).Render(bar)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m AppModel) renderSplash() string {
|
||||||
|
w := m.width
|
||||||
|
h := m.height
|
||||||
|
if w == 0 {
|
||||||
|
w = 80
|
||||||
|
}
|
||||||
|
if h == 0 {
|
||||||
|
h = 24
|
||||||
|
}
|
||||||
|
|
||||||
|
// F1-themed ASCII logo
|
||||||
|
logo := []string{
|
||||||
|
"██████╗ ██████╗ ██╗ ██╗ ██████╗ ██████╗ ██╗ ██╗",
|
||||||
|
"██╔══██╗██╔═══██╗╚██╗██╔╝ ██╔══██╗██╔═══██╗╚██╗██╔╝",
|
||||||
|
"██████╔╝██║ ██║ ╚███╔╝█████╗██████╔╝██║ ██║ ╚███╔╝ ",
|
||||||
|
"██╔══██╗██║ ██║ ██╔██╗╚════╝██╔══██╗██║ ██║ ██╔██╗ ",
|
||||||
|
"██████╔╝╚██████╔╝██╔╝ ██╗ ██████╔╝╚██████╔╝██╔╝ ██╗",
|
||||||
|
"╚═════╝ ╚═════╝ ╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═╝",
|
||||||
|
}
|
||||||
|
|
||||||
|
redStyle := lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color(colorF1Red)).
|
||||||
|
Bold(true)
|
||||||
|
|
||||||
|
var logoBlock strings.Builder
|
||||||
|
for _, line := range logo {
|
||||||
|
logoBlock.WriteString(redStyle.Render(line) + "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
subtitle := lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color(colorMuted)).
|
||||||
|
Render("Formula 1 Terminal Dashboard")
|
||||||
|
|
||||||
|
loadingLine := fmt.Sprintf("%s %s",
|
||||||
|
m.splashSpinner.View(),
|
||||||
|
styleMuted.Render("Loading data..."))
|
||||||
|
|
||||||
|
hint := lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color(colorSurface2)).
|
||||||
|
Render("press any key to skip")
|
||||||
|
|
||||||
|
content := lipgloss.JoinVertical(lipgloss.Center,
|
||||||
|
logoBlock.String(),
|
||||||
|
"",
|
||||||
|
subtitle,
|
||||||
|
"",
|
||||||
|
loadingLine,
|
||||||
|
"",
|
||||||
|
hint,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Center the splash on screen
|
||||||
|
return lipgloss.Place(w, h, lipgloss.Center, lipgloss.Center, content)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,16 +18,19 @@ type CalendarModel struct {
|
|||||||
loading bool
|
loading bool
|
||||||
err error
|
err error
|
||||||
spinner spinner.Model
|
spinner spinner.Model
|
||||||
cursor int
|
|
||||||
year int
|
year int
|
||||||
width int
|
width int
|
||||||
height int
|
height int
|
||||||
|
|
||||||
|
cursor int
|
||||||
|
scroll int
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewCalendarModel(client *api.OpenF1Client, year int) CalendarModel {
|
func NewCalendarModel(client *api.OpenF1Client, year int) CalendarModel {
|
||||||
s := spinner.New()
|
s := spinner.New()
|
||||||
s.Spinner = spinner.MiniDot
|
s.Spinner = spinner.MiniDot
|
||||||
s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color(colorF1Red))
|
s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color(colorF1Red))
|
||||||
|
|
||||||
return CalendarModel{
|
return CalendarModel{
|
||||||
client: client,
|
client: client,
|
||||||
loading: true,
|
loading: true,
|
||||||
@@ -51,7 +54,13 @@ func (m CalendarModel) Init() tea.Cmd {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m CalendarModel) Update(msg tea.Msg) (CalendarModel, tea.Cmd) {
|
func (m CalendarModel) Update(msg tea.Msg) (CalendarModel, tea.Cmd) {
|
||||||
|
var cmds []tea.Cmd
|
||||||
|
|
||||||
switch msg := msg.(type) {
|
switch msg := msg.(type) {
|
||||||
|
case tea.WindowSizeMsg:
|
||||||
|
m.width = msg.Width
|
||||||
|
m.height = msg.Height
|
||||||
|
|
||||||
case spinner.TickMsg:
|
case spinner.TickMsg:
|
||||||
if m.loading {
|
if m.loading {
|
||||||
var cmd tea.Cmd
|
var cmd tea.Cmd
|
||||||
@@ -69,26 +78,75 @@ func (m CalendarModel) Update(msg tea.Msg) (CalendarModel, tea.Cmd) {
|
|||||||
m.loading = false
|
m.loading = false
|
||||||
// Auto-scroll to next upcoming race
|
// Auto-scroll to next upcoming race
|
||||||
m.cursor = m.findNextRaceIndex()
|
m.cursor = m.findNextRaceIndex()
|
||||||
|
m.ensureCursorVisible()
|
||||||
|
|
||||||
case tea.KeyMsg:
|
case tea.KeyMsg:
|
||||||
switch {
|
switch {
|
||||||
|
case matchKey(msg, GlobalKeys.Retry):
|
||||||
|
if m.err != nil {
|
||||||
|
m.err = nil
|
||||||
|
m.loading = true
|
||||||
|
return m, tea.Batch(m.spinner.Tick, fetchMeetings(m.client, m.year))
|
||||||
|
}
|
||||||
case matchKey(msg, GlobalKeys.Up):
|
case matchKey(msg, GlobalKeys.Up):
|
||||||
if m.cursor > 0 {
|
if m.cursor > 0 {
|
||||||
m.cursor--
|
m.cursor--
|
||||||
|
m.ensureCursorVisible()
|
||||||
}
|
}
|
||||||
case matchKey(msg, GlobalKeys.Down):
|
case matchKey(msg, GlobalKeys.Down):
|
||||||
if m.cursor < len(m.meetings)-1 {
|
if m.cursor < len(m.meetings)-1 {
|
||||||
m.cursor++
|
m.cursor++
|
||||||
|
m.ensureCursorVisible()
|
||||||
}
|
}
|
||||||
|
case matchKey(msg, GlobalKeys.GoTop):
|
||||||
|
m.cursor = 0
|
||||||
|
m.scroll = 0
|
||||||
|
case matchKey(msg, GlobalKeys.GoBottom):
|
||||||
|
if len(m.meetings) > 0 {
|
||||||
|
m.cursor = len(m.meetings) - 1
|
||||||
|
m.ensureCursorVisible()
|
||||||
|
}
|
||||||
|
case matchKey(msg, GlobalKeys.HalfUp):
|
||||||
|
half := m.visibleRows() / 2
|
||||||
|
m.cursor -= half
|
||||||
|
if m.cursor < 0 {
|
||||||
|
m.cursor = 0
|
||||||
|
}
|
||||||
|
m.ensureCursorVisible()
|
||||||
|
case matchKey(msg, GlobalKeys.HalfDown):
|
||||||
|
half := m.visibleRows() / 2
|
||||||
|
m.cursor += half
|
||||||
|
if m.cursor >= len(m.meetings) {
|
||||||
|
m.cursor = len(m.meetings) - 1
|
||||||
|
}
|
||||||
|
m.ensureCursorVisible()
|
||||||
case matchKey(msg, GlobalKeys.Enter):
|
case matchKey(msg, GlobalKeys.Enter):
|
||||||
if len(m.meetings) > 0 && m.cursor < len(m.meetings) {
|
if len(m.meetings) > 0 && m.cursor >= 0 && m.cursor < len(m.meetings) {
|
||||||
return m, func() tea.Msg {
|
return m, func() tea.Msg {
|
||||||
return meetingSelectedMsg{meeting: m.meetings[m.cursor]}
|
return meetingSelectedMsg{meeting: m.meetings[m.cursor]}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, tea.Batch(cmds...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m CalendarModel) visibleRows() int {
|
||||||
|
rows := m.height - 10
|
||||||
|
if rows < 5 {
|
||||||
|
rows = 5
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *CalendarModel) ensureCursorVisible() {
|
||||||
|
visible := m.visibleRows()
|
||||||
|
if m.cursor < m.scroll {
|
||||||
|
m.scroll = m.cursor
|
||||||
|
}
|
||||||
|
if m.cursor >= m.scroll+visible {
|
||||||
|
m.scroll = m.cursor - visible + 1
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m CalendarModel) findNextRaceIndex() int {
|
func (m CalendarModel) findNextRaceIndex() int {
|
||||||
@@ -110,81 +168,145 @@ func (m CalendarModel) findNextRaceIndex() int {
|
|||||||
|
|
||||||
func (m CalendarModel) View() string {
|
func (m CalendarModel) View() string {
|
||||||
if m.loading {
|
if m.loading {
|
||||||
return fmt.Sprintf("\n %s Loading %d calendar…", m.spinner.View(), m.year)
|
return fmt.Sprintf("\n %s Loading %d calendar...", m.spinner.View(), m.year)
|
||||||
}
|
}
|
||||||
if m.err != nil {
|
if m.err != nil {
|
||||||
return styleError.Render(fmt.Sprintf("\n Error: %v", m.err))
|
return styleError.Render(fmt.Sprintf("\n Error: %v\n\n", m.err)) +
|
||||||
|
helpBar("r retry", "q quit")
|
||||||
}
|
}
|
||||||
if len(m.meetings) == 0 {
|
if len(m.meetings) == 0 {
|
||||||
return styleMuted.Render(fmt.Sprintf("\n No meetings found for %d.", m.year))
|
return styleMuted.Render(fmt.Sprintf("\n No meetings found for %d.\n", m.year))
|
||||||
}
|
|
||||||
|
|
||||||
const (
|
|
||||||
wRound = 3
|
|
||||||
wName = 28
|
|
||||||
wCircuit = 20
|
|
||||||
wCountry = 16
|
|
||||||
wDates = 20
|
|
||||||
wStatus = 3
|
|
||||||
)
|
|
||||||
|
|
||||||
header := styleBold.Render(
|
|
||||||
padRight("Rd", wRound) + " " +
|
|
||||||
padRight("Grand Prix", wName) + " " +
|
|
||||||
padRight("Circuit", wCircuit) + " " +
|
|
||||||
padRight("Country", wCountry) + " " +
|
|
||||||
padRight("Dates", wDates) + " " +
|
|
||||||
padRight("", wStatus),
|
|
||||||
)
|
|
||||||
|
|
||||||
var rows []string
|
|
||||||
rows = append(rows, header)
|
|
||||||
|
|
||||||
now := time.Now()
|
|
||||||
nextIdx := m.findNextRaceIndex()
|
|
||||||
|
|
||||||
for i, meeting := range m.meetings {
|
|
||||||
isNext := (i == nextIdx)
|
|
||||||
status := meetingStatus(meeting, now, isNext)
|
|
||||||
|
|
||||||
dates := formatMeetingDates(meeting)
|
|
||||||
flag := countryFlag(meeting.CountryCode)
|
|
||||||
|
|
||||||
country := flag + " " + truncate(meeting.CountryName, wCountry-3)
|
|
||||||
|
|
||||||
row := fmt.Sprintf("%s %s %s %s %s %s",
|
|
||||||
padLeft(fmt.Sprintf("%d", i+1), wRound),
|
|
||||||
padRight(truncate(meeting.MeetingName, wName), wName),
|
|
||||||
padRight(truncate(meeting.CircuitShortName, wCircuit), wCircuit),
|
|
||||||
padRight(country, wCountry),
|
|
||||||
padRight(dates, wDates),
|
|
||||||
status,
|
|
||||||
)
|
|
||||||
|
|
||||||
if i == m.cursor {
|
|
||||||
row = styleSelected.Render(row)
|
|
||||||
} else if isNext {
|
|
||||||
row = styleNext.Render(row)
|
|
||||||
} else {
|
|
||||||
// Mute past races
|
|
||||||
end, err := time.Parse(time.RFC3339, meeting.DateEnd)
|
|
||||||
if err != nil {
|
|
||||||
end, _ = time.Parse("2006-01-02", meeting.DateEnd[:min(len(meeting.DateEnd), 10)])
|
|
||||||
end = end.Add(24 * time.Hour)
|
|
||||||
}
|
|
||||||
if end.Before(now) && i != m.cursor {
|
|
||||||
row = stylePast.Render(row)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
rows = append(rows, row)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
sb.WriteString(styleBold.Render(fmt.Sprintf(" Season: %d", m.year)) + "\n\n")
|
|
||||||
sb.WriteString(strings.Join(rows, "\n"))
|
// Title
|
||||||
sb.WriteString("\n\n")
|
title := lipgloss.NewStyle().
|
||||||
sb.WriteString(helpBar("y season", "j/k navigate", "enter select race", "q quit"))
|
Bold(true).
|
||||||
|
Foreground(lipgloss.Color(colorF1Red)).
|
||||||
|
Render(fmt.Sprintf(" FORMULA 1 %d RACE CALENDAR", m.year))
|
||||||
|
sb.WriteString(title + "\n\n")
|
||||||
|
|
||||||
|
// Custom rendered list (no bubbles/table - gives us more control)
|
||||||
|
now := time.Now()
|
||||||
|
nextIdx := m.findNextRaceIndex()
|
||||||
|
visible := m.visibleRows()
|
||||||
|
|
||||||
|
endIdx := m.scroll + visible
|
||||||
|
if endIdx > len(m.meetings) {
|
||||||
|
endIdx = len(m.meetings)
|
||||||
|
}
|
||||||
|
|
||||||
|
w := m.width
|
||||||
|
if w < 40 {
|
||||||
|
w = 40
|
||||||
|
}
|
||||||
|
compact := w < 90
|
||||||
|
wide := w >= 120
|
||||||
|
|
||||||
|
// Responsive column widths
|
||||||
|
gpWidth := 30
|
||||||
|
circuitWidth := 22
|
||||||
|
countryWidth := 18
|
||||||
|
if compact {
|
||||||
|
gpWidth = min(24, w-30)
|
||||||
|
circuitWidth = 0 // hide circuit in compact mode
|
||||||
|
countryWidth = 14
|
||||||
|
} else if wide {
|
||||||
|
gpWidth = 34
|
||||||
|
circuitWidth = 26
|
||||||
|
}
|
||||||
|
|
||||||
|
// Header
|
||||||
|
var header string
|
||||||
|
if compact {
|
||||||
|
header = fmt.Sprintf(" %s %s %s %s %s",
|
||||||
|
padRight("RD", 3),
|
||||||
|
padRight("S", 1),
|
||||||
|
padRight("GRAND PRIX", gpWidth),
|
||||||
|
padRight("COUNTRY", countryWidth),
|
||||||
|
padRight("DATES", 14),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
header = fmt.Sprintf(" %s %s %s %s %s %s",
|
||||||
|
padRight("RD", 3),
|
||||||
|
padRight("S", 1),
|
||||||
|
padRight("GRAND PRIX", gpWidth),
|
||||||
|
padRight("CIRCUIT", circuitWidth),
|
||||||
|
padRight("COUNTRY", countryWidth),
|
||||||
|
padRight("DATES", 14),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
sb.WriteString(lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color(colorMuted)).
|
||||||
|
Bold(true).
|
||||||
|
Render(header) + "\n")
|
||||||
|
sb.WriteString(" " + divider(min(w-6, lipgloss.Width(header))) + "\n")
|
||||||
|
|
||||||
|
for i := m.scroll; i < endIdx; i++ {
|
||||||
|
meeting := m.meetings[i]
|
||||||
|
isNext := (i == nextIdx)
|
||||||
|
status := meetingStatus(meeting, now, isNext)
|
||||||
|
dates := formatMeetingDates(meeting)
|
||||||
|
flag := countryFlag(meeting.CountryCode)
|
||||||
|
|
||||||
|
// Round number with special styling
|
||||||
|
roundNum := fmt.Sprintf("R%d", i+1)
|
||||||
|
if i+1 < 10 {
|
||||||
|
roundNum = fmt.Sprintf("R%d ", i+1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Style round number based on status
|
||||||
|
var roundStyle lipgloss.Style
|
||||||
|
if isNext {
|
||||||
|
roundStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(colorF1Red)).Bold(true)
|
||||||
|
} else {
|
||||||
|
roundStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(colorMuted))
|
||||||
|
}
|
||||||
|
|
||||||
|
// flag is 2 regional-indicator runes but renders as 2 terminal columns (double-width).
|
||||||
|
// Reserve 3 columns for "🇬🇧 " (2 cols for emoji + 1 for space) so country name width
|
||||||
|
// is countryWidth-3 runes, keeping the whole field at countryWidth visible columns.
|
||||||
|
countryField := padRight(flag+" "+truncate(meeting.CountryName, countryWidth-3), countryWidth)
|
||||||
|
|
||||||
|
var row string
|
||||||
|
if compact {
|
||||||
|
row = fmt.Sprintf(" %s %s %s %s %s",
|
||||||
|
roundStyle.Render(padRight(roundNum, 3)),
|
||||||
|
padRightVisible(status, 1), // status icon is 1 visible column; no extra padding needed
|
||||||
|
padRight(truncate(meeting.MeetingName, gpWidth), gpWidth),
|
||||||
|
countryField,
|
||||||
|
styleMuted.Render(dates),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
row = fmt.Sprintf(" %s %s %s %s %s %s",
|
||||||
|
roundStyle.Render(padRight(roundNum, 3)),
|
||||||
|
padRightVisible(status, 1), // status icon is 1 visible column; no extra padding needed
|
||||||
|
padRight(truncate(meeting.MeetingName, gpWidth), gpWidth),
|
||||||
|
padRight(truncate(meeting.CircuitShortName, circuitWidth), circuitWidth),
|
||||||
|
countryField,
|
||||||
|
styleMuted.Render(dates),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if i == m.cursor {
|
||||||
|
// Highlight the entire row
|
||||||
|
row = styleSelected.Render(row)
|
||||||
|
} else if isNext {
|
||||||
|
// Subtle highlight for next race
|
||||||
|
row = lipgloss.NewStyle().Foreground(lipgloss.Color(colorWhite)).Bold(true).Render(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.WriteString(row + "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scroll indicator
|
||||||
|
if len(m.meetings) > visible {
|
||||||
|
sb.WriteString(styleMuted.Render(fmt.Sprintf("\n Showing %d-%d of %d races", m.scroll+1, endIdx, len(m.meetings))) + "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.WriteString("\n")
|
||||||
|
sb.WriteString(helpBar("y season", "j/k navigate", "g/G top/bottom", "^d/^u page", "enter select", "q quit"))
|
||||||
return sb.String()
|
return sb.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,7 +325,7 @@ func formatMeetingDates(m models.Meeting) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if start.Month() == end.Month() {
|
if start.Month() == end.Month() {
|
||||||
return fmt.Sprintf("%s %d–%d", start.Format("Jan"), start.Day(), end.Day())
|
return fmt.Sprintf("%s %d-%d", start.Format("Jan"), start.Day(), end.Day())
|
||||||
}
|
}
|
||||||
return fmt.Sprintf("%s %d – %s %d", start.Format("Jan"), start.Day(), end.Format("Jan"), end.Day())
|
return fmt.Sprintf("%s %d - %s %d", start.Format("Jan"), start.Day(), end.Format("Jan"), end.Day())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,12 +3,16 @@ package ui
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/AmanTahiliani/box-box/internal/api"
|
"github.com/AmanTahiliani/box-box/internal/api"
|
||||||
"github.com/AmanTahiliani/box-box/internal/models"
|
"github.com/AmanTahiliani/box-box/internal/models"
|
||||||
"github.com/charmbracelet/bubbles/spinner"
|
"github.com/charmbracelet/bubbles/spinner"
|
||||||
|
"github.com/charmbracelet/bubbles/textinput"
|
||||||
|
"github.com/charmbracelet/bubbles/viewport"
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
"github.com/sahilm/fuzzy"
|
||||||
)
|
)
|
||||||
|
|
||||||
type driverView int
|
type driverView int
|
||||||
@@ -23,10 +27,13 @@ type DriverModel struct {
|
|||||||
sessionKey int
|
sessionKey int
|
||||||
|
|
||||||
drivers []models.Driver
|
drivers []models.Driver
|
||||||
|
filteredDrivers []models.Driver
|
||||||
selectedDriver *models.Driver
|
selectedDriver *models.Driver
|
||||||
stints []models.Stint
|
stints []models.Stint
|
||||||
laps []models.Lap
|
laps []models.Lap
|
||||||
pits []models.Pit
|
pits []models.Pit
|
||||||
|
positions []models.Position
|
||||||
|
teamRadios []models.TeamRadio
|
||||||
|
|
||||||
view driverView
|
view driverView
|
||||||
loading bool
|
loading bool
|
||||||
@@ -34,28 +41,53 @@ type DriverModel struct {
|
|||||||
spinner spinner.Model
|
spinner spinner.Model
|
||||||
|
|
||||||
cursor int
|
cursor int
|
||||||
|
scroll int
|
||||||
|
input textinput.Model
|
||||||
|
|
||||||
|
// Detail view viewport
|
||||||
|
detailView viewport.Model
|
||||||
|
detailViewReady bool
|
||||||
|
|
||||||
width int
|
width int
|
||||||
height int
|
height int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type driverSource []models.Driver
|
||||||
|
|
||||||
|
func (d driverSource) String(i int) string {
|
||||||
|
return d[i].FullName + " " + d[i].NameAcronym + " " + d[i].TeamName
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d driverSource) Len() int {
|
||||||
|
return len(d)
|
||||||
|
}
|
||||||
|
|
||||||
func NewDriverModel(client *api.OpenF1Client) DriverModel {
|
func NewDriverModel(client *api.OpenF1Client) DriverModel {
|
||||||
s := spinner.New()
|
s := spinner.New()
|
||||||
s.Spinner = spinner.MiniDot
|
s.Spinner = spinner.MiniDot
|
||||||
s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color(colorF1Red))
|
s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color(colorF1Red))
|
||||||
|
|
||||||
|
ti := textinput.New()
|
||||||
|
ti.Placeholder = "Search drivers..."
|
||||||
|
ti.Focus()
|
||||||
|
ti.CharLimit = 50
|
||||||
|
ti.Width = 30
|
||||||
|
ti.PromptStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(colorF1Red))
|
||||||
|
ti.TextStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(colorWhite))
|
||||||
|
|
||||||
return DriverModel{
|
return DriverModel{
|
||||||
client: client,
|
client: client,
|
||||||
loading: false, // lazy-loaded on first focus
|
loading: false,
|
||||||
spinner: s,
|
spinner: s,
|
||||||
|
input: ti,
|
||||||
view: driverViewList,
|
view: driverViewList,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func fetchDriverList(client *api.OpenF1Client) tea.Cmd {
|
func fetchDriverList(client *api.OpenF1Client) tea.Cmd {
|
||||||
return func() tea.Msg {
|
return func() tea.Msg {
|
||||||
// Use latest session key
|
drivers, err := client.GetDriversForSession(9999)
|
||||||
drivers, err := client.GetDriversForSession(9999) // will use "latest" via a workaround
|
|
||||||
if err != nil || len(drivers) == 0 {
|
if err != nil || len(drivers) == 0 {
|
||||||
// Fallback: get latest championship drivers' session key
|
|
||||||
champ, champErr := client.GetLatestDriverChampionship()
|
champ, champErr := client.GetLatestDriverChampionship()
|
||||||
if champErr != nil || len(champ) == 0 {
|
if champErr != nil || len(champ) == 0 {
|
||||||
return driverListLoadedMsg{err: err}
|
return driverListLoadedMsg{err: err}
|
||||||
@@ -99,6 +131,14 @@ func fetchDriverDetail(client *api.OpenF1Client, sessionKey, driverNumber int) t
|
|||||||
}
|
}
|
||||||
return driverPitsLoadedMsg{pits: driverPits, err: err}
|
return driverPitsLoadedMsg{pits: driverPits, err: err}
|
||||||
},
|
},
|
||||||
|
func() tea.Msg {
|
||||||
|
positions, err := client.GetPositions(sessionKey, driverNumber)
|
||||||
|
return driverPositionsLoadedMsg{positions: positions, err: err}
|
||||||
|
},
|
||||||
|
func() tea.Msg {
|
||||||
|
radios, err := client.GetTeamRadio(sessionKey, driverNumber)
|
||||||
|
return driverTeamRadioLoadedMsg{radios: radios, err: err}
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,7 +147,13 @@ func (m DriverModel) Init() tea.Cmd {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m DriverModel) Update(msg tea.Msg) (DriverModel, tea.Cmd) {
|
func (m DriverModel) Update(msg tea.Msg) (DriverModel, tea.Cmd) {
|
||||||
|
var cmds []tea.Cmd
|
||||||
|
|
||||||
switch msg := msg.(type) {
|
switch msg := msg.(type) {
|
||||||
|
case tea.WindowSizeMsg:
|
||||||
|
m.width = msg.Width
|
||||||
|
m.height = msg.Height
|
||||||
|
|
||||||
case spinner.TickMsg:
|
case spinner.TickMsg:
|
||||||
if m.loading {
|
if m.loading {
|
||||||
var cmd tea.Cmd
|
var cmd tea.Cmd
|
||||||
@@ -122,32 +168,44 @@ func (m DriverModel) Update(msg tea.Msg) (DriverModel, tea.Cmd) {
|
|||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
m.drivers = msg.drivers
|
m.drivers = msg.drivers
|
||||||
m.cursor = 0
|
m.filterDrivers()
|
||||||
|
|
||||||
case driverStintsLoadedMsg:
|
case driverStintsLoadedMsg:
|
||||||
if msg.err == nil {
|
if msg.err == nil {
|
||||||
m.stints = msg.stints
|
m.stints = msg.stints
|
||||||
}
|
}
|
||||||
m.loading = false
|
m.loading = false
|
||||||
|
m.updateDetailViewport()
|
||||||
|
|
||||||
case driverLapsLoadedMsg:
|
case driverLapsLoadedMsg:
|
||||||
if msg.err == nil {
|
if msg.err == nil {
|
||||||
m.laps = msg.laps
|
m.laps = msg.laps
|
||||||
}
|
}
|
||||||
|
m.updateDetailViewport()
|
||||||
|
|
||||||
case driverPitsLoadedMsg:
|
case driverPitsLoadedMsg:
|
||||||
if msg.err == nil {
|
if msg.err == nil {
|
||||||
m.pits = msg.pits
|
m.pits = msg.pits
|
||||||
}
|
}
|
||||||
|
m.updateDetailViewport()
|
||||||
|
|
||||||
|
case driverPositionsLoadedMsg:
|
||||||
|
if msg.err == nil {
|
||||||
|
m.positions = msg.positions
|
||||||
|
}
|
||||||
|
m.updateDetailViewport()
|
||||||
|
|
||||||
|
case driverTeamRadioLoadedMsg:
|
||||||
|
if msg.err == nil {
|
||||||
|
m.teamRadios = msg.radios
|
||||||
|
}
|
||||||
|
m.updateDetailViewport()
|
||||||
|
|
||||||
// When a meeting is selected from calendar, update session key for driver lookup
|
|
||||||
case meetingSelectedMsg:
|
case meetingSelectedMsg:
|
||||||
// We'll pick up session key when sessions are loaded; for now reset
|
|
||||||
m.drivers = nil
|
m.drivers = nil
|
||||||
|
|
||||||
case sessionsLoadedMsg:
|
case sessionsLoadedMsg:
|
||||||
if msg.err == nil && len(msg.sessions) > 0 {
|
if msg.err == nil && len(msg.sessions) > 0 {
|
||||||
// Use the Race session key if available
|
|
||||||
for _, s := range msg.sessions {
|
for _, s := range msg.sessions {
|
||||||
if s.SessionName == "Race" {
|
if s.SessionName == "Race" {
|
||||||
m.sessionKey = s.SessionKey
|
m.sessionKey = s.SessionKey
|
||||||
@@ -161,32 +219,86 @@ func (m DriverModel) Update(msg tea.Msg) (DriverModel, tea.Cmd) {
|
|||||||
switch m.view {
|
switch m.view {
|
||||||
case driverViewList:
|
case driverViewList:
|
||||||
switch {
|
switch {
|
||||||
|
case matchKey(msg, GlobalKeys.Retry):
|
||||||
|
if m.err != nil {
|
||||||
|
m.err = nil
|
||||||
|
m.loading = true
|
||||||
|
var cmd tea.Cmd
|
||||||
|
m, cmd = m.TriggerLoad()
|
||||||
|
cmds = append(cmds, cmd)
|
||||||
|
}
|
||||||
case matchKey(msg, GlobalKeys.Up):
|
case matchKey(msg, GlobalKeys.Up):
|
||||||
if m.cursor > 0 {
|
if m.cursor > 0 {
|
||||||
m.cursor--
|
m.cursor--
|
||||||
|
m.ensureCursorVisible()
|
||||||
}
|
}
|
||||||
case matchKey(msg, GlobalKeys.Down):
|
case matchKey(msg, GlobalKeys.Down):
|
||||||
if m.cursor < len(m.drivers)-1 {
|
if m.cursor < len(m.filteredDrivers)-1 {
|
||||||
m.cursor++
|
m.cursor++
|
||||||
|
m.ensureCursorVisible()
|
||||||
}
|
}
|
||||||
|
case matchKey(msg, GlobalKeys.GoTop):
|
||||||
|
if m.input.Value() == "" {
|
||||||
|
m.cursor = 0
|
||||||
|
m.scroll = 0
|
||||||
|
} else {
|
||||||
|
var cmd tea.Cmd
|
||||||
|
m.input, cmd = m.input.Update(msg)
|
||||||
|
cmds = append(cmds, cmd)
|
||||||
|
m.filterDrivers()
|
||||||
|
}
|
||||||
|
case matchKey(msg, GlobalKeys.GoBottom):
|
||||||
|
if m.input.Value() == "" {
|
||||||
|
if len(m.filteredDrivers) > 0 {
|
||||||
|
m.cursor = len(m.filteredDrivers) - 1
|
||||||
|
m.ensureCursorVisible()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
var cmd tea.Cmd
|
||||||
|
m.input, cmd = m.input.Update(msg)
|
||||||
|
cmds = append(cmds, cmd)
|
||||||
|
m.filterDrivers()
|
||||||
|
}
|
||||||
|
case matchKey(msg, GlobalKeys.HalfUp):
|
||||||
|
half := m.visibleRows() / 2
|
||||||
|
m.cursor -= half
|
||||||
|
if m.cursor < 0 {
|
||||||
|
m.cursor = 0
|
||||||
|
}
|
||||||
|
m.ensureCursorVisible()
|
||||||
|
case matchKey(msg, GlobalKeys.HalfDown):
|
||||||
|
half := m.visibleRows() / 2
|
||||||
|
m.cursor += half
|
||||||
|
if m.cursor >= len(m.filteredDrivers) {
|
||||||
|
m.cursor = len(m.filteredDrivers) - 1
|
||||||
|
}
|
||||||
|
if m.cursor < 0 {
|
||||||
|
m.cursor = 0
|
||||||
|
}
|
||||||
|
m.ensureCursorVisible()
|
||||||
case matchKey(msg, GlobalKeys.Enter):
|
case matchKey(msg, GlobalKeys.Enter):
|
||||||
if len(m.drivers) > 0 && m.cursor < len(m.drivers) {
|
if len(m.filteredDrivers) > 0 && m.cursor >= 0 && m.cursor < len(m.filteredDrivers) {
|
||||||
d := m.drivers[m.cursor]
|
d := m.filteredDrivers[m.cursor]
|
||||||
m.selectedDriver = &d
|
m.selectedDriver = &d
|
||||||
m.stints = nil
|
m.stints = nil
|
||||||
m.laps = nil
|
m.laps = nil
|
||||||
m.pits = nil
|
m.pits = nil
|
||||||
|
m.positions = nil
|
||||||
|
m.teamRadios = nil
|
||||||
m.view = driverViewDetail
|
m.view = driverViewDetail
|
||||||
m.loading = true
|
m.loading = true
|
||||||
|
m.detailViewReady = false
|
||||||
sessionKey := m.sessionKey
|
sessionKey := m.sessionKey
|
||||||
if sessionKey == 0 && d.SessionKey != 0 {
|
if sessionKey == 0 && d.SessionKey != 0 {
|
||||||
sessionKey = d.SessionKey
|
sessionKey = d.SessionKey
|
||||||
}
|
}
|
||||||
return m, tea.Batch(
|
cmds = append(cmds, m.spinner.Tick, fetchDriverDetail(m.client, sessionKey, d.DriverNumber))
|
||||||
m.spinner.Tick,
|
|
||||||
fetchDriverDetail(m.client, sessionKey, d.DriverNumber),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
default:
|
||||||
|
var cmd tea.Cmd
|
||||||
|
m.input, cmd = m.input.Update(msg)
|
||||||
|
cmds = append(cmds, cmd)
|
||||||
|
m.filterDrivers()
|
||||||
}
|
}
|
||||||
case driverViewDetail:
|
case driverViewDetail:
|
||||||
switch {
|
switch {
|
||||||
@@ -196,14 +308,73 @@ func (m DriverModel) Update(msg tea.Msg) (DriverModel, tea.Cmd) {
|
|||||||
m.stints = nil
|
m.stints = nil
|
||||||
m.laps = nil
|
m.laps = nil
|
||||||
m.pits = nil
|
m.pits = nil
|
||||||
|
m.positions = nil
|
||||||
|
m.teamRadios = nil
|
||||||
|
m.detailViewReady = false
|
||||||
|
case matchKey(msg, GlobalKeys.Up):
|
||||||
|
if m.detailViewReady {
|
||||||
|
m.detailView.LineUp(1)
|
||||||
|
}
|
||||||
|
case matchKey(msg, GlobalKeys.Down):
|
||||||
|
if m.detailViewReady {
|
||||||
|
m.detailView.LineDown(1)
|
||||||
|
}
|
||||||
|
case matchKey(msg, GlobalKeys.GoTop):
|
||||||
|
if m.detailViewReady {
|
||||||
|
m.detailView.GotoTop()
|
||||||
|
}
|
||||||
|
case matchKey(msg, GlobalKeys.GoBottom):
|
||||||
|
if m.detailViewReady {
|
||||||
|
m.detailView.GotoBottom()
|
||||||
|
}
|
||||||
|
case matchKey(msg, GlobalKeys.HalfUp):
|
||||||
|
if m.detailViewReady {
|
||||||
|
m.detailView.HalfViewUp()
|
||||||
|
}
|
||||||
|
case matchKey(msg, GlobalKeys.HalfDown):
|
||||||
|
if m.detailViewReady {
|
||||||
|
m.detailView.HalfViewDown()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return m, nil
|
return m, tea.Batch(cmds...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m DriverModel) visibleRows() int {
|
||||||
|
rows := m.height - 12
|
||||||
|
if rows < 5 {
|
||||||
|
rows = 5
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *DriverModel) ensureCursorVisible() {
|
||||||
|
visible := m.visibleRows()
|
||||||
|
if m.cursor < m.scroll {
|
||||||
|
m.scroll = m.cursor
|
||||||
|
}
|
||||||
|
if m.cursor >= m.scroll+visible {
|
||||||
|
m.scroll = m.cursor - visible + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *DriverModel) filterDrivers() {
|
||||||
|
query := m.input.Value()
|
||||||
|
if query == "" {
|
||||||
|
m.filteredDrivers = m.drivers
|
||||||
|
} else {
|
||||||
|
matches := fuzzy.FindFrom(query, driverSource(m.drivers))
|
||||||
|
m.filteredDrivers = make([]models.Driver, 0, len(matches))
|
||||||
|
for _, match := range matches {
|
||||||
|
m.filteredDrivers = append(m.filteredDrivers, m.drivers[match.Index])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m.cursor = 0
|
||||||
|
m.scroll = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// TriggerLoad initiates the driver list load if not already loaded.
|
|
||||||
func (m DriverModel) TriggerLoad() (DriverModel, tea.Cmd) {
|
func (m DriverModel) TriggerLoad() (DriverModel, tea.Cmd) {
|
||||||
if m.drivers != nil || m.loading {
|
if m.drivers != nil || m.loading {
|
||||||
return m, nil
|
return m, nil
|
||||||
@@ -213,11 +384,12 @@ func (m DriverModel) TriggerLoad() (DriverModel, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m DriverModel) View() string {
|
func (m DriverModel) View() string {
|
||||||
if m.loading {
|
if m.loading && m.view == driverViewList {
|
||||||
return fmt.Sprintf("\n %s Loading drivers…", m.spinner.View())
|
return fmt.Sprintf("\n %s Loading drivers...", m.spinner.View())
|
||||||
}
|
}
|
||||||
if m.err != nil {
|
if m.err != nil {
|
||||||
return styleError.Render(fmt.Sprintf("\n Error: %v", m.err))
|
return styleError.Render(fmt.Sprintf("\n Error: %v\n\n", m.err)) +
|
||||||
|
helpBar("r retry", "q quit")
|
||||||
}
|
}
|
||||||
|
|
||||||
switch m.view {
|
switch m.view {
|
||||||
@@ -230,49 +402,133 @@ func (m DriverModel) View() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m DriverModel) renderDriverList() string {
|
func (m DriverModel) renderDriverList() string {
|
||||||
if len(m.drivers) == 0 {
|
if len(m.drivers) == 0 && !m.loading {
|
||||||
return styleMuted.Render("\n No driver data. Select a race from Calendar first.\n\n" +
|
return styleMuted.Render("\n No driver data. Select a race from Calendar first.\n\n") +
|
||||||
helpBar("2 calendar", "q quit"))
|
helpBar("2 calendar", "r retry", "q quit")
|
||||||
}
|
|
||||||
|
|
||||||
const (
|
|
||||||
wNum = 3
|
|
||||||
wAcronym = 5
|
|
||||||
wName = 25
|
|
||||||
wTeam = 22
|
|
||||||
)
|
|
||||||
|
|
||||||
header := styleBold.Render(
|
|
||||||
padLeft("#", wNum) + " " +
|
|
||||||
padRight("DRV", wAcronym) + " " +
|
|
||||||
padRight("Name", wName) + " " +
|
|
||||||
padRight("Team", wTeam),
|
|
||||||
)
|
|
||||||
|
|
||||||
var rows []string
|
|
||||||
rows = append(rows, header)
|
|
||||||
|
|
||||||
for i, d := range m.drivers {
|
|
||||||
teamStr := hexToStyle(d.TeamColour).Render(padRight(truncate(d.TeamName, wTeam), wTeam))
|
|
||||||
row := fmt.Sprintf("%s %s %s %s",
|
|
||||||
padLeft(fmt.Sprintf("%d", d.DriverNumber), wNum),
|
|
||||||
padRight(d.NameAcronym, wAcronym),
|
|
||||||
padRight(truncate(d.FullName, wName), wName),
|
|
||||||
teamStr,
|
|
||||||
)
|
|
||||||
if i == m.cursor {
|
|
||||||
row = styleSelected.Render(row)
|
|
||||||
}
|
|
||||||
rows = append(rows, row)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
sb.WriteString(strings.Join(rows, "\n"))
|
w := m.width
|
||||||
sb.WriteString("\n\n")
|
if w < 40 {
|
||||||
sb.WriteString(helpBar("j/k navigate", "enter view driver", "q quit"))
|
w = 40
|
||||||
|
}
|
||||||
|
compact := w < 80
|
||||||
|
|
||||||
|
title := lipgloss.NewStyle().
|
||||||
|
Bold(true).
|
||||||
|
Foreground(lipgloss.Color(colorF1Red)).
|
||||||
|
Render(" DRIVER LOOKUP")
|
||||||
|
sb.WriteString(title + "\n\n")
|
||||||
|
|
||||||
|
// Search input
|
||||||
|
sb.WriteString(" " + m.input.View() + "\n\n")
|
||||||
|
|
||||||
|
// Responsive columns
|
||||||
|
nameWidth := 24
|
||||||
|
teamWidth := 22
|
||||||
|
if compact {
|
||||||
|
nameWidth = 16
|
||||||
|
teamWidth = 0 // hide team in compact mode
|
||||||
|
} else if w >= 120 {
|
||||||
|
nameWidth = 28
|
||||||
|
teamWidth = 26
|
||||||
|
}
|
||||||
|
|
||||||
|
// Header
|
||||||
|
var header string
|
||||||
|
if compact {
|
||||||
|
header = fmt.Sprintf(" %s %s %s %s",
|
||||||
|
padRight("#", 3),
|
||||||
|
padRight("", 1),
|
||||||
|
padRight("DRV", 4),
|
||||||
|
padRight("NAME", nameWidth),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
header = fmt.Sprintf(" %s %s %s %s %s",
|
||||||
|
padRight("#", 3),
|
||||||
|
padRight("", 1),
|
||||||
|
padRight("DRV", 4),
|
||||||
|
padRight("NAME", nameWidth),
|
||||||
|
padRight("TEAM", teamWidth),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
sb.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color(colorMuted)).Bold(true).Render(header) + "\n")
|
||||||
|
sb.WriteString(" " + divider(min(w-6, lipgloss.Width(header))) + "\n")
|
||||||
|
|
||||||
|
visible := m.visibleRows()
|
||||||
|
endIdx := m.scroll + visible
|
||||||
|
if endIdx > len(m.filteredDrivers) {
|
||||||
|
endIdx = len(m.filteredDrivers)
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := m.scroll; i < endIdx; i++ {
|
||||||
|
d := m.filteredDrivers[i]
|
||||||
|
|
||||||
|
teamColor := colorMuted
|
||||||
|
if d.TeamColour != "" {
|
||||||
|
teamColor = "#" + d.TeamColour
|
||||||
|
} else {
|
||||||
|
teamColor = teamColorFromName(d.TeamName)
|
||||||
|
}
|
||||||
|
|
||||||
|
colorBar := lipgloss.NewStyle().Foreground(lipgloss.Color(teamColor)).Render("┃")
|
||||||
|
|
||||||
|
var row string
|
||||||
|
if compact {
|
||||||
|
row = fmt.Sprintf(" %s %s %s %s",
|
||||||
|
padRight(fmt.Sprintf("%d", d.DriverNumber), 3),
|
||||||
|
colorBar,
|
||||||
|
padRight(d.NameAcronym, 4),
|
||||||
|
padRight(truncate(d.FullName, nameWidth), nameWidth),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
row = fmt.Sprintf(" %s %s %s %s %s",
|
||||||
|
padRight(fmt.Sprintf("%d", d.DriverNumber), 3),
|
||||||
|
colorBar,
|
||||||
|
padRight(d.NameAcronym, 4),
|
||||||
|
padRight(truncate(d.FullName, nameWidth), nameWidth),
|
||||||
|
padRight(truncate(d.TeamName, teamWidth), teamWidth),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if i == m.cursor {
|
||||||
|
row = styleSelected.Render(row)
|
||||||
|
}
|
||||||
|
sb.WriteString(row + "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(m.filteredDrivers) > visible {
|
||||||
|
sb.WriteString(styleMuted.Render(fmt.Sprintf("\n %d of %d drivers", len(m.filteredDrivers), len(m.drivers))) + "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.WriteString("\n")
|
||||||
|
sb.WriteString(helpBar("↑/↓ navigate", "g/G top/bottom", "^d/^u page", "enter view", "type to search", "q quit"))
|
||||||
return sb.String()
|
return sb.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *DriverModel) updateDetailViewport() {
|
||||||
|
if m.selectedDriver == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
content := m.renderDetailContent()
|
||||||
|
vpWidth := m.width - 2
|
||||||
|
if vpWidth < 40 {
|
||||||
|
vpWidth = 40
|
||||||
|
}
|
||||||
|
vpHeight := m.height - 5 // room for header + help bar
|
||||||
|
if vpHeight < 5 {
|
||||||
|
vpHeight = 5
|
||||||
|
}
|
||||||
|
if !m.detailViewReady {
|
||||||
|
m.detailView = viewport.New(vpWidth, vpHeight)
|
||||||
|
m.detailViewReady = true
|
||||||
|
} else {
|
||||||
|
m.detailView.Width = vpWidth
|
||||||
|
m.detailView.Height = vpHeight
|
||||||
|
}
|
||||||
|
m.detailView.SetContent(content)
|
||||||
|
}
|
||||||
|
|
||||||
func (m DriverModel) renderDriverDetail() string {
|
func (m DriverModel) renderDriverDetail() string {
|
||||||
if m.selectedDriver == nil {
|
if m.selectedDriver == nil {
|
||||||
return ""
|
return ""
|
||||||
@@ -281,75 +537,385 @@ func (m DriverModel) renderDriverDetail() string {
|
|||||||
|
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
|
|
||||||
// Nameplate header
|
teamColor := colorMuted
|
||||||
nameStyle := hexToStyle(d.TeamColour).Bold(true)
|
if d.TeamColour != "" {
|
||||||
sb.WriteString(nameStyle.Render(fmt.Sprintf(" %s %s", d.NameAcronym, d.FullName)))
|
teamColor = "#" + d.TeamColour
|
||||||
sb.WriteString(styleMuted.Render(fmt.Sprintf(" · %s · #%d", d.TeamName, d.DriverNumber)))
|
} else {
|
||||||
sb.WriteString("\n\n")
|
teamColor = teamColorFromName(d.TeamName)
|
||||||
|
}
|
||||||
|
|
||||||
// Stint bar
|
// Driver card header (fixed, not scrollable)
|
||||||
sb.WriteString(styleBold.Render("Stints") + "\n")
|
numberBg := lipgloss.NewStyle().
|
||||||
|
Background(lipgloss.Color(teamColor)).
|
||||||
|
Foreground(lipgloss.Color(colorWhite)).
|
||||||
|
Bold(true).
|
||||||
|
Padding(0, 1).
|
||||||
|
Render(fmt.Sprintf(" #%d ", d.DriverNumber))
|
||||||
|
|
||||||
|
nameStyled := lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color(teamColor)).
|
||||||
|
Bold(true).
|
||||||
|
Render(d.FullName)
|
||||||
|
|
||||||
|
acronymStyled := lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color(colorWhite)).
|
||||||
|
Bold(true).
|
||||||
|
Render(d.NameAcronym)
|
||||||
|
|
||||||
|
teamStyled := styleTeamName.Render(d.TeamName)
|
||||||
|
|
||||||
|
sb.WriteString(fmt.Sprintf("\n %s %s %s %s\n", numberBg, nameStyled, acronymStyled, teamStyled))
|
||||||
|
|
||||||
|
stripeWidth := min(m.width-6, 60)
|
||||||
|
stripe := lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color(teamColor)).
|
||||||
|
Render(strings.Repeat("━", stripeWidth))
|
||||||
|
sb.WriteString(" " + stripe + "\n")
|
||||||
|
|
||||||
|
if m.loading {
|
||||||
|
sb.WriteString(fmt.Sprintf("\n %s Loading driver data...\n", m.spinner.View()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scrollable content via viewport
|
||||||
|
if m.detailViewReady {
|
||||||
|
sb.WriteString(m.detailView.View())
|
||||||
|
sb.WriteString("\n")
|
||||||
|
// Scroll indicator
|
||||||
|
pct := m.detailView.ScrollPercent()
|
||||||
|
scrollInfo := styleMuted.Render(fmt.Sprintf(" %.0f%%", pct*100))
|
||||||
|
sb.WriteString(scrollInfo + "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.WriteString(helpBar("↑/↓ scroll", "g/G top/bottom", "^d/^u page", "b back", "q quit"))
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderDetailContent generates the full content for the driver detail viewport.
|
||||||
|
func (m DriverModel) renderDetailContent() string {
|
||||||
|
var sb strings.Builder
|
||||||
|
|
||||||
|
// ── POSITION HISTORY ────────────────────────────────
|
||||||
|
sb.WriteString("\n " + styleSectionTitle.Render("POSITION HISTORY") + "\n")
|
||||||
|
sb.WriteString(m.renderPositionChart())
|
||||||
|
sb.WriteString("\n")
|
||||||
|
|
||||||
|
// ── RACE STRATEGY ───────────────────────────────────
|
||||||
|
sb.WriteString(" " + styleSectionTitle.Render("RACE STRATEGY") + "\n")
|
||||||
sb.WriteString(m.renderStintBar())
|
sb.WriteString(m.renderStintBar())
|
||||||
sb.WriteString("\n\n")
|
sb.WriteString("\n\n")
|
||||||
|
|
||||||
// Lap sparkline
|
// ── LAP TIMES ───────────────────────────────────────
|
||||||
sb.WriteString(styleBold.Render("Lap Times") + "\n")
|
sb.WriteString(" " + styleSectionTitle.Render("LAP TIMES") + "\n")
|
||||||
sparkWidth := min(m.width-4, 80)
|
sparkWidth := min(m.width-6, 70)
|
||||||
if sparkWidth < 10 {
|
if sparkWidth < 10 {
|
||||||
sparkWidth = 40
|
sparkWidth = 40
|
||||||
}
|
}
|
||||||
sb.WriteString(" " + sparkline(m.laps, sparkWidth) + "\n")
|
sb.WriteString(" " + sparkline(m.laps, sparkWidth) + "\n")
|
||||||
sb.WriteString(styleMuted.Render(fmt.Sprintf(" %d laps (▁=slow, █=fast, space=pit out)\n", len(m.laps))))
|
legend := fmt.Sprintf(" %s fast %s mid %s slow %s pit",
|
||||||
sb.WriteString("\n")
|
lipgloss.NewStyle().Foreground(lipgloss.Color(colorGreen)).Render("█"),
|
||||||
|
lipgloss.NewStyle().Foreground(lipgloss.Color(colorYellow)).Render("█"),
|
||||||
|
lipgloss.NewStyle().Foreground(lipgloss.Color(colorF1Red)).Render("█"),
|
||||||
|
styleMuted.Render("·"),
|
||||||
|
)
|
||||||
|
sb.WriteString(legend)
|
||||||
|
|
||||||
// Pit stops
|
if len(m.laps) > 0 {
|
||||||
sb.WriteString(styleBold.Render("Pit Stops") + "\n")
|
var bestLap, worstLap float64
|
||||||
|
var bestLapNum, worstLapNum int
|
||||||
|
bestLap = 999999
|
||||||
|
for _, lap := range m.laps {
|
||||||
|
if lap.LapDuration != nil && *lap.LapDuration > 0 {
|
||||||
|
if *lap.LapDuration < bestLap {
|
||||||
|
bestLap = *lap.LapDuration
|
||||||
|
bestLapNum = lap.LapNumber
|
||||||
|
}
|
||||||
|
if *lap.LapDuration > worstLap && !lap.IsPitOutLap {
|
||||||
|
worstLap = *lap.LapDuration
|
||||||
|
worstLapNum = lap.LapNumber
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if bestLap < 999999 {
|
||||||
|
sb.WriteString(fmt.Sprintf(" %s %s (Lap %d)",
|
||||||
|
styleMuted.Render("Best:"),
|
||||||
|
lipgloss.NewStyle().Foreground(lipgloss.Color(colorGreen)).Bold(true).Render(formatSeconds(bestLap)),
|
||||||
|
bestLapNum,
|
||||||
|
))
|
||||||
|
if worstLap > 0 {
|
||||||
|
sb.WriteString(fmt.Sprintf(" %s %s (Lap %d)",
|
||||||
|
styleMuted.Render("Slowest:"),
|
||||||
|
lipgloss.NewStyle().Foreground(lipgloss.Color(colorF1Red)).Render(formatSeconds(worstLap)),
|
||||||
|
worstLapNum,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sb.WriteString("\n\n")
|
||||||
|
|
||||||
|
// ── PIT STOPS ───────────────────────────────────────
|
||||||
|
sb.WriteString(" " + styleSectionTitle.Render("PIT STOPS") + "\n")
|
||||||
sb.WriteString(m.renderPitStops())
|
sb.WriteString(m.renderPitStops())
|
||||||
sb.WriteString("\n\n")
|
sb.WriteString("\n\n")
|
||||||
|
|
||||||
sb.WriteString(helpBar("b back to driver list", "q quit"))
|
// ── TEAM RADIO ──────────────────────────────────────
|
||||||
|
sb.WriteString(" " + styleSectionTitle.Render("TEAM RADIO") + "\n")
|
||||||
|
sb.WriteString(m.renderTeamRadio())
|
||||||
|
sb.WriteString("\n")
|
||||||
|
|
||||||
return sb.String()
|
return sb.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m DriverModel) renderStintBar() string {
|
func (m DriverModel) renderStintBar() string {
|
||||||
if len(m.stints) == 0 {
|
if len(m.stints) == 0 {
|
||||||
if m.loading {
|
if m.loading {
|
||||||
return fmt.Sprintf(" %s", m.spinner.View())
|
return fmt.Sprintf(" %s Loading...", m.spinner.View())
|
||||||
}
|
}
|
||||||
return styleMuted.Render(" No stint data.")
|
return styleMuted.Render(" No stint data available.")
|
||||||
}
|
}
|
||||||
|
|
||||||
var parts []string
|
var parts []string
|
||||||
for _, stint := range m.stints {
|
for i, stint := range m.stints {
|
||||||
label := fmt.Sprintf("%s %d-%d", tyreAbbrev(stint.Compound), stint.LapStart, stint.LapEnd)
|
label := fmt.Sprintf(" %s L%d-%d ", tyreAbbrev(stint.Compound), stint.LapStart, stint.LapEnd)
|
||||||
part := tyreStyle(stint.Compound).Render(fmt.Sprintf("[%s]", label))
|
stintStr := tyreBgStyle(stint.Compound).Render(label)
|
||||||
parts = append(parts, part)
|
|
||||||
|
parts = append(parts, stintStr)
|
||||||
|
|
||||||
|
// Arrow between stints
|
||||||
|
if i < len(m.stints)-1 {
|
||||||
|
parts = append(parts, styleMuted.Render(" > "))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return " " + strings.Join(parts, " ")
|
return " " + strings.Join(parts, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m DriverModel) renderPitStops() string {
|
func (m DriverModel) renderPitStops() string {
|
||||||
if len(m.pits) == 0 {
|
if len(m.pits) == 0 {
|
||||||
return styleMuted.Render(" No pit stop data.")
|
return styleMuted.Render(" No pit stop data available.")
|
||||||
}
|
}
|
||||||
|
|
||||||
header := styleBold.Render(
|
var sb strings.Builder
|
||||||
padLeft("Lap", 4) + " " +
|
|
||||||
padLeft("Stop", 8) + " " +
|
|
||||||
padLeft("Lane", 8),
|
|
||||||
)
|
|
||||||
var rows []string
|
|
||||||
rows = append(rows, header)
|
|
||||||
|
|
||||||
for _, p := range m.pits {
|
// Header
|
||||||
row := fmt.Sprintf("%s %s %s",
|
header := fmt.Sprintf(" %s %s %s %s",
|
||||||
|
padRight("STOP", 5),
|
||||||
|
padLeft("LAP", 4),
|
||||||
|
padLeft("STOP TIME", 10),
|
||||||
|
padLeft("PIT LANE", 10),
|
||||||
|
)
|
||||||
|
sb.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color(colorMuted)).Bold(true).Render(header) + "\n")
|
||||||
|
sb.WriteString(" " + divider(35) + "\n")
|
||||||
|
|
||||||
|
for i, p := range m.pits {
|
||||||
|
stopNum := fmt.Sprintf("#%d", i+1)
|
||||||
|
|
||||||
|
// Color stop duration: green = fast, red = slow
|
||||||
|
stopDur := p.StopDuration
|
||||||
|
var stopStyle lipgloss.Style
|
||||||
|
switch {
|
||||||
|
case stopDur < 2.5:
|
||||||
|
stopStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(colorGreen)).Bold(true)
|
||||||
|
case stopDur < 3.5:
|
||||||
|
stopStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(colorYellow))
|
||||||
|
default:
|
||||||
|
stopStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(colorF1Red))
|
||||||
|
}
|
||||||
|
|
||||||
|
row := fmt.Sprintf(" %s %s %s %s",
|
||||||
|
padRight(stopNum, 5),
|
||||||
padLeft(fmt.Sprintf("%d", p.LapNumber), 4),
|
padLeft(fmt.Sprintf("%d", p.LapNumber), 4),
|
||||||
padLeft(fmt.Sprintf("%.3fs", p.StopDuration), 8),
|
padLeftVisible(stopStyle.Render(fmt.Sprintf("%.3fs", p.StopDuration)), 10),
|
||||||
padLeft(fmt.Sprintf("%.3fs", p.LaneDuration), 8),
|
padLeft(fmt.Sprintf("%.3fs", p.LaneDuration), 10),
|
||||||
)
|
)
|
||||||
rows = append(rows, " "+row)
|
sb.WriteString(row + "\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
return strings.Join(rows, "\n")
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderPositionChart draws a text-based position history chart.
|
||||||
|
// Shows position changes over the race using a compact inline format.
|
||||||
|
func (m DriverModel) renderPositionChart() string {
|
||||||
|
if len(m.positions) == 0 {
|
||||||
|
return styleMuted.Render(" No position data available.\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
|
||||||
|
// Deduplicate: only keep position changes
|
||||||
|
type posChange struct {
|
||||||
|
position int
|
||||||
|
date string
|
||||||
|
}
|
||||||
|
var changes []posChange
|
||||||
|
lastPos := -1
|
||||||
|
for _, p := range m.positions {
|
||||||
|
if p.Position != lastPos {
|
||||||
|
changes = append(changes, posChange{position: p.Position, date: p.Date})
|
||||||
|
lastPos = p.Position
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(changes) == 0 {
|
||||||
|
return styleMuted.Render(" No position changes.\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start and end positions
|
||||||
|
startPos := changes[0].position
|
||||||
|
endPos := changes[len(changes)-1].position
|
||||||
|
bestPos := startPos
|
||||||
|
worstPos := startPos
|
||||||
|
for _, c := range changes {
|
||||||
|
if c.position < bestPos {
|
||||||
|
bestPos = c.position
|
||||||
|
}
|
||||||
|
if c.position > worstPos {
|
||||||
|
worstPos = c.position
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Summary line
|
||||||
|
startStyled := renderPosition(startPos)
|
||||||
|
endStyled := renderPosition(endPos)
|
||||||
|
delta := startPos - endPos // positive = gained
|
||||||
|
var deltaStr string
|
||||||
|
if delta > 0 {
|
||||||
|
deltaStr = styleDeltaUp.Render(fmt.Sprintf("▲%d gained", delta))
|
||||||
|
} else if delta < 0 {
|
||||||
|
deltaStr = styleDeltaDown.Render(fmt.Sprintf("▼%d lost", -delta))
|
||||||
|
} else {
|
||||||
|
deltaStr = styleDeltaEqual.Render("─ no change")
|
||||||
|
}
|
||||||
|
sb.WriteString(fmt.Sprintf(" Start: P%s Finish: P%s %s\n", startStyled, endStyled, deltaStr))
|
||||||
|
sb.WriteString(fmt.Sprintf(" %s P%s %s P%s\n",
|
||||||
|
styleMuted.Render("Best:"),
|
||||||
|
lipgloss.NewStyle().Foreground(lipgloss.Color(colorGreen)).Bold(true).Render(fmt.Sprintf("%d", bestPos)),
|
||||||
|
styleMuted.Render("Worst:"),
|
||||||
|
lipgloss.NewStyle().Foreground(lipgloss.Color(colorF1Red)).Render(fmt.Sprintf("%d", worstPos)),
|
||||||
|
))
|
||||||
|
|
||||||
|
// Visual position timeline (compact sparkline-style)
|
||||||
|
chartWidth := min(m.width-6, 60)
|
||||||
|
if chartWidth < 20 {
|
||||||
|
chartWidth = 20
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(changes) > 1 {
|
||||||
|
posRange := worstPos - bestPos
|
||||||
|
if posRange == 0 {
|
||||||
|
posRange = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sample positions at regular intervals
|
||||||
|
samples := make([]int, chartWidth)
|
||||||
|
for i := 0; i < chartWidth; i++ {
|
||||||
|
idx := i * (len(changes) - 1) / (chartWidth - 1)
|
||||||
|
if idx >= len(changes) {
|
||||||
|
idx = len(changes) - 1
|
||||||
|
}
|
||||||
|
samples[i] = changes[idx].position
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw the chart
|
||||||
|
blocks := []rune("▁▂▃▄▅▆▇█")
|
||||||
|
var chartLine strings.Builder
|
||||||
|
for _, pos := range samples {
|
||||||
|
// Invert: lower position (better) = taller bar
|
||||||
|
norm := float64(pos-bestPos) / float64(posRange)
|
||||||
|
idx := int((1.0-norm)*float64(len(blocks)-1) + 0.5)
|
||||||
|
if idx < 0 {
|
||||||
|
idx = 0
|
||||||
|
}
|
||||||
|
if idx >= len(blocks) {
|
||||||
|
idx = len(blocks) - 1
|
||||||
|
}
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
chartLine.WriteString(blockStyle.Render(string(blocks[idx])))
|
||||||
|
}
|
||||||
|
sb.WriteString(" " + chartLine.String() + "\n")
|
||||||
|
sb.WriteString(fmt.Sprintf(" %s%s%s\n",
|
||||||
|
styleMuted.Render(fmt.Sprintf("P%d", bestPos)),
|
||||||
|
strings.Repeat(" ", max(chartWidth-8, 1)),
|
||||||
|
styleMuted.Render(fmt.Sprintf("P%d", worstPos)),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Position change timeline (textual)
|
||||||
|
if len(changes) > 1 && len(changes) <= 20 {
|
||||||
|
sb.WriteString(" ")
|
||||||
|
for i, c := range changes {
|
||||||
|
posStr := fmt.Sprintf("P%d", c.position)
|
||||||
|
if c.position <= 3 {
|
||||||
|
posStr = renderPosition(c.position)
|
||||||
|
}
|
||||||
|
sb.WriteString(posStr)
|
||||||
|
if i < len(changes)-1 {
|
||||||
|
next := changes[i+1].position
|
||||||
|
if next < c.position {
|
||||||
|
sb.WriteString(styleDeltaUp.Render(" > "))
|
||||||
|
} else if next > c.position {
|
||||||
|
sb.WriteString(styleDeltaDown.Render(" > "))
|
||||||
|
} else {
|
||||||
|
sb.WriteString(styleMuted.Render(" > "))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderTeamRadio displays team radio messages with timestamps.
|
||||||
|
func (m DriverModel) renderTeamRadio() string {
|
||||||
|
if len(m.teamRadios) == 0 {
|
||||||
|
return styleMuted.Render(" No team radio messages available.\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
|
||||||
|
countStr := styleMuted.Render(fmt.Sprintf(" %d messages during session", len(m.teamRadios)))
|
||||||
|
sb.WriteString(countStr + "\n")
|
||||||
|
|
||||||
|
for i, radio := range m.teamRadios {
|
||||||
|
// Parse timestamp
|
||||||
|
t := "--:--:--"
|
||||||
|
if len(radio.Date) >= 19 {
|
||||||
|
pt, err := time.Parse(time.RFC3339, radio.Date)
|
||||||
|
if err == nil {
|
||||||
|
t = pt.Format("15:04:05")
|
||||||
|
} else {
|
||||||
|
t = radio.Date[11:19]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
icon := lipgloss.NewStyle().Foreground(lipgloss.Color(colorOrange)).Render("📻")
|
||||||
|
timeStr := styleMuted.Render(fmt.Sprintf("[%s]", t))
|
||||||
|
|
||||||
|
urlStr := radio.RecordingURL
|
||||||
|
urlStyled := lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color(colorCyan)).
|
||||||
|
Render(truncate(urlStr, min(m.width-20, 50)))
|
||||||
|
|
||||||
|
sb.WriteString(fmt.Sprintf(" %s %s %s\n", icon, timeStr, urlStyled))
|
||||||
|
|
||||||
|
// Limit display to 15 entries
|
||||||
|
if i >= 14 && i < len(m.teamRadios)-1 {
|
||||||
|
remaining := len(m.teamRadios) - i - 1
|
||||||
|
sb.WriteString(styleMuted.Render(fmt.Sprintf(" ... and %d more messages\n", remaining)))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.String()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,12 +8,19 @@ type GlobalKeyMap struct {
|
|||||||
Tab2 key.Binding
|
Tab2 key.Binding
|
||||||
Tab3 key.Binding
|
Tab3 key.Binding
|
||||||
Tab4 key.Binding
|
Tab4 key.Binding
|
||||||
|
NextTab key.Binding
|
||||||
|
PrevTab key.Binding
|
||||||
Quit key.Binding
|
Quit key.Binding
|
||||||
Up key.Binding
|
Up key.Binding
|
||||||
Down key.Binding
|
Down key.Binding
|
||||||
Enter key.Binding
|
Enter key.Binding
|
||||||
Back key.Binding
|
Back key.Binding
|
||||||
Year key.Binding
|
Year key.Binding
|
||||||
|
Retry key.Binding
|
||||||
|
GoTop key.Binding
|
||||||
|
GoBottom key.Binding
|
||||||
|
HalfUp key.Binding
|
||||||
|
HalfDown key.Binding
|
||||||
}
|
}
|
||||||
|
|
||||||
// GlobalKeys is the singleton global key map.
|
// GlobalKeys is the singleton global key map.
|
||||||
@@ -34,30 +41,58 @@ var GlobalKeys = GlobalKeyMap{
|
|||||||
key.WithKeys("4"),
|
key.WithKeys("4"),
|
||||||
key.WithHelp("4", "drivers"),
|
key.WithHelp("4", "drivers"),
|
||||||
),
|
),
|
||||||
|
NextTab: key.NewBinding(
|
||||||
|
key.WithKeys("tab", "right"),
|
||||||
|
key.WithHelp("tab/->", "next tab"),
|
||||||
|
),
|
||||||
|
PrevTab: key.NewBinding(
|
||||||
|
key.WithKeys("shift+tab", "left"),
|
||||||
|
key.WithHelp("shift+tab/<-", "prev tab"),
|
||||||
|
),
|
||||||
Quit: key.NewBinding(
|
Quit: key.NewBinding(
|
||||||
key.WithKeys("q", "ctrl+c"),
|
key.WithKeys("q", "ctrl+c"),
|
||||||
key.WithHelp("q", "quit"),
|
key.WithHelp("q", "quit"),
|
||||||
),
|
),
|
||||||
Up: key.NewBinding(
|
Up: key.NewBinding(
|
||||||
key.WithKeys("k", "up"),
|
key.WithKeys("k", "up"),
|
||||||
key.WithHelp("k/↑", "up"),
|
key.WithHelp("k", "up"),
|
||||||
),
|
),
|
||||||
Down: key.NewBinding(
|
Down: key.NewBinding(
|
||||||
key.WithKeys("j", "down"),
|
key.WithKeys("j", "down"),
|
||||||
key.WithHelp("j/↓", "down"),
|
key.WithHelp("j", "down"),
|
||||||
),
|
),
|
||||||
Enter: key.NewBinding(
|
Enter: key.NewBinding(
|
||||||
key.WithKeys("enter"),
|
key.WithKeys("enter"),
|
||||||
key.WithHelp("enter", "select"),
|
key.WithHelp("enter", "select"),
|
||||||
),
|
),
|
||||||
Back: key.NewBinding(
|
Back: key.NewBinding(
|
||||||
key.WithKeys("b"),
|
key.WithKeys("b", "esc"),
|
||||||
key.WithHelp("b", "back"),
|
key.WithHelp("b/esc", "back"),
|
||||||
),
|
),
|
||||||
Year: key.NewBinding(
|
Year: key.NewBinding(
|
||||||
key.WithKeys("y"),
|
key.WithKeys("y"),
|
||||||
key.WithHelp("y", "switch year"),
|
key.WithHelp("y", "switch year"),
|
||||||
),
|
),
|
||||||
|
Retry: key.NewBinding(
|
||||||
|
key.WithKeys("r"),
|
||||||
|
key.WithHelp("r", "retry"),
|
||||||
|
),
|
||||||
|
GoTop: key.NewBinding(
|
||||||
|
key.WithKeys("g", "home"),
|
||||||
|
key.WithHelp("g", "go to top"),
|
||||||
|
),
|
||||||
|
GoBottom: key.NewBinding(
|
||||||
|
key.WithKeys("G", "end"),
|
||||||
|
key.WithHelp("G", "go to bottom"),
|
||||||
|
),
|
||||||
|
HalfUp: key.NewBinding(
|
||||||
|
key.WithKeys("ctrl+u"),
|
||||||
|
key.WithHelp("ctrl+u", "half page up"),
|
||||||
|
),
|
||||||
|
HalfDown: key.NewBinding(
|
||||||
|
key.WithKeys("ctrl+d"),
|
||||||
|
key.WithHelp("ctrl+d", "half page down"),
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
// StandingsKeyMap holds standing-specific keybindings.
|
// StandingsKeyMap holds standing-specific keybindings.
|
||||||
@@ -81,6 +116,8 @@ var StandingsKeys = StandingsKeyMap{
|
|||||||
type RaceDetailKeyMap struct {
|
type RaceDetailKeyMap struct {
|
||||||
ScrollUp key.Binding
|
ScrollUp key.Binding
|
||||||
ScrollDown key.Binding
|
ScrollDown key.Binding
|
||||||
|
PrevSession key.Binding
|
||||||
|
NextSession key.Binding
|
||||||
}
|
}
|
||||||
|
|
||||||
var RaceDetailKeys = RaceDetailKeyMap{
|
var RaceDetailKeys = RaceDetailKeyMap{
|
||||||
@@ -92,4 +129,12 @@ var RaceDetailKeys = RaceDetailKeyMap{
|
|||||||
key.WithKeys("J"),
|
key.WithKeys("J"),
|
||||||
key.WithHelp("J", "scroll race control down"),
|
key.WithHelp("J", "scroll race control down"),
|
||||||
),
|
),
|
||||||
|
PrevSession: key.NewBinding(
|
||||||
|
key.WithKeys("["),
|
||||||
|
key.WithHelp("[", "previous session"),
|
||||||
|
),
|
||||||
|
NextSession: key.NewBinding(
|
||||||
|
key.WithKeys("]"),
|
||||||
|
key.WithHelp("]", "next session"),
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,6 +80,24 @@ type driverPitsLoadedMsg struct {
|
|||||||
err error
|
err error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// driverPositionsLoadedMsg carries position history for a selected driver.
|
||||||
|
type driverPositionsLoadedMsg struct {
|
||||||
|
positions []models.Position
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
// driverTeamRadioLoadedMsg carries team radio messages for a selected driver.
|
||||||
|
type driverTeamRadioLoadedMsg struct {
|
||||||
|
radios []models.TeamRadio
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
// overtakesLoadedMsg carries overtake data for a session.
|
||||||
|
type overtakesLoadedMsg struct {
|
||||||
|
overtakes []models.Overtake
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
// meetingSelectedMsg is emitted when the user selects a meeting in the calendar.
|
// meetingSelectedMsg is emitted when the user selects a meeting in the calendar.
|
||||||
type meetingSelectedMsg struct {
|
type meetingSelectedMsg struct {
|
||||||
meeting models.Meeting
|
meeting models.Meeting
|
||||||
|
|||||||
@@ -22,9 +22,12 @@ type RaceDetailModel struct {
|
|||||||
drivers map[int]models.Driver
|
drivers map[int]models.Driver
|
||||||
rcMsgs []models.RaceControl
|
rcMsgs []models.RaceControl
|
||||||
weather []models.Weather
|
weather []models.Weather
|
||||||
|
overtakes []models.Overtake
|
||||||
|
|
||||||
selectedSession *models.Session
|
selectedSession *models.Session
|
||||||
sessionCursor int
|
sessionCursor int
|
||||||
|
resultsCursor int
|
||||||
|
resultsScroll int
|
||||||
|
|
||||||
loadingSessions bool
|
loadingSessions bool
|
||||||
loadingResults bool
|
loadingResults bool
|
||||||
@@ -43,6 +46,7 @@ func NewRaceDetailModel(client *api.OpenF1Client) RaceDetailModel {
|
|||||||
s := spinner.New()
|
s := spinner.New()
|
||||||
s.Spinner = spinner.MiniDot
|
s.Spinner = spinner.MiniDot
|
||||||
s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color(colorF1Red))
|
s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color(colorF1Red))
|
||||||
|
|
||||||
return RaceDetailModel{
|
return RaceDetailModel{
|
||||||
client: client,
|
client: client,
|
||||||
spinner: s,
|
spinner: s,
|
||||||
@@ -79,6 +83,10 @@ func fetchSessionData(client *api.OpenF1Client, sessionKey int) tea.Cmd {
|
|||||||
weather, err := client.GetWeather(sessionKey)
|
weather, err := client.GetWeather(sessionKey)
|
||||||
return weatherLoadedMsg{weather: weather, err: err}
|
return weatherLoadedMsg{weather: weather, err: err}
|
||||||
},
|
},
|
||||||
|
func() tea.Msg {
|
||||||
|
overtakes, err := client.GetOvertakesForSession(sessionKey)
|
||||||
|
return overtakesLoadedMsg{overtakes: overtakes, err: err}
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,6 +94,9 @@ func (m RaceDetailModel) Update(msg tea.Msg) (RaceDetailModel, tea.Cmd) {
|
|||||||
var cmds []tea.Cmd
|
var cmds []tea.Cmd
|
||||||
|
|
||||||
switch msg := msg.(type) {
|
switch msg := msg.(type) {
|
||||||
|
case tea.WindowSizeMsg:
|
||||||
|
// handled by SetSize from app.go
|
||||||
|
|
||||||
case spinner.TickMsg:
|
case spinner.TickMsg:
|
||||||
if m.loadingSessions || m.loadingResults {
|
if m.loadingSessions || m.loadingResults {
|
||||||
var cmd tea.Cmd
|
var cmd tea.Cmd
|
||||||
@@ -99,8 +110,11 @@ func (m RaceDetailModel) Update(msg tea.Msg) (RaceDetailModel, tea.Cmd) {
|
|||||||
m.results = nil
|
m.results = nil
|
||||||
m.rcMsgs = nil
|
m.rcMsgs = nil
|
||||||
m.weather = nil
|
m.weather = nil
|
||||||
|
m.overtakes = nil
|
||||||
m.selectedSession = nil
|
m.selectedSession = nil
|
||||||
m.sessionCursor = 0
|
m.sessionCursor = 0
|
||||||
|
m.resultsCursor = 0
|
||||||
|
m.resultsScroll = 0
|
||||||
m.loadingSessions = true
|
m.loadingSessions = true
|
||||||
m.loadingResults = false
|
m.loadingResults = false
|
||||||
m.errSessions = nil
|
m.errSessions = nil
|
||||||
@@ -115,13 +129,34 @@ func (m RaceDetailModel) Update(msg tea.Msg) (RaceDetailModel, tea.Cmd) {
|
|||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
m.sessions = msg.sessions
|
m.sessions = msg.sessions
|
||||||
// Auto-select Race session if available
|
// Auto-select the Race session and load its data
|
||||||
|
raceIdx := -1
|
||||||
for i, s := range m.sessions {
|
for i, s := range m.sessions {
|
||||||
if s.SessionName == "Race" {
|
if s.SessionName == "Race" {
|
||||||
m.sessionCursor = i
|
raceIdx = i
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if raceIdx >= 0 {
|
||||||
|
m.sessionCursor = raceIdx
|
||||||
|
sess := m.sessions[raceIdx]
|
||||||
|
m.selectedSession = &sess
|
||||||
|
m.loadingResults = true
|
||||||
|
m.results = nil
|
||||||
|
m.rcMsgs = nil
|
||||||
|
m.weather = nil
|
||||||
|
m.drivers = make(map[int]models.Driver)
|
||||||
|
cmds = append(cmds, m.spinner.Tick, fetchSessionData(m.client, sess.SessionKey))
|
||||||
|
} else if len(m.sessions) > 0 {
|
||||||
|
// Fallback: select last session
|
||||||
|
lastIdx := len(m.sessions) - 1
|
||||||
|
m.sessionCursor = lastIdx
|
||||||
|
sess := m.sessions[lastIdx]
|
||||||
|
m.selectedSession = &sess
|
||||||
|
m.loadingResults = true
|
||||||
|
m.drivers = make(map[int]models.Driver)
|
||||||
|
cmds = append(cmds, m.spinner.Tick, fetchSessionData(m.client, sess.SessionKey))
|
||||||
|
}
|
||||||
|
|
||||||
case sessionResultsLoadedMsg:
|
case sessionResultsLoadedMsg:
|
||||||
if msg.err != nil {
|
if msg.err != nil {
|
||||||
@@ -130,9 +165,8 @@ func (m RaceDetailModel) Update(msg tea.Msg) (RaceDetailModel, tea.Cmd) {
|
|||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
m.results = msg.results
|
m.results = msg.results
|
||||||
if !m.loadingResults {
|
m.resultsCursor = 0
|
||||||
// Both results and drivers may arrive in any order
|
m.resultsScroll = 0
|
||||||
}
|
|
||||||
m.checkResultsLoaded()
|
m.checkResultsLoaded()
|
||||||
|
|
||||||
case sessionDriversLoadedMsg:
|
case sessionDriversLoadedMsg:
|
||||||
@@ -154,15 +188,60 @@ func (m RaceDetailModel) Update(msg tea.Msg) (RaceDetailModel, tea.Cmd) {
|
|||||||
m.weather = msg.weather
|
m.weather = msg.weather
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case overtakesLoadedMsg:
|
||||||
|
if msg.err == nil {
|
||||||
|
m.overtakes = msg.overtakes
|
||||||
|
}
|
||||||
|
|
||||||
case tea.KeyMsg:
|
case tea.KeyMsg:
|
||||||
switch {
|
switch {
|
||||||
|
case matchKey(msg, GlobalKeys.Retry):
|
||||||
|
if m.errSessions != nil && m.meeting != nil {
|
||||||
|
m.errSessions = nil
|
||||||
|
m.loadingSessions = true
|
||||||
|
cmds = append(cmds, m.spinner.Tick, fetchSessions(m.client, int(m.meeting.MeetingKey)))
|
||||||
|
} else if m.errResults != nil && m.selectedSession != nil {
|
||||||
|
m.errResults = nil
|
||||||
|
m.loadingResults = true
|
||||||
|
cmds = append(cmds, m.spinner.Tick, fetchSessionData(m.client, m.selectedSession.SessionKey))
|
||||||
|
}
|
||||||
case matchKey(msg, GlobalKeys.Up):
|
case matchKey(msg, GlobalKeys.Up):
|
||||||
if m.sessionCursor > 0 {
|
if m.results != nil && m.resultsCursor > 0 {
|
||||||
m.sessionCursor--
|
m.resultsCursor--
|
||||||
|
m.ensureResultsVisible()
|
||||||
}
|
}
|
||||||
case matchKey(msg, GlobalKeys.Down):
|
case matchKey(msg, GlobalKeys.Down):
|
||||||
if m.sessionCursor < len(m.sessions)-1 {
|
if m.results != nil && m.resultsCursor < len(m.results)-1 {
|
||||||
m.sessionCursor++
|
m.resultsCursor++
|
||||||
|
m.ensureResultsVisible()
|
||||||
|
}
|
||||||
|
case matchKey(msg, GlobalKeys.GoTop):
|
||||||
|
if m.results != nil {
|
||||||
|
m.resultsCursor = 0
|
||||||
|
m.resultsScroll = 0
|
||||||
|
}
|
||||||
|
case matchKey(msg, GlobalKeys.GoBottom):
|
||||||
|
if m.results != nil && len(m.results) > 0 {
|
||||||
|
m.resultsCursor = len(m.results) - 1
|
||||||
|
m.ensureResultsVisible()
|
||||||
|
}
|
||||||
|
case matchKey(msg, GlobalKeys.HalfUp):
|
||||||
|
if m.results != nil {
|
||||||
|
half := m.resultsVisibleRows() / 2
|
||||||
|
m.resultsCursor -= half
|
||||||
|
if m.resultsCursor < 0 {
|
||||||
|
m.resultsCursor = 0
|
||||||
|
}
|
||||||
|
m.ensureResultsVisible()
|
||||||
|
}
|
||||||
|
case matchKey(msg, GlobalKeys.HalfDown):
|
||||||
|
if m.results != nil {
|
||||||
|
half := m.resultsVisibleRows() / 2
|
||||||
|
m.resultsCursor += half
|
||||||
|
if m.resultsCursor >= len(m.results) {
|
||||||
|
m.resultsCursor = len(m.results) - 1
|
||||||
|
}
|
||||||
|
m.ensureResultsVisible()
|
||||||
}
|
}
|
||||||
case matchKey(msg, GlobalKeys.Enter):
|
case matchKey(msg, GlobalKeys.Enter):
|
||||||
if len(m.sessions) > 0 && m.sessionCursor < len(m.sessions) {
|
if len(m.sessions) > 0 && m.sessionCursor < len(m.sessions) {
|
||||||
@@ -172,13 +251,24 @@ func (m RaceDetailModel) Update(msg tea.Msg) (RaceDetailModel, tea.Cmd) {
|
|||||||
m.results = nil
|
m.results = nil
|
||||||
m.rcMsgs = nil
|
m.rcMsgs = nil
|
||||||
m.weather = nil
|
m.weather = nil
|
||||||
|
m.overtakes = nil
|
||||||
|
m.resultsCursor = 0
|
||||||
|
m.resultsScroll = 0
|
||||||
m.drivers = make(map[int]models.Driver)
|
m.drivers = make(map[int]models.Driver)
|
||||||
cmds = append(cmds, fetchSessionData(m.client, sess.SessionKey))
|
cmds = append(cmds, m.spinner.Tick, fetchSessionData(m.client, sess.SessionKey))
|
||||||
}
|
}
|
||||||
case matchKey(msg, RaceDetailKeys.ScrollUp):
|
case matchKey(msg, RaceDetailKeys.ScrollUp):
|
||||||
m.rcView.LineUp(3)
|
m.rcView.LineUp(3)
|
||||||
case matchKey(msg, RaceDetailKeys.ScrollDown):
|
case matchKey(msg, RaceDetailKeys.ScrollDown):
|
||||||
m.rcView.LineDown(3)
|
m.rcView.LineDown(3)
|
||||||
|
case matchKey(msg, RaceDetailKeys.PrevSession):
|
||||||
|
if m.sessionCursor > 0 {
|
||||||
|
m.sessionCursor--
|
||||||
|
}
|
||||||
|
case matchKey(msg, RaceDetailKeys.NextSession):
|
||||||
|
if m.sessionCursor < len(m.sessions)-1 {
|
||||||
|
m.sessionCursor++
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,12 +282,29 @@ func (m RaceDetailModel) Update(msg tea.Msg) (RaceDetailModel, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *RaceDetailModel) checkResultsLoaded() {
|
func (m *RaceDetailModel) checkResultsLoaded() {
|
||||||
// Mark done once results arrive (drivers may still be loading but we show what we have)
|
|
||||||
if m.results != nil {
|
if m.results != nil {
|
||||||
m.loadingResults = false
|
m.loadingResults = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m RaceDetailModel) resultsVisibleRows() int {
|
||||||
|
rows := m.height - 18
|
||||||
|
if rows < 5 {
|
||||||
|
rows = 5
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *RaceDetailModel) ensureResultsVisible() {
|
||||||
|
visible := m.resultsVisibleRows()
|
||||||
|
if m.resultsCursor < m.resultsScroll {
|
||||||
|
m.resultsScroll = m.resultsCursor
|
||||||
|
}
|
||||||
|
if m.resultsCursor >= m.resultsScroll+visible {
|
||||||
|
m.resultsScroll = m.resultsCursor - visible + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (m *RaceDetailModel) updateRCViewport() {
|
func (m *RaceDetailModel) updateRCViewport() {
|
||||||
content := m.renderRaceControlContent()
|
content := m.renderRaceControlContent()
|
||||||
if m.rcReady {
|
if m.rcReady {
|
||||||
@@ -214,177 +321,307 @@ func (m *RaceDetailModel) initViewport(w, h int) {
|
|||||||
|
|
||||||
func (m RaceDetailModel) View() string {
|
func (m RaceDetailModel) View() string {
|
||||||
if m.meeting == nil {
|
if m.meeting == nil {
|
||||||
return styleMuted.Render("\n Select a race from the Calendar tab (press 2).\n\n" +
|
return styleMuted.Render("\n Select a race from the Calendar tab (press 2).\n\n") +
|
||||||
helpBar("2 calendar", "q quit"))
|
helpBar("2 calendar", "q quit")
|
||||||
|
}
|
||||||
|
|
||||||
|
w := m.width
|
||||||
|
if w < 40 {
|
||||||
|
w = 40
|
||||||
|
}
|
||||||
|
compact := w < 100
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
|
||||||
|
// Race title header
|
||||||
|
flag := countryFlag(m.meeting.CountryCode)
|
||||||
|
titleStyle := lipgloss.NewStyle().
|
||||||
|
Bold(true).
|
||||||
|
Foreground(lipgloss.Color(colorF1Red))
|
||||||
|
|
||||||
|
if compact {
|
||||||
|
sb.WriteString(titleStyle.Render(fmt.Sprintf(" %s %s", flag, m.meeting.MeetingName)) + "\n")
|
||||||
|
} else {
|
||||||
|
sb.WriteString(titleStyle.Render(fmt.Sprintf(" %s %s", flag, m.meeting.MeetingOfficialName)) + "\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Title
|
|
||||||
title := styleBold.Render(m.meeting.MeetingOfficialName)
|
|
||||||
dates := formatMeetingDates(*m.meeting)
|
dates := formatMeetingDates(*m.meeting)
|
||||||
subtitle := styleMuted.Render(fmt.Sprintf("%s · %s · %s", m.meeting.Location, m.meeting.CountryName, dates))
|
subtitle := fmt.Sprintf(" %s %s %s",
|
||||||
|
styleMuted.Render(m.meeting.Location),
|
||||||
|
styleMuted.Render("·"),
|
||||||
|
styleMuted.Render(dates))
|
||||||
|
sb.WriteString(subtitle + "\n\n")
|
||||||
|
|
||||||
header := lipgloss.JoinVertical(lipgloss.Left, title, subtitle) + "\n\n"
|
// Session selector pills
|
||||||
|
sb.WriteString(m.renderSessionPills())
|
||||||
|
sb.WriteString("\n\n")
|
||||||
|
|
||||||
|
if compact {
|
||||||
|
// Single column layout for narrow terminals
|
||||||
|
sb.WriteString(m.renderResults(w - 4))
|
||||||
|
sb.WriteString("\n")
|
||||||
|
sb.WriteString(m.renderWeatherCard(w - 4))
|
||||||
|
sb.WriteString("\n")
|
||||||
|
sb.WriteString(m.renderOvertakes(w - 4))
|
||||||
|
sb.WriteString("\n")
|
||||||
|
sb.WriteString(styleSectionTitle.Render("RACE CONTROL") + "\n")
|
||||||
|
if !m.rcReady || m.selectedSession == nil {
|
||||||
|
sb.WriteString(styleMuted.Render(" No session selected.\n"))
|
||||||
|
} else {
|
||||||
|
// Show limited RC messages inline
|
||||||
|
lines := strings.Split(m.renderRaceControlContent(), "\n")
|
||||||
|
maxRC := 8
|
||||||
|
if len(lines) > maxRC {
|
||||||
|
lines = lines[len(lines)-maxRC:]
|
||||||
|
}
|
||||||
|
sb.WriteString(strings.Join(lines, "\n") + "\n")
|
||||||
|
if len(m.rcMsgs) > maxRC {
|
||||||
|
sb.WriteString(styleMuted.Render(fmt.Sprintf(" ... %d more messages (K/J scroll)", len(m.rcMsgs)-maxRC)) + "\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
// Two-panel layout
|
// Two-panel layout
|
||||||
leftWidth := int(float64(m.width) * 0.55)
|
leftWidth := int(float64(w) * 0.55)
|
||||||
rightWidth := m.width - leftWidth - 4
|
rightWidth := w - leftWidth - 6
|
||||||
|
|
||||||
left := m.renderLeft(leftWidth)
|
left := m.renderResults(leftWidth)
|
||||||
right := m.renderRight(rightWidth)
|
right := m.renderRightPanel(rightWidth)
|
||||||
|
|
||||||
panels := lipgloss.JoinHorizontal(lipgloss.Top,
|
panels := lipgloss.JoinHorizontal(lipgloss.Top,
|
||||||
stylePanelBorder.Width(leftWidth).Render(left),
|
stylePanelBorder.Width(leftWidth).Render(left),
|
||||||
stylePanelBorder.Width(rightWidth).Render(right),
|
stylePanelBorder.Width(rightWidth).Render(right),
|
||||||
)
|
)
|
||||||
|
|
||||||
help := helpBar("j/k sessions", "enter load session", "K/J scroll RC", "b back to calendar", "q quit")
|
sb.WriteString(panels + "\n")
|
||||||
return header + panels + "\n" + help
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m RaceDetailModel) renderLeft(width int) string {
|
|
||||||
var sb strings.Builder
|
|
||||||
|
|
||||||
// Session list
|
|
||||||
sb.WriteString(styleHeader.Render("Sessions") + "\n")
|
|
||||||
if m.loadingSessions {
|
|
||||||
sb.WriteString(fmt.Sprintf(" %s Loading sessions…\n", m.spinner.View()))
|
|
||||||
} else if m.errSessions != nil {
|
|
||||||
sb.WriteString(styleError.Render(fmt.Sprintf(" Error: %v\n", m.errSessions)))
|
|
||||||
} else {
|
|
||||||
for i, sess := range m.sessions {
|
|
||||||
var start string
|
|
||||||
if len(sess.DateStart) >= 10 {
|
|
||||||
t, err := time.Parse(time.RFC3339, sess.DateStart)
|
|
||||||
if err == nil {
|
|
||||||
start = t.Format("Mon Jan 2")
|
|
||||||
} else {
|
|
||||||
start = sess.DateStart[:10]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
row := fmt.Sprintf(" %-12s %s", sess.SessionName, start)
|
|
||||||
if i == m.sessionCursor {
|
|
||||||
row = styleSelected.Render(row)
|
|
||||||
} else if m.selectedSession != nil && m.selectedSession.SessionKey == sess.SessionKey {
|
|
||||||
row = styleDeltaUp.Render(row)
|
|
||||||
}
|
|
||||||
sb.WriteString(row + "\n")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
sb.WriteString("\n" + styleHeader.Render("Results") + "\n")
|
|
||||||
|
|
||||||
if m.loadingResults {
|
|
||||||
sb.WriteString(fmt.Sprintf(" %s Loading results…\n", m.spinner.View()))
|
|
||||||
} else if m.errResults != nil {
|
|
||||||
sb.WriteString(styleError.Render(fmt.Sprintf(" Error: %v\n", m.errResults)))
|
|
||||||
} else if m.selectedSession == nil {
|
|
||||||
sb.WriteString(styleMuted.Render(" Press Enter to load session results.\n"))
|
|
||||||
} else if len(m.results) == 0 {
|
|
||||||
sb.WriteString(styleMuted.Render(" No results available.\n"))
|
|
||||||
} else {
|
|
||||||
sb.WriteString(m.renderResults(width - 4))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sb.WriteString(helpBar("[/] sessions", "enter load", "j/k results", "g/G top/bottom", "K/J scroll RC", "b back", "q quit"))
|
||||||
return sb.String()
|
return sb.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m RaceDetailModel) renderResults(width int) string {
|
func (m RaceDetailModel) renderSessionPills() string {
|
||||||
isRace := m.selectedSession != nil && m.selectedSession.SessionType == "Race"
|
if m.loadingSessions {
|
||||||
|
return fmt.Sprintf(" %s Loading sessions...", m.spinner.View())
|
||||||
const (
|
}
|
||||||
wPos = 3
|
if m.errSessions != nil {
|
||||||
wDRV = 4
|
return styleError.Render(fmt.Sprintf(" Error: %v", m.errSessions))
|
||||||
wTeam = 16
|
|
||||||
wLaps = 4
|
|
||||||
wGap = 12
|
|
||||||
wPts = 4
|
|
||||||
)
|
|
||||||
|
|
||||||
var header string
|
|
||||||
if isRace {
|
|
||||||
header = styleBold.Render(
|
|
||||||
padLeft("Pos", wPos) + " " +
|
|
||||||
padRight("DRV", wDRV) + " " +
|
|
||||||
padRight("Team", wTeam) + " " +
|
|
||||||
padLeft("Laps", wLaps) + " " +
|
|
||||||
padLeft("Gap", wGap) + " " +
|
|
||||||
padLeft("Pts", wPts),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
header = styleBold.Render(
|
|
||||||
padLeft("Pos", wPos) + " " +
|
|
||||||
padRight("DRV", wDRV) + " " +
|
|
||||||
padRight("Team", wTeam) + " " +
|
|
||||||
padLeft("Time", wGap),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var rows []string
|
var pills []string
|
||||||
rows = append(rows, header)
|
for i, sess := range m.sessions {
|
||||||
|
// Format date
|
||||||
|
var dateStr string
|
||||||
|
if len(sess.DateStart) >= 10 {
|
||||||
|
t, err := time.Parse(time.RFC3339, sess.DateStart)
|
||||||
|
if err == nil {
|
||||||
|
dateStr = t.Format("Mon 2")
|
||||||
|
} else {
|
||||||
|
dateStr = sess.DateStart[:10]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for _, r := range m.results {
|
label := fmt.Sprintf("%s %s", sess.SessionName, dateStr)
|
||||||
|
|
||||||
|
if m.selectedSession != nil && m.selectedSession.SessionKey == sess.SessionKey {
|
||||||
|
pills = append(pills, styleSessionActive.Render(label))
|
||||||
|
} else if i == m.sessionCursor {
|
||||||
|
pills = append(pills, styleSessionCursor.Render(label))
|
||||||
|
} else {
|
||||||
|
pills = append(pills, styleSessionInactive.Render(label))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return " " + strings.Join(pills, " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m RaceDetailModel) renderResults(width int) string {
|
||||||
|
var sb strings.Builder
|
||||||
|
|
||||||
|
sb.WriteString(styleSectionTitle.Render("RESULTS") + "\n")
|
||||||
|
|
||||||
|
if m.loadingResults {
|
||||||
|
sb.WriteString(fmt.Sprintf(" %s Loading results...\n", m.spinner.View()))
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
if m.errResults != nil {
|
||||||
|
sb.WriteString(styleError.Render(fmt.Sprintf(" Error: %v\n", m.errResults)))
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
if m.selectedSession == nil {
|
||||||
|
sb.WriteString(styleMuted.Render(" Press Enter to load session results.\n"))
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
if len(m.results) == 0 {
|
||||||
|
sb.WriteString(styleMuted.Render(" No results available.\n"))
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
isRace := m.selectedSession.SessionType == "Race"
|
||||||
|
compact := width < 55
|
||||||
|
|
||||||
|
// Responsive team name width
|
||||||
|
teamWidth := 16
|
||||||
|
if width >= 70 {
|
||||||
|
teamWidth = 20
|
||||||
|
} else if compact {
|
||||||
|
teamWidth = 10
|
||||||
|
}
|
||||||
|
|
||||||
|
// Column header
|
||||||
|
var header string
|
||||||
|
if isRace {
|
||||||
|
if compact {
|
||||||
|
header = fmt.Sprintf(" %s %s %s %s %s",
|
||||||
|
padRight("P", 3),
|
||||||
|
padRight("", 1),
|
||||||
|
padRight("DRV", 4),
|
||||||
|
padLeft("GAP", 10),
|
||||||
|
padLeft("PTS", 4),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
header = fmt.Sprintf(" %s %s %s %s %s %s %s",
|
||||||
|
padRight("P", 3),
|
||||||
|
padRight("", 1),
|
||||||
|
padRight("DRV", 4),
|
||||||
|
padRight("TEAM", teamWidth),
|
||||||
|
padLeft("LAPS", 4),
|
||||||
|
padLeft("GAP", 12),
|
||||||
|
padLeft("PTS", 4),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if compact {
|
||||||
|
header = fmt.Sprintf(" %s %s %s %s",
|
||||||
|
padRight("P", 3),
|
||||||
|
padRight("", 1),
|
||||||
|
padRight("DRV", 4),
|
||||||
|
padLeft("TIME", 12),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
header = fmt.Sprintf(" %s %s %s %s %s",
|
||||||
|
padRight("P", 3),
|
||||||
|
padRight("", 1),
|
||||||
|
padRight("DRV", 4),
|
||||||
|
padRight("TEAM", teamWidth),
|
||||||
|
padLeft("TIME", 12),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sb.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color(colorMuted)).Bold(true).Render(header) + "\n")
|
||||||
|
sb.WriteString(" " + divider(min(width-4, lipgloss.Width(header))) + "\n")
|
||||||
|
|
||||||
|
visible := m.resultsVisibleRows()
|
||||||
|
endIdx := m.resultsScroll + visible
|
||||||
|
if endIdx > len(m.results) {
|
||||||
|
endIdx = len(m.results)
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := m.resultsScroll; i < endIdx; i++ {
|
||||||
|
r := m.results[i]
|
||||||
d := m.drivers[r.DriverNumber]
|
d := m.drivers[r.DriverNumber]
|
||||||
acronym := d.NameAcronym
|
acronym := d.NameAcronym
|
||||||
if acronym == "" {
|
if acronym == "" {
|
||||||
acronym = fmt.Sprintf("#%d", r.DriverNumber)
|
acronym = fmt.Sprintf("#%d", r.DriverNumber)
|
||||||
}
|
}
|
||||||
teamName := d.TeamName
|
|
||||||
teamColor := d.TeamColour
|
|
||||||
|
|
||||||
teamStr := hexToStyle(teamColor).Render(padRight(truncate(teamName, wTeam), wTeam))
|
teamColor := colorMuted
|
||||||
|
if d.TeamColour != "" {
|
||||||
|
teamColor = "#" + d.TeamColour
|
||||||
|
} else if d.TeamName != "" {
|
||||||
|
teamColor = teamColorFromName(d.TeamName)
|
||||||
|
}
|
||||||
|
|
||||||
|
colorBar := lipgloss.NewStyle().Foreground(lipgloss.Color(teamColor)).Render("┃")
|
||||||
|
|
||||||
|
// Position with status
|
||||||
|
var pos string
|
||||||
|
if r.DNF {
|
||||||
|
pos = styleDNF.Render("DNF")
|
||||||
|
} else if r.DNS {
|
||||||
|
pos = styleDNF.Render("DNS")
|
||||||
|
} else if r.DSQ {
|
||||||
|
pos = styleDNF.Render("DSQ")
|
||||||
|
} else {
|
||||||
|
pos = renderPosition(r.Position)
|
||||||
|
}
|
||||||
|
|
||||||
var row string
|
var row string
|
||||||
pos := fmt.Sprintf("%d", r.Position)
|
if isRace {
|
||||||
if r.DNF {
|
gap := formatGap(r.GapToLeader)
|
||||||
pos = "DNF"
|
var gapStyled string
|
||||||
} else if r.DNS {
|
if gap == "LEADER" {
|
||||||
pos = "DNS"
|
gapStyled = styleLeader.Render("LEADER")
|
||||||
} else if r.DSQ {
|
} else {
|
||||||
pos = "DSQ"
|
gapStyled = styleGap.Render(gap)
|
||||||
}
|
}
|
||||||
|
|
||||||
if isRace {
|
if compact {
|
||||||
row = fmt.Sprintf("%s %s %s %s %s %s",
|
row = fmt.Sprintf(" %s %s %s %s %s",
|
||||||
padLeft(pos, wPos),
|
padRightVisible(pos, 3),
|
||||||
padRight(acronym, wDRV),
|
colorBar,
|
||||||
teamStr,
|
padRight(acronym, 4),
|
||||||
padLeft(fmt.Sprintf("%d", r.NumberOfLaps), wLaps),
|
padLeftVisible(gapStyled, 10),
|
||||||
padLeft(formatGap(r.GapToLeader), wGap),
|
padLeft(fmt.Sprintf("%.0f", r.Points), 4),
|
||||||
padLeft(fmt.Sprintf("%.0f", r.Points), wPts),
|
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
row = fmt.Sprintf("%s %s %s %s",
|
row = fmt.Sprintf(" %s %s %s %s %s %s %s",
|
||||||
padLeft(pos, wPos),
|
padRightVisible(pos, 3),
|
||||||
padRight(acronym, wDRV),
|
colorBar,
|
||||||
teamStr,
|
padRight(acronym, 4),
|
||||||
padLeft(formatDuration(r.Duration), wGap),
|
padRight(truncate(d.TeamName, teamWidth), teamWidth),
|
||||||
|
padLeft(fmt.Sprintf("%d", r.NumberOfLaps), 4),
|
||||||
|
padLeftVisible(gapStyled, 12),
|
||||||
|
padLeft(fmt.Sprintf("%.0f", r.Points), 4),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
if r.DNF || r.DNS || r.DSQ {
|
dur := formatDuration(r.Duration)
|
||||||
row = styleMuted.Render(row)
|
if compact {
|
||||||
|
row = fmt.Sprintf(" %s %s %s %s",
|
||||||
|
padRightVisible(pos, 3),
|
||||||
|
colorBar,
|
||||||
|
padRight(acronym, 4),
|
||||||
|
padLeft(dur, 12),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
row = fmt.Sprintf(" %s %s %s %s %s",
|
||||||
|
padRightVisible(pos, 3),
|
||||||
|
colorBar,
|
||||||
|
padRight(acronym, 4),
|
||||||
|
padRight(truncate(d.TeamName, teamWidth), teamWidth),
|
||||||
|
padLeft(dur, 12),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
rows = append(rows, row)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return strings.Join(rows, "\n")
|
if i == m.resultsCursor {
|
||||||
|
row = styleSelected.Render(row)
|
||||||
|
}
|
||||||
|
sb.WriteString(row + "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m RaceDetailModel) renderRight(width int) string {
|
func (m RaceDetailModel) renderRightPanel(width int) string {
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
|
|
||||||
|
// Weather card at the top
|
||||||
|
sb.WriteString(m.renderWeatherCard(width))
|
||||||
|
sb.WriteString("\n")
|
||||||
|
|
||||||
|
// Overtakes summary
|
||||||
|
sb.WriteString(m.renderOvertakes(width))
|
||||||
|
sb.WriteString("\n")
|
||||||
|
|
||||||
// Race control
|
// Race control
|
||||||
sb.WriteString(styleHeader.Render("Race Control") + "\n")
|
sb.WriteString(styleSectionTitle.Render("RACE CONTROL") + "\n")
|
||||||
if !m.rcReady || m.selectedSession == nil {
|
if !m.rcReady || m.selectedSession == nil {
|
||||||
sb.WriteString(styleMuted.Render(" No session selected.\n"))
|
sb.WriteString(styleMuted.Render(" No session selected.\n"))
|
||||||
} else {
|
} else {
|
||||||
sb.WriteString(m.rcView.View() + "\n")
|
sb.WriteString(m.rcView.View() + "\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Weather strip
|
|
||||||
sb.WriteString("\n" + styleHeader.Render("Weather") + "\n")
|
|
||||||
sb.WriteString(m.renderWeather(width))
|
|
||||||
|
|
||||||
return sb.String()
|
return sb.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -405,6 +642,15 @@ func (m RaceDetailModel) renderRaceControlContent() string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Category-specific icon and styling
|
||||||
|
var prefix string
|
||||||
|
switch rc.Category {
|
||||||
|
case models.CategorySafetyCar:
|
||||||
|
prefix = styleSafetyCar.Render(fmt.Sprintf(" ⚠ [%s]", t))
|
||||||
|
case models.CategoryDRS:
|
||||||
|
prefix = styleDRS.Render(fmt.Sprintf(" ▸ [%s]", t))
|
||||||
|
default:
|
||||||
|
// Flag-based coloring
|
||||||
var flagStyle lipgloss.Style
|
var flagStyle lipgloss.Style
|
||||||
switch rc.Flag {
|
switch rc.Flag {
|
||||||
case models.FlagGreen:
|
case models.FlagGreen:
|
||||||
@@ -415,42 +661,88 @@ func (m RaceDetailModel) renderRaceControlContent() string {
|
|||||||
flagStyle = styleFlagRed
|
flagStyle = styleFlagRed
|
||||||
case models.FlagBlue:
|
case models.FlagBlue:
|
||||||
flagStyle = styleFlagBlue
|
flagStyle = styleFlagBlue
|
||||||
|
case models.FlagChequered:
|
||||||
|
flagStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(colorWhite)).Bold(true)
|
||||||
default:
|
default:
|
||||||
flagStyle = styleMuted
|
flagStyle = styleMuted
|
||||||
}
|
}
|
||||||
|
|
||||||
prefix := flagStyle.Render(fmt.Sprintf("[%s]", t))
|
icon := " "
|
||||||
|
switch rc.Flag {
|
||||||
|
case models.FlagGreen:
|
||||||
|
icon = "🟢"
|
||||||
|
case models.FlagYellow:
|
||||||
|
icon = "🟡"
|
||||||
|
case models.FlagDoubleYellow:
|
||||||
|
icon = "🟡"
|
||||||
|
case models.FlagRed:
|
||||||
|
icon = "🔴"
|
||||||
|
case models.FlagBlue:
|
||||||
|
icon = "🔵"
|
||||||
|
case models.FlagChequered:
|
||||||
|
icon = "🏁"
|
||||||
|
default:
|
||||||
|
icon = " "
|
||||||
|
}
|
||||||
|
prefix = flagStyle.Render(fmt.Sprintf(" %s [%s]", icon, t))
|
||||||
|
}
|
||||||
|
|
||||||
lines = append(lines, fmt.Sprintf("%s %s", prefix, rc.Message))
|
lines = append(lines, fmt.Sprintf("%s %s", prefix, rc.Message))
|
||||||
}
|
}
|
||||||
|
|
||||||
return strings.Join(lines, "\n")
|
return strings.Join(lines, "\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m RaceDetailModel) renderWeather(width int) string {
|
func (m RaceDetailModel) renderWeatherCard(width int) string {
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString(styleSectionTitle.Render("WEATHER") + "\n")
|
||||||
|
|
||||||
if len(m.weather) == 0 {
|
if len(m.weather) == 0 {
|
||||||
return styleMuted.Render(" No weather data.")
|
sb.WriteString(styleMuted.Render(" No weather data.\n"))
|
||||||
|
return sb.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use the latest weather snapshot
|
|
||||||
w := m.weather[len(m.weather)-1]
|
w := m.weather[len(m.weather)-1]
|
||||||
|
|
||||||
rain := "Dry"
|
// Weather conditions
|
||||||
|
var condStr string
|
||||||
if w.Rainfall > 0 {
|
if w.Rainfall > 0 {
|
||||||
rain = styleFlagBlue.Render("Rain")
|
condStr = styleRain.Render("🌧 Rain")
|
||||||
|
} else {
|
||||||
|
condStr = styleDry.Render("☀ Dry")
|
||||||
}
|
}
|
||||||
|
|
||||||
return fmt.Sprintf(" Air: %.1f°C Track: %.1f°C %s Humidity: %.0f%% Wind: %s %.1fm/s",
|
sb.WriteString(fmt.Sprintf(" %s %s %s %s %s %s %s %s %s%.1fm/s\n",
|
||||||
w.AirTemperature, w.TrackTemperature, rain,
|
condStr,
|
||||||
w.Humidity, windArrow(w.WindDirection), w.WindSpeed)
|
styleWeatherLabel.Render("Air:"),
|
||||||
|
styleWeatherValue.Render(fmt.Sprintf("%.1f°C", w.AirTemperature)),
|
||||||
|
styleWeatherLabel.Render("Track:"),
|
||||||
|
styleWeatherValue.Render(fmt.Sprintf("%.1f°C", w.TrackTemperature)),
|
||||||
|
styleWeatherLabel.Render("Humidity:"),
|
||||||
|
styleWeatherValue.Render(fmt.Sprintf("%.0f%%", w.Humidity)),
|
||||||
|
styleWeatherLabel.Render("Wind:"),
|
||||||
|
styleWeatherValue.Render(windArrow(w.WindDirection)+" "),
|
||||||
|
w.WindSpeed,
|
||||||
|
))
|
||||||
|
|
||||||
|
return sb.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetSize updates the model's dimensions and initialises the race control viewport.
|
|
||||||
func (m *RaceDetailModel) SetSize(w, h int) {
|
func (m *RaceDetailModel) SetSize(w, h int) {
|
||||||
m.width = w
|
m.width = w
|
||||||
m.height = h
|
m.height = h
|
||||||
// Right panel, minus header/weather/borders
|
if w < 40 {
|
||||||
rightWidth := int(float64(w)*0.45) - 6
|
w = 40
|
||||||
rcHeight := h - 12 // approximate: title + sessions + header + weather + help
|
}
|
||||||
|
compact := w < 100
|
||||||
|
var rightWidth, rcHeight int
|
||||||
|
if compact {
|
||||||
|
rightWidth = w - 6
|
||||||
|
rcHeight = 8
|
||||||
|
} else {
|
||||||
|
rightWidth = int(float64(w)*0.45) - 8
|
||||||
|
rcHeight = h - 14
|
||||||
|
}
|
||||||
if rcHeight < 3 {
|
if rcHeight < 3 {
|
||||||
rcHeight = 3
|
rcHeight = 3
|
||||||
}
|
}
|
||||||
@@ -461,3 +753,98 @@ func (m *RaceDetailModel) SetSize(w, h int) {
|
|||||||
m.rcView.Height = rcHeight
|
m.rcView.Height = rcHeight
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// renderOvertakes renders the overtakes summary section.
|
||||||
|
func (m RaceDetailModel) renderOvertakes(width int) string {
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString(styleSectionTitle.Render("OVERTAKES") + "\n")
|
||||||
|
|
||||||
|
if len(m.overtakes) == 0 {
|
||||||
|
sb.WriteString(styleMuted.Render(" No overtake data.\n"))
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Total count
|
||||||
|
sb.WriteString(fmt.Sprintf(" %s %s\n",
|
||||||
|
styleWeatherLabel.Render("Total:"),
|
||||||
|
lipgloss.NewStyle().Foreground(lipgloss.Color(colorYellow)).Bold(true).Render(fmt.Sprintf("%d", len(m.overtakes))),
|
||||||
|
))
|
||||||
|
|
||||||
|
// Tally: which drivers overtook the most?
|
||||||
|
type driverOvertakes struct {
|
||||||
|
driverNum int
|
||||||
|
overtaking int // times this driver overtook someone
|
||||||
|
overtaken int // times this driver was overtaken
|
||||||
|
}
|
||||||
|
stats := make(map[int]*driverOvertakes)
|
||||||
|
for _, o := range m.overtakes {
|
||||||
|
if _, ok := stats[o.OvertakingDriverNumber]; !ok {
|
||||||
|
stats[o.OvertakingDriverNumber] = &driverOvertakes{driverNum: o.OvertakingDriverNumber}
|
||||||
|
}
|
||||||
|
stats[o.OvertakingDriverNumber].overtaking++
|
||||||
|
|
||||||
|
if _, ok := stats[o.OvertakenDriverNumber]; !ok {
|
||||||
|
stats[o.OvertakenDriverNumber] = &driverOvertakes{driverNum: o.OvertakenDriverNumber}
|
||||||
|
}
|
||||||
|
stats[o.OvertakenDriverNumber].overtaken++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find top overtakers (sorted by most overtakes made)
|
||||||
|
type rankedDriver struct {
|
||||||
|
driverNum int
|
||||||
|
overtaking int
|
||||||
|
overtaken int
|
||||||
|
}
|
||||||
|
var ranked []rankedDriver
|
||||||
|
for _, s := range stats {
|
||||||
|
ranked = append(ranked, rankedDriver{driverNum: s.driverNum, overtaking: s.overtaking, overtaken: s.overtaken})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by overtaking count descending
|
||||||
|
for i := 0; i < len(ranked); i++ {
|
||||||
|
for j := i + 1; j < len(ranked); j++ {
|
||||||
|
if ranked[j].overtaking > ranked[i].overtaking {
|
||||||
|
ranked[i], ranked[j] = ranked[j], ranked[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show top 5 overtakers
|
||||||
|
maxShow := 5
|
||||||
|
if len(ranked) < maxShow {
|
||||||
|
maxShow = len(ranked)
|
||||||
|
}
|
||||||
|
for i := 0; i < maxShow; i++ {
|
||||||
|
r := ranked[i]
|
||||||
|
d := m.drivers[r.driverNum]
|
||||||
|
acronym := d.NameAcronym
|
||||||
|
if acronym == "" {
|
||||||
|
acronym = fmt.Sprintf("#%d", r.driverNum)
|
||||||
|
}
|
||||||
|
|
||||||
|
teamColor := colorMuted
|
||||||
|
if d.TeamColour != "" {
|
||||||
|
teamColor = "#" + d.TeamColour
|
||||||
|
} else if d.TeamName != "" {
|
||||||
|
teamColor = teamColorFromName(d.TeamName)
|
||||||
|
}
|
||||||
|
|
||||||
|
colorBar := lipgloss.NewStyle().Foreground(lipgloss.Color(teamColor)).Render("┃")
|
||||||
|
|
||||||
|
overtakingStr := lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color(colorGreen)).Bold(true).
|
||||||
|
Render(fmt.Sprintf("+%d", r.overtaking))
|
||||||
|
overtakenStr := lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color(colorF1Red)).
|
||||||
|
Render(fmt.Sprintf("-%d", r.overtaken))
|
||||||
|
|
||||||
|
sb.WriteString(fmt.Sprintf(" %s %s %s %s\n",
|
||||||
|
colorBar,
|
||||||
|
padRight(acronym, 4),
|
||||||
|
overtakingStr,
|
||||||
|
overtakenStr,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|||||||
@@ -23,15 +23,17 @@ type StandingsModel struct {
|
|||||||
|
|
||||||
driverStandings []models.ChampionshipDriver
|
driverStandings []models.ChampionshipDriver
|
||||||
teamStandings []models.ChampionshipTeam
|
teamStandings []models.ChampionshipTeam
|
||||||
drivers map[int]models.Driver // driver_number → Driver
|
drivers map[int]models.Driver // driver_number -> Driver
|
||||||
|
|
||||||
view standingsView
|
view standingsView
|
||||||
loading bool
|
loading bool
|
||||||
err error
|
err error
|
||||||
spinner spinner.Model
|
spinner spinner.Model
|
||||||
|
|
||||||
year int
|
|
||||||
cursor int
|
cursor int
|
||||||
|
scroll int
|
||||||
|
|
||||||
|
year int
|
||||||
width int
|
width int
|
||||||
height int
|
height int
|
||||||
}
|
}
|
||||||
@@ -40,6 +42,7 @@ func NewStandingsModel(client *api.OpenF1Client, year int) StandingsModel {
|
|||||||
s := spinner.New()
|
s := spinner.New()
|
||||||
s.Spinner = spinner.MiniDot
|
s.Spinner = spinner.MiniDot
|
||||||
s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color(colorF1Red))
|
s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color(colorF1Red))
|
||||||
|
|
||||||
return StandingsModel{
|
return StandingsModel{
|
||||||
client: client,
|
client: client,
|
||||||
view: standingsViewDriver,
|
view: standingsViewDriver,
|
||||||
@@ -80,7 +83,13 @@ func fetchStandingsDrivers(client *api.OpenF1Client, sessionKey int) tea.Cmd {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m StandingsModel) Update(msg tea.Msg) (StandingsModel, tea.Cmd) {
|
func (m StandingsModel) Update(msg tea.Msg) (StandingsModel, tea.Cmd) {
|
||||||
|
var cmds []tea.Cmd
|
||||||
|
|
||||||
switch msg := msg.(type) {
|
switch msg := msg.(type) {
|
||||||
|
case tea.WindowSizeMsg:
|
||||||
|
m.width = msg.Width
|
||||||
|
m.height = msg.Height
|
||||||
|
|
||||||
case spinner.TickMsg:
|
case spinner.TickMsg:
|
||||||
if m.loading {
|
if m.loading {
|
||||||
var cmd tea.Cmd
|
var cmd tea.Cmd
|
||||||
@@ -95,7 +104,6 @@ func (m StandingsModel) Update(msg tea.Msg) (StandingsModel, tea.Cmd) {
|
|||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
m.driverStandings = msg.standings
|
m.driverStandings = msg.standings
|
||||||
// Phase 2: fetch drivers to join names
|
|
||||||
if len(msg.standings) > 0 {
|
if len(msg.standings) > 0 {
|
||||||
return m, fetchStandingsDrivers(m.client, msg.standings[0].SessionKey)
|
return m, fetchStandingsDrivers(m.client, msg.standings[0].SessionKey)
|
||||||
}
|
}
|
||||||
@@ -121,47 +129,116 @@ func (m StandingsModel) Update(msg tea.Msg) (StandingsModel, tea.Cmd) {
|
|||||||
|
|
||||||
case tea.KeyMsg:
|
case tea.KeyMsg:
|
||||||
switch {
|
switch {
|
||||||
|
case matchKey(msg, GlobalKeys.Retry):
|
||||||
|
if m.err != nil {
|
||||||
|
m.err = nil
|
||||||
|
m.loading = true
|
||||||
|
return m, m.Init()
|
||||||
|
}
|
||||||
case matchKey(msg, StandingsKeys.DriverView):
|
case matchKey(msg, StandingsKeys.DriverView):
|
||||||
m.view = standingsViewDriver
|
m.view = standingsViewDriver
|
||||||
m.cursor = 0
|
m.cursor = 0
|
||||||
|
m.scroll = 0
|
||||||
case matchKey(msg, StandingsKeys.ConstructorView):
|
case matchKey(msg, StandingsKeys.ConstructorView):
|
||||||
m.view = standingsViewConstructor
|
m.view = standingsViewConstructor
|
||||||
m.cursor = 0
|
m.cursor = 0
|
||||||
|
m.scroll = 0
|
||||||
case matchKey(msg, GlobalKeys.Up):
|
case matchKey(msg, GlobalKeys.Up):
|
||||||
if m.cursor > 0 {
|
if m.cursor > 0 {
|
||||||
m.cursor--
|
m.cursor--
|
||||||
|
if m.cursor < m.scroll {
|
||||||
|
m.scroll = m.cursor
|
||||||
|
}
|
||||||
}
|
}
|
||||||
case matchKey(msg, GlobalKeys.Down):
|
case matchKey(msg, GlobalKeys.Down):
|
||||||
|
maxIdx := m.itemCount() - 1
|
||||||
|
if m.cursor < maxIdx {
|
||||||
m.cursor++
|
m.cursor++
|
||||||
|
visibleRows := m.visibleRows()
|
||||||
|
if m.cursor >= m.scroll+visibleRows {
|
||||||
|
m.scroll = m.cursor - visibleRows + 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return m, nil
|
case matchKey(msg, GlobalKeys.GoTop):
|
||||||
|
m.cursor = 0
|
||||||
|
m.scroll = 0
|
||||||
|
case matchKey(msg, GlobalKeys.GoBottom):
|
||||||
|
maxIdx := m.itemCount() - 1
|
||||||
|
if maxIdx >= 0 {
|
||||||
|
m.cursor = maxIdx
|
||||||
|
visibleRows := m.visibleRows()
|
||||||
|
if m.cursor >= visibleRows {
|
||||||
|
m.scroll = m.cursor - visibleRows + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case matchKey(msg, GlobalKeys.HalfUp):
|
||||||
|
half := m.visibleRows() / 2
|
||||||
|
m.cursor -= half
|
||||||
|
if m.cursor < 0 {
|
||||||
|
m.cursor = 0
|
||||||
|
}
|
||||||
|
if m.cursor < m.scroll {
|
||||||
|
m.scroll = m.cursor
|
||||||
|
}
|
||||||
|
case matchKey(msg, GlobalKeys.HalfDown):
|
||||||
|
half := m.visibleRows() / 2
|
||||||
|
maxIdx := m.itemCount() - 1
|
||||||
|
m.cursor += half
|
||||||
|
if m.cursor > maxIdx {
|
||||||
|
m.cursor = maxIdx
|
||||||
|
}
|
||||||
|
visibleRows := m.visibleRows()
|
||||||
|
if m.cursor >= m.scroll+visibleRows {
|
||||||
|
m.scroll = m.cursor - visibleRows + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m, tea.Batch(cmds...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m StandingsModel) itemCount() int {
|
||||||
|
if m.view == standingsViewDriver {
|
||||||
|
return len(m.driverStandings)
|
||||||
|
}
|
||||||
|
return len(m.teamStandings)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m StandingsModel) visibleRows() int {
|
||||||
|
rows := m.height - 12 // header + toggle + help + padding
|
||||||
|
if rows < 5 {
|
||||||
|
rows = 5
|
||||||
|
}
|
||||||
|
return rows
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m StandingsModel) View() string {
|
func (m StandingsModel) View() string {
|
||||||
if m.loading {
|
if m.loading {
|
||||||
return fmt.Sprintf("\n %s Loading %d championship standings…", m.spinner.View(), m.year)
|
return fmt.Sprintf("\n %s Loading %d championship standings...", m.spinner.View(), m.year)
|
||||||
}
|
}
|
||||||
if m.err != nil {
|
if m.err != nil {
|
||||||
return styleError.Render(fmt.Sprintf("\n Error: %v", m.err))
|
return styleError.Render(fmt.Sprintf("\n Error: %v\n\n", m.err)) +
|
||||||
|
helpBar("r retry", "q quit")
|
||||||
}
|
}
|
||||||
|
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
|
|
||||||
// Year indicator
|
// Title row with year and toggle
|
||||||
sb.WriteString(styleBold.Render(fmt.Sprintf(" Season: %d", m.year)) + "\n\n")
|
title := lipgloss.NewStyle().
|
||||||
|
Bold(true).
|
||||||
|
Foreground(lipgloss.Color(colorF1Red)).
|
||||||
|
Render(fmt.Sprintf(" FORMULA 1 %d CHAMPIONSHIP", m.year))
|
||||||
|
|
||||||
|
sb.WriteString(title + "\n\n")
|
||||||
|
|
||||||
// Toggle bar
|
// Toggle bar
|
||||||
dStyle, cStyle := styleInactiveTab, styleInactiveTab
|
dLabel, cLabel := " d Drivers ", " c Constructors "
|
||||||
if m.view == standingsViewDriver {
|
if m.view == standingsViewDriver {
|
||||||
dStyle = styleActiveTab
|
sb.WriteString(styleActiveTab.Render(dLabel))
|
||||||
|
sb.WriteString(styleInactiveTab.Render(cLabel))
|
||||||
} else {
|
} else {
|
||||||
cStyle = styleActiveTab
|
sb.WriteString(styleInactiveTab.Render(dLabel))
|
||||||
|
sb.WriteString(styleActiveTab.Render(cLabel))
|
||||||
}
|
}
|
||||||
sb.WriteString(lipgloss.JoinHorizontal(lipgloss.Top,
|
|
||||||
dStyle.Render("d Drivers"),
|
|
||||||
cStyle.Render("c Constructors"),
|
|
||||||
))
|
|
||||||
sb.WriteString("\n\n")
|
sb.WriteString("\n\n")
|
||||||
|
|
||||||
if m.view == standingsViewDriver {
|
if m.view == standingsViewDriver {
|
||||||
@@ -171,122 +248,206 @@ func (m StandingsModel) View() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
sb.WriteString("\n")
|
sb.WriteString("\n")
|
||||||
sb.WriteString(helpBar("y season", "d drivers", "c constructors", "j/k navigate", "q quit"))
|
sb.WriteString(helpBar("y season", "d drivers", "c constructors", "j/k navigate", "g/G top/bottom", "^d/^u page", "q quit"))
|
||||||
return sb.String()
|
return sb.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m StandingsModel) renderDriverStandings() string {
|
func (m StandingsModel) renderDriverStandings() string {
|
||||||
if len(m.driverStandings) == 0 {
|
if len(m.driverStandings) == 0 {
|
||||||
return styleMuted.Render(" No standings data available.")
|
return styleMuted.Render(" No standings data available.\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Column widths
|
var sb strings.Builder
|
||||||
const (
|
maxPoints := m.driverStandings[0].PointsCurrent
|
||||||
wPos = 4
|
w := m.width
|
||||||
wAcronym = 5
|
if w < 40 {
|
||||||
wName = 22
|
w = 40
|
||||||
wTeam = 22
|
|
||||||
wPoints = 8
|
|
||||||
wDelta = 5
|
|
||||||
)
|
|
||||||
|
|
||||||
header := styleBold.Render(
|
|
||||||
padRight("Pos", wPos) + " " +
|
|
||||||
padRight("DRV", wAcronym) + " " +
|
|
||||||
padRight("Name", wName) + " " +
|
|
||||||
padRight("Team", wTeam) + " " +
|
|
||||||
padLeft("Pts", wPoints) + " " +
|
|
||||||
padLeft("Δ", wDelta),
|
|
||||||
)
|
|
||||||
|
|
||||||
var rows []string
|
|
||||||
rows = append(rows, header)
|
|
||||||
|
|
||||||
maxCursor := len(m.driverStandings) - 1
|
|
||||||
if m.cursor > maxCursor {
|
|
||||||
_ = maxCursor // cursor clamping happens in Update
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for i, s := range m.driverStandings {
|
// Responsive column widths
|
||||||
|
compact := w < 80
|
||||||
|
nameWidth := 20
|
||||||
|
teamWidth := 20
|
||||||
|
barWidth := 20
|
||||||
|
if w >= 130 {
|
||||||
|
nameWidth = 24
|
||||||
|
teamWidth = 24
|
||||||
|
barWidth = 35
|
||||||
|
} else if w >= 100 {
|
||||||
|
barWidth = 25
|
||||||
|
} else if compact {
|
||||||
|
nameWidth = 0 // hide full name in compact mode
|
||||||
|
teamWidth = 14
|
||||||
|
barWidth = 12
|
||||||
|
}
|
||||||
|
|
||||||
|
visible := m.visibleRows()
|
||||||
|
endIdx := m.scroll + visible
|
||||||
|
if endIdx > len(m.driverStandings) {
|
||||||
|
endIdx = len(m.driverStandings)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Header
|
||||||
|
var header string
|
||||||
|
if compact {
|
||||||
|
header = fmt.Sprintf(" %s %s %s %s %s %s",
|
||||||
|
padRight("POS", 3),
|
||||||
|
padRight("", 2),
|
||||||
|
padRight("", 1),
|
||||||
|
padRight("DRV", 4),
|
||||||
|
padRight("TEAM", teamWidth),
|
||||||
|
padLeft("PTS", 5),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
header = fmt.Sprintf(" %s %s %s %s %s %s %s %s",
|
||||||
|
padRight("POS", 4),
|
||||||
|
padRight("", 2),
|
||||||
|
padRight("", 1),
|
||||||
|
padRight("DRV", 4),
|
||||||
|
padRight("DRIVER", nameWidth),
|
||||||
|
padRight("TEAM", teamWidth),
|
||||||
|
padLeft("PTS", 6),
|
||||||
|
padRight("", barWidth),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
sb.WriteString(lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color(colorMuted)).
|
||||||
|
Bold(true).
|
||||||
|
Render(header) + "\n")
|
||||||
|
sb.WriteString(" " + divider(min(w-6, lipgloss.Width(header))) + "\n")
|
||||||
|
|
||||||
|
for i := m.scroll; i < endIdx; i++ {
|
||||||
|
s := m.driverStandings[i]
|
||||||
d, ok := m.drivers[s.DriverNumber]
|
d, ok := m.drivers[s.DriverNumber]
|
||||||
acronym, name, team, teamColor := "---", "Unknown Driver", "Unknown Team", ""
|
acronym, name, team, teamColor := "---", "Unknown", "Unknown", colorMuted
|
||||||
if ok {
|
if ok {
|
||||||
acronym = d.NameAcronym
|
acronym = d.NameAcronym
|
||||||
name = d.FullName
|
name = d.FullName
|
||||||
team = d.TeamName
|
team = d.TeamName
|
||||||
teamColor = d.TeamColour
|
if d.TeamColour != "" {
|
||||||
|
teamColor = "#" + d.TeamColour
|
||||||
|
} else {
|
||||||
|
teamColor = teamColorFromName(d.TeamName)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
delta := renderDelta(s.PositionCurrent, s.PositionStart)
|
delta := renderDelta(s.PositionCurrent, s.PositionStart)
|
||||||
|
pos := renderPosition(s.PositionCurrent)
|
||||||
|
colorBar := lipgloss.NewStyle().Foreground(lipgloss.Color(teamColor)).Render("┃")
|
||||||
|
pointsBar := renderPointsBar(s.PointsCurrent, maxPoints, barWidth, teamColor)
|
||||||
|
|
||||||
teamStr := hexToStyle(teamColor).Render(truncate(team, wTeam))
|
var row string
|
||||||
// Pad team to width (lipgloss rendering may shift widths, so use padRight on plain string then style)
|
if compact {
|
||||||
teamPlain := padRight(truncate(team, wTeam), wTeam)
|
row = fmt.Sprintf(" %s %s %s %s %s %s",
|
||||||
teamStr = hexToStyle(teamColor).Render(teamPlain)
|
padRightVisible(pos, 3),
|
||||||
|
|
||||||
row := fmt.Sprintf("%s %s %s %s %s %s",
|
|
||||||
padLeft(fmt.Sprintf("%d", s.PositionCurrent), wPos),
|
|
||||||
padRight(acronym, wAcronym),
|
|
||||||
padRight(truncate(name, wName), wName),
|
|
||||||
teamStr,
|
|
||||||
padLeft(fmt.Sprintf("%.0f", s.PointsCurrent), wPoints),
|
|
||||||
delta,
|
delta,
|
||||||
|
colorBar,
|
||||||
|
padRight(acronym, 4),
|
||||||
|
padRight(truncate(team, teamWidth), teamWidth),
|
||||||
|
padLeft(fmt.Sprintf("%.0f", s.PointsCurrent), 5),
|
||||||
)
|
)
|
||||||
|
} else {
|
||||||
|
row = fmt.Sprintf(" %s %s %s %s %s %s %s %s",
|
||||||
|
padRightVisible(pos, 4),
|
||||||
|
delta,
|
||||||
|
colorBar,
|
||||||
|
padRight(acronym, 4),
|
||||||
|
padRight(truncate(name, nameWidth), nameWidth),
|
||||||
|
padRight(truncate(team, teamWidth), teamWidth),
|
||||||
|
padLeft(fmt.Sprintf("%.0f", s.PointsCurrent), 6),
|
||||||
|
pointsBar,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if i == m.cursor {
|
if i == m.cursor {
|
||||||
row = styleSelected.Render(row)
|
row = styleSelected.Render(row)
|
||||||
}
|
}
|
||||||
rows = append(rows, row)
|
|
||||||
|
sb.WriteString(row + "\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
return strings.Join(rows, "\n")
|
// Scroll indicator
|
||||||
|
if len(m.driverStandings) > visible {
|
||||||
|
sb.WriteString(styleMuted.Render(fmt.Sprintf(" Showing %d-%d of %d", m.scroll+1, endIdx, len(m.driverStandings))) + "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m StandingsModel) renderTeamStandings() string {
|
func (m StandingsModel) renderTeamStandings() string {
|
||||||
if len(m.teamStandings) == 0 {
|
if len(m.teamStandings) == 0 {
|
||||||
return styleMuted.Render(" No constructor standings available.")
|
return styleMuted.Render(" No standings data available.\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
const (
|
var sb strings.Builder
|
||||||
wPos = 4
|
maxPoints := m.teamStandings[0].PointsCurrent
|
||||||
wTeam = 30
|
w := m.width
|
||||||
wPoints = 8
|
if w < 40 {
|
||||||
wDelta = 5
|
w = 40
|
||||||
|
}
|
||||||
|
|
||||||
|
// Responsive
|
||||||
|
compact := w < 80
|
||||||
|
teamWidth := 28
|
||||||
|
barWidth := 30
|
||||||
|
if w >= 130 {
|
||||||
|
barWidth = 50
|
||||||
|
} else if w >= 100 {
|
||||||
|
barWidth = 40
|
||||||
|
} else if compact {
|
||||||
|
teamWidth = 18
|
||||||
|
barWidth = 15
|
||||||
|
}
|
||||||
|
|
||||||
|
visible := m.visibleRows()
|
||||||
|
endIdx := m.scroll + visible
|
||||||
|
if endIdx > len(m.teamStandings) {
|
||||||
|
endIdx = len(m.teamStandings)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Header
|
||||||
|
header := fmt.Sprintf(" %s %s %s %s %s %s",
|
||||||
|
padRight("POS", 4),
|
||||||
|
padRight("", 2),
|
||||||
|
padRight("", 1),
|
||||||
|
padRight("CONSTRUCTOR", teamWidth),
|
||||||
|
padLeft("PTS", 6),
|
||||||
|
padRight("", barWidth),
|
||||||
)
|
)
|
||||||
|
sb.WriteString(lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color(colorMuted)).
|
||||||
|
Bold(true).
|
||||||
|
Render(header) + "\n")
|
||||||
|
sb.WriteString(" " + divider(min(w-6, lipgloss.Width(header))) + "\n")
|
||||||
|
|
||||||
header := styleBold.Render(
|
for i := m.scroll; i < endIdx; i++ {
|
||||||
padRight("Pos", wPos) + " " +
|
s := m.teamStandings[i]
|
||||||
padRight("Constructor", wTeam) + " " +
|
teamColor := teamColorFromName(s.TeamName)
|
||||||
padLeft("Pts", wPoints) + " " +
|
|
||||||
padLeft("Δ", wDelta),
|
|
||||||
)
|
|
||||||
|
|
||||||
var rows []string
|
|
||||||
rows = append(rows, header)
|
|
||||||
|
|
||||||
for i, s := range m.teamStandings {
|
|
||||||
delta := renderDelta(s.PositionCurrent, s.PositionStart)
|
delta := renderDelta(s.PositionCurrent, s.PositionStart)
|
||||||
row := fmt.Sprintf("%s %s %s %s",
|
pos := renderPosition(s.PositionCurrent)
|
||||||
padLeft(fmt.Sprintf("%d", s.PositionCurrent), wPos),
|
colorBar := lipgloss.NewStyle().Foreground(lipgloss.Color(teamColor)).Render("┃")
|
||||||
padRight(truncate(s.TeamName, wTeam), wTeam),
|
pointsBar := renderPointsBar(s.PointsCurrent, maxPoints, barWidth, teamColor)
|
||||||
padLeft(fmt.Sprintf("%.0f", s.PointsCurrent), wPoints),
|
|
||||||
|
row := fmt.Sprintf(" %s %s %s %s %s %s",
|
||||||
|
padRightVisible(pos, 4),
|
||||||
delta,
|
delta,
|
||||||
|
colorBar,
|
||||||
|
padRight(truncate(s.TeamName, teamWidth), teamWidth),
|
||||||
|
padLeft(fmt.Sprintf("%.0f", s.PointsCurrent), 6),
|
||||||
|
pointsBar,
|
||||||
)
|
)
|
||||||
|
|
||||||
if i == m.cursor {
|
if i == m.cursor {
|
||||||
row = styleSelected.Render(row)
|
row = styleSelected.Render(row)
|
||||||
}
|
}
|
||||||
rows = append(rows, row)
|
|
||||||
|
sb.WriteString(row + "\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
return strings.Join(rows, "\n")
|
if len(m.teamStandings) > visible {
|
||||||
}
|
sb.WriteString(styleMuted.Render(fmt.Sprintf(" Showing %d-%d of %d", m.scroll+1, endIdx, len(m.teamStandings))) + "\n")
|
||||||
|
}
|
||||||
|
|
||||||
// matchKey checks if a KeyMsg matches a binding.
|
return sb.String()
|
||||||
func matchKey(msg tea.KeyMsg, binding interface{ Keys() []string }) bool {
|
|
||||||
for _, k := range binding.Keys() {
|
|
||||||
if msg.String() == k {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,23 +4,44 @@ import "github.com/charmbracelet/lipgloss"
|
|||||||
|
|
||||||
// F1 brand colors
|
// F1 brand colors
|
||||||
const (
|
const (
|
||||||
colorF1Red = "#E8002D"
|
colorF1Red = "#E10600"
|
||||||
colorF1Black = "#15151E"
|
colorF1Black = "#15151E"
|
||||||
colorSubtle = "#3C3C4A"
|
colorSubtle = "#2A2A3C"
|
||||||
colorMuted = "#6B6B7A"
|
colorMuted = "#6B6B7A"
|
||||||
colorWhite = "#FFFFFF"
|
colorWhite = "#FFFFFF"
|
||||||
colorGreen = "#39B54A"
|
colorGreen = "#00D26A"
|
||||||
|
colorYellow = "#FFD700"
|
||||||
|
colorOrange = "#FF8700"
|
||||||
|
colorCyan = "#00BFFF"
|
||||||
|
|
||||||
|
// Surfaces
|
||||||
|
colorSurface0 = "#1B1B2F"
|
||||||
|
colorSurface1 = "#222236"
|
||||||
|
colorSurface2 = "#2D2D44"
|
||||||
|
colorBorder = "#3C3C54"
|
||||||
|
|
||||||
// Tyre compounds
|
// Tyre compounds
|
||||||
colorSoft = "#FF1E1E"
|
colorSoft = "#FF3333"
|
||||||
colorMedium = "#FFD700"
|
colorMedium = "#FFD700"
|
||||||
colorHard = "#EEEEEE"
|
colorHard = "#CCCCCC"
|
||||||
colorInter = "#39B54A"
|
colorInter = "#39B54A"
|
||||||
colorWet = "#0057FF"
|
colorWet = "#0080FF"
|
||||||
|
|
||||||
|
// F1 team colors (2024/2025 season)
|
||||||
|
colorRedbull = "#3671C6"
|
||||||
|
colorMercedes = "#27F4D2"
|
||||||
|
colorFerrari = "#E8002D"
|
||||||
|
colorMclaren = "#FF8000"
|
||||||
|
colorAstonMartin = "#229971"
|
||||||
|
colorAlpine = "#FF87BC"
|
||||||
|
colorWilliams = "#64C4FF"
|
||||||
|
colorHaas = "#B6BABD"
|
||||||
|
colorRB = "#6692FF"
|
||||||
|
colorSauber = "#52E252"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
// Tab bar styles
|
// ── Tab bar ──────────────────────────────────────────
|
||||||
styleActiveTab = lipgloss.NewStyle().
|
styleActiveTab = lipgloss.NewStyle().
|
||||||
Bold(true).
|
Bold(true).
|
||||||
Foreground(lipgloss.Color(colorWhite)).
|
Foreground(lipgloss.Color(colorWhite)).
|
||||||
@@ -29,18 +50,28 @@ var (
|
|||||||
|
|
||||||
styleInactiveTab = lipgloss.NewStyle().
|
styleInactiveTab = lipgloss.NewStyle().
|
||||||
Foreground(lipgloss.Color(colorMuted)).
|
Foreground(lipgloss.Color(colorMuted)).
|
||||||
|
Background(lipgloss.Color(colorSurface0)).
|
||||||
Padding(0, 2)
|
Padding(0, 2)
|
||||||
|
|
||||||
styleTabBar = lipgloss.NewStyle().
|
styleTabBar = lipgloss.NewStyle().
|
||||||
Background(lipgloss.Color(colorF1Black)).
|
Background(lipgloss.Color(colorSurface0))
|
||||||
BorderStyle(lipgloss.NormalBorder()).
|
|
||||||
BorderBottom(true).
|
|
||||||
BorderForeground(lipgloss.Color(colorSubtle))
|
|
||||||
|
|
||||||
// Panel / border styles
|
// Tab bar accent stripe
|
||||||
|
styleTabStripe = lipgloss.NewStyle().
|
||||||
|
Background(lipgloss.Color(colorF1Red)).
|
||||||
|
Foreground(lipgloss.Color(colorF1Red))
|
||||||
|
|
||||||
|
// Year badge in tab bar
|
||||||
|
styleYearBadge = lipgloss.NewStyle().
|
||||||
|
Bold(true).
|
||||||
|
Foreground(lipgloss.Color(colorYellow)).
|
||||||
|
Background(lipgloss.Color(colorSurface0)).
|
||||||
|
Padding(0, 1)
|
||||||
|
|
||||||
|
// ── Panel / border styles ────────────────────────────
|
||||||
stylePanelBorder = lipgloss.NewStyle().
|
stylePanelBorder = lipgloss.NewStyle().
|
||||||
BorderStyle(lipgloss.RoundedBorder()).
|
BorderStyle(lipgloss.RoundedBorder()).
|
||||||
BorderForeground(lipgloss.Color(colorSubtle)).
|
BorderForeground(lipgloss.Color(colorBorder)).
|
||||||
Padding(0, 1)
|
Padding(0, 1)
|
||||||
|
|
||||||
styleActivePanelBorder = lipgloss.NewStyle().
|
styleActivePanelBorder = lipgloss.NewStyle().
|
||||||
@@ -48,8 +79,8 @@ var (
|
|||||||
BorderForeground(lipgloss.Color(colorF1Red)).
|
BorderForeground(lipgloss.Color(colorF1Red)).
|
||||||
Padding(0, 1)
|
Padding(0, 1)
|
||||||
|
|
||||||
// Text styles
|
// ── Text styles ──────────────────────────────────────
|
||||||
styleBold = lipgloss.NewStyle().Bold(true)
|
styleBold = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(colorWhite))
|
||||||
|
|
||||||
styleMuted = lipgloss.NewStyle().
|
styleMuted = lipgloss.NewStyle().
|
||||||
Foreground(lipgloss.Color(colorMuted))
|
Foreground(lipgloss.Color(colorMuted))
|
||||||
@@ -61,10 +92,16 @@ var (
|
|||||||
styleHeader = lipgloss.NewStyle().
|
styleHeader = lipgloss.NewStyle().
|
||||||
Bold(true).
|
Bold(true).
|
||||||
Foreground(lipgloss.Color(colorWhite)).
|
Foreground(lipgloss.Color(colorWhite)).
|
||||||
Background(lipgloss.Color(colorSubtle)).
|
Background(lipgloss.Color(colorSurface2)).
|
||||||
Padding(0, 1)
|
Padding(0, 1).
|
||||||
|
MarginBottom(0)
|
||||||
|
|
||||||
// Delta styles
|
styleSectionTitle = lipgloss.NewStyle().
|
||||||
|
Bold(true).
|
||||||
|
Foreground(lipgloss.Color(colorF1Red)).
|
||||||
|
PaddingLeft(1)
|
||||||
|
|
||||||
|
// ── Delta styles ─────────────────────────────────────
|
||||||
styleDeltaUp = lipgloss.NewStyle().
|
styleDeltaUp = lipgloss.NewStyle().
|
||||||
Foreground(lipgloss.Color(colorGreen)).
|
Foreground(lipgloss.Color(colorGreen)).
|
||||||
Bold(true)
|
Bold(true)
|
||||||
@@ -76,28 +113,154 @@ var (
|
|||||||
styleDeltaEqual = lipgloss.NewStyle().
|
styleDeltaEqual = lipgloss.NewStyle().
|
||||||
Foreground(lipgloss.Color(colorMuted))
|
Foreground(lipgloss.Color(colorMuted))
|
||||||
|
|
||||||
// Selected row style
|
// ── Selected row style ───────────────────────────────
|
||||||
styleSelected = lipgloss.NewStyle().
|
styleSelected = lipgloss.NewStyle().
|
||||||
Background(lipgloss.Color(colorSubtle)).
|
Background(lipgloss.Color(colorSurface2)).
|
||||||
|
Foreground(lipgloss.Color(colorWhite)).
|
||||||
Bold(true)
|
Bold(true)
|
||||||
|
|
||||||
// Status indicators
|
// ── Status indicators ────────────────────────────────
|
||||||
stylePast = lipgloss.NewStyle().
|
stylePast = lipgloss.NewStyle().
|
||||||
Foreground(lipgloss.Color(colorMuted))
|
Foreground(lipgloss.Color(colorGreen))
|
||||||
|
|
||||||
styleNext = lipgloss.NewStyle().
|
styleNext = lipgloss.NewStyle().
|
||||||
Foreground(lipgloss.Color(colorF1Red)).
|
Foreground(lipgloss.Color(colorF1Red)).
|
||||||
Bold(true)
|
Bold(true)
|
||||||
|
|
||||||
// Flag colors for race control
|
styleFuture = lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color(colorMuted))
|
||||||
|
|
||||||
|
// ── Flag colors for race control ─────────────────────
|
||||||
styleFlagGreen = lipgloss.NewStyle().Foreground(lipgloss.Color(colorGreen))
|
styleFlagGreen = lipgloss.NewStyle().Foreground(lipgloss.Color(colorGreen))
|
||||||
styleFlagYellow = lipgloss.NewStyle().Foreground(lipgloss.Color(colorMedium))
|
styleFlagYellow = lipgloss.NewStyle().Foreground(lipgloss.Color(colorYellow))
|
||||||
styleFlagRed = lipgloss.NewStyle().Foreground(lipgloss.Color(colorF1Red))
|
styleFlagRed = lipgloss.NewStyle().Foreground(lipgloss.Color(colorF1Red))
|
||||||
styleFlagBlue = lipgloss.NewStyle().Foreground(lipgloss.Color(colorWet))
|
styleFlagBlue = lipgloss.NewStyle().Foreground(lipgloss.Color(colorCyan))
|
||||||
styleFlagWhite = lipgloss.NewStyle().Foreground(lipgloss.Color(colorWhite))
|
styleFlagWhite = lipgloss.NewStyle().Foreground(lipgloss.Color(colorWhite))
|
||||||
|
|
||||||
// Help bar
|
// Race control category styles
|
||||||
|
styleSafetyCar = lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color(colorOrange)).
|
||||||
|
Bold(true)
|
||||||
|
|
||||||
|
styleDRS = lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color(colorCyan))
|
||||||
|
|
||||||
|
// ── Help bar ─────────────────────────────────────────
|
||||||
styleHelp = lipgloss.NewStyle().
|
styleHelp = lipgloss.NewStyle().
|
||||||
Foreground(lipgloss.Color(colorMuted)).
|
Foreground(lipgloss.Color(colorMuted)).
|
||||||
|
Background(lipgloss.Color(colorSurface0)).
|
||||||
Padding(0, 1)
|
Padding(0, 1)
|
||||||
|
|
||||||
|
styleHelpKey = lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color(colorWhite)).
|
||||||
|
Bold(true)
|
||||||
|
|
||||||
|
styleHelpDesc = lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color(colorMuted))
|
||||||
|
|
||||||
|
// ── Status bar ───────────────────────────────────────
|
||||||
|
styleStatusBar = lipgloss.NewStyle().
|
||||||
|
Background(lipgloss.Color(colorSurface0)).
|
||||||
|
Foreground(lipgloss.Color(colorWhite)).
|
||||||
|
Padding(0, 1)
|
||||||
|
|
||||||
|
styleStatusLabel = lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color(colorMuted))
|
||||||
|
|
||||||
|
styleStatusValue = lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color(colorWhite)).
|
||||||
|
Bold(true)
|
||||||
|
|
||||||
|
styleCountdown = lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color(colorYellow)).
|
||||||
|
Bold(true)
|
||||||
|
|
||||||
|
// ── Points bar ───────────────────────────────────────
|
||||||
|
stylePointsBarFilled = lipgloss.NewStyle().
|
||||||
|
Background(lipgloss.Color(colorF1Red)).
|
||||||
|
Foreground(lipgloss.Color(colorF1Red))
|
||||||
|
|
||||||
|
stylePointsBarEmpty = lipgloss.NewStyle().
|
||||||
|
Background(lipgloss.Color(colorSurface2)).
|
||||||
|
Foreground(lipgloss.Color(colorSurface2))
|
||||||
|
|
||||||
|
// ── Session pill ─────────────────────────────────────
|
||||||
|
styleSessionActive = lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color(colorWhite)).
|
||||||
|
Background(lipgloss.Color(colorF1Red)).
|
||||||
|
Bold(true).
|
||||||
|
Padding(0, 1)
|
||||||
|
|
||||||
|
styleSessionInactive = lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color(colorMuted)).
|
||||||
|
Background(lipgloss.Color(colorSurface1)).
|
||||||
|
Padding(0, 1)
|
||||||
|
|
||||||
|
styleSessionCursor = lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color(colorWhite)).
|
||||||
|
Background(lipgloss.Color(colorSurface2)).
|
||||||
|
Bold(true).
|
||||||
|
Padding(0, 1)
|
||||||
|
|
||||||
|
// ── Driver card ──────────────────────────────────────
|
||||||
|
styleDriverNumber = lipgloss.NewStyle().
|
||||||
|
Bold(true).
|
||||||
|
Foreground(lipgloss.Color(colorWhite)).
|
||||||
|
Padding(0, 1)
|
||||||
|
|
||||||
|
styleDriverName = lipgloss.NewStyle().
|
||||||
|
Bold(true).
|
||||||
|
Foreground(lipgloss.Color(colorWhite))
|
||||||
|
|
||||||
|
styleTeamName = lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color(colorMuted))
|
||||||
|
|
||||||
|
// ── Misc ─────────────────────────────────────────────
|
||||||
|
stylePositionFirst = lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color(colorYellow)).
|
||||||
|
Bold(true)
|
||||||
|
|
||||||
|
stylePositionSecond = lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color(colorHard))
|
||||||
|
|
||||||
|
stylePositionThird = lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color(colorOrange))
|
||||||
|
|
||||||
|
styleDNF = lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color(colorF1Red)).
|
||||||
|
Bold(true)
|
||||||
|
|
||||||
|
styleLeader = lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color(colorYellow)).
|
||||||
|
Bold(true)
|
||||||
|
|
||||||
|
styleGap = lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color(colorMuted))
|
||||||
|
|
||||||
|
// Base Table styles
|
||||||
|
styleTableBase = lipgloss.NewStyle().
|
||||||
|
BorderStyle(lipgloss.NormalBorder()).
|
||||||
|
BorderForeground(lipgloss.Color(colorBorder))
|
||||||
|
|
||||||
|
// Weather card
|
||||||
|
styleWeatherLabel = lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color(colorMuted))
|
||||||
|
|
||||||
|
styleWeatherValue = lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color(colorWhite)).
|
||||||
|
Bold(true)
|
||||||
|
|
||||||
|
styleRain = lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color(colorCyan)).
|
||||||
|
Bold(true)
|
||||||
|
|
||||||
|
styleDry = lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color(colorGreen))
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// DefaultTableStyles returns a base style map for bubbles/table.
|
||||||
|
func DefaultTableStyles() tableStyles {
|
||||||
|
return tableStyles{}
|
||||||
|
}
|
||||||
|
|
||||||
|
type tableStyles struct{}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
"github.com/AmanTahiliani/box-box/internal/models"
|
"github.com/AmanTahiliani/box-box/internal/models"
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -35,7 +36,6 @@ func formatGap(v interface{}) string {
|
|||||||
case string:
|
case string:
|
||||||
return val
|
return val
|
||||||
case []interface{}:
|
case []interface{}:
|
||||||
// Qualifying: return best Q time
|
|
||||||
if len(val) == 0 {
|
if len(val) == 0 {
|
||||||
return "--"
|
return "--"
|
||||||
}
|
}
|
||||||
@@ -56,7 +56,6 @@ func formatDuration(v interface{}) string {
|
|||||||
case float64:
|
case float64:
|
||||||
return formatSeconds(val)
|
return formatSeconds(val)
|
||||||
case []interface{}:
|
case []interface{}:
|
||||||
// Qualifying: return best Q time
|
|
||||||
if len(val) == 0 {
|
if len(val) == 0 {
|
||||||
return "--"
|
return "--"
|
||||||
}
|
}
|
||||||
@@ -69,7 +68,6 @@ func formatDuration(v interface{}) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// hexToStyle creates a lipgloss.Style with the given hex color as the foreground.
|
// 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 {
|
func hexToStyle(hex string) lipgloss.Style {
|
||||||
if hex == "" {
|
if hex == "" {
|
||||||
return lipgloss.NewStyle()
|
return lipgloss.NewStyle()
|
||||||
@@ -80,6 +78,17 @@ func hexToStyle(hex string) lipgloss.Style {
|
|||||||
return lipgloss.NewStyle().Foreground(lipgloss.Color(hex))
|
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.
|
// sparkline generates a unicode block chart for lap times.
|
||||||
// Pit laps (nil duration) are rendered as spaces.
|
// Pit laps (nil duration) are rendered as spaces.
|
||||||
func sparkline(laps []models.Lap, width int) string {
|
func sparkline(laps []models.Lap, width int) string {
|
||||||
@@ -87,7 +96,7 @@ func sparkline(laps []models.Lap, width int) string {
|
|||||||
blockRunes := []rune(blocks)
|
blockRunes := []rune(blocks)
|
||||||
|
|
||||||
if len(laps) == 0 {
|
if len(laps) == 0 {
|
||||||
return strings.Repeat(" ", width)
|
return styleMuted.Render(strings.Repeat("·", width))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Collect valid lap durations
|
// Collect valid lap durations
|
||||||
@@ -120,7 +129,7 @@ func sparkline(laps []models.Lap, width int) string {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
if d < 0 {
|
if d < 0 {
|
||||||
sb.WriteRune(' ')
|
sb.WriteString(styleMuted.Render("·"))
|
||||||
} else {
|
} else {
|
||||||
norm := (d - minDur) / rng
|
norm := (d - minDur) / rng
|
||||||
// Invert: fast laps = tall bar (higher index)
|
// Invert: fast laps = tall bar (higher index)
|
||||||
@@ -131,16 +140,26 @@ func sparkline(laps []models.Lap, width int) string {
|
|||||||
if idx >= len(blockRunes) {
|
if idx >= len(blockRunes) {
|
||||||
idx = len(blockRunes) - 1
|
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++
|
count++
|
||||||
}
|
}
|
||||||
|
|
||||||
result := sb.String()
|
result := sb.String()
|
||||||
resultLen := utf8.RuneCountInString(result)
|
resultLen := utf8.RuneCountInString(lipgloss.NewStyle().Render(result))
|
||||||
if resultLen < width {
|
_ = resultLen
|
||||||
result += strings.Repeat(" ", width-resultLen)
|
|
||||||
}
|
|
||||||
return result
|
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.
|
// tyreAbbrev returns a single-letter abbreviation for a tyre compound.
|
||||||
func tyreAbbrev(c models.TyreCompound) string {
|
func tyreAbbrev(c models.TyreCompound) string {
|
||||||
switch c {
|
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 {
|
func renderDelta(current, start int) string {
|
||||||
diff := start - current // positive = gained positions
|
diff := start - current // positive = gained positions
|
||||||
switch {
|
switch {
|
||||||
case diff > 0:
|
case diff > 0:
|
||||||
return styleDeltaUp.Render(fmt.Sprintf("▲%d", diff))
|
s := fmt.Sprintf("▲%d", diff)
|
||||||
|
return padRightVisible(styleDeltaUp.Render(s), 2)
|
||||||
case diff < 0:
|
case diff < 0:
|
||||||
return styleDeltaDown.Render(fmt.Sprintf("▼%d", -diff))
|
s := fmt.Sprintf("▼%d", -diff)
|
||||||
|
return padRightVisible(styleDeltaDown.Render(s), 2)
|
||||||
default:
|
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.
|
// 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 {
|
func meetingStatus(m models.Meeting, now time.Time, isNext bool) string {
|
||||||
end, err := time.Parse(time.RFC3339, m.DateEnd)
|
end, err := time.Parse(time.RFC3339, m.DateEnd)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Fallback: try date-only parsing
|
|
||||||
end, err = time.Parse("2006-01-02", m.DateEnd[:min(len(m.DateEnd), 10)])
|
end, err = time.Parse("2006-01-02", m.DateEnd[:min(len(m.DateEnd), 10)])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return " "
|
return " "
|
||||||
@@ -217,9 +301,9 @@ func meetingStatus(m models.Meeting, now time.Time, isNext bool) string {
|
|||||||
return stylePast.Render("✓")
|
return stylePast.Render("✓")
|
||||||
}
|
}
|
||||||
if isNext {
|
if isNext {
|
||||||
return styleNext.Render("◎")
|
return styleNext.Render("●")
|
||||||
}
|
}
|
||||||
return " "
|
return styleFuture.Render("○")
|
||||||
}
|
}
|
||||||
|
|
||||||
// truncate shortens a string to max runes, appending "…" if truncated.
|
// 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]) + "…"
|
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 {
|
func padRight(s string, width int) string {
|
||||||
runes := []rune(s)
|
runes := []rune(s)
|
||||||
if len(runes) >= width {
|
if len(runes) >= width {
|
||||||
@@ -243,7 +328,8 @@ func padRight(s string, width int) string {
|
|||||||
return s + strings.Repeat(" ", width-len(runes))
|
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 {
|
func padLeft(s string, width int) string {
|
||||||
runes := []rune(s)
|
runes := []rune(s)
|
||||||
if len(runes) >= width {
|
if len(runes) >= width {
|
||||||
@@ -252,6 +338,26 @@ func padLeft(s string, width int) string {
|
|||||||
return strings.Repeat(" ", width-len(runes)) + s
|
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 {
|
func min(a, b int) int {
|
||||||
if a < b {
|
if a < b {
|
||||||
return a
|
return a
|
||||||
@@ -266,21 +372,90 @@ func max(a, b int) int {
|
|||||||
return b
|
return b
|
||||||
}
|
}
|
||||||
|
|
||||||
// countryFlag converts a country flag URL or code to an emoji flag.
|
// countryFlag converts a country code to an emoji flag.
|
||||||
// OpenF1 provides flag URLs; we do a best-effort mapping from country_code.
|
|
||||||
func countryFlag(countryCode string) string {
|
func countryFlag(countryCode string) string {
|
||||||
// Convert ISO 3166-1 alpha-2 to emoji regional indicators
|
|
||||||
code := strings.ToUpper(countryCode)
|
code := strings.ToUpper(countryCode)
|
||||||
if len(code) != 2 {
|
if len(code) != 2 {
|
||||||
return " "
|
return " "
|
||||||
}
|
}
|
||||||
// Each letter maps to a regional indicator symbol (U+1F1E6 = 'A')
|
|
||||||
r1 := rune(0x1F1E6 + int(code[0]-'A'))
|
r1 := rune(0x1F1E6 + int(code[0]-'A'))
|
||||||
r2 := rune(0x1F1E6 + int(code[1]-'A'))
|
r2 := rune(0x1F1E6 + int(code[1]-'A'))
|
||||||
return string(r1) + string(r2)
|
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 {
|
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