tui-panel-select 0.1.6

Panel-scoped mouse text selection and clipboard copy for ratatui apps
Documentation
# tui-panel-select

Panel-scoped mouse text selection and clipboard copy for
[ratatui](https://docs.rs/ratatui) apps.

A terminal's own click-drag selection can't be confined to one panel — it spans
the full terminal row, sweeping up borders and neighbouring panels. This crate
lets your app capture the mouse itself and implement a selection that is:

- **Confined to a single panel's rectangle** — never spills into other panels
  or the border.
- **Natural "stream" selection** — first line from the click column to its end,
  full lines in between, last line up to the release column (never a
  rectangular block).
- **Stable across resize / rewrap / scroll** — selections are stored as logical
  `(line, column)` positions, not stale screen cells, so the same *characters*
  stay selected when the panel is resized or rewrapped.
- **Cheap on huge content** — only the rows actually on screen are ever wrapped
  or painted, so a multi-megabyte body (or one enormous unbroken line) stays
  responsive.
- **Copy that works locally and remotely** — on mouse-up the text is copied via
  a local clipboard tool (`xclip`/`xsel`/`wl-copy`/`pbcopy`/`clip.exe`) when
  available, falling back to an OSC 52 escape sequence for SSH/tmux sessions.
  Pin or disable the mechanism with `set_clipboard_mode` or the
  `TUI_PANEL_SELECT_CLIPBOARD` environment variable — see
  [Choosing the clipboard backend]#choosing-the-clipboard-backend.

## Quick start — the batteries-included API

```rust
use std::sync::Arc;
use ratatui::layout::Rect;
use tui_panel_select::SelectablePanel;

let mut panel = SelectablePanel::new();

// Each frame, before drawing, give the panel its text and inner width.
panel.set_content(Arc::from("hello world\nsecond line"), 40);

// The panel's inner text area on screen, and its scroll offset (wrapped rows).
let area = Rect::new(1, 1, 40, 10);
let scroll = 0;

// Mouse down starts a selection; drag extends it; up copies it.
panel.begin_selection(area, scroll, (1, 1));   // click at 'h'
panel.extend_selection(area, scroll, (5, 1));  // drag to 'o'
assert_eq!(panel.selected_text().as_deref(), Some("hello"));
panel.copy_selection();                         // -> system clipboard
```

Rendering each frame:

```rust,no_run
# use tui_panel_select::SelectablePanel;
# use ratatui::layout::Rect;
# let panel = SelectablePanel::new();
# let area = Rect::new(0, 0, 40, 10);
# let scroll = 0u16;
// 1. Draw the visible wrapped rows into your panel:
let rows = panel.visible_rows(scroll, area.height);

// 2. Paint the highlight over the selected cells:
for (row, col_from, col_to) in panel.highlight_cells(area, scroll) {
    // invert/style cells [col_from, col_to) on terminal row `row`
}
```

Wire these into your event loop: capture the mouse (`EnableMouseCapture`), then
call `begin_selection` on `MouseEventKind::Down`, `extend_selection` on `Drag`,
and `copy_selection` on `Up`. Call `set_content` every frame — it only rebuilds
its cache when the text (by `Arc` identity) or width actually changed.

## Opt-in mouse handler

If you'd rather not wire the three events up yourself, `handle_mouse` does the
common "drag to select, release to copy" flow in one call. Behaviour is
configured per-application via `MouseConfig` (e.g. `copy_on_release`), and the
low-level methods above stay available:

```rust,no_run
# use tui_panel_select::{MouseConfig, SelectablePanel};
# use ratatui::layout::Rect;
# use ratatui::crossterm::event::MouseEvent;
# fn demo(panel: &mut SelectablePanel, area: Rect, scroll: u16, ev: MouseEvent) {
let cfg = MouseConfig::default(); // copy_on_release: true
let _action = panel.handle_mouse(ev, area, scroll, &cfg);
# }
```

## Panic-safe terminal guard (feature `terminal-guard`, on by default)

Enabling panel selection means turning on the terminal's mouse-tracking mode,
which must be undone on exit *and* on any panic — otherwise the user's shell is
left spewing tracking escape sequences. `TerminalGuard` centralises that: it
enables mouse capture (and, optionally, the keyboard-enhancement protocol),
wraps the panic hook, and restores everything on drop:

```rust,no_run
# fn main() -> std::io::Result<()> {
use tui_panel_select::TerminalGuard;

let mut terminal = ratatui::init();
let guard = TerminalGuard::install(true)?;
// ... run your event loop ...
drop(guard);           // restores mouse capture / keyboard flags
ratatui::restore();
# Ok(())
# }
```

Disable the `terminal-guard` feature (`default-features = false`) if you only
want the pure selection/wrapping logic without the process-global panic hook.

## Line layout: wrap or clip (`WrapMode`)

By default each raw line wider than the panel is **wrapped** onto multiple rows.
For panels that show pre-formatted, column-aligned output (e.g. program output
echoed verbatim), call `set_wrap_mode(WrapMode::Clip)` instead: every raw line
then occupies exactly one screen row and anything past the right edge is
clipped. Selection, copy and scrolling all follow suit (scrolling moves by whole
lines, one row per line).

```rust
# use tui_panel_select::{SelectablePanel, WrapMode};
let mut panel = SelectablePanel::new();
panel.set_wrap_mode(WrapMode::Clip);
```

## End of line wrap marker (`WrapMarker)

There is an optional `WrapMarker` that can be added to a `SelectablePanel`. The
`WrapMarker` can be built with the `WrapMarkerBuilder` like so:

```rust
# use tui_panel_select::{SelectablePanel, WrapMode, WrapMarker};
let mut panel = SelectablePanel::new();
panel.set_wrap_mode(WrapMode::Wrap);
let wrap_marker = WrapMarker::builder().glyph('>').build();
panel.set_wrap_marker(wrap_marker);
```

## Vertical scrollbar (feature `scrollbar`, on by default)

Two panel-agnostic helpers wire up a native-feeling vertical scrollbar for any
scrollable content:

- `scroll_for_track_row(track, row, max_scroll)` maps a click or drag anywhere
  in the scrollbar track to a scroll offset (proportional, clamped to the
  track), so dragging the thumb — or clicking anywhere along it — jumps there.
- `render_scrollbar(area, buf, total, capacity, start, &style)` draws a
  ratatui scrollbar into a track column, sizing the thumb from the content
  totals and no-op-ing when everything already fits.

A `MultiSelectPanel` (which owns its scroll offset) has convenience wrappers
that plumb their own geometry in:

```rust,no_run
# use tui_panel_select::{MultiSelectPanel, ScrollbarStyle};
# use ratatui::{buffer::Buffer, layout::Rect, style::{Style, Color}};
# fn demo(panel: &mut MultiSelectPanel, track: Rect, clicked_row: u16, buf: &mut Buffer) {
// On a scrollbar click/drag: jump/scroll to that row.
panel.scroll_to_track_row(track, clicked_row);

// Each frame: draw the bar (styled to taste; a no-op when content fits).
let style = ScrollbarStyle {
    track_style: Style::default().fg(Color::DarkGray),
    thumb_style: Style::default().fg(Color::Cyan),
    ..ScrollbarStyle::default()
};
panel.render_scrollbar(track, buf, &style);
# }
```

Disable the feature (`default-features = false`) if you draw your own indicator.

## ANSI-coloured content (feature `ansi`, off by default)

If your panel text contains ANSI escape sequences (e.g. coloured program
output), enable the `ansi` feature and feed it via `set_ansi_content` instead of
`set_content`. Rendered rows (`visible_rows`) keep their colour, while
selection, copy and all geometry operate on the plain, **stripped** text — so
you don't have to maintain a second, un-coloured copy yourself:

```toml
tui-panel-select = { version = "0.1", features = ["ansi"] }
```

```rust,no_run
# use std::sync::Arc;
# use tui_panel_select::{SelectablePanel, WrapMode};
# fn demo(panel: &mut SelectablePanel, colored: Arc<str>, width: usize) {
panel.set_wrap_mode(WrapMode::Clip);      // often paired with clip for verbatim output
panel.set_ansi_content(colored, width);   // escapes parsed once, colour preserved
# }
```

## Low-level primitives

If your app already owns its selection state (e.g. multiple simultaneous
selections, keyboard-extended selection, excluding decorative characters from
the copied text), skip `SelectablePanel` and use the stateless building blocks
directly:

- [`wrapcache::PanelWrap`] / [`wrapcache::TextPos`] — the line/wrap cache and
  logical positions, with conversions between screen and logical space.
- [`selection`] — pure functions: `point_to_textpos`, `extract_text`,
  `highlight_cells`, `strip_positions`.
- [`clipboard::copy_to_clipboard`] — local tool + OSC 52 fallback.
- [`wrap`] — the underlying character-exact line-wrapping helpers.

## Choosing the clipboard backend

`copy_to_clipboard` tries a local clipboard tool first and only falls back to
OSC 52. That order is deliberate: writing an OSC 52 sequence to stdout always
"succeeds" whether or not the terminal acts on it, so there is no failure to
detect and nothing to fall back *from* — preferring it would silently drop the
copy on the many terminals that ignore it (GNOME Terminal/VTE among them).

When both an X11 display and a Wayland display are reachable, the X11 tools are
tried first. Setting the Wayland selection needs a serial from an input event,
so it needs keyboard focus and therefore a mapped surface, unless the
compositor implements `wlr-data-control` — which GNOME does not. Under GNOME,
`wl-copy` consequently maps a real window and the desktop lists it as a running
application for as long as it owns the selection, flashing an entry into the
app bar on every copy. An X11 selection owner needs no mapped window, and
XWayland's clipboard bridge still propagates the selection to Wayland clients.

Override the choice with `TUI_PANEL_SELECT_CLIPBOARD` (`auto`, `x11`,
`wayland`, `osc52`, `none`) or, from code, `set_clipboard_mode`:

```rust
use tui_panel_select::{ClipboardMode, set_clipboard_mode};

// Compositor with XWayland but no clipboard bridging? Force the native path.
set_clipboard_mode(ClipboardMode::Wayland);
```

**Test suites should disable it.** Copying reaches the real system clipboard,
so any test that exercises a copy path will overwrite whatever the developer
had copied — and each spawned helper must be reaped or it lingers as an
unkillable zombie. Disable it once during test setup:

```rust
use tui_panel_select::{ClipboardMode, set_clipboard_mode};

set_clipboard_mode(ClipboardMode::None);
```

## License

MIT