// Copyright (C) 2026 alto555
// SPDX-License-Identifier: GPL-2.0-only

//! waveview — interactive waveform viewer.
//!
//!   waveview <file.raw>
//!
//! Opens with an empty pane; click a signal in the list to show it. Wheel
//! zooms about the pointer, right-drag zooms to a region, a/b place cursors.

use std::sync::Arc;
use std::time::Instant;

use wave_core::{Component, Curve, Span, ValueKind, XScale};
use wave_render::{Canvas, Pane, PlotSpec, Trace, PALETTE};
use wave_store::{IndexCfg, WaveSource};
use wave_x11::{Kind, Window};

/// The key and mouse reference, shown in its own window.
///
/// A table rather than a string so it stays readable as bindings are added —
/// the status bar was already full, and this list is only going to grow.
/// An empty key marks a section heading.
const HELP: &[(&str, &str)] = &[
    ("", "SIGNALS"),
    ("click a subcircuit", "expand / collapse it"),
    ("expand an .ac signal", "pick db / phase / mag / real / imag"),
    ("click name", "show / hide in the current pane"),
    ("shift-click name", "also show it here (keep it elsewhere too)"),
    ("click a trace or its label", "select it (highlighted)"),
    ("shift-click a trace", "add / remove it from the selection"),
    ("Del", "remove every selected trace"),
    ("", "PANES"),
    ("click in a pane", "make it the current pane"),
    ("p", "new pane"),
    ("P", "delete the current pane"),
    ("d", "clear the current pane"),
    ("n", "next pane"),
    ("", "ZOOM AND PAN"),
    ("wheel", "zoom time about the pointer"),
    ("shift-wheel", "zoom that pane's amplitude"),
    ("wheel on Y labels", "same - zoom amplitude"),
    ("middle-drag", "pan"),
    ("right-drag in a pane", "zoom to the box, both axes"),
    ("right-drag below X axis", "zoom X only"),
    ("right-drag on Y labels", "zoom that pane's Y only"),
    ("right-click", "zoom out 2x (amplitude if on Y labels)"),
    ("f", "fit both axes"),
    ("arrows / PgUp / PgDn", "pan, and scroll the signal list"),
    ("", "CURSORS"),
    ("drag a cursor", "move it; readouts follow"),
    ("a  /  b", "place cursor 1 / cursor 2 at the pointer"),
    ("x  /  X", "snap cursor 1 / 2 to the selected trace's zero"),
    ("A  /  B", "remove cursor 1 / cursor 2"),
    ("c", "clear both cursors"),
    ("two cursors", "dy and slope per trace, in its own colour"),
    ("", "MEASURE"),
    ("m", "toggle the dy/slope labels in the panes"),
    ("s", "statistics for every trace, in its own window"),
    ("", "OTHER"),
    ("r", "reload the file (after a re-run)"),
    ("o  /  O", "open a file / add one alongside (compare runs)"),
    ("w", "write a PNG of the window"),
    ("status bar", "frame time and KB pushed to the display"),
    ("h  or  ?", "this help"),
    ("q  or  Esc", "quit"),
];

/// The help window, laid out to fit the display.
///
/// At the largest font the full key list fits neither in one column (too
/// tall) nor in two (too wide), so the help — reference text, not the
/// waveform — steps down a size until it fits.  Better slightly smaller and
/// whole than nominally larger with the bottom third off the screen.
fn help_canvas(screen: (u16, u16)) -> Canvas {
    let want = wave_render::font::current().px;
    let sizes = wave_render::font::sizes();
    let mut choice = want;
    for px in sizes.iter().rev() {
        if *px > want {
            continue;
        }
        wave_render::font::set_size(*px);
        let (w, h) = help_extent(screen);
        choice = *px;
        if w <= screen.0 as i64 && h <= screen.1 as i64 - 60 {
            break;
        }
    }
    wave_render::font::set_size(choice);
    let cv = help_draw(screen);
    wave_render::font::set_size(want);
    cv
}

/// Size the help window would take at the current font.
fn help_extent(screen: (u16, u16)) -> (i64, i64) {
    let cv = help_draw(screen);
    (cv.w as i64, cv.h as i64)
}

fn help_draw(screen: (u16, u16)) -> Canvas {
    let cw = wave_render::font::cw();
    let ch = wave_render::font::ch();
    // Column geometry from the actual longest entries rather than a guess:
    // at a larger font a fixed column let the key text run into its
    // description.
    let keyw = HELP.iter().filter(|(k, _)| !k.is_empty()).map(|(k, _)| k.len()).max().unwrap_or(10);
    let descw = HELP.iter().map(|(_, d)| d.len()).max().unwrap_or(30);
    let col_w = (keyw + 2 + descw) as i64 * cw + 40;
    let row_h = ch + 3;
    let head_h = ch * 2;

    // Group into sections first.  A section must not be split across a
    // column break — a run of keys under someone else's heading is worse
    // than an uneven column.
    let mut sections: Vec<(usize, usize)> = Vec::new();
    for (i, (k, _)) in HELP.iter().enumerate() {
        if k.is_empty() {
            sections.push((i, i + 1));
        } else if let Some(last) = sections.last_mut() {
            last.1 = i + 1;
        } else {
            sections.push((i, i + 1));
        }
    }
    // A heading costs an extra blank row above it and a rule below.
    let sec_rows = |s: &(usize, usize)| (s.1 - s.0) as i64 + 1;

    let total: i64 = sections.iter().map(sec_rows).sum();
    // Go to two columns only when one column would not fit the screen, and
    // only if two columns still would.  Whichever way it goes the window has
    // to land inside the display; a help window with its right half off the
    // edge helps nobody.
    let (max_w, max_h) = (screen.0 as i64 - 80, screen.1 as i64 - 80);
    let one_col_h = head_h + total * row_h + 16;
    let cols = if one_col_h > max_h && col_w * 2 <= max_w { 2 } else { 1 };

    // Split at the section boundary that minimises the taller column, rather
    // than filling column one greedily and dumping the rest into column two.
    let mut layout: Vec<Vec<(usize, usize)>> = vec![Vec::new(); cols as usize];
    if cols == 1 {
        layout[0] = sections.clone();
    } else {
        let mut best = (i64::MAX, 1usize);
        for k in 1..sections.len() {
            let a: i64 = sections[..k].iter().map(sec_rows).sum();
            let b: i64 = sections[k..].iter().map(sec_rows).sum();
            let worst = a.max(b);
            if worst < best.0 {
                best = (worst, k);
            }
        }
        layout[0] = sections[..best.1].to_vec();
        layout[1] = sections[best.1..].to_vec();
    }

    let tallest = layout
        .iter()
        .map(|col| col.iter().map(sec_rows).sum::<i64>())
        .max()
        .unwrap_or(total);
    let h = head_h + tallest * row_h + 16;
    let w = (col_w * cols) as usize;

    let mut cv = Canvas::new(w, h as usize, wave_render::PANEL);
    cv.text(16, 8, "waveview - keys and mouse", wave_render::TEXT);

    for (ci, col) in layout.iter().enumerate() {
        let x0 = 16 + ci as i64 * col_w;
        let mut y = head_h;
        for (a, b) in col {
            for (k, d) in &HELP[*a..*b] {
                if k.is_empty() {
                    y += 8;
                    cv.text(x0, y, d, wave_render::CURSOR);
                    cv.hline(x0, x0 + col_w - 32, y + ch + 1, wave_render::GRID);
                    y += row_h + 4;
                } else {
                    cv.text(x0 + 8, y, k, wave_render::TEXT);
                    cv.text(x0 + 8 + (keyw as i64 + 2) * cw, y, d, wave_render::TEXT_DIM);
                    y += row_h;
                }
            }
        }
    }
    cv
}

/// One node of the signal hierarchy.
///
/// Arena-indexed rather than boxed children, which keeps expand/collapse a
/// plain field write instead of a fight with the borrow checker.
struct Node {
    label: String,
    /// Which open file this row came from.
    src: usize,
    /// Stable identity across a rebuild — used to keep expansion state.
    path: String,
    depth: usize,
    children: Vec<usize>,
    /// Set on leaves: the variable this row plots.
    var: Option<usize>,
    /// Which projection of a complex value this row plots.
    comp: Component,
    expanded: bool,
}

/// Split a raw variable name into an instance path plus a leaf label.
///
/// ngspice writes three shapes in one file:
///   v(xctrl_a.xi0.outp)            net inside a subcircuit
///   i(e.xctrl_a.xi0.xst1.ecmim)    element current, leading device letter
///   i(@m.xio.xi35.m5[id])          device internal current, @letter and [param]
/// The device letter and the `@` are notation, not hierarchy, so they are
/// stripped — otherwise every device current hangs off a bogus "m" root.
fn split_path(name: &str) -> (Vec<String>, String) {
    let (kind, inner) = match name.find('(') {
        Some(i) if name.ends_with(')') => (&name[..i], &name[i + 1..name.len() - 1]),
        _ => ("", name),
    };
    let mut inner = inner.to_string();
    if let Some(rest) = inner.strip_prefix('@') {
        inner = rest.to_string();
    }
    let mut parts: Vec<String> = inner.split('.').map(|s| s.to_string()).collect();
    if kind == "i" && parts.len() > 1 && parts[0].len() == 1 {
        parts.remove(0);
    }
    let leaf = parts.pop().unwrap_or_default();
    let leaf = if kind == "i" {
        format!("i({})", leaf)
    } else {
        leaf
    };
    (parts, leaf)
}

