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_layer(Layer::Overlay);
903        window.set_keyboard_mode(KeyboardMode::Exclusive);
904    }
905
906    let form = gtk::Box::new(gtk::Orientation::Vertical, 8);
907    form.add_css_class("bookmark-editor");
908
909    let name_label = gtk::Label::new(Some("Name"));
910    name_label.set_xalign(0.0);
911    name_label.add_css_class("bookmark-field-label");
912    let name_entry = gtk::Entry::new();
913    name_entry.set_text(name);
914    name_entry.set_hexpand(true);
915
916    let path_label = gtk::Label::new(Some("Path"));
917    path_label.set_xalign(0.0);
918    path_label.add_css_class("bookmark-field-label");
919    let path_row = gtk::Box::new(gtk::Orientation::Horizontal, 6);
920    let path_entry = gtk::Entry::new();
921    path_entry.set_text(&path.map(|p| p.display().to_string()).unwrap_or_default());
922    path_entry.set_hexpand(true);
923    let browse = gtk::Button::with_label("Browse...");
924    path_row.append(&path_entry);
925    path_row.append(&browse);
926
927    // A folder has no path of its own.
928    path_label.set_visible(!folder);
929    path_row.set_visible(!folder);
930
931    form.append(&name_label);
932    form.append(&name_entry);
933    form.append(&path_label);
934    form.append(&path_row);
935
936    let footer = gtk::Box::new(gtk::Orientation::Horizontal, 6);
937    footer.set_halign(gtk::Align::End);
938    footer.add_css_class("bookmark-editor-footer");
939    let cancel = gtk::Button::with_label("Cancel");
940    let save = gtk::Button::with_label("Save");
941    save.add_css_class("suggested-action");
942    footer.append(&cancel);
943    footer.append(&save);
944
945    let root = gtk::Box::new(gtk::Orientation::Vertical, 0);
946    root.add_css_class("bookmark-editor-root");
947    root.append(&form);
948    root.append(&gtk::Separator::new(gtk::Orientation::Horizontal));
949    root.append(&footer);
950    window.set_child(Some(&root));
951
952    {
953        let path_entry = path_entry.clone();
954        let window = window.clone();
955        browse.connect_clicked(move |_| {
956            let dialog = gtk::FileDialog::new();
957            let path_entry = path_entry.clone();
958            dialog.select_folder(Some(&window), None::<&gio::Cancellable>, move |res| {
959                if let Ok(file) = res
960                    && let Some(p) = file.path()
961                {
962                    path_entry.set_text(&p.display().to_string());
963                }
964            });
965        });
966    }
967
968    // Validate and dispatch. A folder needs only a name; a leaf needs a path (a
969    // blank name falls back to the directory's name). A blank field is rejected,
970    // leaving the dialog open.
971    let submit: Rc<dyn Fn()> = {
972        let name_entry = name_entry.clone();
973        let path_entry = path_entry.clone();
974        let window = window.clone();
975        let on_save = Rc::new(on_save);
976        Rc::new(move || {
977            let typed = name_entry.text().trim().to_string();
978            if folder {
979                if typed.is_empty() {
980                    return;
981                }
982                on_save(typed, None);
983                window.close();
984                return;
985            }
986            let raw = path_entry.text().trim().to_string();
987            if raw.is_empty() {
988                return;
989            }
990            let path = expand_bookmark_path(Path::new(&raw));
991            let name = if typed.is_empty() { Bookmark::default_name(&path) } else { typed };
992            on_save(name, Some(path));
993            window.close();
994        })
995    };
996
997    {
998        let submit = submit.clone();
999        save.connect_clicked(move |_| submit());
1000    }
1001    {
1002        let submit = submit.clone();
1003        name_entry.connect_activate(move |_| submit());
1004    }
1005    {
1006        let submit = submit.clone();
1007        path_entry.connect_activate(move |_| submit());
1008    }
1009    {
1010        let window = window.clone();
1011        cancel.connect_clicked(move |_| window.close());
1012    }
1013    {
1014        let keys = gtk::EventControllerKey::new();
1015        window.add_controller(keys.clone());
1016        let window = window.clone();
1017        keys.connect_key_pressed(move |_, key, _, _| {
1018            if key == gdk::Key::Escape {
1019                window.close();
1020                glib::Propagation::Stop
1021            } else {
1022                glib::Propagation::Proceed
1023            }
1024        });
1025    }
1026
1027    window.present();
1028}
1029
1030#[cfg(test)]
1031mod tests {
1032    use super::{bookmark_matches, cursor_after_move, cursor_after_rebuild, visible_nodes, BookmarkNode};
1033    use crate::config::Bookmark;
1034    use std::path::PathBuf;
1035
1036    fn bm(name: &str, path: &str) -> Bookmark {
1037        Bookmark::leaf(name.to_owned(), PathBuf::from(path))
1038    }
1039
1040    fn folder(name: &str, expanded: bool, items: Vec<Bookmark>) -> Bookmark {
1041        Bookmark {
1042            name: name.to_owned(),
1043            path: None,
1044            items,
1045            expanded,
1046        }
1047    }
1048
1049    #[test]
1050    fn filter_matches_name_or_path_case_insensitively() {
1051        let b = bm("Projects", "/home/me/Projects");
1052        // The caller lowercases the query; an empty one matches everything.
1053        assert!(bookmark_matches(&b, ""));
1054        assert!(bookmark_matches(&b, "proj"));
1055        assert!(bookmark_matches(&b, "/home/me"));
1056        assert!(!bookmark_matches(&b, "photos"));
1057    }
1058
1059    #[test]
1060    fn visible_nodes_respects_expansion() {
1061        let list = vec![
1062            bm("Home", "/home/me"),
1063            folder("Work", false, vec![bm("Repo", "/srv/repo")]),
1064            folder("Open", true, vec![bm("Doc", "/srv/doc")]),
1065        ];
1066        let nodes = visible_nodes(&list, None);
1067        // A collapsed folder contributes only itself; an expanded one lists its
1068        // child at the next depth.
1069        let shape: Vec<(Vec<usize>, usize)> =
1070            nodes.iter().map(|n| (n.index_path.clone(), n.depth)).collect();
1071        assert_eq!(
1072            shape,
1073            vec![
1074                (vec![0], 0),
1075                (vec![1], 0),
1076                (vec![2], 0),
1077                (vec![2, 0], 1),
1078            ]
1079        );
1080        assert!(nodes[1].folder && !nodes[1].open);
1081        assert!(!nodes[3].folder && nodes[3].depth == 1);
1082    }
1083
1084    #[test]
1085    fn visible_nodes_filter_keeps_a_folder_only_via_a_descendant() {
1086        let list = vec![folder(
1087            "Work",
1088            false,
1089            vec![bm("Design", "/srv/design"), bm("Code", "/srv/code")],
1090        )];
1091        let nodes = visible_nodes(&list, Some("design"));
1092        // The folder is kept (forced open) with only the matching child.
1093        assert_eq!(nodes.len(), 2);
1094        assert_eq!(nodes[0].index_path, vec![0]);
1095        assert!(nodes[0].folder && nodes[0].open);
1096        assert_eq!(nodes[1].index_path, vec![0, 0]);
1097        assert_eq!(nodes[1].name, "Design");
1098    }
1099
1100    #[test]
1101    fn visible_nodes_filter_on_a_folder_shows_all_children() {
1102        let list = vec![folder(
1103            "Work",
1104            false,
1105            vec![bm("Design", "/srv/design"), bm("Code", "/srv/code")],
1106        )];
1107        let nodes = visible_nodes(&list, Some("work"));
1108        assert_eq!(nodes.len(), 3);
1109        assert!(nodes[0].open);
1110        assert_eq!(nodes[1].name, "Design");
1111        assert_eq!(nodes[2].name, "Code");
1112    }
1113
1114    #[test]
1115    fn cursor_after_move_clamps_and_starts_at_the_top() {
1116        assert_eq!(cursor_after_move(None, 1, 3), Some(0));
1117        assert_eq!(cursor_after_move(None, -1, 3), Some(0));
1118        assert_eq!(cursor_after_move(Some(0), -1, 3), Some(0));
1119        assert_eq!(cursor_after_move(Some(2), 1, 3), Some(2));
1120        assert_eq!(cursor_after_move(Some(1), 1, 3), Some(2));
1121        assert_eq!(cursor_after_move(Some(1), -1, 3), Some(0));
1122        assert_eq!(cursor_after_move(Some(0), 1, 0), None);
1123    }
1124
1125    #[test]
1126    fn cursor_after_rebuild_sticks_to_the_same_entry() {
1127        let node = |path: &[usize]| BookmarkNode {
1128            index_path: path.to_vec(),
1129            name: String::new(),
1130            path: None,
1131            folder: false,
1132            open: false,
1133            depth: 0,
1134        };
1135        let nodes = vec![node(&[0]), node(&[1]), node(&[1, 0])];
1136        assert_eq!(cursor_after_rebuild(Some(&[1, 0]), &nodes, nodes.len()), Some(2));
1137        // A vanished entry falls back to the first row.
1138        assert_eq!(cursor_after_rebuild(Some(&[9]), &nodes, nodes.len()), Some(0));
1139        assert_eq!(cursor_after_rebuild(None, &nodes, nodes.len()), None);
1140        assert_eq!(cursor_after_rebuild(Some(&[0]), &[], 0), None);
1141    }
1142}