Skip to main content

tree_space/ui/
tree.rs

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