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

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

355 lines Raw ← Back
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355
//! `BottomSheet` — dolny panel (modalny), wysuwany z dołu okna.
//!
//! Widget immediate: trzyma stan otwarcia, opcjonalny tytuł i przesunięcie przeciągania.
//! Aplikacja woła [`BottomSheet::paint`] oraz metody trafień ([`BottomSheet::close_at`],
//! [`BottomSheet::scrim_at`]) i obsługę przeciągania
//! ([`BottomSheet::begin_drag`] / [`BottomSheet::update_drag`] / [`BottomSheet::end_drag`]).
//! Zawartość panelu rysuje aplikacja w [`BottomSheet::content_rect`].

use libluna_core::style::weight;
use libluna_core::{Animation, Color, Easing, Point, Rect, Size, Theme};

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

/// Bok obszaru przycisku zamknięcia.
const CLOSE: f64 = 24.0;
/// Odstęp „grabbera” od górnej krawędzi.
const HANDLE_TOP: f64 = 8.0;

/// Dolny panel.
#[derive(Debug, Clone, Default)]
pub struct BottomSheet {
    title: Option<String>,
    open: bool,
    height: Option<f64>,
    /// Bieżące przesunięcie w dół podczas przeciągania (px).
    drag: f64,
    /// Pozycja wskaźnika (y) na początku przeciągania.
    drag_from: Option<f64>,
    hover_close: bool,
    /// Postęp wysuwania (0 = schowany, 1 = w pełni otwarty).
    animation: Animation,
}

impl BottomSheet {
    pub fn new() -> Self {
        Self::default()
    }

    pub fn title(mut self, title: impl Into<String>) -> Self {
        self.title = Some(title.into());
        self
    }

    pub fn set_title(&mut self, title: Option<String>) {
        self.title = title;
    }

    /// Wysokość panelu (domyślnie z tokenów motywu).
    pub fn set_height(&mut self, height: f64) {
        self.height = Some(height);
    }

    pub fn set_open(&mut self, open: bool) {
        self.open = open;
        self.animation.jump_to(if open { 1.0 } else { 0.0 });
        if !open {
            self.drag = 0.0;
            self.drag_from = None;
        }
    }

    /// Otwiera/zamyka panel z animacją wysuwania.
    pub fn set_open_animated(&mut self, open: bool) {
        self.set_open_animated_eased(open, Easing::EaseOut);
    }

    /// Jak [`BottomSheet::set_open_animated`], z wyborem krzywej.
    pub fn set_open_animated_eased(&mut self, open: bool, easing: Easing) {
        self.open = open;
        self.animation.set_easing(easing);
        self.animation.animate_to(if open { 1.0 } else { 0.0 });
    }

    /// Krok animacji wysuwania; zwraca `true`, gdy trwa dalej.
    pub fn advance(&mut self, dt: f64, duration: f64) -> bool {
        self.animation.advance(dt, duration)
    }

    pub fn is_animating(&self) -> bool {
        self.animation.is_animating()
    }

    /// Postęp wysuwania (0..1).
    pub fn progress(&self) -> f64 {
        self.animation.value()
    }

    pub fn is_open(&self) -> bool {
        self.open
    }

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

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

    /// Prostokąt panelu (przyklejony do dołu, z uwzględnieniem przeciągania).
    ///
    /// To geometria **docelowa** (ustabilizowana) — używana przez trafienia i układ treści.
    /// Wizualne wysuwanie dodaje [`BottomSheet::slide_offset`] tylko w `paint`.
    pub fn sheet_rect(&self, size: Size, theme: &Theme) -> Rect {
        let h = self.height(theme);
        Rect::from_xywh(0.0, size.height - h + self.drag, size.width, h)
    }

    /// Dodatkowe przesunięcie w dół podczas wysuwania (0, gdy w pełni otwarty).
    fn slide_offset(&self, theme: &Theme) -> f64 {
        (1.0 - self.animation.value().clamp(0.0, 1.0)) * self.height(theme)
    }

    /// Pełnoekranowe tło przygaszające.
    pub fn scrim_rect(&self, size: Size) -> Rect {
        Rect::from_xywh(0.0, 0.0, size.width, size.height)
    }

