facett-app/src/palette.rs
//! **`NavPalette` — a fuzzy command palette over a [`NavModel`](crate::NavModel).**
//! The capstone of "one model → many surfaces": the same nav tree that drives the
//! sidebar and breadcrumb also powers a Ctrl-K palette. Owned ids (no `&'static`
//! requirement, no leak) — the nav-model counterpart to `facett-menu`'s
//! static-command palette. State in the struct (FC-1); headless (`invoke`).
use crate::nav::NavModel;
use serde_json::{Value, json};
/// A subsequence fuzzy score (higher = better); `None` if `needle` isn't a
/// subsequence of `hay`. Rewards contiguous runs + a leading match.
fn fuzzy(needle: &str, hay: &str) -> Option<i32> {
let q: Vec<char> = needle.to_lowercase().chars().collect();
if q.is_empty() {
return Some(0);
}
let h: Vec<char> = hay.to_lowercase().chars().collect();
let mut qi = 0usize;
let mut score = 0i32;
let mut prev: Option<usize> = None;
for (i, &hc) in h.iter().enumerate() {
if qi < q.len() && hc == q[qi] {
score += if prev == Some(i.wrapping_sub(1)) { 5 } else { 1 };
if i == 0 {
score += 3;
}
prev = Some(i);
qi += 1;
}
}
(qi == q.len()).then_some(score)
}
/// A Ctrl-K fuzzy palette that navigates a `NavModel`.
#[derive(Default)]
pub struct NavPalette {
open: bool,
query: String,
cursor: usize,
invoked: Option<String>,
}
impl NavPalette {
pub fn new() -> Self {
Self::default()
}
pub fn is_open(&self) -> bool {
self.open
}
pub fn open(&mut self) {
self.open = true;
self.query.clear();
self.cursor = 0;
}
pub fn close(&mut self) {
self.open = false;
}
/// Consume the last invoked route.
pub fn take_invoked(&mut self) -> Option<String> {
self.invoked.take()
}
/// The ranked `(route, label)` hits for the current query.
fn hits<'a>(&self, model: &'a NavModel) -> Vec<(&'a str, String)> {
let mut scored: Vec<(i32, &str, String)> = model
.flat()
.into_iter()
.filter(|i| i.enabled && !i.is_section())
.filter_map(|i| fuzzy(&self.query, &i.label).map(|s| (s, i.route_or_id(), i.label.clone())))
.collect();
scored.sort_by(|a, b| b.0.cmp(&a.0).then(a.2.cmp(&b.2)));
scored.into_iter().map(|(_, r, l)| (r, l)).collect()
}
/// **Headless**: pick a route by id without drawing (CLI/test parity).
pub fn invoke(&mut self, route: impl Into<String>) -> String {
let route = route.into();
self.invoked = Some(route.clone());
self.open = false;
route
}
/// Ctrl-K opens, Esc closes. Call once per frame with the context.
pub fn handle_hotkeys(&mut self, ctx: &egui::Context) {
if ctx.input(|i| i.modifiers.command && i.key_pressed(egui::Key::K)) {
if self.open {
self.close();
} else {
self.open();
}
}
if self.open && ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
self.close();
}
}
/// Draw the palette (if open). Returns the chosen route this frame.
pub fn show(&mut self, ui: &mut egui::Ui, model: &NavModel) -> Option<String> {
if !self.open {
return None;
}
let ctx = ui.ctx().clone();
let screen = ctx.content_rect();
let hits = self.hits(model);
if !hits.is_empty() {
self.cursor = self.cursor.min(hits.len() - 1);
}
let mut chosen = None;
// Arrow-key navigation + Enter.
let (mut up, mut down, mut enter) = (false, false, false);
ctx.input(|i| {
up = i.key_pressed(egui::Key::ArrowUp);
down = i.key_pressed(egui::Key::ArrowDown);
enter = i.key_pressed(egui::Key::Enter);
});
if down && !hits.is_empty() {
self.cursor = (self.cursor + 1) % hits.len();
}
if up && !hits.is_empty() {
self.cursor = (self.cursor + hits.len() - 1) % hits.len();
}
if enter && let Some((route, _)) = hits.get(self.cursor) {
chosen = Some(route.to_string());
}
egui::Area::new(egui::Id::new("nav_palette_scrim")).order(egui::Order::Background).fixed_pos(screen.min).show(&ctx, |ui| {
ui.painter().rect_filled(screen, 0.0, egui::Color32::from_black_alpha(140));
});
egui::Area::new(egui::Id::new("nav_palette")).order(egui::Order::Foreground).anchor(egui::Align2::CENTER_TOP, egui::vec2(0.0, 80.0)).show(&ctx, |ui| {
egui::Frame::popup(ui.style()).inner_margin(10.0).show(ui, |ui| {
ui.set_width(420.0);
let te = ui.add(egui::TextEdit::singleline(&mut self.query).hint_text("Go to… (fuzzy)").desired_width(f32::INFINITY));
te.request_focus();
ui.separator();
for (idx, (route, label)) in hits.iter().enumerate() {
if ui.add(egui::Button::selectable(idx == self.cursor, label.as_str()).min_size(egui::vec2(400.0, 0.0))).clicked() {
chosen = Some(route.to_string());
}
}
if hits.is_empty() {
ui.weak("no matches");
}
});
});
if let Some(route) = &chosen {
self.invoked = Some(route.clone());
self.open = false;
}
chosen
}
pub fn state_json(&self) -> Value {
json!({ "open": self.open, "query": self.query, "cursor": self.cursor, "invoked": self.invoked })
}
}