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::Style,
7    symbols,
8    text::{Line, Span},
9    widgets::{
10        Axis, Bar, BarChart, BarGroup, Block, Borders, Chart, Dataset, GraphType, List, ListItem,
11        Paragraph, Row, Table, Tabs, Wrap,
12        canvas::{Canvas, Line as CanvasLine, Rectangle},
13    },
14};
15use std::time::Instant;
16
17use scope::market::{BookSide, TradeSide};
18
19use super::config::{ChartMode, LayoutPreset, ScaleMode, WidgetVisibility};
20use super::state::MonitorState;
21use crate::cli::textfit::{cols, cut_with_ellipsis, draw_note_alone};
22use crate::cli::theme::SECONDARY;
23pub(crate) use scope::display::format::format_large_number as format_number;
24pub(crate) use scope::display::format::format_price_usd;
25use scope::display::theme::Role;
26
27/// The smallest terminal the monitor draws in, in columns and rows.
28pub(crate) const MIN_W: u16 = 60;
29/// See [`MIN_W`].
30pub(crate) const MIN_H: u16 = 16;
31
32/// The message drawn alone when the terminal is smaller than [`MIN_W`] x
33/// [`MIN_H`].
34fn too_small_note() -> String {
35    format!("Terminal too small: need at least {MIN_W}×{MIN_H}")
36}
37
38/// Panel title note: the data is built from DEX pairs, not a venue book.
39const SYNTHETIC_NOTE: &str = "(synthetic, from DEX pairs)";
40
41/// Panel title note: venue-only mode has no data for the panel.
42const VENUE_ONLY_NOTE: &str = "(not available in venue-only mode)";
43
44/// Shown in place of a stat that only DEX data has.
45const NOT_AVAILABLE: &str = "n/a";
46
47/// Renders the UI.
48/// Computed layout areas for each widget. `None` means the widget is hidden.
49pub(crate) struct LayoutAreas {
50    pub(crate) price_chart: Option<Rect>,
51    pub(crate) volume_chart: Option<Rect>,
52    pub(crate) buy_sell_gauge: Option<Rect>,
53    pub(crate) metrics_panel: Option<Rect>,
54    pub(crate) activity_feed: Option<Rect>,
55    /// Order book depth panel (Exchange layout).
56    pub(crate) order_book: Option<Rect>,
57    /// Market info panel with pair details (Exchange layout).
58    pub(crate) market_info: Option<Rect>,
59    /// Recent trade history (Exchange layout).
60    pub(crate) trade_history: Option<Rect>,
61}
62
63/// Dashboard layout: charts top, gauges middle, transaction feed bottom.
64///
65/// ```text
66/// ┌──────────────────────┬──────────────────────┐
67/// │  Price Chart (60%)   │  Volume Chart (40%)  │  55%
68/// ├──────────────────────┼──────────────────────┤
69/// │  Buy/Sell (50%)      │  Metrics (50%)       │  20%
70/// ├──────────────────────┴──────────────────────┤
71/// │  Activity Feed                              │  25%
72/// └─────────────────────────────────────────────┘
73/// ```
74pub(crate) fn layout_dashboard(area: Rect, widgets: &WidgetVisibility) -> LayoutAreas {
75    let rows = Layout::default()
76        .direction(Direction::Vertical)
77        .constraints([
78            Constraint::Percentage(55),
79            Constraint::Percentage(20),
80            Constraint::Percentage(25),
81        ])
82        .split(area);
83
84    let top = Layout::default()
85        .direction(Direction::Horizontal)
86        .constraints([Constraint::Percentage(60), Constraint::Percentage(40)])
87        .split(rows[0]);
88
89    let middle = Layout::default()
90        .direction(Direction::Horizontal)
91        .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
92        .split(rows[1]);
93
94    LayoutAreas {
95        price_chart: if widgets.price_chart {
96            Some(top[0])
97        } else {
98            None
99        },
100        volume_chart: if widgets.volume_chart {
101            Some(top[1])
102        } else {
103            None
104        },
105        buy_sell_gauge: if widgets.buy_sell_pressure {
106            Some(middle[0])
107        } else {
108            None
109        },
110        metrics_panel: if widgets.metrics_panel {
111            Some(middle[1])
112        } else {
113            None
114        },
115        activity_feed: if widgets.activity_log {
116            Some(rows[2])
117        } else {
118            None
119        },
120        order_book: None,
121        market_info: None,
122        trade_history: None,
123    }
124}
125
126/// Chart-focus layout: full-width candles with minimal stats overlay.
127///
128/// ```text
129/// ┌─────────────────────────────────────────────┐
130/// │                                             │
131/// │            Price Chart (~85%)                │
132/// │                                             │
133/// ├─────────────────────────────────────────────┤
134/// │  Metrics (compact stats overlay)     ~15%   │
135/// └─────────────────────────────────────────────┘
136/// ```
137pub(crate) fn layout_chart_focus(area: Rect, widgets: &WidgetVisibility) -> LayoutAreas {
138    let vertical = Layout::default()
139        .direction(Direction::Vertical)
140        .constraints([Constraint::Percentage(85), Constraint::Percentage(15)])
141        .split(area);
142
143    LayoutAreas {
144        price_chart: if widgets.price_chart {
145            Some(vertical[0])
146        } else {
147            None
148        },
149        volume_chart: None,   // Hidden in chart-focus
150        buy_sell_gauge: None, // Hidden in chart-focus
151        metrics_panel: if widgets.metrics_panel {
152            Some(vertical[1])
153        } else {
154            None
155        },
156        activity_feed: None, // Hidden in chart-focus
157        order_book: None,
158        market_info: None,
159        trade_history: None,
160    }
161}
162
163/// Feed layout: transaction log takes priority, small price ticker on top.
164///
165/// ```text
166/// ┌──────────────────────┬──────────────────────┐
167/// │  Metrics (50%)       │  Buy/Sell (50%)      │  25%
168/// ├──────────────────────┴──────────────────────┤
169/// │                                             │
170/// │            Activity Feed (~75%)             │
171/// │                                             │
172/// └─────────────────────────────────────────────┘
173/// ```
174pub(crate) fn layout_feed(area: Rect, widgets: &WidgetVisibility) -> LayoutAreas {
175    let vertical = Layout::default()
176        .direction(Direction::Vertical)
177        .constraints([Constraint::Percentage(25), Constraint::Percentage(75)])
178        .split(area);
179
180    let top = Layout::default()
181        .direction(Direction::Horizontal)
182        .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
183        .split(vertical[0]);
184
185    LayoutAreas {
186        price_chart: None,  // Hidden in feed mode
187        volume_chart: None, // Hidden in feed mode
188        metrics_panel: if widgets.metrics_panel {
189            Some(top[0])
190        } else {
191            None
192        },
193        buy_sell_gauge: if widgets.buy_sell_pressure {
194            Some(top[1])
195        } else {
196            None
197        },
198        activity_feed: if widgets.activity_log {
199            Some(vertical[1])
200        } else {
201            None
202        },
203        order_book: None,
204        market_info: None,
205        trade_history: None,
206    }
207}
208
209/// Compact layout: price sparkline and metrics only for small terminals.
210///
211/// ```text
212/// ┌─────────────────────────────────────────────┐
213/// │  Metrics Panel (sparkline + stats)    100%  │
214/// └─────────────────────────────────────────────┘
215/// ```
216pub(crate) fn layout_compact(area: Rect, widgets: &WidgetVisibility) -> LayoutAreas {
217    LayoutAreas {
218        price_chart: None,    // Hidden in compact
219        volume_chart: None,   // Hidden in compact
220        buy_sell_gauge: None, // Hidden in compact
221        metrics_panel: if widgets.metrics_panel {
222            Some(area)
223        } else {
224            None
225        },
226        activity_feed: None, // Hidden in compact
227        order_book: None,
228        market_info: None,
229        trade_history: None,
230    }
231}
232
233/// Exchange layout: order book left, chart center, trade history right.
234///
235/// ```text
236/// ┌─────────────────┬──────────────────────────┬─────────────────┐
237/// │                 │                          │                 │
238/// │  Order Book     │   Price Chart (45%)      │  Trade History  │  60%
239/// │    (25%)        │                          │    (30%)        │
240/// │                 │                          │                 │
241/// ├─────────────────┼──────────────────────────┼─────────────────┤
242/// │  Buy/Sell (25%) │   Market Info (45%)      │  (continued)    │  40%
243/// │                 │                          │    (30%)        │
244/// └─────────────────┴──────────────────────────┴─────────────────┘
245/// ```
246pub(crate) fn layout_exchange(area: Rect, _widgets: &WidgetVisibility) -> LayoutAreas {
247    let rows = Layout::default()
248        .direction(Direction::Vertical)
249        .constraints([Constraint::Percentage(60), Constraint::Percentage(40)])
250        .split(area);
251
252    let top = Layout::default()
253        .direction(Direction::Horizontal)
254        .constraints([
255            Constraint::Percentage(25),
256            Constraint::Percentage(45),
257            Constraint::Percentage(30),
258        ])
259        .split(rows[0]);
260
261    let bottom = Layout::default()
262        .direction(Direction::Horizontal)
263        .constraints([Constraint::Percentage(25), Constraint::Percentage(75)])
264        .split(rows[1]);
265
266    LayoutAreas {
267        price_chart: Some(top[1]),
268        volume_chart: None,
269        buy_sell_gauge: Some(bottom[0]),
270        metrics_panel: None,
271        activity_feed: None,
272        order_book: Some(top[0]),
273        market_info: Some(bottom[1]),
274        trade_history: Some(top[2]),
275    }
276}
277
278/// Selects the best layout preset based on terminal size.
279pub(crate) fn auto_select_layout(size: Rect) -> LayoutPreset {
280    match (size.width, size.height) {
281        (w, h) if w < 80 || h < 24 => LayoutPreset::Compact,
282        (w, _) if w < 120 => LayoutPreset::Feed,
283        (_, h) if h < 30 => LayoutPreset::ChartFocus,
284        _ => LayoutPreset::Dashboard,
285    }
286}
287
288/// Renders the UI, dispatching to the active layout preset.
289pub(crate) fn ui(f: &mut Frame, state: &mut MonitorState) {
290    // Too small: draw only the message. The state is kept and keys work.
291    let area = f.area();
292    if area.width < MIN_W || area.height < MIN_H {
293        draw_note_alone(f, &too_small_note());
294        return;
295    }
296
297    // Responsive breakpoint: auto-select layout for terminal size
298    if state.auto_layout {
299        let suggested = auto_select_layout(f.area());
300        if suggested != state.layout {
301            state.layout = suggested;
302        }
303    }
304
305    // Main layout: header, content, footer
306    let chunks = Layout::default()
307        .direction(Direction::Vertical)
308        .constraints([
309            Constraint::Length(4), // Header (token info + time period tabs)
310            Constraint::Min(0),    // Content: shrinks first, the footer stays
311            Constraint::Length(3), // Footer
312        ])
313        .split(f.area());
314
315    // Render header
316    render_header(f, chunks[0], state);
317
318    // Calculate content areas based on active layout preset
319    let areas = match state.layout {
320        LayoutPreset::Dashboard => layout_dashboard(chunks[1], &state.widgets),
321        LayoutPreset::ChartFocus => layout_chart_focus(chunks[1], &state.widgets),
322        LayoutPreset::Feed => layout_feed(chunks[1], &state.widgets),
323        LayoutPreset::Compact => layout_compact(chunks[1], &state.widgets),
324        LayoutPreset::Exchange => layout_exchange(chunks[1], &state.widgets),
325    };
326
327    // Render each widget if its area is allocated
328    if let Some(area) = areas.price_chart {
329        match state.chart_mode {
330            ChartMode::Line => render_price_chart(f, area, state),
331            ChartMode::Candlestick => render_candlestick_chart(f, area, state),
332            ChartMode::VolumeProfile => render_volume_profile_chart(f, area, state),
333        }
334    }
335    if let Some(area) = areas.volume_chart {
336        render_volume_chart(f, area, &*state);
337    }
338    if let Some(area) = areas.buy_sell_gauge {
339        render_buy_sell_gauge(f, area, state);
340    }
341    if let Some(area) = areas.metrics_panel {
342        // Split metrics area to show liquidity depth if enabled and data available
343        if state.widgets.liquidity_depth && !state.liquidity_pairs.is_empty() {
344            let split = Layout::default()
345                .direction(Direction::Vertical)
346                .constraints([Constraint::Percentage(60), Constraint::Percentage(40)])
347                .split(area);
348            render_metrics_panel(f, split[0], &*state);
349            render_liquidity_depth(f, split[1], &*state);
350        } else {
351            render_metrics_panel(f, area, &*state);
352        }
353    }
354    if let Some(area) = areas.activity_feed {
355        render_activity_feed(f, area, state);
356    }
357    // Exchange-specific widgets
358    if let Some(area) = areas.order_book {
359        render_order_book_panel(f, area, state);
360    }
361    if let Some(area) = areas.market_info {
362        render_market_info_panel(f, area, state);
363    }
364    if let Some(area) = areas.trade_history {
365        render_recent_trades_panel(f, area, state);
366    }
367
368    // Render alert overlay on top of content area if alerts are active
369    if !state.active_alerts.is_empty() {
370        // Show alerts as a banner at the top of the content area (3 lines max)
371        let alert_height = (state.active_alerts.len() as u16 + 2).min(5);
372        let alert_area = Rect::new(
373            chunks[1].x,
374            chunks[1].y,
375            chunks[1].width,
376            alert_height.min(chunks[1].height),
377        );
378        render_alert_overlay(f, alert_area, state);
379    }
380
381    // Render footer
382    render_footer(f, chunks[2], state);
383
384    if state.show_keys {
385        render_keys_overlay(f);
386    }
387}
388
389/// Renders the header with token info and time period tabs.
390pub(crate) fn render_header(f: &mut Frame, area: Rect, state: &MonitorState) {
391    let pal = state.palette();
392    let (price_color, trend_arrow, change_str) = match state.change_24h() {
393        Some(change) => {
394            let color = if change >= 0.0 { pal.up } else { pal.down };
395            // Use Unicode arrows for trend indication
396            let arrow = if change > 0.5 {
397                "▲"
398            } else if change < -0.5 {
399                "▼"
400            } else if change >= 0.0 {
401                "△"
402            } else {
403                "▽"
404            };
405            let sign = if change >= 0.0 { "+" } else { "" };
406            (color, arrow, format!("{}{:.2}%", sign, change))
407        }
408        // No direction is known: no arrow, no up/down color.
409        None => (pal.neutral, "", format!("24h {NOT_AVAILABLE}")),
410    };
411
412    let title = header_title(state, area.width);
413
414    // A price shown beside an error is the last good one (#68).
415    let price_str = if state.error_message.is_some() {
416        format!("{} (stale)", format_price_usd(state.current_price))
417    } else {
418        format_price_usd(state.current_price)
419    };
420
421    // Split header area: top row for token info, bottom row for tabs
422    let header_chunks = Layout::default()
423        .direction(Direction::Vertical)
424        .constraints([Constraint::Length(3), Constraint::Length(1)])
425        .split(area);
426
427    // Token info with price
428    let header = Paragraph::new(Line::from(vec![
429        Span::styled(price_str, Style::new().fg(price_color).bold()),
430        Span::raw(" "),
431        Span::styled(trend_arrow, Style::new().fg(price_color)),
432        Span::styled(format!(" {}", change_str), Style::new().fg(price_color)),
433    ]))
434    .block(
435        Block::default()
436            .title(title)
437            .borders(Borders::ALL)
438            .border_style(crate::cli::theme::ACCENT),
439    );
440
441    f.render_widget(header, header_chunks[0]);
442
443    // Time period tabs
444    let tab_titles = vec!["1m", "5m", "15m", "1h", "4h", "1d"];
445    let chart_label = state.chart_mode.label();
446    let tabs = Tabs::new(tab_titles)
447        .select(state.time_period.index())
448        .highlight_style(crate::cli::theme::strong(Role::Accent))
449        .divider("│")
450        .padding(" ", " ");
451    let tabs_line = Layout::default()
452        .direction(Direction::Horizontal)
453        .constraints([Constraint::Min(20), Constraint::Length(10)])
454        .split(header_chunks[1]);
455    f.render_widget(tabs, tabs_line[0]);
456    f.render_widget(
457        Paragraph::new(Span::styled(
458            format!("⊞ {}", chart_label),
459            crate::cli::theme::ACCENT,
460        )),
461        tabs_line[1],
462    );
463}
464
465/// The header title: token, its source (chain, or venue and pair in
466/// venue-only mode), and the last error cut with `…` to fit `width`.
467fn header_title(state: &MonitorState, width: u16) -> Line<'static> {
468    let base = format!(
469        " ◈ {} ({}) │ {} ",
470        state.symbol,
471        state.name,
472        state.source_label()
473    );
474    let mut spans = vec![Span::raw(base.clone())];
475    if let Some(err) = &state.error_message {
476        // Inside the borders, after the base and "│ " ... " ".
477        let room = (width as usize).saturating_sub(2 + cols(&base) + 3);
478        let err = cut_with_ellipsis(&format!("⚠ {}", err), room);
479        spans.push(Span::styled(format!("│ {} ", err), crate::cli::theme::ERR));
480    }
481    Line::from(spans)
482}
483
484/// Renders the price chart with visual differentiation between real and synthetic data.
485pub(crate) fn render_price_chart(f: &mut Frame, area: Rect, state: &MonitorState) {
486    // Get data filtered by selected time period
487    let (data, is_real) = state.get_price_data_for_period();
488
489    if data.is_empty() {
490        let empty = Paragraph::new("No price data").block(
491            Block::default()
492                .title(" Price (USD) ")
493                .borders(Borders::ALL),
494        );
495        f.render_widget(empty, area);
496        return;
497    }
498
499    // Calculate price statistics
500    let current_price = state.current_price;
501    let first_price = data.first().map(|(_, p)| *p).unwrap_or(current_price);
502    let price_change = current_price - first_price;
503    let price_change_pct = if first_price > 0.0 {
504        (price_change / first_price) * 100.0
505    } else {
506        0.0
507    };
508
509    // Determine if price is up or down for coloring
510    let pal = state.palette();
511    let is_price_up = price_change >= 0.0;
512    let trend_color = if is_price_up { pal.up } else { pal.down };
513    let trend_symbol = if is_price_up { "▲" } else { "▼" };
514
515    // Format current price based on magnitude
516    let price_str = format_price_usd(current_price);
517    let change_str = if price_change_pct.abs() < 0.01 {
518        "0.00%".to_string()
519    } else {
520        format!(
521            "{}{:.2}%",
522            if is_price_up { "+" } else { "" },
523            price_change_pct
524        )
525    };
526
527    // Build title with current price and change
528    let chart_title = Line::from(vec![
529        Span::raw(" ◆ "),
530        Span::styled(
531            format!("{} {} ", price_str, trend_symbol),
532            Style::new().fg(trend_color).bold(),
533        ),
534        Span::styled(format!("({}) ", change_str), Style::new().fg(trend_color)),
535        Span::styled(format!("│{}│ ", state.time_period.label()), SECONDARY),
536    ]);
537
538    // Calculate bounds
539    let (min_price, max_price) = data
540        .iter()
541        .fold((f64::MAX, f64::MIN), |(min, max), (_, p)| {
542            (min.min(*p), max.max(*p))
543        });
544
545    // Handle case where all prices are the same (e.g., stablecoins)
546    let price_range = max_price - min_price;
547    let (y_min, y_max) = if price_range < 0.0001 {
548        // Add ±0.1% padding when prices are flat
549        let padding = min_price * 0.001;
550        (min_price - padding, max_price + padding)
551    } else {
552        (min_price - price_range * 0.1, max_price + price_range * 0.1)
553    };
554
555    let x_min = data.first().map(|(t, _)| *t).unwrap_or(0.0);
556    let x_max = data.last().map(|(t, _)| *t).unwrap_or(1.0);
557    // Ensure x range is non-zero for proper rendering
558    let x_max = if (x_max - x_min).abs() < 0.001 {
559        x_min + 1.0
560    } else {
561        x_max
562    };
563
564    // Apply scale transformation (log or linear)
565    let apply_scale = |price: f64| -> f64 {
566        match state.scale_mode {
567            ScaleMode::Linear => price,
568            ScaleMode::Log => {
569                if price > 0.0 {
570                    price.ln()
571                } else {
572                    0.0
573                }
574            }
575        }
576    };
577
578    let (y_min, y_max) = (apply_scale(y_min), apply_scale(y_max));
579
580    // Split data into synthetic and real datasets for visual differentiation
581    let synthetic_data: Vec<(f64, f64)> = data
582        .iter()
583        .zip(&is_real)
584        .filter(|(_, real)| !**real)
585        .map(|((t, p), _)| (*t, apply_scale(*p)))
586        .collect();
587
588    let real_data: Vec<(f64, f64)> = data
589        .iter()
590        .zip(&is_real)
591        .filter(|(_, real)| **real)
592        .map(|((t, p), _)| (*t, apply_scale(*p)))
593        .collect();
594
595    // Create reference line at first price (horizontal line for comparison)
596    let reference_line: Vec<(f64, f64)> = vec![
597        (x_min, apply_scale(first_price)),
598        (x_max, apply_scale(first_price)),
599    ];
600
601    let mut datasets = Vec::new();
602
603    // Reference line (starting price) - dashed gray
604    datasets.push(
605        Dataset::default()
606            .name("━Start")
607            .marker(symbols::Marker::Braille)
608            .graph_type(GraphType::Line)
609            .style(SECONDARY)
610            .data(&reference_line),
611    );
612
613    // Synthetic data shown with Dot marker and dimmed color
614    if !synthetic_data.is_empty() {
615        datasets.push(
616            Dataset::default()
617                .name("◇Est")
618                .marker(symbols::Marker::Braille)
619                .graph_type(GraphType::Line)
620                .style(crate::cli::theme::ACCENT)
621                .data(&synthetic_data),
622        );
623    }
624
625    // Real data shown with Braille marker and trend color
626    if !real_data.is_empty() {
627        datasets.push(
628            Dataset::default()
629                .name("●Live")
630                .marker(symbols::Marker::Braille)
631                .graph_type(GraphType::Line)
632                .style(Style::new().fg(trend_color))
633                .data(&real_data),
634        );
635    }
636
637    // Create time labels based on period
638    let time_label = format!("-{}", state.time_period.label());
639
640    // Calculate middle price for 3-point y-axis labels
641    // In log mode, labels show original USD prices (exp of log values)
642    let mid_y = (y_min + y_max) / 2.0;
643    let y_label = |val: f64| -> String {
644        match state.scale_mode {
645            ScaleMode::Linear => format_price_usd(val),
646            ScaleMode::Log => format_price_usd(val.exp()),
647        }
648    };
649
650    let scale_label = match state.scale_mode {
651        ScaleMode::Linear => "USD",
652        ScaleMode::Log => "USD (log)",
653    };
654
655    let chart = Chart::new(datasets)
656        .block(
657            Block::default()
658                .title(chart_title)
659                .borders(Borders::ALL)
660                .border_style(Style::new().fg(trend_color)),
661        )
662        .x_axis(
663            Axis::default()
664                .title(Span::styled("Time", SECONDARY))
665                .style(SECONDARY)
666                .bounds([x_min, x_max])
667                .labels(vec![Span::raw(time_label), Span::raw("now")]),
668        )
669        .y_axis(
670            Axis::default()
671                .title(Span::styled(scale_label, SECONDARY))
672                .style(SECONDARY)
673                .bounds([y_min, y_max])
674                .labels(vec![
675                    Span::raw(y_label(y_min)),
676                    Span::raw(y_label(mid_y)),
677                    Span::raw(y_label(y_max)),
678                ]),
679        );
680
681    f.render_widget(chart, area);
682}
683
684/// Renders a candlestick chart using OHLC data.
685pub(crate) fn render_candlestick_chart(f: &mut Frame, area: Rect, state: &MonitorState) {
686    let candles = state.get_ohlc_candles();
687
688    if candles.is_empty() {
689        let empty = Paragraph::new("No candle data (waiting for more data points)").block(
690            Block::default()
691                .title(" Candlestick (USD) ")
692                .borders(Borders::ALL),
693        );
694        f.render_widget(empty, area);
695        return;
696    }
697
698    // Calculate price statistics
699    let current_price = state.current_price;
700    let first_candle = candles.first().unwrap();
701    let last_candle = candles.last().unwrap();
702    let price_change = last_candle.close - first_candle.open;
703    let price_change_pct = if first_candle.open > 0.0 {
704        (price_change / first_candle.open) * 100.0
705    } else {
706        0.0
707    };
708
709    let pal = state.palette();
710    let is_price_up = price_change >= 0.0;
711    let trend_color = if is_price_up { pal.up } else { pal.down };
712    let trend_symbol = if is_price_up { "▲" } else { "▼" };
713
714    let price_str = format_price_usd(current_price);
715    let change_str = format!(
716        "{}{:.2}%",
717        if is_price_up { "+" } else { "" },
718        price_change_pct
719    );
720
721    // Calculate bounds from all candle high/low
722    let (min_price, max_price) = candles.iter().fold((f64::MAX, f64::MIN), |(min, max), c| {
723        (min.min(c.low), max.max(c.high))
724    });
725
726    let price_range = max_price - min_price;
727    let (y_min, y_max) = if price_range < 0.0001 {
728        let padding = min_price * 0.001;
729        (min_price - padding, max_price + padding)
730    } else {
731        (min_price - price_range * 0.1, max_price + price_range * 0.1)
732    };
733
734    let x_min = candles.first().map(|c| c.timestamp).unwrap_or(0.0);
735    let x_max = candles.last().map(|c| c.timestamp).unwrap_or(1.0);
736    let x_range = x_max - x_min;
737    let x_max = if x_range < 0.001 {
738        x_min + 1.0
739    } else {
740        x_max + x_range * 0.05
741    };
742
743    // Calculate candle width based on number of candles and area
744    let candle_count = candles.len() as f64;
745    let candle_spacing = x_range / candle_count.max(1.0);
746    let candle_width = candle_spacing * 0.6; // 60% of spacing for body
747
748    let title = Line::from(vec![
749        Span::raw(" ⬡ "),
750        Span::styled(
751            format!("{} {} ", price_str, trend_symbol),
752            Style::new().fg(trend_color).bold(),
753        ),
754        Span::styled(format!("({}) ", change_str), Style::new().fg(trend_color)),
755        Span::styled(format!("│{}│ ", state.time_period.label()), SECONDARY),
756        Span::styled("⊞Candles ", crate::cli::theme::ACCENT),
757    ]);
758
759    // Apply scale transformation (log or linear)
760    let apply_scale = |price: f64| -> f64 {
761        match state.scale_mode {
762            ScaleMode::Linear => price,
763            ScaleMode::Log => {
764                if price > 0.0 {
765                    price.ln()
766                } else {
767                    0.0
768                }
769            }
770        }
771    };
772    let scaled_y_min = apply_scale(y_min);
773    let scaled_y_max = apply_scale(y_max);
774    let scaled_price_range = scaled_y_max - scaled_y_min;
775
776    // Clone candles for the closure
777    let candles_clone = candles.clone();
778    let is_log = matches!(state.scale_mode, ScaleMode::Log);
779    let pal_up = pal.up;
780    let pal_down = pal.down;
781
782    let canvas = Canvas::default()
783        .block(
784            Block::default()
785                .title(title)
786                .borders(Borders::ALL)
787                .border_style(Style::new().fg(trend_color)),
788        )
789        .x_bounds([x_min - candle_spacing, x_max])
790        .y_bounds([scaled_y_min, scaled_y_max])
791        .paint(move |ctx| {
792            let scale_fn = |p: f64| -> f64 { if is_log && p > 0.0 { p.ln() } else { p } };
793            for candle in &candles_clone {
794                let color = if candle.is_bullish { pal_up } else { pal_down };
795
796                // Draw the wick (high-low line)
797                ctx.draw(&CanvasLine {
798                    x1: candle.timestamp,
799                    y1: scale_fn(candle.low),
800                    x2: candle.timestamp,
801                    y2: scale_fn(candle.high),
802                    color,
803                });
804
805                // Draw the body (open-close rectangle)
806                let body_top = scale_fn(candle.open.max(candle.close));
807                let body_bottom = scale_fn(candle.open.min(candle.close));
808                let body_height = (body_top - body_bottom).max(scaled_price_range * 0.002);
809
810                ctx.draw(&Rectangle {
811                    x: candle.timestamp - candle_width / 2.0,
812                    y: body_bottom,
813                    width: candle_width,
814                    height: body_height,
815                    color,
816                });
817            }
818        });
819
820    f.render_widget(canvas, area);
821}
822
823/// Renders a volume profile chart showing volume distribution by price level.
824///
825/// Buckets the price+volume history into horizontal bars where each bar shows
826/// the accumulated volume at that price level. Accuracy improves over longer
827/// monitoring sessions as more data points are collected.
828pub(crate) fn render_volume_profile_chart(f: &mut Frame, area: Rect, state: &MonitorState) {
829    let pal = state.palette();
830    let (price_data, _) = state.get_price_data_for_period();
831    let (volume_data, _) = state.get_volume_data_for_period();
832
833    if price_data.len() < 2 || volume_data.is_empty() {
834        let block = Block::default()
835            .title(" ◨ Volume Profile (collecting data...) ")
836            .borders(Borders::ALL)
837            .border_style(crate::cli::theme::SECONDARY);
838        f.render_widget(block, area);
839        return;
840    }
841
842    // Find price range
843    let min_price = price_data.iter().map(|(_, p)| *p).fold(f64::MAX, f64::min);
844    let max_price = price_data.iter().map(|(_, p)| *p).fold(f64::MIN, f64::max);
845
846    if (max_price - min_price).abs() < f64::EPSILON {
847        let block = Block::default()
848            .title(" ◨ Volume Profile (no price range) ")
849            .borders(Borders::ALL)
850            .border_style(crate::cli::theme::SECONDARY);
851        f.render_widget(block, area);
852        return;
853    }
854
855    // Number of price buckets = available height minus borders
856    let inner_height = area.height.saturating_sub(2) as usize;
857    let num_buckets = inner_height.clamp(1, 30);
858    let bucket_size = (max_price - min_price) / num_buckets as f64;
859
860    // Accumulate volume per price bucket
861    let mut bucket_volumes = vec![0.0_f64; num_buckets];
862
863    // Pair price and volume data by index (they have same timestamps)
864    let vol_iter: Vec<f64> = volume_data.iter().map(|(_, v)| *v).collect();
865    for (i, (_, price)) in price_data.iter().enumerate() {
866        let bucket_idx =
867            (((price - min_price) / bucket_size).floor() as usize).min(num_buckets - 1);
868        // Use volume delta if available, otherwise use a unit contribution
869        let vol_contribution = if i < vol_iter.len() {
870            // Use relative volume (delta from previous if possible)
871            if i > 0 {
872                (vol_iter[i] - vol_iter[i - 1]).abs().max(1.0)
873            } else {
874                1.0
875            }
876        } else {
877            1.0
878        };
879        bucket_volumes[bucket_idx] += vol_contribution;
880    }
881
882    let max_vol = bucket_volumes
883        .iter()
884        .cloned()
885        .fold(0.0_f64, f64::max)
886        .max(1.0);
887
888    // Find the bucket containing the current price
889    let current_bucket = (((state.current_price - min_price) / bucket_size).floor() as usize)
890        .min(num_buckets.saturating_sub(1));
891
892    // Build horizontal bars using Paragraph with spans
893    let inner_width = area.width.saturating_sub(12) as usize; // leave room for price labels
894
895    let lines: Vec<Line> = (0..num_buckets)
896        .rev() // top = highest price
897        .map(|i| {
898            let bar_width = ((bucket_volumes[i] / max_vol) * inner_width as f64).round() as usize;
899            let price_mid = min_price + (i as f64 + 0.5) * bucket_size;
900            let label = if price_mid >= 1.0 {
901                format!("{:>8.2}", price_mid)
902            } else {
903                format!("{:>8.6}", price_mid)
904            };
905            let bar_str = "█".repeat(bar_width);
906            let style = if i == current_bucket {
907                Style::new().fg(pal.highlight).bold()
908            } else {
909                Style::new().fg(pal.sparkline)
910            };
911            Line::from(vec![
912                Span::styled(label, SECONDARY),
913                Span::raw(" "),
914                Span::styled(bar_str, style),
915            ])
916        })
917        .collect();
918
919    let block = Block::default()
920        .title(" ◨ Volume Profile (accuracy improves over time) ")
921        .borders(Borders::ALL)
922        .border_style(Style::new().fg(pal.sparkline));
923
924    let paragraph = Paragraph::new(lines).block(block);
925    f.render_widget(paragraph, area);
926}
927
928/// Renders the volume chart with visual differentiation between real and synthetic data.
929pub(crate) fn render_volume_chart(f: &mut Frame, area: Rect, state: &MonitorState) {
930    let pal = state.palette();
931    // Get data filtered by selected time period
932    let (data, is_real) = state.get_volume_data_for_period();
933
934    if data.is_empty() {
935        let empty = Paragraph::new("No volume data")
936            .block(Block::default().title(" 24h Volume ").borders(Borders::ALL));
937        f.render_widget(empty, area);
938        return;
939    }
940
941    // Get current volume for display
942    let current_volume = state.volume_24h;
943    let volume_str = scope::display::format_usd(current_volume);
944
945    // Count synthetic vs real points for the legend
946    let has_synthetic = is_real.iter().any(|r| !r);
947    let has_real = is_real.iter().any(|r| *r);
948
949    // Build title with current volume
950    let data_indicator = if has_synthetic && has_real {
951        "[◆ est │ ● live]"
952    } else if has_synthetic {
953        "[◆ estimated]"
954    } else {
955        "[● live]"
956    };
957
958    let chart_title = Line::from(vec![
959        Span::raw(" ▣ "),
960        Span::styled(
961            format!("24h Vol: {} ", volume_str),
962            Style::new().fg(pal.volume_bar).bold(),
963        ),
964        Span::styled(format!("│{}│ ", state.time_period.label()), SECONDARY),
965        Span::styled(data_indicator, SECONDARY),
966    ]);
967
968    // Build bars from data points — bucket into a reasonable number of bars
969    // based on available width (each bar needs at least 3 chars)
970    let inner_width = area.width.saturating_sub(2) as usize; // account for block borders
971    let max_bars = (inner_width / 3).max(1).min(data.len());
972    let bucket_size = data.len().div_ceil(max_bars);
973
974    let bars: Vec<Bar> = data
975        .chunks(bucket_size)
976        .zip(is_real.chunks(bucket_size))
977        .enumerate()
978        .map(|(i, (chunk, real_chunk))| {
979            let avg_vol = chunk.iter().map(|(_, v)| v).sum::<f64>() / chunk.len() as f64;
980            let any_real = real_chunk.iter().any(|r| *r);
981            let bar_color = if any_real {
982                pal.volume_bar
983            } else {
984                pal.neutral
985            };
986            // Show time labels at start, middle, and end
987            let label = if i == 0 || i == max_bars.saturating_sub(1) || i == max_bars / 2 {
988                format_number(avg_vol)
989            } else {
990                String::new()
991            };
992            Bar::default()
993                .value(avg_vol as u64)
994                .label(Line::from(label))
995                .style(Style::new().fg(bar_color))
996        })
997        .collect();
998
999    // Calculate dynamic bar width based on available space
1000    let bar_width = if !bars.is_empty() {
1001        let total_bars = bars.len() as u16;
1002        // Each bar gets: bar_width + 1 gap, minus 1 gap for the last bar
1003        ((inner_width as u16).saturating_sub(total_bars.saturating_sub(1))) / total_bars
1004    } else {
1005        1
1006    }
1007    .max(1);
1008
1009    let barchart = BarChart::default()
1010        .data(BarGroup::default().bars(&bars))
1011        .block(
1012            Block::default()
1013                .title(chart_title)
1014                .borders(Borders::ALL)
1015                .border_style(crate::cli::theme::INFO),
1016        )
1017        .bar_width(bar_width)
1018        .bar_gap(1)
1019        .value_style(SECONDARY);
1020
1021    f.render_widget(barchart, area);
1022}
1023
1024/// Renders the buy/sell ratio gauge (pressure bar only, no activity log).
1025pub(crate) fn render_buy_sell_gauge(f: &mut Frame, area: Rect, state: &mut MonitorState) {
1026    let pal = state.palette();
1027    // Buy/Sell ratio bar
1028    let ratio = state.buy_ratio();
1029    let border_color = if ratio > 0.5 { pal.up } else { pal.down };
1030
1031    let block = Block::default()
1032        .title(" ◐ Buy/Sell Ratio (24h) ")
1033        .borders(Borders::ALL)
1034        .border_style(Style::new().fg(border_color));
1035
1036    if state.venue_only {
1037        let note = format!("Buys/Sells: {NOT_AVAILABLE} (venue-only mode)");
1038        let body = Paragraph::new(Span::styled(note, SECONDARY))
1039            .wrap(Wrap { trim: true })
1040            .block(block);
1041        f.render_widget(body, area);
1042        return;
1043    }
1044
1045    let inner = block.inner(area);
1046    f.render_widget(block, area);
1047
1048    if inner.width > 0 && inner.height > 0 {
1049        let buy_width = ((ratio * inner.width as f64).round() as u16).min(inner.width);
1050        let sell_width = inner.width.saturating_sub(buy_width);
1051
1052        let buy_indicator = if ratio > 0.5 { "▶" } else { "▷" };
1053        let sell_indicator = if ratio < 0.5 { "◀" } else { "◁" };
1054        let label = format!(
1055            "{}Buys: {} │ Sells: {}{} ({:.1}%)",
1056            buy_indicator,
1057            state.buys_24h,
1058            state.sells_24h,
1059            sell_indicator,
1060            ratio * 100.0
1061        );
1062
1063        let buy_bar = "█".repeat(buy_width as usize);
1064        let sell_bar = "█".repeat(sell_width as usize);
1065        let bar_line = Line::from(vec![
1066            Span::styled(buy_bar, Style::new().fg(pal.up)),
1067            Span::styled(sell_bar, Style::new().fg(pal.down)),
1068        ]);
1069        f.render_widget(Paragraph::new(bar_line), inner);
1070
1071        // Center the label on top of the bar
1072        let label_len = label.len() as u16;
1073        if label_len <= inner.width {
1074            let x_offset = (inner.width.saturating_sub(label_len)) / 2;
1075            let label_area = Rect::new(inner.x + x_offset, inner.y, label_len, 1);
1076            let label_widget = Paragraph::new(Span::styled(label, Style::new().bold()));
1077            f.render_widget(label_widget, label_area);
1078        }
1079    }
1080}
1081
1082/// Renders the scrollable activity log feed.
1083pub(crate) fn render_activity_feed(f: &mut Frame, area: Rect, state: &mut MonitorState) {
1084    let log_len = state.log_messages.len();
1085    let log_title = if log_len > 0 {
1086        let selected = state.log_list_state.selected().unwrap_or(0);
1087        format!(" ◷ Activity Log [{}/{}] ", selected + 1, log_len)
1088    } else {
1089        " ◷ Activity Log ".to_string()
1090    };
1091
1092    let items: Vec<ListItem> = state
1093        .log_messages
1094        .iter()
1095        .rev()
1096        .map(|msg| ListItem::new(msg.as_str()).style(SECONDARY))
1097        .collect();
1098
1099    let log_list = List::new(items)
1100        .block(
1101            Block::default()
1102                .title(log_title)
1103                .borders(Borders::ALL)
1104                .border_style(crate::cli::theme::SECONDARY),
1105        )
1106        .highlight_style(Style::new().bold())
1107        .highlight_symbol("▸ ");
1108
1109    f.render_stateful_widget(log_list, area, &mut state.log_list_state);
1110}
1111
1112/// Renders a flashing alert overlay when alerts are active.
1113pub(crate) fn render_alert_overlay(f: &mut Frame, area: Rect, state: &MonitorState) {
1114    if state.active_alerts.is_empty() {
1115        return;
1116    }
1117
1118    let is_flash_on = state
1119        .alert_flash_until
1120        .map(|deadline| {
1121            if Instant::now() < deadline {
1122                // Flash with ~500ms period
1123                (Instant::now().elapsed().subsec_millis() / 500).is_multiple_of(2)
1124            } else {
1125                false
1126            }
1127        })
1128        .unwrap_or(false);
1129
1130    let border_style = if is_flash_on {
1131        crate::cli::theme::strong(Role::Err)
1132    } else {
1133        crate::cli::theme::strong(Role::Warn)
1134    };
1135
1136    let alert_lines: Vec<Line> = state
1137        .active_alerts
1138        .iter()
1139        .map(|a| {
1140            Line::from(Span::styled(
1141                &a.message,
1142                crate::cli::theme::strong(Role::Err),
1143            ))
1144        })
1145        .collect();
1146
1147    let alert_widget = Paragraph::new(alert_lines).block(
1148        Block::default()
1149            .title(" ⚠ ALERTS ")
1150            .borders(Borders::ALL)
1151            .border_style(border_style),
1152    );
1153
1154    f.render_widget(alert_widget, area);
1155}
1156
1157/// Renders a horizontal stacked bar chart of per-pair liquidity.
1158pub(crate) fn render_liquidity_depth(f: &mut Frame, area: Rect, state: &MonitorState) {
1159    let pal = state.palette();
1160
1161    if state.liquidity_pairs.is_empty() {
1162        let block = Block::default()
1163            .title(" ◫ Liquidity Depth (no data) ")
1164            .borders(Borders::ALL)
1165            .border_style(crate::cli::theme::SECONDARY);
1166        f.render_widget(block, area);
1167        return;
1168    }
1169
1170    let max_liquidity = state
1171        .liquidity_pairs
1172        .iter()
1173        .map(|(_, liq)| *liq)
1174        .fold(0.0_f64, f64::max)
1175        .max(1.0);
1176
1177    let inner_width = area.width.saturating_sub(2) as usize;
1178
1179    let lines: Vec<Line> = state
1180        .liquidity_pairs
1181        .iter()
1182        .take(area.height.saturating_sub(2) as usize) // limit to available rows
1183        .map(|(name, liq)| {
1184            let bar_width = ((liq / max_liquidity) * inner_width as f64 * 0.6).round() as usize;
1185            let bar_str = "█".repeat(bar_width);
1186            let label = format!(" {} {}", scope::display::format_usd(*liq), name);
1187            Line::from(vec![
1188                Span::styled(bar_str, Style::new().fg(pal.volume_bar)),
1189                Span::styled(label, Style::new().fg(pal.neutral)),
1190            ])
1191        })
1192        .collect();
1193
1194    let block = Block::default()
1195        .title(format!(
1196            " ◫ Liquidity Depth ({} pairs) ",
1197            state.liquidity_pairs.len()
1198        ))
1199        .borders(Borders::ALL)
1200        .border_style(crate::cli::theme::SECONDARY);
1201
1202    let paragraph = Paragraph::new(lines).block(block);
1203    f.render_widget(paragraph, area);
1204}
1205
1206/// Renders the key metrics panel.
1207pub(crate) fn render_metrics_panel(f: &mut Frame, area: Rect, state: &MonitorState) {
1208    let pal = state.palette();
1209    // Split panel: top sparkline (2 rows), bottom table
1210    let chunks = Layout::default()
1211        .direction(Direction::Vertical)
1212        .constraints([Constraint::Length(3), Constraint::Min(0)])
1213        .split(area);
1214
1215    // --- Price trend: the shared chart component ---
1216    let trend_color = if !state.short_changes_known() {
1217        pal.neutral
1218    } else if state.price_change_5m >= 0.0 {
1219        pal.up
1220    } else {
1221        pal.down
1222    };
1223    let block = Block::default()
1224        .title(" ◉ Price Trend ")
1225        .borders(Borders::ALL)
1226        .border_style(Style::new().fg(pal.sparkline));
1227    let inner = block.inner(chunks[0]);
1228    f.render_widget(block, chunks[0]);
1229    let prices: Vec<Option<f64>> = state
1230        .price_history
1231        .iter()
1232        .map(|dp| Some(dp.value))
1233        .collect();
1234    f.render_widget(
1235        crate::cli::charts::Sparkline::new(&prices, Style::new().fg(trend_color)),
1236        inner,
1237    );
1238
1239    // --- Table: key metrics ---
1240    let change_5m_str = if !state.short_changes_known() {
1241        NOT_AVAILABLE.to_string()
1242    } else if state.price_change_5m.abs() < 0.0001 {
1243        "0.00%".to_string()
1244    } else {
1245        format!("{:+.4}%", state.price_change_5m)
1246    };
1247    let change_5m_color = if !state.short_changes_known() {
1248        pal.neutral
1249    } else if state.price_change_5m > 0.0 {
1250        pal.up
1251    } else if state.price_change_5m < 0.0 {
1252        pal.down
1253    } else {
1254        pal.neutral
1255    };
1256
1257    let now_ts = chrono::Utc::now().timestamp() as f64;
1258    let secs_since_change = (now_ts - state.last_price_change_at).max(0.0) as u64;
1259    let last_change_str = if secs_since_change < 60 {
1260        format!("{}s ago", secs_since_change)
1261    } else if secs_since_change < 3600 {
1262        format!("{}m ago", secs_since_change / 60)
1263    } else {
1264        format!("{}h ago", secs_since_change / 3600)
1265    };
1266    let last_change_color = if secs_since_change < 60 {
1267        pal.up
1268    } else {
1269        pal.highlight
1270    };
1271
1272    let change_24h_str = state
1273        .change_24h()
1274        .map(|c| format!("{}{:.2}%", if c >= 0.0 { "+" } else { "" }, c))
1275        .unwrap_or_else(|| NOT_AVAILABLE.to_string());
1276
1277    let market_cap_str = state
1278        .market_cap
1279        .map(scope::display::format_usd)
1280        .unwrap_or_else(|| "N/A".to_string());
1281
1282    // Liquidity and buy/sell counts exist only for DEX data.
1283    let dex_stat = |value: String| {
1284        if state.venue_only {
1285            NOT_AVAILABLE.to_string()
1286        } else {
1287            value
1288        }
1289    };
1290
1291    let mut rows = vec![
1292        Row::new(vec![
1293            Span::styled("Price", SECONDARY),
1294            Span::styled(format_price_usd(state.current_price), Style::new().bold()),
1295        ]),
1296        Row::new(vec![
1297            Span::styled("5m Chg", SECONDARY),
1298            Span::styled(change_5m_str, Style::new().fg(change_5m_color)),
1299        ]),
1300        Row::new(vec![
1301            Span::styled("Last Δ", SECONDARY),
1302            Span::styled(last_change_str, Style::new().fg(last_change_color)),
1303        ]),
1304        Row::new(vec![
1305            Span::styled("24h Chg", SECONDARY),
1306            Span::raw(change_24h_str),
1307        ]),
1308        Row::new(vec![
1309            Span::styled("Liq", SECONDARY),
1310            Span::raw(dex_stat(scope::display::format_usd(state.liquidity_usd))),
1311        ]),
1312        Row::new(vec![
1313            Span::styled("Vol 24h", SECONDARY),
1314            Span::raw(
1315                state
1316                    .volume_usd()
1317                    .map(scope::display::format_usd)
1318                    .unwrap_or_else(|| NOT_AVAILABLE.to_string()),
1319            ),
1320        ]),
1321        Row::new(vec![
1322            Span::styled("Mkt Cap", SECONDARY),
1323            Span::raw(market_cap_str),
1324        ]),
1325        Row::new(vec![
1326            Span::styled("Buys", SECONDARY),
1327            Span::styled(
1328                dex_stat(state.buys_24h.to_string()),
1329                Style::new().fg(pal.up),
1330            ),
1331        ]),
1332        Row::new(vec![
1333            Span::styled("Sells", SECONDARY),
1334            Span::styled(
1335                dex_stat(state.sells_24h.to_string()),
1336                Style::new().fg(pal.down),
1337            ),
1338        ]),
1339    ];
1340
1341    // Add holder count if available and the widget is enabled
1342    if state.widgets.holder_count
1343        && let Some(count) = state.holder_count
1344    {
1345        rows.push(Row::new(vec![
1346            Span::styled("Holders", SECONDARY),
1347            Span::styled(format_number(count as f64), Style::new().fg(pal.highlight)),
1348        ]));
1349    }
1350
1351    let table = Table::new(rows, [Constraint::Length(8), Constraint::Min(10)]).block(
1352        Block::default()
1353            .title(" ◉ Key Metrics ")
1354            .borders(Borders::ALL)
1355            .border_style(crate::cli::theme::ACCENT),
1356    );
1357
1358    f.render_widget(table, chunks[1]);
1359}
1360
1361/// Renders the order book panel for the Exchange layout.
1362///
1363/// Shows asks (descending), spread, bids (descending) with depth bars.
1364/// The panel's spread row. Spread and percent come from `OrderBook`, the same
1365/// source as `market summary` and the web API.
1366pub(crate) fn spread_line(book: &scope::market::OrderBook) -> String {
1367    book.spread()
1368        .zip(book.spread_pct())
1369        .map(|(s, pct)| format!("  Spread: {:.6} ({:.3}%)", s, pct))
1370        .unwrap_or_else(|| "  Spread: --".to_string())
1371}
1372
1373pub(crate) fn render_order_book_panel(f: &mut Frame, area: Rect, state: &MonitorState) {
1374    let pal = state.palette();
1375
1376    let book = match &state.order_book {
1377        Some(b) if !state.venue_only => b,
1378        _ => {
1379            render_empty_panel(f, area, order_book_title(state), state.venue_only);
1380            return;
1381        }
1382    };
1383
1384    let inner_height = area.height.saturating_sub(2) as usize; // minus borders
1385    if inner_height < 3 {
1386        return;
1387    }
1388
1389    // Allocate rows: half for asks, 1 for spread, half for bids
1390    let ask_rows = (inner_height.saturating_sub(1)) / 2;
1391    let bid_rows = inner_height.saturating_sub(ask_rows).saturating_sub(1);
1392
1393    // Find max quantity for bar scaling
1394    let max_qty = book
1395        .asks
1396        .iter()
1397        .chain(book.bids.iter())
1398        .map(|l| l.quantity)
1399        .fold(0.0_f64, f64::max)
1400        .max(0.001);
1401
1402    let inner_width = area.width.saturating_sub(2) as usize;
1403    // Bar takes ~30% of width, rest is price/qty text
1404    let bar_width_max = (inner_width as f64 * 0.3).round() as usize;
1405
1406    let mut lines: Vec<Line> = Vec::with_capacity(inner_height);
1407
1408    // --- Ask side (show in reverse so lowest ask is nearest the spread) ---
1409    let visible_asks: Vec<_> = book.asks.iter().take(ask_rows).collect();
1410    // Pad with empty lines if fewer asks than rows
1411    for _ in 0..ask_rows.saturating_sub(visible_asks.len()) {
1412        lines.push(Line::from(""));
1413    }
1414    for level in visible_asks.iter().rev() {
1415        let bar_len = ((level.quantity / max_qty) * bar_width_max as f64).round() as usize;
1416        let bar = "█".repeat(bar_len);
1417        let price_str = format!("{:.6}", level.price);
1418        let qty_str = format_number(level.quantity);
1419        let val_str = format_number(level.value());
1420        let padding = inner_width
1421            .saturating_sub(bar_len)
1422            .saturating_sub(price_str.len())
1423            .saturating_sub(qty_str.len())
1424            .saturating_sub(val_str.len())
1425            .saturating_sub(4); // spaces between columns
1426        lines.push(Line::from(vec![
1427            Span::styled(bar, Style::new().fg(pal.down).dim()),
1428            Span::raw(" "),
1429            Span::styled(price_str, Style::new().fg(pal.down)),
1430            Span::raw(" ".repeat(padding.max(1))),
1431            Span::styled(qty_str, Style::new().fg(pal.neutral)),
1432            Span::raw(" "),
1433            Span::styled(val_str, SECONDARY),
1434        ]));
1435    }
1436
1437    // --- Spread line ---
1438    let spread = spread_line(book);
1439    lines.push(Line::from(Span::styled(
1440        spread,
1441        crate::cli::theme::strong(Role::Warn),
1442    )));
1443
1444    // --- Bid side ---
1445    for level in book.bids.iter().take(bid_rows) {
1446        let bar_len = ((level.quantity / max_qty) * bar_width_max as f64).round() as usize;
1447        let bar = "█".repeat(bar_len);
1448        let price_str = format!("{:.6}", level.price);
1449        let qty_str = format_number(level.quantity);
1450        let val_str = format_number(level.value());
1451        let padding = inner_width
1452            .saturating_sub(bar_len)
1453            .saturating_sub(price_str.len())
1454            .saturating_sub(qty_str.len())
1455            .saturating_sub(val_str.len())
1456            .saturating_sub(4);
1457        lines.push(Line::from(vec![
1458            Span::styled(bar, Style::new().fg(pal.up).dim()),
1459            Span::raw(" "),
1460            Span::styled(price_str, Style::new().fg(pal.up)),
1461            Span::raw(" ".repeat(padding.max(1))),
1462            Span::styled(qty_str, Style::new().fg(pal.neutral)),
1463            Span::raw(" "),
1464            Span::styled(val_str, SECONDARY),
1465        ]));
1466    }
1467
1468    let block = Block::default()
1469        .title(order_book_title(state))
1470        .borders(Borders::ALL)
1471        .border_style(crate::cli::theme::SECONDARY);
1472
1473    let paragraph = Paragraph::new(lines).block(block);
1474    f.render_widget(paragraph, area);
1475}
1476
1477/// The order book title names where the book comes from: the venue, or
1478/// the synthetic source the core reports. Venue-only mode without a venue
1479/// book has none.
1480pub(crate) fn order_book_title(state: &MonitorState) -> String {
1481    let Some(book) = &state.order_book else {
1482        if state.venue_only {
1483            return format!(" ◈ Order Book {VENUE_ONLY_NOTE} ");
1484        }
1485        return format!(" ◈ Order Book {SYNTHETIC_NOTE} │ no data ");
1486    };
1487    let source = match &state.book_venue {
1488        Some(venue) => format!("({venue})"),
1489        None => format!("({})", book.source.label()),
1490    };
1491    format!(
1492        " ◈ Order Book {source} │ {} │ Ask {} │ Bid {} ",
1493        book.pair,
1494        format_number(book.total_depth(BookSide::Ask)),
1495        format_number(book.total_depth(BookSide::Bid)),
1496    )
1497}
1498
1499/// The recent trades title names where the trades come from, as
1500/// [`order_book_title`] does.
1501pub(crate) fn trades_title(state: &MonitorState) -> String {
1502    let source = match &state.trades_venue {
1503        Some(venue) => format!("({venue})"),
1504        None if state.venue_only => return format!(" ◈ Recent Trades {VENUE_ONLY_NOTE} "),
1505        None => SYNTHETIC_NOTE.to_string(),
1506    };
1507    match state.recent_trades.len() {
1508        0 => format!(" ◈ Recent Trades {source} │ no data "),
1509        n => format!(" ◈ Recent Trades {source} │ {n} "),
1510    }
1511}
1512
1513/// Draws a panel that has no data. In venue-only mode the body says why,
1514/// so a title cut by a narrow panel still leaves the reason on screen.
1515fn render_empty_panel(f: &mut Frame, area: Rect, title: String, venue_only: bool) {
1516    let block = Block::default()
1517        .title(title)
1518        .borders(Borders::ALL)
1519        .border_style(crate::cli::theme::SECONDARY);
1520    let body = if venue_only {
1521        "Not available in venue-only mode."
1522    } else {
1523        ""
1524    };
1525    let body = Paragraph::new(Span::styled(body, SECONDARY))
1526        .wrap(Wrap { trim: true })
1527        .block(block);
1528    f.render_widget(body, area);
1529}
1530
1531/// Renders the recent trades panel for the Exchange layout.
1532///
1533/// Displays a scrolling list of recent trades with time, side (buy/sell),
1534/// price, and quantity. Buy trades are green, sell trades are red.
1535pub(crate) fn render_recent_trades_panel(f: &mut Frame, area: Rect, state: &MonitorState) {
1536    let pal = state.palette();
1537
1538    if state.venue_only || state.recent_trades.is_empty() {
1539        render_empty_panel(f, area, trades_title(state), state.venue_only);
1540        return;
1541    }
1542
1543    let inner_height = area.height.saturating_sub(2) as usize;
1544    let inner_width = area.width.saturating_sub(2) as usize;
1545
1546    // Column widths
1547    let time_width = 8; // HH:MM:SS
1548    let side_width = 4; // BUY / SELL
1549    let price_width = inner_width
1550        .saturating_sub(time_width)
1551        .saturating_sub(side_width)
1552        .saturating_sub(3) // separators
1553        / 2;
1554    let qty_width = inner_width
1555        .saturating_sub(time_width)
1556        .saturating_sub(side_width)
1557        .saturating_sub(price_width)
1558        .saturating_sub(3);
1559
1560    let mut lines: Vec<Line> = Vec::with_capacity(inner_height);
1561
1562    // Header row
1563    lines.push(Line::from(vec![
1564        Span::styled(format!("{:<time_width$}", "Time"), SECONDARY.bold()),
1565        Span::raw(" "),
1566        Span::styled(format!("{:<side_width$}", "Side"), SECONDARY.bold()),
1567        Span::raw(" "),
1568        Span::styled(format!("{:>price_width$}", "Price"), SECONDARY.bold()),
1569        Span::raw(" "),
1570        Span::styled(format!("{:>qty_width$}", "Qty"), SECONDARY.bold()),
1571    ]));
1572
1573    // Trade rows (most recent first)
1574    let visible_count = inner_height.saturating_sub(1); // minus header
1575    for trade in state.recent_trades.iter().rev().take(visible_count) {
1576        let (side_str, side_color) = match trade.side {
1577            TradeSide::Buy => ("BUY ", pal.up),
1578            TradeSide::Sell => ("SELL", pal.down),
1579        };
1580
1581        // Format timestamp (HH:MM:SS from epoch ms)
1582        let secs = (trade.timestamp_ms / 1000) as i64;
1583        let hours = (secs / 3600) % 24;
1584        let mins = (secs / 60) % 60;
1585        let sec = secs % 60;
1586        let time_str = format!("{:02}:{:02}:{:02}", hours, mins, sec);
1587
1588        let price_str = if trade.price >= 1000.0 {
1589            format!("{:.2}", trade.price)
1590        } else if trade.price >= 1.0 {
1591            format!("{:.4}", trade.price)
1592        } else {
1593            format!("{:.6}", trade.price)
1594        };
1595
1596        let qty_str = format_number(trade.quantity);
1597
1598        lines.push(Line::from(vec![
1599            Span::styled(format!("{:<time_width$}", time_str), SECONDARY),
1600            Span::raw(" "),
1601            Span::styled(
1602                format!("{:<side_width$}", side_str),
1603                Style::new().fg(side_color),
1604            ),
1605            Span::raw(" "),
1606            Span::styled(
1607                format!("{:>price_width$}", price_str),
1608                Style::new().fg(side_color),
1609            ),
1610            Span::raw(" "),
1611            Span::styled(
1612                format!("{:>qty_width$}", qty_str),
1613                Style::new().fg(pal.neutral),
1614            ),
1615        ]));
1616    }
1617
1618    let block = Block::default()
1619        .title(trades_title(state))
1620        .borders(Borders::ALL)
1621        .border_style(crate::cli::theme::SECONDARY);
1622
1623    let paragraph = Paragraph::new(lines).block(block);
1624    f.render_widget(paragraph, area);
1625}
1626
1627/// Renders the market info panel for the Exchange layout.
1628///
1629/// Shows per-pair breakdown (DEX, volume, liquidity), 24h stats,
1630/// token metadata (links, creation date), and aggregated metrics.
1631pub(crate) fn render_market_info_panel(f: &mut Frame, area: Rect, state: &MonitorState) {
1632    let pal = state.palette();
1633
1634    // Split into left (pair table) and right (token info) columns
1635    let cols = Layout::default()
1636        .direction(Direction::Horizontal)
1637        .constraints([Constraint::Percentage(60), Constraint::Percentage(40)])
1638        .split(area);
1639
1640    // ── Left: Trading pairs table ──
1641    {
1642        let header = Row::new(vec!["DEX / Pair", "Volume 24h", "Liquidity", "Δ 24h"])
1643            .style(crate::cli::theme::strong(Role::Accent))
1644            .bottom_margin(0);
1645
1646        let rows: Vec<Row> = state
1647            .dex_pairs
1648            .iter()
1649            .take(cols[0].height.saturating_sub(3) as usize) // fit available rows
1650            .map(|p| {
1651                let pair_label = format!("{}/{}", p.base_token, p.quote_token);
1652                let dex_and_pair = format!("{} {}", p.dex_name, pair_label);
1653                let vol = format_number(p.volume_24h);
1654                let liq = format_number(p.liquidity_usd);
1655                let change_str = format!("{:+.1}%", p.price_change_24h);
1656                let change_color = if p.price_change_24h >= 0.0 {
1657                    pal.up
1658                } else {
1659                    pal.down
1660                };
1661                Row::new(vec![
1662                    ratatui::text::Text::from(dex_and_pair),
1663                    ratatui::text::Text::styled(vol, Style::new().fg(pal.neutral)),
1664                    ratatui::text::Text::styled(liq, Style::new().fg(pal.volume_bar)),
1665                    ratatui::text::Text::styled(change_str, Style::new().fg(change_color)),
1666                ])
1667            })
1668            .collect();
1669
1670        let widths = [
1671            Constraint::Percentage(40),
1672            Constraint::Percentage(22),
1673            Constraint::Percentage(22),
1674            Constraint::Percentage(16),
1675        ];
1676
1677        let table = Table::new(rows, widths).header(header).block(
1678            Block::default()
1679                .title(format!(" ◫ Trading Pairs ({}) ", state.dex_pairs.len()))
1680                .borders(Borders::ALL)
1681                .border_style(crate::cli::theme::SECONDARY),
1682        );
1683
1684        f.render_widget(table, cols[0]);
1685    }
1686
1687    // ── Right: Token info + aggregated metrics ──
1688    {
1689        let mut info_lines: Vec<Line> = Vec::new();
1690
1691        // Price summary
1692        let price_color = match state.change_24h() {
1693            Some(c) if c >= 0.0 => pal.up,
1694            Some(_) => pal.down,
1695            None => pal.neutral,
1696        };
1697        info_lines.push(Line::from(vec![
1698            Span::styled(" Price  ", SECONDARY),
1699            Span::styled(format_price_usd(state.current_price), Style::new().bold()),
1700        ]));
1701
1702        // Multi-timeframe changes
1703        let short = |v: f64| state.short_changes_known().then_some(v);
1704        let changes = [
1705            ("5m", short(state.price_change_5m)),
1706            ("1h", short(state.price_change_1h)),
1707            ("6h", short(state.price_change_6h)),
1708            ("24h", state.change_24h()),
1709        ];
1710        let change_spans: Vec<Span> = changes
1711            .iter()
1712            .flat_map(|(label, val)| {
1713                let (text, color) = match val {
1714                    Some(v) if *v >= 0.0 => (format!("{:+.2}%", v), pal.up),
1715                    Some(v) => (format!("{:+.2}%", v), pal.down),
1716                    None => (NOT_AVAILABLE.to_string(), pal.neutral),
1717                };
1718                vec![
1719                    Span::styled(format!(" {}: ", label), SECONDARY),
1720                    Span::styled(text, Style::new().fg(color)),
1721                ]
1722            })
1723            .collect();
1724        info_lines.push(Line::from(change_spans));
1725
1726        info_lines.push(Line::from(""));
1727
1728        // Volume & Liquidity
1729        info_lines.push(Line::from(vec![
1730            Span::styled(" Vol 24h ", SECONDARY),
1731            Span::styled(
1732                state
1733                    .volume_usd()
1734                    .map(scope::display::format_usd)
1735                    .unwrap_or_else(|| NOT_AVAILABLE.to_string()),
1736                Style::new().fg(pal.neutral),
1737            ),
1738        ]));
1739        let liq = if state.venue_only {
1740            NOT_AVAILABLE.to_string()
1741        } else {
1742            scope::display::format_usd(state.liquidity_usd)
1743        };
1744        info_lines.push(Line::from(vec![
1745            Span::styled(" Liq     ", SECONDARY),
1746            Span::styled(liq, Style::new().fg(pal.volume_bar)),
1747        ]));
1748
1749        // Market cap / FDV
1750        if let Some(mc) = state.market_cap {
1751            info_lines.push(Line::from(vec![
1752                Span::styled(" MCap    ", SECONDARY),
1753                Span::styled(scope::display::format_usd(mc), Style::new().fg(pal.neutral)),
1754            ]));
1755        }
1756        if let Some(fdv) = state.fdv {
1757            info_lines.push(Line::from(vec![
1758                Span::styled(" FDV     ", SECONDARY),
1759                Span::styled(
1760                    scope::display::format_usd(fdv),
1761                    Style::new().fg(pal.neutral),
1762                ),
1763            ]));
1764        }
1765
1766        // Buy/sell stats
1767        info_lines.push(Line::from(""));
1768        let total_txs = state.buys_24h + state.sells_24h;
1769        let buy_pct = if total_txs > 0 {
1770            (state.buys_24h as f64 / total_txs as f64) * 100.0
1771        } else {
1772            50.0
1773        };
1774        let (buys, sells) = if state.venue_only {
1775            (NOT_AVAILABLE.to_string(), NOT_AVAILABLE.to_string())
1776        } else {
1777            (
1778                format!("{} ({:.0}%)", state.buys_24h, buy_pct),
1779                format!("{} ({:.0}%)", state.sells_24h, 100.0 - buy_pct),
1780            )
1781        };
1782        info_lines.push(Line::from(vec![
1783            Span::styled(" Buys    ", SECONDARY),
1784            Span::styled(buys, Style::new().fg(pal.up)),
1785        ]));
1786        info_lines.push(Line::from(vec![
1787            Span::styled(" Sells   ", SECONDARY),
1788            Span::styled(sells, Style::new().fg(pal.down)),
1789        ]));
1790
1791        // Holder count
1792        if let Some(holders) = state.holder_count {
1793            info_lines.push(Line::from(vec![
1794                Span::styled(" Holders ", SECONDARY),
1795                Span::styled(format_number(holders as f64), Style::new().fg(pal.neutral)),
1796            ]));
1797        }
1798
1799        // Listed since
1800        if let Some(ts) = state.earliest_pair_created_at {
1801            let dt = chrono::DateTime::from_timestamp(ts, 0)
1802                .map(|d| d.format("%Y-%m-%d").to_string())
1803                .unwrap_or_else(|| "?".to_string());
1804            info_lines.push(Line::from(vec![
1805                Span::styled(" Listed  ", SECONDARY),
1806                Span::styled(dt, Style::new().fg(pal.neutral)),
1807            ]));
1808        }
1809
1810        // Links
1811        if !state.websites.is_empty() || !state.socials.is_empty() {
1812            info_lines.push(Line::from(""));
1813            let mut link_spans = vec![Span::styled(" Links   ", SECONDARY)];
1814            for (platform, _url) in &state.socials {
1815                link_spans.push(Span::styled(
1816                    format!("[{}] ", platform),
1817                    crate::cli::theme::ACCENT,
1818                ));
1819            }
1820            for url in &state.websites {
1821                // Show domain only
1822                let domain = url
1823                    .trim_start_matches("https://")
1824                    .trim_start_matches("http://")
1825                    .split('/')
1826                    .next()
1827                    .unwrap_or(url);
1828                link_spans.push(Span::styled(
1829                    format!("[{}] ", domain),
1830                    crate::cli::theme::LINK,
1831                ));
1832            }
1833            info_lines.push(Line::from(link_spans));
1834        }
1835
1836        let title = format!(" ◉ {} ({}) ", state.symbol, state.name);
1837        let block = Block::default()
1838            .title(title)
1839            .borders(Borders::ALL)
1840            .border_style(Style::new().fg(price_color));
1841
1842        let paragraph = Paragraph::new(info_lines).block(block);
1843        f.render_widget(paragraph, cols[1]);
1844    }
1845}
1846
1847/// Renders the footer with status and controls.
1848/// Every monitor key, for the `?` overlay (#68).
1849pub(crate) const MONITOR_KEYS: &[(&str, &str)] = &[
1850    ("q, Esc, Ctrl-C", "quit"),
1851    ("r", "refresh now"),
1852    ("p, Space", "pause or resume"),
1853    ("Shift-P", "auto-pause on input"),
1854    ("e", "start or stop the CSV export"),
1855    ("+ / -", "slower or faster refresh"),
1856    ("1-6", "time period 1m, 5m, 15m, 1h, 4h, 1d"),
1857    ("t, Tab", "next time period"),
1858    ("c", "chart: line, candles, volume profile"),
1859    ("s", "scale: linear or log"),
1860    ("/", "color scheme"),
1861    ("l / h", "next or previous layout"),
1862    ("a", "automatic layout"),
1863    ("w then 1-5", "show or hide a widget"),
1864    ("j / k", "scroll the activity log"),
1865    ("?", "this list (any key closes it)"),
1866];
1867
1868/// The `?` overlay: every key and what it does.
1869fn render_keys_overlay(f: &mut Frame) {
1870    let area = f.area();
1871    let width = 56.min(area.width.saturating_sub(4));
1872    let height = (MONITOR_KEYS.len() as u16 + 2).min(area.height.saturating_sub(2));
1873    let rect = Rect::new(
1874        area.x + area.width.saturating_sub(width) / 2,
1875        area.y + area.height.saturating_sub(height) / 2,
1876        width,
1877        height,
1878    );
1879    let lines: Vec<Line> = MONITOR_KEYS
1880        .iter()
1881        .map(|(k, what)| {
1882            Line::from(vec![
1883                Span::styled(format!(" {k:<16}"), crate::cli::theme::KEY),
1884                Span::styled((*what).to_string(), SECONDARY),
1885            ])
1886        })
1887        .collect();
1888    f.render_widget(ratatui::widgets::Clear, rect);
1889    f.render_widget(
1890        Paragraph::new(lines).block(
1891            Block::default()
1892                .title(" Keys ")
1893                .borders(Borders::ALL)
1894                .border_style(crate::cli::theme::ACCENT),
1895        ),
1896        rect,
1897    );
1898}
1899
1900pub(crate) fn render_footer(f: &mut Frame, area: Rect, state: &MonitorState) {
1901    let footer = Paragraph::new(Line::from(footer_spans(state)))
1902        .block(Block::default().borders(Borders::ALL));
1903    f.render_widget(footer, area);
1904}
1905
1906/// The footer line. Quit comes first and PAUSED next, so a narrow
1907/// terminal still shows them; errors go to the header, not here.
1908pub(crate) fn footer_spans(state: &MonitorState) -> Vec<Span<'static>> {
1909    let elapsed = state.last_update.elapsed().as_secs();
1910
1911    // Calculate time since last price change
1912    let now_ts = chrono::Utc::now().timestamp() as f64;
1913    let secs_since_change = (now_ts - state.last_price_change_at).max(0.0) as u64;
1914    let price_change_str = if secs_since_change < 60 {
1915        format!("{}s", secs_since_change)
1916    } else if secs_since_change < 3600 {
1917        format!("{}m", secs_since_change / 60)
1918    } else {
1919        format!("{}h", secs_since_change / 3600)
1920    };
1921
1922    // Get data stats
1923    let (synthetic_count, real_count) = state.data_stats();
1924    let memory_bytes = state.memory_usage();
1925    let memory_str = if memory_bytes >= 1024 * 1024 {
1926        format!("{:.1}MB", memory_bytes as f64 / (1024.0 * 1024.0))
1927    } else if memory_bytes >= 1024 {
1928        format!("{:.1}KB", memory_bytes as f64 / 1024.0)
1929    } else {
1930        format!("{}B", memory_bytes)
1931    };
1932
1933    let stats = Span::styled(
1934        format!(
1935            "↻ {}s of {}s │ Δ {} │ {} pts │ {}",
1936            elapsed,
1937            state.refresh_rate.as_secs(),
1938            price_change_str,
1939            synthetic_count + real_count,
1940            memory_str
1941        ),
1942        SECONDARY,
1943    );
1944
1945    // Keys in brackets read without color too (#68).
1946    let key = |k: &'static str, word: String| {
1947        [
1948            Span::raw("["),
1949            Span::styled(k, crate::cli::theme::KEY),
1950            Span::raw(format!("]{word} ")),
1951        ]
1952    };
1953    let mut spans: Vec<Span<'static>> = key("q", "uit".into()).to_vec();
1954    if state.paused {
1955        spans.push(Span::styled(
1956            "⏸ PAUSED ",
1957            crate::cli::theme::strong(Role::Warn),
1958        ));
1959    } else if state.is_auto_paused() {
1960        spans.push(Span::styled(
1961            "⏸ AUTO-PAUSED ",
1962            crate::cli::theme::strong(Role::Accent),
1963        ));
1964    }
1965
1966    // REC indicator when CSV export is active
1967    if state.export_active {
1968        spans.push(Span::styled("● REC ", crate::cli::theme::strong(Role::Err)));
1969    }
1970
1971    if state.widget_toggle_mode {
1972        spans.push(Span::styled(
1973            "widget 1-5? ",
1974            crate::cli::theme::strong(Role::Warn),
1975        ));
1976    }
1977    for part in [
1978        key("r", "efresh".into()),
1979        key("p", "ause".into()),
1980        key("e", "xport".into()),
1981        key("l", format!("ayout:{}", state.layout.label())),
1982        key("w", "idgets".into()),
1983        key("c", format!("hart:{}", state.chart_mode.label())),
1984        key("s", format!("cale:{}", state.scale_mode.label())),
1985        key("/", format!("colors:{}", state.color_scheme.label())),
1986        key("t", "ime".into()),
1987        key("?", "keys".into()),
1988    ] {
1989        spans.extend(part);
1990    }
1991    spans.extend([Span::raw("║ "), stats]);
1992    spans
1993}