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 7931d22e5..623a3a728 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, @@ -49,7 +50,39 @@ pub trait Action { ) -> Command { Command::none() } - fn view(&self) -> Element; + fn view<'a>(&'a self, content: Element<'a, view::Message>) -> Element<'a, view::Message>; +} + +pub enum PsbtAction { + Save(SaveAction), + Sign(SignAction), + Update(UpdateAction), + Broadcast(BroadcastAction), + Delete(DeleteAction), +} + +impl<'a> AsRef for PsbtAction { + fn as_ref(&self) -> &(dyn Action + 'a) { + match &self { + Self::Save(a) => a, + Self::Sign(a) => a, + Self::Update(a) => a, + Self::Broadcast(a) => a, + Self::Delete(a) => a, + } + } +} + +impl<'a> AsMut for PsbtAction { + fn as_mut(&mut self) -> &mut (dyn Action + 'a) { + match self { + Self::Save(a) => a, + Self::Sign(a) => a, + Self::Update(a) => a, + Self::Broadcast(a) => a, + Self::Delete(a) => a, + } + } } pub struct PsbtState { @@ -59,7 +92,7 @@ pub struct PsbtState { pub saved: bool, pub warning: Option, pub labels_edited: LabelsEdited, - pub action: Option>, + pub action: Option, } impl PsbtState { @@ -77,7 +110,7 @@ impl PsbtState { pub fn subscription(&self) -> Subscription { if let Some(action) = &self.action { - action.subscription() + action.as_ref().subscription() } else { Subscription::none() } @@ -85,7 +118,7 @@ impl PsbtState { pub fn load(&self, daemon: Arc) -> Command { if let Some(action) = &self.action { - action.load(daemon) + action.as_ref().load(daemon) } else { Command::none() } @@ -100,12 +133,26 @@ impl PsbtState { match &message { Message::View(view::Message::Spend(msg)) => match msg { view::SpendTxMessage::Cancel => { + if let Some(PsbtAction::Sign(SignAction { display_modal, .. })) = + &mut self.action + { + *display_modal = false; + return Command::none(); + } + self.action = None; } view::SpendTxMessage::Delete => { - self.action = Some(Box::::default()); + self.action = Some(PsbtAction::Delete(DeleteAction::default())); } view::SpendTxMessage::Sign => { + if let Some(PsbtAction::Sign(SignAction { display_modal, .. })) = + &mut self.action + { + *display_modal = true; + return Command::none(); + } + let action = SignAction::new( self.tx.signers(), self.wallet.clone(), @@ -114,24 +161,26 @@ impl PsbtState { self.saved, ); let cmd = action.load(daemon); - self.action = Some(Box::new(action)); + self.action = Some(PsbtAction::Sign(action)); return cmd; } view::SpendTxMessage::EditPsbt => { let action = UpdateAction::new(self.wallet.clone(), self.tx.psbt.to_string()); let cmd = action.load(daemon); - self.action = Some(Box::new(action)); + self.action = Some(PsbtAction::Update(action)); return cmd; } view::SpendTxMessage::Broadcast => { - self.action = Some(Box::::default()); + self.action = Some(PsbtAction::Broadcast(BroadcastAction::default())); } view::SpendTxMessage::Save => { - self.action = Some(Box::::default()); + self.action = Some(PsbtAction::Save(SaveAction::default())); } _ => { if let Some(action) = self.action.as_mut() { - return action.update(daemon.clone(), message, &mut self.tx); + return action + .as_mut() + .update(daemon.clone(), message, &mut self.tx); } } }, @@ -152,12 +201,16 @@ impl PsbtState { Message::Updated(Ok(_)) => { self.saved = true; if let Some(action) = self.action.as_mut() { - return action.update(daemon.clone(), message, &mut self.tx); + return action + .as_mut() + .update(daemon.clone(), message, &mut self.tx); } } _ => { if let Some(action) = self.action.as_mut() { - return action.update(daemon.clone(), message, &mut self.tx); + return action + .as_mut() + .update(daemon.clone(), message, &mut self.tx); } } }; @@ -176,9 +229,7 @@ impl PsbtState { self.warning.as_ref(), ); if let Some(action) = &self.action { - modal::Modal::new(content, action.view()) - .on_blur(Some(view::Message::Spend(view::SpendTxMessage::Cancel))) - .into() + action.as_ref().view(content) } else { content } @@ -224,8 +275,13 @@ impl Action for SaveAction { } Command::none() } - fn view(&self) -> Element { - view::psbt::save_action(self.error.as_ref(), self.saved) + fn view<'a>(&'a self, content: Element<'a, view::Message>) -> Element<'a, view::Message> { + modal::Modal::new( + content, + view::psbt::save_action(self.error.as_ref(), self.saved), + ) + .on_blur(Some(view::Message::Spend(view::SpendTxMessage::Cancel))) + .into() } } @@ -267,8 +323,13 @@ impl Action for BroadcastAction { } Command::none() } - fn view(&self) -> Element { - view::psbt::broadcast_action(self.error.as_ref(), self.broadcast) + fn view<'a>(&'a self, content: Element<'a, view::Message>) -> Element<'a, view::Message> { + modal::Modal::new( + content, + view::psbt::broadcast_action(self.error.as_ref(), self.broadcast), + ) + .on_blur(Some(view::Message::Spend(view::SpendTxMessage::Cancel))) + .into() } } @@ -307,19 +368,24 @@ impl Action for DeleteAction { } Command::none() } - fn view(&self) -> Element { - view::psbt::delete_action(self.error.as_ref(), self.deleted) + fn view<'a>(&'a self, content: Element<'a, view::Message>) -> Element<'a, view::Message> { + modal::Modal::new( + content, + view::psbt::delete_action(self.error.as_ref(), self.deleted), + ) + .on_blur(Some(view::Message::Spend(view::SpendTxMessage::Cancel))) + .into() } } pub struct SignAction { wallet: Arc, - chosen_hw: Option, - processing: bool, hws: HardwareWallets, error: Option, + signing: HashSet, signed: HashSet, is_saved: bool, + display_modal: bool, } impl SignAction { @@ -331,13 +397,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_modal: true, } } } @@ -365,61 +431,61 @@ impl Action for SignAction { .. }) = self.hws.list.get(i) { - self.chosen_hw = Some(i); - self.processing = true; + self.display_modal = 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(); + merge_signatures(&mut tx.psbt, &psbt); + 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), }, @@ -431,51 +497,78 @@ 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(&self) -> Element { - 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, + fn view<'a>(&'a self, content: Element<'a, view::Message>) -> Element<'a, view::Message> { + let content = toast::Manager::new( + content, + view::psbt::sign_action_toasts(self.error.as_ref(), &self.hws.list, &self.signing), ) + .into(); + if self.display_modal { + 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 { + content + } + } +} + +fn merge_signatures(psbt: &mut Psbt, signed_psbt: &Psbt) { + for i in 0..signed_psbt.inputs.len() { + let psbtin = match psbt.inputs.get_mut(i) { + Some(psbtin) => psbtin, + None => continue, + }; + let signed_psbtin = match signed_psbt.inputs.get(i) { + Some(signed_psbtin) => signed_psbtin, + None => continue, + }; + psbtin + .partial_sigs + .extend(&mut signed_psbtin.partial_sigs.iter()); } } 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. @@ -504,7 +597,7 @@ async fn sign_psbt( } else { hw.sign_tx(&mut psbt).await.map_err(Error::from)?; } - Ok((psbt, fingerprint)) + Ok(psbt) } pub struct UpdateAction { @@ -530,17 +623,22 @@ impl UpdateAction { } impl Action for UpdateAction { - fn view(&self) -> Element { - if self.success { - view::psbt::update_spend_success_view() - } else { - view::psbt::update_spend_view( - self.psbt.clone(), - &self.updated, - self.error.as_ref(), - self.processing, - ) - } + fn view<'a>(&'a self, content: Element<'a, view::Message>) -> Element<'a, view::Message> { + modal::Modal::new( + content, + if self.success { + view::psbt::update_spend_success_view() + } else { + view::psbt::update_spend_view( + self.psbt.clone(), + &self.updated, + self.error.as_ref(), + self.processing, + ) + }, + ) + .on_blur(Some(view::Message::Spend(view::SpendTxMessage::Cancel))) + .into() } fn update( diff --git a/gui/src/app/state/spend/step.rs b/gui/src/app/state/spend/step.rs index 552785b02..9230d96a1 100644 --- a/gui/src/app/state/spend/step.rs +++ b/gui/src/app/state/spend/step.rs @@ -14,10 +14,7 @@ use liana::{ }, }; -use liana_ui::{ - component::{form, modal}, - widget::Element, -}; +use liana_ui::{component::form, widget::Element}; use crate::{ app::{cache::Cache, error::Error, message::Message, state::psbt, view, wallet::Wallet}, @@ -652,9 +649,7 @@ impl Step for SaveSpend { spend.warning.as_ref(), ); if let Some(action) = &spend.action { - modal::Modal::new(content, action.view()) - .on_blur(Some(view::Message::Spend(view::SpendTxMessage::Cancel))) - .into() + action.as_ref().view(content) } else { content } 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 d0a8d76b6..4033181c1 100644 --- a/gui/src/app/view/psbt.rs +++ b/gui/src/app/view/psbt.rs @@ -941,9 +941,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)))) @@ -963,11 +962,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), )) }, )) @@ -992,6 +992,55 @@ pub fn sign_action<'a>( .into() } +pub fn sign_action_toasts<'a>( + error: Option<&Error>, + hws: &'a [HardwareWallet], + signing: &HashSet, +) -> Vec> { + let mut vec: Vec> = hws + .iter() + .filter_map(|hw| { + if let HardwareWallet::Supported { + kind, + fingerprint, + version, + alias, + .. + } = &hw + { + if signing.contains(fingerprint) { + Some( + liana_ui::component::notification::processing_hardware_wallet( + kind, + version.as_ref(), + fingerprint, + alias.as_ref(), + ) + .max_width(400.0) + .into(), + ) + } else { + None + } + } else { + None + } + }) + .collect(); + if let Some(e) = error { + vec.push( + liana_ui::component::notification::processing_hardware_wallet_error( + "Device failed to sign".to_string(), + e.to_string(), + ) + .max_width(400.0) + .into(), + ) + } + + vec +} + pub fn update_spend_view<'a>( psbt: String, updated: &form::Value, 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/notification.rs b/gui/ui/src/component/notification.rs index 9a2cef653..ed6b57942 100644 --- a/gui/ui/src/component/notification.rs +++ b/gui/ui/src/component/notification.rs @@ -1,10 +1,17 @@ +use std::borrow::Cow; +use std::fmt::Display; + use crate::{ color, component::{collapse, text}, icon, theme, + util::*, widget::*, }; -use iced::{Alignment, Length}; +use iced::{ + widget::{column, container, row}, + Alignment, Length, +}; pub fn warning<'a, T: 'a + Clone>(message: String, error: String) -> Container<'a, T> { let message_clone = message.clone(); @@ -14,7 +21,7 @@ pub fn warning<'a, T: 'a + Clone>(message: String, error: String) -> Container<' Row::new() .push( Container::new( - text::p1_bold(message_clone.to_string()).style(color::WHITE), + text::p1_bold(message_clone.to_string()).style(color::LIGHT_BLACK), ) .width(Length::Fill), ) @@ -22,8 +29,8 @@ pub fn warning<'a, T: 'a + Clone>(message: String, error: String) -> Container<' Row::new() .align_items(Alignment::Center) .spacing(10) - .push(text::p1_bold("Learn more").style(color::WHITE)) - .push(icon::collapse_icon()), + .push(text::p1_bold("Learn more").style(color::LIGHT_BLACK)) + .push(icon::collapse_icon().style(color::LIGHT_BLACK)), ), ) .style(theme::Button::Transparent) @@ -32,15 +39,15 @@ pub fn warning<'a, T: 'a + Clone>(message: String, error: String) -> Container<' Button::new( Row::new() .push( - Container::new(text::p1_bold(message.to_owned()).style(color::WHITE)) + Container::new(text::p1_bold(message.to_owned()).style(color::LIGHT_BLACK)) .width(Length::Fill), ) .push( Row::new() .align_items(Alignment::Center) .spacing(10) - .push(text::p1_bold("Learn more").style(color::WHITE)) - .push(icon::collapsed_icon()), + .push(text::p1_bold("Learn more").style(color::LIGHT_BLACK)) + .push(icon::collapsed_icon().style(color::LIGHT_BLACK)), ), ) .style(theme::Button::Transparent) @@ -51,3 +58,86 @@ pub fn warning<'a, T: 'a + Clone>(message: String, error: String) -> Container<' .style(theme::Container::Card(theme::Card::Warning)) .width(Length::Fill) } + +pub fn processing_hardware_wallet<'a, T: 'a, K: Display, V: Display, F: Display>( + kind: K, + version: Option, + fingerprint: F, + alias: Option>>, +) -> Container<'a, T> { + container( + row(vec![ + column(vec![ + Row::new() + .spacing(5) + .push_maybe(alias.map(|a| text::p1_bold(a))) + .push(text::p1_regular(format!("#{}", fingerprint))) + .into(), + Row::new() + .spacing(5) + .push(text::caption(kind.to_string())) + .push_maybe(version.map(|v| text::caption(v.to_string()))) + .into(), + ]) + .width(Length::Fill) + .into(), + column(vec![ + text::p2_regular("Processing...").into(), + text::p2_regular("Please check your device").into(), + ]) + .into(), + ]) + .align_items(Alignment::Center), + ) + .style(theme::Container::Notification(theme::Notification::Pending)) + .padding(10) +} + +pub fn processing_hardware_wallet_error<'a, T: 'a + Clone>( + message: String, + error: String, +) -> Container<'a, T> { + let message_clone = message.clone(); + Container::new(Container::new(collapse::Collapse::new( + move || { + Button::new( + Row::new() + .push( + Container::new( + text::p1_bold(message_clone.to_string()).style(color::LIGHT_BLACK), + ) + .width(Length::Fill), + ) + .push( + Row::new() + .align_items(Alignment::Center) + .spacing(10) + .push(text::p1_bold("Learn more").style(color::LIGHT_BLACK)) + .push(icon::collapse_icon().style(color::LIGHT_BLACK)), + ), + ) + .style(theme::Button::Transparent) + }, + move || { + Button::new( + Row::new() + .push( + Container::new(text::p1_bold(message.to_owned()).style(color::LIGHT_BLACK)) + .width(Length::Fill), + ) + .push( + Row::new() + .align_items(Alignment::Center) + .spacing(10) + .push(text::p1_bold("Learn more").style(color::LIGHT_BLACK)) + .push(icon::collapsed_icon().style(color::LIGHT_BLACK)), + ), + ) + .style(theme::Button::Transparent) + }, + move || Element::<'a, T>::from(text::p2_regular(error.to_owned())), + ))) + .padding(10) + .style(theme::Container::Notification(theme::Notification::Error)) + .width(Length::Fill) +} diff --git a/gui/ui/src/component/toast.rs b/gui/ui/src/component/toast.rs new file mode 100644 index 000000000..7a3e3fdd8 --- /dev/null +++ b/gui/ui/src/component/toast.rs @@ -0,0 +1,348 @@ +use std::time::Instant; + +use super::theme::Theme; + +use iced::{Alignment, Element, Length, Point, Rectangle, Size, Vector}; +use iced_native::widget::{Operation, Tree}; +use iced_native::{event, layout, mouse, overlay, renderer}; +use iced_native::{Clipboard, Event, Layout, Shell, Widget}; + +pub trait Toast { + fn title(&self) -> &str; + fn body(&self) -> &str; +} + +pub struct Manager<'a, Message, Renderer> { + content: Element<'a, Message, Renderer>, + toasts: Vec>, +} + +impl<'a, Message> Manager<'a, Message, iced::Renderer> +where + Message: 'a + Clone, +{ + pub fn new( + content: impl Into>>, + toasts: Vec>>, + ) -> Self { + Self { + content: content.into(), + toasts: toasts.into_iter().collect(), + } + } +} + +impl<'a, Message, Renderer> Widget for Manager<'a, Message, Renderer> +where + Renderer: iced_native::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) -> iced_native::widget::tree::Tag { + struct Marker(Vec); + iced_native::widget::tree::Tag::of::() + } + + fn state(&self) -> iced_native::widget::tree::State { + iced_native::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, &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_position: Point, + renderer: &Renderer, + clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, Message>, + ) -> event::Status { + self.content.as_widget_mut().on_event( + &mut state.children[0], + event, + layout, + cursor_position, + renderer, + clipboard, + shell, + ) + } + + fn draw( + &self, + state: &Tree, + renderer: &mut Renderer, + theme: &::Theme, + style: &renderer::Style, + layout: Layout<'_>, + cursor_position: Point, + viewport: &Rectangle, + ) { + self.content.as_widget().draw( + &state.children[0], + renderer, + theme, + style, + layout, + cursor_position, + viewport, + ); + } + + fn mouse_interaction( + &self, + state: &Tree, + layout: Layout<'_>, + cursor_position: Point, + viewport: &Rectangle, + renderer: &Renderer, + ) -> mouse::Interaction { + self.content.as_widget().mouse_interaction( + &state.children[0], + layout, + cursor_position, + 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, + }), + ) + }); + 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], +} + +impl<'a, 'b, Message, Renderer> overlay::Overlay + for Overlay<'a, 'b, Message, Renderer> +where + Renderer: iced_native::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_position: Point, + renderer: &Renderer, + clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, Message>, + ) -> event::Status { + 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_position, + renderer, + clipboard, + &mut local_shell, + ); + + 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_position: Point, + ) { + 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_position, + &viewport, + ); + } + } + + fn operate( + &mut self, + layout: Layout<'_>, + renderer: &Renderer, + operation: &mut dyn iced_native::widget::Operation, + ) { + operation.container(None, &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_position: Point, + 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_position, + viewport, + renderer, + ) + }) + .max() + .unwrap_or_default() + } + + fn is_over(&self, layout: Layout<'_>, 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 + iced_native::Renderer, + Message: 'a, +{ + fn from(manager: Manager<'a, Message, Renderer>) -> Self { + Element::new(manager) + } +} diff --git a/gui/ui/src/theme.rs b/gui/ui/src/theme.rs index 777ba1a07..e40d9010c 100644 --- a/gui/ui/src/theme.rs +++ b/gui/ui/src/theme.rs @@ -93,6 +93,7 @@ pub enum Container { Badge(Badge), Pill(Pill), Custom(iced::Color), + Notification(Notification), QrCode, } @@ -122,6 +123,7 @@ impl container::StyleSheet for Theme { Container::Card(c) => c.appearance(self), Container::Badge(c) => c.appearance(self), Container::Pill(c) => c.appearance(self), + Container::Notification(c) => c.appearance(self), Container::Custom(c) => container::Appearance { background: (*c).into(), ..container::Appearance::default() @@ -154,6 +156,7 @@ impl container::StyleSheet for Theme { Container::Card(c) => c.appearance(self), Container::Badge(c) => c.appearance(self), Container::Pill(c) => c.appearance(self), + Container::Notification(c) => c.appearance(self), Container::Custom(c) => container::Appearance { background: (*c).into(), ..container::Appearance::default() @@ -186,6 +189,52 @@ impl From for Container { } } +#[derive(Debug, Copy, Clone, Default)] +pub enum Notification { + #[default] + Pending, + Error, +} + +impl Notification { + fn appearance(&self, theme: &Theme) -> iced::widget::container::Appearance { + match theme { + Theme::Light => match self { + Self::Pending => container::Appearance { + background: color::GREEN.into(), + text_color: color::LIGHT_BLACK.into(), + border_width: 1.0, + border_color: color::GREEN, + border_radius: 25.0, + }, + Self::Error => container::Appearance { + background: color::ORANGE.into(), + text_color: color::LIGHT_BLACK.into(), + border_width: 1.0, + border_color: color::ORANGE, + border_radius: 25.0, + }, + }, + Theme::Dark => match self { + Self::Pending => container::Appearance { + background: color::GREEN.into(), + text_color: color::LIGHT_BLACK.into(), + border_width: 1.0, + border_color: color::GREEN, + border_radius: 25.0, + }, + Self::Error => container::Appearance { + background: color::ORANGE.into(), + text_color: color::LIGHT_BLACK.into(), + border_width: 1.0, + border_color: color::ORANGE, + border_radius: 25.0, + }, + }, + } + } +} + #[derive(Debug, Copy, Clone, Default)] pub enum Card { #[default] @@ -260,7 +309,7 @@ impl Card { }, Card::Warning => container::Appearance { background: color::ORANGE.into(), - text_color: color::WHITE.into(), + text_color: color::LIGHT_BLACK.into(), ..container::Appearance::default() }, }, @@ -574,7 +623,7 @@ impl button::StyleSheet for Theme { }, Button::Transparent => button::Appearance { shadow_offset: iced::Vector::default(), - background: color::GREY_7.into(), + background: iced::Color::TRANSPARENT.into(), border_radius: 25.0, border_width: 0.0, border_color: iced::Color::TRANSPARENT,