1use 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#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
19pub enum Cursor {
20 Block,
21 Beam,
22 Underline,
23}
24
25#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
27pub struct Span {
28 pub text: String,
29 pub color: Option<AnsiColor>,
31}
32
33#[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 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 fn to_color(self, th: &facett_core::Theme) -> egui::Color32 {
66 match self {
67 AnsiColor::Black => th.text_dim,
68 AnsiColor::Red => th.accent, 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
79pub 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 if !cur.is_empty() {
92 spans.push(Span { text: std::mem::take(&mut cur), color });
93 }
94 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 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#[derive(Clone, Debug, Serialize, Deserialize)]
123pub struct Console {
124 title: String,
125 lines: Vec<Vec<Span>>,
127 max_lines: usize,
129 prompt: String,
131 input: String,
132 cursor: Cursor,
133 scroll: SmoothScroll,
135 scale: f32,
136 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 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 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 pub fn is_idle(&self) -> bool {
187 true
188 }
189
190 pub fn advance(&mut self, dt: f32) {
192 self.scroll.advance(dt);
193 }
194
195 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
205impl 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 matches!(k, ClipKind::Text | ClipKind::Rows | ClipKind::DataColumns)
221 }
222
223 fn paste_payload(&mut self, p: &ClipPayload) {
224 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 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 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 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 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 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 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 #[cfg(feature = "testmatrix")]
323 facett_core::testmatrix::emit(
324 "facett-console::Console::ui",
325 "ui_render",
326 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;