Merge #275: gui: add modal widget and change spend actions

bde519fa7bf1eeed72950961d09eaf96acfc9e93 Integrate modal component in spend tx actions (edouard)
7ffde7381b166acabd121e1915365cc8b517d566 ui: add modal component (edouard)

Pull request description:

  based on #274
  close #198
  close #171
  close #235

ACKs for top commit:
  edouardparis:
    Self-ACK bde519fa7bf1eeed72950961d09eaf96acfc9e93

Tree-SHA512: 44be8ca11676e64a31b08339cfc2979f3427ca586c391e603aada8d8018c283253a0ee6558ecbfc978240845d3a1c57249f35da2e1f709b12e06a999cbc7753b
This commit is contained in:
edouard 2023-01-03 15:29:24 +01:00
commit 683294da1e
No known key found for this signature in database
GPG Key ID: E65F7A089C20DC8F
8 changed files with 415 additions and 125 deletions

View File

@ -26,6 +26,7 @@ pub enum Message {
Psbt(Result<Psbt, Error>),
Signed(Result<(Psbt, Fingerprint), Error>),
Updated(Result<(), Error>),
Saved(Result<(), Error>),
StartRescan(Result<(), Error>),
ConnectedHardwareWallets(Vec<HardwareWallet>),
HistoryTransactions(Result<Vec<HistoryTransaction>, Error>),

View File

@ -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<dyn Daemon + Sync + Send>) -> Command<Message> {
Command::none()
}
@ -40,13 +38,13 @@ pub struct SpendTxState {
config: Config,
tx: SpendTx,
saved: bool,
action: Box<dyn Action>,
action: Option<Box<dyn Action>>,
}
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<dyn Daemon + Sync + Send>) -> Command<Message> {
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<Message> {
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<dyn Action> {
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<dyn Daemon + Sync + Send>,
@ -159,20 +150,17 @@ impl Action for SaveAction {
Command::none()
}
fn view(&self) -> Element<view::Message> {
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<Error>,
}
impl Action for BroadcastAction {
fn warning(&self) -> Option<&Error> {
self.error.as_ref()
}
fn update(
&mut self,
daemon: Arc<dyn Daemon + Sync + Send>,
@ -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<view::Message> {
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<dyn Daemon + Sync + Send>,
@ -248,7 +235,7 @@ impl Action for DeleteAction {
Command::none()
}
fn view(&self) -> Element<view::Message> {
detail::delete_action(self.deleted)
detail::delete_action(self.error.as_ref(), self.deleted)
}
}
@ -259,7 +246,6 @@ pub struct SignAction {
hws: Vec<HardwareWallet>,
error: Option<Error>,
signed: Vec<Fingerprint>,
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<dyn Daemon + Sync + Send>) -> Command<Message> {
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::Message> {
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<view::Message> {
iced::widget::Column::new().into()
}
}

View File

@ -27,6 +27,9 @@ pub enum CreateSpendMessage {
#[derive(Debug, Clone)]
pub enum SpendTxMessage {
Delete,
Sign,
Broadcast,
Save,
Confirm,
Cancel,
SelectHardwareWallet(usize),

View File

@ -24,21 +24,14 @@ use crate::{
},
};
pub fn spend_view<'a, T: Into<Element<'a, Message>>>(
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<Message> {
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<Element<'a, Message>>>(
)
}
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<Element<'a, Message>>>(
show_delete: bool,
saved: bool,
warning: Option<&Error>,
content: T,
) -> Element<'a, Message> {
@ -132,7 +140,7 @@ pub fn spend_modal<'a, T: Into<Element<'a, Message>>>(
.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<Element<'a, Message>>>(
.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<usize>,
@ -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()
}

View File

@ -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),
),

View File

@ -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)]

View File

@ -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;

View File

@ -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<Message>,
}
impl<'a, Message, Renderer> Modal<'a, Message, Renderer> {
/// Returns a new [`Modal`]
pub fn new(
base: impl Into<Element<'a, Message, Renderer>>,
modal: impl Into<Element<'a, Message, Renderer>>,
) -> 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<Message, Renderer> for Modal<'a, Message, Renderer>
where
Renderer: iced_native::Renderer,
Message: Clone,
{
fn children(&self) -> Vec<Tree> {
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: &<Renderer as iced_native::Renderer>::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<overlay::Element<'b, Message, Renderer>> {
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<Message>,
) {
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<Message>,
}
impl<'a, 'b, Message, Renderer> overlay::Overlay<Message, Renderer>
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<Message>) {
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<Modal<'a, Message, Renderer>> 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)
}
}