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

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

197 lines Raw ← Back
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197
//! `ColorChooser` — wybór koloru z palety (GTK4, w duchu libLuny).
//!
//! To nie jest pełny `GtkColorChooser` z kolem barw — libLuna daje zwięzłą **paletę
//! próbek** (`swatch`): kwadratowa siatka kolorów z wyborem. Idealna do wyboru akcentu
//! w ustawieniach.

use libluna_core::{Color, Point, Rect, Theme};

use crate::ctx::PaintCtx;

/// Paleta próbek kolorów.
#[derive(Debug, Clone, Default)]
pub struct ColorChooser {
    colors: Vec<Color>,
    selected: usize,
    columns: usize,
}

impl ColorChooser {
    /// Paleta domyślna (osiem wyrazistych kolorów w duchu libadwaita).
    pub fn default_palette() -> Vec<Color> {
        vec![
            Color::from_rgba8(0x35, 0x84, 0xe4, 0xff),
            Color::from_rgba8(0x91, 0x41, 0xac, 0xff),
            Color::from_rgba8(0xc0, 0x1c, 0x28, 0xff),
            Color::from_rgba8(0xe5, 0xa5, 0x0a, 0xff),
            Color::from_rgba8(0x2e, 0xc2, 0x7e, 0xff),
            Color::from_rgba8(0x1a, 0x5f, 0xb4, 0xff),
            Color::from_rgba8(0x5e, 0x5c, 0x64, 0xff),
            Color::from_rgba8(0xe0, 0x1b, 0x24, 0xff),
        ]
    }

    /// Paleta z listy kolorów (wybrany pierwszy, 4 kolumny).
    pub fn new(colors: Vec<Color>) -> Self {
        Self {
            colors,
            selected: 0,
            columns: 4,
        }
    }

    pub fn colors(&self) -> &[Color] {
        &self.colors
    }

    pub fn columns(&self) -> usize {
        self.columns
    }

    /// Ustawia liczbę kolumn siatki.
    pub fn set_columns(&mut self, columns: usize) {
        self.columns = columns.max(1);
    }

    pub fn selected(&self) -> usize {
        self.selected
    }

    pub fn selected_color(&self) -> Option<Color> {
        self.colors.get(self.selected).copied()
    }

    /// Ustawia wybraną próbkę (poza zakresem jest ignorowane).
    pub fn set_selected(&mut self, index: usize) {
        if index < self.colors.len() {
            self.selected = index;
        }
    }

    /// Prostokąty próbek (kwadraty wypełniające szerokość, wierszami).
    pub fn cells(&self, rect: Rect, theme: &Theme) -> Vec<Rect> {
        if self.colors.is_empty() {
            return Vec::new();
        }
        let cols = self.columns.max(1);
        let gap = theme.spacing.sm;
        let side = ((rect.width() - gap * (cols as f64 - 1.0)) / cols as f64).max(0.0);
        self.colors
            .iter()
            .enumerate()
            .map(|(i, _)| {
                let row = i / cols;
                let col = i % cols;
                Rect::from_xywh(
                    rect.x() + col as f64 * (side + gap),
                    rect.y() + row as f64 * (side + gap),
                    side,
                    side,
                )
            })
            .collect()
    }

    /// Indeks próbki pod punktem `p` (lub `None`).
    pub fn index_at(&self, rect: Rect, theme: &Theme, p: Point) -> Option<usize> {
        self.cells(rect, theme).iter().position(|c| c.contains(p))
    }

    /// Obsługuje klik: wybiera próbkę. Zwraca `true`, gdy wybór się zmienił.
    pub fn click_at(&mut self, rect: Rect, theme: &Theme, p: Point) -> bool {
        match self.index_at(rect, theme, p) {
            Some(i) if i != self.selected => {
                self.selected = i;
                true
            }
            _ => false,
        }
    }

    /// Wysokość potrzebna na całą siatkę.
    pub fn height(&self, rect_width: f64, theme: &Theme) -> f64 {
        if self.colors.is_empty() {
            return 0.0;
        }
        let cols = self.columns.max(1);
        let gap = theme.spacing.sm;
        let side = ((rect_width - gap * (cols as f64 - 1.0)) / cols as f64).max(0.0);
        let rows = self.colors.len().div_ceil(cols) as f64;
        rows * side + (rows - 1.0) * gap
    }

    /// Rysuje siatkę próbek; wybrana dostaje obwódkę akcentu.
    pub fn paint(&self, ctx: &mut PaintCtx, rect: Rect) {
        let theme = *ctx.theme;
        let cells = self.cells(rect, &theme);
        for (i, cell) in cells.iter().enumerate() {
            let swatch = cell.deflate(libluna_core::Insets::splat(2.0));
            ctx.scene
                .fill_rounded_rect(swatch, theme.radius.small, self.colors[i]);
            if i == self.selected {
                ctx.scene
                    .stroke_rounded_rect(swatch, theme.radius.small, 2.0, theme.colors.accent);
            } else {
                ctx.scene
                    .stroke_rounded_rect(swatch, theme.radius.small, 1.0, theme.colors.border);
            }
        }
    }
}

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

    fn chooser() -> ColorChooser {
        ColorChooser::new(ColorChooser::default_palette())
    }

    #[test]
    fn cells_fill_width_row_major() {
        let theme = Theme::light();
        let c = chooser();
        let rect = Rect::from_xywh(0.0, 0.0, 200.0, 200.0);
        let cells = c.cells(rect, &theme);
        assert_eq!(cells.len(), 8);
        let gap = theme.spacing.sm;
        let side = (200.0 - 3.0 * gap) / 4.0;
        assert!((cells[0].width() - side).abs() < 1e-9);
        // Piąta próbka zaczyna drugi wiersz.
        assert_eq!(cells[4].x(), 0.0);
        assert!(cells[4].y() > cells[0].y());
        assert!((cells[3].right() - 200.0).abs() < 1e-9);
    }

    #[test]
    fn click_selects_and_reports_change() {
        let theme = Theme::light();
        let rect = Rect::from_xywh(0.0, 0.0, 200.0, 200.0);
        let mut c = chooser();
        let cells = c.cells(rect, &theme);
        assert!(c.click_at(rect, &theme, cells[2].center()));
        assert_eq!(c.selected(), 2);
        assert_eq!(c.selected_color(), Some(c.colors()[2]));
        // Ponowny klik w tę samą próbkę nic nie zmienia.
        assert!(!c.click_at(rect, &theme, cells[2].center()));
        // Klik poza siatką nie zmienia wyboru.
        assert!(!c.click_at(rect, &theme, Point::new(999.0, 999.0)));
    }

    #[test]
    fn height_matches_grid_rows() {
        let theme = Theme::light();
        let c = chooser(); // 8 kolorów, 4 kolumny → 2 wiersze
        let w = 200.0;
        let side = (w - 3.0 * theme.spacing.sm) / 4.0;
        let expected = 2.0 * side + theme.spacing.sm;
        assert!((c.height(w, &theme) - expected).abs() < 1e-9);
    }

    #[test]
    fn selection_out_of_range_is_ignored() {
        let mut c = chooser();
        c.set_selected(99);
        assert_eq!(c.selected(), 0);
    }
}