feat(terminal): text selection + copy (custom model)

Drag to select terminal text and copy it with Ctrl-Shift-C. Built as a
custom selection model (xterm.js-style) rather than native selection,
because the virtual scroll only keeps ~60 rows in the DOM and rebuilds
them every frame — native selection would be destroyed and couldn't span
scrolled-out rows.

core.rs:
- selection_text(start, end) extracts a (row,col)→(row,col) range straight
  from history_rows + screen_cache, so copy is correct across the whole
  buffer regardless of what is currently rendered
- TerminalRow::text_in_cols slices a row by grid columns (using the
  segment `cols` widths), trimming trailing whitespace

component.rs:
- Selection { anchor, head } in logical buffer coords; mousedown/move/up
  drag updates it; point_to_cell maps pixels→cell (padding + scrollTop)
- selection_rects computes per-row highlight rects clipped to the rendered
  window; rendered as an overlay inside the translated rows layer so the
  highlight follows scrolling without splitting text segments
- cell_size signal kept in sync with viewport measurement
- Ctrl-Shift-C copies via navigator.clipboard.write_text; Ctrl-C is left
  untouched (still SIGINT); typing clears the selection

style: .terminal-selection overlay, user-select:none on the grid, rows
above the highlight via z-index.

web-sys: add DomRect, MouseEvent, Navigator, Clipboard.

Adds point_to_cell / selection_rects / selection_text unit tests (16
terminal tests total). Marks P0 done in TERMINAL_ROADMAP.md.

Known follow-up: no auto-scroll when dragging past the viewport edge
(scroll first, then select); wide+combining column slicing is approximate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
zhangheng
2026-06-17 16:24:32 +08:00
parent c886befac7
commit 8863977c0d
5 changed files with 431 additions and 17 deletions

View File

