Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/facett

facett-console/src/lib.rs

//! **facett-console** (§8 TYPE-3, interactive) — a themed terminal/shell
//! component: a fixed **monospace cell grid**, a block/beam **cursor**, optional
//! **ANSI** colour mapped to the palette, **scrollback** on the CygnusEd
//! smooth-scroll engine (§11), plus the real terminal **gestures** — command
//! entry, history recall, scrolling, and per-line selection. A [`Facet`]:
//! themeable · resizable · scalable · copyable · pasteable · searchable ·
//! selectable.
//!
//! ## FC contract (the canonical Elm split, FC-2 / FC-9)
//! - **[`ConsoleModel`]** — the complete observable, serializable, round-trippable
//!   state: the scrollback (each line carrying a **stable id**, FC-5), the prompt +
//!   in-progress input, the smooth-scroll offset, the command history, and the
//!   selected line. [`Console::state`] hands back a `&ConsoleModel`.
//! - **[`Msg`] + [`Console::update`]** — the single mutation path (FC-2): every
//!   gesture (type / backspace / submit / history / scroll / select / filter) *and*
//!   every host push flow through it, so a headless driver
//!   ([`facett_core::harness`]) reaches the identical transitions the canvas does.
//! - **[`Console::view`]** — a **pure** paint (FC-9): it reads `&self`, paints the
//!   scrollback + prompt, and *returns* the [`Msg`]s the interactions produced; the
//!   [`impl_facet_via_elm!`](facett_core::impl_facet_via_elm) bridge applies them.
//! - **[`Effect::RunCommand`]** — side work *as data* (FC-8): submitting a line
//!   echoes it and asks the host to *run* the command. The console performs no I/O
//!   itself; it hands the command back and the host feeds output via [`Msg::PushLine`].
//! - **Rich `state_json`** — the observable `lines` is published as a *count* (not
//!   the raw span array) and the scroll offset/extent as flat keys, so the macro is
//!   invoked in **form 3** (`custom_state_json`) to preserve those exact keys.

use egui::{FontId, Rect, Sense, Stroke, Ui, WidgetType, pos2, vec2};
use facett_core::clip::{ClipKind, ClipPayload, CopySource, PasteTarget};
use facett_core::scroll_engine::SmoothScroll;
use facett_core::{FacetCaps, Semantics, a11y_node, stable_id, theme};
use serde::{Deserialize, Serialize};

/// A stable, monotonic scrollback-line id (FC-5): assigned once when a line is
/// appended, it survives ring eviction and is **never** an index — selection and
/// per-line interaction key on this, so evicting the oldest line does not silently
/// re-target a selection onto a different line.
pub type LineId = u64;

/// Cursor shape (TYPE-3).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum Cursor {
    Block,
    Beam,
    Underline,
}

/// A coloured run of text within a console line, with a palette-role colour.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Span {
    pub text: String,
    /// Palette role index (see [`AnsiColor`]); `None` = default fg.
    pub color: Option<AnsiColor>,
}

/// One scrollback line: its **stable id** (FC-5) plus the ANSI-parsed coloured runs.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Line {
    /// Stable, monotonic id — survives ring eviction; never an index (FC-5).
    pub id: LineId,
    /// The coloured runs the line is painted from.
    pub spans: Vec<Span>,
}

impl Line {
    /// The plain (ANSI-stripped) text of the line.
    pub fn plain(&self) -> String {
        self.spans.iter().map(|s| s.text.as_str()).collect()
    }
}

/// The 8 ANSI colours, mapped to **palette roles** (not raw RGB) so the terminal
/// follows the theme.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum AnsiColor {
    Black,
    Red,
    Green,
    Yellow,
    Blue,
    Magenta,
    Cyan,
    White,
}

impl AnsiColor {
    /// Map an SGR foreground code (30–37, 90–97) to a colour.
    fn from_sgr(code: u8) -> Option<AnsiColor> {
        Some(match code % 10 {
            0 => AnsiColor::Black,
            1 => AnsiColor::Red,
            2 => AnsiColor::Green,
            3 => AnsiColor::Yellow,
            4 => AnsiColor::Blue,
            5 => AnsiColor::Magenta,
            6 => AnsiColor::Cyan,
            7 => AnsiColor::White,
            _ => return None,
        })
    }