fn short_file(p: &str) -> &str {
    p.rsplit('/').next().unwrap_or(p)
}

// Every metric below is derived from the text size: with a larger font a
// fixed 250 px sidebar clips the names and a fixed 19 px row overlaps.
fn sidebar() -> i64 {
    wave_render::font::cw() * 30 + 10
}
fn status_h() -> i64 {
    wave_render::font::ch() + 9
}
fn row_h() -> i64 {
    wave_render::font::ch() + 2
}
fn list_top() -> i64 {
    wave_render::font::ch() * 2 + 10
}

/// A trace is a signal plus which projection of it to draw.  For real data
/// the projection is always `Real`; for `.ac` data the same signal can appear
/// as magnitude, dB and phase at once.
/// A trace is (file, signal, projection).  The file index is first because
/// with several runs open the same signal name exists in each of them.
type TraceId = (usize, usize, Component);

/// What a right-drag is zooming.  Which one is decided by where the drag
/// starts: inside a pane zooms both axes, below the X axis zooms time only,
/// over the Y labels zooms that pane's amplitude only.
#[derive(Clone, Copy, PartialEq)]
enum BandKind {
    Box,
    XOnly,
    YOnly,
}

#[derive(Clone, Copy)]
struct Band {
    kind: BandKind,
    pane: usize,
    x0: i16,
    y0: i16,
    x1: i16,
    y1: i16,
}

struct App {
    srcs: Vec<WaveSource>,
    /// Signal hierarchy, built once from the variable names.
    nodes: Vec<Node>,
    roots: Vec<usize>,
    /// Flattened visible rows, recomputed when something expands.
    rows: Vec<usize>,
    panes: Vec<Vec<TraceId>>,
    cur_pane: usize,
    span: Span,
    full: Span,
    x_scale: XScale,
    /// Cursor 1 and cursor 2, each independently present or absent.
    cursors: [Option<f64>; 2],
    /// Last pointer position, so `a`/`b` can drop a cursor where you are
    /// looking rather than where you last clicked.
    mouse: (i16, i16),
    scroll: usize,
    file: String,
    /// Full paths, for reload and for the open prompt's default.
    paths: Vec<String>,
    /// Text being typed into the open prompt, and whether Enter should add
    /// the file alongside the current ones rather than replace them.
    prompt: Option<String>,
    prompt_adds: bool,
    status: String,
    /// Middle-drag panning state.
    drag: Option<(i16, Span)>,
    /// Cursor being dragged, if the pointer grabbed one.
    cursor_drag: Option<usize>,
    /// Per-pane manual Y range; `None` means autoscale.
    y_range: Vec<Option<(f64, f64)>>,
    /// Y range each pane was last drawn with, so a mouse position can be
    /// turned back into a voltage.
    pane_y: Vec<(f64, f64)>,
    /// Emphasised traces as (pane, index within pane).  Shift-click extends
    /// the set so a batch can be removed in one go.
    selected: Vec<(usize, usize)>,
    band: Option<Band>,
    /// Id of the help window while it is open.
    help_win: Option<u32>,
    /// Statistics window, and the table last rendered into it.
    stats_win: Option<u32>,
    stats_img: Option<Canvas>,
    /// Whether the per-trace dy/slope labels are drawn in the panes.
    show_deltas: bool,
    last_frame_ms: f64,
    /// Kilobytes the last repaint actually pushed to the display.
    last_push_kb: usize,
}

impl App {
    /// Building a curve is a few reference-count bumps; the heavy state lives
    /// in the shared store, so there is nothing worth caching here.
    fn curve(&self, t: TraceId) -> Arc<dyn Curve> {
        let src = &self.srcs[t.0.min(self.srcs.len() - 1)];
        src.curve_component(t.1, t.2)
            .unwrap_or_else(|| src.curve(t.1))
    }

    /// Legend label: bare signal name with one file open, prefixed with the
    /// file when several are, since the names repeat across runs.
    fn trace_label(&self, t: TraceId) -> Option<String> {
        if self.srcs.len() < 2 {
            return None;
        }
        Some(format!(
            "{}:{}",
            short_file(&self.paths[t.0]),
            self.curve(t).name()
        ))
    }

    fn src(&self) -> &WaveSource {
        &self.srcs[0]
    }

    /// Pane under a click, in plot-canvas coordinates.
    fn pane_at(&self, py: i64, plot_h: usize) -> Option<usize> {
        for i in 0..self.panes.len() {
            let (a, b) = wave_render::pane_bounds(plot_h, self.panes.len(), i);
            if py >= a && py < b {
                return Some(i);
            }
        }
        None
    }

    /// Cursor whose line is within a few pixels of a click, so it can be
    /// dragged.  Keying a cursor to the pointer is fine for a transient, but
    /// parking one exactly on a 0 dB crossing needs the mouse.
    fn cursor_at(&self, px: i64, plot_w: usize) -> Option<usize> {
        let (x0, x1) = wave_render::plot_x_bounds(plot_w);
        let mut best: Option<(usize, i64)> = None;
        for (i, c) in self.cursors.iter().enumerate() {
            if let Some(cx) = c {
                let cpx = {
                    let t = match self.x_scale {
                        XScale::Log => {
                            let (a, b) = (self.span.x0.max(1e-300).log10(), self.span.x1.max(1e-300).log10());
                            if b > a { (cx.max(1e-300).log10() - a) / (b - a) } else { 0.0 }
                        }
                        XScale::Linear => {
                            if self.span.width() > 0.0 {
                                (cx - self.span.x0) / self.span.width()
                            } else {
                                0.0
                            }
                        }
                    };
                    x0 + (t * (x1 - x0) as f64).round() as i64
                };
                let d = (cpx - px).abs();
                if d <= 4 && best.map_or(true, |(_, bd)| d < bd) {
                    best = Some((i, d));
                }
            }
        }
        best.map(|(i, _)| i)
    }

    /// Trace under a click inside a pane.
    ///
    /// Tests against the envelope actually drawn in that pixel column rather
    /// than against a single sample: zoomed out, a fast signal is a band tens
    /// of pixels tall and clicking inside it should count as clicking it.
    fn trace_at(&self, px: i64, py: i64, plot_w: usize, plot_h: usize) -> Option<(usize, usize)> {
        let pi = self.pane_at(py, plot_h)?;
        let (py0, py1) = wave_render::pane_bounds(plot_h, self.panes.len(), pi);
        let (ylo, yhi) = *self.pane_y.get(pi)?;
        if !(yhi > ylo) {
            return None;
        }
        let xa = wave_render::px_to_x(self.span, self.x_scale, plot_w, px);
        let xb = wave_render::px_to_x(self.span, self.x_scale, plot_w, px + 1);
        let col = Span::new(xa.min(xb), xa.max(xb));
        let to_px = |v: f64| py1 as f64 - ((v - ylo) / (yhi - ylo)) * (py1 - py0) as f64;

        let mut best: Option<(usize, f64)> = None;
        for (k, t) in self.panes[pi].iter().enumerate() {
            let c = self.curve(*t);
            let r = match c.reduce(col, 1) {
                wave_core::Poll::Ready(r) if !r.is_empty() => r,
                _ => continue,
            };
            let (a, b) = (to_px(r.max[0] as f64), to_px(r.min[0] as f64));
            let (top, bot) = (a.min(b), a.max(b));
            let d = if (py as f64) < top {
                top - py as f64
            } else if (py as f64) > bot {
                py as f64 - bot
            } else {
                0.0
            };
            if d <= 6.0 && best.map_or(true, |(_, bd)| d < bd) {
                best = Some((k, d));
            }
        }
        best.map(|(k, _)| (pi, k))
    }

    /// Legend entry under a click, if any: (pane, index within that pane).
    fn legend_at(&self, px: i64, py: i64, plot_h: usize) -> Option<(usize, usize)> {
        let pi = self.pane_at(py, plot_h)?;
        let (a, _) = wave_render::pane_bounds(plot_h, self.panes.len(), pi);
        let (ly0, ly1) = wave_render::legend_y(a);
        if py < ly0 || py > ly1 {
            return None;
        }
        // The same labels the renderer draws.  Hit-testing against the bare
        // signal name while the legend showed "file:signal" made the boxes
        // far narrower than the text: clicking the second entry landed in the
        // first one's box, or missed everything.
        let names: Vec<String> = self.panes[pi]
            .iter()
            .map(|t| {
                self.trace_label(*t)
                    .unwrap_or_else(|| self.curve(*t).name().to_string())
            })
            .collect();
        for (k, (x0, x1)) in wave_render::legend_entries(&names).into_iter().enumerate() {
            if px >= x0 && px <= x1 {
                return Some((pi, k));
            }
        }
        None
    }

