// Copyright 2026 California Institute of Technology
//
// You should have received a copy of the licensing terms for this
// software included in the file “LICENSE” located in the top-level
// directory of this package. If you did not, you can view a copy at
// http://dcc.ligo.org/M1500244/LICENSE.txt

//! The TUI, with two modes: a list (two-column name | value table with a
//! selection) and a scope (chart of the selected channel's history).  Runs
//! on the main thread; each frame snapshots the store the capture thread
//! writes into, and every [`history::SAMPLE_PERIOD`] a sample per channel
//! is pushed into the app-side history buffers.

use crate::capture::SignalStore;
use crate::channel::{ChannelConfig, Range};
use crate::clipboard;
use crate::command::Command;
use crate::history::{self, HistoryStore, Sample};
use crate::signal::{ChannelEntry, SignalData, SignalInfo};
use crossterm::event::{
    self, Event, KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind,
};
use ratatui::layout::{Constraint, Layout, Position, Rect};
use ratatui::style::Stylize;
use ratatui::symbols;
use ratatui::text::{Line, Span};
use ratatui::widgets::{Axis, Block, Chart, Clear, Dataset, GraphType, Paragraph, Row, Table};
use ratatui::{DefaultTerminal, Frame};
use std::collections::HashMap;
use std::time::{Duration, Instant};

/// How long to wait for input between frames (~10 Hz refresh).
const POLL_INTERVAL: Duration = Duration::from_millis(100);
/// How long a status-line notice ("copied ...", errors) stays up.
const NOTICE_DURATION: Duration = Duration::from_secs(2);
const DISCONNECTED: &str = "----";
/// Status-line room for the HH:MM:SS clock plus a leading space.
const CLOCK_WIDTH: u16 = 9;
/// Room the list table keeps for the value column when the preview pane
/// splits off; without a pane the value column takes all remaining width.
const LIST_VALUE_WIDTH: u16 = 16;
/// The preview pane only appears when at least this many columns remain
/// after the list takes its natural width.
const PREVIEW_MIN_WIDTH: u16 = 30;
/// The miniscope shows at most the newest 5 minutes of history
/// (at [`history::SAMPLE_PERIOD`] = 250 ms, 4 samples/s).
const PREVIEW_SAMPLES: usize = 1200;
/// Zooming in shows 20% less data; zooming out divides by the same factor
/// so in-then-out returns to the original window.
const ZOOM_FACTOR: f64 = 0.8;
/// The tightest zoom window: 1 minute, in seconds.
const MIN_ZOOM_SECS: f64 = 60.0;
/// A key pan moves the window by this fraction of the view span.
const PAN_FRACTION: f64 = 0.1;
/// Each cell of a right-button drag toward the right multiplies the view
/// span by this (3% less data per cell); a cell leftward divides by it.
/// Only the net displacement matters, so an out-and-back drag restores
/// the original window.
const ZOOM_DRAG_PER_CELL: f64 = 0.97;

const HELP_TEXT: &[(&str, &str)] = &[
    ("q, Esc", "quit"),
    ("Up, k", "select the previous channel"),
    ("Down, j", "select the next channel"),
    ("PgUp, C-b", "page up"),
    ("PgDn, C-f", "page down"),
    ("s, Enter", "scope the selected channel / back to list"),
    ("p", "pause/unpause updates"),
    ("<, >", "shrink / expand the history lookback"),
    ("+, -", "zoom the scope in / out"),
    ("right drag", "zoom the scope (right: in, left: out)"),
    ("h/l, arrows", "pan the zoomed scope (mouse drag works too)"),
    ("0", "restore the full scope view"),
    ("left click", "select a row"),
    ("wheel", "move the selection"),
    ("left drag", "scroll the list (selection stays)"),
    ("middle click", "copy channel name / value"),
    ("?", "show this help"),
];

/// Drive the UI until the user quits.  The caller owns terminal setup and
/// restore (and backend shutdown) so they happen even if this errors.
pub fn run(
    terminal: &mut DefaultTerminal,
    channels: Vec<ChannelConfig>,
    store: &SignalStore,
    lookback_minutes: usize,
) -> std::io::Result<()> {
    let mut app = App::new(channels, lookback_minutes);
    let mut last_sample = Instant::now();
    while app.running {
        let snapshot = store.lock().expect("store lock poisoned").clone();
        if last_sample.elapsed() >= history::SAMPLE_PERIOD {
            last_sample = Instant::now();
            app.apply(Command::TimerEvent, &snapshot);
        }
        app.refresh_display(&snapshot);
        terminal.draw(|frame| app.render(frame))?;
        if event::poll(POLL_INTERVAL)?
            && let Some(text) = app.handle_event(&event::read()?, &snapshot)
        {
            clipboard::copy(&text)?;
            app.set_notice(format!("copied \"{text}\""));
        }
    }
    Ok(())
}

/// List-mode state: the viewport plus the preview-pane copies.
struct ListState {
    /// Index of the first displayed row.
    scroll: usize,
    /// The last row of an in-progress left-button drag scroll.
    drag_row: Option<u16>,
    /// The preview-pane copies for the selected channel.
    preview: Preview,
}

impl ListState {
    fn new() -> Self {
        ListState {
            scroll: 0,
            drag_row: None,
            preview: Preview::default(),
        }
    }
}

/// The selected channel's preview copies: frozen while paused (like the
/// scope trace), rebuilt when live or when the selection moves.
#[derive(Default)]
struct Preview {
    /// Which channel the copies belong to; empty before the first refresh.
    channel: String,
    /// Miniscope trace: the newest [`PREVIEW_SAMPLES`] history samples.
    trace: Vec<Sample>,
    /// The channel's auxiliary information (y-axis range).
    range: Option<Range>,
    /// The cached backend description, if the channel ever connected.
    info: Option<SignalInfo>,
}

/// A zoomed scope window, expressed relative to the newest sample:
/// `span_secs` of data ending `right_offset_secs` behind the trace's right
/// edge.  Live, the window therefore stays a fixed distance behind "now"
/// and data flows through it; pause is the tool for holding a feature
/// still.
#[derive(Clone, Copy, Debug, PartialEq)]
struct ScopeView {
    span_secs: f64,
    right_offset_secs: f64,
}

/// An in-progress right-button drag zoom: the drag's last column and the
/// data offset pinned under the column where the drag began, so the sample
/// grabbed stays put while the span scales around it.
#[derive(Clone, Copy)]
struct ZoomDrag {
    last_column: u16,
    anchor_secs: f64,
}

/// Scope-mode state: which channel is plotted plus its auxiliary
/// information (the y-axis range), both captured at entry, and the view
/// window (`None` = the full history).
struct ScopeState {
    channel: String,
    range: Option<Range>,
    view: Option<ScopeView>,
    /// The last column of an in-progress left-button drag pan.
    pan_drag_column: Option<u16>,
    /// The in-progress right-button drag zoom, if any.
    zoom_drag: Option<ZoomDrag>,
}

/// Only one mode (and its state) is active at a time.
enum Mode {
    List(ListState),
    Scope(ScopeState),
}

struct App {
    /// Channels in file order (name + optional range); rows are looked up
    /// in `snapshot` by name.
    channels: Vec<ChannelConfig>,
    /// Width of the name column: the longest name (names are ASCII).
    name_width: u16,
    /// Index of the selected (focused) channel; shared by both modes.
    selected: usize,
    mode: Mode,
    /// The frozen-while-paused copy of the store used for display.
    snapshot: HashMap<String, ChannelEntry>,
    /// Master history buffers; always fed, even while paused.
    history: HistoryStore,
    /// The frozen-while-paused copy of the scoped channel's history.
    scope_trace: Vec<Sample>,
    /// `Some(moment)` while paused: when the pause began.  Shown in the
    /// PAUSED badge, and it is the wall-clock reference for the scope's
    /// right-edge x-axis label (the frozen trace ends at this moment).
    paused: Option<jiff::Zoned>,
    show_help: bool,
    running: bool,
    /// Rows the last list frame had space for; scrolling clamps against it.
    visible_rows: usize,
    /// Where the table was drawn last frame; clicks are hit-tested against it.
    table_area: Rect,
    /// Where the scope chart was drawn last frame; drag pans are measured
    /// against its width.
    scope_area: Rect,
    /// Status-line notice ("copied ...", errors) and when it was set.
    notice: Option<(String, Instant)>,
}

impl App {
    fn new(channels: Vec<ChannelConfig>, lookback_minutes: usize) -> Self {
        let name_width = channels.iter().map(|c| c.name.len()).max().unwrap_or(0) as u16;
        let history = HistoryStore::new(channels.iter().map(|c| c.name.as_str()), lookback_minutes);
        Self {
            channels,
            name_width,
            selected: 0,
            mode: Mode::List(ListState::new()),
            snapshot: HashMap::new(),
            history,
            scope_trace: Vec::new(),
            paused: None,
            show_help: false,
            running: true,
            visible_rows: 0,
            table_area: Rect::default(),
            scope_area: Rect::default(),
            notice: None,
        }
    }

    /// Per-frame refresh of the display copies (skipped while paused), plus
    /// the check that a scoped channel is still plottable.
    fn refresh_display(&mut self, store_snapshot: &HashMap<String, ChannelEntry>) {
        if self.paused.is_none() {
            self.snapshot = store_snapshot.clone();
            if let Mode::Scope(scope) = &self.mode {
                self.scope_trace = self.history.samples_for(&scope.channel);
            }
        }
        if let Mode::Scope(scope) = &self.mode
            && is_string(self.snapshot.get(&scope.channel))
        {
            self.set_notice(format!("cannot scope string channel {}", scope.channel));
            self.enter_list_mode();
        }
        self.clamp_scope_view();
        self.refresh_preview();
    }

    /// Keep the scope view inside the data the trace actually holds — a
    /// history shrink may have moved the goalposts.  A window the data no
    /// longer fills falls back to the full view.  (While paused the trace
    /// is frozen, so this never disturbs a paused inspection.)
    fn clamp_scope_view(&mut self) {
        let available = trace_span_secs(&self.scope_trace);
        if let Mode::Scope(scope) = &mut self.mode
            && let Some(view) = &mut scope.view
        {
            if view.span_secs >= available {
                scope.view = None;
            } else {
                view.right_offset_secs = view.right_offset_secs.min(available - view.span_secs);
            }
        }
    }

    /// Rebuild the list-mode preview copies.  While paused they stay frozen
    /// for the channel they belong to, but a selection move still rebuilds
    /// them (info from the frozen snapshot, trace from the master history —
    /// the same live-history behavior as entering scope mode while paused).
    fn refresh_preview(&mut self) {
        let Mode::List(list) = &self.mode else {
            return;
        };
        let Some(config) = self.channels.get(self.selected) else {
            return;
        };
        if self.paused.is_some() && list.preview.channel == config.name {
            return;
        }
        let mut trace = self.history.samples_for(&config.name);
        if trace.len() > PREVIEW_SAMPLES {
            trace.drain(..trace.len() - PREVIEW_SAMPLES);
        }
        let preview = Preview {
            channel: config.name.clone(),
            trace,
            range: config.range,
            info: self
                .snapshot
                .get(&config.name)
                .and_then(|entry| entry.info.clone()),
        };
        if let Mode::List(list) = &mut self.mode {
            list.preview = preview;
        }
    }