    /// Resolve to a [`Theme`](facett_core::Theme) colour — palette roles, never raw
    /// literals (COH-1).
    fn to_color(self, th: &facett_core::Theme) -> egui::Color32 {
        match self {
            AnsiColor::Black => th.text_dim,
            AnsiColor::Red => th.accent, // error-ish; themes pick the accent
            AnsiColor::Green => th.point,
            AnsiColor::Yellow => th.glow,
            AnsiColor::Blue => th.node_stroke,
            AnsiColor::Magenta => th.panel_stroke,
            AnsiColor::Cyan => th.accent,
            AnsiColor::White => th.text,
        }
    }
}

/// Parse a line containing ANSI SGR colour escapes into coloured [`Span`]s. Only
/// the foreground-colour subset is handled (others are stripped); enough to make
/// CLI output match the palette.
pub fn parse_ansi(line: &str) -> Vec<Span> {
    let mut spans = Vec::new();
    let mut cur = String::new();
    let mut color: Option<AnsiColor> = None;
    let bytes = line.as_bytes();
    let mut i = 0;
    while i < bytes.len() {
        if bytes[i] == 0x1b && i + 1 < bytes.len() && bytes[i + 1] == b'[' {
            // Flush the current run.
            if !cur.is_empty() {
                spans.push(Span { text: std::mem::take(&mut cur), color });
            }
            // Read until 'm'.
            let mut j = i + 2;
            let mut num = String::new();
            while j < bytes.len() && bytes[j] != b'm' {
                num.push(bytes[j] as char);
                j += 1;
            }
            // Apply the (last) SGR code.
            if let Some(code) = num.split(';').next_back().and_then(|s| s.parse::<u8>().ok()) {
                if code == 0 {
                    color = None;
                } else if (30..=37).contains(&code) || (90..=97).contains(&code) {
                    color = AnsiColor::from_sgr(code);
                }
            }
            i = j + 1;
        } else {
            cur.push(bytes[i] as char);
            i += 1;
        }
    }
    if !cur.is_empty() || spans.is_empty() {
        spans.push(Span { text: cur, color });
    }
    spans
}

// ── FC-2: the input surface ───────────────────────────────────────────────────

/// A robot-/CLI-addressable control message (FC-2) — the named boundary a headless
/// driver (or the host, or a canvas gesture) drives the console through. Applied by
/// [`Console::update`], which is the **only** legal way to mutate the
/// [`ConsoleModel`].
#[derive(Clone, Debug, PartialEq)]
pub enum Msg {
    /// The host appends a raw (ANSI) output line to the scrollback (a process'
    /// stdout, a REPL result). Assigns the next stable id and rolls the ring.
    PushLine(String),
    /// Replace the whole in-progress input line (e.g. a paste, or a programmatic set).
    SetInput(String),
    /// Type a character at the input caret.
    InputChar(char),
    /// Delete the character before the input caret.
    Backspace,
    /// Submit the current input: echo `prompt+input` into the scrollback, record the
    /// command in history, clear the input, and emit [`Effect::RunCommand`].
    Submit,
    /// Recall the previous (older) history entry into the input.
    HistoryPrev,
    /// Recall the next (newer) history entry — past the newest returns a blank draft.
    HistoryNext,
    /// Select a scrollback line by its **stable id** (FC-5), or clear with `None`.
    /// An unknown id is ignored (guards against a stale/evicted id).
    SelectLine(Option<LineId>),
    /// Set the free-text find filter (searchable).
    SetFilter(String),
    /// Smooth-scroll the scrollback by a pixel delta.
    ScrollBy(f32),
    /// Smooth-scroll to an absolute pixel offset.
    ScrollTo(f32),
    /// Snap the scroll target to the bottom (newest output).
    ScrollToBottom,
    /// Sync the scroll extent to the laid-out content vs. viewport height. Emitted by
    /// the pure [`view`](Console::view) each frame (it is the one layout-derived
    /// value the view cannot compute without egui); applied on the next `update`, so
    /// the extent trails the content by **one frame** (see the crate note).
    SetScrollMax(f32),
    /// Advance the smooth-scroll animation by `dt` seconds (injected clock, FC-7).
    Tick(f32),
    /// Set the render scale (clamped to `[0.25, 4.0]`).
    SetScale(f32),
    /// Change the cursor shape.
    SetCursor(Cursor),
}