    /// Closest pane to a y position, for drags that start outside every pane.
    fn nearest_pane(&self, py: i64, plot_h: usize) -> usize {
        let mut best = 0usize;
        let mut best_d = i64::MAX;
        for i in 0..self.panes.len() {
            let (a, b) = wave_render::pane_bounds(plot_h, self.panes.len(), i);
            let d = if py < a { a - py } else if py > b { py - b } else { 0 };
            if d < best_d {
                best_d = d;
                best = i;
            }
        }
        best
    }

    /// Turn a finished rubber band into a new view.
    ///
    /// A drag too small to be deliberate is treated as a plain right-click and
    /// zooms out, so a mis-click does not leave you staring at three samples.
    fn apply_band(&mut self, plot_w: usize, plot_h: usize) {
        let b = match self.band.take() {
            Some(b) => b,
            None => return,
        };
        let (ax, bx) = (b.x0.min(b.x1) as i64, b.x0.max(b.x1) as i64);
        let (ay, by) = (b.y0.min(b.y1) as i64, b.y0.max(b.y1) as i64);
        let wide = bx - ax >= 5;
        let tall = by - ay >= 5;

        if !wide && !tall {
            // Zoom out the axis the click was aimed at, mirroring how a drag
            // there would have zoomed in.
            match b.kind {
                BandKind::YOnly => {
                    if let Some(v) = self.value_at_pixel(b.pane, ay, plot_h) {
                        self.zoom_y(b.pane, 2.0, v);
                    }
                }
                _ => {
                    let dx = wave_render::px_to_x(self.span, self.x_scale, plot_w, ax - sidebar());
                    self.zoom(2.0, dx);
                    // If this pane's amplitude was zoomed by hand, back it out
                    // too — otherwise Y stays stuck wherever it was left.
                    if self.y_range.get(b.pane).copied().flatten().is_some() {
                        if let Some(v) = self.value_at_pixel(b.pane, ay, plot_h) {
                            self.zoom_y(b.pane, 2.0, v);
                        }
                    }
                }
            }
            return;
        }
        if b.kind != BandKind::YOnly && wide {
            let x0 = wave_render::px_to_x(self.span, self.x_scale, plot_w, ax - sidebar());
            let x1 = wave_render::px_to_x(self.span, self.x_scale, plot_w, bx - sidebar());
            self.span = Span::new(x0.min(x1), x0.max(x1));
            self.clamp();
        }
        if b.kind != BandKind::XOnly && tall {
            if let Some((ylo, yhi)) = self.pane_y.get(b.pane).copied() {
                let (py0, py1) = wave_render::pane_bounds(plot_h, self.panes.len(), b.pane);
                let denom = (py1 - py0).max(1) as f64;
                let val = |yp: i64| ylo + ((py1 - yp) as f64 / denom) * (yhi - ylo);
                let (v0, v1) = (val(ay), val(by));
                let (lo, hi) = (v0.min(v1), v0.max(v1));
                if hi > lo {
                    self.y_range.resize(self.panes.len(), None);
                    self.y_range[b.pane] = Some((lo, hi));
                }
            }
        }
    }

    /// Open one or more files, keeping as much of the current view as still
    /// applies.
    ///
    /// Signals are re-bound by (file, NAME), not by index: a re-run may have
    /// added or dropped nodes, and index 37 in the new file is not
    /// necessarily the signal you were watching.  Anything that no longer
    /// exists is dropped and reported rather than silently plotting the wrong
    /// trace.
    fn load_all(&mut self, paths: &[String]) -> Result<(), String> {
        let mut srcs = Vec::with_capacity(paths.len());
        for p in paths {
            srcs.push(
                WaveSource::open_raw(p, IndexCfg::default(), true)
                    .map_err(|e| format!("{}: {}", p, e))?,
            );
        }

        // Remember the view in terms that survive a different variable list.
        let old: Vec<Vec<(String, String, Component)>> = self
            .panes
            .iter()
            .map(|p| {
                p.iter()
                    .filter_map(|(si, v, c)| {
                        let f = self.paths.get(*si)?.clone();
                        let n = self.srcs.get(*si)?.reader().variables().get(*v)?.name.clone();
                        Some((f, n, *c))
                    })
                    .collect()
            })
            .collect();
        let expanded: std::collections::HashSet<String> = self
            .nodes
            .iter()
            .filter(|n| n.expanded)
            .map(|n| n.path.clone())
            .collect();
        let keep_span = self.span;

        self.srcs = srcs;
        self.paths = paths.to_vec();
        let (nodes, roots) = build_tree(&self.srcs, &self.paths);
        self.nodes = nodes;
        self.roots = roots;
        for n in self.nodes.iter_mut() {
            if expanded.contains(&n.path) {
                n.expanded = true;
            }
        }
        self.reflow();

        self.x_scale = self.srcs[0].x_scale();
        // Union of every file's range: comparing runs of different length is
        // exactly the case this exists for.
        let mut full = self.srcs[0].x_span();
        for s in self.srcs.iter().skip(1) {
            let o = s.x_span();
            full = Span::new(full.x0.min(o.x0), full.x1.max(o.x1));
        }
        if !(full.x1 > full.x0) {
            full = Span::new(0.0, 1.0);
        }
        self.full = full;
        self.span = if keep_span.x0 >= full.x0 && keep_span.x1 <= full.x1 && keep_span.width() > 0.0
        {
            keep_span
        } else {
            full
        };

        let mut missing = 0usize;
        self.panes = old
            .iter()
            .map(|p| {
                p.iter()
                    .filter_map(|(f, name, c)| {
                        let si = self.paths.iter().position(|q| q == f)?;
                        match self.srcs[si].find(name) {
                            Some(v) => Some((si, v, *c)),
                            None => {
                                missing += 1;
                                None
                            }
                        }
                    })
                    .collect()
            })
            .collect();
        if self.panes.is_empty() {
            self.panes = vec![Vec::new()];
        }
        self.cur_pane = self.cur_pane.min(self.panes.len() - 1);
        self.selected.clear();
        self.y_range.clear();
        self.y_range.resize(self.panes.len(), None);
        self.scroll = 0;
        self.file = paths
            .iter()
            .map(|p| short_file(p))
            .collect::<Vec<_>>()
            .join(" + ");
        self.status = if missing > 0 {
            format!("{}  ({} signal(s) no longer present)", self.file, missing)
        } else {
            format!("{}  loaded", self.file)
        };
        Ok(())
    }

    /// Render the per-trace statistics table.
    ///
    /// Its own window rather than the status line: with several traces this
    /// is a table, and a table does not fit on one row.  Measured over the
    /// cursor span when both cursors are down, otherwise over the view.
    fn stats_canvas(&self) -> Canvas {
        let (a, b) = match (self.cursors[0], self.cursors[1]) {
            (Some(c1), Some(c2)) => (c1.min(c2), c1.max(c2)),
            _ => (self.span.x0, self.span.x1),
        };
        let span = Span::new(a, b);
        let xu = if self.x_scale == XScale::Log {
            wave_core::Unit::Hertz
        } else {
            wave_core::Unit::Second
        };

        struct Row {
            color: wave_render::Color,
            name: String,
            cells: Vec<String>,
        }
        let heads = ["signal", "min", "max", "pp", "avg", "rms", "freq", "duty"];
        let mut rows: Vec<Row> = Vec::new();
        for (pi, p) in self.panes.iter().enumerate() {
            for (k, t) in p.iter().enumerate() {
                let c = self.curve(*t);
                let u = c.unit();
                let st = wave_meas::stats(&*c, span);
                let per = st.and_then(|s0| {
                    wave_meas::periodic_with(
                        &*c,
                        span,
                        wave_meas::Levels { low: s0.min, high: s0.max },
                    )
                    .filter(|p| {
                        // A "period" only a few samples long is noise being
                        // Schmitt-triggered, not a signal.
                        let span_w = s0.x1 - s0.x0;
                        span_w > 0.0
                            && p.period > 0.0
                            && (s0.n as f64) * p.period / span_w >= 8.0
                    })
                });
                let cells = match st {
                    None => vec!["-".into(); 7],
                    Some(s0) => vec![
                        wave_render::fmt_eng(s0.min, u),
                        wave_render::fmt_eng(s0.max, u),
                        wave_render::fmt_eng(s0.peak_to_peak(), u),
                        wave_render::fmt_eng(s0.mean, u),
                        wave_render::fmt_eng(s0.rms, u),
                        per.map(|p| wave_render::fmt_eng(p.frequency, wave_core::Unit::Hertz))
                            .unwrap_or_else(|| "-".into()),
                        per.map(|p| format!("{:.1}%", p.duty))
                            .unwrap_or_else(|| "-".into()),
                    ],
                };
                rows.push(Row {
                    color: wave_render::PALETTE[(k + pi) % wave_render::PALETTE.len()],
                    name: self.trace_label(*t).unwrap_or_else(|| c.name().to_string()),
                    cells,
                });
            }
        }

        let cw = wave_render::font::cw() as usize;
        let ch = wave_render::font::ch();
        let name_w = rows.iter().map(|r| r.name.len()).max().unwrap_or(6).max(6) + 2;
        let col_w = 13usize;
        let w = 24 + (name_w + col_w * 7) * cw + 24;
        let h = ch * 4 + rows.len().max(1) as i64 * (ch + 3) + 16;
        let mut cv = Canvas::new(w, h as usize, wave_render::PANEL);
        cv.text(16, 10, "waveview - statistics", wave_render::TEXT);
        cv.text(
            16,
            30,
            &format!(
                "over {} .. {}   ({}){}",
                wave_render::fmt_eng(a, xu),
                wave_render::fmt_eng(b, xu),
                wave_render::fmt_eng(b - a, xu),
                if self.cursors[0].is_some() && self.cursors[1].is_some() {
                    "  between cursors"
                } else {
                    "  the visible range - place both cursors to narrow it"
                }
            ),
            wave_render::TEXT_DIM,
        );
        let head_y = ch * 2 + 6;
        let mut x = 24i64;
        cv.text(x, head_y, heads[0], wave_render::CURSOR);
        x += name_w as i64 * cw as i64;
        for hcell in &heads[1..] {
            cv.text(x, head_y, hcell, wave_render::CURSOR);
            x += col_w as i64 * cw as i64;
        }
        cv.hline(16, w as i64 - 16, head_y + ch, wave_render::GRID);
        for (i, r) in rows.iter().enumerate() {
            let y = head_y + ch + 6 + i as i64 * (ch + 3);
            cv.text(24, y, &r.name, r.color);
            let mut x = 24 + name_w as i64 * cw as i64;
            for cell in &r.cells {
                cv.text(x, y, cell, wave_render::TEXT);
                x += col_w as i64 * cw as i64;
            }
        }
        if rows.is_empty() {
            cv.text(24, head_y + ch + 6, "nothing plotted", wave_render::TEXT_DIM);
        }
        cv
    }

