,
) -> impl IntoView {
view! {
+ {rects.into_iter().map(render_selection_rect).collect_view()}
{visible.into_iter().map(render_terminal_row).collect_view()}
}
}
+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! { }
+}
+
fn render_terminal_row(row: TerminalRow) -> impl IntoView {
view! {
@@ -826,12 +1034,48 @@ fn measure_cell_size(measure_ref: NodeRef) -> (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) {
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.
diff --git a/app/src/terminal/core.rs b/app/src/terminal/core.rs
index ebbde7a..c2f098b 100644
--- a/app/src/terminal/core.rs
+++ b/app/src/terminal/core.rs
@@ -221,6 +221,44 @@ impl TerminalCore {
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 = 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) --
pub fn size(&self) -> (u16, u16) {
@@ -324,6 +362,49 @@ pub struct TerminalRow {
pub segments: Vec,
}
+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 = 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)]
pub struct TerminalSegment {
pub text: String,
@@ -671,6 +752,32 @@ mod tests {
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]
fn clear_wipes_history_and_keeps_only_new_prompt() {
let mut core = TerminalCore::new(24, 80);
diff --git a/style/tailwind.css b/style/tailwind.css
index 239b8c7..29b2dfc 100644
--- a/style/tailwind.css
+++ b/style/tailwind.css
@@ -634,9 +634,12 @@
position: relative;
display: block;
min-height: 100%;
+ user-select: none;
}
.terminal-row {
+ position: relative;
+ z-index: 1;
height: 18px;
min-height: 18px;
max-height: 18px;
@@ -674,6 +677,13 @@
will-change: transform;
}
+ .terminal-selection {
+ position: absolute;
+ background: rgb(56 189 248 / 30%);
+ pointer-events: none;
+ z-index: 0;
+ }
+
.terminal-measure {
position: absolute;
left: -9999px;