/// Side work as data (FC-8). The console does one thing it cannot do itself —
/// *run* a submitted command — so it hands it back as an [`Effect::RunCommand`]
/// for the host to execute (the host then streams output back via
/// [`Msg::PushLine`]). No I/O is performed inside [`Console::update`].
#[derive(Clone, Debug, PartialEq)]
pub enum Effect {
    /// The user submitted a non-empty command line; the host should execute it.
    RunCommand(String),
}

/// **The complete observable state (FC-1 / FC-3)** of a [`Console`], in one
/// serializable, round-trippable struct.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ConsoleModel {
    /// The deck keys facets off this.
    pub title: String,
    /// Scrollback lines, each carrying a stable id (FC-5) + parsed coloured spans.
    pub lines: Vec<Line>,
    /// Max scrollback (oldest dropped).
    pub max_lines: usize,
    /// The next stable line id to hand out (monotonic; never reused).
    pub next_id: LineId,
    /// The shell prompt shown before the input line.
    pub prompt: String,
    /// The in-progress input (command being typed).
    pub input: String,
    /// The cursor shape.
    pub cursor: Cursor,
    /// Smooth scrollback offset (CygnusEd engine; deterministic via `Tick`).
    pub scroll: SmoothScroll,
    /// Render scale.
    pub scale: f32,
    /// Free-text find filter (searchable).
    pub filter: String,
    /// The selected scrollback line, keyed on its **stable id** (FC-5).
    pub selected: Option<LineId>,
    /// Submitted-command history (oldest → newest).
    pub history: Vec<String>,
    /// The recall cursor into [`history`](Self::history): `None` = editing a fresh
    /// draft; `Some(i)` = showing `history[i]`.
    pub history_pos: Option<usize>,
}

impl ConsoleModel {
    fn new(title: String) -> Self {
        Self {
            title,
            lines: Vec::new(),
            max_lines: 5000,
            next_id: 0,
            prompt: "$ ".into(),
            input: String::new(),
            cursor: Cursor::Block,
            scroll: SmoothScroll::default(),
            scale: 1.0,
            filter: String::new(),
            selected: None,
            history: Vec::new(),
            history_pos: None,
        }
    }
}

/// The console / shell component.
#[derive(Clone, Debug)]
pub struct Console {
    /// All observable state (FC-3).
    state: ConsoleModel,
}

impl Console {
    pub fn new(title: impl Into<String>) -> Self {
        Self { state: ConsoleModel::new(title.into()) }
    }

    pub fn with_prompt(mut self, p: impl Into<String>) -> Self {
        self.state.prompt = p.into();
        self
    }
    pub fn with_cursor(mut self, c: Cursor) -> Self {
        self.state.cursor = c;
        self
    }
    /// Cap the scrollback ring (oldest lines dropped past this).
    pub fn with_max_lines(mut self, n: usize) -> Self {
        self.state.max_lines = n;
        self
    }

    /// **FC-3** — read the complete observable state at any frame boundary.
    pub fn state(&self) -> &ConsoleModel {
        &self.state
    }

    // ── FC-2: the single mutation path ───────────────────────────────────────

