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

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

280 lines Raw ← Back
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280
//! `ColumnView` — tabela z kolumnami i wierszami (GTK4).
//!
//! Widget immediate: trzyma definicje kolumn, liczbę wierszy i wybór. Rysuje nagłówek
//! i siatkę; **treść komórek** rysuje aplikacja w prostokątach z [`ColumnView::cell_rect`].

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

use crate::ctx::PaintCtx;

/// Definicja kolumny: tytuł i waga szerokości (udziały normalizowane do szerokości tabeli).
#[derive(Debug, Clone, PartialEq)]
pub struct Column {
    pub title: String,
    pub weight: f64,
}

impl Column {
    pub fn new(title: impl Into<String>) -> Self {
        Self {
            title: title.into(),
            weight: 1.0,
        }
    }

    /// Ustawia wagę szerokości kolumny.
    pub fn weight(mut self, weight: f64) -> Self {
        self.weight = weight.max(0.0);
        self
    }
}

/// Tabela (widok kolumnowy).
#[derive(Debug, Clone, Default)]
pub struct ColumnView {
    columns: Vec<Column>,
    rows: usize,
    selected: Option<usize>,
    hovered: Option<usize>,
}

impl ColumnView {
    pub fn new(columns: Vec<Column>) -> Self {
        Self {
            columns,
            ..Self::default()
        }
    }

    pub fn columns(&self) -> &[Column] {
        &self.columns
    }

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

    pub fn set_rows(&mut self, rows: usize) {
        self.rows = rows;
        if let Some(s) = self.selected {
            if s >= rows {
                self.selected = None;
            }
        }
    }

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

    pub fn set_selected(&mut self, index: Option<usize>) {
        self.selected = index.filter(|i| *i < self.rows);
    }

    pub fn set_hovered(&mut self, hovered: Option<usize>) {
        self.hovered = hovered.filter(|i| *i < self.rows);
    }

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

    pub fn row_height(&self, theme: &Theme) -> f64 {
        theme.typography.body.line_height + theme.spacing.md
    }

    /// Granice kolumn (współrzędna `x` i szerokość) rozłożone wg wag.
    fn spans(&self, rect: Rect) -> Vec<(f64, f64)> {
        let total: f64 = self.columns.iter().map(|c| c.weight).sum();
        if total <= 0.0 || self.columns.is_empty() {
            return vec![(rect.x(), 0.0); self.columns.len()];
        }
        let mut x = rect.x();
        self.columns
            .iter()
            .map(|c| {
                let w = rect.width() * (c.weight / total);
                let span = (x, w);
                x += w;
                span
            })
            .collect()
    }

    /// Prostokąty nagłówków kolumn.
    pub fn header_cells(&self, rect: Rect, theme: &Theme) -> Vec<Rect> {
        let h = self.header_height(theme);
        self.spans(rect)
            .into_iter()
            .map(|(x, w)| Rect::from_xywh(x, rect.y(), w, h))
            .collect()
    }

    /// Obszar wierszy (pod nagłówkiem).
    pub fn rows_rect(&self, rect: Rect, theme: &Theme) -> Rect {
        let h = self.header_height(theme);
        Rect::from_xywh(
            rect.x(),
            rect.y() + h,
            rect.width(),
            (rect.height() - h).max(0.0),
        )
    }

    /// Prostokąt wiersza `index`.
    pub fn row_rect(&self, rect: Rect, theme: &Theme, index: usize) -> Rect {
        let area = self.rows_rect(rect, theme);
        let rh = self.row_height(theme);
        Rect::from_xywh(area.x(), area.y() + index as f64 * rh, area.width(), rh)
    }

    /// Prostokąt komórki `(row, col)`.
    pub fn cell_rect(&self, rect: Rect, theme: &Theme, row: usize, col: usize) -> Rect {
        let r = self.row_rect(rect, theme, row);
        match self.spans(rect).get(col) {
            Some(&(x, w)) => Rect::from_xywh(x, r.y(), w, r.height()),
            None => Rect::from_xywh(rect.x(), r.y(), 0.0, r.height()),
        }
    }

    /// Indeks wiersza pod punktem `p` (tylko w obszarze wierszy).
    pub fn row_at(&self, rect: Rect, theme: &Theme, p: Point) -> Option<usize> {
        let area = self.rows_rect(rect, theme);
        if !area.contains(p) {
            return None;
        }
        let rh = self.row_height(theme);
        if rh <= 0.0 {
            return None;
        }
        let idx = ((p.y - area.y()) / rh) as usize;
        if idx < self.rows {
            Some(idx)
        } else {
            None
        }
    }

