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