Skip to content

Commit 767723a

Browse files
thewrzcodex
andcommitted
fix(ui): cache sorted sound indices
Move sound filtering and sorting to update-side invalidation boundaries so Iced view construction only indexes cached results. Project only the active sort key and keep render benchmarks on the production cached-index API. Co-Authored-By: Codex <noreply@openai.com>
1 parent f87503e commit 767723a

11 files changed

Lines changed: 664 additions & 369 deletions

File tree

benches/grid_render.rs

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,7 @@ mod support;
88

99
use criterion::{BenchmarkId, Criterion, criterion_group, criterion_main};
1010

11-
use support::{
12-
GridFixture, init_wgpu, make_sounds, render_tiny_skia, self_check, sound_refs, try_render_wgpu,
13-
};
11+
use support::{GridFixture, init_wgpu, make_sounds, render_tiny_skia, self_check, try_render_wgpu};
1412

1513
/// Tile counts ADR-009 anchors the baseline against.
1614
const SIZES: [usize; 3] = [50, 200, 500];
@@ -24,9 +22,9 @@ fn bench_tiny_skia(c: &mut Criterion) {
2422
for &n in &SIZES {
2523
let sounds = make_sounds(n);
2624
let fx = GridFixture::new();
27-
let refs = sound_refs(&sounds);
25+
let visible_indices = (0..sounds.len()).collect::<Vec<_>>();
2826
group.bench_with_input(BenchmarkId::from_parameter(n), &n, |b, _| {
29-
b.iter(|| render_tiny_skia(&refs, fx.grid_ctx(COLUMNS)));
27+
b.iter(|| render_tiny_skia(&sounds, &visible_indices, fx.grid_ctx(COLUMNS)));
3028
});
3129
}
3230
group.finish();
@@ -45,9 +43,9 @@ fn bench_wgpu(c: &mut Criterion) {
4543
for &n in &SIZES {
4644
let sounds = make_sounds(n);
4745
let fx = GridFixture::new();
48-
let refs = sound_refs(&sounds);
46+
let visible_indices = (0..sounds.len()).collect::<Vec<_>>();
4947
group.bench_with_input(BenchmarkId::from_parameter(n), &n, |b, _| {
50-
b.iter(|| try_render_wgpu(&refs, fx.grid_ctx(COLUMNS), &gpu));
48+
b.iter(|| try_render_wgpu(&sounds, &visible_indices, fx.grid_ctx(COLUMNS), &gpu));
5149
});
5250
}
5351
group.finish();

benches/support/mod.rs

Lines changed: 18 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -66,11 +66,6 @@ pub fn make_sounds(n: usize) -> Vec<SoundEntry> {
6666
.collect()
6767
}
6868

69-
/// Borrows entries into the `&[&SoundEntry]` slice `view_grid` expects.
70-
pub fn sound_refs(sounds: &[SoundEntry]) -> Vec<&SoundEntry> {
71-
sounds.iter().collect()
72-
}
73-
7469
/// Owns the per-grid context state (`SlotMap`, trigger labels, meta store) so a
7570
/// `GridCtx` can borrow from it for the duration of a bench iteration. Empty /
7671
/// default state represents the common case (no slots bound, no favorites).
@@ -111,8 +106,13 @@ impl Default for GridFixture {
111106
/// Builds the grid `Element` and runs Iced's layout + draw passes against the
112107
/// provided renderer. This is the `view()`-construction + tessellation work
113108
/// ADR-009 cares about. Rasterization is renderer-specific (done by callers).
114-
fn layout_and_draw(sounds: &[&SoundEntry], grid: GridCtx, renderer: &mut iced::Renderer) {
115-
let element: Element<'_, Message> = view_grid(sounds.to_vec(), None, grid);
109+
fn layout_and_draw(
110+
sounds: &[SoundEntry],
111+
visible_indices: &[usize],
112+
grid: GridCtx,
113+
renderer: &mut iced::Renderer,
114+
) {
115+
let element: Element<'_, Message> = view_grid(sounds, visible_indices, None, grid);
116116
let bounds = Size::new(VIEW_W as f32, VIEW_H as f32);
117117
let mut ui = UserInterface::build(element, bounds, Cache::new(), renderer);
118118
let theme = Theme::Dark;
@@ -138,12 +138,12 @@ fn full_damage() -> [Rectangle; 1] {
138138
/// Full tiny-skia render: layout + draw + CPU rasterization into a `Pixmap`.
139139
/// This is the `HONKHONK_RENDERER=software` path. Returns the top-left pixel so
140140
/// the optimizer cannot elide the raster. Always available (pure CPU).
141-
pub fn render_tiny_skia(sounds: &[&SoundEntry], grid: GridCtx) -> u32 {
141+
pub fn render_tiny_skia(sounds: &[SoundEntry], visible_indices: &[usize], grid: GridCtx) -> u32 {
142142
// The `Element` is generic over `iced::Renderer` (the fallback enum); its
143143
// `Secondary` arm *is* the tiny-skia renderer, so draw lands in its layers.
144144
let mut renderer =
145145
iced::Renderer::Secondary(iced_tiny_skia::Renderer::new(Font::DEFAULT, text_size()));
146-
layout_and_draw(sounds, grid, &mut renderer);
146+
layout_and_draw(sounds, visible_indices, grid, &mut renderer);
147147

148148
let iced::Renderer::Secondary(ts) = &mut renderer else {
149149
unreachable!("constructed Secondary");
@@ -235,13 +235,18 @@ pub fn init_wgpu() -> Option<WgpuCtx> {
235235
/// Full wgpu render: layout + draw + present to the reusable offscreen target.
236236
/// Returns a token so the work cannot be optimized away. Requires an
237237
/// initialized context.
238-
pub fn try_render_wgpu(sounds: &[&SoundEntry], grid: GridCtx, gpu: &WgpuCtx) -> u32 {
238+
pub fn try_render_wgpu(
239+
sounds: &[SoundEntry],
240+
visible_indices: &[usize],
241+
grid: GridCtx,
242+
gpu: &WgpuCtx,
243+
) -> u32 {
239244
let mut renderer = iced::Renderer::Primary(iced_wgpu::Renderer::new(
240245
gpu.engine.clone(),
241246
Font::DEFAULT,
242247
text_size(),
243248
));
244-
layout_and_draw(sounds, grid, &mut renderer);
249+
layout_and_draw(sounds, visible_indices, grid, &mut renderer);
245250

246251
let iced::Renderer::Primary(wr) = &mut renderer else {
247252
unreachable!("constructed Primary");
@@ -272,7 +277,7 @@ pub fn self_check() {
272277
u64::from_str_radix(head, 16).expect("fixture id head parses as hex");
273278
}
274279
let sounds = make_sounds(50);
275-
let refs = sound_refs(&sounds);
280+
let visible_indices = (0..sounds.len()).collect::<Vec<_>>();
276281
let fx = GridFixture::new();
277-
let _ = render_tiny_skia(&refs, fx.grid_ctx(5));
282+
let _ = render_tiny_skia(&sounds, &visible_indices, fx.grid_ctx(5));
278283
}

src/app/filtering.rs

Lines changed: 129 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
use iced::event::Status;
22
use iced::keyboard;
3-
use std::borrow::Cow;
43

5-
use super::{FAVORITES_TAB, HonkHonk, Message, ViewMode, sorting};
6-
use crate::state::SoundEntry;
7-
use crate::ui::list_controls::filter::{Activation, ActivationContext, filter_items};
4+
use super::{HonkHonk, Message, ViewMode};
5+
use crate::ui::list_controls::filter::{Activation, ActivationContext};
86
use crate::ui::search_bar;
97

8+
mod cache;
9+
1010
pub(super) fn type_to_filter_text(event: &iced::Event, status: Status) -> Option<String> {
1111
if status != Status::Ignored {
1212
return None;
@@ -30,6 +30,22 @@ pub(super) fn type_to_filter_text(event: &iced::Event, status: Status) -> Option
3030
}
3131

3232
impl HonkHonk {
33+
pub(super) fn select_sound_category(&mut self, category: Option<String>) {
34+
if self.active_category == category {
35+
return;
36+
}
37+
self.active_category = category;
38+
self.refresh_filtered_sounds();
39+
}
40+
41+
pub(super) fn replace_filter_query(&mut self, query: String) {
42+
let changed = self.filter.query() != query;
43+
self.filter.replace(query);
44+
if changed {
45+
self.refresh_filtered_sounds();
46+
}
47+
}
48+
3349
fn filter_context(&self) -> ActivationContext {
3450
let activation = match self.view_mode {
3551
ViewMode::Main => Activation::TypeToFilter,
@@ -52,6 +68,9 @@ impl HonkHonk {
5268
}
5369

5470
self.filter.insert(text);
71+
if !text.is_empty() {
72+
self.refresh_filtered_sounds();
73+
}
5574
iced::widget::operation::focus(search_bar::input_id())
5675
}
5776

@@ -75,40 +94,14 @@ impl HonkHonk {
7594
} else if event_was_captured {
7695
self.filter.consume_focus();
7796
} else {
97+
let query_was_present = !self.filter.query().is_empty();
7898
self.filter.escape();
99+
if query_was_present && self.filter.query().is_empty() {
100+
self.refresh_filtered_sounds();
101+
}
79102
}
80103
iced::Task::none()
81104
}
82-
83-
/// Returns sounds matching the shared query and active category filters.
84-
pub fn filtered_sounds(&self) -> Vec<&SoundEntry> {
85-
let sounds = filter_items(&self.sounds, self.filter.query(), |sound| {
86-
let display_name = self
87-
.sound_meta
88-
.get_ref(&sound.id)
89-
.and_then(|meta| meta.display_name.as_deref())
90-
.unwrap_or("");
91-
let filename = sound
92-
.path
93-
.file_name()
94-
.map(std::ffi::OsStr::to_string_lossy)
95-
.unwrap_or_default();
96-
[
97-
Cow::Borrowed(display_name),
98-
filename,
99-
Cow::Borrowed(sound.name.as_str()),
100-
Cow::Borrowed(sound.category.as_str()),
101-
]
102-
})
103-
.into_iter()
104-
.filter(|sound| match self.active_category.as_deref() {
105-
Some(FAVORITES_TAB) => self.sound_meta.is_favorite(&sound.id),
106-
Some(category) => sound.category == category,
107-
None => true,
108-
})
109-
.collect();
110-
sorting::sorted_sounds(sounds, self.sound_sort, &self.sound_meta)
111-
}
112105
}
113106

114107
#[cfg(test)]
@@ -118,7 +111,27 @@ mod tests {
118111
use iced::keyboard::{self, Key, Location, Modifiers};
119112

120113
use super::*;
121-
use crate::state::{AudioFormat, Macro};
114+
use crate::app::FAVORITES_TAB;
115+
use crate::state::{AudioFormat, Macro, SoundEntry};
116+
117+
fn sound(id: &str, name: &str, duration_ms: Option<u64>, category: &str) -> SoundEntry {
118+
SoundEntry {
119+
id: id.into(),
120+
name: name.into(),
121+
path: format!("/sounds/{category}/{id}.wav").into(),
122+
format: AudioFormat::Wav,
123+
duration_ms,
124+
category: category.into(),
125+
modified_ms: None,
126+
}
127+
}
128+
129+
fn filtered_ids(app: &HonkHonk) -> Vec<&str> {
130+
app.filtered_sounds()
131+
.into_iter()
132+
.map(|sound| sound.id.as_str())
133+
.collect()
134+
}
122135

123136
fn key_event(text: Option<&str>, modifiers: Modifiers) -> iced::Event {
124137
iced::Event::Keyboard(keyboard::Event::KeyPressed {
@@ -268,23 +281,92 @@ mod tests {
268281
#[test]
269282
fn main_grid_filter_results_follow_the_active_sort_state() {
270283
let mut app = HonkHonk::new_for_test();
271-
app.sounds = ["Zulu", "alpha"]
272-
.into_iter()
273-
.map(|name| SoundEntry {
274-
id: name.into(),
275-
name: name.into(),
276-
path: format!("/sounds/{name}.wav").into(),
277-
format: AudioFormat::Wav,
278-
duration_ms: None,
279-
category: "Other".into(),
280-
modified_ms: None,
281-
})
282-
.collect();
284+
app.sounds = vec![
285+
sound("zulu", "Zulu", None, "Other"),
286+
sound("alpha", "alpha", None, "Other"),
287+
];
288+
app.refresh_filtered_sounds();
283289

284290
assert_eq!(app.filtered_sounds()[0].name, "alpha");
285291

286292
let _ = app.update(Message::ToggleSoundSortDirection);
287293

288294
assert_eq!(app.filtered_sounds()[0].name, "Zulu");
289295
}
296+
297+
#[test]
298+
fn filtered_sounds_reads_cached_order_without_resorting() {
299+
let mut app = HonkHonk::new_for_test();
300+
app.sounds = vec![
301+
sound("zulu", "Zulu", None, "Other"),
302+
sound("alpha", "alpha", None, "Other"),
303+
];
304+
app.refresh_filtered_sounds();
305+
306+
assert_eq!(filtered_ids(&app), vec!["alpha", "zulu"]);
307+
app.sound_sort.toggle_direction();
308+
309+
assert_eq!(
310+
filtered_ids(&app),
311+
vec!["alpha", "zulu"],
312+
"reading filtered sounds must not recompute their order"
313+
);
314+
}
315+
316+
#[test]
317+
fn query_category_and_favorite_updates_refresh_cached_membership() {
318+
let mut app = HonkHonk::new_for_test();
319+
app.sounds = vec![
320+
sound("alpha", "Alpha", None, "Animals"),
321+
sound("beta", "Beta", None, "Memes"),
322+
];
323+
app.refresh_filtered_sounds();
324+
325+
let _ = app.update(Message::SearchChanged("beta".into()));
326+
assert_eq!(filtered_ids(&app), vec!["beta"]);
327+
328+
let _ = app.update(Message::SearchChanged(String::new()));
329+
let _ = app.update(Message::SelectCategory(Some("Animals".into())));
330+
assert_eq!(filtered_ids(&app), vec!["alpha"]);
331+
332+
let _ = app.update(Message::SelectCategory(None));
333+
let _ = app.update(Message::TypeToFilter("beta".into()));
334+
assert_eq!(filtered_ids(&app), vec!["beta"]);
335+
let _ = app.update(Message::EscapePressed);
336+
let _ = app.update(Message::EscapePressed);
337+
assert_eq!(filtered_ids(&app), vec!["alpha", "beta"]);
338+
339+
let _ = app.update(Message::ToggleFavorite("beta".into()));
340+
let _ = app.update(Message::SelectCategory(Some(FAVORITES_TAB.into())));
341+
assert_eq!(filtered_ids(&app), vec!["beta"]);
342+
343+
let _ = app.update(Message::ToggleFavorite("beta".into()));
344+
assert_eq!(filtered_ids(&app), vec!["alpha", "beta"]);
345+
}
346+
347+
#[test]
348+
fn duration_and_display_name_updates_refresh_cached_order() {
349+
let mut app = HonkHonk::new_for_test();
350+
app.sounds = vec![
351+
sound("alpha", "Alpha", Some(200), "Other"),
352+
sound("zulu", "Zulu", None, "Other"),
353+
];
354+
app.refresh_filtered_sounds();
355+
356+
let _ = app.update(Message::SelectSoundSort("length"));
357+
assert_eq!(filtered_ids(&app), vec!["alpha", "zulu"]);
358+
359+
let durations = std::collections::HashMap::from([("zulu".to_owned(), 100)]);
360+
let _ = app.update(Message::DurationsLoaded(durations));
361+
assert_eq!(filtered_ids(&app), vec!["zulu", "alpha"]);
362+
363+
let _ = app.update(Message::SelectSoundSort("name"));
364+
let _ = app.update(Message::OpenSoundEditor("zulu".into()));
365+
let _ = app.update(Message::SoundEditorNameChanged("Aardvark".into()));
366+
let _ = app.update(Message::SaveSoundMeta("zulu".into()));
367+
assert_eq!(filtered_ids(&app), vec!["zulu", "alpha"]);
368+
369+
let _ = app.update(Message::SearchChanged("aardvark".into()));
370+
assert_eq!(filtered_ids(&app), vec!["zulu"]);
371+
}
290372
}

src/app/filtering/cache.rs

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
use std::borrow::Cow;
2+
3+
use super::HonkHonk;
4+
use crate::app::{FAVORITES_TAB, sorting};
5+
use crate::state::SoundEntry;
6+
use crate::ui::list_controls::filter::filter_indices;
7+
8+
impl HonkHonk {
9+
pub(in crate::app) fn refresh_filtered_sounds(&mut self) {
10+
let indices = filter_indices(&self.sounds, self.filter.query(), |sound| {
11+
let display_name = self
12+
.sound_meta
13+
.get_ref(&sound.id)
14+
.and_then(|meta| meta.display_name.as_deref())
15+
.unwrap_or("");
16+
let filename = sound
17+
.path
18+
.file_name()
19+
.map(std::ffi::OsStr::to_string_lossy)
20+
.unwrap_or_default();
21+
[
22+
Cow::Borrowed(display_name),
23+
filename,
24+
Cow::Borrowed(sound.name.as_str()),
25+
Cow::Borrowed(sound.category.as_str()),
26+
]
27+
})
28+
.into_iter()
29+
.filter(|&index| self.sound_matches_active_category(index))
30+
.collect();
31+
self.filtered_sound_indices =
32+
sorting::sorted_sound_indices(&self.sounds, indices, self.sound_sort, &self.sound_meta);
33+
}
34+
35+
fn sound_matches_active_category(&self, index: usize) -> bool {
36+
let Some(sound) = self.sounds.get(index) else {
37+
return false;
38+
};
39+
match self.active_category.as_deref() {
40+
Some(FAVORITES_TAB) => self.sound_meta.is_favorite(&sound.id),
41+
Some(category) => sound.category == category,
42+
None => true,
43+
}
44+
}
45+
46+
/// Returns cached sounds matching the shared query and category filters.
47+
pub fn filtered_sounds(&self) -> Vec<&SoundEntry> {
48+
self.filtered_sound_indices
49+
.iter()
50+
.filter_map(|&index| self.sounds.get(index))
51+
.collect()
52+
}
53+
}

0 commit comments

Comments
 (0)