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;