From f04d2e89cf9d49c55cab53e1fca84e59d610933c Mon Sep 17 00:00:00 2001 From: Amin Borghei Date: Fri, 14 Aug 2026 12:33:57 +0200 Subject: [PATCH] feat: text selection and clipboard copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ink captures the mouse for wheel-scroll, which meant giving up the terminal's own text selection. It now does the job itself. - v/V start a character- or line-wise selection; hjkl/arrows, w/b, 0/$, g/G and Ctrl+d/Ctrl+u move it, the view follows, y copies, Esc cancels - mouse drag selects and copies on release; double-click takes the word, triple-click the line - c labels the code blocks on screen and copies one's raw source - Y copies the current section's markdown; inside link hints it copies a URL instead of opening it - copies go out over OSC 52 (crosses SSH, wrapped for tmux) and through a native helper when one is on PATH; no new dependencies - selection edges are measured in terminal cells and snap to grapheme boundaries, so a wide glyph is never cut in half - themes gained optional selection_bg/selection_fg; older theme files still load and derive a legible selection from their existing colors Heading text and fence info strings are now sanitized where they are recorded rather than only where they are drawn as lines — they reach the TOC sidebar and the copy status line, neither of which is a StyledLine. --- CHANGELOG.md | 15 ++ Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 37 ++- docs/index.html | 4 + docs/llms-full.txt | 9 +- docs/llms.txt | 1 + src/app.rs | 546 ++++++++++++++++++++++++++++++++++++++- src/cli.rs | 23 ++ src/clipboard.rs | 264 +++++++++++++++++++ src/config.rs | 2 + src/input/keymap.rs | 4 + src/input/mod.rs | 86 ++++++ src/input/preset.rs | 8 + src/layout/mod.rs | 176 ++++++++++++- src/lib.rs | 2 + src/render/mod.rs | 209 ++++++++++++++- src/selection.rs | 524 +++++++++++++++++++++++++++++++++++++ src/theme/builtin.rs | 14 + src/theme/mod.rs | 80 ++++++ src/toc.rs | 3 + tests/cli.rs | 10 + tests/plain_snapshots.rs | 1 + tests/render_viewport.rs | 24 +- tests/security.rs | 1 + 25 files changed, 2026 insertions(+), 21 deletions(-) create mode 100644 src/clipboard.rs create mode 100644 src/selection.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 7685ba9..90b2c3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## 0.7.0 — 2026-08-14 + +### Added +- **Text selection and clipboard copy.** ink captures the mouse for wheel-scroll, which meant the terminal's own selection was unavailable; it now does the job itself: + - `v` starts a character-wise selection, `V` a line-wise one. `h j k l`/arrows, `w`/`b`, `0`/`$`, `g`/`G`, and `Ctrl+d`/`Ctrl+u` move the cursor end, the view follows it, `y` copies, `Esc` cancels. Selection edges are measured in terminal cells and snap to grapheme boundaries, so a wide CJK glyph or a ZWJ emoji is never cut in half. + - Mouse drag selects and copies on release; double-click takes the word under the pointer, triple-click the line. + - `c` labels every code block on screen and copies the one you pick — the block's raw source, with none of the box borders, syntax escapes, or margin that surround it on screen. + - `Y` copies the markdown source of the section you are reading, down to the next heading of the same or higher level. Inside link-hint mode (`f`), `Y` flips the labels from "open this link" to "copy this URL". + - Copies go out over **OSC 52** (works across SSH, wrapped for tmux) *and* through a native helper (`pbcopy`, `wl-copy`, `xclip`, `xsel`, `clip.exe`) when one is on `PATH`. No new dependencies. `[behavior] clipboard = "auto" | "osc52" | "native" | "off"` narrows or disables it. + - New action IDs `select_mode`, `select_line_mode`, `copy_code`, and `copy_section` are rebindable like any other; visual-mode motions themselves are a fixed table, as with search and slides. + - Themes gained `selection_bg` / `selection_fg`. Both are optional — a theme file written before this release still loads and derives a legible selection from its existing colors. + +### Fixed +- **Heading text is stripped of terminal control bytes where it is recorded**, not only where it is drawn as a line. A heading carrying an ESC byte reached the TOC sidebar unfiltered before; it now goes through the same sanitizer as body text, which also covers the new "copied section" status line. Fence info strings get the same treatment. + ## 0.6.7 — 2026-07-26 ### Added diff --git a/Cargo.lock b/Cargo.lock index 4016540..709af3d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1521,7 +1521,7 @@ dependencies = [ [[package]] name = "ink-md" -version = "0.6.7" +version = "0.7.0" dependencies = [ "anyhow", "assert_cmd", diff --git a/Cargo.toml b/Cargo.toml index 8b07931..068687a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ink-md" -version = "0.6.7" +version = "0.7.0" edition = "2021" authors = ["Amin Borghei "] description = "The most advanced terminal markdown reader" diff --git a/README.md b/README.md index d64bda6..259e276 100644 --- a/README.md +++ b/README.md @@ -146,6 +146,31 @@ Switch between them with `Tab` and `Shift+Tab`. Press `/` to search within a document. Matches highlight inline; press Enter to lock in the search, then `n`/`N` to cycle forward and backward through results. `Esc` clears the highlights. +### Select text and copy it + +ink captures the mouse for wheel-scroll, which used to mean giving up your terminal's own text +selection. Now it does the job itself, and does it better — it knows where a code block starts and +where ink's own decoration ends. + +- **`v`** starts a character-wise selection, **`V`** a line-wise one. Move with `h j k l`, arrows, + `w`/`b` (word), `0`/`$` (line ends), `g`/`G` (document ends), `Ctrl+d`/`Ctrl+u` (half page). + **`y`** copies and exits; `Esc` cancels. +- **Drag with the mouse** to select, release to copy. Double-click takes the word, triple-click the + line. +- **`c`** labels every code block on screen — press its letter to copy the block's *raw* source: no + borders, no line numbers, no syntax-highlighting escapes. +- **`Y`** copies the markdown source of the section you are reading (heading included, down to the + next heading of the same level). Inside link-hint mode (`f`), `Y` switches the labels from + "open this link" to "copy this URL". + +Copied text reaches the clipboard two ways at once: an OSC 52 escape sequence, which works over +SSH and inside tmux, and a native helper (`pbcopy`, `wl-copy`, `xclip`, `xsel`, `clip.exe`) when one +is installed locally. No extra dependencies, nothing to configure. Set `clipboard` in your config to +narrow it down or turn it off. + +`v` and `V` copy lines the way they are drawn, ink's heading bars and code-box borders included — +they select what you can see. Reach for `c` and `Y` when you want the source instead. + ### Open links from the keyboard Press `f` to label every link on screen with a letter. Press that letter to open web and mail links in your browser, or to follow a relative `.md` link right inside ink. @@ -218,7 +243,10 @@ ink diff old.md new.md | `Ctrl+d` / `Ctrl+u` | Half-page down / up | | `n` / `N` | Next / previous heading (cycles search matches after a search) | | `/` | Search | -| `f` | Link-hint mode (open/follow links by letter) | +| `f` | Link-hint mode (open links by letter, `Y` to copy one instead) | +| `v` / `V` | Select text — character-wise / line-wise (`y` copies, `Esc` cancels) | +| `c` | Copy a code block by letter | +| `Y` | Copy the current section as markdown | | `t` | Toggle table of contents | | `T` | Theme picker (choice is saved to config) | | `?` | Help overlay | @@ -275,6 +303,13 @@ browser_loop = false # Set false to let your terminal handle the mouse (click-to-open links, text # selection) instead of ink capturing it for wheel-scroll. Default: true mouse_capture = true + +# How copied text reaches the clipboard: +# auto - OSC 52 escape (crosses SSH and tmux) plus a native helper, if present +# osc52 - escape sequence only +# native - pbcopy / wl-copy / xclip / xsel / clip.exe only +# off - copying is disabled +clipboard = "auto" ``` ### Custom themes diff --git a/docs/index.html b/docs/index.html index a527aa4..9854d40 100644 --- a/docs/index.html +++ b/docs/index.html @@ -325,6 +325,10 @@

Search

Table of contents

Sidebar showing every heading. Tracks your scroll position.

+
+

Select & copy

+

v/V or a mouse drag selects text, y copies it. c copies a code block's raw source, Y the current section.

+

Open links by keyboard

Press f to label links, then a letter to open or follow them.

