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