use super::{
visibility_blocking, CommandBlocking, CommandInfo, Component,
DrawableComponent, EventState, ScrollType, TextInputComponent,
};
use crate::{
keys::{key_match, SharedKeyConfig},
queue::{InternalEvent, Queue},
string_utils::trim_length_left,
strings,
ui::{self, style::SharedTheme},
};
use anyhow::Result;
use asyncgit::sync::TreeFile;
use crossterm::event::Event;
use fuzzy_matcher::FuzzyMatcher;
use std::borrow::Cow;
use tui::{
backend::Backend,
layout::{Constraint, Direction, Layout, Margin, Rect},
text::{Span, Spans},
widgets::{Block, Borders, Clear},
Frame,
};
pub struct FileFindPopup {
queue: Queue,
visible: bool,
find_text: TextInputComponent,
query: Option<String>,
theme: SharedTheme,
files: Vec<TreeFile>,
selection: usize,
selected_index: Option<usize>,
files_filtered: Vec<(usize, Vec<usize>)>,
key_config: SharedKeyConfig,
}
impl FileFindPopup {
pub fn new(
queue: &Queue,
theme: SharedTheme,
key_config: SharedKeyConfig,
) -> Self {
let mut find_text = TextInputComponent::new(
theme.clone(),
key_config.clone(),
"",
"start typing..",
false,
);
find_text.embed();
Self {
queue: queue.clone(),
visible: false,
query: None,
find_text,
theme,
files: Vec::new(),
files_filtered: Vec::new(),
selected_index: None,
key_config,
selection: 0,
}
}
fn update_query(&mut self) {
if self.find_text.get_text().is_empty() {
self.set_query(None);
} else if self
.query
.as_ref()
.map_or(true, |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.files_filtered.clear();
if let Some(q) = &self.query {
let matcher =
fuzzy_matcher::skim::SkimMatcherV2::default();
let mut files = self
.files
.iter()
.enumerate()
.filter_map(|a| {
a.1.path.to_str().and_then(|path| {
matcher.fuzzy_indices(path, q).map(
|(score, indices)| (score, a.0, indices),
)
})
})
.collect::<Vec<(_, _, _)>>();
files.sort_by(|(score1, _, _), (score2, _, _)| {
score2.cmp(score1)
});
self.files_filtered.extend(
files.into_iter().map(|entry| (entry.1, entry.2)),
);
}
self.selection = 0;
self.refresh_selection();
}
fn refresh_selection(&mut self) {
let selection =
self.files_filtered.get(self.selection).map(|a| a.0);
if self.selected_index != selection {
self.selected_index = selection;
let file = self
.selected_index
.and_then(|index| self.files.get(index))
.map(|f| f.path.clone());
self.queue.push(InternalEvent::FileFinderChanged(file));
}
}
pub fn open(&mut self, files: &[TreeFile]) -> Result<()> {
self.show()?;
self.find_text.show()?;
self.find_text.set_text(String::new());
self.query = None;
if self.files != *files {
self.files = files.to_owned();
}
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.files_filtered.len().saturating_sub(1));
if new_selection != self.selection {
self.selection = new_selection;
self.refresh_selection();
return true;
}
false
}
}
impl DrawableComponent for FileFindPopup {
fn draw<B: Backend>(
&self,
f: &mut Frame<B>,
area: Rect,
) -> Result<()> {
if self.is_visible() {
const MAX_SIZE: (u16, u16) = (50, 20);
let any_hits = !self.files_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 {
let title =
format!("Hits: {}", self.files_filtered.len());
let height = usize::from(chunks[1].height);
let width = usize::from(chunks[1].width);
let items = self
.files_filtered
.iter()
.take(height)
.map(|(idx, indicies)| {
let selected = self
.selected_index
.map_or(false, |index| index == *idx);
let full_text = trim_length_left(
self.files[*idx]
.path
.to_str()
.unwrap_or_default(),
width,
);
Spans::from(
full_text
.char_indices()
.map(|(c_idx, c)| {
Span::styled(
Cow::from(c.to_string()),
self.theme.text(
selected,
indicies.contains(&c_idx),
),
)
})
.collect::<Vec<_>>(),
)
});
ui::draw_list_block(
f,
chunks[1],
Block::default()
.title(Span::styled(
title,
self.theme.title(true),
))
.borders(Borders::TOP),
items,
);
}
}
Ok(())
}
}
impl Component for FileFindPopup {
fn commands(
&self,
out: &mut Vec<CommandInfo>,
force_all: bool,
) -> CommandBlocking {
if self.is_visible() || force_all {
out.push(
CommandInfo::new(
strings::commands::close_popup(&self.key_config),
true,
true,
)
.order(1),
);
out.push(CommandInfo::new(
strings::commands::scroll(&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(())
}
}