1use 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#[derive(Debug, Clone)]
29pub enum BookmarkEvent {
30 Open(PathBuf),
32 Edit(usize),
34 Delete(usize),
36 Action { path: PathBuf, target: ShortcutTarget },
39}
40
41pub struct BookmarkMenuConfig<'a> {
45 pub context: &'a ContextMenu,
46 pub extras: &'a [ContextAction],
47 pub side: PanelSide,
48}
49
50pub fn fill_bookmarks(
54 list: >k::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
86fn 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
94fn 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 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 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 {
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
158fn 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
172fn 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
186fn append_bookmark_items(
188 menu_box: >k::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 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
259fn 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
269fn 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
288fn 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
297fn 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
309fn 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
318pub fn show_bookmark_editor(
321 parent: >k::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(>k::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 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 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}