use crate::components::{
visibility_blocking, CommandBlocking, CommandInfo, Component,
DrawableComponent, EventState, FuzzyFinderTarget, InputType,
ScrollType, TextInputComponent,
};
use crate::{
app::Environment,
keys::{key_match, SharedKeyConfig},
queue::{InternalEvent, Queue},
string_utils::trim_length_left,
strings,
ui::{self, style::SharedTheme},
};
use anyhow::Result;
use crossterm::event::Event;
use fuzzy_matcher::FuzzyMatcher;
use ratatui::{
layout::{Constraint, Direction, Layout, Margin, Rect},
text::{Line, Span},
widgets::{Block, Borders, Clear},
Frame,
};
use std::borrow::Cow;
use unicode_segmentation::UnicodeSegmentation;
pub struct FuzzyFindPopup {
queue: Queue,
visible: bool,
find_text: TextInputComponent,
query: Option<String>,
theme: SharedTheme,
contents: Vec<String>,
selection: usize,
selected_index: Option<usize>,
filtered: Vec<(usize, Vec<usize>)>,
key_config: SharedKeyConfig,
target: Option<FuzzyFinderTarget>,
}
impl FuzzyFindPopup {
pub fn new(env: &Environment) -> Self {
let mut find_text =
TextInputComponent::new(env, "", "start typing..", false)
.with_input_type(InputType::Singleline);
find_text.embed();
Self {
queue: env.queue.clone(),
visible: false,
query: None,
find_text,
theme: env.theme.clone(),
contents: Vec::new(),
filtered: Vec::new(),
selected_index: None,
key_config: env.key_config.clone(),
selection: 0,
target: None,
}
}
fn update_query(&mut self) {
if self.find_text.get_text().is_empty() {
self.set_query(None);
} else if self
.query
.as_ref()
.is_none_or(|q| q != self.find_text.get_text())
{
self.set_query(Some(
self.find_text.get_text().to_string(),
));
}
}
fn set_query(&mut self, query: Option<String>) {
self.query = query;
self.filtered.clear();
if let Some(q) = &self.query {
let matcher =
fuzzy_matcher::skim::SkimMatcherV2::default();
let mut contents = self
.contents
.iter()
.enumerate()
.filter_map(|a| {
matcher
.fuzzy_indices(a.1, q)
.map(|(score, indices)| (score, a.0, indices))
})
.collect::<Vec<(_, _, _)>>();
contents.sort_by(|(score1, _, _), (score2, _, _)| {
score2.cmp(score1)
});
self.filtered.extend(
contents.into_iter().map(|entry| (entry.1, entry.2)),
);
}
self.selection = 0;
self.refresh_selection();
}
fn refresh_selection(&mut self) {
let selection =
self.filtered.get(self.selection).map(|a| a.0);
if self.selected_index != selection {
self.selected_index = selection;
if let Some(idx) = self.selected_index {
if let Some(target) = self.target {
self.queue.push(
InternalEvent::FuzzyFinderChanged(
idx,
self.contents[idx].clone(),
target,
),
);
}
}
}
}
pub fn open(
&mut self,
contents: Vec<String>,
target: FuzzyFinderTarget,
) -> Result<()> {
self.show()?;
self.find_text.show()?;
self.find_text.set_text(String::new());
self.query = None;
self.target = Some(target);
if self.contents != contents {
self.contents = contents;
}
self.update_query();
Ok(())
}
fn move_selection(&mut self, move_type: ScrollType) -> bool {
let new_selection = match move_type {
ScrollType::Up => self.selection.saturating_sub(1),
ScrollType::Down => self.selection.saturating_add(1),
_ => self.selection,
};
let new_selection = new_selection
.clamp(0, self.filtered.len().saturating_sub(1));
if new_selection != self.selection {
self.selection = new_selection;
self.refresh_selection();
return true;
}
false
}
#[inline]
fn draw_matches_list(&self, f: &mut Frame, mut area: Rect) {
{
const HEIGHT_BLOCK_MARGIN: usize = 2;
let title = format!("Hits: {}", self.filtered.len());
let height = usize::from(area.height);
let width = usize::from(area.width);
let list_height =
height.saturating_sub(HEIGHT_BLOCK_MARGIN);
let scroll_skip =
self.selection.saturating_sub(list_height);
let items = self
.filtered
.iter()
.skip(scroll_skip)
.take(height)
.map(|(idx, indices)| {
let selected = self
.selected_index
.is_some_and(|index| index == *idx);
let full_text =
trim_length_left(&self.contents[*idx], width);
let trim_length =
self.contents[*idx].graphemes(true).count()
- full_text.graphemes(true).count();
Line::from(
full_text
.graphemes(true)
.enumerate()
.map(|(c_idx, c)| {
Span::styled(
Cow::from(c.to_string()),
self.theme.text(
selected,
indices.contains(
&(c_idx + trim_length),
),
),
)
})
.collect::<Vec<_>>(),
)
});
ui::draw_list_block(
f,
area,
Block::default()
.title(Span::styled(
title,
self.theme.title(true),
))
.borders(Borders::TOP),
items,
);
if self.filtered.len() > list_height {
area.width += 1;
area.height += 1;
ui::draw_scrollbar(
f,
area,
&self.theme,
self.filtered.len().saturating_sub(1),
self.selection,
ui::Orientation::Vertical,
);
}
}
}
}
impl DrawableComponent for FuzzyFindPopup {
fn draw(&self, f: &mut Frame, area: Rect) -> Result<()> {
if self.is_visible() {
const MAX_SIZE: (u16, u16) = (50, 20);
let any_hits = !self.filtered.is_empty();
let area = ui::centered_rect_absolute(
MAX_SIZE.0, MAX_SIZE.1, area,
);
let area = if any_hits {
area
} else {
Layout::default()
.direction(Direction::Vertical)
.constraints(
[
Constraint::Length(3),
Constraint::Percentage(100),
]
.as_ref(),
)
.split(area)[0]
};
f.render_widget(Clear, area);
f.render_widget(
Block::default()
.borders(Borders::all())
.style(self.theme.title(true))
.title(Span::styled(
strings::POPUP_TITLE_FUZZY_FIND,
self.theme.title(true),
)),
area,
);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints(
[
Constraint::Length(1),
Constraint::Percentage(100),
]
.as_ref(),
)
.split(area.inner(Margin {
horizontal: 1,
vertical: 1,
}));
self.find_text.draw(f, chunks[0])?;
if any_hits {
self.draw_matches_list(f, chunks[1]);
}
}
Ok(())
}
}
impl Component for FuzzyFindPopup {
fn commands(
&self,
out: &mut Vec<CommandInfo>,
force_all: bool,
) -> CommandBlocking {
if self.is_visible() || force_all {
out.push(CommandInfo::new(
strings::commands::scroll_popup(&self.key_config),
true,
true,
));
out.push(CommandInfo::new(
strings::commands::close_fuzzy_finder(
&self.key_config,
),
true,
true,
));
}
visibility_blocking(self)
}
fn event(
&mut self,
event: &crossterm::event::Event,
) -> Result<EventState> {
if self.is_visible() {
if let Event::Key(key) = event {
if key_match(key, self.key_config.keys.exit_popup)
|| key_match(key, self.key_config.keys.enter)
{
self.hide();
} else if key_match(
key,
self.key_config.keys.popup_down,
) {
self.move_selection(ScrollType::Down);
} else if key_match(
key,
self.key_config.keys.popup_up,
) {
self.move_selection(ScrollType::Up);
}
}
if self.find_text.event(event)?.is_consumed() {
self.update_query();
}
return Ok(EventState::Consumed);
}
Ok(EventState::NotConsumed)
}
fn is_visible(&self) -> bool {
self.visible
}
fn hide(&mut self) {
self.visible = false;
}
fn show(&mut self) -> Result<()> {
self.visible = true;
Ok(())
}
}