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