diff --git a/CHANGELOG.md b/CHANGELOG.md index c1b72af..db03166 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/globals.rs b/crates/rabbit-ui-wxdragon/src/wx_app/globals.rs index 9a43796..4d33a5f 100644 --- a/crates/rabbit-ui-wxdragon/src/wx_app/globals.rs +++ b/crates/rabbit-ui-wxdragon/src/wx_app/globals.rs @@ -49,6 +49,27 @@ pub(crate) 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")] +pub(crate) use crate::voiceover::Priority as AnnouncePriority; + +#[cfg(not(target_os = "macos"))] +#[derive(Clone, Copy)] +pub(crate) enum AnnouncePriority { + Interrupt, + Polite, +} + +pub(crate) fn announce(text: &str, priority: AnnouncePriority) { + #[cfg(target_os = "macos")] + crate::voiceover::announce(text, priority); + #[cfg(not(target_os = "macos"))] + let _ = (text, priority); +} + pub(crate) fn install_ui_frame(frame: Frame) { UI_FRAME.with(|cell| { *cell.borrow_mut() = Some(frame); diff --git a/crates/rabbit-ui-wxdragon/src/wx_app/packages_page/dataview.rs b/crates/rabbit-ui-wxdragon/src/wx_app/packages_page/dataview.rs index f9c5b16..c8153ed 100644 --- a/crates/rabbit-ui-wxdragon/src/wx_app/packages_page/dataview.rs +++ b/crates/rabbit-ui-wxdragon/src/wx_app/packages_page/dataview.rs @@ -19,6 +19,7 @@ use wxdragon::prelude::*; use super::{PackagesStateCell, PackagesView, WXK_SPACE}; +use crate::wx_app::globals::{AnnouncePriority, announce, with_ui_localizer}; use crate::wx_app::pages::{WizardPage, add_heading, add_label}; use crate::wx_app::widgets::{ REAPER_LANGUAGE_LABEL_NAME, SPANISH_VARIANT_LABEL_NAME, WizardWidgets, package_details, @@ -522,25 +523,28 @@ pub(crate) 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); }); } @@ -574,8 +578,10 @@ pub(crate) struct PackagesSideWidgets { pub(crate) type PackagesSideWidgetsCell = Rc>>; /// Non-Windows: tick or untick one node of the packages tree, with every -/// side effect, and report whether anything changed. -type PackagesTreeToggle = Rc bool>; +/// 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. +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 @@ -612,6 +618,21 @@ fn packages_tree_toggle_state( changeable.peek().is_some() && changeable.all(|r| r.selected) } +/// 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 +} + /// Non-Windows: build the `CustomDataViewTreeModel` that backs the packages /// tree. The closures capture clones of `package_rows`, `package_items` /// (the self-referential model handle cell), `can_install`, and the wizard @@ -644,7 +665,8 @@ pub(crate) 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 @@ -670,11 +692,9 @@ pub(crate) 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); } @@ -688,11 +708,9 @@ pub(crate) 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; } @@ -741,6 +759,10 @@ pub(crate) 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, @@ -749,6 +771,21 @@ pub(crate) 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())); + } + } } } @@ -827,7 +864,7 @@ pub(crate) fn build_packages_tree_model( } } - true + Some(choice_notes) }, ); let toggle_for_model = Rc::clone(&toggle); @@ -963,7 +1000,18 @@ pub(crate) 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. diff --git a/crates/rabbit-ui-wxdragon/src/wx_app/progress_ui.rs b/crates/rabbit-ui-wxdragon/src/wx_app/progress_ui.rs index 7793082..5e51413 100644 --- a/crates/rabbit-ui-wxdragon/src/wx_app/progress_ui.rs +++ b/crates/rabbit-ui-wxdragon/src/wx_app/progress_ui.rs @@ -7,7 +7,7 @@ use std::sync::{Arc, Mutex}; use rabbit_core::localization::Localizer; use rabbit_core::progress::ProgressEvent; -use crate::wx_app::globals::with_ui_localizer; +use crate::wx_app::globals::{AnnouncePriority, announce, with_ui_localizer}; use crate::wx_app::widgets::WizardWidgets; /// State carried across [`ProgressEvent`] notifications during a wizard @@ -322,10 +322,20 @@ pub(crate) 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 diff --git a/crates/rabbit-ui-wxdragon/src/wx_app/version_check.rs b/crates/rabbit-ui-wxdragon/src/wx_app/version_check.rs index 1cc83bc..79fcea5 100644 --- a/crates/rabbit-ui-wxdragon/src/wx_app/version_check.rs +++ b/crates/rabbit-ui-wxdragon/src/wx_app/version_check.rs @@ -25,7 +25,7 @@ use wxdragon::widgets::SimpleBook; use wxdragon::prelude::*; use crate::wx_app::expert_mode::RUN_AVAILABLE; -use crate::wx_app::globals::VERSION_CHECK_GENERATION; +use crate::wx_app::globals::{AnnouncePriority, VERSION_CHECK_GENERATION, announce}; use crate::wx_app::PACKAGES_STEP; use crate::wx_app::globals::{ @@ -267,6 +267,9 @@ pub(crate) fn render_version_check_errors(ui: &VersionCheckUi, errors: &[(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); }); } diff --git a/locales/de-DE/rabbit.ftl b/locales/de-DE/rabbit.ftl index b52e4a8..db15d13 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 e90ccd3..85c4cc9 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 1f71ca1..57f24bd 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 18ae909..5c5b89a 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 474832f..151eade 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