// tui_model.go - Updated to match the reference UI package main import ( "fmt" "math" "strings" "time" "github.com/charmbracelet/bubbles/progress" "github.com/charmbracelet/bubbles/spinner" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) // Model represents the TUI application state type Model struct { response *OWMResponse location string days int viewport viewport.Model progress progress.Model spinner spinner.Model loading bool refreshing bool width int height int selectedDay int showHourly bool refreshTimer *time.Ticker refreshMinutes int err error } // NewModel creates a new TUI model func NewModel(response *OWMResponse, location string, days int) *Model { // Initialize spinner s := spinner.New() s.Spinner = spinner.Dot s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205")) // Initialize progress bar for hourly forecast p := progress.New(progress.WithScaledGradient("#FF7CCB", "#FDFF8C")) // Initialize viewport vp := viewport.New(80, 24) m := &Model{ response: response, location: location, days: min(days, 3), spinner: s, progress: p, viewport: vp, loading: false, refreshing: false, selectedDay: 0, showHourly: true, } return m } // SetRefreshInterval sets the auto-refresh interval func (m *Model) SetRefreshInterval(interval time.Duration) { if interval > 0 { m.refreshTimer = time.NewTicker(interval) m.refreshMinutes = int(interval.Minutes()) } } // Quit gracefully exits the TUI func (m *Model) Quit() { if m.refreshTimer != nil { m.refreshTimer.Stop() } } // Run starts the Bubble Tea program func (m *Model) Run() error { p := tea.NewProgram(m, tea.WithAltScreen()) _, err := p.Run() return err } // Init initializes the model func (m Model) Init() tea.Cmd { return tea.Batch( m.spinner.Tick, tea.EnterAltScreen, ) } // Update handles messages and updates the model func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd switch msg := msg.(type) { case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height m.viewport.Width = msg.Width m.viewport.Height = msg.Height - 2 case tea.KeyMsg: switch msg.String() { case "q", "ctrl+c", "esc": m.Quit() return m, tea.Quit case "r", "R": m.refreshing = true cmds = append(cmds, m.refreshData()) case "H": m.showHourly = !m.showHourly case "left", "h": m.selectedDay = max(0, m.selectedDay-1) case "right", "l": m.selectedDay = min(m.days-1, m.selectedDay+1) case "d", "D": m.selectedDay = (m.selectedDay + 1) % m.days } case refreshMsg: m.response = msg.response m.err = msg.err m.refreshing = false case spinner.TickMsg: var cmd tea.Cmd m.spinner, cmd = m.spinner.Update(msg) cmds = append(cmds, cmd) case progress.FrameMsg: progressModel, cmd := m.progress.Update(msg) m.progress = progressModel.(progress.Model) cmds = append(cmds, cmd) } // Handle auto-refresh timer if m.refreshTimer != nil { select { case <-m.refreshTimer.C: m.refreshing = true cmds = append(cmds, m.refreshData()) default: } } // Update viewport var vpCmd tea.Cmd m.viewport, vpCmd = m.viewport.Update(msg) cmds = append(cmds, vpCmd) return m, tea.Batch(cmds...) } // View renders the TUI func (m Model) View() string { if m.loading { return m.loadingView() } if m.err != nil { return m.errorView() } if m.response == nil { return m.placeholderView() } content := m.mainView() // Add help footer footer := m.footerView() // Combine content and footer return lipgloss.JoinVertical(lipgloss.Top, content, footer) } func (m Model) mainView() string { // Create a box with border boxStyle := lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(lipgloss.Color("240")). Padding(1, 2). Width(m.width - 4) // Account for padding and border content := lipgloss.JoinVertical(lipgloss.Left, m.renderHeader(), "", m.renderCurrentWeather(), "", m.renderHourlyForecast(), ) return boxStyle.Render(content) } func (m Model) renderHeader() string { current := m.response.Current // Format location locParts := strings.Split(m.location, ",") locationText := m.location if len(locParts) > 1 { locationText = strings.TrimSpace(locParts[0]) } // Get weather description description := "Unknown" if len(current.Weather) > 0 { description = current.Weather[0].Description } // Format temperature temp := fmt.Sprintf("%.1f°C", current.Temp) feelsLike := fmt.Sprintf("%.1f°C", current.FeelsLike) // Get timezone-aware current time loc, _ := time.LoadLocation(m.response.Timezone) currentTime := time.Now().In(loc).Format("15:04 MST") // Build header header := lipgloss.JoinVertical(lipgloss.Left, lipgloss.NewStyle(). Foreground(lipgloss.Color("15")). Bold(true). Render(fmt.Sprintf("%s • %s", locationText, description)), "", lipgloss.JoinHorizontal(lipgloss.Left, lipgloss.NewStyle(). Foreground(lipgloss.Color("39")). Bold(true). Width(10). Render(temp), lipgloss.NewStyle(). Foreground(lipgloss.Color("246")). Render(fmt.Sprintf("Feels like %s", feelsLike)), ), "", lipgloss.NewStyle(). Foreground(lipgloss.Color("245")). Italic(true). Render(currentTime), ) return header } func (m Model) renderCurrentWeather() string { current := m.response.Current // Calculate sunrise and sunset times loc, _ := time.LoadLocation(m.response.Timezone) // Use the actual sunrise/sunset from API response sunriseTime := time.Unix(current.Sunrise, 0).In(loc).Format("15:04") sunsetTime := time.Unix(current.Sunset, 0).In(loc).Format("15:04") // Left column leftColumn := lipgloss.JoinVertical(lipgloss.Left, m.renderMetricRow("šŸ’§ Humidity", fmt.Sprintf("%d%%", current.Humidity)), m.renderMetricRow("šŸ’Ø Wind", fmt.Sprintf("%.1f km/h", current.WindSpeed*3.6)), m.renderMetricRow("🧭 Direction", fmt.Sprintf("%d°", current.WindDeg)), m.renderMetricRow("šŸŒ€ Pressure", fmt.Sprintf("%.0f hPa", float64(current.Pressure))), ) // Right column rightColumn := lipgloss.JoinVertical(lipgloss.Left, m.renderMetricRow("šŸŒ«ļø Dew Point", fmt.Sprintf("%.1f°C", current.DewPoint)), m.renderMetricRow("šŸ‘ļø Visibility", fmt.Sprintf("%d m", current.Visibility)), m.renderMetricRow("ā˜ļø Clouds", fmt.Sprintf("%d%%", current.Clouds)), m.renderMetricRow("ā˜€ļø UV Index", fmt.Sprintf("%.1f", current.Uvi)), ) // Sunrise/Sunset sunRow := lipgloss.JoinHorizontal(lipgloss.Center, lipgloss.NewStyle().Foreground(lipgloss.Color("226")).Render("ā˜€ļø "+sunriseTime), lipgloss.NewStyle().Width(20).Render(""), lipgloss.NewStyle().Foreground(lipgloss.Color("69")).Render("šŸŒ™ "+sunsetTime), ) columns := lipgloss.JoinHorizontal(lipgloss.Top, leftColumn, lipgloss.NewStyle().Width(10).Render(""), rightColumn, ) return lipgloss.JoinVertical(lipgloss.Left, columns, "", sunRow, ) } func (m Model) renderHourlyForecast() string { // Take next 8 hours for better display hours := min(8, len(m.response.Hourly)) if hours == 0 { return "" } // Find min/max temperatures for the range display minTemp := math.MaxFloat64 maxTemp := -math.MaxFloat64 for i := 0; i < hours; i++ { temp := m.response.Hourly[i].Temp if temp < minTemp { minTemp = temp } if temp > maxTemp { maxTemp = temp } } // Create title with temperature range title := lipgloss.JoinHorizontal(lipgloss.Left, lipgloss.NewStyle(). Foreground(lipgloss.Color("15")). Bold(true). Render("Hourly Forecast"), lipgloss.NewStyle().Width(m.width/2).Render(""), lipgloss.NewStyle(). Foreground(lipgloss.Color("246")). Render(fmt.Sprintf("%.0f/%.0f°C", maxTemp, minTemp)), ) // Create temperature row (above bars) var tempRow []string var barRow []string var timeRow []string // Set bar height barHeight := 6 for i := 0; i < hours; i++ { hour := m.response.Hourly[i] temp := hour.Temp // Temperature label (subscript formatting for negative) tempLabel := fmt.Sprintf("%.0f", math.Abs(temp)) if temp < 0 { // Use subscript for negative sign (approximation) tempLabel = "ā‚‹" + tempLabel } // Weather icon icon := " " if len(hour.Weather) > 0 { icon = m.getWeatherIcon(hour.Weather[0].Icon) } tempRow = append(tempRow, lipgloss.NewStyle(). Foreground(m.getTempColor(temp)). Render(fmt.Sprintf("%s%s", tempLabel, icon)), ) // Create bar - simple vertical representation bar := m.createTemperatureBar(temp, minTemp, maxTemp, barHeight) barRow = append(barRow, bar) // Time label (with special formatting for hour 0) time := time.Unix(hour.Dt, 0).Format("15") if time == "00" { time = "⁰⁰" } else if time == "03" { time = "⁰³" } else if time == "06" { time = "⁰⁶" } else if time == "09" { time = "⁰⁹" } else if time == "12" { time = "¹²" } else if time == "15" { time = "¹⁵" } else if time == "18" { time = "¹⁸" } else if time == "21" { time = "²¹" } timeRow = append(timeRow, lipgloss.NewStyle(). Foreground(lipgloss.Color("245")). Render(fmt.Sprintf("%s˙⁰⁰", time)), ) } // Build the chart tempRowStr := lipgloss.JoinHorizontal(lipgloss.Center, tempRow...) barRowStr := lipgloss.JoinHorizontal(lipgloss.Center, barRow...) timeRowStr := lipgloss.JoinHorizontal(lipgloss.Center, timeRow...) chart := lipgloss.JoinVertical(lipgloss.Left, "", tempRowStr, "", barRowStr, "", timeRowStr, ) return lipgloss.JoinVertical(lipgloss.Left, title, "", chart, ) } func (m Model) createTemperatureBar(temp, minTemp, maxTemp float64, height int) string { // Normalize temperature to bar height tempRange := maxTemp - minTemp if tempRange == 0 { tempRange = 1 } // Calculate bar position (0 to height-1) pos := int(((temp - minTemp) / tempRange) * float64(height-1)) // Create the bar bar := "" for i := 0; i < height; i++ { if i == pos { // Use block character at the temperature position bar += "ā–Š" } else if (temp > 0 && i < pos) || (temp < 0 && i > pos) { // Use lighter block for the bar bar += "ā–Š" } else { bar += " " } } return lipgloss.NewStyle(). Foreground(m.getTempColor(temp)). Render(bar) } func (m Model) renderMetricRow(label, value string) string { return lipgloss.JoinHorizontal(lipgloss.Left, lipgloss.NewStyle(). Foreground(lipgloss.Color("246")). Width(15). Render(label+":"), lipgloss.NewStyle(). Foreground(lipgloss.Color("15")). Width(15). Render(value), ) } func (m Model) getTempColor(temp float64) lipgloss.Color { // Color gradient based on temperature if temp < -15 { return lipgloss.Color("27") // Deep blue } else if temp < -10 { return lipgloss.Color("39") // Blue } else if temp < -5 { return lipgloss.Color("45") // Light blue } else if temp < 0 { return lipgloss.Color("51") // Cyan } else if temp < 5 { return lipgloss.Color("50") // Green } else if temp < 10 { return lipgloss.Color("190") // Yellow-green } else if temp < 15 { return lipgloss.Color("220") // Yellow } else if temp < 20 { return lipgloss.Color("208") // Orange } else if temp < 25 { return lipgloss.Color("202") // Red-orange } else { return lipgloss.Color("196") // Red } } func (m Model) getWeatherIcon(icon string) string { // Map OpenWeatherMap icons to Nerd Font icons (approximation) iconMap := map[string]string{ "01d": "īŒ", // clear sky day (sun) "01n": "", // clear sky night (moon) "02d": "īŒ‚", // few clouds day "02n": "ī¾", // few clouds night "03d": "", // scattered clouds "03n": "", "04d": "īŒ’", // broken clouds "04n": "īŒ’", "09d": "", // shower rain "09n": "", "10d": "", // rain day "10n": "", // rain night "11d": "īŒ", // thunderstorm day "11n": "īŒ", // thunderstorm night "13d": "", // snow "13n": "", "50d": "īŒž", // mist "50n": "īŒž", } if iconChar, ok := iconMap[icon]; ok { return iconChar } return "ī°" // default thermometer } func (m Model) loadingView() string { return lipgloss.Place( m.width, m.height, lipgloss.Center, lipgloss.Center, lipgloss.JoinVertical(lipgloss.Center, m.spinner.View(), "Loading weather data...", ), ) } func (m Model) errorView() string { return lipgloss.Place( m.width, m.height, lipgloss.Center, lipgloss.Center, lipgloss.NewStyle(). Foreground(lipgloss.Color("196")). Render(fmt.Sprintf("Error: %v\n\nPress 'r' to retry or 'q' to quit", m.err)), ) } func (m Model) placeholderView() string { return lipgloss.Place( m.width, m.height, lipgloss.Center, lipgloss.Center, "No weather data available\n\nPress 'r' to refresh or 'q' to quit", ) } func (m Model) footerView() string { var helpItems []string if m.showHourly { helpItems = append(helpItems, "H: Daily view") } else { helpItems = append(helpItems, "H: Hourly view") } helpItems = append(helpItems, "←→: Change day", "R: Refresh", "Q: Quit", ) if m.refreshMinutes > 0 { helpItems = append(helpItems, fmt.Sprintf("Auto-refresh: %dm", m.refreshMinutes)) } if m.refreshing { helpItems = append(helpItems, m.spinner.View()+" Refreshing...") } helpBar := strings.Join(helpItems, " │ ") return lipgloss.NewStyle(). Foreground(lipgloss.Color("240")). Padding(0, 1). Render(helpBar) } // refreshMsg is a message sent when data refresh completes type refreshMsg struct { response *OWMResponse err error } // refreshData fetches new data func (m *Model) refreshData() tea.Cmd { return func() tea.Msg { client := NewFMIClient() response, err := client.GetForecast(m.location) return refreshMsg{response: response, err: err} } }