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