use egui::{FontId, Rect, Sense, Stroke, Ui, WidgetType, pos2, vec2};
use facett_core::clip::{ClipKind, ClipPayload, CopySource, PasteTarget};
use facett_core::scroll_engine::SmoothScroll;
use facett_core::{FacetCaps, Semantics, a11y_node, stable_id, theme};
use serde::{Deserialize, Serialize};
pub type LineId = u64;
#[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, Debug, PartialEq, Serialize, Deserialize)]
pub struct Line {
pub id: LineId,
pub spans: Vec<Span>,
}
impl Line {
pub fn plain(&self) -> String {
self.spans.iter().map(|s| s.text.as_str()).collect()
}
}
#[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, PartialEq)]
pub enum Msg {
PushLine(String),
SetInput(String),
InputChar(char),
Backspace,
Submit,
HistoryPrev,
HistoryNext,
SelectLine(Option<LineId>),
SetFilter(String),
ScrollBy(f32),
ScrollTo(f32),
ScrollToBottom,
SetScrollMax(f32),
Tick(f32),
SetScale(f32),
SetCursor(Cursor),
}
#[derive(Clone, Debug, PartialEq)]
pub enum Effect {
RunCommand(String),
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ConsoleModel {
pub title: String,
pub lines: Vec<Line>,
pub max_lines: usize,
pub next_id: LineId,
pub prompt: String,
pub input: String,
pub cursor: Cursor,
pub scroll: SmoothScroll,
pub scale: f32,
pub filter: String,
pub selected: Option<LineId>,
pub history: Vec<String>,
pub history_pos: Option<usize>,
}
impl ConsoleModel {
fn new(title: String) -> Self {
Self {
title,
lines: Vec::new(),
max_lines: 5000,
next_id: 0,
prompt: "$ ".into(),
input: String::new(),
cursor: Cursor::Block,
scroll: SmoothScroll::default(),
scale: 1.0,
filter: String::new(),
selected: None,
history: Vec::new(),
history_pos: None,
}
}
}
#[derive(Clone, Debug)]
pub struct Console {
state: ConsoleModel,
}
impl Console {
pub fn new(title: impl Into<String>) -> Self {
Self { state: ConsoleModel::new(title.into()) }
}
pub fn with_prompt(mut self, p: impl Into<String>) -> Self {
self.state.prompt = p.into();
self
}
pub fn with_cursor(mut self, c: Cursor) -> Self {
self.state.cursor = c;
self
}
pub fn with_max_lines(mut self, n: usize) -> Self {
self.state.max_lines = n;
self
}
pub fn state(&self) -> &ConsoleModel {
&self.state
}
pub fn update(&mut self, msg: Msg) -> Vec<Effect> {
let mut effects = Vec::new();
match msg {
Msg::PushLine(raw) => {
self.append_line(&raw);
self.state.scroll.scroll_to(self.state.scroll.max); }
Msg::SetInput(s) => self.state.input = s,
Msg::InputChar(c) => self.state.input.push(c),
Msg::Backspace => {
self.state.input.pop();
}
Msg::Submit => {
let cmd = std::mem::take(&mut self.state.input);
self.append_line(&format!("{}{}", self.state.prompt, cmd));
self.state.scroll.scroll_to(self.state.scroll.max);
self.state.history_pos = None;
if !cmd.is_empty() {
if self.state.history.last().map(String::as_str) != Some(cmd.as_str()) {
self.state.history.push(cmd.clone());
}
effects.push(Effect::RunCommand(cmd)); }
}
Msg::HistoryPrev => {
if !self.state.history.is_empty() {
let pos = match self.state.history_pos {
None => self.state.history.len() - 1,
Some(p) => p.saturating_sub(1),
};
self.state.history_pos = Some(pos);
self.state.input = self.state.history[pos].clone();
}
}
Msg::HistoryNext => match self.state.history_pos {
Some(p) if p + 1 < self.state.history.len() => {
self.state.history_pos = Some(p + 1);
self.state.input = self.state.history[p + 1].clone();
}
Some(_) => {
self.state.history_pos = None;
self.state.input.clear();
}
None => {}
},
Msg::SelectLine(sel) => match sel {
Some(id) if self.state.lines.iter().any(|l| l.id == id) => {
self.state.selected = Some(id);
}
Some(_) => {} None => self.state.selected = None,
},
Msg::SetFilter(s) => self.state.filter = s,
Msg::ScrollBy(d) => self.state.scroll.scroll_by(d),
Msg::ScrollTo(o) => self.state.scroll.scroll_to(o),
Msg::ScrollToBottom => self.state.scroll.scroll_to(self.state.scroll.max),
Msg::SetScrollMax(m) => self.state.scroll.set_max(m),
Msg::Tick(dt) => self.state.scroll.advance(dt),
Msg::SetScale(s) => self.state.scale = s.clamp(0.25, 4.0),
Msg::SetCursor(c) => self.state.cursor = c,
}
effects
}
fn append_line(&mut self, raw: &str) {
let id = self.state.next_id;
self.state.next_id += 1;
self.state.lines.push(Line { id, spans: parse_ansi(raw) });
let over = self.state.lines.len().saturating_sub(self.state.max_lines);
if over > 0 {
self.state.lines.drain(0..over);
if self.state.selected.is_some_and(|sel| !self.state.lines.iter().any(|l| l.id == sel)) {
self.state.selected = None;
}
}
}
pub fn push_line(&mut self, raw: impl AsRef<str>) {
let _ = self.update(Msg::PushLine(raw.as_ref().to_string()));
}
pub fn set_input(&mut self, s: impl Into<String>) {
let _ = self.update(Msg::SetInput(s.into()));
}
pub fn select(&mut self, id: Option<LineId>) {
let _ = self.update(Msg::SelectLine(id));
}
pub fn advance(&mut self, dt: f32) {
let _ = self.update(Msg::Tick(dt));
}
pub fn line_count(&self) -> usize {
self.state.lines.len()
}
pub fn is_idle(&self) -> bool {
true
}
pub fn plain_text(&self) -> String {
self.state.lines.iter().map(Line::plain).collect::<Vec<_>>().join("\n")
}
}
impl CopySource for Console {
fn copy_kinds(&self) -> &[ClipKind] {
&[ClipKind::Text]
}
fn copy_payload(&self) -> Option<ClipPayload> {
let t = self.plain_text();
if t.is_empty() { None } else { Some(ClipPayload::Text(t)) }
}
}
impl PasteTarget for Console {
fn accepts(&self, k: ClipKind) -> bool {
matches!(k, ClipKind::Text | ClipKind::Rows | ClipKind::DataColumns)
}
fn paste_payload(&mut self, p: &ClipPayload) {
let text = p.as_text().replace('\n', " ");
let combined = format!("{}{}", self.state.input, text);
let _ = self.update(Msg::SetInput(combined));
}
}
impl Console {
pub fn view(&self, ui: &mut Ui) -> Vec<Msg> {
let mut msgs: Vec<Msg> = Vec::new();
let st = &self.state;
let th = theme(ui);
let cell_h = 16.0 * st.scale;
let font = FontId::monospace(13.0 * st.scale);
let (rect, area_resp) = ui.allocate_exact_size(
vec2(ui.available_width(), ui.available_height().max(cell_h * 3.0)),
Sense::click_and_drag(),
);
let view_h = rect.height();
let total_h = st.lines.len() as f32 * cell_h;
let want_max = (total_h - view_h).max(0.0);
if (want_max - st.scroll.max).abs() > f32::EPSILON {
msgs.push(Msg::SetScrollMax(want_max));
}
if area_resp.hovered() {
let dy = ui.input(|i| i.smooth_scroll_delta.y);
if dy.abs() > f32::EPSILON {
msgs.push(Msg::ScrollBy(-dy));
}
}
let painter = ui.painter_at(rect);
painter.rect_filled(rect, 0.0, th.bg);
let base = ui.id().with("console");
let py = rect.bottom() - cell_h;
let (first, frac) = st.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 >= st.lines.len() {
break;
}
let line = &st.lines[li];
let y = rect.top() + vi as f32 * cell_h - frac;
let line_rect = Rect::from_min_size(pos2(rect.left(), y), vec2(rect.width(), cell_h));
if st.selected == Some(line.id) {
painter.rect_filled(line_rect, 0.0, th.accent.linear_multiply(0.18));
}
let lid = stable_id(base, format!("line-{}", line.id));
let resp = ui.interact(line_rect, lid, Sense::click());
if resp.clicked() {
msgs.push(if st.selected == Some(line.id) {
Msg::SelectLine(None)
} else {
Msg::SelectLine(Some(line.id))
});
}
let mut x = rect.left() + 4.0;
for span in &line.spans {
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 prompt_text = format!("{}{}", st.prompt, st.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 st.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 input_rect = Rect::from_min_size(pos2(rect.left(), py), vec2(rect.width(), cell_h));
let input_id = stable_id(base, "input");
let input_resp = ui.interact(input_rect, input_id, Sense::click());
if input_resp.clicked() {
input_resp.request_focus();
}
let typed = st.input.clone();
input_resp.widget_info(|| egui::WidgetInfo::text_edit(true, "", &typed, "console input"));
if input_resp.has_focus() {
let events = ui.input(|i| i.events.clone());
for ev in &events {
match ev {
egui::Event::Text(t) => {
for c in t.chars() {
msgs.push(Msg::InputChar(c));
}
}
egui::Event::Key { key, pressed: true, .. } => match key {
egui::Key::Enter => msgs.push(Msg::Submit),
egui::Key::Backspace => msgs.push(Msg::Backspace),
egui::Key::ArrowUp => msgs.push(Msg::HistoryPrev),
egui::Key::ArrowDown => msgs.push(Msg::HistoryNext),
_ => {}
},
_ => {}
}
}
}
let count = st.lines.len();
let scrollback_rect = Rect::from_min_max(rect.min, pos2(rect.right(), py));
a11y_node(
ui,
base,
"scrollback",
Sense::hover(),
scrollback_rect,
Semantics::new(WidgetType::Label, format!("console — {count} lines")).value(count as f64),
);
if st.scroll.animating() {
ui.ctx().request_repaint();
}
#[cfg(feature = "testmatrix")]
facett_core::testmatrix::emit(
"facett-console::Console::view",
"ui_render",
count == st.lines.len() && st.lines.len() <= st.max_lines,
&format!(
"lines={} max={} input_len={} scroll_max={}",
st.lines.len(),
st.max_lines,
st.input.chars().count(),
st.scroll.max,
),
);
msgs
}
}
impl facett_core::Elm for Console {
type Model = ConsoleModel;
type Msg = Msg;
type Effect = Effect;
fn title(&self) -> &str {
&self.state.title
}
fn state(&self) -> &ConsoleModel {
&self.state
}
fn update(&mut self, msg: Msg) -> Vec<Effect> {
Console::update(self, msg)
}
fn view(&self, ui: &mut Ui) -> Vec<Msg> {
Console::view(self, ui)
}
}
facett_core::impl_facet_via_elm!(Console, custom_state_json, {
fn state_json(&self) -> serde_json::Value {
let st = self.state();
serde_json::json!({
"title": st.title,
"lines": st.lines.len(),
"prompt": st.prompt,
"input": st.input,
"cursor": format!("{:?}", st.cursor),
"scroll_offset": st.scroll.offset,
"scroll_max": st.scroll.max,
"scale": st.scale,
"filter": st.filter,
"idle": self.is_idle(),
"selected": st.selected,
"history_len": st.history.len(),
})
}
fn selection_json(&self) -> serde_json::Value {
match self.state().selected {
Some(id) => serde_json::json!({ "line": id }),
None => serde_json::Value::Null,
}
}
fn caps(&self) -> FacetCaps {
FacetCaps::NONE.themeable().resizable().scalable().copyable().pasteable().searchable().selectable()
}
fn scale(&self) -> f32 {
self.state().scale
}
fn set_scale(&mut self, scale: f32) {
let _ = self.update(Msg::SetScale(scale));
}
fn copy(&mut self) -> Option<String> {
self.copy_payload().map(|p| p.as_text())
}
fn paste(&mut self, text: &str) -> bool {
self.paste_payload(&ClipPayload::Text(text.to_string()));
true
}
fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
Some(self)
}
});
#[cfg(test)]
mod tests;