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