use egui::{Align2, FontId, Rect, Sense, Stroke, Ui, pos2, vec2};
use facett_core::scroll_engine::SmoothScroll;
use facett_core::{Facet, FacetCaps, theme};
use serde::{Deserialize, Serialize};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum Cursor {
Block,
Beam,
Underline,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Span {
pub text: String,
pub color: Option<AnsiColor>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum AnsiColor {
Black,
Red,
Green,
Yellow,
Blue,
Magenta,
Cyan,
White,
}
impl AnsiColor {
fn from_sgr(code: u8) -> Option<AnsiColor> {
Some(match code % 10 {
0 => AnsiColor::Black,
1 => AnsiColor::Red,
2 => AnsiColor::Green,
3 => AnsiColor::Yellow,
4 => AnsiColor::Blue,
5 => AnsiColor::Magenta,
6 => AnsiColor::Cyan,
7 => AnsiColor::White,
_ => return None,
})
}
fn to_color(self, th: &facett_core::Theme) -> egui::Color32 {
match self {
AnsiColor::Black => th.text_dim,
AnsiColor::Red => th.accent, AnsiColor::Green => th.point,
AnsiColor::Yellow => th.glow,
AnsiColor::Blue => th.node_stroke,
AnsiColor::Magenta => th.panel_stroke,
AnsiColor::Cyan => th.accent,
AnsiColor::White => th.text,
}
}
}
pub fn parse_ansi(line: &str) -> Vec<Span> {
let mut spans = Vec::new();
let mut cur = String::new();
let mut color: Option<AnsiColor> = None;
let bytes = line.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == 0x1b && i + 1 < bytes.len() && bytes[i + 1] == b'[' {
if !cur.is_empty() {
spans.push(Span { text: std::mem::take(&mut cur), color });
}
let mut j = i + 2;
let mut num = String::new();
while j < bytes.len() && bytes[j] != b'm' {
num.push(bytes[j] as char);
j += 1;
}
if let Some(code) = num.split(';').next_back().and_then(|s| s.parse::<u8>().ok()) {
if code == 0 {
color = None;
} else if (30..=37).contains(&code) || (90..=97).contains(&code) {
color = AnsiColor::from_sgr(code);
}
}
i = j + 1;
} else {
cur.push(bytes[i] as char);
i += 1;
}
}
if !cur.is_empty() || spans.is_empty() {
spans.push(Span { text: cur, color });
}
spans
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Console {
title: String,
lines: Vec<Vec<Span>>,
max_lines: usize,
prompt: String,
input: String,
cursor: Cursor,
scroll: SmoothScroll,
scale: f32,
filter: String,
}
impl Console {
pub fn new(title: impl Into<String>) -> Self {
Self {
title: title.into(),
lines: Vec::new(),
max_lines: 5000,
prompt: "$ ".into(),
input: String::new(),
cursor: Cursor::Block,
scroll: SmoothScroll::default(),
scale: 1.0,
filter: String::new(),
}
}
pub fn with_prompt(mut self, p: impl Into<String>) -> Self {
self.prompt = p.into();
self
}
pub fn with_cursor(mut self, c: Cursor) -> Self {
self.cursor = c;
self
}
pub fn push_line(&mut self, raw: impl AsRef<str>) {
self.lines.push(parse_ansi(raw.as_ref()));
if self.lines.len() > self.max_lines {
let drop = self.lines.len() - self.max_lines;
self.lines.drain(0..drop);
}
self.scroll.scroll_to(self.scroll.max);
}
pub fn set_input(&mut self, s: impl Into<String>) {
self.input = s.into();
}
pub fn line_count(&self) -> usize {
self.lines.len()
}
pub fn advance(&mut self, dt: f32) {
self.scroll.advance(dt);
}
pub fn plain_text(&self) -> String {
self.lines
.iter()
.map(|spans| spans.iter().map(|s| s.text.as_str()).collect::<String>())
.collect::<Vec<_>>()
.join("\n")
}
}
impl Facet for Console {
fn title(&self) -> &str {
&self.title
}
fn ui(&mut self, ui: &mut Ui) {
let th = theme(ui);
let cell_h = 16.0 * self.scale;
let font = FontId::monospace(13.0 * self.scale);
let total_h = self.lines.len() as f32 * cell_h;
let (rect, _) = ui.allocate_exact_size(vec2(ui.available_width(), ui.available_height().max(cell_h * 3.0)), Sense::hover());
let view_h = rect.height();
self.scroll.set_max((total_h - view_h).max(0.0));
let painter = ui.painter_at(rect);
painter.rect_filled(rect, 0.0, th.bg);
let (first, frac) = self.scroll.first_row_and_frac(cell_h);
let visible = (view_h / cell_h).ceil() as usize + 1;
for vi in 0..visible {
let li = first + vi;
if li >= self.lines.len() {
break;
}
let y = rect.top() + vi as f32 * cell_h - frac;
let mut x = rect.left() + 4.0;
for span in &self.lines[li] {
let color = span.color.map(|c| c.to_color(&th)).unwrap_or(th.text);
let g = painter.layout_no_wrap(span.text.clone(), font.clone(), color);
let w = g.size().x;
painter.galley(pos2(x, y), g, color);
x += w;
}
}
let py = rect.bottom() - cell_h;
let prompt_text = format!("{}{}", self.prompt, self.input);
let pg = painter.layout_no_wrap(prompt_text.clone(), font.clone(), th.accent);
painter.galley(pos2(rect.left() + 4.0, py), pg, th.accent);
let cw = font.size * 0.6;
let cx = rect.left() + 4.0 + prompt_text.chars().count() as f32 * cw;
let crect = Rect::from_min_size(pos2(cx, py), vec2(cw, cell_h));
match self.cursor {
Cursor::Block => {
painter.rect_filled(crect, 0.0, th.accent.linear_multiply(0.5));
}
Cursor::Beam => {
painter.line_segment([crect.left_top(), crect.left_bottom()], Stroke::new(2.0, th.accent));
}
Cursor::Underline => {
painter.line_segment([crect.left_bottom(), crect.right_bottom()], Stroke::new(2.0, th.accent));
}
}
let _ = Align2::LEFT_TOP;
}
fn state_json(&self) -> serde_json::Value {
serde_json::json!({
"title": self.title,
"lines": self.lines.len(),
"prompt": self.prompt,
"input": self.input,
"cursor": format!("{:?}", self.cursor),
"scroll_offset": self.scroll.offset,
"scroll_max": self.scroll.max,
"scale": self.scale,
"filter": self.filter,
})
}
fn caps(&self) -> FacetCaps {
FacetCaps::NONE.themeable().resizable().scalable().copyable().searchable()
}
fn scale(&self) -> f32 {
self.scale
}
fn set_scale(&mut self, scale: f32) {
self.scale = scale.clamp(0.25, 4.0);
}
fn copy(&mut self) -> Option<String> {
let t = self.plain_text();
if t.is_empty() { None } else { Some(t) }
}
}
#[cfg(test)]
mod tests;