Skip to main content

scope_cli/cli/monitor/
widgets.rs

1//! Layout and rendering functions for the monitor TUI.
2
3use 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
22/// Renders the UI.
23/// Computed layout areas for each widget. `None` means the widget is hidden.
24pub(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    /// Order book depth panel (Exchange layout).
31    pub(crate) order_book: Option<Rect>,
32    /// Market info panel with pair details (Exchange layout).
33    pub(crate) market_info: Option<Rect>,
34    /// Recent trade history (Exchange layout).
35    pub(crate) trade_history: Option<Rect>,
36}
37
38/// Dashboard layout: charts top, gauges middle, transaction feed bottom.
39///
40/// ```text
41/// ┌──────────────────────┬──────────────────────┐
42/// │  Price Chart (60%)   │  Volume Chart (40%)  │  55%
43/// ├──────────────────────┼──────────────────────┤
44/// │  Buy/Sell (50%)      │  Metrics (50%)       │  20%
45/// ├──────────────────────┴──────────────────────┤
46/// │  Activity Feed                              │  25%
47/// └─────────────────────────────────────────────┘
48/// ```
49pub(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
101/// Chart-focus layout: full-width candles with minimal stats overlay.
102///
103/// ```text
104/// ┌─────────────────────────────────────────────┐
105/// │                                             │
106/// │            Price Chart (~85%)                │
107/// │                                             │
108/// ├─────────────────────────────────────────────┤
109/// │  Metrics (compact stats overlay)     ~15%   │
110/// └─────────────────────────────────────────────┘
111/// ```
112pub(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,   // Hidden in chart-focus
125        buy_sell_gauge: None, // Hidden in chart-focus
126        metrics_panel: if widgets.metrics_panel {
127            Some(vertical[1])
128        } else {
129            None
130        },
131        activity_feed: None, // Hidden in chart-focus
132        order_book: None,
133        market_info: None,
134        trade_history: None,
135    }
136}
137
138/// Feed layout: transaction log takes priority, small price ticker on top.
139///
140/// ```text
141/// ┌──────────────────────┬──────────────────────┐
142/// │  Metrics (50%)       │  Buy/Sell (50%)      │  25%
143/// ├──────────────────────┴──────────────────────┤
144/// │                                             │
145/// │            Activity Feed (~75%)             │
146/// │                                             │
147/// └─────────────────────────────────────────────┘
148/// ```
149pub(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,  // Hidden in feed mode
162        volume_chart: None, // Hidden in feed mode
163        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
184/// Compact layout: price sparkline and metrics only for small terminals.
185///
186/// ```text
187/// ┌─────────────────────────────────────────────┐
188/// │  Metrics Panel (sparkline + stats)    100%  │
189/// └─────────────────────────────────────────────┘
190/// ```
191pub(crate) fn layout_compact(area: Rect, widgets: &WidgetVisibility) -> LayoutAreas {
192    LayoutAreas {
193        price_chart: None,    // Hidden in compact
194        volume_chart: None,   // Hidden in compact
195        buy_sell_gauge: None, // Hidden in compact
196        metrics_panel: if widgets.metrics_panel {
197            Some(area)
198        } else {
199            None
200        },
201        activity_feed: None, // Hidden in compact
202        order_book: None,
203        market_info: None,
204        trade_history: None,
205    }
206}
207
208/// Exchange layout: order book left, chart center, trade history right.
209///
210/// ```text
211/// ┌─────────────────┬──────────────────────────┬─────────────────┐
212/// │                 │                          │                 │
213/// │  Order Book     │   Price Chart (45%)      │  Trade History  │  60%
214/// │    (25%)        │                          │    (30%)        │
215/// │                 │                          │                 │
216/// ├─────────────────┼──────────────────────────┼─────────────────┤
217/// │  Buy/Sell (25%) │   Market Info (45%)      │  (continued)    │  40%
218/// │                 │                          │    (30%)        │
219/// └─────────────────┴──────────────────────────┴─────────────────┘
220/// ```
221pub(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
253/// Selects the best layout preset based on terminal size.
254pub(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
263/// Renders the UI, dispatching to the active layout preset.
264pub(crate) fn ui(f: &mut Frame, state: &mut MonitorState) {
265    // Responsive breakpoint: auto-select layout for terminal size
266    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    // Main layout: header, content, footer
274    let chunks = Layout::default()
275        .direction(Direction::Vertical)
276        .constraints([
277            Constraint::Length(4), // Header (token info + time period tabs)
278            Constraint::Min(10),   // Content
279            Constraint::Length(3), // Footer
280        ])
281        .split(f.area());
282
283    // Render header
284    render_header(f, chunks[0], state);
285
286    // Calculate content areas based on active layout preset
287    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    // Render each widget if its area is allocated
296    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        // Split metrics area to show liquidity depth if enabled and data available
311        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    // Exchange-specific widgets
326    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    // Render alert overlay on top of content area if alerts are active
337    if !state.active_alerts.is_empty() {
338        // Show alerts as a banner at the top of the content area (3 lines max)
339        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
350    render_footer(f, chunks[2], state);
351}
352
353/// Renders the header with token info and time period tabs.
354pub(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    // Use Unicode arrows for trend indication
362    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    // Split header area: top row for token info, bottom row for tabs
392    let header_chunks = Layout::default()
393        .direction(Direction::Vertical)
394        .constraints([Constraint::Length(3), Constraint::Length(1)])
395        .split(area);
396
397    // Token info with price
398    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    // Time period tabs
414    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
435/// Renders the price chart with visual differentiation between real and synthetic data.
436pub(crate) fn render_price_chart(f: &mut Frame, area: Rect, state: &MonitorState) {
437    // Get data filtered by selected time period
438    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    // Calculate price statistics
451    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    // Determine if price is up or down for coloring
461    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    // Format current price based on magnitude
467    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    // Build title with current price and change
479    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    // Calculate bounds
493    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    // Handle case where all prices are the same (e.g., stablecoins)
500    let price_range = max_price - min_price;
501    let (y_min, y_max) = if price_range < 0.0001 {
502        // Add ±0.1% padding when prices are flat
503        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    // Ensure x range is non-zero for proper rendering
512    let x_max = if (x_max - x_min).abs() < 0.001 {
513        x_min + 1.0
514    } else {
515        x_max
516    };
517
518    // Apply scale transformation (log or linear)
519    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    // Split data into synthetic and real datasets for visual differentiation
535    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    // Create reference line at first price (horizontal line for comparison)
550    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    // Reference line (starting price) - dashed gray
558    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    // Synthetic data shown with Dot marker and dimmed color
568    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    // Real data shown with Braille marker and trend color
580    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    // Create time labels based on period
592    let time_label = format!("-{}", state.time_period.label());
593
594    // Calculate middle price for 3-point y-axis labels
595    // In log mode, labels show original USD prices (exp of log values)
596    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
638/// Checks if a price indicates a stablecoin (pegged around $1.00).
639pub(crate) fn is_stablecoin_price(price: f64) -> bool {
640    (0.95..=1.05).contains(&price)
641}
642
643/// Formats a price in USD with appropriate precision.
644/// Stablecoins get extra precision (6 decimals) to show micro-fluctuations.
645pub(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        // Stablecoins get 6 decimals to show micro-fluctuations
650        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
662/// Renders a candlestick chart using OHLC data.
663pub(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    // Calculate price statistics
677    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    // Calculate bounds from all candle high/low
700    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    // Calculate candle width based on number of candles and area
722    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; // 60% of spacing for body
725
726    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    // Apply scale transformation (log or linear)
741    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    // Clone candles for the closure
758    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                // Draw the wick (high-low line)
778                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                // Draw the body (open-close rectangle)
787                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
804/// Renders a volume profile chart showing volume distribution by price level.
805///
806/// Buckets the price+volume history into horizontal bars where each bar shows
807/// the accumulated volume at that price level. Accuracy improves over longer
808/// monitoring sessions as more data points are collected.
809pub(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    // Find price range
824    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    // Number of price buckets = available height minus borders
837    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    // Accumulate volume per price bucket
842    let mut bucket_volumes = vec![0.0_f64; num_buckets];
843
844    // Pair price and volume data by index (they have same timestamps)
845    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        // Use volume delta if available, otherwise use a unit contribution
850        let vol_contribution = if i < vol_iter.len() {
851            // Use relative volume (delta from previous if possible)
852            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    // Find the bucket containing the current price
870    let current_bucket = (((state.current_price - min_price) / bucket_size).floor() as usize)
871        .min(num_buckets.saturating_sub(1));
872
873    // Build horizontal bars using Paragraph with spans
874    let inner_width = area.width.saturating_sub(12) as usize; // leave room for price labels
875
876    let lines: Vec<Line> = (0..num_buckets)
877        .rev() // top = highest price
878        .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
909/// Renders the volume chart with visual differentiation between real and synthetic data.
910pub(crate) fn render_volume_chart(f: &mut Frame, area: Rect, state: &MonitorState) {
911    let pal = state.palette();
912    // Get data filtered by selected time period
913    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    // Get current volume for display
923    let current_volume = state.volume_24h;
924    let volume_str = scope::display::format_usd(current_volume);
925
926    // Count synthetic vs real points for the legend
927    let has_synthetic = is_real.iter().any(|r| !r);
928    let has_real = is_real.iter().any(|r| *r);
929
930    // Build title with current volume
931    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    // Build bars from data points — bucket into a reasonable number of bars
953    // based on available width (each bar needs at least 3 chars)
954    let inner_width = area.width.saturating_sub(2) as usize; // account for block borders
955    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            // Show time labels at start, middle, and end
971            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    // Calculate dynamic bar width based on available space
984    let bar_width = if !bars.is_empty() {
985        let total_bars = bars.len() as u16;
986        // Each bar gets: bar_width + 1 gap, minus 1 gap for the last bar
987        ((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
1008/// Renders the buy/sell ratio gauge (pressure bar only, no activity log).
1009pub(crate) fn render_buy_sell_gauge(f: &mut Frame, area: Rect, state: &mut MonitorState) {
1010    let pal = state.palette();
1011    // Buy/Sell ratio bar
1012    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        // Center the label on top of the bar
1047        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
1058/// Renders the scrollable activity log feed.
1059pub(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
1088/// Renders a flashing alert overlay when alerts are active.
1089pub(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                // Flash with ~500ms period
1099                (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
1128/// Renders a horizontal stacked bar chart of per-pair liquidity.
1129pub(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) // limit to available rows
1154        .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
1177/// Renders the key metrics panel.
1178pub(crate) fn render_metrics_panel(f: &mut Frame, area: Rect, state: &MonitorState) {
1179    let pal = state.palette();
1180    // Split panel: top sparkline (2 rows), bottom table
1181    let chunks = Layout::default()
1182        .direction(Direction::Vertical)
1183        .constraints([Constraint::Length(3), Constraint::Min(0)])
1184        .split(area);
1185
1186    // --- Sparkline: recent price trend ---
1187    let sparkline_data: Vec<u64> = {
1188        // Take the last N price points and normalize to u64 for Sparkline
1189        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    // --- Table: key metrics ---
1222    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    // Add holder count if available and the widget is enabled
1305    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
1324/// Renders the order book panel for the Exchange layout.
1325///
1326/// Shows asks (descending), spread, bids (descending) with depth bars.
1327pub(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; // minus borders
1343    if inner_height < 3 {
1344        return;
1345    }
1346
1347    // Allocate rows: half for asks, 1 for spread, half for bids
1348    let ask_rows = (inner_height.saturating_sub(1)) / 2;
1349    let bid_rows = inner_height.saturating_sub(ask_rows).saturating_sub(1);
1350
1351    // Find max quantity for bar scaling
1352    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    // Bar takes ~30% of width, rest is price/qty text
1362    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    // --- Ask side (show in reverse so lowest ask is nearest the spread) ---
1367    let visible_asks: Vec<_> = book.asks.iter().take(ask_rows).collect();
1368    // Pad with empty lines if fewer asks than rows
1369    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); // spaces between columns
1384        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    // --- Spread line ---
1396    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    // --- Bid side ---
1411    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
1452/// Renders the recent trades panel for the Exchange layout.
1453///
1454/// Displays a scrolling list of recent trades with time, side (buy/sell),
1455/// price, and quantity. Buy trades are green, sell trades are red.
1456pub(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    // Column widths
1472    let time_width = 8; // HH:MM:SS
1473    let side_width = 4; // BUY / SELL
1474    let price_width = inner_width
1475        .saturating_sub(time_width)
1476        .saturating_sub(side_width)
1477        .saturating_sub(3) // separators
1478        / 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    // Header row
1488    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    // Trade rows (most recent first)
1511    let visible_count = inner_height.saturating_sub(1); // minus header
1512    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        // Format timestamp (HH:MM:SS from epoch ms)
1519        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
1568/// Renders the market info panel for the Exchange layout.
1569///
1570/// Shows per-pair breakdown (DEX, volume, liquidity), 24h stats,
1571/// token metadata (links, creation date), and aggregated metrics.
1572pub(crate) fn render_market_info_panel(f: &mut Frame, area: Rect, state: &MonitorState) {
1573    let pal = state.palette();
1574
1575    // Split into left (pair table) and right (token info) columns
1576    let cols = Layout::default()
1577        .direction(Direction::Horizontal)
1578        .constraints([Constraint::Percentage(60), Constraint::Percentage(40)])
1579        .split(area);
1580
1581    // ── Left: Trading pairs table ──
1582    {
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) // fit available rows
1591            .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    // ── Right: Token info + aggregated metrics ──
1629    {
1630        let mut info_lines: Vec<Line> = Vec::new();
1631
1632        // Price summary
1633        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        // Multi-timeframe changes
1647        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        // Volume & Liquidity
1668        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        // Market cap / FDV
1684        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        // Buy/sell stats
1704        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        // Holder count
1727        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        // Listed since
1735        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        // Links
1746        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                // Show domain only
1757                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
1782/// Renders the footer with status and controls.
1783pub(crate) fn render_footer(f: &mut Frame, area: Rect, state: &MonitorState) {
1784    let elapsed = state.last_update.elapsed().as_secs();
1785
1786    // Calculate time since last price change
1787    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    // Get data stats
1798    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    // REC indicator when CSV export is active
1836    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
1868/// Formats a number with K/M/B suffixes.
1869pub(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}