diff --git a/gui/src/app/message.rs b/gui/src/app/message.rs index bc7b62b5b..6496df2ef 100644 --- a/gui/src/app/message.rs +++ b/gui/src/app/message.rs @@ -31,7 +31,7 @@ pub enum Message { SpendTxs(Result, Error>), Psbt(Result), Recovery(Result), - Signed(Result<(Psbt, Fingerprint), Error>), + Signed(Fingerprint, Result), WalletRegistered(Result), Updated(Result<(), Error>), Saved(Result<(), Error>), diff --git a/gui/src/app/state/psbt.rs b/gui/src/app/state/psbt.rs index 6f6bf9767..e05270909 100644 --- a/gui/src/app/state/psbt.rs +++ b/gui/src/app/state/psbt.rs @@ -10,6 +10,7 @@ use liana::{ miniscript::bitcoin::{bip32::Fingerprint, psbt::Psbt, Network}, }; +use liana_ui::component::toast; use liana_ui::{ component::{form, modal}, widget::Element, @@ -138,6 +139,11 @@ impl PsbtState { self.action = Some(PsbtAction::Delete(DeleteAction::default())); } view::SpendTxMessage::Sign => { + if let Some(PsbtAction::Sign(SignAction { display, .. })) = &mut self.action { + *display = true; + return Command::none(); + } + let action = SignAction::new( self.tx.signers(), self.wallet.clone(), @@ -365,12 +371,12 @@ impl Action for DeleteAction { pub struct SignAction { wallet: Arc, - chosen_hw: Option, - processing: bool, hws: HardwareWallets, error: Option, + signing: HashSet, signed: HashSet, is_saved: bool, + display: bool, } impl SignAction { @@ -382,13 +388,13 @@ impl SignAction { is_saved: bool, ) -> Self { Self { - chosen_hw: None, - processing: false, + signing: HashSet::new(), hws: HardwareWallets::new(datadir_path, network).with_wallet(wallet.clone()), wallet, error: None, signed, is_saved, + display: true, } } } @@ -416,61 +422,61 @@ impl Action for SignAction { .. }) = self.hws.list.get(i) { - self.chosen_hw = Some(i); - self.processing = true; + self.display = false; + self.signing.insert(*fingerprint); let psbt = tx.psbt.clone(); + let fingerprint = *fingerprint; return Command::perform( - sign_psbt(self.wallet.clone(), device.clone(), *fingerprint, psbt), - Message::Signed, + sign_psbt(self.wallet.clone(), device.clone(), psbt), + move |res| Message::Signed(fingerprint, res), ); } } Message::View(view::Message::Spend(view::SpendTxMessage::SelectHotSigner)) => { - self.processing = true; return Command::perform( sign_psbt_with_hot_signer(self.wallet.clone(), tx.psbt.clone()), - Message::Signed, + |(fg, res)| Message::Signed(fg, res), ); } - Message::Signed(res) => match res { - Err(e) => self.error = Some(e), - Ok((psbt, fingerprint)) => { - self.error = None; - self.signed.insert(fingerprint); - let daemon = daemon.clone(); - tx.psbt = psbt.clone(); - if self.is_saved { - return Command::perform( - async move { daemon.update_spend_tx(&psbt).map_err(|e| e.into()) }, - Message::Updated, - ); - // If the spend transaction was never saved before, then both the psbt and - // labels attached to it must be updated. - } else { - let mut labels = HashMap::>::new(); - for (item, label) in tx.labels() { - if !label.is_empty() { - labels.insert(label_item_from_str(item), Some(label.clone())); + Message::Signed(fingerprint, res) => { + self.signing.remove(&fingerprint); + match res { + Err(e) => self.error = Some(e), + Ok(psbt) => { + self.error = None; + self.signed.insert(fingerprint); + let daemon = daemon.clone(); + tx.psbt = psbt.clone(); + if self.is_saved { + return Command::perform( + async move { daemon.update_spend_tx(&psbt).map_err(|e| e.into()) }, + Message::Updated, + ); + // If the spend transaction was never saved before, then both the psbt and + // labels attached to it must be updated. + } else { + let mut labels = HashMap::>::new(); + for (item, label) in tx.labels() { + if !label.is_empty() { + labels.insert(label_item_from_str(item), Some(label.clone())); + } } + return Command::perform( + async move { + daemon.update_spend_tx(&psbt)?; + daemon.update_labels(&labels).map_err(|e| e.into()) + }, + Message::Updated, + ); } - return Command::perform( - async move { - daemon.update_spend_tx(&psbt)?; - daemon.update_labels(&labels).map_err(|e| e.into()) - }, - Message::Updated, - ); } } - }, + } Message::Updated(res) => match res { - Ok(()) => { - self.processing = false; - match self.wallet.main_descriptor.partial_spend_info(&tx.psbt) { - Ok(sigs) => tx.sigs = sigs, - Err(e) => self.error = Some(Error::Unexpected(e.to_string())), - } - } + Ok(()) => match self.wallet.main_descriptor.partial_spend_info(&tx.psbt) { + Ok(sigs) => tx.sigs = sigs, + Err(e) => self.error = Some(Error::Unexpected(e.to_string())), + }, Err(e) => self.error = Some(e), }, @@ -482,56 +488,57 @@ impl Action for SignAction { self.error = Some(e.into()); } }, - Message::View(view::Message::Reload) => { - self.chosen_hw = None; - self.error = None; - return self.load(daemon); - } _ => {} }; Command::none() } fn view<'a>(&'a self, content: Element<'a, view::Message>) -> Element<'a, view::Message> { - modal::Modal::new( - content, - view::psbt::sign_action( - self.error.as_ref(), - &self.hws.list, - self.wallet.signer.as_ref().map(|s| s.fingerprint()), - self.wallet - .signer - .as_ref() - .and_then(|signer| self.wallet.keys_aliases.get(&signer.fingerprint)), - self.processing, - self.chosen_hw, - &self.signed, - ), - ) - .on_blur(Some(view::Message::Spend(view::SpendTxMessage::Cancel))) - .into() + if self.display { + modal::Modal::new( + content, + view::psbt::sign_action( + self.error.as_ref(), + &self.hws.list, + self.wallet.signer.as_ref().map(|s| s.fingerprint()), + self.wallet + .signer + .as_ref() + .and_then(|signer| self.wallet.keys_aliases.get(&signer.fingerprint)), + &self.signed, + &self.signing, + ), + ) + .on_blur(Some(view::Message::Spend(view::SpendTxMessage::Cancel))) + .into() + } else { + toast::Manager::new(content, &[("currently signing", "yes")]).into() + } } } async fn sign_psbt_with_hot_signer( wallet: Arc, psbt: Psbt, -) -> Result<(Psbt, Fingerprint), Error> { +) -> (Fingerprint, Result) { if let Some(signer) = &wallet.signer { - let psbt = signer.sign_psbt(psbt).map_err(|e| { - WalletError::HotSigner(format!("Hot signer failed to sign psbt: {}", e)) - })?; - Ok((psbt, signer.fingerprint())) + let res = signer + .sign_psbt(psbt) + .map_err(|e| WalletError::HotSigner(format!("Hot signer failed to sign psbt: {}", e))) + .map_err(|e| e.into()); + (signer.fingerprint(), res) } else { - Err(WalletError::HotSigner("Hot signer not loaded".to_string()).into()) + ( + Fingerprint::default(), + Err(WalletError::HotSigner("Hot signer not loaded".to_string()).into()), + ) } } async fn sign_psbt( wallet: Arc, hw: std::sync::Arc, - fingerprint: Fingerprint, mut psbt: Psbt, -) -> Result<(Psbt, Fingerprint), Error> { +) -> Result { // The BitBox02 is only going to produce a signature for a single key in the Script. In order // to make sure it doesn't sign for a public key from another spending path we remove the BIP32 // derivation for the other paths. @@ -560,7 +567,7 @@ async fn sign_psbt( } else { hw.sign_tx(&mut psbt).await.map_err(Error::from)?; } - Ok((psbt, fingerprint)) + Ok(psbt) } pub struct UpdateAction { diff --git a/gui/src/app/view/hw.rs b/gui/src/app/view/hw.rs index 05cacf602..65a914c82 100644 --- a/gui/src/app/view/hw.rs +++ b/gui/src/app/view/hw.rs @@ -11,9 +11,8 @@ use async_hwi::DeviceKind; pub fn hw_list_view( i: usize, hw: &HardwareWallet, - chosen: bool, - processing: bool, signed: bool, + signing: bool, ) -> Element { let mut bttn = Button::new(match hw { HardwareWallet::Supported { @@ -24,7 +23,7 @@ pub fn hw_list_view( registered, .. } => { - if chosen && processing { + if signing { hw::processing_hardware_wallet(kind, version.as_ref(), fingerprint, alias.as_ref()) } else if signed { hw::sign_success_hardware_wallet( @@ -61,7 +60,7 @@ pub fn hw_list_view( }) .style(theme::Button::Border) .width(Length::Fill); - if !processing { + if !signing { if let HardwareWallet::Supported { registered, .. } = hw { if *registered != Some(false) { bttn = bttn.on_press(Message::SelectHardwareWallet(i)); diff --git a/gui/src/app/view/psbt.rs b/gui/src/app/view/psbt.rs index e861c7b97..8db4a595d 100644 --- a/gui/src/app/view/psbt.rs +++ b/gui/src/app/view/psbt.rs @@ -945,9 +945,8 @@ pub fn sign_action<'a>( hws: &'a [HardwareWallet], signer: Option, signer_alias: Option<&'a String>, - processing: bool, - chosen_hw: Option, signed: &HashSet, + signing: &HashSet, ) -> Element<'a, Message> { Column::new() .push_maybe(warning.map(|w| warn(Some(w)))) @@ -967,11 +966,12 @@ pub fn sign_action<'a>( col.push(hw_list_view( i, hw, - Some(i) == chosen_hw, - processing, hw.fingerprint() .map(|f| signed.contains(&f)) .unwrap_or(false), + hw.fingerprint() + .map(|f| signing.contains(&f)) + .unwrap_or(false), )) }, )) diff --git a/gui/ui/src/component/mod.rs b/gui/ui/src/component/mod.rs index dc9d88b35..19e24b375 100644 --- a/gui/ui/src/component/mod.rs +++ b/gui/ui/src/component/mod.rs @@ -9,6 +9,7 @@ pub mod hw; pub mod modal; pub mod notification; pub mod text; +pub mod toast; pub mod tooltip; pub use tooltip::tooltip; diff --git a/gui/ui/src/component/toast.rs b/gui/ui/src/component/toast.rs new file mode 100644 index 000000000..6a7903d35 --- /dev/null +++ b/gui/ui/src/component/toast.rs @@ -0,0 +1,431 @@ +use std::fmt; +use std::time::{Duration, Instant}; + +use super::theme::Theme; +use crate::widget::*; + +use iced::advanced; +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::mouse; +use iced::window; +use iced::{Alignment, Element, Length, Point, Rectangle, Size, Vector}; + +pub const DEFAULT_TIMEOUT: u64 = 5; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum Status { + #[default] + Primary, + Secondary, + Success, + Danger, +} + +impl Status { + pub const ALL: &'static [Self] = &[Self::Primary, Self::Secondary, Self::Success, Self::Danger]; +} + +impl fmt::Display for Status { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Status::Primary => "Primary", + Status::Secondary => "Secondary", + Status::Success => "Success", + Status::Danger => "Danger", + } + .fmt(f) + } +} + +#[derive(Debug, Clone, Default)] +pub struct Toast { + pub title: String, + pub body: String, + pub status: Status, +} + +pub struct Manager<'a, Message, Renderer> { + content: Element<'a, Message, Renderer>, + toasts: Vec>, + timeout_secs: u64, + on_close: Box Message + 'a>, +} + +impl<'a, Message> Manager<'a, Message, iced::Renderer> +where + Message: 'a + Clone, +{ + 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)| Column::new().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, Renderer> Widget for Manager<'a, Message, Renderer> +where + Renderer: iced::advanced::Renderer, +{ + fn width(&self) -> Length { + self.content.as_widget().width() + } + + fn height(&self) -> Length { + self.content.as_widget().height() + } + + fn layout(&self, renderer: &Renderer, limits: &layout::Limits) -> layout::Node { + self.content.as_widget().layout(renderer, limits) + } + + fn tag(&self) -> widget::tree::Tag { + struct Marker(Vec); + 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, + ) -> 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); + + let toasts = (!self.toasts.is_empty()).then(|| { + overlay::Element::new( + layout.bounds().position(), + Box::new(Overlay { + 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, Renderer> { + toasts: &'b mut [Element<'a, Message, Renderer>], + state: &'b mut [Tree], + instants: &'b mut [Option], + on_close: &'b dyn Fn(usize) -> Message, + timeout_secs: u64, +} + +impl<'a, 'b, Message, Renderer> overlay::Overlay + for Overlay<'a, 'b, Message, Renderer> +where + Renderer: iced::advanced::Renderer, +{ + fn layout(&self, renderer: &Renderer, bounds: Size, position: Point) -> layout::Node { + let limits = layout::Limits::new(Size::ZERO, bounds) + .width(Length::Fill) + .height(Length::Fill); + + layout::flex::resolve( + layout::flex::Axis::Vertical, + renderer, + &limits, + 10.into(), + 10.0, + Alignment::End, + self.toasts, + ) + .translate(Vector::new(position.x, 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, Renderer> From> for Element<'a, Message, Renderer> +where + Renderer: 'a + advanced::Renderer, + Message: 'a, +{ + fn from(manager: Manager<'a, Message, Renderer>) -> Self { + Element::new(manager) + } +}