max / audiofiles
13 files changed,
+527 insertions,
-83 deletions
| @@ -7554,6 +7554,22 @@ | |||
| 7554 | 7554 | "winnow 1.0.4", | |
| 7555 | 7555 | ] | |
| 7556 | 7556 | ||
| 7557 | + | [[patch.unused]] | |
| 7558 | + | name = "kberg" | |
| 7559 | + | version = "0.1.0" | |
| 7560 | + | ||
| 7561 | + | [[patch.unused]] | |
| 7562 | + | name = "ops-status" | |
| 7563 | + | version = "0.1.0" | |
| 7564 | + | ||
| 7565 | + | [[patch.unused]] | |
| 7566 | + | name = "painhours" | |
| 7567 | + | version = "0.1.0" | |
| 7568 | + | ||
| 7569 | + | [[patch.unused]] | |
| 7570 | + | name = "quasi-type" | |
| 7571 | + | version = "0.1.0" | |
| 7572 | + | ||
| 7557 | 7573 | [[patch.unused]] | |
| 7558 | 7574 | name = "quasi-axum" | |
| 7559 | 7575 | version = "0.56.0" | |
| @@ -7581,19 +7597,3 @@ | |||
| 7581 | 7597 | [[patch.unused]] | |
| 7582 | 7598 | name = "quasi-webview" | |
| 7583 | 7599 | version = "0.56.0" | |
| 7584 | - | ||
| 7585 | - | [[patch.unused]] | |
| 7586 | - | name = "quasi-type" | |
| 7587 | - | version = "0.1.0" | |
| 7588 | - | ||
| 7589 | - | [[patch.unused]] | |
| 7590 | - | name = "kberg" | |
| 7591 | - | version = "0.1.0" | |
| 7592 | - | ||
| 7593 | - | [[patch.unused]] | |
| 7594 | - | name = "ops-status" | |
| 7595 | - | version = "0.1.0" | |
| 7596 | - | ||
| 7597 | - | [[patch.unused]] | |
| 7598 | - | name = "painhours" | |
| 7599 | - | version = "0.1.0" |
| @@ -435,12 +435,13 @@ | |||
| 435 | 435 | } | |
| 436 | 436 | } | |
| 437 | 437 | if input.key_pressed(egui::Key::Backspace) || input.key_pressed(egui::Key::ArrowLeft) { | |
| 438 | - | // Two-step Backspace: while in similarity / duplicate mode, the | |
| 439 | - | // first press exits the mode; a follow-up press then falls through | |
| 440 | - | // to "go up one folder." Matches the "Esc closes the dialog, then | |
| 441 | - | // Esc closes the parent" muscle memory. | |
| 442 | - | if state.search.similarity_search_hash.is_some() { | |
| 443 | - | state.clear_similarity_search(); | |
| 438 | + | // Step-at-a-time Backspace: while in similarity / duplicate mode, | |
| 439 | + | // each press walks one step back down the trail, and the press that | |
| 440 | + | // leaves the first step exits the mode; a follow-up press then falls | |
| 441 | + | // through to "go up one folder." Matches the "Esc closes the dialog, | |
| 442 | + | // then Esc closes the parent" muscle memory, one dialog per press. | |
| 443 | + | if state.search.in_similarity() { | |
| 444 | + | state.walk_back_one(); | |
| 444 | 445 | } else { | |
| 445 | 446 | state.go_up(); | |
| 446 | 447 | } |
| @@ -744,6 +744,8 @@ | |||
| 744 | 744 | GoTo(i64, usize), | |
| 745 | 745 | /// Leave whatever mode the list is in. | |
| 746 | 746 | Leave, | |
| 747 | + | /// Go back to this step of the similarity trail. | |
| 748 | + | WalkBack(usize), | |
| 747 | 749 | /// Switch to this vault. | |
| 748 | 750 | OpenVault(i64), | |
| 749 | 751 | /// Delete this vault and everything in it. | |
| @@ -2497,11 +2499,25 @@ | |||
| 2497 | 2499 | }, | |
| 2498 | 2500 | /// Samples that sound like one particular sample. | |
| 2499 | 2501 | Similar { | |
| 2500 | - | /// What that sample is called. | |
| 2501 | - | name: String, | |
| 2502 | + | /// The samples walked through to get here, oldest first. The last is | |
| 2503 | + | /// the one the visible results were computed from. | |
| 2504 | + | /// | |
| 2505 | + | /// A trail rather than a name, for [`Where::Folder`]'s reason: each | |
| 2506 | + | /// step is somewhere the reader can go back to, and where they are is | |
| 2507 | + | /// the end of it. | |
| 2508 | + | trail: Vec<Walked>, | |
| 2502 | 2509 | }, | |
| 2503 | 2510 | } | |
| 2504 | 2511 | ||
| 2512 | + | /// One step of a similarity walk. | |
| 2513 | + | #[derive(Debug, Clone, PartialEq, Eq)] | |
| 2514 | + | pub struct Walked { | |
| 2515 | + | /// What the sample asked about is called. | |
| 2516 | + | pub name: String, | |
| 2517 | + | /// Whether the step asked for near-duplicates rather than similar samples. | |
| 2518 | + | pub near_dup: bool, | |
| 2519 | + | } | |
| 2520 | + | ||
| 2505 | 2521 | /// What is being looked for. | |
| 2506 | 2522 | #[derive(Debug, Clone, PartialEq, Eq)] | |
| 2507 | 2523 | pub struct Searching { | |
| @@ -2656,6 +2672,9 @@ | |||
| 2656 | 2672 | ||
| 2657 | 2673 | /// Leave whatever mode the list is in. | |
| 2658 | 2674 | fn leave(&self); | |
| 2675 | + | ||
| 2676 | + | /// Go back to this step of the similarity trail, this far along it. | |
| 2677 | + | fn walk_back(&self, at: usize); | |
| 2659 | 2678 | } | |
| 2660 | 2679 | ||
| 2661 | 2680 | /// The app's toolbar, as the narrow thing a described screen borrows. | |
| @@ -2668,14 +2687,18 @@ | |||
| 2668 | 2687 | ||
| 2669 | 2688 | impl Bar for FromBar<'_> { | |
| 2670 | 2689 | fn place(&self) -> Where { | |
| 2671 | - | if self.state.search.similarity_search_hash.is_some() { | |
| 2690 | + | if self.state.search.in_similarity() { | |
| 2672 | 2691 | return Where::Similar { | |
| 2673 | - | name: self | |
| 2692 | + | trail: self | |
| 2674 | 2693 | .state | |
| 2675 | 2694 | .search | |
| 2676 | - | .similarity_source_name | |
| 2677 | - | .clone() | |
| 2678 | - | .unwrap_or_else(|| "sample".to_owned()), | |
| 2695 | + | .similarity_trail | |
| 2696 | + | .iter() | |
| 2697 | + | .map(|step| Walked { | |
| 2698 | + | name: step.name.clone().unwrap_or_else(|| "sample".to_owned()), | |
| 2699 | + | near_dup: step.near_dup, | |
| 2700 | + | }) | |
| 2701 | + | .collect(), | |
| 2679 | 2702 | }; | |
| 2680 | 2703 | } | |
| 2681 | 2704 | if let Some(active) = self.state.collections_ui.active_collection { | |
| @@ -2773,6 +2796,10 @@ | |||
| 2773 | 2796 | fn leave(&self) { | |
| 2774 | 2797 | self.push(Intent::Leave); | |
| 2775 | 2798 | } | |
| 2799 | + | ||
| 2800 | + | fn walk_back(&self, at: usize) { | |
| 2801 | + | self.push(Intent::WalkBack(at)); | |
| 2802 | + | } | |
| 2776 | 2803 | } | |
| 2777 | 2804 | ||
| 2778 | 2805 | impl FromBar<'_> { |
| @@ -864,12 +864,16 @@ | |||
| 864 | 864 | // thing to the user: go back to browsing. Which one is showing is | |
| 865 | 865 | // what `Where` already says. | |
| 866 | 866 | Intent::Leave => { | |
| 867 | - | if state.search.similarity_search_hash.is_some() { | |
| 867 | + | if state.search.in_similarity() { | |
| 868 | 868 | state.clear_similarity_search(); | |
| 869 | 869 | } else { | |
| 870 | 870 | state.deactivate_collection(); | |
| 871 | 871 | } | |
| 872 | 872 | } | |
| 873 | + | // Walking back is the app's, not the screen's: the step names a | |
| 874 | + | // sample and returning to it re-runs that sample's query, which is | |
| 875 | + | // the same call the trail was built out of. | |
| 876 | + | Intent::WalkBack(at) => state.walk_back_to(at), | |
| 873 | 877 | // The sidebar. Two of these hand an already-agreed decision to the | |
| 874 | 878 | // app's own executor: the described control asked with | |
| 875 | 879 | // `Act::confirm`, the runtime answered `Step::Ask`, the user said |
| @@ -19,7 +19,7 @@ | |||
| 19 | 19 | Narrowing, Order, Panel, Panels, Phase, Playing, Preflight, Pricing, ProfileChoice, Queue, | |
| 20 | 20 | Queued, Reviewed, Sample, Saying, Scope, Searching, Setting, Settings, Shared, Shell, Source, | |
| 21 | 21 | Spread, Stage, State, Status, Strategy, Subject, Subscription, Suggested, Suggestion, Sweep, | |
| 22 | - | Sync, Tagged, ThemeChoice, Vault, VaultChoice, Where, router, | |
| 22 | + | Sync, Tagged, ThemeChoice, Vault, VaultChoice, Walked, Where, router, | |
| 23 | 23 | }; | |
| 24 | 24 | ||
| 25 | 25 | /// A config store in memory. | |
| @@ -4613,6 +4613,7 @@ | |||
| 4613 | 4613 | fn go_root(&self) {} | |
| 4614 | 4614 | fn go_to(&self, _id: i64, _depth: usize) {} | |
| 4615 | 4615 | fn leave(&self) {} | |
| 4616 | + | fn walk_back(&self, _at: usize) {} | |
| 4616 | 4617 | } | |
| 4617 | 4618 | ||
| 4618 | 4619 | /// A toolbar in memory, recording what was asked of it. | |
| @@ -4727,6 +4728,10 @@ | |||
| 4727 | 4728 | fn leave(&self) { | |
| 4728 | 4729 | self.note("leave"); | |
| 4729 | 4730 | } | |
| 4731 | + | ||
| 4732 | + | fn walk_back(&self, at: usize) { | |
| 4733 | + | self.note(format!("walk back to {at}")); | |
| 4734 | + | } | |
| 4730 | 4735 | } | |
| 4731 | 4736 | ||
| 4732 | 4737 | /// A router call against this toolbar. | |
| @@ -4829,7 +4834,10 @@ | |||
| 4829 | 4834 | name: "Favourites".to_owned(), | |
| 4830 | 4835 | }, | |
| 4831 | 4836 | Where::Similar { | |
| 4832 | - | name: "kick.wav".to_owned(), | |
| 4837 | + | trail: vec![Walked { | |
| 4838 | + | name: "kick.wav".to_owned(), | |
| 4839 | + | near_dup: false, | |
| 4840 | + | }], | |
| 4833 | 4841 | }, | |
| 4834 | 4842 | ] { | |
| 4835 | 4843 | let bar = FakeBar::at(place); | |
| @@ -4845,19 +4853,104 @@ | |||
| 4845 | 4853 | // One control for both, because leaving either means the same thing to the | |
| 4846 | 4854 | // user; which mode is showing is what `Where` already says. | |
| 4847 | 4855 | let bar = FakeBar::at(Where::Similar { | |
| 4848 | - | name: "kick.wav".to_owned(), | |
| 4856 | + | trail: vec![Walked { | |
| 4857 | + | name: "kick.wav".to_owned(), | |
| 4858 | + | near_dup: false, | |
| 4859 | + | }], | |
| 4849 | 4860 | }); | |
| 4850 | 4861 | barred(&bar, Request::post("/here/leave")).unwrap(); | |
| 4851 | 4862 | assert_eq!(bar.asked(), ["leave"]); | |
| 4852 | 4863 | } | |
| 4853 | 4864 | ||
| 4865 | + | #[test] | |
| 4866 | + | fn a_similarity_trail_is_links_back_to_every_sample_walked_through() { | |
| 4867 | + | // The same distinction the folder crumbs make: a step you can return to is | |
| 4868 | + | // a link, and the step you are on is where you are, so it is prose. | |
| 4869 | + | let bar = FakeBar::at(Where::Similar { | |
| 4870 | + | trail: vec![ | |
| 4871 | + | Walked { | |
| 4872 | + | name: "kick.wav".to_owned(), | |
| 4873 | + | near_dup: false, | |
| 4874 | + | }, | |
| 4875 | + | Walked { | |
| 4876 | + | name: "snare.wav".to_owned(), | |
| 4877 | + | near_dup: false, | |
| 4878 | + | }, | |
| 4879 | + | Walked { | |
| 4880 | + | name: "clap.wav".to_owned(), | |
| 4881 | + | near_dup: false, | |
| 4882 | + | }, | |
| 4883 | + | ], | |
| 4884 | + | }); | |
| 4885 | + | let screen = topped(&bar); | |
| 4886 | + | ||
| 4887 | + | assert_eq!( | |
| 4888 | + | links(&screen), | |
| 4889 | + | [ | |
| 4890 | + | ( | |
| 4891 | + | "Similar to: kick.wav".to_owned(), | |
| 4892 | + | "/here/similar/0".to_owned() | |
| 4893 | + | ), | |
| 4894 | + | ("snare.wav".to_owned(), "/here/similar/1".to_owned()), | |
| 4895 | + | ] | |
| 4896 | + | ); | |
| 4897 | + | assert!( | |
| 4898 | + | said(&screen).contains("clap.wav"), | |
| 4899 | + | "where you are is not a link" | |
| 4900 | + | ); | |
| 4901 | + | // Walking is not leaving: the way out of the mode is still offered. | |
| 4902 | + | assert!( | |
| 4903 | + | acts(&screen) | |
| 4904 | + | .iter() | |
| 4905 | + | .any(|label| label == "Back to browsing") | |
| 4906 | + | ); | |
| 4907 | + | } | |
| 4908 | + | ||
| 4909 | + | #[test] | |
| 4910 | + | fn the_first_step_says_which_question_was_asked() { | |
| 4911 | + | // Two searches share the view and the trail. Which one this walk started | |
| 4912 | + | // as is the reader's only clue why these rows are the rows. | |
| 4913 | + | let bar = FakeBar::at(Where::Similar { | |
| 4914 | + | trail: vec![Walked { | |
| 4915 | + | name: "kick.wav".to_owned(), | |
| 4916 | + | near_dup: true, | |
| 4917 | + | }], | |
| 4918 | + | }); | |
| 4919 | + | assert!(said(&topped(&bar)).contains("Duplicates of: kick.wav")); | |
| 4920 | + | } | |
| 4921 | + | ||
| 4922 | + | #[test] | |
| 4923 | + | fn a_step_is_asked_for_by_how_far_along_it_is() { | |
| 4924 | + | // A position in this walk, not an id: the sample a step asked about is | |
| 4925 | + | // what the app already holds there. | |
| 4926 | + | let bar = FakeBar::at(Where::Similar { | |
| 4927 | + | trail: vec![ | |
| 4928 | + | Walked { | |
| 4929 | + | name: "kick.wav".to_owned(), | |
| 4930 | + | near_dup: false, | |
| 4931 | + | }, | |
| 4932 | + | Walked { | |
| 4933 | + | name: "snare.wav".to_owned(), | |
| 4934 | + | near_dup: false, | |
| 4935 | + | }, | |
| 4936 | + | ], | |
| 4937 | + | }); | |
| 4938 | + | barred(&bar, Request::post("/here/similar/0")).unwrap(); | |
| 4939 | + | assert_eq!(bar.asked(), ["walk back to 0"]); | |
| 4940 | + | ||
| 4941 | + | assert!(barred(&bar, Request::post("/here/similar/first")).is_err()); | |
| 4942 | + | } | |
| 4943 | + | ||
| 4854 | 4944 | #[test] | |
| 4855 | 4945 | fn the_similarity_mode_says_why_the_columns_stopped_sorting() { | |
| 4856 | 4946 | // The shipped breadcrumb moved this off the column headings, "where the | |
| 4857 | 4947 | // explanation lived on a control the user had no reason to point at". Here | |
| 4858 | 4948 | // it is prose beside the mode, which is where the mode is. | |
| 4859 | 4949 | let bar = FakeBar::at(Where::Similar { | |
| 4860 | - | name: "kick.wav".to_owned(), | |
| 4950 | + | trail: vec![Walked { | |
| 4951 | + | name: "kick.wav".to_owned(), | |
| 4952 | + | near_dup: false, | |
| 4953 | + | }], | |
| 4861 | 4954 | }); | |
| 4862 | 4955 | assert!(said(&topped(&bar)).contains("ranked by similarity")); | |
| 4863 | 4956 | } |
| @@ -116,6 +116,7 @@ | |||
| 116 | 116 | .post("/here/root", root) | |
| 117 | 117 | .post("/here/{id}/{depth}", go) | |
| 118 | 118 | .post("/here/leave", leave) | |
| 119 | + | .post("/here/similar/{at}", walk_back) | |
| 119 | 120 | } | |
| 120 | 121 | ||
| 121 | 122 | /// `POST /search` | |
| @@ -235,6 +236,22 @@ | |||
| 235 | 236 | Ok(super::shell::screen(state).into()) | |
| 236 | 237 | } | |
| 237 | 238 | ||
| 239 | + | /// `POST /here/similar/{at}` | |
| 240 | + | /// | |
| 241 | + | /// How far along the trail, and nothing else. A folder crumb carries an id as | |
| 242 | + | /// well because a folder is a thing that exists whether or not you walked to | |
| 243 | + | /// it; a similarity step is only ever a position in this walk, and the sample | |
| 244 | + | /// it asked about is what the app already holds there. | |
| 245 | + | fn walk_back(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> { | |
| 246 | + | let at: usize = request | |
| 247 | + | .captures | |
| 248 | + | .require("at")? | |
| 249 | + | .parse() | |
| 250 | + | .map_err(|_| RouteError::not_found("no such place in the trail"))?; | |
| 251 | + | state.bar.walk_back(at); | |
| 252 | + | Ok(super::shell::screen(state).into()) | |
| 253 | + | } | |
| 254 | + | ||
| 238 | 255 | /// The toolbar, as a region something else holds. | |
| 239 | 256 | pub fn body(state: &Panels<'_>) -> Slot { | |
| 240 | 257 | let bar = Slot::new(BAR, RegionKind::Band); | |
| @@ -282,10 +299,44 @@ | |||
| 282 | 299 | Where::Collection { name } => { | |
| 283 | 300 | leaving(bar, format!("Collection: {name}"), "Back to browsing") | |
| 284 | 301 | } | |
| 285 | - | Where::Similar { name } => leaving(bar, format!("Similar to: {name}"), "Back to browsing") | |
| 302 | + | // A mode, and a trail inside it. The steps are links for the folder | |
| 303 | + | // crumbs' reason and the way out is an act for the collection's, so | |
| 304 | + | // this is the one place where both are true at once: you can go back | |
| 305 | + | // one sample, or out of walking altogether. | |
| 306 | + | Where::Similar { trail } => { | |
| 307 | + | let last = trail.len().saturating_sub(1); | |
| 308 | + | let mut bar = bar; | |
| 309 | + | for (at, step) in trail.iter().enumerate() { | |
| 310 | + | let says = if at == 0 { | |
| 311 | + | let asked = if step.near_dup { | |
| 312 | + | "Duplicates of" | |
| 313 | + | } else { | |
| 314 | + | "Similar to" | |
| 315 | + | }; | |
| 316 | + | format!("{asked}: {}", step.name) | |
| 317 | + | } else { | |
| 318 | + | step.name.clone() | |
| 319 | + | }; | |
| 320 | + | if at == last { | |
| 321 | + | bar = bar.with(Node::Text { | |
| 322 | + | text: says, | |
| 323 | + | tone: Tone::Info, | |
| 324 | + | }); | |
| 325 | + | } else { | |
| 326 | + | bar = bar.with(Node::Link { | |
| 327 | + | text: says, | |
| 328 | + | action: Action::post(format!("/here/similar/{at}")), | |
| 329 | + | }); | |
| 330 | + | } | |
| 331 | + | } | |
| 332 | + | bar.with(Node::Act(Act::new( | |
| 333 | + | "Back to browsing", | |
| 334 | + | Action::post("/here/leave"), | |
| 335 | + | ))) | |
| 286 | 336 | .with(Node::text( | |
| 287 | 337 | "Results are ranked by similarity, so column sort is off.", | |
| 288 | - | )), | |
| 338 | + | )) | |
| 339 | + | } | |
| 289 | 340 | } | |
| 290 | 341 | } | |
| 291 | 342 |
| @@ -1,8 +1,8 @@ | |||
| 1 | 1 | //! Library state: smart folders, collections, similarity search, refresh helpers, mirror. | |
| 2 | 2 | ||
| 3 | 3 | use super::{ | |
| 4 | - | Arc, BrowserState, CollectionId, ColumnConfig, ContentsRef, PathBuf, SearchFilter, SortColumn, | |
| 5 | - | SortDirection, error, warn, | |
| 4 | + | Arc, BrowserState, CollectionId, ColumnConfig, ContentsRef, PathBuf, SearchFilter, SimilarStep, | |
| 5 | + | SortColumn, SortDirection, error, warn, | |
| 6 | 6 | }; | |
| 7 | 7 | use crate::backend::{BackendError, BackendResult}; | |
| 8 | 8 | ||
| @@ -265,8 +265,7 @@ | |||
| 265 | 265 | self.nav.current_dir = None; // a filter view has no parent ".." row | |
| 266 | 266 | self.search.search_filter = filter.clone(); | |
| 267 | 267 | self.search.search_query.clone_from(&filter.text_query); | |
| 268 | - | self.search.similarity_search_hash = None; | |
| 269 | - | self.search.similarity_source_name = None; | |
| 268 | + | self.search.similarity_trail.clear(); | |
| 270 | 269 | self.nav.selection.clear(); | |
| 271 | 270 | self.refresh_contents(); | |
| 272 | 271 | } | |
| @@ -303,8 +302,7 @@ | |||
| 303 | 302 | self.nav.contents = ContentsRef::new(nodes); | |
| 304 | 303 | self.collections_ui.active_collection = Some(id); | |
| 305 | 304 | self.nav.current_dir = None; // a collection view has no parent ".." row | |
| 306 | - | self.search.similarity_search_hash = None; | |
| 307 | - | self.search.similarity_source_name = None; | |
| 305 | + | self.search.similarity_trail.clear(); | |
| 308 | 306 | self.nav.selection.clear(); | |
| 309 | 307 | self.status = format!("{count} samples in collection"); | |
| 310 | 308 | } | |
| @@ -386,17 +384,72 @@ | |||
| 386 | 384 | // The in-flight request resolved, clear it so the repaint-while-busy | |
| 387 | 385 | // guard stops and a later search starts from a clean slate. | |
| 388 | 386 | self.search.latest_similarity_request = None; | |
| 389 | - | self.search.similarity_search_hash = Some(source_hash.to_string()); | |
| 390 | - | self.search.similarity_is_near_dup = is_near_dup; | |
| 391 | - | self.search.similarity_source_name = self.backend.sample_original_name(source_hash).ok(); | |
| 387 | + | let name = self.backend.sample_original_name(source_hash).ok(); | |
| 388 | + | self.record_similarity_step(source_hash, name, is_near_dup); | |
| 392 | 389 | self.nav.selection.clear(); | |
| 393 | 390 | self.status = format!("Found {count} {noun}"); | |
| 394 | 391 | } | |
| 395 | 392 | ||
| 393 | + | /// Where the results that just landed belong on the trail. | |
| 394 | + | /// | |
| 395 | + | /// Three cases, and only one of them grows it. A result for the step | |
| 396 | + | /// already on top is `refresh_contents` re-running the same query after a | |
| 397 | + | /// delete or a move, so it replaces that step rather than repeating it. A | |
| 398 | + | /// result for a step further down is the reader walking back, so the trail | |
| 399 | + | /// truncates to it and the steps after it are gone. Anything else is a new | |
| 400 | + | /// sample asked about from the current one, which is a step forward. | |
| 401 | + | fn record_similarity_step(&mut self, hash: &str, name: Option<String>, near_dup: bool) { | |
| 402 | + | let step = SimilarStep { | |
| 403 | + | hash: hash.to_string(), | |
| 404 | + | name, | |
| 405 | + | near_dup, | |
| 406 | + | }; | |
| 407 | + | let existing = self | |
| 408 | + | .search | |
| 409 | + | .similarity_trail | |
| 410 | + | .iter() | |
| 411 | + | .position(|walked| walked.hash == step.hash && walked.near_dup == step.near_dup); | |
| 412 | + | match existing { | |
| 413 | + | Some(at) => { | |
| 414 | + | self.search.similarity_trail.truncate(at + 1); | |
| 415 | + | self.search.similarity_trail[at] = step; | |
| 416 | + | } | |
| 417 | + | None => self.search.similarity_trail.push(step), | |
| 418 | + | } | |
| 419 | + | } | |
| 420 | + | ||
| 421 | + | /// Re-run the step at `at`, dropping everything walked after it. | |
| 422 | + | /// | |
| 423 | + | /// The query is re-run rather than the rows cached: results are ranked | |
| 424 | + | /// against the library as it is now, and a step returned to after a delete | |
| 425 | + | /// should not show what was deleted. `record_similarity_step` does the | |
| 426 | + | /// truncation when the results land, so a step that fails to start leaves | |
| 427 | + | /// the trail alone. | |
| 428 | + | pub fn walk_back_to(&mut self, at: usize) { | |
| 429 | + | let Some(step) = self.search.similarity_trail.get(at).cloned() else { | |
| 430 | + | return; | |
| 431 | + | }; | |
| 432 | + | if step.near_dup { | |
| 433 | + | self.find_near_duplicates(&step.hash); | |
| 434 | + | } else { | |
| 435 | + | self.find_similar(&step.hash); | |
| 436 | + | } | |
| 437 | + | } | |
| 438 | + | ||
| 439 | + | /// Drop the newest step and return to the one before it. Leaves similarity | |
| 440 | + | /// mode when the trail had one step, which is what the reader means by | |
| 441 | + | /// backing out of the only search they ran. | |
| 442 | + | pub fn walk_back_one(&mut self) { | |
| 443 | + | match self.search.similarity_trail.len() { | |
| 444 | + | 0 => {} | |
| 445 | + | 1 => self.clear_similarity_search(), | |
| 446 | + | len => self.walk_back_to(len - 2), | |
| 447 | + | } | |
| 448 | + | } | |
| 449 | + | ||
| 396 | 450 | /// Clear similarity search mode and return to normal browsing. | |
| 397 | 451 | pub fn clear_similarity_search(&mut self) { | |
| 398 | - | self.search.similarity_search_hash = None; | |
| 399 | - | self.search.similarity_source_name = None; | |
| 452 | + | self.search.similarity_trail.clear(); | |
| 400 | 453 | self.search.latest_similarity_request = None; | |
| 401 | 454 | self.refresh_contents(); | |
| 402 | 455 | } |
| @@ -46,11 +46,11 @@ | |||
| 46 | 46 | // stale rows until the user exits the view, so re-run the same query; the | |
| 47 | 47 | // fresh results replace the view via apply_similarity_results. (Previously | |
| 48 | 48 | // this early-returned and did nothing, which was the staleness bug.) | |
| 49 | - | if let Some(hash) = self.search.similarity_search_hash.clone() { | |
| 50 | - | if self.search.similarity_is_near_dup { | |
| 51 | - | self.find_near_duplicates(&hash); | |
| 49 | + | if let Some(step) = self.search.similarity_anchor().cloned() { | |
| 50 | + | if step.near_dup { | |
| 51 | + | self.find_near_duplicates(&step.hash); | |
| 52 | 52 | } else { | |
| 53 | - | self.find_similar(&hash); | |
| 53 | + | self.find_similar(&step.hash); | |
| 54 | 54 | } | |
| 55 | 55 | return; | |
| 56 | 56 | } | |
| @@ -262,8 +262,7 @@ | |||
| 262 | 262 | ||
| 263 | 263 | /// Navigate into the selected directory, or go up if ".." is selected. | |
| 264 | 264 | pub fn enter_directory(&mut self) { | |
| 265 | - | self.search.similarity_search_hash = None; | |
| 266 | - | self.search.similarity_source_name = None; | |
| 265 | + | self.search.similarity_trail.clear(); | |
| 267 | 266 | ||
| 268 | 267 | if self.has_parent_entry() && self.nav.selection.focus == 0 { | |
| 269 | 268 | self.go_up(); | |
| @@ -289,8 +288,7 @@ | |||
| 289 | 288 | /// Navigate to the parent directory, or do nothing if already at root. | |
| 290 | 289 | /// If a collection is active, exits collection view first. | |
| 291 | 290 | pub fn go_up(&mut self) { | |
| 292 | - | self.search.similarity_search_hash = None; | |
| 293 | - | self.search.similarity_source_name = None; | |
| 291 | + | self.search.similarity_trail.clear(); | |
| 294 | 292 | if self.collections_ui.active_collection.is_some() { | |
| 295 | 293 | self.deactivate_collection(); | |
| 296 | 294 | return; | |
| @@ -322,8 +320,7 @@ | |||
| 322 | 320 | self.nav.current_dir = None; | |
| 323 | 321 | self.nav.breadcrumb.clear(); | |
| 324 | 322 | self.nav.selection.clear(); | |
| 325 | - | self.search.similarity_search_hash = None; | |
| 326 | - | self.search.similarity_source_name = None; | |
| 323 | + | self.search.similarity_trail.clear(); | |
| 327 | 324 | // Persist the selection by VFS id (not index, indices shift when | |
| 328 | 325 | // vaults are added or removed) so it restores on next launch. | |
| 329 | 326 | super::log_backend_err( |
| @@ -3650,3 +3650,159 @@ | |||
| 3650 | 3650 | ); | |
| 3651 | 3651 | } | |
| 3652 | 3652 | } | |
| 3653 | + | ||
| 3654 | + | /// The similarity trail: walking through neighbours keeps every anchor. | |
| 3655 | + | mod similarity_trail { | |
| 3656 | + | use super::*; | |
| 3657 | + | ||
| 3658 | + | /// Land a similarity result for `hash`, the way the search worker would. | |
| 3659 | + | /// The request has to match or the result reads as stale and is dropped, | |
| 3660 | + | /// which is `apply_similarity_results`' own guard. | |
| 3661 | + | fn results_for(state: &mut BrowserState, hash: &str, near_dup: bool) { | |
| 3662 | + | state.search.latest_similarity_request = Some((hash.to_string(), near_dup)); | |
| 3663 | + | let noun = if near_dup { | |
| 3664 | + | "near-duplicates" | |
| 3665 | + | } else { | |
| 3666 | + | "similar samples" | |
| 3667 | + | }; | |
| 3668 | + | state.apply_similarity_results(hash, &[], noun, near_dup); | |
| 3669 | + | } | |
| 3670 | + | ||
| 3671 | + | fn walked(state: &BrowserState) -> Vec<String> { | |
| 3672 | + | state | |
| 3673 | + | .search | |
| 3674 | + | .similarity_trail | |
| 3675 | + | .iter() | |
| 3676 | + | .map(|step| step.hash.clone()) | |
| 3677 | + | .collect() | |
| 3678 | + | } | |
| 3679 | + | ||
| 3680 | + | #[test] | |
| 3681 | + | fn each_sample_asked_about_is_another_step() { | |
| 3682 | + | let (mut state, _dir) = make_state(); | |
| 3683 | + | add_sample_to_vfs(&state, "aaa", "a.wav"); | |
| 3684 | + | add_sample_to_vfs(&state, "bbb", "b.wav"); | |
| 3685 | + | ||
| 3686 | + | results_for(&mut state, "aaa", false); | |
| 3687 | + | results_for(&mut state, "bbb", false); | |
| 3688 | + | ||
| 3689 | + | assert_eq!(walked(&state), ["aaa", "bbb"]); | |
| 3690 | + | assert_eq!( | |
| 3691 | + | state | |
| 3692 | + | .search | |
| 3693 | + | .similarity_anchor() | |
| 3694 | + | .map(|step| step.hash.as_str()), | |
| 3695 | + | Some("bbb"), | |
| 3696 | + | "the anchor is the top of the stack" | |
| 3697 | + | ); | |
| 3698 | + | } | |
| 3699 | + | ||
| 3700 | + | #[test] | |
| 3701 | + | fn re_running_the_current_anchor_replaces_it_rather_than_repeating_it() { | |
| 3702 | + | // What `refresh_contents` does after a delete or a move inside the | |
| 3703 | + | // view. It is the same step, so the trail must not grow a rung every | |
| 3704 | + | // time a row is removed. | |
| 3705 | + | let (mut state, _dir) = make_state(); | |
| 3706 | + | add_sample_to_vfs(&state, "aaa", "a.wav"); | |
| 3707 | + | ||
| 3708 | + | results_for(&mut state, "aaa", false); | |
| 3709 | + | results_for(&mut state, "aaa", false); | |
| 3710 | + | ||
| 3711 | + | assert_eq!(walked(&state), ["aaa"]); | |
| 3712 | + | } | |
| 3713 | + | ||
| 3714 | + | #[test] | |
| 3715 | + | fn walking_back_to_a_step_drops_the_ones_after_it() { | |
| 3716 | + | let (mut state, _dir) = make_state(); | |
| 3717 | + | for hash in ["aaa", "bbb", "ccc"] { | |
| 3718 | + | add_sample_to_vfs(&state, hash, &format!("{hash}.wav")); | |
| 3719 | + | results_for(&mut state, hash, false); | |
| 3720 | + | } | |
| 3721 | + | assert_eq!(walked(&state), ["aaa", "bbb", "ccc"]); | |
| 3722 | + | ||
| 3723 | + | // Returning to the first step re-runs its query; when those results | |
| 3724 | + | // land, the two steps walked after it are gone. | |
| 3725 | + | results_for(&mut state, "aaa", false); | |
| 3726 | + | assert_eq!(walked(&state), ["aaa"]); | |
| 3727 | + | } | |
| 3728 | + | ||
| 3729 | + | #[test] | |
| 3730 | + | fn the_same_sample_asked_two_ways_is_two_steps() { | |
| 3731 | + | // The kind travels with the step because the queries differ. Walking | |
| 3732 | + | // back into a near-duplicate step must not re-run it as a feature | |
| 3733 | + | // search, so the two cannot collapse into one rung. | |
| 3734 | + | let (mut state, _dir) = make_state(); | |
| 3735 | + | add_sample_to_vfs(&state, "aaa", "a.wav"); | |
| 3736 | + | ||
| 3737 | + | results_for(&mut state, "aaa", false); | |
| 3738 | + | results_for(&mut state, "aaa", true); | |
| 3739 | + | ||
| 3740 | + | assert_eq!(walked(&state), ["aaa", "aaa"]); | |
| 3741 | + | assert!(state.search.similarity_anchor().unwrap().near_dup); | |
| 3742 | + | } | |
| 3743 | + | ||
| 3744 | + | #[test] | |
| 3745 | + | fn a_step_carries_the_name_the_breadcrumb_shows() { | |
| 3746 | + | let (mut state, _dir) = make_state(); | |
| 3747 | + | add_sample_to_vfs(&state, "aaa", "a.wav"); | |
| 3748 | + | ||
| 3749 | + | results_for(&mut state, "aaa", false); | |
| 3750 | + | ||
| 3751 | + | assert_eq!( | |
| 3752 | + | state.search.similarity_anchor().unwrap().name.as_deref(), | |
| 3753 | + | Some("aaa.wav"), | |
| 3754 | + | "resolved once, when the results landed, not per frame" | |
| 3755 | + | ); | |
| 3756 | + | } | |
| 3757 | + | ||
| 3758 | + | #[test] | |
| 3759 | + | fn backing_out_of_the_only_step_leaves_the_mode() { | |
| 3760 | + | let (mut state, _dir) = make_state(); | |
| 3761 | + | add_sample_to_vfs(&state, "aaa", "a.wav"); | |
| 3762 | + | results_for(&mut state, "aaa", false); | |
| 3763 | + | ||
| 3764 | + | state.walk_back_one(); | |
| 3765 | + | ||
| 3766 | + | assert!(!state.search.in_similarity()); | |
| 3767 | + | assert!(state.search.latest_similarity_request.is_none()); | |
| 3768 | + | } | |
| 3769 | + | ||
| 3770 | + | #[test] | |
| 3771 | + | fn leaving_similarity_forgets_the_walk() { | |
| 3772 | + | let (mut state, _dir) = make_state(); | |
| 3773 | + | add_sample_to_vfs(&state, "aaa", "a.wav"); | |
| 3774 | + | add_sample_to_vfs(&state, "bbb", "b.wav"); | |
| 3775 | + | results_for(&mut state, "aaa", false); | |
| 3776 | + | results_for(&mut state, "bbb", false); | |
| 3777 | + | ||
| 3778 | + | state.clear_similarity_search(); | |
| 3779 | + | ||
| 3780 | + | assert!( | |
| 3781 | + | walked(&state).is_empty(), | |
| 3782 | + | "a trail kept invisibly is a trap" | |
| 3783 | + | ); | |
| 3784 | + | } | |
| 3785 | + | ||
| 3786 | + | #[test] | |
| 3787 | + | fn browsing_away_forgets_the_walk() { | |
| 3788 | + | // Every way out of the mode clears it, not just the Clear control. | |
| 3789 | + | let (mut state, _dir) = make_state(); | |
| 3790 | + | add_sample_to_vfs(&state, "aaa", "a.wav"); | |
| 3791 | + | results_for(&mut state, "aaa", false); | |
| 3792 | + | ||
| 3793 | + | state.go_up(); | |
| 3794 | + | ||
| 3795 | + | assert!(walked(&state).is_empty()); | |
| 3796 | + | } | |
| 3797 | + | ||
| 3798 | + | #[test] | |
| 3799 | + | fn a_step_past_the_end_of_the_trail_does_nothing() { | |
| 3800 | + | let (mut state, _dir) = make_state(); | |
| 3801 | + | add_sample_to_vfs(&state, "aaa", "a.wav"); | |
| 3802 | + | results_for(&mut state, "aaa", false); | |
| 3803 | + | ||
| 3804 | + | state.walk_back_to(9); | |
| 3805 | + | ||
| 3806 | + | assert_eq!(walked(&state), ["aaa"], "and it does not panic"); | |
| 3807 | + | } | |
| 3808 | + | } |
| @@ -578,6 +578,24 @@ | |||
| 578 | 578 | pub tag_folders_apply_all_input: String, | |
| 579 | 579 | } | |
| 580 | 580 | ||
| 581 | + | /// One step of the similarity trail: a sample that was asked about, and what | |
| 582 | + | /// kind of question it was. | |
| 583 | + | /// | |
| 584 | + | /// The kind travels with the step because the two searches are different | |
| 585 | + | /// queries over the same anchor. Walking back into a near-duplicate step and | |
| 586 | + | /// re-running it as a feature search would answer a question nobody asked. | |
| 587 | + | #[derive(Debug, Clone, PartialEq, Eq)] | |
| 588 | + | pub struct SimilarStep { | |
| 589 | + | /// Content hash of the sample the results were computed from. | |
| 590 | + | pub hash: String, | |
| 591 | + | /// Display name, resolved when the results landed. `None` when the sample | |
| 592 | + | /// has no stored original name, which the breadcrumb renders as "sample". | |
| 593 | + | pub name: Option<String>, | |
| 594 | + | /// Whether this step was a near-duplicate search rather than a feature | |
| 595 | + | /// similarity search. | |
| 596 | + | pub near_dup: bool, | |
| 597 | + | } | |
| 598 | + | ||
| 581 | 599 | /// Search bar, filter panel, and similarity-search state. | |
| 582 | 600 | #[derive(Default)] | |
| 583 | 601 | pub struct SearchUiState { | |
| @@ -592,23 +610,36 @@ | |||
| 592 | 610 | /// add tag filters from inside the filter panel itself (M-5 closed the | |
| 593 | 611 | /// add/remove asymmetry, tag chips already had a remove X, but no entry). | |
| 594 | 612 | pub filter_tag_input: String, | |
| 595 | - | pub similarity_search_hash: Option<String>, | |
| 613 | + | /// The samples walked through to get to the current results, oldest first. | |
| 614 | + | /// Empty means normal browsing; the last entry is the anchor the visible | |
| 615 | + | /// results were computed from. | |
| 616 | + | /// | |
| 617 | + | /// A stack rather than one anchor because walking is what the view is for: | |
| 618 | + | /// a sample's neighbours suggest the next sample, and three steps in there | |
| 619 | + | /// was no way back to the second. Every read of "the current similarity | |
| 620 | + | /// search" is [`Self::similarity_anchor`], the top of this stack. | |
| 621 | + | pub similarity_trail: Vec<SimilarStep>, | |
| 596 | 622 | /// The most recent similarity request as `(source_hash, is_near_dup)`. Async | |
| 597 | 623 | /// results that don't match it are stale (the user fired another search | |
| 598 | 624 | /// first) and are dropped, so a slow query can't replace the current view. | |
| 599 | 625 | pub latest_similarity_request: Option<(String, bool)>, | |
| 600 | - | /// Whether the active similarity view is a near-duplicate search (vs. a | |
| 601 | - | /// feature-similarity search). Persisted so `refresh_contents` can re-run the | |
| 602 | - | /// correct query when the view is mutated (delete/move) in similarity mode. | |
| 603 | - | pub similarity_is_near_dup: bool, | |
| 604 | - | /// Display name of the source sample for the active similarity / duplicate | |
| 605 | - | /// search. Cached so the breadcrumb can render "Similar to: <name>" without | |
| 606 | - | /// a backend lookup on every frame. | |
| 607 | - | pub similarity_source_name: Option<String>, | |
| 608 | 626 | /// Sidebar tag tree filter input. | |
| 609 | 627 | pub tag_search: String, | |
| 610 | 628 | } | |
| 611 | 629 | ||
| 630 | + | impl SearchUiState { | |
| 631 | + | /// The sample the visible results were computed from, or `None` when the | |
| 632 | + | /// list is an ordinary folder, search or collection view. | |
| 633 | + | pub fn similarity_anchor(&self) -> Option<&SimilarStep> { | |
| 634 | + | self.similarity_trail.last() | |
| 635 | + | } | |
| 636 | + | ||
| 637 | + | /// Whether the file list is showing similarity or near-duplicate results. | |
| 638 | + | pub fn in_similarity(&self) -> bool { | |
| 639 | + | !self.similarity_trail.is_empty() | |
| 640 | + | } | |
| 641 | + | } | |
| 642 | + | ||
| 612 | 643 | /// Loose-files mode integrity tracking. | |
| 613 | 644 | #[derive(Default)] | |
| 614 | 645 | pub struct LooseFilesUiState { |
| @@ -388,7 +388,7 @@ | |||
| 388 | 388 | // heading as not sortable instead, so the score order stays trustworthy. | |
| 389 | 389 | // The mode says so where it lives, on the toolbar's "Similar to:" segment; | |
| 390 | 390 | // a heading nobody has reason to point at is the wrong place to explain it. | |
| 391 | - | let sort_enabled = state.search.similarity_search_hash.is_none(); | |
| 391 | + | let sort_enabled = !state.search.in_similarity(); | |
| 392 | 392 | ||
| 393 | 393 | let columns = describe(visible, sort_col, &sort_dir, sort_enabled); | |
| 394 | 394 |
| @@ -82,7 +82,10 @@ | |||
| 82 | 82 | ("j / Down".to_string(), "Move down"), | |
| 83 | 83 | ("k / Up".to_string(), "Move up"), | |
| 84 | 84 | ("Enter / Right".to_string(), "Open / preview"), | |
| 85 | - | ("Backspace / Left".to_string(), "Go up"), | |
| 85 | + | ( | |
| 86 | + | "Backspace / Left".to_string(), | |
| 87 | + | "Go up / back one similarity step", | |
| 88 | + | ), | |
| 86 | 89 | ("Space".to_string(), "Play / pause"), | |
| 87 | 90 | ]; | |
| 88 | 91 | let selection: &[(String, &str)] = &[ |
| @@ -438,24 +438,48 @@ | |||
| 438 | 438 | ||
| 439 | 439 | ui.separator(); | |
| 440 | 440 | ||
| 441 | + | // Iterate by reference, defer the mutation, the same way the folder crumbs | |
| 442 | + | // below do: walking back re-runs a query and the trail is borrowed here. | |
| 443 | + | let mut walk_back_to: Option<usize> = None; | |
| 444 | + | ||
| 441 | 445 | // Similarity / duplicate search view: replace the folder path with a | |
| 442 | - | // "Similar to: <name>" segment so the breadcrumb reflects the active mode | |
| 446 | + | // trail of the samples walked through, so the breadcrumb reflects the | |
| 447 | + | // active mode instead of the folder the user happened to be in | |
| 443 | 448 | // instead of the folder the user happened to be in when they triggered it. | |
| 444 | - | if state.search.similarity_search_hash.is_some() { | |
| 445 | - | let name = state | |
| 446 | - | .search | |
| 447 | - | .similarity_source_name | |
| 448 | - | .as_deref() | |
| 449 | - | .unwrap_or("sample"); | |
| 449 | + | if state.search.in_similarity() { | |
| 450 | 450 | ui.label("/"); | |
| 451 | - | // Why the file list's headings stop responding in this mode. It used to | |
| 452 | - | // be a hover on the headings themselves, which meant the explanation | |
| 453 | - | // lived on a control the user had no reason to point at; it belongs | |
| 454 | - | // where the mode does. | |
| 455 | - | ui.label(widgets::accent_strong(format!("Similar to: {name}"))) | |
| 456 | - | .on_hover_text( | |
| 457 | - | "Results are ranked by similarity, so column sort is off. Clear to sort again.", | |
| 458 | - | ); | |
| 451 | + | // The trail, read left to right: every sample walked through, each one | |
| 452 | + | // a way back to its own results. The last is where you are, so it is a | |
| 453 | + | // label rather than a button, the same distinction the folder crumbs | |
| 454 | + | // make. Only the current step carries the hover, because the sentence | |
| 455 | + | // is about the list showing now. | |
| 456 | + | let last = state.search.similarity_trail.len().saturating_sub(1); | |
| 457 | + | for (at, step) in state.search.similarity_trail.iter().enumerate() { | |
| 458 | + | let name = step.name.as_deref().unwrap_or("sample"); | |
| 459 | + | let says = if at == 0 { | |
| 460 | + | format!("Similar to: {name}") | |
| 461 | + | } else { | |
| 462 | + | name.to_owned() | |
| 463 | + | }; | |
| 464 | + | if at == last { | |
| 465 | + | // Why the file list's headings stop responding in this mode. It | |
| 466 | + | // used to be a hover on the headings themselves, which meant the | |
| 467 | + | // explanation lived on a control the user had no reason to point | |
| 468 | + | // at; it belongs where the mode does. | |
| 469 | + | ui.label(widgets::accent_strong(says)).on_hover_text( | |
| 470 | + | "Results are ranked by similarity, so column sort is off. Clear to sort again.", | |
| 471 | + | ); | |
| 472 | + | } else { | |
| 473 | + | if ui | |
| 474 | + | .small_button(says) | |
| 475 | + | .on_hover_text("Back to what this sample sounds like") | |
| 476 | + | .clicked() | |
| 477 | + | { | |
| 478 | + | walk_back_to = Some(at); | |
| 479 | + | } | |
| 480 | + | ui.label(">"); | |
| 481 | + | } | |
| 482 | + | } | |
| 459 | 483 | // M-9: Clear lives at the breadcrumb segment so the mode label and | |
| 460 | 484 | // the exit affordance occupy one row, not two. | |
| 461 | 485 | if ui | |
| @@ -523,6 +547,10 @@ | |||
| 523 | 547 | } | |
| 524 | 548 | } | |
| 525 | 549 | ||
| 550 | + | if let Some(at) = walk_back_to { | |
| 551 | + | state.walk_back_to(at); | |
| 552 | + | } | |
| 553 | + | ||
| 526 | 554 | // Import + Export buttons + Sync + theme selector (right-aligned) | |
| 527 | 555 | ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { | |
| 528 | 556 | let import_id = ui.make_persistent_id("import_menu"); |