Skip to content

Commit 7b064e2

Browse files
authored
fix: cursor jump on search (#320)
* search don't filter * corner case fix
1 parent ecd8c9c commit 7b064e2

3 files changed

Lines changed: 79 additions & 1 deletion

File tree

src/tui/home/input.rs

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -670,6 +670,60 @@ impl HomeView {
670670
}
671671
}
672672

673+
/// Re-score matches after a reload without moving the cursor.
674+
pub(super) fn refresh_search_matches(&mut self) {
675+
let query = self.search_query.value();
676+
if query.is_empty() {
677+
self.search_matches.clear();
678+
self.search_match_index = 0;
679+
return;
680+
}
681+
682+
use nucleo_matcher::pattern::{Atom, AtomKind, CaseMatching, Normalization};
683+
use nucleo_matcher::{Config, Matcher, Utf32Str};
684+
685+
let mut matcher = Matcher::new(Config::DEFAULT.match_paths());
686+
let atom = Atom::new(
687+
query,
688+
CaseMatching::Ignore,
689+
Normalization::Smart,
690+
AtomKind::Fuzzy,
691+
false,
692+
);
693+
694+
let mut scored: Vec<(usize, u16)> = Vec::new();
695+
let mut buf = Vec::new();
696+
697+
for (idx, item) in self.flat_items.iter().enumerate() {
698+
let haystack = match item {
699+
Item::Session { id, .. } => {
700+
if let Some(inst) = self.instance_map.get(id) {
701+
format!("{} {}", inst.title, inst.project_path)
702+
} else {
703+
continue;
704+
}
705+
}
706+
Item::Group { name, path, .. } => {
707+
format!("{} {}", name, path)
708+
}
709+
};
710+
711+
let haystack_utf32 = Utf32Str::new(&haystack, &mut buf);
712+
if let Some(score) = atom.score(haystack_utf32, &mut matcher) {
713+
scored.push((idx, score));
714+
}
715+
}
716+
717+
scored.sort_by(|a, b| b.1.cmp(&a.1));
718+
self.search_matches = scored.into_iter().map(|(idx, _)| idx).collect();
719+
// Clamp match_index in case matches shrank
720+
if self.search_matches.is_empty() {
721+
self.search_match_index = 0;
722+
} else if self.search_match_index >= self.search_matches.len() {
723+
self.search_match_index = self.search_matches.len() - 1;
724+
}
725+
}
726+
673727
pub(super) fn update_search(&mut self) {
674728
self.search_matches.clear();
675729
self.search_match_index = 0;

src/tui/home/mod.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -276,8 +276,11 @@ impl HomeView {
276276
self.cursor = self.flat_items.len() - 1;
277277
}
278278

279-
if !self.search_query.value().is_empty() {
279+
if self.search_active && !self.search_query.value().is_empty() {
280280
self.update_search();
281+
} else if !self.search_matches.is_empty() {
282+
// Recalculate match indices without moving the cursor
283+
self.refresh_search_matches();
281284
}
282285

283286
self.update_selected();

src/tui/home/tests.rs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -572,6 +572,27 @@ fn test_esc_in_normal_mode_clears_matches() {
572572
assert_eq!(env.view.cursor, cursor_before);
573573
}
574574

575+
#[test]
576+
#[serial]
577+
fn test_reload_does_not_snap_cursor_after_enter() {
578+
let mut env = create_test_env_with_sessions(5);
579+
// Search and exit with Enter
580+
env.view.handle_key(key(KeyCode::Char('/')));
581+
env.view.handle_key(key(KeyCode::Char('s')));
582+
env.view.handle_key(key(KeyCode::Enter));
583+
assert!(!env.view.search_active);
584+
585+
// Navigate away from the search result
586+
env.view.cursor = 4;
587+
env.view.update_selected();
588+
589+
// Simulate periodic reload
590+
env.view.reload().unwrap();
591+
592+
// Cursor should stay where the user put it, not snap back to best match
593+
assert_eq!(env.view.cursor, 4);
594+
}
595+
575596
#[test]
576597
#[serial]
577598
fn test_enter_keeps_matches_for_n_cycling() {

0 commit comments

Comments
 (0)