1use ratatui::{
4 Frame,
5 layout::{Constraint, Direction, Layout, Rect},
6 style::{Color, Style},
7 symbols,
8 text::{Line, Span},
9 widgets::{
10 Axis, Bar, BarChart, BarGroup, Block, Borders, Chart, Dataset, GraphType, List, ListItem,
11 Paragraph, Row, Sparkline, Table, Tabs,
12 canvas::{Canvas, Line as CanvasLine, Rectangle},
13 },
14};
15use std::time::Instant;
16
17use scope::market::TradeSide;
18
19use super::config::{ChartMode, LayoutPreset, ScaleMode, WidgetVisibility};
20use super::state::MonitorState;
21
22pub(crate) struct LayoutAreas {
25 pub(crate) price_chart: Option<Rect>,
26 pub(crate) volume_chart: Option<Rect>,
27 pub(crate) buy_sell_gauge: Option<Rect>,
28 pub(crate) metrics_panel: Option<Rect>,
29 pub(crate) activity_feed: Option<Rect>,
30 pub(crate) order_book: Option<Rect>,
32 pub(crate) market_info: Option<Rect>,
34 pub(crate) trade_history: Option<Rect>,
36}
37
38pub(crate) fn layout_dashboard(area: Rect, widgets: &WidgetVisibility) -> LayoutAreas {
50 let rows = Layout::default()
51 .direction(Direction::Vertical)
52 .constraints([
53 Constraint::Percentage(55),
54 Constraint::Percentage(20),
55 Constraint::Percentage(25),
56 ])
57 .split(area);
58
59 let top = Layout::default()
60 .direction(Direction::Horizontal)
61 .constraints([Constraint::Percentage(60), Constraint::Percentage(40)])
62 .split(rows[0]);
63
64 let middle = Layout::default()
65 .direction(Direction::Horizontal)
66 .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
67 .split(rows[1]);
68
69 LayoutAreas {
70 price_chart: if widgets.price_chart {
71 Some(top[0])
72 } else {
73 None
74 },
75 volume_chart: if widgets.volume_chart {
76 Some(top[1])
77 } else {
78 None
79 },
80 buy_sell_gauge: if widgets.buy_sell_pressure {
81 Some(middle[0])
82 } else {
83 None
84 },
85 metrics_panel: if widgets.metrics_panel {
86 Some(middle[1])
87 } else {
88 None
89 },
90 activity_feed: if widgets.activity_log {
91 Some(rows[2])
92 } else {
93 None
94 },
95 order_book: None,
96 market_info: None,
97 trade_history: None,
98 }
99}
100
101pub(crate) fn layout_chart_focus(area: Rect, widgets: &WidgetVisibility) -> LayoutAreas {
113 let vertical = Layout::default()
114 .direction(Direction::Vertical)
115 .constraints([Constraint::Percentage(85), Constraint::Percentage(15)])
116 .split(area);
117
118 LayoutAreas {
119 price_chart: if widgets.price_chart {
120 Some(vertical[0])
121 } else {
122 None
123 },
124 volume_chart: None, buy_sell_gauge: None, metrics_panel: if widgets.metrics_panel {
127 Some(vertical[1])
128 } else {
129 None
130 },
131 activity_feed: None, order_book: None,
133 market_info: None,
134 trade_history: None,
135 }
136}
137
138pub(crate) fn layout_feed(area: Rect, widgets: &WidgetVisibility) -> LayoutAreas {
150 let vertical = Layout::default()
151 .direction(Direction::Vertical)
152 .constraints([Constraint::Percentage(25), Constraint::Percentage(75)])
153 .split(area);
154
155 let top = Layout::default()
156 .direction(Direction::Horizontal)
157 .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
158 .split(vertical[0]);
159
160 LayoutAreas {
161 price_chart: None, volume_chart: None, metrics_panel: if widgets.metrics_panel {
164 Some(top[0])
165 } else {
166 None
167 },
168 buy_sell_gauge: if widgets.buy_sell_pressure {
169 Some(top[1])
170 } else {
171 None
172 },
173 activity_feed: if widgets.activity_log {
174 Some(vertical[1])
175 } else {
176 None
177 },
178 order_book: None,
179 market_info: None,
180 trade_history: None,
181 }
182}
183
184pub(crate) fn layout_compact(area: Rect, widgets: &WidgetVisibility) -> LayoutAreas {
192 LayoutAreas {
193 price_chart: None, volume_chart: None, buy_sell_gauge: None, metrics_panel: if widgets.metrics_panel {
197 Some(area)
198 } else {
199 None
200 },
201 activity_feed: None, order_book: None,
203 market_info: None,
204 trade_history: None,
205 }
206}
207
208pub(crate) fn layout_exchange(area: Rect, _widgets: &WidgetVisibility) -> LayoutAreas {
222 let rows = Layout::default()
223 .direction(Direction::Vertical)
224 .constraints([Constraint::Percentage(60), Constraint::Percentage(40)])
225 .split(area);
226
227 let top = Layout::default()
228 .direction(Direction::Horizontal)
229 .constraints([
230 Constraint::Percentage(25),
231 Constraint::Percentage(45),
232 Constraint::Percentage(30),
233 ])
234 .split(rows[0]);
235
236 let bottom = Layout::default()
237 .direction(Direction::Horizontal)
238 .constraints([Constraint::Percentage(25), Constraint::Percentage(75)])
239 .split(rows[1]);
240
241 LayoutAreas {
242 price_chart: Some(top[1]),
243 volume_chart: None,
244 buy_sell_gauge: Some(bottom[0]),
245 metrics_panel: None,
246 activity_feed: None,
247 order_book: Some(top[0]),
248 market_info: Some(bottom[1]),
249 trade_history: Some(top[2]),
250 }
251}
252
253pub(crate) fn auto_select_layout(size: Rect) -> LayoutPreset {
255 match (size.width, size.height) {
256 (w, h) if w < 80 || h < 24 => LayoutPreset::Compact,
257 (w, _) if w < 120 => LayoutPreset::Feed,
258 (_, h) if h < 30 => LayoutPreset::ChartFocus,
259 _ => LayoutPreset::Dashboard,
260 }
261}
262
263pub(crate) fn ui(f: &mut Frame, state: &mut MonitorState) {
265 if state.auto_layout {
267 let suggested = auto_select_layout(f.area());
268 if suggested != state.layout {
269 state.layout = suggested;
270 }
271 }
272
273 let chunks = Layout::default()
275 .direction(Direction::Vertical)
276 .constraints([
277 Constraint::Length(4), Constraint::Min(10), Constraint::Length(3), ])
281 .split(f.area());
282
283 render_header(f, chunks[0], state);
285
286 let areas = match state.layout {
288 LayoutPreset::Dashboard => layout_dashboard(chunks[1], &state.widgets),
289 LayoutPreset::ChartFocus => layout_chart_focus(chunks[1], &state.widgets),
290 LayoutPreset::Feed => layout_feed(chunks[1], &state.widgets),
291 LayoutPreset::Compact => layout_compact(chunks[1], &state.widgets),
292 LayoutPreset::Exchange => layout_exchange(chunks[1], &state.widgets),
293 };
294
295 if let Some(area) = areas.price_chart {
297 match state.chart_mode {
298 ChartMode::Line => render_price_chart(f, area, state),
299 ChartMode::Candlestick => render_candlestick_chart(f, area, state),
300 ChartMode::VolumeProfile => render_volume_profile_chart(f, area, state),
301 }
302 }
303 if let Some(area) = areas.volume_chart {
304 render_volume_chart(f, area, &*state);
305 }
306 if let Some(area) = areas.buy_sell_gauge {
307 render_buy_sell_gauge(f, area, state);
308 }
309 if let Some(area) = areas.metrics_panel {
310 if state.widgets.liquidity_depth && !state.liquidity_pairs.is_empty() {
312 let split = Layout::default()
313 .direction(Direction::Vertical)
314 .constraints([Constraint::Percentage(60), Constraint::Percentage(40)])
315 .split(area);
316 render_metrics_panel(f, split[0], &*state);
317 render_liquidity_depth(f, split[1], &*state);
318 } else {
319 render_metrics_panel(f, area, &*state);
320 }
321 }
322 if let Some(area) = areas.activity_feed {
323 render_activity_feed(f, area, state);
324 }
325 if let Some(area) = areas.order_book {
327 render_order_book_panel(f, area, state);
328 }
329 if let Some(area) = areas.market_info {
330 render_market_info_panel(f, area, state);
331 }
332 if let Some(area) = areas.trade_history {
333 render_recent_trades_panel(f, area, state);
334 }
335
336 if !state.active_alerts.is_empty() {
338 let alert_height = (state.active_alerts.len() as u16 + 2).min(5);
340 let alert_area = Rect::new(
341 chunks[1].x,
342 chunks[1].y,
343 chunks[1].width,
344 alert_height.min(chunks[1].height),
345 );
346 render_alert_overlay(f, alert_area, state);
347 }
348
349 render_footer(f, chunks[2], state);
351}
352
353pub(crate) fn render_header(f: &mut Frame, area: Rect, state: &MonitorState) {
355 let price_color = if state.price_change_24h >= 0.0 {
356 Color::Green
357 } else {
358 Color::Red
359 };
360
361 let trend_arrow = if state.price_change_24h > 0.5 {
363 "▲"
364 } else if state.price_change_24h < -0.5 {
365 "▼"
366 } else if state.price_change_24h >= 0.0 {
367 "△"
368 } else {
369 "▽"
370 };
371
372 let change_str = format!(
373 "{}{:.2}%",
374 if state.price_change_24h >= 0.0 {
375 "+"
376 } else {
377 ""
378 },
379 state.price_change_24h
380 );
381
382 let title = format!(
383 " ◈ {} ({}) │ {} ",
384 state.symbol,
385 state.name,
386 state.chain.to_uppercase(),
387 );
388
389 let price_str = format_price_usd(state.current_price);
390
391 let header_chunks = Layout::default()
393 .direction(Direction::Vertical)
394 .constraints([Constraint::Length(3), Constraint::Length(1)])
395 .split(area);
396
397 let header = Paragraph::new(Line::from(vec![
399 Span::styled(price_str, Style::new().fg(price_color).bold()),
400 Span::raw(" "),
401 Span::styled(trend_arrow, Style::new().fg(price_color)),
402 Span::styled(format!(" {}", change_str), Style::new().fg(price_color)),
403 ]))
404 .block(
405 Block::default()
406 .title(title)
407 .borders(Borders::ALL)
408 .border_style(Style::new().cyan()),
409 );
410
411 f.render_widget(header, header_chunks[0]);
412
413 let tab_titles = vec!["1m", "5m", "15m", "1h", "4h", "1d"];
415 let chart_label = state.chart_mode.label();
416 let tabs = Tabs::new(tab_titles)
417 .select(state.time_period.index())
418 .highlight_style(Style::new().cyan().bold())
419 .divider("│")
420 .padding(" ", " ");
421 let tabs_line = Layout::default()
422 .direction(Direction::Horizontal)
423 .constraints([Constraint::Min(20), Constraint::Length(10)])
424 .split(header_chunks[1]);
425 f.render_widget(tabs, tabs_line[0]);
426 f.render_widget(
427 Paragraph::new(Span::styled(
428 format!("⊞ {}", chart_label),
429 Style::new().magenta(),
430 )),
431 tabs_line[1],
432 );
433}
434
435pub(crate) fn render_price_chart(f: &mut Frame, area: Rect, state: &MonitorState) {
437 let (data, is_real) = state.get_price_data_for_period();
439
440 if data.is_empty() {
441 let empty = Paragraph::new("No price data").block(
442 Block::default()
443 .title(" Price (USD) ")
444 .borders(Borders::ALL),
445 );
446 f.render_widget(empty, area);
447 return;
448 }
449
450 let current_price = state.current_price;
452 let first_price = data.first().map(|(_, p)| *p).unwrap_or(current_price);
453 let price_change = current_price - first_price;
454 let price_change_pct = if first_price > 0.0 {
455 (price_change / first_price) * 100.0
456 } else {
457 0.0
458 };
459
460 let pal = state.palette();
462 let is_price_up = price_change >= 0.0;
463 let trend_color = if is_price_up { pal.up } else { pal.down };
464 let trend_symbol = if is_price_up { "▲" } else { "▼" };
465
466 let price_str = format_price_usd(current_price);
468 let change_str = if price_change_pct.abs() < 0.01 {
469 "0.00%".to_string()
470 } else {
471 format!(
472 "{}{:.2}%",
473 if is_price_up { "+" } else { "" },
474 price_change_pct
475 )
476 };
477
478 let chart_title = Line::from(vec![
480 Span::raw(" ◆ "),
481 Span::styled(
482 format!("{} {} ", price_str, trend_symbol),
483 Style::new().fg(trend_color).bold(),
484 ),
485 Span::styled(format!("({}) ", change_str), Style::new().fg(trend_color)),
486 Span::styled(
487 format!("│{}│ ", state.time_period.label()),
488 Style::new().gray(),
489 ),
490 ]);
491
492 let (min_price, max_price) = data
494 .iter()
495 .fold((f64::MAX, f64::MIN), |(min, max), (_, p)| {
496 (min.min(*p), max.max(*p))
497 });
498
499 let price_range = max_price - min_price;
501 let (y_min, y_max) = if price_range < 0.0001 {
502 let padding = min_price * 0.001;
504 (min_price - padding, max_price + padding)
505 } else {
506 (min_price - price_range * 0.1, max_price + price_range * 0.1)
507 };
508
509 let x_min = data.first().map(|(t, _)| *t).unwrap_or(0.0);
510 let x_max = data.last().map(|(t, _)| *t).unwrap_or(1.0);
511 let x_max = if (x_max - x_min).abs() < 0.001 {
513 x_min + 1.0
514 } else {
515 x_max
516 };
517
518 let apply_scale = |price: f64| -> f64 {
520 match state.scale_mode {
521 ScaleMode::Linear => price,
522 ScaleMode::Log => {
523 if price > 0.0 {
524 price.ln()
525 } else {
526 0.0
527 }
528 }
529 }
530 };
531
532 let (y_min, y_max) = (apply_scale(y_min), apply_scale(y_max));
533
534 let synthetic_data: Vec<(f64, f64)> = data
536 .iter()
537 .zip(&is_real)
538 .filter(|(_, real)| !**real)
539 .map(|((t, p), _)| (*t, apply_scale(*p)))
540 .collect();
541
542 let real_data: Vec<(f64, f64)> = data
543 .iter()
544 .zip(&is_real)
545 .filter(|(_, real)| **real)
546 .map(|((t, p), _)| (*t, apply_scale(*p)))
547 .collect();
548
549 let reference_line: Vec<(f64, f64)> = vec![
551 (x_min, apply_scale(first_price)),
552 (x_max, apply_scale(first_price)),
553 ];
554
555 let mut datasets = Vec::new();
556
557 datasets.push(
559 Dataset::default()
560 .name("━Start")
561 .marker(symbols::Marker::Braille)
562 .graph_type(GraphType::Line)
563 .style(Style::new().dark_gray())
564 .data(&reference_line),
565 );
566
567 if !synthetic_data.is_empty() {
569 datasets.push(
570 Dataset::default()
571 .name("◇Est")
572 .marker(symbols::Marker::Braille)
573 .graph_type(GraphType::Line)
574 .style(Style::new().cyan())
575 .data(&synthetic_data),
576 );
577 }
578
579 if !real_data.is_empty() {
581 datasets.push(
582 Dataset::default()
583 .name("●Live")
584 .marker(symbols::Marker::Braille)
585 .graph_type(GraphType::Line)
586 .style(Style::new().fg(trend_color))
587 .data(&real_data),
588 );
589 }
590
591 let time_label = format!("-{}", state.time_period.label());
593
594 let mid_y = (y_min + y_max) / 2.0;
597 let y_label = |val: f64| -> String {
598 match state.scale_mode {
599 ScaleMode::Linear => format_price_usd(val),
600 ScaleMode::Log => format_price_usd(val.exp()),
601 }
602 };
603
604 let scale_label = match state.scale_mode {
605 ScaleMode::Linear => "USD",
606 ScaleMode::Log => "USD (log)",
607 };
608
609 let chart = Chart::new(datasets)
610 .block(
611 Block::default()
612 .title(chart_title)
613 .borders(Borders::ALL)
614 .border_style(Style::new().fg(trend_color)),
615 )
616 .x_axis(
617 Axis::default()
618 .title(Span::styled("Time", Style::new().gray()))
619 .style(Style::new().gray())
620 .bounds([x_min, x_max])
621 .labels(vec![Span::raw(time_label), Span::raw("now")]),
622 )
623 .y_axis(
624 Axis::default()
625 .title(Span::styled(scale_label, Style::new().gray()))
626 .style(Style::new().gray())
627 .bounds([y_min, y_max])
628 .labels(vec![
629 Span::raw(y_label(y_min)),
630 Span::raw(y_label(mid_y)),
631 Span::raw(y_label(y_max)),
632 ]),
633 );
634
635 f.render_widget(chart, area);
636}
637
638pub(crate) fn is_stablecoin_price(price: f64) -> bool {
640 (0.95..=1.05).contains(&price)
641}
642
643pub(crate) fn format_price_usd(price: f64) -> String {
646 if price >= 1000.0 {
647 format!("${:.2}", price)
648 } else if is_stablecoin_price(price) {
649 format!("${:.6}", price)
651 } else if price >= 1.0 {
652 format!("${:.4}", price)
653 } else if price >= 0.01 {
654 format!("${:.6}", price)
655 } else if price >= 0.0001 {
656 format!("${:.8}", price)
657 } else {
658 format!("${:.10}", price)
659 }
660}
661
662pub(crate) fn render_candlestick_chart(f: &mut Frame, area: Rect, state: &MonitorState) {
664 let candles = state.get_ohlc_candles();
665
666 if candles.is_empty() {
667 let empty = Paragraph::new("No candle data (waiting for more data points)").block(
668 Block::default()
669 .title(" Candlestick (USD) ")
670 .borders(Borders::ALL),
671 );
672 f.render_widget(empty, area);
673 return;
674 }
675
676 let current_price = state.current_price;
678 let first_candle = candles.first().unwrap();
679 let last_candle = candles.last().unwrap();
680 let price_change = last_candle.close - first_candle.open;
681 let price_change_pct = if first_candle.open > 0.0 {
682 (price_change / first_candle.open) * 100.0
683 } else {
684 0.0
685 };
686
687 let pal = state.palette();
688 let is_price_up = price_change >= 0.0;
689 let trend_color = if is_price_up { pal.up } else { pal.down };
690 let trend_symbol = if is_price_up { "▲" } else { "▼" };
691
692 let price_str = format_price_usd(current_price);
693 let change_str = format!(
694 "{}{:.2}%",
695 if is_price_up { "+" } else { "" },
696 price_change_pct
697 );
698
699 let (min_price, max_price) = candles.iter().fold((f64::MAX, f64::MIN), |(min, max), c| {
701 (min.min(c.low), max.max(c.high))
702 });
703
704 let price_range = max_price - min_price;
705 let (y_min, y_max) = if price_range < 0.0001 {
706 let padding = min_price * 0.001;
707 (min_price - padding, max_price + padding)
708 } else {
709 (min_price - price_range * 0.1, max_price + price_range * 0.1)
710 };
711
712 let x_min = candles.first().map(|c| c.timestamp).unwrap_or(0.0);
713 let x_max = candles.last().map(|c| c.timestamp).unwrap_or(1.0);
714 let x_range = x_max - x_min;
715 let x_max = if x_range < 0.001 {
716 x_min + 1.0
717 } else {
718 x_max + x_range * 0.05
719 };
720
721 let candle_count = candles.len() as f64;
723 let candle_spacing = x_range / candle_count.max(1.0);
724 let candle_width = candle_spacing * 0.6; let title = Line::from(vec![
727 Span::raw(" ⬡ "),
728 Span::styled(
729 format!("{} {} ", price_str, trend_symbol),
730 Style::new().fg(trend_color).bold(),
731 ),
732 Span::styled(format!("({}) ", change_str), Style::new().fg(trend_color)),
733 Span::styled(
734 format!("│{}│ ", state.time_period.label()),
735 Style::new().gray(),
736 ),
737 Span::styled("⊞Candles ", Style::new().magenta()),
738 ]);
739
740 let apply_scale = |price: f64| -> f64 {
742 match state.scale_mode {
743 ScaleMode::Linear => price,
744 ScaleMode::Log => {
745 if price > 0.0 {
746 price.ln()
747 } else {
748 0.0
749 }
750 }
751 }
752 };
753 let scaled_y_min = apply_scale(y_min);
754 let scaled_y_max = apply_scale(y_max);
755 let scaled_price_range = scaled_y_max - scaled_y_min;
756
757 let candles_clone = candles.clone();
759 let is_log = matches!(state.scale_mode, ScaleMode::Log);
760 let pal_up = pal.up;
761 let pal_down = pal.down;
762
763 let canvas = Canvas::default()
764 .block(
765 Block::default()
766 .title(title)
767 .borders(Borders::ALL)
768 .border_style(Style::new().fg(trend_color)),
769 )
770 .x_bounds([x_min - candle_spacing, x_max])
771 .y_bounds([scaled_y_min, scaled_y_max])
772 .paint(move |ctx| {
773 let scale_fn = |p: f64| -> f64 { if is_log && p > 0.0 { p.ln() } else { p } };
774 for candle in &candles_clone {
775 let color = if candle.is_bullish { pal_up } else { pal_down };
776
777 ctx.draw(&CanvasLine {
779 x1: candle.timestamp,
780 y1: scale_fn(candle.low),
781 x2: candle.timestamp,
782 y2: scale_fn(candle.high),
783 color,
784 });
785
786 let body_top = scale_fn(candle.open.max(candle.close));
788 let body_bottom = scale_fn(candle.open.min(candle.close));
789 let body_height = (body_top - body_bottom).max(scaled_price_range * 0.002);
790
791 ctx.draw(&Rectangle {
792 x: candle.timestamp - candle_width / 2.0,
793 y: body_bottom,
794 width: candle_width,
795 height: body_height,
796 color,
797 });
798 }
799 });
800
801 f.render_widget(canvas, area);
802}
803
804pub(crate) fn render_volume_profile_chart(f: &mut Frame, area: Rect, state: &MonitorState) {
810 let pal = state.palette();
811 let (price_data, _) = state.get_price_data_for_period();
812 let (volume_data, _) = state.get_volume_data_for_period();
813
814 if price_data.len() < 2 || volume_data.is_empty() {
815 let block = Block::default()
816 .title(" ◨ Volume Profile (collecting data...) ")
817 .borders(Borders::ALL)
818 .border_style(Style::new().fg(Color::DarkGray));
819 f.render_widget(block, area);
820 return;
821 }
822
823 let min_price = price_data.iter().map(|(_, p)| *p).fold(f64::MAX, f64::min);
825 let max_price = price_data.iter().map(|(_, p)| *p).fold(f64::MIN, f64::max);
826
827 if (max_price - min_price).abs() < f64::EPSILON {
828 let block = Block::default()
829 .title(" ◨ Volume Profile (no price range) ")
830 .borders(Borders::ALL)
831 .border_style(Style::new().fg(Color::DarkGray));
832 f.render_widget(block, area);
833 return;
834 }
835
836 let inner_height = area.height.saturating_sub(2) as usize;
838 let num_buckets = inner_height.clamp(1, 30);
839 let bucket_size = (max_price - min_price) / num_buckets as f64;
840
841 let mut bucket_volumes = vec![0.0_f64; num_buckets];
843
844 let vol_iter: Vec<f64> = volume_data.iter().map(|(_, v)| *v).collect();
846 for (i, (_, price)) in price_data.iter().enumerate() {
847 let bucket_idx =
848 (((price - min_price) / bucket_size).floor() as usize).min(num_buckets - 1);
849 let vol_contribution = if i < vol_iter.len() {
851 if i > 0 {
853 (vol_iter[i] - vol_iter[i - 1]).abs().max(1.0)
854 } else {
855 1.0
856 }
857 } else {
858 1.0
859 };
860 bucket_volumes[bucket_idx] += vol_contribution;
861 }
862
863 let max_vol = bucket_volumes
864 .iter()
865 .cloned()
866 .fold(0.0_f64, f64::max)
867 .max(1.0);
868
869 let current_bucket = (((state.current_price - min_price) / bucket_size).floor() as usize)
871 .min(num_buckets.saturating_sub(1));
872
873 let inner_width = area.width.saturating_sub(12) as usize; let lines: Vec<Line> = (0..num_buckets)
877 .rev() .map(|i| {
879 let bar_width = ((bucket_volumes[i] / max_vol) * inner_width as f64).round() as usize;
880 let price_mid = min_price + (i as f64 + 0.5) * bucket_size;
881 let label = if price_mid >= 1.0 {
882 format!("{:>8.2}", price_mid)
883 } else {
884 format!("{:>8.6}", price_mid)
885 };
886 let bar_str = "█".repeat(bar_width);
887 let style = if i == current_bucket {
888 Style::new().fg(pal.highlight).bold()
889 } else {
890 Style::new().fg(pal.sparkline)
891 };
892 Line::from(vec![
893 Span::styled(label, Style::new().dark_gray()),
894 Span::raw(" "),
895 Span::styled(bar_str, style),
896 ])
897 })
898 .collect();
899
900 let block = Block::default()
901 .title(" ◨ Volume Profile (accuracy improves over time) ")
902 .borders(Borders::ALL)
903 .border_style(Style::new().fg(pal.sparkline));
904
905 let paragraph = Paragraph::new(lines).block(block);
906 f.render_widget(paragraph, area);
907}
908
909pub(crate) fn render_volume_chart(f: &mut Frame, area: Rect, state: &MonitorState) {
911 let pal = state.palette();
912 let (data, is_real) = state.get_volume_data_for_period();
914
915 if data.is_empty() {
916 let empty = Paragraph::new("No volume data")
917 .block(Block::default().title(" 24h Volume ").borders(Borders::ALL));
918 f.render_widget(empty, area);
919 return;
920 }
921
922 let current_volume = state.volume_24h;
924 let volume_str = scope::display::format_usd(current_volume);
925
926 let has_synthetic = is_real.iter().any(|r| !r);
928 let has_real = is_real.iter().any(|r| *r);
929
930 let data_indicator = if has_synthetic && has_real {
932 "[◆ est │ ● live]"
933 } else if has_synthetic {
934 "[◆ estimated]"
935 } else {
936 "[● live]"
937 };
938
939 let chart_title = Line::from(vec![
940 Span::raw(" ▣ "),
941 Span::styled(
942 format!("24h Vol: {} ", volume_str),
943 Style::new().fg(pal.volume_bar).bold(),
944 ),
945 Span::styled(
946 format!("│{}│ ", state.time_period.label()),
947 Style::new().gray(),
948 ),
949 Span::styled(data_indicator, Style::new().dark_gray()),
950 ]);
951
952 let inner_width = area.width.saturating_sub(2) as usize; let max_bars = (inner_width / 3).max(1).min(data.len());
956 let bucket_size = data.len().div_ceil(max_bars);
957
958 let bars: Vec<Bar> = data
959 .chunks(bucket_size)
960 .zip(is_real.chunks(bucket_size))
961 .enumerate()
962 .map(|(i, (chunk, real_chunk))| {
963 let avg_vol = chunk.iter().map(|(_, v)| v).sum::<f64>() / chunk.len() as f64;
964 let any_real = real_chunk.iter().any(|r| *r);
965 let bar_color = if any_real {
966 pal.volume_bar
967 } else {
968 pal.neutral
969 };
970 let label = if i == 0 || i == max_bars.saturating_sub(1) || i == max_bars / 2 {
972 format_number(avg_vol)
973 } else {
974 String::new()
975 };
976 Bar::default()
977 .value(avg_vol as u64)
978 .label(Line::from(label))
979 .style(Style::new().fg(bar_color))
980 })
981 .collect();
982
983 let bar_width = if !bars.is_empty() {
985 let total_bars = bars.len() as u16;
986 ((inner_width as u16).saturating_sub(total_bars.saturating_sub(1))) / total_bars
988 } else {
989 1
990 }
991 .max(1);
992
993 let barchart = BarChart::default()
994 .data(BarGroup::default().bars(&bars))
995 .block(
996 Block::default()
997 .title(chart_title)
998 .borders(Borders::ALL)
999 .border_style(Style::new().blue()),
1000 )
1001 .bar_width(bar_width)
1002 .bar_gap(1)
1003 .value_style(Style::new().dark_gray());
1004
1005 f.render_widget(barchart, area);
1006}
1007
1008pub(crate) fn render_buy_sell_gauge(f: &mut Frame, area: Rect, state: &mut MonitorState) {
1010 let pal = state.palette();
1011 let ratio = state.buy_ratio();
1013 let border_color = if ratio > 0.5 { pal.up } else { pal.down };
1014
1015 let block = Block::default()
1016 .title(" ◐ Buy/Sell Ratio (24h) ")
1017 .borders(Borders::ALL)
1018 .border_style(Style::new().fg(border_color));
1019
1020 let inner = block.inner(area);
1021 f.render_widget(block, area);
1022
1023 if inner.width > 0 && inner.height > 0 {
1024 let buy_width = ((ratio * inner.width as f64).round() as u16).min(inner.width);
1025 let sell_width = inner.width.saturating_sub(buy_width);
1026
1027 let buy_indicator = if ratio > 0.5 { "▶" } else { "▷" };
1028 let sell_indicator = if ratio < 0.5 { "◀" } else { "◁" };
1029 let label = format!(
1030 "{}Buys: {} │ Sells: {}{} ({:.1}%)",
1031 buy_indicator,
1032 state.buys_24h,
1033 state.sells_24h,
1034 sell_indicator,
1035 ratio * 100.0
1036 );
1037
1038 let buy_bar = "█".repeat(buy_width as usize);
1039 let sell_bar = "█".repeat(sell_width as usize);
1040 let bar_line = Line::from(vec![
1041 Span::styled(buy_bar, Style::new().fg(pal.up)),
1042 Span::styled(sell_bar, Style::new().fg(pal.down)),
1043 ]);
1044 f.render_widget(Paragraph::new(bar_line), inner);
1045
1046 let label_len = label.len() as u16;
1048 if label_len <= inner.width {
1049 let x_offset = (inner.width.saturating_sub(label_len)) / 2;
1050 let label_area = Rect::new(inner.x + x_offset, inner.y, label_len, 1);
1051 let label_widget =
1052 Paragraph::new(Span::styled(label, Style::new().fg(Color::White).bold()));
1053 f.render_widget(label_widget, label_area);
1054 }
1055 }
1056}
1057
1058pub(crate) fn render_activity_feed(f: &mut Frame, area: Rect, state: &mut MonitorState) {
1060 let log_len = state.log_messages.len();
1061 let log_title = if log_len > 0 {
1062 let selected = state.log_list_state.selected().unwrap_or(0);
1063 format!(" ◷ Activity Log [{}/{}] ", selected + 1, log_len)
1064 } else {
1065 " ◷ Activity Log ".to_string()
1066 };
1067
1068 let items: Vec<ListItem> = state
1069 .log_messages
1070 .iter()
1071 .rev()
1072 .map(|msg| ListItem::new(msg.as_str()).style(Style::new().gray()))
1073 .collect();
1074
1075 let log_list = List::new(items)
1076 .block(
1077 Block::default()
1078 .title(log_title)
1079 .borders(Borders::ALL)
1080 .border_style(Style::new().dark_gray()),
1081 )
1082 .highlight_style(Style::new().white().bold())
1083 .highlight_symbol("▸ ");
1084
1085 f.render_stateful_widget(log_list, area, &mut state.log_list_state);
1086}
1087
1088pub(crate) fn render_alert_overlay(f: &mut Frame, area: Rect, state: &MonitorState) {
1090 if state.active_alerts.is_empty() {
1091 return;
1092 }
1093
1094 let is_flash_on = state
1095 .alert_flash_until
1096 .map(|deadline| {
1097 if Instant::now() < deadline {
1098 (Instant::now().elapsed().subsec_millis() / 500).is_multiple_of(2)
1100 } else {
1101 false
1102 }
1103 })
1104 .unwrap_or(false);
1105
1106 let border_color = if is_flash_on {
1107 Color::Red
1108 } else {
1109 Color::Yellow
1110 };
1111
1112 let alert_lines: Vec<Line> = state
1113 .active_alerts
1114 .iter()
1115 .map(|a| Line::from(Span::styled(&a.message, Style::new().fg(Color::Red).bold())))
1116 .collect();
1117
1118 let alert_widget = Paragraph::new(alert_lines).block(
1119 Block::default()
1120 .title(" ⚠ ALERTS ")
1121 .borders(Borders::ALL)
1122 .border_style(Style::new().fg(border_color).bold()),
1123 );
1124
1125 f.render_widget(alert_widget, area);
1126}
1127
1128pub(crate) fn render_liquidity_depth(f: &mut Frame, area: Rect, state: &MonitorState) {
1130 let pal = state.palette();
1131
1132 if state.liquidity_pairs.is_empty() {
1133 let block = Block::default()
1134 .title(" ◫ Liquidity Depth (no data) ")
1135 .borders(Borders::ALL)
1136 .border_style(Style::new().fg(Color::DarkGray));
1137 f.render_widget(block, area);
1138 return;
1139 }
1140
1141 let max_liquidity = state
1142 .liquidity_pairs
1143 .iter()
1144 .map(|(_, liq)| *liq)
1145 .fold(0.0_f64, f64::max)
1146 .max(1.0);
1147
1148 let inner_width = area.width.saturating_sub(2) as usize;
1149
1150 let lines: Vec<Line> = state
1151 .liquidity_pairs
1152 .iter()
1153 .take(area.height.saturating_sub(2) as usize) .map(|(name, liq)| {
1155 let bar_width = ((liq / max_liquidity) * inner_width as f64 * 0.6).round() as usize;
1156 let bar_str = "█".repeat(bar_width);
1157 let label = format!(" {} {}", scope::display::format_usd(*liq), name);
1158 Line::from(vec![
1159 Span::styled(bar_str, Style::new().fg(pal.volume_bar)),
1160 Span::styled(label, Style::new().fg(pal.neutral)),
1161 ])
1162 })
1163 .collect();
1164
1165 let block = Block::default()
1166 .title(format!(
1167 " ◫ Liquidity Depth ({} pairs) ",
1168 state.liquidity_pairs.len()
1169 ))
1170 .borders(Borders::ALL)
1171 .border_style(Style::new().fg(pal.border));
1172
1173 let paragraph = Paragraph::new(lines).block(block);
1174 f.render_widget(paragraph, area);
1175}
1176
1177pub(crate) fn render_metrics_panel(f: &mut Frame, area: Rect, state: &MonitorState) {
1179 let pal = state.palette();
1180 let chunks = Layout::default()
1182 .direction(Direction::Vertical)
1183 .constraints([Constraint::Length(3), Constraint::Min(0)])
1184 .split(area);
1185
1186 let sparkline_data: Vec<u64> = {
1188 let points: Vec<f64> = state.price_history.iter().map(|dp| dp.value).collect();
1190 if points.len() < 2 {
1191 vec![0; chunks[0].width.saturating_sub(2) as usize]
1192 } else {
1193 let min_p = points.iter().cloned().fold(f64::MAX, f64::min);
1194 let max_p = points.iter().cloned().fold(f64::MIN, f64::max);
1195 let range = (max_p - min_p).max(0.0001);
1196 points
1197 .iter()
1198 .map(|p| (((*p - min_p) / range) * 100.0) as u64)
1199 .collect()
1200 }
1201 };
1202
1203 let trend_color = if state.price_change_5m >= 0.0 {
1204 pal.up
1205 } else {
1206 pal.down
1207 };
1208
1209 let sparkline = Sparkline::default()
1210 .block(
1211 Block::default()
1212 .title(" ◉ Price Trend ")
1213 .borders(Borders::ALL)
1214 .border_style(Style::new().fg(pal.sparkline)),
1215 )
1216 .data(&sparkline_data)
1217 .style(Style::new().fg(trend_color));
1218
1219 f.render_widget(sparkline, chunks[0]);
1220
1221 let change_5m_str = if state.price_change_5m.abs() < 0.0001 {
1223 "0.00%".to_string()
1224 } else {
1225 format!("{:+.4}%", state.price_change_5m)
1226 };
1227 let change_5m_color = if state.price_change_5m > 0.0 {
1228 pal.up
1229 } else if state.price_change_5m < 0.0 {
1230 pal.down
1231 } else {
1232 pal.neutral
1233 };
1234
1235 let now_ts = chrono::Utc::now().timestamp() as f64;
1236 let secs_since_change = (now_ts - state.last_price_change_at).max(0.0) as u64;
1237 let last_change_str = if secs_since_change < 60 {
1238 format!("{}s ago", secs_since_change)
1239 } else if secs_since_change < 3600 {
1240 format!("{}m ago", secs_since_change / 60)
1241 } else {
1242 format!("{}h ago", secs_since_change / 3600)
1243 };
1244 let last_change_color = if secs_since_change < 60 {
1245 pal.up
1246 } else {
1247 pal.highlight
1248 };
1249
1250 let change_24h_str = format!(
1251 "{}{:.2}%",
1252 if state.price_change_24h >= 0.0 {
1253 "+"
1254 } else {
1255 ""
1256 },
1257 state.price_change_24h
1258 );
1259
1260 let market_cap_str = state
1261 .market_cap
1262 .map(scope::display::format_usd)
1263 .unwrap_or_else(|| "N/A".to_string());
1264
1265 let mut rows = vec![
1266 Row::new(vec![
1267 Span::styled("Price", Style::new().gray()),
1268 Span::styled(format_price_usd(state.current_price), Style::new().bold()),
1269 ]),
1270 Row::new(vec![
1271 Span::styled("5m Chg", Style::new().gray()),
1272 Span::styled(change_5m_str, Style::new().fg(change_5m_color)),
1273 ]),
1274 Row::new(vec![
1275 Span::styled("Last Δ", Style::new().gray()),
1276 Span::styled(last_change_str, Style::new().fg(last_change_color)),
1277 ]),
1278 Row::new(vec![
1279 Span::styled("24h Chg", Style::new().gray()),
1280 Span::raw(change_24h_str),
1281 ]),
1282 Row::new(vec![
1283 Span::styled("Liq", Style::new().gray()),
1284 Span::raw(scope::display::format_usd(state.liquidity_usd)),
1285 ]),
1286 Row::new(vec![
1287 Span::styled("Vol 24h", Style::new().gray()),
1288 Span::raw(scope::display::format_usd(state.volume_24h)),
1289 ]),
1290 Row::new(vec![
1291 Span::styled("Mkt Cap", Style::new().gray()),
1292 Span::raw(market_cap_str),
1293 ]),
1294 Row::new(vec![
1295 Span::styled("Buys", Style::new().gray()),
1296 Span::styled(format!("{}", state.buys_24h), Style::new().fg(pal.up)),
1297 ]),
1298 Row::new(vec![
1299 Span::styled("Sells", Style::new().gray()),
1300 Span::styled(format!("{}", state.sells_24h), Style::new().fg(pal.down)),
1301 ]),
1302 ];
1303
1304 if state.widgets.holder_count
1306 && let Some(count) = state.holder_count
1307 {
1308 rows.push(Row::new(vec![
1309 Span::styled("Holders", Style::new().gray()),
1310 Span::styled(format_number(count as f64), Style::new().fg(pal.highlight)),
1311 ]));
1312 }
1313
1314 let table = Table::new(rows, [Constraint::Length(8), Constraint::Min(10)]).block(
1315 Block::default()
1316 .title(" ◉ Key Metrics ")
1317 .borders(Borders::ALL)
1318 .border_style(Style::new().magenta()),
1319 );
1320
1321 f.render_widget(table, chunks[1]);
1322}
1323
1324pub(crate) fn render_order_book_panel(f: &mut Frame, area: Rect, state: &MonitorState) {
1328 let pal = state.palette();
1329
1330 let book = match &state.order_book {
1331 Some(b) => b,
1332 None => {
1333 let block = Block::default()
1334 .title(" ◈ Order Book (no data) ")
1335 .borders(Borders::ALL)
1336 .border_style(Style::new().fg(Color::DarkGray));
1337 f.render_widget(block, area);
1338 return;
1339 }
1340 };
1341
1342 let inner_height = area.height.saturating_sub(2) as usize; if inner_height < 3 {
1344 return;
1345 }
1346
1347 let ask_rows = (inner_height.saturating_sub(1)) / 2;
1349 let bid_rows = inner_height.saturating_sub(ask_rows).saturating_sub(1);
1350
1351 let max_qty = book
1353 .asks
1354 .iter()
1355 .chain(book.bids.iter())
1356 .map(|l| l.quantity)
1357 .fold(0.0_f64, f64::max)
1358 .max(0.001);
1359
1360 let inner_width = area.width.saturating_sub(2) as usize;
1361 let bar_width_max = (inner_width as f64 * 0.3).round() as usize;
1363
1364 let mut lines: Vec<Line> = Vec::with_capacity(inner_height);
1365
1366 let visible_asks: Vec<_> = book.asks.iter().take(ask_rows).collect();
1368 for _ in 0..ask_rows.saturating_sub(visible_asks.len()) {
1370 lines.push(Line::from(""));
1371 }
1372 for level in visible_asks.iter().rev() {
1373 let bar_len = ((level.quantity / max_qty) * bar_width_max as f64).round() as usize;
1374 let bar = "█".repeat(bar_len);
1375 let price_str = format!("{:.6}", level.price);
1376 let qty_str = format_number(level.quantity);
1377 let val_str = format_number(level.value());
1378 let padding = inner_width
1379 .saturating_sub(bar_len)
1380 .saturating_sub(price_str.len())
1381 .saturating_sub(qty_str.len())
1382 .saturating_sub(val_str.len())
1383 .saturating_sub(4); lines.push(Line::from(vec![
1385 Span::styled(bar, Style::new().fg(pal.down).dim()),
1386 Span::raw(" "),
1387 Span::styled(price_str, Style::new().fg(pal.down)),
1388 Span::raw(" ".repeat(padding.max(1))),
1389 Span::styled(qty_str, Style::new().fg(pal.neutral)),
1390 Span::raw(" "),
1391 Span::styled(val_str, Style::new().fg(Color::DarkGray)),
1392 ]));
1393 }
1394
1395 let spread = book
1397 .best_ask()
1398 .zip(book.best_bid())
1399 .map(|(ask, bid)| {
1400 let s = ask - bid;
1401 let pct = if bid > 0.0 { (s / bid) * 100.0 } else { 0.0 };
1402 format!(" Spread: {:.6} ({:.3}%)", s, pct)
1403 })
1404 .unwrap_or_else(|| " Spread: --".to_string());
1405 lines.push(Line::from(Span::styled(
1406 spread,
1407 Style::new().fg(Color::Yellow).bold(),
1408 )));
1409
1410 for level in book.bids.iter().take(bid_rows) {
1412 let bar_len = ((level.quantity / max_qty) * bar_width_max as f64).round() as usize;
1413 let bar = "█".repeat(bar_len);
1414 let price_str = format!("{:.6}", level.price);
1415 let qty_str = format_number(level.quantity);
1416 let val_str = format_number(level.value());
1417 let padding = inner_width
1418 .saturating_sub(bar_len)
1419 .saturating_sub(price_str.len())
1420 .saturating_sub(qty_str.len())
1421 .saturating_sub(val_str.len())
1422 .saturating_sub(4);
1423 lines.push(Line::from(vec![
1424 Span::styled(bar, Style::new().fg(pal.up).dim()),
1425 Span::raw(" "),
1426 Span::styled(price_str, Style::new().fg(pal.up)),
1427 Span::raw(" ".repeat(padding.max(1))),
1428 Span::styled(qty_str, Style::new().fg(pal.neutral)),
1429 Span::raw(" "),
1430 Span::styled(val_str, Style::new().fg(Color::DarkGray)),
1431 ]));
1432 }
1433
1434 let ask_depth: f64 = book.asks.iter().map(|l| l.value()).sum();
1435 let bid_depth: f64 = book.bids.iter().map(|l| l.value()).sum();
1436 let title = format!(
1437 " ◈ {} │ Ask {} │ Bid {} ",
1438 book.pair,
1439 format_number(ask_depth),
1440 format_number(bid_depth),
1441 );
1442
1443 let block = Block::default()
1444 .title(title)
1445 .borders(Borders::ALL)
1446 .border_style(Style::new().fg(pal.border));
1447
1448 let paragraph = Paragraph::new(lines).block(block);
1449 f.render_widget(paragraph, area);
1450}
1451
1452pub(crate) fn render_recent_trades_panel(f: &mut Frame, area: Rect, state: &MonitorState) {
1457 let pal = state.palette();
1458
1459 if state.recent_trades.is_empty() {
1460 let block = Block::default()
1461 .title(" ◈ Recent Trades (no data) ")
1462 .borders(Borders::ALL)
1463 .border_style(Style::new().fg(Color::DarkGray));
1464 f.render_widget(block, area);
1465 return;
1466 }
1467
1468 let inner_height = area.height.saturating_sub(2) as usize;
1469 let inner_width = area.width.saturating_sub(2) as usize;
1470
1471 let time_width = 8; let side_width = 4; let price_width = inner_width
1475 .saturating_sub(time_width)
1476 .saturating_sub(side_width)
1477 .saturating_sub(3) / 2;
1479 let qty_width = inner_width
1480 .saturating_sub(time_width)
1481 .saturating_sub(side_width)
1482 .saturating_sub(price_width)
1483 .saturating_sub(3);
1484
1485 let mut lines: Vec<Line> = Vec::with_capacity(inner_height);
1486
1487 lines.push(Line::from(vec![
1489 Span::styled(
1490 format!("{:<time_width$}", "Time"),
1491 Style::new().fg(Color::DarkGray).bold(),
1492 ),
1493 Span::raw(" "),
1494 Span::styled(
1495 format!("{:<side_width$}", "Side"),
1496 Style::new().fg(Color::DarkGray).bold(),
1497 ),
1498 Span::raw(" "),
1499 Span::styled(
1500 format!("{:>price_width$}", "Price"),
1501 Style::new().fg(Color::DarkGray).bold(),
1502 ),
1503 Span::raw(" "),
1504 Span::styled(
1505 format!("{:>qty_width$}", "Qty"),
1506 Style::new().fg(Color::DarkGray).bold(),
1507 ),
1508 ]));
1509
1510 let visible_count = inner_height.saturating_sub(1); for trade in state.recent_trades.iter().rev().take(visible_count) {
1513 let (side_str, side_color) = match trade.side {
1514 TradeSide::Buy => ("BUY ", pal.up),
1515 TradeSide::Sell => ("SELL", pal.down),
1516 };
1517
1518 let secs = (trade.timestamp_ms / 1000) as i64;
1520 let hours = (secs / 3600) % 24;
1521 let mins = (secs / 60) % 60;
1522 let sec = secs % 60;
1523 let time_str = format!("{:02}:{:02}:{:02}", hours, mins, sec);
1524
1525 let price_str = if trade.price >= 1000.0 {
1526 format!("{:.2}", trade.price)
1527 } else if trade.price >= 1.0 {
1528 format!("{:.4}", trade.price)
1529 } else {
1530 format!("{:.6}", trade.price)
1531 };
1532
1533 let qty_str = format_number(trade.quantity);
1534
1535 lines.push(Line::from(vec![
1536 Span::styled(
1537 format!("{:<time_width$}", time_str),
1538 Style::new().fg(Color::DarkGray),
1539 ),
1540 Span::raw(" "),
1541 Span::styled(
1542 format!("{:<side_width$}", side_str),
1543 Style::new().fg(side_color),
1544 ),
1545 Span::raw(" "),
1546 Span::styled(
1547 format!("{:>price_width$}", price_str),
1548 Style::new().fg(side_color),
1549 ),
1550 Span::raw(" "),
1551 Span::styled(
1552 format!("{:>qty_width$}", qty_str),
1553 Style::new().fg(pal.neutral),
1554 ),
1555 ]));
1556 }
1557
1558 let title = format!(" ◈ Recent Trades ({}) ", state.recent_trades.len());
1559 let block = Block::default()
1560 .title(title)
1561 .borders(Borders::ALL)
1562 .border_style(Style::new().fg(pal.border));
1563
1564 let paragraph = Paragraph::new(lines).block(block);
1565 f.render_widget(paragraph, area);
1566}
1567
1568pub(crate) fn render_market_info_panel(f: &mut Frame, area: Rect, state: &MonitorState) {
1573 let pal = state.palette();
1574
1575 let cols = Layout::default()
1577 .direction(Direction::Horizontal)
1578 .constraints([Constraint::Percentage(60), Constraint::Percentage(40)])
1579 .split(area);
1580
1581 {
1583 let header = Row::new(vec!["DEX / Pair", "Volume 24h", "Liquidity", "Δ 24h"])
1584 .style(Style::new().fg(Color::Cyan).bold())
1585 .bottom_margin(0);
1586
1587 let rows: Vec<Row> = state
1588 .dex_pairs
1589 .iter()
1590 .take(cols[0].height.saturating_sub(3) as usize) .map(|p| {
1592 let pair_label = format!("{}/{}", p.base_token, p.quote_token);
1593 let dex_and_pair = format!("{} {}", p.dex_name, pair_label);
1594 let vol = format_number(p.volume_24h);
1595 let liq = format_number(p.liquidity_usd);
1596 let change_str = format!("{:+.1}%", p.price_change_24h);
1597 let change_color = if p.price_change_24h >= 0.0 {
1598 pal.up
1599 } else {
1600 pal.down
1601 };
1602 Row::new(vec![
1603 ratatui::text::Text::from(dex_and_pair),
1604 ratatui::text::Text::styled(vol, Style::new().fg(pal.neutral)),
1605 ratatui::text::Text::styled(liq, Style::new().fg(pal.volume_bar)),
1606 ratatui::text::Text::styled(change_str, Style::new().fg(change_color)),
1607 ])
1608 })
1609 .collect();
1610
1611 let widths = [
1612 Constraint::Percentage(40),
1613 Constraint::Percentage(22),
1614 Constraint::Percentage(22),
1615 Constraint::Percentage(16),
1616 ];
1617
1618 let table = Table::new(rows, widths).header(header).block(
1619 Block::default()
1620 .title(format!(" ◫ Trading Pairs ({}) ", state.dex_pairs.len()))
1621 .borders(Borders::ALL)
1622 .border_style(Style::new().fg(pal.border)),
1623 );
1624
1625 f.render_widget(table, cols[0]);
1626 }
1627
1628 {
1630 let mut info_lines: Vec<Line> = Vec::new();
1631
1632 let price_color = if state.price_change_24h >= 0.0 {
1634 pal.up
1635 } else {
1636 pal.down
1637 };
1638 info_lines.push(Line::from(vec![
1639 Span::styled(" Price ", Style::new().fg(Color::DarkGray)),
1640 Span::styled(
1641 format!("${:.6}", state.current_price),
1642 Style::new().fg(Color::White).bold(),
1643 ),
1644 ]));
1645
1646 let changes = [
1648 ("5m", state.price_change_5m),
1649 ("1h", state.price_change_1h),
1650 ("6h", state.price_change_6h),
1651 ("24h", state.price_change_24h),
1652 ];
1653 let change_spans: Vec<Span> = changes
1654 .iter()
1655 .flat_map(|(label, val)| {
1656 let color = if *val >= 0.0 { pal.up } else { pal.down };
1657 vec![
1658 Span::styled(format!(" {}: ", label), Style::new().fg(Color::DarkGray)),
1659 Span::styled(format!("{:+.2}%", val), Style::new().fg(color)),
1660 ]
1661 })
1662 .collect();
1663 info_lines.push(Line::from(change_spans));
1664
1665 info_lines.push(Line::from(""));
1666
1667 info_lines.push(Line::from(vec![
1669 Span::styled(" Vol 24h ", Style::new().fg(Color::DarkGray)),
1670 Span::styled(
1671 format!("${}", format_number(state.volume_24h)),
1672 Style::new().fg(pal.neutral),
1673 ),
1674 ]));
1675 info_lines.push(Line::from(vec![
1676 Span::styled(" Liq ", Style::new().fg(Color::DarkGray)),
1677 Span::styled(
1678 format!("${}", format_number(state.liquidity_usd)),
1679 Style::new().fg(pal.volume_bar),
1680 ),
1681 ]));
1682
1683 if let Some(mc) = state.market_cap {
1685 info_lines.push(Line::from(vec![
1686 Span::styled(" MCap ", Style::new().fg(Color::DarkGray)),
1687 Span::styled(
1688 format!("${}", format_number(mc)),
1689 Style::new().fg(pal.neutral),
1690 ),
1691 ]));
1692 }
1693 if let Some(fdv) = state.fdv {
1694 info_lines.push(Line::from(vec![
1695 Span::styled(" FDV ", Style::new().fg(Color::DarkGray)),
1696 Span::styled(
1697 format!("${}", format_number(fdv)),
1698 Style::new().fg(pal.neutral),
1699 ),
1700 ]));
1701 }
1702
1703 info_lines.push(Line::from(""));
1705 let total_txs = state.buys_24h + state.sells_24h;
1706 let buy_pct = if total_txs > 0 {
1707 (state.buys_24h as f64 / total_txs as f64) * 100.0
1708 } else {
1709 50.0
1710 };
1711 info_lines.push(Line::from(vec![
1712 Span::styled(" Buys ", Style::new().fg(Color::DarkGray)),
1713 Span::styled(
1714 format!("{} ({:.0}%)", state.buys_24h, buy_pct),
1715 Style::new().fg(pal.up),
1716 ),
1717 ]));
1718 info_lines.push(Line::from(vec![
1719 Span::styled(" Sells ", Style::new().fg(Color::DarkGray)),
1720 Span::styled(
1721 format!("{} ({:.0}%)", state.sells_24h, 100.0 - buy_pct),
1722 Style::new().fg(pal.down),
1723 ),
1724 ]));
1725
1726 if let Some(holders) = state.holder_count {
1728 info_lines.push(Line::from(vec![
1729 Span::styled(" Holders ", Style::new().fg(Color::DarkGray)),
1730 Span::styled(format_number(holders as f64), Style::new().fg(pal.neutral)),
1731 ]));
1732 }
1733
1734 if let Some(ts) = state.earliest_pair_created_at {
1736 let dt = chrono::DateTime::from_timestamp(ts, 0)
1737 .map(|d| d.format("%Y-%m-%d").to_string())
1738 .unwrap_or_else(|| "?".to_string());
1739 info_lines.push(Line::from(vec![
1740 Span::styled(" Listed ", Style::new().fg(Color::DarkGray)),
1741 Span::styled(dt, Style::new().fg(pal.neutral)),
1742 ]));
1743 }
1744
1745 if !state.websites.is_empty() || !state.socials.is_empty() {
1747 info_lines.push(Line::from(""));
1748 let mut link_spans = vec![Span::styled(" Links ", Style::new().fg(Color::DarkGray))];
1749 for (platform, _url) in &state.socials {
1750 link_spans.push(Span::styled(
1751 format!("[{}] ", platform),
1752 Style::new().fg(Color::Cyan),
1753 ));
1754 }
1755 for url in &state.websites {
1756 let domain = url
1758 .trim_start_matches("https://")
1759 .trim_start_matches("http://")
1760 .split('/')
1761 .next()
1762 .unwrap_or(url);
1763 link_spans.push(Span::styled(
1764 format!("[{}] ", domain),
1765 Style::new().fg(Color::Blue),
1766 ));
1767 }
1768 info_lines.push(Line::from(link_spans));
1769 }
1770
1771 let title = format!(" ◉ {} ({}) ", state.symbol, state.name);
1772 let block = Block::default()
1773 .title(title)
1774 .borders(Borders::ALL)
1775 .border_style(Style::new().fg(price_color));
1776
1777 let paragraph = Paragraph::new(info_lines).block(block);
1778 f.render_widget(paragraph, cols[1]);
1779 }
1780}
1781
1782pub(crate) fn render_footer(f: &mut Frame, area: Rect, state: &MonitorState) {
1784 let elapsed = state.last_update.elapsed().as_secs();
1785
1786 let now_ts = chrono::Utc::now().timestamp() as f64;
1788 let secs_since_change = (now_ts - state.last_price_change_at).max(0.0) as u64;
1789 let price_change_str = if secs_since_change < 60 {
1790 format!("{}s", secs_since_change)
1791 } else if secs_since_change < 3600 {
1792 format!("{}m", secs_since_change / 60)
1793 } else {
1794 format!("{}h", secs_since_change / 3600)
1795 };
1796
1797 let (synthetic_count, real_count) = state.data_stats();
1799 let memory_bytes = state.memory_usage();
1800 let memory_str = if memory_bytes >= 1024 * 1024 {
1801 format!("{:.1}MB", memory_bytes as f64 / (1024.0 * 1024.0))
1802 } else if memory_bytes >= 1024 {
1803 format!("{:.1}KB", memory_bytes as f64 / 1024.0)
1804 } else {
1805 format!("{}B", memory_bytes)
1806 };
1807
1808 let status = if let Some(ref err) = state.error_message {
1809 Span::styled(format!("⚠ {}", err), Style::new().red())
1810 } else if state.paused {
1811 Span::styled("⏸ PAUSED", Style::new().fg(Color::Yellow).bold())
1812 } else if state.is_auto_paused() {
1813 Span::styled("⏸ AUTO-PAUSED", Style::new().fg(Color::Cyan).bold())
1814 } else {
1815 Span::styled(
1816 format!(
1817 "↻ {}s │ Δ {} │ {} pts │ {}",
1818 elapsed,
1819 price_change_str,
1820 synthetic_count + real_count,
1821 memory_str
1822 ),
1823 Style::new().gray(),
1824 )
1825 };
1826
1827 let widget_hint = if state.widget_toggle_mode {
1828 Span::styled("W:1-5?", Style::new().fg(Color::Yellow).bold())
1829 } else {
1830 Span::styled("W", Style::new().fg(Color::Cyan).bold())
1831 };
1832
1833 let mut spans = vec![status, Span::raw(" ║ ")];
1834
1835 if state.export_active {
1837 spans.push(Span::styled("● REC ", Style::new().fg(Color::Red).bold()));
1838 }
1839
1840 spans.extend([
1841 Span::styled("Q", Style::new().red().bold()),
1842 Span::raw("uit "),
1843 Span::styled("R", Style::new().fg(Color::Green).bold()),
1844 Span::raw("efresh "),
1845 Span::styled("P", Style::new().fg(Color::Yellow).bold()),
1846 Span::raw("ause "),
1847 Span::styled("E", Style::new().fg(Color::LightRed).bold()),
1848 Span::raw("xport "),
1849 Span::styled("L", Style::new().fg(Color::Cyan).bold()),
1850 Span::raw(format!(":{} ", state.layout.label())),
1851 widget_hint,
1852 Span::raw("idget "),
1853 Span::styled("C", Style::new().fg(Color::LightBlue).bold()),
1854 Span::raw(format!("hart:{} ", state.chart_mode.label())),
1855 Span::styled("S", Style::new().fg(Color::LightGreen).bold()),
1856 Span::raw(format!("cale:{} ", state.scale_mode.label())),
1857 Span::styled("/", Style::new().fg(Color::LightRed).bold()),
1858 Span::raw(format!(":{} ", state.color_scheme.label())),
1859 Span::styled("T", Style::new().fg(Color::Magenta).bold()),
1860 Span::raw("ime "),
1861 ]);
1862
1863 let footer = Paragraph::new(Line::from(spans)).block(Block::default().borders(Borders::ALL));
1864
1865 f.render_widget(footer, area);
1866}
1867
1868pub(crate) fn format_number(n: f64) -> String {
1870 if n >= 1_000_000_000.0 {
1871 format!("{:.2}B", n / 1_000_000_000.0)
1872 } else if n >= 1_000_000.0 {
1873 format!("{:.2}M", n / 1_000_000.0)
1874 } else if n >= 1_000.0 {
1875 format!("{:.2}K", n / 1_000.0)
1876 } else {
1877 format!("{:.2}", n)
1878 }
1879}