Skip to main content

max / audiofiles

Add a parity harness for the described screens Compares what a described screen offers against what the shipped panel it replaces offers: the same controls, the same words, dead in the same states. Covers the file list, the detail panel and the four described settings sections. The shipped side is read through egui's AccessKit tree, which egui fills from the WidgetInfo every widget already reports, so the harness asks the panel what it drew rather than parsing pixels. Both sides read one fixture: a real BrowserState on a temp dir, with the described side going through the app's own adapters via panel::described_screen. What it found on the way in, and what changed: - The detail panel said "Find similar", "Find duplicates" and "Copy path" where the shipped panel and the described file list both say Title Case. Fixed in detail.rs. - Copy Path was hidden when a sample had no path, while the shipped button was always alive and did nothing. It is now present and dead, which is the rule the same file states two functions below. - The described file list renames the Duration column from "Dur". Left as a named allowance: Column::name is both the heading and the sort key, so the abbreviation and the key cannot come apart, and the call is not the harness's. The four settings section bodies are split from their CollapsingHeaders so the harness can read a section without opening it: a header derives its id from the ui.vertical it makes for itself, so its fold state cannot be set from outside.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-22 16:52 UTC
Signed with PGP, not checked
Commit: 1b77cf0cf21b0cfe89d2c5cb6600b274dadd2bef
Parent: c07cbfc
7 files changed, +810 insertions, -230 deletions
M Cargo.lock +16 -8
@@ -7554,6 +7554,18 @@
7554 7554 "winnow 1.0.4",
7555 7555 ]
7556 7556
7557 + [[patch.unused]]
7558 + name = "kberg"
7559 + version = "0.1.0"
7560 +
7561 + [[patch.unused]]
7562 + name = "ops-status"
7563 + version = "0.1.0"
7564 +
7565 + [[patch.unused]]
7566 + name = "painhours"
7567 + version = "0.1.0"
7568 +
7557 7569 [[patch.unused]]
7558 7570 name = "quasi-axum"
7559 7571 version = "0.53.0"
@@ -7583,16 +7595,12 @@
7583 7595 version = "0.53.0"
7584 7596
7585 7597 [[patch.unused]]
7586 - name = "kberg"
7587 - version = "0.1.0"
7598 + name = "makeover-build"
7599 + version = "0.50.0"
7588 7600
7589 7601 [[patch.unused]]
7590 - name = "ops-status"
7591 - version = "0.1.0"
7592 -
7593 - [[patch.unused]]
7594 - name = "painhours"
7595 - version = "0.1.0"
7602 + name = "makeover-webview"
7603 + version = "0.58.0"
7596 7604
7597 7605 [[patch.unused]]
7598 7606 name = "quasi-type"
@@ -367,12 +367,17 @@
367 367 /// What can be done to the sample.
368 368 fn actions(body: Slot, sample: &Detailed) -> Slot {
369 369 let mut body = body.with(Node::section("Actions"));
370 - if sample.path.is_some() {
371 - body = body.with(Node::Act(Act::new(
372 - "Copy path",
373 - Action::post("/detail/path/copy"),
374 - )));
370 + // Present whether or not there is a path, and dead when there is not. This
371 + // used to be hidden when `path` was `None`, which is the shape [`discovery`]
372 + // argues against four functions below: a control that vanishes when its
373 + // prerequisite is missing teaches nothing. The shipped panel draws it
374 + // always and does nothing when pressed with no path, which teaches less
375 + // still, so neither side was saying what it meant.
376 + let mut copy = Act::new("Copy Path", Action::post("/detail/path/copy"));
377 + if sample.path.is_none() {
378 + copy = copy.disabled();
375 379 }
380 + body = body.with(Node::Act(copy));
376 381 if sample.is_sample {
377 382 body = body
378 383 .with(Node::Act(
@@ -399,14 +404,14 @@
399 404 }
400 405 let mut body = body.with(Node::section("Discovery"));
401 406
402 - let mut similar = Act::new("Find similar", Action::post("/detail/similar")).key("shift+f");
407 + let mut similar = Act::new("Find Similar", Action::post("/detail/similar")).key("shift+f");
403 408 if !sample.has_spectral {
404 409 similar = similar.disabled();
405 410 }
406 411 body = body.with(Node::Act(similar));
407 412
408 413 let mut duplicates =
409 - Act::new("Find duplicates", Action::post("/detail/duplicates")).key("shift+d");
414 + Act::new("Find Duplicates", Action::post("/detail/duplicates")).key("shift+d");
410 415 if !sample.has_fingerprint {
411 416 duplicates = duplicates.disabled();
412 417 }
@@ -5012,5 +5012,7 @@
5012 5012 ))))
5013 5013 }
5014 5014
5015 + #[cfg(test)]
5016 + mod parity;
5015 5017 #[cfg(test)]
5016 5018 mod tests;
@@ -1979,3 +1979,33 @@
1979 1979 })
1980 1980 .collect()
1981 1981 }
1982 +
1983 + /// The screen the app's own adapters answer at `address`, for the parity tests.
1984 + ///
1985 + /// The parity harness compares a described screen against the shipped panel it
1986 + /// replaces, and both have to read one fixture or the comparison proves
1987 + /// nothing. This is that seam: the same `answer` the window loop calls, with
1988 + /// the same `Host`, against a real [`BrowserState`]. A test that built its own
1989 + /// `Panels` out of fakes would be comparing the shipped panel against a fixture
1990 + /// rather than against the description the app actually serves.
1991 + ///
1992 + /// Intents are collected and dropped. A parity read presses nothing.
1993 + #[cfg(test)]
1994 + pub(super) fn described_screen(state: &BrowserState, address: &str) -> quasi_router::Screen {
1995 + use quasi_router::Outcome;
1996 +
1997 + let intents = RefCell::new(Vec::new());
1998 + let host = Host {
1999 + state,
2000 + sync: None,
2001 + themes: themes(),
2002 + intents: &intents,
2003 + };
2004 + match answer(&host, Request::get(address)) {
2005 + Ok(response) => match response.outcome {
2006 + Outcome::Screen(screen) | Outcome::Over(screen) => screen,
2007 + other => panic!("{address} answered with {other:?} rather than a screen"),
2008 + },
2009 + Err(message) => panic!("{address} was refused: {message}"),
2010 + }
2011 + }
@@ -2803,8 +2803,8 @@
2803 2803
2804 2804 // Offered rather than hidden, which is the shipped panel's choice: a
2805 2805 // control that vanishes teaches nothing.
2806 - assert!(acts(&screen).iter().any(|label| label == "Find similar"));
2807 - assert_eq!(dead(&screen), ["Find similar", "Find duplicates"]);
2806 + assert!(acts(&screen).iter().any(|label| label == "Find Similar"));
2807 + assert_eq!(dead(&screen), ["Find Similar", "Find Duplicates"]);
2808 2808
2809 2809 // And the sentence that would revive each is said. THE FINDING is that it
2810 2810 // is said beside the control rather than on it -- see the module header.
@@ -2845,9 +2845,9 @@
2845 2845
2846 2846 assert!(!labels.iter().any(|label| label == "Edit"));
2847 2847 assert!(!labels.iter().any(|label| label == "Forge"));
2848 - assert!(!labels.iter().any(|label| label == "Find similar"));
2848 + assert!(!labels.iter().any(|label| label == "Find Similar"));
2849 2849 // The path is still copyable: a folder has one.
2850 - assert!(labels.iter().any(|label| label == "Copy path"));
2850 + assert!(labels.iter().any(|label| label == "Copy Path"));
2851 2851 }
2852 2852
2853 2853 #[test]
@@ -523,243 +523,278 @@
523 523
524 524 // Appearance section
525 525
526 - fn draw_appearance_section(ui: &mut egui::Ui, state: &mut BrowserState) {
526 + pub(crate) fn draw_appearance_section(ui: &mut egui::Ui, state: &mut BrowserState) {
527 527 egui::CollapsingHeader::new(egui::RichText::new("Appearance").strong())
528 528 .default_open(false)
529 - .show(ui, |ui| {
530 - let themes = theme::list_themes();
531 - let active_id = theme::active_id();
532 - let active_name = themes
533 - .iter()
534 - .find(|t| t.id == active_id)
535 - .map_or(active_id.as_str(), |t| t.name.as_str());
536 - // Following the system is a standing instruction, so name both
537 - // halves: what was asked for, and what it currently comes out as.
538 - let current_name = match &state.theme_selection {
539 - theme::ThemeSelection::Follow => format!("System ({active_name})"),
540 - theme::ThemeSelection::Fixed(_) => active_name.to_string(),
541 - };
529 + .show(ui, |ui| appearance_body(ui, state));
530 + }
542 531
543 - let mut new_selection = None;
544 - ui.horizontal(|ui| {
545 - ui.label("Theme:");
546 - egui::ComboBox::from_id_salt("settings_theme_select")
547 - .selected_text(&current_name)
548 - .width(200.0)
549 - .show_ui(ui, |ui| {
550 - let follows = matches!(state.theme_selection, theme::ThemeSelection::Follow);
551 - if ui
552 - .selectable_label(follows, "Follow the system")
553 - .on_hover_text(
554 - "Use the desktop's light or dark appearance, and change with it",
532 + /// What the Appearance section holds, with the fold taken off.
533 + ///
534 + /// Split from the header so `quasi::parity` can read what the section
535 + /// offers without opening it. A `CollapsingHeader`'s id comes from the
536 + /// `ui.vertical` it makes for itself, so its fold state cannot be set from
537 + /// outside, and folding is the renderer's business rather than something
538 + /// either screen describes.
539 + pub(crate) fn appearance_body(ui: &mut egui::Ui, state: &mut BrowserState) {
540 + let themes = theme::list_themes();
541 + let active_id = theme::active_id();
542 + let active_name = themes
543 + .iter()
544 + .find(|t| t.id == active_id)
545 + .map_or(active_id.as_str(), |t| t.name.as_str());
546 + // Following the system is a standing instruction, so name both
547 + // halves: what was asked for, and what it currently comes out as.
548 + let current_name = match &state.theme_selection {
549 + theme::ThemeSelection::Follow => format!("System ({active_name})"),
550 + theme::ThemeSelection::Fixed(_) => active_name.to_string(),
551 + };
552 +
553 + let mut new_selection = None;
554 + ui.horizontal(|ui| {
555 + ui.label("Theme:");
556 + egui::ComboBox::from_id_salt("settings_theme_select")
557 + .selected_text(&current_name)
558 + .width(200.0)
559 + .show_ui(ui, |ui| {
560 + let follows = matches!(state.theme_selection, theme::ThemeSelection::Follow);
561 + if ui
562 + .selectable_label(follows, "Follow the system")
563 + .on_hover_text(
564 + "Use the desktop's light or dark appearance, and change with it",
565 + )
566 + .clicked()
567 + {
568 + new_selection = Some(theme::ThemeSelection::Follow);
569 + }
570 + ui.separator();
571 +
572 + for (label, variant) in [("Dark", "dark"), ("Light", "light"), ("High Contrast", "high-contrast")] {
573 + // Pair each theme with its muted-text contrast tier and
574 + // sort most-accessible-first, so readable themes surface
575 + // at the top of each group and low-contrast curated
576 + // palettes are clearly badged rather than silently mixed in.
577 + let mut group: Vec<(&theme::ThemeMeta, theme::ContrastTier)> = themes
578 + .iter()
579 + .filter(|t| t.variant == variant)
580 + .map(|t| (t, theme::theme_contrast_tier(&t.id)))
581 + .collect();
582 + if group.is_empty() {
583 + continue;
584 + }
585 + group.sort_by_key(|(_, tier)| std::cmp::Reverse(*tier));
586 + ui.label(egui::RichText::new(label).small().strong());
587 + for (t, tier) in group {
588 + let is_selected =
589 + state.theme_selection == theme::ThemeSelection::Fixed(t.id.clone());
590 + ui.horizontal(|ui| {
591 + // Color swatch (bg + accent)
592 + if let Some((bg, accent, _fg)) = theme::theme_preview_colors(&t.id) {
593 + let size = egui::vec2(12.0, 12.0);
594 + let (rect, _) = ui.allocate_exact_size(size, egui::Sense::hover());
595 + ui.painter().rect_filled(rect, theme::radius_container(), bg);
596 + let accent_rect = egui::Rect::from_min_size(
597 + rect.min + egui::vec2(6.0, 0.0),
598 + egui::vec2(6.0, 12.0),
599 + );
600 + ui.painter().rect_filled(accent_rect, 0.0, accent);
601 + }
602 + let display = if t.is_custom {
603 + format!("{} (custom)", t.name)
604 + } else {
605 + t.name.clone()
606 + };
607 + if ui.selectable_label(is_selected, display).clicked() {
608 + new_selection =
609 + Some(theme::ThemeSelection::Fixed(t.id.clone()));
610 + }
611 + // Contrast-tier badge (text legibility, not the
612 + // theme's accent palette).
613 + let badge_color = match tier {
614 + theme::ContrastTier::High => theme::success(),
615 + theme::ContrastTier::Standard => theme::content_muted(),
616 + theme::ContrastTier::Low => theme::warning(),
617 + };
618 + ui.label(
619 + egui::RichText::new(tier.badge())
620 + .small()
621 + .color(badge_color),
555 622 )
556 - .clicked()
557 - {
558 - new_selection = Some(theme::ThemeSelection::Follow);
559 - }
560 - ui.separator();
561 -
562 - for (label, variant) in [("Dark", "dark"), ("Light", "light"), ("High Contrast", "high-contrast")] {
563 - // Pair each theme with its muted-text contrast tier and
564 - // sort most-accessible-first, so readable themes surface
565 - // at the top of each group and low-contrast curated
566 - // palettes are clearly badged rather than silently mixed in.
567 - let mut group: Vec<(&theme::ThemeMeta, theme::ContrastTier)> = themes
568 - .iter()
569 - .filter(|t| t.variant == variant)
570 - .map(|t| (t, theme::theme_contrast_tier(&t.id)))
571 - .collect();
572 - if group.is_empty() {
573 - continue;
574 - }
575 - group.sort_by_key(|(_, tier)| std::cmp::Reverse(*tier));
576 - ui.label(egui::RichText::new(label).small().strong());
577 - for (t, tier) in group {
578 - let is_selected =
579 - state.theme_selection == theme::ThemeSelection::Fixed(t.id.clone());
580 - ui.horizontal(|ui| {
581 - // Color swatch (bg + accent)
582 - if let Some((bg, accent, _fg)) = theme::theme_preview_colors(&t.id) {
583 - let size = egui::vec2(12.0, 12.0);
584 - let (rect, _) = ui.allocate_exact_size(size, egui::Sense::hover());
585 - ui.painter().rect_filled(rect, theme::radius_container(), bg);
586 - let accent_rect = egui::Rect::from_min_size(
587 - rect.min + egui::vec2(6.0, 0.0),
588 - egui::vec2(6.0, 12.0),
589 - );
590 - ui.painter().rect_filled(accent_rect, 0.0, accent);
591 - }
592 - let display = if t.is_custom {
593 - format!("{} (custom)", t.name)
594 - } else {
595 - t.name.clone()
596 - };
597 - if ui.selectable_label(is_selected, display).clicked() {
598 - new_selection =
599 - Some(theme::ThemeSelection::Fixed(t.id.clone()));
600 - }
601 - // Contrast-tier badge (text legibility, not the
602 - // theme's accent palette).
603 - let badge_color = match tier {
604 - theme::ContrastTier::High => theme::success(),
605 - theme::ContrastTier::Standard => theme::content_muted(),
606 - theme::ContrastTier::Low => theme::warning(),
607 - };
608 - ui.label(
609 - egui::RichText::new(tier.badge())
610 - .small()
611 - .color(badge_color),
612 - )
613 - .on_hover_text(
614 - "Muted-text legibility: AA = passes WCAG AA, OK = readable, low = subtle",
615 - );
616 - });
617 - }
618 - ui.separator();
619 - }
620 - });
623 + .on_hover_text(
624 + "Muted-text legibility: AA = passes WCAG AA, OK = readable, low = subtle",
625 + );
626 + });
627 + }
628 + ui.separator();
629 + }
621 630 });
631 + });
622 632
623 - if let Some(chosen) = new_selection {
624 - theme::set_selection(chosen.clone());
625 - state.theme_selection = chosen;
626 - state.save_theme_preference();
627 - }
628 -
629 - });
633 + if let Some(chosen) = new_selection {
634 + theme::set_selection(chosen.clone());
635 + state.theme_selection = chosen;
636 + state.save_theme_preference();
637 + }
630 638 }
631 639
632 640 // Preview section
633 641
634 - fn draw_preview_section(ui: &mut egui::Ui, state: &mut BrowserState) {
642 + pub(crate) fn draw_preview_section(ui: &mut egui::Ui, state: &mut BrowserState) {
635 643 egui::CollapsingHeader::new(egui::RichText::new("Preview").strong())
636 644 .default_open(false)
637 - .show(ui, |ui| {
638 - let mut loop_enabled = state.preview.loop_enabled;
639 - if ui
640 - .checkbox(&mut loop_enabled, "Loop playback")
641 - .on_hover_text("Loop sample preview (L)")
642 - .changed()
643 - {
644 - state.toggle_loop();
645 - }
645 + .show(ui, |ui| preview_body(ui, state));
646 + }
646 647
647 - let mut autoplay = state.preview.autoplay;
648 - if ui
649 - .checkbox(&mut autoplay, "Auto-play on navigate")
650 - .on_hover_text("Automatically preview sample when navigating")
651 - .changed()
652 - {
653 - state.toggle_autoplay();
654 - }
655 - });
648 + /// What the Preview section holds, with the fold taken off.
649 + ///
650 + /// Split from the header so `quasi::parity` can read what the section
651 + /// offers without opening it. A `CollapsingHeader`'s id comes from the
652 + /// `ui.vertical` it makes for itself, so its fold state cannot be set from
653 + /// outside, and folding is the renderer's business rather than something
654 + /// either screen describes.
655 + pub(crate) fn preview_body(ui: &mut egui::Ui, state: &mut BrowserState) {
656 + let mut loop_enabled = state.preview.loop_enabled;
657 + if ui
658 + .checkbox(&mut loop_enabled, "Loop playback")
659 + .on_hover_text("Loop sample preview (L)")
660 + .changed()
661 + {
662 + state.toggle_loop();
663 + }
664 +
665 + let mut autoplay = state.preview.autoplay;
666 + if ui
667 + .checkbox(&mut autoplay, "Auto-play on navigate")
668 + .on_hover_text("Automatically preview sample when navigating")
669 + .changed()
670 + {
671 + state.toggle_autoplay();
672 + }
656 673 }
657 674
658 675 // Forge section
659 676
660 - fn draw_forge_section(ui: &mut egui::Ui, state: &mut BrowserState) {
677 + pub(crate) fn draw_forge_section(ui: &mut egui::Ui, state: &mut BrowserState) {
661 678 egui::CollapsingHeader::new(egui::RichText::new("Forge").strong())
662 679 .default_open(false)
663 - .show(ui, |ui| {
664 - let mut auto_trim = state.preview.forge_auto_trim_overshoot;
665 - if ui
666 - .checkbox(&mut auto_trim, "Auto-trim resample overshoot")
667 - .on_hover_text(
668 - "Resampling can push peaks just past full scale. Off (default): the \
669 - signal is left untouched and a warning is shown if a conform will clip. \
670 - On: the forge applies the smallest gain reduction to bring the peak back \
671 - to full scale, avoiding the clip.",
672 - )
673 - .changed()
674 - {
675 - state.toggle_forge_auto_trim_overshoot();
676 - }
677 - });
680 + .show(ui, |ui| forge_body(ui, state));
681 + }
682 +
683 + /// What the Forge section holds, with the fold taken off.
684 + ///
685 + /// Split from the header so `quasi::parity` can read what the section
686 + /// offers without opening it. A `CollapsingHeader`'s id comes from the
687 + /// `ui.vertical` it makes for itself, so its fold state cannot be set from
688 + /// outside, and folding is the renderer's business rather than something
689 + /// either screen describes.
690 + pub(crate) fn forge_body(ui: &mut egui::Ui, state: &mut BrowserState) {
691 + let mut auto_trim = state.preview.forge_auto_trim_overshoot;
692 + if ui
693 + .checkbox(&mut auto_trim, "Auto-trim resample overshoot")
694 + .on_hover_text(
695 + "Resampling can push peaks just past full scale. Off (default): the \
696 + signal is left untouched and a warning is shown if a conform will clip. \
697 + On: the forge applies the smallest gain reduction to bring the peak back \
698 + to full scale, avoiding the clip.",
699 + )
700 + .changed()
701 + {
702 + state.toggle_forge_auto_trim_overshoot();
703 + }
678 704 }
679 705
680 706 // Display section
681 707
682 - fn draw_display_section(ui: &mut egui::Ui, state: &mut BrowserState) {
708 + pub(crate) fn draw_display_section(ui: &mut egui::Ui, state: &mut BrowserState) {
683 709 egui::CollapsingHeader::new(egui::RichText::new("Display").strong())
684 710 .default_open(false)
685 - .show(ui, |ui| {
686 - ui.label(
687 - egui::RichText::new("Visible Columns")
688 - .small()
689 - .color(theme::content_secondary()),
711 + .show(ui, |ui| display_body(ui, state));
712 + }
713 +
714 + /// What the Display section holds, with the fold taken off.
715 + ///
716 + /// Split from the header so `quasi::parity` can read what the section
717 + /// offers without opening it. A `CollapsingHeader`'s id comes from the
718 + /// `ui.vertical` it makes for itself, so its fold state cannot be set from
719 + /// outside, and folding is the renderer's business rather than something
720 + /// either screen describes.
721 + pub(crate) fn display_body(ui: &mut egui::Ui, state: &mut BrowserState) {
722 + ui.label(
723 + egui::RichText::new("Visible Columns")
724 + .small()
725 + .color(theme::content_secondary()),
726 + );
727 +
728 + let mut col_changed = false;
729 + col_changed |= ui
730 + .checkbox(&mut state.column_config.show_bpm, "BPM")
731 + .changed();
732 + col_changed |= ui
733 + .checkbox(&mut state.column_config.show_key, "Key")
734 + .changed();
735 + col_changed |= ui
736 + .checkbox(&mut state.column_config.show_duration, "Duration")
737 + .changed();
738 + col_changed |= ui
739 + .checkbox(&mut state.column_config.show_peak_db, "Peak dB")
740 + .changed();
741 + col_changed |= ui
742 + .checkbox(&mut state.column_config.show_tags, "Tags")
743 + .changed();
744 + if col_changed {
745 + state.save_column_config();
746 + }
747 +
748 + ui.add_space(theme::space::bound());
749 + if ui
750 + .button("Reset columns")
751 + .on_hover_text(
752 + "Restore column visibility, sort, and row density to defaults. \
753 + Column widths reset on next app launch.",
754 + )
755 + .clicked()
756 + {
757 + state.reset_columns();
758 + }
759 +
760 + ui.add_space(theme::space::peer());
761 + ui.separator();
762 + ui.add_space(theme::space::bound());
763 + ui.label(
764 + egui::RichText::new("Row Density")
765 + .small()
766 + .color(theme::content_secondary()),
767 + );
768 + let mut row_height = state.row_height;
769 + let label = if row_height <= 22.0 {
770 + "Compact"
771 + } else if row_height >= 28.0 {
772 + "Spacious"
773 + } else {
774 + "Normal"
775 + };
776 + ui.horizontal(|ui| {
777 + ui.label(label);
778 + ui.label(
779 + egui::RichText::new(format!("{} px", row_height as i32))
780 + .small()
781 + .color(theme::content_muted()),
782 + );
783 + if ui
784 + .add(
785 + egui::Slider::new(&mut row_height, 20.0..=32.0)
786 + .step_by(2.0)
787 + .show_value(false),
788 + )
789 + .changed()
790 + {
791 + state.row_height = row_height;
792 + let _ = state.backend.set_config(
793 + crate::backend::ConfigKey::RowHeight,
794 + &format!("{row_height}"),
690 795 );
691 -
692 - let mut col_changed = false;
693 - col_changed |= ui
694 - .checkbox(&mut state.column_config.show_bpm, "BPM")
695 - .changed();
696 - col_changed |= ui
697 - .checkbox(&mut state.column_config.show_key, "Key")
698 - .changed();
699 - col_changed |= ui
700 - .checkbox(&mut state.column_config.show_duration, "Duration")
701 - .changed();
702 - col_changed |= ui
703 - .checkbox(&mut state.column_config.show_peak_db, "Peak dB")
704 - .changed();
705 - col_changed |= ui
706 - .checkbox(&mut state.column_config.show_tags, "Tags")
707 - .changed();
708 - if col_changed {
709 - state.save_column_config();
710 - }
711 -
712 - ui.add_space(theme::space::bound());
713 - if ui
714 - .button("Reset columns")
715 - .on_hover_text(
716 - "Restore column visibility, sort, and row density to defaults. \
717 - Column widths reset on next app launch.",
718 - )
719 - .clicked()
720 - {
721 - state.reset_columns();
722 - }
723 -
724 - ui.add_space(theme::space::peer());
725 - ui.separator();
726 - ui.add_space(theme::space::bound());
727 - ui.label(
728 - egui::RichText::new("Row Density")
729 - .small()
730 - .color(theme::content_secondary()),
731 - );
732 - let mut row_height = state.row_height;
733 - let label = if row_height <= 22.0 {
734 - "Compact"
735 - } else if row_height >= 28.0 {
736 - "Spacious"
737 - } else {
738 - "Normal"
739 - };
740 - ui.horizontal(|ui| {
741 - ui.label(label);
742 - ui.label(
743 - egui::RichText::new(format!("{} px", row_height as i32))
744 - .small()
745 - .color(theme::content_muted()),
746 - );
747 - if ui
748 - .add(
749 - egui::Slider::new(&mut row_height, 20.0..=32.0)
750 - .step_by(2.0)
751 - .show_value(false),
752 - )
753 - .changed()
754 - {
755 - state.row_height = row_height;
756 - let _ = state.backend.set_config(
757 - crate::backend::ConfigKey::RowHeight,
758 - &format!("{row_height}"),
759 - );
760 - }
761 - });
762 - });
796 + }
797 + });
763 798 }
764 799
765 800 // License section
@@ -1,0 +1,706 @@
1 + //! Does the described screen offer what the shipped one offers?
2 + //!
3 + //! Every flip in the audiofiles set replaces a hand-written egui panel with a
4 + //! described screen. Without this file the only evidence that the replacement
5 + //! matches is that both were written from the same intent, which is not
6 + //! evidence. mnw-server built the equivalent (`tests/harness/parity.rs`) and it
7 + //! is what made that flip set startable: a flip with a parity harness behind it
8 + //! is a mechanical change, and one without it is a rewrite nobody can check.
9 + //!
10 + //! # What equivalence means here, and why it is not pixels
11 + //!
12 + //! The described half renders through `quasi-immediate` and the shipped half
13 + //! through hand-written egui. They do not look identical and are not supposed
14 + //! to: choosing the layout is the renderer's job and the whole reason the
15 + //! description stops short of one.
16 + //!
17 + //! What has to agree is what the screen **offers** -- the same controls, saying
18 + //! the same words, dead in the same states. That is a set of [`Offer`]s, and
19 + //! both sides are reduced to one:
20 + //!
21 + //! - The described side by walking the [`Screen`] the router answered.
22 + //! - The shipped side by drawing the panel into a headless [`egui::Context`]
23 + //! with AccessKit on, and reading the tree egui built for a screen reader.
24 + //! egui fills that tree from the same [`egui::WidgetInfo`] every widget
25 + //! already reports, so this asks the panel what it drew rather than parsing
26 + //! pixels or duplicating its logic.
27 + //!
28 + //! # What an offer is, and what it deliberately drops
29 + //!
30 + //! A [`Role`] and a label, plus whether the control is dead. Position is not
31 + //! compared: the two renderers order a screen differently by design, so offers
32 + //! are compared as a sorted multiset.
33 + //!
34 + //! Prose is dropped. A described screen says what it says through
35 + //! `Node::Text`, and the shipped panel scatters the same sentences through
36 + //! `ui.label` calls that AccessKit reports as `Role::Label` -- comparing them
37 + //! would fail on every line break either side chose. What a screen *says* is
38 + //! already asserted by `tests::said`; what it *offers* is this file's question.
39 + //!
40 + //! Addresses are the described side's alone, because egui has none: a shipped
41 + //! control calls a closure, and the whole point of the flip is that a described
42 + //! one names a route instead. So they are not compared across the two sides.
43 + //! They are checked *within* the described side by
44 + //! [`Offering::addresses_resolve`], which is the other half of the same claim:
45 + //! every act the screen offers reaches a route the router actually has.
46 + //!
47 + //! # The allowances
48 + //!
49 + //! A flip is allowed to change what a screen offers, and where it does, the
50 + //! call site names the change rather than the harness ignoring a class of
51 + //! difference blanket. That is [`Parity::dropping`] and [`Parity::gaining`]: each one at a
52 + //! call site is a claim somebody wrote down.
53 +
54 + use std::collections::BTreeMap;
55 + use std::fmt::Write as _;
56 +
57 + use quasi_router::{Node, Screen, layout};
58 +
59 + /// What kind of control an offer is.
60 + ///
61 + /// Deliberately coarser than either side's own vocabulary. egui reports a
62 + /// `SelectableLabel` and a `Button` as the same AccessKit role, and the
63 + /// description says `Act` for both, so a finer split would be a difference
64 + /// neither side chose.
65 + #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
66 + pub(super) enum Role {
67 + /// Something you press.
68 + Button,
69 + /// Something you type into.
70 + Text,
71 + /// Something you tick.
72 + Check,
73 + /// Something you pick one of.
74 + Choice,
75 + /// Something you drag to a number.
76 + Number,
77 + }
78 +
79 + impl Role {
80 + const fn show(self) -> &'static str {
81 + match self {
82 + Self::Button => "button",
83 + Self::Text => "text",
84 + Self::Check => "check",
85 + Self::Choice => "choice",
86 + Self::Number => "number",
87 + }
88 + }
89 + }
90 +
91 + /// One thing a screen offers.
92 + #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
93 + pub(super) struct Offer {
94 + /// What kind of control it is.
95 + pub(super) role: Role,
96 + /// What it says.
97 + pub(super) label: String,
98 + /// Whether it is present and not answering.
99 + pub(super) dead: bool,
100 + }
101 +
102 + impl Offer {
103 + fn show(&self) -> String {
104 + let dead = if self.dead { " (dead)" } else { "" };
105 + format!("{} {:?}{dead}", self.role.show(), self.label)
106 + }
107 + }
108 +
109 + /// Everything a screen offers, as a multiset.
110 + #[derive(Debug, Clone, Default, PartialEq, Eq)]
111 + pub(super) struct Offering {
112 + offers: Vec<Offer>,
113 + /// The routes the described side's controls name. Empty for a shipped
114 + /// screen, which has no addresses to name.
115 + addresses: Vec<String>,
116 + }
117 +
118 + impl Offering {
119 + fn push(&mut self, role: Role, label: impl Into<String>, dead: bool) {
120 + let label = label.into();
121 + // A control with nothing to say is not an offer anyone can act on, and
122 + // both sides produce them: egui reports a spacer, and the description
123 + // has icon-only acts whose label is empty by design.
124 + if label.trim().is_empty() {
125 + return;
126 + }
127 + self.offers.push(Offer { role, label, dead });
128 + }
129 +
130 + /// The offers, sorted, so position stops being a difference.
131 + fn sorted(&self) -> Vec<Offer> {
132 + let mut offers = self.offers.clone();
133 + offers.sort();
134 + offers
135 + }
136 +
137 + /// Assert every address this screen names is a route the router has.
138 + ///
139 + /// The other half of "they agree on their addresses". An address is the
140 + /// described side's alone -- a shipped control calls a closure and has
141 + /// none -- so it cannot be compared across the two. What can be checked is
142 + /// that it is real: a described act naming a route nobody registered is a
143 + /// dead control that a rendering test would never notice, because the
144 + /// screen draws perfectly and does nothing when pressed.
145 + ///
146 + /// Matched segment-wise against the router's own patterns rather than by
147 + /// asking the router, because `Path::match_path` is crate-private and the
148 + /// public alternative is `handle`, which would run the handler. Pressing
149 + /// things is not what a parity read does.
150 + fn addresses_resolve(&self) {
151 + let router = super::router();
152 + let patterns: Vec<String> = router.routes().map(|(_, path)| path.to_owned()).collect();
153 + for address in &self.addresses {
154 + assert!(
155 + patterns.iter().any(|pattern| matches(pattern, address)),
156 + "the screen offers a control addressed {address:?}, \
157 + and the router has no route that answers it"
158 + );
159 + }
160 + }
161 +
162 + /// A one-per-line rendering, for a failure message.
163 + fn show(&self) -> String {
164 + let mut out = String::new();
165 + for offer in self.sorted() {
166 + let _ = writeln!(out, " {}", offer.show());
167 + }
168 + out
169 + }
170 + }
171 +
172 + /// Reduce a described screen to what it offers.
173 + ///
174 + /// Walks every node, descending into regions, forms, tables, lists and cells,
175 + /// because a control inside a row is as much of an offer as one in the header.
176 + pub(super) fn described(screen: &Screen) -> Offering {
177 + let mut out = Offering::default();
178 + for slot in &screen.slots {
179 + for placed in &slot.body {
180 + walk(&placed.node, &mut out);
181 + }
182 + }
183 + out
184 + }
185 +
186 + /// The role a field of this kind is drawn as.
187 + fn field_role(kind: layout::FieldKind) -> Role {
188 + use layout::FieldKind as K;
189 + match kind {
190 + K::Checkbox => Role::Check,
191 + K::Select | K::Radio => Role::Choice,
192 + K::Number | K::Range | K::Interval => Role::Number,
193 + // Everything else is a box you type into. `File` is the one stretch and
194 + // it is the honest answer: a host draws it as a control that opens a
195 + // picker, which is a button on some hosts and a path box on others, and
196 + // guessing which would be this file deciding a renderer's question.
197 + _ => Role::Text,
198 + }
199 + }
200 +
201 + fn walk(node: &Node, out: &mut Offering) {
202 + match node {
203 + Node::Act(act) => {
204 + out.push(
205 + Role::Button,
206 + act.label.clone(),
207 + act.state == Some(layout::State::Disabled),
208 + );
209 + out.addresses
210 + .push(act.action.destination.as_str().to_owned());
211 + // An act that asks for something before it fires carries its own
212 + // fields, and those are as much of what the screen offers as a
213 + // field standing on its own.
214 + for field in &act.asks {
215 + out.push(field_role(field.kind), field.label.clone(), false);
216 + }
217 + }
218 + Node::Link { text, action } => {
219 + out.push(Role::Button, text.clone(), false);
220 + out.addresses.push(action.destination.as_str().to_owned());
221 + }
222 + Node::Field(field) => out.push(field_role(field.kind), field.label.clone(), false),
223 + Node::Form {
224 + submit,
225 + action,
226 + fields,
227 + } => {
228 + out.push(Role::Button, submit.clone(), false);
229 + out.addresses.push(action.destination.as_str().to_owned());
230 + for field in fields {
231 + out.push(field_role(field.kind), field.label.clone(), false);
232 + }
233 + }
234 + Node::Select {
235 + options, action, ..
236 + } => {
237 + // A segmented control is one choice with several labels on the
238 + // shipped side too, so each option is an offer rather than the
239 + // strip being one.
240 + for (choice, own) in options {
241 + out.push(Role::Choice, choice.label.clone(), false);
242 + if let Some(action) = own.as_ref().or(action.as_ref()) {
243 + out.addresses.push(action.destination.as_str().to_owned());
244 + }
245 + }
246 + }
247 + Node::Table { columns, rows, .. } => {
248 + for column in columns {
249 + // A heading with no address is a heading. Only a sortable one
250 + // is something you can press.
251 + if let Some(reorder) = &column.reorder {
252 + out.push(Role::Button, column.name.clone(), false);
253 + out.addresses.push(reorder.destination.as_str().to_owned());
254 + }
255 + }
256 + for cells in rows {
257 + if let Some(activate) = &cells.activate {
258 + out.push(Role::Button, first_words(&cells.values), false);
259 + out.addresses.push(activate.destination.as_str().to_owned());
260 + }
261 + for cell in &cells.values {
262 + for part in &cell.parts {
263 + walk(part, out);
264 + }
265 + }
266 + }
267 + }
268 + Node::List { rows, .. } => {
269 + for row in rows {
270 + if let Some(activate) = &row.activate {
271 + let named: Vec<_> = row.parts.iter().map(|part| part.node.clone()).collect();
272 + out.push(Role::Button, first_text(&named), false);
273 + out.addresses.push(activate.destination.as_str().to_owned());
274 + }
275 + for part in &row.parts {
276 + walk(&part.node, out);
277 + }
278 + }
279 + }
280 + Node::Region(slot) => {
281 + for placed in &slot.body {
282 + walk(&placed.node, out);
283 + }
284 + }
285 + Node::Stats { figures } => {
286 + for (figure, action) in figures {
287 + if let Some(action) = action {
288 + out.push(Role::Button, figure.caption.clone(), false);
289 + out.addresses.push(action.destination.as_str().to_owned());
290 + }
291 + }
292 + }
293 + // Prose, figures, images, tokens, meters, timelines and stand-ins are
294 + // things a screen says rather than things it offers. See the header.
295 + _ => {}
296 + }
297 + }
298 +
299 + /// Whether a concrete address is what this route pattern describes.
300 + ///
301 + /// Segment counts must agree and each segment must match, with a `{name}`
302 + /// segment matching anything. A trailing query is dropped first: it carries
303 + /// parameters, not a route.
304 + fn matches(pattern: &str, address: &str) -> bool {
305 + let address = address.split('?').next().unwrap_or(address);
306 + let pattern: Vec<&str> = pattern.trim_matches('/').split('/').collect();
307 + let address: Vec<&str> = address.trim_matches('/').split('/').collect();
308 + pattern.len() == address.len()
309 + && pattern
310 + .iter()
311 + .zip(&address)
312 + .all(|(want, got)| want.starts_with('{') || want == got)
313 + }
314 +
315 + /// What a row is called: the first words in it.
316 + ///
317 + /// A row's press has no label of its own on either side. The shipped panel
318 + /// announces the row by its first column, because that is the cell it senses
319 + /// the click on, and a described row names the same text in the same place, so
320 + /// this reads it from there rather than inventing a name for the press.
321 + fn first_words(cells: &[quasi_router::Cell]) -> String {
322 + cells
323 + .iter()
324 + .find_map(|cell| {
325 + let said = first_text(&cell.parts);
326 + (!said.is_empty()).then_some(said)
327 + })
328 + .unwrap_or_default()
329 + }
330 +
331 + /// The first thing a run of leaves says.
332 + fn first_text(parts: &[Node]) -> String {
333 + parts
334 + .iter()
335 + .find_map(|part| match part {
336 + Node::Text { text, .. } | Node::Heading { text, .. } | Node::Link { text, .. } => {
337 + Some(text.clone())
338 + }
339 + _ => None,
340 + })
341 + .unwrap_or_default()
342 + }
343 +
344 + /// Reduce a shipped egui panel to what it offers.
345 + ///
346 + /// Draws `paint` into a headless context with AccessKit on and reads the tree
347 + /// egui built. The closure is handed the root [`egui::Ui`], which is what the
348 + /// panels take; a screen that opens a window instead reaches the context
349 + /// through `ui.ctx()`, the same way the app does.
350 + ///
351 + /// A control egui reports with no label is dropped by [`Offering::push`]: a
352 + /// separator, a spacer, the panel background. What survives is what a screen
353 + /// reader would announce, which is the same set a user can act on.
354 + pub(super) fn shipped(mut paint: impl FnMut(&mut egui::Ui)) -> Offering {
355 + let ctx = egui::Context::default();
356 + ctx.enable_accesskit();
357 + // Selectable labels off, and it is load-bearing rather than cosmetic. With
358 + // them on -- egui's default -- every `ui.label` senses a click so its text
359 + // can be dragged over, and the tree says a paragraph of prose answers a
360 + // press exactly as a sortable heading does. Selecting text is not something
361 + // a screen offers, and turning it off is what leaves the click sense
362 + // meaning what `role_of` reads it as meaning.
363 + for theme in [egui::Theme::Light, egui::Theme::Dark] {
364 + ctx.style_mut_of(theme, |style| {
365 + style.interaction.selectable_labels = false;
366 + // No animation, so a section that has been opened is open on the
367 + // next pass rather than a fraction of the way there. Openness is
368 + // animated, the harness runs its passes at one instant, and a
369 + // section caught mid-open draws none of its contents -- which reads
370 + // as a screen offering nothing.
371 + style.animation_time = 0.0;
372 + });
373 + }
374 + // A real size, because a panel that lays out into a zero-width viewport
375 + // drops columns and would look like a screen offering less than it does.
376 + let input = || egui::RawInput {
377 + screen_rect: Some(egui::Rect::from_min_size(
378 + egui::Pos2::ZERO,
379 + egui::vec2(1440.0, 900.0),
380 + )),
381 + ..Default::default()
382 + };
383 +
384 + // Two passes, and the second is the one that is read. egui lays out against
385 + // the previous frame, so a first pass sees widgets at the wrong rect and
386 + // misses anything whose existence depends on a measurement taken last
387 + // frame. A window is the sharp case: it has no size until it has been
388 + // drawn once.
389 + let _ = ctx.run_ui(input(), &mut paint);
390 + let output = ctx.run_ui(input(), &mut paint);
391 +
392 + let mut out = Offering::default();
393 + let Some(update) = output.platform_output.accesskit_update else {
394 + panic!("accesskit produced no tree: the panel drew nothing at all");
395 + };
396 + for (_, node) in update.nodes {
397 + let Some(role) = role_of(&node) else {
398 + continue;
399 + };
400 + // egui puts a label's text in `value` and every other widget's in
401 + // `label`, because a `Role::Label` IS its text. See
402 + // `Response::fill_accesskit_node_from_widget_info`.
403 + let said = node
404 + .label()
405 + .map(str::to_owned)
406 + .or_else(|| node.value().map(str::to_owned))
407 + .unwrap_or_default();
408 + out.push(role, undecorated(&said), node.is_disabled());
409 + }
410 + out
411 + }
412 +
413 + /// A rendered label with the renderer's own decoration taken back off.
414 + ///
415 + /// A sorted column heading is drawn as its name plus a caret, because a glyph
416 + /// beside the word is how a table says which column is in force. The
417 + /// description says the same thing structurally, as `Column::sorted`, and
418 + /// carries no caret in the name. Stripping it is therefore normalization
419 + /// rather than an allowance: the fact is on both sides, said two ways.
420 + ///
421 + /// The glyphs come from `layout::Sort::glyph`, which is where their spelling
422 + /// lives, so a renderer that changes its caret does not quietly break this.
423 + fn undecorated(said: &str) -> String {
424 + let mut said = said.trim();
425 + for direction in [layout::Sort::Ascending, layout::Sort::Descending] {
426 + if let Some(stripped) = said.strip_suffix(direction.glyph()) {
427 + said = stripped.trim_end();
428 + }
429 + }
430 + said.to_owned()
431 + }
432 +
433 + /// The role an AccessKit node maps to, or `None` if it is not a control.
434 + ///
435 + /// The role alone is not enough, and a sortable column heading is why. egui
436 + /// draws one as an `egui::Label` that senses a click, so the role that reaches
437 + /// the tree is `Label` -- a screen reader announces static text where a user can
438 + /// press to reorder the table. What decides here is therefore whether the node
439 + /// answers a click, which egui records faithfully from the widget's own
440 + /// `Sense`. The description says the same thing by giving the column a
441 + /// `reorder` address, so the two agree.
442 + ///
443 + /// That the announcement is wrong is a real finding about the renderer rather
444 + /// than about either screen, and it is filed rather than worked around here:
445 + /// this reads the sense because the sense is the honest signal, not to paper
446 + /// over the role.
447 + fn role_of(node: &egui::accesskit::Node) -> Option<Role> {
448 + use egui::accesskit::{Action, Role as R};
449 + match node.role() {
450 + R::Button | R::Link => Some(Role::Button),
451 + R::TextInput | R::MultilineTextInput => Some(Role::Text),
452 + R::CheckBox | R::Switch => Some(Role::Check),
453 + R::RadioButton | R::ComboBox | R::ListBox => Some(Role::Choice),
454 + R::Slider | R::SpinButton => Some(Role::Number),
455 + // Anything else that answers a press is a control whatever it is
456 + // announced as. Anything else that does not is prose, an image, a
457 + // scrollbar, a pane: things a screen has rather than things it offers.
458 + _ if node.supports_action(Action::Click) => Some(Role::Button),
459 + _ => None,
460 + }
461 + }
462 +
463 + /// How a described screen is allowed to differ from the one it replaces.
464 + ///
465 + /// Every allowance is named at the call site, so the list on a test is the
466 + /// record of what that flip changed. There is deliberately no "ignore whatever
467 + /// differs" option: an unexplained difference is the thing this file exists to
468 + /// find.
469 + #[derive(Debug, Clone, Default)]
470 + pub(super) struct Parity {
471 + dropped: Vec<String>,
472 + gained: Vec<String>,
473 + }
474 +
475 + impl Parity {
476 + /// Strict: every difference fails.
477 + pub(super) fn strict() -> Self {
478 + Self::default()
479 + }
480 +
481 + /// A control the shipped screen had and the described one does not.
482 + ///
483 + /// For chrome the description deliberately refuses -- the ten-variant
484 + /// confirm dialog, a panel's own close button -- where the flip's claim is
485 + /// that the thing is the host's rather than the screen's.
486 + #[must_use]
487 + pub(super) fn dropping(mut self, label: &str) -> Self {
488 + self.dropped.push(label.to_owned());
489 + self
490 + }
491 +
492 + /// A control the described screen has and the shipped one did not.
493 + ///
494 + /// For what a port fixed on the way through: a dead-end the shipped panel
495 + /// left the user in, an act that was only reachable by a keyboard shortcut.
496 + #[must_use]
497 + pub(super) fn gaining(mut self, label: &str) -> Self {
498 + self.gained.push(label.to_owned());
499 + self
500 + }
Lines truncated