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;