    /// React to one input event.  Returns text the caller should copy to the
    /// clipboard (the I/O stays out of here so this is unit-testable).
    fn handle_event(
        &mut self,
        event: &Event,
        store_snapshot: &HashMap<String, ChannelEntry>,
    ) -> Option<String> {
        let command = Command::from_event(event)?;
        let command = self.transform(command)?;
        self.apply(command, store_snapshot)
    }

    /// Mode-aware translation: raw key/mouse commands become semantic
    /// commands or are consumed here (help toggling, selection clicks).
    /// Semantic commands pass through untouched.
    fn transform(&mut self, command: Command) -> Option<Command> {
        match command {
            Command::KeyboardEvent(key) => self.map_key(key),
            Command::MouseEvent(mouse) => self.map_mouse(&mouse),
            other => Some(other),
        }
    }

    fn map_key(&mut self, key: KeyEvent) -> Option<Command> {
        if self.show_help {
            // Any key closes the help overlay.
            self.show_help = false;
            return None;
        }
        let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
        match key.code {
            KeyCode::Char('q') | KeyCode::Esc => Some(Command::Quit),
            KeyCode::Char('p') => Some(Command::TogglePause),
            KeyCode::Char('>') => Some(Command::ExpandHistory),
            KeyCode::Char('<') => Some(Command::ShrinkHistory),
            KeyCode::Char('?') => {
                self.show_help = true;
                None
            }
            _ => match &self.mode {
                Mode::List(_) => match key.code {
                    KeyCode::Up | KeyCode::Char('k') => Some(Command::SelectUp),
                    KeyCode::Down | KeyCode::Char('j') => Some(Command::SelectDown),
                    KeyCode::PageUp => Some(Command::PageUp),
                    KeyCode::PageDown => Some(Command::PageDown),
                    KeyCode::Char('b') if ctrl => Some(Command::PageUp),
                    KeyCode::Char('f') if ctrl => Some(Command::PageDown),
                    KeyCode::Char('s') | KeyCode::Enter => Some(Command::SetScopeMode),
                    _ => None,
                },
                Mode::Scope(_) => match key.code {
                    KeyCode::Char('s') | KeyCode::Enter => Some(Command::SetListMode),
                    KeyCode::Left | KeyCode::Char('h') => Some(Command::PanLeft),
                    KeyCode::Right | KeyCode::Char('l') => Some(Command::PanRight),
                    // '=' is unshifted '+' on common layouts.
                    KeyCode::Char('+') | KeyCode::Char('=') => Some(Command::ZoomIn),
                    KeyCode::Char('-') => Some(Command::ZoomOut),
                    KeyCode::Char('0') => Some(Command::ResetView),
                    _ => None,
                },
            },
        }
    }

    /// List-mode mouse: a left press on a row selects it and anchors a
    /// drag scroll; each drag event scrolls the viewport by the rows moved
    /// since the last one, so the list follows the mouse — view only, the
    /// selection stays where it is.  The wheel moves the selection like
    /// the arrow keys.  Middle click copies.
    fn map_mouse(&mut self, mouse: &MouseEvent) -> Option<Command> {
        if self.show_help {
            return None;
        }
        if matches!(self.mode, Mode::Scope(_)) {
            return self.map_scope_mouse(mouse);
        }
        let in_table = self
            .table_area
            .contains(Position::new(mouse.column, mouse.row));
        match mouse.kind {
            MouseEventKind::Down(MouseButton::Left) => {
                if let Some(index) = self.row_at(mouse.column, mouse.row) {
                    self.selected = index;
                    self.scroll_selected_into_view();
                    if let Mode::List(list) = &mut self.mode {
                        list.drag_row = Some(mouse.row);
                    }
                }
                None
            }
            MouseEventKind::Drag(MouseButton::Left) => {
                let Mode::List(list) = &mut self.mode else {
                    return None;
                };
                let anchor = list.drag_row?;
                list.drag_row = Some(mouse.row);
                // Dragging up pulls later rows into view: the viewport
                // moves toward the end of the list.
                let rows = i32::from(anchor) - i32::from(mouse.row);
                (rows != 0).then_some(Command::ScrollBy(rows))
            }
            MouseEventKind::Up(MouseButton::Left) => {
                if let Mode::List(list) = &mut self.mode {
                    list.drag_row = None;
                }
                None
            }
            MouseEventKind::ScrollUp => in_table.then_some(Command::SelectUp),
            MouseEventKind::ScrollDown => in_table.then_some(Command::SelectDown),
            MouseEventKind::Down(MouseButton::Middle) => self
                .click_target(mouse.column, mouse.row)
                .map(Command::ValueToClipboard),
            _ => None,
        }
    }

    /// Scope-mode drags.  Left button pans: a press in the chart anchors
    /// the drag, and each drag event pans by the distance dragged since the
    /// last one — proportional to the visible span per cell, so the trace
    /// follows the mouse.  Right button zooms (the ndscope gesture):
    /// dragging right zooms in, left zooms out, [`ZOOM_DRAG_PER_CELL`] per
    /// cell, anchored at the data offset grabbed by the press.
    fn map_scope_mouse(&mut self, mouse: &MouseEvent) -> Option<Command> {
        let secs_per_cell = self.scope_secs_per_cell();
        let grab_secs = self.scope_offset_at(mouse.column);
        let in_chart = self
            .scope_area
            .contains(Position::new(mouse.column, mouse.row));
        let Mode::Scope(scope) = &mut self.mode else {
            return None;
        };
        match mouse.kind {
            MouseEventKind::Down(MouseButton::Left) => {
                scope.pan_drag_column = in_chart.then_some(mouse.column);
                None
            }
            MouseEventKind::Drag(MouseButton::Left) => {
                let anchor = scope.pan_drag_column?;
                scope.pan_drag_column = Some(mouse.column);
                // Dragging the trace right reveals older data: the window's
                // offset behind "now" grows.
                let cells = f64::from(mouse.column) - f64::from(anchor);
                (cells != 0.0).then_some(Command::PanBy(cells * secs_per_cell))
            }
            MouseEventKind::Up(MouseButton::Left) => {
                scope.pan_drag_column = None;
                None
            }
            MouseEventKind::Down(MouseButton::Right) => {
                scope.zoom_drag = in_chart.then_some(ZoomDrag {
                    last_column: mouse.column,
                    anchor_secs: grab_secs,
                });
                None
            }
            MouseEventKind::Drag(MouseButton::Right) => {
                let drag = scope.zoom_drag.as_mut()?;
                let cells = f64::from(mouse.column) - f64::from(drag.last_column);
                drag.last_column = mouse.column;
                let anchor_secs = drag.anchor_secs;
                (cells != 0.0).then_some(Command::ZoomAbout {
                    factor: ZOOM_DRAG_PER_CELL.powf(cells),
                    anchor_secs,
                })
            }
            MouseEventKind::Up(MouseButton::Right) => {
                scope.zoom_drag = None;
                None
            }
            _ => None,
        }
    }

    /// The scope view's (span, right-edge offset) in seconds; the full
    /// trace at zero offset when no window is set.  Zeros in list mode.
    fn scope_window(&self) -> (f64, f64) {
        match &self.mode {
            Mode::Scope(scope) => match scope.view {
                Some(view) => (view.span_secs, view.right_offset_secs),
                None => (trace_span_secs(&self.scope_trace), 0.0),
            },
            Mode::List(_) => (0.0, 0.0),
        }
    }

    /// Seconds of data one chart column spans in the current scope view.
    fn scope_secs_per_cell(&self) -> f64 {
        let (span, _) = self.scope_window();
        // Two border columns; the y-axis labels eat a few more, but an
        // approximate drag feel is fine.
        span / f64::from(self.scope_area.width.saturating_sub(2).max(1))
    }

    /// The data offset (seconds behind the newest sample) under a chart
    /// column, with the same plot-width approximation as the drag pan.
    fn scope_offset_at(&self, column: u16) -> f64 {
        let (_, offset) = self.scope_window();
        // The rightmost plot column (inside the right border) shows the
        // window's right edge.
        let right = f64::from(self.scope_area.right().saturating_sub(2));
        offset + (right - f64::from(column)).max(0.0) * self.scope_secs_per_cell()
    }

    /// Apply a semantic command.  Returns text to copy for
    /// [`Command::ValueToClipboard`].
    fn apply(
        &mut self,
        command: Command,
        store_snapshot: &HashMap<String, ChannelEntry>,
    ) -> Option<String> {
        match command {
            Command::Quit => self.running = false,
            Command::TogglePause => {
                self.paused = match self.paused {
                    None => Some(jiff::Zoned::now()),
                    Some(_) => None,
                };
            }
            Command::SelectUp => {
                self.selected = self.selected.saturating_sub(1);
                self.scroll_selected_into_view();
            }
            Command::SelectDown => {
                if !self.channels.is_empty() {
                    self.selected = (self.selected + 1).min(self.channels.len() - 1);
                }
                self.scroll_selected_into_view();
            }
            Command::PageUp => {
                self.selected = self.selected.saturating_sub(self.visible_rows.max(1));
                self.scroll_selected_into_view();
            }
            Command::PageDown => {
                if !self.channels.is_empty() {
                    self.selected =
                        (self.selected + self.visible_rows.max(1)).min(self.channels.len() - 1);
                }
                self.scroll_selected_into_view();
            }
            Command::ScrollBy(rows) => self.scroll_list(rows),
            Command::SetScopeMode => self.enter_scope_mode(),
            Command::SetListMode => self.enter_list_mode(),
            Command::ExpandHistory => {
                self.resize_history(self.history.minutes() + history::HISTORY_STEP_MINUTES)
            }
            Command::ShrinkHistory => self.resize_history(
                self.history
                    .minutes()
                    .saturating_sub(history::HISTORY_STEP_MINUTES),
            ),
            Command::ZoomIn => self.zoom(ZOOM_FACTOR),
            Command::ZoomOut => self.zoom(1.0 / ZOOM_FACTOR),
            Command::ZoomAbout {
                factor,
                anchor_secs,
            } => self.zoom_about(factor, anchor_secs),
            Command::PanLeft => self.pan(self.pan_step_secs()),
            Command::PanRight => self.pan(-self.pan_step_secs()),
            Command::PanBy(secs) => self.pan(secs),
            Command::ResetView => {
                if let Mode::Scope(scope) = &mut self.mode {
                    scope.view = None;
                }
            }
            Command::TimerEvent => self.history.sample(store_snapshot),
            Command::ValueToClipboard(text) => return Some(text),
            // Raw events are translated in `transform`, never applied.
            Command::KeyboardEvent(_) | Command::MouseEvent(_) => {}
        }
        None
    }

