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};
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    /// `(anchor, cursor)` in logical positions. The anchor is where the
112    /// selection started (mouse-down); the cursor is its live end (drag).
113    selection: Option<(TextPos, TextPos)>,
114}
115
116impl SelectablePanel {
117    /// A panel with no content and no selection.
118    pub fn new() -> Self {
119        Self::default()
120    }
121
122    /// Set (or update) the panel's text and the inner width it wraps to, in
123    /// columns. A no-op when neither the text (by `Arc` identity) nor the
124    /// width changed, so it's safe — and intended — to call every frame.
125    ///
126    /// Pass a fresh `Arc<str>` whenever the underlying text changes; identity
127    /// (not byte comparison) is what signals "content changed".
128    pub fn set_content(&mut self, text: Arc<str>, width: usize) {
129        PanelWrap::rebuild_if_needed(&mut self.wrap, &text, width);
130    }
131
132    /// Whether any content has been set yet.
133    pub fn has_content(&self) -> bool {
134        self.wrap.is_some()
135    }
136
137    /// The total number of wrapped rows the current content occupies — the
138    /// scrollable extent, for sizing a scrollbar or clamping a scroll offset.
139    pub fn total_rows(&self) -> u32 {
140        self.wrap.as_ref().map_or(0, PanelWrap::total_rows)
141    }
142
143    /// The exact, unmodified text the panel was built from (every line, not
144    /// just what's scrolled into view) — for a "copy the whole panel"
145    /// action that needs no selection.
146    pub fn whole_text(&self) -> Option<&str> {
147        self.wrap.as_ref().map(PanelWrap::source)
148    }
149
150    /// Start a selection at terminal point `(col, row)`, given the panel's
151    /// inner `area` and current `scroll` (in wrapped rows). Points outside
152    /// `area` clamp to its nearest edge. No-op if there's no content.
153    pub fn begin_selection(&mut self, area: Rect, scroll: u16, point: (u16, u16)) {
154        let Some(wrap) = self.wrap.as_ref() else {
155            return;
156        };
157        let pos = selection::point_to_textpos(point, area, scroll, wrap);
158        self.selection = Some((pos, pos));
159    }
160
161    /// Extend the in-progress selection's live end to terminal point
162    /// `(col, row)`. No-op if no selection was started or there's no content.
163    pub fn extend_selection(&mut self, area: Rect, scroll: u16, point: (u16, u16)) {
164        let Some(wrap) = self.wrap.as_ref() else {
165            return;
166        };
167        if let Some((_, cursor)) = self.selection.as_mut() {
168            *cursor = selection::point_to_textpos(point, area, scroll, wrap);
169        }
170    }
171
172    /// Drop the current selection.
173    pub fn clear_selection(&mut self) {
174        self.selection = None;
175    }
176
177    /// Whether there is a selection (even a zero-width one from a bare click).
178    pub fn has_selection(&self) -> bool {
179        self.selection.is_some()
180    }
181
182    /// The currently selected text (lines joined with `\n`), or `None` when
183    /// there's no selection or it covers nothing but whitespace.
184    pub fn selected_text(&self) -> Option<String> {
185        let wrap = self.wrap.as_ref()?;
186        let (anchor, cursor) = self.selection?;
187        selection::extract_text(anchor, cursor, wrap, None)
188    }
189
190    /// Copy the current selection to the system clipboard (best-effort:
191    /// local clipboard tool, else an OSC 52 escape sequence). Returns `true`
192    /// if there was text to copy.
193    pub fn copy_selection(&self) -> bool {
194        match self.selected_text() {
195            Some(text) => {
196                copy_to_clipboard(&text);
197                true
198            }
199            None => false,
200        }
201    }
202
203    /// Batteries-included mouse handling for the common "drag to select, release
204    /// to copy" workflow. This is entirely opt-in — the lower-level
205    /// [`begin_selection`](Self::begin_selection) /
206    /// [`extend_selection`](Self::extend_selection) /
207    /// [`copy_selection`](Self::copy_selection) methods stay available if you
208    /// want to wire events up yourself.
209    ///
210    /// Pass the panel's inner `area`, its current `scroll` (in wrapped rows),
211    /// and a [`MouseConfig`] describing the behaviour you want. The returned
212    /// [`MouseAction`] tells you whether anything changed so you can redraw.
213    ///
214    /// Only the left button is handled. A left press inside `area` starts a
215    /// selection; a drag extends it; a release copies it (when
216    /// [`MouseConfig::copy_on_release`]).
217    ///
218    /// ```no_run
219    /// use ratatui::layout::Rect;
220    /// use ratatui::crossterm::event::MouseEvent;
221    /// use tui_panel_select::{MouseConfig, SelectablePanel};
222    ///
223    /// # fn demo(panel: &mut SelectablePanel, area: Rect, scroll: u16, ev: MouseEvent) {
224    /// let cfg = MouseConfig::default();
225    /// let _action = panel.handle_mouse(ev, area, scroll, &cfg);
226    /// # }
227    /// ```
228    pub fn handle_mouse(
229        &mut self,
230        event: MouseEvent,
231        area: Rect,
232        scroll: u16,
233        config: &MouseConfig,
234    ) -> MouseAction {
235        let point = (event.column, event.row);
236        let inside = area.contains(Position {
237            x: event.column,
238            y: event.row,
239        });
240        match event.kind {
241            MouseEventKind::Down(MouseButton::Left) => {
242                if inside {
243                    self.begin_selection(area, scroll, point);
244                    MouseAction::Selecting
245                } else if config.clear_on_outside_click && self.has_selection() {
246                    self.clear_selection();
247                    MouseAction::Cleared
248                } else {
249                    MouseAction::Ignored
250                }
251            }
252            MouseEventKind::Drag(MouseButton::Left) if self.has_selection() => {
253                self.extend_selection(area, scroll, point);
254                MouseAction::Selecting
255            }
256            MouseEventKind::Up(MouseButton::Left) if self.has_selection() => {
257                if config.copy_on_release && self.copy_selection() {
258                    MouseAction::Copied
259                } else {
260                    MouseAction::Ignored
261                }
262            }
263            _ => MouseAction::Ignored,
264        }
265    }
266
267    /// The visible wrapped rows for a `height`-row window starting at
268    /// wrapped-row `scroll` — ready to render. Only the rows actually on
269    /// screen are wrapped, regardless of total content size.
270    pub fn visible_rows(&self, scroll: u16, height: u16) -> Vec<Line<'static>> {
271        self.wrap
272            .as_ref()
273            .map(|w| w.visible_window(scroll, height))
274            .unwrap_or_default()
275    }
276
277    /// The selection's on-screen cells to highlight, as `(row, col_from,
278    /// col_to_exclusive)` in absolute terminal coordinates, bounded to the
279    /// visible window. Empty when there's no selection or it's off-screen.
280    pub fn highlight_cells(&self, area: Rect, scroll: u16) -> Vec<(u16, u16, u16)> {
281        let Some(wrap) = self.wrap.as_ref() else {
282            return Vec::new();
283        };
284        let Some((anchor, cursor)) = self.selection else {
285            return Vec::new();
286        };
287        selection::highlight_cells(anchor, cursor, wrap, area, scroll)
288    }
289}
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294
295    fn panel(text: &str, width: usize) -> SelectablePanel {
296        let mut p = SelectablePanel::new();
297        p.set_content(Arc::from(text), width);
298        p
299    }
300
301    #[test]
302    fn a_fresh_panel_has_no_content_or_selection() {
303        let p = SelectablePanel::new();
304        assert!(!p.has_content());
305        assert!(!p.has_selection());
306        assert_eq!(p.selected_text(), None);
307        assert!(p.highlight_cells(Rect::new(0, 0, 10, 5), 0).is_empty());
308    }
309
310    #[test]
311    fn begin_and_extend_select_the_covered_text() {
312        // "hello world" on one line; width wide enough not to wrap.
313        let mut p = panel("hello world", 40);
314        let area = Rect::new(2, 1, 40, 5);
315        p.begin_selection(area, 0, (2, 1)); // col 0 -> 'h'
316        p.extend_selection(area, 0, (6, 1)); // col 4 -> 'o'
317        assert!(p.has_selection());
318        assert_eq!(p.selected_text().as_deref(), Some("hello"));
319    }
320
321    #[test]
322    fn a_multi_line_drag_joins_lines_with_newlines() {
323        let mut p = panel("first\nsecond", 40);
324        let area = Rect::new(0, 0, 40, 5);
325        p.begin_selection(area, 0, (2, 0)); // 'r' in first (col 2)
326        p.extend_selection(area, 0, (2, 1)); // 'c' in second (col 2)
327        assert_eq!(p.selected_text().as_deref(), Some("rst\nsec"));
328    }
329
330    #[test]
331    fn clearing_removes_the_selection_and_its_highlight() {
332        let mut p = panel("hello", 40);
333        let area = Rect::new(0, 0, 40, 5);
334        p.begin_selection(area, 0, (0, 0));
335        p.extend_selection(area, 0, (4, 0));
336        assert!(!p.highlight_cells(area, 0).is_empty());
337        p.clear_selection();
338        assert!(!p.has_selection());
339        assert!(p.highlight_cells(area, 0).is_empty());
340    }
341
342    #[test]
343    fn whole_text_and_total_rows_reflect_the_content() {
344        let p = panel("0123456789ABCDE\n", 10); // 15-char line wraps to 2 rows
345        assert_eq!(p.whole_text(), Some("0123456789ABCDE\n"));
346        assert_eq!(p.total_rows(), 2);
347    }
348
349    #[test]
350    fn selection_survives_a_width_change_by_staying_on_the_same_characters() {
351        // Selection is stored logically, so re-wrapping at a new width keeps
352        // the same characters selected.
353        let mut p = panel("hello world", 40);
354        let area = Rect::new(0, 0, 40, 5);
355        p.begin_selection(area, 0, (0, 0));
356        p.extend_selection(area, 0, (4, 0)); // "hello"
357        assert_eq!(p.selected_text().as_deref(), Some("hello"));
358        // Same text, narrower width (forces a rewrap); selection unchanged.
359        p.set_content(Arc::from("hello world"), 5);
360        assert_eq!(p.selected_text().as_deref(), Some("hello"));
361    }
362
363    fn mouse(kind: MouseEventKind, col: u16, row: u16) -> MouseEvent {
364        use ratatui::crossterm::event::KeyModifiers;
365        MouseEvent {
366            kind,
367            column: col,
368            row,
369            modifiers: KeyModifiers::NONE,
370        }
371    }
372
373    #[test]
374    fn handle_mouse_drags_a_selection_and_copies_on_release() {
375        let mut p = panel("hello world", 40);
376        let area = Rect::new(2, 1, 40, 5);
377        let cfg = MouseConfig::default();
378        let down = MouseEventKind::Down(MouseButton::Left);
379        let drag = MouseEventKind::Drag(MouseButton::Left);
380        let up = MouseEventKind::Up(MouseButton::Left);
381
382        assert_eq!(
383            p.handle_mouse(mouse(down, 2, 1), area, 0, &cfg),
384            MouseAction::Selecting
385        );
386        assert_eq!(
387            p.handle_mouse(mouse(drag, 6, 1), area, 0, &cfg),
388            MouseAction::Selecting
389        );
390        assert_eq!(p.selected_text().as_deref(), Some("hello"));
391        assert_eq!(
392            p.handle_mouse(mouse(up, 6, 1), area, 0, &cfg),
393            MouseAction::Copied
394        );
395    }
396
397    #[test]
398    fn handle_mouse_respects_copy_on_release_false() {
399        let mut p = panel("hello world", 40);
400        let area = Rect::new(0, 0, 40, 5);
401        let cfg = MouseConfig {
402            copy_on_release: false,
403            ..MouseConfig::default()
404        };
405        p.handle_mouse(
406            mouse(MouseEventKind::Down(MouseButton::Left), 0, 0),
407            area,
408            0,
409            &cfg,
410        );
411        p.handle_mouse(
412            mouse(MouseEventKind::Drag(MouseButton::Left), 4, 0),
413            area,
414            0,
415            &cfg,
416        );
417        assert_eq!(
418            p.handle_mouse(
419                mouse(MouseEventKind::Up(MouseButton::Left), 4, 0),
420                area,
421                0,
422                &cfg
423            ),
424            MouseAction::Ignored
425        );
426        // Selection is still present so the host can copy on its own terms.
427        assert_eq!(p.selected_text().as_deref(), Some("hello"));
428    }
429
430    #[test]
431    fn handle_mouse_clears_selection_on_outside_click() {
432        let mut p = panel("hello world", 40);
433        let area = Rect::new(2, 1, 10, 3);
434        let cfg = MouseConfig::default();
435        p.handle_mouse(
436            mouse(MouseEventKind::Down(MouseButton::Left), 2, 1),
437            area,
438            0,
439            &cfg,
440        );
441        p.handle_mouse(
442            mouse(MouseEventKind::Drag(MouseButton::Left), 6, 1),
443            area,
444            0,
445            &cfg,
446        );
447        assert!(p.has_selection());
448        // A press well outside the panel area clears it.
449        assert_eq!(
450            p.handle_mouse(
451                mouse(MouseEventKind::Down(MouseButton::Left), 30, 20),
452                area,
453                0,
454                &cfg
455            ),
456            MouseAction::Cleared
457        );
458        assert!(!p.has_selection());
459    }
460
461    #[test]
462    fn handle_mouse_ignores_other_buttons() {
463        let mut p = panel("hello", 40);
464        let area = Rect::new(0, 0, 40, 5);
465        let cfg = MouseConfig::default();
466        assert_eq!(
467            p.handle_mouse(
468                mouse(MouseEventKind::Down(MouseButton::Right), 0, 0),
469                area,
470                0,
471                &cfg
472            ),
473            MouseAction::Ignored
474        );
475        assert!(!p.has_selection());
476    }
477}