use std::{rc::Rc, sync::Arc};
use indexmap::IndexSet;
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, List, ListDirection, ListItem, ListState, StatefulWidget, Widget};
use regex::Regex;
use unicode_display_width::width as display_width;
use crate::options::feature_flag;
use crate::tui::util::char_display_width;
use crate::{
DisplayContext, MatchRange, Selector, SkimOptions,
item::MatchedItem,
spinlock::SpinLock,
theme::ColorTheme,
tui::BorderType,
tui::options::TuiLayout,
tui::util::wrap_text,
tui::widget::{SkimRender, SkimWidget},
};
#[derive(Default, Clone, Copy)]
pub(crate) enum MergeStrategy {
#[default]
Replace,
SortedMerge,
Append,
}
pub(crate) struct ProcessedItems {
pub(crate) items: Vec<MatchedItem>,
pub(crate) merge: MergeStrategy,
}
impl Default for ProcessedItems {
fn default() -> Self {
Self {
items: Vec::new(),
merge: MergeStrategy::Replace,
}
}
}
pub struct ItemList {
pub(crate) items: Vec<MatchedItem>,
pub(crate) selection: IndexSet<MatchedItem>,
pub(crate) processed_items: Arc<SpinLock<Option<ProcessedItems>>>,
pub(crate) direction: ListDirection,
pub(crate) offset: usize,
pub(crate) current: usize,
pub(crate) height: u16,
pub(crate) theme: std::sync::Arc<crate::theme::ColorTheme>,
pub(crate) multi_select: bool,
reserved: usize,
no_hscroll: bool,
ellipsis: String,
keep_right: bool,
skip_to_pattern: Option<Regex>,
tabstop: usize,
selector: Option<Rc<dyn Selector>>,
pre_select_target: usize, no_clear_if_empty: bool,
interactive: bool, showing_stale_items: bool, pub(crate) manual_hscroll: i32, selector_icon: String,
multi_select_icon: String,
cycle: bool,
wrap: bool,
pub border: Option<BorderType>,
show_score: bool,
show_index: bool,
}
impl Default for ItemList {
fn default() -> Self {
let processed_items = Arc::new(SpinLock::new(None));
Self {
processed_items,
direction: ListDirection::BottomToTop,
items: Default::default(),
selection: Default::default(),
offset: Default::default(),
current: Default::default(),
height: Default::default(),
theme: Arc::new(ColorTheme::default()),
multi_select: false,
reserved: 0,
no_hscroll: false,
ellipsis: String::from(".."),
keep_right: false,
skip_to_pattern: None,
tabstop: 8,
selector: None,
pre_select_target: 0,
no_clear_if_empty: false,
interactive: false,
showing_stale_items: false,
manual_hscroll: 0,
selector_icon: String::from(">"),
multi_select_icon: String::from(">"),
cycle: false,
wrap: false,
border: None,
show_score: false,
show_index: false,
}
}
}
impl ItemList {
fn cursor(&self) -> usize {
self.current
}
pub fn count(&self) -> usize {
if self.showing_stale_items { 0 } else { self.items.len() }
}
pub fn selected(&self) -> Option<MatchedItem> {
self.items.get(self.cursor()).cloned()
}
pub fn append(&mut self, items: &mut Vec<MatchedItem>) {
self.items.append(items);
self.showing_stale_items = false;
}
fn calc_skip_width(&self, text: &str) -> usize {
if let Some(ref regex) = self.skip_to_pattern
&& let Some(mat) = regex.find(text)
{
return display_width(&text[..mat.start()]).try_into().unwrap();
}
0
}
fn calc_hscroll(
&self,
text: &str,
container_width: usize,
match_start_char: usize,
match_end_char: usize,
) -> (usize, usize, bool, bool) {
let full_width = text.chars().fold(0, |acc, ch| {
if ch == '\t' {
acc + self.tabstop - (acc % self.tabstop)
} else {
acc + char_display_width(ch)
}
});
let available_width = if container_width >= display_width(&self.ellipsis).try_into().unwrap() {
container_width
} else {
return (0, full_width, false, false);
};
let base_shift = if self.no_hscroll {
0
} else if match_start_char == 0 && match_end_char == 0 {
let skip_width = self.calc_skip_width(text);
if skip_width > 0 {
skip_width
} else if self.keep_right {
full_width.saturating_sub(available_width)
} else {
0
}
} else {
let mut match_start_width = 0;
let mut match_end_width = 0;
let mut current_width = 0;
let mut found_start = false;
let mut found_end = false;
for (idx, ch) in text.chars().enumerate() {
if idx == match_start_char {
match_start_width = current_width;
found_start = true;
}
if idx == match_end_char {
match_end_width = current_width;
found_end = true;
break;
}
if ch == '\t' {
current_width += self.tabstop - (current_width % self.tabstop);
} else {
current_width += char_display_width(ch);
}
}
if found_start && !found_end {
match_end_width = current_width;
}
let match_width = match_end_width.saturating_sub(match_start_width);
if match_width >= available_width {
match_start_width
} else {
let desired_shift = match_start_width.saturating_sub((available_width - match_width) / 2);
let max_shift = full_width.saturating_sub(available_width);
desired_shift.min(max_shift)
}
};
let proposed_shift = (base_shift as i32 + self.manual_hscroll).max(0) as usize;
let shift = if full_width > available_width {
let max_shift = full_width.saturating_sub(available_width);
proposed_shift.min(max_shift)
} else {
proposed_shift
};
let has_left_overflow = shift > 0;
let has_right_overflow = shift + available_width < full_width;
(shift, full_width, has_left_overflow, has_right_overflow)
}
fn apply_hscroll<'a>(
&'a self,
line: Line<'a>,
shift: usize,
container_width: usize,
full_width: usize,
) -> Line<'a> {
let has_left_overflow = shift > 0;
let has_right_overflow = shift + container_width < full_width;
let left_indicator_width = if has_left_overflow {
display_width(&self.ellipsis).try_into().unwrap()
} else {
0
};
let right_indicator_width = if has_right_overflow {
display_width(&self.ellipsis).try_into().unwrap()
} else {
0
};
let content_width = container_width.saturating_sub(left_indicator_width + right_indicator_width);
let mut result = Line::default();
if has_left_overflow {
result.push_span(Span::raw(&self.ellipsis));
}
let mut current_char_index = 0;
let mut current_width = 0;
let shift_char_start = self.char_index_at_width(&line, shift);
let shift_char_end = self.char_index_at_width(&line, shift + content_width);
for span in line.spans {
let span_text = span.content.as_ref();
let span_chars: Vec<char> = span_text.chars().collect();
let span_start_char = current_char_index;
let span_end_char = current_char_index + span_chars.len();
if span_end_char > shift_char_start && span_start_char < shift_char_end {
let visible_start = shift_char_start.saturating_sub(span_start_char);
let visible_end = if span_end_char > shift_char_end {
shift_char_end - span_start_char
} else {
span_chars.len()
};
if visible_start < visible_end && visible_start < span_chars.len() {
let visible_chars: String = span_chars[visible_start..visible_end.min(span_chars.len())]
.iter()
.collect();
let processed_chars = if visible_chars.contains('\t') {
self.expand_tabs(&visible_chars, current_width)
} else {
visible_chars
};
if !processed_chars.is_empty() {
result.push_span(Span::styled(processed_chars, span.style));
}
}
}
current_char_index += span_chars.len();
current_width += usize::try_from(display_width(span_text)).unwrap();
}
if has_right_overflow {
result.push_span(Span::raw(&self.ellipsis));
}
result
}
fn char_index_at_width(&self, line: &Line<'_>, target_width: usize) -> usize {
let mut current_width = 0;
let mut char_index = 0;
for span in &line.spans {
for ch in span.content.chars() {
let ch_width = if ch == '\t' {
self.tabstop - (current_width % self.tabstop)
} else {
char_display_width(ch)
};
if current_width >= target_width {
return char_index;
}
current_width += ch_width;
char_index += 1;
}
}
char_index
}
fn expand_tabs(&self, text: &str, start_width: usize) -> String {
let mut result = String::new();
let mut current_width = start_width;
for ch in text.chars() {
if ch == '\t' {
let tab_width = self.tabstop - (current_width % self.tabstop);
result.push_str(&" ".repeat(tab_width));
current_width += tab_width;
} else {
result.push(ch);
current_width += char_display_width(ch)
}
}
result
}
pub fn toggle_at(&mut self, index: usize) {
if self.items.is_empty() {
return;
}
let item = &self.items[index];
trace!("Toggled item {} at index {}", item.text(), index);
toggle_item(&mut self.selection, item);
trace!(
"Selection is now {:#?}",
self.selection.iter().map(|item| item.item.text()).collect::<Vec<_>>()
);
}
pub fn toggle(&mut self) {
self.toggle_at(self.cursor());
}
pub fn toggle_all(&mut self) {
for item in &self.items {
toggle_item(&mut self.selection, item);
}
}
pub fn select(&mut self) {
debug!("{}", self.cursor());
self.select_row(self.cursor())
}
pub fn select_row(&mut self, index: usize) {
let item = self.items[index].clone();
self.selection.insert(item);
}
pub fn select_all(&mut self) {
for item in self.items.clone() {
self.selection.insert(item.clone());
}
}
pub fn clear_selection(&mut self) {
self.selection.clear();
}
pub fn clear(&mut self) {
self.items.clear();
self.selection.clear();
self.current = 0;
self.offset = 0;
self.showing_stale_items = false;
}
pub fn scroll_by(&mut self, offset: i32) {
if self.reserved >= self.items.len() {
return;
}
let reserved = self.reserved as i32;
let total = self.items.len() as i32;
let mut new = self.current as i32 + offset;
if self.cycle {
let n = total - reserved;
new = reserved + (new + n - reserved) % n;
} else {
new = new.min(self.items.len() as i32 - 1).max(self.reserved as i32);
}
self.current = new.max(0) as usize;
debug!("Scrolled to {}", self.current);
debug!("Selection: {:?}", self.selection);
}
pub fn select_previous(&mut self) {
self.scroll_by(-1);
}
pub fn select_next(&mut self) {
self.scroll_by(1);
}
pub fn jump_to_first(&mut self) {
if self.items.len() > self.reserved {
self.current = self.reserved;
}
}
pub fn jump_to_last(&mut self) {
if !self.items.is_empty() {
self.current = self.items.len().saturating_sub(1);
}
}
}
impl SkimWidget for ItemList {
fn from_options(options: &SkimOptions, theme: Arc<ColorTheme>) -> Self {
use crate::helper::selector::DefaultSkimSelector;
use crate::util::read_file_lines;
let skip_to_pattern = options
.skip_to_pattern
.as_ref()
.and_then(|pattern| Regex::new(pattern).ok());
let (selector, pre_select_target) = if options.pre_select_n > 0
|| !options.pre_select_pat.is_empty()
|| !options.pre_select_items.is_empty()
|| options.pre_select_file.is_some()
|| options.selector.is_some()
{
match options.selector.clone() {
Some(s) => {
(Some(s), usize::MAX)
}
None => {
let mut preset_items: Vec<String> = options
.pre_select_items
.split('\n')
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
.collect();
if let Some(ref pre_select_file) = options.pre_select_file
&& let Ok(file_items) = read_file_lines(pre_select_file)
{
preset_items.extend(file_items);
}
let selector = DefaultSkimSelector::default()
.first_n(options.pre_select_n)
.regex(&options.pre_select_pat)
.preset(preset_items.clone());
let target = if options.pre_select_n > 0 {
options.pre_select_n
} else {
usize::MAX };
(Some(Rc::new(selector) as Rc<dyn Selector>), target)
}
}
} else {
(None, 0)
};
let processed_items = Arc::new(SpinLock::new(None));
let interactive = options.interactive;
let no_clear_if_empty = options.no_clear_if_empty;
let multi_select = options.multi;
Self {
processed_items,
reserved: 0, direction: match options.layout {
TuiLayout::Default => ratatui::widgets::ListDirection::BottomToTop,
TuiLayout::Reverse | TuiLayout::ReverseList => ratatui::widgets::ListDirection::TopToBottom,
},
current: 0,
theme,
multi_select,
no_hscroll: options.no_hscroll,
ellipsis: options.ellipsis.clone(),
keep_right: options.keep_right,
skip_to_pattern,
tabstop: options.tabstop.max(1),
selector,
pre_select_target,
no_clear_if_empty,
interactive,
showing_stale_items: false,
manual_hscroll: 0,
items: Default::default(),
selection: Default::default(),
offset: Default::default(),
height: Default::default(),
selector_icon: options.selector_icon.clone(),
multi_select_icon: options.multi_select_icon.clone(),
cycle: options.cycle,
wrap: options.wrap_items,
border: options.border,
show_score: feature_flag!(options, ShowScore),
show_index: feature_flag!(options, ShowIndex),
}
}
fn render(&mut self, area: ratatui::prelude::Rect, buf: &mut ratatui::prelude::Buffer) -> SkimRender {
let this = &mut *self;
let inner_area = if this.border.is_some() {
ratatui::layout::Rect {
x: area.x + 1,
y: area.y + 1,
width: area.width.saturating_sub(2),
height: area.height.saturating_sub(2),
}
} else {
area
};
this.height = inner_area.height;
if this.current < this.offset {
this.offset = this.current;
} else if this.offset + inner_area.height as usize <= this.current {
this.offset = this.current - inner_area.height as usize + 1;
}
let initial_current = this.selected();
let items_updated = if let Some(processed) = this.processed_items.lock().take() {
debug!("Render: Got {} processed items", processed.items.len());
let items_are_empty_or_blank =
processed.items.is_empty() || processed.items.iter().all(|item| item.item.text().trim().is_empty());
if this.interactive && this.no_clear_if_empty && items_are_empty_or_blank && !this.items.is_empty() {
debug!(
"no_clear_if_empty: keeping {} old items for display (new items are empty/blank)",
this.items.len()
);
this.showing_stale_items = true;
} else {
match processed.merge {
MergeStrategy::Replace => {
this.items = processed.items;
}
MergeStrategy::SortedMerge => {
let existing = std::mem::take(&mut this.items);
this.items = MatchedItem::sorted_merge(existing, processed.items);
}
MergeStrategy::Append => {
this.items.extend(processed.items);
}
}
this.showing_stale_items = false;
if this.multi_select
&& let Some(selector) = &this.selector
&& this.selection.len() < this.pre_select_target
{
debug!(
"Applying pre-selection to {} items (currently {} selected, target {})",
this.items.len(),
this.selection.len(),
this.pre_select_target
);
for (index, item) in this.items.iter().enumerate() {
if this.selection.len() >= this.pre_select_target {
break;
}
let should_select = selector.should_select(index, item.item.as_ref());
if should_select {
debug!("Pre-selecting item[{}]: '{}'", index, item.item.text());
this.selection.insert(item.clone());
}
}
debug!("Pre-selected {} items total", this.selection.len());
}
}
true
} else {
false
};
let theme = &this.theme;
let selector_icon = &this.selector_icon;
let multi_select_icon = &this.multi_select_icon;
let wrap = &this.wrap;
let list = List::new(
this.items
.iter()
.enumerate()
.skip(this.offset)
.take(inner_area.height as usize)
.map(|(idx, item)| {
let is_current = idx == this.current;
let is_selected = this.selection.contains(item);
let container_width = (inner_area.width as usize)
.saturating_sub(selector_icon.chars().count() + multi_select_icon.chars().count());
let item_text = item.item.text();
let (match_start_char, match_end_char) = match &item.matched_range {
Some(MatchRange::Chars(matched_indices)) => {
if !matched_indices.is_empty() {
(matched_indices[0], matched_indices[matched_indices.len() - 1] + 1)
} else {
(0, 0)
}
}
Some(MatchRange::ByteRange(match_start, match_end)) => {
let match_start_char = item_text[..*match_start].chars().count();
let diff = item_text[*match_start..*match_end].chars().count();
(match_start_char, match_start_char + diff)
}
None => (0, 0),
};
let (shift, full_width, _has_left, _has_right) =
this.calc_hscroll(&item_text, container_width, match_start_char, match_end_char);
let matches = match &item.matched_range {
Some(MatchRange::ByteRange(start, end)) => crate::Matches::ByteRange(*start, *end),
Some(MatchRange::Chars(chars)) => crate::Matches::CharIndices(chars.clone()),
None => crate::Matches::None,
};
let mut display_line = item.item.display(DisplayContext {
score: item.rank.score,
matches,
container_width,
base_style: if is_current { theme.current } else { theme.normal },
matched_syle: if is_current { theme.current_match } else { theme.matched },
});
if !wrap {
display_line = this.apply_hscroll(display_line, shift, container_width, full_width);
}
let mut spans: Vec<Span> = Vec::with_capacity(3 + display_line.spans.len());
spans.push(Span::styled(
if is_current {
selector_icon.to_owned()
} else {
str::repeat(" ", selector_icon.chars().count())
},
theme.cursor,
));
spans.push(Span::styled(
if this.multi_select && is_selected {
multi_select_icon.to_owned()
} else {
str::repeat(" ", multi_select_icon.chars().count())
},
theme.selected,
));
if this.show_score {
let score = item.rank.score;
spans.push(Span::styled(
format!("[{score}] "),
if is_current { theme.current } else { theme.normal },
));
}
if this.show_index {
let index = item.rank.index;
spans.push(Span::styled(
format!("[{index}] "),
if is_current { theme.current } else { theme.normal },
));
}
spans.extend(display_line.spans);
if *wrap {
wrap_text(ratatui::text::Text::from(Line::from(spans)), inner_area.width.into()).into()
} else {
Line::from(spans).into()
}
})
.collect::<Vec<ListItem>>(),
)
.direction(this.direction)
.style(this.theme.normal);
Widget::render(Clear, area, buf);
if let Some(border_type) = this.border {
let block = Block::default()
.borders(Borders::ALL)
.border_type(border_type.into())
.border_style(this.theme.border);
Widget::render(block, area, buf);
}
StatefulWidget::render(
list,
inner_area,
buf,
&mut ListState::default().with_selected(Some(this.current.saturating_sub(this.offset))),
);
let run_preview = if let Some(curr) = self.selected()
&& let Some(prev) = initial_current
{
curr.text() != prev.text()
} else {
self.selected().is_some() != initial_current.is_some()
};
SkimRender {
items_updated,
run_preview,
}
}
}
fn toggle_item(sel: &mut IndexSet<MatchedItem>, item: &MatchedItem) {
if sel.contains(item) {
sel.shift_remove(item);
} else {
sel.insert(item.clone());
}
}