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