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:
@@ -39,11 +39,13 @@ wasm-bindgen = "0.2"
|
|||||||
wasm-bindgen-futures = "0.4"
|
wasm-bindgen-futures = "0.4"
|
||||||
web-sys = { version = "0.3", default-features = false, features = [
|
web-sys = { version = "0.3", default-features = false, features = [
|
||||||
"BinaryType",
|
"BinaryType",
|
||||||
|
"Clipboard",
|
||||||
"ClipboardEvent",
|
"ClipboardEvent",
|
||||||
"CloseEvent",
|
"CloseEvent",
|
||||||
"console",
|
"console",
|
||||||
"DataTransfer",
|
"DataTransfer",
|
||||||
"Document",
|
"Document",
|
||||||
|
"DomRect",
|
||||||
"DomRectReadOnly",
|
"DomRectReadOnly",
|
||||||
"Element",
|
"Element",
|
||||||
"ErrorEvent",
|
"ErrorEvent",
|
||||||
@@ -53,6 +55,8 @@ web-sys = { version = "0.3", default-features = false, features = [
|
|||||||
"KeyboardEvent",
|
"KeyboardEvent",
|
||||||
"Location",
|
"Location",
|
||||||
"MessageEvent",
|
"MessageEvent",
|
||||||
|
"MouseEvent",
|
||||||
|
"Navigator",
|
||||||
"ResizeObserver",
|
"ResizeObserver",
|
||||||
"ResizeObserverEntry",
|
"ResizeObserverEntry",
|
||||||
"WebSocket",
|
"WebSocket",
|
||||||
|
|||||||
@@ -30,28 +30,31 @@
|
|||||||
| alt-screen 滚轮 → 方向键翻页 | `component.rs::handle_wheel` |
|
| alt-screen 滚轮 → 方向键翻页 | `component.rs::handle_wheel` |
|
||||||
| 基础按键:Ctrl-C/D/L、方向键、Enter/Tab/Esc/Backspace | `component.rs::map_key_to_terminal_input` |
|
| 基础按键:Ctrl-C/D/L、方向键、Enter/Tab/Esc/Backspace | `component.rs::map_key_to_terminal_input` |
|
||||||
| 粘贴(Ctrl-V / 浏览器菜单,含 bracketed paste) | `component.rs::handle_paste` / `prepare_paste` |
|
| 粘贴(Ctrl-V / 浏览器菜单,含 bracketed paste) | `component.rs::handle_paste` / `prepare_paste` |
|
||||||
|
| 文本选区 + 复制(自建模型,Ctrl-Shift-C,跨滚动) | `component.rs` 鼠标处理 / `core.rs::selection_text` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2. 追平项(按优先级)
|
## 2. 追平项(按优先级)
|
||||||
|
|
||||||
### P0 — 复制 / 粘贴 + 文本选区 ⭐ 投入产出比最高
|
### P0 — 复制 / 粘贴 + 文本选区 ⭐ ✅ 已完成
|
||||||
|
|
||||||
**现状**:粘贴**已完成**(见下);选区 / 复制尚未做。
|
**现状**:粘贴、选区、复制**全部完成并验证通过**。
|
||||||
|
|
||||||
**计划实现**:
|
**已实现**:
|
||||||
1. ~~**粘贴(先做,最简单)**~~ ✅ **已完成**
|
1. **粘贴** ✅
|
||||||
- `on:paste`(`ClipboardEvent`)读 `clipboard_data().get_data("text")`,归一换行为 `\r` 后作为 `Input` 发送。
|
- `on:paste`(`ClipboardEvent`)读 `clipboard_data().get_data("text")`,归一换行为 `\r` 后作为 `Input` 发送。
|
||||||
- bracketed paste:应用开启时(`core.bracketed_paste()`)包进 `ESC[200~ … ESC[201~`。
|
- bracketed paste:应用开启时(`core.bracketed_paste()`)包进 `ESC[200~ … ESC[201~`。
|
||||||
- 实现于 `component.rs::handle_paste` + 纯函数 `prepare_paste`(有单元测试)。
|
- 实现于 `component.rs::handle_paste` + 纯函数 `prepare_paste`。
|
||||||
- 右键不拦截,走浏览器默认菜单(避免依赖剪贴板读权限);Ctrl-V 为主路径。
|
- 右键走浏览器默认菜单(避免依赖剪贴板读权限);Ctrl-V 为主路径。
|
||||||
2. **选区(较复杂,下一步)**
|
2. **选区 + 复制** ✅(自建选区模型,xterm.js 风格)
|
||||||
- 因为渲染是 DOM(每行一个 `<div>`、每段一个 `<span>`),可优先尝试**浏览器原生选区**:给可见行允许 `user-select: text`,监听 `copy` 事件,从 `window.getSelection()` 取文本。
|
- 选区按**逻辑行列坐标**跟踪(`Selection { anchor, head }`),鼠标 `mousedown/move/up` 拖拽更新;`point_to_cell` 做像素→单元格换算(含 padding/scroll)。
|
||||||
- 但虚拟滚动只渲染可见窗口,跨窗口选区会断 → 需要把「逻辑行号 → 文本」的映射暴露给选区逻辑,从 `TerminalCore` 的 `history_rows + screen_rows` 直接取纯文本(已有 `row` → 文本的能力,测试里 `row_text` 即是)。
|
- 高亮 overlay:`selection_rects` 算每行矩形,渲染在虚拟滚动层内(共享 translateY,跟随滚动),不拆分文本段。
|
||||||
- MVP 可只支持**可见范围内选区 + 复制**,跨滚动选区列为后续。
|
- 复制取 `TerminalCore::selection_text(start, end)` —— 直接从 `history_rows + screen_cache` 数据模型按行列取文本,**跨滚动、输出流动时都正确**(不依赖 DOM)。
|
||||||
3. **快捷键**:Ctrl-Shift-C / Ctrl-Shift-V(终端惯例,避免和 Ctrl-C 中断冲突)。
|
- **Ctrl-Shift-C** 复制(Ctrl-C 保持 SIGINT);打字清除选区。
|
||||||
|
- 实现于 `component.rs`(`Selection`/`selection_rects`/`point_to_cell`/鼠标处理)+ `core.rs::selection_text` + `core.rs::TerminalRow::text_in_cols`,均有单元测试。
|
||||||
|
3. **快捷键**:Ctrl-Shift-C 复制 / Ctrl-V 粘贴。
|
||||||
|
|
||||||
**风险**:选区与虚拟滚动的交互是主要难点;建议 MVP 限定可见区,先把粘贴 + 可见区复制跑通。
|
**已知后续项(非阻塞)**:拖拽到视窗边缘时**没有自动滚动延伸选区**(需先滚到位再选);行内 wide+组合字符混排的列切片为近似。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ use wasm_bindgen::closure::Closure;
|
|||||||
use web_sys::js_sys::{Array, Function};
|
use web_sys::js_sys::{Array, Function};
|
||||||
use web_sys::{
|
use web_sys::{
|
||||||
BinaryType, ClipboardEvent, ErrorEvent, Event, HtmlElement, KeyboardEvent,
|
BinaryType, ClipboardEvent, ErrorEvent, Event, HtmlElement, KeyboardEvent,
|
||||||
MessageEvent, ResizeObserver, ResizeObserverEntry, WebSocket, WheelEvent,
|
MessageEvent, MouseEvent, ResizeObserver, ResizeObserverEntry, WebSocket,
|
||||||
|
WheelEvent,
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::core::{TerminalCore, TerminalRow, TerminalSegment};
|
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.
|
/// scrolling doesn't flash blank areas before the next frame arrives.
|
||||||
const VIRTUAL_OVERSCAN: usize = 5;
|
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)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
struct TerminalViewport {
|
struct TerminalViewport {
|
||||||
cols: u16,
|
cols: u16,
|
||||||
@@ -81,6 +111,14 @@ pub fn TerminalPanel() -> impl IntoView {
|
|||||||
// the virtual-scroll render closure knows which window to materialise.
|
// the virtual-scroll render closure knows which window to materialise.
|
||||||
let scroll_top = RwSignal::new(0_i32);
|
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 ------------------------------------------------
|
// -- WebSocket lifecycle ------------------------------------------------
|
||||||
|
|
||||||
Effect::new(move |_| {
|
Effect::new(move |_| {
|
||||||
@@ -110,6 +148,7 @@ pub fn TerminalPanel() -> impl IntoView {
|
|||||||
|
|
||||||
let viewport = measure_terminal_viewport(open_ref, open_measure);
|
let viewport = measure_terminal_viewport(open_ref, open_measure);
|
||||||
open_viewport.set(viewport);
|
open_viewport.set(viewport);
|
||||||
|
cell_size.set(measure_cell_size(open_measure));
|
||||||
|
|
||||||
open_core
|
open_core
|
||||||
.update_untracked(|core| core.resize(viewport.rows, viewport.cols));
|
.update_untracked(|core| core.resize(viewport.rows, viewport.cols));
|
||||||
@@ -241,6 +280,7 @@ pub fn TerminalPanel() -> impl IntoView {
|
|||||||
|
|
||||||
if viewport != resize_viewport.get_untracked() {
|
if viewport != resize_viewport.get_untracked() {
|
||||||
resize_viewport.set(viewport);
|
resize_viewport.set(viewport);
|
||||||
|
cell_size.set(measure_cell_size(resize_measure));
|
||||||
|
|
||||||
let mode_val = resize_mode.get_untracked();
|
let mode_val = resize_mode.get_untracked();
|
||||||
let st = resize_st.get_untracked();
|
let st = resize_st.get_untracked();
|
||||||
@@ -283,8 +323,31 @@ pub fn TerminalPanel() -> impl IntoView {
|
|||||||
let handle_keydown = {
|
let handle_keydown = {
|
||||||
let keydown_mode = view_mode;
|
let keydown_mode = view_mode;
|
||||||
let keydown_prog_scroll = programmatic_scroll_top;
|
let keydown_prog_scroll = programmatic_scroll_top;
|
||||||
|
let keydown_selection = selection;
|
||||||
|
let keydown_core = core_signal;
|
||||||
|
|
||||||
move |event: KeyboardEvent| {
|
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 {
|
let Some(socket) = ws_signal.get_untracked() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
@@ -293,6 +356,9 @@ pub fn TerminalPanel() -> impl IntoView {
|
|||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Typing replaces a selection, like a normal terminal.
|
||||||
|
keydown_selection.set(None);
|
||||||
|
|
||||||
if !keydown_mode.get_untracked().is_live() {
|
if !keydown_mode.get_untracked().is_live() {
|
||||||
keydown_mode.set(ViewMode::Live);
|
keydown_mode.set(ViewMode::Live);
|
||||||
sync_scroll_to_bottom(terminal_ref, keydown_prog_scroll);
|
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 |_| {
|
let handle_click = move |_| {
|
||||||
focus_terminal(terminal_ref);
|
focus_terminal(terminal_ref);
|
||||||
};
|
};
|
||||||
@@ -482,6 +617,9 @@ pub fn TerminalPanel() -> impl IntoView {
|
|||||||
on:scroll=handle_scroll
|
on:scroll=handle_scroll
|
||||||
on:wheel=handle_wheel
|
on:wheel=handle_wheel
|
||||||
on:paste=handle_paste
|
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">
|
<span node_ref=measure_ref class="terminal-measure" aria-hidden="true">
|
||||||
{MEASURE_SAMPLE_TEXT}
|
{MEASURE_SAMPLE_TEXT}
|
||||||
@@ -489,11 +627,14 @@ pub fn TerminalPanel() -> impl IntoView {
|
|||||||
<div class="terminal-grid">
|
<div class="terminal-grid">
|
||||||
{move || {
|
{move || {
|
||||||
// Subscribe to render_tick (frame-coalesced data
|
// Subscribe to render_tick (frame-coalesced data
|
||||||
// changes), scroll_top (scroll), and view_mode
|
// changes), scroll_top (scroll), view_mode, selection.
|
||||||
// (live/history switch).
|
|
||||||
let _ = render_tick.get();
|
let _ = render_tick.get();
|
||||||
let is_live = view_mode.get().is_live();
|
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();
|
let st = scroll_top.get();
|
||||||
// Read core untracked — the core signal itself never
|
// Read core untracked — the core signal itself never
|
||||||
// notifies; render_tick is the explicit render trigger.
|
// 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)
|
virtual_window(core, is_live, st, vp_rows)
|
||||||
})
|
})
|
||||||
.unwrap_or((0.0, 0.0, Vec::new()));
|
.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>
|
||||||
</div>
|
</div>
|
||||||
@@ -560,6 +707,57 @@ fn virtual_window(
|
|||||||
(total_px, offset_px, core.collect_window(start, end))
|
(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
|
/// Render the visible rows using absolute positioning inside a fixed-height
|
||||||
/// container.
|
/// container.
|
||||||
///
|
///
|
||||||
@@ -572,6 +770,7 @@ fn render_spaced_view(
|
|||||||
total_px: f64,
|
total_px: f64,
|
||||||
offset_px: f64,
|
offset_px: f64,
|
||||||
visible: Vec<TerminalRow>,
|
visible: Vec<TerminalRow>,
|
||||||
|
rects: Vec<SelectionRect>,
|
||||||
) -> impl IntoView {
|
) -> impl IntoView {
|
||||||
view! {
|
view! {
|
||||||
<div
|
<div
|
||||||
@@ -583,11 +782,20 @@ fn render_spaced_view(
|
|||||||
class="terminal-virtual-rows"
|
class="terminal-virtual-rows"
|
||||||
style=format!("transform:translateY({offset_px}px);")
|
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()}
|
{visible.into_iter().map(render_terminal_row).collect_view()}
|
||||||
</div>
|
</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 {
|
fn render_terminal_row(row: TerminalRow) -> impl IntoView {
|
||||||
view! {
|
view! {
|
||||||
<div class="terminal-row">
|
<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>) {
|
fn focus_terminal(terminal_ref: NodeRef<html::Div>) {
|
||||||
if let Some(element) = terminal_ref.get_untracked() {
|
if let Some(element) = terminal_ref.get_untracked() {
|
||||||
let _ = element.focus();
|
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
|
// Key mapping
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -931,6 +1175,52 @@ mod tests {
|
|||||||
assert_eq!(wheel_delta_to_rows(100_000.0, 0), 12);
|
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]
|
#[test]
|
||||||
fn prepare_paste_normalizes_newlines_and_wraps_when_bracketed() {
|
fn prepare_paste_normalizes_newlines_and_wraps_when_bracketed() {
|
||||||
// Plain paste: CRLF and LF both become CR, no wrapping.
|
// Plain paste: CRLF and LF both become CR, no wrapping.
|
||||||
|
|||||||
@@ -221,6 +221,44 @@ impl TerminalCore {
|
|||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The logical row at combined index `i` (history first, then screen).
|
||||||
|
fn row_at(&self, i: usize) -> Option<&TerminalRow> {
|
||||||
|
let hist_len = self.history_rows.len();
|
||||||
|
if i < hist_len {
|
||||||
|
self.history_rows.get(i)
|
||||||
|
} else {
|
||||||
|
self.screen_cache.get(i - hist_len)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract the text covered by a selection from `start` to `end`
|
||||||
|
/// (inclusive of `start`, exclusive of `end.col` on the last row), both in
|
||||||
|
/// `(row, col)` buffer coordinates. Reads straight from the row data, so it
|
||||||
|
/// works across the whole buffer regardless of what is currently rendered.
|
||||||
|
///
|
||||||
|
/// Per-row trailing whitespace is trimmed; rows are joined with `\n`.
|
||||||
|
pub fn selection_text(
|
||||||
|
&self,
|
||||||
|
start: (usize, usize),
|
||||||
|
end: (usize, usize),
|
||||||
|
) -> String {
|
||||||
|
let (start, end) = order_points(start, end);
|
||||||
|
let (sr, sc) = start;
|
||||||
|
let (er, ec) = end;
|
||||||
|
let mut lines: Vec<String> = Vec::new();
|
||||||
|
|
||||||
|
for r in sr..=er {
|
||||||
|
let Some(row) = self.row_at(r) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let from = if r == sr { sc } else { 0 };
|
||||||
|
let to = if r == er { ec } else { usize::MAX };
|
||||||
|
lines.push(row.text_in_cols(from, to));
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
// -- Convenience proxies for terminal metadata (all &self, no mutation) --
|
// -- Convenience proxies for terminal metadata (all &self, no mutation) --
|
||||||
|
|
||||||
pub fn size(&self) -> (u16, u16) {
|
pub fn size(&self) -> (u16, u16) {
|
||||||
@@ -324,6 +362,49 @@ pub struct TerminalRow {
|
|||||||
pub segments: Vec<TerminalSegment>,
|
pub segments: Vec<TerminalSegment>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl TerminalRow {
|
||||||
|
/// Plain text of the columns in `[from, to)` of this row, with trailing
|
||||||
|
/// whitespace trimmed. `to` may be `usize::MAX` to mean "to end of row".
|
||||||
|
///
|
||||||
|
/// Columns are walked using each segment's `cols` (grid-column) width. For
|
||||||
|
/// normal text one char maps to one column; the mapping is approximate only
|
||||||
|
/// for the rare mix of wide + combining chars in a single selection edge,
|
||||||
|
/// which trailing-trim and cell-accurate highlighting make unnoticeable.
|
||||||
|
pub fn text_in_cols(&self, from: usize, to: usize) -> String {
|
||||||
|
let mut out = String::new();
|
||||||
|
let mut col = 0usize;
|
||||||
|
for seg in &self.segments {
|
||||||
|
let seg_end = col + seg.cols;
|
||||||
|
if seg_end <= from {
|
||||||
|
col = seg_end;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if col >= to {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// This segment overlaps [from, to): emit the columns inside range.
|
||||||
|
let chars: Vec<char> = seg.text.chars().collect();
|
||||||
|
for (i, ch) in chars.iter().enumerate() {
|
||||||
|
// Distribute chars across the segment's columns 1:1 (clamped).
|
||||||
|
let ch_col = col + i.min(seg.cols.saturating_sub(1));
|
||||||
|
if ch_col >= from && ch_col < to {
|
||||||
|
out.push(*ch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
col = seg_end;
|
||||||
|
}
|
||||||
|
out.trim_end().to_owned()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Order two `(row, col)` points so the first is the top-left of the selection.
|
||||||
|
fn order_points(
|
||||||
|
a: (usize, usize),
|
||||||
|
b: (usize, usize),
|
||||||
|
) -> ((usize, usize), (usize, usize)) {
|
||||||
|
if a <= b { (a, b) } else { (b, a) }
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
pub struct TerminalSegment {
|
pub struct TerminalSegment {
|
||||||
pub text: String,
|
pub text: String,
|
||||||
@@ -671,6 +752,32 @@ mod tests {
|
|||||||
assert!(core.collect_window(total + 10, total + 20).is_empty());
|
assert!(core.collect_window(total + 10, total + 20).is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn selection_text_extracts_partial_and_multi_row_ranges() {
|
||||||
|
let mut core = TerminalCore::new(24, 80);
|
||||||
|
// Three known lines on screen.
|
||||||
|
core.process(b"hello world\r\n");
|
||||||
|
core.process(b"second line\r\n");
|
||||||
|
core.process(b"third\r\n");
|
||||||
|
|
||||||
|
// Rows 0..2 are the three lines (history empty: all on screen).
|
||||||
|
// Single-row partial: cols 0..5 of row 0 => "hello".
|
||||||
|
assert_eq!(core.selection_text((0, 0), (0, 5)), "hello");
|
||||||
|
// Single-row mid range: cols 6..11 of row 0 => "world".
|
||||||
|
assert_eq!(core.selection_text((0, 6), (0, 11)), "world");
|
||||||
|
|
||||||
|
// Multi-row: from row 0 col 6 to row 2 col 5 =>
|
||||||
|
// "world" (rest of row 0) + whole row 1 + "third" (start of row 2).
|
||||||
|
let text = core.selection_text((0, 6), (2, 5));
|
||||||
|
assert_eq!(text, "world\nsecond line\nthird");
|
||||||
|
|
||||||
|
// Trailing whitespace within a row is trimmed (cols past content).
|
||||||
|
assert_eq!(core.selection_text((0, 0), (0, 40)), "hello world");
|
||||||
|
|
||||||
|
// Reversed points are normalized.
|
||||||
|
assert_eq!(core.selection_text((0, 5), (0, 0)), "hello");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn clear_wipes_history_and_keeps_only_new_prompt() {
|
fn clear_wipes_history_and_keeps_only_new_prompt() {
|
||||||
let mut core = TerminalCore::new(24, 80);
|
let mut core = TerminalCore::new(24, 80);
|
||||||
|
|||||||
@@ -634,9 +634,12 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
display: block;
|
display: block;
|
||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.terminal-row {
|
.terminal-row {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
height: 18px;
|
height: 18px;
|
||||||
min-height: 18px;
|
min-height: 18px;
|
||||||
max-height: 18px;
|
max-height: 18px;
|
||||||
@@ -674,6 +677,13 @@
|
|||||||
will-change: transform;
|
will-change: transform;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.terminal-selection {
|
||||||
|
position: absolute;
|
||||||
|
background: rgb(56 189 248 / 30%);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.terminal-measure {
|
.terminal-measure {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: -9999px;
|
left: -9999px;
|
||||||
|
|||||||
Reference in New Issue
Block a user