lernie 0.1.68

lernie: the operator seat — the window and wire client for a yog server
//! **One wall's row**, the band of per-wall controls that hangs off the aimed
//! one ([`controls`]), and the conversations that stand beneath it.
//!
//! Split from [`super`] at the design-time budget on the seam that module's own
//! doc draws twice over: it is *the channels and their sections*, `acts` is
//! *the ops whose subject is every channel*, and this is *one workspace* — the
//! line it wears, whether this seat can address it at all, and what an operator
//! can do to it once it is aimed at. The first changes when a section grows a
//! sentence; this one when a wall-level op lands a control.

use crate::reply::roster::WsRow;
use crate::ui::{Chunk, Model, convs, theme};

/// The eight acts on one aimed wall, and the band they lie in.
mod controls;

/// **What a row this seat cannot address says instead of being hidden.**
///
/// Dropping it would hide a workspace the operator has; addressing it by the
/// entry's leaf would aim a gesture at a different wall. So it is painted, and
/// painted as what it is.
///
/// The wording states the FACT rather than a verdict (bl-77df). It used to read
/// *"this seat holds no name for it"*, which lands beside a perfectly correct
/// provisioning and reads as an error about the row above it. What is actually
/// true is structural: an entry directory names one workspace, the channel
/// enumerates every workspace that client is registered in, and the extras have
/// no entry of their own — so no envelope this seat can write reaches them.
pub const NO_NAME_HERE: &str = "no entry here names it, so nothing typed here can address it";

/// **The word on the control that floats this wall to the front of the strip**,
/// offered on an aimed row that is not pinned.
pub const PIN: &str = "pin";
/// **And the one that takes it back out**, offered where it is pinned. Two
/// words rather than one that toggles, because the two ops are assertions: the
/// control names the act it fires (`crate::verbs::workspace`).
pub const UNPIN: &str = "unpin";

/// One wall: selectable when this seat can address it, a plain line when it
/// cannot.
///
/// **The eight per-wall controls hang off the aimed row and off no other**,
/// because
/// an enrollment mints the pair `(client, workspace)` and the workspace is
/// exactly what an aim is. Offering it on every row would be offering it before
/// the operator had said which wall — and the answer to that question is
/// already on the screen, once.
pub fn render(ui: &mut egui::Ui, model: &mut Model, chunk: &Chunk, row: &WsRow, reveal: bool) {
    let Some(address) = chunk.channel.address(row) else {
        // **It is a row, and it is faint** (`docs/STYLE.md` §5): the same
        // shape as the rows above and below it, so the list reads as one list,
        // in the ink that says *on the glass and not a target*. It carries no
        // state and takes no click, because there is no gesture to fire.
        theme::paint::row(
            ui,
            &format!("{}  — {NO_NAME_HERE}", line(row)),
            theme::INK_FAINT,
            None,
            false,
            0.0,
        );
        return;
    };
    let aim = crate::ui::Aim {
        channel: chunk.channel.name.clone(),
        address: address.clone(),
    };
    let aimed = model.aim.as_ref() == Some(&aim);
    // **The row's rule is its state and the aim is the brand** (§2: *the eye
    // lands on green*). Asking outranks running because asking is the one that
    // wants a person; the aimed row keeps the brand over both, which
    // `theme::paint::row` decides so that no pane holds a second opinion.
    let state = if row.attention > 0 {
        Some(theme::accent(theme::State::Attention))
    } else if row.running {
        Some(theme::accent(theme::State::Working))
    } else {
        None
    };
    let seat = theme::paint::row(ui, &line(row), theme::INK, state, aimed, 0.0);
    // **The read this gesture reaches**, not the one that painted the row
    // (yog's `docs/PARITY.md` §2: the interactable a query owes a seat is the
    // affordance that reaches the view it populates). Aiming at a wall is what
    // makes this seat read that wall's conversations.
    crate::ui::act::tag(&seat, &[crate::verbs::CONVERSATIONS.word]);
    if aimed && reveal {
        seat.scroll_to_me(None);
    }
    if seat.clicked() {
        model.aim_at(&chunk.channel.name.clone(), &address);
    }
    if !aimed {
        return;
    }
    // **All eight per-wall controls hang off the aimed row and off no other**,
    // and all eight stand down while a pane already covers the conversation:
    // what they open would replace what is standing there, so offering them is
    // offering to lose it without saying so.
    //
    // **They are ONE compact strip under the row, indented, in the order the
    // ledger reads them** (`docs/STYLE.md` §2, DESIGN §4.20): the acts on the
    // wall as an object, a short verb apiece, wrapping at the column's width,
    // with the destructive one last (bl-f251). A column of eight full-width
    // controls under a row would read as eight more rows.
    if !model.covered() {
        ui.horizontal_wrapped(|ui| {
            ui.add_space(theme::space::L);
            controls::render(ui, model, row);
        });
    }
    // **And under the strip, this wall's conversations** (DESIGN §4.39): the
    // rows the middle column used to paint, standing where the operator's own
    // sentence put them. They hang off the AIMED wall and off no other,
    // because the standing read set asks about one wall (§4.12) and a seat
    // that painted rows under a wall nobody has asked about would be painting
    // an emptiness it has no evidence for. Unlike the controls they stand
    // under a covering pane: a covering pane replaces the CONVERSATION, and
    // the list beside it goes on being the list.
    convs::under(ui, model, &aim, reveal);
}

/// One wall's line: what it is called, then the state it is in, then the
/// facts that never change what the operator does next.
///
/// **The order is what survives the cut** (bl-cecd). The row is one run elided
/// at the column's width ([`theme::paint::row`]), so whatever stands last is
/// what a narrow roster loses. The rollups that carry a state — `N waiting`,
/// `running` — follow the name, where the rule at the row's left edge is
/// echoed in words; the count of conversations and the classification are the
/// weakest facts and go last, where losing them costs least. The two rollups
/// are stated only when they are non-zero — a roster of `0 waiting` on every
/// row teaches nothing and costs the one that says `3`.
///
/// **The ledger column** (REMOTE §9.23, DESIGN §4.41) is what the wall has
/// cost, whole, where the engine priced it — after the state and before the
/// weak facts, because money is what an operator scans a roster for second.
pub fn line(row: &WsRow) -> String {
    let mut said = vec![row.workspace.clone()];
    if row.attention > 0 {
        said.push(format!("{} waiting", row.attention));
    }
    if row.running {
        said.push("running".to_owned());
    }
    if let Some(cost) = &row.spend {
        said.push(cost.said());
    }
    said.push(format!("{} conversations", row.agents));
    said.push(format!("({})", row.kind.label()));
    said.join("  ")
}

#[cfg(test)]
mod tests;