use std::collections::{BTreeMap, HashMap};
use std::path::PathBuf;
use std::rc::Rc;
use std::sync::Arc;
use std::time::Duration;
use crate::geometry::{Rect, Size};
use crate::style::CellStyle;
use crate::text;
use crate::widget::{Align, Length, MeasureCx, PaintCx, View, Widget};
use super::super::delayed::DelayedIndicator;
use super::super::{
CardGrid, Column, ColumnWidth, ContextItem, IconTile, RowDrop, SortDirection, SpinnerStyle, Table, TableCell,
TableRow, TextInput, TreeDrop,
};
use super::details::FileDetails;
use super::kinds::KindLook;
use super::sort::{Ranked, Sort, SortBy};
use super::state::{Place, ROOT};
use super::{FileManager, FileManagerMsg, FileManagerState, Marks, OnPath, child_key, is_within, root_name};
const DATE_WIDTH: u16 = 16;
const SIZE_WIDTH: u16 = 9;
const PERMISSIONS_WIDTH: u16 = 11;
const NAME_WIDTH: u16 = 12;
const FROM_WIDTH: u16 = 24;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[non_exhaustive]
pub enum FileView {
#[default]
Tree,
List,
Icons,
}
#[derive(Debug)]
pub(super) struct FlatRow {
pub(super) key: String,
pub(super) name: String,
pub(super) folder: bool,
pub(super) executable: bool,
pub(super) changed: Option<i64>,
pub(super) from: String,
pub(super) deleted: String,
}
impl FlatRow {
fn ranked<'a>(&'a self, details: &'a BTreeMap<String, Option<FileDetails>>) -> Ranked<'a> {
Ranked {
name: &self.name,
folder: self.folder,
executable: self.executable,
details: details.get(&self.key).and_then(Option::as_ref),
changed: self.changed,
}
}
}
#[derive(Debug)]
pub(super) struct FlatEntries {
pub(super) revision: u64,
pub(super) place: Place,
pub(super) folder: String,
pub(super) hidden: bool,
pub(super) details_revision: u64,
pub(super) sort: Sort,
pub(super) filter: Option<String>,
pub(super) known: bool,
pub(super) rows: Vec<FlatRow>,
index: HashMap<String, usize>,
}
impl FlatEntries {
pub(super) fn of(state: &FileManagerState, revision: u64, details_revision: u64) -> Self {
#[cfg(test)]
ENTRIES_LISTED.with(|count| count.set(count.get() + 1));
let place = state.place();
let sort = state.sort();
let folder = if place == Place::Trash { ROOT.to_owned() } else { state.folder().to_owned() };
let (mut rows, known) = match place {
Place::Files => {
let entries = state.shown_children(&folder);
let rows: Vec<FlatRow> = entries
.as_deref()
.unwrap_or_default()
.iter()
.map(|entry| FlatRow {
key: child_key(&folder, &entry.name),
name: entry.name.clone(),
folder: entry.folder,
executable: entry.executable,
changed: None,
from: String::new(),
deleted: String::new(),
})
.collect();
(rows, entries.is_some())
}
Place::Trash => {
let rows = state
.trashed()
.into_iter()
.map(|entry| FlatRow {
key: entry.name.clone(),
name: entry.label.clone(),
folder: entry.folder,
executable: entry.executable,
changed: entry.deleted_at(),
from: entry.origin_text(),
deleted: entry.deleted_text(),
})
.collect();
(rows, !state.is_reading_trash() && state.trash_error().is_none())
}
};
let filter = state.filter().map(str::to_owned);
if let Some(text) = filter.as_deref().map(folded).filter(|text| !text.is_empty()) {
rows.retain(|row| folded(&row.name).contains(&text));
}
if sort != Sort::default() {
let details = state.details_shared();
rows.sort_by(|a, b| super::sort::compare(sort, &a.ranked(&details), &b.ranked(&details)));
}
let index = rows.iter().enumerate().map(|(at, row)| (row.key.clone(), at)).collect();
let hidden = state.shows_hidden();
Self { revision, details_revision, sort, filter, place, folder, hidden, known, rows, index }
}
pub(super) fn position(&self, key: &str) -> Option<usize> {
self.index.get(key).copied()
}
}
struct Flat<Msg> {
entries: Arc<FlatEntries>,
label: String,
up: Option<String>,
wrap: Wrap<Msg>,
on_open: Option<OnPath<Msg>>,
root: PathBuf,
}
type Wrap<Msg> = Rc<dyn Fn(FileManagerMsg) -> Msg>;
impl<Msg> Flat<Msg> {
fn len(&self) -> usize {
self.entries.rows.len() + 1
}
fn entry(&self, index: usize) -> Option<&FlatRow> {
index.checked_sub(1).and_then(|at| self.entries.rows.get(at))
}
fn key(&self, index: usize) -> Option<&str> {
if index == 0 { Some(&self.entries.folder) } else { self.entry(index).map(|row| row.key.as_str()) }
}
fn select(&self, index: usize) -> Msg {
(self.wrap)(FileManagerMsg::Select(self.key(index).unwrap_or_default().to_owned()))
}
fn activate(&self, index: usize) -> Msg {
let Some(row) = self.entry(index) else { return (self.wrap)(FileManagerMsg::Leave) };
if self.entries.place == Place::Trash {
return (self.wrap)(FileManagerMsg::Select(row.key.clone()));
}
if row.folder {
return (self.wrap)(FileManagerMsg::Enter(row.key.clone()));
}
match &self.on_open {
Some(open) => {
let path = row
.key
.split('/')
.filter(|part| !part.is_empty())
.fold(self.root.clone(), |at, part| at.join(part));
open(&path)
}
None => (self.wrap)(FileManagerMsg::Select(row.key.clone())),
}
}
fn keys_of(&self, indexes: &[usize]) -> Vec<String> {
indexes.iter().filter_map(|index| self.entry(*index)).map(|row| row.key.clone()).collect()
}
fn takes_drop(&self, index: usize) -> bool {
if self.entries.place == Place::Trash {
return false;
}
if index == 0 {
return self.up.is_some();
}
self.entry(index).is_some_and(|row| row.folder)
}
fn dropped(&self, dropped: &RowDrop) -> TreeDrop {
let into =
if dropped.into == 0 { self.up.clone() } else { self.entry(dropped.into).map(|row| row.key.clone()) };
TreeDrop { keys: self.keys_of(&dropped.rows), into }
}
}
#[cfg(test)]
thread_local! {
pub(super) static ROWS_LOOKED: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
pub(super) static ENTRIES_LISTED: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
struct Look {
marks: Option<Marks>,
kinds: KindLook,
cut: Vec<String>,
disabled: bool,
root_name: String,
trash: bool,
details: Arc<BTreeMap<String, Option<FileDetails>>>,
}
impl Look {
fn of(
&self,
key: &str,
name: &str,
folder: bool,
executable: bool,
itself: bool,
) -> (String, Option<String>, bool) {
let mark = self.marks.as_ref().map(|mark| mark(key)).unwrap_or_default();
let name = if key == ROOT { if self.trash { "trash" } else { self.root_name.as_str() } } else { name };
let (icon, tone) = match mark.icon() {
Some(icon) => (icon.to_owned(), mark.tone().map(str::to_owned)),
None => self.kinds.own_icon(key, name, folder, executable),
};
let cut = !itself && self.cut.iter().any(|cut| is_within(key, cut));
(icon, tone, self.disabled || cut || mark.is_faint())
}
fn row<Msg>(&self, flat: &Flat<Msg>, index: usize) -> (String, String, Option<String>, bool) {
#[cfg(test)]
ROWS_LOOKED.with(|count| count.set(count.get() + 1));
let (key, name, folder, executable) = match flat.entry(index) {
Some(row) => (row.key.as_str(), row.name.as_str(), row.folder, row.executable),
None => (flat.entries.folder.as_str(), flat.label.as_str(), true, false),
};
let (icon, tone, faint) = self.of(key, name, folder, executable, index == 0);
(name.to_owned(), icon, tone, faint)
}
}
impl<'a, Msg: Clone + 'static> FileManager<'a, Msg> {
fn flat(&self) -> Rc<Flat<Msg>> {
let state = self.state;
let entries = state.flat_entries();
let label = match entries.place {
Place::Trash => crate::t!("quvyta.file-manager.trash-place"),
Place::Files if entries.folder == ROOT => {
self.root_label.clone().unwrap_or_else(|| root_name(state.root()))
}
Place::Files => super::name_of(&entries.folder).to_owned(),
};
let up = (entries.place == Place::Files && entries.folder != ROOT)
.then(|| super::parent_key(&entries.folder).to_owned());
Rc::new(Flat {
entries,
label,
up,
wrap: Rc::clone(&self.wrap),
on_open: self.on_open.clone(),
root: state.root().to_path_buf(),
})
}
fn look(&self) -> Rc<Look> {
let state = self.state;
let cut = if state.is_copying() { Vec::new() } else { state.pending().to_vec() };
Rc::new(Look {
marks: self.marks.clone(),
kinds: self.kinds.clone(),
cut,
disabled: self.disabled,
root_name: root_name(state.root()),
trash: state.in_trash(),
details: state.details_shared(),
})
}
fn flat_selection(&self, flat: &Flat<Msg>) -> (Option<usize>, Vec<usize>) {
let state = self.state;
let place = |key: &str| {
if key == flat.entries.folder { Some(0) } else { flat.entries.position(key).map(|at| at + 1) }
};
let selected = state.selected().and_then(place);
let mut chosen: Vec<usize> = state.chosen().iter().filter_map(|key| place(key)).filter(|at| *at > 0).collect();
chosen.sort_unstable();
(selected, chosen)
}
fn table(&self, flat: &Rc<Flat<Msg>>) -> Table<Msg> {
let sortable = !self.disabled;
let trash = flat.entries.place == Place::Trash;
let name = Column::new(crate::t!("quvyta.file-manager.column-name")).min(NAME_WIDTH).sortable(sortable);
let changed = Column::new(crate::t!("quvyta.file-manager.column-modified"))
.width(ColumnWidth::Fixed(DATE_WIDTH))
.sortable(sortable);
let columns = if trash {
vec![name, Column::new(crate::t!("quvyta.file-manager.column-from")).min(FROM_WIDTH), changed]
} else {
vec![
name,
Column::new(crate::t!("quvyta.file-manager.column-size"))
.width(ColumnWidth::Fixed(SIZE_WIDTH))
.align(Align::End)
.sortable(sortable),
changed,
Column::new(crate::t!("quvyta.file-manager.column-permissions"))
.width(ColumnWidth::Fixed(PERMISSIONS_WIDTH)),
]
};
let (rows, look) = (Rc::clone(flat), self.look());
let cells = columns.len() - 1;
let table = Table::lazy(columns, flat.len(), move |index| {
let (name, icon, tone, faint) = look.row(&rows, index);
let name = TableCell::new(name).icon(icon, tone.as_deref());
let cells = match rows.entry(index) {
None => vec![TableCell::new(""); cells],
Some(row) if trash => vec![TableCell::new(row.from.clone()), TableCell::new(row.deleted.clone())],
Some(row) => {
let details = look.details.get(&row.key).and_then(Option::as_ref);
match details {
Some(details) => vec![
TableCell::new(details.size_text(row.folder)),
TableCell::new(details.modified_text()),
TableCell::new(details.permissions_text(row.folder)),
],
None => vec![TableCell::new(""); 3],
}
}
};
TableRow::new([name].into_iter().chain(cells)).faint(faint)
});
if self.disabled {
return table;
}
let (selected, chosen) = self.flat_selection(flat);
let [select, activate, choose, drop, copy, accepts] = std::array::from_fn(|_| Rc::clone(flat));
let [choosing, dropping, copying, sorting] = std::array::from_fn(|_| Rc::clone(&self.wrap));
let sort = flat.entries.sort;
let table = match sort_column(sort, trash) {
Some(column) => {
table.sort(column, if sort.reverse { SortDirection::Descending } else { SortDirection::Ascending })
}
None => table,
};
table
.on_sort(move |column, direction| {
let by = sorted_by(column, trash);
sorting(FileManagerMsg::Sort(Sort::by(by).reversed(direction == SortDirection::Descending)))
})
.selected(selected)
.activate_on(self.open_on)
.multi_select(&chosen, move |indexes| choosing(FileManagerMsg::Choose(choose.keys_of(&indexes))))
.box_select(true)
.droppable(
move |dropped| dropping(FileManagerMsg::Drop(drop.dropped(&dropped))),
move |index| accepts.takes_drop(index),
)
.on_copy_drop(move |dropped| copying(FileManagerMsg::DropCopy(copy.dropped(&dropped))))
.on_select(move |index| select.select(index))
.on_activate(move |index| activate.activate(index))
.context_menu(self.flat_menu(flat))
}
fn grid(&self, flat: &Rc<Flat<Msg>>) -> CardGrid<Msg> {
let (rows, look) = (Rc::clone(flat), self.look());
let (selected, chosen) = self.flat_selection(flat);
let marked = chosen.clone();
let grid = CardGrid::new(flat.len())
.card_width(IconTile::WIDTH, IconTile::WIDTH)
.card_height(IconTile::HEIGHT)
.gap(1, 1)
.bare_cards(true)
.disabled(self.disabled)
.card(move |ui, index| {
let (name, icon, tone, faint) = look.row(&rows, index);
let cursor = selected == Some(index);
let chosen = if marked.is_empty() { cursor } else { marked.contains(&index) };
let mut tile = IconTile::new(icon, name).selected(chosen).cursor(cursor && !chosen).faint(faint);
if let Some(tone) = tone {
tile = tile.color(tone);
}
ui.add(tile);
});
if self.disabled {
return grid;
}
let [select, activate, choose, drop, copy, accepts] = std::array::from_fn(|_| Rc::clone(flat));
let [choosing, dropping, copying] = std::array::from_fn(|_| Rc::clone(&self.wrap));
grid.selected(selected)
.activate_on(self.open_on)
.multi_select(&chosen, move |indexes| choosing(FileManagerMsg::Choose(choose.keys_of(&indexes))))
.box_select(true)
.droppable(
move |dropped| dropping(FileManagerMsg::Drop(drop.dropped(&dropped))),
move |index| accepts.takes_drop(index),
)
.on_copy_drop(move |dropped| copying(FileManagerMsg::DropCopy(copy.dropped(&dropped))))
.on_select(move |index| select.select(index))
.on_activate(move |index| activate.activate(index))
.context_menu(self.flat_menu(flat))
}
fn flat_menu(&self, flat: &Rc<Flat<Msg>>) -> impl Fn(usize) -> Vec<ContextItem<Msg>> + 'static {
let rows = Rc::clone(flat);
let items = self.row_menu();
let leave = (!self.state.in_trash() && self.state.folder() != ROOT).then(|| (self.wrap)(FileManagerMsg::Leave));
move |index| {
let mut own = rows.key(index).map(&items).unwrap_or_default();
if index == 0
&& let Some(leave) = &leave
{
own.insert(0, ContextItem::new(crate::t!("quvyta.file-manager.up"), leave.clone()));
own.insert(1, ContextItem::gap());
}
own
}
}
pub(super) fn flat_view(&self, ui: &mut View<'_, Msg>, name: String) {
let flat = self.flat();
if let Some(text) = self.state.filter()
&& !self.disabled
{
let wrap = Rc::clone(&self.wrap);
let field = TextInput::new(text)
.placeholder(crate::t!("quvyta.file-manager.filter"))
.on_change(move |text| wrap(FileManagerMsg::Filter(Some(text))))
.on_submit({
let wrap = Rc::clone(&self.wrap);
move |_| wrap(FileManagerMsg::FilterChosen)
})
.on_cancel((self.wrap)(FileManagerMsg::Filter(None)));
ui.add(field).fill_width().id(format!("{name}-filter"));
}
if self.view == FileView::Icons {
let grid = self.grid(&flat);
ui.add(grid).fill().id(name);
} else {
let table = self.table(&flat);
ui.add(table).fill().id(name);
}
self.foot(ui, &flat);
}
fn foot(&self, ui: &mut View<'_, Msg>, flat: &Flat<Msg>) {
let state = self.state;
let trash = flat.entries.place == Place::Trash;
let shown = flat.entries.folder.as_str();
let entries = flat.entries.rows.len();
let unreadable = state.folder_error(shown).is_some() || (trash && state.trash_error().is_some());
let label = if !flat.entries.known {
String::new()
} else if unreadable {
crate::t!("quvyta.file-manager.unreadable-short")
} else if entries == 0 && flat.entries.filter.is_some() {
crate::t!("quvyta.file-manager.no-match")
} else if entries == 0 {
crate::t!("quvyta.file-manager.empty")
} else {
crate::t!("quvyta.file-manager.entries", n = entries)
};
let busy = if trash { state.is_reading_trash() } else { state.is_loading(shown) };
ui.add(Reading { busy, label }).fill_width().height(Length::Cells(1));
}
}
fn folded(text: &str) -> String {
text.chars()
.flat_map(char::to_lowercase)
.map(|c| if c == 'ı' { 'i' } else { c })
.filter(|c| *c != '\u{307}')
.collect()
}
fn sort_column(sort: Sort, trash: bool) -> Option<usize> {
match sort.by {
SortBy::Name => Some(0),
SortBy::Size if !trash => Some(1),
SortBy::Changed => Some(2),
_ => None,
}
}
fn sorted_by(column: usize, trash: bool) -> SortBy {
match (column, trash) {
(2, _) => SortBy::Changed,
(1, false) => SortBy::Size,
_ => SortBy::Name,
}
}
struct Reading {
busy: bool,
label: String,
}
#[derive(Debug, Default)]
struct Mark(DelayedIndicator);
impl<Msg: 'static> Widget<Msg> for Reading {
fn measure(&self, _cx: &mut MeasureCx<'_>, available: Size) -> Size {
Size::new(available.width, 1)
}
fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
if area.is_empty() {
return;
}
let now = cx.now();
let mut mark = cx.memory::<Mark>().0;
let shown = mark.update(self.busy, now);
if let Some(change) = mark.next_change(self.busy, now) {
cx.request_frame_in(change);
}
cx.memory::<Mark>().0 = mark;
let faint = cx.style("list-header", None, &[]).text();
if !shown {
cx.text(area.x, area.y, &self.label, faint, area.width);
return;
}
let style = cx.style("spinner", None, &[]).text();
let cell = cx.animation(SpinnerStyle::Dots.animation(), style, Some(Duration::ZERO));
let glyph = text::truncate(&cell.glyph, 1).into_owned();
cx.text(area.x, area.y, &glyph, cell.style, 1);
let word = crate::t!("quvyta.file-manager.reading");
cx.text(area.x + 2, area.y, &word, CellStyle { bg: None, ..faint }, area.width.saturating_sub(2));
}
}