Merge pull request #5 from selimaj-dev/ui

UI
This commit is contained in:
2026-07-15 12:36:01 +02:00
committed by GitHub
10 changed files with 498 additions and 18 deletions
+1 -1
View File
@@ -114,7 +114,7 @@ impl Rect {
} }
pub fn layout(rows: Vec<LayoutItem>) -> LayoutItem { pub fn layout(rows: Vec<LayoutItem>) -> LayoutItem {
LayoutItem::Columns { LayoutItem::Rows {
unit: Size::Percent(100), unit: Size::Percent(100),
items: rows, items: rows,
} }
+10
View File
@@ -10,6 +10,7 @@ pub struct Context {
pub(crate) tx: Sender<Box<dyn Any + Send + Sync>>, pub(crate) tx: Sender<Box<dyn Any + Send + Sync>>,
} }
#[derive(Debug)]
pub struct State<T> { pub struct State<T> {
pub value: Arc<Mutex<T>>, pub value: Arc<Mutex<T>>,
tx: Sender<Box<dyn Any + Send + Sync>>, tx: Sender<Box<dyn Any + Send + Sync>>,
@@ -46,6 +47,15 @@ impl<T: std::fmt::Display> State<T> {
} }
} }
impl<T> Clone for State<T> {
fn clone(&self) -> Self {
State {
value: self.value.clone(),
tx: self.tx.clone(),
}
}
}
impl<'a, T> std::ops::Deref for StateGuard<'a, T> { impl<'a, T> std::ops::Deref for StateGuard<'a, T> {
type Target = T; type Target = T;
@@ -18,3 +18,22 @@ impl Widget for Center {
scope.draw_text((x, y), &self.0); 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);
}
}
+9
View File
@@ -0,0 +1,9 @@
use crate::{unit::Rect, widget::Widget};
pub struct Dynamic<W: Widget>(pub fn(Rect) -> W);
impl<W: Widget> Widget for Dynamic<W> {
fn render(&self, scope: &mut crate::render::RenderScope) {
(self.0)(scope.rect).render(scope);
}
}
+93
View File
@@ -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,
}
}
}
+3 -1
View File
@@ -1,6 +1,8 @@
pub mod center; pub mod align;
pub mod outline; pub mod outline;
pub mod spaced; pub mod spaced;
pub mod dynamic;
pub mod input;
use crate::render::RenderScope; use crate::render::RenderScope;
+30
View File
@@ -23,3 +23,33 @@ impl Widget for Outline {
scope.draw_text((scope.rect.width - 1, scope.rect.height - 1), "┘"); 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));
}
}
+141
View File
@@ -0,0 +1,141 @@
use crate::types::{Account, ActivePosition, WatchListItem};
pub trait Formatted {
fn get_formatted(&self) -> Vec<String>;
}
impl Formatted for WatchListItem {
fn get_formatted(&self) -> Vec<String> {
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<String> {
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<String> {
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<String> {
vec![format!("\x1b[91m{}\x1b[0m", self.0), self.1.clone()]
}
}
impl<T: Formatted> Formatted for Vec<T> {
fn get_formatted(&self) -> Vec<String> {
let mut output = Vec::new();
let rows: Vec<Vec<String>> = 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!("{:<width$}", col, width = width));
if col_idx + 1 != row.len() {
row_out.push_str(" ");
}
}
output.push(row_out);
}
output
}
}
pub fn apply_padding(mut items: Vec<String>) -> Vec<String> {
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
}
+173 -16
View File
@@ -1,57 +1,214 @@
pub mod formatting;
pub mod types;
use std::any::Any; use std::any::Any;
use pulse_ui::{ 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 { pub struct PulseTradeApp {
count: State<i32>, command: State<InputState>,
watch_list: State<Vec<WatchListItem>>,
active_positions: State<Vec<ActivePosition>>,
account: State<Account>,
} }
impl App for PulseTradeApp { 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<dyn Any + Send + Sync>) { async fn update(&mut self, ctx: &pulse_ui::state::Context, event: Box<dyn Any + Send + Sync>) {
if let Some(Refresh) = event.downcast_ref() { if let Some(Refresh) = event.downcast_ref() {
return; return;
} }
*self.count.lock().await += 1; if let Some(event) = event.downcast_ref() {
if self.command.value.lock().await.handle_event(event) {
if *self.count.lock().await > 10 { return;
ctx.close().await; }
} }
ctx.close().await;
} }
async fn layout(&self) -> pulse_ui::layout::LayoutItem { async fn layout(&self) -> pulse_ui::layout::LayoutItem {
layout(vec![LayoutItem::Frame { LayoutItem::Frame {
padding: 1, 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) { async fn render(&mut self, layout: pulse_ui::layout::Allocation) {
layout.draw_frame(0, Outline); 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( layout.draw(
0, 3,
SpacedColumns(vec![ 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)), 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] #[tokio::main]
async fn main() { async fn main() {
pulse_ui::run(|ctx| PulseTradeApp { 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; .await;
} }
+19
View File
@@ -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,
}