use std::path::PathBuf;
use cellix_input::{Command, Format};
use cellix_render::{Bounds, Font, Grid, Icon, Measure, Rect, Rgb, Style};
use crate::theme::Theme;
const WIDTH: u16 = 64;
const INTERFACE_SIZE_PERCENT: u8 = 100;
const ROWS: u16 = 12;
const TEXT_INSET: f32 = 1.4;
const PANEL_PADDING: f32 = 0.3;
const HEADER_ROWS: u16 = 2;
const HEADER_LINE: f32 = 1.3;
const STATUS_GAP: f32 = 1.5;
const HINT_GAP: f32 = 0.45;
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Item {
File(PathBuf, String),
Command(&'static str, Command),
Branch(String, String),
NewBranch,
Gap,
Citation(String, String),
Image(PathBuf, String, bool),
Folder(PathBuf, String, &'static str),
}
impl Item {
fn label(&self) -> &str {
match self {
Self::File(_, label) | Self::Image(_, label, _) | Self::Folder(_, label, _) => label,
Self::Command(label, _) => label,
Self::Branch(name, _) | Self::Citation(name, _) => name,
Self::NewBranch => "New branch…",
Self::Gap => "",
}
}
fn keys(&self) -> Option<&str> {
match self {
Self::File(..) | Self::Image(..) | Self::NewBranch | Self::Gap => None,
Self::Command(_, command) => keys_of(*command),
Self::Branch(_, when) | Self::Citation(_, when) => Some(when),
Self::Folder(_, _, note) => (!note.is_empty()).then_some(note),
}
}
}
pub fn keys_of(command: Command) -> Option<&'static str> {
Some(match command {
Command::Save => "Ctrl+S",
Command::Cut => "Ctrl+X",
Command::Copy => "Ctrl+C",
Command::Paste => "Ctrl+V",
Command::CommandPalette => "Ctrl+Shift+P",
Command::Undo => "Ctrl+Z",
Command::Redo => "Ctrl+Y",
Command::SelectAll => "Ctrl+A",
Command::CloseTab => "Ctrl+W",
Command::FindInFile => "Ctrl+F",
Command::GoBack => "Ctrl+-",
Command::QuickFix => "Ctrl+.",
Command::GoForward => "Ctrl+Shift+-",
Command::ReplaceInFile => "Ctrl+R",
Command::FindInProject => "Ctrl+Shift+F",
Command::OpenDiff => "Ctrl+G",
Command::ToggleSidebar => "Ctrl+B",
Command::ToggleComment => "Ctrl+/",
Command::GoToLine => "Ctrl+L",
Command::GoToDefinition => "F12",
Command::GoToReferences => "Shift+F12",
Command::FormatDocument => "Alt+Shift+F",
Command::RenameSymbol => "F2",
Command::AddComment => "Alt+C",
Command::SplitPane => "Ctrl+\\",
Command::Quit => "Ctrl+Q",
_ => return None,
})
}
pub const COMMANDS: [(&str, Command); 88] = [
("Save", Command::Save),
("Undo", Command::Undo),
("Redo", Command::Redo),
("Select All", Command::SelectAll),
("Close Tab", Command::CloseTab),
("Next Tab", Command::NextTab),
("Previous Tab", Command::PreviousTab),
("Split Pane", Command::SplitPane),
("Focus Next Pane", Command::FocusNextPane),
("Toggle Sidebar", Command::ToggleSidebar),
("Find in Project", Command::FindInProject),
("Review Changes", Command::OpenDiff),
("Add Comment", Command::AddComment),
("Blame Line", Command::BlameLine),
("Replace in Project", Command::ReplaceInProject),
("Toggle Markdown Source", Command::MarkdownPreview),
("Open in Browser", Command::OpenInBrowser),
("Focus Agent Pane", Command::FocusAgent),
("Build PDF", Command::BuildPdf),
("Export to PDF", Command::ExportPdf),
("Compare Plan Versions", Command::ComparePlanVersions),
("Show Line in PDF", Command::ShowInPdf),
("Toggle Maximized Side Pane", Command::MaximizePane),
("Toggle Maximized Editor", Command::MaximizeEditor),
("Find in File", Command::FindInFile),
("Go Back", Command::GoBack),
("Next Change", Command::NextChange),
("Previous Change", Command::PreviousChange),
("Show Hover", Command::ShowHover),
("Quick Fix…", Command::QuickFix),
("Go Forward", Command::GoForward),
("Replace in File", Command::ReplaceInFile),
("Toggle Comment", Command::ToggleComment),
("Duplicate Line", Command::DuplicateLine),
("Move Line Up", Command::MoveLineUp),
("Go to Line", Command::GoToLine),
("Format: Bold", Command::Format(Format::Bold)),
("Format: Italic", Command::Format(Format::Italic)),
("Format: Inline Code", Command::Format(Format::Code)),
("Format: Heading", Command::Format(Format::Heading)),
("Format: Bulleted List", Command::Format(Format::Bullet)),
("Format: Task List", Command::Format(Format::Task)),
("Format: Quote", Command::Format(Format::Quote)),
("Send Comments to Agent", Command::SendToAgent),
("Send Selection to Agent", Command::MentionSelection),
("Accept Proposed Change", Command::AcceptProposal),
("Reject Proposed Change", Command::RejectProposal),
("Approve Plan", Command::ApprovePlan),
("Send Plan Back", Command::SendBackPlan),
("Show Agents", Command::ShowAgents),
("Close Side Pane", Command::CloseDock),
("Show Outline", Command::ShowOutline),
("Show Source Control", Command::ShowSourceControl),
("Toggle Terminal", Command::ToggleTerminal),
("New Terminal", Command::NewTerminal),
("Show Comments", Command::ShowComments),
("Show Browser", Command::ShowBrowser),
("List Agents", Command::ListAgents),
("Run Cell", Command::RunCell),
("Run All Cells", Command::RunAllCells),
("Clear All Outputs", Command::ClearOutputs),
("Interrupt Kernel", Command::InterruptKernel),
("Restart Kernel", Command::RestartKernel),
("Getting Started", Command::ShowGuide),
("New Document", Command::NewDocument),
("Toggle Track Changes", Command::ToggleTracking),
("Toggle Spell Check", Command::ToggleSpelling),
("Accept Change", Command::AcceptChange),
("Reject Change", Command::RejectChange),
("Accept All Changes", Command::AcceptAllChanges),
("Reject All Changes", Command::RejectAllChanges),
("Review Previous Agent Turn", Command::ReviewPreviousTurn),
("Undo Agent Turn", Command::UndoTurn),
("Commit Staged Changes", Command::Commit),
("Open Claude Code", Command::OpenClaude),
("Open Codex", Command::OpenCodex),
("Open Folder as a Workspace", Command::OpenWorkspace),
("Next Workspace", Command::NextWorkspace),
("Previous Workspace", Command::PreviousWorkspace),
("Open Settings", Command::OpenSettings),
("Switch Branch", Command::SwitchBranch),
("Open Gemini CLI", Command::OpenGemini),
("Go to Definition", Command::GoToDefinition),
("Go to References", Command::GoToReferences),
("Format Document", Command::FormatDocument),
("Complete", Command::Complete),
("Rename Symbol", Command::RenameSymbol),
("Quit", Command::Quit),
];
pub struct Palette {
pub query: String,
hint: &'static str,
items: Vec<Item>,
matches: Vec<usize>,
scores: Vec<i32>,
matched: String,
pub selected: usize,
pub status: Option<String>,
pub folders: Option<crate::folders::Picker>,
}
pub fn fuzzy_score(query: &str, text: &str) -> Option<i32> {
let name_start = text.rfind('/').map_or(0, |i| i + 1);
let mut score = 0;
let mut last = None;
let mut rest = text.char_indices();
for q in query.chars().flat_map(char::to_lowercase) {
let (found, c) = rest.by_ref().find(|(_, c)| same_letter(*c, q))?;
score += 1;
if last == Some(found) {
score += 4;
}
if found >= name_start {
score += 2;
}
last = Some(found + c.len_utf8());
}
Some(score - text.len() as i32 / 16)
}
fn same_letter(c: char, lower: char) -> bool {
c == lower || (!c.is_ascii() || c.is_ascii_uppercase()) && c.to_lowercase().eq([lower])
}
impl Palette {
pub fn new(items: Vec<Item>) -> Self {
let mut palette = Self {
query: String::new(),
hint: "Type to search",
items,
matches: Vec::new(),
scores: Vec::new(),
matched: String::new(),
selected: 0,
status: None,
folders: None,
};
palette.filter();
palette
}
pub fn extend(&mut self, items: Vec<Item>) {
let picked = self.matches.get(self.selected).copied();
let from = self.items.len();
self.items.extend(items);
if self.query.is_empty() {
self.matches.extend(from..self.items.len());
return;
}
let mut scored: Vec<(i32, usize)> = self
.scores
.iter()
.copied()
.zip(self.matches.iter().copied())
.collect();
for i in from..self.items.len() {
if let Some(score) = fuzzy_score(&self.query, self.items[i].label()) {
scored.push((score, i));
}
}
scored.sort_by_key(|(score, i)| (-score, *i));
(self.scores, self.matches) = scored.into_iter().unzip();
if let Some(picked) = picked {
self.selected = self.matches.iter().position(|&i| i == picked).unwrap_or(0);
}
}
#[cfg(test)]
pub fn len(&self) -> usize {
self.items.len()
}
pub fn asking(items: Vec<Item>, hint: &'static str) -> Self {
Self {
hint,
..Self::new(items)
}
}
pub fn refresh(&mut self) {
let fresh = self
.folders
.as_mut()
.and_then(|picker| picker.items_for(&self.query));
if let Some(items) = fresh {
self.items = items;
self.matched.clear();
}
self.filter();
}
pub fn filter(&mut self) {
if self.query.is_empty() {
self.matches = (0..self.items.len()).collect();
self.scores.clear();
self.matched.clear();
self.selected = 0;
return;
}
let narrowing = !self.matched.is_empty() && self.query.starts_with(&self.matched);
let candidates: Vec<usize> = if narrowing {
std::mem::take(&mut self.matches)
} else {
(0..self.items.len()).collect()
};
let mut scored: Vec<(i32, usize)> = candidates
.into_iter()
.filter(|&i| self.items[i] != Item::Gap)
.filter_map(|i| Some((fuzzy_score(&self.query, self.items[i].label())?, i)))
.collect();
scored.sort_by_key(|(score, i)| (-score, *i));
(self.scores, self.matches) = scored.into_iter().unzip();
self.matched = self.query.clone();
self.selected = 0;
}
pub fn move_by(&mut self, delta: isize) {
let last = self.matches.len().saturating_sub(1) as isize;
self.selected = (self.selected as isize + delta).clamp(0, last) as usize;
if self.current() == Some(&Item::Gap) {
self.selected = (self.selected as isize + delta).clamp(0, last) as usize;
}
}
pub fn current(&self) -> Option<&Item> {
self.items.get(*self.matches.get(self.selected)?)
}
pub fn area(screen: Rect) -> Rect {
let width = WIDTH.min(screen.w);
Rect::new(
screen.x + (screen.w - width) / 2,
3,
width,
ROWS + HEADER_ROWS,
)
}
pub fn row_at(&self, screen: Rect, y: u16) -> Option<usize> {
let row = y.checked_sub(Self::area(screen).y + HEADER_ROWS)? as usize;
(row < self.matches.len().min(ROWS as usize)).then_some(row)
}
pub fn draw(
&self,
grid: &mut Grid,
hover: Option<(u16, u16)>,
measure: &dyn Measure,
theme: &Theme,
) {
let area = Self::area(grid.area());
let rows = self.matches.len().min(ROWS as usize) as u16;
let area = Rect::new(area.x, area.y, area.w, rows + HEADER_ROWS);
raised(grid, area, theme);
let base = Style {
fg: theme.text.fg,
bg: theme.popup,
..theme.bar
};
let dim = Style {
fg: theme.popup_dim,
..base
};
grid.glyph(
area.x as f32 + TEXT_INSET,
area.y,
Icon::Search,
theme.popup_dim,
"⌕",
base,
);
let x = area.x as f32 + TEXT_INSET + 2.3;
let cell = area.x + 4;
let font = Font {
size: INTERFACE_SIZE_PERCENT,
..Font::default()
};
let caret = x - 0.1 + measure.width(&self.query, font);
let status_right = area.right().saturating_sub(2);
let status = self.status.as_deref().map(|text| {
let width = measure.width(
text,
Font {
size: 85,
..Font::default()
},
);
(text, status_right as f32 - width)
});
let status = status.filter(|(_, left)| caret + STATUS_GAP <= *left);
let end = status.map_or(area.right(), |(_, left)| (left - STATUS_GAP / 2.0) as u16);
if self.query.is_empty() {
grid.label_at(x + HINT_GAP, cell, area.y, self.hint, dim, end, 100);
} else {
grid.label_at(x, cell, area.y, &self.query, base, end, 100);
}
if let Some((text, _)) = status {
grid.label_right(status_right, area.y, text, dim, 85);
}
grid.set_caret(caret, area.y, 0.56);
let line_y = area.y as f32 + HEADER_LINE;
let edge = Bounds::cells(area.x as f32, line_y, area.w as f32, 0.0);
grid.hairline(edge, theme.popup_border);
for (row, index) in self.matches.iter().take(ROWS as usize).enumerate() {
let y = area.y + HEADER_ROWS + row as u16;
let line = Rect::new(area.x, y, area.w, 1);
if self.items[*index] == Item::Gap {
let across = area.w as f32 - TEXT_INSET * 2.0;
let rule = Bounds::cells(area.x as f32 + TEXT_INSET, y as f32 + 0.5, across, 0.0);
grid.hairline(rule, theme.popup_border);
continue;
}
let hovered = hover.is_some_and(|h| line.contains(h.0, h.1));
if row == self.selected || hovered {
let fill = if row == self.selected {
theme.pick
} else {
theme.popup_hover
};
grid.panel(Bounds::inset(line, 0.5, 0.055), 18, Some(fill), None);
}
let text = self.items[*index].label();
let end = area.right().saturating_sub(16);
let inset = area.x as f32 + TEXT_INSET;
let shown = match &self.items[*index] {
Item::Folder(..) => middle_out(text, f32::from(end) - inset, measure),
_ => text.to_string(),
};
grid.label_at(inset, area.x + 2, y, &shown, base, end, 100);
if let Some(keys) = self.items[*index].keys() {
let fg = if row == self.selected {
theme.text.fg
} else {
theme.popup_dim
};
let quiet = Style { fg, ..base };
grid.label_right(area.right().saturating_sub(2), y, keys, quiet, 85);
}
}
}
}
fn middle_out(text: &str, room: f32, measure: &dyn Measure) -> String {
let font = Font {
size: INTERFACE_SIZE_PERCENT,
..Font::default()
};
if measure.width(text, font) <= room {
return text.to_string();
}
let chars: Vec<char> = text.chars().collect();
let mut keep = chars.len();
while keep > 2 {
keep -= 1;
let head = keep / 3;
let tail = keep - head;
let tried: String = chars[..head]
.iter()
.chain(std::iter::once(&'…'))
.chain(&chars[chars.len() - tail..])
.collect();
if measure.width(&tried, font) <= room {
return tried;
}
}
"…".to_string()
}
pub struct Input<'a> {
pub text: &'a str,
pub hint: &'a str,
pub focused: bool,
pub behind: Rgb,
}
pub fn input(grid: &mut Grid, line: Rect, field: &Input, measure: &dyn Measure, theme: &Theme) {
const PADDING: f32 = 0.8;
let style = Style {
fg: theme.text.fg,
bg: theme.input,
..theme.sidebar
};
grid.fill(line, style);
let border = if field.focused {
theme.focus
} else {
theme.popup_border
};
grid.field(line, field.behind, theme.input, border);
let (x, y) = (line.x as f32 + PADDING, line.y);
let first = line.x + 1;
if field.text.is_empty() {
let dim = Style {
fg: theme.gutter.fg,
..style
};
grid.label_at(x, first, y, field.hint, dim, line.right(), 95);
} else {
grid.label_at(x, first, y, field.text, style, line.right(), 95);
}
if field.focused {
let font = Font {
size: 95,
..Font::default()
};
let end = x + measure.width(field.text, font) - 0.1;
let last = line.right() as f32 - PADDING;
grid.set_caret(end.min(last), y, 0.56);
}
}
pub fn raised(grid: &mut Grid, area: Rect, theme: &Theme) {
let base = Style {
fg: theme.text.fg,
bg: theme.popup,
..theme.bar
};
grid.fill(area, base);
let bounds = Bounds::inset(area, 0.0, -PANEL_PADDING);
grid.popup(bounds, 28, theme.popup, theme.popup_border);
}
pub fn popup(grid: &mut Grid, area: Rect, theme: &Theme) {
let base = Style {
fg: theme.text.fg,
bg: theme.popup,
..theme.bar
};
for y in area.y..area.bottom() {
let edge = y == area.y || y + 1 == area.bottom();
let (x, w) = if edge {
(area.x + 1, area.w.saturating_sub(2))
} else {
(area.x, area.w)
};
grid.fill(Rect::new(x, y, w, 1), base);
}
let bounds = cellix_render::Bounds::inset(area, 0.0, 0.0);
grid.popup(bounds, 28, theme.popup, theme.popup_border);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_long_query_keeps_clear_of_the_status() {
let field = |query: &str| {
let mut palette = Palette::new(Vec::new());
palette.query = query.to_string();
palette.status = Some("8,310 files…".to_string());
let mut grid = Grid::new(100, 20, Style::default());
palette.draw(
&mut grid,
None,
&cellix_render::CellMeasure,
&Theme::default(),
);
grid.row_text(Palette::area(grid.area()).y)
};
let short = field("needle");
assert!(
short.contains("needle") && short.contains("8,310 files…"),
"{short}"
);
let long = "src/a/long/path/that/someone/pasted/in/it/needle.rs";
let row = field(long);
assert!(row.contains(long), "the query stays whole: {row}");
assert!(!row.contains("files…"), "and the status gives way: {row}");
}
#[test]
fn subsequence_required() {
assert!(fuzzy_score("xyz", "abc").is_none());
assert!(fuzzy_score("abc", "a-b-c").is_some());
}
#[test]
fn the_keyboard_steps_over_a_gap() {
let items = vec![
Item::Branch("main".to_string(), "current".to_string()),
Item::Gap,
Item::NewBranch,
];
let mut palette = Palette::asking(items, "Switch branch");
palette.move_by(1);
assert_eq!(palette.current(), Some(&Item::NewBranch));
palette.query = "new".to_string();
palette.filter();
assert_eq!(
palette.current(),
Some(&Item::NewBranch),
"gaps don't match"
);
}
#[test]
fn file_name_beats_directory() {
let by_name = fuzzy_score("app", "src/app.rs").unwrap();
let by_dir = fuzzy_score("app", "app/src/main.rs").unwrap();
assert!(by_name > by_dir);
}
}