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..cc57fb89 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()), @@ -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 }, @@ -279,7 +283,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..6a4c8f13 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 { @@ -141,6 +148,7 @@ impl State for Home { &self.events, self.is_last_page, self.processing, + self.wallet_is_syncing(), ), ) } @@ -217,6 +225,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 +309,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(); diff --git a/gui/src/app/view/home.rs b/gui/src/app/view/home.rs index 9fd206ca..70889d6e 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) diff --git a/gui/ui/src/component/amount.rs b/gui/ui/src/component/amount.rs index 1e64c768..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> { - render_amount(amount_as_string(*a), P1_SIZE) + 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) 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..6d061dba --- /dev/null +++ b/gui/ui/src/component/spinner.rs @@ -0,0 +1,185 @@ +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) + } +} + +/// 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) +}