From ebd5d0a02ce47915a4a511818ccf70c823b9b080 Mon Sep 17 00:00:00 2001 From: multiplex55 <6619098+multiplex55@users.noreply.github.com> Date: Sat, 7 Feb 2026 13:09:54 -0500 Subject: [PATCH] Optimize TodoWidget filter/sort lowercase allocations --- Cargo.toml | 4 ++ benches/todo_widget_filtering.rs | 110 +++++++++++++++++++++++++++++++ src/dashboard/widgets/todo.rs | 58 +++++++++++----- 3 files changed, 154 insertions(+), 18 deletions(-) create mode 100644 benches/todo_widget_filtering.rs diff --git a/Cargo.toml b/Cargo.toml index 9a5bc024..528d8444 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -89,3 +89,7 @@ harness = false [[bench]] name = "omni_search" harness = false +[[bench]] +name = "todo_widget_filtering" +harness = false + diff --git a/benches/todo_widget_filtering.rs b/benches/todo_widget_filtering.rs new file mode 100644 index 00000000..dbe0ecf0 --- /dev/null +++ b/benches/todo_widget_filtering.rs @@ -0,0 +1,110 @@ +use criterion::{black_box, criterion_group, criterion_main, Criterion}; +use multi_launcher::plugins::todo::TodoEntry; + +fn build_entries(count: usize) -> Vec { + (0..count) + .map(|i| TodoEntry { + text: format!("Todo item {i:05} with mixed CASE"), + done: i % 3 == 0, + priority: (i % 10) as u8, + tags: vec![ + format!("team{}", i % 8), + format!("Feature{}", i % 16), + "Urgent".into(), + ], + }) + .collect() +} + +fn old_tags_match(filter_tags: &[String], entry: &TodoEntry) -> bool { + if filter_tags.is_empty() { + return true; + } + filter_tags.iter().any(|tag| { + let filter = tag.to_lowercase(); + entry + .tags + .iter() + .any(|t| t.to_lowercase().contains(&filter)) + }) +} + +fn new_tags_match(normalized_filter_tags: &[String], entry: &TodoEntry) -> bool { + if normalized_filter_tags.is_empty() { + return true; + } + normalized_filter_tags.iter().any(|filter| { + entry + .tags + .iter() + .any(|tag| tag.eq_ignore_ascii_case(filter) || tag.to_lowercase().contains(filter)) + }) +} + +fn old_sort_entries(entries: &mut Vec<(usize, TodoEntry)>) { + entries.sort_by(|a, b| { + a.1.text + .to_lowercase() + .cmp(&b.1.text.to_lowercase()) + .then_with(|| a.0.cmp(&b.0)) + }); +} + +fn new_sort_entries(entries: &mut Vec<(usize, TodoEntry)>) { + let mut keyed: Vec<(String, usize, TodoEntry)> = entries + .drain(..) + .map(|(idx, entry)| (entry.text.to_lowercase(), idx, entry)) + .collect(); + keyed.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1))); + entries.extend(keyed.into_iter().map(|(_, idx, entry)| (idx, entry))); +} + +fn bench_todo_filter_and_sort(c: &mut Criterion) { + let todos = build_entries(25_000); + let filter_tags = vec![ + "urgent".to_string(), + "feature1".to_string(), + "team7".to_string(), + ]; + let normalized_filter_tags: Vec = + filter_tags.iter().map(|t| t.to_lowercase()).collect(); + + c.bench_function("todo_filter_old", |b| { + b.iter(|| { + let count = todos + .iter() + .filter(|entry| old_tags_match(black_box(&filter_tags), entry)) + .count(); + black_box(count) + }) + }); + + c.bench_function("todo_filter_new", |b| { + b.iter(|| { + let count = todos + .iter() + .filter(|entry| new_tags_match(black_box(&normalized_filter_tags), entry)) + .count(); + black_box(count) + }) + }); + + c.bench_function("todo_sort_old", |b| { + b.iter(|| { + let mut entries: Vec<(usize, TodoEntry)> = todos.iter().cloned().enumerate().collect(); + old_sort_entries(&mut entries); + black_box(entries.len()) + }) + }); + + c.bench_function("todo_sort_new", |b| { + b.iter(|| { + let mut entries: Vec<(usize, TodoEntry)> = todos.iter().cloned().enumerate().collect(); + new_sort_entries(&mut entries); + black_box(entries.len()) + }) + }); +} + +criterion_group!(benches, bench_todo_filter_and_sort); +criterion_main!(benches); diff --git a/src/dashboard/widgets/todo.rs b/src/dashboard/widgets/todo.rs index fff85e95..a1c86cd2 100644 --- a/src/dashboard/widgets/todo.rs +++ b/src/dashboard/widgets/todo.rs @@ -194,16 +194,23 @@ impl TodoWidget { }) } - fn tags_match(&self, entry: &TodoEntry) -> bool { - if self.cfg.filter_tags.is_empty() { + fn normalized_filter_tags(&self) -> Vec { + self.cfg + .filter_tags + .iter() + .map(|tag| tag.to_lowercase()) + .collect() + } + + fn tags_match(&self, entry: &TodoEntry, normalized_filter_tags: &[String]) -> bool { + if normalized_filter_tags.is_empty() { return true; } - self.cfg.filter_tags.iter().any(|tag| { - let filter = tag.to_lowercase(); + normalized_filter_tags.iter().any(|filter| { entry .tags .iter() - .any(|t| t.to_lowercase().contains(&filter)) + .any(|tag| tag.eq_ignore_ascii_case(filter) || tag.to_lowercase().contains(filter)) }) } @@ -219,16 +226,22 @@ impl TodoWidget { entry.priority >= self.cfg.min_priority } - fn entry_matches_filters(&self, entry: &TodoEntry) -> bool { - self.status_match(entry) && self.priority_match(entry) && self.tags_match(entry) + fn entry_matches_filters(&self, entry: &TodoEntry, normalized_filter_tags: &[String]) -> bool { + self.status_match(entry) + && self.priority_match(entry) + && self.tags_match(entry, normalized_filter_tags) } - fn filter_entries(&self, todos: &[TodoEntry]) -> Vec<(usize, TodoEntry)> { + fn filter_entries( + &self, + todos: &[TodoEntry], + normalized_filter_tags: &[String], + ) -> Vec<(usize, TodoEntry)> { todos .iter() .cloned() .enumerate() - .filter(|(_, t)| self.entry_matches_filters(t)) + .filter(|(_, t)| self.entry_matches_filters(t, normalized_filter_tags)) .collect() } @@ -250,19 +263,26 @@ impl TodoWidget { entries.sort_by(|a, b| b.1.priority.cmp(&a.1.priority).then_with(|| a.0.cmp(&b.0))) } TodoSort::Created => entries.sort_by_key(|(idx, _)| *idx), - TodoSort::Alphabetical => entries.sort_by(|a, b| { - a.1.text - .to_lowercase() - .cmp(&b.1.text.to_lowercase()) - .then_with(|| a.0.cmp(&b.0)) - }), + TodoSort::Alphabetical => { + let mut keyed_entries: Vec<(String, usize, TodoEntry)> = entries + .drain(..) + .map(|(idx, entry)| (entry.text.to_lowercase(), idx, entry)) + .collect(); + keyed_entries.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1))); + entries.extend( + keyed_entries + .into_iter() + .map(|(_, idx, entry)| (idx, entry)), + ); + } } } fn render_summary(&mut self, ui: &mut egui::Ui, todos: &[TodoEntry]) -> Option { + let normalized_filter_tags = self.normalized_filter_tags(); let filtered: Vec<&TodoEntry> = todos .iter() - .filter(|t| self.priority_match(t) && self.tags_match(t)) + .filter(|t| self.priority_match(t) && self.tags_match(t, &normalized_filter_tags)) .collect(); let done = filtered.iter().filter(|t| t.done).count(); let total = filtered.len(); @@ -353,7 +373,8 @@ impl TodoWidget { ctx: &DashboardContext<'_>, todos: &[TodoEntry], ) -> Option { - let mut entries = self.filter_entries(todos); + let normalized_filter_tags = self.normalized_filter_tags(); + let mut entries = self.filter_entries(todos, &normalized_filter_tags); Self::sort_entries(&mut entries, self.cfg.sort); entries.truncate(self.cfg.count); @@ -505,7 +526,8 @@ mod tests { cfg.sort = TodoSort::Priority; let widget = TodoWidget::new(cfg); - let mut filtered = widget.filter_entries(&sample_entries()); + let normalized_filter_tags = widget.normalized_filter_tags(); + let mut filtered = widget.filter_entries(&sample_entries(), &normalized_filter_tags); TodoWidget::sort_entries(&mut filtered, TodoSort::Priority); let texts: Vec = filtered.into_iter().map(|(_, entry)| entry.text).collect(); assert_eq!(texts, vec!["gamma"]);