use std::collections::BTreeMap;
use vte::{Params, Parser, Perform};
const MAX_TRACKED_ROWS: usize = 512;
const MAX_ROW_CHARS: usize = 4096;
pub struct CursorModel {
parser: Parser,
state: ScreenState,
}
impl CursorModel {
pub fn new() -> Self {
Self {
parser: Parser::new(),
state: ScreenState::new(),
}
}
pub fn feed(&mut self, bytes: &[u8]) {
self.parser.advance(&mut self.state, bytes);
}
pub fn take_command_line(&mut self) -> String {
let completed = self.state.last_completed_row.take();
let dirty = self.state.dirty_row.take();
match completed.or(dirty) {
Some(row) => self.state.row_text(row),
None => String::new(),
}
}
pub fn mark_output_boundary(&mut self) {
self.state.last_completed_row = None;
self.state.dirty_row = None;
}
}
impl Default for CursorModel {
fn default() -> Self {
Self::new()
}
}
struct ScreenState {
rows: BTreeMap<i64, Vec<char>>,
cursor_row: i64,
cursor_col: usize,
last_completed_row: Option<i64>,
dirty_row: Option<i64>,
}
impl ScreenState {
fn new() -> Self {
Self {
rows: BTreeMap::new(),
cursor_row: 0,
cursor_col: 0,
last_completed_row: None,
dirty_row: None,
}
}
fn row_text(&self, row: i64) -> String {
self.rows
.get(&row)
.map(|chars| chars.iter().collect::<String>().trim_end().to_string())
.unwrap_or_default()
}
fn prune(&mut self) {
while self.rows.len() > MAX_TRACKED_ROWS {
let Some(&oldest) = self.rows.keys().next() else {
break;
};
self.rows.remove(&oldest);
}
}
fn put_char(&mut self, c: char) {
self.dirty_row = Some(self.cursor_row);
let row = self.rows.entry(self.cursor_row).or_default();
if row.len() < MAX_ROW_CHARS {
if self.cursor_col >= row.len() {
row.resize(self.cursor_col + 1, ' ');
}
if self.cursor_col < MAX_ROW_CHARS {
row[self.cursor_col] = c;
}
}
self.cursor_col += 1;
}
fn erase_in_line(&mut self, mode: i64) {
let Some(row) = self.rows.get_mut(&self.cursor_row) else {
return;
};
match mode {
0 => row.truncate(self.cursor_col.min(row.len())),
1 => {
let end = (self.cursor_col + 1).min(row.len());
for cell in &mut row[..end] {
*cell = ' ';
}
}
_ => row.clear(),
}
}
fn erase_in_display(&mut self, mode: i64) {
match mode {
0 => {
self.erase_in_line(0);
let cur = self.cursor_row;
self.rows.retain(|&r, _| r <= cur);
}
1 => {
self.erase_in_line(1);
let cur = self.cursor_row;
self.rows.retain(|&r, _| r >= cur);
}
_ => self.rows.clear(),
}
}
}
fn csi_param(params: &Params, index: usize) -> Option<i64> {
params
.iter()
.nth(index)
.and_then(|sub| sub.first())
.map(|&v| v as i64)
}
fn csi_count(params: &Params, index: usize) -> i64 {
match csi_param(params, index) {
Some(0) | None => 1,
Some(n) => n,
}
}
impl Perform for ScreenState {
fn print(&mut self, c: char) {
self.put_char(c);
}
fn execute(&mut self, byte: u8) {
match byte {
b'\r' => self.cursor_col = 0,
b'\n' => {
self.last_completed_row = Some(self.cursor_row);
self.cursor_row += 1;
self.prune();
}
0x08 => self.cursor_col = self.cursor_col.saturating_sub(1),
0x09 => self.cursor_col = (self.cursor_col / 8 + 1) * 8,
_ => {}
}
}
fn csi_dispatch(
&mut self,
params: &Params,
_intermediates: &[u8],
_ignore: bool,
action: char,
) {
match action {
'H' | 'f' => {
self.cursor_row = csi_count(params, 0) - 1;
self.cursor_col = (csi_count(params, 1) - 1).max(0) as usize;
}
'A' => self.cursor_row -= csi_count(params, 0),
'B' => self.cursor_row += csi_count(params, 0),
'C' => self.cursor_col += csi_count(params, 0) as usize,
'D' => {
self.cursor_col = self
.cursor_col
.saturating_sub(csi_count(params, 0) as usize)
}
'G' | '`' => self.cursor_col = (csi_count(params, 0) - 1).max(0) as usize,
'd' => self.cursor_row = csi_count(params, 0) - 1,
'K' => self.erase_in_line(csi_param(params, 0).unwrap_or(0)),
'J' => self.erase_in_display(csi_param(params, 0).unwrap_or(0)),
_ => {}
}
self.prune();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn plain_text_ending_in_crlf_becomes_a_completed_row() {
let mut m = CursorModel::new();
m.feed(b"hello world\r\n");
assert_eq!(m.take_command_line(), "hello world");
}
#[test]
fn cup_repositioning_writes_to_a_different_row_than_the_completed_one() {
let mut m = CursorModel::new();
m.feed(b"prompt$ cmd\r\n");
m.feed(b"\x1b[24;1H\x1b[Kstatus noise, no newline");
assert_eq!(m.take_command_line(), "prompt$ cmd");
}
#[test]
fn mode_toggle_csi_never_becomes_printed_text() {
let mut m = CursorModel::new();
m.feed(b"prompt$ cmd\x1b[?2004l\r\n");
assert_eq!(m.take_command_line(), "prompt$ cmd");
}
#[test]
fn take_resets_so_a_stale_row_is_not_reused() {
let mut m = CursorModel::new();
m.feed(b"first\r\n");
assert_eq!(m.take_command_line(), "first");
m.feed(b"partial, no newline yet");
assert_eq!(m.take_command_line(), "partial, no newline yet");
}
#[test]
fn no_fresh_activity_since_last_take_returns_empty_not_stale_content() {
let mut m = CursorModel::new();
m.feed(b"echo one\r\n");
assert_eq!(m.take_command_line(), "echo one");
m.feed(b"\x1b[1;1H");
assert_eq!(m.take_command_line(), "");
}
#[test]
fn erase_in_line_truncates_from_cursor() {
let mut m = CursorModel::new();
m.feed(b"0123456789");
m.feed(b"\x1b[5G"); m.feed(b"\x1b[K"); m.feed(b"\r\n");
assert_eq!(m.take_command_line(), "0123");
}
}