diff --git a/Cargo.lock b/Cargo.lock index 4ea41cf5..c442e703 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2590,6 +2590,7 @@ dependencies = [ name = "revault_ui" version = "0.1.0" dependencies = [ + "bitcoin", "iced", ] diff --git a/Cargo.toml b/Cargo.toml index 3505f1f1..a527a976 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,7 +23,7 @@ bitcoin = { version = "0.27", features = ["base64", "use-serde"] } revaultd = { git = "https://github.com/revault/revaultd", branch = "master", default-features = false} backtrace = "0.3" -iced = { version = "0.3", default-features= false, features = ["tokio", "wgpu", "svg", "qr_code"] } +iced = { version = "0.3", default-features= false, features = ["tokio", "wgpu", "svg", "qr_code", "canvas"] } revault_ui = { path = "./ui" } revault_hwi = { path = "./hwi" } iced_native = "0.4" diff --git a/src/app/message.rs b/src/app/message.rs index 72688623..6819d4cd 100644 --- a/src/app/message.rs +++ b/src/app/message.rs @@ -1,8 +1,10 @@ use bitcoin::{util::psbt::PartiallySignedTransaction as Psbt, OutPoint}; -use revault_hwi::{app::revault::RevaultHWI, HWIError}; use std::sync::Arc; use tokio::sync::Mutex; +use revault_hwi::{app::revault::RevaultHWI, HWIError}; +use revault_ui::chart::FlowChartMessage; + use crate::{ app::{error::Error, menu::Menu}, daemon::{ @@ -77,6 +79,8 @@ pub enum SpendTxMessage { #[derive(Debug, Clone)] pub enum HistoryEventMessage { OnChainTransactions(Result, RevaultDError>), + ToggleFlowChart(bool), + FlowChart(FlowChartMessage), } #[derive(Debug, Clone)] diff --git a/src/app/state/history.rs b/src/app/state/history.rs index 6fc9b48d..605ce2c6 100644 --- a/src/app/state/history.rs +++ b/src/app/state/history.rs @@ -1,10 +1,13 @@ use std::convert::TryInto; use std::time::{SystemTime, UNIX_EPOCH}; +use bitcoin::Txid; use iced::{Command, Element}; use super::State; +use revault_ui::chart::{FlowChart, FlowChartMessage}; + use crate::{ app::{ context::Context, @@ -13,7 +16,10 @@ use crate::{ view::LoadingDashboard, view::{HistoryEventListItemView, HistoryEventView, HistoryView}, }, - daemon::model::{HistoryEvent, HistoryEventKind, VaultTransactions, ALL_HISTORY_EVENTS}, + daemon::model::{ + HistoryEvent, HistoryEventKind, HistoryEventTransaction, TransactionKind, + ALL_HISTORY_EVENTS, + }, }; pub const HISTORY_EVENT_PAGE_SIZE: u64 = 20; @@ -93,7 +99,7 @@ impl State for HistoryState { } Message::HistoryEvent(msg) => { if let Some(event) = selected_event { - event.update(msg) + event.update(ctx, msg) } } Message::Close => { @@ -283,7 +289,9 @@ impl HistoryEventListItemState { #[derive(Debug)] pub struct HistoryEventState { event: HistoryEvent, - txs: Vec, + txs: Vec, + selected_tx: Option, + flowchart: Option, loading_fail: Option, view: HistoryEventView, } @@ -293,22 +301,99 @@ impl HistoryEventState { Self { event, txs: Vec::new(), + flowchart: None, + selected_tx: None, loading_fail: None, view: HistoryEventView::new(), } } - pub fn update(&mut self, message: HistoryEventMessage) { - let HistoryEventMessage::OnChainTransactions(res) = message; - match res { - Ok(txs) => self.txs = txs, - Err(e) => self.loading_fail = Some(e.into()), + pub fn update(&mut self, ctx: &Context, message: HistoryEventMessage) { + match message { + HistoryEventMessage::ToggleFlowChart(toggle) => { + if toggle { + self.flowchart = Some(FlowChart::new( + ctx.network(), + self.txs + .iter() + .map(|event_tx| event_tx.tx.clone()) + .collect(), + )); + } else { + self.flowchart = None; + } + } + HistoryEventMessage::FlowChart(FlowChartMessage::TxSelected(txid)) => { + if self.selected_tx.is_none() { + self.selected_tx = txid; + } else { + self.selected_tx = None; + } + } + HistoryEventMessage::OnChainTransactions(res) => match res { + Ok(vault_txs) => { + let mut list: Vec = Vec::new(); + for txs in vault_txs { + list.push(HistoryEventTransaction::new( + &txs.deposit, + TransactionKind::Deposit, + )); + + if let Some(unvault) = txs.unvault { + list.push(HistoryEventTransaction::new( + &unvault, + TransactionKind::Unvault, + )); + } + if let Some(cancel) = txs.cancel { + list.push(HistoryEventTransaction::new( + &cancel, + TransactionKind::Cancel, + )); + } + if let Some(spend) = txs.spend { + list.push(HistoryEventTransaction::new(&spend, TransactionKind::Spend)); + } + if let Some(unvault_emergency) = txs.unvault_emergency { + list.push(HistoryEventTransaction::new( + &unvault_emergency, + TransactionKind::UnvaultEmergency, + )); + } + if let Some(emergency) = txs.emergency { + list.push(HistoryEventTransaction::new( + &emergency, + TransactionKind::Emergency, + )); + } + } + + list.sort_by(|a, b| a.blockheight.cmp(&b.blockheight)); + self.txs = list; + } + Err(e) => self.loading_fail = Some(e.into()), + }, } } pub fn view(&mut self, ctx: &Context) -> Element { - self.view - .view(ctx, &self.event, &self.txs, self.loading_fail.as_ref()) + let selected = if let Some(txid) = self.selected_tx { + self.txs.iter().find(|vault_tx| vault_tx.tx.txid() == txid) + } else { + None + }; + self.view.view( + ctx, + &self.event, + &self.txs, + selected, + self.flowchart.as_mut().map(|chart| { + chart + .view() + .map(|msg| Message::HistoryEvent(HistoryEventMessage::FlowChart(msg))) + }), + self.loading_fail.as_ref(), + ) } pub fn load(&self, ctx: &Context) -> Command { diff --git a/src/app/state/manager.rs b/src/app/state/manager.rs index 8afbcdce..ded53682 100644 --- a/src/app/state/manager.rs +++ b/src/app/state/manager.rs @@ -344,7 +344,7 @@ impl State for ManagerHomeState { } Message::HistoryEvent(msg) => { if let Some(event) = selected_event { - event.update(msg) + event.update(ctx, msg) } } Message::Close => { diff --git a/src/app/state/stakeholder.rs b/src/app/state/stakeholder.rs index a26e33bc..cf0d66b1 100644 --- a/src/app/state/stakeholder.rs +++ b/src/app/state/stakeholder.rs @@ -204,7 +204,7 @@ impl State for StakeholderHomeState { } Message::HistoryEvent(msg) => { if let Some(event) = selected_event { - event.update(msg) + event.update(ctx, msg) } } Message::Close => { diff --git a/src/app/view/history.rs b/src/app/view/history.rs index a564c134..7b4f7567 100644 --- a/src/app/view/history.rs +++ b/src/app/view/history.rs @@ -1,15 +1,23 @@ use bitcoin::Amount; use chrono::NaiveDateTime; -use iced::{pick_list, Align, Column, Container, Element, Length, Row}; +use iced::{pick_list, scrollable, Align, Column, Container, Element, Length, Row}; use revault_ui::{ - component::{badge, button, card, separation, text::Text, TransparentPickListStyle}, + component::{ + badge, button, button::white_card_button, card, scroll, separation, text::Text, + ContainerBackgroundStyle, TransparentPickListStyle, + }, icon, }; use crate::{ - app::{context::Context, error::Error, message::Message, view::layout}, - daemon::model::{transaction_from_hex, HistoryEvent, HistoryEventKind, VaultTransactions}, + app::{ + context::Context, + error::Error, + message::{HistoryEventMessage, Message}, + view::{layout, warning::warn}, + }, + daemon::model::{HistoryEvent, HistoryEventKind, HistoryEventTransaction, TransactionKind}, }; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -218,13 +226,17 @@ fn event_badge<'a, T: 'a>(event: &HistoryEvent) -> Container<'a, T> { #[derive(Debug)] pub struct HistoryEventView { - modal: layout::Modal, + scroll: scrollable::State, + close_button: iced::button::State, + toggle_button: iced::button::State, } impl HistoryEventView { pub fn new() -> Self { Self { - modal: layout::Modal::new(), + scroll: scrollable::State::new(), + close_button: iced::button::State::new(), + toggle_button: iced::button::State::new(), } } @@ -232,17 +244,110 @@ impl HistoryEventView { &'a mut self, ctx: &Context, event: &HistoryEvent, - txs: &Vec, + txs: &Vec, + selected_tx: Option<&HistoryEventTransaction>, + flowchart: Option>, warning: Option<&Error>, ) -> Element<'a, Message> { if txs.is_empty() { - return self.modal.view( - ctx, - warning, - Container::new(Column::new()), - None, - Message::Close, + let col = Column::new().push( + Column::new() + .push(warn(warning)) + .push( + Row::new() + .push(Column::new().width(Length::Fill)) + .push( + Container::new( + button::close_button(&mut self.close_button) + .on_press(Message::Close), + ) + .width(Length::Shrink), + ) + .align_items(Align::Center) + .padding(20), + ) + .spacing(20), + ); + + return Container::new(scroll(&mut self.scroll, Container::new(col))) + .width(Length::Fill) + .height(Length::Fill) + .style(ContainerBackgroundStyle) + .into(); + } + + if let Some(content) = flowchart { + let mut col = Column::new().push( + Column::new() + .push(warn(warning)) + .push( + Row::new() + .push( + white_card_button( + &mut self.toggle_button, + Container::new( + Row::new() + .push(icon::toggle_on()) + .push(Text::new("Flow chart")) + .spacing(5) + .align_items(iced::Align::Center), + ) + .padding(10), + ) + .on_press(Message::HistoryEvent( + HistoryEventMessage::ToggleFlowChart(false), + )) + .width(Length::Shrink), + ) + .push(Column::new().width(Length::Fill)) + .push( + Container::new( + button::close_button(&mut self.close_button) + .on_press(Message::Close), + ) + .width(Length::Shrink), + ) + .align_items(Align::Center) + .padding(20), + ) + .spacing(20) + .padding(10), ); + + if let Some(selected) = selected_tx { + col = col.push( + Container::new( + Column::new() + .push( + Row::new() + .push(Text::new("Tx ID:").bold().width(Length::Fill)) + .push(Text::new(&format!("{}", selected.tx.txid())).small()), + ) + .push( + Row::new() + .push(Text::new("Blockheight:").bold().width(Length::Fill)) + .push(Text::new(&format!("{}", selected.blockheight)).small()), + ) + .max_width(800), + ) + .width(Length::Fill) + .align_x(Align::Center), + ); + } + + col = col + .push( + Container::new(content) + .width(Length::Fill) + .height(Length::Fill), + ) + .spacing(50); + + return Container::new(col) + .width(Length::Fill) + .height(Length::Fill) + .style(ContainerBackgroundStyle) + .into(); } let content: Element = match event.kind { @@ -251,13 +356,54 @@ impl HistoryEventView { HistoryEventKind::Spend => spend(ctx, event, txs), }; - self.modal.view( - ctx, - warning, - Container::new(content).padding(20).max_width(800), - None, - Message::Close, - ) + let col = Column::new() + .push( + Column::new() + .push(warn(warning)) + .push( + Row::new() + .push( + white_card_button( + &mut self.toggle_button, + Container::new( + Row::new() + .push(icon::toggle_off()) + .push(Text::new("Flow chart")) + .spacing(5) + .align_items(iced::Align::Center), + ) + .padding(10), + ) + .on_press(Message::HistoryEvent( + HistoryEventMessage::ToggleFlowChart(true), + )) + .width(Length::Shrink), + ) + .push(Column::new().width(Length::Fill)) + .push( + Container::new( + button::close_button(&mut self.close_button) + .on_press(Message::Close), + ) + .width(Length::Shrink), + ) + .align_items(Align::Center) + .padding(20), + ) + .spacing(20), + ) + .push( + Container::new(Container::new(content).padding(20).max_width(800)) + .width(Length::Fill) + .align_x(Align::Center), + ) + .spacing(50); + + Container::new(scroll(&mut self.scroll, Container::new(col))) + .width(Length::Fill) + .height(Length::Fill) + .style(ContainerBackgroundStyle) + .into() } } @@ -383,13 +529,16 @@ fn cancel<'a, T: 'a>(ctx: &Context, event: &HistoryEvent) -> Element<'a, T> { fn spend<'a, T: 'a>( ctx: &Context, event: &HistoryEvent, - txs: &Vec, + txs: &Vec, ) -> Element<'a, T> { - let tx = transaction_from_hex(&txs.first().as_ref().unwrap().spend.as_ref().unwrap().hex); + let spend = txs + .iter() + .find(|tx| tx.kind == TransactionKind::Spend) + .unwrap(); let mut col_recipients = Column::new() .push(Text::new("Recipients:").bold()) .spacing(10); - for output in &tx.output { + for output in &spend.tx.output { let addr = bitcoin::Address::from_script(&output.script_pubkey, ctx.network()); let mut row = Row::new(); if let Some(a) = addr { diff --git a/src/daemon/model.rs b/src/daemon/model.rs index 5787a3ec..80b94795 100644 --- a/src/daemon/model.rs +++ b/src/daemon/model.rs @@ -81,3 +81,34 @@ pub const ALL_HISTORY_EVENTS: [HistoryEventKind; 3] = [ HistoryEventKind::Deposit, HistoryEventKind::Spend, ]; + +#[derive(Debug, Clone, PartialEq)] +pub enum TransactionKind { + Deposit, + Unvault, + Cancel, + Spend, + UnvaultEmergency, + Emergency, +} + +#[derive(Debug, Clone)] +pub struct HistoryEventTransaction { + pub tx: Transaction, + pub blockheight: u32, + pub received_time: u32, + pub blocktime: u32, + pub kind: TransactionKind, +} + +impl HistoryEventTransaction { + pub fn new(tx: &WalletTransaction, kind: TransactionKind) -> Self { + Self { + tx: transaction_from_hex(&tx.hex), + blockheight: tx.blockheight.unwrap_or(0), + blocktime: tx.blockheight.unwrap_or(0), + received_time: tx.received_time, + kind, + } + } +} diff --git a/ui/Cargo.toml b/ui/Cargo.toml index 52cbb48f..8bdb83e7 100644 --- a/ui/Cargo.toml +++ b/ui/Cargo.toml @@ -8,4 +8,5 @@ authors = ["Edouard Paris ", "Daniela Brozzoni Transaction { + let bytes = Vec::from_hex(&hex).unwrap(); + encode::deserialize::(&bytes).unwrap() +} + +pub fn main() -> iced::Result { + Example::run(Settings { + antialiasing: true, + ..Settings::default() + }) +} + +struct Example { + flowchart: FlowChart, + selected_tx: Option, +} + +impl Sandbox for Example { + type Message = Message; + + fn new() -> Self { + let deposit1 = transaction_from_hex("02000000000101a0633c79ade54261e15c492c74df1ff8c80950c2d43e0c04b67f9e1837202d4d0000000000feffffff02a019f8070000000022002024c9386efc4c8adef217b2931caed1cf4891f49770526ad5703d6893b49102ce4762894200000000160014a46574ebd02cd775611cd667167c7c0e0389c75602473044022059387b04513edb9e453b73b6bc786d8f3c3f69635825cca56466791cb8eb36ee02207b693ef36162fc0f65ffbc80803a7e64d3a9330953d1fcbd4af76cbe0dfea1fd0121033a62660f6bcb07fce54464b1af49bc6ab42dd62982aeaf141b63665e88cdff6479010000"); + let unvault1 = transaction_from_hex("02000000000101999ca43ceffdfbe623ea6db115b895051a351bdce4313fd3704b8b63a0ae3ba40000000000fdffffff025892f707000000002200206c1c9b72e836a6dec99014406cf88b4df54da9c57b214b99464b987b447a05723075000000000000220020362dcc982d454a66b2e8febd8740769ce23e2030dda9d7f35c08444f8775ab8f040047304402207e93c1fa8a73b155ee918fb82c745cf6ed4a2c58f6c92d7854349584c67a766702200e75f1a301f8338e0ec7082ad2be61d9c5418b7b0b8bf452ff5b3ccd30dc1c2501483045022100cc874a1bfe25e4b2724aa97994d824c181e847015bdb9f4664a5444337988fb1022079801835a0b5c4aa6dd30860f7b6d46fb8fdee032f82dbf9ff6e72cb20fe9a7b0147522103614f52e6ff874bed71cdadeb0ca7bedace71b40f3e8045982c8222fcf2e444fb2102becc5ebd0649c836922ce8a72b54cf9dbdc9d7de56163dcf706368b3b5ade30952ae00000000"); + let deposit2 = transaction_from_hex("02000000000101999ca43ceffdfbe623ea6db115b895051a351bdce4313fd3704b8b63a0ae3ba40100000000feffffff02ce1d663e000000001600145609eb575ae466c49dbbabf917555d3936a8d6ece0432304000000002200200071401c8209b9cddce78d4a7f0447ac5ae7ff1aaaf0b23c47d039b515602aed0247304402200779b079efa05fc6ead8bd7425d2385d37efc7edf74c6f79db39890248f8e3b1022066a27f2e65220d6f04c7fc52124dc4b649ce6299df6ff159e49b48a599cc2ea00121029078365e60b84940862d480210b669fa3c1dafbe518e9ff395320746366fb5e8a4010000"); + let unvault2 = transaction_from_hex("02000000000101a291d1790dabcf8dbea4582616538225ba65ab11a555208b6de8d3659e063ec00100000000fdffffff0298bc220400000000220020c8874f9efdb6d53ee465a21e72d2b86a01aadc37f043286e3b539d6365b0a4d330750000000000002200200d70a091ea22dc0db14ff09c2f5a7504f7ea73bde8cdff194f1cea7687dc28800400483045022100923109e96efbb249848fe70210dd917eedc74c960cd02a98a61656be88fe0474022037e895712c94a29d5c43dfbd54cf0c56682e8288c3cad0b6074ea854fd3c1d21014830450221009dcee6119bbdcbbe513b5bde6a3d960f7763c777f50f790f330c71e8a8891c4b02205a589e738459c757b908749ec6cf0c492814e4d0e6b0a030cd7272096bcd0a1e0147522102f6df74980f0df6e6de298f00798ce53df31594709bc95014bb0f9374d106b57f2102aa12b2ebe1e812fda873df2739d78bff8760814370acb15cad2fded3012490f052ae00000000"); + let spend = transaction_from_hex("02000000000102b82f4bda5f7f2aee012cbdfe24258e4e3117caad6dc6e0c90ceacd220bcee5b200000000000a00000022f340512074d5e5c8874c042e09f7e788a07022aedce2fa00b5ecd1a39b15ec00000000000a00000006a0770000000000002200200d70a091ea22dc0db14ff09c2f5a7504f7ea73bde8cdff194f1cea7687dc2880c0c10d020000000016001439fd6460bc4876e4b8db91b003708532ada21c7390e7850200000000160014532b344ad1ea3397c64f57606d998c61f26cb3edc0de170300000000160014967309eee79b9be232209395e267dbc9357dc29ee8a9080100000000160014dc8efc8b6fb5192855eb2449e120858da5f8f6205b7a6503000000002200200071401c8209b9cddce78d4a7f0447ac5ae7ff1aaaf0b23c47d039b515602aed0300483045022100e309c0d67eeddc5a6740f7cc8dd679696cb213e3ae5684562bf04ae6ce9256ff02206369fcb1e97254d6e71775c5ce73185908fd0667129860f953dedfad50e0a4740183512102a6db8d9cdb53da7175ae9df456499c24ed569b96918f8bb560f0343833d70f092102fe8334ab977d4f6cd14ba25f1dc5153954329d4a68a050e59362884d1141b70552ae6476a914270cbdbbc948dbd56f28a6265c86b31153bacc5388ac6b76a914959beb32358ccd19fb93e9fd25542d0c8d9a2f7c88ac6c935287675ab2680300483045022100ec38954abe5ceedd3c60a7d7ced1e73217f2f7a66fd2efe776906f1d5986804c02203ddf330ecabe2119d4ab6ae573f7b6850c1c6f57c16d24b4c8a2caff68b2197f0183512103f9af2d4e21bc6e1e30f7974146fd728f0dcfa280521172aee1afba659a7290662103b9ad21981e42c41f38df5f8f3ddb80a597d0d3df230818203a8f50e6be443a2652ae6476a914b5e60d45ce94c86e4acace579dbab85fccf05b7288ac6b76a914f6fbf60a5629321aa8dfe9f7d7ce41447ccc670388ac6c935287675ab26800000000"); + Self { + flowchart: FlowChart::new( + Network::Regtest, + vec![deposit1, unvault1, deposit2, unvault2, spend], + ), + selected_tx: None, + } + } + + fn title(&self) -> String { + String::from("FlowChart tool - Iced") + } + + fn update(&mut self, message: Message) { + match message { + Message::TxSelected(selected) => self.selected_tx = selected, + } + } + + fn view(&mut self) -> Element { + Column::new() + .spacing(20) + .align_items(Align::Center) + .push( + Text::new( + self.selected_tx + .map(|id| id.to_string()) + .unwrap_or("nothing".to_string()), + ) + .width(Length::Shrink) + .size(50), + ) + .push(self.flowchart.view()) + .into() + } +} diff --git a/ui/src/chart.rs b/ui/src/chart.rs new file mode 100644 index 00000000..8385ab05 --- /dev/null +++ b/ui/src/chart.rs @@ -0,0 +1,440 @@ +use iced::{ + canvas::event::{self, Event}, + canvas::{self, Canvas, Cursor, Frame, Geometry, Path, Stroke}, + mouse, Color, Element, HorizontalAlignment, Length, Point, Rectangle, Vector, + VerticalAlignment, +}; + +pub use bitcoin::{Network, Transaction, TxIn, TxOut, Txid}; + +use std::collections::BTreeMap; + +#[derive(Debug, Clone)] +pub enum FlowChartMessage { + TxSelected(Option), +} + +#[derive(Debug)] +pub struct FlowChart { + interaction: Interaction, + translation: Vector, + scaling: f32, + cache: canvas::Cache, + transactions: BTreeMap, + selected: Option, +} + +impl FlowChart { + const MIN_SCALING: f32 = 0.2; + const MAX_SCALING: f32 = 2.0; + + /// transactions must be ordered by blockheight. + pub fn new(network: Network, txs: Vec) -> Self { + let mut transactions = BTreeMap::new(); + for (i, tx) in txs.into_iter().enumerate() { + transactions.insert( + tx.txid(), + TxNode::new( + tx, + &transactions, + Point::new(100.0, 400.0 + 100.0 * i as f32), + network, + ), + ); + } + + Self { + interaction: Interaction::None, + cache: canvas::Cache::default(), + translation: Vector::default(), + scaling: 1.0, + transactions, + selected: None, + } + } + + pub fn view<'a>(&'a mut self) -> Element<'a, FlowChartMessage> { + Canvas::new(self) + .width(Length::Fill) + .height(Length::Fill) + .into() + } + + pub fn scale(&self, cursor_position: Point) -> Point { + Point::new( + cursor_position.x / &self.scaling, + cursor_position.y / &self.scaling, + ) + } +} + +impl<'a> canvas::Program for FlowChart { + fn update( + &mut self, + event: Event, + bounds: Rectangle, + cursor: Cursor, + ) -> (event::Status, Option) { + if let Event::Mouse(mouse::Event::ButtonReleased(_)) = event { + self.interaction = Interaction::None; + } + + let cursor_position = if let Some(position) = cursor.position_in(&bounds) { + position + } else { + return (event::Status::Ignored, None); + }; + + match event { + Event::Mouse(mouse_event) => match mouse_event { + mouse::Event::ButtonPressed(button) => { + let message = match button { + mouse::Button::Left => { + for tx in self.transactions.values() { + if tx.hovered(self.scale(cursor_position) - self.translation) { + if self.selected.is_none() { + self.selected = Some(tx.txid); + } else { + self.selected = None; + } + self.cache.clear(); + return ( + event::Status::Captured, + Some(FlowChartMessage::TxSelected(Some(tx.txid.clone()))), + ); + } + } + + self.interaction = Interaction::Panning { + translation: self.translation, + start: cursor_position, + }; + + None + } + _ => None, + }; + + (event::Status::Captured, message) + } + mouse::Event::CursorMoved { .. } => match self.interaction { + Interaction::Panning { translation, start } => { + self.translation = + translation + (cursor_position - start) * (1.0 / self.scaling); + + self.cache.clear(); + (event::Status::Captured, None) + } + Interaction::Hovering => { + self.interaction = Interaction::None; + for tx in self.transactions.values() { + if tx.hovered(self.scale(cursor_position) - self.translation) { + self.interaction = Interaction::Hovering; + return (event::Status::Captured, None); + } + } + (event::Status::Ignored, None) + } + Interaction::None => { + for tx in self.transactions.values() { + if tx.hovered(self.scale(cursor_position) - self.translation) { + self.interaction = Interaction::Hovering; + return (event::Status::Captured, None); + } + } + (event::Status::Ignored, None) + } + }, + mouse::Event::WheelScrolled { delta } => match delta { + mouse::ScrollDelta::Lines { y, .. } | mouse::ScrollDelta::Pixels { y, .. } => { + if y < 0.0 && self.scaling > Self::MIN_SCALING + || y > 0.0 && self.scaling < Self::MAX_SCALING + { + let old_scaling = self.scaling; + + self.scaling = (self.scaling * (1.0 + y / 30.0)) + .max(Self::MIN_SCALING) + .min(Self::MAX_SCALING); + + if let Some(cursor_to_center) = cursor.position_from(bounds.center()) { + let factor = self.scaling - old_scaling; + + self.translation = self.translation + - Vector::new( + cursor_to_center.x * factor / (old_scaling * old_scaling), + cursor_to_center.y * factor / (old_scaling * old_scaling), + ); + } + + self.cache.clear(); + } + + (event::Status::Captured, None) + } + }, + _ => (event::Status::Ignored, None), + }, + _ => (event::Status::Ignored, None), + } + } + + fn draw(&self, bounds: Rectangle, _cursor: Cursor) -> Vec { + let content = self.cache.draw(bounds.size(), |frame: &mut Frame| { + frame.with_save(|frame| { + frame.scale(self.scaling); + frame.translate(self.translation); + if let Some(i) = self.selected { + self.transactions.get(&i).unwrap().draw( + frame, + self.scaling, + true, + &self.transactions, + ); + } else { + for tx in self.transactions.values() { + tx.draw(frame, self.scaling, false, &self.transactions); + } + } + }); + }); + + vec![content] + } + + fn mouse_interaction(&self, _bounds: Rectangle, _cursor: Cursor) -> mouse::Interaction { + match self.interaction { + Interaction::Hovering => mouse::Interaction::Pointer, + Interaction::Panning { .. } => mouse::Interaction::Grabbing, + _ => mouse::Interaction::default(), + } + } +} + +#[derive(Debug)] +pub struct TxNode { + pub position: Point, + pub inputs: Vec, + pub outputs: Vec, + txid: Txid, +} + +impl TxNode { + const BODY_WIDTH: f32 = 120.0; + const BODY_HEIGHT: f32 = 40.0; + const OUTPUT_GAP: f32 = 80.0; + const DISTANCE_FROM_OUTPUTS: f32 = 200.0; + + pub fn new( + transaction: Transaction, + previous_transactions: &BTreeMap, + default_position: Point, + network: Network, + ) -> Self { + let mut inputs: Vec<&OutputNode> = Vec::new(); + for input in &transaction.input { + if let Some(previous) = previous_transactions.get(&input.previous_output.txid) { + inputs.push(&previous.outputs[input.previous_output.vout as usize]) + } + } + + let mut position = if inputs.is_empty() { + default_position + } else { + Self::position_from_inputs(inputs) + }; + + // check that a node is not too close. + for node in previous_transactions.values() { + let minimal_distance = + (Self::total_height(transaction.output.len()) + node.height()) / 2.0; + if node.position.x == position.x && node.position.distance(position) <= minimal_distance + { + if position.y > node.position.y { + position.y += minimal_distance - node.position.distance(position) + 100.0 + } else { + position.y -= minimal_distance + node.position.distance(position) - 100.0 + } + } + } + + let mut y = position.y + Self::BODY_HEIGHT / 2.0 + - Self::total_height(transaction.output.len()) / 2.0; + let mut outputs: Vec = Vec::new(); + for output in transaction.output.iter() { + outputs.push(OutputNode::new( + Point::new( + position.x + Self::BODY_WIDTH + Self::DISTANCE_FROM_OUTPUTS, + y, + ), + output, + network, + )); + y += Self::OUTPUT_GAP; + } + Self { + txid: transaction.txid(), + inputs: transaction.input, + position, + outputs, + } + } + + pub fn hovered(&self, p: Point) -> bool { + p.x >= self.position.x + && p.x <= self.position.x + Self::BODY_WIDTH + && p.y >= self.position.y + && p.y <= self.position.y + Self::BODY_HEIGHT + } + + pub fn position_from_inputs(inputs: Vec<&OutputNode>) -> Point { + let mut min_y = 0.0; + let mut max_y = 0.0; + let mut max_x = 0.0; + for input in &inputs { + if input.position.y < min_y || min_y == 0.0 { + min_y = input.position.y; + } + if input.position.y >= max_y || max_y == 0.0 { + max_y = input.position.y; + } + if input.position.x >= max_x || max_x == 0.0 { + max_x = input.position.x; + } + } + + Point::new( + max_x + Self::DISTANCE_FROM_OUTPUTS, + min_y + (max_y - min_y) / 2.0 - Self::BODY_HEIGHT / 2.0, + ) + } + + pub fn total_height(n_outputs: usize) -> f32 { + (n_outputs as f32 - 1.0) * Self::OUTPUT_GAP + } + + pub fn height(&self) -> f32 { + Self::total_height(self.outputs.len()) + } + + pub fn width(&self) -> f32 { + Self::BODY_WIDTH + Self::DISTANCE_FROM_OUTPUTS + } + + pub fn connect_right(&self) -> Point { + self.position + Vector::new(Self::BODY_WIDTH, Self::BODY_HEIGHT / 2.0) + } + + pub fn connect_left(&self) -> Point { + self.position + Vector::new(0.0, Self::BODY_HEIGHT / 2.0) + } + + pub fn draw( + &self, + frame: &mut Frame, + scale: f32, + selected: bool, + previous_transactions: &BTreeMap, + ) { + let rectangle = Path::rectangle( + self.position, + iced::Size::new(Self::BODY_WIDTH, Self::BODY_HEIGHT), + ); + frame.stroke(&rectangle, Stroke::default().with_width(2.0)); + + // txid + let mut text = canvas::Text::from(&self.txid.to_string()[0..5]); + text.horizontal_alignment = HorizontalAlignment::Center; + text.vertical_alignment = VerticalAlignment::Center; + text.size = text.size * scale; + text.position = + self.position + Vector::new(Self::BODY_WIDTH / 2.0, Self::BODY_HEIGHT / 2.0); + frame.fill_text(text); + + // outputs + for (i, output) in self.outputs.iter().enumerate() { + output.draw(frame, i as u32, scale, selected, false); + let link = Path::new(|p| { + p.move_to(self.connect_right()); + p.bezier_curve_to( + self.connect_right() + Vector::new(25.0, 0.0), + output.connect_left() + Vector::new(-25.0, 0.0), + output.connect_left(), + ); + }); + frame.stroke(&link, Stroke::default().with_width(2.0)); + } + + for input in self.inputs.iter() { + if let Some(previous) = previous_transactions.get(&input.previous_output.txid) { + let node = &previous.outputs[input.previous_output.vout as usize]; + if selected { + node.draw(frame, input.previous_output.vout, scale, selected, true); + } + let link = Path::new(|p| { + p.move_to(node.connect_right()); + p.bezier_curve_to( + node.connect_right() + Vector::new(30.0, 0.0), + self.connect_left() + Vector::new(-30.0, 0.0), + self.connect_left(), + ); + }); + frame.stroke(&link, Stroke::default().with_width(2.0)); + } + } + } +} + +#[derive(Debug, Clone)] +pub struct OutputNode { + position: Point, + address: bitcoin::Address, + value: bitcoin::Amount, +} + +impl OutputNode { + const TXO_RADIUS: f32 = 10.0; + pub fn new(position: Point, txo: &TxOut, network: Network) -> Self { + Self { + position, + address: bitcoin::Address::from_script(&txo.script_pubkey, network).unwrap(), + value: bitcoin::Amount::from_sat(txo.value), + } + } + pub fn connect_left(&self) -> Point { + self.position + Vector::new(-Self::TXO_RADIUS, 0.0) + } + pub fn connect_right(&self) -> Point { + self.position + Vector::new(Self::TXO_RADIUS, 0.0) + } + pub fn draw(&self, frame: &mut Frame, vout: u32, scale: f32, selected: bool, as_input: bool) { + if selected { + let mut address = canvas::Text::from(format!("#{}: {}", vout, self.address)); + address.vertical_alignment = VerticalAlignment::Center; + address.size = address.size * scale; + if as_input { + address.horizontal_alignment = HorizontalAlignment::Right; + address.position = self.position + Vector::new(-Self::TXO_RADIUS - 15.0, 0.0); + } else { + address.horizontal_alignment = HorizontalAlignment::Left; + address.position = self.position + Vector::new(Self::TXO_RADIUS + 15.0, 0.0); + } + frame.fill_text(address); + } + let mut amount = canvas::Text::from(format!("{:.8} BTC", self.value.as_btc(),)); + amount.horizontal_alignment = HorizontalAlignment::Center; + amount.vertical_alignment = VerticalAlignment::Center; + amount.size = amount.size * scale; + amount.position = self.position + Vector::new(0.0, -Self::TXO_RADIUS - 15.0); + frame.fill_text(amount); + let circle = Path::circle(self.position, Self::TXO_RADIUS); + frame.stroke(&circle, Stroke::default().with_width(2.0)); + frame.fill(&circle, Color::BLACK); + } +} + +#[derive(Debug)] +enum Interaction { + None, + Hovering, + Panning { translation: Vector, start: Point }, +} diff --git a/ui/src/icon.rs b/ui/src/icon.rs index 4baac2c7..e109463e 100644 --- a/ui/src/icon.rs +++ b/ui/src/icon.rs @@ -85,6 +85,14 @@ pub fn shield_check_icon() -> Text { icon('\u{F52F}') } +pub fn toggle_off() -> Text { + icon('\u{F5D5}') +} + +pub fn toggle_on() -> Text { + icon('\u{F5D6}') +} + pub fn person_check_icon() -> Text { icon('\u{F4D6}') } diff --git a/ui/src/lib.rs b/ui/src/lib.rs index a05211e7..e519e4f5 100644 --- a/ui/src/lib.rs +++ b/ui/src/lib.rs @@ -1,3 +1,4 @@ +pub mod chart; pub mod color; /// component are wrappers around iced elements; pub mod component;