max / audiofiles
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
7 files changed,
+1333 insertions,
-106 deletions
| @@ -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 ®ion.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