diff --git a/gui/Cargo.lock b/gui/Cargo.lock index 2edbe4d8..4e98bd16 100644 --- a/gui/Cargo.lock +++ b/gui/Cargo.lock @@ -23,6 +23,12 @@ dependencies = [ "mach 0.1.2", ] +[[package]] +name = "Inflector" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe438c63458706e03479442743baae6c88256498e6431708f6dfc520a26515d3" + [[package]] name = "ab_glyph" version = "0.2.15" @@ -86,6 +92,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "aliasable" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "250f629c0161ad8107cf89319e990051fae62832fd343083bea452d93e2205fd" + [[package]] name = "approx" version = "0.5.1" @@ -1159,6 +1171,17 @@ dependencies = [ "thiserror", ] +[[package]] +name = "iced_lazy" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "333979d705964832864ee7676516ab3c3df4ab0b65efb603c86a256d4adbec6f" +dependencies = [ + "iced_native", + "iced_pure", + "ouroboros", +] + [[package]] name = "iced_native" version = "0.5.1" @@ -1624,7 +1647,9 @@ dependencies = [ "dirs", "fern", "iced", + "iced_lazy", "iced_native", + "iced_pure", "log", "minisafe", "serde", @@ -1920,6 +1945,30 @@ dependencies = [ "num-traits", ] +[[package]] +name = "ouroboros" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f357ef82d1b4db66fbed0b8d542cbd3c22d0bf5b393b3c257b9ba4568e70c9c3" +dependencies = [ + "aliasable", + "ouroboros_macro", + "stable_deref_trait", +] + +[[package]] +name = "ouroboros_macro" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44a0b52c2cbaef7dffa5fec1a43274afe8bd2a644fa9fc50a9ef4ff0269b1257" +dependencies = [ + "Inflector", + "proc-macro-error", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "owned_ttf_parser" version = "0.15.0" @@ -2012,6 +2061,30 @@ dependencies = [ "toml", ] +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + [[package]] name = "proc-macro2" version = "1.0.41" @@ -2537,6 +2610,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "stable_deref_trait" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" + [[package]] name = "static_assertions" version = "1.1.0" @@ -2725,7 +2804,7 @@ version = "1.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97fee6b57c6a41524a810daee9286c02d7752c4253064d0b05472833a438f675" dependencies = [ - "cfg-if 0.1.10", + "cfg-if 1.0.0", "rand 0.8.5", "static_assertions", ] diff --git a/gui/Cargo.toml b/gui/Cargo.toml index d1b71997..581fb88f 100644 --- a/gui/Cargo.toml +++ b/gui/Cargo.toml @@ -21,6 +21,8 @@ base64 = "0.13" iced = { version = "0.4", default-features= false, features = ["tokio", "wgpu", "svg", "qr_code", "pure"] } iced_native = "0.5" +iced_lazy = { version = "0.1.1", features = ["pure"] } +iced_pure = "0.2.2" tokio = {version = "1.21.0", features = ["signal"]} serde = { version = "1.0", features = ["derive"] } diff --git a/gui/src/app/mod.rs b/gui/src/app/mod.rs index 64b0b147..6b444926 100644 --- a/gui/src/app/mod.rs +++ b/gui/src/app/mod.rs @@ -65,7 +65,11 @@ impl App { ) .into(), menu::Menu::Home => Home::new(&self.cache.coins).into(), - menu::Menu::Coins => CoinsPanel::new(&self.cache.coins).into(), + menu::Menu::Coins => CoinsPanel::new( + &self.cache.coins, + self.daemon.config().main_descriptor.timelock_value(), + ) + .into(), menu::Menu::Receive => ReceivePanel::default().into(), menu::Menu::Spend => SpendPanel::new(self.config.clone(), &self.cache.spend_txs).into(), menu::Menu::CreateSpendTx => { diff --git a/gui/src/app/state/coins.rs b/gui/src/app/state/coins.rs index 0b5123c5..14d7ff0e 100644 --- a/gui/src/app/state/coins.rs +++ b/gui/src/app/state/coins.rs @@ -12,14 +12,26 @@ pub struct CoinsPanel { coins: Vec, selected_coin: Option, warning: Option, + /// timelock value to pass for the heir to consume a coin. + timelock: u32, } impl CoinsPanel { - pub fn new(coins: &[Coin]) -> Self { + pub fn new(coins: &[Coin], timelock: u32) -> Self { Self { - coins: coins.to_owned(), + coins: coins + .iter() + .filter_map(|coin| { + if coin.spend_info.is_none() { + Some(*coin) + } else { + None + } + }) + .collect(), selected_coin: None, warning: None, + timelock, } } } @@ -30,7 +42,7 @@ impl State for CoinsPanel { &Menu::Coins, cache, self.warning.as_ref(), - view::coins::coins_view(&self.coins), + view::coins::coins_view(cache, &self.coins, self.timelock), ) } @@ -45,7 +57,16 @@ impl State for CoinsPanel { Err(e) => self.warning = Some(e), Ok(coins) => { self.warning = None; - self.coins = coins; + self.coins = coins + .iter() + .filter_map(|coin| { + if coin.spend_info.is_none() { + Some(*coin) + } else { + None + } + }) + .collect(); } }, Message::View(view::Message::Close) => { diff --git a/gui/src/app/view/coins.rs b/gui/src/app/view/coins.rs index 47a474b9..b7311476 100644 --- a/gui/src/app/view/coins.rs +++ b/gui/src/app/view/coins.rs @@ -1,16 +1,21 @@ use iced::{ - pure::{button, column, container, row, Element}, + pure::{column, container, row, Element}, Alignment, Length, }; use crate::ui::{ - component::{badge, button::Style, card, text::*}, + color, + component::{badge, card, collapse::collapse, separation, text::*}, + icon, util::Collection, }; -use crate::{app::view::message::Message, daemon::model::Coin}; +use crate::{ + app::{cache::Cache, view::message::Message}, + daemon::model::Coin, +}; -pub fn coins_view<'a>(coins: &[Coin]) -> Element<'a, Message> { +pub fn coins_view<'a>(cache: &Cache, coins: &'a [Coin], timelock: u32) -> Element<'a, Message> { column() .push( container( @@ -21,32 +26,64 @@ pub fn coins_view<'a>(coins: &[Coin]) -> Element<'a, Message> { .width(Length::Fill), ) .push( - column().spacing(10).push( - coins - .iter() - .enumerate() - .fold(column().spacing(10), |col, (i, coin)| { - col.push(coin_list_view(i, coin)) - }), - ), + column() + .spacing(10) + .push(coins.iter().fold(column().spacing(10), |col, coin| { + col.push(coin_list_view(coin, timelock, cache.blockheight as u32)) + })), ) .align_items(Alignment::Center) .spacing(20) .into() } -fn coin_list_view<'a>(i: usize, coin: &Coin) -> Element<'a, Message> { - container( - button( - row() +#[allow(clippy::collapsible_else_if)] +fn coin_list_view(coin: &Coin, timelock: u32, blockheight: u32) -> Element { + container(collapse::<_, _, _, _, _>( + move || { + row::() .push( row() .push(badge::coin()) - .push_maybe(coin.spend_info.map(|_| { - container(text(" Spent ").small()) - .padding(3) - .style(badge::PillStyle::Success) - })) + .push_maybe(if coin.spend_info.is_some() { + Some( + container(text(" Spent ").small()) + .padding(3) + .style(badge::PillStyle::Success), + ) + } else { + if let Some(b) = coin.block_height { + if blockheight > b as u32 + timelock { + Some(container( + row() + .spacing(5) + .push(text(" 0").small().color(color::ALERT)) + .push( + icon::hourglass_done_icon() + .small() + .color(color::ALERT), + ) + .align_items(Alignment::Center), + )) + } else { + Some(container( + row() + .spacing(5) + .push( + text(&format!( + " {}", + b as u32 + timelock - blockheight + )) + .small(), + ) + .push(icon::hourglass_icon().small()) + .align_items(Alignment::Center), + )) + } + } else { + None + } + }) .spacing(10) .align_items(Alignment::Center) .width(Length::Fill), @@ -57,12 +94,79 @@ fn coin_list_view<'a>(i: usize, coin: &Coin) -> Element<'a, Message> { .width(Length::Shrink), ) .align_items(Alignment::Center) - .spacing(20), - ) - .padding(10) - .on_press(Message::Select(i)) - .style(Style::TransparentBorder), - ) + .spacing(20) + .into() + }, + move || { + column() + .spacing(10) + .push(separation().width(Length::Fill)) + .push( + column() + .padding(10) + .spacing(5) + .push_maybe(if coin.spend_info.is_none() { + if let Some(b) = coin.block_height { + if blockheight > b as u32 + timelock { + Some(container( + text("The recovery path is available") + .bold() + .small() + .color(color::ALERT), + )) + } else { + Some(container( + text(&format!( + "The recovery path will be available in {} blocks", + b as u32 + timelock - blockheight + )) + .bold() + .small(), + )) + } + } else { + None + } + } else { + None + }) + .push( + column() + .push( + row() + .push(text("Outpoint:").small().bold()) + .push(text(&format!("{}", coin.outpoint)).small()) + .spacing(5), + ) + .push_maybe(coin.block_height.map(|b| { + row() + .push(text("Block height:").small().bold()) + .push(text(&format!("{}", b)).small()) + .spacing(5) + })), + ) + .push_maybe(coin.spend_info.map(|info| { + column() + .push( + row() + .push(text("Spend txid:").small().bold()) + .push(text(&format!("{}", info.txid)).small()) + .spacing(5), + ) + .push(if let Some(height) = info.height { + row() + .push(text("Spend block height:").small().bold()) + .push(text(&format!("{}", height)).small()) + .spacing(5) + } else { + row().push(text("Not in a block").bold().small()) + }) + .spacing(5) + })), + ) + .into() + }, + )) .style(card::SimpleCardStyle) .into() } diff --git a/gui/src/ui/component/collapse.rs b/gui/src/ui/component/collapse.rs new file mode 100644 index 00000000..3137d748 --- /dev/null +++ b/gui/src/ui/component/collapse.rs @@ -0,0 +1,95 @@ +use iced::pure::{button, column}; +use iced_lazy::pure::{self, Component}; +use iced_native::text; +use iced_pure::Element; +use std::marker::PhantomData; + +use crate::ui::component::button::Style; + +pub fn collapse< + 'a, + Message: 'a, + T: Into + Clone + 'a, + Renderer: text::Renderer + 'static, + H: Fn() -> Element<'a, T, Renderer> + 'a, + C: Fn() -> Element<'a, T, Renderer> + 'a, +>( + header: H, + content: C, +) -> impl Into> { + Collapse { + header, + content, + phantom: PhantomData, + } +} + +struct Collapse<'a, H, C> { + header: H, + content: C, + phantom: PhantomData<&'a H>, +} + +#[derive(Debug, Clone, Copy)] +enum Event { + Internal(T), + Collapse(bool), +} + +impl<'a, Message, Renderer, T, H, C> Component for Collapse<'a, H, C> +where + T: Into + Clone + 'a, + H: Fn() -> Element<'a, T, Renderer>, + C: Fn() -> Element<'a, T, Renderer>, + Renderer: text::Renderer + 'static, +{ + type State = bool; + type Event = Event; + + fn update(&mut self, state: &mut Self::State, event: Event) -> Option { + match event { + Event::Internal(e) => Some(e.into()), + Event::Collapse(s) => { + *state = s; + None + } + } + } + + fn view(&self, state: &Self::State) -> Element { + if *state { + column() + .push( + button((self.header)().map(Event::Internal)) + .style(Style::TransparentBorder) + .padding(10) + .on_press(Event::Collapse(false)), + ) + .push((self.content)().map(Event::Internal)) + .into() + } else { + column() + .push( + button((self.header)().map(Event::Internal)) + .padding(10) + .style(Style::TransparentBorder) + .on_press(Event::Collapse(true)), + ) + .into() + } + } +} + +impl<'a, Message, Renderer, T, H: 'a, C: 'a> From> + for Element<'a, Message, Renderer> +where + Message: 'a, + Renderer: 'static + text::Renderer, + T: Into + Clone + 'a, + H: Fn() -> Element<'a, T, Renderer>, + C: Fn() -> Element<'a, T, Renderer>, +{ + fn from(c: Collapse<'a, H, C>) -> Self { + pure::component(c) + } +} diff --git a/gui/src/ui/component/mod.rs b/gui/src/ui/component/mod.rs index 0bae5edf..1c1e7877 100644 --- a/gui/src/ui/component/mod.rs +++ b/gui/src/ui/component/mod.rs @@ -1,6 +1,7 @@ pub mod badge; pub mod button; pub mod card; +pub mod collapse; pub mod form; pub mod text; diff --git a/gui/src/ui/icon.rs b/gui/src/ui/icon.rs index 52e27587..7860de10 100644 --- a/gui/src/ui/icon.rs +++ b/gui/src/ui/icon.rs @@ -14,6 +14,14 @@ fn icon(unicode: char) -> Text { .size(20) } +pub fn hourglass_icon() -> Text { + icon('\u{F41F}') +} + +pub fn hourglass_done_icon() -> Text { + icon('\u{F41E}') +} + pub fn vault_icon() -> Text { icon('\u{F65A}') }