Skip to main content

tree_space/ui/
toolbar.rs

1//! The panel toolbar: a hamburger menu button and an editable current-path
2//! entry that fills the remaining width.
3//!
4//! Layout (left to right):
5//!   [☰]  [/home/user/projects/...                               ]
6//!
7//! The hamburger opens a popover built from the configurable `[pane_menu]`
8//! list (`Open Folder...`, `Filter...`, `Split View`, view/sort actions, `Collapse`,
9//! `Close Pane`, custom commands). Each row shows its configured shortcut as a
10//! right-aligned hint, mirroring the tree context menu. See
11//! [`build_pane_popover`].
12//!
13//! The same menu's shortcuts are compiled by [`PaneShortcuts`] and matched by a
14//! pane-wide key controller (attached in `app::make_pane`), so a pane action
15//! works no matter which widget in the pane has focus.
16//!
17//! While the path entry is edited it shows a dropdown of directory completions
18//! for the typed text ([`suggestions`] lists the matching subdirectories of the
19//! typed directory, non-recursively). ↑/↓ move the highlight, Enter opens the
20//! highlighted one (the first by default, or the typed path when the dropdown is
21//! closed), and clicking a row opens it too.
22//!
23//! The dropdown is a plain child of the pane overlay, *not* a `GtkPopover`:
24//! a popover gets its own Wayland surface and would steal the keyboard from the
25//! entry, so typing would stop after the first suggestion appeared.
26
27use 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
37/// Most completions to offer for a single typed path.
38const MAX_SUGGESTIONS: usize = 15;
39
40/// Intent produced by the toolbar, forwarded to the app.
41#[derive(Debug)]
42pub enum ToolbarOutput {
43    /// The user asked to pick a folder to browse (from the hamburger menu).
44    OpenFolder,
45    /// The user pressed Enter in the path entry with a (possibly new) path.
46    NavigateTo(PathBuf),
47    /// The user asked (from the top-bar menu) to open the panel filter bar.
48    FilterRequested,
49    /// The user requested a new split pane.
50    SplitView,
51    /// The user requested to hide this pane's entire side (the whole dock).
52    Collapse,
53    /// The user requested to close this pane (exits the app on the last one).
54    ClosePane,
55    /// A configurable pane-menu item (a tree builtin, a custom command, or a
56    /// parameterized builtin such as "Move to Workspace"). The app routes it:
57    /// pane actions are performed there, everything else goes to the tree.
58    PaneItem(ContextAction),
59}
60
61/// Messages the app can send into the toolbar.
62#[derive(Debug)]
63pub enum ToolbarMsg {
64    /// Update the path entry to reflect the current root.
65    SetRoot(PathBuf),
66    /// Switch the hamburger to the bookmarks-view menu (or back to the pane
67    /// menu) as the pane's body changes.
68    SetBookmarks(bool),
69}
70
71/// The toolbar's init payload: the pane overlay its completion dropdown is
72/// parented to (so the dropdown can draw over the tree without stealing focus),
73/// plus the two configurable hamburger menus.
74pub struct ToolbarInit {
75    pub overlay: gtk::Overlay,
76    pub pane_menu: PaneMenu,
77    /// The hamburger shown while the bookmarks view is active.
78    pub bookmarks_menu: Vec<ContextAction>,
79}
80
81/// The toolbar component. Holds the current root path for the editable entry.
82pub struct Toolbar {
83    root: PathBuf,
84    path_entry: gtk::Entry,
85    /// The pane-move drag handle the app attaches its gesture to.
86    grip: gtk::Image,
87    menu_button: gtk::MenuButton,
88    /// Items of the normal pane menu and of the bookmarks-view menu; the
89    /// popover is rebuilt from one or the other as the body switches.
90    pane_items: Vec<ContextAction>,
91    bookmarks_items: Vec<ContextAction>,
92    completion: Completion,
93}
94
95/// The path-entry completion dropdown and the shared state its handlers read.
96struct Completion {
97    overlay: gtk::Overlay,
98    dropdown: gtk::ScrolledWindow,
99    list: gtk::Box,
100    /// Current suggestions, top-first.
101    items: Rc<RefCell<Vec<PathBuf>>>,
102    /// Index of the highlighted suggestion.
103    selected: Rc<Cell<usize>>,
104    /// Set while the entry text is changed programmatically, so `changed` does
105    /// not treat it as typing and pop the dropdown open.
106    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            // ── hamburger menu button ────────────────────────────────────────
122            // The popover is built from the configurable pane menu in `init`.
123            append: menu_button = &gtk::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            // ── current path entry ────────────────────────────────────────────
131            // The text is driven by `ToolbarMsg::SetRoot`; typing is read by the
132            // completion handlers installed in `init`.
133            append: path_entry = &gtk::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            // ── drag handle ───────────────────────────────────────────────────
141            // A grip the app turns into a pane-move gesture (drag it to another
142            // edge or monitor). A plain image, not a button, so it never
143            // competes with the menu or the path entry.
144            append: grip = &gtk::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        // The dropdown lives in the pane overlay so it can be drawn on top of
162        // the tree while keyboard focus stays in the entry.
163        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        // Build the hamburger popover from the configured pane menu.
198        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    /// The pane-move drag handle, so the app can attach a [`gtk::GestureDrag`].
226    pub fn grip(&self) -> &gtk::Image {
227        &self.grip
228    }
229}
230
231/// Wire the entry's `changed`, key and focus handlers to the completion state.
232fn attach_completion(
233    entry: &gtk::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
318/// Position the dropdown just below `entry`, matching its width, using the
319/// pane overlay's coordinate space.
320fn place_dropdown(overlay: &gtk::Overlay, dropdown: &gtk::ScrolledWindow, entry: &gtk::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
328/// Rebuild `list` from `items`, marking row `selected` as highlighted.
329fn rebuild_list(
330    list: &gtk::Box,
331    items: &[PathBuf],
332    selected: usize,
333    entry: &gtk::Entry,
334    dropdown: &gtk::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        // Keep focus in the entry: clicking a row must not steal the keyboard.
345        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
368/// Move the highlight to `index` without rebuilding the rows.
369fn highlight(list: &gtk::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
385/// Open `path`: fill the entry, close the dropdown, and tell the app to navigate.
386fn activate(
387    entry: &gtk::Entry,
388    dropdown: &gtk::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
401/// Directory completions for the typed `text`, non-recursively: the directories
402/// inside the typed path's parent whose name starts with the typed final
403/// component. A leading `~` expands to `$HOME`; a trailing `/` lists everything
404/// in that directory. Results are name-sorted and capped at [`MAX_SUGGESTIONS`].
405fn 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    // `parent` keeps the typed directory (with its trailing separator) so
418    // suggestions read back as an extension of what was typed.
419    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
441/// Build the hamburger popover from a configured menu (the pane menu or the
442/// bookmarks-view menu). Separators become dividers; every other item becomes a
443/// button that pops the menu down and dispatches its action. Plain buttons never
444/// auto-close a `GtkPopover`, hence the explicit `popdown`.
445fn 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
457/// Append `items` (recursing into submenus) to the hamburger menu box.
458/// `close` is the top-level popover a leaf item pops down before dispatching;
459/// submenu rows open a nested popover instead.
460fn append_pane_items(
461    box_: &gtk::Box,
462    items: &[ContextAction],
463    close: &gtk::Popover,
464    sender: &ComponentSender<Toolbar>,
465) {
466    for item in items {
467        // A hidden item keeps its shortcut live but is not rendered.
468        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(&divider);
475            continue;
476        }
477
478        let row = gtk::Box::new(gtk::Orientation::Horizontal, 12);
479        row.add_css_class("tree-menu-item");
480
481        // A submenu row opens a nested popover.
482        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
534/// A full-width, left-aligned label button for a hamburger-menu row.
535fn 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
546/// Resolve one pane-menu item to a toolbar output. Pane-level builtins map to
547/// their dedicated variants; tree-level builtins and custom commands go through
548/// [`ToolbarOutput::PaneItem`] for the app to route.
549fn 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        // A submenu row never dispatches; its children carry the actions.
558        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        // Navigation actions are pane-level too; route them through the shared
567        // pane-item path so the app's dispatcher performs them.
568        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
577/// The pane-menu accelerators compiled to key + modifier form, so a key press
578/// can be resolved anywhere in the pane (not only while the tree has focus).
579pub struct PaneShortcuts {
580    bindings: Vec<(gdk::Key, gdk::ModifierType, ContextAction)>,
581}
582
583impl PaneShortcuts {
584    /// Compile every shortcut-carrying item in `items`. Unparseable
585    /// accelerators are skipped (the menu item still works).
586    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    /// The menu item whose shortcut matches this key event, if any.
593    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
606/// Recursively collect shortcut bindings from `items`, descending into
607/// submenus. Hidden items are included: hiding only affects rendering, never
608/// whether a shortcut fires.
609fn 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
627/// Strip a key state down to the modifier bits accelerators care about.
628fn 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
637/// Parse a `Ctrl+x` style accelerator into a key and modifier set. GTK's parser
638/// only understands its native `<Control>x` spelling, so normalize `+`-joined
639/// strings first.
640pub 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
668/// Map friendly key names GTK does not know onto its canonical ones, so the
669/// IPC `--key` command accepts the names people actually write.
670fn 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}