Skip to main content

max / audiofiles

Describe the three forms the vocabulary could not hold The gaps this port filed are closed upstream (makeover-layout 0.28.0), so the three conversions it stopped short of are done. The forge's conform picker is a described select: its 'Select device...' was a ComboBox selected_text, and is the field's placeholder now, so the instruction sits in the control rather than on the disabled button beside it. The instrument mode selector is a described radio group. Multi-sample is offered with Choice::unavailable rather than drawn as a permanently disabled RadioButton plus a hover plus a line of prose under the row; InstrumentMode gains as_value/from_value so the value the control submits and the value the app matches on are one pairing, with tests. The per-tag thresholds, the layer weight and the cluster count are FieldKind::Range. The two ends are the question for each of them and were drawn by egui's Slider with a hand-written label beside it. Not converted, with reasons: the storage cap picker is logarithmic and nothing describes a non-linear scale, and the filter panel's Min/Max pair is one question with two values, which a single-valued range cannot say. Both filed.
Author: Max Johnson <me@maxj.phd> · 2026-08-17 18:58 UTC
Commit: a544d478d7b79968b03a2d7b2f7cbaed83132d58
Parent: 21dd4e6
10 files changed, +297 insertions, -210 deletions
M Cargo.lock +44 -32
@@ -3072,9 +3072,7 @@
3072 3072
3073 3073 [[package]]
3074 3074 name = "makeover-immediate"
3075 - version = "0.27.0"
3076 - source = "registry+https://github.com/rust-lang/crates.io-index"
3077 - checksum = "70330efb17e6cb4fdc10e6483960dcef51d3cd2770b89565a11e3697badaeab0"
3075 + version = "0.28.0"
3078 3076 dependencies = [
3079 3077 "egui",
3080 3078 "egui_extras",
@@ -3083,9 +3081,7 @@
3083 3081
3084 3082 [[package]]
3085 3083 name = "makeover-layout"
3086 - version = "0.27.5"
3087 - source = "registry+https://github.com/rust-lang/crates.io-index"
3088 - checksum = "ed4a0a89e118db1cc010c72e696741b3701c83bfe6bec5db06e29515b4c1d216"
3084 + version = "0.28.0"
3089 3085
3090 3086 [[package]]
3091 3087 name = "maplit"
@@ -4244,7 +4240,7 @@
4244 4240
4245 4241 [[package]]
4246 4242 name = "quasi-immediate"
4247 - version = "0.18.0"
4243 + version = "0.19.0"
4248 4244 dependencies = [
4249 4245 "docengine",
4250 4246 "egui",
@@ -4254,7 +4250,7 @@
4254 4250
4255 4251 [[package]]
4256 4252 name = "quasi-router"
4257 - version = "0.18.0"
4253 + version = "0.19.0"
4258 4254 dependencies = [
4259 4255 "makeover-layout",
4260 4256 ]
@@ -7543,6 +7539,46 @@
7543 7539 "winnow 1.0.4",
7544 7540 ]
7545 7541
7542 + [[patch.unused]]
7543 + name = "quasi-axum"
7544 + version = "0.19.0"
7545 +
7546 + [[patch.unused]]
7547 + name = "quasi-basics"
7548 + version = "0.19.0"
7549 +
7550 + [[patch.unused]]
7551 + name = "quasi-http"
7552 + version = "0.19.0"
7553 +
7554 + [[patch.unused]]
7555 + name = "quasi-store"
7556 + version = "0.1.0"
7557 +
7558 + [[patch.unused]]
7559 + name = "quasi-tauri"
7560 + version = "0.19.0"
7561 +
7562 + [[patch.unused]]
7563 + name = "quasi-webview"
7564 + version = "0.19.0"
7565 +
7566 + [[patch.unused]]
7567 + name = "quasi-type"
7568 + version = "0.1.0"
7569 +
7570 + [[patch.unused]]
7571 + name = "makeover-touch"
7572 + version = "0.20.0"
7573 +
7574 + [[patch.unused]]
7575 + name = "makeover-tui"
7576 + version = "0.27.0"
7577 +
7578 + [[patch.unused]]
7579 + name = "makeover-webview"
7580 + version = "0.47.0"
7581 +
7546 7582 [[patch.unused]]
7547 7583 name = "kberg"
7548 7584 version = "0.1.0"
@@ -7554,27 +7590,3 @@
7554 7590 [[patch.unused]]
7555 7591 name = "painhours"
7556 7592 version = "0.1.0"
7557 -
7558 - [[patch.unused]]
7559 - name = "quasi-axum"
7560 - version = "0.18.0"
7561 -
7562 - [[patch.unused]]
7563 - name = "quasi-basics"
7564 - version = "0.18.0"
7565 -
7566 - [[patch.unused]]
7567 - name = "quasi-http"
7568 - version = "0.18.0"
7569 -
7570 - [[patch.unused]]
7571 - name = "quasi-store"
7572 - version = "0.1.0"
7573 -
7574 - [[patch.unused]]
7575 - name = "quasi-tauri"
7576 - version = "0.18.0"
7577 -
7578 - [[patch.unused]]
7579 - name = "quasi-webview"
7580 - version = "0.18.0"
M Cargo.toml +4 -4
@@ -17,12 +17,12 @@
17 17 # to compile against an API added in a later one. The two move together --
18 18 # makeover-immediate re-exports nothing, so the `Column` the app describes and
19 19 # the `Column` the renderer matches on have to be the same type.
20 - makeover-layout = "0.27.5"
21 - makeover-immediate = "0.27.0"
20 + makeover-layout = "0.28.0"
21 + makeover-immediate = "0.28.0"
22 22 # The described screens, behind audiofiles-browser's `quasi` feature. By git URL
23 23 # with a version requirement, per the tree's rule for cross-repo deps.
24 - quasi-router = { git = "https://makenot.work/git/max/quasi.git", version = "0.18" }
25 - quasi-immediate = { git = "https://makenot.work/git/max/quasi.git", version = "0.18" }
24 + quasi-router = { git = "https://makenot.work/git/max/quasi.git", version = "0.19" }
25 + quasi-immediate = { git = "https://makenot.work/git/max/quasi.git", version = "0.19" }
26 26 egui = { version = "0.35", default-features = false, features = ["default_fonts"] }
27 27 egui_extras = { version = "0.35", default-features = false }
28 28 eframe = { version = "0.35", default-features = false, features = ["default_fonts", "glow"] }
@@ -9,6 +9,55 @@
9 9 MultiSample,
10 10 }
11 11
12 + impl InstrumentMode {
13 + /// What a described field submits for this mode.
14 + ///
15 + /// `EditResultMode`'s pairing, for its reason: the value a described option
16 + /// carries and the value the app matches on would otherwise be two
17 + /// hand-kept literals, and a disagreement between them is not a compile
18 + /// error, it is a control that answers with a mode nothing recognises.
19 + #[must_use]
20 + pub const fn as_value(self) -> &'static str {
21 + match self {
22 + Self::Chromatic => "chromatic",
23 + Self::MultiSample => "multi",
24 + }
25 + }
26 +
27 + /// The mode a described field submitted, if it named one.
28 + ///
29 + /// `None` for anything else, which is the honest answer rather than a
30 + /// silent fall back to `Chromatic`: a value no option carries means the
31 + /// control and this pairing have drifted, and that is what the test below
32 + /// exists to catch.
33 + #[must_use]
34 + pub fn from_value(value: &str) -> Option<Self> {
35 + match value {
36 + "chromatic" => Some(Self::Chromatic),
37 + "multi" => Some(Self::MultiSample),
38 + _ => None,
39 + }
40 + }
41 + }
42 +
43 + #[cfg(test)]
44 + mod mode_tests {
45 + use super::InstrumentMode;
46 +
47 + #[test]
48 + fn every_mode_round_trips_through_the_value_a_described_field_submits() {
49 + for mode in [InstrumentMode::Chromatic, InstrumentMode::MultiSample] {
50 + assert_eq!(InstrumentMode::from_value(mode.as_value()), Some(mode));
51 + }
52 + }
53 +
54 + #[test]
55 + fn a_value_no_option_carries_is_not_quietly_chromatic() {
56 + assert_eq!(InstrumentMode::from_value(""), None);
57 + assert_eq!(InstrumentMode::from_value("Chromatic"), None);
58 + }
59 + }
60 +
12 61 /// ADSR envelope parameters.
13 62 #[derive(Debug, Clone, Copy)]
14 63 pub struct AdsrEnvelope {
@@ -12,6 +12,51 @@
12 12 use super::widgets;
13 13 use crate::state::BrowserState;
14 14
15 + /// A confidence between never and only-on-certainty, as a described field.
16 + ///
17 + /// `makeover-layout` 0.28.0's `FieldKind::Range`. The two ends are the question
18 + /// here and not a rule the answer is checked against: 0 is never and 1 is
19 + /// only-on-certainty, and a typed 0.72 means nothing without both of them on
20 + /// screen. That is why these were the measured sites on the gap (`fb93426b`)
21 + /// and why they stayed hand-rolled until it closed.
22 + ///
23 + /// The value round-trips through a string because that is what the description
24 + /// carries and what every renderer of it takes: a webview reads a string out of
25 + /// the DOM and a terminal keeps an edit buffer, so a number would be this
26 + /// renderer's convenience imposed on the other two. Rebuilt per frame rather
27 + /// than held, which a slider allows and a text box does not: there is no
28 + /// half-typed state to lose.
29 + fn threshold_field(
30 + ui: &mut egui::Ui,
31 + name: &str,
32 + label: &str,
33 + value: &mut f32,
34 + ) -> Option<egui::Response> {
35 + let described = makeover_layout::Field {
36 + step: Some(THRESHOLD_STEP),
37 + ..makeover_layout::Field::range(name, label, "0", "1")
38 + };
39 + let mut text = format!("{value:.2}");
40 + let response = widgets::field(
41 + ui,
42 + &described,
43 + makeover_immediate::Filling::Text(&mut text),
44 + None,
45 + );
46 + if let Ok(parsed) = text.parse::<f32>() {
47 + *value = parsed;
48 + }
49 + response
50 + }
51 +
52 + /// The granularity a confidence moves in.
53 + ///
54 + /// Two decimals, which is what the thresholds were already stored and shown at.
55 + /// Without it the host's own granularity stands, and egui's is continuous, so a
56 + /// drag would write back 0.7200000000000001 and the next comparison against a
57 + /// stored value would be against a number nobody chose.
58 + const THRESHOLD_STEP: &str = "0.01";
59 +
15 60 /// Fields offered in the condition builder (path/name + tags first, then DSP).
16 61 const FIELDS: &[RuleField] = &[
17 62 RuleField::Name,
@@ -609,24 +654,22 @@
609 654 ui.horizontal(|ui| {
610 655 ui.label(egui::RichText::new(tag).small());
611 656 });
657 + // Side by side rather than stacked: each is its own question
658 + // and takes its own label above its own bar, and the pair is
659 + // read across. Two described fields in a `horizontal` is the
660 + // shape the export screen's conditional fields already have.
612 661 ui.horizontal(|ui| {
613 - ui.label(
614 - egui::RichText::new("review")
615 - .small()
616 - .color(theme::content_muted()),
617 - );
618 - let r = ui.add(egui::Slider::new(&mut review, 0.0..=1.0).show_value(true));
619 - ui.label(
620 - egui::RichText::new("auto")
621 - .small()
622 - .color(theme::content_muted()),
623 - );
624 - let a = ui.add(egui::Slider::new(&mut auto, 0.0..=1.0).show_value(true));
625 - if r.drag_stopped()
626 - || a.drag_stopped()
627 - || (r.changed() && !r.dragged())
628 - || (a.changed() && !a.dragged())
629 - {
662 + let r = threshold_field(ui, "review_threshold", "review", &mut review);
663 + let a = threshold_field(ui, "auto_threshold", "auto", &mut auto);
664 + // Committed on release, or on a change that was not a drag
665 + // (a keyboard nudge), so a drag across the bar is one write
666 + // and not one per frame. Unchanged by the conversion.
667 + let settled = |response: Option<egui::Response>| {
668 + response.is_some_and(|response| {
669 + response.drag_stopped() || (response.changed() && !response.dragged())
670 + })
671 + };
672 + if settled(r) || settled(a) {
630 673 changed = Some((tag.clone(), review as f64, auto as f64));
631 674 }
632 675 });
@@ -805,8 +848,25 @@
805 848 let busy = state.classifier.busy.is_some();
806 849 ui.horizontal(|ui| {
807 850 let mut k = state.classifier.cluster_k.max(2);
808 - ui.label("Groups");
809 - if ui.add(egui::Slider::new(&mut k, 2..=24)).changed() {
851 + // A whole number between two ends, so the step is 1 and the
852 + // ends are the question the same way a threshold's are: 2 is
853 + // the fewest groups worth having and 24 is where the grouping
854 + // stops telling you anything.
855 + let described = makeover_layout::Field {
856 + step: Some("1"),
857 + ..makeover_layout::Field::range("cluster_k", "Groups", "2", "24")
858 + };
859 + let mut text = k.to_string();
860 + let response = widgets::field(
861 + ui,
862 + &described,
863 + makeover_immediate::Filling::Text(&mut text),
864 + None,
865 + );
866 + if let Ok(parsed) = text.parse() {
867 + k = parsed;
868 + }
869 + if response.is_some_and(|response| response.changed()) {
810 870 state.classifier.cluster_k = k;
811 871 }
812 872 if ui
@@ -1116,19 +1176,14 @@
1116 1176 });
1117 1177 // Row B: weight + remove (with inline two-step confirm, destructive, no undo).
1118 1178 ui.horizontal(|ui| {
1119 - ui.label(
1120 - egui::RichText::new("weight")
1121 - .small()
1122 - .color(theme::content_muted()),
1123 - );
1124 1179 let mut w = layer.weight as f32;
1125 - let r = ui
1126 - .add(egui::Slider::new(&mut w, 0.0..=1.0).show_value(true))
1127 - .on_hover_text(
1180 + let r = threshold_field(ui, "layer_weight", "weight", &mut w).map(|response| {
1181 + response.on_hover_text(
1128 1182 "How much this layer counts when matching. Your own tags are always \
1129 1183 1.0; lower means weaker.",
1130 - );
1131 - if r.drag_stopped() || (r.changed() && !r.dragged()) {
1184 + )
1185 + });
1186 + if r.is_some_and(|r| r.drag_stopped() || (r.changed() && !r.dragged())) {
1132 1187 weight_change = Some((layer.id.clone(), w as f64));
1133 1188 }
1134 1189 if confirming {
@@ -605,14 +605,8 @@
605 605 /// stored under, so the described answer and the persisted one cannot drift
606 606 /// apart.
607 607 const RESULT_MODES: [makeover_layout::Choice<'static>; 2] = [
608 - makeover_layout::Choice {
609 - value: EditResultMode::Replace.as_value(),
610 - label: "Replace original",
611 - },
612 - makeover_layout::Choice {
613 - value: EditResultMode::Sibling.as_value(),
614 - label: "Create sibling",
615 - },
608 + makeover_layout::Choice::new(EditResultMode::Replace.as_value(), "Replace original"),
609 + makeover_layout::Choice::new(EditResultMode::Sibling.as_value(), "Create sibling"),
616 610 ];
617 611
618 612 /// Result mode section: replace original vs create sibling.
@@ -323,7 +323,7 @@
323 323 .collect();
324 324 let profile_choices: Vec<makeover_layout::Choice<'_>> = profile_options
325 325 .iter()
326 - .map(|(value, label)| makeover_layout::Choice { value, label })
326 + .map(|(value, label)| makeover_layout::Choice::new(value, label))
327 327 .collect();
328 328
329 329 // The chosen device's readout, which stays a block under the field rather
@@ -407,74 +407,29 @@
407 407 };
408 408
409 409 const FORMATS: [makeover_layout::Choice<'_>; 3] = [
410 - makeover_layout::Choice {
411 - value: "original",
412 - label: "Original (copy as-is)",
413 - },
414 - makeover_layout::Choice {
415 - value: "wav",
416 - label: "WAV (decode and re-encode)",
417 - },
418 - makeover_layout::Choice {
419 - value: "aiff",
420 - label: "AIFF (decode and re-encode)",
421 - },
410 + makeover_layout::Choice::new("original", "Original (copy as-is)"),
411 + makeover_layout::Choice::new("wav", "WAV (decode and re-encode)"),
412 + makeover_layout::Choice::new("aiff", "AIFF (decode and re-encode)"),
422 413 ];
423 414 const RATES: [makeover_layout::Choice<'_>; 4] = [
424 - makeover_layout::Choice {
425 - value: "",
426 - label: "Original",
427 - },
428 - makeover_layout::Choice {
429 - value: "44100",
430 - label: "44,100 Hz",
431 - },
432 - makeover_layout::Choice {
433 - value: "48000",
434 - label: "48,000 Hz",
435 - },
436 - makeover_layout::Choice {
437 - value: "96000",
438 - label: "96,000 Hz",
439 - },
415 + makeover_layout::Choice::new("", "Original"),
416 + makeover_layout::Choice::new("44100", "44,100 Hz"),
417 + makeover_layout::Choice::new("48000", "48,000 Hz"),
418 + makeover_layout::Choice::new("96000", "96,000 Hz"),
440 419 ];
441 420 const DEPTHS: [makeover_layout::Choice<'_>; 3] = [
442 - makeover_layout::Choice {
443 - value: "",
444 - label: "Original",
445 - },
446 - makeover_layout::Choice {
447 - value: "16",
448 - label: "16-bit",
449 - },
450 - makeover_layout::Choice {
451 - value: "24",
452 - label: "24-bit",
453 - },
421 + makeover_layout::Choice::new("", "Original"),
422 + makeover_layout::Choice::new("16", "16-bit"),
423 + makeover_layout::Choice::new("24", "24-bit"),
454 424 ];
455 425 const CHANNELS: [makeover_layout::Choice<'_>; 3] = [
456 - makeover_layout::Choice {
457 - value: "original",
458 - label: "Original",
459 - },
460 - makeover_layout::Choice {
461 - value: "mono",
462 - label: "Mono",
463 - },
464 - makeover_layout::Choice {
465 - value: "stereo",
466 - label: "Stereo",
467 - },
426 + makeover_layout::Choice::new("original", "Original"),
427 + makeover_layout::Choice::new("mono", "Mono"),
428 + makeover_layout::Choice::new("stereo", "Stereo"),
468 429 ];
469 430 const STRUCTURES: [makeover_layout::Choice<'_>; 2] = [
470 - makeover_layout::Choice {
471 - value: "preserve",
472 - label: "Preserve tree",
473 - },
474 - makeover_layout::Choice {
475 - value: "flatten",
476 - label: "Flatten (all files in one folder)",
477 - },
431 + makeover_layout::Choice::new("preserve", "Preserve tree"),
432 + makeover_layout::Choice::new("flatten", "Flatten (all files in one folder)"),
478 433 ];
479 434
480 435 // Radio rather than select for all five: each decides what comes out of the
@@ -217,9 +217,9 @@
217 217 /// Conform controls: pick a device, conform to its accepted format.
218 218 fn draw_conform_section(ui: &mut egui::Ui, state: &mut BrowserState) {
219 219 let disabled = state.forge.busy;
220 - ui.label(egui::RichText::new("Conform for device").strong());
221 220
222 221 if state.forge.devices.is_empty() {
222 + ui.label(egui::RichText::new("Conform for device").strong());
223 223 ui.label(
224 224 egui::RichText::new("No device profiles available.")
225 225 .small()
@@ -228,40 +228,71 @@
228 228 return;
229 229 }
230 230
231 - let selected_text = state
231 + // The option labels have to outlive the described field, which holds them
232 + // as `&str`. Built once here rather than inside the draw, since a summary
233 + // is part of what the option reads as.
234 + let labels: Vec<String> = state
232 235 .forge
233 - .conform_device
234 - .clone()
235 - .unwrap_or_else(|| "Select device...".to_string());
236 + .devices
237 + .iter()
238 + .map(|(name, summary)| {
239 + if summary.is_empty() {
240 + name.clone()
241 + } else {
242 + format!("{name} ({summary})")
243 + }
244 + })
245 + .collect();
246 + let options: Vec<makeover_layout::Choice<'_>> = state
247 + .forge
248 + .devices
249 + .iter()
250 + .zip(&labels)
251 + .map(|((name, _), label)| makeover_layout::Choice::new(name, label))
252 + .collect();
236 253
237 - ui.horizontal(|ui| {
238 - egui::ComboBox::from_id_salt("forge_conform_device")
239 - .selected_text(selected_text)
240 - .width(200.0)
241 - .show_ui(ui, |ui| {
242 - for (name, summary) in &state.forge.devices {
243 - let label = if summary.is_empty() {
244 - name.clone()
245 - } else {
246 - format!("{name} ({summary})")
247 - };
248 - let selected = state.forge.conform_device.as_deref() == Some(name);
249 - if ui.selectable_label(selected, label).clicked() {
250 - state.forge.conform_device = Some(name.clone());
251 - }
252 - }
253 - });
254 + // The instruction is the field's ghost text rather than a disabled button's
255 + // job. It was `selected_text` before makeover-layout 0.28.0, which is what
256 + // stopped this picker being described in the 2026-08-16 pass: a described
257 + // select with nothing chosen read as an empty box, so the only thing left
258 + // saying what to do would have been the greyed Conform beside it.
259 + let described = makeover_layout::Field {
260 + placeholder: Some("Select device..."),
261 + ..makeover_layout::Field::select("conform_device", "Conform for device", &options)
262 + };
263 +
264 + // The label is the field's now, so the section's own `strong` line is gone.
265 + // What is left here is the button beside it, which is not part of the
266 + // question.
267 + //
268 + // Both the click and the answer come back out of this block rather than
269 + // being acted on inside it: the described options borrow `state.forge`, and
270 + // conforming writes to `state`.
271 + let mut chosen = state.forge.conform_device.clone().unwrap_or_default();
272 + let conform = {
273 + let mut conform = false;
274 + ui.horizontal(|ui| {
275 + widgets::field(
276 + ui,
277 + &described,
278 + makeover_immediate::Filling::Text(&mut chosen),
279 + disabled.then_some(makeover_layout::State::Disabled),
280 + );
281 +
282 + let can_conform = !disabled && !chosen.is_empty();
283 + conform = ui
284 + .add_enabled(can_conform, egui::Button::new("Conform"))
285 + .clicked();
286 + });
287 + conform
288 + };
289 + // Written back after the draw: the field borrows the buffer, and an empty
290 + // one is "nothing chosen" rather than a device named the empty string.
291 + state.forge.conform_device = (!chosen.is_empty()).then_some(chosen);
292 + if conform && let Some(device) = state.forge.conform_device.clone() {
293 + state.forge_conform_device(&device);
294 + }
254 295
255 - let device = state.forge.conform_device.clone();
256 - let can_conform = !disabled && device.is_some();
257 - if ui
258 - .add_enabled(can_conform, egui::Button::new("Conform"))
259 - .clicked()
260 - && let Some(d) = device
261 - {
262 - state.forge_conform_device(&d);
263 - }
264 - });
265 296 ui.label(
266 297 egui::RichText::new(
267 298 "Resamples and converts bit depth to match the device, as a new sample.",
@@ -173,17 +173,36 @@
173 173
174 174 /// Mode selector, root note label, lock checkbox.
175 175 fn draw_mode_controls(ui: &mut egui::Ui, state: &mut BrowserState) {
176 + // The mode question, described. Multi-sample is offered rather than hidden
177 + // and says what would make it available, which is `Choice::unavailable`
178 + // (makeover-layout 0.28.0) doing what the hand-rolled version needed three
179 + // things to do: a permanently disabled radio, a hover nobody without a
180 + // pointer reaches, and a line of prose under the row.
181 + let options = [
182 + makeover_layout::Choice::new(InstrumentMode::Chromatic.as_value(), "Chromatic"),
183 + makeover_layout::Choice::new(InstrumentMode::MultiSample.as_value(), "Multi-sample")
184 + .unless("Drop a second sample onto the keyboard."),
185 + ];
186 + let described = makeover_layout::Field {
187 + hint: Some("Pitch one sample up and down across the keyboard."),
188 + ..makeover_layout::Field::radio("instrument_mode", "Mode", &options)
189 + };
190 +
176 191 ui.horizontal(|ui| {
177 - let mut mode = state.shared.instrument.lock().config.mode;
178 - let was_chromatic = mode == InstrumentMode::Chromatic;
179 - ui.radio_value(&mut mode, InstrumentMode::Chromatic, "Chromatic")
180 - .on_hover_text("Pitch one sample up and down across the keyboard");
181 - ui.add_enabled(
182 - false,
183 - egui::RadioButton::new(mode == InstrumentMode::MultiSample, "Multi-sample"),
184 - )
185 - .on_hover_text("Drop two or more samples onto the keyboard to enable multi-sample mode");
186 - if (mode == InstrumentMode::Chromatic) != was_chromatic {
192 + let was = state.shared.instrument.lock().config.mode;
193 + let mut value = was.as_value().to_owned();
194 + widgets::field(
195 + ui,
196 + &described,
197 + makeover_immediate::Filling::Text(&mut value),
198 + None,
199 + );
200 + // An unavailable option cannot be picked, so the only value that comes
201 + // back is one of the available ones; `from_value` returning `None` is
202 + // the drift case and leaves the mode alone rather than guessing.
203 + if let Some(mode) = InstrumentMode::from_value(&value)
204 + && mode != was
205 + {
187 206 state.shared.instrument.lock().config.mode = mode;
188 207 }
189 208
@@ -202,19 +221,6 @@
202 221 ui.checkbox(&mut state.preview.instrument_locked, "Lock sample")
203 222 .on_hover_text("Keep the current sample loaded as the table selection changes");
204 223 });
205 -
206 - // Surface the Multi-sample precondition inline (not hover-only) while in
207 - // Chromatic mode, so the permanently-disabled radio doesn't read as a
208 - // dead-end. The mode flips automatically once a second sample is dropped.
209 - if state.shared.instrument.lock().config.mode == InstrumentMode::Chromatic {
210 - ui.label(
211 - egui::RichText::new(
212 - "Multi-sample activates after you drop a second sample onto the keyboard.",
213 - )
214 - .small()
215 - .color(theme::content_muted()),
216 - );
217 - }
218 224 }
219 225
220 226 /// Draw a 3-octave piano keyboard with click-to-play, active voice highlighting, and zone overlays.
@@ -430,14 +430,8 @@
430 430 // being enforced by hand here because the description could not say
431 431 // it.
432 432 const STORAGE_STYLES: [makeover_layout::Choice<'static>; 2] = [
433 - makeover_layout::Choice {
434 - value: "copy",
435 - label: "Copy samples into library (recommended)",
436 - },
437 - makeover_layout::Choice {
438 - value: "reference",
439 - label: "Reference samples in place (loose-files mode)",
440 - },
433 + makeover_layout::Choice::new("copy", "Copy samples into library (recommended)"),
434 + makeover_layout::Choice::new("reference", "Reference samples in place (loose-files mode)"),
441 435 ];
442 436 // The description names its options by string and this app holds a
443 437 // bool, so the two are marshalled here rather than by reshaping the
@@ -69,18 +69,9 @@
69 69 // afterwards without re-importing, so the three answers have to be
70 70 // readable without opening anything.
71 71 const STRATEGIES: [makeover_layout::Choice<'static>; 3] = [
72 - makeover_layout::Choice {
73 - value: "flat",
74 - label: "Flat (all files in current directory)",
75 - },
76 - makeover_layout::Choice {
77 - value: "new",
78 - label: "New vault (preserve directory structure)",
79 - },
80 - makeover_layout::Choice {
81 - value: "merge",
82 - label: "Merge into existing vault",
83 - },
72 + makeover_layout::Choice::new("flat", "Flat (all files in current directory)"),
73 + makeover_layout::Choice::new("new", "New vault (preserve directory structure)"),
74 + makeover_layout::Choice::new("merge", "Merge into existing vault"),
84 75 ];
85 76
86 77 // The description names its answers by string and this app holds a
@@ -118,7 +109,7 @@
118 109 .collect();
119 110 let vault_choices: Vec<makeover_layout::Choice<'_>> = vault_options
120 111 .iter()
121 - .map(|(value, label)| makeover_layout::Choice { value, label })
112 + .map(|(value, label)| makeover_layout::Choice::new(value, label))
122 113 .collect();
123 114
124 115 let mut fields = vec![makeover_layout::Field::radio(