yog 0.0.5

yog: a balls-oriented session manager for lernie loops (egui frontend)
Documentation
//! The inbox-composer's queue region (§11 inbox-composer, bl-929d): the
//! pending deposits above the typed draft, under the derived fold line.
//! Coverage-excluded glue — every decision is [`crate::composer`]'s (the row
//! projection, the fold-line height, the snap) and [`crate::tail`]'s (the
//! anchor and the measurement); this file only paints them.
//!
//! The region's top edge — the panel boundary's rule — **is** the fold line:
//! its height is [`SnapState::desired`], the settled content measurement
//! (pending rows + the draft's wrapped height) capped at half the pane, so an
//! item landing pushes the line up one row and typing wraps it up the same
//! way, with no stored height and no draggable boundary (§11 rule 3). Inside,
//! [`crate::tail::scroll`] anchors the queue on its bottom edge — the input is
//! the queue's last item — scrolls it past the cap, and during a snap seats
//! the shrunken content on the floor while the line eases down over it.
//!
//! [SnapState::desired]: crate::composer::SnapState::desired

use crate::actions::{DraftKey, Drafts};
use crate::composer::{self, Caret, ComposerRam, QueueRow, Step};
use crate::inboxview::InboxEntry;
use crate::jsonview::{GLYPH_COLLAPSED, GLYPH_EXPANDED, toggle_path};
use crate::theme;

/// The one box (§11): what Enter in it does, either way the target falls.
const BOX_HINT: &str = "Say what you want done. Enter sends it — as a message to the selected \
     conversation, or as a new conversation when nothing is selected. Shift+Enter \
     inserts a newline instead. A draft starting with `/` is a command instead — \
     type `/` alone to see them all, `//` to say a literal slash. ↑ on the box's \
     top line brings back what you already said here, newest first; ↓ on its \
     bottom line comes forward again, and past the newest hands your draft back.";

/// What a pending row's fold arrow reveals (§11 discoverability).
const FOLD_HOVER: &str = "Fold this pending message open or shut. Either way it stays in the \
     inbox: everything below the line enters the next prompt when delivery drains it. \
     No key of its own: Tab reaches it, Space presses it.";

/// What the composer paints, bundled (owned, per the no-named-lifetimes rule)
/// to keep the region under the argument cap.
pub(super) struct QueueCtx {
    /// The draft this composer is composing (bl-a69a).
    pub key: DraftKey,
    /// The message target's agent id — `None` for a new conversation, which
    /// has no inbox and therefore a queue of zero items (the general path).
    pub agent_id: Option<String>,
    /// The target's pending deposits, from the snapshot (§5.1 #11) — never a
    /// frame-time read.
    pub pending: Vec<InboxEntry>,
    /// The box's greyed hint — the target line's twin spelling (bl-2f30).
    pub hint: String,
    /// What the operator has already said to this target, newest first — the
    /// derived recall history (bl-f908), never a stored list.
    pub prompts: Vec<String>,
    /// Half the pane: the fold line's ceiling (§11 rule 3).
    pub cap: f32,
}

/// Paint the queue region — pending rows oldest-first, then the input as the
/// queue's last item — at the derived fold-line height, and return the input
/// box's response for the caller's focus/Enter wiring.
///
/// `titles` is the frame's roster in the form a seat can hold (bl-1eb0): the
/// §3.3 ladder the pending headers' senders ride (bl-b6d0), a paint-time input
/// borrowed rather than copied into [`QueueCtx`], which carries what this
/// region *paints*.
pub(super) fn region(
    ui: &mut egui::Ui,
    ram: &mut ComposerRam,
    drafts: &mut Drafts,
    ctx: &QueueCtx,
    titles: &crate::nav::convs::Titles,
) -> egui::Response {
    // Split the RAM into its independent facts up front: the body closure
    // below holds the folds while the box holds the recall, and one `&mut`
    // over the whole bundle would make them borrow each other.
    let ComposerRam {
        folds,
        snap,
        recall,
        caret,
    } = ram;
    let now = ui.input(|i| i.time);
    snap.observe(&ctx.key, ctx.pending.len(), now);
    let desired = snap.desired(ctx.cap, now);
    let settled = snap.settled();
    let agent = ctx.agent_id.clone().unwrap_or_default();
    let queue = composer::rows(&agent, &ctx.pending, titles, folds);
    // The region's rect is allocated **exactly** at the derived height, and
    // the queue paints into a child bounded to it: an explicit allocation is
    // what lets the panel above shrink as well as grow (a `set_max_height`
    // scope ratchets — egui keeps the larger stale extent), so the fold line
    // rides the content both ways.
    let (rect, _space) = ui.allocate_exact_size(
        egui::vec2(ui.available_width(), desired),
        egui::Sense::hover(),
    );
    let mut region = ui.new_child(
        egui::UiBuilder::new()
            .max_rect(rect)
            .layout(egui::Layout::top_down(egui::Align::Min)),
    );
    let mut body = |ui: &mut egui::Ui| {
        for row in &queue {
            pending_row(ui, row, folds);
        }
        input_box(ui, recall, caret, drafts, ctx)
    };
    // Content past the region (the cap engaged) scrolls, tail-anchored — the
    // §11 tail idiom from its one home. Content within it lays out directly,
    // seated on the region's bottom edge by an explicit top pad — the snap's
    // descending headroom, zero in the steady state (the region *is* its
    // content height, so nothing is ever hidden and there is nothing to
    // scroll).
    let (edit, painted) = if settled > desired + 0.5 {
        crate::tail::scroll(&mut region, true, body)
    } else {
        region.add_space((desired - settled).max(0.0));
        let top = region.cursor().top();
        let edit = body(&mut region);
        (edit, (region.min_rect().bottom() - top).max(0.0))
    };
    snap.settle(painted);
    if snap.active(now) {
        ui.ctx().request_repaint();
    }
    edit
}

