Skip to main content

facett_console/
lib.rs

1//! **facett-console** (§8 TYPE-2) — a themed terminal/shell component: a fixed
2//! **monospace cell grid**, a block/beam **cursor**, optional **ANSI** colour
3//! mapped to the palette, and **scrollback** on the CygnusEd smooth-scroll engine
4//! (§11). A [`Facet`]: themeable · resizable · scalable · copyable · searchable.
5//!
6//! Lines are appended by the host (a process' stdout, a REPL); the console owns
7//! the scrollback ring + the smooth-scroll offset (deterministic under an
8//! injected clock via `advance`). ANSI SGR colour codes (30–37/90–97) map onto
9//! the active [`Theme`](facett_core::Theme) so the terminal matches the app.
10
11use egui::{Align2, FontId, Rect, Sense, Stroke, Ui, WidgetType, pos2, vec2};
12use facett_core::clip::{ClipKind, ClipPayload, CopySource, PasteTarget};
13use facett_core::scroll_engine::SmoothScroll;
14use facett_core::{Facet, FacetCaps, Semantics, a11y_node, stable_id, theme};
15use serde::{Deserialize, Serialize};
16
17/// Cursor shape (TYPE-2).
18#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
19pub enum Cursor {
20    Block,
21    Beam,
22    Underline,
23}
24
25/// A coloured run of text within a console line, with a palette-role colour.
26#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
27pub struct Span {
28    pub text: String,
29    /// Palette role index (see [`AnsiColor`]); `None` = default fg.
30    pub color: Option<AnsiColor>,
31}
32
33/// The 8 ANSI colours, mapped to **palette roles** (not raw RGB) so the terminal
34/// follows the theme.
35#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
36pub enum AnsiColor {
37    Black,
38    Red,
39    Green,
40    Yellow,
41    Blue,
42    Magenta,
43    Cyan,
44    White,
45}
46
47impl AnsiColor {
48    /// Map an SGR foreground code (30–37, 90–97) to a colour.
49    fn from_sgr(code: u8) -> Option<AnsiColor> {
50        Some(match code % 10 {
51            0 => AnsiColor::Black,
52            1 => AnsiColor::Red,
53            2 => AnsiColor::Green,
54            3 => AnsiColor::Yellow,
55            4 => AnsiColor::Blue,
56            5 => AnsiColor::Magenta,
57            6 => AnsiColor::Cyan,
58            7 => AnsiColor::White,
59            _ => return None,
60        })
61    }
62
63    /// Resolve to a [`Theme`](facett_core::Theme) colour — palette roles, never raw
64    /// literals (COH-1).
65    fn to_color(self, th: &facett_core::Theme) -> egui::Color32 {
66        match self {
67            AnsiColor::Black => th.text_dim,
68            AnsiColor::Red => th.accent, // error-ish; themes pick the accent
69            AnsiColor::Green => th.point,
70            AnsiColor::Yellow => th.glow,
71            AnsiColor::Blue => th.node_stroke,
72            AnsiColor::Magenta => th.panel_stroke,
73            AnsiColor::Cyan => th.accent,
74            AnsiColor::White => th.text,
75        }
76    }
77}
78
79/// Parse a line containing ANSI SGR colour escapes into coloured [`Span`]s. Only
80/// the foreground-colour subset is handled (others are stripped); enough to make
81/// CLI output match the palette.
82pub fn parse_ansi(line: &str) -> Vec<Span> {
83    let mut spans = Vec::new();
84    let mut cur = String::new();
85    let mut color: Option<AnsiColor> = None;
86    let bytes = line.as_bytes();
87    let mut i = 0;
88    while i < bytes.len() {
89        if bytes[i] == 0x1b && i + 1 < bytes.len() && bytes[i + 1] == b'[' {
90            // Flush the current run.
91            if !cur.is_empty() {
92                spans.push(Span { text: std::mem::take(&mut cur), color });
93            }
94            // Read until 'm'.
95            let mut j = i + 2;
96            let mut num = String::new();
97            while j < bytes.len() && bytes[j] != b'm' {
98                num.push(bytes[j] as char);
99                j += 1;
100            }
101            // Apply the (last) SGR code.
102            if let Some(code) = num.split(';').next_back().and_then(|s| s.parse::<u8>().ok()) {
103                if code == 0 {
104                    color = None;
105                } else if (30..=37).contains(&code) || (90..=97).contains(&code) {
106                    color = AnsiColor::from_sgr(code);
107                }
108            }
109            i = j + 1;
110        } else {
111            cur.push(bytes[i] as char);
112            i += 1;
113        }
114    }
115    if !cur.is_empty() || spans.is_empty() {
116        spans.push(Span { text: cur, color });
117    }
118    spans
119}
120
121/// The console / shell component.
122#[derive(Clone, Debug, Serialize, Deserialize)]
123pub struct Console {
124    title: String,
125    /// Scrollback lines (parsed into coloured spans).
126    lines: Vec<Vec<Span>>,
127    /// Max scrollback (oldest dropped).
128    max_lines: usize,
129    /// The prompt + the in-progress input line.
130    prompt: String,
131    input: String,
132    cursor: Cursor,
133    /// Smooth scrollback offset (CygnusEd engine; deterministic via `advance`).
134    scroll: SmoothScroll,
135    scale: f32,
136    /// Free-text find filter (searchable).
137    filter: String,
138}
139
140impl Console {
141    pub fn new(title: impl Into<String>) -> Self {
142        Self {
143            title: title.into(),
144            lines: Vec::new(),
145            max_lines: 5000,
146            prompt: "$ ".into(),
147            input: String::new(),
148            cursor: Cursor::Block,
149            scroll: SmoothScroll::default(),
150            scale: 1.0,
151            filter: String::new(),
152        }
153    }
154
155    pub fn with_prompt(mut self, p: impl Into<String>) -> Self {
156        self.prompt = p.into();
157        self
158    }
159    pub fn with_cursor(mut self, c: Cursor) -> Self {
160        self.cursor = c;
161        self
162    }
163
164    /// Append a raw line (ANSI-parsed), dropping the oldest past `max_lines`.
165    pub fn push_line(&mut self, raw: impl AsRef<str>) {
166        self.lines.push(parse_ansi(raw.as_ref()));
167        if self.lines.len() > self.max_lines {
168            let drop = self.lines.len() - self.max_lines;
169            self.lines.drain(0..drop);
170        }
171        // Stick to the bottom on new output.
172        self.scroll.scroll_to(self.scroll.max);
173    }
174
175    pub fn set_input(&mut self, s: impl Into<String>) {
176        self.input = s.into();
177    }
178    pub fn line_count(&self) -> usize {
179        self.lines.len()
180    }
181
182    /// Whether the console is idle (FC-8). The console is host-pushed and
183    /// synchronous — lines arrive via [`push_line`](Self::push_line) on the host's
184    /// thread and there is no internal async work — so it is always idle from the
185    /// component's own perspective.
186    pub fn is_idle(&self) -> bool {
187        true
188    }
189
190    /// Advance the scrollback animation (the injected clock; deterministic).
191    pub fn advance(&mut self, dt: f32) {
192        self.scroll.advance(dt);
193    }
194
195    /// The plain-text scrollback (for copy/search).
196    pub fn plain_text(&self) -> String {
197        self.lines
198            .iter()
199            .map(|spans| spans.iter().map(|s| s.text.as_str()).collect::<String>())
200            .collect::<Vec<_>>()
201            .join("\n")
202    }
203}
204
205// ── typed copy/paste (§16) — scrollback out as Text, paste into the input line ─
206impl CopySource for Console {
207    fn copy_kinds(&self) -> &[ClipKind] {
208        &[ClipKind::Text]
209    }
210
211    fn copy_payload(&self) -> Option<ClipPayload> {
212        let t = self.plain_text();
213        if t.is_empty() { None } else { Some(ClipPayload::Text(t)) }
214    }
215}
216
217impl PasteTarget for Console {
218    fn accepts(&self, k: ClipKind) -> bool {
219        // The input prompt ingests any payload's text view (universal fallback).
220        matches!(k, ClipKind::Text | ClipKind::Rows | ClipKind::DataColumns)
221    }
222
223    fn paste_payload(&mut self, p: &ClipPayload) {
224        // Append the text view at the input cursor (shell-prompt paste), collapsing
225        // newlines to spaces so a multi-line paste stays a single command line.
226        let text = p.as_text().replace('\n', " ");
227        self.input.push_str(&text);
228    }
229}
230
231impl Facet for Console {
232    fn title(&self) -> &str {
233        &self.title
234    }
235
236    fn ui(&mut self, ui: &mut Ui) {
237        let th = theme(ui);
238        let cell_h = 16.0 * self.scale;
239        let font = FontId::monospace(13.0 * self.scale);
240
241        // Keep the scroll extent in sync with the content.
242        let total_h = self.lines.len() as f32 * cell_h;
243        let (rect, _) = ui.allocate_exact_size(vec2(ui.available_width(), ui.available_height().max(cell_h * 3.0)), Sense::hover());
244        let view_h = rect.height();
245        self.scroll.set_max((total_h - view_h).max(0.0));
246
247        let painter = ui.painter_at(rect);
248        painter.rect_filled(rect, 0.0, th.bg);
249
250        // Render only visible lines (virtualised), at the fractional smooth offset.
251        let (first, frac) = self.scroll.first_row_and_frac(cell_h);
252        let visible = (view_h / cell_h).ceil() as usize + 1;
253        for vi in 0..visible {
254            let li = first + vi;
255            if li >= self.lines.len() {
256                break;
257            }
258            let y = rect.top() + vi as f32 * cell_h - frac;
259            let mut x = rect.left() + 4.0;
260            for span in &self.lines[li] {
261                let color = span.color.map(|c| c.to_color(&th)).unwrap_or(th.text);
262                let g = painter.layout_no_wrap(span.text.clone(), font.clone(), color);
263                let w = g.size().x;
264                painter.galley(pos2(x, y), g, color);
265                x += w;
266            }
267        }
268
269        // The prompt + input on the last visible row + the cursor.
270        let py = rect.bottom() - cell_h;
271        let prompt_text = format!("{}{}", self.prompt, self.input);
272        let pg = painter.layout_no_wrap(prompt_text.clone(), font.clone(), th.accent);
273        painter.galley(pos2(rect.left() + 4.0, py), pg, th.accent);
274        // Cursor glyph after the input.
275        let cw = font.size * 0.6;
276        let cx = rect.left() + 4.0 + prompt_text.chars().count() as f32 * cw;
277        let crect = Rect::from_min_size(pos2(cx, py), vec2(cw, cell_h));
278        match self.cursor {
279            Cursor::Block => {
280                painter.rect_filled(crect, 0.0, th.accent.linear_multiply(0.5));
281            }
282            Cursor::Beam => {
283                painter.line_segment([crect.left_top(), crect.left_bottom()], Stroke::new(2.0, th.accent));
284            }
285            Cursor::Underline => {
286                painter.line_segment([crect.left_bottom(), crect.right_bottom()], Stroke::new(2.0, th.accent));
287            }
288        }
289        let _ = Align2::LEFT_TOP;
290
291        // FC-4: the whole console (input line + cursor + scrollback) was painted on
292        // a single `Sense::hover()` alloc above — nothing rode into the AccessKit
293        // tree. Surface the two value-bearing surfaces as real accesskit nodes.
294
295        // (a) The input line → a TextInput node whose accesskit *value* is the
296        // current `self.input` string, so `get_by_role(Role::TextInput)` / a value
297        // query reads back the typed text. `WidgetInfo::text_edit` carries the
298        // editable string as the accesskit value (a Semantics(TextEdit) would map
299        // the label, not the typed text — so we set widget_info directly).
300        let base = ui.id().with("console");
301        let input_rect = Rect::from_min_size(pos2(rect.left(), py), vec2(rect.width(), cell_h));
302        let input_id = stable_id(base, "input");
303        let input_resp = ui.interact(input_rect, input_id, Sense::click());
304        let typed = self.input.clone();
305        input_resp.widget_info(|| egui::WidgetInfo::text_edit(true, "", &typed, "console input"));
306
307        // (b) The scrollback region → a labelled Label node carrying the line count
308        // as its numeric value, so a robot driver / screen reader can find the
309        // scrollback and read how many lines it holds.
310        let count = self.lines.len();
311        let scrollback_rect = Rect::from_min_max(rect.min, pos2(rect.right(), py));
312        a11y_node(
313            ui,
314            base,
315            "scrollback",
316            Sense::hover(),
317            scrollback_rect,
318            Semantics::new(WidgetType::Label, format!("console — {count} lines")).value(count as f64),
319        );
320
321        // ── render-lane emit: this Facet::ui path RAN ─────────────────────────
322        #[cfg(feature = "testmatrix")]
323        facett_core::testmatrix::emit(
324            "facett-console::Console::ui",
325            "ui_render",
326            // OK = the scrollback the a11y node announced matches the line count the
327            // virtualiser drew from, and the ring never exceeds its cap.
328            count == self.lines.len() && self.lines.len() <= self.max_lines,
329            &format!(
330                "lines={} max={} input_len={} scroll_max={}",
331                self.lines.len(),
332                self.max_lines,
333                self.input.chars().count(),
334                self.scroll.max,
335            ),
336        );
337    }
338
339    fn state_json(&self) -> serde_json::Value {
340        serde_json::json!({
341            "title": self.title,
342            "lines": self.lines.len(),
343            "prompt": self.prompt,
344            "input": self.input,
345            "cursor": format!("{:?}", self.cursor),
346            "scroll_offset": self.scroll.offset,
347            "scroll_max": self.scroll.max,
348            "scale": self.scale,
349            "filter": self.filter,
350            "idle": self.is_idle(),
351        })
352    }
353
354    fn caps(&self) -> FacetCaps {
355        FacetCaps::NONE.themeable().resizable().scalable().copyable().pasteable().searchable()
356    }
357
358    fn scale(&self) -> f32 {
359        self.scale
360    }
361    fn set_scale(&mut self, scale: f32) {
362        self.scale = scale.clamp(0.25, 4.0);
363    }
364
365    fn copy(&mut self) -> Option<String> {
366        self.copy_payload().map(|p| p.as_text())
367    }
368
369    fn paste(&mut self, text: &str) -> bool {
370        self.paste_payload(&ClipPayload::Text(text.to_string()));
371        true
372    }
373}
374
375#[cfg(test)]
376mod tests;