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

luna/apps/luna-files/src/list.rs main

277 lines Raw ← Back
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277
//! Widok listy z kolumnami (Nazwa / Rozmiar / Typ / Modyfikacja).
//!
//! `ItemView` z libLuny rysuje tylko etykietę z dopiskiem — do menedżera plików
//! potrzebne są prawdziwe kolumny, klikalne nagłówki i wirtualizacja. Ten moduł
//! rysuje listę bezpośrednio z `DirView`, więc przewijanie i zaznaczanie zostają
//! spójne z resztą aplikacji.

use libluna_core::style::weight;
use libluna_core::{Point, Rect, Theme};
use libluna_widgets::{icon, IconName, PaintCtx};

use crate::model::{format_mtime, human_size, DirView, Kind, SortKey};

/// Wysokość paska nagłówków kolumn.
pub const HEADER_H: f64 = 26.0;

#[derive(Debug, Clone, Copy)]
pub struct Columns {
    pub name: Rect,
    pub size: Rect,
    pub kind: Rect,
    pub modified: Rect,
}

/// Szerokości kolumn: nazwa bierze resztę, reszta ma stałe minima.
pub fn columns(rect: Rect, _theme: &Theme) -> Columns {
    let size_w = 90.0;
    let kind_w = 120.0;
    let date_w = 140.0;
    let name_w = (rect.width() - size_w - kind_w - date_w).max(100.0);
    Columns {
        name: Rect::from_xywh(rect.x(), rect.y(), name_w, rect.height()),
        size: Rect::from_xywh(rect.x() + name_w, rect.y(), size_w, rect.height()),
        kind: Rect::from_xywh(rect.x() + name_w + size_w, rect.y(), kind_w, rect.height()),
        modified: Rect::from_xywh(
            rect.x() + name_w + size_w + kind_w,
            rect.y(),
            date_w,
            rect.height(),
        ),
    }
}

/// Obszar wierszy (poniżej nagłówka).
pub fn rows_rect(rect: Rect) -> Rect {
    Rect::from_xywh(
        rect.x(),
        rect.y() + HEADER_H,
        rect.width(),
        (rect.height() - HEADER_H).max(1.0),
    )
}

/// Trafienie w nagłówek kolumny (sortowanie).
pub fn header_at(rect: Rect, p: Point, theme: &Theme) -> Option<SortKey> {
    if p.y < rect.y() || p.y >= rect.y() + HEADER_H {
        return None;
    }
    let c = columns(rect, theme);
    if c.name.contains(p) {
        Some(SortKey::Name)
    } else if c.size.contains(p) {
        Some(SortKey::Size)
    } else if c.kind.contains(p) {
        Some(SortKey::Kind)
    } else if c.modified.contains(p) {
        Some(SortKey::Modified)
    } else {
        None
    }
}

/// Prostokąt wiersza (z przewinięciem).
pub fn row_rect(rows: Rect, i: usize, scroll: f64, theme: &Theme) -> Rect {
    Rect::from_xywh(
        rows.x(),
        rows.y() + i as f64 * theme.metrics.itemview.row_h - scroll,
        rows.width(),
        theme.metrics.itemview.row_h,
    )
}

/// Indeks wiersza pod punktem.
pub fn index_at(rows: Rect, p: Point, scroll: f64, len: usize, theme: &Theme) -> Option<usize> {
    if !rows.contains(p) {
        return None;
    }
    let row_h = theme.metrics.itemview.row_h;
    let index = ((p.y - rows.y() + scroll) / row_h).floor();
    if index < 0.0 {
        return None;
    }
    let index = index as usize;
    (index < len).then_some(index)
}

