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