    /// **FC-2 / FC-8** — the single mutation path. Apply one [`Msg`]; return the
    /// [`Effect`]s the host should run (empty unless a command was submitted). Every
    /// gesture and every host push routes here, so live == headless.
    pub fn update(&mut self, msg: Msg) -> Vec<Effect> {
        let mut effects = Vec::new();
        match msg {
            Msg::PushLine(raw) => {
                self.append_line(&raw);
                self.state.scroll.scroll_to(self.state.scroll.max); // stick to bottom
            }
            Msg::SetInput(s) => self.state.input = s,
            Msg::InputChar(c) => self.state.input.push(c),
            Msg::Backspace => {
                self.state.input.pop();
            }
            Msg::Submit => {
                let cmd = std::mem::take(&mut self.state.input);
                // Echo the entered command into the scrollback (the shell's own echo).
                self.append_line(&format!("{}{}", self.state.prompt, cmd));
                self.state.scroll.scroll_to(self.state.scroll.max);
                self.state.history_pos = None;
                if !cmd.is_empty() {
                    // De-dup consecutive identical commands (shell HISTCONTROL-ish).
                    if self.state.history.last().map(String::as_str) != Some(cmd.as_str()) {
                        self.state.history.push(cmd.clone());
                    }
                    effects.push(Effect::RunCommand(cmd)); // FC-8: hand it to the host
                }
            }
            Msg::HistoryPrev => {
                if !self.state.history.is_empty() {
                    let pos = match self.state.history_pos {
                        None => self.state.history.len() - 1,
                        Some(p) => p.saturating_sub(1),
                    };
                    self.state.history_pos = Some(pos);
                    self.state.input = self.state.history[pos].clone();
                }
            }
            Msg::HistoryNext => match self.state.history_pos {
                Some(p) if p + 1 < self.state.history.len() => {
                    self.state.history_pos = Some(p + 1);
                    self.state.input = self.state.history[p + 1].clone();
                }
                Some(_) => {
                    // Past the newest → back to an empty draft line.
                    self.state.history_pos = None;
                    self.state.input.clear();
                }
                None => {}
            },
            Msg::SelectLine(sel) => match sel {
                // FC-5: only select a line whose stable id currently exists.
                Some(id) if self.state.lines.iter().any(|l| l.id == id) => {
                    self.state.selected = Some(id);
                }
                Some(_) => {} // unknown/evicted id — ignore
                None => self.state.selected = None,
            },
            Msg::SetFilter(s) => self.state.filter = s,
            Msg::ScrollBy(d) => self.state.scroll.scroll_by(d),
            Msg::ScrollTo(o) => self.state.scroll.scroll_to(o),
            Msg::ScrollToBottom => self.state.scroll.scroll_to(self.state.scroll.max),
            Msg::SetScrollMax(m) => self.state.scroll.set_max(m),
            Msg::Tick(dt) => self.state.scroll.advance(dt),
            Msg::SetScale(s) => self.state.scale = s.clamp(0.25, 4.0),
            Msg::SetCursor(c) => self.state.cursor = c,
        }
        effects
    }

    /// Append one ANSI-parsed line with a fresh stable id, then roll the ring —
    /// dropping the oldest past `max_lines` and clearing a selection whose line was
    /// evicted (FC-5: a stale id must never silently re-target). Private: only
    /// [`update`](Self::update) mutates the model.
    fn append_line(&mut self, raw: &str) {
        let id = self.state.next_id;
        self.state.next_id += 1;
        self.state.lines.push(Line { id, spans: parse_ansi(raw) });
        let over = self.state.lines.len().saturating_sub(self.state.max_lines);
        if over > 0 {
            self.state.lines.drain(0..over);
            // Drop a selection whose line was just evicted (FC-5: never re-target).
            if self.state.selected.is_some_and(|sel| !self.state.lines.iter().any(|l| l.id == sel)) {
                self.state.selected = None;
            }
        }
    }

    // ── host-facing convenience wrappers over `update` (FC-2 single path) ─────

    /// Append a raw line (ANSI-parsed), dropping the oldest past `max_lines`.
    pub fn push_line(&mut self, raw: impl AsRef<str>) {
        let _ = self.update(Msg::PushLine(raw.as_ref().to_string()));
    }

    /// Replace the in-progress input line.
    pub fn set_input(&mut self, s: impl Into<String>) {
        let _ = self.update(Msg::SetInput(s.into()));
    }

    /// Select a scrollback line by stable id (or clear). A thin wrapper over the
    /// FC-2 mutation path.
    pub fn select(&mut self, id: Option<LineId>) {
        let _ = self.update(Msg::SelectLine(id));
    }

