lernie 0.1.68

lernie: the operator seat — the window and wire client for a yog server
//! **The config pane**: the files a wall's policy is written in, and the
//! settings the engine reads out of them (yog's `docs/REMOTE.md` §9, §9.18;
//! PROTOCOL 13).
//!
//! # Two views of one read, never two reads
//!
//! The answer carries the file's bytes and the schema applied to *those very
//! bytes* (REMOTE §9.18), so the settings above the text and the text below it
//! are one moment. A pane that asked twice would be painting two.
//!
//! # The judgement is the engine's and this pane never composes one
//!
//! A setting's `fault` is the engine's own words about that value — the same
//! call its pick gate makes — so what is painted red here is what the far end
//! says is wrong, never what this end guessed. A file with no schema answers no
//! settings at all, which is not an error: it is the raw-text destination doing
//! exactly what upstream says it does.
//!
//! # It writes, and the address is the read's rather than a second rule
//!
//! `config` is one op that is a read or a write depending on whether it carries
//! `text` at all, so the editor is one field away — and the field was never the
//! problem. Three of the five destinations name no workspace, and this seat's
//! poster used to read *no workspace* as *every channel this box holds*, which
//! would have written one operator's `cadence.yaml` onto every engine they are
//! a client of. The fix was not a flag telling the poster not to fan: it was
//! that the fan is what *addressed to no channel in particular* means, and a
//! gesture about ONE engine says which — the way this pane's read already did
//! (`crate::offframe::asker::wall`, DESIGN §4.30, bl-4855).
//!
//! # The workflow destination is a box, because the box is the listing
//!
//! `litany-workflow` is addressed by a name and no read this seat has answers
//! what workflow names exist, so the name is typed. That is not a control
//! asking the operator to remember something a listing could have told them —
//! there is no listing, upstream mints the file on a name it has not seen, and
//! a box is the honest shape of *say which*. Empty names no destination, which
//! is what keeps it an enablement.

/// The box the new bytes are typed into, and the two controls it enables.
mod edit;
/// What a reviewer has staged for this wall's config, and the two verdicts.
pub mod proposals;
/// The typed view of what the engine reads in those bytes.
mod settings;

use crate::reply::lineages::Lineage;
use crate::ui::{Aim, Model, keys, theme};
use crate::verbs::Where;

/// The word that opens the pane, on the wall the window is aimed at.
pub const OPEN: &str = "config…";
/// The word that closes it.
pub const CLOSE: &str = "done";
/// The pane's own heading.
pub const HEADING: &str = "config";
/// The hint the workflow name box wears, and the control beside it.
pub const WORKFLOW: &str = "workflow";
/// How wide that box is — the composer's own arming width, because a workflow
/// name is the same kind of short typed value.
const NAME_WIDTH: f32 = 220.0;
/// What it says before a file has been picked.
pub const NOTHING_PICKED: &str = "pick a file to read";
/// What it says for a wall nobody has been answered about yet.
pub const NOT_ANSWERED: &str = "waiting to hear which lineages this wall holds";
/// What it says for a wall that answered and holds no lineage — a fact about
/// the workspace, and the one empty state here that is not a wait.
pub const NO_LINEAGES: &str = "this wall holds no config lineage";
/// What it says under a file that was asked for and has not answered.
pub const NOT_READ: &str = "waiting to hear what this file holds";
/// What it says for a destination that answered and is empty — a file that
/// does not exist yet reads as no bytes rather than as a refusal.
pub const NO_BYTES: &str = "this file has no bytes yet";
/// What it says for a file whose destination has no schema at all.
pub const NO_SETTINGS: &str = "this file has no typed settings — its bytes are the whole of it";

/// 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.
///
/// **The pane is about the aimed wall**, so it stands only where there is
/// one: it opens only on an aim and the aim is never withdrawn, and taking
/// both here hands every reader below the wall rather than a second question.
pub fn render(ui: &mut egui::Ui, model: &mut Model) -> bool {
    let (Some(_), Some(aim)) = (&model.configuring, model.aim.clone()) else {
        return false;
    };
    ui.heading(HEADING);
    ui.label(format!("on {} — {}", aim.address, aim.channel));
    ui.horizontal_wrapped(|ui| {
        if ui.button(CLOSE).clicked() {
            model.close_configuring();
        }
    });
    ui.separator();
    egui::ScrollArea::vertical()
        .id_salt(HEADING)
        .auto_shrink(false)
        .show(ui, |ui| {
            destinations(ui, model, &aim);
            ui.separator();
            proposals::render(ui, model);
            ui.separator();
            file(ui, model);
        });
    true
}

