test(vterm): prove Stage 2 TUI integration
Add cross-surface Lua, shared-view, clipboard, authenticated routing, BEL, resize, and real-host PTY acceptance. Ensure terminal-local keymaps run before raw child transport and document the criterion-to-test map. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
0dacac7e8d
commit
dc9225778a
|
|
@ -88,21 +88,18 @@ protocol v17; and #125 added composable `pmacs.statusline` providers,
|
|||
per-window TUI composition, a pure built-in LSP segment, dynamic modeline
|
||||
faces, and semantic/GPU transport through protocol v18.
|
||||
|
||||
### Arc 5 — Terminal, staged — VTERM STAGE 1 ON FEATURE BRANCH
|
||||
### Arc 5 — Terminal, staged — VTERM STAGE 2 ON FEATURE BRANCH
|
||||
|
||||
- **Compile-mode landed** in #113: line-oriented PTY/ANSI output,
|
||||
error-regex navigation, and `M-x compile`.
|
||||
- **Vterm Stage 1 terminal core** is implemented, two review rounds are
|
||||
addressed, and the branch is fully gated on `vterm-core`; PR #126 awaits
|
||||
merge authorization and is **not merged**. It adds compatibility parser
|
||||
- **Vterm Stage 1 terminal core landed** in #126: compatibility parser
|
||||
profiles, bounded VT screen/scrollback/reflow state, IND/NEL/RI, input
|
||||
encoders, internal `TerminalManager`, read-only identity buffers, process
|
||||
lifecycle, renderer-safe control-free cells, and headless real-PTY
|
||||
acceptance. It intentionally adds no interactive Lua command or frontend
|
||||
rendering.
|
||||
- **Vterm Stage 2 TUI** starts only after Stage 1 merges: terminal-window
|
||||
composition, input/resize, per-context scroll/selection/copy, and the Lua
|
||||
surface.
|
||||
acceptance.
|
||||
- **Vterm Stage 2 TUI** is implemented on `vterm-tui`: terminal-window
|
||||
composition, input/resize, per-context scroll/selection/copy, authenticated
|
||||
frontend ownership, BEL/clipboard drainage, and the strict Lua surface.
|
||||
- **Vterm Stage 3 protocol/GPU** starts only after Stage 2 merges: additive
|
||||
protocol v19 complete frames, authenticated daemon routing, and native GPU
|
||||
cell rendering. Its framing must resolve the current 16 MiB transport cap's
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
# Vterm — framing (Arc 5 stage 2, three-PR delivery)
|
||||
|
||||
**Revision 7 — 2026-07-21. Status: Stage 1 landed on `main` as PR #126
|
||||
at merge `643d1e1`; Stages 2 and 3 are not implemented. Stage 2 framing review
|
||||
is complete and the contract is ready for implementation.**
|
||||
at merge `643d1e1`; Stage 2 is implemented on branch `vterm-tui` and Stage 3
|
||||
is not implemented.**
|
||||
|
||||
Revision 7 closes the final three precision findings: `at_bottom` is geometric
|
||||
and distinct from live-tail following; the fixed `C-c` transport escape
|
||||
|
|
@ -1134,6 +1134,48 @@ base-branch deletion/auto-close risk and makes each PR's gate evidence honest.
|
|||
exercises key/paste, resize, scroll/select/copy, BEL, and clean exit, then
|
||||
proves host raw/alternate-screen state is restored.
|
||||
|
||||
#### Stage 2 verification map
|
||||
|
||||
The cross-surface suite is `tests/vterm_stage2_acceptance.rs`; focused unit
|
||||
coverage remains beside the owning implementation. The criteria map as follows:
|
||||
|
||||
- **15:** `lua_surface_is_strict_fresh_transactional_and_context_safe`.
|
||||
- **16:** `editor::tests::terminal_snapshot_composes_only_content_and_translates_cursor`
|
||||
plus the real-TUI smoke.
|
||||
- **17:** `terminal::view::tests::{tail_projection_pads_above_and_right_and_translates_cursor,
|
||||
frozen_top_is_geometrically_at_bottom_when_view_still_reaches_tail,
|
||||
alternate_switch_clears_view_anchors_and_selection}` and
|
||||
`shared_screen_keeps_view_scroll_selection_and_controller_independent`.
|
||||
- **18:** `terminal::input::tests::{utf8_ctrl_and_alt_boundaries,
|
||||
application_cursor_and_xterm_modifiers, ambiguous_digits_ignore_application_keypad,
|
||||
paste_and_focus_are_exact, unsupported_keys_are_invisible}` and the real-TUI
|
||||
input/paste path.
|
||||
- **19:** `editor::tests::dispatch_prefix_state_is_independent_per_frontend`,
|
||||
`lua_surface_is_strict_fresh_transactional_and_context_safe`, and the
|
||||
real-TUI terminal-escape/quit path.
|
||||
- **20:** `terminal::input::tests::sgr_mouse_modes_modifiers_and_coordinates`
|
||||
plus the real-TUI editor-owned scroll/drag/copy path.
|
||||
- **21:** `terminal::view::tests::{copy_joins_soft_wraps_trims_default_blanks_and_separates_hard_rows,
|
||||
wide_continuation_canonicalizes_to_lead_and_copies_once}`,
|
||||
`lua_surface_is_strict_fresh_transactional_and_context_safe`, and the real
|
||||
OSC 52 clipboard assertion.
|
||||
- **22:** `lua_surface_is_strict_fresh_transactional_and_context_safe`,
|
||||
`shared_screen_keeps_view_scroll_selection_and_controller_independent`, and
|
||||
the real child-PTY resize assertion.
|
||||
- **23:** `daemon::tests::forged_resize_mutates_only_the_authenticated_frontend`,
|
||||
`daemon::tests::inbound_paste_uses_authenticated_source_not_the_claimed_id`,
|
||||
the existing `m5_4_dispatch_{key,mouse}_threads_frontend_id_to_lua_surface`
|
||||
tests, and the multi-frontend detach assertions in
|
||||
`shared_screen_keeps_view_scroll_selection_and_controller_independent`.
|
||||
- **24:** the built-in-provider and clipboard assertions in
|
||||
`lua_surface_is_strict_fresh_transactional_and_context_safe`,
|
||||
`daemon::tests::terminal_bell_baseline_suppresses_history_and_delivers_each_new_bell_once`,
|
||||
and the real-TUI BEL/OSC 52 assertions.
|
||||
- **25:** `shared_screen_keeps_view_scroll_selection_and_controller_independent`.
|
||||
- **26:** both in-process acceptance tests' termination/cleanup assertions and
|
||||
the real-TUI clean-exit/host-restoration assertions.
|
||||
- **27:** `real_tui_terminal_smoke_restores_host_after_output_input_resize_scroll_copy_and_bell`.
|
||||
|
||||
### Stage 3 — GPU/protocol
|
||||
|
||||
28. Protocol v19 appends all new variants after v18 pins; v18 grid traffic
|
||||
|
|
|
|||
131
src/daemon.rs
131
src/daemon.rs
|
|
@ -871,10 +871,8 @@ fn dispatcher_loop(
|
|||
let mut last_active_buffer_sent: HashMap<FrontendId, crate::buffer::BufferId> = HashMap::new();
|
||||
// Active-terminal BEL delivery baseline. Switching away forgets the
|
||||
// terminal so historical bells are never replayed on later activation.
|
||||
let mut terminal_bell_baselines: HashMap<
|
||||
FrontendId,
|
||||
(crate::buffer::BufferId, u64),
|
||||
> = HashMap::new();
|
||||
let mut terminal_bell_baselines: HashMap<FrontendId, (crate::buffer::BufferId, u64)> =
|
||||
HashMap::new();
|
||||
let mut session_registry = SessionRegistry::new();
|
||||
// T M10.11 Q8 — jitter PRNG, seeded once so the
|
||||
// convergence-under-jitter scenario is deterministically
|
||||
|
|
@ -1077,12 +1075,7 @@ fn dispatcher_loop(
|
|||
let render_state = render_states
|
||||
.get_mut(fid)
|
||||
.expect("render_state present for attached grid fid");
|
||||
render_state.render_frame(
|
||||
editor,
|
||||
*fid,
|
||||
&terminal_snapshots,
|
||||
&other_presences,
|
||||
)
|
||||
render_state.render_frame(editor, *fid, &terminal_snapshots, &other_presences)
|
||||
};
|
||||
|
||||
// T M10.6 per-frontend presence sweep. The snapshot is
|
||||
|
|
@ -1537,10 +1530,7 @@ fn handle_dispatcher_event(
|
|||
term_sizes: &mut HashMap<FrontendId, CellSize>,
|
||||
last_dispatch_idle_sent: &mut HashMap<FrontendId, bool>,
|
||||
last_active_buffer_sent: &mut HashMap<FrontendId, crate::buffer::BufferId>,
|
||||
terminal_bell_baselines: &mut HashMap<
|
||||
FrontendId,
|
||||
(crate::buffer::BufferId, u64),
|
||||
>,
|
||||
terminal_bell_baselines: &mut HashMap<FrontendId, (crate::buffer::BufferId, u64)>,
|
||||
session_registry: &mut SessionRegistry,
|
||||
) {
|
||||
match event {
|
||||
|
|
@ -3071,6 +3061,119 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forged_resize_mutates_only_the_authenticated_frontend() {
|
||||
let source = FrontendId(41);
|
||||
let forged = FrontendId(42);
|
||||
let old_size = CellSize::new(24, 80);
|
||||
let new_size = CellSize::new(31, 97);
|
||||
let mut editor = EditorState::new();
|
||||
let mut render_states = HashMap::from([
|
||||
(source, RenderState::new(old_size)),
|
||||
(forged, RenderState::new(old_size)),
|
||||
]);
|
||||
let mut semantic_states: HashMap<FrontendId, crate::semantic_render::SemanticRenderState> =
|
||||
HashMap::new();
|
||||
let mut streams: HashMap<FrontendId, UnixStream> = HashMap::new();
|
||||
let mut term_sizes = HashMap::from([(source, old_size), (forged, old_size)]);
|
||||
let mut last_dispatch_idle_sent = HashMap::new();
|
||||
let mut last_active_buffer_sent = HashMap::new();
|
||||
let mut terminal_bell_baselines = HashMap::new();
|
||||
let mut session_registry = SessionRegistry::new();
|
||||
|
||||
handle_dispatcher_event(
|
||||
DispatcherEvent::FrontendEvent {
|
||||
source,
|
||||
event: FrontendEvent::Resize {
|
||||
frontend_id: forged,
|
||||
size: new_size,
|
||||
},
|
||||
},
|
||||
&mut editor,
|
||||
&mut render_states,
|
||||
&mut semantic_states,
|
||||
&mut streams,
|
||||
&mut term_sizes,
|
||||
&mut last_dispatch_idle_sent,
|
||||
&mut last_active_buffer_sent,
|
||||
&mut terminal_bell_baselines,
|
||||
&mut session_registry,
|
||||
);
|
||||
|
||||
assert_eq!(render_states[&source].size(), new_size);
|
||||
assert_eq!(term_sizes[&source], new_size);
|
||||
assert_eq!(render_states[&forged].size(), old_size);
|
||||
assert_eq!(term_sizes[&forged], old_size);
|
||||
}
|
||||
#[test]
|
||||
fn terminal_bell_baseline_suppresses_history_and_delivers_each_new_bell_once() {
|
||||
let mut editor = EditorState::new();
|
||||
let mut spec = crate::terminal::TerminalSpec::new("/bin/sh");
|
||||
spec.args = vec![
|
||||
"-c".into(),
|
||||
"printf '\\a'; IFS= read -r _; printf '\\a'; sleep 30".into(),
|
||||
];
|
||||
let buffer_id = editor
|
||||
.terminal_manager
|
||||
.borrow_mut()
|
||||
.open(
|
||||
spec,
|
||||
&mut editor.core.borrow_mut(),
|
||||
&mut editor.process_supervisor.borrow_mut(),
|
||||
)
|
||||
.expect("open bell probe");
|
||||
editor
|
||||
.core
|
||||
.borrow_mut()
|
||||
.switch_active_buffer_for(FrontendId::LOCAL, buffer_id)
|
||||
.expect("display bell probe");
|
||||
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
while editor.terminal_manager.borrow().bell_count(buffer_id) != Some(1) {
|
||||
editor.tick_processes();
|
||||
assert!(Instant::now() < deadline, "initial terminal bell timed out");
|
||||
thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
let mut baselines = HashMap::new();
|
||||
assert!(!take_pending_terminal_bell(
|
||||
&editor,
|
||||
FrontendId::LOCAL,
|
||||
&mut baselines
|
||||
));
|
||||
|
||||
editor
|
||||
.terminal_manager
|
||||
.borrow()
|
||||
.send(
|
||||
buffer_id,
|
||||
b"\n",
|
||||
&mut editor.process_supervisor.borrow_mut(),
|
||||
)
|
||||
.expect("advance bell probe");
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
while editor.terminal_manager.borrow().bell_count(buffer_id) != Some(2) {
|
||||
editor.tick_processes();
|
||||
assert!(Instant::now() < deadline, "second terminal bell timed out");
|
||||
thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
assert!(take_pending_terminal_bell(
|
||||
&editor,
|
||||
FrontendId::LOCAL,
|
||||
&mut baselines
|
||||
));
|
||||
assert!(!take_pending_terminal_bell(
|
||||
&editor,
|
||||
FrontendId::LOCAL,
|
||||
&mut baselines
|
||||
));
|
||||
|
||||
editor
|
||||
.terminal_manager
|
||||
.borrow_mut()
|
||||
.terminate(buffer_id, &mut editor.process_supervisor.borrow_mut())
|
||||
.expect("terminate bell probe");
|
||||
}
|
||||
|
||||
/// Kill ring Q#KR10a — the unified paste route trusts only the
|
||||
/// dispatcher's authenticated source. A forged payload id must not
|
||||
/// paste into another frontend's active window, and the paste
|
||||
|
|
|
|||
143
src/editor.rs
143
src/editor.rs
|
|
@ -39,9 +39,9 @@ use crate::protocol::{
|
|||
MouseKind as TerminalMouseKind,
|
||||
};
|
||||
use crate::terminal::TerminalSnapshot;
|
||||
use crate::terminal::view::TerminalViewKey;
|
||||
use crate::view::{View, Viewport};
|
||||
use crate::window::{Rect, WindowId};
|
||||
use crate::terminal::view::TerminalViewKey;
|
||||
|
||||
/// Ephemeral authenticated origin for one interactive command invocation.
|
||||
///
|
||||
|
|
@ -300,11 +300,9 @@ impl EditorState {
|
|||
// shutdown enforces no-zombie cleanup at editor exit.
|
||||
let process_supervisor = crate::lua_bindings::make_process_supervisor(lua_host.lua())
|
||||
.expect("install pmacs.process");
|
||||
let terminal_manager = crate::lua_bindings::make_terminal_manager(
|
||||
lua_host.lua(),
|
||||
process_supervisor.clone(),
|
||||
)
|
||||
.expect("install pmacs.terminal");
|
||||
let terminal_manager =
|
||||
crate::lua_bindings::make_terminal_manager(lua_host.lua(), process_supervisor.clone())
|
||||
.expect("install pmacs.terminal");
|
||||
lua_host
|
||||
.eval(
|
||||
Some("@pmacs/builtin/runtime/terminal.lua"),
|
||||
|
|
@ -726,9 +724,7 @@ impl EditorState {
|
|||
if self
|
||||
.dispatchers
|
||||
.get(&frontend_id)
|
||||
.is_some_and(|state| {
|
||||
state.terminal_escape || !state.dispatcher.pending().is_empty()
|
||||
})
|
||||
.is_some_and(|state| state.terminal_escape || !state.dispatcher.pending().is_empty())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
|
@ -739,9 +735,7 @@ impl EditorState {
|
|||
&& !core.menu_is_open()
|
||||
&& core
|
||||
.active_window_for(frontend_id)
|
||||
.is_some_and(|window| {
|
||||
!core.active_buffer_round_trips_for(window.buffer_id)
|
||||
})
|
||||
.is_some_and(|window| !core.active_buffer_round_trips_for(window.buffer_id))
|
||||
}
|
||||
|
||||
/// Local-frontend compatibility wrapper.
|
||||
|
|
@ -827,6 +821,17 @@ 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
|
||||
|
|
@ -847,21 +852,23 @@ impl EditorState {
|
|||
self.claim_terminal_controller(view_key);
|
||||
return;
|
||||
}
|
||||
let Some((terminal_key, modifiers)) = terminal_key_from_crossterm(key) else {
|
||||
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);
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1005,9 +1012,7 @@ impl EditorState {
|
|||
.modes_for_view(key)
|
||||
.unwrap_or_default();
|
||||
self.claim_terminal_controller(key);
|
||||
if let Some(bytes) =
|
||||
crate::terminal::input::encode_focus(true, modes.focus_reporting)
|
||||
{
|
||||
if let Some(bytes) = crate::terminal::input::encode_focus(true, modes.focus_reporting) {
|
||||
self.send_terminal_bytes(key.buffer_id, &bytes);
|
||||
}
|
||||
return;
|
||||
|
|
@ -1025,9 +1030,7 @@ impl EditorState {
|
|||
.borrow()
|
||||
.modes_for_view(key)
|
||||
.unwrap_or_default();
|
||||
if let Some(bytes) =
|
||||
crate::terminal::input::encode_focus(false, modes.focus_reporting)
|
||||
{
|
||||
if let Some(bytes) = crate::terminal::input::encode_focus(false, modes.focus_reporting) {
|
||||
self.send_terminal_bytes(key.buffer_id, &bytes);
|
||||
}
|
||||
let _ = self.terminal_manager.borrow_mut().release_controller(key);
|
||||
|
|
@ -1036,11 +1039,7 @@ impl EditorState {
|
|||
/// Resize the one session durably controlled by `frontend_id`.
|
||||
///
|
||||
/// This is called before process drain and paint, never from rendering.
|
||||
pub fn sync_terminal_layout(
|
||||
&mut self,
|
||||
frontend_id: FrontendId,
|
||||
term_size: CellSize,
|
||||
) -> bool {
|
||||
pub fn sync_terminal_layout(&mut self, frontend_id: FrontendId, term_size: CellSize) -> bool {
|
||||
let Some(key) = self
|
||||
.terminal_manager
|
||||
.borrow()
|
||||
|
|
@ -1063,8 +1062,9 @@ impl EditorState {
|
|||
let _ = self.terminal_manager.borrow_mut().release_controller(key);
|
||||
return false;
|
||||
}
|
||||
let Some(placement) =
|
||||
window_placements(&core, frontend_id, term_size).get(&key.window_id).copied()
|
||||
let Some(placement) = window_placements(&core, frontend_id, term_size)
|
||||
.get(&key.window_id)
|
||||
.copied()
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
|
|
@ -1119,8 +1119,7 @@ impl EditorState {
|
|||
if placement.content.size.rows == 0 || placement.content.size.cols == 0 {
|
||||
continue;
|
||||
}
|
||||
let key =
|
||||
TerminalViewKey::new(frontend_id, window_id, window.buffer_id);
|
||||
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));
|
||||
|
|
@ -1395,9 +1394,7 @@ impl EditorState {
|
|||
// rotate the boundary so a menu Cut chains like a keybound
|
||||
// one. The invoke below bypasses dispatch_key, which would
|
||||
// otherwise leave the boundary stale.
|
||||
self.core
|
||||
.borrow_mut()
|
||||
.rotate_command(frontend_id, &command);
|
||||
self.core.borrow_mut().rotate_command(frontend_id, &command);
|
||||
// Q#KR10b: menu invocation bypasses dispatch_key's
|
||||
// revision check — a menu Cut's edit must still fire
|
||||
// `buffer.after-edit`.
|
||||
|
|
@ -1602,9 +1599,13 @@ impl EditorState {
|
|||
return;
|
||||
}
|
||||
|
||||
let Some((win_id, rect)) =
|
||||
window_at_cell(&self.core.borrow(), frontend_id, term_size, cell_row, cell_col)
|
||||
else {
|
||||
let Some((win_id, rect)) = window_at_cell(
|
||||
&self.core.borrow(),
|
||||
frontend_id,
|
||||
term_size,
|
||||
cell_row,
|
||||
cell_col,
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
let inner_rows = rect.size.rows.saturating_sub(1);
|
||||
|
|
@ -1616,10 +1617,7 @@ impl EditorState {
|
|||
self.mouse_click = None;
|
||||
return;
|
||||
}
|
||||
let local = CellCoord::new(
|
||||
local_row,
|
||||
cell_col.saturating_sub(rect.origin.col),
|
||||
);
|
||||
let local = CellCoord::new(local_row, cell_col.saturating_sub(rect.origin.col));
|
||||
self.dispatch_terminal_mouse(
|
||||
TerminalViewKey::new(frontend_id, win_id, buffer_id),
|
||||
content_size,
|
||||
|
|
@ -1746,8 +1744,7 @@ impl EditorState {
|
|||
&& modes.mouse_sgr
|
||||
&& coord.row < screen_size.rows
|
||||
&& coord.col < screen_size.cols
|
||||
&& let Some(bytes) =
|
||||
crate::terminal::input::encode_mouse(kind, coord, modifiers, modes)
|
||||
&& let Some(bytes) = crate::terminal::input::encode_mouse(kind, coord, modifiers, modes)
|
||||
{
|
||||
self.claim_terminal_controller(key);
|
||||
self.send_terminal_bytes(key.buffer_id, &bytes);
|
||||
|
|
@ -2220,12 +2217,8 @@ pub fn run(file: Option<PathBuf>) -> io::Result<()> {
|
|||
let size = frontend.size();
|
||||
let _ = state.sync_terminal_layout(FrontendId::LOCAL, size);
|
||||
let terminal_snapshots = state.prepare_terminal_views(FrontendId::LOCAL, size);
|
||||
let mut messages = render_state.render_frame(
|
||||
&state,
|
||||
FrontendId::LOCAL,
|
||||
&terminal_snapshots,
|
||||
&[],
|
||||
);
|
||||
let mut messages =
|
||||
render_state.render_frame(&state, FrontendId::LOCAL, &terminal_snapshots, &[]);
|
||||
messages.extend(state.take_local_signals());
|
||||
frontend.present_messages(&messages)?;
|
||||
if state.core.borrow().quit {
|
||||
|
|
@ -2745,14 +2738,7 @@ pub fn paint_frame(
|
|||
}
|
||||
drop(reg);
|
||||
|
||||
paint_status_line(
|
||||
grid,
|
||||
core,
|
||||
&state.lua_host,
|
||||
dispatcher,
|
||||
term_size,
|
||||
&theme,
|
||||
);
|
||||
paint_status_line(grid, core, &state.lua_host, dispatcher, term_size, &theme);
|
||||
|
||||
// An active isearch owns the bottom row (its prompt + match
|
||||
// readout), but the terminal cursor stays in the buffer at the
|
||||
|
|
@ -3604,8 +3590,7 @@ fn is_terminal_escape_chord(chord: Chord) -> bool {
|
|||
}
|
||||
|
||||
fn terminal_key_from_crossterm(key: KeyEvent) -> Option<(TerminalKey, TerminalModifiers)> {
|
||||
let modifiers =
|
||||
crate::protocol::crossterm_translate::mods_from_crossterm(key.modifiers);
|
||||
let modifiers = crate::protocol::crossterm_translate::mods_from_crossterm(key.modifiers);
|
||||
let key = crate::protocol::crossterm_translate::keycode_from_crossterm(key.code);
|
||||
if matches!(key, TerminalKey::Unknown(_)) {
|
||||
return None;
|
||||
|
|
@ -3749,13 +3734,7 @@ mod tests {
|
|||
stride: size.cols,
|
||||
size,
|
||||
};
|
||||
paint_frame(
|
||||
&state,
|
||||
FrontendId::LOCAL,
|
||||
&snapshots,
|
||||
&mut grid,
|
||||
size,
|
||||
)
|
||||
paint_frame(&state, FrontendId::LOCAL, &snapshots, &mut grid, size)
|
||||
};
|
||||
assert_eq!(backing[0].glyph, crate::cell::Glyph::Char('T'));
|
||||
assert!(backing[0].style.reverse);
|
||||
|
|
@ -4343,7 +4322,11 @@ mod tests {
|
|||
// window to the *buffer-list* buffer).
|
||||
let mut s = fresh_with(b"");
|
||||
s.dispatch_key(FrontendId::LOCAL, ctrl('x'));
|
||||
assert_eq!(local_dispatcher(&s).pending().len(), 1, "C-x should start prefix");
|
||||
assert_eq!(
|
||||
local_dispatcher(&s).pending().len(),
|
||||
1,
|
||||
"C-x should start prefix"
|
||||
);
|
||||
s.dispatch_key(FrontendId::LOCAL, ctrl('b'));
|
||||
assert!(
|
||||
local_dispatcher(&s).pending().is_empty(),
|
||||
|
|
@ -7656,7 +7639,13 @@ mod tests {
|
|||
stride: cols,
|
||||
size: crate::cell::CellSize::new(rows, cols),
|
||||
};
|
||||
let cursor = paint_frame(s, FrontendId::LOCAL, &HashMap::new(), &mut grid, crate::cell::CellSize::new(rows, cols));
|
||||
let cursor = paint_frame(
|
||||
s,
|
||||
FrontendId::LOCAL,
|
||||
&HashMap::new(),
|
||||
&mut grid,
|
||||
crate::cell::CellSize::new(rows, cols),
|
||||
);
|
||||
(backing, cols, cursor)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -112,13 +112,7 @@ impl RenderState {
|
|||
stride: self.size.cols,
|
||||
size: self.size,
|
||||
};
|
||||
let coord = paint_frame(
|
||||
state,
|
||||
frontend_id,
|
||||
terminal_snapshots,
|
||||
&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.
|
||||
|
|
|
|||
|
|
@ -54,8 +54,8 @@ use crate::buffer::{BufferId, EditOp, MarkGravity, MarkId};
|
|||
use crate::buffer_registry::BufferRegistry;
|
||||
use crate::cell::{Color, Style, UnderlineStyle};
|
||||
use crate::command::{Command, CommandError, CommandRegistry, SourceLocation};
|
||||
use crate::editor_core::EditorCore;
|
||||
use crate::editor::InteractiveCommandOrigin;
|
||||
use crate::editor_core::EditorCore;
|
||||
use crate::highlight::SyntaxHighlightView;
|
||||
use crate::hook::{Hook, HookRegistry};
|
||||
use crate::key::{display_sequence, parse_sequence};
|
||||
|
|
@ -8270,11 +8270,7 @@ fn install_terminal(
|
|||
let buffer_id = {
|
||||
let mut manager = manager.borrow_mut();
|
||||
manager
|
||||
.open(
|
||||
spec,
|
||||
&mut core.borrow_mut(),
|
||||
&mut supervisor.borrow_mut(),
|
||||
)
|
||||
.open(spec, &mut core.borrow_mut(), &mut supervisor.borrow_mut())
|
||||
.map_err(mlua::Error::external)?
|
||||
};
|
||||
let key = {
|
||||
|
|
@ -8351,7 +8347,11 @@ fn install_terminal(
|
|||
lua.create_function(move |_, (buffer, bytes): (BufferIdLua, mlua::String)| {
|
||||
manager
|
||||
.borrow()
|
||||
.send(buffer.0, bytes.as_bytes().as_ref(), &mut supervisor.borrow_mut())
|
||||
.send(
|
||||
buffer.0,
|
||||
bytes.as_bytes().as_ref(),
|
||||
&mut supervisor.borrow_mut(),
|
||||
)
|
||||
.map_err(mlua::Error::external)
|
||||
})?,
|
||||
)?;
|
||||
|
|
@ -8397,9 +8397,13 @@ fn install_terminal(
|
|||
terminal.set(
|
||||
"scroll",
|
||||
lua.create_function(move |lua, lines: i64| {
|
||||
let lines = i32::try_from(lines).map_err(|_| {
|
||||
mlua::Error::external("pmacs.terminal.scroll: `lines` exceeds i32 range")
|
||||
})?;
|
||||
let lines = i32::try_from(lines).unwrap_or_else(|_| {
|
||||
if lines.is_negative() {
|
||||
i32::MIN
|
||||
} else {
|
||||
i32::MAX
|
||||
}
|
||||
});
|
||||
let core = terminal_shared_core(lua, "scroll")?;
|
||||
let key = active_terminal_view_key(lua, &core, "scroll")?;
|
||||
Ok(manager.borrow_mut().scroll_lines(key, lines))
|
||||
|
|
@ -8446,8 +8450,7 @@ fn install_terminal(
|
|||
let Some(bytes) = manager.borrow_mut().copy_selection(key) else {
|
||||
return Ok(false);
|
||||
};
|
||||
core.borrow_mut()
|
||||
.clipboard_set_for(key.frontend_id, bytes);
|
||||
core.borrow_mut().clipboard_set_for(key.frontend_id, bytes);
|
||||
Ok(true)
|
||||
})?,
|
||||
)?;
|
||||
|
|
@ -8490,8 +8493,8 @@ fn parse_terminal_spec(table: Table) -> mlua::Result<crate::terminal::TerminalSp
|
|||
let command = strict_terminal_string(table.raw_get("command")?, "command", false)?
|
||||
.ok_or_else(|| mlua::Error::external("pmacs.terminal.open: missing field `command`"))?;
|
||||
let args = strict_terminal_args(table.raw_get("args")?)?;
|
||||
let cwd = strict_terminal_string(table.raw_get("cwd")?, "cwd", true)?
|
||||
.map(std::path::PathBuf::from);
|
||||
let cwd =
|
||||
strict_terminal_string(table.raw_get("cwd")?, "cwd", true)?.map(std::path::PathBuf::from);
|
||||
let env = strict_terminal_env(table.raw_get("env")?)?;
|
||||
let name = strict_terminal_string(table.raw_get("name")?, "name", true)?;
|
||||
let rows = strict_terminal_u16(table.raw_get("rows")?, "rows", 24)?;
|
||||
|
|
@ -8628,11 +8631,7 @@ fn strict_terminal_u16(value: Value, field: &'static str, default: u16) -> mlua:
|
|||
}
|
||||
}
|
||||
|
||||
fn strict_terminal_usize(
|
||||
value: Value,
|
||||
field: &'static str,
|
||||
default: usize,
|
||||
) -> mlua::Result<usize> {
|
||||
fn strict_terminal_usize(value: Value, field: &'static str, default: usize) -> mlua::Result<usize> {
|
||||
match value {
|
||||
Value::Nil => Ok(default),
|
||||
Value::Integer(value) => usize::try_from(value).map_err(|_| {
|
||||
|
|
|
|||
|
|
@ -3824,12 +3824,7 @@ mod tests {
|
|||
let buffer_id = active_buffer(&state);
|
||||
sem.set_viewport(buffer_id, ByteRange { start: 0, end: 80 }, 0);
|
||||
|
||||
let grid_msgs = grid.render_frame(
|
||||
&state,
|
||||
FrontendId::LOCAL,
|
||||
&HashMap::new(),
|
||||
&[],
|
||||
);
|
||||
let grid_msgs = grid.render_frame(&state, FrontendId::LOCAL, &HashMap::new(), &[]);
|
||||
let sem_msgs = sem.render_frame(&state);
|
||||
assert!(
|
||||
matches!(grid_msgs[0], InstanceMessage::CellDelta { .. }),
|
||||
|
|
|
|||
|
|
@ -7,12 +7,8 @@ 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::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.
|
||||
|
|
@ -284,10 +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).copied().map(row_lead);
|
||||
}
|
||||
state.selection = Some(TerminalSelection {
|
||||
anchor,
|
||||
|
|
@ -320,7 +313,9 @@ impl TerminalManager {
|
|||
let Some(head) = anchor_at(&rows, &geometry, viewport_size, coord) else {
|
||||
return false;
|
||||
};
|
||||
let changed = state.selection.is_some_and(|selection| selection.head != head);
|
||||
let changed = state
|
||||
.selection
|
||||
.is_some_and(|selection| selection.head != head);
|
||||
if let Some(selection) = state.selection.as_mut() {
|
||||
selection.head = head;
|
||||
}
|
||||
|
|
@ -372,10 +367,7 @@ impl TerminalManager {
|
|||
|
||||
/// Exact controlled view for one frontend, if it still exists.
|
||||
#[must_use]
|
||||
pub fn controller_view_for_frontend(
|
||||
&self,
|
||||
frontend_id: FrontendId,
|
||||
) -> Option<TerminalViewKey> {
|
||||
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))
|
||||
|
|
@ -478,10 +470,7 @@ fn anchor_for(rows: &[&TerminalRow], resolved: ResolvedCell) -> LogicalCellAncho
|
|||
}
|
||||
}
|
||||
|
||||
fn clamp_or_clear(
|
||||
rows: &[&TerminalRow],
|
||||
anchor: LogicalCellAnchor,
|
||||
) -> Option<LogicalCellAnchor> {
|
||||
fn clamp_or_clear(rows: &[&TerminalRow], anchor: LogicalCellAnchor) -> Option<LogicalCellAnchor> {
|
||||
if let Some(resolved) = resolve_anchor(rows, anchor) {
|
||||
return Some(anchor_for(rows, resolved));
|
||||
}
|
||||
|
|
@ -537,7 +526,9 @@ fn view_geometry(
|
|||
} else {
|
||||
0
|
||||
};
|
||||
let rows_after_view = rows.len().saturating_sub(start.saturating_add(viewport_rows));
|
||||
let rows_after_view = rows
|
||||
.len()
|
||||
.saturating_sub(start.saturating_add(viewport_rows));
|
||||
ViewGeometry {
|
||||
start,
|
||||
top_padding,
|
||||
|
|
@ -618,8 +609,7 @@ fn project_snapshot(
|
|||
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)
|
||||
let Some(target_row) = viewport_row(&geometry, viewport_size.rows as usize, retained_row)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
|
@ -665,10 +655,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))
|
||||
})
|
||||
|
|
@ -697,10 +684,7 @@ 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: &[&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 {
|
||||
|
|
@ -753,9 +737,7 @@ mod tests {
|
|||
}
|
||||
|
||||
fn projection(history: Vec<TerminalRow>, visible_rows: Vec<TerminalRow>) -> ScreenProjection {
|
||||
let cols = visible_rows
|
||||
.first()
|
||||
.map_or(1, |row| row.cells.len() as u32);
|
||||
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,
|
||||
|
|
@ -769,7 +751,10 @@ mod tests {
|
|||
|
||||
#[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)]);
|
||||
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(),
|
||||
|
|
@ -780,7 +765,11 @@ mod tests {
|
|||
TerminalProcessState::Running,
|
||||
);
|
||||
assert_eq!(snapshot.cells.len(), 20);
|
||||
assert!(snapshot.cells[..10].iter().all(|cell| *cell == Cell::default()));
|
||||
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'));
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
use std::ffi::OsString;
|
||||
use std::io::{Read, Write};
|
||||
use std::path::Path;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
|
|
@ -26,7 +27,8 @@ pub struct PmacsPty {
|
|||
child: Box<dyn portable_pty::Child + Send + Sync>,
|
||||
writer: Box<dyn Write + Send>,
|
||||
_reader_thread: thread::JoinHandle<()>,
|
||||
_master: Box<dyn portable_pty::MasterPty + Send>,
|
||||
output: Arc<Mutex<Vec<u8>>>,
|
||||
master: Box<dyn portable_pty::MasterPty + Send>,
|
||||
}
|
||||
|
||||
impl PmacsPty {
|
||||
|
|
@ -36,6 +38,27 @@ impl PmacsPty {
|
|||
self.writer.flush()
|
||||
}
|
||||
|
||||
/// Resize the real host PTY in terminal cells.
|
||||
pub fn resize(&self, rows: u16, cols: u16) -> Result<(), String> {
|
||||
self.master
|
||||
.resize(PtySize {
|
||||
rows,
|
||||
cols,
|
||||
pixel_width: 0,
|
||||
pixel_height: 0,
|
||||
})
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
/// Snapshot all bytes emitted by pmacs to its host terminal.
|
||||
#[must_use]
|
||||
pub fn output(&self) -> Vec<u8> {
|
||||
self.output
|
||||
.lock()
|
||||
.expect("PTY output mutex poisoned")
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// Poll-wait for pmacs to exit, up to `timeout`. Returns the
|
||||
/// exit status on success, `None` on timeout (and leaves the
|
||||
/// child running for the caller to clean up).
|
||||
|
|
@ -103,16 +126,19 @@ pub fn spawn_pmacs_in_pty(args: &[&str], envs: &[(&str, &Path)], rows: u16, cols
|
|||
|
||||
let writer = pair.master.take_writer().expect("take_writer");
|
||||
let mut reader = pair.master.try_clone_reader().expect("try_clone_reader");
|
||||
// Drain reader to /dev/null so pmacs's writes never block on a
|
||||
// backed-up terminal output buffer. We don't need to inspect the
|
||||
// bytes; the tests assert on exit status and side effects, not
|
||||
// on screen content.
|
||||
let output = Arc::new(Mutex::new(Vec::new()));
|
||||
let captured = output.clone();
|
||||
// Drain and retain reader bytes so terminal-mode restoration can be
|
||||
// asserted without ever exposing pmacs to the test runner's own TTY.
|
||||
let reader_thread = thread::spawn(move || {
|
||||
let mut buf = [0u8; 4096];
|
||||
loop {
|
||||
match reader.read(&mut buf) {
|
||||
Ok(0) | Err(_) => return,
|
||||
Ok(_) => {}
|
||||
Ok(read) => captured
|
||||
.lock()
|
||||
.expect("PTY output mutex poisoned")
|
||||
.extend_from_slice(&buf[..read]),
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
@ -121,6 +147,7 @@ pub fn spawn_pmacs_in_pty(args: &[&str], envs: &[(&str, &Path)], rows: u16, cols
|
|||
child,
|
||||
writer,
|
||||
_reader_thread: reader_thread,
|
||||
_master: pair.master,
|
||||
output,
|
||||
master: pair.master,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,508 @@
|
|||
//! Stage 2 terminal/TUI integration and real-host acceptance.
|
||||
|
||||
mod common;
|
||||
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
use mlua::{AnyUserData, Table, Value};
|
||||
use pmacs::cell::{CellCoord, CellSize, Glyph};
|
||||
use pmacs::editor::EditorState;
|
||||
use pmacs::lua_bindings::BufferIdLua;
|
||||
use pmacs::protocol::FrontendId;
|
||||
use pmacs::statusline::{
|
||||
StatuslineEvaluationOutcome, StatuslineEvaluationTarget, evaluate_statusline,
|
||||
};
|
||||
use pmacs::terminal::{TerminalProcessState, TerminalSpec, TerminalViewKey};
|
||||
use pmacs::window::WindowId;
|
||||
|
||||
use common::pty::{PmacsPty, spawn_pmacs_in_pty};
|
||||
|
||||
fn tick_until(
|
||||
state: &mut EditorState,
|
||||
timeout: Duration,
|
||||
mut done: impl FnMut(&EditorState) -> bool,
|
||||
) {
|
||||
let deadline = Instant::now() + timeout;
|
||||
loop {
|
||||
state.tick_processes();
|
||||
if done(state) {
|
||||
return;
|
||||
}
|
||||
assert!(Instant::now() < deadline, "terminal condition timed out");
|
||||
thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
}
|
||||
|
||||
fn lua_string(value: &str) -> String {
|
||||
format!("{:?}", value)
|
||||
}
|
||||
|
||||
fn snapshot_text(snapshot: &pmacs::terminal::TerminalSnapshot) -> String {
|
||||
let mut text = String::new();
|
||||
for cell in &snapshot.cells {
|
||||
match &cell.glyph {
|
||||
Glyph::Char(ch) => text.push(*ch),
|
||||
Glyph::Cluster(bytes) => text.push_str(&String::from_utf8_lossy(bytes)),
|
||||
Glyph::Continuation => {}
|
||||
}
|
||||
}
|
||||
text
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lua_surface_is_strict_fresh_transactional_and_context_safe() {
|
||||
let mut state = EditorState::new();
|
||||
let command_lua = lua_string("/bin/sh");
|
||||
|
||||
let baseline_sessions = state.terminal_manager.borrow().len();
|
||||
let baseline_buffers = state.core.borrow().registry.borrow().ids().len();
|
||||
{
|
||||
let lua = state.lua_host.lua();
|
||||
let error = lua
|
||||
.load(format!(
|
||||
"return pmacs.terminal.open {{ command = {command_lua}, unknown = true }}"
|
||||
))
|
||||
.eval::<Value>()
|
||||
.expect_err("unknown open field must fail");
|
||||
assert!(error.to_string().contains("unknown field `unknown`"));
|
||||
}
|
||||
assert_eq!(state.terminal_manager.borrow().len(), baseline_sessions);
|
||||
assert_eq!(
|
||||
state.core.borrow().registry.borrow().ids().len(),
|
||||
baseline_buffers
|
||||
);
|
||||
|
||||
{
|
||||
let lua = state.lua_host.lua();
|
||||
let kind: String = lua
|
||||
.load(format!(
|
||||
r#"
|
||||
TERM_BUFFER = pmacs.terminal.open {{
|
||||
command = {command_lua},
|
||||
args = {{ "-c", "printf 'copy-me\\n'; sleep 30" }},
|
||||
rows = 4,
|
||||
cols = 30,
|
||||
}}
|
||||
local first = pmacs.terminal.state(TERM_BUFFER)
|
||||
first.process.kind = "poisoned"
|
||||
first.injected = true
|
||||
local second = pmacs.terminal.state(TERM_BUFFER)
|
||||
assert(second.injected == nil)
|
||||
assert(pmacs.terminal.resize == nil)
|
||||
return second.process.kind
|
||||
"#
|
||||
))
|
||||
.eval()
|
||||
.expect("open terminal and read fresh state");
|
||||
assert_eq!(kind, "running");
|
||||
}
|
||||
|
||||
let buffer_id = {
|
||||
let userdata: AnyUserData = state
|
||||
.lua_host
|
||||
.lua()
|
||||
.globals()
|
||||
.get("TERM_BUFFER")
|
||||
.expect("terminal buffer global");
|
||||
userdata
|
||||
.borrow::<BufferIdLua>()
|
||||
.expect("BufferId userdata")
|
||||
.0
|
||||
};
|
||||
let buffer_name = state
|
||||
.core
|
||||
.borrow()
|
||||
.registry
|
||||
.borrow()
|
||||
.get(buffer_id)
|
||||
.expect("terminal identity buffer")
|
||||
.name()
|
||||
.to_owned();
|
||||
assert_eq!(buffer_name, "*terminal:sh*");
|
||||
tick_until(&mut state, Duration::from_secs(5), |state| {
|
||||
state
|
||||
.terminal_manager
|
||||
.borrow()
|
||||
.snapshot(buffer_id)
|
||||
.is_some_and(|snapshot| snapshot_text(&snapshot).contains("copy-me"))
|
||||
});
|
||||
|
||||
let frontend_id = FrontendId::LOCAL;
|
||||
let window_id = state.core.borrow().active_window_id();
|
||||
assert!(state.sync_terminal_layout(frontend_id, CellSize::new(8, 30)));
|
||||
let snapshots = state.prepare_terminal_views(frontend_id, CellSize::new(8, 30));
|
||||
assert!(snapshots.contains_key(&window_id));
|
||||
let modeline = evaluate_statusline(
|
||||
state.lua_host.lua(),
|
||||
&state.core,
|
||||
&state.statusline_registry,
|
||||
StatuslineEvaluationTarget::Grid { frontend_id },
|
||||
);
|
||||
let StatuslineEvaluationOutcome::Ready(windows) = modeline.outcome else {
|
||||
panic!("terminal statusline provider must evaluate successfully");
|
||||
};
|
||||
assert!(
|
||||
windows
|
||||
.iter()
|
||||
.flat_map(|window| &window.right)
|
||||
.any(|segment| segment.text.starts_with("TERM")),
|
||||
"built-in terminal statusline provider must report live process state"
|
||||
);
|
||||
let snapshot = snapshots.get(&window_id).expect("active terminal snapshot");
|
||||
let snapshot_text = snapshot_text(snapshot);
|
||||
let start = snapshot_text
|
||||
.find("copy-me")
|
||||
.unwrap_or_else(|| panic!("copy probe missing from projected snapshot: {snapshot_text:?}"));
|
||||
let cols = usize::try_from(snapshot.size.cols).expect("column count fits");
|
||||
let row = u32::try_from(start / cols).expect("row fits");
|
||||
let col = u32::try_from(start % cols).expect("column fits");
|
||||
let view_key = TerminalViewKey::new(frontend_id, window_id, buffer_id);
|
||||
{
|
||||
let mut manager = state.terminal_manager.borrow_mut();
|
||||
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)));
|
||||
}
|
||||
state.dispatch_key(
|
||||
frontend_id,
|
||||
KeyEvent::new(KeyCode::Char('w'), KeyModifiers::ALT),
|
||||
);
|
||||
let (clipboard_frontend, clipboard) = state
|
||||
.core
|
||||
.borrow_mut()
|
||||
.take_pending_clipboard()
|
||||
.expect("terminal copy must queue host clipboard bytes");
|
||||
assert_eq!(clipboard_frontend, frontend_id);
|
||||
assert_eq!(clipboard, b"copy-me");
|
||||
{
|
||||
let lua = state.lua_host.lua();
|
||||
let status: Table = lua
|
||||
.load(format!(
|
||||
r#"
|
||||
local first = pmacs.terminal.view_state {{
|
||||
frontend = 1, window = {}, buffer = TERM_BUFFER, active = true
|
||||
}}
|
||||
first.injected = true
|
||||
local second = pmacs.terminal.view_state {{
|
||||
frontend = 1, window = {}, buffer = TERM_BUFFER, active = true
|
||||
}}
|
||||
assert(second.injected == nil)
|
||||
return second
|
||||
"#,
|
||||
window_id.raw(),
|
||||
window_id.raw()
|
||||
))
|
||||
.eval()
|
||||
.expect("fresh exact view state");
|
||||
assert!(status.get::<bool>("at_bottom").expect("at_bottom"));
|
||||
|
||||
let (ok, error): (bool, String) = lua
|
||||
.load(
|
||||
r#"
|
||||
local ok, err = pcall(function() pmacs.terminal.scroll(1) end)
|
||||
return ok, tostring(err)
|
||||
"#,
|
||||
)
|
||||
.eval()
|
||||
.expect("pcall implicit scroll");
|
||||
assert!(!ok);
|
||||
assert!(error.contains("interactive frontend context"));
|
||||
}
|
||||
|
||||
state
|
||||
.terminal_manager
|
||||
.borrow_mut()
|
||||
.terminate(buffer_id, &mut state.process_supervisor.borrow_mut())
|
||||
.expect("terminate terminal child");
|
||||
tick_until(&mut state, Duration::from_secs(5), |state| {
|
||||
state
|
||||
.terminal_manager
|
||||
.borrow()
|
||||
.snapshot(buffer_id)
|
||||
.is_some_and(|snapshot| !matches!(snapshot.process, TerminalProcessState::Running))
|
||||
});
|
||||
state
|
||||
.core
|
||||
.borrow_mut()
|
||||
.kill_buffer(buffer_id)
|
||||
.expect("kill retained terminal buffer");
|
||||
state.tick_processes();
|
||||
assert_eq!(state.terminal_manager.borrow().len(), baseline_sessions);
|
||||
assert_eq!(
|
||||
state.core.borrow().registry.borrow().ids().len(),
|
||||
baseline_buffers
|
||||
);
|
||||
|
||||
state.process_supervisor.borrow_mut().shutdown();
|
||||
let _error = state
|
||||
.lua_host
|
||||
.lua()
|
||||
.load(format!(
|
||||
"return pmacs.terminal.open {{ command = {command_lua} }}"
|
||||
))
|
||||
.eval::<Value>()
|
||||
.expect_err("closed supervisor must make spawn fail transactionally");
|
||||
assert_eq!(state.terminal_manager.borrow().len(), baseline_sessions);
|
||||
assert_eq!(
|
||||
state.core.borrow().registry.borrow().ids().len(),
|
||||
baseline_buffers
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_screen_keeps_view_scroll_selection_and_controller_independent() {
|
||||
let mut state = EditorState::new();
|
||||
let mut spec = TerminalSpec::new("/bin/sh");
|
||||
spec.args = vec![
|
||||
"-c".into(),
|
||||
"i=0; while [ $i -lt 30 ]; do printf 'row%02d\\n' \"$i\"; i=$((i+1)); done; sleep 30"
|
||||
.into(),
|
||||
];
|
||||
spec.rows = 6;
|
||||
spec.cols = 24;
|
||||
let buffer_id = state
|
||||
.terminal_manager
|
||||
.borrow_mut()
|
||||
.open(
|
||||
spec,
|
||||
&mut state.core.borrow_mut(),
|
||||
&mut state.process_supervisor.borrow_mut(),
|
||||
)
|
||||
.expect("open terminal");
|
||||
tick_until(&mut state, Duration::from_secs(5), |state| {
|
||||
state
|
||||
.terminal_manager
|
||||
.borrow()
|
||||
.snapshot(buffer_id)
|
||||
.is_some_and(|snapshot| snapshot_text(&snapshot).contains("row29"))
|
||||
});
|
||||
|
||||
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 mut manager = state.terminal_manager.borrow_mut();
|
||||
let first_tail = manager
|
||||
.snapshot_for_view(first, size)
|
||||
.expect("first snapshot");
|
||||
let second_tail = manager
|
||||
.snapshot_for_view(second, size)
|
||||
.expect("second snapshot");
|
||||
assert_eq!(first_tail.title, second_tail.title);
|
||||
assert_eq!(first_tail.process, second_tail.process);
|
||||
|
||||
assert!(manager.scroll_lines(first, 3));
|
||||
let first_status = manager.view_status(first).expect("first status");
|
||||
let second_status = manager.view_status(second).expect("second status");
|
||||
assert_eq!(first_status.scroll_offset, 3);
|
||||
assert_eq!(second_status.scroll_offset, 0);
|
||||
|
||||
assert!(manager.begin_selection(first, size, CellCoord::new(0, 0)));
|
||||
assert!(manager.finish_selection(first, size, CellCoord::new(0, 4)));
|
||||
assert!(
|
||||
manager
|
||||
.view_status(first)
|
||||
.expect("selected status")
|
||||
.selection
|
||||
);
|
||||
assert!(
|
||||
!manager
|
||||
.view_status(second)
|
||||
.expect("passive status")
|
||||
.selection
|
||||
);
|
||||
assert!(
|
||||
!manager
|
||||
.copy_selection(first)
|
||||
.expect("copied selection")
|
||||
.is_empty()
|
||||
);
|
||||
assert!(manager.copy_selection(second).is_none());
|
||||
|
||||
assert!(manager.claim_controller(first));
|
||||
assert!(manager.claim_controller(second));
|
||||
assert_eq!(
|
||||
manager.controller_view_for_frontend(FrontendId(11)),
|
||||
None,
|
||||
"one terminal session has one most-recent controller"
|
||||
);
|
||||
assert_eq!(
|
||||
manager.controller_view_for_frontend(FrontendId(22)),
|
||||
Some(second)
|
||||
);
|
||||
manager.detach_frontend(FrontendId(11));
|
||||
assert!(manager.view_status(first).is_none());
|
||||
assert!(manager.view_status(second).is_some());
|
||||
assert!(
|
||||
manager
|
||||
.controller_view_for_frontend(FrontendId(11))
|
||||
.is_none()
|
||||
);
|
||||
assert_eq!(
|
||||
manager.controller_view_for_frontend(FrontendId(22)),
|
||||
Some(second)
|
||||
);
|
||||
}
|
||||
|
||||
state
|
||||
.terminal_manager
|
||||
.borrow_mut()
|
||||
.terminate(buffer_id, &mut state.process_supervisor.borrow_mut())
|
||||
.expect("terminate terminal child");
|
||||
}
|
||||
|
||||
fn wait_for_output(pty: &PmacsPty, needle: &[u8], timeout: Duration) {
|
||||
let deadline = Instant::now() + timeout;
|
||||
loop {
|
||||
if pty
|
||||
.output()
|
||||
.windows(needle.len())
|
||||
.any(|window| window == needle)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
let output = pty.output();
|
||||
let start = output.len().saturating_sub(4_000);
|
||||
panic!(
|
||||
"host output never contained {:?}; tail: {}",
|
||||
String::from_utf8_lossy(needle),
|
||||
output[start..].escape_ascii()
|
||||
);
|
||||
}
|
||||
thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
}
|
||||
|
||||
fn wait_for_file(path: &Path, timeout: Duration) -> Vec<u8> {
|
||||
let deadline = Instant::now() + timeout;
|
||||
loop {
|
||||
if let Ok(bytes) = fs::read(path) {
|
||||
return bytes;
|
||||
}
|
||||
assert!(
|
||||
Instant::now() < deadline,
|
||||
"file was not published: {}",
|
||||
path.display()
|
||||
);
|
||||
thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn real_tui_terminal_smoke_restores_host_after_output_input_resize_scroll_copy_and_bell() {
|
||||
let temp = tempfile::TempDir::new().expect("tempdir");
|
||||
let config_root = temp.path().join("config");
|
||||
let config_dir = config_root.join("pmacs");
|
||||
let state_root = temp.path().join("state");
|
||||
let input_path = temp.path().join("child-input");
|
||||
let size_path = temp.path().join("child-size");
|
||||
fs::create_dir_all(&config_dir).expect("config dir");
|
||||
|
||||
let probe = format!(
|
||||
concat!(
|
||||
"import os\n",
|
||||
"def read_exact(count):\n",
|
||||
" data = b''\n",
|
||||
" while len(data) < count:\n",
|
||||
" data += os.read(0, count - len(data))\n",
|
||||
" return data\n",
|
||||
"def read_until(marker):\n",
|
||||
" data = b''\n",
|
||||
" while marker not in data:\n",
|
||||
" data += os.read(0, 4096)\n",
|
||||
"os.write(1, b'\\x1b[?1049h\\x1b[2J')\n",
|
||||
"for i in range(20): os.write(1, b'alt%02d\\n' % i)\n",
|
||||
"os.write(1, b'VTERM_ALT_READY')\n",
|
||||
"read_until(b'ALT_GATE\\n')\n",
|
||||
"os.write(1, b'\\x1b[?1049l')\n",
|
||||
"for i in range(40): os.write(1, b'main%02d\\n' % i)\n",
|
||||
"os.write(1, b'VTERM_MAIN_READY\\x07')\n",
|
||||
"data = read_exact(18)\n",
|
||||
"open({:?}, 'wb').write(data)\n",
|
||||
"size = os.get_terminal_size(0)\n",
|
||||
"open({:?}, 'w').write(f'{{size.lines}} {{size.columns}}\\n')\n"
|
||||
),
|
||||
input_path.to_str().expect("UTF-8 input path"),
|
||||
size_path.to_str().expect("UTF-8 size path")
|
||||
);
|
||||
let init = format!(
|
||||
r#"
|
||||
local terminal_buffer = pmacs.terminal.open {{
|
||||
command = "/bin/sh",
|
||||
args = {{ "-c", "exec /usr/bin/python3 -c \"$1\"", "pmacs-vterm-probe", {} }},
|
||||
rows = 10,
|
||||
cols = 40,
|
||||
scrollback_rows = 200,
|
||||
}}
|
||||
local ticks = 0
|
||||
pmacs.hook.add("process.after-tick", function()
|
||||
ticks = ticks + 1
|
||||
if ticks == 120 then
|
||||
pmacs.terminal.send(terminal_buffer, "ALT_GATE\n")
|
||||
elseif ticks == 180 then
|
||||
pmacs.terminal.send(terminal_buffer, "VTERM_INPUT_SMOKE\n")
|
||||
end
|
||||
end)
|
||||
"#,
|
||||
lua_string(&probe)
|
||||
);
|
||||
fs::write(config_dir.join("init.lua"), init).expect("write init.lua");
|
||||
|
||||
let mut pty = spawn_pmacs_in_pty(
|
||||
&[],
|
||||
&[
|
||||
("XDG_CONFIG_HOME", config_root.as_path()),
|
||||
("PMACS_STATE_HOME", state_root.as_path()),
|
||||
("TERM", Path::new("xterm-256color")),
|
||||
],
|
||||
24,
|
||||
80,
|
||||
);
|
||||
wait_for_output(&pty, b"VTERM_ALT_READY", Duration::from_secs(10));
|
||||
|
||||
pty.resize(30, 90).expect("resize host PTY");
|
||||
thread::sleep(Duration::from_millis(150));
|
||||
pty.write_input(b"\x1bv").expect("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");
|
||||
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");
|
||||
pty.write_input(b"\x1b[200~PASTE_AFTER_EXIT\x1b[201~")
|
||||
.expect("route a real host paste event");
|
||||
let size = String::from_utf8(wait_for_file(&size_path, Duration::from_secs(5)))
|
||||
.expect("UTF-8 stty size");
|
||||
assert_eq!(size.trim(), "28 90", "child PTY must receive cell geometry");
|
||||
thread::sleep(Duration::from_millis(200));
|
||||
pty.write_input(b"\x03\x18\x03").expect("quit pmacs");
|
||||
|
||||
let status = pty
|
||||
.wait_for_exit(Duration::from_secs(5))
|
||||
.expect("pmacs should exit after terminal smoke");
|
||||
assert!(status.success(), "pmacs exit status: {status:?}");
|
||||
thread::sleep(Duration::from_millis(50));
|
||||
let output = pty.output();
|
||||
assert!(
|
||||
output.windows(8).any(|window| window == b"\x1b[?1049h"),
|
||||
"pmacs must enter its host alternate screen"
|
||||
);
|
||||
assert!(
|
||||
output.windows(8).any(|window| window == b"\x1b[?1049l"),
|
||||
"pmacs must restore the host main screen"
|
||||
);
|
||||
assert!(
|
||||
output.contains(&0x07),
|
||||
"one active-terminal BEL must reach the local host"
|
||||
);
|
||||
assert!(
|
||||
output.windows(8).any(|window| window == b"\x1b[?2004l"),
|
||||
"pmacs must disable host bracketed paste on exit"
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue