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::path::{Path, PathBuf};
15use std::rc::Rc;
16
17use gtk4_layer_shell::{KeyboardMode, Layer, LayerShell};
18use relm4::gtk;
19use relm4::gtk::{gdk, gio, glib, pango, prelude::*};
20
21use crate::config::{
22    Bookmark, BuiltinAction, ContextAction, ContextMenu, PanelSide, ShortcutTarget,
23    expand_bookmark_path,
24};
25use crate::ui::tree::{accel_display, is_path_safe, menu_label};
26
27/// What the user did in the bookmarks view.
28#[derive(Debug, Clone)]
29pub enum BookmarkEvent {
30    /// Jump this pane to the bookmark's directory.
31    Open(PathBuf),
32    /// Edit the bookmark at this index in the list.
33    Edit(usize),
34    /// Delete the bookmark at this index in the list.
35    Delete(usize),
36    /// Run an inherited (directory) context action against `path` without
37    /// opening it.
38    Action { path: PathBuf, target: ShortcutTarget },
39}
40
41/// The config a bookmark's right-click menu is assembled from: the inherited
42/// context menu, the extra `[bookmarks] context` items, and the pane side (for
43/// labels like "In right panel").
44pub struct BookmarkMenuConfig<'a> {
45    pub context: &'a ContextMenu,
46    pub extras: &'a [ContextAction],
47    pub side: PanelSide,
48}
49
50/// Replace the contents of `list` with a row per bookmark that matches `filter`
51/// (case-insensitive substring of the name or path; empty shows all). Used both
52/// for the initial view and to refresh every open pane when the list changes.
53pub fn fill_bookmarks(
54    list: &gtk::Box,
55    bookmarks: &[Bookmark],
56    filter: &str,
57    menu: &BookmarkMenuConfig,
58    on_event: Rc<dyn Fn(BookmarkEvent)>,
59) {
60    while let Some(child) = list.first_child() {
61        list.remove(&child);
62    }
63    let query = filter.trim().to_lowercase();
64    let visible: Vec<(usize, &Bookmark)> = bookmarks
65        .iter()
66        .enumerate()
67        .filter(|(_, bookmark)| bookmark_matches(bookmark, &query))
68        .collect();
69    if visible.is_empty() {
70        let text = if query.is_empty() {
71            "No bookmarks — use Add Bookmark"
72        } else {
73            "No matching bookmarks"
74        };
75        let empty = gtk::Label::new(Some(text));
76        empty.add_css_class("bookmarks-empty");
77        empty.set_xalign(0.0);
78        list.append(&empty);
79        return;
80    }
81    for (index, bookmark) in visible {
82        list.append(&bookmark_row(index, bookmark, menu, on_event.clone()));
83    }
84}
85
86/// Whether `bookmark` matches a lowercase `query` (already trimmed). Matches the
87/// name or the path so a directory can be found by either.
88fn bookmark_matches(bookmark: &Bookmark, query: &str) -> bool {
89    query.is_empty()
90        || bookmark.name.to_lowercase().contains(query)
91        || bookmark.path.to_string_lossy().to_lowercase().contains(query)
92}
93
94/// One bookmark row: an icon and its label, clickable to open, with a
95/// right-click menu (the inherited directory menu plus `[bookmarks] context`).
96fn bookmark_row(
97    index: usize,
98    bookmark: &Bookmark,
99    menu: &BookmarkMenuConfig,
100    on_event: Rc<dyn Fn(BookmarkEvent)>,
101) -> gtk::Widget {
102    let path = expand_bookmark_path(&bookmark.path);
103    let row = gtk::Button::new();
104    row.add_css_class("bookmark-row");
105    row.set_tooltip_text(Some(&path.display().to_string()));
106
107    let content = gtk::Box::new(gtk::Orientation::Horizontal, 8);
108    // Deliberately not a folder icon, so bookmarks read as a distinct kind.
109    let icon = gtk::Image::from_icon_name("user-bookmarks-symbolic");
110    icon.add_css_class("bookmark-icon");
111    let label = gtk::Label::new(Some(&bookmark.name));
112    label.set_xalign(0.0);
113    label.set_hexpand(true);
114    label.set_ellipsize(pango::EllipsizeMode::End);
115    label.add_css_class("bookmark-label");
116    content.append(&icon);
117    content.append(&label);
118    row.set_child(Some(&content));
119
120    {
121        let on_event = on_event.clone();
122        let path = path.clone();
123        row.connect_clicked(move |_| on_event(BookmarkEvent::Open(path.clone())));
124    }
125
126    // ── right-click: inherited directory menu + bookmark extras ─────────────
127    let popover = gtk::Popover::new();
128    popover.add_css_class("bookmark-menu-popover");
129    popover.set_has_arrow(true);
130    let menu_box = gtk::Box::new(gtk::Orientation::Vertical, 0);
131    menu_box.add_css_class("bookmark-menu");
132    let actions = build_bookmark_actions(&path, menu);
133    append_bookmark_items(&menu_box, &actions, &path, index, menu.side, on_event);
134    popover.set_child(Some(&menu_box));
135
136    popover.set_parent(&row);
137    // The row is rebuilt whenever the list changes; unparent the popover as the
138    // row goes away so GTK does not warn about a widget with children left.
139    {
140        let popover = popover.clone();
141        row.connect_destroy(move |_| popover.unparent());
142    }
143    let gesture = gtk::GestureClick::new();
144    gesture.set_button(gdk::BUTTON_SECONDARY);
145    {
146        let popover = popover.clone();
147        gesture.connect_pressed(move |gesture, _, x, y| {
148            gesture.set_state(gtk::EventSequenceState::Claimed);
149            popover.set_pointing_to(Some(&gdk::Rectangle::new(x as i32, y as i32, 1, 1)));
150            popover.popup();
151        });
152    }
153    row.add_controller(gesture);
154
155    row.upcast()
156}
157
158/// The full right-click menu for the bookmark at `path`: the context menu the
159/// directory would get in the tree, reduced to actions that make sense on a
160/// bare path, followed by the `[bookmarks] context` extras.
161fn build_bookmark_actions(path: &Path, menu: &BookmarkMenuConfig) -> Vec<ContextAction> {
162    let mut actions: Vec<ContextAction> = menu
163        .context
164        .actions_for(path)
165        .into_iter()
166        .filter(action_allowed)
167        .collect();
168    actions.extend(menu.extras.iter().filter(|a| action_allowed(a)).cloned());
169    actions
170}
171
172/// Whether a configured item can appear in a bookmark's menu. Inherited
173/// actions that need a tree row (new file/folder, cut/copy/paste, thumbnails)
174/// are dropped; bookmark-only actions and separators are always kept.
175fn action_allowed(action: &ContextAction) -> bool {
176    match action {
177        ContextAction::Command(_) => true,
178        ContextAction::Submenu(sub) => sub.items.iter().any(action_allowed),
179        ContextAction::Builtin(b) => {
180            *b == BuiltinAction::Separator || is_path_safe(*b) || b.is_bookmark_only()
181        }
182        ContextAction::Entry(e) => is_path_safe(e.action) || e.action.is_bookmark_only(),
183    }
184}
185
186/// Append `actions` (recursively, including submenus) to a bookmark menu box.
187fn append_bookmark_items(
188    menu_box: &gtk::Box,
189    actions: &[ContextAction],
190    path: &Path,
191    index: usize,
192    side: PanelSide,
193    on_event: Rc<dyn Fn(BookmarkEvent)>,
194) {
195    for action in actions {
196        if action.is_hidden() {
197            continue;
198        }
199        if builtin_of(action) == Some(BuiltinAction::Separator) {
200            let separator = gtk::Separator::new(gtk::Orientation::Horizontal);
201            separator.add_css_class("tree-menu-separator");
202            menu_box.append(&separator);
203            continue;
204        }
205
206        // A submenu row opens a nested popover.
207        if let ContextAction::Submenu(sub) = action {
208            let button = submenu_row_button(&sub.label);
209            let popover = build_bookmark_submenu(&sub.items, path, index, side, on_event.clone());
210            let child = popover.clone();
211            let anchor = button.clone();
212            button.connect_clicked(move |_| {
213                child.set_parent(&anchor);
214                child.popup();
215            });
216            let child = popover.clone();
217            popover.connect_closed(move |_| child.unparent());
218            let row_box = gtk::Box::new(gtk::Orientation::Horizontal, 12);
219            row_box.add_css_class("tree-menu-item");
220            row_box.append(&button);
221            row_box.append(&submenu_arrow());
222            menu_box.append(&row_box);
223            continue;
224        }
225
226        let Some(target) = ShortcutTarget::from_action(action) else {
227            continue;
228        };
229        let event = bookmark_event(target, path, index);
230
231        let row_box = gtk::Box::new(gtk::Orientation::Horizontal, 12);
232        row_box.add_css_class("tree-menu-item");
233        let label = gtk::Label::new(Some(&menu_label(action, path, side)));
234        label.set_xalign(0.0);
235        label.set_hexpand(true);
236        let button = gtk::Button::new();
237        button.set_halign(gtk::Align::Fill);
238        button.set_hexpand(true);
239        button.set_child(Some(&label));
240        {
241            let on_event = on_event.clone();
242            button.connect_clicked(move |_| on_event(event.clone()));
243        }
244        row_box.append(&button);
245
246        if let Some(shortcut) = action.shortcut()
247            && let Some(display) = accel_display(&shortcut)
248        {
249            let hint = gtk::Label::new(Some(&display));
250            hint.add_css_class("tree-menu-shortcut");
251            hint.set_halign(gtk::Align::End);
252            hint.set_valign(gtk::Align::Center);
253            row_box.append(&hint);
254        }
255        menu_box.append(&row_box);
256    }
257}
258
259/// The event a menu `target` produces: bookmark-only actions edit/delete the
260/// entry; everything else runs against the target path.
261fn bookmark_event(target: ShortcutTarget, path: &Path, index: usize) -> BookmarkEvent {
262    match &target {
263        ShortcutTarget::Builtin(BuiltinAction::EditBookmark) => BookmarkEvent::Edit(index),
264        ShortcutTarget::Builtin(BuiltinAction::DeleteBookmark) => BookmarkEvent::Delete(index),
265        _ => BookmarkEvent::Action { path: path.to_path_buf(), target },
266    }
267}
268
269/// Build a nested popover for a bookmark submenu.
270fn build_bookmark_submenu(
271    items: &[ContextAction],
272    path: &Path,
273    index: usize,
274    side: PanelSide,
275    on_event: Rc<dyn Fn(BookmarkEvent)>,
276) -> gtk::Popover {
277    let menu_box = gtk::Box::new(gtk::Orientation::Vertical, 0);
278    menu_box.add_css_class("bookmark-menu");
279    append_bookmark_items(&menu_box, items, path, index, side, on_event);
280    let popover = gtk::Popover::new();
281    popover.add_css_class("bookmark-menu-popover");
282    popover.set_has_arrow(false);
283    popover.set_position(gtk::PositionType::Right);
284    popover.set_child(Some(&menu_box));
285    popover
286}
287
288/// The builtin behind an item, if any (mirrors the tree's own helper).
289fn builtin_of(action: &ContextAction) -> Option<BuiltinAction> {
290    match action {
291        ContextAction::Builtin(b) => Some(*b),
292        ContextAction::Entry(e) => Some(e.action),
293        _ => None,
294    }
295}
296
297/// A full-width, left-aligned button for a submenu's label row.
298fn submenu_row_button(label: &str) -> gtk::Button {
299    let button = gtk::Button::new();
300    button.set_halign(gtk::Align::Fill);
301    button.set_hexpand(true);
302    let text = gtk::Label::new(Some(label));
303    text.set_xalign(0.0);
304    text.set_hexpand(true);
305    button.set_child(Some(&text));
306    button
307}
308
309/// The trailing "▸" glyph shown on a submenu row.
310fn submenu_arrow() -> gtk::Label {
311    let arrow = gtk::Label::new(Some("\u{25b8}"));
312    arrow.add_css_class("tree-menu-submenu-arrow");
313    arrow.set_halign(gtk::Align::End);
314    arrow.set_valign(gtk::Align::Center);
315    arrow
316}
317
318/// Show the editor for a bookmark: an editable name and path (with a folder
319/// picker). `on_save` receives the resolved name and path when the user saves.
320pub fn show_bookmark_editor(
321    parent: &gtk::Window,
322    name: &str,
323    path: &Path,
324    on_save: impl Fn(String, PathBuf) + 'static,
325) {
326    let window = gtk::Window::new();
327    window.set_title(Some("Edit Bookmark"));
328    window.set_default_size(420, -1);
329    window.set_transient_for(Some(parent));
330    if gtk4_layer_shell::is_supported() && !window.is_layer_window() {
331        window.init_layer_shell();
332        window.set_layer(Layer::Overlay);
333        window.set_keyboard_mode(KeyboardMode::Exclusive);
334    }
335
336    let form = gtk::Box::new(gtk::Orientation::Vertical, 8);
337    form.add_css_class("bookmark-editor");
338
339    let name_label = gtk::Label::new(Some("Name"));
340    name_label.set_xalign(0.0);
341    name_label.add_css_class("bookmark-field-label");
342    let name_entry = gtk::Entry::new();
343    name_entry.set_text(name);
344    name_entry.set_hexpand(true);
345
346    let path_label = gtk::Label::new(Some("Path"));
347    path_label.set_xalign(0.0);
348    path_label.add_css_class("bookmark-field-label");
349    let path_row = gtk::Box::new(gtk::Orientation::Horizontal, 6);
350    let path_entry = gtk::Entry::new();
351    path_entry.set_text(&path.display().to_string());
352    path_entry.set_hexpand(true);
353    let browse = gtk::Button::with_label("Browse...");
354    path_row.append(&path_entry);
355    path_row.append(&browse);
356
357    form.append(&name_label);
358    form.append(&name_entry);
359    form.append(&path_label);
360    form.append(&path_row);
361
362    let footer = gtk::Box::new(gtk::Orientation::Horizontal, 6);
363    footer.set_halign(gtk::Align::End);
364    footer.add_css_class("bookmark-editor-footer");
365    let cancel = gtk::Button::with_label("Cancel");
366    let save = gtk::Button::with_label("Save");
367    save.add_css_class("suggested-action");
368    footer.append(&cancel);
369    footer.append(&save);
370
371    let root = gtk::Box::new(gtk::Orientation::Vertical, 0);
372    root.add_css_class("bookmark-editor-root");
373    root.append(&form);
374    root.append(&gtk::Separator::new(gtk::Orientation::Horizontal));
375    root.append(&footer);
376    window.set_child(Some(&root));
377
378    {
379        let path_entry = path_entry.clone();
380        let window = window.clone();
381        browse.connect_clicked(move |_| {
382            let dialog = gtk::FileDialog::new();
383            let path_entry = path_entry.clone();
384            dialog.select_folder(Some(&window), None::<&gio::Cancellable>, move |res| {
385                if let Ok(file) = res
386                    && let Some(p) = file.path()
387                {
388                    path_entry.set_text(&p.display().to_string());
389                }
390            });
391        });
392    }
393
394    // Validate and dispatch. A blank name falls back to the directory's name; a
395    // blank path is rejected (leaving the dialog open).
396    let submit: Rc<dyn Fn()> = {
397        let name_entry = name_entry.clone();
398        let path_entry = path_entry.clone();
399        let window = window.clone();
400        let on_save = Rc::new(on_save);
401        Rc::new(move || {
402            let raw = path_entry.text().trim().to_string();
403            if raw.is_empty() {
404                return;
405            }
406            let path = expand_bookmark_path(Path::new(&raw));
407            let typed = name_entry.text().trim().to_string();
408            let name = if typed.is_empty() { Bookmark::default_name(&path) } else { typed };
409            on_save(name, path);
410            window.close();
411        })
412    };
413
414    {
415        let submit = submit.clone();
416        save.connect_clicked(move |_| submit());
417    }
418    {
419        let submit = submit.clone();
420        name_entry.connect_activate(move |_| submit());
421    }
422    {
423        let submit = submit.clone();
424        path_entry.connect_activate(move |_| submit());
425    }
426    {
427        let window = window.clone();
428        cancel.connect_clicked(move |_| window.close());
429    }
430    {
431        let keys = gtk::EventControllerKey::new();
432        window.add_controller(keys.clone());
433        let window = window.clone();
434        keys.connect_key_pressed(move |_, key, _, _| {
435            if key == gdk::Key::Escape {
436                window.close();
437                glib::Propagation::Stop
438            } else {
439                glib::Propagation::Proceed
440            }
441        });
442    }
443
444    window.present();
445}
446
447#[cfg(test)]
448mod tests {
449    use super::bookmark_matches;
450    use crate::config::Bookmark;
451    use std::path::PathBuf;
452
453    fn bm(name: &str, path: &str) -> Bookmark {
454        Bookmark { name: name.to_owned(), path: PathBuf::from(path) }
455    }
456
457    #[test]
458    fn filter_matches_name_or_path_case_insensitively() {
459        let b = bm("Projects", "/home/me/Projects");
460        // The caller lowercases the query; an empty one matches everything.
461        assert!(bookmark_matches(&b, ""));
462        assert!(bookmark_matches(&b, "proj"));
463        assert!(bookmark_matches(&b, "/home/me"));
464        assert!(!bookmark_matches(&b, "photos"));
465    }
466}