Skip to main content

max / audiofiles

Describe the export configuration form Six questions the port's sweep never saw: it matched radio_value and selectable_value, and this screen asks five of its six with ui.radio, so export_screens.rs read as converted for three passes without being looked at. Device Profile is a Select, the other five are Radio: each decides what comes out of the export and none can be revised after it runs. The naming pattern's preview becomes the field's hint and its parse error the field's error, and the input gains the well every other text input in the app already painted. The token chips move below the box they append to. The metadata sidecar stays a bare checkbox and the destination stays a button over a native dialog, both by the rules this port already set.
Author: Max Johnson <me@maxj.phd> · 2026-08-17 03:14 UTC
Signed with PGP, not checked
Commit: 5d08840f9f2a01a25fec569ca7cf69edca7f1cfd
Parent: e3176d1
2 files changed, +199 insertions, -316 deletions
M Cargo.lock +12 -12
@@ -7543,6 +7543,18 @@
7543 7543 "winnow 1.0.4",
7544 7544 ]
7545 7545
7546 + [[patch.unused]]
7547 + name = "kberg"
7548 + version = "0.1.0"
7549 +
7550 + [[patch.unused]]
7551 + name = "ops-status"
7552 + version = "0.1.0"
7553 +
7554 + [[patch.unused]]
7555 + name = "painhours"
7556 + version = "0.1.0"
7557 +
7546 7558 [[patch.unused]]
7547 7559 name = "quasi-axum"
7548 7560 version = "0.18.0"
@@ -7566,15 +7578,3 @@
7566 7578 [[patch.unused]]
7567 7579 name = "quasi-webview"
7568 7580 version = "0.18.0"
7569 -
7570 - [[patch.unused]]
7571 - name = "kberg"
7572 - version = "0.1.0"
7573 -
7574 - [[patch.unused]]
7575 - name = "ops-status"
7576 - version = "0.1.0"
7577 -
7578 - [[patch.unused]]
7579 - name = "painhours"
7580 - version = "0.1.0"
@@ -249,310 +249,7 @@
249 249 }
250 250 });
251 251 ui.add_space(theme::space::group());
252 -
253 - // --- Device Profile ---
254 - if profile_count > 0 {
255 - ui.label(egui::RichText::new("Device Profile").strong());
256 - if let ImportMode::ConfigureExport {
257 - ref mut config,
258 - ref available_profiles,
259 - ..
260 - } = state.import_wf.import_mode
261 - {
262 - let current_label = config.device_profile.as_deref().unwrap_or("None (manual)");
263 -
264 - egui::ComboBox::from_id_salt("device_profile_picker")
265 - .selected_text(current_label)
266 - .width(250.0)
267 - .show_ui(ui, |ui| {
268 - if ui
269 - .selectable_value(&mut config.device_profile, None, "None (manual)")
270 - .clicked()
271 - {
272 - // Reset profile-derived fields when switching to manual
273 - config.naming_rules = None;
274 - config.max_file_size_bytes = None;
275 - config.name_overrides = None;
276 - }
277 -
278 - for profile in available_profiles {
279 - let label = format!("{} ({})", profile.name, profile.manufacturer);
280 - let value = Some(profile.name.clone());
281 - ui.selectable_value(&mut config.device_profile, value, label);
282 - }
283 - });
284 -
285 - // Show profile info when one is selected. M-6: surface the
286 - // device's supported formats / rates / depths / channels
287 - // so the user knows what the lock is hiding, not just
288 - // that something is hidden.
289 - if let Some(ref name) = config.device_profile
290 - && let Some(profile) = available_profiles.iter().find(|p| &p.name == name)
291 - {
292 - ui.label(
293 - egui::RichText::new(format!("by {}", profile.manufacturer))
294 - .small()
295 - .color(theme::content_muted()),
296 - );
297 - if let Some(ref summary) = profile.format_summary {
298 - ui.label(
299 - egui::RichText::new(summary)
300 - .small()
301 - .color(theme::content_muted()),
302 - );
303 - }
304 - if let Some(ref category) = profile.category {
305 - ui.label(
306 - egui::RichText::new(category)
307 - .small()
308 - .color(theme::content_muted()),
309 - );
310 - }
311 - if let Some(ref notes) = profile.notes {
312 - ui.label(
313 - egui::RichText::new(notes)
314 - .small()
315 - .color(theme::content_muted()),
316 - );
317 - }
318 - }
319 - }
320 - ui.add_space(theme::space::peer());
321 - }
322 -
323 - // --- Format ---
324 - let has_profile = matches!(
325 - &state.import_wf.import_mode,
326 - ImportMode::ConfigureExport {
327 - config: ExportConfig {
328 - device_profile: Some(_),
329 - ..
330 - },
331 - ..
332 - }
333 - );
334 -
335 - if !has_profile {
336 - ui.label(egui::RichText::new("Format").strong());
337 - if let ImportMode::ConfigureExport { ref mut config, .. } =
338 - state.import_wf.import_mode
339 - {
340 - let is_original = config.format == ExportFormat::Original;
341 - let is_wav = config.format == ExportFormat::Wav;
342 - let is_aiff = config.format == ExportFormat::Aiff;
343 -
344 - if ui.radio(is_original, "Original (copy as-is)").clicked() && !is_original {
345 - config.format = ExportFormat::Original;
346 - }
347 - if ui.radio(is_wav, "WAV (decode and re-encode)").clicked() && !is_wav {
348 - config.format = ExportFormat::Wav;
349 - }
350 - if ui.radio(is_aiff, "AIFF (decode and re-encode)").clicked() && !is_aiff {
351 - config.format = ExportFormat::Aiff;
352 - }
353 -
354 - // Heads-up: WAV/AIFF re-encode writes only fmt+data /
355 - // COMM+SSND. Embedded BWF (bext), iXML, smpl loop points,
356 - // cue markers, and ID3 tags are not preserved. Choose
357 - // Original to keep them intact.
358 - if config.format != ExportFormat::Original {
359 - ui.add_space(theme::space::hair());
360 - ui.label(
361 - egui::RichText::new(
362 - "Re-encoding strips embedded metadata chunks \
363 - (BWF, iXML, loop points, cue markers, ID3). \
364 - Choose Original to preserve them.",
365 - )
366 - .small()
367 - .color(theme::warning()),
368 - );
369 - }
370 - }
371 - ui.add_space(theme::space::peer());
372 -
373 - // --- Audio encoding options (WAV/AIFF) ---
374 - let needs_encoding_options = matches!(
375 - &state.import_wf.import_mode,
376 - ImportMode::ConfigureExport {
377 - config: ExportConfig {
378 - format: ExportFormat::Wav | ExportFormat::Aiff,
379 - ..
380 - },
381 - ..
382 - }
383 - );
384 -
385 - if needs_encoding_options
386 - && let ImportMode::ConfigureExport { ref mut config, .. } =
387 - state.import_wf.import_mode
388 - {
389 - // Sample rate
390 - ui.label(egui::RichText::new("Sample Rate").strong());
391 - let rates: [(Option<u32>, &str); 4] = [
392 - (None, "Original"),
393 - (Some(44100), "44,100 Hz"),
394 - (Some(48000), "48,000 Hz"),
395 - (Some(96000), "96,000 Hz"),
396 - ];
397 - for (rate, label) in &rates {
398 - if ui.radio(config.sample_rate == *rate, *label).clicked() {
399 - config.sample_rate = *rate;
400 - }
401 - }
402 - ui.add_space(theme::space::peer());
403 -
404 - // Bit depth
405 - ui.label(egui::RichText::new("Bit Depth").strong());
406 - let depths: [(Option<u16>, &str); 3] = [
407 - (None, "Original"),
408 - (Some(16), "16-bit"),
409 - (Some(24), "24-bit"),
410 - ];
411 - for (depth, label) in &depths {
412 - if ui.radio(config.bit_depth == *depth, *label).clicked() {
413 - config.bit_depth = *depth;
414 - }
415 - }
416 - ui.add_space(theme::space::peer());
417 - }
418 -
419 - // --- Channels ---
420 - ui.label(egui::RichText::new("Channels").strong());
421 - if let ImportMode::ConfigureExport { ref mut config, .. } =
422 - state.import_wf.import_mode
423 - {
424 - let ch_options: [(ExportChannels, &str); 3] = [
425 - (ExportChannels::Original, "Original"),
426 - (ExportChannels::Mono, "Mono"),
427 - (ExportChannels::Stereo, "Stereo"),
428 - ];
429 - for (ch, label) in &ch_options {
430 - if ui.radio(config.channels == *ch, *label).clicked() {
431 - config.channels = ch.clone();
432 - }
433 - }
434 - }
435 - ui.add_space(theme::space::peer());
436 - }
437 -
438 - // --- Structure ---
439 - ui.label(egui::RichText::new("Structure").strong());
440 - if let ImportMode::ConfigureExport { ref mut config, .. } = state.import_wf.import_mode
441 - {
442 - if ui.radio(!config.flatten, "Preserve tree").clicked() && config.flatten {
443 - config.flatten = false;
444 - }
445 - if ui
446 - .radio(config.flatten, "Flatten (all files in one folder)")
447 - .clicked()
448 - && !config.flatten
449 - {
450 - config.flatten = true;
451 - }
452 - }
453 - ui.add_space(theme::space::peer());
454 -
455 - // --- Metadata sidecar ---
456 - if let ImportMode::ConfigureExport { ref mut config, .. } = state.import_wf.import_mode
457 - {
458 - ui.checkbox(
459 - &mut config.metadata_sidecar,
460 - "Include metadata (.audiofiles.json)",
461 - );
462 - }
463 - ui.add_space(theme::space::peer());
464 -
465 - // --- Naming pattern (when flattened) ---
466 - if let ImportMode::ConfigureExport {
467 - ref mut config,
468 - ref items,
469 - ..
470 - } = state.import_wf.import_mode
471 - && config.flatten
472 - {
473 - ui.label(egui::RichText::new("Naming Pattern").strong());
474 - let mut pattern = config.naming_pattern.clone().unwrap_or_default();
475 -
476 - // Token chips (M-8): clicking appends the token to the
477 - // pattern. egui doesn't surface the cursor position on
478 - // TextEdit so append-to-end is the honest affordance.
479 - ui.horizontal_wrapped(|ui| {
480 - ui.label(
481 - egui::RichText::new("Tokens:")
482 - .small()
483 - .color(theme::content_muted()),
484 - );
485 - const TOKENS: &[&str] = &[
486 - "{name}",
487 - "{bpm}",
488 - "{key}",
489 - "{class}",
490 - "{duration}",
491 - "{n}",
492 - "{nn}",
493 - "{nnn}",
494 - "{ext}",
495 - ];
496 - for tok in TOKENS {
497 - if ui
498 - .small_button(*tok)
499 - .on_hover_text("Append this token to the pattern")
500 - .clicked()
501 - {
502 - pattern.push_str(tok);
503 - }
504 - }
505 - });
506 -
507 - let changed = ui.text_edit_singleline(&mut pattern).changed();
508 - if changed || config.naming_pattern.as_deref().unwrap_or("") != pattern {
509 - config.naming_pattern = if pattern.is_empty() {
510 - None
511 - } else {
512 - Some(pattern.clone())
513 - };
514 - }
515 -
516 - // Live preview (M-7): parse + resolve against the first
517 - // item's context. Parse errors (unknown token, unclosed
518 - // brace) render in yellow so the user catches typos before
519 - // committing to a 200-file export.
520 - if !pattern.is_empty() {
521 - match audiofiles_core::rename::RenamePattern::parse(&pattern) {
522 - Ok(parsed) => {
523 - if let Some(first) = items.first() {
524 - let ctx = audiofiles_core::rename::RenameContext {
525 - name: first.name.clone(),
526 - extension: first.ext.clone(),
527 - bpm: first.bpm,
528 - musical_key: first.musical_key.clone(),
529 - duration: first.duration,
530 - index: 0,
531 - };
532 - let stem = parsed.resolve(&ctx);
533 - let preview = if first.ext.is_empty() {
534 - stem
535 - } else {
536 - format!("{stem}.{}", first.ext)
537 - };
538 - ui.label(
539 - egui::RichText::new(format!("Preview: {preview}"))
540 - .small()
541 - .color(theme::content_muted()),
542 - );
543 - }
544 - }
545 - Err(e) => {
546 - ui.label(
547 - egui::RichText::new(format!("Pattern: {e}"))
548 - .small()
549 - .color(theme::warning()),
550 - );
551 - }
552 - }
553 - }
554 - ui.add_space(theme::space::peer());
555 - }
252 + draw_export_fields(ui, state);
556 253
557 254 // --- Destination ---
558 255 ui.label(egui::RichText::new("Destination").strong());
@@ -584,6 +281,374 @@
584 281 });
585 282 }
586 283
284 + /// The export configuration form, as described fields.
285 + ///
286 + /// Six questions, in two groups split by the metadata-sidecar checkbox that
287 + /// sits between them: a bare toggle is a control rather than a field, which is
288 + /// the rule `settings_panel` set for this port, so it is drawn between the
289 + /// groups instead of being described as one. The destination stays outside for
290 + /// the other standing reason — there is no `FieldKind::Path`, deliberately, and
291 + /// a button plus a native dialog is not a question a form holds an answer to.
292 + ///
293 + /// The whole form used to be six hand-drawn `strong` labels over raw egui
294 + /// controls, and it was invisible to the port's sweep for three passes because
295 + /// that sweep matched `radio_value` and `selectable_value` and this screen asks
296 + /// five of its six questions with `ui.radio`.
297 + fn draw_export_fields(ui: &mut egui::Ui, state: &mut BrowserState) {
298 + let ImportMode::ConfigureExport {
299 + ref config,
300 + ref available_profiles,
301 + ref items,
302 + ..
303 + } = state.import_wf.import_mode
304 + else {
305 + return;
306 + };
307 +
308 + // Owned first, borrowed second: `Choice` holds `&str` and the fillings need
309 + // `state` mutably. Same division as `import_screens::configure` — the
310 + // question is described, this app's encoding of the answer stays private.
311 + //
312 + // The empty value is "no profile", which is a real answer and not an
313 + // unanswered chooser: it is the default, it has a label, and choosing it is
314 + // what "export exactly what I configured" means. So this field is not the
315 + // shape makeover-layout `661ff8ba` blocks.
316 + let profile_options: Vec<(String, String)> =
317 + std::iter::once((String::new(), String::from("None (manual)")))
318 + .chain(
319 + available_profiles
320 + .iter()
321 + .map(|p| (p.name.clone(), format!("{} ({})", p.name, p.manufacturer))),
322 + )
323 + .collect();
324 + let profile_choices: Vec<makeover_layout::Choice<'_>> = profile_options
325 + .iter()
326 + .map(|(value, label)| makeover_layout::Choice { value, label })
327 + .collect();
328 +
329 + // The chosen device's readout, which stays a block under the field rather
330 + // than becoming its `hint`. The rule this port has been applying is that a
331 + // message belongs to the thing it is about; these lines are about the
332 + // device, not about the question, and there are up to four of them.
333 + let profile_detail: Vec<String> = config
334 + .device_profile
335 + .as_ref()
336 + .and_then(|name| available_profiles.iter().find(|p| &p.name == name))
337 + .map(|p| {
338 + std::iter::once(format!("by {}", p.manufacturer))
339 + .chain(p.format_summary.clone())
340 + .chain(p.category.clone())
341 + .chain(p.notes.clone())
342 + .collect()
343 + })
344 + .unwrap_or_default();
345 +
346 + // A profile answers the format, rate, depth and channel questions itself,
347 + // so those four are not asked while one is chosen. Conditional membership
348 + // in the field list, the same way the import strategy's vault name is.
349 + let has_profile = config.device_profile.is_some();
350 + let needs_encoding_options = matches!(config.format, ExportFormat::Wav | ExportFormat::Aiff);
351 + let re_encoding = config.format != ExportFormat::Original;
352 +
353 + let mut profile_value = config.device_profile.clone().unwrap_or_default();
354 + let mut format_value = String::from(match config.format {
355 + ExportFormat::Original => "original",
356 + ExportFormat::Wav => "wav",
357 + ExportFormat::Aiff => "aiff",
358 + });
359 + let mut rate_value = config
360 + .sample_rate
361 + .map(|r| r.to_string())
362 + .unwrap_or_default();
363 + let mut depth_value = config.bit_depth.map(|d| d.to_string()).unwrap_or_default();
364 + let mut channels_value = String::from(match config.channels {
365 + ExportChannels::Original => "original",
366 + ExportChannels::Mono => "mono",
367 + ExportChannels::Stereo => "stereo",
368 + });
369 + let mut structure_value = String::from(if config.flatten {
370 + "flatten"
371 + } else {
372 + "preserve"
373 + });
374 + let mut sidecar = config.metadata_sidecar;
375 + let mut pattern = config.naming_pattern.clone().unwrap_or_default();
376 + let flatten = config.flatten;
377 +
378 + // The pattern's preview and its parse error are both facts about the value
379 + // in the field, and they are mutually exclusive, so one field carries both:
380 + // the preview as `hint`, the parse failure as `error`. It used to be two
381 + // detached lines under a text box with no well.
382 + let (pattern_hint, pattern_error) = if pattern.is_empty() {
383 + (None, None)
384 + } else {
385 + match audiofiles_core::rename::RenamePattern::parse(&pattern) {
386 + Ok(parsed) => (
387 + items.first().map(|first| {
388 + let ctx = audiofiles_core::rename::RenameContext {
389 + name: first.name.clone(),
390 + extension: first.ext.clone(),
391 + bpm: first.bpm,
392 + musical_key: first.musical_key.clone(),
393 + duration: first.duration,
394 + index: 0,
395 + };
396 + let stem = parsed.resolve(&ctx);
397 + if first.ext.is_empty() {
398 + format!("Preview: {stem}")
399 + } else {
400 + format!("Preview: {stem}.{}", first.ext)
401 + }
402 + }),
403 + None,
404 + ),
405 + Err(e) => (None, Some(format!("Pattern: {e}"))),
406 + }
407 + };
408 +
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 + },
422 + ];
423 + 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 + },
440 + ];
441 + 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 + },
454 + ];
455 + 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 + },
468 + ];
469 + const STRUCTURES: [makeover_layout::Choice<'_>; 2] = [
Lines truncated