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

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

303 lines Raw ← Back
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303
//! `PathBar` — pasek ścieżki (okruszki): `Domowy › Dokumenty › Projekty ▾`.
//!
//! Widget immediate: trzyma listę elementów ścieżki (etykiety podaje aplikacja, już
//! przetłumaczone). Aplikacja woła [`PathBar::paint`] oraz [`PathBar::index_at`] /
//! [`PathBar::click_at`] i sama decyduje o nawigacji. Gdy ścieżka nie mieści się w szerokości,
//! środkowe elementy zastępuje wielokropek `…` (zostaje początek i bieżący katalog).

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

use crate::ctx::PaintCtx;
use crate::icon;

/// Rozpiętość separatora „›” i wskaźnika „▾”.
const CHEVRON: f64 = 8.0;

/// Pasek ścieżki.
#[derive(Debug, Clone, Default)]
pub struct PathBar {
    components: Vec<String>,
    hovered: Option<usize>,
}

impl PathBar {
    /// Tworzy pasek z listy etykiet (ostatnia to bieżący katalog).
    pub fn new<I, S>(components: I) -> Self
    where
        I: IntoIterator<Item = S>,
        S: Into<String>,
    {
        Self {
            components: components.into_iter().map(Into::into).collect(),
            hovered: None,
        }
    }

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

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

    pub fn is_empty(&self) -> bool {
        self.components.is_empty()
    }

    /// Dokłada element na końcu (np. po wejściu do katalogu).
    pub fn push(&mut self, label: impl Into<String>) {
        self.components.push(label.into());
    }

    /// Skraca ścieżkę do `len` elementów (nawigacja „w górę”).
    pub fn truncate(&mut self, len: usize) {
        self.components.truncate(len);
        if self.hovered.is_some_and(|h| h >= self.components.len()) {
            self.hovered = None;
        }
    }

    /// Indeks bieżącego katalogu (ostatni element) albo `None` dla pustego paska.
    pub fn active(&self) -> Option<usize> {
        self.components.len().checked_sub(1)
    }

    pub fn set_hovered(&mut self, hovered: Option<usize>) {
        self.hovered = hovered;
    }

    /// Wysokość paska (z tokenów motywu).
    pub fn height(&self, theme: &Theme) -> f64 {
        theme.metrics.pathbar.height
    }

    fn seg_width(&self, index: usize, text: &TextEngine, theme: &Theme) -> f64 {
        let m = theme.metrics.pathbar;
        let body = theme.typography.body.size;
        let mut w = text
            .measure(&self.components[index], body, weight::NORMAL)
            .width
            + 2.0 * m.pad_x;
        if Some(index) == self.active() {
            w += CHEVRON + m.gap; // miejsce na wskaźnik „▾”.
        }
        w
    }

    /// Prostokąty widocznych elementów; `None` oznacza wielokropek `…`.
    pub fn segments(
        &self,
        rect: Rect,
        text: &TextEngine,
        theme: &Theme,
    ) -> Vec<(Option<usize>, Rect)> {
        let m = theme.metrics.pathbar;
        let n = self.components.len();
        if n == 0 || rect.width() <= 0.0 {
            return Vec::new();
        }
        let sep = 2.0 * m.gap + CHEVRON;
        let total: f64 =
            (0..n).map(|i| self.seg_width(i, text, theme)).sum::<f64>() + (n as f64 - 1.0) * sep;

        let picks: Vec<Option<usize>> = if total <= rect.width() {
            (0..n).map(Some).collect()
        } else {
            // Zostaw początek i bieżący katalog; w środku wielokropek.
            let body = theme.typography.body.size;
            let ell = text.measure("…", body, weight::NORMAL).width + 2.0 * m.pad_x;
            let mut rev = vec![n - 1];
            let mut used = self.seg_width(n - 1, text, theme);
            for i in (0..n - 1).rev() {
                let w = self.seg_width(i, text, theme) + sep;
                if used + w + sep + ell <= rect.width() {
                    used += w;
                    rev.push(i);
                } else {
                    break;
                }
            }
            rev.reverse();
            let mut v: Vec<Option<usize>> = vec![None];
            v.extend(rev.into_iter().map(Some));
            v
        };

        let mut x = rect.x();
        let mut out = Vec::with_capacity(picks.len());
        for (k, pick) in picks.iter().enumerate() {
            if k > 0 {
                x += sep;
            }
            let w = match pick {
                Some(i) => self.seg_width(*i, text, theme),
                None => {
                    text.measure("…", theme.typography.body.size, weight::NORMAL)
                        .width
                        + 2.0 * m.pad_x
                }
            };
            out.push((*pick, Rect::from_xywh(x, rect.y(), w, rect.height())));
            x += w;
        }
        out
    }

