edouardparis bd3422ca35
Merge #1412: gui(installer): make edit key modal scrollable
47a5d3218503294065acebaee1add5b7d52d3a81 gui(installer): make edit key modal scrollable (Michael Mallan)

Pull request description:

  This is to fix #1402 following the suggestion in https://github.com/wizardsardine/liana/issues/1402#issuecomment-2448730840.

ACKs for top commit:
  pythcoiner:
    tACK [47a5d32](47a5d32185)
  edouardparis:
    ACK 47a5d3218503294065acebaee1add5b7d52d3a81

Tree-SHA512: 4bf84e041e4e2ae6cbb7645a75290912d975c7c7312e7779b95f855753dc8000ff6f377b13c85ed69cd68be721fde2998c6c1f3034b92b0dab03a07edc82af68
2024-11-04 18:08:16 +01:00

623 lines
23 KiB
Rust

pub mod template;
use iced::widget::{container, pick_list, scrollable, slider, Button, Space};
use iced::{Alignment, Length};
use liana::miniscript::bitcoin::Network;
use liana_ui::component::text::{self, h3, p1_bold, p2_regular, H3_SIZE};
use liana_ui::image;
use std::borrow::Cow;
use std::str::FromStr;
use liana::miniscript::bitcoin::{self, bip32::Fingerprint};
use liana_ui::{
color,
component::{
button, card, form, hw, separation,
text::{p1_regular, text, Text},
tooltip,
},
icon, theme,
widget::*,
};
use crate::installer::{
message::{self, Message},
prompt,
view::defined_sequence,
Error,
};
use super::defined_threshold;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DescriptorKind {
P2WSH,
Taproot,
}
const DESCRIPTOR_KINDS: [DescriptorKind; 2] = [DescriptorKind::P2WSH, DescriptorKind::Taproot];
impl std::fmt::Display for DescriptorKind {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
match self {
Self::P2WSH => write!(f, "P2WSH"),
Self::Taproot => write!(f, "Taproot"),
}
}
}
#[allow(clippy::too_many_arguments)]
pub fn define_descriptor_advanced_settings<'a>(use_taproot: bool) -> Element<'a, Message> {
let col_wallet = Column::new()
.spacing(10)
.push(text("Descriptor type").bold())
.push(container(
pick_list(
&DESCRIPTOR_KINDS[..],
Some(if use_taproot {
DescriptorKind::Taproot
} else {
DescriptorKind::P2WSH
}),
|kind| Message::CreateTaprootDescriptor(kind == DescriptorKind::Taproot),
)
.style(theme::PickList::Secondary)
.padding(10),
));
container(
Column::new()
.spacing(20)
.push(Space::with_height(0))
.push(separation().width(500))
.push(Row::new().push(col_wallet))
.push_maybe(if use_taproot {
Some(
p1_regular("Taproot is only supported by Liana version 5.0 and above")
.style(color::GREY_2),
)
} else {
None
}),
)
.into()
}
pub fn path(
color: iced::Color,
title: Option<String>,
sequence: u16,
duplicate_sequence: bool,
threshold: usize,
keys: Vec<Element<message::DefinePath>>,
fixed: bool,
) -> Element<message::DefinePath> {
let keys_len = keys.len();
Container::new(
Column::new()
.spacing(10)
.push_maybe(title.map(p1_bold))
.push(defined_sequence(sequence, duplicate_sequence))
.push(
Column::new()
.spacing(5)
.align_items(Alignment::Center)
.push(Column::with_children(keys).spacing(5)),
)
.push_maybe(if fixed {
if keys_len == 1 {
None
} else {
Some(Row::new().push(defined_threshold(color, fixed, (threshold, keys_len))))
}
} else {
Some(
Row::new()
.spacing(10)
.push(defined_threshold(color, fixed, (threshold, keys_len)))
.push(
button::secondary(Some(icon::plus_icon()), "Add key")
.on_press(message::DefinePath::AddKey),
),
)
}),
)
.padding(10)
.style(theme::Container::Card(theme::Card::Border))
.into()
}
pub fn uneditable_defined_key<'a>(
alias: &'a str,
color: iced::Color,
title: impl Into<Cow<'a, str>>,
warning: Option<&'static str>,
) -> Element<'a, message::DefineKey> {
card::simple(
Row::new()
.spacing(10)
.width(Length::Fill)
.align_items(Alignment::Center)
.push(icon::round_key_icon().size(H3_SIZE).style(color))
.push(
Column::new()
.width(Length::Fill)
.spacing(5)
.push(
Row::new()
.spacing(10)
.push(p1_regular(title).style(color::GREY_2))
.push(p1_bold(alias)),
)
.push_maybe(warning.map(|w| p2_regular(w).style(color::RED))),
)
.push_maybe(if warning.is_none() {
Some(icon::check_icon().style(color::GREEN))
} else {
None
}),
)
.into()
}
pub fn defined_key<'a>(
alias: &'a str,
color: iced::Color,
title: impl Into<Cow<'a, str>>,
warning: Option<&'static str>,
fixed: bool,
) -> Element<'a, message::DefineKey> {
card::simple(
Row::new()
.spacing(10)
.width(Length::Fill)
.align_items(Alignment::Center)
.push(icon::round_key_icon().size(H3_SIZE).style(color))
.push(
Column::new()
.width(Length::Fill)
.spacing(5)
.push(
Row::new()
.spacing(10)
.push(p1_regular(title).style(color::GREY_2))
.push(p1_bold(alias)),
)
.push_maybe(warning.map(|w| p2_regular(w).style(color::RED))),
)
.push_maybe(if warning.is_none() {
Some(icon::check_icon().style(color::GREEN))
} else {
None
})
.push(
button::secondary(Some(icon::pencil_icon()), "Edit")
.on_press(message::DefineKey::Edit),
)
.push_maybe(if fixed {
None
} else {
Some(
Button::new(icon::trash_icon())
.style(theme::Button::Secondary)
.padding(5)
.on_press(message::DefineKey::Delete),
)
}),
)
.into()
}
pub fn undefined_key<'a>(
color: iced::Color,
title: impl Into<Cow<'a, str>>,
active: bool,
fixed: bool,
) -> Element<'a, message::DefineKey> {
card::simple(
Row::new()
.spacing(10)
.width(Length::Fill)
.align_items(Alignment::Center)
.push(icon::round_key_icon().size(H3_SIZE).style(color))
.push(
Column::new()
.width(Length::Fill)
.spacing(5)
.push(p1_bold(title)),
)
.push_maybe(if active {
Some(
button::primary(Some(icon::pencil_icon()), "Set")
.on_press(message::DefineKey::Edit),
)
} else {
None
})
.push_maybe(if fixed {
None
} else {
Some(
Button::new(icon::trash_icon())
.style(theme::Button::Secondary)
.padding(5)
.on_press(message::DefineKey::Delete),
)
}),
)
.into()
}
#[allow(clippy::too_many_arguments)]
pub fn edit_key_modal<'a>(
title: &'a str,
network: bitcoin::Network,
hws: Vec<Element<'a, Message>>,
keys: Vec<Element<'a, Message>>,
error: Option<&Error>,
chosen_signer: Option<Fingerprint>,
hot_signer_fingerprint: &Fingerprint,
signer_alias: Option<&'a String>,
form_name: &'a form::Value<String>,
form_xpub: &form::Value<String>,
manually_imported_xpub: bool,
duplicate_master_fg: bool,
) -> Element<'a, Message> {
let content = Column::new()
.padding(25)
.push_maybe(error.map(|e| card::error("Failed to import xpub", e.to_string())))
.push(card::modal(
Column::new()
.spacing(25)
.push(Row::new()
.push(h3(title))
.push(Space::with_width(Length::Fill))
.push(button::transparent(Some(icon::cross_icon().size(40)), "").on_press(Message::Close))
.align_items(Alignment::Center)
)
.push(
Column::new()
.push(p1_regular("Select the signing device for your key"))
.spacing(10)
.push(
Column::with_children(hws).spacing(10)
)
.push(
Column::with_children(keys).spacing(10)
)
.push(
Button::new(if Some(*hot_signer_fingerprint) == chosen_signer {
hw::selected_hot_signer(hot_signer_fingerprint, signer_alias)
} else {
hw::unselected_hot_signer(hot_signer_fingerprint, signer_alias)
})
.width(Length::Fill)
.on_press(Message::UseHotSigner)
.style(theme::Button::Border),
)
.push(if manually_imported_xpub {
card::simple(Column::new()
.spacing(10)
.push(
Row::new()
.align_items(Alignment::Center)
.push(p1_regular("Enter an extended public key:").width(Length::Fill))
.push(image::success_mark_icon().width(Length::Fixed(50.0)))
)
.push(
Row::new()
.push(
form::Form::new_trimmed(
&example_xpub(network),
form_xpub, |msg| {
Message::DefineDescriptor(
message::DefineDescriptor::KeyModal(
message::ImportKeyModal::XPubEdited(msg),),)
})
.warning(if network == bitcoin::Network::Bitcoin {
"Please enter correct xpub with origin and without appended derivation path"
} else {
"Please enter correct tpub with origin and without appended derivation path"
})
.size(text::P1_SIZE)
.padding(10),
)
.spacing(10)
))
} else {
Container::new(
Button::new(
Row::new()
.align_items(Alignment::Center)
.spacing(10)
.push(icon::import_icon())
.push(p1_regular("Enter an extended public key"))
)
.padding(20)
.width(Length::Fill)
.on_press(Message::DefineDescriptor(
message::DefineDescriptor::KeyModal(message::ImportKeyModal::ManuallyImportXpub)
))
.style(theme::Button::Secondary),
)
}
)
.width(Length::Fill),
)
.push_maybe(
if chosen_signer.is_some() {
Some(card::simple(Column::new()
.spacing(10)
.push(
Row::new()
.spacing(5)
.push(text("Key name:").bold())
.push(tooltip(prompt::DEFINE_DESCRIPTOR_FINGERPRINT_TOOLTIP)),
)
.push(p1_regular("Give this key a friendly name. It helps you identify it later").style(color::GREY_2))
.push(
form::Form::new("Name", form_name, |msg| {
Message::DefineDescriptor(message::DefineDescriptor::KeyModal(
message::ImportKeyModal::NameEdited(msg),
))
})
.warning("Two different keys cannot have the same name")
.padding(10)
.size(text::P1_SIZE)
)))
} else {
None
}
)
.push_maybe(
if duplicate_master_fg {
Some(text("A single signing device may not be used more than once per path. (It can still be used in other paths.)").style(color::RED))
} else {
None
}
)
.push(
button::primary(None, "Apply")
.on_press_maybe(if !duplicate_master_fg
&& (!manually_imported_xpub || form_xpub.valid)
&& !form_name.value.is_empty() && form_name.valid {
Some(Message::DefineDescriptor(
message::DefineDescriptor::KeyModal(
message::ImportKeyModal::ConfirmXpub,
),
))
} else {None})
.width(Length::Fixed(200.0))
)
.align_items(Alignment::Center),
))
.width(Length::Fixed(800.0));
scrollable(content).into()
}
fn example_xpub(network: Network) -> String {
format!("[aabbccdd/42'/0']{}pub6DAkq8LWw91WGgUGnkR5Sbzjev5JCsXaTVZQ9MwsPV4BkNFKygtJ8GHodfDVx1udR723nT7JASqGPpKvz7zQ25pUTW6zVEBdiWoaC4aUqik",
if network == bitcoin::Network::Bitcoin { "x" } else { "t" }
)
}
/// returns y,m,d,h,m
pub fn duration_from_sequence(sequence: u16) -> (u32, u32, u32, u32, u32) {
let mut n_minutes = sequence as u32 * 10;
let n_years = n_minutes / 525960;
n_minutes -= n_years * 525960;
let n_months = n_minutes / 43830;
n_minutes -= n_months * 43830;
let n_days = n_minutes / 1440;
n_minutes -= n_days * 1440;
let n_hours = n_minutes / 60;
n_minutes -= n_hours * 60;
(n_years, n_months, n_days, n_hours, n_minutes)
}
pub fn edit_sequence_modal<'a>(sequence: &form::Value<String>) -> Element<'a, Message> {
let mut col = Column::new()
.width(Length::Fill)
.spacing(20)
.align_items(Alignment::Center)
.push(text("Keys can move the funds after inactivity of:"))
.push(
Row::new()
.push(
Container::new(
form::Form::new_trimmed("ex: 1000", sequence, |v| {
Message::DefineDescriptor(
message::DefineDescriptor::ThresholdSequenceModal(
message::ThresholdSequenceModal::SequenceEdited(v),
),
)
})
.warning("Sequence must be superior to 0 and inferior to 65535"),
)
.width(Length::Fixed(200.0)),
)
.spacing(10)
.push(text("blocks").bold()),
);
if sequence.valid {
if let Ok(sequence) = u16::from_str(&sequence.value) {
let (n_years, n_months, n_days, n_hours, n_minutes) = duration_from_sequence(sequence);
col = col
.push(
[
(n_years, "year"),
(n_months, "month"),
(n_days, "day"),
(n_hours, "hour"),
(n_minutes, "minute"),
]
.iter()
.fold(Row::new().spacing(5), |row, (n, unit)| {
row.push_maybe(if *n > 0 {
Some(
text(format!("{} {}{}", n, unit, if *n > 1 { "s" } else { "" }))
.bold(),
)
} else {
None
})
}),
)
.push(
Container::new(
slider(1..=u16::MAX, sequence, |v| {
Message::DefineDescriptor(
message::DefineDescriptor::ThresholdSequenceModal(
message::ThresholdSequenceModal::SequenceEdited(v.to_string()),
),
)
})
.step(144_u16), // 144 blocks per day
)
.width(Length::Fixed(500.0)),
);
}
}
card::modal(col.push(if sequence.valid {
button::primary(None, "Apply")
.on_press(Message::DefineDescriptor(
message::DefineDescriptor::ThresholdSequenceModal(
message::ThresholdSequenceModal::Confirm,
),
))
.width(Length::Fixed(200.0))
} else {
button::primary(None, "Apply").width(Length::Fixed(200.0))
}))
.width(Length::Fixed(800.0))
.into()
}
pub fn edit_threshold_modal<'a>(threshold: (usize, usize)) -> Element<'a, Message> {
card::modal(
Column::new()
.width(Length::Fill)
.spacing(20)
.align_items(Alignment::Center)
.push(threshsold_input::threshsold_input(
threshold.0,
threshold.1,
|v| {
Message::DefineDescriptor(message::DefineDescriptor::ThresholdSequenceModal(
message::ThresholdSequenceModal::ThresholdEdited(v),
))
},
))
.push(
button::primary(None, "Apply")
.on_press(Message::DefineDescriptor(
message::DefineDescriptor::ThresholdSequenceModal(
message::ThresholdSequenceModal::Confirm,
),
))
.width(Length::Fixed(200.0)),
),
)
.width(Length::Fixed(800.0))
.into()
}
mod threshsold_input {
use iced::alignment::{self, Alignment};
use iced::widget::{component, Component};
use iced::Length;
use liana_ui::{component::text::*, icon, theme, widget::*};
pub struct ThresholdInput<Message> {
value: usize,
max: usize,
on_change: Box<dyn Fn(usize) -> Message>,
}
pub fn threshsold_input<Message>(
value: usize,
max: usize,
on_change: impl Fn(usize) -> Message + 'static,
) -> ThresholdInput<Message> {
ThresholdInput::new(value, max, on_change)
}
#[derive(Debug, Clone)]
pub enum Event {
IncrementPressed,
DecrementPressed,
}
impl<Message> ThresholdInput<Message> {
pub fn new(
value: usize,
max: usize,
on_change: impl Fn(usize) -> Message + 'static,
) -> Self {
Self {
value,
max,
on_change: Box::new(on_change),
}
}
}
impl<Message> Component<Message, theme::Theme> for ThresholdInput<Message> {
type State = ();
type Event = Event;
fn update(&mut self, _state: &mut Self::State, event: Event) -> Option<Message> {
match event {
Event::IncrementPressed => {
if self.value < self.max {
Some((self.on_change)(self.value.saturating_add(1)))
} else {
None
}
}
Event::DecrementPressed => {
if self.value > 1 {
Some((self.on_change)(self.value.saturating_sub(1)))
} else {
None
}
}
}
}
fn view(&self, _state: &Self::State) -> Element<Self::Event> {
let button = |label, on_press| {
Button::new(label)
.style(theme::Button::Transparent)
.width(Length::Fixed(50.0))
.on_press(on_press)
};
Column::new()
.width(Length::Fixed(150.0))
.push(button(icon::up_icon().size(30), Event::IncrementPressed))
.push(text("Threshold:").small().bold())
.push(
Container::new(text(format!("{}/{}", self.value, self.max)).size(30))
.align_y(alignment::Vertical::Center),
)
.push(button(icon::down_icon().size(30), Event::DecrementPressed))
.align_items(Alignment::Center)
.into()
}
}
impl<'a, Message> From<ThresholdInput<Message>> for Element<'a, Message>
where
Message: 'a,
{
fn from(numeric_input: ThresholdInput<Message>) -> Self {
component(numeric_input)
}
}
}