1use egui::{Align2, FontId, Rect, Sense, Stroke, Ui, WidgetType, pos2, vec2};
12use facett_core::scroll_engine::SmoothScroll;
13use facett_core::{Facet, FacetCaps, Semantics, a11y_node, stable_id, theme};
14use serde::{Deserialize, Serialize};
15
16#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
18pub enum Cursor {
19 Block,
20 Beam,
21 Underline,
22}
23
24#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
26pub struct Span {
27 pub text: String,
28 pub color: Option<AnsiColor>,
30}
31
32#[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 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 fn to_color(self, th: &facett_core::Theme) -> egui::Color32 {
65 match self {
66 AnsiColor::Black => th.text_dim,
67 AnsiColor::Red => th.accent, 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
78pub 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 if !cur.is_empty() {
91 spans.push(Span { text: std::mem::take(&mut cur), color });
92 }
93 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 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#[derive(Clone, Debug, Serialize, Deserialize)]
122pub struct Console {
123 title: String,
124 lines: Vec<Vec<Span>>,
126 max_lines: usize,
128 prompt: String,
130 input: String,
131 cursor: Cursor,
132 scroll: SmoothScroll,
134 scale: f32,
135 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 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 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 pub fn is_idle(&self) -> bool {
186 true
187 }
188
189 pub fn advance(&mut self, dt: f32) {
191 self.scroll.advance(dt);
192 }
193
194 pub fn plain_text(&self) -> String {
196 self.lines
197 .iter()
198 .map(|spans| spans.iter().map(|s| s.text.as_str()).collect::<String>())
199 .collect::<Vec<_>>()
200 .join("\n")
201 }
202}
203
204impl Facet for Console {
205 fn title(&self) -> &str {
206 &self.title
207 }
208
209 fn ui(&mut self, ui: &mut Ui) {
210 let th = theme(ui);
211 let cell_h = 16.0 * self.scale;
212 let font = FontId::monospace(13.0 * self.scale);
213
214 let total_h = self.lines.len() as f32 * cell_h;
216 let (rect, _) = ui.allocate_exact_size(vec2(ui.available_width(), ui.available_height().max(cell_h * 3.0)), Sense::hover());
217 let view_h = rect.height();
218 self.scroll.set_max((total_h - view_h).max(0.0));
219
220 let painter = ui.painter_at(rect);
221 painter.rect_filled(rect, 0.0, th.bg);
222
223 let (first, frac) = self.scroll.first_row_and_frac(cell_h);
225 let visible = (view_h / cell_h).ceil() as usize + 1;
226 for vi in 0..visible {
227 let li = first + vi;
228 if li >= self.lines.len() {
229 break;
230 }
231 let y = rect.top() + vi as f32 * cell_h - frac;
232 let mut x = rect.left() + 4.0;
233 for span in &self.lines[li] {
234 let color = span.color.map(|c| c.to_color(&th)).unwrap_or(th.text);
235 let g = painter.layout_no_wrap(span.text.clone(), font.clone(), color);
236 let w = g.size().x;
237 painter.galley(pos2(x, y), g, color);
238 x += w;
239 }
240 }
241
242 let py = rect.bottom() - cell_h;
244 let prompt_text = format!("{}{}", self.prompt, self.input);
245 let pg = painter.layout_no_wrap(prompt_text.clone(), font.clone(), th.accent);
246 painter.galley(pos2(rect.left() + 4.0, py), pg, th.accent);
247 let cw = font.size * 0.6;
249 let cx = rect.left() + 4.0 + prompt_text.chars().count() as f32 * cw;
250 let crect = Rect::from_min_size(pos2(cx, py), vec2(cw, cell_h));
251 match self.cursor {
252 Cursor::Block => {
253 painter.rect_filled(crect, 0.0, th.accent.linear_multiply(0.5));
254 }
255 Cursor::Beam => {
256 painter.line_segment([crect.left_top(), crect.left_bottom()], Stroke::new(2.0, th.accent));
257 }
258 Cursor::Underline => {
259 painter.line_segment([crect.left_bottom(), crect.right_bottom()], Stroke::new(2.0, th.accent));
260 }
261 }
262 let _ = Align2::LEFT_TOP;
263
264 let base = ui.id().with("console");
274 let input_rect = Rect::from_min_size(pos2(rect.left(), py), vec2(rect.width(), cell_h));
275 let input_id = stable_id(base, "input");
276 let input_resp = ui.interact(input_rect, input_id, Sense::click());
277 let typed = self.input.clone();
278 input_resp.widget_info(|| egui::WidgetInfo::text_edit(true, "", &typed, "console input"));
279
280 let count = self.lines.len();
284 let scrollback_rect = Rect::from_min_max(rect.min, pos2(rect.right(), py));
285 a11y_node(
286 ui,
287 base,
288 "scrollback",
289 Sense::hover(),
290 scrollback_rect,
291 Semantics::new(WidgetType::Label, format!("console — {count} lines")).value(count as f64),
292 );
293 }
294
295 fn state_json(&self) -> serde_json::Value {
296 serde_json::json!({
297 "title": self.title,
298 "lines": self.lines.len(),
299 "prompt": self.prompt,
300 "input": self.input,
301 "cursor": format!("{:?}", self.cursor),
302 "scroll_offset": self.scroll.offset,
303 "scroll_max": self.scroll.max,
304 "scale": self.scale,
305 "filter": self.filter,
306 "idle": self.is_idle(),
307 })
308 }
309
310 fn caps(&self) -> FacetCaps {
311 FacetCaps::NONE.themeable().resizable().scalable().copyable().searchable()
312 }
313
314 fn scale(&self) -> f32 {
315 self.scale
316 }
317 fn set_scale(&mut self, scale: f32) {
318 self.scale = scale.clamp(0.25, 4.0);
319 }
320
321 fn copy(&mut self) -> Option<String> {
322 let t = self.plain_text();
323 if t.is_empty() { None } else { Some(t) }
324 }
325}
326
327#[cfg(test)]
328mod tests;