From 013feb3909ebed0a8850a51a7d61383f327b5d4d Mon Sep 17 00:00:00 2001 From: Michael Mallan Date: Mon, 7 Oct 2024 13:55:43 +0100 Subject: [PATCH 1/7] ui: refactor amount function The `amount` function can call `amount_with_size` rather than repeating the underlying call to `render_amount`. --- gui/ui/src/component/amount.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gui/ui/src/component/amount.rs b/gui/ui/src/component/amount.rs index 1e64c768..78ca2ec7 100644 --- a/gui/ui/src/component/amount.rs +++ b/gui/ui/src/component/amount.rs @@ -3,7 +3,7 @@ pub use bitcoin::Amount; use crate::{color, component::text::*, widget::*}; pub fn amount<'a, T: 'a>(a: &Amount) -> Row<'a, T> { - render_amount(amount_as_string(*a), P1_SIZE) + amount_with_size(a, P1_SIZE) } pub fn amount_with_size<'a, T: 'a>(a: &Amount, size: u16) -> Row<'a, T> { From e4c1ab106d74ad193aa1ba2f5746ce598c4801e5 Mon Sep 17 00:00:00 2001 From: Michael Mallan Date: Mon, 7 Oct 2024 12:25:43 +0100 Subject: [PATCH 2/7] ui: render amount with chosen colors --- gui/ui/src/component/amount.rs | 39 +++++++++++++++++++++++++++++----- 1 file changed, 34 insertions(+), 5 deletions(-) diff --git a/gui/ui/src/component/amount.rs b/gui/ui/src/component/amount.rs index 78ca2ec7..c270462c 100644 --- a/gui/ui/src/component/amount.rs +++ b/gui/ui/src/component/amount.rs @@ -1,13 +1,33 @@ pub use bitcoin::Amount; +use iced::Color; use crate::{color, component::text::*, widget::*}; +/// Amount with default size and colors. pub fn amount<'a, T: 'a>(a: &Amount) -> Row<'a, T> { amount_with_size(a, P1_SIZE) } +/// Amount with default colors. pub fn amount_with_size<'a, T: 'a>(a: &Amount, size: u16) -> Row<'a, T> { - render_amount(amount_as_string(*a), size) + amount_with_size_and_colors(a, size, color::GREY_3, None) +} + +/// Amount with the given size and colors. +/// +/// `color_before` is the color to use before the first non-zero +/// value in `a`. +/// +/// `color_after` is the color to use from the first non-zero +/// value in `a` onwards. If `None`, the default theme value +/// will be used. +pub fn amount_with_size_and_colors<'a, T: 'a>( + a: &Amount, + size: u16, + color_before: Color, + color_after: Option, +) -> Row<'a, T> { + render_amount(amount_as_string(*a), size, color_before, color_after) } pub fn unconfirmed_amount_with_size<'a, T: 'a>(a: &Amount, size: u16) -> Row<'a, T> { @@ -67,7 +87,12 @@ fn split_at_first_non_zero(s: String) -> Option<(String, String)> { // Build the rendering elements for displaying a Bitcoin amount. // The text should be bolded beginning where the BTC amount is non-zero. -fn render_amount<'a, T: 'a>(amount: String, size: u16) -> Row<'a, T> { +fn render_amount<'a, T: 'a>( + amount: String, + size: u16, + color_before: Color, + color_after: Option, +) -> Row<'a, T> { let spacing = if size > P1_SIZE { 10 } else { 5 }; let (before, after) = match split_at_first_non_zero(amount) { @@ -75,13 +100,17 @@ fn render_amount<'a, T: 'a>(amount: String, size: u16) -> Row<'a, T> { None => (String::from("0.00 000 000"), String::from("")), }; + let mut child_after = text(after).size(size).bold(); + if let Some(color_after) = color_after { + child_after = child_after.style(color_after); + } let row = Row::new() - .push(text(before).size(size).style(color::GREY_3)) - .push(text(after).size(size).bold()); + .push(text(before).size(size).style(color_before)) + .push(child_after); Row::with_children(vec![ row.into(), - text("BTC").size(size).style(color::GREY_3).into(), + text("BTC").size(size).style(color_before).into(), ]) .spacing(spacing) .align_items(iced::Alignment::Center) From 3c46a7337c57ef646626d052a0ab23a7fa0ecb17 Mon Sep 17 00:00:00 2001 From: Michael Mallan Date: Mon, 7 Oct 2024 15:04:25 +0100 Subject: [PATCH 3/7] ui: add carousel loading spinner --- gui/ui/src/component/mod.rs | 1 + gui/ui/src/component/spinner.rs | 157 ++++++++++++++++++++++++++++++++ 2 files changed, 158 insertions(+) create mode 100644 gui/ui/src/component/spinner.rs diff --git a/gui/ui/src/component/mod.rs b/gui/ui/src/component/mod.rs index 06ec126f..edce276a 100644 --- a/gui/ui/src/component/mod.rs +++ b/gui/ui/src/component/mod.rs @@ -8,6 +8,7 @@ pub mod form; pub mod hw; pub mod modal; pub mod notification; +pub mod spinner; pub mod text; pub mod toast; pub mod tooltip; diff --git a/gui/ui/src/component/spinner.rs b/gui/ui/src/component/spinner.rs new file mode 100644 index 00000000..3b530c78 --- /dev/null +++ b/gui/ui/src/component/spinner.rs @@ -0,0 +1,157 @@ +use std::time::Duration; + +use iced::{ + advanced::{ + layout, renderer, + widget::tree::{self, Tree}, + Clipboard, Layout, Shell, Widget, + }, + event, mouse, + time::Instant, + window, Element, Event, Length, Rectangle, Renderer, Size, +}; + +/// A loading spinner widget that cycles through a collection of +/// `children` at a fixed rate. +/// +/// `interval` is how long to wait before displaying the next child. +pub struct Carousel<'a, Message, Theme> { + interval: Duration, + children: Vec>, +} + +impl<'a, Message, Theme> Carousel<'a, Message, Theme> { + pub fn new(interval: Duration, children: Vec>>) -> Self { + Carousel { + interval, + children: children.into_iter().map(|child| child.into()).collect(), + } + } +} + +/// The state of a `Carousel`. +/// +/// `last_transition` is when the `current`th child +/// of `Carousel::children` was selected. +struct CarouselState { + last_transition: Instant, + current: usize, +} + +impl CarouselState { + fn new() -> Self { + Self { + last_transition: Instant::now(), + current: 0, + } + } +} + +impl<'a, Message, Theme> Widget for Carousel<'a, Message, Theme> +where + Message: 'a + Clone, +{ + fn tag(&self) -> tree::Tag { + tree::Tag::of::() + } + + fn diff(&self, tree: &mut Tree) { + tree.diff_children(self.children.as_slice()); + } + + fn layout( + &self, + tree: &mut Tree, + renderer: &Renderer, + limits: &layout::Limits, + ) -> layout::Node { + let state = tree.state.downcast_mut::(); + let child_nodes: Vec<_> = self + .children + .iter() + .enumerate() + .map(|(i, child)| { + child + .as_widget() + .layout(&mut tree.children[i], renderer, limits) + }) + .collect(); + layout::Node::with_children(child_nodes[state.current].size(), child_nodes) + } + + fn children(&self) -> Vec { + self.children.iter().map(|child| Tree::new(child)).collect() + } + + fn state(&self) -> tree::State { + tree::State::new(CarouselState::new()) + } + + fn size(&self) -> Size { + // Use an arbitrary size here as the layout node size + // is determined from the current child. + Size { + width: Length::Shrink, + height: Length::Shrink, + } + } + + fn on_event( + &mut self, + tree: &mut Tree, + event: Event, + _layout: Layout<'_>, + _cursor: mouse::Cursor, + _renderer: &Renderer, + _clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, Message>, + _viewport: &Rectangle, + ) -> event::Status { + let state = tree.state.downcast_mut::(); + if let Event::Window(_, window::Event::RedrawRequested(now)) = event { + if now.duration_since(state.last_transition) > self.interval { + state.last_transition = now; + state.current = (state.current + 1) % self.children.len(); + } + shell.request_redraw(window::RedrawRequest::NextFrame); + } + event::Status::Ignored + } + + fn draw( + &self, + tree: &Tree, + renderer: &mut Renderer, + theme: &Theme, + style: &renderer::Style, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + ) { + let state = tree.state.downcast_ref::(); + let current = self.children.get(state.current).expect("current"); + let current_layout = layout + .children() + .nth(state.current) + .expect("current layout"); + current.as_widget().draw( + &tree.children[state.current], + renderer, + theme, + style, + current_layout, + cursor, + viewport, + ); + } +} + +impl<'a, Message, Theme> From> for Element<'a, Message, Theme> +where + Message: 'a + Clone, + Theme: 'a, +{ + fn from(carousel: Carousel<'a, Message, Theme>) -> Self { + Element::new(carousel) + } +} From 1d1e735ae975bb73af5389090e1eee68c727c7ea Mon Sep 17 00:00:00 2001 From: Michael Mallan Date: Mon, 7 Oct 2024 15:05:53 +0100 Subject: [PATCH 4/7] ui: add loading spinner that types text --- gui/ui/src/component/spinner.rs | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/gui/ui/src/component/spinner.rs b/gui/ui/src/component/spinner.rs index 3b530c78..6d061dba 100644 --- a/gui/ui/src/component/spinner.rs +++ b/gui/ui/src/component/spinner.rs @@ -155,3 +155,31 @@ where Element::new(carousel) } } + +/// Create a `Carousel` that types out the given `content` one character +/// at a time. +/// +/// If `show_empty` is `true`, the text will begin with an empty string. +/// +/// `interval` is how long to wait before the next character appears. +/// +/// `text_builder` is used to build each `Text` element with the required +/// style etc. +pub fn typing_text_carousel<'a, Message, Theme>( + content: &'a str, + show_empty: bool, + interval: Duration, + text_builder: impl Fn(&'a str) -> iced::widget::Text<'a, Theme, Renderer>, +) -> Carousel<'a, Message, Theme> +where + Theme: 'a + iced::widget::text::StyleSheet, +{ + let mut children = Vec::new(); + if show_empty { + children.push(text_builder("")); + } + for end_char in 0..content.chars().count() { + children.push(text_builder(&content[0..=end_char])); + } + Carousel::new(interval, children) +} From b452966653da1f2d857dd5901972171aa9d1250c Mon Sep 17 00:00:00 2001 From: Michael Mallan Date: Mon, 7 Oct 2024 15:08:27 +0100 Subject: [PATCH 5/7] gui(home): track blockheight from cache --- gui/src/app/cache.rs | 2 +- gui/src/app/message.rs | 1 + gui/src/app/mod.rs | 21 +++++++++++++++++++-- gui/src/app/state/mod.rs | 21 ++++++++++++++++++++- 4 files changed, 41 insertions(+), 4 deletions(-) diff --git a/gui/src/app/cache.rs b/gui/src/app/cache.rs index 05944514..1a26bc67 100644 --- a/gui/src/app/cache.rs +++ b/gui/src/app/cache.rs @@ -2,7 +2,7 @@ use crate::daemon::model::Coin; use liana::miniscript::bitcoin::Network; use std::path::PathBuf; -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct Cache { pub datadir_path: PathBuf, pub network: Network, diff --git a/gui/src/app/message.rs b/gui/src/app/message.rs index 78ac4ced..dd466946 100644 --- a/gui/src/app/message.rs +++ b/gui/src/app/message.rs @@ -20,6 +20,7 @@ use crate::{ pub enum Message { Tick, UpdateCache(Result), + UpdatePanelCache(/* is current panel */ bool, Result), View(view::Message), LoadDaemonConfig(Box), DaemonConfigLoaded(Result<(), Error>), diff --git a/gui/src/app/mod.rs b/gui/src/app/mod.rs index 43415384..7e75873a 100644 --- a/gui/src/app/mod.rs +++ b/gui/src/app/mod.rs @@ -63,7 +63,7 @@ impl Panels { ) -> Panels { Self { current: Menu::Home, - home: Home::new(wallet.clone(), &cache.coins), + home: Home::new(wallet.clone(), &cache.coins, cache.blockheight), coins: CoinsPanel::new(&cache.coins, wallet.main_descriptor.first_timelock_value()), transactions: TransactionsPanel::new(wallet.clone()), psbts: PsbtsPanel::new(wallet.clone()), @@ -279,7 +279,24 @@ impl App { } Message::UpdateCache(res) => { match res { - Ok(cache) => self.cache = cache, + Ok(cache) => { + self.cache.clone_from(&cache); + let current = &self.panels.current; + let daemon = self.daemon.clone(); + // These are the panels to update with the cache. + let mut panels = [(&mut self.panels.home, Menu::Home)]; + let commands: Vec<_> = panels + .iter_mut() + .map(|(panel, menu)| { + panel.update( + daemon.clone(), + &cache, + Message::UpdatePanelCache(current == menu, Ok(cache.clone())), + ) + }) + .collect(); + return Command::batch(commands); + } Err(e) => tracing::error!("Failed to update cache: {}", e), } Command::none() diff --git a/gui/src/app/state/mod.rs b/gui/src/app/state/mod.rs index ca70cf82..88cdc75c 100644 --- a/gui/src/app/state/mod.rs +++ b/gui/src/app/state/mod.rs @@ -68,6 +68,7 @@ pub fn redirect(menu: Menu) -> Command { pub struct Home { wallet: Arc, + blockheight: i32, balance: Amount, unconfirmed_balance: Amount, remaining_sequence: Option, @@ -82,7 +83,7 @@ pub struct Home { } impl Home { - pub fn new(wallet: Arc, coins: &[Coin]) -> Self { + pub fn new(wallet: Arc, coins: &[Coin], blockheight: i32) -> Self { let (balance, unconfirmed_balance) = coins.iter().fold( (Amount::from_sat(0), Amount::from_sat(0)), |(balance, unconfirmed_balance), coin| { @@ -95,8 +96,10 @@ impl Home { } }, ); + Self { wallet, + blockheight, balance, unconfirmed_balance, remaining_sequence: None, @@ -110,6 +113,10 @@ impl Home { processing: false, } } + + fn wallet_is_syncing(&self) -> bool { + self.blockheight <= 0 + } } impl State for Home { @@ -217,6 +224,14 @@ impl State for Home { self.pending_events = events; } }, + Message::UpdatePanelCache(is_current, Ok(cache)) => { + let wallet_was_syncing = self.wallet_is_syncing(); + self.blockheight = cache.blockheight; + // If this is the current panel, reload it if wallet is no longer syncing. + if is_current && wallet_was_syncing && !self.wallet_is_syncing() { + return self.reload(daemon, self.wallet.clone()); + } + } Message::View(view::Message::Label(_, _)) | Message::LabelsUpdated(_) => { match self.labels_edited.update( daemon, @@ -293,6 +308,10 @@ impl State for Home { daemon: Arc, wallet: Arc, ) -> Command { + // Wait for wallet to finish syncing before reloading data. + if self.wallet_is_syncing() { + return Command::none(); + } self.selected_event = None; self.wallet = wallet; let daemon1 = daemon.clone(); From a51110269c96e7a9d7c4143e0bd6f8cf27a703a3 Mon Sep 17 00:00:00 2001 From: Michael Mallan Date: Mon, 7 Oct 2024 15:11:17 +0100 Subject: [PATCH 6/7] gui(home): indicate that wallet is syncing --- gui/src/app/state/mod.rs | 1 + gui/src/app/view/home.rs | 42 +++++++++++++++++++++++++++++++++++----- 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/gui/src/app/state/mod.rs b/gui/src/app/state/mod.rs index 88cdc75c..6a4c8f13 100644 --- a/gui/src/app/state/mod.rs +++ b/gui/src/app/state/mod.rs @@ -148,6 +148,7 @@ impl State for Home { &self.events, self.is_last_page, self.processing, + self.wallet_is_syncing(), ), ) } diff --git a/gui/src/app/view/home.rs b/gui/src/app/view/home.rs index eedc70d6..1dbb20aa 100644 --- a/gui/src/app/view/home.rs +++ b/gui/src/app/view/home.rs @@ -1,12 +1,16 @@ use chrono::{DateTime, Local, Utc}; -use std::collections::HashMap; +use std::{collections::HashMap, time::Duration, vec}; -use iced::{alignment, widget::Space, Alignment, Length}; +use iced::{ + alignment, + widget::{Container, Row, Space}, + Alignment, Length, +}; use liana::miniscript::bitcoin; use liana_ui::{ color, - component::{amount::*, button, card, event, form, text::*}, + component::{amount::*, button, card, event, form, spinner, text::*}, icon, theme, widget::*, }; @@ -31,13 +35,41 @@ pub fn home_view<'a>( events: &'a [HistoryTransaction], is_last_page: bool, processing: bool, + wallet_is_syncing: bool, ) -> Element<'a, Message> { Column::new() .push(h3("Balance")) .push( Column::new() - .push(amount_with_size(balance, H1_SIZE)) - .push_maybe(if unconfirmed_balance.to_sat() != 0 { + .push(if !wallet_is_syncing { + amount_with_size(balance, H1_SIZE) + } else { + Row::new().push(spinner::Carousel::new( + Duration::from_millis(1000), + vec![ + amount_with_size(balance, H1_SIZE), + amount_with_size_and_colors( + balance, + H1_SIZE, + color::GREY_4, + Some(color::GREY_2), + ), + ], + )) + }) + .push_maybe(if wallet_is_syncing { + Some(Row::new().push(text("Syncing").style(color::GREY_2)).push( + spinner::typing_text_carousel( + "...", + true, + Duration::from_millis(2000), + |content| text(content).style(color::GREY_2), + ), + )) + } else { + None + }) + .push_maybe(if unconfirmed_balance.to_sat() != 0 && !wallet_is_syncing { Some( Row::new() .spacing(10) From 6f4eb79a5a45751a1c919565ea9c88f59fbda0ab Mon Sep 17 00:00:00 2001 From: Michael Mallan Date: Tue, 8 Oct 2024 14:59:00 +0100 Subject: [PATCH 7/7] gui: refresh cache more often while height is 0 The wallet's height is taken from the cache and is used to check if the wallet has been initially synced after creation. For the remote backend, the cache refresh should be done with the usual frequency while the wallet's height is 0 so that the sync completion can be detected sooner. --- gui/src/app/mod.rs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/gui/src/app/mod.rs b/gui/src/app/mod.rs index 7e75873a..cc57fb89 100644 --- a/gui/src/app/mod.rs +++ b/gui/src/app/mod.rs @@ -221,12 +221,16 @@ impl App { Subscription::batch(vec![ time::every(Duration::from_secs( // LianaLite has no rescan feature, the cache refresh loop is only - // to fetch the new block height tip which is only used to warn user - // about recovery availability. - if self.daemon.backend() == DaemonBackend::RemoteBackend { + // to fetch the new block height tip, which for a synced wallet + // (height > 0) is only used to warn user about recovery availability. + if self.daemon.backend() == DaemonBackend::RemoteBackend + && self.cache.blockheight > 0 + { 120 // For the rescan feature, we set a higher frequency of cache refresh // to give to user an up-to-date view of the rescan progress. + // For a remote backend, we refresh cache more often while height is 0 + // to detect sooner that syncing has finished. } else { 10 },