Skip to main content

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}