tui_panel_select/panel.rs
1//! A ready-to-use, batteries-included wrapper: [`SelectablePanel`].
2//!
3//! The primitives in [`crate::wrapcache`] and [`crate::selection`] are
4//! deliberately stateless (easy to embed in an existing app that already
5//! owns its own selection state). `SelectablePanel` bundles them into the
6//! smallest useful stateful object for the common case: one scrollable text
7//! panel whose content can be mouse-selected and copied, with the selection
8//! confined to the panel and surviving resizes/rewraps.
9//!
10//! # Example
11//!
12//! ```
13//! use std::sync::Arc;
14//! use ratatui::layout::Rect;
15//! use tui_panel_select::SelectablePanel;
16//! # use tui_panel_select::{ClipboardMode, set_clipboard_mode};
17//! # // Doctests are compiled *without* `cfg(test)`, so without this the
18//! # // example below would overwrite the real desktop clipboard on every
19//! # // `cargo test` run.
20//! # set_clipboard_mode(ClipboardMode::None);
21//!
22//! let mut panel = SelectablePanel::new();
23//! // Each frame, before drawing, tell the panel its text and inner width.
24//! panel.set_content(Arc::from("hello world\nsecond line"), 40);
25//!
26//! // The panel's inner text area on screen, and its current scroll offset.
27//! let area = Rect::new(1, 1, 40, 10);
28//! let scroll = 0;
29//!
30//! // Mouse down starts a selection; drag extends it; up copies it.
31//! panel.begin_selection(area, scroll, (1, 1)); // click at "h"
32//! panel.extend_selection(area, scroll, (5, 1)); // drag to "o"
33//! assert_eq!(panel.selected_text().as_deref(), Some("hello"));
34//!
35//! // On mouse-up, copy to the system clipboard (best-effort).
36//! panel.copy_selection();
37//! ```
38//!
39//! Anything that copies reaches the real system clipboard, so a host
40//! application's own test suite should call
41//! [`set_clipboard_mode`](crate::set_clipboard_mode) with
42//! [`ClipboardMode::None`](crate::ClipboardMode::None) during setup.
43//!
44//! Rendering each frame:
45//!
46//! ```no_run
47//! # use tui_panel_select::SelectablePanel;
48//! # use ratatui::layout::Rect;
49//! # let panel = SelectablePanel::new();
50//! # let area = Rect::new(0, 0, 40, 10);
51//! # let scroll = 0u16;
52//! // 1. Draw the visible wrapped rows:
53//! let rows = panel.visible_rows(scroll, area.height);
54//! // ...render `rows` into `area`...
55//!
56//! // 2. Paint the highlight over the selected cells:
57//! for (row, col_from, col_to) in panel.highlight_cells(area, scroll) {
58//! // ...invert/style cells [col_from, col_to) on terminal row `row`...
59//! let _ = (row, col_from, col_to);
60//! }
61//! ```
62
63use std::sync::Arc;
64
65use ratatui::crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
66use ratatui::layout::{Position, Rect};
67use ratatui::text::Line;
68
69use crate::clipboard::copy_to_clipboard;
70use crate::selection;
71use crate::wrapcache::{PanelWrap, TextPos, WrapMarker, WrapMode};
72
73/// How [`SelectablePanel::handle_mouse`] should behave. Every field is a
74/// per-application choice, so different consumers can wire the same panel up
75/// differently. Start from [`MouseConfig::default`] and override what you want.
76#[derive(Clone, Copy, Debug)]
77pub struct MouseConfig {
78 /// Copy the selection to the clipboard when the left button is released.
79 /// `true` mirrors a typical terminal drag-select-to-copy; set it `false`
80 /// if you'd rather copy from an explicit key binding (call
81 /// [`SelectablePanel::copy_selection`] yourself).
82 pub copy_on_release: bool,
83 /// Clear the selection when the left button is pressed *outside* the
84 /// panel's text area (a click elsewhere deselects). `false` leaves any
85 /// existing selection untouched on an outside click.
86 pub clear_on_outside_click: bool,
87}
88
89impl Default for MouseConfig {
90 fn default() -> Self {
91 Self {
92 copy_on_release: true,
93 clear_on_outside_click: true,
94 }
95 }
96}
97
98/// What [`SelectablePanel::handle_mouse`] did with an event, so the host
99/// knows whether to redraw or react.
100#[derive(Clone, Copy, Debug, PartialEq, Eq)]
101pub enum MouseAction {
102 /// A selection was started or extended (the highlight likely changed).
103 Selecting,
104 /// The selection was copied to the clipboard (on release).
105 Copied,
106 /// The selection was cleared (an outside click, per [`MouseConfig`]).
107 Cleared,
108 /// Nothing relevant happened (some other event/button).
109 Ignored,
110}
111
112/// One scrollable, mouse-selectable text panel.
113///
114/// Holds the panel's wrapped-line cache and its current selection. Cheap to
115/// keep around across frames: [`set_content`](Self::set_content) only
116/// rebuilds the cache when the text or width actually changed, so calling it
117/// unconditionally every frame is fine.
118#[derive(Default)]
119pub struct SelectablePanel {
120 wrap: Option<PanelWrap>,
121 /// How raw lines wider than the panel are laid out (wrap vs clip).
122 mode: WrapMode,
123 /// Optional end-of-row wrap marker (a dim chevron/arrow in a reserved
124 /// rightmost column on continued rows). `None` disables it (the default).
125 marker: Option<WrapMarker>,
126 /// `(anchor, cursor)` in logical positions. The anchor is where the
127 /// selection started (mouse-down); the cursor is its live end (drag).
128 selection: Option<(TextPos, TextPos)>,
129}
130
131impl SelectablePanel {
132 /// A panel with no content and no selection.
133 pub fn new() -> Self {
134 Self::default()
135 }
136
137 /// Choose how raw lines wider than the panel are laid out — [`WrapMode::Wrap`]
138 /// (the default) breaks them onto multiple rows, [`WrapMode::Clip`] renders
139 /// each raw line on exactly one row and clips the overflow. Takes effect on
140 /// the next [`set_content`](Self::set_content) call (which apps make every
141 /// frame).
142 pub fn set_wrap_mode(&mut self, mode: WrapMode) {
143 self.mode = mode;
144 }
145
146 /// This panel's current [`WrapMode`].
147 pub fn wrap_mode(&self) -> WrapMode {
148 self.mode
149 }
150
151 /// Enable or disable the end-of-row wrap marker — a dim glyph (a chevron
152 /// `›`, a return arrow `↵`, …) drawn in a reserved rightmost column on
153 /// every *continued* wrapped row, so a soft wrap reads differently from a
154 /// real line break. Pass `Some(WrapMarker { .. })` to enable it (start
155 /// from [`WrapMarker::default`] and override the glyph/style), or `None`
156 /// to disable it (the default).
157 ///
158 /// Only meaningful in [`WrapMode::Wrap`]. When enabled, lines wrap to one
159 /// column narrower than the panel to make room for the glyph; because all
160 /// selection and copy geometry keys off that reduced wrap width, the
161 /// marker column is automatically excluded from highlighting and from
162 /// copied text. Takes effect on the next [`set_content`](Self::set_content)
163 /// call (which apps make every frame).
164 pub fn set_wrap_marker(&mut self, marker: Option<WrapMarker>) {
165 self.marker = marker;
166 }
167
168 /// This panel's current end-of-row wrap marker, if any.
169 pub fn wrap_marker(&self) -> Option<WrapMarker> {
170 self.marker
171 }
172
173 /// Set (or update) the panel's text and the inner width it wraps to, in
174 /// columns. A no-op when neither the text (by `Arc` identity) nor the
175 /// width (nor the [`WrapMode`]) changed, so it's safe — and intended — to
176 /// call every frame.
177 ///
178 /// Pass a fresh `Arc<str>` whenever the underlying text changes; identity
179 /// (not byte comparison) is what signals "content changed".
180 pub fn set_content(&mut self, text: Arc<str>, width: usize) {
181 PanelWrap::rebuild_if_needed_marker(&mut self.wrap, &text, width, self.mode, self.marker);
182 }
183
184 /// Set (or update) the panel's text from a string that may contain ANSI
185 /// escape sequences: rendered rows ([`visible_rows`](Self::visible_rows))
186 /// keep their colour, while selection, copy and geometry operate on the
187 /// plain, stripped text. Otherwise identical to
188 /// [`set_content`](Self::set_content) (safe to call every frame; honours
189 /// the current [`WrapMode`]). Requires the `ansi` feature.
190 #[cfg(feature = "ansi")]
191 pub fn set_ansi_content(&mut self, text: Arc<str>, width: usize) {
192 PanelWrap::rebuild_if_needed_ansi_marker(
193 &mut self.wrap,
194 &text,
195 width,
196 self.mode,
197 self.marker,
198 );
199 }
200
201 /// Whether any content has been set yet.
202 pub fn has_content(&self) -> bool {
203 self.wrap.is_some()
204 }
205
206 /// The total number of wrapped rows the current content occupies — the
207 /// scrollable extent, for sizing a scrollbar or clamping a scroll offset.
208 pub fn total_rows(&self) -> u32 {
209 self.wrap.as_ref().map_or(0, PanelWrap::total_rows)
210 }
211
212 /// The exact, unmodified text the panel was built from (every line, not
213 /// just what's scrolled into view) — for a "copy the whole panel"
214 /// action that needs no selection.
215 pub fn whole_text(&self) -> Option<&str> {
216 self.wrap.as_ref().map(PanelWrap::source)
217 }
218
219 /// Start a selection at terminal point `(col, row)`, given the panel's
220 /// inner `area` and current `scroll` (in wrapped rows). Points outside
221 /// `area` clamp to its nearest edge. No-op if there's no content.
222 pub fn begin_selection(&mut self, area: Rect, scroll: u16, point: (u16, u16)) {
223 let Some(wrap) = self.wrap.as_ref() else {
224 return;
225 };
226 let pos = selection::point_to_textpos(point, area, scroll, wrap);
227 self.selection = Some((pos, pos));
228 }
229
230 /// Extend the in-progress selection's live end to terminal point
231 /// `(col, row)`. No-op if no selection was started or there's no content.
232 pub fn extend_selection(&mut self, area: Rect, scroll: u16, point: (u16, u16)) {
233 let Some(wrap) = self.wrap.as_ref() else {
234 return;
235 };
236 if let Some((_, cursor)) = self.selection.as_mut() {
237 *cursor = selection::point_to_textpos(point, area, scroll, wrap);
238 }
239 }
240
241 /// Drop the current selection.
242 pub fn clear_selection(&mut self) {
243 self.selection = None;
244 }
245
246 /// Whether there is a selection (even a zero-width one from a bare click).
247 pub fn has_selection(&self) -> bool {
248 self.selection.is_some()
249 }
250
251 /// The currently selected text (lines joined with `\n`), or `None` when
252 /// there's no selection or it covers nothing but whitespace.
253 pub fn selected_text(&self) -> Option<String> {
254 let wrap = self.wrap.as_ref()?;
255 let (anchor, cursor) = self.selection?;
256 selection::extract_text(anchor, cursor, wrap, None)
257 }
258
259 /// Copy the current selection to the system clipboard (best-effort:
260 /// local clipboard tool, else an OSC 52 escape sequence). Returns `true`
261 /// if there was text to copy.
262 pub fn copy_selection(&self) -> bool {
263 match self.selected_text() {
264 Some(text) => {
265 copy_to_clipboard(&text);
266 true
267 }
268 None => false,
269 }
270 }
271
272 /// Batteries-included mouse handling for the common "drag to select, release
273 /// to copy" workflow. This is entirely opt-in — the lower-level
274 /// [`begin_selection`](Self::begin_selection) /
275 /// [`extend_selection`](Self::extend_selection) /
276 /// [`copy_selection`](Self::copy_selection) methods stay available if you
277 /// want to wire events up yourself.
278 ///
279 /// Pass the panel's inner `area`, its current `scroll` (in wrapped rows),
280 /// and a [`MouseConfig`] describing the behaviour you want. The returned
281 /// [`MouseAction`] tells you whether anything changed so you can redraw.
282 ///
283 /// Only the left button is handled. A left press inside `area` starts a
284 /// selection; a drag extends it; a release copies it (when
285 /// [`MouseConfig::copy_on_release`]).
286 ///
287 /// ```no_run
288 /// use ratatui::layout::Rect;
289 /// use ratatui::crossterm::event::MouseEvent;
290 /// use tui_panel_select::{MouseConfig, SelectablePanel};
291 ///
292 /// # fn demo(panel: &mut SelectablePanel, area: Rect, scroll: u16, ev: MouseEvent) {
293 /// let cfg = MouseConfig::default();
294 /// let _action = panel.handle_mouse(ev, area, scroll, &cfg);
295 /// # }
296 /// ```
297 pub fn handle_mouse(
298 &mut self,
299 event: MouseEvent,
300 area: Rect,
301 scroll: u16,
302 config: &MouseConfig,
303 ) -> MouseAction {
304 let point = (event.column, event.row);
305 let inside = area.contains(Position {
306 x: event.column,
307 y: event.row,
308 });
309 match event.kind {
310 MouseEventKind::Down(MouseButton::Left) => {
311 if inside {
312 self.begin_selection(area, scroll, point);
313 MouseAction::Selecting
314 } else if config.clear_on_outside_click && self.has_selection() {
315 self.clear_selection();
316 MouseAction::Cleared
317 } else {
318 MouseAction::Ignored
319 }
320 }
321 MouseEventKind::Drag(MouseButton::Left) if self.has_selection() => {
322 self.extend_selection(area, scroll, point);
323 MouseAction::Selecting
324 }
325 MouseEventKind::Up(MouseButton::Left) if self.has_selection() => {
326 if config.copy_on_release && self.copy_selection() {
327 MouseAction::Copied
328 } else {
329 MouseAction::Ignored
330 }
331 }
332 _ => MouseAction::Ignored,
333 }
334 }
335
336 /// The visible wrapped rows for a `height`-row window starting at
337 /// wrapped-row `scroll` — ready to render. Only the rows actually on
338 /// screen are wrapped, regardless of total content size.
339 pub fn visible_rows(&self, scroll: u16, height: u16) -> Vec<Line<'static>> {
340 self.wrap
341 .as_ref()
342 .map(|w| w.visible_window(scroll, height))
343 .unwrap_or_default()
344 }
345
346 /// The selection's on-screen cells to highlight, as `(row, col_from,
347 /// col_to_exclusive)` in absolute terminal coordinates, bounded to the
348 /// visible window. Empty when there's no selection or it's off-screen.
349 pub fn highlight_cells(&self, area: Rect, scroll: u16) -> Vec<(u16, u16, u16)> {
350 let Some(wrap) = self.wrap.as_ref() else {
351 return Vec::new();
352 };
353 let Some((anchor, cursor)) = self.selection else {
354 return Vec::new();
355 };
356 selection::highlight_cells(anchor, cursor, wrap, area, scroll)
357 }
358}
359
360#[cfg(test)]
361mod tests {
362 use super::*;
363
364 fn panel(text: &str, width: usize) -> SelectablePanel {
365 let mut p = SelectablePanel::new();
366 p.set_content(Arc::from(text), width);
367 p
368 }
369
370 #[test]
371 fn a_fresh_panel_has_no_content_or_selection() {
372 let p = SelectablePanel::new();
373 assert!(!p.has_content());
374 assert!(!p.has_selection());
375 assert_eq!(p.selected_text(), None);
376 assert!(p.highlight_cells(Rect::new(0, 0, 10, 5), 0).is_empty());
377 }
378
379 #[test]
380 fn begin_and_extend_select_the_covered_text() {
381 // "hello world" on one line; width wide enough not to wrap.
382 let mut p = panel("hello world", 40);
383 let area = Rect::new(2, 1, 40, 5);
384 p.begin_selection(area, 0, (2, 1)); // col 0 -> 'h'
385 p.extend_selection(area, 0, (6, 1)); // col 4 -> 'o'
386 assert!(p.has_selection());
387 assert_eq!(p.selected_text().as_deref(), Some("hello"));
388 }
389
390 #[test]
391 fn a_multi_line_drag_joins_lines_with_newlines() {
392 let mut p = panel("first\nsecond", 40);
393 let area = Rect::new(0, 0, 40, 5);
394 p.begin_selection(area, 0, (2, 0)); // 'r' in first (col 2)
395 p.extend_selection(area, 0, (2, 1)); // 'c' in second (col 2)
396 assert_eq!(p.selected_text().as_deref(), Some("rst\nsec"));
397 }
398
399 #[test]
400 fn clearing_removes_the_selection_and_its_highlight() {
401 let mut p = panel("hello", 40);
402 let area = Rect::new(0, 0, 40, 5);
403 p.begin_selection(area, 0, (0, 0));
404 p.extend_selection(area, 0, (4, 0));
405 assert!(!p.highlight_cells(area, 0).is_empty());
406 p.clear_selection();
407 assert!(!p.has_selection());
408 assert!(p.highlight_cells(area, 0).is_empty());
409 }
410
411 #[test]
412 fn whole_text_and_total_rows_reflect_the_content() {
413 let p = panel("0123456789ABCDE\n", 10); // 15-char line wraps to 2 rows
414 assert_eq!(p.whole_text(), Some("0123456789ABCDE\n"));
415 assert_eq!(p.total_rows(), 2);
416 }
417
418 #[test]
419 fn selection_survives_a_width_change_by_staying_on_the_same_characters() {
420 // Selection is stored logically, so re-wrapping at a new width keeps
421 // the same characters selected.
422 let mut p = panel("hello world", 40);
423 let area = Rect::new(0, 0, 40, 5);
424 p.begin_selection(area, 0, (0, 0));
425 p.extend_selection(area, 0, (4, 0)); // "hello"
426 assert_eq!(p.selected_text().as_deref(), Some("hello"));
427 // Same text, narrower width (forces a rewrap); selection unchanged.
428 p.set_content(Arc::from("hello world"), 5);
429 assert_eq!(p.selected_text().as_deref(), Some("hello"));
430 }
431
432 fn mouse(kind: MouseEventKind, col: u16, row: u16) -> MouseEvent {
433 use ratatui::crossterm::event::KeyModifiers;
434 MouseEvent {
435 kind,
436 column: col,
437 row,
438 modifiers: KeyModifiers::NONE,
439 }
440 }
441
442 #[test]
443 fn handle_mouse_drags_a_selection_and_copies_on_release() {
444 let mut p = panel("hello world", 40);
445 let area = Rect::new(2, 1, 40, 5);
446 let cfg = MouseConfig::default();
447 let down = MouseEventKind::Down(MouseButton::Left);
448 let drag = MouseEventKind::Drag(MouseButton::Left);
449 let up = MouseEventKind::Up(MouseButton::Left);
450
451 assert_eq!(
452 p.handle_mouse(mouse(down, 2, 1), area, 0, &cfg),
453 MouseAction::Selecting
454 );
455 assert_eq!(
456 p.handle_mouse(mouse(drag, 6, 1), area, 0, &cfg),
457 MouseAction::Selecting
458 );
459 assert_eq!(p.selected_text().as_deref(), Some("hello"));
460 assert_eq!(
461 p.handle_mouse(mouse(up, 6, 1), area, 0, &cfg),
462 MouseAction::Copied
463 );
464 }
465
466 #[test]
467 fn handle_mouse_respects_copy_on_release_false() {
468 let mut p = panel("hello world", 40);
469 let area = Rect::new(0, 0, 40, 5);
470 let cfg = MouseConfig {
471 copy_on_release: false,
472 ..MouseConfig::default()
473 };
474 p.handle_mouse(
475 mouse(MouseEventKind::Down(MouseButton::Left), 0, 0),
476 area,
477 0,
478 &cfg,
479 );
480 p.handle_mouse(
481 mouse(MouseEventKind::Drag(MouseButton::Left), 4, 0),
482 area,
483 0,
484 &cfg,
485 );
486 assert_eq!(
487 p.handle_mouse(
488 mouse(MouseEventKind::Up(MouseButton::Left), 4, 0),
489 area,
490 0,
491 &cfg
492 ),
493 MouseAction::Ignored
494 );
495 // Selection is still present so the host can copy on its own terms.
496 assert_eq!(p.selected_text().as_deref(), Some("hello"));
497 }
498
499 #[test]
500 fn handle_mouse_clears_selection_on_outside_click() {
501 let mut p = panel("hello world", 40);
502 let area = Rect::new(2, 1, 10, 3);
503 let cfg = MouseConfig::default();
504 p.handle_mouse(
505 mouse(MouseEventKind::Down(MouseButton::Left), 2, 1),
506 area,
507 0,
508 &cfg,
509 );
510 p.handle_mouse(
511 mouse(MouseEventKind::Drag(MouseButton::Left), 6, 1),
512 area,
513 0,
514 &cfg,
515 );
516 assert!(p.has_selection());
517 // A press well outside the panel area clears it.
518 assert_eq!(
519 p.handle_mouse(
520 mouse(MouseEventKind::Down(MouseButton::Left), 30, 20),
521 area,
522 0,
523 &cfg
524 ),
525 MouseAction::Cleared
526 );
527 assert!(!p.has_selection());
528 }
529
530 #[test]
531 fn handle_mouse_ignores_other_buttons() {
532 let mut p = panel("hello", 40);
533 let area = Rect::new(0, 0, 40, 5);
534 let cfg = MouseConfig::default();
535 assert_eq!(
536 p.handle_mouse(
537 mouse(MouseEventKind::Down(MouseButton::Right), 0, 0),
538 area,
539 0,
540 &cfg
541 ),
542 MouseAction::Ignored
543 );
544 assert!(!p.has_selection());
545 }
546
547 #[test]
548 fn clip_mode_keeps_one_row_per_line_and_selects_visible_columns() {
549 let mut p = SelectablePanel::new();
550 p.set_wrap_mode(WrapMode::Clip);
551 assert_eq!(p.wrap_mode(), WrapMode::Clip);
552 // Two lines; the first is wider than the width but stays one row.
553 p.set_content(Arc::from("hello world foo\nsecond line"), 10);
554 assert_eq!(p.total_rows(), 2, "one row per raw line in clip mode");
555
556 let area = Rect::new(2, 1, 10, 5);
557 // Select "hello" on the first (clipped) row.
558 p.begin_selection(area, 0, (2, 1)); // col 0 -> 'h'
559 p.extend_selection(area, 0, (6, 1)); // col 4 -> 'o'
560 assert_eq!(p.selected_text().as_deref(), Some("hello"));
561 let cells = p.highlight_cells(area, 0);
562 assert_eq!(cells, vec![(1, 2, 7)], "a single clipped highlight row");
563
564 // A drag onto the row below lands on line 1 (rows map 1:1 to lines).
565 p.extend_selection(area, 0, (4, 2)); // row below -> line 1, col 2
566 assert_eq!(p.selected_text().as_deref(), Some("hello world foo\nsec"));
567 }
568}