Lab 14: Selection, Copy, Paste, and Scrollback
Background
The last frontend lab implements the features users judge a terminal by within ten seconds: selecting text with the mouse, copying it, pasting it, and scrolling back through history.
They are conceptually simple and full of edge cases: wrapped lines must join, trailing whitespace must not be copied, wide characters must be emitted once, and the selection must survive scrolling.
Why This Lab Matters
- The wrapped-line join is why Lab 7
stored a
wrappedflag per line. This is the payoff. - Scrollback rendering is the last piece of the render model, and it is what the mux client will need too.
Prerequisites
Predict First
- You select a path that wrapped across two lines and paste it. What should you get?
- You select a line with 60 characters in an 80-column terminal. Should the copy include 20 trailing spaces?
- You select text, then output scrolls. Where does the selection go?
- You select
日本語(3 characters, 6 cells). How many characters are copied?
Step 1: The Selection Model
#![allow(unused)] fn main() { #[derive(Copy, Clone, PartialEq, Eq, Debug)] pub enum SelectionMode { /// Character-wise: from an exact cell to an exact cell, following line order. Normal, /// Word-wise: expands to word boundaries. Double-click. Word, /// Line-wise: whole lines. Triple-click. Line, /// Rectangular: a column range across rows. Alt+drag. Block, } pub struct Selection { pub mode: SelectionMode, /// Anchored at the press; `end` follows the mouse. Either may be earlier. pub start: AbsolutePoint, pub end: AbsolutePoint, } /// A point in the SCROLLBACK-INCLUSIVE coordinate space. /// /// Using screen coordinates would make the selection slide as output scrolls — /// the user selected specific TEXT, not a specific screen position. Absolute /// line numbers (scrollback line 0 is the oldest ever) fix that for free. #[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Debug)] pub struct AbsolutePoint { pub line: u64, pub col: usize, } }
Tip: The absolute-coordinate decision is the one that makes everything else easy. Selections in screen coordinates require fixing up on every scroll, and you will get it wrong at the scrollback boundary. Convert to screen coordinates only in the renderer.
Step 2: Extracting the Text
This is where the edge cases live.
#![allow(unused)] fn main() { pub fn selected_text(term: &Terminal, sel: &Selection) -> String { let (start, end) = sel.normalized(); let mut out = String::new(); for line_no in start.line..=end.line { let Some(line) = term.absolute_line(line_no) else { continue }; let (c0, c1) = match sel.mode { SelectionMode::Block => (start.col.min(end.col), start.col.max(end.col)), _ => ( if line_no == start.line { start.col } else { 0 }, if line_no == end.line { end.col } else { line.len() }, ), }; let mut text = String::new(); let mut col = c0; while col < c1.min(line.len()) { let cell = line.cell(col); if cell.is_spacer() { // The second half of a wide character. Its content already came // out with the first half; emitting it again duplicates the char. col += 1; continue; } text.push_str(cell.grapheme()); col += 1; } // Trailing blanks are PADDING, not content. A terminal pads every line to // `cols`; copying that padding pastes dozens of spaces and is the second // most-complained-about terminal bug. // Exception: block mode, where the rectangle is what the user asked for. if sel.mode != SelectionMode::Block { let trimmed_len = text.trim_end().len(); text.truncate(trimmed_len); } out.push_str(&text); // THE WRAPPED-LINE RULE. A line that ended by wrapping is a continuation: // joining it with a newline breaks every copied URL and file path. This is // exactly why Line::wrapped exists. let is_last = line_no == end.line; let hard_break = !line.wrapped; if !is_last && (hard_break || sel.mode == SelectionMode::Block) { out.push('\n'); } } out } }
The four rules, restated because each is a real bug in shipped terminals:
| Rule | Bug if violated |
|---|---|
| Skip spacer cells | Wide characters duplicated: 日日本本語語 |
| Trim trailing blanks (except block mode) | Pasting a short line brings 40 spaces |
| Join wrapped lines | Copied URLs and paths get a spurious newline in the middle |
| Use absolute coordinates | The selection slides when output scrolls |
Step 3: Word and Line Selection
#![allow(unused)] fn main() { /// Word boundaries. The character class is a policy decision: a terminal that /// treats `/` as a separator makes double-clicking a path select one component, /// which is sometimes what you want and sometimes maddening. Make it /// configurable; default to "not whitespace and not a bracket". fn expand_to_word(term: &Terminal, p: AbsolutePoint) -> (AbsolutePoint, AbsolutePoint) { const SEPARATORS: &str = " \t\n\"'`()[]{}<>|;"; let line = term.absolute_line(p.line).unwrap(); let is_word = |c: usize| { line.cell(c).grapheme().chars().next() .map_or(false, |ch| !SEPARATORS.contains(ch)) }; if !is_word(p.col) { return (p, p); } let mut a = p.col; let mut b = p.col; while a > 0 && is_word(a - 1) { a -= 1; } while b + 1 < line.len() && is_word(b + 1) { b += 1; } (AbsolutePoint { line: p.line, col: a }, AbsolutePoint { line: p.line, col: b + 1 }) } /// Line selection follows the WRAPPED chain: triple-clicking a logical line that /// wrapped across three screen rows selects all three. fn expand_to_logical_line(term: &Terminal, p: AbsolutePoint) -> (AbsolutePoint, AbsolutePoint) { let mut first = p.line; while first > 0 && term.absolute_line(first - 1).map_or(false, |l| l.wrapped) { first -= 1; } let mut last = p.line; while term.absolute_line(last).map_or(false, |l| l.wrapped) { last += 1; } (AbsolutePoint { line: first, col: 0 }, AbsolutePoint { line: last, col: term.cols() }) } }
Step 4: Clipboard Integration
#![allow(unused)] fn main() { fn copy_selection(&mut self) { let Some(sel) = &self.selection else { return }; let text = selected_text(&self.terminal, sel); if text.is_empty() { return; } self.clipboard.set_text(&text); // On Linux, also set the PRIMARY selection (middle-click paste) if the // platform has one. Two independent clipboards is an X11/Wayland concept // that surprises people coming from macOS. #[cfg(target_os = "linux")] self.clipboard.set_primary(&text); } fn paste(&mut self) { let Some(text) = self.clipboard.get_text() else { return }; // Normalize line endings: a Windows clipboard carries \r\n, which would send // TWO line terminators to the shell and run the command twice. let text = text.replace("\r\n", "\r").replace('\n', "\r"); let bytes = terminal_input::encode_paste(&text, self.terminal.modes()); let _ = self.pty.write_all(&bytes); self.scroll_to_bottom(); } }
Warning: The
\n→\rnormalization is not cosmetic. The keyboard sends CR for Enter, andICRNLconverts it. A pasted\nbypasses that path and behaves differently. Send CR, as the keyboard would.
The multi-line paste warning. When bracketed paste is off and the text contains a line
terminator, pasting executes a command immediately. Several terminals now prompt. It is a product
decision — but make it deliberately, and note that a pasted \n from a web page is a real attack
vector:
#![allow(unused)] fn main() { fn should_warn(&self, text: &str) -> bool { !self.terminal.modes().contains(Mode::BRACKETED_PASTE) && (text.contains('\n') || text.contains('\r')) } }
Step 5: Scrollback Rendering
#![allow(unused)] fn main() { pub struct ViewportState { /// Lines scrolled up from the bottom. 0 = live view. pub offset: usize, } impl RenderSnapshot { pub fn from_terminal_with_viewport(t: &Terminal, view: &ViewportState, ...) -> Self { // Rows come from scrollback and the grid, joined: // scrollback: [ ... older ... , len-offset ... len-1 ] // grid: [ 0 .. rows-1 ] // With offset > 0, the first `offset` rows come from the tail of // scrollback and the rest from the top of the grid. // ... } } }
Scrollback interaction rules:
| Rule | Reason |
|---|---|
| Typing scrolls to the bottom | Otherwise you type into a view you cannot see |
| New output does not scroll to the bottom while the user is scrolled up | Otherwise reading history is impossible on a chatty terminal |
| The alternate screen has no scrollback: the wheel becomes arrow keys | There is nothing to scroll |
| Selection uses absolute coordinates | It survives both scrolling and new output |
| Shift+PgUp/PgDn scroll by a page | Convention |
A scroll indicator when offset > 0 | Otherwise the user does not know why output "stopped" |
Expected Output
1. Drag across "hello world" → highlighted; Ctrl+Shift+C; paste elsewhere
→ "hello world"
2. A long path wrapped across two rows:
/very/long/path/that/wraps/across/the/terminal/width/file.txt
Select it all, paste → ONE line, no newline in the middle.
3. A 60-character line in an 80-column terminal:
Select the whole row, paste → 60 characters, NOT 60 + 20 spaces.
4. printf '日本語\n', select all, paste → "日本語" (3 chars, not 6)
5. Double-click a word → the word. Triple-click → the whole logical line,
including its wrapped continuations.
6. Alt+drag → a rectangular block; each row keeps its own trailing spaces.
7. Scroll up with the wheel, then type → jumps to the bottom, input goes through.
8. In `less`, the wheel scrolls (translated to arrow keys) rather than moving
your terminal's scrollback.
Debugging Steps
Copied text has a newline in the middle of a URL
The wrapped-line join is missing. Check Line::wrapped is actually being set by the print path.
Copied text has trailing spaces
Not trimming. Remember the block-mode exception.
CJK characters are duplicated
Spacer cells are not being skipped.
The selection slides when output scrolls
Screen coordinates instead of absolute.
Paste runs the command twice
\r\n normalization missing — two line terminators.
Middle-click paste does nothing on Linux
Primary selection not set on copy.
Scrolling up and then getting output jumps you to the bottom
An auto-scroll on output that should only apply when offset == 0.
Experiment
CLAIM. The wrapped-line join is what separates a usable terminal from an annoying one, and it is directly testable.
METHOD.
# In an 80-column terminal:
echo "/very/long/path/that/definitely/wraps/across/eighty/columns/and/keeps/going/file.txt"
# Select the whole thing with the mouse, copy, and paste into a text editor.
# Do this in: your terminal, xterm, Ghostty/kitty/Alacritty, and inside tmux.
PREDICTION. Which of them insert a newline? Does tmux change the answer for the terminal it is
running in? Why might it?
RESULT. Note that tmux re-emits the pane content into the outer terminal, so the outer
terminal's idea of which lines wrapped comes from what tmux sent — not from the original program.
That is a preview of the compositing problem in Section 4.
Test
#![allow(unused)] fn main() { #[test] fn wrapped_lines_join_without_a_newline() { let mut t = Terminal::new(5, 10); t.advance(b"abcdefghijKLMNO"); // wraps after 10 let sel = Selection::all(&t); assert_eq!(selected_text(&t, &sel).trim_end(), "abcdefghijKLMNO"); } #[test] fn hard_newlines_are_preserved() { let mut t = Terminal::new(5, 10); t.advance(b"abc\ndef\n"); let sel = Selection::all(&t); assert_eq!(selected_text(&t, &sel).trim_end(), "abc\ndef"); } #[test] fn trailing_padding_is_not_copied() { let mut t = Terminal::new(3, 20); t.advance(b"short\n"); let sel = Selection::line(0); assert_eq!(selected_text(&t, &sel), "short"); } #[test] fn block_selection_keeps_its_rectangle() { // In block mode the trailing spaces ARE the selection. let mut t = Terminal::new(3, 20); t.advance(b"ab\ncd\n"); let sel = Selection::block((0, 0), (1, 5)); assert_eq!(selected_text(&t, &sel), "ab \ncd "); } #[test] fn wide_characters_are_copied_once() { let mut t = Terminal::new(3, 20); t.advance("日本語".as_bytes()); let sel = Selection::all(&t); assert_eq!(selected_text(&t, &sel).trim_end(), "日本語"); } #[test] fn selection_survives_scrolling() { // Absolute coordinates mean the selection follows the TEXT, not the screen. let mut t = Terminal::new(3, 20); t.advance(b"target\n"); let sel = Selection::word_at(&t, AbsolutePoint { line: 0, col: 2 }); for _ in 0..10 { t.advance(b"filler\n"); } assert_eq!(selected_text(&t, &sel), "target"); } #[test] fn triple_click_selects_the_whole_logical_line() { let mut t = Terminal::new(5, 10); t.advance(b"aaaaaaaaaabbbbbbbbbbcccc\n"); // wraps twice let sel = Selection::logical_line_at(&t, AbsolutePoint { line: 1, col: 0 }); assert_eq!(selected_text(&t, &sel).trim_end(), "aaaaaaaaaabbbbbbbbbbcccc"); } #[test] fn paste_normalizes_line_endings_to_cr() { let modes = TerminalModes::default(); let out = normalize_and_encode_paste("a\r\nb\nc", &modes); assert_eq!(out, b"a\rb\rc".to_vec(), "a pasted CRLF must not send two terminators"); } }
Challenge Extensions
- Semantic selection using OSC 133 marks: select a whole command's output with one click.
- URL detection and clicking without OSC 8: regex-scan visible rows, underline on hover, Ctrl+click to open. Note the security consideration — show the URL before opening.
- Search in scrollback, with match highlighting and next/previous navigation.
- Selection auto-scroll: dragging past the top or bottom edge scrolls.
- A configurable word-separator set, with a test that shows path-selection behavior in both configurations.
- Rectangular selection with wide characters — decide what happens when the rectangle splits a wide character, and test it.
Deliverables
- Character, word, line, and block selection.
- Absolute coordinates; the selection survives scrolling and new output.
- The four extraction rules implemented and tested.
-
Copy to clipboard (and primary selection on Linux); paste with
\rnormalization and bracketed paste. - Scrollback rendering with all six interaction rules.
- Alt-screen wheel translation.
- All eight tests above passing.
- The cross-terminal wrapped-line comparison from the experiment.
Validation / Self-check
- Why absolute rather than screen coordinates for a selection?
- State the four text-extraction rules and the bug each one prevents.
- Why does block mode keep trailing spaces when normal mode does not?
- What is the
wrappedflag for, and which lab created it? - Why must a pasted
\nbecome\r? - When should a paste warn the user, and why is it a real security concern?
- Why does the mouse wheel send arrow keys on the alternate screen?
- Why must new output not scroll the viewport when the user has scrolled up?
- What is the primary selection, and which platforms have it?
- Why does copying from inside
tmuxbehave differently, and what does that tell you about compositing?
Section 3 Complete
You have a real graphical terminal: a window, a shell, correct input encoding for every key, CPU rendering with a glyph atlas and damage tracking, selection, clipboard, and scrollback.
What you do not have: more than one session. That is Section 4.
Next: Section 4 — The Multiplexer.