gled-ui 2.34.0

gled's egui interface, shared by the desktop app and the browser
Documentation
//! A searchable, grouped list to pick from, shown inside a popup: the
//! search field has the focus, arrow keys move, Enter picks, Escape closes.
//! Without a search the suggested entries come first.

use egui::{Id, Key, Modifiers, RichText, ScrollArea, TextEdit, Ui, UiKind};

/// An entry of the palette.
pub(crate) struct PaletteEntry<T> {
    pub(crate) group: &'static str,
    pub(crate) label: String,
    pub(crate) description: &'static str,
    /// Whether the entry is listed under "Suggested" before the groups.
    pub(crate) suggested: bool,
    /// Whether the entry is what is set now.
    pub(crate) current: bool,
    /// Whether the search text, lowercased, finds the entry.
    pub(crate) found: Box<dyn Fn(&str) -> bool>,
    pub(crate) value: T,
}

#[derive(Clone, Default)]
struct PaletteState {
    query: String,
    highlighted: usize,
    focused: bool,
}

/// Shows the palette; returns the picked entry's value and closes the
/// popup it is in.
pub(crate) fn palette<T: Clone>(ui: &mut Ui, id: Id, entries: &[PaletteEntry<T>]) -> Option<T> {
    let mut state: PaletteState = ui.data(|data| data.get_temp(id)).unwrap_or_default();
    ui.set_min_width(320.0);
    ui.set_max_width(420.0);

    // Taken before the search field sees them, which would lose its focus
    // on Enter.
    let (up, down, enter, escape) = ui.input_mut(|input| {
        (
            input.consume_key(Modifiers::NONE, Key::ArrowUp),
            input.consume_key(Modifiers::NONE, Key::ArrowDown),
            input.consume_key(Modifiers::NONE, Key::Enter),
            input.consume_key(Modifiers::NONE, Key::Escape),
        )
    });
    let search = ui.add(
        TextEdit::singleline(&mut state.query)
            .hint_text("Search…")
            .desired_width(f32::INFINITY),
    );
    if !state.focused {
        search.request_focus();
        state.focused = true;
    }
    if search.changed() {
        state.highlighted = 0;
    }

    let query = state.query.trim().to_lowercase();
    let mut order: Vec<(Option<&'static str>, usize)> = Vec::new();
    if query.is_empty() {
        order.extend(
            entries
                .iter()
                .enumerate()
                .filter(|(_, entry)| entry.suggested)
                .map(|(index, _)| (Some("Suggested"), index)),
        );
        order.extend(
            entries
                .iter()
                .enumerate()
                .map(|(index, entry)| (Some(entry.group), index)),
        );
    } else {
        order.extend(
            entries
                .iter()
                .enumerate()
                .filter(|(_, entry)| (entry.found)(&query))
                .map(|(index, entry)| (Some(entry.group), index)),
        );
    }

    if down && state.highlighted + 1 < order.len() {
        state.highlighted += 1;
    }
    if up {
        state.highlighted = state.highlighted.saturating_sub(1);
    }

    let mut picked = None;
    if enter && let Some((_, index)) = order.get(state.highlighted) {
        picked = Some(entries[*index].value.clone());
    }

    ScrollArea::vertical().max_height(380.0).show(ui, |ui| {
        if order.is_empty() {
            ui.weak("Nothing found");
        }
        let mut heading = None;
        for (position, (group, index)) in order.iter().enumerate() {
            if *group != heading {
                heading = *group;
                ui.add_space(4.0);
                ui.label(
                    RichText::new(group.unwrap_or_default().to_uppercase())
                        .small()
                        .weak(),
                );
            }
            let entry = &entries[*index];
            let text = if entry.current {
                egui::RichText::new(format!("{}  (now)", entry.label)).strong()
            } else {
                egui::RichText::new(&entry.label)
            };
            let mut response = ui.selectable_label(position == state.highlighted, text);
            if !entry.description.is_empty() {
                response = response.on_hover_text(entry.description);
            }
            if position == state.highlighted && (up || down) {
                response.scroll_to_me(None);
            }
            if response.clicked() {
                picked = Some(entry.value.clone());
            }
        }
    });

    if picked.is_some() || escape {
        ui.data_mut(|data| data.remove::<PaletteState>(id));
        ui.close_kind(UiKind::Menu);
    } else {
        ui.data_mut(|data| data.insert_temp(id, state));
    }
    picked
}