/// **What can be read**, in the order an operator meets them: the wall's own
/// brazen file, the engine's two globals, then every path on every lineage.
fn destinations(ui: &mut egui::Ui, model: &mut Model, aim: &Aim) {
    ui.horizontal_wrapped(|ui| {
        for at in [
            Where::Brazen {
                workspace: aim.address.clone(),
            },
            Where::LitanyModels,
            Where::Cadence,
        ] {
            pick(ui, model, &at);
        }
    });
    workflow(ui, model);
    match model.lineages.clone() {
        None => {
            ui.label(NOT_ANSWERED);
        }
        Some(rows) if rows.is_empty() => {
            ui.label(NO_LINEAGES);
        }
        Some(rows) => {
            for row in &rows {
                lineage(ui, model, row, &aim.address);
            }
        }
    }
}

/// **The workflow destination, which is a box and then a control.**
///
/// The name is typed because nothing lists one — upstream addresses a workflow
/// by name and mints the file on a name it has not seen — so the box IS the
/// listing. Empty names no destination and the control is dark, which is the
/// tuning pane's `set` rather than an absence: the parameter is missing, not
/// the subject.
fn workflow(ui: &mut egui::Ui, model: &mut Model) {
    ui.horizontal_wrapped(|ui| {
        if let Some(name) = model.workflow_box() {
            ui.add(
                egui::TextEdit::singleline(name)
                    .id(egui::Id::new(keys::WORKFLOW_ID))
                    .desired_width(NAME_WIDTH)
                    .hint_text(WORKFLOW),
            );
        }
        let name = model.workflow_named();
        let at = Where::LitanyWorkflow { name: name.clone() };
        let chosen = model.configured().as_ref() == Some(&at);
        let control = ui.add_enabled(
            !name.is_empty(),
            egui::SelectableLabel::new(chosen, WORKFLOW),
        );
        crate::ui::act::tag(&control, &[crate::verbs::CONFIG]);
        if control.clicked() {
            model.read_config(&at);
        }
    });
}

/// One lineage: what it is and where its tip stands, then a control per path
/// the tip holds.
fn lineage(ui: &mut egui::Ui, model: &mut Model, row: &Lineage, workspace: &str) {
    ui.colored_label(
        theme::tone_ink(&crate::reply::convs::Tone::Weak),
        row.line(),
    );
    ui.horizontal_wrapped(|ui| {
        for path in &row.files {
            pick(
                ui,
                model,
                &Where::Branch {
                    workspace: workspace.to_owned(),
                    lineage: row.name.clone(),
                    path: path.clone(),
                },
            );
        }
    });
}

/// One control that points the pane at a file. **The one this seat is already
/// reading shows as chosen** rather than being dropped from the row: the set
/// of destinations is a fact about the wall, and hiding the one in force would
/// make the picker change shape under a click.
fn pick(ui: &mut egui::Ui, model: &mut Model, at: &Where) {
    let chosen = model.configured().as_ref() == Some(at);
    let control = ui.selectable_label(chosen, at.label());
    crate::ui::act::tag(&control, &[crate::verbs::CONFIG]);
    if control.clicked() {
        model.read_config(at);
    }
}

/// The file the pane is pointed at: its settings, then its bytes.
fn file(ui: &mut egui::Ui, model: &mut Model) {
    let Some(at) = model.configured() else {
        ui.label(NOTHING_PICKED);
        return;
    };
    ui.label(at.label());
    let Some(held) = model.config.clone() else {
        ui.label(NOT_READ);
        return;
    };
    settings::render(ui, &held);
    ui.separator();
    if held.text.is_empty() {
        ui.label(NO_BYTES);
    }
    edit::render(ui, model, &at, &held.text);
}

#[cfg(test)]
mod tests;