ratto 0.8.0

Ratatui-powered terminal primitives for shell dashboards: flicker-free repaints, progress bars, prompts, and portable time tools
//! `rat dashboard`: N declared panes, one flicker-free frame. Thin by
//! construction — the declaration file becomes a [`Registry`], the
//! flags become a `SessionArgs`, and the watch engine does the rest.

use crate::cli::DashboardArgs;
use crate::color::ColorProfile;
use crate::commands::watch::{SessionArgs, run_registry};
use crate::core::dashboard_file::load;
use crate::core::registry::Registry;
use crate::exit::AppResult;
use crate::theme::Palette;

pub fn run(args: DashboardArgs, profile: ColorProfile, palette: Palette) -> AppResult {
    let registry = load(&args.file)?;
    let session = SessionArgs {
        once: args.once,
        clear: args.clear,
        no_hide_cursor: args.no_hide_cursor,
        no_sync: args.no_sync,
        // Declared geometry: a wrapped line would add rows the composed
        // frame's run-constant height did not budget for.
        wrap: false,
        max_height: args.max_height,
        snapshot_dir: args.snapshot_dir.clone(),
        snapshot_ansi: args.snapshot_ansi,
        live_tail: dashboard_suffix(args.once, registry.len()),
        help_heading: "rat dashboard — keys",
        help_extra: pane_help(&registry),
        // Boxes are allocated from the terminal width, so a resize
        // reflows and every child is respawned under the new geometry.
        resize_respawn: true,
    };
    run_registry(registry, session, profile, palette)
}

/// The run-constant tail of every live row — the same rule as watch's
/// `live_suffix`: nothing here may count, or the repaint gate is
/// defeated and a parked dashboard stops being byte-silent. The source
/// count is the one fact worth the width.
fn dashboard_suffix(once: bool, sources: usize) -> String {
    if once {
        return String::new();
    }
    match sources {
        1 => " · 1 source · ? help".to_string(),
        n => format!(" · {n} sources · ? help"),
    }
}

/// The dashboard's slice of the `?` reference: one line per pane with
/// its cadence, any trigger specs indented beneath — the same shape as
/// watch's trigger section.
fn pane_help(registry: &Registry) -> Vec<String> {
    let mut lines = vec![String::new(), "  panes:".to_string()];
    for id in registry.ids() {
        let spec = registry.spec(id);
        lines.push(format!(
            "    {}  {}",
            spec.name,
            crate::commands::watch::cadence_label(spec)
        ));
        for trigger in &spec.triggers {
            lines.push(format!("      {trigger}"));
        }
    }
    if registry
        .ids()
        .any(|id| !registry.spec(id).triggers.is_empty())
    {
        lines.extend(LOOPING_HELP.iter().map(|l| (*l).to_string()));
    }
    lines
}

/// What `· looping` means, and what to do about it.
///
/// **Static, and it does not name the looping panes.** `pane_help` is
/// called once at startup and its result is stored in the session, so
/// `?` cannot report live state without making help dynamic — a bigger
/// change than this earns. The live naming is the notice row's job; it
/// already names the panes and the paths. This is a deliberate
/// departure from the sketch, which showed `?` listing the specific
/// panes, and it is recorded here so it does not read as an oversight.
///
/// **The closing paragraph is about what the badge's ABSENCE does not
/// mean**, and it is here because the detector can now decline to answer.
/// A window abstains when a candidate's reader evidence is all ambiguous,
/// and condition 3 has always abstained on lost evidence or a busy
/// dashboard — none of which reach a surface. So a silent dashboard and a
/// clean one look identical from outside, which is a quieter form of the
/// confident-negative defect the interval model removed.
///
/// Deliberately STATIC, and deliberately not a live "cannot decide" badge.
/// Nobody has measured how often abstention actually fires, and
/// `Verdict::abstained` carries no user-facing cause to put in a notice —
/// so a dynamic surface would be guessing at both its frequency and its
/// wording. Stating the limitation once, where the badge is explained,
/// costs nothing and is true today.
///
/// The mtime sentence below is the load-bearing one and is not guessable:
/// `fingerprint` is mtime-only, so a command that rewrites a file with
/// identical bytes still fires the trigger. Guarding the *change*
/// rather than the *write* is the mistake that produces exactly this
/// badge, and it is a real reported confusion elsewhere, not a
/// hypothetical one.
///
/// Wrapped by hand: `?` pages plain grouped text through the pager, so
/// there is no wrapping engine to lean on, and nothing here may exceed
/// the width the key table already sets.
const LOOPING_HELP: &[&str] = &[
    "",
    "  looping panes:",
    "    A pane marked `· looping` is still running — nothing has been",
    "    stopped. rat cannot see who writes a file, only that a watched",
    "    path changes while the dashboard is busy and never while it is",
    "    idle, which is what a pane whose own command touches another",
    "    pane's trigger looks like.",
    "",
    "    The fix is in the declaration: give the command a guard so it",
    "    writes only when the content changed, or point the trigger at a",
    "    path no pane writes. A trigger fires on mtime, not on content,",
    "    so writing identical bytes still fires — the guard has to skip",
    "    the write, not just the change.",
    "",
    "    The absence of the badge is weaker than its presence. rat stays",
    "    silent whenever it cannot tell: when a write cannot be placed",
    "    against the commands that were running, when a reader's evidence",
    "    was lost, or when the dashboard was too busy to judge. No badge",
    "    means no loop was proved, not that there is none.",
];