    /// Advance the scrollback animation (the injected clock; deterministic).
    pub fn advance(&mut self, dt: f32) {
        let _ = self.update(Msg::Tick(dt));
    }

    pub fn line_count(&self) -> usize {
        self.state.lines.len()
    }

    /// Whether the console is idle (FC-8). The console is host-pushed and
    /// synchronous — lines arrive via [`push_line`](Self::push_line) and there is no
    /// internal async work — so it is always idle from the component's own
    /// perspective. (A submitted command's *execution* is the host's `RunCommand`
    /// effect, not the console's own work.)
    pub fn is_idle(&self) -> bool {
        true
    }

    /// The plain-text scrollback (for copy/search).
    pub fn plain_text(&self) -> String {
        self.state.lines.iter().map(Line::plain).collect::<Vec<_>>().join("\n")
    }
}

// ── typed copy/paste (§16) — scrollback out as Text, paste into the input line ─
impl CopySource for Console {
    fn copy_kinds(&self) -> &[ClipKind] {
        &[ClipKind::Text]
    }

    fn copy_payload(&self) -> Option<ClipPayload> {
        let t = self.plain_text();
        if t.is_empty() { None } else { Some(ClipPayload::Text(t)) }
    }
}

impl PasteTarget for Console {
    fn accepts(&self, k: ClipKind) -> bool {
        // The input prompt ingests any payload's text view (universal fallback).
        matches!(k, ClipKind::Text | ClipKind::Rows | ClipKind::DataColumns)
    }

    fn paste_payload(&mut self, p: &ClipPayload) {
        // Append the text view at the input cursor (shell-prompt paste), collapsing
        // newlines to spaces so a multi-line paste stays a single command line.
        // Routed through the FC-2 mutation path so `update` stays the only writer.
        let text = p.as_text().replace('\n', " ");
        let combined = format!("{}{}", self.state.input, text);
        let _ = self.update(Msg::SetInput(combined));
    }
}

