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, ShortcutTarget};
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 resolved to a tree builtin or custom
56    /// command. The app routes builtins to the pane's tree and reports errors
57    /// for commands.
58    PaneItem(ShortcutTarget),
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    menu_button: gtk::MenuButton,
86    /// Items of the normal pane menu and of the bookmarks-view menu; the
87    /// popover is rebuilt from one or the other as the body switches.
88    pane_items: Vec<ContextAction>,
89    bookmarks_items: Vec<ContextAction>,
90    completion: Completion,
91}
92
93/// The path-entry completion dropdown and the shared state its handlers read.
94struct Completion {
95    overlay: gtk::Overlay,
96    dropdown: gtk::ScrolledWindow,
97    list: gtk::Box,
98    /// Current suggestions, top-first.
99    items: Rc<RefCell<Vec<PathBuf>>>,
100    /// Index of the highlighted suggestion.
101    selected: Rc<Cell<usize>>,
102    /// Set while the entry text is changed programmatically, so `changed` does
103    /// not treat it as typing and pop the dropdown open.
104    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            // ── hamburger menu button ────────────────────────────────────────
120            // The popover is built from the configurable pane menu in `init`.
121            append: menu_button = &gtk::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            // ── current path entry ────────────────────────────────────────────
129            // The text is driven by `ToolbarMsg::SetRoot`; typing is read by the
130            // completion handlers installed in `init`.
131            append: path_entry = &gtk::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        // The dropdown lives in the pane overlay so it can be drawn on top of
149        // the tree while keyboard focus stays in the entry.
150        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        // Build the hamburger popover from the configured pane menu.
184        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
210/// Wire the entry's `changed`, key and focus handlers to the completion state.
211fn attach_completion(
212    entry: &gtk::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
297/// Position the dropdown just below `entry`, matching its width, using the
298/// pane overlay's coordinate space.
299fn place_dropdown(overlay: &gtk::Overlay, dropdown: &gtk::ScrolledWindow, entry: &gtk::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
307/// Rebuild `list` from `items`, marking row `selected` as highlighted.
308fn rebuild_list(
309    list: &gtk::Box,
310    items: &[PathBuf],
311    selected: usize,
312    entry: &gtk::Entry,
313    dropdown: &gtk::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        // Keep focus in the entry: clicking a row must not steal the keyboard.
324        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
347/// Move the highlight to `index` without rebuilding the rows.
348fn highlight(list: &gtk::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
364/// Open `path`: fill the entry, close the dropdown, and tell the app to navigate.
365fn activate(
366    entry: &gtk::Entry,
367    dropdown: &gtk::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
380/// Directory completions for the typed `text`, non-recursively: the directories
381/// inside the typed path's parent whose name starts with the typed final
382/// component. A leading `~` expands to `$HOME`; a trailing `/` lists everything
383/// in that directory. Results are name-sorted and capped at [`MAX_SUGGESTIONS`].
384fn 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    // `parent` keeps the typed directory (with its trailing separator) so
397    // suggestions read back as an extension of what was typed.
398    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
420/// Build the hamburger popover from a configured menu (the pane menu or the
421/// bookmarks-view menu). Separators become dividers; every other item becomes a
422/// button that pops the menu down and dispatches its action. Plain buttons never
423/// auto-close a `GtkPopover`, hence the explicit `popdown`.
424fn 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
436/// Append `items` (recursing into submenus) to the hamburger menu box.
437/// `close` is the top-level popover a leaf item pops down before dispatching;
438/// submenu rows open a nested popover instead.
439fn append_pane_items(
440    box_: &gtk::Box,
441    items: &[ContextAction],
442    close: &gtk::Popover,
443    sender: &ComponentSender<Toolbar>,
444) {
445    for item in items {
446        // A hidden item keeps its shortcut live but is not rendered.
447        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(&divider);
454            continue;
455        }
456
457        let row = gtk::Box::new(gtk::Orientation::Horizontal, 12);
458        row.add_css_class("tree-menu-item");
459
460        // A submenu row opens a nested popover.
461        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
513/// A full-width, left-aligned label button for a hamburger-menu row.
514fn 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
525/// Resolve one pane-menu item to a toolbar output. Pane-level builtins map to
526/// their dedicated variants; tree-level builtins and custom commands go through
527/// [`ToolbarOutput::PaneItem`] for the app to route.
528fn 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        // A submenu row never dispatches; its children carry the actions.
537        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        // Navigation actions are pane-level too; route them through the shared
546        // pane-item path so the app's dispatcher performs them.
547        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
556/// The pane-menu accelerators compiled to key + modifier form, so a key press
557/// can be resolved anywhere in the pane (not only while the tree has focus).
558pub struct PaneShortcuts {
559    bindings: Vec<(gdk::Key, gdk::ModifierType, ContextAction)>,
560}
561
562impl PaneShortcuts {
563    /// Compile every shortcut-carrying item in `items`. Unparseable
564    /// accelerators are skipped (the menu item still works).
565    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    /// The menu item whose shortcut matches this key event, if any.
572    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
585/// Recursively collect shortcut bindings from `items`, descending into
586/// submenus. Hidden items are included: hiding only affects rendering, never
587/// whether a shortcut fires.
588fn 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
606/// Strip a key state down to the modifier bits accelerators care about.
607fn 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
616/// Parse a `Ctrl+x` style accelerator into a key and modifier set. GTK's parser
617/// only understands its native `<Control>x` spelling, so normalize `+`-joined
618/// strings first.
619pub 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}