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

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

294 lines Raw ← Back
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294
//! `HeaderBar` — pasek tytułu okna (CSD): tło, tytuł, kontrolki okna i obszar na treść.
//!
//! Widget immediate: liczy rozkład ([`HeaderBar::controls_layout`]), rysuje
//! ([`HeaderBar::paint`]) i podpowiada trafienia ([`HeaderBar::control_at`]). Aplikacja
//! wypełnia zwrócony `content` własnymi widgetami (np. przełącznik paska bocznego, menu).

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

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

/// Kontrolka okna.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WindowControl {
    Minimize,
    Maximize,
    Close,
}

/// Po której stronie rysować kontrolki okna.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ControlsSide {
    Left,
    #[default]
    Right,
    None,
}

/// Wyrównanie tytułu.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum TitleAlign {
    #[default]
    Center,
    Start,
}

/// Rozkład paska: prostokąty kontrolek i wolny obszar na treść.
#[derive(Debug, Clone, Copy)]
pub struct ControlsLayout {
    pub minimize: Option<Rect>,
    pub maximize: Option<Rect>,
    pub close: Option<Rect>,
    /// Wolny obszar (między krawędzią a kontrolkami) na własne widgety aplikacji.
    pub content: Rect,
}

/// Pasek tytułu (HeaderBar).
#[derive(Debug, Clone, Default)]
pub struct HeaderBar {
    title: Option<String>,
    title_align: TitleAlign,
    controls: ControlsSide,
    /// Opcjonalne barwy dobrane do okna: `(tło, tekst/ikony)`.
    tint: Option<(Color, Color)>,
}

impl HeaderBar {
    pub fn new() -> Self {
        Self {
            title: None,
            title_align: TitleAlign::Center,
            controls: ControlsSide::Right,
            tint: None,
        }
    }

    /// Nadpisuje barwy paska (np. kolorem dobranym do treści okna).
    pub fn tinted(mut self, bg: Color, fg: Color) -> Self {
        self.tint = Some((bg, fg));
        self
    }

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

    pub fn title_align(mut self, align: TitleAlign) -> Self {
        self.title_align = align;
        self
    }

    pub fn controls(mut self, side: ControlsSide) -> Self {
        self.controls = side;
        self
    }

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

    /// Rozkłada kontrolki i wolny obszar w prostokącie paska.
    pub fn controls_layout(&self, rect: Rect, theme: &Theme) -> ControlsLayout {
        let metrics = theme.metrics.headerbar;
        let pad = theme.spacing.sm;
        let control = metrics.control;
        let gap = metrics.control_gap;
        let y = rect.center().y - control / 2.0;
        let control = |x: f64| Rect::from_xywh(x, y, control, control);

        match self.controls {
            ControlsSide::None => ControlsLayout {
                minimize: None,
                maximize: None,
                close: None,
                content: rect.deflate(Insets::symmetric(0.0, pad)),
            },
            ControlsSide::Right => {
                let close_x = rect.right() - pad - metrics.control;
                let max_x = close_x - gap - metrics.control;
                let min_x = max_x - gap - metrics.control;
                let content = Rect::from_xywh(
                    rect.x() + pad,
                    rect.y(),
                    (min_x - pad - (rect.x() + pad)).max(0.0),
                    rect.height(),
                );
                ControlsLayout {
                    minimize: Some(control(min_x)),
                    maximize: Some(control(max_x)),
                    close: Some(control(close_x)),
                    content,
                }
            }
            ControlsSide::Left => {
                let min_x = rect.x() + pad;
                let max_x = min_x + metrics.control + gap;
                let close_x = max_x + metrics.control + gap;
                let left = close_x + metrics.control + pad;
                let content = Rect::from_xywh(
                    left,
                    rect.y(),
                    (rect.right() - pad - left).max(0.0),
                    rect.height(),
                );
                ControlsLayout {
                    minimize: Some(control(min_x)),
                    maximize: Some(control(max_x)),
                    close: Some(control(close_x)),
                    content,
                }
            }
        }
    }

    /// Która kontrolka okna jest pod punktem `p` (albo `None`).
    pub fn control_at(&self, rect: Rect, theme: &Theme, p: Point) -> Option<WindowControl> {
        let layout = self.controls_layout(rect, theme);
        for (control, hit) in [
            (WindowControl::Minimize, layout.minimize),
            (WindowControl::Maximize, layout.maximize),
            (WindowControl::Close, layout.close),
        ] {
            if hit.is_some_and(|r| r.contains(p)) {
                return Some(control);
            }
        }
        None
    }

