rae 0.1.10

Renderer-neutral widget, layout, sanitization, and GLSL shader primitives for Rust desktop tools.
Documentation
use crate::{sanitize_str, sanitize_title};

#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
pub enum KeyModifier {
    Ctrl,
    Shift,
    Alt,
    Meta,
}

impl KeyModifier {
    pub const fn label(self) -> &'static str {
        match self {
            Self::Ctrl => "Ctrl",
            Self::Shift => "Shift",
            Self::Alt => "Alt",
            Self::Meta => "Meta",
        }
    }
}

#[derive(Clone, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct Shortcut {
    pub modifiers: Vec<KeyModifier>,
    pub key: String,
}

impl Shortcut {
    pub fn new(key: impl Into<String>) -> Self {
        Self {
            modifiers: Vec::new(),
            key: sanitize_title(&key.into(), 24),
        }
    }

    pub fn with_modifiers(mut self, modifiers: impl IntoIterator<Item = KeyModifier>) -> Self {
        self.modifiers = modifiers.into_iter().collect();
        self
    }

    pub fn label(&self) -> String {
        self.modifiers
            .iter()
            .map(|modifier| modifier.label())
            .chain(std::iter::once(self.key.as_str()))
            .collect::<Vec<_>>()
            .join("+")
    }
}

#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct CommandItem {
    pub id: String,
    pub title: String,
    pub subtitle: Option<String>,
    pub keywords: Vec<String>,
    pub shortcut: Option<Shortcut>,
    pub disabled: bool,
    pub score_bias: f32,
}

impl CommandItem {
    pub fn new(id: impl Into<String>, title: impl Into<String>) -> Self {
        Self {
            id: sanitize_key(&id.into()),
            title: sanitize_title(&title.into(), 80),
            subtitle: None,
            keywords: Vec::new(),
            shortcut: None,
            disabled: false,
            score_bias: 0.0,
        }
    }

    pub fn with_subtitle(mut self, subtitle: impl Into<String>) -> Self {
        self.subtitle = Some(sanitize_str(&subtitle.into(), 120));
        self
    }

    pub fn with_keywords(mut self, keywords: impl IntoIterator<Item = impl Into<String>>) -> Self {
        self.keywords = keywords
            .into_iter()
            .map(|keyword| sanitize_key(&keyword.into()))
            .filter(|keyword| !keyword.is_empty())
            .collect();
        self
    }

    pub fn with_shortcut(mut self, shortcut: Shortcut) -> Self {
        self.shortcut = Some(shortcut);
        self
    }

    pub fn disabled(mut self, disabled: bool) -> Self {
        self.disabled = disabled;
        self
    }

    pub fn with_score_bias(mut self, score_bias: f32) -> Self {
        self.score_bias = score_bias;
        self
    }

    pub fn score(&self, query: &str) -> Option<f32> {
        if self.disabled {
            return None;
        }

        let query = normalize_query(query);
        if query.is_empty() {
            return Some(1.0 + self.score_bias);
        }

        let id = self.id.to_lowercase();
        let title = self.title.to_lowercase();
        let subtitle = self.subtitle.as_deref().unwrap_or_default().to_lowercase();
        let mut score = None;

        score = max_score(score, match_rank(&id, &query, 100.0));
        score = max_score(score, match_rank(&title, &query, 90.0));
        score = max_score(score, match_rank(&subtitle, &query, 40.0));

        for keyword in &self.keywords {
            score = max_score(score, match_rank(&keyword.to_lowercase(), &query, 65.0));
        }

        score.map(|value| value + self.score_bias)
    }
}

#[derive(Clone, Debug, Default, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct CommandPalette {
    pub query: String,
    pub selected_index: usize,
    pub items: Vec<CommandItem>,
}

impl CommandPalette {
    pub fn new(items: impl IntoIterator<Item = CommandItem>) -> Self {
        Self {
            query: String::new(),
            selected_index: 0,
            items: items.into_iter().collect(),
        }
    }

    pub fn set_query(&mut self, query: impl Into<String>) {
        self.query = sanitize_str(&query.into(), 120);
        self.selected_index = 0;
    }

    pub fn move_selection(&mut self, delta: isize) {
        let count = self.filtered_items().len();
        if count == 0 {
            self.selected_index = 0;
            return;
        }

        let current = self.selected_index.min(count - 1);
        self.selected_index = if delta.is_negative() {
            current.saturating_sub(delta.unsigned_abs())
        } else {
            current.saturating_add(delta as usize).min(count - 1)
        };
    }

    pub fn filtered_items(&self) -> Vec<&CommandItem> {
        let mut matches = self
            .items
            .iter()
            .filter_map(|item| item.score(&self.query).map(|score| (score, item)))
            .collect::<Vec<_>>();
        matches.sort_by(|(left_score, left), (right_score, right)| {
            right_score
                .total_cmp(left_score)
                .then_with(|| left.title.cmp(&right.title))
        });
        matches.into_iter().map(|(_, item)| item).collect()
    }

