Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/facett

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 })
    }
}