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