fix(vterm): honor terminal escape and view contracts
Require the fixed C-c escape before editor-local terminal bindings, reject context-implicit Lua operations from document windows, and make controller replacement atomic per frontend. Borrow screen rows during view projection instead of deep-cloning retained history, preserve view anchors through zero-area layouts, and remove redundant detach paths. Add focused child-input coverage for unescaped bound keys and C-c C-c, plus controller, zero-area, context-error, and clone-free projection assertions. Co-authored-by: OpenAI Codex <codex@openai.com>
This commit is contained in:
parent
6725e9a598
commit
8702791de9
|
|
@ -1302,7 +1302,6 @@ fn dispatcher_loop(
|
|||
last_active_buffer_sent.remove(fid);
|
||||
terminal_bell_baselines.remove(fid);
|
||||
editor.detach_frontend_input(*fid);
|
||||
editor.terminal_manager.borrow_mut().detach_frontend(*fid);
|
||||
session_registry.unregister_session(*fid);
|
||||
editor
|
||||
.statusline_registry
|
||||
|
|
@ -1743,10 +1742,6 @@ fn handle_dispatcher_event(
|
|||
last_active_buffer_sent.remove(&frontend_id);
|
||||
terminal_bell_baselines.remove(&frontend_id);
|
||||
editor.detach_frontend_input(frontend_id);
|
||||
editor
|
||||
.terminal_manager
|
||||
.borrow_mut()
|
||||
.detach_frontend(frontend_id);
|
||||
session_registry.unregister_session(frontend_id);
|
||||
editor
|
||||
.statusline_registry
|
||||
|
|
|
|||
|
|
@ -825,17 +825,6 @@ impl EditorState {
|
|||
.dispatchers
|
||||
.get(&frontend_id)
|
||||
.is_some_and(|state| state.terminal_escape);
|
||||
let terminal_local_binding = terminal_key.is_some_and(|view_key| {
|
||||
chord.is_some_and(|chord| {
|
||||
let stack = self.lua_host.keymaps().borrow();
|
||||
stack.buffers.get(&view_key.buffer_id).is_some_and(|map| {
|
||||
!matches!(
|
||||
map.lookup(&[chord]),
|
||||
crate::keymap_tree::Resolution::Unbound
|
||||
)
|
||||
})
|
||||
})
|
||||
});
|
||||
if let Some(view_key) = terminal_key {
|
||||
if escaped {
|
||||
self.dispatchers
|
||||
|
|
@ -856,23 +845,21 @@ impl EditorState {
|
|||
self.claim_terminal_controller(view_key);
|
||||
return;
|
||||
}
|
||||
if !terminal_local_binding {
|
||||
let Some((terminal_key, modifiers)) = terminal_key_from_crossterm(key) else {
|
||||
return;
|
||||
};
|
||||
let modes = self
|
||||
.terminal_manager
|
||||
.borrow()
|
||||
.modes_for_view(view_key)
|
||||
.unwrap_or_default();
|
||||
if let Some(bytes) =
|
||||
crate::terminal::input::encode_key(terminal_key, modifiers, modes)
|
||||
{
|
||||
self.claim_terminal_controller(view_key);
|
||||
self.send_terminal_bytes(view_key.buffer_id, &bytes);
|
||||
}
|
||||
let Some((terminal_key, modifiers)) = terminal_key_from_crossterm(key) else {
|
||||
return;
|
||||
};
|
||||
let modes = self
|
||||
.terminal_manager
|
||||
.borrow()
|
||||
.modes_for_view(view_key)
|
||||
.unwrap_or_default();
|
||||
if let Some(bytes) =
|
||||
crate::terminal::input::encode_key(terminal_key, modifiers, modes)
|
||||
{
|
||||
self.claim_terminal_controller(view_key);
|
||||
self.send_terminal_bytes(view_key.buffer_id, &bytes);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -954,11 +941,6 @@ impl EditorState {
|
|||
|
||||
fn claim_terminal_controller(&self, key: TerminalViewKey) {
|
||||
let mut manager = self.terminal_manager.borrow_mut();
|
||||
if let Some(previous) = manager.controller_view_for_frontend(key.frontend_id)
|
||||
&& previous != key
|
||||
{
|
||||
let _ = manager.release_controller(previous);
|
||||
}
|
||||
let _ = manager.register_view(key);
|
||||
let _ = manager.claim_controller(key);
|
||||
}
|
||||
|
|
@ -1070,6 +1052,7 @@ impl EditorState {
|
|||
.get(&key.window_id)
|
||||
.copied()
|
||||
else {
|
||||
let _ = self.terminal_manager.borrow_mut().release_controller(key);
|
||||
return false;
|
||||
};
|
||||
placement.content
|
||||
|
|
@ -1115,19 +1098,33 @@ impl EditorState {
|
|||
let core = self.core.borrow();
|
||||
let placements = window_placements(&core, frontend_id, term_size);
|
||||
let mut live = HashSet::new();
|
||||
if let Some(view) = core.views.get(&frontend_id) {
|
||||
for window_id in view.layout.iter_ids() {
|
||||
let Some(window) = core.windows.get(&window_id) else {
|
||||
continue;
|
||||
};
|
||||
if self.terminal_manager.borrow().is_terminal(window.buffer_id) {
|
||||
live.insert(TerminalViewKey::new(
|
||||
frontend_id,
|
||||
window_id,
|
||||
window.buffer_id,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut sizes = Vec::new();
|
||||
for (window_id, placement) in placements {
|
||||
let Some(window) = core.windows.get(&window_id) else {
|
||||
continue;
|
||||
};
|
||||
if placement.content.size.rows == 0 || placement.content.size.cols == 0 {
|
||||
let key = TerminalViewKey::new(frontend_id, window_id, window.buffer_id);
|
||||
if !live.contains(&key)
|
||||
|| placement.content.size.rows == 0
|
||||
|| placement.content.size.cols == 0
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let key = TerminalViewKey::new(frontend_id, window_id, window.buffer_id);
|
||||
if self.terminal_manager.borrow().is_terminal(window.buffer_id) {
|
||||
live.insert(key);
|
||||
sizes.push((key, placement.content.size));
|
||||
}
|
||||
sizes.push((key, placement.content.size));
|
||||
}
|
||||
(live, sizes)
|
||||
};
|
||||
|
|
@ -1155,10 +1152,14 @@ impl EditorState {
|
|||
{
|
||||
messages.push(InstanceMessage::Signal(InstanceSignal::Bell));
|
||||
}
|
||||
if let Some((target, bytes)) = self.core.borrow_mut().take_pending_clipboard()
|
||||
&& target == frontend_id
|
||||
{
|
||||
messages.push(InstanceMessage::Signal(InstanceSignal::Clipboard(bytes)));
|
||||
if let Some((target, bytes)) = self.core.borrow_mut().take_pending_clipboard() {
|
||||
debug_assert_eq!(
|
||||
target, frontend_id,
|
||||
"local signal drain received a non-local clipboard target"
|
||||
);
|
||||
if target == frontend_id {
|
||||
messages.push(InstanceMessage::Signal(InstanceSignal::Clipboard(bytes)));
|
||||
}
|
||||
}
|
||||
messages
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8162,6 +8162,7 @@ fn terminal_command_frontend(lua: &Lua, core: &SharedCore) -> crate::protocol::F
|
|||
fn active_terminal_view_key(
|
||||
lua: &Lua,
|
||||
core: &SharedCore,
|
||||
manager: &crate::terminal::SharedTerminalManager,
|
||||
operation: &str,
|
||||
) -> mlua::Result<crate::terminal::TerminalViewKey> {
|
||||
let frontend_id = lua
|
||||
|
|
@ -8178,6 +8179,11 @@ fn active_terminal_view_key(
|
|||
"pmacs.terminal.{operation}: invoking frontend has no active window"
|
||||
))
|
||||
})?;
|
||||
if !manager.borrow().is_terminal(window.buffer_id) {
|
||||
return Err(mlua::Error::external(format!(
|
||||
"pmacs.terminal.{operation}: invoking frontend's active window is not a terminal"
|
||||
)));
|
||||
}
|
||||
Ok(crate::terminal::TerminalViewKey::new(
|
||||
frontend_id,
|
||||
core.views
|
||||
|
|
@ -8417,7 +8423,7 @@ fn install_terminal(
|
|||
}
|
||||
});
|
||||
let core = terminal_shared_core(lua, "scroll")?;
|
||||
let key = active_terminal_view_key(lua, &core, "scroll")?;
|
||||
let key = active_terminal_view_key(lua, &core, &manager, "scroll")?;
|
||||
Ok(manager.borrow_mut().scroll_lines(key, lines))
|
||||
})?,
|
||||
)?;
|
||||
|
|
@ -8434,7 +8440,7 @@ fn install_terminal(
|
|||
)
|
||||
})?;
|
||||
let core = terminal_shared_core(lua, "_scroll_page")?;
|
||||
let key = active_terminal_view_key(lua, &core, "_scroll_page")?;
|
||||
let key = active_terminal_view_key(lua, &core, &manager, "_scroll_page")?;
|
||||
Ok(manager.borrow_mut().scroll_page(key, direction))
|
||||
})?,
|
||||
)?;
|
||||
|
|
@ -8446,7 +8452,7 @@ fn install_terminal(
|
|||
"scroll_to_bottom",
|
||||
lua.create_function(move |lua, ()| {
|
||||
let core = terminal_shared_core(lua, "scroll_to_bottom")?;
|
||||
let key = active_terminal_view_key(lua, &core, "scroll_to_bottom")?;
|
||||
let key = active_terminal_view_key(lua, &core, &manager, "scroll_to_bottom")?;
|
||||
Ok(manager.borrow_mut().scroll_to_bottom(key))
|
||||
})?,
|
||||
)?;
|
||||
|
|
@ -8458,7 +8464,7 @@ fn install_terminal(
|
|||
"copy_selection",
|
||||
lua.create_function(move |lua, ()| {
|
||||
let core = terminal_shared_core(lua, "copy_selection")?;
|
||||
let key = active_terminal_view_key(lua, &core, "copy_selection")?;
|
||||
let key = active_terminal_view_key(lua, &core, &manager, "copy_selection")?;
|
||||
let Some(bytes) = manager.borrow_mut().copy_selection(key) else {
|
||||
return Ok(false);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -116,6 +116,39 @@ pub struct ScreenProjection {
|
|||
pub generation: u64,
|
||||
}
|
||||
|
||||
/// Borrowed, publication-consistent row projection for in-process views.
|
||||
#[allow(missing_docs)]
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) struct BorrowedScreenProjection<'a> {
|
||||
pub alternate_active: bool,
|
||||
pub history_head: &'a [TerminalRow],
|
||||
pub history_tail: &'a [TerminalRow],
|
||||
pub visible_rows: &'a [TerminalRow],
|
||||
pub cursor: Option<CellCoord>,
|
||||
pub title: Option<&'a str>,
|
||||
pub generation: u64,
|
||||
}
|
||||
|
||||
impl BorrowedScreenProjection<'_> {
|
||||
pub(crate) fn history_len(self) -> usize {
|
||||
self.history_head.len() + self.history_tail.len()
|
||||
}
|
||||
}
|
||||
|
||||
impl ScreenProjection {
|
||||
pub(crate) fn as_borrowed(&self) -> BorrowedScreenProjection<'_> {
|
||||
BorrowedScreenProjection {
|
||||
alternate_active: self.alternate_active,
|
||||
history_head: &self.history,
|
||||
history_tail: &[],
|
||||
visible_rows: &self.visible_rows,
|
||||
cursor: self.cursor,
|
||||
title: self.title.as_deref(),
|
||||
generation: self.generation,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
struct Cursor {
|
||||
row: usize,
|
||||
|
|
@ -538,6 +571,30 @@ impl TerminalScreen {
|
|||
}
|
||||
}
|
||||
|
||||
/// Borrow one publication-consistent row projection without cloning cells.
|
||||
pub(crate) fn projection_ref(&self) -> BorrowedScreenProjection<'_> {
|
||||
if self.modes.synchronized_output {
|
||||
return self.published.as_borrowed();
|
||||
}
|
||||
let (history_head, history_tail) = if self.alt_active {
|
||||
(&[][..], &[][..])
|
||||
} else {
|
||||
self.main.history.as_slices()
|
||||
};
|
||||
BorrowedScreenProjection {
|
||||
alternate_active: self.alt_active,
|
||||
history_head,
|
||||
history_tail,
|
||||
visible_rows: &self.active().rows,
|
||||
cursor: self
|
||||
.modes
|
||||
.cursor_visible
|
||||
.then(|| CellCoord::new(self.cursor.row as u32, self.cursor.col as u32)),
|
||||
title: self.title.as_deref(),
|
||||
generation: self.generation,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn modes(&self) -> TerminalModes {
|
||||
self.modes
|
||||
}
|
||||
|
|
@ -1768,6 +1825,30 @@ mod tests {
|
|||
assert!(s.alternate_active());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn borrowed_projection_reuses_live_and_published_row_storage() {
|
||||
let mut s = screen(2, 4);
|
||||
s.apply_event(AnsiEvent::Text("main".into()));
|
||||
s.apply_event(AnsiEvent::LineFeed);
|
||||
s.apply_event(AnsiEvent::LineFeed);
|
||||
let (live_history, _) = s.main.history.as_slices();
|
||||
let live_history_ptr = live_history.as_ptr();
|
||||
let live_visible_ptr = s.main.rows.as_ptr();
|
||||
let projection = s.projection_ref();
|
||||
assert_eq!(projection.history_head.as_ptr(), live_history_ptr);
|
||||
assert_eq!(projection.visible_rows.as_ptr(), live_visible_ptr);
|
||||
|
||||
s.apply_event(AnsiEvent::SetMode {
|
||||
mode: TerminalMode::SynchronizedOutput,
|
||||
enabled: true,
|
||||
});
|
||||
let published_history_ptr = s.published.history.as_ptr();
|
||||
let published_visible_ptr = s.published.visible_rows.as_ptr();
|
||||
let projection = s.projection_ref();
|
||||
assert_eq!(projection.history_head.as_ptr(), published_history_ptr);
|
||||
assert_eq!(projection.visible_rows.as_ptr(), published_visible_ptr);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn main_reflows_soft_wrap_and_alt_clips() {
|
||||
let mut s = screen(2, 4);
|
||||
|
|
|
|||
|
|
@ -406,10 +406,16 @@ impl TerminalManager {
|
|||
}
|
||||
|
||||
/// Give an exact registered view durable PTY control for its session.
|
||||
///
|
||||
/// A frontend controls at most one session. Claiming another registered
|
||||
/// view atomically releases that frontend's previous session first.
|
||||
pub fn claim_controller(&mut self, key: TerminalViewKey) -> bool {
|
||||
if !self.views.contains_key(&key) || !self.sessions.contains_key(&key.buffer_id) {
|
||||
return false;
|
||||
}
|
||||
self.controllers.retain(|buffer_id, controller| {
|
||||
controller.frontend_id != key.frontend_id || *buffer_id == key.buffer_id
|
||||
});
|
||||
self.controllers
|
||||
.insert(key.buffer_id, TerminalController::from_view(key));
|
||||
true
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
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::screen::{BorrowedScreenProjection, 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;
|
||||
|
|
@ -119,7 +119,7 @@ impl TerminalManager {
|
|||
return None;
|
||||
}
|
||||
let session = self.sessions.get(&key.buffer_id)?;
|
||||
let projection = session.screen.projection();
|
||||
let projection = session.screen.projection_ref();
|
||||
let pid = session.pid;
|
||||
let process = session.process.clone();
|
||||
let bell_count = session.screen.bell_count();
|
||||
|
|
@ -129,12 +129,12 @@ impl TerminalManager {
|
|||
last_bell_count: bell_count,
|
||||
..TerminalViewState::default()
|
||||
});
|
||||
normalize_state(state, &projection);
|
||||
normalize_state(state, projection);
|
||||
state.viewport_size = Some(viewport_size);
|
||||
Some(project_snapshot(
|
||||
key.buffer_id,
|
||||
viewport_size,
|
||||
&projection,
|
||||
projection,
|
||||
state,
|
||||
pid,
|
||||
process,
|
||||
|
|
@ -157,16 +157,16 @@ impl TerminalManager {
|
|||
let Some(session) = self.sessions.get(&key.buffer_id) else {
|
||||
return false;
|
||||
};
|
||||
let projection = session.screen.projection();
|
||||
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);
|
||||
normalize_state(state, projection);
|
||||
state.viewport_size = Some(viewport_size);
|
||||
let rows = retained_rows(&projection);
|
||||
let rows = retained_rows(projection);
|
||||
if rows.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
|
@ -184,7 +184,7 @@ impl TerminalManager {
|
|||
state.top = if next == tail_start && state.selection.is_none() {
|
||||
None
|
||||
} else {
|
||||
Some(row_lead(rows[next]))
|
||||
Some(row_lead(rows.get(next).expect("bounded retained row")))
|
||||
};
|
||||
true
|
||||
}
|
||||
|
|
@ -215,10 +215,10 @@ impl TerminalManager {
|
|||
/// 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 projection = self.sessions.get(&key.buffer_id)?.screen.projection_ref();
|
||||
let state = self.views.get_mut(&key)?;
|
||||
normalize_state(state, &projection);
|
||||
let rows = retained_rows(&projection);
|
||||
normalize_state(state, projection);
|
||||
let rows = retained_rows(projection);
|
||||
let size = state.viewport_size?;
|
||||
let geometry = view_geometry(&rows, state, size.rows);
|
||||
Some(TerminalViewStatus {
|
||||
|
|
@ -245,11 +245,11 @@ impl TerminalManager {
|
|||
#[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 projection = session.screen.projection_ref();
|
||||
let state = self.views.get_mut(&key)?;
|
||||
normalize_state(state, &projection);
|
||||
normalize_state(state, projection);
|
||||
let selection = state.selection?;
|
||||
let rows = retained_rows(&projection);
|
||||
let rows = retained_rows(projection);
|
||||
copy_selection_bytes(&rows, selection)
|
||||
}
|
||||
|
||||
|
|
@ -263,15 +263,15 @@ impl TerminalManager {
|
|||
let Some(session) = self.sessions.get(&key.buffer_id) else {
|
||||
return false;
|
||||
};
|
||||
let projection = session.screen.projection();
|
||||
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);
|
||||
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;
|
||||
|
|
@ -280,7 +280,7 @@ impl TerminalManager {
|
|||
};
|
||||
state.selection_froze_top = state.top.is_none();
|
||||
if state.top.is_none() {
|
||||
state.top = rows.get(geometry.start).copied().map(row_lead);
|
||||
state.top = rows.get(geometry.start).map(row_lead);
|
||||
}
|
||||
state.selection = Some(TerminalSelection {
|
||||
anchor,
|
||||
|
|
@ -300,15 +300,15 @@ impl TerminalManager {
|
|||
let Some(session) = self.sessions.get(&key.buffer_id) else {
|
||||
return false;
|
||||
};
|
||||
let projection = session.screen.projection();
|
||||
let projection = session.screen.projection_ref();
|
||||
let Some(state) = self.views.get_mut(&key) else {
|
||||
return false;
|
||||
};
|
||||
normalize_state(state, &projection);
|
||||
normalize_state(state, projection);
|
||||
if state.drag.is_none() {
|
||||
return false;
|
||||
}
|
||||
let rows = retained_rows(&projection);
|
||||
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;
|
||||
|
|
@ -412,6 +412,46 @@ struct ViewGeometry {
|
|||
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
|
||||
|
|
@ -420,12 +460,8 @@ fn valid_viewport(size: CellSize) -> bool {
|
|||
&& 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 retained_rows(projection: BorrowedScreenProjection<'_>) -> RetainedRows<'_> {
|
||||
RetainedRows { projection }
|
||||
}
|
||||
|
||||
fn row_lead(row: &TerminalRow) -> LogicalCellAnchor {
|
||||
|
|
@ -435,7 +471,7 @@ fn row_lead(row: &TerminalRow) -> LogicalCellAnchor {
|
|||
}
|
||||
}
|
||||
|
||||
fn resolve_anchor(rows: &[&TerminalRow], anchor: LogicalCellAnchor) -> Option<ResolvedCell> {
|
||||
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;
|
||||
|
|
@ -461,15 +497,15 @@ fn canonical_col(row: &TerminalRow, mut col: usize) -> usize {
|
|||
col
|
||||
}
|
||||
|
||||
fn anchor_for(rows: &[&TerminalRow], resolved: ResolvedCell) -> LogicalCellAnchor {
|
||||
let row = rows[resolved.row];
|
||||
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: &[&TerminalRow], anchor: LogicalCellAnchor) -> Option<LogicalCellAnchor> {
|
||||
fn clamp_or_clear(rows: &RetainedRows<'_>, anchor: LogicalCellAnchor) -> Option<LogicalCellAnchor> {
|
||||
if let Some(resolved) = resolve_anchor(rows, anchor) {
|
||||
return Some(anchor_for(rows, resolved));
|
||||
}
|
||||
|
|
@ -477,7 +513,7 @@ fn clamp_or_clear(rows: &[&TerminalRow], anchor: LogicalCellAnchor) -> Option<Lo
|
|||
(anchor.logical_line_id < first.logical_line_id).then(|| row_lead(first))
|
||||
}
|
||||
|
||||
fn normalize_state(state: &mut TerminalViewState, projection: &ScreenProjection) {
|
||||
fn normalize_state(state: &mut TerminalViewState, projection: BorrowedScreenProjection<'_>) {
|
||||
if state
|
||||
.alternate_active
|
||||
.is_some_and(|active| active != projection.alternate_active)
|
||||
|
|
@ -505,7 +541,7 @@ fn normalize_state(state: &mut TerminalViewState, projection: &ScreenProjection)
|
|||
}
|
||||
|
||||
fn view_geometry(
|
||||
rows: &[&TerminalRow],
|
||||
rows: &RetainedRows<'_>,
|
||||
state: &TerminalViewState,
|
||||
viewport_rows: u32,
|
||||
) -> ViewGeometry {
|
||||
|
|
@ -550,7 +586,7 @@ fn viewport_row(
|
|||
}
|
||||
|
||||
fn anchor_at(
|
||||
rows: &[&TerminalRow],
|
||||
rows: &RetainedRows<'_>,
|
||||
geometry: &ViewGeometry,
|
||||
viewport_size: CellSize,
|
||||
coord: CellCoord,
|
||||
|
|
@ -565,7 +601,7 @@ fn anchor_at(
|
|||
let retained_row = geometry
|
||||
.start
|
||||
.saturating_add(viewport_row - geometry.top_padding);
|
||||
let row = *rows.get(retained_row)?;
|
||||
let row = rows.get(retained_row)?;
|
||||
if coord.col as usize >= row.cells.len() {
|
||||
return None;
|
||||
}
|
||||
|
|
@ -577,7 +613,7 @@ fn anchor_at(
|
|||
}
|
||||
|
||||
fn normalized_selection(
|
||||
rows: &[&TerminalRow],
|
||||
rows: &RetainedRows<'_>,
|
||||
selection: TerminalSelection,
|
||||
) -> Option<(ResolvedCell, ResolvedCell)> {
|
||||
let mut start = resolve_anchor(rows, selection.anchor)?;
|
||||
|
|
@ -599,7 +635,7 @@ fn glyph_width(row: &TerminalRow, col: usize) -> usize {
|
|||
fn project_snapshot(
|
||||
buffer_id: BufferId,
|
||||
viewport_size: CellSize,
|
||||
projection: &ScreenProjection,
|
||||
projection: BorrowedScreenProjection<'_>,
|
||||
state: &TerminalViewState,
|
||||
pid: u32,
|
||||
process: crate::terminal::session::TerminalProcessState,
|
||||
|
|
@ -607,7 +643,7 @@ fn project_snapshot(
|
|||
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) {
|
||||
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;
|
||||
|
|
@ -622,7 +658,7 @@ fn project_snapshot(
|
|||
.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) {
|
||||
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 {
|
||||
|
|
@ -652,7 +688,7 @@ fn project_snapshot(
|
|||
|
||||
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 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))
|
||||
})
|
||||
|
|
@ -665,7 +701,7 @@ fn project_snapshot(
|
|||
size: viewport_size,
|
||||
cells,
|
||||
cursor,
|
||||
title: projection.title.clone(),
|
||||
title: projection.title.map(str::to_owned),
|
||||
screen_generation: projection.generation,
|
||||
selection,
|
||||
scroll_offset: geometry.scroll_offset,
|
||||
|
|
@ -681,10 +717,10 @@ fn is_default_blank(cell: &Cell) -> bool {
|
|||
&& cell.attachment.is_none()
|
||||
}
|
||||
|
||||
fn copy_selection_bytes(rows: &[&TerminalRow], selection: TerminalSelection) -> Option<Vec<u8>> {
|
||||
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) {
|
||||
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))
|
||||
|
|
@ -714,6 +750,7 @@ fn copy_selection_bytes(rows: &[&TerminalRow], selection: TerminalSelection) ->
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::terminal::screen::ScreenProjection;
|
||||
use crate::terminal::session::TerminalProcessState;
|
||||
|
||||
fn row(id: u64, offset: u32, text: &str, soft_wrapped: bool) -> TerminalRow {
|
||||
|
|
@ -755,7 +792,7 @@ mod tests {
|
|||
let snapshot = project_snapshot(
|
||||
BufferId::next(),
|
||||
CellSize::new(4, 5),
|
||||
&source,
|
||||
source.as_borrowed(),
|
||||
&TerminalViewState::default(),
|
||||
42,
|
||||
TerminalProcessState::Running,
|
||||
|
|
@ -800,7 +837,7 @@ mod tests {
|
|||
let snapshot = project_snapshot(
|
||||
BufferId::next(),
|
||||
CellSize::new(3, 3),
|
||||
&source,
|
||||
source.as_borrowed(),
|
||||
&state,
|
||||
1,
|
||||
TerminalProcessState::Running,
|
||||
|
|
@ -816,9 +853,10 @@ mod tests {
|
|||
row(1, 3, "cd ", false),
|
||||
row(2, 0, "e ", false),
|
||||
];
|
||||
let refs: Vec<&TerminalRow> = rows.iter().collect();
|
||||
let source = projection(Vec::new(), rows.into());
|
||||
let retained = retained_rows(source.as_borrowed());
|
||||
let bytes = copy_selection_bytes(
|
||||
&refs,
|
||||
&retained,
|
||||
TerminalSelection {
|
||||
anchor: LogicalCellAnchor {
|
||||
logical_line_id: 1,
|
||||
|
|
@ -854,9 +892,10 @@ mod tests {
|
|||
cell_offset: 0,
|
||||
soft_wrapped: false,
|
||||
};
|
||||
let refs = vec![&wide];
|
||||
let source = projection(Vec::new(), vec![wide]);
|
||||
let retained = retained_rows(source.as_borrowed());
|
||||
let continuation = resolve_anchor(
|
||||
&refs,
|
||||
&retained,
|
||||
LogicalCellAnchor {
|
||||
logical_line_id: 9,
|
||||
cell_offset: 1,
|
||||
|
|
@ -865,7 +904,7 @@ mod tests {
|
|||
.expect("continuation resolves");
|
||||
assert_eq!(continuation.col, 0);
|
||||
let bytes = copy_selection_bytes(
|
||||
&refs,
|
||||
&retained,
|
||||
TerminalSelection {
|
||||
anchor: LogicalCellAnchor {
|
||||
logical_line_id: 9,
|
||||
|
|
@ -910,7 +949,7 @@ mod tests {
|
|||
alternate_active: Some(false),
|
||||
..TerminalViewState::default()
|
||||
};
|
||||
normalize_state(&mut state, &source);
|
||||
normalize_state(&mut state, source.as_borrowed());
|
||||
assert_eq!(state.top, None);
|
||||
assert_eq!(state.selection, None);
|
||||
assert_eq!(state.alternate_active, Some(true));
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ fn snapshot_text(snapshot: &pmacs::terminal::TerminalSnapshot) -> String {
|
|||
fn lua_surface_is_strict_fresh_transactional_and_context_safe() {
|
||||
let mut state = EditorState::new();
|
||||
let command_lua = lua_string("/bin/sh");
|
||||
let baseline_buffer_id = state.core.borrow().active_buffer_id();
|
||||
|
||||
let baseline_sessions = state.terminal_manager.borrow().len();
|
||||
let baseline_buffers = state.core.borrow().registry.borrow().ids().len();
|
||||
|
|
@ -170,6 +171,29 @@ fn lua_surface_is_strict_fresh_transactional_and_context_safe() {
|
|||
assert!(manager.begin_selection(view_key, snapshot.size, CellCoord::new(row, col)));
|
||||
assert!(manager.finish_selection(view_key, snapshot.size, CellCoord::new(row, col + 7)));
|
||||
}
|
||||
assert!(
|
||||
state
|
||||
.prepare_terminal_views(frontend_id, CellSize::new(1, 1))
|
||||
.is_empty(),
|
||||
"zero-area terminal placements paint no snapshot"
|
||||
);
|
||||
assert!(
|
||||
state
|
||||
.terminal_manager
|
||||
.borrow()
|
||||
.view_state(view_key)
|
||||
.is_some_and(|view| view.selection.is_some()),
|
||||
"a transient zero-area placement must retain existing view anchors"
|
||||
);
|
||||
assert!(
|
||||
state
|
||||
.prepare_terminal_views(frontend_id, CellSize::new(8, 30))
|
||||
.contains_key(&window_id)
|
||||
);
|
||||
state.dispatch_key(
|
||||
frontend_id,
|
||||
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
|
||||
);
|
||||
state.dispatch_key(
|
||||
frontend_id,
|
||||
KeyEvent::new(KeyCode::Char('w'), KeyModifiers::ALT),
|
||||
|
|
@ -216,6 +240,46 @@ fn lua_surface_is_strict_fresh_transactional_and_context_safe() {
|
|||
assert!(error.contains("interactive frontend context"));
|
||||
}
|
||||
|
||||
state
|
||||
.lua_host
|
||||
.lua()
|
||||
.load(
|
||||
r#"
|
||||
pmacs.command.define {
|
||||
name = "test.terminal-context",
|
||||
description = "Exercise context-implicit terminal failure",
|
||||
fn = function() pmacs.terminal.scroll(1) end,
|
||||
}
|
||||
pmacs.keymap.bind {
|
||||
scope = "global", sequence = "<f12>", command = "test.terminal-context"
|
||||
}
|
||||
"#,
|
||||
)
|
||||
.exec()
|
||||
.expect("install context failure probe");
|
||||
state
|
||||
.core
|
||||
.borrow_mut()
|
||||
.switch_active_buffer_for(frontend_id, baseline_buffer_id)
|
||||
.expect("switch to document buffer");
|
||||
state.dispatch_key(
|
||||
frontend_id,
|
||||
KeyEvent::new(KeyCode::F(12), KeyModifiers::NONE),
|
||||
);
|
||||
assert!(
|
||||
state
|
||||
.core
|
||||
.borrow()
|
||||
.status
|
||||
.contains("active window is not a terminal"),
|
||||
"context-implicit terminal command must raise a named Lua error"
|
||||
);
|
||||
state
|
||||
.core
|
||||
.borrow_mut()
|
||||
.switch_active_buffer_for(frontend_id, buffer_id)
|
||||
.expect("restore terminal buffer");
|
||||
|
||||
state
|
||||
.terminal_manager
|
||||
.borrow_mut()
|
||||
|
|
@ -257,6 +321,7 @@ fn lua_surface_is_strict_fresh_transactional_and_context_safe() {
|
|||
}
|
||||
|
||||
#[test]
|
||||
#[allow(clippy::too_many_lines, reason = "shared view and controller scenario")]
|
||||
fn shared_screen_keeps_view_scroll_selection_and_controller_independent() {
|
||||
let mut state = EditorState::new();
|
||||
let mut spec = TerminalSpec::new("/bin/sh");
|
||||
|
|
@ -283,10 +348,24 @@ fn shared_screen_keeps_view_scroll_selection_and_controller_independent() {
|
|||
.snapshot(buffer_id)
|
||||
.is_some_and(|snapshot| snapshot_text(&snapshot).contains("row29"))
|
||||
});
|
||||
let mut replacement_spec = TerminalSpec::new("/bin/sh");
|
||||
replacement_spec.args = vec!["-c".into(), "sleep 30".into()];
|
||||
replacement_spec.rows = 4;
|
||||
replacement_spec.cols = 24;
|
||||
let replacement_buffer = state
|
||||
.terminal_manager
|
||||
.borrow_mut()
|
||||
.open(
|
||||
replacement_spec,
|
||||
&mut state.core.borrow_mut(),
|
||||
&mut state.process_supervisor.borrow_mut(),
|
||||
)
|
||||
.expect("open replacement terminal");
|
||||
|
||||
let size = CellSize::new(4, 24);
|
||||
let first = TerminalViewKey::new(FrontendId(11), WindowId::next(), buffer_id);
|
||||
let second = TerminalViewKey::new(FrontendId(22), WindowId::next(), buffer_id);
|
||||
let replacement = TerminalViewKey::new(FrontendId(11), WindowId::next(), replacement_buffer);
|
||||
{
|
||||
let mut manager = state.terminal_manager.borrow_mut();
|
||||
let first_tail = manager
|
||||
|
|
@ -295,6 +374,9 @@ fn shared_screen_keeps_view_scroll_selection_and_controller_independent() {
|
|||
let second_tail = manager
|
||||
.snapshot_for_view(second, size)
|
||||
.expect("second snapshot");
|
||||
manager
|
||||
.snapshot_for_view(replacement, size)
|
||||
.expect("replacement snapshot");
|
||||
assert_eq!(first_tail.title, second_tail.title);
|
||||
assert_eq!(first_tail.process, second_tail.process);
|
||||
|
||||
|
|
@ -327,12 +409,14 @@ fn shared_screen_keeps_view_scroll_selection_and_controller_independent() {
|
|||
assert!(manager.copy_selection(second).is_none());
|
||||
|
||||
assert!(manager.claim_controller(first));
|
||||
assert!(manager.claim_controller(second));
|
||||
assert!(manager.claim_controller(replacement));
|
||||
assert_eq!(
|
||||
manager.controller_view_for_frontend(FrontendId(11)),
|
||||
None,
|
||||
"one terminal session has one most-recent controller"
|
||||
Some(replacement),
|
||||
"claiming another session atomically replaces a frontend's controller"
|
||||
);
|
||||
assert!(manager.controller(buffer_id).is_none());
|
||||
assert!(manager.claim_controller(second));
|
||||
assert_eq!(
|
||||
manager.controller_view_for_frontend(FrontendId(22)),
|
||||
Some(second)
|
||||
|
|
@ -356,6 +440,75 @@ fn shared_screen_keeps_view_scroll_selection_and_controller_independent() {
|
|||
.borrow_mut()
|
||||
.terminate(buffer_id, &mut state.process_supervisor.borrow_mut())
|
||||
.expect("terminate terminal child");
|
||||
state
|
||||
.terminal_manager
|
||||
.borrow_mut()
|
||||
.terminate(
|
||||
replacement_buffer,
|
||||
&mut state.process_supervisor.borrow_mut(),
|
||||
)
|
||||
.expect("terminate replacement terminal child");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_escape_gates_local_bindings_and_double_escape_sends_interrupt() {
|
||||
let temp = tempfile::TempDir::new().expect("tempdir");
|
||||
let ready_path = temp.path().join("ready");
|
||||
let input_path = temp.path().join("input");
|
||||
let probe = format!(
|
||||
concat!(
|
||||
"import os, tty\n",
|
||||
"tty.setraw(0)\n",
|
||||
"open({:?}, 'wb').write(b'1')\n",
|
||||
"data = b''\n",
|
||||
"while len(data) < 5: data += os.read(0, 5 - len(data))\n",
|
||||
"open({:?}, 'wb').write(data)\n",
|
||||
),
|
||||
ready_path.to_str().expect("UTF-8 ready path"),
|
||||
input_path.to_str().expect("UTF-8 input path")
|
||||
);
|
||||
let mut state = EditorState::new();
|
||||
state
|
||||
.lua_host
|
||||
.lua()
|
||||
.load(format!(
|
||||
r#"
|
||||
return pmacs.terminal.open {{
|
||||
command = "/usr/bin/python3",
|
||||
args = {{ "-c", {} }},
|
||||
rows = 4,
|
||||
cols = 20,
|
||||
}}
|
||||
"#,
|
||||
lua_string(&probe)
|
||||
))
|
||||
.eval::<AnyUserData>()
|
||||
.expect("open raw input probe");
|
||||
assert_eq!(wait_for_file(&ready_path, Duration::from_secs(5)), b"1");
|
||||
|
||||
let frontend_id = FrontendId::LOCAL;
|
||||
state.dispatch_key(
|
||||
frontend_id,
|
||||
KeyEvent::new(KeyCode::Char('v'), KeyModifiers::ALT),
|
||||
);
|
||||
state.dispatch_key(
|
||||
frontend_id,
|
||||
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
|
||||
);
|
||||
state.dispatch_key(
|
||||
frontend_id,
|
||||
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
|
||||
);
|
||||
state.dispatch_key(
|
||||
frontend_id,
|
||||
KeyEvent::new(KeyCode::Char('w'), KeyModifiers::ALT),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
wait_for_file(&input_path, Duration::from_secs(5)),
|
||||
b"\x1bv\x03\x1bw",
|
||||
"unescaped local bindings reach the child; C-c C-c sends one literal interrupt"
|
||||
);
|
||||
}
|
||||
|
||||
fn wait_for_output(pty: &PmacsPty, needle: &[u8], timeout: Duration) {
|
||||
|
|
@ -471,12 +624,14 @@ fn real_tui_terminal_smoke_restores_host_after_output_input_resize_scroll_copy_a
|
|||
|
||||
pty.resize(30, 90).expect("resize host PTY");
|
||||
thread::sleep(Duration::from_millis(150));
|
||||
pty.write_input(b"\x1bv").expect("terminal page-up binding");
|
||||
pty.write_input(b"\x03\x1bv")
|
||||
.expect("escaped terminal page-up binding");
|
||||
// Inject editor-owned scrolling, selection, and copy gestures while the
|
||||
// real terminal child is active; focused tests pin their exact state.
|
||||
pty.write_input(b"\x1b[<4;2;2M\x1b[<36;8;2M\x1b[<4;8;2m")
|
||||
.expect("terminal selection drag");
|
||||
pty.write_input(b"\x1bw").expect("copy selection binding");
|
||||
pty.write_input(b"\x03\x1bw")
|
||||
.expect("escaped copy selection binding");
|
||||
wait_for_output(&pty, b"\x1b]52;c;", Duration::from_secs(5));
|
||||
let input = wait_for_file(&input_path, Duration::from_secs(5));
|
||||
assert_eq!(input, b"VTERM_INPUT_SMOKE\n");
|
||||
|
|
|
|||
Loading…
Reference in New Issue