BREP_app 0.2.1

The BREP CAD application: an eframe (egui + wgpu) host that draws the brep-render 3D engine into an egui frame — native + wasm from one codebase.
Documentation
//! A generic, reusable searchable **command-palette** modal — engine-agnostic.
//!
//! The caller drives it with a flat list of [`PaletteItem`]s (each an opaque
//! `id`, a displayed `label`, and extra `keywords` to match on) and reads back
//! the chosen `id`. The dialog itself knows NOTHING about features, the engine,
//! or the scene — so every "pick one of N named things" call site (add-feature
//! here; later: insert-datum, apply-appearance, jump-to-solid, …) reuses it
//! verbatim.
//!
//! # Public API shape — `show(...) -> Option<String>`
//!
//! The user asked for "a callback executed upon selection". In egui's immediate
//! mode a stored `Box<dyn FnMut(&str)>` would have to be invoked from inside the
//! per-frame draw while the caller ALSO holds `&mut EngineState` (to act on the
//! pick) — the closure would need to capture that same `&mut`, which the borrow
//! checker rejects. So the idiomatic form is inverted: [`Palette::show`] RETURNS
//! `Some(id)` on the frame an item is chosen (and closes itself), and the caller
//! acts on the id with its own `&mut EngineState` right there. Same effect as a
//! callback, no borrow fight, and the palette stays free of caller state.
//!
//! # Behaviour
//!
//! * A centred modal [`egui::Modal`] (backdrop dims + blocks the rest of the UI).
//! * A single-line text input **focused by default**, then a scrollable list.
//! * The list is **alphabetical by label** and **filtered live** (case-insensitive
//!   substring, with a light subsequence fuzzy fallback) against `label` +
//!   `keywords` — see [`filter_items`] (pure + unit-tested).
//! * **Click** an item → selects it. **Enter** → selects the current TOP of the
//!   filtered list. **Esc** / backdrop click → cancels.

use eframe::egui;
use std::collections::HashMap;

/// One selectable entry. `id` is the opaque token returned on selection; `label`
/// is shown (and is the alphabetical sort key); `keywords` are extra strings the
/// query also matches against (short codes / aliases) but which aren't displayed.
#[derive(Clone, Debug)]
pub struct PaletteItem {
    pub id: String,
    pub label: String,
    pub keywords: Vec<String>,
}

impl PaletteItem {
    pub fn new(
        id: impl Into<String>,
        label: impl Into<String>,
        keywords: Vec<String>,
    ) -> Self {
        Self {
            id: id.into(),
            label: label.into(),
            keywords,
        }
    }

    /// The row text as shown. For features the label already carries the leading
    /// glyph (prepended by `feature_long_name`); the palette sorts/searches on a
    /// glyph-stripped key ([`sort_key`]) so ordering stays alphabetical by name.
    fn display(&self) -> String {
        self.label.clone()
    }
}

/// The palette's transient state. The caller owns ONE, calls [`Palette::open`]
/// to populate + show it, and [`Palette::show`] every frame; the return of
/// `show` is the selection outcome.
#[derive(Default)]
pub struct Palette {
    open: bool,
    /// The live query text (bound to the text input).
    query: String,
    /// Heading text (empty → no heading).
    title: String,
    /// Text-input hint.
    placeholder: String,
    /// The items, pre-sorted alphabetical by label at [`open`](Palette::open).
    items: Vec<PaletteItem>,
    /// Set on open (and after a no-op Enter) so the text input grabs focus.
    want_focus: bool,
    /// Per-frame widget rects (text input + visible rows) for the headed
    /// verifier. Keys: `"input"`, `"top"`, and `"item:<id>"`. Rebuilt each show.
    hits: HashMap<String, egui::Rect>,
}

impl Palette {
    pub fn new() -> Self {
        Self::default()
    }

    /// Populate + open the palette. Items are sorted alphabetical by label; the
    /// query is reset and the text input will focus on the next frame.
    pub fn open(
        &mut self,
        mut items: Vec<PaletteItem>,
        title: impl Into<String>,
        placeholder: impl Into<String>,
    ) {
        sort_by_label(&mut items);
        self.items = items;
        self.title = title.into();
        self.placeholder = placeholder.into();
        self.query.clear();
        self.open = true;
        self.want_focus = true;
    }

    pub fn is_open(&self) -> bool {
        self.open
    }

    pub fn close(&mut self) {
        self.open = false;
        self.query.clear();
        self.items.clear();
        self.hits.clear();
    }

    /// The per-frame widget rects (text input + visible rows) for the headed
    /// verifier — empty when the palette is closed.
    pub fn hits(&self) -> &HashMap<String, egui::Rect> {
        &self.hits
    }