    fn enter_scope_mode(&mut self) {
        let Some(config) = self.channels.get(self.selected) else {
            return;
        };
        if is_string(self.snapshot.get(&config.name)) {
            self.set_notice(format!("cannot scope string channel {}", config.name));
            return;
        }
        self.scope_trace = self.history.samples_for(&config.name);
        self.mode = Mode::Scope(ScopeState {
            channel: config.name.clone(),
            range: config.range,
            view: None,
            pan_drag_column: None,
            zoom_drag: None,
        });
    }

    /// Apply a history resize (the store clamps it) and report the result:
    /// the new lookback and its estimated memory footprint, or that a limit
    /// was already reached.
    fn resize_history(&mut self, minutes: usize) {
        let before = self.history.minutes();
        let applied = self.history.resize(minutes);
        if applied == before {
            let limit = if minutes < before {
                "minimum"
            } else {
                "maximum"
            };
            self.set_notice(format!(
                "history already at the {} {limit}",
                fmt_minutes(applied)
            ));
            return;
        }
        let bytes = history::memory_estimate(self.channels.len(), applied);
        self.set_notice(format!(
            "history: {} (~{})",
            fmt_minutes(applied),
            fmt_bytes(bytes)
        ));
        // A shrink may have pulled data out from under a zoomed scope view;
        // the trace itself catches up on the next live refresh.
        if self.paused.is_none() {
            if let Mode::Scope(scope) = &self.mode {
                self.scope_trace = self.history.samples_for(&scope.channel);
            }
            self.clamp_scope_view();
        }
    }

    /// Scale the scope window's span by `factor` (< 1 zooms in), anchored
    /// at the right edge: the keyboard zoom.
    fn zoom(&mut self, factor: f64) {
        let (_, offset) = self.scope_window();
        self.zoom_about(factor, offset);
    }

    /// Scale the scope window's span by `factor` (< 1 zooms in), keeping
    /// the sample `anchor_secs` behind the newest sample at the same
    /// window fraction — the data grabbed by a drag zoom stays put.  The
    /// span is clamped between [`MIN_ZOOM_SECS`] and the available data;
    /// at or past the full span the view returns to `None` (full view).
    fn zoom_about(&mut self, factor: f64, anchor_secs: f64) {
        let available = trace_span_secs(&self.scope_trace);
        let Mode::Scope(scope) = &mut self.mode else {
            return;
        };
        let (span, offset) = match scope.view {
            Some(view) => (view.span_secs, view.right_offset_secs),
            None => (available, 0.0),
        };
        let fraction = ((anchor_secs - offset) / span.max(1e-9)).clamp(0.0, 1.0);
        let span = (span * factor).max(MIN_ZOOM_SECS);
        let offset = anchor_secs - fraction * span;
        // The margin absorbs the float drift of the multiplicative steps:
        // zooming in and back out must land on the full view, not a
        // phantom window an epsilon short of it.
        scope.view = (span < available * (1.0 - 1e-9)).then(|| ScopeView {
            span_secs: span,
            right_offset_secs: offset.clamp(0.0, available - span),
        });
    }

    /// One key-pan step: [`PAN_FRACTION`] of the current view span.  Zero
    /// at the full view, where panning is a no-op anyway.
    fn pan_step_secs(&self) -> f64 {
        match &self.mode {
            Mode::Scope(scope) => scope.view.map_or(0.0, |view| view.span_secs * PAN_FRACTION),
            Mode::List(_) => 0.0,
        }
    }

    /// Slide a zoomed scope window `delta_secs` toward older data
    /// (negative: newer), clamped inside the available data.  The full
    /// view has nowhere to pan.
    fn pan(&mut self, delta_secs: f64) {
        let available = trace_span_secs(&self.scope_trace);
        if let Mode::Scope(scope) = &mut self.mode
            && let Some(view) = &mut scope.view
        {
            view.right_offset_secs = (view.right_offset_secs + delta_secs)
                .clamp(0.0, (available - view.span_secs).max(0.0));
        }
    }

    fn enter_list_mode(&mut self) {
        self.mode = Mode::List(ListState::new());
        // Re-derive the viewport from the selection; the preview fills on
        // the next refresh.
        self.scroll_selected_into_view();
    }

    /// Clamp the list viewport and pull it along so the selection stays
    /// visible.  Every selection move goes through here; a drag scroll
    /// does not, so the viewport can wander off the selection until the
    /// next move snaps it back.  No-op in scope mode or before the first
    /// frame sized us.
    fn scroll_selected_into_view(&mut self) {
        let (visible, len, selected) = (self.visible_rows, self.channels.len(), self.selected);
        if let Mode::List(list) = &mut self.mode
            && visible > 0
        {
            list.scroll = list.scroll.min(len.saturating_sub(visible));
            if selected < list.scroll {
                list.scroll = selected;
            } else if selected >= list.scroll + visible {
                list.scroll = selected + 1 - visible;
            }
        }
    }

    /// Scroll the list viewport `rows` rows toward the end of the list
    /// (negative: the start), clamped to the list bounds.  The selection
    /// stays put — it may leave the screen.
    fn scroll_list(&mut self, rows: i32) {
        let (visible, len) = (self.visible_rows, self.channels.len());
        if let Mode::List(list) = &mut self.mode
            && visible > 0
        {
            list.scroll = list
                .scroll
                .saturating_add_signed(rows as isize)
                .min(len.saturating_sub(visible));
        }
    }

    /// Per-frame viewport clamp: keep the scroll inside the list bounds
    /// (a resize may have grown the window) without touching where a drag
    /// scroll put it.
    fn clamp_list_scroll(&mut self) {
        let (visible, len) = (self.visible_rows, self.channels.len());
        if let Mode::List(list) = &mut self.mode
            && visible > 0
        {
            list.scroll = list.scroll.min(len.saturating_sub(visible));
        }
    }

    fn set_notice(&mut self, text: String) {
        self.notice = Some((text, Instant::now()));
    }

    fn render(&mut self, frame: &mut Frame) {
        let [main_area, status_area] =
            Layout::vertical([Constraint::Min(0), Constraint::Length(1)]).areas(frame.area());
        if matches!(self.mode, Mode::Scope(_)) {
            self.render_scope(frame, main_area);
        } else {
            self.render_list(frame, main_area);
        }
        self.render_status(frame, status_area);
        if self.show_help {
            render_help(frame);
        }
    }

    fn render_list(&mut self, frame: &mut Frame, area: Rect) {
        let (table_area, preview_area) = split_list_area(area, self.name_width);
        self.visible_rows = table_area.height as usize;
        self.table_area = table_area;
        self.clamp_list_scroll();
        let scroll = self.list_scroll();

        let rows = self
            .channels
            .iter()
            .enumerate()
            .skip(scroll)
            .take(self.visible_rows)
            .map(|(index, config)| {
                let value = match self
                    .snapshot
                    .get(&config.name)
                    .and_then(|entry| entry.value.data.as_ref())
                {
                    Some(data) => {
                        let span = Span::raw(data.to_string());
                        if is_out_of_range(config.range, data) {
                            span.red()
                        } else {
                            span
                        }
                    }
                    None => Span::from(DISCONNECTED).dim(),
                };
                let selected = index == self.selected;
                let (lead, trail) = if selected { (">", "<") } else { (" ", " ") };
                let row = Row::new([
                    Span::raw(lead),
                    Span::raw(config.name.clone()),
                    value,
                    Span::raw(trail),
                ]);
                if selected { row.bold() } else { row }
            });
        // Marker | name | value | marker, one-cell spacing between columns.
        let table = Table::new(
            rows,
            [
                Constraint::Length(1),
                Constraint::Length(self.name_width),
                Constraint::Fill(1),
                Constraint::Length(1),
            ],
        );
        frame.render_widget(table, table_area);
        if let Some(preview_area) = preview_area {
            self.render_preview(frame, preview_area);
        }
    }

    /// The right-hand preview pane: a miniscope of the selected channel over
    /// its info block.  String channels and channels that never connected
    /// (no cached description) get the info block only.
    fn render_preview(&mut self, frame: &mut Frame, area: Rect) {
        let Mode::List(list) = &self.mode else {
            return;
        };
        let preview = &list.preview;
        if preview.channel.is_empty() {
            return; // nothing selected yet (empty channel list)
        }
        let entry = self.snapshot.get(&preview.channel);
        let lines = info_lines(entry, preview.info.as_ref());
        if !preview_has_chart(entry, preview.info.as_ref()) {
            let block = Block::bordered().title(format!(" {} ", preview.channel));
            frame.render_widget(Paragraph::new(lines).block(block), area);
            return;
        }
        let [chart_area, info_area] =
            Layout::vertical([Constraint::Min(3), Constraint::Length(lines.len() as u16)])
                .areas(area);
        render_chart(
            frame,
            chart_area,
            &preview.channel,
            &preview.trace,
            preview.range,
            self.paused.as_ref(),
            None, // the miniscope never zooms or pans
        );
        frame.render_widget(Paragraph::new(lines), info_area);
    }

    fn render_scope(&mut self, frame: &mut Frame, area: Rect) {
        self.scope_area = area;
        let Mode::Scope(scope) = &self.mode else {
            return;
        };
        render_chart(
            frame,
            area,
            &scope.channel,
            &self.scope_trace,
            scope.range,
            self.paused.as_ref(),
            scope.view,
        );
    }

    fn render_status(&self, frame: &mut Frame, area: Rect) {
        let [line_area, clock_area] =
            Layout::horizontal([Constraint::Min(0), Constraint::Length(CLOCK_WIDTH)]).areas(area);

        let mut spans = Vec::new();
        if let Some(since) = &self.paused {
            spans.push(format!(" PAUSED {} ", fmt_clock(since)).bold().reversed());
            spans.push(Span::raw(" "));
        }
        if let Some((text, at)) = &self.notice
            && at.elapsed() < NOTICE_DURATION
        {
            spans.push(format!("{text} ").bold());
        }
        if matches!(self.mode, Mode::List(_)) && self.channels.len() > self.visible_rows {
            let scroll = self.list_scroll();
            let last = (scroll + self.visible_rows).min(self.channels.len());
            spans.push(Span::raw(format!(
                "[{}-{}/{}] ",
                scroll + 1,
                last,
                self.channels.len()
            )));
        }
        spans.push("? help  q quit".dim());
        frame.render_widget(Line::from(spans), line_area);

        // Wall clock, deliberately live even while paused: an updating time
        // is the "not frozen" indicator.
        frame.render_widget(Line::from(wall_clock()).right_aligned().dim(), clock_area);
    }

    fn list_scroll(&self) -> usize {
        match &self.mode {
            Mode::List(list) => list.scroll,
            Mode::Scope(_) => 0,
        }
    }

    /// The channel index of the list row at terminal cell (column, row).
    fn row_at(&self, column: u16, row: u16) -> Option<usize> {
        if !self.table_area.contains(Position::new(column, row)) {
            return None;
        }
        let index = self.list_scroll() + (row - self.table_area.y) as usize;
        (index < self.channels.len()).then_some(index)
    }

