1use 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#[derive(Debug, Clone)]
30pub enum MoveTarget {
31 Into(Vec<usize>),
33 Before(Vec<usize>),
35 Root,
37}
38
39#[derive(Default)]
42pub struct BookmarkDrag {
43 start: RefCell<Option<(f64, f64)>>,
45 pressed: RefCell<Option<(Vec<usize>, Option<PathBuf>)>>,
47 active: Cell<bool>,
50 dragging: RefCell<Option<Vec<usize>>>,
52}
53
54impl BookmarkDrag {
55 pub fn new() -> Rc<Self> {
56 Rc::new(Self::default())
57 }
58}
59
60#[derive(Debug, Clone)]
64pub struct BookmarkNode {
65 pub index_path: Vec<usize>,
67 pub name: String,
69 pub path: Option<PathBuf>,
72 pub folder: bool,
74 pub open: bool,
77 pub depth: usize,
79}
80
81pub struct BookmarkNav {
87 list: gtk::Box,
88 nodes: RefCell<Vec<BookmarkNode>>,
89 cursor: Cell<Option<usize>>,
91 focus_after_fill: Cell<bool>,
94}
95
96impl BookmarkNav {
97 pub fn new(list: >k::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 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 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 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 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 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 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 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 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 fn focus_on_next_fill(&self) {
199 self.focus_after_fill.set(true);
200 }
201}
202
203fn 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
214fn 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#[derive(Debug, Clone)]
228pub enum BookmarkEvent {
229 Open(PathBuf),
231 Toggle(Vec<usize>),
233 Edit(Vec<usize>),
235 Delete(Vec<usize>),
237 NewBookmark,
239 NewBookmarkFolder,
241 Action { path: PathBuf, target: ShortcutTarget },
244 Move { from: Vec<usize>, to: MoveTarget },
246}
247
248pub struct BookmarkMenuConfig<'a> {
252 pub context: &'a ContextMenu,
253 pub extras: &'a [ContextAction],
254 pub side: PanelSide,
255}
256
257pub fn fill_bookmarks(
262 list: >k::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
298pub fn attach_bookmarks_scroller(
303 anchor: >k::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 {
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 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 {
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 {
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 {
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
466fn 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
477pub 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 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
528fn 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
553fn 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 let spacer = gtk::Box::new(gtk::Orientation::Horizontal, 0);
584 spacer.set_size_request(16, -1);
585 content.append(&spacer);
586 }
587 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 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 {
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 {
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 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
689fn 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
702fn 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
713fn is_separator(action: &ContextAction) -> bool {
715 builtin_of(action) == Some(BuiltinAction::Separator)
716}
717
718fn 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
732fn append_bookmark_items(
734 menu_box: >k::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 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
807fn 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
835fn 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
854fn 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
863fn 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
875fn 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
884pub fn show_bookmark_dialog(
889 parent: >k::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 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(>k::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 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 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 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 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 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}