use crate::fs_model::{
absolute_time_label, breadcrumbs, canonical_display_path, create_file, create_folder,
expand_tilde, filter_entries, home_dir, human_size, known_places, modified_label,
path_for_display, recursive_search_cancellable, rename_path, scan_directory, trash_files_dir,
validate_child_name, DirectorySnapshot, EntryKind, FileEntry, NameValidation, Place,
RecursiveSearchOptions, RecursiveSearchResults, ScanOptions, SortKey,
};
use crate::ops::{
active_child_reapers, launch_default, launch_new_window, launch_terminal_at, operation_detail,
operation_percent, start_operation, undo_for_created_file, undo_for_created_folder,
undo_for_rename, ClipboardMode, ClipboardPayload, CompletedAction, OperationEvent,
OperationProgress, OperationRequest, UndoAction, CHILD_REAPER_LIMIT, OPERATION_DEPTH_LIMIT,
OPERATION_ITEM_LIMIT, OPERATION_SOURCE_LIMIT,
};
use crate::plugins::{
PluginDescriptor, PluginRegistry, KNOTWORK_NAME, MEDIA_PREVIEW_PLUGIN_ID,
TEXT_PREVIEW_PLUGIN_ID,
};
use crate::theme::{field_label, muted_label, paint_background};
use crate::thumbnails::{
media_class_for_path, modified_stamp as thumbnail_modified_stamp, MediaClass, ThumbnailManager,
ThumbnailView,
};
use eframe::egui::{
self, Align2, Color32, FontId, Pos2, Rect, RichText, Rounding, Sense, Shadow, Stroke, Vec2,
};
use std::collections::{BTreeSet, VecDeque};
use std::io::{BufRead, Read};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering as AtomicOrdering};
use std::sync::mpsc::{self, Receiver, TryRecvError};
use std::sync::Arc;
use std::thread;
use std::time::{Duration, Instant};
const DIRECTORY_ENTRY_LIMIT: usize = 20_000;
const DIRECTORY_ERROR_LIMIT: usize = 64;
const SEARCH_LIMIT: usize = 4_000;
const SEARCH_DIR_LIMIT: usize = 4_000;
const SEARCH_QUEUE_LIMIT: usize = 8_000;
const PREFERENCE_LINE_LIMIT: usize = 64;
const BACKGROUND_IMAGE_BYTES_LIMIT: u64 = 64 * 1024 * 1024;
const BACKGROUND_IMAGE_PIXELS_LIMIT: u64 = 16_000_000;
const MEDIA_PREVIEW_IMAGE_BYTES_LIMIT: u64 = 128 * 1024 * 1024;
const MEDIA_PREVIEW_IMAGE_PIXELS_LIMIT: u64 = 64_000_000;
const MEDIA_PREVIEW_IMAGE_MAX_EDGE: u32 = 2400;
const MEDIA_PREVIEW_IMAGE_CACHE_LIMIT: usize = 12;
const TEXT_PREVIEW_BYTES_LIMIT: u64 = 2 * 1024 * 1024;
const TEXT_PREVIEW_LINE_LIMIT: usize = 2_000;
const OPEN_SELECTION_LIMIT: usize = CHILD_REAPER_LIMIT;
const HISTORY_LIMIT: usize = 120;
const BOOKMARK_LIMIT: usize = 80;
const UNDO_LIMIT: usize = 40;
const LOG_LIMIT: usize = 220;
const VIDEO_HOVER_INTENT_SECONDS: f64 = 0.16;
const SEARCH_DEBOUNCE: Duration = Duration::from_millis(180);
const FOLDER_ICON_ASPECT: f32 = 1.22;
pub struct GuthApp {
current_dir: PathBuf,
path_input: String,
location_entry_open: bool,
search_focus_requested: bool,
search_query: String,
entries: Vec<FileEntry>,
snapshot: Option<DirectorySnapshot>,
deep_results: Option<RecursiveSearchResults>,
deep_search_pending: Option<DeepSearchPending>,
deep_search_rx: Option<Receiver<DeepSearchResult>>,
deep_search_rx_generation: Option<u64>,
deep_search_generation: Arc<AtomicU64>,
bookmarks: Vec<PathBuf>,
selected: BTreeSet<PathBuf>,
selection_anchor: Option<PathBuf>,
selection_focus: Option<PathBuf>,
pin_drag_candidate: Option<PathBuf>,
pinned_drop_rect: Option<Rect>,
entry_drag_paths: Vec<PathBuf>,
history: Vec<PathBuf>,
history_cursor: usize,
sort_key: SortKey,
sort_reverse: bool,
folders_first: bool,
view_mode: ViewMode,
show_hidden: bool,
recursive_search: bool,
clipboard: Option<ClipboardPayload>,
undo_stack: Vec<UndoAction>,
operation_rx: Option<Receiver<OperationEvent>>,
operation_title: Option<String>,
operation_progress: Option<OperationProgress>,
operation_is_undo: bool,
thumbnails: ThumbnailManager,
plugins: PluginRegistry,
media_preview_plugin_enabled: bool,
text_preview_plugin_enabled: bool,
media_preview: MediaPreviewState,
hover: HoverState,
hover_animation_active: bool,
palette: AppPalette,
motion_enabled: bool,
hover_effects_enabled: bool,
theme_opacity: f32,
background_image_path: String,
background_image_opacity: f32,
background_texture: Option<egui::TextureHandle>,
background_loaded_path: Option<PathBuf>,
background_error: Option<String>,
runtime: RuntimeInfo,
status: String,
logs: Vec<String>,
dialogs: Dialogs,
show_properties: bool,
show_settings: bool,
sidebar_collapsed: bool,
}
#[derive(Clone, Debug)]
pub struct RuntimeInfo {
pub renderer: String,
pub gpu_mode: String,
pub shader: String,
pub wgpu_backends: String,
pub wgpu_power: String,
pub frame_latency: String,
pub vsync: bool,
}
impl RuntimeInfo {
pub fn glow_default() -> Self {
Self {
renderer: "GLOW / OPENGL".to_string(),
gpu_mode: "REQUIRED".to_string(),
shader: "AUTO".to_string(),
wgpu_backends: "N/A".to_string(),
wgpu_power: "N/A".to_string(),
frame_latency: "N/A".to_string(),
vsync: true,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum AppPalette {
NautilusLight,
NautilusDark,
MacosLight,
MacosDark,
}
impl AppPalette {
const ALL: [Self; 4] = [
Self::NautilusLight,
Self::NautilusDark,
Self::MacosLight,
Self::MacosDark,
];
fn label(self) -> &'static str {
match self {
Self::NautilusLight => "Guth Light",
Self::NautilusDark => "Guth Dark",
Self::MacosLight => "Studio Light",
Self::MacosDark => "Studio Dark",
}
}
fn as_config(self) -> &'static str {
match self {
Self::NautilusLight => "guth_light",
Self::NautilusDark => "guth_dark",
Self::MacosLight => "studio_light",
Self::MacosDark => "studio_dark",
}
}
fn from_config(value: &str) -> Self {
match value {
"guth_light" | "nautilus_light" => Self::NautilusLight,
"guth_dark" | "nautilus_dark" | "paper_dark" | "graphite" | "emerald" | "violet"
| "amber" | "arctic" => Self::NautilusDark,
"studio_light" | "macos_light" | "cupertino_light" => Self::MacosLight,
"studio_dark" | "macos_dark" | "cupertino_dark" => Self::MacosDark,
_ => Self::NautilusDark,
}
}
fn is_light(self) -> bool {
matches!(self, Self::NautilusLight | Self::MacosLight)
}
fn accent(self, phase: f32) -> Color32 {
let _ = phase;
match self {
Self::NautilusLight => Color32::from_rgb(53, 132, 228),
Self::NautilusDark => Color32::from_rgb(53, 132, 228),
Self::MacosLight => Color32::from_rgb(0, 122, 255),
Self::MacosDark => Color32::from_rgb(10, 132, 255),
}
}
fn background(self) -> Color32 {
match self {
Self::NautilusLight => Color32::from_rgb(246, 245, 244),
Self::NautilusDark => Color32::from_rgb(12, 14, 24),
Self::MacosLight => Color32::from_rgb(242, 242, 247),
Self::MacosDark => Color32::from_rgb(12, 13, 22),
}
}
fn panel_fill(self) -> Color32 {
match self {
Self::NautilusLight => Color32::from_rgb(255, 255, 255),
Self::NautilusDark => Color32::from_rgb(25, 27, 42),
Self::MacosLight => Color32::from_rgb(255, 255, 255),
Self::MacosDark => Color32::from_rgb(24, 25, 39),
}
}
fn sidebar_fill(self) -> Color32 {
match self {
Self::NautilusLight => Color32::from_rgb(242, 241, 239),
Self::NautilusDark => Color32::from_rgb(20, 22, 36),
Self::MacosLight => Color32::from_rgb(247, 247, 250),
Self::MacosDark => Color32::from_rgb(19, 20, 34),
}
}
fn content_fill(self) -> Color32 {
match self {
Self::NautilusLight => Color32::from_rgb(255, 255, 255),
Self::NautilusDark => Color32::from_rgb(13, 15, 26),
Self::MacosLight => Color32::from_rgb(255, 255, 255),
Self::MacosDark => Color32::from_rgb(12, 14, 24),
}
}
fn surface(self) -> Color32 {
match self {
Self::NautilusLight => Color32::from_rgb(249, 249, 248),
Self::NautilusDark => Color32::from_rgb(28, 30, 46),
Self::MacosLight => Color32::from_rgb(247, 247, 249),
Self::MacosDark => Color32::from_rgb(27, 29, 44),
}
}
fn elevated_fill(self) -> Color32 {
match self {
Self::NautilusLight => Color32::from_rgb(255, 255, 255),
Self::NautilusDark => Color32::from_rgb(31, 34, 52),
Self::MacosLight => Color32::from_rgb(255, 255, 255),
Self::MacosDark => Color32::from_rgb(31, 33, 52),
}
}
fn recessed_fill(self) -> Color32 {
match self {
Self::NautilusLight => Color32::from_rgb(244, 244, 243),
Self::NautilusDark => Color32::from_rgb(14, 16, 28),
Self::MacosLight => Color32::from_rgb(244, 244, 247),
Self::MacosDark => Color32::from_rgb(13, 15, 27),
}
}
fn button_fill(self) -> Color32 {
match self {
Self::NautilusLight => Color32::from_rgb(248, 248, 247),
Self::NautilusDark => Color32::from_rgb(34, 37, 56),
Self::MacosLight => Color32::from_rgb(246, 246, 248),
Self::MacosDark => Color32::from_rgb(34, 36, 55),
}
}
fn hover_fill(self) -> Color32 {
match self {
Self::NautilusLight => Color32::from_rgba_unmultiplied(53, 132, 228, 18),
Self::NautilusDark => Color32::from_rgba_unmultiplied(53, 132, 228, 34),
Self::MacosLight => Color32::from_rgba_unmultiplied(0, 122, 255, 16),
Self::MacosDark => Color32::from_rgba_unmultiplied(10, 132, 255, 34),
}
}
fn selected_fill(self) -> Color32 {
match self {
Self::NautilusLight => Color32::from_rgba_unmultiplied(53, 132, 228, 58),
Self::NautilusDark => Color32::from_rgba_unmultiplied(53, 132, 228, 66),
Self::MacosLight => Color32::from_rgba_unmultiplied(0, 122, 255, 48),
Self::MacosDark => Color32::from_rgba_unmultiplied(10, 132, 255, 70),
}
}
fn accent_soft(self) -> Color32 {
Color32::from_rgba_unmultiplied(
self.accent(0.0).r(),
self.accent(0.0).g(),
self.accent(0.0).b(),
if self.is_light() { 18 } else { 34 },
)
}
fn row_alt_fill(self) -> Color32 {
match self {
Self::NautilusLight => Color32::from_rgba_unmultiplied(30, 32, 36, 5),
Self::NautilusDark => Color32::from_rgba_unmultiplied(255, 255, 255, 5),
Self::MacosLight => Color32::from_rgba_unmultiplied(0, 0, 0, 5),
Self::MacosDark => Color32::from_rgba_unmultiplied(255, 255, 255, 5),
}
}
fn hairline(self) -> Color32 {
Color32::from_rgba_unmultiplied(
self.border().r(),
self.border().g(),
self.border().b(),
if self.is_light() { 156 } else { 128 },
)
}
fn header_fill(self) -> Color32 {
match self {
Self::NautilusLight => Color32::from_rgb(240, 239, 237),
Self::NautilusDark => Color32::from_rgb(17, 19, 32),
Self::MacosLight => Color32::from_rgb(247, 247, 249),
Self::MacosDark => Color32::from_rgb(16, 18, 31),
}
}
fn border(self) -> Color32 {
match self {
Self::NautilusLight => Color32::from_rgb(214, 213, 211),
Self::NautilusDark => Color32::from_rgb(49, 52, 76),
Self::MacosLight => Color32::from_rgb(216, 216, 221),
Self::MacosDark => Color32::from_rgb(50, 52, 76),
}
}
fn text(self) -> Color32 {
if self.is_light() {
Color32::from_rgb(32, 33, 36)
} else {
Color32::from_rgb(246, 245, 244)
}
}
fn secondary_text(self) -> Color32 {
match self {
Self::NautilusLight => Color32::from_rgb(94, 92, 100),
Self::NautilusDark => Color32::from_rgb(204, 207, 228),
Self::MacosLight => Color32::from_rgb(60, 60, 67),
Self::MacosDark => Color32::from_rgb(213, 216, 238),
}
}
fn muted(self) -> Color32 {
match self {
Self::NautilusLight => Color32::from_rgb(119, 118, 123),
Self::NautilusDark => Color32::from_rgb(143, 148, 178),
Self::MacosLight => Color32::from_rgb(99, 99, 102),
Self::MacosDark => Color32::from_rgb(151, 156, 188),
}
}
fn visuals(self) -> egui::Visuals {
let mut visuals = if self.is_light() {
egui::Visuals::light()
} else {
egui::Visuals::dark()
};
visuals.override_text_color = Some(self.text());
visuals.panel_fill = self.background();
visuals.window_fill = self.panel_fill();
visuals.extreme_bg_color = self.background();
visuals.faint_bg_color = self.surface();
visuals.widgets.noninteractive.bg_fill = self.surface();
visuals.widgets.inactive.bg_fill = self.surface();
visuals.widgets.noninteractive.bg_stroke = Stroke::new(1.0, self.hairline());
visuals.widgets.inactive.bg_stroke = Stroke::new(1.0, self.hairline());
visuals.widgets.hovered.bg_fill = self.hover_fill();
visuals.widgets.active.bg_fill = self.selected_fill();
visuals.widgets.inactive.fg_stroke = Stroke::new(1.0, self.secondary_text());
visuals.widgets.hovered.fg_stroke = Stroke::new(1.0, self.text());
visuals.widgets.active.fg_stroke = Stroke::new(1.0, self.text());
visuals.selection.bg_fill = self.accent(0.0);
visuals.selection.stroke = Stroke::new(1.0, self.text());
visuals.window_fill = self.panel_fill();
visuals.window_stroke = Stroke::new(1.0, self.border());
visuals.window_rounding = Rounding::same(theme_rounding(self) + 8.0);
visuals.window_shadow = Shadow::NONE;
visuals.popup_shadow = Shadow::NONE;
visuals.menu_rounding = Rounding::same(theme_rounding(self) + 8.0);
visuals
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum ViewMode {
Grid,
List,
}
impl ViewMode {
fn as_config(self) -> &'static str {
match self {
Self::Grid => "grid",
Self::List => "list",
}
}
fn from_config(value: &str) -> Self {
match value {
"list" => Self::List,
_ => Self::Grid,
}
}
}
#[derive(Default)]
struct Dialogs {
new_folder_open: bool,
new_folder_name: String,
new_folder_focus_requested: bool,
new_file_open: bool,
new_file_name: String,
new_file_focus_requested: bool,
rename_open: bool,
rename_path: Option<PathBuf>,
rename_name: String,
rename_focus_requested: bool,
delete_confirm: Option<DeleteIntent>,
error: Option<String>,
}
impl Dialogs {
fn is_open(&self) -> bool {
self.new_folder_open
|| self.new_file_open
|| self.rename_open
|| self.delete_confirm.is_some()
|| self.error.is_some()
}
}
struct DeepSearchPending {
generation: u64,
requested_at: Instant,
root: PathBuf,
query: String,
options: RecursiveSearchOptions,
}
struct DeepSearchResult {
generation: u64,
results: RecursiveSearchResults,
}
#[derive(Clone, Debug)]
struct HoverState {
path: Option<PathBuf>,
name: String,
class: MediaClass,
started_at: f64,
last_seen_at: f64,
}
impl Default for HoverState {
fn default() -> Self {
Self {
path: None,
name: String::new(),
class: MediaClass::File,
started_at: 0.0,
last_seen_at: 0.0,
}
}
}
impl HoverState {
fn observe(&mut self, entry: &FileEntry, class: MediaClass, now: f64) {
if self.path.as_deref() != Some(entry.path.as_path()) {
self.started_at = now;
}
self.path = Some(entry.path.clone());
self.name = entry.name.clone();
self.class = class;
self.last_seen_at = now;
}
fn intent_ready(&self, path: &Path, now: f64, delay_seconds: f64) -> bool {
self.path.as_deref() == Some(path) && now - self.started_at >= delay_seconds
}
fn clear_if_stale(&mut self, now: f64) {
if self.path.is_some() && now - self.last_seen_at > 0.18 {
self.path = None;
self.name.clear();
}
}
}
#[derive(Clone, Debug)]
struct DeleteIntent {
paths: Vec<PathBuf>,
permanent: bool,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
struct SelectionSummary {
count: usize,
folders: usize,
files: usize,
file_bytes: u64,
}
struct MediaPreviewState {
open: bool,
entry: Option<FileEntry>,
active_plugin: Option<PreviewPluginKind>,
image_texture: Option<egui::TextureHandle>,
image_dimensions: Option<[u32; 2]>,
loaded_key: Option<MediaPreviewKey>,
loading_key: Option<MediaPreviewKey>,
image_worker_key: Option<MediaPreviewKey>,
image_rx: Option<Receiver<MediaPreviewImageResult>>,
image_pending: Option<MediaPreviewPending>,
image_cache: VecDeque<MediaPreviewCachedImage>,
cache_clock: u64,
text_document: Option<TextPreviewDocument>,
text_loaded_key: Option<MediaPreviewKey>,
text_loading_key: Option<MediaPreviewKey>,
text_worker_key: Option<MediaPreviewKey>,
text_rx: Option<Receiver<TextPreviewResult>>,
text_pending: Option<MediaPreviewPending>,
text_view_buffer: String,
text_view_buffer_key: Option<MediaPreviewKey>,
text_view_buffer_line_numbers: bool,
text_line_numbers: bool,
text_find_query: String,
text_find_last_query: String,
text_find_loaded_key: Option<MediaPreviewKey>,
text_find_match_count: usize,
text_find_focus_requested: bool,
error: Option<String>,
zoom: f32,
text_scale: f32,
pan: Vec2,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum PreviewPluginKind {
Media,
Text,
}
struct MediaPreviewImageResult {
key: MediaPreviewKey,
image: Result<LoadedPreviewImage, String>,
}
struct LoadedPreviewImage {
pixels: egui::ColorImage,
source_dimensions: [u32; 2],
}
struct TextPreviewResult {
key: MediaPreviewKey,
document: Result<TextPreviewDocument, String>,
}
struct MediaPreviewPending {
key: MediaPreviewKey,
path: PathBuf,
}
struct TextPreviewDocument {
content: String,
numbered_content: String,
bytes_read: usize,
line_count: usize,
truncated: bool,
lossy: bool,
}
struct MediaPreviewCachedImage {
key: MediaPreviewKey,
texture: egui::TextureHandle,
source_dimensions: [u32; 2],
last_used: u64,
}
impl Default for MediaPreviewState {
fn default() -> Self {
Self {
open: false,
entry: None,
active_plugin: None,
image_texture: None,
image_dimensions: None,
loaded_key: None,
loading_key: None,
image_worker_key: None,
image_rx: None,
image_pending: None,
image_cache: VecDeque::new(),
cache_clock: 0,
text_document: None,
text_loaded_key: None,
text_loading_key: None,
text_worker_key: None,
text_rx: None,
text_pending: None,
text_view_buffer: String::new(),
text_view_buffer_key: None,
text_view_buffer_line_numbers: true,
text_line_numbers: true,
text_find_query: String::new(),
text_find_last_query: String::new(),
text_find_loaded_key: None,
text_find_match_count: 0,
text_find_focus_requested: false,
error: None,
zoom: 1.0,
text_scale: 1.0,
pan: Vec2::ZERO,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct MediaPreviewKey {
path: PathBuf,
size: u64,
modified_stamp: u128,
}
impl MediaPreviewKey {
fn from_entry(entry: &FileEntry) -> Self {
Self {
path: entry.path.clone(),
size: entry.size,
modified_stamp: thumbnail_modified_stamp(entry.modified),
}
}
}
#[derive(Clone, Copy, Debug)]
enum HistoryMode {
Push,
Replace,
}
#[derive(Clone, Debug)]
struct Preferences {
view_mode: ViewMode,
show_hidden: bool,
recursive_search: bool,
sort_key: SortKey,
sort_reverse: bool,
folders_first: bool,
palette: AppPalette,
motion_enabled: bool,
hover_effects_enabled: bool,
theme_opacity: f32,
background_image_path: String,
background_image_opacity: f32,
sidebar_collapsed: bool,
media_preview_plugin_enabled: bool,
text_preview_plugin_enabled: bool,
}
impl Default for Preferences {
fn default() -> Self {
Self {
view_mode: ViewMode::List,
show_hidden: false,
recursive_search: false,
sort_key: SortKey::Name,
sort_reverse: false,
folders_first: true,
palette: AppPalette::NautilusDark,
motion_enabled: true,
hover_effects_enabled: true,
theme_opacity: 1.0,
background_image_path: String::new(),
background_image_opacity: 0.22,
sidebar_collapsed: false,
media_preview_plugin_enabled: true,
text_preview_plugin_enabled: true,
}
}
}
impl GuthApp {
pub fn new(
cc: &eframe::CreationContext<'_>,
start_path: Option<PathBuf>,
runtime: RuntimeInfo,
) -> Self {
migrate_legacy_config();
let preferences = load_preferences();
cc.egui_ctx.set_visuals(preferences.palette.visuals());
let bookmarks = load_bookmarks();
let requested = start_path.unwrap_or_else(home_dir);
let initial_dir = if requested.is_dir() {
requested
} else {
requested
.parent()
.map(Path::to_path_buf)
.unwrap_or_else(home_dir)
};
let current_dir = usable_start_dir(initial_dir);
let mut app = Self {
path_input: current_dir.display().to_string(),
location_entry_open: false,
search_focus_requested: false,
current_dir: current_dir.clone(),
search_query: String::new(),
entries: Vec::new(),
snapshot: None,
deep_results: None,
deep_search_pending: None,
deep_search_rx: None,
deep_search_rx_generation: None,
deep_search_generation: Arc::new(AtomicU64::new(0)),
bookmarks,
selected: BTreeSet::new(),
selection_anchor: None,
selection_focus: None,
pin_drag_candidate: None,
pinned_drop_rect: None,
entry_drag_paths: Vec::new(),
history: vec![current_dir],
history_cursor: 0,
sort_key: preferences.sort_key,
sort_reverse: preferences.sort_reverse,
folders_first: preferences.folders_first,
view_mode: preferences.view_mode,
show_hidden: preferences.show_hidden,
recursive_search: preferences.recursive_search,
clipboard: None,
undo_stack: Vec::new(),
operation_rx: None,
operation_title: None,
operation_progress: None,
operation_is_undo: false,
thumbnails: ThumbnailManager::new(),
plugins: PluginRegistry::builtin(),
media_preview_plugin_enabled: preferences.media_preview_plugin_enabled,
text_preview_plugin_enabled: preferences.text_preview_plugin_enabled,
media_preview: MediaPreviewState::default(),
hover: HoverState::default(),
hover_animation_active: false,
palette: preferences.palette,
motion_enabled: preferences.motion_enabled,
hover_effects_enabled: preferences.hover_effects_enabled,
theme_opacity: preferences.theme_opacity,
background_image_path: preferences.background_image_path,
background_image_opacity: preferences.background_image_opacity,
background_texture: None,
background_loaded_path: None,
background_error: None,
runtime,
status: "Ready".to_string(),
logs: vec!["Guth ready".to_string()],
dialogs: Dialogs::default(),
show_properties: false,
show_settings: false,
sidebar_collapsed: preferences.sidebar_collapsed,
};
app.refresh_current_dir();
app
}
fn refresh_current_dir(&mut self) -> bool {
let options = ScanOptions {
show_hidden: self.show_hidden,
sort_key: self.sort_key,
reverse: self.sort_reverse,
folders_first: self.folders_first,
entry_limit: DIRECTORY_ENTRY_LIMIT,
error_limit: DIRECTORY_ERROR_LIMIT,
};
match scan_directory(&self.current_dir, options) {
Ok(snapshot) => {
self.entries = snapshot.entries.clone();
let visible: BTreeSet<PathBuf> = self
.entries
.iter()
.map(|entry| entry.path.clone())
.collect();
self.selected.retain(|path| visible.contains(path));
if !self
.selection_anchor
.as_ref()
.is_some_and(|path| visible.contains(path))
{
self.selection_anchor = self.selected.iter().next().cloned();
}
if !self
.selection_focus
.as_ref()
.is_some_and(|path| visible.contains(path))
{
self.selection_focus = self.selection_anchor.clone();
}
self.reconcile_media_preview_entry();
self.status = if snapshot.scan_limited {
format!(
"Ready · showing {} items · scan capped at {} entries",
snapshot.entries.len(),
snapshot.entry_limit
)
} else {
format!(
"Ready · {} items · {} hidden",
snapshot.entries.len(),
snapshot.hidden_entries
)
};
if snapshot.scan_limited {
self.push_log(format!(
"SCAN CAPPED // {} // {} entries",
self.current_dir.display(),
snapshot.entry_limit
));
}
if snapshot.error_limited {
self.push_log(format!(
"SCAN WARNINGS CAPPED // {} warnings shown",
snapshot.error_limit
));
}
for error in &snapshot.errors {
self.push_log(format!("SCAN WARNING // {error}"));
}
self.snapshot = Some(snapshot);
self.update_deep_search();
true
}
Err(error) => {
self.status = "Could not load folder".to_string();
self.push_log(format!(
"LOAD FAILED // {} // {error}",
self.current_dir.display()
));
false
}
}
}
fn update_deep_search(&mut self) {
let generation = self
.deep_search_generation
.fetch_add(1, AtomicOrdering::Relaxed)
.wrapping_add(1);
self.deep_search_pending = None;
self.deep_results = None;
if self.recursive_search && !self.search_query.trim().is_empty() {
self.status = "Searching subfolders".to_string();
self.deep_search_pending = Some(DeepSearchPending {
generation,
requested_at: Instant::now(),
root: self.current_dir.clone(),
query: self.search_query.trim().to_string(),
options: RecursiveSearchOptions {
show_hidden: self.show_hidden,
sort_key: self.sort_key,
reverse: self.sort_reverse,
folders_first: self.folders_first,
result_limit: SEARCH_LIMIT,
dir_limit: SEARCH_DIR_LIMIT,
queue_limit: SEARCH_QUEUE_LIMIT,
},
});
}
}
fn start_deep_search_if_ready(&mut self) {
if self.deep_search_rx.is_some() {
return;
}
let Some(pending) = self.deep_search_pending.as_ref() else {
return;
};
if pending.requested_at.elapsed() < SEARCH_DEBOUNCE {
return;
}
let Some(pending) = self.deep_search_pending.take() else {
return;
};
let generation = pending.generation;
let generation_clock = Arc::clone(&self.deep_search_generation);
let (tx, rx) = mpsc::sync_channel(1);
self.deep_search_rx = Some(rx);
self.deep_search_rx_generation = Some(generation);
if let Err(error) = thread::Builder::new()
.name("guth-recursive-search".to_string())
.spawn(move || {
let results = recursive_search_cancellable(
&pending.root,
&pending.query,
pending.options,
|| generation_clock.load(AtomicOrdering::Relaxed) != generation,
);
let _ = tx.send(DeepSearchResult {
generation,
results,
});
})
{
self.deep_search_rx = None;
self.deep_search_rx_generation = None;
self.report_error(
"Could not search subfolders",
"SEARCH",
format!("Could not start recursive search worker: {error}"),
);
}
}
fn poll_deep_search(&mut self, ctx: &egui::Context) {
let received = self
.deep_search_rx
.as_ref()
.and_then(|rx| match rx.try_recv() {
Ok(result) => Some(Ok(result)),
Err(TryRecvError::Empty) => None,
Err(TryRecvError::Disconnected) => Some(Err(
"Recursive search worker stopped before reporting results".to_string(),
)),
});
let Some(received) = received else {
return;
};
let received_generation = self.deep_search_rx_generation.take();
self.deep_search_rx = None;
match received {
Ok(result)
if result.generation
== self.deep_search_generation.load(AtomicOrdering::Relaxed) =>
{
self.status = if result.results.limited() {
format!(
"{} search matches · capped after {} folders",
result.results.entries.len(),
result.results.visited_dirs
)
} else {
format!("{} search matches", result.results.entries.len())
};
self.deep_results = Some(result.results);
ctx.request_repaint();
}
Ok(_) => {}
Err(error)
if received_generation
== Some(self.deep_search_generation.load(AtomicOrdering::Relaxed)) =>
{
self.report_error("Search interrupted", "SEARCH", error);
}
Err(_) => {}
}
}
fn deep_search_active(&self) -> bool {
self.deep_search_pending.is_some()
|| self.deep_search_rx_generation
== Some(self.deep_search_generation.load(AtomicOrdering::Relaxed))
}
fn visible_entries(&self) -> Vec<FileEntry> {
if let Some(results) = &self.deep_results {
return results.entries.clone();
}
filter_entries(&self.entries, &self.search_query)
}
fn navigate_to(&mut self, path: PathBuf, mode: HistoryMode) {
let path = expand_navigation_path(path, &self.current_dir);
if path == trash_files_dir() && !path.exists() {
if let Err(error) = std::fs::create_dir_all(&path) {
self.status = "Could not open Trash".to_string();
self.push_log(format!("TRASH OPEN FAILED // {error}"));
return;
}
}
if !path.exists() {
self.status = "Path not found".to_string();
self.push_log(format!("PATH NOT FOUND // {}", path.display()));
self.path_input = self.current_dir.display().to_string();
return;
}
if !path.is_dir() {
self.open_path(&path);
return;
}
let normalized = canonical_display_path(&path);
self.current_dir = normalized.clone();
self.path_input = normalized.display().to_string();
self.selected.clear();
self.selection_anchor = None;
self.selection_focus = None;
self.pin_drag_candidate = None;
self.pinned_drop_rect = None;
self.entry_drag_paths.clear();
if self.media_preview.open {
self.close_media_preview_plugin();
}
match mode {
HistoryMode::Push => self.push_history(normalized),
HistoryMode::Replace => {
if let Some(slot) = self.history.get_mut(self.history_cursor) {
*slot = normalized;
}
}
}
self.refresh_current_dir();
self.push_log(format!("LOCATION // {}", self.current_dir.display()));
}
fn push_history(&mut self, path: PathBuf) {
if self.history.get(self.history_cursor) == Some(&path) {
return;
}
self.history.truncate(self.history_cursor + 1);
self.history.push(path);
self.history_cursor = self.history.len().saturating_sub(1);
self.trim_history();
}
fn trim_history(&mut self) {
if self.history.len() <= HISTORY_LIMIT {
return;
}
let overflow = self.history.len() - HISTORY_LIMIT;
self.history.drain(0..overflow);
self.history_cursor = self.history_cursor.saturating_sub(overflow);
}
fn set_sort_key(&mut self, key: SortKey) {
if self.sort_key == key {
self.sort_reverse = !self.sort_reverse;
} else {
self.sort_key = key;
self.sort_reverse = false;
}
self.save_preferences();
self.refresh_current_dir();
}
fn go_back(&mut self) {
if self.history_cursor > 0 {
self.history_cursor -= 1;
if let Some(path) = self.history.get(self.history_cursor).cloned() {
self.navigate_to(path, HistoryMode::Replace);
}
}
}
fn go_forward(&mut self) {
if self.history_cursor + 1 < self.history.len() {
self.history_cursor += 1;
if let Some(path) = self.history.get(self.history_cursor).cloned() {
self.navigate_to(path, HistoryMode::Replace);
}
}
}
fn go_parent(&mut self) {
if let Some(parent) = self.current_dir.parent() {
self.navigate_to(parent.to_path_buf(), HistoryMode::Push);
}
}
fn open_entry(&mut self, entry: &FileEntry) {
if let Some(plugin_kind) =
preview_plugin_kind_for_entry(entry).filter(|kind| self.preview_plugin_enabled(*kind))
{
self.set_media_preview_entry(entry.clone(), plugin_kind, true);
return;
}
self.open_path(&entry.path);
}
fn open_path_external(&mut self, path: &Path) {
match launch_default(path) {
Ok(()) => {
self.status = "Opening with system app".to_string();
self.push_log(format!("OPEN EXTERNAL // {}", path.display()));
}
Err(error) => {
self.report_error("Could not open externally", "OPEN EXTERNAL", error);
}
}
}
fn open_path(&mut self, path: &Path) {
if path.is_dir() {
self.navigate_to(path.to_path_buf(), HistoryMode::Push);
return;
}
match launch_default(path) {
Ok(()) => {
self.status = "Opening file".to_string();
self.push_log(format!("OPEN // {}", path.display()));
}
Err(error) => {
self.report_error("Could not open file", "OPEN", error);
}
}
}
fn open_selection_externally(&mut self) {
let selected = self.selected_entries();
if selected.is_empty() {
self.status = "No selection".to_string();
return;
}
if selected.len() > OPEN_SELECTION_LIMIT {
self.status = format!("Open selection limit reached ({OPEN_SELECTION_LIMIT})");
self.push_log(format!(
"OPEN EXTERNAL BLOCKED // {} ITEMS // LIMIT {}",
selected.len(),
OPEN_SELECTION_LIMIT
));
return;
}
for entry in selected {
self.open_path_external(&entry.path);
}
}
fn open_selection(&mut self) {
let selected = self.selected_entries();
if selected.len() == 1 {
self.open_entry(&selected[0]);
return;
}
let openable_count = selected
.iter()
.filter(|entry| entry.path.is_file() || entry.kind == EntryKind::Symlink)
.count();
if openable_count > OPEN_SELECTION_LIMIT {
self.status = format!("Open selection limit reached ({OPEN_SELECTION_LIMIT})");
self.push_log(format!(
"OPEN BLOCKED // {} FILES // LIMIT {}",
openable_count, OPEN_SELECTION_LIMIT
));
return;
}
for entry in selected {
if entry.path.is_file() || entry.kind == EntryKind::Symlink {
self.open_path(&entry.path);
}
}
}
fn toggle_media_preview_plugin(&mut self) {
if self.media_preview.open {
self.close_media_preview_plugin();
} else {
self.open_media_preview_plugin();
}
}
fn open_media_preview_plugin(&mut self) {
let Some((entry, plugin_kind)) = self.selected_preview_candidate() else {
self.status = "Select an image, video, text, or code file to preview".to_string();
return;
};
if !self.preview_plugin_enabled(plugin_kind) {
self.status = format!(
"{} plugin is disabled",
self.preview_plugin_descriptor(plugin_kind).name
);
return;
}
self.set_media_preview_entry(entry, plugin_kind, true);
}
fn set_media_preview_entry(
&mut self,
entry: FileEntry,
plugin_kind: PreviewPluginKind,
log_plugin: bool,
) {
if log_plugin {
self.show_settings = false;
self.show_properties = false;
}
self.replace_media_preview_entry(entry, plugin_kind);
if let Some(entry) = self.media_preview.entry.clone() {
self.status = format!("Previewing {}", entry.name);
if log_plugin {
self.push_log(format!(
"PLUGIN // {} // {}",
self.preview_plugin_descriptor(plugin_kind).name,
entry.path.display()
));
}
}
}
fn replace_media_preview_entry(&mut self, entry: FileEntry, plugin_kind: PreviewPluginKind) {
let key = MediaPreviewKey::from_entry(&entry);
if self.media_preview.loaded_key.as_ref() != Some(&key) {
self.media_preview.image_texture = None;
self.media_preview.image_dimensions = None;
self.media_preview.loaded_key = None;
self.media_preview.loading_key = None;
self.media_preview.image_pending = None;
}
if self.media_preview.text_loaded_key.as_ref() != Some(&key) {
self.media_preview.text_document = None;
self.media_preview.text_loaded_key = None;
self.media_preview.text_loading_key = None;
self.media_preview.text_pending = None;
self.media_preview.text_view_buffer.clear();
self.media_preview.text_view_buffer_key = None;
self.media_preview.text_find_loaded_key = None;
}
if self.media_preview.loaded_key.as_ref() != Some(&key)
&& self.media_preview.text_loaded_key.as_ref() != Some(&key)
{
self.media_preview.error = None;
self.media_preview.pan = Vec2::ZERO;
}
self.media_preview.active_plugin = Some(plugin_kind);
self.media_preview.entry = Some(entry);
self.media_preview.open = true;
}
fn reconcile_media_preview_entry(&mut self) {
if !self.media_preview.open {
return;
}
let Some(path) = self
.media_preview
.entry
.as_ref()
.map(|entry| entry.path.clone())
else {
self.close_media_preview_plugin();
return;
};
let Ok(entry) = FileEntry::from_path(path) else {
self.close_media_preview_plugin();
return;
};
let Some(plugin_kind) = preview_plugin_kind_for_entry(&entry) else {
self.close_media_preview_plugin();
return;
};
if self.preview_plugin_enabled(plugin_kind) {
self.replace_media_preview_entry(entry, plugin_kind);
} else {
self.close_media_preview_plugin();
}
}
fn close_media_preview_plugin(&mut self) {
self.media_preview.open = false;
self.media_preview.entry = None;
self.media_preview.active_plugin = None;
self.media_preview.image_texture = None;
self.media_preview.image_dimensions = None;
self.media_preview.loaded_key = None;
self.media_preview.loading_key = None;
self.media_preview.image_pending = None;
self.media_preview.text_document = None;
self.media_preview.text_loaded_key = None;
self.media_preview.text_loading_key = None;
self.media_preview.text_pending = None;
self.media_preview.text_view_buffer.clear();
self.media_preview.text_view_buffer_key = None;
self.media_preview.text_find_query.clear();
self.media_preview.text_find_last_query.clear();
self.media_preview.text_find_loaded_key = None;
self.media_preview.text_find_match_count = 0;
self.media_preview.text_find_focus_requested = false;
self.media_preview.error = None;
self.media_preview.zoom = 1.0;
self.media_preview.text_scale = 1.0;
self.media_preview.pan = Vec2::ZERO;
}
fn step_media_preview_plugin(&mut self, direction: isize) {
let entries = self.previewable_entries();
if entries.is_empty() {
return;
}
let current_path = self
.media_preview
.entry
.as_ref()
.map(|entry| entry.path.as_path());
let current_index =
current_path.and_then(|path| entries.iter().position(|entry| entry.path == path));
let len = entries.len() as isize;
let next_index = match current_index {
Some(current_index) if entries.len() > 1 => {
(current_index as isize + direction).rem_euclid(len) as usize
}
Some(_) => return,
None if direction < 0 => entries.len() - 1,
None => 0,
};
let entry = entries[next_index].clone();
let Some(plugin_kind) = preview_plugin_kind_for_entry(&entry) else {
return;
};
self.set_single_selection(entry.path.clone());
self.set_media_preview_entry(entry, plugin_kind, false);
}
fn adjust_media_preview_zoom(&mut self, amount: f32) {
self.media_preview.zoom = (self.media_preview.zoom + amount).clamp(0.5, 2.5);
if self.media_preview.zoom <= 1.0 {
self.media_preview.pan = Vec2::ZERO;
}
}
fn adjust_text_preview_scale(&mut self, amount: f32) {
self.media_preview.text_scale = (self.media_preview.text_scale + amount).clamp(0.8, 1.7);
}
fn previewable_selection(&self) -> Option<FileEntry> {
self.selected_entries()
.into_iter()
.find(|entry| self.preview_plugin_kind_enabled_for_entry(entry))
}
fn previewable_entries(&self) -> Vec<FileEntry> {
self.visible_entries()
.into_iter()
.filter(|entry| self.preview_plugin_kind_enabled_for_entry(entry))
.collect()
}
fn selected_preview_candidate(&self) -> Option<(FileEntry, PreviewPluginKind)> {
self.selected_entries().into_iter().find_map(|entry| {
let kind = preview_plugin_kind_for_entry(&entry)?;
self.preview_plugin_enabled(kind).then_some((entry, kind))
})
}
fn preview_plugin_kind_enabled_for_entry(&self, entry: &FileEntry) -> bool {
preview_plugin_kind_for_entry(entry)
.map(|kind| self.preview_plugin_enabled(kind))
.unwrap_or(false)
}
fn preview_plugin_enabled(&self, kind: PreviewPluginKind) -> bool {
match kind {
PreviewPluginKind::Media => self.media_preview_plugin_enabled,
PreviewPluginKind::Text => self.text_preview_plugin_enabled,
}
}
fn preview_plugin_descriptor(&self, kind: PreviewPluginKind) -> PluginDescriptor {
match kind {
PreviewPluginKind::Media => self.plugins.media_preview(),
PreviewPluginKind::Text => self.plugins.text_preview(),
}
}
fn selected_entries(&self) -> Vec<FileEntry> {
self.visible_entries()
.into_iter()
.filter(|entry| self.selected.contains(&entry.path))
.collect()
}
fn selected_paths(&self) -> Vec<PathBuf> {
self.selected_entries()
.into_iter()
.map(|entry| entry.path)
.collect()
}
fn selected_paths_for_operation(&mut self) -> Option<Vec<PathBuf>> {
let paths = self.selected_paths();
self.check_operation_sources(paths)
}
fn copy_selection_text(&mut self, ctx: &egui::Context, names_only: bool) {
let entries = self.selected_entries();
if entries.is_empty() {
return;
}
let text = entries
.iter()
.map(|entry| {
if names_only {
entry.name.clone()
} else {
entry.path.display().to_string()
}
})
.collect::<Vec<_>>()
.join("\n");
ctx.copy_text(text);
let label = if names_only { "names" } else { "paths" };
self.status = format!("Copied {} {label}", entries.len());
self.push_log(format!("COPY TEXT // {} {}", entries.len(), label));
}
fn show_entry_in_folder(&mut self, entry: &FileEntry) {
let Some(parent) = entry.path.parent().map(Path::to_path_buf) else {
self.status = "Item has no containing folder".to_string();
return;
};
if !parent.is_dir() {
self.status = "Containing folder is not available".to_string();
self.push_log(format!("SHOW IN FOLDER FAILED // {}", entry.path.display()));
return;
}
let target = entry.path.clone();
let name = entry.name.clone();
self.navigate_to(parent, HistoryMode::Push);
if self.entries.iter().any(|entry| entry.path == target) {
self.set_single_selection(target.clone());
}
self.status = format!("Showing {name}");
self.push_log(format!("SHOW IN FOLDER // {}", target.display()));
}
fn check_operation_sources(&mut self, paths: Vec<PathBuf>) -> Option<Vec<PathBuf>> {
if paths.len() > OPERATION_SOURCE_LIMIT {
self.status = format!("Operation source limit reached ({OPERATION_SOURCE_LIMIT})");
self.push_log(format!(
"OPERATION BLOCKED // {} SOURCES // LIMIT {}",
paths.len(),
OPERATION_SOURCE_LIMIT
));
return None;
}
Some(paths)
}
fn primary_selection(&self) -> Option<FileEntry> {
self.selected_entries().into_iter().next()
}
fn set_single_selection(&mut self, path: PathBuf) {
self.selected.clear();
self.selected.insert(path.clone());
self.selection_anchor = Some(path.clone());
self.selection_focus = Some(path);
}
fn toggle_selection(&mut self, path: PathBuf) {
if !self.selected.remove(&path) {
self.selected.insert(path.clone());
self.selection_anchor = Some(path.clone());
self.selection_focus = Some(path);
} else if self.selection_anchor.as_ref() == Some(&path) {
self.selection_anchor = self.selected.iter().next().cloned();
self.selection_focus = self.selection_anchor.clone();
} else if self.selection_focus.as_ref() == Some(&path) {
self.selection_focus = self.selection_anchor.clone();
}
}
fn clear_selection(&mut self) {
let count = self.selected.len();
self.reset_selection();
if count > 0 {
self.status = "Selection cleared".to_string();
}
}
fn reset_selection(&mut self) {
self.selected.clear();
self.selection_anchor = None;
self.selection_focus = None;
}
fn focus_operation_results(&mut self, paths: &[PathBuf]) {
let paths = operation_results_in_directory(paths, &self.current_dir);
if paths.is_empty() {
return;
}
self.selection_anchor = paths.first().cloned();
self.selection_focus = paths.last().cloned();
self.selected = paths.into_iter().collect();
}
fn select_entry_with_modifiers(&mut self, path: PathBuf, modifiers: egui::Modifiers) {
let toggle = modifiers.command || modifiers.ctrl;
if modifiers.shift {
self.select_range_to(path, toggle);
} else if toggle {
self.toggle_selection(path);
} else {
self.set_single_selection(path);
}
}
fn select_range_to(&mut self, path: PathBuf, additive: bool) {
let entries = self.visible_entries();
let Some(target_index) = entries.iter().position(|entry| entry.path == path) else {
self.set_single_selection(path);
return;
};
let anchor_path = self
.selection_anchor
.clone()
.filter(|anchor| entries.iter().any(|entry| entry.path == *anchor))
.or_else(|| {
entries
.iter()
.find(|entry| self.selected.contains(&entry.path))
.map(|entry| entry.path.clone())
})
.unwrap_or_else(|| path.clone());
let anchor_index = entries
.iter()
.position(|entry| entry.path == anchor_path)
.unwrap_or(target_index);
if !additive {
self.selected.clear();
}
let start = anchor_index.min(target_index);
let end = anchor_index.max(target_index);
for entry in &entries[start..=end] {
self.selected.insert(entry.path.clone());
}
self.selection_anchor = Some(anchor_path);
self.selection_focus = Some(path);
self.status = format!("{} selected", self.selected.len());
}
fn step_selection(&mut self, direction: isize, extend: bool) {
let entries = self.visible_entries();
if entries.is_empty() {
return;
}
let current_index = self
.selection_focus
.as_ref()
.and_then(|focus| entries.iter().position(|entry| entry.path == *focus))
.or_else(|| {
entries
.iter()
.position(|entry| self.selected.contains(&entry.path))
});
let Some(next_index) = stepped_selection_index(entries.len(), current_index, direction)
else {
return;
};
let path = entries[next_index].path.clone();
if extend {
if self.selection_anchor.is_none() {
self.selection_anchor =
Some(entries[current_index.unwrap_or(next_index)].path.clone());
}
self.select_range_to(path, false);
} else {
self.set_single_selection(path);
}
}
fn jump_selection(&mut self, to_end: bool, extend: bool) {
let entries = self.visible_entries();
let target = if to_end {
entries.last()
} else {
entries.first()
};
let Some(target) = target else {
return;
};
let path = target.path.clone();
if extend {
if self.selection_anchor.is_none() {
self.selection_anchor = self
.selection_focus
.clone()
.or_else(|| self.selected.iter().next().cloned())
.or_else(|| entries.first().map(|entry| entry.path.clone()));
}
self.select_range_to(path, false);
} else {
self.set_single_selection(path);
}
}
fn select_all_visible(&mut self) {
let entries = self.visible_entries();
self.selection_anchor = entries.first().map(|entry| entry.path.clone());
self.selection_focus = entries.last().map(|entry| entry.path.clone());
self.selected = entries.into_iter().map(|entry| entry.path).collect();
self.status = format!("{} selected", self.selected.len());
}
fn invert_visible_selection(&mut self) {
let entries = self.visible_entries();
self.selected = entries
.iter()
.filter(|entry| !self.selected.contains(&entry.path))
.map(|entry| entry.path.clone())
.collect();
self.selection_anchor = entries
.iter()
.find(|entry| self.selected.contains(&entry.path))
.map(|entry| entry.path.clone());
self.selection_focus = entries
.iter()
.rev()
.find(|entry| self.selected.contains(&entry.path))
.map(|entry| entry.path.clone());
self.status = format!("{} selected", self.selected.len());
}
fn select_visible_kind(&mut self, directories: bool) {
let entries = self.visible_entries();
self.selected = entries
.iter()
.filter(|entry| (entry.kind == EntryKind::Directory) == directories)
.map(|entry| entry.path.clone())
.collect();
self.selection_anchor = entries
.iter()
.find(|entry| self.selected.contains(&entry.path))
.map(|entry| entry.path.clone());
self.selection_focus = entries
.iter()
.rev()
.find(|entry| self.selected.contains(&entry.path))
.map(|entry| entry.path.clone());
self.status = if directories {
format!("{} folders selected", self.selected.len())
} else {
format!("{} files selected", self.selected.len())
};
}
fn is_bookmarked(&self, path: &Path) -> bool {
let normalized = canonical_display_path(path);
self.bookmarks
.iter()
.any(|bookmark| canonical_display_path(bookmark) == normalized)
}
fn add_bookmark(&mut self, path: PathBuf) {
let normalized = canonical_display_path(&path);
if !normalized.is_dir() {
self.status = "Only folders can be pinned".to_string();
return;
}
if self.is_bookmarked(&normalized) {
self.status = "Folder is already pinned".to_string();
return;
}
if self.bookmarks.len() >= BOOKMARK_LIMIT {
self.status = format!("Pinned folder limit reached ({BOOKMARK_LIMIT})");
return;
}
self.bookmarks.push(normalized.clone());
self.save_bookmarks();
self.status = "Folder pinned".to_string();
self.push_log(format!("PIN // {}", normalized.display()));
}
fn remove_bookmark(&mut self, path: &Path) {
let normalized = canonical_display_path(path);
let previous_len = self.bookmarks.len();
self.bookmarks
.retain(|bookmark| canonical_display_path(bookmark) != normalized);
if self.bookmarks.len() == previous_len {
self.status = "Pinned folder not found".to_string();
return;
}
self.save_bookmarks();
self.status = "Folder unpinned".to_string();
self.push_log(format!("UNPIN // {}", normalized.display()));
}
fn move_bookmark(&mut self, path: &Path, direction: isize) {
let Some((from, to)) = move_bookmark_path(&mut self.bookmarks, path, direction) else {
self.status = "Pinned folder cannot move further".to_string();
return;
};
self.save_bookmarks();
self.status = if to < from {
"Pinned folder moved up".to_string()
} else {
"Pinned folder moved down".to_string()
};
self.push_log(format!(
"PIN ORDER // {} // {} -> {}",
path.display(),
from + 1,
to + 1
));
}
fn pin_selected_folders(&mut self) {
let folders = self
.selected_entries()
.into_iter()
.filter(|entry| entry.kind == EntryKind::Directory)
.map(|entry| entry.path)
.collect::<Vec<_>>();
if folders.is_empty() {
self.status = "Select folders to pin".to_string();
return;
}
let mut pinned = 0;
for folder in folders {
let previous_len = self.bookmarks.len();
self.add_bookmark(folder);
if self.bookmarks.len() > previous_len {
pinned += 1;
}
}
if pinned > 0 {
self.status = format!("Pinned {pinned} folders");
}
}
fn observe_entry_drag_source(&mut self, response: &egui::Response, entry: &FileEntry) {
if response.dragged() {
let paths = if self.selected.contains(&entry.path) {
self.selected_paths()
} else {
vec![entry.path.clone()]
};
self.pin_drag_candidate = paths.iter().find(|path| path.is_dir()).cloned();
self.entry_drag_paths = paths;
}
}
fn trash_dragged_entries(&mut self) {
if self.operation_rx.is_some() {
self.status = "Operation in progress".to_string();
return;
}
let paths = std::mem::take(&mut self.entry_drag_paths);
self.pin_drag_candidate = None;
let Some(paths) = self.check_operation_sources(paths) else {
return;
};
if paths.is_empty() {
return;
}
self.start_operation(OperationRequest::Trash { sources: paths }, false);
}
fn register_pinned_drop_rect(&mut self, rect: Rect) {
self.pinned_drop_rect = Some(if let Some(existing) = self.pinned_drop_rect {
Rect::from_min_max(
Pos2::new(
existing.min.x.min(rect.min.x),
existing.min.y.min(rect.min.y),
),
Pos2::new(
existing.max.x.max(rect.max.x),
existing.max.y.max(rect.max.y),
),
)
} else {
rect
});
}
fn dropped_on_pinned_folders(&self, ctx: &egui::Context) -> bool {
let Some(rect) = self.pinned_drop_rect else {
return false;
};
ctx.input(|input| {
input
.pointer
.interact_pos()
.or_else(|| input.pointer.hover_pos())
.is_some_and(|pos| rect.contains(pos))
})
}
fn pin_dropped_folders(&mut self, paths: Vec<PathBuf>) {
let mut pinned = 0;
let mut folders = 0;
let mut ignored = 0;
for path in paths {
if path.is_dir() {
folders += 1;
let previous_len = self.bookmarks.len();
self.add_bookmark(path);
if self.bookmarks.len() > previous_len {
pinned += 1;
}
} else {
ignored += 1;
}
}
if pinned > 0 {
self.status = if ignored > 0 {
format!("Pinned {pinned} folders · ignored {ignored} files")
} else {
format!("Pinned {pinned} folders")
};
} else if folders == 0 {
self.status = "Drop folders onto Pinned Folders to pin them".to_string();
if ignored > 0 {
self.push_log(format!("PIN DROP IGNORED // {ignored} NON-FOLDERS"));
}
}
}
fn pinned_drop_target(&mut self, ui: &mut egui::Ui, compact: bool) {
let external_hover = ui.ctx().input(|input| !input.raw.hovered_files.is_empty());
let path = self.pin_drag_candidate.clone();
if path.is_none() && !external_hover {
return;
}
if compact {
let (rect, response) = ui.allocate_exact_size(Vec2::new(34.0, 32.0), Sense::hover());
self.register_pinned_drop_rect(rect);
let painter = ui.painter_at(rect);
let active = response.hovered();
let row = rect.shrink2(egui::vec2(2.0, 1.0));
painter.rect_filled(
row,
Rounding::same(theme_rounding(self.palette) + 7.0),
if active {
self.palette.selected_fill()
} else {
self.palette.elevated_fill()
},
);
painter.rect_stroke(
row,
Rounding::same(theme_rounding(self.palette) + 7.0),
Stroke::new(1.1, self.palette.accent(0.0)),
);
draw_chrome_icon(
&painter,
Rect::from_center_size(row.center(), Vec2::splat(15.0)),
ChromeIcon::Pin,
self.palette.accent(0.0),
);
if active && ui.input(|input| input.pointer.any_released()) {
if let Some(path) = path {
self.add_bookmark(path);
}
self.pin_drag_candidate = None;
}
return;
}
let (rect, response) =
ui.allocate_exact_size(Vec2::new(ui.available_width(), 42.0), Sense::hover());
self.register_pinned_drop_rect(rect);
let painter = ui.painter_at(rect);
let active = response.hovered();
let row = rect.shrink2(egui::vec2(2.0, 2.0));
painter.rect_filled(
row,
Rounding::same(theme_rounding(self.palette) + 7.0),
if active {
self.palette.selected_fill()
} else {
self.palette.elevated_fill()
},
);
painter.rect_stroke(
row,
Rounding::same(theme_rounding(self.palette) + 7.0),
Stroke::new(1.1, self.palette.accent(0.0)),
);
let icon_rect =
Rect::from_center_size(row.left_center() + egui::vec2(18.0, 0.0), Vec2::splat(18.0));
draw_chrome_icon(
&painter,
icon_rect,
ChromeIcon::Pin,
self.palette.accent(0.0),
);
let title = match &path {
Some(path) if self.is_bookmarked(path) => "Already pinned",
Some(_) if active => "Release to pin folder",
Some(_) => "Drop to pin folder",
None if active => "Release to pin folders",
None => "Drop folders to pin",
};
painter.text(
row.left_center() + egui::vec2(38.0, -6.0),
Align2::LEFT_CENTER,
title,
FontId::proportional(12.0),
self.palette.text(),
);
painter.text(
row.left_center() + egui::vec2(38.0, 9.0),
Align2::LEFT_CENTER,
path.as_ref()
.map(|path| truncate_middle(&bookmark_label(path), 22))
.unwrap_or_else(|| "External folders".to_string()),
FontId::proportional(10.0),
self.palette.muted(),
);
if active && ui.input(|input| input.pointer.any_released()) {
if let Some(path) = path {
self.add_bookmark(path);
}
self.pin_drag_candidate = None;
}
}
fn save_bookmarks(&self) {
if let Err(error) = write_bookmarks(&self.bookmarks) {
eprintln!("guth: could not save bookmarks: {error}");
}
}
fn ensure_background_texture(&mut self, ctx: &egui::Context) {
let trimmed = self.background_image_path.trim();
if trimmed.is_empty() {
self.background_texture = None;
self.background_loaded_path = None;
self.background_error = None;
return;
}
let path = canonical_display_path(&expand_tilde(trimmed));
if self.background_loaded_path.as_ref() == Some(&path) {
return;
}
self.background_loaded_path = Some(path.clone());
match load_background_image(&path) {
Ok(image) => {
self.background_texture = Some(ctx.load_texture(
format!("background:{}", path.display()),
image,
egui::TextureOptions::LINEAR,
));
self.background_error = None;
}
Err(error) => {
self.background_texture = None;
self.background_error = Some(error);
}
}
}
fn bookmark_places(&self) -> Vec<Place> {
self.bookmarks
.iter()
.take(BOOKMARK_LIMIT)
.map(|path| Place {
label: bookmark_label(path),
path: path.clone(),
detail: if path.is_dir() {
"Pinned".to_string()
} else {
"Missing".to_string()
},
})
.collect()
}
fn copy_selection(&mut self, mode: ClipboardMode) {
let Some(paths) = self.selected_paths_for_operation() else {
return;
};
if paths.is_empty() {
self.status = "No selection".to_string();
return;
}
let count = paths.len();
self.clipboard = Some(ClipboardPayload { mode, paths });
self.status = match mode {
ClipboardMode::Copy => format!("Copied {count} items"),
ClipboardMode::Cut => format!("Cut {count} items"),
};
}
fn paste_clipboard(&mut self) {
let Some(clipboard) = self.clipboard.clone() else {
self.status = "Clipboard is empty".to_string();
return;
};
if clipboard.paths.is_empty() {
self.status = "Clipboard is empty".to_string();
return;
}
if target_inside_any_source(&clipboard.paths, &self.current_dir) {
self.status = "Paste blocked".to_string();
self.push_log("PASTE BLOCKED // TARGET INSIDE SOURCE".to_string());
return;
}
if clipboard.mode == ClipboardMode::Cut
&& clipboard
.paths
.iter()
.all(|path| path.parent() == Some(self.current_dir.as_path()))
{
self.status = "Nothing to move".to_string();
return;
}
let request = match clipboard.mode {
ClipboardMode::Copy => OperationRequest::Copy {
sources: clipboard.paths,
target_dir: self.current_dir.clone(),
},
ClipboardMode::Cut => {
self.clipboard = None;
OperationRequest::Move {
sources: clipboard.paths,
target_dir: self.current_dir.clone(),
}
}
};
self.start_operation(request, false);
}
fn duplicate_selection(&mut self) {
let Some(sources) = self.selected_paths_for_operation() else {
return;
};
if sources.is_empty() {
self.status = "No selection".to_string();
return;
}
self.start_operation(OperationRequest::Duplicate { sources }, false);
}
fn trash_selection(&mut self) {
let Some(sources) = self.selected_paths_for_operation() else {
return;
};
if sources.is_empty() {
self.status = "No selection".to_string();
return;
}
self.start_operation(OperationRequest::Trash { sources }, false);
}
fn request_delete_selection(&mut self, permanent: bool) {
let Some(paths) = self.selected_paths_for_operation() else {
return;
};
if paths.is_empty() {
self.status = "No selection".to_string();
return;
}
self.dialogs.delete_confirm = Some(DeleteIntent { paths, permanent });
}
fn start_operation(&mut self, request: OperationRequest, undo: bool) {
if self.operation_rx.is_some() {
self.status = "Operation in progress".to_string();
return;
}
self.status = format!("{} started", title_case(request.title()));
self.operation_title = Some(request.title().to_string());
self.operation_progress = None;
self.operation_is_undo = undo;
self.operation_rx = Some(start_operation(request));
}
fn undo_last(&mut self) {
if self.operation_rx.is_some() {
self.status = "Operation in progress".to_string();
return;
}
let Some(action) = self.undo_stack.pop() else {
self.status = "Nothing to undo".to_string();
return;
};
self.push_log(format!("UNDO // {}", action.label()));
self.start_operation(OperationRequest::Undo(action), true);
}
fn drain_operation_events(&mut self) {
let Some(rx) = self.operation_rx.take() else {
return;
};
let mut keep_rx = true;
loop {
let event = match rx.try_recv() {
Ok(event) => event,
Err(TryRecvError::Empty) => break,
Err(TryRecvError::Disconnected) => {
if keep_rx {
self.handle_operation_disconnect();
keep_rx = false;
}
break;
}
};
match event {
OperationEvent::Started {
title,
total_items,
total_bytes,
} => {
self.operation_title = Some(title.clone());
self.status = format!(
"{} · {} items · {}",
title_case(&title),
total_items,
human_size(total_bytes)
);
}
OperationEvent::Progress(progress) => {
self.status = pretty_status(&operation_detail(&progress));
self.operation_progress = Some(progress);
}
OperationEvent::Finished(result) => {
keep_rx = false;
self.operation_title = None;
self.operation_progress = None;
let was_undo = self.operation_is_undo;
self.operation_is_undo = false;
match result {
Ok(outcome) => {
self.handle_operation_outcome(outcome, was_undo);
}
Err(error) => {
self.status = if was_undo {
"Undo failed".to_string()
} else {
"Operation failed".to_string()
};
self.push_log(format!("OPERATION FAILED // {error}"));
self.dialogs.error = Some(error);
}
}
let operation_status = self.status.clone();
if self.refresh_current_dir() {
self.status = operation_status;
}
}
}
}
if keep_rx {
self.operation_rx = Some(rx);
}
}
fn handle_operation_disconnect(&mut self) {
let was_undo = self.operation_is_undo;
self.operation_is_undo = false;
self.operation_title = None;
self.operation_progress = None;
let message = "Operation worker stopped before reporting completion".to_string();
self.status = if was_undo {
"Undo interrupted".to_string()
} else {
"Operation interrupted".to_string()
};
self.push_log(format!("OPERATION INTERRUPTED // {message}"));
self.dialogs.error = Some(message);
let operation_status = self.status.clone();
if self.refresh_current_dir() {
self.status = operation_status;
}
}
fn handle_operation_outcome(&mut self, outcome: crate::ops::OperationOutcome, was_undo: bool) {
self.status = pretty_status(&outcome.summary);
self.push_log(outcome.summary);
if was_undo {
return;
}
match outcome.action {
CompletedAction::Copied(paths) => {
self.focus_operation_results(&paths);
self.push_undo(UndoAction::RemoveCreated {
paths,
label: "UNDO COPY".to_string(),
});
}
CompletedAction::Duplicated(paths) => {
self.focus_operation_results(&paths);
self.push_undo(UndoAction::RemoveCreated {
paths,
label: "UNDO DUPLICATE".to_string(),
});
}
CompletedAction::Moved(moves) => {
let destinations = moves.iter().map(|item| item.to.clone()).collect::<Vec<_>>();
self.focus_operation_results(&destinations);
self.push_undo(UndoAction::MoveBack {
moves,
label: "UNDO MOVE".to_string(),
});
}
CompletedAction::Trashed(records) => {
self.reset_selection();
self.push_undo(UndoAction::RestoreTrash {
records,
label: "UNDO TRASH".to_string(),
});
}
CompletedAction::Deleted(_) => self.reset_selection(),
CompletedAction::UndoApplied => {}
}
}
fn push_undo(&mut self, action: UndoAction) {
self.undo_stack.push(action);
if self.undo_stack.len() > UNDO_LIMIT {
let overflow = self.undo_stack.len() - UNDO_LIMIT;
self.undo_stack.drain(0..overflow);
}
}
fn push_log(&mut self, line: String) {
self.logs.push(line.to_ascii_uppercase());
if self.logs.len() > LOG_LIMIT {
let overflow = self.logs.len() - LOG_LIMIT;
self.logs.drain(0..overflow);
}
}
fn report_error(&mut self, status: &str, context: &str, error: String) {
self.status = status.to_string();
self.push_log(format!("{context} FAILED // {error}"));
self.dialogs.error = Some(error);
}
fn save_preferences(&self) {
if let Err(error) = write_preferences(Preferences {
view_mode: self.view_mode,
show_hidden: self.show_hidden,
recursive_search: self.recursive_search,
sort_key: self.sort_key,
sort_reverse: self.sort_reverse,
folders_first: self.folders_first,
palette: self.palette,
motion_enabled: self.motion_enabled,
hover_effects_enabled: self.hover_effects_enabled,
theme_opacity: self.theme_opacity,
background_image_path: self.background_image_path.clone(),
background_image_opacity: self.background_image_opacity,
sidebar_collapsed: self.sidebar_collapsed,
media_preview_plugin_enabled: self.media_preview_plugin_enabled,
text_preview_plugin_enabled: self.text_preview_plugin_enabled,
}) {
eprintln!("guth: could not save preferences: {error}");
}
}
fn handle_shortcuts(&mut self, ctx: &egui::Context) {
let wants_keyboard = ctx.wants_keyboard_input();
let shortcuts = ctx.input(|input| {
let command = input.modifiers.command || input.modifiers.ctrl;
let unmodified = !input.modifiers.alt
&& !input.modifiers.command
&& !input.modifiers.ctrl
&& !input.modifiers.shift;
ShortcutState {
refresh: input.key_pressed(egui::Key::F5)
|| (command && input.key_pressed(egui::Key::R)),
back: input.modifiers.alt && input.key_pressed(egui::Key::ArrowLeft),
forward: input.modifiers.alt && input.key_pressed(egui::Key::ArrowRight),
parent: input.modifiers.alt && input.key_pressed(egui::Key::ArrowUp),
open: !input.modifiers.alt
&& !input.modifiers.shift
&& input.key_pressed(egui::Key::Enter),
open_external: !input.modifiers.alt
&& input.modifiers.shift
&& input.key_pressed(egui::Key::Enter),
select_all: command && input.key_pressed(egui::Key::A),
copy: command && input.key_pressed(egui::Key::C),
duplicate: command && input.key_pressed(egui::Key::D),
search: command && input.key_pressed(egui::Key::F),
cut: command && input.key_pressed(egui::Key::X),
paste: command && input.key_pressed(egui::Key::V),
undo: command && input.key_pressed(egui::Key::Z),
new_folder: command && input.key_pressed(egui::Key::N),
hidden: command && input.key_pressed(egui::Key::H),
location: command && input.key_pressed(egui::Key::L),
cancel_location: input.key_pressed(egui::Key::Escape),
settings: command && input.key_pressed(egui::Key::Comma),
properties: (command && input.key_pressed(egui::Key::I))
|| (input.modifiers.alt && input.key_pressed(egui::Key::Enter)),
media_preview: unmodified && input.key_pressed(egui::Key::Space),
media_preview_next: unmodified && input.key_pressed(egui::Key::ArrowRight),
media_preview_previous: unmodified && input.key_pressed(egui::Key::ArrowLeft),
media_preview_zoom_in: unmodified && input.key_pressed(egui::Key::ArrowUp),
media_preview_zoom_out: unmodified && input.key_pressed(egui::Key::ArrowDown),
selection_previous: !input.modifiers.alt && input.key_pressed(egui::Key::ArrowUp),
selection_next: !input.modifiers.alt && input.key_pressed(egui::Key::ArrowDown),
selection_first: !input.modifiers.alt && input.key_pressed(egui::Key::Home),
selection_last: !input.modifiers.alt && input.key_pressed(egui::Key::End),
selection_extend: input.modifiers.shift,
rename: input.key_pressed(egui::Key::F2),
delete: input.key_pressed(egui::Key::Delete),
permanent_delete: input.modifiers.shift && input.key_pressed(egui::Key::Delete),
}
});
if self.media_preview.open {
self.handle_preview_shortcuts(&shortcuts, wants_keyboard);
return;
}
if shortcuts.refresh {
self.refresh_current_dir();
}
if shortcuts.back {
self.go_back();
}
if shortcuts.forward {
self.go_forward();
}
if shortcuts.parent
|| (!wants_keyboard && ctx.input(|input| input.key_pressed(egui::Key::Backspace)))
{
self.go_parent();
}
if shortcuts.location {
self.path_input = self.current_dir.display().to_string();
self.location_entry_open = true;
}
if shortcuts.search {
self.location_entry_open = false;
self.search_focus_requested = true;
}
if self.location_entry_open && shortcuts.cancel_location {
self.path_input = self.current_dir.display().to_string();
self.location_entry_open = false;
return;
}
if wants_keyboard {
return;
}
if shortcuts.cancel_location && !self.selected.is_empty() {
self.clear_selection();
return;
}
if shortcuts.open {
self.open_selection();
}
if shortcuts.open_external {
self.open_selection_externally();
}
if shortcuts.media_preview {
self.toggle_media_preview_plugin();
}
if shortcuts.selection_previous {
self.step_selection(-1, shortcuts.selection_extend);
return;
}
if shortcuts.selection_next {
self.step_selection(1, shortcuts.selection_extend);
return;
}
if shortcuts.selection_first {
self.jump_selection(false, shortcuts.selection_extend);
return;
}
if shortcuts.selection_last {
self.jump_selection(true, shortcuts.selection_extend);
return;
}
if shortcuts.select_all {
self.select_all_visible();
}
if shortcuts.copy {
self.copy_selection(ClipboardMode::Copy);
}
if shortcuts.duplicate {
self.duplicate_selection();
}
if shortcuts.cut {
self.copy_selection(ClipboardMode::Cut);
}
if shortcuts.paste {
self.paste_clipboard();
}
if shortcuts.undo {
self.undo_last();
}
if shortcuts.new_folder {
self.open_new_folder_dialog();
}
if shortcuts.hidden {
self.show_hidden = !self.show_hidden;
self.save_preferences();
self.refresh_current_dir();
}
if shortcuts.properties {
self.show_properties = !self.show_properties;
}
if shortcuts.settings {
self.show_settings = !self.show_settings;
}
if shortcuts.rename {
self.open_rename_dialog();
}
if shortcuts.permanent_delete {
self.request_delete_selection(true);
} else if shortcuts.delete {
self.trash_selection();
}
}
fn handle_preview_shortcuts(&mut self, shortcuts: &ShortcutState, wants_keyboard: bool) {
let text_preview = self.media_preview.active_plugin == Some(PreviewPluginKind::Text);
if shortcuts.search && text_preview {
self.media_preview.text_find_focus_requested = true;
return;
}
if shortcuts.cancel_location {
if text_preview && !self.media_preview.text_find_query.is_empty() {
self.media_preview.text_find_query.clear();
self.media_preview.text_find_loaded_key = None;
self.media_preview.text_find_focus_requested = false;
} else {
self.close_media_preview_plugin();
}
return;
}
if shortcuts.properties {
return;
}
if wants_keyboard {
return;
}
if shortcuts.open_external {
if let Some(entry) = self.media_preview.entry.clone() {
self.open_path_external(&entry.path);
}
return;
}
if shortcuts.media_preview {
self.close_media_preview_plugin();
return;
}
if shortcuts.media_preview_previous {
self.step_media_preview_plugin(-1);
return;
}
if shortcuts.media_preview_next {
self.step_media_preview_plugin(1);
return;
}
if shortcuts.media_preview_zoom_in {
if text_preview {
self.adjust_text_preview_scale(0.10);
} else {
self.adjust_media_preview_zoom(0.10);
}
return;
}
if shortcuts.media_preview_zoom_out {
if text_preview {
self.adjust_text_preview_scale(-0.10);
} else {
self.adjust_media_preview_zoom(-0.10);
}
}
}
fn handle_dropped_files(&mut self, ctx: &egui::Context) {
let dropped: Vec<PathBuf> = ctx.input(|input| {
input
.raw
.dropped_files
.iter()
.filter_map(|file| file.path.clone())
.take(OPERATION_SOURCE_LIMIT + 1)
.collect()
});
if dropped.is_empty() || self.operation_rx.is_some() {
return;
}
let Some(dropped) = self.check_operation_sources(dropped) else {
return;
};
if self.dropped_on_pinned_folders(ctx) {
self.pin_dropped_folders(dropped);
return;
}
if target_inside_any_source(&dropped, &self.current_dir) {
self.push_log("DROP BLOCKED // TARGET INSIDE SOURCE".to_string());
return;
}
self.start_operation(
OperationRequest::Copy {
sources: dropped,
target_dir: self.current_dir.clone(),
},
false,
);
}
fn drag_feedback_overlay(&self, ctx: &egui::Context) {
let external_count = ctx.input(|input| input.raw.hovered_files.len());
let internal_count = self.entry_drag_paths.len();
if external_count == 0 && internal_count == 0 {
return;
}
let Some(pointer) = ctx.input(|input| input.pointer.hover_pos()) else {
return;
};
let over_pins = self.dropped_on_pinned_folders(ctx);
let can_pin = self.pin_drag_candidate.is_some() || external_count > 0;
let count = internal_count.max(external_count);
let count_label = if count == 1 {
"1 item".to_string()
} else {
format!("{count} items")
};
let (icon, title, detail) = if over_pins && can_pin {
(
ChromeIcon::Pin,
"Release to Pin",
if external_count > 0 {
"Folders will be pinned"
} else {
"Folder will be pinned"
},
)
} else if external_count > 0 {
(
ChromeIcon::Copy,
"Drop to Copy",
"Drop on Pinned Folders to pin folders",
)
} else {
(
ChromeIcon::List,
"Dragging Selection",
"Drop on Trash to move to trash",
)
};
egui::Area::new(egui::Id::new("drag-feedback-overlay"))
.order(egui::Order::Tooltip)
.fixed_pos(pointer + egui::vec2(18.0, 18.0))
.interactable(false)
.show(ctx, |ui| {
egui::Frame::none()
.fill(themed_alpha(
self.palette.elevated_fill(),
self.theme_opacity,
))
.stroke(Stroke::new(1.0, self.palette.accent(0.0)))
.rounding(Rounding::same(theme_rounding(self.palette) + 10.0))
.inner_margin(egui::Margin::symmetric(10.0, 8.0))
.show(ui, |ui| {
ui.horizontal(|ui| {
inline_chrome_icon(ui, self.palette, icon, self.palette.accent(0.0));
ui.vertical(|ui| {
ui.label(
RichText::new(format!("{title} · {count_label}"))
.size(12.0)
.strong()
.color(self.palette.text()),
);
ui.label(
RichText::new(detail).size(10.0).color(self.palette.muted()),
);
});
});
});
});
}
fn open_new_folder_dialog(&mut self) {
self.dialogs.new_folder_open = true;
self.dialogs.new_folder_name = "New Folder".to_string();
self.dialogs.new_folder_focus_requested = true;
}
fn open_new_file_dialog(&mut self) {
self.dialogs.new_file_open = true;
self.dialogs.new_file_name = "New File.txt".to_string();
self.dialogs.new_file_focus_requested = true;
}
fn open_rename_dialog(&mut self) {
let selected = self.selected_entries();
if selected.len() != 1 {
self.status = "Select one item to rename".to_string();
return;
}
let entry = selected[0].clone();
self.dialogs.rename_open = true;
self.dialogs.rename_path = Some(entry.path);
self.dialogs.rename_name = entry.name;
self.dialogs.rename_focus_requested = true;
}
}
#[derive(Default)]
struct ShortcutState {
refresh: bool,
back: bool,
forward: bool,
parent: bool,
open: bool,
open_external: bool,
select_all: bool,
copy: bool,
duplicate: bool,
search: bool,
cut: bool,
paste: bool,
undo: bool,
new_folder: bool,
hidden: bool,
location: bool,
cancel_location: bool,
settings: bool,
properties: bool,
media_preview: bool,
media_preview_next: bool,
media_preview_previous: bool,
media_preview_zoom_in: bool,
media_preview_zoom_out: bool,
selection_previous: bool,
selection_next: bool,
selection_first: bool,
selection_last: bool,
selection_extend: bool,
rename: bool,
delete: bool,
permanent_delete: bool,
}
impl eframe::App for GuthApp {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
ctx.set_visuals(self.palette.visuals());
if !self.dialogs.is_open() {
self.handle_shortcuts(ctx);
}
if !self.dialogs.is_open() && !self.media_preview.open {
self.handle_dropped_files(ctx);
}
self.drain_operation_events();
self.thumbnails.poll(ctx);
self.poll_media_preview_image(ctx);
self.poll_media_preview_text(ctx);
self.poll_deep_search(ctx);
self.start_deep_search_if_ready();
self.ensure_background_texture(ctx);
let raw_phase = ctx.input(|input| input.time) as f32;
let phase = if self.motion_enabled { raw_phase } else { 0.0 };
let now = raw_phase as f64;
self.hover_animation_active = false;
egui::TopBottomPanel::top("headerbar")
.frame(panel_frame(
self.palette,
self.palette.header_fill(),
egui::Margin::symmetric(8.0, 4.0),
self.theme_opacity,
))
.exact_height(48.0)
.show(ctx, |ui| self.header(ui));
egui::TopBottomPanel::bottom("statusbar")
.frame(panel_frame(
self.palette,
self.palette.header_fill(),
egui::Margin::symmetric(8.0, 4.0),
self.theme_opacity,
))
.exact_height(28.0)
.show(ctx, |ui| self.activity_panel(ui, phase));
let sidebar_compact = self.sidebar_collapsed;
let sidebar_width = if sidebar_compact { 50.0 } else { 188.0 };
let sidebar_range = if sidebar_compact {
48.0..=58.0
} else {
176.0..=236.0
};
egui::SidePanel::left("places-sidebar")
.frame(panel_frame(
self.palette,
self.palette.sidebar_fill(),
egui::Margin::symmetric(8.0, 6.0),
self.theme_opacity,
))
.default_width(sidebar_width)
.width_range(sidebar_range)
.show(ctx, |ui| self.places_panel(ui, sidebar_compact));
egui::CentralPanel::default()
.frame(egui::Frame::none().fill(themed_alpha(
self.palette.content_fill(),
self.theme_opacity,
)))
.show(ctx, |ui| {
paint_background(
ui,
phase,
themed_alpha(self.palette.content_fill(), self.theme_opacity),
self.palette.row_alt_fill(),
self.palette.accent_soft(),
self.background_texture.as_ref(),
self.background_image_opacity,
);
self.toolbar(ui);
ui.add_space(4.0);
self.file_panel(ui, phase, now);
});
self.properties_overlay(ctx, phase);
self.settings_overlay(ctx, phase);
self.media_preview_overlay(ctx, phase);
self.dialogs(ctx, phase);
self.drag_feedback_overlay(ctx);
self.hover.clear_if_stale(now);
if (self.pin_drag_candidate.is_some() || !self.entry_drag_paths.is_empty())
&& ctx.input(|input| input.pointer.any_released())
{
self.pin_drag_candidate = None;
self.entry_drag_paths.clear();
}
let repaint_ms = if self.operation_rx.is_some()
|| (self.motion_enabled && self.hover_animation_active)
|| self.pin_drag_candidate.is_some()
|| !self.entry_drag_paths.is_empty()
{
16
} else if self.thumbnails.has_work()
|| self.media_preview.image_rx.is_some()
|| self.media_preview.text_rx.is_some()
|| self.deep_search_active()
{
33
} else {
120
};
ctx.request_repaint_after(Duration::from_millis(repaint_ms));
}
}
impl GuthApp {
fn properties_overlay(&mut self, ctx: &egui::Context, phase: f32) {
if !self.show_properties {
return;
}
let mut open = self.show_properties;
egui::Window::new("Properties")
.id(egui::Id::new("properties-overlay"))
.order(egui::Order::Foreground)
.open(&mut open)
.collapsible(false)
.resizable(true)
.default_width(340.0)
.min_width(300.0)
.max_width(460.0)
.anchor(Align2::RIGHT_TOP, egui::vec2(-24.0, 86.0))
.frame(window_frame(self.palette, self.theme_opacity))
.vscroll(true)
.show(ctx, |ui| self.properties_panel(ui, phase));
self.show_properties = open;
}
fn settings_overlay(&mut self, ctx: &egui::Context, phase: f32) {
if !self.show_settings {
return;
}
let mut open = self.show_settings;
egui::Window::new("View Options")
.id(egui::Id::new("view-options-overlay"))
.order(egui::Order::Foreground)
.open(&mut open)
.collapsible(false)
.resizable(true)
.default_width(430.0)
.min_width(340.0)
.max_width(560.0)
.anchor(Align2::CENTER_TOP, egui::vec2(0.0, 86.0))
.frame(window_frame(self.palette, self.theme_opacity))
.vscroll(true)
.show(ctx, |ui| self.rendering_panel(ui, phase));
self.show_settings = open;
}
fn media_preview_overlay(&mut self, ctx: &egui::Context, phase: f32) {
if !self.media_preview.open {
return;
}
let Some(plugin_kind) = self.media_preview.active_plugin else {
self.close_media_preview_plugin();
return;
};
if !self.preview_plugin_enabled(plugin_kind) {
self.close_media_preview_plugin();
return;
}
let backdrop_clicked = preview_backdrop(ctx, self.palette);
let mut open = self.media_preview.open;
let screen = ctx.screen_rect().size();
egui::Window::new("Preview")
.id(egui::Id::new("guth-inline-preview"))
.order(egui::Order::Foreground)
.open(&mut open)
.collapsible(false)
.resizable(true)
.default_size(Vec2::new(
(screen.x * 0.72).clamp(620.0, 980.0),
(screen.y * 0.74).clamp(460.0, 760.0),
))
.min_width(380.0)
.min_height(360.0)
.anchor(Align2::CENTER_CENTER, egui::vec2(0.0, 0.0))
.frame(window_frame(self.palette, self.theme_opacity))
.show(ctx, |ui| self.media_preview_panel(ui, phase));
open = open && self.media_preview.open && !backdrop_clicked;
self.media_preview.open = open;
if !open {
self.close_media_preview_plugin();
}
}
fn media_preview_panel(&mut self, ui: &mut egui::Ui, phase: f32) {
let Some(entry) = self.media_preview.entry.clone() else {
muted_label(ui, "Select an image, video, text, or code file to preview.");
return;
};
let Some(plugin_kind) = self.media_preview.active_plugin else {
muted_label(ui, "No preview plugin is active.");
return;
};
let media_class = media_class_for_entry(&entry);
let preview_entries = self.previewable_entries();
let preview_position = preview_entries
.iter()
.position(|candidate| candidate.path == entry.path)
.map(|index| (index + 1, preview_entries.len()));
let can_step = if preview_position.is_some() {
preview_entries.len() > 1
} else {
!preview_entries.is_empty()
};
preview_file_header(
ui,
self.palette,
self.theme_opacity,
&entry,
preview_position,
);
ui.add_space(8.0);
self.media_preview_control_bar(ui, &entry, plugin_kind, can_step, preview_position);
if self
.media_preview
.entry
.as_ref()
.is_none_or(|current| current.path != entry.path)
{
return;
}
ui.add_space(8.0);
match plugin_kind {
PreviewPluginKind::Media => match media_class {
MediaClass::Image => self.media_preview_image(ui, &entry, phase),
MediaClass::Video => self.media_preview_video(ui, &entry, phase),
_ => muted_label(ui, "This media type cannot be previewed inline."),
},
PreviewPluginKind::Text => match media_class {
MediaClass::Code | MediaClass::Text => self.media_preview_text(ui, &entry, phase),
_ => muted_label(ui, "This document cannot be previewed inline."),
},
}
ui.add_space(7.0);
self.media_preview_status_bar(ui, &entry, plugin_kind);
}
fn media_preview_control_bar(
&mut self,
ui: &mut egui::Ui,
entry: &FileEntry,
plugin_kind: PreviewPluginKind,
can_step: bool,
preview_position: Option<(usize, usize)>,
) {
let text_preview = plugin_kind == PreviewPluginKind::Text;
egui::Frame::none()
.fill(themed_alpha(self.palette.surface(), self.theme_opacity))
.stroke(Stroke::NONE)
.rounding(Rounding::same(theme_rounding(self.palette) + 4.0))
.inner_margin(egui::Margin::symmetric(8.0, 5.0))
.show(ui, |ui| {
ui.horizontal_wrapped(|ui| {
if icon_button(ui, self.palette, ChromeIcon::Back, None, false, can_step)
.on_hover_text("Previous preview · Left")
.clicked()
{
self.step_media_preview_plugin(-1);
}
if let Some((index, total)) = preview_position {
info_chip(ui, self.palette, &format!("{} / {}", index, total), true);
} else {
info_chip(ui, self.palette, "Outside list", false);
}
if icon_button(ui, self.palette, ChromeIcon::Forward, None, false, can_step)
.on_hover_text("Next preview · Right")
.clicked()
{
self.step_media_preview_plugin(1);
}
command_separator(ui, self.palette);
if icon_button(
ui,
self.palette,
ChromeIcon::ZoomOut,
None,
false,
if text_preview {
self.media_preview.text_scale > 0.8
} else {
self.media_preview.zoom > 0.5
},
)
.on_hover_text("Zoom out · Down")
.clicked()
{
if text_preview {
self.adjust_text_preview_scale(-0.10);
} else {
self.adjust_media_preview_zoom(-0.10);
}
}
info_chip(
ui,
self.palette,
&if text_preview {
format!("{}% text", (self.media_preview.text_scale * 100.0).round())
} else {
preview_zoom_label(self.media_preview.zoom)
},
false,
);
if icon_button(
ui,
self.palette,
ChromeIcon::ZoomIn,
None,
false,
if text_preview {
self.media_preview.text_scale < 1.7
} else {
self.media_preview.zoom < 2.5
},
)
.on_hover_text("Zoom in · Up")
.clicked()
{
if text_preview {
self.adjust_text_preview_scale(0.10);
} else {
self.adjust_media_preview_zoom(0.10);
}
}
if icon_button(
ui,
self.palette,
ChromeIcon::ResetZoom,
None,
false,
if text_preview {
(self.media_preview.text_scale - 1.0).abs() > f32::EPSILON
} else {
(self.media_preview.zoom - 1.0).abs() > f32::EPSILON
},
)
.on_hover_text(if text_preview {
"Reset text size"
} else {
"Reset to fitted view"
})
.clicked()
{
if text_preview {
self.media_preview.text_scale = 1.0;
} else {
self.media_preview.zoom = 1.0;
self.media_preview.pan = Vec2::ZERO;
}
}
command_separator(ui, self.palette);
if icon_button(
ui,
self.palette,
ChromeIcon::OpenExternal,
None,
false,
true,
)
.on_hover_text("Open externally · Shift+Enter")
.clicked()
{
self.open_path_external(&entry.path);
}
});
});
}
fn media_preview_status_bar(
&self,
ui: &mut egui::Ui,
entry: &FileEntry,
plugin_kind: PreviewPluginKind,
) {
let plugin = self.preview_plugin_descriptor(plugin_kind);
let mut details = vec![
plugin.name.to_string(),
entry.type_label().to_string(),
human_size(entry.size),
];
if let Some([width, height]) = self.media_preview.image_dimensions {
details.push(format!("{} × {} px", width, height));
}
if let Some(document) = &self.media_preview.text_document {
details.push(format!("{} lines", document.line_count));
details.push(format!(
"{} preview",
human_size(document.bytes_read as u64)
));
details.push(if document.lossy {
"lossy UTF-8".to_string()
} else {
"UTF-8".to_string()
});
if document.truncated {
details.push("truncated preview".to_string());
}
}
if plugin_kind == PreviewPluginKind::Media && self.media_preview.zoom > 1.0 {
details.push("drag to pan".to_string());
}
details.push(modified_label(entry.modified));
egui::Frame::none()
.fill(themed_alpha(self.palette.surface(), self.theme_opacity))
.stroke(Stroke::new(1.0, self.palette.hairline()))
.rounding(Rounding::same(theme_rounding(self.palette) + 5.0))
.inner_margin(egui::Margin::symmetric(9.0, 5.0))
.show(ui, |ui| {
ui.horizontal_wrapped(|ui| {
ui.label(
RichText::new(details.join(" / "))
.size(10.5)
.color(self.palette.muted()),
);
});
});
}
fn media_preview_image(&mut self, ui: &mut egui::Ui, entry: &FileEntry, phase: f32) {
self.request_media_preview_image(entry);
let canvas_height =
preview_canvas_height(ui.available_width(), ui.available_height(), 0.66);
let (rect, response) = ui.allocate_exact_size(
Vec2::new(ui.available_width(), canvas_height),
Sense::click_and_drag(),
);
if response.double_clicked() {
self.media_preview.zoom = 1.0;
self.media_preview.pan = Vec2::ZERO;
} else if response.dragged() && self.media_preview.zoom > 1.0 {
self.media_preview.pan += ui.input(|input| input.pointer.delta());
}
let scroll = ui.input(|input| input.smooth_scroll_delta.y);
if response.hovered() && scroll.abs() > 0.1 {
self.adjust_media_preview_zoom(if scroll > 0.0 { 0.10 } else { -0.10 });
}
let painter = ui.painter_at(rect);
let canvas = draw_preview_canvas(&painter, rect, self.palette);
if let Some(texture) = self.media_preview.image_texture.clone() {
let media_painter = painter.with_clip_rect(canvas.shrink(1.0));
let [width, height] = texture.size();
let fitted_rect =
fit_rect_to_aspect(canvas.shrink(12.0), width as f32 / height.max(1) as f32);
let zoomed_rect = zoom_rect(fitted_rect, self.media_preview.zoom);
self.media_preview.pan =
clamp_preview_pan(self.media_preview.pan, zoomed_rect, canvas.shrink(12.0));
let image_rect = zoomed_rect.translate(self.media_preview.pan);
media_shadow(&media_painter, image_rect, 5.0, self.palette);
draw_transparency_grid(&media_painter, image_rect, self.palette);
media_painter.image(
texture.id(),
image_rect,
Rect::from_min_max(Pos2::ZERO, Pos2::new(1.0, 1.0)),
Color32::WHITE,
);
media_painter.rect_stroke(
image_rect,
Rounding::same(5.0),
Stroke::new(1.0, self.palette.hairline()),
);
if self.media_preview.zoom > 1.0 {
let cursor = if response.dragged() {
egui::CursorIcon::Grabbing
} else {
egui::CursorIcon::Grab
};
response.on_hover_cursor(cursor);
}
} else {
draw_preview_placeholder(
&painter,
canvas,
MediaClass::Image,
self.media_preview.image_rx.is_some(),
self.media_preview.error.as_deref(),
self.palette,
phase,
);
}
}
fn media_preview_video(&mut self, ui: &mut egui::Ui, entry: &FileEntry, phase: f32) {
let modified_stamp = thumbnail_modified_stamp(entry.modified);
self.thumbnails
.request_poster(&entry.path, entry.size, modified_stamp);
if self.hover_effects_enabled {
self.thumbnails
.request_hover_strip(&entry.path, entry.size, modified_stamp);
}
let view = self.thumbnails.thumbnail_for(
&entry.path,
entry.size,
modified_stamp,
self.hover_effects_enabled,
phase,
);
if view.as_ref().is_some_and(|view| view.animated) {
self.hover_animation_active = true;
}
let canvas_height =
preview_canvas_height(ui.available_width(), ui.available_height(), 0.62);
let (rect, response) = ui.allocate_exact_size(
Vec2::new(ui.available_width(), canvas_height),
Sense::click_and_drag(),
);
if response.double_clicked() {
self.media_preview.zoom = 1.0;
self.media_preview.pan = Vec2::ZERO;
} else if response.dragged() && self.media_preview.zoom > 1.0 {
self.media_preview.pan += ui.input(|input| input.pointer.delta());
}
let scroll = ui.input(|input| input.smooth_scroll_delta.y);
if response.hovered() && scroll.abs() > 0.1 {
self.adjust_media_preview_zoom(if scroll > 0.0 { 0.10 } else { -0.10 });
}
let painter = ui.painter_at(rect);
let canvas = draw_preview_canvas(&painter, rect, self.palette);
if let Some(view) = view {
let media_painter = painter.with_clip_rect(canvas.shrink(1.0));
let fitted_rect = fit_rect_to_aspect(canvas.shrink(12.0), view.aspect);
let zoomed_rect = zoom_rect(fitted_rect, self.media_preview.zoom);
self.media_preview.pan =
clamp_preview_pan(self.media_preview.pan, zoomed_rect, canvas.shrink(12.0));
let image_rect = zoomed_rect.translate(self.media_preview.pan);
media_shadow(&media_painter, image_rect, 5.0, self.palette);
media_painter.image(
view.texture_id,
image_rect,
Rect::from_min_max(Pos2::ZERO, Pos2::new(1.0, 1.0)),
Color32::WHITE,
);
media_painter.rect_stroke(
image_rect,
Rounding::same(5.0),
Stroke::new(1.0, self.palette.hairline()),
);
draw_preview_stage_badge(
&painter,
canvas,
if view.animated {
"MOTION PREVIEW"
} else {
"VIDEO POSTER"
},
self.palette,
);
if self.media_preview.zoom > 1.0 {
let cursor = if response.dragged() {
egui::CursorIcon::Grabbing
} else {
egui::CursorIcon::Grab
};
response.on_hover_cursor(cursor);
}
} else {
let failure = self
.thumbnails
.poster_failure(&entry.path, entry.size, modified_stamp);
draw_preview_placeholder(
&painter,
canvas,
MediaClass::Video,
self.thumbnails.is_loading(&entry.path),
failure,
self.palette,
phase,
);
}
}
fn media_preview_text(&mut self, ui: &mut egui::Ui, entry: &FileEntry, phase: f32) {
self.request_media_preview_text(entry);
let media_class = media_class_for_entry(entry);
self.ensure_text_preview_buffer();
self.update_text_find_summary();
let canvas_height =
preview_canvas_height(ui.available_width(), ui.available_height(), 0.64);
if self.media_preview.text_document.is_none() {
let (rect, _) = ui.allocate_exact_size(
Vec2::new(ui.available_width(), canvas_height),
Sense::hover(),
);
let painter = ui.painter_at(rect);
let canvas = draw_preview_canvas(&painter, rect, self.palette);
draw_preview_placeholder(
&painter,
canvas,
media_class,
self.media_preview.text_rx.is_some(),
self.media_preview.error.as_deref(),
self.palette,
phase,
);
return;
}
egui::Frame::none()
.fill(self.palette.recessed_fill())
.stroke(Stroke::new(1.0, self.palette.hairline()))
.rounding(Rounding::same(theme_rounding(self.palette) + 8.0))
.inner_margin(egui::Margin::same(0.0))
.show(ui, |ui| {
ui.set_min_height(canvas_height);
egui::Frame::none()
.fill(themed_alpha(self.palette.surface(), self.theme_opacity))
.stroke(Stroke::new(1.0, self.palette.hairline()))
.rounding(Rounding {
nw: theme_rounding(self.palette) + 8.0,
ne: theme_rounding(self.palette) + 8.0,
sw: 0.0,
se: 0.0,
})
.inner_margin(egui::Margin::symmetric(8.0, 6.0))
.show(ui, |ui| {
ui.horizontal_wrapped(|ui| {
inline_chrome_icon(
ui,
self.palette,
ChromeIcon::Search,
self.palette.muted(),
);
let field_width = (ui.available_width() * 0.48).clamp(150.0, 320.0);
let response = ui.add_sized(
Vec2::new(field_width, 26.0),
egui::TextEdit::singleline(&mut self.media_preview.text_find_query)
.hint_text("Find in document")
.frame(false),
);
if self.media_preview.text_find_focus_requested {
response.request_focus();
self.media_preview.text_find_focus_requested = false;
}
if response.changed() {
self.media_preview.text_find_loaded_key = None;
self.update_text_find_summary();
}
if self.media_preview.text_find_query.trim().is_empty() {
ui.label(
RichText::new("Ctrl+F")
.size(10.0)
.color(self.palette.muted()),
);
} else {
info_chip(
ui,
self.palette,
&format!(
"{} {}",
self.media_preview.text_find_match_count,
if self.media_preview.text_find_match_count == 1 {
"match"
} else {
"matches"
}
),
self.media_preview.text_find_match_count > 0,
);
if icon_button(
ui,
self.palette,
ChromeIcon::Close,
None,
false,
true,
)
.on_hover_text("Clear find")
.clicked()
{
self.media_preview.text_find_query.clear();
self.media_preview.text_find_loaded_key = None;
self.update_text_find_summary();
}
}
command_separator(ui, self.palette);
if icon_button(
ui,
self.palette,
ChromeIcon::List,
None,
self.media_preview.text_line_numbers,
true,
)
.on_hover_text("Toggle line numbers")
.clicked()
{
self.media_preview.text_line_numbers =
!self.media_preview.text_line_numbers;
self.media_preview.text_view_buffer_key = None;
self.ensure_text_preview_buffer();
}
});
});
egui::Frame::none()
.inner_margin(egui::Margin::same(9.0))
.show(ui, |ui| {
let text_height = (canvas_height - 76.0).max(180.0);
let font_size = (13.0 * self.media_preview.text_scale).clamp(10.0, 22.0);
ui.scope(|ui| {
ui.style_mut()
.text_styles
.insert(egui::TextStyle::Monospace, FontId::monospace(font_size));
let min_width = ui.available_width();
egui::ScrollArea::both()
.id_salt((
"guth-text-preview-content",
&entry.path,
entry.size,
thumbnail_modified_stamp(entry.modified),
))
.auto_shrink([false, false])
.max_height(text_height)
.show(ui, |ui| {
ui.set_min_width(min_width);
ui.add(
egui::TextEdit::multiline(
&mut self.media_preview.text_view_buffer,
)
.font(egui::TextStyle::Monospace)
.code_editor()
.frame(false)
.desired_width(f32::INFINITY)
.interactive(false),
);
});
});
});
});
}
fn ensure_text_preview_buffer(&mut self) {
let key = self.media_preview.text_loaded_key.clone();
let line_numbers = self.media_preview.text_line_numbers;
if self.media_preview.text_view_buffer_key == key
&& self.media_preview.text_view_buffer_line_numbers == line_numbers
{
return;
}
self.media_preview.text_view_buffer = self
.media_preview
.text_document
.as_ref()
.map(|document| {
if line_numbers {
document.numbered_content.clone()
} else {
document.content.clone()
}
})
.unwrap_or_default();
self.media_preview.text_view_buffer_key = key;
self.media_preview.text_view_buffer_line_numbers = line_numbers;
}
fn update_text_find_summary(&mut self) {
let key = self.media_preview.text_loaded_key.clone();
if self.media_preview.text_find_loaded_key == key
&& self.media_preview.text_find_last_query == self.media_preview.text_find_query
{
return;
}
self.media_preview.text_find_match_count = self
.media_preview
.text_document
.as_ref()
.map(|document| {
count_text_matches(&document.content, &self.media_preview.text_find_query)
})
.unwrap_or_default();
self.media_preview.text_find_last_query = self.media_preview.text_find_query.clone();
self.media_preview.text_find_loaded_key = key;
}
fn poll_media_preview_image(&mut self, ctx: &egui::Context) {
let Some(result) =
self.media_preview
.image_rx
.as_ref()
.and_then(|rx| match rx.try_recv() {
Ok(result) => Some(Ok(result)),
Err(TryRecvError::Empty) => None,
Err(TryRecvError::Disconnected) => {
Some(Err("Preview loader stopped".to_string()))
}
})
else {
return;
};
self.media_preview.image_rx = None;
let worker_key = self.media_preview.image_worker_key.take();
let current_key = self
.media_preview
.entry
.as_ref()
.map(MediaPreviewKey::from_entry);
match result {
Ok(result) => {
if current_key.as_ref() == Some(&result.key) {
match result.image {
Ok(image) => {
let source_dimensions = image.source_dimensions;
let texture = ctx.load_texture(
format!(
"media-preview:{}:{}",
result.key.path.display(),
result.key.modified_stamp
),
image.pixels,
egui::TextureOptions::LINEAR,
);
self.cache_media_preview_texture(
result.key.clone(),
texture.clone(),
source_dimensions,
);
self.media_preview.image_texture = Some(texture);
self.media_preview.image_dimensions = Some(source_dimensions);
self.media_preview.loaded_key = Some(result.key);
self.media_preview.error = None;
}
Err(error) => {
self.media_preview.loaded_key = Some(result.key);
self.media_preview.image_texture = None;
self.media_preview.image_dimensions = None;
self.media_preview.error = Some(error);
}
}
ctx.request_repaint();
}
}
Err(error) if current_key.as_ref() == worker_key.as_ref() => {
self.media_preview.loaded_key = worker_key;
self.media_preview.image_texture = None;
self.media_preview.image_dimensions = None;
self.media_preview.error = Some(error);
}
Err(_) => {}
}
if let Some(pending) = self.media_preview.image_pending.take() {
if current_key.as_ref() == Some(&pending.key) {
self.start_media_preview_image(pending);
return;
}
}
self.media_preview.loading_key = None;
}
fn request_media_preview_image(&mut self, entry: &FileEntry) {
let key = MediaPreviewKey::from_entry(entry);
if self.media_preview.loaded_key.as_ref() == Some(&key)
&& (self.media_preview.image_texture.is_some() || self.media_preview.error.is_some())
{
return;
}
if self.media_preview.loading_key.as_ref() == Some(&key) {
return;
}
if let Some((texture, source_dimensions)) = self.cached_media_preview_texture(&key) {
self.media_preview.image_texture = Some(texture);
self.media_preview.image_dimensions = Some(source_dimensions);
self.media_preview.loaded_key = Some(key);
self.media_preview.loading_key = None;
self.media_preview.image_pending = None;
self.media_preview.error = None;
return;
}
self.media_preview.image_texture = None;
self.media_preview.image_dimensions = None;
self.media_preview.loaded_key = None;
self.media_preview.loading_key = Some(key.clone());
self.media_preview.error = None;
let pending = MediaPreviewPending {
key,
path: entry.path.clone(),
};
if self.media_preview.image_rx.is_some() {
self.media_preview.image_pending = Some(pending);
} else {
self.start_media_preview_image(pending);
}
}
fn start_media_preview_image(&mut self, pending: MediaPreviewPending) {
let key = pending.key;
let path = pending.path;
let (tx, rx) = mpsc::sync_channel(1);
let failure_key = key.clone();
self.media_preview.image_worker_key = Some(key.clone());
self.media_preview.image_rx = Some(rx);
if let Err(error) = thread::Builder::new()
.name("guth-media-preview".to_string())
.spawn(move || {
let image = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
load_media_preview_image(&path)
}))
.unwrap_or_else(|_| Err("Preview worker stopped unexpectedly".to_string()));
let _ = tx.send(MediaPreviewImageResult { key, image });
})
{
self.media_preview.loading_key = None;
self.media_preview.loaded_key = Some(failure_key);
self.media_preview.image_worker_key = None;
self.media_preview.image_rx = None;
self.media_preview.error = Some(format!("Preview worker failed: {error}"));
}
}
fn poll_media_preview_text(&mut self, ctx: &egui::Context) {
let Some(result) = self
.media_preview
.text_rx
.as_ref()
.and_then(|rx| match rx.try_recv() {
Ok(result) => Some(Ok(result)),
Err(TryRecvError::Empty) => None,
Err(TryRecvError::Disconnected) => {
Some(Err("Text preview loader stopped".to_string()))
}
})
else {
return;
};
self.media_preview.text_rx = None;
let worker_key = self.media_preview.text_worker_key.take();
let current_key = self
.media_preview
.entry
.as_ref()
.map(MediaPreviewKey::from_entry);
match result {
Ok(result) => {
if current_key.as_ref() == Some(&result.key) {
match result.document {
Ok(document) => {
self.media_preview.text_document = Some(document);
self.media_preview.text_loaded_key = Some(result.key);
self.media_preview.text_view_buffer_key = None;
self.media_preview.text_find_loaded_key = None;
self.media_preview.error = None;
}
Err(error) => {
self.media_preview.text_loaded_key = Some(result.key);
self.media_preview.text_document = None;
self.media_preview.text_view_buffer.clear();
self.media_preview.text_view_buffer_key = None;
self.media_preview.text_find_loaded_key = None;
self.media_preview.error = Some(error);
}
}
ctx.request_repaint();
}
}
Err(error) if current_key.as_ref() == worker_key.as_ref() => {
self.media_preview.text_loaded_key = worker_key;
self.media_preview.text_document = None;
self.media_preview.text_view_buffer.clear();
self.media_preview.error = Some(error);
}
Err(_) => {}
}
if let Some(pending) = self.media_preview.text_pending.take() {
if current_key.as_ref() == Some(&pending.key) {
self.start_media_preview_text(pending);
return;
}
}
self.media_preview.text_loading_key = None;
}
fn request_media_preview_text(&mut self, entry: &FileEntry) {
let key = MediaPreviewKey::from_entry(entry);
if self.media_preview.text_loaded_key.as_ref() == Some(&key)
&& (self.media_preview.text_document.is_some() || self.media_preview.error.is_some())
{
return;
}
if self.media_preview.text_loading_key.as_ref() == Some(&key) {
return;
}
self.media_preview.text_document = None;
self.media_preview.text_loaded_key = None;
self.media_preview.text_loading_key = Some(key.clone());
self.media_preview.text_view_buffer.clear();
self.media_preview.text_view_buffer_key = None;
self.media_preview.text_find_loaded_key = None;
self.media_preview.error = None;
let pending = MediaPreviewPending {
key,
path: entry.path.clone(),
};
if self.media_preview.text_rx.is_some() {
self.media_preview.text_pending = Some(pending);
} else {
self.start_media_preview_text(pending);
}
}
fn start_media_preview_text(&mut self, pending: MediaPreviewPending) {
let key = pending.key;
let path = pending.path;
let (tx, rx) = mpsc::sync_channel(1);
let failure_key = key.clone();
self.media_preview.text_worker_key = Some(key.clone());
self.media_preview.text_rx = Some(rx);
if let Err(error) = thread::Builder::new()
.name("guth-text-preview".to_string())
.spawn(move || {
let document = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
load_text_preview(&path)
}))
.unwrap_or_else(|_| Err("Text preview worker stopped unexpectedly".to_string()));
let _ = tx.send(TextPreviewResult { key, document });
})
{
self.media_preview.text_loading_key = None;
self.media_preview.text_loaded_key = Some(failure_key);
self.media_preview.text_worker_key = None;
self.media_preview.text_rx = None;
self.media_preview.error = Some(format!("Text preview worker failed: {error}"));
}
}
fn cached_media_preview_texture(
&mut self,
key: &MediaPreviewKey,
) -> Option<(egui::TextureHandle, [u32; 2])> {
let index = self
.media_preview
.image_cache
.iter()
.position(|cached| &cached.key == key)?;
self.media_preview.cache_clock = self.media_preview.cache_clock.saturating_add(1);
let cached = self.media_preview.image_cache.get_mut(index)?;
cached.last_used = self.media_preview.cache_clock;
Some((cached.texture.clone(), cached.source_dimensions))
}
fn cache_media_preview_texture(
&mut self,
key: MediaPreviewKey,
texture: egui::TextureHandle,
source_dimensions: [u32; 2],
) {
self.media_preview.cache_clock = self.media_preview.cache_clock.saturating_add(1);
if let Some(cached) = self
.media_preview
.image_cache
.iter_mut()
.find(|cached| cached.key == key)
{
cached.texture = texture;
cached.source_dimensions = source_dimensions;
cached.last_used = self.media_preview.cache_clock;
return;
}
self.media_preview
.image_cache
.push_back(MediaPreviewCachedImage {
key,
texture,
source_dimensions,
last_used: self.media_preview.cache_clock,
});
while self.media_preview.image_cache.len() > MEDIA_PREVIEW_IMAGE_CACHE_LIMIT {
let Some((index, _)) = self
.media_preview
.image_cache
.iter()
.enumerate()
.min_by_key(|(_, cached)| cached.last_used)
else {
break;
};
self.media_preview.image_cache.remove(index);
}
}
fn header(&mut self, ui: &mut egui::Ui) {
let compact = ui.available_width() < 1_100.0;
ui.horizontal(|ui| {
if icon_button(
ui,
self.palette,
ChromeIcon::Sidebar,
None,
!self.sidebar_collapsed,
true,
)
.on_hover_text("Toggle sidebar")
.clicked()
{
self.sidebar_collapsed = !self.sidebar_collapsed;
self.save_preferences();
}
ui.add_space(4.0);
if icon_button(
ui,
self.palette,
ChromeIcon::Back,
None,
false,
self.history_cursor > 0,
)
.on_hover_text("Back")
.clicked()
{
self.go_back();
}
if icon_button(
ui,
self.palette,
ChromeIcon::Forward,
None,
false,
self.history_cursor + 1 < self.history.len(),
)
.on_hover_text("Forward")
.clicked()
{
self.go_forward();
}
if icon_button(ui, self.palette, ChromeIcon::Up, None, false, true)
.on_hover_text("Parent folder")
.clicked()
{
self.go_parent();
}
if !compact
&& icon_button(ui, self.palette, ChromeIcon::Home, None, false, true)
.on_hover_text("Home")
.clicked()
{
self.navigate_to(home_dir(), HistoryMode::Push);
}
ui.add_space(if compact { 4.0 } else { 8.0 });
if self.location_entry_open {
self.header_location_entry(ui);
} else {
self.header_breadcrumbs(ui, compact);
}
if !compact || !self.location_entry_open {
ui.add_space(if compact { 4.0 } else { 8.0 });
let search_changed = self.header_search_box(ui);
if search_changed {
self.update_deep_search();
}
}
});
}
fn header_search_box(&mut self, ui: &mut egui::Ui) -> bool {
let available = ui.available_width().max(96.0);
let width = (available * 0.42).clamp(148.0, 320.0).min(available);
let mut changed = false;
egui::Frame::none()
.fill(self.palette.elevated_fill())
.stroke(Stroke::new(1.0, self.palette.hairline()))
.rounding(Rounding::same(theme_rounding(self.palette) + 5.0))
.inner_margin(egui::Margin::symmetric(8.0, 3.0))
.show(ui, |ui| {
ui.set_width(width);
ui.horizontal(|ui| {
let search_has_text = !self.search_query.is_empty();
let (icon_rect, _) = ui.allocate_exact_size(Vec2::splat(18.0), Sense::hover());
draw_chrome_icon(
ui.painter(),
icon_rect.shrink(2.0),
ChromeIcon::Search,
self.palette.muted(),
);
let response = ui.add(
egui::TextEdit::singleline(&mut self.search_query)
.hint_text(format!("Search {}", folder_title(&self.current_dir)))
.desired_width(
(width - if search_has_text { 58.0 } else { 36.0 }).max(90.0),
)
.frame(false),
);
if self.search_focus_requested {
response.request_focus();
}
changed = response.changed();
if response.has_focus()
&& ui.input(|input| input.key_pressed(egui::Key::Escape))
&& !self.search_query.is_empty()
{
self.search_query.clear();
changed = true;
}
if search_has_text {
let (clear_rect, clear_response) =
ui.allocate_exact_size(Vec2::splat(18.0), Sense::click());
let painter = ui.painter_at(clear_rect);
let clear_fill = if clear_response.hovered() {
self.palette.hover_fill()
} else {
self.palette.surface()
};
painter.circle_filled(clear_rect.center(), 8.0, clear_fill);
painter.circle_stroke(
clear_rect.center(),
8.0,
Stroke::new(1.0, self.palette.hairline()),
);
let clear_color = if clear_response.hovered() {
self.palette.text()
} else {
self.palette.muted()
};
painter.line_segment(
[
clear_rect.center() + egui::vec2(-3.0, -3.0),
clear_rect.center() + egui::vec2(3.0, 3.0),
],
Stroke::new(1.2, clear_color),
);
painter.line_segment(
[
clear_rect.center() + egui::vec2(3.0, -3.0),
clear_rect.center() + egui::vec2(-3.0, 3.0),
],
Stroke::new(1.2, clear_color),
);
if clear_response.clicked() {
self.search_query.clear();
changed = true;
}
}
});
});
self.search_focus_requested = false;
changed
}
fn header_location_entry(&mut self, ui: &mut egui::Ui) {
let available = ui.available_width().max(140.0);
let width = (available * 0.72).clamp(180.0, 560.0).min(available);
let mut navigate = false;
let mut close = false;
egui::Frame::none()
.fill(self.palette.accent_soft())
.stroke(Stroke::new(1.0, self.palette.accent(0.0)))
.rounding(Rounding::same(theme_rounding(self.palette) + 5.0))
.inner_margin(egui::Margin::symmetric(8.0, 3.0))
.show(ui, |ui| {
ui.set_width(width);
ui.horizontal(|ui| {
let (icon_rect, _) = ui.allocate_exact_size(Vec2::splat(18.0), Sense::hover());
draw_chrome_icon(
ui.painter(),
icon_rect.shrink(2.0),
ChromeIcon::Folder,
self.palette.accent(0.0),
);
let response = ui.add(
egui::TextEdit::singleline(&mut self.path_input)
.hint_text("Type a folder path")
.desired_width((width - 36.0).max(120.0))
.frame(false),
);
response.request_focus();
navigate = response.has_focus()
&& ui.input(|input| input.key_pressed(egui::Key::Enter));
close = ui.input(|input| input.key_pressed(egui::Key::Escape));
});
});
if close {
self.path_input = self.current_dir.display().to_string();
self.location_entry_open = false;
} else if navigate {
let path = self.path_input.trim();
if path.is_empty() {
self.status = "Enter a folder path".to_string();
self.path_input = self.current_dir.display().to_string();
} else {
self.location_entry_open = false;
self.navigate_to(expand_tilde(path), HistoryMode::Push);
}
}
}
fn header_breadcrumbs(&mut self, ui: &mut egui::Ui, compact: bool) {
egui::Frame::none()
.fill(self.palette.elevated_fill())
.stroke(Stroke::NONE)
.rounding(Rounding::same(theme_rounding(self.palette) + 4.0))
.inner_margin(egui::Margin::symmetric(4.0, 2.0))
.show(ui, |ui| {
ui.horizontal(|ui| {
let mut crumbs = breadcrumbs(&self.current_dir);
if let Some(home_index) =
crumbs.iter().position(|(_, path)| *path == home_dir())
{
crumbs.drain(..home_index);
}
if compact && crumbs.len() > 1 {
if let Some(current) = crumbs.pop() {
crumbs.clear();
crumbs.push(current);
}
} else if crumbs.len() > 3 {
let first = crumbs.remove(0);
let tail = crumbs.split_off(crumbs.len().saturating_sub(1));
crumbs = vec![first, ("…".to_string(), PathBuf::new())];
crumbs.extend(tail);
}
let last_index = crumbs.len().saturating_sub(1);
for (index, (label, path)) in crumbs.into_iter().enumerate() {
if index > 0 {
ui.label(RichText::new("›").size(13.0).color(self.palette.muted()));
}
if path.as_os_str().is_empty() {
ui.label(RichText::new(label).size(11.0).color(self.palette.muted()));
continue;
}
let label = if path == home_dir() {
"Home".to_string()
} else {
label
};
let current = index == last_index;
if ui
.add(pathbar_button(self.palette, &label, current))
.clicked()
&& !current
{
self.navigate_to(path, HistoryMode::Push);
}
}
});
});
}
fn toolbar(&mut self, ui: &mut egui::Ui) {
let compact = ui.available_width() < 980.0;
egui::Frame::none()
.fill(Color32::TRANSPARENT)
.stroke(Stroke::NONE)
.rounding(Rounding::ZERO)
.inner_margin(egui::Margin::symmetric(12.0, 4.0))
.show(ui, |ui| {
ui.spacing_mut().item_spacing = Vec2::new(5.0, 5.0);
ui.horizontal_wrapped(|ui| {
if icon_button(ui, self.palette, ChromeIcon::Refresh, None, false, true)
.on_hover_text("Refresh")
.clicked()
{
self.refresh_current_dir();
}
if icon_button(
ui,
self.palette,
ChromeIcon::FolderPlus,
if compact { None } else { Some("New Folder") },
false,
true,
)
.on_hover_text("New folder")
.clicked()
{
self.open_new_folder_dialog();
}
if icon_button(
ui,
self.palette,
ChromeIcon::FilePlus,
if compact { None } else { Some("New File") },
false,
true,
)
.on_hover_text("New file")
.clicked()
{
self.open_new_file_dialog();
}
if !self.selected.is_empty() {
command_separator(ui, self.palette);
if icon_button(ui, self.palette, ChromeIcon::Cut, None, false, true)
.on_hover_text("Cut selection")
.clicked()
{
self.copy_selection(ClipboardMode::Cut);
}
if icon_button(ui, self.palette, ChromeIcon::Copy, None, false, true)
.on_hover_text("Copy selection")
.clicked()
{
self.copy_selection(ClipboardMode::Copy);
}
if icon_button(ui, self.palette, ChromeIcon::Trash, None, false, true)
.on_hover_text("Move selection to trash")
.clicked()
{
self.trash_selection();
}
}
if self.clipboard.is_some()
&& icon_button(ui, self.palette, ChromeIcon::Paste, None, false, true)
.on_hover_text("Paste into this folder")
.clicked()
{
self.paste_clipboard();
}
command_separator(ui, self.palette);
egui::ComboBox::from_id_salt("sort-key")
.selected_text(self.sort_key.label())
.show_ui(ui, |ui| {
for key in SortKey::ALL {
let selected = self.sort_key == key;
if ui.selectable_label(selected, key.label()).clicked() && !selected
{
self.set_sort_key(key);
}
}
});
if icon_button(
ui,
self.palette,
ChromeIcon::Sort,
if compact { None } else { Some("Desc") },
self.sort_reverse,
true,
)
.on_hover_text("Reverse sort order")
.clicked()
{
self.sort_reverse = !self.sort_reverse;
self.save_preferences();
self.refresh_current_dir();
}
if icon_button(
ui,
self.palette,
ChromeIcon::FolderOrder,
if compact { None } else { Some("Folders") },
self.folders_first,
true,
)
.on_hover_text("Folders first")
.clicked()
{
self.folders_first = !self.folders_first;
self.save_preferences();
self.refresh_current_dir();
}
command_separator(ui, self.palette);
if icon_button(
ui,
self.palette,
ChromeIcon::List,
None,
self.view_mode == ViewMode::List,
true,
)
.on_hover_text("List view")
.clicked()
{
self.view_mode = ViewMode::List;
self.save_preferences();
}
if icon_button(
ui,
self.palette,
ChromeIcon::Grid,
None,
self.view_mode == ViewMode::Grid,
true,
)
.on_hover_text("Grid view")
.clicked()
{
self.view_mode = ViewMode::Grid;
self.save_preferences();
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if icon_button(
ui,
self.palette,
ChromeIcon::Settings,
if compact { None } else { Some("Settings") },
self.show_settings,
true,
)
.on_hover_text("Settings")
.clicked()
{
self.show_settings = !self.show_settings;
}
if icon_button(
ui,
self.palette,
ChromeIcon::Properties,
if compact { None } else { Some("Properties") },
self.show_properties,
true,
)
.on_hover_text("Properties")
.clicked()
{
self.show_properties = !self.show_properties;
}
});
});
});
}
fn places_panel(&mut self, ui: &mut egui::Ui, compact: bool) {
self.pinned_drop_rect = None;
if compact {
self.compact_places_panel(ui);
return;
}
egui::ScrollArea::vertical()
.id_salt("guth-places-scroll")
.auto_shrink([false, false])
.show(ui, |ui| {
sidebar_section_header(ui, self.palette, "Places", "");
ui.add_space(5.0);
for place in known_places(&self.current_dir)
.into_iter()
.filter(|place| place.label != "Current")
{
self.place_row(ui, place, false);
}
let bookmarks = self.bookmark_places();
let current_bookmarked = self.is_bookmarked(&self.current_dir);
ui.add_space(10.0);
ui.horizontal(|ui| {
ui.label(
RichText::new("Pinned")
.size(11.5)
.strong()
.color(self.palette.secondary_text()),
);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if icon_button(
ui,
self.palette,
ChromeIcon::Pin,
None,
current_bookmarked,
true,
)
.on_hover_text(if current_bookmarked {
"Unpin current folder"
} else {
"Pin current folder"
})
.clicked()
{
let current_dir = self.current_dir.clone();
if current_bookmarked {
self.remove_bookmark(¤t_dir);
} else {
self.add_bookmark(current_dir);
}
}
});
});
ui.add_space(5.0);
self.pinned_drop_target(ui, false);
if bookmarks.is_empty() && self.pin_drag_candidate.is_none() {
sidebar_pin_empty_state(ui, self.palette);
} else {
for place in bookmarks {
self.place_row(ui, place, true);
}
}
});
}
fn compact_places_panel(&mut self, ui: &mut egui::Ui) {
ui.vertical_centered(|ui| {
if icon_button(ui, self.palette, ChromeIcon::Sidebar, None, false, true)
.on_hover_text("Expand sidebar")
.clicked()
{
self.sidebar_collapsed = false;
self.save_preferences();
}
ui.separator();
for place in known_places(&self.current_dir)
.into_iter()
.filter(|place| place.label != "Current")
{
self.compact_place_row(ui, place, false);
}
let bookmarks = self.bookmark_places();
if !bookmarks.is_empty() {
ui.add_space(8.0);
ui.separator();
compact_section_icon(ui, self.palette, ChromeIcon::Pin);
for place in bookmarks {
self.compact_place_row(ui, place, true);
}
} else if self.pin_drag_candidate.is_some() {
ui.add_space(8.0);
ui.separator();
compact_section_icon(ui, self.palette, ChromeIcon::Pin);
}
self.pinned_drop_target(ui, true);
});
}
fn compact_place_row(&mut self, ui: &mut egui::Ui, place: Place, removable: bool) {
let active = self.current_dir == canonical_display_path(&place.path);
let trash_drop_target =
place.path == trash_files_dir() && !self.entry_drag_paths.is_empty();
let (rect, response) = ui.allocate_exact_size(Vec2::new(34.0, 30.0), Sense::click());
let painter = ui.painter_at(rect);
let row_rect = rect.shrink2(egui::vec2(2.0, 1.0));
let trash_drop_hovered = trash_drop_target && response.hovered();
painter.rect_filled(
row_rect,
Rounding::same(theme_rounding(self.palette) + 6.0),
if trash_drop_hovered || active {
self.palette.selected_fill()
} else if response.hovered() {
self.palette.hover_fill()
} else {
Color32::TRANSPARENT
},
);
if active {
painter.line_segment(
[
row_rect.left_center() + egui::vec2(0.0, -7.0),
row_rect.left_center() + egui::vec2(0.0, 7.0),
],
Stroke::new(3.0, self.palette.accent(0.0)),
);
}
if trash_drop_hovered {
painter.rect_stroke(
row_rect.shrink(1.0),
Rounding::same(theme_rounding(self.palette) + 6.0),
Stroke::new(1.3, self.palette.accent(0.0)),
);
} else if response.hovered() && !active {
painter.rect_stroke(
row_rect.shrink(1.0),
Rounding::same(theme_rounding(self.palette) + 6.0),
Stroke::new(1.0, self.palette.hairline()),
);
}
let icon_rect = Rect::from_center_size(row_rect.center(), Vec2::new(17.0, 17.0));
draw_place_icon(&painter, icon_rect, &place, removable, active, self.palette);
let response = response.on_hover_text(format!("{} · {}", place.label, place.detail));
if response.clicked() {
self.navigate_to(place.path.clone(), HistoryMode::Push);
}
if trash_drop_hovered && ui.input(|input| input.pointer.any_released()) {
self.trash_dragged_entries();
}
response.context_menu(|ui| self.place_context_menu(ui, &place, removable));
ui.add_space(1.0);
}
fn place_row(&mut self, ui: &mut egui::Ui, place: Place, removable: bool) {
let active = self.current_dir == canonical_display_path(&place.path);
let trash_drop_target =
place.path == trash_files_dir() && !self.entry_drag_paths.is_empty();
let (rect, response) =
ui.allocate_exact_size(Vec2::new(ui.available_width(), 28.0), Sense::click());
let painter = ui.painter_at(rect);
let row_rect = rect.shrink2(egui::vec2(2.0, 0.0));
let trash_drop_hovered = trash_drop_target && response.hovered();
painter.rect_filled(
row_rect,
Rounding::same(theme_rounding(self.palette)),
if trash_drop_hovered || active {
self.palette.selected_fill()
} else if response.hovered() {
self.palette.hover_fill()
} else {
Color32::TRANSPARENT
},
);
if active {
painter.line_segment(
[
row_rect.left_center() + egui::vec2(0.0, -7.0),
row_rect.left_center() + egui::vec2(0.0, 7.0),
],
Stroke::new(2.0, self.palette.accent(0.0)),
);
}
if trash_drop_hovered {
painter.rect_stroke(
row_rect.shrink(1.0),
Rounding::same(theme_rounding(self.palette)),
Stroke::new(1.3, self.palette.accent(0.0)),
);
} else if response.hovered() && !active {
painter.rect_stroke(
row_rect.shrink(1.0),
Rounding::same(theme_rounding(self.palette)),
Stroke::new(1.0, self.palette.hairline()),
);
}
let icon_rect = Rect::from_center_size(
row_rect.left_center() + egui::vec2(16.0, 0.0),
Vec2::new(18.0, 18.0),
);
draw_place_icon(&painter, icon_rect, &place, removable, active, self.palette);
let label_limit = if row_rect.width() > 205.0 { 24 } else { 18 };
painter.text(
row_rect.left_center() + egui::vec2(34.0, 0.0),
Align2::LEFT_CENTER,
truncate_middle(&place.label, label_limit),
FontId::proportional(12.0),
if place.path.is_dir() {
self.palette.text()
} else {
self.palette.muted()
},
);
if row_rect.width() > 168.0 && removable && !place.path.is_dir() {
painter.text(
row_rect.right_center() + egui::vec2(-8.0, 0.0),
Align2::RIGHT_CENTER,
"Missing",
FontId::proportional(10.0),
self.palette.muted(),
);
}
if response.clicked() {
self.navigate_to(place.path.clone(), HistoryMode::Push);
}
if trash_drop_hovered && ui.input(|input| input.pointer.any_released()) {
self.trash_dragged_entries();
}
response.context_menu(|ui| self.place_context_menu(ui, &place, removable));
ui.add_space(1.0);
}
fn place_context_menu(&mut self, ui: &mut egui::Ui, place: &Place, removable: bool) {
context_menu_chrome(ui, self.palette, &place.label, &place.detail);
if removable {
let bookmark_index = bookmark_position(&self.bookmarks, &place.path);
let can_move_up = bookmark_index.is_some_and(|index| index > 0);
let can_move_down = bookmark_index
.is_some_and(|index| index + 1 < self.bookmarks.len().min(BOOKMARK_LIMIT));
context_section(ui, self.palette, "Pinned");
if context_action(
ui,
self.palette,
ChromeIcon::Up,
"Move Up",
"Order",
can_move_up,
false,
) {
self.move_bookmark(&place.path, -1);
ui.close_menu();
}
if context_action(
ui,
self.palette,
ChromeIcon::Down,
"Move Down",
"Order",
can_move_down,
false,
) {
self.move_bookmark(&place.path, 1);
ui.close_menu();
}
if context_action(
ui,
self.palette,
ChromeIcon::Pin,
"Unpin Folder",
"Pinned",
true,
false,
) {
self.remove_bookmark(&place.path);
ui.close_menu();
}
return;
}
if context_action(
ui,
self.palette,
ChromeIcon::Folder,
"Open",
"Enter",
place.path.is_dir(),
false,
) {
self.navigate_to(place.path.clone(), HistoryMode::Push);
ui.close_menu();
}
if context_action(
ui,
self.palette,
ChromeIcon::Pin,
"Pin This Folder",
"Quick Access",
place.path.is_dir() && !self.is_bookmarked(&place.path),
false,
) {
self.add_bookmark(place.path.clone());
ui.close_menu();
}
if context_action(
ui,
self.palette,
ChromeIcon::Terminal,
"Open Terminal Here",
"Shell",
place.path.is_dir(),
false,
) {
match launch_terminal_at(&place.path) {
Ok(()) => self.push_log(format!("TERMINAL // {}", place.path.display())),
Err(error) => self.report_error("Could not open terminal", "TERMINAL", error),
}
ui.close_menu();
}
}
fn activity_panel(&mut self, ui: &mut egui::Ui, _phase: f32) {
ui.horizontal(|ui| {
let visible_count = self.visible_entries().len();
let main_status = if let Some(progress) = &self.operation_progress {
let percent = operation_percent(progress.done_items, progress.total_items) * 100.0;
format!(
"{}% {}",
percent.round() as u32,
operation_detail(progress)
)
} else {
self.status.clone()
};
if !main_status.is_empty() {
ui.label(
RichText::new(truncate_middle(&main_status, 54))
.size(10.8)
.color(if self.operation_progress.is_some() {
self.palette.accent(0.0)
} else {
self.palette.secondary_text()
}),
)
.on_hover_text(main_status);
}
let selected = self.selected_entries();
if !selected.is_empty() {
let summary = selection_summary(&selected);
let detail = if summary.folders > 0 && summary.files == 0 {
format!(
"{} folder{} selected",
summary.folders,
if summary.folders == 1 { "" } else { "s" }
)
} else if summary.folders > 0 {
format!(
"{} selected · {} folders · {}",
summary.count,
summary.folders,
human_size(summary.file_bytes)
)
} else {
format!(
"{} selected · {}",
summary.count,
human_size(summary.file_bytes)
)
};
ui.label(
RichText::new(detail)
.size(10.8)
.color(self.palette.accent(0.0)),
);
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.label(
RichText::new(format!("{visible_count} items"))
.size(10.5)
.color(self.palette.muted()),
);
if let Some(action) = self.undo_stack.last() {
if ui
.add_enabled(
self.operation_rx.is_none(),
status_button(self.palette, "Undo"),
)
.on_hover_text(pretty_value(action.label()))
.clicked()
{
self.undo_last();
}
}
});
});
}
fn rendering_panel(&mut self, ui: &mut egui::Ui, _phase: f32) {
overlay_header(
ui,
self.palette,
self.theme_opacity,
ChromeIcon::Settings,
"View Options",
"Appearance, renderer, resources, and background",
);
ui.add_space(10.0);
overlay_card(
ui,
self.palette,
self.theme_opacity,
ChromeIcon::List,
"View",
|ui| {
ui.horizontal_wrapped(|ui| {
if icon_button(
ui,
self.palette,
ChromeIcon::List,
Some("List"),
self.view_mode == ViewMode::List,
true,
)
.clicked()
{
self.view_mode = ViewMode::List;
self.save_preferences();
}
if icon_button(
ui,
self.palette,
ChromeIcon::Grid,
Some("Grid"),
self.view_mode == ViewMode::Grid,
true,
)
.clicked()
{
self.view_mode = ViewMode::Grid;
self.save_preferences();
}
if icon_button(
ui,
self.palette,
ChromeIcon::FolderOrder,
Some("Folders first"),
self.folders_first,
true,
)
.clicked()
{
self.folders_first = !self.folders_first;
self.save_preferences();
self.refresh_current_dir();
}
if icon_button(
ui,
self.palette,
ChromeIcon::Hidden,
Some("Hidden"),
self.show_hidden,
true,
)
.clicked()
{
self.show_hidden = !self.show_hidden;
self.save_preferences();
self.refresh_current_dir();
}
if icon_button(
ui,
self.palette,
ChromeIcon::Search,
Some("Subfolders"),
self.recursive_search,
true,
)
.clicked()
{
self.recursive_search = !self.recursive_search;
self.save_preferences();
self.update_deep_search();
}
});
ui.horizontal_wrapped(|ui| {
ui.label(RichText::new("Sort").size(12.0).color(self.palette.muted()));
egui::ComboBox::from_id_salt("settings-sort-key")
.selected_text(self.sort_key.label())
.show_ui(ui, |ui| {
for key in SortKey::ALL {
let selected = self.sort_key == key;
if ui.selectable_label(selected, key.label()).clicked() && !selected
{
self.set_sort_key(key);
}
}
});
if icon_button(
ui,
self.palette,
ChromeIcon::Sort,
Some("Descending"),
self.sort_reverse,
true,
)
.clicked()
{
self.sort_reverse = !self.sort_reverse;
self.save_preferences();
self.refresh_current_dir();
}
});
muted_label(
ui,
"Folders first keeps directories grouped above files unless you turn it off.",
);
},
);
overlay_card(
ui,
self.palette,
self.theme_opacity,
ChromeIcon::Settings,
"Appearance",
|ui| {
ui.horizontal_wrapped(|ui| {
egui::ComboBox::from_id_salt("activity-palette")
.selected_text(self.palette.label())
.show_ui(ui, |ui| {
for palette in AppPalette::ALL {
if ui
.selectable_value(&mut self.palette, palette, palette.label())
.clicked()
{
self.save_preferences();
}
}
});
if icon_button(
ui,
self.palette,
ChromeIcon::Settings,
Some("Motion"),
self.motion_enabled,
true,
)
.clicked()
{
self.motion_enabled = !self.motion_enabled;
self.save_preferences();
}
if icon_button(
ui,
self.palette,
ChromeIcon::Search,
Some("Hover preview"),
self.hover_effects_enabled,
true,
)
.clicked()
{
self.hover_effects_enabled = !self.hover_effects_enabled;
self.save_preferences();
}
});
if ui
.add(
egui::Slider::new(&mut self.theme_opacity, 0.55..=1.0)
.text("Theme opacity"),
)
.changed()
{
self.save_preferences();
}
muted_label(
ui,
"Dark is the default startup style. Opacity affects panels and overlays.",
);
},
);
overlay_card(
ui,
self.palette,
self.theme_opacity,
ChromeIcon::Preview,
"Knotwork Plugins",
|ui| {
property_line(ui, "System", KNOTWORK_NAME);
property_line(ui, "Installed", &self.plugins.all().len().to_string());
for plugin in self.plugins.all() {
ui.separator();
property_line(ui, "Plugin", plugin.name);
property_line(ui, "Slot", "Overlay");
property_line(ui, "Shortcut", plugin.shortcut);
muted_label(ui, plugin.description);
ui.add_space(8.0);
let enabled = match plugin.id {
MEDIA_PREVIEW_PLUGIN_ID => self.media_preview_plugin_enabled,
TEXT_PREVIEW_PLUGIN_ID => self.text_preview_plugin_enabled,
_ => false,
};
if icon_button(
ui,
self.palette,
ChromeIcon::Preview,
Some(if enabled { "Enabled" } else { "Enable" }),
enabled,
true,
)
.clicked()
{
match plugin.id {
MEDIA_PREVIEW_PLUGIN_ID => {
self.media_preview_plugin_enabled =
!self.media_preview_plugin_enabled;
if !self.media_preview_plugin_enabled
&& self.media_preview.active_plugin
== Some(PreviewPluginKind::Media)
{
self.close_media_preview_plugin();
}
}
TEXT_PREVIEW_PLUGIN_ID => {
self.text_preview_plugin_enabled =
!self.text_preview_plugin_enabled;
if !self.text_preview_plugin_enabled
&& self.media_preview.active_plugin
== Some(PreviewPluginKind::Text)
{
self.close_media_preview_plugin();
}
}
_ => {}
}
self.save_preferences();
}
}
},
);
overlay_card(
ui,
self.palette,
self.theme_opacity,
ChromeIcon::Properties,
"Background",
|ui| {
let response = ui.add(
egui::TextEdit::singleline(&mut self.background_image_path)
.hint_text("~/Pictures/wallpaper.png")
.desired_width(ui.available_width()),
);
if response.changed() {
self.background_loaded_path = None;
self.background_texture = None;
self.background_error = None;
}
ui.horizontal_wrapped(|ui| {
let apply_clicked = icon_button(
ui,
self.palette,
ChromeIcon::Check,
Some("Apply"),
false,
true,
)
.clicked();
let enter_pressed = response.lost_focus()
&& ui.input(|input| input.key_pressed(egui::Key::Enter));
if apply_clicked || enter_pressed {
self.background_loaded_path = None;
self.save_preferences();
self.status = "Background updated".to_string();
}
if icon_button(
ui,
self.palette,
ChromeIcon::Trash,
Some("Clear"),
false,
true,
)
.clicked()
{
self.background_image_path.clear();
self.background_texture = None;
self.background_loaded_path = None;
self.background_error = None;
self.save_preferences();
self.status = "Background cleared".to_string();
}
});
if ui
.add(
egui::Slider::new(&mut self.background_image_opacity, 0.0..=0.62)
.text("Background opacity"),
)
.changed()
{
self.save_preferences();
}
if let Some(error) = &self.background_error {
ui.label(RichText::new(error).size(11.0).color(self.palette.muted()));
}
},
);
overlay_card(
ui,
self.palette,
self.theme_opacity,
ChromeIcon::List,
"Resource Bounds",
|ui| {
let thumbnail_stats = self.thumbnails.stats();
property_line(
ui,
"Pins",
&format!("{}/{}", self.bookmarks.len(), BOOKMARK_LIMIT),
);
property_line(
ui,
"History",
&format!("{}/{}", self.history.len(), HISTORY_LIMIT),
);
property_line(
ui,
"Undo",
&format!("{}/{}", self.undo_stack.len(), UNDO_LIMIT),
);
property_line(ui, "Logs", &format!("{}/{}", self.logs.len(), LOG_LIMIT));
property_line(
ui,
"Directory",
&format!(
"{}/{} scan entries",
self.entries.len(),
DIRECTORY_ENTRY_LIMIT
),
);
if let Some(results) = &self.deep_results {
property_line(
ui,
"Search",
&format!(
"{}/{} results / {} dirs",
results.entries.len(),
results.result_limit,
results.visited_dirs
),
);
} else {
property_line(ui, "Search", &format!("0/{} results", SEARCH_LIMIT));
}
property_line(
ui,
"Operations",
&format!(
"{} sources / {} items / {} depth",
OPERATION_SOURCE_LIMIT, OPERATION_ITEM_LIMIT, OPERATION_DEPTH_LIMIT
),
);
property_line(
ui,
"Launchers",
&format!(
"{} active / {} reapers",
active_child_reapers(),
CHILD_REAPER_LIMIT
),
);
property_line(
ui,
"Thumbnails",
&format!(
"{} cached / {} pending / {} queued / {} active",
thumbnail_stats.cache_entries,
thumbnail_stats.pending,
thumbnail_stats.queued,
thumbnail_stats.active_workers
),
);
property_line(
ui,
"Thumb limits",
&format!(
"{} cache / {} queue / {} workers",
thumbnail_stats.cache_limit,
thumbnail_stats.queue_limit,
thumbnail_stats.worker_limit
),
);
property_line(
ui,
"Preview",
&format!(
"{} cache / {} image / {} px / async worker",
MEDIA_PREVIEW_IMAGE_CACHE_LIMIT,
human_size(MEDIA_PREVIEW_IMAGE_BYTES_LIMIT),
MEDIA_PREVIEW_IMAGE_MAX_EDGE
),
);
property_line(
ui,
"Text preview",
&format!(
"{} / {} lines / async worker",
human_size(TEXT_PREVIEW_BYTES_LIMIT),
TEXT_PREVIEW_LINE_LIMIT
),
);
},
);
overlay_card(
ui,
self.palette,
self.theme_opacity,
ChromeIcon::Terminal,
"Renderer",
|ui| {
property_line(ui, "GPU", &pretty_value(&self.runtime.gpu_mode));
property_line(ui, "Renderer", &pretty_value(&self.runtime.renderer));
property_line(ui, "Shader", &pretty_value(&self.runtime.shader));
property_line(ui, "WGPU", &pretty_value(&self.runtime.wgpu_backends));
property_line(ui, "Power", &pretty_value(&self.runtime.wgpu_power));
property_line(ui, "Latency", &pretty_value(&self.runtime.frame_latency));
property_line(ui, "VSync", if self.runtime.vsync { "On" } else { "Off" });
},
);
}
fn file_panel(&mut self, ui: &mut egui::Ui, phase: f32, now: f64) {
let response = egui::Frame::none()
.inner_margin(egui::Margin::symmetric(12.0, 6.0))
.show(ui, |ui| {
self.folder_overview(ui);
ui.add_space(6.0);
if let Some(progress) = &self.operation_progress {
let title =
pretty_value(self.operation_title.as_deref().unwrap_or("Operation"));
progress_card(ui, &title, progress, phase, self.palette);
ui.add_space(8.0);
}
let entries = self.visible_entries();
if entries.is_empty() {
empty_state(
ui,
&self.search_query,
self.recursive_search,
self.deep_search_active(),
self.palette,
);
self.empty_state_actions(ui);
return;
}
if self.view_mode == ViewMode::List {
self.list_header(ui);
ui.add_space(2.0);
}
let scroll_height = (ui.available_height() - 4.0).max(140.0);
egui::ScrollArea::vertical()
.auto_shrink([false, false])
.max_height(scroll_height)
.show(ui, |ui| match self.view_mode {
ViewMode::List => self.list_view(ui, entries, phase, now),
ViewMode::Grid => self.grid_view(ui, entries, phase, now),
});
});
let external_drag = ui.ctx().input(|input| !input.raw.hovered_files.is_empty());
if external_drag && !self.dropped_on_pinned_folders(ui.ctx()) {
draw_drop_outline(
ui.painter(),
response.response.rect.shrink(3.0),
self.palette,
"Drop to copy here",
);
}
}
fn folder_overview(&self, ui: &mut egui::Ui) {
ui.horizontal(|ui| {
let (icon_rect, _) = ui.allocate_exact_size(Vec2::splat(30.0), Sense::hover());
draw_folder_summary_icon(ui.painter(), icon_rect);
ui.add_space(3.0);
ui.horizontal(|ui| {
let title = folder_title(&self.current_dir);
ui.label(
RichText::new(truncate_middle(&title, 36))
.size(15.5)
.strong()
.color(self.palette.text()),
)
.on_hover_text(path_for_display(&self.current_dir));
if self.is_bookmarked(&self.current_dir) {
inline_chrome_icon(ui, self.palette, ChromeIcon::Pin, self.palette.accent(0.0));
}
});
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.label(RichText::new(self.folder_detail()).size(10.8).color(
if self.deep_search_active() {
self.palette.accent(0.0)
} else {
self.palette.muted()
},
));
});
});
ui.add_space(5.0);
let (divider, _) =
ui.allocate_exact_size(Vec2::new(ui.available_width(), 1.0), Sense::hover());
ui.painter().line_segment(
[divider.left_center(), divider.right_center()],
Stroke::new(1.0, self.palette.hairline()),
);
}
fn empty_state_actions(&mut self, ui: &mut egui::Ui) {
ui.add_space(10.0);
ui.horizontal_centered(|ui| {
if self.search_query.trim().is_empty() {
if icon_button(
ui,
self.palette,
ChromeIcon::FolderPlus,
Some("New Folder"),
false,
true,
)
.clicked()
{
self.open_new_folder_dialog();
}
if icon_button(
ui,
self.palette,
ChromeIcon::FilePlus,
Some("New File"),
false,
true,
)
.clicked()
{
self.open_new_file_dialog();
}
} else {
if icon_button(
ui,
self.palette,
ChromeIcon::Search,
Some("Clear Search"),
false,
true,
)
.clicked()
{
self.search_query.clear();
self.update_deep_search();
}
if icon_button(
ui,
self.palette,
ChromeIcon::Search,
Some("Subfolders"),
self.recursive_search,
true,
)
.clicked()
{
self.recursive_search = !self.recursive_search;
self.save_preferences();
self.update_deep_search();
}
}
});
}
fn folder_detail(&self) -> String {
if self.deep_search_active() {
return "Searching subfolders within configured limits".to_string();
}
if let Some(results) = &self.deep_results {
return format!(
"{} matches across {} folders{}",
results.entries.len(),
results.visited_dirs,
if results.limited() { " · capped" } else { "" }
);
}
if !self.search_query.trim().is_empty() {
return "Filtered in the current folder".to_string();
}
self.snapshot
.as_ref()
.map(|snapshot| {
format!(
"{} folders · {} files · {}{}",
snapshot.directory_count,
snapshot.file_count,
human_size(snapshot.total_size),
if snapshot.scan_limited {
" · scan capped"
} else {
""
}
)
})
.unwrap_or_else(|| "Loading folder details".to_string())
}
fn list_view(&mut self, ui: &mut egui::Ui, entries: Vec<FileEntry>, phase: f32, now: f64) {
for (index, entry) in entries.into_iter().enumerate() {
self.entry_row(ui, entry, index, phase, now);
}
}
fn list_header(&mut self, ui: &mut egui::Ui) {
let (rect, _) =
ui.allocate_exact_size(Vec2::new(ui.available_width(), 28.0), Sense::hover());
let painter = ui.painter_at(rect);
let header_rect = rect.shrink2(egui::vec2(0.0, 1.0));
painter.rect_filled(
header_rect,
Rounding::same(theme_rounding(self.palette) + 3.0),
self.palette.recessed_fill(),
);
painter.rect_stroke(
header_rect,
Rounding::same(theme_rounding(self.palette) + 3.0),
Stroke::new(1.0, self.palette.hairline()),
);
painter.line_segment(
[header_rect.left_bottom(), header_rect.right_bottom()],
Stroke::new(1.0, self.palette.hover_fill()),
);
let columns = list_columns(rect);
draw_list_column_rules(&painter, rect, columns, self.palette);
let mut clicked_sort = None;
for (label, key, x, cell) in [
(
"Name",
SortKey::Name,
columns.name,
Rect::from_min_max(
Pos2::new(rect.left() + 4.0, rect.top()),
Pos2::new(columns.kind - 8.0, rect.bottom()),
),
),
(
"Kind",
SortKey::Kind,
columns.kind,
Rect::from_min_max(
Pos2::new(columns.kind - 8.0, rect.top()),
Pos2::new(columns.size - 8.0, rect.bottom()),
),
),
(
"Size",
SortKey::Size,
columns.size,
Rect::from_min_max(
Pos2::new(columns.size - 8.0, rect.top()),
Pos2::new(columns.modified - 8.0, rect.bottom()),
),
),
(
"Modified",
SortKey::Modified,
columns.modified,
Rect::from_min_max(
Pos2::new(columns.modified - 8.0, rect.top()),
Pos2::new(rect.right() - 4.0, rect.bottom()),
),
),
] {
let response = ui.interact(cell, ui.id().with(("list-header", label)), Sense::click());
let active = self.sort_key == key;
if response.hovered() || active {
painter.rect_filled(
cell.shrink2(egui::vec2(3.0, 4.0)),
Rounding::same(theme_rounding(self.palette) + 3.0),
if active {
self.palette.selected_fill()
} else {
self.palette.hover_fill()
},
);
}
let color = if active {
self.palette.accent(0.0)
} else if response.hovered() {
self.palette.text()
} else {
self.palette.muted()
};
painter.text(
Pos2::new(x, rect.center().y),
Align2::LEFT_CENTER,
label,
FontId::proportional(11.0),
color,
);
if active {
draw_sort_indicator(
&painter,
Pos2::new(x + label.len() as f32 * 6.3 + 8.0, rect.center().y),
self.sort_reverse,
color,
);
}
if response.clicked() {
clicked_sort = Some(key);
}
}
if let Some(key) = clicked_sort {
self.set_sort_key(key);
}
}
fn entry_row(
&mut self,
ui: &mut egui::Ui,
entry: FileEntry,
index: usize,
phase: f32,
now: f64,
) {
let selected = self.selected.contains(&entry.path);
let focused = self.selection_focus.as_ref() == Some(&entry.path);
let (rect, response) = ui.allocate_exact_size(
Vec2::new(ui.available_width(), 40.0),
Sense::click_and_drag(),
);
let painter = ui.painter_at(rect);
let row_rect = rect.shrink2(egui::vec2(1.0, 2.0));
let accent = self.palette.accent(phase);
if selected {
painter.rect_filled(
row_rect,
Rounding::same(theme_rounding(self.palette) + 5.0),
self.palette.selected_fill(),
);
painter.rect_filled(
Rect::from_min_max(
row_rect.left_top(),
row_rect.left_bottom() + egui::vec2(3.0, 0.0),
),
Rounding::same(theme_rounding(self.palette) + 5.0),
accent,
);
} else if response.hovered() {
painter.rect_filled(
row_rect,
Rounding::same(theme_rounding(self.palette) + 5.0),
self.palette.hover_fill(),
);
} else if index % 2 == 1 {
painter.rect_filled(
row_rect,
Rounding::same(theme_rounding(self.palette) + 5.0),
self.palette.row_alt_fill(),
);
}
if focused {
painter.rect_stroke(
row_rect.shrink(1.0),
Rounding::same(theme_rounding(self.palette) + 5.0),
Stroke::new(1.4, accent),
);
}
let columns = list_columns(rect);
draw_list_row_rules(&painter, rect, columns, self.palette);
let muted = if selected {
self.palette.secondary_text()
} else {
self.palette.muted()
};
let media_class = media_class_for_path(
&entry.path,
entry.kind == EntryKind::Directory,
entry.kind == EntryKind::Symlink,
);
if response.hovered() {
self.hover.observe(&entry, media_class, now);
self.hover_animation_active = self.hover_effects_enabled;
}
self.observe_entry_drag_source(&response, &entry);
let hover_intent = response.hovered()
&& self.hover_effects_enabled
&& self
.hover
.intent_ready(&entry.path, now, VIDEO_HOVER_INTENT_SECONDS);
let thumb_rect = Rect::from_center_size(
Pos2::new(columns.name + 16.0, rect.center().y),
Vec2::new(30.0, 28.0),
);
let tile_visible = ui.clip_rect().intersects(rect);
let modified_stamp = thumbnail_modified_stamp(entry.modified);
if tile_visible {
self.thumbnails
.request_poster(&entry.path, entry.size, modified_stamp);
if hover_intent {
self.thumbnails
.request_hover_strip(&entry.path, entry.size, modified_stamp);
}
}
if let Some(view) = tile_visible
.then(|| {
self.thumbnails.thumbnail_for(
&entry.path,
entry.size,
modified_stamp,
hover_intent,
phase,
)
})
.flatten()
{
paint_thumbnail(
&painter,
thumb_rect,
view,
response.hovered() && self.hover_effects_enabled,
accent,
self.palette,
);
if media_class == MediaClass::Video {
draw_video_badge(&painter, thumb_rect, view.animated);
}
} else {
draw_file_type_icon(
&painter,
thumb_rect,
media_class,
response.hovered() && self.hover_effects_enabled,
self.thumbnails.is_loading(&entry.path),
self.palette,
);
}
let name_chars = ((columns.kind - columns.name - 92.0) / 7.0)
.floor()
.clamp(10.0, 64.0) as usize;
let name_text = truncate_middle(&entry.name, name_chars);
painter.text(
Pos2::new(columns.name + 38.0, rect.center().y),
Align2::LEFT_CENTER,
&name_text,
FontId::proportional(13.0),
self.palette.text(),
);
let emblem_x =
(columns.name + 40.0 + name_text.chars().count() as f32 * 6.9).min(columns.kind - 48.0);
draw_entry_emblems(
&painter,
Pos2::new(emblem_x, rect.center().y),
&entry,
self.palette,
);
if self.preview_plugin_kind_enabled_for_entry(&entry) && (response.hovered() || selected) {
draw_inline_preview_badge(
&painter,
Rect::from_center_size(
Pos2::new(columns.kind - 25.0, rect.center().y),
Vec2::splat(20.0),
),
self.palette,
true,
);
}
painter.text(
Pos2::new(columns.kind, rect.center().y),
Align2::LEFT_CENTER,
entry.type_label(),
FontId::proportional(11.0),
muted,
);
painter.text(
Pos2::new(columns.size, rect.center().y),
Align2::LEFT_CENTER,
if entry.kind == EntryKind::Directory {
"--".to_string()
} else {
human_size(entry.size)
},
FontId::proportional(11.0),
muted,
);
painter.text(
Pos2::new(columns.modified, rect.center().y),
Align2::LEFT_CENTER,
modified_label(entry.modified),
FontId::proportional(11.0),
muted,
);
if response.clicked() {
let modifiers = ui.input(|input| input.modifiers);
self.select_entry_with_modifiers(entry.path.clone(), modifiers);
}
if response.double_clicked() {
self.open_entry(&entry);
}
if response.secondary_clicked() && !selected {
self.set_single_selection(entry.path.clone());
}
response.context_menu(|ui| self.selection_context_menu(ui));
ui.add_space(1.0);
}
fn grid_view(&mut self, ui: &mut egui::Ui, entries: Vec<FileEntry>, phase: f32, now: f64) {
let available_width = ui.available_width().max(1.0);
let spacing = 10.0;
let target_width = 140.0;
let columns = ((available_width + spacing) / (target_width + spacing))
.floor()
.max(1.0) as usize;
let tile_width = ((available_width - spacing * columns.saturating_sub(1) as f32)
/ columns as f32)
.clamp(116.0, 164.0);
let tile_height = 154.0;
egui::Grid::new("file-grid")
.num_columns(columns)
.spacing(Vec2::new(spacing, 10.0))
.show(ui, |ui| {
for (index, entry) in entries.into_iter().enumerate() {
self.entry_tile(ui, entry, Vec2::new(tile_width, tile_height), phase, now);
if (index + 1) % columns == 0 {
ui.end_row();
}
}
});
}
fn entry_tile(
&mut self,
ui: &mut egui::Ui,
entry: FileEntry,
size: Vec2,
phase: f32,
now: f64,
) {
let selected = self.selected.contains(&entry.path);
let focused = self.selection_focus.as_ref() == Some(&entry.path);
let (rect, response) = ui.allocate_exact_size(size, Sense::click_and_drag());
let painter = ui.painter_at(rect);
let accent = self.palette.accent(phase);
let tile_rect = rect.shrink(2.0);
painter.rect_filled(
tile_rect,
Rounding::same(theme_rounding(self.palette) + 5.0),
if selected {
self.palette.selected_fill()
} else if response.hovered() {
self.palette.hover_fill()
} else {
Color32::TRANSPARENT
},
);
painter.rect_stroke(
tile_rect,
Rounding::same(theme_rounding(self.palette) + 5.0),
Stroke::new(
if focused { 1.6 } else { 1.0 },
if selected {
self.palette.accent(phase)
} else if response.hovered() && self.hover_effects_enabled {
accent
} else if response.hovered() {
self.palette.border()
} else {
Color32::TRANSPARENT
},
),
);
let media_class = media_class_for_path(
&entry.path,
entry.kind == EntryKind::Directory,
entry.kind == EntryKind::Symlink,
);
if response.hovered() {
self.hover.observe(&entry, media_class, now);
self.hover_animation_active = self.hover_effects_enabled;
}
self.observe_entry_drag_source(&response, &entry);
let hover_intent = response.hovered()
&& self.hover_effects_enabled
&& self
.hover
.intent_ready(&entry.path, now, VIDEO_HOVER_INTENT_SECONDS);
let icon_rect = Rect::from_center_size(
Pos2::new(rect.center().x, rect.top() + 46.0),
Vec2::new((rect.width() - 28.0).min(110.0), 74.0),
);
let tile_visible = ui.clip_rect().intersects(rect);
let modified_stamp = thumbnail_modified_stamp(entry.modified);
if tile_visible {
self.thumbnails
.request_poster(&entry.path, entry.size, modified_stamp);
if hover_intent {
self.thumbnails
.request_hover_strip(&entry.path, entry.size, modified_stamp);
}
}
if let Some(view) = tile_visible
.then(|| {
self.thumbnails.thumbnail_for(
&entry.path,
entry.size,
modified_stamp,
hover_intent,
phase,
)
})
.flatten()
{
paint_thumbnail(
&painter,
icon_rect,
view,
response.hovered() && self.hover_effects_enabled,
accent,
self.palette,
);
if media_class == MediaClass::Video {
draw_video_badge(&painter, icon_rect, view.animated);
}
} else {
draw_file_type_icon(
&painter,
icon_rect,
media_class,
response.hovered() && self.hover_effects_enabled,
self.thumbnails.is_loading(&entry.path),
self.palette,
);
}
draw_entry_emblems(
&painter,
tile_rect.left_top() + egui::vec2(8.0, 14.0),
&entry,
self.palette,
);
if self.preview_plugin_kind_enabled_for_entry(&entry) && (response.hovered() || selected) {
draw_inline_preview_badge(
&painter,
Rect::from_center_size(
icon_rect.right_top() + egui::vec2(-10.0, 10.0),
Vec2::splat(22.0),
),
self.palette,
true,
);
}
let name_limit = (((rect.width() - 20.0) / 6.8).floor() as usize).clamp(12, 24);
painter.text(
Pos2::new(rect.center().x, rect.top() + 93.0),
Align2::CENTER_TOP,
truncate_middle(&entry.name, name_limit),
FontId::proportional(13.2),
self.palette.text(),
);
painter.text(
Pos2::new(rect.center().x, rect.top() + 123.0),
Align2::CENTER_TOP,
if entry.kind == EntryKind::Directory {
entry.type_label().to_string()
} else {
format!("{} · {}", entry.type_label(), human_size(entry.size))
},
FontId::proportional(10.5),
self.palette.muted(),
);
if response.clicked() {
let modifiers = ui.input(|input| input.modifiers);
self.select_entry_with_modifiers(entry.path.clone(), modifiers);
}
if response.double_clicked() {
self.open_entry(&entry);
}
if response.secondary_clicked() && !selected {
self.set_single_selection(entry.path.clone());
}
response.context_menu(|ui| self.selection_context_menu(ui));
}
fn selection_context_menu(&mut self, ui: &mut egui::Ui) {
let selected_count = self.selected.len().max(1);
let primary = self.primary_selection();
let title = if selected_count == 1 {
primary
.as_ref()
.map(|entry| entry.name.as_str())
.unwrap_or("Selection")
} else {
"Selection"
};
let detail = if selected_count == 1 {
primary
.as_ref()
.map(|entry| entry.type_label())
.unwrap_or("Item")
.to_string()
} else {
format!("{selected_count} items")
};
context_menu_chrome(ui, self.palette, title, &detail);
context_section(ui, self.palette, "Open");
if context_action(
ui,
self.palette,
ChromeIcon::Folder,
"Open",
"Enter",
true,
false,
) {
self.open_selection();
ui.close_menu();
}
if context_action(
ui,
self.palette,
ChromeIcon::OpenExternal,
"Open Externally",
"Shift+Enter",
true,
false,
) {
self.open_selection_externally();
ui.close_menu();
}
if context_action(
ui,
self.palette,
ChromeIcon::Preview,
"Preview Inline",
"Space",
self.previewable_selection().is_some(),
false,
) {
self.open_media_preview_plugin();
ui.close_menu();
}
if let Some(entry) = primary.clone() {
if context_action(
ui,
self.palette,
ChromeIcon::Up,
if entry.kind == EntryKind::Directory {
"Show in Parent"
} else {
"Show in Folder"
},
"Parent",
selected_count == 1 && entry.path.parent().is_some(),
false,
) {
self.show_entry_in_folder(&entry);
ui.close_menu();
}
}
if context_action(
ui,
self.palette,
ChromeIcon::Window,
"Open in New Window",
"Window",
true,
false,
) {
let target = location_target_for_entry(primary.as_ref(), &self.current_dir);
match launch_new_window(&target) {
Ok(()) => self.push_log(format!("NEW WINDOW // {}", target.display())),
Err(error) => self.report_error("Could not open new window", "NEW WINDOW", error),
}
ui.close_menu();
}
if context_action(
ui,
self.palette,
ChromeIcon::Terminal,
"Open Terminal Here",
"Shell",
true,
false,
) {
let target = location_target_for_entry(primary.as_ref(), &self.current_dir);
match launch_terminal_at(&target) {
Ok(()) => self.push_log(format!("TERMINAL // {}", target.display())),
Err(error) => self.report_error("Could not open terminal", "TERMINAL", error),
}
ui.close_menu();
}
context_section(ui, self.palette, "Selection");
if context_action(
ui,
self.palette,
ChromeIcon::List,
"Invert Selection",
"Visible",
true,
false,
) {
self.invert_visible_selection();
ui.close_menu();
}
if context_action(
ui,
self.palette,
ChromeIcon::FolderPlus,
"Select Folders",
"Visible",
true,
false,
) {
self.select_visible_kind(true);
ui.close_menu();
}
if context_action(
ui,
self.palette,
ChromeIcon::FilePlus,
"Select Files",
"Visible",
true,
false,
) {
self.select_visible_kind(false);
ui.close_menu();
}
let selected_folder_count = self
.selected_entries()
.iter()
.filter(|entry| entry.kind == EntryKind::Directory)
.count();
if selected_folder_count > 0 {
context_section(ui, self.palette, "Pinning");
if selected_folder_count == 1 {
let target = self
.selected_entries()
.into_iter()
.find(|entry| entry.kind == EntryKind::Directory)
.map(|entry| entry.path);
if let Some(target) = target {
if self.is_bookmarked(&target) {
if context_action(
ui,
self.palette,
ChromeIcon::Pin,
"Unpin Folder",
"Pinned",
true,
false,
) {
self.remove_bookmark(&target);
ui.close_menu();
}
} else if context_action(
ui,
self.palette,
ChromeIcon::Pin,
"Pin Folder",
"Quick Access",
true,
false,
) {
self.add_bookmark(target);
ui.close_menu();
}
}
} else if context_action(
ui,
self.palette,
ChromeIcon::Pin,
"Pin Selected Folders",
"Quick Access",
true,
false,
) {
self.pin_selected_folders();
ui.close_menu();
}
}
context_divider(ui, self.palette);
context_section(ui, self.palette, "Edit");
if context_action(
ui,
self.palette,
ChromeIcon::Copy,
"Copy",
"Ctrl+C",
true,
false,
) {
self.copy_selection(ClipboardMode::Copy);
ui.close_menu();
}
if context_action(
ui,
self.palette,
ChromeIcon::Copy,
if selected_count == 1 {
"Copy Path"
} else {
"Copy Paths"
},
"Path",
true,
false,
) {
self.copy_selection_text(ui.ctx(), false);
ui.close_menu();
}
if context_action(
ui,
self.palette,
ChromeIcon::Copy,
if selected_count == 1 {
"Copy Name"
} else {
"Copy Names"
},
"Name",
true,
false,
) {
self.copy_selection_text(ui.ctx(), true);
ui.close_menu();
}
if context_action(
ui,
self.palette,
ChromeIcon::Cut,
"Cut",
"Ctrl+X",
true,
false,
) {
self.copy_selection(ClipboardMode::Cut);
ui.close_menu();
}
if context_action(
ui,
self.palette,
ChromeIcon::Paste,
"Paste Into This Folder",
"Ctrl+V",
self.clipboard.is_some(),
false,
) {
self.paste_clipboard();
ui.close_menu();
}
if context_action(
ui,
self.palette,
ChromeIcon::FilePlus,
"New File",
"Create",
true,
false,
) {
self.open_new_file_dialog();
ui.close_menu();
}
if context_action(
ui,
self.palette,
ChromeIcon::FolderPlus,
"New Folder",
"Ctrl+N",
true,
false,
) {
self.open_new_folder_dialog();
ui.close_menu();
}
if context_action(
ui,
self.palette,
ChromeIcon::Duplicate,
"Duplicate",
"Ctrl+D",
true,
false,
) {
self.duplicate_selection();
ui.close_menu();
}
context_divider(ui, self.palette);
context_section(ui, self.palette, "Danger Zone");
if context_action(
ui,
self.palette,
ChromeIcon::Rename,
"Rename",
"F2",
selected_count == 1,
false,
) {
self.open_rename_dialog();
ui.close_menu();
}
if context_action(
ui,
self.palette,
ChromeIcon::Trash,
"Move to Trash",
"Delete",
true,
false,
) {
self.trash_selection();
ui.close_menu();
}
if context_action(
ui,
self.palette,
ChromeIcon::Trash,
"Delete Forever...",
"Shift+Del",
true,
true,
) {
self.request_delete_selection(true);
ui.close_menu();
}
}
fn properties_panel(&mut self, ui: &mut egui::Ui, phase: f32) {
let selected = self.selected_entries();
let (title, detail) = if selected.is_empty() {
(
folder_title(&self.current_dir),
path_for_display(&self.current_dir),
)
} else if selected.len() == 1 {
(
selected[0].name.clone(),
selected[0].type_label().to_string(),
)
} else {
("Selection".to_string(), format!("{} items", selected.len()))
};
overlay_header(
ui,
self.palette,
self.theme_opacity,
ChromeIcon::Properties,
&title,
&detail,
);
ui.add_space(10.0);
overlay_card(
ui,
self.palette,
self.theme_opacity,
ChromeIcon::Properties,
"Summary",
|ui| {
self.properties_summary(ui, &selected);
},
);
overlay_card(
ui,
self.palette,
self.theme_opacity,
ChromeIcon::Properties,
"Metadata",
|ui| {
if selected.is_empty() {
self.directory_properties(ui);
} else if selected.len() == 1 {
self.single_properties(ui, &selected[0]);
} else {
self.multi_properties(ui, &selected);
}
},
);
overlay_card(
ui,
self.palette,
self.theme_opacity,
ChromeIcon::Open,
"Actions",
|ui| {
self.action_buttons(ui);
},
);
if selected.is_empty() {
ui.add_space(2.0);
}
if let Some(progress) = &self.operation_progress {
let title = pretty_value(self.operation_title.as_deref().unwrap_or("Operation"));
overlay_card(
ui,
self.palette,
self.theme_opacity,
ChromeIcon::Refresh,
"Activity",
|ui| {
progress_card(ui, &title, progress, phase, self.palette);
},
);
}
}
fn properties_summary(&self, ui: &mut egui::Ui, selected: &[FileEntry]) {
if selected.is_empty() {
let detail = self
.snapshot
.as_ref()
.map(|snapshot| {
format!(
"{} folders · {} files · {}",
snapshot.directory_count,
snapshot.file_count,
human_size(snapshot.total_size)
)
})
.unwrap_or_else(|| "Current folder".to_string());
summary_row(
ui,
self.palette,
ChromeIcon::FolderPlus,
&folder_title(&self.current_dir),
&detail,
&path_for_display(&self.current_dir),
);
} else if selected.len() == 1 {
let entry = &selected[0];
let detail = if entry.kind == EntryKind::Directory {
format!("{} · folder size not scanned", entry.type_label())
} else {
format!("{} · {}", entry.type_label(), human_size(entry.size))
};
summary_row(
ui,
self.palette,
ChromeIcon::Properties,
&entry.name,
&detail,
&path_for_display(&entry.path),
);
} else {
let summary = selection_summary(selected);
summary_row(
ui,
self.palette,
ChromeIcon::List,
&format!("{} selected", summary.count),
&format!(
"{} files · {} folders · {} known",
summary.files,
summary.folders,
human_size(summary.file_bytes)
),
"Folder sizes are not scanned recursively",
);
}
}
fn directory_properties(&self, ui: &mut egui::Ui) {
property_section_title(ui, "Folder Details");
property_line(ui, "Location", &path_for_display(&self.current_dir));
if let Some(snapshot) = &self.snapshot {
property_section_title(ui, "Contents");
property_line(ui, "Folders", &snapshot.directory_count.to_string());
property_line(ui, "Files", &snapshot.file_count.to_string());
property_line(ui, "Visible", &snapshot.entries.len().to_string());
property_line(ui, "Hidden", &snapshot.hidden_entries.to_string());
property_line(ui, "Size", &human_size(snapshot.total_size));
property_line(
ui,
"Scan limit",
if snapshot.scan_limited {
"Reached"
} else {
"Open"
},
);
if snapshot.scan_limited {
property_line(ui, "Scanned", &snapshot.total_entries.to_string());
property_line(ui, "Entry cap", &snapshot.entry_limit.to_string());
}
if snapshot.error_limited {
property_line(ui, "Warning cap", &snapshot.error_limit.to_string());
}
}
}
fn single_properties(&self, ui: &mut egui::Ui, entry: &FileEntry) {
property_section_title(ui, "Item Details");
property_line(ui, "Name", &entry.name);
property_line(ui, "Path", &path_for_display(&entry.path));
property_line(ui, "Type", entry.type_label());
property_line(ui, "Size", &human_size(entry.size));
property_section_title(ui, "Metadata");
property_line(ui, "Modified", &absolute_time_label(entry.modified));
property_line(ui, "Age", &modified_label(entry.modified));
property_line(ui, "Permissions", &entry.permissions);
property_line(ui, "Read only", if entry.readonly { "Yes" } else { "No" });
if let Some(target) = &entry.symlink_target {
property_line(ui, "Link target", &target.display().to_string());
}
}
fn multi_properties(&self, ui: &mut egui::Ui, entries: &[FileEntry]) {
let summary = selection_summary(entries);
property_section_title(ui, "Selection Details");
property_line(ui, "Selected", &summary.count.to_string());
property_line(ui, "Folders", &summary.folders.to_string());
property_line(ui, "Files", &summary.files.to_string());
property_line(ui, "Known file size", &human_size(summary.file_bytes));
if summary.folders > 0 {
property_line(ui, "Folder size", "Not scanned recursively");
}
}
fn action_buttons(&mut self, ui: &mut egui::Ui) {
ui.horizontal_wrapped(|ui| {
if icon_button(
ui,
self.palette,
ChromeIcon::Open,
Some("Open"),
false,
true,
)
.clicked()
{
self.open_selection();
}
if icon_button(
ui,
self.palette,
ChromeIcon::Copy,
Some("Copy"),
false,
true,
)
.clicked()
{
self.copy_selection(ClipboardMode::Copy);
}
if icon_button(ui, self.palette, ChromeIcon::Cut, Some("Cut"), false, true).clicked() {
self.copy_selection(ClipboardMode::Cut);
}
if icon_button(
ui,
self.palette,
ChromeIcon::Paste,
Some("Paste"),
false,
self.clipboard.is_some(),
)
.clicked()
{
self.paste_clipboard();
}
if icon_button(
ui,
self.palette,
ChromeIcon::FilePlus,
Some("New File"),
false,
true,
)
.clicked()
{
self.open_new_file_dialog();
}
if icon_button(
ui,
self.palette,
ChromeIcon::Rename,
Some("Rename"),
false,
self.selected.len() == 1,
)
.clicked()
{
self.open_rename_dialog();
}
if icon_button(
ui,
self.palette,
ChromeIcon::Trash,
Some("Trash"),
false,
!self.selected.is_empty(),
)
.clicked()
{
self.trash_selection();
}
});
}
fn dialogs(&mut self, ctx: &egui::Context, phase: f32) {
if !self.dialogs.is_open() {
return;
}
modal_backdrop(ctx, self.palette);
if self.dialogs.error.is_some() {
self.error_dialog(ctx);
} else if self.dialogs.delete_confirm.is_some() {
self.delete_dialog(ctx, phase);
} else if self.dialogs.rename_open {
self.rename_dialog(ctx, phase);
} else if self.dialogs.new_file_open {
self.new_file_dialog(ctx, phase);
} else {
self.new_folder_dialog(ctx, phase);
}
}
fn new_folder_dialog(&mut self, ctx: &egui::Context, _phase: f32) {
if !self.dialogs.new_folder_open {
return;
}
let mut open = self.dialogs.new_folder_open;
let mut create_clicked = false;
let mut cancel_clicked = false;
egui::Window::new("New Folder")
.id(egui::Id::new("new-folder-dialog"))
.order(egui::Order::Foreground)
.open(&mut open)
.collapsible(false)
.resizable(false)
.default_width(390.0)
.min_width(340.0)
.frame(window_frame(self.palette, self.theme_opacity))
.show(ctx, |ui| {
overlay_header(
ui,
self.palette,
self.theme_opacity,
ChromeIcon::FolderPlus,
"New Folder",
&path_for_display(&self.current_dir),
);
ui.add_space(10.0);
overlay_card(
ui,
self.palette,
self.theme_opacity,
ChromeIcon::FolderPlus,
"Folder Name",
|ui| {
field_label(ui, "Name");
let response = ui.add(
egui::TextEdit::singleline(&mut self.dialogs.new_folder_name)
.id_salt("new-folder-name")
.desired_width(ui.available_width()),
);
if self.dialogs.new_folder_focus_requested {
let selection_end = self.dialogs.new_folder_name.chars().count();
focus_text_edit(ui, &response, selection_end);
self.dialogs.new_folder_focus_requested = false;
}
let validation = validate_child_name(
&self.current_dir,
&self.dialogs.new_folder_name,
true,
None,
);
validation_status(ui, self.palette, &validation, "Ready to create folder");
ui.add_space(10.0);
ui.horizontal_wrapped(|ui| {
if icon_button(
ui,
self.palette,
ChromeIcon::FolderPlus,
Some("Create"),
validation.accepted,
validation.accepted,
)
.clicked()
|| (response.lost_focus()
&& ui.input(|input| input.key_pressed(egui::Key::Enter))
&& validation.accepted)
{
create_clicked = true;
}
if icon_button(
ui,
self.palette,
ChromeIcon::Close,
Some("Cancel"),
false,
true,
)
.clicked()
{
cancel_clicked = true;
}
});
},
);
});
if ctx.input(|input| input.key_pressed(egui::Key::Escape)) {
cancel_clicked = true;
create_clicked = false;
}
self.dialogs.new_folder_open = open && !cancel_clicked;
if create_clicked {
match create_folder(&self.current_dir, &self.dialogs.new_folder_name) {
Ok(path) => {
self.push_undo(undo_for_created_folder(path.clone()));
self.set_single_selection(path.clone());
self.push_log(format!("NEW FOLDER // {}", path.display()));
self.dialogs.new_folder_open = false;
self.refresh_current_dir();
}
Err(error) => self.dialogs.error = Some(error),
}
}
}
fn new_file_dialog(&mut self, ctx: &egui::Context, _phase: f32) {
if !self.dialogs.new_file_open {
return;
}
let mut open = self.dialogs.new_file_open;
let mut create_clicked = false;
let mut cancel_clicked = false;
egui::Window::new("New File")
.id(egui::Id::new("new-file-dialog"))
.order(egui::Order::Foreground)
.open(&mut open)
.collapsible(false)
.resizable(false)
.default_width(390.0)
.min_width(340.0)
.frame(window_frame(self.palette, self.theme_opacity))
.show(ctx, |ui| {
overlay_header(
ui,
self.palette,
self.theme_opacity,
ChromeIcon::FilePlus,
"New File",
&path_for_display(&self.current_dir),
);
ui.add_space(10.0);
overlay_card(
ui,
self.palette,
self.theme_opacity,
ChromeIcon::FilePlus,
"File Name",
|ui| {
field_label(ui, "Name");
let response = ui.add(
egui::TextEdit::singleline(&mut self.dialogs.new_file_name)
.id_salt("new-file-name")
.desired_width(ui.available_width()),
);
if self.dialogs.new_file_focus_requested {
let selection_end =
rename_selection_end(&self.dialogs.new_file_name, false);
focus_text_edit(ui, &response, selection_end);
self.dialogs.new_file_focus_requested = false;
}
let validation = validate_child_name(
&self.current_dir,
&self.dialogs.new_file_name,
false,
None,
);
validation_status(ui, self.palette, &validation, "Ready to create file");
ui.add_space(10.0);
ui.horizontal_wrapped(|ui| {
if icon_button(
ui,
self.palette,
ChromeIcon::FilePlus,
Some("Create"),
validation.accepted,
validation.accepted,
)
.clicked()
|| (response.lost_focus()
&& ui.input(|input| input.key_pressed(egui::Key::Enter))
&& validation.accepted)
{
create_clicked = true;
}
if icon_button(
ui,
self.palette,
ChromeIcon::Close,
Some("Cancel"),
false,
true,
)
.clicked()
{
cancel_clicked = true;
}
});
},
);
});
if ctx.input(|input| input.key_pressed(egui::Key::Escape)) {
cancel_clicked = true;
create_clicked = false;
}
self.dialogs.new_file_open = open && !cancel_clicked;
if create_clicked {
match create_file(&self.current_dir, &self.dialogs.new_file_name) {
Ok(path) => {
self.push_undo(undo_for_created_file(path.clone()));
self.set_single_selection(path.clone());
self.push_log(format!("NEW FILE // {}", path.display()));
self.dialogs.new_file_open = false;
self.refresh_current_dir();
}
Err(error) => self.dialogs.error = Some(error),
}
}
}
fn rename_dialog(&mut self, ctx: &egui::Context, _phase: f32) {
if !self.dialogs.rename_open {
return;
}
let Some(path) = self.dialogs.rename_path.clone() else {
self.dialogs.rename_open = false;
return;
};
let parent = path.parent().unwrap_or(self.current_dir.as_path());
let original_name = path.file_name().and_then(|name| name.to_str());
let target_is_folder = path.is_dir();
let mut open = self.dialogs.rename_open;
let mut rename_clicked = false;
let mut cancel_clicked = false;
egui::Window::new("Rename")
.id(egui::Id::new("rename-dialog"))
.order(egui::Order::Foreground)
.open(&mut open)
.collapsible(false)
.resizable(false)
.default_width(420.0)
.min_width(360.0)
.frame(window_frame(self.palette, self.theme_opacity))
.show(ctx, |ui| {
overlay_header(
ui,
self.palette,
self.theme_opacity,
ChromeIcon::Rename,
"Rename",
original_name.unwrap_or("Selected item"),
);
ui.add_space(10.0);
overlay_card(
ui,
self.palette,
self.theme_opacity,
ChromeIcon::Rename,
"Name",
|ui| {
property_line(ui, "Current", &path_for_display(&path));
field_label(ui, "New name");
let response = ui.add(
egui::TextEdit::singleline(&mut self.dialogs.rename_name)
.id_salt("rename-name")
.desired_width(ui.available_width()),
);
if self.dialogs.rename_focus_requested {
let selection_end =
rename_selection_end(&self.dialogs.rename_name, target_is_folder);
focus_text_edit(ui, &response, selection_end);
self.dialogs.rename_focus_requested = false;
}
let validation = validate_child_name(
parent,
&self.dialogs.rename_name,
target_is_folder,
original_name,
);
validation_status(ui, self.palette, &validation, "Ready to rename item");
ui.add_space(10.0);
ui.horizontal_wrapped(|ui| {
if icon_button(
ui,
self.palette,
ChromeIcon::Rename,
Some("Rename"),
validation.accepted,
validation.accepted,
)
.clicked()
|| (response.lost_focus()
&& ui.input(|input| input.key_pressed(egui::Key::Enter))
&& validation.accepted)
{
rename_clicked = true;
}
if icon_button(
ui,
self.palette,
ChromeIcon::Close,
Some("Cancel"),
false,
true,
)
.clicked()
{
cancel_clicked = true;
}
});
},
);
});
if ctx.input(|input| input.key_pressed(egui::Key::Escape)) {
cancel_clicked = true;
rename_clicked = false;
}
self.dialogs.rename_open = open && !cancel_clicked;
if rename_clicked {
match rename_path(&path, &self.dialogs.rename_name) {
Ok(target) => {
if target != path {
self.push_undo(undo_for_rename(path.clone(), target.clone()));
}
self.set_single_selection(target.clone());
self.push_log(format!(
"RENAME // {} -> {}",
path.display(),
target.display()
));
self.dialogs.rename_open = false;
self.dialogs.rename_path = None;
self.refresh_current_dir();
}
Err(error) => self.dialogs.error = Some(error),
}
}
}
fn delete_dialog(&mut self, ctx: &egui::Context, _phase: f32) {
let Some(intent) = self.dialogs.delete_confirm.clone() else {
return;
};
let title = if intent.permanent {
"Delete Forever"
} else {
"Move to Trash"
};
let mut open = true;
let mut confirm_clicked = false;
let mut cancel_clicked = false;
egui::Window::new(title)
.id(egui::Id::new("delete-dialog"))
.order(egui::Order::Foreground)
.open(&mut open)
.collapsible(false)
.resizable(false)
.default_width(440.0)
.min_width(360.0)
.frame(window_frame(self.palette, self.theme_opacity))
.show(ctx, |ui| {
overlay_header(
ui,
self.palette,
self.theme_opacity,
ChromeIcon::Trash,
title,
&format!("{} selected items", intent.paths.len()),
);
ui.add_space(10.0);
overlay_card(
ui,
self.palette,
self.theme_opacity,
ChromeIcon::Trash,
"Confirmation",
|ui| {
safety_notice(
ui,
self.palette,
ChromeIcon::Trash,
if intent.permanent {
"Permanent Delete"
} else {
"Move to Trash"
},
if intent.permanent {
"This cannot be undone. Review the selected items before continuing."
} else {
"Items are moved to the local trash and can be restored with undo."
},
intent.permanent,
);
ui.add_space(8.0);
property_line(ui, "Items", &intent.paths.len().to_string());
property_line(
ui,
"Mode",
if intent.permanent {
"Permanent delete"
} else {
"Move to trash"
},
);
ui.add_space(8.0);
ui.horizontal_wrapped(|ui| {
if icon_button(
ui,
self.palette,
ChromeIcon::Trash,
Some(if intent.permanent {
"Delete Forever"
} else {
"Move to Trash"
}),
true,
true,
)
.clicked()
{
confirm_clicked = true;
}
if icon_button(
ui,
self.palette,
ChromeIcon::Close,
Some("Cancel"),
false,
true,
)
.clicked()
{
cancel_clicked = true;
}
});
},
);
overlay_card(
ui,
self.palette,
self.theme_opacity,
ChromeIcon::List,
"Selected Items",
|ui| {
egui::ScrollArea::vertical()
.max_height(130.0)
.show(ui, |ui| {
for path in intent.paths.iter().take(40) {
muted_label(ui, path_for_display(path));
}
if intent.paths.len() > 40 {
muted_label(
ui,
format!("... {} more", intent.paths.len() - 40),
);
}
});
},
);
});
let escape_pressed = ctx.input(|input| input.key_pressed(egui::Key::Escape));
let enter_pressed = ctx.input(|input| input.key_pressed(egui::Key::Enter));
if !open || cancel_clicked || escape_pressed {
self.dialogs.delete_confirm = None;
} else if confirm_clicked || (enter_pressed && !intent.permanent) {
let request = if intent.permanent {
OperationRequest::Delete {
sources: intent.paths,
}
} else {
OperationRequest::Trash {
sources: intent.paths,
}
};
self.dialogs.delete_confirm = None;
self.start_operation(request, false);
}
}
fn error_dialog(&mut self, ctx: &egui::Context) {
let Some(message) = self.dialogs.error.clone() else {
return;
};
let mut open = true;
let mut close_clicked = false;
egui::Window::new("Action Failed")
.id(egui::Id::new("error-dialog"))
.order(egui::Order::Foreground)
.open(&mut open)
.collapsible(false)
.resizable(false)
.default_width(410.0)
.min_width(340.0)
.frame(window_frame(self.palette, self.theme_opacity))
.show(ctx, |ui| {
overlay_header(
ui,
self.palette,
self.theme_opacity,
ChromeIcon::Error,
"Action Failed",
"The requested action could not be completed",
);
ui.add_space(10.0);
overlay_card(
ui,
self.palette,
self.theme_opacity,
ChromeIcon::Error,
"Message",
|ui| {
safety_notice(
ui,
self.palette,
ChromeIcon::Error,
"Action Failed",
&message,
true,
);
ui.add_space(10.0);
ui.horizontal_wrapped(|ui| {
if icon_button(
ui,
self.palette,
ChromeIcon::Copy,
Some("Copy Details"),
false,
true,
)
.clicked()
{
ui.ctx().copy_text(message.clone());
self.status = "Error details copied".to_string();
}
if icon_button(
ui,
self.palette,
ChromeIcon::Close,
Some("Close"),
true,
true,
)
.clicked()
{
close_clicked = true;
}
});
},
);
});
let close_pressed = ctx.input(|input| {
input.key_pressed(egui::Key::Escape) || input.key_pressed(egui::Key::Enter)
});
if !open || close_clicked || close_pressed {
self.dialogs.error = None;
}
}
}
#[derive(Clone, Copy)]
struct ListColumns {
name: f32,
kind: f32,
size: f32,
modified: f32,
}
#[derive(Clone, Copy)]
enum ChromeIcon {
Sidebar,
Back,
Forward,
Up,
Down,
Home,
Folder,
Search,
ZoomOut,
ZoomIn,
ResetZoom,
Close,
Open,
OpenExternal,
Check,
Error,
Preview,
Window,
Terminal,
Refresh,
FolderPlus,
FolderOrder,
FilePlus,
Cut,
Copy,
Paste,
Trash,
Pin,
Rename,
Duplicate,
Sort,
Hidden,
Link,
Lock,
List,
Grid,
Properties,
Settings,
}
fn list_columns(rect: Rect) -> ListColumns {
let width = rect.width();
ListColumns {
name: rect.left() + 12.0,
kind: rect.left() + width * 0.58,
size: rect.left() + width * 0.72,
modified: rect.left() + width * 0.84,
}
}
fn draw_list_column_rules(
painter: &egui::Painter,
rect: Rect,
columns: ListColumns,
theme: AppPalette,
) {
for x in [
columns.kind - 14.0,
columns.size - 14.0,
columns.modified - 14.0,
] {
painter.line_segment(
[
Pos2::new(x, rect.top() + 5.0),
Pos2::new(x, rect.bottom() - 5.0),
],
Stroke::new(1.0, theme.hairline()),
);
}
}
fn draw_list_row_rules(
painter: &egui::Painter,
rect: Rect,
_columns: ListColumns,
theme: AppPalette,
) {
painter.line_segment(
[
Pos2::new(rect.left() + 10.0, rect.bottom() - 1.0),
Pos2::new(rect.right() - 10.0, rect.bottom() - 1.0),
],
Stroke::new(
1.0,
Color32::from_rgba_unmultiplied(
theme.hairline().r(),
theme.hairline().g(),
theme.hairline().b(),
52,
),
),
);
}
fn draw_sort_indicator(painter: &egui::Painter, center: Pos2, descending: bool, color: Color32) {
let half_width = 3.5;
let half_height = 3.0;
let points = if descending {
vec![
center + egui::vec2(-half_width, -half_height),
center + egui::vec2(half_width, -half_height),
center + egui::vec2(0.0, half_height),
]
} else {
vec![
center + egui::vec2(-half_width, half_height),
center + egui::vec2(half_width, half_height),
center + egui::vec2(0.0, -half_height),
]
};
painter.add(egui::Shape::convex_polygon(points, color, Stroke::NONE));
}
fn draw_drop_outline(painter: &egui::Painter, rect: Rect, theme: AppPalette, label: &str) {
let rounding = Rounding::same(theme_rounding(theme) + 11.0);
painter.rect_stroke(rect, rounding, Stroke::new(1.6, theme.accent(0.0)));
painter.rect_filled(
rect.shrink(3.0),
Rounding::same(theme_rounding(theme) + 9.0),
Color32::from_rgba_unmultiplied(
theme.accent(0.0).r(),
theme.accent(0.0).g(),
theme.accent(0.0).b(),
if theme.is_light() { 16 } else { 30 },
),
);
let label_rect = Rect::from_center_size(
rect.center_top() + egui::vec2(0.0, 23.0),
Vec2::new(148.0, 24.0),
);
painter.rect_filled(label_rect, Rounding::same(999.0), theme.elevated_fill());
painter.rect_stroke(
label_rect,
Rounding::same(999.0),
Stroke::new(1.0, theme.accent(0.0)),
);
painter.text(
label_rect.center(),
Align2::CENTER_CENTER,
label,
FontId::proportional(11.0),
theme.accent(0.0),
);
}
fn preview_canvas_height(width: f32, available_height: f32, ratio: f32) -> f32 {
let proportional = (width * ratio).clamp(240.0, 720.0);
if available_height.is_finite() && available_height > 0.0 {
proportional.min((available_height - 42.0).max(220.0))
} else {
proportional
}
}
fn draw_preview_canvas(painter: &egui::Painter, rect: Rect, theme: AppPalette) -> Rect {
let canvas = rect.shrink(2.0);
let rounding = Rounding::same(theme_rounding(theme) + 8.0);
painter.rect_filled(canvas, rounding, theme.recessed_fill());
painter.rect_stroke(canvas, rounding, Stroke::new(1.0, theme.hairline()));
painter.rect_filled(
Rect::from_min_max(
canvas.left_top() + egui::vec2(1.0, 1.0),
canvas.right_top() + egui::vec2(-1.0, 3.0),
),
Rounding::same(theme_rounding(theme) + 6.0),
Color32::from_rgba_unmultiplied(255, 255, 255, if theme.is_light() { 96 } else { 22 }),
);
canvas
}
fn draw_transparency_grid(painter: &egui::Painter, rect: Rect, theme: AppPalette) {
let visible = rect.intersect(painter.clip_rect());
if visible.is_negative() {
return;
}
let cell = 24.0;
let low = if theme.is_light() {
Color32::from_rgb(224, 225, 228)
} else {
Color32::from_rgb(31, 34, 47)
};
let high = if theme.is_light() {
Color32::from_rgb(242, 243, 245)
} else {
Color32::from_rgb(42, 45, 60)
};
painter.rect_filled(visible, Rounding::same(4.0), low);
let columns = (visible.width() / cell).ceil() as usize + 1;
let rows = (visible.height() / cell).ceil() as usize + 1;
for row in 0..rows {
for column in 0..columns {
if (row + column) % 2 == 0 {
let min = visible.left_top() + egui::vec2(column as f32 * cell, row as f32 * cell);
painter.rect_filled(
Rect::from_min_size(min, Vec2::splat(cell)).intersect(visible),
Rounding::ZERO,
high,
);
}
}
}
}
fn draw_preview_stage_badge(painter: &egui::Painter, canvas: Rect, label: &str, theme: AppPalette) {
let badge = Rect::from_min_size(
canvas.left_bottom() + egui::vec2(12.0, -34.0),
Vec2::new(label.chars().count() as f32 * 6.1 + 18.0, 22.0),
);
painter.rect_filled(
badge,
Rounding::same(999.0),
Color32::from_black_alpha(if theme.is_light() { 156 } else { 176 }),
);
painter.text(
badge.center(),
Align2::CENTER_CENTER,
label,
FontId::monospace(9.0),
Color32::WHITE,
);
}
fn draw_preview_placeholder(
painter: &egui::Painter,
canvas: Rect,
class: MediaClass,
loading: bool,
error: Option<&str>,
theme: AppPalette,
phase: f32,
) {
let icon_rect =
Rect::from_center_size(canvas.center() + egui::vec2(0.0, -35.0), Vec2::splat(78.0));
draw_file_type_icon(painter, icon_rect, class, false, loading, theme);
let title = if error.is_some() {
"Preview unavailable"
} else if loading {
"Preparing preview"
} else {
"No preview available"
};
painter.text(
canvas.center() + egui::vec2(0.0, 30.0),
Align2::CENTER_CENTER,
title,
FontId::proportional(14.0),
if error.is_some() {
Color32::from_rgb(255, 114, 108)
} else {
theme.secondary_text()
},
);
let detail = error.unwrap_or(if loading {
match class {
MediaClass::Image => "Preparing a high-quality image preview",
MediaClass::Video => "Generating a video poster with ffmpeg",
MediaClass::Code | MediaClass::Text => "Reading a bounded document preview",
_ => "Preparing a bounded inline preview",
}
} else {
"Guth could not generate an inline view for this file"
});
painter.text(
canvas.center() + egui::vec2(0.0, 51.0),
Align2::CENTER_CENTER,
truncate_middle(detail, 86),
FontId::proportional(10.5),
theme.muted(),
);
if loading {
let track = Rect::from_center_size(
canvas.center() + egui::vec2(0.0, 77.0),
Vec2::new(116.0, 3.0),
);
painter.rect_filled(track, Rounding::same(999.0), theme.hairline());
let segment_width = 34.0;
let travel = track.width() - segment_width;
let start = track.left() + travel * phase.fract();
painter.rect_filled(
Rect::from_min_size(Pos2::new(start, track.top()), Vec2::new(segment_width, 3.0)),
Rounding::same(999.0),
theme.accent(0.0),
);
}
}
fn draw_entry_emblems(painter: &egui::Painter, start: Pos2, entry: &FileEntry, theme: AppPalette) {
let mut x = start.x;
for icon in [
(entry.kind == EntryKind::Symlink).then_some(ChromeIcon::Link),
entry.readonly.then_some(ChromeIcon::Lock),
entry.hidden.then_some(ChromeIcon::Hidden),
]
.into_iter()
.flatten()
{
let icon_rect = Rect::from_center_size(Pos2::new(x + 7.0, start.y), Vec2::splat(13.0));
draw_chrome_icon(painter, icon_rect.shrink(1.0), icon, theme.muted());
x += 15.0;
}
}
fn draw_folder_summary_icon(painter: &egui::Painter, rect: Rect) {
draw_folder_icon(painter, rect.shrink(3.0), class_accent(MediaClass::Folder));
}
fn draw_place_icon(
painter: &egui::Painter,
rect: Rect,
place: &Place,
_pinned: bool,
active: bool,
theme: AppPalette,
) {
let base = if !place.path.is_dir() {
theme.muted()
} else if active {
theme.accent(0.0)
} else {
theme.secondary_text()
};
match place.label.as_str() {
"Home" => draw_home_place_icon(painter, rect.shrink(3.0), base),
"Trash" => draw_trash_place_icon(painter, rect.shrink(3.0), base),
"Current" => draw_current_place_icon(painter, rect.shrink(3.0), base),
"Desktop" => draw_chrome_icon(painter, rect.shrink(2.0), ChromeIcon::Window, base),
"Documents" => draw_document_outline(painter, rect.shrink(1.5), base),
"Downloads" => {
draw_chrome_icon(painter, rect.shrink(3.0), ChromeIcon::Down, base);
painter.line_segment(
[
rect.left_bottom() + egui::vec2(3.0, -2.0),
rect.right_bottom() + egui::vec2(-3.0, -2.0),
],
Stroke::new(1.2, base),
);
}
"Pictures" => draw_picture_place_icon(painter, rect.shrink(2.0), base),
"Music" => draw_music_place_icon(painter, rect.shrink(2.0), base),
"Videos" => draw_video_place_icon(painter, rect.shrink(2.0), base),
"Filesystem" => draw_drive_place_icon(painter, rect.shrink(2.0), base),
"Temp" => draw_clock_place_icon(painter, rect.shrink(2.0), base),
_ => draw_folder_place_icon(painter, rect.shrink(2.5), base),
}
}
fn draw_picture_place_icon(painter: &egui::Painter, rect: Rect, color: Color32) {
painter.rect_stroke(rect, Rounding::same(2.0), Stroke::new(1.2, color));
painter.circle_filled(
rect.left_top() + egui::vec2(rect.width() * 0.72, 4.0),
1.5,
color,
);
painter.line_segment(
[
rect.left_bottom() + egui::vec2(2.0, -3.0),
rect.center() + egui::vec2(-1.0, 1.0),
],
Stroke::new(1.2, color),
);
painter.line_segment(
[
rect.center() + egui::vec2(-1.0, 1.0),
rect.right_bottom() + egui::vec2(-2.0, -3.0),
],
Stroke::new(1.2, color),
);
}
fn draw_music_place_icon(painter: &egui::Painter, rect: Rect, color: Color32) {
let stem = rect.center() + egui::vec2(2.0, -1.0);
painter.line_segment(
[stem, stem + egui::vec2(0.0, -6.0)],
Stroke::new(1.4, color),
);
painter.line_segment(
[stem + egui::vec2(0.0, -6.0), stem + egui::vec2(5.0, -4.5)],
Stroke::new(1.4, color),
);
painter.circle_filled(stem + egui::vec2(-2.0, 4.0), 2.7, color);
}
fn draw_video_place_icon(painter: &egui::Painter, rect: Rect, color: Color32) {
painter.rect_stroke(rect, Rounding::same(2.0), Stroke::new(1.2, color));
painter.add(egui::Shape::convex_polygon(
vec![
rect.center() + egui::vec2(-2.0, -3.5),
rect.center() + egui::vec2(-2.0, 3.5),
rect.center() + egui::vec2(4.0, 0.0),
],
color,
Stroke::NONE,
));
}
fn draw_drive_place_icon(painter: &egui::Painter, rect: Rect, color: Color32) {
painter.rect_stroke(rect, Rounding::same(2.5), Stroke::new(1.2, color));
painter.line_segment(
[
rect.left_bottom() + egui::vec2(2.0, -4.0),
rect.right_bottom() + egui::vec2(-2.0, -4.0),
],
Stroke::new(1.0, color),
);
painter.circle_filled(rect.right_bottom() + egui::vec2(-3.5, -2.0), 1.0, color);
}
fn draw_clock_place_icon(painter: &egui::Painter, rect: Rect, color: Color32) {
painter.circle_stroke(
rect.center(),
rect.width().min(rect.height()) * 0.42,
Stroke::new(1.2, color),
);
painter.line_segment(
[rect.center(), rect.center() + egui::vec2(0.0, -4.0)],
Stroke::new(1.2, color),
);
painter.line_segment(
[rect.center(), rect.center() + egui::vec2(3.0, 2.0)],
Stroke::new(1.2, color),
);
}
fn draw_folder_place_icon(painter: &egui::Painter, rect: Rect, color: Color32) {
let body = Rect::from_min_max(
rect.left_top() + egui::vec2(0.5, rect.height() * 0.32),
rect.right_bottom() - egui::vec2(0.5, 0.5),
);
let tab = Rect::from_min_size(
body.left_top() + egui::vec2(1.0, -3.8),
Vec2::new(body.width() * 0.48, 5.2),
);
painter.rect_filled(tab, Rounding::same(1.5), lift(color, 0.20));
painter.rect_filled(body, Rounding::same(2.0), color);
painter.line_segment(
[
body.left_top() + egui::vec2(2.0, 1.5),
body.right_top() + egui::vec2(-2.0, 1.5),
],
Stroke::new(0.8, Color32::from_white_alpha(92)),
);
painter.rect_stroke(
body,
Rounding::same(2.0),
Stroke::new(0.8, shade(color, 0.18)),
);
}
fn draw_home_place_icon(painter: &egui::Painter, rect: Rect, color: Color32) {
let roof = vec![
Pos2::new(rect.left(), rect.center().y),
Pos2::new(rect.center().x, rect.top()),
Pos2::new(rect.right(), rect.center().y),
];
painter.add(egui::Shape::convex_polygon(roof, color, Stroke::NONE));
painter.rect_filled(
Rect::from_min_max(
Pos2::new(rect.left() + 1.5, rect.center().y - 0.5),
Pos2::new(rect.right() - 1.5, rect.bottom()),
),
Rounding::same(1.5),
color,
);
}
fn draw_trash_place_icon(painter: &egui::Painter, rect: Rect, color: Color32) {
painter.line_segment(
[
Pos2::new(rect.left() + 1.0, rect.top() + 2.0),
Pos2::new(rect.right() - 1.0, rect.top() + 2.0),
],
Stroke::new(1.4, color),
);
let bin = Rect::from_min_max(
Pos2::new(rect.left() + 2.0, rect.top() + 4.0),
Pos2::new(rect.right() - 2.0, rect.bottom()),
);
painter.rect_stroke(bin, Rounding::same(1.2), Stroke::new(1.2, color));
painter.line_segment(
[
Pos2::new(bin.center().x, bin.top() + 1.5),
Pos2::new(bin.center().x, bin.bottom() - 1.5),
],
Stroke::new(1.0, color),
);
}
fn draw_current_place_icon(painter: &egui::Painter, rect: Rect, color: Color32) {
painter.circle_stroke(
rect.center(),
rect.width().min(rect.height()) * 0.42,
Stroke::new(1.3, color),
);
painter.circle_filled(rect.center(), 2.2, color);
}
fn draw_inline_preview_badge(
painter: &egui::Painter,
rect: Rect,
theme: AppPalette,
compact: bool,
) {
let rounding = Rounding::same(999.0);
icon_shadow(painter, rect, 4.0, if compact { 22 } else { 18 });
painter.rect_filled(rect, rounding, theme.selected_fill());
painter.rect_stroke(rect, rounding, Stroke::new(1.0, theme.accent(0.0)));
let icon_rect = Rect::from_center_size(
if compact {
rect.center()
} else {
rect.left_center() + egui::vec2(10.0, 0.0)
},
Vec2::splat(if compact { 12.0 } else { 10.5 }),
);
draw_chrome_icon(painter, icon_rect, ChromeIcon::Preview, theme.accent(0.0));
if !compact {
painter.text(
rect.left_center() + egui::vec2(20.0, 0.0),
Align2::LEFT_CENTER,
"Inline",
FontId::proportional(9.5),
theme.secondary_text(),
);
}
}
fn media_class_for_entry(entry: &FileEntry) -> MediaClass {
media_class_for_path(
&entry.path,
entry.kind == EntryKind::Directory,
entry.kind == EntryKind::Symlink,
)
}
fn preview_plugin_kind_for_entry(entry: &FileEntry) -> Option<PreviewPluginKind> {
match media_class_for_entry(entry) {
MediaClass::Image if entry.extension() != "svg" => Some(PreviewPluginKind::Media),
MediaClass::Video => Some(PreviewPluginKind::Media),
MediaClass::Code | MediaClass::Text => Some(PreviewPluginKind::Text),
_ => None,
}
}
fn selection_summary(entries: &[FileEntry]) -> SelectionSummary {
let mut summary = SelectionSummary {
count: entries.len(),
..SelectionSummary::default()
};
for entry in entries {
if entry.kind == EntryKind::Directory {
summary.folders += 1;
} else {
summary.files += 1;
summary.file_bytes = summary.file_bytes.saturating_add(entry.size);
}
}
summary
}
fn operation_results_in_directory(paths: &[PathBuf], directory: &Path) -> Vec<PathBuf> {
paths
.iter()
.filter(|path| path.parent() == Some(directory))
.cloned()
.collect()
}
fn stepped_selection_index(
entry_count: usize,
current_index: Option<usize>,
direction: isize,
) -> Option<usize> {
if entry_count == 0 {
return None;
}
Some(match current_index {
Some(current) => (current as isize + direction).clamp(0, entry_count as isize - 1) as usize,
None if direction < 0 => entry_count - 1,
None => 0,
})
}
fn location_target_for_entry(entry: Option<&FileEntry>, fallback: &Path) -> PathBuf {
let Some(entry) = entry else {
return fallback.to_path_buf();
};
if entry.kind == EntryKind::Directory {
entry.path.clone()
} else {
entry
.path
.parent()
.map(Path::to_path_buf)
.unwrap_or_else(|| fallback.to_path_buf())
}
}
fn fit_rect_to_aspect(rect: Rect, aspect: f32) -> Rect {
let aspect = aspect.max(0.05);
let rect_aspect = rect.width() / rect.height().max(1.0);
if rect_aspect > aspect {
Rect::from_center_size(
rect.center(),
Vec2::new(rect.height() * aspect, rect.height()),
)
} else {
Rect::from_center_size(
rect.center(),
Vec2::new(rect.width(), rect.width() / aspect),
)
}
}
fn zoom_rect(rect: Rect, zoom: f32) -> Rect {
Rect::from_center_size(rect.center(), rect.size() * zoom.clamp(0.5, 2.5))
}
fn clamp_preview_pan(pan: Vec2, content: Rect, viewport: Rect) -> Vec2 {
let max_x = ((content.width() - viewport.width()) * 0.5).max(0.0);
let max_y = ((content.height() - viewport.height()) * 0.5).max(0.0);
egui::vec2(pan.x.clamp(-max_x, max_x), pan.y.clamp(-max_y, max_y))
}
fn preview_zoom_percent(zoom: f32) -> u16 {
(zoom.clamp(0.5, 2.5) * 100.0).round() as u16
}
fn preview_zoom_label(zoom: f32) -> String {
if (zoom - 1.0).abs() < f32::EPSILON {
"Fit".to_string()
} else {
format!("{}%", preview_zoom_percent(zoom))
}
}
fn preview_backdrop(ctx: &egui::Context, theme: AppPalette) -> bool {
let screen = ctx.screen_rect();
let mut clicked = false;
egui::Area::new(egui::Id::new("preview-backdrop"))
.order(egui::Order::Middle)
.fixed_pos(screen.min)
.interactable(true)
.show(ctx, |ui| {
let (rect, response) = ui.allocate_exact_size(screen.size(), Sense::click());
ui.painter().rect_filled(
rect,
Rounding::ZERO,
Color32::from_black_alpha(if theme.is_light() { 54 } else { 88 }),
);
clicked = response.clicked();
});
clicked
}
fn modal_backdrop(ctx: &egui::Context, theme: AppPalette) {
let screen = ctx.screen_rect();
egui::Area::new(egui::Id::new("modal-backdrop"))
.order(egui::Order::Middle)
.fixed_pos(screen.min)
.interactable(true)
.show(ctx, |ui| {
let (rect, _) = ui.allocate_exact_size(screen.size(), Sense::click_and_drag());
ui.painter().rect_filled(
rect,
Rounding::ZERO,
Color32::from_black_alpha(if theme.is_light() { 54 } else { 96 }),
);
});
}
fn focus_text_edit(ui: &egui::Ui, response: &egui::Response, selection_end: usize) {
response.request_focus();
if let Some(mut state) = egui::TextEdit::load_state(ui.ctx(), response.id) {
state
.cursor
.set_char_range(Some(egui::text::CCursorRange::two(
egui::text::CCursor::new(0),
egui::text::CCursor::new(selection_end),
)));
state.store(ui.ctx(), response.id);
}
}
fn rename_selection_end(name: &str, is_folder: bool) -> usize {
if is_folder {
return name.chars().count();
}
Path::new(name)
.file_stem()
.and_then(|stem| stem.to_str())
.map(|stem| stem.chars().count())
.filter(|count| *count > 0)
.unwrap_or_else(|| name.chars().count())
}
fn property_line(ui: &mut egui::Ui, key: &str, value: &str) {
let muted = ui.visuals().widgets.inactive.fg_stroke.color;
let text = ui
.visuals()
.override_text_color
.unwrap_or_else(|| Color32::from_gray(198));
ui.horizontal_wrapped(|ui| {
ui.add_sized(
Vec2::new(104.0, 18.0),
egui::Label::new(RichText::new(key).size(11.0).color(muted)),
);
ui.label(RichText::new(value).size(12.0).color(text));
});
ui.add_space(3.0);
}
fn property_section_title(ui: &mut egui::Ui, title: &str) {
ui.add_space(4.0);
ui.horizontal(|ui| {
ui.label(
RichText::new(title.to_uppercase())
.size(9.5)
.strong()
.color(ui.visuals().widgets.inactive.fg_stroke.color),
);
ui.add(egui::Separator::default().horizontal());
});
ui.add_space(2.0);
}
fn summary_row(
ui: &mut egui::Ui,
theme: AppPalette,
icon: ChromeIcon,
title: &str,
detail: &str,
path: &str,
) {
egui::Frame::none()
.fill(theme.recessed_fill())
.stroke(Stroke::new(1.0, theme.hairline()))
.rounding(Rounding::same(theme_rounding(theme) + 8.0))
.inner_margin(egui::Margin::symmetric(10.0, 8.0))
.show(ui, |ui| {
ui.horizontal(|ui| {
let (icon_rect, _) = ui.allocate_exact_size(Vec2::splat(34.0), Sense::hover());
let painter = ui.painter_at(icon_rect);
painter.circle_filled(icon_rect.center(), 16.0, theme.accent_soft());
painter.circle_stroke(icon_rect.center(), 16.0, Stroke::new(1.0, theme.hairline()));
draw_chrome_icon(
&painter,
Rect::from_center_size(icon_rect.center(), Vec2::splat(16.0)),
icon,
theme.accent(0.0),
);
ui.add_space(4.0);
ui.vertical(|ui| {
ui.label(
RichText::new(truncate_middle(title, 38))
.size(13.0)
.strong()
.color(theme.text()),
);
ui.label(
RichText::new(detail)
.size(11.0)
.color(theme.secondary_text()),
);
ui.label(
RichText::new(truncate_middle(path, 58))
.size(10.0)
.color(theme.muted()),
);
});
});
});
}
fn validation_status(
ui: &mut egui::Ui,
theme: AppPalette,
validation: &NameValidation,
ready: &str,
) {
let (icon, title, detail, color, fill) = if !validation.accepted {
(
ChromeIcon::Error,
"Needs Attention",
validation
.message
.as_deref()
.unwrap_or("The name cannot be used"),
Color32::from_rgb(255, 122, 128),
Color32::from_rgba_unmultiplied(255, 122, 128, if theme.is_light() { 24 } else { 34 }),
)
} else if validation.warning {
(
ChromeIcon::Hidden,
"Hidden Item",
validation.message.as_deref().unwrap_or(ready),
Color32::from_rgb(235, 174, 76),
Color32::from_rgba_unmultiplied(235, 174, 76, if theme.is_light() { 24 } else { 34 }),
)
} else {
(
ChromeIcon::Check,
"Ready",
ready,
theme.accent(0.0),
theme.accent_soft(),
)
};
egui::Frame::none()
.fill(fill)
.stroke(Stroke::new(1.0, color))
.rounding(Rounding::same(theme_rounding(theme) + 7.0))
.inner_margin(egui::Margin::symmetric(8.0, 6.0))
.show(ui, |ui| {
ui.horizontal(|ui| {
inline_chrome_icon(ui, theme, icon, color);
ui.vertical(|ui| {
ui.label(RichText::new(title).size(11.5).strong().color(theme.text()));
ui.label(
RichText::new(detail)
.size(10.5)
.color(theme.secondary_text()),
);
});
});
});
}
fn safety_notice(
ui: &mut egui::Ui,
theme: AppPalette,
icon: ChromeIcon,
title: &str,
detail: &str,
destructive: bool,
) {
let accent = if destructive {
Color32::from_rgb(255, 122, 128)
} else {
theme.accent(0.0)
};
egui::Frame::none()
.fill(Color32::from_rgba_unmultiplied(
accent.r(),
accent.g(),
accent.b(),
if theme.is_light() { 24 } else { 34 },
))
.stroke(Stroke::new(1.0, accent))
.rounding(Rounding::same(theme_rounding(theme) + 8.0))
.inner_margin(egui::Margin::symmetric(10.0, 8.0))
.show(ui, |ui| {
ui.horizontal(|ui| {
inline_chrome_icon(ui, theme, icon, accent);
ui.vertical(|ui| {
ui.label(RichText::new(title).size(12.0).strong().color(theme.text()));
ui.label(
RichText::new(detail)
.size(10.8)
.color(theme.secondary_text()),
);
});
});
});
}
fn progress_card(
ui: &mut egui::Ui,
title: &str,
progress: &OperationProgress,
phase: f32,
theme: AppPalette,
) {
let value = operation_percent(progress.done_items, progress.total_items).clamp(0.0, 1.0);
let (rect, _) = ui.allocate_exact_size(Vec2::new(ui.available_width(), 46.0), Sense::hover());
let painter = ui.painter_at(rect);
let card = rect.shrink2(egui::vec2(1.0, 1.0));
painter.rect_filled(
card,
Rounding::same(theme_rounding(theme) + 7.0),
theme.surface(),
);
painter.rect_stroke(
card,
Rounding::same(theme_rounding(theme) + 7.0),
Stroke::new(1.0, theme.hairline()),
);
painter.text(
card.left_top() + egui::vec2(8.0, 6.0),
Align2::LEFT_TOP,
title,
FontId::proportional(12.0),
theme.text(),
);
painter.text(
card.right_top() + egui::vec2(-8.0, 6.0),
Align2::RIGHT_TOP,
format!("{}%", (value * 100.0).round() as u8),
FontId::proportional(12.0),
theme.secondary_text(),
);
let bar = Rect::from_min_max(
card.left_bottom() + egui::vec2(8.0, -14.0),
card.right_bottom() + egui::vec2(-8.0, -8.0),
);
painter.rect_filled(bar, Rounding::same(999.0), theme.recessed_fill());
let filled = Rect::from_min_max(
bar.left_top(),
Pos2::new(bar.left() + bar.width() * value, bar.bottom()),
);
painter.rect_filled(filled, Rounding::same(999.0), theme.accent(phase));
}
fn paint_thumbnail(
painter: &egui::Painter,
rect: Rect,
view: ThumbnailView,
hovered: bool,
accent: Color32,
theme: AppPalette,
) {
painter.rect_filled(
rect,
Rounding::same(4.0),
if theme.is_light() {
Color32::from_rgb(246, 248, 252)
} else {
Color32::from_rgb(34, 38, 52)
},
);
painter.rect_stroke(
rect,
Rounding::same(4.0),
Stroke::new(1.0, if hovered { accent } else { theme.border() }),
);
let frame = rect.shrink(2.0);
let frame_aspect = frame.width() / frame.height().max(1.0);
let target = if frame_aspect > view.aspect {
let width = frame.height() * view.aspect;
Rect::from_center_size(frame.center(), Vec2::new(width, frame.height()))
} else {
let height = frame.width() / view.aspect.max(0.01);
Rect::from_center_size(frame.center(), Vec2::new(frame.width(), height))
};
painter.image(
view.texture_id,
target,
Rect::from_min_max(Pos2::ZERO, Pos2::new(1.0, 1.0)),
Color32::WHITE,
);
if hovered {
painter.rect_stroke(
rect.shrink(1.0),
Rounding::same(3.0),
Stroke::new(1.0, accent),
);
}
}
fn draw_video_badge(painter: &egui::Painter, rect: Rect, animated: bool) {
let size = (rect.width().min(rect.height()) * 0.30).clamp(14.0, 28.0);
let center = rect.right_bottom() - egui::vec2(size * 0.64, size * 0.64);
painter.circle_filled(
center + egui::vec2(0.0, 1.0),
size * 0.52,
Color32::from_black_alpha(176),
);
painter.circle_stroke(
center,
size * 0.50,
Stroke::new(1.0, Color32::from_white_alpha(72)),
);
let half = size * 0.20;
painter.add(egui::Shape::convex_polygon(
vec![
center + egui::vec2(-half * 0.72, -half),
center + egui::vec2(-half * 0.72, half),
center + egui::vec2(half, 0.0),
],
Color32::WHITE,
Stroke::NONE,
));
if animated {
painter.circle_stroke(
center,
size * 0.58,
Stroke::new(1.2, Color32::from_rgb(166, 126, 255)),
);
}
}
fn draw_file_type_icon(
painter: &egui::Painter,
rect: Rect,
class: MediaClass,
_hovered: bool,
loading: bool,
theme: AppPalette,
) {
let accent = class_accent(class);
let glyph_rect = if rect.height() < 42.0 {
rect.shrink2(Vec2::new(2.0, 2.0))
} else {
rect.shrink2(Vec2::new(8.0, 6.0))
};
let glyph_rect = fit_rect_to_aspect(glyph_rect, file_icon_aspect(class));
match class {
MediaClass::Folder => draw_folder_icon(painter, glyph_rect, accent),
MediaClass::Image => draw_image_icon(painter, glyph_rect, accent, theme),
MediaClass::Video => draw_video_icon(painter, glyph_rect, accent),
MediaClass::Audio => draw_audio_icon(painter, glyph_rect, accent),
MediaClass::Archive => draw_archive_icon(painter, glyph_rect, accent),
MediaClass::Code => draw_code_icon(painter, glyph_rect, accent, theme),
MediaClass::Pdf
| MediaClass::Text
| MediaClass::Symlink
| MediaClass::Special
| MediaClass::File => draw_document_icon(painter, glyph_rect, accent, class, theme),
}
if loading {
let center = rect.right_bottom() + egui::vec2(-9.0, -8.0);
painter.circle_filled(center, 6.0, theme.elevated_fill());
painter.circle_stroke(center, 6.0, Stroke::new(1.0, theme.hairline()));
let radius = 3.2;
let points = (0..9)
.map(|index| {
let angle = std::f32::consts::TAU * (index as f32 / 12.0) - 1.2;
center + egui::vec2(angle.cos(), angle.sin()) * radius
})
.collect::<Vec<_>>();
painter.add(egui::Shape::line(
points,
Stroke::new(1.4, theme.accent(0.0)),
));
}
}
fn class_accent(class: MediaClass) -> Color32 {
match class {
MediaClass::Folder => Color32::from_rgb(74, 153, 255),
MediaClass::Image => Color32::from_rgb(65, 181, 103),
MediaClass::Video => Color32::from_rgb(188, 118, 255),
MediaClass::Audio => Color32::from_rgb(66, 149, 255),
MediaClass::Archive => Color32::from_rgb(220, 154, 46),
MediaClass::Code => Color32::from_rgb(70, 166, 82),
MediaClass::Pdf => Color32::from_rgb(215, 55, 55),
MediaClass::Text => Color32::from_rgb(128, 128, 128),
MediaClass::Symlink => Color32::from_rgb(48, 184, 184),
MediaClass::Special => Color32::from_rgb(128, 128, 128),
MediaClass::File => Color32::from_rgb(128, 128, 128),
}
}
fn file_icon_aspect(class: MediaClass) -> f32 {
match class {
MediaClass::Folder => FOLDER_ICON_ASPECT,
MediaClass::Image | MediaClass::Video => 1.34,
MediaClass::Audio | MediaClass::Archive | MediaClass::Special => 1.0,
MediaClass::Code
| MediaClass::Pdf
| MediaClass::Text
| MediaClass::Symlink
| MediaClass::File => 0.78,
}
}
fn icon_shadow(painter: &egui::Painter, rect: Rect, _rounding: f32, alpha: u8) {
let soft_alpha = (alpha as f32 * 0.50).round() as u8;
let mid_alpha = (alpha as f32 * 0.34).round() as u8;
let center = rect.center_bottom() + egui::vec2(0.0, -1.5);
painter.rect_filled(
Rect::from_center_size(
center + egui::vec2(0.0, 2.0),
Vec2::new(rect.width() * 0.86, (rect.height() * 0.14).clamp(2.0, 7.0)),
),
Rounding::same(999.0),
Color32::from_black_alpha(soft_alpha),
);
painter.rect_filled(
Rect::from_center_size(
center,
Vec2::new(rect.width() * 0.58, (rect.height() * 0.08).clamp(1.5, 4.0)),
),
Rounding::same(999.0),
Color32::from_black_alpha(mid_alpha),
);
}
fn media_shadow(painter: &egui::Painter, rect: Rect, rounding: f32, theme: AppPalette) {
painter.rect_filled(
rect.expand(3.0).translate(egui::vec2(0.0, 3.0)),
Rounding::same(rounding + 3.0),
Color32::from_black_alpha(if theme.is_light() { 22 } else { 48 }),
);
painter.rect_filled(
rect.expand(1.0).translate(egui::vec2(0.0, 1.0)),
Rounding::same(rounding + 1.0),
Color32::from_black_alpha(if theme.is_light() { 20 } else { 34 }),
);
}
fn mix_color(a: Color32, b: Color32, amount: f32) -> Color32 {
let amount = amount.clamp(0.0, 1.0);
let mix = |left: u8, right: u8| {
((left as f32) + ((right as f32) - (left as f32)) * amount).round() as u8
};
Color32::from_rgb(mix(a.r(), b.r()), mix(a.g(), b.g()), mix(a.b(), b.b()))
}
fn lift(color: Color32, amount: f32) -> Color32 {
mix_color(color, Color32::WHITE, amount)
}
fn shade(color: Color32, amount: f32) -> Color32 {
mix_color(color, Color32::BLACK, amount)
}
fn icon_scale(rect: Rect) -> f32 {
(rect.height() / 58.0).clamp(0.42, 1.18)
}
fn draw_folder_icon(painter: &egui::Painter, rect: Rect, accent: Color32) {
let rect = fit_rect_to_aspect(rect, FOLDER_ICON_ASPECT);
let s = icon_scale(rect);
let body = Rect::from_min_max(
rect.left_top() + egui::vec2(2.5 * s, rect.height() * 0.20),
rect.right_bottom() - egui::vec2(2.5 * s, 1.5 * s),
);
let tab = Rect::from_min_size(
body.left_top() + egui::vec2(3.0 * s, -10.0 * s),
Vec2::new(body.width() * 0.52, 15.0 * s),
);
let front = Rect::from_min_max(
Pos2::new(body.left(), body.top() + body.height() * 0.16),
body.right_bottom(),
);
icon_shadow(painter, body, 7.0 * s, 34);
painter.rect_filled(tab, Rounding::same(4.5 * s), lift(accent, 0.38));
painter.rect_stroke(
tab,
Rounding::same(4.5 * s),
Stroke::new(0.8, lift(accent, 0.52)),
);
painter.rect_filled(body, Rounding::same(7.0 * s), lift(accent, 0.18));
painter.rect_stroke(
body,
Rounding::same(7.0 * s),
Stroke::new(1.0, shade(accent, 0.16)),
);
painter.rect_filled(front, Rounding::same(7.0 * s), accent);
painter.rect_filled(
Rect::from_min_max(
front.left_top(),
front.right_top() + egui::vec2(0.0, front.height() * 0.38),
),
Rounding::same(7.0 * s),
lift(accent, 0.24),
);
painter.rect_filled(
Rect::from_min_max(
front.left_bottom() + egui::vec2(0.0, -8.0 * s),
front.right_bottom(),
),
Rounding::same(6.0 * s),
shade(accent, 0.10),
);
painter.line_segment(
[
front.left_top() + egui::vec2(6.0 * s, 3.0 * s),
front.right_top() + egui::vec2(-7.0 * s, 3.0 * s),
],
Stroke::new(1.0, Color32::from_white_alpha(126)),
);
painter.rect_stroke(
front,
Rounding::same(7.0 * s),
Stroke::new(1.0, shade(accent, 0.20)),
);
}
fn draw_image_icon(painter: &egui::Painter, rect: Rect, accent: Color32, theme: AppPalette) {
let s = icon_scale(rect);
let frame = rect.shrink(5.0 * s);
icon_shadow(painter, frame, 6.0 * s, 34);
painter.rect_filled(
frame,
Rounding::same(6.0 * s),
if theme.is_light() {
Color32::from_rgb(244, 248, 255)
} else {
Color32::from_rgb(42, 46, 62)
},
);
let photo = frame.shrink(4.0 * s);
painter.rect_filled(
photo,
Rounding::same(4.0 * s),
Color32::from_rgb(98, 176, 238),
);
painter.rect_filled(
Rect::from_min_max(
photo.left_bottom() + egui::vec2(0.0, -20.0 * s),
photo.right_bottom(),
),
Rounding::same(4.0 * s),
Color32::from_rgb(56, 155, 89),
);
painter.circle_filled(
photo.left_top() + egui::vec2(photo.width() * 0.72, 12.0 * s),
4.0 * s,
Color32::from_rgb(255, 213, 92),
);
painter.add(egui::Shape::convex_polygon(
vec![
photo.left_bottom() + egui::vec2(6.0 * s, -4.0 * s),
photo.center() + egui::vec2(-8.0 * s, -4.0 * s),
photo.center() + egui::vec2(3.0 * s, 12.0 * s),
],
Color32::from_rgb(37, 120, 72),
Stroke::NONE,
));
painter.rect_stroke(
frame,
Rounding::same(6.0 * s),
Stroke::new(1.0, shade(accent, 0.18)),
);
}
fn draw_video_icon(painter: &egui::Painter, rect: Rect, accent: Color32) {
let s = icon_scale(rect);
let frame = rect.shrink(6.0 * s);
icon_shadow(painter, frame, 6.0 * s, 38);
painter.rect_filled(frame, Rounding::same(7.0 * s), shade(accent, 0.36));
painter.rect_filled(
Rect::from_min_max(
frame.left_top(),
frame.right_top() + egui::vec2(0.0, frame.height() * 0.42),
),
Rounding::same(7.0 * s),
lift(accent, 0.18),
);
for index in 0..4 {
let x = frame.left() + 7.0 * s + index as f32 * 8.0 * s;
painter.line_segment(
[
Pos2::new(x, frame.top() + 5.0 * s),
Pos2::new(x + 3.0 * s, frame.top() + 5.0 * s),
],
Stroke::new(1.4 * s, Color32::from_white_alpha(116)),
);
painter.line_segment(
[
Pos2::new(x, frame.bottom() - 5.0 * s),
Pos2::new(x + 3.0 * s, frame.bottom() - 5.0 * s),
],
Stroke::new(1.4 * s, Color32::from_white_alpha(84)),
);
}
painter.rect_filled(
Rect::from_center_size(frame.center(), Vec2::new(25.0 * s, 25.0 * s)),
Rounding::same(999.0),
Color32::from_rgba_unmultiplied(255, 255, 255, 42),
);
painter.add(egui::Shape::convex_polygon(
vec![
frame.center() + egui::vec2(-6.0 * s, -9.0 * s),
frame.center() + egui::vec2(-6.0 * s, 9.0 * s),
frame.center() + egui::vec2(10.0 * s, 0.0),
],
Color32::WHITE,
Stroke::NONE,
));
painter.rect_stroke(
frame,
Rounding::same(7.0 * s),
Stroke::new(1.0, shade(accent, 0.22)),
);
}
fn draw_audio_icon(painter: &egui::Painter, rect: Rect, accent: Color32) {
let s = icon_scale(rect);
let frame = rect.shrink(7.0 * s);
icon_shadow(painter, frame, 7.0 * s, 34);
painter.rect_filled(frame, Rounding::same(999.0), accent);
painter.rect_filled(
Rect::from_min_max(
frame.left_top(),
frame.right_top() + egui::vec2(0.0, frame.height() * 0.46),
),
Rounding::same(999.0),
lift(accent, 0.24),
);
let center = frame.center();
for (index, height) in [13.0, 22.0, 31.0, 22.0, 14.0].into_iter().enumerate() {
let x = center.x - 18.0 * s + index as f32 * 9.0 * s;
painter.line_segment(
[
Pos2::new(x, center.y - height * 0.5 * s),
Pos2::new(x, center.y + height * 0.5 * s),
],
Stroke::new(2.6 * s, Color32::WHITE),
);
}
painter.rect_stroke(
frame,
Rounding::same(999.0),
Stroke::new(1.0, shade(accent, 0.22)),
);
}
fn draw_archive_icon(painter: &egui::Painter, rect: Rect, accent: Color32) {
let s = icon_scale(rect);
let frame = rect.shrink(6.0 * s);
icon_shadow(painter, frame, 6.0 * s, 34);
painter.rect_filled(frame, Rounding::same(6.0 * s), lift(accent, 0.16));
painter.rect_filled(
Rect::from_min_max(
frame.left_top(),
frame.right_top() + egui::vec2(0.0, frame.height() * 0.32),
),
Rounding::same(6.0 * s),
lift(accent, 0.34),
);
let strap = Rect::from_min_max(
Pos2::new(frame.center().x - 6.0 * s, frame.top() + 2.0 * s),
Pos2::new(frame.center().x + 6.0 * s, frame.bottom() - 2.0 * s),
);
painter.rect_filled(strap, Rounding::same(2.0 * s), shade(accent, 0.12));
for index in 0..4 {
let y = strap.top() + 5.0 * s + index as f32 * 8.0 * s;
if y < strap.bottom() - 3.0 * s {
painter.line_segment(
[
Pos2::new(strap.left() + 2.0 * s, y),
Pos2::new(strap.right() - 2.0 * s, y),
],
Stroke::new(1.0, lift(accent, 0.30)),
);
}
}
painter.rect_stroke(
frame,
Rounding::same(6.0 * s),
Stroke::new(1.0, shade(accent, 0.22)),
);
}
fn draw_code_icon(painter: &egui::Painter, rect: Rect, accent: Color32, theme: AppPalette) {
let s = icon_scale(rect);
draw_document_icon(painter, rect, accent, MediaClass::Code, theme);
let center = rect.center() + egui::vec2(1.5 * s, 3.0 * s);
let stroke = Stroke::new((1.8 * s).max(1.0), accent);
painter.line_segment(
[
center + egui::vec2(-8.0 * s, -5.0 * s),
center + egui::vec2(-12.0 * s, 0.0),
],
stroke,
);
painter.line_segment(
[
center + egui::vec2(-12.0 * s, 0.0),
center + egui::vec2(-8.0 * s, 5.0 * s),
],
stroke,
);
painter.line_segment(
[
center + egui::vec2(8.0 * s, -5.0 * s),
center + egui::vec2(12.0 * s, 0.0),
],
stroke,
);
painter.line_segment(
[
center + egui::vec2(12.0 * s, 0.0),
center + egui::vec2(8.0 * s, 5.0 * s),
],
stroke,
);
painter.line_segment(
[
center + egui::vec2(3.0 * s, -7.0 * s),
center + egui::vec2(-3.0 * s, 7.0 * s),
],
stroke,
);
}
fn draw_document_icon(
painter: &egui::Painter,
rect: Rect,
accent: Color32,
class: MediaClass,
theme: AppPalette,
) {
let s = icon_scale(rect);
let frame = rect.shrink(7.0 * s);
icon_shadow(painter, frame, 5.0 * s, 28);
painter.rect_filled(
frame,
Rounding::same(5.0 * s),
if theme.is_light() {
Color32::from_rgb(252, 253, 255)
} else {
Color32::from_rgb(40, 44, 58)
},
);
let fold = 14.0 * s;
painter.add(egui::Shape::convex_polygon(
vec![
frame.right_top(),
frame.right_top() + egui::vec2(0.0, fold),
frame.right_top() + egui::vec2(-fold, 0.0),
],
lift(accent, 0.38),
Stroke::NONE,
));
painter.line_segment(
[
frame.right_top() + egui::vec2(-fold, 0.0),
frame.right_top() + egui::vec2(0.0, fold),
],
Stroke::new(1.0, shade(accent, 0.12)),
);
painter.rect_filled(
Rect::from_min_max(
frame.left_top(),
frame.left_top() + egui::vec2(5.0 * s, frame.height()),
),
Rounding::same(4.0 * s),
accent,
);
painter.line_segment(
[
frame.left_top() + egui::vec2(7.0 * s, 3.0 * s),
frame.right_top() + egui::vec2(-fold - 3.0 * s, 3.0 * s),
],
Stroke::new(1.0, Color32::from_white_alpha(180)),
);
match class {
MediaClass::Pdf => {
painter.text(
frame.center() + egui::vec2(2.0 * s, 5.0 * s),
Align2::CENTER_CENTER,
"PDF",
FontId::monospace((10.0 * s).max(7.0)),
accent,
);
}
MediaClass::Symlink => draw_chrome_icon(
painter,
Rect::from_center_size(
frame.center() + egui::vec2(2.0 * s, 4.0 * s),
Vec2::splat(16.0 * s),
),
ChromeIcon::Link,
accent,
),
MediaClass::Special => draw_chrome_icon(
painter,
Rect::from_center_size(
frame.center() + egui::vec2(2.0 * s, 4.0 * s),
Vec2::splat(16.0 * s),
),
ChromeIcon::Settings,
accent,
),
MediaClass::Code => {}
_ => {
let line_count = if s < 0.7 { 2 } else { 4 };
for index in 0..line_count {
let y = frame.top() + 14.0 * s + index as f32 * 8.0 * s;
painter.line_segment(
[
Pos2::new(frame.left() + 13.0 * s, y),
Pos2::new(frame.right() - 8.0 * s, y),
],
Stroke::new(1.0, theme.muted()),
);
}
}
}
painter.rect_stroke(
frame,
Rounding::same(5.0 * s),
Stroke::new(
1.0,
if theme.is_light() {
Color32::from_rgb(190, 195, 205)
} else {
theme.border()
},
),
);
}
fn empty_state(
ui: &mut egui::Ui,
query: &str,
recursive: bool,
searching: bool,
theme: AppPalette,
) {
let (rect, _) = ui.allocate_exact_size(Vec2::new(ui.available_width(), 170.0), Sense::hover());
let painter = ui.painter_at(rect);
let card = rect.shrink2(egui::vec2(14.0, 8.0));
painter.rect_filled(
card,
Rounding::same(theme_rounding(theme) + 7.0),
Color32::from_rgba_unmultiplied(
theme.recessed_fill().r(),
theme.recessed_fill().g(),
theme.recessed_fill().b(),
if theme.is_light() { 210 } else { 178 },
),
);
painter.rect_stroke(
card,
Rounding::same(theme_rounding(theme) + 7.0),
Stroke::new(1.0, theme.hairline()),
);
let icon_rect =
Rect::from_center_size(card.center() + egui::vec2(0.0, -42.0), Vec2::splat(48.0));
if query.trim().is_empty() {
draw_file_type_icon(&painter, icon_rect, MediaClass::Folder, false, false, theme);
} else {
draw_file_type_icon(&painter, icon_rect, MediaClass::Text, false, false, theme);
let badge = Rect::from_center_size(
icon_rect.right_bottom() + egui::vec2(-8.0, -8.0),
Vec2::splat(20.0),
);
painter.circle_filled(badge.center(), 11.0, theme.surface());
painter.circle_stroke(badge.center(), 11.0, Stroke::new(1.0, theme.hairline()));
draw_chrome_icon(
&painter,
badge.shrink(4.0),
ChromeIcon::Search,
theme.accent(0.0),
);
}
painter.text(
card.center() + egui::vec2(0.0, 9.0),
Align2::CENTER_CENTER,
if searching {
"Searching Subfolders"
} else if query.trim().is_empty() {
"Empty Folder"
} else {
"No Matches"
},
FontId::proportional(16.0),
theme.text(),
);
let detail = if searching {
"Results will appear as the search completes"
} else if query.trim().is_empty() {
"Drop files here or create something new"
} else if recursive {
"No results in this folder or its subfolders"
} else {
"Clear the search or include subfolders"
};
painter.text(
card.center() + egui::vec2(0.0, 35.0),
Align2::CENTER_CENTER,
detail,
FontId::proportional(12.0),
theme.muted(),
);
}
fn panel_frame(
theme: AppPalette,
fill: Color32,
margin: egui::Margin,
opacity: f32,
) -> egui::Frame {
egui::Frame::none()
.fill(themed_alpha(fill, opacity))
.stroke(Stroke::new(1.0, theme.hairline()))
.inner_margin(margin)
}
fn themed_alpha(color: Color32, opacity: f32) -> Color32 {
let alpha = ((color.a() as f32) * opacity.clamp(0.45, 1.0)).round() as u8;
Color32::from_rgba_unmultiplied(color.r(), color.g(), color.b(), alpha)
}
fn pathbar_button(theme: AppPalette, label: &str, current: bool) -> egui::Button<'_> {
let text = RichText::new(truncate_middle(label, 24))
.size(11.8)
.color(if current {
theme.text()
} else {
theme.secondary_text()
});
let text = if current { text.strong() } else { text };
egui::Button::new(text)
.fill(Color32::TRANSPARENT)
.stroke(Stroke::NONE)
.rounding(Rounding::same(theme_rounding(theme) + 2.0))
.min_size(Vec2::new(0.0, 22.0))
}
fn info_chip(ui: &mut egui::Ui, theme: AppPalette, text: &str, active: bool) {
let fill = if active {
Color32::from_rgba_unmultiplied(
theme.accent(0.0).r(),
theme.accent(0.0).g(),
theme.accent(0.0).b(),
if theme.is_light() { 24 } else { 42 },
)
} else {
Color32::from_rgba_unmultiplied(
theme.surface().r(),
theme.surface().g(),
theme.surface().b(),
if theme.is_light() { 210 } else { 192 },
)
};
let stroke = if active {
theme.accent(0.0)
} else {
theme.hairline()
};
egui::Frame::none()
.fill(fill)
.stroke(Stroke::new(1.0, stroke))
.rounding(Rounding::same(theme_rounding(theme)))
.inner_margin(egui::Margin::symmetric(6.0, 2.0))
.show(ui, |ui| {
ui.label(RichText::new(text).size(10.0).color(if active {
theme.accent(0.0)
} else {
theme.muted()
}));
});
}
fn status_button(theme: AppPalette, label: &str) -> egui::Button<'_> {
egui::Button::new(
RichText::new(label)
.size(11.0)
.color(theme.secondary_text()),
)
.fill(theme.elevated_fill())
.stroke(Stroke::new(1.0, theme.hairline()))
.rounding(Rounding::same(theme_rounding(theme) + 6.0))
.min_size(Vec2::new(0.0, 22.0))
}
fn preview_file_header(
ui: &mut egui::Ui,
theme: AppPalette,
opacity: f32,
entry: &FileEntry,
position: Option<(usize, usize)>,
) {
let (rect, _) = ui.allocate_exact_size(Vec2::new(ui.available_width(), 52.0), Sense::hover());
let painter = ui.painter_at(rect);
let card = rect.shrink(1.0);
let rounding = Rounding::same(theme_rounding(theme) + 4.0);
painter.rect_filled(card, rounding, themed_alpha(theme.surface(), opacity));
painter.line_segment(
[card.left_bottom(), card.right_bottom()],
Stroke::new(1.0, theme.hairline()),
);
let class = media_class_for_entry(entry);
let icon_back = Rect::from_center_size(
card.left_center() + egui::vec2(25.0, 0.0),
Vec2::splat(34.0),
);
painter.rect_filled(
icon_back,
Rounding::same(theme_rounding(theme) + 5.0),
theme.recessed_fill(),
);
draw_file_type_icon(&painter, icon_back.shrink(4.0), class, false, false, theme);
let position_label = position.map(|(index, total)| format!("{} of {}", index, total));
let reserved = if position_label.is_some() { 86.0 } else { 16.0 };
let text_width = (card.width() - 58.0 - reserved).max(90.0);
let title_chars = (text_width / 8.2).floor().max(12.0) as usize;
let detail_chars = (text_width / 6.2).floor().max(16.0) as usize;
let parent = entry
.path
.parent()
.map(path_for_display)
.unwrap_or_else(|| path_for_display(&entry.path));
let detail = format!(
"{} / {} / {}",
entry.type_label(),
human_size(entry.size),
parent
);
let text_x = card.left() + 50.0;
painter.text(
Pos2::new(text_x, card.center().y - 10.0),
Align2::LEFT_CENTER,
truncate_middle(&entry.name, title_chars),
FontId::proportional(15.0),
theme.text(),
);
painter.text(
Pos2::new(text_x, card.center().y + 12.0),
Align2::LEFT_CENTER,
truncate_middle(&detail, detail_chars),
FontId::proportional(10.5),
theme.muted(),
);
if let Some(position_label) = position_label {
painter.text(
card.right_center() + egui::vec2(-14.0, 0.0),
Align2::RIGHT_CENTER,
position_label,
FontId::monospace(10.5),
theme.secondary_text(),
);
}
}
fn overlay_header(
ui: &mut egui::Ui,
theme: AppPalette,
opacity: f32,
icon: ChromeIcon,
title: &str,
detail: &str,
) {
let (rect, _) = ui.allocate_exact_size(Vec2::new(ui.available_width(), 66.0), Sense::hover());
let painter = ui.painter_at(rect);
let card = rect.shrink2(egui::vec2(1.0, 1.0));
painter.rect_filled(
card,
Rounding::same(theme_rounding(theme) + 10.0),
themed_alpha(theme.surface(), opacity),
);
painter.rect_stroke(
card,
Rounding::same(theme_rounding(theme) + 10.0),
Stroke::new(1.0, theme.hairline()),
);
painter.rect_filled(
Rect::from_min_max(card.left_top(), card.left_bottom() + egui::vec2(4.0, 0.0)),
Rounding::same(theme_rounding(theme) + 6.0),
theme.accent(0.0),
);
let icon_rect = Rect::from_center_size(
card.left_center() + egui::vec2(28.0, 0.0),
Vec2::splat(28.0),
);
let icon_glow = Rect::from_center_size(
icon_rect.center_bottom() + egui::vec2(0.0, -2.0),
Vec2::new(25.0, 5.0),
);
painter.rect_filled(
icon_glow,
Rounding::same(999.0),
Color32::from_rgba_unmultiplied(
theme.accent(0.0).r(),
theme.accent(0.0).g(),
theme.accent(0.0).b(),
if theme.is_light() { 32 } else { 46 },
),
);
draw_chrome_icon(&painter, icon_rect, icon, theme.accent(0.0));
painter.text(
card.left_center() + egui::vec2(58.0, -10.0),
Align2::LEFT_CENTER,
truncate_middle(title, 34),
FontId::proportional(17.0),
theme.text(),
);
painter.text(
card.left_center() + egui::vec2(58.0, 12.0),
Align2::LEFT_CENTER,
truncate_middle(detail, 48),
FontId::proportional(11.0),
theme.muted(),
);
}
fn overlay_card(
ui: &mut egui::Ui,
theme: AppPalette,
opacity: f32,
icon: ChromeIcon,
title: &str,
add_contents: impl FnOnce(&mut egui::Ui),
) {
egui::Frame::none()
.fill(themed_alpha(theme.surface(), opacity))
.stroke(Stroke::new(1.0, theme.hairline()))
.rounding(Rounding::same(theme_rounding(theme) + 8.0))
.inner_margin(egui::Margin::same(10.0))
.show(ui, |ui| {
ui.horizontal(|ui| {
inline_chrome_icon(ui, theme, icon, theme.accent(0.0));
ui.label(
RichText::new(title)
.size(12.0)
.strong()
.color(theme.secondary_text()),
);
});
ui.add_space(8.0);
add_contents(ui);
});
ui.add_space(8.0);
}
fn inline_chrome_icon(ui: &mut egui::Ui, theme: AppPalette, icon: ChromeIcon, color: Color32) {
let (rect, _) = ui.allocate_exact_size(Vec2::splat(20.0), Sense::hover());
let painter = ui.painter_at(rect);
let _ = theme;
draw_chrome_icon(&painter, rect.shrink(3.0), icon, color);
}
fn icon_button(
ui: &mut egui::Ui,
theme: AppPalette,
icon: ChromeIcon,
label: Option<&str>,
active: bool,
enabled: bool,
) -> egui::Response {
let quiet = label.is_none();
let label_width = label
.map(|text| text.chars().count() as f32 * 6.4 + 32.0)
.unwrap_or(28.0);
let width = label_width.clamp(28.0, 116.0);
let sense = if enabled {
Sense::click()
} else {
Sense::hover()
};
let (rect, response) = ui.allocate_exact_size(Vec2::new(width, 28.0), sense);
let painter = ui.painter_at(rect);
let row = rect.shrink2(egui::vec2(1.0, 1.0));
let fill = if active {
theme.selected_fill()
} else if enabled && response.hovered() {
theme.hover_fill()
} else if !enabled {
if quiet {
Color32::TRANSPARENT
} else {
Color32::from_rgba_unmultiplied(
theme.button_fill().r(),
theme.button_fill().g(),
theme.button_fill().b(),
if theme.is_light() { 92 } else { 76 },
)
}
} else if quiet {
Color32::TRANSPARENT
} else {
theme.button_fill()
};
painter.rect_filled(row, Rounding::same(theme_rounding(theme) + 4.0), fill);
painter.rect_stroke(
row,
Rounding::same(theme_rounding(theme) + 4.0),
Stroke::new(
1.0,
if quiet || active {
Color32::TRANSPARENT
} else {
theme.hairline()
},
),
);
let color = if !enabled {
Color32::from_rgba_unmultiplied(
theme.muted().r(),
theme.muted().g(),
theme.muted().b(),
120,
)
} else if active {
theme.accent(0.0)
} else {
theme.secondary_text()
};
let icon_rect = Rect::from_center_size(
Pos2::new(row.left() + 14.0, row.center().y),
Vec2::splat(15.0),
);
draw_chrome_icon(&painter, icon_rect, icon, color);
if let Some(label) = label {
painter.text(
Pos2::new(row.left() + 28.0, row.center().y),
Align2::LEFT_CENTER,
label,
FontId::proportional(11.0),
if enabled { theme.text() } else { color },
);
}
response
}
fn horizontal_chevron_points(rect: Rect, forward: bool) -> [Pos2; 3] {
let center = rect.center();
let direction = if forward { 1.0 } else { -1.0 };
[
center + egui::vec2(-2.5 * direction, -5.0),
center + egui::vec2(3.5 * direction, 0.0),
center + egui::vec2(-2.5 * direction, 5.0),
]
}
fn draw_chrome_icon(painter: &egui::Painter, rect: Rect, icon: ChromeIcon, color: Color32) {
let stroke = Stroke::new(1.5, color);
let thin = Stroke::new(1.1, color);
match icon {
ChromeIcon::Sidebar => {
painter.rect_stroke(rect, Rounding::same(2.0), thin);
painter.line_segment(
[
Pos2::new(rect.left() + rect.width() * 0.38, rect.top()),
Pos2::new(rect.left() + rect.width() * 0.38, rect.bottom()),
],
thin,
);
for index in 0..3 {
let y = rect.top() + 4.0 + index as f32 * 3.7;
painter.line_segment(
[
Pos2::new(rect.left() + 2.0, y),
Pos2::new(rect.left() + 4.0, y),
],
thin,
);
}
}
ChromeIcon::Back | ChromeIcon::Forward => {
let [top, tip, bottom] =
horizontal_chevron_points(rect, matches!(icon, ChromeIcon::Forward));
painter.line_segment([top, tip], stroke);
painter.line_segment([tip, bottom], stroke);
}
ChromeIcon::Up | ChromeIcon::Down => {
let c = rect.center();
let dir = if matches!(icon, ChromeIcon::Up) {
-1.0
} else {
1.0
};
let tip = c + egui::vec2(0.0, 5.5 * dir);
painter.line_segment([c + egui::vec2(0.0, -5.5 * dir), tip], stroke);
painter.line_segment([tip, c + egui::vec2(-4.0, 1.0 * dir)], stroke);
painter.line_segment([tip, c + egui::vec2(4.0, 1.0 * dir)], stroke);
}
ChromeIcon::Home => draw_home_place_icon(painter, rect.shrink(1.0), color),
ChromeIcon::Folder => draw_folder_place_icon(painter, rect.shrink(1.0), color),
ChromeIcon::Search => {
painter.circle_stroke(rect.center() + egui::vec2(-1.5, -1.5), 4.4, thin);
painter.line_segment(
[
rect.center() + egui::vec2(2.0, 2.0),
rect.right_bottom() + egui::vec2(-2.0, -2.0),
],
stroke,
);
}
ChromeIcon::ZoomOut | ChromeIcon::ZoomIn => {
painter.circle_stroke(rect.center() + egui::vec2(-1.5, -1.5), 4.4, thin);
painter.line_segment(
[
rect.center() + egui::vec2(2.0, 2.0),
rect.right_bottom() + egui::vec2(-2.0, -2.0),
],
stroke,
);
painter.line_segment(
[
rect.center() + egui::vec2(-4.5, -1.5),
rect.center() + egui::vec2(1.5, -1.5),
],
thin,
);
if matches!(icon, ChromeIcon::ZoomIn) {
painter.line_segment(
[
rect.center() + egui::vec2(-1.5, -4.5),
rect.center() + egui::vec2(-1.5, 1.5),
],
thin,
);
}
}
ChromeIcon::ResetZoom => {
let frame = rect.shrink(2.0);
let corner = 3.5;
for (start, horizontal, vertical) in [
(frame.left_top(), 1.0, 1.0),
(frame.right_top(), -1.0, 1.0),
(frame.left_bottom(), 1.0, -1.0),
(frame.right_bottom(), -1.0, -1.0),
] {
painter.line_segment([start, start + egui::vec2(horizontal * corner, 0.0)], thin);
painter.line_segment([start, start + egui::vec2(0.0, vertical * corner)], thin);
}
}
ChromeIcon::Close => {
let center = rect.center();
painter.line_segment(
[
center + egui::vec2(-4.5, -4.5),
center + egui::vec2(4.5, 4.5),
],
stroke,
);
painter.line_segment(
[
center + egui::vec2(4.5, -4.5),
center + egui::vec2(-4.5, 4.5),
],
stroke,
);
}
ChromeIcon::Open => {
draw_folder_place_icon(painter, rect.shrink(1.0), color);
painter.line_segment(
[
rect.center() + egui::vec2(-1.5, -2.5),
rect.center() + egui::vec2(4.5, 0.0),
],
stroke,
);
painter.line_segment(
[
rect.center() + egui::vec2(4.5, 0.0),
rect.center() + egui::vec2(-1.5, 2.5),
],
stroke,
);
}
ChromeIcon::OpenExternal => {
let frame = rect.shrink(3.0);
painter.rect_stroke(
Rect::from_min_max(
frame.left_bottom() + egui::vec2(0.0, -8.0),
frame.right_bottom(),
),
Rounding::same(1.5),
thin,
);
let start = rect.center() + egui::vec2(-2.0, 2.0);
let end = rect.right_top() + egui::vec2(-2.0, 2.0);
painter.line_segment([start, end], stroke);
painter.line_segment([end, end + egui::vec2(-5.0, 0.0)], stroke);
painter.line_segment([end, end + egui::vec2(0.0, 5.0)], stroke);
}
ChromeIcon::Check => {
painter.line_segment(
[
rect.center() + egui::vec2(-6.0, 0.0),
rect.center() + egui::vec2(-1.5, 4.5),
],
stroke,
);
painter.line_segment(
[
rect.center() + egui::vec2(-1.5, 4.5),
rect.center() + egui::vec2(6.0, -5.0),
],
stroke,
);
}
ChromeIcon::Error => {
painter.circle_stroke(
rect.center(),
rect.width().min(rect.height()) * 0.40,
stroke,
);
painter.line_segment(
[
rect.center() + egui::vec2(0.0, -4.0),
rect.center() + egui::vec2(0.0, 1.5),
],
stroke,
);
painter.circle_filled(rect.center() + egui::vec2(0.0, 5.0), 1.1, color);
}
ChromeIcon::Preview => {
let screen = rect.shrink2(egui::vec2(1.5, 3.0));
painter.rect_stroke(screen, Rounding::same(2.2), thin);
painter.circle_stroke(screen.center(), 3.8, thin);
painter.circle_filled(screen.center(), 1.7, color);
}
ChromeIcon::Window => {
painter.rect_stroke(rect.shrink(2.0), Rounding::same(2.0), thin);
painter.line_segment(
[
Pos2::new(rect.left() + 3.0, rect.top() + 5.0),
Pos2::new(rect.right() - 3.0, rect.top() + 5.0),
],
thin,
);
painter.circle_filled(rect.left_top() + egui::vec2(4.0, 3.0), 0.9, color);
painter.circle_filled(rect.left_top() + egui::vec2(7.0, 3.0), 0.9, color);
}
ChromeIcon::Terminal => {
painter.rect_stroke(rect.shrink(2.0), Rounding::same(2.0), thin);
let left = rect.left_center() + egui::vec2(3.0, -1.0);
painter.line_segment([left, left + egui::vec2(3.0, 2.0)], stroke);
painter.line_segment(
[left + egui::vec2(3.0, 2.0), left + egui::vec2(0.0, 4.0)],
stroke,
);
painter.line_segment(
[
rect.center() + egui::vec2(2.0, 4.0),
rect.center() + egui::vec2(6.0, 4.0),
],
thin,
);
}
ChromeIcon::Refresh => {
painter.circle_stroke(rect.center(), 5.4, thin);
painter.line_segment(
[
rect.center() + egui::vec2(4.8, -3.2),
rect.center() + egui::vec2(7.0, -6.0),
],
stroke,
);
painter.line_segment(
[
rect.center() + egui::vec2(4.8, -3.2),
rect.center() + egui::vec2(2.0, -5.2),
],
stroke,
);
}
ChromeIcon::FolderPlus => {
draw_folder_place_icon(painter, rect.shrink(1.0), color);
draw_plus(
painter,
rect.right_bottom() + egui::vec2(-4.0, -4.0),
2.8,
color,
);
}
ChromeIcon::FolderOrder => {
draw_folder_place_icon(painter, rect.shrink(1.0), color);
let center = rect.right_bottom() + egui::vec2(-3.0, -4.0);
painter.line_segment(
[
center + egui::vec2(-2.0, -2.5),
center + egui::vec2(2.0, -2.5),
],
Stroke::new(1.0, color),
);
painter.line_segment(
[
center + egui::vec2(-2.0, 0.5),
center + egui::vec2(2.0, 0.5),
],
Stroke::new(1.0, color),
);
}
ChromeIcon::FilePlus => {
draw_document_outline(painter, rect, color);
draw_plus(painter, rect.center() + egui::vec2(2.0, 2.0), 3.0, color);
}
ChromeIcon::Cut => {
painter.circle_stroke(rect.left_center() + egui::vec2(3.0, -3.0), 2.0, thin);
painter.circle_stroke(rect.left_center() + egui::vec2(3.0, 4.0), 2.0, thin);
painter.line_segment(
[
rect.left_center() + egui::vec2(5.0, -2.0),
rect.right_top() + egui::vec2(-2.0, 3.0),
],
thin,
);
painter.line_segment(
[
rect.left_center() + egui::vec2(5.0, 3.0),
rect.right_bottom() + egui::vec2(-2.0, -3.0),
],
thin,
);
}
ChromeIcon::Copy => {
painter.rect_stroke(
rect.translate(egui::vec2(-2.0, 2.0)).shrink(3.0),
Rounding::same(1.5),
thin,
);
painter.rect_stroke(
rect.translate(egui::vec2(2.0, -2.0)).shrink(3.0),
Rounding::same(1.5),
thin,
);
}
ChromeIcon::Paste => {
painter.rect_stroke(
rect.shrink2(egui::vec2(3.0, 2.0)),
Rounding::same(1.5),
thin,
);
painter.rect_filled(
Rect::from_center_size(
rect.center_top() + egui::vec2(0.0, 2.0),
Vec2::new(6.0, 3.0),
),
Rounding::same(1.0),
color,
);
}
ChromeIcon::Trash => draw_trash_place_icon(painter, rect.shrink(1.0), color),
ChromeIcon::Pin => {
painter.circle_stroke(rect.center_top() + egui::vec2(0.0, 4.0), 3.0, thin);
painter.line_segment(
[
rect.center() + egui::vec2(0.0, -1.0),
rect.center() + egui::vec2(0.0, 6.0),
],
stroke,
);
painter.line_segment(
[
rect.center() + egui::vec2(-4.0, 2.0),
rect.center() + egui::vec2(4.0, 2.0),
],
thin,
);
}
ChromeIcon::Rename => {
painter.line_segment(
[
rect.left_bottom() + egui::vec2(3.0, -3.0),
rect.right_top() + egui::vec2(-3.0, 3.0),
],
stroke,
);
painter.line_segment(
[
rect.left_bottom() + egui::vec2(2.0, -2.0),
rect.left_bottom() + egui::vec2(5.0, -1.0),
],
thin,
);
painter.rect_stroke(
Rect::from_min_size(
rect.right_top() + egui::vec2(-5.0, 2.0),
Vec2::new(3.0, 3.0),
),
Rounding::same(0.8),
thin,
);
}
ChromeIcon::Duplicate => {
painter.rect_stroke(
rect.translate(egui::vec2(-2.0, 2.0)).shrink(3.0),
Rounding::same(1.5),
thin,
);
painter.rect_stroke(
rect.translate(egui::vec2(2.0, -2.0)).shrink(3.0),
Rounding::same(1.5),
thin,
);
draw_plus(
painter,
rect.right_bottom() + egui::vec2(-4.0, -4.0),
2.5,
color,
);
}
ChromeIcon::Sort => {
for index in 0..3 {
let y = rect.top() + 3.0 + index as f32 * 4.5;
painter.line_segment(
[
Pos2::new(rect.left() + 2.0, y),
Pos2::new(rect.right() - 2.0 - index as f32 * 3.0, y),
],
thin,
);
}
}
ChromeIcon::Hidden => {
let eye = Rect::from_center_size(rect.center(), Vec2::new(13.0, 8.0));
painter.circle_stroke(eye.center(), 3.5, thin);
painter.circle_filled(eye.center(), 1.5, color);
painter.line_segment(
[eye.left_top(), eye.right_bottom()],
Stroke::new(1.8, color),
);
}
ChromeIcon::Link => {
let left =
Rect::from_center_size(rect.center() + egui::vec2(-3.2, 0.0), Vec2::new(6.0, 4.6));
let right =
Rect::from_center_size(rect.center() + egui::vec2(3.2, 0.0), Vec2::new(6.0, 4.6));
painter.rect_stroke(left, Rounding::same(2.6), thin);
painter.rect_stroke(right, Rounding::same(2.6), thin);
painter.line_segment(
[
rect.center() + egui::vec2(-2.0, 0.0),
rect.center() + egui::vec2(2.0, 0.0),
],
stroke,
);
}
ChromeIcon::Lock => {
let body = Rect::from_min_max(
rect.left_bottom() + egui::vec2(2.5, -7.0),
rect.right_bottom() + egui::vec2(-2.5, -1.0),
);
painter.rect_stroke(body, Rounding::same(1.8), thin);
painter.circle_stroke(rect.center_top() + egui::vec2(0.0, 5.5), 3.1, thin);
painter.circle_filled(body.center(), 0.9, color);
}
ChromeIcon::List => {
for index in 0..3 {
let y = rect.top() + 3.0 + index as f32 * 4.5;
painter.circle_filled(Pos2::new(rect.left() + 2.0, y), 1.0, color);
painter.line_segment(
[
Pos2::new(rect.left() + 5.0, y),
Pos2::new(rect.right() - 1.0, y),
],
thin,
);
}
}
ChromeIcon::Grid => {
for x in 0..2 {
for y in 0..2 {
let min =
rect.left_top() + egui::vec2(2.0 + x as f32 * 6.5, 2.0 + y as f32 * 6.5);
painter.rect_stroke(
Rect::from_min_size(min, Vec2::splat(4.5)),
Rounding::same(1.0),
thin,
);
}
}
}
ChromeIcon::Properties => draw_document_outline(painter, rect, color),
ChromeIcon::Settings => {
painter.circle_stroke(rect.center(), 4.0, thin);
painter.circle_filled(rect.center(), 1.6, color);
for angle in [
0.0_f32,
std::f32::consts::FRAC_PI_2,
std::f32::consts::PI,
std::f32::consts::PI + std::f32::consts::FRAC_PI_2,
] {
let dir = egui::vec2(angle.cos(), angle.sin());
painter.line_segment([rect.center() + dir * 5.0, rect.center() + dir * 7.0], thin);
}
}
}
}
fn draw_plus(painter: &egui::Painter, center: Pos2, radius: f32, color: Color32) {
let stroke = Stroke::new(1.4, color);
painter.line_segment(
[
center + egui::vec2(-radius, 0.0),
center + egui::vec2(radius, 0.0),
],
stroke,
);
painter.line_segment(
[
center + egui::vec2(0.0, -radius),
center + egui::vec2(0.0, radius),
],
stroke,
);
}
fn draw_document_outline(painter: &egui::Painter, rect: Rect, color: Color32) {
let frame = rect.shrink2(egui::vec2(3.0, 1.5));
painter.rect_stroke(frame, Rounding::same(1.8), Stroke::new(1.1, color));
painter.line_segment(
[
Pos2::new(frame.left() + 3.0, frame.center().y),
Pos2::new(frame.right() - 3.0, frame.center().y),
],
Stroke::new(1.0, color),
);
}
fn command_separator(ui: &mut egui::Ui, theme: AppPalette) {
let (rect, _) = ui.allocate_exact_size(Vec2::new(8.0, 26.0), Sense::hover());
ui.painter_at(rect).line_segment(
[
rect.center_top() + egui::vec2(0.0, 4.0),
rect.center_bottom() + egui::vec2(0.0, -4.0),
],
Stroke::new(1.0, theme.border()),
);
}
fn context_menu_chrome(ui: &mut egui::Ui, theme: AppPalette, title: &str, detail: &str) {
ui.set_min_width(246.0);
ui.spacing_mut().item_spacing = Vec2::new(0.0, 3.0);
let (rect, _) = ui.allocate_exact_size(Vec2::new(ui.available_width(), 42.0), Sense::hover());
let painter = ui.painter_at(rect);
let card = rect.shrink2(egui::vec2(1.0, 1.0));
painter.rect_filled(
card,
Rounding::same(theme_rounding(theme) + 4.0),
theme.elevated_fill(),
);
painter.rect_stroke(
card,
Rounding::same(theme_rounding(theme) + 4.0),
Stroke::new(1.0, theme.hairline()),
);
painter.rect_filled(
Rect::from_min_max(card.left_top(), card.left_bottom() + egui::vec2(3.0, 0.0)),
Rounding::same(theme_rounding(theme) + 2.0),
theme.accent(0.0),
);
painter.text(
card.left_top() + egui::vec2(12.0, 8.0),
Align2::LEFT_TOP,
truncate_middle(title, 28),
FontId::proportional(12.5),
theme.text(),
);
painter.text(
card.left_top() + egui::vec2(12.0, 25.0),
Align2::LEFT_TOP,
truncate_middle(detail, 30),
FontId::proportional(10.0),
theme.muted(),
);
}
fn context_section(ui: &mut egui::Ui, theme: AppPalette, label: &str) {
let (rect, _) = ui.allocate_exact_size(Vec2::new(ui.available_width(), 18.0), Sense::hover());
ui.painter_at(rect).text(
rect.left_center() + egui::vec2(8.0, 0.0),
Align2::LEFT_CENTER,
label.to_ascii_uppercase(),
FontId::monospace(9.0),
theme.muted(),
);
}
fn context_divider(ui: &mut egui::Ui, theme: AppPalette) {
let (rect, _) = ui.allocate_exact_size(Vec2::new(ui.available_width(), 8.0), Sense::hover());
ui.painter_at(rect).line_segment(
[
rect.left_center() + egui::vec2(6.0, 0.0),
rect.right_center() + egui::vec2(-6.0, 0.0),
],
Stroke::new(1.0, theme.border()),
);
}
fn context_action(
ui: &mut egui::Ui,
theme: AppPalette,
icon: ChromeIcon,
label: &str,
hint: &str,
enabled: bool,
destructive: bool,
) -> bool {
let sense = if enabled {
Sense::click()
} else {
Sense::hover()
};
let (rect, response) = ui.allocate_exact_size(Vec2::new(ui.available_width(), 30.0), sense);
let painter = ui.painter_at(rect);
let row = rect.shrink2(egui::vec2(2.0, 1.0));
if enabled && response.hovered() {
painter.rect_filled(
row,
Rounding::same(theme_rounding(theme) + 4.0),
theme.hover_fill(),
);
painter.rect_stroke(
row,
Rounding::same(theme_rounding(theme) + 4.0),
Stroke::new(1.0, theme.hairline()),
);
}
let base_color = if destructive {
Color32::from_rgb(255, 114, 108)
} else if enabled {
theme.text()
} else {
theme.muted()
};
let hint_color = if enabled {
theme.muted()
} else {
Color32::from_rgba_unmultiplied(
theme.muted().r(),
theme.muted().g(),
theme.muted().b(),
120,
)
};
draw_chrome_icon(
&painter,
Rect::from_center_size(row.left_center() + egui::vec2(14.0, 0.0), Vec2::splat(14.0)),
icon,
base_color,
);
painter.text(
row.left_center() + egui::vec2(32.0, 0.0),
Align2::LEFT_CENTER,
label,
FontId::proportional(12.0),
base_color,
);
painter.text(
row.right_center() + egui::vec2(-10.0, 0.0),
Align2::RIGHT_CENTER,
hint,
FontId::monospace(9.5),
hint_color,
);
enabled && response.clicked()
}
fn sidebar_section_header(ui: &mut egui::Ui, theme: AppPalette, title: &str, detail: &str) {
ui.vertical(|ui| {
ui.horizontal(|ui| {
ui.label(
RichText::new(title)
.size(11.5)
.strong()
.color(theme.secondary_text()),
);
if !detail.is_empty() {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.label(RichText::new(detail).size(9.0).color(theme.muted()));
});
}
});
let (rect, _) =
ui.allocate_exact_size(Vec2::new(ui.available_width(), 1.0), Sense::hover());
ui.painter_at(rect).line_segment(
[rect.left_center(), rect.right_center()],
Stroke::new(1.0, theme.hairline()),
);
});
}
fn sidebar_pin_empty_state(ui: &mut egui::Ui, theme: AppPalette) {
egui::Frame::none()
.fill(theme.elevated_fill())
.stroke(Stroke::new(1.0, theme.hairline()))
.rounding(Rounding::same(theme_rounding(theme) + 6.0))
.inner_margin(egui::Margin::same(8.0))
.show(ui, |ui| {
ui.horizontal(|ui| {
inline_chrome_icon(ui, theme, ChromeIcon::Pin, theme.accent(0.0));
ui.vertical(|ui| {
ui.label(
RichText::new("No pinned folders")
.size(12.0)
.strong()
.color(theme.text()),
);
ui.label(
RichText::new("Pin current or drag folders here")
.size(10.5)
.color(theme.muted()),
);
});
});
});
}
fn compact_section_icon(ui: &mut egui::Ui, theme: AppPalette, icon: ChromeIcon) {
let (rect, _) = ui.allocate_exact_size(Vec2::new(34.0, 20.0), Sense::hover());
let painter = ui.painter_at(rect);
let icon_rect = Rect::from_center_size(rect.center(), Vec2::splat(15.0));
painter.rect_filled(
Rect::from_center_size(
rect.center_bottom() + egui::vec2(0.0, -3.0),
Vec2::new(18.0, 4.0),
),
Rounding::same(999.0),
Color32::from_rgba_unmultiplied(
theme.accent(0.0).r(),
theme.accent(0.0).g(),
theme.accent(0.0).b(),
if theme.is_light() { 22 } else { 36 },
),
);
draw_chrome_icon(&painter, icon_rect, icon, theme.accent(0.0));
}
fn theme_rounding(theme: AppPalette) -> f32 {
match theme {
AppPalette::NautilusLight | AppPalette::NautilusDark => 4.0,
AppPalette::MacosLight | AppPalette::MacosDark => 6.0,
}
}
fn folder_title(path: &Path) -> String {
if path == home_dir() {
return "Home".to_string();
}
path.file_name()
.and_then(|name| name.to_str())
.map(str::to_string)
.unwrap_or_else(|| path.display().to_string())
}
fn title_case(value: &str) -> String {
let mut words = Vec::new();
for (index, word) in value.split_whitespace().enumerate() {
words.push(title_word(word, index));
}
words.join(" ")
}
fn title_word(word: &str, index: usize) -> String {
match word {
"/" | "·" => return word.to_string(),
"GPU" | "WGPU" | "CPU" | "USB" | "SSH" | "HTTP" | "HTTPS" | "DX12" | "N/A" => {
return word.to_string()
}
"GLOW" => return "Glow".to_string(),
"OPENGL" => return "OpenGL".to_string(),
"GLES" => return "GLES".to_string(),
"VSYNC" => return "VSync".to_string(),
"GUTH" => return "Guth".to_string(),
_ => {}
}
let lower = word.to_ascii_lowercase();
if index > 0
&& matches!(
lower.as_str(),
"and" | "or" | "to" | "of" | "in" | "on" | "at" | "for"
)
{
return lower;
}
let mut chars = lower.chars();
if let Some(first) = chars.next() {
format!("{}{}", first.to_uppercase(), chars.as_str())
} else {
String::new()
}
}
fn pretty_value(value: &str) -> String {
let normalized = value.replace(" // ", " · ").replace(" -> ", " to ");
if normalized.chars().any(|ch| ch.is_lowercase()) {
normalized
} else {
title_case(&normalized)
}
}
fn pretty_status(value: &str) -> String {
let normalized = value
.replace(" // ", " · ")
.replace(" ITEMS", " items")
.replace(" ITEM", " item")
.replace(" BYTES", " bytes");
if normalized.chars().any(|ch| ch.is_lowercase()) {
normalized
} else {
title_case(&normalized)
}
}
fn window_frame(theme: AppPalette, opacity: f32) -> egui::Frame {
egui::Frame::none()
.fill(themed_alpha(theme.elevated_fill(), opacity))
.stroke(Stroke::new(1.0, theme.hairline()))
.rounding(Rounding::same(theme_rounding(theme) + 6.0))
.inner_margin(egui::Margin::same(12.0))
}
fn load_background_image(path: &Path) -> Result<egui::ColorImage, String> {
if !path.is_file() {
return Err("Background image path is not a file".to_string());
}
let metadata = std::fs::metadata(path)
.map_err(|error| format!("Background image metadata failed: {error}"))?;
if metadata.len() > BACKGROUND_IMAGE_BYTES_LIMIT {
return Err(format!(
"Background image is too large: {} limit",
human_size(BACKGROUND_IMAGE_BYTES_LIMIT)
));
}
let dimensions_reader = image::ImageReader::open(path)
.map_err(|error| format!("Background image open failed: {error}"))?
.with_guessed_format()
.map_err(|error| format!("Background image format failed: {error}"))?;
let (width, height) = dimensions_reader
.into_dimensions()
.map_err(|error| format!("Background image dimensions failed: {error}"))?;
let pixels = u64::from(width).saturating_mul(u64::from(height));
if pixels > BACKGROUND_IMAGE_PIXELS_LIMIT {
return Err(format!(
"Background image dimensions are too large: {} pixels limit",
BACKGROUND_IMAGE_PIXELS_LIMIT
));
}
let image = image::ImageReader::open(path)
.map_err(|error| format!("Background image open failed: {error}"))?
.with_guessed_format()
.map_err(|error| format!("Background image format failed: {error}"))?
.decode()
.map_err(|error| format!("Background image decode failed: {error}"))?
.to_rgba8();
let size = [image.width() as usize, image.height() as usize];
Ok(egui::ColorImage::from_rgba_unmultiplied(
size,
image.as_raw(),
))
}
fn load_media_preview_image(path: &Path) -> Result<LoadedPreviewImage, String> {
if !path.is_file() {
return Err("Preview path is not a file".to_string());
}
let metadata = std::fs::metadata(path)
.map_err(|error| format!("Preview image metadata failed: {error}"))?;
if metadata.len() > MEDIA_PREVIEW_IMAGE_BYTES_LIMIT {
return Err(format!(
"Preview image is too large: {} limit",
human_size(MEDIA_PREVIEW_IMAGE_BYTES_LIMIT)
));
}
let dimensions_reader = image::ImageReader::open(path)
.map_err(|error| format!("Preview image open failed: {error}"))?
.with_guessed_format()
.map_err(|error| format!("Preview image format failed: {error}"))?;
let (width, height) = dimensions_reader
.into_dimensions()
.map_err(|error| format!("Preview image dimensions failed: {error}"))?;
let pixels = u64::from(width).saturating_mul(u64::from(height));
if pixels > MEDIA_PREVIEW_IMAGE_PIXELS_LIMIT {
return Err(format!(
"Preview image dimensions are too large: {} pixels limit",
MEDIA_PREVIEW_IMAGE_PIXELS_LIMIT
));
}
let mut image = image::ImageReader::open(path)
.map_err(|error| format!("Preview image open failed: {error}"))?
.with_guessed_format()
.map_err(|error| format!("Preview image format failed: {error}"))?
.decode()
.map_err(|error| format!("Preview image decode failed: {error}"))?;
if width.max(height) > MEDIA_PREVIEW_IMAGE_MAX_EDGE {
image = image.thumbnail(MEDIA_PREVIEW_IMAGE_MAX_EDGE, MEDIA_PREVIEW_IMAGE_MAX_EDGE);
}
let image = image.to_rgba8();
let size = [image.width() as usize, image.height() as usize];
Ok(LoadedPreviewImage {
pixels: egui::ColorImage::from_rgba_unmultiplied(size, image.as_raw()),
source_dimensions: [width, height],
})
}
fn load_text_preview(path: &Path) -> Result<TextPreviewDocument, String> {
if !path.is_file() {
return Err("Text preview path is not a file".to_string());
}
let metadata = std::fs::metadata(path)
.map_err(|error| format!("Text preview metadata failed: {error}"))?;
let mut file =
std::fs::File::open(path).map_err(|error| format!("Text preview open failed: {error}"))?;
let read_limit = TEXT_PREVIEW_BYTES_LIMIT.saturating_add(1);
let capacity = metadata.len().min(read_limit).min(usize::MAX as u64) as usize;
let mut bytes = Vec::with_capacity(capacity);
file.by_ref()
.take(read_limit)
.read_to_end(&mut bytes)
.map_err(|error| format!("Text preview read failed: {error}"))?;
let mut truncated = metadata.len() > TEXT_PREVIEW_BYTES_LIMIT;
if bytes.len() as u64 > TEXT_PREVIEW_BYTES_LIMIT {
bytes.truncate(TEXT_PREVIEW_BYTES_LIMIT as usize);
truncated = true;
}
if looks_like_binary(&bytes) {
return Err("Text preview skipped: binary data detected".to_string());
}
truncated |= trim_incomplete_utf8(&mut bytes);
let lossy = std::str::from_utf8(&bytes).is_err();
let mut content = String::from_utf8_lossy(&bytes).into_owned();
let total_lines = content.lines().count();
if total_lines > TEXT_PREVIEW_LINE_LIMIT {
content = content
.lines()
.take(TEXT_PREVIEW_LINE_LIMIT)
.collect::<Vec<_>>()
.join("\n");
truncated = true;
}
let line_count = content.lines().count();
let numbered_content = numbered_text(&content);
Ok(TextPreviewDocument {
content,
numbered_content,
bytes_read: bytes.len(),
line_count,
truncated,
lossy,
})
}
fn trim_incomplete_utf8(bytes: &mut Vec<u8>) -> bool {
match std::str::from_utf8(bytes) {
Err(error) if error.error_len().is_none() => {
bytes.truncate(error.valid_up_to());
true
}
_ => false,
}
}
fn looks_like_binary(bytes: &[u8]) -> bool {
let sample_len = bytes.len().min(4096);
if sample_len == 0 {
return false;
}
let sample = &bytes[..sample_len];
if sample.contains(&0) {
return true;
}
let control_count = sample
.iter()
.filter(|byte| matches!(**byte, 0x00..=0x08 | 0x0E..=0x1F | 0x7F))
.count();
control_count > sample_len / 12
}
fn numbered_text(content: &str) -> String {
if content.is_empty() {
return String::new();
}
let line_count = content.lines().count().max(1);
let width = decimal_digits(line_count);
let mut numbered = String::with_capacity(content.len() + line_count * (width + 3));
for (index, line) in content.lines().enumerate() {
use std::fmt::Write as _;
let _ = writeln!(numbered, "{:>width$} {line}", index + 1, width = width);
}
if !content.ends_with('\n') && numbered.ends_with('\n') {
numbered.pop();
}
numbered
}
fn decimal_digits(value: usize) -> usize {
let mut digits = 1;
let mut value = value;
while value >= 10 {
value /= 10;
digits += 1;
}
digits
}
fn count_text_matches(content: &str, query: &str) -> usize {
let query = query.trim();
if query.is_empty() {
return 0;
}
let content = content.to_lowercase();
let query = query.to_lowercase();
content.matches(&query).count()
}
fn truncate_middle(value: &str, max_chars: usize) -> String {
let count = value.chars().count();
if count <= max_chars || max_chars < 5 {
return value.to_string();
}
let head = (max_chars - 3) / 2;
let tail = max_chars - 3 - head;
let prefix: String = value.chars().take(head).collect();
let suffix: String = value.chars().skip(count - tail).collect();
format!("{prefix}...{suffix}")
}
fn usable_start_dir(path: PathBuf) -> PathBuf {
if path.is_dir() {
canonical_display_path(&path)
} else if home_dir().is_dir() {
home_dir()
} else {
PathBuf::from("/")
}
}
fn load_preferences() -> Preferences {
let mut preferences = Preferences::default();
let Ok(file) = std::fs::File::open(preferences_path()) else {
return preferences;
};
let reader = std::io::BufReader::new(file);
for line in reader
.lines()
.map_while(Result::ok)
.take(PREFERENCE_LINE_LIMIT)
{
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let Some((key, value)) = line.split_once('=') else {
continue;
};
let key = key.trim();
let value = value.trim();
match key {
"view_mode" => preferences.view_mode = ViewMode::from_config(value),
"show_hidden" => preferences.show_hidden = parse_bool(value),
"recursive_search" => preferences.recursive_search = parse_bool(value),
"plugin.knotlook_media" => preferences.media_preview_plugin_enabled = parse_bool(value),
"plugin.knotread_text" => preferences.text_preview_plugin_enabled = parse_bool(value),
"sort_key" => preferences.sort_key = sort_key_from_config(value),
"sort_reverse" => preferences.sort_reverse = parse_bool(value),
"folders_first" => preferences.folders_first = parse_bool(value),
"palette" => preferences.palette = AppPalette::from_config(value),
"motion_enabled" => preferences.motion_enabled = parse_bool(value),
"hover_effects_enabled" => preferences.hover_effects_enabled = parse_bool(value),
"theme_opacity" => {
preferences.theme_opacity = value
.parse::<f32>()
.map(|opacity| opacity.clamp(0.55, 1.0))
.unwrap_or(preferences.theme_opacity)
}
"background_image" => preferences.background_image_path = value.to_string(),
"sidebar_collapsed" => preferences.sidebar_collapsed = parse_bool(value),
"background_opacity" => {
preferences.background_image_opacity = value
.parse::<f32>()
.map(|opacity| opacity.clamp(0.0, 0.62))
.unwrap_or(preferences.background_image_opacity)
}
_ => {}
}
}
preferences
}
fn write_preferences(preferences: Preferences) -> std::io::Result<()> {
let path = preferences_path();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(
path,
format!(
"view_mode={}\nshow_hidden={}\nrecursive_search={}\nplugin.knotlook_media={}\nplugin.knotread_text={}\nsort_key={}\nsort_reverse={}\nfolders_first={}\npalette={}\nmotion_enabled={}\nhover_effects_enabled={}\ntheme_opacity={:.2}\nbackground_image={}\nbackground_opacity={:.2}\nsidebar_collapsed={}\n",
preferences.view_mode.as_config(),
preferences.show_hidden,
preferences.recursive_search,
preferences.media_preview_plugin_enabled,
preferences.text_preview_plugin_enabled,
sort_key_as_config(preferences.sort_key),
preferences.sort_reverse,
preferences.folders_first,
preferences.palette.as_config(),
preferences.motion_enabled,
preferences.hover_effects_enabled,
preferences.theme_opacity,
preferences.background_image_path,
preferences.background_image_opacity,
preferences.sidebar_collapsed,
),
)
}
fn preferences_path() -> PathBuf {
config_dir().join("preferences.conf")
}
fn bookmarks_path() -> PathBuf {
config_dir().join("bookmarks.conf")
}
fn config_dir() -> PathBuf {
config_home().join("guth")
}
fn config_home() -> PathBuf {
std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.unwrap_or_else(|| home_dir().join(".config"))
}
fn migrate_legacy_config() {
let current = config_dir();
let legacy = config_home().join("knottfiles");
if let Err(error) = migrate_config_files(&legacy, ¤t) {
eprintln!("guth: could not migrate legacy configuration: {error}");
}
}
fn migrate_config_files(legacy: &Path, current: &Path) -> std::io::Result<()> {
if !legacy.is_dir() {
return Ok(());
}
std::fs::create_dir_all(current)?;
for name in ["preferences.conf", "bookmarks.conf"] {
let source = legacy.join(name);
let destination = current.join(name);
if source.is_file() && !destination.exists() {
std::fs::copy(source, destination)?;
}
}
Ok(())
}
fn load_bookmarks() -> Vec<PathBuf> {
let Ok(file) = std::fs::File::open(bookmarks_path()) else {
return Vec::new();
};
let reader = std::io::BufReader::new(file);
let mut bookmarks = Vec::new();
let mut seen = BTreeSet::new();
for line in reader.lines().map_while(Result::ok) {
if bookmarks.len() >= BOOKMARK_LIMIT {
break;
}
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let path = canonical_display_path(&expand_tilde(line));
if seen.insert(path.clone()) {
bookmarks.push(path);
}
}
bookmarks
}
fn write_bookmarks(bookmarks: &[PathBuf]) -> std::io::Result<()> {
let path = bookmarks_path();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let mut content = String::new();
for bookmark in bookmarks.iter().take(BOOKMARK_LIMIT) {
content.push_str(&bookmark.display().to_string());
content.push('\n');
}
std::fs::write(path, content)
}
fn bookmark_label(path: &Path) -> String {
path.file_name()
.and_then(|name| name.to_str())
.map(str::to_string)
.unwrap_or_else(|| path_for_display(path))
}
fn bookmark_position(bookmarks: &[PathBuf], path: &Path) -> Option<usize> {
let normalized = canonical_display_path(path);
bookmarks
.iter()
.position(|bookmark| canonical_display_path(bookmark) == normalized)
}
fn move_bookmark_path(
bookmarks: &mut Vec<PathBuf>,
path: &Path,
direction: isize,
) -> Option<(usize, usize)> {
if bookmarks.len() < 2 || direction == 0 {
return None;
}
let from = bookmark_position(bookmarks, path)?;
let to = (from as isize + direction).clamp(0, bookmarks.len() as isize - 1) as usize;
if from == to {
return None;
}
let bookmark = bookmarks.remove(from);
bookmarks.insert(to, bookmark);
Some((from, to))
}
fn parse_bool(value: &str) -> bool {
matches!(value, "1" | "true" | "yes" | "on")
}
fn sort_key_as_config(key: SortKey) -> &'static str {
match key {
SortKey::Name => "name",
SortKey::Modified => "modified",
SortKey::Size => "size",
SortKey::Kind => "kind",
}
}
fn sort_key_from_config(value: &str) -> SortKey {
match value {
"modified" => SortKey::Modified,
"size" => SortKey::Size,
"kind" => SortKey::Kind,
_ => SortKey::Name,
}
}
fn expand_navigation_path(path: PathBuf, base: &Path) -> PathBuf {
if path.is_absolute() {
path
} else {
base.join(path)
}
}
fn target_inside_any_source(sources: &[PathBuf], target_dir: &Path) -> bool {
sources.iter().any(|source| target_dir.starts_with(source))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bookmark_labels_use_leaf_or_display_path() {
assert_eq!(
bookmark_label(Path::new("/tmp/guth-project")),
"guth-project"
);
assert_eq!(bookmark_label(Path::new("/")), "/");
}
#[test]
fn dialogs_report_modal_state() {
assert!(!Dialogs::default().is_open());
assert!(Dialogs {
new_file_open: true,
..Dialogs::default()
}
.is_open());
assert!(Dialogs {
error: Some("failed".to_string()),
..Dialogs::default()
}
.is_open());
}
#[test]
fn branded_palette_names_accept_legacy_values() {
assert_eq!(
AppPalette::from_config("guth_dark"),
AppPalette::NautilusDark
);
assert_eq!(
AppPalette::from_config("nautilus_dark"),
AppPalette::NautilusDark
);
assert_eq!(
AppPalette::from_config("studio_light"),
AppPalette::MacosLight
);
assert_eq!(
AppPalette::from_config("macos_light"),
AppPalette::MacosLight
);
assert_eq!(AppPalette::NautilusDark.as_config(), "guth_dark");
assert_eq!(AppPalette::MacosLight.as_config(), "studio_light");
}
#[test]
fn rename_selection_preserves_file_extension() {
assert_eq!(rename_selection_end("report.pdf", false), 6);
assert_eq!(rename_selection_end("archive.tar.gz", false), 11);
assert_eq!(rename_selection_end(".gitignore", false), 10);
assert_eq!(
rename_selection_end("folder.with.dots", true),
"folder.with.dots".chars().count()
);
}
#[test]
fn folder_icon_fit_preserves_tall_proportions() {
let source = Rect::from_min_size(Pos2::ZERO, Vec2::new(108.0, 74.0));
let fitted = fit_rect_to_aspect(source, FOLDER_ICON_ASPECT);
assert!((fitted.width() / fitted.height() - FOLDER_ICON_ASPECT).abs() < 0.001);
assert_eq!(fitted.height(), source.height());
assert!(fitted.width() < source.width());
}
#[test]
fn chrome_chevrons_point_in_their_named_direction() {
let rect = Rect::from_center_size(Pos2::ZERO, Vec2::splat(16.0));
let [back_top, back_tip, back_bottom] = horizontal_chevron_points(rect, false);
let [forward_top, forward_tip, forward_bottom] = horizontal_chevron_points(rect, true);
assert!(back_tip.x < back_top.x && back_tip.x < back_bottom.x);
assert!(forward_tip.x > forward_top.x && forward_tip.x > forward_bottom.x);
}
#[test]
fn file_icon_family_uses_stable_optical_aspects() {
assert_eq!(file_icon_aspect(MediaClass::Folder), FOLDER_ICON_ASPECT);
assert!(file_icon_aspect(MediaClass::Image) > 1.0);
assert!(file_icon_aspect(MediaClass::Text) < 1.0);
assert_eq!(file_icon_aspect(MediaClass::Audio), 1.0);
}
#[test]
fn preview_canvas_adapts_without_crowding_footer() {
assert_eq!(preview_canvas_height(800.0, 520.0, 0.66), 478.0);
assert_eq!(preview_canvas_height(320.0, 800.0, 0.66), 240.0);
assert_eq!(preview_canvas_height(1400.0, f32::INFINITY, 0.66), 720.0);
}
#[test]
fn preview_pan_keeps_zoomed_edges_reachable() {
let viewport = Rect::from_center_size(Pos2::ZERO, Vec2::new(200.0, 100.0));
let content = Rect::from_center_size(Pos2::ZERO, Vec2::new(400.0, 150.0));
assert_eq!(
clamp_preview_pan(egui::vec2(500.0, -500.0), content, viewport),
egui::vec2(100.0, -25.0)
);
assert_eq!(
clamp_preview_pan(Vec2::new(20.0, 20.0), viewport, content),
Vec2::ZERO
);
}
#[test]
fn fitted_preview_zoom_has_an_explicit_label() {
assert_eq!(preview_zoom_label(1.0), "Fit");
assert_eq!(preview_zoom_label(1.5), "150%");
}
#[test]
fn image_preview_preserves_source_dimensions() {
let nonce = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
let path = std::env::temp_dir().join(format!("guth-preview-{nonce}.png"));
let source = image::RgbaImage::from_pixel(2501, 4, image::Rgba([40, 90, 180, 160]));
source.save(&path).expect("write preview image");
let loaded = load_media_preview_image(&path).expect("load preview image");
assert_eq!(loaded.source_dimensions, [2501, 4]);
assert!(loaded.pixels.size[0] <= MEDIA_PREVIEW_IMAGE_MAX_EDGE as usize);
let _ = std::fs::remove_file(path);
}
#[test]
fn text_preview_binary_detection_allows_plain_text() {
assert!(!looks_like_binary(
b"fn main() {\n println!(\"hi\");\n}\n"
));
assert!(looks_like_binary(b"PNG\0\0\0binary"));
}
#[test]
fn text_preview_trims_only_incomplete_utf8_suffixes() {
let mut incomplete = vec![b'a', 0xE2, 0x82];
assert!(trim_incomplete_utf8(&mut incomplete));
assert_eq!(incomplete, b"a");
let mut invalid = vec![b'a', 0xFF, b'b'];
assert!(!trim_incomplete_utf8(&mut invalid));
assert_eq!(invalid, vec![b'a', 0xFF, b'b']);
}
#[test]
fn text_preview_numbering_preserves_content_shape() {
assert_eq!(numbered_text("alpha\nbeta"), "1 alpha\n2 beta");
assert_eq!(numbered_text("alpha\nbeta\n"), "1 alpha\n2 beta\n");
}
#[test]
fn text_preview_find_counts_case_insensitive_matches() {
assert_eq!(count_text_matches("Alpha alpha ALPHA", "alpha"), 3);
assert_eq!(count_text_matches("Alpha alpha ALPHA", ""), 0);
}
#[test]
fn selection_summary_counts_known_file_bytes_only() {
let entries = vec![
test_entry("folder", EntryKind::Directory, 4096),
test_entry("file-a", EntryKind::File, 120),
test_entry("file-b", EntryKind::File, 30),
];
assert_eq!(
selection_summary(&entries),
SelectionSummary {
count: 3,
folders: 1,
files: 2,
file_bytes: 150,
}
);
}
#[test]
fn operation_results_only_focus_current_folder() {
let paths = vec![
PathBuf::from("/tmp/current/copied.txt"),
PathBuf::from("/tmp/current/folder"),
PathBuf::from("/tmp/elsewhere/hidden.txt"),
];
assert_eq!(
operation_results_in_directory(&paths, Path::new("/tmp/current")),
paths[..2]
);
}
#[test]
fn initial_arrow_selection_does_not_skip_entries() {
assert_eq!(stepped_selection_index(4, None, 1), Some(0));
assert_eq!(stepped_selection_index(4, None, -1), Some(3));
assert_eq!(stepped_selection_index(4, Some(1), 1), Some(2));
assert_eq!(stepped_selection_index(0, None, 1), None);
}
#[test]
fn legacy_configuration_is_copied_once() {
let nonce = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
let root = std::env::temp_dir().join(format!("guth-config-migration-{nonce}"));
let legacy = root.join("knottfiles");
let current = root.join("guth");
std::fs::create_dir_all(&legacy).expect("create legacy config");
std::fs::write(legacy.join("preferences.conf"), "palette=nautilus_dark\n")
.expect("write legacy preferences");
std::fs::write(legacy.join("bookmarks.conf"), "/tmp\n").expect("write legacy bookmarks");
migrate_config_files(&legacy, ¤t).expect("migrate config");
assert_eq!(
std::fs::read_to_string(current.join("preferences.conf")).unwrap_or_default(),
"palette=nautilus_dark\n"
);
assert_eq!(
std::fs::read_to_string(current.join("bookmarks.conf")).unwrap_or_default(),
"/tmp\n"
);
std::fs::write(current.join("preferences.conf"), "kept=true\n")
.expect("replace current preferences");
std::fs::remove_file(current.join("bookmarks.conf")).expect("remove current bookmarks");
migrate_config_files(&legacy, ¤t).expect("skip existing config");
assert_eq!(
std::fs::read_to_string(current.join("preferences.conf")).unwrap_or_default(),
"kept=true\n"
);
assert_eq!(
std::fs::read_to_string(current.join("bookmarks.conf")).unwrap_or_default(),
"/tmp\n"
);
let _ = std::fs::remove_dir_all(root);
}
#[test]
fn location_target_uses_folder_or_parent() {
let fallback = Path::new("/tmp/base");
let folder = test_entry("/tmp/base/folder", EntryKind::Directory, 0);
let file = test_entry("/tmp/base/file.txt", EntryKind::File, 10);
assert_eq!(
location_target_for_entry(Some(&folder), fallback),
folder.path
);
assert_eq!(
location_target_for_entry(Some(&file), fallback),
PathBuf::from("/tmp/base")
);
assert_eq!(location_target_for_entry(None, fallback), fallback);
}
#[test]
fn bookmark_order_moves_within_bounds() {
let mut bookmarks = vec![
PathBuf::from("/tmp/a"),
PathBuf::from("/tmp/b"),
PathBuf::from("/tmp/c"),
];
assert_eq!(
move_bookmark_path(&mut bookmarks, Path::new("/tmp/b"), -1),
Some((1, 0))
);
assert_eq!(
bookmarks,
vec![
PathBuf::from("/tmp/b"),
PathBuf::from("/tmp/a"),
PathBuf::from("/tmp/c")
]
);
assert_eq!(
move_bookmark_path(&mut bookmarks, Path::new("/tmp/b"), -1),
None
);
assert_eq!(
move_bookmark_path(&mut bookmarks, Path::new("/tmp/a"), 1),
Some((1, 2))
);
assert_eq!(
bookmarks,
vec![
PathBuf::from("/tmp/b"),
PathBuf::from("/tmp/c"),
PathBuf::from("/tmp/a")
]
);
}
fn test_entry(name: &str, kind: EntryKind, size: u64) -> FileEntry {
FileEntry {
name: Path::new(name)
.file_name()
.and_then(|name| name.to_str())
.unwrap_or(name)
.to_string(),
path: PathBuf::from(name),
kind,
size,
modified: None,
permissions: "rw".to_string(),
readonly: false,
hidden: false,
symlink_target: None,
}
}
}