lepticons 0.11.0

Lucide icons as a Leptos component with category-based features.
Documentation
use std::collections::BTreeMap;
use std::str::FromStr;
use std::sync::OnceLock;

use convert_case::{Case, Casing};
use lucide_icon_data::LucideGlyph;
use strum::{EnumProperty, IntoEnumIterator};

use crate::lucide_icon_data;

/// Trait for types that provide SVG content for rendering.
pub trait Glyph: Copy {
    /// Returns the inner SVG content as a static string.
    fn svg(&self) -> &'static str;
}

impl Glyph for LucideGlyph {
    fn svg(&self) -> &'static str {
        self.get_str("svg").unwrap_or("")
    }
}

/// Pre-built search entry: (icon variant, concatenated searchable text).
struct SearchEntry {
    glyph: LucideGlyph,
    text: String,
}

/// Global flat search index, built once on first use.
static SEARCH_INDEX: OnceLock<Vec<SearchEntry>> = OnceLock::new();

/// Global categories cache, built once on first use.
static CATEGORIES: OnceLock<BTreeMap<String, u16>> = OnceLock::new();

/// Cached icon count, computed once on first use.
static COUNT: OnceLock<usize> = OnceLock::new();

fn build_search_index() -> Vec<SearchEntry> {
    LucideGlyph::iter()
        .map(|glyph| {
            let name: &'static str = glyph.into();
            let name_lower = name.to_lowercase();
            let tags = glyph.get_str("tags").unwrap_or("").to_lowercase();
            let categories = glyph.get_str("categories").unwrap_or("").to_lowercase();
            let text = format!("{},{},{}", name_lower, tags, categories);
            SearchEntry { glyph, text }
        })
        .collect()
}

fn build_categories() -> BTreeMap<String, u16> {
    let mut categories: BTreeMap<String, u16> = BTreeMap::new();
    for icon in LucideGlyph::iter() {
        let cats = icon.get_str("categories").unwrap_or("");
        for cat in cats.split(',') {
            let cat = cat.trim();
            if !cat.is_empty() {
                let count = categories
                    .entry(cat.to_case(Case::Title).to_string())
                    .or_insert(0);
                *count += 1;
            }
        }
    }
    categories
}

impl LucideGlyph {
    /// Returns the variant name as a static string (e.g. "AArrowDown").
    /// Zero-allocation.
    pub fn name(&self) -> &'static str {
        (*self).into()
    }

    /// Returns the kebab-case display name (e.g. "a-arrow-down").
    pub fn kebab_name(&self) -> String {
        self.name().to_case(Case::Kebab)
    }

    /// Looks up an icon by its variant name (e.g. "Activity", "ArrowRight").
    /// Returns `None` if the name doesn't match or the icon's category feature is disabled.
    pub fn by_name(name: &str) -> Option<LucideGlyph> {
        LucideGlyph::from_str(name).ok()
    }

    /// Returns the total number of available icon variants.
    pub fn count() -> usize {
        *COUNT.get_or_init(|| LucideGlyph::iter().count())
    }

    /// Returns the raw categories string from the icon metadata.
    pub fn categories_str(&self) -> &'static str {
        self.get_str("categories").unwrap_or("")
    }

    /// Returns the raw tags string from the icon metadata.
    pub fn tags_str(&self) -> &'static str {
        self.get_str("tags").unwrap_or("")
    }

    /// Returns categories as an iterator of string slices.
    pub fn categories(&self) -> impl Iterator<Item = &'static str> {
        self.categories_str()
            .split(',')
            .map(|s| s.trim())
            .filter(|s| !s.is_empty())
    }

    /// Returns tags as an iterator of string slices.
    pub fn tags(&self) -> impl Iterator<Item = &'static str> {
        self.tags_str()
            .split(',')
            .map(|s| s.trim())
            .filter(|s| !s.is_empty())
    }

    /// Returns contributors as an iterator of string slices.
    pub fn contributors(&self) -> impl Iterator<Item = &'static str> {
        self.get_str("contributors")
            .unwrap_or("")
            .split(',')
            .map(|s| s.trim())
            .filter(|s| !s.is_empty())
    }

    /// Returns a sorted map of all categories and their icon count.
    /// Computed once and cached.
    pub fn all_categories() -> &'static BTreeMap<String, u16> {
        CATEGORIES.get_or_init(build_categories)
    }

    /// Finds icons matching the filter string.
    /// Case-insensitive. Multiple words use AND logic (all must match).
    /// Uses a pre-built flat index for zero per-call allocations.
    pub fn find(filter: &str) -> Vec<LucideGlyph> {
        if filter.is_empty() {
            return LucideGlyph::iter().collect();
        }

        let index = SEARCH_INDEX.get_or_init(build_search_index);
        let filter_lower = filter.to_lowercase();
        let terms: Vec<&str> = filter_lower.split_whitespace().collect();

        index
            .iter()
            .filter(|entry| {
                terms
                    .iter()
                    .all(|term| entry.text.contains(term))
            })
            .map(|entry| entry.glyph)
            .collect()
    }
}