Skip to main content

max / audiofiles

Ask the segmented pickers as fields, not as Node::Select Seven of the eight construction sites were form questions wearing a strip: pick one of a handful, write immediately, no submit, and `action: None` on every option. Node::Select carries a destination per option, which none of them used, and it has no label, hint, error or note to ask with. They are Field::radio now. The section headings above them became the fields' own labels, and the export screen's re-encoding banner became the format field's note, which is the message channel the member was missing. Two controls gained a label they never had: the auto-sync interval, whose "Auto-sync" heading names the checkbox above it, and the search scope and review order, which were unlabelled in chrome. A description that omits the label says the question has no name; where to put it is the renderer's call. Two routes moved the answer out of the path and into the payload, because a field submits under its own name: POST /forge/slice no longer captures the method it is being asked to leave. help.rs's Selector::Tabs stays: it is the one genuine tab strip.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-28 18:36 UTC
Signed with PGP, not checked
Commit: be473de6a33aba5cdf61a8de2e87857f11fe7d32
Parent: b928002
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