feat(vterm): compose terminal views in the TUI

Render exact per-frontend terminal snapshots inside split-window content rects,
route terminal key, paste, focus, and mouse input through durable controllers,
and preserve logical row anchors across reflow, selection, and scrollback.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Levi Neuwirth 2026-07-21 19:21:50 -04:00
parent 0a846d91ce
commit 0dacac7e8d
4 changed files with 1673 additions and 235 deletions

File diff suppressed because it is too large Load Diff

View File

@ -348,8 +348,11 @@ impl Frontend {
let payload = format!("\x1b]52;c;{}\x07", osc52_base64(data));
queue!(self.out, Print(payload))?;
}
InstanceMessage::Signal(InstanceSignal::Bell) => {
queue!(self.out, Print("\x07"))?;
}
InstanceMessage::ModeLine(_)
// Bell / window-title Signals stay reserved for v0.3.
// Window-title requests remain metadata-only.
| InstanceMessage::Signal(_)
| InstanceMessage::Goodbye(_)
// T M10.5: CrdtOp's wire shape exists; the v1.0 TUI doesn't

View File

@ -18,7 +18,10 @@
use crate::cell::{Cell, CellGrid, CellSize, diff};
use crate::editor::{EditorState, paint_frame};
use crate::protocol::{CursorState, InstanceMessage};
use crate::protocol::{CursorState, FrontendId, InstanceMessage};
use crate::terminal::TerminalSnapshot;
use crate::window::WindowId;
use std::collections::HashMap;
/// Owns the cell buffers and runs the paint-and-diff cycle.
pub struct RenderState {
@ -95,6 +98,8 @@ impl RenderState {
pub fn render_frame(
&mut self,
state: &EditorState,
frontend_id: FrontendId,
terminal_snapshots: &HashMap<WindowId, TerminalSnapshot>,
other_presences: &[crate::overlay_paint::OtherPresence],
) -> Vec<InstanceMessage> {
if self.size.rows < 2 || self.size.cols == 0 {
@ -107,7 +112,13 @@ impl RenderState {
stride: self.size.cols,
size: self.size,
};
let coord = paint_frame(state, &mut grid, self.size);
let coord = paint_frame(
state,
frontend_id,
terminal_snapshots,
&mut grid,
self.size,
);
// T M10.9 — overlay paint after main paint, before diff.
// Modifies cells in `next`; diff captures the changes
// as ordinary style updates.
@ -186,7 +197,7 @@ mod tests {
#[test]
fn render_returns_cell_delta_and_cursor() {
let mut r = RenderState::new(CellSize::new(24, 80));
let msgs = r.render_frame(&empty_state(), &[]);
let msgs = r.render_frame(&empty_state(), FrontendId::LOCAL, &HashMap::new(), &[]);
assert_eq!(msgs.len(), 2);
assert!(matches!(msgs[0], InstanceMessage::CellDelta { .. }));
assert!(matches!(msgs[1], InstanceMessage::Cursor(_)));
@ -195,7 +206,7 @@ mod tests {
#[test]
fn first_frame_is_full_grid_sync() {
let mut r = RenderState::new(CellSize::new(24, 80));
let msgs = r.render_frame(&empty_state(), &[]);
let msgs = r.render_frame(&empty_state(), FrontendId::LOCAL, &HashMap::new(), &[]);
match &msgs[0] {
InstanceMessage::CellDelta { full_grid, .. } => assert!(*full_grid),
_ => panic!("expected CellDelta first"),
@ -205,8 +216,8 @@ mod tests {
#[test]
fn second_frame_is_differential() {
let mut r = RenderState::new(CellSize::new(24, 80));
let _ = r.render_frame(&empty_state(), &[]);
let msgs = r.render_frame(&empty_state(), &[]);
let _ = r.render_frame(&empty_state(), FrontendId::LOCAL, &HashMap::new(), &[]);
let msgs = r.render_frame(&empty_state(), FrontendId::LOCAL, &HashMap::new(), &[]);
match &msgs[0] {
InstanceMessage::CellDelta { full_grid, .. } => assert!(!*full_grid),
_ => panic!("expected CellDelta first"),
@ -217,8 +228,8 @@ mod tests {
fn unchanged_state_produces_empty_spans_after_first_frame() {
let state = empty_state();
let mut r = RenderState::new(CellSize::new(24, 80));
let _ = r.render_frame(&state, &[]);
let msgs = r.render_frame(&state, &[]);
let _ = r.render_frame(&state, FrontendId::LOCAL, &HashMap::new(), &[]);
let msgs = r.render_frame(&state, FrontendId::LOCAL, &HashMap::new(), &[]);
match &msgs[0] {
InstanceMessage::CellDelta { spans, .. } => assert!(
spans.is_empty(),
@ -231,7 +242,7 @@ mod tests {
#[test]
fn resize_reallocates_and_flags_full_grid() {
let mut r = RenderState::new(CellSize::new(24, 80));
let _ = r.render_frame(&empty_state(), &[]);
let _ = r.render_frame(&empty_state(), FrontendId::LOCAL, &HashMap::new(), &[]);
assert!(!r.needs_full_grid);
r.resize(CellSize::new(40, 120));
@ -240,7 +251,7 @@ mod tests {
assert_eq!(r.next.len(), 40 * 120);
assert!(r.needs_full_grid);
let msgs = r.render_frame(&empty_state(), &[]);
let msgs = r.render_frame(&empty_state(), FrontendId::LOCAL, &HashMap::new(), &[]);
match &msgs[0] {
InstanceMessage::CellDelta { full_grid, .. } => assert!(*full_grid),
_ => unreachable!(),
@ -250,7 +261,7 @@ mod tests {
#[test]
fn resize_to_same_size_is_noop() {
let mut r = RenderState::new(CellSize::new(24, 80));
let _ = r.render_frame(&empty_state(), &[]);
let _ = r.render_frame(&empty_state(), FrontendId::LOCAL, &HashMap::new(), &[]);
assert!(!r.needs_full_grid);
r.resize(CellSize::new(24, 80));
// No reallocation, no full-grid flip.
@ -260,7 +271,7 @@ mod tests {
#[test]
fn force_full_grid_resync_flips_flag() {
let mut r = RenderState::new(CellSize::new(24, 80));
let _ = r.render_frame(&empty_state(), &[]);
let _ = r.render_frame(&empty_state(), FrontendId::LOCAL, &HashMap::new(), &[]);
assert!(!r.needs_full_grid);
r.force_full_grid_resync();
assert!(r.needs_full_grid);
@ -270,16 +281,22 @@ mod tests {
fn too_small_grid_returns_empty_messages() {
// rows < 2 means we can't paint a text-area + status row.
let mut r = RenderState::new(CellSize::new(1, 80));
assert!(r.render_frame(&empty_state(), &[]).is_empty());
assert!(
r.render_frame(&empty_state(), FrontendId::LOCAL, &HashMap::new(), &[])
.is_empty()
);
let mut r = RenderState::new(CellSize::new(24, 0));
assert!(r.render_frame(&empty_state(), &[]).is_empty());
assert!(
r.render_frame(&empty_state(), FrontendId::LOCAL, &HashMap::new(), &[])
.is_empty()
);
}
#[test]
fn cursor_message_carries_coord_when_paint_returns_one() {
let mut r = RenderState::new(CellSize::new(24, 80));
let msgs = r.render_frame(&empty_state(), &[]);
let msgs = r.render_frame(&empty_state(), FrontendId::LOCAL, &HashMap::new(), &[]);
match &msgs[1] {
InstanceMessage::Cursor(Some(cs)) => {
assert!(cs.visible);
@ -309,7 +326,7 @@ mod tests {
// Criterion 1: the first frame after construction is a full-grid
// CellDelta carrying every non-default cell.
let mut r = RenderState::new(CellSize::new(24, 80));
let msgs = r.render_frame(&empty_state(), &[]);
let msgs = r.render_frame(&empty_state(), FrontendId::LOCAL, &HashMap::new(), &[]);
match &msgs[0] {
InstanceMessage::CellDelta { full_grid, spans } => {
assert!(*full_grid, "first frame must be flagged full_grid=true");
@ -335,7 +352,7 @@ mod tests {
let mut state = EditorState::new();
let mut r = RenderState::new(size);
// Seat the prev buffer.
let _ = r.render_frame(&state, &[]);
let _ = r.render_frame(&state, FrontendId::LOCAL, &HashMap::new(), &[]);
// Single character insert.
state.dispatch_key(
@ -347,7 +364,7 @@ mod tests {
state: KeyEventState::empty(),
},
);
let msgs = r.render_frame(&state, &[]);
let msgs = r.render_frame(&state, FrontendId::LOCAL, &HashMap::new(), &[]);
match &msgs[0] {
InstanceMessage::CellDelta { full_grid, spans } => {
assert!(!*full_grid, "differential frame must not flag full_grid");
@ -374,7 +391,7 @@ mod tests {
let mut r = RenderState::new(size);
// First render: seats prev with the painted frame.
let first = r.render_frame(&empty_state(), &[]);
let first = r.render_frame(&empty_state(), FrontendId::LOCAL, &HashMap::new(), &[]);
let baseline_changed: usize = match &first[0] {
InstanceMessage::CellDelta { spans, .. } => spans.iter().map(|s| s.cells.len()).sum(),
_ => unreachable!(),
@ -383,7 +400,7 @@ mod tests {
// A second render with no state change normally produces zero
// spans (the state matches prev exactly).
let unchanged = r.render_frame(&empty_state(), &[]);
let unchanged = r.render_frame(&empty_state(), FrontendId::LOCAL, &HashMap::new(), &[]);
match &unchanged[0] {
InstanceMessage::CellDelta { full_grid, spans } => {
assert!(!*full_grid);
@ -396,7 +413,7 @@ mod tests {
// what's on screen. force_full_grid_resync flags the next frame
// for full sync.
r.force_full_grid_resync();
let resync = r.render_frame(&empty_state(), &[]);
let resync = r.render_frame(&empty_state(), FrontendId::LOCAL, &HashMap::new(), &[]);
match &resync[0] {
InstanceMessage::CellDelta { full_grid, spans } => {
assert!(*full_grid, "post-resync frame must be full_grid=true");

View File

@ -1,10 +1,18 @@
//! Per-frontend terminal viewport and selection identities.
//! Per-frontend terminal viewport, selection, copy, and bell projection.
//!
//! These types identify projections over one [`super::screen::TerminalScreen`].
//! They never own or mirror terminal cells.
//! 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::{ScreenProjection, TerminalModes, TerminalRow};
use crate::terminal::session::{
TerminalManager, TerminalSelectionSpan, TerminalSnapshot,
};
use crate::terminal::{
MAX_TERMINAL_COLS, MAX_TERMINAL_ROWS, MAX_TERMINAL_VISIBLE_CELLS,
};
use crate::window::WindowId;
/// One frontend/window projection of a terminal session.
@ -48,6 +56,17 @@ pub struct TerminalSelection {
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 {
@ -57,6 +76,10 @@ pub struct TerminalViewState {
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.
@ -84,3 +107,827 @@ impl TerminalController {
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();
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()
});
normalize_state(state, &projection);
state.viewport_size = Some(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();
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[next]))
};
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()))
}
/// Return fresh geometric status for one registered view.
#[must_use]
pub fn view_status(&mut self, key: TerminalViewKey) -> Option<TerminalViewStatus> {
let projection = self.sessions.get(&key.buffer_id)?.screen.projection();
let state = self.views.get_mut(&key)?;
normalize_state(state, &projection);
let rows = retained_rows(&projection);
let size = state.viewport_size?;
let geometry = view_geometry(&rows, state, size.rows);
Some(TerminalViewStatus {
at_bottom: geometry.scroll_offset == 0,
scroll_offset: geometry.scroll_offset,
selection: state.selection.is_some(),
})
}
/// 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();
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)
}
/// 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();
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)
.copied()
.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();
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;
};
let changed = state.selection.take().is_some() || state.drag.take().is_some();
changed
}
/// 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,
}
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: &ScreenProjection) -> Vec<&TerminalRow> {
projection
.history
.iter()
.chain(projection.visible_rows.iter())
.collect()
}
fn row_lead(row: &TerminalRow) -> LogicalCellAnchor {
LogicalCellAnchor {
logical_line_id: row.logical_line_id,
cell_offset: row.cell_offset,
}
}
fn resolve_anchor(rows: &[&TerminalRow], 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: &[&TerminalRow], resolved: ResolvedCell) -> LogicalCellAnchor {
let row = rows[resolved.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: &[&TerminalRow],
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).then(|| row_lead(first))
}
fn normalize_state(state: &mut TerminalViewState, projection: &ScreenProjection) {
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: &[&TerminalRow],
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: &[&TerminalRow],
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: &[&TerminalRow],
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: &ScreenProjection,
state: &TerminalViewState,
pid: u32,
process: crate::terminal::session::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 in geometry.start..rows.len() {
let Some(target_row) =
viewport_row(&geometry, viewport_size.rows as usize, retained_row)
else {
continue;
};
let row = rows[retained_row];
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 in start.row..=end.row {
let Some(target_row) =
viewport_row(&geometry, viewport_size.rows as usize, retained_row)
else {
continue;
};
let row = rows[retained_row];
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.clone(),
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: &[&TerminalRow],
selection: TerminalSelection,
) -> Option<Vec<u8>> {
let (start, end) = normalized_selection(rows, selection)?;
let mut out = Vec::new();
for row_index in start.row..=end.row {
let row = rows[row_index];
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::session::TerminalProcessState;
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,
&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,
&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 = vec![
row(1, 0, "ab ", true),
row(1, 3, "cd ", false),
row(2, 0, "e ", false),
];
let refs: Vec<&TerminalRow> = rows.iter().collect();
let bytes = copy_selection_bytes(
&refs,
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");
}
#[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 refs = vec![&wide];
let continuation = resolve_anchor(
&refs,
LogicalCellAnchor {
logical_line_id: 9,
cell_offset: 1,
},
)
.expect("continuation resolves");
assert_eq!(continuation.col, 0);
let bytes = copy_selection_bytes(
&refs,
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 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);
assert_eq!(state.top, None);
assert_eq!(state.selection, None);
assert_eq!(state.alternate_active, Some(true));
}
}