    /// Rysuje nagłówek (tło, tytuły, separator dolny i pionowe) oraz zaznaczony wiersz.
    pub fn paint(&self, ctx: &mut PaintCtx, rect: Rect) {
        let theme = *ctx.theme;
        let header = self.header_cells(rect, &theme);
        let hh = self.header_height(&theme);
        let header_bg = Rect::from_xywh(rect.x(), rect.y(), rect.width(), hh);
        ctx.scene.fill_rect(header_bg, theme.colors.shade);

        let body = theme.typography.body.size;
        for (i, cell) in header.iter().enumerate() {
            ctx.label_left(
                cell.x() + theme.spacing.md,
                cell.center().y,
                &self.columns[i].title,
                body,
                weight::MEDIUM,
                theme.colors.dim_text,
            );
            if i > 0 {
                let line = Rect::from_xywh(cell.x(), cell.y(), 1.0, cell.height());
                ctx.scene.fill_rect(line, theme.colors.border);
            }
        }
        // Dolna krawędź nagłówka.
        ctx.scene.fill_rect(
            Rect::from_xywh(rect.x(), rect.y() + hh - 1.0, rect.width(), 1.0),
            theme.colors.border,
        );

        // Zaznaczony / podświetlony wiersz.
        let area = self.rows_rect(rect, &theme);
        for i in 0..self.rows {
            let row = self.row_rect(rect, &theme, i);
            if row.y() >= area.bottom() {
                break;
            }
            if self.selected == Some(i) {
                ctx.scene
                    .fill_rect(row, theme.colors.accent.scale_alpha(0.18));
            } else if self.hovered == Some(i) {
                ctx.scene.fill_rect(row, theme.colors.shade);
            }
            // Separator między wierszami.
            ctx.scene.fill_rect(
                Rect::from_xywh(row.x(), row.bottom() - 1.0, row.width(), 1.0),
                theme.colors.border.scale_alpha(0.5),
            );
        }
    }
}

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

    fn view() -> ColumnView {
        ColumnView::new(vec![
            Column::new("Nazwa").weight(2.0),
            Column::new("Rozmiar"),
            Column::new("Data"),
        ])
    }

    fn rect() -> Rect {
        Rect::from_xywh(0.0, 0.0, 400.0, 300.0)
    }

    #[test]
    fn columns_split_by_weight() {
        let theme = Theme::light();
        let v = view();
        let cells = v.header_cells(rect(), &theme);
        assert_eq!(cells.len(), 3);
        // Waga 2 z sumy 4 → połowa szerokości.
        assert!((cells[0].width() - 200.0).abs() < 1e-9);
        assert!((cells[1].width() - 100.0).abs() < 1e-9);
        assert!((cells[2].right() - 400.0).abs() < 1e-9);
    }

    #[test]
    fn rows_start_below_header() {
        let theme = Theme::light();
        let v = view();
        let area = v.rows_rect(rect(), &theme);
        assert_eq!(area.y(), v.header_height(&theme));
        assert_eq!(area.height(), 300.0 - v.header_height(&theme));
    }

    #[test]
    fn row_at_maps_points_and_ignores_header() {
        let theme = Theme::light();
        let mut v = view();
        v.set_rows(5);
        let r1 = v.row_rect(rect(), &theme, 2);
        assert_eq!(v.row_at(rect(), &theme, r1.center()), Some(2));
        // W nagłówku nie ma wiersza.
        assert_eq!(v.row_at(rect(), &theme, Point::new(10.0, 5.0)), None);
    }

    #[test]
    fn selection_is_clamped_to_rows() {
        let mut v = view();
        v.set_rows(3);
        v.set_selected(Some(2));
        assert_eq!(v.selected(), Some(2));
        v.set_rows(1);
        assert_eq!(v.selected(), None);
        v.set_selected(Some(9));
        assert_eq!(v.selected(), None);
    }

    #[test]
    fn cell_rect_matches_row_and_column() {
        let theme = Theme::light();
        let v = view();
        let cell = v.cell_rect(rect(), &theme, 1, 2);
        let row = v.row_rect(rect(), &theme, 1);
        assert_eq!(cell.y(), row.y());
        assert_eq!(cell.height(), row.height());
        assert!(cell.right() <= 400.0 + 1e-9);
    }
}