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

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

448 lines Raw ← Back
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448
//! `ItemView` — widok elementów (pliki/katalogi) w trybie **siatki** lub **listy**.
//!
//! Widget immediate: trzyma listę elementów, tryb wyświetlania, zaznaczenie i podświetlenie.
//! Aplikacja woła [`ItemView::paint`], [`ItemView::index_at`] / [`ItemView::click_at`] oraz
//! [`ItemView::slot_rect`] (np. do rysowania podpisów). Geometria wynika z tokenów motywu
//! ([`ItemViewMetrics`](libluna_core::ItemViewMetrics)).

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

use crate::ctx::PaintCtx;
use crate::icon::{self, IconName};

/// Rodzaj elementu (wpływa na ikonę).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ItemKind {
    Folder,
    #[default]
    File,
    Image,
    Audio,
    Video,
    Archive,
    Document,
    Code,
    Text,
}

/// Pojedynczy element (plik/katalog).
#[derive(Debug, Clone, PartialEq)]
pub struct Item {
    pub label: String,
    /// Krótki dopisek (np. typ lub rozmiar).
    pub detail: Option<String>,
    pub kind: ItemKind,
}

impl Item {
    pub fn new(label: impl Into<String>) -> Self {
        Self {
            label: label.into(),
            detail: None,
            kind: ItemKind::File,
        }
    }

    pub fn folder(label: impl Into<String>) -> Self {
        Self {
            label: label.into(),
            detail: None,
            kind: ItemKind::Folder,
        }
    }

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

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

/// Tryb prezentacji.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ViewMode {
    #[default]
    List,
    Grid,
}

/// Widok elementów.
#[derive(Debug, Clone, Default)]
pub struct ItemView {
    items: Vec<Item>,
    mode: ViewMode,
    /// Zaznaczenie „główne” (kotwica dla Shift+klik i klawiatury).
    selected: Option<usize>,
    /// Wszystkie zaznaczone elementy (rosnąco, bez duplikatów).
    selected_set: Vec<usize>,
    hovered: Option<usize>,
    scroll: f64,
}

impl ItemView {
    pub fn new(items: Vec<Item>) -> Self {
        Self {
            items,
            ..Self::default()
        }
    }

    pub fn items(&self) -> &[Item] {
        &self.items
    }

    pub fn set_items(&mut self, items: Vec<Item>) {
        self.items = items;
        self.selected = None;
        self.selected_set.clear();
        self.scroll = 0.0;
    }

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

    pub fn is_empty(&self) -> bool {
        self.items.is_empty()
    }

    pub fn mode(&self) -> ViewMode {
        self.mode
    }

    pub fn set_mode(&mut self, mode: ViewMode) {
        self.mode = mode;
    }

    pub fn toggle_mode(&mut self) {
        self.mode = match self.mode {
            ViewMode::List => ViewMode::Grid,
            ViewMode::Grid => ViewMode::List,
        };
    }

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

    /// Wszystkie zaznaczone indeksy (rosnąco).
    pub fn selected_indices(&self) -> &[usize] {
        &self.selected_set
    }

    pub fn is_selected(&self, i: usize) -> bool {
        self.selected_set.binary_search(&i).is_ok()
    }

    pub fn set_selected(&mut self, selected: Option<usize>) {
        self.selected = selected;
        self.selected_set.clear();
        if let Some(i) = selected {
            self.selected_set.push(i);
        }
    }

    /// Dodaje/usuwa element z zaznaczenia (Ctrl+klik); staje się kotwicą.
    pub fn toggle_selected(&mut self, i: usize) {
        match self.selected_set.binary_search(&i) {
            Ok(pos) => {
                self.selected_set.remove(pos);
            }
            Err(pos) => self.selected_set.insert(pos, i),
        }
        self.selected = Some(i);
    }

    /// Zaznacza zakres od kotwicy do `to` (Shift+klik).
    pub fn select_range(&mut self, to: usize) {
        let from = self.selected.unwrap_or(to);
        let (a, b) = if from <= to { (from, to) } else { (to, from) };
        self.selected_set = (a..=b).collect();
        self.selected = Some(to);
    }

    pub fn select_all(&mut self) {
        self.selected_set = (0..self.items.len()).collect();
        self.selected = self.items.len().checked_sub(1);
    }

    pub fn clear_selection(&mut self) {
        self.selected = None;
        self.selected_set.clear();
    }

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

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

    pub fn scroll(&self) -> f64 {
        self.scroll
    }

    pub fn set_scroll(&mut self, scroll: f64) {
        self.scroll = scroll.max(0.0);
    }

