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

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

155 lines Raw ← Back
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155
//! `Expander` — rozwijana sekcja (GTK4): nagłówek z tytułem i chevronem, pod nim treść.
//!
//! Widget immediate: trzyma stan rozwinięcia i tytuł. Aplikacja rysuje treść w prostokącie
//! [`Expander::content_rect`] (zerowej wysokości, gdy zwinięty).

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

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

/// Rozwijana sekcja.
#[derive(Debug, Clone, Default)]
pub struct Expander {
    title: String,
    expanded: bool,
    hovered: bool,
}

impl Expander {
    pub fn new(title: impl Into<String>) -> Self {
        Self {
            title: title.into(),
            ..Self::default()
        }
    }

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

    pub fn title(&self) -> &str {
        &self.title
    }

    pub fn is_expanded(&self) -> bool {
        self.expanded
    }

    pub fn set_expanded(&mut self, expanded: bool) {
        self.expanded = expanded;
    }

    /// Przełącza rozwinięcie; zwraca nowy stan.
    pub fn toggle(&mut self) -> bool {
        self.expanded = !self.expanded;
        self.expanded
    }

    pub fn set_hovered(&mut self, hovered: bool) {
        self.hovered = hovered;
    }

    pub fn header_height(&self, theme: &Theme) -> f64 {
        theme.metrics.button.height
    }

    /// Nagłówek (górna część `rect`).
    pub fn header_rect(&self, rect: Rect, theme: &Theme) -> Rect {
        Rect::from_xywh(rect.x(), rect.y(), rect.width(), self.header_height(theme))
    }

    /// Treść pod nagłówkiem; zerowej wysokości, gdy zwinięty.
    pub fn content_rect(&self, rect: Rect, theme: &Theme) -> Rect {
        let hdr = self.header_rect(rect, theme);
        let h = if self.expanded {
            (rect.height() - hdr.height()).max(0.0)
        } else {
            0.0
        };
        Rect::from_xywh(rect.x(), hdr.bottom(), rect.width(), h)
    }

    /// Kwadrat chevronu po lewej stronie nagłówka.
    pub fn chevron_rect(&self, rect: Rect, theme: &Theme) -> Rect {
        let hdr = self.header_rect(rect, theme);
        let s = theme.metrics.button.chevron * 2.0;
        Rect::from_xywh(hdr.x() + theme.spacing.sm, hdr.center().y - s / 2.0, s, s)
    }

    /// Obsługuje klik w nagłówek (przełącza rozwinięcie).
    pub fn click_at(&mut self, rect: Rect, theme: &Theme, p: Point) -> bool {
        if self.header_rect(rect, theme).contains(p) {
            self.toggle();
            true
        } else {
            false
        }
    }

    pub fn paint(&self, ctx: &mut PaintCtx, rect: Rect) {
        let theme = *ctx.theme;
        let hdr = self.header_rect(rect, &theme);
        if self.hovered {
            ctx.scene.fill_rect(hdr, theme.colors.shade);
        }
        let chev = self.chevron_rect(rect, &theme).center();
        if self.expanded {
            icon::chevron_down(ctx, theme.colors.dim_text, chev, 4.0, 2.0);
        } else {
            icon::chevron_right(ctx, theme.colors.dim_text, chev, 4.0, 2.0);
        }
        let text_x = chev.x + theme.metrics.button.chevron + theme.spacing.sm;
        ctx.label_left(
            text_x,
            hdr.center().y,
            &self.title,
            theme.typography.body.size,
            weight::MEDIUM,
            theme.colors.text,
        );
    }
}

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

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

    #[test]
    fn collapsed_has_no_content() {
        let theme = Theme::light();
        let e = Expander::new("Więcej");
        assert_eq!(e.content_rect(rect(), &theme).height(), 0.0);
        assert_eq!(
            e.header_rect(rect(), &theme).height(),
            e.header_height(&theme)
        );
    }

    #[test]
    fn expanded_content_fills_below_header() {
        let theme = Theme::light();
        let e = Expander::new("Więcej").expanded(true);
        let c = e.content_rect(rect(), &theme);
        assert!((c.height() - (200.0 - e.header_height(&theme))).abs() < 1e-9);
        assert_eq!(c.y(), e.header_height(&theme));
    }

    #[test]
    fn click_header_toggles_only_there() {
        let theme = Theme::light();
        let mut e = Expander::new("Sekcja");
        assert!(e.click_at(rect(), &theme, Point::new(150.0, 10.0)));
        assert!(e.is_expanded());
        assert!(!e.click_at(rect(), &theme, Point::new(150.0, 150.0)));
        assert!(e.is_expanded());
        assert!(e.click_at(rect(), &theme, Point::new(150.0, 10.0)));
        assert!(!e.is_expanded());
    }
}