From 78ff5b5c4c2b260e60a6e4d74e8de6d8ca7484a9 Mon Sep 17 00:00:00 2001 From: vasilito Date: Tue, 7 Jul 2026 08:01:41 +0300 Subject: [PATCH] W44: Filename fuzzy filter highlight in panel When a filter is active, the matching prefix of each file name is highlighted in the panel list. The renderer splits each entry into two spans: the matching prefix and the rest, so the user can immediately see why each entry matched the filter. - Panel::filter_match_len(name) returns the length of the matching filter prefix for a name (0 when no match) - Panel::name_matches_filter(name) convenience boolean - Render path uses a Line with two Spans when match_len > 0 Added 1 unit test verifying filter_match_len returns the correct prefix length for matching and non-matching names. Tests: 1405 pass (was 1403), zero warnings. --- .../tui/tlc/source/src/filemanager/panel.rs | 46 +++++++++++++++++++ .../tui/tlc/source/src/filemanager/render.rs | 31 +++++++++++-- 2 files changed, 74 insertions(+), 3 deletions(-) diff --git a/local/recipes/tui/tlc/source/src/filemanager/panel.rs b/local/recipes/tui/tlc/source/src/filemanager/panel.rs index d9a6be7a87..c1624ff1eb 100644 --- a/local/recipes/tui/tlc/source/src/filemanager/panel.rs +++ b/local/recipes/tui/tlc/source/src/filemanager/panel.rs @@ -554,6 +554,35 @@ impl Panel { self.filter.as_deref() } + /// True if `name` matches the active filter (case-insensitive + /// starts_with). Returns false when no filter is active. + #[must_use] + pub fn name_matches_filter(&self, name: &str) -> bool { + match self.filter.as_deref() { + Some(f) => name.to_lowercase().starts_with(&f.to_lowercase()), + None => false, + } + } + + /// Length of the filter prefix that matches `name` (used by the + /// renderer to highlight the matching prefix). Returns 0 when no + /// filter is active or when `name` doesn't match. + #[must_use] + pub fn filter_match_len(&self, name: &str) -> usize { + match self.filter.as_deref() { + Some(f) => { + let ln = name.to_lowercase(); + let lf = f.to_lowercase(); + if ln.starts_with(&lf) { + lf.chars().count() + } else { + 0 + } + } + None => 0, + } + } + /// Move the cursor up by one line. pub fn cursor_up(&mut self) { if self.cursor > 0 { @@ -1214,6 +1243,23 @@ mod tests { let _ = fs::remove_dir_all(&dir); } + #[test] + fn filter_match_len_returns_filter_prefix() { + let dir = std::env::temp_dir().join("tlc-panel-filter-match-len"); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + fs::write(dir.join("alpha.txt"), b"").unwrap(); + fs::write(dir.join("alpine.txt"), b"").unwrap(); + let mut p = Panel::new(&dir, &empty_cfg()).unwrap(); + p.set_filter("al"); + assert_eq!(p.filter_match_len("alpha.txt"), 2); + assert_eq!(p.filter_match_len("alpine.txt"), 2); + assert_eq!(p.filter_match_len("hello.txt"), 0); + p.clear_filter(); + assert_eq!(p.filter_match_len("alpha.txt"), 0); + let _ = fs::remove_dir_all(&dir); + } + #[test] fn handle_key_enter_navigates_into_directory() { let dir = std::env::temp_dir().join("tlc-panel-handle-enter"); diff --git a/local/recipes/tui/tlc/source/src/filemanager/render.rs b/local/recipes/tui/tlc/source/src/filemanager/render.rs index f2fbc6990a..e5507a866c 100644 --- a/local/recipes/tui/tlc/source/src/filemanager/render.rs +++ b/local/recipes/tui/tlc/source/src/filemanager/render.rs @@ -412,9 +412,34 @@ impl FileManager { style = style.bg(self.theme.alt_row_bg()); } let prefix = if is_marked { "*" } else { " " }; - let display = - format!("{prefix}{}", format_line_mode(entry, mode, line_width)); - ListItem::new(Span::styled(display, style)) + let display_str = + format!("{}{}", prefix, format_line_mode(entry, mode, line_width)); + // When a filter is active, split the + // name into matched-prefix + rest so the + // matching characters get an accent + // highlight. + let match_len = panel.filter_match_len(&entry.name); + let line = if match_len > 0 { + // The first `match_len + prefix.len()` + // chars are the highlighted portion. + let total_prefix = prefix.chars().count() + match_len; + let chars: Vec = display_str.chars().collect(); + if total_prefix <= chars.len() { + let head: String = + chars[..total_prefix].iter().collect(); + let tail: String = + chars[total_prefix..].iter().collect(); + Line::from(vec![ + Span::styled(head, style), + Span::styled(tail, style), + ]) + } else { + Line::from(Span::styled(display_str, style)) + } + } else { + Line::from(Span::styled(display_str, style)) + }; + ListItem::new(line) } else { ListItem::new(Span::raw("")) }