diff --git a/thecockpit/.gitignore b/thecockpit/.gitignore index 4e30131..7c35d48 100644 --- a/thecockpit/.gitignore +++ b/thecockpit/.gitignore @@ -4,3 +4,4 @@ Cargo.lock bin/ pkg/ wasm-pack.log +.env diff --git a/thecockpit/Cargo.toml b/thecockpit/Cargo.toml index 0d7401d..aad0687 100644 --- a/thecockpit/Cargo.toml +++ b/thecockpit/Cargo.toml @@ -17,10 +17,12 @@ path = "src/main.rs" required-features = ["ratatui/default"] [target.'cfg(not(target_arch = "wasm32"))'.dependencies] +tokio = { version = "1.47.0", features = ["rt", "macros"] } ratatui = { version = "0.29.0", default-features = false } [target.'cfg(target_arch = "wasm32")'.dependencies] wasm-bindgen = "0.2.84" +wasm-bindgen-futures = "0.4.50" # The `console_error_panic_hook` crate provides better debugging of panics by # logging them with `console.error`. This is great for development, but requires @@ -37,9 +39,13 @@ wasm-bindgen-test = "0.3.34" opt-level = "s" [dependencies] +dotenvy_macro = "0.15.7" +futures-util = "0.3.31" getrandom = { version = "0.3.3", features = ["wasm_js"] } heapless = "0.8.0" 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 fffc904..e16a028 100644 --- a/thecockpit/src/app/mod.rs +++ b/thecockpit/src/app/mod.rs @@ -1,18 +1,24 @@ use std::{ + future::Future, rc::Rc, - sync::atomic::{AtomicUsize, Ordering}, + sync::{ + atomic::{AtomicUsize, Ordering}, + mpsc::{self, Receiver, Sender}, + }, }; 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}, style::{Color, Style, Stylize}, widgets::{Block, Borders, Paragraph, Tabs}, Frame, }; +use serde::Deserialize; use tachyonfx::{fx, Effect, Interpolation, Shader}; use web_time::Instant; @@ -61,12 +67,43 @@ impl Shader for SelectedTab { fn filter(&mut self, _filter: tachyonfx::CellFilter) {} } -pub struct App { +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, +} + +pub struct App { tabs: [&'static str; 3], transition_instant: Instant, selected_tab: SelectedTab, password_locker: PasswordLocker, current_effect: Effect, + weather: Pending, + executor: E, } const SPLASH: &str = r#" @@ -109,14 +146,27 @@ const TABS: [&'static str; 10] = [ "Goobers", ]; -impl App { - pub fn new() -> Self { +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); 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), + executor, } } @@ -162,13 +212,32 @@ impl App { } pub fn draw_first_tab(&mut self, frame: &mut Frame, layout: Rect) { + let layout = Layout::new( + 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"), + ) + .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) .fg(Color::Rgb(226, 190, 89)) .bg(Color::Black) .block(Block::new().borders(Borders::all())), - layout, + layout[1], ); } diff --git a/thecockpit/src/main.rs b/thecockpit/src/main.rs index decc82e..c61fe32 100644 --- a/thecockpit/src/main.rs +++ b/thecockpit/src/main.rs @@ -1,37 +1,63 @@ -use std::time::Duration; +use std::{future::Future, time::Duration}; +use futures_util::FutureExt; use ratatui::crossterm::event; -use thecockpit::app::App; +use thecockpit::app::{App, AppExecutor}; + +struct TokioExecutor; + +impl AppExecutor for TokioExecutor { + fn execute( + &self, + future: impl Future + 'static, + sender: std::sync::mpsc::Sender, + ) { + tokio::task::spawn_local(future.map(move |output| sender.send(output).unwrap())); + } +} fn main() { - let mut terminal = ratatui::init(); - let mut app = App::new(); + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); - loop { - terminal.draw(|frame| app.draw(frame)).unwrap(); + let local = tokio::task::LocalSet::new(); - if event::poll(Duration::from_secs(0)).unwrap() { - match event::read().unwrap() { - event::Event::Key(event::KeyEvent { - code: event::KeyCode::Char('q'), - .. - }) => break, - event::Event::Key(event::KeyEvent { - code: event::KeyCode::Left, - .. - }) => app.prev_tab(), - event::Event::Key(event::KeyEvent { - code: event::KeyCode::Right, - .. - }) => app.next_tab(), - event::Event::Key(event::KeyEvent { - code: event::KeyCode::Char(c), - .. - }) => app.add_char_to_number_guess(c), - _ => {} + local.spawn_local(async { + let mut terminal = ratatui::init(); + let mut app = App::new(TokioExecutor); + + loop { + terminal.draw(|frame| app.draw(frame)).unwrap(); + + if event::poll(Duration::from_secs(0)).unwrap() { + match event::read().unwrap() { + event::Event::Key(event::KeyEvent { + code: event::KeyCode::Char('q'), + .. + }) => break, + event::Event::Key(event::KeyEvent { + code: event::KeyCode::Left, + .. + }) => app.prev_tab(), + event::Event::Key(event::KeyEvent { + code: event::KeyCode::Right, + .. + }) => app.next_tab(), + event::Event::Key(event::KeyEvent { + code: event::KeyCode::Char(c), + .. + }) => app.add_char_to_number_guess(c), + _ => {} + } } - } - } - ratatui::restore(); + tokio::task::yield_now().await; + } + + ratatui::restore(); + }); + + rt.block_on(local); } diff --git a/thecockpit/src/web/mod.rs b/thecockpit/src/web/mod.rs index 4eb575f..dbc3daf 100644 --- a/thecockpit/src/web/mod.rs +++ b/thecockpit/src/web/mod.rs @@ -1,28 +1,36 @@ -use std::{cell::RefCell, rc::Rc}; +use std::{cell::RefCell, future::Future, rc::Rc}; +use futures_util::FutureExt; use ratzilla::{ - backend::canvas::CanvasBackendOptions, - event::KeyCode, - ratatui::{ - widgets::{Block, Borders}, - Terminal, - }, - web_sys, CanvasBackend, WebRenderer, + backend::canvas::CanvasBackendOptions, event::KeyCode, ratatui::Terminal, CanvasBackend, + WebRenderer, }; use wasm_bindgen::prelude::*; mod utils; -use crate::app::App; +use crate::app::{App, AppExecutor}; #[wasm_bindgen] extern "C" { fn alert(s: &str); } +struct WasmBindgenExecutor; + +impl AppExecutor for WasmBindgenExecutor { + fn execute( + &self, + future: impl Future + 'static, + sender: std::sync::mpsc::Sender, + ) { + wasm_bindgen_futures::spawn_local(future.map(move |output| sender.send(output).unwrap())); + } +} + #[wasm_bindgen] pub fn run(grid_id: &str) { - console_error_panic_hook::set_once(); + utils::set_panic_hook(); let backend = CanvasBackend::new_with_options( CanvasBackendOptions::new() @@ -32,7 +40,7 @@ pub fn run(grid_id: &str) { .unwrap(); let terminal = Terminal::new(backend).unwrap(); - let app = Rc::new(RefCell::new(App::new())); + let app = Rc::new(RefCell::new(App::new(WasmBindgenExecutor))); terminal.on_key_event({ let app = Rc::clone(&app);