summaryrefslogtreecommitdiffstats
path: root/tui_model.go
diff options
context:
space:
mode:
Diffstat (limited to 'tui_model.go')
-rw-r--r--tui_model.go708
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}
+ }
+}