use std::cell::{Cell, RefCell};
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::rc::Rc;
use std::time::{Duration, SystemTime};
use crate::audio::AudioPlayer;
use crate::config::{
BuiltinAction, ContextAction, ContextMenu, PanelConfig, PanelSide, ShortcutTarget,
TreeConfig, action_command,
};
use crate::fs::model::{Change, SortKey, StdDirSource, TreeModel, VisibleRow};
use crate::fs::ops::FileOps;
use crate::fs::watcher::{RecursiveMode, RecommendedWatcher, Watcher, spawn as spawn_watcher};
use gtk4_layer_shell::{KeyboardMode, Layer, LayerShell};
use relm4::gtk::{gdk, gio, glib, pango, prelude::*};
use relm4::prelude::*;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ClipboardOp {
Copy,
Cut,
}
#[derive(Debug, Default)]
struct TypeAhead {
prefix: String,
last: Option<std::time::Instant>,
}
const TYPEAHEAD_TIMEOUT: Duration = Duration::from_millis(700);
const TYPEAHEAD_MAX: usize = 32;
impl TypeAhead {
fn push(&mut self, ch: char, now: std::time::Instant) -> Option<String> {
if !ch.is_alphanumeric() && ch != '_' && ch != '-' && ch != '.' && ch != ' ' {
return None;
}
let expired = self
.last
.is_some_and(|last| now.duration_since(last) > TYPEAHEAD_TIMEOUT);
if expired {
self.prefix.clear();
}
if self.prefix.len() >= TYPEAHEAD_MAX {
self.prefix.clear();
}
self.prefix.push(ch.to_ascii_lowercase());
self.last = Some(now);
Some(self.prefix.clone())
}
fn clear(&mut self) {
self.prefix.clear();
self.last = None;
}
}
fn typeahead_target(rows: &[VisibleRow], prefix: &str, cursor: Option<usize>) -> Option<usize> {
if prefix.is_empty() || rows.is_empty() {
return None;
}
let len = rows.len();
let start = cursor.map_or(0, |c| (c + 1) % len);
for offset in 0..len {
let index = (start + offset) % len;
if rows[index].name.to_ascii_lowercase().starts_with(prefix) {
return Some(index);
}
}
None
}
#[derive(Debug, Clone)]
struct Clipboard {
op: ClipboardOp,
paths: Vec<PathBuf>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum DropPlan {
Ignore,
Copy(Vec<PathBuf>),
Move(Vec<PathBuf>),
Reject(String),
}
fn resolve_drop(target: &Path, sources: Vec<PathBuf>, copy: bool) -> DropPlan {
let sources: Vec<PathBuf> = sources
.into_iter()
.filter(|src| src != target && src.parent() != Some(target))
.collect();
if sources.is_empty() {
return DropPlan::Ignore;
}
if sources.iter().any(|src| target.starts_with(src)) {
return DropPlan::Reject("Cannot move a folder into itself".to_string());
}
if copy { DropPlan::Copy(sources) } else { DropPlan::Move(sources) }
}
fn index_range(a: usize, b: usize) -> std::ops::RangeInclusive<usize> {
if a <= b { a..=b } else { b..=a }
}
fn drag_paths(selected: &[PathBuf], pressed: &Path) -> Vec<PathBuf> {
if selected.len() > 1 && selected.iter().any(|p| p == pressed) {
selected.to_vec()
} else {
vec![pressed.to_path_buf()]
}
}
fn format_uri_list(paths: &[PathBuf]) -> String {
let mut out = String::new();
for path in paths {
if let Ok(uri) = glib::filename_to_uri(path, None) {
out.push_str(&uri);
out.push_str("\r\n");
}
}
out
}
fn provider_for_paths(paths: &[PathBuf]) -> gdk::ContentProvider {
let files: Vec<gio::File> = paths.iter().map(gio::File::for_path).collect();
let list = gdk::FileList::from_array(&files);
let native = gdk::ContentProvider::for_value(&list.to_value());
let uris = format_uri_list(paths);
let bytes = glib::Bytes::from(uris.as_bytes());
let text = gdk::ContentProvider::for_bytes(URI_LIST_MIME, &bytes);
gdk::ContentProvider::new_union(&[native, text])
}
fn past_drag_threshold(row: >k::Widget, start: (f64, f64), now: (f64, f64)) -> bool {
let threshold = row
.settings()
.gtk_dnd_drag_threshold()
.max(1) as f64;
(now.0 - start.0).abs() > threshold || (now.1 - start.1).abs() > threshold
}
fn drag_badge(count: usize) -> gtk::Widget {
let label = gtk::Label::new(Some(&if count <= 1 {
"1 item".to_owned()
} else {
format!("{count} items")
}));
label.add_css_class("tree-drag-badge");
label.upcast()
}
#[derive(Debug, Clone)]
struct DragOrigin {
x: f64,
y: f64,
}
fn begin_row_drag(
widget: >k::Widget,
paths: &[PathBuf],
start: (f64, f64),
actions: gdk::DragAction,
on_finished: impl Fn(gdk::Drag, bool) + 'static,
on_cancelled: impl Fn(gdk::Drag, gdk::DragCancelReason) + 'static,
) -> bool {
let Some(native) = widget.native() else {
return false;
};
let Some(surface) = native.surface() else {
return false;
};
let Some(device) = widget
.display()
.default_seat()
.and_then(|seat| seat.pointer())
else {
return false;
};
let (dx, dy) = match (
surface.device_position(&device),
widget.compute_point(
&native,
&relm4::gtk::graphene::Point::new(start.0 as f32, start.1 as f32),
),
) {
(Some((px, py, _)), Some(p)) => ((px - p.x() as f64).round(), (py - p.y() as f64).round()),
_ => (0.0, 0.0),
};
let content = provider_for_paths(paths);
let Some(drag) = gdk::Drag::begin(&surface, &device, &content, actions, dx, dy) else {
return false;
};
let icon = gtk::DragIcon::for_drag(&drag);
icon.set_child(Some(&drag_badge(paths.len())));
{
let on_finished = Rc::new(on_finished);
let drag = drag.clone();
drag.connect_dnd_finished(move |drag| {
let delete = drag.selected_action() == gdk::DragAction::MOVE;
on_finished(drag.clone(), delete);
});
}
{
let on_cancelled = Rc::new(on_cancelled);
let drag = drag.clone();
drag.connect_cancel(move |drag, reason| on_cancelled(drag.clone(), reason));
}
true
}
pub struct TreeInit {
pub config: TreeConfig,
pub parent: gtk::Window,
pub menu: ContextMenu,
pub side: PanelSide,
pub panel: PanelConfig,
}
#[derive(Debug, Clone)]
pub enum TreeMsg {
OpenRoot(PathBuf),
SelectPath(PathBuf),
Focus,
SetFilter(String),
SetPanel(PanelConfig),
MoveUp,
MoveDown,
ExtendSelection(isize),
MoveExtendingSelection(isize),
CollapseCursor,
ExpandCursor,
ActivateCursor,
Rename,
Activate(PathBuf),
Toggle(PathBuf),
Select(PathBuf),
ToggleSelect(PathBuf),
RangeSelect(PathBuf),
RowPress { path: PathBuf, is_dir: bool, ctrl: bool, shift: bool },
RowRelease { path: PathBuf },
DragStarted,
DroppedAsMove(Vec<PathBuf>),
Deselect,
Menu(PathBuf),
MenuAt { x: f64, y: f64 },
RenameAt(PathBuf),
Trash(PathBuf),
DeleteSelected,
ConfirmTrash(Vec<PathBuf>),
PermanentDelete(PathBuf),
PermanentDeleteSelected,
ConfirmPermanentDelete(Vec<PathBuf>),
RunCommand { command: String, path: PathBuf },
OpenSplit(PathBuf),
OpenInOppositePanel(PathBuf),
OpenWith(PathBuf),
OpenWithDefault(PathBuf),
CreateLink(PathBuf),
Properties(PathBuf),
PropertiesSelected(Vec<PathBuf>),
CopyPaths(Vec<PathBuf>),
CopyRelativePaths(Vec<PathBuf>),
RunCommandEach { command: String, paths: Vec<PathBuf> },
RenameTo { path: PathBuf, name: String },
ToggleThumbnail(PathBuf),
ToggleThumbnailPlay(PathBuf),
RunShortcut { target: ShortcutTarget },
Duplicate(PathBuf),
RequestDuplicateCursor,
SelectAllRows,
TypeAhead(char),
CopyPath(PathBuf),
CopyRelativePath(PathBuf),
RenameCommit,
RenameCancel,
Copy,
Cut,
Paste,
PastePaths(Vec<PathBuf>),
NewFile,
NewFolder,
ToggleHidden,
SetSortKey(SortKey),
ToggleSortDirection,
FsChange(Change),
FlushChanges,
RequestOpenFolder,
DropInto { target: PathBuf, sources: Vec<PathBuf>, copy: bool },
DropIntoRoot { sources: Vec<PathBuf>, copy: bool },
DropIntoConfirmed { target: PathBuf, sources: Vec<PathBuf> },
Shutdown,
}
#[derive(Debug)]
pub enum TreeOutput {
Status(String),
RootChanged(PathBuf),
OpenFolderRequested,
OpenSplit(PathBuf),
OpenOpposite(PathBuf),
PaneAction(BuiltinAction),
}
pub struct Tree {
tree: Option<TreeModel>,
ops: FileOps,
config: TreeConfig,
parent: gtk::Window,
menu: ContextMenu,
side: PanelSide,
thumbnails: HashSet<PathBuf>,
thumb_cache: HashMap<PathBuf, gdk::Texture>,
media: HashMap<PathBuf, gtk::MediaFile>,
audio: HashMap<PathBuf, Rc<AudioPlayer>>,
playing: HashSet<PathBuf>,
gif_anims: Rc<RefCell<HashMap<PathBuf, GifAnim>>>,
gif_widgets: Rc<RefCell<HashMap<PathBuf, gtk::Picture>>>,
gif_tick: Rc<RefCell<Option<glib::SourceId>>>,
audio_widgets: Rc<RefCell<HashMap<PathBuf, AudioWidgets>>>,
audio_tick: Rc<RefCell<Option<glib::SourceId>>>,
content_width: i32,
rows: Vec<VisibleRow>,
cursor: Option<usize>,
selected: Vec<PathBuf>,
anchor: Option<PathBuf>,
last_press: Option<(PathBuf, std::time::Instant)>,
renaming: Option<PathBuf>,
rename_entry: Option<gtk::Entry>,
clipboard: Option<Clipboard>,
pending: Vec<Change>,
flush_source: Option<glib::SourceId>,
_watcher: Option<RecommendedWatcher>,
watched: HashSet<PathBuf>,
popover: Option<gtk::Popover>,
menu_target: Option<PathBuf>,
menu_point: Option<(f64, f64)>,
renaming_state: Rc<Cell<bool>>,
selection_dirty: bool,
typeahead: TypeAhead,
scroll_to: Option<usize>,
drag_payload: Rc<RefCell<Vec<PathBuf>>>,
drag_active: Rc<Cell<bool>>,
pending_click: Option<PathBuf>,
drag_landed_internal: Rc<Cell<bool>>,
drag_origin: Rc<RefCell<Option<DragOrigin>>>,
drag_button_down: Rc<Cell<bool>>,
}
#[relm4::component(pub)]
impl Component for Tree {
type Init = TreeInit;
type Input = TreeMsg;
type Output = TreeOutput;
type CommandOutput = ();
view! {
scrolled = gtk::ScrolledWindow {
set_vexpand: true,
set_hexpand: true,
set_min_content_width: 160,
add_css_class: "tree-scroll",
#[name = "list"]
gtk::ListBox {
set_selection_mode: gtk::SelectionMode::None,
set_activate_on_single_click: false,
}
}
}
fn init(
init: Self::Init,
root: Self::Root,
sender: ComponentSender<Self>,
) -> ComponentParts<Self> {
let renaming_state = Rc::new(Cell::new(false));
let keys = compile_shortcuts(&init.menu);
let widgets = view_output!();
let controller = gtk::EventControllerKey::new();
controller.set_propagation_phase(gtk::PropagationPhase::Capture);
let capture_sender = sender.clone();
let capture_state = renaming_state.clone();
let capture_keys = keys.clone();
controller.connect_key_pressed(move |_ctrl, key, _keycode, state| {
handle_key(
key,
state,
capture_state.get(),
&capture_keys,
&capture_sender,
)
});
widgets.scrolled.add_controller(controller);
let root_drop = gtk::DropTarget::new(gdk::FileList::static_type(), gdk::DragAction::MOVE | gdk::DragAction::COPY);
let root_drop_sender = sender.clone();
root_drop.connect_drop(move |target, value, _x, _y| {
let Ok(list) = value.get::<gdk::FileList>() else {
return false;
};
let sources: Vec<PathBuf> = list.files().iter().filter_map(gio::File::path).collect();
if sources.is_empty() {
return false;
}
let copy = target.current_event_state().contains(gdk::ModifierType::CONTROL_MASK);
root_drop_sender.input(TreeMsg::DropIntoRoot { sources, copy });
true
});
widgets.list.add_controller(root_drop);
let blank_scrolled = widgets.scrolled.clone();
let blank_click = gtk::GestureClick::new();
blank_click.set_button(0);
blank_click.set_propagation_phase(gtk::PropagationPhase::Capture);
let blank_sender = sender.clone();
blank_click.connect_pressed(move |gesture, _n_press, x, y| {
if gesture.current_button() == 3
&& gesture.current_event_state().contains(gdk::ModifierType::SUPER_MASK)
{
gesture.set_state(gtk::EventSequenceState::Denied);
return;
}
let on_row = blank_scrolled
.pick(x, y, gtk::PickFlags::DEFAULT)
.is_some_and(|widget| in_list_row(&widget));
if on_row {
return;
}
match gesture.current_button() {
1 => blank_sender.input(TreeMsg::Deselect),
3 => blank_sender.input(TreeMsg::MenuAt { x, y }),
_ => {}
}
});
widgets.scrolled.add_controller(blank_click);
let model = Tree {
tree: None,
ops: FileOps::new(),
config: init.config,
parent: init.parent,
menu: init.menu,
side: init.side,
thumbnails: HashSet::new(),
thumb_cache: HashMap::new(),
media: HashMap::new(),
audio: HashMap::new(),
playing: HashSet::new(),
gif_anims: Rc::new(RefCell::new(HashMap::new())),
gif_widgets: Rc::new(RefCell::new(HashMap::new())),
gif_tick: Rc::new(RefCell::new(None)),
audio_widgets: Rc::new(RefCell::new(HashMap::new())),
audio_tick: Rc::new(RefCell::new(None)),
content_width: thumbnail_content_width(init.panel),
rows: Vec::new(),
cursor: None,
selected: Vec::new(),
anchor: None,
last_press: None,
renaming: None,
rename_entry: None,
clipboard: None,
pending: Vec::new(),
flush_source: None,
_watcher: None,
watched: HashSet::new(),
popover: None,
menu_target: None,
menu_point: None,
renaming_state,
selection_dirty: false,
typeahead: TypeAhead::default(),
scroll_to: None,
drag_payload: Rc::new(RefCell::new(Vec::new())),
drag_active: Rc::new(Cell::new(false)),
pending_click: None,
drag_landed_internal: Rc::new(Cell::new(false)),
drag_origin: Rc::new(RefCell::new(None)),
drag_button_down: Rc::new(Cell::new(false)),
};
{
let legacy = gtk::EventControllerLegacy::new();
legacy.set_propagation_phase(gtk::PropagationPhase::Capture);
let origin = model.drag_origin.clone();
let payload = model.drag_payload.clone();
let active = model.drag_active.clone();
let landed = model.drag_landed_internal.clone();
let down = model.drag_button_down.clone();
let drag_root = widgets.scrolled.clone();
let s = sender.clone();
legacy.connect_event(move |_controller, event| {
match event.event_type() {
gdk::EventType::ButtonPress => {
let button = event
.downcast_ref::<gdk::ButtonEvent>()
.map(|b| b.button())
.unwrap_or(0);
if button == 1
&& let Some((x, y)) = event.position()
{
down.set(true);
*origin.borrow_mut() = Some(DragOrigin { x, y });
}
}
gdk::EventType::ButtonRelease => {
let button = event
.downcast_ref::<gdk::ButtonEvent>()
.map(|b| b.button())
.unwrap_or(0);
if button == 1 {
down.set(false);
if !active.get() {
*origin.borrow_mut() = None;
}
}
}
gdk::EventType::MotionNotify => {
if active.get() || !down.get() {
return glib::Propagation::Proceed;
}
let Some((x, y)) = event.position() else {
return glib::Propagation::Proceed;
};
let start = {
let guard = origin.borrow();
match guard.as_ref() {
Some(o) => (o.x, o.y),
None => return glib::Propagation::Proceed,
}
};
if !past_drag_threshold(drag_root.upcast_ref(), start, (x, y)) {
return glib::Propagation::Proceed;
}
let paths = payload.borrow().clone();
if paths.is_empty() {
return glib::Propagation::Proceed;
}
*origin.borrow_mut() = None;
let finished = {
let active = active.clone();
let landed = landed.clone();
let s = s.clone();
let payload = payload.clone();
move |_drag: gdk::Drag, delete: bool| {
active.set(false);
if delete && !landed.replace(false) {
let paths = payload.borrow().clone();
if !paths.is_empty() {
s.input(TreeMsg::DroppedAsMove(paths));
}
}
}
};
let cancelled = {
let active = active.clone();
let landed = landed.clone();
move |_drag: gdk::Drag, _reason: gdk::DragCancelReason| {
active.set(false);
landed.set(false);
}
};
if begin_row_drag(
drag_root.upcast_ref(),
&paths,
start,
gdk::DragAction::MOVE | gdk::DragAction::COPY,
finished,
cancelled,
) {
active.set(true);
s.input(TreeMsg::DragStarted);
}
}
_ => {}
}
glib::Propagation::Proceed
});
widgets.scrolled.add_controller(legacy);
}
ComponentParts { model, widgets }
}
fn update_with_view(
&mut self,
widgets: &mut Self::Widgets,
message: Self::Input,
sender: ComponentSender<Self>,
root: &Self::Root,
) {
let _ = root;
if let TreeMsg::Focus = message {
let index = self.cursor.unwrap_or(0) as i32;
if let Some(row) = widgets
.list
.row_at_index(index)
.or_else(|| widgets.list.row_at_index(0))
{
row.grab_focus();
} else if !widgets.list.grab_focus() {
widgets.scrolled.grab_focus();
}
return;
}
self.handle_message(message, sender.clone());
if let Some(index) = self.scroll_to.take() {
scroll_row_into_view(widgets, index);
}
self.reconcile_watches();
if self.selection_dirty {
self.selection_dirty = false;
if update_selection_classes(self, widgets) {
return;
}
}
<Self as Component>::update_view(self, widgets, sender.clone());
rebuild(self, widgets, sender);
}
}
impl Tree {
fn handle_message(&mut self, msg: TreeMsg, sender: ComponentSender<Self>) {
match msg {
TreeMsg::OpenRoot(path) => self.open_root(&path, &sender),
TreeMsg::SelectPath(path) => self.reveal_path(path),
TreeMsg::Focus => {}
TreeMsg::SetFilter(filter) => {
if let Some(tree) = self.tree.as_mut() {
tree.set_filter(&filter);
self.refresh_rows();
}
}
TreeMsg::SetPanel(panel) => self.content_width = thumbnail_content_width(panel),
TreeMsg::Shutdown => self.shutdown(),
TreeMsg::MoveUp => self.cursor_delta(-1),
TreeMsg::MoveDown => self.cursor_delta(1),
TreeMsg::ExtendSelection(delta) => self.cursor_extend(delta, false),
TreeMsg::MoveExtendingSelection(delta) => self.cursor_extend(delta, true),
TreeMsg::CollapseCursor => {
if let Some(path) = self.cursor_path()
&& self.tree.as_ref().is_some_and(|t| t.is_expanded(&path))
{
self.collapse_dir(&path);
}
}
TreeMsg::ExpandCursor => {
if let Some(path) = self.cursor_path() {
self.expand_dir(&path, &sender);
}
}
TreeMsg::ActivateCursor => {
if let Some(path) = self.cursor_path() {
self.activate(&path, &sender);
}
}
TreeMsg::Rename => {
if let Some(path) = self.cursor_path() {
self.renaming = Some(path);
self.renaming_state.set(true);
}
}
TreeMsg::Activate(path) => self.activate(&path, &sender),
TreeMsg::Toggle(path) => self.toggle_dir(&path, &sender),
TreeMsg::Select(path) => {
self.select(&path);
self.anchor = Some(path);
self.selection_dirty = true;
}
TreeMsg::ToggleSelect(path) => {
if let Some(i) = self.rows.iter().position(|r| r.path == path) {
self.cursor = Some(i);
}
if let Some(i) = self.selected.iter().position(|p| p == &path) {
self.selected.remove(i);
} else {
self.selected.push(path.clone());
}
self.anchor = Some(path);
self.selection_dirty = true;
}
TreeMsg::RangeSelect(path) => {
self.range_select(&path);
self.selection_dirty = true;
}
TreeMsg::RowPress { path, is_dir, ctrl, shift } => {
if ctrl {
sender.input(TreeMsg::ToggleSelect(path));
return;
}
if shift {
sender.input(TreeMsg::RangeSelect(path));
return;
}
*self.drag_payload.borrow_mut() = drag_paths(&self.selected, &path);
if self.selected.len() > 1 && self.selected.iter().any(|p| p == &path) {
self.pending_click = Some(path.clone());
if is_dir {
self.toggle_dir(&path, &sender);
}
return;
}
self.pending_click = None;
self.press_default(&path, is_dir, &sender);
}
TreeMsg::RowRelease { path } => {
*self.drag_origin.borrow_mut() = None;
self.drag_button_down.set(false);
if self.drag_active.get() {
return;
}
if self.pending_click.take().as_deref() == Some(path.as_path()) {
let is_dir = self
.rows
.iter()
.find(|r| r.path == path)
.map(|r| r.is_dir)
.unwrap_or(false);
self.press_default(&path, is_dir, &sender);
}
}
TreeMsg::DragStarted => {
self.drag_active.set(true);
self.pending_click = None;
*self.drag_origin.borrow_mut() = None;
self.drag_button_down.set(false);
}
TreeMsg::DroppedAsMove(paths) => {
match self.ops.trash(&paths) {
Ok(()) => {
for path in &paths {
self.apply_change(Change::Removed { path: path.clone() });
}
let n = paths.len();
self.status(format!("Moved {n} item(s) out of the panel"), &sender);
}
Err(err) => self.status(format!("Could not finish move: {err}"), &sender),
}
}
TreeMsg::Deselect => {
self.selected.clear();
self.cursor = None;
self.anchor = None;
self.typeahead.clear();
self.selection_dirty = true;
}
TreeMsg::Menu(path) => self.open_menu(&path),
TreeMsg::MenuAt { x, y } => {
if let Some(root) = self.tree.as_ref().map(|t| t.root().to_path_buf()) {
self.open_menu_at(&root, (x, y));
}
}
TreeMsg::RenameAt(path) => {
self.select(&path);
self.renaming = Some(path);
self.renaming_state.set(true);
}
TreeMsg::Trash(path) => self.confirm_trash(vec![path], sender.clone()),
TreeMsg::DeleteSelected => {
let paths = self.selected_for_clipboard();
if !paths.is_empty() {
self.confirm_trash(paths, sender.clone());
}
}
TreeMsg::PermanentDelete(path) => {
self.confirm_permanent_delete(vec![path], sender.clone());
}
TreeMsg::PermanentDeleteSelected => {
let paths = self.selected_for_clipboard();
if !paths.is_empty() {
self.confirm_permanent_delete(paths, sender.clone());
}
}
TreeMsg::ConfirmPermanentDelete(paths) => {
if let Some(renaming) = self.renaming.as_ref()
&& paths.contains(renaming)
{
self.renaming = None;
self.renaming_state.set(false);
}
match self.ops.delete_permanently(&paths) {
Ok(()) => {
for path in paths {
self.apply_change(Change::Removed { path });
}
self.selected.clear();
self.cursor_snap();
}
Err(err) => self.status(err.to_string(), &sender),
}
}
TreeMsg::ConfirmTrash(paths) => {
if let Some(renaming) = self.renaming.as_ref()
&& paths.contains(renaming)
{
self.renaming = None;
self.renaming_state.set(false);
}
match self.ops.trash(&paths) {
Ok(()) => {
for path in paths {
self.apply_change(Change::Removed { path });
}
self.selected.clear();
self.cursor_snap();
}
Err(err) => self.status(err.to_string(), &sender),
}
}
TreeMsg::Duplicate(path) => match self.ops.duplicate(&path) {
Ok(new_path) => {
self.apply_change(Change::Created { path: new_path.clone() });
self.select(&new_path);
}
Err(err) => self.status(format!("Could not duplicate: {err}"), &sender),
},
TreeMsg::RequestDuplicateCursor => {
if let Some(path) = self.cursor_path() {
sender.input(TreeMsg::Duplicate(path));
}
}
TreeMsg::CopyPath(path) => {
set_clipboard_text(&path.display().to_string());
self.status("Copied path to clipboard".to_string(), &sender);
}
TreeMsg::CopyRelativePath(path) => {
let relative = self
.tree
.as_ref()
.and_then(|t| path.strip_prefix(t.root()).ok())
.map(|p| p.display().to_string())
.unwrap_or_else(|| path.display().to_string());
set_clipboard_text(&relative);
self.status("Copied relative path to clipboard".to_string(), &sender);
}
TreeMsg::RunCommand { command, path } => {
let cmd = action_command(&command, &path);
match spawn_command(&cmd) {
Ok(()) => self.status(format!("Ran {command} on {}", path.display()), &sender),
Err(err) => self.status(format!("Could not run {command}: {err}"), &sender),
}
}
TreeMsg::OpenSplit(path) => {
let _ = sender.output(TreeOutput::OpenSplit(path));
}
TreeMsg::OpenInOppositePanel(path) => {
let _ = sender.output(TreeOutput::OpenOpposite(path));
}
TreeMsg::OpenWith(path) => open_with_dialog(&self.parent.clone(), &path, &sender),
TreeMsg::OpenWithDefault(path) => {
open_in_app(&path);
self.status(format!("Opened {} with its default app", path.display()), &sender);
}
TreeMsg::CreateLink(path) => match self.ops.create_link(&path) {
Ok(new_path) => {
self.apply_change(Change::Created { path: new_path.clone() });
}
Err(err) => self.status(format!("Could not create link: {err}"), &sender),
},
TreeMsg::Properties(path) => {
crate::ui::props::show_properties_dialog(&self.parent.clone(), &path, &sender);
}
TreeMsg::PropertiesSelected(paths) => {
crate::ui::props::show_multi_properties_dialog(&self.parent.clone(), &paths);
}
TreeMsg::CopyPaths(paths) => {
let text = paths
.iter()
.map(|p| p.display().to_string())
.collect::<Vec<_>>()
.join("\n");
set_clipboard_text(&text);
self.status(format!("Copied {} paths to clipboard", paths.len()), &sender);
}
TreeMsg::CopyRelativePaths(paths) => {
let root = self.tree.as_ref().map(|t| t.root().to_path_buf());
let text = paths
.iter()
.map(|p| {
root.as_deref()
.and_then(|root| p.strip_prefix(root).ok())
.map(|rel| rel.display().to_string())
.unwrap_or_else(|| p.display().to_string())
})
.collect::<Vec<_>>()
.join("\n");
set_clipboard_text(&text);
self.status(
format!("Copied {} relative paths to clipboard", paths.len()),
&sender,
);
}
TreeMsg::RunCommandEach { command, paths } => {
let mut failures = 0usize;
for path in &paths {
let cmd = action_command(&command, path);
if spawn_command(&cmd).is_err() {
failures += 1;
}
}
let total = paths.len();
if failures == 0 {
self.status(format!("Ran {command} on {total} item(s)"), &sender);
} else {
self.status(
format!("Ran {command} on {total} item(s); {failures} failed"),
&sender,
);
}
}
TreeMsg::RenameTo { path, name } => match self.ops.rename(&path, &name) {
Ok(new_path) => {
self.apply_change(Change::Renamed {
from: path,
to: new_path.clone(),
});
self.select(&new_path);
}
Err(err) => self.status(format!("Could not rename: {err}"), &sender),
},
TreeMsg::ToggleThumbnail(path) => {
if self.thumbnails.contains(&path) {
self.clear_thumbnails_under(&path);
} else {
self.thumbnails.insert(path.clone());
if path.is_dir() {
self.expand_dir(&path, &sender);
}
}
}
TreeMsg::ToggleThumbnailPlay(path) => self.toggle_thumbnail_play(&path),
TreeMsg::RunShortcut { target } => self.run_shortcut(target, &sender),
TreeMsg::SelectAllRows => {
if !self.rows.is_empty() {
self.selected = self.rows.iter().map(|r| r.path.clone()).collect();
self.cursor = Some(0);
self.anchor = self.rows.first().map(|r| r.path.clone());
self.selection_dirty = true;
}
}
TreeMsg::TypeAhead(ch) => {
let now = std::time::Instant::now();
if let Some(prefix) = self.typeahead.push(ch, now)
&& let Some(index) = typeahead_target(&self.rows, &prefix, self.cursor)
{
let path = self.rows[index].path.clone();
self.cursor = Some(index);
self.set_single_selection(path);
self.selection_dirty = true;
}
}
TreeMsg::RenameCommit => {
let text = self
.rename_entry
.as_ref()
.map(|e| e.text().to_string())
.unwrap_or_default();
let Some(old) = self.renaming.take() else {
return;
};
self.renaming_state.set(false);
self.rename_entry = None;
let trimmed = text.trim().to_string();
let unchanged = old
.file_name()
.map(|n| n.to_string_lossy() == trimmed.as_str())
.unwrap_or(false);
if trimmed.is_empty() || unchanged {
return;
}
match self.ops.rename(&old, &trimmed) {
Ok(new_path) => {
self.apply_change(Change::Renamed {
from: old,
to: new_path.clone(),
});
self.select(&new_path);
}
Err(err) => self.status(format!("Could not rename: {err}"), &sender),
}
}
TreeMsg::RenameCancel => {
self.renaming = None;
self.renaming_state.set(false);
self.rename_entry = None;
}
TreeMsg::Copy => {
let paths = self.selected_for_clipboard();
if !paths.is_empty() {
self.clipboard = Some(Clipboard { op: ClipboardOp::Copy, paths: paths.clone() });
self.status(format!("Copying {} item(s)", paths.len()), &sender);
}
}
TreeMsg::Cut => {
let paths = self.selected_for_clipboard();
if !paths.is_empty() {
self.clipboard = Some(Clipboard { op: ClipboardOp::Cut, paths: paths.clone() });
self.status(format!("Cutting {} item(s)", paths.len()), &sender);
}
}
TreeMsg::Paste => self.paste(&sender),
TreeMsg::PastePaths(paths) => self.paste_paths(paths, &sender),
TreeMsg::NewFile => self.create_entry(false, &sender),
TreeMsg::NewFolder => self.create_entry(true, &sender),
TreeMsg::ToggleHidden => self.toggle_hidden(&sender),
TreeMsg::SetSortKey(key) => self.set_sort_key(key, &sender),
TreeMsg::ToggleSortDirection => self.toggle_sort_direction(&sender),
TreeMsg::FsChange(change) => {
self.pending.push(change);
if self.flush_source.is_none() {
let s = sender.clone();
let id = glib::timeout_add_local_once(
Duration::from_millis(100),
move || {
s.input(TreeMsg::FlushChanges);
}
);
self.flush_source = Some(id);
}
}
TreeMsg::FlushChanges => {
self.flush_source = None;
let changes = std::mem::take(&mut self.pending);
for change in changes {
self.apply_change(change);
}
self.cursor_snap();
}
TreeMsg::RequestOpenFolder => {
self.status("Pick a folder to browse...".to_string(), &sender);
let _ = sender.output(TreeOutput::OpenFolderRequested);
}
TreeMsg::DropInto { target, sources, copy } => {
self.request_drop_into(target, sources, copy, &sender);
}
TreeMsg::DropIntoRoot { sources, copy } => {
if let Some(root) = self.tree.as_ref().map(|t| t.root().to_path_buf()) {
self.request_drop_into(root, sources, copy, &sender);
}
}
TreeMsg::DropIntoConfirmed { target, sources } => {
self.drop_into(&target, &sources, false, &sender);
}
}
}
}
fn accel_mods(m: gdk::ModifierType) -> gdk::ModifierType {
m & (gdk::ModifierType::CONTROL_MASK
| gdk::ModifierType::SHIFT_MASK
| gdk::ModifierType::ALT_MASK
| gdk::ModifierType::SUPER_MASK
| gdk::ModifierType::HYPER_MASK
| gdk::ModifierType::META_MASK)
}
#[derive(Clone)]
struct KeyBinding {
key: gdk::Key,
mods: gdk::ModifierType,
target: ShortcutTarget,
}
fn compile_shortcuts(menu: &ContextMenu) -> Rc<Vec<KeyBinding>> {
let mut out = Vec::new();
for (accel, target) in menu.shortcuts() {
if let Some((key, mods)) = parse_accelerator(&accel) {
out.push(KeyBinding { key, mods, target });
}
}
Rc::new(out)
}
fn parse_accelerator(accel: &str) -> Option<(gdk::Key, gdk::ModifierType)> {
gtk::accelerator_parse(normalize_accelerator(accel))
}
fn normalize_accelerator(accel: &str) -> String {
if !accel.contains('+') {
return accel.to_owned();
}
let mut mods = String::new();
let mut key = String::new();
for token in accel.split('+') {
let token = token.trim();
if token.is_empty() {
continue;
}
match token.to_ascii_lowercase().as_str() {
"ctrl" | "control" | "primary" => mods.push_str("<Control>"),
"shift" => mods.push_str("<Shift>"),
"alt" => mods.push_str("<Alt>"),
"super" | "win" | "mod4" => mods.push_str("<Super>"),
"meta" => mods.push_str("<Meta>"),
"hyper" => mods.push_str("<Hyper>"),
_ => key = token.to_owned(),
}
}
format!("{mods}{key}")
}
fn handle_key(
key: gdk::Key,
state: gdk::ModifierType,
renaming: bool,
keys: &[KeyBinding],
sender: &ComponentSender<Tree>,
) -> glib::Propagation {
use gdk::Key;
if renaming {
return glib::Propagation::Proceed;
}
let ctrl = state.contains(gdk::ModifierType::CONTROL_MASK);
let shift = state.contains(gdk::ModifierType::SHIFT_MASK);
let structural = match key {
Key::Up if ctrl && shift => Some(TreeMsg::MoveExtendingSelection(-1)),
Key::Down if ctrl && shift => Some(TreeMsg::MoveExtendingSelection(1)),
Key::Up if shift => Some(TreeMsg::ExtendSelection(-1)),
Key::Down if shift => Some(TreeMsg::ExtendSelection(1)),
Key::Up => Some(TreeMsg::MoveUp),
Key::Down => Some(TreeMsg::MoveDown),
Key::Left => Some(TreeMsg::CollapseCursor),
Key::Right => Some(TreeMsg::ExpandCursor),
Key::Return | Key::KP_Enter | Key::space => Some(TreeMsg::ActivateCursor),
Key::Escape => Some(TreeMsg::RenameCancel),
Key::o | Key::O if ctrl && !shift => Some(TreeMsg::RequestOpenFolder),
Key::a | Key::A if ctrl && !shift => Some(TreeMsg::SelectAllRows),
_ => None,
};
let msg = match structural {
Some(msg) => msg,
None => {
let mut hit = None;
for binding in keys.iter() {
if key.to_lower() == binding.key.to_lower()
&& accel_mods(state) == accel_mods(binding.mods)
{
hit = Some(TreeMsg::RunShortcut {
target: binding.target.clone(),
});
break;
}
}
match hit {
Some(msg) => msg,
None if !ctrl
&& !state.contains(gdk::ModifierType::ALT_MASK)
&& !state.contains(gdk::ModifierType::SUPER_MASK) =>
{
if let Some(ch) = key.to_unicode()
&& ch.is_ascii_graphic()
{
TreeMsg::TypeAhead(ch)
} else {
return glib::Propagation::Proceed;
}
}
None => return glib::Propagation::Proceed,
}
}
};
sender.input(msg);
glib::Propagation::Stop
}
fn update_selection_classes(
tree: &mut Tree,
widgets: &mut <Tree as Component>::Widgets,
) -> bool {
let mut existing = 0;
let mut child = widgets.list.first_child();
while let Some(current) = child {
if current.is::<gtk::ListBoxRow>() {
existing += 1;
}
child = current.next_sibling();
}
if existing != tree.rows.len() {
return false;
}
for (index, row) in tree.rows.iter().enumerate() {
let Some(list_row) = widgets.list.row_at_index(index as i32) else {
return false;
};
let selected = tree.cursor == Some(index) || tree.selected.contains(&row.path);
if selected {
list_row.add_css_class("tree-row-selected");
} else {
list_row.remove_css_class("tree-row-selected");
}
}
true
}
fn scroll_row_into_view(widgets: &<Tree as Component>::Widgets, index: usize) {
let Some(row) = widgets.list.row_at_index(index as i32) else {
return;
};
let adj = widgets.scrolled.vadjustment();
let page = adj.page_size();
if page <= 0.0 {
return;
}
let Some(bounds) = row.compute_bounds(&widgets.list) else {
return;
};
let y = bounds.y() as f64;
let height = bounds.height() as f64;
let top = adj.value();
if y < top {
adj.set_value(y);
} else if y + height > top + page {
adj.set_value((y + height - page).max(0.0));
}
}
fn rebuild(tree: &mut Tree, widgets: &mut <Tree as Component>::Widgets, sender: ComponentSender<Tree>) {
let list = &widgets.list;
if let Some(previous) = tree.popover.take() {
previous.unparent();
}
widgets.scrolled.set_child(None::<>k::Widget>);
list.remove_all();
if tree.tree.is_none() {
widgets.scrolled.set_child(Some(list));
return;
}
let rows = std::mem::take(&mut tree.rows);
let mut menu_row: Option<gtk::ListBoxRow> = None;
tree.rename_entry = None;
tree.gif_widgets.borrow_mut().clear();
tree.audio_widgets.borrow_mut().clear();
for (index, row) in rows.iter().enumerate() {
let list_row = gtk::ListBoxRow::new();
list_row.set_focusable(true);
list_row.set_can_focus(true);
list_row.add_css_class("tree-row");
let row_indent = row.depth as i32 * 16 + 4;
let container = gtk::Box::new(gtk::Orientation::Vertical, 0);
container.set_hexpand(true);
list_row.set_child(Some(&container));
let hbox = gtk::Box::new(gtk::Orientation::Horizontal, 6);
hbox.set_margin_start(row_indent);
hbox.set_hexpand(true);
container.append(&hbox);
let icon = gtk::Image::from_icon_name(icon_name(row));
icon.set_pixel_size(tree.config.icon_size as i32);
icon.set_valign(gtk::Align::Center);
hbox.append(&icon);
if tree.renaming.as_ref() == Some(&row.path) {
let entry = gtk::Entry::new();
entry.set_text(&row.name);
entry.set_hexpand(true);
entry.add_css_class("tree-rename-entry");
{
let s = sender.clone();
entry.connect_activate(move |_| s.input(TreeMsg::RenameCommit));
}
let esc_controller = gtk::EventControllerKey::new();
let s = sender.clone();
esc_controller.connect_key_pressed(move |_, key, _code, _state| {
if key == gdk::Key::Escape {
s.input(TreeMsg::RenameCancel);
glib::Propagation::Stop
} else {
glib::Propagation::Proceed
}
});
entry.add_controller(esc_controller);
hbox.append(&entry);
tree.rename_entry = Some(entry.clone());
entry.grab_focus();
} else {
let label = gtk::Label::new(Some(&row.name));
label.set_xalign(0.0);
label.set_hexpand(true);
label.set_ellipsize(pango::EllipsizeMode::Middle);
label.set_tooltip_text(Some(&row.path.to_string_lossy()));
hbox.append(&label);
}
if tree.cursor == Some(index) || tree.selected.contains(&row.path) {
list_row.add_css_class("tree-row-selected");
}
if row.matches {
list_row.add_css_class("tree-row-match");
}
if tree.menu_target.as_ref() == Some(&row.path) {
menu_row = Some(list_row.clone());
}
if tree.wants_thumbnail(row) {
tree.append_thumbnail(&row.path, row_indent, &container, &sender);
}
let path = row.path.clone();
let release_path = row.path.clone();
let is_dir = row.is_dir;
let click = gtk::GestureClick::new();
click.set_button(0);
let s = sender.clone();
let focus_row = list_row.clone();
click.connect_pressed(move |gesture, _n_press, x, y| {
if focus_row
.pick(x, y, gtk::PickFlags::DEFAULT)
.is_some_and(|widget| in_inline_media(&widget))
{
return;
}
focus_row.grab_focus();
let button = gesture.current_button();
let state = gesture.current_event_state();
if button == 3 {
if state.contains(gdk::ModifierType::SUPER_MASK) {
gesture.set_state(gtk::EventSequenceState::Denied);
return;
}
s.input(TreeMsg::Menu(path.clone()));
return;
}
if button != 1 {
return;
}
let ctrl = state.contains(gdk::ModifierType::CONTROL_MASK);
let shift = state.contains(gdk::ModifierType::SHIFT_MASK);
s.input(TreeMsg::RowPress { path: path.clone(), is_dir, ctrl, shift });
});
{
let s = sender.clone();
click.connect_released(move |gesture, _n_press, _x, _y| {
if gesture.current_button() == 1 {
s.input(TreeMsg::RowRelease { path: release_path.clone() });
}
});
}
list_row.add_controller(click);
if row.is_dir {
let drop = gtk::DropTarget::new(gdk::FileList::static_type(), gdk::DragAction::MOVE | gdk::DragAction::COPY);
let s = sender.clone();
let target_dir = row.path.clone();
let active = tree.drag_active.clone();
let landed = tree.drag_landed_internal.clone();
drop.connect_drop(move |target, value, _x, _y| {
let Ok(list) = value.get::<gdk::FileList>() else {
return false;
};
let sources: Vec<PathBuf> = list.files().iter().filter_map(gio::File::path).collect();
if sources.is_empty() {
return false;
}
let copy = target.current_event_state().contains(gdk::ModifierType::CONTROL_MASK);
if active.get() {
landed.set(true);
}
s.input(TreeMsg::DropInto { target: target_dir.clone(), sources, copy });
true
});
list_row.add_controller(drop);
}
list.append(&list_row);
}
tree.rows = rows;
widgets.scrolled.set_child(Some(list));
if let Some(row) = menu_row {
let target = tree.menu_target.take().expect("menu_row set implies a target");
let selection = tree.selected.clone();
tree.popover = Some(build_menu(&row, &target, &tree.menu, &selection, tree.side, &sender));
tree.menu_point = None;
} else if let (Some(target), Some(at)) = (tree.menu_target.take(), tree.menu_point.take()) {
let selection = tree.selected.clone();
tree.popover = Some(build_menu_at(&widgets.scrolled, at, &target, &tree.menu, &selection, tree.side, &sender));
}
tree.menu_target = None;
tree.menu_point = None;
}
fn action_builtin(action: &ContextAction) -> Option<BuiltinAction> {
match action {
ContextAction::Builtin(b) => Some(*b),
ContextAction::Entry(e) => Some(e.action),
ContextAction::Command(_) | ContextAction::Submenu(_) => None,
}
}
fn menu_label(action: &ContextAction, path: &Path, side: PanelSide) -> String {
let dynamic = match action_builtin(action) {
Some(BuiltinAction::OpenWithDefault) => default_app_label(path),
Some(BuiltinAction::InOppositePanel) => Some(format!("In {} panel", side.opposite().name())),
Some(BuiltinAction::ViewThumbnail) if media_kind(path) == Some(MediaKind::Audio) => {
Some("Show Player".to_owned())
}
_ => None,
};
dynamic.unwrap_or_else(|| action.label())
}
pub(crate) fn accel_display(accel: &str) -> Option<String> {
let (key, mods) = parse_accelerator(accel)?;
Some(gtk::accelerator_get_label(key, mods).to_string())
}
fn build_menu_box(
path: &Path,
menu: &ContextMenu,
selection: &[PathBuf],
side: PanelSide,
sender: &ComponentSender<Tree>,
) -> gtk::Box {
let menu_box = gtk::Box::new(gtk::Orientation::Vertical, 0);
menu_box.add_css_class("tree-menu");
let path = path.to_path_buf();
let actions = menu.actions_for_selection(&path, selection.len());
append_menu_items(&menu_box, &actions, &path, selection, side, sender);
menu_box
}
fn append_menu_items(
menu_box: >k::Box,
actions: &[ContextAction],
path: &Path,
selection: &[PathBuf],
side: PanelSide,
sender: &ComponentSender<Tree>,
) {
let multi = selection.len() > 1;
for action in actions {
if action.is_hidden() {
continue;
}
if action_builtin(action) == Some(BuiltinAction::Separator) {
let separator = gtk::Separator::new(gtk::Orientation::Horizontal);
separator.add_css_class("tree-menu-separator");
menu_box.append(&separator);
continue;
}
if let Some(builtin) = action_builtin(action) {
if builtin.is_directory_only() && !path.is_dir() {
continue;
}
if multi && builtin.is_single_row_only() {
continue;
}
if matches!(builtin, BuiltinAction::OpenWithDefault) && path.is_dir() {
continue;
}
if builtin == BuiltinAction::ViewThumbnail
&& !path.is_dir()
&& !is_thumbnailable(path)
{
continue;
}
}
let row_box = gtk::Box::new(gtk::Orientation::Horizontal, 12);
row_box.add_css_class("tree-menu-item");
if let ContextAction::Submenu(sub) = action {
let button = submenu_row_button(&sub.label);
let popover = build_submenu_popover(&sub.items, path, selection, side, sender);
let child = popover.clone();
let anchor = button.clone();
button.connect_clicked(move |_| {
child.set_parent(&anchor);
child.popup();
});
let child = popover.clone();
popover.connect_closed(move |_| child.unparent());
row_box.append(&button);
row_box.append(&submenu_arrow());
menu_box.append(&row_box);
continue;
}
let label = menu_label(action, path, side);
let msg = match action {
ContextAction::Builtin(b) => action_message(*b, path, selection),
ContextAction::Entry(e) => action_message(e.action, path, selection),
ContextAction::Command(cmd) => {
if multi {
TreeMsg::RunCommandEach {
command: cmd.command.clone(),
paths: selection.to_vec(),
}
} else {
TreeMsg::RunCommand {
command: cmd.command.clone(),
path: path.to_path_buf(),
}
}
}
ContextAction::Submenu(_) => unreachable!("handled above"),
};
let button = gtk::Button::new();
button.set_halign(gtk::Align::Fill);
button.set_hexpand(true);
let label_widget = gtk::Label::new(Some(&label));
label_widget.set_xalign(0.0);
label_widget.set_hexpand(true);
button.set_child(Some(&label_widget));
let s = sender.clone();
button.connect_clicked(move |_| s.input(msg.clone()));
row_box.append(&button);
if let Some(shortcut) = action.shortcut()
&& let Some(display) = accel_display(&shortcut)
{
let hint = gtk::Label::new(Some(&display));
hint.add_css_class("tree-menu-shortcut");
hint.set_halign(gtk::Align::End);
hint.set_valign(gtk::Align::Center);
row_box.append(&hint);
}
menu_box.append(&row_box);
}
}
fn submenu_row_button(label: &str) -> gtk::Button {
let button = gtk::Button::new();
button.set_halign(gtk::Align::Fill);
button.set_hexpand(true);
let text = gtk::Label::new(Some(label));
text.set_xalign(0.0);
text.set_hexpand(true);
button.set_child(Some(&text));
button
}
fn submenu_arrow() -> gtk::Label {
let arrow = gtk::Label::new(Some("\u{25b8}"));
arrow.add_css_class("tree-menu-submenu-arrow");
arrow.set_halign(gtk::Align::End);
arrow.set_valign(gtk::Align::Center);
arrow
}
fn build_submenu_popover(
items: &[ContextAction],
path: &Path,
selection: &[PathBuf],
side: PanelSide,
sender: &ComponentSender<Tree>,
) -> gtk::Popover {
let menu_box = gtk::Box::new(gtk::Orientation::Vertical, 0);
menu_box.add_css_class("tree-menu");
append_menu_items(&menu_box, items, path, selection, side, sender);
let popover = gtk::Popover::new();
popover.add_css_class("tree-menu-popover");
popover.set_has_arrow(false);
popover.set_position(gtk::PositionType::Right);
popover.set_child(Some(&menu_box));
popover
}
fn show_menu(
anchor: &impl IsA<gtk::Widget>,
menu_box: >k::Box,
point: Option<(f64, f64)>,
) -> gtk::Popover {
let popover = gtk::Popover::new();
popover.add_css_class("tree-menu-popover");
popover.set_child(Some(menu_box));
popover.set_autohide(true);
popover.set_parent(anchor);
if let Some((x, y)) = point {
popover.set_pointing_to(Some(&gdk::Rectangle::new(x as i32, y as i32, 1, 1)));
}
popover.popup();
popover
}
fn build_menu(
row: >k::ListBoxRow,
path: &Path,
menu: &ContextMenu,
selection: &[PathBuf],
side: PanelSide,
sender: &ComponentSender<Tree>,
) -> gtk::Popover {
let menu_box = build_menu_box(path, menu, selection, side, sender);
show_menu(row, &menu_box, None)
}
fn build_menu_at(
anchor: &impl IsA<gtk::Widget>,
at: (f64, f64),
path: &Path,
menu: &ContextMenu,
selection: &[PathBuf],
side: PanelSide,
sender: &ComponentSender<Tree>,
) -> gtk::Popover {
let menu_box = build_menu_box(path, menu, selection, side, sender);
show_menu(anchor, &menu_box, Some(at))
}
fn action_message(action: BuiltinAction, path: &Path, selection: &[PathBuf]) -> TreeMsg {
if selection.len() > 1 {
match action {
BuiltinAction::Trash => return TreeMsg::DeleteSelected,
BuiltinAction::DeletePermanently => return TreeMsg::PermanentDeleteSelected,
BuiltinAction::CopyPath => return TreeMsg::CopyPaths(selection.to_vec()),
BuiltinAction::CopyRelativePath => return TreeMsg::CopyRelativePaths(selection.to_vec()),
BuiltinAction::Properties => return TreeMsg::PropertiesSelected(selection.to_vec()),
_ => {}
}
}
builtin_message(action, path)
}
fn builtin_message(action: BuiltinAction, path: &Path) -> TreeMsg {
match action {
BuiltinAction::Open => TreeMsg::Activate(path.to_path_buf()),
BuiltinAction::OpenSplit => TreeMsg::OpenSplit(path.to_path_buf()),
BuiltinAction::InNewPanel => TreeMsg::OpenSplit(path.to_path_buf()),
BuiltinAction::InOppositePanel => TreeMsg::OpenInOppositePanel(path.to_path_buf()),
BuiltinAction::OpenWith => TreeMsg::OpenWith(path.to_path_buf()),
BuiltinAction::OpenWithDefault => TreeMsg::OpenWithDefault(path.to_path_buf()),
BuiltinAction::ViewThumbnail => TreeMsg::ToggleThumbnail(path.to_path_buf()),
BuiltinAction::NewFile => TreeMsg::NewFile,
BuiltinAction::NewFolder => TreeMsg::NewFolder,
BuiltinAction::Duplicate => TreeMsg::Duplicate(path.to_path_buf()),
BuiltinAction::CreateLink => TreeMsg::CreateLink(path.to_path_buf()),
BuiltinAction::Cut => TreeMsg::Cut,
BuiltinAction::Copy => TreeMsg::Copy,
BuiltinAction::Paste => TreeMsg::Paste,
BuiltinAction::Rename => TreeMsg::RenameAt(path.to_path_buf()),
BuiltinAction::CopyPath => TreeMsg::CopyPath(path.to_path_buf()),
BuiltinAction::CopyRelativePath => TreeMsg::CopyRelativePath(path.to_path_buf()),
BuiltinAction::Properties => TreeMsg::Properties(path.to_path_buf()),
BuiltinAction::Trash => TreeMsg::Trash(path.to_path_buf()),
BuiltinAction::DeletePermanently => TreeMsg::PermanentDelete(path.to_path_buf()),
BuiltinAction::ToggleHidden => TreeMsg::ToggleHidden,
BuiltinAction::SortByName => TreeMsg::SetSortKey(SortKey::Name),
BuiltinAction::SortBySize => TreeMsg::SetSortKey(SortKey::Size),
BuiltinAction::SortByModified => TreeMsg::SetSortKey(SortKey::Modified),
BuiltinAction::SortByType => TreeMsg::SetSortKey(SortKey::Type),
BuiltinAction::ToggleSortAscending => TreeMsg::ToggleSortDirection,
BuiltinAction::OpenFolder
| BuiltinAction::Filter
| BuiltinAction::SplitView
| BuiltinAction::Up
| BuiltinAction::Back
| BuiltinAction::Forward
| BuiltinAction::Collapse
| BuiltinAction::ClosePane => unreachable!("pane action in a row context menu"),
BuiltinAction::Separator => unreachable!("separators are rendered, not dispatched"),
}
}
const ARCHIVE_EXTENSIONS: [&str; 8] = ["zip", "tar", "gz", "xz", "bz2", "zst", "7z", "rar"];
fn icon_name(row: &VisibleRow) -> &'static str {
if row.is_dir {
return if row.expanded { "folder-open-symbolic" } else { "folder-symbolic" };
}
if has_extension(&row.path, &["pdf"]) {
"application-pdf-symbolic"
} else if has_extension(&row.path, &["jpg", "jpeg", "png", "gif", "svg", "webp", "bmp", "avif"])
{
"image-x-generic-symbolic"
} else if has_extension(&row.path, &["mp3", "flac", "wav", "ogg", "m4a", "opus"]) {
"audio-x-generic-symbolic"
} else if has_extension(&row.path, &["mp4", "mkv", "webm", "mov", "avi"]) {
"video-x-generic-symbolic"
} else if has_extension(&row.path, &ARCHIVE_EXTENSIONS) {
"package-x-generic-symbolic"
} else {
"text-x-generic-symbolic"
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum MediaKind {
Image,
Gif,
Video,
Audio,
}
const IMAGE_EXTENSIONS: [&str; 10] =
["jpg", "jpeg", "png", "webp", "bmp", "tif", "tiff", "avif", "heic", "heif"];
const VIDEO_EXTENSIONS: [&str; 15] = [
"mp4", "m4v", "mkv", "webm", "mov", "avi", "wmv", "flv", "ogv", "ogg", "ts", "mpg",
"mpeg", "3gp", "m2ts",
];
const AUDIO_EXTENSIONS: [&str; 12] = [
"mp3", "m4a", "m4b", "aac", "flac", "wav", "opus", "oga", "wma", "aif", "aiff", "alac",
];
fn has_extension(path: &Path, extensions: &[&str]) -> bool {
path.extension()
.and_then(|ext| ext.to_str())
.is_some_and(|ext| extensions.iter().any(|candidate| ext.eq_ignore_ascii_case(candidate)))
}
fn media_kind(path: &Path) -> Option<MediaKind> {
if has_extension(path, &["gif"]) {
Some(MediaKind::Gif)
} else if has_extension(path, &IMAGE_EXTENSIONS) {
Some(MediaKind::Image)
} else if has_extension(path, &VIDEO_EXTENSIONS) {
Some(MediaKind::Video)
} else if cfg!(feature = "audio") && has_extension(path, &AUDIO_EXTENSIONS) {
Some(MediaKind::Audio)
} else {
None
}
}
fn is_thumbnailable(path: &Path) -> bool {
media_kind(path).is_some()
}
struct GifAnim {
iter: gdk::gdk_pixbuf::PixbufAnimationIter,
}
struct AudioWidgets {
player: Rc<AudioPlayer>,
play: gtk::Button,
seek: gtk::Scale,
time: gtk::Label,
seeking: Rc<Cell<bool>>,
}
fn needs_selection(action: BuiltinAction) -> bool {
matches!(
action,
BuiltinAction::Rename
| BuiltinAction::Duplicate
| BuiltinAction::CreateLink
| BuiltinAction::Cut
| BuiltinAction::Copy
| BuiltinAction::OpenWith
| BuiltinAction::Trash
| BuiltinAction::DeletePermanently
)
}
fn in_list_row(widget: >k::Widget) -> bool {
let mut current = Some(widget.clone());
while let Some(widget) = current {
if widget.downcast_ref::<gtk::ListBoxRow>().is_some() {
return true;
}
current = widget.parent();
}
false
}
fn in_inline_media(widget: >k::Widget) -> bool {
let mut current = Some(widget.clone());
while let Some(widget) = current {
if widget.downcast_ref::<gtk::Video>().is_some() || widget.has_css_class("tree-audio") {
return true;
}
current = widget.parent();
}
false
}
fn thumbnail_content_width(panel: PanelConfig) -> i32 {
(panel.width as i32 - 2 * panel.margin as i32 - 24).max(64)
}
fn thumbnail_size(texture: &gdk::Texture, available: i32) -> (i32, i32) {
let natural_w = texture.width().max(1);
let natural_h = texture.height().max(1);
let available = available.max(32);
if natural_w <= available {
return (natural_w, natural_h);
}
let height = (natural_h as i64 * available as i64 / natural_w as i64).max(1) as i32;
(available, height)
}
fn thumbnail_picture(
texture: &gdk::Texture,
margin_start: i32,
width: i32,
height: i32,
) -> gtk::Picture {
let picture = gtk::Picture::for_paintable(texture);
picture.add_css_class("tree-thumbnail");
picture.set_can_shrink(true);
picture.set_content_fit(gtk::ContentFit::ScaleDown);
picture.set_halign(gtk::Align::Start);
picture.set_size_request(width, height);
picture.set_margin_start(margin_start);
picture.set_margin_bottom(6);
picture
}
fn format_time(micros: i64) -> String {
if micros <= 0 {
return "-:--".to_owned();
}
let total = micros / 1_000_000;
let (h, m, s) = (total / 3600, (total % 3600) / 60, total % 60);
if h > 0 {
format!("{h}:{m:02}:{s:02}")
} else {
format!("{m}:{s:02}")
}
}
fn build_audio_player(player: &Rc<AudioPlayer>, margin_start: i32) -> (gtk::Box, AudioWidgets) {
let controls = gtk::Box::new(gtk::Orientation::Horizontal, 6);
controls.add_css_class("tree-audio");
controls.set_margin_start(margin_start);
controls.set_margin_bottom(6);
controls.set_hexpand(true);
let play = gtk::Button::from_icon_name("media-playback-start-symbolic");
play.add_css_class("flat");
play.set_tooltip_text(Some("Play"));
play.set_valign(gtk::Align::Center);
{
let player = player.clone();
play.connect_clicked(move |button| {
let playing = player.is_playing();
player.set_playing(!playing);
button.set_icon_name(if playing {
"media-playback-start-symbolic"
} else {
"media-playback-pause-symbolic"
});
});
}
controls.append(&play);
let seek = gtk::Scale::with_range(gtk::Orientation::Horizontal, 0.0, 1.0, 1.0);
seek.set_draw_value(false);
seek.set_hexpand(true);
seek.set_valign(gtk::Align::Center);
seek.set_size_request(60, -1);
let seeking = Rc::new(Cell::new(false));
{
let player = player.clone();
let seeking = seeking.clone();
seek.connect_change_value(move |_, _, value| {
seeking.set(true);
let flag = seeking.clone();
glib::timeout_add_local_once(Duration::from_millis(200), move || flag.set(false));
player.seek(value.round() as i64);
glib::Propagation::Proceed
});
}
controls.append(&seek);
let time = gtk::Label::new(Some("0:00 / -:--"));
time.add_css_class("tree-audio-time");
time.set_valign(gtk::Align::Center);
time.set_width_chars(13);
time.set_xalign(1.0);
controls.append(&time);
let volume = gtk::Scale::with_range(gtk::Orientation::Horizontal, 0.0, 1.0, 0.05);
volume.set_draw_value(false);
volume.set_value(player.volume());
volume.set_valign(gtk::Align::Center);
volume.set_size_request(56, -1);
volume.set_tooltip_text(Some("Volume"));
{
let player = player.clone();
volume.connect_value_changed(move |scale| player.set_volume(scale.value()));
}
controls.append(&volume);
let widgets = AudioWidgets {
player: player.clone(),
play,
seek,
time,
seeking,
};
(controls, widgets)
}
impl Tree {
fn open_root(&mut self, path: &Path, sender: &ComponentSender<Self>) {
if let Some(id) = self.flush_source.take() {
id.remove();
}
self.pending.clear();
let mut tree = TreeModel::new(
path.to_path_buf(),
self.config.sort_options(),
self.config.show_hidden,
);
if let Err(err) = tree.expand(path, &StdDirSource) {
self.status(format!("Could not open {}: {err}", path.display()), sender);
return;
}
self.tree = Some(tree);
self.refresh_rows();
self.cursor = if self.rows.is_empty() { None } else { Some(0) };
self.selected = Vec::new();
self.renaming = None;
self.renaming_state.set(false);
self.rename_entry = None;
self.menu_target = None;
self.thumbnails.clear();
self.thumb_cache.clear();
self.playing.clear();
for media in self.media.values() {
media.set_playing(false);
}
self.media.clear();
self.audio.clear();
self.gif_anims.borrow_mut().clear();
self.gif_widgets.borrow_mut().clear();
self.audio_widgets.borrow_mut().clear();
if let Some(previous) = self.popover.take() {
previous.unparent();
}
let root = path.to_path_buf();
let watcher_sender = sender.clone();
self._watcher = spawn_watcher(path, move |change| {
watcher_sender.input(TreeMsg::FsChange(change));
})
.ok();
self.watched.clear();
if self._watcher.is_some() {
self.watched.insert(root.clone());
}
let _ = sender.output(TreeOutput::RootChanged(root));
}
fn refresh_rows(&mut self) {
if let Some(tree) = self.tree.as_ref() {
tree.visible_rows_into(&mut self.rows);
} else {
self.rows.clear();
}
}
fn reconcile_watches(&mut self) {
let Some(watcher) = self._watcher.as_mut() else {
return;
};
let Some(model) = self.tree.as_ref() else {
return;
};
let desired: HashSet<PathBuf> = model.expanded_loaded_dirs().into_iter().collect();
let stale: Vec<PathBuf> = self.watched.difference(&desired).cloned().collect();
for path in stale {
let _ = watcher.unwatch(&path);
self.watched.remove(&path);
}
let fresh: Vec<PathBuf> = desired.difference(&self.watched).cloned().collect();
for path in fresh {
if watcher.watch(&path, RecursiveMode::NonRecursive).is_ok() {
self.watched.insert(path);
}
}
}
fn cursor_path(&self) -> Option<PathBuf> {
self.cursor.and_then(|i| self.rows.get(i)).map(|r| r.path.clone())
}
fn cursor_delta(&mut self, delta: isize) {
if self.rows.is_empty() {
return;
}
let next = (self.cursor.unwrap_or(0) as isize + delta).clamp(0, self.rows.len() as isize - 1) as usize;
let path = self.rows[next].path.clone();
self.cursor = Some(next);
self.set_single_selection(path);
self.selection_dirty = true;
}
fn cursor_extend(&mut self, delta: isize, _keep_anchor: bool) {
if self.rows.is_empty() {
return;
}
let anchor = self
.anchor
.clone()
.or_else(|| self.cursor.map(|i| self.rows[i].path.clone()))
.unwrap_or_else(|| self.rows[0].path.clone());
let next = (self.cursor.unwrap_or(0) as isize + delta)
.clamp(0, self.rows.len() as isize - 1) as usize;
self.cursor = Some(next);
self.anchor = Some(anchor.clone());
let (Some(a), Some(b)) = (self.rows.iter().position(|r| r.path == anchor), Some(next)) else {
return;
};
self.selected = self.rows[index_range(a, b)].iter().map(|r| r.path.clone()).collect();
self.scroll_to = Some(next);
self.selection_dirty = true;
}
fn cursor_snap(&mut self) {
let deselected = self.cursor.is_none();
let prev = self.cursor_path();
self.refresh_rows();
if self.rows.is_empty() {
self.cursor = None;
self.selected.clear();
return;
}
if deselected {
self.cursor = None;
return;
}
let index = prev
.and_then(|p| self.rows.iter().position(|r| r.path == p))
.unwrap_or(0);
self.cursor = Some(index);
}
fn select(&mut self, path: &Path) {
if let Some(i) = self.rows.iter().position(|r| r.path == path) {
self.cursor = Some(i);
}
self.selected = vec![path.to_path_buf()];
}
fn press_default(&mut self, path: &Path, is_dir: bool, sender: &ComponentSender<Self>) {
let now = std::time::Instant::now();
let is_double = self.last_press.as_ref().is_some_and(|(last, at)| {
last == path && now.duration_since(*at) < Duration::from_millis(400)
});
self.last_press = Some((path.to_path_buf(), now));
self.select(path);
self.anchor = Some(path.to_path_buf());
if is_dir {
self.toggle_dir(path, sender);
} else if is_double {
self.activate(path, sender);
}
}
fn reveal_path(&mut self, path: PathBuf) {
if let Some(i) = self.rows.iter().position(|r| r.path == path) {
self.cursor = Some(i);
self.selected = vec![path.clone()];
self.anchor = Some(path);
self.selection_dirty = true;
self.scroll_to = Some(i);
}
}
fn range_select(&mut self, path: &Path) {
let Some(anchor) = self.anchor.clone() else {
self.select(path);
self.anchor = Some(path.to_path_buf());
return;
};
let (Some(a), Some(b)) = (
self.rows.iter().position(|r| r.path == anchor),
self.rows.iter().position(|r| r.path == path),
) else {
self.select(path);
return;
};
self.selected = self.rows[index_range(a, b)].iter().map(|r| r.path.clone()).collect();
self.cursor = Some(b);
}
fn set_single_selection(&mut self, path: PathBuf) {
self.selected = vec![path.clone()];
if let Some(i) = self.rows.iter().position(|r| r.path == path) {
self.cursor = Some(i);
}
}
fn selected_for_clipboard(&self) -> Vec<PathBuf> {
if self.selected.is_empty() {
if let Some(path) = self.cursor_path() {
return vec![path];
}
return Vec::new();
}
self.selected.clone()
}
fn collapse_dir(&mut self, path: &Path) {
if let Some(tree) = self.tree.as_mut() {
tree.collapse(path);
}
self.clear_thumbnails_under(path);
self.cursor_snap();
}
fn toggle_dir(&mut self, path: &Path, sender: &ComponentSender<Self>) {
let expanded = self.tree.as_ref().is_some_and(|t| t.is_expanded(path));
if expanded {
self.collapse_dir(path);
} else {
self.expand_dir(path, sender);
}
}
fn shutdown(&mut self) {
if let Some(id) = self.gif_tick.borrow_mut().take() {
id.remove();
}
if let Some(id) = self.audio_tick.borrow_mut().take() {
id.remove();
}
self.gif_anims.borrow_mut().clear();
self.gif_widgets.borrow_mut().clear();
self.audio_widgets.borrow_mut().clear();
self.playing.clear();
self.thumbnails.clear();
for media in self.media.values() {
media.set_playing(false);
}
self.media.clear();
self.audio.clear();
}
fn clear_thumbnails_under(&mut self, prefix: &Path) {
self.thumbnails.retain(|path| !path.starts_with(prefix));
self.thumb_cache.retain(|path, _| !path.starts_with(prefix));
self.playing.retain(|path| !path.starts_with(prefix));
self.media.retain(|path, media| {
if path.starts_with(prefix) {
media.set_playing(false);
false
} else {
true
}
});
self.gif_anims.borrow_mut().retain(|path, _| !path.starts_with(prefix));
self.gif_widgets.borrow_mut().retain(|path, _| !path.starts_with(prefix));
self.audio_widgets.borrow_mut().retain(|path, _| !path.starts_with(prefix));
self.audio.retain(|path, _| !path.starts_with(prefix));
}
fn wants_thumbnail(&self, row: &VisibleRow) -> bool {
if row.is_dir || !is_thumbnailable(&row.path) {
return false;
}
self.thumbnails.contains(&row.path)
|| row
.path
.ancestors()
.skip(1)
.any(|ancestor| self.thumbnails.contains(ancestor))
}
fn static_texture(&mut self, path: &Path) -> Option<gdk::Texture> {
if let Some(texture) = self.thumb_cache.get(path) {
return Some(texture.clone());
}
let texture = match media_kind(path)? {
MediaKind::Image => gdk::Texture::from_filename(path).ok()?,
MediaKind::Gif => {
let animation = gdk::gdk_pixbuf::PixbufAnimation::from_file(path).ok()?;
let pixbuf = animation.static_image()?;
gdk::Texture::for_pixbuf(&pixbuf)
}
MediaKind::Video | MediaKind::Audio => return None,
};
self.thumb_cache.insert(path.to_path_buf(), texture.clone());
Some(texture)
}
fn media_file(&mut self, path: &Path) -> Option<gtk::MediaFile> {
if let Some(media) = self.media.get(path) {
return Some(media.clone());
}
let media = gtk::MediaFile::for_filename(path);
media.set_loop(true);
media.set_muted(false);
media.set_volume(1.0);
self.media.insert(path.to_path_buf(), media.clone());
Some(media)
}
fn audio_player(&mut self, path: &Path) -> Option<Rc<AudioPlayer>> {
if let Some(player) = self.audio.get(path) {
return Some(player.clone());
}
let player = AudioPlayer::new(path)?;
self.audio.insert(path.to_path_buf(), player.clone());
Some(player)
}
fn toggle_thumbnail_play(&mut self, path: &Path) {
if media_kind(path) != Some(MediaKind::Gif) {
return;
}
if self.playing.remove(path) {
self.gif_anims.borrow_mut().remove(path);
} else {
let Ok(animation) = gdk::gdk_pixbuf::PixbufAnimation::from_file(path) else {
return;
};
let iter = animation.iter(Some(SystemTime::now()));
self.gif_anims.borrow_mut().insert(path.to_path_buf(), GifAnim { iter });
self.playing.insert(path.to_path_buf());
self.ensure_gif_ticker();
}
}
fn ensure_gif_ticker(&mut self) {
if self.gif_tick.borrow().is_some() {
return;
}
let anims = self.gif_anims.clone();
let widgets = self.gif_widgets.clone();
let tick = self.gif_tick.clone();
let id = glib::timeout_add_local(Duration::from_millis(33), move || {
let mut anims = anims.borrow_mut();
if anims.is_empty() {
*tick.borrow_mut() = None;
return glib::ControlFlow::Break;
}
let now = SystemTime::now();
let widgets = widgets.borrow();
for (path, anim) in anims.iter_mut() {
let _ = anim.iter.advance(now);
if let Some(picture) = widgets.get(path) {
let pixbuf = anim.iter.pixbuf();
picture.set_paintable(Some(&gdk::Texture::for_pixbuf(&pixbuf)));
}
}
glib::ControlFlow::Continue
});
*self.gif_tick.borrow_mut() = Some(id);
}
fn ensure_audio_ticker(&mut self) {
if self.audio_tick.borrow().is_some() {
return;
}
let players = self.audio_widgets.clone();
let tick = self.audio_tick.clone();
let id = glib::timeout_add_local(Duration::from_millis(200), move || {
let players = players.borrow();
if players.is_empty() {
*tick.borrow_mut() = None;
return glib::ControlFlow::Break;
}
for widgets in players.values() {
let position = widgets.player.position();
let duration = widgets.player.duration();
if duration > 0
&& (widgets.seek.adjustment().upper() - duration as f64).abs() > 0.5
{
widgets.seek.adjustment().set_upper(duration as f64);
}
if !widgets.seeking.get() {
widgets.seek.set_value(position as f64);
}
widgets.time.set_text(&format!(
"{} / {}",
format_time(position),
format_time(duration)
));
widgets.play.set_icon_name(if widgets.player.is_playing() {
"media-playback-pause-symbolic"
} else {
"media-playback-start-symbolic"
});
}
glib::ControlFlow::Continue
});
*self.audio_tick.borrow_mut() = Some(id);
}
fn append_thumbnail(
&mut self,
path: &Path,
row_indent: i32,
container: >k::Box,
sender: &ComponentSender<Self>,
) {
let Some(kind) = media_kind(path) else {
return;
};
let label_start = row_indent + self.config.icon_size as i32 + 6;
let available = self.content_width - label_start - 6;
match kind {
MediaKind::Image => {
if let Some(texture) = self.static_texture(path) {
let (width, height) = thumbnail_size(&texture, available);
container.append(&thumbnail_picture(&texture, label_start, width, height));
}
}
MediaKind::Gif => {
let playing = self.playing.contains(path);
let texture = if playing {
self.gif_anims
.borrow()
.get(path)
.map(|anim| gdk::Texture::for_pixbuf(&anim.iter.pixbuf()))
} else {
self.static_texture(path)
};
if let Some(texture) = texture {
let (width, height) = thumbnail_size(&texture, available);
let picture = thumbnail_picture(&texture, label_start, width, height);
picture.set_tooltip_text(Some("Click to play"));
if playing {
self.gif_widgets
.borrow_mut()
.insert(path.to_path_buf(), picture.clone());
}
let msg = TreeMsg::ToggleThumbnailPlay(path.to_path_buf());
let s = sender.clone();
let click = gtk::GestureClick::new();
click.set_button(1);
click.connect_pressed(move |_, _, _, _| s.input(msg.clone()));
picture.add_controller(click);
container.append(&picture);
}
}
MediaKind::Video => {
if let Some(media) = self.media_file(path) {
let video = gtk::Video::new();
video.set_autoplay(false);
video.set_media_stream(Some(&media));
video.set_loop(true);
video.add_css_class("tree-thumbnail");
video.set_halign(gtk::Align::Start);
let width = available.max(64);
video.set_size_request(width, (width * 9 / 16).max(36));
video.set_margin_start(label_start);
video.set_margin_bottom(6);
container.append(&video);
}
}
MediaKind::Audio => {
if let Some(player) = self.audio_player(path) {
let (controls, widgets) = build_audio_player(&player, label_start);
self.audio_widgets.borrow_mut().insert(path.to_path_buf(), widgets);
container.append(&controls);
self.ensure_audio_ticker();
}
}
}
}
fn expand_dir(&mut self, path: &Path, sender: &ComponentSender<Self>) {
let Some(tree) = self.tree.as_mut() else {
return;
};
if let Err(err) = tree.expand(path, &StdDirSource) {
self.status(format!("Could not open {}: {err}", path.display()), sender);
}
self.refresh_rows();
}
fn activate(&mut self, path: &Path, sender: &ComponentSender<Self>) {
let Some(index) = self.rows.iter().position(|r| r.path == path) else {
return;
};
if self.rows[index].is_dir {
self.open_root(path, sender);
} else {
open_in_app(path);
}
}
fn confirm_trash(&mut self, paths: Vec<PathBuf>, sender: ComponentSender<Self>) {
let message = match paths.as_slice() {
[single] => {
let name = single
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| single.display().to_string());
format!("Move \u{201c}{name}\u{201d} to trash?")
}
many => format!("Move {} items to trash?", many.len()),
};
let dialog = gtk::AlertDialog::builder()
.modal(true)
.message(message)
.detail("The panel cannot restore trashed items.")
.buttons(["Cancel", "Trash"])
.cancel_button(0)
.default_button(1)
.build();
dialog.choose(Some(&self.parent), None::<&gio::Cancellable>, move |res| {
if matches!(res, Ok(1)) {
sender.input(TreeMsg::ConfirmTrash(paths));
}
});
}
fn confirm_permanent_delete(&mut self, paths: Vec<PathBuf>, sender: ComponentSender<Self>) {
let message = match paths.as_slice() {
[single] => {
let name = single
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| single.display().to_string());
format!("Permanently delete \u{201c}{name}\u{201d}?")
}
many => format!("Permanently delete {} items?", many.len()),
};
let dialog = gtk::AlertDialog::builder()
.modal(true)
.message(message)
.detail("This cannot be undone. Files will not go to the trash.")
.buttons(["Cancel", "Delete Permanently"])
.cancel_button(0)
.default_button(1)
.build();
dialog.choose(Some(&self.parent), None::<&gio::Cancellable>, move |res| {
if matches!(res, Ok(1)) {
sender.input(TreeMsg::ConfirmPermanentDelete(paths));
}
});
}
fn open_menu(&mut self, path: &Path) {
let in_selection = self.selected.len() > 1 && self.selected.iter().any(|p| p == path);
if !in_selection {
self.select(path);
} else if let Some(i) = self.rows.iter().position(|r| r.path == path) {
self.cursor = Some(i);
}
if let Some(previous) = self.popover.take() {
previous.unparent();
}
self.menu_target = Some(path.to_path_buf());
self.menu_point = None;
}
fn open_menu_at(&mut self, path: &Path, at: (f64, f64)) {
if let Some(previous) = self.popover.take() {
previous.unparent();
}
self.menu_target = Some(path.to_path_buf());
self.menu_point = Some(at);
}
fn paste(&mut self, sender: &ComponentSender<Self>) {
let Some(clip) = self.clipboard.clone() else {
self.paste_from_system_clipboard(sender);
return;
};
let Some(dir) = self.target_dir() else {
return;
};
let mut count = 0usize;
let mut failed: Option<String> = None;
for src in clip.paths {
if src == dir {
continue;
}
let result = match clip.op {
ClipboardOp::Copy => self.ops.copy(&src, &dir),
ClipboardOp::Cut => self.ops.move_(&src, &dir),
};
match result {
Ok(dest) => {
count += 1;
self.apply_change(Change::Created { path: dest });
if clip.op == ClipboardOp::Cut {
self.apply_change(Change::Removed { path: src });
}
}
Err(err) => {
failed = Some(err.to_string());
break;
}
}
}
if let Some(failed) = failed {
self.status(failed, sender);
return;
}
let verb = match clip.op {
ClipboardOp::Copy => "Copied",
ClipboardOp::Cut => "Moved",
};
self.status(format!("{verb} {count} item(s) into {}", dir.display()), sender);
if clip.op == ClipboardOp::Cut {
self.clipboard = None;
}
self.cursor_snap();
}
fn paste_from_system_clipboard(&mut self, sender: &ComponentSender<Self>) {
if self.target_dir().is_none() {
return;
}
let s = sender.clone();
read_clipboard_files(move |paths| s.input(TreeMsg::PastePaths(paths)));
}
fn paste_paths(&mut self, paths: Vec<PathBuf>, sender: &ComponentSender<Self>) {
let Some(dir) = self.target_dir() else {
return;
};
if paths.is_empty() {
self.status("Clipboard has no files to paste".to_string(), sender);
return;
}
let mut count = 0usize;
let mut failed: Option<String> = None;
for src in paths {
if src == dir {
continue;
}
match self.ops.copy(&src, &dir) {
Ok(dest) => {
count += 1;
self.apply_change(Change::Created { path: dest });
}
Err(err) => {
failed = Some(err.to_string());
break;
}
}
}
if let Some(failed) = failed {
self.status(failed, sender);
return;
}
self.status(format!("Pasted {count} item(s) into {}", dir.display()), sender);
self.cursor_snap();
}
fn request_drop_into(
&mut self,
target: PathBuf,
sources: Vec<PathBuf>,
copy: bool,
sender: &ComponentSender<Self>,
) {
match resolve_drop(&target, sources, copy) {
DropPlan::Ignore => {}
DropPlan::Reject(message) => self.status(message, sender),
DropPlan::Copy(sources) => self.drop_into(&target, &sources, true, sender),
DropPlan::Move(sources) => {
if self.config.confirm_drop_move {
self.confirm_move(target, sources, sender);
} else {
self.drop_into(&target, &sources, false, sender);
}
}
}
}
fn confirm_move(
&mut self,
target: PathBuf,
sources: Vec<PathBuf>,
sender: &ComponentSender<Self>,
) {
let target_name = target
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| target.display().to_string());
let message = match sources.as_slice() {
[single] => {
let name = single
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| single.display().to_string());
format!("Move \u{201c}{name}\u{201d} into \u{201c}{target_name}\u{201d}?")
}
many => format!("Move {} items into \u{201c}{target_name}\u{201d}?", many.len()),
};
let dialog = gtk::AlertDialog::builder()
.modal(true)
.message(message)
.detail("Hold Ctrl while dropping to copy instead of move.")
.buttons(["Cancel", "Move"])
.cancel_button(0)
.default_button(1)
.build();
let s = sender.clone();
dialog.choose(Some(&self.parent), None::<&gio::Cancellable>, move |res| {
if matches!(res, Ok(1)) {
s.input(TreeMsg::DropIntoConfirmed { target, sources });
}
});
}
fn drop_into(
&mut self,
target: &Path,
sources: &[PathBuf],
copy: bool,
sender: &ComponentSender<Self>,
) {
let mut count = 0usize;
let mut failed: Option<String> = None;
for src in sources {
if src == target || src.parent() == Some(target) {
continue; }
if !copy && target.starts_with(src) {
failed = Some("Cannot move a folder into itself".to_string());
break;
}
let result = if copy { self.ops.copy(src, target) } else { self.ops.move_(src, target) };
match result {
Ok(dest) => {
count += 1;
self.apply_change(Change::Created { path: dest });
if !copy {
self.apply_change(Change::Removed { path: src.clone() });
}
}
Err(err) => {
failed = Some(err.to_string());
break;
}
}
}
if let Some(failed) = failed {
self.status(failed, sender);
return;
}
if count > 0 {
let verb = if copy { "Copied" } else { "Moved" };
self.status(format!("{verb} {count} item(s) into {}", target.display()), sender);
}
self.cursor_snap();
}
fn create_entry(&mut self, is_dir: bool, sender: &ComponentSender<Self>) {
let Some(dir) = self.target_dir() else {
return;
};
let name = fresh_name(&dir, if is_dir { "new folder" } else { "new file" });
let result = if is_dir {
self.ops.create_dir(&dir, &name)
} else {
self.ops.create_file(&dir, &name)
};
match result {
Ok(new_path) => {
self.apply_change(Change::Created { path: new_path.clone() });
self.renaming = Some(new_path.clone());
self.renaming_state.set(true);
self.select(&new_path);
}
Err(err) => self.status(format!("Could not create: {err}"), sender),
}
}
fn target_dir(&self) -> Option<PathBuf> {
if let Some(path) = self.cursor_path() {
let is_dir = self.rows.iter().find(|r| r.path == path).map(|r| r.is_dir).unwrap_or(false);
if is_dir {
return Some(path);
}
return path.parent().map(Path::to_path_buf);
}
self.tree.as_ref().map(|t| t.root().to_path_buf())
}
fn apply_change(&mut self, change: Change) {
if let Some(tree) = self.tree.as_mut() {
tree.apply(&change, &StdDirSource);
self.refresh_rows();
}
}
fn toggle_hidden(&mut self, sender: &ComponentSender<Self>) {
let show = !self.config.show_hidden;
self.config.show_hidden = show;
if let Some(tree) = self.tree.as_mut() {
tree.set_show_hidden(show);
tree.reload_all(&StdDirSource);
self.refresh_rows();
}
let label = if show { "Showing hidden files." } else { "Hiding hidden files." };
self.status(label.to_string(), sender);
}
fn set_sort_key(&mut self, key: SortKey, sender: &ComponentSender<Self>) {
let mut opts = self.config.sort_options();
opts.key = key;
self.config.sort_key = key.as_str().to_owned();
if let Some(tree) = self.tree.as_mut() {
tree.set_sort(opts);
tree.reload_all(&StdDirSource);
self.refresh_rows();
}
self.status(format!("Sorting by {}.", key.as_str()), sender);
}
fn toggle_sort_direction(&mut self, sender: &ComponentSender<Self>) {
let mut opts = self.config.sort_options();
opts.ascending = !opts.ascending;
self.config.sort_ascending = opts.ascending;
if let Some(tree) = self.tree.as_mut() {
tree.set_sort(opts);
tree.reload_all(&StdDirSource);
self.refresh_rows();
}
let dir = if opts.ascending { "ascending" } else { "descending" };
self.status(format!("Sort order: {dir}."), sender);
}
fn status(&mut self, message: String, sender: &ComponentSender<Self>) {
let _ = sender.output(TreeOutput::Status(message));
}
fn run_shortcut(&mut self, target: ShortcutTarget, sender: &ComponentSender<Self>) {
if let ShortcutTarget::Builtin(builtin) = &target
&& builtin.is_pane_action()
{
let _ = sender.output(TreeOutput::PaneAction(*builtin));
return;
}
let has_selection = self.cursor.is_some() || !self.selected.is_empty();
let path = match self.cursor_path() {
Some(path) => path,
None => match self.tree.as_ref().map(|t| t.root().to_path_buf()) {
Some(root) => root,
None => return,
},
};
if let ShortcutTarget::Command(cmd) = &target {
if !has_selection {
return;
}
let command = action_command(&cmd.command, &path);
match spawn_command(&command) {
Ok(()) => {
self.status(format!("Ran {} on {}", cmd.command, path.display()), sender);
}
Err(err) => {
self.status(format!("Could not run {}: {err}", cmd.command), sender);
}
}
return;
}
let ShortcutTarget::Builtin(builtin) = target else {
return;
};
if builtin == BuiltinAction::Separator
|| (builtin.is_directory_only() && !path.is_dir())
{
return;
}
if matches!(builtin, BuiltinAction::OpenWithDefault) && path.is_dir() {
return;
}
if builtin == BuiltinAction::ViewThumbnail && !path.is_dir() && !is_thumbnailable(&path) {
return;
}
if !has_selection && needs_selection(builtin) {
return;
}
if has_selection && !self.selected.iter().any(|p| p == &path) {
self.select(&path);
}
let selection = self.selected.clone();
sender.input(action_message(builtin, &path, &selection));
}
}
fn fresh_name(dir: &Path, stem: &str) -> String {
for n in 0.. {
let name = if n == 0 { stem.to_string() } else { format!("{stem} {n}") };
if !dir.join(&name).exists() {
return name;
}
}
unreachable!("the loop above always finds a free name")
}
fn open_in_app(path: &Path) {
let file = gio::File::for_path(path);
let _ = gio::AppInfo::launch_default_for_uri(&file.uri(), None::<&gio::AppLaunchContext>);
}
fn content_type_for(path: &Path) -> Option<glib::GString> {
let file = gio::File::for_path(path);
let info = file
.query_info(
gio::FILE_ATTRIBUTE_STANDARD_CONTENT_TYPE,
gio::FileQueryInfoFlags::NONE,
None::<&gio::Cancellable>,
)
.ok()?;
info.content_type()
}
fn default_app_label(path: &Path) -> Option<String> {
let content_type = content_type_for(path)?;
gio::AppInfo::default_for_type(&content_type, true).map(|app| {
format!("Open With {}", app.display_name())
})
}
#[allow(deprecated)] fn open_with_dialog(parent: >k::Window, path: &Path, sender: &ComponentSender<Tree>) {
let Some(content_type) = content_type_for(path) else {
let _ = sender.output(TreeOutput::Status(format!(
"Could not determine the type of {}",
path.display()
)));
return;
};
let dialog = gtk::AppChooserDialog::for_content_type(
Some(parent),
gtk::DialogFlags::MODAL,
&content_type,
);
dialog.set_title(Some(&format!("Open {} with...", path.display())));
if gtk4_layer_shell::is_supported() && !dialog.is_layer_window() {
dialog.init_layer_shell();
dialog.set_layer(Layer::Overlay);
dialog.set_keyboard_mode(KeyboardMode::Exclusive);
dialog.set_default_size(540, 620);
}
let dialog = dialog.clone();
let path = path.to_path_buf();
let s = sender.clone();
dialog.connect_response(move |dialog, response| {
if response == gtk::ResponseType::Ok
&& let Some(app) = dialog.app_info()
{
let file = gio::File::for_path(&path);
match app.launch(&[file], None::<&gio::AppLaunchContext>) {
Ok(()) => {
let _ = s.output(TreeOutput::Status(format!(
"Opened {} with {}",
path.display(),
app.display_name()
)));
}
Err(err) => {
let _ = s.output(TreeOutput::Status(format!(
"Could not open {} with {}: {err}",
path.display(),
app.display_name()
)));
}
}
}
dialog.close();
});
dialog.present();
}
fn set_clipboard_text(text: &str) {
if let Some(display) = gdk::Display::default() {
display.clipboard().set_text(text);
}
}
const URI_LIST_MIME: &str = "text/uri-list";
const GNOME_CLIP_MIME: &str = "x-special/gnome-copied-files";
fn read_clipboard_files(done: impl FnOnce(Vec<PathBuf>) + 'static) {
let Some(display) = gdk::Display::default() else {
done(Vec::new());
return;
};
let clipboard = display.clipboard();
let formats = clipboard.formats();
let has_uri = formats.contain_mime_type(URI_LIST_MIME);
let has_gnome = formats.contain_mime_type(GNOME_CLIP_MIME);
if !has_uri && !has_gnome {
done(Vec::new());
return;
}
clipboard.read_text_async(gio::Cancellable::NONE, move |result| {
let text = result.ok().flatten().unwrap_or_default();
done(parse_clipboard_uris(&text, has_gnome && !has_uri));
});
}
fn parse_clipboard_uris(text: &str, verb_line: bool) -> Vec<PathBuf> {
text.lines()
.map(str::trim)
.filter(|line| !line.is_empty() && !line.starts_with('#'))
.filter_map(|line| {
if verb_line && (line.eq_ignore_ascii_case("copy") || line.eq_ignore_ascii_case("cut")) {
return None;
}
let (path, _host) = glib::filename_from_uri(line).ok()?;
path.is_absolute().then_some(path)
})
.collect()
}
fn spawn_command(cmd: &[String]) -> Result<(), std::io::Error> {
if cmd.is_empty() {
return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, "empty command"));
}
let mut process = std::process::Command::new(&cmd[0]);
for arg in &cmd[1..] {
process.arg(arg);
}
process.spawn().map(|_| ())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn media_detection_is_extension_based_and_case_insensitive() {
for name in ["a.JPG", "b.jpeg", "c.png", "d.webp", "e.HEIC", "f.tiff"] {
assert_eq!(media_kind(Path::new(name)), Some(MediaKind::Image), "{name}");
}
for name in ["loop.GIF", "x.gif"] {
assert_eq!(media_kind(Path::new(name)), Some(MediaKind::Gif), "{name}");
}
for name in ["clip.mp4", "b.MKV", "c.webm", "d.mov", "e.OGV"] {
assert_eq!(media_kind(Path::new(name)), Some(MediaKind::Video), "{name}");
}
for name in ["song.MP3", "b.flac", "c.wav", "d.m4a", "e.opus", "f.oga"] {
if cfg!(feature = "audio") {
assert_eq!(media_kind(Path::new(name)), Some(MediaKind::Audio), "{name}");
assert!(is_thumbnailable(Path::new(name)), "{name}");
} else {
assert_eq!(media_kind(Path::new(name)), None, "{name}");
}
}
for name in ["a.svg", "b.txt", "c.rs", "noext", "d.png.bak"] {
assert_eq!(media_kind(Path::new(name)), None, "{name}");
assert!(!is_thumbnailable(Path::new(name)), "{name}");
}
}
#[test]
fn thumbnail_width_tracks_the_panel_and_stays_positive() {
let panel = PanelConfig { width: 300, margin: 0, ..PanelConfig::default() };
assert_eq!(thumbnail_content_width(panel), 276);
let panel = PanelConfig { width: 500, margin: 10, ..PanelConfig::default() };
assert_eq!(thumbnail_content_width(panel), 456);
let tiny = PanelConfig { width: 40, margin: 0, ..PanelConfig::default() };
assert_eq!(thumbnail_content_width(tiny), 64);
}
#[test]
fn accelerator_normalization_supports_both_syntaxes() {
assert_eq!(normalize_accelerator("Ctrl+x"), "<Control>x");
assert_eq!(normalize_accelerator("Ctrl+Shift+m"), "<Control><Shift>m");
assert_eq!(normalize_accelerator("Shift+Delete"), "<Shift>Delete");
assert_eq!(normalize_accelerator("ctrl+alt+t"), "<Control><Alt>t");
assert_eq!(normalize_accelerator("<Control>x"), "<Control>x");
assert_eq!(normalize_accelerator("F2"), "F2");
assert_eq!(normalize_accelerator("Delete"), "Delete");
}
fn row(path: &str) -> VisibleRow {
VisibleRow {
path: PathBuf::from(path),
name: path.rsplit('/').next().unwrap().to_string(),
depth: 0,
is_dir: false,
is_symlink: false,
expanded: false,
has_children: false,
matches: false,
}
}
#[test]
fn parse_clipboard_uris_handles_both_formats() {
let uri_list = "#comment\r\nfile:///tmp/a.txt\r\n\r\nfile:///tmp/b%20c.txt\r\n";
assert_eq!(
parse_clipboard_uris(uri_list, false),
vec![PathBuf::from("/tmp/a.txt"), PathBuf::from("/tmp/b c.txt")]
);
let gnome = "cut\nfile:///home/u/x\n";
assert_eq!(parse_clipboard_uris(gnome, true), vec![PathBuf::from("/home/u/x")]);
assert_eq!(parse_clipboard_uris("copy\n", false), Vec::<PathBuf>::new());
assert_eq!(
parse_clipboard_uris("https://example.com/x\nrelative/path\nfile:///ok\n", false),
vec![PathBuf::from("/ok")]
);
assert!(parse_clipboard_uris("", false).is_empty());
}
#[test]
fn icon_name_matches_extensions_case_insensitively() {
assert_eq!(icon_name(&row("/a/Photo.JPG")), "image-x-generic-symbolic");
assert_eq!(icon_name(&row("/a/song.Flac")), "audio-x-generic-symbolic");
assert_eq!(icon_name(&row("/a/clip.mkv")), "video-x-generic-symbolic");
assert_eq!(icon_name(&row("/a/archive.tar")), "package-x-generic-symbolic");
assert_eq!(icon_name(&row("/a/doc.pdf")), "application-pdf-symbolic");
assert_eq!(icon_name(&row("/a/notes.txt")), "text-x-generic-symbolic");
let mut dir = row("/a/pictures");
dir.is_dir = true;
assert_eq!(icon_name(&dir), "folder-symbolic");
dir.expanded = true;
assert_eq!(icon_name(&dir), "folder-open-symbolic");
assert_eq!(icon_name(&row("/a/.bashrc")), "text-x-generic-symbolic");
}
#[test]
fn resolve_drop_filters_noops_and_detects_cycles() {
let target = Path::new("/a/b");
assert_eq!(resolve_drop(target, vec![PathBuf::from("/a/b")], false), DropPlan::Ignore);
assert_eq!(resolve_drop(target, vec![PathBuf::from("/a/b/c")], false), DropPlan::Ignore);
assert!(matches!(
resolve_drop(target, vec![PathBuf::from("/a")], false),
DropPlan::Reject(_)
));
assert!(matches!(
resolve_drop(target, vec![PathBuf::from("/x/y")], false),
DropPlan::Move(_)
));
assert!(matches!(
resolve_drop(target, vec![PathBuf::from("/x/y")], true),
DropPlan::Copy(_)
));
}
#[test]
fn drag_paths_uses_the_selection_only_when_the_row_is_in_it() {
let sel = vec![PathBuf::from("/r/a"), PathBuf::from("/r/b")];
assert_eq!(drag_paths(&sel, Path::new("/r/b")), sel);
assert_eq!(
drag_paths(&sel, Path::new("/r/c")),
vec![PathBuf::from("/r/c")]
);
assert_eq!(
drag_paths(&[PathBuf::from("/r/a")], Path::new("/r/a")),
vec![PathBuf::from("/r/a")]
);
assert_eq!(
drag_paths(&[], Path::new("/r/z")),
vec![PathBuf::from("/r/z")]
);
}
#[test]
fn index_range_is_inclusive_and_order_independent() {
assert_eq!(index_range(2, 2).collect::<Vec<_>>(), vec![2]);
assert_eq!(index_range(1, 4).collect::<Vec<_>>(), vec![1, 2, 3, 4]);
assert_eq!(index_range(4, 1).collect::<Vec<_>>(), vec![1, 2, 3, 4]);
}
#[test]
fn format_uri_list_serializes_file_uris() {
let text = format_uri_list(&[PathBuf::from("/tmp/a.txt"), PathBuf::from("/tmp/b c.txt")]);
assert_eq!(text, "file:///tmp/a.txt\r\nfile:///tmp/b%20c.txt\r\n");
assert!(format_uri_list(&[]).is_empty());
assert_eq!(
parse_clipboard_uris(&text, false),
vec![PathBuf::from("/tmp/a.txt"), PathBuf::from("/tmp/b c.txt")]
);
}
#[test]
fn typeahead_finds_the_next_matching_row_and_wraps() {
let rows = vec![row("/r/apple"), row("/r/banana"), row("/r/apricot"), row("/r/cherry")];
assert_eq!(typeahead_target(&rows, "ap", None), Some(0));
assert_eq!(typeahead_target(&rows, "ap", Some(0)), Some(2)); assert_eq!(typeahead_target(&rows, "b", Some(2)), Some(1)); assert_eq!(typeahead_target(&rows, "zzz", None), None);
}
#[test]
fn typeahead_buffer_resets_after_a_pause_and_caps_length() {
let mut ta = TypeAhead::default();
let t0 = std::time::Instant::now();
assert_eq!(ta.push('a', t0).as_deref(), Some("a"));
assert_eq!(ta.push('b', t0 + Duration::from_millis(100)).as_deref(), Some("ab"));
assert_eq!(ta.push('c', t0 + Duration::from_millis(2000)).as_deref(), Some("c"));
assert_eq!(ta.push('\u{1}', t0), None);
}
}