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