tui_panel_select/multiselect.rs
1//! A batteries-included, multi-region text panel: [`MultiSelectPanel`].
2//!
3//! Where [`crate::panel::SelectablePanel`] covers the common single-selection,
4//! mouse-only case, `MultiSelectPanel` bundles the same primitives into a panel
5//! that additionally supports:
6//!
7//! - **Multiple simultaneous selection regions** — one *active* (live,
8//! draggable, keyboard-extendable) region plus any number of *finalized*
9//! ones, so an app can build up several highlighted runs (e.g. with an
10//! Alt-click gesture) and copy them together.
11//! - **Keyboard extension** — grow the active region a character or a line at a
12//! time ([`extend`](MultiSelectPanel::extend)), scrolling it into view.
13//! - **Owned scrolling with drag auto-scroll** — the panel owns its scroll
14//! offset, so a drag held past the top/bottom edge keeps scrolling and
15//! extending the selection ([`autoscroll_tick`](MultiSelectPanel::autoscroll_tick)).
16//! - **Styled content** — set syntax-highlighted [`Line`]s directly
17//! ([`set_styled_content`](MultiSelectPanel::set_styled_content)), not just
18//! plain (or ANSI) text.
19//!
20//! Cross-*panel* concerns (ordering a copy that spans two different panels,
21//! excluding app-specific annotation glyphs from copied text, drawing a
22//! scrollbar) stay with the host: the panel exposes
23//! [`selected_parts`](MultiSelectPanel::selected_parts) and
24//! [`highlight_regions`](MultiSelectPanel::highlight_regions) so the host can
25//! compose several panels however it likes.
26
27use std::collections::HashSet;
28use std::sync::Arc;
29
30use ratatui::layout::Rect;
31use ratatui::text::Line;
32
33use crate::selection;
34use crate::wrapcache::{PanelWrap, TextPos, WrapMarker, WrapMode};
35
36/// A keyboard motion for [`MultiSelectPanel::extend`] — which way to move the
37/// active region's live end.
38#[derive(Clone, Copy, Debug, PartialEq, Eq)]
39pub enum Motion {
40 /// One character left (crossing into the previous line at column 0).
41 Left,
42 /// One character right (crossing into the next line at its start).
43 Right,
44 /// One logical line up, keeping the column where possible.
45 Up,
46 /// One logical line down, keeping the column where possible.
47 Down,
48}
49
50/// A vertical auto-scroll direction for
51/// [`MultiSelectPanel::start_autoscroll`], used when a selection drag is held
52/// past the panel's top or bottom edge.
53#[derive(Clone, Copy, Debug, PartialEq, Eq)]
54pub enum AutoScroll {
55 /// Scroll up (drag held above the panel).
56 Up,
57 /// Scroll down (drag held below the panel).
58 Down,
59}
60
61/// One selection region: `anchor` is where it began, `cursor` its live end.
62/// Stored as logical [`TextPos`] so it survives rewraps/resizes/scrolling.
63#[derive(Clone, Copy, Debug, PartialEq, Eq)]
64struct Region {
65 anchor: TextPos,
66 cursor: TextPos,
67}
68
69impl Region {
70 /// The region's earliest position (its logical start), for ordering
71 /// several regions by where they begin rather than by draw order.
72 fn start(&self) -> TextPos {
73 self.anchor.min(self.cursor)
74 }
75}
76
77/// One scrollable text panel that owns its scroll offset and one-or-more
78/// selection regions.
79///
80/// Cheap to keep across frames: the content setters only rebuild the wrap
81/// cache when the content or width actually changed, and selections are logical
82/// positions that are unaffected by rewrapping. See the [module
83/// docs](self) for the feature set.
84#[derive(Default)]
85pub struct MultiSelectPanel {
86 wrap: Option<PanelWrap>,
87 /// How raw lines wider than the panel are laid out (wrap vs clip).
88 mode: WrapMode,
89 /// Optional end-of-row wrap marker (see [`WrapMarker`]).
90 marker: Option<WrapMarker>,
91 /// Current scroll offset, in wrapped rows.
92 scroll: u16,
93 /// The live region being dragged / keyboard-extended, if any.
94 active: Option<Region>,
95 /// Additional, already-finalized regions (e.g. Alt-click gestures).
96 extras: Vec<Region>,
97 /// While a drag is held past the top (`-1`) or bottom (`+1`) edge, the
98 /// direction to keep auto-scrolling; `None` when the drag is inside.
99 pending_autoscroll: Option<i8>,
100}
101
102impl MultiSelectPanel {
103 /// A panel with no content, no selection and scroll at the top.
104 pub fn new() -> Self {
105 Self::default()
106 }
107
108 // --- Configuration ----------------------------------------------------
109
110 /// Choose how raw lines wider than the panel are laid out. Takes effect on
111 /// the next content set (which apps do every frame).
112 pub fn set_wrap_mode(&mut self, mode: WrapMode) {
113 self.mode = mode;
114 }
115
116 /// This panel's current [`WrapMode`].
117 pub fn wrap_mode(&self) -> WrapMode {
118 self.mode
119 }
120
121 /// Enable/disable the end-of-row wrap marker (see [`WrapMarker`]). Takes
122 /// effect on the next content set.
123 pub fn set_wrap_marker(&mut self, marker: Option<WrapMarker>) {
124 self.marker = marker;
125 }
126
127 /// This panel's current end-of-row wrap marker, if any.
128 pub fn wrap_marker(&self) -> Option<WrapMarker> {
129 self.marker
130 }
131
132 // --- Content ----------------------------------------------------------
133
134 /// Set (or update) the panel's plain text and inner wrap width. A no-op
135 /// when neither the text (by `Arc` identity) nor the width nor the mode /
136 /// marker changed — safe, and intended, to call every frame.
137 pub fn set_content(&mut self, text: Arc<str>, width: usize) {
138 PanelWrap::rebuild_if_needed_marker(&mut self.wrap, &text, width, self.mode, self.marker);
139 }
140
141 /// Set the panel's text from a string that may contain ANSI escape
142 /// sequences: rendered rows keep their colour, while selection/copy/geometry
143 /// operate on the plain, stripped text. Requires the `ansi` feature.
144 #[cfg(feature = "ansi")]
145 pub fn set_ansi_content(&mut self, text: Arc<str>, width: usize) {
146 PanelWrap::rebuild_if_needed_ansi_marker(
147 &mut self.wrap,
148 &text,
149 width,
150 self.mode,
151 self.marker,
152 );
153 }
154
155 /// Set the panel's content from pre-styled [`Line`]s (e.g.
156 /// syntax-highlighted source). Rendered rows keep their per-span styling
157 /// while selection/copy/geometry operate on the plain text. Styled content
158 /// has no stable identity to diff against, so this rebuilds every call —
159 /// call it only when the content actually changed.
160 pub fn set_styled_content(&mut self, lines: &[Line<'_>], width: usize) {
161 self.wrap = Some(PanelWrap::build_styled_with_marker(
162 lines,
163 width,
164 self.mode,
165 self.marker,
166 ));
167 }
168
169 /// Whether any content has been set yet.
170 pub fn has_content(&self) -> bool {
171 self.wrap.is_some()
172 }
173
174 /// The exact, unmodified source text the panel was built from — for a
175 /// "copy the whole panel" action that needs no selection. `None` before any
176 /// content is set.
177 pub fn whole_text(&self) -> Option<&str> {
178 self.wrap.as_ref().map(PanelWrap::source)
179 }
180
181 // --- Scrolling --------------------------------------------------------
182
183 /// The total number of wrapped rows the current content occupies.
184 pub fn total_rows(&self) -> u32 {
185 self.wrap.as_ref().map_or(0, PanelWrap::total_rows)
186 }
187
188 /// The largest in-bounds scroll offset for a `viewport_height`-row window
189 /// (content rows − height, floored at 0) — for sizing a scrollbar or
190 /// clamping a scroll.
191 pub fn max_scroll(&self, viewport_height: u16) -> u16 {
192 let total = self.total_rows().min(u16::MAX as u32) as u16;
193 total.saturating_sub(viewport_height)
194 }
195
196 /// This panel's current scroll offset, in wrapped rows.
197 pub fn scroll(&self) -> u16 {
198 self.scroll
199 }
200
201 /// Set the scroll offset directly (e.g. from a scrollbar drag). Not
202 /// clamped here — call [`clamp_scroll`](Self::clamp_scroll) once the
203 /// viewport height is known (at draw time).
204 pub fn set_scroll(&mut self, scroll: u16) {
205 self.scroll = scroll;
206 }
207
208 /// Move the scroll offset by `delta` rows (negative = up), clamped to
209 /// `[0, max_scroll(viewport_height)]`.
210 pub fn scroll_by(&mut self, delta: i32, viewport_height: u16) {
211 let max = self.max_scroll(viewport_height) as i32;
212 let next = (self.scroll as i32 + delta).clamp(0, max);
213 self.scroll = next as u16;
214 }
215
216 /// Clamp the scroll offset into range for a `viewport_height`-row window
217 /// and return that window's `max_scroll` (for the scrollbar). Call once per
218 /// frame at draw time, after setting content.
219 pub fn clamp_scroll(&mut self, viewport_height: u16) -> u16 {
220 let max = self.max_scroll(viewport_height);
221 self.scroll = self.scroll.min(max);
222 max
223 }
224
225 // --- Rendering --------------------------------------------------------
226
227 /// The visible wrapped rows for a `height`-row window at the current
228 /// scroll — ready to render. Only on-screen rows are wrapped, regardless of
229 /// total content size.
230 pub fn visible_rows(&self, height: u16) -> Vec<Line<'static>> {
231 self.wrap
232 .as_ref()
233 .map(|w| w.visible_window(self.scroll, height))
234 .unwrap_or_default()
235 }
236
237 /// Every selection region's on-screen cells to highlight, as `(row,
238 /// col_from, col_to_exclusive)` in absolute terminal coordinates, bounded
239 /// to the visible window. Covers the active region and all finalized ones.
240 pub fn highlight_regions(&self, area: Rect) -> Vec<(u16, u16, u16)> {
241 let Some(wrap) = self.wrap.as_ref() else {
242 return Vec::new();
243 };
244 let mut cells = Vec::new();
245 for region in self.regions() {
246 cells.extend(selection::highlight_cells(
247 region.anchor,
248 region.cursor,
249 wrap,
250 area,
251 self.scroll,
252 ));
253 }
254 cells
255 }
256
257 // --- Selection: mouse -------------------------------------------------
258
259 /// Begin a new *active* selection region at terminal `point`, given the
260 /// panel's inner `area`. Leaves any finalized ([`finalize_active`]) regions
261 /// intact — call [`clear`](Self::clear) first for a fresh, single-region
262 /// selection. No-op without content.
263 ///
264 /// [`finalize_active`]: Self::finalize_active
265 pub fn begin(&mut self, area: Rect, point: (u16, u16)) {
266 self.pending_autoscroll = None;
267 let Some(wrap) = self.wrap.as_ref() else {
268 self.active = None;
269 return;
270 };
271 let pos = selection::point_to_textpos(point, area, self.scroll, wrap);
272 self.active = Some(Region {
273 anchor: pos,
274 cursor: pos,
275 });
276 }
277
278 /// Continue the active selection's drag to terminal `point`. When the drag
279 /// moves past the panel's top/bottom edge this begins auto-scrolling in
280 /// that direction (extending the selection a whole line at a time) and
281 /// advances it once immediately; call
282 /// [`autoscroll_tick`](Self::autoscroll_tick) from an idle loop to keep it
283 /// going while the mouse is still. No-op without an active region.
284 pub fn drag(&mut self, area: Rect, point: (u16, u16)) {
285 if self.active.is_none() {
286 return;
287 }
288 let (_, row) = point;
289 if area.height > 0 && row < area.y {
290 self.pending_autoscroll = Some(-1);
291 self.autoscroll_tick(area);
292 return;
293 }
294 if area.height > 0 && row >= area.y.saturating_add(area.height) {
295 self.pending_autoscroll = Some(1);
296 self.autoscroll_tick(area);
297 return;
298 }
299 self.pending_autoscroll = None;
300 let Some(wrap) = self.wrap.as_ref() else {
301 return;
302 };
303 let pos = selection::point_to_textpos(point, area, self.scroll, wrap);
304 if let Some(region) = self.active.as_mut() {
305 region.cursor = pos;
306 }
307 }
308
309 /// End the current drag (a mouse-up): stops any pending auto-scroll. The
310 /// selection itself is kept — read it with
311 /// [`selected_parts`](Self::selected_parts) / copy it as the host sees fit.
312 pub fn end_drag(&mut self) {
313 self.pending_autoscroll = None;
314 }
315
316 /// Whether a drag is currently held past an edge, waiting for
317 /// [`autoscroll_tick`](Self::autoscroll_tick) to keep scrolling.
318 pub fn has_pending_autoscroll(&self) -> bool {
319 self.pending_autoscroll.is_some()
320 }
321
322 /// One "tick" of auto-scrolling a drag held past the panel's vertical
323 /// bounds: scroll one row in the pending direction and extend the active
324 /// region's live end to the newly revealed edge line. Once the content's
325 /// own top/bottom is reached but the drag is still held past the edge, the
326 /// cursor snaps to the very first/last line's full extent instead, so that
327 /// boundary line ends up entirely highlighted. No-op when nothing is
328 /// pending. `area` is the panel's inner rectangle.
329 pub fn autoscroll_tick(&mut self, area: Rect) {
330 let Some(dir) = self.pending_autoscroll else {
331 return;
332 };
333 if self.active.is_none() {
334 self.pending_autoscroll = None;
335 return;
336 }
337 let max_scroll = self.max_scroll(area.height);
338 let new_scroll = if dir < 0 {
339 self.scroll.saturating_sub(1)
340 } else {
341 (self.scroll + 1).min(max_scroll)
342 };
343 let reached_bound = new_scroll == self.scroll;
344 self.scroll = new_scroll;
345 let Some(wrap) = self.wrap.as_ref() else {
346 return;
347 };
348 let edge_row = if reached_bound {
349 if dir < 0 {
350 0
351 } else {
352 wrap.total_rows().saturating_sub(1)
353 }
354 } else if dir < 0 {
355 new_scroll as u32
356 } else {
357 (new_scroll as u32 + area.height as u32).saturating_sub(1)
358 };
359 let col = if dir < 0 { 0 } else { usize::MAX };
360 let pos = wrap.row_col_to_textpos(edge_row, col);
361 if let Some(region) = self.active.as_mut() {
362 region.cursor = pos;
363 }
364 }
365
366 // --- Selection: keyboard ---------------------------------------------
367
368 /// Move the active region's live end by one character ([`Motion::Left`] /
369 /// [`Motion::Right`], crossing line boundaries) or one logical line
370 /// ([`Motion::Up`] / [`Motion::Down`], keeping the column where possible),
371 /// then scroll the panel so that end stays visible. No-op without an active
372 /// region or content. `area` is the panel's inner rectangle.
373 pub fn extend(&mut self, motion: Motion, area: Rect) {
374 let Some(region) = self.active else {
375 return;
376 };
377 let Some(wrap) = self.wrap.as_ref() else {
378 return;
379 };
380 let mut pos = region.cursor;
381 match motion {
382 Motion::Left => {
383 if pos.col > 0 {
384 pos.col -= 1;
385 } else if pos.line > 0 {
386 pos.line -= 1;
387 pos.col = wrap.line_char_len(pos.line).saturating_sub(1);
388 }
389 }
390 Motion::Right => {
391 let len = wrap.line_char_len(pos.line);
392 if pos.col + 1 < len {
393 pos.col += 1;
394 } else if pos.line + 1 < wrap.line_count() {
395 pos.line += 1;
396 pos.col = 0;
397 }
398 }
399 Motion::Up => {
400 if pos.line > 0 {
401 pos.line -= 1;
402 pos.col = pos.col.min(wrap.line_char_len(pos.line).saturating_sub(1));
403 }
404 }
405 Motion::Down => {
406 if pos.line + 1 < wrap.line_count() {
407 pos.line += 1;
408 pos.col = pos.col.min(wrap.line_char_len(pos.line).saturating_sub(1));
409 }
410 }
411 }
412 if let Some(region) = self.active.as_mut() {
413 region.cursor = pos;
414 }
415 self.scroll_cursor_into_view(area);
416 }
417
418 /// After moving the active region's live end, nudge the scroll so that end
419 /// stays visible, like a text editor never letting its cursor scroll off
420 /// screen.
421 fn scroll_cursor_into_view(&mut self, area: Rect) {
422 if area.height == 0 {
423 return;
424 }
425 let Some(region) = self.active else {
426 return;
427 };
428 let Some(wrap) = self.wrap.as_ref() else {
429 return;
430 };
431 let (row, _) = wrap.textpos_to_row_col(region.cursor);
432 let max_scroll = self.max_scroll(area.height);
433 if row < self.scroll as u32 {
434 self.scroll = row as u16;
435 } else if row >= self.scroll as u32 + area.height as u32 {
436 self.scroll = (row + 1).saturating_sub(area.height as u32) as u16;
437 }
438 self.scroll = self.scroll.min(max_scroll);
439 }
440
441 // --- Selection: regions & text ---------------------------------------
442
443 /// Finalize the active region: move it into the set of kept regions and
444 /// clear the live one, so a subsequent [`begin`](Self::begin) starts a new
445 /// region alongside it. No-op when there's no active region.
446 pub fn finalize_active(&mut self) {
447 if let Some(region) = self.active.take() {
448 self.extras.push(region);
449 }
450 self.pending_autoscroll = None;
451 }
452
453 /// Drop every selection region (active and finalized) and stop any pending
454 /// auto-scroll. Call whenever the underlying content is about to change so
455 /// a highlight never lingers over stale text.
456 pub fn clear(&mut self) {
457 self.active = None;
458 self.extras.clear();
459 self.pending_autoscroll = None;
460 }
461
462 /// Whether there is any selection region at all (active or finalized).
463 pub fn has_selection(&self) -> bool {
464 self.active.is_some() || !self.extras.is_empty()
465 }
466
467 /// The panel's wrap cache, if content has been set. Exposed so a host can
468 /// run its own geometry queries (row/column ↔ [`TextPos`] mapping, line
469 /// text, hit-testing) against the exact layout the panel is rendering.
470 pub fn wrap(&self) -> Option<&PanelWrap> {
471 self.wrap.as_ref()
472 }
473
474 /// The live (active) region as `(anchor, cursor)` logical positions, or
475 /// `None` when nothing is being dragged / keyboard-extended. `anchor` is
476 /// where the region began; `cursor` is its live end.
477 pub fn active_selection(&self) -> Option<(TextPos, TextPos)> {
478 self.active.map(|r| (r.anchor, r.cursor))
479 }
480
481 /// Replace the live (active) region with one spanning `anchor`..`cursor`,
482 /// without touching any finalized regions. Lets a host restore or script a
483 /// selection (the positions are logical, so they survive rewraps).
484 pub fn set_active_selection(&mut self, anchor: TextPos, cursor: TextPos) {
485 self.active = Some(Region { anchor, cursor });
486 }
487
488 /// The finalized regions (those moved aside by
489 /// [`finalize_active`](Self::finalize_active)) as `(anchor, cursor)` pairs,
490 /// in insertion order.
491 pub fn finalized_selections(&self) -> Vec<(TextPos, TextPos)> {
492 self.extras.iter().map(|r| (r.anchor, r.cursor)).collect()
493 }
494
495 /// Append a finalized region spanning `anchor`..`cursor`, as though it had
496 /// been dragged and then [`finalize_active`](Self::finalize_active)d. Lets
497 /// a host restore several kept regions.
498 pub fn push_finalized(&mut self, anchor: TextPos, cursor: TextPos) {
499 self.extras.push(Region { anchor, cursor });
500 }
501
502 /// Begin auto-scrolling in `dir` on the next
503 /// [`autoscroll_tick`](Self::autoscroll_tick), as though a drag were being
504 /// held past that edge. Mainly for hosts/tests that drive auto-scroll
505 /// without simulating exact drag geometry.
506 pub fn start_autoscroll(&mut self, dir: AutoScroll) {
507 self.pending_autoscroll = Some(match dir {
508 AutoScroll::Up => -1,
509 AutoScroll::Down => 1,
510 });
511 }
512
513 /// All regions (finalized then active), for iterating in insertion order.
514 fn regions(&self) -> impl Iterator<Item = &Region> {
515 self.extras.iter().chain(self.active.iter())
516 }
517
518 /// The extracted text of every selection region, **ordered by where each
519 /// region starts** in the content (not by draw order), each as one element.
520 /// Empty (whitespace-only) regions are skipped. `exclude` drops individual
521 /// character positions from the copied text (e.g. app-specific annotation
522 /// glyphs). The host joins these — possibly across several panels — however
523 /// it wants (see [`selected_text`](Self::selected_text) for the common
524 /// single-panel join).
525 pub fn selected_parts(&self, exclude: Option<&HashSet<TextPos>>) -> Vec<String> {
526 let Some(wrap) = self.wrap.as_ref() else {
527 return Vec::new();
528 };
529 let mut regions: Vec<&Region> = self.regions().collect();
530 regions.sort_by_key(|r| r.start());
531 let mut parts = Vec::new();
532 for region in regions {
533 if let Some(text) = selection::extract_text(region.anchor, region.cursor, wrap, exclude)
534 {
535 parts.push(text);
536 }
537 }
538 parts
539 }
540
541 /// The whole selection as a single string: every region's text (ordered by
542 /// start) joined by a blank line, or `None` when nothing is selected. A
543 /// convenience over [`selected_parts`](Self::selected_parts) for the common
544 /// single-panel case.
545 pub fn selected_text(&self, exclude: Option<&HashSet<TextPos>>) -> Option<String> {
546 let parts = self.selected_parts(exclude);
547 if parts.is_empty() {
548 None
549 } else {
550 Some(parts.join("\n\n"))
551 }
552 }
553}
554
555/// Scrollbar conveniences (default-on `scrollbar` feature): thin wrappers that
556/// plumb the panel's own geometry into the panel-agnostic
557/// [`crate::scrollbar`] helpers.
558#[cfg(feature = "scrollbar")]
559impl MultiSelectPanel {
560 /// Jump/scroll to the position a scrollbar-track click or drag at terminal
561 /// `row` maps to, given the `track` Rect (the panel's scrollbar column).
562 /// The track's height doubles as the viewport height for computing the
563 /// scrollable extent, so this stays correct between frames without any
564 /// cached `max_scroll`.
565 pub fn scroll_to_track_row(&mut self, track: ratatui::layout::Rect, row: u16) {
566 let max = self.max_scroll(track.height);
567 self.set_scroll(crate::scrollbar::scroll_for_track_row(track, row, max));
568 }
569
570 /// Render this panel's vertical scrollbar into `area` (its scrollbar
571 /// column) with `style`. A no-op when the content already fits, so it's
572 /// safe to call every frame; `area.height` is taken as the visible row
573 /// capacity.
574 pub fn render_scrollbar(
575 &self,
576 area: ratatui::layout::Rect,
577 buf: &mut ratatui::buffer::Buffer,
578 style: &crate::scrollbar::ScrollbarStyle,
579 ) {
580 crate::scrollbar::render_scrollbar(
581 area,
582 buf,
583 self.total_rows() as usize,
584 area.height as usize,
585 self.scroll() as usize,
586 style,
587 );
588 }
589}
590
591#[cfg(test)]
592mod tests {
593 use super::*;
594
595 fn panel(text: &str, width: usize) -> MultiSelectPanel {
596 let mut p = MultiSelectPanel::new();
597 p.set_content(Arc::from(text), width);
598 p
599 }
600
601 // A 40-wide, 10-high panel anchored at the origin.
602 fn area() -> Rect {
603 Rect::new(0, 0, 40, 10)
604 }
605
606 #[test]
607 fn mouse_drag_selects_a_run_within_one_line() {
608 let mut p = panel("hello world", 40);
609 p.begin(area(), (0, 0)); // 'h'
610 p.drag(area(), (4, 0)); // through 'hello' (inclusive of col 4)
611 assert_eq!(p.selected_text(None).as_deref(), Some("hello"));
612 assert!(p.has_selection());
613 }
614
615 #[test]
616 fn clear_drops_all_regions() {
617 let mut p = panel("hello world", 40);
618 p.begin(area(), (0, 0));
619 p.drag(area(), (5, 0));
620 p.clear();
621 assert!(!p.has_selection());
622 assert_eq!(p.selected_text(None), None);
623 }
624
625 #[test]
626 fn multiple_regions_copy_in_start_order_regardless_of_creation_order() {
627 // Two lines; select the SECOND line's word first, then the FIRST.
628 let mut p = panel("alpha\nbravo", 40);
629 // Region A: "bravo" on line 1.
630 p.begin(area(), (0, 1));
631 p.drag(area(), (5, 1));
632 p.finalize_active();
633 // Region B: "alpha" on line 0.
634 p.begin(area(), (0, 0));
635 p.drag(area(), (5, 0));
636 // Ordered by start -> alpha (line 0) before bravo (line 1).
637 assert_eq!(p.selected_parts(None), vec!["alpha", "bravo"]);
638 assert_eq!(p.selected_text(None).as_deref(), Some("alpha\n\nbravo"));
639 }
640
641 #[test]
642 fn keyboard_extend_grows_the_active_region() {
643 let mut p = panel("hello world", 40);
644 p.begin(area(), (0, 0)); // caret at 'h', empty selection
645 p.extend(Motion::Right, area());
646 p.extend(Motion::Right, area());
647 // anchor col 0 ..= cursor col 2 -> "hel"
648 assert_eq!(p.selected_text(None).as_deref(), Some("hel"));
649 }
650
651 #[test]
652 fn drag_past_bottom_edge_autoscrolls_and_extends() {
653 // 30 short lines into a 10-row window: dragging below the panel
654 // scrolls down and keeps extending the selection.
655 let body: String = (0..30)
656 .map(|i| format!("line{i}"))
657 .collect::<Vec<_>>()
658 .join("\n");
659 let mut p = panel(&body, 40);
660 p.begin(area(), (0, 0)); // start at top
661 assert_eq!(p.scroll(), 0);
662 // Drag below the panel's bottom edge (area.y + height = 10).
663 p.drag(area(), (0, 10));
664 assert!(p.has_pending_autoscroll());
665 assert!(p.scroll() > 0, "auto-scrolled down");
666 // Idle ticks keep scrolling toward the bottom.
667 for _ in 0..40 {
668 p.autoscroll_tick(area());
669 }
670 assert_eq!(p.scroll(), p.max_scroll(area().height));
671 // The selection now reaches the last line.
672 let text = p.selected_text(None).unwrap();
673 assert!(text.starts_with("line0"));
674 assert!(text.contains("line29"));
675 }
676
677 #[test]
678 fn end_drag_stops_autoscroll_but_keeps_the_selection() {
679 let body: String = (0..30)
680 .map(|i| format!("line{i}"))
681 .collect::<Vec<_>>()
682 .join("\n");
683 let mut p = panel(&body, 40);
684 p.begin(area(), (0, 0));
685 p.drag(area(), (0, 10));
686 assert!(p.has_pending_autoscroll());
687 p.end_drag();
688 assert!(!p.has_pending_autoscroll());
689 assert!(p.has_selection());
690 }
691
692 #[test]
693 fn scroll_helpers_clamp_to_content() {
694 let body: String = (0..30)
695 .map(|i| format!("line{i}"))
696 .collect::<Vec<_>>()
697 .join("\n");
698 let mut p = panel(&body, 40);
699 assert_eq!(p.total_rows(), 30);
700 assert_eq!(p.max_scroll(10), 20);
701 p.set_scroll(999);
702 assert_eq!(p.clamp_scroll(10), 20);
703 assert_eq!(p.scroll(), 20);
704 p.scroll_by(-5, 10);
705 assert_eq!(p.scroll(), 15);
706 p.scroll_by(100, 10);
707 assert_eq!(p.scroll(), 20);
708 }
709
710 #[test]
711 fn exclude_drops_specific_positions_from_copied_text() {
712 let mut p = panel("a!bc", 40);
713 p.begin(area(), (0, 0));
714 p.drag(area(), (4, 0));
715 let mut ex = HashSet::new();
716 ex.insert(TextPos::new(0, 1)); // the '!'
717 assert_eq!(p.selected_text(Some(&ex)).as_deref(), Some("abc"));
718 }
719
720 #[test]
721 fn styled_content_selects_on_the_plain_text() {
722 use ratatui::style::{Color, Style};
723 let lines = vec![Line::from(vec![
724 ratatui::text::Span::styled("key", Style::default().fg(Color::Green)),
725 ratatui::text::Span::raw(": v"),
726 ])];
727 let mut p = MultiSelectPanel::new();
728 p.set_styled_content(&lines, 40);
729 p.begin(area(), (0, 0));
730 p.drag(area(), (2, 0)); // "key" (inclusive of col 2)
731 assert_eq!(p.selected_text(None).as_deref(), Some("key"));
732 // Rendered row keeps the colour.
733 let rows = p.visible_rows(10);
734 assert_eq!(rows[0].spans[0].style.fg, Some(Color::Green));
735 }
736
737 #[test]
738 fn active_and_finalized_selections_round_trip_programmatically() {
739 let mut p = panel("alpha\nbravo", 40);
740 assert_eq!(p.active_selection(), None);
741 assert!(p.finalized_selections().is_empty());
742
743 p.set_active_selection(TextPos::new(0, 0), TextPos::new(0, 4));
744 assert_eq!(
745 p.active_selection(),
746 Some((TextPos::new(0, 0), TextPos::new(0, 4)))
747 );
748 // Inclusive of the cursor column -> "alpha".
749 assert_eq!(p.selected_text(None).as_deref(), Some("alpha"));
750
751 p.push_finalized(TextPos::new(1, 0), TextPos::new(1, 4));
752 assert_eq!(
753 p.finalized_selections(),
754 vec![(TextPos::new(1, 0), TextPos::new(1, 4))]
755 );
756 assert_eq!(p.selected_parts(None), vec!["alpha", "bravo"]);
757 }
758
759 #[test]
760 fn wrap_accessor_exposes_the_live_layout() {
761 let p = panel("hello world", 40);
762 let wrap = p.wrap().expect("content was set");
763 assert_eq!(wrap.line_text(0), "hello world");
764 }
765
766 #[test]
767 fn start_autoscroll_drives_autoscroll_tick() {
768 let body: String = (0..30)
769 .map(|i| format!("line{i}"))
770 .collect::<Vec<_>>()
771 .join("\n");
772 let mut p = panel(&body, 40);
773 p.set_active_selection(TextPos::new(0, 0), TextPos::new(0, 0));
774 p.start_autoscroll(AutoScroll::Down);
775 assert!(p.has_pending_autoscroll());
776 p.autoscroll_tick(area());
777 assert_eq!(p.scroll(), 1);
778 }
779
780 #[test]
781 fn wrap_marker_is_drawn_on_wrapped_rows_but_not_the_last() {
782 // Width 4 with a reserved marker column -> wraps at 3 chars.
783 let mut p = MultiSelectPanel::new();
784 p.set_wrap_marker(Some(WrapMarker::default()));
785 p.set_content(Arc::from("abcdef"), 4);
786 assert_eq!(p.wrap_marker(), Some(WrapMarker::default()));
787
788 let rows = p.visible_rows(10);
789 assert_eq!(rows.len(), 2, "'abcdef' wraps to two rows at width 3");
790 // The first (continued) row ends with the marker glyph...
791 let first_last = &rows[0].spans[rows[0].spans.len() - 1];
792 assert_eq!(first_last.content, WrapMarker::default().glyph.to_string());
793 // ...the final row does not (nothing continues after it).
794 let second_last = &rows[1].spans[rows[1].spans.len() - 1];
795 assert_ne!(second_last.content, WrapMarker::default().glyph.to_string());
796
797 // The marker column is purely visual: it never maps to a character,
798 // so selecting the whole logical line still yields exactly the text.
799 p.set_active_selection(TextPos::new(0, 0), TextPos::new(0, 5));
800 assert_eq!(p.selected_text(None).as_deref(), Some("abcdef"));
801 }
802
803 #[test]
804 fn wrap_marker_survives_styled_content() {
805 use ratatui::text::Span;
806 let mut p = MultiSelectPanel::new();
807 p.set_wrap_marker(Some(WrapMarker::default()));
808 let lines = vec![Line::from(vec![Span::raw("abcdef")])];
809 p.set_styled_content(&lines, 4);
810 let rows = p.visible_rows(10);
811 assert_eq!(rows.len(), 2);
812 let first_last = &rows[0].spans[rows[0].spans.len() - 1];
813 assert_eq!(first_last.content, WrapMarker::default().glyph.to_string());
814 }
815
816 #[cfg(feature = "scrollbar")]
817 #[test]
818 fn scroll_to_track_row_maps_a_click_to_the_panels_scroll() {
819 // 40 one-char rows in a 10-row viewport -> max_scroll 30.
820 let body: String = (0..40).map(|i| format!("line{i}\n")).collect();
821 let mut p = panel(&body, 40);
822 // The scrollbar track is 10 rows tall (the viewport height).
823 let track = Rect::new(39, 0, 1, 10);
824 // A click at the very bottom of the track jumps to max scroll.
825 p.scroll_to_track_row(track, 9);
826 assert_eq!(p.scroll(), p.max_scroll(10));
827 // A click at the top returns to zero.
828 p.scroll_to_track_row(track, 0);
829 assert_eq!(p.scroll(), 0);
830 }
831
832 #[cfg(feature = "scrollbar")]
833 #[test]
834 fn render_scrollbar_paints_a_thumb_only_when_content_overflows() {
835 use crate::scrollbar::ScrollbarStyle;
836 use ratatui::buffer::Buffer;
837
838 let long: String = (0..40).map(|i| format!("line{i}\n")).collect();
839 let mut p = panel(&long, 40);
840 p.clamp_scroll(10);
841 let area = Rect::new(0, 0, 1, 10);
842 let mut buf = Buffer::empty(area);
843 p.render_scrollbar(area, &mut buf, &ScrollbarStyle::default());
844 let painted: String = (0..area.height)
845 .map(|y| buf[(0, y)].symbol().to_string())
846 .collect();
847 assert!(
848 painted.contains('\u{2588}'),
849 "overflowing content shows a thumb"
850 );
851
852 // A panel whose content fits draws nothing.
853 let mut short = panel("only one line", 40);
854 short.clamp_scroll(10);
855 let mut blank = Buffer::empty(area);
856 short.render_scrollbar(area, &mut blank, &ScrollbarStyle::default());
857 assert_eq!(blank, Buffer::empty(area));
858 }
859}