Skip to main content

tree_space/ui/
app.rs

1//! The root component: hosts the panel as one or more dock windows — a left
2//! dock, a right dock, or both — each containing its own stack of split panes,
3//! and applies single-instance launch requests.
4//!
5//! Dock model
6//! ──────────
7//! [`Dock`] is one layer-shell window anchored to one screen edge. It owns a
8//! vertical stack of [`Pane`]s. A [`Pane`] is a top bar (hamburger menu +
9//! editable path entry), an optional filter row, and its own `Tree` component.
10//! Panes have globally stable ids (never reused after close) so toolbar/tree
11//! messages survive dock rebuilds and pane removals.
12//!
13//! The first dock is the *primary* dock: its window is the relm4 root window
14//! (rendered by `view!`), its container is `App::pane_container`. Additional
15//! docks are created on demand when a launch request names a side not present
16//! yet; they are ordinary `gtk::Window`s built imperatively.
17//!
18//! Split-pane layout within a dock
19//! ───────────────────────────────
20//! The GTK widget tree is rebuilt from scratch when a dock's pane count
21//! changes. Panes are nested with `gtk::Paned`:
22//!
23//!   panes = [A, B, C]
24//!   widget tree = Box { Paned { A, Paned { B, C } } }
25//!
26//! Each pane is a vertical `Box { toolbar, filter-bar, tree }`. The tree
27//! widget's `vexpand` is set to `true` so each half fills its allocation.
28//! Every pane has its own filter bar (hidden until requested from that pane's
29//! top-bar menu); it applies only to that pane's tree.
30//!
31//! Launch requests
32//! ───────────────
33//! A second `tree-space` invocation is forwarded over the instance socket (see
34//! [`crate::ipc`]) and arrives as [`AppMsg::LaunchRequest`]:
35//!   * no path            → show/hide the whole panel (toggle), or with
36//!     `--side X` ensure a dock exists on X and show it
37//!   * with path(s)       → add a pane for each path (never a duplicate of an
38//!     already-open directory), then show the panel
39
40use std::{cell::{Cell, RefCell}, collections::HashMap, path::{Path, PathBuf}, rc::Rc};
41
42use gtk4_layer_shell::{Edge, KeyboardMode, Layer, LayerShell};
43use relm4::gtk::{gdk, gio, glib, prelude::*};
44use relm4::prelude::*;
45
46use crate::cmd::{Command, WidthArg};
47use crate::config::{
48    Bookmark, BuiltinAction, Config, ContextAction, PANEL_MAX_WIDTH, PANEL_MIN_WIDTH, PanelConfig,
49    PanelLayer, PanelSide, SessionState, ShortcutTarget, StartupRoot, bookmark_file_path,
50    load_stylesheet, save_bookmarks_to_path,
51};
52use crate::fs::SortKey;
53use crate::ipc;
54use crate::ui::bookmarks::{self, BookmarkEvent};
55use crate::ui::toolbar::{PaneShortcuts, Toolbar, ToolbarInit, ToolbarMsg, ToolbarOutput};
56use crate::ui::tree::{Tree, TreeInit, TreeOutput, TreeMsg};
57
58/// One entry in a pane's history: a directory it showed, or the bookmarks view.
59#[derive(Debug, Clone, PartialEq, Eq)]
60enum ViewEntry {
61    Dir(PathBuf),
62    Bookmarks,
63}
64
65/// A pane's back/forward navigation history.
66///
67/// Views are recorded in visit order with a cursor into the list. Going back
68/// moves the cursor left, forward moves it right; visiting a *new* view (not via
69/// back/forward) truncates the forward tail and appends. This is a pure data
70/// structure so the rules can be unit-tested without a display.
71#[derive(Debug, Default, Clone, PartialEq, Eq)]
72struct NavHistory {
73    entries: Vec<ViewEntry>,
74    /// Index of the current entry. Meaningless while `entries` is empty.
75    cursor: usize,
76    /// Set while a back/forward navigation is in flight, so the `RootChanged`
77    /// (or view switch) it produces is not itself recorded as a new visit.
78    navigating: bool,
79}
80
81impl NavHistory {
82    /// Record a newly shown view. A repeat of the current entry is ignored; any
83    /// forward history is dropped. While a back/forward is navigating this is a
84    /// no-op (the target is already in the list).
85    fn record(&mut self, entry: ViewEntry) {
86        if self.navigating {
87            return;
88        }
89        if self.entries.get(self.cursor).is_some_and(|cur| cur == &entry) {
90            return;
91        }
92        if self.entries.is_empty() {
93            self.entries.push(entry);
94            self.cursor = 0;
95            return;
96        }
97        self.entries.truncate(self.cursor + 1);
98        self.entries.push(entry);
99        self.cursor = self.entries.len() - 1;
100    }
101
102    fn can_back(&self) -> bool {
103        self.cursor > 0 && self.cursor < self.entries.len()
104    }
105
106    fn can_forward(&self) -> bool {
107        !self.entries.is_empty() && self.cursor + 1 < self.entries.len()
108    }
109
110    /// Step back one entry and return it, arming `navigating`.
111    fn back(&mut self) -> Option<ViewEntry> {
112        if !self.can_back() {
113            return None;
114        }
115        self.cursor -= 1;
116        self.navigating = true;
117        Some(self.entries[self.cursor].clone())
118    }
119
120    /// Step forward one entry and return it, arming `navigating`.
121    fn forward(&mut self) -> Option<ViewEntry> {
122        if !self.can_forward() {
123            return None;
124        }
125        self.cursor += 1;
126        self.navigating = true;
127        Some(self.entries[self.cursor].clone())
128    }
129
130    /// Clear the in-flight flag once the resulting view has been shown.
131    fn finish_navigation(&mut self) {
132        self.navigating = false;
133    }
134}
135
136/// One split view inside a dock: its own top bar above its own body.
137pub struct Pane {
138    id: u64,
139    toolbar: Controller<Toolbar>,
140    tree: Controller<Tree>,
141    /// The directory this pane currently shows, if any. Owned here (rather than
142    /// in a parallel `Vec` on the dock) so a pane and its root can never drift.
143    root: Option<PathBuf>,
144    /// The canonical form of `root`, cached so launch deduplication does not
145    /// `canonicalize` every pane on every request. Refreshed on `RootChanged`.
146    canonical_root: Option<PathBuf>,
147    /// Back/forward history for this pane's root changes.
148    history: NavHistory,
149    /// This pane's own filter row (hidden until requested from its top bar).
150    /// Only this pane's tree receives the filter.
151    filter_bar: gtk::Box,
152    filter_entry: gtk::SearchEntry,
153    /// Switches the pane body between the tree and the bookmarks view. A pane
154    /// created without a directory starts on the bookmarks page (a "new panel"
155    /// suggesting places to jump to).
156    body: gtk::Stack,
157    /// The bookmarks list container (the bookmarks page of `body`), refilled
158    /// whenever the list changes.
159    bookmarks_list: gtk::Box,
160    /// Case-insensitive filter applied to the bookmarks list. Kept per pane so
161    /// the filter bar works on the bookmarks view too.
162    bookmark_filter: String,
163    /// The pane overlay: its main child is the `{ toolbar, filter_bar?, body }`
164    /// vertical box, and its overlay children hold panes' popovers (e.g. the
165    /// path-entry completion dropdown). Built once and reused across split/close
166    /// rebuilds, so the tree and toolbar widgets never need to be reparented
167    /// (which would trip `gtk_box_append: child has a parent`).
168    widget: gtk::Overlay,
169}
170
171impl Pane {
172    /// Record `root` as this pane's directory and refresh its cached canonical
173    /// form.
174    fn set_root(&mut self, root: PathBuf) {
175        self.canonical_root = std::fs::canonicalize(&root).ok();
176        self.root = Some(root);
177    }
178
179    /// Show the bookmarks view in this pane's body (and switch its hamburger).
180    fn show_bookmarks(&self) {
181        self.body.set_visible_child_name("bookmarks");
182        self.toolbar.emit(ToolbarMsg::SetBookmarks(true));
183    }
184
185    /// Show the tree in this pane's body (and switch its hamburger).
186    fn show_tree(&self) {
187        self.body.set_visible_child_name("tree");
188        self.toolbar.emit(ToolbarMsg::SetBookmarks(false));
189    }
190
191    /// Whether this pane is currently on the bookmarks view.
192    fn on_bookmarks(&self) -> bool {
193        self.body.visible_child_name().as_deref() == Some("bookmarks")
194    }
195}
196
197/// One layer-shell dock window anchored to a screen edge, with its panes.
198struct Dock {
199    side: PanelSide,
200    /// The dock's window. For the primary dock this is the relm4 root window;
201    /// for additional docks an imperatively-built `gtk::Window`.
202    window: gtk::Window,
203    /// The vertical box holding the pane stack (for the primary dock this is
204    /// `App::pane_container`, referenced by `view!`).
205    container: gtk::Box,
206    panes: Vec<Pane>,
207    /// Id of the pane most recently interacted with in this dock.
208    active_pane: Option<u64>,
209}
210
211/// What an invocation (or the hamburger "Collapse") wants to do to dock
212/// visibility. Pure data, so the show/hide rules can be unit-tested without a
213/// display (see the `visibility` tests below).
214#[derive(Debug, Clone, Copy, PartialEq, Eq)]
215pub enum VisibilityIntent {
216    /// Show every dock.
217    ShowAll,
218    /// Hide every dock.
219    HideAll,
220    /// Toggle every dock: hide them all when any is visible, else show all.
221    ToggleAll,
222    /// Show the named side, creating its dock if needed.
223    ShowSide(PanelSide),
224    /// Hide the named side (a no-op when that dock does not exist).
225    HideSide(PanelSide),
226    /// Toggle the named side, creating and showing it if it does not exist.
227    ToggleSide(PanelSide),
228}
229
230/// The resolved effect of a [`VisibilityIntent`]: which sides should be shown
231/// afterwards, and whether a missing dock should be created to satisfy it.
232///
233/// This is the entire visibility state machine as a pure function; the app
234/// method [`App::apply_visibility`] only carries out the plan.
235#[derive(Debug, Clone, PartialEq, Eq)]
236pub struct VisibilityPlan {
237    /// Sides that should be visible afterwards.
238    pub show: Vec<PanelSide>,
239    /// Sides that should be hidden afterwards.
240    pub hide: Vec<PanelSide>,
241    /// Create and seed a dock on this side when it does not already exist.
242    pub create: Option<PanelSide>,
243    /// Seed an empty dock with a default pane (so showing it is not an empty
244    /// shell). Only used with `create`/`show`.
245    pub seed: bool,
246}
247
248/// Resolve `intent` against the dock sides that currently exist and the sides
249/// currently shown. `show`/`hide` name the sides to act on; `create` names a
250/// side whose dock must be created first.
251pub fn resolve_visibility(
252    intent: VisibilityIntent,
253    existing: &[PanelSide],
254    shown: &[PanelSide],
255) -> VisibilityPlan {
256    match intent {
257        VisibilityIntent::ShowAll => VisibilityPlan {
258            show: existing.to_vec(),
259            hide: Vec::new(),
260            create: None,
261            seed: true,
262        },
263        VisibilityIntent::HideAll => VisibilityPlan {
264            show: Vec::new(),
265            hide: PanelSide::ALL.to_vec(),
266            create: None,
267            seed: false,
268        },
269        VisibilityIntent::ToggleAll => {
270            if shown.is_empty() {
271                VisibilityPlan {
272                    show: existing.to_vec(),
273                    hide: Vec::new(),
274                    create: None,
275                    seed: true,
276                }
277            } else {
278                VisibilityPlan {
279                    show: Vec::new(),
280                    hide: PanelSide::ALL.to_vec(),
281                    create: None,
282                    seed: false,
283                }
284            }
285        }
286        VisibilityIntent::ShowSide(side) => VisibilityPlan {
287            show: vec![side],
288            hide: Vec::new(),
289            create: (!existing.contains(&side)).then_some(side),
290            seed: true,
291        },
292        VisibilityIntent::HideSide(side) => VisibilityPlan {
293            show: Vec::new(),
294            hide: vec![side],
295            create: None,
296            seed: false,
297        },
298        VisibilityIntent::ToggleSide(side) => {
299            if !existing.contains(&side) {
300                VisibilityPlan {
301                    show: vec![side],
302                    hide: Vec::new(),
303                    create: Some(side),
304                    seed: true,
305                }
306            } else if shown.contains(&side) {
307                VisibilityPlan {
308                    show: Vec::new(),
309                    hide: vec![side],
310                    create: None,
311                    seed: false,
312                }
313            } else {
314                VisibilityPlan {
315                    show: vec![side],
316                    hide: Vec::new(),
317                    create: None,
318                    seed: true,
319                }
320            }
321        }
322    }
323}
324
325/// Messages handled by the app itself.
326#[derive(Debug)]
327pub enum AppMsg {
328    /// Output from the top bar of pane `id`.
329    PaneToolbar { id: u64, out: ToolbarOutput },
330    /// Output from the tree of pane `id`.
331    PaneTree { id: u64, out: TreeOutput },
332    /// A pane-menu shortcut fired anywhere in pane `id` (the tree, the path
333    /// entry, or the filter bar), resolved from the `[pane_menu]` accelerators.
334    PaneShortcut { id: u64, action: ContextAction },
335    /// The folder-picker launched for pane `id` returned.
336    OpenFolderPicked { id: u64, path: Option<PathBuf> },
337    /// A new `tree-space` invocation was forwarded by the instance socket.
338    LaunchRequest { command: Command },
339    /// Split pane `id`'s dock, seeding the new pane from `id`'s root.
340    SplitFromPane { id: u64 },
341    /// Split pane `id`'s dock with a specific root (context-menu "Open in Split
342    /// View").
343    OpenSplitFrom { id: u64, root: PathBuf },
344    /// Open `root` in a pane on the dock opposite pane `id`'s (context-menu
345    /// "In {other} panel").
346    OpenOppositeFrom { id: u64, root: PathBuf },
347    /// Remove pane `id` (closes the dock, or the whole app, when it is the
348    /// last pane).
349    ClosePane { id: u64 },
350    /// Pane `id`'s filter text changed (empty string clears the filter).
351    FilterChanged { id: u64, filter: String },
352    /// Close pane `id`'s filter bar and cancel its filter.
353    FilterClosed { id: u64 },
354    /// Quit requested (window close). Tears media down before the window closes.
355    Shutdown,
356    /// Change the width of `side`'s dock by `delta` px (drag / keyboard).
357    ResizeBy { side: PanelSide, delta: i32 },
358    /// Persist the width after an interactive resize finishes.
359    ResizeCommit,
360    /// Move keyboard focus into pane `id`'s tree (after it is allocated).
361    FocusPane { id: u64 },
362    /// The bookmarks view in pane `id` reported a user action.
363    BookmarkEvent { id: u64, event: BookmarkEvent },
364    /// The bookmark editor for index `index` was saved with the new values.
365    BookmarkEditSaved { index: usize, name: String, path: PathBuf },
366}
367
368/// The init payload: the parsed invocation plus the instance socket, if this
369/// process became the single server.
370pub struct AppInit {
371    pub command: Command,
372    pub listener: Option<std::os::unix::net::UnixListener>,
373}
374
375/// Root state.
376pub struct App {
377    config: Config,
378    status: String,
379    window: gtk::Window,
380    file_dialog: gtk::FileDialog,
381    /// All dock windows. Index 0 is the primary dock (the root window).
382    docks: Vec<Dock>,
383    /// The primary dock's container child (referenced by `view!`).
384    pane_container: gtk::Box,
385    /// The panel's side for launches that do not specify one.
386    primary_side: PanelSide,
387    /// Next pane id; bumped on every pane creation so ids never repeat.
388    next_id: u64,
389    /// Whether any dock window is currently shown (drives no-arg toggling).
390    visible: bool,
391    /// Per-side dock width in pixels. Starts from session state (falling back to
392    /// `[panel] width`) and changes on interactive resize — one entry per side,
393    /// so the two docks are sized independently without parallel scalar fields.
394    widths: HashMap<PanelSide, u32>,
395    /// Most recently opened root, kept so a width save never drops it.
396    last_root: Option<PathBuf>,
397    /// The bookmarks list, loaded from the bookmarks file and written back
398    /// whenever it changes.
399    bookmarks: Vec<Bookmark>,
400    /// Monotonic id for the debounced width save: a scheduled save only writes
401    /// if it is still the latest (no `SourceId` juggling — removing a one-shot
402    /// source that has already fired panics).
403    save_generation: Rc<Cell<u64>>,
404}
405
406#[relm4::component(pub)]
407impl SimpleComponent for App {
408    type Init = AppInit;
409    type Input = AppMsg;
410    type Output = ();
411
412    view! {
413        gtk::Window {
414            set_default_size: (model.config.panel.width as i32, 520),
415
416            gtk::Box {
417                set_orientation: gtk::Orientation::Vertical,
418                set_spacing: 0,
419                add_css_class: "panel",
420
421                append: &model.pane_container,
422
423                append: status = &gtk::Label {
424                    #[watch]
425                    set_label: &model.status,
426                    set_halign: gtk::Align::Start,
427                    set_ellipsize: gtk::pango::EllipsizeMode::End,
428                    add_css_class: "status-bar",
429                }
430            }
431        }
432    }
433
434    fn init(
435        init: Self::Init,
436        root: Self::Root,
437        sender: ComponentSender<Self>,
438    ) -> ComponentParts<Self> {
439        let loaded = Config::load();
440        let mut config = loaded.config;
441        let parent = root.clone();
442
443        // Bookmarks are runtime data kept in their own file beside the config.
444        // Materialize it (home by default) on first launch.
445        let (bookmarks, bookmark_problem) = crate::config::load_bookmarks(&config.bookmarks);
446        if let Err(problem) = crate::config::ensure_bookmarks_file(&config.bookmarks, &bookmarks) {
447            eprintln!("tree-space: could not create bookmarks file: {problem:?}");
448        }
449
450        let session = SessionState::load();
451        // An interactive resize is sticky across launches; the configured
452        // `[panel] width` is only the initial default for each side.
453        let default_width = config.panel.width;
454        let widths: HashMap<PanelSide, u32> = [
455            (
456                PanelSide::Left,
457                session.left_width.unwrap_or(default_width).clamp(PANEL_MIN_WIDTH, PANEL_MAX_WIDTH),
458            ),
459            (
460                PanelSide::Right,
461                session.right_width.unwrap_or(default_width).clamp(PANEL_MIN_WIDTH, PANEL_MAX_WIDTH),
462            ),
463        ]
464        .into_iter()
465        .collect();
466
467        let primary_side = init.command.side.unwrap_or(config.panel.side);
468        // Keep `config.panel.width` meaningful for the primary dock.
469        config.panel.width = widths[&primary_side];
470        let primary_width = config.panel.width;
471        // Build the primary dock's initial panes from the invocation. With no
472        // roots, resolve the configured startup directory; a `bookmarks` startup
473        // (the default) resolves to none, so the pane opens the bookmarks view.
474        let roots = if !init.command.roots.is_empty() {
475            init.command.roots.clone()
476        } else if config.startup.is_bookmarks() {
477            Vec::new()
478        } else {
479            let last = session.last_root.clone().filter(|p| p.is_dir());
480            let fallback = config
481                .startup
482                .resolve(last)
483                .filter(|p| p.is_dir())
484                .or_else(home_dir);
485            if let Some(p) = fallback { vec![p] } else { Vec::new() }
486        };
487
488        let mut panes: Vec<Pane> = Vec::new();
489        let mut next_id = 0u64;
490        for root in roots.iter() {
491            let pane =
492                make_pane(&config, parent.clone(), next_id, primary_side, primary_width, sender.clone());
493            pane.tree.emit(TreeMsg::OpenRoot(root.clone()));
494            panes.push(pane);
495            panes.last_mut().unwrap().set_root(root.clone());
496            next_id += 1;
497        }
498        // Guarantee at least one pane. With no roots it opens the bookmarks view
499        // (a "new panel" suggesting places to jump to).
500        if panes.is_empty() {
501            let mut pane = make_pane(
502                &config,
503                parent.clone(),
504                next_id,
505                primary_side,
506                primary_width,
507                sender.clone(),
508            );
509            pane.show_bookmarks();
510            pane.history.record(ViewEntry::Bookmarks);
511            panes.push(pane);
512            next_id += 1;
513        }
514
515        let pane_container = gtk::Box::new(gtk::Orientation::Vertical, 0);
516        pane_container.set_vexpand(true);
517        fill_pane_container(&pane_container, &panes);
518
519        let mut status = String::new();
520        if let Some(problem) = loaded.problem {
521            status = format!("config: {problem:?}");
522        } else if let Some(problem) = bookmark_problem {
523            status = format!("bookmarks: {problem:?}");
524        }
525
526        let mut model = App {
527            config,
528            status,
529            window: root.clone(),
530            file_dialog: gtk::FileDialog::new(),
531            docks: vec![Dock {
532                side: primary_side,
533                window: root.clone(),
534                container: pane_container.clone(),
535                panes,
536                active_pane: None,
537            }],
538            pane_container,
539            primary_side,
540            next_id,
541            visible: !init.command.hidden,
542            widths,
543            last_root: session.last_root.clone(),
544            bookmarks,
545            save_generation: Rc::new(Cell::new(0)),
546        };
547
548        init_layer_window(&model.window, &model.config, primary_side, primary_width);
549        install_css(&model.config);
550        // Interactive resize only makes sense for a docked layer surface; a
551        // plain fallback window is resized like any other window.
552        if layer_shell_available() {
553            attach_resize_controls(&model.window, primary_side, &sender);
554        }
555
556        // Serve the instance socket: every new `tree-space` invocation delivers
557        // a Command here, hopped onto the UI thread by the main context.
558        if let Some(listener) = init.listener {
559            let ipc_sender = sender.input_sender().clone();
560            ipc::spawn_listener(listener, move |command| {
561                let sender = ipc_sender.clone();
562                glib::MainContext::default().invoke(move || {
563                    let _ = sender.send(AppMsg::LaunchRequest { command });
564                });
565            });
566        }
567
568        let widgets = view_output!();
569
570        // Populate every pane's bookmarks list now that the order is settled.
571        model.refresh_all_bookmarks(&sender);
572
573        // Quitting while a video thumbnail is playing is a shutdown race:
574        // `GtkMediaFile` renders through GStreamer's GL sink, and exiting with
575        // that context live lets NVIDIA's at-exit EGL teardown unmap GPU memory
576        // while the `gstglcontext` thread is still issuing GL calls (SIGSEGV in
577        // the driver). Intercept the first close, tear the media down on the
578        // main loop, and only let the window close once that has settled.
579        let shutting_down = Rc::new(Cell::new(false));
580        let flag = shutting_down.clone();
581        let shutdown_sender = sender.clone();
582        model.window.connect_close_request(move |_| {
583            if flag.get() {
584                return glib::Propagation::Proceed;
585            }
586            flag.set(true);
587            shutdown_sender.input(AppMsg::Shutdown);
588            glib::Propagation::Stop
589        });
590
591        ComponentParts { model, widgets }
592    }
593
594    fn update(&mut self, msg: Self::Input, sender: ComponentSender<Self>) {
595        match msg {
596            AppMsg::PaneToolbar { id, out } => match out {
597                ToolbarOutput::OpenFolder => {
598                    self.set_active(id);
599                    self.show_open_folder(id, &sender);
600                }
601                ToolbarOutput::NavigateTo(path) => {
602                    self.open_root_in_pane(id, path);
603                }
604                ToolbarOutput::FilterRequested => {
605                    self.set_active(id);
606                    self.open_filter(id);
607                }
608                ToolbarOutput::SplitView => {
609                    sender.input(AppMsg::SplitFromPane { id });
610                }
611                ToolbarOutput::Collapse => {
612                    self.collapse_dock(id);
613                }
614                ToolbarOutput::ClosePane => {
615                    sender.input(AppMsg::ClosePane { id });
616                }
617                ToolbarOutput::PaneItem(target) => {
618                    self.set_active(id);
619                    // A pane-level builtin (Up/Back/Forward, ...) is performed by
620                    // the app; everything else is a tree action.
621                    if let ShortcutTarget::Builtin(action) = &target
622                        && action.is_pane_action()
623                    {
624                        self.dispatch_pane_builtin(id, *action, &sender);
625                    } else if let Some((di, pi)) = self.dock_pane_of(id)
626                        && let Some(msg) = pane_item_message(&target)
627                    {
628                        self.docks[di].panes[pi].tree.emit(msg);
629                    }
630                }
631            },
632
633            AppMsg::PaneTree { id, out } => match out {
634                TreeOutput::Status(message) => {
635                    self.set_active(id);
636                    self.status = message;
637                }
638                TreeOutput::OpenFolderRequested => {
639                    self.set_active(id);
640                    self.show_open_folder(id, &sender);
641                }
642                TreeOutput::OpenSplit(root) => {
643                    self.set_active(id);
644                    sender.input(AppMsg::OpenSplitFrom { id, root });
645                }
646                TreeOutput::OpenOpposite(root) => {
647                    self.set_active(id);
648                    sender.input(AppMsg::OpenOppositeFrom { id, root });
649                }
650                TreeOutput::PaneAction(action) => {
651                    self.dispatch_pane_builtin(id, action, &sender);
652                }
653                TreeOutput::AddBookmark(path) => {
654                    self.set_active(id);
655                    self.add_bookmark(path);
656                    self.refresh_all_bookmarks(&sender);
657                }
658                TreeOutput::RootChanged(root) => {
659                    self.set_active(id);
660                    if let Some((di, pi)) = self.dock_pane_of(id) {
661                        let pane = &mut self.docks[di].panes[pi];
662                        // Record the visit for back/forward, then clear the
663                        // in-flight flag a back/forward navigation sets (so its
664                        // own root change is not recorded as a fresh visit).
665                        pane.history.record(ViewEntry::Dir(root.clone()));
666                        pane.history.finish_navigation();
667                        pane.show_tree();
668                        pane.set_root(root.clone());
669                        pane.toolbar.emit(ToolbarMsg::SetRoot(root.clone()));
670                    }
671                    self.status = String::new();
672                    // Persist the most recently opened root for the next launch.
673                    self.last_root = Some(root);
674                    self.persist_session();
675                }
676            },
677
678            AppMsg::PaneShortcut { id, action } => {
679                self.run_pane_shortcut(id, action, &sender);
680            }
681
682            AppMsg::OpenFolderPicked { id, path: Some(path) } => {
683                self.open_root_in_pane(id, path);
684            }
685            AppMsg::OpenFolderPicked { path: None, .. } => {}
686
687            AppMsg::LaunchRequest { command } => {
688                self.handle_launch(command, &sender);
689            }
690
691            AppMsg::SplitFromPane { id } => {
692                // A new panel with no path opens the bookmarks view, so the
693                // split suggests places to jump to.
694                let di = self.dock_pane_of(id).map(|(di, _)| di).unwrap_or(0);
695                self.add_pane(di, None, &sender);
696            }
697
698            AppMsg::OpenSplitFrom { id, root } => {
699                if let Some((di, _pi)) = self.dock_pane_of(id) {
700                    self.add_pane(di, Some(root), &sender);
701                }
702            }
703
704            AppMsg::OpenOppositeFrom { id, root } => {
705                if let Some((di, _pi)) = self.dock_pane_of(id) {
706                    let side = self.docks[di].side.opposite();
707                    let target = self.ensure_dock(side, false, &sender);
708                    self.add_pane(target, Some(root), &sender);
709                    // A freshly created dock starts hidden; reveal it (unless
710                    // the panel is currently toggled off).
711                    self.set_docks_visible(self.visible);
712                }
713            }
714
715            AppMsg::ClosePane { id } => {
716                let Some((di, pi)) = self.dock_pane_of(id) else {
717                    return;
718                };
719                self.docks[di].panes.remove(pi);
720                if self.docks[di].active_pane == Some(id) {
721                    self.docks[di].active_pane = self.docks[di].panes.last().map(|p| p.id);
722                }
723                // The program only exits once the *last* pane anywhere closes.
724                let panes_left: usize = self.docks.iter().map(|d| d.panes.len()).sum();
725                if panes_left == 0 {
726                    self.window.close();
727                    return;
728                }
729                if self.docks[di].panes.is_empty() {
730                    if di == 0 {
731                        // The primary dock is the relm4 root window; closing it
732                        // tears the whole app down. Hide it instead, and show it
733                        // again on the next launch/toggle.
734                        self.visible = false;
735                        self.docks[di].window.set_visible(false);
736                    } else {
737                        let dock = self.docks.remove(di);
738                        dock.window.close();
739                    }
740                } else {
741                    fill_pane_container(&self.docks[di].container.clone(), &self.docks[di].panes);
742                }
743            }
744
745            AppMsg::FilterChanged { id, filter } => {
746                if let Some((di, pi)) = self.dock_pane_of(id) {
747                    if self.docks[di].panes[pi].on_bookmarks() {
748                        self.docks[di].panes[pi].bookmark_filter = filter;
749                        self.refresh_all_bookmarks(&sender);
750                    } else {
751                        self.docks[di].panes[pi].tree.emit(TreeMsg::SetFilter(filter));
752                    }
753                }
754            }
755            AppMsg::FilterClosed { id } => {
756                if let Some((di, pi)) = self.dock_pane_of(id) {
757                    let pane = &mut self.docks[di].panes[pi];
758                    pane.filter_entry.set_text("");
759                    pane.tree.emit(TreeMsg::SetFilter(String::new()));
760                    pane.bookmark_filter.clear();
761                    pane.filter_bar.set_visible(false);
762                }
763                self.refresh_all_bookmarks(&sender);
764            }
765
766            AppMsg::FocusPane { id } => {
767                if let Some((di, pi)) = self.dock_pane_of(id) {
768                    self.docks[di].panes[pi].tree.emit(TreeMsg::Focus);
769                }
770            }
771
772            AppMsg::Shutdown => {
773                // Release every video stream synchronously, walking the widget
774                // trees rather than the model so that panes already detached
775                // from `self.docks` (a closed last pane keeps its widgets alive
776                // until the window is destroyed) are covered too.
777                for dock in &self.docks {
778                    stop_video_widgets(dock.window.upcast_ref());
779                }
780                // Drop the cached media (and thumbnails) on the main loop.
781                for dock in &self.docks {
782                    for pane in &dock.panes {
783                        pane.tree.emit(TreeMsg::Shutdown);
784                    }
785                }
786                // Let the message above run and GStreamer wind its GL context
787                // down before the window closes and the process exits.
788                let window = self.window.clone();
789                glib::timeout_add_local_once(std::time::Duration::from_millis(SHUTDOWN_GRACE_MS), move || {
790                    window.close();
791                });
792            }
793
794            AppMsg::ResizeBy { side, delta } => self.resize_by(side, delta),
795            AppMsg::ResizeCommit => self.persist_session(),
796
797            AppMsg::BookmarkEvent { id, event } => match event {
798                BookmarkEvent::Open(path) => self.open_bookmark(id, path, &sender),
799                BookmarkEvent::Edit(index) => self.edit_bookmark(id, index, &sender),
800                BookmarkEvent::Action { path, target } => {
801                    self.run_bookmark_action(id, path, target);
802                }
803                BookmarkEvent::Delete(index) => {
804                    if index < self.bookmarks.len() {
805                        self.bookmarks.remove(index);
806                        self.save_bookmarks();
807                        self.refresh_all_bookmarks(&sender);
808                    }
809                }
810            },
811            AppMsg::BookmarkEditSaved { index, name, path } => {
812                if let Some(bookmark) = self.bookmarks.get_mut(index) {
813                    bookmark.name = name;
814                    bookmark.path = path;
815                    self.save_bookmarks();
816                    self.refresh_all_bookmarks(&sender);
817                }
818            }
819        }
820    }
821}
822
823impl App {
824    /// Apply a forwarded launch request.
825    fn handle_launch(&mut self, command: Command, sender: &ComponentSender<Self>) {
826        let hidden = command.hidden;
827
828        // A width change is a side effect that never touches visibility. It
829        // targets the named side, or the primary side when none is given.
830        if let Some(width) = command.width {
831            let side = command.side.unwrap_or(self.primary_side);
832            match width {
833                WidthArg::To(px) => self.set_width(side, px),
834                WidthArg::By(delta) => self.resize_by(side, delta),
835            }
836            self.persist_session();
837            if command.roots.is_empty() && command.reveal.is_empty() {
838                return;
839            }
840        }
841
842        if command.roots.is_empty() && command.reveal.is_empty() {
843            // `--hidden` always means "hide", never toggle. Otherwise the
844            // command is a show/toggle scoped to a side or to every dock.
845            let intent = match (command.side, hidden) {
846                (Some(side), true) => VisibilityIntent::HideSide(side),
847                (Some(side), false) => VisibilityIntent::ToggleSide(side),
848                (None, true) => VisibilityIntent::HideAll,
849                (None, false) => VisibilityIntent::ToggleAll,
850            };
851            self.apply_visibility(intent, sender);
852            return;
853        }
854
855        for root in &command.roots {
856            if self.find_pane_with_dir(root).is_some() {
857                // The directory is already open in some pane: never spawn a
858                // duplicate — just keep it. The panel is shown below.
859                continue;
860            }
861            let side = command.side.unwrap_or(self.primary_side);
862            let di = self.ensure_dock(side, false, sender);
863            self.add_pane(di, Some(root.clone()), sender);
864        }
865
866        // Reveal each `--select`/file argument: open a pane rooted at the
867        // path's parent and select the path inside it. Reveals always open a
868        // fresh pane on the default side (they carry a specific target, so
869        // reusing an existing pane would lose it).
870        for (root, select) in command.reveal_targets() {
871            if !root.is_dir() {
872                continue;
873            }
874            let side = command.side.unwrap_or(self.primary_side);
875            let di = self.ensure_dock(side, false, sender);
876            let id = self.add_pane(di, Some(root), sender);
877            if let Some(select) = select
878                && let Some((di, pi)) = self.dock_pane_of(id)
879            {
880                self.docks[di].panes[pi].tree.emit(TreeMsg::SelectPath(select));
881            }
882        }
883        // Show the panel, unless this launch asked to stay hidden.
884        self.set_docks_visible(!hidden);
885    }
886
887    /// Carry out a [`VisibilityIntent`] using the pure [`resolve_visibility`]
888    /// plan. Creates a dock when the intent calls for it and seeds an empty one
889    /// so "show" never reveals an empty shell.
890    fn apply_visibility(&mut self, intent: VisibilityIntent, sender: &ComponentSender<Self>) {
891        let existing: Vec<PanelSide> = self.docks.iter().map(|d| d.side).collect();
892        let shown: Vec<PanelSide> = self
893            .docks
894            .iter()
895            .filter(|d| d.window.is_visible())
896            .map(|d| d.side)
897            .collect();
898        let plan = resolve_visibility(intent, &existing, &shown);
899
900        if let Some(side) = plan.create {
901            self.ensure_dock(side, plan.seed, sender);
902        }
903        for side in &plan.show {
904            // A newly created dock starts hidden; reveal it. An emptied primary
905            // dock is re-seeded so showing it is not an empty shell.
906            let di = self.ensure_dock(*side, plan.seed, sender);
907            self.docks[di].window.set_visible(true);
908        }
909        for side in &plan.hide {
910            if let Some(di) = self.dock_of_side(*side) {
911                self.docks[di].window.set_visible(false);
912            }
913        }
914        // Any side not named by the plan keeps its current visibility.
915        self.refresh_visible();
916        // When a side was just shown, hand keyboard focus to its top pane so
917        // the panel is immediately usable.
918        let focus = plan
919            .show
920            .iter()
921            .filter_map(|side| self.dock_of_side(*side))
922            .filter_map(|di| self.docks[di].panes.last())
923            .map(|pane| pane.tree.emit(TreeMsg::Focus))
924            .next();
925        let _ = focus;
926    }
927
928    /// Set `id` as the active pane in whichever dock holds it.
929    fn set_active(&mut self, id: u64) {
930        if let Some((di, _pi)) = self.dock_pane_of(id) {
931            self.docks[di].active_pane = Some(id);
932        }
933    }
934
935    /// Show the filter bar for pane `id` and focus its entry. The bar was just
936    /// mapped, so an immediate `grab_focus()` no-ops (GTK applies visibility on
937    /// the next layout pass) and keystrokes would land in the tree; defer it.
938    fn open_filter(&mut self, id: u64) {
939        if let Some((di, pi)) = self.dock_pane_of(id) {
940            let pane = &mut self.docks[di].panes[pi];
941            pane.filter_bar.set_visible(true);
942            let entry = pane.filter_entry.clone();
943            glib::idle_add_local_once(move || {
944                entry.grab_focus();
945            });
946        }
947    }
948
949    /// Append `path` to the bookmarks (skipping a duplicate path) and persist.
950    fn add_bookmark(&mut self, path: PathBuf) {
951        if self.bookmarks.iter().any(|b| b.path == path) {
952            self.status = format!("{} is already bookmarked", path.display());
953            return;
954        }
955        self.bookmarks.push(Bookmark {
956            name: Bookmark::default_name(&path),
957            path: path.clone(),
958        });
959        self.save_bookmarks();
960        self.status = format!("Bookmarked {}", path.display());
961    }
962
963    /// Show `path` in pane `id`: switch its body to the tree and load the
964    /// directory (leaving the bookmarks view, if it was showing).
965    fn open_root_in_pane(&mut self, id: u64, path: PathBuf) {
966        if let Some((di, pi)) = self.dock_pane_of(id) {
967            self.docks[di].panes[pi].show_tree();
968            self.docks[di].panes[pi].tree.emit(TreeMsg::OpenRoot(path));
969            self.set_active(id);
970        }
971    }
972
973    /// Jump pane `id` to a bookmark's directory, replacing its bookmarks view
974    /// (or its current directory) with that folder.
975    fn open_bookmark(&mut self, id: u64, path: PathBuf, _sender: &ComponentSender<Self>) {
976        let path = crate::config::expand_bookmark_path(&path);
977        if !path.is_dir() {
978            self.status = format!("{} is not a directory", path.display());
979            return;
980        }
981        self.open_root_in_pane(id, path);
982    }
983
984    /// Run a bookmark's inherited context action against its directory without
985    /// opening it. `Open` navigates (a directory) or launches (a file); other
986    /// path-safe builtins are sent to the pane's tree, which acts on the
987    /// explicit path; custom commands run against the path.
988    fn run_bookmark_action(&mut self, id: u64, path: PathBuf, target: ShortcutTarget) {
989        let path = crate::config::expand_bookmark_path(&path);
990        let Some((di, pi)) = self.dock_pane_of(id) else {
991            return;
992        };
993        match target {
994            ShortcutTarget::Builtin(BuiltinAction::Open) => {
995                if path.is_dir() {
996                    self.open_root_in_pane(id, path);
997                } else {
998                    self.docks[di].panes[pi].tree.emit(TreeMsg::OpenWithDefault(path));
999                }
1000            }
1001            ShortcutTarget::Builtin(action) => {
1002                if let Some(msg) = crate::ui::tree::path_action_message(action, &path) {
1003                    self.docks[di].panes[pi].tree.emit(msg);
1004                }
1005            }
1006            ShortcutTarget::Command(cmd) => {
1007                self.docks[di].panes[pi]
1008                    .tree
1009                    .emit(TreeMsg::RunCommand { command: cmd.command, path });
1010            }
1011        }
1012    }
1013
1014    /// Switch pane `id` to the bookmarks view (the hamburger "Bookmarks" item).
1015    fn show_bookmarks_view(&mut self, id: u64) {
1016        if let Some((di, pi)) = self.dock_pane_of(id) {
1017            self.docks[di].panes[pi].show_bookmarks();
1018            self.docks[di].panes[pi].history.record(ViewEntry::Bookmarks);
1019            self.set_active(id);
1020        }
1021    }
1022
1023    /// Open the bookmark editor for the bookmark at `index`, parented to the
1024    /// window of pane `id`.
1025    fn edit_bookmark(&mut self, id: u64, index: usize, sender: &ComponentSender<Self>) {
1026        let Some(bookmark) = self.bookmarks.get(index).cloned() else {
1027            return;
1028        };
1029        let Some((di, _pi)) = self.dock_pane_of(id) else { return };
1030        let parent = self.docks[di].window.clone();
1031        let sender = sender.clone();
1032        let path = crate::config::expand_bookmark_path(&bookmark.path);
1033        bookmarks::show_bookmark_editor(&parent, &bookmark.name, &path, move |name, path| {
1034            sender.input(AppMsg::BookmarkEditSaved { index, name, path });
1035        });
1036    }
1037
1038    /// Save the bookmarks list to its file, reporting any failure.
1039    fn save_bookmarks(&mut self) {
1040        let path = bookmark_file_path(&self.config.bookmarks.file);
1041        if let Err(err) = save_bookmarks_to_path(&path, &self.bookmarks) {
1042            self.status = format!("Could not save bookmarks: {err:?}");
1043        }
1044    }
1045
1046    /// Refill every pane's bookmarks list after the list changes. Panes showing
1047    /// the view update in place; hidden ones are ready when next shown. Each
1048    /// pane's own filter and menu config are applied.
1049    fn refresh_all_bookmarks(&mut self, sender: &ComponentSender<Self>) {
1050        let context = self.config.context_menu.clone();
1051        let extras = self.config.bookmarks.context.clone();
1052        let bookmarks = self.bookmarks.clone();
1053        for di in 0..self.docks.len() {
1054            for pi in 0..self.docks[di].panes.len() {
1055                let pane = &self.docks[di].panes[pi];
1056                let menu = bookmarks::BookmarkMenuConfig {
1057                    context: &context,
1058                    extras: &extras,
1059                    side: self.docks[di].side,
1060                };
1061                let id = pane.id;
1062                let filter = pane.bookmark_filter.clone();
1063                let sender = sender.clone();
1064                let on_event: Rc<dyn Fn(BookmarkEvent)> =
1065                    Rc::new(move |event| sender.input(AppMsg::BookmarkEvent { id, event }));
1066                bookmarks::fill_bookmarks(&pane.bookmarks_list, &bookmarks, &filter, &menu, on_event);
1067            }
1068        }
1069    }
1070
1071    /// Run a pane-level builtin (`Split View`, `Open Folder...`, `Filter...`,
1072    /// `Collapse`, `Close Pane`) against pane `id`. Shared by the toolbar and by
1073    /// keyboard shortcuts forwarded up from the tree.
1074    fn dispatch_pane_builtin(
1075        &mut self,
1076        id: u64,
1077        action: BuiltinAction,
1078        sender: &ComponentSender<Self>,
1079    ) {
1080        self.set_active(id);
1081        match action {
1082            BuiltinAction::OpenFolder => self.show_open_folder(id, sender),
1083            BuiltinAction::Filter => self.open_filter(id),
1084            BuiltinAction::SplitView => sender.input(AppMsg::SplitFromPane { id }),
1085            BuiltinAction::Up => self.go_up(id),
1086            BuiltinAction::Back => self.go_back(id),
1087            BuiltinAction::Forward => self.go_forward(id),
1088            BuiltinAction::Collapse => self.collapse_dock(id),
1089            BuiltinAction::ClosePane => sender.input(AppMsg::ClosePane { id }),
1090            BuiltinAction::ToggleBookmarks => self.show_bookmarks_view(id),
1091            _ => {}
1092        }
1093    }
1094
1095    /// Open the parent directory of pane `id`'s current root (`Up One Level`).
1096    /// A no-op at the filesystem root.
1097    fn go_up(&mut self, id: u64) {
1098        let Some((di, pi)) = self.dock_pane_of(id) else {
1099            return;
1100        };
1101        let Some(parent) = self.docks[di].panes[pi]
1102            .root
1103            .as_deref()
1104            .and_then(Path::parent)
1105            .map(Path::to_path_buf)
1106        else {
1107            return;
1108        };
1109        self.docks[di].panes[pi].tree.emit(TreeMsg::OpenRoot(parent));
1110    }
1111
1112    /// Step pane `id` back one entry in its history and show it.
1113    fn go_back(&mut self, id: u64) {
1114        let Some((di, pi)) = self.dock_pane_of(id) else {
1115            return;
1116        };
1117        if let Some(target) = self.docks[di].panes[pi].history.back() {
1118            self.show_view_entry(id, target);
1119        }
1120    }
1121
1122    /// Step pane `id` forward one entry in its history and show it.
1123    fn go_forward(&mut self, id: u64) {
1124        let Some((di, pi)) = self.dock_pane_of(id) else {
1125            return;
1126        };
1127        if let Some(target) = self.docks[di].panes[pi].history.forward() {
1128            self.show_view_entry(id, target);
1129        }
1130    }
1131
1132    /// Show a history entry in pane `id`: a directory (load it in the tree) or
1133    /// the bookmarks view. A back/forward to the bookmarks view produces no
1134    /// `RootChanged`, so its navigation flag is cleared here.
1135    fn show_view_entry(&mut self, id: u64, entry: ViewEntry) {
1136        let Some((di, pi)) = self.dock_pane_of(id) else {
1137            return;
1138        };
1139        match entry {
1140            ViewEntry::Dir(path) => {
1141                self.docks[di].panes[pi].show_tree();
1142                self.docks[di].panes[pi].tree.emit(TreeMsg::OpenRoot(path));
1143            }
1144            ViewEntry::Bookmarks => {
1145                self.docks[di].panes[pi].show_bookmarks();
1146                self.docks[di].panes[pi].history.finish_navigation();
1147            }
1148        }
1149        self.set_active(id);
1150    }
1151
1152    /// Run a pane-menu shortcut resolved anywhere in pane `id`. Pane builtins go
1153    /// to [`Self::dispatch_pane_builtin`]; view builtins and custom commands are
1154    /// sent to the pane's tree (which targets the open directory).
1155    fn run_pane_shortcut(
1156        &mut self,
1157        id: u64,
1158        action: ContextAction,
1159        sender: &ComponentSender<Self>,
1160    ) {
1161        self.set_active(id);
1162        let Some(target) = ShortcutTarget::from_action(&action) else {
1163            return;
1164        };
1165        if let ShortcutTarget::Builtin(builtin) = &target
1166            && builtin.is_pane_action()
1167        {
1168            self.dispatch_pane_builtin(id, *builtin, sender);
1169            return;
1170        }
1171        if let Some((di, pi)) = self.dock_pane_of(id)
1172            && let Some(msg) = pane_item_message(&target)
1173        {
1174            self.docks[di].panes[pi].tree.emit(msg);
1175        }
1176    }
1177
1178    /// Locate `(dock_index, pane_index)` for a pane id.
1179    fn dock_pane_of(&self, id: u64) -> Option<(usize, usize)> {
1180        self.docks.iter().enumerate().find_map(|(di, dock)| {
1181            dock.panes
1182                .iter()
1183                .position(|pane| pane.id == id)
1184                .map(|pi| (di, pi))
1185        })
1186    }
1187
1188    /// Find a pane (across all docks) that already shows `path`, comparing
1189    /// canonical forms so that spellings like `./x` and `/a/x` dedupe. The
1190    /// canonical form of each pane root is cached on the pane, so this does no
1191    /// filesystem work for the common case.
1192    fn find_pane_with_dir(&self, path: &PathBuf) -> Option<(usize, usize)> {
1193        let canonical = std::fs::canonicalize(path).ok().unwrap_or_else(|| path.clone());
1194        self.docks.iter().enumerate().find_map(|(di, dock)| {
1195            dock.panes.iter().enumerate().find_map(|(pi, pane)| {
1196                if pane.canonical_root.as_deref() == Some(canonical.as_path()) {
1197                    Some((di, pi))
1198                } else {
1199                    None
1200                }
1201            })
1202        })
1203    }
1204
1205    /// Index of the dock on `side`, if one exists.
1206    fn dock_of_side(&self, side: PanelSide) -> Option<usize> {
1207        self.docks.iter().position(|dock| dock.side == side)
1208    }
1209
1210    /// Hide the entire dock that holds pane `id` (the toolbar "Collapse"
1211    /// action). The dock keeps its panes, so it comes back on the next show.
1212    fn collapse_dock(&mut self, id: u64) {
1213        if let Some((di, _)) = self.dock_pane_of(id) {
1214            self.set_dock_visible(di, false);
1215        }
1216    }
1217
1218    /// Show/hide one dock.
1219    fn set_dock_visible(&mut self, di: usize, visible: bool) {
1220        self.docks[di].window.set_visible(visible);
1221        self.refresh_visible();
1222    }
1223
1224    /// Show/hide every dock window.
1225    fn set_docks_visible(&mut self, visible: bool) {
1226        for dock in &self.docks {
1227            dock.window.set_visible(visible);
1228            // When the panel is shown, hand keyboard focus to its active pane.
1229            if visible
1230                && let Some(pane) = dock.panes.last()
1231            {
1232                pane.tree.emit(TreeMsg::Focus);
1233            }
1234        }
1235        self.visible = visible;
1236    }
1237
1238    /// Recomputed flag: true while at least one dock is shown. Drives the
1239    /// no-argument toggle.
1240    fn refresh_visible(&mut self) {
1241        self.visible = self.docks.iter().any(|dock| dock.window.is_visible());
1242    }
1243
1244    /// The current width of `side`'s dock.
1245    fn width_for(&self, side: PanelSide) -> u32 {
1246        self.widths.get(&side).copied().unwrap_or(self.config.panel.width)
1247    }
1248
1249    /// Resize `side`'s dock to `width` px, clamped, telling its trees so inline
1250    /// thumbnails re-measure. Saves are debounced.
1251    fn set_width(&mut self, side: PanelSide, width: u32) {
1252        let width = width.clamp(PANEL_MIN_WIDTH, PANEL_MAX_WIDTH);
1253        if width == self.width_for(side) {
1254            return;
1255        }
1256        self.widths.insert(side, width);
1257        let panel = PanelConfig { width, ..self.config.panel };
1258        for dock in self.docks.iter().filter(|dock| dock.side == side) {
1259            apply_window_width(&dock.window, width);
1260            for pane in &dock.panes {
1261                pane.tree.emit(TreeMsg::SetPanel(panel));
1262            }
1263        }
1264        // Keep the config's copy in step with the primary dock.
1265        if side == self.primary_side {
1266            self.config.panel.width = width;
1267        }
1268        self.schedule_save();
1269    }
1270
1271    /// Change `side`'s width by `delta` px (negative narrows).
1272    fn resize_by(&mut self, side: PanelSide, delta: i32) {
1273        let target = (self.width_for(side) as i64 + delta as i64)
1274            .clamp(PANEL_MIN_WIDTH as i64, PANEL_MAX_WIDTH as i64) as u32;
1275        self.set_width(side, target);
1276    }
1277
1278    /// The session state to persist: last root plus both dock widths.
1279    fn session_state(&self) -> SessionState {
1280        SessionState {
1281            last_root: self.last_root.clone(),
1282            left_width: Some(self.width_for(PanelSide::Left)),
1283            right_width: Some(self.width_for(PanelSide::Right)),
1284        }
1285    }
1286
1287    /// Persist session state now. Invalidates any pending debounced save.
1288    fn persist_session(&mut self) {
1289        self.save_generation.set(self.save_generation.get().wrapping_add(1));
1290        if let Err(err) = self.session_state().save() {
1291            self.status = format!("Could not save session: {err}");
1292        }
1293    }
1294
1295    /// Persist session state after a short quiet period, coalescing the many
1296    /// width changes a drag produces into a single write. A later change bumps
1297    /// the generation, so only the newest scheduled save actually writes.
1298    fn schedule_save(&mut self) {
1299        let generation = self.save_generation.get().wrapping_add(1);
1300        self.save_generation.set(generation);
1301        let current = self.save_generation.clone();
1302        let state = self.session_state();
1303        glib::timeout_add_local_once(std::time::Duration::from_millis(SAVE_DEBOUNCE_MS), move || {
1304            if current.get() == generation {
1305                let _ = state.save();
1306            }
1307        });
1308    }
1309
1310    /// Return the index of the dock on `side`, creating it (with a seeded pane
1311    /// from session state) when missing. The primary dock is always the
1312    /// config/default side.
1313    fn ensure_dock(&mut self, side: PanelSide, seed: bool, sender: &ComponentSender<Self>) -> usize {
1314        if let Some((di, _)) = self.docks.iter().enumerate().find(|(_, d)| d.side == side) {
1315            if seed && self.docks[di].panes.is_empty() {
1316                // The dock exists but was emptied; give it a pane again.
1317                let startup = self.config.startup.clone();
1318                if let Some(root) = default_root(&startup) {
1319                    self.add_pane(di, Some(root), sender);
1320                }
1321            }
1322            return di;
1323        }
1324        let config = &self.config;
1325        let width = self.width_for(side);
1326        let window = gtk::Window::new();
1327        init_layer_window(&window, config, side, width);
1328        window.set_default_size(width as i32, 520);
1329        if layer_shell_available() {
1330            attach_resize_controls(&window, side, sender);
1331        }
1332        // The window's child is the box holding the pane stack.
1333        let outer = gtk::Box::new(gtk::Orientation::Vertical, 0);
1334        outer.set_vexpand(true);
1335        let container = gtk::Box::new(gtk::Orientation::Vertical, 0);
1336        container.set_vexpand(true);
1337        outer.append(&container);
1338        window.set_child(Some(&outer));
1339
1340        let di = self.docks.len();
1341        self.docks.push(Dock {
1342            side,
1343            window,
1344            container,
1345            panes: Vec::new(),
1346            active_pane: None,
1347        });
1348        if seed {
1349            let startup = self.config.startup.clone();
1350            if let Some(root) = default_root(&startup) {
1351                self.add_pane(di, Some(root), sender);
1352            } else {
1353                // No directory: seed the dock with a bookmarks pane.
1354                self.add_pane(di, None, sender);
1355            }
1356        }
1357        di
1358    }
1359
1360    /// Append a pane showing `root` to dock `di`, returning its id. A `None`
1361    /// root opens the bookmarks view instead of a directory.
1362    fn add_pane(&mut self, di: usize, root: Option<PathBuf>, sender: &ComponentSender<Self>) -> u64 {
1363        let id = self.next_id;
1364        self.next_id += 1;
1365        let parent = self.docks[di].window.clone();
1366        let side = self.docks[di].side;
1367        let width = self.width_for(side);
1368        let pane = make_pane(&self.config, parent, id, side, width, sender.clone());
1369        let mut pane = pane;
1370        match &root {
1371            Some(root) => pane.tree.emit(TreeMsg::OpenRoot(root.clone())),
1372            None => {
1373                pane.show_bookmarks();
1374                pane.history.record(ViewEntry::Bookmarks);
1375            }
1376        }
1377        if let Some(root) = &root {
1378            pane.set_root(root.clone());
1379        }
1380        let dock = &mut self.docks[di];
1381        dock.panes.push(pane);
1382        dock.active_pane = Some(id);
1383        let container = dock.container.clone();
1384        fill_pane_container(&container, &dock.panes);
1385        // Give the new pane keyboard focus once GTK has allocated it, so the
1386        // app (and each new split) is usable without a click.
1387        let focus_sender = sender.clone();
1388        glib::idle_add_local_once(move || focus_sender.input(AppMsg::FocusPane { id }));
1389        // Populate the new pane's bookmarks list.
1390        self.refresh_all_bookmarks(sender);
1391        id
1392    }
1393
1394    fn show_open_folder(&self, id: u64, sender: &ComponentSender<Self>) {
1395        let sender = sender.clone();
1396        self.file_dialog.select_folder(
1397            None::<&gtk::Window>,
1398            None::<&gio::Cancellable>,
1399            move |result| {
1400                let path = result.ok().and_then(|file| file.path());
1401                sender.input(AppMsg::OpenFolderPicked { id, path });
1402            },
1403        );
1404    }
1405}
1406
1407// ---------------------------------------------------------------------------
1408// helpers
1409// ---------------------------------------------------------------------------
1410
1411/// Map a configurable pane-menu item to the tree message that performs it.
1412/// Pane-level view actions have dedicated messages; custom commands are run
1413/// through the tree's shortcut path (which targets the open directory).
1414fn pane_item_message(target: &ShortcutTarget) -> Option<TreeMsg> {
1415    match target {
1416        ShortcutTarget::Builtin(action) => match action {
1417            BuiltinAction::ToggleHidden => Some(TreeMsg::ToggleHidden),
1418            BuiltinAction::SortByName => Some(TreeMsg::SetSortKey(SortKey::Name)),
1419            BuiltinAction::SortBySize => Some(TreeMsg::SetSortKey(SortKey::Size)),
1420            BuiltinAction::SortByModified => Some(TreeMsg::SetSortKey(SortKey::Modified)),
1421            BuiltinAction::SortByType => Some(TreeMsg::SetSortKey(SortKey::Type)),
1422            BuiltinAction::ToggleSortAscending => Some(TreeMsg::ToggleSortDirection),
1423            other => Some(TreeMsg::RunShortcut {
1424                target: ShortcutTarget::Builtin(*other),
1425            }),
1426        },
1427        ShortcutTarget::Command(_) => Some(TreeMsg::RunShortcut { target: target.clone() }),
1428    }
1429}
1430
1431fn make_pane(
1432    config: &Config,
1433    parent: gtk::Window,
1434    id: u64,
1435    side: PanelSide,
1436    width: u32,
1437    sender: ComponentSender<App>,
1438) -> Pane {
1439    // The pane overlay: the toolbar's completion dropdown is added to it so it
1440    // can draw over the tree while the entry keeps keyboard focus.
1441    let overlay = gtk::Overlay::new();
1442    let toolbar = Toolbar::builder()
1443        .launch(ToolbarInit {
1444            overlay: overlay.clone(),
1445            pane_menu: config.pane_menu.clone(),
1446            bookmarks_menu: config.bookmarks.menu.clone(),
1447        })
1448        .forward(sender.input_sender(), move |out| AppMsg::PaneToolbar { id, out });
1449    let tree = Tree::builder()
1450        .launch(TreeInit {
1451            config: config.tree.clone(),
1452            parent,
1453            menu: config.context_menu.clone(),
1454            side,
1455            panel: PanelConfig { width, ..config.panel },
1456        })
1457        .forward(sender.input_sender(), move |out| AppMsg::PaneTree { id, out });
1458
1459    let widget = gtk::Box::new(gtk::Orientation::Vertical, 0);
1460    widget.append(toolbar.widget());
1461
1462    // ── per-pane filter row (hidden until requested) ────────────────────────
1463    let filter_bar = gtk::Box::new(gtk::Orientation::Horizontal, 4);
1464    filter_bar.add_css_class("filter-bar");
1465    filter_bar.set_visible(false);
1466
1467    let filter_entry = gtk::SearchEntry::new();
1468    filter_entry.set_placeholder_text(Some("Filter..."));
1469    filter_entry.set_hexpand(true);
1470    filter_entry.set_valign(gtk::Align::Center);
1471    filter_entry.add_css_class("filter-entry");
1472    {
1473        let s = sender.clone();
1474        filter_entry.connect_search_changed(move |entry| {
1475            s.input(AppMsg::FilterChanged {
1476                id,
1477                filter: entry.text().to_string(),
1478            });
1479        });
1480    }
1481    filter_bar.append(&filter_entry);
1482
1483    let close_btn = gtk::Button::from_icon_name("window-close-symbolic");
1484    close_btn.set_tooltip_text(Some("Clear filter"));
1485    close_btn.set_valign(gtk::Align::Center);
1486    close_btn.add_css_class("flat");
1487    {
1488        let s = sender.clone();
1489        close_btn.connect_clicked(move |_| {
1490            s.input(AppMsg::FilterClosed { id });
1491        });
1492    }
1493    filter_bar.append(&close_btn);
1494
1495    widget.append(&filter_bar);
1496
1497    // ── pane body: tree or bookmarks, switched by `body` ────────────────────
1498    let tree_widget = tree.widget();
1499    tree_widget.set_vexpand(true);
1500
1501    let bookmarks_list = gtk::Box::new(gtk::Orientation::Vertical, 0);
1502    bookmarks_list.add_css_class("bookmarks-list");
1503    let bookmarks_scroll = gtk::ScrolledWindow::new();
1504    bookmarks_scroll.set_policy(gtk::PolicyType::Never, gtk::PolicyType::Automatic);
1505    bookmarks_scroll.set_vexpand(true);
1506    bookmarks_scroll.add_css_class("bookmarks-view");
1507    bookmarks_scroll.set_child(Some(&bookmarks_list));
1508
1509    let body = gtk::Stack::new();
1510    body.set_vexpand(true);
1511    body.add_named(tree_widget, Some("tree"));
1512    body.add_named(&bookmarks_scroll, Some("bookmarks"));
1513    body.set_visible_child_name("tree");
1514    widget.append(&body);
1515    widget.set_vexpand(true);
1516
1517    overlay.set_child(Some(&widget));
1518
1519    // Pane-menu shortcuts are resolved at the pane level (capture phase), so
1520    // they work whether focus is on the tree, the path entry, the filter bar, or
1521    // nothing at all. Row shortcuts stay on the tree.
1522    {
1523        // Both menus' shortcuts are live regardless of which body is showing,
1524        // so a binding in either works from the tree or the bookmarks view.
1525        let mut items = config.pane_menu.items.clone();
1526        items.extend(config.bookmarks.menu.iter().cloned());
1527        let shortcuts = Rc::new(PaneShortcuts::compile(&items));
1528        let s = sender.clone();
1529        let keys = gtk::EventControllerKey::new();
1530        keys.set_propagation_phase(gtk::PropagationPhase::Capture);
1531        keys.connect_key_pressed(move |_, key, _, state| {
1532            if let Some(action) = shortcuts.action_for(key, state) {
1533                s.input(AppMsg::PaneShortcut { id, action });
1534                glib::Propagation::Stop
1535            } else {
1536                glib::Propagation::Proceed
1537            }
1538        });
1539        widget.add_controller(keys);
1540    }
1541
1542    Pane {
1543        id,
1544        toolbar,
1545        tree,
1546        root: None,
1547        canonical_root: None,
1548        history: NavHistory::default(),
1549        filter_bar,
1550        filter_entry,
1551        body,
1552        bookmarks_list,
1553        bookmark_filter: String::new(),
1554        widget: overlay,
1555    }
1556}
1557
1558/// Remove `w` from whichever parent it currently has, using the parent's own
1559/// removal API instead of a raw `unparent()`.
1560///
1561/// This matters because a `GtkPaned` keeps its start/end child slots alive
1562/// across a raw `unparent()`, and its *deferred* destroy (GTK can keep a
1563/// container alive beyond `container.remove`) then unparents the widget a
1564/// second time — kicking it out of whatever new parent we re-appended it to.
1565fn remove_from_parent(w: &gtk::Widget) {
1566    let Some(parent) = w.parent() else { return };
1567    if let Ok(paned) = parent.clone().downcast::<gtk::Paned>() {
1568        let none: Option<&gtk::Widget> = None;
1569        if paned.start_child().is_some_and(|c| c == *w) {
1570            paned.set_start_child(none);
1571        }
1572        let none: Option<&gtk::Widget> = None;
1573        if paned.end_child().is_some_and(|c| c == *w) {
1574            paned.set_end_child(none);
1575        }
1576    } else if let Ok(b) = parent.downcast::<gtk::Box>() {
1577        b.remove(w);
1578    } else {
1579        w.unparent();
1580    }
1581}
1582
1583fn pane_widget(pane: &Pane) -> gtk::Widget {
1584    pane.widget.clone().upcast()
1585}
1586
1587/// Rebuild a dock's container Box to show its panes in a nested Paned
1588/// structure.
1589///
1590/// Layout for N panes:
1591///   N=1 → container has the single pane widget.
1592///   N=2 → container has Paned { pane[0], pane[1] }
1593///   N=3 → container has Paned { pane[0], Paned { pane[1], pane[2] } }
1594///   etc.
1595///
1596/// Called on every split/close so widget references are always fresh.
1597fn fill_pane_container(container: &gtk::Box, panes: &[Pane]) {
1598    // Detach the pane boxes from their old parents *first*, using each
1599    // parent's removal API. A raw `unparent()` alone is not enough: a Paned
1600    // keeps stale child slots that re-unparent the widget when the old chain
1601    // is finally destroyed (possibly after we have re-appended it).
1602    for pane in panes {
1603        remove_from_parent(pane.widget.upcast_ref());
1604    }
1605    // Now drop the old structure wholesale.
1606    while let Some(child) = container.first_child() {
1607        container.remove(&child);
1608    }
1609
1610    match panes {
1611        [] => {}
1612        [single] => {
1613            let w = pane_widget(single);
1614            w.set_vexpand(true);
1615            container.append(&w);
1616        }
1617        panes => {
1618            // Build a right-nested Paned from the last two, then keep
1619            // wrapping from right to left.
1620            let n = panes.len();
1621            let last_w = pane_widget(&panes[n - 1]);
1622            last_w.set_vexpand(true);
1623            let mut right: gtk::Widget = last_w.clone().upcast();
1624            for pane in panes[..n - 1].iter().rev() {
1625                let left_w = pane_widget(pane);
1626                left_w.set_vexpand(true);
1627                let split = gtk::Paned::new(gtk::Orientation::Vertical);
1628                split.set_vexpand(true);
1629                split.set_wide_handle(true);
1630                // Allow both children to shrink and resize freely.
1631                split.set_shrink_start_child(true);
1632                split.set_shrink_end_child(true);
1633                split.set_resize_start_child(true);
1634                split.set_resize_end_child(true);
1635                split.set_start_child(Some(&left_w));
1636                split.set_end_child(Some(&right));
1637                // Default to 50/50 split: set position after a short delay once
1638                // GTK has allocated space and computed max_position.
1639                {
1640                    let split2 = split.clone();
1641                    relm4::gtk::glib::timeout_add_local_once(
1642                        std::time::Duration::from_millis(PANED_CENTER_DELAY_MS),
1643                        move || {
1644                            // max_position is INT_MAX until the widget is allocated;
1645                            // once it has a real allocation, use half the actual height.
1646                            let alloc = split2.height();
1647                            if alloc > 0 {
1648                                split2.set_position(alloc / 2);
1649                            }
1650                        },
1651                    );
1652                }
1653                right = split.upcast();
1654            }
1655            container.append(&right);
1656        }
1657    }
1658}
1659
1660/// Clear the media stream of every `GtkVideo` under `widget`, releasing its
1661/// GStreamer GL sink. Walking the widget tree (rather than the model) means a
1662/// video also stops when the pane that built it has already been removed from
1663/// `App::docks` — a closed last pane keeps its widgets parented until the
1664/// window itself is destroyed.
1665fn stop_video_widgets(widget: &gtk::Widget) {
1666    if let Some(video) = widget.downcast_ref::<gtk::Video>() {
1667        video.set_media_stream(None::<&gtk::MediaStream>);
1668    }
1669    let mut child = widget.first_child();
1670    while let Some(current) = child {
1671        stop_video_widgets(&current);
1672        child = current.next_sibling();
1673    }
1674}
1675
1676/// Pixels added/removed per keyboard or CLI width increment.
1677const WIDTH_STEP: i32 = 24;
1678
1679/// Quiet period before a debounced session save is written after a resize.
1680const SAVE_DEBOUNCE_MS: u64 = 300;
1681
1682/// Delay before a freshly split `GtkPaned` is centred at 50/50, once GTK has
1683/// allocated it (before allocation `max_position` is `INT_MAX`).
1684const PANED_CENTER_DELAY_MS: u64 = 100;
1685
1686/// Grace period between tearing media down and closing the window on quit,
1687/// letting GStreamer release its GL context before the process exits.
1688const SHUTDOWN_GRACE_MS: u64 = 150;
1689
1690/// Resize a dock window's layer surface to `width`.
1691fn apply_window_width(window: &gtk::Window, width: u32) {
1692    let (_, height) = window.default_size();
1693    // `set_exclusive_zone` only means anything for a layer surface; on the
1694    // plain-window fallback it would warn, so resize the window directly.
1695    if window.is_layer_window() {
1696        window.set_exclusive_zone(width as i32);
1697    }
1698    window.set_size_request(width as i32, -1);
1699    window.set_default_size(width as i32, if height > 0 { height } else { -1 });
1700}
1701
1702/// Wire the interactive width controls into a dock window: Super+right-drag to
1703/// resize and Super+plus/minus to step the width.
1704fn attach_resize_controls(window: &gtk::Window, side: PanelSide, sender: &ComponentSender<App>) {
1705    attach_resize_drag(window, side, sender.input_sender().clone());
1706    attach_resize_keys(window, side, sender.input_sender().clone());
1707}
1708
1709fn attach_resize_drag(window: &gtk::Window, side: PanelSide, sender: relm4::Sender<AppMsg>) {
1710    let drag = gtk::GestureDrag::new();
1711    drag.set_button(gdk::BUTTON_SECONDARY);
1712    drag.set_propagation_phase(gtk::PropagationPhase::Capture);
1713    // (was Super held at drag start, cumulative offset of the last handled update)
1714    let state = Rc::new(RefCell::new((false, 0.0f64)));
1715
1716    let begin_state = state.clone();
1717    drag.connect_drag_begin(move |gesture, _, _| {
1718        let super_held = gesture
1719            .current_event_state()
1720            .contains(gdk::ModifierType::SUPER_MASK);
1721        *begin_state.borrow_mut() = (super_held, 0.0);
1722    });
1723
1724    let update_sender = sender.clone();
1725    let update_state = state.clone();
1726    drag.connect_drag_update(move |_, offset_x, _| {
1727        let mut state = update_state.borrow_mut();
1728        if !state.0 {
1729            return;
1730        }
1731        let step = offset_x - state.1;
1732        state.1 = offset_x;
1733        // A left dock grows as the pointer moves right; a right dock mirrors it.
1734        let widen = match side {
1735            PanelSide::Left => step,
1736            PanelSide::Right => -step,
1737        };
1738        let delta = widen.round() as i32;
1739        if delta != 0 {
1740            let _ = update_sender.send(AppMsg::ResizeBy { side, delta });
1741        }
1742    });
1743
1744    let end_sender = sender;
1745    let end_state = state.clone();
1746    drag.connect_drag_end(move |_, _, _| {
1747        if end_state.borrow().0 {
1748            let _ = end_sender.send(AppMsg::ResizeCommit);
1749        }
1750    });
1751
1752    window.add_controller(drag);
1753}
1754
1755fn attach_resize_keys(window: &gtk::Window, side: PanelSide, sender: relm4::Sender<AppMsg>) {
1756    let keys = gtk::EventControllerKey::new();
1757    keys.set_propagation_phase(gtk::PropagationPhase::Capture);
1758    keys.connect_key_pressed(move |_, key, _, state| {
1759        if !state.contains(gdk::ModifierType::SUPER_MASK) {
1760            return glib::Propagation::Proceed;
1761        }
1762        match key {
1763            gdk::Key::minus | gdk::Key::underscore | gdk::Key::KP_Subtract => {
1764                let _ = sender.send(AppMsg::ResizeBy { side, delta: -WIDTH_STEP });
1765                glib::Propagation::Stop
1766            }
1767            gdk::Key::equal | gdk::Key::plus | gdk::Key::KP_Add => {
1768                let _ = sender.send(AppMsg::ResizeBy { side, delta: WIDTH_STEP });
1769                glib::Propagation::Stop
1770            }
1771            _ => glib::Propagation::Proceed,
1772        }
1773    });
1774    window.add_controller(keys);
1775}
1776
1777/// Whether the running compositor implements the `wlr-layer-shell` protocol.
1778///
1779/// Hyprland, sway, river, niri and other wlroots-based compositors do; GNOME's
1780/// Mutter and KDE's KWin do not. Without it the panel cannot dock, reserve an
1781/// exclusive zone or use layer stacking, so it degrades to an ordinary window
1782/// (see [`init_layer_window`]). Cached after the first call, since it cannot
1783/// change while the app runs.
1784fn layer_shell_available() -> bool {
1785    static SUPPORTED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
1786    *SUPPORTED.get_or_init(gtk4_layer_shell::is_supported)
1787}
1788
1789/// Configure `window` as a layer-shell surface docked to `side`.
1790///
1791/// When the compositor has no `wlr-layer-shell` support the window is left as a
1792/// plain, decorated, freely-floating toplevel (a usable, undocked fallback
1793/// rather than a silently broken layer); check [`layer_shell_available`] when
1794/// docked-only behavior (like interactive resize) must be skipped.
1795fn init_layer_window(window: &gtk::Window, config: &Config, side: PanelSide, width: u32) {
1796    if !layer_shell_available() {
1797        configure_plain_window(window, width);
1798        return;
1799    }
1800    if window.is_layer_window() {
1801        return;
1802    }
1803    window.init_layer_shell();
1804    window.set_layer(layer_of(config.panel.layer));
1805    let edge = match side {
1806        PanelSide::Left => Edge::Left,
1807        PanelSide::Right => Edge::Right,
1808    };
1809    window.set_anchor(Edge::Top, true);
1810    window.set_anchor(Edge::Bottom, true);
1811    window.set_anchor(edge, true);
1812    window.set_exclusive_zone(width as i32);
1813    window.set_size_request(width as i32, -1);
1814    let margin = config.panel.margin as i32;
1815    window.set_margin(Edge::Top, margin);
1816    window.set_margin(Edge::Bottom, margin);
1817    window.set_margin(Edge::Left, margin);
1818    window.set_margin(Edge::Right, margin);
1819
1820    // Keyboard focus is `OnDemand`: the compositor grants it when the layer is
1821    // mapped (so launching or showing the panel focuses it) and while the
1822    // pointer moves over it, and the claim is released the moment the pointer
1823    // leaves.
1824    //
1825    // Releasing on pointer-leave matters because a Hyprland layer surface that
1826    // *keeps* keyboard focus does not update the compositor's notion of the
1827    // focused window (`hyprctl activewindow` still reports the previous one).
1828    // A click only triggers a refocus when the clicked window differs from that
1829    // focused window (`CInputManager::processMouseDownNormal`:
1830    // `focusState()->window() != w`), so a panel that holds the claim makes
1831    // clicking the previously-focused window a no-op — keyboard focus never
1832    // returns to it. Dropping the claim kicks the layer from the seat, so the
1833    // next click refocuses the window normally.
1834    window.set_keyboard_mode(KeyboardMode::OnDemand);
1835    let motion = gtk::EventControllerMotion::new();
1836    {
1837        let kb_window = window.clone();
1838        motion.connect_enter(move |_, _, _| kb_window.set_keyboard_mode(KeyboardMode::OnDemand));
1839    }
1840    {
1841        let kb_window = window.clone();
1842        motion.connect_leave(move |_| {
1843            // A row/menu popover is a child surface, so opening it fires a
1844            // pointer-leave for the panel even though the user has not left it.
1845            // Keep the keyboard claim in that case; the compositor returns
1846            // focus to the panel when the popover closes.
1847            if has_visible_popover(kb_window.upcast_ref()) {
1848                return;
1849            }
1850            kb_window.set_keyboard_mode(KeyboardMode::None);
1851        });
1852    }
1853    window.add_controller(motion);
1854}
1855
1856/// Fall back to a plain, decorated, freely-floating window when the compositor
1857/// has no layer-shell support. The panel cannot dock, so it opens at its
1858/// configured width with a normal title bar; the user places it like any other
1859/// window. Announced once so the reason for the un-docked panel is not a
1860/// mystery.
1861fn configure_plain_window(window: &gtk::Window, width: u32) {
1862    static ANNOUNCED: std::sync::OnceLock<()> = std::sync::OnceLock::new();
1863    ANNOUNCED.get_or_init(|| {
1864        eprintln!(
1865            "tree-space: this compositor does not support wlr-layer-shell, so the panel \
1866             cannot dock; opening it as a normal window instead."
1867        );
1868    });
1869    window.set_title(Some("tree-space"));
1870    window.set_default_size(width as i32, 720);
1871    window.set_size_request(width as i32, -1);
1872}
1873
1874/// Whether any visible [`gtk::Popover`] is open under `widget` (walking the
1875/// widget tree). Used to keep keyboard focus on the panel while its menu is up.
1876fn has_visible_popover(widget: &gtk::Widget) -> bool {
1877    if widget.is::<gtk::Popover>() && widget.is_visible() {
1878        return true;
1879    }
1880    let mut child = widget.first_child();
1881    while let Some(current) = child {
1882        if has_visible_popover(&current) {
1883            return true;
1884        }
1885        child = current.next_sibling();
1886    }
1887    false
1888}
1889
1890fn layer_of(layer: PanelLayer) -> Layer {
1891    use PanelLayer::*;
1892    match layer {
1893        Background => Layer::Background,
1894        Bottom => Layer::Bottom,
1895        Top => Layer::Top,
1896        Overlay => Layer::Overlay,
1897    }
1898}
1899
1900/// Load the user stylesheet (falling back to the shipped default) plus the
1901/// dynamically-sized font rule, which is appended last so `tree.font_size`
1902/// still wins over anything the stylesheet sets.
1903fn install_css(config: &Config) {
1904    let stylesheet = load_stylesheet();
1905    if let Some(problem) = &stylesheet.problem {
1906        eprintln!("tree-space: could not read stylesheet: {problem:?}");
1907    }
1908    let css = format!(
1909        "{}\n.tree-row label, .tree-rename-entry, .tree-menu, .hamburger-menu {{ font-size: {}px; }}",
1910        stylesheet.css, config.tree.font_size
1911    );
1912    let provider = gtk::CssProvider::new();
1913    provider.load_from_string(&css);
1914    if let Some(display) = gdk::Display::default() {
1915        gtk::style_context_add_provider_for_display(
1916            &display,
1917            &provider,
1918            gtk::STYLE_PROVIDER_PRIORITY_APPLICATION,
1919        );
1920    }
1921}
1922
1923fn home_dir() -> Option<PathBuf> {
1924    std::env::var_os("HOME").map(PathBuf::from)
1925}
1926
1927/// The root a freshly-seeded dock should show, resolved from the `[startup]`
1928/// config (last-used directory, home, or a fixed path), falling back to home.
1929fn default_root(startup: &StartupRoot) -> Option<PathBuf> {
1930    // A bookmarks launch opens no directory pane; seeding one would defeat it.
1931    if startup.is_bookmarks() {
1932        return None;
1933    }
1934    let last = SessionState::load().last_root.filter(|p| p.is_dir());
1935    startup.resolve(last).filter(|p| p.is_dir()).or_else(home_dir)
1936}
1937#[cfg(test)]
1938mod visibility_tests {
1939    use super::*;
1940
1941    const L: PanelSide = PanelSide::Left;
1942    const R: PanelSide = PanelSide::Right;
1943
1944    fn plan(intent: VisibilityIntent, existing: &[PanelSide], shown: &[PanelSide]) -> VisibilityPlan {
1945        resolve_visibility(intent, existing, shown)
1946    }
1947
1948    #[test]
1949    fn show_all_shows_both() {
1950        let p = plan(VisibilityIntent::ShowAll, &[L], &[]);
1951        // Only docks that already exist are shown; a second one is not created.
1952        assert_eq!(p.show, vec![L]);
1953        assert!(p.seed);
1954        let both = plan(VisibilityIntent::ShowAll, &[L, R], &[]);
1955        assert_eq!(both.show, vec![L, R]);
1956    }
1957
1958    #[test]
1959    fn hide_all_hides_both() {
1960        let p = plan(VisibilityIntent::HideAll, &[L, R], &[L, R]);
1961        assert_eq!(p.hide, vec![L, R]);
1962        assert!(p.show.is_empty());
1963    }
1964
1965    #[test]
1966    fn toggle_all_shows_when_nothing_visible_and_hides_otherwise() {
1967        let shown = plan(VisibilityIntent::ToggleAll, &[L], &[]);
1968        // Toggling all shows the docks that exist (not a newly created side).
1969        assert_eq!(shown.show, vec![L]);
1970        let hidden = plan(VisibilityIntent::ToggleAll, &[L, R], &[L]);
1971        assert_eq!(hidden.hide, vec![L, R]);
1972    }
1973
1974    #[test]
1975    fn show_side_creates_a_missing_dock() {
1976        let p = plan(VisibilityIntent::ShowSide(R), &[L], &[L]);
1977        assert_eq!(p.create, Some(R));
1978        assert_eq!(p.show, vec![R]);
1979    }
1980
1981    #[test]
1982    fn hide_side_never_creates() {
1983        let p = plan(VisibilityIntent::HideSide(R), &[L], &[L]);
1984        assert_eq!(p.create, None);
1985        assert_eq!(p.hide, vec![R]);
1986    }
1987
1988    #[test]
1989    fn toggle_side_is_granular() {
1990        // Missing -> create and show.
1991        let create = plan(VisibilityIntent::ToggleSide(R), &[L], &[L]);
1992        assert_eq!(create.create, Some(R));
1993        assert_eq!(create.show, vec![R]);
1994        // Shown -> hide.
1995        let hide = plan(VisibilityIntent::ToggleSide(L), &[L, R], &[L, R]);
1996        assert_eq!(hide.hide, vec![L]);
1997        // Hidden -> show.
1998        let show = plan(VisibilityIntent::ToggleSide(L), &[L, R], &[R]);
1999        assert_eq!(show.show, vec![L]);
2000    }
2001
2002    #[test]
2003    fn toggling_one_side_leaves_the_other_alone() {
2004        // Left visible, right hidden: toggling right must not name left at all.
2005        let p = plan(VisibilityIntent::ToggleSide(R), &[L, R], &[L]);
2006        assert_eq!(p.show, vec![R]);
2007        assert!(!p.hide.contains(&L));
2008    }
2009}
2010
2011#[cfg(test)]
2012mod nav_history_tests {
2013    use super::{NavHistory, ViewEntry};
2014    use std::path::PathBuf;
2015
2016    fn dir(s: &str) -> ViewEntry {
2017        ViewEntry::Dir(PathBuf::from(s))
2018    }
2019
2020    #[test]
2021    fn records_visits_in_order_and_navigates_both_ways() {
2022        let mut h = NavHistory::default();
2023        h.record(dir("/a"));
2024        h.record(dir("/b"));
2025        h.record(dir("/c"));
2026        assert!(h.can_back());
2027        assert!(!h.can_forward());
2028
2029        assert_eq!(h.back(), Some(dir("/b")));
2030        h.finish_navigation();
2031        assert_eq!(h.back(), Some(dir("/a")));
2032        h.finish_navigation();
2033        assert!(!h.can_back());
2034        assert!(h.can_forward());
2035
2036        assert_eq!(h.forward(), Some(dir("/b")));
2037        h.finish_navigation();
2038        assert_eq!(h.forward(), Some(dir("/c")));
2039        h.finish_navigation();
2040        assert!(h.can_back());
2041        assert!(!h.can_forward());
2042    }
2043
2044    #[test]
2045    fn a_new_visit_after_going_back_truncates_the_forward_tail() {
2046        let mut h = NavHistory::default();
2047        h.record(dir("/a"));
2048        h.record(dir("/b"));
2049        h.record(dir("/c"));
2050        assert_eq!(h.back(), Some(dir("/b")));
2051        h.finish_navigation();
2052        // Visiting /d from /b drops /c from the forward history.
2053        h.record(dir("/d"));
2054        assert!(!h.can_forward());
2055        assert_eq!(h.back(), Some(dir("/b")));
2056    }
2057
2058    #[test]
2059    fn navigating_does_not_record_and_repeats_are_ignored() {
2060        let mut h = NavHistory::default();
2061        h.record(dir("/a"));
2062        h.record(dir("/b"));
2063        // Back, then the resulting RootChanged; recording must be suppressed.
2064        let target = h.back().unwrap();
2065        assert_eq!(target, dir("/a"));
2066        h.record(target.clone());
2067        h.finish_navigation();
2068        // Still at /a with /b ahead, and no duplicate /a entry was appended.
2069        assert!(h.can_forward());
2070        assert_eq!(h.forward(), Some(dir("/b")));
2071        h.finish_navigation();
2072
2073        // Re-recording the current entry is a no-op.
2074        h.record(dir("/b"));
2075        assert_eq!(h.cursor, 1);
2076        assert_eq!(h.entries.len(), 2);
2077    }
2078
2079    #[test]
2080    fn bookmarks_view_is_a_history_entry() {
2081        let mut h = NavHistory::default();
2082        h.record(ViewEntry::Bookmarks);
2083        h.record(dir("/a"));
2084        // From /a, back goes to the bookmarks view.
2085        assert_eq!(h.back(), Some(ViewEntry::Bookmarks));
2086        h.finish_navigation();
2087        // Re-recording the view we are already on is a no-op.
2088        h.record(ViewEntry::Bookmarks);
2089        assert_eq!(h.entries.len(), 2);
2090        // And forward returns to the directory.
2091        assert_eq!(h.forward(), Some(dir("/a")));
2092        h.finish_navigation();
2093    }
2094
2095    #[test]
2096    fn cannot_navigate_an_empty_history() {
2097        let mut h = NavHistory::default();
2098        assert!(!h.can_back());
2099        assert!(!h.can_forward());
2100        assert_eq!(h.back(), None);
2101        assert_eq!(h.forward(), None);
2102    }
2103}