Skip to main content

facett_console/
lib.rs

1//! **facett-console** (§8 TYPE-3, interactive) — a themed terminal/shell
2//! component: a fixed **monospace cell grid**, a block/beam **cursor**, optional
3//! **ANSI** colour mapped to the palette, **scrollback** on the CygnusEd
4//! smooth-scroll engine (§11), plus the real terminal **gestures** — command
5//! entry, history recall, scrolling, and per-line selection. A [`Facet`]:
6//! themeable · resizable · scalable · copyable · pasteable · searchable ·
7//! selectable.
8//!
9//! ## FC contract (the canonical Elm split, FC-2 / FC-9)
10//! - **[`ConsoleModel`]** — the complete observable, serializable, round-trippable
11//!   state: the scrollback (each line carrying a **stable id**, FC-5), the prompt +
12//!   in-progress input, the smooth-scroll offset, the command history, and the
13//!   selected line. [`Console::state`] hands back a `&ConsoleModel`.
14//! - **[`Msg`] + [`Console::update`]** — the single mutation path (FC-2): every
15//!   gesture (type / backspace / submit / history / scroll / select / filter) *and*
16//!   every host push flow through it, so a headless driver
17//!   ([`facett_core::harness`]) reaches the identical transitions the canvas does.
18//! - **[`Console::view`]** — a **pure** paint (FC-9): it reads `&self`, paints the
19//!   scrollback + prompt, and *returns* the [`Msg`]s the interactions produced; the
20//!   [`impl_facet_via_elm!`](facett_core::impl_facet_via_elm) bridge applies them.
21//! - **[`Effect::RunCommand`]** — side work *as data* (FC-8): submitting a line
22//!   echoes it and asks the host to *run* the command. The console performs no I/O
23//!   itself; it hands the command back and the host feeds output via [`Msg::PushLine`].
24//! - **Rich `state_json`** — the observable `lines` is published as a *count* (not
25//!   the raw span array) and the scroll offset/extent as flat keys, so the macro is
26//!   invoked in **form 3** (`custom_state_json`) to preserve those exact keys.
27
28use egui::{FontId, Rect, Sense, Stroke, Ui, WidgetType, pos2, vec2};
29use facett_core::clip::{ClipKind, ClipPayload, CopySource, PasteTarget};
30use facett_core::scroll_engine::SmoothScroll;
31use facett_core::{FacetCaps, Semantics, a11y_node, stable_id, theme};
32use serde::{Deserialize, Serialize};
33
34/// A stable, monotonic scrollback-line id (FC-5): assigned once when a line is
35/// appended, it survives ring eviction and is **never** an index — selection and
36/// per-line interaction key on this, so evicting the oldest line does not silently
37/// re-target a selection onto a different line.
38pub type LineId = u64;
39
40/// Cursor shape (TYPE-3).
41#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
42pub enum Cursor {
43    Block,
44    Beam,
45    Underline,
46}
47
48/// A coloured run of text within a console line, with a palette-role colour.
49#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
50pub struct Span {
51    pub text: String,
52    /// Palette role index (see [`AnsiColor`]); `None` = default fg.
53    pub color: Option<AnsiColor>,
54}
55
56/// One scrollback line: its **stable id** (FC-5) plus the ANSI-parsed coloured runs.
57#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
58pub struct Line {
59    /// Stable, monotonic id — survives ring eviction; never an index (FC-5).
60    pub id: LineId,
61    /// The coloured runs the line is painted from.
62    pub spans: Vec<Span>,
63}
64
65impl Line {
66    /// The plain (ANSI-stripped) text of the line.
67    pub fn plain(&self) -> String {
68        self.spans.iter().map(|s| s.text.as_str()).collect()
69    }
70}
71
72/// The 8 ANSI colours, mapped to **palette roles** (not raw RGB) so the terminal
73/// follows the theme.
74#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
75pub enum AnsiColor {
76    Black,
77    Red,
78    Green,
79    Yellow,
80    Blue,
81    Magenta,
82    Cyan,
83    White,
84}
85
86impl AnsiColor {
87    /// Map an SGR foreground code (30–37, 90–97) to a colour.
88    fn from_sgr(code: u8) -> Option<AnsiColor> {
89        Some(match code % 10 {
90            0 => AnsiColor::Black,
91            1 => AnsiColor::Red,
92            2 => AnsiColor::Green,
93            3 => AnsiColor::Yellow,
94            4 => AnsiColor::Blue,
95            5 => AnsiColor::Magenta,
96            6 => AnsiColor::Cyan,
97            7 => AnsiColor::White,
98            _ => return None,
99        })
100    }
101
102    /// Resolve to a [`Theme`](facett_core::Theme) colour — palette roles, never raw
103    /// literals (COH-1).
104    fn to_color(self, th: &facett_core::Theme) -> egui::Color32 {
105        match self {
106            AnsiColor::Black => th.text_dim,
107            AnsiColor::Red => th.accent, // error-ish; themes pick the accent
108            AnsiColor::Green => th.point,
109            AnsiColor::Yellow => th.glow,
110            AnsiColor::Blue => th.node_stroke,
111            AnsiColor::Magenta => th.panel_stroke,
112            AnsiColor::Cyan => th.accent,
113            AnsiColor::White => th.text,
114        }
115    }
116}
117
118/// Parse a line containing ANSI SGR colour escapes into coloured [`Span`]s. Only
119/// the foreground-colour subset is handled (others are stripped); enough to make
120/// CLI output match the palette.
121pub fn parse_ansi(line: &str) -> Vec<Span> {
122    let mut spans = Vec::new();
123    let mut cur = String::new();
124    let mut color: Option<AnsiColor> = None;
125    let bytes = line.as_bytes();
126    let mut i = 0;
127    while i < bytes.len() {
128        if bytes[i] == 0x1b && i + 1 < bytes.len() && bytes[i + 1] == b'[' {
129            // Flush the current run.
130            if !cur.is_empty() {
131                spans.push(Span { text: std::mem::take(&mut cur), color });
132            }
133            // Read until 'm'.
134            let mut j = i + 2;
135            let mut num = String::new();
136            while j < bytes.len() && bytes[j] != b'm' {
137                num.push(bytes[j] as char);
138                j += 1;
139            }
140            // Apply the (last) SGR code.
141            if let Some(code) = num.split(';').next_back().and_then(|s| s.parse::<u8>().ok()) {
142                if code == 0 {
143                    color = None;
144                } else if (30..=37).contains(&code) || (90..=97).contains(&code) {
145                    color = AnsiColor::from_sgr(code);
146                }
147            }
148            i = j + 1;
149        } else {
150            cur.push(bytes[i] as char);
151            i += 1;
152        }
153    }
154    if !cur.is_empty() || spans.is_empty() {
155        spans.push(Span { text: cur, color });
156    }
157    spans
158}
159
160// ── FC-2: the input surface ───────────────────────────────────────────────────
161
162/// A robot-/CLI-addressable control message (FC-2) — the named boundary a headless
163/// driver (or the host, or a canvas gesture) drives the console through. Applied by
164/// [`Console::update`], which is the **only** legal way to mutate the
165/// [`ConsoleModel`].
166#[derive(Clone, Debug, PartialEq)]
167pub enum Msg {
168    /// The host appends a raw (ANSI) output line to the scrollback (a process'
169    /// stdout, a REPL result). Assigns the next stable id and rolls the ring.
170    PushLine(String),
171    /// Replace the whole in-progress input line (e.g. a paste, or a programmatic set).
172    SetInput(String),
173    /// Type a character at the input caret.
174    InputChar(char),
175    /// Delete the character before the input caret.
176    Backspace,
177    /// Submit the current input: echo `prompt+input` into the scrollback, record the
178    /// command in history, clear the input, and emit [`Effect::RunCommand`].
179    Submit,
180    /// Recall the previous (older) history entry into the input.
181    HistoryPrev,
182    /// Recall the next (newer) history entry — past the newest returns a blank draft.
183    HistoryNext,
184    /// Select a scrollback line by its **stable id** (FC-5), or clear with `None`.
185    /// An unknown id is ignored (guards against a stale/evicted id).
186    SelectLine(Option<LineId>),
187    /// Set the free-text find filter (searchable).
188    SetFilter(String),
189    /// Smooth-scroll the scrollback by a pixel delta.
190    ScrollBy(f32),
191    /// Smooth-scroll to an absolute pixel offset.
192    ScrollTo(f32),
193    /// Snap the scroll target to the bottom (newest output).
194    ScrollToBottom,
195    /// Sync the scroll extent to the laid-out content vs. viewport height. Emitted by
196    /// the pure [`view`](Console::view) each frame (it is the one layout-derived
197    /// value the view cannot compute without egui); applied on the next `update`, so
198    /// the extent trails the content by **one frame** (see the crate note).
199    SetScrollMax(f32),
200    /// Advance the smooth-scroll animation by `dt` seconds (injected clock, FC-7).
201    Tick(f32),
202    /// Set the render scale (clamped to `[0.25, 4.0]`).
203    SetScale(f32),
204    /// Change the cursor shape.
205    SetCursor(Cursor),
206}
207
208/// Side work as data (FC-8). The console does one thing it cannot do itself —
209/// *run* a submitted command — so it hands it back as an [`Effect::RunCommand`]
210/// for the host to execute (the host then streams output back via
211/// [`Msg::PushLine`]). No I/O is performed inside [`Console::update`].
212#[derive(Clone, Debug, PartialEq)]
213pub enum Effect {
214    /// The user submitted a non-empty command line; the host should execute it.
215    RunCommand(String),
216}
217
218/// **The complete observable state (FC-1 / FC-3)** of a [`Console`], in one
219/// serializable, round-trippable struct.
220#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
221pub struct ConsoleModel {
222    /// The deck keys facets off this.
223    pub title: String,
224    /// Scrollback lines, each carrying a stable id (FC-5) + parsed coloured spans.
225    pub lines: Vec<Line>,
226    /// Max scrollback (oldest dropped).
227    pub max_lines: usize,
228    /// The next stable line id to hand out (monotonic; never reused).
229    pub next_id: LineId,
230    /// The shell prompt shown before the input line.
231    pub prompt: String,
232    /// The in-progress input (command being typed).
233    pub input: String,
234    /// The cursor shape.
235    pub cursor: Cursor,
236    /// Smooth scrollback offset (CygnusEd engine; deterministic via `Tick`).
237    pub scroll: SmoothScroll,
238    /// Render scale.
239    pub scale: f32,
240    /// Free-text find filter (searchable).
241    pub filter: String,
242    /// The selected scrollback line, keyed on its **stable id** (FC-5).
243    pub selected: Option<LineId>,
244    /// Submitted-command history (oldest → newest).
245    pub history: Vec<String>,
246    /// The recall cursor into [`history`](Self::history): `None` = editing a fresh
247    /// draft; `Some(i)` = showing `history[i]`.
248    pub history_pos: Option<usize>,
249}
250
251impl ConsoleModel {
252    fn new(title: String) -> Self {
253        Self {
254            title,
255            lines: Vec::new(),
256            max_lines: 5000,
257            next_id: 0,
258            prompt: "$ ".into(),
259            input: String::new(),
260            cursor: Cursor::Block,
261            scroll: SmoothScroll::default(),
262            scale: 1.0,
263            filter: String::new(),
264            selected: None,
265            history: Vec::new(),
266            history_pos: None,
267        }
268    }
269}
270
271/// The console / shell component.
272#[derive(Clone, Debug)]
273pub struct Console {
274    /// All observable state (FC-3).
275    state: ConsoleModel,
276}
277
278impl Console {
279    pub fn new(title: impl Into<String>) -> Self {
280        Self { state: ConsoleModel::new(title.into()) }
281    }
282
283    pub fn with_prompt(mut self, p: impl Into<String>) -> Self {
284        self.state.prompt = p.into();
285        self
286    }
287    pub fn with_cursor(mut self, c: Cursor) -> Self {
288        self.state.cursor = c;
289        self
290    }
291    /// Cap the scrollback ring (oldest lines dropped past this).
292    pub fn with_max_lines(mut self, n: usize) -> Self {
293        self.state.max_lines = n;
294        self
295    }
296
297    /// **FC-3** — read the complete observable state at any frame boundary.
298    pub fn state(&self) -> &ConsoleModel {
299        &self.state
300    }
301
302    // ── FC-2: the single mutation path ───────────────────────────────────────
303
304    /// **FC-2 / FC-8** — the single mutation path. Apply one [`Msg`]; return the
305    /// [`Effect`]s the host should run (empty unless a command was submitted). Every
306    /// gesture and every host push routes here, so live == headless.
307    pub fn update(&mut self, msg: Msg) -> Vec<Effect> {
308        let mut effects = Vec::new();
309        match msg {
310            Msg::PushLine(raw) => {
311                self.append_line(&raw);
312                self.state.scroll.scroll_to(self.state.scroll.max); // stick to bottom
313            }
314            Msg::SetInput(s) => self.state.input = s,
315            Msg::InputChar(c) => self.state.input.push(c),
316            Msg::Backspace => {
317                self.state.input.pop();
318            }
319            Msg::Submit => {
320                let cmd = std::mem::take(&mut self.state.input);
321                // Echo the entered command into the scrollback (the shell's own echo).
322                self.append_line(&format!("{}{}", self.state.prompt, cmd));
323                self.state.scroll.scroll_to(self.state.scroll.max);
324                self.state.history_pos = None;
325                if !cmd.is_empty() {
326                    // De-dup consecutive identical commands (shell HISTCONTROL-ish).
327                    if self.state.history.last().map(String::as_str) != Some(cmd.as_str()) {
328                        self.state.history.push(cmd.clone());
329                    }
330                    effects.push(Effect::RunCommand(cmd)); // FC-8: hand it to the host
331                }
332            }
333            Msg::HistoryPrev => {
334                if !self.state.history.is_empty() {
335                    let pos = match self.state.history_pos {
336                        None => self.state.history.len() - 1,
337                        Some(p) => p.saturating_sub(1),
338                    };
339                    self.state.history_pos = Some(pos);
340                    self.state.input = self.state.history[pos].clone();
341                }
342            }
343            Msg::HistoryNext => match self.state.history_pos {
344                Some(p) if p + 1 < self.state.history.len() => {
345                    self.state.history_pos = Some(p + 1);
346                    self.state.input = self.state.history[p + 1].clone();
347                }
348                Some(_) => {
349                    // Past the newest → back to an empty draft line.
350                    self.state.history_pos = None;
351                    self.state.input.clear();
352                }
353                None => {}
354            },
355            Msg::SelectLine(sel) => match sel {
356                // FC-5: only select a line whose stable id currently exists.
357                Some(id) if self.state.lines.iter().any(|l| l.id == id) => {
358                    self.state.selected = Some(id);
359                }
360                Some(_) => {} // unknown/evicted id — ignore
361                None => self.state.selected = None,
362            },
363            Msg::SetFilter(s) => self.state.filter = s,
364            Msg::ScrollBy(d) => self.state.scroll.scroll_by(d),
365            Msg::ScrollTo(o) => self.state.scroll.scroll_to(o),
366            Msg::ScrollToBottom => self.state.scroll.scroll_to(self.state.scroll.max),
367            Msg::SetScrollMax(m) => self.state.scroll.set_max(m),
368            Msg::Tick(dt) => self.state.scroll.advance(dt),
369            Msg::SetScale(s) => self.state.scale = s.clamp(0.25, 4.0),
370            Msg::SetCursor(c) => self.state.cursor = c,
371        }
372        effects
373    }
374
375    /// Append one ANSI-parsed line with a fresh stable id, then roll the ring —
376    /// dropping the oldest past `max_lines` and clearing a selection whose line was
377    /// evicted (FC-5: a stale id must never silently re-target). Private: only
378    /// [`update`](Self::update) mutates the model.
379    fn append_line(&mut self, raw: &str) {
380        let id = self.state.next_id;
381        self.state.next_id += 1;
382        self.state.lines.push(Line { id, spans: parse_ansi(raw) });
383        let over = self.state.lines.len().saturating_sub(self.state.max_lines);
384        if over > 0 {
385            self.state.lines.drain(0..over);
386            // Drop a selection whose line was just evicted (FC-5: never re-target).
387            if self.state.selected.is_some_and(|sel| !self.state.lines.iter().any(|l| l.id == sel)) {
388                self.state.selected = None;
389            }
390        }
391    }
392
393    // ── host-facing convenience wrappers over `update` (FC-2 single path) ─────
394
395    /// Append a raw line (ANSI-parsed), dropping the oldest past `max_lines`.
396    pub fn push_line(&mut self, raw: impl AsRef<str>) {
397        let _ = self.update(Msg::PushLine(raw.as_ref().to_string()));
398    }
399
400    /// Replace the in-progress input line.
401    pub fn set_input(&mut self, s: impl Into<String>) {
402        let _ = self.update(Msg::SetInput(s.into()));
403    }
404
405    /// Select a scrollback line by stable id (or clear). A thin wrapper over the
406    /// FC-2 mutation path.
407    pub fn select(&mut self, id: Option<LineId>) {
408        let _ = self.update(Msg::SelectLine(id));
409    }
410
411    /// Advance the scrollback animation (the injected clock; deterministic).
412    pub fn advance(&mut self, dt: f32) {
413        let _ = self.update(Msg::Tick(dt));
414    }
415
416    pub fn line_count(&self) -> usize {
417        self.state.lines.len()
418    }
419
420    /// Whether the console is idle (FC-8). The console is host-pushed and
421    /// synchronous — lines arrive via [`push_line`](Self::push_line) and there is no
422    /// internal async work — so it is always idle from the component's own
423    /// perspective. (A submitted command's *execution* is the host's `RunCommand`
424    /// effect, not the console's own work.)
425    pub fn is_idle(&self) -> bool {
426        true
427    }
428
429    /// The plain-text scrollback (for copy/search).
430    pub fn plain_text(&self) -> String {
431        self.state.lines.iter().map(Line::plain).collect::<Vec<_>>().join("\n")
432    }
433}
434
435// ── typed copy/paste (§16) — scrollback out as Text, paste into the input line ─
436impl CopySource for Console {
437    fn copy_kinds(&self) -> &[ClipKind] {
438        &[ClipKind::Text]
439    }
440
441    fn copy_payload(&self) -> Option<ClipPayload> {
442        let t = self.plain_text();
443        if t.is_empty() { None } else { Some(ClipPayload::Text(t)) }
444    }
445}
446
447impl PasteTarget for Console {
448    fn accepts(&self, k: ClipKind) -> bool {
449        // The input prompt ingests any payload's text view (universal fallback).
450        matches!(k, ClipKind::Text | ClipKind::Rows | ClipKind::DataColumns)
451    }
452
453    fn paste_payload(&mut self, p: &ClipPayload) {
454        // Append the text view at the input cursor (shell-prompt paste), collapsing
455        // newlines to spaces so a multi-line paste stays a single command line.
456        // Routed through the FC-2 mutation path so `update` stays the only writer.
457        let text = p.as_text().replace('\n', " ");
458        let combined = format!("{}{}", self.state.input, text);
459        let _ = self.update(Msg::SetInput(combined));
460    }
461}
462
463impl Console {
464    /// **FC-9 render** — a **pure** function of `&self`: it paints the scrollback +
465    /// the prompt/cursor and *returns* the [`Msg`]s the gestures produced (typing,
466    /// backspace, submit, history, scroll, per-line select). It MUST NOT mutate the
467    /// [`ConsoleModel`]; the [`impl_facet_via_elm!`](facett_core::impl_facet_via_elm)
468    /// bridge applies the returned messages through [`update`](Self::update).
469    ///
470    /// The only `&self`-external effects are egui-side, not model writes: it may
471    /// `request_focus`/`request_repaint`. The layout-derived scroll extent is
472    /// returned as a [`Msg::SetScrollMax`] (applied next frame) rather than written
473    /// here — that is the deliberate one-frame trail noted on the crate.
474    pub fn view(&self, ui: &mut Ui) -> Vec<Msg> {
475        let mut msgs: Vec<Msg> = Vec::new();
476        let st = &self.state;
477        let th = theme(ui);
478        let cell_h = 16.0 * st.scale;
479        let font = FontId::monospace(13.0 * st.scale);
480
481        // Allocate the console surface (click + drag = focus + wheel/flick).
482        let (rect, area_resp) = ui.allocate_exact_size(
483            vec2(ui.available_width(), ui.available_height().max(cell_h * 3.0)),
484            Sense::click_and_drag(),
485        );
486        let view_h = rect.height();
487        let total_h = st.lines.len() as f32 * cell_h;
488        // FC-9: emit the layout-derived extent instead of mutating the model here.
489        let want_max = (total_h - view_h).max(0.0);
490        if (want_max - st.scroll.max).abs() > f32::EPSILON {
491            msgs.push(Msg::SetScrollMax(want_max));
492        }
493
494        // Wheel scroll while hovered.
495        if area_resp.hovered() {
496            let dy = ui.input(|i| i.smooth_scroll_delta.y);
497            if dy.abs() > f32::EPSILON {
498                msgs.push(Msg::ScrollBy(-dy));
499            }
500        }
501
502        let painter = ui.painter_at(rect);
503        painter.rect_filled(rect, 0.0, th.bg);
504
505        let base = ui.id().with("console");
506        let py = rect.bottom() - cell_h; // the prompt row sits on the last line
507
508        // Render only visible lines (virtualised), at the fractional smooth offset.
509        let (first, frac) = st.scroll.first_row_and_frac(cell_h);
510        let visible = (view_h / cell_h).ceil() as usize + 1;
511        for vi in 0..visible {
512            let li = first + vi;
513            if li >= st.lines.len() {
514                break;
515            }
516            let line = &st.lines[li];
517            let y = rect.top() + vi as f32 * cell_h - frac;
518            let line_rect = Rect::from_min_size(pos2(rect.left(), y), vec2(rect.width(), cell_h));
519
520            // Selection highlight (keyed on the stable id, FC-5).
521            if st.selected == Some(line.id) {
522                painter.rect_filled(line_rect, 0.0, th.accent.linear_multiply(0.18));
523            }
524
525            // Per-line click → toggle selection by stable id (FC-5). The interaction
526            // id itself is derived from the stable line id, not the row index.
527            let lid = stable_id(base, format!("line-{}", line.id));
528            let resp = ui.interact(line_rect, lid, Sense::click());
529            if resp.clicked() {
530                msgs.push(if st.selected == Some(line.id) {
531                    Msg::SelectLine(None)
532                } else {
533                    Msg::SelectLine(Some(line.id))
534                });
535            }
536
537            let mut x = rect.left() + 4.0;
538            for span in &line.spans {
539                let color = span.color.map(|c| c.to_color(&th)).unwrap_or(th.text);
540                let g = painter.layout_no_wrap(span.text.clone(), font.clone(), color);
541                let w = g.size().x;
542                painter.galley(pos2(x, y), g, color);
543                x += w;
544            }
545        }
546
547        // The prompt + input on the last row + the cursor.
548        let prompt_text = format!("{}{}", st.prompt, st.input);
549        let pg = painter.layout_no_wrap(prompt_text.clone(), font.clone(), th.accent);
550        painter.galley(pos2(rect.left() + 4.0, py), pg, th.accent);
551        let cw = font.size * 0.6;
552        let cx = rect.left() + 4.0 + prompt_text.chars().count() as f32 * cw;
553        let crect = Rect::from_min_size(pos2(cx, py), vec2(cw, cell_h));
554        match st.cursor {
555            Cursor::Block => {
556                painter.rect_filled(crect, 0.0, th.accent.linear_multiply(0.5));
557            }
558            Cursor::Beam => {
559                painter.line_segment([crect.left_top(), crect.left_bottom()], Stroke::new(2.0, th.accent));
560            }
561            Cursor::Underline => {
562                painter.line_segment([crect.left_bottom(), crect.right_bottom()], Stroke::new(2.0, th.accent));
563            }
564        }
565
566        // ── the input line as a focusable, keyboard-driven surface ────────────
567        // FC-4: surface the input as a real accesskit TextInput node whose value is
568        // the current `input` string, and drive command entry from egui key events.
569        let input_rect = Rect::from_min_size(pos2(rect.left(), py), vec2(rect.width(), cell_h));
570        let input_id = stable_id(base, "input");
571        let input_resp = ui.interact(input_rect, input_id, Sense::click());
572        if input_resp.clicked() {
573            input_resp.request_focus();
574        }
575        let typed = st.input.clone();
576        input_resp.widget_info(|| egui::WidgetInfo::text_edit(true, "", &typed, "console input"));
577
578        // Command entry: read key/text events only while the input holds focus, and
579        // translate each into a Msg (the pure view never mutates the model).
580        if input_resp.has_focus() {
581            let events = ui.input(|i| i.events.clone());
582            for ev in &events {
583                match ev {
584                    egui::Event::Text(t) => {
585                        for c in t.chars() {
586                            msgs.push(Msg::InputChar(c));
587                        }
588                    }
589                    egui::Event::Key { key, pressed: true, .. } => match key {
590                        egui::Key::Enter => msgs.push(Msg::Submit),
591                        egui::Key::Backspace => msgs.push(Msg::Backspace),
592                        egui::Key::ArrowUp => msgs.push(Msg::HistoryPrev),
593                        egui::Key::ArrowDown => msgs.push(Msg::HistoryNext),
594                        _ => {}
595                    },
596                    _ => {}
597                }
598            }
599        }
600
601        // (b) The scrollback region → a labelled Label node carrying the line count
602        // as its numeric value, so a robot driver / screen reader can find it.
603        let count = st.lines.len();
604        let scrollback_rect = Rect::from_min_max(rect.min, pos2(rect.right(), py));
605        a11y_node(
606            ui,
607            base,
608            "scrollback",
609            Sense::hover(),
610            scrollback_rect,
611            Semantics::new(WidgetType::Label, format!("console — {count} lines")).value(count as f64),
612        );
613
614        // Keep the smooth-scroll animation alive while it eases (injected clock via
615        // `Tick`; here we only ask egui for another frame — not a model write).
616        if st.scroll.animating() {
617            ui.ctx().request_repaint();
618        }
619
620        // ── render-lane emit: this pure view path RAN ─────────────────────────
621        #[cfg(feature = "testmatrix")]
622        facett_core::testmatrix::emit(
623            "facett-console::Console::view",
624            "ui_render",
625            // OK = the scrollback the a11y node announced matches the line count the
626            // virtualiser drew from, and the ring never exceeds its cap.
627            count == st.lines.len() && st.lines.len() <= st.max_lines,
628            &format!(
629                "lines={} max={} input_len={} scroll_max={}",
630                st.lines.len(),
631                st.max_lines,
632                st.input.chars().count(),
633                st.scroll.max,
634            ),
635        );
636
637        msgs
638    }
639}
640
641// ── FC-2 / FC-3 / FC-8 / FC-9: the canonical Elm split ────────────────────────
642impl facett_core::Elm for Console {
643    type Model = ConsoleModel;
644    type Msg = Msg;
645    type Effect = Effect;
646
647    fn title(&self) -> &str {
648        &self.state.title
649    }
650    fn state(&self) -> &ConsoleModel {
651        &self.state
652    }
653    fn update(&mut self, msg: Msg) -> Vec<Effect> {
654        Console::update(self, msg)
655    }
656    fn view(&self, ui: &mut Ui) -> Vec<Msg> {
657        Console::view(self, ui)
658    }
659}
660
661// The bridge macro writes `impl Facet for Console` from the `Elm` impl: `title`, the
662// FC-9 `ui` loop (`for m in view(ui) { update(m) }`), plus the overrides below.
663// **Form 3** (`custom_state_json`) because the console publishes a RICHER
664// `state_json` than a plain `serde(state())`: `lines` is exposed as a *count* (not
665// the raw span array) and the scroll offset/extent + `idle` as flat keys — the exact
666// observable contract callers read. All the original keys are preserved.
667facett_core::impl_facet_via_elm!(Console, custom_state_json, {
668    fn state_json(&self) -> serde_json::Value {
669        let st = self.state();
670        serde_json::json!({
671            "title": st.title,
672            "lines": st.lines.len(),
673            "prompt": st.prompt,
674            "input": st.input,
675            "cursor": format!("{:?}", st.cursor),
676            "scroll_offset": st.scroll.offset,
677            "scroll_max": st.scroll.max,
678            "scale": st.scale,
679            "filter": st.filter,
680            "idle": self.is_idle(),
681            // additive (non-breaking): the stable-id selection (FC-5) + history depth.
682            "selected": st.selected,
683            "history_len": st.history.len(),
684        })
685    }
686
687    fn selection_json(&self) -> serde_json::Value {
688        match self.state().selected {
689            Some(id) => serde_json::json!({ "line": id }),
690            None => serde_json::Value::Null,
691        }
692    }
693
694    fn caps(&self) -> FacetCaps {
695        FacetCaps::NONE.themeable().resizable().scalable().copyable().pasteable().searchable().selectable()
696    }
697
698    fn scale(&self) -> f32 {
699        self.state().scale
700    }
701    fn set_scale(&mut self, scale: f32) {
702        let _ = self.update(Msg::SetScale(scale));
703    }
704
705    fn copy(&mut self) -> Option<String> {
706        self.copy_payload().map(|p| p.as_text())
707    }
708
709    fn paste(&mut self, text: &str) -> bool {
710        self.paste_payload(&ClipPayload::Text(text.to_string()));
711        true
712    }
713
714    fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
715        Some(self)
716    }
717});
718
719#[cfg(test)]
720mod tests;