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