lernie 0.1.86

lernie: the operator seat — the window and wire client for a yog server
//! **Find text**, across the balls, workspaces and conversations every engine
//! this box holds can see (bl-40ec; DESIGN §4.21).
//!
//! # The seventh covering pane, and the third about no focus
//!
//! `search` names no workspace, so its subject is every channel and the pane
//! is the union — `crate::ui::queue`'s shape, and `crate::ui::commands`' — and
//! its control hangs off the roster for the same reason.
//!
//! # The needle is a required parameter, so the act is DISABLED and not absent
//!
//! DESIGN §4.20's enablement rule, which is the tuning pane's `set` rather
//! than the composer's second start: what is missing is the parameter, not the
//! subject, so the control stays on the glass saying what would fill it. And
//! the box is **not spent on firing** — refining a needle is the common act,
//! and clearing it would charge a retype for every second search.
//!
//! # A hit is READ and not actionable, and the pane says why
//!
//! yog bl-ef16: a search row addresses its workspace and its project by the
//! ENGINE'S OWN ABSOLUTE PATH while every gesture this box composes carries a
//! name, so the keys a row spells are the keys the acts take and the values
//! are not — feed one back and it earns `unknown workspace`. So there is no
//! *go to it* here, and the reason is on the glass rather than in a comment:
//! a control that guessed a name off a path would be the mis-aim
//! `crate::ui::model::window` refuses one layer down, and a control that
//! silently did nothing would be worse.
//!
//! That is `crate::ui::queue`'s decision about a parked invocation, one noun
//! over: saying what was found is worth more than saying nothing while the
//! address it names cannot be spent.

use crate::ui::{Model, theme};

/// **The word typed after the slash** that opens it (`crate::ui::slash`).
pub const WORD: &str = "find";
/// The pane's own heading.
pub const HEADING: &str = "find text";
/// The word on the control that spends the needle.
pub const FIND: &str = "find";
/// What stands beside the control while there is nothing to search for.
pub const NEEDS_WORDS: &str = "type what to look for and this becomes live";
/// What the pane says before anything has been asked.
pub const NOT_ASKED: &str = "nothing has been looked for yet";
/// What a section says for an engine that answered and found nothing.
pub const NOTHING: &str = "this engine found none of it — try fewer words";
/// **Why a hit cannot be acted on** — the standing sentence, said once above
/// the list rather than on every row (yog bl-ef16).
pub const NOT_ADDRESSABLE: &str = "a hit says where it is in the engine's own path spelling, which no gesture \
     from this box can carry — so these are read here and aimed at by hand";

/// 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.finding() {
        return false;
    }
    crate::ui::overlay::name(ui, HEADING);
    asking(ui, model);
    ui.separator();
    let found = model.found.clone();
    if found.is_empty() {
        ui.label(NOT_ASKED);
        return true;
    }
    ui.colored_label(theme::NOTICE, NOT_ADDRESSABLE);
    egui::ScrollArea::vertical()
        .id_salt(HEADING)
        .auto_shrink(false)
        .show(ui, |ui| {
            for section in &found {
                ui.separator();
                ui.label(crate::ui::roster::header(&section.channel));
                answered(ui, &section.found);
            }
        });
    true
}

/// The box and the act: what to look for, and the control that asks.
fn asking(ui: &mut egui::Ui, model: &mut Model) {
    // **The field is as wide as the box and scrolls inside itself** (bl-4f8d).
    // egui's one-line box allocates its text's overflow in the ui it is
    // added to, clipped or not, so a long needle widened the whole overlay
    // and every line under it wrapped at that width — off the window. It is
    // laid in a child region whose one allocation is the field's own rect.
    let (rect, _) = ui.allocate_exact_size(
        egui::vec2(ui.available_width(), ui.spacing().interact_size.y),
        egui::Sense::hover(),
    );
    ui.new_child(egui::UiBuilder::new().max_rect(rect)).add(
        egui::TextEdit::singleline(&mut model.needle)
            .desired_width(rect.width())
            .clip_text(true),
    );
    let live = model.needled();
    let mut fired = false;
    ui.horizontal_wrapped(|ui| {
        let act = ui.add_enabled(live, egui::Button::new(FIND));
        crate::ui::act::tag(&act, &[crate::verbs::SEARCH.word]);
        fired = act.clicked();
    });
    // **A greyed control says a thing is not live and nothing about what would
    // make it live** (DESIGN §4.20), so the sentence stands beside it.
    if !live {
        ui.colored_label(theme::NOTICE, NEEDS_WORDS);
    }
    if fired {
        model.post_search();
    }
}

/// One channel's answer: what it read the needle as, what it could not read,
/// and the hits.
fn answered(ui: &mut egui::Ui, found: &crate::reply::search::Found) {
    // **The engine's own echo of the needle**, never the box's current
    // contents: these rows are an answer to what was asked, and an operator
    // mid-edit must not be told the old rows are about the new words.
    broken(ui, &looked_for(found), theme::INK);
    for why in &found.unreadable {
        ui.colored_label(theme::NOTICE, unread(why));
    }
    if found.rows.is_empty() {
        theme::paint::empty(ui, NOTHING);
        return;
    }
    for row in &found.rows {
        hit(ui, row);
    }
}

/// One hit: what it is in, where in it, and the words around the needle.
fn hit(ui: &mut egui::Ui, row: &crate::reply::search::Hit) {
    broken(ui, &row.subject(), theme::INK);
    broken(
        ui,
        &row.at_field(),
        theme::tone_ink(&crate::reply::convs::Tone::Weak),
    );
    broken(ui, &row.excerpt, theme::INK);
}

/// **A line that breaks anywhere** (bl-4f8d). What these rows carry is the
/// operator's needle and the engine's words around it, and a needle with no
/// space in it — a path, a hash, a pasted line — is one word that egui's wrap
/// will not break: it laid the run past the box's edge, and the box grew with
/// it off the window. Breaking mid-word keeps every glyph inside the box.
fn broken(ui: &mut egui::Ui, text: &str, ink: egui::Color32) {
    let font = egui::TextStyle::Body.resolve(ui.style());
    let mut job = egui::text::LayoutJob::simple(text.to_owned(), font, ink, ui.available_width());
    job.wrap.break_anywhere = true;
    ui.label(job);
}

/// **What this section is an answer to**, in the engine's own words.
pub fn looked_for(found: &crate::reply::search::Found) -> String {
    format!("looked for {:?}", found.needle)
}

/// **What the engine could not read**, which is a different claim from finding
/// nothing there — so it is said as its own sentence rather than folded into
/// an empty result.
pub fn unread(why: &str) -> String {
    format!("could not be read: {why}")
}

#[cfg(test)]
mod tests;