use std::collections::HashSet;
use std::sync::Arc;
use ratatui::layout::Rect;
use ratatui::text::Line;
use crate::selection;
use crate::wrapcache::{PanelWrap, TextPos, WrapMarker, WrapMode};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Motion {
Left,
Right,
Up,
Down,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum AutoScroll {
Up,
Down,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct Region {
anchor: TextPos,
cursor: TextPos,
}
impl Region {
fn start(&self) -> TextPos {
self.anchor.min(self.cursor)
}
}
#[derive(Default)]
pub struct MultiSelectPanel {
wrap: Option<PanelWrap>,
mode: WrapMode,
marker: Option<WrapMarker>,
scroll: u16,
active: Option<Region>,
extras: Vec<Region>,
pending_autoscroll: Option<i8>,
}
impl MultiSelectPanel {
pub fn new() -> Self {
Self::default()
}
pub fn set_wrap_mode(&mut self, mode: WrapMode) {
self.mode = mode;
}
pub fn wrap_mode(&self) -> WrapMode {
self.mode
}
pub fn set_wrap_marker(&mut self, marker: Option<WrapMarker>) {
self.marker = marker;
}
pub fn wrap_marker(&self) -> Option<WrapMarker> {
self.marker
}
pub fn set_content(&mut self, text: Arc<str>, width: usize) {
PanelWrap::rebuild_if_needed_marker(&mut self.wrap, &text, width, self.mode, self.marker);
}
#[cfg(feature = "ansi")]
pub fn set_ansi_content(&mut self, text: Arc<str>, width: usize) {
PanelWrap::rebuild_if_needed_ansi_marker(
&mut self.wrap,
&text,
width,
self.mode,
self.marker,
);
}
pub fn set_styled_content(&mut self, lines: &[Line<'_>], width: usize) {
self.wrap = Some(PanelWrap::build_styled_with_marker(
lines,
width,
self.mode,
self.marker,
));
}
pub fn has_content(&self) -> bool {
self.wrap.is_some()
}
pub fn whole_text(&self) -> Option<&str> {
self.wrap.as_ref().map(PanelWrap::source)
}
pub fn total_rows(&self) -> u32 {
self.wrap.as_ref().map_or(0, PanelWrap::total_rows)
}
pub fn max_scroll(&self, viewport_height: u16) -> u16 {
let total = self.total_rows().min(u16::MAX as u32) as u16;
total.saturating_sub(viewport_height)
}
pub fn scroll(&self) -> u16 {
self.scroll
}
pub fn set_scroll(&mut self, scroll: u16) {
self.scroll = scroll;
}
pub fn scroll_by(&mut self, delta: i32, viewport_height: u16) {
let max = self.max_scroll(viewport_height) as i32;
let next = (self.scroll as i32 + delta).clamp(0, max);
self.scroll = next as u16;
}
pub fn clamp_scroll(&mut self, viewport_height: u16) -> u16 {
let max = self.max_scroll(viewport_height);
self.scroll = self.scroll.min(max);
max
}
pub fn visible_rows(&self, height: u16) -> Vec<Line<'static>> {
self.wrap
.as_ref()
.map(|w| w.visible_window(self.scroll, height))
.unwrap_or_default()
}
pub fn highlight_regions(&self, area: Rect) -> Vec<(u16, u16, u16)> {
let Some(wrap) = self.wrap.as_ref() else {
return Vec::new();
};
let mut cells = Vec::new();
for region in self.regions() {
cells.extend(selection::highlight_cells(
region.anchor,
region.cursor,
wrap,
area,
self.scroll,
));
}
cells
}
pub fn begin(&mut self, area: Rect, point: (u16, u16)) {
self.pending_autoscroll = None;
let Some(wrap) = self.wrap.as_ref() else {
self.active = None;
return;
};
let pos = selection::point_to_textpos(point, area, self.scroll, wrap);
self.active = Some(Region {
anchor: pos,
cursor: pos,
});
}
pub fn drag(&mut self, area: Rect, point: (u16, u16)) {
if self.active.is_none() {
return;
}
let (_, row) = point;
if area.height > 0 && row < area.y {
self.pending_autoscroll = Some(-1);
self.autoscroll_tick(area);
return;
}
if area.height > 0 && row >= area.y.saturating_add(area.height) {
self.pending_autoscroll = Some(1);
self.autoscroll_tick(area);
return;
}
self.pending_autoscroll = None;
let Some(wrap) = self.wrap.as_ref() else {
return;
};
let pos = selection::point_to_textpos(point, area, self.scroll, wrap);
if let Some(region) = self.active.as_mut() {
region.cursor = pos;
}
}
pub fn end_drag(&mut self) {
self.pending_autoscroll = None;
}
pub fn has_pending_autoscroll(&self) -> bool {
self.pending_autoscroll.is_some()
}
pub fn autoscroll_tick(&mut self, area: Rect) {
let Some(dir) = self.pending_autoscroll else {
return;
};
if self.active.is_none() {
self.pending_autoscroll = None;
return;
}
let max_scroll = self.max_scroll(area.height);
let new_scroll = if dir < 0 {
self.scroll.saturating_sub(1)
} else {
(self.scroll + 1).min(max_scroll)
};
let reached_bound = new_scroll == self.scroll;
self.scroll = new_scroll;
let Some(wrap) = self.wrap.as_ref() else {
return;
};
let edge_row = if reached_bound {
if dir < 0 {
0
} else {
wrap.total_rows().saturating_sub(1)
}
} else if dir < 0 {
new_scroll as u32
} else {
(new_scroll as u32 + area.height as u32).saturating_sub(1)
};
let col = if dir < 0 { 0 } else { usize::MAX };
let pos = wrap.row_col_to_textpos(edge_row, col);
if let Some(region) = self.active.as_mut() {
region.cursor = pos;
}
}
pub fn extend(&mut self, motion: Motion, area: Rect) {
let Some(region) = self.active else {
return;
};
let Some(wrap) = self.wrap.as_ref() else {
return;
};
let mut pos = region.cursor;
match motion {
Motion::Left => {
if pos.col > 0 {
pos.col -= 1;
} else if pos.line > 0 {
pos.line -= 1;
pos.col = wrap.line_char_len(pos.line).saturating_sub(1);
}
}
Motion::Right => {
let len = wrap.line_char_len(pos.line);
if pos.col + 1 < len {
pos.col += 1;
} else if pos.line + 1 < wrap.line_count() {
pos.line += 1;
pos.col = 0;
}
}
Motion::Up => {
if pos.line > 0 {
pos.line -= 1;
pos.col = pos.col.min(wrap.line_char_len(pos.line).saturating_sub(1));
}
}
Motion::Down => {
if pos.line + 1 < wrap.line_count() {
pos.line += 1;
pos.col = pos.col.min(wrap.line_char_len(pos.line).saturating_sub(1));
}
}
}
if let Some(region) = self.active.as_mut() {
region.cursor = pos;
}
self.scroll_cursor_into_view(area);
}
fn scroll_cursor_into_view(&mut self, area: Rect) {
if area.height == 0 {
return;
}
let Some(region) = self.active else {
return;
};
let Some(wrap) = self.wrap.as_ref() else {
return;
};
let (row, _) = wrap.textpos_to_row_col(region.cursor);
let max_scroll = self.max_scroll(area.height);
if row < self.scroll as u32 {
self.scroll = row as u16;
} else if row >= self.scroll as u32 + area.height as u32 {
self.scroll = (row + 1).saturating_sub(area.height as u32) as u16;
}
self.scroll = self.scroll.min(max_scroll);
}
pub fn finalize_active(&mut self) {
if let Some(region) = self.active.take() {
self.extras.push(region);
}
self.pending_autoscroll = None;
}
pub fn clear(&mut self) {
self.active = None;
self.extras.clear();
self.pending_autoscroll = None;
}
pub fn has_selection(&self) -> bool {
self.active.is_some() || !self.extras.is_empty()
}
pub fn wrap(&self) -> Option<&PanelWrap> {
self.wrap.as_ref()
}
pub fn active_selection(&self) -> Option<(TextPos, TextPos)> {
self.active.map(|r| (r.anchor, r.cursor))
}
pub fn set_active_selection(&mut self, anchor: TextPos, cursor: TextPos) {
self.active = Some(Region { anchor, cursor });
}
pub fn finalized_selections(&self) -> Vec<(TextPos, TextPos)> {
self.extras.iter().map(|r| (r.anchor, r.cursor)).collect()
}
pub fn push_finalized(&mut self, anchor: TextPos, cursor: TextPos) {
self.extras.push(Region { anchor, cursor });
}
pub fn start_autoscroll(&mut self, dir: AutoScroll) {
self.pending_autoscroll = Some(match dir {
AutoScroll::Up => -1,
AutoScroll::Down => 1,
});
}
fn regions(&self) -> impl Iterator<Item = &Region> {
self.extras.iter().chain(self.active.iter())
}
pub fn selected_parts(&self, exclude: Option<&HashSet<TextPos>>) -> Vec<String> {
let Some(wrap) = self.wrap.as_ref() else {
return Vec::new();
};
let mut regions: Vec<&Region> = self.regions().collect();
regions.sort_by_key(|r| r.start());
let mut parts = Vec::new();
for region in regions {
if let Some(text) = selection::extract_text(region.anchor, region.cursor, wrap, exclude)
{
parts.push(text);
}
}
parts
}
pub fn selected_text(&self, exclude: Option<&HashSet<TextPos>>) -> Option<String> {
let parts = self.selected_parts(exclude);
if parts.is_empty() {
None
} else {
Some(parts.join("\n\n"))
}
}
}
#[cfg(feature = "scrollbar")]
impl MultiSelectPanel {
pub fn scroll_to_track_row(&mut self, track: ratatui::layout::Rect, row: u16) {
let max = self.max_scroll(track.height);
self.set_scroll(crate::scrollbar::scroll_for_track_row(track, row, max));
}
pub fn render_scrollbar(
&self,
area: ratatui::layout::Rect,
buf: &mut ratatui::buffer::Buffer,
style: &crate::scrollbar::ScrollbarStyle,
) {
crate::scrollbar::render_scrollbar(
area,
buf,
self.total_rows() as usize,
area.height as usize,
self.scroll() as usize,
style,
);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn panel(text: &str, width: usize) -> MultiSelectPanel {
let mut p = MultiSelectPanel::new();
p.set_content(Arc::from(text), width);
p
}
fn area() -> Rect {
Rect::new(0, 0, 40, 10)
}
#[test]
fn mouse_drag_selects_a_run_within_one_line() {
let mut p = panel("hello world", 40);
p.begin(area(), (0, 0)); p.drag(area(), (4, 0)); assert_eq!(p.selected_text(None).as_deref(), Some("hello"));
assert!(p.has_selection());
}
#[test]
fn clear_drops_all_regions() {
let mut p = panel("hello world", 40);
p.begin(area(), (0, 0));
p.drag(area(), (5, 0));
p.clear();
assert!(!p.has_selection());
assert_eq!(p.selected_text(None), None);
}
#[test]
fn multiple_regions_copy_in_start_order_regardless_of_creation_order() {
let mut p = panel("alpha\nbravo", 40);
p.begin(area(), (0, 1));
p.drag(area(), (5, 1));
p.finalize_active();
p.begin(area(), (0, 0));
p.drag(area(), (5, 0));
assert_eq!(p.selected_parts(None), vec!["alpha", "bravo"]);
assert_eq!(p.selected_text(None).as_deref(), Some("alpha\n\nbravo"));
}
#[test]
fn keyboard_extend_grows_the_active_region() {
let mut p = panel("hello world", 40);
p.begin(area(), (0, 0)); p.extend(Motion::Right, area());
p.extend(Motion::Right, area());
assert_eq!(p.selected_text(None).as_deref(), Some("hel"));
}
#[test]
fn drag_past_bottom_edge_autoscrolls_and_extends() {
let body: String = (0..30)
.map(|i| format!("line{i}"))
.collect::<Vec<_>>()
.join("\n");
let mut p = panel(&body, 40);
p.begin(area(), (0, 0)); assert_eq!(p.scroll(), 0);
p.drag(area(), (0, 10));
assert!(p.has_pending_autoscroll());
assert!(p.scroll() > 0, "auto-scrolled down");
for _ in 0..40 {
p.autoscroll_tick(area());
}
assert_eq!(p.scroll(), p.max_scroll(area().height));
let text = p.selected_text(None).unwrap();
assert!(text.starts_with("line0"));
assert!(text.contains("line29"));
}
#[test]
fn end_drag_stops_autoscroll_but_keeps_the_selection() {
let body: String = (0..30)
.map(|i| format!("line{i}"))
.collect::<Vec<_>>()
.join("\n");
let mut p = panel(&body, 40);
p.begin(area(), (0, 0));
p.drag(area(), (0, 10));
assert!(p.has_pending_autoscroll());
p.end_drag();
assert!(!p.has_pending_autoscroll());
assert!(p.has_selection());
}
#[test]
fn scroll_helpers_clamp_to_content() {
let body: String = (0..30)
.map(|i| format!("line{i}"))
.collect::<Vec<_>>()
.join("\n");
let mut p = panel(&body, 40);
assert_eq!(p.total_rows(), 30);
assert_eq!(p.max_scroll(10), 20);
p.set_scroll(999);
assert_eq!(p.clamp_scroll(10), 20);
assert_eq!(p.scroll(), 20);
p.scroll_by(-5, 10);
assert_eq!(p.scroll(), 15);
p.scroll_by(100, 10);
assert_eq!(p.scroll(), 20);
}
#[test]
fn exclude_drops_specific_positions_from_copied_text() {
let mut p = panel("a!bc", 40);
p.begin(area(), (0, 0));
p.drag(area(), (4, 0));
let mut ex = HashSet::new();
ex.insert(TextPos::new(0, 1)); assert_eq!(p.selected_text(Some(&ex)).as_deref(), Some("abc"));
}
#[test]
fn styled_content_selects_on_the_plain_text() {
use ratatui::style::{Color, Style};
let lines = vec![Line::from(vec![
ratatui::text::Span::styled("key", Style::default().fg(Color::Green)),
ratatui::text::Span::raw(": v"),
])];
let mut p = MultiSelectPanel::new();
p.set_styled_content(&lines, 40);
p.begin(area(), (0, 0));
p.drag(area(), (2, 0)); assert_eq!(p.selected_text(None).as_deref(), Some("key"));
let rows = p.visible_rows(10);
assert_eq!(rows[0].spans[0].style.fg, Some(Color::Green));
}
#[test]
fn active_and_finalized_selections_round_trip_programmatically() {
let mut p = panel("alpha\nbravo", 40);
assert_eq!(p.active_selection(), None);
assert!(p.finalized_selections().is_empty());
p.set_active_selection(TextPos::new(0, 0), TextPos::new(0, 4));
assert_eq!(
p.active_selection(),
Some((TextPos::new(0, 0), TextPos::new(0, 4)))
);
assert_eq!(p.selected_text(None).as_deref(), Some("alpha"));
p.push_finalized(TextPos::new(1, 0), TextPos::new(1, 4));
assert_eq!(
p.finalized_selections(),
vec![(TextPos::new(1, 0), TextPos::new(1, 4))]
);
assert_eq!(p.selected_parts(None), vec!["alpha", "bravo"]);
}
#[test]
fn wrap_accessor_exposes_the_live_layout() {
let p = panel("hello world", 40);
let wrap = p.wrap().expect("content was set");
assert_eq!(wrap.line_text(0), "hello world");
}
#[test]
fn start_autoscroll_drives_autoscroll_tick() {
let body: String = (0..30)
.map(|i| format!("line{i}"))
.collect::<Vec<_>>()
.join("\n");
let mut p = panel(&body, 40);
p.set_active_selection(TextPos::new(0, 0), TextPos::new(0, 0));
p.start_autoscroll(AutoScroll::Down);
assert!(p.has_pending_autoscroll());
p.autoscroll_tick(area());
assert_eq!(p.scroll(), 1);
}
#[test]
fn wrap_marker_is_drawn_on_wrapped_rows_but_not_the_last() {
let mut p = MultiSelectPanel::new();
p.set_wrap_marker(Some(WrapMarker::default()));
p.set_content(Arc::from("abcdef"), 4);
assert_eq!(p.wrap_marker(), Some(WrapMarker::default()));
let rows = p.visible_rows(10);
assert_eq!(rows.len(), 2, "'abcdef' wraps to two rows at width 3");
let first_last = &rows[0].spans[rows[0].spans.len() - 1];
assert_eq!(first_last.content, WrapMarker::default().glyph.to_string());
let second_last = &rows[1].spans[rows[1].spans.len() - 1];
assert_ne!(second_last.content, WrapMarker::default().glyph.to_string());
p.set_active_selection(TextPos::new(0, 0), TextPos::new(0, 5));
assert_eq!(p.selected_text(None).as_deref(), Some("abcdef"));
}
#[test]
fn wrap_marker_survives_styled_content() {
use ratatui::text::Span;
let mut p = MultiSelectPanel::new();
p.set_wrap_marker(Some(WrapMarker::default()));
let lines = vec![Line::from(vec![Span::raw("abcdef")])];
p.set_styled_content(&lines, 4);
let rows = p.visible_rows(10);
assert_eq!(rows.len(), 2);
let first_last = &rows[0].spans[rows[0].spans.len() - 1];
assert_eq!(first_last.content, WrapMarker::default().glyph.to_string());
}
#[cfg(feature = "scrollbar")]
#[test]
fn scroll_to_track_row_maps_a_click_to_the_panels_scroll() {
let body: String = (0..40).map(|i| format!("line{i}\n")).collect();
let mut p = panel(&body, 40);
let track = Rect::new(39, 0, 1, 10);
p.scroll_to_track_row(track, 9);
assert_eq!(p.scroll(), p.max_scroll(10));
p.scroll_to_track_row(track, 0);
assert_eq!(p.scroll(), 0);
}
#[cfg(feature = "scrollbar")]
#[test]
fn render_scrollbar_paints_a_thumb_only_when_content_overflows() {
use crate::scrollbar::ScrollbarStyle;
use ratatui::buffer::Buffer;
let long: String = (0..40).map(|i| format!("line{i}\n")).collect();
let mut p = panel(&long, 40);
p.clamp_scroll(10);
let area = Rect::new(0, 0, 1, 10);
let mut buf = Buffer::empty(area);
p.render_scrollbar(area, &mut buf, &ScrollbarStyle::default());
let painted: String = (0..area.height)
.map(|y| buf[(0, y)].symbol().to_string())
.collect();
assert!(
painted.contains('\u{2588}'),
"overflowing content shows a thumb"
);
let mut short = panel("only one line", 40);
short.clamp_scroll(10);
let mut blank = Buffer::empty(area);
short.render_scrollbar(area, &mut blank, &ScrollbarStyle::default());
assert_eq!(blank, Buffer::empty(area));
}
}