Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
28 changes: 23 additions & 5 deletions crates/rabbit-ui-wxdragon/src/voiceover.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand All @@ -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] {
Expand Down
21 changes: 21 additions & 0 deletions crates/rabbit-ui-wxdragon/src/wx_app/globals.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,27 @@ pub(crate) fn with_ui_localizer<F: FnOnce(&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);
Expand Down
104 changes: 76 additions & 28 deletions crates/rabbit-ui-wxdragon/src/wx_app/packages_page/dataview.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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::<Vec<_>>()
.join(". ");
announce(&message, AnnouncePriority::Interrupt);
});
}

Expand Down Expand Up @@ -574,8 +578,10 @@ pub(crate) struct PackagesSideWidgets {
pub(crate) type PackagesSideWidgetsCell = Rc<RefCell<Option<PackagesSideWidgets>>>;

/// Non-Windows: tick or untick one node of the packages tree, with every
/// side effect, and report whether anything changed.
type PackagesTreeToggle = Rc<dyn Fn(&PackageTreeData, &Node, bool) -> 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<dyn Fn(&PackageTreeData, &Node, bool) -> Option<Vec<String>>>;

/// 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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<Vec<String>> {
let mut choice_notes = Vec::new();
match node.kind {
NodeKind::PackagesGroup
| NodeKind::AdditionalSoftwareGroup
Expand All @@ -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);
}
Expand All @@ -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;
}
Expand Down Expand Up @@ -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,
Expand All @@ -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()));
}
}
}
}

Expand Down Expand Up @@ -827,7 +864,7 @@ pub(crate) fn build_packages_tree_model(
}
}

true
Some(choice_notes)
},
);
let toggle_for_model = Rc::clone(&toggle);
Expand Down Expand Up @@ -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.
Expand Down
12 changes: 11 additions & 1 deletion crates/rabbit-ui-wxdragon/src/wx_app/progress_ui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion crates/rabbit-ui-wxdragon/src/wx_app/version_check.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::{
Expand Down Expand Up @@ -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);
});
}

Expand Down
2 changes: 2 additions & 0 deletions locales/de-DE/rabbit.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions locales/en-US/rabbit.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions locales/es-ES/rabbit.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions locales/fr-FR/rabbit.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions locales/it-IT/rabbit.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading