Skip to main content

tree_space/ui/
tree.rs

1//! The file tree: rendering, keyboard navigation, context menu, inline rename,
2//! clipboard and trash.
3//!
4//! Architecture notes:
5//!
6//! * Rendering is *stateless*: [`TreeMsg`] mutates the model and `update_view`
7//!   rebuilds the whole [`gtk::ListBox`] from [`TreeModel::visible_rows`].
8//!   A tree this size does not need widget recycling, and a full rebuild keeps
9//!   row state trivially consistent.
10//! * Selection lives as paths in the model, not in `ListBox` selection, so it
11//!   survives rebuilds as the tree expands and collapses.
12//! * Keyboard input is captured *in the capture phase* on the scrolled window,
13//!   so arrows work before the user clicks a row. While an inline rename entry
14//!   is focused the capture defers entirely to that entry.
15//! * Filesystem events are debounced (buffered + flushed once after a quiet
16//!   window) so bulk operations do not re-render once per event.
17
18use std::cell::{Cell, RefCell};
19use std::collections::{HashMap, HashSet};
20use std::path::{Path, PathBuf};
21use std::rc::Rc;
22use std::time::{Duration, SystemTime};
23
24use crate::audio::AudioPlayer;
25use crate::config::{
26    BuiltinAction, ContextAction, ContextMenu, PanelConfig, PanelSide, ShortcutTarget,
27    TreeConfig, action_command,
28};
29use crate::fs::model::{Change, SortKey, StdDirSource, TreeModel, VisibleRow};
30use crate::fs::ops::FileOps;
31use crate::fs::watcher::{RecursiveMode, RecommendedWatcher, Watcher, spawn as spawn_watcher};
32use gtk4_layer_shell::{KeyboardMode, Layer, LayerShell};
33use relm4::gtk::{gdk, gio, glib, pango, prelude::*};
34use relm4::prelude::*;
35
36/// Clipboard payload: the operation and the source paths.
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38enum ClipboardOp {
39    Copy,
40    Cut,
41}
42
43/// Prefix-based type-ahead state: printable keys typed in quick succession
44/// accumulate into a search prefix that jumps the cursor to the next matching
45/// row. Reset after [`TYPEAHEAD_TIMEOUT`].
46#[derive(Debug, Default)]
47struct TypeAhead {
48    /// The accumulated prefix (already lowercased).
49    prefix: String,
50    /// When the last key was typed; a gap longer than the timeout resets the
51    /// buffer instead of extending it.
52    last: Option<std::time::Instant>,
53}
54
55/// How long a type-ahead prefix stays live after the last keystroke.
56const TYPEAHEAD_TIMEOUT: Duration = Duration::from_millis(700);
57/// Guard against runaway prefixes from a stuck key.
58const TYPEAHEAD_MAX: usize = 32;
59
60impl TypeAhead {
61    /// Feed `ch`, returning the prefix to search for (empty when the key is not
62    /// part of a useful search, e.g. a control character).
63    fn push(&mut self, ch: char, now: std::time::Instant) -> Option<String> {
64        if !ch.is_alphanumeric() && ch != '_' && ch != '-' && ch != '.' && ch != ' ' {
65            return None;
66        }
67        let expired = self
68            .last
69            .is_some_and(|last| now.duration_since(last) > TYPEAHEAD_TIMEOUT);
70        if expired {
71            self.prefix.clear();
72        }
73        if self.prefix.len() >= TYPEAHEAD_MAX {
74            self.prefix.clear();
75        }
76        self.prefix.push(ch.to_ascii_lowercase());
77        self.last = Some(now);
78        Some(self.prefix.clone())
79    }
80
81    /// Clear the buffer (called when the cursor moves, a rename starts, ...).
82    fn clear(&mut self) {
83        self.prefix.clear();
84        self.last = None;
85    }
86}
87
88/// The index of the next row (cyclically, *after* the current cursor) whose name
89/// starts with `prefix`, or `None` when nothing matches.
90fn typeahead_target(rows: &[VisibleRow], prefix: &str, cursor: Option<usize>) -> Option<usize> {
91    if prefix.is_empty() || rows.is_empty() {
92        return None;
93    }
94    let len = rows.len();
95    let start = cursor.map_or(0, |c| (c + 1) % len);
96    for offset in 0..len {
97        let index = (start + offset) % len;
98        if rows[index].name.to_ascii_lowercase().starts_with(prefix) {
99            return Some(index);
100        }
101    }
102    None
103}
104
105#[derive(Debug, Clone)]
106struct Clipboard {
107    op: ClipboardOp,
108    paths: Vec<PathBuf>,
109}
110
111/// What a drag-and-drop onto `target` should do, decided purely from the paths
112/// (no filesystem access), so it can be unit-tested.
113#[derive(Debug, Clone, PartialEq, Eq)]
114enum DropPlan {
115    /// Nothing to do (empty after filtering, or a no-op drop).
116    Ignore,
117    /// Perform the copy immediately.
118    Copy(Vec<PathBuf>),
119    /// Move the selection (immediately or after confirmation, per config).
120    Move(Vec<PathBuf>),
121    /// Reject with this message (moving a directory into itself).
122    Reject(String),
123}
124
125/// Decide what dropping `sources` onto `target` does. `sources` are filtered to
126/// those that are not the target itself and do not already live in it.
127fn resolve_drop(target: &Path, sources: Vec<PathBuf>, copy: bool) -> DropPlan {
128    let sources: Vec<PathBuf> = sources
129        .into_iter()
130        .filter(|src| src != target && src.parent() != Some(target))
131        .collect();
132    if sources.is_empty() {
133        return DropPlan::Ignore;
134    }
135    if sources.iter().any(|src| target.starts_with(src)) {
136        return DropPlan::Reject("Cannot move a folder into itself".to_string());
137    }
138    if copy { DropPlan::Copy(sources) } else { DropPlan::Move(sources) }
139}
140
141/// The inclusive index range between two rows, regardless of order. Pure so the
142/// shift-extension logic can be unit-tested without a live widget tree.
143fn index_range(a: usize, b: usize) -> std::ops::RangeInclusive<usize> {
144    if a <= b { a..=b } else { b..=a }
145}
146
147/// The paths a drag started on `pressed` should carry. When `pressed` is part
148/// of the current multi-selection, the whole selection moves together (the
149/// usual file-manager behavior); otherwise only the pressed row is dragged.
150/// Pure so it can be unit-tested.
151fn drag_paths(selected: &[PathBuf], pressed: &Path) -> Vec<PathBuf> {
152    if selected.len() > 1 && selected.iter().any(|p| p == pressed) {
153        selected.to_vec()
154    } else {
155        vec![pressed.to_path_buf()]
156    }
157}
158
159/// Serialize `paths` as a `text/uri-list` payload (CRLF-separated `file://`
160/// URIs), for the drag content provider. Pure and unit-testable.
161fn format_uri_list(paths: &[PathBuf]) -> String {
162    let mut out = String::new();
163    for path in paths {
164        if let Ok(uri) = glib::filename_to_uri(path, None) {
165            out.push_str(&uri);
166            out.push_str("\r\n");
167        }
168    }
169    out
170}
171
172/// The drag content provider for `paths`: a `gdk::FileList` (which GDK
173/// serializes to the app-native file formats many GTK apps prefer) unioned
174/// with an explicit `text/uri-list` byte payload (which browsers, terminals and
175/// non-GTK apps read). Advertising both maximizes compatibility.
176fn provider_for_paths(paths: &[PathBuf]) -> gdk::ContentProvider {
177    let files: Vec<gio::File> = paths.iter().map(gio::File::for_path).collect();
178    let list = gdk::FileList::from_array(&files);
179    let native = gdk::ContentProvider::for_value(&list.to_value());
180    let uris = format_uri_list(paths);
181    let bytes = glib::Bytes::from(uris.as_bytes());
182    let text = gdk::ContentProvider::for_bytes(URI_LIST_MIME, &bytes);
183    gdk::ContentProvider::new_union(&[native, text])
184}
185
186/// Whether a pointer move from `start` to `now` (both row-local) has grown past
187/// the DND threshold, in either axis. Mirrors GTK's own
188/// `gtk_drag_check_threshold_double` so a drag starts at the same distance a
189/// `DragSource` would use. The threshold comes from the widget's settings
190/// (`gtk-dnd-drag-threshold`, 8px by default); a cast guards against a
191/// nonsensical negative value.
192pub(crate) fn past_drag_threshold(row: &gtk::Widget, start: (f64, f64), now: (f64, f64)) -> bool {
193    let threshold = row
194        .settings()
195        .gtk_dnd_drag_threshold()
196        .max(1) as f64;
197    (now.0 - start.0).abs() > threshold || (now.1 - start.1).abs() > threshold
198}
199
200/// A small drag icon showing how many rows are being dragged. `1` renders as a
201/// single-file glyph, more as an "N items" badge.
202fn drag_badge(count: usize) -> gtk::Widget {
203    let label = gtk::Label::new(Some(&if count <= 1 {
204        "1 item".to_owned()
205    } else {
206        format!("{count} items")
207    }));
208    label.add_css_class("tree-drag-badge");
209    label.upcast()
210}
211
212/// Where a press landed, so a subsequent pointer move can decide whether the
213/// press has grown into a drag. Recorded on every left press and consumed by
214/// the scrolled-window motion controller.
215#[derive(Debug, Clone)]
216struct DragOrigin {
217    /// The press point in scrolled-window coordinates.
218    x: f64,
219    y: f64,
220}
221
222/// Start a DND drag of `paths` originating from `widget`, manually.
223///
224/// GTK's own [`gtk::DragSource`] cannot start a drag from this panel: the
225/// window is a wlr-layer-shell surface, and its internal drag gesture is never
226/// *recognized* here, so `gdk_drag_begin` is never called (confirmed against
227/// `gtkdragsource.c` and a `WAYLAND_DEBUG=1` trace, which showed pointer motion
228/// arriving but no `wl_data_device.start_drag`). The layer surface itself is
229/// fine — calling [`gdk::Drag::begin`] directly from it makes the compositor
230/// accept the drag — so we reproduce the handful of steps
231/// `gtk_drag_source_drag_begin` performs instead of relying on its gesture.
232///
233/// Returns `true` if the drag actually began.
234pub(crate) fn begin_row_drag(
235    widget: &gtk::Widget,
236    paths: &[PathBuf],
237    start: (f64, f64),
238    actions: gdk::DragAction,
239    on_finished: impl Fn(gdk::Drag, bool) + 'static,
240    on_cancelled: impl Fn(gdk::Drag, gdk::DragCancelReason) + 'static,
241) -> bool {
242    let Some(native) = widget.native() else {
243        return false;
244    };
245    let Some(surface) = native.surface() else {
246        return false;
247    };
248    let Some(device) = widget
249        .display()
250        .default_seat()
251        .and_then(|seat| seat.pointer())
252    else {
253        return false;
254    };
255
256    // Pointer offset within the widget, in surface coordinates, so the drag
257    // icon lines up under the cursor (mirrors GTK's own `dx`/`dy` math).
258    let (dx, dy) = match (
259        surface.device_position(&device),
260        widget.compute_point(
261            &native,
262            &relm4::gtk::graphene::Point::new(start.0 as f32, start.1 as f32),
263        ),
264    ) {
265        (Some((px, py, _)), Some(p)) => ((px - p.x() as f64).round(), (py - p.y() as f64).round()),
266        _ => (0.0, 0.0),
267    };
268
269    let content = provider_for_paths(paths);
270    let Some(drag) = gdk::Drag::begin(&surface, &device, &content, actions, dx, dy) else {
271        return false;
272    };
273
274    let icon = gtk::DragIcon::for_drag(&drag);
275    icon.set_child(Some(&drag_badge(paths.len())));
276
277    {
278        let on_finished = Rc::new(on_finished);
279        let drag = drag.clone();
280        drag.connect_dnd_finished(move |drag| {
281            let delete = drag.selected_action() == gdk::DragAction::MOVE;
282            on_finished(drag.clone(), delete);
283        });
284    }
285    {
286        let on_cancelled = Rc::new(on_cancelled);
287        let drag = drag.clone();
288        drag.connect_cancel(move |drag, reason| on_cancelled(drag.clone(), reason));
289    }
290    true
291}
292
293/// Everything the tree needs that only the app knows.
294pub struct TreeInit {
295    /// Per-tree visual configuration.
296    pub config: TreeConfig,
297    /// The parent window, used to anchor dialogs.
298    pub parent: gtk::Window,
299    /// Configurable per-row context-menu rules.
300    pub menu: ContextMenu,
301    /// Which side of the screen this pane's dock is on (drives the
302    /// "In {opposite} panel" menu label).
303    pub side: PanelSide,
304    /// Panel geometry, used to size inline thumbnails to the column width.
305    pub panel: PanelConfig,
306}
307
308/// User intent for the tree component.
309#[derive(Debug, Clone)]
310pub enum TreeMsg {
311    OpenRoot(PathBuf),
312    /// Reveal `path` in the already-open tree: move the cursor to it, select it,
313    /// and scroll it into view. The path must be a visible row (a child of the
314    /// current root); when it is not visible nothing changes.
315    SelectPath(PathBuf),
316    /// Move keyboard focus into the tree, so a freshly shown or freshly split
317    /// pane receives shortcuts immediately instead of waiting for a click.
318    Focus,
319    SetFilter(String),
320    /// The panel geometry changed (interactive width resize): re-measure the
321    /// column so inline thumbnails follow the new width.
322    SetPanel(PanelConfig),
323
324    MoveUp,
325    MoveDown,
326    /// Shift+Up / Shift+Down: move the cursor `delta` rows and extend the
327    /// selection from the anchor to the new cursor. With no anchor yet, the
328    /// current cursor becomes the anchor.
329    ExtendSelection(isize),
330    /// Ctrl+Shift+Up / Ctrl+Shift+Down: move the cursor `delta` rows without
331    /// collapsing the selection, extending it to include the new cursor while
332    /// keeping the existing anchor. Falls back to [`Self::ExtendSelection`] when
333    /// there is no anchor.
334    MoveExtendingSelection(isize),
335    CollapseCursor,
336    ExpandCursor,
337    ActivateCursor,
338    Rename,
339
340    Activate(PathBuf),
341    Toggle(PathBuf),
342    Select(PathBuf),
343    /// Ctrl+click: add/remove `path` from the selection without disturbing
344    /// the rest of it.
345    ToggleSelect(PathBuf),
346    /// Shift+click: select every row between the last selection anchor and
347    /// `path`, inclusive.
348    RangeSelect(PathBuf),
349    /// A plain left-click on a row: select it and, for a directory, toggle it
350    /// immediately on every press. For files, a second quick click opens the
351    /// file (double-click detection happens in the handler, since the click
352    /// gesture is rebuilt with the rows).
353    RowPress { path: PathBuf, is_dir: bool, ctrl: bool, shift: bool },
354    /// A left button released on a row. Used only to finish a click whose
355    /// selection collapse was deferred at press time (see
356    /// [`Self::RowPress`] and the tree's `pending_click`): if no drag started,
357    /// the deferred press is applied now.
358    RowRelease { path: PathBuf },
359    /// A row drag has begun (`DragSource::drag-begin`). Marks the in-flight
360    /// drag so a click's release does not treat it as a plain click.
361    DragStarted,
362    /// A drag that left the app finished as a MOVE: the receiving application
363    /// took the data, so the sources are moved to the trash. (An in-tree move
364    /// is performed by the drop target instead and never reaches this.)
365    DroppedAsMove(Vec<PathBuf>),
366    /// Left-click on the blank area below the rows: clear the selection and the
367    /// keyboard cursor, so hotkeys act on the open directory rather than a row.
368    Deselect,
369    Menu(PathBuf),
370    /// Right-click on the blank area below the rows: open the context menu for
371    /// the currently open directory (the tree root), anchored at the click.
372    MenuAt { x: f64, y: f64 },
373    RenameAt(PathBuf),
374    /// Trash a single, explicit path (used by the context menu, which always
375    /// targets the row it was opened on regardless of the current selection).
376    Trash(PathBuf),
377    /// Trash the current selection (used by the Delete key).
378    DeleteSelected,
379    ConfirmTrash(Vec<PathBuf>),
380    /// Permanently delete a single explicit path (context menu target).
381    PermanentDelete(PathBuf),
382    /// Permanently delete the current selection (Shift+Delete).
383    PermanentDeleteSelected,
384    /// Actually perform the permanent delete after the user confirmed the dialog.
385    ConfirmPermanentDelete(Vec<PathBuf>),
386    /// Run a configured custom command against `path` (via `{path}`/`{dir}`
387    /// substitution in the config template).
388    RunCommand { command: String, path: PathBuf },
389    /// Open the row's path as a brand-new split pane (directories only).
390    OpenSplit(PathBuf),
391    /// Open the directory as a pane in the *opposite* side's dock.
392    OpenInOppositePanel(PathBuf),
393    /// Pick an application for the row and open it with that app.
394    OpenWith(PathBuf),
395    /// Open the file with its default application.
396    OpenWithDefault(PathBuf),
397    /// Create a symlink to the row next to it.
398    CreateLink(PathBuf),
399    /// Show the row's properties dialog (metadata and a permissions editor).
400    Properties(PathBuf),
401    /// Show a summary properties dialog for a multi-row selection (count, total
402    /// size, and the individual paths).
403    PropertiesSelected(Vec<PathBuf>),
404    /// Copy every path in the list to the system clipboard as text, one per
405    /// line (multi-select form of [`Self::CopyPath`]).
406    CopyPaths(Vec<PathBuf>),
407    /// Copy every path relative to the open directory, one per line
408    /// (multi-select form of [`Self::CopyRelativePath`]).
409    CopyRelativePaths(Vec<PathBuf>),
410    /// Run a configured custom command once per path, in visual top-to-bottom
411    /// order (multi-select form of [`Self::RunCommand`]).
412    RunCommandEach { command: String, paths: Vec<PathBuf> },
413    /// Rename `path` to the bare file name `name` (used by the Properties
414    /// dialog's editable Name field).
415    RenameTo { path: PathBuf, name: String },
416    /// Toggle the inline thumbnail preview for the row (an image, or every
417    /// image inside a directory).
418    ToggleThumbnail(PathBuf),
419    /// Clicked an inline thumbnail: play/pause a video, or animate a GIF.
420    ToggleThumbnailPlay(PathBuf),
421    /// A configured shortcut fired against the row under the keyboard cursor.
422    RunShortcut { target: ShortcutTarget },
423    Duplicate(PathBuf),
424    /// Ctrl+D: duplicate whatever is at the keyboard cursor.
425    RequestDuplicateCursor,
426    /// Ctrl+A: select every visible row.
427    SelectAllRows,
428    /// A printable key was typed in the tree: type-ahead jumps the cursor to
429    /// the next row whose name starts with the accumulated prefix.
430    TypeAhead(char),
431    CopyPath(PathBuf),
432    CopyRelativePath(PathBuf),
433    /// Add the directory row to the app's bookmarks list.
434    AddBookmark(PathBuf),
435
436    RenameCommit,
437    RenameCancel,
438
439    Copy,
440    Cut,
441    Paste,
442    /// Paths decoded from the system clipboard, delivered asynchronously by
443    /// [`read_clipboard_files`] after a Paste with no internal clipboard.
444    PastePaths(Vec<PathBuf>),
445    NewFile,
446    NewFolder,
447
448    /// Toggle dotfile visibility in the current tree.
449    ToggleHidden,
450    /// Set the primary sort key (and keep the current direction).
451    SetSortKey(SortKey),
452    /// Flip ascending/descending order.
453    ToggleSortDirection,
454
455    FsChange(Change),
456    FlushChanges,
457    RequestOpenFolder,
458
459    /// A file was dropped from outside the tree (or dragged from elsewhere in
460    /// it) onto directory `target`. Copies happen immediately; moves are
461    /// confirmed first (see [`TreeMsg::DropIntoConfirmed`]). `copy` is set by
462    /// holding Ctrl during the drop.
463    DropInto { target: PathBuf, sources: Vec<PathBuf>, copy: bool },
464    /// A drop landed on empty space below the rows (or a non-directory row);
465    /// resolved to the current tree root.
466    DropIntoRoot { sources: Vec<PathBuf>, copy: bool },
467    /// The user confirmed a drag-to-move; perform it.
468    DropIntoConfirmed { target: PathBuf, sources: Vec<PathBuf> },
469    /// Drop every thumbnail and stop all media playback ahead of app exit.
470    Shutdown,
471    /// Do nothing. Produced for actions that are dispatched elsewhere (the
472    /// bookmark-only builtins never run against a tree row).
473    Noop,
474}
475
476/// Notable events the tree reports upwards.
477#[derive(Debug)]
478pub enum TreeOutput {
479    /// A transient message for the status bar.
480    Status(String),
481    /// The tree's root changed.
482    RootChanged(PathBuf),
483    /// The user asked to browse for a new root folder.
484    OpenFolderRequested,
485    /// The user asked to open `path` as a new split pane.
486    OpenSplit(PathBuf),
487    /// The user asked to open `path` in the dock on the opposite side.
488    OpenOpposite(PathBuf),
489    /// A pane-level builtin (`Split View`, `Open Folder...`, `Collapse`, ...) fired
490    /// from a keyboard shortcut. The tree cannot perform it, so it forwards the
491    /// action upward for the app to handle.
492    PaneAction(BuiltinAction),
493    /// The user asked to bookmark `path` (a directory).
494    AddBookmark(PathBuf),
495}
496
497/// Rendered state of the tree component.
498pub struct Tree {
499    tree: Option<TreeModel>,
500    ops: FileOps,
501    config: TreeConfig,
502    parent: gtk::Window,
503    menu: ContextMenu,
504    side: PanelSide,
505
506    /// Paths the user turned "View Thumbnail" on: a file previews only itself,
507    /// a directory previews every image beneath it. Non-persistent — cleared
508    /// when a directory collapses or the root changes.
509    thumbnails: HashSet<PathBuf>,
510    /// Decoded textures for the active thumbnails, so rebuilds (which run on
511    /// every selection change) don't re-decode images.
512    thumb_cache: HashMap<PathBuf, gdk::Texture>,
513    /// Decoded media streams for video thumbnails (kept across rebuilds so a
514    /// playing clip isn't restarted by a redraw).
515    media: HashMap<PathBuf, gtk::MediaFile>,
516    /// Audio players (GStreamer `playbin`, not GtkMediaFile — see `crate::audio`),
517    /// kept across rebuilds so playback continues through a redraw.
518    audio: HashMap<PathBuf, Rc<AudioPlayer>>,
519    /// Paths whose thumbnail is currently playing (video or animated GIF).
520    playing: HashSet<PathBuf>,
521    /// GIF frame iterators currently animating, advanced by `gif_tick`.
522    gif_anims: Rc<RefCell<HashMap<PathBuf, GifAnim>>>,
523    /// The live `GtkPicture` for each GIF thumbnail, so `gif_tick` can advance
524    /// it without rebuilding every row. Repopulated on each render.
525    gif_widgets: Rc<RefCell<HashMap<PathBuf, gtk::Picture>>>,
526    /// The GIF animation ticker; present while any GIF is playing.
527    gif_tick: Rc<RefCell<Option<glib::SourceId>>>,
528    /// The live controls for each shown audio player, so `audio_tick` can keep
529    /// its seek bar, clock and play/pause icon in sync with the stream.
530    /// Repopulated on each render.
531    audio_widgets: Rc<RefCell<HashMap<PathBuf, AudioWidgets>>>,
532    /// The audio player ticker; present while any audio player is shown.
533    audio_tick: Rc<RefCell<Option<glib::SourceId>>>,
534    /// Width in px available to a depth-0 row, used to size thumbnails.
535    content_width: i32,
536
537    rows: Vec<VisibleRow>,
538    cursor: Option<usize>,
539    selected: Vec<PathBuf>,
540    /// Base row for the next Shift+click range (last plain- or Ctrl-clicked
541    /// row). `None` means "no anchor yet"; a Shift+click with no anchor
542    /// behaves like a plain click.
543    anchor: Option<PathBuf>,
544    /// Path and time of the last plain row press. Row widgets (and their click
545    /// gestures) are rebuilt on every message, so double-click detection can't
546    /// live in the gesture; this survives across rebuilds.
547    last_press: Option<(PathBuf, std::time::Instant)>,
548
549    renaming: Option<PathBuf>,
550    rename_entry: Option<gtk::Entry>,
551    clipboard: Option<Clipboard>,
552
553    pending: Vec<Change>,
554    flush_source: Option<glib::SourceId>,
555    _watcher: Option<RecommendedWatcher>,
556    /// Directories currently registered with `_watcher` (kept in sync with the
557    /// model's expanded+loaded set, so the watch joins are lazy and cheap).
558    watched: HashSet<PathBuf>,
559
560    /// Popover for the row context menu, parented to the row built by the most
561    /// recent rebuild.
562    popover: Option<gtk::Popover>,
563    /// Set by a context-menu request; consumed by the next rebuild, which
564    /// anchors the popover to the freshly-built row (rebuilds destroy rows, so
565    /// the popover must be attached afterwards).
566    menu_target: Option<PathBuf>,
567    /// When a menu is requested for the open directory (which has no row of its
568    /// own), the click point to anchor the popover at, in `scrolled` coords.
569    menu_point: Option<(f64, f64)>,
570
571    /// Shared with the key-capture closure so it can defer to the rename entry.
572    renaming_state: Rc<Cell<bool>>,
573
574    /// Set by a message that only changed the selection/cursor (no structural
575    /// change). `update_with_view` then updates the `tree-row-selected` class on
576    /// the existing rows instead of rebuilding every widget — the single
577    /// biggest interactive win on large directories.
578    selection_dirty: bool,
579    /// Type-ahead buffer: printable keys typed in quick succession jump the
580    /// cursor to the next row whose name starts with the accumulated prefix.
581    typeahead: TypeAhead,
582    /// Row index to bring into view on the next widget pass (set by
583    /// [`TreeMsg::SelectPath`], which has no widget access of its own).
584    scroll_to: Option<usize>,
585    /// A focus request arrived before any row existed (a freshly-opened
586    /// directory may still be loading); take the keyboard once the first row is
587    /// built.
588    focus_pending: bool,
589
590    /// The paths a drag started from a row should carry. Updated on every row
591    /// press (from the selection at that moment) and read by each row's
592    /// `DragSource::prepare`, so a drag of a multi-selection keeps the whole
593    /// set even though the click that starts it may collapse the selection.
594    drag_payload: Rc<RefCell<Vec<PathBuf>>>,
595    /// True from `drag-begin` until `drag-end`. Lets a click's `released`
596    /// handler tell a click apart from a drag, so selecting-on-click can be
597    /// deferred until we know a drag did not start.
598    drag_active: Rc<Cell<bool>>,
599    /// Set on a plain press onto a row that is already part of a multi-row
600    /// selection; the selection collapse is deferred to `RowRelease` so a drag
601    /// can still carry the whole selection.
602    pending_click: Option<PathBuf>,
603    /// Set by a drop handler that accepted an in-tree drag, so `drag-end` knows
604    /// the move/copy was already performed here and must not also act on the
605    /// source (which GDK's `delete_data` would suggest for a MOVE).
606    drag_landed_internal: Rc<Cell<bool>>,
607    /// The press that may grow into a drag (see [`begin_row_drag`]). Recorded on
608    /// every left press; cleared on release, on drag start, and when a fresh
609    /// press supersedes it.
610    drag_origin: Rc<RefCell<Option<DragOrigin>>>,
611    /// Whether the primary button is currently held anywhere in the tree.
612    /// Rows are rebuilt whenever the selection changes (which a press does), so
613    /// this cannot live on the row widget: the motion controller on the *new*
614    /// row must still see that a press is in flight.
615    drag_button_down: Rc<Cell<bool>>,
616}
617
618#[relm4::component(pub)]
619impl Component for Tree {
620    type Init = TreeInit;
621    type Input = TreeMsg;
622    type Output = TreeOutput;
623    type CommandOutput = ();
624
625    view! {
626        scrolled = gtk::ScrolledWindow {
627            set_vexpand: true,
628            set_hexpand: true,
629            set_min_content_width: 160,
630            add_css_class: "tree-scroll",
631
632            #[name = "list"]
633            gtk::ListBox {
634                set_selection_mode: gtk::SelectionMode::None,
635                set_activate_on_single_click: false,
636            }
637        }
638    }
639
640    fn init(
641        init: Self::Init,
642        root: Self::Root,
643        sender: ComponentSender<Self>,
644    ) -> ComponentParts<Self> {
645        let renaming_state = Rc::new(Cell::new(false));
646        let keys = compile_shortcuts(&init.menu);
647        let widgets = view_output!();
648
649        let controller = gtk::EventControllerKey::new();
650        controller.set_propagation_phase(gtk::PropagationPhase::Capture);
651        let capture_sender = sender.clone();
652        let capture_state = renaming_state.clone();
653        let capture_keys = keys.clone();
654        controller.connect_key_pressed(move |_ctrl, key, _keycode, state| {
655            handle_key(
656                key,
657                state,
658                capture_state.get(),
659                &capture_keys,
660                &capture_sender,
661            )
662        });
663        widgets.scrolled.add_controller(controller);
664
665        // Drops that land on empty space below the rows (or bubble up from a
666        // non-directory row, which has no drop target of its own) resolve to
667        // the tree's current root. Attached once here — unlike the per-row
668        // drop targets rebuilt with their rows, `list` itself persists across
669        // rebuilds, so adding this in `rebuild` would pile up duplicates.
670        let root_drop = gtk::DropTarget::new(gdk::FileList::static_type(), gdk::DragAction::MOVE | gdk::DragAction::COPY);
671        let root_drop_sender = sender.clone();
672        root_drop.connect_drop(move |target, value, _x, _y| {
673            let Ok(list) = value.get::<gdk::FileList>() else {
674                return false;
675            };
676            let sources: Vec<PathBuf> = list.files().iter().filter_map(gio::File::path).collect();
677            if sources.is_empty() {
678                return false;
679            }
680            let copy = target.current_event_state().contains(gdk::ModifierType::CONTROL_MASK);
681            root_drop_sender.input(TreeMsg::DropIntoRoot { sources, copy });
682            true
683        });
684        widgets.list.add_controller(root_drop);
685
686        // The blank area below the rows: left-click clears the selection (so
687        // hotkeys act on the open directory), right-click opens that
688        // directory's context menu. Attached to the scrolled window (which
689        // receives the click even when it falls outside the row list); clicks
690        // that land on a row are left to the row's own handler.
691        let blank_scrolled = widgets.scrolled.clone();
692        let blank_click = gtk::GestureClick::new();
693        blank_click.set_button(0);
694        blank_click.set_propagation_phase(gtk::PropagationPhase::Capture);
695        let blank_sender = sender.clone();
696        blank_click.connect_pressed(move |gesture, _n_press, x, y| {
697            // Super+right is the panel-resize drag: release the sequence so the
698            // window's drag gesture can claim it instead of opening the menu.
699            if gesture.current_button() == 3
700                && gesture.current_event_state().contains(gdk::ModifierType::SUPER_MASK)
701            {
702                gesture.set_state(gtk::EventSequenceState::Denied);
703                return;
704            }
705            let on_row = blank_scrolled
706                .pick(x, y, gtk::PickFlags::DEFAULT)
707                .is_some_and(|widget| in_list_row(&widget));
708            if on_row {
709                return;
710            }
711            match gesture.current_button() {
712                1 => blank_sender.input(TreeMsg::Deselect),
713                3 => blank_sender.input(TreeMsg::MenuAt { x, y }),
714                _ => {}
715            }
716        });
717        widgets.scrolled.add_controller(blank_click);
718
719        let model = Tree {
720            tree: None,
721            ops: FileOps::new(),
722            config: init.config,
723            parent: init.parent,
724            menu: init.menu,
725            side: init.side,
726            thumbnails: HashSet::new(),
727            thumb_cache: HashMap::new(),
728            media: HashMap::new(),
729            audio: HashMap::new(),
730            playing: HashSet::new(),
731            gif_anims: Rc::new(RefCell::new(HashMap::new())),
732            gif_widgets: Rc::new(RefCell::new(HashMap::new())),
733            gif_tick: Rc::new(RefCell::new(None)),
734            audio_widgets: Rc::new(RefCell::new(HashMap::new())),
735            audio_tick: Rc::new(RefCell::new(None)),
736            content_width: thumbnail_content_width(init.panel),
737            rows: Vec::new(),
738            cursor: None,
739            selected: Vec::new(),
740            anchor: None,
741            last_press: None,
742            renaming: None,
743            rename_entry: None,
744            clipboard: None,
745            pending: Vec::new(),
746            flush_source: None,
747            _watcher: None,
748            watched: HashSet::new(),
749            popover: None,
750            menu_target: None,
751            menu_point: None,
752            renaming_state,
753            selection_dirty: false,
754            typeahead: TypeAhead::default(),
755            scroll_to: None,
756            focus_pending: false,
757            drag_payload: Rc::new(RefCell::new(Vec::new())),
758            drag_active: Rc::new(Cell::new(false)),
759            pending_click: None,
760            drag_landed_internal: Rc::new(Cell::new(false)),
761            drag_origin: Rc::new(RefCell::new(None)),
762            drag_button_down: Rc::new(Cell::new(false)),
763        };
764
765        // Drag-out. GTK's `DragSource` never starts here (its gesture is not
766        // recognized on a layer-shell surface), so the drag is detected and
767        // begun by hand. Everything lives on the scrolled window via a legacy
768        // event controller: a press starts a potential drag, a move past the
769        // DND threshold with the button still held issues `gdk_drag_begin`, and
770        // the release ends it. This cannot use the row's click gesture or a
771        // per-row controller, because a press rebuilds the rows — destroying any
772        // row-attached controller before the release arrives (which previously
773        // left the button "stuck down" and started a drag on the next move).
774        {
775            let legacy = gtk::EventControllerLegacy::new();
776            legacy.set_propagation_phase(gtk::PropagationPhase::Capture);
777            let origin = model.drag_origin.clone();
778            let payload = model.drag_payload.clone();
779            let active = model.drag_active.clone();
780            let landed = model.drag_landed_internal.clone();
781            let down = model.drag_button_down.clone();
782            let drag_root = widgets.scrolled.clone();
783            let s = sender.clone();
784            legacy.connect_event(move |_controller, event| {
785                match event.event_type() {
786                    gdk::EventType::ButtonPress => {
787                        let button = event
788                            .downcast_ref::<gdk::ButtonEvent>()
789                            .map(|b| b.button())
790                            .unwrap_or(0);
791                        if button == 1
792                            && let Some((x, y)) = event.position()
793                        {
794                            down.set(true);
795                            *origin.borrow_mut() = Some(DragOrigin { x, y });
796                        }
797                    }
798                    gdk::EventType::ButtonRelease => {
799                        let button = event
800                            .downcast_ref::<gdk::ButtonEvent>()
801                            .map(|b| b.button())
802                            .unwrap_or(0);
803                        if button == 1 {
804                            down.set(false);
805                            if !active.get() {
806                                *origin.borrow_mut() = None;
807                            }
808                        }
809                    }
810                    gdk::EventType::MotionNotify => {
811                        if active.get() || !down.get() {
812                            return glib::Propagation::Proceed;
813                        }
814                        let Some((x, y)) = event.position() else {
815                            return glib::Propagation::Proceed;
816                        };
817                        let start = {
818                            let guard = origin.borrow();
819                            match guard.as_ref() {
820                                Some(o) => (o.x, o.y),
821                                None => return glib::Propagation::Proceed,
822                            }
823                        };
824                        if !past_drag_threshold(drag_root.upcast_ref(), start, (x, y)) {
825                            return glib::Propagation::Proceed;
826                        }
827                        let paths = payload.borrow().clone();
828                        if paths.is_empty() {
829                            return glib::Propagation::Proceed;
830                        }
831                        // One press starts at most one drag.
832                        *origin.borrow_mut() = None;
833                        let finished = {
834                            let active = active.clone();
835                            let landed = landed.clone();
836                            let s = s.clone();
837                            let payload = payload.clone();
838                            move |_drag: gdk::Drag, delete: bool| {
839                                active.set(false);
840                                // The receiver took the data as a MOVE and no
841                                // in-tree drop handler ran: trash the sources.
842                                if delete && !landed.replace(false) {
843                                    let paths = payload.borrow().clone();
844                                    if !paths.is_empty() {
845                                        s.input(TreeMsg::DroppedAsMove(paths));
846                                    }
847                                }
848                            }
849                        };
850                        let cancelled = {
851                            let active = active.clone();
852                            let landed = landed.clone();
853                            move |_drag: gdk::Drag, _reason: gdk::DragCancelReason| {
854                                active.set(false);
855                                landed.set(false);
856                            }
857                        };
858                        if begin_row_drag(
859                            drag_root.upcast_ref(),
860                            &paths,
861                            start,
862                            gdk::DragAction::MOVE | gdk::DragAction::COPY,
863                            finished,
864                            cancelled,
865                        ) {
866                            active.set(true);
867                            s.input(TreeMsg::DragStarted);
868                        }
869                    }
870                    _ => {}
871                }
872                glib::Propagation::Proceed
873            });
874            widgets.scrolled.add_controller(legacy);
875        }
876
877        ComponentParts { model, widgets }
878    }
879
880    fn update_with_view(
881        &mut self,
882        widgets: &mut Self::Widgets,
883        message: Self::Input,
884        sender: ComponentSender<Self>,
885        root: &Self::Root,
886    ) {
887        let _ = root;
888        // Focus is a pure widget-side request: grab it and stop, without
889        // touching the model or rebuilding the rows.
890        if let TreeMsg::Focus = message {
891            // The rows are the focusable widgets (the list/scrolled are not),
892            // so focus the row at the keyboard cursor — falling back to the
893            // first row, then the list.
894            if widgets.list.row_at_index(0).is_none() {
895                // A freshly-opened directory has not loaded yet; take focus once
896                // the first row is built (see the tail of this method).
897                self.focus_pending = true;
898                return;
899            }
900            let index = self.cursor.unwrap_or(0) as i32;
901            if let Some(row) = widgets
902                .list
903                .row_at_index(index)
904                .or_else(|| widgets.list.row_at_index(0))
905            {
906                row.grab_focus();
907            } else if !widgets.list.grab_focus() {
908                widgets.scrolled.grab_focus();
909            }
910            return;
911        }
912        self.handle_message(message, sender.clone());
913        // A launch-time reveal asked for its row to be brought into view. Do it
914        // once the model was updated and before any early-return on the
915        // selection fast path, so it is never skipped.
916        if let Some(index) = self.scroll_to.take() {
917            scroll_row_into_view(widgets, index);
918        }
919        // The model's expanded/loaded set changed; align the lazily-watched
920        // set of directories with it (startup only ever watches the root, so
921        // opening a tree is instant).
922        self.reconcile_watches();
923        if self.selection_dirty {
924            // The selection/cursor moved but the rows themselves are unchanged:
925            // just restyle the affected rows instead of rebuilding every widget.
926            self.selection_dirty = false;
927            if update_selection_classes(self, widgets) {
928                return;
929            }
930        }
931        <Self as Component>::update_view(self, widgets, sender.clone());
932        rebuild(self, widgets, sender);
933        // A focus request that arrived while the directory was still loading:
934        // the first row now exists, so take the keyboard.
935        if self.focus_pending && widgets.list.row_at_index(0).is_some() {
936            self.focus_pending = false;
937            let index = self.cursor.unwrap_or(0) as i32;
938            if let Some(row) = widgets
939                .list
940                .row_at_index(index)
941                .or_else(|| widgets.list.row_at_index(0))
942            {
943                row.grab_focus();
944            }
945        }
946    }
947}
948
949impl Tree {
950    fn handle_message(&mut self, msg: TreeMsg, sender: ComponentSender<Self>) {
951        match msg {
952            TreeMsg::OpenRoot(path) => self.open_root(&path, &sender),
953            TreeMsg::SelectPath(path) => self.reveal_path(path),
954            // Handled in `update_with_view` (it is a pure widget-side grab).
955            TreeMsg::Focus => {}
956            TreeMsg::SetFilter(filter) => {
957                if let Some(tree) = self.tree.as_mut() {
958                    tree.set_filter(&filter);
959                    self.refresh_rows();
960                }
961            }
962            TreeMsg::SetPanel(panel) => self.content_width = thumbnail_content_width(panel),
963            TreeMsg::Shutdown => self.shutdown(),
964            TreeMsg::Noop => {}
965
966            TreeMsg::MoveUp => self.cursor_delta(-1),
967            TreeMsg::MoveDown => self.cursor_delta(1),
968            TreeMsg::ExtendSelection(delta) => self.cursor_extend(delta, false),
969            TreeMsg::MoveExtendingSelection(delta) => self.cursor_extend(delta, true),
970            TreeMsg::CollapseCursor => {
971                if let Some(path) = self.cursor_path()
972                    && self.tree.as_ref().is_some_and(|t| t.is_expanded(&path))
973                {
974                    self.collapse_dir(&path);
975                }
976            }
977            TreeMsg::ExpandCursor => {
978                if let Some(path) = self.cursor_path() {
979                    self.expand_dir(&path, &sender);
980                }
981            }
982            TreeMsg::ActivateCursor => {
983                if let Some(path) = self.cursor_path() {
984                    self.activate(&path, &sender);
985                }
986            }
987            TreeMsg::Rename => {
988                if let Some(path) = self.cursor_path() {
989                    self.renaming = Some(path);
990                    self.renaming_state.set(true);
991                }
992            }
993
994            TreeMsg::Activate(path) => self.activate(&path, &sender),
995            TreeMsg::Toggle(path) => self.toggle_dir(&path, &sender),
996            TreeMsg::Select(path) => {
997                self.select(&path);
998                self.anchor = Some(path);
999                self.selection_dirty = true;
1000            }
1001            TreeMsg::ToggleSelect(path) => {
1002                if let Some(i) = self.rows.iter().position(|r| r.path == path) {
1003                    self.cursor = Some(i);
1004                }
1005                if let Some(i) = self.selected.iter().position(|p| p == &path) {
1006                    self.selected.remove(i);
1007                } else {
1008                    self.selected.push(path.clone());
1009                }
1010                self.anchor = Some(path);
1011                self.selection_dirty = true;
1012            }
1013            TreeMsg::RangeSelect(path) => {
1014                self.range_select(&path);
1015                self.selection_dirty = true;
1016            }
1017            TreeMsg::RowPress { path, is_dir, ctrl, shift } => {
1018                // The press point and button state are tracked by the drag
1019                // controller on the scrolled window (which survives the rebuild
1020                // this message triggers). Here we only snapshot what a drag
1021                // starting from this row should carry.
1022                if ctrl {
1023                    sender.input(TreeMsg::ToggleSelect(path));
1024                    return;
1025                }
1026                if shift {
1027                    sender.input(TreeMsg::RangeSelect(path));
1028                    return;
1029                }
1030                // Snapshot what a drag starting from this row should carry: the
1031                // whole selection when the row is already part of it, otherwise
1032                // just the row. Fixed now (before any collapse below), because
1033                // the drag's `prepare` runs later.
1034                *self.drag_payload.borrow_mut() = drag_paths(&self.selected, &path);
1035
1036                // A plain press on a row already part of a multi-row selection
1037                // defers the collapse to release, so a drag can still carry the
1038                // whole selection. Everything else collapses now.
1039                if self.selected.len() > 1 && self.selected.iter().any(|p| p == &path) {
1040                    self.pending_click = Some(path.clone());
1041                    if is_dir {
1042                        self.toggle_dir(&path, &sender);
1043                    }
1044                    return;
1045                }
1046                self.pending_click = None;
1047                self.press_default(&path, is_dir, &sender);
1048            }
1049            TreeMsg::RowRelease { path } => {
1050                // The press did not become a drag; it can no longer start one.
1051                *self.drag_origin.borrow_mut() = None;
1052                self.drag_button_down.set(false);
1053                // The click finished without a drag: apply the deferred press
1054                // (collapse the multi-selection onto the pressed row).
1055                if self.drag_active.get() {
1056                    return;
1057                }
1058                if self.pending_click.take().as_deref() == Some(path.as_path()) {
1059                    let is_dir = self
1060                        .rows
1061                        .iter()
1062                        .find(|r| r.path == path)
1063                        .map(|r| r.is_dir)
1064                        .unwrap_or(false);
1065                    self.press_default(&path, is_dir, &sender);
1066                }
1067            }
1068            TreeMsg::DragStarted => {
1069                // A drag is in flight: cancel any deferred click collapse.
1070                self.drag_active.set(true);
1071                self.pending_click = None;
1072                *self.drag_origin.borrow_mut() = None;
1073                self.drag_button_down.set(false);
1074            }
1075            TreeMsg::DroppedAsMove(paths) => {
1076                // A drag left the app as a MOVE: the receiver took the data, so
1077                // the sources are trashed. Best-effort and non-interactive
1078                // (the drag already completed); failures are surfaced.
1079                match self.ops.trash(&paths) {
1080                    Ok(()) => {
1081                        for path in &paths {
1082                            self.apply_change(Change::Removed { path: path.clone() });
1083                        }
1084                        let n = paths.len();
1085                        self.status(format!("Moved {n} item(s) out of the panel"), &sender);
1086                    }
1087                    Err(err) => self.status(format!("Could not finish move: {err}"), &sender),
1088                }
1089            }
1090            TreeMsg::Deselect => {
1091                self.selected.clear();
1092                self.cursor = None;
1093                self.anchor = None;
1094                self.typeahead.clear();
1095                self.selection_dirty = true;
1096            }
1097            TreeMsg::Menu(path) => self.open_menu(&path),
1098            TreeMsg::MenuAt { x, y } => {
1099                if let Some(root) = self.tree.as_ref().map(|t| t.root().to_path_buf()) {
1100                    self.open_menu_at(&root, (x, y));
1101                }
1102            }
1103            TreeMsg::RenameAt(path) => {
1104                self.select(&path);
1105                self.renaming = Some(path);
1106                self.renaming_state.set(true);
1107            }
1108            TreeMsg::Trash(path) => self.confirm_trash(vec![path], sender.clone()),
1109            TreeMsg::DeleteSelected => {
1110                let paths = self.selected_for_clipboard();
1111                if !paths.is_empty() {
1112                    self.confirm_trash(paths, sender.clone());
1113                }
1114            }
1115            TreeMsg::PermanentDelete(path) => {
1116                self.confirm_permanent_delete(vec![path], sender.clone());
1117            }
1118            TreeMsg::PermanentDeleteSelected => {
1119                let paths = self.selected_for_clipboard();
1120                if !paths.is_empty() {
1121                    self.confirm_permanent_delete(paths, sender.clone());
1122                }
1123            }
1124            TreeMsg::ConfirmPermanentDelete(paths) => {
1125                if let Some(renaming) = self.renaming.as_ref()
1126                    && paths.contains(renaming)
1127                {
1128                    self.renaming = None;
1129                    self.renaming_state.set(false);
1130                }
1131                match self.ops.delete_permanently(&paths) {
1132                    Ok(()) => {
1133                        for path in paths {
1134                            self.apply_change(Change::Removed { path });
1135                        }
1136                        self.selected.clear();
1137                        self.cursor_snap();
1138                    }
1139                    Err(err) => self.status(err.to_string(), &sender),
1140                }
1141            }
1142            TreeMsg::ConfirmTrash(paths) => {
1143                if let Some(renaming) = self.renaming.as_ref()
1144                    && paths.contains(renaming)
1145                {
1146                    self.renaming = None;
1147                    self.renaming_state.set(false);
1148                }
1149                match self.ops.trash(&paths) {
1150                    Ok(()) => {
1151                        for path in paths {
1152                            self.apply_change(Change::Removed { path });
1153                        }
1154                        self.selected.clear();
1155                        self.cursor_snap();
1156                    }
1157                    // `OpsError::Trash`'s own `Display` already reads "could
1158                    // not move to trash: ..."; do not prefix it again.
1159                    Err(err) => self.status(err.to_string(), &sender),
1160                }
1161            }
1162            TreeMsg::Duplicate(path) => match self.ops.duplicate(&path) {
1163                Ok(new_path) => {
1164                    self.apply_change(Change::Created { path: new_path.clone() });
1165                    self.select(&new_path);
1166                }
1167                Err(err) => self.status(format!("Could not duplicate: {err}"), &sender),
1168            },
1169            TreeMsg::RequestDuplicateCursor => {
1170                if let Some(path) = self.cursor_path() {
1171                    sender.input(TreeMsg::Duplicate(path));
1172                }
1173            }
1174            TreeMsg::CopyPath(path) => {
1175                set_clipboard_text(&path.display().to_string());
1176                self.status("Copied path to clipboard".to_string(), &sender);
1177            }
1178            TreeMsg::CopyRelativePath(path) => {
1179                let relative = self
1180                    .tree
1181                    .as_ref()
1182                    .and_then(|t| path.strip_prefix(t.root()).ok())
1183                    .map(|p| p.display().to_string())
1184                    .unwrap_or_else(|| path.display().to_string());
1185                set_clipboard_text(&relative);
1186                self.status("Copied relative path to clipboard".to_string(), &sender);
1187            }
1188            TreeMsg::AddBookmark(path) => {
1189                let _ = sender.output(TreeOutput::AddBookmark(path));
1190            }
1191            TreeMsg::RunCommand { command, path } => {
1192                let cmd = action_command(&command, &path);
1193                match spawn_command(&cmd) {
1194                    Ok(()) => self.status(format!("Ran {command} on {}", path.display()), &sender),
1195                    Err(err) => self.status(format!("Could not run {command}: {err}"), &sender),
1196                }
1197            }
1198            TreeMsg::OpenSplit(path) => {
1199                let _ = sender.output(TreeOutput::OpenSplit(path));
1200            }
1201            TreeMsg::OpenInOppositePanel(path) => {
1202                let _ = sender.output(TreeOutput::OpenOpposite(path));
1203            }
1204            TreeMsg::OpenWith(path) => open_with_dialog(&self.parent.clone(), &path, &sender),
1205            TreeMsg::OpenWithDefault(path) => match open_in_app(&path) {
1206                Ok(app) => {
1207                    self.status(format!("Opened {} with {app}", path.display()), &sender);
1208                }
1209                Err(err) => self.status(err, &sender),
1210            },
1211            TreeMsg::CreateLink(path) => match self.ops.create_link(&path) {
1212                Ok(new_path) => {
1213                    self.apply_change(Change::Created { path: new_path.clone() });
1214                }
1215                Err(err) => self.status(format!("Could not create link: {err}"), &sender),
1216            },
1217            TreeMsg::Properties(path) => {
1218                crate::ui::props::show_properties_dialog(&self.parent.clone(), &path, &sender);
1219            }
1220            TreeMsg::PropertiesSelected(paths) => {
1221                crate::ui::props::show_multi_properties_dialog(&self.parent.clone(), &paths);
1222            }
1223            TreeMsg::CopyPaths(paths) => {
1224                let text = paths
1225                    .iter()
1226                    .map(|p| p.display().to_string())
1227                    .collect::<Vec<_>>()
1228                    .join("\n");
1229                set_clipboard_text(&text);
1230                self.status(format!("Copied {} paths to clipboard", paths.len()), &sender);
1231            }
1232            TreeMsg::CopyRelativePaths(paths) => {
1233                let root = self.tree.as_ref().map(|t| t.root().to_path_buf());
1234                let text = paths
1235                    .iter()
1236                    .map(|p| {
1237                        root.as_deref()
1238                            .and_then(|root| p.strip_prefix(root).ok())
1239                            .map(|rel| rel.display().to_string())
1240                            .unwrap_or_else(|| p.display().to_string())
1241                    })
1242                    .collect::<Vec<_>>()
1243                    .join("\n");
1244                set_clipboard_text(&text);
1245                self.status(
1246                    format!("Copied {} relative paths to clipboard", paths.len()),
1247                    &sender,
1248                );
1249            }
1250            TreeMsg::RunCommandEach { command, paths } => {
1251                let mut failures = 0usize;
1252                for path in &paths {
1253                    let cmd = action_command(&command, path);
1254                    if spawn_command(&cmd).is_err() {
1255                        failures += 1;
1256                    }
1257                }
1258                let total = paths.len();
1259                if failures == 0 {
1260                    self.status(format!("Ran {command} on {total} item(s)"), &sender);
1261                } else {
1262                    self.status(
1263                        format!("Ran {command} on {total} item(s); {failures} failed"),
1264                        &sender,
1265                    );
1266                }
1267            }
1268            TreeMsg::RenameTo { path, name } => match self.ops.rename(&path, &name) {
1269                Ok(new_path) => {
1270                    self.apply_change(Change::Renamed {
1271                        from: path,
1272                        to: new_path.clone(),
1273                    });
1274                    self.select(&new_path);
1275                }
1276                Err(err) => self.status(format!("Could not rename: {err}"), &sender),
1277            },
1278            TreeMsg::ToggleThumbnail(path) => {
1279                if self.thumbnails.contains(&path) {
1280                    // Toggled off: drop the preview(s) and their textures.
1281                    self.clear_thumbnails_under(&path);
1282                } else {
1283                    self.thumbnails.insert(path.clone());
1284                    // Turning a directory on expands it so its images show.
1285                    if path.is_dir() {
1286                        self.expand_dir(&path, &sender);
1287                    }
1288                }
1289            }
1290            TreeMsg::ToggleThumbnailPlay(path) => self.toggle_thumbnail_play(&path),
1291            TreeMsg::RunShortcut { target } => self.run_shortcut(target, &sender),
1292
1293            TreeMsg::SelectAllRows => {
1294                if !self.rows.is_empty() {
1295                    self.selected = self.rows.iter().map(|r| r.path.clone()).collect();
1296                    self.cursor = Some(0);
1297                    self.anchor = self.rows.first().map(|r| r.path.clone());
1298                    self.selection_dirty = true;
1299                }
1300            }
1301            TreeMsg::TypeAhead(ch) => {
1302                let now = std::time::Instant::now();
1303                if let Some(prefix) = self.typeahead.push(ch, now)
1304                    && let Some(index) = typeahead_target(&self.rows, &prefix, self.cursor)
1305                {
1306                    let path = self.rows[index].path.clone();
1307                    self.cursor = Some(index);
1308                    self.set_single_selection(path);
1309                    self.selection_dirty = true;
1310                }
1311            }
1312
1313            TreeMsg::RenameCommit => {
1314                let text = self
1315                    .rename_entry
1316                    .as_ref()
1317                    .map(|e| e.text().to_string())
1318                    .unwrap_or_default();
1319                let Some(old) = self.renaming.take() else {
1320                    return;
1321                };
1322                self.renaming_state.set(false);
1323                self.rename_entry = None;
1324                let trimmed = text.trim().to_string();
1325                let unchanged = old
1326                    .file_name()
1327                    .map(|n| n.to_string_lossy() == trimmed.as_str())
1328                    .unwrap_or(false);
1329                if trimmed.is_empty() || unchanged {
1330                    return;
1331                }
1332                match self.ops.rename(&old, &trimmed) {
1333                    Ok(new_path) => {
1334                        self.apply_change(Change::Renamed {
1335                            from: old,
1336                            to: new_path.clone(),
1337                        });
1338                        self.select(&new_path);
1339                    }
1340                    Err(err) => self.status(format!("Could not rename: {err}"), &sender),
1341                }
1342            }
1343            TreeMsg::RenameCancel => {
1344                self.renaming = None;
1345                self.renaming_state.set(false);
1346                self.rename_entry = None;
1347            }
1348
1349            TreeMsg::Copy => {
1350                let paths = self.selected_for_clipboard();
1351                if !paths.is_empty() {
1352                    self.clipboard = Some(Clipboard { op: ClipboardOp::Copy, paths: paths.clone() });
1353                    self.status(format!("Copying {} item(s)", paths.len()), &sender);
1354                }
1355            }
1356            TreeMsg::Cut => {
1357                let paths = self.selected_for_clipboard();
1358                if !paths.is_empty() {
1359                    self.clipboard = Some(Clipboard { op: ClipboardOp::Cut, paths: paths.clone() });
1360                    self.status(format!("Cutting {} item(s)", paths.len()), &sender);
1361                }
1362            }
1363            TreeMsg::Paste => self.paste(&sender),
1364            TreeMsg::PastePaths(paths) => self.paste_paths(paths, &sender),
1365
1366            TreeMsg::NewFile => self.create_entry(false, &sender),
1367            TreeMsg::NewFolder => self.create_entry(true, &sender),
1368
1369            TreeMsg::ToggleHidden => self.toggle_hidden(&sender),
1370            TreeMsg::SetSortKey(key) => self.set_sort_key(key, &sender),
1371            TreeMsg::ToggleSortDirection => self.toggle_sort_direction(&sender),
1372
1373            TreeMsg::FsChange(change) => {
1374                // Consolidate bursts into a single re-render.
1375                self.pending.push(change);
1376                if self.flush_source.is_none() {
1377                    let s = sender.clone();
1378                    let id = glib::timeout_add_local_once(
1379                        Duration::from_millis(100),
1380                        move || {
1381                            s.input(TreeMsg::FlushChanges);
1382                        }
1383                    );
1384                    self.flush_source = Some(id);
1385                }
1386            }
1387            TreeMsg::FlushChanges => {
1388                self.flush_source = None;
1389                let changes = std::mem::take(&mut self.pending);
1390                for change in changes {
1391                    self.apply_change(change);
1392                }
1393                self.cursor_snap();
1394            }
1395            TreeMsg::RequestOpenFolder => {
1396                self.status("Pick a folder to browse...".to_string(), &sender);
1397                let _ = sender.output(TreeOutput::OpenFolderRequested);
1398            }
1399
1400            TreeMsg::DropInto { target, sources, copy } => {
1401                self.request_drop_into(target, sources, copy, &sender);
1402            }
1403            TreeMsg::DropIntoRoot { sources, copy } => {
1404                if let Some(root) = self.tree.as_ref().map(|t| t.root().to_path_buf()) {
1405                    self.request_drop_into(root, sources, copy, &sender);
1406                }
1407            }
1408            TreeMsg::DropIntoConfirmed { target, sources } => {
1409                self.drop_into(&target, &sources, false, &sender);
1410            }
1411        }
1412    }
1413}
1414
1415// ---------------------------------------------------------------------------
1416// key handling
1417// ---------------------------------------------------------------------------
1418
1419/// The modifier bits that matter for accelerator matching (everything except
1420/// the lock/numeric-pad/altgr noise GTK sprinkles into key state).
1421fn accel_mods(m: gdk::ModifierType) -> gdk::ModifierType {
1422    m & (gdk::ModifierType::CONTROL_MASK
1423        | gdk::ModifierType::SHIFT_MASK
1424        | gdk::ModifierType::ALT_MASK
1425        | gdk::ModifierType::SUPER_MASK
1426        | gdk::ModifierType::HYPER_MASK
1427        | gdk::ModifierType::META_MASK)
1428}
1429
1430/// A configured accelerator, parsed and ready to match against key events.
1431#[derive(Clone)]
1432struct KeyBinding {
1433    key: gdk::Key,
1434    mods: gdk::ModifierType,
1435    target: ShortcutTarget,
1436}
1437
1438/// Parse every configured shortcut into an easily matched form. Unparseable
1439/// accelerators are skipped (the menu still shows them, but the key does
1440/// nothing).
1441fn compile_shortcuts(menu: &ContextMenu) -> Rc<Vec<KeyBinding>> {
1442    let mut out = Vec::new();
1443    for (accel, target) in menu.shortcuts() {
1444        if let Some((key, mods)) = parse_accelerator(&accel) {
1445            out.push(KeyBinding { key, mods, target });
1446        }
1447    }
1448    Rc::new(out)
1449}
1450
1451/// Parse a shortcut into a GTK accelerator.
1452///
1453/// GTK's own parser only understands its native `<Control>x` / `<Shift>Delete`
1454/// syntax and silently rejects the friendlier `Ctrl+x` / `Shift+Delete` form,
1455/// so normalize the latter first. Both spellings are accepted.
1456fn parse_accelerator(accel: &str) -> Option<(gdk::Key, gdk::ModifierType)> {
1457    gtk::accelerator_parse(normalize_accelerator(accel))
1458}
1459
1460/// Rewrite a `+`-joined accelerator (`Ctrl+Shift+m`) as GTK's `<Control><Shift>m`
1461/// form. Strings without a `+` (already native, or a bare key) pass through.
1462fn normalize_accelerator(accel: &str) -> String {
1463    if !accel.contains('+') {
1464        return accel.to_owned();
1465    }
1466    let mut mods = String::new();
1467    let mut key = String::new();
1468    for token in accel.split('+') {
1469        let token = token.trim();
1470        if token.is_empty() {
1471            continue;
1472        }
1473        match token.to_ascii_lowercase().as_str() {
1474            "ctrl" | "control" | "primary" => mods.push_str("<Control>"),
1475            "shift" => mods.push_str("<Shift>"),
1476            "alt" => mods.push_str("<Alt>"),
1477            "super" | "win" | "mod4" => mods.push_str("<Super>"),
1478            "meta" => mods.push_str("<Meta>"),
1479            "hyper" => mods.push_str("<Hyper>"),
1480            _ => key = token.to_owned(),
1481        }
1482    }
1483    format!("{mods}{key}")
1484}
1485
1486fn handle_key(
1487    key: gdk::Key,
1488    state: gdk::ModifierType,
1489    renaming: bool,
1490    keys: &[KeyBinding],
1491    sender: &ComponentSender<Tree>,
1492) -> glib::Propagation {
1493    use gdk::Key;
1494
1495    // While a rename entry is focused, let it win all keys.
1496    if renaming {
1497        return glib::Propagation::Proceed;
1498    }
1499
1500    let ctrl = state.contains(gdk::ModifierType::CONTROL_MASK);
1501    let shift = state.contains(gdk::ModifierType::SHIFT_MASK);
1502
1503    // Structural navigation is not configurable; these always win. The arrow
1504    // keys carry the shift/ctrl state so shift extends the selection.
1505    let structural = match key {
1506        Key::Up if ctrl && shift => Some(TreeMsg::MoveExtendingSelection(-1)),
1507        Key::Down if ctrl && shift => Some(TreeMsg::MoveExtendingSelection(1)),
1508        Key::Up if shift => Some(TreeMsg::ExtendSelection(-1)),
1509        Key::Down if shift => Some(TreeMsg::ExtendSelection(1)),
1510        Key::Up => Some(TreeMsg::MoveUp),
1511        Key::Down => Some(TreeMsg::MoveDown),
1512        Key::Left => Some(TreeMsg::CollapseCursor),
1513        Key::Right => Some(TreeMsg::ExpandCursor),
1514        Key::Return | Key::KP_Enter | Key::space => Some(TreeMsg::ActivateCursor),
1515        Key::Escape => Some(TreeMsg::RenameCancel),
1516        Key::o | Key::O if ctrl && !shift => Some(TreeMsg::RequestOpenFolder),
1517        Key::a | Key::A if ctrl && !shift => Some(TreeMsg::SelectAllRows),
1518        _ => None,
1519    };
1520
1521    let msg = match structural {
1522        Some(msg) => msg,
1523        None => {
1524            // Everything else comes from the configured accelerators
1525            // (cut/copy/paste/rename/delete/new tab/...), fired against the row
1526            // under the keyboard cursor.
1527            let mut hit = None;
1528            for binding in keys.iter() {
1529                if key.to_lower() == binding.key.to_lower()
1530                    && accel_mods(state) == accel_mods(binding.mods)
1531                {
1532                    hit = Some(TreeMsg::RunShortcut {
1533                        target: binding.target.clone(),
1534                    });
1535                    break;
1536                }
1537            }
1538            match hit {
1539                Some(msg) => msg,
1540                // No binding matched. A bare printable key (no ctrl/alt/super)
1541                // starts or extends a type-ahead search.
1542                None if !ctrl
1543                    && !state.contains(gdk::ModifierType::ALT_MASK)
1544                    && !state.contains(gdk::ModifierType::SUPER_MASK) =>
1545                {
1546                    if let Some(ch) = key.to_unicode()
1547                        && ch.is_ascii_graphic()
1548                    {
1549                        TreeMsg::TypeAhead(ch)
1550                    } else {
1551                        return glib::Propagation::Proceed;
1552                    }
1553                }
1554                None => return glib::Propagation::Proceed,
1555            }
1556        }
1557    };
1558
1559    sender.input(msg);
1560    glib::Propagation::Stop
1561}
1562
1563// ---------------------------------------------------------------------------
1564// rendering
1565// ---------------------------------------------------------------------------
1566
1567/// The rows are unchanged; only the selection/cursor moved. Restyle the
1568/// existing `ListBoxRow`s in place (O(rows) class toggles, no widget
1569/// construction). Returns `false` when the row set no longer matches the model
1570/// (so the caller falls back to a full rebuild).
1571fn update_selection_classes(
1572    tree: &mut Tree,
1573    widgets: &mut <Tree as Component>::Widgets,
1574) -> bool {
1575    // If the row count drifted (a background change), rebuild instead.
1576    let mut existing = 0;
1577    let mut child = widgets.list.first_child();
1578    while let Some(current) = child {
1579        if current.is::<gtk::ListBoxRow>() {
1580            existing += 1;
1581        }
1582        child = current.next_sibling();
1583    }
1584    if existing != tree.rows.len() {
1585        return false;
1586    }
1587    for (index, row) in tree.rows.iter().enumerate() {
1588        let Some(list_row) = widgets.list.row_at_index(index as i32) else {
1589            return false;
1590        };
1591        let selected = tree.cursor == Some(index) || tree.selected.contains(&row.path);
1592        if selected {
1593            list_row.add_css_class("tree-row-selected");
1594        } else {
1595            list_row.remove_css_class("tree-row-selected");
1596        }
1597    }
1598    true
1599}
1600
1601/// Scroll `widgets.scrolled` so row `index` is visible. Uses the row's
1602/// allocation relative to the list and nudges the vertical adjustment only when
1603/// the row lies outside the current viewport (so a visible reveal does not
1604/// jerk the scroll position).
1605fn scroll_row_into_view(widgets: &<Tree as Component>::Widgets, index: usize) {
1606    let Some(row) = widgets.list.row_at_index(index as i32) else {
1607        return;
1608    };
1609    // Suppose the row is allocated at `y` within the list; convert to the
1610    // viewport and scroll only if it falls outside `[0, page_size)`.
1611    let adj = widgets.scrolled.vadjustment();
1612    let page = adj.page_size();
1613    if page <= 0.0 {
1614        return;
1615    }
1616    // Bounds of the row in the list's coordinate space, which is the space the
1617    // scroll adjustment measures against.
1618    let Some(bounds) = row.compute_bounds(&widgets.list) else {
1619        return;
1620    };
1621    let y = bounds.y() as f64;
1622    let height = bounds.height() as f64;
1623    let top = adj.value();
1624    if y < top {
1625        adj.set_value(y);
1626    } else if y + height > top + page {
1627        adj.set_value((y + height - page).max(0.0));
1628    }
1629}
1630
1631fn rebuild(tree: &mut Tree, widgets: &mut <Tree as Component>::Widgets, sender: ComponentSender<Tree>) {
1632    let list = &widgets.list;
1633
1634    // The context-menu popover is parented to one of the rows below (see
1635    // `build_menu`). `ListBox::remove_all` destroys every row unconditionally;
1636    // if the popover were still attached to one, GTK would finalize a widget
1637    // that still has a child, which corrupts widget state and reliably
1638    // crashes the process a little later (surfaces as
1639    // `gtk_accessible_get_accessible_role: assertion 'GTK_IS_ACCESSIBLE
1640    // (self)' failed` and friends). Every rebuild — not just the ones
1641    // triggered by opening a new menu or root — must detach it first.
1642    if let Some(previous) = tree.popover.take() {
1643        previous.unparent();
1644    }
1645
1646    // Detach the list while it is rebuilt: with several thousand rows, each
1647    // `append` into a list that is already inside a `ScrolledWindow` forces a
1648    // re-measure, making the rebuild O(n²). Reattaching once at the end keeps
1649    // insertion linear.
1650    widgets.scrolled.set_child(None::<&gtk::Widget>);
1651    list.remove_all();
1652
1653    if tree.tree.is_none() {
1654        widgets.scrolled.set_child(Some(list));
1655        return;
1656    }
1657
1658    // Swapping the rows out lets us mutate other `Tree` fields in the loop
1659    // without a borrow conflict.
1660    let rows = std::mem::take(&mut tree.rows);
1661    let mut menu_row: Option<gtk::ListBoxRow> = None;
1662    tree.rename_entry = None;
1663    // The previous render's GIF pictures are about to be destroyed; drop them
1664    // so the ticker can't paint into freed widgets (they're re-added below).
1665    tree.gif_widgets.borrow_mut().clear();
1666    // Likewise the previous render's audio players: `audio_tick` must never
1667    // touch controls that this rebuild is about to free.
1668    tree.audio_widgets.borrow_mut().clear();
1669
1670    for (index, row) in rows.iter().enumerate() {
1671        let list_row = gtk::ListBoxRow::new();
1672        list_row.set_focusable(true);
1673        list_row.set_can_focus(true);
1674        list_row.add_css_class("tree-row");
1675
1676        let row_indent = row.depth as i32 * 16 + 4;
1677        // A vertical container so an inline thumbnail can sit below the row.
1678        let container = gtk::Box::new(gtk::Orientation::Vertical, 0);
1679        container.set_hexpand(true);
1680        list_row.set_child(Some(&container));
1681
1682        let hbox = gtk::Box::new(gtk::Orientation::Horizontal, 6);
1683        hbox.set_margin_start(row_indent);
1684        hbox.set_hexpand(true);
1685        container.append(&hbox);
1686
1687        let icon = gtk::Image::from_icon_name(icon_name(row));
1688        icon.set_pixel_size(tree.config.icon_size as i32);
1689        icon.set_valign(gtk::Align::Center);
1690        hbox.append(&icon);
1691
1692        if tree.renaming.as_ref() == Some(&row.path) {
1693            // The entry is recreated on every rebuild (rows are rebuilt from
1694            // scratch) and the live instance is handed back to the model so a
1695            // later commit can read the text.
1696            let entry = gtk::Entry::new();
1697            entry.set_text(&row.name);
1698            entry.set_hexpand(true);
1699            entry.add_css_class("tree-rename-entry");
1700            {
1701                let s = sender.clone();
1702                entry.connect_activate(move |_| s.input(TreeMsg::RenameCommit));
1703            }
1704            let esc_controller = gtk::EventControllerKey::new();
1705            let s = sender.clone();
1706            esc_controller.connect_key_pressed(move |_, key, _code, _state| {
1707                if key == gdk::Key::Escape {
1708                    s.input(TreeMsg::RenameCancel);
1709                    glib::Propagation::Stop
1710                } else {
1711                    glib::Propagation::Proceed
1712                }
1713            });
1714            entry.add_controller(esc_controller);
1715            hbox.append(&entry);
1716            tree.rename_entry = Some(entry.clone());
1717            entry.grab_focus();
1718        } else {
1719            let label = gtk::Label::new(Some(&row.name));
1720            label.set_xalign(0.0);
1721            label.set_hexpand(true);
1722            label.set_ellipsize(pango::EllipsizeMode::Middle);
1723            label.set_tooltip_text(Some(&row.path.to_string_lossy()));
1724            hbox.append(&label);
1725        }
1726
1727        if tree.cursor == Some(index) || tree.selected.contains(&row.path) {
1728            list_row.add_css_class("tree-row-selected");
1729        }
1730
1731        if row.matches {
1732            list_row.add_css_class("tree-row-match");
1733        }
1734
1735        if tree.menu_target.as_ref() == Some(&row.path) {
1736            menu_row = Some(list_row.clone());
1737        }
1738
1739        // Inline thumbnail preview (a previewable file, or every one under a
1740        // directory the user turned on). Sized to the column, never enlarged.
1741        if tree.wants_thumbnail(row) {
1742            tree.append_thumbnail(&row.path, row_indent, &container, &sender);
1743        }
1744
1745        let path = row.path.clone();
1746        let release_path = row.path.clone();
1747        let is_dir = row.is_dir;
1748        let click = gtk::GestureClick::new();
1749        // `GtkGestureSingle::button` defaults to 1 (primary only); without
1750        // this, right-clicks never reach `connect_pressed` at all and the
1751        // context menu is unreachable.
1752        click.set_button(0);
1753        let s = sender.clone();
1754        let focus_row = list_row.clone();
1755        click.connect_pressed(move |gesture, _n_press, x, y| {
1756            // Clicks on an inline player's controls belong to that player.
1757            // Handling them here would select the row and rebuild it,
1758            // destroying the control before its click completes — so skip any
1759            // click that lands inside a video or the audio player.
1760            if focus_row
1761                .pick(x, y, gtk::PickFlags::DEFAULT)
1762                .is_some_and(|widget| in_inline_media(&widget))
1763            {
1764                return;
1765            }
1766            // Nothing else claims keyboard focus for us: without this, a
1767            // click on a row that follows focus being anywhere else (the
1768            // filter entry, another app entirely) leaves every keyboard
1769            // shortcut firing into whatever was focused before, not the tree.
1770            focus_row.grab_focus();
1771            let button = gesture.current_button();
1772            let state = gesture.current_event_state();
1773            if button == 3 {
1774                // Super+right is the panel-resize drag: release the sequence so
1775                // the window's drag gesture can claim it instead of opening the
1776                // row menu.
1777                if state.contains(gdk::ModifierType::SUPER_MASK) {
1778                    gesture.set_state(gtk::EventSequenceState::Denied);
1779                    return;
1780                }
1781                s.input(TreeMsg::Menu(path.clone()));
1782                return;
1783            }
1784            if button != 1 {
1785                return;
1786            }
1787            let ctrl = state.contains(gdk::ModifierType::CONTROL_MASK);
1788            let shift = state.contains(gdk::ModifierType::SHIFT_MASK);
1789            // Directories toggle on the first press, so the action must be
1790            // immediate; a following second press is swallowed in the handler.
1791            s.input(TreeMsg::RowPress { path: path.clone(), is_dir, ctrl, shift });
1792        });
1793        {
1794            // A release with no drag finishes a press whose selection collapse
1795            // was deferred (see `RowPress`), so a multi-selection click still
1796            // collapses once we know no drag is in flight.
1797            let s = sender.clone();
1798            click.connect_released(move |gesture, _n_press, _x, _y| {
1799                if gesture.current_button() == 1 {
1800                    s.input(TreeMsg::RowRelease { path: release_path.clone() });
1801                }
1802            });
1803        }
1804        list_row.add_controller(click);
1805
1806        // Drag-out is driven by a single motion controller on the scrolled
1807        // window (installed in `init`), not by per-row controllers: a press
1808        // rebuilds the rows, which would destroy any controller attached to the
1809        // pressed row before the drag could start. See `begin_row_drag`.
1810
1811        // Drop target: only directories accept drops (files within the tree
1812        // are moved/copied into whichever directory row they land on).
1813        // Drop target: only directories accept drops (files within the tree
1814        // are moved/copied into whichever directory row they land on).
1815        if row.is_dir {
1816            let drop = gtk::DropTarget::new(gdk::FileList::static_type(), gdk::DragAction::MOVE | gdk::DragAction::COPY);
1817            let s = sender.clone();
1818            let target_dir = row.path.clone();
1819            let active = tree.drag_active.clone();
1820            let landed = tree.drag_landed_internal.clone();
1821            drop.connect_drop(move |target, value, _x, _y| {
1822                let Ok(list) = value.get::<gdk::FileList>() else {
1823                    return false;
1824                };
1825                let sources: Vec<PathBuf> = list.files().iter().filter_map(gio::File::path).collect();
1826                if sources.is_empty() {
1827                    return false;
1828                }
1829                let copy = target.current_event_state().contains(gdk::ModifierType::CONTROL_MASK);
1830                // An in-tree drag landing here is handled by `drop_into`; mark
1831                // it so `drag-end` does not also act on the source.
1832                if active.get() {
1833                    landed.set(true);
1834                }
1835                s.input(TreeMsg::DropInto { target: target_dir.clone(), sources, copy });
1836                true
1837            });
1838            list_row.add_controller(drop);
1839        }
1840
1841        list.append(&list_row);
1842    }
1843
1844    tree.rows = rows;
1845    widgets.scrolled.set_child(Some(list));
1846
1847    // The context menu must anchor to a row that is actually attached, so it
1848    // is built after the loop instead of inside the menu handler. The open
1849    // directory has no row of its own, so that menu anchors at the click point.
1850    if let Some(row) = menu_row {
1851        let target = tree.menu_target.take().expect("menu_row set implies a target");
1852        let selection = tree.selected.clone();
1853        tree.popover = Some(build_menu(&row, &target, &tree.menu, &selection, tree.side, &sender));
1854        tree.menu_point = None;
1855    } else if let (Some(target), Some(at)) = (tree.menu_target.take(), tree.menu_point.take()) {
1856        let selection = tree.selected.clone();
1857        tree.popover = Some(build_menu_at(&widgets.scrolled, at, &target, &tree.menu, &selection, tree.side, &sender));
1858    }
1859    tree.menu_target = None;
1860    tree.menu_point = None;
1861}
1862
1863/// The builtin an action carries, if it is one (plain string or table form).
1864fn action_builtin(action: &ContextAction) -> Option<BuiltinAction> {
1865    match action {
1866        ContextAction::Builtin(b) => Some(*b),
1867        ContextAction::Entry(e) => Some(e.action),
1868        ContextAction::Command(_) | ContextAction::Submenu(_) => None,
1869    }
1870}
1871
1872/// The label shown for `action` on a row. A few builtins compute a dynamic
1873/// label from the row's path and the side of the screen the tree lives on:
1874///   * "Open With {default app}" — the default application's display name;
1875///   * "In {right|left} panel" — the opposite side of the dock.
1876pub(crate) fn menu_label(action: &ContextAction, path: &Path, side: PanelSide) -> String {
1877    let dynamic = match action_builtin(action) {
1878        Some(BuiltinAction::OpenWithDefault) => default_app_label(path),
1879        Some(BuiltinAction::InOppositePanel) => Some(format!("In {} panel", side.opposite().name())),
1880        // Audio has no picture to show, so the same toggle reads as "Show Player".
1881        Some(BuiltinAction::ViewThumbnail) if media_kind(path) == Some(MediaKind::Audio) => {
1882            Some("Show Player".to_owned())
1883        }
1884        _ => None,
1885    };
1886    dynamic.unwrap_or_else(|| action.label())
1887}
1888
1889/// The human-readable form of a configured accelerator ("Ctrl+X", "F2", ...).
1890/// Shared with the hamburger menu so both render shortcut hints identically.
1891pub(crate) fn accel_display(accel: &str) -> Option<String> {
1892    let (key, mods) = parse_accelerator(accel)?;
1893    Some(gtk::accelerator_get_label(key, mods).to_string())
1894}
1895
1896/// Lay out the configured [`ContextMenu`] rules for `path` as a vertical box.
1897/// `selection` is the tree's current selection; when it holds more than one
1898/// path the menu is built for a multi-selection (the `multi` rule wins and
1899/// selection-wide actions are offered).
1900fn build_menu_box(
1901    path: &Path,
1902    menu: &ContextMenu,
1903    selection: &[PathBuf],
1904    side: PanelSide,
1905    sender: &ComponentSender<Tree>,
1906) -> gtk::Box {
1907    let menu_box = gtk::Box::new(gtk::Orientation::Vertical, 0);
1908    menu_box.add_css_class("tree-menu");
1909    let path = path.to_path_buf();
1910    let actions = menu.actions_for_selection(&path, selection.len());
1911    append_menu_items(&menu_box, &actions, &path, selection, side, sender);
1912    menu_box
1913}
1914
1915/// Append `actions` (and, recursively, any submenus) to `menu_box`. Shared by
1916/// the top-level context menu and its nested submenus.
1917fn append_menu_items(
1918    menu_box: &gtk::Box,
1919    actions: &[ContextAction],
1920    path: &Path,
1921    selection: &[PathBuf],
1922    side: PanelSide,
1923    sender: &ComponentSender<Tree>,
1924) {
1925    let multi = selection.len() > 1;
1926    for action in actions {
1927        // Hidden items are skipped entirely — their shortcut stays live, but
1928        // they never appear in the menu (nor do hidden separators/submenus).
1929        if action.is_hidden() {
1930            continue;
1931        }
1932
1933        // Separators render as a divider; they never dispatch.
1934        if action_builtin(action) == Some(BuiltinAction::Separator) {
1935            let separator = gtk::Separator::new(gtk::Orientation::Horizontal);
1936            separator.add_css_class("tree-menu-separator");
1937            menu_box.append(&separator);
1938            continue;
1939        }
1940
1941        // Drop actions that can't describe this row (a rule may list them; the
1942        // rows they apply to just never match). In a multi-selection, actions
1943        // that only make sense for one row are hidden too.
1944        if let Some(builtin) = action_builtin(action) {
1945            if builtin.is_directory_only() && !path.is_dir() {
1946                continue;
1947            }
1948            if multi && builtin.is_single_row_only() {
1949                continue;
1950            }
1951            if matches!(builtin, BuiltinAction::OpenWithDefault) && path.is_dir() {
1952                continue;
1953            }
1954            // Thumbnails only make sense for directories and image files.
1955            if builtin == BuiltinAction::ViewThumbnail
1956                && !path.is_dir()
1957                && !is_thumbnailable(path)
1958            {
1959                continue;
1960            }
1961        }
1962
1963        let row_box = gtk::Box::new(gtk::Orientation::Horizontal, 12);
1964        row_box.add_css_class("tree-menu-item");
1965
1966        // A submenu row opens a nested popover instead of dispatching.
1967        if let ContextAction::Submenu(sub) = action {
1968            let button = submenu_row_button(&sub.label);
1969            let popover = build_submenu_popover(&sub.items, path, selection, side, sender);
1970            let child = popover.clone();
1971            let anchor = button.clone();
1972            button.connect_clicked(move |_| {
1973                // Re-anchor on each open; `closed` unparents so GTK never tears
1974                // down a widget that still owns a parented popover.
1975                child.set_parent(&anchor);
1976                child.popup();
1977            });
1978            let child = popover.clone();
1979            popover.connect_closed(move |_| child.unparent());
1980            row_box.append(&button);
1981            row_box.append(&submenu_arrow());
1982            menu_box.append(&row_box);
1983            continue;
1984        }
1985
1986        let label = menu_label(action, path, side);
1987        let msg = match action {
1988            ContextAction::Builtin(b) => action_message(*b, path, selection),
1989            ContextAction::Entry(e) => action_message(e.action, path, selection),
1990            ContextAction::Command(cmd) => {
1991                if multi {
1992                    TreeMsg::RunCommandEach {
1993                        command: cmd.command.clone(),
1994                        paths: selection.to_vec(),
1995                    }
1996                } else {
1997                    TreeMsg::RunCommand {
1998                        command: cmd.command.clone(),
1999                        path: path.to_path_buf(),
2000                    }
2001                }
2002            }
2003            ContextAction::Submenu(_) => unreachable!("handled above"),
2004        };
2005
2006        // A button with an explicit label so the text can be left-aligned
2007        // (the shortcut hint, if any, stays pinned to the right).
2008        let button = gtk::Button::new();
2009        button.set_halign(gtk::Align::Fill);
2010        button.set_hexpand(true);
2011        let label_widget = gtk::Label::new(Some(&label));
2012        label_widget.set_xalign(0.0);
2013        label_widget.set_hexpand(true);
2014        button.set_child(Some(&label_widget));
2015        let s = sender.clone();
2016        button.connect_clicked(move |_| s.input(msg.clone()));
2017        row_box.append(&button);
2018
2019        if let Some(shortcut) = action.shortcut()
2020            && let Some(display) = accel_display(&shortcut)
2021        {
2022            let hint = gtk::Label::new(Some(&display));
2023            hint.add_css_class("tree-menu-shortcut");
2024            hint.set_halign(gtk::Align::End);
2025            hint.set_valign(gtk::Align::Center);
2026            row_box.append(&hint);
2027        }
2028
2029        menu_box.append(&row_box);
2030    }
2031}
2032
2033/// A full-width, left-aligned button for a submenu's label row.
2034fn submenu_row_button(label: &str) -> gtk::Button {
2035    let button = gtk::Button::new();
2036    button.set_halign(gtk::Align::Fill);
2037    button.set_hexpand(true);
2038    let text = gtk::Label::new(Some(label));
2039    text.set_xalign(0.0);
2040    text.set_hexpand(true);
2041    button.set_child(Some(&text));
2042    button
2043}
2044
2045/// The trailing "▸" glyph shown on a submenu row.
2046fn submenu_arrow() -> gtk::Label {
2047    let arrow = gtk::Label::new(Some("\u{25b8}"));
2048    arrow.add_css_class("tree-menu-submenu-arrow");
2049    arrow.set_halign(gtk::Align::End);
2050    arrow.set_valign(gtk::Align::Center);
2051    arrow
2052}
2053
2054/// Build a nested popover for a submenu, parented to its row's button and
2055/// recursively laid out from `items`.
2056fn build_submenu_popover(
2057    items: &[ContextAction],
2058    path: &Path,
2059    selection: &[PathBuf],
2060    side: PanelSide,
2061    sender: &ComponentSender<Tree>,
2062) -> gtk::Popover {
2063    let menu_box = gtk::Box::new(gtk::Orientation::Vertical, 0);
2064    menu_box.add_css_class("tree-menu");
2065    append_menu_items(&menu_box, items, path, selection, side, sender);
2066
2067    let popover = gtk::Popover::new();
2068    popover.add_css_class("tree-menu-popover");
2069    popover.set_has_arrow(false);
2070    popover.set_position(gtk::PositionType::Right);
2071    popover.set_child(Some(&menu_box));
2072    popover
2073}
2074
2075/// Show `menu_box` in a popover parented to `anchor`, optionally pointing at
2076/// `point` (in `anchor`'s coordinate space) rather than the anchor's edge.
2077fn show_menu(
2078    anchor: &impl IsA<gtk::Widget>,
2079    menu_box: &gtk::Box,
2080    point: Option<(f64, f64)>,
2081) -> gtk::Popover {
2082    let popover = gtk::Popover::new();
2083    popover.add_css_class("tree-menu-popover");
2084    popover.set_child(Some(menu_box));
2085    popover.set_autohide(true);
2086    popover.set_parent(anchor);
2087    if let Some((x, y)) = point {
2088        popover.set_pointing_to(Some(&gdk::Rectangle::new(x as i32, y as i32, 1, 1)));
2089    }
2090    popover.popup();
2091    popover
2092}
2093
2094/// Build a context-menu popover anchored to `row` for `path`.
2095fn build_menu(
2096    row: &gtk::ListBoxRow,
2097    path: &Path,
2098    menu: &ContextMenu,
2099    selection: &[PathBuf],
2100    side: PanelSide,
2101    sender: &ComponentSender<Tree>,
2102) -> gtk::Popover {
2103    let menu_box = build_menu_box(path, menu, selection, side, sender);
2104    show_menu(row, &menu_box, None)
2105}
2106
2107/// Build a context-menu popover for `path` anchored at the click point `at`
2108/// within `anchor` (used for the open directory, which has no row of its own).
2109fn build_menu_at(
2110    anchor: &impl IsA<gtk::Widget>,
2111    at: (f64, f64),
2112    path: &Path,
2113    menu: &ContextMenu,
2114    selection: &[PathBuf],
2115    side: PanelSide,
2116    sender: &ComponentSender<Tree>,
2117) -> gtk::Popover {
2118    let menu_box = build_menu_box(path, menu, selection, side, sender);
2119    show_menu(anchor, &menu_box, Some(at))
2120}
2121
2122/// The [`TreeMsg`] a builtin context-menu action resolves to for `path`, given
2123/// the current `selection`. When `selection` holds more than one path the
2124/// selection-wide forms are produced (trash/delete/copy-path act on every
2125/// selected row); the single-row actions are filtered out before this is
2126/// reached, so their arms are only used in single-selection mode.
2127fn action_message(action: BuiltinAction, path: &Path, selection: &[PathBuf]) -> TreeMsg {
2128    if selection.len() > 1 {
2129        match action {
2130            BuiltinAction::Trash => return TreeMsg::DeleteSelected,
2131            BuiltinAction::DeletePermanently => return TreeMsg::PermanentDeleteSelected,
2132            BuiltinAction::CopyPath => return TreeMsg::CopyPaths(selection.to_vec()),
2133            BuiltinAction::CopyRelativePath => return TreeMsg::CopyRelativePaths(selection.to_vec()),
2134            BuiltinAction::Properties => return TreeMsg::PropertiesSelected(selection.to_vec()),
2135            _ => {}
2136        }
2137    }
2138    builtin_message(action, path)
2139}
2140
2141/// The [`TreeMsg`] a builtin context-menu action resolves to for `path`.
2142fn builtin_message(action: BuiltinAction, path: &Path) -> TreeMsg {
2143    match action {
2144        BuiltinAction::Open => TreeMsg::Activate(path.to_path_buf()),
2145        BuiltinAction::OpenSplit => TreeMsg::OpenSplit(path.to_path_buf()),
2146        BuiltinAction::InNewPanel => TreeMsg::OpenSplit(path.to_path_buf()),
2147        BuiltinAction::InOppositePanel => TreeMsg::OpenInOppositePanel(path.to_path_buf()),
2148        BuiltinAction::OpenWith => TreeMsg::OpenWith(path.to_path_buf()),
2149        BuiltinAction::OpenWithDefault => TreeMsg::OpenWithDefault(path.to_path_buf()),
2150        BuiltinAction::ViewThumbnail => TreeMsg::ToggleThumbnail(path.to_path_buf()),
2151        BuiltinAction::NewFile => TreeMsg::NewFile,
2152        BuiltinAction::NewFolder => TreeMsg::NewFolder,
2153        BuiltinAction::Duplicate => TreeMsg::Duplicate(path.to_path_buf()),
2154        BuiltinAction::CreateLink => TreeMsg::CreateLink(path.to_path_buf()),
2155        BuiltinAction::Cut => TreeMsg::Cut,
2156        BuiltinAction::Copy => TreeMsg::Copy,
2157        BuiltinAction::Paste => TreeMsg::Paste,
2158        BuiltinAction::Rename => TreeMsg::RenameAt(path.to_path_buf()),
2159        BuiltinAction::CopyPath => TreeMsg::CopyPath(path.to_path_buf()),
2160        BuiltinAction::CopyRelativePath => TreeMsg::CopyRelativePath(path.to_path_buf()),
2161        BuiltinAction::AddBookmark => TreeMsg::AddBookmark(path.to_path_buf()),
2162        BuiltinAction::Properties => TreeMsg::Properties(path.to_path_buf()),
2163        BuiltinAction::Trash => TreeMsg::Trash(path.to_path_buf()),
2164        BuiltinAction::DeletePermanently => TreeMsg::PermanentDelete(path.to_path_buf()),
2165        BuiltinAction::ToggleHidden => TreeMsg::ToggleHidden,
2166        BuiltinAction::SortByName => TreeMsg::SetSortKey(SortKey::Name),
2167        BuiltinAction::SortBySize => TreeMsg::SetSortKey(SortKey::Size),
2168        BuiltinAction::SortByModified => TreeMsg::SetSortKey(SortKey::Modified),
2169        BuiltinAction::SortByType => TreeMsg::SetSortKey(SortKey::Type),
2170        BuiltinAction::ToggleSortAscending => TreeMsg::ToggleSortDirection,
2171        // Pane-level actions are dispatched by the toolbar, never by a row menu.
2172        BuiltinAction::OpenFolder
2173        | BuiltinAction::Filter
2174        | BuiltinAction::SplitView
2175        | BuiltinAction::Up
2176        | BuiltinAction::Back
2177        | BuiltinAction::Forward
2178        | BuiltinAction::Collapse
2179        | BuiltinAction::ClosePane
2180        | BuiltinAction::ToggleBookmarks
2181        | BuiltinAction::NewBookmark
2182        | BuiltinAction::NewBookmarkFolder => unreachable!("pane action in a row context menu"),
2183        // Bookmark-only actions are handled by the bookmarks view, never a tree.
2184        BuiltinAction::EditBookmark | BuiltinAction::DeleteBookmark => TreeMsg::Noop,
2185        BuiltinAction::Separator => unreachable!("separators are rendered, not dispatched"),
2186    }
2187}
2188
2189/// Whether `action` can be run against a bare path with no tree row behind it
2190/// (used by the bookmarks view, whose entries point at directories that may not
2191/// be visible in any tree). Actions that need the tree's model or selection —
2192/// new file/folder, cut/copy/paste, thumbnails — are excluded.
2193pub(crate) fn is_path_safe(action: BuiltinAction) -> bool {
2194    matches!(
2195        action,
2196        BuiltinAction::Open
2197            | BuiltinAction::OpenSplit
2198            | BuiltinAction::InNewPanel
2199            | BuiltinAction::InOppositePanel
2200            | BuiltinAction::OpenWith
2201            | BuiltinAction::OpenWithDefault
2202            | BuiltinAction::Duplicate
2203            | BuiltinAction::CreateLink
2204            | BuiltinAction::CopyPath
2205            | BuiltinAction::CopyRelativePath
2206            | BuiltinAction::Properties
2207            | BuiltinAction::Trash
2208            | BuiltinAction::DeletePermanently
2209            | BuiltinAction::AddBookmark
2210    )
2211}
2212
2213/// The [`TreeMsg`] that runs path-safe `action` against `path` for a caller
2214/// with no tree row (the bookmarks view). `None` for actions without a
2215/// path-based form; `Open` is handled by the app directly and never reaches
2216/// here.
2217pub(crate) fn path_action_message(action: BuiltinAction, path: &Path) -> Option<TreeMsg> {
2218    if !is_path_safe(action) || action == BuiltinAction::Open {
2219        return None;
2220    }
2221    Some(builtin_message(action, path))
2222}
2223
2224/// Extensions that map to a package/archive icon in the row list.
2225const ARCHIVE_EXTENSIONS: [&str; 8] = ["zip", "tar", "gz", "xz", "bz2", "zst", "7z", "rar"];
2226
2227fn icon_name(row: &VisibleRow) -> &'static str {
2228    if row.is_dir {
2229        return if row.expanded { "folder-open-symbolic" } else { "folder-symbolic" };
2230    }
2231    // Match on the extension only (case-insensitively) rather than lowercasing
2232    // the whole path string, which allocated a new `String` for every row on
2233    // every rebuild.
2234    if has_extension(&row.path, &["pdf"]) {
2235        "application-pdf-symbolic"
2236    } else if has_extension(&row.path, &["jpg", "jpeg", "png", "gif", "svg", "webp", "bmp", "avif"])
2237    {
2238        "image-x-generic-symbolic"
2239    } else if has_extension(&row.path, &["mp3", "flac", "wav", "ogg", "m4a", "opus"]) {
2240        "audio-x-generic-symbolic"
2241    } else if has_extension(&row.path, &["mp4", "mkv", "webm", "mov", "avi"]) {
2242        "video-x-generic-symbolic"
2243    } else if has_extension(&row.path, &ARCHIVE_EXTENSIONS) {
2244        "package-x-generic-symbolic"
2245    } else {
2246        "text-x-generic-symbolic"
2247    }
2248}
2249
2250// ---------------------------------------------------------------------------
2251// model operations
2252// ---------------------------------------------------------------------------
2253
2254/// What kind of inline preview a file supports.
2255#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2256enum MediaKind {
2257    /// A still image, decoded to a `GdkTexture`.
2258    Image,
2259    /// An animated GIF: first frame still, animates on click.
2260    Gif,
2261    /// A video: first frame (paused) still, plays on click.
2262    Video,
2263    /// An audio file: no picture, just an inline transport.
2264    Audio,
2265}
2266
2267/// Extensions GdkTexture can decode as a still image (GIF is handled
2268/// separately so it can animate). SVG is deliberately excluded: neither
2269/// GdkTexture nor the media backend can rasterise it.
2270const IMAGE_EXTENSIONS: [&str; 10] =
2271    ["jpg", "jpeg", "png", "webp", "bmp", "tif", "tiff", "avif", "heic", "heif"];
2272
2273/// Video extensions offered a thumbnail. Whether a given file actually plays
2274/// depends on the installed GStreamer plugins.
2275const VIDEO_EXTENSIONS: [&str; 15] = [
2276    "mp4", "m4v", "mkv", "webm", "mov", "avi", "wmv", "flv", "ogv", "ogg", "ts", "mpg",
2277    "mpeg", "3gp", "m2ts",
2278];
2279
2280/// Audio extensions offered an inline player. Like video, actual playback
2281/// depends on the installed GStreamer plugins (`gst-plugins-good` covers the
2282/// common ones; `gst-libav`/`gst-plugins-ugly` add more).
2283const AUDIO_EXTENSIONS: [&str; 12] = [
2284    "mp3", "m4a", "m4b", "aac", "flac", "wav", "opus", "oga", "wma", "aif", "aiff", "alac",
2285];
2286
2287fn has_extension(path: &Path, extensions: &[&str]) -> bool {
2288    path.extension()
2289        .and_then(|ext| ext.to_str())
2290        .is_some_and(|ext| extensions.iter().any(|candidate| ext.eq_ignore_ascii_case(candidate)))
2291}
2292
2293/// The preview kind for `path`, if it is previewable media.
2294fn media_kind(path: &Path) -> Option<MediaKind> {
2295    if has_extension(path, &["gif"]) {
2296        Some(MediaKind::Gif)
2297    } else if has_extension(path, &IMAGE_EXTENSIONS) {
2298        Some(MediaKind::Image)
2299    } else if has_extension(path, &VIDEO_EXTENSIONS) {
2300        Some(MediaKind::Video)
2301    } else if cfg!(feature = "audio") && has_extension(path, &AUDIO_EXTENSIONS) {
2302        Some(MediaKind::Audio)
2303    } else {
2304        None
2305    }
2306}
2307
2308/// Whether `path` can have an inline preview (a thumbnail, or an audio player).
2309fn is_thumbnailable(path: &Path) -> bool {
2310    media_kind(path).is_some()
2311}
2312
2313/// A GIF animation being played inline. The iterator keeps its animation
2314/// alive and yields the frame for a given wall-clock time.
2315struct GifAnim {
2316    iter: gdk::gdk_pixbuf::PixbufAnimationIter,
2317}
2318
2319/// The live controls of one inline audio player. `audio_tick` reads `player`
2320/// and writes the other widgets, so the bar/clock/icon stay correct even as
2321/// the row is rebuilt.
2322struct AudioWidgets {
2323    player: Rc<AudioPlayer>,
2324    play: gtk::Button,
2325    seek: gtk::Scale,
2326    time: gtk::Label,
2327    /// True while the user is dragging the seek bar, so the ticker doesn't
2328    /// yank the handle out from under them.
2329    seeking: Rc<Cell<bool>>,
2330}
2331
2332/// Builtins that act on a specific row. With nothing selected (the blank area
2333/// was clicked), a bound hotkey for one of these does nothing rather than
2334/// acting on the open directory.
2335fn needs_selection(action: BuiltinAction) -> bool {
2336    matches!(
2337        action,
2338        BuiltinAction::Rename
2339            | BuiltinAction::Duplicate
2340            | BuiltinAction::CreateLink
2341            | BuiltinAction::Cut
2342            | BuiltinAction::Copy
2343            | BuiltinAction::OpenWith
2344            | BuiltinAction::Trash
2345            | BuiltinAction::DeletePermanently
2346    )
2347}
2348
2349/// Whether `widget` is, or is inside, a tree row. Used to tell a click on a row
2350/// from a click on the blank space below the rows.
2351fn in_list_row(widget: &gtk::Widget) -> bool {
2352    let mut current = Some(widget.clone());
2353    while let Some(widget) = current {
2354        if widget.downcast_ref::<gtk::ListBoxRow>().is_some() {
2355            return true;
2356        }
2357        current = widget.parent();
2358    }
2359    false
2360}
2361
2362/// Whether `widget` is, or is inside, an inline media widget (a video or the
2363/// audio player's controls), so the row leaves such clicks to that widget.
2364fn in_inline_media(widget: &gtk::Widget) -> bool {
2365    let mut current = Some(widget.clone());
2366    while let Some(widget) = current {
2367        if widget.downcast_ref::<gtk::Video>().is_some() || widget.has_css_class("tree-audio") {
2368            return true;
2369        }
2370        current = widget.parent();
2371    }
2372    false
2373}
2374
2375/// Width in px available to a depth-0 thumbnail: the panel's inner width, minus
2376/// the scrollbar and a little padding.
2377fn thumbnail_content_width(panel: PanelConfig) -> i32 {
2378    (panel.width as i32 - 2 * panel.margin as i32 - 24).max(64)
2379}
2380
2381/// Target (width, height) for a thumbnail: scaled to `available` width while
2382/// keeping the aspect ratio, but never enlarged past the image's natural size.
2383fn thumbnail_size(texture: &gdk::Texture, available: i32) -> (i32, i32) {
2384    let natural_w = texture.width().max(1);
2385    let natural_h = texture.height().max(1);
2386    let available = available.max(32);
2387    if natural_w <= available {
2388        return (natural_w, natural_h);
2389    }
2390    let height = (natural_h as i64 * available as i64 / natural_w as i64).max(1) as i32;
2391    (available, height)
2392}
2393
2394/// A thumbnail `GtkPicture` sized to `width`/`height`, aligned under the row's
2395/// label.
2396fn thumbnail_picture(
2397    texture: &gdk::Texture,
2398    margin_start: i32,
2399    width: i32,
2400    height: i32,
2401) -> gtk::Picture {
2402    let picture = gtk::Picture::for_paintable(texture);
2403    picture.add_css_class("tree-thumbnail");
2404    picture.set_can_shrink(true);
2405    picture.set_content_fit(gtk::ContentFit::ScaleDown);
2406    picture.set_halign(gtk::Align::Start);
2407    picture.set_size_request(width, height);
2408    picture.set_margin_start(margin_start);
2409    picture.set_margin_bottom(6);
2410    picture
2411}
2412
2413/// `m:ss` (or `h:mm:ss` past an hour) for a media timestamp in microseconds.
2414/// Unknown (`<= 0`) durations render as `-:--`.
2415fn format_time(micros: i64) -> String {
2416    if micros <= 0 {
2417        return "-:--".to_owned();
2418    }
2419    let total = micros / 1_000_000;
2420    let (h, m, s) = (total / 3600, (total % 3600) / 60, total % 60);
2421    if h > 0 {
2422        format!("{h}:{m:02}:{s:02}")
2423    } else {
2424        format!("{m}:{s:02}")
2425    }
2426}
2427
2428/// Build the inline audio player for an audio file: play/pause, a seek bar, a
2429/// position/duration clock and a volume slider. The `GtkMediaFile` is shared
2430/// with `AudioWidgets` so `audio_tick` can drive the bar and clock.
2431fn build_audio_player(player: &Rc<AudioPlayer>, margin_start: i32) -> (gtk::Box, AudioWidgets) {
2432    let controls = gtk::Box::new(gtk::Orientation::Horizontal, 6);
2433    controls.add_css_class("tree-audio");
2434    controls.set_margin_start(margin_start);
2435    controls.set_margin_bottom(6);
2436    controls.set_hexpand(true);
2437
2438    let play = gtk::Button::from_icon_name("media-playback-start-symbolic");
2439    play.add_css_class("flat");
2440    play.set_tooltip_text(Some("Play"));
2441    play.set_valign(gtk::Align::Center);
2442    {
2443        let player = player.clone();
2444        play.connect_clicked(move |button| {
2445            let playing = player.is_playing();
2446            player.set_playing(!playing);
2447            button.set_icon_name(if playing {
2448                "media-playback-start-symbolic"
2449            } else {
2450                "media-playback-pause-symbolic"
2451            });
2452        });
2453    }
2454    controls.append(&play);
2455
2456    // The ticker sets the bar's value programmatically (which does *not* emit
2457    // `change-value`), so seeking on `change-value` can't feed back on itself.
2458    let seek = gtk::Scale::with_range(gtk::Orientation::Horizontal, 0.0, 1.0, 1.0);
2459    seek.set_draw_value(false);
2460    seek.set_hexpand(true);
2461    seek.set_valign(gtk::Align::Center);
2462    seek.set_size_request(60, -1);
2463    let seeking = Rc::new(Cell::new(false));
2464    {
2465        let player = player.clone();
2466        let seeking = seeking.clone();
2467        seek.connect_change_value(move |_, _, value| {
2468            // Pause the ticker's clock while the user drags the handle.
2469            seeking.set(true);
2470            let flag = seeking.clone();
2471            glib::timeout_add_local_once(Duration::from_millis(200), move || flag.set(false));
2472            player.seek(value.round() as i64);
2473            glib::Propagation::Proceed
2474        });
2475    }
2476    controls.append(&seek);
2477
2478    let time = gtk::Label::new(Some("0:00 / -:--"));
2479    time.add_css_class("tree-audio-time");
2480    time.set_valign(gtk::Align::Center);
2481    time.set_width_chars(13);
2482    time.set_xalign(1.0);
2483    controls.append(&time);
2484
2485    let volume = gtk::Scale::with_range(gtk::Orientation::Horizontal, 0.0, 1.0, 0.05);
2486    volume.set_draw_value(false);
2487    volume.set_value(player.volume());
2488    volume.set_valign(gtk::Align::Center);
2489    volume.set_size_request(56, -1);
2490    volume.set_tooltip_text(Some("Volume"));
2491    {
2492        let player = player.clone();
2493        volume.connect_value_changed(move |scale| player.set_volume(scale.value()));
2494    }
2495    controls.append(&volume);
2496
2497    let widgets = AudioWidgets {
2498        player: player.clone(),
2499        play,
2500        seek,
2501        time,
2502        seeking,
2503    };
2504    (controls, widgets)
2505}
2506
2507impl Tree {
2508    fn open_root(&mut self, path: &Path, sender: &ComponentSender<Self>) {
2509        if let Some(id) = self.flush_source.take() {
2510            id.remove();
2511        }
2512        self.pending.clear();
2513
2514        let mut tree = TreeModel::new(
2515            path.to_path_buf(),
2516            self.config.sort_options(),
2517            self.config.show_hidden,
2518        );
2519        if let Err(err) = tree.expand(path, &StdDirSource) {
2520            self.status(format!("Could not open {}: {err}", path.display()), sender);
2521            return;
2522        }
2523        self.tree = Some(tree);
2524        self.refresh_rows();
2525        self.cursor = if self.rows.is_empty() { None } else { Some(0) };
2526        self.selected = Vec::new();
2527        self.renaming = None;
2528        self.renaming_state.set(false);
2529        self.rename_entry = None;
2530        self.menu_target = None;
2531        self.thumbnails.clear();
2532        self.thumb_cache.clear();
2533        self.playing.clear();
2534        for media in self.media.values() {
2535            media.set_playing(false);
2536        }
2537        self.media.clear();
2538        self.audio.clear();
2539        self.gif_anims.borrow_mut().clear();
2540        self.gif_widgets.borrow_mut().clear();
2541        self.audio_widgets.borrow_mut().clear();
2542        if let Some(previous) = self.popover.take() {
2543            previous.unparent();
2544        }
2545
2546        let root = path.to_path_buf();
2547        let watcher_sender = sender.clone();
2548        self._watcher = spawn_watcher(path, move |change| {
2549            watcher_sender.input(TreeMsg::FsChange(change));
2550        })
2551        .ok();
2552        self.watched.clear();
2553        if self._watcher.is_some() {
2554            self.watched.insert(root.clone());
2555        }
2556
2557        let _ = sender.output(TreeOutput::RootChanged(root));
2558    }
2559
2560    fn refresh_rows(&mut self) {
2561        if let Some(tree) = self.tree.as_ref() {
2562            tree.visible_rows_into(&mut self.rows);
2563        } else {
2564            self.rows.clear();
2565        }
2566    }
2567
2568    /// Align the set of watched directories with the model's expanded+loaded
2569    /// directories. The initial watch is the (non-recursive) root only, so
2570    /// opening a tree is instant; expanded directories get individual watches
2571    /// as they are loaded. `unwatch`/`watch` failures are ignored — a stale
2572    /// watch only costs an auto-update, never correctness.
2573    fn reconcile_watches(&mut self) {
2574        let Some(watcher) = self._watcher.as_mut() else {
2575            return;
2576        };
2577        let Some(model) = self.tree.as_ref() else {
2578            return;
2579        };
2580        let desired: HashSet<PathBuf> = model.expanded_loaded_dirs().into_iter().collect();
2581
2582        let stale: Vec<PathBuf> = self.watched.difference(&desired).cloned().collect();
2583        for path in stale {
2584            let _ = watcher.unwatch(&path);
2585            self.watched.remove(&path);
2586        }
2587
2588        let fresh: Vec<PathBuf> = desired.difference(&self.watched).cloned().collect();
2589        for path in fresh {
2590            if watcher.watch(&path, RecursiveMode::NonRecursive).is_ok() {
2591                self.watched.insert(path);
2592            }
2593        }
2594    }
2595
2596    fn cursor_path(&self) -> Option<PathBuf> {
2597        self.cursor.and_then(|i| self.rows.get(i)).map(|r| r.path.clone())
2598    }
2599
2600    fn cursor_delta(&mut self, delta: isize) {
2601        if self.rows.is_empty() {
2602            return;
2603        }
2604        let next = (self.cursor.unwrap_or(0) as isize + delta).clamp(0, self.rows.len() as isize - 1) as usize;
2605        let path = self.rows[next].path.clone();
2606        self.cursor = Some(next);
2607        self.set_single_selection(path);
2608        self.selection_dirty = true;
2609    }
2610
2611    /// Shift+Arrow selection extension. Moves the cursor `delta` rows and
2612    /// selects from the anchor to the new cursor.
2613    ///
2614    /// The anchor is fixed on the first extension and kept afterwards, so a run
2615    /// of Shift+Arrows grows a contiguous block; Ctrl+Shift+Arrows behave the
2616    /// same once an anchor exists, but keep the message distinct for clarity.
2617    fn cursor_extend(&mut self, delta: isize, _keep_anchor: bool) {
2618        if self.rows.is_empty() {
2619            return;
2620        }
2621        let anchor = self
2622            .anchor
2623            .clone()
2624            .or_else(|| self.cursor.map(|i| self.rows[i].path.clone()))
2625            .unwrap_or_else(|| self.rows[0].path.clone());
2626        let next = (self.cursor.unwrap_or(0) as isize + delta)
2627            .clamp(0, self.rows.len() as isize - 1) as usize;
2628        self.cursor = Some(next);
2629        self.anchor = Some(anchor.clone());
2630        let (Some(a), Some(b)) = (self.rows.iter().position(|r| r.path == anchor), Some(next)) else {
2631            return;
2632        };
2633        self.selected = self.rows[index_range(a, b)].iter().map(|r| r.path.clone()).collect();
2634        self.scroll_to = Some(next);
2635        self.selection_dirty = true;
2636    }
2637
2638    fn cursor_snap(&mut self) {
2639        let deselected = self.cursor.is_none();
2640        let prev = self.cursor_path();
2641        self.refresh_rows();
2642        if self.rows.is_empty() {
2643            self.cursor = None;
2644            self.selected.clear();
2645            return;
2646        }
2647        if deselected {
2648            // Keep the "nothing selected" state across refreshes.
2649            self.cursor = None;
2650            return;
2651        }
2652        let index = prev
2653            .and_then(|p| self.rows.iter().position(|r| r.path == p))
2654            .unwrap_or(0);
2655        self.cursor = Some(index);
2656    }
2657
2658    fn select(&mut self, path: &Path) {
2659        if let Some(i) = self.rows.iter().position(|r| r.path == path) {
2660            self.cursor = Some(i);
2661        }
2662        self.selected = vec![path.to_path_buf()];
2663    }
2664
2665    /// The default effect of a plain left press on a row: collapse the
2666    /// selection onto it, then toggle a directory or open a double-clicked
2667    /// file. Shared by the immediate press path and the deferred
2668    /// [`TreeMsg::RowRelease`] path.
2669    fn press_default(&mut self, path: &Path, is_dir: bool, sender: &ComponentSender<Self>) {
2670        // Record the press for double-click detection (files only — see below).
2671        // It lives here because the click gesture is recreated with the rows.
2672        let now = std::time::Instant::now();
2673        let is_double = self.last_press.as_ref().is_some_and(|(last, at)| {
2674            last == path && now.duration_since(*at) < Duration::from_millis(400)
2675        });
2676        self.last_press = Some((path.to_path_buf(), now));
2677
2678        self.select(path);
2679        self.anchor = Some(path.to_path_buf());
2680        if is_dir {
2681            // Directories toggle on *every* press, so they can be expanded and
2682            // collapsed as fast as the user clicks. Double-click on a directory
2683            // is not a distinct action.
2684            self.toggle_dir(path, sender);
2685        } else if is_double {
2686            // Files open on a double-click.
2687            self.activate(path, sender);
2688        }
2689    }
2690
2691    /// Reveal a path forwarded from a launch (`--select` or a file argument).
2692    /// Selects it, moves the keyboard cursor onto it, and asks the next widget
2693    /// pass to scroll it into view. A path that is not a visible row (e.g. it
2694    /// lives outside the current root) is ignored.
2695    fn reveal_path(&mut self, path: PathBuf) {
2696        if let Some(i) = self.rows.iter().position(|r| r.path == path) {
2697            self.cursor = Some(i);
2698            self.selected = vec![path.clone()];
2699            self.anchor = Some(path);
2700            self.selection_dirty = true;
2701            self.scroll_to = Some(i);
2702        }
2703    }
2704
2705    /// Shift+click: select the contiguous run of rows between the current
2706    /// anchor and `path` (inclusive of both ends). With no anchor yet, this
2707    /// degrades to a plain single selection.
2708    fn range_select(&mut self, path: &Path) {
2709        let Some(anchor) = self.anchor.clone() else {
2710            self.select(path);
2711            self.anchor = Some(path.to_path_buf());
2712            return;
2713        };
2714        let (Some(a), Some(b)) = (
2715            self.rows.iter().position(|r| r.path == anchor),
2716            self.rows.iter().position(|r| r.path == path),
2717        ) else {
2718            self.select(path);
2719            return;
2720        };
2721        self.selected = self.rows[index_range(a, b)].iter().map(|r| r.path.clone()).collect();
2722        self.cursor = Some(b);
2723    }
2724
2725    fn set_single_selection(&mut self, path: PathBuf) {
2726        self.selected = vec![path.clone()];
2727        if let Some(i) = self.rows.iter().position(|r| r.path == path) {
2728            self.cursor = Some(i);
2729        }
2730    }
2731
2732    fn selected_for_clipboard(&self) -> Vec<PathBuf> {
2733        if self.selected.is_empty() {
2734            if let Some(path) = self.cursor_path() {
2735                return vec![path];
2736            }
2737            return Vec::new();
2738        }
2739        self.selected.clone()
2740    }
2741
2742    /// Collapse `path` and drop any thumbnails beneath it (non-persistent:
2743    /// collapsing a directory hides its previews for good).
2744    fn collapse_dir(&mut self, path: &Path) {
2745        if let Some(tree) = self.tree.as_mut() {
2746            tree.collapse(path);
2747        }
2748        self.clear_thumbnails_under(path);
2749        self.cursor_snap();
2750    }
2751
2752    /// Expand or collapse `path` depending on its current state.
2753    fn toggle_dir(&mut self, path: &Path, sender: &ComponentSender<Self>) {
2754        let expanded = self.tree.as_ref().is_some_and(|t| t.is_expanded(path));
2755        if expanded {
2756            self.collapse_dir(path);
2757        } else {
2758            self.expand_dir(path, sender);
2759        }
2760    }
2761
2762    /// Tear down all media ahead of app exit. Called via [`TreeMsg::Shutdown`]
2763    /// while the main loop is still running: the queued message drops every
2764    /// thumbnail, so the following rebuild destroys the `GtkVideo` widgets and
2765    /// releases their streams, and the cached `GtkMediaFile`s are dropped here.
2766    /// Without this the process exits with a live GStreamer GL context, whose
2767    /// context thread then races the driver's at-exit GL teardown.
2768    fn shutdown(&mut self) {
2769        if let Some(id) = self.gif_tick.borrow_mut().take() {
2770            id.remove();
2771        }
2772        if let Some(id) = self.audio_tick.borrow_mut().take() {
2773            id.remove();
2774        }
2775        self.gif_anims.borrow_mut().clear();
2776        self.gif_widgets.borrow_mut().clear();
2777        self.audio_widgets.borrow_mut().clear();
2778        self.playing.clear();
2779        self.thumbnails.clear();
2780        for media in self.media.values() {
2781            media.set_playing(false);
2782        }
2783        self.media.clear();
2784        // Dropping each player returns its pipeline to NULL.
2785        self.audio.clear();
2786    }
2787
2788    /// Forget all thumbnail state for `prefix` and everything under it.
2789    fn clear_thumbnails_under(&mut self, prefix: &Path) {
2790        self.thumbnails.retain(|path| !path.starts_with(prefix));
2791        self.thumb_cache.retain(|path, _| !path.starts_with(prefix));
2792        self.playing.retain(|path| !path.starts_with(prefix));
2793        self.media.retain(|path, media| {
2794            if path.starts_with(prefix) {
2795                media.set_playing(false);
2796                false
2797            } else {
2798                true
2799            }
2800        });
2801        self.gif_anims.borrow_mut().retain(|path, _| !path.starts_with(prefix));
2802        self.gif_widgets.borrow_mut().retain(|path, _| !path.starts_with(prefix));
2803        self.audio_widgets.borrow_mut().retain(|path, _| !path.starts_with(prefix));
2804        // Dropping a player returns its pipeline to NULL.
2805        self.audio.retain(|path, _| !path.starts_with(prefix));
2806    }
2807
2808    /// Whether `row` should render an inline preview: a previewable file that
2809    /// was turned on directly, or that sits under a directory that was.
2810    fn wants_thumbnail(&self, row: &VisibleRow) -> bool {
2811        if row.is_dir || !is_thumbnailable(&row.path) {
2812            return false;
2813        }
2814        self.thumbnails.contains(&row.path)
2815            || row
2816                .path
2817                .ancestors()
2818                .skip(1)
2819                .any(|ancestor| self.thumbnails.contains(ancestor))
2820    }
2821
2822    /// The still texture for `path` — an image, or a GIF's first frame —
2823    /// decoded and cached on first use.
2824    fn static_texture(&mut self, path: &Path) -> Option<gdk::Texture> {
2825        if let Some(texture) = self.thumb_cache.get(path) {
2826            return Some(texture.clone());
2827        }
2828        let texture = match media_kind(path)? {
2829            MediaKind::Image => gdk::Texture::from_filename(path).ok()?,
2830            MediaKind::Gif => {
2831                let animation = gdk::gdk_pixbuf::PixbufAnimation::from_file(path).ok()?;
2832                let pixbuf = animation.static_image()?;
2833                gdk::Texture::for_pixbuf(&pixbuf)
2834            }
2835            // Videos and audio are rendered by their own widgets, not a texture.
2836            MediaKind::Video | MediaKind::Audio => return None,
2837        };
2838        self.thumb_cache.insert(path.to_path_buf(), texture.clone());
2839        Some(texture)
2840    }
2841
2842    /// The cached, looping media stream for a video thumbnail. It starts paused
2843    /// (the widget controls playback), so nothing is heard until the user
2844    /// presses play; the built-in volume control handles mute/volume. Audio uses
2845    /// its own `playbin`-backed player (see [`Tree::audio_player`]).
2846    fn media_file(&mut self, path: &Path) -> Option<gtk::MediaFile> {
2847        if let Some(media) = self.media.get(path) {
2848            return Some(media.clone());
2849        }
2850        let media = gtk::MediaFile::for_filename(path);
2851        media.set_loop(true);
2852        // PipeWire/WirePlumber remembers per-application stream volume and
2853        // mute state; a stale "muted at 0" entry (e.g. from a session that
2854        // pre-muted thumbnails) would otherwise silence every stream. Start
2855        // from a known unmuted state so playback is actually audible.
2856        media.set_muted(false);
2857        media.set_volume(1.0);
2858        self.media.insert(path.to_path_buf(), media.clone());
2859        Some(media)
2860    }
2861
2862    /// The cached audio player for `path`, created on first use. `None` when the
2863    /// `audio` feature is disabled (the stub always declines).
2864    fn audio_player(&mut self, path: &Path) -> Option<Rc<AudioPlayer>> {
2865        if let Some(player) = self.audio.get(path) {
2866            return Some(player.clone());
2867        }
2868        let player = AudioPlayer::new(path)?;
2869        self.audio.insert(path.to_path_buf(), player.clone());
2870        Some(player)
2871    }
2872
2873    /// Play/pause an animated GIF thumbnail. (Videos are handled entirely by
2874    /// `GtkVideo`'s own transport controls, so they never reach here.)
2875    fn toggle_thumbnail_play(&mut self, path: &Path) {
2876        if media_kind(path) != Some(MediaKind::Gif) {
2877            return;
2878        }
2879        if self.playing.remove(path) {
2880            self.gif_anims.borrow_mut().remove(path);
2881        } else {
2882            let Ok(animation) = gdk::gdk_pixbuf::PixbufAnimation::from_file(path) else {
2883                return;
2884            };
2885            let iter = animation.iter(Some(SystemTime::now()));
2886            self.gif_anims.borrow_mut().insert(path.to_path_buf(), GifAnim { iter });
2887            self.playing.insert(path.to_path_buf());
2888            self.ensure_gif_ticker();
2889        }
2890    }
2891
2892    /// Start the GIF ticker if it isn't already running. It advances the frame
2893    /// iterator and repaints the current `GtkPicture` for each playing GIF,
2894    /// without rebuilding the row list.
2895    fn ensure_gif_ticker(&mut self) {
2896        if self.gif_tick.borrow().is_some() {
2897            return;
2898        }
2899        let anims = self.gif_anims.clone();
2900        let widgets = self.gif_widgets.clone();
2901        let tick = self.gif_tick.clone();
2902        let id = glib::timeout_add_local(Duration::from_millis(33), move || {
2903            let mut anims = anims.borrow_mut();
2904            if anims.is_empty() {
2905                *tick.borrow_mut() = None;
2906                return glib::ControlFlow::Break;
2907            }
2908            let now = SystemTime::now();
2909            let widgets = widgets.borrow();
2910            for (path, anim) in anims.iter_mut() {
2911                let _ = anim.iter.advance(now);
2912                if let Some(picture) = widgets.get(path) {
2913                    let pixbuf = anim.iter.pixbuf();
2914                    picture.set_paintable(Some(&gdk::Texture::for_pixbuf(&pixbuf)));
2915                }
2916            }
2917            glib::ControlFlow::Continue
2918        });
2919        *self.gif_tick.borrow_mut() = Some(id);
2920    }
2921
2922    /// Start the audio ticker if it isn't already running. It mirrors each
2923    /// player's stream into its seek bar, clock and play/pause icon.
2924    fn ensure_audio_ticker(&mut self) {
2925        if self.audio_tick.borrow().is_some() {
2926            return;
2927        }
2928        let players = self.audio_widgets.clone();
2929        let tick = self.audio_tick.clone();
2930        let id = glib::timeout_add_local(Duration::from_millis(200), move || {
2931            let players = players.borrow();
2932            if players.is_empty() {
2933                *tick.borrow_mut() = None;
2934                return glib::ControlFlow::Break;
2935            }
2936            for widgets in players.values() {
2937                let position = widgets.player.position();
2938                let duration = widgets.player.duration();
2939                if duration > 0
2940                    && (widgets.seek.adjustment().upper() - duration as f64).abs() > 0.5
2941                {
2942                    widgets.seek.adjustment().set_upper(duration as f64);
2943                }
2944                if !widgets.seeking.get() {
2945                    widgets.seek.set_value(position as f64);
2946                }
2947                widgets.time.set_text(&format!(
2948                    "{} / {}",
2949                    format_time(position),
2950                    format_time(duration)
2951                ));
2952                widgets.play.set_icon_name(if widgets.player.is_playing() {
2953                    "media-playback-pause-symbolic"
2954                } else {
2955                    "media-playback-start-symbolic"
2956                });
2957            }
2958            glib::ControlFlow::Continue
2959        });
2960        *self.audio_tick.borrow_mut() = Some(id);
2961    }
2962
2963    /// Append the inline preview for `path` below its row (a still image, an
2964    /// animated GIF, a video, or an audio player).
2965    fn append_thumbnail(
2966        &mut self,
2967        path: &Path,
2968        row_indent: i32,
2969        container: &gtk::Box,
2970        sender: &ComponentSender<Self>,
2971    ) {
2972        let Some(kind) = media_kind(path) else {
2973            return;
2974        };
2975        let label_start = row_indent + self.config.icon_size as i32 + 6;
2976        let available = self.content_width - label_start - 6;
2977
2978        match kind {
2979            MediaKind::Image => {
2980                if let Some(texture) = self.static_texture(path) {
2981                    let (width, height) = thumbnail_size(&texture, available);
2982                    container.append(&thumbnail_picture(&texture, label_start, width, height));
2983                }
2984            }
2985            MediaKind::Gif => {
2986                // While playing, show the current frame; otherwise the first.
2987                let playing = self.playing.contains(path);
2988                let texture = if playing {
2989                    self.gif_anims
2990                        .borrow()
2991                        .get(path)
2992                        .map(|anim| gdk::Texture::for_pixbuf(&anim.iter.pixbuf()))
2993                } else {
2994                    self.static_texture(path)
2995                };
2996                if let Some(texture) = texture {
2997                    let (width, height) = thumbnail_size(&texture, available);
2998                    let picture = thumbnail_picture(&texture, label_start, width, height);
2999                    picture.set_tooltip_text(Some("Click to play"));
3000                    if playing {
3001                        self.gif_widgets
3002                            .borrow_mut()
3003                            .insert(path.to_path_buf(), picture.clone());
3004                    }
3005                    let msg = TreeMsg::ToggleThumbnailPlay(path.to_path_buf());
3006                    let s = sender.clone();
3007                    let click = gtk::GestureClick::new();
3008                    click.set_button(1);
3009                    click.connect_pressed(move |_, _, _, _| s.input(msg.clone()));
3010                    picture.add_controller(click);
3011                    container.append(&picture);
3012                }
3013            }
3014            MediaKind::Video => {
3015                if let Some(media) = self.media_file(path) {
3016                    // `GtkVideo` brings its own transport controls (play/pause,
3017                    // seek, mute). Adding our own click gesture here would fire
3018                    // on clicks aimed at those controls and rebuild the row,
3019                    // destroying the control before it acts — so leave the
3020                    // interaction entirely to `GtkVideo`.
3021                    let video = gtk::Video::new();
3022                    video.set_autoplay(false);
3023                    video.set_media_stream(Some(&media));
3024                    video.set_loop(true);
3025                    video.add_css_class("tree-thumbnail");
3026                    video.set_halign(gtk::Align::Start);
3027                    let width = available.max(64);
3028                    video.set_size_request(width, (width * 9 / 16).max(36));
3029                    video.set_margin_start(label_start);
3030                    video.set_margin_bottom(6);
3031                    container.append(&video);
3032                }
3033            }
3034            MediaKind::Audio => {
3035                if let Some(player) = self.audio_player(path) {
3036                    // Unlike a video, audio has no picture to show, so we build
3037                    // a compact transport (play/pause, seek, clock, volume) that
3038                    // the ticker keeps in sync with the stream.
3039                    let (controls, widgets) = build_audio_player(&player, label_start);
3040                    self.audio_widgets.borrow_mut().insert(path.to_path_buf(), widgets);
3041                    container.append(&controls);
3042                    self.ensure_audio_ticker();
3043                }
3044            }
3045        }
3046    }
3047
3048    fn expand_dir(&mut self, path: &Path, sender: &ComponentSender<Self>) {
3049        let Some(tree) = self.tree.as_mut() else {
3050            return;
3051        };
3052        if let Err(err) = tree.expand(path, &StdDirSource) {
3053            self.status(format!("Could not open {}: {err}", path.display()), sender);
3054        }
3055        self.refresh_rows();
3056    }
3057
3058    fn activate(&mut self, path: &Path, sender: &ComponentSender<Self>) {
3059        let Some(index) = self.rows.iter().position(|r| r.path == path) else {
3060            return;
3061        };
3062        if self.rows[index].is_dir {
3063            // "Open" navigates: the directory becomes this pane's root,
3064            // replacing the current one. Expanding/collapsing stays on the
3065            // plain left click (and the arrow keys).
3066            self.open_root(path, sender);
3067        } else if let Err(err) = open_in_app(path) {
3068            self.status(err, sender);
3069        }
3070    }
3071
3072    fn confirm_trash(&mut self, paths: Vec<PathBuf>, sender: ComponentSender<Self>) {
3073        let message = match paths.as_slice() {
3074            [single] => {
3075                let name = single
3076                    .file_name()
3077                    .map(|n| n.to_string_lossy().into_owned())
3078                    .unwrap_or_else(|| single.display().to_string());
3079                format!("Move \u{201c}{name}\u{201d} to trash?")
3080            }
3081            many => format!("Move {} items to trash?", many.len()),
3082        };
3083        let dialog = gtk::AlertDialog::builder()
3084            .modal(true)
3085            .message(message)
3086            .detail("The panel cannot restore trashed items.")
3087            .buttons(["Cancel", "Trash"])
3088            .cancel_button(0)
3089            .default_button(1)
3090            .build();
3091        dialog.choose(Some(&self.parent), None::<&gio::Cancellable>, move |res| {
3092            if matches!(res, Ok(1)) {
3093                sender.input(TreeMsg::ConfirmTrash(paths));
3094            }
3095        });
3096    }
3097
3098    fn confirm_permanent_delete(&mut self, paths: Vec<PathBuf>, sender: ComponentSender<Self>) {
3099        let message = match paths.as_slice() {
3100            [single] => {
3101                let name = single
3102                    .file_name()
3103                    .map(|n| n.to_string_lossy().into_owned())
3104                    .unwrap_or_else(|| single.display().to_string());
3105                format!("Permanently delete \u{201c}{name}\u{201d}?")
3106            }
3107            many => format!("Permanently delete {} items?", many.len()),
3108        };
3109        let dialog = gtk::AlertDialog::builder()
3110            .modal(true)
3111            .message(message)
3112            .detail("This cannot be undone. Files will not go to the trash.")
3113            .buttons(["Cancel", "Delete Permanently"])
3114            .cancel_button(0)
3115            .default_button(1)
3116            .build();
3117        dialog.choose(Some(&self.parent), None::<&gio::Cancellable>, move |res| {
3118            if matches!(res, Ok(1)) {
3119                sender.input(TreeMsg::ConfirmPermanentDelete(paths));
3120            }
3121        });
3122    }
3123
3124    fn open_menu(&mut self, path: &Path) {
3125        // Preserve an existing multi-selection when the right-clicked row is
3126        // part of it, so the menu can act on all selected rows. Any other
3127        // right-click collapses onto the clicked row.
3128        let in_selection = self.selected.len() > 1 && self.selected.iter().any(|p| p == path);
3129        if !in_selection {
3130            self.select(path);
3131        } else if let Some(i) = self.rows.iter().position(|r| r.path == path) {
3132            self.cursor = Some(i);
3133        }
3134        if let Some(previous) = self.popover.take() {
3135            previous.unparent();
3136        }
3137        self.menu_target = Some(path.to_path_buf());
3138        self.menu_point = None;
3139    }
3140
3141    /// Open the context menu for `path` anchored at the click point `at` (in
3142    /// `scrolled` coordinates). Used for the open directory, which has no row
3143    /// of its own to anchor to.
3144    fn open_menu_at(&mut self, path: &Path, at: (f64, f64)) {
3145        if let Some(previous) = self.popover.take() {
3146            previous.unparent();
3147        }
3148        self.menu_target = Some(path.to_path_buf());
3149        self.menu_point = Some(at);
3150    }
3151
3152    fn paste(&mut self, sender: &ComponentSender<Self>) {
3153        let Some(clip) = self.clipboard.clone() else {
3154            // Nothing copied/cut inside tree-space: fall back to the system
3155            // clipboard so files copied in another app can be pasted here.
3156            self.paste_from_system_clipboard(sender);
3157            return;
3158        };
3159        let Some(dir) = self.target_dir() else {
3160            return;
3161        };
3162        let mut count = 0usize;
3163        let mut failed: Option<String> = None;
3164        for src in clip.paths {
3165            if src == dir {
3166                continue;
3167            }
3168            let result = match clip.op {
3169                ClipboardOp::Copy => self.ops.copy(&src, &dir),
3170                ClipboardOp::Cut => self.ops.move_(&src, &dir),
3171            };
3172            match result {
3173                Ok(dest) => {
3174                    count += 1;
3175                    self.apply_change(Change::Created { path: dest });
3176                    if clip.op == ClipboardOp::Cut {
3177                        self.apply_change(Change::Removed { path: src });
3178                    }
3179                }
3180                Err(err) => {
3181                    failed = Some(err.to_string());
3182                    break;
3183                }
3184            }
3185        }
3186        if let Some(failed) = failed {
3187            self.status(failed, sender);
3188            return;
3189        }
3190        let verb = match clip.op {
3191            ClipboardOp::Copy => "Copied",
3192            ClipboardOp::Cut => "Moved",
3193        };
3194        self.status(format!("{verb} {count} item(s) into {}", dir.display()), sender);
3195        if clip.op == ClipboardOp::Cut {
3196            self.clipboard = None;
3197        }
3198        self.cursor_snap();
3199    }
3200
3201    /// Paste the system clipboard's file list (if any) into the target
3202    /// directory as *copies*. Asynchronous: the clipboard read completes on the
3203    /// main loop and re-enters through [`TreeMsg::PastePaths`].
3204    fn paste_from_system_clipboard(&mut self, sender: &ComponentSender<Self>) {
3205        if self.target_dir().is_none() {
3206            return;
3207        }
3208        let s = sender.clone();
3209        read_clipboard_files(move |paths| s.input(TreeMsg::PastePaths(paths)));
3210    }
3211
3212    /// Perform the actual paste of externally-sourced paths as copies.
3213    fn paste_paths(&mut self, paths: Vec<PathBuf>, sender: &ComponentSender<Self>) {
3214        let Some(dir) = self.target_dir() else {
3215            return;
3216        };
3217        if paths.is_empty() {
3218            self.status("Clipboard has no files to paste".to_string(), sender);
3219            return;
3220        }
3221        let mut count = 0usize;
3222        let mut failed: Option<String> = None;
3223        for src in paths {
3224            if src == dir {
3225                continue;
3226            }
3227            match self.ops.copy(&src, &dir) {
3228                Ok(dest) => {
3229                    count += 1;
3230                    self.apply_change(Change::Created { path: dest });
3231                }
3232                Err(err) => {
3233                    failed = Some(err.to_string());
3234                    break;
3235                }
3236            }
3237        }
3238        if let Some(failed) = failed {
3239            self.status(failed, sender);
3240            return;
3241        }
3242        self.status(format!("Pasted {count} item(s) into {}", dir.display()), sender);
3243        self.cursor_snap();
3244    }
3245
3246    /// Route a drag-and-drop onto `target`: copies happen immediately, and
3247    /// moves either happen immediately or ask first, depending on the
3248    /// `tree.confirm_drop_move` setting. No-op drops (onto the item itself or
3249    /// the directory it already lives in) are silently ignored.
3250    fn request_drop_into(
3251        &mut self,
3252        target: PathBuf,
3253        sources: Vec<PathBuf>,
3254        copy: bool,
3255        sender: &ComponentSender<Self>,
3256    ) {
3257        match resolve_drop(&target, sources, copy) {
3258            DropPlan::Ignore => {}
3259            DropPlan::Reject(message) => self.status(message, sender),
3260            DropPlan::Copy(sources) => self.drop_into(&target, &sources, true, sender),
3261            DropPlan::Move(sources) => {
3262                if self.config.confirm_drop_move {
3263                    self.confirm_move(target, sources, sender);
3264                } else {
3265                    self.drop_into(&target, &sources, false, sender);
3266                }
3267            }
3268        }
3269    }
3270
3271    /// Ask before moving a dragged selection into `target`. Only reached when
3272    /// `tree.confirm_drop_move` is set; copying (Ctrl+drag) is non-destructive
3273    /// and never asks.
3274    fn confirm_move(
3275        &mut self,
3276        target: PathBuf,
3277        sources: Vec<PathBuf>,
3278        sender: &ComponentSender<Self>,
3279    ) {
3280        let target_name = target
3281            .file_name()
3282            .map(|n| n.to_string_lossy().into_owned())
3283            .unwrap_or_else(|| target.display().to_string());
3284        let message = match sources.as_slice() {
3285            [single] => {
3286                let name = single
3287                    .file_name()
3288                    .map(|n| n.to_string_lossy().into_owned())
3289                    .unwrap_or_else(|| single.display().to_string());
3290                format!("Move \u{201c}{name}\u{201d} into \u{201c}{target_name}\u{201d}?")
3291            }
3292            many => format!("Move {} items into \u{201c}{target_name}\u{201d}?", many.len()),
3293        };
3294        let dialog = gtk::AlertDialog::builder()
3295            .modal(true)
3296            .message(message)
3297            .detail("Hold Ctrl while dropping to copy instead of move.")
3298            .buttons(["Cancel", "Move"])
3299            .cancel_button(0)
3300            .default_button(1)
3301            .build();
3302        let s = sender.clone();
3303        dialog.choose(Some(&self.parent), None::<&gio::Cancellable>, move |res| {
3304            if matches!(res, Ok(1)) {
3305                s.input(TreeMsg::DropIntoConfirmed { target, sources });
3306            }
3307        });
3308    }
3309
3310    /// Drag-and-drop landed on directory `target`: move (or copy, when
3311    /// `copy`) every path in `sources` into it. Used for both in-tree drags
3312    /// and drops of files from other applications.
3313    fn drop_into(
3314        &mut self,
3315        target: &Path,
3316        sources: &[PathBuf],
3317        copy: bool,
3318        sender: &ComponentSender<Self>,
3319    ) {
3320        let mut count = 0usize;
3321        let mut failed: Option<String> = None;
3322        for src in sources {
3323            if src == target || src.parent() == Some(target) {
3324                continue; // dropping onto its own parent/itself is a no-op
3325            }
3326            if !copy && target.starts_with(src) {
3327                failed = Some("Cannot move a folder into itself".to_string());
3328                break;
3329            }
3330            let result = if copy { self.ops.copy(src, target) } else { self.ops.move_(src, target) };
3331            match result {
3332                Ok(dest) => {
3333                    count += 1;
3334                    self.apply_change(Change::Created { path: dest });
3335                    if !copy {
3336                        self.apply_change(Change::Removed { path: src.clone() });
3337                    }
3338                }
3339                Err(err) => {
3340                    failed = Some(err.to_string());
3341                    break;
3342                }
3343            }
3344        }
3345        if let Some(failed) = failed {
3346            self.status(failed, sender);
3347            return;
3348        }
3349        if count > 0 {
3350            let verb = if copy { "Copied" } else { "Moved" };
3351            self.status(format!("{verb} {count} item(s) into {}", target.display()), sender);
3352        }
3353        self.cursor_snap();
3354    }
3355
3356    fn create_entry(&mut self, is_dir: bool, sender: &ComponentSender<Self>) {
3357        let Some(dir) = self.target_dir() else {
3358            return;
3359        };
3360        let name = fresh_name(&dir, if is_dir { "new folder" } else { "new file" });
3361        let result = if is_dir {
3362            self.ops.create_dir(&dir, &name)
3363        } else {
3364            self.ops.create_file(&dir, &name)
3365        };
3366        match result {
3367            Ok(new_path) => {
3368                self.apply_change(Change::Created { path: new_path.clone() });
3369                self.renaming = Some(new_path.clone());
3370                self.renaming_state.set(true);
3371                self.select(&new_path);
3372            }
3373            Err(err) => self.status(format!("Could not create: {err}"), sender),
3374        }
3375    }
3376
3377    /// The directory new items paste into / are created inside: the cursor's
3378    /// directory itself, or its parent when the cursor is on a file.
3379    fn target_dir(&self) -> Option<PathBuf> {
3380        if let Some(path) = self.cursor_path() {
3381            let is_dir = self.rows.iter().find(|r| r.path == path).map(|r| r.is_dir).unwrap_or(false);
3382            if is_dir {
3383                return Some(path);
3384            }
3385            return path.parent().map(Path::to_path_buf);
3386        }
3387        self.tree.as_ref().map(|t| t.root().to_path_buf())
3388    }
3389
3390    fn apply_change(&mut self, change: Change) {
3391        if let Some(tree) = self.tree.as_mut() {
3392            tree.apply(&change, &StdDirSource);
3393            self.refresh_rows();
3394        }
3395    }
3396
3397    /// Toggle dotfile visibility for the current tree, then re-read every
3398    /// loaded directory so the change takes effect.
3399    fn toggle_hidden(&mut self, sender: &ComponentSender<Self>) {
3400        let show = !self.config.show_hidden;
3401        self.config.show_hidden = show;
3402        if let Some(tree) = self.tree.as_mut() {
3403            tree.set_show_hidden(show);
3404            tree.reload_all(&StdDirSource);
3405            self.refresh_rows();
3406        }
3407        let label = if show { "Showing hidden files." } else { "Hiding hidden files." };
3408        self.status(label.to_string(), sender);
3409    }
3410
3411    /// Change the primary sort key in the current tree.
3412    fn set_sort_key(&mut self, key: SortKey, sender: &ComponentSender<Self>) {
3413        let mut opts = self.config.sort_options();
3414        opts.key = key;
3415        self.config.sort_key = key.as_str().to_owned();
3416        if let Some(tree) = self.tree.as_mut() {
3417            tree.set_sort(opts);
3418            tree.reload_all(&StdDirSource);
3419            self.refresh_rows();
3420        }
3421        self.status(format!("Sorting by {}.", key.as_str()), sender);
3422    }
3423
3424    /// Flip ascending/descending order in the current tree.
3425    fn toggle_sort_direction(&mut self, sender: &ComponentSender<Self>) {
3426        let mut opts = self.config.sort_options();
3427        opts.ascending = !opts.ascending;
3428        self.config.sort_ascending = opts.ascending;
3429        if let Some(tree) = self.tree.as_mut() {
3430            tree.set_sort(opts);
3431            tree.reload_all(&StdDirSource);
3432            self.refresh_rows();
3433        }
3434        let dir = if opts.ascending { "ascending" } else { "descending" };
3435        self.status(format!("Sort order: {dir}."), sender);
3436    }
3437
3438    fn status(&mut self, message: String, sender: &ComponentSender<Self>) {
3439        let _ = sender.output(TreeOutput::Status(message));
3440    }
3441
3442    /// Execute a configured shortcut against the row under the keyboard
3443    /// cursor. The selection is aligned with the cursor first so selection
3444    /// based actions (cut/copy/paste/delete/...) act on that row, exactly as
3445    /// they would from the context menu.
3446    fn run_shortcut(&mut self, target: ShortcutTarget, sender: &ComponentSender<Self>) {
3447        // Pane-level actions (bound to hamburger-menu shortcuts) are not row
3448        // operations; hand them to the app, which owns docks and panes.
3449        if let ShortcutTarget::Builtin(builtin) = &target
3450            && builtin.is_pane_action()
3451        {
3452            let _ = sender.output(TreeOutput::PaneAction(*builtin));
3453            return;
3454        }
3455        // With a row selected the action targets it; with nothing selected
3456        // (the user left-clicked the blank area) directory-targeted actions
3457        // target the open directory.
3458        let has_selection = self.cursor.is_some() || !self.selected.is_empty();
3459        let path = match self.cursor_path() {
3460            Some(path) => path,
3461            None => match self.tree.as_ref().map(|t| t.root().to_path_buf()) {
3462                Some(root) => root,
3463                None => return,
3464            },
3465        };
3466
3467        // Custom commands operate on the cursor row.
3468        if let ShortcutTarget::Command(cmd) = &target {
3469            if !has_selection {
3470                return;
3471            }
3472            let command = action_command(&cmd.command, &path);
3473            match spawn_command(&command) {
3474                Ok(()) => {
3475                    self.status(format!("Ran {} on {}", cmd.command, path.display()), sender);
3476                }
3477                Err(err) => {
3478                    self.status(format!("Could not run {}: {err}", cmd.command), sender);
3479                }
3480            }
3481            return;
3482        }
3483        let ShortcutTarget::Builtin(builtin) = target else {
3484            return;
3485        };
3486        if builtin == BuiltinAction::Separator
3487            || (builtin.is_directory_only() && !path.is_dir())
3488        {
3489            return;
3490        }
3491        if matches!(builtin, BuiltinAction::OpenWithDefault) && path.is_dir() {
3492            return;
3493        }
3494        if builtin == BuiltinAction::ViewThumbnail && !path.is_dir() && !is_thumbnailable(&path) {
3495            return;
3496        }
3497        // Row-specific actions are meaningless with nothing selected.
3498        if !has_selection && needs_selection(builtin) {
3499            return;
3500        }
3501        // Align the selection with the cursor, but don't clobber an existing
3502        // multi-selection the cursor is already part of (so e.g. Delete still
3503        // acts on every selected row).
3504        if has_selection && !self.selected.iter().any(|p| p == &path) {
3505            self.select(&path);
3506        }
3507        // With a multi-selection, fan the action out (trash/delete/properties/
3508        // copy-path act on every selected row), exactly as the context menu does.
3509        let selection = self.selected.clone();
3510        sender.input(action_message(builtin, &path, &selection));
3511    }
3512}
3513
3514// ---------------------------------------------------------------------------
3515// out-of-component helpers
3516// ---------------------------------------------------------------------------
3517
3518fn fresh_name(dir: &Path, stem: &str) -> String {
3519    for n in 0.. {
3520        let name = if n == 0 { stem.to_string() } else { format!("{stem} {n}") };
3521        if !dir.join(&name).exists() {
3522            return name;
3523        }
3524    }
3525    unreachable!("the loop above always finds a free name")
3526}
3527
3528/// Open `path` with its registered default application.
3529///
3530/// Resolves the app from the file's *content type* instead of going through
3531/// `launch_default_for_uri`. The latter falls back to the handler for the
3532/// generic `file:` URI scheme when no app is registered for the type, and
3533/// tree-space itself is often that handler (it registers `x-scheme-handler/file`
3534/// so "Show in folder" requests arrive here) — which turned every open of an
3535/// unassociated file into a new pane. Returns the app's display name.
3536fn open_in_app(path: &Path) -> Result<String, String> {
3537    let content_type = content_type_for(path)
3538        .ok_or_else(|| format!("Could not determine the type of {}", path.display()))?;
3539    let app = gio::AppInfo::default_for_type(&content_type, false)
3540        .ok_or_else(|| format!("No application is registered to open {}", path.display()))?;
3541    let file = gio::File::for_path(path);
3542    app.launch(&[file], None::<&gio::AppLaunchContext>)
3543        .map_err(|err| format!("Could not open {}: {err}", path.display()))?;
3544    Ok(app.display_name().to_string())
3545}
3546
3547/// The content type GIO reports for `path` (e.g. `text/plain`), or `None` if the
3548/// file cannot be queried.
3549///
3550/// Deliberately *not* `content_type_guess_for_tree`: that call exists to find a
3551/// type common to a whole tree and returns an empty list for a single plain
3552/// file, which silently disabled both "Open With..." and its default-app label.
3553fn content_type_for(path: &Path) -> Option<glib::GString> {
3554    let file = gio::File::for_path(path);
3555    let info = file
3556        .query_info(
3557            gio::FILE_ATTRIBUTE_STANDARD_CONTENT_TYPE,
3558            gio::FileQueryInfoFlags::NONE,
3559            None::<&gio::Cancellable>,
3560        )
3561        .ok()?;
3562    info.content_type()
3563}
3564
3565/// The display name of the default app for `path` (used to render the
3566/// "Open With {app}" menu item). `None` when the type cannot be determined or no
3567/// default is registered.
3568fn default_app_label(path: &Path) -> Option<String> {
3569    let content_type = content_type_for(path)?;
3570    gio::AppInfo::default_for_type(&content_type, false).map(|app| {
3571        format!("Open With {}", app.display_name())
3572    })
3573}
3574
3575/// The "Open With..." chooser: a modal dialog listing every application that
3576/// can handle the row, plus the option to choose a custom command.
3577#[allow(deprecated)] // gtk_file_dialog / gio since 4.10; the chooser is still the standard picker
3578fn open_with_dialog(parent: &gtk::Window, path: &Path, sender: &ComponentSender<Tree>) {
3579    let Some(content_type) = content_type_for(path) else {
3580        let _ = sender.output(TreeOutput::Status(format!(
3581            "Could not determine the type of {}",
3582            path.display()
3583        )));
3584        return;
3585    };
3586    let dialog = gtk::AppChooserDialog::for_content_type(
3587        Some(parent),
3588        gtk::DialogFlags::MODAL,
3589        &content_type,
3590    );
3591    dialog.set_title(Some(&format!("Open {} with...", path.display())));
3592
3593    // Show it as a centered overlay layer surface. As a plain toplevel it has
3594    // no *toplevel* parent to be transient for (the panel is a layer surface),
3595    // so a tiling compositor would tile it edge-to-edge instead of floating it
3596    // like a dialog. Without layer-shell support, leave it a normal dialog
3597    // (transient for the panel) so it still behaves modally.
3598    if gtk4_layer_shell::is_supported() && !dialog.is_layer_window() {
3599        dialog.init_layer_shell();
3600        dialog.set_layer(Layer::Overlay);
3601        dialog.set_keyboard_mode(KeyboardMode::Exclusive);
3602        dialog.set_default_size(540, 620);
3603        // No anchors: the compositor centers the surface.
3604    }
3605
3606    let dialog = dialog.clone();
3607    let path = path.to_path_buf();
3608    let s = sender.clone();
3609    dialog.connect_response(move |dialog, response| {
3610        if response == gtk::ResponseType::Ok
3611            && let Some(app) = dialog.app_info()
3612        {
3613            let file = gio::File::for_path(&path);
3614            match app.launch(&[file], None::<&gio::AppLaunchContext>) {
3615                Ok(()) => {
3616                    let _ = s.output(TreeOutput::Status(format!(
3617                        "Opened {} with {}",
3618                        path.display(),
3619                        app.display_name()
3620                    )));
3621                }
3622                Err(err) => {
3623                    let _ = s.output(TreeOutput::Status(format!(
3624                        "Could not open {} with {}: {err}",
3625                        path.display(),
3626                        app.display_name()
3627                    )));
3628                }
3629            }
3630        }
3631        dialog.close();
3632    });
3633    dialog.present();
3634}
3635
3636/// Put `text` on the system clipboard (used by the "Copy Path" menu items).
3637fn set_clipboard_text(text: &str) {
3638    if let Some(display) = gdk::Display::default() {
3639        display.clipboard().set_text(text);
3640    }
3641}
3642
3643/// The standard interop MIME type for a file-list clipboard payload. Read by
3644/// browsers, terminals and other file managers.
3645const URI_LIST_MIME: &str = "text/uri-list";
3646
3647/// The GNOME/Nautilus file-clipboard format, which additionally encodes the
3648/// operation ("copy"/"cut") on its first line. Nautilus and other GTK apps
3649/// advertise this alongside `text/uri-list`.
3650const GNOME_CLIP_MIME: &str = "x-special/gnome-copied-files";
3651
3652/// Asynchronously read a file-list payload from the system clipboard and call
3653/// `done` with the decoded absolute paths (empty when the clipboard holds
3654/// anything else). Prefers the portable `text/uri-list`; falls back to the
3655/// GNOME format when only that is offered. The read completes on the main loop.
3656fn read_clipboard_files(done: impl FnOnce(Vec<PathBuf>) + 'static) {
3657    let Some(display) = gdk::Display::default() else {
3658        done(Vec::new());
3659        return;
3660    };
3661    let clipboard = display.clipboard();
3662    let formats = clipboard.formats();
3663    let has_uri = formats.contain_mime_type(URI_LIST_MIME);
3664    let has_gnome = formats.contain_mime_type(GNOME_CLIP_MIME);
3665    if !has_uri && !has_gnome {
3666        done(Vec::new());
3667        return;
3668    }
3669    clipboard.read_text_async(gio::Cancellable::NONE, move |result| {
3670        let text = result.ok().flatten().unwrap_or_default();
3671        done(parse_clipboard_uris(&text, has_gnome && !has_uri));
3672    });
3673}
3674
3675/// Decode paths from a file-list clipboard payload. Accepts both `text/uri-list`
3676/// (CRLF- or LF-separated, `#` comments, optional blanks) and
3677/// `x-special/gnome-copied-files` (a leading `copy`/`cut` verb line). `verb_line`
3678/// selects whether to expect and skip that GNOME preamble. Non-`file:` URIs
3679/// (e.g. `http:`) and relative entries are dropped. Pure and unit-testable.
3680fn parse_clipboard_uris(text: &str, verb_line: bool) -> Vec<PathBuf> {
3681    text.lines()
3682        .map(str::trim)
3683        .filter(|line| !line.is_empty() && !line.starts_with('#'))
3684        .filter_map(|line| {
3685            if verb_line && (line.eq_ignore_ascii_case("copy") || line.eq_ignore_ascii_case("cut")) {
3686                return None;
3687            }
3688            let (path, _host) = glib::filename_from_uri(line).ok()?;
3689            path.is_absolute().then_some(path)
3690        })
3691        .collect()
3692}
3693
3694fn spawn_command(cmd: &[String]) -> Result<(), std::io::Error> {
3695    if cmd.is_empty() {
3696        return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, "empty command"));
3697    }
3698    let mut process = std::process::Command::new(&cmd[0]);
3699    for arg in &cmd[1..] {
3700        process.arg(arg);
3701    }
3702    process.spawn().map(|_| ())
3703}
3704
3705#[cfg(test)]
3706mod tests {
3707    use super::*;
3708
3709    #[test]
3710    fn media_detection_is_extension_based_and_case_insensitive() {
3711        for name in ["a.JPG", "b.jpeg", "c.png", "d.webp", "e.HEIC", "f.tiff"] {
3712            assert_eq!(media_kind(Path::new(name)), Some(MediaKind::Image), "{name}");
3713        }
3714        for name in ["loop.GIF", "x.gif"] {
3715            assert_eq!(media_kind(Path::new(name)), Some(MediaKind::Gif), "{name}");
3716        }
3717        for name in ["clip.mp4", "b.MKV", "c.webm", "d.mov", "e.OGV"] {
3718            assert_eq!(media_kind(Path::new(name)), Some(MediaKind::Video), "{name}");
3719        }
3720        for name in ["song.MP3", "b.flac", "c.wav", "d.m4a", "e.opus", "f.oga"] {
3721            if cfg!(feature = "audio") {
3722                assert_eq!(media_kind(Path::new(name)), Some(MediaKind::Audio), "{name}");
3723                assert!(is_thumbnailable(Path::new(name)), "{name}");
3724            } else {
3725                // Without the `audio` feature, audio files are not previewable.
3726                assert_eq!(media_kind(Path::new(name)), None, "{name}");
3727            }
3728        }
3729        for name in ["a.svg", "b.txt", "c.rs", "noext", "d.png.bak"] {
3730            assert_eq!(media_kind(Path::new(name)), None, "{name}");
3731            assert!(!is_thumbnailable(Path::new(name)), "{name}");
3732        }
3733    }
3734
3735    #[test]
3736    fn thumbnail_width_tracks_the_panel_and_stays_positive() {
3737        let panel = PanelConfig { width: 300, margin: 0, ..PanelConfig::default() };
3738        assert_eq!(thumbnail_content_width(panel), 276);
3739
3740        let panel = PanelConfig { width: 500, margin: 10, ..PanelConfig::default() };
3741        assert_eq!(thumbnail_content_width(panel), 456);
3742
3743        let tiny = PanelConfig { width: 40, margin: 0, ..PanelConfig::default() };
3744        assert_eq!(thumbnail_content_width(tiny), 64);
3745    }
3746
3747    #[test]
3748    fn accelerator_normalization_supports_both_syntaxes() {
3749        // The friendly `+` form is rewritten to GTK's native angle-bracket form.
3750        assert_eq!(normalize_accelerator("Ctrl+x"), "<Control>x");
3751        assert_eq!(normalize_accelerator("Ctrl+Shift+m"), "<Control><Shift>m");
3752        assert_eq!(normalize_accelerator("Shift+Delete"), "<Shift>Delete");
3753        assert_eq!(normalize_accelerator("ctrl+alt+t"), "<Control><Alt>t");
3754        // Native syntax and bare keys pass through untouched.
3755        assert_eq!(normalize_accelerator("<Control>x"), "<Control>x");
3756        assert_eq!(normalize_accelerator("F2"), "F2");
3757        assert_eq!(normalize_accelerator("Delete"), "Delete");
3758    }
3759
3760    fn row(path: &str) -> VisibleRow {
3761        VisibleRow {
3762            path: PathBuf::from(path),
3763            name: path.rsplit('/').next().unwrap().to_string(),
3764            depth: 0,
3765            is_dir: false,
3766            is_symlink: false,
3767            expanded: false,
3768            has_children: false,
3769            matches: false,
3770        }
3771    }
3772
3773    #[test]
3774    fn parse_clipboard_uris_handles_both_formats() {
3775        // Portable uri-list: CRLF-separated, with a comment and blank line.
3776        let uri_list = "#comment\r\nfile:///tmp/a.txt\r\n\r\nfile:///tmp/b%20c.txt\r\n";
3777        assert_eq!(
3778            parse_clipboard_uris(uri_list, false),
3779            vec![PathBuf::from("/tmp/a.txt"), PathBuf::from("/tmp/b c.txt")]
3780        );
3781        // GNOME format: a leading verb line that must be skipped.
3782        let gnome = "cut\nfile:///home/u/x\n";
3783        assert_eq!(parse_clipboard_uris(gnome, true), vec![PathBuf::from("/home/u/x")]);
3784        // A stray verb line without the flag is not treated specially.
3785        assert_eq!(parse_clipboard_uris("copy\n", false), Vec::<PathBuf>::new());
3786        // Non-file and relative entries are dropped.
3787        assert_eq!(
3788            parse_clipboard_uris("https://example.com/x\nrelative/path\nfile:///ok\n", false),
3789            vec![PathBuf::from("/ok")]
3790        );
3791        assert!(parse_clipboard_uris("", false).is_empty());
3792    }
3793
3794    #[test]
3795    fn icon_name_matches_extensions_case_insensitively() {
3796        assert_eq!(icon_name(&row("/a/Photo.JPG")), "image-x-generic-symbolic");
3797        assert_eq!(icon_name(&row("/a/song.Flac")), "audio-x-generic-symbolic");
3798        assert_eq!(icon_name(&row("/a/clip.mkv")), "video-x-generic-symbolic");
3799        assert_eq!(icon_name(&row("/a/archive.tar")), "package-x-generic-symbolic");
3800        assert_eq!(icon_name(&row("/a/doc.pdf")), "application-pdf-symbolic");
3801        assert_eq!(icon_name(&row("/a/notes.txt")), "text-x-generic-symbolic");
3802        // A directory named like an image is still a folder.
3803        let mut dir = row("/a/pictures");
3804        dir.is_dir = true;
3805        assert_eq!(icon_name(&dir), "folder-symbolic");
3806        dir.expanded = true;
3807        assert_eq!(icon_name(&dir), "folder-open-symbolic");
3808        // No extension / a dotfile must not panic or misclassify.
3809        assert_eq!(icon_name(&row("/a/.bashrc")), "text-x-generic-symbolic");
3810    }
3811
3812    #[test]
3813    fn resolve_drop_filters_noops_and_detects_cycles() {
3814        let target = Path::new("/a/b");
3815        // Dropping a path onto itself or its own parent is a no-op.
3816        assert_eq!(resolve_drop(target, vec![PathBuf::from("/a/b")], false), DropPlan::Ignore);
3817        assert_eq!(resolve_drop(target, vec![PathBuf::from("/a/b/c")], false), DropPlan::Ignore);
3818        // Moving a directory into itself is rejected.
3819        assert!(matches!(
3820            resolve_drop(target, vec![PathBuf::from("/a")], false),
3821            DropPlan::Reject(_)
3822        ));
3823        // A normal move is confirmed; a copy is immediate.
3824        assert!(matches!(
3825            resolve_drop(target, vec![PathBuf::from("/x/y")], false),
3826            DropPlan::Move(_)
3827        ));
3828        assert!(matches!(
3829            resolve_drop(target, vec![PathBuf::from("/x/y")], true),
3830            DropPlan::Copy(_)
3831        ));
3832    }
3833
3834    #[test]
3835    fn drag_paths_uses_the_selection_only_when_the_row_is_in_it() {
3836        let sel = vec![PathBuf::from("/r/a"), PathBuf::from("/r/b")];
3837        // A row inside a multi-selection drags the whole selection.
3838        assert_eq!(drag_paths(&sel, Path::new("/r/b")), sel);
3839        // A row outside it drags only itself.
3840        assert_eq!(
3841            drag_paths(&sel, Path::new("/r/c")),
3842            vec![PathBuf::from("/r/c")]
3843        );
3844        // A single-row selection drags just that row.
3845        assert_eq!(
3846            drag_paths(&[PathBuf::from("/r/a")], Path::new("/r/a")),
3847            vec![PathBuf::from("/r/a")]
3848        );
3849        // No selection at all still drags the pressed row.
3850        assert_eq!(
3851            drag_paths(&[], Path::new("/r/z")),
3852            vec![PathBuf::from("/r/z")]
3853        );
3854    }
3855
3856    #[test]
3857    fn index_range_is_inclusive_and_order_independent() {
3858        assert_eq!(index_range(2, 2).collect::<Vec<_>>(), vec![2]);
3859        assert_eq!(index_range(1, 4).collect::<Vec<_>>(), vec![1, 2, 3, 4]);
3860        // Selecting upward (anchor below the cursor) yields the same range.
3861        assert_eq!(index_range(4, 1).collect::<Vec<_>>(), vec![1, 2, 3, 4]);
3862    }
3863
3864    #[test]
3865    fn format_uri_list_serializes_file_uris() {
3866        let text = format_uri_list(&[PathBuf::from("/tmp/a.txt"), PathBuf::from("/tmp/b c.txt")]);
3867        assert_eq!(text, "file:///tmp/a.txt\r\nfile:///tmp/b%20c.txt\r\n");
3868        assert!(format_uri_list(&[]).is_empty());
3869        // Round-trips through the reader used for pastes.
3870        assert_eq!(
3871            parse_clipboard_uris(&text, false),
3872            vec![PathBuf::from("/tmp/a.txt"), PathBuf::from("/tmp/b c.txt")]
3873        );
3874    }
3875
3876    #[test]
3877    fn typeahead_finds_the_next_matching_row_and_wraps() {
3878        let rows = vec![row("/r/apple"), row("/r/banana"), row("/r/apricot"), row("/r/cherry")];
3879        // From the top, "ap" -> apple (index 0); nowhere to go but wrap
3880        // is only used to *find* the first match from the cursor onward.
3881        assert_eq!(typeahead_target(&rows, "ap", None), Some(0));
3882        assert_eq!(typeahead_target(&rows, "ap", Some(0)), Some(2)); // apricot
3883        assert_eq!(typeahead_target(&rows, "b", Some(2)), Some(1)); // wraps
3884        assert_eq!(typeahead_target(&rows, "zzz", None), None);
3885    }
3886
3887    #[test]
3888    fn typeahead_buffer_resets_after_a_pause_and_caps_length() {
3889        let mut ta = TypeAhead::default();
3890        let t0 = std::time::Instant::now();
3891        assert_eq!(ta.push('a', t0).as_deref(), Some("a"));
3892        assert_eq!(ta.push('b', t0 + Duration::from_millis(100)).as_deref(), Some("ab"));
3893        // A long pause starts a fresh prefix.
3894        assert_eq!(ta.push('c', t0 + Duration::from_millis(2000)).as_deref(), Some("c"));
3895        // Non-searchable characters are ignored.
3896        assert_eq!(ta.push('\u{1}', t0), None);
3897    }
3898}