Skip to main content

herdr_pretty_which/
app.rs

1use crate::model::{Binding, BindingStatus, Category};
2use fuzzy_matcher::skim::SkimMatcherV2;
3use fuzzy_matcher::FuzzyMatcher;
4use serde::{Deserialize, Serialize};
5use std::collections::BTreeSet;
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum AppMode {
9    Interactive,
10    Snapshot,
11}
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum BindingViewMode {
15    All,
16    Assigned,
17    Unassigned,
18}
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
21#[serde(rename_all = "kebab-case")]
22pub enum NavigationViewMode {
23    List,
24    Tree,
25}
26
27impl NavigationViewMode {
28    pub const fn label(self) -> &'static str {
29        match self {
30            Self::List => "LIST",
31            Self::Tree => "TREE",
32        }
33    }
34}
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum TreeRowKind {
38    Group,
39    Binding,
40}
41
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct TreeRow {
44    pub path: Vec<String>,
45    pub label: String,
46    pub depth: usize,
47    pub kind: TreeRowKind,
48    pub selectable: bool,
49    pub context_only: bool,
50    pub expanded: bool,
51    pub binding: Option<Binding>,
52    pub score: Option<i64>,
53}
54
55impl BindingViewMode {
56    pub const fn label(self) -> &'static str {
57        match self {
58            Self::All => "ALL",
59            Self::Assigned => "ASSIGNED",
60            Self::Unassigned => "UNASSIGNED",
61        }
62    }
63
64    fn next(self) -> Self {
65        match self {
66            Self::All => Self::Assigned,
67            Self::Assigned => Self::Unassigned,
68            Self::Unassigned => Self::All,
69        }
70    }
71
72    fn previous(self) -> Self {
73        match self {
74            Self::All => Self::Unassigned,
75            Self::Assigned => Self::All,
76            Self::Unassigned => Self::Assigned,
77        }
78    }
79}
80
81#[derive(Debug, Clone)]
82pub struct App {
83    pub title: String,
84    pub config_path: String,
85    pub theme_name: String,
86    pub query: String,
87    pub selected: usize,
88    pub mode: AppMode,
89    pub binding_view: BindingViewMode,
90    pub navigation_view: NavigationViewMode,
91    collapsed_tree_paths: BTreeSet<String>,
92    bindings: Vec<Binding>,
93}
94
95impl App {
96    pub fn new(bindings: Vec<Binding>, config_path: String, theme_name: String) -> Self {
97        Self {
98            title: "Herdr Pretty Which".to_string(),
99            config_path,
100            theme_name,
101            query: String::new(),
102            selected: 0,
103            mode: AppMode::Interactive,
104            binding_view: BindingViewMode::All,
105            navigation_view: NavigationViewMode::Tree,
106            collapsed_tree_paths: BTreeSet::new(),
107            bindings,
108        }
109    }
110
111    pub fn snapshot(mut self, query: impl Into<String>) -> Self {
112        self.mode = AppMode::Snapshot;
113        self.query = query.into();
114        self
115    }
116
117    pub fn bindings(&self) -> &[Binding] {
118        &self.bindings
119    }
120
121    pub fn active_count(&self) -> usize {
122        self.bindings
123            .iter()
124            .filter(|binding| binding.status == BindingStatus::Active)
125            .count()
126    }
127
128    pub fn total_count(&self) -> usize {
129        self.bindings.len()
130    }
131
132    pub fn unassigned_count(&self) -> usize {
133        self.bindings
134            .iter()
135            .filter(|binding| binding.status == BindingStatus::Disabled)
136            .count()
137    }
138
139    pub fn category_counts(&self) -> std::collections::BTreeMap<Category, usize> {
140        let mut counts = std::collections::BTreeMap::new();
141        for binding in self
142            .bindings
143            .iter()
144            .filter(|binding| self.matches_view(binding))
145        {
146            *counts.entry(binding.category).or_insert(0) += 1;
147        }
148        counts
149    }
150
151    pub fn next_binding_view(&mut self) {
152        self.binding_view = self.binding_view.next();
153        self.selected = 0;
154    }
155
156    pub fn previous_binding_view(&mut self) {
157        self.binding_view = self.binding_view.previous();
158        self.selected = 0;
159    }
160
161    pub fn set_navigation_view(&mut self, navigation_view: NavigationViewMode) {
162        if self.navigation_view == navigation_view {
163            return;
164        }
165        let selected_action = self.selected_binding().map(|binding| binding.action);
166        self.navigation_view = navigation_view;
167        self.selected = 0;
168        if let Some(action) = selected_action {
169            self.select_action(&action);
170        }
171    }
172
173    pub fn toggle_navigation_view(&mut self) {
174        let next = match self.navigation_view {
175            NavigationViewMode::List => NavigationViewMode::Tree,
176            NavigationViewMode::Tree => NavigationViewMode::List,
177        };
178        self.set_navigation_view(next);
179    }
180
181    pub fn filtered_bindings(&self) -> Vec<(Binding, Option<i64>)> {
182        let query = self.query.trim();
183        if query.is_empty() {
184            return self
185                .bindings
186                .iter()
187                .filter(|binding| self.matches_view(binding))
188                .cloned()
189                .map(|binding| (binding, None))
190                .collect();
191        }
192        let matcher = SkimMatcherV2::default().smart_case();
193        let mut scored = self
194            .bindings
195            .iter()
196            .filter(|binding| self.matches_view(binding))
197            .filter_map(|binding| {
198                score_binding(&matcher, binding, query).map(|score| (binding.clone(), Some(score)))
199            })
200            .collect::<Vec<_>>();
201        scored.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.label.cmp(&b.0.label)));
202        scored
203    }
204
205    pub fn visible_tree_rows(&self) -> Vec<TreeRow> {
206        let query = self.query.trim();
207        let matcher = SkimMatcherV2::default().smart_case();
208        let query_active = !query.is_empty();
209        let mut rows = Vec::new();
210        let mut emitted_groups = BTreeSet::new();
211
212        for binding in self
213            .bindings
214            .iter()
215            .filter(|binding| self.matches_view(binding))
216        {
217            let score = if query_active {
218                match score_binding(&matcher, binding, query) {
219                    Some(score) => Some(score),
220                    None => continue,
221                }
222            } else {
223                None
224            };
225
226            let mut parent_path = Vec::new();
227            let mut hidden_by_collapse = false;
228            for segment in &binding.tree_path {
229                parent_path.push(segment.clone());
230                let key = path_key(&parent_path);
231                let expanded = query_active || !self.collapsed_tree_paths.contains(&key);
232                if emitted_groups.insert(key.clone()) && !hidden_by_collapse {
233                    rows.push(TreeRow {
234                        path: parent_path.clone(),
235                        label: segment.clone(),
236                        depth: parent_path.len() - 1,
237                        kind: TreeRowKind::Group,
238                        selectable: !query_active,
239                        context_only: query_active,
240                        expanded,
241                        binding: None,
242                        score: None,
243                    });
244                }
245                if !expanded && !query_active {
246                    hidden_by_collapse = true;
247                    break;
248                }
249            }
250
251            if hidden_by_collapse {
252                continue;
253            }
254
255            let mut leaf_path = binding.tree_path.clone();
256            leaf_path.push(binding.label.clone());
257            rows.push(TreeRow {
258                path: leaf_path,
259                label: binding.label.clone(),
260                depth: binding.tree_path.len(),
261                kind: TreeRowKind::Binding,
262                selectable: true,
263                context_only: false,
264                expanded: false,
265                binding: Some(binding.clone()),
266                score,
267            });
268        }
269
270        rows
271    }
272
273    pub fn selected_tree_row(&self) -> Option<TreeRow> {
274        self.visible_tree_rows()
275            .into_iter()
276            .filter(|row| row.selectable)
277            .nth(self.selected)
278    }
279
280    pub fn selected_binding(&self) -> Option<Binding> {
281        match self.navigation_view {
282            NavigationViewMode::List => self
283                .filtered_bindings()
284                .get(self.selected)
285                .map(|(binding, _)| binding.clone()),
286            NavigationViewMode::Tree => self.selected_tree_row().and_then(|row| row.binding),
287        }
288    }
289
290    pub fn set_query(&mut self, query: impl Into<String>) {
291        self.query = query.into();
292        self.selected = 0;
293    }
294
295    pub fn push_query_char(&mut self, ch: char) {
296        self.query.push(ch);
297        self.selected = 0;
298    }
299
300    pub fn pop_query_char(&mut self) {
301        self.query.pop();
302        self.selected = 0;
303    }
304
305    pub fn move_down(&mut self) {
306        let len = self.selectable_len();
307        if len > 0 {
308            self.selected = (self.selected + 1).min(len - 1);
309        }
310    }
311
312    pub fn move_up(&mut self) {
313        self.selected = self.selected.saturating_sub(1);
314    }
315
316    pub fn tree_left(&mut self) {
317        if self.navigation_view != NavigationViewMode::Tree || !self.query.trim().is_empty() {
318            return;
319        }
320        let Some(row) = self.selected_tree_row() else {
321            return;
322        };
323        match row.kind {
324            TreeRowKind::Binding => {
325                let parent = row.path[..row.path.len().saturating_sub(1)].to_vec();
326                self.select_tree_path(&parent);
327            }
328            TreeRowKind::Group if row.expanded => {
329                self.collapsed_tree_paths.insert(path_key(&row.path));
330                self.select_tree_path(&row.path);
331            }
332            TreeRowKind::Group if row.path.len() > 1 => {
333                let parent = row.path[..row.path.len() - 1].to_vec();
334                self.select_tree_path(&parent);
335            }
336            TreeRowKind::Group => {}
337        }
338    }
339
340    pub fn tree_right(&mut self) {
341        if self.navigation_view != NavigationViewMode::Tree || !self.query.trim().is_empty() {
342            return;
343        }
344        let Some(row) = self.selected_tree_row() else {
345            return;
346        };
347        if row.kind != TreeRowKind::Group {
348            return;
349        }
350        let key = path_key(&row.path);
351        if self.collapsed_tree_paths.remove(&key) {
352            self.select_tree_path(&row.path);
353            return;
354        }
355        if let Some(child) = self
356            .visible_tree_rows()
357            .into_iter()
358            .filter(|candidate| candidate.selectable)
359            .find(|candidate| candidate.path.starts_with(&row.path) && candidate.path != row.path)
360        {
361            self.select_tree_path(&child.path);
362        }
363    }
364
365    pub fn expand_all_tree_nodes(&mut self) {
366        self.collapsed_tree_paths.clear();
367    }
368
369    pub fn collapse_all_tree_nodes(&mut self) {
370        let mut collapsed = BTreeSet::new();
371        for binding in self
372            .bindings
373            .iter()
374            .filter(|binding| self.matches_view(binding))
375        {
376            let mut path = Vec::new();
377            for segment in &binding.tree_path {
378                path.push(segment.clone());
379                collapsed.insert(path_key(&path));
380            }
381        }
382        self.collapsed_tree_paths = collapsed;
383        self.selected = 0;
384    }
385
386    fn matches_view(&self, binding: &Binding) -> bool {
387        match self.binding_view {
388            BindingViewMode::All => true,
389            BindingViewMode::Assigned => binding.status == BindingStatus::Active,
390            BindingViewMode::Unassigned => binding.status == BindingStatus::Disabled,
391        }
392    }
393
394    fn selectable_len(&self) -> usize {
395        match self.navigation_view {
396            NavigationViewMode::List => self.filtered_bindings().len(),
397            NavigationViewMode::Tree => self
398                .visible_tree_rows()
399                .iter()
400                .filter(|row| row.selectable)
401                .count(),
402        }
403    }
404
405    fn select_action(&mut self, action: &str) {
406        match self.navigation_view {
407            NavigationViewMode::List => {
408                if let Some(index) = self
409                    .filtered_bindings()
410                    .iter()
411                    .position(|(binding, _)| binding.action == action)
412                {
413                    self.selected = index;
414                }
415            }
416            NavigationViewMode::Tree => {
417                if let Some(index) = self
418                    .visible_tree_rows()
419                    .iter()
420                    .filter(|row| row.selectable)
421                    .position(|row| {
422                        row.binding
423                            .as_ref()
424                            .is_some_and(|binding| binding.action == action)
425                    })
426                {
427                    self.selected = index;
428                }
429            }
430        }
431    }
432
433    fn select_tree_path(&mut self, path: &[String]) {
434        if let Some(index) = self
435            .visible_tree_rows()
436            .iter()
437            .filter(|row| row.selectable)
438            .position(|row| row.path == path)
439        {
440            self.selected = index;
441        }
442    }
443}
444
445pub fn binding_search_score(binding: &Binding, query: &str) -> Option<i64> {
446    let matcher = SkimMatcherV2::default().smart_case();
447    score_binding(&matcher, binding, query)
448}
449
450fn score_binding(matcher: &SkimMatcherV2, binding: &Binding, query: &str) -> Option<i64> {
451    let query_lower = query.to_lowercase();
452    let keyish_query = query_lower.contains('+')
453        || ["prefix", "ctrl", "alt", "shift", "tab", "enter", "esc"]
454            .iter()
455            .any(|token| query_lower.contains(token));
456    let haystack = if keyish_query {
457        format!(
458            "{} {} {} {} {}",
459            binding.label,
460            binding.action,
461            binding.keys.join(" "),
462            binding.default_keys.join(" "),
463            binding.hint
464        )
465    } else {
466        format!("{} {} {}", binding.label, binding.action, binding.hint)
467    };
468    matcher.fuzzy_match(&haystack, query)
469}
470
471fn path_key(path: &[String]) -> String {
472    path.join("\u{1f}")
473}
474
475#[cfg(test)]
476mod tests {
477    use super::*;
478    use crate::model::{effective_bindings, KeysSection};
479
480    #[test]
481    fn fuzzy_search_finds_split() {
482        let mut app = App::new(
483            effective_bindings(&KeysSection::default()),
484            "x".into(),
485            "terminal".into(),
486        );
487        app.set_query("split");
488        let labels = app
489            .filtered_bindings()
490            .into_iter()
491            .map(|(binding, _)| binding.label)
492            .collect::<Vec<_>>();
493        assert!(labels.iter().any(|label| label.contains("Split")));
494    }
495
496    #[test]
497    fn binding_search_score_matches_labels_and_keyish_queries() {
498        let bindings = effective_bindings(&KeysSection::default());
499        let split = bindings
500            .iter()
501            .find(|binding| binding.action == "split_vertical")
502            .unwrap();
503
504        assert!(binding_search_score(split, "split").is_some());
505        assert!(binding_search_score(split, "prefix+v").is_some());
506    }
507
508    #[test]
509    fn selection_is_clamped() {
510        let mut app = App::new(
511            effective_bindings(&KeysSection::default()),
512            "x".into(),
513            "terminal".into(),
514        );
515        app.set_navigation_view(NavigationViewMode::List);
516        for _ in 0..500 {
517            app.move_down();
518        }
519        assert!(app.selected < app.filtered_bindings().len());
520    }
521
522    #[test]
523    fn view_modes_cycle_forward_and_backward() {
524        let mut app = App::new(
525            effective_bindings(&KeysSection::default()),
526            "x".into(),
527            "terminal".into(),
528        );
529        assert_eq!(app.binding_view, BindingViewMode::All);
530        app.next_binding_view();
531        assert_eq!(app.binding_view, BindingViewMode::Assigned);
532        app.next_binding_view();
533        assert_eq!(app.binding_view, BindingViewMode::Unassigned);
534        app.next_binding_view();
535        assert_eq!(app.binding_view, BindingViewMode::All);
536        app.previous_binding_view();
537        assert_eq!(app.binding_view, BindingViewMode::Unassigned);
538    }
539
540    #[test]
541    fn assigned_and_unassigned_modes_filter_bindings() {
542        let mut app = App::new(
543            effective_bindings(&KeysSection::default()),
544            "x".into(),
545            "terminal".into(),
546        );
547        assert!(app
548            .filtered_bindings()
549            .iter()
550            .any(|(binding, _)| binding.keys.is_empty()));
551        app.next_binding_view();
552        assert!(app
553            .filtered_bindings()
554            .iter()
555            .all(|(binding, _)| !binding.keys.is_empty()));
556        app.next_binding_view();
557        assert!(app
558            .filtered_bindings()
559            .iter()
560            .all(|(binding, _)| binding.keys.is_empty()));
561    }
562
563    #[test]
564    fn tree_toggle_preserves_query_and_binding_view() {
565        let mut app = App::new(
566            effective_bindings(&KeysSection::default()),
567            "x".into(),
568            "terminal".into(),
569        );
570        app.set_navigation_view(NavigationViewMode::List);
571        app.set_query("split");
572        app.next_binding_view();
573        app.toggle_navigation_view();
574
575        assert_eq!(app.navigation_view, NavigationViewMode::Tree);
576        assert_eq!(app.binding_view, BindingViewMode::Assigned);
577        assert_eq!(app.query, "split");
578        assert!(app
579            .visible_tree_rows()
580            .iter()
581            .filter(|row| row.selectable)
582            .all(|row| row
583                .binding
584                .as_ref()
585                .is_some_and(|binding| binding.status == BindingStatus::Active)));
586    }
587
588    #[test]
589    fn tree_mode_filter_keeps_dimmed_ancestors_for_matching_leaves() {
590        let mut app = App::new(
591            effective_bindings(&KeysSection::default()),
592            "x".into(),
593            "terminal".into(),
594        );
595        app.set_query("existing");
596
597        let rows = app.visible_tree_rows();
598        let labels = rows
599            .iter()
600            .map(|row| row.label.as_str())
601            .collect::<Vec<_>>();
602
603        assert_eq!(app.navigation_view, NavigationViewMode::Tree);
604        assert!(labels.contains(&"Workspaces"));
605        assert!(labels.contains(&"Worktrees"));
606        assert!(labels.contains(&"Open worktree"));
607        assert!(!labels.contains(&"New worktree"));
608        assert!(!labels.contains(&"Remove worktree"));
609
610        let workspaces = rows.iter().find(|row| row.label == "Workspaces").unwrap();
611        let worktrees = rows.iter().find(|row| row.label == "Worktrees").unwrap();
612        let open_worktree = rows
613            .iter()
614            .find(|row| row.label == "Open worktree")
615            .unwrap();
616
617        assert!(workspaces.context_only);
618        assert!(worktrees.context_only);
619        assert!(!workspaces.selectable);
620        assert!(!worktrees.selectable);
621        assert!(open_worktree.selectable);
622    }
623
624    #[test]
625    fn tree_left_moves_to_parent_then_collapses_parent() {
626        let mut app = App::new(
627            effective_bindings(&KeysSection::default()),
628            "x".into(),
629            "terminal".into(),
630        );
631        let focus_left_index = app
632            .visible_tree_rows()
633            .iter()
634            .filter(|row| row.selectable)
635            .position(|row| row.path == ["Panes", "Focus", "Focus left"])
636            .unwrap();
637        app.selected = focus_left_index;
638
639        app.tree_left();
640        assert_eq!(app.selected_tree_row().unwrap().path, ["Panes", "Focus"]);
641
642        app.tree_left();
643        assert!(!app
644            .visible_tree_rows()
645            .iter()
646            .any(|row| row.path == ["Panes", "Focus", "Focus left"]));
647    }
648}