tree_space/ui/
bookmarks.rs1use std::path::{Path, PathBuf};
15use std::rc::Rc;
16
17use gtk4_layer_shell::{KeyboardMode, Layer, LayerShell};
18use relm4::gtk;
19use relm4::gtk::{gdk, gio, glib, pango, prelude::*};
20
21use crate::config::{Bookmark, expand_bookmark_path};
22
23#[derive(Debug)]
25pub enum BookmarkEvent {
26 Open(PathBuf),
28 Edit(usize),
30 Delete(usize),
32 Hide,
34}
35
36pub fn bookmarks_section(
41 bookmarks: &[Bookmark],
42 on_event: Rc<dyn Fn(BookmarkEvent)>,
43) -> gtk::Widget {
44 let root = gtk::Box::new(gtk::Orientation::Vertical, 0);
45 root.add_css_class("bookmarks");
46
47 let list = gtk::Box::new(gtk::Orientation::Vertical, 0);
48 list.add_css_class("bookmarks-list");
49 if bookmarks.is_empty() {
50 let empty = gtk::Label::new(Some("No bookmarks — use Add Bookmark"));
51 empty.add_css_class("bookmarks-empty");
52 empty.set_xalign(0.0);
53 list.append(&empty);
54 }
55 for (index, bookmark) in bookmarks.iter().enumerate() {
56 list.append(&bookmark_row(index, bookmark, on_event.clone()));
57 }
58
59 let scroller = gtk::ScrolledWindow::new();
62 scroller.set_policy(gtk::PolicyType::Never, gtk::PolicyType::Automatic);
63 scroller.set_propagate_natural_height(true);
64 scroller.set_max_content_height(220);
65 scroller.set_child(Some(&list));
66
67 let overlay = gtk::Overlay::new();
70 overlay.set_child(Some(&scroller));
71 let close = gtk::Button::from_icon_name("window-close-symbolic");
72 close.add_css_class("bookmarks-close");
73 close.set_tooltip_text(Some("Hide bookmarks"));
74 close.set_halign(gtk::Align::End);
75 close.set_valign(gtk::Align::Start);
76 {
77 let on_event = on_event.clone();
78 close.connect_clicked(move |_| on_event(BookmarkEvent::Hide));
79 }
80 overlay.add_overlay(&close);
81 root.append(&overlay);
82
83 root.upcast()
84}
85
86fn bookmark_row(index: usize, bookmark: &Bookmark, on_event: Rc<dyn Fn(BookmarkEvent)>) -> gtk::Widget {
89 let path = expand_bookmark_path(&bookmark.path);
90 let row = gtk::Button::new();
91 row.add_css_class("bookmark-row");
92 row.set_tooltip_text(Some(&path.display().to_string()));
93
94 let content = gtk::Box::new(gtk::Orientation::Horizontal, 8);
95 let icon = gtk::Image::from_icon_name("user-bookmarks-symbolic");
97 icon.add_css_class("bookmark-icon");
98 let label = gtk::Label::new(Some(&bookmark.name));
99 label.set_xalign(0.0);
100 label.set_hexpand(true);
101 label.set_ellipsize(pango::EllipsizeMode::End);
102 label.add_css_class("bookmark-label");
103 content.append(&icon);
104 content.append(&label);
105 row.set_child(Some(&content));
106
107 {
108 let on_event = on_event.clone();
109 let path = path.clone();
110 row.connect_clicked(move |_| on_event(BookmarkEvent::Open(path.clone())));
111 }
112
113 let popover = gtk::Popover::new();
114 popover.add_css_class("bookmark-menu-popover");
115 popover.set_has_arrow(true);
116 let menu = gtk::Box::new(gtk::Orientation::Vertical, 0);
117 menu.add_css_class("bookmark-menu");
118 let open_item = menu_button("Open");
119 let edit_item = menu_button("Edit...");
120 let delete_item = menu_button("Delete");
121 menu.append(&open_item);
122 menu.append(&edit_item);
123 menu.append(&delete_item);
124 popover.set_child(Some(&menu));
125
126 {
127 let on_event = on_event.clone();
128 let popover = popover.clone();
129 let path = path.clone();
130 open_item.connect_clicked(move |_| {
131 popover.popdown();
132 on_event(BookmarkEvent::Open(path.clone()));
133 });
134 }
135 {
136 let on_event = on_event.clone();
137 let popover = popover.clone();
138 edit_item.connect_clicked(move |_| {
139 popover.popdown();
140 on_event(BookmarkEvent::Edit(index));
141 });
142 }
143 {
144 let on_event = on_event.clone();
145 let popover = popover.clone();
146 delete_item.connect_clicked(move |_| {
147 popover.popdown();
148 on_event(BookmarkEvent::Delete(index));
149 });
150 }
151
152 popover.set_parent(&row);
153 {
156 let popover = popover.clone();
157 row.connect_destroy(move |_| popover.unparent());
158 }
159 let gesture = gtk::GestureClick::new();
160 gesture.set_button(gdk::BUTTON_SECONDARY);
161 {
162 let popover = popover.clone();
163 gesture.connect_pressed(move |gesture, _, x, y| {
164 gesture.set_state(gtk::EventSequenceState::Claimed);
165 popover.set_pointing_to(Some(&gdk::Rectangle::new(x as i32, y as i32, 1, 1)));
166 popover.popup();
167 });
168 }
169 row.add_controller(gesture);
170
171 row.upcast()
172}
173
174fn menu_button(label: &str) -> gtk::Button {
176 let button = gtk::Button::with_label(label);
177 button.add_css_class("flat");
178 button.set_halign(gtk::Align::Fill);
179 if let Some(child) = button.child().and_downcast::<gtk::Label>() {
180 child.set_xalign(0.0);
181 }
182 button
183}
184
185pub fn show_bookmark_editor(
188 parent: >k::Window,
189 name: &str,
190 path: &Path,
191 on_save: impl Fn(String, PathBuf) + 'static,
192) {
193 let window = gtk::Window::new();
194 window.set_title(Some("Edit Bookmark"));
195 window.set_default_size(420, -1);
196 window.set_transient_for(Some(parent));
197 if gtk4_layer_shell::is_supported() && !window.is_layer_window() {
198 window.init_layer_shell();
199 window.set_layer(Layer::Overlay);
200 window.set_keyboard_mode(KeyboardMode::Exclusive);
201 }
202
203 let form = gtk::Box::new(gtk::Orientation::Vertical, 8);
204 form.add_css_class("bookmark-editor");
205
206 let name_label = gtk::Label::new(Some("Name"));
207 name_label.set_xalign(0.0);
208 name_label.add_css_class("bookmark-field-label");
209 let name_entry = gtk::Entry::new();
210 name_entry.set_text(name);
211 name_entry.set_hexpand(true);
212
213 let path_label = gtk::Label::new(Some("Path"));
214 path_label.set_xalign(0.0);
215 path_label.add_css_class("bookmark-field-label");
216 let path_row = gtk::Box::new(gtk::Orientation::Horizontal, 6);
217 let path_entry = gtk::Entry::new();
218 path_entry.set_text(&path.display().to_string());
219 path_entry.set_hexpand(true);
220 let browse = gtk::Button::with_label("Browse...");
221 path_row.append(&path_entry);
222 path_row.append(&browse);
223
224 form.append(&name_label);
225 form.append(&name_entry);
226 form.append(&path_label);
227 form.append(&path_row);
228
229 let footer = gtk::Box::new(gtk::Orientation::Horizontal, 6);
230 footer.set_halign(gtk::Align::End);
231 footer.add_css_class("bookmark-editor-footer");
232 let cancel = gtk::Button::with_label("Cancel");
233 let save = gtk::Button::with_label("Save");
234 save.add_css_class("suggested-action");
235 footer.append(&cancel);
236 footer.append(&save);
237
238 let root = gtk::Box::new(gtk::Orientation::Vertical, 0);
239 root.add_css_class("bookmark-editor-root");
240 root.append(&form);
241 root.append(>k::Separator::new(gtk::Orientation::Horizontal));
242 root.append(&footer);
243 window.set_child(Some(&root));
244
245 {
246 let path_entry = path_entry.clone();
247 let window = window.clone();
248 browse.connect_clicked(move |_| {
249 let dialog = gtk::FileDialog::new();
250 let path_entry = path_entry.clone();
251 dialog.select_folder(Some(&window), None::<&gio::Cancellable>, move |res| {
252 if let Ok(file) = res
253 && let Some(p) = file.path()
254 {
255 path_entry.set_text(&p.display().to_string());
256 }
257 });
258 });
259 }
260
261 let submit: Rc<dyn Fn()> = {
264 let name_entry = name_entry.clone();
265 let path_entry = path_entry.clone();
266 let window = window.clone();
267 let on_save = Rc::new(on_save);
268 Rc::new(move || {
269 let raw = path_entry.text().trim().to_string();
270 if raw.is_empty() {
271 return;
272 }
273 let path = expand_bookmark_path(Path::new(&raw));
274 let typed = name_entry.text().trim().to_string();
275 let name = if typed.is_empty() { Bookmark::default_name(&path) } else { typed };
276 on_save(name, path);
277 window.close();
278 })
279 };
280
281 {
282 let submit = submit.clone();
283 save.connect_clicked(move |_| submit());
284 }
285 {
286 let submit = submit.clone();
287 name_entry.connect_activate(move |_| submit());
288 }
289 {
290 let submit = submit.clone();
291 path_entry.connect_activate(move |_| submit());
292 }
293 {
294 let window = window.clone();
295 cancel.connect_clicked(move |_| window.close());
296 }
297 {
298 let keys = gtk::EventControllerKey::new();
299 window.add_controller(keys.clone());
300 let window = window.clone();
301 keys.connect_key_pressed(move |_, key, _, _| {
302 if key == gdk::Key::Escape {
303 window.close();
304 glib::Propagation::Stop
305 } else {
306 glib::Propagation::Proceed
307 }
308 });
309 }
310
311 window.present();
312}