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 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 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 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 fn focus_on_next_fill(&self) {
260 self.focus_after_fill.set(true);
261 }
262}
263
264fn 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
275fn 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#[derive(Debug, Clone)]
289pub enum BookmarkEvent {
290 Open(PathBuf),
292 Toggle(Vec<usize>),
294 Edit(Vec<usize>),
296 Delete(Vec<usize>),
298 NewBookmark,
300 NewBookmarkFolder,
302 Action { path: PathBuf, target: ShortcutTarget },
305 Move { from: Vec<usize>, to: MoveTarget },
307}
308
309pub struct BookmarkMenuConfig<'a> {
313 pub context: &'a ContextMenu,
314 pub extras: &'a [ContextAction],
315 pub side: PanelSide,
316}
317
318pub fn fill_bookmarks(
323 list: >k::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
359pub fn attach_bookmarks_scroller(
364 anchor: >k::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 {
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 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 {
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 {
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 {
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
527fn 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
538pub 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 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
589fn 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
614fn 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 let spacer = gtk::Box::new(gtk::Orientation::Horizontal, 0);
645 spacer.set_size_request(16, -1);
646 content.append(&spacer);
647 }
648 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 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 {
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 {
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 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
750fn 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
763fn 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
774fn is_separator(action: &ContextAction) -> bool {
776 builtin_of(action) == Some(BuiltinAction::Separator)
777}
778
779fn 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
793fn append_bookmark_items(
795 menu_box: >k::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 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
868fn 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
896fn 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
915fn 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
924fn 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
936fn 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
945pub fn show_bookmark_dialog(
950 parent: >k::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 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(>k::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 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 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 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 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 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}