1273 lines
44 KiB
Rust
1273 lines
44 KiB
Rust
//! Per-frontend terminal viewport, selection, copy, and bell projection.
|
|
//!
|
|
//! A view stores only logical anchors into one [`TerminalScreen`]. Cells,
|
|
//! modes, history, and process state remain session-owned.
|
|
|
|
use crate::buffer::BufferId;
|
|
use crate::cell::{Cell, CellCoord, CellSize, Glyph, Style};
|
|
use crate::protocol::FrontendId;
|
|
use crate::terminal::screen::{BorrowedScreenProjection, TerminalModes, TerminalRow};
|
|
use crate::terminal::session::{TerminalManager, TerminalSnapshot};
|
|
use crate::terminal::{
|
|
MAX_TERMINAL_COLS, MAX_TERMINAL_ROWS, MAX_TERMINAL_VISIBLE_CELLS, TerminalProcessState,
|
|
TerminalSelectionSpan,
|
|
};
|
|
use crate::window::WindowId;
|
|
|
|
/// One frontend/window projection of a terminal session.
|
|
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
|
pub struct TerminalViewKey {
|
|
/// Authenticated frontend that owns this view state.
|
|
pub frontend_id: FrontendId,
|
|
/// Stable editor window showing the terminal.
|
|
pub window_id: WindowId,
|
|
/// Identity buffer whose session is projected.
|
|
pub buffer_id: BufferId,
|
|
}
|
|
|
|
impl TerminalViewKey {
|
|
/// Construct an exact terminal view identity.
|
|
#[must_use]
|
|
pub const fn new(frontend_id: FrontendId, window_id: WindowId, buffer_id: BufferId) -> Self {
|
|
Self {
|
|
frontend_id,
|
|
window_id,
|
|
buffer_id,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Leading display-cell offset within one retained logical line.
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
pub struct LogicalCellAnchor {
|
|
/// Stable logical line identity preserved by main-screen reflow.
|
|
pub logical_line_id: u64,
|
|
/// Leading display-cell offset within that logical line.
|
|
pub cell_offset: u32,
|
|
}
|
|
|
|
/// Inclusive terminal selection endpoints.
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
pub struct TerminalSelection {
|
|
/// Fixed endpoint where the drag began.
|
|
pub anchor: LogicalCellAnchor,
|
|
/// Moving endpoint under the pointer.
|
|
pub head: LogicalCellAnchor,
|
|
}
|
|
|
|
/// Fresh context metadata for Lua/statusline consumers.
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
pub struct TerminalViewStatus {
|
|
/// Geometric live-tail visibility.
|
|
pub at_bottom: bool,
|
|
/// Physical retained rows between this viewport and the live tail.
|
|
pub scroll_offset: u32,
|
|
/// Whether the view owns a nonempty selection.
|
|
pub selection: bool,
|
|
}
|
|
|
|
/// Mutable state for one [`TerminalViewKey`].
|
|
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
|
pub struct TerminalViewState {
|
|
/// First visible retained logical cell, or live-tail following when absent.
|
|
pub top: Option<LogicalCellAnchor>,
|
|
/// Inclusive logical-cell selection.
|
|
pub selection: Option<TerminalSelection>,
|
|
/// Current editor-owned drag endpoint; cleared on release.
|
|
pub drag: Option<LogicalCellAnchor>,
|
|
pub(super) alternate_active: Option<bool>,
|
|
pub(super) last_bell_count: u64,
|
|
pub(super) viewport_size: Option<CellSize>,
|
|
pub(super) selection_froze_top: bool,
|
|
}
|
|
|
|
/// The one authenticated frontend/window allowed to control a session's PTY.
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
pub struct TerminalController {
|
|
/// Authenticated controlling frontend.
|
|
pub frontend_id: FrontendId,
|
|
/// Active terminal window on that frontend.
|
|
pub window_id: WindowId,
|
|
}
|
|
|
|
impl TerminalController {
|
|
/// Construct a controller from an exact view identity.
|
|
#[must_use]
|
|
pub const fn from_view(key: TerminalViewKey) -> Self {
|
|
Self {
|
|
frontend_id: key.frontend_id,
|
|
window_id: key.window_id,
|
|
}
|
|
}
|
|
|
|
/// Whether this controller names `key`'s frontend and window.
|
|
#[must_use]
|
|
pub fn matches(self, key: TerminalViewKey) -> bool {
|
|
self.frontend_id == key.frontend_id && self.window_id == key.window_id
|
|
}
|
|
}
|
|
|
|
impl TerminalManager {
|
|
/// Project one exact view into an owned viewport-sized snapshot.
|
|
///
|
|
/// Zero or out-of-range viewports and unknown sessions return `None`
|
|
/// without registering view state.
|
|
#[must_use]
|
|
pub fn snapshot_for_view(
|
|
&mut self,
|
|
key: TerminalViewKey,
|
|
viewport_size: CellSize,
|
|
) -> Option<TerminalSnapshot> {
|
|
if !valid_viewport(viewport_size) {
|
|
return None;
|
|
}
|
|
let session = self.sessions.get(&key.buffer_id)?;
|
|
let projection = session.screen.projection_ref();
|
|
let pid = session.pid;
|
|
let process = session.process.clone();
|
|
let bell_count = session.screen.bell_count();
|
|
|
|
let state = self.views.entry(key).or_insert_with(|| TerminalViewState {
|
|
alternate_active: Some(projection.alternate_active),
|
|
last_bell_count: bell_count,
|
|
..TerminalViewState::default()
|
|
});
|
|
declare_view_size(state, projection, viewport_size);
|
|
Some(project_snapshot(
|
|
key.buffer_id,
|
|
viewport_size,
|
|
projection,
|
|
state,
|
|
pid,
|
|
process,
|
|
))
|
|
}
|
|
|
|
/// Scroll one view by physical retained rows.
|
|
///
|
|
/// Positive values move toward older rows and negative values move toward
|
|
/// the live tail. Returns whether the top anchor changed.
|
|
pub fn scroll_view(
|
|
&mut self,
|
|
key: TerminalViewKey,
|
|
viewport_size: CellSize,
|
|
lines: i32,
|
|
) -> bool {
|
|
if lines == 0 || !valid_viewport(viewport_size) {
|
|
return false;
|
|
}
|
|
let Some(session) = self.sessions.get(&key.buffer_id) else {
|
|
return false;
|
|
};
|
|
let projection = session.screen.projection_ref();
|
|
let bell_count = session.screen.bell_count();
|
|
let state = self.views.entry(key).or_insert_with(|| TerminalViewState {
|
|
alternate_active: Some(projection.alternate_active),
|
|
last_bell_count: bell_count,
|
|
..TerminalViewState::default()
|
|
});
|
|
normalize_state(state, projection);
|
|
state.viewport_size = Some(viewport_size);
|
|
let rows = retained_rows(projection);
|
|
if rows.is_empty() {
|
|
return false;
|
|
}
|
|
let geometry = view_geometry(&rows, state, viewport_size.rows);
|
|
let tail_start = rows.len().saturating_sub(viewport_size.rows as usize);
|
|
let magnitude = lines.unsigned_abs() as usize;
|
|
let next = if lines > 0 {
|
|
geometry.start.saturating_sub(magnitude)
|
|
} else {
|
|
geometry.start.saturating_add(magnitude).min(tail_start)
|
|
};
|
|
if next == geometry.start {
|
|
return false;
|
|
}
|
|
state.top = if next == tail_start && state.selection.is_none() {
|
|
None
|
|
} else {
|
|
Some(row_lead(rows.get(next).expect("bounded retained row")))
|
|
};
|
|
true
|
|
}
|
|
|
|
/// Scroll by `lines` using the last nonzero rendered viewport.
|
|
pub fn scroll_lines(&mut self, key: TerminalViewKey, lines: i32) -> bool {
|
|
if lines == 0 {
|
|
return false;
|
|
}
|
|
let Some(size) = self.views.get(&key).and_then(|state| state.viewport_size) else {
|
|
return false;
|
|
};
|
|
self.scroll_view(key, size, lines)
|
|
}
|
|
|
|
/// Scroll by one last-rendered page in `direction` (`1` older, `-1` tail).
|
|
pub fn scroll_page(&mut self, key: TerminalViewKey, direction: i32) -> bool {
|
|
if direction == 0 {
|
|
return false;
|
|
}
|
|
let Some(size) = self.views.get(&key).and_then(|state| state.viewport_size) else {
|
|
return false;
|
|
};
|
|
let rows = i32::try_from(size.rows).unwrap_or(i32::MAX);
|
|
self.scroll_view(key, size, rows.saturating_mul(direction.signum()))
|
|
}
|
|
|
|
/// Register or refresh one view at `viewport_size` and return its geometry
|
|
/// without allocating an owned cell snapshot.
|
|
#[must_use]
|
|
pub(crate) fn view_status_for_size(
|
|
&mut self,
|
|
key: TerminalViewKey,
|
|
viewport_size: CellSize,
|
|
) -> Option<TerminalViewStatus> {
|
|
if !valid_viewport(viewport_size) {
|
|
return None;
|
|
}
|
|
let session = self.sessions.get(&key.buffer_id)?;
|
|
let projection = session.screen.projection_ref();
|
|
let bell_count = session.screen.bell_count();
|
|
let state = self.views.entry(key).or_insert_with(|| TerminalViewState {
|
|
alternate_active: Some(projection.alternate_active),
|
|
last_bell_count: bell_count,
|
|
..TerminalViewState::default()
|
|
});
|
|
declare_view_size(state, projection, viewport_size);
|
|
let rows = retained_rows(projection);
|
|
let geometry = view_geometry(&rows, state, viewport_size.rows);
|
|
Some(TerminalViewStatus {
|
|
at_bottom: geometry.scroll_offset == 0,
|
|
scroll_offset: geometry.scroll_offset,
|
|
selection: state.selection.is_some(),
|
|
})
|
|
}
|
|
|
|
/// Return the publication-consistent child grid size for one view.
|
|
#[must_use]
|
|
pub(crate) fn screen_size_for_view(&self, key: TerminalViewKey) -> Option<CellSize> {
|
|
self.screen_size(key.buffer_id)
|
|
}
|
|
|
|
/// The shared screen's current size, read from the borrowed
|
|
/// projection.
|
|
///
|
|
/// Deliberately not `snapshot(..).size`: that clones the whole
|
|
/// visible cell grid, and geometry comparison runs on every
|
|
/// dispatcher tick for every frontend with a declared terminal.
|
|
#[must_use]
|
|
pub fn screen_size(&self, buffer_id: BufferId) -> Option<CellSize> {
|
|
self.sessions
|
|
.get(&buffer_id)
|
|
.map(|session| session.screen.projection_ref().size)
|
|
}
|
|
|
|
/// Record an exact view's declared viewport size without projecting.
|
|
///
|
|
/// Vterm Stage 3: a semantic frontend declares terminal geometry
|
|
/// through its own message rather than through a layout pass, and a
|
|
/// PASSIVE view must still record its size — that is what lets it
|
|
/// receive its own clipped/padded projection instead of the
|
|
/// controller's. Recording a size is deliberately not claiming
|
|
/// control; the caller decides whether to resize the PTY.
|
|
///
|
|
/// Returns `false` for an unknown session or an out-of-range size,
|
|
/// leaving prior geometry untouched.
|
|
pub fn record_view_size(&mut self, key: TerminalViewKey, viewport_size: CellSize) -> bool {
|
|
if !valid_viewport(viewport_size) {
|
|
return false;
|
|
}
|
|
let Some(session) = self.sessions.get(&key.buffer_id) else {
|
|
return false;
|
|
};
|
|
let projection = session.screen.projection_ref();
|
|
let bell_count = session.screen.bell_count();
|
|
let state = self.views.entry(key).or_insert_with(|| TerminalViewState {
|
|
alternate_active: Some(projection.alternate_active),
|
|
last_bell_count: bell_count,
|
|
..TerminalViewState::default()
|
|
});
|
|
declare_view_size(state, projection, viewport_size);
|
|
true
|
|
}
|
|
|
|
/// The viewport size an exact view last declared or rendered at.
|
|
#[must_use]
|
|
pub fn declared_view_size(&self, key: TerminalViewKey) -> Option<CellSize> {
|
|
self.views.get(&key).and_then(|state| state.viewport_size)
|
|
}
|
|
|
|
/// Return fresh geometric status for one registered view.
|
|
#[must_use]
|
|
pub fn view_status(&mut self, key: TerminalViewKey) -> Option<TerminalViewStatus> {
|
|
let size = self.views.get(&key)?.viewport_size?;
|
|
self.view_status_for_size(key, size)
|
|
}
|
|
|
|
/// Clear selection and resume live-tail following for one view.
|
|
pub fn scroll_to_bottom(&mut self, key: TerminalViewKey) -> bool {
|
|
let Some(state) = self.views.get_mut(&key) else {
|
|
return false;
|
|
};
|
|
let changed = state.top.is_some() || state.selection.is_some() || state.drag.is_some();
|
|
state.top = None;
|
|
state.selection = None;
|
|
state.drag = None;
|
|
state.selection_froze_top = false;
|
|
changed
|
|
}
|
|
|
|
/// Serialize one view's current selection from retained terminal rows.
|
|
#[must_use]
|
|
pub fn copy_selection(&mut self, key: TerminalViewKey) -> Option<Vec<u8>> {
|
|
let session = self.sessions.get(&key.buffer_id)?;
|
|
let projection = session.screen.projection_ref();
|
|
let state = self.views.get_mut(&key)?;
|
|
normalize_state(state, projection);
|
|
let selection = state.selection?;
|
|
let rows = retained_rows(projection);
|
|
copy_selection_bytes(&rows, selection)
|
|
}
|
|
|
|
/// Serialize a session's ENTIRE retained range — scrollback plus the
|
|
/// visible screen — through the same path [`copy_selection`] uses.
|
|
///
|
|
/// Q#TC7. This deliberately builds a whole-range *selection* and hands
|
|
/// it to the existing serializer rather than walking the rows itself.
|
|
/// Soft-wrap joining, wide-glyph continuation, cluster bytes, and
|
|
/// per-row trailing-blank trimming are Vterm Stage 2 criterion 21's
|
|
/// pinned behavior; a second walk would re-derive all four and the two
|
|
/// would drift. That inheritance is what acceptance 13 asserts, by
|
|
/// comparing this against a full-range `copy_selection` rather than
|
|
/// against a literal.
|
|
///
|
|
/// Returns `None` for a non-terminal buffer and for a session whose
|
|
/// retained rows are all empty — there is no cell to anchor to.
|
|
/// Unlike `copy_selection` this needs no registered view, so copy mode
|
|
/// does not depend on the terminal being currently displayed.
|
|
#[must_use]
|
|
pub fn copy_retained(&self, buffer_id: BufferId) -> Option<Vec<u8>> {
|
|
let session = self.sessions.get(&buffer_id)?;
|
|
let projection = session.screen.projection_ref();
|
|
retained_bytes(&retained_rows(projection))
|
|
}
|
|
|
|
/// Start an editor-owned primary selection at a viewport coordinate.
|
|
pub fn begin_selection(
|
|
&mut self,
|
|
key: TerminalViewKey,
|
|
viewport_size: CellSize,
|
|
coord: CellCoord,
|
|
) -> bool {
|
|
let Some(session) = self.sessions.get(&key.buffer_id) else {
|
|
return false;
|
|
};
|
|
let projection = session.screen.projection_ref();
|
|
let bell_count = session.screen.bell_count();
|
|
let state = self.views.entry(key).or_insert_with(|| TerminalViewState {
|
|
alternate_active: Some(projection.alternate_active),
|
|
last_bell_count: bell_count,
|
|
..TerminalViewState::default()
|
|
});
|
|
normalize_state(state, projection);
|
|
let rows = retained_rows(projection);
|
|
let geometry = view_geometry(&rows, state, viewport_size.rows);
|
|
let Some(anchor) = anchor_at(&rows, &geometry, viewport_size, coord) else {
|
|
state.selection = None;
|
|
state.drag = None;
|
|
return false;
|
|
};
|
|
state.selection_froze_top = state.top.is_none();
|
|
if state.top.is_none() {
|
|
state.top = rows.get(geometry.start).map(row_lead);
|
|
}
|
|
state.selection = Some(TerminalSelection {
|
|
anchor,
|
|
head: anchor,
|
|
});
|
|
state.drag = Some(anchor);
|
|
true
|
|
}
|
|
|
|
/// Move an active editor-owned terminal selection.
|
|
pub fn update_selection(
|
|
&mut self,
|
|
key: TerminalViewKey,
|
|
viewport_size: CellSize,
|
|
coord: CellCoord,
|
|
) -> bool {
|
|
let Some(session) = self.sessions.get(&key.buffer_id) else {
|
|
return false;
|
|
};
|
|
let projection = session.screen.projection_ref();
|
|
let Some(state) = self.views.get_mut(&key) else {
|
|
return false;
|
|
};
|
|
normalize_state(state, projection);
|
|
if state.drag.is_none() {
|
|
return false;
|
|
}
|
|
let rows = retained_rows(projection);
|
|
let geometry = view_geometry(&rows, state, viewport_size.rows);
|
|
let Some(head) = anchor_at(&rows, &geometry, viewport_size, coord) else {
|
|
return false;
|
|
};
|
|
let changed = state
|
|
.selection
|
|
.is_some_and(|selection| selection.head != head);
|
|
if let Some(selection) = state.selection.as_mut() {
|
|
selection.head = head;
|
|
}
|
|
state.drag = Some(head);
|
|
changed
|
|
}
|
|
|
|
/// Finish an editor-owned terminal selection.
|
|
pub fn finish_selection(
|
|
&mut self,
|
|
key: TerminalViewKey,
|
|
viewport_size: CellSize,
|
|
coord: CellCoord,
|
|
) -> bool {
|
|
let moved = self.update_selection(key, viewport_size, coord);
|
|
let Some(state) = self.views.get_mut(&key) else {
|
|
return false;
|
|
};
|
|
let was_dragging = state.drag.take().is_some();
|
|
if state
|
|
.selection
|
|
.is_some_and(|selection| selection.anchor == selection.head)
|
|
{
|
|
state.selection = None;
|
|
if state.selection_froze_top {
|
|
state.top = None;
|
|
}
|
|
state.selection_froze_top = false;
|
|
}
|
|
moved || was_dragging
|
|
}
|
|
|
|
/// Clear one view's terminal selection without changing its scroll anchor.
|
|
pub fn clear_selection(&mut self, key: TerminalViewKey) -> bool {
|
|
let Some(state) = self.views.get_mut(&key) else {
|
|
return false;
|
|
};
|
|
state.selection.take().is_some() || state.drag.take().is_some()
|
|
}
|
|
|
|
/// Current child input modes for one session.
|
|
#[must_use]
|
|
pub fn modes_for_view(&self, key: TerminalViewKey) -> Option<TerminalModes> {
|
|
self.sessions
|
|
.get(&key.buffer_id)
|
|
.map(|session| session.screen.modes())
|
|
}
|
|
|
|
/// Exact controlled view for one frontend, if it still exists.
|
|
#[must_use]
|
|
pub fn controller_view_for_frontend(&self, frontend_id: FrontendId) -> Option<TerminalViewKey> {
|
|
self.controllers.iter().find_map(|(buffer_id, controller)| {
|
|
(controller.frontend_id == frontend_id)
|
|
.then(|| TerminalViewKey::new(frontend_id, controller.window_id, *buffer_id))
|
|
})
|
|
}
|
|
|
|
/// Observe BEL counters for every live view on one frontend.
|
|
///
|
|
/// Returns `true` only for a new bell in `active`; all other counters are
|
|
/// advanced so historical bells cannot replay after a later activation.
|
|
pub fn take_bell_for_frontend(
|
|
&mut self,
|
|
frontend_id: FrontendId,
|
|
active: Option<TerminalViewKey>,
|
|
) -> bool {
|
|
let mut ring = false;
|
|
for (key, state) in &mut self.views {
|
|
if key.frontend_id != frontend_id {
|
|
continue;
|
|
}
|
|
let Some(session) = self.sessions.get(&key.buffer_id) else {
|
|
continue;
|
|
};
|
|
let current = session.screen.bell_count();
|
|
if Some(*key) == active && current > state.last_bell_count {
|
|
ring = true;
|
|
}
|
|
state.last_bell_count = current;
|
|
}
|
|
ring
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy)]
|
|
struct ResolvedCell {
|
|
row: usize,
|
|
col: usize,
|
|
}
|
|
|
|
struct ViewGeometry {
|
|
start: usize,
|
|
top_padding: usize,
|
|
scroll_offset: u32,
|
|
}
|
|
|
|
#[derive(Clone, Copy)]
|
|
struct RetainedRows<'a> {
|
|
projection: BorrowedScreenProjection<'a>,
|
|
}
|
|
|
|
impl<'a> RetainedRows<'a> {
|
|
fn len(self) -> usize {
|
|
self.projection.history_len() + self.projection.visible_rows.len()
|
|
}
|
|
|
|
fn is_empty(self) -> bool {
|
|
self.len() == 0
|
|
}
|
|
|
|
fn get(self, index: usize) -> Option<&'a TerminalRow> {
|
|
let head_len = self.projection.history_head.len();
|
|
if index < head_len {
|
|
return self.projection.history_head.get(index);
|
|
}
|
|
let index = index - head_len;
|
|
let tail_len = self.projection.history_tail.len();
|
|
if index < tail_len {
|
|
return self.projection.history_tail.get(index);
|
|
}
|
|
self.projection.visible_rows.get(index - tail_len)
|
|
}
|
|
|
|
fn first(self) -> Option<&'a TerminalRow> {
|
|
self.get(0)
|
|
}
|
|
|
|
fn iter(self) -> impl Iterator<Item = &'a TerminalRow> {
|
|
self.projection
|
|
.history_head
|
|
.iter()
|
|
.chain(self.projection.history_tail)
|
|
.chain(self.projection.visible_rows)
|
|
}
|
|
}
|
|
|
|
fn valid_viewport(size: CellSize) -> bool {
|
|
size.rows > 0
|
|
&& size.cols > 0
|
|
&& size.rows <= u32::from(MAX_TERMINAL_ROWS)
|
|
&& size.cols <= u32::from(MAX_TERMINAL_COLS)
|
|
&& size.area() as usize <= MAX_TERMINAL_VISIBLE_CELLS
|
|
}
|
|
|
|
fn retained_rows(projection: BorrowedScreenProjection<'_>) -> RetainedRows<'_> {
|
|
RetainedRows { projection }
|
|
}
|
|
|
|
/// Serialize every retained cell, through the selection-copy serializer.
|
|
///
|
|
/// Split out from [`TerminalManager::copy_retained`] so the fidelity
|
|
/// claims — soft-wrap joining, per-row trailing-blank trimming, wide-glyph
|
|
/// continuation, cluster bytes — are testable against the same projection
|
|
/// fixtures that pin `copy_selection_bytes` itself. Those four are exactly
|
|
/// what a second, independently written walk would get wrong.
|
|
fn retained_bytes(rows: &RetainedRows<'_>) -> Option<Vec<u8>> {
|
|
copy_selection_bytes(rows, full_retained_selection(rows)?)
|
|
}
|
|
|
|
/// The selection spanning every retained cell.
|
|
///
|
|
/// Rows with no cells are skipped at both ends rather than clamped: an
|
|
/// anchor into a zero-width row cannot resolve (`resolve_anchor` requires
|
|
/// `cell_offset` to fall inside `cell_offset .. cell_offset + len`), so
|
|
/// including one would make the whole range unresolvable and silently
|
|
/// yield nothing. Interior empty rows are untouched, because trailing- and
|
|
/// interior-blank handling belongs to the serializer.
|
|
fn full_retained_selection(rows: &RetainedRows<'_>) -> Option<TerminalSelection> {
|
|
let mut occupied = rows.iter().filter(|row| !row.cells.is_empty());
|
|
let first = occupied.next()?;
|
|
// `RetainedRows::iter` is a chain of slice iterators exposed as
|
|
// `impl Iterator`, so it is not double-ended; scan forward.
|
|
let last = occupied.last().unwrap_or(first);
|
|
Some(TerminalSelection {
|
|
anchor: row_lead(first),
|
|
head: LogicalCellAnchor {
|
|
logical_line_id: last.logical_line_id,
|
|
cell_offset: last.cell_offset.saturating_add(last.cells.len() as u32 - 1),
|
|
},
|
|
})
|
|
}
|
|
|
|
fn row_lead(row: &TerminalRow) -> LogicalCellAnchor {
|
|
LogicalCellAnchor {
|
|
logical_line_id: row.logical_line_id,
|
|
cell_offset: row.cell_offset,
|
|
}
|
|
}
|
|
|
|
fn resolve_anchor(rows: &RetainedRows<'_>, anchor: LogicalCellAnchor) -> Option<ResolvedCell> {
|
|
rows.iter().enumerate().find_map(|(row_index, row)| {
|
|
if row.logical_line_id != anchor.logical_line_id {
|
|
return None;
|
|
}
|
|
let start = row.cell_offset;
|
|
let end = start.saturating_add(row.cells.len() as u32);
|
|
if anchor.cell_offset < start || anchor.cell_offset >= end {
|
|
return None;
|
|
}
|
|
let col = (anchor.cell_offset - start) as usize;
|
|
Some(ResolvedCell {
|
|
row: row_index,
|
|
col: canonical_col(row, col),
|
|
})
|
|
})
|
|
}
|
|
|
|
fn canonical_col(row: &TerminalRow, mut col: usize) -> usize {
|
|
col = col.min(row.cells.len().saturating_sub(1));
|
|
while col > 0 && matches!(row.cells[col].glyph, Glyph::Continuation) {
|
|
col -= 1;
|
|
}
|
|
col
|
|
}
|
|
|
|
fn anchor_for(rows: &RetainedRows<'_>, resolved: ResolvedCell) -> LogicalCellAnchor {
|
|
let row = rows.get(resolved.row).expect("resolved retained row");
|
|
LogicalCellAnchor {
|
|
logical_line_id: row.logical_line_id,
|
|
cell_offset: row.cell_offset.saturating_add(resolved.col as u32),
|
|
}
|
|
}
|
|
|
|
fn clamp_or_clear(rows: &RetainedRows<'_>, anchor: LogicalCellAnchor) -> Option<LogicalCellAnchor> {
|
|
if let Some(resolved) = resolve_anchor(rows, anchor) {
|
|
return Some(anchor_for(rows, resolved));
|
|
}
|
|
let first = rows.first()?;
|
|
(anchor.logical_line_id < first.logical_line_id
|
|
|| (anchor.logical_line_id == first.logical_line_id
|
|
&& anchor.cell_offset < first.cell_offset))
|
|
.then(|| row_lead(first))
|
|
}
|
|
|
|
/// The shared viewport-size declaration path (bottom-panel arc, Q#BP7).
|
|
///
|
|
/// Normalize, then re-arm live-tail following when the newly declared
|
|
/// viewport reaches the tail, then record the size. Every path that
|
|
/// *declares* a size routes through here so grid and semantic
|
|
/// declarations cannot disagree; `scroll_view` and `begin_selection`
|
|
/// deliberately do not, because they write `top` themselves.
|
|
fn declare_view_size(
|
|
state: &mut TerminalViewState,
|
|
projection: BorrowedScreenProjection<'_>,
|
|
viewport_size: CellSize,
|
|
) {
|
|
normalize_state(state, projection);
|
|
rearm_follow_on_growth(state, projection, viewport_size.rows);
|
|
state.viewport_size = Some(viewport_size);
|
|
}
|
|
|
|
/// Q#BP7 item 1: **growth reaching the live tail re-arms follow.**
|
|
///
|
|
/// A height change is a viewport change, never a scroll change — `top`
|
|
/// is preserved verbatim — but once a taller viewport covers the tail,
|
|
/// staying anchored would leave the view frozen just short of the live
|
|
/// output while `at_bottom` reported `true`: `at_bottom` is the
|
|
/// instantaneous geometric readout `scroll_offset == 0`, so it cannot
|
|
/// distinguish "following" from "anchored, and currently tall enough to
|
|
/// reach". The next rows the child prints would then push the anchored
|
|
/// view back into history with nothing to explain it.
|
|
///
|
|
/// **Only when no selection is active** (R1-8): a historical selection
|
|
/// froze this anchor on purpose, and growth must not yank the user's
|
|
/// region out from under them. `scroll_view` already handles the
|
|
/// scroll-driven arm (`next == tail_start`), so during ordinary
|
|
/// scrolling `scroll_offset == 0` implies follow is already armed —
|
|
/// which makes this rule fire on exactly the growth (and shrink-back)
|
|
/// case it names, and be idempotent everywhere else.
|
|
fn rearm_follow_on_growth(
|
|
state: &mut TerminalViewState,
|
|
projection: BorrowedScreenProjection<'_>,
|
|
viewport_rows: u32,
|
|
) {
|
|
if state.top.is_none() || state.selection.is_some() || viewport_rows == 0 {
|
|
return;
|
|
}
|
|
let rows = retained_rows(projection);
|
|
if view_geometry(&rows, state, viewport_rows).scroll_offset == 0 {
|
|
state.top = None;
|
|
state.selection_froze_top = false;
|
|
}
|
|
}
|
|
|
|
fn normalize_state(state: &mut TerminalViewState, projection: BorrowedScreenProjection<'_>) {
|
|
if state
|
|
.alternate_active
|
|
.is_some_and(|active| active != projection.alternate_active)
|
|
{
|
|
state.top = None;
|
|
state.selection = None;
|
|
state.drag = None;
|
|
state.selection_froze_top = false;
|
|
}
|
|
state.alternate_active = Some(projection.alternate_active);
|
|
let rows = retained_rows(projection);
|
|
state.top = state.top.and_then(|anchor| clamp_or_clear(&rows, anchor));
|
|
state.selection = state.selection.and_then(|selection| {
|
|
let anchor = clamp_or_clear(&rows, selection.anchor)?;
|
|
let head = clamp_or_clear(&rows, selection.head)?;
|
|
let collapsed_by_clamp =
|
|
anchor == head && (anchor != selection.anchor || head != selection.head);
|
|
(!collapsed_by_clamp).then_some(TerminalSelection { anchor, head })
|
|
});
|
|
state.drag = state.drag.and_then(|anchor| clamp_or_clear(&rows, anchor));
|
|
if state.selection.is_none() {
|
|
state.drag = None;
|
|
state.selection_froze_top = false;
|
|
}
|
|
}
|
|
|
|
fn view_geometry(
|
|
rows: &RetainedRows<'_>,
|
|
state: &TerminalViewState,
|
|
viewport_rows: u32,
|
|
) -> ViewGeometry {
|
|
let viewport_rows = viewport_rows as usize;
|
|
let follow = state.top.is_none() && state.selection.is_none();
|
|
let tail_start = rows.len().saturating_sub(viewport_rows);
|
|
let start = if follow {
|
|
tail_start
|
|
} else {
|
|
state
|
|
.top
|
|
.and_then(|anchor| resolve_anchor(rows, anchor))
|
|
.map_or(tail_start, |resolved| resolved.row)
|
|
};
|
|
let top_padding = if follow && rows.len() < viewport_rows {
|
|
viewport_rows - rows.len()
|
|
} else {
|
|
0
|
|
};
|
|
let rows_after_view = rows
|
|
.len()
|
|
.saturating_sub(start.saturating_add(viewport_rows));
|
|
ViewGeometry {
|
|
start,
|
|
top_padding,
|
|
scroll_offset: u32::try_from(rows_after_view).unwrap_or(u32::MAX),
|
|
}
|
|
}
|
|
|
|
fn viewport_row(
|
|
geometry: &ViewGeometry,
|
|
viewport_rows: usize,
|
|
retained_row: usize,
|
|
) -> Option<usize> {
|
|
if retained_row < geometry.start {
|
|
return None;
|
|
}
|
|
let row = geometry
|
|
.top_padding
|
|
.saturating_add(retained_row - geometry.start);
|
|
(row < viewport_rows).then_some(row)
|
|
}
|
|
|
|
fn anchor_at(
|
|
rows: &RetainedRows<'_>,
|
|
geometry: &ViewGeometry,
|
|
viewport_size: CellSize,
|
|
coord: CellCoord,
|
|
) -> Option<LogicalCellAnchor> {
|
|
if coord.row >= viewport_size.rows || coord.col >= viewport_size.cols {
|
|
return None;
|
|
}
|
|
let viewport_row = coord.row as usize;
|
|
if viewport_row < geometry.top_padding {
|
|
return None;
|
|
}
|
|
let retained_row = geometry
|
|
.start
|
|
.saturating_add(viewport_row - geometry.top_padding);
|
|
let row = rows.get(retained_row)?;
|
|
if coord.col as usize >= row.cells.len() {
|
|
return None;
|
|
}
|
|
let col = canonical_col(row, coord.col as usize);
|
|
Some(LogicalCellAnchor {
|
|
logical_line_id: row.logical_line_id,
|
|
cell_offset: row.cell_offset.saturating_add(col as u32),
|
|
})
|
|
}
|
|
|
|
fn normalized_selection(
|
|
rows: &RetainedRows<'_>,
|
|
selection: TerminalSelection,
|
|
) -> Option<(ResolvedCell, ResolvedCell)> {
|
|
let mut start = resolve_anchor(rows, selection.anchor)?;
|
|
let mut end = resolve_anchor(rows, selection.head)?;
|
|
if (start.row, start.col) > (end.row, end.col) {
|
|
std::mem::swap(&mut start, &mut end);
|
|
}
|
|
Some((start, end))
|
|
}
|
|
|
|
fn glyph_width(row: &TerminalRow, col: usize) -> usize {
|
|
if col + 1 < row.cells.len() && matches!(row.cells[col + 1].glyph, Glyph::Continuation) {
|
|
2
|
|
} else {
|
|
1
|
|
}
|
|
}
|
|
|
|
fn project_snapshot(
|
|
buffer_id: BufferId,
|
|
viewport_size: CellSize,
|
|
projection: BorrowedScreenProjection<'_>,
|
|
state: &TerminalViewState,
|
|
pid: u32,
|
|
process: TerminalProcessState,
|
|
) -> TerminalSnapshot {
|
|
let rows = retained_rows(projection);
|
|
let geometry = view_geometry(&rows, state, viewport_size.rows);
|
|
let mut cells = vec![Cell::default(); viewport_size.area() as usize];
|
|
for (retained_row, row) in rows.iter().enumerate().skip(geometry.start) {
|
|
let Some(target_row) = viewport_row(&geometry, viewport_size.rows as usize, retained_row)
|
|
else {
|
|
continue;
|
|
};
|
|
let copy_cols = row.cells.len().min(viewport_size.cols as usize);
|
|
let target = target_row * viewport_size.cols as usize;
|
|
cells[target..target + copy_cols].clone_from_slice(&row.cells[..copy_cols]);
|
|
}
|
|
|
|
let selection = state
|
|
.selection
|
|
.and_then(|selection| normalized_selection(&rows, selection))
|
|
.map_or_else(Vec::new, |(start, end)| {
|
|
let mut spans = Vec::new();
|
|
for (retained_row, row) in rows.iter().enumerate().take(end.row + 1).skip(start.row) {
|
|
let Some(target_row) =
|
|
viewport_row(&geometry, viewport_size.rows as usize, retained_row)
|
|
else {
|
|
continue;
|
|
};
|
|
let start_col = if retained_row == start.row {
|
|
start.col
|
|
} else {
|
|
0
|
|
};
|
|
let end_col = if retained_row == end.row {
|
|
end.col.saturating_add(glyph_width(row, end.col))
|
|
} else {
|
|
row.cells.len()
|
|
}
|
|
.min(viewport_size.cols as usize);
|
|
if start_col < end_col && start_col < viewport_size.cols as usize {
|
|
spans.push(TerminalSelectionSpan {
|
|
row: target_row as u32,
|
|
start_col: start_col as u32,
|
|
end_col: end_col as u32,
|
|
});
|
|
}
|
|
}
|
|
spans
|
|
});
|
|
|
|
let cursor = if geometry.scroll_offset == 0 {
|
|
projection.cursor.and_then(|cursor| {
|
|
let retained_row = projection.history_len().saturating_add(cursor.row as usize);
|
|
let row = viewport_row(&geometry, viewport_size.rows as usize, retained_row)?;
|
|
(cursor.col < viewport_size.cols).then(|| CellCoord::new(row as u32, cursor.col))
|
|
})
|
|
} else {
|
|
None
|
|
};
|
|
|
|
TerminalSnapshot {
|
|
buffer_id,
|
|
size: viewport_size,
|
|
cells,
|
|
cursor,
|
|
title: projection.title.map(str::to_owned),
|
|
screen_generation: projection.generation,
|
|
selection,
|
|
scroll_offset: geometry.scroll_offset,
|
|
at_bottom: geometry.scroll_offset == 0,
|
|
pid,
|
|
process,
|
|
}
|
|
}
|
|
|
|
fn is_default_blank(cell: &Cell) -> bool {
|
|
matches!(cell.glyph, Glyph::Char(' '))
|
|
&& cell.style == Style::default()
|
|
&& cell.attachment.is_none()
|
|
}
|
|
|
|
fn copy_selection_bytes(rows: &RetainedRows<'_>, selection: TerminalSelection) -> Option<Vec<u8>> {
|
|
let (start, end) = normalized_selection(rows, selection)?;
|
|
let mut out = Vec::new();
|
|
for (row_index, row) in rows.iter().enumerate().take(end.row + 1).skip(start.row) {
|
|
let from = if row_index == start.row { start.col } else { 0 };
|
|
let mut to = if row_index == end.row {
|
|
end.col.saturating_add(glyph_width(row, end.col))
|
|
} else {
|
|
row.cells.len()
|
|
};
|
|
while to > from && is_default_blank(&row.cells[to - 1]) {
|
|
to -= 1;
|
|
}
|
|
for cell in &row.cells[from..to] {
|
|
match &cell.glyph {
|
|
Glyph::Char(ch) => {
|
|
let mut bytes = [0; 4];
|
|
out.extend_from_slice(ch.encode_utf8(&mut bytes).as_bytes());
|
|
}
|
|
Glyph::Cluster(bytes) => out.extend_from_slice(bytes),
|
|
Glyph::Continuation => {}
|
|
}
|
|
}
|
|
if row_index < end.row && !row.soft_wrapped {
|
|
out.push(b'\n');
|
|
}
|
|
}
|
|
Some(out)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::terminal::TerminalProcessState;
|
|
use crate::terminal::screen::ScreenProjection;
|
|
|
|
fn row(id: u64, offset: u32, text: &str, soft_wrapped: bool) -> TerminalRow {
|
|
TerminalRow {
|
|
cells: text
|
|
.chars()
|
|
.map(|ch| Cell {
|
|
glyph: Glyph::Char(ch),
|
|
style: Style::default(),
|
|
attachment: None,
|
|
})
|
|
.collect(),
|
|
logical_line_id: id,
|
|
cell_offset: offset,
|
|
soft_wrapped,
|
|
}
|
|
}
|
|
|
|
fn projection(history: Vec<TerminalRow>, visible_rows: Vec<TerminalRow>) -> ScreenProjection {
|
|
let cols = visible_rows.first().map_or(1, |row| row.cells.len() as u32);
|
|
ScreenProjection {
|
|
size: CellSize::new(visible_rows.len() as u32, cols),
|
|
alternate_active: false,
|
|
history,
|
|
visible_rows,
|
|
cursor: None,
|
|
title: Some("shell".into()),
|
|
generation: 7,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn tail_projection_pads_above_and_right_and_translates_cursor() {
|
|
let mut source = projection(
|
|
Vec::new(),
|
|
vec![row(1, 0, "abc", false), row(2, 0, "def", false)],
|
|
);
|
|
source.cursor = Some(CellCoord::new(1, 2));
|
|
let snapshot = project_snapshot(
|
|
BufferId::next(),
|
|
CellSize::new(4, 5),
|
|
source.as_borrowed(),
|
|
&TerminalViewState::default(),
|
|
42,
|
|
TerminalProcessState::Running,
|
|
);
|
|
assert_eq!(snapshot.cells.len(), 20);
|
|
assert!(
|
|
snapshot.cells[..10]
|
|
.iter()
|
|
.all(|cell| *cell == Cell::default())
|
|
);
|
|
assert_eq!(snapshot.cells[10].glyph, Glyph::Char('a'));
|
|
assert_eq!(snapshot.cells[13], Cell::default());
|
|
assert_eq!(snapshot.cells[15].glyph, Glyph::Char('d'));
|
|
assert_eq!(snapshot.cursor, Some(CellCoord::new(3, 2)));
|
|
assert!(snapshot.at_bottom);
|
|
assert_eq!(snapshot.scroll_offset, 0);
|
|
}
|
|
|
|
#[test]
|
|
fn frozen_top_is_geometrically_at_bottom_when_view_still_reaches_tail() {
|
|
let source = projection(
|
|
vec![row(1, 0, "aaa", false)],
|
|
vec![row(2, 0, "bbb", false), row(3, 0, "ccc", false)],
|
|
);
|
|
let state = TerminalViewState {
|
|
top: Some(LogicalCellAnchor {
|
|
logical_line_id: 1,
|
|
cell_offset: 0,
|
|
}),
|
|
selection: Some(TerminalSelection {
|
|
anchor: LogicalCellAnchor {
|
|
logical_line_id: 2,
|
|
cell_offset: 0,
|
|
},
|
|
head: LogicalCellAnchor {
|
|
logical_line_id: 2,
|
|
cell_offset: 1,
|
|
},
|
|
}),
|
|
..TerminalViewState::default()
|
|
};
|
|
let snapshot = project_snapshot(
|
|
BufferId::next(),
|
|
CellSize::new(3, 3),
|
|
source.as_borrowed(),
|
|
&state,
|
|
1,
|
|
TerminalProcessState::Running,
|
|
);
|
|
assert!(snapshot.at_bottom);
|
|
assert_eq!(snapshot.scroll_offset, 0);
|
|
}
|
|
|
|
#[test]
|
|
fn copy_joins_soft_wraps_trims_default_blanks_and_separates_hard_rows() {
|
|
let rows = [
|
|
row(1, 0, "ab ", true),
|
|
row(1, 3, "cd ", false),
|
|
row(2, 0, "e ", false),
|
|
];
|
|
let source = projection(Vec::new(), rows.into());
|
|
let retained = retained_rows(source.as_borrowed());
|
|
let bytes = copy_selection_bytes(
|
|
&retained,
|
|
TerminalSelection {
|
|
anchor: LogicalCellAnchor {
|
|
logical_line_id: 1,
|
|
cell_offset: 0,
|
|
},
|
|
head: LogicalCellAnchor {
|
|
logical_line_id: 2,
|
|
cell_offset: 2,
|
|
},
|
|
},
|
|
)
|
|
.expect("selection resolves");
|
|
assert_eq!(bytes, b"abcd\ne");
|
|
}
|
|
|
|
/// Stage 2 criteria 13 and 14. Every property here is one a second,
|
|
/// independently written whole-range walk would get wrong: a naive
|
|
/// walk emits a newline per physical row (breaking the soft wrap),
|
|
/// keeps trailing default blanks, and has to rediscover that history
|
|
/// precedes the visible screen. Asserting exact bytes is what makes
|
|
/// "it reuses the serializer" falsifiable.
|
|
#[test]
|
|
fn retained_copy_spans_history_joins_soft_wraps_and_trims_blanks() {
|
|
let source = projection(
|
|
vec![row(1, 0, "ab ", true), row(1, 3, "cd ", false)],
|
|
vec![row(2, 0, "e ", false), row(3, 0, " ", false)],
|
|
);
|
|
let retained = retained_rows(source.as_borrowed());
|
|
let bytes = retained_bytes(&retained).expect("whole range resolves");
|
|
// `ab`+`cd` joined across the soft wrap; `e` on its own hard row;
|
|
// the all-blank final row trimmed to nothing but still separated.
|
|
assert_eq!(bytes, b"abcd\ne\n");
|
|
}
|
|
|
|
/// The whole-range selection must not depend on a view existing, and
|
|
/// must agree with an explicit full-span selection through the public
|
|
/// serializer — the anti-drift half of criterion 13.
|
|
#[test]
|
|
fn retained_copy_agrees_with_an_explicit_full_span_selection() {
|
|
let source = projection(
|
|
vec![row(1, 0, "aaa", false)],
|
|
vec![row(2, 0, "bbb", false), row(3, 0, "ccc", false)],
|
|
);
|
|
let retained = retained_rows(source.as_borrowed());
|
|
let explicit = copy_selection_bytes(
|
|
&retained,
|
|
TerminalSelection {
|
|
anchor: LogicalCellAnchor {
|
|
logical_line_id: 1,
|
|
cell_offset: 0,
|
|
},
|
|
head: LogicalCellAnchor {
|
|
logical_line_id: 3,
|
|
cell_offset: 2,
|
|
},
|
|
},
|
|
)
|
|
.expect("explicit selection resolves");
|
|
assert_eq!(retained_bytes(&retained).expect("whole range"), explicit);
|
|
assert_eq!(explicit, b"aaa\nbbb\nccc");
|
|
}
|
|
|
|
/// A wide glyph must be copied once across the whole range too, not
|
|
/// once per cell it occupies.
|
|
#[test]
|
|
fn retained_copy_emits_a_wide_glyph_once() {
|
|
let wide = TerminalRow {
|
|
cells: vec![
|
|
Cell {
|
|
glyph: Glyph::Char('界'),
|
|
style: Style::default(),
|
|
attachment: None,
|
|
},
|
|
Cell {
|
|
glyph: Glyph::Continuation,
|
|
style: Style::default(),
|
|
attachment: None,
|
|
},
|
|
Cell::default(),
|
|
],
|
|
logical_line_id: 9,
|
|
cell_offset: 0,
|
|
soft_wrapped: false,
|
|
};
|
|
let source = projection(Vec::new(), vec![wide]);
|
|
let retained = retained_rows(source.as_borrowed());
|
|
assert_eq!(
|
|
retained_bytes(&retained).expect("whole range"),
|
|
"界".as_bytes()
|
|
);
|
|
}
|
|
|
|
/// A session with nothing retained yields `None` rather than an empty
|
|
/// string, so the caller can tell "no terminal" from "empty terminal".
|
|
#[test]
|
|
fn retained_copy_of_zero_width_rows_is_none() {
|
|
let source = projection(Vec::new(), vec![row(1, 0, "", false)]);
|
|
let retained = retained_rows(source.as_borrowed());
|
|
assert!(retained_bytes(&retained).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn wide_continuation_canonicalizes_to_lead_and_copies_once() {
|
|
let wide = TerminalRow {
|
|
cells: vec![
|
|
Cell {
|
|
glyph: Glyph::Char('界'),
|
|
style: Style::default(),
|
|
attachment: None,
|
|
},
|
|
Cell {
|
|
glyph: Glyph::Continuation,
|
|
style: Style::default(),
|
|
attachment: None,
|
|
},
|
|
Cell::default(),
|
|
],
|
|
logical_line_id: 9,
|
|
cell_offset: 0,
|
|
soft_wrapped: false,
|
|
};
|
|
let source = projection(Vec::new(), vec![wide]);
|
|
let retained = retained_rows(source.as_borrowed());
|
|
let continuation = resolve_anchor(
|
|
&retained,
|
|
LogicalCellAnchor {
|
|
logical_line_id: 9,
|
|
cell_offset: 1,
|
|
},
|
|
)
|
|
.expect("continuation resolves");
|
|
assert_eq!(continuation.col, 0);
|
|
let bytes = copy_selection_bytes(
|
|
&retained,
|
|
TerminalSelection {
|
|
anchor: LogicalCellAnchor {
|
|
logical_line_id: 9,
|
|
cell_offset: 0,
|
|
},
|
|
head: LogicalCellAnchor {
|
|
logical_line_id: 9,
|
|
cell_offset: 1,
|
|
},
|
|
},
|
|
)
|
|
.expect("wide selection resolves");
|
|
assert_eq!(bytes, "界".as_bytes());
|
|
}
|
|
|
|
#[test]
|
|
fn partially_evicted_wrapped_anchor_clamps_to_first_surviving_cell() {
|
|
let source = projection(
|
|
vec![row(7, 4, "tail", true)],
|
|
vec![row(8, 0, "next", false)],
|
|
);
|
|
let first_survivor = LogicalCellAnchor {
|
|
logical_line_id: 7,
|
|
cell_offset: 4,
|
|
};
|
|
let mut state = TerminalViewState {
|
|
top: Some(LogicalCellAnchor {
|
|
logical_line_id: 7,
|
|
cell_offset: 1,
|
|
}),
|
|
selection: Some(TerminalSelection {
|
|
anchor: LogicalCellAnchor {
|
|
logical_line_id: 7,
|
|
cell_offset: 2,
|
|
},
|
|
head: LogicalCellAnchor {
|
|
logical_line_id: 8,
|
|
cell_offset: 1,
|
|
},
|
|
}),
|
|
..TerminalViewState::default()
|
|
};
|
|
|
|
normalize_state(&mut state, source.as_borrowed());
|
|
|
|
assert_eq!(state.top, Some(first_survivor));
|
|
assert_eq!(
|
|
state.selection,
|
|
Some(TerminalSelection {
|
|
anchor: first_survivor,
|
|
head: LogicalCellAnchor {
|
|
logical_line_id: 8,
|
|
cell_offset: 1,
|
|
},
|
|
})
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn alternate_switch_clears_view_anchors_and_selection() {
|
|
let source = ScreenProjection {
|
|
size: CellSize::new(1, 3),
|
|
alternate_active: true,
|
|
history: Vec::new(),
|
|
visible_rows: vec![row(10, 0, "alt", false)],
|
|
cursor: None,
|
|
title: None,
|
|
generation: 2,
|
|
};
|
|
let mut state = TerminalViewState {
|
|
top: Some(LogicalCellAnchor {
|
|
logical_line_id: 1,
|
|
cell_offset: 0,
|
|
}),
|
|
selection: Some(TerminalSelection {
|
|
anchor: LogicalCellAnchor {
|
|
logical_line_id: 1,
|
|
cell_offset: 0,
|
|
},
|
|
head: LogicalCellAnchor {
|
|
logical_line_id: 1,
|
|
cell_offset: 1,
|
|
},
|
|
}),
|
|
alternate_active: Some(false),
|
|
..TerminalViewState::default()
|
|
};
|
|
normalize_state(&mut state, source.as_borrowed());
|
|
assert_eq!(state.top, None);
|
|
assert_eq!(state.selection, None);
|
|
assert_eq!(state.alternate_active, Some(true));
|
|
}
|
|
}
|