lernie 0.1.23

lernie: the operator seat — the window and wire client for a yog server
Documentation
//! **The trail**: every action that crossed an engine's boundary, newest last
//! (bl-4c48; yog's `docs/REMOTE.md` §9.17).
//!
//! # The ninth covering pane, and the fourth about no focus
//!
//! `ops` names no workspace, so the read fans over every channel and the pane
//! is the union — the decision queue's shape (`crate::ui::queue`), which is
//! also why its control hangs off the roster rather than off a row. What it
//! adds to the window is the answer to *what has this box been doing*, which
//! until now was a question the seat carried the wire for and painted nowhere.
//!
//! # It reads the engine's words and classifies nothing
//!
//! REMOTE §9.17 put the classification on the wire precisely so a seat would
//! not re-derive it: the sentinel table, the `128 + n` signal reading, the
//! retirement key, the ack scan and the origin grouping are five derivations
//! with one home apiece, *"whose failure mode is a seat quietly disagreeing
//! rather than failing to build"*. So this pane paints `exit_label`, `failed`
//! and `standing` and never looks at the exit integer — which rides in the
//! detail because it is the next thing an operator asks for, not because
//! anything here reads it.
//!
//! # An alarm is a row that is standing, and it is said in the engine's word
//!
//! `clean` is silence (`crate::reply::ops::CLEAN`), because a badge on every
//! ordinary run would bury the rows this pane exists for. Every other standing
//! paints as its own word, in the notice ink where the row failed and weakly
//! where it did not — so *handed off, no exit to observe* does not read as an
//! alarm, and a word this build has never seen still paints as itself
//! (`crate::reply` rung 3).
//!
//! # What is painted is words, computed beside the paint
//!
//! Every line comes off a pure function of the row, so the suite reads the
//! sentence rather than the layout — `crate::ui::records`' own rule.

use crate::reply::ops::{CLEAN, OpRow};
use crate::ui::{Model, theme};

/// The word that opens the pane. It hangs off the roster, above the channels.
pub const OPEN: &str = "the trail…";
/// The word that closes it.
pub const CLOSE: &str = "done";
/// The pane's own heading.
pub const HEADING: &str = "the trail";
/// What it says before any channel has answered.
pub const NOT_ANSWERED: &str = "waiting to hear what has crossed the boundary";
/// What it says once they have and nothing has. A fact about the engines, and
/// the one empty state here that is not a wait.
pub const NOTHING: &str = "nothing has crossed the boundary yet";

/// Paint the pane and take the clicks on it. Answers whether there was one to
/// paint, so the shell knows whether the conversation still stands.
pub fn render(ui: &mut egui::Ui, model: &mut Model) -> bool {
    if !model.trailing() {
        return false;
    }
    ui.heading(HEADING);
    ui.horizontal_wrapped(|ui| {
        if ui.button(CLOSE).clicked() {
            model.close_lookup();
        }
    });
    ui.separator();
    let trails = model.trails.clone();
    if trails.is_empty() {
        ui.label(NOT_ANSWERED);
        return true;
    }
    if trails.iter().all(|section| section.rows.is_empty()) {
        ui.label(NOTHING);
        return true;
    }
    // One scroll for every section, and the heading above it fixed — the shape
    // every pane here keeps, for the reason the tuning pane states: a pane cut
    // off mid-row says nothing about having been cut.
    egui::ScrollArea::vertical()
        .id_salt(HEADING)
        .auto_shrink(false)
        .show(ui, |ui| {
            for section in &trails {
                // **A section that answered nothing is silent, not a sentence**
                // — the queue's own rule: the union is the subject, and a
                // header over a blank for every quiet engine would bury the
                // rows this pane exists for.
                if section.rows.is_empty() {
                    continue;
                }
                ui.separator();
                ui.label(crate::ui::roster::header(&section.channel));
                for row in &section.rows {
                    crossed(ui, row);
                }
            }
        });
    true
}

/// One action: what ran, where its alarm stands, when and where it ran, and
/// whatever it said.
fn crossed(ui: &mut egui::Ui, row: &OpRow) {
    ui.label(headline(row));
    if let Some(said) = standing(row) {
        let ink = if row.failed {
            theme::NOTICE
        } else {
            theme::tone_ink(&crate::reply::convs::Tone::Weak)
        };
        ui.colored_label(ink, said);
    }
    ui.colored_label(
        theme::tone_ink(&crate::reply::convs::Tone::Weak),
        provenance(row),
    );
    if let Some(said) = output(row) {
        ui.label(egui::RichText::new(said).monospace());
    }
}

/// **The one line a row always gets**: the command that ran, and how it ended
/// in the engine's own words.
///
/// The label and never the integer — REMOTE §9.17's *"a client that renders
/// the trail should stop classifying `exit` and read the words"*.
pub fn headline(row: &OpRow) -> String {
    format!("{}  [{}]", row.argv, row.exit_label)
}

/// **Where the row's alarm stands**, or none for a clean run.
///
/// The word rides verbatim: this seat knows one of the five ([`CLEAN`]) and
/// knows it only in order to say nothing, so a standing that grew upstream
/// costs a badge and not a decode.
pub fn standing(row: &OpRow) -> Option<String> {
    (row.standing != CLEAN).then(|| row.standing.clone())
}

/// When and where it ran, and what subject it belongs to — the field a failure
/// banner groups by, carried here because it is what makes two rows with the
/// same argv two different facts.
pub fn provenance(row: &OpRow) -> String {
    format!(
        "{}  {}  in {}  (exit {})",
        row.ts, row.origin, row.cwd, row.exit
    )
}

/// **What it said**, if it said anything — the complaint first, because a row
/// that failed is read for why.
///
/// The two are one line rather than two rows: a trail is read by scanning, and
/// a child that printed on both streams is one event.
pub fn output(row: &OpRow) -> Option<String> {
    let said: Vec<String> = [&row.stderr, &row.stdout]
        .into_iter()
        .filter(|stream| !stream.is_empty())
        .cloned()
        .collect();
    (!said.is_empty()).then(|| said.join("\n"))
}

#[cfg(test)]
mod tests;