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