    /// Strefa chwytu do przeciągania (nagłówek z „grabberem”).
    pub fn drag_zone(&self, size: Size, theme: &Theme) -> Rect {
        let s = self.sheet_rect(size, theme);
        Rect::from_xywh(s.x(), s.y(), s.width(), theme.metrics.sheet.header)
    }

    /// Prostokąt „grabbera” (dla danej geometrii panelu).
    fn grabber_in(&self, sheet: Rect, theme: &Theme) -> Rect {
        let m = theme.metrics.sheet;
        Rect::from_xywh(
            sheet.center().x - m.handle_width / 2.0,
            sheet.y() + HANDLE_TOP,
            m.handle_width,
            m.handle_height,
        )
    }

    /// Prostokąt „grabbera”.
    pub fn grabber_rect(&self, size: Size, theme: &Theme) -> Rect {
        self.grabber_in(self.sheet_rect(size, theme), theme)
    }

    /// Prostokąt przycisku zamknięcia (dla danej geometrii panelu).
    fn close_in(&self, sheet: Rect, theme: &Theme) -> Rect {
        let md = theme.spacing.md;
        let cy = sheet.y() + theme.metrics.sheet.header / 2.0;
        Rect::from_xywh(sheet.right() - md - CLOSE, cy - CLOSE / 2.0, CLOSE, CLOSE)
    }

    /// Prostokąt przycisku zamknięcia.
    pub fn close_rect(&self, size: Size, theme: &Theme) -> Rect {
        self.close_in(self.sheet_rect(size, theme), theme)
    }

    /// Obszar zawartości (poniżej nagłówka).
    pub fn content_rect(&self, size: Size, theme: &Theme) -> Rect {
        let s = self.sheet_rect(size, theme);
        let header = theme.metrics.sheet.header;
        Rect::from_xywh(
            s.x(),
            s.y() + header,
            s.width(),
            (s.height() - header).max(0.0),
        )
    }

    /// Czy punkt trafia w przycisk zamknięcia.
    pub fn close_at(&self, size: Size, theme: &Theme, p: Point) -> bool {
        self.open && self.close_rect(size, theme).contains(p)
    }

    /// Czy punkt trafia w tło poza panelem (klik zamykający).
    pub fn scrim_at(&self, size: Size, theme: &Theme, p: Point) -> bool {
        self.open && !self.sheet_rect(size, theme).contains(p)
    }

    /// Czy punkt trafia w strefę przeciągania panelu.
    pub fn drag_zone_at(&self, size: Size, theme: &Theme, p: Point) -> bool {
        self.open && self.drag_zone(size, theme).contains(p)
    }

    /// Rozpoczyna przeciąganie od pozycji wskaźnika `y`.
    pub fn begin_drag(&mut self, y: f64) {
        self.drag_from = Some(y);
    }

    /// Aktualizuje przeciągnięcie (w dół).
    pub fn update_drag(&mut self, y: f64) {
        if let Some(from) = self.drag_from {
            self.drag = (y - from).max(0.0);
        }
    }

    /// Kończy przeciąganie; zwraca `true`, jeśli panel został zamknięty.
    pub fn end_drag(&mut self, theme: &Theme) -> bool {
        let dismissed = self.drag > theme.metrics.sheet.dismiss;
        if dismissed {
            self.open = false;
            self.animation.jump_to(0.0);
        }
        self.drag = 0.0;
        self.drag_from = None;
        dismissed
    }