    /// Recompute which rows are visible after an expand or collapse.
    fn reflow(&mut self) {
        fn walk(nodes: &[Node], list: &[usize], out: &mut Vec<usize>) {
            for i in list {
                out.push(*i);
                if nodes[*i].expanded {
                    let kids = nodes[*i].children.clone();
                    walk(nodes, &kids, out);
                }
            }
        }
        let mut out = Vec::new();
        let roots = self.roots.clone();
        walk(&self.nodes, &roots, &mut out);
        self.rows = out;
    }

    /// Extend or replace the selection.
    fn select(&mut self, hit: (usize, usize), extend: bool) {
        if extend {
            match self.selected.iter().position(|s| *s == hit) {
                Some(i) => {
                    self.selected.remove(i);
                }
                None => self.selected.push(hit),
            }
        } else if self.selected == vec![hit] {
            self.selected.clear();
        } else {
            self.selected = vec![hit];
        }
    }


    /// Clicking a signal shows it, clicking it again hides it.
    ///
    /// "Again" has to mean *wherever it is*, not "in the current pane".
    /// Scoping the removal to the current pane meant clicking a visible trace
    /// added a second copy of it to some other pane instead of removing it.
    fn toggle(&mut self, t: TraceId, force_add: bool) {
        let shown = self.panes.iter().any(|p| p.contains(&t));
        if shown && !force_add {
            for p in self.panes.iter_mut() {
                p.retain(|v| *v != t);
            }
        } else {
            let p = self.cur_pane;
            if !self.panes[p].contains(&t) {
                self.panes[p].push(t);
            }
        }
    }

    /// Drop a pane and keep the selection somewhere sensible.
    fn remove_pane(&mut self, i: usize) {
        if self.panes.len() <= 1 {
            self.panes[0].clear();
            return;
        }
        self.panes.remove(i);
        if self.cur_pane >= self.panes.len() {
            self.cur_pane = self.panes.len() - 1;
        }
    }

    fn zoom(&mut self, factor: f64, about: f64) {
        match self.x_scale {
            XScale::Log => {
                let (a, b, c) = (
                    self.span.x0.max(1e-300).log10(),
                    self.span.x1.max(1e-300).log10(),
                    about.max(1e-300).log10(),
                );
                let (na, nb) = (c + (a - c) * factor, c + (b - c) * factor);
                self.span = Span::new(10f64.powf(na), 10f64.powf(nb));
            }
            XScale::Linear => {
                self.span = Span::new(
                    about + (self.span.x0 - about) * factor,
                    about + (self.span.x1 - about) * factor,
                );
            }
        }
        self.clamp();
    }

    /// Data value at a pixel row within a pane, using the range that pane was
    /// last drawn with.
    fn value_at_pixel(&self, pane: usize, ypix: i64, plot_h: usize) -> Option<f64> {
        let (ylo, yhi) = self.y_span_of(pane)?;
        let (py0, py1) = wave_render::pane_bounds(plot_h, self.panes.len(), pane);
        let denom = (py1 - py0).max(1) as f64;
        Some(ylo + ((py1 - ypix) as f64 / denom) * (yhi - ylo))
    }

    /// The Y range a pane is currently showing: the manual one if set,
    /// otherwise whatever autoscale last produced.
    fn y_span_of(&self, pane: usize) -> Option<(f64, f64)> {
        match self.y_range.get(pane).copied().flatten() {
            Some(r) => Some(r),
            None => self.pane_y.get(pane).copied(),
        }
    }

    /// Zoom one pane's amplitude about a data value.
    ///
    /// Starting from the autoscaled range when none is set, so the first
    /// zoom-out step continues from what is on screen rather than jumping.
    fn zoom_y(&mut self, pane: usize, factor: f64, about: f64) {
        let (lo, hi) = match self.y_span_of(pane) {
            Some(r) => r,
            None => return,
        };
        let (nlo, nhi) = (about + (lo - about) * factor, about + (hi - about) * factor);
        if nhi > nlo && nlo.is_finite() && nhi.is_finite() {
            self.y_range.resize(self.panes.len(), None);
            self.y_range[pane] = Some((nlo, nhi));
        }
    }

    fn pan(&mut self, frac: f64) {
        let w = self.span.width();
        self.span = Span::new(self.span.x0 + w * frac, self.span.x1 + w * frac);
        self.clamp();
    }

    /// Keep the view inside the data and never let it collapse to nothing.
    fn clamp(&mut self) {
        if !(self.span.x1 > self.span.x0) || !self.span.x0.is_finite() || !self.span.x1.is_finite() {
            self.span = self.full;
            return;
        }
        let min_w = self.full.width() * 1e-9;
        if self.span.width() < min_w {
            let c = (self.span.x0 + self.span.x1) * 0.5;
            self.span = Span::new(c - min_w / 2.0, c + min_w / 2.0);
        }
    }

