max / audiofiles
10 files changed,
+297 insertions,
-210 deletions
| @@ -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" |
| @@ -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( |