All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
382 lines
12 KiB
Rust
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)),
|
|
]);
|
|
}
|
|
}
|