/// One pending line (§11 transcript density idiom): the jsonview fold arrow,
/// the brazen `✉ from · at` header — the same signal as the `✉n` badge and the
/// Inbox tab, one derivation seen thrice — and the first line while folded, the
/// whole body below while open. Only the input has no arrow.
fn pending_row(ui: &mut egui::Ui, row: &QueueRow, folds: &mut std::collections::HashSet<String>) {
    // Faded while the deposit is only §7.2's pending echo, solid the moment the
    // derivation makes it a statement (§11, bl-915e). Opacity over the whole
    // row rather than a second hue per element: the row already wears the
    // colours it will keep, so brightening is this same row at full strength.
    ui.scope(|ui| {
        ui.set_opacity(theme::tone_solidity(row.tone));
        row_body(ui, row, folds);
    });
}

/// The row itself, inside its tone scope.
fn row_body(ui: &mut egui::Ui, row: &QueueRow, folds: &mut std::collections::HashSet<String>) {
    ui.horizontal(|ui| {
        // One line, always: overflow truncates at the pane's edge rather than
        // wrapping, which would grow the row (§11 rule 1 on the other axis).
        ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Truncate);
        // The §11 role stripe (bl-3acb): the same role identity the row will
        // wear once delivered, from the one mapping the transcript reads.
        theme::role_stripe(ui, Some(row.role));
        let glyph = if row.expanded {
            GLYPH_EXPANDED
        } else {
            GLYPH_COLLAPSED
        };
        let hit = ui
            .add(
                egui::Label::new(egui::RichText::new(glyph).monospace())
                    .sense(egui::Sense::click()),
            )
            .on_hover_text(FOLD_HOVER);
        if hit.clicked() {
            toggle_path(folds, &row.key);
        }
        ui.colored_label(theme::BRAZEN, &row.header);
        if !row.expanded {
            ui.weak(&row.preview);
        }
    });
    if row.expanded {
        // The opened body **wraps** (bl-5410). The row above truncates because
        // it is a row; this is the whole message the fold exists to show, and
        // the panel's rule-1 `Truncate` would keep only its first line — so
        // opening the fold would paint one line where the shut row already
        // painted one, and the gesture would do nothing at all.
        ui.scope(|ui| {
            ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Wrap);
            ui.label(&row.body);
        });
    }
}

/// The queue's last item: the multiline draft box (bl-4515 key contract — the
/// widget newlines only on Shift+Enter, so a plain Enter stays whole for the
/// caller's send read). The buffer is the target's own (bl-a69a), read in and
/// written back every frame; it is RAM until sent (§5.3).
///
/// The recall's two keys (bl-f908) are taken **before** the widget is added,
/// so a step never also moves the caret; a step the caret gate or the history
/// declines is left alone and the arrow does what it always did.
fn input_box(
    ui: &mut egui::Ui,
    recall: &mut composer::Recall,
    caret: &mut Caret,
    drafts: &mut Drafts,
    ctx: &QueueCtx,
) -> egui::Response {
    // A **stable** widget id: the box sits after a queue whose row count
    // changes frame to frame, and egui's auto-id would shift with it — moving
    // the keyboard's focus target out from under the operator mid-typing
    // exactly when an item lands.
    let id = egui::Id::new("inbox-composer-box");
    let mut buffer = drafts.text(&ctx.key);
    recall.settle(&buffer, &ctx.prompts);
    let mut stepped = false;
    if ui.memory(|m| m.has_focus(id)) {
        for (key, dir) in [
            (egui::Key::ArrowUp, Step::Back),
            (egui::Key::ArrowDown, Step::Forward),
        ] {
            if !ui.input(|i| i.modifiers.is_none() && i.key_pressed(key)) {
                continue;
            }
            if let Some(text) = recall.step(dir, *caret, &buffer, &ctx.prompts) {
                ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, key));
                buffer = text;
                stepped = true;
            }
        }
    }
    let out = egui::TextEdit::multiline(&mut buffer)
        .id(id)
        .desired_rows(1)
        .desired_width(f32::INFINITY)
        .return_key(egui::KeyboardShortcut::new(
            egui::Modifiers::SHIFT,
            egui::Key::Enter,
        ))
        .hint_text(&ctx.hint)
        .show(ui);
    let rows = out.galley.rows.len();
    *caret = if stepped {
        // A recall parks the caret at the end of what it brought back — the
        // galley just painted IS that text, so the row is known here and the
        // next frame's gate reads it rather than the pre-recall cursor.
        park_at_end(ui, id, out.state, buffer.chars().count());
        Caret {
            row: rows.saturating_sub(1),
            rows,
        }
    } else {
        Caret {
            row: out.cursor_range.map_or(0, |r| r.primary.rcursor.row),
            rows,
        }
    };
    drafts.set(ctx.key.clone(), buffer);
    out.response.on_hover_text(BOX_HINT)
}

/// Seat the caret past the last character of a recalled prompt — the shell
/// idiom for "you are looking at this one now", and what makes a one-row
/// prompt sit on the top row and the bottom row at once (§11, bl-f908).
fn park_at_end(
    ui: &egui::Ui,
    id: egui::Id,
    mut state: egui::text_edit::TextEditState,
    chars: usize,
) {
    state
        .cursor
        .set_char_range(Some(egui::text::CCursorRange::one(
            egui::text::CCursor::new(chars),
        )));
    state.store(ui.ctx(), id);
}