    /// Liczba kolumn w trybie siatki dla zadanej szerokości.
    pub fn columns(&self, rect: Rect, theme: &Theme) -> usize {
        let m = theme.metrics.itemview;
        if m.tile_w + m.gap <= 0.0 {
            return 1;
        }
        (((rect.width() + m.gap) / (m.tile_w + m.gap)).floor() as usize).max(1)
    }

    /// Wysokość całej zawartości dla zadanej szerokości (do przycinania przewijania).
    pub fn content_height(&self, rect: Rect, theme: &Theme) -> f64 {
        let m = theme.metrics.itemview;
        if self.items.is_empty() {
            return 0.0;
        }
        match self.mode {
            ViewMode::Grid => {
                let cols = self.columns(rect, theme);
                let rows = self.items.len().div_ceil(cols);
                rows as f64 * (m.tile_h + m.gap) - m.gap
            }
            ViewMode::List => self.items.len() as f64 * m.row_h,
        }
    }

    /// Maksymalne przewinięcie (0, gdy wszystko się mieści).
    pub fn max_scroll(&self, rect: Rect, theme: &Theme) -> f64 {
        (self.content_height(rect, theme) - rect.height()).max(0.0)
    }

    /// Przytina przewinięcie do dozwolonego zakresu.
    pub fn clamp_scroll(&mut self, rect: Rect, theme: &Theme) {
        self.scroll = self.scroll.clamp(0.0, self.max_scroll(rect, theme));
    }

    /// Prostokąt elementu `i` (z uwzględnieniem trybu i przewinięcia).
    pub fn slot_rect(&self, rect: Rect, i: usize, theme: &Theme) -> Rect {
        let m = theme.metrics.itemview;
        match self.mode {
            ViewMode::Grid => {
                let cols = self.columns(rect, theme);
                let col = i % cols;
                let row = i / cols;
                Rect::from_xywh(
                    rect.x() + col as f64 * (m.tile_w + m.gap),
                    rect.y() + row as f64 * (m.tile_h + m.gap) - self.scroll,
                    m.tile_w,
                    m.tile_h,
                )
            }
            ViewMode::List => Rect::from_xywh(
                rect.x(),
                rect.y() + i as f64 * m.row_h - self.scroll,
                rect.width(),
                m.row_h,
            ),
        }
    }

    /// Indeks elementu pod punktem `p` (tylko jeśli trafienie jest w widocznym obszarze).
    pub fn index_at(&self, rect: Rect, p: Point, theme: &Theme) -> Option<usize> {
        if !rect.contains(p) {
            return None;
        }
        (0..self.items.len()).find(|i| self.slot_rect(rect, *i, theme).contains(p))
    }

    /// Zaznacza element pod punktem `p`; zwraca jego indeks.
    pub fn click_at(&mut self, rect: Rect, p: Point, theme: &Theme) -> Option<usize> {
        let i = self.index_at(rect, p, theme)?;
        self.set_selected(Some(i));
        Some(i)
    }

    pub fn paint(&self, ctx: &mut PaintCtx, rect: Rect) {
        let theme = *ctx.theme;
        let m = theme.metrics.itemview;
        let colors = theme.colors;
        let body = theme.typography.body.size;
        let caption = theme.typography.caption.size;

        ctx.scene.push_clip(rect);
        for (i, item) in self.items.iter().enumerate() {
            let s = self.slot_rect(rect, i, &theme);
            // Wirtualizacja: elementy poza widokiem nie generują komend rysowania.
            // Bez tego katalog z tysiącami plików zamulał każdą klatkę.
            if s.bottom() <= rect.y() || s.y() >= rect.bottom() {
                continue;
            }
            let selected = self.is_selected(i);
            if selected {
                ctx.scene
                    .fill_rounded_rect(s, theme.radius.small, colors.shade.scale_alpha(1.8));
            } else if self.hovered == Some(i) {
                ctx.scene
                    .fill_rounded_rect(s, theme.radius.small, colors.shade);
            }

            match self.mode {
                ViewMode::Grid => {
                    let ib =
                        Rect::from_xywh(s.center().x - m.icon / 2.0, s.y() + 8.0, m.icon, m.icon);
                    draw_icon(ctx, item.kind, ib, colors.accent, colors.dim_text);
                    ctx.label_center(
                        s.center().x,
                        ib.bottom() + 12.0,
                        &item.label,
                        body,
                        weight::NORMAL,
                        colors.text,
                    );
                    if let Some(detail) = &item.detail {
                        ctx.label_center(
                            s.center().x,
                            s.bottom() - 8.0,
                            detail,
                            caption,
                            weight::NORMAL,
                            colors.dim_text,
                        );
                    }
                }
                ViewMode::List => {
                    let ib =
                        Rect::from_xywh(s.x() + 12.0, s.center().y - m.icon / 2.0, m.icon, m.icon);
                    draw_icon(ctx, item.kind, ib, colors.accent, colors.dim_text);
                    let tx = ib.right() + 12.0;
                    ctx.label_left(
                        tx,
                        s.center().y,
                        &item.label,
                        body,
                        weight::NORMAL,
                        colors.text,
                    );
                    if let Some(detail) = &item.detail {
                        ctx.label_right(
                            s.right() - 12.0,
                            s.center().y,
                            detail,
                            caption,
                            weight::NORMAL,
                            colors.dim_text,
                        );
                    }
                }
            }
        }
        ctx.scene.pop_clip();
    }
}

