Skip to main content

max / audiofiles

Flip the filter panel to the described screen The panel serves from `quasi::filters` into the app's own left pane, and `ui/filter_panel.rs` is gone. `RangeAxis` and the six axis constants came out of it rather than going with it, the same way `RENDER_ROWS` did: a sentinel edge and a unit are facts about the axis, and `FromFilters::table` pairs each key with its geometry from one place. The eight sections had to be opened to compare anything, and the panel's own rule opens them: `filter_section` is `default_open(active)`, so the fixture narrows every axis rather than reaching past the rule. That is also the state worth comparing -- an empty filter panel is the one arrangement where neither side has much to say. What the flip changes, all named at the call site: eight bare "clear" links become named acts, two of which now say what they clear; "+" and "x" on the tag row become "Add" and a described remove; and Exact is dead when it is already the mode in force, where the shipped control was alive and did nothing. The six axes are intervals with an Apply each, against twelve unnamed drag values that filtered on every drag. The harness was dropping pressable tokens. A `Tag::chip` carrying an action is a control -- the twenty-four key pills are the site -- and one carrying none is a badge, so `Tag::action` is what decides rather than the kind. Without that, a whole axis of filtering read as a difference and was the reader's.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-22 18:18 UTC
Signed with PGP, not checked
Commit: ba2b1e97a8618cd06c8c74ddc906f5134a3defec
Parent: 73dcf75
8 files changed, +233 insertions, -506 deletions
@@ -4,8 +4,8 @@
4 4
5 5 use crate::state::{BrowserState, ImportMode};
6 6 use crate::ui::{
7 - detail, export_screens, file_list, filter_panel, footer, import_screens, instrument_panel,
8 - layout_strip, overlays, sidebar, theme, toolbar,
7 + detail, export_screens, file_list, footer, import_screens, instrument_panel, layout_strip,
8 + overlays, sidebar, theme, toolbar,
9 9 };
10 10 use audiofiles_core::vfs::NodeType;
11 11
@@ -313,14 +313,6 @@
313 313 instrument_panel::draw_midi_window(&ctx, state);
314 314 }
315 315
316 - // The described filter panel, beside the shipped one and on its toggle. The
317 - // shipped side is a left pane rather than a window, so there is nothing to
318 - // share -- same terms as the file list and the detail panel.
319 - #[cfg(feature = "quasi")]
320 - if state.search.filter_panel_open {
321 - crate::quasi::panel::draw_filters(&ctx, state);
322 - }
323 -
324 316 // Left sidebar (or filter panel)
325 317 if state.search.filter_panel_open {
326 318 egui::Panel::left("filter_panel")
@@ -328,7 +320,7 @@
328 320 .size_range(160.0..=300.0)
329 321 .show(ui, |ui| {
330 322 egui::ScrollArea::vertical().show(ui, |ui| {
331 - filter_panel::draw_filter_panel(ui, state);
323 + crate::quasi::panel::draw_filters(ui, state);
332 324 });
333 325 });
334 326 } else if state.sidebar_visible {
@@ -110,6 +110,116 @@
110 110 ),
111 111 ];
112 112
113 + // The six axes, and the geometry each one is fixed by.
114 + //
115 + // Lived in `ui::filter_panel` until that module was deleted (2026-08-22) and
116 + // came here rather than going with it: the description is what reads them now,
117 + // and `FromFilters::table` pairs each key with its geometry from the same
118 + // place. A sentinel edge and a unit are facts about the axis rather than about
119 + // any renderer.
120 +
121 + /// A numeric range filter's fixed geometry: the sentinel edges, the drag speed,
122 + /// and the unit shown in the field.
123 + #[derive(Debug, PartialEq)]
124 + pub struct RangeAxis {
125 + /// Section title. Names the axis the way a producer would say it; the unit in
126 + /// `suffix` keeps the number honest.
127 + pub title: &'static str,
128 + /// Hover text, for axes whose title is perceptual rather than physical.
129 + pub hint: Option<&'static str>,
130 + /// The low sentinel edge: a minimum sitting here means no lower bound.
131 + pub lo: f64,
132 + /// The high sentinel edge: a maximum sitting here means no upper bound.
133 + pub hi: f64,
134 + /// How fast a drag moves the value.
135 + pub speed: f64,
136 + /// What the number is measured in, with its leading space.
137 + pub suffix: &'static str,
138 + /// Decimal places in the field. Hz wants 0, a 0..1 ratio wants 2.
139 + pub decimals: usize,
140 + }
141 +
142 + // Every range axis below is one RangeAxis + one call. The sentinel edges are
143 + // the "no filter" values: a min sitting on `lo` or a max on `hi` stores None,
144 + // so the SQL omits that bound. The per-section [clear] link closes the
145 + // sentinel-visibility gap (M-1) and the sibling snap makes contradictory
146 + // min > max states unrepresentable (M-2).
147 + //
148 + // 300 BPM matches the ceiling in bpm.rs's plausible-BPM filter.
149 + pub const BPM: RangeAxis = RangeAxis {
150 + title: "BPM Range",
151 + hint: None,
152 + lo: 0.0,
153 + hi: 300.0,
154 + speed: 1.0,
155 + suffix: "",
156 + decimals: 0,
157 + };
158 + pub const DURATION: RangeAxis = RangeAxis {
159 + title: "Duration",
160 + hint: None,
161 + lo: 0.0,
162 + hi: 600.0,
163 + speed: 0.1,
164 + suffix: " s",
165 + decimals: 1,
166 + };
167 + pub const LOUDNESS: RangeAxis = RangeAxis {
168 + title: "Loudness",
169 + hint: None,
170 + lo: -96.0,
171 + hi: 0.0,
172 + speed: 0.5,
173 + suffix: " dB",
174 + decimals: 0,
175 + };
176 +
177 + // The measured axes. These are what replaced the sample-class filter: a class
178 + // was a lossy guess at a question nobody asked, where a region of the feature
179 + // space is the thing a producer actually reaches for (wiki af-browse-axes).
180 + //
181 + // Titled perceptually and valued in the real unit. "Brightness" is how anyone
182 + // describes the axis; 2500 Hz is what the query compares. Neither alone is
183 + // enough: Hz in the title reads as an instrument spec, and a bare 0-100
184 + // "brightness" score would be a made-up number.
185 + //
186 + // Deliberately NOT binned. Fixed breakpoints do not transfer across material:
187 + // kick/tom one-shots sit 48% low / 43% lowmid / 0% high, reverb loops sit 71%
188 + // high, FSL10K loops spread 16/22/40/22. A "bright" one-shot and a "bright"
189 + // loop are nowhere near each other, so any binning has to be relative to the
190 + // library. Exposing the value and letting the range be chosen sidesteps that.
191 + //
192 + // Ceilings are the measurable range, not a guess: centroid tops out near
193 + // Nyquist for 44.1k material, flatness is 0..1 by construction, and attack is
194 + // capped at 1 s because a slower onset than that is a pad, not a transient.
195 + pub const BRIGHTNESS: RangeAxis = RangeAxis {
196 + title: "Brightness",
197 + hint: Some("Spectral centroid: where the energy sits. Low is dark, high is bright."),
198 + lo: 0.0,
199 + hi: 20_000.0,
200 + speed: 50.0,
201 + suffix: " Hz",
202 + decimals: 0,
203 + };
204 + pub const NOISINESS: RangeAxis = RangeAxis {
205 + title: "Tonal / Noisy",
206 + hint: Some("Spectral flatness: 0 is a pure tone, 1 is white noise."),
207 + lo: 0.0,
208 + hi: 1.0,
209 + speed: 0.01,
210 + suffix: "",
211 + decimals: 2,
212 + };
213 + pub const ATTACK: RangeAxis = RangeAxis {
214 + title: "Attack",
215 + hint: Some("Time to reach full level. Short is a transient, long is a swell."),
216 + lo: 0.0,
217 + hi: 1.0,
218 + speed: 0.005,
219 + suffix: " s",
220 + decimals: 3,
221 + };
222 +
113 223 /// Register the panel's routes.
114 224 pub fn routes(router: Router<Panels<'_>>) -> Router<Panels<'_>> {
115 225 router
@@ -4214,7 +4214,7 @@
4214 4214
4215 4215 /// One numeric axis of the filter panel, as a described screen needs it.
4216 4216 ///
4217 - /// The geometry is [`crate::ui::filter_panel::RangeAxis`], which is the shipped
4217 + /// The geometry is [`crate::quasi::filters::RangeAxis`], which is the shipped
4218 4218 /// panel's own table read rather than copied: the six axes are a constant, and a
4219 4219 /// second table here would drift the way the class filter's list and colour
4220 4220 /// table drifted before the first one existed.
@@ -4229,7 +4229,7 @@
4229 4229 /// from.
4230 4230 pub key: &'static str,
4231 4231 /// The fixed geometry: the sentinel edges, the granularity, the unit.
4232 - pub axis: &'static crate::ui::filter_panel::RangeAxis,
4232 + pub axis: &'static crate::quasi::filters::RangeAxis,
4233 4233 /// The lower end wanted, if one is.
4234 4234 pub lower: Option<f64>,
4235 4235 /// The upper end wanted, if one is.
@@ -4321,42 +4321,42 @@
4321 4321 /// geometry in one place is what stops the description and the write
4322 4322 /// disagreeing about which axis `bpm` is.
4323 4323 fn table(&self) -> [Narrowing; 6] {
4324 - use crate::ui::filter_panel as shipped;
4324 + use crate::quasi::filters as axes;
4325 4325 let f = &self.state.search.search_filter;
4326 4326 [
4327 4327 Narrowing {
4328 4328 key: "bpm",
4329 - axis: &shipped::BPM,
4329 + axis: &axes::BPM,
4330 4330 lower: f.bpm_min,
4331 4331 upper: f.bpm_max,
4332 4332 },
4333 4333 Narrowing {
4334 4334 key: "duration",
4335 - axis: &shipped::DURATION,
4335 + axis: &axes::DURATION,
4336 4336 lower: f.duration_min,
4337 4337 upper: f.duration_max,
4338 4338 },
4339 4339 Narrowing {
4340 4340 key: "loudness",
4341 - axis: &shipped::LOUDNESS,
4341 + axis: &axes::LOUDNESS,
4342 4342 lower: f.peak_db_min,
4343 4343 upper: f.peak_db_max,
4344 4344 },
4345 4345 Narrowing {
4346 4346 key: "brightness",
4347 - axis: &shipped::BRIGHTNESS,
4347 + axis: &axes::BRIGHTNESS,
4348 4348 lower: f.centroid_min,
4349 4349 upper: f.centroid_max,
4350 4350 },
4351 4351 Narrowing {
4352 4352 key: "noisiness",
4353 - axis: &shipped::NOISINESS,
4353 + axis: &axes::NOISINESS,
4354 4354 lower: f.flatness_min,
4355 4355 upper: f.flatness_max,
4356 4356 },
4357 4357 Narrowing {
4358 4358 key: "attack",
4359 - axis: &shipped::ATTACK,
4359 + axis: &axes::ATTACK,
4360 4360 lower: f.attack_min,
4361 4361 upper: f.attack_max,
4362 4362 },
@@ -372,17 +372,15 @@
372 372 apply(ui.ctx(), state, None, intents.into_inner());
373 373 }
374 374
375 - /// Draw the described filter panel, and act on whatever was pressed.
375 + /// Draw the filter panel, and act on whatever was pressed.
376 376 ///
377 - /// Beside the shipped panel and on the same toggle, which is the arrangement
378 - /// every port with a shipped pane rather than a shipped window has taken: the
379 - /// filter panel is a left `egui::Panel` and there is no window to share.
377 + /// Into the app's own left pane rather than a window, because that is what the
378 + /// shipped panel was.
380 379 ///
381 - /// Refreshed unconditionally, and this one earns it more plainly than most. The
382 - /// count under the controls is the size of the result set, so every narrowing
383 - /// changes what the screen says about itself, and an intent lands after the
384 - /// answer was built.
385 - pub fn draw_filters(ctx: &egui::Context, state: &mut BrowserState) {
380 + /// Refreshed unconditionally, and this one earns it more plainly than most:
381 + /// every control here writes through an intent, so a screen that is not re-asked
382 + /// shows the filter state from before the last press.
383 + pub fn draw_filters(ui: &mut egui::Ui, state: &mut BrowserState) {
386 384 let intents = RefCell::new(Vec::new());
387 385 let mut runtime = state.described.filters.take();
388 386 let host = Host {
@@ -391,19 +389,9 @@
391 389 themes: themes(),
392 390 intents: &intents,
393 391 };
394 - let closed = window(
395 - ctx,
396 - "Filters (described)",
397 - &mut runtime,
398 - &host,
399 - "/filters",
400 - true,
401 - );
392 + inline(ui, &mut runtime, &host, "/filters", true);
402 393 state.described.filters = runtime;
403 - apply(ctx, state, None, intents.into_inner());
404 - if closed {
405 - state.described.filters = None;
406 - }
394 + apply(ui.ctx(), state, None, intents.into_inner());
407 395 }
408 396
409 397 /// Draw the described sweep, and act on whatever was pressed.
@@ -331,8 +331,18 @@
331 331 }
332 332 }
333 333 }
334 - // Prose, figures, images, tokens, meters, timelines and stand-ins are
335 - // things a screen says rather than things it offers. See the header.
334 + // A token that calls a route is a control drawn as a chip -- the filter
335 + // panel's twenty-four key pills are the site -- and one that calls
336 + // nothing is a badge. `Tag::action` is the whole of the difference, so
337 + // it is what decides here rather than the kind.
338 + Node::Token(tag) => {
339 + if let Some(action) = &tag.action {
340 + out.push(Role::Button, tag.label.clone(), false);
341 + out.addresses.push(action.destination.as_str().to_owned());
342 + }
343 + }
344 + // Prose, figures, images, meters, timelines and stand-ins are things a
345 + // screen says rather than things it offers. See the header.
336 346 _ => {}
337 347 }
338 348 }
@@ -612,6 +622,24 @@
612 622 self
613 623 }
614 624
625 + /// A chip the description makes pressable and the renderer draws mute.
626 + ///
627 + /// `makeover_immediate::widget::token` allocated its rect and painted its
628 + /// own text, registering no `WidgetInfo`, so a chip reached the tree as
629 + /// nothing: pressable by a mouse and invisible to everything else. Same
630 + /// class as the row and the field before it.
631 + ///
632 + /// Fixed in makeover-immediate 0.34.0 and not published, so it is an
633 + /// allowance here. `drums` runs the other way: the shipped tag chip is an
634 + /// ordinary button and the described one is a token.
635 + #[must_use]
636 + pub(super) fn mute_chips(mut self, labels: &[&str]) -> Self {
637 + for label in labels {
638 + self = self.gaining(label);
639 + }
640 + self
641 + }
642 +
615 643 /// Assert the two sides offer the same thing, panicking with a diff if not.
616 644 pub(super) fn assert(&self, described: &Offering, shipped: &Offering) {
617 645 let mut want: BTreeMap<Offer, isize> = BTreeMap::new();
@@ -1043,3 +1071,67 @@
1043 1071 )
1044 1072 .assert(&described, &drawn);
1045 1073 }
1074 +
1075 + /// Every filter narrowed, which is how the shipped pane opens its sections.
1076 + ///
1077 + /// `widgets::filter_section` is `default_open(active)`, so a pane read with
1078 + /// nothing filtered offers eight headings and no controls. Setting a bound on
1079 + /// each axis is the panel's own rule for showing them rather than a way round
1080 + /// it, and it is also the state worth comparing: an empty filter panel is the
1081 + /// one arrangement where neither side has much to say.
1082 + fn with_every_filter_narrowed(state: &mut crate::state::BrowserState) {
1083 + let f = &mut state.search.search_filter;
1084 + f.bpm_min = Some(90.0);
1085 + f.duration_min = Some(1.0);
1086 + f.peak_db_min = Some(-12.0);
1087 + f.centroid_min = Some(500.0);
1088 + f.flatness_min = Some(0.2);
1089 + f.attack_min = Some(5.0);
1090 + f.keys.push("Am".to_owned());
1091 + f.required_tags.push("drums".to_owned());
1092 + state.search.filter_panel_open = true;
1093 + }
1094 +
1095 + #[test]
1096 + fn the_filter_panel_serves_what_it_describes() {
1097 + let (mut state, _dir) = fixture();
1098 + with_every_filter_narrowed(&mut state);
1099 +
1100 + let described = described(&super::panel::described_screen(&state, "/filters"));
1101 + let drawn = shipped(|ui| {
1102 + super::panel::draw_filters(ui, &mut state);
1103 + });
1104 +
1105 + described.addresses_resolve();
1106 + // A left pane rather than a window, which is what the shipped panel was, so
1107 + // there is no frame to discount.
1108 + Parity::strict()
1109 + // Twenty-four key chips, two spellings of each of twelve notes. See
1110 + // `Parity::mute_chips`; they come out when makeover-immediate 0.34.0 is
1111 + // published, and the tag chip goes with them.
1112 + .mute_chips(&[
1113 + "C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B", "C", "C#", "D", "D#",
1114 + "E", "F", "F#", "G", "G#", "A", "A#", "B",
1115 + ])
1116 + .dropping("drums")
1117 + // The six intervals, unnamed and therefore announced as their ends.
1118 + .unnamed_fields(
1119 + &[
1120 + "BPM Range",
1121 + "Duration",
1122 + "Loudness",
1123 + "Brightness",
1124 + "Tonal / Noisy",
1125 + "Attack",
1126 + ],
1127 + &[
1128 + "90", "300", "1.0 s", "600.0 s", "-12 dB", "0 dB", "500 Hz", "20000 Hz", "0.20",
1129 + "1.00", "1.000 s", "1.000 s",
1130 + ],
1131 + )
1132 + // Two text boxes the renderer leaves unnamed and empty, so they are
1133 + // announced as nothing at all rather than as their contents.
1134 + .unnamed_field("Collection name")
1135 + .unnamed_field("Require a tag")
1136 + .assert(&described, &drawn);
1137 + }
@@ -8035,14 +8035,14 @@
8035 8035 /// The shipped geometry table read rather than a second one written, which is
8036 8036 /// what `FromFilters::table` does and is the point of the axes being `pub`.
8037 8037 fn open_axes() -> Vec<Narrowing> {
8038 - use crate::ui::filter_panel as shipped;
8038 + use crate::quasi::filters as axes;
8039 8039 [
8040 - ("bpm", &shipped::BPM),
8041 - ("duration", &shipped::DURATION),
8042 - ("loudness", &shipped::LOUDNESS),
8043 - ("brightness", &shipped::BRIGHTNESS),
8044 - ("noisiness", &shipped::NOISINESS),
8045 - ("attack", &shipped::ATTACK),
8040 + ("bpm", &axes::BPM),
8041 + ("duration", &axes::DURATION),
8042 + ("loudness", &axes::LOUDNESS),
8043 + ("brightness", &axes::BRIGHTNESS),
8044 + ("noisiness", &axes::NOISINESS),
8045 + ("attack", &axes::ATTACK),
8046 8046 ]
8047 8047 .into_iter()
8048 8048 .map(|(key, axis)| Narrowing {
@@ -8646,7 +8646,7 @@
8646 8646
8647 8647 assert_eq!(bpm.min.as_deref(), Some("0"));
8648 8648 assert_eq!(bpm.max.as_deref(), Some("300"));
8649 - assert_eq!(bpm.label, crate::ui::filter_panel::BPM.title);
8649 + assert_eq!(bpm.label, crate::quasi::filters::BPM.title);
8650 8650 // Two of the six have no unit, and an empty suffix is not one.
8651 8651 assert_eq!(bpm.unit, None);
8652 8652
@@ -7,7 +7,6 @@
7 7 pub mod export_screens;
8 8 pub mod file_list;
9 9 pub mod file_list_menus;
10 - pub mod filter_panel;
11 10 pub mod footer;
12 11 pub mod import_screens;
13 12 pub mod instrument_panel;
@@ -1,454 +1,0 @@
1 - //! Filter panel: numeric range axes (BPM, duration, loudness, and the measured
2 - //! spectral axes), key selector, tag filters.
3 -
4 - use egui;
5 -
6 - use super::theme;
7 - use super::widgets;
8 - use crate::state::BrowserState;
9 -
10 - /// Debounce numeric range filters: re-run the search only when a DragValue
11 - /// drag finishes or the field loses keyboard focus, not on every intermediate
12 - /// tick. The filter value still updates live on `.changed()` (so the widget
13 - /// shows the new number); only the (potentially heavy) query is deferred to
14 - /// the settle point.
15 - fn requery_now(resp: &egui::Response) -> bool {
16 - resp.drag_stopped() || resp.lost_focus()
17 - }
18 -
19 - /// Map a numeric range filter's raw `(min, max)` onto the stored `(Option, Option)`
20 - /// bounds. A value sitting on its sentinel edge means "no bound": `min == lo` (or
21 - /// below) stores `None`, as does `max == hi` (or above). The caller snaps the
22 - /// sibling for `min <= max` before calling, so this only handles the edge mapping.
23 - fn range_bounds(min: f64, max: f64, lo: f64, hi: f64) -> (Option<f64>, Option<f64>) {
24 - let lower = if min > lo { Some(min) } else { None };
25 - let upper = if max < hi { Some(max) } else { None };
26 - (lower, upper)
27 - }
28 -
29 - /// A numeric range filter's fixed geometry: the sentinel edges, the drag speed,
30 - /// and the unit shown in the field.
31 - #[derive(Debug, PartialEq)]
32 - pub struct RangeAxis {
33 - /// Section title. Names the axis the way a producer would say it; the unit in
34 - /// `suffix` keeps the number honest.
35 - pub title: &'static str,
36 - /// Hover text, for axes whose title is perceptual rather than physical.
37 - pub hint: Option<&'static str>,
38 - /// The low sentinel edge: a minimum sitting here means no lower bound.
39 - pub lo: f64,
40 - /// The high sentinel edge: a maximum sitting here means no upper bound.
41 - pub hi: f64,
42 - /// How fast a drag moves the value.
43 - pub speed: f64,
44 - /// What the number is measured in, with its leading space.
45 - pub suffix: &'static str,
46 - /// Decimal places in the field. Hz wants 0, a 0..1 ratio wants 2.
47 - pub decimals: usize,
48 - }
49 -
50 - // Every range axis below is one RangeAxis + one call. The sentinel edges are
51 - // the "no filter" values: a min sitting on `lo` or a max on `hi` stores None,
52 - // so the SQL omits that bound. The per-section [clear] link closes the
53 - // sentinel-visibility gap (M-1) and the sibling snap makes contradictory
54 - // min > max states unrepresentable (M-2).
55 - //
56 - // 300 BPM matches the ceiling in bpm.rs's plausible-BPM filter.
57 - pub const BPM: RangeAxis = RangeAxis {
58 - title: "BPM Range",
59 - hint: None,
60 - lo: 0.0,
61 - hi: 300.0,
62 - speed: 1.0,
63 - suffix: "",
64 - decimals: 0,
65 - };
66 - pub const DURATION: RangeAxis = RangeAxis {
67 - title: "Duration",
68 - hint: None,
69 - lo: 0.0,
70 - hi: 600.0,
71 - speed: 0.1,
72 - suffix: " s",
73 - decimals: 1,
74 - };
75 - pub const LOUDNESS: RangeAxis = RangeAxis {
76 - title: "Loudness",
77 - hint: None,
78 - lo: -96.0,
79 - hi: 0.0,
80 - speed: 0.5,
81 - suffix: " dB",
82 - decimals: 0,
83 - };
84 -
85 - // The measured axes. These are what replaced the sample-class filter: a class
86 - // was a lossy guess at a question nobody asked, where a region of the feature
87 - // space is the thing a producer actually reaches for (wiki af-browse-axes).
88 - //
89 - // Titled perceptually and valued in the real unit. "Brightness" is how anyone
90 - // describes the axis; 2500 Hz is what the query compares. Neither alone is
91 - // enough: Hz in the title reads as an instrument spec, and a bare 0-100
92 - // "brightness" score would be a made-up number.
93 - //
94 - // Deliberately NOT binned. Fixed breakpoints do not transfer across material:
95 - // kick/tom one-shots sit 48% low / 43% lowmid / 0% high, reverb loops sit 71%
96 - // high, FSL10K loops spread 16/22/40/22. A "bright" one-shot and a "bright"
97 - // loop are nowhere near each other, so any binning has to be relative to the
98 - // library. Exposing the value and letting the range be chosen sidesteps that.
99 - //
100 - // Ceilings are the measurable range, not a guess: centroid tops out near
101 - // Nyquist for 44.1k material, flatness is 0..1 by construction, and attack is
102 - // capped at 1 s because a slower onset than that is a pad, not a transient.
103 - pub const BRIGHTNESS: RangeAxis = RangeAxis {
104 - title: "Brightness",
105 - hint: Some("Spectral centroid: where the energy sits. Low is dark, high is bright."),
106 - lo: 0.0,
107 - hi: 20_000.0,
108 - speed: 50.0,
109 - suffix: " Hz",
110 - decimals: 0,
111 - };
112 - pub const NOISINESS: RangeAxis = RangeAxis {
113 - title: "Tonal / Noisy",
114 - hint: Some("Spectral flatness: 0 is a pure tone, 1 is white noise."),
115 - lo: 0.0,
116 - hi: 1.0,
117 - speed: 0.01,
118 - suffix: "",
119 - decimals: 2,
120 - };
121 - pub const ATTACK: RangeAxis = RangeAxis {
122 - title: "Attack",
123 - hint: Some("Time to reach full level. Short is a transient, long is a swell."),
124 - lo: 0.0,
125 - hi: 1.0,
126 - speed: 0.005,
127 - suffix: " s",
128 - decimals: 3,
129 - };
130 -
131 - /// Draw one numeric range filter: a collapsing section, min/max drag fields with
132 - /// sentinel edges, a sibling snap so `min <= max` always holds, and a per-section
133 - /// clear. Returns true when the search needs re-running.
134 - ///
135 - /// Every range axis in the panel goes through here. They were six copies of the
136 - /// same 45 lines differing only in constants, which is how the class filter's
137 - /// list and colour table drifted apart, so the axis geometry is data now.
138 - fn range_filter_section(
139 - ui: &mut egui::Ui,
140 - axis: &RangeAxis,
141 - min_field: &mut Option<f64>,
142 - max_field: &mut Option<f64>,
143 - ) -> bool {
144 - let active = min_field.is_some() || max_field.is_some();
145 - let mut changed = false;
146 -
147 - widgets::filter_section(ui, axis.title, active, |ui| {
148 - if let Some(hint) = axis.hint {
149 - ui.label(
150 - egui::RichText::new(hint)
151 - .small()
152 - .color(theme::content_muted()),
153 - );
154 - }
155 - if active && draw_section_clear(ui) {
156 - *min_field = None;
157 - *max_field = None;
158 - changed = true;
159 - }
160 - ui.horizontal(|ui| {
161 - let mut min = min_field.unwrap_or(axis.lo);
162 - let mut max = max_field.unwrap_or(axis.hi);
163 - // A closure returning the DragValue would have to name the borrow of
164 - // `value` in its return type, which a closure cannot do, so the builder
165 - // is spelled out at both call sites.
166 - fn field<'a>(value: &'a mut f64, axis: &RangeAxis) -> egui::DragValue<'a> {
167 - egui::DragValue::new(value)
168 - .speed(axis.speed)
169 - .range(axis.lo..=axis.hi)
170 - .max_decimals(axis.decimals)
171 - .suffix(axis.suffix)
172 - }
173 -
174 - ui.label("Min");
175 - let r = ui.add(field(&mut min, axis));
176 - if r.changed() {
177 - if min > max {
178 - max = min;
179 - }
180 - (*min_field, *max_field) = range_bounds(min, max, axis.lo, axis.hi);
181 - }
182 - changed |= requery_now(&r);
183 -
184 - ui.label("Max");
185 - let r = ui.add(field(&mut max, axis));
186 - if r.changed() {
187 - if max < min {
188 - min = max;
189 - }
190 - (*min_field, *max_field) = range_bounds(min, max, axis.lo, axis.hi);
191 - }
192 - changed |= requery_now(&r);
193 - });
194 - });
195 -
196 - changed
197 - }
198 -
199 - /// Render the per-section "[clear]" mini-button used by every active filter
200 - /// section. Returns true on click. The wrapping `if active` lives at the call
201 - /// site so each section's clear semantics stay local.
202 - fn draw_section_clear(ui: &mut egui::Ui) -> bool {
203 - let mut clicked = false;
204 - ui.horizontal(|ui| {
205 - if ui
206 - .small_button(egui::RichText::new("clear").color(theme::content_muted()))
207 - .on_hover_text("Clear this filter category")
208 - .clicked()
209 - {
210 - clicked = true;
211 - }
212 - });
213 - clicked
214 - }
215 -
216 - /// Draw the filter panel content.
217 - pub fn draw_filter_panel(ui: &mut egui::Ui, state: &mut BrowserState) {
218 - widgets::section_header(ui, "Filters");
219 -
220 - let mut changed = false;
221 -
222 - let f = &mut state.search.search_filter;
223 - changed |= range_filter_section(ui, &BPM, &mut f.bpm_min, &mut f.bpm_max);
224 - changed |= range_filter_section(ui, &DURATION, &mut f.duration_min, &mut f.duration_max);
225 - changed |= range_filter_section(ui, &LOUDNESS, &mut f.peak_db_min, &mut f.peak_db_max);
226 - changed |= range_filter_section(ui, &BRIGHTNESS, &mut f.centroid_min, &mut f.centroid_max);
227 - changed |= range_filter_section(ui, &NOISINESS, &mut f.flatness_min, &mut f.flatness_max);
228 - changed |= range_filter_section(ui, &ATTACK, &mut f.attack_min, &mut f.attack_max);
229 -
230 - let key_active = !state.search.search_filter.keys.is_empty();
231 - widgets::filter_section(ui, "Key Filter", key_active, |ui| {
232 - if key_active && draw_section_clear(ui) {
233 - state.search.search_filter.keys.clear();
234 - changed = true;
235 - }
236 - use audiofiles_core::search::KeyFilterMode;
237 - if let Some(mode) = widgets::segmented_control(
238 - ui,
239 - &state.search.search_filter.key_mode,
240 - &[
241 - (KeyFilterMode::Exact, "Exact", "Match only selected keys"),
242 - (
243 - KeyFilterMode::Compatible,
244 - "Compatible",
245 - "Include musically compatible keys (circle of fifths)",
246 - ),
247 - ],
248 - ) {
249 - state.search.search_filter.key_mode = mode;
250 - changed = true;
251 - }
252 - ui.add_space(theme::space::bound());
253 -
254 - // Grouped Major | Minor, each wrapped, showing the compact note pill
255 - // ("C", "C#", and so on) while storing the full key, 24 stacked rows hid every
256 - // section below it and made hunting for one key a linear scan (p-1,
257 - // matches the Classification section's grouping).
258 - let key_groups: &[(&str, &[&str])] = &[
259 - (
260 - "Major",
261 - &[
262 - "C major", "C# major", "D major", "D# major", "E major", "F major", "F# major",
263 - "G major", "G# major", "A major", "A# major", "B major",
264 - ],
265 - ),
266 - (
267 - "Minor",
268 - &[
269 - "C minor", "C# minor", "D minor", "D# minor", "E minor", "F minor", "F# minor",
270 - "G minor", "G# minor", "A minor", "A# minor", "B minor",
271 - ],
272 - ),
273 - ];
274 - for (label, keys) in key_groups {
275 - ui.label(
276 - egui::RichText::new(*label)
277 - .small()
278 - .color(theme::content_muted()),
279 - );
280 - ui.horizontal_wrapped(|ui| {
281 - for key in *keys {
282 - let note = key.split(' ').next().unwrap_or(key);
283 - let active = state.search.search_filter.keys.contains(&key.to_string());
284 - if ui.selectable_label(active, note).clicked() {
285 - if active {
286 - state.search.search_filter.keys.retain(|k| k != *key);
287 - } else {
288 - state.search.search_filter.keys.push(key.to_string());
289 - }
290 - changed = true;
291 - }
292 - }
293 - });
294 - }
295 - });
296 -
297 - // Tag filter section: add input + active-tag list. Promoted from a bare
298 - // ui.label to filter_section so visual contract matches its siblings (m-3),
299 - // and gains an entry path so users can add tag filters without round-trip
300 - // through the detail panel or right-click (M-5).
301 - let tag_active = !state.search.search_filter.required_tags.is_empty();
302 - widgets::filter_section(ui, "Tags", tag_active, |ui| {
303 - if tag_active && draw_section_clear(ui) {
304 - state.search.search_filter.required_tags.clear();
305 - changed = true;
306 - }
307 - ui.horizontal(|ui| {
308 - let resp = widgets::text_field(
309 - ui,
310 - egui::TextEdit::singleline(&mut state.search.filter_tag_input)
311 - .hint_text("Filter by tag")
312 - .desired_width(ui.available_width() - 32.0),
313 - );
314 - let commit = (resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)))
315 - || ui
316 - .small_button("+")
317 - .on_hover_text("Add tag filter")
318 - .clicked();
319 - if commit {
320 - let tag = state.search.filter_tag_input.trim().to_string();
321 - if !tag.is_empty() {
322 - if audiofiles_core::tags::validate_tag(&tag).is_ok() {
323 - if !state.search.search_filter.required_tags.contains(&tag) {
324 - state.search.search_filter.required_tags.push(tag);
325 - changed = true;
326 - }
327 - state.search.filter_tag_input.clear();
328 - } else {
329 - state.status = format!("Invalid tag: {tag}");
330 - }
331 - }
332 - }
333 - });
334 - let tags = state.search.search_filter.required_tags.clone();
335 - for tag in &tags {
336 - ui.horizontal(|ui| {
337 - ui.label(egui::RichText::new(tag).color(theme::action()));
338 - if ui
339 - .small_button("x")
340 - .on_hover_text("Remove tag filter")
341 - .clicked()
342 - {
343 - state
344 - .search
345 - .search_filter
346 - .required_tags
347 - .retain(|t| t != tag);
348 - changed = true;
349 - }
350 - });
351 - }
352 - });
353 -
354 - ui.add_space(theme::space::group());
355 -
356 - if ui
357 - .button("Clear search and filters")
358 - .on_hover_text("Reset every filter category and the search query")
359 - .clicked()
360 - {
361 - state.search.search_filter.clear();
362 - state.search.search_query.clear();
363 - changed = true;
364 - }
365 -
366 - // Result-count line so the user gets feedback on filter toggles even when
367 - // the filter panel is covering the table (p-2). Files only, directories
368 - // are structural and not what the filter targets.
369 - let file_count = state
370 - .nav
371 - .contents
372 - .iter()
373 - .filter(|c| c.node.node_type != audiofiles_core::vfs::NodeType::Directory)
374 - .count();
375 - ui.add_space(theme::space::hair());
376 - ui.label(
377 - egui::RichText::new(format!(
378 - "{file_count} sample{} match",
379 - if file_count == 1 { "" } else { "s" }
380 - ))
381 - .small()
382 - .color(theme::content_muted()),
383 - );
384 -
385 - if state.search.search_filter.is_active() {
386 - ui.add_space(theme::space::peer());
387 - ui.separator();
388 - widgets::subsection_label(ui, "Save as Collection");
389 - ui.label(
390 - egui::RichText::new("Save current filters as a dynamic collection")
391 - .small()
392 - .color(theme::content_muted()),
393 - );
394 - ui.add_space(theme::space::bound());
395 - ui.horizontal(|ui| {
396 - let auto_name = state.search.search_filter.describe();
397 - let edit =
398 - egui::TextEdit::singleline(&mut state.collections_ui.collection_filter_name_input)
399 - .hint_text(&auto_name)
400 - .desired_width(ui.available_width() - 50.0);
401 - let resp = widgets::text_field(ui, edit);
402 - let trimmed = state.collections_ui.collection_filter_name_input.trim();
403 - let name = if trimmed.is_empty() {
404 - auto_name
405 - } else {
406 - trimmed.to_string()
407 - };
408 - let enter_commit = resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
409 - if enter_commit || ui.button("Save").clicked() {
410 - state.save_dynamic_collection(&name);
411 - state.collections_ui.collection_filter_name_input.clear();
412 - }
413 - });
414 - }
415 -
416 - if changed {
417 - state.apply_search();
418 - }
419 - }
420 -
421 - #[cfg(test)]
422 - mod tests {
423 - use super::*;
424 -
425 - #[test]
426 - fn range_bounds_treats_sentinel_edges_as_unbounded() {
427 - // BPM: 0 = no lower bound, 300 = no upper bound.
428 - assert_eq!(range_bounds(0.0, 300.0, 0.0, 300.0), (None, None));
429 - }
430 -
431 - #[test]
432 - fn range_bounds_keeps_interior_values() {
433 - assert_eq!(
434 - range_bounds(90.0, 140.0, 0.0, 300.0),
435 - (Some(90.0), Some(140.0))
436 - );
437 - }
438 -
439 - #[test]
440 - fn range_bounds_maps_each_end_independently() {
441 - assert_eq!(range_bounds(90.0, 300.0, 0.0, 300.0), (Some(90.0), None));
442 - assert_eq!(range_bounds(0.0, 140.0, 0.0, 300.0), (None, Some(140.0)));
443 - }
444 -
445 - #[test]
446 - fn range_bounds_handles_negative_sentinels() {
447 - // Loudness: -96 dB = no floor, 0 dB = no ceiling.
448 - assert_eq!(range_bounds(-96.0, 0.0, -96.0, 0.0), (None, None));
449 - assert_eq!(
450 - range_bounds(-40.0, -6.0, -96.0, 0.0),
451 - (Some(-40.0), Some(-6.0))
452 - );
453 - }
454 - }