🔒 Repository is read-only – file editing is disabled.

luna/crates/libluna-widgets/src/navigation_rail.rs main

209 lines Raw ← Back
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209
//! `NavigationRail` — kompaktowa szyna nawigacji (pionowa lista pozycji z wyborem).
//!
//! Własny widget libLuny (nie klon `GtkXxx`): wąski pasek pozycji — w wersji pełnej z etykietą,
//! a zwinięty (`collapsed`) z pojedynczym znakiem. Wybrana pozycja dostaje pasek-wskaźnik
//! przy lewej krawędzi.

use libluna_core::style::weight;
use libluna_core::{Point, Rect, Theme};

use crate::ctx::PaintCtx;

/// Kompaktowa szyna nawigacji.
#[derive(Debug, Clone, Default)]
pub struct NavigationRail {
    items: Vec<String>,
    selected: usize,
    hovered: Option<usize>,
    collapsed: bool,
}

impl NavigationRail {
    pub fn new<I, S>(items: I) -> Self
    where
        I: IntoIterator<Item = S>,
        S: Into<String>,
    {
        Self {
            items: items.into_iter().map(Into::into).collect(),
            ..Self::default()
        }
    }

    /// Zwinięta szyna (tylko inicjały zamiast etykiet).
    pub fn collapsed(mut self, collapsed: bool) -> Self {
        self.collapsed = collapsed;
        self
    }

    pub fn items(&self) -> &[String] {
        &self.items
    }

    pub fn is_collapsed(&self) -> bool {
        self.collapsed
    }

    pub fn selected(&self) -> usize {
        self.selected
    }

    pub fn selected_label(&self) -> Option<&str> {
        self.items.get(self.selected).map(String::as_str)
    }

    pub fn set_selected(&mut self, index: usize) {
        if index < self.items.len() {
            self.selected = index;
        }
    }

    pub fn set_hovered(&mut self, hovered: Option<usize>) {
        self.hovered = hovered.filter(|i| *i < self.items.len());
    }

    /// Szerokość szyny wynikająca z trybu (zwinięta/pełna).
    pub fn width(&self, theme: &Theme) -> f64 {
        if self.collapsed {
            theme.metrics.nav.item_h
        } else {
            theme.metrics.nav.item_h * 4.0
        }
    }

    /// Wysokość jednej pozycji.
    pub fn item_height(&self, theme: &Theme) -> f64 {
        theme.metrics.nav.item_h
    }

    /// Prostokąt pozycji `index`.
    pub fn item_rect(&self, rect: Rect, theme: &Theme, index: usize) -> Rect {
        let h = self.item_height(theme);
        Rect::from_xywh(rect.x(), rect.y() + index as f64 * h, rect.width(), h)
    }

    /// Indeks pozycji pod punktem `p` (lub `None`).
    pub fn index_at(&self, rect: Rect, theme: &Theme, p: Point) -> Option<usize> {
        (0..self.items.len()).find(|&i| self.item_rect(rect, theme, i).contains(p))
    }

    /// Obsługuje klik: ustawia wybór. Zwraca `true`, gdy wybór się zmienił.
    pub fn click_at(&mut self, rect: Rect, theme: &Theme, p: Point) -> bool {
        match self.index_at(rect, theme, p) {
            Some(i) if i != self.selected => {
                self.selected = i;
                true
            }
            _ => false,
        }
    }

    pub fn hover_at(&mut self, rect: Rect, theme: &Theme, p: Option<Point>) {
        self.hovered = p.and_then(|p| self.index_at(rect, theme, p));
    }

    /// Rysuje szynę: tło, wskaźnik zaznaczenia i etykiety (lub inicjały).
    pub fn paint(&self, ctx: &mut PaintCtx, rect: Rect) {
        let theme = *ctx.theme;
        ctx.scene.fill_rect(rect, theme.colors.sidebar_bg);
        let body = theme.typography.body.size;
        let indicator_w = 3.0;
        for (i, label) in self.items.iter().enumerate() {
            let item = self.item_rect(rect, &theme, i);
            let selected = i == self.selected;
            if selected || self.hovered == Some(i) {
                ctx.scene.fill_rounded_rect(
                    item.deflate(libluna_core::Insets::new(4.0, 6.0, 4.0, 6.0)),
                    theme.radius.small,
                    theme.colors.shade,
                );
            }
            if selected {
                let bar = Rect::from_xywh(
                    rect.x() + 2.0,
                    item.y() + 8.0,
                    indicator_w,
                    (item.height() - 16.0).max(0.0),
                );
                ctx.scene.fill_rounded_rect(bar, 1.5, theme.colors.accent);
            }
            let text = if self.collapsed {
                label.chars().next().map(|c| c.to_uppercase().to_string())
            } else {
                Some(label.clone())
            };
            if let Some(text) = text {
                let x = if self.collapsed {
                    item.center().x
                } else {
                    item.x() + 16.0
                };
                ctx.label_left(
                    x,
                    item.center().y,
                    &text,
                    body,
                    weight::MEDIUM,
                    theme.colors.text,
                );
            }
        }
    }
}

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

    fn rail() -> NavigationRail {
        NavigationRail::new(["Przegląd", "Sieć", "System"])
    }

    fn area() -> Rect {
        Rect::from_xywh(0.0, 0.0, 200.0, 200.0)
    }

    #[test]
    fn items_stack_vertically() {
        let theme = Theme::light();
        let r = rail();
        let h = r.item_height(&theme);
        let r0 = r.item_rect(area(), &theme, 0);
        let r2 = r.item_rect(area(), &theme, 2);
        assert_eq!(r0.height(), h);
        assert_eq!(r2.y(), 2.0 * h);
    }

    #[test]
    fn click_selects_item() {
        let theme = Theme::light();
        let mut r = rail();
        let target = r.item_rect(area(), &theme, 1).center();
        assert!(r.click_at(area(), &theme, target));
        assert_eq!(r.selected(), 1);
        assert_eq!(r.selected_label(), Some("Sieć"));
        assert!(!r.click_at(area(), &theme, target));
    }

    #[test]
    fn width_depends_on_mode() {
        let theme = Theme::light();
        let full = rail();
        let collapsed = rail().collapsed(true);
        assert!(collapsed.width(&theme) < full.width(&theme));
    }

    #[test]
    fn hover_at_clears_outside() {
        let theme = Theme::light();
        let mut r = rail();
        r.hover_at(
            area(),
            &theme,
            Some(r.item_rect(area(), &theme, 0).center()),
        );
        assert_eq!(r.hovered, Some(0));
        r.hover_at(area(), &theme, Some(Point::new(-10.0, -10.0)));
        assert_eq!(r.hovered, None);
    }
}