Skip to main content

max / audiofiles

Describe the filter panel, the last of the shipped screens `quasi/filters.rs`, the sixteenth port and the first consumer of `FieldKind::Interval` (makeover-layout 0.34.0, ruled by Max 2026-08-21 with this screen named as the consumer). Six axes, the keys, the tags, and what matched. The port waited on the vocabulary and nothing else. Described as `Number` pairs the six axes are twelve fields with no relationship: the crossing rule is app-side per pair, and an error can only be attached to one side of a fault that belongs to both. `range_filter_section` is the 55 lines that stood in for the missing member, and every axis went through it. What the description takes over: the label above the pair rather than the `ui.label("Min")` beside each box; one extent read once and given to both ends, so a typo cannot bound them differently; and the DragValue suffix, which is what `Field::unit` has been the word for since 0.33.0. The geometry table is the shipped one, read rather than copied. `RangeAxis` and its six consts are hoisted to module scope and public, unchanged -- the same move `files::body` made -- because a second table here would drift the way the class filter's list and colour table drifted before the first one existed. The sentinel edge is the one thing the two spellings disagree about, and the route is where they meet, in both directions: a stored `None` comes back as an empty box, and an end sitting on the edge stores no bound at all. What stayed behind, with reasons in the header: the sibling snap is a write and belongs in the route, because a description that could snap could rewrite an answer without being asked; the debounce is already `Field::changes`; and the per-axis disclosure is unsayable, since `Field::extended` is a property of the form rather than of the field. Filed as a second consumer. 612 tests with the feature on, 384 with it off. The shipped panel is untouched apart from the hoist.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-22 02:06 UTC
Signed with PGP, not checked
Commit: b6e90774e250ec155a5e15a2d7f341af556bb4e6
Parent: 2f562f8
7 files changed, +1333 insertions, -106 deletions
M Cargo.toml +4 -4
@@ -17,12 +17,12 @@
17 17 # to compile against an API added in a later one. The two move together --
18 18 # makeover-immediate re-exports nothing, so the `Column` the app describes and
19 19 # the `Column` the renderer matches on have to be the same type.
20 - makeover-layout = "0.33.0"
21 - makeover-immediate = "0.32.0"
20 + makeover-layout = "0.34.0"
21 + makeover-immediate = "0.33.0"
22 22 # The described screens, behind audiofiles-browser's `quasi` feature. By git URL
23 23 # with a version requirement, per the tree's rule for cross-repo deps.
24 - quasi-router = { git = "https://makenot.work/git/max/quasi.git", version = "0.48" }
25 - quasi-immediate = { git = "https://makenot.work/git/max/quasi.git", version = "0.48" }
24 + quasi-router = { git = "https://makenot.work/git/max/quasi.git", version = "0.49" }
25 + quasi-immediate = { git = "https://makenot.work/git/max/quasi.git", version = "0.49" }
26 26 egui = { version = "0.35", default-features = false, features = ["default_fonts"] }
27 27 egui_extras = { version = "0.35", default-features = false }
28 28 eframe = { version = "0.35", default-features = false, features = ["default_fonts", "glow"] }
@@ -336,6 +336,14 @@
336 336 instrument_panel::draw_midi_window(&ctx, state);
337 337 }
338 338
339 + // The described filter panel, beside the shipped one and on its toggle. The
340 + // shipped side is a left pane rather than a window, so there is nothing to
341 + // share -- same terms as the file list and the detail panel.
342 + #[cfg(feature = "quasi")]
343 + if state.search.filter_panel_open {
344 + crate::quasi::panel::draw_filters(&ctx, state);
345 + }
346 +
339 347 // Left sidebar (or filter panel)
340 348 if state.search.filter_panel_open {
341 349 egui::Panel::left("filter_panel")
@@ -25,6 +25,7 @@
25 25 //! | [`Shell`] | [`shell`] | an [`Intent`], applied after the frame |
26 26 //! | [`Library`] | [`library`] | an [`Intent`], applied after the frame |
27 27 //! | [`Bar`] | [`toolbar`] | an [`Intent`], applied after the frame |
28 + //! | [`Filters`] | [`filters`] | an [`Intent`], applied after the frame |
28 29 //! | [`ThemeChoice`] | [`settings`] | nothing: resolved once by the host |
29 30 //!
30 31 //! The themes are the settled rule from goingson's settings port applied first
@@ -50,6 +51,21 @@
50 51 //! writes through an intent" but **what the app does about a write decides
51 52 //! where the write goes**. [`Detail`]'s header has the long form.
52 53 //!
54 + //! # The filter panel waited on the vocabulary, and that is why it is last
55 + //!
56 + //! [`filters`] is the sixteenth port and the only one held up by something the
57 + //! description could not say. Its centre is six min/max pairs, and until
58 + //! makeover-layout 0.34.0 nothing said two values were one question with two
59 + //! ends: described as `Number` pairs they are twelve fields with no
60 + //! relationship, the crossing rule is app-side per pair, and an error can only
61 + //! be attached to one side of a fault that belongs to both.
62 + //!
63 + //! `FieldKind::Interval` was ruled on 2026-08-21 with this screen named as its
64 + //! first consumer, and the port is what `range_filter_section`'s 55 lines were
65 + //! standing in for. See [`filters`]'s header for what the member deleted and
66 + //! what stayed behind: the sibling snap is a write and lives in the route, and
67 + //! the per-axis disclosure is still unsayable.
68 + //!
53 69 //! # What is not a capability, because it is not a screen
54 70 //!
55 71 //! `ui::overlays::draw_confirm_dialog` is a ten-variant `ConfirmAction` enum, a
@@ -101,6 +117,7 @@
101 117 pub mod edit;
102 118 pub mod export;
103 119 pub mod files;
120 + pub mod filters;
104 121 pub mod forge;
105 122 pub mod help;
106 123 pub mod importing;
@@ -661,6 +678,24 @@
661 678 AcceptSuggestion(String),
662 679 /// Put the sample's path on the clipboard.
663 680 CopyPath,
681 + /// Narrow one numeric axis, by its key and its two ends.
682 + Narrow(&'static str, Option<f64>, Option<f64>),
683 + /// Match only these keys, or every key compatible with them.
684 + KeyMode(bool),
685 + /// Want this key, or stop wanting it.
686 + ToggleKey(String),
687 + /// Stop wanting any key.
688 + ClearKeys,
689 + /// Remember what is being typed into the tag box.
690 + TypingTag(String),
691 + /// Require this tag of every result.
692 + RequireTag(String),
693 + /// Stop requiring it.
694 + UnrequireTag(String),
695 + /// Stop requiring any tag.
696 + ClearTags,
697 + /// Drop every filter and the query with them.
698 + ClearFilters,
664 699 /// Open the sample editor.
665 700 Edit,
666 701 /// Open the forge.
@@ -4165,6 +4200,247 @@
4165 4200 }
4166 4201 }
4167 4202
4203 + /// One numeric axis of the filter panel, as a described screen needs it.
4204 + ///
4205 + /// The geometry is [`crate::ui::filter_panel::RangeAxis`], which is the shipped
4206 + /// panel's own table read rather than copied: the six axes are a constant, and a
4207 + /// second table here would drift the way the class filter's list and colour
4208 + /// table drifted before the first one existed.
4209 + ///
4210 + /// The two ends are `Option` because an absent end is an answer. A minimum
4211 + /// sitting on the sentinel edge stores `None` and the SQL omits that bound,
4212 + /// which is what "no lower bound" is, and the description says the same thing by
4213 + /// leaving the box empty.
4214 + #[derive(Debug, Clone, PartialEq)]
4215 + pub struct Narrowing {
4216 + /// The key the axis is addressed by, and the stem both its names are built
4217 + /// from.
4218 + pub key: &'static str,
4219 + /// The fixed geometry: the sentinel edges, the granularity, the unit.
4220 + pub axis: &'static crate::ui::filter_panel::RangeAxis,
4221 + /// The lower end wanted, if one is.
4222 + pub lower: Option<f64>,
4223 + /// The upper end wanted, if one is.
4224 + pub upper: Option<f64>,
4225 + }
4226 +
4227 + /// How a key filter matches.
4228 + #[derive(Debug, Clone, PartialEq, Eq)]
4229 + pub struct Keys {
4230 + /// The keys wanted, spelled the way the library spells them.
4231 + pub wanted: Vec<String>,
4232 + /// Whether musically compatible keys count as well.
4233 + pub compatible: bool,
4234 + }
4235 +
4236 + /// The filter panel, as much of it as a described screen needs.
4237 + ///
4238 + /// The sixteenth narrow trait, and the last of audiofiles' real screens. It
4239 + /// reads what is being filtered for and writes through [`Intent`]s, which is
4240 + /// [`files`]'s arrangement and for its reason: every write here lands in
4241 + /// `state.search.search_filter`, which is the app's own UI state and not
4242 + /// something a route holding `&S` can reach.
4243 + pub trait Filters {
4244 + /// The six numeric axes, in the order the panel offers them.
4245 + fn axes(&self) -> Vec<Narrowing>;
4246 +
4247 + /// The keys wanted, and how they are matched.
4248 + fn keys(&self) -> Keys;
4249 +
4250 + /// The tags every result has to carry.
4251 + fn tags(&self) -> Vec<String>;
4252 +
4253 + /// What is being typed into the tag box.
4254 + fn typing(&self) -> String;
4255 +
4256 + /// How many samples match now.
4257 + fn matched(&self) -> usize;
4258 +
4259 + /// Whether anything is filtering at all.
4260 + fn active(&self) -> bool;
4261 +
4262 + /// What the current filters would be called, if they were saved unnamed.
4263 + fn describes(&self) -> String;
4264 +
4265 + /// Narrow one axis to these two ends.
4266 + fn narrow(&self, key: &'static str, lower: Option<f64>, upper: Option<f64>);
4267 +
4268 + /// Match only the chosen keys, or every key compatible with them.
4269 + fn set_key_mode(&self, compatible: bool);
4270 +
4271 + /// Want this key, or stop wanting it.
4272 + fn toggle_key(&self, key: &str);
4273 +
4274 + /// Stop wanting any key.
4275 + fn clear_keys(&self);
4276 +
4277 + /// Remember what is being typed.
4278 + fn typed(&self, text: &str);
4279 +
4280 + /// Require this tag of every result.
4281 + fn require(&self, tag: &str);
4282 +
4283 + /// Stop requiring it.
4284 + fn unrequire(&self, tag: &str);
4285 +
4286 + /// Stop requiring any tag.
4287 + fn clear_tags(&self);
4288 +
4289 + /// Drop every filter, and the query with it.
4290 + fn clear_all(&self);
4291 +
4292 + /// Keep the active filters under this name.
4293 + fn save_collection(&self, name: &str);
4294 + }
4295 +
4296 + /// The app's filters, as the narrow thing a described screen borrows.
4297 + pub struct FromFilters<'a> {
4298 + /// What is being filtered for, and what matched.
4299 + pub state: &'a crate::state::BrowserState,
4300 + /// What the described screen asked for, applied after the frame.
4301 + pub intents: &'a std::cell::RefCell<Vec<Intent>>,
4302 + }
4303 +
4304 + impl FromFilters<'_> {
4305 + /// The six axes and where each one's two ends are kept.
4306 + ///
4307 + /// One table, read twice: here for the values and in
4308 + /// [`filters`](self::filters) for the description. Pairing the key with the
4309 + /// geometry in one place is what stops the description and the write
4310 + /// disagreeing about which axis `bpm` is.
4311 + fn table(&self) -> [Narrowing; 6] {
4312 + use crate::ui::filter_panel as shipped;
4313 + let f = &self.state.search.search_filter;
4314 + [
4315 + Narrowing {
4316 + key: "bpm",
4317 + axis: &shipped::BPM,
4318 + lower: f.bpm_min,
4319 + upper: f.bpm_max,
4320 + },
4321 + Narrowing {
4322 + key: "duration",
4323 + axis: &shipped::DURATION,
4324 + lower: f.duration_min,
4325 + upper: f.duration_max,
4326 + },
4327 + Narrowing {
4328 + key: "loudness",
4329 + axis: &shipped::LOUDNESS,
4330 + lower: f.peak_db_min,
4331 + upper: f.peak_db_max,
4332 + },
4333 + Narrowing {
4334 + key: "brightness",
4335 + axis: &shipped::BRIGHTNESS,
4336 + lower: f.centroid_min,
4337 + upper: f.centroid_max,
4338 + },
4339 + Narrowing {
4340 + key: "noisiness",
4341 + axis: &shipped::NOISINESS,
4342 + lower: f.flatness_min,
4343 + upper: f.flatness_max,
4344 + },
4345 + Narrowing {
4346 + key: "attack",
4347 + axis: &shipped::ATTACK,
4348 + lower: f.attack_min,
4349 + upper: f.attack_max,
4350 + },
4351 + ]
4352 + }
4353 +
4354 + /// Record what the described screen asked for.
4355 + fn push(&self, intent: Intent) {
4356 + self.intents.borrow_mut().push(intent);
4357 + }
4358 + }
4359 +
4360 + impl Filters for FromFilters<'_> {
4361 + fn axes(&self) -> Vec<Narrowing> {
4362 + self.table().to_vec()
4363 + }
4364 +
4365 + fn keys(&self) -> Keys {
4366 + use audiofiles_core::search::KeyFilterMode;
4367 + Keys {
4368 + wanted: self.state.search.search_filter.keys.clone(),
4369 + compatible: matches!(
4370 + self.state.search.search_filter.key_mode,
4371 + KeyFilterMode::Compatible
4372 + ),
4373 + }
4374 + }
4375 +
4376 + fn tags(&self) -> Vec<String> {
4377 + self.state.search.search_filter.required_tags.clone()
4378 + }
4379 +
4380 + fn typing(&self) -> String {
4381 + self.state.search.filter_tag_input.clone()
4382 + }
4383 +
4384 + fn matched(&self) -> usize {
4385 + // Files only. A directory is structural and is not what a filter
4386 + // targets, which is the shipped count's own rule.
4387 + self.state
4388 + .nav
4389 + .contents
4390 + .iter()
4391 + .filter(|entry| entry.node.node_type != audiofiles_core::vfs::NodeType::Directory)
4392 + .count()
4393 + }
4394 +
4395 + fn active(&self) -> bool {
4396 + self.state.search.search_filter.is_active()
4397 + }
4398 +
4399 + fn describes(&self) -> String {
4400 + self.state.search.search_filter.describe()
4401 + }
4402 +
4403 + fn narrow(&self, key: &'static str, lower: Option<f64>, upper: Option<f64>) {
4404 + self.push(Intent::Narrow(key, lower, upper));
4405 + }
4406 +
4407 + fn set_key_mode(&self, compatible: bool) {
4408 + self.push(Intent::KeyMode(compatible));
4409 + }
4410 +
4411 + fn toggle_key(&self, key: &str) {
4412 + self.push(Intent::ToggleKey(key.to_owned()));
4413 + }
4414 +
4415 + fn clear_keys(&self) {
4416 + self.push(Intent::ClearKeys);
4417 + }
4418 +
4419 + fn typed(&self, text: &str) {
4420 + self.push(Intent::TypingTag(text.to_owned()));
4421 + }
4422 +
4423 + fn require(&self, tag: &str) {
4424 + self.push(Intent::RequireTag(tag.to_owned()));
4425 + }
4426 +
4427 + fn unrequire(&self, tag: &str) {
4428 + self.push(Intent::UnrequireTag(tag.to_owned()));
4429 + }
4430 +
4431 + fn clear_tags(&self) {
4432 + self.push(Intent::ClearTags);
4433 + }
4434 +
4435 + fn clear_all(&self) {
4436 + self.push(Intent::ClearFilters);
4437 + }
4438 +
4439 + fn save_collection(&self, name: &str) {
4440 + self.push(Intent::SaveCollection(name.to_owned()));
4441 + }
4442 + }
4443 +
4168 4444 impl FromQueue<'_> {
4169 4445 /// Record what the described screen asked for.
4170 4446 fn push(&self, intent: Intent) {
@@ -4703,6 +4979,8 @@
4703 4979 pub forge: &'a dyn Forge,
4704 4980 /// The library-wide tag queue, for the review screen.
4705 4981 pub queue: &'a dyn Queue,
4982 + /// What is being filtered for, for the filter panel.
4983 + pub filters: &'a dyn Filters,
4706 4984 /// The themes on offer, resolved by the host at startup.
4707 4985 pub themes: &'a [ThemeChoice],
4708 4986 }
@@ -4713,10 +4991,10 @@
4713 4991 /// cost is nothing, and building it fresh is what lets the state borrow.
4714 4992 #[must_use]
4715 4993 pub fn router<'a>() -> Router<Panels<'a>> {
4716 - queue::routes(forge::routes(edit::routes(integrity::routes(
4717 - importing::routes(naming::routes(toolbar::routes(library::routes(
4718 - shell::routes(help::routes(bulk::routes(detail::routes(export::routes(
4719 - files::routes(sync::routes(settings::routes(Router::new()))),
4994 + filters::routes(queue::routes(forge::routes(edit::routes(
4995 + integrity::routes(importing::routes(naming::routes(toolbar::routes(
4996 + library::routes(shell::routes(help::routes(bulk::routes(detail::routes(
4997 + export::routes(files::routes(sync::routes(settings::routes(Router::new())))),
4720 4998 ))))),
4721 4999 )))),
4722 5000 ))))
@@ -33,9 +33,9 @@
33 33 use std::cell::RefCell;
34 34
35 35 use super::{
36 - FromBackend, FromBar, FromBulk, FromContents, FromEditor, FromExport, FromForge, FromImport,
37 - FromIntegrity, FromLibrary, FromNaming, FromQueue, FromSelection, FromSyncManager, FromWindow,
38 - Intent, Panels, Setting, Sync, ThemeChoice, Unconfigured,
36 + FromBackend, FromBar, FromBulk, FromContents, FromEditor, FromExport, FromFilters, FromForge,
37 + FromImport, FromIntegrity, FromLibrary, FromNaming, FromQueue, FromSelection, FromSyncManager,
38 + FromWindow, Intent, Panels, Setting, Sync, ThemeChoice, Unconfigured,
39 39 };
40 40 use crate::state::BrowserState;
41 41 use crate::ui::theme;
@@ -58,6 +58,7 @@
58 58 import: Option<Runtime>,
59 59 queue: Option<Runtime>,
60 60 sweep: Option<Runtime>,
61 + filters: Option<Runtime>,
61 62 /// Whether the described main window is open.
62 63 pub show_shell: bool,
63 64 /// Whether the described detail panel is open.
@@ -389,6 +390,40 @@
389 390 }
390 391 }
391 392
393 + /// Draw the described filter panel, and act on whatever was pressed.
394 + ///
395 + /// Beside the shipped panel and on the same toggle, which is the arrangement
396 + /// every port with a shipped pane rather than a shipped window has taken: the
397 + /// filter panel is a left `egui::Panel` and there is no window to share.
398 + ///
399 + /// Refreshed unconditionally, and this one earns it more plainly than most. The
400 + /// count under the controls is the size of the result set, so every narrowing
401 + /// changes what the screen says about itself, and an intent lands after the
402 + /// answer was built.
403 + pub fn draw_filters(ctx: &egui::Context, state: &mut BrowserState) {
404 + let intents = RefCell::new(Vec::new());
405 + let mut runtime = state.described.filters.take();
406 + let host = Host {
407 + state,
408 + sync: None,
409 + themes: themes(),
410 + intents: &intents,
411 + };
412 + let closed = window(
413 + ctx,
414 + "Filters (described)",
415 + &mut runtime,
416 + &host,
417 + "/filters",
418 + true,
419 + );
420 + state.described.filters = runtime;
421 + apply(ctx, state, None, intents.into_inner());
422 + if closed {
423 + state.described.filters = None;
424 + }
425 + }
426 +
392 427 /// Draw the described sweep, and act on whatever was pressed.
393 428 ///
394 429 /// Its own window rather than a stage of the import flow's, because it is not
@@ -1174,6 +1209,76 @@
1174 1209 state.forge_conform_device(&device);
1175 1210 }
1176 1211 }
1212 + // The filter panel. Every one of these lands in
1213 + // `state.search.search_filter`, which is the app's own UI state, and
1214 + // `apply_search` is what turns a changed filter into a new result
1215 + // set -- the shipped panel's own `if changed` at the end of the
1216 + // draw, said once here instead of per control.
1217 + Intent::Narrow(key, lower, upper) => {
1218 + let f = &mut state.search.search_filter;
1219 + let ends: [(&str, &mut Option<f64>, &mut Option<f64>); 6] = [
1220 + ("bpm", &mut f.bpm_min, &mut f.bpm_max),
1221 + ("duration", &mut f.duration_min, &mut f.duration_max),
1222 + ("loudness", &mut f.peak_db_min, &mut f.peak_db_max),
1223 + ("brightness", &mut f.centroid_min, &mut f.centroid_max),
1224 + ("noisiness", &mut f.flatness_min, &mut f.flatness_max),
1225 + ("attack", &mut f.attack_min, &mut f.attack_max),
1226 + ];
1227 + for (named, low, high) in ends {
1228 + if named == key {
1229 + *low = lower;
1230 + *high = upper;
1231 + break;
1232 + }
1233 + }
1234 + state.apply_search();
1235 + }
1236 + Intent::KeyMode(compatible) => {
1237 + use audiofiles_core::search::KeyFilterMode;
1238 + state.search.search_filter.key_mode = if compatible {
1239 + KeyFilterMode::Compatible
1240 + } else {
1241 + KeyFilterMode::Exact
1242 + };
1243 + state.apply_search();
1244 + }
1245 + Intent::ToggleKey(key) => {
1246 + let keys = &mut state.search.search_filter.keys;
1247 + if let Some(at) = keys.iter().position(|held| *held == key) {
1248 + keys.remove(at);
1249 + } else {
1250 + keys.push(key);
1251 + }
1252 + state.apply_search();
1253 + }
1254 + Intent::ClearKeys => {
1255 + state.search.search_filter.keys.clear();
1256 + state.apply_search();
1257 + }
1258 + // The one filter intent that does not re-run the search: what is in
1259 + // the tag box is not a filter until it is added.
1260 + Intent::TypingTag(text) => state.search.filter_tag_input = text,
1261 + Intent::RequireTag(tag) => {
1262 + state.search.search_filter.required_tags.push(tag);
1263 + state.apply_search();
1264 + }
1265 + Intent::UnrequireTag(tag) => {
1266 + state
1267 + .search
1268 + .search_filter
1269 + .required_tags
1270 + .retain(|held| *held != tag);
1271 + state.apply_search();
1272 + }
1273 + Intent::ClearTags => {
1274 + state.search.search_filter.required_tags.clear();
1275 + state.apply_search();
1276 + }
1277 + Intent::ClearFilters => {
1278 + state.search.search_filter.clear();
1279 + state.search.search_query.clear();
1280 + state.apply_search();
1281 + }
1177 1282 Intent::TrimSilence => {
1178 1283 let threshold = state.forge.trim_threshold_db;
1179 1284 state.batch_trim_silence(threshold);
@@ -1764,6 +1869,7 @@
1764 1869 let editor = FromEditor { state, intents };
1765 1870 let forge = FromForge { state, intents };
1766 1871 let queue = FromQueue { state, intents };
1872 + let filters = FromFilters { state, intents };
1767 1873 let panels = Panels {
1768 1874 config: &config,
1769 1875 sync,
@@ -1780,6 +1886,7 @@
1780 1886 editor: &editor,
1781 1887 forge: &forge,
1782 1888 queue: &queue,
1889 + filters: &filters,
1783 1890 themes,
1784 1891 };
1785 1892 super::router()
@@ -14,12 +14,12 @@
14 14 use super::{
15 15 Analysed, Analysis, Bar, Bulk, Candidate, Channels, Chop, Chosen, Collection, ColumnsShown,
16 16 Config, Coverage, Crumb, Decision, Detail, Detailed, DeviceChoice, Editing, Export, Failure,
17 - Files, Filter, Focus, Folder, FolderTags, Forge, Forging, Format, Group, Halted, Holding,
18 - Importing, Integrity, Knob, Library, Measure, Measures, Migrating, Naming, Order, Panel,
19 - Panels, Phase, Playing, Preflight, Pricing, ProfileChoice, Queue, Queued, Reviewed, Sample,
20 - Saying, Scope, Searching, Setting, Settings, Shared, Shell, Source, Spread, Stage, State,
21 - Status, Strategy, Subject, Subscription, Suggested, Suggestion, Sweep, Sync, Tagged,
22 - ThemeChoice, Vault, VaultChoice, Where, router,
17 + Files, Filter, Filters, Focus, Folder, FolderTags, Forge, Forging, Format, Group, Halted,
18 + Holding, Importing, Integrity, Keys, Knob, Library, Measure, Measures, Migrating, Naming,
19 + Narrowing, Order, Panel, Panels, Phase, Playing, Preflight, Pricing, ProfileChoice, Queue,
20 + Queued, Reviewed, Sample, Saying, Scope, Searching, Setting, Settings, Shared, Shell, Source,
21 + Spread, Stage, State, Status, Strategy, Subject, Subscription, Suggested, Suggestion, Sweep,
22 + Sync, Tagged, ThemeChoice, Vault, VaultChoice, Where, router,
23 23 };
24 24
25 25 /// A config store in memory.
@@ -260,6 +260,7 @@
260 260 editor: &Unedited,
261 261 forge: &Unforged,
262 262 queue: &Unqueued,
263 + filters: &Unfiltered,
263 264 themes: &themes,
264 265 };
265 266 router().handle(&state, request)
@@ -393,6 +394,7 @@
393 394 editor: &Unedited,
394 395 forge: &Unforged,
395 396 queue: &Unqueued,
397 + filters: &Unfiltered,
396 398 themes: &themes,
397 399 };
398 400 router().handle(&state, request)
@@ -453,6 +455,7 @@
453 455 editor: &Unedited,
454 456 forge: &Unforged,
455 457 queue: &Unqueued,
458 + filters: &Unfiltered,
456 459 themes: &themes,
457 460 };
458 461 router().handle(&state, request)
@@ -612,6 +615,7 @@
612 615 editor: &Unedited,
613 616 forge: &Unforged,
614 617 queue: &Unqueued,
618 + filters: &Unfiltered,
615 619 themes: &themes,
616 620 };
617 621 let response = router()
@@ -668,6 +672,7 @@
668 672 editor: &Unedited,
669 673 forge: &Unforged,
670 674 queue: &Unqueued,
675 + filters: &Unfiltered,
671 676 themes: &themes,
672 677 };
673 678
@@ -718,6 +723,7 @@
718 723 editor: &Unedited,
719 724 forge: &Unforged,
720 725 queue: &Unqueued,
726 + filters: &Unfiltered,
721 727 themes: &themes,
722 728 };
723 729 let refused = router().handle(
@@ -754,6 +760,7 @@
754 760 editor: &Unedited,
755 761 forge: &Unforged,
756 762 queue: &Unqueued,
763 + filters: &Unfiltered,
757 764 themes: &themes,
758 765 };
759 766
@@ -813,6 +820,7 @@
813 820 editor: &Unedited,
814 821 forge: &Unforged,
815 822 queue: &Unqueued,
823 + filters: &Unfiltered,
816 824 themes: &themes,
817 825 };
818 826 let response = router()
@@ -868,6 +876,7 @@
868 876 editor: &Unedited,
869 877 forge: &Unforged,
870 878 queue: &Unqueued,
879 + filters: &Unfiltered,
871 880 themes: &themes,
872 881 };
873 882 let response = router()
@@ -1036,6 +1045,7 @@
1036 1045 editor: &Unedited,
1037 1046 forge: &Unforged,
1038 1047 queue: &Unqueued,
1048 + filters: &Unfiltered,
1039 1049 themes: &themes,
1040 1050 };
1041 1051 router().handle(&state, request)
@@ -2485,6 +2495,7 @@
2485 2495 editor: &Unedited,
2486 2496 forge: &Unforged,
2487 2497 queue: &Unqueued,
2498 + filters: &Unfiltered,
2488 2499 themes: &themes,
2489 2500 };
2490 2501 router().handle(&state, request)
@@ -3021,6 +3032,7 @@
3021 3032 editor: &Unedited,
3022 3033 forge: &Unforged,
3023 3034 queue: &Unqueued,
3035 + filters: &Unfiltered,
3024 3036 themes: &themes,
3025 3037 };
3026 3038 router().handle(&state, request)
@@ -3397,6 +3409,7 @@
3397 3409 editor: &Unedited,
3398 3410 forge: &Unforged,
3399 3411 queue: &Unqueued,
3412 + filters: &Unfiltered,
3400 3413 themes: &themes,
3401 3414 };
3402 3415 router().handle(&state, request)
@@ -3453,6 +3466,7 @@
3453 3466 editor: &Unedited,
3454 3467 forge: &Unforged,
3455 3468 queue: &Unqueued,
3469 + filters: &Unfiltered,
3456 3470 themes: &themes,
3457 3471 };
3458 3472
@@ -3684,6 +3698,7 @@
3684 3698 editor: &Unedited,
3685 3699 forge: &Unforged,
3686 3700 queue: &Unqueued,
3701 + filters: &Unfiltered,
3687 3702 themes: &themes,
3688 3703 };
3689 3704 router().handle(&state, request)
@@ -4104,6 +4119,7 @@
4104 4119 editor: &Unedited,
4105 4120 forge: &Unforged,
4106 4121 queue: &Unqueued,
4122 + filters: &Unfiltered,
4107 4123 themes: &themes,
4108 4124 };
4109 4125 router().handle(&state, request)
@@ -4540,6 +4556,7 @@
4540 4556 editor: &Unedited,
4541 4557 forge: &Unforged,
4542 4558 queue: &Unqueued,
4559 + filters: &Unfiltered,
4543 4560 themes: &themes,
4544 4561 };
4545 4562 router().handle(&state, request)
@@ -5097,6 +5114,7 @@
5097 5114 editor: &Unedited,
5098 5115 forge: &Unforged,
5099 5116 queue: &Unqueued,
5117 + filters: &Unfiltered,
5100 5118 themes: &themes,
5101 5119 };
5102 5120 router().handle(&state, request)
@@ -5255,6 +5273,7 @@
5255 5273 editor: &Unedited,
5256 5274 forge: &Unforged,
5257 5275 queue: &Unqueued,
5276 + filters: &Unfiltered,
5258 5277 themes: &themes,
5259 5278 };
5260 5279 router().handle(&state, request)
@@ -5560,6 +5579,7 @@
5560 5579 editor: &Unedited,
5561 5580 forge: &Unforged,
5562 5581 queue: &Unqueued,
5582 + filters: &Unfiltered,
5563 5583 themes: &themes,
5564 5584 };
5565 5585 router().handle(&state, request)
@@ -5642,6 +5662,7 @@
5642 5662 editor: &Unedited,
5643 5663 forge: &Unforged,
5644 5664 queue: &Unqueued,
5665 + filters: &Unfiltered,
5645 5666 themes: &themes,
5646 5667 };
5647 5668 let response = router().handle(&state, Request::get("/")).unwrap();
@@ -5822,6 +5843,7 @@
5822 5843 editor,
5823 5844 forge: &Unforged,
5824 5845 queue: &Unqueued,
5846 + filters: &Unfiltered,
5825 5847 themes: &themes,
5826 5848 };
5827 5849 router().handle(&state, request)
@@ -7398,6 +7420,7 @@
7398 7420 editor: &Unedited,
7399 7421 forge,
7400 7422 queue: &Unqueued,
7423 + filters: &Unfiltered,
7401 7424 themes: &themes,
7402 7425 };
7403 7426 router().handle(&state, request)
@@ -7780,6 +7803,214 @@
7780 7803 fn close(&self) {}
7781 7804 }
7782 7805
7806 + /// Nothing is filtering, and every axis is open.
7807 + ///
7808 + /// [`Unqueued`]'s peer, and the last of them.
7809 + struct Unfiltered;
7810 +
7811 + impl Filters for Unfiltered {
7812 + fn axes(&self) -> Vec<Narrowing> {
7813 + open_axes()
7814 + }
7815 + fn keys(&self) -> Keys {
7816 + Keys {
7817 + wanted: Vec::new(),
7818 + compatible: false,
7819 + }
7820 + }
7821 + fn tags(&self) -> Vec<String> {
7822 + Vec::new()
7823 + }
7824 + fn typing(&self) -> String {
7825 + String::new()
7826 + }
7827 + fn matched(&self) -> usize {
7828 + 0
7829 + }
7830 + fn active(&self) -> bool {
7831 + false
7832 + }
7833 + fn describes(&self) -> String {
7834 + "Filters".to_owned()
7835 + }
7836 + fn narrow(&self, _key: &'static str, _lower: Option<f64>, _upper: Option<f64>) {}
7837 + fn set_key_mode(&self, _compatible: bool) {}
7838 + fn toggle_key(&self, _key: &str) {}
7839 + fn clear_keys(&self) {}
7840 + fn typed(&self, _text: &str) {}
7841 + fn require(&self, _tag: &str) {}
7842 + fn unrequire(&self, _tag: &str) {}
7843 + fn clear_tags(&self) {}
7844 + fn clear_all(&self) {}
7845 + fn save_collection(&self, _name: &str) {}
7846 + }
7847 +
7848 + /// The six axes with neither end wanted.
7849 + ///
7850 + /// The shipped geometry table read rather than a second one written, which is
7851 + /// what `FromFilters::table` does and is the point of the axes being `pub`.
7852 + fn open_axes() -> Vec<Narrowing> {
7853 + use crate::ui::filter_panel as shipped;
7854 + [
7855 + ("bpm", &shipped::BPM),
7856 + ("duration", &shipped::DURATION),
7857 + ("loudness", &shipped::LOUDNESS),
7858 + ("brightness", &shipped::BRIGHTNESS),
7859 + ("noisiness", &shipped::NOISINESS),
7860 + ("attack", &shipped::ATTACK),
7861 + ]
7862 + .into_iter()
7863 + .map(|(key, axis)| Narrowing {
7864 + key,
7865 + axis,
7866 + lower: None,
7867 + upper: None,
7868 + })
7869 + .collect()
7870 + }
7871 +
7872 + /// Filters in memory, recording what was asked of them.
7873 + struct FakeFilters {
7874 + axes: RefCell<Vec<Narrowing>>,
7875 + keys: RefCell<Keys>,
7876 + tags: RefCell<Vec<String>>,
7877 + typing: RefCell<String>,
7878 + asked: RefCell<Vec<String>>,
7879 + }
7880 +
7881 + impl Default for FakeFilters {
7882 + fn default() -> Self {
7883 + Self {
7884 + axes: RefCell::new(open_axes()),
7885 + keys: RefCell::new(Keys {
7886 + wanted: Vec::new(),
7887 + compatible: false,
7888 + }),
7889 + tags: RefCell::new(Vec::new()),
7890 + typing: RefCell::new(String::new()),
7891 + asked: RefCell::new(Vec::new()),
7892 + }
7893 + }
7894 + }
7895 +
7896 + impl FakeFilters {
7897 + fn say(&self, said: impl Into<String>) {
7898 + self.asked.borrow_mut().push(said.into());
7899 + }
7900 +
7901 + fn asked(&self) -> Vec<String> {
7902 + self.asked.borrow().clone()
7903 + }
7904 +
7905 + /// Narrow an axis up front, as a screen being re-read would find it.
7906 + fn holding(self, key: &str, lower: Option<f64>, upper: Option<f64>) -> Self {
7907 + for axis in self.axes.borrow_mut().iter_mut() {
7908 + if axis.key == key {
7909 + axis.lower = lower;
7910 + axis.upper = upper;
7911 + }
7912 + }
7913 + self
7914 + }
7915 + }
7916 +
7917 + impl Filters for FakeFilters {
7918 + fn axes(&self) -> Vec<Narrowing> {
7919 + self.axes.borrow().clone()
7920 + }
7921 + fn keys(&self) -> Keys {
7922 + self.keys.borrow().clone()
7923 + }
7924 + fn tags(&self) -> Vec<String> {
7925 + self.tags.borrow().clone()
7926 + }
7927 + fn typing(&self) -> String {
7928 + self.typing.borrow().clone()
7929 + }
7930 + fn matched(&self) -> usize {
7931 + 7
7932 + }
7933 + fn active(&self) -> bool {
7934 + self.axes
7935 + .borrow()
7936 + .iter()
7937 + .any(|axis| axis.lower.is_some() || axis.upper.is_some())
7938 + || !self.tags.borrow().is_empty()
7939 + || !self.keys.borrow().wanted.is_empty()
7940 + }
7941 + fn describes(&self) -> String {
7942 + "BPM 90-130".to_owned()
7943 + }
7944 + fn narrow(&self, key: &'static str, lower: Option<f64>, upper: Option<f64>) {
7945 + self.say(format!("narrow:{key}={lower:?}..{upper:?}"));
7946 + }
7947 + fn set_key_mode(&self, compatible: bool) {
7948 + self.say(format!("mode:compatible={compatible}"));
7949 + }
7950 + fn toggle_key(&self, key: &str) {
7951 + self.say(format!("key:{key}"));
7952 + }
7953 + fn clear_keys(&self) {
7954 + self.say("keys:clear");
7955 + }
7956 + fn typed(&self, text: &str) {
7957 + self.say(format!("typing:{text}"));
7958 + *self.typing.borrow_mut() = text.to_owned();
7959 + }
7960 + fn require(&self, tag: &str) {
7961 + self.say(format!("require:{tag}"));
7962 + self.tags.borrow_mut().push(tag.to_owned());
7963 + }
7964 + fn unrequire(&self, tag: &str) {
7965 + self.say(format!("unrequire:{tag}"));
7966 + }
7967 + fn clear_tags(&self) {
7968 + self.say("tags:clear");
7969 + }
7970 + fn clear_all(&self) {
7971 + self.say("clear");
7972 + }
7973 + fn save_collection(&self, name: &str) {
7974 + self.say(format!("save:{name}"));
7975 + }
7976 + }
7977 +
7978 + /// A router call against these filters.
7979 + fn filtering(
7980 + filters: &FakeFilters,
7981 + request: Request,
7982 + ) -> Result<Response, quasi_router::RouteError> {
7983 + let store = Store::default();
7984 + let sync = Offline;
7985 + let files = FakeFiles::default();
7986 + let themes = themes();
7987 + let state = Panels {
7988 + detail: &Unfocused,
7989 + bulk: &Unchosen,
7990 + shell: &Quiet,
7991 + library: &Empty,
7992 + bar: &Still,
7993 + config: &store,
7994 + sync: &sync,
7995 + files: &files,
7996 + export: &Idle,
7997 + naming: &Unnamed,
7998 + importing: &NoImport,
7999 + integrity: &Sound,
8000 + editor: &Unedited,
8001 + forge: &Unforged,
8002 + queue: &Unqueued,
8003 + filters,
8004 + themes: &themes,
8005 + };
8006 + router().handle(&state, request)
8007 + }
8008 +
8009 + /// The screen the filter panel answers.
8010 + fn filter_screen(filters: &FakeFilters) -> Screen {
8011 + screen_of(&filtering(filters, Request::get("/filters")).unwrap()).clone()
8012 + }
8013 +
7783 8014 /// A queue in memory, recording what was asked of it.
7784 8015 struct FakeQueue {
7785 8016 queued: Option<Queued>,
@@ -7907,6 +8138,7 @@
7907 8138 editor: &Unedited,
7908 8139 forge: &Unforged,
7909 8140 queue,
8141 + filters: &Unfiltered,
7910 8142 themes: &themes,
7911 8143 };
7912 8144 router().handle(&state, request)
@@ -8152,3 +8384,275 @@
8152 8384 .collect();
8153 8385 assert_eq!(notices, ["Applied 120 tags."]);
8154 8386 }
8387 +
8388 + // ---------------------------------------------------------------------------
8389 + // The filter panel. The sixteenth port and the first consumer of
8390 + // `FieldKind::Interval`, so what these cover is mostly the pair: that the six
8391 + // axes are six questions rather than twelve, that both ends travel together,
8392 + // and that the sentinel edges survive the round trip in both directions.
8393 + // ---------------------------------------------------------------------------
8394 +
8395 + /// The intervals on the screen, by the name their lower end submits under.
8396 + fn intervals(
8397 + screen: &Screen,
8398 + ) -> BTreeMap<String, (Option<String>, Option<String>, Option<String>)> {
8399 + let mut found = BTreeMap::new();
8400 + for slot in &screen.slots {
8401 + for placed in &slot.body {
8402 + let regions = match &placed.node {
8403 + Node::Region(region) => std::slice::from_ref(region),
8404 + _ => &[][..],
8405 + };
8406 + for region in regions {
8407 + for inner in &region.body {
8408 + if let Node::Form { fields, .. } = &inner.node {
8409 + for field in fields {
8410 + if field.kind == quasi_router::layout::FieldKind::Interval {
8411 + found.insert(
8412 + field.name.clone(),
8413 + (
8414 + field.upper_name.clone(),
8415 + field.value.clone(),
8416 + field.upper_value.clone(),
8417 + ),
8418 + );
8419 + }
8420 + }
8421 + }
8422 + }
8423 + }
8424 + }
8425 + }
8426 + found
8427 + }
8428 +
8429 + #[test]
8430 + fn the_six_axes_are_six_questions_and_not_twelve() {
8431 + // The whole reason this port waited for makeover-layout 0.34.0. Described
8432 + // as `Number` pairs these are twelve fields with no relationship, and the
8433 + // shipped panel's `range_filter_section` is the 55 lines that stood in for
8434 + // the missing member.
8435 + let filters = FakeFilters::default();
8436 + let screen = filter_screen(&filters);
8437 + let axes = intervals(&screen);
8438 +
8439 + assert_eq!(axes.len(), 6, "{axes:?}");
8440 + for (lower, upper) in [
8441 + ("bpm_min", "bpm_max"),
8442 + ("duration_min", "duration_max"),
8443 + ("loudness_min", "loudness_max"),
8444 + ("brightness_min", "brightness_max"),
8445 + ("noisiness_min", "noisiness_max"),
8446 + ("attack_min", "attack_max"),
8447 + ] {
8448 + let (named, _, _) = axes.get(lower).unwrap_or_else(|| panic!("{lower}"));
8449 + assert_eq!(named.as_deref(), Some(upper), "{lower}");
8450 + }
8451 + }
8452 +
8453 + #[test]
8454 + fn an_axis_carries_the_shipped_geometry_rather_than_a_second_table() {
8455 + // The six axes are a constant the shipped panel already reduced them to,
8456 + // and a table here would drift the way the class filter's list and colour
8457 + // table drifted before that reduction.
8458 + let filters = FakeFilters::default();
8459 + let screen = filter_screen(&filters);
8460 + let bpm = axis_field(&screen, "bpm_min");
8461 +
8462 + assert_eq!(bpm.min.as_deref(), Some("0"));
8463 + assert_eq!(bpm.max.as_deref(), Some("300"));
8464 + assert_eq!(bpm.label, crate::ui::filter_panel::BPM.title);
8465 + // Two of the six have no unit, and an empty suffix is not one.
8466 + assert_eq!(bpm.unit, None);
8467 +
8468 + let loudness = axis_field(&screen, "loudness_min");
8469 + assert_eq!(loudness.min.as_deref(), Some("-96"));
8470 + // The suffix carried a leading space because it was going into a DragValue.
8471 + // `Field::unit` is the symbol alone.
8472 + assert_eq!(loudness.unit.as_deref(), Some("dB"));
8473 +
8474 + let attack = axis_field(&screen, "attack_min");
8475 + assert_eq!(attack.unit.as_deref(), Some("s"));
8476 + // Written at the axis's own precision, which is what the shipped control
8477 + // was given as `decimals`.
8478 + assert_eq!(attack.max.as_deref(), Some("1.000"));
8479 + }
8480 +
8481 + /// One axis's field, by the name its lower end submits under.
8482 + fn axis_field(screen: &Screen, name: &str) -> quasi_router::Field {
8483 + for slot in &screen.slots {
Lines truncated
@@ -28,20 +28,106 @@
28 28
29 29 /// A numeric range filter's fixed geometry: the sentinel edges, the drag speed,
30 30 /// and the unit shown in the field.
31 - struct RangeAxis {
31 + #[derive(Debug, PartialEq)]
32 + pub struct RangeAxis {
32 33 /// Section title. Names the axis the way a producer would say it; the unit in
33 34 /// `suffix` keeps the number honest.
34 - title: &'static str,
35 + pub title: &'static str,
35 36 /// Hover text, for axes whose title is perceptual rather than physical.
36 - hint: Option<&'static str>,
37 - lo: f64,
38 - hi: f64,
39 - speed: f64,
40 - suffix: &'static str,
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,
41 46 /// Decimal places in the field. Hz wants 0, a 0..1 ratio wants 2.
42 - decimals: usize,
47 + pub decimals: usize,
43 48 }
44 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 +
45 131 /// Draw one numeric range filter: a collapsing section, min/max drag fields with
46 132 /// sentinel edges, a sibling snap so `min <= max` always holds, and a per-section
47 133 /// clear. Returns true when the search needs re-running.
@@ -133,87 +219,6 @@
133 219
134 220 let mut changed = false;
135 221
136 - // Every range axis below is one RangeAxis + one call. The sentinel edges are
137 - // the "no filter" values: a min sitting on `lo` or a max on `hi` stores None,
138 - // so the SQL omits that bound. The per-section [clear] link closes the
139 - // sentinel-visibility gap (M-1) and the sibling snap makes contradictory
140 - // min > max states unrepresentable (M-2).
141 - //
142 - // 300 BPM matches the ceiling in bpm.rs's plausible-BPM filter.
143 - const BPM: RangeAxis = RangeAxis {
144 - title: "BPM Range",
145 - hint: None,
146 - lo: 0.0,
147 - hi: 300.0,
148 - speed: 1.0,
149 - suffix: "",
150 - decimals: 0,
151 - };
152 - const DURATION: RangeAxis = RangeAxis {
153 - title: "Duration",
154 - hint: None,
155 - lo: 0.0,
156 - hi: 600.0,
157 - speed: 0.1,
158 - suffix: " s",
159 - decimals: 1,
160 - };
161 - const LOUDNESS: RangeAxis = RangeAxis {
162 - title: "Loudness",
163 - hint: None,
164 - lo: -96.0,
165 - hi: 0.0,
166 - speed: 0.5,
167 - suffix: " dB",
168 - decimals: 0,
169 - };
170 -
171 - // The measured axes. These are what replaced the sample-class filter: a class
172 - // was a lossy guess at a question nobody asked, where a region of the feature
173 - // space is the thing a producer actually reaches for (wiki af-browse-axes).
174 - //
175 - // Titled perceptually and valued in the real unit. "Brightness" is how anyone
176 - // describes the axis; 2500 Hz is what the query compares. Neither alone is
177 - // enough: Hz in the title reads as an instrument spec, and a bare 0-100
178 - // "brightness" score would be a made-up number.
179 - //
180 - // Deliberately NOT binned. Fixed breakpoints do not transfer across material:
181 - // kick/tom one-shots sit 48% low / 43% lowmid / 0% high, reverb loops sit 71%
182 - // high, FSL10K loops spread 16/22/40/22. A "bright" one-shot and a "bright"
183 - // loop are nowhere near each other, so any binning has to be relative to the
184 - // library. Exposing the value and letting the range be chosen sidesteps that.
185 - //
186 - // Ceilings are the measurable range, not a guess: centroid tops out near
187 - // Nyquist for 44.1k material, flatness is 0..1 by construction, and attack is
188 - // capped at 1 s because a slower onset than that is a pad, not a transient.
189 - const BRIGHTNESS: RangeAxis = RangeAxis {
190 - title: "Brightness",
191 - hint: Some("Spectral centroid: where the energy sits. Low is dark, high is bright."),
192 - lo: 0.0,
193 - hi: 20_000.0,
194 - speed: 50.0,
195 - suffix: " Hz",
196 - decimals: 0,
197 - };
198 - const NOISINESS: RangeAxis = RangeAxis {
199 - title: "Tonal / Noisy",
200 - hint: Some("Spectral flatness: 0 is a pure tone, 1 is white noise."),
201 - lo: 0.0,
202 - hi: 1.0,
203 - speed: 0.01,
204 - suffix: "",
205 - decimals: 2,
206 - };
207 - const ATTACK: RangeAxis = RangeAxis {
208 - title: "Attack",
209 - hint: Some("Time to reach full level. Short is a transient, long is a swell."),
210 - lo: 0.0,
211 - hi: 1.0,
212 - speed: 0.005,
213 - suffix: " s",
214 - decimals: 3,
215 - };
216 -
217 222 let f = &mut state.search.search_filter;
218 223 changed |= range_filter_section(ui, &BPM, &mut f.bpm_min, &mut f.bpm_max);
219 224 changed |= range_filter_section(ui, &DURATION, &mut f.duration_min, &mut f.duration_max);
@@ -1,0 +1,524 @@
1 + //! The filter panel, described: six numeric axes, the keys, and the tags.
2 + //!
3 + //! The sixteenth port, and the one that had to wait. `ui/filter_panel.rs` is 449
4 + //! lines and its centre is six min/max pairs, which the vocabulary could not say
5 + //! were six questions rather than twelve until makeover-layout 0.34.0 gave it
6 + //! [`FieldKind::Interval`](quasi_router::layout::FieldKind::Interval). That
7 + //! member was ruled on 2026-08-21 with this screen named as its first consumer,
8 + //! and this is the consuming.
9 + //!
10 + //! # What the interval buys, measured against what is here
11 + //!
12 + //! `range_filter_section` is 55 lines every axis goes through, and it exists
13 + //! because nothing above it could carry the pair. Described, the six axes are
14 + //! six [`Field::interval`](quasi_router::Field::interval) calls over the same
15 + //! geometry table the shipped panel already reduced them to, and the three
16 + //! things that helper does by hand fall out:
17 + //!
18 + //! - **The two ends are one question.** The label sits above the pair rather
19 + //! than beside either box, which is what the shipped `ui.label("Min")` and
20 + //! `ui.label("Max")` were standing in for.
21 + //! - **The extent is the axis's, not each end's.** `min`, `max` and `step` are
22 + //! read once and both boxes take them, so the two ends cannot be given
23 + //! different bounds by a typo.
24 + //! - **The unit is on the value.** `RangeAxis::suffix` was already a DragValue
25 + //! suffix, and [`Field::unit`](quasi_router::Field::unit) is the description's
26 + //! word for exactly that, so the port is a rename rather than a redesign.
27 + //!
28 + //! # The sentinel edge is what an empty end means
29 + //!
30 + //! The shipped panel maps a value sitting on its edge to `None` and omits the
31 + //! bound from the SQL: a BPM minimum of 0 is not a filter for "at least 0", it
32 + //! is no filter at all. Described, the same fact is an end with nothing in it,
33 + //! and [`ends`] is the one place the two spellings meet. Both directions, so a
34 + //! stored `None` comes back as an empty box rather than as the edge.
35 + //!
36 + //! That is why the route reads both ends and writes both. An interval is one
37 + //! answer: a handler taking only the end that moved would drop the other bound
38 + //! every time either box was touched.
39 + //!
40 + //! # What the description does not carry
41 + //!
42 + //! - **The sibling snap.** The shipped helper pushes the other end along so
43 + //! `min <= max` always holds, and there is no member for it: makeover-layout
44 + //! `FieldKind::Interval`'s own header says the crossing rule stays with
45 + //! whoever validated, on the rule that this vocabulary carries constraints and
46 + //! never checks them. So the route is where it goes, and it goes there for a
47 + //! better reason than the vocabulary's convenience -- snapping is a *write*,
48 + //! and a description that could snap could rewrite an answer without being
49 + //! asked.
50 + //! - **The debounce.** `requery_now` defers the query to a drag's settle point,
51 + //! which is renderer policy the way the footer's fade timer was, and is
52 + //! already `Field::changes`'s own behaviour: a described field answers when it
53 + //! settles (`audiofiles@a3e59e3`).
54 + //! - **The per-section collapse.** `widgets::filter_section` is a disclosure per
55 + //! axis. [`Field::extended`](quasi_router::Field::extended) says a field lives
56 + //! behind one, and it is a property of the form rather than of the field, so a
57 + //! screen of six independently collapsing sections is not sayable. Filed as
58 + //! the second consumer of `quasi:vocabulary:per-field-disclosure`; the
59 + //! described panel draws all six open, which is what an unfiltered panel looks
60 + //! like anyway.
61 + //!
62 + //! # FINDING, second consumer: two actions over one value
63 + //!
64 + //! Save-as-collection takes a name and offers Save; the tag box takes a tag and
65 + //! offers Add. Both are a [`Node::Form`] whose submit is the only act, which is
66 + //! fine, and both sit on a screen with other forms on it. `7da72faf` is the
67 + //! filed shape and this is not quite it -- what is here is several forms rather
68 + //! than one form with two submits -- so it is recorded rather than counted.
69 +
70 + use quasi_router::layout::{FieldKind, Tone, Width};
71 + use quasi_router::{
72 + Act, Action, Field, Node, RegionKind, Request, Response, RouteError, Router, Row, Screen, Slot,
73 + Tag,
74 + };
75 +
76 + use super::{Narrowing, Panels};
77 +
78 + /// The axes band.
79 + const AXES: &str = "filters-axes";
80 +
81 + /// The keys band.
82 + const KEYS: &str = "filters-keys";
83 +
84 + /// The tags band.
85 + const TAGS: &str = "filters-tags";
86 +
87 + /// The band under it all.
88 + const FOOT: &str = "filters-foot";
89 +
90 + /// The two key groups, and the twelve keys in each.
91 + ///
92 + /// The shipped panel's own grouping and its own reason: 24 keys stacked hid
93 + /// every section below them and made hunting for one a linear scan. A group is
94 + /// a [`Node::section`] with a row of tags under it, which is the same shape one
95 + /// level up from `ui.horizontal_wrapped`.
96 + const KEY_GROUPS: [(&str, [&str; 12]); 2] = [
97 + (
98 + "Major",
99 + [
100 + "C major", "C# major", "D major", "D# major", "E major", "F major", "F# major",
101 + "G major", "G# major", "A major", "A# major", "B major",
102 + ],
103 + ),
104 + (
105 + "Minor",
106 + [
107 + "C minor", "C# minor", "D minor", "D# minor", "E minor", "F minor", "F# minor",
108 + "G minor", "G# minor", "A minor", "A# minor", "B minor",
109 + ],
110 + ),
111 + ];
112 +
113 + /// Register the panel's routes.
114 + pub fn routes(router: Router<Panels<'_>>) -> Router<Panels<'_>> {
115 + router
116 + .get("/filters", index)
117 + .post("/filters/axis/{key}", narrow)
118 + .post("/filters/axis/{key}/clear", widen)
119 + .post("/filters/keys/mode", key_mode)
120 + .post("/filters/keys/{key}/toggle", toggle_key)
121 + .post("/filters/keys/clear", clear_keys)
122 + .post("/filters/tags/typing", typing)
123 + .post("/filters/tags/add", add_tag)
124 + .post("/filters/tags/{tag}/remove", remove_tag)
125 + .post("/filters/tags/clear", clear_tags)
126 + .post("/filters/clear", clear_all)
127 + .post("/filters/save", save)
128 + }
129 +
130 + /// `GET /filters`
131 + fn index(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
132 + Ok(screen(state).into())
133 + }
134 +
135 + /// `POST /filters/axis/{key}`
136 + ///
137 + /// Both ends, always. An interval is one answer, so a handler reading only the
138 + /// end that moved would drop the other bound every time either box was touched.
139 + fn narrow(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
140 + let key = request.captures.require("key")?;
141 + let axis = axis_named(state, key)?;
142 + let lower = submitted(&request, &lower_name(axis.key));
143 + let upper = submitted(&request, &upper_name(axis.key));
144 +
145 + // The sibling snap, which is the shipped helper's and stays a write. The
146 + // description carries the extent and never checks it, so an interval that
147 + // crosses is describable and is corrected here rather than refused: the user
148 + // dragged one end past the other and meant the axis to follow.
149 + let (lower, upper) = match (lower, upper) {
150 + (Some(low), Some(high)) if low > high => (Some(low), Some(low)),
151 + pair => pair,
152 + };
153 + // Back through the sentinel edges on the way out, so an end sitting on the
154 + // edge stores no bound at all. Same mapping `range_bounds` does, in the one
155 + // place the two spellings meet.
156 + state.filters.narrow(
157 + axis.key,
158 + lower.filter(|low| *low > axis.axis.lo),
159 + upper.filter(|high| *high < axis.axis.hi),
160 + );
161 + Ok(screen(state).into())
162 + }
163 +
164 + /// `POST /filters/axis/{key}/clear`
165 + fn widen(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
166 + let axis = axis_named(state, request.captures.require("key")?)?;
167 + state.filters.narrow(axis.key, None, None);
168 + Ok(screen(state).into())
169 + }
170 +
171 + /// `POST /filters/keys/mode`
172 + fn key_mode(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
173 + let compatible = request.payload.get("mode") == Some("compatible");
174 + state.filters.set_key_mode(compatible);
175 + Ok(screen(state).into())
176 + }
177 +
178 + /// `POST /filters/keys/{key}/toggle`
179 + fn toggle_key(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
180 + let key = request.captures.require("key")?;
181 + if !KEY_GROUPS.iter().any(|(_, keys)| keys.contains(&key)) {
182 + return Err(RouteError::not_found("no such key"));
183 + }
184 + state.filters.toggle_key(key);
185 + Ok(screen(state).into())
186 + }
187 +
188 + /// `POST /filters/keys/clear`
189 + fn clear_keys(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
190 + if state.filters.keys().wanted.is_empty() {
191 + return Err(RouteError::not_found("no key is wanted"));
192 + }
193 + state.filters.clear_keys();
194 + Ok(screen(state).into())
195 + }
196 +
197 + /// `POST /filters/tags/typing`
198 + fn typing(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
199 + state
200 + .filters
201 + .typed(request.payload.get("tag").unwrap_or_default());
202 + Ok(screen(state).into())
203 + }
204 +
205 + /// `POST /filters/tags/add`
206 + ///
207 + /// Refused where the tag is not one, which is the shipped path's own check
208 + /// (`validate_tag`) said the way a route says it. The shipped panel writes the
209 + /// complaint to the status line; a refusal puts it on the field it was typed
210 + /// into, which is where a refused value belongs.
211 + fn add_tag(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
212 + let tag = request.payload.get("tag").unwrap_or_default().trim();
213 + if tag.is_empty() {
214 + return Err(RouteError::not_found("nothing was typed"));
215 + }
216 + if audiofiles_core::tags::validate_tag(tag).is_err() {
217 + return Err(RouteError::not_found(format!("Invalid tag: {tag}")));
218 + }
219 + if state.filters.tags().iter().any(|held| held == tag) {
220 + return Err(RouteError::not_found("already required"));
221 + }
222 + state.filters.require(tag);
223 + // The box empties on the way through, which is the shipped path's own
224 + // behaviour and is the half a bare `require` would lose.
225 + state.filters.typed("");
226 + Ok(screen(state).into())
227 + }
228 +
229 + /// `POST /filters/tags/{tag}/remove`
230 + fn remove_tag(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
231 + let tag = request.captures.require("tag")?;
232 + if !state.filters.tags().iter().any(|held| *held == tag) {
233 + return Err(RouteError::not_found("not required"));
234 + }
235 + state.filters.unrequire(tag);
236 + Ok(screen(state).into())
237 + }
238 +
239 + /// `POST /filters/tags/clear`
240 + fn clear_tags(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
241 + if state.filters.tags().is_empty() {
242 + return Err(RouteError::not_found("no tag is required"));
243 + }
244 + state.filters.clear_tags();
245 + Ok(screen(state).into())
246 + }
247 +
248 + /// `POST /filters/clear`
249 + fn clear_all(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
250 + if !state.filters.active() {
251 + return Err(RouteError::not_found("nothing is filtering"));
252 + }
253 + state.filters.clear_all();
254 + Ok(screen(state).into())
255 + }
256 +
257 + /// `POST /filters/save`
258 + ///
259 + /// An empty name is the auto-name rather than a refusal, which is the shipped
260 + /// control's own behaviour: the box's ghost text *is* the name it would take.
261 + fn save(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
262 + if !state.filters.active() {
263 + return Err(RouteError::not_found("nothing is filtering"));
264 + }
265 + let typed = request.payload.get("name").unwrap_or_default().trim();
266 + let name = if typed.is_empty() {
267 + state.filters.describes()
268 + } else {
269 + typed.to_owned()
270 + };
271 + state.filters.save_collection(&name);
272 + Ok(screen(state).into())
273 + }
274 +
275 + /// The screen: the axes, the keys, the tags, and what matched.
276 + fn screen(state: &Panels<'_>) -> Screen {
277 + let mut pane = Screen::sidebar_content("Filters")
278 + .with(axes(state))
279 + .with(keys(state))
280 + .with(tags(state))
281 + .with(foot(state));
282 + if state.filters.active() {
283 + pane = pane.with(saving(state));
284 + }
285 + pane
286 + }
287 +
288 + /// The six numeric axes, one interval each.
289 + fn axes(state: &Panels<'_>) -> Slot {
290 + let mut band = Slot::new(AXES, RegionKind::Band).with(Node::section("Filters"));
291 + for narrowing in state.filters.axes() {
292 + band = band.with(axis_form(&narrowing));
293 + }
294 + band
295 + }
296 +
297 + /// One axis, as the question it is.
298 + ///
299 + /// A form rather than a bare field because the two ends submit together: the
300 + /// route needs both, and a `changes` per end would have it guessing at the one
301 + /// that did not move.
302 + fn axis_form(narrowing: &Narrowing) -> Node {
303 + let axis = narrowing.axis;
304 + let mut field = Field {
305 + min: Some(number(axis.lo, axis.decimals)),
306 + max: Some(number(axis.hi, axis.decimals)),
307 + step: Some(number(axis.speed, axis.decimals)),
308 + // The suffix carried its leading space because it was going straight
309 + // into a DragValue. The description writes the symbol alone and the
310 + // spacing is the renderer's.
311 + unit: unit(axis.suffix),
312 + hint: axis.hint.map(str::to_owned),
313 + width: Width::Fill,
314 + ..Field::interval(
315 + lower_name(narrowing.key),
316 + upper_name(narrowing.key),
317 + axis.title,
318 + )
319 + };
320 + if let Some(low) = narrowing.lower {
321 + field = field.value(number(low, axis.decimals));
322 + }
323 + if let Some(high) = narrowing.upper {
324 + field = field.upper_value(number(high, axis.decimals));
325 + }
326 +
327 + let mut region =
328 + Slot::new(format!("filters-axis-{}", narrowing.key), RegionKind::Group).with(Node::Form {
329 + action: Action::post(format!("/filters/axis/{}", narrowing.key)),
330 + submit: "Apply".into(),
331 + fields: vec![field],
332 + });
333 + // Offered only when the axis is narrowed, which is the shipped section's own
334 + // gate: a clear link on an axis nobody has touched is a control that does
335 + // nothing and says the axis is doing something.
336 + if narrowing.lower.is_some() || narrowing.upper.is_some() {
337 + region = region.with(Node::Act(Act::new(
338 + "Clear",
339 + Action::post(format!("/filters/axis/{}/clear", narrowing.key)),
340 + )));
341 + }
342 + Node::Region(region)
343 + }
344 +
345 + /// The keys wanted, and how they are matched.
346 + fn keys(state: &Panels<'_>) -> Slot {
347 + let held = state.filters.keys();
348 + let mut band = Slot::new(KEYS, RegionKind::Band).with(Node::section("Key"));
349 +
350 + // Two acts rather than a two-option field, because picking a matching rule
351 + // is not a value being submitted with anything: it takes effect as it is
352 + // pressed, which is what the shipped segmented control does.
353 + for (compatible, label, why) in [
354 + (false, "Exact", "Match only the keys chosen"),
355 + (
356 + true,
357 + "Compatible",
358 + "Include musically compatible keys, around the circle of fifths",
359 + ),
360 + ] {
361 + let mut act = Act::new(
362 + label,
363 + Action::post("/filters/keys/mode")
364 + .with("mode", if compatible { "compatible" } else { "exact" }),
365 + );
366 + if held.compatible == compatible {
367 + act = act.disabled();
368 + }
369 + band = band.with(Node::Act(act)).with(Node::text(why));
370 + }
371 +
372 + for (group, spellings) in KEY_GROUPS {
373 + band = band.with(Node::text(group));
374 + for spelling in spellings {
375 + // The pill reads as the note and stores the whole key, which is the
376 + // shipped row's own split: "C#" is what a producer scans for and
377 + // "C# minor" is what the query compares.
378 + let note = spelling.split(' ').next().unwrap_or(spelling);
379 + let wanted = held.wanted.iter().any(|held| held == spelling);
380 + band = band.with(Node::Token(
381 + Tag::chip(
382 + note,
383 + Action::post(format!("/filters/keys/{spelling}/toggle")),
384 + )
385 + // Latched rather than toned, which is what a chip has for
386 + // exactly this: the key is held down, not saying something.
387 + .latched(wanted),
388 + ));
389 + }
390 + }
391 +
392 + if !held.wanted.is_empty() {
393 + band = band.with(Node::Act(Act::new(
394 + "Clear keys",
395 + Action::post("/filters/keys/clear"),
396 + )));
397 + }
398 + band
399 + }
400 +
401 + /// The tags every result has to carry.
402 + fn tags(state: &Panels<'_>) -> Slot {
403 + let held = state.filters.tags();
404 + let mut band = Slot::new(TAGS, RegionKind::Band)
405 + .with(Node::section("Tags"))
406 + .with(Node::Form {
407 + action: Action::post("/filters/tags/add"),
408 + submit: "Add".into(),
409 + fields: vec![
410 + Field {
411 + placeholder: Some("Filter by tag".to_owned()),
412 + ..Field::new(FieldKind::Text, "tag", "Require a tag")
413 + }
414 + .value(state.filters.typing())
415 + .changes(Action::post("/filters/tags/typing")),
416 + ],
417 + });
418 +
419 + if held.is_empty() {
420 + return band.with(Node::empty("No tag is required."));
421 + }
422 + band = band.with(Node::list(held.iter().map(|tag| {
423 + Row::new(tag.clone()).offers(
424 + Act::new(
425 + "Remove",
426 + Action::post(format!("/filters/tags/{tag}/remove")),
427 + )
428 + .tone(Tone::Danger),
429 + )
430 + })));
431 + band.with(Node::Act(Act::new(
432 + "Clear tags",
433 + Action::post("/filters/tags/clear"),
434 + )))
435 + }
436 +
437 + /// What matched, and the way back to everything.
438 + fn foot(state: &Panels<'_>) -> Slot {
439 + let matched = state.filters.matched();
440 + let band = Slot::new(FOOT, RegionKind::Band).with(Node::text(format!(
441 + "{matched} sample{} match",
442 + if matched == 1 { "" } else { "s" }
443 + )));
444 + let mut clear = Act::new("Clear search and filters", Action::post("/filters/clear"));
445 + if state.filters.active() {
446 + clear = clear.confirm("Every filter category and the search query will be reset. Clear?");
447 + } else {
448 + clear = clear.disabled();
449 + }
450 + band.with(Node::Act(clear))
451 + }
452 +
453 + /// Keeping the current filters as a collection.
454 + fn saving(state: &Panels<'_>) -> Slot {
455 + Slot::new("filters-save", RegionKind::Group)
456 + .with(Node::section("Save as Collection"))
457 + .with(Node::text(
458 + "Keep the current filters as a dynamic collection.",
459 + ))
460 + .with(Node::Form {
461 + action: Action::post("/filters/save"),
462 + submit: "Save".into(),
463 + fields: vec![
464 + // The ghost text is the name it would take, which is the shipped
465 + // box's own arrangement rather than a placeholder standing in
466 + // for one: an empty name is not a refusal here, it is the
467 + // auto-name being accepted.
468 + Field {
469 + placeholder: Some(state.filters.describes()),
470 + ..Field::new(FieldKind::Text, "name", "Collection name")
471 + },
472 + ],
473 + })
474 + }
475 +
476 + /// The axis a request names.
477 + fn axis_named(state: &Panels<'_>, key: &str) -> Result<Narrowing, RouteError> {
478 + state
479 + .filters
480 + .axes()
481 + .into_iter()
482 + .find(|narrowing| narrowing.key == key)
483 + .ok_or_else(|| RouteError::not_found("no such axis"))
484 + }
485 +
486 + /// One end of an interval, as a number this screen can use.
487 + ///
488 + /// Absent where nothing was submitted and where what was submitted is not a
489 + /// number. Both are the same outcome on purpose: an end nobody filled and an
490 + /// end holding something unreadable are equally "no bound from this box", and
491 + /// refusing the second would put an error on a screen whose every control
492 + /// writes as it is touched.
493 + fn submitted(request: &Request, name: &str) -> Option<f64> {
494 + request.payload.get(name)?.trim().parse::<f64>().ok()
495 + }
496 +
497 + /// The name an axis's lower end submits under.
498 + fn lower_name(key: &str) -> String {
499 + format!("{key}_min")
500 + }
Lines truncated