diff --git a/README.md b/README.md index 5b3b40c..f7521a8 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Terminal Bitcoin Monitor -**!Work in progres!** +![btcmon](share/screenshots/demo.gif?raw=true) Command line monitor for the Bitcoin Network and your Bitcoin and Lightning node. @@ -52,6 +52,8 @@ macaroon_hex = "replaceme" enabled = true currency = "USD" big_text = true +variation = "minute" +variation_threshold = 0.0 [fees] enabled = true diff --git a/share/config/example-multiple.toml b/share/config/example-multiple.toml index 217db44..595ab00 100644 --- a/share/config/example-multiple.toml +++ b/share/config/example-multiple.toml @@ -4,6 +4,8 @@ tick_rate = 250 enabled = true currency = "USD" big_text = true +variation = "minute" +variation_threshold = 0.0 [fees] enabled = true diff --git a/share/config/example.toml b/share/config/example.toml index b052d33..7706b72 100644 --- a/share/config/example.toml +++ b/share/config/example.toml @@ -11,18 +11,12 @@ rpc_user = "polaruser" rpc_password = "polarpass" zmq_port = 28334 -[core_lightning] -rest_address = "http://127.0.0.1:3010" -rest_rune = "replaceme" - -[lnd] -rest_address = "https://127.0.0.1:8080" -macaroon_hex = "replaceme" - [price] enabled = true currency = "USD" big_text = true +variation = "minute" +variation_threshold = 0.0 [fees] enabled = true diff --git a/share/config/price-only.toml b/share/config/price-only.toml new file mode 100644 index 0000000..faf2058 --- /dev/null +++ b/share/config/price-only.toml @@ -0,0 +1,12 @@ +tick_rate = 250 +streamer_mode = false + +[price] +enabled = true +currency = "USD" +big_text = true +variation = "minute" +variation_threshold = 0.0 + +[fees] +enabled = false diff --git a/share/screenshots/demo.gif b/share/screenshots/demo.gif new file mode 100644 index 0000000..9be27cd Binary files /dev/null and b/share/screenshots/demo.gif differ diff --git a/src/app.rs b/src/app.rs index 586259e..888a40a 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,4 +1,5 @@ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEvent, MouseEventKind}; +use std::collections::VecDeque; use std::error; use std::str::FromStr; use tokio::sync::mpsc; @@ -40,6 +41,7 @@ pub struct AppState { pub price: PriceState, pub fees: FeesState, pub node_states: Vec, + pub price_history: VecDeque<(Instant, f64)>, } pub struct App { @@ -81,6 +83,7 @@ impl App { counter: 0, price: PriceState::new(), fees: FeesState::new(), + price_history: VecDeque::new(), node_states: widget_states .into_iter() .map(|ws| { @@ -150,6 +153,18 @@ impl App { pub fn handle_price_update(&mut self, state: PriceState) { self.state.price = state; + let now = Instant::now(); + if let Some(price) = self.state.price.last_price_in_currency { + self.state.price_history.push_back((now, price)); + let max_age = Duration::from_secs(60 * 60 * 24); + while let Some((timestamp, _)) = self.state.price_history.front() { + if now.duration_since(*timestamp) > max_age { + self.state.price_history.pop_front(); + } else { + break; + } + } + } } pub fn handle_node_update( @@ -234,4 +249,4 @@ impl App { } Ok(()) } -} \ No newline at end of file +} diff --git a/src/config.rs b/src/config.rs index 5895fee..b0aa0cb 100644 --- a/src/config.rs +++ b/src/config.rs @@ -33,6 +33,8 @@ pub struct PriceSettings { pub enabled: bool, pub currency: String, pub big_text: bool, + pub variation: String, + pub variation_threshold: f64, } #[derive(Debug, Deserialize, Clone)] @@ -99,6 +101,8 @@ impl AppConfig { .set_default("price.enabled", true)? .set_default("price.big_text", true)? .set_default("price.currency", "USD")? + .set_default("price.variation", "minute")? + .set_default("price.variation_threshold", 0.0)? // fees .set_default("fees.enabled", true)?; @@ -108,21 +112,23 @@ impl AppConfig { (true, false) => argv .get("c") .and_then(|v| Some(v.first().unwrap().as_str())) - .unwrap(), + .unwrap() + .to_string(), (false, true) | (true, true) => argv .get("config") .and_then(|v| Some(v.first().unwrap().as_str())) - .unwrap(), + .unwrap() + .to_string(), _ => match home_path { Some(home_path) => { default_config_file = vec![home_path, "/.btcmon/btcmon.toml"].join(""); - default_config_file.as_str() + default_config_file } - _ => default_config_file.as_str(), + _ => default_config_file, }, }; - s = s.add_source(File::with_name(config_file).required(false)); + s = s.add_source(File::with_name(&config_file).required(false)); let args = argv.clone(); for key in argv.into_keys() { @@ -143,6 +149,29 @@ impl AppConfig { let mut config: AppConfig = s.build()?.try_deserialize()?; + let has_node_args = args.keys().any(|key| { + key.starts_with("bitcoin_core.") + || key.starts_with("core_lightning.") + || key.starts_with("lnd.") + || key.starts_with("nodes") + || key.starts_with("node.") + }); + let file_has_node_settings = std::fs::read_to_string(&config_file) + .map(|contents| { + contents.contains("[bitcoin_core]") + || contents.contains("[core_lightning]") + || contents.contains("[lnd]") + || contents.contains("[nodes]") + || contents.contains("[node]") + }) + .unwrap_or(false); + + if config.nodes.is_empty() && !has_node_args && !file_has_node_settings { + config.bitcoin_core = BitcoinCoreSettings::default(); + config.core_lightning = CoreLightningSettings::default(); + config.lnd = LndSettings::default(); + } + // Clear legacy providers if nodes array is used if !config.nodes.is_empty() { config.bitcoin_core = BitcoinCoreSettings::default(); diff --git a/src/main.rs b/src/main.rs index 3f01533..13bca10 100644 --- a/src/main.rs +++ b/src/main.rs @@ -31,6 +31,8 @@ async fn main() -> AppResult<()> { let mut widgets: Vec> = vec![]; let mut widget_states: Vec> = vec![]; + let mut price_only = false; + // Use nodes from config.nodes if present, otherwise use single node configuration if !config.nodes.is_empty() { for node in &config.nodes { @@ -68,7 +70,6 @@ async fn main() -> AppResult<()> { } } } else { - // Use single node configuration, prioritizing lnd if !config.lnd.rest_address.is_empty() { providers.push(Box::new(LndNode::new(&config.lnd))); widgets.push(Box::new(LndWidget)); @@ -82,14 +83,22 @@ async fn main() -> AppResult<()> { widgets.push(Box::new(BitcoinCoreWidget)); widget_states.push(Box::new(BitcoinCoreWidgetState::default())); } else { - eprintln!("No nodes or single node configuration found."); - std::process::exit(1); + price_only = true; } } if providers.is_empty() { - eprintln!("No valid nodes configured."); - std::process::exit(1); + let no_node_config = config.nodes.is_empty() + && config.lnd.rest_address.is_empty() + && config.core_lightning.rest_address.is_empty() + && config.bitcoin_core.host.is_empty(); + + if no_node_config { + price_only = true; + } else { + eprintln!("No valid nodes configured."); + std::process::exit(1); + } } let mut app = App::new(thread, widgets, widget_states, config.clone()); @@ -111,11 +120,11 @@ async fn main() -> AppResult<()> { app.nodes[i].init(provider, i); } - if config.price.enabled { + if config.price.enabled || price_only { app.init_price(); } - if config.fees.enabled { + if config.fees.enabled && !price_only { app.init_fees(); } diff --git a/src/node/providers/core_lightning.rs b/src/node/providers/core_lightning.rs index cca7c06..bf37f40 100644 --- a/src/node/providers/core_lightning.rs +++ b/src/node/providers/core_lightning.rs @@ -7,8 +7,9 @@ use ratatui::layout::Rect; use ratatui::style::{Color, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::Widget; -use reqwest::Client; +use reqwest::{Client, StatusCode}; use serde::Deserialize; +use serde_json::Value; use std::sync::Arc; use tokio::sync::mpsc::UnboundedSender; use tokio::time::{self, Duration, Instant}; @@ -52,6 +53,36 @@ struct PeerChannelsResponse { channels: Vec, } +fn parse_watchtower_count(value: &Value) -> Option { + value + .get("towers") + .and_then(|towers| towers.as_array()) + .map(|towers| towers.len() as u32) + .or_else(|| { + value + .get("watchtowers") + .and_then(|towers| towers.as_array()) + .map(|towers| towers.len() as u32) + }) + .or_else(|| { + value + .get("towers") + .and_then(|towers| towers.as_object()) + .map(|towers| towers.len() as u32) + }) + .or_else(|| { + value + .as_object() + .map(|towers| towers.len() as u32) + }) + .or_else(|| { + value + .get("num_towers") + .and_then(|count| count.as_u64()) + .map(|count| count as u32) + }) +} + #[derive(Clone)] pub struct CoreLightning { rest_address: String, @@ -70,6 +101,8 @@ pub struct CoreLightningWidgetState { pub total_capacity: u64, pub local_balance: u64, pub num_pending_htlcs: u32, // New field for pending HTLCs + pub num_watchtowers: Option, + pub watchtower_status: Option, } impl DynamicState for CoreLightningWidgetState { @@ -99,50 +132,54 @@ impl DynamicNodeStatefulWidget for CoreLightningWidget { true => "****".to_string(), false => state.alias.clone(), }; - - let lines = vec![ - Line::from(vec![ - Span::raw("Block Height: "), - Span::styled(node_state.height.to_string(), Style::new().fg(Color::White)), - ]), - Line::from(vec![ - Span::raw("Alias: "), - Span::styled(alias_text, Style::new().fg(Color::White)), - ]), - Line::from(vec![ - Span::raw("Active Channels: "), - Span::styled( - state.num_active_channels.to_string(), - Style::new().fg(Color::White), - ), - ]), - Line::from(vec![ - Span::raw("Pending Channels: "), - Span::styled( - state.num_pending_channels.to_string(), - Style::new().fg(Color::White), - ), - ]), - Line::from(vec![ - Span::raw("Inactive Channels: "), - Span::styled( - state.num_inactive_channels.to_string(), - Style::new().fg(Color::White), - ), - ]), - Line::from(vec![ - Span::raw("Peers: "), - Span::styled(state.num_peers.to_string(), Style::new().fg(Color::White)), - ]), - Line::from(vec![ - Span::raw("Pending HTLCs: "), - Span::styled( - state.num_pending_htlcs.to_string(), - Style::new().fg(Color::White), - ), - ]), - Line::raw(""), - ]; + let mut lines = Vec::new(); + lines.push(Line::from(vec![ + Span::raw("Block Height: "), + Span::styled(node_state.height.to_string(), Style::new().fg(Color::White)), + ])); + lines.push(Line::from(vec![ + Span::raw("Alias: "), + Span::styled(alias_text, Style::new().fg(Color::White)), + ])); + lines.push(Line::from(vec![ + Span::raw("Active Channels: "), + Span::styled( + state.num_active_channels.to_string(), + Style::new().fg(Color::White), + ), + ])); + lines.push(Line::from(vec![ + Span::raw("Pending Channels: "), + Span::styled( + state.num_pending_channels.to_string(), + Style::new().fg(Color::White), + ), + ])); + lines.push(Line::from(vec![ + Span::raw("Inactive Channels: "), + Span::styled( + state.num_inactive_channels.to_string(), + Style::new().fg(Color::White), + ), + ])); + lines.push(Line::from(vec![ + Span::raw("Peers: "), + Span::styled(state.num_peers.to_string(), Style::new().fg(Color::White)), + ])); + if let Some(count) = state.num_watchtowers { + lines.push(Line::from(vec![ + Span::raw("Connected Towers: "), + Span::styled(count.to_string(), Style::new().fg(Color::White)), + ])); + } + lines.push(Line::from(vec![ + Span::raw("Pending HTLCs: "), + Span::styled( + state.num_pending_htlcs.to_string(), + Style::new().fg(Color::White), + ), + ])); + lines.push(Line::raw("")); if config.streamer_mode { let widget = BlockedParagraph::new(&state.title, node_state.status, lines); @@ -173,6 +210,8 @@ struct NodeInfo { total_capacity: u64, local_balance: u64, num_pending_htlcs: u32, + num_watchtowers: Option, + watchtower_status: Option, } impl CoreLightning { @@ -232,6 +271,35 @@ impl CoreLightning { Ok(response.json::().await?) } + async fn fetch_watchtowers(&self) -> Result<(Option, Option)> { + let url = format!("{}/v1/listtowers", self.rest_address); + let response = self + .client + .post(&url) + .header("Rune", &self.rune) + .header("Content-Type", "application/json") + .body("{}") + .send() + .await?; + + let status = response.status(); + if status == StatusCode::NOT_FOUND { + return Ok((None, Some("endpoint not exposed".to_string()))); + } + if !status.is_success() { + return Ok((None, Some(format!("HTTP {}", status.as_u16())))); + } + + let value = response.json::().await?; + let count = parse_watchtower_count(&value); + let status = if count.is_some() { + None + } else { + Some("unexpected response".to_string()) + }; + Ok((count, status)) + } + async fn get_node_info(&self) -> Result { let info = match self.fetch_node_info().await { Ok(info) => info, @@ -248,6 +316,8 @@ impl CoreLightning { total_capacity: 0, local_balance: 0, num_pending_htlcs: 0, + num_watchtowers: None, + watchtower_status: None, }); } }; @@ -278,6 +348,10 @@ impl CoreLightning { } Err(e) => (0, 0, 0, format!("Channels fetch error: {}", e)), }; + let (num_watchtowers, watchtower_status) = match self.fetch_watchtowers().await { + Ok((count, status)) => (count, status), + Err(e) => (None, Some(format!("request {}", e))), + }; Ok(NodeInfo { status: NodeStatus::Online, @@ -291,6 +365,8 @@ impl CoreLightning { total_capacity, local_balance, num_pending_htlcs, + num_watchtowers, + watchtower_status, }) } @@ -328,6 +404,8 @@ impl CoreLightning { total_capacity: node_info.total_capacity, local_balance: node_info.local_balance, num_pending_htlcs: node_info.num_pending_htlcs, + num_watchtowers: node_info.num_watchtowers, + watchtower_status: node_info.watchtower_status.clone(), }); state diff --git a/src/node/providers/lnd.rs b/src/node/providers/lnd.rs index 46d7246..b05badb 100644 --- a/src/node/providers/lnd.rs +++ b/src/node/providers/lnd.rs @@ -5,8 +5,9 @@ use ratatui::layout::Rect; use ratatui::style::{Color, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::Widget; -use reqwest::Client; +use reqwest::{Client, StatusCode}; use serde::Deserialize; +use serde_json::Value; use std::sync::Arc; use tokio::sync::mpsc::UnboundedSender; use tokio::time::{self, Duration, Instant}; @@ -51,6 +52,43 @@ struct ChannelsResponse { channels: Vec, } +fn parse_watchtower_count(value: &Value) -> Option { + value + .get("towers") + .and_then(|towers| towers.as_array()) + .map(|towers| towers.len() as u32) + .or_else(|| { + value + .get("watchtowers") + .and_then(|towers| towers.as_array()) + .map(|towers| towers.len() as u32) + }) + .or_else(|| { + value + .get("towers") + .and_then(|towers| towers.as_object()) + .and_then(|towers| { + towers + .get("towers") + .and_then(|nested| nested.as_array()) + .map(|nested| nested.len() as u32) + .or_else(|| Some(towers.len() as u32)) + }) + }) + .or_else(|| { + value + .get("tower") + .and_then(|towers| towers.as_array()) + .map(|towers| towers.len() as u32) + }) + .or_else(|| { + value + .get("num_towers") + .and_then(|count| count.as_u64()) + .map(|count| count as u32) + }) +} + #[derive(Clone)] pub struct LndNode { address: String, @@ -72,6 +110,9 @@ pub struct LndWidgetState { pub synced_to_chain: bool, pub synced_to_graph: bool, pub num_pending_htlcs: u64, + pub num_watchtowers: Option, + pub watchtower_status: Option, + pub watchtower_server_online: Option, } impl DynamicState for LndWidgetState { @@ -112,69 +153,72 @@ impl DynamicNodeStatefulWidget for LndWidget { true => "****".to_string(), false => state.alias.clone(), }; - - let lines = vec![ - block_height, - Line::from(vec![ - Span::raw("Alias: "), - Span::styled(alias_text, Style::new().fg(Color::White)), - ]), - Line::from(vec![ - Span::raw("Active Channels: "), - Span::styled( - state.num_active_channels.to_string(), - Style::new().fg(Color::White), - ), - ]), - Line::from(vec![ - Span::raw("Pending Channels: "), - Span::styled( - state.num_pending_channels.to_string(), - Style::new().fg(Color::White), - ), - ]), - Line::from(vec![ - Span::raw("Inactive Channels: "), - Span::styled( - state.num_inactive_channels.to_string(), - Style::new().fg(Color::White), - ), - ]), - Line::from(vec![ - Span::raw("Synced to Bitcoin: "), - Span::styled( - if state.synced_to_chain { - "True" - } else { - "False" - }, - Style::new().fg(Color::White), - ), - ]), - Line::from(vec![ - Span::raw("Synced to Lightning: "), - Span::styled( - if state.synced_to_graph { - "True" - } else { - "False" - }, - Style::new().fg(Color::White), - ), - ]), - Line::from(vec![ - Span::raw("Peers: "), - Span::styled(state.num_peers.to_string(), Style::new().fg(Color::White)), - ]), - Line::from(vec![ - Span::raw("Pending HTLCs: "), - Span::styled( - state.num_pending_htlcs.to_string(), - Style::new().fg(Color::White), - ), - ]), - Line::raw(""), - ]; + let mut lines = Vec::new(); + lines.push(block_height); + lines.push(Line::from(vec![ + Span::raw("Alias: "), + Span::styled(alias_text, Style::new().fg(Color::White)), + ])); + lines.push(Line::from(vec![ + Span::raw("Active Channels: "), + Span::styled( + state.num_active_channels.to_string(), + Style::new().fg(Color::White), + ), + ])); + lines.push(Line::from(vec![ + Span::raw("Pending Channels: "), + Span::styled( + state.num_pending_channels.to_string(), + Style::new().fg(Color::White), + ), + ])); + lines.push(Line::from(vec![ + Span::raw("Inactive Channels: "), + Span::styled( + state.num_inactive_channels.to_string(), + Style::new().fg(Color::White), + ), + ])); + lines.push(Line::from(vec![ + Span::raw("Peers: "), + Span::styled(state.num_peers.to_string(), Style::new().fg(Color::White)), + ])); + if let Some(count) = state.num_watchtowers { + lines.push(Line::from(vec![ + Span::raw("Connected Towers: "), + Span::styled(count.to_string(), Style::new().fg(Color::White)), + ])); + } + lines.push(Line::from(vec![ + Span::raw("Pending HTLCs: "), + Span::styled( + state.num_pending_htlcs.to_string(), + Style::new().fg(Color::White), + ), + ])); + if let Some(is_online) = state.watchtower_server_online { + let status = if is_online { "Online" } else { "Offline" }; + lines.push(Line::from(vec![ + Span::raw("Tower Server: "), + Span::styled(status, Style::new().fg(Color::White)), + ])); + } + lines.push(Line::from(vec![ + Span::raw("Synced to Bitcoin: "), + Span::styled( + if state.synced_to_chain { "True" } else { "False" }, + Style::new().fg(Color::White), + ), + ])); + lines.push(Line::from(vec![ + Span::raw("Synced to Lightning: "), + Span::styled( + if state.synced_to_graph { "True" } else { "False" }, + Style::new().fg(Color::White), + ), + ])); + lines.push(Line::raw("")); if config.streamer_mode { let widget = BlockedParagraph::new(&state.title, node_state.status, lines); @@ -206,8 +250,16 @@ impl LndNode { } } + fn build_url(&self, path: &str) -> String { + format!( + "{}/{}", + self.address.trim_end_matches('/'), + path.trim_start_matches('/') + ) + } + async fn get_channels(&self) -> Result { - let url = format!("{}/v1/channels", self.address); + let url = self.build_url("/v1/channels"); let resp = self .client .get(&url) @@ -229,8 +281,77 @@ impl LndNode { Ok(channels) } + async fn get_watchtower_count(&self) -> Result<(Option, Option)> { + let endpoints = [ + "/v2/watchtower/client/towers", + "/v2/watchtower/client", + "/v1/watchtower/client/towers", + "/v1/watchtower/client", + ]; + let mut saw_not_found = false; + let mut last_status: Option = None; + + for endpoint in endpoints { + let url = self.build_url(endpoint); + let resp = self + .client + .get(&url) + .header("Grpc-Metadata-macaroon", &self.macaroon) + .send() + .await?; + + let status = resp.status(); + if status == StatusCode::NOT_FOUND { + saw_not_found = true; + continue; + } + if !status.is_success() { + last_status = Some(format!("HTTP {}", status.as_u16())); + continue; + } + + let value = resp.json::().await?; + let count = parse_watchtower_count(&value); + if let Some(count) = count { + return Ok((Some(count), None)); + } + last_status = Some("unexpected response".to_string()); + } + + if saw_not_found { + return Ok((None, Some("endpoint not exposed".to_string()))); + } + + Ok((None, last_status)) + } + + async fn get_watchtower_server_status(&self) -> Result> { + let endpoints = ["/v2/watchtower/server", "/v1/watchtower/server"]; + + for endpoint in endpoints { + let url = self.build_url(endpoint); + let resp = self + .client + .get(&url) + .header("Grpc-Metadata-macaroon", &self.macaroon) + .send() + .await?; + + let status = resp.status(); + if status == StatusCode::NOT_FOUND || status == StatusCode::NOT_IMPLEMENTED { + continue; + } + if status.is_success() { + return Ok(Some(true)); + } + return Ok(Some(false)); + } + + Ok(None) + } + async fn get_node_info(&self, sender: UnboundedSender, index: usize) -> Result<()> { - let url = format!("{}/v1/getinfo", self.address); + let url = self.build_url("/v1/getinfo"); let response_result = self .client @@ -284,6 +405,14 @@ impl LndNode { } Err(_) => (0, 0, 0, 0), }; + let (num_watchtowers, watchtower_status) = match self.get_watchtower_count().await { + Ok((count, status)) => (count, status), + Err(e) => (None, Some(format!("request {}", e))), + }; + let watchtower_server_online = match self.get_watchtower_server_status().await { + Ok(status) => status, + Err(_) => None, + }; let new_status = if info.synced_to_chain && info.synced_to_graph { NodeStatus::Online @@ -324,6 +453,9 @@ impl LndNode { synced_to_chain: info.synced_to_chain, synced_to_graph: info.synced_to_graph, num_pending_htlcs, + num_watchtowers, + watchtower_status: watchtower_status.clone(), + watchtower_server_online, }); state }), @@ -380,6 +512,9 @@ impl NodeProvider for LndNode { synced_to_chain: false, synced_to_graph: false, num_pending_htlcs: 0, + num_watchtowers: None, + watchtower_status: None, + watchtower_server_online: None, }); state }), diff --git a/src/ui/mod.rs b/src/ui/mod.rs index bb2cda0..613bc8a 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -9,16 +9,45 @@ use crate::{ }, }; use ratatui::{ - layout::{Constraint, Direction, Layout}, + layout::{Alignment, Constraint, Direction, Layout}, style::{Color, Style}, + widgets::Widget, + widgets::Paragraph, Frame, }; +use tokio::time::Instant; pub mod fees; pub mod node; pub mod price; pub fn render(config: &AppConfig, app: &mut App, frame: &mut Frame) { + let price_block_style = get_price_block_style(&app.state); + if app.nodes.is_empty() || app.state.node_states.is_empty() { + let layout = Layout::default() + .direction(Direction::Vertical) + .constraints(vec![Constraint::Min(0), Constraint::Length(1)]) + .split(frame.area()); + + let price_widget = PriceWidget::new(PriceWidgetOptions { + big_text: config.price.big_text, + style: price_block_style, + pixel_size: tui_widgets::big_text::PixelSize::Full, + price_style: Style::default().fg(Color::White), + title: "Bitcoin Price".to_string(), + }); + + frame.render_stateful_widget(price_widget, layout[0], &mut app.state); + + let (status_text, status_style) = get_price_variation_text(config, &app.state); + Paragraph::new(status_text) + .style(status_style) + .alignment(Alignment::Right) + .render(layout[1], frame.buffer_mut()); + + return; + } + let current_index = app.current_node_index; let (layout_constraints, status_panel_i): (Vec, usize) = @@ -59,7 +88,10 @@ pub fn render(config: &AppConfig, app: &mut App, frame: &mut Frame) { let price_widget = PriceWidget::new(PriceWidgetOptions { big_text: config.price.big_text, - style, + style: price_block_style, + pixel_size: tui_widgets::big_text::PixelSize::Sextant, + price_style: Style::default().fg(Color::White), + title: "Price".to_string(), }); let fees_widget = FeesWidget { style }; @@ -108,3 +140,76 @@ pub fn get_status_style(status: &NodeStatus) -> Style { NodeStatus::Connecting => Style::default().fg(Color::Blue), } } + +fn get_price_style(config: &AppConfig, state: &crate::app::AppState) -> Style { + let Some((change_pct, _label)) = get_price_variation(config, state) else { + return Style::default().fg(Color::White); + }; + + let threshold = config.price.variation_threshold.abs(); + if change_pct.abs() < threshold { + Style::default().fg(Color::White) + } else if change_pct > 0.0 { + Style::default().fg(Color::Green) + } else if change_pct < 0.0 { + Style::default().fg(Color::Red) + } else { + Style::default().fg(Color::White) + } +} + +fn get_price_variation_text(config: &AppConfig, state: &crate::app::AppState) -> (String, Style) { + let style = get_price_style(config, state); + match get_price_variation(config, state) { + Some((change_pct, label)) => (format!("{:+.2}% ({})", change_pct, label), style), + None => ("Price change: --".to_string(), Style::default().fg(Color::White)), + } +} + +fn get_price_variation( + config: &AppConfig, + state: &crate::app::AppState, +) -> Option<(f64, String)> { + let Some(current) = state.price.last_price_in_currency else { + return None; + }; + + let (window, label) = price_variation_window(config); + let now = Instant::now(); + let cutoff = now.checked_sub(window).unwrap_or(now); + let mut reference = None; + for (timestamp, price) in state.price_history.iter() { + if *timestamp <= cutoff { + reference = Some(*price); + } else { + break; + } + } + if reference.is_none() { + reference = state.price_history.front().map(|(_, price)| *price); + } + + let reference = reference?; + if reference == 0.0 { + return None; + } + + let change_pct = (current - reference) / reference * 100.0; + Some((change_pct, label)) +} + +fn price_variation_window(config: &AppConfig) -> (std::time::Duration, String) { + match config.price.variation.as_str() { + "minute" => (std::time::Duration::from_secs(60), "minute".to_string()), + "hour" => (std::time::Duration::from_secs(60 * 60), "hour".to_string()), + "day" => (std::time::Duration::from_secs(60 * 60 * 24), "day".to_string()), + other => (std::time::Duration::from_secs(60), other.to_string()), + } +} + +fn get_price_block_style(state: &crate::app::AppState) -> Style { + match state.price.last_price_in_currency { + Some(_) => Style::default().fg(Color::Green), + None => Style::default().fg(Color::Red), + } +} diff --git a/src/ui/price.rs b/src/ui/price.rs index 99685c4..eac60b8 100644 --- a/src/ui/price.rs +++ b/src/ui/price.rs @@ -1,7 +1,7 @@ use ratatui::buffer::Buffer; use ratatui::layout::{Alignment, Rect}; use ratatui::style::Style; -use ratatui::widgets::{Block, BorderType, Padding, Paragraph, StatefulWidget, Widget}; +use ratatui::widgets::{Block, BorderType, Paragraph, StatefulWidget, Widget}; use tui_widgets::big_text::{BigText, PixelSize}; use crate::app::AppState; @@ -10,11 +10,20 @@ use crate::app::AppState; pub struct PriceWidgetOptions { pub big_text: bool, pub style: Style, + pub pixel_size: PixelSize, + pub price_style: Style, + pub title: String, } impl Default for PriceWidgetOptions { fn default() -> Self { - PriceWidgetOptions { big_text: true, style: Style::default() } + PriceWidgetOptions { + big_text: true, + style: Style::default(), + pixel_size: PixelSize::Sextant, + price_style: Style::default(), + title: "Price".to_string(), + } } } @@ -32,16 +41,16 @@ impl StatefulWidget for PriceWidget { type State = AppState; fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { - let price_with_currency_lines = vec![match state.price.last_price_in_currency { - Some(v) => vec![v.trunc().to_string(), state.price.currency.to_string()] - .join(" ") - .into(), - None => "...".into(), - }]; + let price_with_currency_lines = match state.price.last_price_in_currency { + Some(v) => vec![ + v.trunc().to_string().into(), + state.price.currency.to_string().into(), + ], + None => vec!["...".into()], + }; let price_block = Block::bordered() - .padding(Padding::top(1)) - .title("Price") + .title(self.options.title) .title_alignment(Alignment::Center) .border_type(BorderType::Plain) .style(self.options.style); @@ -51,41 +60,64 @@ impl StatefulWidget for PriceWidget { if self.options.big_text { if area.width > 48 { + let content_area = centered_area( + price_block_area, + big_text_height(price_with_currency_lines.len(), self.options.pixel_size), + ); let big_text = BigText::builder() .alignment(Alignment::Center) - .pixel_size(PixelSize::Sextant) - .style(self.options.style) + .pixel_size(self.options.pixel_size) + .style(self.options.price_style) .lines(price_with_currency_lines) .build(); - big_text.render(price_block_area, buf); + big_text.render(content_area, buf); return; } else if area.width > 24 { - let price_lines = match state.price.last_price_in_currency { - Some(v) => vec![ - v.trunc().to_string().into(), - state.price.currency.to_string().into(), - ], - None => vec!["...".into()], - }; - + let content_area = centered_area( + price_block_area, + big_text_height(price_with_currency_lines.len(), self.options.pixel_size), + ); let big_text = BigText::builder() .alignment(Alignment::Center) - .pixel_size(PixelSize::Sextant) - .style(self.options.style) - .lines(price_lines) + .pixel_size(self.options.pixel_size) + .style(self.options.price_style) + .lines(price_with_currency_lines.clone()) .build(); - big_text.render(price_block_area, buf); + big_text.render(content_area, buf); return; } } + let content_area = centered_area(price_block_area, price_with_currency_lines.len() as u16); Paragraph::new(price_with_currency_lines) - .style(self.options.style) + .style(self.options.price_style) .alignment(Alignment::Center) - .render(price_block_area, buf); + .render(content_area, buf); + } +} + +fn centered_area(area: Rect, content_height: u16) -> Rect { + if content_height == 0 || area.height == 0 { + return area; } -} \ No newline at end of file + + let content_height = content_height.min(area.height); + let offset = area.height.saturating_sub(content_height) / 2; + Rect::new(area.x, area.y + offset, area.width, content_height) +} + +fn big_text_height(line_count: usize, pixel_size: PixelSize) -> u16 { + let line_height: u16 = match pixel_size { + PixelSize::Full => 8, + PixelSize::HalfHeight => 4, + PixelSize::HalfWidth => 8, + PixelSize::Quadrant => 4, + PixelSize::ThirdHeight => 3, + PixelSize::Sextant => 3, + }; + line_height.saturating_mul(line_count as u16) +}