diff --git a/assets/icons/small_close.svg b/assets/icons/small_close.svg new file mode 100644 index 0000000..77843b6 --- /dev/null +++ b/assets/icons/small_close.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/components/icon.rs b/src/components/icon.rs index 3960839..28a926a 100644 --- a/src/components/icon.rs +++ b/src/components/icon.rs @@ -13,6 +13,7 @@ pub enum SvgIcon { Copy, Plus, Qr, + SmallClose, } pub fn map_icon(icon: SvgIcon) -> Svg<'static, Theme> { @@ -29,5 +30,6 @@ pub fn map_icon(icon: SvgIcon) -> Svg<'static, Theme> { SvgIcon::Copy => Svg::from_path("assets/icons/copy.svg"), SvgIcon::Plus => Svg::from_path("assets/icons/plus.svg"), SvgIcon::Qr => Svg::from_path("assets/icons/qr.svg"), + SvgIcon::SmallClose => Svg::from_path("assets/icons/small_close.svg"), } } diff --git a/src/components/mod.rs b/src/components/mod.rs index 167aa16..b527664 100644 --- a/src/components/mod.rs +++ b/src/components/mod.rs @@ -39,3 +39,6 @@ pub use rules::*; mod layout; pub use layout::*; + +mod toast; +pub use toast::*; diff --git a/src/components/toast.rs b/src/components/toast.rs new file mode 100644 index 0000000..db602a8 --- /dev/null +++ b/src/components/toast.rs @@ -0,0 +1,516 @@ +// Mostly stolen from https://github.com/iced-rs/iced/blob/master/examples/toast/src/main.rs but I made it pretty +use std::fmt; +use std::time::{Duration, Instant}; + +use iced::advanced::layout::{self, Layout}; +use iced::advanced::overlay; +use iced::advanced::renderer; +use iced::advanced::widget::{self, Operation, Tree}; +use iced::advanced::{Clipboard, Shell, Widget}; +use iced::event::{self, Event}; +use iced::widget::button::Status; +use iced::widget::{button, column, container, horizontal_space, row, text}; +use iced::Border; +use iced::{mouse, Color, Font}; +use iced::{window, Shadow}; +use iced::{Alignment, Element, Length, Point, Rectangle, Renderer, Size, Theme, Vector}; + +use super::{darken, lighten, map_icon, SvgIcon}; + +pub const DEFAULT_TIMEOUT: u64 = 5; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum ToastStatus { + #[default] + Neutral, + Good, + Bad, +} + +impl ToastStatus { + pub const ALL: &'static [Self] = &[Self::Neutral, Self::Good, Self::Bad]; +} + +impl fmt::Display for ToastStatus { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + ToastStatus::Neutral => "Neutral", + ToastStatus::Good => "Good", + ToastStatus::Bad => "Bad", + } + .fmt(f) + } +} + +#[derive(Debug, Clone, Default)] +pub struct Toast { + pub title: String, + pub body: String, + pub status: ToastStatus, +} + +pub struct ToastManager<'a, Message> { + content: Element<'a, Message>, + toasts: Vec>, + timeout_secs: u64, + on_close: Box Message + 'a>, +} + +impl<'a, Message> ToastManager<'a, Message> +where + Message: 'a + Clone + 'static, +{ + pub fn new( + content: impl Into>, + toasts: &'a [Toast], + on_close: impl Fn(usize) -> Message + 'a, + ) -> Self { + let toasts = toasts + .iter() + .enumerate() + .map(|(index, toast)| { + let close_icon = map_icon(SvgIcon::SmallClose).height(12).width(12); + + let close_button = button(close_icon) + .style(|theme: &Theme, status| { + let border = Border { + color: Color::WHITE, + width: 0., + radius: (4.).into(), + }; + + let background = match status { + Status::Hovered => darken(theme.palette().background, 0.1), + Status::Pressed => darken(Color::BLACK, 0.1), + _ => theme.palette().background, + }; + button::Style { + background: Some(background.into()), + text_color: Color::WHITE, + border, + shadow: Shadow::default(), + } + }) + .padding(6) + .width(Length::Fixed(24.)) + .height(Length::Fixed(24.)); + + container(column![container(column![ + row![ + text(toast.title.as_str()).font(Font { + family: iced::font::Family::default(), + weight: iced::font::Weight::Bold, + stretch: iced::font::Stretch::Normal, + style: iced::font::Style::Normal, + }), + horizontal_space(), + close_button.on_press((on_close)(index)) + ] + .align_items(Alignment::Center), + text(toast.body.as_str()) + ]) + .width(Length::Fill) + .padding(16) + .style(match toast.status { + ToastStatus::Neutral => neutral, + ToastStatus::Good => good, + ToastStatus::Bad => bad, + }),]) + .max_width(256) + .into() + }) + .collect(); + + Self { + content: content.into(), + toasts, + timeout_secs: DEFAULT_TIMEOUT, + on_close: Box::new(on_close), + } + } + + pub fn timeout(self, seconds: u64) -> Self { + Self { + timeout_secs: seconds, + ..self + } + } +} + +impl<'a, Message> Widget for ToastManager<'a, Message> { + fn size(&self) -> Size { + self.content.as_widget().size() + } + + fn layout( + &self, + tree: &mut Tree, + renderer: &Renderer, + limits: &layout::Limits, + ) -> layout::Node { + self.content + .as_widget() + .layout(&mut tree.children[0], renderer, limits) + } + + fn tag(&self) -> widget::tree::Tag { + struct Marker; + widget::tree::Tag::of::() + } + + fn state(&self) -> widget::tree::State { + widget::tree::State::new(Vec::>::new()) + } + + fn children(&self) -> Vec { + std::iter::once(Tree::new(&self.content)) + .chain(self.toasts.iter().map(Tree::new)) + .collect() + } + + fn diff(&self, tree: &mut Tree) { + let instants = tree.state.downcast_mut::>>(); + + // Invalidating removed instants to None allows us to remove + // them here so that diffing for removed / new toast instants + // is accurate + instants.retain(Option::is_some); + + match (instants.len(), self.toasts.len()) { + (old, new) if old > new => { + instants.truncate(new); + } + (old, new) if old < new => { + instants.extend(std::iter::repeat(Some(Instant::now())).take(new - old)); + } + _ => {} + } + + tree.diff_children( + &std::iter::once(&self.content) + .chain(self.toasts.iter()) + .collect::>(), + ); + } + + fn operate( + &self, + state: &mut Tree, + layout: Layout<'_>, + renderer: &Renderer, + operation: &mut dyn Operation, + ) { + operation.container(None, layout.bounds(), &mut |operation| { + self.content + .as_widget() + .operate(&mut state.children[0], layout, renderer, operation); + }); + } + + fn on_event( + &mut self, + state: &mut Tree, + event: Event, + layout: Layout<'_>, + cursor: mouse::Cursor, + renderer: &Renderer, + clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, Message>, + viewport: &Rectangle, + ) -> event::Status { + self.content.as_widget_mut().on_event( + &mut state.children[0], + event, + layout, + cursor, + renderer, + clipboard, + shell, + viewport, + ) + } + + fn draw( + &self, + state: &Tree, + renderer: &mut Renderer, + theme: &Theme, + style: &renderer::Style, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + ) { + self.content.as_widget().draw( + &state.children[0], + renderer, + theme, + style, + layout, + cursor, + viewport, + ); + } + + fn mouse_interaction( + &self, + state: &Tree, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + renderer: &Renderer, + ) -> mouse::Interaction { + self.content.as_widget().mouse_interaction( + &state.children[0], + layout, + cursor, + viewport, + renderer, + ) + } + + fn overlay<'b>( + &'b mut self, + state: &'b mut Tree, + layout: Layout<'_>, + renderer: &Renderer, + translation: Vector, + ) -> Option> { + let instants = state.state.downcast_mut::>>(); + + let (content_state, toasts_state) = state.children.split_at_mut(1); + + let content = self.content.as_widget_mut().overlay( + &mut content_state[0], + layout, + renderer, + translation, + ); + + let toasts = (!self.toasts.is_empty()).then(|| { + overlay::Element::new(Box::new(Overlay { + position: layout.bounds().position() + translation, + toasts: &mut self.toasts, + state: toasts_state, + instants, + on_close: &self.on_close, + timeout_secs: self.timeout_secs, + })) + }); + let overlays = content.into_iter().chain(toasts).collect::>(); + + (!overlays.is_empty()).then(|| overlay::Group::with_children(overlays).overlay()) + } +} + +struct Overlay<'a, 'b, Message> { + position: Point, + toasts: &'b mut [Element<'a, Message>], + state: &'b mut [Tree], + instants: &'b mut [Option], + on_close: &'b dyn Fn(usize) -> Message, + timeout_secs: u64, +} + +impl<'a, 'b, Message> overlay::Overlay for Overlay<'a, 'b, Message> { + fn layout(&mut self, renderer: &Renderer, bounds: Size) -> layout::Node { + let limits = layout::Limits::new(Size::ZERO, bounds); + + layout::flex::resolve( + layout::flex::Axis::Vertical, + renderer, + &limits, + Length::Fill, + Length::Fill, + 10.into(), + 10.0, + Alignment::End, + self.toasts, + self.state, + ) + .translate(Vector::new(self.position.x, self.position.y)) + } + + fn on_event( + &mut self, + event: Event, + layout: Layout<'_>, + cursor: mouse::Cursor, + renderer: &Renderer, + clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, Message>, + ) -> event::Status { + if let Event::Window(_, window::Event::RedrawRequested(now)) = &event { + let mut next_redraw: Option = None; + + self.instants + .iter_mut() + .enumerate() + .for_each(|(index, maybe_instant)| { + if let Some(instant) = maybe_instant.as_mut() { + let remaining = Duration::from_secs(self.timeout_secs) + .saturating_sub(instant.elapsed()); + + if remaining == Duration::ZERO { + maybe_instant.take(); + shell.publish((self.on_close)(index)); + next_redraw = Some(window::RedrawRequest::NextFrame); + } else { + let redraw_at = window::RedrawRequest::At(*now + remaining); + next_redraw = next_redraw + .map(|redraw| redraw.min(redraw_at)) + .or(Some(redraw_at)); + } + } + }); + + if let Some(redraw) = next_redraw { + shell.request_redraw(redraw); + } + } + + let viewport = layout.bounds(); + + self.toasts + .iter_mut() + .zip(self.state.iter_mut()) + .zip(layout.children()) + .zip(self.instants.iter_mut()) + .map(|(((child, state), layout), instant)| { + let mut local_messages = vec![]; + let mut local_shell = Shell::new(&mut local_messages); + + let status = child.as_widget_mut().on_event( + state, + event.clone(), + layout, + cursor, + renderer, + clipboard, + &mut local_shell, + &viewport, + ); + + if !local_shell.is_empty() { + instant.take(); + } + + shell.merge(local_shell, std::convert::identity); + + status + }) + .fold(event::Status::Ignored, event::Status::merge) + } + + fn draw( + &self, + renderer: &mut Renderer, + theme: &Theme, + style: &renderer::Style, + layout: Layout<'_>, + cursor: mouse::Cursor, + ) { + let viewport = layout.bounds(); + + for ((child, state), layout) in self + .toasts + .iter() + .zip(self.state.iter()) + .zip(layout.children()) + { + child + .as_widget() + .draw(state, renderer, theme, style, layout, cursor, &viewport); + } + } + + fn operate( + &mut self, + layout: Layout<'_>, + renderer: &Renderer, + operation: &mut dyn widget::Operation, + ) { + operation.container(None, layout.bounds(), &mut |operation| { + self.toasts + .iter() + .zip(self.state.iter_mut()) + .zip(layout.children()) + .for_each(|((child, state), layout)| { + child + .as_widget() + .operate(state, layout, renderer, operation); + }); + }); + } + + fn mouse_interaction( + &self, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + renderer: &Renderer, + ) -> mouse::Interaction { + self.toasts + .iter() + .zip(self.state.iter()) + .zip(layout.children()) + .map(|((child, state), layout)| { + child + .as_widget() + .mouse_interaction(state, layout, cursor, viewport, renderer) + }) + .max() + .unwrap_or_default() + } + + fn is_over(&self, layout: Layout<'_>, _renderer: &Renderer, cursor_position: Point) -> bool { + layout + .children() + .any(|layout| layout.bounds().contains(cursor_position)) + } +} + +impl<'a, Message> From> for Element<'a, Message> +where + Message: 'a, +{ + fn from(manager: ToastManager<'a, Message>) -> Self { + Element::new(manager) + } +} + +fn styled(background: Color, border: Color) -> container::Style { + container::Style { + background: Some(background.into()), + text_color: Color::WHITE.into(), + border: Border { + color: border, + width: 1., + radius: (4.).into(), + }, + shadow: Shadow { + color: Color::from_rgba8(0, 0, 0, 0.25), + offset: Vector::new(-2., -2.), + blur_radius: 4., + }, + } +} + +fn neutral(theme: &Theme) -> container::Style { + let gray = lighten(theme.palette().background, 0.1); + + styled(gray, gray) +} + +fn good(theme: &Theme) -> container::Style { + let gray = lighten(theme.palette().background, 0.1); + let green = Color::from_rgb8(40, 164, 127); + + styled(gray, green) +} + +fn bad(theme: &Theme) -> container::Style { + let gray = lighten(theme.palette().background, 0.1); + let red = theme.palette().primary; + + styled(gray, red) +} diff --git a/src/main.rs b/src/main.rs index 67ca76d..f7d295c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,5 @@ use bitcoin::Address; -use components::{FederationItem, TransactionItem}; +use components::{FederationItem, Toast, ToastManager, ToastStatus, TransactionItem}; use core::run_core; use fedimint_core::api::InviteCode; use fedimint_ln_common::lightning_invoice::Bolt11Invoice; @@ -94,6 +94,8 @@ pub enum Message { CopyToClipboard(String), ReceiveMethodChanged(ReceiveMethod), ShowSeedWords(bool), + AddToast(Toast), + CloseToast(usize), // Async commands we fire from the UI to core Noop, Send(String), @@ -113,6 +115,7 @@ pub enum Message { pub struct HarborWallet { ui_handle: Option>, active_route: Route, + toasts: Vec, // Globals balance_sats: u64, transaction_history: Vec, @@ -302,6 +305,14 @@ impl HarborWallet { self.receive_method = method; Command::none() } + Message::AddToast(toast) => { + self.toasts.push(toast); + Command::none() + } + Message::CloseToast(index) => { + self.toasts.remove(index); + Command::none() + } // Async commands we fire from the UI to core Message::Noop => Command::none(), Message::Send(invoice_str) => match self.send_status { @@ -406,10 +417,16 @@ impl HarborWallet { Command::none() } } - Message::CopyToClipboard(s) => { - println!("Copying to clipboard: {s}"); - clipboard::write(s) - } + Message::CopyToClipboard(s) => Command::batch([ + clipboard::write(s), + Command::perform(async {}, |_| { + Message::AddToast(Toast { + title: "Copied to clipboard".to_string(), + body: "...".to_string(), + status: ToastStatus::Neutral, + }) + }), + ]), Message::ShowSeedWords(show) => { if show { Command::perform(Self::async_get_seed_words(self.ui_handle.clone()), |_| { @@ -551,7 +568,7 @@ impl HarborWallet { Route::Settings => row![sidebar, crate::routes::settings(self)].into(), }; - active_route + ToastManager::new(active_route, &self.toasts, Message::CloseToast).into() } fn theme(&self) -> iced::Theme { diff --git a/src/routes/settings.rs b/src/routes/settings.rs index 17d7ebd..6ad84bd 100644 --- a/src/routes/settings.rs +++ b/src/routes/settings.rs @@ -1,12 +1,26 @@ use iced::widget::{column, text}; use iced::Element; -use crate::components::{basic_layout, h_button, h_header, SvgIcon}; +use crate::components::{basic_layout, h_button, h_header, SvgIcon, Toast, ToastStatus}; use crate::{HarborWallet, Message}; pub fn settings(harbor: &HarborWallet) -> Element { let header = h_header("Settings", "The fun stuff."); + let add_good_toast_button = + h_button("Nice!", SvgIcon::Plus, false).on_press(Message::AddToast(Toast { + title: "Hello".to_string(), + body: "This is a toast".to_string(), + status: ToastStatus::Good, + })); + + let add_error_toast_button = + h_button("Error Toast", SvgIcon::Plus, false).on_press(Message::AddToast(Toast { + title: "Error".to_string(), + body: "This is a toast".to_string(), + status: ToastStatus::Bad, + })); + let column = match (harbor.settings_show_seed_words, &harbor.seed_words) { (true, Some(s)) => { let button = h_button("Hide Seed Words", SvgIcon::Squirrel, false) @@ -23,7 +37,12 @@ pub fn settings(harbor: &HarborWallet) -> Element { let button = h_button("Show Seed Words", SvgIcon::Squirrel, false) .on_press(Message::ShowSeedWords(true)); - column![header, button] + column![ + header, + button, + add_good_toast_button, + add_error_toast_button + ] } };