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_namespace(Some(crate::ui::LAYER_NAMESPACE));
903 window.set_layer(Layer::Overlay);
904 window.set_keyboard_mode(KeyboardMode::Exclusive);
905 }
906
907 let form = gtk::Box::new(gtk::Orientation::Vertical, 8);
908 form.add_css_class("bookmark-editor");
909
910 let name_label = gtk::Label::new(Some("Name"));
911 name_label.set_xalign(0.0);
912 name_label.add_css_class("bookmark-field-label");
913 let name_entry = gtk::Entry::new();
914 name_entry.set_text(name);
915 name_entry.set_hexpand(true);
916
917 let path_label = gtk::Label::new(Some("Path"));
918 path_label.set_xalign(0.0);
919 path_label.add_css_class("bookmark-field-label");
920 let path_row = gtk::Box::new(gtk::Orientation::Horizontal, 6);
921 let path_entry = gtk::Entry::new();
922 path_entry.set_text(&path.map(|p| p.display().to_string()).unwrap_or_default());
923 path_entry.set_hexpand(true);
924 let browse = gtk::Button::with_label("Browse...");
925 path_row.append(&path_entry);
926 path_row.append(&browse);
927
928 path_label.set_visible(!folder);
930 path_row.set_visible(!folder);
931
932 form.append(&name_label);
933 form.append(&name_entry);
934 form.append(&path_label);
935 form.append(&path_row);
936
937 let footer = gtk::Box::new(gtk::Orientation::Horizontal, 6);
938 footer.set_halign(gtk::Align::End);
939 footer.add_css_class("bookmark-editor-footer");
940 let cancel = gtk::Button::with_label("Cancel");
941 let save = gtk::Button::with_label("Save");
942 save.add_css_class("suggested-action");
943 footer.append(&cancel);
944 footer.append(&save);
945
946 let root = gtk::Box::new(gtk::Orientation::Vertical, 0);
947 root.add_css_class("bookmark-editor-root");
948 root.append(&form);
949 root.append(>k::Separator::new(gtk::Orientation::Horizontal));
950 root.append(&footer);
951 window.set_child(Some(&root));
952
953 {
954 let path_entry = path_entry.clone();
955 let window = window.clone();
956 browse.connect_clicked(move |_| {
957 let dialog = gtk::FileDialog::new();
958 let path_entry = path_entry.clone();
959 dialog.select_folder(Some(&window), None::<&gio::Cancellable>, move |res| {
960 if let Ok(file) = res
961 && let Some(p) = file.path()
962 {
963 path_entry.set_text(&p.display().to_string());
964 }
965 });
966 });
967 }
968
969 let submit: Rc<dyn Fn()> = {
973 let name_entry = name_entry.clone();
974 let path_entry = path_entry.clone();
975 let window = window.clone();
976 let on_save = Rc::new(on_save);
977 Rc::new(move || {
978 let typed = name_entry.text().trim().to_string();
979 if folder {
980 if typed.is_empty() {
981 return;
982 }
983 on_save(typed, None);
984 window.close();
985 return;
986 }
987 let raw = path_entry.text().trim().to_string();
988 if raw.is_empty() {
989 return;
990 }
991 let path = expand_bookmark_path(Path::new(&raw));
992 let name = if typed.is_empty() { Bookmark::default_name(&path) } else { typed };
993 on_save(name, Some(path));
994 window.close();
995 })
996 };
997
998 {
999 let submit = submit.clone();
1000 save.connect_clicked(move |_| submit());
1001 }
1002 {
1003 let submit = submit.clone();
1004 name_entry.connect_activate(move |_| submit());
1005 }
1006 {
1007 let submit = submit.clone();
1008 path_entry.connect_activate(move |_| submit());
1009 }
1010 {
1011 let window = window.clone();
1012 cancel.connect_clicked(move |_| window.close());
1013 }
1014 {
1015 let keys = gtk::EventControllerKey::new();
1016 window.add_controller(keys.clone());
1017 let window = window.clone();
1018 keys.connect_key_pressed(move |_, key, _, _| {
1019 if key == gdk::Key::Escape {
1020 window.close();
1021 glib::Propagation::Stop
1022 } else {
1023 glib::Propagation::Proceed
1024 }
1025 });
1026 }
1027
1028 window.present();
1029}
1030
1031#[cfg(test)]
1032mod tests {
1033 use super::{bookmark_matches, cursor_after_move, cursor_after_rebuild, visible_nodes, BookmarkNode};
1034 use crate::config::Bookmark;
1035 use std::path::PathBuf;
1036
1037 fn bm(name: &str, path: &str) -> Bookmark {
1038 Bookmark::leaf(name.to_owned(), PathBuf::from(path))
1039 }
1040
1041 fn folder(name: &str, expanded: bool, items: Vec<Bookmark>) -> Bookmark {
1042 Bookmark {
1043 name: name.to_owned(),
1044 path: None,
1045 items,
1046 expanded,
1047 }
1048 }
1049
1050 #[test]
1051 fn filter_matches_name_or_path_case_insensitively() {
1052 let b = bm("Projects", "/home/me/Projects");
1053 assert!(bookmark_matches(&b, ""));
1055 assert!(bookmark_matches(&b, "proj"));
1056 assert!(bookmark_matches(&b, "/home/me"));
1057 assert!(!bookmark_matches(&b, "photos"));
1058 }
1059
1060 #[test]
1061 fn visible_nodes_respects_expansion() {
1062 let list = vec![
1063 bm("Home", "/home/me"),
1064 folder("Work", false, vec![bm("Repo", "/srv/repo")]),
1065 folder("Open", true, vec![bm("Doc", "/srv/doc")]),
1066 ];
1067 let nodes = visible_nodes(&list, None);
1068 let shape: Vec<(Vec<usize>, usize)> =
1071 nodes.iter().map(|n| (n.index_path.clone(), n.depth)).collect();
1072 assert_eq!(
1073 shape,
1074 vec![
1075 (vec![0], 0),
1076 (vec![1], 0),
1077 (vec![2], 0),
1078 (vec![2, 0], 1),
1079 ]
1080 );
1081 assert!(nodes[1].folder && !nodes[1].open);
1082 assert!(!nodes[3].folder && nodes[3].depth == 1);
1083 }
1084
1085 #[test]
1086 fn visible_nodes_filter_keeps_a_folder_only_via_a_descendant() {
1087 let list = vec![folder(
1088 "Work",
1089 false,
1090 vec![bm("Design", "/srv/design"), bm("Code", "/srv/code")],
1091 )];
1092 let nodes = visible_nodes(&list, Some("design"));
1093 assert_eq!(nodes.len(), 2);
1095 assert_eq!(nodes[0].index_path, vec![0]);
1096 assert!(nodes[0].folder && nodes[0].open);
1097 assert_eq!(nodes[1].index_path, vec![0, 0]);
1098 assert_eq!(nodes[1].name, "Design");
1099 }
1100
1101 #[test]
1102 fn visible_nodes_filter_on_a_folder_shows_all_children() {
1103 let list = vec![folder(
1104 "Work",
1105 false,
1106 vec![bm("Design", "/srv/design"), bm("Code", "/srv/code")],
1107 )];
1108 let nodes = visible_nodes(&list, Some("work"));
1109 assert_eq!(nodes.len(), 3);
1110 assert!(nodes[0].open);
1111 assert_eq!(nodes[1].name, "Design");
1112 assert_eq!(nodes[2].name, "Code");
1113 }
1114
1115 #[test]
1116 fn cursor_after_move_clamps_and_starts_at_the_top() {
1117 assert_eq!(cursor_after_move(None, 1, 3), Some(0));
1118 assert_eq!(cursor_after_move(None, -1, 3), Some(0));
1119 assert_eq!(cursor_after_move(Some(0), -1, 3), Some(0));
1120 assert_eq!(cursor_after_move(Some(2), 1, 3), Some(2));
1121 assert_eq!(cursor_after_move(Some(1), 1, 3), Some(2));
1122 assert_eq!(cursor_after_move(Some(1), -1, 3), Some(0));
1123 assert_eq!(cursor_after_move(Some(0), 1, 0), None);
1124 }
1125
1126 #[test]
1127 fn cursor_after_rebuild_sticks_to_the_same_entry() {
1128 let node = |path: &[usize]| BookmarkNode {
1129 index_path: path.to_vec(),
1130 name: String::new(),
1131 path: None,
1132 folder: false,
1133 open: false,
1134 depth: 0,
1135 };
1136 let nodes = vec![node(&[0]), node(&[1]), node(&[1, 0])];
1137 assert_eq!(cursor_after_rebuild(Some(&[1, 0]), &nodes, nodes.len()), Some(2));
1138 assert_eq!(cursor_after_rebuild(Some(&[9]), &nodes, nodes.len()), Some(0));
1140 assert_eq!(cursor_after_rebuild(None, &nodes, nodes.len()), None);
1141 assert_eq!(cursor_after_rebuild(Some(&[0]), &[], 0), None);
1142 }
1143}