use std::collections::HashSet;
use std::time::Instant;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use crate::api::types::{Cycle, Issue, Project};
use crate::app::{App, Nav, Popup, SidebarAction, TeamSection};
use crate::fuzzy;
use crate::keys::{self, Binding};
const KEYWORD_PENALTY: i32 = 20;
const EXACT_ID: i32 = 1000;
const MAX_PLACES: usize = 20;
const MAX_ISSUES: usize = 30;
pub enum Target<'a> {
Command(&'static Binding),
Go(Nav),
Favorite(usize),
Issue(&'a Issue),
Project(&'a Project),
Cycle(&'a Cycle),
}
pub struct Entry<'a> {
pub target: Target<'a>,
pub title: String,
pub section: &'static str,
pub detail: String,
pub positions: Vec<usize>,
}
pub fn entries(app: &App) -> Vec<Entry<'_>> {
let raw = app.view.palette.query.value.trim();
let (query, commands_only) = match raw.strip_prefix('>') {
Some(rest) => (rest.trim(), true),
None => (raw, false),
};
let mut entries = commands(app, query);
if !query.is_empty() && !commands_only {
entries.extend(places(app, query));
entries.extend(issues(app, query));
}
entries
}
fn command_entry(binding: &'static Binding, positions: Vec<usize>) -> Option<Entry<'static>> {
let command = binding.command?;
Some(Entry {
target: Target::Command(binding),
title: command.title.to_string(),
section: command.section.title(),
detail: binding.keys_label(),
positions,
})
}
fn commands(app: &App, query: &str) -> Vec<Entry<'static>> {
let mut commands = keys::commands(keys::context(app));
if query.is_empty() {
let recent = &app.view.palette.recent;
commands.sort_by_key(|b| {
let title = b.command.map(|c| c.title);
recent
.iter()
.position(|t| Some(*t) == title)
.unwrap_or(usize::MAX)
});
return commands
.into_iter()
.filter_map(|b| command_entry(b, Vec::new()))
.collect();
}
ranked(commands.into_iter().filter_map(|binding| {
let command = binding.command?;
let (score, positions) = match fuzzy::score(query, command.title) {
Some(m) => (m.score, m.positions),
None => {
let best = command
.keywords
.iter()
.filter_map(|k| fuzzy::score(query, k))
.map(|m| m.score)
.max()?;
(best - KEYWORD_PENALTY, Vec::new())
}
};
Some((score, command_entry(binding, positions)?))
}))
}
fn ranked<'a>(scored: impl Iterator<Item = (i32, Entry<'a>)>) -> Vec<Entry<'a>> {
let mut scored: Vec<(i32, Entry)> = scored.collect();
scored.sort_by_key(|(score, _)| -score);
scored.into_iter().map(|(_, entry)| entry).collect()
}
fn matched<'a>(
query: &str,
title: String,
section: &'static str,
detail: String,
target: Target<'a>,
) -> Option<(i32, Entry<'a>)> {
let m = fuzzy::score(query, &title)?;
Some((
m.score,
Entry {
target,
title,
section,
detail,
positions: m.positions,
},
))
}
fn places<'a>(app: &'a App, query: &str) -> Vec<Entry<'a>> {
let store = &app.store;
let mut found = Vec::new();
for (index, team) in store.teams.iter().enumerate() {
let mut pages = vec![
("Issues", TeamSection::Issues),
("Projects", TeamSection::Projects),
("Views", TeamSection::Views),
];
if team.cycles_enabled {
pages.insert(1, ("Cycles", TeamSection::Cycles));
}
for (page, section) in pages {
found.extend(matched(
query,
format!("{} \u{203a} {page}", team.name),
"Team",
team.key.clone(),
Target::Go(Nav::Team(index, section)),
));
}
}
for (index, view) in store.custom_views.iter().enumerate() {
found.extend(matched(
query,
view.name.clone(),
"View",
String::new(),
Target::Go(Nav::View(index)),
));
}
for (index, fav) in store.favorites.iter().enumerate() {
if fav.is_folder() {
continue;
}
found.extend(matched(
query,
fav.label(),
"Favorite",
String::new(),
Target::Favorite(index),
));
}
for project in &store.projects.items {
found.extend(matched(
query,
project.name.clone(),
"Project",
project.state.clone().unwrap_or_default(),
Target::Project(project),
));
}
for cycle in &store.cycles.items {
let name = cycle
.name
.clone()
.unwrap_or_else(|| format!("Cycle {}", cycle.number.unwrap_or(0.0)));
found.extend(matched(
query,
name,
"Cycle",
String::new(),
Target::Cycle(cycle),
));
}
let mut places = ranked(found.into_iter());
places.truncate(MAX_PLACES);
places
}
fn issues<'a>(app: &'a App, query: &str) -> Vec<Entry<'a>> {
let store = &app.store;
let mut seen = HashSet::new();
let candidates = store
.current_issue
.iter()
.chain(store.issues.iter().flat_map(|rows| &rows.items))
.chain(&app.view.palette.results)
.filter(|issue| seen.insert(&issue.id));
let found = candidates.filter_map(|issue| {
let (score, entry) = matched(
query,
format!("{} {}", issue.identifier, issue.title),
"Issue",
issue
.state
.as_ref()
.map(|s| s.name.clone())
.unwrap_or_default(),
Target::Issue(issue),
)?;
let exact = issue.identifier.eq_ignore_ascii_case(query);
Some((score + if exact { EXACT_ID } else { 0 }, entry))
});
let mut issues = ranked(found);
issues.truncate(MAX_ISSUES);
issues
}
pub fn handle_key(app: &mut App, key: KeyEvent) {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
match key.code {
KeyCode::Esc => app.close_popup(),
KeyCode::Char('k') if ctrl => app.close_popup(),
KeyCode::Enter => run_selected(app),
KeyCode::Down | KeyCode::Tab => move_by(app, 1),
KeyCode::Up | KeyCode::BackTab => move_by(app, -1),
KeyCode::Char('n') if ctrl => move_by(app, 1),
KeyCode::Char('p') if ctrl => move_by(app, -1),
KeyCode::PageDown => move_by(app, 10),
KeyCode::PageUp => move_by(app, -10),
_ => edit(app, key.code, ctrl),
}
}
fn edit(app: &mut App, code: KeyCode, ctrl: bool) {
let query = &mut app.view.palette.query;
let before = query.value.clone();
match code {
KeyCode::Char('w') if ctrl => query.kill_word(),
KeyCode::Char('u') if ctrl => query.kill_to_start(),
KeyCode::Char('a') if ctrl => query.home(),
KeyCode::Char('e') if ctrl => query.end(),
KeyCode::Char(c) if !ctrl => query.insert(c),
KeyCode::Backspace => query.backspace(),
KeyCode::Delete => query.delete(),
KeyCode::Left => query.left(),
KeyCode::Right => query.right(),
KeyCode::Home => query.home(),
KeyCode::End => query.end(),
_ => {}
}
if app.view.palette.query.value != before {
app.view.palette.selected = 0;
app.palette_query_changed(Instant::now());
}
}
fn move_by(app: &mut App, delta: isize) {
let len = entries(app).len();
App::nav_by(len, &mut app.view.palette.selected, delta);
}
enum Chosen {
Command(&'static Binding),
Go(Nav),
Favorite(usize),
Issue(Box<Issue>),
Project(Box<Project>),
Cycle(Box<Cycle>),
}
impl Target<'_> {
fn chosen(&self) -> Chosen {
match self {
Target::Command(binding) => Chosen::Command(binding),
Target::Go(nav) => Chosen::Go(*nav),
Target::Favorite(index) => Chosen::Favorite(*index),
Target::Issue(issue) => Chosen::Issue(Box::new((*issue).clone())),
Target::Project(project) => Chosen::Project(Box::new((*project).clone())),
Target::Cycle(cycle) => Chosen::Cycle(Box::new((*cycle).clone())),
}
}
}
fn chosen_at(app: &App, index: usize) -> Option<Chosen> {
entries(app).get(index).map(|e| e.target.chosen())
}
fn run_selected(app: &mut App) {
match chosen_at(app, app.view.palette.selected) {
Some(chosen) => run(app, chosen),
None => app.close_popup(),
}
}
fn run(app: &mut App, chosen: Chosen) {
let issue = app.view.palette.issue.clone();
app.close_popup();
match chosen {
Chosen::Command(binding) => {
if issue.is_some() && app.focused_issue_id() != issue {
app.set_status("The issue under the cursor changed \u{2014} nothing was done");
return;
}
if let Some(command) = binding.command {
app.remember_command(command.title);
}
(binding.action)(app);
if app.view.popup != Popup::None {
app.view.popup_from_palette = true;
}
}
Chosen::Go(nav) => app.run_sidebar_action(SidebarAction::Go(nav)),
Chosen::Favorite(index) => app.open_favorite_entry(index),
Chosen::Issue(issue) => app.open_issue_from_list(&issue),
Chosen::Project(project) => app.go_to_project(*project),
Chosen::Cycle(cycle) => app.go_to_cycle(*cycle),
}
}
pub fn click(app: &mut App, x: u16, y: u16) {
let area = app.frame.popup_area;
let inside = area.width > 0
&& (area.x..area.x + area.width).contains(&x)
&& (area.y..area.y + area.height).contains(&y);
if !inside {
app.close_popup();
return;
}
let index = app.frame.popup_offset + (y - area.y) as usize;
if let Some(chosen) = chosen_at(app, index) {
run(app, chosen);
}
}
pub fn wheel(app: &mut App, delta: i16) {
move_by(app, delta as isize);
}
#[cfg(test)]
mod tests;