use alacritty_terminal::grid::Dimensions;
use alacritty_terminal::term::Term;
use once_cell::sync::Lazy;
use regex::Regex;
static SPINNER_ONLY_PATTERN: Lazy<Regex> =
Lazy::new(|| Regex::new(r"^[·✻✽✶✳✢⠐⠂⠈⠁⠉⠃⠋⠓⠒⠖⠦⠤]+$").unwrap());
static SEPARATOR_PATTERN: Lazy<Regex> = Lazy::new(|| Regex::new(r"^[─━═]+$").unwrap());
static STATUSBAR_PATTERN: Lazy<Regex> =
Lazy::new(|| Regex::new(r"(?i)esc to interrupt").unwrap());
static PROMPT_ONLY_PATTERN: Lazy<Regex> = Lazy::new(|| Regex::new(r"^[❯>]\s*$").unwrap());
#[derive(Debug, Clone)]
pub struct LineData {
pub text: String,
pub is_wrapped: bool,
}
#[derive(Debug, Clone)]
pub struct ScreenSnapshot {
pub start_y: usize,
pub end_y: usize,
pub lines: Vec<LineData>,
pub cursor_x: usize,
pub cursor_y: usize,
pub base_y: usize,
pub timestamp: i64,
}
#[derive(Debug, Clone)]
pub enum StableTextOp {
Line {
y: usize,
text: String,
is_wrapped: bool,
},
Replace {
y: usize,
text: String,
is_wrapped: bool,
},
Append { y: usize, text: String },
}
impl StableTextOp {
pub fn y(&self) -> usize {
match self {
StableTextOp::Line { y, .. } => *y,
StableTextOp::Replace { y, .. } => *y,
StableTextOp::Append { y, .. } => *y,
}
}
pub fn text(&self) -> &str {
match self {
StableTextOp::Line { text, .. } => text,
StableTextOp::Replace { text, .. } => text,
StableTextOp::Append { text, .. } => text,
}
}
pub fn kind(&self) -> &'static str {
match self {
StableTextOp::Line { .. } => "line",
StableTextOp::Replace { .. } => "replace",
StableTextOp::Append { .. } => "append",
}
}
pub fn is_wrapped(&self) -> bool {
match self {
StableTextOp::Line { is_wrapped, .. } => *is_wrapped,
StableTextOp::Replace { is_wrapped, .. } => *is_wrapped,
StableTextOp::Append { .. } => false,
}
}
}
#[derive(Debug, Clone)]
pub struct AddedLine {
pub y: usize,
pub text: String,
pub is_wrapped: bool,
}
#[derive(Debug, Clone)]
pub struct ModifiedLine {
pub y: usize,
pub old_text: String,
pub new_text: String,
pub is_wrapped: bool,
}
#[derive(Debug, Clone)]
pub struct FrameDelta {
pub timestamp: i64,
pub added_lines: Vec<AddedLine>,
pub modified_lines: Vec<ModifiedLine>,
pub scrolled_lines: i32,
pub stable_ops: Vec<StableTextOp>,
pub cursor_position: (usize, usize),
pub window: (usize, usize),
}
pub struct IncrementalExtractor {
last_snapshot: Option<ScreenSnapshot>,
window_lines: usize,
}
impl IncrementalExtractor {
pub fn new(rows: usize, window_lines: Option<usize>) -> Self {
let default_window = std::cmp::max(rows * 20, 800);
Self {
last_snapshot: None,
window_lines: window_lines.unwrap_or(default_window),
}
}
pub fn extract<T>(&mut self, term: &Term<T>) -> FrameDelta {
let current = self.capture_screen(term);
let delta = self.compute_delta(self.last_snapshot.as_ref(), ¤t);
self.last_snapshot = Some(current);
delta
}
pub fn reset(&mut self) {
self.last_snapshot = None;
}
pub fn peek<T>(&self, term: &Term<T>) -> ScreenSnapshot {
self.capture_screen(term)
}
fn get_snapshot_line(snap: &ScreenSnapshot, abs_y: usize) -> Option<&LineData> {
if abs_y < snap.start_y || abs_y >= snap.end_y {
return None;
}
snap.lines.get(abs_y - snap.start_y)
}
fn capture_screen<T>(&self, term: &Term<T>) -> ScreenSnapshot {
let grid = term.grid();
let mut lines = Vec::new();
let total_lines = grid.total_lines();
let display_offset = grid.display_offset();
let rows = grid.screen_lines();
let base_y = if total_lines > rows {
total_lines - rows - display_offset
} else {
0
};
let end_y = base_y + rows;
let start_y = if end_y > self.window_lines {
end_y - self.window_lines
} else {
0
};
for y in start_y..end_y {
let line_idx = alacritty_terminal::index::Line(y as i32);
if y < total_lines {
let row = &grid[line_idx];
let text: String = row.into_iter().map(|cell| cell.c).collect();
let text = text.trim_end().to_string();
let is_wrapped = if row.len() > 0 {
row[alacritty_terminal::index::Column(0)]
.flags
.contains(alacritty_terminal::term::cell::Flags::WRAPLINE)
} else {
false
};
lines.push(LineData { text, is_wrapped });
} else {
lines.push(LineData {
text: String::new(),
is_wrapped: false,
});
}
}
let cursor = &term.grid().cursor;
ScreenSnapshot {
start_y,
end_y,
lines,
cursor_x: cursor.point.column.0,
cursor_y: cursor.point.line.0 as usize,
base_y,
timestamp: chrono::Utc::now().timestamp_millis(),
}
}
fn compute_delta(&self, prev: Option<&ScreenSnapshot>, curr: &ScreenSnapshot) -> FrameDelta {
let mut added_lines = Vec::new();
let mut modified_lines = Vec::new();
let scrolled_lines: i32;
match prev {
None => {
scrolled_lines = 0;
for (local_y, line) in curr.lines.iter().enumerate() {
let y = curr.start_y + local_y;
if !line.text.trim().is_empty() {
added_lines.push(AddedLine {
y,
text: line.text.clone(),
is_wrapped: line.is_wrapped,
});
}
}
}
Some(prev) => {
scrolled_lines = curr.base_y as i32 - prev.base_y as i32;
let start_y = std::cmp::min(prev.start_y, curr.start_y);
let end_y = std::cmp::max(prev.end_y, curr.end_y);
for y in start_y..end_y {
let prev_line = Self::get_snapshot_line(prev, y);
let curr_line = Self::get_snapshot_line(curr, y);
let prev_text = prev_line.map(|l| l.text.as_str()).unwrap_or("");
let curr_text = curr_line.map(|l| l.text.as_str()).unwrap_or("");
let curr_wrapped = curr_line.map(|l| l.is_wrapped).unwrap_or(false);
if prev_line.is_none() && curr_line.is_some() {
if y < prev.start_y {
continue;
}
if !curr_text.trim().is_empty() {
added_lines.push(AddedLine {
y,
text: curr_text.to_string(),
is_wrapped: curr_wrapped,
});
}
continue;
}
if prev_line.is_some()
&& curr_line.is_some()
&& prev_text != curr_text
&& !curr_text.trim().is_empty()
{
modified_lines.push(ModifiedLine {
y,
old_text: prev_text.to_string(),
new_text: curr_text.to_string(),
is_wrapped: curr_wrapped,
});
}
}
}
}
let stable_ops = self.extract_stable_ops(&added_lines, &modified_lines);
FrameDelta {
timestamp: curr.timestamp,
added_lines,
modified_lines,
scrolled_lines,
stable_ops,
cursor_position: (curr.cursor_x, curr.cursor_y),
window: (curr.start_y, curr.end_y),
}
}
fn extract_stable_ops(
&self,
added: &[AddedLine],
modified: &[ModifiedLine],
) -> Vec<StableTextOp> {
let mut ops = Vec::new();
for line in added {
let text = line.text.trim_end();
if self.is_stable_line(text) {
ops.push(StableTextOp::Line {
y: line.y,
text: text.to_string(),
is_wrapped: line.is_wrapped,
});
}
}
for line in modified {
let new_text = line.new_text.trim_end();
let old_text = line.old_text.trim_end();
if self.is_stable_line(new_text) && !self.is_stable_line(old_text) {
ops.push(StableTextOp::Replace {
y: line.y,
text: new_text.to_string(),
is_wrapped: line.is_wrapped,
});
continue;
}
if self.is_stable_line(new_text) && self.is_stable_line(old_text) {
if new_text.starts_with(old_text) && new_text.len() > old_text.len() {
let appended = &new_text[old_text.len()..];
if !appended.is_empty() {
ops.push(StableTextOp::Append {
y: line.y,
text: appended.to_string(),
});
}
}
}
}
ops.sort_by(|a, b| {
if a.y() != b.y() {
return a.y().cmp(&b.y());
}
let order = |op: &StableTextOp| -> u8 {
match op {
StableTextOp::Append { .. } => 2,
_ => 1,
}
};
order(a).cmp(&order(b))
});
ops
}
fn is_stable_line(&self, text: &str) -> bool {
let trimmed = text.trim();
if trimmed.is_empty() {
return false;
}
if SPINNER_ONLY_PATTERN.is_match(trimmed) {
return false;
}
if SEPARATOR_PATTERN.is_match(trimmed) {
return false;
}
if STATUSBAR_PATTERN.is_match(trimmed) {
return false;
}
if PROMPT_ONLY_PATTERN.is_match(trimmed) {
return false;
}
true
}
}
pub struct TextAssembler {
buffer: String,
}
impl Default for TextAssembler {
fn default() -> Self {
Self::new()
}
}
impl TextAssembler {
pub fn new() -> Self {
Self {
buffer: String::new(),
}
}
pub fn reset(&mut self) {
self.buffer.clear();
}
pub fn apply(&mut self, op: &StableTextOp) -> String {
if op.text().is_empty() {
return String::new();
}
match op {
StableTextOp::Append { text, .. } => {
self.buffer.push_str(text);
text.clone()
}
StableTextOp::Line { text, is_wrapped, .. }
| StableTextOp::Replace { text, is_wrapped, .. } => {
let needs_newline =
!self.buffer.is_empty() && !self.buffer.ends_with('\n') && !is_wrapped;
let chunk = if needs_newline {
format!("\n{}", text)
} else {
text.clone()
};
self.buffer.push_str(&chunk);
chunk
}
}
}
pub fn apply_all(&mut self, ops: &[StableTextOp]) -> String {
let mut appended = String::new();
for op in ops {
appended.push_str(&self.apply(op));
}
appended
}
pub fn finalize(&self) -> String {
self.buffer.clone()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_text_assembler_basic() {
let mut assembler = TextAssembler::new();
let op1 = StableTextOp::Line {
y: 0,
text: "Hello".to_string(),
is_wrapped: false,
};
assert_eq!(assembler.apply(&op1), "Hello");
let op2 = StableTextOp::Append {
y: 0,
text: " World".to_string(),
};
assert_eq!(assembler.apply(&op2), " World");
let op3 = StableTextOp::Line {
y: 1,
text: "Second line".to_string(),
is_wrapped: false,
};
assert_eq!(assembler.apply(&op3), "\nSecond line");
assert_eq!(assembler.finalize(), "Hello World\nSecond line");
}
#[test]
fn test_text_assembler_wrapped_line() {
let mut assembler = TextAssembler::new();
let op1 = StableTextOp::Line {
y: 0,
text: "This is a very long line that".to_string(),
is_wrapped: false,
};
assembler.apply(&op1);
let op2 = StableTextOp::Line {
y: 1,
text: " continues here".to_string(),
is_wrapped: true,
};
assert_eq!(assembler.apply(&op2), " continues here");
assert_eq!(
assembler.finalize(),
"This is a very long line that continues here"
);
}
#[test]
fn test_stable_line_detection() {
let extractor = IncrementalExtractor::new(30, None);
assert!(extractor.is_stable_line("Hello world"));
assert!(extractor.is_stable_line(" Some code "));
assert!(extractor.is_stable_line("> user input here"));
assert!(!extractor.is_stable_line("·····"));
assert!(!extractor.is_stable_line("────────"));
assert!(!extractor.is_stable_line("Press esc to interrupt"));
assert!(!extractor.is_stable_line("> "));
assert!(!extractor.is_stable_line("❯ "));
assert!(!extractor.is_stable_line(""));
assert!(!extractor.is_stable_line(" "));
}
}