use ratatui::{
Frame,
layout::{Constraint, Direction, Layout, Rect},
style::Style,
symbols,
text::{Line, Span},
widgets::{
Axis, Bar, BarChart, BarGroup, Block, Borders, Chart, Dataset, GraphType, List, ListItem,
Paragraph, Row, Table, Tabs, Wrap,
canvas::{Canvas, Line as CanvasLine, Rectangle},
},
};
use std::time::Instant;
use scope::market::{BookSide, TradeSide};
use super::config::{ChartMode, LayoutPreset, ScaleMode, WidgetVisibility};
use super::state::MonitorState;
use crate::cli::textfit::{cols, cut_with_ellipsis, draw_note_alone};
use crate::cli::theme::SECONDARY;
pub(crate) use scope::display::format::format_large_number as format_number;
pub(crate) use scope::display::format::format_price_usd;
use scope::display::theme::Role;
pub(crate) const MIN_W: u16 = 60;
pub(crate) const MIN_H: u16 = 16;
fn too_small_note() -> String {
format!("Terminal too small: need at least {MIN_W}×{MIN_H}")
}
const SYNTHETIC_NOTE: &str = "(synthetic, from DEX pairs)";
const VENUE_ONLY_NOTE: &str = "(not available in venue-only mode)";
const NOT_AVAILABLE: &str = "n/a";
pub(crate) struct LayoutAreas {
pub(crate) price_chart: Option<Rect>,
pub(crate) volume_chart: Option<Rect>,
pub(crate) buy_sell_gauge: Option<Rect>,
pub(crate) metrics_panel: Option<Rect>,
pub(crate) activity_feed: Option<Rect>,
pub(crate) order_book: Option<Rect>,
pub(crate) market_info: Option<Rect>,
pub(crate) trade_history: Option<Rect>,
}
pub(crate) fn layout_dashboard(area: Rect, widgets: &WidgetVisibility) -> LayoutAreas {
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage(55),
Constraint::Percentage(20),
Constraint::Percentage(25),
])
.split(area);
let top = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(60), Constraint::Percentage(40)])
.split(rows[0]);
let middle = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(rows[1]);
LayoutAreas {
price_chart: if widgets.price_chart {
Some(top[0])
} else {
None
},
volume_chart: if widgets.volume_chart {
Some(top[1])
} else {
None
},
buy_sell_gauge: if widgets.buy_sell_pressure {
Some(middle[0])
} else {
None
},
metrics_panel: if widgets.metrics_panel {
Some(middle[1])
} else {
None
},
activity_feed: if widgets.activity_log {
Some(rows[2])
} else {
None
},
order_book: None,
market_info: None,
trade_history: None,
}
}
pub(crate) fn layout_chart_focus(area: Rect, widgets: &WidgetVisibility) -> LayoutAreas {
let vertical = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Percentage(85), Constraint::Percentage(15)])
.split(area);
LayoutAreas {
price_chart: if widgets.price_chart {
Some(vertical[0])
} else {
None
},
volume_chart: None, buy_sell_gauge: None, metrics_panel: if widgets.metrics_panel {
Some(vertical[1])
} else {
None
},
activity_feed: None, order_book: None,
market_info: None,
trade_history: None,
}
}
pub(crate) fn layout_feed(area: Rect, widgets: &WidgetVisibility) -> LayoutAreas {
let vertical = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Percentage(25), Constraint::Percentage(75)])
.split(area);
let top = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(vertical[0]);
LayoutAreas {
price_chart: None, volume_chart: None, metrics_panel: if widgets.metrics_panel {
Some(top[0])
} else {
None
},
buy_sell_gauge: if widgets.buy_sell_pressure {
Some(top[1])
} else {
None
},
activity_feed: if widgets.activity_log {
Some(vertical[1])
} else {
None
},
order_book: None,
market_info: None,
trade_history: None,
}
}
pub(crate) fn layout_compact(area: Rect, widgets: &WidgetVisibility) -> LayoutAreas {
LayoutAreas {
price_chart: None, volume_chart: None, buy_sell_gauge: None, metrics_panel: if widgets.metrics_panel {
Some(area)
} else {
None
},
activity_feed: None, order_book: None,
market_info: None,
trade_history: None,
}
}
pub(crate) fn layout_exchange(area: Rect, _widgets: &WidgetVisibility) -> LayoutAreas {
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Percentage(60), Constraint::Percentage(40)])
.split(area);
let top = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(25),
Constraint::Percentage(45),
Constraint::Percentage(30),
])
.split(rows[0]);
let bottom = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(25), Constraint::Percentage(75)])
.split(rows[1]);
LayoutAreas {
price_chart: Some(top[1]),
volume_chart: None,
buy_sell_gauge: Some(bottom[0]),
metrics_panel: None,
activity_feed: None,
order_book: Some(top[0]),
market_info: Some(bottom[1]),
trade_history: Some(top[2]),
}
}
pub(crate) fn auto_select_layout(size: Rect) -> LayoutPreset {
match (size.width, size.height) {
(w, h) if w < 80 || h < 24 => LayoutPreset::Compact,
(w, _) if w < 120 => LayoutPreset::Feed,
(_, h) if h < 30 => LayoutPreset::ChartFocus,
_ => LayoutPreset::Dashboard,
}
}
pub(crate) fn ui(f: &mut Frame, state: &mut MonitorState) {
let area = f.area();
if area.width < MIN_W || area.height < MIN_H {
draw_note_alone(f, &too_small_note());
return;
}
if state.auto_layout {
let suggested = auto_select_layout(f.area());
if suggested != state.layout {
state.layout = suggested;
}
}
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(4), Constraint::Min(0), Constraint::Length(3), ])
.split(f.area());
render_header(f, chunks[0], state);
let areas = match state.layout {
LayoutPreset::Dashboard => layout_dashboard(chunks[1], &state.widgets),
LayoutPreset::ChartFocus => layout_chart_focus(chunks[1], &state.widgets),
LayoutPreset::Feed => layout_feed(chunks[1], &state.widgets),
LayoutPreset::Compact => layout_compact(chunks[1], &state.widgets),
LayoutPreset::Exchange => layout_exchange(chunks[1], &state.widgets),
};
if let Some(area) = areas.price_chart {
match state.chart_mode {
ChartMode::Line => render_price_chart(f, area, state),
ChartMode::Candlestick => render_candlestick_chart(f, area, state),
ChartMode::VolumeProfile => render_volume_profile_chart(f, area, state),
}
}
if let Some(area) = areas.volume_chart {
render_volume_chart(f, area, &*state);
}
if let Some(area) = areas.buy_sell_gauge {
render_buy_sell_gauge(f, area, state);
}
if let Some(area) = areas.metrics_panel {
if state.widgets.liquidity_depth && !state.liquidity_pairs.is_empty() {
let split = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Percentage(60), Constraint::Percentage(40)])
.split(area);
render_metrics_panel(f, split[0], &*state);
render_liquidity_depth(f, split[1], &*state);
} else {
render_metrics_panel(f, area, &*state);
}
}
if let Some(area) = areas.activity_feed {
render_activity_feed(f, area, state);
}
if let Some(area) = areas.order_book {
render_order_book_panel(f, area, state);
}
if let Some(area) = areas.market_info {
render_market_info_panel(f, area, state);
}
if let Some(area) = areas.trade_history {
render_recent_trades_panel(f, area, state);
}
if !state.active_alerts.is_empty() {
let alert_height = (state.active_alerts.len() as u16 + 2).min(5);
let alert_area = Rect::new(
chunks[1].x,
chunks[1].y,
chunks[1].width,
alert_height.min(chunks[1].height),
);
render_alert_overlay(f, alert_area, state);
}
render_footer(f, chunks[2], state);
if state.show_keys {
render_keys_overlay(f);
}
}
pub(crate) fn render_header(f: &mut Frame, area: Rect, state: &MonitorState) {
let pal = state.palette();
let (price_color, trend_arrow, change_str) = match state.change_24h() {
Some(change) => {
let color = if change >= 0.0 { pal.up } else { pal.down };
let arrow = if change > 0.5 {
"▲"
} else if change < -0.5 {
"▼"
} else if change >= 0.0 {
"△"
} else {
"▽"
};
let sign = if change >= 0.0 { "+" } else { "" };
(color, arrow, format!("{}{:.2}%", sign, change))
}
None => (pal.neutral, "", format!("24h {NOT_AVAILABLE}")),
};
let title = header_title(state, area.width);
let price_str = if state.error_message.is_some() {
format!("{} (stale)", format_price_usd(state.current_price))
} else {
format_price_usd(state.current_price)
};
let header_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Length(1)])
.split(area);
let header = Paragraph::new(Line::from(vec![
Span::styled(price_str, Style::new().fg(price_color).bold()),
Span::raw(" "),
Span::styled(trend_arrow, Style::new().fg(price_color)),
Span::styled(format!(" {}", change_str), Style::new().fg(price_color)),
]))
.block(
Block::default()
.title(title)
.borders(Borders::ALL)
.border_style(crate::cli::theme::ACCENT),
);
f.render_widget(header, header_chunks[0]);
let tab_titles = vec!["1m", "5m", "15m", "1h", "4h", "1d"];
let chart_label = state.chart_mode.label();
let tabs = Tabs::new(tab_titles)
.select(state.time_period.index())
.highlight_style(crate::cli::theme::strong(Role::Accent))
.divider("│")
.padding(" ", " ");
let tabs_line = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Min(20), Constraint::Length(10)])
.split(header_chunks[1]);
f.render_widget(tabs, tabs_line[0]);
f.render_widget(
Paragraph::new(Span::styled(
format!("⊞ {}", chart_label),
crate::cli::theme::ACCENT,
)),
tabs_line[1],
);
}
fn header_title(state: &MonitorState, width: u16) -> Line<'static> {
let base = format!(
" ◈ {} ({}) │ {} ",
state.symbol,
state.name,
state.source_label()
);
let mut spans = vec![Span::raw(base.clone())];
if let Some(err) = &state.error_message {
let room = (width as usize).saturating_sub(2 + cols(&base) + 3);
let err = cut_with_ellipsis(&format!("⚠ {}", err), room);
spans.push(Span::styled(format!("│ {} ", err), crate::cli::theme::ERR));
}
Line::from(spans)
}
pub(crate) fn render_price_chart(f: &mut Frame, area: Rect, state: &MonitorState) {
let (data, is_real) = state.get_price_data_for_period();
if data.is_empty() {
let empty = Paragraph::new("No price data").block(
Block::default()
.title(" Price (USD) ")
.borders(Borders::ALL),
);
f.render_widget(empty, area);
return;
}
let current_price = state.current_price;
let first_price = data.first().map(|(_, p)| *p).unwrap_or(current_price);
let price_change = current_price - first_price;
let price_change_pct = if first_price > 0.0 {
(price_change / first_price) * 100.0
} else {
0.0
};
let pal = state.palette();
let is_price_up = price_change >= 0.0;
let trend_color = if is_price_up { pal.up } else { pal.down };
let trend_symbol = if is_price_up { "▲" } else { "▼" };
let price_str = format_price_usd(current_price);
let change_str = if price_change_pct.abs() < 0.01 {
"0.00%".to_string()
} else {
format!(
"{}{:.2}%",
if is_price_up { "+" } else { "" },
price_change_pct
)
};
let chart_title = Line::from(vec![
Span::raw(" ◆ "),
Span::styled(
format!("{} {} ", price_str, trend_symbol),
Style::new().fg(trend_color).bold(),
),
Span::styled(format!("({}) ", change_str), Style::new().fg(trend_color)),
Span::styled(format!("│{}│ ", state.time_period.label()), SECONDARY),
]);
let (min_price, max_price) = data
.iter()
.fold((f64::MAX, f64::MIN), |(min, max), (_, p)| {
(min.min(*p), max.max(*p))
});
let price_range = max_price - min_price;
let (y_min, y_max) = if price_range < 0.0001 {
let padding = min_price * 0.001;
(min_price - padding, max_price + padding)
} else {
(min_price - price_range * 0.1, max_price + price_range * 0.1)
};
let x_min = data.first().map(|(t, _)| *t).unwrap_or(0.0);
let x_max = data.last().map(|(t, _)| *t).unwrap_or(1.0);
let x_max = if (x_max - x_min).abs() < 0.001 {
x_min + 1.0
} else {
x_max
};
let apply_scale = |price: f64| -> f64 {
match state.scale_mode {
ScaleMode::Linear => price,
ScaleMode::Log => {
if price > 0.0 {
price.ln()
} else {
0.0
}
}
}
};
let (y_min, y_max) = (apply_scale(y_min), apply_scale(y_max));
let synthetic_data: Vec<(f64, f64)> = data
.iter()
.zip(&is_real)
.filter(|(_, real)| !**real)
.map(|((t, p), _)| (*t, apply_scale(*p)))
.collect();
let real_data: Vec<(f64, f64)> = data
.iter()
.zip(&is_real)
.filter(|(_, real)| **real)
.map(|((t, p), _)| (*t, apply_scale(*p)))
.collect();
let reference_line: Vec<(f64, f64)> = vec![
(x_min, apply_scale(first_price)),
(x_max, apply_scale(first_price)),
];
let mut datasets = Vec::new();
datasets.push(
Dataset::default()
.name("━Start")
.marker(symbols::Marker::Braille)
.graph_type(GraphType::Line)
.style(SECONDARY)
.data(&reference_line),
);
if !synthetic_data.is_empty() {
datasets.push(
Dataset::default()
.name("◇Est")
.marker(symbols::Marker::Braille)
.graph_type(GraphType::Line)
.style(crate::cli::theme::ACCENT)
.data(&synthetic_data),
);
}
if !real_data.is_empty() {
datasets.push(
Dataset::default()
.name("●Live")
.marker(symbols::Marker::Braille)
.graph_type(GraphType::Line)
.style(Style::new().fg(trend_color))
.data(&real_data),
);
}
let time_label = format!("-{}", state.time_period.label());
let mid_y = (y_min + y_max) / 2.0;
let y_label = |val: f64| -> String {
match state.scale_mode {
ScaleMode::Linear => format_price_usd(val),
ScaleMode::Log => format_price_usd(val.exp()),
}
};
let scale_label = match state.scale_mode {
ScaleMode::Linear => "USD",
ScaleMode::Log => "USD (log)",
};
let chart = Chart::new(datasets)
.block(
Block::default()
.title(chart_title)
.borders(Borders::ALL)
.border_style(Style::new().fg(trend_color)),
)
.x_axis(
Axis::default()
.title(Span::styled("Time", SECONDARY))
.style(SECONDARY)
.bounds([x_min, x_max])
.labels(vec![Span::raw(time_label), Span::raw("now")]),
)
.y_axis(
Axis::default()
.title(Span::styled(scale_label, SECONDARY))
.style(SECONDARY)
.bounds([y_min, y_max])
.labels(vec![
Span::raw(y_label(y_min)),
Span::raw(y_label(mid_y)),
Span::raw(y_label(y_max)),
]),
);
f.render_widget(chart, area);
}
pub(crate) fn render_candlestick_chart(f: &mut Frame, area: Rect, state: &MonitorState) {
let candles = state.get_ohlc_candles();
if candles.is_empty() {
let empty = Paragraph::new("No candle data (waiting for more data points)").block(
Block::default()
.title(" Candlestick (USD) ")
.borders(Borders::ALL),
);
f.render_widget(empty, area);
return;
}
let current_price = state.current_price;
let first_candle = candles.first().unwrap();
let last_candle = candles.last().unwrap();
let price_change = last_candle.close - first_candle.open;
let price_change_pct = if first_candle.open > 0.0 {
(price_change / first_candle.open) * 100.0
} else {
0.0
};
let pal = state.palette();
let is_price_up = price_change >= 0.0;
let trend_color = if is_price_up { pal.up } else { pal.down };
let trend_symbol = if is_price_up { "▲" } else { "▼" };
let price_str = format_price_usd(current_price);
let change_str = format!(
"{}{:.2}%",
if is_price_up { "+" } else { "" },
price_change_pct
);
let (min_price, max_price) = candles.iter().fold((f64::MAX, f64::MIN), |(min, max), c| {
(min.min(c.low), max.max(c.high))
});
let price_range = max_price - min_price;
let (y_min, y_max) = if price_range < 0.0001 {
let padding = min_price * 0.001;
(min_price - padding, max_price + padding)
} else {
(min_price - price_range * 0.1, max_price + price_range * 0.1)
};
let x_min = candles.first().map(|c| c.timestamp).unwrap_or(0.0);
let x_max = candles.last().map(|c| c.timestamp).unwrap_or(1.0);
let x_range = x_max - x_min;
let x_max = if x_range < 0.001 {
x_min + 1.0
} else {
x_max + x_range * 0.05
};
let candle_count = candles.len() as f64;
let candle_spacing = x_range / candle_count.max(1.0);
let candle_width = candle_spacing * 0.6;
let title = Line::from(vec![
Span::raw(" ⬡ "),
Span::styled(
format!("{} {} ", price_str, trend_symbol),
Style::new().fg(trend_color).bold(),
),
Span::styled(format!("({}) ", change_str), Style::new().fg(trend_color)),
Span::styled(format!("│{}│ ", state.time_period.label()), SECONDARY),
Span::styled("⊞Candles ", crate::cli::theme::ACCENT),
]);
let apply_scale = |price: f64| -> f64 {
match state.scale_mode {
ScaleMode::Linear => price,
ScaleMode::Log => {
if price > 0.0 {
price.ln()
} else {
0.0
}
}
}
};
let scaled_y_min = apply_scale(y_min);
let scaled_y_max = apply_scale(y_max);
let scaled_price_range = scaled_y_max - scaled_y_min;
let candles_clone = candles.clone();
let is_log = matches!(state.scale_mode, ScaleMode::Log);
let pal_up = pal.up;
let pal_down = pal.down;
let canvas = Canvas::default()
.block(
Block::default()
.title(title)
.borders(Borders::ALL)
.border_style(Style::new().fg(trend_color)),
)
.x_bounds([x_min - candle_spacing, x_max])
.y_bounds([scaled_y_min, scaled_y_max])
.paint(move |ctx| {
let scale_fn = |p: f64| -> f64 { if is_log && p > 0.0 { p.ln() } else { p } };
for candle in &candles_clone {
let color = if candle.is_bullish { pal_up } else { pal_down };
ctx.draw(&CanvasLine {
x1: candle.timestamp,
y1: scale_fn(candle.low),
x2: candle.timestamp,
y2: scale_fn(candle.high),
color,
});
let body_top = scale_fn(candle.open.max(candle.close));
let body_bottom = scale_fn(candle.open.min(candle.close));
let body_height = (body_top - body_bottom).max(scaled_price_range * 0.002);
ctx.draw(&Rectangle {
x: candle.timestamp - candle_width / 2.0,
y: body_bottom,
width: candle_width,
height: body_height,
color,
});
}
});
f.render_widget(canvas, area);
}
pub(crate) fn render_volume_profile_chart(f: &mut Frame, area: Rect, state: &MonitorState) {
let pal = state.palette();
let (price_data, _) = state.get_price_data_for_period();
let (volume_data, _) = state.get_volume_data_for_period();
if price_data.len() < 2 || volume_data.is_empty() {
let block = Block::default()
.title(" ◨ Volume Profile (collecting data...) ")
.borders(Borders::ALL)
.border_style(crate::cli::theme::SECONDARY);
f.render_widget(block, area);
return;
}
let min_price = price_data.iter().map(|(_, p)| *p).fold(f64::MAX, f64::min);
let max_price = price_data.iter().map(|(_, p)| *p).fold(f64::MIN, f64::max);
if (max_price - min_price).abs() < f64::EPSILON {
let block = Block::default()
.title(" ◨ Volume Profile (no price range) ")
.borders(Borders::ALL)
.border_style(crate::cli::theme::SECONDARY);
f.render_widget(block, area);
return;
}
let inner_height = area.height.saturating_sub(2) as usize;
let num_buckets = inner_height.clamp(1, 30);
let bucket_size = (max_price - min_price) / num_buckets as f64;
let mut bucket_volumes = vec![0.0_f64; num_buckets];
let vol_iter: Vec<f64> = volume_data.iter().map(|(_, v)| *v).collect();
for (i, (_, price)) in price_data.iter().enumerate() {
let bucket_idx =
(((price - min_price) / bucket_size).floor() as usize).min(num_buckets - 1);
let vol_contribution = if i < vol_iter.len() {
if i > 0 {
(vol_iter[i] - vol_iter[i - 1]).abs().max(1.0)
} else {
1.0
}
} else {
1.0
};
bucket_volumes[bucket_idx] += vol_contribution;
}
let max_vol = bucket_volumes
.iter()
.cloned()
.fold(0.0_f64, f64::max)
.max(1.0);
let current_bucket = (((state.current_price - min_price) / bucket_size).floor() as usize)
.min(num_buckets.saturating_sub(1));
let inner_width = area.width.saturating_sub(12) as usize;
let lines: Vec<Line> = (0..num_buckets)
.rev() .map(|i| {
let bar_width = ((bucket_volumes[i] / max_vol) * inner_width as f64).round() as usize;
let price_mid = min_price + (i as f64 + 0.5) * bucket_size;
let label = if price_mid >= 1.0 {
format!("{:>8.2}", price_mid)
} else {
format!("{:>8.6}", price_mid)
};
let bar_str = "█".repeat(bar_width);
let style = if i == current_bucket {
Style::new().fg(pal.highlight).bold()
} else {
Style::new().fg(pal.sparkline)
};
Line::from(vec![
Span::styled(label, SECONDARY),
Span::raw(" "),
Span::styled(bar_str, style),
])
})
.collect();
let block = Block::default()
.title(" ◨ Volume Profile (accuracy improves over time) ")
.borders(Borders::ALL)
.border_style(Style::new().fg(pal.sparkline));
let paragraph = Paragraph::new(lines).block(block);
f.render_widget(paragraph, area);
}
pub(crate) fn render_volume_chart(f: &mut Frame, area: Rect, state: &MonitorState) {
let pal = state.palette();
let (data, is_real) = state.get_volume_data_for_period();
if data.is_empty() {
let empty = Paragraph::new("No volume data")
.block(Block::default().title(" 24h Volume ").borders(Borders::ALL));
f.render_widget(empty, area);
return;
}
let current_volume = state.volume_24h;
let volume_str = scope::display::format_usd(current_volume);
let has_synthetic = is_real.iter().any(|r| !r);
let has_real = is_real.iter().any(|r| *r);
let data_indicator = if has_synthetic && has_real {
"[◆ est │ ● live]"
} else if has_synthetic {
"[◆ estimated]"
} else {
"[● live]"
};
let chart_title = Line::from(vec![
Span::raw(" ▣ "),
Span::styled(
format!("24h Vol: {} ", volume_str),
Style::new().fg(pal.volume_bar).bold(),
),
Span::styled(format!("│{}│ ", state.time_period.label()), SECONDARY),
Span::styled(data_indicator, SECONDARY),
]);
let inner_width = area.width.saturating_sub(2) as usize; let max_bars = (inner_width / 3).max(1).min(data.len());
let bucket_size = data.len().div_ceil(max_bars);
let bars: Vec<Bar> = data
.chunks(bucket_size)
.zip(is_real.chunks(bucket_size))
.enumerate()
.map(|(i, (chunk, real_chunk))| {
let avg_vol = chunk.iter().map(|(_, v)| v).sum::<f64>() / chunk.len() as f64;
let any_real = real_chunk.iter().any(|r| *r);
let bar_color = if any_real {
pal.volume_bar
} else {
pal.neutral
};
let label = if i == 0 || i == max_bars.saturating_sub(1) || i == max_bars / 2 {
format_number(avg_vol)
} else {
String::new()
};
Bar::default()
.value(avg_vol as u64)
.label(Line::from(label))
.style(Style::new().fg(bar_color))
})
.collect();
let bar_width = if !bars.is_empty() {
let total_bars = bars.len() as u16;
((inner_width as u16).saturating_sub(total_bars.saturating_sub(1))) / total_bars
} else {
1
}
.max(1);
let barchart = BarChart::default()
.data(BarGroup::default().bars(&bars))
.block(
Block::default()
.title(chart_title)
.borders(Borders::ALL)
.border_style(crate::cli::theme::INFO),
)
.bar_width(bar_width)
.bar_gap(1)
.value_style(SECONDARY);
f.render_widget(barchart, area);
}
pub(crate) fn render_buy_sell_gauge(f: &mut Frame, area: Rect, state: &mut MonitorState) {
let pal = state.palette();
let ratio = state.buy_ratio();
let border_color = if ratio > 0.5 { pal.up } else { pal.down };
let block = Block::default()
.title(" ◐ Buy/Sell Ratio (24h) ")
.borders(Borders::ALL)
.border_style(Style::new().fg(border_color));
if state.venue_only {
let note = format!("Buys/Sells: {NOT_AVAILABLE} (venue-only mode)");
let body = Paragraph::new(Span::styled(note, SECONDARY))
.wrap(Wrap { trim: true })
.block(block);
f.render_widget(body, area);
return;
}
let inner = block.inner(area);
f.render_widget(block, area);
if inner.width > 0 && inner.height > 0 {
let buy_width = ((ratio * inner.width as f64).round() as u16).min(inner.width);
let sell_width = inner.width.saturating_sub(buy_width);
let buy_indicator = if ratio > 0.5 { "▶" } else { "▷" };
let sell_indicator = if ratio < 0.5 { "◀" } else { "◁" };
let label = format!(
"{}Buys: {} │ Sells: {}{} ({:.1}%)",
buy_indicator,
state.buys_24h,
state.sells_24h,
sell_indicator,
ratio * 100.0
);
let buy_bar = "█".repeat(buy_width as usize);
let sell_bar = "█".repeat(sell_width as usize);
let bar_line = Line::from(vec![
Span::styled(buy_bar, Style::new().fg(pal.up)),
Span::styled(sell_bar, Style::new().fg(pal.down)),
]);
f.render_widget(Paragraph::new(bar_line), inner);
let label_len = label.len() as u16;
if label_len <= inner.width {
let x_offset = (inner.width.saturating_sub(label_len)) / 2;
let label_area = Rect::new(inner.x + x_offset, inner.y, label_len, 1);
let label_widget = Paragraph::new(Span::styled(label, Style::new().bold()));
f.render_widget(label_widget, label_area);
}
}
}
pub(crate) fn render_activity_feed(f: &mut Frame, area: Rect, state: &mut MonitorState) {
let log_len = state.log_messages.len();
let log_title = if log_len > 0 {
let selected = state.log_list_state.selected().unwrap_or(0);
format!(" ◷ Activity Log [{}/{}] ", selected + 1, log_len)
} else {
" ◷ Activity Log ".to_string()
};
let items: Vec<ListItem> = state
.log_messages
.iter()
.rev()
.map(|msg| ListItem::new(msg.as_str()).style(SECONDARY))
.collect();
let log_list = List::new(items)
.block(
Block::default()
.title(log_title)
.borders(Borders::ALL)
.border_style(crate::cli::theme::SECONDARY),
)
.highlight_style(Style::new().bold())
.highlight_symbol("▸ ");
f.render_stateful_widget(log_list, area, &mut state.log_list_state);
}
pub(crate) fn render_alert_overlay(f: &mut Frame, area: Rect, state: &MonitorState) {
if state.active_alerts.is_empty() {
return;
}
let is_flash_on = state
.alert_flash_until
.map(|deadline| {
if Instant::now() < deadline {
(Instant::now().elapsed().subsec_millis() / 500).is_multiple_of(2)
} else {
false
}
})
.unwrap_or(false);
let border_style = if is_flash_on {
crate::cli::theme::strong(Role::Err)
} else {
crate::cli::theme::strong(Role::Warn)
};
let alert_lines: Vec<Line> = state
.active_alerts
.iter()
.map(|a| {
Line::from(Span::styled(
&a.message,
crate::cli::theme::strong(Role::Err),
))
})
.collect();
let alert_widget = Paragraph::new(alert_lines).block(
Block::default()
.title(" ⚠ ALERTS ")
.borders(Borders::ALL)
.border_style(border_style),
);
f.render_widget(alert_widget, area);
}
pub(crate) fn render_liquidity_depth(f: &mut Frame, area: Rect, state: &MonitorState) {
let pal = state.palette();
if state.liquidity_pairs.is_empty() {
let block = Block::default()
.title(" ◫ Liquidity Depth (no data) ")
.borders(Borders::ALL)
.border_style(crate::cli::theme::SECONDARY);
f.render_widget(block, area);
return;
}
let max_liquidity = state
.liquidity_pairs
.iter()
.map(|(_, liq)| *liq)
.fold(0.0_f64, f64::max)
.max(1.0);
let inner_width = area.width.saturating_sub(2) as usize;
let lines: Vec<Line> = state
.liquidity_pairs
.iter()
.take(area.height.saturating_sub(2) as usize) .map(|(name, liq)| {
let bar_width = ((liq / max_liquidity) * inner_width as f64 * 0.6).round() as usize;
let bar_str = "█".repeat(bar_width);
let label = format!(" {} {}", scope::display::format_usd(*liq), name);
Line::from(vec![
Span::styled(bar_str, Style::new().fg(pal.volume_bar)),
Span::styled(label, Style::new().fg(pal.neutral)),
])
})
.collect();
let block = Block::default()
.title(format!(
" ◫ Liquidity Depth ({} pairs) ",
state.liquidity_pairs.len()
))
.borders(Borders::ALL)
.border_style(crate::cli::theme::SECONDARY);
let paragraph = Paragraph::new(lines).block(block);
f.render_widget(paragraph, area);
}
pub(crate) fn render_metrics_panel(f: &mut Frame, area: Rect, state: &MonitorState) {
let pal = state.palette();
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Min(0)])
.split(area);
let trend_color = if !state.short_changes_known() {
pal.neutral
} else if state.price_change_5m >= 0.0 {
pal.up
} else {
pal.down
};
let block = Block::default()
.title(" ◉ Price Trend ")
.borders(Borders::ALL)
.border_style(Style::new().fg(pal.sparkline));
let inner = block.inner(chunks[0]);
f.render_widget(block, chunks[0]);
let prices: Vec<Option<f64>> = state
.price_history
.iter()
.map(|dp| Some(dp.value))
.collect();
f.render_widget(
crate::cli::charts::Sparkline::new(&prices, Style::new().fg(trend_color)),
inner,
);
let change_5m_str = if !state.short_changes_known() {
NOT_AVAILABLE.to_string()
} else if state.price_change_5m.abs() < 0.0001 {
"0.00%".to_string()
} else {
format!("{:+.4}%", state.price_change_5m)
};
let change_5m_color = if !state.short_changes_known() {
pal.neutral
} else if state.price_change_5m > 0.0 {
pal.up
} else if state.price_change_5m < 0.0 {
pal.down
} else {
pal.neutral
};
let now_ts = chrono::Utc::now().timestamp() as f64;
let secs_since_change = (now_ts - state.last_price_change_at).max(0.0) as u64;
let last_change_str = if secs_since_change < 60 {
format!("{}s ago", secs_since_change)
} else if secs_since_change < 3600 {
format!("{}m ago", secs_since_change / 60)
} else {
format!("{}h ago", secs_since_change / 3600)
};
let last_change_color = if secs_since_change < 60 {
pal.up
} else {
pal.highlight
};
let change_24h_str = state
.change_24h()
.map(|c| format!("{}{:.2}%", if c >= 0.0 { "+" } else { "" }, c))
.unwrap_or_else(|| NOT_AVAILABLE.to_string());
let market_cap_str = state
.market_cap
.map(scope::display::format_usd)
.unwrap_or_else(|| "N/A".to_string());
let dex_stat = |value: String| {
if state.venue_only {
NOT_AVAILABLE.to_string()
} else {
value
}
};
let mut rows = vec![
Row::new(vec![
Span::styled("Price", SECONDARY),
Span::styled(format_price_usd(state.current_price), Style::new().bold()),
]),
Row::new(vec![
Span::styled("5m Chg", SECONDARY),
Span::styled(change_5m_str, Style::new().fg(change_5m_color)),
]),
Row::new(vec![
Span::styled("Last Δ", SECONDARY),
Span::styled(last_change_str, Style::new().fg(last_change_color)),
]),
Row::new(vec![
Span::styled("24h Chg", SECONDARY),
Span::raw(change_24h_str),
]),
Row::new(vec![
Span::styled("Liq", SECONDARY),
Span::raw(dex_stat(scope::display::format_usd(state.liquidity_usd))),
]),
Row::new(vec![
Span::styled("Vol 24h", SECONDARY),
Span::raw(
state
.volume_usd()
.map(scope::display::format_usd)
.unwrap_or_else(|| NOT_AVAILABLE.to_string()),
),
]),
Row::new(vec![
Span::styled("Mkt Cap", SECONDARY),
Span::raw(market_cap_str),
]),
Row::new(vec![
Span::styled("Buys", SECONDARY),
Span::styled(
dex_stat(state.buys_24h.to_string()),
Style::new().fg(pal.up),
),
]),
Row::new(vec![
Span::styled("Sells", SECONDARY),
Span::styled(
dex_stat(state.sells_24h.to_string()),
Style::new().fg(pal.down),
),
]),
];
if state.widgets.holder_count
&& let Some(count) = state.holder_count
{
rows.push(Row::new(vec![
Span::styled("Holders", SECONDARY),
Span::styled(format_number(count as f64), Style::new().fg(pal.highlight)),
]));
}
let table = Table::new(rows, [Constraint::Length(8), Constraint::Min(10)]).block(
Block::default()
.title(" ◉ Key Metrics ")
.borders(Borders::ALL)
.border_style(crate::cli::theme::ACCENT),
);
f.render_widget(table, chunks[1]);
}
pub(crate) fn spread_line(book: &scope::market::OrderBook) -> String {
book.spread()
.zip(book.spread_pct())
.map(|(s, pct)| format!(" Spread: {:.6} ({:.3}%)", s, pct))
.unwrap_or_else(|| " Spread: --".to_string())
}
pub(crate) fn render_order_book_panel(f: &mut Frame, area: Rect, state: &MonitorState) {
let pal = state.palette();
let book = match &state.order_book {
Some(b) if !state.venue_only => b,
_ => {
render_empty_panel(f, area, order_book_title(state), state.venue_only);
return;
}
};
let inner_height = area.height.saturating_sub(2) as usize; if inner_height < 3 {
return;
}
let ask_rows = (inner_height.saturating_sub(1)) / 2;
let bid_rows = inner_height.saturating_sub(ask_rows).saturating_sub(1);
let max_qty = book
.asks
.iter()
.chain(book.bids.iter())
.map(|l| l.quantity)
.fold(0.0_f64, f64::max)
.max(0.001);
let inner_width = area.width.saturating_sub(2) as usize;
let bar_width_max = (inner_width as f64 * 0.3).round() as usize;
let mut lines: Vec<Line> = Vec::with_capacity(inner_height);
let visible_asks: Vec<_> = book.asks.iter().take(ask_rows).collect();
for _ in 0..ask_rows.saturating_sub(visible_asks.len()) {
lines.push(Line::from(""));
}
for level in visible_asks.iter().rev() {
let bar_len = ((level.quantity / max_qty) * bar_width_max as f64).round() as usize;
let bar = "█".repeat(bar_len);
let price_str = format!("{:.6}", level.price);
let qty_str = format_number(level.quantity);
let val_str = format_number(level.value());
let padding = inner_width
.saturating_sub(bar_len)
.saturating_sub(price_str.len())
.saturating_sub(qty_str.len())
.saturating_sub(val_str.len())
.saturating_sub(4); lines.push(Line::from(vec![
Span::styled(bar, Style::new().fg(pal.down).dim()),
Span::raw(" "),
Span::styled(price_str, Style::new().fg(pal.down)),
Span::raw(" ".repeat(padding.max(1))),
Span::styled(qty_str, Style::new().fg(pal.neutral)),
Span::raw(" "),
Span::styled(val_str, SECONDARY),
]));
}
let spread = spread_line(book);
lines.push(Line::from(Span::styled(
spread,
crate::cli::theme::strong(Role::Warn),
)));
for level in book.bids.iter().take(bid_rows) {
let bar_len = ((level.quantity / max_qty) * bar_width_max as f64).round() as usize;
let bar = "█".repeat(bar_len);
let price_str = format!("{:.6}", level.price);
let qty_str = format_number(level.quantity);
let val_str = format_number(level.value());
let padding = inner_width
.saturating_sub(bar_len)
.saturating_sub(price_str.len())
.saturating_sub(qty_str.len())
.saturating_sub(val_str.len())
.saturating_sub(4);
lines.push(Line::from(vec![
Span::styled(bar, Style::new().fg(pal.up).dim()),
Span::raw(" "),
Span::styled(price_str, Style::new().fg(pal.up)),
Span::raw(" ".repeat(padding.max(1))),
Span::styled(qty_str, Style::new().fg(pal.neutral)),
Span::raw(" "),
Span::styled(val_str, SECONDARY),
]));
}
let block = Block::default()
.title(order_book_title(state))
.borders(Borders::ALL)
.border_style(crate::cli::theme::SECONDARY);
let paragraph = Paragraph::new(lines).block(block);
f.render_widget(paragraph, area);
}
pub(crate) fn order_book_title(state: &MonitorState) -> String {
let Some(book) = &state.order_book else {
if state.venue_only {
return format!(" ◈ Order Book {VENUE_ONLY_NOTE} ");
}
return format!(" ◈ Order Book {SYNTHETIC_NOTE} │ no data ");
};
let source = match &state.book_venue {
Some(venue) => format!("({venue})"),
None => format!("({})", book.source.label()),
};
format!(
" ◈ Order Book {source} │ {} │ Ask {} │ Bid {} ",
book.pair,
format_number(book.total_depth(BookSide::Ask)),
format_number(book.total_depth(BookSide::Bid)),
)
}
pub(crate) fn trades_title(state: &MonitorState) -> String {
let source = match &state.trades_venue {
Some(venue) => format!("({venue})"),
None if state.venue_only => return format!(" ◈ Recent Trades {VENUE_ONLY_NOTE} "),
None => SYNTHETIC_NOTE.to_string(),
};
match state.recent_trades.len() {
0 => format!(" ◈ Recent Trades {source} │ no data "),
n => format!(" ◈ Recent Trades {source} │ {n} "),
}
}
fn render_empty_panel(f: &mut Frame, area: Rect, title: String, venue_only: bool) {
let block = Block::default()
.title(title)
.borders(Borders::ALL)
.border_style(crate::cli::theme::SECONDARY);
let body = if venue_only {
"Not available in venue-only mode."
} else {
""
};
let body = Paragraph::new(Span::styled(body, SECONDARY))
.wrap(Wrap { trim: true })
.block(block);
f.render_widget(body, area);
}
pub(crate) fn render_recent_trades_panel(f: &mut Frame, area: Rect, state: &MonitorState) {
let pal = state.palette();
if state.venue_only || state.recent_trades.is_empty() {
render_empty_panel(f, area, trades_title(state), state.venue_only);
return;
}
let inner_height = area.height.saturating_sub(2) as usize;
let inner_width = area.width.saturating_sub(2) as usize;
let time_width = 8; let side_width = 4; let price_width = inner_width
.saturating_sub(time_width)
.saturating_sub(side_width)
.saturating_sub(3) / 2;
let qty_width = inner_width
.saturating_sub(time_width)
.saturating_sub(side_width)
.saturating_sub(price_width)
.saturating_sub(3);
let mut lines: Vec<Line> = Vec::with_capacity(inner_height);
lines.push(Line::from(vec![
Span::styled(format!("{:<time_width$}", "Time"), SECONDARY.bold()),
Span::raw(" "),
Span::styled(format!("{:<side_width$}", "Side"), SECONDARY.bold()),
Span::raw(" "),
Span::styled(format!("{:>price_width$}", "Price"), SECONDARY.bold()),
Span::raw(" "),
Span::styled(format!("{:>qty_width$}", "Qty"), SECONDARY.bold()),
]));
let visible_count = inner_height.saturating_sub(1); for trade in state.recent_trades.iter().rev().take(visible_count) {
let (side_str, side_color) = match trade.side {
TradeSide::Buy => ("BUY ", pal.up),
TradeSide::Sell => ("SELL", pal.down),
};
let secs = (trade.timestamp_ms / 1000) as i64;
let hours = (secs / 3600) % 24;
let mins = (secs / 60) % 60;
let sec = secs % 60;
let time_str = format!("{:02}:{:02}:{:02}", hours, mins, sec);
let price_str = if trade.price >= 1000.0 {
format!("{:.2}", trade.price)
} else if trade.price >= 1.0 {
format!("{:.4}", trade.price)
} else {
format!("{:.6}", trade.price)
};
let qty_str = format_number(trade.quantity);
lines.push(Line::from(vec![
Span::styled(format!("{:<time_width$}", time_str), SECONDARY),
Span::raw(" "),
Span::styled(
format!("{:<side_width$}", side_str),
Style::new().fg(side_color),
),
Span::raw(" "),
Span::styled(
format!("{:>price_width$}", price_str),
Style::new().fg(side_color),
),
Span::raw(" "),
Span::styled(
format!("{:>qty_width$}", qty_str),
Style::new().fg(pal.neutral),
),
]));
}
let block = Block::default()
.title(trades_title(state))
.borders(Borders::ALL)
.border_style(crate::cli::theme::SECONDARY);
let paragraph = Paragraph::new(lines).block(block);
f.render_widget(paragraph, area);
}
pub(crate) fn render_market_info_panel(f: &mut Frame, area: Rect, state: &MonitorState) {
let pal = state.palette();
let cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(60), Constraint::Percentage(40)])
.split(area);
{
let header = Row::new(vec!["DEX / Pair", "Volume 24h", "Liquidity", "Δ 24h"])
.style(crate::cli::theme::strong(Role::Accent))
.bottom_margin(0);
let rows: Vec<Row> = state
.dex_pairs
.iter()
.take(cols[0].height.saturating_sub(3) as usize) .map(|p| {
let pair_label = format!("{}/{}", p.base_token, p.quote_token);
let dex_and_pair = format!("{} {}", p.dex_name, pair_label);
let vol = format_number(p.volume_24h);
let liq = format_number(p.liquidity_usd);
let change_str = format!("{:+.1}%", p.price_change_24h);
let change_color = if p.price_change_24h >= 0.0 {
pal.up
} else {
pal.down
};
Row::new(vec![
ratatui::text::Text::from(dex_and_pair),
ratatui::text::Text::styled(vol, Style::new().fg(pal.neutral)),
ratatui::text::Text::styled(liq, Style::new().fg(pal.volume_bar)),
ratatui::text::Text::styled(change_str, Style::new().fg(change_color)),
])
})
.collect();
let widths = [
Constraint::Percentage(40),
Constraint::Percentage(22),
Constraint::Percentage(22),
Constraint::Percentage(16),
];
let table = Table::new(rows, widths).header(header).block(
Block::default()
.title(format!(" ◫ Trading Pairs ({}) ", state.dex_pairs.len()))
.borders(Borders::ALL)
.border_style(crate::cli::theme::SECONDARY),
);
f.render_widget(table, cols[0]);
}
{
let mut info_lines: Vec<Line> = Vec::new();
let price_color = match state.change_24h() {
Some(c) if c >= 0.0 => pal.up,
Some(_) => pal.down,
None => pal.neutral,
};
info_lines.push(Line::from(vec![
Span::styled(" Price ", SECONDARY),
Span::styled(format_price_usd(state.current_price), Style::new().bold()),
]));
let short = |v: f64| state.short_changes_known().then_some(v);
let changes = [
("5m", short(state.price_change_5m)),
("1h", short(state.price_change_1h)),
("6h", short(state.price_change_6h)),
("24h", state.change_24h()),
];
let change_spans: Vec<Span> = changes
.iter()
.flat_map(|(label, val)| {
let (text, color) = match val {
Some(v) if *v >= 0.0 => (format!("{:+.2}%", v), pal.up),
Some(v) => (format!("{:+.2}%", v), pal.down),
None => (NOT_AVAILABLE.to_string(), pal.neutral),
};
vec![
Span::styled(format!(" {}: ", label), SECONDARY),
Span::styled(text, Style::new().fg(color)),
]
})
.collect();
info_lines.push(Line::from(change_spans));
info_lines.push(Line::from(""));
info_lines.push(Line::from(vec![
Span::styled(" Vol 24h ", SECONDARY),
Span::styled(
state
.volume_usd()
.map(scope::display::format_usd)
.unwrap_or_else(|| NOT_AVAILABLE.to_string()),
Style::new().fg(pal.neutral),
),
]));
let liq = if state.venue_only {
NOT_AVAILABLE.to_string()
} else {
scope::display::format_usd(state.liquidity_usd)
};
info_lines.push(Line::from(vec![
Span::styled(" Liq ", SECONDARY),
Span::styled(liq, Style::new().fg(pal.volume_bar)),
]));
if let Some(mc) = state.market_cap {
info_lines.push(Line::from(vec![
Span::styled(" MCap ", SECONDARY),
Span::styled(scope::display::format_usd(mc), Style::new().fg(pal.neutral)),
]));
}
if let Some(fdv) = state.fdv {
info_lines.push(Line::from(vec![
Span::styled(" FDV ", SECONDARY),
Span::styled(
scope::display::format_usd(fdv),
Style::new().fg(pal.neutral),
),
]));
}
info_lines.push(Line::from(""));
let total_txs = state.buys_24h + state.sells_24h;
let buy_pct = if total_txs > 0 {
(state.buys_24h as f64 / total_txs as f64) * 100.0
} else {
50.0
};
let (buys, sells) = if state.venue_only {
(NOT_AVAILABLE.to_string(), NOT_AVAILABLE.to_string())
} else {
(
format!("{} ({:.0}%)", state.buys_24h, buy_pct),
format!("{} ({:.0}%)", state.sells_24h, 100.0 - buy_pct),
)
};
info_lines.push(Line::from(vec![
Span::styled(" Buys ", SECONDARY),
Span::styled(buys, Style::new().fg(pal.up)),
]));
info_lines.push(Line::from(vec![
Span::styled(" Sells ", SECONDARY),
Span::styled(sells, Style::new().fg(pal.down)),
]));
if let Some(holders) = state.holder_count {
info_lines.push(Line::from(vec![
Span::styled(" Holders ", SECONDARY),
Span::styled(format_number(holders as f64), Style::new().fg(pal.neutral)),
]));
}
if let Some(ts) = state.earliest_pair_created_at {
let dt = chrono::DateTime::from_timestamp(ts, 0)
.map(|d| d.format("%Y-%m-%d").to_string())
.unwrap_or_else(|| "?".to_string());
info_lines.push(Line::from(vec![
Span::styled(" Listed ", SECONDARY),
Span::styled(dt, Style::new().fg(pal.neutral)),
]));
}
if !state.websites.is_empty() || !state.socials.is_empty() {
info_lines.push(Line::from(""));
let mut link_spans = vec![Span::styled(" Links ", SECONDARY)];
for (platform, _url) in &state.socials {
link_spans.push(Span::styled(
format!("[{}] ", platform),
crate::cli::theme::ACCENT,
));
}
for url in &state.websites {
let domain = url
.trim_start_matches("https://")
.trim_start_matches("http://")
.split('/')
.next()
.unwrap_or(url);
link_spans.push(Span::styled(
format!("[{}] ", domain),
crate::cli::theme::LINK,
));
}
info_lines.push(Line::from(link_spans));
}
let title = format!(" ◉ {} ({}) ", state.symbol, state.name);
let block = Block::default()
.title(title)
.borders(Borders::ALL)
.border_style(Style::new().fg(price_color));
let paragraph = Paragraph::new(info_lines).block(block);
f.render_widget(paragraph, cols[1]);
}
}
pub(crate) const MONITOR_KEYS: &[(&str, &str)] = &[
("q, Esc, Ctrl-C", "quit"),
("r", "refresh now"),
("p, Space", "pause or resume"),
("Shift-P", "auto-pause on input"),
("e", "start or stop the CSV export"),
("+ / -", "slower or faster refresh"),
("1-6", "time period 1m, 5m, 15m, 1h, 4h, 1d"),
("t, Tab", "next time period"),
("c", "chart: line, candles, volume profile"),
("s", "scale: linear or log"),
("/", "color scheme"),
("l / h", "next or previous layout"),
("a", "automatic layout"),
("w then 1-5", "show or hide a widget"),
("j / k", "scroll the activity log"),
("?", "this list (any key closes it)"),
];
fn render_keys_overlay(f: &mut Frame) {
let area = f.area();
let width = 56.min(area.width.saturating_sub(4));
let height = (MONITOR_KEYS.len() as u16 + 2).min(area.height.saturating_sub(2));
let rect = Rect::new(
area.x + area.width.saturating_sub(width) / 2,
area.y + area.height.saturating_sub(height) / 2,
width,
height,
);
let lines: Vec<Line> = MONITOR_KEYS
.iter()
.map(|(k, what)| {
Line::from(vec![
Span::styled(format!(" {k:<16}"), crate::cli::theme::KEY),
Span::styled((*what).to_string(), SECONDARY),
])
})
.collect();
f.render_widget(ratatui::widgets::Clear, rect);
f.render_widget(
Paragraph::new(lines).block(
Block::default()
.title(" Keys ")
.borders(Borders::ALL)
.border_style(crate::cli::theme::ACCENT),
),
rect,
);
}
pub(crate) fn render_footer(f: &mut Frame, area: Rect, state: &MonitorState) {
let footer = Paragraph::new(Line::from(footer_spans(state)))
.block(Block::default().borders(Borders::ALL));
f.render_widget(footer, area);
}
pub(crate) fn footer_spans(state: &MonitorState) -> Vec<Span<'static>> {
let elapsed = state.last_update.elapsed().as_secs();
let now_ts = chrono::Utc::now().timestamp() as f64;
let secs_since_change = (now_ts - state.last_price_change_at).max(0.0) as u64;
let price_change_str = if secs_since_change < 60 {
format!("{}s", secs_since_change)
} else if secs_since_change < 3600 {
format!("{}m", secs_since_change / 60)
} else {
format!("{}h", secs_since_change / 3600)
};
let (synthetic_count, real_count) = state.data_stats();
let memory_bytes = state.memory_usage();
let memory_str = if memory_bytes >= 1024 * 1024 {
format!("{:.1}MB", memory_bytes as f64 / (1024.0 * 1024.0))
} else if memory_bytes >= 1024 {
format!("{:.1}KB", memory_bytes as f64 / 1024.0)
} else {
format!("{}B", memory_bytes)
};
let stats = Span::styled(
format!(
"↻ {}s of {}s │ Δ {} │ {} pts │ {}",
elapsed,
state.refresh_rate.as_secs(),
price_change_str,
synthetic_count + real_count,
memory_str
),
SECONDARY,
);
let key = |k: &'static str, word: String| {
[
Span::raw("["),
Span::styled(k, crate::cli::theme::KEY),
Span::raw(format!("]{word} ")),
]
};
let mut spans: Vec<Span<'static>> = key("q", "uit".into()).to_vec();
if state.paused {
spans.push(Span::styled(
"⏸ PAUSED ",
crate::cli::theme::strong(Role::Warn),
));
} else if state.is_auto_paused() {
spans.push(Span::styled(
"⏸ AUTO-PAUSED ",
crate::cli::theme::strong(Role::Accent),
));
}
if state.export_active {
spans.push(Span::styled("● REC ", crate::cli::theme::strong(Role::Err)));
}
if state.widget_toggle_mode {
spans.push(Span::styled(
"widget 1-5? ",
crate::cli::theme::strong(Role::Warn),
));
}
for part in [
key("r", "efresh".into()),
key("p", "ause".into()),
key("e", "xport".into()),
key("l", format!("ayout:{}", state.layout.label())),
key("w", "idgets".into()),
key("c", format!("hart:{}", state.chart_mode.label())),
key("s", format!("cale:{}", state.scale_mode.label())),
key("/", format!("colors:{}", state.color_scheme.label())),
key("t", "ime".into()),
key("?", "keys".into()),
] {
spans.extend(part);
}
spans.extend([Span::raw("║ "), stats]);
spans
}