    /// What a middle-click at terminal cell (column, row) copies: the channel
    /// name in the name column, the rendered value in the value column.
    /// `None` on the marker columns, spacers, empty space, or a disconnected
    /// value.
    fn click_target(&self, column: u16, row: u16) -> Option<String> {
        let index = self.row_at(column, row)?;
        let name = &self.channels[index].name;
        // Layout: marker(1) sp name(name_width) sp value(fill) sp marker(1).
        let name_start = self.table_area.x + 2;
        let name_end = name_start + self.name_width; // exclusive
        let value_start = name_end + 1;
        let value_end = self.table_area.right().saturating_sub(2); // exclusive
        if (name_start..name_end).contains(&column) {
            Some(name.clone())
        } else if (value_start..value_end).contains(&column) {
            self.snapshot
                .get(name)
                .and_then(|entry| entry.value.data.as_ref())
                .map(|data| data.to_string())
        } else {
            None
        }
    }
}

fn is_string(entry: Option<&ChannelEntry>) -> bool {
    matches!(
        entry.and_then(|e| e.value.data.as_ref()),
        Some(SignalData::String(_))
    )
}

/// Split the list-mode area between the table and the preview pane.  The
/// table keeps its natural width (markers, spacing, the name column, and
/// [`LIST_VALUE_WIDTH`] for values); the pane takes the remainder, but only
/// when at least [`PREVIEW_MIN_WIDTH`] columns remain.
fn split_list_area(area: Rect, name_width: u16) -> (Rect, Option<Rect>) {
    // marker(1) sp name sp value sp marker(1): 5 cells beyond the columns.
    let list_width = name_width + LIST_VALUE_WIDTH + 5;
    if area.width >= list_width.saturating_add(PREVIEW_MIN_WIDTH) {
        let [table, preview] =
            Layout::horizontal([Constraint::Length(list_width), Constraint::Min(0)]).areas(area);
        (table, Some(preview))
    } else {
        (area, None)
    }
}

/// The preview shows a miniscope unless the channel is a string channel or
/// has never connected (no cached description to size an info block by,
/// and no plottable history either).
fn preview_has_chart(entry: Option<&ChannelEntry>, info: Option<&SignalInfo>) -> bool {
    !is_string(entry) && info.is_some()
}

/// The preview info block: a state row derived from the live value (so it
/// can never contradict the value column) followed by the cached backend
/// description, keys aligned and dimmed.
fn info_lines(entry: Option<&ChannelEntry>, info: Option<&SignalInfo>) -> Vec<Line<'static>> {
    let state = if entry.is_some_and(|e| e.value.data.is_some()) {
        "connected"
    } else {
        "disconnected"
    };
    let pairs: Vec<(&str, &str)> = std::iter::once(("state", state))
        .chain(
            info.iter()
                .flat_map(|i| i.0.iter().map(|(k, v)| (k.as_str(), v.as_str()))),
        )
        .collect();
    let key_width = pairs.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
    pairs
        .into_iter()
        .map(|(key, value)| {
            Line::from(vec![
                format!(" {key:<key_width$}  ").dim(),
                Span::raw(value.to_string()),
            ])
        })
        .collect()
}

/// The sample slice a view window covers, plus the window's right-edge
/// offset and span in seconds and whether older data exists beyond its
/// left edge.  `None` is the full trace at zero offset.
fn view_slice(trace: &[Sample], view: Option<ScopeView>) -> (&[Sample], f64, f64, bool) {
    match view {
        Some(view) => {
            let skip = (view.right_offset_secs / history::SAMPLE_PERIOD_SECS).round() as usize;
            let count = (view.span_secs / history::SAMPLE_PERIOD_SECS).round() as usize + 1;
            let end = trace.len().saturating_sub(skip);
            let start = end.saturating_sub(count);
            (
                &trace[start..end],
                view.right_offset_secs,
                view.span_secs,
                start > 0,
            )
        }
        None => (trace, 0.0, trace_span_secs(trace), false),
    }
}

/// The trace's span in seconds (0 for empty or single-sample traces).
fn trace_span_secs(trace: &[Sample]) -> f64 {
    trace.len().saturating_sub(1) as f64 * history::SAMPLE_PERIOD_SECS
}

/// Render one history trace as a bordered chart; shared by scope mode and
/// the list-mode miniscope.  A configured range fixes the y-axis, clamps
/// the trace, and draws the out-of-range runs red along the boundary;
/// without one the axis autoscales.  Live, the x-axis labels are all
/// relative to "now" ("0" at the live right edge, the window offset when
/// panned behind it).  Paused (the trace ends at `paused_at`, the moment
/// the pause began) the right label is the timestamp of the newest second
/// in view and the other markers are deltas from it.  A `view` restricts
/// the plot to that window: the title carries the window position with
/// ◀/▶ cues on the sides that have more data.
fn render_chart(
    frame: &mut Frame,
    area: Rect,
    title: &str,
    trace: &[Sample],
    range: Option<Range>,
    paused_at: Option<&jiff::Zoned>,
    view: Option<ScopeView>,
) {
    let full_span = trace_span_secs(trace);
    let (trace, offset, span, more_left) = view_slice(trace, view);
    let span = span.max(1.0);

    // Braille marks two points per cell column; borders take 2 cells.
    let max_points = (area.width as usize).saturating_sub(2).max(1) * 2;
    let mut segments = history::chart_segments(trace, max_points);
    // chart_segments puts x = 0 at the slice's newest sample; shift the
    // window back to where it sits behind the trace's right edge.
    if offset != 0.0 {
        for point in segments.iter_mut().flatten() {
            point.0 -= offset;
        }
    }

    let (segments, excursions, (y_min, y_max)) = match range {
        Some(range) => {
            let (clamped, excursions) = clamp_segments(&segments, range);
            (clamped, excursions, (range.min(), range.max()))
        }
        None => {
            let bounds = autoscale_bounds(&segments);
            (segments, Vec::new(), bounds)
        }
    };

    let title = match view {
        Some(_) => {
            let left_cue = if more_left { "◀ " } else { "" };
            let (right, right_cue) = if offset > 0.0 {
                (fmt_delta(offset), " ▶")
            } else {
                ("now".to_string(), "")
            };
            format!(
                " {left_cue}{title} [{}…{right} of {}]{right_cue} ",
                fmt_delta(offset + span),
                fmt_span(full_span),
            )
        }
        None => format!(" {title} "),
    };

    let dataset = |segment| {
        Dataset::default()
            .marker(symbols::Marker::Braille)
            .graph_type(GraphType::Line)
            .data(segment)
    };
    let datasets: Vec<Dataset> = segments
        .iter()
        .map(|segment| dataset(segment).cyan())
        // Excursions come last so the red draws over the clamped trace.
        .chain(excursions.iter().map(|run| dataset(run).red()))
        .collect();
    let chart = Chart::new(datasets)
        .block(Block::bordered().title(title))
        .x_axis(
            Axis::default()
                .bounds([-(offset + span), -offset])
                .labels(cue_labels(
                    time_labels(offset, span, right_edge_time(paused_at, offset).as_deref()),
                    more_left,
                    offset > 0.0,
                )),
        )
        .y_axis(Axis::default().bounds([y_min, y_max]).labels([
            axis_value(y_min),
            axis_value((y_min + y_max) / 2.0),
            axis_value(y_max),
        ]));
    frame.render_widget(chart, area);
}

/// A list-mode value shows red when its channel has a range and the value
/// has a numeric form outside it.
fn is_out_of_range(range: Option<Range>, data: &SignalData) -> bool {
    range
        .zip(data.as_f64())
        .is_some_and(|(range, value)| range.excludes(value))
}

/// Autoscaled y bounds: the segment extremes padded by 5%, with fallbacks
/// for an empty or flat trace.
fn autoscale_bounds(segments: &[Vec<(f64, f64)>]) -> (f64, f64) {
    let (mut y_min, mut y_max) = (f64::INFINITY, f64::NEG_INFINITY);
    for &(_, y) in segments.iter().flatten() {
        y_min = y_min.min(y);
        y_max = y_max.max(y);
    }
    if !y_min.is_finite() {
        (0.0, 1.0) // nothing plottable yet
    } else if y_min == y_max {
        (y_min - 1.0, y_max + 1.0)
    } else {
        let pad = (y_max - y_min) * 0.05;
        (y_min - pad, y_max + pad)
    }
}

/// Connected chart runs, as produced by [`history::chart_segments`].
type Segments = Vec<Vec<(f64, f64)>>;

/// Clamp chart segments to a fixed range.  Returns the clamped trace plus
/// the excursion runs — maximal stretches of consecutive points out of
/// range on the same side, pinned flat at the boundary they crossed — to
/// be drawn red on top of the trace.  Operating on the downsampled points
/// is exact: they are the per-bucket raw extrema, so any excursion that
/// survives downsampling is seen here.
fn clamp_segments(segments: &[Vec<(f64, f64)>], range: Range) -> (Segments, Segments) {
    let mut excursions = Vec::new();
    let clamped = segments
        .iter()
        .map(|segment| {
            let mut side = 0i8;
            let mut run = Vec::new();
            for &(x, y) in segment {
                let s = if y > range.max() {
                    1
                } else if y < range.min() {
                    -1
                } else {
                    0
                };
                if s != side && !run.is_empty() {
                    excursions.push(std::mem::take(&mut run));
                }
                side = s;
                if s != 0 {
                    run.push((x, if s > 0 { range.max() } else { range.min() }));
                }
            }
            if !run.is_empty() {
                excursions.push(run);
            }
            segment
                .iter()
                .map(|&(x, y)| (x, y.clamp(range.min(), range.max())))
                .collect()
        })
        .collect();
    (clamped, excursions)
}

/// The current local time as `HH:MM:SS`.
fn wall_clock() -> String {
    fmt_clock(&jiff::Zoned::now())
}

/// A moment as `HH:MM:SS` for the PAUSED badge and axis labels.
fn fmt_clock(time: &jiff::Zoned) -> String {
    time.strftime("%H:%M:%S").to_string()
}

/// The wall-clock `HH:MM:SS` of the newest second a paused chart shows:
/// the pause moment (where the frozen trace ends) minus the window's
/// right-edge offset.  `None` while live — live axes carry no timestamp,
/// only deltas from "now".
fn right_edge_time(paused_at: Option<&jiff::Zoned>, right_offset_secs: f64) -> Option<String> {
    let edge = paused_at?.checked_sub(jiff::Span::new().seconds(right_offset_secs.round() as i64));
    edge.ok().as_ref().map(fmt_clock)
}

/// X-axis labels for a window of `span_secs` ending `right_offset_secs`
/// behind the newest sample.  With a `right_time` (the paused case) the
/// right edge carries that timestamp and the other markers are deltas
/// from it — panning moves the timestamp, not the markers.  Without one
/// (live) every label is a delta from "now", e.g.
/// `["-15min", "-10min", "-5min", "0"]`, the right edge showing its
/// offset when the window sits behind the newest data.
fn time_labels(right_offset_secs: f64, span_secs: f64, right_time: Option<&str>) -> Vec<String> {
    let (base, right) = match right_time {
        Some(time) => (0.0, time.to_string()),
        None if right_offset_secs > 0.0 => (right_offset_secs, fmt_delta(right_offset_secs)),
        None => (0.0, "0".to_string()),
    };
    let at = |fraction: f64| fmt_delta(base + span_secs * fraction);
    vec![at(1.0), at(2.0 / 3.0), at(1.0 / 3.0), right]
}

