package main import ( "fmt" "strings" "time" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) // CLI Model type model struct { config *Config stations []StationData lastUpdate time.Time loading bool width int height int } // Messages type updateDataMsg []StationData type tickMsg time.Time func initialModel(config *Config) model { return model{ config: config, loading: true, width: 80, height: 24, } } func (m model) Init() tea.Cmd { return tea.Batch( fetchData(m.config.Stations), tickCmd(), ) } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height return m, nil case tea.KeyMsg: switch msg.String() { case "q", "ctrl+c": return m, tea.Quit case "r": m.loading = true return m, fetchData(m.config.Stations) } case updateDataMsg: m.stations = []StationData(msg) m.lastUpdate = time.Now() m.loading = false return m, nil case tickMsg: return m, tea.Batch( fetchData(m.config.Stations), tickCmd(), ) } return m, nil } func (m model) View() string { if m.loading && len(m.stations) == 0 { return lipgloss.NewStyle(). Width(m.width). Align(lipgloss.Center). Render("Loading train data...") } var sections []string // Header headerStyle := lipgloss.NewStyle(). Bold(true). Foreground(lipgloss.Color("15")). MarginBottom(1) timeStyle := lipgloss.NewStyle(). Foreground(lipgloss.Color("8")). Align(lipgloss.Right) header := lipgloss.JoinHorizontal( lipgloss.Top, headerStyle.Width(m.width-20).Render("SUNDIAL"), timeStyle.Width(20).Render("Last updated: "+m.lastUpdate.Format("15:04")), ) sections = append(sections, header) // Horizontal rule rule := strings.Repeat("─", m.width) sections = append(sections, rule) // Station tables for _, station := range m.stations { sections = append(sections, renderStation(station, m.width)) } // Loading indicator if m.loading { sections = append(sections, "\nRefreshing...") } // Help help := lipgloss.NewStyle(). Foreground(lipgloss.Color("8")). MarginTop(1). Render("Press 'r' to refresh, 'q' to quit") sections = append(sections, help) return lipgloss.JoinVertical(lipgloss.Left, sections...) } // Commands func fetchData(stations []Station) tea.Cmd { return func() tea.Msg { data := FetchAllStationsData(stations) return updateDataMsg(data) } } func tickCmd() tea.Cmd { return tea.Tick(60*time.Second, func(t time.Time) tea.Msg { return tickMsg(t) }) } // Rendering functions func renderStation(station StationData, width int) string { var sections []string // Station name stationStyle := lipgloss.NewStyle(). Bold(true). Foreground(lipgloss.Color("15")). MarginTop(1). MarginBottom(1) sections = append(sections, stationStyle.Render(station.Station.Name+" ("+station.Station.Code+")")) if station.Error != "" { errorStyle := lipgloss.NewStyle(). Foreground(lipgloss.Color("9")). Background(lipgloss.Color("1")) sections = append(sections, errorStyle.Render("ERROR: "+station.Error)) return lipgloss.JoinVertical(lipgloss.Left, sections...) } if len(station.Departures) == 0 { sections = append(sections, "No departures found - The National Rail website now uses dynamic JavaScript to load departure data.") sections = append(sections, "This simple HTTP scraper cannot access the live data. Consider using the National Rail API instead.") return lipgloss.JoinVertical(lipgloss.Left, sections...) } // Table table := renderDeparturesTable(station.Departures, width-4) sections = append(sections, table) return lipgloss.JoinVertical(lipgloss.Left, sections...) } func renderDeparturesTable(departures []Departure, width int) string { // Styles headerStyle := lipgloss.NewStyle(). Bold(true). Foreground(lipgloss.Color("15")) cellStyle := lipgloss.NewStyle(). Foreground(lipgloss.Color("15")) delayedStyle := lipgloss.NewStyle(). Foreground(lipgloss.Color("9")) // Red // Column widths (matching web interface) timeWidth := 6 // "HH:MM" platformWidth := 4 // "Pl." destWidth := width - timeWidth - platformWidth - 4 // Remaining space minus padding // Header row headerRow := lipgloss.JoinHorizontal( lipgloss.Top, headerStyle.Width(timeWidth).Render("Time"), headerStyle.Width(destWidth).Render("Destination"), headerStyle.Width(platformWidth).Align(lipgloss.Right).Render("Pl."), ) var rows []string rows = append(rows, headerRow) // Data rows for _, departure := range departures { // Time cell var timeText string if departure.ExpectedTime != "" { timeText = departure.ExpectedTime } else { timeText = departure.ScheduledTime } var timeCell string if departure.ExpectedTime != "" && departure.ExpectedTime != departure.ScheduledTime { timeCell = delayedStyle.Width(timeWidth).Render(timeText) } else { timeCell = cellStyle.Width(timeWidth).Render(timeText) } // Destination cell destText := departure.Destination if departure.Via != "" { destText += " " + departure.Via } destCell := cellStyle.Width(destWidth).Render(destText) // Platform cell platformCell := cellStyle.Width(platformWidth).Align(lipgloss.Right).Render(departure.Platform) row := lipgloss.JoinHorizontal(lipgloss.Top, timeCell, destCell, platformCell) rows = append(rows, row) } return lipgloss.JoinVertical(lipgloss.Left, rows...) } // runTUI starts the Bubble Tea CLI application func runTUI() error { config, err := LoadConfig("config.yaml") if err != nil { return fmt.Errorf("error loading config: %w", err) } p := tea.NewProgram(initialModel(config), tea.WithAltScreen()) _, err = p.Run() return err }