use crate::utils::{GridPos, GridSelection};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct TerminalPos {
pub line: usize,
pub col: usize,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct TerminalSelection {
pub anchor: TerminalPos,
pub cursor: TerminalPos,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TerminalSelectionEvent {
pub selection: Option<TerminalSelection>,
pub text: Option<String>,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct ScrollbackLineage {
pub evicted_lines: u64,
pub history_epoch: u64,
}
impl TerminalSelection {
pub fn new(pos: TerminalPos) -> Self {
Self {
anchor: pos,
cursor: pos,
}
}
pub fn extend_to(&mut self, pos: TerminalPos) {
self.cursor = pos;
}
pub fn normalized(&self) -> (TerminalPos, TerminalPos) {
if (self.anchor.line, self.anchor.col) <= (self.cursor.line, self.cursor.col) {
(self.anchor, self.cursor)
} else {
(self.cursor, self.anchor)
}
}
pub fn is_empty(&self) -> bool {
self.anchor == self.cursor
}
pub fn contains(&self, line: usize, col: usize) -> bool {
let (start, end) = self.normalized();
if line < start.line || line > end.line {
return false;
}
if line == start.line && line == end.line {
col >= start.col && col < end.col
} else if line == start.line {
col >= start.col
} else if line == end.line {
col < end.col
} else {
true
}
}
}
pub fn absolute_line(
total_scrollback_rows: usize,
scrollback_offset: usize,
viewport_row: usize,
) -> usize {
total_scrollback_rows
.saturating_sub(scrollback_offset)
.saturating_add(viewport_row)
}
pub fn viewport_row(
total_scrollback_rows: usize,
scrollback_offset: usize,
absolute_line: usize,
) -> isize {
absolute_line as isize - total_scrollback_rows as isize + scrollback_offset as isize
}
pub fn from_viewport(
anchor: GridPos,
cursor: GridPos,
scrollback_offset: usize,
total_scrollback_rows: usize,
) -> TerminalSelection {
TerminalSelection {
anchor: TerminalPos {
line: absolute_line(total_scrollback_rows, scrollback_offset, anchor.row),
col: anchor.col,
},
cursor: TerminalPos {
line: absolute_line(total_scrollback_rows, scrollback_offset, cursor.row),
col: cursor.col,
},
}
}
pub fn to_viewport(
sel: &TerminalSelection,
scrollback_offset: usize,
total_scrollback_rows: usize,
viewport_rows: usize,
) -> Option<GridSelection> {
let (start, end) = sel.normalized();
let top = total_scrollback_rows.saturating_sub(scrollback_offset);
let bottom = top.saturating_add(viewport_rows.saturating_sub(1));
if end.line < top || start.line > bottom {
return None;
}
let start_row = viewport_row(total_scrollback_rows, scrollback_offset, start.line);
let end_row = viewport_row(total_scrollback_rows, scrollback_offset, end.line);
let (anchor_row, anchor_col) = if start_row < 0 {
(0, 0)
} else {
(start_row as usize, start.col)
};
let (cursor_row, cursor_col) = if end_row >= viewport_rows as isize {
(viewport_rows, 0)
} else {
(end_row as usize, end.col)
};
Some(GridSelection {
anchor: GridPos {
row: anchor_row,
col: anchor_col,
},
cursor: GridPos {
row: cursor_row,
col: cursor_col,
},
})
}
impl From<TerminalSelection> for GridSelection {
fn from(sel: TerminalSelection) -> Self {
Self {
anchor: GridPos {
row: sel.anchor.line,
col: sel.anchor.col,
},
cursor: GridPos {
row: sel.cursor.line,
col: sel.cursor.col,
},
}
}
}
pub(crate) fn rebase_selection(
sel: Option<TerminalSelection>,
from: ScrollbackLineage,
to: ScrollbackLineage,
) -> Option<TerminalSelection> {
let sel = sel?;
if from.history_epoch != to.history_epoch {
return None;
}
let delta = to.evicted_lines.saturating_sub(from.evicted_lines);
if delta == 0 {
return Some(sel);
}
let shift = usize::try_from(delta).unwrap_or(usize::MAX);
let rebase_pos = |pos: TerminalPos| -> Option<TerminalPos> {
pos.line
.checked_sub(shift)
.map(|line| TerminalPos { line, col: pos.col })
};
Some(TerminalSelection {
anchor: rebase_pos(sel.anchor)?,
cursor: rebase_pos(sel.cursor)?,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn from_viewport_round_trips_at_live_view() {
let sel = from_viewport(
GridPos { row: 1, col: 2 },
GridPos { row: 3, col: 5 },
0,
10,
);
let projected = to_viewport(&sel, 0, 10, 24).expect("visible");
assert_eq!(projected.anchor, GridPos { row: 1, col: 2 });
assert_eq!(projected.cursor, GridPos { row: 3, col: 5 });
}
#[test]
fn to_viewport_clamps_start_above_and_end_below() {
let sel = TerminalSelection {
anchor: TerminalPos { line: 2, col: 4 },
cursor: TerminalPos { line: 20, col: 7 },
};
let projected = to_viewport(&sel, 5, 10, 8).expect("partial overlap");
assert_eq!(projected.anchor, GridPos { row: 0, col: 0 });
assert_eq!(projected.cursor, GridPos { row: 8, col: 0 });
}
#[test]
fn to_viewport_none_when_entirely_off_screen() {
let above = TerminalSelection {
anchor: TerminalPos { line: 0, col: 0 },
cursor: TerminalPos { line: 1, col: 3 },
};
assert!(to_viewport(&above, 0, 10, 8).is_none());
let below = TerminalSelection {
anchor: TerminalPos { line: 30, col: 0 },
cursor: TerminalPos { line: 31, col: 3 },
};
assert!(to_viewport(&below, 0, 10, 8).is_none());
}
#[test]
fn rebase_selection_shifts_and_drops_on_eviction() {
let sel = TerminalSelection {
anchor: TerminalPos { line: 5, col: 1 },
cursor: TerminalPos { line: 8, col: 2 },
};
let from = ScrollbackLineage {
evicted_lines: 2,
history_epoch: 1,
};
let to = ScrollbackLineage {
evicted_lines: 4,
history_epoch: 1,
};
let rebased = rebase_selection(Some(sel), from, to).expect("shift");
assert_eq!(rebased.anchor.line, 3);
assert_eq!(rebased.cursor.line, 6);
let lost_anchor = TerminalSelection {
anchor: TerminalPos { line: 1, col: 0 },
cursor: TerminalPos { line: 8, col: 0 },
};
assert!(rebase_selection(Some(lost_anchor), from, to).is_none());
assert!(
rebase_selection(
Some(sel),
from,
ScrollbackLineage {
evicted_lines: 4,
history_epoch: 2,
}
)
.is_none()
);
}
}