rae 0.1.13

Renderer-neutral Rust design catalog for desktop widgets, layouts, sanitized output, and GLSL shader primitives.
Documentation
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};

use crate::sanitize::sanitize_str;

#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
pub enum PaneId {
    #[default]
    Main,
    Sidebar,
    Prompt,
}

impl PaneId {
    pub fn next(self) -> Self {
        match self {
            Self::Main => Self::Sidebar,
            Self::Sidebar => Self::Prompt,
            Self::Prompt => Self::Main,
        }
    }
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
pub enum OverlayKind {
    Help,
    CommandPalette,
    Search,
    Inspector,
    Settings,
    ThemeEditor,
}

/// Direction used when traversing a renderer-owned focus order.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
pub enum FocusDirection {
    Forward,
    Backward,
}

/// A named focus stop in a renderer-neutral focus chain.
#[derive(Clone, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct FocusTarget {
    pub id: String,
    pub disabled: bool,
}

impl FocusTarget {
    pub fn new(id: impl Into<String>) -> Self {
        Self {
            id: sanitize_str(&id.into(), 120),
            disabled: false,
        }
    }

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

/// Ordered focus targets with wrapping traversal and disabled-item skipping.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct FocusChain {
    pub targets: Vec<FocusTarget>,
    pub active_index: Option<usize>,
}

impl FocusChain {
    pub fn new(targets: impl IntoIterator<Item = FocusTarget>) -> Self {
        let targets = targets.into_iter().collect::<Vec<_>>();
        let active_index = targets.iter().position(|target| !target.disabled);
        Self {
            targets,
            active_index,
        }
    }

    pub fn active(&self) -> Option<&FocusTarget> {
        self.active_index
            .and_then(|index| self.targets.get(index))
            .filter(|target| !target.disabled)
    }

    pub fn active_id(&self) -> Option<&str> {
        self.active().map(|target| target.id.as_str())
    }

    pub fn set_active(&mut self, id: &str) -> bool {
        let id = sanitize_str(id, 120);
        if let Some(index) = self
            .targets
            .iter()
            .position(|target| target.id == id && !target.disabled)
        {
            self.active_index = Some(index);
            true
        } else {
            false
        }
    }

    pub fn move_focus(&mut self, direction: FocusDirection) -> Option<&FocusTarget> {
        let len = self.targets.len();
        if len == 0 || self.targets.iter().all(|target| target.disabled) {
            self.active_index = None;
            return None;
        }

        let base = self
            .active_index
            .filter(|index| *index < len)
            .unwrap_or(match direction {
                FocusDirection::Forward => len - 1,
                FocusDirection::Backward => 0,
            });
        for step in 1..=len {
            let index = match direction {
                FocusDirection::Forward => (base + step) % len,
                FocusDirection::Backward => (base + len - step % len) % len,
            };
            if !self.targets[index].disabled {
                self.active_index = Some(index);
                return self.active();
            }
        }
        None
    }
}

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

    #[test]
    fn focus_chain_skips_disabled_targets_and_wraps() {
        let mut chain = FocusChain::new([
            FocusTarget::new("editor"),
            FocusTarget::new("disabled").disabled(true),
            FocusTarget::new("prompt"),
        ]);

        assert_eq!(chain.active_id(), Some("editor"));
        assert_eq!(
            chain
                .move_focus(FocusDirection::Forward)
                .map(|target| target.id.as_str()),
            Some("prompt")
        );
        assert_eq!(
            chain
                .move_focus(FocusDirection::Forward)
                .map(|target| target.id.as_str()),
            Some("editor")
        );
        assert_eq!(
            chain
                .move_focus(FocusDirection::Backward)
                .map(|target| target.id.as_str()),
            Some("prompt")
        );
    }

    #[test]
    fn focus_chain_refuses_disabled_or_missing_active_targets() {
        let mut chain = FocusChain::new([
            FocusTarget::new("one").disabled(true),
            FocusTarget::new("two"),
        ]);

        assert_eq!(chain.active_id(), Some("two"));
        assert!(!chain.set_active("one"));
        assert!(!chain.set_active("missing"));
        assert!(chain.set_active("two"));
        assert_eq!(chain.active_id(), Some("two"));
    }

    #[test]
    fn focus_chain_reports_none_when_all_targets_are_disabled() {
        let mut chain = FocusChain::new([
            FocusTarget::new("one").disabled(true),
            FocusTarget::new("two").disabled(true),
        ]);

        assert_eq!(chain.active_id(), None);
        assert_eq!(chain.move_focus(FocusDirection::Forward), None);
        assert_eq!(chain.active_index, None);
    }

    #[test]
    fn focus_chain_recovers_from_out_of_range_public_active_index() {
        let mut chain = FocusChain {
            targets: vec![FocusTarget::new("first"), FocusTarget::new("second")],
            active_index: Some(usize::MAX),
        };

        assert_eq!(chain.active_id(), None);
        assert_eq!(
            chain
                .move_focus(FocusDirection::Forward)
                .map(|target| target.id.as_str()),
            Some("first")
        );
    }

    #[test]
    fn focus_chain_set_active_sanitizes_lookup_id() {
        let mut chain = FocusChain::new([FocusTarget::new("pane\u{200f}\x1b[31m")]);

        assert_eq!(chain.active_id(), Some("pane"));
        chain.active_index = None;
        assert!(chain.set_active("pane\u{200f}\x1b[31m"));
        assert_eq!(chain.active_id(), Some("pane"));
    }
}