1use std::cell::{Cell, RefCell};
28use std::path::PathBuf;
29use std::rc::Rc;
30
31use crate::config::{BuiltinAction, ContextAction, PaneMenu, ShortcutTarget};
32use relm4::gtk;
33use relm4::gtk::gdk;
34use relm4::gtk::prelude::*;
35use relm4::prelude::*;
36
37const MAX_SUGGESTIONS: usize = 15;
39
40#[derive(Debug)]
42pub enum ToolbarOutput {
43 OpenFolder,
45 NavigateTo(PathBuf),
47 FilterRequested,
49 SplitView,
51 Collapse,
53 ClosePane,
55 PaneItem(ShortcutTarget),
59}
60
61#[derive(Debug)]
63pub enum ToolbarMsg {
64 SetRoot(PathBuf),
66 SetBookmarks(bool),
69}
70
71pub struct ToolbarInit {
75 pub overlay: gtk::Overlay,
76 pub pane_menu: PaneMenu,
77 pub bookmarks_menu: Vec<ContextAction>,
79}
80
81pub struct Toolbar {
83 root: PathBuf,
84 path_entry: gtk::Entry,
85 menu_button: gtk::MenuButton,
86 pane_items: Vec<ContextAction>,
89 bookmarks_items: Vec<ContextAction>,
90 completion: Completion,
91}
92
93struct Completion {
95 overlay: gtk::Overlay,
96 dropdown: gtk::ScrolledWindow,
97 list: gtk::Box,
98 items: Rc<RefCell<Vec<PathBuf>>>,
100 selected: Rc<Cell<usize>>,
102 suppress: Rc<Cell<bool>>,
105}
106
107#[relm4::component(pub)]
108impl SimpleComponent for Toolbar {
109 type Init = ToolbarInit;
110 type Input = ToolbarMsg;
111 type Output = ToolbarOutput;
112
113 view! {
114 gtk::Box {
115 set_orientation: gtk::Orientation::Horizontal,
116 set_spacing: 4,
117 add_css_class: "toolbar",
118
119 append: menu_button = >k::MenuButton {
122 set_icon_name: "open-menu-symbolic",
123 set_tooltip_text: Some("Menu"),
124 set_valign: gtk::Align::Center,
125 set_hexpand: false,
126 },
127
128 append: path_entry = >k::Entry {
132 set_placeholder_text: Some("Path..."),
133 set_hexpand: true,
134 set_valign: gtk::Align::Center,
135 add_css_class: "path-entry",
136 },
137 }
138 }
139
140 fn init(
141 init: Self::Init,
142 root: Self::Root,
143 sender: ComponentSender<Self>,
144 ) -> ComponentParts<Self> {
145 let widgets = view_output!();
146 let entry = widgets.path_entry.clone();
147
148 let list = gtk::Box::new(gtk::Orientation::Vertical, 0);
151 list.add_css_class("path-completion-list");
152
153 let dropdown = gtk::ScrolledWindow::new();
154 dropdown.add_css_class("path-completion");
155 dropdown.set_visible(false);
156 dropdown.set_halign(gtk::Align::Start);
157 dropdown.set_valign(gtk::Align::Start);
158 dropdown.set_policy(gtk::PolicyType::Never, gtk::PolicyType::Automatic);
159 dropdown.set_max_content_height(240);
160 dropdown.set_propagate_natural_height(true);
161 dropdown.set_child(Some(&list));
162 init.overlay.add_overlay(&dropdown);
163
164 let completion = Completion {
165 overlay: init.overlay,
166 dropdown,
167 list,
168 items: Rc::new(RefCell::new(Vec::new())),
169 selected: Rc::new(Cell::new(0)),
170 suppress: Rc::new(Cell::new(false)),
171 };
172 attach_completion(&entry, &completion, &sender);
173
174 let model = Toolbar {
175 root: PathBuf::new(),
176 path_entry: entry,
177 menu_button: widgets.menu_button.clone(),
178 pane_items: init.pane_menu.items.clone(),
179 bookmarks_items: init.bookmarks_menu.clone(),
180 completion,
181 };
182
183 let popover = build_pane_popover(&model.pane_items, &sender);
185 model.menu_button.set_popover(Some(&popover));
186
187 ComponentParts { model, widgets }
188 }
189
190 fn update(&mut self, msg: Self::Input, sender: ComponentSender<Self>) {
191 match msg {
192 ToolbarMsg::SetRoot(path) => {
193 self.root = path;
194 self.completion.dropdown.set_visible(false);
195 self.completion.suppress.set(true);
196 self.path_entry.set_text(&self.root.display().to_string());
197 self.path_entry.set_position(-1);
198 self.completion.suppress.set(false);
199 }
200 ToolbarMsg::SetBookmarks(on) => {
201 let items =
202 if on { &self.bookmarks_items } else { &self.pane_items };
203 let popover = build_pane_popover(items, &sender);
204 self.menu_button.set_popover(Some(&popover));
205 }
206 }
207 }
208}
209
210fn attach_completion(
212 entry: >k::Entry,
213 completion: &Completion,
214 sender: &ComponentSender<Toolbar>,
215) {
216 {
217 let entry_for_rows = entry.clone();
218 let overlay = completion.overlay.clone();
219 let dropdown = completion.dropdown.clone();
220 let list = completion.list.clone();
221 let items = completion.items.clone();
222 let selected = completion.selected.clone();
223 let suppress = completion.suppress.clone();
224 let sender = sender.clone();
225 entry.connect_changed(move |entry| {
226 if suppress.get() {
227 return;
228 }
229 let found = suggestions(&entry.text());
230 *items.borrow_mut() = found;
231 selected.set(0);
232 rebuild_list(&list, &items.borrow(), 0, &entry_for_rows, &dropdown, &suppress, &sender);
233 if items.borrow().is_empty() {
234 dropdown.set_visible(false);
235 } else {
236 place_dropdown(&overlay, &dropdown, entry);
237 dropdown.set_visible(true);
238 }
239 });
240 }
241
242 {
243 let key_entry = entry.clone();
244 let dropdown = completion.dropdown.clone();
245 let list = completion.list.clone();
246 let items = completion.items.clone();
247 let selected = completion.selected.clone();
248 let suppress = completion.suppress.clone();
249 let sender = sender.clone();
250 let keys = gtk::EventControllerKey::new();
251 keys.set_propagation_phase(gtk::PropagationPhase::Capture);
252 keys.connect_key_pressed(move |_, key, _, _| {
253 let count = items.borrow().len();
254 match key {
255 gdk::Key::Down if count > 0 => {
256 let i = (selected.get() + 1).min(count - 1);
257 selected.set(i);
258 highlight(&list, i);
259 gtk::glib::Propagation::Stop
260 }
261 gdk::Key::Up if count > 0 => {
262 let i = selected.get().saturating_sub(1);
263 selected.set(i);
264 highlight(&list, i);
265 gtk::glib::Propagation::Stop
266 }
267 gdk::Key::Return | gdk::Key::KP_Enter => {
268 if dropdown.is_visible() && count > 0 {
269 let path = items.borrow()[selected.get()].clone();
270 activate(&key_entry, &dropdown, &suppress, &sender, path);
271 } else {
272 let path = PathBuf::from(key_entry.text().to_string());
273 if path.is_dir() {
274 activate(&key_entry, &dropdown, &suppress, &sender, path);
275 }
276 }
277 gtk::glib::Propagation::Stop
278 }
279 gdk::Key::Escape if dropdown.is_visible() => {
280 dropdown.set_visible(false);
281 gtk::glib::Propagation::Stop
282 }
283 _ => gtk::glib::Propagation::Proceed,
284 }
285 });
286 entry.add_controller(keys);
287 }
288
289 {
290 let dropdown = completion.dropdown.clone();
291 let focus = gtk::EventControllerFocus::new();
292 focus.connect_leave(move |_| dropdown.set_visible(false));
293 entry.add_controller(focus);
294 }
295}
296
297fn place_dropdown(overlay: >k::Overlay, dropdown: >k::ScrolledWindow, entry: >k::Entry) {
300 if let Some(rect) = entry.compute_bounds(overlay) {
301 dropdown.set_margin_start(rect.x() as i32);
302 dropdown.set_margin_top((rect.y() + rect.height()) as i32);
303 dropdown.set_width_request(rect.width() as i32);
304 }
305}
306
307fn rebuild_list(
309 list: >k::Box,
310 items: &[PathBuf],
311 selected: usize,
312 entry: >k::Entry,
313 dropdown: >k::ScrolledWindow,
314 suppress: &Rc<Cell<bool>>,
315 sender: &ComponentSender<Toolbar>,
316) {
317 while let Some(child) = list.first_child() {
318 list.remove(&child);
319 }
320 for (i, path) in items.iter().enumerate() {
321 let button = gtk::Button::new();
322 button.set_halign(gtk::Align::Fill);
323 button.set_focusable(false);
325 button.set_can_focus(false);
326 button.add_css_class("path-completion-item");
327 if i == selected {
328 button.add_css_class("active");
329 }
330 let label = gtk::Label::new(Some(&path.display().to_string()));
331 label.set_xalign(0.0);
332 label.set_ellipsize(gtk::pango::EllipsizeMode::Start);
333 button.set_child(Some(&label));
334
335 let path = path.clone();
336 let entry = entry.clone();
337 let dropdown = dropdown.clone();
338 let suppress = suppress.clone();
339 let sender = sender.clone();
340 button.connect_clicked(move |_| {
341 activate(&entry, &dropdown, &suppress, &sender, path.clone());
342 });
343 list.append(&button);
344 }
345}
346
347fn highlight(list: >k::Box, index: usize) {
349 let mut i = 0;
350 let mut child = list.first_child();
351 while let Some(current) = child {
352 if let Some(button) = current.downcast_ref::<gtk::Button>() {
353 if i == index {
354 button.add_css_class("active");
355 } else {
356 button.remove_css_class("active");
357 }
358 i += 1;
359 }
360 child = current.next_sibling();
361 }
362}
363
364fn activate(
366 entry: >k::Entry,
367 dropdown: >k::ScrolledWindow,
368 suppress: &Rc<Cell<bool>>,
369 sender: &ComponentSender<Toolbar>,
370 path: PathBuf,
371) {
372 suppress.set(true);
373 entry.set_text(&path.display().to_string());
374 entry.set_position(-1);
375 suppress.set(false);
376 dropdown.set_visible(false);
377 let _ = sender.output(ToolbarOutput::NavigateTo(path));
378}
379
380fn suggestions(text: &str) -> Vec<PathBuf> {
385 if text.is_empty() {
386 return Vec::new();
387 }
388 let expanded = match text.strip_prefix('~') {
389 Some(rest) => match std::env::var("HOME") {
390 Ok(home) => format!("{home}{rest}"),
391 Err(_) => text.to_string(),
392 },
393 None => text.to_string(),
394 };
395
396 let (parent, prefix) = match expanded.rsplit_once('/') {
399 Some((head, name)) => (format!("{head}/"), name.to_string()),
400 None => (String::new(), expanded),
401 };
402 let dir = if parent.is_empty() { PathBuf::from(".") } else { PathBuf::from(&parent) };
403 let Ok(entries) = std::fs::read_dir(&dir) else {
404 return Vec::new();
405 };
406
407 let show_hidden = prefix.starts_with('.');
408 let mut names: Vec<String> = entries
409 .filter_map(Result::ok)
410 .filter(|entry| entry.path().is_dir())
411 .filter_map(|entry| entry.file_name().into_string().ok())
412 .filter(|name| show_hidden || !name.starts_with('.'))
413 .filter(|name| name.starts_with(&prefix))
414 .collect();
415 names.sort();
416 names.truncate(MAX_SUGGESTIONS);
417 names.into_iter().map(|name| PathBuf::from(format!("{parent}{name}"))).collect()
418}
419
420fn build_pane_popover(items: &[ContextAction], sender: &ComponentSender<Toolbar>) -> gtk::Popover {
425 let popover = gtk::Popover::new();
426 popover.add_css_class("hamburger-popover");
427
428 let box_ = gtk::Box::new(gtk::Orientation::Vertical, 0);
429 box_.add_css_class("hamburger-menu");
430 append_pane_items(&box_, items, &popover, sender);
431
432 popover.set_child(Some(&box_));
433 popover
434}
435
436fn append_pane_items(
440 box_: >k::Box,
441 items: &[ContextAction],
442 close: >k::Popover,
443 sender: &ComponentSender<Toolbar>,
444) {
445 for item in items {
446 if item.is_hidden() {
448 continue;
449 }
450 if matches!(item, ContextAction::Builtin(BuiltinAction::Separator)) {
451 let divider = gtk::Separator::new(gtk::Orientation::Horizontal);
452 divider.add_css_class("tree-menu-separator");
453 box_.append(÷r);
454 continue;
455 }
456
457 let row = gtk::Box::new(gtk::Orientation::Horizontal, 12);
458 row.add_css_class("tree-menu-item");
459
460 if let ContextAction::Submenu(sub) = item {
462 let button = menu_row_button(&sub.label);
463 let child_box = gtk::Box::new(gtk::Orientation::Vertical, 0);
464 child_box.add_css_class("hamburger-menu");
465 let child = gtk::Popover::new();
466 child.add_css_class("hamburger-popover");
467 append_pane_items(&child_box, &sub.items, close, sender);
468 child.set_child(Some(&child_box));
469 child.set_has_arrow(false);
470 child.set_position(gtk::PositionType::Right);
471 let child_anchor = child.clone();
472 let anchor = button.clone();
473 button.connect_clicked(move |_| {
474 child_anchor.set_parent(&anchor);
475 child_anchor.popup();
476 });
477 let child_anchor = child.clone();
478 child.connect_closed(move |_| child_anchor.unparent());
479 row.append(&button);
480 let arrow = gtk::Label::new(Some("\u{25b8}"));
481 arrow.add_css_class("tree-menu-submenu-arrow");
482 arrow.set_halign(gtk::Align::End);
483 arrow.set_valign(gtk::Align::Center);
484 row.append(&arrow);
485 box_.append(&row);
486 continue;
487 }
488
489 let button = menu_row_button(&item.label());
490 let action = item.clone();
491 let p = close.clone();
492 let s = sender.clone();
493 button.connect_clicked(move |_| {
494 p.popdown();
495 dispatch_pane_action(&action, &s);
496 });
497 row.append(&button);
498
499 if let Some(shortcut) = item.shortcut()
500 && let Some(display) = crate::ui::tree::accel_display(&shortcut)
501 {
502 let hint = gtk::Label::new(Some(&display));
503 hint.add_css_class("tree-menu-shortcut");
504 hint.set_halign(gtk::Align::End);
505 hint.set_valign(gtk::Align::Center);
506 row.append(&hint);
507 }
508
509 box_.append(&row);
510 }
511}
512
513fn menu_row_button(label: &str) -> gtk::Button {
515 let button = gtk::Button::new();
516 button.set_halign(gtk::Align::Fill);
517 button.set_hexpand(true);
518 let text = gtk::Label::new(Some(label));
519 text.set_xalign(0.0);
520 text.set_hexpand(true);
521 button.set_child(Some(&text));
522 button
523}
524
525fn dispatch_pane_action(action: &ContextAction, sender: &ComponentSender<Toolbar>) {
529 let target = match action {
530 ContextAction::Builtin(a) => *a,
531 ContextAction::Entry(e) => e.action,
532 ContextAction::Command(cmd) => {
533 let _ = sender.output(ToolbarOutput::PaneItem(ShortcutTarget::Command(cmd.clone())));
534 return;
535 }
536 ContextAction::Submenu(_) => return,
538 };
539 let out = match target {
540 BuiltinAction::OpenFolder => ToolbarOutput::OpenFolder,
541 BuiltinAction::Filter => ToolbarOutput::FilterRequested,
542 BuiltinAction::SplitView => ToolbarOutput::SplitView,
543 BuiltinAction::Collapse => ToolbarOutput::Collapse,
544 BuiltinAction::ClosePane => ToolbarOutput::ClosePane,
545 BuiltinAction::Up | BuiltinAction::Back | BuiltinAction::Forward => {
548 ToolbarOutput::PaneItem(ShortcutTarget::Builtin(target))
549 }
550 BuiltinAction::Separator => return,
551 other => ToolbarOutput::PaneItem(ShortcutTarget::Builtin(other)),
552 };
553 let _ = sender.output(out);
554}
555
556pub struct PaneShortcuts {
559 bindings: Vec<(gdk::Key, gdk::ModifierType, ContextAction)>,
560}
561
562impl PaneShortcuts {
563 pub fn compile(items: &[ContextAction]) -> Self {
566 let mut bindings = Vec::new();
567 collect_pane_bindings(items, &mut bindings);
568 Self { bindings }
569 }
570
571 pub fn action_for(
573 &self,
574 key: gdk::Key,
575 state: gdk::ModifierType,
576 ) -> Option<ContextAction> {
577 let state = accel_mods(state);
578 self.bindings
579 .iter()
580 .find(|(k, mods, _)| key.to_lower() == k.to_lower() && accel_mods(*mods) == state)
581 .map(|(_, _, action)| action.clone())
582 }
583}
584
585fn collect_pane_bindings(
589 items: &[ContextAction],
590 out: &mut Vec<(gdk::Key, gdk::ModifierType, ContextAction)>,
591) {
592 for item in items {
593 if let Some(children) = item.submenu_items() {
594 collect_pane_bindings(children, out);
595 continue;
596 }
597 let Some(accel) = item.shortcut() else {
598 continue;
599 };
600 if let Some((key, mods)) = parse_accelerator(&accel) {
601 out.push((key, mods, item.clone()));
602 }
603 }
604}
605
606fn accel_mods(m: gdk::ModifierType) -> gdk::ModifierType {
608 m & (gdk::ModifierType::CONTROL_MASK
609 | gdk::ModifierType::SHIFT_MASK
610 | gdk::ModifierType::ALT_MASK
611 | gdk::ModifierType::SUPER_MASK
612 | gdk::ModifierType::HYPER_MASK
613 | gdk::ModifierType::META_MASK)
614}
615
616pub fn parse_accelerator(accel: &str) -> Option<(gdk::Key, gdk::ModifierType)> {
620 gtk::accelerator_parse(normalize_accelerator(accel))
621}
622
623fn normalize_accelerator(accel: &str) -> String {
624 if !accel.contains('+') {
625 return accel.to_owned();
626 }
627 let mut mods = String::new();
628 let mut key = String::new();
629 for token in accel.split('+') {
630 let token = token.trim();
631 if token.is_empty() {
632 continue;
633 }
634 match token.to_ascii_lowercase().as_str() {
635 "ctrl" | "control" | "primary" => mods.push_str("<Control>"),
636 "shift" => mods.push_str("<Shift>"),
637 "alt" => mods.push_str("<Alt>"),
638 "super" | "win" | "mod4" => mods.push_str("<Super>"),
639 "meta" => mods.push_str("<Meta>"),
640 "hyper" => mods.push_str("<Hyper>"),
641 _ => key = token.to_owned(),
642 }
643 }
644 format!("{mods}{key}")
645}
646
647#[cfg(test)]
648mod tests {
649 use super::*;
650
651 #[test]
652 fn trailing_slash_lists_subdirectories() {
653 let dir = tempfile::tempdir().unwrap();
654 std::fs::create_dir(dir.path().join("alpha")).unwrap();
655 std::fs::create_dir(dir.path().join("beta")).unwrap();
656 std::fs::write(dir.path().join("file.txt"), b"x").unwrap();
657
658 let query = format!("{}/", dir.path().display());
659 let got = suggestions(&query);
660 assert_eq!(
661 got,
662 vec![dir.path().join("alpha"), dir.path().join("beta")],
663 "only directories, name-sorted"
664 );
665 }
666
667 #[test]
668 fn partial_name_completes_case_sensitively() {
669 let dir = tempfile::tempdir().unwrap();
670 std::fs::create_dir(dir.path().join("projects")).unwrap();
671 std::fs::create_dir(dir.path().join("pictures")).unwrap();
672
673 let query = format!("{}/pro", dir.path().display());
674 assert_eq!(suggestions(&query), vec![dir.path().join("projects")]);
675 }
676
677 #[test]
678 fn non_matching_prefix_yields_nothing() {
679 let dir = tempfile::tempdir().unwrap();
680 std::fs::create_dir(dir.path().join("alpha")).unwrap();
681 let query = format!("{}/zzz", dir.path().display());
682 assert!(suggestions(&query).is_empty());
683 }
684
685 #[test]
686 fn hidden_directories_only_shown_for_a_dot_prefix() {
687 let dir = tempfile::tempdir().unwrap();
688 std::fs::create_dir(dir.path().join(".config")).unwrap();
689 std::fs::create_dir(dir.path().join("visible")).unwrap();
690
691 let all = suggestions(&format!("{}/", dir.path().display()));
692 assert_eq!(all, vec![dir.path().join("visible")]);
693
694 let hidden = suggestions(&format!("{}/.", dir.path().display()));
695 assert_eq!(hidden, vec![dir.path().join(".config")]);
696 }
697
698 #[test]
699 fn suggestions_are_capped() {
700 let dir = tempfile::tempdir().unwrap();
701 for i in 0..(MAX_SUGGESTIONS + 5) {
702 std::fs::create_dir(dir.path().join(format!("dir{i:03}"))).unwrap();
703 }
704 let got = suggestions(&format!("{}/", dir.path().display()));
705 assert_eq!(got.len(), MAX_SUGGESTIONS);
706 }
707
708 #[test]
709 fn empty_text_yields_nothing() {
710 assert!(suggestions("").is_empty());
711 }
712}