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

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

187 lines Raw ← Back
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187
//! `Button` — przycisk z etykietą (warianty: domyślny, płaski, sugerowany, destrukcyjny).
//!
//! Widget immediate: trzyma wygląd (etykieta, wariant, stan), a aplikacja wywołuje
//! [`Button::paint`] i [`Button::click_at`].

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

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

/// Wariant wizualny przycisku.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ButtonVariant {
    /// Tło „shade” (jak przyciski w HeaderBarze).
    #[default]
    Default,
    /// Bez tła; tło pojawia się przy najechaniu/wciśnięciu.
    Flat,
    /// Sugerowana akcja (akcent).
    Suggested,
    /// Akcja destrukcyjna (kolor błędu).
    Destructive,
}

/// Przycisk z etykietą.
#[derive(Debug, Clone)]
pub struct Button {
    label: String,
    variant: ButtonVariant,
    dropdown: bool,
    enabled: bool,
    hovered: bool,
    pressed: bool,
}

impl Button {
    pub fn new(label: impl Into<String>) -> Self {
        Self {
            label: label.into(),
            variant: ButtonVariant::Default,
            dropdown: false,
            enabled: true,
            hovered: false,
            pressed: false,
        }
    }

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

    /// Dodaje na końcu wskaźnik rozwijania (chevron) — przyciski menu.
    pub fn dropdown(mut self, dropdown: bool) -> Self {
        self.dropdown = dropdown;
        self
    }

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

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

    pub fn set_pressed(&mut self, pressed: bool) {
        self.pressed = pressed;
    }

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

    pub fn is_enabled(&self) -> bool {
        self.enabled
    }

    /// Szerokość dopasowana do etykiety.
    pub fn width(&self, text: &TextEngine, theme: &Theme) -> f64 {
        let m = theme.metrics.button;
        let body = theme.typography.body.size;
        let mut w = m.pad_x * 2.0 + text.measure(&self.label, body, weight::NORMAL).width;
        if self.dropdown {
            w += m.chevron_gap + m.chevron;
        }
        w
    }

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

    pub fn hit(&self, rect: Rect, p: Point) -> bool {
        rect.contains(p)
    }

    /// Zwraca `true`, jeśli klik trafił w aktywny przycisk.
    pub fn click_at(&self, rect: Rect, p: Point) -> bool {
        self.enabled && rect.contains(p)
    }

    pub fn paint(&self, ctx: &mut PaintCtx, rect: Rect) {
        let theme = *ctx.theme;
        let colors = theme.colors;
        let radius = theme.radius.small;
        let strong = self.pressed || self.hovered;

        match self.variant {
            ButtonVariant::Suggested => {
                ctx.scene.fill_rounded_rect(rect, radius, colors.accent);
            }
            ButtonVariant::Destructive => {
                ctx.scene.fill_rounded_rect(rect, radius, colors.error);
            }
            ButtonVariant::Default => {
                ctx.scene
                    .fill_rounded_rect(rect, radius, shade(&colors, strong));
            }
            ButtonVariant::Flat => {
                if strong {
                    ctx.scene
                        .fill_rounded_rect(rect, radius, shade(&colors, false));
                }
            }
        }

        let fg = match self.variant {
            ButtonVariant::Suggested | ButtonVariant::Destructive => colors.accent_fg,
            _ if self.enabled => colors.text,
            _ => colors.dim_text,
        };

        let body = theme.typography.body.size;
        let m = theme.metrics.button;
        let cy = rect.center().y;
        let label_w = ctx.measure(&self.label, body, weight::NORMAL);
        let content_w = label_w
            + if self.dropdown {
                m.chevron_gap + m.chevron
            } else {
                0.0
            };
        let start_x = rect.center().x - content_w / 2.0;
        ctx.label_left(start_x, cy, &self.label, body, weight::NORMAL, fg);

        if self.dropdown {
            let cx = start_x + label_w + m.chevron_gap + m.chevron / 2.0;
            icon::chevron_down(ctx, fg, Point::new(cx, cy), 4.0, 2.0);
        }
    }
}

fn shade(colors: &ColorScheme, strong: bool) -> Color {
    if strong {
        colors.shade.scale_alpha(1.8)
    } else {
        colors.shade
    }
}

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

    #[test]
    fn click_only_when_enabled_and_inside() {
        let rect = Rect::from_xywh(0.0, 0.0, 100.0, 34.0);
        let b = Button::new("OK");
        assert!(b.click_at(rect, Point::new(50.0, 17.0)));
        assert!(!b.click_at(rect, Point::new(200.0, 17.0)));
        let disabled = Button::new("OK").enabled(false);
        assert!(!disabled.click_at(rect, Point::new(50.0, 17.0)));
    }

    #[test]
    fn width_grows_with_dropdown() {
        let theme = Theme::light();
        let text = TextEngine::system();
        let plain = Button::new("Opcje");
        let dd = Button::new("Opcje").dropdown(true);
        assert!(dd.width(&text, &theme) > plain.width(&text, &theme));
    }
}