/// Echo a zoomed window's more-data cues on the x axis itself: ◀ ahead of
/// the oldest label when older data exists past the left edge, ▶ after
/// the right-edge label when newer data exists past it.  Both conditions
/// are false for a full view, so only zoomed windows are decorated.
fn cue_labels(mut labels: Vec<String>, more_left: bool, more_right: bool) -> Vec<String> {
    if more_left && let Some(first) = labels.first_mut() {
        *first = format!("◀ {first}");
    }
    if more_right && let Some(last) = labels.last_mut() {
        *last = format!("{last} ▶");
    }
    labels
}

/// A time delta for axis labels: "-24h" / "-15min" / "-45s".
fn fmt_delta(secs: f64) -> String {
    format!("-{}", fmt_span(secs))
}

/// A duration for labels and the zoom-window annotation, in the coarsest
/// unit that stays readable: "45s" under 2 minutes, "15min" under 2 hours,
/// then hours with one trimmed decimal ("2.5h", "24h").
fn fmt_span(secs: f64) -> String {
    let minutes = secs / 60.0;
    if secs < 120.0 {
        format!("{}s", secs.round())
    } else if minutes < 120.0 {
        format!("{}min", minutes.round())
    } else {
        let text = format!("{:.1}", minutes / 60.0);
        let text = text.trim_end_matches('0').trim_end_matches('.');
        format!("{text}h")
    }
}

/// A history lookback for status notices: "15 min", or "2 h" on whole
/// hours.
fn fmt_minutes(minutes: usize) -> String {
    if minutes >= 60 && minutes.is_multiple_of(60) {
        format!("{} h", minutes / 60)
    } else {
        format!("{minutes} min")
    }
}

/// A memory size with one decimal in SI units: "7.7 MB".
fn fmt_bytes(bytes: usize) -> String {
    let bytes = bytes as f64;
    if bytes >= 1e9 {
        format!("{:.1} GB", bytes / 1e9)
    } else if bytes >= 1e6 {
        format!("{:.1} MB", bytes / 1e6)
    } else {
        format!("{:.1} kB", bytes / 1e3)
    }
}

/// Compact y-axis number: three decimals with trailing zeros trimmed.
fn axis_value(value: f64) -> String {
    let text = format!("{value:.3}");
    let text = text.trim_end_matches('0').trim_end_matches('.');
    if text == "-0" {
        "0".to_string()
    } else {
        text.to_string()
    }
}

fn render_help(frame: &mut Frame) {
    let key_width = HELP_TEXT.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
    let lines: Vec<Line> = HELP_TEXT
        .iter()
        .map(|(key, action)| {
            Line::from(vec![
                format!(" {key:key_width$}  ").bold(),
                (*action).into(),
            ])
        })
        .collect();
    // +2 each way for the block borders, +1 line of bottom padding for the hint.
    let width = (lines.iter().map(Line::width).max().unwrap_or(0) + 3) as u16;
    let height = (lines.len() + 3) as u16;
    let area = centered_rect(width, height, frame.area());
    let help = Paragraph::new(lines).block(
        Block::bordered()
            .title(" Help ")
            .title_bottom(Line::from(" any key to close ").dim().right_aligned()),
    );
    frame.render_widget(Clear, area);
    frame.render_widget(help, area);
}