diff --git a/docs/llms-full.txt b/docs/llms-full.txt index 4152867..e57ea48 100644 --- a/docs/llms-full.txt +++ b/docs/llms-full.txt @@ -54,7 +54,7 @@ ink is a feature-rich terminal markdown reader built in Rust. It renders markdow - Help overlay: press ? for a popup of the active keybindings - Table of contents sidebar showing heading structure, tracks scroll position - Navigation history with back/forward ([ ] keys or Alt+Arrow) -- Mouse wheel scrolling (can be disabled via behavior.mouse_capture so the terminal handles clicks/selection) +- Mouse wheel scrolling, plus drag-to-select (can be disabled via behavior.mouse_capture so the terminal handles clicks/selection instead) - Event-driven redraw (no CPU when idle) and virtualized rendering for large documents - Responsive to terminal resizing; shows a message instead of breaking on very small terminals @@ -106,7 +106,11 @@ ink is a feature-rich terminal markdown reader built in Rust. It renders markdow - Ctrl+d/Ctrl+u: half-page down/up - n/N: next/previous heading (cycles search matches after a search) - /: search -- f: link-hint mode (open/follow links by letter) +- f: link-hint mode (open/follow links by letter; Y inside the overlay copies the URL instead) +- v/V: select text character-wise / line-wise (motions hjkl, arrows, w/b, 0/$, g/G, Ctrl+d/Ctrl+u; y copies, Esc cancels) +- Mouse: drag selects and copies on release; double-click selects a word, triple-click a line +- c: label the code blocks on screen and copy one's raw source by letter +- Y: copy the current section as markdown source - ?: help overlay - t: toggle table of contents - T: theme picker (saved to config) @@ -131,6 +135,7 @@ frontmatter = false [behavior] browser_loop = false # return to browser after closing a doc mouse_capture = true # set false to let the terminal handle clicks/selection +clipboard = "auto" # auto | osc52 | native | off — how copied text reaches the clipboard [keybindings] preset = "emacs" # default | vim | emacs diff --git a/docs/llms.txt b/docs/llms.txt index 4c8760c..e9be388 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -13,6 +13,7 @@ ink is a command-line tool that renders markdown files in the terminal with rich - Supports wikilink syntax: [[page]] and [[page|display text]] - Renders inline/block math ($...$, $$...$$) in code style and resolves :emoji: shortcodes - Interactive TUI with multi-tab support, in-document search (n/N to cycle matches), keyboard link opening (press f), a help overlay (?), and a table of contents sidebar +- Text selection and clipboard copy: v/V for character- and line-wise visual selection (y copies), mouse drag with double-click word and triple-click line, c to copy a code block's raw source, Y to copy the current section's markdown; delivered over OSC 52 (works across SSH/tmux) and a native helper (pbcopy/wl-copy/xclip/xsel/clip.exe) - File browser mode for exploring directories of markdown files - 8 built-in color themes with auto dark/light detection, live theme picker that saves to config, plus custom TOML themes - Presentation mode (split on --- separators), navigate with arrows/space diff --git a/src/app.rs b/src/app.rs index 54717bc..a4da1c0 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,8 +1,10 @@ +use crate::clipboard::{self, ClipboardMode, CopyOutcome}; use crate::input::{self, Action}; use crate::layout; use crate::parser::frontmatter; use crate::render; use crate::search::SearchState; +use crate::selection::{self, Pos, SelMode, Selection}; use crate::stats; use crate::theme; use crate::toc::TocState; @@ -17,14 +19,22 @@ use crossterm::terminal::{ use ratatui::prelude::*; use ratatui::widgets::Paragraph; use std::io; -use std::time::Duration; +use std::time::{Duration, Instant}; struct Tab { filename: String, #[allow(dead_code)] source: String, + /// The markdown actually parsed: frontmatter stripped, wikilinks expanded. + /// Heading `source_line`s index into this, so `Y` slices sections from it. + content: String, styled_lines: Vec, ratatui_lines: Vec>, + /// Per-line rendered text. Selection columns, clipboard extraction, and + /// hint placement all measure against these. + plain: Vec, + /// Fenced code blocks and their raw source, for `c` (copy code block). + code_blocks: Vec, /// Per-line text, lowercased once, for allocation-free search scans. lowered: Vec, scroll_offset: usize, @@ -65,6 +75,26 @@ struct LinkHint { url: String, } +/// What the letters in the link-hint overlay do. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum HintKind { + /// Open the link (the `f` default). + Open, + /// Copy the URL to the clipboard (`Y` inside the overlay). + CopyUrl, +} + +/// A labeled code block in the current viewport, for `c` (copy code block). +struct CodeHint { + label: char, + /// Screen row (relative to the document area) to paint the label on. + row: u16, + /// Column to paint it at — the right end of the block's top border. + col: u16, + lang: String, + source: String, +} + /// Available themes for the theme picker. const THEME_LIST: &[&str] = &[ "dark", @@ -157,6 +187,22 @@ fn run_inner( let mut help_open = false; // Active link-hint overlay: labeled links currently on screen. let mut link_hints: Vec = Vec::new(); + let mut hint_kind = HintKind::Open; + // Active code-block hint overlay. + let mut code_hints: Vec = Vec::new(); + // Active text selection (visual mode or an in-progress mouse drag). + let mut sel: Option = None; + let mut visual_mode = false; + // A left button is down and the pointer has moved since it went down. + let mut dragging = false; + let mut drag_moved = false; + // (when, column, row, consecutive clicks) — crossterm reports no click + // count, so double/triple clicks are timed here. + let mut last_click: Option<(Instant, u16, u16, u8)> = None; + // Transient status message ("copied 84 chars") and when it was set. + let mut flash: Option<(String, Instant)> = None; + // Where the document was last drawn, for translating mouse coordinates. + let mut doc_rect = Rect::new(0, 0, 0, 0); // Bumped on every theme change so tabs know their cached layout is stale. let mut theme_gen: u32 = 0; @@ -306,6 +352,14 @@ fn run_inner( } } + // A copy message is worth two seconds of the status bar, no longer. + if let Some((_, at)) = flash { + if at.elapsed() >= Duration::from_secs(2) { + flash = None; + dirty = true; + } + } + let tab = &tabs[active_tab]; let total_lines = tab.ratatui_lines.len(); let max_scroll = total_lines.saturating_sub(viewport_height as usize); @@ -399,7 +453,11 @@ fn run_inner( render::render_toc(frame, toc_area, &tab.toc.headings, tab.toc.selected, &t); } - // Render document (with search highlights) + // Remembered for the next mouse event: screen coordinates only + // mean something relative to where the document was drawn. + doc_rect = doc_area; + + // Render document (with search highlights and selection) render::render_document_with_search( frame, doc_area, @@ -407,6 +465,8 @@ fn run_inner( tab.scroll_offset, total_lines, &search, + sel.as_ref(), + &tab.plain, &t, ); @@ -464,7 +524,20 @@ fn run_inner( .iter() .map(|h| (h.label, h.url.clone())) .collect(); - render::render_link_hints(frame, main_area, &hints, &t); + let title = match hint_kind { + HintKind::Open => { + "Follow link — press a letter, Y to copy instead, Esc to cancel" + } + HintKind::CopyUrl => "Copy link URL — press a letter, Esc to cancel", + }; + render::render_link_hints(frame, main_area, &hints, title, &t); + } + + // Code-block hint labels, painted on the blocks themselves. + if !code_hints.is_empty() { + let labels: Vec<(char, u16, u16)> = + code_hints.iter().map(|h| (h.label, h.row, h.col)).collect(); + render::render_code_hints(frame, doc_area, &labels, &t); } // Help overlay @@ -472,8 +545,10 @@ fn run_inner( render::render_help(frame, main_area, &t); } - // Bottom bar: search input OR keybindings + stats - if search.active { + // Bottom bar: copy result OR search input OR keybindings + stats + if let Some((ref msg, _)) = flash { + render::render_flash_bar(frame, bottom_bar_area, msg, &t); + } else if search.active { render::render_search_bar(frame, bottom_bar_area, &search, &t); } else { render::render_bottom_bar( @@ -495,8 +570,10 @@ fn run_inner( // Handle input let input_mode = if search.active { input::InputMode::Search - } else if !link_hints.is_empty() { + } else if !link_hints.is_empty() || !code_hints.is_empty() { input::InputMode::LinkHint + } else if visual_mode { + input::InputMode::Visual } else if args.slides && !theme_picker_open && !help_open { input::InputMode::Slides } else { @@ -514,15 +591,46 @@ fn run_inner( continue; } - // Link-hint overlay is modal: a label opens that link, Esc cancels. + // Code-block hint overlay is modal: a label copies that block. + if !code_hints.is_empty() { + if let Action::LinkHint(c) = action { + if let Some(hint) = code_hints.iter().find(|h| h.label == c) { + let what = if hint.lang.is_empty() { + "code block".to_string() + } else { + format!("code block ({})", hint.lang) + }; + flash = Some(( + copy_text(&hint.source, args.clipboard, &what), + Instant::now(), + )); + } + } + code_hints.clear(); + continue; + } + + // Link-hint overlay is modal: a label opens (or copies) that link, + // Y switches between the two, Esc cancels. if !link_hints.is_empty() { match action { + Action::HintCopyToggle => { + hint_kind = HintKind::CopyUrl; + } Action::LinkHint(c) => { if let Some(hint) = link_hints.iter().find(|h| h.label == c) { - open_link( - &hint.url, &mut tabs, active_tab, &args, terminal, theme_gen, - graphics, - ); + match hint_kind { + HintKind::Open => open_link( + &hint.url, &mut tabs, active_tab, &args, terminal, theme_gen, + graphics, + ), + HintKind::CopyUrl => { + flash = Some(( + copy_text(&hint.url, args.clipboard, "link"), + Instant::now(), + )); + } + } } link_hints.clear(); } @@ -579,6 +687,85 @@ fn run_inner( continue; } + // Visual mode is modal: motions move the cursor end of the + // selection, `y` copies it, Esc/q leaves without copying. + if visual_mode { + let plain = &tabs[active_tab].plain; + let Some(mut cursel) = sel else { + visual_mode = false; + continue; + }; + let cur = cursel.cursor; + let line_text = plain.get(cur.line).map(|t| t.as_str()).unwrap_or(""); + let vh = viewport_height as usize; + let mut moved = true; + match action { + Action::Yank => { + let text = cursel.extract(plain); + let what = format!("{} chars", text.chars().count()); + flash = Some((copy_text(&text, args.clipboard, &what), Instant::now())); + visual_mode = false; + sel = None; + continue; + } + Action::SelCancel | Action::ExitApp => { + visual_mode = false; + sel = None; + continue; + } + Action::SelectMode => cursel.mode = SelMode::Char, + Action::SelectLineMode => { + cursel.mode = if cursel.mode == SelMode::Line { + SelMode::Char + } else { + SelMode::Line + } + } + Action::SelDown(n) => cursel.cursor.line = cur.line.saturating_add(n as usize), + Action::SelUp(n) => cursel.cursor.line = cur.line.saturating_sub(n as usize), + Action::SelLeft(n) => cursel.cursor.col = cur.col.saturating_sub(n as usize), + Action::SelRight(n) => cursel.cursor.col = cur.col.saturating_add(n as usize), + Action::SelWordNext => { + cursel.cursor.col = selection::next_word_col(line_text, cur.col) + } + Action::SelWordPrev => { + cursel.cursor.col = selection::prev_word_col(line_text, cur.col) + } + Action::SelLineStart => cursel.cursor.col = 0, + Action::SelLineEnd => { + cursel.cursor.col = selection::line_width(line_text).saturating_sub(1) + } + Action::SelDocStart => cursel.cursor = Pos::new(0, 0), + Action::SelDocEnd => { + cursel.cursor = Pos::new(plain.len().saturating_sub(1), 0); + } + Action::SelPageDown => { + cursel.cursor.line = cur.line.saturating_add(vh.max(1) - 1) + } + Action::SelPageUp => { + cursel.cursor.line = cur.line.saturating_sub(vh.max(1) - 1) + } + Action::Resize(_, _) => { + resize_pending = Some(std::time::Instant::now()); + moved = false; + } + _ => moved = false, + } + if moved { + cursel.cursor = selection::clamp(cursel.cursor, plain); + // Keep the moving end of the selection on screen. + let scroll = &mut tabs[active_tab].scroll_offset; + if cursel.cursor.line < *scroll { + *scroll = cursel.cursor.line; + } else if cursel.cursor.line >= *scroll + vh { + *scroll = cursel.cursor.line + 1 - vh; + } + *scroll = (*scroll).min(max_scroll); + } + sel = Some(cursel); + continue; + } + // A confirmed search shows highlighted matches; the first Esc/q // dismisses them (vim/less convention) rather than quitting. if action == Action::ExitApp && !search.matches.is_empty() { @@ -888,6 +1075,134 @@ fn run_inner( } } + // Selection & clipboard + Action::SelectMode | Action::SelectLineMode => { + let mode = if action == Action::SelectLineMode { + SelMode::Line + } else { + SelMode::Char + }; + // Normal mode has no caret to inherit, so the selection + // starts on the first line of the viewport that has text — + // anchoring on the blank spacing above a heading looks + // broken. + let tab = &tabs[active_tab]; + let start = tab.scroll_offset.min(tab.plain.len().saturating_sub(1)); + let end = (start + viewport_height as usize).min(tab.plain.len()); + let line = (start..end) + .find(|i| !tab.plain[*i].trim().is_empty()) + .unwrap_or(start); + let col = tab + .plain + .get(line) + .map_or(0, |t| selection::content_start_col(t)); + sel = Some(Selection::new(Pos::new(line, col), mode)); + visual_mode = true; + } + + // Code-block hints: label every block touching the viewport. + Action::CopyCode => { + let tab = &tabs[active_tab]; + let top = tab.scroll_offset; + let bottom = top + viewport_height as usize; + code_hints = tab + .code_blocks + .iter() + .filter(|b| b.line_index < bottom && b.line_index + b.rows > top) + .zip('a'..='z') + .map(|(b, label)| { + // A block scrolled off the top keeps its label on + // the first visible row. + let row = b.line_index.max(top) - top; + let width = tab + .plain + .get(b.line_index) + .map_or(0, |t| selection::line_width(t)); + CodeHint { + label, + row: row as u16, + col: width.saturating_sub(4) as u16, + lang: b.lang.clone(), + source: b.source.clone(), + } + }) + .collect(); + if code_hints.is_empty() { + flash = Some(("no code blocks on screen".to_string(), Instant::now())); + } + } + + // Copy the section the viewport starts in, as markdown source. + Action::CopySection => { + let tab = &tabs[active_tab]; + let (text, what) = section_source(tab, viewport_height as usize); + flash = Some((copy_text(&text, args.clipboard, &what), Instant::now())); + } + + // Mouse selection. Only reachable when ink holds the mouse; + // with `mouse_capture = false` these events never arrive and + // the terminal's own selection keeps working. + Action::MouseDown(col, row) => { + if let Some(pos) = screen_to_doc(doc_rect, &tabs[active_tab], col, row) { + let clicks = match last_click { + Some((at, c, r, n)) + if c == col + && r == row + && at.elapsed() < Duration::from_millis(400) => + { + n % 3 + 1 + } + _ => 1, + }; + last_click = Some((Instant::now(), col, row, clicks)); + let plain = &tabs[active_tab].plain; + let line_text = plain.get(pos.line).map(|t| t.as_str()).unwrap_or(""); + sel = Some(match clicks { + 2 => { + let (from, to) = selection::word_bounds(line_text, pos.col); + Selection { + anchor: Pos::new(pos.line, from), + cursor: Pos::new(pos.line, to), + mode: SelMode::Char, + } + } + 3 => Selection::new(pos, SelMode::Line), + _ => Selection::new(pos, SelMode::Char), + }); + // A double/triple click has already selected something; + // release copies it without any drag. + drag_moved = clicks > 1; + dragging = true; + visual_mode = false; + } + } + Action::MouseDrag(col, row) => { + if dragging { + if let Some(pos) = screen_to_doc(doc_rect, &tabs[active_tab], col, row) { + if let Some(ref mut cursel) = sel { + if pos != cursel.cursor { + drag_moved = true; + } + cursel.cursor = pos; + } + } + } + } + Action::MouseUp(_, _) => { + dragging = false; + match sel { + // A plain click with no drag is a dismiss, not a copy. + Some(_) if !drag_moved => sel = None, + Some(cursel) => { + let text = cursel.extract(&tabs[active_tab].plain); + let what = format!("{} chars", text.chars().count()); + flash = Some((copy_text(&text, args.clipboard, &what), Instant::now())); + } + None => {} + } + drag_moved = false; + } + Action::Resize(_, _) => { // Debounced: dragging a terminal edge fires dozens of // resize events, and every rebuild re-encodes all images. @@ -903,6 +1218,82 @@ fn run_inner( } } +/// Put `text` on the clipboard and return the message for the status bar. +/// +/// `what` names the thing copied ("84 chars", "code block (bash)") so every +/// copy path reports itself the same way. +fn copy_text(text: &str, mode: ClipboardMode, what: &str) -> String { + match clipboard::copy(text, mode) { + CopyOutcome::Copied => format!("copied {what}"), + CopyOutcome::Disabled => "clipboard disabled in config".to_string(), + CopyOutcome::Empty => "nothing to copy".to_string(), + CopyOutcome::TooLarge => "selection too large for clipboard".to_string(), + CopyOutcome::Failed => "clipboard unavailable".to_string(), + } +} + +/// Translate a screen cell to a document position, or `None` when the click +/// landed outside the document area (top bar, TOC sidebar, status bar). +fn screen_to_doc(area: Rect, tab: &Tab, col: u16, row: u16) -> Option { + if area.width == 0 + || col < area.x + || row < area.y + || col >= area.x + area.width + || row >= area.y + area.height + { + return None; + } + let line = tab.scroll_offset + (row - area.y) as usize; + if line >= tab.plain.len() { + return None; + } + // Snap to the start of the grapheme under the pointer, so clicking the + // right half of a wide character selects that character. + let col = selection::snap_col(&tab.plain[line], (col - area.x) as usize); + Some(Pos::new(line, col)) +} + +/// The markdown source of the section the viewport currently starts in, plus a +/// label for the status bar. +/// +/// A section runs from its heading to the next heading of the same or higher +/// level — a `##` copies its `###` subsections along with it. A document with +/// no headings copies whole. +fn section_source(tab: &Tab, viewport_height: usize) -> (String, String) { + let headings = &tab.toc.headings; + let lines: Vec<&str> = tab.content.lines().collect(); + let Some(current) = headings.get(tab.toc.selected) else { + return (tab.content.clone(), "document".to_string()); + }; + // `toc.selected` is the last heading at or above the viewport top, and + // falls back to the first heading when the reader is still above it. In + // that case the section only counts if its heading is actually on screen; + // otherwise there is no current section and the whole document is the + // honest answer. + if tab.toc.selected == 0 + && current.line_index > tab.scroll_offset + && current.line_index >= tab.scroll_offset + viewport_height + { + return (tab.content.clone(), "document".to_string()); + } + // sourcepos lines are 1-based. + let start = current.source_line.saturating_sub(1); + let end = headings + .iter() + .skip(tab.toc.selected + 1) + .find(|h| h.level <= current.level) + .map(|h| h.source_line.saturating_sub(1)) + .unwrap_or(lines.len()); + if start >= lines.len() { + return (String::new(), "nothing".to_string()); + } + let body = lines[start..end.min(lines.len())] + .join("\n") + .trim_end() + .to_string(); + (body, format!("section \"{}\"", current.text)) +} + fn is_local_file(input: &str) -> bool { !input.starts_with("http://") && !input.starts_with("https://") && input != "stdin" } @@ -1069,6 +1460,7 @@ fn build_tab( lines: styled_lines, headings, images: image_specs, + code_blocks, } = layout::layout_document( root, &theme::resolve_theme(&args.theme), @@ -1122,6 +1514,7 @@ fn build_tab( level: h.level, text: h.text, line_index: h.line_index, + source_line: h.source_line, }) .collect(); @@ -1129,11 +1522,16 @@ fn build_tab( toc.headings = toc_entries; toc.visible = args.toc; + let plain = crate::selection::plain_lines(&styled_lines); + Tab { filename: filename.to_string(), source, + content, styled_lines, ratatui_lines, + plain, + code_blocks, lowered, scroll_offset: 0, toc, @@ -1144,3 +1542,129 @@ fn build_tab( images, } } + +#[cfg(test)] +mod section_tests { + use super::*; + + /// A tab carrying just the fields the section/mouse helpers read. + fn tab(content: &str, headings: &[(u8, &str, usize, usize)], scroll: usize) -> Tab { + let plain: Vec = content.lines().map(|l| l.to_string()).collect(); + let mut toc = TocState::empty(); + toc.headings = headings + .iter() + .map( + |(level, text, line_index, source_line)| crate::toc::TocEntry { + level: *level, + text: (*text).to_string(), + line_index: *line_index, + source_line: *source_line, + }, + ) + .collect(); + toc.update_selection(scroll); + Tab { + filename: "t.md".into(), + source: content.into(), + content: content.into(), + styled_lines: Vec::new(), + ratatui_lines: Vec::new(), + plain, + code_blocks: Vec::new(), + lowered: Vec::new(), + scroll_offset: scroll, + toc, + word_count: 0, + reading_time: 0, + built_width: 80, + built_gen: 0, + images: Vec::new(), + } + } + + const DOC: &str = + "# Top\n\nintro\n\n## Alpha\n\na body\n\n### Alpha sub\n\nnested\n\n## Beta\n\nb body\n"; + // (level, text, display line, source line) + const HEADINGS: &[(u8, &str, usize, usize)] = &[ + (1, "Top", 2, 1), + (2, "Alpha", 8, 5), + (3, "Alpha sub", 14, 9), + (2, "Beta", 20, 13), + ]; + + #[test] + fn a_section_runs_to_the_next_heading_of_the_same_level() { + // Sitting inside Alpha: its subsection comes along, Beta does not. + let t = tab(DOC, HEADINGS, 8); + let (body, label) = section_source(&t, 20); + assert_eq!(label, "section \"Alpha\""); + assert!(body.starts_with("## Alpha")); + assert!( + body.contains("### Alpha sub"), + "subsection must be included" + ); + assert!( + !body.contains("## Beta"), + "must stop at the next same-level heading" + ); + } + + #[test] + fn a_subsection_stops_at_its_own_level() { + let t = tab(DOC, HEADINGS, 14); + let (body, label) = section_source(&t, 20); + assert_eq!(label, "section \"Alpha sub\""); + assert_eq!(body, "### Alpha sub\n\nnested"); + } + + #[test] + fn the_last_section_runs_to_the_end_of_the_document() { + let t = tab(DOC, HEADINGS, 20); + let (body, _) = section_source(&t, 20); + assert_eq!(body, "## Beta\n\nb body"); + } + + #[test] + fn a_document_without_headings_copies_whole() { + let t = tab("just prose\n\nmore prose\n", &[], 0); + let (body, label) = section_source(&t, 20); + assert_eq!(label, "document"); + assert_eq!(body, "just prose\n\nmore prose\n"); + } + + #[test] + fn scrolled_past_every_heading_still_resolves_the_last_one() { + let t = tab(DOC, HEADINGS, 99); + let (_, label) = section_source(&t, 20); + assert_eq!(label, "section \"Beta\""); + } + + #[test] + fn a_viewport_that_has_not_reached_the_first_heading_copies_the_document() { + // Heading at display line 40, viewport is 20 rows from the top: the + // reader is not in any section yet. + let t = tab(DOC, &[(1, "Top", 40, 1)], 0); + let (_, label) = section_source(&t, 20); + assert_eq!(label, "document"); + } + + #[test] + fn screen_to_doc_maps_only_inside_the_document_area() { + // Ten lines, scrolled to line 5 — the doc must outlast the scroll + // offset or every lookup is out of range for the wrong reason. + let doc: String = (0..10).map(|i| format!("line {i}\n")).collect(); + let t = tab(&doc, &[], 5); + let area = Rect::new(4, 2, 40, 10); + // Top-left of the area is the first line of the current scroll window. + assert_eq!(screen_to_doc(area, &t, 4, 2), Some(Pos::new(5, 0))); + // Outside on every side. + assert_eq!(screen_to_doc(area, &t, 3, 2), None); + assert_eq!(screen_to_doc(area, &t, 4, 1), None); + assert_eq!(screen_to_doc(area, &t, 44, 2), None); + assert_eq!(screen_to_doc(area, &t, 4, 12), None); + // Past the end of the document (scroll 5 + row 5 = line 10 of 10). + assert_eq!(screen_to_doc(area, &t, 4, 7), None); + // A never-drawn area cannot resolve anything. + assert_eq!(screen_to_doc(Rect::new(0, 0, 0, 0), &t, 0, 0), None); + } +} diff --git a/src/cli.rs b/src/cli.rs index a97b23f..590bf49 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -146,6 +146,7 @@ pub struct Args { pub frontmatter: bool, pub spacing: Spacing, pub mouse_capture: bool, + pub clipboard: crate::clipboard::ClipboardMode, } #[derive(Debug, Clone, Copy, PartialEq)] @@ -276,6 +277,17 @@ pub fn run() -> Result<()> { .and_then(|c| c.behavior.as_ref()) .and_then(|b| b.mouse_capture) .unwrap_or(true), + clipboard: user_config + .as_ref() + .and_then(|c| c.behavior.as_ref()) + .and_then(|b| b.clipboard.as_deref()) + .map(|v| { + crate::clipboard::ClipboardMode::parse(v).unwrap_or_else(|| { + eprintln!("ink: unknown clipboard mode '{v}', using 'auto'"); + crate::clipboard::ClipboardMode::Auto + }) + }) + .unwrap_or_default(), }; // Check if input is a directory or no input with a TTY → launch file browser @@ -505,6 +517,13 @@ const STARTER_CONFIG: &str = r#"# ink configuration # inside ink). Default: true. # mouse_capture = true +# How copied text reaches the clipboard: +# auto - OSC 52 escape (works over SSH) and a native helper, if present +# osc52 - escape sequence only +# native - pbcopy / wl-copy / xclip / xsel / clip.exe only +# off - copying is disabled +# clipboard = "auto" + [keybindings] # Built-in preset: "default" (vim-flavored), "vim", or "emacs" # preset = "default" @@ -610,6 +629,10 @@ fn print_keybindings() { Help => "help", NavBack => "nav_back", NavForward => "nav_forward", + SelectMode => "select_mode", + SelectLineMode => "select_line_mode", + CopyCode => "copy_code", + CopySection => "copy_section", _ => "?", } .to_string() diff --git a/src/clipboard.rs b/src/clipboard.rs new file mode 100644 index 0000000..ad15d50 --- /dev/null +++ b/src/clipboard.rs @@ -0,0 +1,264 @@ +//! Putting text on the system clipboard from inside a full-screen TUI. +//! +//! Two independent, best-effort deliveries, because neither one covers every +//! terminal ink runs in: +//! +//! * **OSC 52** — an escape sequence the terminal itself acts on, so it crosses +//! an SSH boundary. Some terminals ship it disabled (Terminal.app) and none +//! of them report back, so it can silently do nothing. +//! * **A native helper** (`pbcopy`, `wl-copy`, `xclip`, `xsel`, `clip.exe`) — +//! reliable locally, useless over SSH (it would write the *server's* +//! clipboard). +//! +//! Running both can write the same bytes twice. That is harmless, and cheaper +//! than trying to detect which one worked. + +use base64::engine::general_purpose::STANDARD; +use base64::Engine as _; +use std::io::Write; +use std::process::{Command, Stdio}; + +/// How `copy` is allowed to reach the clipboard. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum ClipboardMode { + /// Escape sequence *and* native helper (default). + #[default] + Auto, + /// Escape sequence only. + Osc52, + /// Native helper only. + Native, + /// Copy is a no-op. + Off, +} + +impl ClipboardMode { + pub fn parse(s: &str) -> Option { + match s.trim().to_ascii_lowercase().as_str() { + "auto" | "both" => Some(Self::Auto), + "osc52" | "osc" => Some(Self::Osc52), + "native" | "helper" => Some(Self::Native), + "off" | "none" | "false" => Some(Self::Off), + _ => None, + } + } +} + +/// What happened, for the status-bar flash. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CopyOutcome { + /// Reached at least one delivery path. + Copied, + /// Clipboard is turned off in config. + Disabled, + /// Nothing to copy (empty selection). + Empty, + /// Too big for OSC 52 and no helper on PATH. + TooLarge, + /// Every available path failed. + Failed, +} + +/// xterm's hard cap on an OSC 52 payload, in base64 bytes. Past it terminals +/// drop the whole sequence, so a 200 KB selection would silently copy nothing. +const OSC52_MAX_B64: usize = 74_994; + +/// Build the OSC 52 sequence for `text`, or `None` if it exceeds the cap. +/// +/// Inside tmux the sequence has to be wrapped in a passthrough envelope, with +/// every inner ESC doubled, or tmux swallows it instead of forwarding it to the +/// outer terminal. +pub fn osc52_payload(text: &str) -> Option { + osc52_payload_in(text, std::env::var_os("TMUX").is_some()) +} + +/// Testable core of [`osc52_payload`]: `tmux` selects the passthrough envelope +/// instead of reading the environment. +pub fn osc52_payload_in(text: &str, tmux: bool) -> Option { + let encoded = STANDARD.encode(text.as_bytes()); + if encoded.len() > OSC52_MAX_B64 { + return None; + } + let inner = format!("\x1b]52;c;{encoded}\x07"); + if !tmux { + return Some(inner); + } + Some(format!( + "\x1bPtmux;{}\x1b\\", + inner.replace('\x1b', "\x1b\x1b") + )) +} + +/// The clipboard helper to use on this machine, as (program, args). +/// +/// Ordered by how specific the signal is: a Wayland session that also exports +/// `DISPLAY` (XWayland) should still get `wl-copy`. +fn native_helper() -> Option<(&'static str, &'static [&'static str])> { + let candidates: &[(&str, &[&str], bool)] = &[ + ("pbcopy", &[], cfg!(target_os = "macos")), + ( + "wl-copy", + &[], + std::env::var_os("WAYLAND_DISPLAY").is_some(), + ), + ( + "xclip", + &["-selection", "clipboard"], + std::env::var_os("DISPLAY").is_some(), + ), + ( + "xsel", + &["--clipboard", "--input"], + std::env::var_os("DISPLAY").is_some(), + ), + ("clip.exe", &[], true), + ]; + candidates + .iter() + .find(|(prog, _, applicable)| *applicable && on_path(prog)) + .map(|(prog, args, _)| (*prog, *args)) +} + +fn on_path(program: &str) -> bool { + let Some(paths) = std::env::var_os("PATH") else { + return false; + }; + std::env::split_paths(&paths).any(|dir| dir.join(program).is_file()) +} + +/// Hand `text` to the native helper, if there is one. +/// +/// Deliberately does not wait for the child: `wl-copy` and `xclip` stay +/// resident to *own* the X/Wayland selection, so waiting would hang ink until +/// the user copied something else. +fn copy_native(text: &str) -> bool { + let Some((prog, args)) = native_helper() else { + return false; + }; + let Ok(mut child) = Command::new(prog) + .args(args) + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + else { + return false; + }; + let wrote = match child.stdin.take() { + Some(mut stdin) => stdin.write_all(text.as_bytes()).is_ok(), + None => false, + }; + // Reap it if it already exited (pbcopy, clip.exe); leave the persistent + // ones alone. + let _ = child.try_wait(); + wrote +} + +/// Write the OSC 52 sequence straight to the terminal. +/// +/// Safe to do mid-frame: it paints nothing, and ratatui redraws over it on the +/// next tick regardless. +fn copy_osc52(text: &str) -> Option { + let payload = osc52_payload(text)?; + let mut out = std::io::stdout(); + let ok = out.write_all(payload.as_bytes()).is_ok() && out.flush().is_ok(); + Some(ok) +} + +/// Put `text` on the clipboard by every route `mode` allows. +pub fn copy(text: &str, mode: ClipboardMode) -> CopyOutcome { + if mode == ClipboardMode::Off { + return CopyOutcome::Disabled; + } + if text.is_empty() { + return CopyOutcome::Empty; + } + + let want_osc = matches!(mode, ClipboardMode::Auto | ClipboardMode::Osc52); + let want_native = matches!(mode, ClipboardMode::Auto | ClipboardMode::Native); + + let mut any = false; + // `Some(false)` = tried and failed, `None` = over the size cap. + let mut oversized = false; + if want_osc { + match copy_osc52(text) { + Some(true) => any = true, + Some(false) => {} + None => oversized = true, + } + } + if want_native && copy_native(text) { + any = true; + } + + if any { + CopyOutcome::Copied + } else if oversized { + CopyOutcome::TooLarge + } else { + CopyOutcome::Failed + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn osc52_payload_is_base64_in_an_osc_52_sequence() { + assert_eq!(osc52_payload_in("hi", false).unwrap(), "\x1b]52;c;aGk=\x07"); + } + + #[test] + fn osc52_payload_wraps_for_tmux_and_doubles_escapes() { + let wrapped = osc52_payload_in("hi", true).unwrap(); + assert_eq!(wrapped, "\x1bPtmux;\x1b\x1b]52;c;aGk=\x07\x1b\\"); + // No lone ESC survives inside the envelope: every one is doubled, so + // tmux forwards the sequence instead of eating it. Removing the pairs + // must leave a body with no ESC left in it. + let body = &wrapped["\x1bPtmux;".len()..wrapped.len() - 2]; + assert!(!body.replace("\x1b\x1b", "").contains('\x1b')); + } + + #[test] + fn osc52_payload_refuses_an_oversized_selection() { + // 200 KB of text encodes well past the 74_994-byte cap. + let big = "x".repeat(200_000); + assert!(osc52_payload_in(&big, false).is_none()); + // Just under the cap still encodes. + let ok = "x".repeat(OSC52_MAX_B64 / 4 * 3); + assert!(osc52_payload_in(&ok, false).is_some()); + } + + #[test] + fn osc52_payload_survives_non_ascii() { + let payload = osc52_payload_in("héllo → 世界", false).unwrap(); + let b64 = payload + .trim_start_matches("\x1b]52;c;") + .trim_end_matches('\x07'); + let decoded = STANDARD.decode(b64).unwrap(); + assert_eq!(String::from_utf8(decoded).unwrap(), "héllo → 世界"); + } + + #[test] + fn off_writes_nothing() { + assert_eq!(copy("anything", ClipboardMode::Off), CopyOutcome::Disabled); + } + + #[test] + fn empty_selection_is_not_a_copy() { + assert_eq!(copy("", ClipboardMode::Auto), CopyOutcome::Empty); + } + + #[test] + fn mode_parses_its_config_spellings() { + assert_eq!(ClipboardMode::parse("auto"), Some(ClipboardMode::Auto)); + assert_eq!(ClipboardMode::parse("OSC52"), Some(ClipboardMode::Osc52)); + assert_eq!( + ClipboardMode::parse(" native "), + Some(ClipboardMode::Native) + ); + assert_eq!(ClipboardMode::parse("off"), Some(ClipboardMode::Off)); + assert_eq!(ClipboardMode::parse("sometimes"), None); + } +} diff --git a/src/config.rs b/src/config.rs index 7213e8f..789f6f5 100644 --- a/src/config.rs +++ b/src/config.rs @@ -22,6 +22,8 @@ pub struct BehaviorConfig { /// click-to-open (OSC 8) and text selection keep working. Default true /// (mouse wheel scrolls the document). pub mouse_capture: Option, + /// How copies reach the clipboard: "auto" | "osc52" | "native" | "off". + pub clipboard: Option, } #[derive(Debug, Deserialize)] diff --git a/src/input/keymap.rs b/src/input/keymap.rs index 433a49b..8d7dd30 100644 --- a/src/input/keymap.rs +++ b/src/input/keymap.rs @@ -122,6 +122,10 @@ pub fn action_from_id(id: &str) -> Option { "help" => Action::Help, "nav_back" => Action::NavBack, "nav_forward" => Action::NavForward, + "select_mode" => Action::SelectMode, + "select_line_mode" => Action::SelectLineMode, + "copy_code" => Action::CopyCode, + "copy_section" => Action::CopySection, _ => return None, }) } diff --git a/src/input/mod.rs b/src/input/mod.rs index 403b6ce..55ef1a7 100644 --- a/src/input/mod.rs +++ b/src/input/mod.rs @@ -41,6 +41,41 @@ pub enum Action { LinkHint(char), SlideNext, SlidePrev, + + // Selection & clipboard + /// Enter character-wise visual mode. + SelectMode, + /// Enter (or, inside visual mode, toggle) line-wise visual mode. + SelectLineMode, + /// Copy the selection and leave visual mode. + Yank, + /// Leave visual mode without copying. + SelCancel, + /// Move the visual-mode cursor by whole lines / columns. + SelUp(u16), + SelDown(u16), + SelLeft(u16), + SelRight(u16), + SelWordNext, + SelWordPrev, + SelLineStart, + SelLineEnd, + SelDocStart, + SelDocEnd, + SelPageDown, + SelPageUp, + /// Label the visible code blocks for copying. + CopyCode, + /// Copy the section the viewport starts in, as markdown source. + CopySection, + /// Inside the link-hint overlay: copy the URL instead of opening it. + HintCopyToggle, + + /// Mouse press / drag / release at a (column, row) on screen. + MouseDown(u16, u16), + MouseDrag(u16, u16), + MouseUp(u16, u16), + None, } @@ -55,6 +90,8 @@ pub enum InputMode { LinkHint, /// Presentation mode — arrows/space move between slides. Slides, + /// Visual selection — motions move the cursor, `y` copies. + Visual, } /// Process-wide resolved keymap. Initialized once at startup via `init_keymap`. @@ -139,6 +176,9 @@ pub fn keymap_summary() -> Vec<(&'static str, Vec)> { "table of contents", "follow link", "link hints", + "select text", + "copy code block", + "copy section", "theme picker", "tabs", "back / forward", @@ -167,6 +207,9 @@ fn action_label(a: &Action) -> &'static str { Action::ToggleToc => "table of contents", Action::FollowLink => "follow link", Action::LinkMode => "link hints", + Action::SelectMode | Action::SelectLineMode => "select text", + Action::CopyCode => "copy code block", + Action::CopySection => "copy section", Action::ThemePicker => "theme picker", Action::NextTab | Action::PrevTab => "tabs", Action::NavBack | Action::NavForward => "back / forward", @@ -227,6 +270,7 @@ fn map_event(event: Event, mode: InputMode) -> Action { InputMode::Search => map_search_key(key), InputMode::LinkHint => map_link_hint_key(key), InputMode::Slides => map_slides_key(key), + InputMode::Visual => map_visual_key(key), InputMode::Normal => map_key(key), }, Event::Mouse(mouse) => map_mouse(mouse), @@ -238,11 +282,49 @@ fn map_event(event: Event, mode: InputMode) -> Action { fn map_link_hint_key(key: KeyEvent) -> Action { match key.code { KeyCode::Esc => Action::CloseSearch, + // Labels are always lowercase, so an uppercase Y is free to mean + // "copy this one instead of opening it". + KeyCode::Char('Y') => Action::HintCopyToggle, KeyCode::Char(c) if c.is_ascii_alphabetic() => Action::LinkHint(c.to_ascii_lowercase()), _ => Action::None, } } +/// Visual-mode keys. +/// +/// Fixed, like Search and Slides mode: motions here are a self-contained +/// vim-shaped table rather than the configurable keymap, and arrow keys cover +/// anyone who does not think in `hjkl`. +fn map_visual_key(key: KeyEvent) -> Action { + if key.modifiers.contains(KeyModifiers::CONTROL) { + return match key.code { + KeyCode::Char('c') => Action::SelCancel, + KeyCode::Char('d') | KeyCode::Char('f') => Action::SelPageDown, + KeyCode::Char('u') | KeyCode::Char('b') => Action::SelPageUp, + _ => Action::None, + }; + } + match key.code { + KeyCode::Esc | KeyCode::Char('q') => Action::SelCancel, + KeyCode::Char('y') | KeyCode::Enter => Action::Yank, + KeyCode::Char('v') => Action::SelectMode, + KeyCode::Char('V') => Action::SelectLineMode, + KeyCode::Char('j') | KeyCode::Down => Action::SelDown(1), + KeyCode::Char('k') | KeyCode::Up => Action::SelUp(1), + KeyCode::Char('h') | KeyCode::Left => Action::SelLeft(1), + KeyCode::Char('l') | KeyCode::Right => Action::SelRight(1), + KeyCode::Char('w') | KeyCode::Char('e') => Action::SelWordNext, + KeyCode::Char('b') => Action::SelWordPrev, + KeyCode::Char('0') | KeyCode::Home => Action::SelLineStart, + KeyCode::Char('$') | KeyCode::End => Action::SelLineEnd, + KeyCode::Char('g') => Action::SelDocStart, + KeyCode::Char('G') => Action::SelDocEnd, + KeyCode::Char(' ') | KeyCode::PageDown => Action::SelPageDown, + KeyCode::PageUp => Action::SelPageUp, + _ => Action::None, + } +} + fn map_slides_key(key: KeyEvent) -> Action { if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') { return Action::ExitApp; @@ -330,9 +412,13 @@ fn normalize_event(key: KeyEvent) -> (KeyCode, KeyModifiers) { } fn map_mouse(mouse: MouseEvent) -> Action { + use crossterm::event::MouseButton; match mouse.kind { MouseEventKind::ScrollUp => Action::ScrollUp(3), MouseEventKind::ScrollDown => Action::ScrollDown(3), + MouseEventKind::Down(MouseButton::Left) => Action::MouseDown(mouse.column, mouse.row), + MouseEventKind::Drag(MouseButton::Left) => Action::MouseDrag(mouse.column, mouse.row), + MouseEventKind::Up(MouseButton::Left) => Action::MouseUp(mouse.column, mouse.row), _ => Action::None, } } diff --git a/src/input/preset.rs b/src/input/preset.rs index 8676499..8d98d8e 100644 --- a/src/input/preset.rs +++ b/src/input/preset.rs @@ -29,6 +29,10 @@ pub const DEFAULT: Preset = &[ ("help", &["?"]), ("nav_back", &["[", "alt-left"]), ("nav_forward", &["]", "alt-right"]), + ("select_mode", &["v"]), + ("select_line_mode", &["shift-v"]), + ("copy_code", &["c"]), + ("copy_section", &["shift-y"]), ]; /// Vim preset is just an alias for the default. @@ -60,6 +64,10 @@ pub const EMACS: Preset = &[ ("help", &["?"]), ("nav_back", &["[", "alt-left"]), ("nav_forward", &["]", "alt-right"]), + ("select_mode", &["v"]), + ("select_line_mode", &["shift-v"]), + ("copy_code", &["c"]), + ("copy_section", &["shift-y"]), ]; pub fn lookup(name: &str) -> Option { diff --git a/src/layout/mod.rs b/src/layout/mod.rs index e2800c8..b3b03da 100644 --- a/src/layout/mod.rs +++ b/src/layout/mod.rs @@ -96,6 +96,10 @@ pub struct LayoutHeading { pub level: u8, pub text: String, pub line_index: usize, + /// 1-based line in the (frontmatter-stripped) markdown source this heading + /// was written on. Lets `Y` copy a section's *source* rather than its + /// rendered form. + pub source_line: usize, } /// A block image reserved in the text flow for graphics-protocol rendering. @@ -108,12 +112,28 @@ pub struct ImageSpec { pub image: std::sync::Arc, } +/// A fenced code block located in the rendered output, carrying the raw source +/// that `c` (code-hint mode) copies — the rendered rows are wrapped in box +/// borders and syntax colors, so they are useless as clipboard content. +#[derive(Debug, Clone)] +pub struct CodeBlockSpec { + /// Display line of the block's top border. + pub line_index: usize, + /// Rows the block occupies, borders included. + pub rows: usize, + /// Fence info string's first word; empty when the fence was bare. + pub lang: String, + /// The block's literal text, tabs expanded, without a trailing newline. + pub source: String, +} + /// Result of laying out a document: the display lines plus the headings and /// their line positions. pub struct LayoutResult { pub lines: Vec, pub headings: Vec, pub images: Vec, + pub code_blocks: Vec, } /// Convert a comrak AST into a flat list of styled lines for rendering. @@ -135,6 +155,7 @@ pub fn layout_document<'a>( let mut lines: Vec = Vec::new(); let headings = std::cell::RefCell::new(Vec::new()); let image_specs = std::cell::RefCell::new(Vec::new()); + let code_blocks = std::cell::RefCell::new(Vec::new()); let ctx = LayoutContext { theme, width: width as usize, @@ -149,6 +170,7 @@ pub fn layout_document<'a>( graphics_font, headings: &headings, image_specs: &image_specs, + code_blocks: &code_blocks, record_headings: true, }; layout_node(root, &ctx, &mut lines); @@ -157,6 +179,7 @@ pub fn layout_document<'a>( lines, headings: headings.into_inner(), images: image_specs.into_inner(), + code_blocks: code_blocks.into_inner(), } } @@ -193,6 +216,7 @@ struct LayoutContext<'a> { graphics_font: Option<(u16, u16)>, headings: &'a std::cell::RefCell>, image_specs: &'a std::cell::RefCell>, + code_blocks: &'a std::cell::RefCell>, // Only the top-level walk writes into the shared `lines`, so only it can // record correct absolute line indices. Nested walks (blockquotes, list // items) build into their own buffers, so they don't record headings. @@ -392,12 +416,18 @@ fn layout_heading<'a>( }; let prefix_w = prefix.width(); - // Record the heading's exact display-line index for the TOC. + // Record the heading's exact display-line index for the TOC, and the + // source line it came from so `Y` can slice the section out of the source. if ctx.record_headings { + let source_line = node.data.borrow().sourcepos.start.line; ctx.headings.borrow_mut().push(LayoutHeading { level, - text: collect_child_text(node), + // Sanitized here, not by `sanitize_lines`: heading text reaches the + // screen through the TOC sidebar and the copy status line, neither + // of which is a `StyledLine`. + text: crate::sanitize::sanitize_text(&collect_child_text(node)).into_owned(), line_index: lines.len(), + source_line, }); } @@ -812,6 +842,7 @@ fn expand_tabs(text: &str) -> String { fn layout_code_block(info: &str, literal: &str, ctx: &LayoutContext, lines: &mut Vec) { let lang = info.split_whitespace().next().unwrap_or(""); + let start_line = lines.len(); // Mermaid diagrams get special rendering if lang == "mermaid" { @@ -955,6 +986,23 @@ fn layout_code_block(info: &str, literal: &str, ctx: &LayoutContext, lines: &mut }, }); lines.push(footer); + + // Only the top-level walk writes into the shared `lines`, so only it knows + // absolute row numbers — a block nested in a list item or blockquote is + // laid out into a sub-buffer and simply is not offered as a copy hint. + if ctx.record_headings { + ctx.code_blocks.borrow_mut().push(CodeBlockSpec { + line_index: start_line, + rows: lines.len() - start_line, + // Same reasoning as heading text: the language label is shown in + // the copy status line, which does not pass through the layout + // sanitizer. `source` needs no cleaning — it only ever goes to the + // clipboard, base64-encoded for OSC 52 or piped to a helper's stdin. + lang: crate::sanitize::sanitize_text(lang).into_owned(), + source: literal.trim_end_matches('\n').to_string(), + }); + } + add_spacing(ctx, lines); } @@ -1031,6 +1079,7 @@ fn layout_blockquote<'a>( images: ctx.images, headings: ctx.headings, image_specs: ctx.image_specs, + code_blocks: ctx.code_blocks, graphics_font: ctx.graphics_font, record_headings: false, }; @@ -1138,6 +1187,7 @@ fn layout_list<'a>( images: ctx.images, headings: ctx.headings, image_specs: ctx.image_specs, + code_blocks: ctx.code_blocks, graphics_font: ctx.graphics_font, record_headings: false, }; @@ -1842,3 +1892,125 @@ mod tests { assert_eq!(line_text(&lines[0]), "touched 44 bpm"); } } + +/// Code-block discovery: what `c` (copy code block) is allowed to offer, and +/// what raw text it hands to the clipboard. +#[cfg(test)] +mod code_blocks { + use super::*; + use comrak::{parse_document, Arena}; + + fn layout(src: &str) -> LayoutResult { + let arena = Arena::new(); + let root = parse_document(&arena, src, &crate::parser::options()); + let theme = crate::theme::resolve_theme("dark"); + layout_document( + root, + &theme, + 60, + Spacing::Normal, + 0, + None, + ImageMode::Off, + None, + ) + } + + #[test] + fn fenced_blocks_are_recorded_with_their_language_and_raw_source() { + let result = + layout("# Title\n\n```bash\ncurl -fsSL x | sh\n```\n\ntext\n\n```\nplain\n```\n"); + assert_eq!(result.code_blocks.len(), 2); + + let bash = &result.code_blocks[0]; + assert_eq!(bash.lang, "bash"); + assert_eq!(bash.source, "curl -fsSL x | sh"); + // `line_index` points at the block's top border, which is where the + // hint label gets painted. + let border: String = result.lines[bash.line_index] + .spans + .iter() + .map(|s| s.text.as_str()) + .collect(); + assert!(border.starts_with('╭'), "not a top border: {border:?}"); + let bottom: String = result.lines[bash.line_index + bash.rows - 1] + .spans + .iter() + .map(|s| s.text.as_str()) + .collect(); + assert!(bottom.starts_with('╰'), "not a bottom border: {bottom:?}"); + + let bare = &result.code_blocks[1]; + assert_eq!(bare.lang, ""); + assert_eq!(bare.source, "plain"); + } + + #[test] + fn multi_line_source_keeps_its_indentation_and_drops_the_trailing_newline() { + let result = layout("```rust\nfn main() {\n println!();\n}\n```\n"); + assert_eq!( + result.code_blocks[0].source, + "fn main() {\n println!();\n}" + ); + } + + #[test] + fn tabs_are_expanded_so_the_copy_matches_what_was_rendered() { + let result = layout("```go\n\tx := 1\n```\n"); + assert!(!result.code_blocks[0].source.contains('\t')); + assert!(result.code_blocks[0].source.ends_with("x := 1")); + } + + #[test] + fn a_block_nested_in_a_list_is_not_offered_because_its_rows_are_unknown() { + // Nested walks render into a sub-buffer, so an absolute `line_index` + // cannot be computed for them — better to skip than to label a row + // that is not the block. + let result = layout("- item\n\n ```bash\n echo hi\n ```\n"); + assert!(result.code_blocks.is_empty()); + } + + /// The language label is printed in the status line when a block is + /// copied — a path that never touches `sanitize_lines`, so the escape + /// stripping has to happen where the spec is built. + #[test] + fn a_fence_info_string_cannot_smuggle_control_bytes_into_the_label() { + let result = layout("```ba\u{1b}[31msh\ncode\n```\n"); + assert_eq!(result.code_blocks[0].lang, "ba[31msh"); + assert!(!result.code_blocks[0].lang.contains('\u{1b}')); + } + + #[test] + fn a_mermaid_fence_is_a_diagram_not_a_code_block() { + let result = layout("```mermaid\ngraph TD;\nA-->B;\n```\n"); + assert!(result.code_blocks.is_empty()); + } +} + +#[cfg(test)] +mod heading_sanitization { + use super::*; + use comrak::{parse_document, Arena}; + + /// Heading text is shown in the TOC sidebar and in the "copied section" + /// status line, neither of which is a `StyledLine` — so it cannot rely on + /// `sanitize_lines` and is cleaned at the point it is recorded. + #[test] + fn heading_text_is_stripped_of_control_bytes() { + let arena = Arena::new(); + let root = parse_document(&arena, "# He\u{1b}[2Jading\n", &crate::parser::options()); + let theme = crate::theme::resolve_theme("dark"); + let result = layout_document( + root, + &theme, + 60, + Spacing::Normal, + 0, + None, + ImageMode::Off, + None, + ); + assert_eq!(result.headings[0].text, "He[2Jading"); + assert!(!result.headings[0].text.contains('\u{1b}')); + } +} diff --git a/src/lib.rs b/src/lib.rs index 77288eb..5d49d2e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,6 +1,7 @@ pub mod app; pub mod browser; pub mod cli; +pub mod clipboard; pub mod config; pub mod doctor; pub mod graphics; @@ -13,6 +14,7 @@ pub mod parser; pub mod render; pub mod sanitize; pub mod search; +pub mod selection; pub mod slides; pub mod stats; pub mod theme; diff --git a/src/render/mod.rs b/src/render/mod.rs index 269d2c2..42345eb 100644 --- a/src/render/mod.rs +++ b/src/render/mod.rs @@ -2,6 +2,7 @@ pub mod plain; use crate::layout::StyledLine; use crate::search::SearchState; +use crate::selection::Selection; use crate::theme; use ratatui::prelude::*; use ratatui::widgets::{Block, Borders, Clear, Paragraph}; @@ -185,6 +186,7 @@ pub fn render_bottom_bar( /// display line per `Line` (the document `Paragraph` never wraps), so the /// slice `[scroll_offset .. scroll_offset + height]` is exactly the viewport. /// This bounds per-frame work to the viewport instead of the whole document. +#[allow(clippy::too_many_arguments)] pub fn render_document_with_search( frame: &mut Frame, area: Rect, @@ -192,6 +194,8 @@ pub fn render_document_with_search( scroll_offset: usize, _total_lines: usize, search: &SearchState, + selection: Option<&Selection>, + plain: &[String], t: &theme::Theme, ) { let offset = scroll_offset.min(lines.len()); @@ -224,10 +228,91 @@ pub fn render_document_with_search( visible.to_vec() }; + // Selection paints last: it is the thing the user is actively pointing at, + // so it wins over a search highlight underneath it. + let rendered = match selection { + Some(sel) => rendered + .into_iter() + .enumerate() + .map(|(i, line)| { + let abs = offset + i; + let width = plain.get(abs).map(|p| crate::selection::line_width(p)); + match (width, sel.highlight_span(abs, width.unwrap_or(0))) { + (Some(_), Some((from, to))) => { + let (bg, fg) = t.colors.selection(); + let style = Style::default() + .bg(theme::hex_to_color(&bg)) + .fg(theme::hex_to_color(&fg)); + restyle_columns(&line, from, to, style) + } + _ => line, + } + }) + .collect(), + None => rendered, + }; + // The slice already starts at the viewport top, so no Paragraph scroll. frame.render_widget(Paragraph::new(rendered), area); } +/// Re-style the part of `line` covering display columns `[from, to)`. +/// +/// Works in terminal cells rather than bytes, so a wide glyph is restyled whole +/// or not at all — a selection edge can never split one in half. +fn restyle_columns(line: &Line<'static>, from: usize, to: usize, style: Style) -> Line<'static> { + use unicode_segmentation::UnicodeSegmentation; + use unicode_width::UnicodeWidthStr; + + let mut out: Vec> = Vec::with_capacity(line.spans.len() + 2); + let mut col = 0usize; + for span in &line.spans { + let text = span.content.to_string(); + // Fast path: the span lies entirely inside or entirely outside. + let span_w = text.width(); + if col >= to || col + span_w <= from { + out.push(Span::styled(text, span.style)); + col += span_w; + continue; + } + + let mut pending = String::new(); + let mut pending_selected = false; + for g in text.graphemes(true) { + let w = g.width().max(1); + let selected = col + w > from && col < to; + if !pending.is_empty() && selected != pending_selected { + let st = if pending_selected { + span.style.patch(style) + } else { + span.style + }; + out.push(Span::styled(std::mem::take(&mut pending), st)); + } + pending_selected = selected; + pending.push_str(g); + col += w; + } + if !pending.is_empty() { + let st = if pending_selected { + span.style.patch(style) + } else { + span.style + }; + out.push(Span::styled(pending, st)); + } + } + + // A selection that runs past the end of the text (the newline a multi-line + // selection swallows) shows as one trailing cell, so the user can see the + // line break is included. + if to > col { + let pad = (to - col).min(1); + out.push(Span::styled(" ".repeat(pad), style)); + } + Line::from(out) +} + /// Lowercase `s`, and map every byte of the result back to the byte offset in /// `s` it came from. /// @@ -449,11 +534,62 @@ pub fn render_help(frame: &mut Frame, area: Rect, t: &theme::Theme) { frame.render_widget(Paragraph::new(lines).block(block), popup_area); } +/// Paint code-block hint labels directly onto the blocks' top borders. +/// +/// Unlike the link overlay, these are not listed in a popup: a code block *is* +/// its own label site, and putting `[a]` on the border keeps the block itself +/// visible while you choose. +pub fn render_code_hints( + frame: &mut Frame, + area: Rect, + hints: &[(char, u16, u16)], + t: &theme::Theme, +) { + let bg = theme::hex_to_color(&t.colors.search_current); + let fg = t + .colors + .bg + .as_ref() + .map(|b| theme::hex_to_color(b)) + .unwrap_or_else(|| theme::hex_to_color(&t.colors.fg)); + let style = Style::default().fg(fg).bg(bg).add_modifier(Modifier::BOLD); + + for (label, row, col) in hints { + if *row >= area.height { + continue; + } + let text = format!("[{label}]"); + let x = (area.x + col).min(area.x + area.width.saturating_sub(text.len() as u16)); + frame.buffer_mut().set_string(x, area.y + row, &text, style); + } +} + +/// Render a transient message (a copy result) in place of the bottom bar. +pub fn render_flash_bar(frame: &mut Frame, area: Rect, message: &str, t: &theme::Theme) { + let bg = theme::hex_to_color(&t.colors.status_bar_bg); + let accent = theme::hex_to_color(&t.colors.heading2); + let line = Line::from(vec![ + Span::styled( + " ✓ ", + Style::default() + .fg(accent) + .bg(bg) + .add_modifier(Modifier::BOLD), + ), + Span::styled(message.to_string(), Style::default().fg(accent).bg(bg)), + ]); + frame.render_widget( + Paragraph::new(vec![line]).style(Style::default().bg(bg)), + area, + ); +} + /// Render the link-hint overlay: a centered popup listing labeled links. pub fn render_link_hints( frame: &mut Frame, area: Rect, hints: &[(char, String)], + title: &str, t: &theme::Theme, ) { let title_color = theme::hex_to_color(&t.colors.heading1); @@ -500,7 +636,7 @@ pub fn render_link_hints( .borders(Borders::ALL) .border_style(Style::default().fg(border_color)) .border_type(ratatui::widgets::BorderType::Rounded) - .title(" Follow link — press a letter, Esc to cancel ") + .title(format!(" {title} ")) .title_style( Style::default() .fg(title_color) @@ -623,3 +759,74 @@ mod tests { assert_eq!(spans_of(&out), vec!["anything"]); } } + +#[cfg(test)] +mod selection_paint { + use super::*; + + fn text_of(line: &Line<'static>) -> Vec<(String, bool)> { + line.spans + .iter() + .map(|s| (s.content.to_string(), s.style.bg.is_some())) + .collect() + } + + #[test] + fn restyle_splits_a_span_at_the_selection_edges() { + let line = Line::from(vec![Span::raw("hello world")]); + let sel = Style::default().bg(Color::Blue); + let out = restyle_columns(&line, 6, 11, sel); + assert_eq!( + text_of(&out), + vec![("hello ".to_string(), false), ("world".to_string(), true)] + ); + } + + #[test] + fn restyle_spans_a_selection_across_several_spans() { + let line = Line::from(vec![ + Span::raw("ab"), + Span::styled("cd", Style::default().fg(Color::Red)), + Span::raw("ef"), + ]); + let out = restyle_columns(&line, 1, 5, Style::default().bg(Color::Blue)); + let selected: String = out + .spans + .iter() + .filter(|s| s.style.bg.is_some()) + .map(|s| s.content.to_string()) + .collect(); + assert_eq!(selected, "bcde"); + } + + #[test] + fn restyle_never_splits_a_wide_glyph() { + // 世 occupies columns 0-1; a selection starting at column 1 must take + // the whole character rather than emit half of it. + let line = Line::from(vec![Span::raw("世界")]); + let out = restyle_columns(&line, 1, 4, Style::default().bg(Color::Blue)); + let selected: String = out + .spans + .iter() + .filter(|s| s.style.bg.is_some()) + .map(|s| s.content.to_string()) + .collect(); + assert_eq!(selected, "世界"); + } + + #[test] + fn restyle_marks_the_swallowed_newline_with_one_trailing_cell() { + let line = Line::from(vec![Span::raw("short")]); + let out = restyle_columns(&line, 0, 6, Style::default().bg(Color::Blue)); + let last = out.spans.last().unwrap(); + assert_eq!(last.content.as_ref(), " "); + assert!(last.style.bg.is_some()); + } + + #[test] + fn restyle_leaves_a_line_outside_the_range_untouched() { + let line = Line::from(vec![Span::raw("hello")]); + let out = restyle_columns(&line, 8, 12, Style::default().bg(Color::Blue)); + assert_eq!(text_of(&out)[0], ("hello".to_string(), false)); + } +} diff --git a/src/selection.rs b/src/selection.rs new file mode 100644 index 0000000..c36769a --- /dev/null +++ b/src/selection.rs @@ -0,0 +1,524 @@ +//! Text selection over the laid-out document. +//! +//! Positions are **document** coordinates — a display-line index plus a display +//! *column* (terminal cells, not bytes and not chars). Screen coordinates never +//! get stored, so scrolling, resizing, and `--watch` reloads cannot rot a +//! selection into pointing at the wrong text. +//! +//! Columns are counted the same way the layout counts them (grapheme clusters +//! measured with `unicode-width`), which is what keeps a selection edge from +//! landing inside a ZWJ emoji or on the second cell of a CJK character. + +use crate::layout::StyledLine; +use unicode_segmentation::UnicodeSegmentation; +use unicode_width::UnicodeWidthStr; + +/// A caret position in the document. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)] +pub struct Pos { + /// Display-line index into the tab's laid-out lines. + pub line: usize, + /// Display column (terminal cells from the left edge of the line). + pub col: usize, +} + +impl Pos { + pub fn new(line: usize, col: usize) -> Self { + Self { line, col } + } +} + +/// Character-wise (`v`) or whole-line (`V`) selection. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SelMode { + Char, + Line, +} + +/// An active selection: fixed `anchor`, moving `cursor`. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Selection { + pub anchor: Pos, + pub cursor: Pos, + pub mode: SelMode, +} + +impl Selection { + pub fn new(at: Pos, mode: SelMode) -> Self { + Self { + anchor: at, + cursor: at, + mode, + } + } + + /// Anchor and cursor in document order. The end is **inclusive** of the + /// cell it names, matching vim's visual mode (and matching what a user + /// expects a drag to have grabbed). + pub fn range(&self) -> (Pos, Pos) { + if self.anchor <= self.cursor { + (self.anchor, self.cursor) + } else { + (self.cursor, self.anchor) + } + } + + /// The half-open column range `[start, end)` to paint on `line`, or `None` + /// when the line is outside the selection. `width` is the line's rendered + /// width, used to bound line-wise selection and end-of-line runs. + pub fn highlight_span(&self, line: usize, width: usize) -> Option<(usize, usize)> { + let (start, end) = self.range(); + if line < start.line || line > end.line { + return None; + } + if self.mode == SelMode::Line { + return Some((0, width.max(1))); + } + // Highlighting one cell past a line's text marks the newline the copy + // will include — without it, a multi-line selection looks like it stops + // at the last glyph. + let full_end = if line < end.line { width + 1 } else { width }; + let from = if line == start.line { start.col } else { 0 }; + let to = if line == end.line { + // `end` is inclusive: extend past the whole grapheme under it. + (end.col + 1).min(full_end.max(end.col + 1)) + } else { + full_end + }; + if to <= from { + return None; + } + Some((from, to)) + } + + /// The selected text, ready for the clipboard. + /// + /// `plain` is the document's per-line text (see [`plain_lines`]). Trailing + /// padding is dropped from every line. Line-wise selections are dedented by + /// their common leading whitespace, which strips ink's centering margin + /// while preserving the relative indentation of code and nested lists. + pub fn extract(&self, plain: &[String]) -> String { + let (start, end) = self.range(); + if plain.is_empty() || start.line >= plain.len() { + return String::new(); + } + let last = end.line.min(plain.len() - 1); + + let mut out: Vec = Vec::with_capacity(last - start.line + 1); + for (line, text) in plain.iter().enumerate().take(last + 1).skip(start.line) { + let piece = match self.mode { + SelMode::Line => text.clone(), + SelMode::Char => { + let from = if line == start.line { start.col } else { 0 }; + let to = if line == end.line { + Some(end.col) + } else { + None + }; + slice_columns(text, from, to) + } + }; + out.push(piece.trim_end().to_string()); + } + + if self.mode == SelMode::Line { + dedent(&mut out); + } + out.join("\n") + } +} + +/// Strip the leading-whitespace prefix that every non-empty line shares. +/// +/// Compared character by character, not by byte count: leading whitespace is +/// not always one byte wide (U+00A0, U+3000), and a byte offset measured on one +/// line lands inside a character on another — which panics. Matching the actual +/// prefix (rather than counting whitespace) also means a document's own +/// indentation survives when it happens to use a different space character than +/// ink's centering margin. +fn dedent(lines: &mut [String]) { + let mut prefix: Option> = None; + for line in lines.iter().filter(|l| !l.is_empty()) { + let ws: Vec = line.chars().take_while(|c| c.is_whitespace()).collect(); + prefix = Some(match prefix { + None => ws, + Some(common) => common + .iter() + .zip(ws.iter()) + .take_while(|(a, b)| a == b) + .map(|(a, _)| *a) + .collect(), + }); + } + let indent = prefix.map_or(0, |p| p.len()); + if indent == 0 { + return; + } + // Safe for every line: a non-empty one starts with at least `indent` + // whitespace characters by construction, and an empty one yields "". + for line in lines.iter_mut() { + *line = line.chars().skip(indent).collect(); + } +} + +/// The text of `text` covering display columns `[from, to]` (`to` inclusive, +/// `None` = to end of line). +/// +/// A grapheme is included when it overlaps the range at all, so a range edge +/// that falls on the second cell of a double-width character still yields that +/// whole character rather than half of it or nothing. +pub fn slice_columns(text: &str, from: usize, to: Option) -> String { + let end = to.map(|t| t + 1).unwrap_or(usize::MAX); + if end <= from { + return String::new(); + } + let mut out = String::new(); + let mut col = 0usize; + for g in text.graphemes(true) { + let w = g.width().max(1); + let (gs, ge) = (col, col + w); + if gs >= end { + break; + } + if ge > from { + out.push_str(g); + } + col = ge; + } + out +} + +/// Flatten styled lines into the plain text the clipboard and the column math +/// both work against. +pub fn plain_lines(lines: &[StyledLine]) -> Vec { + lines + .iter() + .map(|line| { + let mut s = String::new(); + for span in &line.spans { + s.push_str(&span.text); + } + s + }) + .collect() +} + +/// Display width of a line, in terminal cells. +pub fn line_width(text: &str) -> usize { + text.width() +} + +/// Keep a position inside the document: line clamped to the last line, column +/// to one past the last cell (so a caret can sit at end of line). +pub fn clamp(pos: Pos, plain: &[String]) -> Pos { + if plain.is_empty() { + return Pos::default(); + } + let line = pos.line.min(plain.len() - 1); + let col = pos.col.min(line_width(&plain[line])); + Pos::new(line, col) +} + +/// Glyphs ink draws as structure rather than content: heading bars, list +/// bullets, blockquote rules, code-box borders. +const DECORATION: &[char] = &[ + '█', '▌', '▎', '▏', '│', '┃', '╭', '╰', '├', '•', '▪', '◦', '‣', '▸', '»', +]; + +/// The column where a rendered line's *content* starts, past ink's own +/// decoration. +/// +/// Used to place the visual-mode cursor: a selection that starts on the `█` of +/// a heading copies a glyph the document never contained. +pub fn content_start_col(text: &str) -> usize { + let mut col = 0usize; + let mut seen_decoration = false; + for g in text.graphemes(true) { + let is_space = g.chars().all(char::is_whitespace); + let is_decoration = g.chars().all(|c| DECORATION.contains(&c)); + if is_space { + col += g.width().max(1); + continue; + } + if is_decoration && !seen_decoration { + seen_decoration = true; + col += g.width().max(1); + continue; + } + break; + } + col +} + +/// Snap a column to the leading edge of the grapheme covering it. +/// +/// A click on the right half of a wide character must select that character, +/// not the gap after it. +pub fn snap_col(text: &str, col: usize) -> usize { + let mut at = 0usize; + for g in text.graphemes(true) { + let w = g.width().max(1); + if col < at + w { + return at; + } + at += w; + } + at +} + +/// Columns `[start, end]` (end inclusive) of the word under `col`. +/// +/// Falls back to the run of whitespace when the click landed between words, so +/// a double-click always selects *something* visible. +pub fn word_bounds(text: &str, col: usize) -> (usize, usize) { + let mut bounds: Vec<(usize, usize)> = Vec::new(); // (start_col, end_col_inclusive) + let mut at = 0usize; + for word in text.split_word_bounds() { + let w = word.width().max(1); + bounds.push((at, at + w - 1)); + at += w; + } + for (s, e) in bounds { + if col >= s && col <= e { + return (s, e); + } + } + (col, col) +} + +/// Start column of the word after `col`, or end of line. +pub fn next_word_col(text: &str, col: usize) -> usize { + let mut at = 0usize; + let mut candidate = None; + for word in text.split_word_bounds() { + if at > col && !word.trim().is_empty() { + candidate = Some(at); + break; + } + at += word.width().max(1); + } + candidate.unwrap_or_else(|| line_width(text)) +} + +/// Start column of the word before `col`, or 0. +pub fn prev_word_col(text: &str, col: usize) -> usize { + let mut at = 0usize; + let mut best = 0usize; + for word in text.split_word_bounds() { + if at >= col { + break; + } + if !word.trim().is_empty() { + best = at; + } + at += word.width().max(1); + } + best +} + +#[cfg(test)] +mod tests { + use super::*; + + fn doc() -> Vec { + vec![ + "the quick brown fox".to_string(), + "jumps over".to_string(), + "the lazy dog".to_string(), + ] + } + + #[test] + fn char_selection_spans_partial_first_and_last_lines() { + let sel = Selection { + anchor: Pos::new(0, 4), + cursor: Pos::new(2, 7), + mode: SelMode::Char, + }; + assert_eq!(sel.extract(&doc()), "quick brown fox\njumps over\nthe lazy"); + } + + #[test] + fn selection_is_direction_agnostic() { + let forward = Selection { + anchor: Pos::new(0, 4), + cursor: Pos::new(2, 7), + mode: SelMode::Char, + }; + let backward = Selection { + anchor: Pos::new(2, 7), + cursor: Pos::new(0, 4), + mode: SelMode::Char, + }; + assert_eq!(forward.extract(&doc()), backward.extract(&doc())); + } + + #[test] + fn line_selection_takes_whole_lines_and_strips_the_margin() { + let margined: Vec = doc().iter().map(|l| format!(" {l} ")).collect(); + let sel = Selection { + anchor: Pos::new(0, 4), + cursor: Pos::new(2, 7), + mode: SelMode::Line, + }; + assert_eq!( + sel.extract(&margined), + "the quick brown fox\njumps over\nthe lazy dog" + ); + } + + /// Regression: the shared indent used to be counted in *bytes* and then + /// sliced off by byte offset. With leading whitespace of mixed width — an + /// ideographic space on one line, non-breaking spaces on the next — that + /// offset lands inside a character and slicing panics, taking ink down + /// mid-selection. + #[test] + fn line_selection_survives_mixed_width_leading_whitespace() { + let lines = vec![ + " \u{3000}alpha".to_string(), // 2 spaces + 3-byte ideographic space + " \u{a0}\u{a0}beta".to_string(), // 2 spaces + two 2-byte NBSPs + ]; + let sel = Selection { + anchor: Pos::new(0, 0), + cursor: Pos::new(1, 4), + mode: SelMode::Line, + }; + // The common prefix is the two ASCII spaces; everything past it stays. + assert_eq!(sel.extract(&lines), "\u{3000}alpha\n\u{a0}\u{a0}beta"); + } + + #[test] + fn line_selection_keeps_relative_indentation() { + let lines = vec![ + " fn main() {".to_string(), + " println!();".to_string(), + " }".to_string(), + ]; + let sel = Selection { + anchor: Pos::new(0, 0), + cursor: Pos::new(2, 4), + mode: SelMode::Line, + }; + assert_eq!(sel.extract(&lines), "fn main() {\n println!();\n}"); + } + + #[test] + fn a_selection_edge_inside_a_wide_character_takes_the_whole_character() { + // 世 and 界 are two cells each: columns 0-1 and 2-3. + let lines = vec!["世界a".to_string()]; + // Start on the *second* cell of 世, end on the second cell of 界. + let sel = Selection { + anchor: Pos::new(0, 1), + cursor: Pos::new(0, 3), + mode: SelMode::Char, + }; + assert_eq!(sel.extract(&lines), "世界"); + assert!(sel.extract(&lines).contains('世')); + } + + #[test] + fn single_cell_selection_takes_one_grapheme() { + let lines = vec!["abc".to_string()]; + let sel = Selection::new(Pos::new(0, 1), SelMode::Char); + assert_eq!(sel.extract(&lines), "b"); + } + + #[test] + fn selection_past_the_end_of_the_document_is_empty_not_a_panic() { + let sel = Selection { + anchor: Pos::new(9, 0), + cursor: Pos::new(12, 4), + mode: SelMode::Char, + }; + assert_eq!(sel.extract(&doc()), ""); + } + + #[test] + fn highlight_span_covers_the_cursor_cell() { + let sel = Selection { + anchor: Pos::new(0, 2), + cursor: Pos::new(0, 5), + mode: SelMode::Char, + }; + assert_eq!(sel.highlight_span(0, 19), Some((2, 6))); + assert_eq!(sel.highlight_span(1, 10), None); + } + + #[test] + fn line_mode_highlights_the_whole_line() { + let sel = Selection { + anchor: Pos::new(0, 7), + cursor: Pos::new(1, 2), + mode: SelMode::Line, + }; + assert_eq!(sel.highlight_span(0, 19), Some((0, 19))); + assert_eq!(sel.highlight_span(1, 10), Some((0, 10))); + } + + #[test] + fn content_start_col_skips_inks_own_decoration() { + // Heading bar, list bullet, blockquote rule — none of these are in the + // markdown the user is reading. + assert_eq!(content_start_col(" █ Install ink"), 4); + assert_eq!(content_start_col(" • an item"), 4); + assert_eq!(content_start_col(" │ quoted"), 4); + // Plain text keeps everything past the margin. + assert_eq!(content_start_col(" hello"), 4); + // Only one run is skipped: a second bar is content (nested quote). + assert_eq!(content_start_col(" │ │ nested"), 4); + assert_eq!(content_start_col(""), 0); + } + + #[test] + fn snap_col_lands_on_grapheme_starts() { + assert_eq!(snap_col("世界a", 0), 0); + assert_eq!(snap_col("世界a", 1), 0); + assert_eq!(snap_col("世界a", 2), 2); + assert_eq!(snap_col("世界a", 3), 2); + assert_eq!(snap_col("世界a", 4), 4); + } + + #[test] + fn word_bounds_grabs_the_word_under_the_column() { + let text = "the quick brown"; + assert_eq!(word_bounds(text, 5), (4, 8)); + assert_eq!(word_bounds(text, 4), (4, 8)); + assert_eq!(word_bounds(text, 8), (4, 8)); + } + + #[test] + fn word_motions_step_between_words() { + let text = "the quick brown fox"; + assert_eq!(next_word_col(text, 0), 4); + assert_eq!(next_word_col(text, 4), 10); + assert_eq!(next_word_col(text, 16), line_width(text)); + assert_eq!(prev_word_col(text, 10), 4); + assert_eq!(prev_word_col(text, 2), 0); + } + + #[test] + fn clamp_keeps_positions_inside_the_document() { + let d = doc(); + assert_eq!(clamp(Pos::new(99, 99), &d), Pos::new(2, 12)); + assert_eq!(clamp(Pos::new(1, 3), &d), Pos::new(1, 3)); + assert_eq!(clamp(Pos::new(0, 0), &[]), Pos::default()); + } + + #[test] + fn plain_lines_concatenates_spans() { + use crate::layout::{SpanStyle, StyledSpan}; + let line = StyledLine { + spans: vec![ + StyledSpan { + text: " ".into(), + style: SpanStyle::default(), + }, + StyledSpan { + text: "hello".into(), + style: SpanStyle::default(), + }, + ], + }; + assert_eq!(plain_lines(&[line]), vec![" hello".to_string()]); + } +} diff --git a/src/theme/builtin.rs b/src/theme/builtin.rs index 7c27a3b..515db68 100644 --- a/src/theme/builtin.rs +++ b/src/theme/builtin.rs @@ -40,6 +40,8 @@ pub fn dark() -> Theme { task_pending: "#565f89".to_string(), search_match: "#e0af68".to_string(), search_current: "#ff9e64".to_string(), + selection_bg: Some("#33467c".to_string()), + selection_fg: Some("#c0caf5".to_string()), status_bar_bg: "#16161e".to_string(), status_bar_fg: "#a9b1d6".to_string(), toc_active: "#7aa2f7".to_string(), @@ -86,6 +88,8 @@ pub fn light() -> Theme { // Search: must be visible as FG on white — use warm dark tones search_match: "#b35000".to_string(), // burnt orange — visible on white search_current: "#cf222e".to_string(), // red — unmistakable current match + selection_bg: Some("#cfe4ff".to_string()), + selection_fg: Some("#24292f".to_string()), status_bar_bg: "#d0d7de".to_string(), status_bar_fg: "#24292f".to_string(), toc_active: "#0550ae".to_string(), @@ -131,6 +135,8 @@ pub fn dracula() -> Theme { task_pending: "#6272a4".to_string(), search_match: "#f1fa8c".to_string(), // yellow — pops on dark dracula bg search_current: "#ffb86c".to_string(), // orange + selection_bg: Some("#44475a".to_string()), + selection_fg: Some("#f8f8f2".to_string()), status_bar_bg: "#21222c".to_string(), status_bar_fg: "#f8f8f2".to_string(), toc_active: "#bd93f9".to_string(), @@ -176,6 +182,8 @@ pub fn catppuccin() -> Theme { task_pending: "#585b70".to_string(), search_match: "#f9e2af".to_string(), // peach — warm accent search_current: "#fab387".to_string(), // orange + selection_bg: Some("#45475a".to_string()), + selection_fg: Some("#cdd6f4".to_string()), status_bar_bg: "#11111b".to_string(), status_bar_fg: "#bac2de".to_string(), toc_active: "#89b4fa".to_string(), @@ -221,6 +229,8 @@ pub fn nord() -> Theme { task_pending: "#4c566a".to_string(), search_match: "#ebcb8b".to_string(), // warm yellow search_current: "#d08770".to_string(), // aurora orange + selection_bg: Some("#434c5e".to_string()), + selection_fg: Some("#eceff4".to_string()), status_bar_bg: "#3b4252".to_string(), status_bar_fg: "#d8dee9".to_string(), toc_active: "#88c0d0".to_string(), @@ -270,6 +280,8 @@ pub fn gruvbox() -> Theme { task_pending: "#665c54".to_string(), search_match: "#fabd2f".to_string(), // gruvbox yellow search_current: "#fe8019".to_string(), // gruvbox orange + selection_bg: Some("#504945".to_string()), + selection_fg: Some("#ebdbb2".to_string()), status_bar_bg: "#1d2021".to_string(), status_bar_fg: "#ebdbb2".to_string(), toc_active: "#83a598".to_string(), @@ -315,6 +327,8 @@ pub fn solarized() -> Theme { task_pending: "#586e75".to_string(), search_match: "#b58900".to_string(), // solarized yellow search_current: "#cb4b16".to_string(), // solarized orange + selection_bg: Some("#135b68".to_string()), + selection_fg: Some("#eee8d5".to_string()), status_bar_bg: "#073642".to_string(), status_bar_fg: "#839496".to_string(), toc_active: "#268bd2".to_string(), diff --git a/src/theme/mod.rs b/src/theme/mod.rs index 7bc7735..c5c5a06 100644 --- a/src/theme/mod.rs +++ b/src/theme/mod.rs @@ -83,6 +83,12 @@ pub struct ThemeColors { pub task_pending: String, pub search_match: String, pub search_current: String, + /// Background of selected text. Optional so themes written before + /// selection existed still deserialize; see [`ThemeColors::selection`]. + #[serde(default)] + pub selection_bg: Option, + #[serde(default)] + pub selection_fg: Option, pub status_bar_bg: String, pub status_bar_fg: String, pub toc_active: String, @@ -94,6 +100,25 @@ pub struct ThemeColors { pub admonition_caution: String, } +impl ThemeColors { + /// Selection colors as `(bg, fg)`, filling in for a theme that predates + /// them: the search-match color already had to be legible against this + /// theme's background, and the background itself is the safest foreground + /// to print on top of it. + pub fn selection(&self) -> (String, String) { + let bg = self + .selection_bg + .clone() + .unwrap_or_else(|| self.search_match.clone()); + let fg = self + .selection_fg + .clone() + .or_else(|| self.bg.clone()) + .unwrap_or_else(|| self.fg.clone()); + (bg, fg) + } +} + /// Resolve a theme by name. Checks built-in themes first, then user config dir. /// A broken or unknown theme falls back to `dark`, but silently doing so /// left users debugging "why does my theme do nothing". Warn once per @@ -177,6 +202,61 @@ pub fn hex_to_color(hex: &str) -> ratatui::style::Color { } } +/// A theme file written before selection colors existed must still load, and +/// must still produce a legible selection. Every user theme in the wild is one +/// of these. +#[test] +#[cfg(test)] +fn user_theme_without_selection_keys() { + const LEGACY: &str = r##"name = "mytheme" + +[colors] +bg = "#1a1b26" +fg = "#c0caf5" +heading1 = "#7aa2f7" +heading2 = "#7dcfff" +heading3 = "#bb9af7" +heading4 = "#9ece6a" +heading5 = "#e0af68" +heading6 = "#f7768e" +bold = "#e6e8f0" +italic = "#c0caf5" +strikethrough = "#565f89" +code_fg = "#a9b1d6" +code_bg = "#24283b" +code_block_bg = "#24283b" +link = "#7aa2f7" +link_url = "#565f89" +blockquote_bar = "#565f89" +blockquote_text = "#a9b1d6" +list_bullet = "#7aa2f7" +list_number = "#7aa2f7" +table_border = "#3b4261" +table_header = "#7dcfff" +hr = "#3b4261" +task_done = "#9ece6a" +task_pending = "#565f89" +search_match = "#e0af68" +search_current = "#ff9e64" +status_bar_bg = "#16161e" +status_bar_fg = "#a9b1d6" +toc_active = "#7aa2f7" +toc_inactive = "#565f89" +admonition_note = "#7aa2f7" +admonition_warning = "#e0af68" +admonition_tip = "#9ece6a" +admonition_important = "#bb9af7" +admonition_caution = "#f7768e" +"##; + let theme: Theme = toml::from_str(LEGACY).expect("legacy theme must still deserialize"); + assert!(theme.colors.selection_bg.is_none()); + let (bg, fg) = theme.colors.selection(); + // Falls back to the search-match color, which this theme already had to + // make legible against its own background. + assert_eq!(bg, "#e0af68"); + assert_eq!(fg, "#1a1b26"); +} + #[cfg(test)] mod hex_tests { use super::*; diff --git a/src/toc.rs b/src/toc.rs index 123dda0..c835faa 100644 --- a/src/toc.rs +++ b/src/toc.rs @@ -14,6 +14,8 @@ pub struct TocEntry { pub level: u8, pub text: String, pub line_index: usize, + /// 1-based line in the markdown source, for copying a section's source. + pub source_line: usize, } impl TocState { @@ -31,6 +33,7 @@ impl TocState { level: h.level, text: h.text.clone(), line_index, + source_line: h.byte_offset, } }) .collect(); diff --git a/tests/cli.rs b/tests/cli.rs index 79b551b..9959bff 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -191,3 +191,13 @@ fn doctor_save_writes_report_file() { let saved = std::fs::read_to_string(&path).unwrap(); assert!(saved.contains("ink doctor — image rendering diagnostics")); } + +#[test] +fn keybindings_lists_copy_actions() { + ink().arg("keybindings").assert().success().stdout( + predicate::str::contains("select_mode") + .and(predicate::str::contains("select_line_mode")) + .and(predicate::str::contains("copy_code")) + .and(predicate::str::contains("copy_section")), + ); +} diff --git a/tests/plain_snapshots.rs b/tests/plain_snapshots.rs index 958f798..80c043b 100644 --- a/tests/plain_snapshots.rs +++ b/tests/plain_snapshots.rs @@ -21,6 +21,7 @@ fn test_args() -> Args { frontmatter: false, spacing: Spacing::Normal, mouse_capture: true, + clipboard: ink_md::clipboard::ClipboardMode::Off, } } diff --git a/tests/render_viewport.rs b/tests/render_viewport.rs index 5267bfe..06a5f35 100644 --- a/tests/render_viewport.rs +++ b/tests/render_viewport.rs @@ -38,7 +38,17 @@ fn viewport_shows_slice_at_offset() { terminal .draw(|f| { let area = Rect::new(0, 0, 20, 10); - render_document_with_search(f, area, &lines, 500, lines.len(), &search, &theme); + render_document_with_search( + f, + area, + &lines, + 500, + lines.len(), + &search, + None, + &[], + &theme, + ); }) .unwrap(); @@ -63,7 +73,17 @@ fn offset_past_end_does_not_panic() { terminal .draw(|f| { let area = Rect::new(0, 0, 20, 10); - render_document_with_search(f, area, &lines, 9999, lines.len(), &search, &theme); + render_document_with_search( + f, + area, + &lines, + 9999, + lines.len(), + &search, + None, + &[], + &theme, + ); }) .unwrap(); // No panic is the assertion. diff --git a/tests/security.rs b/tests/security.rs index 66e7f82..2a443eb 100644 --- a/tests/security.rs +++ b/tests/security.rs @@ -18,6 +18,7 @@ fn args() -> Args { frontmatter: false, spacing: Spacing::Normal, mouse_capture: true, + clipboard: ink_md::clipboard::ClipboardMode::Off, } }