    /// Indeks elementu pod punktem `p` (wielokropek i separatory zwracają `None`).
    pub fn index_at(
        &self,
        rect: Rect,
        text: &TextEngine,
        theme: &Theme,
        p: Point,
    ) -> Option<usize> {
        self.segments(rect, text, theme)
            .into_iter()
            .find(|(pick, r)| pick.is_some() && r.contains(p))
            .and_then(|(pick, _)| pick)
    }

    /// Obsługuje klik: zwraca indeks wybranego elementu (nawigację robi aplikacja).
    pub fn click_at(
        &self,
        rect: Rect,
        text: &TextEngine,
        theme: &Theme,
        p: Point,
    ) -> Option<usize> {
        self.index_at(rect, text, theme, p)
    }

    /// Rysuje pasek ścieżki w prostokącie `rect`.
    pub fn paint(&self, ctx: &mut PaintCtx, rect: Rect) {
        let theme = *ctx.theme;
        let m = theme.metrics.pathbar;
        let colors = theme.colors;
        let body = theme.typography.body.size;
        let active = self.active();
        let text = ctx.text;
        let segs = self.segments(rect, text, &theme);

        for (k, (pick, seg)) in segs.iter().enumerate() {
            if k > 0 {
                let cx = seg.x() - m.gap - CHEVRON / 2.0;
                icon::chevron_right(
                    ctx,
                    colors.dim_text,
                    Point::new(cx, seg.center().y),
                    4.0,
                    1.6,
                );
            }
            match pick {
                Some(i) => {
                    let is_active = Some(*i) == active;
                    if self.hovered == Some(*i) && !is_active {
                        ctx.scene
                            .fill_rounded_rect(*seg, theme.radius.small, colors.shade);
                    }
                    let color = if is_active {
                        colors.text
                    } else {
                        colors.dim_text
                    };
                    let weight = if is_active {
                        weight::MEDIUM
                    } else {
                        weight::NORMAL
                    };
                    let shift = if is_active {
                        (CHEVRON + m.gap) / 2.0
                    } else {
                        0.0
                    };
                    ctx.label_center(
                        seg.center().x - shift,
                        seg.center().y,
                        &self.components[*i],
                        body,
                        weight,
                        color,
                    );
                    if is_active {
                        icon::chevron_down(
                            ctx,
                            colors.dim_text,
                            Point::new(seg.right() - m.pad_x - CHEVRON / 2.0, seg.center().y),
                            4.0,
                            1.6,
                        );
                    }
                }
                None => ctx.label_center(
                    seg.center().x,
                    seg.center().y,
                    "…",
                    body,
                    weight::NORMAL,
                    colors.dim_text,
                ),
            }
        }
    }
}

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

    fn rect() -> Rect {
        Rect::from_xywh(0.0, 0.0, 400.0, 34.0)
    }

    #[test]
    fn all_components_when_they_fit() {
        let text = TextEngine::system();
        let theme = Theme::light();
        let bar = PathBar::new(["Dom", "Dokumenty", "Projekty"]);
        let segs = bar.segments(rect(), &text, &theme);
        assert_eq!(segs.len(), 3);
        assert!(segs.iter().all(|(p, _)| p.is_some()));
        assert_eq!(bar.active(), Some(2));
    }

    #[test]
    fn index_at_maps_components() {
        let text = TextEngine::system();
        let theme = Theme::light();
        let bar = PathBar::new(["Dom", "Dokumenty", "Projekty"]);
        let segs = bar.segments(rect(), &text, &theme);
        assert_eq!(
            bar.index_at(rect(), &text, &theme, segs[0].1.center()),
            Some(0)
        );
        assert_eq!(
            bar.index_at(rect(), &text, &theme, segs[2].1.center()),
            Some(2)
        );
    }

    #[test]
    fn overflow_inserts_ellipsis_and_keeps_last() {
        let text = TextEngine::system();
        let theme = Theme::light();
        let narrow = Rect::from_xywh(0.0, 0.0, 150.0, 34.0);
        let bar = PathBar::new(["Katalog domowy", "Dokumenty", "Projekty", "libLuna"]);
        let segs = bar.segments(narrow, &text, &theme);
        // Pierwszy widoczny to wielokropek, ostatni to bieżący katalog.
        assert_eq!(segs[0].0, None);
        assert_eq!(segs.last().unwrap().0, Some(3));
    }

    #[test]
    fn empty_bar_has_no_segments() {
        let text = TextEngine::system();
        let theme = Theme::light();
        let bar = PathBar::default();
        assert!(bar.segments(rect(), &text, &theme).is_empty());
        assert_eq!(bar.active(), None);
    }
}