Skip to main content

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}