From 7ffde7381b166acabd121e1915365cc8b517d566 Mon Sep 17 00:00:00 2001 From: edouard Date: Thu, 29 Dec 2022 17:35:39 +0100 Subject: [PATCH 1/2] ui: add modal component --- gui/src/ui/component/mod.rs | 1 + gui/src/ui/component/modal.rs | 273 ++++++++++++++++++++++++++++++++++ 2 files changed, 274 insertions(+) create mode 100644 gui/src/ui/component/modal.rs diff --git a/gui/src/ui/component/mod.rs b/gui/src/ui/component/mod.rs index cc41b055..3f97ac8d 100644 --- a/gui/src/ui/component/mod.rs +++ b/gui/src/ui/component/mod.rs @@ -4,6 +4,7 @@ pub mod card; pub mod collapse; pub mod container; pub mod form; +pub mod modal; pub mod notification; pub mod text; diff --git a/gui/src/ui/component/modal.rs b/gui/src/ui/component/modal.rs new file mode 100644 index 00000000..13716482 --- /dev/null +++ b/gui/src/ui/component/modal.rs @@ -0,0 +1,273 @@ +/// modal widget from https://github.com/iced-rs/iced/blob/master/examples/modal/ +use iced_native::alignment::Alignment; +use iced_native::widget::{self, Tree}; +use iced_native::{ + event, layout, mouse, overlay, renderer, Clipboard, Color, Element, Event, Layout, Length, + Point, Rectangle, Shell, Size, Widget, +}; + +/// A widget that centers a modal element over some base element +pub struct Modal<'a, Message, Renderer> { + base: Element<'a, Message, Renderer>, + modal: Element<'a, Message, Renderer>, + on_blur: Option, +} + +impl<'a, Message, Renderer> Modal<'a, Message, Renderer> { + /// Returns a new [`Modal`] + pub fn new( + base: impl Into>, + modal: impl Into>, + ) -> Self { + Self { + base: base.into(), + modal: modal.into(), + on_blur: None, + } + } + + /// Sets the message that will be produces when the background + /// of the [`Modal`] is pressed + pub fn on_blur(self, on_blur: Message) -> Self { + Self { + on_blur: Some(on_blur), + ..self + } + } +} + +impl<'a, Message, Renderer> Widget for Modal<'a, Message, Renderer> +where + Renderer: iced_native::Renderer, + Message: Clone, +{ + fn children(&self) -> Vec { + vec![Tree::new(&self.base), Tree::new(&self.modal)] + } + + fn diff(&self, tree: &mut Tree) { + tree.diff_children(&[&self.base, &self.modal]); + } + + fn width(&self) -> Length { + self.base.as_widget().width() + } + + fn height(&self) -> Length { + self.base.as_widget().height() + } + + fn layout(&self, renderer: &Renderer, limits: &layout::Limits) -> layout::Node { + self.base.as_widget().layout(renderer, limits) + } + + 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.base.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.base.as_widget().draw( + &state.children[0], + renderer, + theme, + style, + layout, + cursor_position, + viewport, + ); + } + + fn overlay<'b>( + &'b mut self, + state: &'b mut Tree, + layout: Layout<'_>, + _renderer: &Renderer, + ) -> Option> { + Some(overlay::Element::new( + layout.position(), + Box::new(Overlay { + content: &mut self.modal, + tree: &mut state.children[1], + size: layout.bounds().size(), + on_blur: self.on_blur.clone(), + }), + )) + } + + fn mouse_interaction( + &self, + state: &Tree, + layout: Layout<'_>, + cursor_position: Point, + viewport: &Rectangle, + renderer: &Renderer, + ) -> mouse::Interaction { + self.base.as_widget().mouse_interaction( + &state.children[0], + layout, + cursor_position, + viewport, + renderer, + ) + } + + fn operate( + &self, + state: &mut Tree, + layout: Layout<'_>, + operation: &mut dyn widget::Operation, + ) { + self.base + .as_widget() + .operate(&mut state.children[0], layout, operation); + } +} + +struct Overlay<'a, 'b, Message, Renderer> { + content: &'b mut Element<'a, Message, Renderer>, + tree: &'b mut Tree, + size: Size, + on_blur: Option, +} + +impl<'a, 'b, Message, Renderer> overlay::Overlay + for Overlay<'a, 'b, Message, Renderer> +where + Renderer: iced_native::Renderer, + Message: Clone, +{ + fn layout(&self, renderer: &Renderer, _bounds: Size, position: Point) -> layout::Node { + let limits = layout::Limits::new(Size::ZERO, self.size) + .width(Length::Fill) + .height(Length::Fill); + + let mut child = self.content.as_widget().layout(renderer, &limits); + child.align(Alignment::Center, Alignment::Center, limits.max()); + + let mut node = layout::Node::with_children(self.size, vec![child]); + node.move_to(position); + + node + } + + fn on_event( + &mut self, + event: Event, + layout: Layout<'_>, + cursor_position: Point, + renderer: &Renderer, + clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, Message>, + ) -> event::Status { + let content_bounds = layout.children().next().unwrap().bounds(); + + if let Some(message) = self.on_blur.as_ref() { + if let Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) = &event { + if !content_bounds.contains(cursor_position) { + shell.publish(message.clone()); + return event::Status::Captured; + } + } + } + + self.content.as_widget_mut().on_event( + self.tree, + event, + layout.children().next().unwrap(), + cursor_position, + renderer, + clipboard, + shell, + ) + } + + fn draw( + &self, + renderer: &mut Renderer, + theme: &Renderer::Theme, + style: &renderer::Style, + layout: Layout<'_>, + cursor_position: Point, + ) { + renderer.fill_quad( + renderer::Quad { + bounds: layout.bounds(), + border_radius: renderer::BorderRadius::from(0.0), + border_width: 0.0, + border_color: Color::TRANSPARENT, + }, + Color { + a: 0.80, + ..Color::BLACK + }, + ); + + self.content.as_widget().draw( + self.tree, + renderer, + theme, + style, + layout.children().next().unwrap(), + cursor_position, + &layout.bounds(), + ); + } + + fn operate(&mut self, layout: Layout<'_>, operation: &mut dyn widget::Operation) { + self.content + .as_widget() + .operate(self.tree, layout.children().next().unwrap(), operation); + } + + fn mouse_interaction( + &self, + layout: Layout<'_>, + cursor_position: Point, + viewport: &Rectangle, + renderer: &Renderer, + ) -> mouse::Interaction { + self.content.as_widget().mouse_interaction( + self.tree, + layout.children().next().unwrap(), + cursor_position, + viewport, + renderer, + ) + } +} + +impl<'a, Message, Renderer> From> for Element<'a, Message, Renderer> +where + Renderer: 'a + iced_native::Renderer, + Message: 'a + Clone, +{ + fn from(modal: Modal<'a, Message, Renderer>) -> Self { + Element::new(modal) + } +} From bde519fa7bf1eeed72950961d09eaf96acfc9e93 Mon Sep 17 00:00:00 2001 From: edouard Date: Fri, 30 Dec 2022 12:26:05 +0100 Subject: [PATCH 2/2] Integrate modal component in spend tx actions --- gui/src/app/message.rs | 1 + gui/src/app/state/spend/detail.rs | 146 +++++++++++++----------------- gui/src/app/view/message.rs | 3 + gui/src/app/view/spend/detail.rs | 102 +++++++++++++-------- gui/src/app/view/spend/mod.rs | 4 +- gui/src/daemon/model.rs | 10 +- 6 files changed, 141 insertions(+), 125 deletions(-) diff --git a/gui/src/app/message.rs b/gui/src/app/message.rs index 438d0977..fa622ec8 100644 --- a/gui/src/app/message.rs +++ b/gui/src/app/message.rs @@ -26,6 +26,7 @@ pub enum Message { Psbt(Result), Signed(Result<(Psbt, Fingerprint), Error>), Updated(Result<(), Error>), + Saved(Result<(), Error>), StartRescan(Result<(), Error>), ConnectedHardwareWallets(Vec), HistoryTransactions(Result, Error>), diff --git a/gui/src/app/state/spend/detail.rs b/gui/src/app/state/spend/detail.rs index 709a5a73..49567f85 100644 --- a/gui/src/app/state/spend/detail.rs +++ b/gui/src/app/state/spend/detail.rs @@ -12,15 +12,13 @@ use crate::{ Daemon, }, hw::{list_hardware_wallets, HardwareWallet}, + ui::component::modal, }; trait Action { fn warning(&self) -> Option<&Error> { None } - fn updated(&self) -> bool { - false - } fn load(&self, _daemon: Arc) -> Command { Command::none() } @@ -40,13 +38,13 @@ pub struct SpendTxState { config: Config, tx: SpendTx, saved: bool, - action: Box, + action: Option>, } impl SpendTxState { pub fn new(config: Config, tx: SpendTx, saved: bool) -> Self { Self { - action: choose_action(&config, saved, &tx), + action: None, config, tx, saved, @@ -54,7 +52,11 @@ impl SpendTxState { } pub fn load(&self, daemon: Arc) -> Command { - self.action.load(daemon) + if let Some(action) = &self.action { + action.load(daemon) + } else { + Command::none() + } } pub fn update( @@ -63,60 +65,57 @@ impl SpendTxState { cache: &Cache, message: Message, ) -> Command { - let cmd = match &message { + match &message { Message::View(view::Message::Spend(msg)) => match msg { view::SpendTxMessage::Cancel => { - self.action = choose_action(&self.config, self.saved, &self.tx); - self.action.load(daemon.clone()) + self.action = None; } view::SpendTxMessage::Delete => { - self.action = Box::new(DeleteAction::default()); - self.action.load(daemon.clone()) + self.action = Some(Box::new(DeleteAction::default())); + } + view::SpendTxMessage::Sign => { + let action = SignAction::new(self.config.clone()); + let cmd = action.load(daemon); + self.action = Some(Box::new(action)); + return cmd; + } + view::SpendTxMessage::Broadcast => { + self.action = Some(Box::new(BroadcastAction::default())); + } + view::SpendTxMessage::Save => { + self.action = Some(Box::new(SaveAction::default())); + } + _ => { + if let Some(action) = self.action.as_mut() { + return action.update(daemon.clone(), cache, message, &mut self.tx); + } } - _ => self - .action - .update(daemon.clone(), cache, message, &mut self.tx), }, - _ => self - .action - .update(daemon.clone(), cache, message, &mut self.tx), + Message::Updated(Ok(_)) => { + self.saved = true; + if let Some(action) = self.action.as_mut() { + return action.update(daemon.clone(), cache, message, &mut self.tx); + } + } + _ => { + if let Some(action) = self.action.as_mut() { + return action.update(daemon.clone(), cache, message, &mut self.tx); + } + } }; - if self.action.updated() { - self.saved = true; - self.action = choose_action(&self.config, self.saved, &self.tx); - self.action.load(daemon) - } else { - cmd - } + Command::none() } pub fn view<'a>(&'a self, cache: &'a Cache) -> Element<'a, view::Message> { - detail::spend_view( - self.action.warning(), - &self.tx, - self.action.view(), - self.saved, - cache.network, - ) - } -} - -fn choose_action(config: &Config, saved: bool, tx: &SpendTx) -> Box { - if saved { - match tx.status { - SpendStatus::Deprecated | SpendStatus::Broadcasted => { - return Box::new(NoAction::default()); - } - _ => {} - } - - if !tx.psbt.inputs.first().unwrap().partial_sigs.is_empty() { - return Box::new(BroadcastAction::default()); + let content = detail::spend_view(&self.tx, self.saved, cache.network); + if let Some(action) = &self.action { + modal::Modal::new(content, action.view()) + .on_blur(view::Message::Spend(view::SpendTxMessage::Cancel)) + .into() } else { - return Box::new(SignAction::new(config.clone())); + content } } - Box::new(SaveAction::default()) } #[derive(Default)] @@ -126,14 +125,6 @@ pub struct SaveAction { } impl Action for SaveAction { - fn warning(&self) -> Option<&Error> { - self.error.as_ref() - } - - fn updated(&self) -> bool { - self.saved - } - fn update( &mut self, daemon: Arc, @@ -159,20 +150,17 @@ impl Action for SaveAction { Command::none() } fn view(&self) -> Element { - detail::save_action(self.saved) + detail::save_action(self.error.as_ref(), self.saved) } } #[derive(Default)] pub struct BroadcastAction { - broadcasted: bool, + broadcast: bool, error: Option, } impl Action for BroadcastAction { - fn warning(&self) -> Option<&Error> { - self.error.as_ref() - } fn update( &mut self, daemon: Arc, @@ -195,7 +183,10 @@ impl Action for BroadcastAction { ); } Message::Updated(res) => match res { - Ok(()) => self.broadcasted = true, + Ok(()) => { + tx.status = SpendStatus::Broadcast; + self.broadcast = true; + } Err(e) => self.error = Some(e), }, _ => {} @@ -203,7 +194,7 @@ impl Action for BroadcastAction { Command::none() } fn view(&self) -> Element { - detail::broadcast_action(self.broadcasted) + detail::broadcast_action(self.error.as_ref(), self.broadcast) } } @@ -214,10 +205,6 @@ pub struct DeleteAction { } impl Action for DeleteAction { - fn warning(&self) -> Option<&Error> { - self.error.as_ref() - } - fn update( &mut self, daemon: Arc, @@ -248,7 +235,7 @@ impl Action for DeleteAction { Command::none() } fn view(&self) -> Element { - detail::delete_action(self.deleted) + detail::delete_action(self.error.as_ref(), self.deleted) } } @@ -259,7 +246,6 @@ pub struct SignAction { hws: Vec, error: Option, signed: Vec, - updated: bool, } impl SignAction { @@ -271,7 +257,6 @@ impl SignAction { hws: Vec::new(), error: None, signed: Vec::new(), - updated: false, } } } @@ -281,10 +266,6 @@ impl Action for SignAction { self.error.as_ref() } - fn updated(&self) -> bool { - self.updated - } - fn load(&self, daemon: Arc) -> Command { let config = self.config.clone(); let desc = daemon.config().main_descriptor.to_string(); @@ -327,7 +308,7 @@ impl Action for SignAction { } }, Message::Updated(res) => match res { - Ok(()) => self.updated = true, + Ok(()) => self.processing = false, Err(e) => self.error = Some(e), }, // We add the new hws without dropping the reference of the previous ones. @@ -346,7 +327,13 @@ impl Action for SignAction { Command::none() } fn view(&self) -> Element { - view::spend::detail::sign_action(&self.hws, self.processing, self.chosen_hw, &self.signed) + view::spend::detail::sign_action( + self.error.as_ref(), + &self.hws, + self.processing, + self.chosen_hw, + &self.signed, + ) } } @@ -362,12 +349,3 @@ async fn sign_psbt( hw.sign_tx(&mut psbt).await.map_err(Error::from)?; Ok((psbt, fingerprint)) } - -#[derive(Default)] -pub struct NoAction {} - -impl Action for NoAction { - fn view(&self) -> Element { - iced::widget::Column::new().into() - } -} diff --git a/gui/src/app/view/message.rs b/gui/src/app/view/message.rs index 21affcad..8cf2bc0e 100644 --- a/gui/src/app/view/message.rs +++ b/gui/src/app/view/message.rs @@ -27,6 +27,9 @@ pub enum CreateSpendMessage { #[derive(Debug, Clone)] pub enum SpendTxMessage { Delete, + Sign, + Broadcast, + Save, Confirm, Cancel, SelectHardwareWallet(usize), diff --git a/gui/src/app/view/spend/detail.rs b/gui/src/app/view/spend/detail.rs index b1b342ac..c7966a9c 100644 --- a/gui/src/app/view/spend/detail.rs +++ b/gui/src/app/view/spend/detail.rs @@ -24,21 +24,14 @@ use crate::{ }, }; -pub fn spend_view<'a, T: Into>>( - warning: Option<&Error>, - tx: &'a SpendTx, - action: T, - show_delete: bool, - network: Network, -) -> Element<'a, Message> { +pub fn spend_view(tx: &SpendTx, saved: bool, network: Network) -> Element { spend_modal( - show_delete, - warning, + saved, + None, Column::new() .align_items(Alignment::Center) .spacing(20) .push(spend_header(tx)) - .push(action) .push(spend_overview_view(tx)) .push(inputs_and_outputs_view( &tx.coins, @@ -50,36 +43,44 @@ pub fn spend_view<'a, T: Into>>( ) } -pub fn save_action<'a>(saved: bool) -> Element<'a, Message> { +pub fn save_action<'a>(warning: Option<&Error>, saved: bool) -> Element<'a, Message> { if saved { card::simple(text("Transaction is saved")) - .width(Length::Fill) + .width(Length::Units(400)) .align_x(iced::alignment::Horizontal::Center) .into() } else { card::simple( Column::new() .spacing(10) - .push(text("Save the transaction")) - .push(Row::new().push(Column::new().width(Length::Fill)).push( - button::primary(None, "Save").on_press(Message::Spend(SpendTxMessage::Confirm)), - )), + .push_maybe(warning.map(|w| warn(Some(w)))) + .push(text("Save the transaction as draft")) + .push( + Row::new() + .push(Column::new().width(Length::Fill)) + .push(button::alert(None, "Ignore").on_press(Message::Close)) + .push( + button::primary(None, "Save") + .on_press(Message::Spend(SpendTxMessage::Confirm)), + ), + ), ) - .width(Length::Fill) + .width(Length::Units(400)) .into() } } -pub fn broadcast_action<'a>(saved: bool) -> Element<'a, Message> { +pub fn broadcast_action<'a>(warning: Option<&Error>, saved: bool) -> Element<'a, Message> { if saved { - card::simple(text("Transaction is broadcasted")) - .width(Length::Fill) + card::simple(text("Transaction is broadcast")) + .width(Length::Units(400)) .align_x(iced::alignment::Horizontal::Center) .into() } else { card::simple( Column::new() .spacing(10) + .push_maybe(warning.map(|w| warn(Some(w)))) .push(text("Broadcast the transaction")) .push( Row::new().push(Column::new().width(Length::Fill)).push( @@ -88,21 +89,28 @@ pub fn broadcast_action<'a>(saved: bool) -> Element<'a, Message> { ), ), ) - .width(Length::Fill) + .width(Length::Units(400)) .into() } } -pub fn delete_action<'a>(deleted: bool) -> Element<'a, Message> { +pub fn delete_action<'a>(warning: Option<&Error>, deleted: bool) -> Element<'a, Message> { if deleted { - card::simple(text("Transaction is deleted")) - .align_x(iced::alignment::Horizontal::Center) - .width(Length::Fill) - .into() + card::simple( + Column::new() + .spacing(20) + .align_items(Alignment::Center) + .push(text("Transaction is deleted")) + .push(button::primary(None, "Go back to drafts").on_press(Message::Close)), + ) + .align_x(iced::alignment::Horizontal::Center) + .width(Length::Units(400)) + .into() } else { card::simple( Column::new() .spacing(10) + .push_maybe(warning.map(|w| warn(Some(w)))) .push(text("Delete the transaction draft")) .push( Row::new() @@ -117,13 +125,13 @@ pub fn delete_action<'a>(deleted: bool) -> Element<'a, Message> { ), ), ) - .width(Length::Fill) + .width(Length::Units(400)) .into() } } pub fn spend_modal<'a, T: Into>>( - show_delete: bool, + saved: bool, warning: Option<&Error>, content: T, ) -> Element<'a, Message> { @@ -132,7 +140,7 @@ pub fn spend_modal<'a, T: Into>>( .push( Container::new( Row::new() - .push(if show_delete { + .push(if saved { Column::new() .push( button::alert(Some(icon::trash_icon()), "Delete") @@ -147,9 +155,12 @@ pub fn spend_modal<'a, T: Into>>( .width(Length::Fill) }) .align_items(iced::Alignment::Center) - .push( - button::primary(Some(icon::cross_icon()), "Close").on_press(Message::Close), - ), + .push(if saved { + button::primary(Some(icon::cross_icon()), "Close").on_press(Message::Close) + } else { + button::primary(Some(icon::cross_icon()), "Close") + .on_press(Message::Spend(SpendTxMessage::Save)) + }), ) .padding(10) .style(container::Style::Background), @@ -185,8 +196,8 @@ fn spend_header<'a>(tx: &SpendTx) -> Element<'a, Message> { .padding(3) .style(badge::PillStyle::Simple), ), - SpendStatus::Broadcasted => Some( - Container::new(text(" Broadcasted ").small()) + SpendStatus::Broadcast => Some( + Container::new(text(" Broadcast ").small()) .padding(3) .style(badge::PillStyle::Success), ), @@ -225,8 +236,25 @@ fn spend_overview_view<'a>(tx: &SpendTx) -> Element<'a, Message> { .push(text(format!( "{}", tx.psbt.inputs[0].partial_sigs.len(), - ))), + ))) + .width(Length::Fill), ) + .push_maybe(if tx.status == SpendStatus::Pending { + if !tx.is_signed() { + Some( + button::primary(None, "Sign") + .on_press(Message::Spend(SpendTxMessage::Sign)), + ) + } else { + Some( + button::primary(None, "Broadcast").on_press( + Message::Spend(SpendTxMessage::Broadcast), + ), + ) + } + } else { + None + }) .align_items(Alignment::Center) .spacing(20), ) @@ -471,6 +499,7 @@ pub fn inputs_and_outputs_view<'a>( } pub fn sign_action<'a>( + warning: Option<&Error>, hws: &[HardwareWallet], processing: bool, chosen_hw: Option, @@ -478,6 +507,7 @@ pub fn sign_action<'a>( ) -> Element<'a, Message> { card::simple( Column::new() + .push_maybe(warning.map(|w| warn(Some(w)))) .push(if !hws.is_empty() { Column::new() .push( @@ -520,6 +550,6 @@ pub fn sign_action<'a>( .width(Length::Fill) .align_items(Alignment::Center), ) - .width(Length::Fill) + .width(Length::Units(500)) .into() } diff --git a/gui/src/app/view/spend/mod.rs b/gui/src/app/view/spend/mod.rs index d7f3e0df..49fdea25 100644 --- a/gui/src/app/view/spend/mod.rs +++ b/gui/src/app/view/spend/mod.rs @@ -62,8 +62,8 @@ fn spend_tx_list_view<'a>(i: usize, tx: &SpendTx) -> Element<'a, Message> { .padding(3) .style(badge::PillStyle::Simple), ), - SpendStatus::Broadcasted => Some( - Container::new(text(" Broadcasted ").small()) + SpendStatus::Broadcast => Some( + Container::new(text(" Broadcast ").small()) .padding(3) .style(badge::PillStyle::Success), ), diff --git a/gui/src/daemon/model.rs b/gui/src/daemon/model.rs index d64230fe..ec14e83d 100644 --- a/gui/src/daemon/model.rs +++ b/gui/src/daemon/model.rs @@ -30,11 +30,11 @@ pub struct SpendTx { pub status: SpendStatus, } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Eq)] pub enum SpendStatus { Pending, Deprecated, - Broadcasted, + Broadcast, } impl SpendTx { @@ -58,7 +58,7 @@ impl SpendTx { inputs_amount += coin.amount; if let Some(info) = coin.spend_info { if info.txid == psbt.unsigned_tx.txid() { - status = SpendStatus::Broadcasted + status = SpendStatus::Broadcast } else { status = SpendStatus::Deprecated } @@ -74,6 +74,10 @@ impl SpendTx { status, } } + + pub fn is_signed(&self) -> bool { + !self.psbt.inputs.first().unwrap().partial_sigs.is_empty() + } } #[derive(Debug, Clone)]