#[cfg(test)]
mod tests {
    use std::time::Duration;

    use super::*;
    use crate::core::box_model::{BorderPreset, Sides};
    use crate::core::registry::{LayoutNode, Overflow, PaneBox, PaneWidth, SourceId, SourceSpec};
    use crate::core::trigger::TriggerSpec;

    fn registry(triggers: bool) -> Registry {
        let spec = |name: &str, path: &str| SourceSpec {
            name: name.to_string(),
            command: vec!["true".to_string()],
            shell: false,
            interval: (!triggers).then(|| Duration::from_secs(5)),
            triggers: if triggers {
                vec![TriggerSpec::File(std::path::PathBuf::from(path))]
            } else {
                Vec::new()
            },
            debounce: Duration::from_millis(250),
        };
        let pane = || PaneBox {
            height: 5,
            width: PaneWidth::Weight(1),
            overflow: Overflow::KeepTop,
            border: BorderPreset::Rounded,
            padding: Sides::default(),
            title: None,
            chrome: true,
        };
        Registry::panes(
            vec![spec("a", "./sa"), spec("b", "./sb")],
            vec![pane(), pane()],
            LayoutNode::Row(vec![
                LayoutNode::Pane(SourceId(0)),
                LayoutNode::Pane(SourceId(1)),
            ]),
            1,
            0,
        )
        .expect("a valid two-pane registry")
    }

    #[test]
    fn the_help_explains_the_badge_and_names_the_fix() {
        // De-wrapped before matching. The section is wrapped by hand,
        // so a phrase the reader sees as one sentence can straddle a
        // line break — asserting on the wrapped bytes would pin the
        // wrap POINTS rather than the claim, and would fail on any
        // future rewrap that changed nothing a user can perceive. The
        // width is pinned separately, below, because that is a
        // different property.
        let lines = pane_help(&registry(true));
        let text = lines.iter().map(|l| l.trim()).collect::<Vec<_>>().join(" ");
        assert!(text.contains("looping"), "got {text}");
        // The two facts a user cannot guess: that the pane was left
        // running, and that a trigger fires on the timestamp rather
        // than on the bytes — so writing identical content still fires.
        assert!(text.contains("nothing has been stopped"), "got {text}");
        assert!(text.contains("mtime, not on content"), "got {text}");
        // And the third, which is about the badge's ABSENCE: the detector
        // can decline to answer, and a silent dashboard is not a clean
        // one. Asserted because a static string nothing reads is exactly
        // how this codebase has shipped a lie before.
        assert!(text.contains("no loop was proved"), "got {text}");
    }

    #[test]
    fn the_help_stays_inside_the_width_the_key_table_already_sets() {
        // `?` pages plain grouped text through the pager with no
        // wrapping engine behind it, so a line wider than the shipped
        // key table is one the pager has to chop. 74 is the widest row
        // that ships (`p  freeze the frame in place …`); this section
        // may reach it and must not pass it.
        for line in pane_help(&registry(true)) {
            assert!(
                line.chars().count() <= 74,
                "{} cells: {line:?}",
                line.chars().count()
            );
        }
    }

    #[test]
    fn the_help_is_unchanged_when_no_pane_has_a_trigger() {
        // Byte-identity for the common case, asserted against the
        // literal shipped block rather than against a recomputation of
        // it: a dashboard with no triggers gains no help text at all,
        // and cannot gain any by a later edit without failing here.
        assert_eq!(
            pane_help(&registry(false)),
            vec![
                String::new(),
                "  panes:".to_string(),
                "    a  every 5s".to_string(),
                "    b  every 5s".to_string(),
            ]
        );
    }
}