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