    /// Draw the palette (if open) and return the chosen item id on the frame a
    /// selection is made (the palette closes itself). `None` while it stays open,
    /// while nothing is chosen, and on cancel (Esc / backdrop click, which also
    /// close it). Idempotent when closed.
    pub fn show(&mut self, ctx: &egui::Context) -> Option<String> {
        if !self.open {
            return None;
        }
        self.hits.clear();

        let mut selected: Option<String> = None;
        let mut enter_no_match = false;

        let modal = egui::Modal::new(egui::Id::new("brep-command-palette")).show(ctx, |ui| {
            ui.set_width(360.0);

            if !self.title.is_empty() {
                ui.heading(&self.title);
                ui.add_space(4.0);
            }

            // --- search input (focused by default) ----------------------------
            let input = ui.add(
                egui::TextEdit::singleline(&mut self.query)
                    .hint_text(&self.placeholder)
                    .desired_width(f32::INFINITY),
            );
            self.hits.insert("input".into(), input.rect);
            if self.want_focus {
                input.request_focus();
                self.want_focus = false;
            }
            // While focused, keep Esc for the modal (its `should_close` consumes
            // it to cancel) instead of letting egui surrender focus — which would
            // otherwise let the app's global Esc handler eat it a frame early.
            if input.has_focus() {
                ui.memory_mut(|m| {
                    m.set_focus_lock_filter(
                        input.id,
                        egui::EventFilter {
                            escape: true,
                            horizontal_arrows: true,
                            ..Default::default()
                        },
                    )
                });
            }
            // Enter commits the current TOP of the filtered list.
            let enter =
                input.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));

            ui.add_space(4.0);
            ui.separator();

            // --- filtered, ranked list ---------------------------------------
            let filtered = filter_items(&self.items, &self.query);
            if enter {
                match filtered.first() {
                    Some(top) => selected = Some(top.id.clone()),
                    None => enter_no_match = true,
                }
            }

            egui::ScrollArea::vertical()
                .max_height(320.0)
                .auto_shrink([false, false])
                .show(ui, |ui| {
                    if filtered.is_empty() {
                        ui.weak("No matches");
                    }
                    for (idx, item) in filtered.iter().enumerate() {
                        let is_top = idx == 0;
                        // The row's leading glyph is drawn as artwork; there is
                        // no icon font left to draw it as a character.
                        let text = item.display();
                        let row = crate::icon_text::selectable_icon_label(ui, is_top, &text);
                        // Only publish hit-rects for rows actually inside the
                        // scroll viewport — a clipped row's rect lies off-screen,
                        // so a programmatic driver clicking it would miss the modal.
                        if ui.is_rect_visible(row.rect) {
                            self.hits.insert(format!("item:{}", item.id), row.rect);
                            if is_top {
                                self.hits.insert("top".into(), row.rect);
                            }
                        }
                        if row.clicked() {
                            selected = Some(item.id.clone());
                        }
                    }
                });
        });

        // A pick closes + reports; a cancel (Esc / backdrop) just closes.
        if let Some(id) = selected {
            self.close();
            return Some(id);
        }
        if modal.should_close() {
            self.close();
        } else if enter_no_match {
            // Enter with an empty result surrendered the input's focus; grab it
            // back so the user can keep typing.
            self.want_focus = true;
        }
        None
    }
}

/// Sort/tie-break key: the label lower-cased with any leading non-alphanumeric
/// glyph (a feature icon prepended by `feature_long_name`) and spaces stripped,
/// so the resting order stays alphabetical by NAME despite an icon prefix. A
/// glyph-free label is unaffected.
fn sort_key(label: &str) -> String {
    label
        .trim_start_matches(|c: char| !c.is_ascii_alphanumeric())
        .to_lowercase()
}

/// Sort items alphabetical (case-insensitive) by label — the resting list order.
pub(crate) fn sort_by_label(items: &mut [PaletteItem]) {
    items.sort_by(|a, b| sort_key(&a.label).cmp(&sort_key(&b.label)));
}

/// Filter + rank `items` against `query` (case-insensitive). Returns references
/// to the matching items, best match first, ties broken alphabetically by label.
/// An empty query returns ALL items in their existing (alphabetical) order.
///
/// Matching is substring-first (an earlier, longer hit ranks higher) with a
/// light subsequence fuzzy fallback, evaluated against the label (slightly
/// preferred) and every keyword.
pub fn filter_items<'a>(items: &'a [PaletteItem], query: &str) -> Vec<&'a PaletteItem> {
    let needle = query.trim().to_lowercase();
    if needle.is_empty() {
        return items.iter().collect();
    }
    let mut scored: Vec<(i32, &PaletteItem)> = items
        .iter()
        .filter_map(|item| item_score(item, &needle).map(|s| (s, item)))
        .collect();
    // Higher score first; alphabetical by label on ties (stable, predictable top).
    scored.sort_by(|a, b| {
        b.0.cmp(&a.0)
            .then_with(|| sort_key(&a.1.label).cmp(&sort_key(&b.1.label)))
    });
    scored.into_iter().map(|(_, item)| item).collect()
}

