diff --git a/pulse-ui/src/layout.rs b/pulse-ui/src/layout.rs index 2d992cb..ca37247 100644 --- a/pulse-ui/src/layout.rs +++ b/pulse-ui/src/layout.rs @@ -114,7 +114,7 @@ impl Rect { } pub fn layout(rows: Vec) -> LayoutItem { - LayoutItem::Columns { + LayoutItem::Rows { unit: Size::Percent(100), items: rows, } diff --git a/pulse-ui/src/state.rs b/pulse-ui/src/state.rs index 7ff8e6b..f84f952 100644 --- a/pulse-ui/src/state.rs +++ b/pulse-ui/src/state.rs @@ -10,6 +10,7 @@ pub struct Context { pub(crate) tx: Sender>, } +#[derive(Debug)] pub struct State { pub value: Arc>, tx: Sender>, @@ -46,6 +47,15 @@ impl State { } } +impl Clone for State { + fn clone(&self) -> Self { + State { + value: self.value.clone(), + tx: self.tx.clone(), + } + } +} + impl<'a, T> std::ops::Deref for StateGuard<'a, T> { type Target = T; diff --git a/pulse-ui/src/widget/center.rs b/pulse-ui/src/widget/align.rs similarity index 52% rename from pulse-ui/src/widget/center.rs rename to pulse-ui/src/widget/align.rs index 33ac06e..b72c7fd 100644 --- a/pulse-ui/src/widget/center.rs +++ b/pulse-ui/src/widget/align.rs @@ -18,3 +18,22 @@ impl Widget for Center { scope.draw_text((x, y), &self.0); } } + +pub struct End(pub String); + +impl Widget for End { + fn render(&self, scope: &mut crate::render::RenderScope) { + let mut width = 0; + let mut height = 0; + + for line in self.0.lines() { + height += 1; + width = width.max(line.len() as u16); + } + + let x = scope.rect.width - width; + let y = scope.rect.height - height; + + scope.draw_text((x, y), &self.0); + } +} diff --git a/pulse-ui/src/widget/dynamic.rs b/pulse-ui/src/widget/dynamic.rs new file mode 100644 index 0000000..939e481 --- /dev/null +++ b/pulse-ui/src/widget/dynamic.rs @@ -0,0 +1,9 @@ +use crate::{unit::Rect, widget::Widget}; + +pub struct Dynamic(pub fn(Rect) -> W); + +impl Widget for Dynamic { + fn render(&self, scope: &mut crate::render::RenderScope) { + (self.0)(scope.rect).render(scope); + } +} diff --git a/pulse-ui/src/widget/input.rs b/pulse-ui/src/widget/input.rs new file mode 100644 index 0000000..76bcefe --- /dev/null +++ b/pulse-ui/src/widget/input.rs @@ -0,0 +1,93 @@ +use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers}; + +use crate::widget::Widget; + +#[derive(Debug, Clone)] +pub struct InputState { + pub text: String, + pub cursor: usize, +} + +impl InputState { + pub fn new() -> Self { + Self { + text: String::new(), + cursor: 0, + } + } +} + +pub struct Input<'a>(pub &'a str, pub &'a InputState); + +impl<'a> Widget for Input<'a> { + fn render(&self, scope: &mut crate::render::RenderScope) { + let mut text = self.1.text.clone(); + text.insert_str(self.1.cursor, "❘"); + scope.draw_text(0, format!("{}{text}", self.0)); + } +} + +impl InputState { + pub fn handle_event(&mut self, event: &Event) -> bool { + match event { + Event::Key(KeyEvent { + code: KeyCode::Left, + .. + }) => { + if self.cursor > 0 { + self.cursor -= 1; + } + true + } + + Event::Key(KeyEvent { + code: KeyCode::Right, + .. + }) => { + if self.cursor < self.text.len() { + self.cursor += 1; + } + true + } + + Event::Key(KeyEvent { + code: KeyCode::Backspace, + .. + }) => { + if self.cursor > 0 { + let prev = self.text[..self.cursor] + .char_indices() + .last() + .map(|(i, _)| i) + .unwrap(); + + self.text.remove(prev); + self.cursor = prev; + } + true + } + + Event::Key(KeyEvent { + code: KeyCode::Char(c), + modifiers, + .. + }) => { + if !modifiers.contains(KeyModifiers::CONTROL) + && !modifiers.contains(KeyModifiers::ALT) + { + self.text.insert(self.cursor, *c); + self.cursor += c.len_utf8(); + } + true + } + + Event::Paste(content) => { + self.text.insert_str(self.cursor, content); + self.cursor += content.len(); + true + } + + _ => false, + } + } +} diff --git a/pulse-ui/src/widget/mod.rs b/pulse-ui/src/widget/mod.rs index d75c0e6..dc75b5f 100644 --- a/pulse-ui/src/widget/mod.rs +++ b/pulse-ui/src/widget/mod.rs @@ -1,6 +1,8 @@ -pub mod center; +pub mod align; pub mod outline; pub mod spaced; +pub mod dynamic; +pub mod input; use crate::render::RenderScope; diff --git a/pulse-ui/src/widget/outline.rs b/pulse-ui/src/widget/outline.rs index 8ff1e8c..0d0d75f 100644 --- a/pulse-ui/src/widget/outline.rs +++ b/pulse-ui/src/widget/outline.rs @@ -23,3 +23,33 @@ impl Widget for Outline { scope.draw_text((scope.rect.width - 1, scope.rect.height - 1), "┘"); } } + +pub struct TVLine; + +impl Widget for TVLine { + fn render(&self, scope: &mut crate::render::RenderScope) { + scope.draw_text(0, "─".repeat(scope.rect.width as usize)); + + scope.draw_text(0, "┌"); + scope.draw_text((scope.rect.width - 1, 0), "┐"); + } +} + +pub struct BVLine; + +impl Widget for BVLine { + fn render(&self, scope: &mut crate::render::RenderScope) { + scope.draw_text(0, "─".repeat(scope.rect.width as usize)); + + scope.draw_text(0, "└"); + scope.draw_text((scope.rect.width - 1, 0), "┘"); + } +} + +pub struct VLine; + +impl Widget for VLine { + fn render(&self, scope: &mut crate::render::RenderScope) { + scope.draw_text(0, "─".repeat(scope.rect.width as usize)); + } +} diff --git a/src/formatting.rs b/src/formatting.rs new file mode 100644 index 0000000..2eff77f --- /dev/null +++ b/src/formatting.rs @@ -0,0 +1,141 @@ +use crate::types::{Account, ActivePosition, WatchListItem}; + +pub trait Formatted { + fn get_formatted(&self) -> Vec; +} + +impl Formatted for WatchListItem { + fn get_formatted(&self) -> Vec { + vec![ + format!("\x1b[35m{}\x1b[0m", self.symbol.to_string()), + format_f64(self.price), + format!( + "{} {}", + if self.trend.is_sign_positive() { + "\x1b[32m▲\x1b[0m" + } else { + "\x1b[31m▼\x1b[0m" + }, + format_f64(self.trend.abs()) + ), + ] + } +} + +impl Formatted for ActivePosition { + fn get_formatted(&self) -> Vec { + vec![ + format!("\x1b[35m{}\x1b[0m", self.symbol.to_string()), + format_f64(self.amount), + format!( + "{} {}", + if self.profit.is_sign_positive() { + "\x1b[32m▲\x1b[0m" + } else { + "\x1b[31m▼\x1b[0m" + }, + format_f64(self.profit.abs()) + ), + ] + } +} + +pub struct Property(&'static str, String); + +impl Formatted for Account { + fn get_formatted(&self) -> Vec { + vec![ + Property("Equity", format_f64(self.equity)), + Property("Liquid", format_f64(self.liquid)), + Property("Unreal", format_f64(self.unreal)), + Property("Realized", format_f64(self.realized)), + Property("Margin", format_f64(self.margin)), + ] + .get_formatted() + } +} + +impl Formatted for Property { + fn get_formatted(&self) -> Vec { + vec![format!("\x1b[91m{}\x1b[0m", self.0), self.1.clone()] + } +} + +impl Formatted for Vec { + fn get_formatted(&self) -> Vec { + let mut output = Vec::new(); + + let rows: Vec> = self.iter().map(|v| v.get_formatted()).collect(); + + if rows.is_empty() { + return output; + } + + let column_count = rows.iter().map(|r| r.len()).max().unwrap_or(0); + + let mut widths = vec![0; column_count]; + + for row in &rows { + for (i, col) in row.iter().enumerate() { + widths[i] = widths[i].max(col.len()); + } + } + + for row in rows { + let mut row_out = String::new(); + + for (col_idx, col) in row.iter().enumerate() { + let width = widths[col_idx]; + + row_out.push_str(&format!("{:) -> Vec { + for item in &mut items { + item.insert(0, ' '); + } + + items +} + +pub fn format_f64(value: f64) -> String { + let val = value.to_string(); + let parts: Vec<&str> = val.split('.').collect(); + + let int = parts[0].to_string(); + let negative = int.starts_with('-'); + + let start = if negative { 1 } else { 0 }; + let mut result = String::new(); + + for (i, c) in int[start..].chars().rev().enumerate() { + if i > 0 && i % 3 == 0 { + result.push(','); + } + result.push(c); + } + + let mut formatted: String = result.chars().rev().collect(); + + if negative { + formatted.insert(0, '-'); + } + + if parts.len() > 1 { + formatted.push('.'); + formatted.push_str(parts[1]); + } + + formatted +} diff --git a/src/main.rs b/src/main.rs index 414d870..6577301 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,57 +1,214 @@ +pub mod formatting; +pub mod types; + use std::any::Any; use pulse_ui::{ - App, layout::{LayoutItem, layout}, state::{Refresh, State}, unit::Size, widget::{outline::Outline, spaced::{SpacedColumns, SpacedRows}}, + App, + layout::{LayoutItem, layout}, + state::{Refresh, State}, + unit::Size, + widget::{ + align::Center, + input::{Input, InputState}, + outline::{Outline, VLine}, + spaced::SpacedColumns, + }, +}; + +use crate::{ + formatting::{Formatted, apply_padding}, + types::{Account, ActivePosition, WatchListItem}, }; pub struct PulseTradeApp { - count: State, + command: State, + watch_list: State>, + active_positions: State>, + account: State, } impl App for PulseTradeApp { - async fn init(&mut self, ctx: &pulse_ui::state::Context) {} + async fn init(&mut self, ctx: &pulse_ui::state::Context) { + let mut watch_list = self.watch_list.lock().await; + + watch_list.push(WatchListItem { + symbol: "BTC".to_string(), + price: 118_402.12, + trend: 0.82, + }); + watch_list.push(WatchListItem { + symbol: "ETH".to_string(), + price: 3_912.48, + trend: -0.41, + }); + watch_list.push(WatchListItem { + symbol: "SOL".to_string(), + price: 182.91, + trend: 2.18, + }); + watch_list.push(WatchListItem { + symbol: "XRP".to_string(), + price: 2.84, + trend: 1.22, + }); + + let mut active_positions = self.active_positions.lock().await; + + active_positions.push(ActivePosition { + symbol: "BTC".to_string(), + profit: 125.50, + amount: 0.25, + }); + active_positions.push(ActivePosition { + symbol: "ETH".to_string(), + profit: -32.75, + amount: 1.0, + }); + active_positions.push(ActivePosition { + symbol: "SOL".to_string(), + profit: 84.20, + amount: 5.0, + }); + active_positions.push(ActivePosition { + symbol: "XRP".to_string(), + profit: 12.30, + amount: 0.75, + }); + } async fn update(&mut self, ctx: &pulse_ui::state::Context, event: Box) { if let Some(Refresh) = event.downcast_ref() { return; } - *self.count.lock().await += 1; - - if *self.count.lock().await > 10 { - ctx.close().await; + if let Some(event) = event.downcast_ref() { + if self.command.value.lock().await.handle_event(event) { + return; + } } + + ctx.close().await; } async fn layout(&self) -> pulse_ui::layout::LayoutItem { - layout(vec![LayoutItem::Frame { + LayoutItem::Frame { padding: 1, - item: Box::new(LayoutItem::Widget(Size::Flex(1))), - }]) + item: Box::new(layout(vec![ + LayoutItem::Columns { + unit: Size::Fixed(1), + items: vec![ + LayoutItem::Widget(Size::Flex(1)), + LayoutItem::Widget(Size::Flex(1)), + LayoutItem::Widget(Size::Flex(1)), + ], + }, + // Account status + LayoutItem::Spacing(Size::Fixed(1)), + LayoutItem::Widget(Size::Flex(1)), + // Live status + LayoutItem::Spacing(Size::Fixed(1)), + LayoutItem::Widget(Size::Flex(1)), + // Event logs + LayoutItem::Spacing(Size::Fixed(1)), + LayoutItem::Widget(Size::Flex(1)), + // Input + LayoutItem::Spacing(Size::Fixed(1)), + LayoutItem::Widget(Size::Fixed(1)), + ])), + } } async fn render(&mut self, layout: pulse_ui::layout::Allocation) { layout.draw_frame(0, Outline); + layout.draw(0, format!(" PULSE TRADER v0.1.0")); + layout.draw(1, Center("LIVE".to_string())); + layout.draw(2, Center(format!("14:32:51 UTC"))); + layout.draw_frame(3, VLine); + layout.draw_frame(4, VLine); + layout.draw_frame(5, VLine); + layout.draw_frame(6, VLine); layout.draw( - 0, + 3, SpacedColumns(vec![ - (LayoutItem::Widget(Size::Flex(1)), Box::new("one")), - (LayoutItem::Widget(Size::Flex(1)), Box::new("two")), - (LayoutItem::Widget(Size::Flex(1)), Box::new("three")), ( LayoutItem::Widget(Size::Flex(1)), - Box::new(self.count.display().await), + Box::new(format!( + " WATCHLIST\n{}", + apply_padding(self.watch_list.lock().await.get_formatted()).join("\n") + )), + ), + ( + LayoutItem::Widget(Size::Flex(1)), + Box::new(format!( + " ACTIVE POSITIONS\n{}", + apply_padding(self.active_positions.lock().await.get_formatted()) + .join("\n") + )), + ), + ( + LayoutItem::Widget(Size::Flex(1)), + Box::new(format![ + " ACCOUNT\n{}", + apply_padding(self.account.lock().await.get_formatted()).join("\n") + ]), ), ]), ); + + layout.draw( + 4, + SpacedColumns(vec![ + ( + LayoutItem::Widget(Size::Flex(1)), + Box::new(vec![" ACTIVE STRATEGIES"].join("\n")), + ), + ( + LayoutItem::Widget(Size::Flex(1)), + Box::new( + vec![ + " SIGNALS", + " BUY BTC LIM 118,800.12", + " BUY BTC STL 118,000.00", + " BUY BTC TAP 120,000.00", + ] + .join("\n"), + ), + ), + ( + LayoutItem::Widget(Size::Flex(1)), + Box::new( + vec![ + " SYSTEM", + " Feed: Connected", + " Exchange: Binance", + " DEX: DEX SCREENER", + " Latency: 18 ms", + ] + .join("\n"), + ), + ), + ]), + ); + + layout.draw(6, Input(" > ", &*self.command.lock().await)); } } #[tokio::main] async fn main() { pulse_ui::run(|ctx| PulseTradeApp { - count: ctx.use_state(0), + command: ctx.use_state(InputState::new()), + watch_list: ctx.use_state(Vec::new()), + active_positions: ctx.use_state(Vec::new()), + account: ctx.use_state(Account { + equity: 25_483.21, + liquid: 11_928.43, + unreal: 483.12, + realized: 2_182.49, + margin: 0.0, + }), }) .await; } diff --git a/src/types.rs b/src/types.rs new file mode 100644 index 0000000..3c3b4c1 --- /dev/null +++ b/src/types.rs @@ -0,0 +1,19 @@ +pub struct WatchListItem { + pub symbol: String, + pub price: f64, + pub trend: f64, +} + +pub struct ActivePosition { + pub symbol: String, + pub profit: f64, + pub amount: f64, +} + +pub struct Account { + pub equity: f64, + pub liquid: f64, + pub unreal: f64, + pub realized: f64, + pub margin: f64, +}