From 8d851ee0909141e286de2397df8ece94612da704 Mon Sep 17 00:00:00 2001 From: math65 Date: Fri, 25 Sep 2026 19:44:47 +0200 Subject: [PATCH] feat(ui): have VoiceOver announce changes made away from the focus VoiceOver reads a control's new label or state only when the user moves to it, so three things RABBIT changes on its own went unheard on macOS: - ticking a package can show or hide the REAPER-language and Spanish variant dropdowns below the list, off to the side of the row the user is on; - the progress page's status line moves from package to package while focus sits on the log; - a failed version check rewrites the status line while focus stays on the gauge. Each is now announced through the voiceover module added for Space toggles, which gains a priority: the answer to a key press interrupts, news the user didn't ask for waits for current speech. Only the start of each install and configuration step is spoken, not download lines, which change several times a second. A Space toggle folds its dropdown news into the same announcement, since a second one would cut the first off; a click or VO+Space, which VoiceOver already reads back, gets the dropdown news on its own, politely. The packages toggle now returns the dropdown lines instead of a bool; elsewhere than macOS `announce` does nothing. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 + crates/rabbit-ui-wxdragon/src/voiceover.rs | 28 ++++- crates/rabbit-ui-wxdragon/src/wx_app.rs | 137 ++++++++++++++++----- locales/de-DE/rabbit.ftl | 2 + locales/en-US/rabbit.ftl | 2 + locales/es-ES/rabbit.ftl | 2 + locales/fr-FR/rabbit.ftl | 2 + locales/it-IT/rabbit.ftl | 2 + 8 files changed, 146 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e4ce6f3..7cb5942 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -66,6 +66,10 @@ from this file and posts it as the GitHub release body. requests an hour, which testers following OSARA pull request builds can run out of. With a personal access token set, the limit is 5,000. RABBIT already used the variable, but only CI knew about it. +- On macOS, VoiceOver now hears what changes away from where the user is + standing: a dropdown appearing or going away below the **Packages** list + when a package is ticked, each package and configuration step as the + install reaches it, and the reason when the version check fails. ### Changed diff --git a/crates/rabbit-ui-wxdragon/src/voiceover.rs b/crates/rabbit-ui-wxdragon/src/voiceover.rs index 85e5ca1..2811c51 100644 --- a/crates/rabbit-ui-wxdragon/src/voiceover.rs +++ b/crates/rabbit-ui-wxdragon/src/voiceover.rs @@ -23,9 +23,26 @@ struct CfCallBacks { const CF_STRING_ENCODING_UTF8: u32 = 0x0800_0100; const CF_NUMBER_CF_INDEX_TYPE: isize = 14; -/// `NSAccessibilityPriorityHigh`: interrupts whatever VoiceOver is saying, -/// which is what a direct answer to a key press should do. -const NS_ACCESSIBILITY_PRIORITY_HIGH: isize = 90; + +/// How an announcement treats whatever VoiceOver is already saying. +#[derive(Clone, Copy)] +pub(crate) enum Priority { + /// `NSAccessibilityPriorityHigh`: cut in. For the direct answer to a key + /// press, which is stale by the time a queue would reach it. + Interrupt, + /// `NSAccessibilityPriorityMedium`: wait for current speech. For news the + /// user didn't ask for, such as the next step of a running install. + Polite, +} + +impl Priority { + fn value(self) -> isize { + match self { + Priority::Interrupt => 90, + Priority::Polite => 50, + } + } +} // SAFETY: AppKit and CoreFoundation are system frameworks that wxWidgets // already links; these declarations match their public C headers. @@ -61,10 +78,11 @@ unsafe extern "C" { /// Have VoiceOver speak `text` now. Does nothing when VoiceOver is off: /// macOS drops the notification. -pub(crate) fn announce(text: &str) { +pub(crate) fn announce(text: &str, priority: Priority) { let Ok(text) = CString::new(text) else { return; }; + let priority_value = priority.value(); // SAFETY: every object created here is released before returning, and // the dictionary retains what it holds. NSApp is set by wxWidgets before // any window exists, so it is valid wherever a key event can arrive. @@ -77,7 +95,7 @@ pub(crate) fn announce(text: &str) { let priority = CFNumberCreate( std::ptr::null(), CF_NUMBER_CF_INDEX_TYPE, - (&NS_ACCESSIBILITY_PRIORITY_HIGH as *const isize).cast(), + (&priority_value as *const isize).cast(), ); if message.is_null() || priority.is_null() { for object in [message, priority] { diff --git a/crates/rabbit-ui-wxdragon/src/wx_app.rs b/crates/rabbit-ui-wxdragon/src/wx_app.rs index 8289232..2c22422 100644 --- a/crates/rabbit-ui-wxdragon/src/wx_app.rs +++ b/crates/rabbit-ui-wxdragon/src/wx_app.rs @@ -52,6 +52,27 @@ fn with_ui_localizer(f: F) { }); } +/// Have the screen reader speak `text`. VoiceOver reads a control's new +/// label or state only when the user moved there, so a change made from +/// code (a status line, a row ticked through the model, a dropdown shown) +/// goes unheard without this. macOS only for now: elsewhere it does nothing. +#[cfg(target_os = "macos")] +use crate::voiceover::Priority as AnnouncePriority; + +#[cfg(not(target_os = "macos"))] +#[derive(Clone, Copy)] +enum AnnouncePriority { + Interrupt, + Polite, +} + +fn announce(text: &str, priority: AnnouncePriority) { + #[cfg(target_os = "macos")] + crate::voiceover::announce(text, priority); + #[cfg(not(target_os = "macos"))] + let _ = (text, priority); +} + fn install_ui_frame(frame: Frame) { UI_FRAME.with(|cell| { *cell.borrow_mut() = Some(frame); @@ -1953,10 +1974,20 @@ fn apply_progress_event_to_ui( }); widgets.progress_gauge.set_value(state.percentage()); + // Only the start of each install or configuration step is spoken, and + // politely: download lines change several times a second and would bury + // everything else. + let spoken = matches!( + event, + ProgressEvent::InstallStarted { .. } | ProgressEvent::ConfigurationStarted { .. } + ); if let Some(line) = status_line && !status_frozen { widgets.progress_status.set_label(&line); + if spoken { + announce(&line, AnnouncePriority::Polite); + } } // Hold the lock no longer than necessary — the TextCtrl call below // re-enters the wxWidgets event pump, which can run other queued @@ -3768,6 +3799,9 @@ fn render_version_check_errors(ui: &VersionCheckUi, errors: &[(String, String)]) ) .value; ui.widgets.version_check_status.set_label(&status); + // Focus stays on the gauge, so nothing else tells a screen reader + // that the check stopped and the page now waits for Back or Close. + announce(&status, AnnouncePriority::Interrupt); }); } @@ -5953,25 +5987,28 @@ fn build_packages_page( &data.configuration_rows.borrow(), node.kind, ); - if !toggle(data, node, !checked) { + let Some(choice_notes) = toggle(data, node, !checked) else { return; - } + }; // The row VoiceOver is on was rebuilt behind it, so it says // nothing on its own. Read back the state the row ended up in: // a group can stay unticked when some of its rows are disabled. - #[cfg(target_os = "macos")] - { - let now_checked = packages_tree_toggle_state( - &data.rows.borrow(), - &data.configuration_rows.borrow(), - node.kind, - ); - crate::voiceover::announce(if now_checked { - &model_text.text.packages_row_checked - } else { - &model_text.text.packages_row_unchecked - }); - } + // One announcement, not two: a second would cut the first off. + let now_checked = packages_tree_toggle_state( + &data.rows.borrow(), + &data.configuration_rows.borrow(), + node.kind, + ); + let state = if now_checked { + &model_text.text.packages_row_checked + } else { + &model_text.text.packages_row_unchecked + }; + let message = std::iter::once(state.as_str()) + .chain(choice_notes.iter().map(String::as_str)) + .collect::>() + .join(". "); + announce(&message, AnnouncePriority::Interrupt); }); } @@ -6007,9 +6044,11 @@ struct PackagesSideWidgets { type PackagesSideWidgetsCell = Rc>>; /// Non-Windows: tick or untick one node of the packages tree, with every -/// side effect, and report whether anything changed. +/// side effect. `None` when nothing changed; otherwise one line for each +/// dropdown below the list that the toggle showed or hid, for the caller +/// to announce. #[cfg(not(target_os = "windows"))] -type PackagesTreeToggle = Rc bool>; +type PackagesTreeToggle = Rc Option>>; /// Non-Windows: whether a node's checkbox reads as ticked. A group reads /// ticked only when every row it can change is selected: the toggle @@ -6080,7 +6119,8 @@ fn build_packages_tree_model( // (a click, or VO+Space while interacting with the list) and the Space // key handler, which wx never routes through `set_value` on macOS. let toggle: PackagesTreeToggle = Rc::new( - move |data: &PackageTreeData, node: &Node, new_state: bool| -> bool { + move |data: &PackageTreeData, node: &Node, new_state: bool| -> Option> { + let mut choice_notes = Vec::new(); match node.kind { NodeKind::PackagesGroup | NodeKind::AdditionalSoftwareGroup @@ -6106,11 +6146,9 @@ fn build_packages_tree_model( } NodeKind::Package(idx) => { let mut rows = rows_for_set_value.borrow_mut(); - let Some(row) = rows.get_mut(idx) else { - return false; - }; + let row = rows.get_mut(idx)?; if !row.available_for_target { - return false; + return None; } let _ = apply_checkbox_state_to_package_row(&wizard_model, row, new_state); } @@ -6124,11 +6162,9 @@ fn build_packages_tree_model( } NodeKind::Configuration(idx) => { let mut cfg_rows = configuration_rows_for_set_value.borrow_mut(); - let Some(row) = cfg_rows.get_mut(idx) else { - return false; - }; + let row = cfg_rows.get_mut(idx)?; if !row.available_for_target || row.already_applied { - return false; + return None; } row.selected = new_state; } @@ -6177,6 +6213,10 @@ fn build_packages_tree_model( // never fires. if let Some(widgets) = *side_widgets_for_set_value.borrow() { let rows = rows_for_set_value.borrow(); + let shown_before = [ + widgets.language_choice.is_shown(), + widgets.spanish_choice.is_shown(), + ]; sync_osara_keymap_widgets( &wizard_model_for_recompute, &rows, @@ -6185,6 +6225,21 @@ fn build_packages_tree_model( ); sync_spanish_variant_widget(&rows, &widgets.spanish_choice); sync_reaper_language_widget(&rows, &widgets.language_choice); + let labels = [ + &wizard_model_for_recompute + .text + .packages_reaper_language_label, + &wizard_model_for_recompute + .text + .packages_spanish_variant_label, + ]; + let choices = [widgets.language_choice, widgets.spanish_choice]; + for ((choice, was_shown), label) in choices.iter().zip(shown_before).zip(labels) + { + if choice.is_shown() != was_shown { + choice_notes.push(choice_shown_note(label, choice.is_shown())); + } + } } } @@ -6263,7 +6318,7 @@ fn build_packages_tree_model( } } - true + Some(choice_notes) }, ); let toggle_for_model = Rc::clone(&toggle); @@ -6399,7 +6454,18 @@ fn build_packages_tree_model( let Some(node) = item else { return false; }; - toggle_for_model(data, node, var.get_bool().unwrap_or(false)) + let Some(choice_notes) = + toggle_for_model(data, node, var.get_bool().unwrap_or(false)) + else { + return false; + }; + // A click, or VO+Space: VoiceOver reads the checkbox itself, + // so only the dropdowns that came or went are left to say, + // after it. + if !choice_notes.is_empty() { + announce(&choice_notes.join(". "), AnnouncePriority::Polite); + } + true }, ), // is_enabled — gray out the checkbox + label of unavailable rows. @@ -7106,6 +7172,21 @@ const SPANISH_VARIANT_LABEL_NAME: &str = "rabbit-spanish-variant-label"; /// control and read out an empty combo box; hiding it removes it from the /// accessibility tree entirely, and it comes straight back when ticking a /// package makes the choice mean something again. +/// What to tell a screen reader when a dropdown below the packages list comes +/// or goes: it happens off to the side of the row the user just ticked. +fn choice_shown_note(label: &str, shown: bool) -> String { + let key = if shown { + "wizard-packages-choice-shown" + } else { + "wizard-packages-choice-hidden" + }; + let mut note = String::new(); + with_ui_localizer(|localizer| { + note = localizer.format(key, &[("choice", label)]).value; + }); + note +} + fn set_optional_choice_shown(choice: &Choice, label_name: &str, shown: bool) { if choice.is_shown() == shown { return; diff --git a/locales/de-DE/rabbit.ftl b/locales/de-DE/rabbit.ftl index 3b30da1..9cf71ec 100644 --- a/locales/de-DE/rabbit.ftl +++ b/locales/de-DE/rabbit.ftl @@ -114,6 +114,8 @@ wizard-packages-heading = Pakete auswählen wizard-packages-list-label = Zu installierende oder zu aktualisierende Pakete wizard-packages-row-checked = Ausgewählt wizard-packages-row-unchecked = Nicht ausgewählt +wizard-packages-choice-shown = Neue Auswahl unter der Liste: { $choice }. +wizard-packages-choice-hidden = Auswahl entfernt: { $choice }. wizard-packages-tree-group-label = Pakete wizard-additional-software-tree-group-label = Zusätzliche Software wizard-language-tree-group-label = Sprachpakete diff --git a/locales/en-US/rabbit.ftl b/locales/en-US/rabbit.ftl index 619663f..80054ff 100644 --- a/locales/en-US/rabbit.ftl +++ b/locales/en-US/rabbit.ftl @@ -114,6 +114,8 @@ wizard-packages-heading = Choose packages wizard-packages-list-label = Packages to install or update wizard-packages-row-checked = Checked wizard-packages-row-unchecked = Unchecked +wizard-packages-choice-shown = New choice below the list: { $choice }. +wizard-packages-choice-hidden = Choice removed: { $choice }. wizard-packages-tree-group-label = Packages wizard-additional-software-tree-group-label = Additional software wizard-language-tree-group-label = Language packs diff --git a/locales/es-ES/rabbit.ftl b/locales/es-ES/rabbit.ftl index c1ae2d6..f9025a1 100644 --- a/locales/es-ES/rabbit.ftl +++ b/locales/es-ES/rabbit.ftl @@ -114,6 +114,8 @@ wizard-packages-heading = Selecciona los paquetes wizard-packages-list-label = Paquetes para instalar o actualizar wizard-packages-row-checked = Marcado wizard-packages-row-unchecked = No marcado +wizard-packages-choice-shown = Nueva opción debajo de la lista: { $choice }. +wizard-packages-choice-hidden = Opción retirada: { $choice }. wizard-packages-tree-group-label = Paquetes wizard-additional-software-tree-group-label = Software adicional wizard-language-tree-group-label = Paquetes de idioma diff --git a/locales/fr-FR/rabbit.ftl b/locales/fr-FR/rabbit.ftl index 972a8ce..e47763b 100644 --- a/locales/fr-FR/rabbit.ftl +++ b/locales/fr-FR/rabbit.ftl @@ -114,6 +114,8 @@ wizard-packages-heading = Choisissez les paquets wizard-packages-list-label = Paquets à installer ou à mettre à jour wizard-packages-row-checked = Coché wizard-packages-row-unchecked = Non coché +wizard-packages-choice-shown = Nouveau choix sous la liste : { $choice }. +wizard-packages-choice-hidden = Choix retiré : { $choice }. wizard-packages-tree-group-label = Paquets wizard-additional-software-tree-group-label = Logiciels supplémentaires wizard-language-tree-group-label = Packs de langue diff --git a/locales/it-IT/rabbit.ftl b/locales/it-IT/rabbit.ftl index 5b0af72..9c16004 100644 --- a/locales/it-IT/rabbit.ftl +++ b/locales/it-IT/rabbit.ftl @@ -114,6 +114,8 @@ wizard-packages-heading = Scegli i pacchetti wizard-packages-list-label = Pacchetti da installare o aggiornare wizard-packages-row-checked = Selezionato wizard-packages-row-unchecked = Non selezionato +wizard-packages-choice-shown = Nuova scelta sotto l'elenco: { $choice }. +wizard-packages-choice-hidden = Scelta rimossa: { $choice }. wizard-packages-tree-group-label = Pacchetti wizard-additional-software-tree-group-label = Software aggiuntivo wizard-language-tree-group-label = Pacchetti lingua