🔒 Repository is read-only – file editing is disabled.
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
);
}
}