Skip to main content

tree_space/ui/
bookmarks.rs

1//! The bookmarks pane view and its editor dialog.
2//!
3//! Bookmarks are shown as the body of a pane that has no directory yet (a
4//! "new panel"): a scrollable list of suggested directories. Clicking an entry
5//! tells the pane to jump to that directory, replacing the view. The row's
6//! context menu can open, edit, or delete it. The list itself lives in
7//! `bookmarks.toml` (see [`crate::config`]); this module only renders it and
8//! reports user intent upward as [`BookmarkEvent`]s.
9//!
10//! Like the other panels the editor dialog is shown as a centered overlay *layer
11//! surface*: a plain toplevel has no toplevel parent to be transient for (the
12//! panel is itself a layer surface), so a tiling compositor would tile it.
13
14use std::cell::{Cell, RefCell};
15use std::path::{Path, PathBuf};
16use std::rc::Rc;
17
18use gtk4_layer_shell::{KeyboardMode, Layer, LayerShell};
19use relm4::gtk;
20use relm4::gtk::{gdk, gio, glib, pango, prelude::*};
21
22use crate::config::{
23    Bookmark, BuiltinAction, ContextAction, ContextMenu, PanelSide, ShortcutTarget,
24    expand_bookmark_path,
25};
26use crate::ui::tree::{accel_display, begin_row_drag, is_path_safe, menu_label, past_drag_threshold};
27
28/// Where a dragged bookmark was dropped.
29#[derive(Debug, Clone)]
30pub enum MoveTarget {
31    /// Into the folder at this index path (appended).
32    Into(Vec<usize>),
33    /// At the level of this entry, immediately before it.
34    Before(Vec<usize>),
35    /// Top level, appended.
36    Root,
37}
38
39/// Shared drag state for one bookmarks view. Lives on the `Pane` so it survives
40/// the row rebuilds that happen on every list change.
41#[derive(Default)]
42pub struct BookmarkDrag {
43    /// Press point (scrolled-window coordinates), set on a primary press.
44    start: RefCell<Option<(f64, f64)>>,
45    /// The entry pressed: its index path and path (if it is a leaf).
46    pressed: RefCell<Option<(Vec<usize>, Option<PathBuf>)>>,
47    /// Set once `gdk_drag_begin` has been issued; suppresses further moves and
48    /// is cleared when the drag finishes.
49    active: Cell<bool>,
50    /// The entry currently being dragged, read by the drop handlers.
51    dragging: RefCell<Option<Vec<usize>>>,
52}
53
54impl BookmarkDrag {
55    pub fn new() -> Rc<Self> {
56        Rc::new(Self::default())
57    }
58}
59
60/// One visible bookmark row, in on-screen order. Built by [`visible_nodes`] and
61/// used both to render the rows and to drive keyboard navigation (the cursor is
62/// an index into this list).
63#[derive(Debug, Clone)]
64pub struct BookmarkNode {
65    /// Index path into the `bookmarks` tree (folder levels then position).
66    pub index_path: Vec<usize>,
67    /// Display name.
68    pub name: String,
69    /// The expanded directory for a leaf (or a clickable folder); `None` for a
70    /// pure folder.
71    pub path: Option<PathBuf>,
72    /// Whether this entry is a folder (holds children or has no path).
73    pub folder: bool,
74    /// Whether the folder is shown open (either expanded, or forced open while a
75    /// filter is active so matching children stay visible).
76    pub open: bool,
77    /// Indentation depth.
78    pub depth: usize,
79}
80
81/// Keyboard cursor and selection state for one bookmarks view. Lives on the
82/// `Pane` so it survives the row rebuilds that happen on every list change, and
83/// is shared with the list's key controller. The focused/selected row mirrors
84/// the tree's cursor, so launching the panel hands the keyboard straight to the
85/// first bookmark.
86pub struct BookmarkNav {
87    list: gtk::Box,
88    nodes: RefCell<Vec<BookmarkNode>>,
89    /// Position into `nodes`, or `None` before the first interaction.
90    cursor: Cell<Option<usize>>,
91    /// Set when a rebuild should restore keyboard focus to the cursor row (for
92    /// example after expand/collapse re-creates it).
93    focus_after_fill: Cell<bool>,
94}
95
96impl BookmarkNav {
97    pub fn new(list: &gtk::Box) -> Rc<Self> {
98        Rc::new(Self {
99            list: list.clone(),
100            nodes: RefCell::new(Vec::new()),
101            cursor: Cell::new(None),
102            focus_after_fill: Cell::new(false),
103        })
104    }
105
106    fn len(&self) -> usize {
107        self.nodes.borrow().len()
108    }
109
110    /// The row widget at `index` (rows are the `.bookmark-row` buttons, in the
111    /// same order as `nodes`).
112    fn row(&self, index: usize) -> Option<gtk::Widget> {
113        let mut child = self.list.first_child();
114        let mut seen = 0usize;
115        while let Some(current) = child {
116            if current.has_css_class("bookmark-row") {
117                if seen == index {
118                    return Some(current);
119                }
120                seen += 1;
121            }
122            child = current.next_sibling();
123        }
124        None
125    }
126
127    /// Record the freshly-built visible rows, keeping the cursor on the same
128    /// entry when it is still visible.
129    fn set_nodes(&self, nodes: Vec<BookmarkNode>) {
130        let prev = self
131            .cursor
132            .get()
133            .and_then(|c| self.nodes.borrow().get(c).map(|n| n.index_path.clone()));
134        *self.nodes.borrow_mut() = nodes;
135        let len = self.len();
136        self.cursor.set(cursor_after_rebuild(prev.as_deref(), &self.nodes.borrow(), len));
137    }
138
139    /// Reflect the cursor in the `.bookmark-row-selected` class.
140    fn apply_selection(&self) {
141        let cursor = self.cursor.get();
142        for i in 0..self.len() {
143            if let Some(row) = self.row(i) {
144                if cursor == Some(i) {
145                    row.add_css_class("bookmark-row-selected");
146                } else {
147                    row.remove_css_class("bookmark-row-selected");
148                }
149            }
150        }
151    }
152
153    /// Move the cursor to `index`, style it, and take keyboard focus.
154    fn focus_index(&self, index: usize) {
155        self.cursor.set(Some(index));
156        self.apply_selection();
157        if let Some(row) = self.row(index) {
158            row.grab_focus();
159        }
160    }
161
162    /// Move the cursor by `delta` rows (clamped; the first press selects the
163    /// first row).
164    fn move_by(&self, delta: i32) {
165        let len = self.len();
166        if let Some(next) = cursor_after_move(self.cursor.get(), delta, len) {
167            self.focus_index(next);
168        }
169    }
170
171    /// Jump to the first or last row.
172    fn move_bound(&self, last: bool) {
173        let len = self.len();
174        if len == 0 {
175            return;
176        }
177        self.focus_index(if last { len - 1 } else { 0 });
178    }
179
180    /// The entry under the cursor, if any.
181    fn current(&self) -> Option<BookmarkNode> {
182        let nodes = self.nodes.borrow();
183        self.cursor.get().and_then(|c| nodes.get(c)).cloned()
184    }
185
186    /// Focus the first bookmark, or the cursor if it is already placed. Used
187    /// when the panel is launched or shown so the keyboard works immediately.
188    pub fn focus_start(&self) {
189        let len = self.len();
190        if len == 0 {
191            return;
192        }
193        let index = self.cursor.get().unwrap_or(0).min(len - 1);
194        self.focus_index(index);
195    }
196
197    /// Ask the next rebuild to restore keyboard focus to the cursor row.
198    fn focus_on_next_fill(&self) {
199        self.focus_after_fill.set(true);
200    }
201}
202
203/// The cursor after moving `delta` rows over `len` entries (clamped).
204fn cursor_after_move(cursor: Option<usize>, delta: i32, len: usize) -> Option<usize> {
205    if len == 0 {
206        return None;
207    }
208    let Some(cursor) = cursor else {
209        return Some(0);
210    };
211    Some((cursor as i64 + delta as i64).clamp(0, (len - 1) as i64) as usize)
212}
213
214/// The cursor after a rebuild: stay on the entry with the same index path, or
215/// fall back to the first row when it is gone.
216fn cursor_after_rebuild(path: Option<&[usize]>, nodes: &[BookmarkNode], len: usize) -> Option<usize> {
217    if len == 0 {
218        return None;
219    }
220    match path {
221        Some(path) => nodes.iter().position(|n| n.index_path == path).or(Some(0)),
222        None => None,
223    }
224}
225
226/// What the user did in the bookmarks view.
227#[derive(Debug, Clone)]
228pub enum BookmarkEvent {
229    /// Jump this pane to the bookmark's directory.
230    Open(PathBuf),
231    /// Expand or collapse the folder at this index path.
232    Toggle(Vec<usize>),
233    /// Edit the entry at this index path.
234    Edit(Vec<usize>),
235    /// Delete the entry at this index path.
236    Delete(Vec<usize>),
237    /// Create a new leaf bookmark (opens the editor).
238    NewBookmark,
239    /// Create a new empty bookmark folder (opens the editor).
240    NewBookmarkFolder,
241    /// Run an inherited (directory) context action against `path` without
242    /// opening it.
243    Action { path: PathBuf, target: ShortcutTarget },
244    /// Move the entry at `from` to `to` (drag and drop).
245    Move { from: Vec<usize>, to: MoveTarget },
246}
247
248/// The config a bookmark's right-click menu is assembled from: the inherited
249/// context menu, the extra `[bookmarks] context` items, and the pane side (for
250/// labels like "In right panel").
251pub struct BookmarkMenuConfig<'a> {
252    pub context: &'a ContextMenu,
253    pub extras: &'a [ContextAction],
254    pub side: PanelSide,
255}
256
257/// Replace the contents of `list` with one row per visible bookmark, recursing
258/// into folders. `filter` (case-insensitive substring of name or path; empty
259/// shows all) keeps a folder when its name or any descendant matches. Used both
260/// for the initial view and to refresh every open pane when the list changes.
261pub fn fill_bookmarks(
262    list: &gtk::Box,
263    bookmarks: &[Bookmark],
264    filter: &str,
265    menu: &BookmarkMenuConfig,
266    drag: &Rc<BookmarkDrag>,
267    nav: &Rc<BookmarkNav>,
268    on_event: Rc<dyn Fn(BookmarkEvent)>,
269) {
270    while let Some(child) = list.first_child() {
271        list.remove(&child);
272    }
273    let query = filter.trim().to_lowercase();
274    let filtering = !query.is_empty();
275    let nodes = if filtering {
276        visible_nodes(bookmarks, Some(&query))
277    } else {
278        visible_nodes(bookmarks, None)
279    };
280    for node in &nodes {
281        list.append(&bookmark_row(node, menu, drag, on_event.clone()));
282    }
283    nav.set_nodes(nodes);
284    nav.apply_selection();
285    if nav.focus_after_fill.get() {
286        nav.focus_after_fill.set(false);
287        nav.focus_start();
288    }
289    if list.first_child().is_none() {
290        let text = if filtering { "No matching bookmarks" } else { "No bookmarks — use Add Bookmark" };
291        let empty = gtk::Label::new(Some(text));
292        empty.add_css_class("bookmarks-empty");
293        empty.set_xalign(0.0);
294        list.append(&empty);
295    }
296}
297
298/// Wire the bookmarks scroller with its blank-area menu and drag-and-drop:
299/// right-clicking empty space opens the `[bookmarks] blank` menu, dropping there
300/// moves a dragged entry to the top level, and a legacy event controller starts
301/// the row drag (GTK gestures do not activate on a layer-shell surface).
302pub fn attach_bookmarks_scroller(
303    anchor: &gtk::ScrolledWindow,
304    blank_items: &[ContextAction],
305    side: PanelSide,
306    drag: &Rc<BookmarkDrag>,
307    nav: &Rc<BookmarkNav>,
308    on_event: Rc<dyn Fn(BookmarkEvent)>,
309) {
310    // ── keyboard navigation ─────────────────────────────────────────────────
311    // The rows are focusable buttons, but arrow keys do not traverse them on
312    // their own; move the cursor and move focus with it. Enter/Space fall
313    // through to the focused row's own click handler.
314    {
315        let nav = nav.clone();
316        let on_event = on_event.clone();
317        let keys = gtk::EventControllerKey::new();
318        keys.set_propagation_phase(gtk::PropagationPhase::Capture);
319        keys.connect_key_pressed(move |_, key, _, state| {
320            if state.intersects(gdk::ModifierType::CONTROL_MASK | gdk::ModifierType::SHIFT_MASK) {
321                return glib::Propagation::Proceed;
322            }
323            match key {
324                gdk::Key::Up => nav.move_by(-1),
325                gdk::Key::Down => nav.move_by(1),
326                gdk::Key::Home => nav.move_bound(false),
327                gdk::Key::End => nav.move_bound(true),
328                gdk::Key::Left | gdk::Key::Right => {
329                    let Some(node) = nav.current() else {
330                        return glib::Propagation::Proceed;
331                    };
332                    let want_open = key == gdk::Key::Right;
333                    if !node.folder || node.open == want_open {
334                        return glib::Propagation::Proceed;
335                    }
336                    // The rebuild re-creates the rows; ask it to restore focus.
337                    nav.focus_on_next_fill();
338                    on_event(BookmarkEvent::Toggle(node.index_path));
339                }
340                _ => return glib::Propagation::Proceed,
341            }
342            glib::Propagation::Stop
343        });
344        anchor.add_controller(keys);
345    }
346
347    // ── blank-area context menu ─────────────────────────────────────────────
348    {
349        let items = blank_items.to_vec();
350        let gesture = gtk::GestureClick::new();
351        gesture.set_button(gdk::BUTTON_SECONDARY);
352        let menu_anchor = anchor.clone();
353        let on_event = on_event.clone();
354        gesture.connect_pressed(move |gesture, _, x, y| {
355            gesture.set_state(gtk::EventSequenceState::Claimed);
356            let popover = gtk::Popover::new();
357            popover.add_css_class("bookmark-menu-popover");
358            popover.set_has_arrow(true);
359            let menu_box = gtk::Box::new(gtk::Orientation::Vertical, 0);
360            menu_box.add_css_class("bookmark-menu");
361            append_bookmark_items(&menu_box, &items, None, &[], side, on_event.clone());
362            popover.set_child(Some(&menu_box));
363            popover.set_parent(&menu_anchor);
364            popover.set_pointing_to(Some(&gdk::Rectangle::new(x as i32, y as i32, 1, 1)));
365            let popover2 = popover.clone();
366            popover.connect_closed(move |_| popover2.unparent());
367            popover.popup();
368        });
369        anchor.add_controller(gesture);
370    }
371
372    // ── drop on empty space: move the dragged entry to the top level ────────
373    {
374        let drag = drag.clone();
375        let on_event = on_event.clone();
376        let drop = gtk::DropTarget::new(gdk::FileList::static_type(), gdk::DragAction::MOVE);
377        drop.connect_drop(move |_, _value, _x, _y| {
378            let Some(from) = drag.dragging.borrow().clone() else {
379                return false;
380            };
381            on_event(BookmarkEvent::Move { from, to: MoveTarget::Root });
382            true
383        });
384        anchor.add_controller(drop);
385    }
386
387    // ── drag start (legacy controller; see `begin_row_drag`) ────────────────
388    {
389        let legacy = gtk::EventControllerLegacy::new();
390        legacy.set_propagation_phase(gtk::PropagationPhase::Capture);
391        let d = drag.clone();
392        let scrolled = anchor.clone();
393        legacy.connect_event(move |_, event| {
394            match event.event_type() {
395                gdk::EventType::ButtonPress => {
396                    let button = event
397                        .downcast_ref::<gdk::ButtonEvent>()
398                        .map(|b| b.button())
399                        .unwrap_or(0);
400                    if button == 1 && let Some(pos) = event.position() {
401                        *d.start.borrow_mut() = Some(pos);
402                    }
403                }
404                gdk::EventType::ButtonRelease => {
405                    let button = event
406                        .downcast_ref::<gdk::ButtonEvent>()
407                        .map(|b| b.button())
408                        .unwrap_or(0);
409                    if button == 1 && !d.active.get() {
410                        *d.start.borrow_mut() = None;
411                        *d.pressed.borrow_mut() = None;
412                    }
413                }
414                gdk::EventType::MotionNotify => {
415                    if d.active.get() {
416                        return glib::Propagation::Proceed;
417                    }
418                    let Some(start) = *d.start.borrow() else {
419                        return glib::Propagation::Proceed;
420                    };
421                    let Some(now) = event.position() else {
422                        return glib::Propagation::Proceed;
423                    };
424                    if !past_drag_threshold(scrolled.upcast_ref(), start, now) {
425                        return glib::Propagation::Proceed;
426                    }
427                    let Some((from, path)) = d.pressed.borrow().clone() else {
428                        return glib::Propagation::Proceed;
429                    };
430                    *d.start.borrow_mut() = None;
431                    let paths: Vec<PathBuf> = path.into_iter().collect();
432                    let finished = {
433                        let d = d.clone();
434                        move |_drag: gdk::Drag, _delete: bool| {
435                            d.active.set(false);
436                            *d.dragging.borrow_mut() = None;
437                        }
438                    };
439                    let cancelled = {
440                        let d = d.clone();
441                        move |_drag: gdk::Drag, _reason: gdk::DragCancelReason| {
442                            d.active.set(false);
443                            *d.dragging.borrow_mut() = None;
444                        }
445                    };
446                    if begin_row_drag(
447                        scrolled.upcast_ref(),
448                        &paths,
449                        start,
450                        gdk::DragAction::MOVE,
451                        finished,
452                        cancelled,
453                    ) {
454                        d.active.set(true);
455                        *d.dragging.borrow_mut() = Some(from);
456                    }
457                }
458                _ => {}
459            }
460            glib::Propagation::Proceed
461        });
462        anchor.add_controller(legacy);
463    }
464}
465
466/// Whether `bookmark` matches a lowercase `query` (already trimmed). Matches the
467/// name or the path so a directory can be found by either.
468fn bookmark_matches(bookmark: &Bookmark, query: &str) -> bool {
469    query.is_empty()
470        || bookmark.name.to_lowercase().contains(query)
471        || bookmark
472            .path
473            .as_deref()
474            .is_some_and(|path| path.to_string_lossy().to_lowercase().contains(query))
475}
476
477/// Flatten the bookmark tree into the rows that should be visible, in order.
478/// Without a filter, a folder's children show only when it is expanded. With a
479/// filter, a matching folder shows all of its children; a folder that does not
480/// match is shown only when a descendant matches, and then only the matching
481/// subtree.
482pub fn visible_nodes(bookmarks: &[Bookmark], query: Option<&str>) -> Vec<BookmarkNode> {
483    let mut out = Vec::new();
484    walk_nodes(bookmarks, &[], 0, query, &mut out);
485    out
486}
487
488fn walk_nodes(
489    entries: &[Bookmark],
490    prefix: &[usize],
491    depth: usize,
492    query: Option<&str>,
493    out: &mut Vec<BookmarkNode>,
494) {
495    for (i, entry) in entries.iter().enumerate() {
496        let mut index_path = prefix.to_vec();
497        index_path.push(i);
498        let folder = entry.is_folder();
499        match query {
500            None => {
501                out.push(node_of(entry, index_path.clone(), depth, folder, entry.expanded));
502                if folder && entry.expanded {
503                    walk_nodes(&entry.items, &index_path, depth + 1, None, out);
504                }
505            }
506            Some(query) => {
507                if folder {
508                    if bookmark_matches(entry, query) {
509                        out.push(node_of(entry, index_path.clone(), depth, true, true));
510                        walk_all(&entry.items, &index_path, depth + 1, out);
511                    } else {
512                        // Keep the folder only if one of its descendants survives
513                        // the filter; put the folder row ahead of that subtree.
514                        let start = out.len();
515                        walk_nodes(&entry.items, &index_path, depth + 1, Some(query), out);
516                        if out.len() > start {
517                            out.insert(start, node_of(entry, index_path, depth, true, true));
518                        }
519                    }
520                } else if bookmark_matches(entry, query) {
521                    out.push(node_of(entry, index_path, depth, false, false));
522                }
523            }
524        }
525    }
526}
527
528/// Every entry of `entries`, recursing through folders (used when a folder
529/// matches the filter and all of its contents should be shown).
530fn walk_all(entries: &[Bookmark], prefix: &[usize], depth: usize, out: &mut Vec<BookmarkNode>) {
531    for (i, entry) in entries.iter().enumerate() {
532        let mut index_path = prefix.to_vec();
533        index_path.push(i);
534        let folder = entry.is_folder();
535        out.push(node_of(entry, index_path.clone(), depth, folder, true));
536        if folder {
537            walk_all(&entry.items, &index_path, depth + 1, out);
538        }
539    }
540}
541
542fn node_of(entry: &Bookmark, index_path: Vec<usize>, depth: usize, folder: bool, open: bool) -> BookmarkNode {
543    BookmarkNode {
544        index_path,
545        name: entry.name.clone(),
546        path: entry.path.as_deref().map(expand_bookmark_path),
547        folder,
548        open,
549        depth,
550    }
551}
552
553/// One bookmark row. A folder toggles expand/collapse on click; a leaf opens.
554/// Right-clicking opens the inherited directory menu (for a path) plus the
555/// `[bookmarks] context` extras.
556fn bookmark_row(
557    node: &BookmarkNode,
558    menu: &BookmarkMenuConfig,
559    drag: &Rc<BookmarkDrag>,
560    on_event: Rc<dyn Fn(BookmarkEvent)>,
561) -> gtk::Widget {
562    let folder = node.folder;
563    let path = node.path.clone();
564    let index_path = &node.index_path;
565    let row = gtk::Button::new();
566    row.add_css_class("bookmark-row");
567    if let Some(path) = &path {
568        row.set_tooltip_text(Some(&path.display().to_string()));
569    }
570
571    let content = gtk::Box::new(gtk::Orientation::Horizontal, 6);
572    content.set_margin_start((node.depth * 14) as i32);
573    if folder {
574        let arrow = gtk::Image::from_icon_name(if node.open {
575            "pan-down-symbolic"
576        } else {
577            "pan-end-symbolic"
578        });
579        arrow.add_css_class("bookmark-arrow");
580        content.append(&arrow);
581    } else {
582        // Align leaves with folder labels (the arrow column).
583        let spacer = gtk::Box::new(gtk::Orientation::Horizontal, 0);
584        spacer.set_size_request(16, -1);
585        content.append(&spacer);
586    }
587    // A folder gets a folder icon; a leaf a bookmark, so the two kinds read
588    // apart from ordinary file rows.
589    let icon = gtk::Image::from_icon_name(if folder {
590        "folder-symbolic"
591    } else {
592        "user-bookmarks-symbolic"
593    });
594    icon.add_css_class("bookmark-icon");
595    let label = gtk::Label::new(Some(&node.name));
596    label.set_xalign(0.0);
597    label.set_hexpand(true);
598    label.set_ellipsize(pango::EllipsizeMode::End);
599    label.add_css_class("bookmark-label");
600    content.append(&icon);
601    content.append(&label);
602    row.set_child(Some(&content));
603
604    {
605        let on_event = on_event.clone();
606        if folder {
607            let index_path = index_path.to_vec();
608            row.connect_clicked(move |_| on_event(BookmarkEvent::Toggle(index_path.clone())));
609        } else if let Some(path) = path.clone() {
610            row.connect_clicked(move |_| on_event(BookmarkEvent::Open(path.clone())));
611        }
612    }
613
614    // ── right-click: inherited directory menu + bookmark extras ─────────────
615    let actions = build_bookmark_actions(path.as_deref(), menu);
616    if !actions.is_empty() {
617        let popover = gtk::Popover::new();
618        popover.add_css_class("bookmark-menu-popover");
619        popover.set_has_arrow(true);
620        let menu_box = gtk::Box::new(gtk::Orientation::Vertical, 0);
621        menu_box.add_css_class("bookmark-menu");
622        append_bookmark_items(&menu_box, &actions, path.as_deref(), index_path, menu.side, on_event.clone());
623        popover.set_child(Some(&menu_box));
624
625        popover.set_parent(&row);
626        // The row is rebuilt whenever the list changes; unparent the popover as
627        // the row goes away so GTK does not warn about children left behind.
628        {
629            let popover = popover.clone();
630            row.connect_destroy(move |_| popover.unparent());
631        }
632        let gesture = gtk::GestureClick::new();
633        gesture.set_button(gdk::BUTTON_SECONDARY);
634        {
635            let popover = popover.clone();
636            gesture.connect_pressed(move |gesture, _, x, y| {
637                gesture.set_state(gtk::EventSequenceState::Claimed);
638                popover.set_pointing_to(Some(&gdk::Rectangle::new(x as i32, y as i32, 1, 1)));
639                popover.popup();
640            });
641        }
642        row.add_controller(gesture);
643    }
644
645    // ── drag: record the pressed entry, and accept drops ────────────────────
646    // The press only records state; the actual `gdk_drag_begin` is issued by the
647    // scroller's legacy controller (gestures do not activate on a layer
648    // surface). A folder accepts a drop "into"; a leaf accepts one "before" it.
649    {
650        let drag = drag.clone();
651        let index_path = index_path.to_vec();
652        let path = path.clone();
653        let press = gtk::GestureClick::new();
654        press.set_button(gdk::BUTTON_PRIMARY);
655        press.set_propagation_phase(gtk::PropagationPhase::Capture);
656        press.connect_pressed(move |_, _, _, _| {
657            *drag.pressed.borrow_mut() = Some((index_path.clone(), path.clone()));
658        });
659        row.add_controller(press);
660    }
661    {
662        let drag = drag.clone();
663        let index_path = index_path.to_vec();
664        let target_path = index_path.clone();
665        let drop = gtk::DropTarget::new(gdk::FileList::static_type(), gdk::DragAction::MOVE);
666        drop.connect_drop(move |_, _value, _x, _y| {
667            let Some(from) = drag.dragging.borrow().clone() else {
668                return false;
669            };
670            // Claim a drop on the dragged row itself so the scroller's
671            // top-level target does not fire; otherwise do the move.
672            if from == index_path {
673                return true;
674            }
675            let to = if folder {
676                MoveTarget::Into(index_path.clone())
677            } else {
678                MoveTarget::Before(target_path.clone())
679            };
680            on_event(BookmarkEvent::Move { from, to });
681            true
682        });
683        row.add_controller(drop);
684    }
685
686    row.upcast()
687}
688
689/// The full right-click menu for a bookmark: the context menu its directory
690/// would get in the tree (when it has a path), reduced to actions that make
691/// sense on a bare path, followed by the `[bookmarks] context` extras.
692fn build_bookmark_actions(path: Option<&Path>, menu: &BookmarkMenuConfig) -> Vec<ContextAction> {
693    let mut actions: Vec<ContextAction> = match path {
694        Some(path) => menu.context.actions_for(path).into_iter().filter(action_allowed).collect(),
695        None => Vec::new(),
696    };
697    actions.extend(menu.extras.iter().filter(|a| action_allowed(a)).cloned());
698    trim_separators(&mut actions);
699    actions
700}
701
702/// Drop separators that would lead or trail the menu (e.g. a path-less folder
703/// whose only inherited section is empty).
704fn trim_separators(actions: &mut Vec<ContextAction>) {
705    while actions.first().is_some_and(is_separator) {
706        actions.remove(0);
707    }
708    while actions.last().is_some_and(is_separator) {
709        actions.pop();
710    }
711}
712
713/// Whether an item is a menu divider.
714fn is_separator(action: &ContextAction) -> bool {
715    builtin_of(action) == Some(BuiltinAction::Separator)
716}
717
718/// Whether a configured item can appear in a bookmark's menu. Inherited
719/// actions that need a tree row (new file/folder, cut/copy/paste, thumbnails)
720/// are dropped; bookmark-only actions and separators are always kept.
721fn action_allowed(action: &ContextAction) -> bool {
722    match action {
723        ContextAction::Command(_) => true,
724        ContextAction::Submenu(sub) => sub.items.iter().any(action_allowed),
725        ContextAction::Builtin(b) => {
726            *b == BuiltinAction::Separator || is_path_safe(*b) || b.is_bookmark_only()
727        }
728        ContextAction::Entry(e) => is_path_safe(e.action) || e.action.is_bookmark_only(),
729    }
730}
731
732/// Append `actions` (recursively, including submenus) to a bookmark menu box.
733fn append_bookmark_items(
734    menu_box: &gtk::Box,
735    actions: &[ContextAction],
736    path: Option<&Path>,
737    index_path: &[usize],
738    side: PanelSide,
739    on_event: Rc<dyn Fn(BookmarkEvent)>,
740) {
741    for action in actions {
742        if action.is_hidden() {
743            continue;
744        }
745        if builtin_of(action) == Some(BuiltinAction::Separator) {
746            let separator = gtk::Separator::new(gtk::Orientation::Horizontal);
747            separator.add_css_class("tree-menu-separator");
748            menu_box.append(&separator);
749            continue;
750        }
751
752        // A submenu row opens a nested popover.
753        if let ContextAction::Submenu(sub) = action {
754            let button = submenu_row_button(&sub.label);
755            let popover = build_bookmark_submenu(&sub.items, path, index_path, side, on_event.clone());
756            let child = popover.clone();
757            let anchor = button.clone();
758            button.connect_clicked(move |_| {
759                child.set_parent(&anchor);
760                child.popup();
761            });
762            let child = popover.clone();
763            popover.connect_closed(move |_| child.unparent());
764            let row_box = gtk::Box::new(gtk::Orientation::Horizontal, 12);
765            row_box.add_css_class("tree-menu-item");
766            row_box.append(&button);
767            row_box.append(&submenu_arrow());
768            menu_box.append(&row_box);
769            continue;
770        }
771
772        let Some(target) = ShortcutTarget::from_action(action) else {
773            continue;
774        };
775        let Some(event) = bookmark_event(target, path, index_path) else {
776            continue;
777        };
778
779        let row_box = gtk::Box::new(gtk::Orientation::Horizontal, 12);
780        row_box.add_css_class("tree-menu-item");
781        let label = gtk::Label::new(Some(&menu_label(action, path.unwrap_or(Path::new("")), side)));
782        label.set_xalign(0.0);
783        label.set_hexpand(true);
784        let button = gtk::Button::new();
785        button.set_halign(gtk::Align::Fill);
786        button.set_hexpand(true);
787        button.set_child(Some(&label));
788        {
789            let on_event = on_event.clone();
790            button.connect_clicked(move |_| on_event(event.clone()));
791        }
792        row_box.append(&button);
793
794        if let Some(shortcut) = action.shortcut()
795            && let Some(display) = accel_display(&shortcut)
796        {
797            let hint = gtk::Label::new(Some(&display));
798            hint.add_css_class("tree-menu-shortcut");
799            hint.set_halign(gtk::Align::End);
800            hint.set_valign(gtk::Align::Center);
801            row_box.append(&hint);
802        }
803        menu_box.append(&row_box);
804    }
805}
806
807/// The event a menu `target` produces: bookmark-only actions edit/delete the
808/// entry; everything else runs against the target path (which a path-less
809/// folder never offers, hence `None`).
810fn bookmark_event(
811    target: ShortcutTarget,
812    path: Option<&Path>,
813    index_path: &[usize],
814) -> Option<BookmarkEvent> {
815    match &target {
816        ShortcutTarget::Builtin(BuiltinAction::EditBookmark) => {
817            Some(BookmarkEvent::Edit(index_path.to_vec()))
818        }
819        ShortcutTarget::Builtin(BuiltinAction::DeleteBookmark) => {
820            Some(BookmarkEvent::Delete(index_path.to_vec()))
821        }
822        ShortcutTarget::Builtin(BuiltinAction::NewBookmark) => {
823            Some(BookmarkEvent::NewBookmark)
824        }
825        ShortcutTarget::Builtin(BuiltinAction::NewBookmarkFolder) => {
826            Some(BookmarkEvent::NewBookmarkFolder)
827        }
828        _ => path.map(|path| BookmarkEvent::Action {
829            path: path.to_path_buf(),
830            target,
831        }),
832    }
833}
834
835/// Build a nested popover for a bookmark submenu.
836fn build_bookmark_submenu(
837    items: &[ContextAction],
838    path: Option<&Path>,
839    index_path: &[usize],
840    side: PanelSide,
841    on_event: Rc<dyn Fn(BookmarkEvent)>,
842) -> gtk::Popover {
843    let menu_box = gtk::Box::new(gtk::Orientation::Vertical, 0);
844    menu_box.add_css_class("bookmark-menu");
845    append_bookmark_items(&menu_box, items, path, index_path, side, on_event);
846    let popover = gtk::Popover::new();
847    popover.add_css_class("bookmark-menu-popover");
848    popover.set_has_arrow(false);
849    popover.set_position(gtk::PositionType::Right);
850    popover.set_child(Some(&menu_box));
851    popover
852}
853
854/// The builtin behind an item, if any (mirrors the tree's own helper).
855fn builtin_of(action: &ContextAction) -> Option<BuiltinAction> {
856    match action {
857        ContextAction::Builtin(b) => Some(*b),
858        ContextAction::Entry(e) => Some(e.action),
859        _ => None,
860    }
861}
862
863/// A full-width, left-aligned button for a submenu's label row.
864fn submenu_row_button(label: &str) -> gtk::Button {
865    let button = gtk::Button::new();
866    button.set_halign(gtk::Align::Fill);
867    button.set_hexpand(true);
868    let text = gtk::Label::new(Some(label));
869    text.set_xalign(0.0);
870    text.set_hexpand(true);
871    button.set_child(Some(&text));
872    button
873}
874
875/// The trailing "▸" glyph shown on a submenu row.
876fn submenu_arrow() -> gtk::Label {
877    let arrow = gtk::Label::new(Some("\u{25b8}"));
878    arrow.add_css_class("tree-menu-submenu-arrow");
879    arrow.set_halign(gtk::Align::End);
880    arrow.set_valign(gtk::Align::Center);
881    arrow
882}
883
884/// Show the bookmark editor / creation dialog. `title` is the window title
885/// ("Edit Bookmark", "New Folder", ...). A folder (`path` is `None`) edits just
886/// the name; a leaf edits the name and path (with a folder picker). `on_save`
887/// receives the resolved name and, for a leaf, the path.
888pub fn show_bookmark_dialog(
889    parent: &gtk::Window,
890    title: &str,
891    name: &str,
892    path: Option<&Path>,
893    on_save: impl Fn(String, Option<PathBuf>) + 'static,
894) {
895    let folder = path.is_none();
896    let window = gtk::Window::new();
897    window.set_title(Some(title));
898    window.set_default_size(420, -1);
899    window.set_transient_for(Some(parent));
900    if gtk4_layer_shell::is_supported() && !window.is_layer_window() {
901        window.init_layer_shell();
902        window.set_namespace(Some(crate::ui::LAYER_NAMESPACE));
903        window.set_layer(Layer::Overlay);
904        window.set_keyboard_mode(KeyboardMode::Exclusive);
905    }
906
907    let form = gtk::Box::new(gtk::Orientation::Vertical, 8);
908    form.add_css_class("bookmark-editor");
909
910    let name_label = gtk::Label::new(Some("Name"));
911    name_label.set_xalign(0.0);
912    name_label.add_css_class("bookmark-field-label");
913    let name_entry = gtk::Entry::new();
914    name_entry.set_text(name);
915    name_entry.set_hexpand(true);
916
917    let path_label = gtk::Label::new(Some("Path"));
918    path_label.set_xalign(0.0);
919    path_label.add_css_class("bookmark-field-label");
920    let path_row = gtk::Box::new(gtk::Orientation::Horizontal, 6);
921    let path_entry = gtk::Entry::new();
922    path_entry.set_text(&path.map(|p| p.display().to_string()).unwrap_or_default());
923    path_entry.set_hexpand(true);
924    let browse = gtk::Button::with_label("Browse...");
925    path_row.append(&path_entry);
926    path_row.append(&browse);
927
928    // A folder has no path of its own.
929    path_label.set_visible(!folder);
930    path_row.set_visible(!folder);
931
932    form.append(&name_label);
933    form.append(&name_entry);
934    form.append(&path_label);
935    form.append(&path_row);
936
937    let footer = gtk::Box::new(gtk::Orientation::Horizontal, 6);
938    footer.set_halign(gtk::Align::End);
939    footer.add_css_class("bookmark-editor-footer");
940    let cancel = gtk::Button::with_label("Cancel");
941    let save = gtk::Button::with_label("Save");
942    save.add_css_class("suggested-action");
943    footer.append(&cancel);
944    footer.append(&save);
945
946    let root = gtk::Box::new(gtk::Orientation::Vertical, 0);
947    root.add_css_class("bookmark-editor-root");
948    root.append(&form);
949    root.append(&gtk::Separator::new(gtk::Orientation::Horizontal));
950    root.append(&footer);
951    window.set_child(Some(&root));
952
953    {
954        let path_entry = path_entry.clone();
955        let window = window.clone();
956        browse.connect_clicked(move |_| {
957            let dialog = gtk::FileDialog::new();
958            let path_entry = path_entry.clone();
959            dialog.select_folder(Some(&window), None::<&gio::Cancellable>, move |res| {
960                if let Ok(file) = res
961                    && let Some(p) = file.path()
962                {
963                    path_entry.set_text(&p.display().to_string());
964                }
965            });
966        });
967    }
968
969    // Validate and dispatch. A folder needs only a name; a leaf needs a path (a
970    // blank name falls back to the directory's name). A blank field is rejected,
971    // leaving the dialog open.
972    let submit: Rc<dyn Fn()> = {
973        let name_entry = name_entry.clone();
974        let path_entry = path_entry.clone();
975        let window = window.clone();
976        let on_save = Rc::new(on_save);
977        Rc::new(move || {
978            let typed = name_entry.text().trim().to_string();
979            if folder {
980                if typed.is_empty() {
981                    return;
982                }
983                on_save(typed, None);
984                window.close();
985                return;
986            }
987            let raw = path_entry.text().trim().to_string();
988            if raw.is_empty() {
989                return;
990            }
991            let path = expand_bookmark_path(Path::new(&raw));
992            let name = if typed.is_empty() { Bookmark::default_name(&path) } else { typed };
993            on_save(name, Some(path));
994            window.close();
995        })
996    };
997
998    {
999        let submit = submit.clone();
1000        save.connect_clicked(move |_| submit());
1001    }
1002    {
1003        let submit = submit.clone();
1004        name_entry.connect_activate(move |_| submit());
1005    }
1006    {
1007        let submit = submit.clone();
1008        path_entry.connect_activate(move |_| submit());
1009    }
1010    {
1011        let window = window.clone();
1012        cancel.connect_clicked(move |_| window.close());
1013    }
1014    {
1015        let keys = gtk::EventControllerKey::new();
1016        window.add_controller(keys.clone());
1017        let window = window.clone();
1018        keys.connect_key_pressed(move |_, key, _, _| {
1019            if key == gdk::Key::Escape {
1020                window.close();
1021                glib::Propagation::Stop
1022            } else {
1023                glib::Propagation::Proceed
1024            }
1025        });
1026    }
1027
1028    window.present();
1029}
1030
1031#[cfg(test)]
1032mod tests {
1033    use super::{bookmark_matches, cursor_after_move, cursor_after_rebuild, visible_nodes, BookmarkNode};
1034    use crate::config::Bookmark;
1035    use std::path::PathBuf;
1036
1037    fn bm(name: &str, path: &str) -> Bookmark {
1038        Bookmark::leaf(name.to_owned(), PathBuf::from(path))
1039    }
1040
1041    fn folder(name: &str, expanded: bool, items: Vec<Bookmark>) -> Bookmark {
1042        Bookmark {
1043            name: name.to_owned(),
1044            path: None,
1045            items,
1046            expanded,
1047        }
1048    }
1049
1050    #[test]
1051    fn filter_matches_name_or_path_case_insensitively() {
1052        let b = bm("Projects", "/home/me/Projects");
1053        // The caller lowercases the query; an empty one matches everything.
1054        assert!(bookmark_matches(&b, ""));
1055        assert!(bookmark_matches(&b, "proj"));
1056        assert!(bookmark_matches(&b, "/home/me"));
1057        assert!(!bookmark_matches(&b, "photos"));
1058    }
1059
1060    #[test]
1061    fn visible_nodes_respects_expansion() {
1062        let list = vec![
1063            bm("Home", "/home/me"),
1064            folder("Work", false, vec![bm("Repo", "/srv/repo")]),
1065            folder("Open", true, vec![bm("Doc", "/srv/doc")]),
1066        ];
1067        let nodes = visible_nodes(&list, None);
1068        // A collapsed folder contributes only itself; an expanded one lists its
1069        // child at the next depth.
1070        let shape: Vec<(Vec<usize>, usize)> =
1071            nodes.iter().map(|n| (n.index_path.clone(), n.depth)).collect();
1072        assert_eq!(
1073            shape,
1074            vec![
1075                (vec![0], 0),
1076                (vec![1], 0),
1077                (vec![2], 0),
1078                (vec![2, 0], 1),
1079            ]
1080        );
1081        assert!(nodes[1].folder && !nodes[1].open);
1082        assert!(!nodes[3].folder && nodes[3].depth == 1);
1083    }
1084
1085    #[test]
1086    fn visible_nodes_filter_keeps_a_folder_only_via_a_descendant() {
1087        let list = vec![folder(
1088            "Work",
1089            false,
1090            vec![bm("Design", "/srv/design"), bm("Code", "/srv/code")],
1091        )];
1092        let nodes = visible_nodes(&list, Some("design"));
1093        // The folder is kept (forced open) with only the matching child.
1094        assert_eq!(nodes.len(), 2);
1095        assert_eq!(nodes[0].index_path, vec![0]);
1096        assert!(nodes[0].folder && nodes[0].open);
1097        assert_eq!(nodes[1].index_path, vec![0, 0]);
1098        assert_eq!(nodes[1].name, "Design");
1099    }
1100
1101    #[test]
1102    fn visible_nodes_filter_on_a_folder_shows_all_children() {
1103        let list = vec![folder(
1104            "Work",
1105            false,
1106            vec![bm("Design", "/srv/design"), bm("Code", "/srv/code")],
1107        )];
1108        let nodes = visible_nodes(&list, Some("work"));
1109        assert_eq!(nodes.len(), 3);
1110        assert!(nodes[0].open);
1111        assert_eq!(nodes[1].name, "Design");
1112        assert_eq!(nodes[2].name, "Code");
1113    }
1114
1115    #[test]
1116    fn cursor_after_move_clamps_and_starts_at_the_top() {
1117        assert_eq!(cursor_after_move(None, 1, 3), Some(0));
1118        assert_eq!(cursor_after_move(None, -1, 3), Some(0));
1119        assert_eq!(cursor_after_move(Some(0), -1, 3), Some(0));
1120        assert_eq!(cursor_after_move(Some(2), 1, 3), Some(2));
1121        assert_eq!(cursor_after_move(Some(1), 1, 3), Some(2));
1122        assert_eq!(cursor_after_move(Some(1), -1, 3), Some(0));
1123        assert_eq!(cursor_after_move(Some(0), 1, 0), None);
1124    }
1125
1126    #[test]
1127    fn cursor_after_rebuild_sticks_to_the_same_entry() {
1128        let node = |path: &[usize]| BookmarkNode {
1129            index_path: path.to_vec(),
1130            name: String::new(),
1131            path: None,
1132            folder: false,
1133            open: false,
1134            depth: 0,
1135        };
1136        let nodes = vec![node(&[0]), node(&[1]), node(&[1, 0])];
1137        assert_eq!(cursor_after_rebuild(Some(&[1, 0]), &nodes, nodes.len()), Some(2));
1138        // A vanished entry falls back to the first row.
1139        assert_eq!(cursor_after_rebuild(Some(&[9]), &nodes, nodes.len()), Some(0));
1140        assert_eq!(cursor_after_rebuild(None, &nodes, nodes.len()), None);
1141        assert_eq!(cursor_after_rebuild(Some(&[0]), &[], 0), None);
1142    }
1143}