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

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

284 lines Raw ← Back
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284
//! `InfoBar` — pasek informacyjny (odpowiednik GTK4 `GtkInfoBar`).
//!
//! W odróżnieniu od libadwaitowej karty [`Banner`](crate::Banner), `InfoBar` ma cztery
//! rodzaje (info/pytanie/ostrzeżenie/błąd), opcjonalny przycisk zamknięcia oraz dowolną
//! liczbę przycisków akcji — jak w GTK4.

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

use crate::ctx::PaintCtx;

/// Rodzaj paska (kolor ikony i paska po lewej).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum InfoBarKind {
    #[default]
    Info,
    Question,
    Warning,
    Error,
}

/// Pasek informacyjny.
#[derive(Debug, Clone, Default)]
pub struct InfoBar {
    kind: InfoBarKind,
    message: String,
    actions: Vec<String>,
    show_close: bool,
}

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

impl InfoBar {
    pub fn new(message: impl Into<String>) -> Self {
        Self {
            kind: InfoBarKind::Info,
            message: message.into(),
            actions: Vec::new(),
            show_close: true,
        }
    }

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

    pub fn action(mut self, label: impl Into<String>) -> Self {
        self.actions.push(label.into());
        self
    }

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

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

    /// Symbol rodzaju rysowany fontem interfejsu.
    pub fn glyph(&self) -> &'static str {
        match self.kind {
            InfoBarKind::Info => "●",
            InfoBarKind::Question => "?",
            InfoBarKind::Warning => "▲",
            InfoBarKind::Error => "✗",
        }
    }

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

    pub fn rects(&self, area: Rect, theme: &Theme, text: &TextEngine) -> InfoBarRects {
        let m = theme.metrics.banner;
        let body = theme.typography.body.size;
        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);

        // Zamknięcie po prawej.
        let mut right = inner.right();
        let close = if self.show_close {
            let r = Rect::from_xywh(
                right - m.button_h,
                cy - m.button_h / 2.0,
                m.button_h,
                m.button_h,
            );
            right = r.x() - m.gap;
            Some(r)
        } else {
            None
        };

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

        let text_left = icon.right() + m.gap;
        let width = (right - text_left).max(0.0);
        let message = Rect::from_xywh(text_left, cy - body / 2.0 - 2.0, width, body + 4.0);

        InfoBarRects {
            icon,
            message,
            actions,
            close,
        }
    }

    /// 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))
    }

    /// Czy kliknięto przycisk zamknięcia.
    pub fn close_at(&self, area: Rect, theme: &Theme, text: &TextEngine, p: Point) -> bool {
        self.rects(area, theme, text)
            .close
            .map(|r| r.contains(p))
            .unwrap_or(false)
    }

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

        ctx.scene
            .fill_rounded_rect(area, theme.radius.medium, accent.scale_alpha(0.14));
        ctx.scene
            .stroke_rounded_rect(area, theme.radius.medium, 1.0, accent.scale_alpha(0.35));

        // Ikona rodzaju.
        ctx.label_center(
            r.icon.center().x,
            r.icon.center().y,
            self.glyph(),
            theme.typography.body.size,
            weight::BOLD,
            accent,
        );

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

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

        if let Some(close) = r.close {
            ctx.scene
                .fill_rounded_rect(close, theme.radius.small, theme.colors.shade);
            ctx.label_center(
                close.center().x,
                close.center().y,
                "✗",
                theme.typography.caption.size,
                weight::MEDIUM,
                theme.colors.text,
            );
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use libluna_render::{Scene, TextEngine};

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

    #[test]
    fn layout_icon_left_actions_and_close_right() {
        let theme = Theme::light();
        let text = TextEngine::none();
        let bar = InfoBar::new("Zapisano").action("Cofnij");
        let r = bar.rects(area(), &theme, &text);
        assert!(r.icon.x() < r.message.x());
        assert!(r.close.is_some());
        assert_eq!(r.actions.len(), 1);
        assert!(r.actions[0].right() <= r.close.unwrap().x());
        assert!(r.message.right() <= r.actions[0].x());
    }

    #[test]
    fn hit_tests_for_action_and_close() {
        let theme = Theme::light();
        let text = TextEngine::none();
        let bar = InfoBar::new("Pytanie?")
            .kind(InfoBarKind::Question)
            .action("Tak");
        let r = bar.rects(area(), &theme, &text);
        assert_eq!(
            bar.action_at(area(), &theme, &text, r.actions[0].center()),
            Some(0)
        );
        assert!(bar.close_at(area(), &theme, &text, r.close.unwrap().center()));
        assert!(!bar.close_at(area(), &theme, &text, Point::new(2.0, 2.0)));
    }

    #[test]
    fn close_can_be_hidden() {
        let theme = Theme::light();
        let text = TextEngine::none();
        let bar = InfoBar::new("Info").show_close(false);
        assert!(bar.rects(area(), &theme, &text).close.is_none());
    }

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

    #[test]
    fn paints_without_window() {
        let theme = Theme::dark();
        let text = TextEngine::none();
        let mut scene = Scene::new();
        let mut ctx = PaintCtx::new(&mut scene, &theme, &text);
        InfoBar::new("Zapisano zmiany")
            .action("Cofnij")
            .paint(&mut ctx, area());
        assert!(scene.len() > 5);
    }
}