/// A `width` x `height` rect centered in `area`, shrunk to fit if needed.
fn centered_rect(width: u16, height: u16, area: Rect) -> Rect {
    let width = width.min(area.width);
    let height = height.min(area.height);
    Rect {
        x: area.x + (area.width - width) / 2,
        y: area.y + (area.height - height) / 2,
        width,
        height,
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::signal::SignalValue;
    use std::time::SystemTime;

    fn empty_snap() -> HashMap<String, ChannelEntry> {
        HashMap::new()
    }

    fn entry(data: SignalData) -> ChannelEntry {
        ChannelEntry {
            value: SignalValue {
                data: Some(data),
                time: SystemTime::now(),
            },
            info: None,
        }
    }

    /// An app laid out as if rendered: table at (0,0), 40 wide, with CHAN_0
    /// connected at 1.5 and CHAN_2 a string (name column width 6).
    fn app_with(count: usize, visible_rows: usize) -> App {
        let mut app = App::new(
            (0..count)
                .map(|i| ChannelConfig::new(format!("CHAN_{i}"), None))
                .collect(),
            15, // a mid-range lookback keeps the resize tests two-sided
        );
        app.visible_rows = visible_rows;
        app.table_area = Rect::new(0, 0, 40, visible_rows as u16);
        app.snapshot
            .insert("CHAN_0".to_string(), entry(SignalData::Float64(1.5)));
        if count > 2 {
            app.snapshot.insert(
                "CHAN_2".to_string(),
                entry(SignalData::String("text".to_string())),
            );
        }
        app
    }

    fn press(app: &mut App, code: KeyCode) -> Option<String> {
        let event = Event::Key(KeyEvent::new(code, KeyModifiers::NONE));
        app.handle_event(&event, &empty_snap())
    }

    fn press_ctrl(app: &mut App, c: char) -> Option<String> {
        let event = Event::Key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::CONTROL));
        app.handle_event(&event, &empty_snap())
    }

    fn preview_of(app: &App) -> &Preview {
        match &app.mode {
            Mode::List(list) => &list.preview,
            Mode::Scope(_) => panic!("expected list mode"),
        }
    }

    fn click(app: &mut App, button: MouseButton, column: u16, row: u16) -> Option<String> {
        let event = Event::Mouse(MouseEvent {
            kind: MouseEventKind::Down(button),
            column,
            row,
            modifiers: KeyModifiers::NONE,
        });
        app.handle_event(&event, &empty_snap())
    }

    fn scroll_of(app: &App) -> usize {
        app.list_scroll()
    }

    #[test]
    fn name_width_is_longest_name() {
        let app = App::new(
            vec![
                ChannelConfig::new("A", None),
                ChannelConfig::new("LONGER:NAME", None),
            ],
            15,
        );
        assert_eq!(app.name_width, 11);
    }

    #[test]
    fn selection_moves_and_viewport_follows() {
        let mut app = app_with(10, 4);
        for _ in 0..5 {
            press(&mut app, KeyCode::Char('j'));
        }
        assert_eq!(app.selected, 5);
        assert_eq!(scroll_of(&app), 2); // rows 2..=5 visible

        for _ in 0..20 {
            press(&mut app, KeyCode::Down);
        }
        assert_eq!(app.selected, 9); // clamped to the last row
        assert_eq!(scroll_of(&app), 6);

        for _ in 0..20 {
            press(&mut app, KeyCode::Char('k'));
        }
        assert_eq!(app.selected, 0);
        assert_eq!(scroll_of(&app), 0);
    }

    #[test]
    fn selection_stays_put_when_everything_fits() {
        let mut app = app_with(3, 10);
        press(&mut app, KeyCode::Char('j'));
        assert_eq!(app.selected, 1);
        assert_eq!(scroll_of(&app), 0);
    }

    #[test]
    fn pause_toggles_and_quit_keys_stop_the_loop() {
        let mut app = app_with(1, 10);
        press(&mut app, KeyCode::Char('p'));
        let since = app.paused.clone().expect("should be paused");
        // The pause badge carries the HH:MM:SS the pause began.
        let badge = fmt_clock(&since);
        assert_eq!(badge.len(), 8);
        assert!(badge.chars().all(|c| c.is_ascii_digit() || c == ':'));
        press(&mut app, KeyCode::Char('p'));
        assert!(app.paused.is_none());

        for code in [KeyCode::Char('q'), KeyCode::Esc] {
            let mut app = app_with(1, 10);
            press(&mut app, code);
            assert!(!app.running);
        }
    }

    #[test]
    fn any_key_closes_help_without_acting() {
        let mut app = app_with(1, 10);
        press(&mut app, KeyCode::Char('?'));
        assert!(app.show_help);
        // 'q' only closes the overlay; it must not quit.
        press(&mut app, KeyCode::Char('q'));
        assert!(!app.show_help);
        assert!(app.running);
    }

    #[test]
    fn left_click_selects_but_copies_nothing() {
        let mut app = app_with(10, 4);
        assert_eq!(click(&mut app, MouseButton::Left, 3, 2), None);
        assert_eq!(app.selected, 2);
        // A click outside the rows leaves the selection alone.
        click(&mut app, MouseButton::Left, 3, 4); // below the table
        assert_eq!(app.selected, 2);
    }

    #[test]
    fn middle_click_copies_name_or_value() {
        let mut app = app_with(3, 10);
        // Layout: marker at 0, name at 2..8, spacer at 8, value from 9.
        assert_eq!(
            click(&mut app, MouseButton::Middle, 2, 0),
            Some("CHAN_0".to_string())
        );
        assert_eq!(
            click(&mut app, MouseButton::Middle, 9, 0),
            Some("1.5".to_string())
        );
        // Marker column, spacer, and disconnected values copy nothing.
        assert_eq!(click(&mut app, MouseButton::Middle, 0, 0), None);
        assert_eq!(click(&mut app, MouseButton::Middle, 8, 0), None);
        assert_eq!(click(&mut app, MouseButton::Middle, 9, 1), None);
        // Middle-clicking must not move the selection.
        assert_eq!(app.selected, 0);
    }

    #[test]
    fn middle_click_respects_scroll_offset() {
        let mut app = app_with(10, 4);
        for _ in 0..5 {
            press(&mut app, KeyCode::Char('j'));
        }
        assert_eq!(scroll_of(&app), 2);
        assert_eq!(
            click(&mut app, MouseButton::Middle, 3, 1),
            Some("CHAN_3".to_string())
        );
    }

    #[test]
    fn wheel_moves_the_selection_over_the_table_only() {
        let mut app = app_with(10, 4);
        mouse(&mut app, MouseEventKind::ScrollDown, 3, 2);
        mouse(&mut app, MouseEventKind::ScrollDown, 3, 2);
        assert_eq!(app.selected, 2);
        assert_eq!(scroll_of(&app), 0);
        // The viewport follows once the selection passes the bottom.
        for _ in 0..4 {
            mouse(&mut app, MouseEventKind::ScrollDown, 3, 2);
        }
        assert_eq!(app.selected, 6);
        assert_eq!(scroll_of(&app), 3);
        mouse(&mut app, MouseEventKind::ScrollUp, 3, 2);
        assert_eq!(app.selected, 5);
        // Outside the table (and in scope mode) the wheel does nothing.
        mouse(&mut app, MouseEventKind::ScrollDown, 100, 50);
        assert_eq!(app.selected, 5);
        press(&mut app, KeyCode::Char('s'));
        mouse(&mut app, MouseEventKind::ScrollDown, 3, 2);
        assert_eq!(app.selected, 5);
    }

    #[test]
    fn left_drag_scrolls_the_view_and_leaves_the_selection() {
        let mut app = app_with(10, 4);
        // Grab row 3 (selecting CHAN_3) and drag up: later rows come into
        // view while the selection stays put.
        mouse(&mut app, MouseEventKind::Down(MouseButton::Left), 3, 3);
        assert_eq!(app.selected, 3);
        mouse(&mut app, MouseEventKind::Drag(MouseButton::Left), 3, 1);
        assert_eq!(scroll_of(&app), 2);
        assert_eq!(app.selected, 3);
        mouse(&mut app, MouseEventKind::Drag(MouseButton::Left), 3, 0);
        assert_eq!(scroll_of(&app), 3);
        // Release ends the drag: further drag events are ignored.
        mouse(&mut app, MouseEventKind::Up(MouseButton::Left), 3, 0);
        mouse(&mut app, MouseEventKind::Drag(MouseButton::Left), 3, 3);
        assert_eq!(scroll_of(&app), 3);
        // Dragging back down clamps at the top of the list.
        mouse(&mut app, MouseEventKind::Down(MouseButton::Left), 3, 0);
        mouse(&mut app, MouseEventKind::Drag(MouseButton::Left), 3, 3);
        assert_eq!(scroll_of(&app), 0);
        mouse(&mut app, MouseEventKind::Drag(MouseButton::Left), 3, 5);
        assert_eq!(scroll_of(&app), 0);
    }

    #[test]
    fn drag_scroll_can_leave_the_selection_off_screen_until_it_moves() {
        let mut app = app_with(20, 4);
        for _ in 0..7 {
            press(&mut app, KeyCode::Char('j'));
        }
        assert_eq!((app.selected, scroll_of(&app)), (7, 4));
        // Grab the selected row and drag well below the table: the view
        // scrolls back to the top, the selection stays on CHAN_7.
        mouse(&mut app, MouseEventKind::Down(MouseButton::Left), 3, 3);
        assert_eq!(app.selected, 7);
        mouse(&mut app, MouseEventKind::Drag(MouseButton::Left), 3, 10);
        assert_eq!(scroll_of(&app), 0);
        assert_eq!(app.selected, 7);
        // The next selection move snaps the viewport back to it.
        press(&mut app, KeyCode::Char('j'));
        assert_eq!((app.selected, scroll_of(&app)), (8, 5));
    }

    #[test]
    fn scope_mode_captures_the_selected_channel() {
        let mut app = app_with(3, 10);
        press(&mut app, KeyCode::Char('s'));
        match &app.mode {
            Mode::Scope(scope) => assert_eq!(scope.channel, "CHAN_0"),
            Mode::List(_) => panic!("expected scope mode"),
        }
        // 's' again returns to the list.
        press(&mut app, KeyCode::Char('s'));
        assert!(matches!(app.mode, Mode::List(_)));
    }

    #[test]
    fn scope_mode_refuses_string_channels() {
        let mut app = app_with(3, 10);
        app.selected = 2; // CHAN_2 is a string
        press(&mut app, KeyCode::Char('s'));
        assert!(matches!(app.mode, Mode::List(_)));
        assert!(app.notice.is_some());
    }

    #[test]
    fn scoped_channel_turning_string_drops_to_list() {
        let mut app = app_with(3, 10);
        press(&mut app, KeyCode::Char('s'));
        assert!(matches!(app.mode, Mode::Scope(_)));
        // The channel reconnects as a string.
        let mut snap = empty_snap();
        snap.insert(
            "CHAN_0".to_string(),
            entry(SignalData::String("oops".to_string())),
        );
        app.refresh_display(&snap);
        assert!(matches!(app.mode, Mode::List(_)));
        assert!(app.notice.is_some());
    }

    #[test]
    fn returning_to_list_brings_the_selection_into_view() {
        let mut app = app_with(20, 4);
        app.selected = 15;
        app.enter_scope_mode();
        press(&mut app, KeyCode::Char('s'));
        assert!(matches!(app.mode, Mode::List(_)));
        let scroll = scroll_of(&app);
        assert!((scroll..scroll + 4).contains(&app.selected));
    }

    #[test]
    fn list_keys_do_nothing_in_scope_mode() {
        let mut app = app_with(3, 10);
        press(&mut app, KeyCode::Char('s'));
        press(&mut app, KeyCode::Char('j'));
        assert_eq!(app.selected, 0);
        // Mouse selection is list-only too.
        click(&mut app, MouseButton::Left, 3, 2);
        assert_eq!(app.selected, 0);
    }

    #[test]
    fn timer_event_feeds_the_history() {
        let mut app = app_with(3, 10);
        let mut snap = empty_snap();
        snap.insert("CHAN_1".to_string(), entry(SignalData::Int32(7)));
        app.apply(Command::TimerEvent, &snap);
        app.apply(Command::TimerEvent, &snap);
        assert_eq!(
            app.history.samples_for("CHAN_1"),
            vec![Some(7.0), Some(7.0)]
        );
        assert_eq!(app.history.samples_for("CHAN_0"), vec![None, None]);
    }

    #[test]
    fn paused_scope_trace_stays_frozen() {
        let mut app = app_with(3, 10);
        let mut snap = empty_snap();
        snap.insert("CHAN_0".to_string(), entry(SignalData::Float64(1.0)));
        app.apply(Command::TimerEvent, &snap);
        press(&mut app, KeyCode::Char('s'));
        app.refresh_display(&snap);
        assert_eq!(app.scope_trace.len(), 1);

        press(&mut app, KeyCode::Char('p')); // pause
        app.apply(Command::TimerEvent, &snap); // master history keeps filling
        app.refresh_display(&snap);
        assert_eq!(app.scope_trace.len(), 1, "trace must stay frozen");

        press(&mut app, KeyCode::Char('p')); // unpause: jump forward
        app.refresh_display(&snap);
        assert_eq!(app.scope_trace.len(), 2);
    }

    #[test]
    fn time_labels_scale_with_the_span() {
        assert_eq!(
            time_labels(0.0, 900.0, None),
            ["-15min", "-10min", "-5min", "0"]
        );
        assert_eq!(time_labels(0.0, 45.0, None), ["-45s", "-30s", "-15s", "0"]);
    }

    #[test]
    fn paused_time_labels_pin_the_right_edge_to_the_pause_time() {
        assert_eq!(
            time_labels(0.0, 45.0, Some("19:02:35")),
            ["-45s", "-30s", "-15s", "19:02:35"]
        );
    }

    #[test]
    fn paused_labels_anchor_on_the_timestamp_and_ignore_the_offset() {
        // With a timestamp the deltas are measured from the right edge, so
        // they depend only on the span: panning a paused view changes the
        // timestamp but not the delta markers.
        let panned = time_labels(300.0, 900.0, Some("18:57:35"));
        assert_eq!(panned, ["-15min", "-10min", "-5min", "18:57:35"]);
        let unpanned = time_labels(0.0, 900.0, Some("19:02:35"));
        assert_eq!(panned[..3], unpanned[..3]);
    }

    #[test]
    fn live_panned_labels_are_all_now_relative() {
        // No timestamp while live: a window 15 minutes wide sitting 5
        // minutes behind now labels everything as a delta from now.
        assert_eq!(
            time_labels(300.0, 900.0, None),
            ["-20min", "-15min", "-10min", "-5min"]
        );
    }

    #[test]
    fn axis_labels_carry_more_data_cues() {
        let labels: Vec<String> = ["-15min", "-10min", "-5min", "0"]
            .map(String::from)
            .to_vec();
        // Full view (or a window pinned at both ends): no cues.
        assert_eq!(cue_labels(labels.clone(), false, false), labels);
        // More data on both sides: only the end labels are decorated.
        let cued = cue_labels(labels.clone(), true, true);
        assert_eq!(cued, ["◀ -15min", "-10min", "-5min", "0 ▶"]);
        // One-sided windows decorate one side only.
        assert_eq!(cue_labels(labels.clone(), true, false)[3], "0");
        assert_eq!(cue_labels(labels, false, true)[0], "-15min");
    }

    #[test]
    fn right_edge_time_is_paused_only() {
        let paused: jiff::Zoned = "2026-08-18T19:02:35[America/Los_Angeles]".parse().unwrap();
        // Live: no timestamp, panned or not.
        assert_eq!(right_edge_time(None, 0.0), None);
        assert_eq!(right_edge_time(None, 300.0), None);
        // Paused: the newest second in view — the pause moment backed off
        // by the window's right-edge offset.
        assert_eq!(
            right_edge_time(Some(&paused), 0.0),
            Some("19:02:35".to_string())
        );
        assert_eq!(
            right_edge_time(Some(&paused), 300.0),
            Some("18:57:35".to_string())
        );
    }

    #[test]
    fn scope_mode_captures_the_channel_range() {
        let mut app = app_with(3, 10);
        app.channels[0].range = Range::new(0.0, 10.0);
        press(&mut app, KeyCode::Char('s'));
        match &app.mode {
            Mode::Scope(scope) => assert_eq!(scope.range, Range::new(0.0, 10.0)),
            Mode::List(_) => panic!("expected scope mode"),
        }
    }

    #[test]
    fn out_of_range_values_need_a_range_and_a_number() {
        let range = Range::new(0.0, 10.0);
        assert!(is_out_of_range(range, &SignalData::Float64(10.5)));
        assert!(is_out_of_range(range, &SignalData::Int32(-1)));
        assert!(!is_out_of_range(range, &SignalData::Float64(10.0)));
        assert!(!is_out_of_range(range, &SignalData::Float64(0.0)));
        // No range, or no numeric form: never red.
        assert!(!is_out_of_range(None, &SignalData::Float64(10.5)));
        assert!(!is_out_of_range(
            range,
            &SignalData::String("text".to_string())
        ));
    }

    #[test]
    fn autoscale_bounds_pad_and_handle_degenerate_traces() {
        assert_eq!(autoscale_bounds(&[]), (0.0, 1.0));
        assert_eq!(autoscale_bounds(&[vec![(0.0, 5.0)]]), (4.0, 6.0));
        let (lo, hi) = autoscale_bounds(&[vec![(-0.25, 0.0), (0.0, 10.0)]]);
        assert_eq!((lo, hi), (-0.5, 10.5)); // 5% padding
    }

    #[test]
    fn clamping_splits_excursion_runs_per_side() {
        let range = Range::new(0.0, 10.0).unwrap();
        // in, over, over, under, in — one overflow run, one underflow run.
        let segment = vec![
            (-1.0, 5.0),
            (-0.75, 12.0),
            (-0.5, 11.0),
            (-0.25, -3.0),
            (0.0, 5.0),
        ];
        let (clamped, excursions) = clamp_segments(&[segment], range);
        assert_eq!(
            clamped,
            vec![vec![
                (-1.0, 5.0),
                (-0.75, 10.0),
                (-0.5, 10.0),
                (-0.25, 0.0),
                (0.0, 5.0),
            ]]
        );
        assert_eq!(
            excursions,
            vec![
                vec![(-0.75, 10.0), (-0.5, 10.0)], // pinned at max
                vec![(-0.25, 0.0)],                // pinned at min
            ]
        );
    }

    #[test]
    fn in_range_trace_has_no_excursions() {
        let range = Range::new(0.0, 10.0).unwrap();
        let segment = vec![(-0.25, 1.0), (0.0, 9.0)];
        let (clamped, excursions) = clamp_segments(std::slice::from_ref(&segment), range);
        assert_eq!(clamped, vec![segment]);
        assert!(excursions.is_empty());
    }

    #[test]
    fn excursion_runs_do_not_cross_gap_segments() {
        let range = Range::new(0.0, 10.0).unwrap();
        // Two segments (a disconnection between them), both ending high:
        // the runs must stay separate.
        let segments = vec![vec![(-1.0, 12.0)], vec![(0.0, 13.0)]];
        let (_, excursions) = clamp_segments(&segments, range);
        assert_eq!(excursions, vec![vec![(-1.0, 10.0)], vec![(0.0, 10.0)]]);
    }

    #[test]
    fn axis_values_are_compact() {
        assert_eq!(axis_value(42.0), "42");
        assert_eq!(axis_value(1.5), "1.5");
        assert_eq!(axis_value(-0.0001), "0");
        assert_eq!(axis_value(2.125), "2.125");
    }

    #[test]
    fn page_keys_move_the_selection_by_a_viewport() {
        let mut app = app_with(20, 4);
        press(&mut app, KeyCode::PageDown);
        assert_eq!(app.selected, 4);
        press_ctrl(&mut app, 'f');
        assert_eq!(app.selected, 8);
        press(&mut app, KeyCode::PageUp);
        assert_eq!(app.selected, 4);
        press_ctrl(&mut app, 'b');
        assert_eq!(app.selected, 0);
        // Both directions clamp at the ends.
        for _ in 0..10 {
            press(&mut app, KeyCode::PageDown);
        }
        assert_eq!(app.selected, 19);
        for _ in 0..10 {
            press(&mut app, KeyCode::PageUp);
        }
        assert_eq!(app.selected, 0);
        // The viewport followed the whole way.
        assert_eq!(scroll_of(&app), 0);
    }

    #[test]
    fn plain_b_and_f_do_not_page() {
        let mut app = app_with(20, 4);
        press(&mut app, KeyCode::Char('f'));
        press(&mut app, KeyCode::Char('b'));
        assert_eq!(app.selected, 0);
    }

    #[test]
    fn page_keys_do_nothing_in_scope_mode() {
        let mut app = app_with(20, 4);
        press(&mut app, KeyCode::Char('s'));
        press(&mut app, KeyCode::PageDown);
        assert_eq!(app.selected, 0);
    }

    #[test]
    fn preview_pane_needs_room() {
        // name_width 6 -> the table needs 6 + 16 + 5 = 27 columns.
        let (table, pane) = split_list_area(Rect::new(0, 0, 57, 10), 6);
        assert_eq!(table.width, 27);
        assert_eq!(pane, Some(Rect::new(27, 0, 30, 10)));
        // One column short of the pane minimum: full-width list.
        let (table, pane) = split_list_area(Rect::new(0, 0, 56, 10), 6);
        assert_eq!(table.width, 56);
        assert_eq!(pane, None);
    }

    #[test]
    fn miniscope_skips_strings_and_never_connected_channels() {
        let info = SignalInfo(vec![("host".to_string(), "ioc0:5064".to_string())]);
        let numeric = entry(SignalData::Float64(1.0));
        assert!(preview_has_chart(Some(&numeric), Some(&info)));
        let string = entry(SignalData::String("text".to_string()));
        assert!(!preview_has_chart(Some(&string), Some(&info)));
        // Never connected: no store entry or no cached description.
        assert!(!preview_has_chart(None, None));
        assert!(!preview_has_chart(Some(&numeric), None));
    }

    #[test]
    fn info_lines_derive_state_and_keep_the_backend_order() {
        let info = SignalInfo(vec![
            ("host".to_string(), "ioc0:5064".to_string()),
            ("access".to_string(), "read, write".to_string()),
        ]);
        let lines = info_lines(Some(&entry(SignalData::Float64(1.0))), Some(&info));
        let texts: Vec<String> = lines.iter().map(|l| l.to_string()).collect();
        assert_eq!(texts.len(), 3);
        assert!(texts[0].contains("state") && texts[0].contains("connected"));
        assert!(texts[1].contains("host") && texts[1].contains("ioc0:5064"));
        assert!(texts[2].contains("access") && texts[2].contains("read, write"));

        // Disconnected (or absent) entries derive the state row; the cached
        // description still shows.
        let lines = info_lines(None, Some(&info));
        assert!(lines[0].to_string().contains("disconnected"));
        // Never connected: just the state row.
        assert_eq!(info_lines(None, None).len(), 1);
    }

    #[test]
    fn preview_follows_the_selection_and_copies_the_config() {
        let mut app = app_with(3, 10);
        app.channels[1].range = Range::new(0.0, 10.0);
        let mut snap = empty_snap();
        let mut connected = entry(SignalData::Float64(1.5));
        connected.info = Some(SignalInfo(vec![("host".to_string(), "a:1".to_string())]));
        snap.insert("CHAN_0".to_string(), connected);
        app.refresh_display(&snap);
        assert_eq!(preview_of(&app).channel, "CHAN_0");
        assert!(preview_of(&app).info.is_some());

        press(&mut app, KeyCode::Char('j'));
        app.refresh_preview();
        let preview = preview_of(&app);
        assert_eq!(preview.channel, "CHAN_1");
        assert_eq!(preview.range, Range::new(0.0, 10.0));
        assert_eq!(preview.info, None);
    }

    #[test]
    fn preview_trace_is_capped_at_five_minutes() {
        let mut app = app_with(1, 10);
        let mut snap = empty_snap();
        snap.insert("CHAN_0".to_string(), entry(SignalData::Float64(1.0)));
        for _ in 0..PREVIEW_SAMPLES + 50 {
            app.apply(Command::TimerEvent, &snap);
        }
        app.refresh_display(&snap);
        assert_eq!(preview_of(&app).trace.len(), PREVIEW_SAMPLES);
    }

    #[test]
    fn paused_preview_freezes_but_a_selection_move_rebuilds_it() {
        let mut app = app_with(3, 10);
        let mut snap = empty_snap();
        snap.insert("CHAN_0".to_string(), entry(SignalData::Float64(1.0)));
        snap.insert("CHAN_1".to_string(), entry(SignalData::Float64(2.0)));
        app.apply(Command::TimerEvent, &snap);
        app.refresh_display(&snap);
        assert_eq!(preview_of(&app).trace.len(), 1);

        press(&mut app, KeyCode::Char('p')); // pause
        app.apply(Command::TimerEvent, &snap); // master history keeps filling
        app.refresh_display(&snap);
        assert_eq!(preview_of(&app).trace.len(), 1, "preview must stay frozen");

        // Moving the selection while paused still rebuilds the preview for
        // the newly selected channel (from the master history).
        press(&mut app, KeyCode::Char('j'));
        app.refresh_display(&snap);
        let preview = preview_of(&app);
        assert_eq!(preview.channel, "CHAN_1");
        assert_eq!(preview.trace.len(), 2);
    }

    fn mouse(app: &mut App, kind: MouseEventKind, column: u16, row: u16) {
        let event = Event::Mouse(MouseEvent {
            kind,
            column,
            row,
            modifiers: KeyModifiers::NONE,
        });
        app.handle_event(&event, &empty_snap());
    }

    fn view_of(app: &App) -> Option<ScopeView> {
        match &app.mode {
            Mode::Scope(scope) => scope.view,
            Mode::List(_) => panic!("expected scope mode"),
        }
    }

    /// An app in scope mode on CHAN_0 with a hand-set trace of
    /// `trace_samples` samples and a 42-wide chart (40 plot columns).
    fn scoped_app(trace_samples: usize) -> App {
        let mut app = app_with(3, 10);
        press(&mut app, KeyCode::Char('s'));
        app.scope_trace = vec![Some(1.0); trace_samples];
        app.scope_area = Rect::new(0, 0, 42, 10);
        app
    }

    /// Push `count` history samples for every channel.
    fn feed_history(app: &mut App, count: usize) {
        let mut snap = empty_snap();
        snap.insert("CHAN_0".to_string(), entry(SignalData::Float64(1.0)));
        for _ in 0..count {
            app.apply(Command::TimerEvent, &snap);
        }
    }

    #[test]
    fn enter_toggles_modes_like_s() {
        let mut app = app_with(3, 10);
        press(&mut app, KeyCode::Enter);
        assert!(matches!(app.mode, Mode::Scope(_)));
        press(&mut app, KeyCode::Enter);
        assert!(matches!(app.mode, Mode::List(_)));
    }

    #[test]
    fn history_keys_resize_in_both_modes_and_report() {
        let mut app = app_with(3, 10);
        press(&mut app, KeyCode::Char('>'));
        assert_eq!(app.history.minutes(), 20);
        let (notice, _) = app.notice.as_ref().expect("notice expected");
        // 3 channels x 16 B x 240/min x 20 min = 230.4 kB.
        assert_eq!(notice, "history: 20 min (~230.4 kB)");

        press(&mut app, KeyCode::Char('s')); // the keys are global
        press(&mut app, KeyCode::Char('<'));
        assert_eq!(app.history.minutes(), 15);

        // Shrinking clamps at the 5-minute floor and says so.
        for _ in 0..5 {
            press(&mut app, KeyCode::Char('<'));
        }
        assert_eq!(app.history.minutes(), 5);
        let (notice, _) = app.notice.as_ref().expect("notice expected");
        assert!(notice.contains("minimum"), "{notice}");
    }

    fn assert_close(actual: f64, expected: f64) {
        assert!(
            (actual - expected).abs() < 1e-6 * expected.max(1.0),
            "{actual} != {expected}"
        );
    }

    fn set_view(app: &mut App, span_secs: f64, right_offset_secs: f64) {
        let Mode::Scope(scope) = &mut app.mode else {
            panic!("expected scope mode");
        };
        scope.view = Some(ScopeView {
            span_secs,
            right_offset_secs,
        });
    }

    #[test]
    fn zoom_is_proportional_and_round_trips() {
        let mut app = scoped_app(4801); // 1200 s of data
        assert_eq!(view_of(&app), None); // scope entry is the full view
        press(&mut app, KeyCode::Char('+')); // 20% less data
        assert_close(view_of(&app).unwrap().span_secs, 960.0);
        assert_eq!(view_of(&app).unwrap().right_offset_secs, 0.0);
        press(&mut app, KeyCode::Char('=')); // unshifted '+'
        assert_close(view_of(&app).unwrap().span_secs, 768.0);
        // Zoom out is the exact inverse...
        press(&mut app, KeyCode::Char('-'));
        assert_close(view_of(&app).unwrap().span_secs, 960.0);
        // ...so in-then-out round-trips to the full view, float drift
        // notwithstanding.
        press(&mut app, KeyCode::Char('-'));
        assert_eq!(view_of(&app), None);
    }

    #[test]
    fn zoom_floors_at_one_minute() {
        let mut app = scoped_app(4801); // 1200 s of data
        for _ in 0..20 {
            press(&mut app, KeyCode::Char('+')); // 1200 * 0.8^15 < 60
        }
        assert_eq!(view_of(&app).unwrap().span_secs, 60.0);
        press(&mut app, KeyCode::Char('-'));
        assert_close(view_of(&app).unwrap().span_secs, 75.0);
    }

    #[test]
    fn zoom_needs_more_than_a_minute_of_data() {
        // 60 s of data: the tightest window IS the full view already.
        let mut app = scoped_app(241);
        press(&mut app, KeyCode::Char('+'));
        assert_eq!(view_of(&app), None);
    }

    #[test]
    fn pan_steps_by_a_tenth_of_the_view_and_clamps() {
        let mut app = scoped_app(4801); // 1200 s of data
        for _ in 0..20 {
            press(&mut app, KeyCode::Char('+')); // floored at a 60 s span
        }
        press(&mut app, KeyCode::Left); // 10% of 60 s per step
        press(&mut app, KeyCode::Char('h'));
        assert_close(view_of(&app).unwrap().right_offset_secs, 12.0);
        press(&mut app, KeyCode::Right);
        press(&mut app, KeyCode::Char('l'));
        let edge = view_of(&app).unwrap().right_offset_secs;
        assert!(edge.abs() < 1e-9, "should be back at the live edge: {edge}");
        press(&mut app, KeyCode::Char('l')); // already at the live edge
        assert_eq!(view_of(&app).unwrap().right_offset_secs, 0.0);
        // The other end clamps at the oldest data.
        for _ in 0..1000 {
            press(&mut app, KeyCode::Left);
        }
        assert_eq!(view_of(&app).unwrap().right_offset_secs, 1140.0);
    }

    #[test]
    fn full_view_does_not_pan_and_zero_restores_it() {
        let mut app = scoped_app(4801);
        press(&mut app, KeyCode::Left);
        assert_eq!(view_of(&app), None);
        press(&mut app, KeyCode::Char('+'));
        press(&mut app, KeyCode::Char('h'));
        assert!(view_of(&app).is_some());
        press(&mut app, KeyCode::Char('0'));
        assert_eq!(view_of(&app), None);
    }

    #[test]
    fn drag_pans_proportionally_and_follows_the_mouse() {
        let mut app = scoped_app(4801);
        set_view(&mut app, 300.0, 0.0);
        // 40 plot columns for 300 s: 7.5 s per cell.
        mouse(&mut app, MouseEventKind::Down(MouseButton::Left), 10, 5);
        mouse(&mut app, MouseEventKind::Drag(MouseButton::Left), 20, 5);
        assert_eq!(view_of(&app).unwrap().right_offset_secs, 75.0);
        // Dragging back pans the other way, from the new anchor.
        mouse(&mut app, MouseEventKind::Drag(MouseButton::Left), 15, 5);
        assert_eq!(view_of(&app).unwrap().right_offset_secs, 37.5);
        // Releasing ends the drag: further drag events are ignored.
        mouse(&mut app, MouseEventKind::Up(MouseButton::Left), 15, 5);
        mouse(&mut app, MouseEventKind::Drag(MouseButton::Left), 30, 5);
        assert_eq!(view_of(&app).unwrap().right_offset_secs, 37.5);
        // A press outside the chart anchors nothing.
        mouse(&mut app, MouseEventKind::Down(MouseButton::Left), 100, 50);
        mouse(&mut app, MouseEventKind::Drag(MouseButton::Left), 110, 50);
        assert_eq!(view_of(&app).unwrap().right_offset_secs, 37.5);
    }

    #[test]
    fn right_drag_zooms_about_the_grab_point_and_round_trips() {
        let mut app = scoped_app(4801); // 1200 s, 40 plot columns, 30 s/cell
        // Grab the window center (column 20 of the 1..=40 plot columns is
        // 600 s behind the newest sample) and drag 10 cells right.
        mouse(&mut app, MouseEventKind::Down(MouseButton::Right), 20, 5);
        mouse(&mut app, MouseEventKind::Drag(MouseButton::Right), 30, 5);
        let view = view_of(&app).expect("dragging right zooms in");
        let span = 1200.0 * ZOOM_DRAG_PER_CELL.powi(10);
        assert_close(view.span_secs, span);
        // The grabbed sample stays at the window center.
        assert_close(view.right_offset_secs, 600.0 - span / 2.0);
        // Dragging back to the start restores the full view.
        mouse(&mut app, MouseEventKind::Drag(MouseButton::Right), 20, 5);
        assert_eq!(view_of(&app), None);
    }

    #[test]
    fn right_drag_left_zooms_out_and_stops_at_the_full_view() {
        let mut app = scoped_app(4801);
        set_view(&mut app, 1000.0, 0.0);
        mouse(&mut app, MouseEventKind::Down(MouseButton::Right), 40, 5);
        mouse(&mut app, MouseEventKind::Drag(MouseButton::Right), 35, 5);
        assert_close(
            view_of(&app).unwrap().span_secs,
            1000.0 / ZOOM_DRAG_PER_CELL.powi(5),
        );
        // Zooming out past the available data: back to the full view, and
        // further left drags stay there.
        mouse(&mut app, MouseEventKind::Drag(MouseButton::Right), 30, 5);
        assert_eq!(view_of(&app), None);
        mouse(&mut app, MouseEventKind::Drag(MouseButton::Right), 20, 5);
        assert_eq!(view_of(&app), None);
    }

    #[test]
    fn right_drag_needs_an_anchor_and_release_ends_it() {
        let mut app = scoped_app(4801);
        // A press outside the chart anchors nothing.
        mouse(&mut app, MouseEventKind::Down(MouseButton::Right), 100, 50);
        mouse(&mut app, MouseEventKind::Drag(MouseButton::Right), 90, 50);
        assert_eq!(view_of(&app), None);
        // Release ends an in-chart drag: further drag events are ignored.
        mouse(&mut app, MouseEventKind::Down(MouseButton::Right), 20, 5);
        mouse(&mut app, MouseEventKind::Drag(MouseButton::Right), 25, 5);
        let span = view_of(&app).unwrap().span_secs;
        mouse(&mut app, MouseEventKind::Up(MouseButton::Right), 25, 5);
        mouse(&mut app, MouseEventKind::Drag(MouseButton::Right), 35, 5);
        assert_close(view_of(&app).unwrap().span_secs, span);
    }

    #[test]
    fn keyboard_zoom_stays_anchored_at_the_right_edge() {
        let mut app = scoped_app(4801);
        set_view(&mut app, 300.0, 300.0);
        press(&mut app, KeyCode::Char('+'));
        let view = view_of(&app).unwrap();
        assert_close(view.span_secs, 240.0);
        assert_close(view.right_offset_secs, 300.0);
    }

    #[test]
    fn history_shrink_clamps_the_scope_view() {
        let mut app = app_with(3, 10);
        feed_history(&mut app, 8 * 240); // 8 minutes of data
        press(&mut app, KeyCode::Char('s'));
        for _ in 0..2 {
            press(&mut app, KeyCode::Char('+')); // down to a ~307 s window
        }
        for _ in 0..100 {
            press(&mut app, KeyCode::Left); // pan to the oldest data
        }
        let offset = view_of(&app).unwrap().right_offset_secs;
        assert!(offset > 150.0, "should have panned well back: {offset}");

        // 15 -> 10 min: the 8 min of data still fit, nothing moves.
        press(&mut app, KeyCode::Char('<'));
        assert_eq!(view_of(&app).unwrap().right_offset_secs, offset);
        // 10 -> 5 min: the data is truncated under the window; a 300 s
        // window no longer fits inside 5 min of data, so back to full view.
        press(&mut app, KeyCode::Char('<'));
        assert_eq!(view_of(&app), None);
        assert_eq!(app.scope_trace.len(), 5 * 240);
    }

    #[test]
    fn paused_shrink_leaves_the_frozen_trace_alone() {
        let mut app = app_with(3, 10);
        feed_history(&mut app, 6 * 240);
        press(&mut app, KeyCode::Char('s'));
        let mut snap = empty_snap();
        snap.insert("CHAN_0".to_string(), entry(SignalData::Float64(1.0)));
        app.refresh_display(&snap);
        assert_eq!(app.scope_trace.len(), 6 * 240);

        press(&mut app, KeyCode::Char('p'));
        press(&mut app, KeyCode::Char('<')); // 15 -> 10 min
        press(&mut app, KeyCode::Char('<')); // 10 -> 5 min
        app.refresh_display(&snap);
        assert_eq!(app.scope_trace.len(), 6 * 240, "frozen trace must stay");
        // Unpausing jumps to the (truncated) master history.
        press(&mut app, KeyCode::Char('p'));
        app.refresh_display(&snap);
        assert_eq!(app.scope_trace.len(), 5 * 240);
    }

    #[test]
    fn view_slice_windows_the_trace() {
        let trace: Vec<Sample> = (0..9).map(|i| Some(f64::from(i))).collect(); // 2 s span
        let (slice, offset, span, more_left) = view_slice(&trace, None);
        assert_eq!((slice.len(), offset, span, more_left), (9, 0.0, 2.0, false));

        // 1 s of data ending 0.5 s behind the newest sample.
        let view = ScopeView {
            span_secs: 1.0,
            right_offset_secs: 0.5,
        };
        let (slice, offset, span, more_left) = view_slice(&trace, Some(view));
        assert_eq!((offset, span, more_left), (0.5, 1.0, true));
        assert_eq!(
            slice,
            vec![Some(2.0), Some(3.0), Some(4.0), Some(5.0), Some(6.0)]
        );

        // Pinned at the oldest edge: no more data to the left.
        let view = ScopeView {
            span_secs: 1.0,
            right_offset_secs: 1.0,
        };
        let (slice, _, _, more_left) = view_slice(&trace, Some(view));
        assert_eq!(slice[0], Some(0.0));
        assert!(!more_left);
    }

    #[test]
    fn lookback_and_size_formats() {
        assert_eq!(fmt_minutes(15), "15 min");
        assert_eq!(fmt_minutes(65), "65 min");
        assert_eq!(fmt_minutes(60), "1 h");
        assert_eq!(fmt_minutes(1440), "24 h");
        assert_eq!(fmt_bytes(2_304), "2.3 kB");
        assert_eq!(fmt_bytes(11_520_000), "11.5 MB");
        assert_eq!(fmt_bytes(1_660_000_000), "1.7 GB");
    }

    #[test]
    fn spans_and_deltas_pick_a_readable_unit() {
        assert_eq!(fmt_span(45.0), "45s");
        assert_eq!(fmt_span(900.0), "15min");
        assert_eq!(fmt_span(5_400.0), "90min"); // hours start at 2 h
        assert_eq!(fmt_span(9_000.0), "2.5h");
        assert_eq!(fmt_span(86_400.0), "24h");
        assert_eq!(fmt_delta(45.0), "-45s");
        assert_eq!(fmt_delta(9_000.0), "-2.5h");
    }

    #[test]
    fn day_scale_time_labels_use_hours() {
        assert_eq!(
            time_labels(0.0, 86_400.0, None),
            ["-24h", "-16h", "-8h", "0"]
        );
    }
}
