use std::sync::mpsc::Sender;
use std::sync::{Arc, Mutex};
use alacritty_terminal::event::{Event, EventListener};
use alacritty_terminal::grid::{Dimensions, Scroll};
use alacritty_terminal::term::cell::Flags;
use alacritty_terminal::term::{Config, Term, TermMode};
use alacritty_terminal::vte::ansi::{Color as VtColor, Processor};
use ratatui::style::{Color, Modifier};
use super::{Cursor, RenderCell, VtEngine};
type TitleSlot = Arc<Mutex<Option<String>>>;
#[derive(Clone)]
pub struct EventProxy {
tx: Sender<Vec<u8>>,
title: TitleSlot,
}
impl EventListener for EventProxy {
fn send_event(&self, event: Event) {
match event {
Event::PtyWrite(text) => {
let _ = self.tx.send(text.into_bytes());
}
Event::Title(t) => {
if let Ok(mut g) = self.title.lock() {
*g = Some(t);
}
}
Event::ResetTitle => {
if let Ok(mut g) = self.title.lock() {
*g = None;
}
}
_ => {}
}
}
}
#[derive(Clone, Copy)]
struct Dims {
cols: usize,
rows: usize,
}
impl Dimensions for Dims {
fn total_lines(&self) -> usize {
self.rows
}
fn screen_lines(&self) -> usize {
self.rows
}
fn columns(&self) -> usize {
self.cols
}
}
pub struct AlacrittyEngine {
term: Term<EventProxy>,
parser: Processor,
title: TitleSlot,
}
impl AlacrittyEngine {
pub fn new(cols: u16, rows: u16, resp_tx: Sender<Vec<u8>>) -> Self {
let dims = Dims {
cols: cols.max(1) as usize,
rows: rows.max(1) as usize,
};
let title: TitleSlot = Arc::new(Mutex::new(None));
let proxy = EventProxy {
tx: resp_tx,
title: title.clone(),
};
let term = Term::new(Config::default(), &dims, proxy);
AlacrittyEngine {
term,
parser: Processor::new(),
title,
}
}
}
impl VtEngine for AlacrittyEngine {
fn advance(&mut self, bytes: &[u8]) {
self.parser.advance(&mut self.term, bytes);
}
fn resize(&mut self, cols: u16, rows: u16) {
self.term.resize(Dims {
cols: cols.max(1) as usize,
rows: rows.max(1) as usize,
});
}
fn cursor(&self) -> Cursor {
let p = self.term.grid().cursor.point;
Cursor {
x: p.column.0 as u16,
y: p.line.0.max(0) as u16,
visible: self.term.mode().contains(TermMode::SHOW_CURSOR)
&& self.term.grid().display_offset() == 0,
}
}
fn for_each_cell(&self, f: &mut dyn FnMut(u16, u16, RenderCell)) {
for indexed in self.term.grid().display_iter() {
let row = indexed.point.line.0;
if row < 0 {
continue;
}
let cell = indexed.cell;
if cell.flags.contains(Flags::WIDE_CHAR_SPACER) {
continue;
}
f(
row as u16,
indexed.point.column.0 as u16,
RenderCell {
c: cell.c,
fg: map_color(cell.fg),
bg: map_color(cell.bg),
mods: map_flags(cell.flags),
},
);
}
}
fn detection_text(&self, n: u16) -> String {
let grid = self.term.grid();
let rows = grid.screen_lines();
let mut lines = vec![String::new(); rows];
for indexed in grid.display_iter() {
let r = indexed.point.line.0;
if r < 0 || r as usize >= rows {
continue;
}
if indexed.cell.flags.contains(Flags::WIDE_CHAR_SPACER) {
continue;
}
let c = indexed.cell.c;
lines[r as usize].push(if c == '\0' { ' ' } else { c });
}
let start = rows.saturating_sub(n as usize);
lines[start..]
.iter()
.map(|l| l.trim_end())
.collect::<Vec<_>>()
.join("\n")
}
fn visible_rows(&self) -> Vec<String> {
let grid = self.term.grid();
let rows = grid.screen_lines();
let mut lines = vec![String::new(); rows];
for indexed in grid.display_iter() {
let r = indexed.point.line.0;
if r < 0 || r as usize >= rows {
continue;
}
if indexed.cell.flags.contains(Flags::WIDE_CHAR_SPACER) {
continue;
}
let c = indexed.cell.c;
lines[r as usize].push(if c == '\0' { ' ' } else { c });
}
lines
}
fn title(&self) -> Option<String> {
self.title.lock().ok().and_then(|g| g.clone())
}
fn scroll(&mut self, delta: i32) {
if !self.term.mode().contains(TermMode::ALT_SCREEN) {
self.term.scroll_display(Scroll::Delta(delta));
}
}
fn scroll_to_top(&mut self) {
if !self.term.mode().contains(TermMode::ALT_SCREEN) {
self.term.scroll_display(Scroll::Top);
}
}
fn scroll_to_bottom(&mut self) {
self.term.scroll_display(Scroll::Bottom);
}
fn scroll_offset(&self) -> usize {
self.term.grid().display_offset()
}
fn history_len(&self) -> usize {
self.term.grid().history_size()
}
fn alt_screen(&self) -> bool {
self.term.mode().contains(TermMode::ALT_SCREEN)
}
fn mouse_report(&self) -> bool {
self.term.mode().intersects(TermMode::MOUSE_MODE)
}
fn sgr_mouse(&self) -> bool {
self.term.mode().contains(TermMode::SGR_MOUSE)
}
fn snapshot_ansi(&self) -> String {
let grid = self.term.grid();
let rows = grid.screen_lines();
let cols = grid.columns();
if rows == 0 || cols == 0 {
return String::new();
}
let default = (' ', Color::Reset, Color::Reset, Modifier::empty());
let mut cells = vec![vec![default; cols]; rows];
for indexed in grid.display_iter() {
let r = indexed.point.line.0;
let c = indexed.point.column.0;
if r < 0 || r as usize >= rows || c >= cols {
continue;
}
let cell = indexed.cell;
if cell.flags.contains(Flags::WIDE_CHAR_SPACER) {
continue;
}
let ch = if cell.c == '\0' { ' ' } else { cell.c };
cells[r as usize][c] = (
ch,
map_color(cell.fg),
map_color(cell.bg),
map_flags(cell.flags),
);
}
let last_row = match cells
.iter()
.rposition(|row| row.iter().any(|c| *c != default))
{
Some(r) => r,
None => return String::from("\x1b[2J\x1b[H"),
};
let mut out = String::from("\x1b[2J\x1b[H");
for (ri, row) in cells.iter().take(last_row + 1).enumerate() {
let last = row.iter().rposition(|c| *c != default).map_or(0, |i| i + 1);
let mut cur = (Color::Reset, Color::Reset, Modifier::empty());
for (ch, fg, bg, m) in &row[..last] {
if (*fg, *bg, *m) != cur {
out.push_str(&sgr(*fg, *bg, *m));
cur = (*fg, *bg, *m);
}
out.push(*ch);
}
out.push_str("\x1b[0m");
if ri < last_row {
out.push_str("\r\n");
}
}
out
}
}
fn sgr(fg: Color, bg: Color, m: Modifier) -> String {
let mut s = String::from("\x1b[0");
if m.contains(Modifier::BOLD) {
s.push_str(";1");
}
if m.contains(Modifier::DIM) {
s.push_str(";2");
}
if m.contains(Modifier::ITALIC) {
s.push_str(";3");
}
if m.contains(Modifier::UNDERLINED) {
s.push_str(";4");
}
if m.contains(Modifier::REVERSED) {
s.push_str(";7");
}
push_color(&mut s, fg, 38);
push_color(&mut s, bg, 48);
s.push('m');
s
}
fn push_color(s: &mut String, c: Color, base: u8) {
match c {
Color::Indexed(i) => s.push_str(&format!(";{base};5;{i}")),
Color::Rgb(r, g, b) => s.push_str(&format!(";{base};2;{r};{g};{b}")),
_ => {}
}
}
fn map_color(c: VtColor) -> Color {
match c {
VtColor::Spec(rgb) => Color::Rgb(rgb.r, rgb.g, rgb.b),
VtColor::Indexed(i) => Color::Indexed(i),
VtColor::Named(n) => {
let idx = n as usize;
if idx < 16 {
Color::Indexed(idx as u8)
} else {
Color::Reset
}
}
}
}
fn map_flags(fl: Flags) -> Modifier {
let mut m = Modifier::empty();
if fl.contains(Flags::BOLD) {
m |= Modifier::BOLD;
}
if fl.contains(Flags::ITALIC) {
m |= Modifier::ITALIC;
}
if fl.contains(Flags::UNDERLINE) {
m |= Modifier::UNDERLINED;
}
if fl.contains(Flags::DIM) {
m |= Modifier::DIM;
}
if fl.contains(Flags::INVERSE) {
m |= Modifier::REVERSED;
}
if fl.contains(Flags::HIDDEN) {
m |= Modifier::HIDDEN;
}
if fl.contains(Flags::STRIKEOUT) {
m |= Modifier::CROSSED_OUT;
}
m
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::mpsc::channel;
fn feed_lines(e: &mut AlacrittyEngine, n: usize) {
for i in 0..n {
e.advance(format!("line{i}\r\n").as_bytes());
}
}
#[test]
fn scrollback_offset_moves_clamps_and_resets() {
let (tx, _rx) = channel();
let mut e = AlacrittyEngine::new(20, 5, tx); feed_lines(&mut e, 50);
assert_eq!(e.scroll_offset(), 0, "starts live at the bottom");
e.scroll(10);
assert_eq!(e.scroll_offset(), 10, "scrolls up 10 lines into history");
assert!(!e.cursor().visible, "cursor hidden while scrolled back");
e.scroll_to_top();
let top = e.scroll_offset();
assert!(top > 10, "top of history is well above the live bottom");
e.scroll(1000);
assert_eq!(
e.scroll_offset(),
top,
"cannot scroll past the top of history"
);
e.scroll(-1000);
assert_eq!(e.scroll_offset(), 0, "cannot scroll below the live bottom");
e.scroll(5);
e.scroll_to_bottom();
assert_eq!(e.scroll_offset(), 0, "snaps back to live");
assert!(e.cursor().visible, "cursor returns once live");
}
#[test]
fn alt_screen_has_no_scrollback() {
let (tx, _rx) = channel();
let mut e = AlacrittyEngine::new(20, 5, tx);
feed_lines(&mut e, 20);
e.advance(b"\x1b[?1049h"); assert!(e.alt_screen());
e.scroll(5);
assert_eq!(e.scroll_offset(), 0, "the alt screen ignores scrollback");
}
#[test]
fn mouse_tracking_modes_are_detected() {
let (tx, _rx) = channel();
let mut e = AlacrittyEngine::new(20, 5, tx);
assert!(!e.mouse_report(), "no tracking by default");
assert!(!e.sgr_mouse());
e.advance(b"\x1b[?1000h\x1b[?1006h");
assert!(e.mouse_report(), "wheel should be forwarded to the app");
assert!(e.sgr_mouse(), "reports use the SGR encoding");
e.advance(b"\x1b[?1000l");
assert!(!e.mouse_report());
}
}