Merge #850: Gui show transaction detail while signing

7d97f466ef174f5c9a7f57f757f737cf66ff5d81 ui: add processing hw notification to components (edouardparis)
1ea292f058475e2a1139328f33133397f9fe7bde gui: merge psbt signatures instead of override full psbt (edouardparis)
a539cfe157bff1b765520514d6ed854a2603ec42 Add ui component toast from iced repo examples (edouardparis)
71bc585ed350cf22c6aef8a34596c984777f02a6 encapsulate psbt actions in enum (edouardparis)
e55269def7d4becd267505321eef37b9553a6393 Make psbt action components handle the whole view (edouardparis)

Pull request description:

  Fixes https://github.com/wizardsardine/liana/issues/644.

ACKs for top commit:
  edouardparis:
    Self-ACK 7d97f466ef174f5c9a7f57f757f737cf66ff5d81

Tree-SHA512: 497e1edafa2755e39498b5cfb0e90c978a5575fcdc544ee478af3468f39097133de6595db739fdc2ec004d3a8a0b3be715c389a44c745d8ba4802aa44612e20a
This commit is contained in:
edouardparis 2024-01-15 18:06:51 +01:00
commit ffbbaa8285
No known key found for this signature in database
GPG Key ID: E65F7A089C20DC8F
9 changed files with 756 additions and 127 deletions

View File

@ -31,7 +31,7 @@ pub enum Message {
SpendTxs(Result<Vec<SpendTx>, Error>),
Psbt(Result<Psbt, Error>),
Recovery(Result<SpendTx, Error>),
Signed(Result<(Psbt, Fingerprint), Error>),
Signed(Fingerprint, Result<Psbt, Error>),
WalletRegistered(Result<Fingerprint, Error>),
Updated(Result<(), Error>),
Saved(Result<(), Error>),

View File

@ -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<Message> {
Command::none()
}
fn view(&self) -> Element<view::Message>;
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<dyn Action + 'a> 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<dyn Action + 'a> 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<Error>,
pub labels_edited: LabelsEdited,
pub action: Option<Box<dyn Action>>,
pub action: Option<PsbtAction>,
}
impl PsbtState {
@ -77,7 +110,7 @@ impl PsbtState {
pub fn subscription(&self) -> Subscription<Message> {
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<dyn Daemon + Sync + Send>) -> Command<Message> {
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::<DeleteAction>::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::<BroadcastAction>::default());
self.action = Some(PsbtAction::Broadcast(BroadcastAction::default()));
}
view::SpendTxMessage::Save => {
self.action = Some(Box::<SaveAction>::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::Message> {
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::Message> {
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::Message> {
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<Wallet>,
chosen_hw: Option<usize>,
processing: bool,
hws: HardwareWallets,
error: Option<Error>,
signing: HashSet<Fingerprint>,
signed: HashSet<Fingerprint>,
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::<LabelItem, Option<String>>::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::<LabelItem, Option<String>>::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::Message> {
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<Wallet>,
psbt: Psbt,
) -> Result<(Psbt, Fingerprint), Error> {
) -> (Fingerprint, Result<Psbt, Error>) {
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<Wallet>,
hw: std::sync::Arc<dyn async_hwi::HWI + Send + Sync>,
fingerprint: Fingerprint,
mut psbt: Psbt,
) -> Result<(Psbt, Fingerprint), Error> {
) -> Result<Psbt, Error> {
// 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<view::Message> {
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(

View File

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

View File

@ -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<Message> {
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));

View File

@ -941,9 +941,8 @@ pub fn sign_action<'a>(
hws: &'a [HardwareWallet],
signer: Option<Fingerprint>,
signer_alias: Option<&'a String>,
processing: bool,
chosen_hw: Option<usize>,
signed: &HashSet<Fingerprint>,
signing: &HashSet<Fingerprint>,
) -> 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<Fingerprint>,
) -> Vec<Element<'a, Message>> {
let mut vec: Vec<Element<'a, Message>> = 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<String>,

View File

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

View File

@ -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<V>,
fingerprint: F,
alias: Option<impl Into<Cow<'a, str>>>,
) -> 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)
}

View File

@ -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<Element<'a, Message, Renderer>>,
}
impl<'a, Message> Manager<'a, Message, iced::Renderer<Theme>>
where
Message: 'a + Clone,
{
pub fn new(
content: impl Into<Element<'a, Message, iced::Renderer<Theme>>>,
toasts: Vec<Element<'a, Message, iced::Renderer<Theme>>>,
) -> Self {
Self {
content: content.into(),
toasts: toasts.into_iter().collect(),
}
}
}
impl<'a, Message, Renderer> Widget<Message, Renderer> 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<Instant>);
iced_native::widget::tree::Tag::of::<Marker>()
}
fn state(&self) -> iced_native::widget::tree::State {
iced_native::widget::tree::State::new(Vec::<Option<Instant>>::new())
}
fn children(&self) -> Vec<Tree> {
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::<Vec<Option<Instant>>>();
// 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::<Vec<_>>(),
);
}
fn operate(
&self,
state: &mut Tree,
layout: Layout<'_>,
renderer: &Renderer,
operation: &mut dyn Operation<Message>,
) {
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: &<Renderer as iced_native::Renderer>::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<overlay::Element<'b, Message, Renderer>> {
let instants = state.state.downcast_mut::<Vec<Option<Instant>>>();
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::<Vec<_>>();
(!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<Instant>],
}
impl<'a, 'b, Message, Renderer> overlay::Overlay<Message, Renderer>
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: &<Renderer as iced_native::Renderer>::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<Message>,
) {
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<Manager<'a, Message, Renderer>> for Element<'a, Message, Renderer>
where
Renderer: 'a + iced_native::Renderer,
Message: 'a,
{
fn from(manager: Manager<'a, Message, Renderer>) -> Self {
Element::new(manager)
}
}

View File

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