impl Console {
    /// **FC-9 render** — a **pure** function of `&self`: it paints the scrollback +
    /// the prompt/cursor and *returns* the [`Msg`]s the gestures produced (typing,
    /// backspace, submit, history, scroll, per-line select). It MUST NOT mutate the
    /// [`ConsoleModel`]; the [`impl_facet_via_elm!`](facett_core::impl_facet_via_elm)
    /// bridge applies the returned messages through [`update`](Self::update).
    ///
    /// The only `&self`-external effects are egui-side, not model writes: it may
    /// `request_focus`/`request_repaint`. The layout-derived scroll extent is
    /// returned as a [`Msg::SetScrollMax`] (applied next frame) rather than written
    /// here — that is the deliberate one-frame trail noted on the crate.
    pub fn view(&self, ui: &mut Ui) -> Vec<Msg> {
        facett_core::navgraph::declare_own_draw_order(ui); // atoms follow the data order (navgraph §9)
        let mut msgs: Vec<Msg> = Vec::new();
        let st = &self.state;
        let th = theme(ui);
        let cell_h = 16.0 * st.scale;
        let font = FontId::monospace(13.0 * st.scale);

        // Allocate the console surface (click + drag = focus + wheel/flick).
        let (rect, area_resp) = ui.allocate_exact_size(
            vec2(ui.available_width(), ui.available_height().max(cell_h * 3.0)),
            Sense::click_and_drag(),
        );
        let view_h = rect.height();
        let total_h = st.lines.len() as f32 * cell_h;
        // FC-9: emit the layout-derived extent instead of mutating the model here.
        let want_max = (total_h - view_h).max(0.0);
        if (want_max - st.scroll.max).abs() > f32::EPSILON {
            msgs.push(Msg::SetScrollMax(want_max));
        }

        // Wheel scroll while hovered.
        if area_resp.hovered() {
            let dy = ui.input(|i| i.smooth_scroll_delta.y);
            if dy.abs() > f32::EPSILON {
                msgs.push(Msg::ScrollBy(-dy));
            }
        }

        let painter = ui.painter_at(rect);
        painter.rect_filled(rect, 0.0, th.bg);

        let base = ui.id().with("console");
        let py = rect.bottom() - cell_h; // the prompt row sits on the last line

        // Render only visible lines (virtualised), at the fractional smooth offset.
        let (first, frac) = st.scroll.first_row_and_frac(cell_h);
        let visible = (view_h / cell_h).ceil() as usize + 1;
        for vi in 0..visible {
            let li = first + vi;
            if li >= st.lines.len() {
                break;
            }
            let line = &st.lines[li];
            let y = rect.top() + vi as f32 * cell_h - frac;
            let line_rect = Rect::from_min_size(pos2(rect.left(), y), vec2(rect.width(), cell_h));

            // Selection highlight (keyed on the stable id, FC-5).
            if st.selected == Some(line.id) {
                painter.rect_filled(line_rect, 0.0, th.accent.linear_multiply(0.18));
            }

            // Per-line click → toggle selection by stable id (FC-5). The interaction
            // id itself is derived from the stable line id, not the row index.
            let lid = stable_id(base, format!("line-{}", line.id));
            let resp = ui.interact(line_rect, lid, Sense::click());
            if resp.clicked() {
                msgs.push(if st.selected == Some(line.id) {
                    Msg::SelectLine(None)
                } else {
                    Msg::SelectLine(Some(line.id))
                });
            }

            let mut x = rect.left() + 4.0;
            for span in &line.spans {
                let color = span.color.map(|c| c.to_color(&th)).unwrap_or(th.text);
                let g = painter.layout_no_wrap(span.text.clone(), font.clone(), color);
                let w = g.size().x;
                painter.galley(pos2(x, y), g, color);
                x += w;
            }
        }

        // The prompt + input on the last row + the cursor.
        let prompt_text = format!("{}{}", st.prompt, st.input);
        let pg = painter.layout_no_wrap(prompt_text.clone(), font.clone(), th.accent);
        painter.galley(pos2(rect.left() + 4.0, py), pg, th.accent);
        let cw = font.size * 0.6;
        let cx = rect.left() + 4.0 + prompt_text.chars().count() as f32 * cw;
        let crect = Rect::from_min_size(pos2(cx, py), vec2(cw, cell_h));
        match st.cursor {
            Cursor::Block => {
                painter.rect_filled(crect, 0.0, th.accent.linear_multiply(0.5));
            }
            Cursor::Beam => {
                painter.line_segment([crect.left_top(), crect.left_bottom()], Stroke::new(2.0_f32, th.accent));
            }
            Cursor::Underline => {
                painter.line_segment([crect.left_bottom(), crect.right_bottom()], Stroke::new(2.0_f32, th.accent));
            }
        }

        // ── the input line as a focusable, keyboard-driven surface ────────────
        // FC-4: surface the input as a real accesskit TextInput node whose value is
        // the current `input` string, and drive command entry from egui key events.
        let input_rect = Rect::from_min_size(pos2(rect.left(), py), vec2(rect.width(), cell_h));
        let input_id = stable_id(base, "input");
        let input_resp = ui.interact(input_rect, input_id, Sense::click());
        if input_resp.clicked() {
            input_resp.request_focus();
        }
        let typed = st.input.clone();
        input_resp.widget_info(|| egui::WidgetInfo::text_edit(true, "", &typed, "console input"));

        // Command entry: read key/text events only while the input holds focus, and
        // translate each into a Msg (the pure view never mutates the model).
        if input_resp.has_focus() {
            let events = ui.input(|i| i.events.clone());
            for ev in &events {
                match ev {
                    egui::Event::Text(t) => {
                        for c in t.chars() {
                            msgs.push(Msg::InputChar(c));
                        }
                    }
                    egui::Event::Key { key, pressed: true, .. } => match key {
                        egui::Key::Enter => msgs.push(Msg::Submit),
                        egui::Key::Backspace => msgs.push(Msg::Backspace),
                        egui::Key::ArrowUp => msgs.push(Msg::HistoryPrev),
                        egui::Key::ArrowDown => msgs.push(Msg::HistoryNext),
                        _ => {}
                    },
                    _ => {}
                }
            }
        }

        // (b) The scrollback region → a labelled Label node carrying the line count
        // as its numeric value, so a robot driver / screen reader can find it.
        let count = st.lines.len();
        let scrollback_rect = Rect::from_min_max(rect.min, pos2(rect.right(), py));
        a11y_node(
            ui,
            base,
            "scrollback",
            Sense::hover(),
            scrollback_rect,
            Semantics::new(WidgetType::Label, format!("console — {count} lines")).value(count as f64),
        );

        // Keep the smooth-scroll animation alive while it eases (injected clock via
        // `Tick`; here we only ask egui for another frame — not a model write).
        if st.scroll.animating() {
            ui.ctx().request_repaint();
        }

        // ── render-lane emit: this pure view path RAN ─────────────────────────
        #[cfg(feature = "testmatrix")]
        facett_core::testmatrix::emit(
            "facett-console::Console::view",
            "ui_render",
            // OK = the scrollback the a11y node announced matches the line count the
            // virtualiser drew from, and the ring never exceeds its cap.
            count == st.lines.len() && st.lines.len() <= st.max_lines,
            &format!(
                "lines={} max={} input_len={} scroll_max={}",
                st.lines.len(),
                st.max_lines,
                st.input.chars().count(),
                st.scroll.max,
            ),
        );

        msgs
    }
}

