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}
67
68/// The toolbar's init payload: the pane overlay its completion dropdown is
69/// parented to (so the dropdown can draw over the tree without stealing focus),
70/// plus the configurable hamburger-menu definition.
71pub struct ToolbarInit {
72    pub overlay: gtk::Overlay,
73    pub pane_menu: PaneMenu,
74}
75
76/// The toolbar component. Holds the current root path for the editable entry.
77pub struct Toolbar {
78    root: PathBuf,
79    path_entry: gtk::Entry,
80    menu_button: gtk::MenuButton,
81    completion: Completion,
82}
83
84/// The path-entry completion dropdown and the shared state its handlers read.
85struct Completion {
86    overlay: gtk::Overlay,
87    dropdown: gtk::ScrolledWindow,
88    list: gtk::Box,
89    /// Current suggestions, top-first.
90    items: Rc<RefCell<Vec<PathBuf>>>,
91    /// Index of the highlighted suggestion.
92    selected: Rc<Cell<usize>>,
93    /// Set while the entry text is changed programmatically, so `changed` does
94    /// not treat it as typing and pop the dropdown open.
95    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            // ── hamburger menu button ────────────────────────────────────────
111            // The popover is built from the configurable pane menu in `init`.
112            append: menu_button = &gtk::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            // ── current path entry ────────────────────────────────────────────
120            // The text is driven by `ToolbarMsg::SetRoot`; typing is read by the
121            // completion handlers installed in `init`.
122            append: path_entry = &gtk::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        // The dropdown lives in the pane overlay so it can be drawn on top of
140        // the tree while keyboard focus stays in the entry.
141        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        // Build the hamburger popover from the configured pane menu.
173        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
193/// Wire the entry's `changed`, key and focus handlers to the completion state.
194fn attach_completion(
195    entry: &gtk::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
280/// Position the dropdown just below `entry`, matching its width, using the
281/// pane overlay's coordinate space.
282fn place_dropdown(overlay: &gtk::Overlay, dropdown: &gtk::ScrolledWindow, entry: &gtk::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
290/// Rebuild `list` from `items`, marking row `selected` as highlighted.
291fn rebuild_list(
292    list: &gtk::Box,
293    items: &[PathBuf],
294    selected: usize,
295    entry: &gtk::Entry,
296    dropdown: &gtk::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        // Keep focus in the entry: clicking a row must not steal the keyboard.
307        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
330/// Move the highlight to `index` without rebuilding the rows.
331fn highlight(list: &gtk::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
347/// Open `path`: fill the entry, close the dropdown, and tell the app to navigate.
348fn activate(
349    entry: &gtk::Entry,
350    dropdown: &gtk::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
363/// Directory completions for the typed `text`, non-recursively: the directories
364/// inside the typed path's parent whose name starts with the typed final
365/// component. A leading `~` expands to `$HOME`; a trailing `/` lists everything
366/// in that directory. Results are name-sorted and capped at [`MAX_SUGGESTIONS`].
367fn 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    // `parent` keeps the typed directory (with its trailing separator) so
380    // suggestions read back as an extension of what was typed.
381    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
403/// Build the hamburger popover from the configured pane menu. Separators become
404/// dividers; every other item becomes a button that pops the menu down and
405/// dispatches its action. Plain buttons never auto-close a `GtkPopover`, hence
406/// the explicit `popdown`.
407fn 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
419/// Append `items` (recursing into submenus) to the hamburger menu box.
420/// `close` is the top-level popover a leaf item pops down before dispatching;
421/// submenu rows open a nested popover instead.
422fn append_pane_items(
423    box_: &gtk::Box,
424    items: &[ContextAction],
425    close: &gtk::Popover,
426    sender: &ComponentSender<Toolbar>,
427) {
428    for item in items {
429        // A hidden item keeps its shortcut live but is not rendered.
430        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(&divider);
437            continue;
438        }
439
440        let row = gtk::Box::new(gtk::Orientation::Horizontal, 12);
441        row.add_css_class("tree-menu-item");
442
443        // A submenu row opens a nested popover.
444        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
496/// A full-width, left-aligned label button for a hamburger-menu row.
497fn 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
508/// Resolve one pane-menu item to a toolbar output. Pane-level builtins map to
509/// their dedicated variants; tree-level builtins and custom commands go through
510/// [`ToolbarOutput::PaneItem`] for the app to route.
511fn 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        // A submenu row never dispatches; its children carry the actions.
520        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        // Navigation actions are pane-level too; route them through the shared
529        // pane-item path so the app's dispatcher performs them.
530        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
539/// The pane-menu accelerators compiled to key + modifier form, so a key press
540/// can be resolved anywhere in the pane (not only while the tree has focus).
541pub struct PaneShortcuts {
542    bindings: Vec<(gdk::Key, gdk::ModifierType, ContextAction)>,
543}
544
545impl PaneShortcuts {
546    /// Compile every shortcut-carrying item in `menu`. Unparseable accelerators
547    /// are skipped (the menu item still works).
548    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    /// The menu item whose shortcut matches this key event, if any.
555    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
568/// Recursively collect shortcut bindings from `items`, descending into
569/// submenus. Hidden items are included: hiding only affects rendering, never
570/// whether a shortcut fires.
571fn 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
589/// Strip a key state down to the modifier bits accelerators care about.
590fn 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
599/// Parse a `Ctrl+x` style accelerator into a key and modifier set. GTK's parser
600/// only understands its native `<Control>x` spelling, so normalize `+`-joined
601/// strings first.
602pub 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}