Merge fuzzy launcher search
This commit is contained in:
Generated
+1
-1
@@ -436,7 +436,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tui2"
|
||||
version = "0.1.5"
|
||||
version = "0.1.6"
|
||||
dependencies = [
|
||||
"crossterm",
|
||||
"ratatui",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "tui2"
|
||||
version = "0.1.5"
|
||||
version = "0.1.6"
|
||||
edition = "2024"
|
||||
authors = ["Austin"]
|
||||
description = "A terminal UI launcher for terminal UI apps"
|
||||
|
||||
@@ -12,6 +12,7 @@ cargo run
|
||||
|
||||
| Key | Action |
|
||||
| --- | --- |
|
||||
| `/` | Search launchers |
|
||||
| `j` / Down | Move down |
|
||||
| `k` / Up | Move up |
|
||||
| Enter | Launch selected TUI |
|
||||
@@ -20,6 +21,9 @@ cargo run
|
||||
| `r` | Rescan detected and saved launchers |
|
||||
| `q` / Esc | Quit |
|
||||
|
||||
Search mode fuzzy-matches launcher names, commands, and descriptions. Press
|
||||
Backspace to edit the search and Esc to clear it.
|
||||
|
||||
## How Launchers Are Chosen
|
||||
|
||||
Launchers are sorted in this order:
|
||||
|
||||
+206
-17
@@ -13,7 +13,9 @@ use crate::{
|
||||
|
||||
pub struct App {
|
||||
pub(crate) items: Vec<LauncherItem>,
|
||||
pub(crate) visible_items: Vec<usize>,
|
||||
pub(crate) selected: usize,
|
||||
pub(crate) search_query: String,
|
||||
pub(crate) status: String,
|
||||
pub(crate) mode: AppMode,
|
||||
should_quit: bool,
|
||||
@@ -23,6 +25,7 @@ impl App {
|
||||
pub fn new() -> Self {
|
||||
let custom_items = load_custom_items().unwrap_or_else(|_| Vec::new());
|
||||
let items = build_launcher_items(custom_items);
|
||||
let visible_items = (0..items.len()).collect();
|
||||
let status = format!(
|
||||
"Found {} launcher{}.",
|
||||
items.len(),
|
||||
@@ -31,7 +34,9 @@ impl App {
|
||||
|
||||
Self {
|
||||
items,
|
||||
visible_items,
|
||||
selected: 0,
|
||||
search_query: String::new(),
|
||||
status,
|
||||
mode: AppMode::Normal,
|
||||
should_quit: false,
|
||||
@@ -55,6 +60,7 @@ impl App {
|
||||
fn handle_key(&mut self, key: KeyEvent, terminal: &mut Tui) -> io::Result<()> {
|
||||
match self.mode {
|
||||
AppMode::Normal => self.handle_normal_key(key, terminal),
|
||||
AppMode::Searching => self.handle_search_key(key, terminal),
|
||||
AppMode::Adding(_) => self.handle_add_key(key),
|
||||
}
|
||||
}
|
||||
@@ -62,8 +68,9 @@ impl App {
|
||||
fn handle_normal_key(&mut self, key: KeyEvent, terminal: &mut Tui) -> io::Result<()> {
|
||||
match key.code {
|
||||
KeyCode::Char('q') | KeyCode::Esc => self.should_quit = true,
|
||||
KeyCode::Char('j') | KeyCode::Down => self.select_next(),
|
||||
KeyCode::Char('k') | KeyCode::Up => self.select_previous(),
|
||||
KeyCode::Down => self.select_next(),
|
||||
KeyCode::Up => self.select_previous(),
|
||||
KeyCode::Char('/') => self.start_search(),
|
||||
KeyCode::Char('a') => self.start_add_form(),
|
||||
KeyCode::Char('f') => self.toggle_selected_favorite(),
|
||||
KeyCode::Char('r') => self.reload_items(),
|
||||
@@ -74,6 +81,28 @@ impl App {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_search_key(&mut self, key: KeyEvent, terminal: &mut Tui) -> io::Result<()> {
|
||||
match key.code {
|
||||
KeyCode::Esc => self.clear_search(),
|
||||
KeyCode::Enter => self.launch_selected(terminal)?,
|
||||
KeyCode::Backspace => {
|
||||
self.search_query.pop();
|
||||
self.rebuild_visible_items(None);
|
||||
self.update_search_status();
|
||||
}
|
||||
KeyCode::Char('j') | KeyCode::Down => self.select_next(),
|
||||
KeyCode::Char('k') | KeyCode::Up => self.select_previous(),
|
||||
KeyCode::Char(character) => {
|
||||
self.search_query.push(character);
|
||||
self.rebuild_visible_items(None);
|
||||
self.update_search_status();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_add_key(&mut self, key: KeyEvent) -> io::Result<()> {
|
||||
match key.code {
|
||||
KeyCode::Esc => {
|
||||
@@ -98,23 +127,35 @@ impl App {
|
||||
}
|
||||
|
||||
fn select_next(&mut self) {
|
||||
if !self.items.is_empty() {
|
||||
self.selected = (self.selected + 1) % self.items.len();
|
||||
if !self.visible_items.is_empty() {
|
||||
self.selected = (self.selected + 1) % self.visible_items.len();
|
||||
}
|
||||
}
|
||||
|
||||
fn select_previous(&mut self) {
|
||||
if self.items.is_empty() {
|
||||
if self.visible_items.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
if self.selected == 0 {
|
||||
self.selected = self.items.len() - 1;
|
||||
self.selected = self.visible_items.len() - 1;
|
||||
} else {
|
||||
self.selected -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
fn start_search(&mut self) {
|
||||
self.mode = AppMode::Searching;
|
||||
self.status = "Search launchers.".to_string();
|
||||
}
|
||||
|
||||
fn clear_search(&mut self) {
|
||||
self.search_query.clear();
|
||||
self.mode = AppMode::Normal;
|
||||
self.rebuild_visible_items(None);
|
||||
self.status = format!("Showing all {} launcher(s).", self.items.len());
|
||||
}
|
||||
|
||||
fn start_add_form(&mut self) {
|
||||
self.mode = AppMode::Adding(AddForm::default());
|
||||
self.status = "Adding a launcher.".to_string();
|
||||
@@ -144,8 +185,9 @@ impl App {
|
||||
|
||||
match save_custom_items(&custom_items) {
|
||||
Ok(()) => {
|
||||
let command = item.command.clone();
|
||||
self.items.push(item);
|
||||
self.selected = self.items.len() - 1;
|
||||
self.rebuild_visible_items(Some(&command));
|
||||
self.mode = AppMode::Normal;
|
||||
self.status = format!("Saved launcher to {}.", config_file_path().display());
|
||||
}
|
||||
@@ -156,25 +198,61 @@ impl App {
|
||||
}
|
||||
|
||||
fn reload_items(&mut self) {
|
||||
let selected_command = self
|
||||
.items
|
||||
.get(self.selected)
|
||||
.map(|item| item.command.clone());
|
||||
let selected_command = self.selected_item().map(|item| item.command.clone());
|
||||
self.reload_items_keeping(selected_command.as_deref());
|
||||
self.status = format!("Rescanned and found {} launcher(s).", self.items.len());
|
||||
self.status = if self.search_query.is_empty() {
|
||||
format!("Rescanned and found {} launcher(s).", self.items.len())
|
||||
} else {
|
||||
format!(
|
||||
"Rescanned and found {} launcher(s), {} matching search.",
|
||||
self.items.len(),
|
||||
self.visible_items.len()
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
fn reload_items_keeping(&mut self, selected_command: Option<&str>) {
|
||||
let custom_items = load_custom_items().unwrap_or_else(|_| Vec::new());
|
||||
self.items = build_launcher_items(custom_items);
|
||||
self.rebuild_visible_items(selected_command);
|
||||
}
|
||||
|
||||
self.selected = selected_command
|
||||
.and_then(|command| self.items.iter().position(|item| item.command == command))
|
||||
.unwrap_or_else(|| self.selected.min(self.items.len().saturating_sub(1)));
|
||||
fn rebuild_visible_items(&mut self, selected_command: Option<&str>) {
|
||||
let previous_command = selected_command
|
||||
.map(str::to_string)
|
||||
.or_else(|| self.selected_item().map(|item| item.command.clone()));
|
||||
self.visible_items = matching_item_indices(&self.items, &self.search_query);
|
||||
self.selected = previous_command
|
||||
.and_then(|command| {
|
||||
self.visible_items
|
||||
.iter()
|
||||
.position(|&index| self.items[index].command == command)
|
||||
})
|
||||
.unwrap_or_else(|| {
|
||||
self.selected
|
||||
.min(self.visible_items.len().saturating_sub(1))
|
||||
});
|
||||
}
|
||||
|
||||
fn update_search_status(&mut self) {
|
||||
if self.search_query.is_empty() {
|
||||
self.status = format!("Search cleared. Showing {} launcher(s).", self.items.len());
|
||||
} else {
|
||||
self.status = format!(
|
||||
"Search: {} ({} match{})",
|
||||
self.search_query,
|
||||
self.visible_items.len(),
|
||||
if self.visible_items.len() == 1 {
|
||||
""
|
||||
} else {
|
||||
"es"
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn toggle_selected_favorite(&mut self) {
|
||||
let Some(item) = self.items.get(self.selected).cloned() else {
|
||||
let Some(item) = self.selected_item().cloned() else {
|
||||
self.status = "Nothing to favorite. Press a to add a TUI.".to_string();
|
||||
return;
|
||||
};
|
||||
@@ -196,7 +274,7 @@ impl App {
|
||||
}
|
||||
|
||||
fn launch_selected(&mut self, terminal: &mut Tui) -> io::Result<()> {
|
||||
let Some(item) = self.items.get(self.selected).cloned() else {
|
||||
let Some(item) = self.selected_item().cloned() else {
|
||||
self.status = "Nothing to launch. Press a to add a TUI.".to_string();
|
||||
return Ok(());
|
||||
};
|
||||
@@ -217,13 +295,74 @@ impl App {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn selected_item(&self) -> Option<&LauncherItem> {
|
||||
let item_index = *self.visible_items.get(self.selected)?;
|
||||
self.items.get(item_index)
|
||||
}
|
||||
|
||||
pub(crate) fn visible_items(&self) -> impl Iterator<Item = &LauncherItem> {
|
||||
self.visible_items
|
||||
.iter()
|
||||
.filter_map(|&item_index| self.items.get(item_index))
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) enum AppMode {
|
||||
Normal,
|
||||
Searching,
|
||||
Adding(AddForm),
|
||||
}
|
||||
|
||||
fn matching_item_indices(items: &[LauncherItem], query: &str) -> Vec<usize> {
|
||||
let query = query.trim();
|
||||
if query.is_empty() {
|
||||
return (0..items.len()).collect();
|
||||
}
|
||||
|
||||
let mut matches: Vec<(usize, i32)> = items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(index, item)| fuzzy_score(item, query).map(|score| (index, score)))
|
||||
.collect();
|
||||
|
||||
matches.sort_by(|left, right| right.1.cmp(&left.1).then(left.0.cmp(&right.0)));
|
||||
matches.into_iter().map(|(index, _)| index).collect()
|
||||
}
|
||||
|
||||
fn fuzzy_score(item: &LauncherItem, query: &str) -> Option<i32> {
|
||||
[
|
||||
(&item.name, 300),
|
||||
(&item.command, 200),
|
||||
(&item.description, 100),
|
||||
]
|
||||
.into_iter()
|
||||
.filter_map(|(value, base_score)| fuzzy_text_score(value, query, base_score))
|
||||
.max()
|
||||
}
|
||||
|
||||
fn fuzzy_text_score(text: &str, query: &str, base_score: i32) -> Option<i32> {
|
||||
let query = query.to_lowercase();
|
||||
let text = text.to_lowercase();
|
||||
let mut positions = Vec::new();
|
||||
let mut text_chars = text.chars().enumerate();
|
||||
|
||||
for query_char in query.chars() {
|
||||
let (position, _) = text_chars.find(|(_, text_char)| *text_char == query_char)?;
|
||||
positions.push(position as i32);
|
||||
}
|
||||
|
||||
let start = *positions.first()?;
|
||||
let gaps: i32 = positions.windows(2).map(|pair| pair[1] - pair[0] - 1).sum();
|
||||
let contiguous_bonus: i32 = positions
|
||||
.windows(2)
|
||||
.filter(|pair| pair[1] == pair[0] + 1)
|
||||
.count() as i32
|
||||
* 15;
|
||||
|
||||
Some(base_score + contiguous_bonus - start - gaps)
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct AddForm {
|
||||
pub(crate) name: String,
|
||||
@@ -267,3 +406,53 @@ pub(crate) enum AddStep {
|
||||
Command,
|
||||
Description,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn item(name: &str, command: &str, description: &str) -> LauncherItem {
|
||||
LauncherItem::custom(name, command, description)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fuzzy_search_matches_non_contiguous_characters() {
|
||||
let items = vec![
|
||||
item("Neovim", "nvim", "Edit files"),
|
||||
item("Lazygit", "lazygit", "Git dashboard"),
|
||||
item("Bottom", "btm", "System monitor"),
|
||||
];
|
||||
|
||||
let matches = matching_item_indices(&items, "lg");
|
||||
|
||||
assert_eq!(matches, vec![1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fuzzy_search_prefers_name_matches_over_description_matches() {
|
||||
let items = vec![
|
||||
item("GitUI", "gitui", "Open a keyboard-driven Git interface"),
|
||||
item(
|
||||
"Lazy Docker",
|
||||
"lazydocker",
|
||||
"Open a dashboard for Git-adjacent workflows",
|
||||
),
|
||||
];
|
||||
|
||||
let matches = matching_item_indices(&items, "git");
|
||||
|
||||
assert_eq!(matches[0], 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_search_keeps_existing_order() {
|
||||
let items = vec![
|
||||
item("Neovim", "nvim", "Edit files"),
|
||||
item("Lazygit", "lazygit", "Git dashboard"),
|
||||
];
|
||||
|
||||
let matches = matching_item_indices(&items, "");
|
||||
|
||||
assert_eq!(matches, vec![0, 1]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,8 +49,7 @@ fn draw_header(frame: &mut Frame, area: Rect) {
|
||||
|
||||
fn draw_launcher_list(frame: &mut Frame, area: Rect, app: &App) {
|
||||
let list_items: Vec<ListItem> = app
|
||||
.items
|
||||
.iter()
|
||||
.visible_items()
|
||||
.map(|item| {
|
||||
let favorite = if item.is_favorite { "[fav] " } else { "" };
|
||||
ListItem::new(Line::from(vec![
|
||||
@@ -73,8 +72,19 @@ fn draw_launcher_list(frame: &mut Frame, area: Rect, app: &App) {
|
||||
})
|
||||
.collect();
|
||||
|
||||
let title = if app.search_query.is_empty() {
|
||||
"Launchers".to_string()
|
||||
} else {
|
||||
format!(
|
||||
"Launchers matching \"{}\" ({}/{})",
|
||||
app.search_query,
|
||||
app.visible_items.len(),
|
||||
app.items.len()
|
||||
)
|
||||
};
|
||||
|
||||
let list = List::new(list_items)
|
||||
.block(Block::default().title("Launchers").borders(Borders::ALL))
|
||||
.block(Block::default().title(title).borders(Borders::ALL))
|
||||
.highlight_style(
|
||||
Style::default()
|
||||
.fg(Color::Black)
|
||||
@@ -84,7 +94,7 @@ fn draw_launcher_list(frame: &mut Frame, area: Rect, app: &App) {
|
||||
.highlight_symbol(" > ");
|
||||
|
||||
let mut state = ListState::default();
|
||||
if !app.items.is_empty() {
|
||||
if !app.visible_items.is_empty() {
|
||||
state.select(Some(app.selected));
|
||||
}
|
||||
frame.render_stateful_widget(list, area, &mut state);
|
||||
@@ -92,13 +102,13 @@ fn draw_launcher_list(frame: &mut Frame, area: Rect, app: &App) {
|
||||
|
||||
fn draw_detail_panel(frame: &mut Frame, area: Rect, app: &App) {
|
||||
match &app.mode {
|
||||
AppMode::Normal => draw_selected_item(frame, area, app),
|
||||
AppMode::Normal | AppMode::Searching => draw_selected_item(frame, area, app),
|
||||
AppMode::Adding(form) => draw_add_form(frame, area, form),
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_selected_item(frame: &mut Frame, area: Rect, app: &App) {
|
||||
let details = if let Some(selected) = app.items.get(app.selected) {
|
||||
let details = if let Some(selected) = app.selected_item() {
|
||||
vec![
|
||||
Line::from(vec![
|
||||
Span::styled("Name: ", Style::default().add_modifier(Modifier::BOLD)),
|
||||
@@ -124,10 +134,22 @@ fn draw_selected_item(frame: &mut Frame, area: Rect, app: &App) {
|
||||
]
|
||||
} else {
|
||||
vec![
|
||||
Line::from("No launchers found yet."),
|
||||
Line::from(if app.search_query.is_empty() {
|
||||
"No launchers found yet."
|
||||
} else {
|
||||
"No launchers match this search."
|
||||
}),
|
||||
Line::from(""),
|
||||
Line::from("Press a to add one. The launcher will save it to:"),
|
||||
Line::from(config_file_path().display().to_string()),
|
||||
if app.search_query.is_empty() {
|
||||
Line::from("Press a to add one. The launcher will save it to:")
|
||||
} else {
|
||||
Line::from("Press Esc to clear the search.")
|
||||
},
|
||||
if app.search_query.is_empty() {
|
||||
Line::from(config_file_path().display().to_string())
|
||||
} else {
|
||||
Line::from("")
|
||||
},
|
||||
]
|
||||
};
|
||||
|
||||
@@ -186,7 +208,9 @@ fn draw_add_form(frame: &mut Frame, area: Rect, form: &AddForm) {
|
||||
fn draw_footer(frame: &mut Frame, area: Rect, app: &App) {
|
||||
let help = match app.mode {
|
||||
AppMode::Normal => Line::from(vec![
|
||||
Span::styled("j/down", Style::default().fg(Color::Yellow)),
|
||||
Span::styled("/", Style::default().fg(Color::Yellow)),
|
||||
Span::raw(" search "),
|
||||
Span::styled("down", Style::default().fg(Color::Yellow)),
|
||||
Span::raw(" move "),
|
||||
Span::styled("enter", Style::default().fg(Color::Yellow)),
|
||||
Span::raw(" launch "),
|
||||
@@ -199,6 +223,18 @@ fn draw_footer(frame: &mut Frame, area: Rect, app: &App) {
|
||||
Span::styled("q/esc", Style::default().fg(Color::Yellow)),
|
||||
Span::raw(" quit"),
|
||||
]),
|
||||
AppMode::Searching => Line::from(vec![
|
||||
Span::styled("type", Style::default().fg(Color::Yellow)),
|
||||
Span::raw(" filter "),
|
||||
Span::styled("backspace", Style::default().fg(Color::Yellow)),
|
||||
Span::raw(" delete "),
|
||||
Span::styled("j/down", Style::default().fg(Color::Yellow)),
|
||||
Span::raw(" move "),
|
||||
Span::styled("enter", Style::default().fg(Color::Yellow)),
|
||||
Span::raw(" launch "),
|
||||
Span::styled("esc", Style::default().fg(Color::Yellow)),
|
||||
Span::raw(" clear"),
|
||||
]),
|
||||
AppMode::Adding(_) => Line::from(vec![
|
||||
Span::styled("enter", Style::default().fg(Color::Yellow)),
|
||||
Span::raw(" next/save "),
|
||||
|
||||
Reference in New Issue
Block a user