// tui_model.go - Fixed version 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 { var sections []string // Header with location and current conditions sections = append(sections, m.renderHeader()) // Current weather sections = append(sections, m.renderCurrentWeather()) // Hourly or daily forecast if m.showHourly { sections = append(sections, m.renderHourlyForecast()) } else { sections = append(sections, m.renderDailyForecast()) } // Weather details sections = append(sections, m.renderDetails()) // Combine sections with spacing return lipgloss.JoinVertical(lipgloss.Left, sections..., ) } func (m Model) renderHeader() string { current := m.response.Current // 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) title := lipgloss.NewStyle(). Foreground(lipgloss.Color("15")). Bold(true). Padding(0, 1). Render(fmt.Sprintf("%s • %s", m.location, description)) tempStyle := lipgloss.NewStyle(). Foreground(lipgloss.Color("39")). Bold(true). Render(temp) feelsStyle := lipgloss.NewStyle(). Foreground(lipgloss.Color("246")). Render(fmt.Sprintf("Feels like %s", feelsLike)) // Get timezone-aware current time loc, _ := time.LoadLocation(m.response.Timezone) currentTime := time.Now().In(loc).Format("15:04 MST") timeStyle := lipgloss.NewStyle(). Foreground(lipgloss.Color("245")). Italic(true). Render(currentTime) header := lipgloss.JoinVertical(lipgloss.Left, title, "", lipgloss.JoinHorizontal(lipgloss.Center, tempStyle, lipgloss.NewStyle().Width(4).Render(""), feelsStyle, ), "", timeStyle, ) return lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(lipgloss.Color("240")). Padding(1, 2). Margin(0, 0, 1, 0). Render(header) } func (m Model) renderCurrentWeather() string { current := m.response.Current // Left column: Basic metrics leftColumn := []string{ m.renderMetric("💧 Humidity", fmt.Sprintf("%d%%", current.Humidity)), m.renderMetric("💨 Wind", fmt.Sprintf("%.1f km/h", current.WindSpeed*3.6)), m.renderMetric("🧭 Direction", fmt.Sprintf("%d°", current.WindDeg)), m.renderMetric("🌀 Pressure", fmt.Sprintf("%d hPa", current.Pressure)), } // Right column: Advanced metrics rightColumn := []string{ m.renderMetric("🌫️ Dew Point", fmt.Sprintf("%.1f°C", current.DewPoint)), m.renderMetric("👁️ Visibility", fmt.Sprintf("%d m", current.Visibility)), m.renderMetric("☁️ Clouds", fmt.Sprintf("%d%%", current.Clouds)), m.renderMetric("☀️ UV Index", fmt.Sprintf("%.1f", current.Uvi)), } // Sunrise/Sunset sunrise := time.Unix(current.Sunrise, 0).Format("15:04") sunset := time.Unix(current.Sunset, 0).Format("15:04") sunInfo := lipgloss.JoinHorizontal(lipgloss.Center, lipgloss.NewStyle().Foreground(lipgloss.Color("226")).Render("☀️ "+sunrise), lipgloss.NewStyle().Width(10).Render(""), lipgloss.NewStyle().Foreground(lipgloss.Color("69")).Render("🌙 "+sunset), ) columns := lipgloss.JoinHorizontal(lipgloss.Top, lipgloss.JoinVertical(lipgloss.Left, leftColumn...), lipgloss.NewStyle().Width(10).Render(""), lipgloss.JoinVertical(lipgloss.Left, rightColumn...), ) return lipgloss.NewStyle(). Padding(0, 1). Margin(0, 0, 1, 0). Render(lipgloss.JoinVertical(lipgloss.Left, columns, "", sunInfo, )) } func (m Model) renderHourlyForecast() string { // Take next 24 hours or available data hours := min(24, len(m.response.Hourly)) if hours == 0 { return "" } // Find min/max for scaling minTemp := math.MaxFloat64 maxTemp := -math.MaxFloat64 maxPrecip := 0.0 for i := 0; i < hours; i++ { hour := m.response.Hourly[i] if hour.Temp < minTemp { minTemp = hour.Temp } if hour.Temp > maxTemp { maxTemp = hour.Temp } if hour.Rain != nil && hour.Rain.OneH > maxPrecip { maxPrecip = hour.Rain.OneH } } tempRange := maxTemp - minTemp if tempRange == 0 { tempRange = 1 } var bars []string var times []string var temps []string barHeight := 10 for i := 0; i < hours; i++ { hour := m.response.Hourly[i] // Time label t := time.Unix(hour.Dt, 0) timeLabel := t.Format("15") if i == 0 { timeLabel = "Now" } // Temperature bar tempHeight := int(((hour.Temp - minTemp) / tempRange) * float64(barHeight)) bar := "" for j := 0; j < barHeight; j++ { if j < tempHeight { // Gradient color based on temperature color := m.getTempColor(hour.Temp) bar += lipgloss.NewStyle().Foreground(color).Render("▊") } else { bar += " " } } // Precipitation indicator precip := 0.0 if hour.Rain != nil { precip = hour.Rain.OneH } precipChar := " " if precip > 0.1 { precipChar = "•" if precip > 2.5 { precipChar = "●" } } // Weather icon icon := " " if len(hour.Weather) > 0 { icon = m.getWeatherIcon(hour.Weather[0].Icon) } // Temperature label tempLabel := fmt.Sprintf("%.0f°", hour.Temp) bars = append(bars, bar) times = append(times, timeLabel) temps = append(temps, tempLabel) // Add precipitation indicator and weather icon if i < len(bars) { bars[i] = bars[i] + " " + lipgloss.NewStyle().Foreground(lipgloss.Color("69")).Render(precipChar) + " " + icon } } // Create the chart chart := lipgloss.JoinVertical(lipgloss.Right, lipgloss.JoinHorizontal(lipgloss.Bottom, temps...), lipgloss.JoinHorizontal(lipgloss.Top, bars...), lipgloss.JoinHorizontal(lipgloss.Top, times...), ) title := lipgloss.NewStyle(). Foreground(lipgloss.Color("15")). Bold(true). Render("Hourly Forecast") rangeLabel := lipgloss.NewStyle(). Foreground(lipgloss.Color("246")). Render(fmt.Sprintf("%.0f/%.0f°C", maxTemp, minTemp)) header := lipgloss.JoinHorizontal(lipgloss.Left, title, lipgloss.NewStyle().Width(m.width/2).Render(""), rangeLabel, ) return lipgloss.NewStyle(). Padding(0, 1). Margin(0, 0, 1, 0). Render(lipgloss.JoinVertical(lipgloss.Left, header, "", chart, )) } func (m Model) renderDailyForecast() string { // Group by day daily := make(map[string][]Current) loc, _ := time.LoadLocation(m.response.Timezone) for _, hour := range m.response.Hourly { t := time.Unix(hour.Dt, 0).In(loc) day := t.Format("2006-01-02") daily[day] = append(daily[day], hour) } // Sort days var days []string for day := range daily { days = append(days, day) } if len(days) == 0 { return "" } var rows []string for i, day := range days { if i >= m.days { break } hours := daily[day] if len(hours) == 0 { continue } // Calculate day statistics minTemp := math.MaxFloat64 maxTemp := -math.MaxFloat64 var totalPrecip float64 weatherCount := make(map[int]int) for _, hour := range hours { if hour.Temp < minTemp { minTemp = hour.Temp } if hour.Temp > maxTemp { maxTemp = hour.Temp } if hour.Rain != nil { totalPrecip += hour.Rain.OneH } if len(hour.Weather) > 0 { weatherCount[hour.Weather[0].ID]++ } } // Find most common weather commonWeather := Weather{ID: 800, Main: "Clear", Description: "clear sky", Icon: "01d"} maxCount := 0 // Use the global FmiToOwm map from weather_mapper.go for _, weather := range FmiToOwm { count, exists := weatherCount[weather.ID] if exists && count > maxCount { maxCount = count commonWeather = weather // Set day/night icon based on time t, _ := time.Parse("2006-01-02", day) if t.Hour() < 18 && t.Hour() > 6 { commonWeather.Icon = commonWeather.Icon + "d" } else { commonWeather.Icon = commonWeather.Icon + "n" } } } // Format day t, _ := time.Parse("2006-01-02", day) dayLabel := t.Format("Mon 02") if i == 0 { dayLabel = "Today" } else if i == 1 { dayLabel = "Tomorrow" } // Weather icon icon := m.getWeatherIcon(commonWeather.Icon) // Temperature range tempRange := fmt.Sprintf("%.0f/%.0f°C", maxTemp, minTemp) // Precipitation precip := "" if totalPrecip > 0 { precip = fmt.Sprintf("💧 %.1f mm", totalPrecip) } row := lipgloss.JoinHorizontal(lipgloss.Left, lipgloss.NewStyle().Width(12).Render(dayLabel), lipgloss.NewStyle().Width(3).Render(icon), lipgloss.NewStyle().Width(15).Render(commonWeather.Description), lipgloss.NewStyle().Width(12).Render(tempRange), lipgloss.NewStyle().Width(12).Render(precip), ) rows = append(rows, row) } title := lipgloss.NewStyle(). Foreground(lipgloss.Color("15")). Bold(true). Render("Daily Forecast") return lipgloss.NewStyle(). Padding(0, 1). Margin(0, 0, 1, 0). Render(lipgloss.JoinVertical(lipgloss.Left, title, "", lipgloss.JoinVertical(lipgloss.Left, rows...), )) } func (m Model) renderDetails() string { current := m.response.Current details := []string{ m.renderDetailRow("Wind Gust", fmt.Sprintf("%.1f km/h", current.WindGust*3.6)), m.renderDetailRow("Dew Point", fmt.Sprintf("%.1f°C", current.DewPoint)), m.renderDetailRow("Humidity", fmt.Sprintf("%d%%", current.Humidity)), m.renderDetailRow("Pressure", fmt.Sprintf("%d hPa", current.Pressure)), } return lipgloss.NewStyle(). Padding(0, 1). Render(lipgloss.JoinVertical(lipgloss.Left, details...)) } func (m Model) renderMetric(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")). Bold(true). Render(value), ) } func (m Model) renderDetailRow(label, value string) string { return lipgloss.NewStyle(). Foreground(lipgloss.Color("240")). Render(fmt.Sprintf("%s: %s", label, value)) } func (m Model) getTempColor(temp float64) lipgloss.Color { // Color gradient from blue (cold) to red (hot) if temp < -10 { return lipgloss.Color("27") // Dark blue } else if temp < 0 { return lipgloss.Color("39") // Light blue } else if temp < 10 { return lipgloss.Color("50") // Green } else if temp < 20 { return lipgloss.Color("190") // Yellow } else if temp < 30 { return lipgloss.Color("208") // Orange } else { return lipgloss.Color("196") // Red } } func (m Model) getWeatherIcon(icon string) string { // Map OpenWeatherMap icons to Unicode/emoji iconMap := map[string]string{ "01d": "☀️", // clear sky day "01n": "🌙", // clear sky night "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 icon, ok := iconMap[icon]; ok { return icon } 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} } }