diff options
| author | Petri Hienonen <petri.hienonen@gmail.com> | 2026-02-01 22:15:14 +0200 |
|---|---|---|
| committer | Petri Hienonen <petri.hienonen@gmail.com> | 2026-02-01 22:15:14 +0200 |
| commit | 50541ca317e2ae647fc83d25e38825bb5a120296 (patch) | |
| tree | 16bb87964a1809a83bb69db8ac69e12f457862b5 /tui_model.go | |
| parent | b99d904cd2b37feb129a6d747d1ce422d31cffba (diff) | |
| download | weather-50541ca317e2ae647fc83d25e38825bb5a120296.tar.zst | |
Add initial TUI
Diffstat (limited to 'tui_model.go')
| -rw-r--r-- | tui_model.go | 708 |
1 files changed, 708 insertions, 0 deletions
diff --git a/tui_model.go b/tui_model.go new file mode 100644 index 0000000..5416a39 --- /dev/null +++ b/tui_model.go @@ -0,0 +1,708 @@ +// 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} + } +} |
