max / audiofiles
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
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 | - | } |