Merge #1370: [GUI] Indicate wallet is syncing after completing installer
6f4eb79a5a45751a1c919565ea9c88f59fbda0ab gui: refresh cache more often while height is 0 (Michael Mallan) a51110269c96e7a9d7c4143e0bd6f8cf27a703a3 gui(home): indicate that wallet is syncing (Michael Mallan) b452966653da1f2d857dd5901972171aa9d1250c gui(home): track blockheight from cache (Michael Mallan) 1d1e735ae975bb73af5389090e1eee68c727c7ea ui: add loading spinner that types text (Michael Mallan) 3c46a7337c57ef646626d052a0ab23a7fa0ecb17 ui: add carousel loading spinner (Michael Mallan) e4c1ab106d74ad193aa1ba2f5746ce598c4801e5 ui: render amount with chosen colors (Michael Mallan) 013feb3909ebed0a8850a51a7d61383f327b5d4d ui: refactor amount function (Michael Mallan) Pull request description: This is to resolve #1361. The home page considers the wallet to be syncing if its height is 0. In this case, the balance will slowly blink and a "Syncing..." text will appear just below. The home page will check the wallet's height upon each cache refresh, and will reload the home page once the syncing has completed so that the updated balance is displayed without the user needing to do anything. Both the blinking balance and "Syncing..." text use a new `Carousel` widget that cycles through different child widgets at a specified rate. EDIT: I've added an extra commit to address #1363 as that is also related to the wallet height and cache refresh. ACKs for top commit: edouardparis: ACK 6f4eb79a5a45751a1c919565ea9c88f59fbda0ab Tree-SHA512: 04214ccb1cf998ae6f7589f2c37335416a6d39b668269c4e07e30c8713e1a0e9e46257c60cea7b19ee6d6e35cbdccaf1122d25a66e6e7e905de51fce185f9180
This commit is contained in:
commit
67c9262a30
@ -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,
|
||||
|
||||
@ -20,6 +20,7 @@ use crate::{
|
||||
pub enum Message {
|
||||
Tick,
|
||||
UpdateCache(Result<Cache, Error>),
|
||||
UpdatePanelCache(/* is current panel */ bool, Result<Cache, Error>),
|
||||
View(view::Message),
|
||||
LoadDaemonConfig(Box<DaemonConfig>),
|
||||
DaemonConfigLoaded(Result<(), Error>),
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -68,6 +68,7 @@ pub fn redirect(menu: Menu) -> Command<Message> {
|
||||
|
||||
pub struct Home {
|
||||
wallet: Arc<Wallet>,
|
||||
blockheight: i32,
|
||||
balance: Amount,
|
||||
unconfirmed_balance: Amount,
|
||||
remaining_sequence: Option<u32>,
|
||||
@ -82,7 +83,7 @@ pub struct Home {
|
||||
}
|
||||
|
||||
impl Home {
|
||||
pub fn new(wallet: Arc<Wallet>, coins: &[Coin]) -> Self {
|
||||
pub fn new(wallet: Arc<Wallet>, 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<dyn Daemon + Sync + Send>,
|
||||
wallet: Arc<Wallet>,
|
||||
) -> Command<Message> {
|
||||
// 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();
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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<Color>,
|
||||
) -> 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<Color>,
|
||||
) -> 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)
|
||||
|
||||
@ -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;
|
||||
|
||||
185
gui/ui/src/component/spinner.rs
Normal file
185
gui/ui/src/component/spinner.rs
Normal file
@ -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<Element<'a, Message, Theme>>,
|
||||
}
|
||||
|
||||
impl<'a, Message, Theme> Carousel<'a, Message, Theme> {
|
||||
pub fn new(interval: Duration, children: Vec<impl Into<Element<'a, Message, Theme>>>) -> 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<Message, Theme, Renderer> for Carousel<'a, Message, Theme>
|
||||
where
|
||||
Message: 'a + Clone,
|
||||
{
|
||||
fn tag(&self) -> tree::Tag {
|
||||
tree::Tag::of::<CarouselState>()
|
||||
}
|
||||
|
||||
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::<CarouselState>();
|
||||
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<Tree> {
|
||||
self.children.iter().map(|child| Tree::new(child)).collect()
|
||||
}
|
||||
|
||||
fn state(&self) -> tree::State {
|
||||
tree::State::new(CarouselState::new())
|
||||
}
|
||||
|
||||
fn size(&self) -> Size<Length> {
|
||||
// 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::<CarouselState>();
|
||||
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::<CarouselState>();
|
||||
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<Carousel<'a, Message, Theme>> 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)
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user