use std::{rc::Rc, sync::Arc};
use indexmap::IndexSet;
use ratatui::widgets::{
Block, Borders, Clear, List, ListDirection, ListItem, Scrollbar, ScrollbarOrientation, ScrollbarState,
StatefulWidget, Widget,
};
use regex::Regex;
use crate::options::feature_flag;
use crate::{
Selector, SkimOptions,
item::MatchedItem,
spinlock::SpinLock,
theme::ColorTheme,
tui::BorderType,
tui::item_renderer::ItemRenderer,
tui::options::TuiLayout,
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,
sub_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,
pub(crate) no_hscroll: bool,
pub(crate) ellipsis: String,
pub(crate) keep_right: bool,
pub(crate) skip_to_pattern: Option<Regex>,
pub(crate) 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, pub(crate) selector_icon: String,
pub(crate) multi_select_icon: String,
cycle: bool,
pub(crate) wrap: bool,
pub(crate) multiline: Option<String>,
pub border: BorderType,
pub(crate) show_score: bool,
pub(crate) show_index: bool,
pub(crate) highlight_line: bool,
pub(crate) scrollbar_thumb: String,
}
impl Default for ItemList {
fn default() -> Self {
Self::_default()
}
}
impl ItemList {
fn cursor(&self) -> usize {
self.current
}
#[must_use]
pub fn count(&self) -> usize {
if self.showing_stale_items { 0 } else { self.items.len() }
}
#[must_use]
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;
}
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.sub_offset = 0;
self.showing_stale_items = false;
}
pub fn scroll_by_rows(&mut self, rows: i32) {
if self.reserved >= self.items.len() || rows == 0 {
return;
}
let total = self.items.len();
let reserved = self.reserved;
if rows > 0 {
let mut remaining = rows.unsigned_abs() as usize;
let mut idx = self.current;
while remaining > 0 && idx + 1 < total {
idx += 1;
let row_count = self.item_row_count(idx);
remaining = remaining.saturating_sub(row_count);
}
self.current = idx;
} else {
let mut remaining = rows.unsigned_abs() as usize;
let mut idx = self.current;
while remaining > 0 && idx > reserved {
let row_count = self.item_row_count(idx);
remaining = remaining.saturating_sub(row_count);
idx -= 1;
}
self.current = idx.max(reserved);
}
}
pub fn scroll_by(&mut self, offset: i32) {
if self.reserved >= self.items.len() {
return;
}
let reserved = i32::try_from(self.reserved).unwrap_or(i32::MAX);
let total = i32::try_from(self.items.len()).unwrap_or(i32::MAX);
let mut new = i32::try_from(self.current).unwrap_or(i32::MAX) + offset;
if self.cycle {
let n = total - reserved;
new = reserved + (new + n - reserved) % n;
} else {
new = new
.min(i32::try_from(self.items.len()).unwrap_or(i32::MAX) - 1)
.max(i32::try_from(self.reserved).unwrap_or(i32::MAX));
}
self.current = new.max(0).unsigned_abs() 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;
self.offset = self.reserved;
self.sub_offset = 0;
}
}
#[must_use]
pub fn item_at_visual_row(&self, row: usize) -> Option<usize> {
let available_rows = self.height as usize;
if row >= available_rows || self.items.is_empty() {
return None;
}
let flat_row = match self.direction {
ListDirection::BottomToTop => available_rows - 1 - row,
ListDirection::TopToBottom => row,
};
let mut rows_counted = 0;
for idx in self.offset..self.items.len() {
let item_rows = if idx == self.offset {
self.item_row_count(idx).saturating_sub(self.sub_offset)
} else {
self.item_row_count(idx)
};
rows_counted += item_rows;
if rows_counted > flat_row {
return Some(idx);
}
if rows_counted >= available_rows {
break;
}
}
None
}
pub fn jump_to_last(&mut self) {
if !self.items.is_empty() {
self.current = self.items.len().saturating_sub(1);
self.sub_offset = 0;
}
}
fn item_row_count(&self, index: usize) -> usize {
if let Some(sep) = self.multiline.as_deref()
&& let Some(item) = self.items.get(index)
{
item.item.text().split(sep).count().max(1)
} else {
1
}
}
fn rows_for_range(&self, from: usize, count: usize) -> usize {
(from..from + count).map(|i| self.item_row_count(i)).sum()
}
fn rows_visible(&self, offset: usize, sub_offset: usize, current: usize) -> usize {
if offset > current {
return 0;
}
let top_rows = self.item_row_count(offset).saturating_sub(sub_offset);
if offset == current {
return top_rows;
}
top_rows + self.rows_for_range(offset + 1, current - offset)
}
fn advance_to_fit(&self, current: usize, available_rows: usize) -> (usize, usize) {
let mut offset = self.offset;
let mut sub_offset = self.sub_offset;
while offset < current && self.rows_visible(offset, sub_offset, current) > available_rows {
let top_rows = self.item_row_count(offset);
if sub_offset + 1 < top_rows {
sub_offset += 1;
} else {
offset += 1;
sub_offset = 0;
}
}
(offset, sub_offset)
}
}
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()
{
if let Some(s) = options.selector.clone() {
(Some(s), usize::MAX)
} else {
let mut preset_items: Vec<String> = options
.pre_select_items
.split('\n')
.filter(|s| !s.is_empty())
.map(std::string::ToString::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(),
sub_offset: 0,
height: Default::default(),
selector_icon: options.selector_icon.clone(),
multi_select_icon: options.multi_select_icon.clone(),
cycle: options.cycle,
wrap: options.wrap_items,
multiline: options
.multiline
.clone()
.map(|opt_m| opt_m.unwrap_or(String::from("\\n"))),
border: options.border,
show_score: feature_flag!(options, ShowScore),
show_index: feature_flag!(options, ShowIndex),
highlight_line: options.highlight_line,
scrollbar_thumb: options.scrollbar.clone(),
}
}
#[allow(clippy::too_many_lines)]
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;
let available_rows = inner_area.height as usize;
if this.items.is_empty() {
this.current = 0;
this.offset = 0;
} else {
this.current = this.current.min(this.items.len() - 1).max(this.reserved);
}
if this.current < this.offset {
this.offset = this.current;
this.sub_offset = 0;
} else if this.rows_visible(this.offset, this.sub_offset, this.current) > available_rows {
(this.offset, this.sub_offset) = this.advance_to_fit(this.current, available_rows);
}
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;
this.sub_offset = 0;
}
MergeStrategy::SortedMerge => {
let existing = std::mem::take(&mut this.items);
this.items = MatchedItem::sorted_merge(existing, processed.items);
this.sub_offset = 0;
}
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 icon_width = this.selector_icon.chars().count() + this.multi_select_icon.chars().count();
let container_width = (inner_area.width as usize).saturating_sub(icon_width);
let sub_offset = this.sub_offset;
let renderer = ItemRenderer::new_for(this, container_width);
let mut flat_rows: Vec<ListItem<'static>> = Vec::with_capacity(available_rows + 1);
let mut rows_used = 0usize;
for (idx, item) in this.items.iter().enumerate().skip(this.offset) {
if rows_used >= available_rows {
break;
}
let is_current = idx == this.current;
let is_selected = this.selection.contains(item);
let skip_subs = if idx == this.offset { sub_offset } else { 0 };
rows_used += renderer.render_item(
item,
is_current,
is_selected,
skip_subs,
available_rows,
rows_used,
&mut flat_rows,
);
}
let list = List::new(flat_rows).direction(this.direction).style(this.theme.normal);
Widget::render(Clear, area, buf);
if let Some(border_type) = this.border.into_ratatui() {
let block = Block::default()
.borders(Borders::ALL)
.border_type(border_type)
.border_style(this.theme.border);
Widget::render(block, area, buf);
}
Widget::render(list, inner_area, buf);
if !this.scrollbar_thumb.is_empty() && this.items.len() > available_rows {
let mut scrollbar_state = ScrollbarState::new(this.items.len()).position(this.current);
let scrollbar: Scrollbar<'_> = Scrollbar::new(ScrollbarOrientation::VerticalRight)
.thumb_symbol(&self.scrollbar_thumb)
.track_symbol(None)
.begin_symbol(None)
.end_symbol(None);
StatefulWidget::render(scrollbar, inner_area, buf, &mut scrollbar_state);
}
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());
}
}