    /// Rysuje przygaszenie i panel (na wierzchu sceny).
    pub fn paint(&self, ctx: &mut PaintCtx, size: Size) {
        let progress = self.animation.value();
        if progress <= 0.001 {
            return;
        }
        let theme = *ctx.theme;
        let m = theme.metrics.sheet;
        let colors = theme.colors;

        ctx.scene.fill_rect(
            self.scrim_rect(size),
            Color::rgba(
                0.0,
                0.0,
                0.0,
                m.scrim as f32 * progress.clamp(0.0, 1.0) as f32,
            ),
        );

        let settled = self.sheet_rect(size, &theme);
        let sheet = Rect::from_xywh(
            settled.x(),
            settled.y() + self.slide_offset(&theme),
            settled.width(),
            settled.height(),
        );
        // Panel: zaokrąglone górne narożniki, dolne „przycięte” do krawędzi.
        ctx.scene.fill_rounded_rect(sheet, m.radius, colors.card_bg);
        ctx.scene.fill_rect(
            Rect::from_xywh(
                sheet.x(),
                sheet.y() + m.radius,
                sheet.width(),
                (sheet.height() - m.radius).max(0.0),
            ),
            colors.card_bg,
        );
        ctx.scene.fill_rect(
            Rect::from_xywh(sheet.x(), sheet.y(), sheet.width(), 1.0),
            colors.border,
        );

        let g = self.grabber_in(sheet, &theme);
        ctx.scene
            .fill_rounded_rect(g, g.height() / 2.0, colors.dim_text.scale_alpha(0.5));

        if let Some(title) = &self.title {
            ctx.label_center(
                sheet.center().x,
                sheet.y() + m.header / 2.0,
                title,
                theme.typography.body.size,
                weight::BOLD,
                colors.text,
            );
        }

        let c = self.close_in(sheet, &theme);
        if self.hover_close {
            ctx.scene
                .fill_rounded_rect(c, theme.radius.small, colors.shade);
        }
        icon::close(ctx, colors.dim_text, c, 5.0);
    }
}

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

    fn size() -> Size {
        Size::new(900.0, 820.0)
    }

    #[test]
    fn sheet_is_bottom_anchored() {
        let theme = Theme::light();
        let mut s = BottomSheet::new();
        s.set_open(true);
        let r = s.sheet_rect(size(), &theme);
        assert!((r.bottom() - 820.0).abs() < 1e-9);
        assert!((r.height() - theme.metrics.sheet.height).abs() < 1e-9);
    }

    #[test]
    fn scrim_hit_is_above_sheet_only() {
        let theme = Theme::light();
        let mut s = BottomSheet::new();
        s.set_open(true);
        let sheet = s.sheet_rect(size(), &theme);
        assert!(s.scrim_at(size(), &theme, Point::new(450.0, sheet.y() - 20.0)));
        assert!(!s.scrim_at(size(), &theme, Point::new(450.0, sheet.y() + 20.0)));
    }

    #[test]
    fn close_hit_and_content_below_header() {
        let theme = Theme::light();
        let mut s = BottomSheet::new().title("O aplikacji");
        s.set_open(true);
        assert!(s.close_at(size(), &theme, s.close_rect(size(), &theme).center()));
        let c = s.content_rect(size(), &theme);
        assert!(c.y() >= s.sheet_rect(size(), &theme).y() + theme.metrics.sheet.header);
    }

    #[test]
    fn closed_sheet_ignores_hits() {
        let theme = Theme::light();
        let s = BottomSheet::new();
        assert!(!s.scrim_at(size(), &theme, Point::new(10.0, 10.0)));
        assert!(!s.close_at(size(), &theme, Point::new(880.0, 520.0)));
    }

    #[test]
    fn drag_past_threshold_dismisses() {
        let theme = Theme::light();
        let mut s = BottomSheet::new();
        s.set_open(true);
        s.begin_drag(500.0);
        s.update_drag(500.0 + theme.metrics.sheet.dismiss + 5.0);
        assert!(s.end_drag(&theme));
        assert!(!s.is_open());
    }

    #[test]
    fn small_drag_keeps_open() {
        let theme = Theme::light();
        let mut s = BottomSheet::new();
        s.set_open(true);
        s.begin_drag(500.0);
        s.update_drag(500.0 + 10.0);
        assert!(!s.end_drag(&theme));
        assert!(s.is_open());
    }

    #[test]
    fn animated_open_ramps_and_settles() {
        let theme = Theme::light();
        let mut s = BottomSheet::new();
        s.set_open_animated(true);
        assert!(s.is_animating());
        assert!(s.progress() < 1.0);
        // Geometria (trafienia) jest ustabilizowana niezależnie od postępu.
        assert!((s.sheet_rect(size(), &theme).bottom() - size().height).abs() < 1e-9);
        while s.is_animating() {
            s.advance(0.1, 0.3);
        }
        assert_eq!(s.progress(), 1.0);
        assert!((s.sheet_rect(size(), &theme).bottom() - size().height).abs() < 1e-9);
    }
}