/// Rysuje nagłówek + widoczne wiersze.
pub fn paint(
    ctx: &mut PaintCtx,
    rect: Rect,
    dir: &DirView,
    selection: &[usize],
    hovered: Option<usize>,
    scroll: f64,
    theme: &Theme,
) {
    let colors = theme.colors;
    let c = columns(rect, theme);
    let rows = rows_rect(rect);

    // Nagłówek
    let header = Rect::from_xywh(rect.x(), rect.y(), rect.width(), HEADER_H);
    ctx.scene.fill_rect(header, colors.headerbar_bg);
    ctx.scene.fill_rect(
        Rect::from_xywh(header.x(), header.bottom() - 1.0, header.width(), 1.0),
        colors.border,
    );
    let caption = theme.typography.caption.size;
    let arrow = if dir.ascending() { "▲" } else { "▼" };
    let labels = [
        (c.name, SortKey::Name, "Name", false),
        (c.size, SortKey::Size, "Size", true),
        (c.kind, SortKey::Kind, "Type", false),
        (c.modified, SortKey::Modified, "Modified", false),
    ];
    for (area, key, label, right) in labels {
        let text = if dir.sort_key() == key {
            format!("{} {arrow}", libluna_i18n::tr(label))
        } else {
            libluna_i18n::tr(label)
        };
        let color = if dir.sort_key() == key {
            colors.text
        } else {
            colors.dim_text
        };
        let cy = header.center().y;
        if right {
            ctx.label_right(
                area.right() - 10.0,
                cy,
                &text,
                caption,
                weight::MEDIUM,
                color,
            );
        } else {
            ctx.label_left(area.x() + 10.0, cy, &text, caption, weight::MEDIUM, color);
        }
    }

    // Wiersze (wirtualizacja)
    ctx.scene.push_clip(rows);
    let row_h = theme.metrics.itemview.row_h;
    let first = ((0.0f64).max(scroll / row_h)).floor() as usize;
    let visible = (rows.height() / row_h).ceil() as usize + 2;
    let visible_indices = dir.visible();
    for i in first..(first + visible).min(visible_indices.len()) {
        let entry = &dir.entries[visible_indices[i]];
        let r = row_rect(rows, i, scroll, theme);
        if selection.binary_search(&i).is_ok() {
            ctx.scene
                .fill_rounded_rect(r, theme.radius.small, colors.shade.scale_alpha(1.8));
        } else if hovered == Some(i) {
            ctx.scene
                .fill_rounded_rect(r, theme.radius.small, colors.shade);
        }
        let icon_box = Rect::from_xywh(r.x() + 10.0, r.center().y - 9.0, 18.0, 18.0);
        draw_kind(ctx, entry.kind, icon_box, colors.accent, colors.dim_text);
        let name_color = if entry.hidden {
            colors.dim_text
        } else {
            colors.text
        };
        ctx.label_left(
            c.name.x() + 38.0,
            r.center().y,
            &entry.name,
            theme.typography.body.size,
            weight::NORMAL,
            name_color,
        );
        if !entry.is_dir {
            ctx.label_right(
                c.size.right() - 10.0,
                r.center().y,
                &human_size(entry.size),
                theme.typography.caption.size,
                weight::NORMAL,
                colors.dim_text,
            );
        }
        ctx.label_left(
            c.kind.x() + 10.0,
            r.center().y,
            &entry.kind.label(),
            theme.typography.caption.size,
            weight::NORMAL,
            colors.dim_text,
        );
        ctx.label_left(
            c.modified.x() + 10.0,
            r.center().y,
            &format_mtime(entry.modified),
            theme.typography.caption.size,
            weight::NORMAL,
            colors.dim_text,
        );
    }
    ctx.scene.pop_clip();
}

fn draw_kind(
    ctx: &mut PaintCtx,
    kind: Kind,
    rect: Rect,
    folder: libluna_core::Color,
    file: libluna_core::Color,
) {
    match kind {
        Kind::Folder => icon::folder(ctx, folder, rect),
        Kind::Image => icon::paint_icon(ctx, IconName::Image, rect, file),
        Kind::Audio => icon::paint_icon(ctx, IconName::Music, rect, file),
        Kind::Video => icon::paint_icon(ctx, IconName::Video, rect, file),
        Kind::Archive => icon::paint_icon(ctx, IconName::Archive, rect, file),
        Kind::Document => icon::paint_icon(ctx, IconName::Document, rect, file),
        Kind::Code => icon::paint_icon(ctx, IconName::Code, rect, file),
        Kind::Text => icon::paint_icon(ctx, IconName::Document, rect, file),
        Kind::Other => icon::file(ctx, file, rect),
    }
}

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

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

    #[test]
    fn columns_tile_the_width() {
        let theme = Theme::light();
        let c = columns(rect(), &theme);
        assert!(
            (c.name.width() + c.size.width() + c.kind.width() + c.modified.width() - 700.0).abs()
                < 0.01
        );
        assert!(c.modified.right() <= 700.0 + 0.01);
    }

    #[test]
    fn header_hits_sort_columns() {
        let theme = Theme::light();
        let c = columns(rect(), &theme);
        let p = Point::new(c.size.center().x, 12.0);
        assert_eq!(header_at(rect(), p, &theme), Some(SortKey::Size));
        assert_eq!(header_at(rect(), Point::new(5.0, 40.0), &theme), None);
    }

    #[test]
    fn row_index_respects_scroll() {
        let theme = Theme::light();
        let rows = rows_rect(rect());
        let row_h = theme.metrics.itemview.row_h;
        // Drugi wiersz przy przewinięciu 0.
        let p = Point::new(20.0, rows.y() + row_h * 1.5);
        assert_eq!(index_at(rows, p, 0.0, 10, &theme), Some(1));
        // Przy przewinięciu o jeden wiersz ten sam punkt to trzeci wiersz.
        assert_eq!(index_at(rows, p, row_h, 10, &theme), Some(2));
        // Poza listą.
        assert_eq!(
            index_at(rows, Point::new(20.0, rows.bottom() + 5.0), 0.0, 10, &theme),
            None
        );
    }
}