From 450183c06b30675c7666a2878f3548cf1057eb28 Mon Sep 17 00:00:00 2001 From: Isaac Mills Date: Mon, 28 Jul 2025 17:43:41 -0600 Subject: [PATCH] Add weather data to cockpit --- thecockpit/Cargo.toml | 4 +- thecockpit/src/app/mod.rs | 174 +++++++++++------ thecockpit/src/app/weather_icon.rs | 293 +++++++++++++++++++++++++++++ thecockpit/src/main.rs | 6 +- thecockpit/src/web/mod.rs | 12 +- 5 files changed, 419 insertions(+), 70 deletions(-) create mode 100644 thecockpit/src/app/weather_icon.rs diff --git a/thecockpit/Cargo.toml b/thecockpit/Cargo.toml index aad0687..356c209 100644 --- a/thecockpit/Cargo.toml +++ b/thecockpit/Cargo.toml @@ -43,9 +43,9 @@ dotenvy_macro = "0.15.7" futures-util = "0.3.31" getrandom = { version = "0.3.3", features = ["wasm_js"] } heapless = "0.8.0" +openweathermap_lib = { git = "https://github.com/StratusFearMe21/rusty-openweathermap-library", branch = "wasm_deps" } +phf = { version = "0.12.1", features = ["macros"] } rand = { version = "0.9.2", default-features = false, features = ["os_rng", "std"] } -reqwest = { version = "0.12.22", features = ["json"] } -serde = { version = "1.0.219", features = ["derive"] } tachyonfx = { version = "0.16.0", default-features = false, features = ["web-time"] } web-time = "1.1.0" diff --git a/thecockpit/src/app/mod.rs b/thecockpit/src/app/mod.rs index e16a028..5b4f252 100644 --- a/thecockpit/src/app/mod.rs +++ b/thecockpit/src/app/mod.rs @@ -1,30 +1,35 @@ use std::{ + cell::OnceCell, future::Future, rc::Rc, - sync::{ - atomic::{AtomicUsize, Ordering}, - mpsc::{self, Receiver, Sender}, - }, + sync::atomic::{AtomicUsize, Ordering}, }; +use openweathermap_lib::{ + 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, Rect}, + layout::{Constraint, Direction, Layout, Margin, Rect}, style::{Color, Style, Stylize}, widgets::{Block, Borders, Paragraph, Tabs}, Frame, }; -use serde::Deserialize; use tachyonfx::{fx, Effect, Interpolation, Shader}; use web_time::Instant; -use crate::app::password_locker::PasswordLocker; +use crate::app::{ + password_locker::PasswordLocker, + weather_icon::{WeatherIcon, DAY_ICONS}, +}; mod password_locker; +mod weather_icon; #[derive(Default, Debug, Clone)] struct SelectedTab { @@ -68,32 +73,11 @@ impl Shader for SelectedTab { } pub trait AppExecutor { - fn execute(&self, future: impl Future + 'static, sender: Sender); -} - -pub struct Pending { - rx: Receiver, - resolved: Option, -} - -impl Pending { - pub fn new(rx: Receiver) -> Self { - Self { rx, resolved: None } - } - - pub fn resolved(&mut self) -> &Option { - if self.resolved.is_some() { - return &self.resolved; - } - - self.resolved = self.rx.try_recv().ok(); - &self.resolved - } -} - -#[derive(Deserialize, Debug)] -pub struct WeatherInfo { - name: String, + fn execute( + &self, + future: impl Future + 'static, + output_cell: Rc>, + ); } pub struct App { @@ -102,7 +86,7 @@ pub struct App { selected_tab: SelectedTab, password_locker: PasswordLocker, current_effect: Effect, - weather: Pending, + weather: Rc>, executor: E, } @@ -148,24 +132,31 @@ const TABS: [&'static str; 10] = [ impl App { pub fn new(executor: E) -> Self { - let (weather_tx, weather_rx) = mpsc::channel(); - executor.execute(async move { - let weather_info: WeatherInfo = reqwest::get(concat!("https://api.openweathermap.org/data/2.5/weather?lat=40.7660851712019&lon=-111.89066476757807&appid=", dotenv!("OPENWEATHERMAP_API_KEY"))) - .await - .unwrap() - .json() - .await - .unwrap(); - weather_info - }, - weather_tx); + let weather = Rc::new(OnceCell::new()); + executor.execute( + async move { + let weather_client = WeatherClient::new( + Location { + zip: String::from("84111"), + name: String::from("Salt Lake City"), + lat: 40.7660851712019, + lon: -111.89066476757807, + country: String::from("US"), + }, + "metric".to_owned(), + dotenv!("OPENWEATHERMAP_API_KEY").to_owned(), + ); + weather_client.get_current_weather().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)), - weather: Pending::new(weather_rx), + weather, executor, } } @@ -211,26 +202,85 @@ impl App { ); } - pub fn draw_first_tab(&mut self, frame: &mut Frame, layout: Rect) { + pub fn draw_first_tab(&mut self, frame: &mut Frame, layout_outer: Rect) { let layout = Layout::new( - Direction::Horizontal, + if layout_outer.width < (layout_outer.height * 2) { + Direction::Vertical + } else { + Direction::Horizontal + }, [Constraint::Ratio(1, 2), Constraint::Ratio(1, 2)], ) - .split(layout); - frame.render_widget( - Paragraph::new( - self.weather - .resolved() - .as_ref() - .map(|weather| weather.name.as_str()) - .unwrap_or("Loading"), + .split(layout_outer); + 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), + layout[0], + ); + + let layout = Layout::new( + Direction::Vertical, + [Constraint::Length(12), Constraint::Min(0)], ) - .alignment(ratatui::layout::Alignment::Center) - .fg(Color::Rgb(226, 190, 89)) - .bg(Color::Black) - .block(Block::new().title("Weather").borders(Borders::all())), - layout[0], - ); + .split(layout[0].inner(Margin::new(2, 2))); + + let layout_upper = Layout::new( + Direction::Horizontal, + [Constraint::Ratio(1, 2), Constraint::Ratio(1, 2)], + ) + .split(layout[0]); + + frame.render_widget( + WeatherIcon(DAY_ICONS.get(&weather.weather[0].icon).unwrap()), + layout_upper[0], + ); + frame.render_widget( + Paragraph::new(format!( + "{} {}°C\n~{}°C -{}°C +{}°C\n{} {} {}m/s SSE\nVisibility: {}km", + weather.name, + weather.main.temp.unwrap_or(f64::NAN), + weather.main.feels_like.unwrap_or(f64::NAN), + weather.main.temp_min.unwrap_or(f64::NAN), + weather.main.temp_max.unwrap_or(f64::NAN), + weather.weather[0].main, + match weather.wind.deg { + 0..=45 => "🡓", + 46..=90 => "🡗", + 91..=135 => "🡐", + 136..=180 => "🡔", + 181..=225 => "🡑", + 226..=270 => "🡕", + 271..=315 => "🡒", + _ => "🡖", + }, + weather.wind.speed, + weather.visibility as f64 / 1000.0, + )) + .fg(Color::Rgb(226, 190, 89)) + .bg(Color::Black), + layout_upper[1], + ); + frame.render_widget( + Block::new() + .borders(Borders::all()) + .fg(Color::Rgb(226, 190, 89)) + .bg(Color::Black), + layout[1], + ); + } 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())), + layout[0], + ); + } frame.render_widget( Paragraph::new(SPLASH) .alignment(ratatui::layout::Alignment::Center) diff --git a/thecockpit/src/app/weather_icon.rs b/thecockpit/src/app/weather_icon.rs new file mode 100644 index 0000000..3b79e7e --- /dev/null +++ b/thecockpit/src/app/weather_icon.rs @@ -0,0 +1,293 @@ +use phf::phf_map; +use ratatui::{style::Color, widgets::Widget}; + +pub const DAY_ICONS: phf::Map<&'static str, [&'static str; 3]> = phf_map! { + "01d" => [ + "", + "", + r#" + +========== + ================ + +=================== + +===================== ++======================= +========================= +========================= +========================+ +======================== + ======================= + ===================== + +================= + ============+ +"#, + ], + "02d" => [ + "", + r#" + ...... + .......... + ............ + ................. + ...................... + ........................ +............................. +.............................. +.............................. + ............................. + ......................... +"#, + r#" + ========== + ============== + =+============= + -=============+ + :+======== + +======= + +++==== + += + + + + +"#, + ], + "03d" => [ + "", + r#" + ......... + ............ + .................. + .................... + ........................ + ............................ +.............................. +.............................. + ............................. + ........................ +"#, + "", + ], + "04d" => [ + r#" + ######## + %######### + :############# + ############# + =:..:########## + =########## + -%######### + +##### + % + + + +"#, + r#" + + :... + ......... + ............ + ............ + :.................... + ....................... +............................ +.............................. +.............................. + ............................ + ........................ +"#, + "", + ], + "09d" => [ + r#" + ####### + -%######### + +########## + -..=######## + :%####### + :-###### + -### + + := + ## % + ## ## ## + ### ### + ### ## +"#, + r#" + + ..... + ......... + .......... + ................. +.................... +....................... +........................ + ......:=.............. + + + + +"#, + "", + ], + "10d" => [ + r#" + + + + + + + + + + ## % + ## ## ## + ### *## + ### ## +"#, + r#" + + ...... + ......... + ..........:.. + .................. +..................... +........................ +........................ + ......:+.............. + + + + +"#, + r#" + =========+ + +=========== + -+========== + :+====== + +===== + :=+=+ + + + + + + + +"#, + ], + "11d" => [ + r#" + ###### + ######### + %########### + +############ + *-:-########## + -########## + :%%######## + +##### + + + + + + + +"#, + r#" + + + ....... + .......... + ............ + .................. + ...................... +........................... +............................. + ......... ............... + ....... ................ + + + + +"#, + r#" + + + + + + + + + + +=+: + +=+ + +====+ + === + +=+ + += +"#, + ], + "13d" => [ + r#" + ## + # ######## # + ##### ###### ### # + ###### ## ###### +#################### + # %## ### # +#################### + ##### ## ##### + ##### #### ##### + ############## + ## ## ## +"#, + "", + "", + ], + "50d" => [ + r#" + ########## + + ################# + #################### + %################## +##################### + + ##################### + ############## +"#, + "", + "", + ], +}; + +pub struct WeatherIcon(pub &'static [&'static str; 3]); + +impl Widget for WeatherIcon { + fn render(self, area: ratatui::prelude::Rect, buf: &mut ratatui::prelude::Buffer) + where + Self: Sized, + { + for (layer, color) in [ + (0, Color::Rgb(72, 72, 72)), + (1, Color::Rgb(242, 242, 242)), + (2, Color::Rgb(236, 110, 76)), + ] { + for (y, line) in self.0[layer].lines().skip(1).enumerate() { + for (x, byte) in line.bytes().enumerate() { + if byte != b' ' { + let cell = buf + .cell_mut((area.x + x as u16, area.y + y as u16)) + .unwrap(); + cell.fg = color; + cell.set_symbol(unsafe { std::str::from_utf8_unchecked(&[byte]) }); + } + } + } + } + } +} diff --git a/thecockpit/src/main.rs b/thecockpit/src/main.rs index c61fe32..482f65c 100644 --- a/thecockpit/src/main.rs +++ b/thecockpit/src/main.rs @@ -1,4 +1,4 @@ -use std::{future::Future, time::Duration}; +use std::{cell::OnceCell, future::Future, rc::Rc, time::Duration}; use futures_util::FutureExt; use ratatui::crossterm::event; @@ -10,9 +10,9 @@ impl AppExecutor for TokioExecutor { fn execute( &self, future: impl Future + 'static, - sender: std::sync::mpsc::Sender, + output_cell: Rc>, ) { - tokio::task::spawn_local(future.map(move |output| sender.send(output).unwrap())); + tokio::task::spawn_local(future.map(move |output| output_cell.set(output))); } } diff --git a/thecockpit/src/web/mod.rs b/thecockpit/src/web/mod.rs index dbc3daf..16d0d6b 100644 --- a/thecockpit/src/web/mod.rs +++ b/thecockpit/src/web/mod.rs @@ -1,4 +1,8 @@ -use std::{cell::RefCell, future::Future, rc::Rc}; +use std::{ + cell::{OnceCell, RefCell}, + future::Future, + rc::Rc, +}; use futures_util::FutureExt; use ratzilla::{ @@ -22,9 +26,11 @@ impl AppExecutor for WasmBindgenExecutor { fn execute( &self, future: impl Future + 'static, - sender: std::sync::mpsc::Sender, + output_cell: Rc>, ) { - wasm_bindgen_futures::spawn_local(future.map(move |output| sender.send(output).unwrap())); + wasm_bindgen_futures::spawn_local(future.map(move |output| { + let _ = output_cell.set(output); + })); } }