use std::collections::BTreeSet;
use std::time::SystemTime;
mod select;
mod view;
use select::Selection;
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum EntryKind {
Folder,
File,
}
#[derive(Clone, Debug)]
pub struct Entry {
pub name: String,
pub kind: EntryKind,
pub size: Option<u64>,
pub modified: Option<SystemTime>,
pub icon: Option<egui::ImageSource<'static>>,
pub thumbnail: Option<egui::ImageSource<'static>>,
}
impl Entry {
pub fn folder(name: impl Into<String>) -> Entry {
Entry::new(name, EntryKind::Folder)
}
pub fn file(name: impl Into<String>) -> Entry {
Entry::new(name, EntryKind::File)
}
fn new(name: impl Into<String>, kind: EntryKind) -> Entry {
Entry {
name: name.into(),
kind,
size: None,
modified: None,
icon: None,
thumbnail: None,
}
}
pub fn size(mut self, bytes: u64) -> Entry {
self.size = Some(bytes);
self
}
pub fn modified(mut self, at: SystemTime) -> Entry {
self.modified = Some(at);
self
}
pub fn icon(mut self, icon: egui::ImageSource<'static>) -> Entry {
self.icon = Some(icon);
self
}
pub fn thumbnail(mut self, thumbnail: egui::ImageSource<'static>) -> Entry {
self.thumbnail = Some(thumbnail);
self
}
pub fn is_folder(&self) -> bool {
self.kind == EntryKind::Folder
}
}
pub fn icon_for(entry: &Entry) -> egui::ImageSource<'static> {
use crate::platform::{AUDIO_EXTENSIONS, IMAGE_EXTENSIONS, VIDEO_EXTENSIONS};
if entry.is_folder() {
return crate::icons::folder();
}
let extension = entry
.name
.rsplit_once('.')
.map(|(_, e)| e.to_ascii_lowercase())
.unwrap_or_default();
let is = |list: &[&str]| list.contains(&extension.as_str());
if is(IMAGE_EXTENSIONS) || extension == "svg" {
crate::icons::picture()
} else if is(AUDIO_EXTENSIONS) {
crate::icons::music()
} else if is(VIDEO_EXTENSIONS) {
crate::icons::film()
} else if is(TEXT_EXTENSIONS) {
crate::icons::file_text()
} else {
crate::icons::file()
}
}
const TEXT_EXTENSIONS: &[&str] = &[
"txt", "md", "pdf", "doc", "docx", "odt", "rtf", "csv", "json", "toml", "yaml", "yml", "log",
];
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
pub enum SortBy {
#[default]
Name,
Size,
Modified,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub struct Sort {
pub by: SortBy,
pub ascending: bool,
}
impl Default for Sort {
fn default() -> Sort {
Sort {
by: SortBy::Name,
ascending: true,
}
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
pub enum View {
#[default]
List,
Grid,
}
#[derive(Clone, Debug)]
pub struct MenuItem {
pub id: String,
pub label: String,
pub icon: Option<egui::ImageSource<'static>>,
}
impl MenuItem {
pub fn new(id: impl Into<String>, label: impl Into<String>) -> MenuItem {
MenuItem {
id: id.into(),
label: label.into(),
icon: None,
}
}
pub fn icon(mut self, icon: egui::ImageSource<'static>) -> MenuItem {
self.icon = Some(icon);
self
}
}
#[derive(Clone, PartialEq, Eq, Debug)]
pub enum ExplorerEvent {
Navigated(Vec<String>),
Opened(String),
LoadMore,
Refresh,
Menu { id: String, names: Vec<String> },
Move { names: Vec<String>, to: Vec<String> },
Rename { name: String, to: String },
}
#[derive(Clone, Debug)]
struct Dragged {
explorer: egui::Id,
from: Vec<String>,
names: Vec<String>,
}
type MenuFn = dyn Fn(&egui::Context, &[&Entry]) -> Vec<MenuItem>;
type TimeFn = dyn Fn(SystemTime) -> String;
#[derive(Clone, PartialEq, Eq, Debug)]
enum Status {
Loading,
Ready,
Failed(String),
}
struct Renaming {
name: String,
text: String,
started: bool,
}
pub struct FileExplorer {
id: egui::Id,
root: String,
location: Vec<String>,
entries: Vec<Entry>,
order: Vec<usize>,
status: Status,
more: bool,
sort: Sort,
view: View,
selection: Selection,
renaming: Option<Renaming>,
movable: bool,
renamable: bool,
menu: Option<Box<MenuFn>>,
time: Box<TimeFn>,
reveal: bool,
events: Vec<ExplorerEvent>,
#[cfg(test)]
drawn: std::cell::RefCell<Vec<(String, egui::Rect)>>,
}
impl FileExplorer {
pub fn new(id_salt: impl egui::AsIdSalt, root: impl Into<String>) -> FileExplorer {
FileExplorer {
id: egui::Id::unique(("corundum_explorer", id_salt)),
root: root.into(),
location: Vec::new(),
entries: Vec::new(),
order: Vec::new(),
status: Status::Loading,
more: false,
sort: Sort::default(),
view: View::default(),
selection: Selection::default(),
renaming: None,
movable: false,
renamable: false,
menu: None,
time: Box::new(format_time),
reveal: false,
events: Vec::new(),
#[cfg(test)]
drawn: Default::default(),
}
}
pub fn movable(mut self, movable: bool) -> FileExplorer {
self.movable = movable;
self
}
pub fn renamable(mut self, renamable: bool) -> FileExplorer {
self.renamable = renamable;
self
}
pub fn menu(
mut self,
items: impl Fn(&egui::Context, &[&Entry]) -> Vec<MenuItem> + 'static,
) -> FileExplorer {
self.menu = Some(Box::new(items));
self
}
pub fn time_format(mut self, format: impl Fn(SystemTime) -> String + 'static) -> FileExplorer {
self.time = Box::new(format);
self
}
pub fn location(&self) -> &[String] {
&self.location
}
pub fn entries(&self) -> &[Entry] {
&self.entries
}
pub fn selected(&self) -> Vec<&Entry> {
self.order
.iter()
.map(|&i| &self.entries[i])
.filter(|e| self.selection.contains(&e.name))
.collect()
}
pub fn is_loading(&self) -> bool {
self.status == Status::Loading
}
pub fn sort(&self) -> Sort {
self.sort
}
pub fn set_sort(&mut self, sort: Sort) {
self.sort = sort;
self.reorder();
}
pub fn view(&self) -> View {
self.view
}
pub fn set_view(&mut self, view: View) {
self.view = view;
}
pub fn go_to(&mut self, location: Vec<String>) {
self.location = location;
self.entries.clear();
self.order.clear();
self.selection.clear();
self.renaming = None;
self.status = Status::Loading;
self.more = false;
}
pub fn set_entries(&mut self, location: &[String], entries: Vec<Entry>, more: bool) -> bool {
if location != self.location.as_slice() {
return false;
}
self.entries = entries;
self.status = Status::Ready;
self.more = more;
if let Some(r) = &self.renaming
&& !self.entries.iter().any(|e| e.name == r.name)
{
self.renaming = None;
}
self.reorder();
true
}
pub fn append_entries(&mut self, location: &[String], entries: Vec<Entry>, more: bool) -> bool {
if location != self.location.as_slice() {
return false;
}
self.entries.extend(entries);
self.status = Status::Ready;
self.more = more;
self.reorder();
true
}
pub fn set_failed(&mut self, location: &[String], error: impl Into<String>) -> bool {
if location != self.location.as_slice() {
return false;
}
self.status = Status::Failed(error.into());
self.more = false;
true
}
fn reorder(&mut self) {
let mut order: Vec<usize> = (0..self.entries.len()).collect();
let sort = self.sort;
let entries = &self.entries;
order.sort_by(|&a, &b| compare(&entries[a], &entries[b], sort));
self.order = order;
let shown = self.shown_names();
self.selection.retain(&shown);
}
fn shown_names(&self) -> Vec<String> {
self.order
.iter()
.map(|&i| self.entries[i].name.clone())
.collect()
}
fn shown(&self, at: usize) -> &Entry {
&self.entries[self.order[at]]
}
fn navigate(&mut self, location: Vec<String>) {
self.go_to(location.clone());
self.events.push(ExplorerEvent::Navigated(location));
}
fn open(&mut self, at: usize) {
let entry = self.shown(at);
if entry.is_folder() {
let mut location = self.location.clone();
location.push(entry.name.clone());
self.navigate(location);
} else {
let name = entry.name.clone();
self.events.push(ExplorerEvent::Opened(name));
}
}
fn up(&mut self) {
if let Some((_, above)) = self.location.split_last() {
self.navigate(above.to_vec());
}
}
fn refresh(&mut self) {
self.status = Status::Loading;
self.events.push(ExplorerEvent::Refresh);
}
fn start_rename(&mut self, name: String) {
if self.renamable {
self.renaming = Some(Renaming {
text: name.clone(),
name,
started: false,
});
}
}
fn finish_rename(&mut self, keep_if_invalid: bool) {
let Some(renaming) = self.renaming.take() else {
return;
};
let to = renaming.text.trim().to_owned();
if to == renaming.name {
return;
}
if self.name_allowed(&to) {
self.events.push(ExplorerEvent::Rename {
name: renaming.name,
to,
});
} else if keep_if_invalid {
self.renaming = Some(renaming);
}
}
fn name_allowed(&self, name: &str) -> bool {
!name.is_empty()
&& !name.contains(['/', '\\'])
&& name != "."
&& name != ".."
&& !self.entries.iter().any(|e| e.name == name)
}
}
fn compare(a: &Entry, b: &Entry, sort: Sort) -> std::cmp::Ordering {
use std::cmp::Ordering;
let folders = b.is_folder().cmp(&a.is_folder());
if folders != Ordering::Equal {
return folders;
}
let by = match sort.by {
SortBy::Name => Ordering::Equal,
SortBy::Size => a.size.cmp(&b.size),
SortBy::Modified => a.modified.cmp(&b.modified),
};
let order = by.then_with(|| natural(&a.name, &b.name));
if sort.ascending {
order
} else {
order.reverse()
}
}
fn natural(a: &str, b: &str) -> std::cmp::Ordering {
use std::cmp::Ordering;
let (mut a_chars, mut b_chars) = (a.chars().peekable(), b.chars().peekable());
loop {
match (a_chars.peek(), b_chars.peek()) {
(None, None) => return a.cmp(b),
(None, Some(_)) => return Ordering::Less,
(Some(_), None) => return Ordering::Greater,
(Some(x), Some(y)) if x.is_ascii_digit() && y.is_ascii_digit() => {
let take = |chars: &mut std::iter::Peekable<std::str::Chars>| {
let mut digits = String::new();
while let Some(c) = chars.next_if(char::is_ascii_digit) {
digits.push(c);
}
digits
};
let (x, y) = (take(&mut a_chars), take(&mut b_chars));
let (x_trim, y_trim) = (x.trim_start_matches('0'), y.trim_start_matches('0'));
let order = x_trim
.len()
.cmp(&y_trim.len())
.then_with(|| x_trim.cmp(y_trim));
if order != Ordering::Equal {
return order;
}
}
(Some(_), Some(_)) => {
let x = a_chars.next().into_iter().flat_map(char::to_lowercase);
let y = b_chars.next().into_iter().flat_map(char::to_lowercase);
let order = x.cmp(y);
if order != Ordering::Equal {
return order;
}
}
}
}
}
pub fn format_size(bytes: u64) -> String {
const UNITS: [&str; 5] = ["KB", "MB", "GB", "TB", "PB"];
if bytes < 1000 {
return format!("{bytes} B");
}
let mut value = bytes as f64 / 1000.0;
let mut unit = 0;
while value >= 999.95 && unit < UNITS.len() - 1 {
value /= 1000.0;
unit += 1;
}
if value < 10.0 {
format!("{value:.1} {}", UNITS[unit])
} else {
format!("{value:.0} {}", UNITS[unit])
}
}
pub fn format_time(at: SystemTime) -> String {
let seconds = match at.duration_since(SystemTime::UNIX_EPOCH) {
Ok(d) => d.as_secs() as i64,
Err(e) => -(e.duration().as_secs() as i64),
};
let (days, of_day) = (seconds.div_euclid(86_400), seconds.rem_euclid(86_400));
let (year, month, day) = civil_from_days(days);
format!(
"{year:04}-{month:02}-{day:02} {:02}:{:02}",
of_day / 3600,
of_day % 3600 / 60
)
}
fn civil_from_days(days: i64) -> (i64, u32, u32) {
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z.rem_euclid(146_097);
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let day = (doy - (153 * mp + 2) / 5 + 1) as u32;
let month = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
let year = yoe + era * 400 + i64::from(month <= 2);
(year, month, day)
}
fn names_for(selected: &BTreeSet<String>, shown: &[String], name: &str) -> Vec<String> {
if selected.contains(name) {
shown
.iter()
.filter(|n| selected.contains(*n))
.cloned()
.collect()
} else {
vec![name.to_owned()]
}
}
#[cfg(test)]
mod tests;