fn draw_icon(ctx: &mut PaintCtx, kind: ItemKind, box_: Rect, folder: Color, file: Color) {
    match kind {
        ItemKind::Folder => icon::folder(ctx, folder, box_),
        ItemKind::File => icon::file(ctx, file, box_),
        ItemKind::Image => icon::paint_icon(ctx, IconName::Image, box_, file),
        ItemKind::Audio => icon::paint_icon(ctx, IconName::Music, box_, file),
        ItemKind::Video => icon::paint_icon(ctx, IconName::Video, box_, file),
        ItemKind::Archive => icon::paint_icon(ctx, IconName::Archive, box_, file),
        ItemKind::Document => icon::paint_icon(ctx, IconName::Document, box_, file),
        ItemKind::Code => icon::paint_icon(ctx, IconName::Code, box_, file),
        ItemKind::Text => icon::paint_icon(ctx, IconName::Document, box_, file),
    }
}

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

    fn items() -> Vec<Item> {
        vec![
            Item::folder("Projekty"),
            Item::new("Raport").detail("PDF"),
            Item::new("Notatki").detail("TXT"),
            Item::new("Budżet").detail("XLSX"),
        ]
    }

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

    #[test]
    fn grid_columns_and_slots() {
        let theme = Theme::light();
        let mut v = ItemView::new(items());
        v.set_mode(ViewMode::Grid);
        // (400 + 12) / (100 + 12) = 3 kolumny.
        assert_eq!(v.columns(rect(), &theme), 3);
        let a = v.slot_rect(rect(), 0, &theme);
        let b = v.slot_rect(rect(), 1, &theme);
        assert!(b.x() > a.x());
        let d = v.slot_rect(rect(), 3, &theme); // 4. element → 2. rząd
        assert!(d.y() > a.y());
    }

    #[test]
    fn multi_selection_toggle_range_and_all() {
        let mut v = ItemView::new(items());
        v.set_selected(Some(1));
        assert!(v.is_selected(1));
        v.toggle_selected(3);
        assert_eq!(v.selected_indices(), &[1, 3]);
        v.toggle_selected(1);
        assert_eq!(v.selected_indices(), &[3]);
        v.set_selected(Some(1));
        v.select_range(3);
        assert_eq!(v.selected_indices(), &[1, 2, 3]);
        v.select_all();
        assert_eq!(v.selected_indices().len(), 4);
        v.clear_selection();
        assert!(v.selected_indices().is_empty());
    }

    #[test]
    fn list_slots_stack_vertically() {
        let theme = Theme::light();
        let v = ItemView::new(items());
        let r0 = v.slot_rect(rect(), 0, &theme);
        let r1 = v.slot_rect(rect(), 1, &theme);
        assert_eq!(r0.x(), r1.x());
        assert!((r1.y() - r0.y() - theme.metrics.itemview.row_h).abs() < 1e-9);
    }

    #[test]
    fn click_selects_item() {
        let theme = Theme::light();
        let mut v = ItemView::new(items());
        let center = v.slot_rect(rect(), 2, &theme).center();
        assert_eq!(v.click_at(rect(), center, &theme), Some(2));
        assert_eq!(v.selected(), Some(2));
        // Klik poza widokiem nic nie robi.
        assert_eq!(v.click_at(rect(), Point::new(1000.0, 1000.0), &theme), None);
    }

    #[test]
    fn toggle_mode_switches() {
        let mut v = ItemView::new(items());
        assert_eq!(v.mode(), ViewMode::List);
        v.toggle_mode();
        assert_eq!(v.mode(), ViewMode::Grid);
    }

    #[test]
    fn scroll_is_clamped_to_content() {
        let theme = Theme::light();
        let mut v = ItemView::new(items());
        let short = Rect::from_xywh(0.0, 0.0, 400.0, 100.0);
        v.set_scroll(9999.0);
        v.clamp_scroll(short, &theme);
        assert!(v.scroll() <= v.max_scroll(short, &theme) + 1e-9);
    }
}