@@ -8,7 +8,8 @@ use wasm_bindgen::closure::Closure;
use web_sys::js_sys::{Array, Function};
use web_sys::{
BinaryType, ClipboardEvent, ErrorEvent, Event, HtmlElement, KeyboardEvent,
MessageEvent, ResizeObserver, ResizeObserverEntry, WebSocket, WheelEvent,
MessageEvent, MouseEvent, ResizeObserver, ResizeObserverEntry, WebSocket,
WheelEvent,
};
use super::core::{TerminalCore, TerminalRow, TerminalSegment};
@@ -27,6 +28,35 @@ const SCROLL_LINE_HEIGHT: f64 = 18.0;
/// scrolling doesn't flash blank areas before the next frame arrives.
const VIRTUAL_OVERSCAN: usize = 5;
/// `.terminal-screen` inner padding in px (CSS `padding: 16px`). Mouse
/// coordinates subtract this to map onto the cell grid.
const TERMINAL_PADDING: f64 = 16.0;
/// A text selection in logical buffer coordinates `(row, col)`. `anchor` is
/// where the drag began, `head` follows the pointer; either may be the
/// top-left depending on drag direction.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct Selection {
anchor: (usize, usize),
head: (usize, usize),
}
impl Selection {
/// (top-left, bottom-right) ordered for rendering / extraction.
fn ordered(self) -> ((usize, usize), (usize, usize)) {
if self.anchor <= self.head {
(self.anchor, self.head)
} else {
(self.head, self.anchor)
}
}
/// True when the selection is empty (anchor == head): nothing to copy.
fn is_empty(self) -> bool {
self.anchor == self.head
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct TerminalViewport {
cols: u16,
@@ -81,6 +111,14 @@ pub fn TerminalPanel() -> impl IntoView {
// the virtual-scroll render closure knows which window to materialise.
let scroll_top = RwSignal::new(0_i32);
// Measured cell size (width, height) in px, kept in sync with the viewport
// measurement. Used to map mouse coordinates onto the logical cell grid.
let cell_size = RwSignal::new((DEFAULT_CELL_WIDTH, DEFAULT_CELL_HEIGHT));
// Active text selection in buffer coords, and whether a drag is in progress.
let selection = RwSignal::new(None::<Selection>);
let selecting = RwSignal::new(false);
// -- WebSocket lifecycle ------------------------------------------------
Effect::new(move |_| {
@@ -110,6 +148,7 @@ pub fn TerminalPanel() -> impl IntoView {
let viewport = measure_terminal_viewport(open_ref, open_measure);
open_viewport.set(viewport);
cell_size.set(measure_cell_size(open_measure));
open_core
.update_untracked(|core| core.resize(viewport.rows, viewport.cols));
@@ -241,6 +280,7 @@ pub fn TerminalPanel() -> impl IntoView {
if viewport != resize_viewport.get_untracked() {
resize_viewport.set(viewport);
cell_size.set(measure_cell_size(resize_measure));
let mode_val = resize_mode.get_untracked();
let st = resize_st.get_untracked();
@@ -283,8 +323,31 @@ pub fn TerminalPanel() -> impl IntoView {
let handle_keydown = {
let keydown_mode = view_mode;
let keydown_prog_scroll = programmatic_scroll_top;
let keydown_selection = selection;
let keydown_core = core_signal;
move |event: KeyboardEvent| {
// Ctrl-Shift-C copies the current selection (Ctrl-C is left alone so
// it still sends SIGINT).
if event.ctrl_key()
&& event.shift_key()
&& matches!(event.key().as_str(), "c" | "C")
{
if let Some(sel) =
keydown_selection.get_untracked().filter(|s| !s.is_empty())
{
let (start, end) = sel.ordered();
let text = keydown_core
.try_with_untracked(|core| {
core.selection_text(start, end)
})
.unwrap_or_default();
copy_to_clipboard(&text);
}
event.prevent_default();
return;
}
let Some(socket) = ws_signal.get_untracked() else {
return;
};
@@ -293,6 +356,9 @@ pub fn TerminalPanel() -> impl IntoView {
return;
};
// Typing replaces a selection, like a normal terminal.
keydown_selection.set(None);
if !keydown_mode.get_untracked().is_live() {
keydown_mode.set(ViewMode::Live);
sync_scroll_to_bottom(terminal_ref, keydown_prog_scroll);
@@ -430,6 +496,75 @@ pub fn TerminalPanel() -> impl IntoView {
}
};
// -- Mouse selection ----------------------------------------------------
//
// Drag to select a range of terminal text. Selection is tracked in logical
// buffer coordinates (not DOM), so it survives re-renders and works across
// rows that have scrolled out of the rendered window.
let cell_from_event = move |event: &MouseEvent| -> Option<(usize, usize)> {
let element = terminal_ref.get_untracked()?;
let rect = element.get_bounding_client_rect();
let local_x = f64::from(event.client_x()) - rect.left();
let local_y = f64::from(event.client_y()) - rect.top();
let (cell_w, cell_h) = cell_size.get_untracked();
let cols = current_viewport.get_untracked().cols as usize;
let total = core_signal
.try_with_untracked(|core| core.total_rows())
.unwrap_or(0);
Some(point_to_cell(
local_x,
local_y,
f64::from(element.scroll_top()),
cell_w,
cell_h,
cols,
total,
))
};
let handle_mousedown = {
let down_selection = selection;
let down_selecting = selecting;
move |event: MouseEvent| {
if event.button() != 0 {
return; // only left button starts a selection
}
let Some(cell) = cell_from_event(&event) else {
return;
};
down_selection.set(Some(Selection {
anchor: cell,
head: cell,
}));
down_selecting.set(true);
}
};
let handle_mousemove = {
let move_selection = selection;
let move_selecting = selecting;
move |event: MouseEvent| {
if !move_selecting.get_untracked() {
return;
}
let Some(cell) = cell_from_event(&event) else {
return;
};
move_selection.update(|sel| {
if let Some(s) = sel {
s.head = cell;
}
});
}
};
let handle_mouseup = {
let up_selecting = selecting;
move |_event: MouseEvent| {
up_selecting.set(false);
}
};
let handle_click = move |_| {
focus_terminal(terminal_ref);
};
@@ -482,6 +617,9 @@ pub fn TerminalPanel() -> impl IntoView {
on:scroll=handle_scroll
on:wheel=handle_wheel
on:paste=handle_paste
on:mousedown=handle_mousedown
on:mousemove=handle_mousemove
on:mouseup=handle_mouseup
>
<span node_ref=measure_ref class="terminal-measure" aria-hidden="true">
{MEASURE_SAMPLE_TEXT}
@@ -489,11 +627,14 @@ pub fn TerminalPanel() -> impl IntoView {
<div class="terminal-grid">
{move || {
// Subscribe to render_tick (frame-coalesced data
// changes), scroll_top (scroll), and view_mode
// (live/history switch).
// changes), scroll_top (scroll), view_mode, selection.
let _ = render_tick.get();
let is_live = view_mode.get().is_live();
let vp_rows = current_viewport.get_untracked().rows as usize;
let sel = selection.get();
let (cell_w, _cell_h) = cell_size.get();
let vp = current_viewport.get_untracked();
let vp_rows = vp.rows as usize;
let cols = vp.cols as usize;
let st = scroll_top.get();
// Read core untracked — the core signal itself never
// notifies; render_tick is the explicit render trigger.
@@ -504,7 +645,13 @@ pub fn TerminalPanel() -> impl IntoView {
virtual_window(core, is_live, st, vp_rows)
})
.unwrap_or((0.0, 0.0, Vec::new()));
render_spaced_view(total_px, offset_px, visible)
let window_start =
(offset_px / SCROLL_LINE_HEIGHT).round() as usize;
let window_end = window_start + visible.len();
let rects = selection_rects(
sel, window_start, window_end, cols, cell_w,
);
render_spaced_view(total_px, offset_px, visible, rects)
}}
</div>
</div>
@@ -560,6 +707,57 @@ fn virtual_window(
(total_px, offset_px, core.collect_window(start, end))
}
/// A highlight rectangle for one selected row, positioned relative to the top
/// of the rendered (translated) rows layer.
#[derive(Clone, Copy, Debug)]
struct SelectionRect {
top_px: f64,
left_px: f64,
width_px: f64,
}
/// Compute the per-row highlight rects for `sel`, clipped to the rendered
/// window `[window_start, window_end)`. Returns empty for no/empty selection.
fn selection_rects(
sel: Option<Selection>,
window_start: usize,
window_end: usize,
cols: usize,
cell_w: f64,
) -> Vec<SelectionRect> {
let Some(sel) = sel else {
return Vec::new();
};
if sel.is_empty() {
return Vec::new();
}
let ((sr, sc), (er, ec)) = sel.ordered();
let first = sr.max(window_start);
let last = er.min(window_end.saturating_sub(1));
let mut rects = Vec::new();
for r in first..=last {
if r >= window_end {
break;
}
let (col_a, col_b) = if sr == er {
(sc, ec)
} else if r == sr {
(sc, cols)
} else if r == er {
(0, ec)
} else {
(0, cols)
};
let col_b = col_b.max(col_a);
rects.push(SelectionRect {
top_px: (r - window_start) as f64 * SCROLL_LINE_HEIGHT,
left_px: col_a as f64 * cell_w,
width_px: (col_b - col_a) as f64 * cell_w,
});
}
rects
}
/// Render the visible rows using absolute positioning inside a fixed-height
/// container.
///
@@ -572,6 +770,7 @@ fn render_spaced_view(
total_px: f64,
offset_px: f64,
visible: Vec<TerminalRow>,
rects: Vec<SelectionRect>,
) -> impl IntoView {
view! {
<div
@@ -583,11 +782,20 @@ fn render_spaced_view(
class="terminal-virtual-rows"
style=format!("transform:translateY({offset_px}px);")
>
{rects.into_iter().map(render_selection_rect).collect_view()}
{visible.into_iter().map(render_terminal_row).collect_view()}
</div>
}
}
fn render_selection_rect(rect: SelectionRect) -> impl IntoView {
let style = format!(
"top:{}px;left:{}px;width:{}px;height:{}px;",
rect.top_px, rect.left_px, rect.width_px, SCROLL_LINE_HEIGHT
);
view! { <div class="terminal-selection" style=style aria-hidden="true"></div> }
}
fn render_terminal_row(row: TerminalRow) -> impl IntoView {
view! {
<div class="terminal-row">
@@ -826,12 +1034,48 @@ fn measure_cell_size(measure_ref: NodeRef<html::Span>) -> (f64, f64) {
)
}
/// Map a pointer position to a logical `(row, col)` cell.
///
/// `local_x`/`local_y` are relative to the scroll container's top-left
/// (clientX/Y minus its bounding rect). Padding is removed, the scroll offset
/// is added to Y, then the result is divided by the cell size and clamped to
/// the grid. Pure for unit-testing.
fn point_to_cell(
local_x: f64,
local_y: f64,
scroll_top: f64,
cell_w: f64,
cell_h: f64,
cols: usize,
total_rows: usize,
) -> (usize, usize) {
let x = (local_x - TERMINAL_PADDING).max(0.0);
let y = (local_y - TERMINAL_PADDING + scroll_top).max(0.0);
let col = (x / cell_w.max(1.0)).floor() as usize;
let row = (y / cell_h.max(1.0)).floor() as usize;
(
row.min(total_rows.saturating_sub(1)),
col.min(cols.saturating_sub(1)),
)
}
fn focus_terminal(terminal_ref: NodeRef<html::Div>) {
if let Some(element) = terminal_ref.get_untracked() {
let _ = element.focus();
}
}
/// Write text to the system clipboard (fire-and-forget). Called from a user
/// gesture (Ctrl-Shift-C keydown), which browsers allow without a prompt in
/// secure contexts (localhost counts as secure).
fn copy_to_clipboard(text: &str) {
if text.is_empty() {
return;
}
let clipboard = window().navigator().clipboard();
let _ = clipboard.write_text(text);
}
// ---------------------------------------------------------------------------
// Key mapping
// ---------------------------------------------------------------------------
@@ -931,6 +1175,52 @@ mod tests {
assert_eq!(wheel_delta_to_rows(100_000.0, 0), 12);
}
#[test]
fn point_to_cell_maps_pixels_with_padding_and_scroll() {
let (cw, ch) = (8.0, 18.0);
// Top-left content origin is at (PADDING, PADDING) = (16,16).
assert_eq!(point_to_cell(16.0, 16.0, 0.0, cw, ch, 80, 100), (0, 0));
// One cell right and one row down.
assert_eq!(point_to_cell(16.0 + 8.0, 16.0 + 18.0, 0.0, cw, ch, 80, 100), (1, 1));
// Scroll offset shifts the row mapping down.
assert_eq!(point_to_cell(16.0, 16.0, 180.0, cw, ch, 80, 100), (10, 0));
// Clamped to grid bounds.
assert_eq!(point_to_cell(99999.0, 99999.0, 0.0, cw, ch, 80, 100), (99, 79));
// Inside the padding clamps to (0,0), never negative.
assert_eq!(point_to_cell(0.0, 0.0, 0.0, cw, ch, 80, 100), (0, 0));
}
#[test]
fn selection_rects_clip_to_window_and_span_columns() {
let cw = 8.0;
let cols = 80;
// Single-row selection cols 2..6 on row 5, window [0,30).
let sel = Some(Selection { anchor: (5, 2), head: (5, 6) });
let rects = selection_rects(sel, 0, 30, cols, cw);
assert_eq!(rects.len(), 1);
assert_eq!(rects[0].top_px, 5.0 * SCROLL_LINE_HEIGHT);
assert_eq!(rects[0].left_px, 2.0 * cw);
assert_eq!(rects[0].width_px, 4.0 * cw);
// Multi-row selection rows 4..6: first row partial, middle full, last partial.
let sel = Some(Selection { anchor: (4, 10), head: (6, 3) });
let rects = selection_rects(sel, 0, 30, cols, cw);
assert_eq!(rects.len(), 3);
assert_eq!(rects[0].left_px, 10.0 * cw); // first row starts at col 10
assert_eq!(rects[1].width_px, cols as f64 * cw); // middle row full width
assert_eq!(rects[2].left_px, 0.0); // last row from col 0
assert_eq!(rects[2].width_px, 3.0 * cw);
// Selection entirely above the window → clipped out.
let sel = Some(Selection { anchor: (1, 0), head: (2, 5) });
assert!(selection_rects(sel, 10, 30, cols, cw).is_empty());
// Empty selection → no rects.
let sel = Some(Selection { anchor: (5, 2), head: (5, 2) });
assert!(selection_rects(sel, 0, 30, cols, cw).is_empty());
assert!(selection_rects(None, 0, 30, cols, cw).is_empty());
}
#[test]
fn prepare_paste_normalizes_newlines_and_wraps_when_bracketed() {
// Plain paste: CRLF and LF both become CR, no wrapping.