max / audiofiles
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
7 files changed,
+254 insertions,
-151 deletions
| @@ -63,7 +63,7 @@ | |||
| 63 | 63 | //! Three consumers, three members, one gap. Said as prose here for the same | |
| 64 | 64 | //! reason and with the same complaint. | |
| 65 | 65 | ||
| 66 | - | use quasi_router::layout::{FieldKind, Notice, Selector, Tone}; | |
| 66 | + | use quasi_router::layout::{FieldKind, Notice, Tone}; | |
| 67 | 67 | use quasi_router::{ | |
| 68 | 68 | Act, Action, Cell, Cells, Choice, Column, Field, Node, Outcome, RegionKind, Request, Response, | |
| 69 | 69 | Rest, RouteError, Router, Screen, Slot, Tag, | |
| @@ -198,15 +198,19 @@ | |||
| 198 | 198 | // Add or remove, as one control rather than two selectable labels. The | |
| 199 | 199 | // shipped modal draws `selectable_value(adding, true, ..)` twice, which is a | |
| 200 | 200 | // segmented control spelled out. | |
| 201 | - | body = body.with(Node::Select { | |
| 202 | - | kind: Selector::Segmented, | |
| 203 | - | options: vec![ | |
| 204 | - | (Choice::new("add", "Add tag"), None), | |
| 205 | - | (Choice::new("remove", "Remove tag"), None), | |
| 201 | + | // | |
| 202 | + | // A field in the form rather than a `Node::Select` beside it, which is what | |
| 203 | + | // it always was: nothing writes when it changes, and `tag` reads it out of | |
| 204 | + | // the payload at submit alongside the tag itself. | |
| 205 | + | let mode = Field::radio( | |
| 206 | + | MODE, | |
| 207 | + | "Mode", | |
| 208 | + | vec![ | |
| 209 | + | Choice::new("add", "Add tag"), | |
| 210 | + | Choice::new("remove", "Remove tag"), | |
| 206 | 211 | ], | |
| 207 | - | chosen: Some(if adding { "add" } else { "remove" }.to_owned()), | |
| 208 | - | action: None, | |
| 209 | - | }); | |
| 212 | + | ) | |
| 213 | + | .value(if adding { "add" } else { "remove" }); | |
| 210 | 214 | ||
| 211 | 215 | let mut field = Field::new(FieldKind::Text, TAG, "Tag").hint("e.g. genre.electronic"); | |
| 212 | 216 | if let Some(typed) = typed { | |
| @@ -214,7 +218,7 @@ | |||
| 214 | 218 | } | |
| 215 | 219 | body = body | |
| 216 | 220 | .with(Node::Form { | |
| 217 | - | fields: vec![field], | |
| 221 | + | fields: vec![mode, field], | |
| 218 | 222 | submit: "Apply".to_owned(), | |
| 219 | 223 | action: Action::post("/bulk/tag"), | |
| 220 | 224 | }) |
| @@ -91,7 +91,7 @@ | |||
| 91 | 91 | //! [`Outcome::Locate`]: quasi_router::Outcome::Locate | |
| 92 | 92 | //! [`Locating::labelled`]: quasi_router::Locating::labelled | |
| 93 | 93 | ||
| 94 | - | use quasi_router::layout::{FieldKind, Selector, Tone}; | |
| 94 | + | use quasi_router::layout::{FieldKind, Tone}; | |
| 95 | 95 | use quasi_router::{ | |
| 96 | 96 | Act, Action, Choice, Field, Locating, Node, Outcome, RegionKind, Request, Response, RouteError, | |
| 97 | 97 | Router, Screen, Slot, | |
| @@ -284,28 +284,18 @@ | |||
| 284 | 284 | // a control that cannot be used is worse than one that is not there, and the | |
| 285 | 285 | // shipped screen agrees -- it hides the whole block behind `!has_profile`. | |
| 286 | 286 | if settings.device_profile.is_none() { | |
| 287 | - | body = body | |
| 288 | - | .with(Node::section("Format")) | |
| 289 | - | .with(format_field(settings.format)); | |
| 287 | + | // No section headings any more: each of these fields carries its own | |
| 288 | + | // label, and a heading repeating it is a second name for one question. | |
| 289 | + | body = body.with(format_field(settings.format)); | |
| 290 | 290 | if settings.format != Format::Original { | |
| 291 | - | body = body.with(Node::banner( | |
| 292 | - | Tone::Warning, | |
| 293 | - | "Re-encoding strips embedded metadata chunks (BWF, iXML, loop points, \ | |
| 294 | - | cue markers, ID3). Choose Original to preserve them.", | |
| 295 | - | )); | |
| 296 | 291 | body = body | |
| 297 | - | .with(Node::section("Sample Rate")) | |
| 298 | 292 | .with(sample_rate_field(settings.sample_rate)) | |
| 299 | - | .with(Node::section("Bit Depth")) | |
| 300 | 293 | .with(bit_depth_field(settings.bit_depth)); | |
| 301 | 294 | } | |
| 302 | - | body = body | |
| 303 | - | .with(Node::section("Channels")) | |
| 304 | - | .with(channels_field(settings.channels)); | |
| 295 | + | body = body.with(channels_field(settings.channels)); | |
| 305 | 296 | } | |
| 306 | 297 | ||
| 307 | 298 | body = body | |
| 308 | - | .with(Node::section("Structure")) | |
| 309 | 299 | .with(structure_field(settings.flatten)) | |
| 310 | 300 | .with(Node::Field(Box::new( | |
| 311 | 301 | Field::new( | |
| @@ -585,22 +575,37 @@ | |||
| 585 | 575 | Format::Wav => "wav", | |
| 586 | 576 | Format::Aiff => "aiff", | |
| 587 | 577 | }; | |
| 588 | - | picker( | |
| 578 | + | let field = picker( | |
| 589 | 579 | Setting::Format, | |
| 580 | + | "Format", | |
| 590 | 581 | chosen, | |
| 591 | 582 | [ | |
| 592 | 583 | ("original", "Original (copy as-is)"), | |
| 593 | 584 | ("wav", "WAV (decode and re-encode)"), | |
| 594 | 585 | ("aiff", "AIFF (decode and re-encode)"), | |
| 595 | 586 | ], | |
| 596 | - | ) | |
| 587 | + | ); | |
| 588 | + | // What re-encoding costs, on the control that chose it. It was a banner | |
| 589 | + | // under the picker; a note is the same sentence attached to the answer that | |
| 590 | + | // earns it, which is why the field gained the channel. | |
| 591 | + | let field = if format == Format::Original { | |
| 592 | + | field | |
| 593 | + | } else { | |
| 594 | + | field.note( | |
| 595 | + | Tone::Warning, | |
| 596 | + | "Re-encoding strips embedded metadata chunks (BWF, iXML, loop points, \ | |
| 597 | + | cue markers, ID3). Choose Original to preserve them.", | |
| 598 | + | ) | |
| 599 | + | }; | |
| 600 | + | Node::Field(Box::new(field)) | |
| 597 | 601 | } | |
| 598 | 602 | ||
| 599 | 603 | /// The sample rate to write at. | |
| 600 | 604 | fn sample_rate_field(rate: Option<u32>) -> Node { | |
| 601 | 605 | let chosen = rate.map_or_else(String::new, |rate| rate.to_string()); | |
| 602 | - | picker( | |
| 606 | + | Node::Field(Box::new(picker( | |
| 603 | 607 | Setting::SampleRate, | |
| 608 | + | "Sample rate", | |
| 604 | 609 | &chosen, | |
| 605 | 610 | [ | |
| 606 | 611 | ("", "Original"), | |
| @@ -608,17 +613,18 @@ | |||
| 608 | 613 | ("48000", "48,000 Hz"), | |
| 609 | 614 | ("96000", "96,000 Hz"), | |
| 610 | 615 | ], | |
| 611 | - | ) | |
| 616 | + | ))) | |
| 612 | 617 | } | |
| 613 | 618 | ||
| 614 | 619 | /// The bit depth to write at. | |
| 615 | 620 | fn bit_depth_field(depth: Option<u16>) -> Node { | |
| 616 | 621 | let chosen = depth.map_or_else(String::new, |depth| depth.to_string()); | |
| 617 | - | picker( | |
| 622 | + | Node::Field(Box::new(picker( | |
| 618 | 623 | Setting::BitDepth, | |
| 624 | + | "Bit depth", | |
| 619 | 625 | &chosen, | |
| 620 | 626 | [("", "Original"), ("16", "16-bit"), ("24", "24-bit")], | |
| 621 | - | ) | |
| 627 | + | ))) | |
| 622 | 628 | } | |
| 623 | 629 | ||
| 624 | 630 | /// The channel layout to write. | |
| @@ -628,50 +634,54 @@ | |||
| 628 | 634 | Channels::Mono => "mono", | |
| 629 | 635 | Channels::Stereo => "stereo", | |
| 630 | 636 | }; | |
| 631 | - | picker( | |
| 637 | + | Node::Field(Box::new(picker( | |
| 632 | 638 | Setting::Channels, | |
| 639 | + | "Channels", | |
| 633 | 640 | chosen, | |
| 634 | 641 | [ | |
| 635 | 642 | ("original", "Original"), | |
| 636 | 643 | ("mono", "Mono"), | |
| 637 | 644 | ("stereo", "Stereo"), | |
| 638 | 645 | ], | |
| 639 | - | ) | |
| 646 | + | ))) | |
| 640 | 647 | } | |
| 641 | 648 | ||
| 642 | 649 | /// Whether the tree survives the export. | |
| 643 | 650 | fn structure_field(flatten: bool) -> Node { | |
| 644 | - | picker( | |
| 651 | + | Node::Field(Box::new(picker( | |
| 645 | 652 | Setting::Flatten, | |
| 653 | + | "Structure", | |
| 646 | 654 | if flatten { "on" } else { "" }, | |
| 647 | 655 | [ | |
| 648 | 656 | ("", "Preserve tree"), | |
| 649 | 657 | ("on", "Flatten (all files in one folder)"), | |
| 650 | 658 | ], | |
| 651 | - | ) | |
| 659 | + | ))) | |
| 652 | 660 | } | |
| 653 | 661 | ||
| 654 | - | /// One of a handful of choices, drawn as a strip. | |
| 662 | + | /// One of a handful of choices, asked as a question. | |
| 655 | 663 | /// | |
| 656 | - | /// `Selector::Segmented` and not a `Field`, which is the line `settings.rs` | |
| 657 | - | /// drew: a handful of options that do not fold away is a strip, and the shipped | |
| 658 | - | /// screen draws every one of these as a column of radios. Naming the widget | |
| 659 | - | /// would be the description choosing a control; naming "exactly one of these | |
| 660 | - | /// few" is describing the choice. | |
| 664 | + | /// A `Field` and not a `Node::Select`, which is the line the sweep drew: every | |
| 665 | + | /// one of these is a form question wearing a strip -- pick exactly one of a | |
| 666 | + | /// handful, write immediately, no submit -- and only a field has a label, a | |
| 667 | + | /// hint, an error and a note to say it with. `Node::Select` carries a | |
| 668 | + | /// destination per option, which none of these ever used. | |
| 669 | + | /// | |
| 670 | + | /// Returns the `Field` rather than a `Node` so a caller can say what the | |
| 671 | + | /// answer costs before wrapping it; see [`format_field`]. | |
| 661 | 672 | fn picker<'a>( | |
| 662 | 673 | setting: Setting, | |
| 674 | + | label: &str, | |
| 663 | 675 | chosen: &str, | |
| 664 | 676 | options: impl IntoIterator<Item = (&'a str, &'a str)>, | |
| 665 | - | ) -> Node { | |
| 666 | - | Node::Select { | |
| 667 | - | kind: Selector::Segmented, | |
| 668 | - | options: options | |
| 669 | - | .into_iter() | |
| 670 | - | .map(|(value, label)| (Choice::new(value, label), None)) | |
| 671 | - | .collect(), | |
| 672 | - | chosen: Some(chosen.to_owned()), | |
| 673 | - | action: Some(writes(setting)), | |
| 674 | - | } | |
| 677 | + | ) -> Field { | |
| 678 | + | let options = options | |
| 679 | + | .into_iter() | |
| 680 | + | .map(|(value, label)| Choice::new(value, label)) | |
| 681 | + | .collect(); | |
| 682 | + | Field::radio(setting.as_str(), label, options) | |
| 683 | + | .value(chosen) | |
| 684 | + | .changes(writes(setting)) | |
| 675 | 685 | } | |
| 676 | 686 | ||
| 677 | 687 | /// How to name the output files. |
| @@ -86,7 +86,7 @@ | |||
| 86 | 86 | //! screen should not carry a description of a screen that has not been built. | |
| 87 | 87 | //! The shipped window may keep it; there is nothing to port. | |
| 88 | 88 | ||
| 89 | - | use quasi_router::layout::{Selector, Tone}; | |
| 89 | + | use quasi_router::layout::Tone; | |
| 90 | 90 | use quasi_router::{ | |
| 91 | 91 | Act, Action, Choice, Field, Node, RegionKind, Request, Response, RouteError, Router, Screen, | |
| 92 | 92 | Slot, | |
| @@ -100,11 +100,14 @@ | |||
| 100 | 100 | /// The name the device picker submits under. | |
| 101 | 101 | const DEVICE: &str = "device"; | |
| 102 | 102 | ||
| 103 | + | /// The name the chop-method picker submits under. | |
| 104 | + | const HOW: &str = "how"; | |
| 105 | + | ||
| 103 | 106 | /// Register the forge's routes. | |
| 104 | 107 | pub fn routes(router: Router<Panels<'_>>) -> Router<Panels<'_>> { | |
| 105 | 108 | router | |
| 106 | 109 | .get("/forge", index) | |
| 107 | - | .post("/forge/slice/{how}", slice_by) | |
| 110 | + | .post("/forge/slice", slice_by) | |
| 108 | 111 | .post("/forge/set/{knob}", turn) | |
| 109 | 112 | .post("/forge/preview", preview) | |
| 110 | 113 | .post("/forge/chop", chop) | |
| @@ -118,9 +121,18 @@ | |||
| 118 | 121 | Ok(screen(state).into()) | |
| 119 | 122 | } | |
| 120 | 123 | ||
| 121 | - | /// `POST /forge/slice/{how}` | |
| 124 | + | /// `POST /forge/slice` | |
| 125 | + | /// | |
| 126 | + | /// The method arrives in the payload rather than the path, which is what the | |
| 127 | + | /// picker being a field means: one address for the question, and the answer | |
| 128 | + | /// sent under the field's own name. The path form could only ever carry the | |
| 129 | + | /// method already chosen. | |
| 122 | 130 | fn slice_by(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> { | |
| 123 | - | let name = request.captures.require("how")?; | |
| 131 | + | let name = request | |
| 132 | + | .payload | |
| 133 | + | .get(HOW) | |
| 134 | + | .or_else(|| request.payload.get(Node::SELECTED)) | |
| 135 | + | .unwrap_or_default(); | |
| 124 | 136 | let how = Chop::from_key(name).ok_or_else(|| RouteError::not_found("no such chop method"))?; | |
| 125 | 137 | state.forge.slice_by(how); | |
| 126 | 138 | Ok(screen(state).into()) | |
| @@ -248,20 +260,20 @@ | |||
| 248 | 260 | ||
| 249 | 261 | /// Slicing one sample into several. | |
| 250 | 262 | fn chopping(forging: &Forging) -> Slot { | |
| 251 | - | let mut group = Slot::new("forge-chop", RegionKind::Group) | |
| 252 | - | .with(Node::section("Chop")) | |
| 253 | - | .with(Node::Select { | |
| 254 | - | kind: Selector::Segmented, | |
| 255 | - | options: Chop::ALL | |
| 263 | + | // The section heading became the field's label: one name for one question, | |
| 264 | + | // rather than a heading above a control that could not say what it asked. | |
| 265 | + | let mut group = Slot::new("forge-chop", RegionKind::Group).with(Node::Field(Box::new( | |
| 266 | + | Field::radio( | |
| 267 | + | HOW, | |
| 268 | + | "Chop", | |
| 269 | + | Chop::ALL | |
| 256 | 270 | .into_iter() | |
| 257 | - | .map(|how| (Choice::new(how.as_str(), how.label()), None)) | |
| 271 | + | .map(|how| Choice::new(how.as_str(), how.label())) | |
| 258 | 272 | .collect(), | |
| 259 | - | chosen: Some(forging.how.as_str().to_owned()), | |
| 260 | - | action: Some(Action::post(format!( | |
| 261 | - | "/forge/slice/{}", | |
| 262 | - | forging.how.as_str() | |
| 263 | - | ))), | |
| 264 | - | }); | |
| 273 | + | ) | |
| 274 | + | .value(forging.how.as_str()) | |
| 275 | + | .changes(Action::post("/forge/slice")), | |
| 276 | + | ))); | |
| 265 | 277 | ||
| 266 | 278 | // Only the parameters the chosen method reads, which is the shipped | |
| 267 | 279 | // window's own `match` and the settings screen's line: a control that | |
| @@ -274,8 +286,8 @@ | |||
| 274 | 286 | .value(format!("{:.2}", forging.sensitivity)), | |
| 275 | 287 | )), | |
| 276 | 288 | Chop::Equal => group.with(strip( | |
| 277 | - | forging, | |
| 278 | 289 | Knob::Divisions, | |
| 290 | + | "Slices", | |
| 279 | 291 | &forging.divisions.to_string(), | |
| 280 | 292 | [2_usize, 4, 8, 16, 32].map(|n| (n.to_string(), n.to_string())), | |
| 281 | 293 | )), | |
| @@ -287,8 +299,8 @@ | |||
| 287 | 299 | .value(format!("{:.1}", forging.bpm)), | |
| 288 | 300 | )) | |
| 289 | 301 | .with(strip( | |
| 290 | - | forging, | |
| 291 | 302 | Knob::Subdivisions, | |
| 303 | + | "Subdivision", | |
| 292 | 304 | &forging.subdivisions.to_string(), | |
| 293 | 305 | [("1", "1/4"), ("2", "1/8"), ("4", "1/16")] | |
| 294 | 306 | .map(|(value, label)| (value.to_owned(), label.to_owned())), | |
| @@ -415,28 +427,27 @@ | |||
| 415 | 427 | )) | |
| 416 | 428 | } | |
| 417 | 429 | ||
| 418 | - | /// A handful of values that do not fold away. | |
| 430 | + | /// A handful of values that do not fold away, asked as a question. | |
| 419 | 431 | /// | |
| 420 | - | /// `Selector::Segmented` rather than a `Field`, which is the line `settings.rs` | |
| 421 | - | /// drew and the shipped window agrees with: five slice counts and three | |
| 422 | - | /// subdivisions are drawn as rows of selectable buttons, and naming "exactly one | |
| 423 | - | /// of these few" is describing the choice rather than choosing the widget. | |
| 432 | + | /// A `Field` and not a `Node::Select`: five slice counts and three subdivisions | |
| 433 | + | /// are one choice each, written immediately, with no destination per option. | |
| 434 | + | /// Being a field is what gives them a label, which as a strip they had to | |
| 435 | + | /// borrow from a section heading above. | |
| 424 | 436 | fn strip( | |
| 425 | - | forging: &Forging, | |
| 426 | 437 | knob: Knob, | |
| 438 | + | label: &str, | |
| 427 | 439 | chosen: &str, | |
| 428 | 440 | options: impl IntoIterator<Item = (String, String)>, | |
| 429 | 441 | ) -> Node { | |
| 430 | - | let _ = forging; | |
| 431 | - | Node::Select { | |
| 432 | - | kind: Selector::Segmented, | |
| 433 | - | options: options | |
| 434 | - | .into_iter() | |
| 435 | - | .map(|(value, label)| (Choice::new(value, label), None)) | |
| 436 | - | .collect(), | |
| 437 | - | chosen: Some(chosen.to_owned()), | |
| 438 | - | action: Some(writes(knob)), | |
| 439 | - | } | |
| 442 | + | let options = options | |
| 443 | + | .into_iter() | |
| 444 | + | .map(|(value, label)| Choice::new(value, label)) | |
| 445 | + | .collect(); | |
| 446 | + | Node::Field(Box::new( | |
| 447 | + | Field::radio(knob.as_str(), label, options) | |
| 448 | + | .value(chosen) | |
| 449 | + | .changes(writes(knob)), | |
| 450 | + | )) | |
| 440 | 451 | } | |
| 441 | 452 | ||
| 442 | 453 | /// The address a control changing this number calls. |
| @@ -153,7 +153,7 @@ | |||
| 153 | 153 | //! decide whether there is anything to confirm, and that is the app deciding | |
| 154 | 154 | //! when to ask rather than a fact about the question. | |
| 155 | 155 | ||
| 156 | - | use quasi_router::layout::{Family, FieldKind, Readiness, Selector, Tone}; | |
| 156 | + | use quasi_router::layout::{Family, FieldKind, Readiness, Tone}; | |
| 157 | 157 | use quasi_router::{ | |
| 158 | 158 | Accepted, Act, Action, Choice, Field, Figure, Locating, Meter, Node, Outcome, Prose, | |
| 159 | 159 | RegionKind, Request, Response, RouteError, Router, Row, Screen, Slot, Sought, | |
| @@ -183,6 +183,9 @@ | |||
| 183 | 183 | /// The list half of the review screen. | |
| 184 | 184 | const REVIEW_LIST: &str = "review-samples"; | |
| 185 | 185 | ||
| 186 | + | /// The name the review-order picker submits under. | |
| 187 | + | const ORDER: &str = "order"; | |
| 188 | + | ||
| 186 | 189 | /// The reading half of the review screen. | |
| 187 | 190 | const REVIEW_ITEM: &str = "review-item"; | |
| 188 | 191 | ||
| @@ -693,8 +696,8 @@ | |||
| 693 | 696 | fn order(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> { | |
| 694 | 697 | let chosen = request | |
| 695 | 698 | .payload | |
| 696 | - | .get(Node::SELECTED) | |
| 697 | - | .or_else(|| request.payload.get("order")) | |
| 699 | + | .get(ORDER) | |
| 700 | + | .or_else(|| request.payload.get(Node::SELECTED)) | |
| 698 | 701 | .unwrap_or_default(); | |
| 699 | 702 | let order = Order::from_key(chosen).ok_or_else(|| RouteError::not_found("no such order"))?; | |
| 700 | 703 | state.importing.order(order); | |
| @@ -1322,15 +1325,22 @@ | |||
| 1322 | 1325 | ||
| 1323 | 1326 | /// Every sample with something to say about it. | |
| 1324 | 1327 | fn listing(items: &[Reviewed], at: usize, order: Order) -> Slot { | |
| 1325 | - | let mut list = Slot::new(REVIEW_LIST, RegionKind::Pane).with(Node::Select { | |
| 1326 | - | kind: Selector::Segmented, | |
| 1327 | - | options: Order::ALL | |
| 1328 | - | .into_iter() | |
| 1329 | - | .map(|order| (Choice::new(order.as_str(), order.label()), None)) | |
| 1330 | - | .collect(), | |
| 1331 | - | chosen: Some(order.as_str().to_owned()), | |
| 1332 | - | action: Some(Action::post("/import/review/order")), | |
| 1333 | - | }); | |
| 1328 | + | // Labelled, though it is the first thing in the review pane and the shipped | |
| 1329 | + | // screen draws it bare. Same reason as the search scope in `toolbar`: a | |
| 1330 | + | // description that omits the label is saying the question has no name, and | |
| 1331 | + | // no renderer special-cases an empty one. | |
| 1332 | + | let mut list = Slot::new(REVIEW_LIST, RegionKind::Pane).with(Node::Field(Box::new( | |
| 1333 | + | Field::radio( | |
| 1334 | + | ORDER, | |
| 1335 | + | "Order", | |
| 1336 | + | Order::ALL | |
| 1337 | + | .into_iter() | |
| 1338 | + | .map(|order| Choice::new(order.as_str(), order.label())) | |
| 1339 | + | .collect(), | |
| 1340 | + | ) | |
| 1341 | + | .value(order.as_str()) | |
| 1342 | + | .changes(Action::post("/import/review/order")), | |
| 1343 | + | ))); | |
| 1334 | 1344 | ||
| 1335 | 1345 | if items.is_empty() { | |
| 1336 | 1346 | return list.with(Node::empty("Nothing was analysed.")); |
| @@ -104,7 +104,7 @@ | |||
| 104 | 104 | //! [`Readiness::Pending`](quasi_router::layout::Readiness::Pending) and the | |
| 105 | 105 | //! renderer owns what waiting looks like. | |
| 106 | 106 | ||
| 107 | - | use quasi_router::layout::{FieldKind, Selector, Tone}; | |
| 107 | + | use quasi_router::layout::{FieldKind, Tone}; | |
| 108 | 108 | use quasi_router::{ | |
| 109 | 109 | Act, Action, Choice, Field, Node, RegionKind, Request, Response, RouteError, Router, Screen, | |
| 110 | 110 | Slot, | |
| @@ -123,11 +123,15 @@ | |||
| 123 | 123 | ||
| 124 | 124 | /// The cadences the panel offers, in minutes. | |
| 125 | 125 | /// | |
| 126 | - | /// The same four the shipped panel has. A [`Selector::Segmented`] rather than a | |
| 127 | - | /// number, because four named choices is a strip and not a range: the shipped | |
| 128 | - | /// panel draws pills, and a renderer with no pills draws a small select. | |
| 126 | + | /// The same four the shipped panel has. A choice among four rather than a | |
| 127 | + | /// number, because four named cadences is a question with four answers and not | |
| 128 | + | /// a range: the shipped panel draws pills, and a renderer with no pills draws a | |
| 129 | + | /// column of radios or a small select. | |
| 129 | 130 | const INTERVALS: &[u32] = &[5, 15, 30, 60]; | |
| 130 | 131 | ||
| 132 | + | /// The name the interval picker submits under. | |
| 133 | + | const INTERVAL: &str = "interval"; | |
| 134 | + | ||
| 131 | 135 | /// Register this screen's routes. | |
| 132 | 136 | pub fn routes(router: Router<super::Panels<'_>>) -> Router<super::Panels<'_>> { | |
| 133 | 137 | router | |
| @@ -209,7 +213,8 @@ | |||
| 209 | 213 | fn interval(state: &super::Panels<'_>, request: Request) -> Result<Response, RouteError> { | |
| 210 | 214 | let minutes: u32 = request | |
| 211 | 215 | .payload | |
| 212 | - | .get(Node::SELECTED) | |
| 216 | + | .get(INTERVAL) | |
| 217 | + | .or_else(|| request.payload.get(Node::SELECTED)) | |
| 213 | 218 | .and_then(|value| value.parse().ok()) | |
| 214 | 219 | .ok_or_else(|| RouteError::not_found("no such interval"))?; | |
| 215 | 220 | if !INTERVALS.contains(&minutes) { | |
| @@ -430,20 +435,21 @@ | |||
| 430 | 435 | .value(if status.auto_sync_enabled { "on" } else { "" }) | |
| 431 | 436 | .changes(Action::post("/sync/auto")), | |
| 432 | 437 | ))) | |
| 433 | - | .with(Node::Select { | |
| 434 | - | kind: Selector::Segmented, | |
| 435 | - | options: INTERVALS | |
| 436 | - | .iter() | |
| 437 | - | .map(|minutes| { | |
| 438 | - | ( | |
| 439 | - | Choice::new(minutes.to_string(), format!("{minutes} min")), | |
| 440 | - | None, | |
| 441 | - | ) | |
| 442 | - | }) | |
| 443 | - | .collect(), | |
| 444 | - | chosen: Some(status.sync_interval_minutes.to_string()), | |
| 445 | - | action: Some(Action::post("/sync/interval")), | |
| 446 | - | }) | |
| 438 | + | // A label at last: the "Auto-sync" heading above names the checkbox, so | |
| 439 | + | // as a bare strip this control asked its question without ever saying | |
| 440 | + | // what it was. A field cannot be unlabelled. | |
| 441 | + | .with(Node::Field(Box::new( | |
| 442 | + | Field::radio( | |
| 443 | + | INTERVAL, | |
| 444 | + | "Interval", | |
| 445 | + | INTERVALS | |
| 446 | + | .iter() | |
| 447 | + | .map(|minutes| Choice::new(minutes.to_string(), format!("{minutes} min"))) | |
| 448 | + | .collect(), | |
| 449 | + | ) | |
| 450 | + | .value(status.sync_interval_minutes.to_string()) | |
| 451 | + | .changes(Action::post("/sync/interval")), | |
| 452 | + | ))) | |
| 447 | 453 | .with(Node::section("Audio file sync")) | |
| 448 | 454 | .with(subscription(sync)) | |
| 449 | 455 | .with(Node::Act( |
| @@ -2294,10 +2294,16 @@ | |||
| 2294 | 2294 | let said = said(&screen); | |
| 2295 | 2295 | ||
| 2296 | 2296 | assert!(said.contains("2 samples to export"), "{said}"); | |
| 2297 | + | let asked = asked(&screen); | |
| 2297 | 2298 | // No profiles, so the picker is not offered at all rather than offered | |
| 2298 | 2299 | // empty: an empty dropdown is a control that cannot be used. | |
| 2299 | - | assert!(!said.contains("Device Profile"), "{said}"); | |
| 2300 | - | assert!(said.contains("Format"), "{said}"); | |
| 2300 | + | assert!( | |
| 2301 | + | !asked.iter().any(|label| label == "Device profile"), | |
| 2302 | + | "{asked:?}" | |
| 2303 | + | ); | |
| 2304 | + | // A field label rather than a section heading: the picker names its own | |
| 2305 | + | // question now. | |
| 2306 | + | assert!(asked.iter().any(|label| label == "Format"), "{asked:?}"); | |
| 2301 | 2307 | assert!(said.contains("Destination"), "{said}"); | |
| 2302 | 2308 | } | |
| 2303 | 2309 | ||
| @@ -2312,14 +2318,19 @@ | |||
| 2312 | 2318 | settings: defaults(), | |
| 2313 | 2319 | available_bytes: None, | |
| 2314 | 2320 | }); | |
| 2315 | - | let said_of_original = said(&exported(&original)); | |
| 2321 | + | let original = exported(&original); | |
| 2322 | + | let asked_of_original = asked(&original); | |
| 2316 | 2323 | assert!( | |
| 2317 | - | !said_of_original.contains("Sample Rate"), | |
| 2318 | - | "{said_of_original}" | |
| 2324 | + | !asked_of_original.iter().any(|label| label == "Sample rate"), | |
| 2325 | + | "{asked_of_original:?}" | |
| 2319 | 2326 | ); | |
| 2327 | + | // The warning is the format field's note now, not a banner under it: what | |
| 2328 | + | // re-encoding costs, attached to the answer that chose it. | |
| 2320 | 2329 | assert!( | |
| 2321 | - | !said_of_original.contains("strips embedded metadata"), | |
| 2322 | - | "{said_of_original}" | |
| 2330 | + | !deep_fields(&original) | |
| 2331 | + | .into_iter() | |
| 2332 | + | .any(|field| field.note.is_some()), | |
| 2333 | + | "nothing is being re-encoded, so nothing costs anything" | |
| 2323 | 2334 | ); | |
| 2324 | 2335 | ||
| 2325 | 2336 | let wav = FakeExport::at(Phase::Configuring { | |
| @@ -2331,13 +2342,23 @@ | |||
| 2331 | 2342 | }, | |
| 2332 | 2343 | available_bytes: None, | |
| 2333 | 2344 | }); | |
| 2334 | - | let said_of_wav = said(&exported(&wav)); | |
| 2335 | - | assert!(said_of_wav.contains("Sample Rate"), "{said_of_wav}"); | |
| 2336 | - | assert!(said_of_wav.contains("Bit Depth"), "{said_of_wav}"); | |
| 2345 | + | let wav = exported(&wav); | |
| 2346 | + | let asked_of_wav = asked(&wav); | |
| 2337 | 2347 | assert!( | |
| 2338 | - | said_of_wav.contains("strips embedded metadata"), | |
| 2339 | - | "{said_of_wav}" | |
| 2348 | + | asked_of_wav.iter().any(|label| label == "Sample rate"), | |
| 2349 | + | "{asked_of_wav:?}" | |
| 2340 | 2350 | ); | |
| 2351 | + | assert!( | |
| 2352 | + | asked_of_wav.iter().any(|label| label == "Bit depth"), | |
| 2353 | + | "{asked_of_wav:?}" | |
| 2354 | + | ); | |
| 2355 | + | let note = deep_fields(&wav) | |
| 2356 | + | .into_iter() | |
| 2357 | + | .find(|field| field.label == "Format") | |
| 2358 | + | .and_then(|field| field.note) | |
| 2359 | + | .expect("the format field says what re-encoding costs"); | |
| 2360 | + | assert_eq!(note.0, quasi_router::layout::Tone::Warning); | |
| 2361 | + | assert!(note.1.contains("strips embedded metadata"), "{}", note.1); | |
| 2341 | 2362 | } | |
| 2342 | 2363 | ||
| 2343 | 2364 | #[test] | |
| @@ -7347,6 +7368,19 @@ | |||
| 7347 | 7368 | .collect() | |
| 7348 | 7369 | } | |
| 7349 | 7370 | ||
| 7371 | + | /// The label of every field on the screen, however deeply placed. | |
| 7372 | + | /// | |
| 7373 | + | /// A companion to [`said`] for the questions a screen asks. Since the segmented | |
| 7374 | + | /// pickers became fields, a heading that used to name one is the field's own | |
| 7375 | + | /// label, so an assertion about what a screen names reads here rather than in | |
| 7376 | + | /// its prose. | |
| 7377 | + | fn asked(screen: &Screen) -> Vec<String> { | |
| 7378 | + | deep_fields(screen) | |
| 7379 | + | .into_iter() | |
| 7380 | + | .map(|field| field.label) | |
| 7381 | + | .collect() | |
| 7382 | + | } | |
| 7383 | + | ||
| 7350 | 7384 | /// What one part of a row says. | |
| 7351 | 7385 | fn said_in(row: &quasi_router::Row, part: quasi_router::layout::RowPart) -> String { | |
| 7352 | 7386 | row.role(part) | |
| @@ -8657,8 +8691,10 @@ | |||
| 8657 | 8691 | let said = deep_said(&screen); | |
| 8658 | 8692 | ||
| 8659 | 8693 | assert!(said.contains("Working..."), "{said}"); | |
| 8660 | - | assert!(said.contains("Chop"), "{said}"); | |
| 8661 | 8694 | assert!(said.contains("Batch"), "{said}"); | |
| 8695 | + | // Chop's heading is the picker's own label now, which is the call the | |
| 8696 | + | // conform section already made below. | |
| 8697 | + | assert!(asked(&screen).iter().any(|label| label == "Chop"), "{said}"); | |
| 8662 | 8698 | // The conform section's heading is the picker's own label, which is the | |
| 8663 | 8699 | // shipped screen's call: the question names itself and the `strong` line | |
| 8664 | 8700 | // above it went. | |
| @@ -8708,15 +8744,20 @@ | |||
| 8708 | 8744 | "{named:?}" | |
| 8709 | 8745 | ); | |
| 8710 | 8746 | ||
| 8711 | - | // Divisions is a strip of a handful of values rather than a field, which is | |
| 8712 | - | // what the shipped row of selectable buttons is. | |
| 8747 | + | // Divisions is a field like the rest of them: a handful of values that do | |
| 8748 | + | // not fold away is still one question with one answer, and as a strip it | |
| 8749 | + | // had no label of its own to ask it with. | |
| 8713 | 8750 | let equal = FakeForge::with(forging()); | |
| 8714 | 8751 | let named: Vec<String> = deep_fields(&forge_screen(&equal)) | |
| 8715 | 8752 | .into_iter() | |
| 8716 | 8753 | .map(|field| field.name) | |
| 8717 | 8754 | .collect(); | |
| 8718 | 8755 | assert!( | |
| 8719 | - | !named.contains(&Knob::Divisions.as_str().to_owned()), | |
| 8756 | + | named.contains(&Knob::Divisions.as_str().to_owned()), | |
| 8757 | + | "{named:?}" | |
| 8758 | + | ); | |
| 8759 | + | assert!( | |
| 8760 | + | !named.contains(&Knob::Subdivisions.as_str().to_owned()), | |
| 8720 | 8761 | "{named:?}" | |
| 8721 | 8762 | ); | |
| 8722 | 8763 | } | |
| @@ -8895,7 +8936,16 @@ | |||
| 8895 | 8936 | ||
| 8896 | 8937 | // A name the description does not know is a refusal rather than a no-op. | |
| 8897 | 8938 | assert!(forged(&forge, Request::post("/forge/set/vibes")).is_err()); | |
| 8898 | - | assert!(forged(&forge, Request::post("/forge/slice/sideways")).is_err()); | |
| 8939 | + | // The method travels in the payload now, so an unknown one is a refusal by | |
| 8940 | + | // the route rather than by the router failing to match a path. | |
| 8941 | + | assert!( | |
| 8942 | + | forged( | |
| 8943 | + | &forge, | |
| 8944 | + | posting("/forge/slice", Params::new().with("how", "sideways")), | |
| 8945 | + | ) | |
| 8946 | + | .is_err() | |
| 8947 | + | ); | |
| 8948 | + | assert!(forged(&forge, Request::post("/forge/slice")).is_err()); | |
| 8899 | 8949 | } | |
| 8900 | 8950 | ||
| 8901 | 8951 | #[test] |
| @@ -85,7 +85,7 @@ | |||
| 85 | 85 | //! host draws it rather than what it holds, so no finding — but a vocabulary | |
| 86 | 86 | //! that grows anchoring should know this was the first place it mattered. | |
| 87 | 87 | ||
| 88 | - | use quasi_router::layout::{self, FieldKind, Priority, Selector, Tone, Width}; | |
| 88 | + | use quasi_router::layout::{self, FieldKind, Priority, Tone, Width}; | |
| 89 | 89 | use quasi_router::{ | |
| 90 | 90 | Act, Action, Choice, Field, Node, RegionKind, Request, Response, RouteError, Router, Screen, | |
| 91 | 91 | Slot, | |
| @@ -98,6 +98,9 @@ | |||
| 98 | 98 | ||
| 99 | 99 | /// What a search submits. | |
| 100 | 100 | const QUERY: &str = "query"; | |
| 101 | + | ||
| 102 | + | /// The name the search-scope picker submits under. | |
| 103 | + | const SCOPE: &str = "scope"; | |
| 101 | 104 | /// What the save-as-collection form submits. | |
| 102 | 105 | const NAME: &str = "name"; | |
| 103 | 106 | ||
| @@ -131,7 +134,11 @@ | |||
| 131 | 134 | ||
| 132 | 135 | /// `POST /search/scope` | |
| 133 | 136 | fn scope(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> { | |
| 134 | - | let chosen = request.payload.get(Node::SELECTED).unwrap_or_default(); | |
| 137 | + | let chosen = request | |
| 138 | + | .payload | |
| 139 | + | .get(SCOPE) | |
| 140 | + | .or_else(|| request.payload.get(Node::SELECTED)) | |
| 141 | + | .unwrap_or_default(); | |
| 135 | 142 | match chosen { | |
| 136 | 143 | "all" => state.bar.set_scope(true), | |
| 137 | 144 | "folder" => state.bar.set_scope(false), | |
| @@ -507,22 +514,27 @@ | |||
| 507 | 514 | Priority::Essential, | |
| 508 | 515 | ) | |
| 509 | 516 | .beside( | |
| 510 | - | Node::Select { | |
| 511 | - | kind: Selector::Segmented, | |
| 512 | - | options: vec![ | |
| 513 | - | (Choice::new("folder", "This folder"), None), | |
| 514 | - | (Choice::new("all", "Everywhere"), None), | |
| 515 | - | ], | |
| 516 | - | chosen: Some( | |
| 517 | - | if searching.everywhere { | |
| 518 | - | "all" | |
| 519 | - | } else { | |
| 520 | - | "folder" | |
| 521 | - | } | |
| 522 | - | .to_owned(), | |
| 523 | - | ), | |
| 524 | - | action: Some(Action::post("/search/scope")), | |
| 525 | - | }, | |
| 517 | + | // Labelled, though it sits in a toolbar row and the shipped bar | |
| 518 | + | // draws two bare pills. "This host has no room for a label" is a | |
| 519 | + | // renderer's judgement; a description that leaves the label out is | |
| 520 | + | // encoding the absence as intent, and every renderer would then | |
| 521 | + | // have to guess what the two words mean. | |
| 522 | + | Node::Field(Box::new( | |
| 523 | + | Field::radio( | |
| 524 | + | SCOPE, | |
| 525 | + | "Scope", | |
| 526 | + | vec![ | |
| 527 | + | Choice::new("folder", "This folder"), | |
| 528 | + | Choice::new("all", "Everywhere"), | |
| 529 | + | ], | |
| 530 | + | ) | |
| 531 | + | .value(if searching.everywhere { | |
| 532 | + | "all" | |
| 533 | + | } else { | |
| 534 | + | "folder" | |
| 535 | + | }) | |
| 536 | + | .changes(Action::post("/search/scope")), | |
| 537 | + | )), | |
| 526 | 538 | Priority::Secondary, | |
| 527 | 539 | ); | |
| 528 | 540 |