    fn draw(&mut self, cv: &mut Canvas) {
        let t0 = Instant::now();
        self.y_range.resize(self.panes.len(), None);
        let w = cv.w as i64;
        let h = cv.h as i64;
        cv.fill_rect(0, 0, w, h, wave_render::BG);

        // ---- plot area ------------------------------------------------
        let pw = (w - sidebar()).max(80) as usize;
        let ph = (h - status_h()).max(80) as usize;
        let pane_ids: Vec<Vec<TraceId>> = self.panes.clone();
        let mut owned: Vec<Arc<dyn Curve>> = Vec::new();
        let mut idx: Vec<Vec<usize>> = Vec::new();
        for p in &pane_ids {
            let mut v = Vec::new();
            for t in p {
                v.push(owned.len());
                owned.push(self.curve(*t));
            }
            idx.push(v);
        }

        // The panes are always drawn, even when nothing is plotted anywhere.
        // Skipping the render when no trace exists made every pane vanish the
        // moment the last signal was removed — two empty panes should still
        // look like two empty panes.
        {
            let panes: Vec<Pane> = idx
                .iter()
                .enumerate()
                .map(|(pi, v)| {
                    let mut p = Pane::new(
                        v.iter()
                            .enumerate()
                            .map(|(k, &oi)| Trace {
                                curve: &*owned[oi],
                                color: PALETTE[(k + pi) % PALETTE.len()],
                                label: self.trace_label(pane_ids[pi][k]),
                            })
                            .collect(),
                    );
                    p.current = pi == self.cur_pane;
                    p.y_range = self.y_range.get(pi).copied().flatten();
                    p.selected = self
                        .selected
                        .iter()
                        .filter(|(sp, _)| *sp == pi)
                        .map(|(_, sk)| *sk)
                        .collect();
                    p
                })
                .collect();
            let spec = PlotSpec {
                width: pw,
                height: ph,
                title: String::new(),
                span: self.span,
                x_scale: self.x_scale,
                // From the file's X variable, not from a trace: there may
                // not be a trace.
                x_unit: self
                    .src()
                    .reader()
                    .variables()
                    .first()
                    .map(|v| v.unit)
                    .unwrap_or(wave_core::Unit::Unknown),
                x_label: match self
                    .src()
                    .reader()
                    .variables()
                    .first()
                    .map(|v| v.unit)
                    .unwrap_or(wave_core::Unit::Unknown)
                {
                    wave_core::Unit::Hertz => "frequency (Hz)".into(),
                    wave_core::Unit::Second => "time (s)".into(),
                    _ => "x".into(),
                },
                panes,
                show_deltas: self.show_deltas,
                cursors: self
                    .cursors
                    .iter()
                    .enumerate()
                    .filter_map(|(i, c)| c.map(|x| (i, x)))
                    .collect(),
            };
            let plot = wave_render::render(&spec);
            let pane_y = plot.pane_y.clone();
            cv.blit(&plot.canvas, sidebar(), 0);
            self.pane_y = pane_y;
        }

        // ---- sidebar --------------------------------------------------
        cv.fill_rect(0, 0, sidebar(), h, wave_render::PANEL);
        cv.vline(sidebar() - 1, 0, h, wave_render::AXIS);
        let fch = wave_render::font::ch();
        cv.text(10, 5, &self.file, wave_render::TEXT);
        cv.text(
            10,
            5 + fch,
            &format!(
                "{} signals   pane {}/{}",
                // Across every open file, not just the first.
                self.srcs.iter().map(|s| s.reader().n_vars() - 1).sum::<usize>(),
                self.cur_pane + 1,
                self.panes.len()
            ),
            wave_render::TEXT_DIM,
        );

        let vis = ((h - list_top() - 4) / row_h()).max(1) as usize;
        for r in 0..vis {
            let ri = self.scroll + r;
            if ri >= self.rows.len() {
                break;
            }
            let ni = self.rows[ri];
            let y = list_top() + r as i64 * row_h();
            let depth = self.nodes[ni].depth as i64;
            let ind = 6 + depth * 11;
            let is_leaf = self.nodes[ni].children.is_empty();

            // Colour dot in the colour the trace is drawn, so the list and
            // the plot agree at a glance.
            let mut shown = None;
            if let Some(var) = self.nodes[ni].var {
                let t = (self.nodes[ni].src, var, self.nodes[ni].comp);
                for (pi, p) in self.panes.iter().enumerate() {
                    if let Some(k) = p.iter().position(|v| *v == t) {
                        shown = Some(PALETTE[(k + pi) % PALETTE.len()]);
                    }
                }
            }

            let mut label = self.nodes[ni].label.clone();
            if !is_leaf {
                cv.text(
                    ind,
                    y,
                    if self.nodes[ni].expanded { "-" } else { "+" },
                    wave_render::CURSOR,
                );
                if !self.nodes[ni].expanded {
                    // Child count while collapsed: the cheapest way to tell a
                    // big subcircuit from an empty one without opening it.
                    label.push_str(&format!("  ({})", self.nodes[ni].children.len()));
                }
            } else if let Some(c) = shown {
                cv.fill_rect(ind, y + 5, 8, 8, c);
            }

            let tx = ind + 12;
            let room = ((sidebar() - tx - 6) / 8).max(4) as usize;
            if label.len() > room {
                label.truncate(room.saturating_sub(1));
                label.push('~');
            }
            let col = if !is_leaf {
                wave_render::TEXT
            } else if shown.is_some() {
                wave_render::TEXT
            } else {
                wave_render::TEXT_DIM
            };
            cv.text(tx, y, &label, col);
        }
        if self.scroll + vis < self.rows.len() {
            cv.text(20, h - fch - 2, "... more (PgDn)", wave_render::TEXT_DIM);
        }

        // ---- rubber band ----------------------------------------------
        if let Some(b) = self.band {
            let (x0, x1) = (b.x0.min(b.x1) as i64, b.x0.max(b.x1) as i64);
            let (y0, y1) = (b.y0.min(b.y1) as i64, b.y0.max(b.y1) as i64);
            let plot_h = (h - status_h()).max(80);
            let (py0, py1) = wave_render::pane_bounds(plot_h as usize, self.panes.len(), b.pane);
            match b.kind {
                BandKind::Box => {
                    cv.dashed_hline(x0, x1, y0, wave_render::CURSOR, 5, 4);
                    cv.dashed_hline(x0, x1, y1, wave_render::CURSOR, 5, 4);
                    cv.dashed_vline(x0, y0, y1, wave_render::CURSOR, 5, 4);
                    cv.dashed_vline(x1, y0, y1, wave_render::CURSOR, 5, 4);
                }
                BandKind::XOnly => {
                    cv.dashed_vline(x0, py0, py1, wave_render::CURSOR, 5, 4);
                    cv.dashed_vline(x1, py0, py1, wave_render::CURSOR, 5, 4);
                    cv.hline(x0, x1, h - status_h() - 6, wave_render::CURSOR);
                }
                BandKind::YOnly => {
                    cv.dashed_hline(sidebar(), w, y0, wave_render::CURSOR, 5, 4);
                    cv.dashed_hline(sidebar(), w, y1, wave_render::CURSOR, 5, 4);
                    cv.vline(sidebar() + 6, y0, y1, wave_render::CURSOR);
                }
            }
        }

        // ---- status ---------------------------------------------------
        cv.fill_rect(sidebar(), h - status_h(), w - sidebar(), status_h(), wave_render::PANEL);
        cv.hline(sidebar(), w, h - status_h(), wave_render::AXIS);
        let unit = if self.x_scale == XScale::Log {
            wave_core::Unit::Hertz
        } else {
            wave_core::Unit::Second
        };
        // Assembled from segments so that a narrow window drops the least
        // useful part first.  Blind truncation ate the measurement — the one
        // thing on the line you cannot get anywhere else.
        let seg_span = format!(
            "{} .. {}   ({})",
            wave_render::fmt_eng(self.span.x0, unit),
            wave_render::fmt_eng(self.span.x1, unit),
            wave_render::fmt_eng(self.span.width(), unit)
        );
        let mut s = String::new();
        if self.selected.len() > 1 {
            s.push_str(&format!("   {} selected (Del removes)", self.selected.len()));
        }
        // dx, dy and slope live on the plot now, per trace and in the
        // trace's colour.  Repeating them here only competed for the space
        // and said nothing the pane did not already say more clearly.
        let seg_msg = if self.status.is_empty() {
            String::new()
        } else {
            format!("   |  {}", self.status)
        };
        if let Some(text) = &self.prompt {
            let line = format!("open: {}_", text);
            cv.text(sidebar() + 8, h - status_h() + 5, &line, wave_render::CURSOR);
            let hint = "Enter = open    Esc = cancel";
            cv.text(
                w - Canvas::text_width(hint) - 10,
                h - status_h() + 5,
                hint,
                wave_render::TEXT_DIM,
            );
            self.last_frame_ms = t0.elapsed().as_secs_f64() * 1e3;
            return;
        }
        // The hint keeps its space; the status is what gets clipped, because
        // a measurement running into "q=quit" is unreadable for both.
        let hint = format!(
            "{:.0} ms  {:>5} KB   h = help",
            self.last_frame_ms, self.last_push_kb
        );
        let hint_w = Canvas::text_width(&hint);
        let avail = (w - 10 - hint_w) - (sidebar() + 8) - 16;
        let max_chars = (avail / wave_render::font::cw()).max(8) as usize;
        // Priority, most important last to be dropped: the cursor
        // measurement, then the view range, then which trace, then whatever
        // message was last posted.
        let measure = s.clone();
        for candidate in [
            format!("{}   {}{}", seg_span, measure, seg_msg),
            format!("{}   {}", seg_span, measure),
            format!("{}{}", seg_span, seg_msg),
            seg_span.clone(),
        ] {
            if candidate.chars().count() <= max_chars {
                s = candidate;
                break;
            }
            s = candidate;
        }
        if s.chars().count() > max_chars {
            s = s.chars().take(max_chars.saturating_sub(1)).collect::<String>();
            s.push('~');
        }
        cv.text(sidebar() + 8, h - status_h() + 5, &s, wave_render::TEXT_DIM);
        cv.text(w - hint_w - 10, h - status_h() + 5, &hint, wave_render::TEXT_DIM);
        self.last_frame_ms = t0.elapsed().as_secs_f64() * 1e3;
    }
}

