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