    /// Rysuje tło, tytuł i kontrolki okna. `hovered` podświetla wskazaną kontrolkę.
    pub fn paint(&self, ctx: &mut PaintCtx, rect: Rect, hovered: Option<WindowControl>) {
        let theme = *ctx.theme;
        let colors = theme.colors;
        let layout = self.controls_layout(rect, &theme);
        let (bg, fg_default) = self.tint.unwrap_or((colors.headerbar_bg, colors.text));

        ctx.scene.fill_rect(rect, bg);
        let border = if self.tint.is_some() {
            fg_default.scale_alpha(0.18)
        } else {
            colors.border
        };
        ctx.scene.fill_rect(
            Rect::from_xywh(rect.x(), rect.bottom() - 1.0, rect.width(), 1.0),
            border,
        );

        for (control, hit) in [
            (WindowControl::Minimize, layout.minimize),
            (WindowControl::Maximize, layout.maximize),
            (WindowControl::Close, layout.close),
        ] {
            let Some(box_) = hit else { continue };
            let active = hovered == Some(control);
            if active {
                let bg = if control == WindowControl::Close {
                    colors.error
                } else {
                    fg_default.scale_alpha(0.15)
                };
                ctx.scene.fill_rounded_rect(box_, theme.radius.small, bg);
            }
            let fg = if active && control == WindowControl::Close {
                colors.accent_fg
            } else {
                fg_default
            };
            match control {
                WindowControl::Minimize => icon::minimize(ctx, fg, box_, 5.0),
                WindowControl::Maximize => icon::maximize(ctx, fg, box_, 5.0),
                WindowControl::Close => icon::close(ctx, fg, box_, 5.0),
            }
        }

        if let Some(title) = &self.title {
            let size = theme.typography.body.size;
            match self.title_align {
                TitleAlign::Center => ctx.label_center(
                    rect.center().x,
                    rect.center().y,
                    title,
                    size,
                    weight::BOLD,
                    fg_default,
                ),
                TitleAlign::Start => {
                    let x = layout.content.x();
                    ctx.label_left(x, rect.center().y, title, size, weight::BOLD, fg_default)
                }
            }
        }
    }
}

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

    fn bar() -> Rect {
        Rect::from_xywh(0.0, 0.0, 900.0, Theme::light().metrics.headerbar.height)
    }

    #[test]
    fn right_side_controls_are_at_right_edge() {
        let theme = Theme::light();
        let hb = HeaderBar::new().controls(ControlsSide::Right);
        let layout = hb.controls_layout(bar(), &theme);
        let close = layout.close.unwrap();
        let minimize = layout.minimize.unwrap();
        assert!(close.right() <= bar().right());
        assert!(close.x() > minimize.x());
        // Treść nie nachodzi na kontrolki.
        assert!(layout.content.right() <= minimize.x());
    }

    #[test]
    fn left_side_controls_are_at_left_edge() {
        let theme = Theme::light();
        let hb = HeaderBar::new().controls(ControlsSide::Left);
        let layout = hb.controls_layout(bar(), &theme);
        let minimize = layout.minimize.unwrap();
        let close = layout.close.unwrap();
        assert!(minimize.x() >= bar().x());
        assert!(close.x() > minimize.x());
        assert!(layout.content.x() >= close.right());
    }

    #[test]
    fn control_at_hits_close() {
        let theme = Theme::light();
        let hb = HeaderBar::new().controls(ControlsSide::Right);
        let close = hb.controls_layout(bar(), &theme).close.unwrap();
        assert_eq!(
            hb.control_at(bar(), &theme, close.center()),
            Some(WindowControl::Close)
        );
        assert_eq!(hb.control_at(bar(), &theme, Point::new(400.0, 23.0)), None);
    }

    #[test]
    fn no_controls_leaves_full_content() {
        let theme = Theme::light();
        let hb = HeaderBar::new().controls(ControlsSide::None);
        let layout = hb.controls_layout(bar(), &theme);
        assert!(layout.close.is_none());
        assert!(layout.content.width() > 800.0);
    }

    #[test]
    fn metrics_drive_bar_and_control_geometry() {
        let mut theme = Theme::light();
        theme.metrics.headerbar.control = 40.0;
        theme.metrics.headerbar.height = 60.0;
        let hb = HeaderBar::new();
        assert_eq!(hb.height(&theme), 60.0);
        let rect = Rect::from_xywh(0.0, 0.0, 900.0, hb.height(&theme));
        let close = hb.controls_layout(rect, &theme).close.unwrap();
        assert_eq!(close.width(), 40.0);
        assert_eq!(close.height(), 40.0);
    }
}