/// Build the instance tree from the file's variable names.
/// Build the signal tree over every open file.
///
/// With one file the roots are its own top-level instances and nets.  With
/// several, each file gets a root of its own — comparing runs is the point,
/// and the same net name exists in all of them.
fn build_tree(srcs: &[WaveSource], paths: &[String]) -> (Vec<Node>, Vec<usize>) {
    let mut nodes: Vec<Node> = Vec::new();
    let mut roots: Vec<usize> = Vec::new();
    let multi = srcs.len() > 1;

    for (si, src) in srcs.iter().enumerate() {
        let complex = src.kind() == ValueKind::Complex;
        let base_depth = if multi { 1 } else { 0 };
        let mut file_root = None;
        if multi {
            nodes.push(Node {
                label: short_file(&paths[si]).to_string(),
                src: si,
                path: format!("@{}", si),
                depth: 0,
                children: Vec::new(),
                var: None,
                comp: Component::Real,
                expanded: srcs.len() <= 2,
            });
            let i = nodes.len() - 1;
            roots.push(i);
            file_root = Some(i);
        }
        let mut index: std::collections::HashMap<String, usize> = std::collections::HashMap::new();

        for (v, var) in src.reader().variables().iter().enumerate() {
            if v == 0 {
                continue;
            }
            let (path, leaf) = split_path(&var.name);
            let mut parent = file_root;
            let mut key = format!("@{}/", si);
            for (d, comp) in path.iter().enumerate() {
                key.push_str(comp);
                key.push('.');
                let idx = match index.get(&key) {
                    Some(i) => *i,
                    None => {
                        nodes.push(Node {
                            label: comp.clone(),
                            src: si,
                            path: key.clone(),
                            depth: base_depth + d,
                            children: Vec::new(),
                            var: None,
                            comp: Component::Real,
                            expanded: false,
                        });
                        let i = nodes.len() - 1;
                        index.insert(key.clone(), i);
                        match parent {
                            Some(p) => nodes[p].children.push(i),
                            None => roots.push(i),
                        }
                        i
                    }
                };
                parent = Some(idx);
            }
            let leaf_path = format!("{}{}", key, var.name);
            nodes.push(Node {
                label: leaf,
                src: si,
                path: leaf_path.clone(),
                depth: base_depth + path.len(),
                children: Vec::new(),
                var: if complex { None } else { Some(v) },
                comp: Component::Real,
                expanded: false,
            });
            let leaf_idx = nodes.len() - 1;
            match parent {
                Some(p) => nodes[p].children.push(leaf_idx),
                None => roots.push(leaf_idx),
            }
            if complex {
                // dB first: on an AC run it is what gets asked for.  All six
                // are exact projections of the one stored complex value.
                for (label, c) in [
                    ("db", Component::Db),
                    ("phase", Component::Phase),
                    ("phase unwrapped", Component::PhaseUnwrapped),
                    ("mag", Component::Magnitude),
                    ("real", Component::Real),
                    ("imag", Component::Imag),
                ] {
                    nodes.push(Node {
                        label: label.to_string(),
                        src: si,
                        path: format!("{}:{}", leaf_path, label),
                        depth: base_depth + path.len() + 1,
                        children: Vec::new(),
                        var: Some(v),
                        comp: c,
                        expanded: false,
                    });
                    let ci = nodes.len() - 1;
                    nodes[leaf_idx].children.push(ci);
                }
            }
        }
    }

    // Subcircuits before nets at every level, each alphabetical.  File roots
    // and projection lists keep the order they were built in.
    fn sort_kids(nodes: &mut Vec<Node>, list: &mut Vec<usize>) {
        if list.iter().any(|i| nodes[*i].label == "db")
            || list.iter().any(|i| nodes[*i].path.starts_with('@') && !nodes[*i].path.contains('/'))
        {
            let kids: Vec<usize> = list.clone();
            for k in kids {
                let mut c = std::mem::take(&mut nodes[k].children);
                sort_kids(nodes, &mut c);
                nodes[k].children = c;
            }
            return;
        }
        list.sort_by(|a, b| {
            let (ga, gb) = (nodes[*a].var.is_some(), nodes[*b].var.is_some());
            ga.cmp(&gb).then(nodes[*a].label.cmp(&nodes[*b].label))
        });
        let kids: Vec<usize> = list.clone();
        for k in kids {
            let mut c = std::mem::take(&mut nodes[k].children);
            sort_kids(nodes, &mut c);
            nodes[k].children = c;
        }
    }
    sort_kids(&mut nodes, &mut roots);
    (nodes, roots)
}

fn main() {
    if let Err(e) = run() {
        eprintln!("waveview: {}", e);
        std::process::exit(1);
    }
}

const USAGE: &str = "\
waveview - interactive waveform viewer for SPICE raw files

  waveview <file.raw>

Opens a window with an empty pane and the signal list on the left.  Click a
signal to plot it.  On .ac files each signal expands into its projections
(db, phase, phase unwrapped, mag, real, imag) — pick the one you want.
Hierarchical names (v(xtop.xsub.net)) become a collapsible tree.

  -h, --help    this text
      --size <WxH>  window size (default 1500x860)
      --font-size <px>  text size; baked sizes are 13, 16, 20, 26
      --keys    the key and mouse reference (also on `h` inside the viewer)
      --keys-md the same, as a markdown table (used to build the docs)

See also wave-plot, which renders the same plots to a PNG with no window —
useful after a batch run or over ssh.

waveview and wave-plot.  Copyright (C) 2026 alto555.
Free software under the GNU GPL version 2; see the LICENSE file.
There is NO WARRANTY, to the extent permitted by law.
";

fn print_keys(markdown: bool) {
    if markdown {
        println!("| | |");
        println!("|---|---|");
        for (k, d) in HELP {
            if k.is_empty() {
                println!("| **{}** | |", d);
            } else {
                println!("| `{}` | {} |", k, d);
            }
        }
    } else {
        for (k, d) in HELP {
            if k.is_empty() {
                println!("\n{}", d);
            } else {
                println!("  {:<26} {}", k, d);
            }
        }
    }
}

