use crate::modules::types::{
DailyForecast, HourlyForecast, Location, WeatherCondition, WeatherConfig,
};
use crate::modules::ui::convert_to_local;
use anyhow::Result;
use crossterm::{
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{
backend::CrosstermBackend,
layout::{Constraint, Direction, Layout},
style::{Color, Modifier, Style},
text::{Line, Span, Text},
widgets::{Block, BorderType, Borders, Paragraph, Tabs, Wrap},
Terminal,
};
use std::io;
use std::io::Stdout;
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
pub enum TuiTab {
CurrentWeather,
WeatherForecast,
Calendar,
}
impl TuiTab {
fn next(&self) -> Self {
match self {
TuiTab::CurrentWeather => TuiTab::WeatherForecast,
TuiTab::WeatherForecast => TuiTab::Calendar,
TuiTab::Calendar => TuiTab::CurrentWeather,
}
}
fn prev(&self) -> Self {
match self {
TuiTab::CurrentWeather => TuiTab::Calendar,
TuiTab::WeatherForecast => TuiTab::CurrentWeather,
TuiTab::Calendar => TuiTab::WeatherForecast,
}
}
fn to_string(self) -> &'static str {
match self {
TuiTab::CurrentWeather => "Current Weather",
TuiTab::WeatherForecast => "Weather Forecast",
TuiTab::Calendar => "Weather Calendar",
}
}
}
struct UiState {
active_tab: TuiTab,
hourly_data: Vec<HourlyForecast>,
daily_data: Vec<DailyForecast>,
location: Location,
config: WeatherConfig,
}
pub struct WeatherTui {
state: UiState,
terminal: Terminal<CrosstermBackend<Stdout>>,
}
impl WeatherTui {
pub fn new(
hourly_data: Vec<HourlyForecast>,
daily_data: Vec<DailyForecast>,
location: Location,
config: WeatherConfig,
) -> Result<Self> {
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout);
let terminal = Terminal::new(backend)?;
let state = UiState {
active_tab: TuiTab::CurrentWeather,
hourly_data,
daily_data,
location,
config,
};
Ok(Self { state, terminal })
}
pub fn run(&mut self) -> Result<()> {
loop {
let active_tab = self.state.active_tab;
let hourly_data = self.state.hourly_data.clone();
let daily_data = self.state.daily_data.clone();
let location = self.state.location.clone();
let config = self.state.config.clone();
self.terminal.draw(|f| {
let chunks = Layout::default()
.direction(Direction::Vertical)
.margin(1)
.constraints(
[
Constraint::Length(3), Constraint::Length(3), Constraint::Min(0), Constraint::Length(3), ]
.as_ref(),
)
.split(f.area());
let units_text = match config.units.as_str() {
"metric" => "°C",
"imperial" => "°F",
_ => "K",
};
let title = Paragraph::new(Text::from(vec![Line::from(vec![
Span::styled(
format!("Weather Man - {}", location.name),
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::styled(
format!("[{}, {}]", location.country, location.country_code),
Style::default().fg(Color::Gray),
),
Span::raw(" "),
Span::styled(
format!("({})", units_text),
Style::default().fg(Color::Yellow),
),
])]))
.block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.style(Style::default().fg(Color::Cyan)),
);
f.render_widget(title, chunks[0]);
let titles = [
TuiTab::CurrentWeather,
TuiTab::WeatherForecast,
TuiTab::Calendar,
]
.iter()
.map(|t| {
let (first, rest) = t.to_string().split_at(1);
Line::from(vec![
Span::styled(
first,
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::UNDERLINED),
),
Span::styled(rest, Style::default().fg(Color::White)),
])
})
.collect::<Vec<_>>();
let tabs = Tabs::new(titles)
.block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.title("Tabs")
.style(Style::default().fg(Color::Cyan)),
)
.select(match active_tab {
TuiTab::CurrentWeather => 0,
TuiTab::WeatherForecast => 1,
TuiTab::Calendar => 2,
})
.style(Style::default().fg(Color::White))
.highlight_style(
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
);
f.render_widget(tabs, chunks[1]);
match active_tab {
TuiTab::CurrentWeather => {
use crate::modules::canvas::render_current_weather_canvas;
render_current_weather_canvas(&hourly_data, f, chunks[2]);
}
TuiTab::WeatherForecast => {
use crate::modules::canvas::render_forecast_canvas;
render_forecast_canvas(&daily_data, f, chunks[2]);
}
TuiTab::Calendar => {
render_weather_calendar(&daily_data, &location, f, chunks[2]);
}
}
let help_text = Text::from(vec![Line::from(vec![
Span::styled("Keys: ", Style::default().fg(Color::Cyan)),
Span::styled("←/→", Style::default().fg(Color::Yellow)),
Span::raw(" Switch tabs | "),
Span::styled("1-3", Style::default().fg(Color::Yellow)),
Span::raw(" Select tab | "),
Span::styled("q", Style::default().fg(Color::Yellow)),
Span::raw(" Quit | "),
Span::styled("ESC", Style::default().fg(Color::Yellow)),
Span::raw(" Exit weather view"),
])]);
let help = Paragraph::new(help_text)
.block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.style(Style::default().fg(Color::Cyan)),
)
.wrap(Wrap { trim: true });
f.render_widget(help, chunks[3]);
})?;
match event::read()? {
Event::Key(key) => {
if key.kind == KeyEventKind::Press {
match key.code {
KeyCode::Char('q') | KeyCode::Esc => {
break;
}
KeyCode::Right | KeyCode::Tab => {
self.state.active_tab = self.state.active_tab.next();
}
KeyCode::Left | KeyCode::BackTab => {
self.state.active_tab = self.state.active_tab.prev();
}
KeyCode::Char('1') => {
self.state.active_tab = TuiTab::CurrentWeather;
}
KeyCode::Char('2') => {
self.state.active_tab = TuiTab::WeatherForecast;
}
KeyCode::Char('3') => {
self.state.active_tab = TuiTab::Calendar;
}
_ => {}
}
}
}
_ => {
}
}
}
disable_raw_mode()?;
execute!(
self.terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)?;
self.terminal.show_cursor()?;
Ok(())
}
}
fn render_weather_calendar(
daily_data: &[DailyForecast],
location: &Location,
frame: &mut ratatui::Frame,
area: ratatui::layout::Rect,
) {
let mut calendar_text = Vec::new();
calendar_text.push(Line::from(vec![Span::styled(
"7-Day Weather Calendar",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)]));
calendar_text.push(Line::from(vec![Span::raw("")]));
for day in daily_data.iter().take(7) {
let local_date = convert_to_local(&day.date, &location.timezone);
let weekday = local_date.format("%A").to_string();
let date_str = local_date.format("%m/%d").to_string();
let condition_emoji = day.main_condition.get_emoji();
let color = match day.main_condition {
WeatherCondition::Clear => Color::Yellow,
WeatherCondition::Clouds => Color::Gray,
WeatherCondition::Rain | WeatherCondition::Drizzle => Color::Blue,
WeatherCondition::Thunderstorm => Color::Magenta,
WeatherCondition::Snow => Color::White,
_ => Color::Gray,
};
let pop_percent = (day.pop * 100.0) as u8;
calendar_text.push(Line::from(vec![
Span::styled(format!("{:9}", weekday), Style::default().fg(Color::Cyan)),
Span::raw(" "),
Span::styled(date_str, Style::default().fg(Color::Gray)),
Span::raw(" "),
Span::styled(condition_emoji, Style::default()),
Span::raw(" "),
Span::styled(
format!("{}", day.main_condition),
Style::default().fg(color),
),
Span::raw(" "),
Span::styled(
format!("{}°-{}°C", day.temp_min as i32, day.temp_max as i32),
Style::default().fg(Color::White),
),
Span::raw(" "),
Span::styled(
format!("{}%", pop_percent),
Style::default().fg(Color::Blue),
),
]));
}
calendar_text.push(Line::from(vec![Span::raw("")]));
calendar_text.push(Line::from(vec![
Span::styled("Legend: ", Style::default().fg(Color::Gray)),
Span::styled("Temperature Range", Style::default().fg(Color::White)),
Span::raw(" | "),
Span::styled("Rain %", Style::default().fg(Color::Blue)),
]));
let calendar = Paragraph::new(calendar_text)
.block(
Block::default()
.title("Weather Calendar")
.borders(Borders::ALL)
.style(Style::default().fg(Color::Cyan)),
)
.wrap(Wrap { trim: false });
frame.render_widget(calendar, area);
}
impl Drop for WeatherTui {
fn drop(&mut self) {
let _ = disable_raw_mode();
let _ = execute!(
self.terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
);
let _ = self.terminal.show_cursor();
println!();
}
}