max / audiofiles
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
7 files changed,
+810 insertions,
-230 deletions
| @@ -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(¤t_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(¤t_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