/// Best match score of an item against a (lowercased) needle, or `None` if it
/// matches neither the label nor any keyword. The label carries a small bonus so
/// a label hit outranks an equal keyword hit.
fn item_score(item: &PaletteItem, needle: &str) -> Option<i32> {
    const LABEL_BONUS: i32 = 10;
    let mut best: Option<i32> = None;
    let mut consider = |s: Option<i32>| {
        if let Some(s) = s {
            best = Some(best.map_or(s, |b| b.max(s)));
        }
    };
    consider(fuzzy_score(&item.label, needle).map(|s| s + LABEL_BONUS));
    for kw in &item.keywords {
        consider(fuzzy_score(kw, needle));
    }
    best
}

/// Score one `haystack` against a lowercased `needle`: a substring hit scores
/// `1000 - start` (earlier is better); otherwise a subsequence hit scores
/// `400 - last_index` (tighter is better); no match → `None`.
fn fuzzy_score(haystack: &str, needle: &str) -> Option<i32> {
    if needle.is_empty() {
        return Some(0);
    }
    let hay = haystack.to_lowercase();
    if let Some(pos) = hay.find(needle) {
        return Some(1000 - pos as i32);
    }
    // Subsequence fallback: every needle char appears in order.
    let mut chars = needle.chars().peekable();
    let mut last = 0i32;
    for (i, hc) in hay.chars().enumerate() {
        match chars.peek() {
            Some(&nc) if hc == nc => {
                chars.next();
                last = i as i32;
            }
            Some(_) => {}
            None => break,
        }
    }
    chars.peek().is_none().then_some(400 - last)
}

#[cfg(test)]
mod tests {
    use super::*;

    fn items() -> Vec<PaletteItem> {
        // Deliberately unsorted on input; `open`/`sort_by_label` orders them.
        let mut v = vec![
            PaletteItem::new("P.CY", "Primitive Cylinder", vec!["P.CY".into()]),
            PaletteItem::new("P.CU", "Primitive Cube", vec!["P.CU".into()]),
            PaletteItem::new("B", "Boolean", vec!["B".into()]),
            PaletteItem::new("P.S", "Primitive Sphere", vec!["P.S".into()]),
        ];
        sort_by_label(&mut v);
        v
    }

    #[test]
    fn empty_query_is_alphabetical_by_label() {
        let v = items();
        let labels: Vec<&str> = filter_items(&v, "").iter().map(|i| i.label.as_str()).collect();
        assert_eq!(
            labels,
            ["Boolean", "Primitive Cube", "Primitive Cylinder", "Primitive Sphere"]
        );
    }

    #[test]
    fn substring_query_ranks_the_match_first() {
        let v = items();
        let out = filter_items(&v, "cyl");
        assert_eq!(out.first().unwrap().id, "P.CY");
    }

    #[test]
    fn query_is_case_insensitive() {
        let v = items();
        assert_eq!(filter_items(&v, "CYL").first().unwrap().id, "P.CY");
        assert_eq!(filter_items(&v, "boolean").first().unwrap().id, "B");
    }

    #[test]
    fn keyword_matches_even_when_label_does_not() {
        let v = items();
        // "p.cy" appears only in the keyword, never in a label word.
        let out = filter_items(&v, "p.cy");
        assert_eq!(out.first().unwrap().id, "P.CY");
    }

    #[test]
    fn non_matching_query_yields_nothing() {
        let v = items();
        assert!(filter_items(&v, "zzz").is_empty());
    }

    #[test]
    fn light_fuzzy_subsequence_matches() {
        let v = items();
        // "cye" is not a substring of "Cylinder" but is a subsequence (Cy...e).
        let out = filter_items(&v, "cye");
        assert_eq!(out.first().unwrap().id, "P.CY");
    }

    #[test]
    fn prefix_substring_outranks_a_later_substring() {
        let v = vec![
            PaletteItem::new("a", "Rounded corner", vec![]),
            PaletteItem::new("b", "Corner treatment", vec![]),
        ];
        // Both contain "corner"; the one where it starts earlier ranks first.
        let out = filter_items(&v, "corner");
        assert_eq!(out.first().unwrap().id, "b");
    }
}