use std::{ cell::OnceCell, future::Future, rc::Rc, sync::atomic::{AtomicUsize, Ordering}, }; use openweathermap_lib::{ forecast::{ForecastClient, ForecastResponse}, location::Location, weather::{WeatherClient, WeatherResponse}, }; use rand::{rngs::OsRng, seq::IndexedRandom, TryRngCore}; #[cfg(target_arch = "wasm32")] use ratzilla::ratatui; use dotenvy_macro::dotenv; use ratatui::{ layout::{Constraint, Direction, Layout, Margin, Rect}, style::{Color, Style, Stylize}, widgets::{Block, Borders, List, ListState, Paragraph, Tabs}, Frame, }; use tachyonfx::{fx, Effect, Interpolation, Shader}; use web_time::Instant; use crate::app::{ password_locker::PasswordLocker, weather_icon::{WeatherIcon, WEATHER_ICONS, WEATHER_ICONS_SMALL}, }; mod password_locker; mod weather_icon; #[derive(Default, Debug, Clone)] struct SelectedTab { currently_selected: Rc, transitioning_to: Rc, } impl Shader for SelectedTab { fn name(&self) -> &'static str { "selected_tab" } fn execute( &mut self, _duration: tachyonfx::Duration, _area: Rect, _buf: &mut ratatui::prelude::Buffer, ) { self.currently_selected.store( self.transitioning_to.load(Ordering::Relaxed), Ordering::Relaxed, ); } fn done(&self) -> bool { self.currently_selected.load(Ordering::Relaxed) == self.transitioning_to.load(Ordering::Relaxed) } fn clone_box(&self) -> Box { Box::new(self.clone()) } fn area(&self) -> Option { None } fn set_area(&mut self, _area: Rect) {} fn filter(&mut self, _filter: tachyonfx::CellFilter) {} } pub trait AppExecutor { fn execute( &self, future: impl Future + 'static, output_cell: Rc>, ); } pub struct App { tabs: [&'static str; 3], transition_instant: Instant, selected_tab: SelectedTab, password_locker: PasswordLocker, current_effect: Effect, forecast_state: ListState, weather: Rc>, executor: E, } const SPLASH: &str = r#" !~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ !~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ !!~~~~~~!J5PPPPPPPPPPP?^^^^7PPPPPPPPPPPPPPPPY~^^^^ !!!!!!~~~!JB&@@@@@@@@@5^^^^Y@@@@@@@@@@@@@@@@#~^^^^ !!!!!5?!~~~!JG&@@@@@@@5^^^^Y@@@@@@@@@@@@@@@@#~^^^^ !!!!7#&GJ!~~~!?G&@@@@@5^^^^Y@@@@@@@@@@@@@@@@#~^^^^ 7!!!7#@@&BJ!~~~!?G&@@@5^^^^Y@@@@@@@@@@@@@@@@#~^^^^ 7!!!7#@@@@@BY!~~~~?P&@5~^^^Y@@@@@@@@@@@@@@@@#~^^^^ 777!7#@@@@@@@BY!~~~~7PY~~~^Y@@@@@@@@@@@@@@@@#~^^^^ 7777?#@@@@@@@@@#Y7~~~~!~~~~Y@@@@@@@@@@@@@@@@#~^^^^ 7777?#@@@@@@@@@@@#57~~~~~~~Y@@@@@@@@@@@@@@@@#~^^^^ ?777?#@@@@@@@@@@@@@#57~~~~~?#@@@@@@@@@@@@@@@#~^^^^ ?777?#@@@@@@@@@@@@@@@#J~~~~~!Y#@@@@@@@@@@@@@#~^^^^ ???7?#@@@@@@@@@@@@@@@@P!!~~~~~!YB@@@@@@@@@@@#~^^^^ ?????#@@@@@@@@@@@@@@@@P!!!!!!~~~!YB@@@@@@@@@#~^^^^ ????J&@@@@@@@@@@@@@@@@P!!!!YG?!~~~!JB&@@@@@@#~^^^^ ????J&@@@@@@@@@@@@@@@@P!!!!5@&G?!~~~!JG&@@@@#~^^^^ J???J&@@@@@@@@@@@@@@@@P!!!!5@@@&GJ!~~~!?G&@@#!^^^^ J???J&@@@@@@@@@@@@@@@@P7!!!5@@@@@&BJ!~~~!?G&#!^^^~ JJJ?J&@@@@@@@@@@@@@@@@P777!5@@@@@@@@BY!~~~~?5!~~^~ JJJJJ&@@@@@@@@@@@@@@@@P77775@@@@@@@@@@BY!~~~~~~~~~ JJJJJGBBGGGGGGGGGGGGGGY7777JGGGGGGGPPPPPY!~~~~~~~~ YJJJJJJJJJ????????????77777777777!!!!!!!!!!!~~~~~~ YJJJJJJJJJJJ???????????77777777777!!!!!!!!!!!~~~~~ "#; const TABS: [&'static str; 10] = [ "Doohickies", "Gadgets", "Gizmos", "Inators", "Thingamabobs", "Thingamajigs", "Doodads", "Dingies", "Trinkets", "Goobers", ]; impl App { pub fn new(executor: E) -> Self { let weather = Rc::new(OnceCell::new()); executor.execute( async move { let location = Location { zip: String::from("84111"), name: String::from("Salt Lake City"), lat: 40.7660851712019, lon: -111.89066476757807, country: String::from("US"), }; let units = "imperial".to_owned(); let api_key = dotenv!("OPENWEATHERMAP_API_KEY").to_owned(); let weather_client = WeatherClient::new(location.clone(), units.clone(), api_key.clone()); let forecast_client = ForecastClient::new(location, units, api_key, None); futures_util::future::try_join( weather_client.get_current_weather(), forecast_client.get_current_forecast(), ) .await .unwrap() }, Rc::clone(&weather), ); Self { tabs: TABS.choose_multiple_array(&mut OsRng.unwrap_err()).unwrap(), transition_instant: Instant::now(), selected_tab: SelectedTab::default(), password_locker: PasswordLocker::default(), current_effect: fx::fade_from_fg(Color::Black, (0, Interpolation::CircIn)), forecast_state: ListState::default(), weather, executor, } } pub fn draw(&mut self, frame: &mut Frame) { let area = frame.area(); let layout = Layout::new( Direction::Vertical, [Constraint::Length(3), Constraint::Min(0)], ) .split(area); if self.password_locker.unlocked() { frame.render_widget( Tabs::new(self.tabs) .select(self.selected_tab.transitioning_to.load(Ordering::Relaxed)) .fg(Color::Rgb(226, 190, 89)) .bg(Color::Black) .highlight_style(Style::new().bold()) .block(Block::new().borders(Borders::all()).title("Coming soon")), layout[0], ); match self.selected_tab.currently_selected.load(Ordering::Relaxed) { 0 => self.draw_first_tab(frame, layout[1]), 1 | 2 => self.draw_second_tab(frame, layout[1]), _ => {} } } frame.render_widget(&mut self.password_locker, area); if let Some(cursor_position) = self.password_locker.cursor_position() { if self.password_locker.current_effect_done() { frame.set_cursor_position(cursor_position); } } self.current_effect.process( self.transition_instant.elapsed().into(), frame.buffer_mut(), layout[1], ); } pub fn draw_first_tab(&mut self, frame: &mut Frame, layout_outer: Rect) { let first_tab_layout = Layout::new( if layout_outer.width < (layout_outer.height * 2) { Direction::Vertical } else { Direction::Horizontal }, [Constraint::Ratio(1, 2), Constraint::Ratio(1, 2)], ) .split(layout_outer); let weather_area = first_tab_layout[0]; if let Some(weather) = self.weather.get() { frame.render_widget( Block::new() .title("Weather") .borders(Borders::all()) .fg(Color::Rgb(226, 190, 89)) .bg(Color::Black), weather_area, ); let layout = Layout::new( Direction::Vertical, [Constraint::Length(12), Constraint::Min(0)], ) .split(weather_area.inner(Margin::new(2, 2))); let layout_upper = Layout::new( Direction::Horizontal, [Constraint::Length(32), Constraint::Min(0)], ) .split(layout[0]); frame.render_widget( WeatherIcon( WEATHER_ICONS .get(&weather.0.weather[0].icon[..weather.0.weather[0].icon.len() - 1]) .unwrap(), weather.0.weather[0].icon[weather.0.weather[0].icon.len() - 1..] .bytes() .next() .unwrap(), ), layout_upper[0], ); frame.render_widget( Paragraph::new(format!( "{} {}°F\n~{}°F -{}°F +{}°F\n{} {} {}mph\nVisibility: {}km", weather.0.name, weather.0.main.temp.unwrap_or(f64::NAN), weather.0.main.feels_like.unwrap_or(f64::NAN), weather.0.main.temp_min.unwrap_or(f64::NAN), weather.0.main.temp_max.unwrap_or(f64::NAN), weather.0.weather[0].main, match weather.0.wind.deg { 0..=45 => "🡓", 46..=90 => "🡗", 91..=135 => "🡐", 136..=180 => "🡔", 181..=225 => "🡑", 226..=270 => "🡕", 271..=315 => "🡒", _ => "🡖", }, weather.0.wind.speed, weather.0.visibility as f64 / 1000.0, )) .fg(Color::Rgb(226, 190, 89)) .bg(Color::Black), layout_upper[1], ); frame.render_stateful_widget( List::new(weather.1.list.iter().map(|item| { format!( "{} {:<20} {:<6}°F ~{:<6}°F {}", WEATHER_ICONS_SMALL .get(&item.weather[0].icon[..item.weather[0].icon.len() - 1]) .unwrap(), item.weather[0].description.as_str(), item.main.temp.unwrap_or(f64::NAN), item.main.temp_max.unwrap_or(f64::NAN), item.dt_txt ) })) .block(Block::new().title("5-day forecast").borders(Borders::all())) .highlight_symbol("> "), layout[1], &mut self.forecast_state, ); } else { frame.render_widget( Paragraph::new("Loading") .alignment(ratatui::layout::Alignment::Center) .fg(Color::Rgb(226, 190, 89)) .bg(Color::Black) .block(Block::new().title("Weather").borders(Borders::all())), weather_area, ); } frame.render_widget( Paragraph::new(SPLASH) .alignment(ratatui::layout::Alignment::Center) .fg(Color::Rgb(226, 190, 89)) .bg(Color::Black) .block(Block::new().borders(Borders::all())), first_tab_layout[1], ); } pub fn draw_second_tab(&mut self, frame: &mut Frame, layout: Rect) { frame.render_widget( Block::new() .borders(Borders::all()) .fg(Color::Rgb(226, 190, 89)) .bg(Color::Black), layout, ); } pub fn add_char_to_number_guess(&mut self, c: char) { self.password_locker.add_char_to_number_guess(c); } pub fn next_tab(&mut self) { if self .selected_tab .transitioning_to .fetch_add(1, Ordering::Relaxed) == self.tabs.len() - 1 { self.selected_tab .transitioning_to .store(0, Ordering::Relaxed); } self.tab_transition(); } pub fn prev_tab(&mut self) { if self .selected_tab .transitioning_to .fetch_sub(1, Ordering::Relaxed) == 0 { self.selected_tab .transitioning_to .store(self.tabs.len() - 1, Ordering::Relaxed); } self.tab_transition(); } pub fn next_item(&mut self) { self.forecast_state.select_next(); } pub fn prev_item(&mut self) { self.forecast_state.select_previous(); } fn tab_transition(&mut self) { self.transition_instant = Instant::now(); self.current_effect = fx::sequence(&[ fx::dissolve((5000, Interpolation::Linear)), Effect::new(self.selected_tab.clone()), fx::coalesce((10000, Interpolation::Linear)), ]); } }