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}