diff --git a/Cargo.lock b/Cargo.lock index d468a08b..44b58e48 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2384,6 +2384,23 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "iced_aw" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "582c517a94ce3205da98e9c10b26bb71aa36b7d7d084441d826dc912711d1bac" +dependencies = [ + "cfg-if", + "chrono", + "getrandom 0.3.1", + "iced", + "iced_fonts", + "itertools 0.14.0", + "num-format", + "num-traits", + "web-time", +] + [[package]] name = "iced_core" version = "0.13.2" @@ -2403,6 +2420,15 @@ dependencies = [ "web-time", ] +[[package]] +name = "iced_fonts" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df7deb0800a850ee25c8a42559f72c0f249e577feb3aad37b9b65dc1e517e52a" +dependencies = [ + "iced_core", +] + [[package]] name = "iced_futures" version = "0.13.2" @@ -2781,6 +2807,15 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.14" @@ -3020,6 +3055,7 @@ dependencies = [ "flate2", "hex", "iced", + "iced_aw", "iced_runtime", "jsonrpc 0.12.1", "liana", @@ -3047,6 +3083,7 @@ dependencies = [ "bitcoin", "chrono", "iced", + "iced_aw", "iced_core", "iced_runtime", "unicode-segmentation", @@ -3512,6 +3549,16 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-format" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a652d9771a63711fd3c3deb670acfbe5c30a4072e664d7a3bf5a9e1056ac72c3" +dependencies = [ + "arrayvec", + "itoa", +] + [[package]] name = "num-integer" version = "0.1.46" diff --git a/liana-gui/Cargo.toml b/liana-gui/Cargo.toml index 1b0ec2e2..fe969d87 100644 --- a/liana-gui/Cargo.toml +++ b/liana-gui/Cargo.toml @@ -23,6 +23,7 @@ backtrace = "0.3" hex = "0.4.3" iced = { version = "0.13.1", default-features = false, features = ["tokio", "svg", "qr_code", "image", "lazy", "wgpu", "advanced", "tiny-skia"] } +iced_aw = { version = "0.12.2", features = ["context_menu"] } iced_runtime = "0.13.1" # Used to verify RFC-compliance of an email diff --git a/liana-gui/src/app/mod.rs b/liana-gui/src/app/mod.rs index 0b21daac..2222f9ac 100644 --- a/liana-gui/src/app/mod.rs +++ b/liana-gui/src/app/mod.rs @@ -182,13 +182,13 @@ impl App { ) } - pub fn title(&self) -> String { + pub fn title(&self) -> &str { if let Some(alias) = &self.wallet.alias { if !alias.is_empty() { - return format!("- {}", alias); + return alias; } } - String::new() + "Liana wallet" } fn set_current_panel(&mut self, menu: Menu) -> Task { diff --git a/liana-gui/src/gui/mod.rs b/liana-gui/src/gui/mod.rs index 88357e8a..1dd51c0e 100644 --- a/liana-gui/src/gui/mod.rs +++ b/liana-gui/src/gui/mod.rs @@ -10,18 +10,18 @@ extern crate serde; extern crate serde_json; use liana::miniscript::bitcoin; -use liana_ui::widget::Element; +use liana_ui::widget::{Column, Element}; +pub mod pane; pub mod tab; use crate::{dir::LianaDirectory, logger::Logger, VERSION}; pub struct GUI { - state: tab::Tab, + pane: pane::Pane, + config: Config, // We may change the directory of log outputs later _logger: Logger, - // if set up, it overrides the level filter of the logger. - _log_level: Option, } #[derive(Debug)] @@ -33,7 +33,7 @@ pub enum Key { pub enum Message { CtrlC, FontLoaded(Result<(), iced::font::Error>), - Tab(tab::Message), + Pane(pane::Message), KeyPressed(Key), Event(iced::Event), } @@ -54,11 +54,7 @@ async fn ctrl_c() -> Result<(), ()> { impl GUI { pub fn title(&self) -> String { - match &self.state.0 { - tab::State::Installer(_) => format!("Liana v{} Installer", VERSION), - tab::State::App(a) => format!("Liana v{} {}", VERSION, a.title()), - _ => format!("Liana v{}", VERSION), - } + format!("Liana v{}", VERSION) } pub fn new((config, log_level): (Config, Option)) -> (GUI, Task) { @@ -68,13 +64,13 @@ impl GUI { log_level.unwrap_or_else(|| log_level.unwrap_or(LevelFilter::INFO)), ); let mut cmds = vec![Task::perform(ctrl_c(), |_| Message::CtrlC)]; - let (state, cmd) = tab::Tab::new(config.liana_directory, config.network); - cmds.push(cmd.map(Message::Tab)); + let (pane, cmd) = pane::Pane::new(&config); + cmds.push(cmd.map(Message::Pane)); ( Self { - state, + pane, + config, _logger: logger, - _log_level: log_level, }, Task::batch(cmds), ) @@ -84,7 +80,7 @@ impl GUI { match message { Message::CtrlC | Message::Event(iced::Event::Window(iced::window::Event::CloseRequested)) => { - self.state.stop(); + self.pane.stop(); iced::window::get_latest().and_then(iced::window::close) } Message::KeyPressed(Key::Tab(shift)) => { @@ -95,14 +91,14 @@ impl GUI { focus_next() } } - Message::Tab(msg) => self.state.update(msg).map(Message::Tab), + Message::Pane(msg) => self.pane.update(msg, &self.config).map(Message::Pane), _ => Task::none(), } } pub fn subscription(&self) -> Subscription { Subscription::batch(vec![ - self.state.subscription().map(Message::Tab), + self.pane.subscription().map(Message::Pane), iced::event::listen_with(|event, status, _| match (&event, status) { ( Event::Keyboard(keyboard::Event::KeyPressed { @@ -122,7 +118,10 @@ impl GUI { } pub fn view(&self) -> Element { - self.state.view().map(Message::Tab) + Column::new() + .push(self.pane.tabs_menu_view().map(Message::Pane)) + .push(self.pane.view().map(Message::Pane)) + .into() } pub fn scale_factor(&self) -> f64 { diff --git a/liana-gui/src/gui/pane.rs b/liana-gui/src/gui/pane.rs new file mode 100644 index 00000000..edd57f31 --- /dev/null +++ b/liana-gui/src/gui/pane.rs @@ -0,0 +1,152 @@ +use iced::{Subscription, Task}; +use iced_aw::ContextMenu; +use liana_ui::{component::text::*, icon::plus_icon, theme, widget::*}; + +use crate::gui::Config; + +use super::tab; + +#[derive(Debug)] +pub enum Message { + Tab(usize, tab::Message), + View(ViewMessage), +} + +#[derive(Debug, Clone)] +pub enum ViewMessage { + FocusTab(usize), + CloseTab(usize), + AddTab, +} + +pub struct Pane { + tabs: Vec, + + // this is an index in the tabs array + focused_tab: usize, + + // used to generate tabs ids. + tabs_created: usize, +} + +impl Pane { + pub fn new(cfg: &Config) -> (Self, Task) { + let (state, task) = tab::State::new(cfg.liana_directory.clone(), cfg.network); + ( + Self { + tabs: vec![tab::Tab::new(1, state)], + focused_tab: 0, + tabs_created: 1, + }, + task.map(|msg| Message::Tab(1, msg)), + ) + } + + fn add_tab(&mut self, cfg: &Config) -> Task { + let (state, task) = tab::State::new(cfg.liana_directory.clone(), cfg.network); + self.tabs_created += 1; + let id = self.tabs_created; + self.tabs.push(tab::Tab::new(id, state)); + self.focused_tab = self.tabs.len() - 1; + task.map(move |msg| Message::Tab(id, msg)) + } + + fn remove_tab(&mut self, i: usize) { + let mut tab = self.tabs.remove(i); + tab.stop(); + self.focused_tab = if self.tabs.is_empty() { + 0 + } else if i < self.tabs.len() - 1 { + i + } else { + self.tabs.len() - 1 + }; + } + + pub fn update(&mut self, message: Message, cfg: &Config) -> Task { + match message { + Message::Tab(id, msg) => self + .tabs + .iter_mut() + .find(|t| t.id == id) + .map(|t| t.update(msg).map(move |msg| Message::Tab(id, msg))) + .unwrap_or(Task::none()), + Message::View(ViewMessage::FocusTab(i)) => { + if i < self.tabs.len() { + self.focused_tab = i; + } + Task::none() + } + Message::View(ViewMessage::AddTab) => self.add_tab(cfg), + Message::View(ViewMessage::CloseTab(i)) => { + self.remove_tab(i); + Task::none() + } + } + } + + pub fn subscription(&self) -> Subscription { + let subs: Vec> = self + .tabs + .iter() + .map(|t| { + t.subscription() + .with(t.id) + .map(|(id, msg)| Message::Tab(id, msg)) + }) + .collect(); + Subscription::batch(subs) + } + + pub fn stop(&mut self) { + self.tabs.iter_mut().for_each(|t| t.stop()); + } + + pub fn tabs_menu_view(&self) -> Element { + let mut menu = Row::new().spacing(3); + for (i, tab) in self.tabs.iter().enumerate() { + let title = tab.title(); + menu = menu.push(ContextMenu::new( + Into::>::into( + Button::new(if title.len() < 20 { + Row::new().push(p1_regular(title)).push(p1_regular( + &" ".to_string()[..21 - title.len()], + )) + } else { + Row::new() + .push(p1_regular(&title[..17])) + .push(p1_regular("...")) + }) + .style(if i == self.focused_tab { + theme::button::tab_active + } else { + theme::button::tab + }) + .on_press(ViewMessage::FocusTab(i)), + ), + move || { + Button::new(p1_regular("Close")) + .style(theme::button::secondary) + .on_press(ViewMessage::CloseTab(i)) + .width(100) + .into() + }, + )); + } + menu = menu.push( + Button::new(plus_icon()) + .style(theme::button::tab) + .on_press(ViewMessage::AddTab), + ); + Into::>::into(menu.wrap()).map(Message::View) + } + + pub fn view(&self) -> Element { + if let Some(t) = self.tabs.get(self.focused_tab) { + let id = t.id; + t.view().map(move |msg| Message::Tab(id, msg)) + } else { + Row::new().into() + } + } +} diff --git a/liana-gui/src/gui/tab.rs b/liana-gui/src/gui/tab.rs index 676640f5..34197484 100644 --- a/liana-gui/src/gui/tab.rs +++ b/liana-gui/src/gui/tab.rs @@ -29,8 +29,6 @@ use crate::{ }, }; -pub struct Tab(pub State); - pub enum State { Launcher(Box), Installer(Box), @@ -39,6 +37,19 @@ pub enum State { App(App), } +impl State { + pub fn new( + directory: LianaDirectory, + network: Option, + ) -> (Self, Task) { + let (launcher, command) = Launcher::new(directory, network); + ( + State::Launcher(Box::new(launcher)), + command.map(|msg| Message::Launch(Box::new(msg))), + ) + } +} + #[derive(Debug)] pub enum Message { Launch(Box), @@ -48,20 +59,28 @@ pub enum Message { Login(Box), } +pub struct Tab { + pub id: usize, + pub state: State, +} + impl Tab { - pub fn new( - directory: LianaDirectory, - network: Option, - ) -> (Self, Task) { - let (launcher, command) = Launcher::new(directory, network); - ( - Tab(State::Launcher(Box::new(launcher))), - command.map(|msg| Message::Launch(Box::new(msg))), - ) + pub fn new(id: usize, state: State) -> Self { + Tab { id, state } + } + + pub fn title(&self) -> &str { + match &self.state { + State::Installer(_) => "Installer", + State::Loader(_) => "Loading...", + State::Launcher(_) => "Launcher", + State::Login(_) => "Login", + State::App(a) => a.title(), + } } pub fn update(&mut self, message: Message) -> Task { - match (&mut self.0, message) { + match (&mut self.state, message) { (State::Launcher(l), Message::Launch(msg)) => match *msg { launcher::Message::Install(datadir, network, init) => { if !datadir.exists() { @@ -77,19 +96,19 @@ impl Tab { } } let (install, command) = Installer::new(datadir, network, None, init); - self.0 = State::Installer(Box::new(install)); + self.state = State::Installer(Box::new(install)); command.map(|msg| Message::Install(Box::new(msg))) } launcher::Message::Run(datadir_path, cfg, network, settings) => { if settings.remote_backend_auth.is_some() { let (login, command) = login::LianaLiteLogin::new(datadir_path, network, settings); - self.0 = State::Login(Box::new(login)); + self.state = State::Login(Box::new(login)); command.map(|msg| Message::Login(Box::new(msg))) } else { let (loader, command) = Loader::new(datadir_path, cfg, network, None, None, settings); - self.0 = State::Loader(Box::new(loader)); + self.state = State::Loader(Box::new(loader)); command.map(|msg| Message::Load(Box::new(msg))) } } @@ -98,7 +117,7 @@ impl Tab { (State::Login(l), Message::Login(msg)) => match *msg { login::Message::View(login::ViewMessage::BackToLauncher(network)) => { let (launcher, command) = Launcher::new(l.datadir.clone(), Some(network)); - self.0 = State::Launcher(Box::new(launcher)); + self.state = State::Launcher(Box::new(launcher)); command.map(|msg| Message::Launch(Box::new(msg))) } login::Message::Install(remote_backend) => { @@ -108,7 +127,7 @@ impl Tab { remote_backend, installer::UserFlow::CreateWallet, ); - self.0 = State::Installer(Box::new(install)); + self.state = State::Installer(Box::new(install)); command.map(|msg| Message::Install(Box::new(msg))) } login::Message::Run(Ok((backend_client, wallet, coins))) => { @@ -129,7 +148,7 @@ impl Tab { config, ); - self.0 = State::App(app); + self.state = State::App(app); command.map(|msg| Message::Run(Box::new(msg))) } _ => l.update(*msg).map(|msg| Message::Login(Box::new(msg))), @@ -139,7 +158,7 @@ impl Tab { if settings.remote_backend_auth.is_some() { let (login, command) = login::LianaLiteLogin::new(i.datadir.clone(), i.network, *settings); - self.0 = State::Login(Box::new(login)); + self.state = State::Login(Box::new(login)); command.map(|msg| Message::Login(Box::new(msg))) } else { let cfg = app::Config::from_file( @@ -158,12 +177,12 @@ impl Tab { i.context.backup.take(), *settings, ); - self.0 = State::Loader(Box::new(loader)); + self.state = State::Loader(Box::new(loader)); command.map(|msg| Message::Load(Box::new(msg))) } } else if let installer::Message::BackToLauncher(network) = *msg { let (launcher, command) = Launcher::new(i.destination_path(), Some(network)); - self.0 = State::Launcher(Box::new(launcher)); + self.state = State::Launcher(Box::new(launcher)); command.map(|msg| Message::Launch(Box::new(msg))) } else { i.update(*msg).map(|msg| Message::Install(Box::new(msg))) @@ -173,7 +192,7 @@ impl Tab { loader::Message::View(loader::ViewMessage::SwitchNetwork) => { let (launcher, command) = Launcher::new(loader.datadir_path.clone(), Some(loader.network)); - self.0 = State::Launcher(Box::new(launcher)); + self.state = State::Launcher(Box::new(launcher)); command.map(|msg| Message::Launch(Box::new(msg))) } loader::Message::Synced(Ok((wallet, cache, daemon, bitcoind, backup))) => { @@ -204,7 +223,7 @@ impl Tab { bitcoind, false, ); - self.0 = State::App(app); + self.state = State::App(app); command.map(|msg| Message::Run(Box::new(msg))) } } @@ -221,7 +240,7 @@ impl Tab { bitcoind, restored_from_backup, ); - self.0 = State::App(app); + self.state = State::App(app); command.map(|msg| Message::Run(Box::new(msg))) } loader::Message::App(Err(e), _) => { @@ -239,7 +258,7 @@ impl Tab { } pub fn subscription(&self) -> Subscription { - Subscription::batch(vec![match &self.0 { + Subscription::batch(vec![match &self.state { State::Installer(v) => v.subscription().map(|msg| Message::Install(Box::new(msg))), State::Loader(v) => v.subscription().map(|msg| Message::Load(Box::new(msg))), State::App(v) => v.subscription().map(|msg| Message::Run(Box::new(msg))), @@ -249,7 +268,7 @@ impl Tab { } pub fn view(&self) -> Element { - match &self.0 { + match &self.state { State::Installer(v) => v.view().map(|msg| Message::Install(Box::new(msg))), State::App(v) => v.view().map(|msg| Message::Run(Box::new(msg))), State::Launcher(v) => v.view().map(|msg| Message::Launch(Box::new(msg))), @@ -259,7 +278,7 @@ impl Tab { } pub fn stop(&mut self) { - match &mut self.0 { + match &mut self.state { State::Loader(s) => s.stop(), State::Launcher(s) => s.stop(), State::Installer(s) => s.stop(), diff --git a/liana-ui/Cargo.toml b/liana-ui/Cargo.toml index 42ce2875..a77e41a5 100644 --- a/liana-ui/Cargo.toml +++ b/liana-ui/Cargo.toml @@ -7,6 +7,7 @@ edition = "2021" [dependencies] iced = { version = "0.13.1", default-features = false, features = ["svg", "image", "lazy", "qr_code", "advanced"] } +iced_aw = { version = "0.12.2", features = ["context_menu"] } iced_core = { version = "0.13.2" } iced_runtime = { version = "0.13.2" } unicode-segmentation = "1.0" diff --git a/liana-ui/src/theme/button.rs b/liana-ui/src/theme/button.rs index 63200260..12751956 100644 --- a/liana-ui/src/theme/button.rs +++ b/liana-ui/src/theme/button.rs @@ -145,3 +145,14 @@ fn button(p: &Button, status: Status) -> Style { } } } + +pub fn tab(theme: &Theme, status: Status) -> Style { + let mut style = button(&theme.colors.buttons.secondary, status); + style.border.radius = 0.0.into(); + style.border.width = 0.0; + style +} + +pub fn tab_active(theme: &Theme, _status: Status) -> Style { + tab(theme, Status::Pressed) +} diff --git a/liana-ui/src/theme/context_menu.rs b/liana-ui/src/theme/context_menu.rs new file mode 100644 index 00000000..70d02ac2 --- /dev/null +++ b/liana-ui/src/theme/context_menu.rs @@ -0,0 +1,22 @@ +use iced::Background; +use iced_aw::widget::context_menu::{Catalog, Status, Style}; + +use super::Theme; + +impl Catalog for Theme { + type Class<'a> = Box Style + 'a>; + + fn default<'a>() -> Self::Class<'a> { + Box::new(primary) + } + + fn style(&self, class: &Self::Class<'_>, status: Status) -> Style { + class(self, status) + } +} + +pub fn primary(_theme: &Theme, _status: Status) -> Style { + Style { + background: Background::Color(iced::Color::TRANSPARENT), + } +} diff --git a/liana-ui/src/theme/mod.rs b/liana-ui/src/theme/mod.rs index b2c81810..c1d5e26d 100644 --- a/liana-ui/src/theme/mod.rs +++ b/liana-ui/src/theme/mod.rs @@ -4,6 +4,7 @@ pub mod button; pub mod card; pub mod checkbox; pub mod container; +pub mod context_menu; pub mod notification; pub mod overlay; pub mod palette;