    pub fn selected_item(&self) -> Option<&CommandItem> {
        self.filtered_items().get(self.selected_index).copied()
    }
}

#[derive(Clone, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct TextSnippet {
    pub trigger: String,
    pub body: String,
    pub description: Option<String>,
    pub keywords: Vec<String>,
}

impl TextSnippet {
    pub fn new(trigger: impl Into<String>, body: impl Into<String>) -> Self {
        Self {
            trigger: sanitize_key(&trigger.into()),
            body: sanitize_str(&body.into(), 4_000),
            description: None,
            keywords: Vec::new(),
        }
    }

    pub fn with_description(mut self, description: impl Into<String>) -> Self {
        self.description = Some(sanitize_str(&description.into(), 120));
        self
    }

    pub fn with_keywords(mut self, keywords: impl IntoIterator<Item = impl Into<String>>) -> Self {
        self.keywords = keywords
            .into_iter()
            .map(|keyword| sanitize_key(&keyword.into()))
            .filter(|keyword| !keyword.is_empty())
            .collect();
        self
    }

    pub fn score(&self, query: &str) -> Option<f32> {
        let query = normalize_query(query);
        if query.is_empty() {
            return Some(1.0);
        }

        let trigger = self.trigger.to_lowercase();
        let description = self
            .description
            .as_deref()
            .unwrap_or_default()
            .to_lowercase();
        let mut score = None;
        score = max_score(score, match_rank(&trigger, &query, 100.0));
        score = max_score(score, match_rank(&description, &query, 45.0));

        for keyword in &self.keywords {
            score = max_score(score, match_rank(&keyword.to_lowercase(), &query, 65.0));
        }

        score
    }
}

#[derive(Clone, Debug, Default, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct SnippetSet {
    pub snippets: Vec<TextSnippet>,
}

impl SnippetSet {
    pub fn new(snippets: impl IntoIterator<Item = TextSnippet>) -> Self {
        Self {
            snippets: snippets.into_iter().collect(),
        }
    }

    pub fn expand(&self, trigger: &str) -> Option<&str> {
        let trigger = sanitize_key(trigger);
        self.snippets
            .iter()
            .find(|snippet| snippet.trigger == trigger)
            .map(|snippet| snippet.body.as_str())
    }

    pub fn matches(&self, query: &str) -> Vec<&TextSnippet> {
        let mut matches = self
            .snippets
            .iter()
            .filter_map(|snippet| snippet.score(query).map(|score| (score, snippet)))
            .collect::<Vec<_>>();
        matches.sort_by(|(left_score, left), (right_score, right)| {
            right_score
                .total_cmp(left_score)
                .then_with(|| left.trigger.cmp(&right.trigger))
        });
        matches.into_iter().map(|(_, snippet)| snippet).collect()
    }
}

fn sanitize_key(input: &str) -> String {
    sanitize_str(input, 80)
        .trim()
        .to_lowercase()
        .chars()
        .filter(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | ':' | '.'))
        .collect()
}

fn normalize_query(input: &str) -> String {
    sanitize_str(input, 120).trim().to_lowercase()
}

fn match_rank(candidate: &str, query: &str, base_score: f32) -> Option<f32> {
    if candidate == query {
        Some(base_score + 30.0)
    } else if candidate.starts_with(query) {
        Some(base_score + 15.0)
    } else if candidate.contains(query) {
        Some(base_score)
    } else {
        None
    }
}

fn max_score(left: Option<f32>, right: Option<f32>) -> Option<f32> {
    match (left, right) {
        (Some(left), Some(right)) => Some(left.max(right)),
        (Some(value), None) | (None, Some(value)) => Some(value),
        (None, None) => None,
    }
}

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

    #[test]
    fn shortcut_label_joins_modifiers() {
        let shortcut = Shortcut::new("P").with_modifiers([KeyModifier::Ctrl, KeyModifier::Shift]);

        assert_eq!(shortcut.label(), "Ctrl+Shift+P");
    }

    #[test]
    fn command_palette_filters_and_skips_disabled_items() {
        let mut palette = CommandPalette::new([
            CommandItem::new("open", "Open File"),
            CommandItem::new("close", "Close File").disabled(true),
            CommandItem::new("theme", "Switch Theme").with_keywords(["palette"]),
        ]);
        palette.set_query("pal");

        let matches = palette.filtered_items();
        assert_eq!(matches.len(), 1);
        assert_eq!(matches[0].id, "theme");
        assert_eq!(palette.selected_item().unwrap().title, "Switch Theme");
    }

    #[test]
    fn snippets_expand_and_rank_matches() {
        let snippets = SnippetSet::new([
            TextSnippet::new("hdr", "# Title").with_keywords(["markdown"]),
            TextSnippet::new("todo", "- [ ] ").with_description("Task checkbox"),
        ]);

        assert_eq!(snippets.expand("hdr"), Some("# Title"));
        assert_eq!(snippets.matches("task")[0].trigger, "todo");
    }
}