// ── FC-2 / FC-3 / FC-8 / FC-9: the canonical Elm split ────────────────────────
impl facett_core::Elm for Console {
    type Model = ConsoleModel;
    type Msg = Msg;
    type Effect = Effect;

    fn title(&self) -> &str {
        &self.state.title
    }
    fn state(&self) -> &ConsoleModel {
        &self.state
    }
    fn update(&mut self, msg: Msg) -> Vec<Effect> {
        Console::update(self, msg)
    }
    fn view(&self, ui: &mut Ui) -> Vec<Msg> {
        Console::view(self, ui)
    }
}

// The bridge macro writes `impl Facet for Console` from the `Elm` impl: `title`, the
// FC-9 `ui` loop (`for m in view(ui) { update(m) }`), plus the overrides below.
// **Form 3** (`custom_state_json`) because the console publishes a RICHER
// `state_json` than a plain `serde(state())`: `lines` is exposed as a *count* (not
// the raw span array) and the scroll offset/extent + `idle` as flat keys — the exact
// observable contract callers read. All the original keys are preserved.
facett_core::impl_facet_via_elm!(Console, custom_state_json, {
    fn state_json(&self) -> serde_json::Value {
        let st = self.state();
        serde_json::json!({
            "title": st.title,
            "lines": st.lines.len(),
            "prompt": st.prompt,
            "input": st.input,
            "cursor": format!("{:?}", st.cursor),
            "scroll_offset": st.scroll.offset,
            "scroll_max": st.scroll.max,
            "scale": st.scale,
            "filter": st.filter,
            "idle": self.is_idle(),
            // additive (non-breaking): the stable-id selection (FC-5) + history depth.
            "selected": st.selected,
            "history_len": st.history.len(),
        })
    }

    fn selection_json(&self) -> serde_json::Value {
        match self.state().selected {
            Some(id) => serde_json::json!({ "line": id }),
            None => serde_json::Value::Null,
        }
    }

    fn caps(&self) -> FacetCaps {
        FacetCaps::NONE.themeable().resizable().scalable().copyable().pasteable().searchable().selectable()
    }

    fn scale(&self) -> f32 {
        self.state().scale
    }
    fn set_scale(&mut self, scale: f32) {
        let _ = self.update(Msg::SetScale(scale));
    }

    fn copy(&mut self) -> Option<String> {
        self.copy_payload().map(|p| p.as_text())
    }

    fn paste(&mut self, text: &str) -> bool {
        self.paste_payload(&ClipPayload::Text(text.to_string()));
        true
    }

    fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
        Some(self)
    }
});

#[cfg(test)]
mod tests;