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, pos2, vec2};
12use facett_core::scroll_engine::SmoothScroll;
13use facett_core::{Facet, FacetCaps, theme};
14use serde::{Deserialize, Serialize};
15
16/// Cursor shape (TYPE-2).
17#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
18pub enum Cursor {
19    Block,
20    Beam,
21    Underline,
22}
23
24/// A coloured run of text within a console line, with a palette-role colour.
25#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
26pub struct Span {
27    pub text: String,
28    /// Palette role index (see [`AnsiColor`]); `None` = default fg.
29    pub color: Option<AnsiColor>,
30}
31
32/// The 8 ANSI colours, mapped to **palette roles** (not raw RGB) so the terminal
33/// follows the theme.
34#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
35pub enum AnsiColor {
36    Black,
37    Red,
38    Green,
39    Yellow,
40    Blue,
41    Magenta,
42    Cyan,
43    White,
44}
45
46impl AnsiColor {
47    /// Map an SGR foreground code (30–37, 90–97) to a colour.
48    fn from_sgr(code: u8) -> Option<AnsiColor> {
49        Some(match code % 10 {
50            0 => AnsiColor::Black,
51            1 => AnsiColor::Red,
52            2 => AnsiColor::Green,
53            3 => AnsiColor::Yellow,
54            4 => AnsiColor::Blue,
55            5 => AnsiColor::Magenta,
56            6 => AnsiColor::Cyan,
57            7 => AnsiColor::White,
58            _ => return None,
59        })
60    }
61
62    /// Resolve to a [`Theme`](facett_core::Theme) colour — palette roles, never raw
63    /// literals (COH-1).
64    fn to_color(self, th: &facett_core::Theme) -> egui::Color32 {
65        match self {
66            AnsiColor::Black => th.text_dim,
67            AnsiColor::Red => th.accent, // error-ish; themes pick the accent
68            AnsiColor::Green => th.point,
69            AnsiColor::Yellow => th.glow,
70            AnsiColor::Blue => th.node_stroke,
71            AnsiColor::Magenta => th.panel_stroke,
72            AnsiColor::Cyan => th.accent,
73            AnsiColor::White => th.text,
74        }
75    }
76}
77
78/// Parse a line containing ANSI SGR colour escapes into coloured [`Span`]s. Only
79/// the foreground-colour subset is handled (others are stripped); enough to make
80/// CLI output match the palette.
81pub fn parse_ansi(line: &str) -> Vec<Span> {
82    let mut spans = Vec::new();
83    let mut cur = String::new();
84    let mut color: Option<AnsiColor> = None;
85    let bytes = line.as_bytes();
86    let mut i = 0;
87    while i < bytes.len() {
88        if bytes[i] == 0x1b && i + 1 < bytes.len() && bytes[i + 1] == b'[' {
89            // Flush the current run.
90            if !cur.is_empty() {
91                spans.push(Span { text: std::mem::take(&mut cur), color });
92            }
93            // Read until 'm'.
94            let mut j = i + 2;
95            let mut num = String::new();
96            while j < bytes.len() && bytes[j] != b'm' {
97                num.push(bytes[j] as char);
98                j += 1;
99            }
100            // Apply the (last) SGR code.
101            if let Some(code) = num.split(';').next_back().and_then(|s| s.parse::<u8>().ok()) {
102                if code == 0 {
103                    color = None;
104                } else if (30..=37).contains(&code) || (90..=97).contains(&code) {
105                    color = AnsiColor::from_sgr(code);
106                }
107            }
108            i = j + 1;
109        } else {
110            cur.push(bytes[i] as char);
111            i += 1;
112        }
113    }
114    if !cur.is_empty() || spans.is_empty() {
115        spans.push(Span { text: cur, color });
116    }
117    spans
118}
119
120/// The console / shell component.
121#[derive(Clone, Debug, Serialize, Deserialize)]
122pub struct Console {
123    title: String,
124    /// Scrollback lines (parsed into coloured spans).
125    lines: Vec<Vec<Span>>,
126    /// Max scrollback (oldest dropped).
127    max_lines: usize,
128    /// The prompt + the in-progress input line.
129    prompt: String,
130    input: String,
131    cursor: Cursor,
132    /// Smooth scrollback offset (CygnusEd engine; deterministic via `advance`).
133    scroll: SmoothScroll,
134    scale: f32,
135    /// Free-text find filter (searchable).
136    filter: String,
137}
138
139impl Console {
140    pub fn new(title: impl Into<String>) -> Self {
141        Self {
142            title: title.into(),
143            lines: Vec::new(),
144            max_lines: 5000,
145            prompt: "$ ".into(),
146            input: String::new(),
147            cursor: Cursor::Block,
148            scroll: SmoothScroll::default(),
149            scale: 1.0,
150            filter: String::new(),
151        }
152    }
153
154    pub fn with_prompt(mut self, p: impl Into<String>) -> Self {
155        self.prompt = p.into();
156        self
157    }
158    pub fn with_cursor(mut self, c: Cursor) -> Self {
159        self.cursor = c;
160        self
161    }
162
163    /// Append a raw line (ANSI-parsed), dropping the oldest past `max_lines`.
164    pub fn push_line(&mut self, raw: impl AsRef<str>) {
165        self.lines.push(parse_ansi(raw.as_ref()));
166        if self.lines.len() > self.max_lines {
167            let drop = self.lines.len() - self.max_lines;
168            self.lines.drain(0..drop);
169        }
170        // Stick to the bottom on new output.
171        self.scroll.scroll_to(self.scroll.max);
172    }
173
174    pub fn set_input(&mut self, s: impl Into<String>) {
175        self.input = s.into();
176    }
177    pub fn line_count(&self) -> usize {
178        self.lines.len()
179    }
180
181    /// Advance the scrollback animation (the injected clock; deterministic).
182    pub fn advance(&mut self, dt: f32) {
183        self.scroll.advance(dt);
184    }
185
186    /// The plain-text scrollback (for copy/search).
187    pub fn plain_text(&self) -> String {
188        self.lines
189            .iter()
190            .map(|spans| spans.iter().map(|s| s.text.as_str()).collect::<String>())
191            .collect::<Vec<_>>()
192            .join("\n")
193    }
194}
195
196impl Facet for Console {
197    fn title(&self) -> &str {
198        &self.title
199    }
200
201    fn ui(&mut self, ui: &mut Ui) {
202        let th = theme(ui);
203        let cell_h = 16.0 * self.scale;
204        let font = FontId::monospace(13.0 * self.scale);
205
206        // Keep the scroll extent in sync with the content.
207        let total_h = self.lines.len() as f32 * cell_h;
208        let (rect, _) = ui.allocate_exact_size(vec2(ui.available_width(), ui.available_height().max(cell_h * 3.0)), Sense::hover());
209        let view_h = rect.height();
210        self.scroll.set_max((total_h - view_h).max(0.0));
211
212        let painter = ui.painter_at(rect);
213        painter.rect_filled(rect, 0.0, th.bg);
214
215        // Render only visible lines (virtualised), at the fractional smooth offset.
216        let (first, frac) = self.scroll.first_row_and_frac(cell_h);
217        let visible = (view_h / cell_h).ceil() as usize + 1;
218        for vi in 0..visible {
219            let li = first + vi;
220            if li >= self.lines.len() {
221                break;
222            }
223            let y = rect.top() + vi as f32 * cell_h - frac;
224            let mut x = rect.left() + 4.0;
225            for span in &self.lines[li] {
226                let color = span.color.map(|c| c.to_color(&th)).unwrap_or(th.text);
227                let g = painter.layout_no_wrap(span.text.clone(), font.clone(), color);
228                let w = g.size().x;
229                painter.galley(pos2(x, y), g, color);
230                x += w;
231            }
232        }
233
234        // The prompt + input on the last visible row + the cursor.
235        let py = rect.bottom() - cell_h;
236        let prompt_text = format!("{}{}", self.prompt, self.input);
237        let pg = painter.layout_no_wrap(prompt_text.clone(), font.clone(), th.accent);
238        painter.galley(pos2(rect.left() + 4.0, py), pg, th.accent);
239        // Cursor glyph after the input.
240        let cw = font.size * 0.6;
241        let cx = rect.left() + 4.0 + prompt_text.chars().count() as f32 * cw;
242        let crect = Rect::from_min_size(pos2(cx, py), vec2(cw, cell_h));
243        match self.cursor {
244            Cursor::Block => {
245                painter.rect_filled(crect, 0.0, th.accent.linear_multiply(0.5));
246            }
247            Cursor::Beam => {
248                painter.line_segment([crect.left_top(), crect.left_bottom()], Stroke::new(2.0, th.accent));
249            }
250            Cursor::Underline => {
251                painter.line_segment([crect.left_bottom(), crect.right_bottom()], Stroke::new(2.0, th.accent));
252            }
253        }
254        let _ = Align2::LEFT_TOP;
255    }
256
257    fn state_json(&self) -> serde_json::Value {
258        serde_json::json!({
259            "title": self.title,
260            "lines": self.lines.len(),
261            "prompt": self.prompt,
262            "input": self.input,
263            "cursor": format!("{:?}", self.cursor),
264            "scroll_offset": self.scroll.offset,
265            "scroll_max": self.scroll.max,
266            "scale": self.scale,
267            "filter": self.filter,
268        })
269    }
270
271    fn caps(&self) -> FacetCaps {
272        FacetCaps::NONE.themeable().resizable().scalable().copyable().searchable()
273    }
274
275    fn scale(&self) -> f32 {
276        self.scale
277    }
278    fn set_scale(&mut self, scale: f32) {
279        self.scale = scale.clamp(0.25, 4.0);
280    }
281
282    fn copy(&mut self) -> Option<String> {
283        let t = self.plain_text();
284        if t.is_empty() { None } else { Some(t) }
285    }
286}
287
288#[cfg(test)]
289mod tests;