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

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

250 lines Raw ← Back
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250
//! `Banner` — pasek komunikatu (info/ostrzeżenie/błąd) z tytułem, opisem i akcjami.
//!
//! Widget immediate: [`Banner::rects`] liczy rozmieszczenie (ikona, tytuł, opis, przyciski),
//! [`Banner::action_at`] obsługuje klik w akcje, a [`Banner::paint`] rysuje pasek.

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

use crate::ctx::PaintCtx;

/// Rodzaj komunikatu (wpływa na kolor akcentowy paska).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum BannerKind {
    #[default]
    Info,
    Warning,
    Error,
}

/// Pasek komunikatu.
#[derive(Debug, Clone, Default)]
pub struct Banner {
    kind: BannerKind,
    title: String,
    description: Option<String>,
    actions: Vec<String>,
}

/// Rozmieszczenie elementów paska.
#[derive(Debug, Clone, PartialEq)]
pub struct BannerRects {
    pub icon: Rect,
    pub title: Rect,
    pub description: Option<Rect>,
    pub actions: Vec<Rect>,
}

impl Banner {
    pub fn new(title: impl Into<String>) -> Self {
        Self {
            kind: BannerKind::Info,
            title: title.into(),
            description: None,
            actions: Vec::new(),
        }
    }

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

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

    /// Dodaje przycisk akcji (od lewej do prawej).
    pub fn action(mut self, label: impl Into<String>) -> Self {
        self.actions.push(label.into());
        self
    }

    /// Wysokość paska.
    pub fn height(&self, theme: &Theme) -> f64 {
        theme.metrics.banner.min_height
    }

    /// Kolor akcentowy paska (wg rodzaju).
    pub fn color(&self, theme: &Theme) -> Color {
        match self.kind {
            BannerKind::Info => theme.colors.accent,
            BannerKind::Warning => theme.colors.warning,
            BannerKind::Error => theme.colors.error,
        }
    }

    pub fn rects(&self, area: Rect, theme: &Theme, text: &TextEngine) -> BannerRects {
        let m = theme.metrics.banner;
        let body = theme.typography.body;
        let inner = area.deflate(Insets::splat(m.pad));
        let cy = inner.center().y;

        let icon = Rect::from_xywh(inner.x(), cy - m.icon / 2.0, m.icon, m.icon);

        // Akcje wyrównane do prawej.
        let mut actions: Vec<Rect> = Vec::new();
        let mut x = inner.right();
        for label in self.actions.iter().rev() {
            let w = 2.0 * theme.spacing.md + text.measure(label, body.size, weight::MEDIUM).width;
            actions.push(Rect::from_xywh(x - w, cy - m.button_h / 2.0, w, m.button_h));
            x -= w + m.gap;
        }
        actions.reverse();
        let actions_left = actions.first().map(|r| r.x()).unwrap_or(inner.right());

        let text_left = icon.right() + m.gap;
        let text_right = (actions_left - m.gap).max(text_left);
        let width = (text_right - text_left).max(0.0);

        let (title, description) = match self.description.is_some() {
            true => {
                let block = body.line_height * 2.0;
                let top = cy - block / 2.0;
                (
                    Rect::from_xywh(text_left, top, width, body.line_height),
                    Some(Rect::from_xywh(
                        text_left,
                        top + body.line_height,
                        width,
                        body.line_height,
                    )),
                )
            }
            false => (
                Rect::from_xywh(
                    text_left,
                    cy - body.line_height / 2.0,
                    width,
                    body.line_height,
                ),
                None,
            ),
        };

        BannerRects {
            icon,
            title,
            description,
            actions,
        }
    }

    /// Indeks akcji pod punktem `p`.
    pub fn action_at(
        &self,
        area: Rect,
        theme: &Theme,
        text: &TextEngine,
        p: Point,
    ) -> Option<usize> {
        self.rects(area, theme, text)
            .actions
            .iter()
            .position(|r| r.contains(p))
    }

    pub fn paint(&self, ctx: &mut PaintCtx, area: Rect) {
        let theme = *ctx.theme;
        let colors = theme.colors;
        let accent = self.color(&theme);
        let r = self.rects(area, &theme, ctx.text);

        // Tło (delikatny odcień) + pasek po lewej w kolorze rodzaju.
        ctx.scene
            .fill_rounded_rect(area, theme.radius.medium, accent.scale_alpha(0.16));
        let bar = Rect::from_xywh(area.x(), area.y(), 4.0, area.height());
        ctx.scene
            .fill_rounded_rect(bar, theme.radius.medium, accent);

        // Ikona (kropka w kolorze rodzaju).
        ctx.scene
            .fill_rounded_rect(r.icon, r.icon.width() / 2.0, accent);

        ctx.label_left(
            r.title.x(),
            r.title.center().y,
            &self.title,
            theme.typography.body.size,
            weight::BOLD,
            colors.text,
        );

        if let (Some(description), Some(rect)) = (&self.description, r.description) {
            ctx.label_left(
                rect.x(),
                rect.center().y,
                description,
                theme.typography.body.size,
                weight::NORMAL,
                colors.dim_text,
            );
        }

        for (label, rect) in self.actions.iter().zip(&r.actions) {
            ctx.scene
                .fill_rounded_rect(*rect, theme.radius.small, colors.shade);
            ctx.label_center(
                rect.center().x,
                rect.center().y,
                label,
                theme.typography.body.size,
                weight::MEDIUM,
                colors.text,
            );
        }
    }
}

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

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

    #[test]
    fn layout_places_icon_left_and_actions_right() {
        let theme = Theme::light();
        let text = TextEngine::system();
        let banner = Banner::new("Błąd")
            .kind(BannerKind::Error)
            .description("Nie udało się zapisać")
            .action("Ponów");
        let rects = banner.rects(area(), &theme, &text);
        assert!(rects.icon.x() < rects.title.x());
        assert!(rects.description.is_some());
        assert_eq!(rects.actions.len(), 1);
        assert!(rects.actions[0].right() <= area().right());
        assert!(rects.title.right() <= rects.actions[0].x());
    }

    #[test]
    fn action_hit_test() {
        let theme = Theme::light();
        let text = TextEngine::system();
        let banner = Banner::new("Info").action("OK");
        let r = banner.rects(area(), &theme, &text);
        assert_eq!(
            banner.action_at(area(), &theme, &text, r.actions[0].center()),
            Some(0)
        );
        assert_eq!(
            banner.action_at(area(), &theme, &text, Point::new(2.0, 2.0)),
            None
        );
    }

    #[test]
    fn colors_per_kind() {
        let theme = Theme::light();
        assert_eq!(Banner::new("x").color(&theme), theme.colors.accent);
        assert_eq!(
            Banner::new("x").kind(BannerKind::Error).color(&theme),
            theme.colors.error
        );
    }
}