fn run() -> Result<(), String> {
    let args: Vec<String> = std::env::args().skip(1).collect();
    match args.first().map(|s| s.as_str()) {
        None => {
            print!("{}", USAGE);
            return Ok(());
        }
        Some("-h") | Some("--help") => {
            print!("{}", USAGE);
            return Ok(());
        }
        Some("--keys") => {
            print_keys(false);
            return Ok(());
        }
        Some("--keys-md") => {
            print_keys(true);
            return Ok(());
        }
        _ => {}
    }
    // Every non-flag argument is a file: several of them open side by side
    // for comparing runs.
    let mut paths: Vec<String> = Vec::new();
    let (mut w, mut h) = (1500usize, 860usize);
    let mut i = 0usize;
    while i < args.len() {
        match args[i].as_str() {
            "--font-size" => {
                i += 1;
                if let Some(v) = args.get(i) {
                    if let Ok(px) = v.parse::<usize>() {
                        let got = wave_render::font::set_size(px);
                        if got != px {
                            eprintln!(
                                "waveview: font size {} not baked, using {} (available: {:?})",
                                px,
                                got,
                                wave_render::font::sizes()
                            );
                        }
                    }
                }
            }
            "--size" => {
                i += 1;
                if let Some(v) = args.get(i) {
                    let mut it = v.split('x');
                    w = it.next().and_then(|s| s.parse().ok()).unwrap_or(w);
                    h = it.next().and_then(|s| s.parse().ok()).unwrap_or(h);
                }
            }
            other => paths.push(other.to_string()),
        }
        i += 1;
    }
    if paths.is_empty() {
        return Err("no file given".into());
    }

    let t = Instant::now();
    let mut srcs = Vec::new();
    for p in &paths {
        srcs.push(
            WaveSource::open_raw(p, IndexCfg::default(), true)
                .map_err(|e| format!("{}: {}", p, e))?,
        );
    }
    let open_ms = t.elapsed().as_secs_f64() * 1e3;
    let x_scale = srcs[0].x_scale();

    let (nodes, roots) = build_tree(&srcs, &paths);
    let title = paths
        .iter()
        .map(|p| short_file(p))
        .collect::<Vec<_>>()
        .join(" + ");
    let mut app = App {
        nodes,
        roots,
        rows: Vec::new(),
        // Open with an empty pane.  Auto-plotting "the first variable" showed
        // an arbitrary signal nobody asked for.
        panes: vec![Vec::new()],
        cur_pane: 0,
        span: Span::new(0.0, 1.0),
        full: Span::new(0.0, 1.0),
        x_scale,
        cursors: [None, None],
        mouse: (0, 0),
        scroll: 0,
        file: title,
        paths: paths.clone(),
        prompt: None,
        prompt_adds: false,
        status: format!("opened in {:.0} ms", open_ms),
        drag: None,
        cursor_drag: None,
        y_range: Vec::new(),
        pane_y: Vec::new(),
        selected: Vec::new(),
        band: None,
        help_win: None,
        stats_win: None,
        stats_img: None,
        show_deltas: true,
        last_push_kb: 0,
        last_frame_ms: 0.0,
        srcs,
    };
    app.reflow();
    let mut full = app.srcs[0].x_span();
    for s in app.srcs.iter().skip(1) {
        let o = s.x_span();
        full = Span::new(full.x0.min(o.x0), full.x1.max(o.x1));
    }
    if !(full.x1 > full.x0) {
        full = Span::new(0.0, 1.0);
    }
    app.full = full;
    app.span = full;

    let mut win = Window::open(&format!("waveview - {}", app.file), w as u16, h as u16)
        .map_err(|e| format!("cannot open display: {}", e))?;
    let main_id = win.id();
    let mut cv = Canvas::new(w, h, wave_render::BG);
    app.draw(&mut cv);
    win.present(&cv.px, w, h).map_err(|e| e.to_string())?;

    loop {
        let events = win.next_events().map_err(|e| e.to_string())?;
        let mut dirty = false;
        for e in events {
            // Route by window before anything else.  Events from the help
            // window reached the main window's handlers otherwise, and a
            // ConfigureNotify for a 620px help window resized the main
            // canvas to 620px — the plot came out squeezed into a strip.
            if Some(e.window) == app.stats_win {
                match e.kind {
                    Kind::Expose => {
                        if let (Some(id), Some(img)) = (app.stats_win, app.stats_img.as_ref()) {
                            let _ = win.present_child(id, &img.px, img.w, img.h);
                        }
                    }
                    Kind::Close | Kind::Key(_, _, _) => {
                        if let Some(id) = app.stats_win.take() {
                            let _ = win.destroy(id);
                        }
                        app.stats_img = None;
                    }
                    _ => {}
                }
                continue;
            }
            if Some(e.window) == app.help_win {
                match e.kind {
                    Kind::Expose => {
                        let hc = help_canvas(win.screen_size());
                        if let Some(id) = app.help_win {
                            let _ = win.present_child(id, &hc.px, hc.w, hc.h);
                        }
                    }
                    Kind::Close | Kind::Key(_, _, _) => {
                        if let Some(id) = app.help_win.take() {
                            let _ = win.destroy(id);
                        }
                    }
                    _ => {}
                }
                continue;
            }
            if e.window != main_id {
                continue;
            }
            match e.kind {
                Kind::Close => return Ok(()),
                Kind::Expose => {
                    win.invalidate();
                    dirty = true;
                }
                Kind::Resize(nw, nh) => {
                    if nw as usize != w || nh as usize != h {
                        w = nw as usize;
                        h = nh as usize;
                        cv = Canvas::new(w, h, wave_render::BG);
                        dirty = true;
                    }
                }
                Kind::Key(k, _shift, _ctrl) => {
                    // While the open prompt is up it owns the keyboard, or
                    // typing a path would fire viewer commands letter by
                    // letter.
                    if let Some(mut text) = app.prompt.take() {
                        match k {
                            wave_x11::K_ESC => app.status = "cancelled".into(),
                            0xff0d | 0xff8d => {
                                // Return / KP_Enter
                                let target = text.trim().to_string();
                                let list = if app.prompt_adds {
                                    let mut v = app.paths.clone();
                                    v.push(target.clone());
                                    v
                                } else {
                                    vec![target.clone()]
                                };
                                match app.load_all(&list) {
                                    Ok(()) => {
                                        let _ = win.set_title(&format!(
                                            "waveview - {}",
                                            app.file
                                        ));
                                        win.invalidate();
                                    }
                                    Err(e) => {
                                        app.status = e;
                                    }
                                }
                            }
                            wave_x11::K_BACKSPACE => {
                                text.pop();
                                app.prompt = Some(text);
                            }
                            c if (0x20..0x7f).contains(&c) => {
                                text.push(c as u8 as char);
                                app.prompt = Some(text);
                            }
                            _ => app.prompt = Some(text),
                        }
                        dirty = true;
                        continue;
                    }
                    let plot_w = (w as i64 - sidebar()).max(80) as usize;
                    match k {
                        0x71 | wave_x11::K_ESC => return Ok(()), // q
                        0x66 => {
                            // f - fit: both axes, since Y can now be zoomed too
                            app.span = app.full;
                            for r in app.y_range.iter_mut() {
                                *r = None;
                            }
                            app.status.clear();
                        }
                        0x2b | 0x3d => app.zoom(0.5, mid(app.span, app.x_scale)), // + =
                        0x2d | 0x5f => app.zoom(2.0, mid(app.span, app.x_scale)), // - _
                        wave_x11::K_LEFT => app.pan(-0.1),
                        wave_x11::K_RIGHT => app.pan(0.1),
                        wave_x11::K_PGDN => {
                            let max = app.rows.len().saturating_sub(1);
                            app.scroll = (app.scroll + 10).min(max)
                        }
                        wave_x11::K_PGUP => app.scroll = app.scroll.saturating_sub(10),
                        wave_x11::K_UP => app.scroll = app.scroll.saturating_sub(1),
                        wave_x11::K_DOWN => {
                            let max = app.rows.len().saturating_sub(1);
                            app.scroll = (app.scroll + 1).min(max)
                        }
                        // Shift turns the keysym itself into 'P', so the
                        // uppercase code is the shifted binding — testing the
                        // modifier flag against the lowercase code never fires.
                        0x50 => {
                            let c = app.cur_pane;
                            app.remove_pane(c);
                        }
                        0x70 => {
                            if app.panes.last().map(|p| p.is_empty()) == Some(true) {
                                // Already an empty pane waiting; go there
                                // rather than stacking another.
                                app.cur_pane = app.panes.len() - 1;
                            } else {
                                app.panes.push(Vec::new());
                                app.cur_pane = app.panes.len() - 1;
                            }
                        }
                        0x6e => app.cur_pane = (app.cur_pane + 1) % app.panes.len(), // n
                        0x64 => {
                            // d - clear the traces from this pane
                            let p = app.cur_pane;
                            app.panes[p].clear();
                        }
                        // x / X snap a cursor to where the selected trace
                        // crosses zero — unity gain on a dB trace, so the
                        // other pane then reads the phase there directly.
                        // Dragging gets you close; this lands on it.
                        0x78 | 0x58 => {
                            let slot = if k == 0x78 { 0 } else { 1 };
                            let pick = app
                                .selected
                                .first()
                                .and_then(|(pi, kk)| app.panes.get(*pi).and_then(|p| p.get(*kk)))
                                .copied()
                                .or_else(|| app.panes[app.cur_pane].first().copied());
                            match pick {
                                None => app.status = "x: select a trace first".into(),
                                Some(t) => {
                                    let c = app.curve(t);
                                    // Zero for dB or phase; mid-swing for a
                                    // level that has no natural zero.
                                    let level = match c.unit() {
                                        wave_core::Unit::Decibel | wave_core::Unit::Degree => 0.0,
                                        _ => wave_meas::levels(&*c, app.span)
                                            .map(|l| l.at(50.0))
                                            .unwrap_or(0.0),
                                    };
                                    let mut hits =
                                        wave_meas::crossings(&*c, app.span, level, true)
                                            .unwrap_or_default();
                                    hits.extend(
                                        wave_meas::crossings(&*c, app.span, level, false)
                                            .unwrap_or_default(),
                                    );
                                    hits.sort_by(|a, b| a.partial_cmp(b).unwrap());
                                    match hits.first() {
                                        Some(x) => {
                                            app.cursors[slot] = Some(*x);
                                            app.status = format!(
                                                "c{} at {} = {}",
                                                slot + 1,
                                                c.name(),
                                                wave_render::fmt_eng(level, c.unit())
                                            );
                                        }
                                        None => {
                                            app.status = format!(
                                                "{} does not cross {} in view",
                                                c.name(),
                                                wave_render::fmt_eng(level, c.unit())
                                            )
                                        }
                                    }
                                }
                            }
                        }
                        // a / b place a cursor at the pointer, A / B remove
                        // it.  Explicit placement beats "wherever you last
                        // clicked".
                        0x61 | 0x62 => {
                            let slot = if k == 0x61 { 0 } else { 1 };
                            let plot_w = (w as i64 - sidebar()).max(80) as usize;
                            let px = app.mouse.0 as i64 - sidebar();
                            let x = if px >= 0 {
                                wave_render::px_to_x(app.span, app.x_scale, plot_w, px)
                            } else {
                                mid(app.span, app.x_scale)
                            };
                            app.cursors[slot] = Some(x);
                        }
                        0x41 => app.cursors[0] = None,
                        0x42 => app.cursors[1] = None,
                        0x63 => {
                            app.cursors = [None, None];
                            app.status.clear();
                        } // c
                        wave_x11::K_DEL | wave_x11::K_BACKSPACE => {
                            // Removing the selected trace: the counterpart to
                            // selection now that clicking a name no longer
                            // deletes it.
                            // Remove deepest index first so the earlier
                            // indices in the same pane stay valid.
                            let mut sel = std::mem::take(&mut app.selected);
                            sel.sort_by(|a, b| b.cmp(a));
                            for (pi, k) in sel {
                                if pi < app.panes.len() && k < app.panes[pi].len() {
                                    app.panes[pi].remove(k);
                                }
                            }
                        }
                        // m - toggle the per-trace dy/slope labels.  With
                        // many traces they crowd the pane when all you want
                        // is the c2-c1 delta above it.
                        0x6d => {
                            app.show_deltas = !app.show_deltas;
                            app.status = if app.show_deltas {
                                "delta labels on".into()
                            } else {
                                "delta labels off".into()
                            };
                        }
                        // s - statistics for every plotted trace, in its own
                        // window, because several traces make it a table.
                        0x73 => match app.stats_win.take() {
                            Some(id) => {
                                let _ = win.destroy(id);
                                app.stats_img = None;
                            }
                            None => {
                                app.status = "measuring...".into();
                                app.draw(&mut cv);
                                let _ = win.present(&cv.px, w, h);
                                let sc = app.stats_canvas();
                                match win.open_child(
                                    "waveview - statistics",
                                    sc.w as u16,
                                    sc.h as u16,
                                ) {
                                    Ok(id) => {
                                        let _ = win.present_child(id, &sc.px, sc.w, sc.h);
                                        app.stats_win = Some(id);
                                        app.stats_img = Some(sc);
                                        app.status.clear();
                                    }
                                    Err(e) => app.status = format!("statistics: {}", e),
                                }
                            }
                        },
                        // w - write a PNG of the window
                        0x77 => {
                            let png = cv.to_png();
                            let name = "waveview.png";
                            match std::fs::write(name, &png) {
                                Ok(_) => {
                                    app.status =
                                        format!("wrote {} ({} KB)", name, png.len() / 1024)
                                }
                                Err(e) => app.status = format!("save failed: {}", e),
                            }
                        }
                        // o opens another file in place; O adds it alongside
                        // the current ones, which is how two runs get
                        // compared in one window.
                        0x6f | 0x4f => {
                            let dir = match app.paths[0].rfind('/') {
                                Some(i) => app.paths[0][..=i].to_string(),
                                None => String::new(),
                            };
                            app.prompt_adds = k == 0x4f;
                            app.prompt = Some(dir);
                        }
                        // h or ? - the reference, in its own window
                        0x68 | 0x3f => {
                            match app.help_win.take() {
                                Some(id) => {
                                    let _ = win.destroy(id);
                                }
                                None => {
                                    let hc = help_canvas(win.screen_size());
                                    match win.open_child(
                                        "waveview - help",
                                        hc.w as u16,
                                        hc.h as u16,
                                    ) {
                                        Ok(id) => {
                                            app.help_win = Some(id);
                                            let _ = win.present_child(id, &hc.px, hc.w, hc.h);
                                        }
                                        Err(err) => app.status = format!("help: {}", err),
                                    }
                                }
                            }
                        }
                        // r - reload the same file after a re-run, keeping
                        // the signals, zoom and cursors you already set up.
                        0x72 => {
                            let p = app.paths.clone();
                            if let Err(e) = app.load_all(&p) {
                                app.status = e;
                            }
                            win.invalidate();
                        }
                        _ => {}
                    }
                    let _ = plot_w;
                    dirty = true;
                }
                Kind::ButtonPress(b, x, y, state) => {
                    let shift_held = state & 1 != 0;
                    app.mouse = (x, y);
                    let plot_w = (w as i64 - sidebar()).max(80) as usize;
                    if (x as i64) < sidebar() {
                        if b == 1 && y as i64 >= list_top() {
                            let r = ((y as i64 - list_top()) / row_h()) as usize + app.scroll;
                            if r < app.rows.len() {
                                let ni = app.rows[r];
                                if app.nodes[ni].children.is_empty() {
                                    if let Some(v) = app.nodes[ni].var {
                                        // Shift-click puts a copy in the
                                        // current pane even if it is already
                                        // shown elsewhere.
                                        let c = app.nodes[ni].comp;
                                        let si = app.nodes[ni].src;
                                        app.toggle((si, v, c), shift_held);
                                    }
                                } else {
                                    app.nodes[ni].expanded = !app.nodes[ni].expanded;
                                    app.reflow();
                                }
                            }
                        } else if b == 4 {
                            app.scroll = app.scroll.saturating_sub(3);
                        } else if b == 5 {
                            let max = app.rows.len().saturating_sub(1);
                            app.scroll = (app.scroll + 3).min(max);
                        }
                    } else {
                        let px = x as i64 - sidebar();
                        let plot_h = (h as i64 - status_h()).max(80) as usize;
                        let dx = wave_render::px_to_x(app.span, app.x_scale, plot_w, px);
                        // Clicking inside a pane selects it, so signals can be
                        // added to any pane rather than only the newest.
                        if (b == 1 || b == 3) && !app.panes.is_empty() {
                            if let Some(pi) = app.pane_at(y as i64, plot_h) {
                                app.cur_pane = pi;
                            }
                        }
                        // Grabbing a cursor takes priority: it is a smaller
                        // target than a trace and the intent is unambiguous.
                        if b == 1 {
                            if let Some(slot) = app.cursor_at(px, plot_w) {
                                app.cursor_drag = Some(slot);
                                dirty = true;
                                continue;
                            }
                        }
                        // A click on a legend label selects that trace.
                        // Removing on click was wrong: everywhere else in the
                        // world clicking a name highlights it.  Selection also
                        // decides what `m` measures, and Delete removes it.
                        if b == 1 {
                            if let Some(hit) = app.legend_at(px, y as i64, plot_h) {
                                app.select(hit, shift_held);
                                app.cur_pane = hit.0;
                                dirty = true;
                                continue;
                            }
                            // Clicking the trace itself counts too — the
                            // legend label is a small target when a pane
                            // carries several signals.
                            if let Some(hit) = app.trace_at(px, y as i64, plot_w, plot_h) {
                                app.select(hit, shift_held);
                                app.cur_pane = hit.0;
                                dirty = true;
                                continue;
                            }
                            if !shift_held {
                                // Clicking empty space in a pane clears the
                                // selection, as it does everywhere else.
                                app.selected.clear();
                            }
                        }
                        // Wheel zooms time; with shift held, or with the
                        // pointer over the Y labels, it zooms that pane's
                        // amplitude — the missing counterpart to the Y
                        // rubber band, which could only zoom in.
                        let (pl, _) = wave_render::plot_x_bounds(plot_w);
                        let y_gesture = shift_held || px < pl;
                        if (b == 4 || b == 5) && y_gesture {
                            if let Some(pi) = app
                                .pane_at(y as i64, plot_h)
                                .or_else(|| Some(app.nearest_pane(y as i64, plot_h)))
                            {
                                if let Some(v) = app.value_at_pixel(pi, y as i64, plot_h) {
                                    app.zoom_y(pi, if b == 4 { 0.8 } else { 1.25 }, v);
                                    app.cur_pane = pi;
                                }
                            }
                            dirty = true;
                            continue;
                        }
                        match b {
                            4 => app.zoom(0.8, dx),
                            5 => app.zoom(1.25, dx),
                            // Left click selects the pane and nothing else.
                            // Dropping a cursor on every click was noise: you
                            // click to look at a pane far more often than you
                            // click to place a marker.
                            1 => {}
                            3 => {
                                let (pl, _) = wave_render::plot_x_bounds(plot_w);
                                let kind = if px < pl {
                                    BandKind::YOnly
                                } else if app.pane_at(y as i64, plot_h).is_some() {
                                    BandKind::Box
                                } else {
                                    BandKind::XOnly
                                };
                                let pane = app
                                    .pane_at(y as i64, plot_h)
                                    .unwrap_or_else(|| app.nearest_pane(y as i64, plot_h));
                                app.band = Some(Band { kind, pane, x0: x, y0: y, x1: x, y1: y });
                            }
                            2 => app.drag = Some((x, app.span)),
                            _ => {}
                        }
                    }
                    dirty = true;
                }
                Kind::ButtonRelease(b, _, _) => {
                    if b == 2 {
                        app.drag = None;
                    }
                    if b == 1 {
                        app.cursor_drag = None;
                    }
                    if b == 3 {
                        let plot_w = (w as i64 - sidebar()).max(80) as usize;
                        let plot_h = (h as i64 - status_h()).max(80) as usize;
                        app.apply_band(plot_w, plot_h);
                        dirty = true;
                    }
                }
                Kind::Motion(x, y, _state) => {
                    app.mouse = (x, y);
                    if let Some(slot) = app.cursor_drag {
                        let plot_w = (w as i64 - sidebar()).max(80) as usize;
                        app.cursors[slot] = Some(wave_render::px_to_x(
                            app.span,
                            app.x_scale,
                            plot_w,
                            x as i64 - sidebar(),
                        ));
                        dirty = true;
                    }
                    if let Some(b) = app.band.as_mut() {
                        b.x1 = x;
                        b.y1 = y;
                        dirty = true;
                    }
                    if let Some((x0, span0)) = app.drag {
                        let plot_w = (w as i64 - sidebar()).max(80) as usize;
                        let (a, bnd) = wave_render::plot_x_bounds(plot_w);
                        let frac = (x0 - x) as f64 / (bnd - a).max(1) as f64;
                        app.span = Span::new(
                            span0.x0 + span0.width() * frac,
                            span0.x1 + span0.width() * frac,
                        );
                        app.clamp();
                        dirty = true;
                    }
                }
            }
        }
        if dirty {
            app.draw(&mut cv);
            win.present(&cv.px, w, h).map_err(|e| e.to_string())?;
            app.last_push_kb = win.last_present_bytes / 1024;
        }
    }
}

/// Centre of the view: arithmetic on a linear axis, geometric on a log one.
fn mid(s: Span, scale: XScale) -> f64 {
    match scale {
        XScale::Log => (s.x0.max(1e-300) * s.x1.max(1e-300)).sqrt(),
        XScale::Linear => (s.x0 + s.x1) * 0.5,
    }
}
