use std::{
collections::{HashMap, HashSet},
path::PathBuf,
sync::mpsc,
};
use crate::locations::{self, Location};
const CELL: f32 = 170.0;
const FILMSTRIP_CELL: f32 = 64.0;
const MAX_THUMB_JOBS: usize = 4;
enum ThumbState {
Loading,
Ready(egui::TextureHandle),
Failed,
}
struct ThumbResult {
path: PathBuf,
rgba: Option<(Vec<u8>, usize, usize)>,
}
pub struct Browser {
pub current_dir: PathBuf,
subdirs: Vec<(PathBuf, String)>,
pub images: Vec<(PathBuf, String)>,
pending_nav: Option<PathBuf>,
thumbnails: HashMap<PathBuf, ThumbState>,
tx: mpsc::SyncSender<ThumbResult>,
rx: mpsc::Receiver<ThumbResult>,
pub selected: Option<PathBuf>,
select_anchor: Option<PathBuf>,
pub selection: HashSet<PathBuf>,
path_edit: String,
path_error: Option<String>,
pending_select: Option<PathBuf>,
storage_locations: Vec<Location>,
network_locations: Vec<Location>,
scan_error: Option<String>,
}
impl Browser {
pub fn new(initial_dir: Option<PathBuf>) -> Self {
let dir = initial_dir.filter(|p| p.is_dir()).unwrap_or_else(|| {
let pictures = dirs::picture_dir()
.or_else(|| dirs::home_dir().map(|h| h.join("Pictures")))
.filter(|p| p.is_dir());
pictures.unwrap_or_else(|| dirs::home_dir().unwrap_or_else(|| PathBuf::from("/")))
});
let (tx, rx) = mpsc::sync_channel(64);
let mut b = Self {
path_edit: dir.display().to_string(),
current_dir: dir,
subdirs: Vec::new(),
images: Vec::new(),
pending_nav: None,
thumbnails: HashMap::new(),
tx,
rx,
selected: None,
select_anchor: None,
selection: HashSet::new(),
path_error: None,
pending_select: None,
storage_locations: Vec::new(),
network_locations: Vec::new(),
scan_error: None,
};
b.scan_locations();
b.scan();
b
}
fn scan(&mut self) {
self.subdirs.clear();
self.images.clear();
self.thumbnails.clear();
self.scan_error = None;
let rd = match std::fs::read_dir(&self.current_dir) {
Ok(rd) => rd,
Err(e) => {
let msg = if e.kind() == std::io::ErrorKind::PermissionDenied {
"Cannot read this directory: permission denied".to_string()
} else {
format!("Cannot read this directory: {e}")
};
self.scan_error = Some(msg);
return;
}
};
for entry in rd.flatten() {
let path = entry.path();
let name = entry.file_name().to_string_lossy().into_owned();
if name.starts_with('.') {
continue;
}
if path.is_dir() {
self.subdirs.push((path, name));
} else if is_image(&path) {
self.images.push((path, name));
}
}
self.subdirs.sort_by(|a, b| a.1.cmp(&b.1));
self.images.sort_by(|a, b| a.1.cmp(&b.1));
}
fn scan_locations(&mut self) {
let home = dirs::home_dir();
let (storage, mut network) = locations::mounted_locations(home.as_deref());
network.extend(locations::gvfs_locations());
self.storage_locations = storage;
self.network_locations = network;
}
fn navigate(&mut self, dir: PathBuf) {
self.pending_nav = Some(dir);
}
pub fn toggle_selection(&mut self, path: PathBuf) {
if !self.selection.remove(&path) {
self.selection.insert(path);
}
}
pub fn selected_paths(&self) -> Vec<PathBuf> {
self.selection.iter().cloned().collect()
}
pub fn selection_count(&self) -> usize {
self.selection.len()
}
pub fn is_selected(&self, path: &std::path::Path) -> bool {
self.selection.contains(path)
}
fn queue_pending_thumbs(&mut self, ctx: &egui::Context) {
let in_flight = self
.thumbnails
.values()
.filter(|state| matches!(state, ThumbState::Loading))
.count();
if in_flight >= MAX_THUMB_JOBS {
return;
}
let slots = MAX_THUMB_JOBS - in_flight;
let to_queue: Vec<PathBuf> = self
.images
.iter()
.filter(|(p, _)| !self.thumbnails.contains_key(p))
.take(slots)
.map(|(p, _)| p.clone())
.collect();
for path in to_queue {
self.thumbnails.insert(path.clone(), ThumbState::Loading);
let tx = self.tx.clone();
let ctx2 = ctx.clone();
let cache_dir = self.current_dir.join(".thumbnails");
std::thread::spawn(move || {
let result = generate_thumb(&path, &cache_dir);
let _ = tx.send(ThumbResult { path, rgba: result });
ctx2.request_repaint();
});
}
}
fn drain_channel(&mut self, ctx: &egui::Context) {
while let Ok(ThumbResult { path, rgba }) = self.rx.try_recv() {
let state = match rgba {
Some((data, w, h)) => {
let img = egui::ColorImage::from_rgba_unmultiplied([w, h], &data);
let tex = ctx.load_texture(
path.to_string_lossy().as_ref(),
img,
egui::TextureOptions::LINEAR,
);
ThumbState::Ready(tex)
}
None => ThumbState::Failed,
};
self.thumbnails.insert(path, state);
}
}
pub fn poll(&mut self, ctx: &egui::Context) {
if let Some(nav) = self.pending_nav.take() {
self.current_dir = nav;
self.path_edit = self.current_dir.display().to_string();
self.path_error = None;
self.selected = None;
self.select_anchor = None;
self.selection.clear();
self.scan_locations();
self.scan();
if let Some(file) = self.pending_select.take() {
if self.images.iter().any(|(p, _)| *p == file) {
self.select_anchor = Some(file.clone());
self.selected = Some(file);
}
}
}
self.drain_channel(ctx);
self.queue_pending_thumbs(ctx);
}
pub fn show_sidebar(&mut self, ui: &mut egui::Ui) {
let mut nav_to: Option<PathBuf> = None;
ui.label(egui::RichText::new("LOCATIONS").weak().small());
let mut places: Vec<(PathBuf, &str, &str)> = Vec::new();
if let Some(home) = dirs::home_dir() {
places.push((home, "\u{1F3E0}", "Home"));
}
places.push((PathBuf::from("/"), "\u{1F4BB}", "Computer"));
for (path, icon, label) in places {
let is_current = path == self.current_dir;
if ui
.selectable_label(is_current, format!("{icon} {label}"))
.on_hover_text(path.display().to_string())
.clicked()
{
nav_to = Some(path);
}
}
ui.add_space(8.0);
for (heading, icon, list) in [
("STORAGE", "\u{1F4BE}", &self.storage_locations),
("NETWORK", "\u{1F310}", &self.network_locations),
] {
if list.is_empty() {
continue;
}
ui.label(egui::RichText::new(heading).weak().small());
for loc in list {
let is_current = loc.path == self.current_dir;
if ui
.selectable_label(is_current, format!("{icon} {}", loc.label))
.on_hover_text(&loc.detail)
.clicked()
{
nav_to = Some(loc.path.clone());
}
}
ui.add_space(8.0);
}
ui.separator();
ui.add_space(4.0);
ui.horizontal(|ui| {
let has_parent = self.current_dir.parent().is_some();
if ui
.add_enabled(has_parent, egui::Button::new("\u{2B06}"))
.on_hover_text("Parent directory")
.clicked()
{
if let Some(p) = self.current_dir.parent() {
nav_to = Some(p.to_path_buf());
}
}
let resp = ui.add(
egui::TextEdit::singleline(&mut self.path_edit)
.desired_width(ui.available_width())
.font(egui::TextStyle::Monospace),
);
if resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
let candidate =
locations::expand_typed_path(&self.path_edit, dirs::home_dir().as_deref());
if candidate.is_dir() {
nav_to = Some(candidate);
} else if candidate.is_file() {
if let Some(parent) = candidate.parent() {
nav_to = Some(parent.to_path_buf());
self.pending_select = Some(candidate);
}
} else {
self.path_error = Some(format!("No such folder: {}", candidate.display()));
}
}
if resp.changed() {
self.path_error = None;
}
});
if let Some(err) = &self.path_error {
ui.colored_label(ui.visuals().error_fg_color, err);
}
ui.add_space(4.0);
ui.separator();
if !self.subdirs.is_empty() {
ui.add_space(4.0);
ui.label(egui::RichText::new("FOLDERS").weak().small());
egui::ScrollArea::vertical()
.auto_shrink([false, true])
.show(ui, |ui| {
for (path, name) in &self.subdirs {
if ui.button(format!("\u{1F4C1} {}", name)).clicked() {
nav_to = Some(path.clone());
}
}
});
}
if let Some(nav) = nav_to {
self.navigate(nav);
}
}
pub fn show_contents(&mut self, ui: &mut egui::Ui, _ctx: &egui::Context) -> Option<PathBuf> {
let mut plain_click: Option<PathBuf> = None;
let mut ctrl_click: Option<PathBuf> = None;
let mut shift_click: Option<PathBuf> = None;
let mut open_request: Option<PathBuf> = None;
if let Some(err) = &self.scan_error {
ui.centered_and_justified(|ui| {
ui.label(err.as_str());
});
} else if self.images.is_empty() && self.subdirs.is_empty() {
ui.centered_and_justified(|ui| {
ui.label("No images in this directory");
});
} else {
let avail_w = ui.available_width();
let cols = ((avail_w / (CELL + 8.0)) as usize).max(1);
egui::ScrollArea::vertical()
.auto_shrink([false, false])
.show(ui, |ui| {
egui::Grid::new("image_grid")
.num_columns(cols)
.spacing([8.0, 8.0])
.show(ui, |ui| {
for (i, (path, name)) in self.images.iter().enumerate() {
let is_focused = self.selected.as_ref() == Some(path);
let is_checked = self.selection.contains(path);
let thumb = match self.thumbnails.get(path) {
Some(ThumbState::Ready(tex)) => {
Some((tex.id(), tex.size_vec2()))
}
_ => None,
};
let resp = draw_thumb_cell(
ui,
name,
thumb,
is_focused,
is_checked,
CELL,
true,
);
if resp.double_clicked() {
open_request = Some(path.clone());
} else if resp.clicked() {
let shift_held = ui.input(|i| i.modifiers.shift);
let ctrl_held =
ui.input(|i| i.modifiers.ctrl || i.modifiers.mac_cmd);
if ctrl_held {
ctrl_click = Some(path.clone());
} else if shift_held {
shift_click = Some(path.clone());
} else {
plain_click = Some(path.clone());
}
}
if (i + 1) % cols == 0 {
ui.end_row();
}
}
});
});
}
if let Some(path) = plain_click {
self.selected = Some(path.clone());
self.select_anchor = Some(path.clone());
self.selection.clear();
self.selection.insert(path);
}
if let Some(path) = ctrl_click {
self.selected = Some(path.clone());
self.select_anchor = Some(path.clone());
self.toggle_selection(path);
}
if let Some(path) = shift_click {
self.extend_selection_to(path);
}
open_request
}
fn extend_selection_to(&mut self, path: PathBuf) {
let anchor = self
.select_anchor
.clone()
.or_else(|| self.selected.clone());
let Some(anchor) = anchor else {
self.selected = Some(path.clone());
self.select_anchor = Some(path.clone());
self.selection.clear();
self.selection.insert(path);
return;
};
let anchor_idx = self.images.iter().position(|(p, _)| *p == anchor);
let click_idx = self.images.iter().position(|(p, _)| *p == path);
if let (Some(a), Some(c)) = (anchor_idx, click_idx) {
let (lo, hi) = if a <= c { (a, c) } else { (c, a) };
self.selection = self.images[lo..=hi]
.iter()
.map(|(p, _)| p.clone())
.collect();
}
self.selected = Some(path);
}
pub fn show_filmstrip(&mut self, ui: &mut egui::Ui, active: Option<&std::path::Path>) -> Option<PathBuf> {
let mut clicked_path = None;
let selection: Vec<(PathBuf, String)> = self
.images
.iter()
.filter(|(p, _)| self.selection.contains(p))
.cloned()
.collect();
egui::ScrollArea::horizontal()
.auto_shrink([false, false])
.show(ui, |ui| {
ui.horizontal(|ui| {
for (path, name) in &selection {
let is_active = active == Some(path.as_path());
let is_checked = self.selection.contains(path);
let thumb = match self.thumbnails.get(path) {
Some(ThumbState::Ready(tex)) => Some((tex.id(), tex.size_vec2())),
_ => None,
};
if draw_thumb_cell(
ui,
name,
thumb,
is_active,
is_checked,
FILMSTRIP_CELL,
false,
)
.clicked()
{
clicked_path = Some(path.clone());
}
}
});
});
clicked_path
}
}
fn draw_thumb_cell(
ui: &mut egui::Ui,
name: &str,
thumb: Option<(egui::TextureId, egui::Vec2)>,
selected: bool,
marked: bool,
cell: f32,
show_label: bool,
) -> egui::Response {
let cell_height = if show_label { cell + 22.0 } else { cell };
let (resp, painter) = ui.allocate_painter(egui::vec2(cell, cell_height), egui::Sense::click());
let rect = resp.rect;
if selected {
painter.rect_filled(rect, 4.0, ui.visuals().selection.bg_fill);
} else if resp.hovered() {
painter.rect_filled(rect, 4.0, ui.visuals().widgets.hovered.bg_fill);
}
let img_rect = egui::Rect::from_min_size(rect.min, egui::vec2(cell, cell));
match thumb {
Some((tex_id, tex_size)) => {
let scale = (cell / tex_size.x).min(cell / tex_size.y);
let display = tex_size * scale;
let offset = (egui::vec2(cell, cell) - display) * 0.5;
let draw_rect = egui::Rect::from_min_size(img_rect.min + offset, display);
painter.image(
tex_id,
draw_rect,
egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)),
egui::Color32::WHITE,
);
}
None => {
painter.rect_filled(img_rect, 4.0, egui::Color32::from_gray(40));
painter.text(
img_rect.center(),
egui::Align2::CENTER_CENTER,
"\u{2026}",
egui::FontId::proportional(22.0),
egui::Color32::GRAY,
);
}
}
if marked {
let badge_center = img_rect.right_top() + egui::vec2(-10.0, 10.0);
painter.circle_filled(badge_center, 8.0, ui.visuals().selection.bg_fill);
painter.text(
badge_center,
egui::Align2::CENTER_CENTER,
"\u{2713}",
egui::FontId::proportional(10.0),
egui::Color32::WHITE,
);
}
if show_label {
let label_pos = egui::pos2(rect.center().x, img_rect.max.y + 11.0);
let name_short = if name.len() > 24 { &name[..24] } else { name };
painter.text(
label_pos,
egui::Align2::CENTER_CENTER,
name_short,
egui::FontId::proportional(11.0),
ui.visuals().text_color(),
);
}
resp
}
fn generate_thumb(path: &PathBuf, cache_dir: &PathBuf) -> Option<(Vec<u8>, usize, usize)> {
let thumb_path = crate::thumbnail::cache_path(path, cache_dir);
let img = if thumb_path.exists() {
image::open(&thumb_path).ok()?
} else {
let full = crate::thumbnail::open_image_for_preview(path).ok()?;
let t = full.thumbnail(crate::thumbnail::THUMB_SIZE, crate::thumbnail::THUMB_SIZE);
let _ = std::fs::create_dir_all(cache_dir);
let _ = t.save(&thumb_path);
t
};
let rgba = img.to_rgba8();
let w = rgba.width() as usize;
let h = rgba.height() as usize;
Some((rgba.into_raw(), w, h))
}
fn is_image(path: &std::path::Path) -> bool {
crate::thumbnail::is_supported_image(path)
}