Skip to content

Commit c13bcda

Browse files
thewrzcodex
andauthored
feat(ui): add persisted sound sorting (#211)
* feat(ui): add persisted sound sorting Add reusable list sort controls, tolerant per-view preferences, and deterministic ordering for the main sound grid. Co-Authored-By: Codex <noreply@openai.com> * fix(state): tolerate malformed sort preferences Deserialize sort preferences independently so malformed future entries fall back without invalidating otherwise usable application settings. Co-Authored-By: Codex <noreply@openai.com> * 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> * fix(ui): address sound sorting review findings Keep grid rows aligned when cached indices no longer resolve and consolidate config loading through the explicit-path implementation. Co-Authored-By: Codex <noreply@openai.com> --------- Co-authored-by: Codex <noreply@openai.com>
1 parent 640595c commit c13bcda

21 files changed

Lines changed: 1695 additions & 319 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
}
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
# Issue #197 — Persisted Sound-Tile Sorting
2+
3+
Date: 2026-07-23 · Status: approved
4+
5+
## Why
6+
7+
The main sound grid needs predictable, user-selectable ordering without coupling sorting to its
8+
session-only filter. The first consumer establishes reusable list-control state and widgets for
9+
later slot, shortcut, and macro views while keeping grouping out of scope until user-defined tags
10+
exist.
11+
12+
The current folder-derived category is presented as **Folder**. “Tag” remains reserved for #201.
13+
14+
## Data Shapes
15+
16+
- `ui::list_controls::sort::Direction::{Ascending, Descending}` controls display order.
17+
- `ui::list_controls::sort::SortState<K> { key, direction }` owns one view's active ordering.
18+
- `ui::list_controls::sort::SortKey<T>` supplies a label, primary comparison, and an optional
19+
unknown-value predicate. `SortState` applies direction only within known/unknown buckets so
20+
unknown values remain last in both directions.
21+
- `app::sorting::SoundSortKey::{Name, Length, Folder, Modified, Added}` implements the sound-grid
22+
ordering contract. The view default is Name ascending.
23+
- `state::config::SortPref` is a persistence DTO containing string key and direction values.
24+
`AppConfig.sort_prefs` stores these by view ID; the main grid uses `"tiles"`.
25+
- The sort menu anchor is transient app state. It is never persisted.
26+
27+
Keeping the persisted DTO separate from the runtime enums lets old or future config values load
28+
without making the whole config unreadable. A missing or unrecognized preference resolves to that
29+
view's complete default.
30+
31+
## Interfaces and Touch Map
32+
33+
- `src/ui/list_controls/sort.rs`
34+
- pure generic sort controller and direction handling;
35+
- `view_sort_chip` with separate label and chevron actions;
36+
- `view_sort_menu_overlay` with option selection and a full-window dismiss backdrop.
37+
- `src/state/config/sort.rs`
38+
- tolerant persistence DTO.
39+
- `src/state/config.rs`
40+
- extract the existing test module first so the production file is below 400 lines;
41+
- add `sort_prefs` with a Serde default.
42+
- `src/app/sorting.rs`
43+
- preference conversion, sound comparison, app update helpers, and boundary tests.
44+
- `src/app/header.rs`
45+
- extract the existing header from oversized `app/mod.rs` and place the sort chip directly beside
46+
the main search input.
47+
- `src/app/filtering.rs`
48+
- compose category/query filtering with sound ordering and give an open sort menu Escape priority.
49+
- `src/app/mod.rs`
50+
- declarations and narrow delegation only; the extracted header ensures the already-oversized
51+
file shrinks overall.
52+
53+
No dependency is added.
54+
55+
## Ordering Contract
56+
57+
1. Name compares the customized display name when present, otherwise the scanned name.
58+
2. Name and Folder comparisons are Unicode-lowercased before comparison.
59+
3. Every primary-key tie is resolved by path and then sound ID, producing deterministic output.
60+
4. Length compares milliseconds; Folder compares the current folder-derived category; Modified
61+
uses the scan timestamp; Added uses the persisted first-seen timestamp.
62+
5. Missing Length, Modified, or Added values form an unknown bucket after all known values.
63+
Descending reverses ordering within each bucket, never the bucket placement.
64+
6. Filtering and category selection happen before sorting and do not alter the persisted
65+
preference.
66+
67+
## Interaction Contract
68+
69+
- Clicking the chip label toggles the options menu.
70+
- Clicking only the chevron toggles direction and persists immediately.
71+
- Choosing a key persists it and closes the menu.
72+
- Clicking outside the menu or pressing Escape closes it without changing the preference.
73+
- Filter text remains session-only.
74+
- The menu exposes sorting as its own section/function so a later grouping section can be appended
75+
without changing `SortState`; grouping itself is not implemented.
76+
77+
## Iced API Finding
78+
79+
No throwaway spike is required. `sound_grid::context_menu_overlay` already demonstrates the exact
80+
Iced 0.14 API needed here: a stable window-level `Stack`, a full-size `mouse_area` dismiss layer,
81+
and the interactive menu above it. The sort menu reuses this proven pattern and clamps its captured
82+
cursor anchor to the window bounds.
83+
84+
## Invariants
85+
86+
1. Name ascending is the default for missing, malformed, or unknown tile preferences.
87+
2. A valid preference survives `AppConfig` serialization and reload.
88+
3. Unknown persisted keys or directions never fail config deserialization and never partially
89+
influence runtime state.
90+
4. Unknown dates always sort last for both ascending and descending directions.
91+
5. Case-insensitive name ties are deterministic by path and ID.
92+
6. Changing direction or key never mutates the sound library or metadata.
93+
7. Opening or dismissing the menu never changes the active ordering.
94+
8. The main filter query is never written to config.
95+
96+
## TDD Sequence
97+
98+
1. Add failing pure tests for per-key ordering, case-insensitive ties, direction, and unknown values.
99+
2. Add failing config tests for preference round trips and tolerant unknown data.
100+
3. Implement runtime/persistence types and the pure sound sorter.
101+
4. Add app-boundary tests proving preference load, selection, toggle, dismissal, and ordered
102+
filtered results.
103+
5. Wire the chip and overlay using the established Iced stack pattern.
104+
6. Format, run focused tests, the complete test suite, Clippy with warnings denied, and review the
105+
complete branch diff.

0 commit comments

Comments
 (0)