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:
parent
0a846d91ce
commit
0dacac7e8d
991
src/editor.rs
991
src/editor.rs
File diff suppressed because it is too large
Load Diff
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue