diff --git a/Cargo.lock b/Cargo.lock index 953d890..ed865d8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2460,6 +2460,8 @@ dependencies = [ "home", "iced", "log", + "lyon_algorithms", + "once_cell", "palette", "pretty_env_logger", "rusqlite", diff --git a/Cargo.toml b/Cargo.toml index 38b211d..cd33a54 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,7 +11,9 @@ vendored = ["rusqlite/bundled-sqlcipher-vendored-openssl"] anyhow = "1" log = "0.4" pretty_env_logger = "0.5" # todo swap to a file logger -iced = { git = "https://github.com/iced-rs/iced", rev = "b30d34f", features = ["debug", "tokio", "svg", "qr_code"] } +iced = { git = "https://github.com/iced-rs/iced", rev = "b30d34f", features = ["debug", "tokio", "svg", "qr_code", "advanced"] } +lyon_algorithms = "1.0" +once_cell = "1.0" tokio = { version = "1", features = ["full"] } palette = "0.7" config = "0.14.0" @@ -33,3 +35,4 @@ fedimint-mint-client = "0.3.1" fedimint-ln-client = "0.3.1" fedimint-bip39 = "0.3.1" fedimint-ln-common = "0.3.1" + diff --git a/src/components/button.rs b/src/components/button.rs index 82365fa..98b96aa 100644 --- a/src/components/button.rs +++ b/src/components/button.rs @@ -3,26 +3,26 @@ use iced::{ button::{self, Status}, center, horizontal_space, row, text, Button, Svg, }, - Border, Color, Length, Shadow, Theme, + Border, Color, Element, Length, Shadow, Theme, }; use crate::{Message, Route}; -use super::{darken, lighten, map_icon, SvgIcon}; +use super::{darken, lighten, map_icon, the_spinner, SvgIcon}; -pub fn h_button(text_str: &str, icon: SvgIcon) -> Button<'_, Message, Theme> { +pub fn h_button(text_str: &str, icon: SvgIcon, loading: bool) -> Button<'_, Message, Theme> { + let spinner: Element<'static, Message, Theme> = the_spinner(); let svg: Svg<'_, Theme> = map_icon(icon); - let content = row!( - svg.width(Length::Fixed(24.)).height(Length::Fixed(24.)), - text(text_str).size(24.) // .font(Font { - // family: iced::font::Family::default(), - // weight: iced::font::Weight::Bold, - // stretch: iced::font::Stretch::Normal, - // style: iced::font::Style::Normal, - // }) - ) - .align_items(iced::Alignment::Center) - .spacing(16); + let content = if loading { + row![spinner].align_items(iced::Alignment::Center) + } else { + row![ + svg.width(Length::Fixed(24.)).height(Length::Fixed(24.)), + text(text_str).size(24.) + ] + .align_items(iced::Alignment::Center) + .spacing(16) + }; Button::new(center(content)) .style(|theme, status| { diff --git a/src/components/easing.rs b/src/components/easing.rs new file mode 100644 index 0000000..5966f8a --- /dev/null +++ b/src/components/easing.rs @@ -0,0 +1,122 @@ +use iced::Point; + +use lyon_algorithms::measure::PathMeasurements; +use lyon_algorithms::path::{builder::NoAttributes, path::BuilderImpl, Path}; +use once_cell::sync::Lazy; + +pub static EMPHASIZED: Lazy = Lazy::new(|| { + Easing::builder() + .cubic_bezier_to([0.05, 0.0], [0.133333, 0.06], [0.166666, 0.4]) + .cubic_bezier_to([0.208333, 0.82], [0.25, 1.0], [1.0, 1.0]) + .build() +}); + +pub static EMPHASIZED_DECELERATE: Lazy = Lazy::new(|| { + Easing::builder() + .cubic_bezier_to([0.05, 0.7], [0.1, 1.0], [1.0, 1.0]) + .build() +}); + +pub static EMPHASIZED_ACCELERATE: Lazy = Lazy::new(|| { + Easing::builder() + .cubic_bezier_to([0.3, 0.0], [0.8, 0.15], [1.0, 1.0]) + .build() +}); + +pub static STANDARD: Lazy = Lazy::new(|| { + Easing::builder() + .cubic_bezier_to([0.2, 0.0], [0.0, 1.0], [1.0, 1.0]) + .build() +}); + +pub static STANDARD_DECELERATE: Lazy = Lazy::new(|| { + Easing::builder() + .cubic_bezier_to([0.0, 0.0], [0.0, 1.0], [1.0, 1.0]) + .build() +}); + +pub static STANDARD_ACCELERATE: Lazy = Lazy::new(|| { + Easing::builder() + .cubic_bezier_to([0.3, 0.0], [1.0, 1.0], [1.0, 1.0]) + .build() +}); + +pub struct Easing { + path: Path, + measurements: PathMeasurements, +} + +impl Easing { + pub fn builder() -> Builder { + Builder::new() + } + + pub fn y_at_x(&self, x: f32) -> f32 { + let mut sampler = self + .measurements + .create_sampler(&self.path, lyon_algorithms::measure::SampleType::Normalized); + let sample = sampler.sample(x); + + sample.position().y + } +} + +pub struct Builder(NoAttributes); + +impl Builder { + pub fn new() -> Self { + let mut builder = Path::builder(); + builder.begin(lyon_algorithms::geom::point(0.0, 0.0)); + + Self(builder) + } + + /// Adds a line segment. Points must be between 0,0 and 1,1 + pub fn line_to(mut self, to: impl Into) -> Self { + self.0.line_to(Self::point(to)); + + self + } + + /// Adds a quadratic bézier curve. Points must be between 0,0 and 1,1 + pub fn quadratic_bezier_to(mut self, ctrl: impl Into, to: impl Into) -> Self { + self.0 + .quadratic_bezier_to(Self::point(ctrl), Self::point(to)); + + self + } + + /// Adds a cubic bézier curve. Points must be between 0,0 and 1,1 + pub fn cubic_bezier_to( + mut self, + ctrl1: impl Into, + ctrl2: impl Into, + to: impl Into, + ) -> Self { + self.0 + .cubic_bezier_to(Self::point(ctrl1), Self::point(ctrl2), Self::point(to)); + + self + } + + pub fn build(mut self) -> Easing { + self.0.line_to(lyon_algorithms::geom::point(1.0, 1.0)); + self.0.end(false); + + let path = self.0.build(); + let measurements = PathMeasurements::from_path(&path, 0.0); + + Easing { path, measurements } + } + + fn point(p: impl Into) -> lyon_algorithms::geom::Point { + let p: Point = p.into(); + lyon_algorithms::geom::point(p.x.min(1.0).max(0.0), p.y.min(1.0).max(0.0)) + } +} + +impl Default for Builder { + fn default() -> Self { + Self::new() + } +} diff --git a/src/components/mod.rs b/src/components/mod.rs index 0ad3774..6cfceba 100644 --- a/src/components/mod.rs +++ b/src/components/mod.rs @@ -15,3 +15,9 @@ pub use input::*; mod header; pub use header::*; + +mod easing; +pub use easing::*; + +mod spinner; +pub use spinner::*; diff --git a/src/components/spinner.rs b/src/components/spinner.rs new file mode 100644 index 0000000..f303fc2 --- /dev/null +++ b/src/components/spinner.rs @@ -0,0 +1,405 @@ +//! Show a circular progress indicator. +use iced::advanced::layout; +use iced::advanced::renderer; +use iced::advanced::widget::tree::{self, Tree}; +use iced::advanced::{self, Clipboard, Layout, Shell, Widget}; +use iced::event; +use iced::mouse; +use iced::time::Instant; +use iced::widget::canvas; +use iced::window::{self, RedrawRequest}; +use iced::Theme; +use iced::{Background, Color, Element, Event, Length, Radians, Rectangle, Renderer, Size, Vector}; + +use crate::Message; + +use super::easing::{self, Easing}; +use super::STANDARD; + +use std::f32::consts::PI; +use std::time::Duration; + +const MIN_ANGLE: Radians = Radians(PI / 8.0); +const WRAP_ANGLE: Radians = Radians(2.0 * PI - PI / 4.0); +const BASE_ROTATION_SPEED: u32 = u32::MAX / 80; + +pub fn the_spinner() -> Element<'static, Message, Theme> { + let easing = &STANDARD; + let spinner = Circular::::new() + .easing(&easing) + .cycle_duration(Duration::from_secs_f32(2.)); + return spinner.into(); +} + +#[allow(missing_debug_implementations)] +pub struct Circular<'a, Theme> +where + Theme: StyleSheet, +{ + size: f32, + bar_height: f32, + style: ::Style, + easing: &'a Easing, + cycle_duration: Duration, + rotation_duration: Duration, +} + +impl<'a, Theme> Circular<'a, Theme> +where + Theme: StyleSheet, +{ + /// Creates a new [`Circular`] with the given content. + pub fn new() -> Self { + Circular { + size: 40.0, + bar_height: 4.0, + style: ::Style::default(), + easing: &easing::STANDARD, + cycle_duration: Duration::from_millis(600), + rotation_duration: Duration::from_secs(2), + } + } + + /// Sets the size of the [`Circular`]. + pub fn size(mut self, size: f32) -> Self { + self.size = size; + self + } + + /// Sets the bar height of the [`Circular`]. + pub fn bar_height(mut self, bar_height: f32) -> Self { + self.bar_height = bar_height; + self + } + + /// Sets the style variant of this [`Circular`]. + pub fn style(mut self, style: ::Style) -> Self { + self.style = style; + self + } + + /// Sets the easing of this [`Circular`]. + pub fn easing(mut self, easing: &'a Easing) -> Self { + self.easing = easing; + self + } + + /// Sets the cycle duration of this [`Circular`]. + pub fn cycle_duration(mut self, duration: Duration) -> Self { + self.cycle_duration = duration / 2; + self + } + + /// Sets the base rotation duration of this [`Circular`]. This is the duration that a full + /// rotation would take if the cycle rotation were set to 0.0 (no expanding or contracting) + pub fn rotation_duration(mut self, duration: Duration) -> Self { + self.rotation_duration = duration; + self + } +} + +impl<'a, Theme> Default for Circular<'a, Theme> +where + Theme: StyleSheet, +{ + fn default() -> Self { + Self::new() + } +} + +#[derive(Clone, Copy)] +enum Animation { + Expanding { + start: Instant, + progress: f32, + rotation: u32, + last: Instant, + }, + Contracting { + start: Instant, + progress: f32, + rotation: u32, + last: Instant, + }, +} + +impl Default for Animation { + fn default() -> Self { + Self::Expanding { + start: Instant::now(), + progress: 0.0, + rotation: 0, + last: Instant::now(), + } + } +} + +impl Animation { + fn next(&self, additional_rotation: u32, now: Instant) -> Self { + match self { + Self::Expanding { rotation, .. } => Self::Contracting { + start: now, + progress: 0.0, + rotation: rotation.wrapping_add(additional_rotation), + last: now, + }, + Self::Contracting { rotation, .. } => { + Self::Expanding { + start: now, + progress: 0.0, + rotation: rotation.wrapping_add(BASE_ROTATION_SPEED.wrapping_add( + (f64::from(WRAP_ANGLE / (2.0 * Radians::PI)) * f64::MAX) as u32, + )), + last: now, + } + } + } + } + + fn start(&self) -> Instant { + match self { + Self::Expanding { start, .. } | Self::Contracting { start, .. } => *start, + } + } + + fn last(&self) -> Instant { + match self { + Self::Expanding { last, .. } | Self::Contracting { last, .. } => *last, + } + } + + fn timed_transition( + &self, + cycle_duration: Duration, + rotation_duration: Duration, + now: Instant, + ) -> Self { + let elapsed = now.duration_since(self.start()); + let additional_rotation = ((now - self.last()).as_secs_f32() + / rotation_duration.as_secs_f32() + * (u32::MAX) as f32) as u32; + + match elapsed { + elapsed if elapsed > cycle_duration => self.next(additional_rotation, now), + _ => self.with_elapsed(cycle_duration, additional_rotation, elapsed, now), + } + } + + fn with_elapsed( + &self, + cycle_duration: Duration, + additional_rotation: u32, + elapsed: Duration, + now: Instant, + ) -> Self { + let progress = elapsed.as_secs_f32() / cycle_duration.as_secs_f32(); + match self { + Self::Expanding { + start, rotation, .. + } => Self::Expanding { + start: *start, + progress, + rotation: rotation.wrapping_add(additional_rotation), + last: now, + }, + Self::Contracting { + start, rotation, .. + } => Self::Contracting { + start: *start, + progress, + rotation: rotation.wrapping_add(additional_rotation), + last: now, + }, + } + } + + fn rotation(&self) -> f32 { + match self { + Self::Expanding { rotation, .. } | Self::Contracting { rotation, .. } => { + *rotation as f32 / u32::MAX as f32 + } + } + } +} + +#[derive(Default)] +struct State { + animation: Animation, + cache: canvas::Cache, +} + +impl<'a, Message, Theme> Widget for Circular<'a, Theme> +where + Message: 'a + Clone, + Theme: StyleSheet, +{ + fn tag(&self) -> tree::Tag { + tree::Tag::of::() + } + + fn state(&self) -> tree::State { + tree::State::new(State::default()) + } + + fn size(&self) -> Size { + Size { + width: Length::Fixed(self.size), + height: Length::Fixed(self.size), + } + } + + fn layout( + &self, + _tree: &mut Tree, + _renderer: &Renderer, + limits: &layout::Limits, + ) -> layout::Node { + layout::atomic(limits, self.size, self.size) + } + + fn on_event( + &mut self, + tree: &mut Tree, + event: Event, + _layout: Layout<'_>, + _cursor: mouse::Cursor, + _renderer: &Renderer, + _clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, Message>, + _viewport: &Rectangle, + ) -> event::Status { + let state = tree.state.downcast_mut::(); + + if let Event::Window(_, window::Event::RedrawRequested(now)) = event { + state.animation = + state + .animation + .timed_transition(self.cycle_duration, self.rotation_duration, now); + + state.cache.clear(); + shell.request_redraw(RedrawRequest::NextFrame); + } + + event::Status::Ignored + } + + fn draw( + &self, + tree: &Tree, + renderer: &mut Renderer, + theme: &Theme, + _style: &renderer::Style, + layout: Layout<'_>, + _cursor: mouse::Cursor, + _viewport: &Rectangle, + ) { + use advanced::Renderer as _; + + let state = tree.state.downcast_ref::(); + let bounds = layout.bounds(); + let custom_style = ::appearance(theme, &self.style); + + let geometry = state.cache.draw(renderer, bounds.size(), |frame| { + let track_radius = frame.width() / 2.0 - self.bar_height; + let track_path = canvas::Path::circle(frame.center(), track_radius); + + frame.stroke( + &track_path, + canvas::Stroke::default() + .with_color(custom_style.track_color) + .with_width(self.bar_height), + ); + + let mut builder = canvas::path::Builder::new(); + + let start = Radians(state.animation.rotation() * 2.0 * PI); + + match state.animation { + Animation::Expanding { progress, .. } => { + builder.arc(canvas::path::Arc { + center: frame.center(), + radius: track_radius, + start_angle: start, + end_angle: start + MIN_ANGLE + WRAP_ANGLE * (self.easing.y_at_x(progress)), + }); + } + Animation::Contracting { progress, .. } => { + builder.arc(canvas::path::Arc { + center: frame.center(), + radius: track_radius, + start_angle: start + WRAP_ANGLE * (self.easing.y_at_x(progress)), + end_angle: start + MIN_ANGLE + WRAP_ANGLE, + }); + } + } + + let bar_path = builder.build(); + + frame.stroke( + &bar_path, + canvas::Stroke::default() + .with_color(custom_style.bar_color) + .with_width(self.bar_height), + ); + }); + + renderer.with_translation(Vector::new(bounds.x, bounds.y), |renderer| { + use iced::advanced::graphics::geometry::Renderer as _; + + renderer.draw_geometry(geometry); + }); + } +} + +impl<'a, Message, Theme> From> for Element<'a, Message, Theme, Renderer> +where + Message: Clone + 'a, + Theme: StyleSheet + 'a, +{ + fn from(circular: Circular<'a, Theme>) -> Self { + Self::new(circular) + } +} + +#[derive(Debug, Clone, Copy)] +pub struct Appearance { + /// The [`Background`] of the progress indicator. + pub background: Option, + /// The track [`Color`] of the progress indicator. + pub track_color: Color, + /// The bar [`Color`] of the progress indicator. + pub bar_color: Color, +} + +impl std::default::Default for Appearance { + fn default() -> Self { + Self { + background: None, + track_color: Color::TRANSPARENT, + bar_color: Color::BLACK, + } + } +} + +/// A set of rules that dictate the style of an indicator. +pub trait StyleSheet { + /// The supported style of the [`StyleSheet`]. + type Style: Default; + + /// Produces the active [`Appearance`] of a indicator. + fn appearance(&self, style: &Self::Style) -> Appearance; +} + +impl StyleSheet for iced::Theme { + type Style = (); + + fn appearance(&self, _style: &Self::Style) -> Appearance { + let palette = self.extended_palette(); + + Appearance { + background: None, + track_color: palette.background.weak.color, + bar_color: palette.primary.base.color, + } + } +} diff --git a/src/routes/home.rs b/src/routes/home.rs index d35877a..2914d74 100644 --- a/src/routes/home.rs +++ b/src/routes/home.rs @@ -8,10 +8,10 @@ use super::Route; pub fn home(harbor: &HarborWallet) -> Element { let balance = text(format!("{} sats", harbor.balance.sats_round_down())).size(64); - let send_button = h_button("Send", SvgIcon::UpRight).on_press(Message::Navigate(Route::Send)); - // let receive_button = h_button("Receive", SvgIcon::DownLeft).on_press(Message::Receive(100)); + let send_button = + h_button("Send", SvgIcon::UpRight, false).on_press(Message::Navigate(Route::Send)); let receive_button = - h_button("Receive", SvgIcon::DownLeft).on_press(Message::Navigate(Route::Receive)); + h_button("Receive", SvgIcon::DownLeft, false).on_press(Message::Navigate(Route::Receive)); let buttons = row![send_button, receive_button].spacing(32); container(center( diff --git a/src/routes/mints.rs b/src/routes/mints.rs index b09cb4c..3b2b519 100644 --- a/src/routes/mints.rs +++ b/src/routes/mints.rs @@ -19,7 +19,7 @@ pub fn mints(harbor: &HarborWallet) -> Element { None, ); - let add_mint_button = h_button("Add Mint", SvgIcon::Plus) + let add_mint_button = h_button("Add Mint", SvgIcon::Plus, false) .on_press(Message::AddFederation(harbor.mint_invite_code_str.clone())); let column = column![header, mint_input, add_mint_button].spacing(48); diff --git a/src/routes/receive.rs b/src/routes/receive.rs index 6c19eb2..a48a7b7 100644 --- a/src/routes/receive.rs +++ b/src/routes/receive.rs @@ -36,7 +36,7 @@ pub fn receive(harbor: &HarborWallet) -> Element { header, qr_container, text(format!("{first_ten_chars}...")).size(16), - h_button("Copy to clipboard", SvgIcon::Copy) + h_button("Copy to clipboard", SvgIcon::Copy, false) .on_press(Message::CopyToClipboard(string)), ] } else { @@ -53,13 +53,18 @@ pub fn receive(harbor: &HarborWallet) -> Element { Some("sats"), ); - let generate_button = - h_button("Generate Invoice", SvgIcon::DownLeft).on_press(Message::GenerateInvoice); + let generate_button = h_button("Generate Invoice", SvgIcon::DownLeft, false) + .on_press(Message::GenerateInvoice); - let generate_address_button = - h_button("Generate Address", SvgIcon::Squirrel).on_press(Message::GenerateAddress); + let generate_address_button = h_button("Generate Address", SvgIcon::Squirrel, false) + .on_press(Message::GenerateAddress); - column![header, amount_input, generate_button, generate_address_button] + column![ + header, + amount_input, + generate_button, + generate_address_button + ] }; container(scrollable( diff --git a/src/routes/send.rs b/src/routes/send.rs index f800c03..2a2ccb9 100644 --- a/src/routes/send.rs +++ b/src/routes/send.rs @@ -40,7 +40,7 @@ pub fn send(harbor: &HarborWallet) -> Element { None, ); - let send_button = h_button("Send", SvgIcon::UpRight) + let send_button = h_button("Send", SvgIcon::UpRight, false) .on_press(Message::Send(harbor.send_dest_input_str.clone())); let body = column![header, amount_input, dest_input, send_button].spacing(48); diff --git a/src/routes/unlock.rs b/src/routes/unlock.rs index 5a8d05d..7c2f829 100644 --- a/src/routes/unlock.rs +++ b/src/routes/unlock.rs @@ -1,14 +1,14 @@ use crate::components::{h_button, h_input, SvgIcon}; use iced::{ widget::{center, column, container, text, Svg}, - Color, + Color, Theme, }; use iced::{Alignment, Element, Length}; use crate::{HarborWallet, Message}; -pub fn unlock(harbor: &HarborWallet) -> Element { - let unlock_button = h_button("Unlock", SvgIcon::DownLeft) +pub fn unlock(harbor: &HarborWallet) -> Element { + let unlock_button = h_button("Unlock", SvgIcon::DownLeft, false) .on_press(Message::Unlock(harbor.password_input_str.clone())) .width(Length::Fill);