thesandwi.ch/thecockpit/src/app/mod.rs
Isaac Mills 2c2246ae00
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Add forecast support
2025-07-29 14:29:03 -06:00

382 lines
12 KiB
Rust

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<AtomicUsize>,
transitioning_to: Rc<AtomicUsize>,
}
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<dyn Shader> {
Box::new(self.clone())
}
fn area(&self) -> Option<Rect> {
None
}
fn set_area(&mut self, _area: Rect) {}
fn filter(&mut self, _filter: tachyonfx::CellFilter) {}
}
pub trait AppExecutor {
fn execute<T: 'static>(
&self,
future: impl Future<Output = T> + 'static,
output_cell: Rc<OnceCell<T>>,
);
}
pub struct App<E: AppExecutor> {
tabs: [&'static str; 3],
transition_instant: Instant,
selected_tab: SelectedTab,
password_locker: PasswordLocker,
current_effect: Effect,
forecast_state: ListState,
weather: Rc<OnceCell<(WeatherResponse, ForecastResponse)>>,
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<E: AppExecutor> App<E> {
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)),
]);
}
}