Add pane tabs

User can add and remove wallet with tabs
This commit is contained in:
edouardparis 2025-06-20 12:37:14 +02:00
parent e771a42f45
commit 9f1de060eb
10 changed files with 301 additions and 48 deletions

47
Cargo.lock generated
View File

@ -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"

View File

@ -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

View File

@ -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<Message> {

View File

@ -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<LevelFilter>,
}
#[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<LevelFilter>)) -> (GUI, Task<Message>) {
@ -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<Message> {
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<Message> {
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 {

152
liana-gui/src/gui/pane.rs Normal file
View File

@ -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<tab::Tab>,
// 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<Message>) {
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<Message> {
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<Message> {
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<Message> {
let subs: Vec<Subscription<Message>> = 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<Message> {
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::<Element<ViewMessage>>::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::<Element<ViewMessage>>::into(menu.wrap()).map(Message::View)
}
pub fn view(&self) -> Element<Message> {
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()
}
}
}

View File

@ -29,8 +29,6 @@ use crate::{
},
};
pub struct Tab(pub State);
pub enum State {
Launcher(Box<Launcher>),
Installer(Box<Installer>),
@ -39,6 +37,19 @@ pub enum State {
App(App),
}
impl State {
pub fn new(
directory: LianaDirectory,
network: Option<bitcoin::Network>,
) -> (Self, Task<Message>) {
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<launcher::Message>),
@ -48,20 +59,28 @@ pub enum Message {
Login(Box<login::Message>),
}
pub struct Tab {
pub id: usize,
pub state: State,
}
impl Tab {
pub fn new(
directory: LianaDirectory,
network: Option<bitcoin::Network>,
) -> (Self, Task<Message>) {
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<Message> {
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<Message> {
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<Message> {
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(),

View File

@ -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"

View File

@ -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)
}

View File

@ -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<dyn Fn(&Theme, Status) -> 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),
}
}

View File

@ -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;