Skip to main content

tree_space/ui/
bookmarks.rs

1//! The bookmarks section and its editor dialog.
2//!
3//! The bookmarks list is a strip that sits above or below a dock's pane stack
4//! (never a sidebar): a one-line header with an add button over a short,
5//! scrollable list. Clicking an entry opens a pane at that directory; 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::{Bookmark, expand_bookmark_path};
22
23/// What the user did in the bookmarks section.
24#[derive(Debug)]
25pub enum BookmarkEvent {
26    /// Open a new pane at the bookmark's directory.
27    Open(PathBuf),
28    /// Edit the bookmark at this index in the list.
29    Edit(usize),
30    /// Delete the bookmark at this index in the list.
31    Delete(usize),
32    /// Hide the bookmarks strip (the close button; same as the toggle action).
33    Hide,
34}
35
36/// Build the bookmarks section. Rebuilt whenever the list, its visibility, or a
37/// dock changes, so it holds no state of its own. There is no header bar — the
38/// list is the whole strip with a small close button floating top-right;
39/// bookmarks are added from a directory's context menu.
40pub fn bookmarks_section(
41    bookmarks: &[Bookmark],
42    on_event: Rc<dyn Fn(BookmarkEvent)>,
43) -> gtk::Widget {
44    let root = gtk::Box::new(gtk::Orientation::Vertical, 0);
45    root.add_css_class("bookmarks");
46
47    let list = gtk::Box::new(gtk::Orientation::Vertical, 0);
48    list.add_css_class("bookmarks-list");
49    if bookmarks.is_empty() {
50        let empty = gtk::Label::new(Some("No bookmarks — use Add Bookmark"));
51        empty.add_css_class("bookmarks-empty");
52        empty.set_xalign(0.0);
53        list.append(&empty);
54    }
55    for (index, bookmark) in bookmarks.iter().enumerate() {
56        list.append(&bookmark_row(index, bookmark, on_event.clone()));
57    }
58
59    // Keep a long list from squeezing the panes: scroll within a bounded height
60    // and ask for only as much as the entries need.
61    let scroller = gtk::ScrolledWindow::new();
62    scroller.set_policy(gtk::PolicyType::Never, gtk::PolicyType::Automatic);
63    scroller.set_propagate_natural_height(true);
64    scroller.set_max_content_height(220);
65    scroller.set_child(Some(&list));
66
67    // The close button floats over the top-right corner; the list reserves a
68    // little right padding (in CSS) so the rows never run underneath it.
69    let overlay = gtk::Overlay::new();
70    overlay.set_child(Some(&scroller));
71    let close = gtk::Button::from_icon_name("window-close-symbolic");
72    close.add_css_class("bookmarks-close");
73    close.set_tooltip_text(Some("Hide bookmarks"));
74    close.set_halign(gtk::Align::End);
75    close.set_valign(gtk::Align::Start);
76    {
77        let on_event = on_event.clone();
78        close.connect_clicked(move |_| on_event(BookmarkEvent::Hide));
79    }
80    overlay.add_overlay(&close);
81    root.append(&overlay);
82
83    root.upcast()
84}
85
86/// One bookmark row: an icon and its label, clickable to open, with a
87/// right-click menu (Open / Edit / Delete).
88fn bookmark_row(index: usize, bookmark: &Bookmark, on_event: Rc<dyn Fn(BookmarkEvent)>) -> gtk::Widget {
89    let path = expand_bookmark_path(&bookmark.path);
90    let row = gtk::Button::new();
91    row.add_css_class("bookmark-row");
92    row.set_tooltip_text(Some(&path.display().to_string()));
93
94    let content = gtk::Box::new(gtk::Orientation::Horizontal, 8);
95    // Deliberately not a folder icon, so bookmarks read as a distinct kind.
96    let icon = gtk::Image::from_icon_name("user-bookmarks-symbolic");
97    icon.add_css_class("bookmark-icon");
98    let label = gtk::Label::new(Some(&bookmark.name));
99    label.set_xalign(0.0);
100    label.set_hexpand(true);
101    label.set_ellipsize(pango::EllipsizeMode::End);
102    label.add_css_class("bookmark-label");
103    content.append(&icon);
104    content.append(&label);
105    row.set_child(Some(&content));
106
107    {
108        let on_event = on_event.clone();
109        let path = path.clone();
110        row.connect_clicked(move |_| on_event(BookmarkEvent::Open(path.clone())));
111    }
112
113    let popover = gtk::Popover::new();
114    popover.add_css_class("bookmark-menu-popover");
115    popover.set_has_arrow(true);
116    let menu = gtk::Box::new(gtk::Orientation::Vertical, 0);
117    menu.add_css_class("bookmark-menu");
118    let open_item = menu_button("Open");
119    let edit_item = menu_button("Edit...");
120    let delete_item = menu_button("Delete");
121    menu.append(&open_item);
122    menu.append(&edit_item);
123    menu.append(&delete_item);
124    popover.set_child(Some(&menu));
125
126    {
127        let on_event = on_event.clone();
128        let popover = popover.clone();
129        let path = path.clone();
130        open_item.connect_clicked(move |_| {
131            popover.popdown();
132            on_event(BookmarkEvent::Open(path.clone()));
133        });
134    }
135    {
136        let on_event = on_event.clone();
137        let popover = popover.clone();
138        edit_item.connect_clicked(move |_| {
139            popover.popdown();
140            on_event(BookmarkEvent::Edit(index));
141        });
142    }
143    {
144        let on_event = on_event.clone();
145        let popover = popover.clone();
146        delete_item.connect_clicked(move |_| {
147            popover.popdown();
148            on_event(BookmarkEvent::Delete(index));
149        });
150    }
151
152    popover.set_parent(&row);
153    // The row is rebuilt whenever the list changes; unparent the popover as the
154    // row goes away so GTK does not warn about a widget with children left.
155    {
156        let popover = popover.clone();
157        row.connect_destroy(move |_| popover.unparent());
158    }
159    let gesture = gtk::GestureClick::new();
160    gesture.set_button(gdk::BUTTON_SECONDARY);
161    {
162        let popover = popover.clone();
163        gesture.connect_pressed(move |gesture, _, x, y| {
164            gesture.set_state(gtk::EventSequenceState::Claimed);
165            popover.set_pointing_to(Some(&gdk::Rectangle::new(x as i32, y as i32, 1, 1)));
166            popover.popup();
167        });
168    }
169    row.add_controller(gesture);
170
171    row.upcast()
172}
173
174/// A flat, full-width, left-aligned button for a popover menu.
175fn menu_button(label: &str) -> gtk::Button {
176    let button = gtk::Button::with_label(label);
177    button.add_css_class("flat");
178    button.set_halign(gtk::Align::Fill);
179    if let Some(child) = button.child().and_downcast::<gtk::Label>() {
180        child.set_xalign(0.0);
181    }
182    button
183}
184
185/// Show the editor for a bookmark: an editable name and path (with a folder
186/// picker). `on_save` receives the resolved name and path when the user saves.
187pub fn show_bookmark_editor(
188    parent: &gtk::Window,
189    name: &str,
190    path: &Path,
191    on_save: impl Fn(String, PathBuf) + 'static,
192) {
193    let window = gtk::Window::new();
194    window.set_title(Some("Edit Bookmark"));
195    window.set_default_size(420, -1);
196    window.set_transient_for(Some(parent));
197    if gtk4_layer_shell::is_supported() && !window.is_layer_window() {
198        window.init_layer_shell();
199        window.set_layer(Layer::Overlay);
200        window.set_keyboard_mode(KeyboardMode::Exclusive);
201    }
202
203    let form = gtk::Box::new(gtk::Orientation::Vertical, 8);
204    form.add_css_class("bookmark-editor");
205
206    let name_label = gtk::Label::new(Some("Name"));
207    name_label.set_xalign(0.0);
208    name_label.add_css_class("bookmark-field-label");
209    let name_entry = gtk::Entry::new();
210    name_entry.set_text(name);
211    name_entry.set_hexpand(true);
212
213    let path_label = gtk::Label::new(Some("Path"));
214    path_label.set_xalign(0.0);
215    path_label.add_css_class("bookmark-field-label");
216    let path_row = gtk::Box::new(gtk::Orientation::Horizontal, 6);
217    let path_entry = gtk::Entry::new();
218    path_entry.set_text(&path.display().to_string());
219    path_entry.set_hexpand(true);
220    let browse = gtk::Button::with_label("Browse...");
221    path_row.append(&path_entry);
222    path_row.append(&browse);
223
224    form.append(&name_label);
225    form.append(&name_entry);
226    form.append(&path_label);
227    form.append(&path_row);
228
229    let footer = gtk::Box::new(gtk::Orientation::Horizontal, 6);
230    footer.set_halign(gtk::Align::End);
231    footer.add_css_class("bookmark-editor-footer");
232    let cancel = gtk::Button::with_label("Cancel");
233    let save = gtk::Button::with_label("Save");
234    save.add_css_class("suggested-action");
235    footer.append(&cancel);
236    footer.append(&save);
237
238    let root = gtk::Box::new(gtk::Orientation::Vertical, 0);
239    root.add_css_class("bookmark-editor-root");
240    root.append(&form);
241    root.append(&gtk::Separator::new(gtk::Orientation::Horizontal));
242    root.append(&footer);
243    window.set_child(Some(&root));
244
245    {
246        let path_entry = path_entry.clone();
247        let window = window.clone();
248        browse.connect_clicked(move |_| {
249            let dialog = gtk::FileDialog::new();
250            let path_entry = path_entry.clone();
251            dialog.select_folder(Some(&window), None::<&gio::Cancellable>, move |res| {
252                if let Ok(file) = res
253                    && let Some(p) = file.path()
254                {
255                    path_entry.set_text(&p.display().to_string());
256                }
257            });
258        });
259    }
260
261    // Validate and dispatch. A blank name falls back to the directory's name; a
262    // blank path is rejected (leaving the dialog open).
263    let submit: Rc<dyn Fn()> = {
264        let name_entry = name_entry.clone();
265        let path_entry = path_entry.clone();
266        let window = window.clone();
267        let on_save = Rc::new(on_save);
268        Rc::new(move || {
269            let raw = path_entry.text().trim().to_string();
270            if raw.is_empty() {
271                return;
272            }
273            let path = expand_bookmark_path(Path::new(&raw));
274            let typed = name_entry.text().trim().to_string();
275            let name = if typed.is_empty() { Bookmark::default_name(&path) } else { typed };
276            on_save(name, path);
277            window.close();
278        })
279    };
280
281    {
282        let submit = submit.clone();
283        save.connect_clicked(move |_| submit());
284    }
285    {
286        let submit = submit.clone();
287        name_entry.connect_activate(move |_| submit());
288    }
289    {
290        let submit = submit.clone();
291        path_entry.connect_activate(move |_| submit());
292    }
293    {
294        let window = window.clone();
295        cancel.connect_clicked(move |_| window.close());
296    }
297    {
298        let keys = gtk::EventControllerKey::new();
299        window.add_controller(keys.clone());
300        let window = window.clone();
301        keys.connect_key_pressed(move |_, key, _, _| {
302            if key == gdk::Key::Escape {
303                window.close();
304                glib::Propagation::Stop
305            } else {
306                glib::Propagation::Proceed
307            }
308        });
309    }
310
311    window.present();
312}