max / audiofiles
5 files changed,
+194 insertions,
-40 deletions
| @@ -2100,6 +2100,36 @@ | |||
| 2100 | 2100 | Self::Filters, | |
| 2101 | 2101 | ]; | |
| 2102 | 2102 | ||
| 2103 | + | /// What the toggle is worth when the toolbar runs out of room. | |
| 2104 | + | /// | |
| 2105 | + | /// The declared replacement for the shipped `screen_w < 900.0`, which | |
| 2106 | + | /// collapsed all six into a View menu at one pixel width this file had no | |
| 2107 | + | /// say in. Ranked rather than collapsed, so a narrow window loses the | |
| 2108 | + | /// toggles nobody reaches for and keeps the two that decide the shape of | |
| 2109 | + | /// the window. | |
| 2110 | + | /// | |
| 2111 | + | /// - **Sidebar and Detail never drop.** They are the two structural panes, | |
| 2112 | + | /// and a window narrow enough to want fewer controls is exactly the | |
| 2113 | + | /// window where being able to close one of them matters most. | |
| 2114 | + | /// - **Filters is Secondary.** It carries the count of what is on, so | |
| 2115 | + | /// dropping it while a filter is applied would hide why the list is | |
| 2116 | + | /// short. It survives everything but the narrowest class. | |
| 2117 | + | /// - **Edit, Instrument and Loop drop first.** Each opens an inspector for | |
| 2118 | + | /// the selected sample, which is work a phone-width window is not where | |
| 2119 | + | /// you do. | |
| 2120 | + | /// | |
| 2121 | + | /// What this does not say is a width. Which class drops which rank is the | |
| 2122 | + | /// renderer's, and it is the same rank in all three of them. | |
| 2123 | + | #[must_use] | |
| 2124 | + | pub const fn worth(self) -> quasi_router::layout::Priority { | |
| 2125 | + | use quasi_router::layout::Priority; | |
| 2126 | + | match self { | |
| 2127 | + | Self::Sidebar | Self::Detail => Priority::Essential, | |
| 2128 | + | Self::Filters => Priority::Secondary, | |
| 2129 | + | Self::Edit | Self::Instrument | Self::Loop => Priority::Optional, | |
| 2130 | + | } | |
| 2131 | + | } | |
| 2132 | + | ||
| 2103 | 2133 | /// The name an address is built from. | |
| 2104 | 2134 | #[must_use] | |
| 2105 | 2135 | pub const fn as_str(self) -> &'static str { |
| @@ -34,6 +34,18 @@ | |||
| 34 | 34 | //! layout decision made from a pixel measurement inside a drawing function, | |
| 35 | 35 | //! and it is the clearest case in this app of the thing the description layer | |
| 36 | 36 | //! exists to take away. | |
| 37 | + | //! | |
| 38 | + | //! Worth being exact about what replaced it, because the obvious answer is | |
| 39 | + | //! the wrong one. `Ranked` (quasi 0.18.0) lets a placement say what it is | |
| 40 | + | //! worth when room runs out, and the toolbar's `< 900` collapse became three | |
| 41 | + | //! priorities. This band's `< 1000` did not, and should not: it *reflows*, | |
| 42 | + | //! moving three items to a second row and dropping nothing. Ranking them | |
| 43 | + | //! would delete facts the shipped footer keeps. Wrapping a row that does not | |
| 44 | + | //! fit is the host's arithmetic in its own units, which is what the deletion | |
| 45 | + | //! above already claimed, so nothing is owed here. | |
| 46 | + | //! | |
| 47 | + | //! The one member that is genuinely droppable is the tag badges, and they | |
| 48 | + | //! say so. | |
| 37 | 49 | //! - **A hand-painted progress bar.** Twenty lines of `rect_filled`, a bevel and | |
| 38 | 50 | //! a click-to-seek hit test. The bar is [`Meter`]; the seek is not (see | |
| 39 | 51 | //! below). | |
| @@ -70,7 +82,7 @@ | |||
| 70 | 82 | //! and `sidebar.rs` (722) are the other two bands of the same window, and each | |
| 71 | 83 | //! is its own pass. The screen below has the two regions that exist. | |
| 72 | 84 | ||
| 73 | - | use quasi_router::layout::{Notice, Tone}; | |
| 85 | + | use quasi_router::layout::{Notice, Priority, Tone}; | |
| 74 | 86 | use quasi_router::{ | |
| 75 | 87 | Act, Action, Figure, Meter, Node, RegionKind, Request, Response, RouteError, Router, Screen, | |
| 76 | 88 | Slot, Tag, | |
| @@ -152,8 +164,13 @@ | |||
| 152 | 164 | // muted text rather than chips on purpose -- "these are inert | |
| 153 | 165 | // (informational only), so the affordance contract should not invite a | |
| 154 | 166 | // click" -- which is exactly what a badge is and a chip is not. | |
| 167 | + | // | |
| 168 | + | // Optional, and they are the only thing in this band that is. A badge here | |
| 169 | + | // repeats a fact the detail panel states in full, so a window with no room | |
| 170 | + | // for everything loses the repetition first. Everything else in the footer | |
| 171 | + | // is a fact stated nowhere else on the screen. | |
| 155 | 172 | for tag in state.shell.tags() { | |
| 156 | - | band = band.with(Node::Token(Tag::badge(tag))); | |
| 173 | + | band = band.with_ranked(Node::Token(Tag::badge(tag)), Priority::Optional); | |
| 157 | 174 | } | |
| 158 | 175 | ||
| 159 | 176 | band |
| @@ -230,7 +230,12 @@ | |||
| 230 | 230 | ||
| 231 | 231 | /// Every node on a screen, in order. | |
| 232 | 232 | fn nodes(screen: &Screen) -> Vec<&Node> { | |
| 233 | - | screen.slots.iter().flat_map(|slot| &slot.body).collect() | |
| 233 | + | screen | |
| 234 | + | .slots | |
| 235 | + | .iter() | |
| 236 | + | .flat_map(|slot| &slot.body) | |
| 237 | + | .map(|placed| &placed.node) | |
| 238 | + | .collect() | |
| 234 | 239 | } | |
| 235 | 240 | ||
| 236 | 241 | /// The text of every prose and notice node on a screen, joined. | |
| @@ -290,9 +295,11 @@ | |||
| 290 | 295 | /// `nodes` only walks the screen's own slots. The rename preview lives in one so | |
| 291 | 296 | /// that a fragment can replace it. | |
| 292 | 297 | fn table_of(screen: &Screen) -> (Vec<quasi_router::Column>, Vec<quasi_router::Cells>) { | |
| 293 | - | fn find(body: &[Node]) -> Option<(Vec<quasi_router::Column>, Vec<quasi_router::Cells>)> { | |
| 294 | - | for node in body { | |
| 295 | - | match node { | |
| 298 | + | fn find( | |
| 299 | + | body: &[quasi_router::Ranked], | |
| 300 | + | ) -> Option<(Vec<quasi_router::Column>, Vec<quasi_router::Cells>)> { | |
| 301 | + | for placed in body { | |
| 302 | + | match &placed.node { | |
| 296 | 303 | Node::Table { columns, rows, .. } => { | |
| 297 | 304 | return Some((columns.clone(), rows.clone())); | |
| 298 | 305 | } | |
| @@ -389,8 +396,8 @@ | |||
| 389 | 396 | fn fields(screen: &Screen) -> BTreeMap<String, Option<String>> { | |
| 390 | 397 | let mut found = BTreeMap::new(); | |
| 391 | 398 | for slot in &screen.slots { | |
| 392 | - | for node in &slot.body { | |
| 393 | - | match node { | |
| 399 | + | for placed in &slot.body { | |
| 400 | + | match &placed.node { | |
| 394 | 401 | Node::Field(field) => { | |
| 395 | 402 | found.insert(field.name.clone(), field.value.clone()); | |
| 396 | 403 | } | |
| @@ -611,7 +618,7 @@ | |||
| 611 | 618 | .slots | |
| 612 | 619 | .iter() | |
| 613 | 620 | .flat_map(|slot| &slot.body) | |
| 614 | - | .find_map(|node| match node { | |
| 621 | + | .find_map(|placed| match &placed.node { | |
| 615 | 622 | Node::Field(field) if field.name == ConfigKey::Theme.as_str() => Some(field), | |
| 616 | 623 | _ => None, | |
| 617 | 624 | }) | |
| @@ -814,7 +821,7 @@ | |||
| 814 | 821 | .slots | |
| 815 | 822 | .iter() | |
| 816 | 823 | .flat_map(|slot| &slot.body) | |
| 817 | - | .filter_map(|node| match node { | |
| 824 | + | .filter_map(|placed| match &placed.node { | |
| 818 | 825 | Node::Act(act) => Some(act.label.clone()), | |
| 819 | 826 | _ => None, | |
| 820 | 827 | }) | |
| @@ -905,7 +912,7 @@ | |||
| 905 | 912 | .slots | |
| 906 | 913 | .iter() | |
| 907 | 914 | .flat_map(|slot| &slot.body) | |
| 908 | - | .filter_map(|node| match node { | |
| 915 | + | .filter_map(|placed| match &placed.node { | |
| 909 | 916 | Node::Form { fields, .. } => Some(fields.clone()), | |
| 910 | 917 | _ => None, | |
| 911 | 918 | }) | |
| @@ -929,7 +936,7 @@ | |||
| 929 | 936 | .slots | |
| 930 | 937 | .iter() | |
| 931 | 938 | .flat_map(|slot| &slot.body) | |
| 932 | - | .find_map(|node| match node { | |
| 939 | + | .find_map(|placed| match &placed.node { | |
| 933 | 940 | Node::Act(act) if act.label == "Sync now" => Some(act), | |
| 934 | 941 | _ => None, | |
| 935 | 942 | }) | |
| @@ -972,11 +979,9 @@ | |||
| 972 | 979 | let response = syncing(&sync, Request::get("/sync")).expect("answered"); | |
| 973 | 980 | let screen = screen_of(&response); | |
| 974 | 981 | ||
| 975 | - | let said = screen | |
| 976 | - | .slots | |
| 977 | - | .iter() | |
| 978 | - | .flat_map(|slot| &slot.body) | |
| 979 | - | .any(|node| matches!(node, Node::Notice { text, .. } if text.contains("said no"))); | |
| 982 | + | let said = screen.slots.iter().flat_map(|slot| &slot.body).any( | |
| 983 | + | |placed| matches!(&placed.node, Node::Notice { text, .. } if text.contains("said no")), | |
| 984 | + | ); | |
| 980 | 985 | assert!(said, "{state:?} swallowed the error"); | |
| 981 | 986 | ||
| 982 | 987 | let labels = acts(screen); | |
| @@ -995,8 +1000,8 @@ | |||
| 995 | 1000 | .slots | |
| 996 | 1001 | .iter() | |
| 997 | 1002 | .flat_map(|slot| &slot.body) | |
| 998 | - | .flat_map(|node| match node { | |
| 999 | - | Node::Region(slot) => slot.body.clone(), | |
| 1003 | + | .flat_map(|placed| match &placed.node { | |
| 1004 | + | Node::Region(slot) => slot.body.iter().map(|inner| inner.node.clone()).collect(), | |
| 1000 | 1005 | other => vec![other.clone()], | |
| 1001 | 1006 | }) | |
| 1002 | 1007 | .filter_map(|node| match node { | |
| @@ -1024,7 +1029,7 @@ | |||
| 1024 | 1029 | .slots | |
| 1025 | 1030 | .iter() | |
| 1026 | 1031 | .flat_map(|slot| &slot.body) | |
| 1027 | - | .find_map(|node| match node { | |
| 1032 | + | .find_map(|placed| match &placed.node { | |
| 1028 | 1033 | Node::Region(slot) if slot.id == "subscription" => Some(slot), | |
| 1029 | 1034 | _ => None, | |
| 1030 | 1035 | }) | |
| @@ -1135,8 +1140,8 @@ | |||
| 1135 | 1140 | .slots | |
| 1136 | 1141 | .iter() | |
| 1137 | 1142 | .flat_map(|slot| &slot.body) | |
| 1138 | - | .flat_map(|node| match node { | |
| 1139 | - | Node::Region(slot) => slot.body.clone(), | |
| 1143 | + | .flat_map(|placed| match &placed.node { | |
| 1144 | + | Node::Region(slot) => slot.body.iter().map(|inner| inner.node.clone()).collect(), | |
| 1140 | 1145 | other => vec![other.clone()], | |
| 1141 | 1146 | }) | |
| 1142 | 1147 | .find_map(|node| match node { | |
| @@ -1171,8 +1176,8 @@ | |||
| 1171 | 1176 | .slots | |
| 1172 | 1177 | .iter() | |
| 1173 | 1178 | .flat_map(|slot| &slot.body) | |
| 1174 | - | .flat_map(|node| match node { | |
| 1175 | - | Node::Region(slot) => slot.body.clone(), | |
| 1179 | + | .flat_map(|placed| match &placed.node { | |
| 1180 | + | Node::Region(slot) => slot.body.iter().map(|inner| inner.node.clone()).collect(), | |
| 1176 | 1181 | other => vec![other.clone()], | |
| 1177 | 1182 | }) | |
| 1178 | 1183 | .find_map(|node| match node { | |
| @@ -1280,7 +1285,7 @@ | |||
| 1280 | 1285 | .slots | |
| 1281 | 1286 | .iter() | |
| 1282 | 1287 | .flat_map(|slot| &slot.body) | |
| 1283 | - | .find_map(|node| match node { | |
| 1288 | + | .find_map(|placed| match &placed.node { | |
| 1284 | 1289 | Node::StandIn { message, act, .. } => Some((message.clone(), act.clone())), | |
| 1285 | 1290 | _ => None, | |
| 1286 | 1291 | }) | |
| @@ -3545,7 +3550,7 @@ | |||
| 3545 | 3550 | .iter() | |
| 3546 | 3551 | .filter(|slot| slot.id == "library-side") | |
| 3547 | 3552 | .flat_map(|slot| &slot.body) | |
| 3548 | - | .filter_map(|node| match node { | |
| 3553 | + | .filter_map(|placed| match &placed.node { | |
| 3549 | 3554 | Node::Token(tag) => Some(tag.label.clone()), | |
| 3550 | 3555 | _ => None, | |
| 3551 | 3556 | }) | |
| @@ -4092,3 +4097,79 @@ | |||
| 4092 | 4097 | .expect("the toolbar has a search field"); | |
| 4093 | 4098 | assert_eq!(asked, quasi_router::layout::Width::Fill); | |
| 4094 | 4099 | } | |
| 4100 | + | ||
| 4101 | + | /// What every member of a region is worth, by the text it carries. | |
| 4102 | + | fn worths(screen: &Screen, region: &str) -> Vec<(String, quasi_router::layout::Priority)> { | |
| 4103 | + | screen | |
| 4104 | + | .slots | |
| 4105 | + | .iter() | |
| 4106 | + | .find(|slot| slot.id == region) | |
| 4107 | + | .expect("the region is on the screen") | |
| 4108 | + | .body | |
| 4109 | + | .iter() | |
| 4110 | + | .map(|placed| { | |
| 4111 | + | let name = match &placed.node { | |
| 4112 | + | Node::Token(tag) => tag.label.clone(), | |
| 4113 | + | Node::Act(act) => act.label.clone(), | |
| 4114 | + | Node::Text { text, .. } => text.clone(), | |
| 4115 | + | other => format!("{other:?}"), | |
| 4116 | + | }; | |
| 4117 | + | (name, placed.priority) | |
| 4118 | + | }) | |
| 4119 | + | .collect() | |
| 4120 | + | } | |
| 4121 | + | ||
| 4122 | + | #[test] | |
| 4123 | + | fn the_panel_toggles_say_what_they_are_worth_instead_of_collapsing_at_900px() { | |
| 4124 | + | // The described replacement for `screen_w < 900.0`, which put all six | |
| 4125 | + | // toggles into a View menu at a width this app chose. Ranked now, so a | |
| 4126 | + | // narrow window keeps the two toggles that decide the shape of the window | |
| 4127 | + | // and loses the three that open an inspector. | |
| 4128 | + | use quasi_router::layout::Priority; | |
| 4129 | + | ||
| 4130 | + | let bar = FakeBar::deep(); | |
| 4131 | + | let worth = worths(&topped(&bar), "toolbar-bar"); | |
| 4132 | + | let of = |label: &str| { | |
| 4133 | + | worth | |
| 4134 | + | .iter() | |
| 4135 | + | .find(|(name, _)| name.starts_with(label)) | |
| 4136 | + | .unwrap_or_else(|| panic!("{label} is on the toolbar: {worth:?}")) | |
| 4137 | + | .1 | |
| 4138 | + | }; | |
| 4139 | + | ||
| 4140 | + | assert_eq!(of("Sidebar"), Priority::Essential); | |
| 4141 | + | assert_eq!(of("Detail"), Priority::Essential); | |
| 4142 | + | assert_eq!(of("Filters"), Priority::Secondary); | |
| 4143 | + | for inspector in ["Edit", "Instrument", "Loop"] { | |
| 4144 | + | assert_eq!(of(inspector), Priority::Optional, "{inspector}"); | |
| 4145 | + | } | |
| 4146 | + | ||
| 4147 | + | // Help drops first of the three addresses because `f1` still reaches it. | |
| 4148 | + | assert_eq!(of("Settings"), Priority::Secondary); | |
| 4149 | + | assert_eq!(of("Cloud Sync"), Priority::Secondary); | |
| 4150 | + | assert_eq!(of("Help"), Priority::Optional); | |
| 4151 | + | ||
| 4152 | + | // And nothing was hidden behind a width. The description names no pixels. | |
| 4153 | + | assert!(!format!("{worth:?}").contains("900")); | |
| 4154 | + | } | |
| 4155 | + | ||
| 4156 | + | #[test] | |
| 4157 | + | fn the_footer_drops_only_what_the_detail_panel_says_twice() { | |
| 4158 | + | // The footer's own `< 1000` reflows rather than drops, so ranking its | |
| 4159 | + | // items would delete facts the shipped app keeps. The tag badges are the | |
| 4160 | + | // exception: they repeat what the detail panel states in full. | |
| 4161 | + | use quasi_router::layout::Priority; | |
| 4162 | + | ||
| 4163 | + | let shell = FakeShell { | |
| 4164 | + | tags: vec!["drums".to_owned(), "loop".to_owned()], | |
| 4165 | + | ..Default::default() | |
| 4166 | + | }; | |
| 4167 | + | let worth = worths(&shown(&shell), "shell-foot"); | |
| 4168 | + | ||
| 4169 | + | let optional: Vec<&String> = worth | |
| 4170 | + | .iter() | |
| 4171 | + | .filter(|(_, priority)| *priority != Priority::Essential) | |
| 4172 | + | .map(|(name, _)| name) | |
| 4173 | + | .collect(); | |
| 4174 | + | assert_eq!(optional, ["drums", "loop"], "{worth:?}"); | |
| 4175 | + | } |
| @@ -82,7 +82,7 @@ | |||
| 82 | 82 | //! host draws it rather than what it holds, so no finding — but a vocabulary | |
| 83 | 83 | //! that grows anchoring should know this was the first place it mattered. | |
| 84 | 84 | ||
| 85 | - | use quasi_router::layout::{FieldKind, Selector, Tone, Width}; | |
| 85 | + | use quasi_router::layout::{FieldKind, Priority, Selector, Tone, Width}; | |
| 86 | 86 | use quasi_router::{ | |
| 87 | 87 | Act, Action, Choice, Field, Node, RegionKind, Request, Response, RouteError, Router, Screen, | |
| 88 | 88 | Slot, | |
| @@ -376,10 +376,23 @@ | |||
| 376 | 376 | if panel == Panel::Filters && state.bar.searching().filters > 0 { | |
| 377 | 377 | chip.label = format!("{} ({})", chip.label, state.bar.searching().filters); | |
| 378 | 378 | } | |
| 379 | - | bar = bar.with(Node::Token(chip)); | |
| 379 | + | bar = bar.with_ranked(Node::Token(chip), panel.worth()); | |
| 380 | 380 | } | |
| 381 | 381 | ||
| 382 | - | bar.with(Node::Act(Act::new("Settings", Action::get("/settings")))) | |
| 383 | - | .with(Node::Act(Act::new("Cloud Sync", Action::get("/sync")))) | |
| 384 | - | .with(Node::Act(Act::new("Help", Action::get("/help")).key("f1"))) | |
| 382 | + | // Settings and Cloud Sync are how you reach two whole screens and have no | |
| 383 | + | // other route in, so they hold at Secondary. Help drops first because it | |
| 384 | + | // is the one control here that keeps working when it is not on the screen: | |
| 385 | + | // it carries `f1`, and a key is a route a narrow window cannot take away. | |
| 386 | + | bar.with_ranked( | |
| 387 | + | Node::Act(Act::new("Settings", Action::get("/settings"))), | |
| 388 | + | Priority::Secondary, | |
| 389 | + | ) | |
| 390 | + | .with_ranked( | |
| 391 | + | Node::Act(Act::new("Cloud Sync", Action::get("/sync"))), | |
| 392 | + | Priority::Secondary, | |
| 393 | + | ) | |
| 394 | + | .with_ranked( | |
| 395 | + | Node::Act(Act::new("Help", Action::get("/help")).key("f1")), | |
| 396 | + | Priority::Optional, | |
| 397 | + | ) | |
| 385 | 398 | } |
| @@ -175,8 +175,12 @@ | |||
| 175 | 175 | let screen_w = ui.ctx().content_rect().width(); | |
| 176 | 176 | let collapse_toggles = screen_w < 900.0; | |
| 177 | 177 | // M-13 input (shared by both layouts). | |
| 178 | - | let detail_too_narrow = screen_w < 700.0; | |
| 179 | - | let detail_hidden = state.detail.detail_visible && detail_too_narrow; | |
| 178 | + | // On, and too narrow to be showing. Both halves are read from this | |
| 179 | + | // frame -- the first off persisted config the user set, the second off | |
| 180 | + | // the width right now -- so the answer is the same at a given width | |
| 181 | + | // whatever widths came before it. It decides what the tooltip says and | |
| 182 | + | // nothing else. | |
| 183 | + | let detail_hidden = state.detail.detail_visible && screen_w < 700.0; | |
| 180 | 184 | ||
| 181 | 185 | if collapse_toggles { | |
| 182 | 186 | draw_view_menu(ui, state, detail_hidden); | |
| @@ -218,20 +222,29 @@ | |||
| 218 | 222 | state.toggle_sidebar(); | |
| 219 | 223 | } | |
| 220 | 224 | ||
| 221 | - | // M-13: the Detail toggle conveys "active but hidden" via a muted colour | |
| 222 | - | // and a tooltip explaining the cause; otherwise behaves like the other | |
| 223 | - | // toolbar toggles. | |
| 225 | + | // M-13: the Detail toggle says "active but hidden" in its tooltip. It used | |
| 226 | + | // to say it in its colour as well, and that was two bugs in one line. | |
| 227 | + | // | |
| 228 | + | // The colour was `content_muted` both for "on but the window is too narrow" | |
| 229 | + | // and for "off", so the one control that could tell you whether the panel | |
| 230 | + | // is on told you nothing at either end. And `content_muted` is the tone | |
| 231 | + | // that claims a control will not answer a press (`makeover-layout`, the | |
| 232 | + | // three tones): this one answers in every state, so wearing it was a lie | |
| 233 | + | // that makes a user stop trying. | |
| 234 | + | // | |
| 235 | + | // Two facts, kept apart. Whether the panel is on is the app's, and it is | |
| 236 | + | // what the colour says. Whether the window is wide enough to show it is | |
| 237 | + | // this frame's, derived here and said in the tooltip, where a cause | |
| 238 | + | // belongs. | |
| 224 | 239 | let detail_tooltip = if detail_hidden { | |
| 225 | 240 | "Detail panel hidden \u{2014} widen the window to show it. (D)" | |
| 226 | 241 | } else { | |
| 227 | 242 | "Toggle detail panel (D)" | |
| 228 | 243 | }; | |
| 229 | - | let detail_colour = if detail_hidden { | |
| 230 | - | theme::content_muted() | |
| 231 | - | } else if state.detail.detail_visible { | |
| 244 | + | let detail_colour = if state.detail.detail_visible { | |
| 232 | 245 | theme::action() | |
| 233 | 246 | } else { | |
| 234 | - | theme::content_muted() | |
| 247 | + | theme::content_secondary() | |
| 235 | 248 | }; | |
| 236 | 249 | if ui | |
| 237 | 250 | .button(egui::RichText::new("Detail").color(detail_colour)) |