Skip to content
Open
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
9 changes: 9 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 Expand Up @@ -150,6 +154,11 @@ from this file and posts it as the GitHub release body.
the ReaPack ones — still name it, since there it is the truth.
(Reported by Scott Chesworth from Q&A.)

- On macOS, **Space** now ticks and unticks rows in the **Packages** list,
and VoiceOver says whether the row is now checked. Before, only VO+Space
while interacting with the list changed a checkbox, so the list could not
be used from the keyboard alone
([#28](https://github.com/Timtam/rabbit/issues/28)).
- The package list on the **Packages** page could go missing entirely
unless the window was maximized. The page had grown — the OSARA key-map
note, then the Spanish-variant and REAPER-language dropdowns — until the
Expand Down
6 changes: 6 additions & 0 deletions crates/rabbit-ui-wxdragon/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
#[cfg(all(feature = "gui", target_os = "macos"))]
mod voiceover;
#[cfg(feature = "gui")]
mod wx_app;

Expand Down Expand Up @@ -119,6 +121,8 @@ pub struct WizardText {
pub target_custom_portable_note: String,
pub packages_heading: String,
pub packages_list_label: String,
pub packages_row_checked: String,
pub packages_row_unchecked: String,
pub packages_tree_group_label: String,
pub additional_software_tree_group_label: String,
pub language_tree_group_label: String,
Expand Down Expand Up @@ -783,6 +787,8 @@ fn wizard_text(localizer: &Localizer) -> WizardText {
target_custom_portable_note: localizer.text("wizard-target-custom-portable-note").value,
packages_heading: localizer.text("wizard-packages-heading").value,
packages_list_label: localizer.text("wizard-packages-list-label").value,
packages_row_checked: localizer.text("wizard-packages-row-checked").value,
packages_row_unchecked: localizer.text("wizard-packages-row-unchecked").value,
packages_tree_group_label: localizer.text("wizard-packages-tree-group-label").value,
additional_software_tree_group_label: localizer
.text("wizard-additional-software-tree-group-label")
Expand Down
129 changes: 129 additions & 0 deletions crates/rabbit-ui-wxdragon/src/voiceover.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
//! Ask VoiceOver to speak a short message.
//!
//! A control changed from code rather than by the user tells VoiceOver
//! nothing, so it stays silent where a click would have been read back. The
//! macOS packages list is the case in point: Space ticks a row through the
//! model, and the checkbox VoiceOver would announce is rebuilt behind it.
//!
//! This posts `NSAccessibilityAnnouncementRequestedNotification`, the AppKit
//! call for exactly that. The dictionary is built with CoreFoundation, whose
//! strings, numbers and dictionaries are toll-free bridged to their
//! Foundation counterparts, so no Objective-C message sending is needed.

use std::ffi::{CString, c_char, c_void};

type CfRef = *const c_void;

/// `CFDictionaryKeyCallBacks` / `CFDictionaryValueCallBacks`: only ever
/// passed by address, so their layout doesn't matter here.
#[repr(C)]
struct CfCallBacks {
_opaque: [u8; 0],
}

const CF_STRING_ENCODING_UTF8: u32 = 0x0800_0100;
const CF_NUMBER_CF_INDEX_TYPE: isize = 14;

/// 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.
#[link(name = "AppKit", kind = "framework")]
unsafe extern "C" {
static NSApp: CfRef;
static NSAccessibilityAnnouncementRequestedNotification: CfRef;
static NSAccessibilityAnnouncementKey: CfRef;
static NSAccessibilityPriorityKey: CfRef;
fn NSAccessibilityPostNotificationWithUserInfo(
element: CfRef,
notification: CfRef,
user_info: CfRef,
);
}

#[link(name = "CoreFoundation", kind = "framework")]
unsafe extern "C" {
static kCFTypeDictionaryKeyCallBacks: CfCallBacks;
static kCFTypeDictionaryValueCallBacks: CfCallBacks;
fn CFStringCreateWithCString(alloc: CfRef, text: *const c_char, encoding: u32) -> CfRef;
fn CFNumberCreate(alloc: CfRef, number_type: isize, value: *const c_void) -> CfRef;
fn CFDictionaryCreate(
alloc: CfRef,
keys: *const CfRef,
values: *const CfRef,
count: isize,
key_callbacks: *const CfCallBacks,
value_callbacks: *const CfCallBacks,
) -> CfRef;
fn CFRelease(object: CfRef);
}

/// Have VoiceOver speak `text` now. Does nothing when VoiceOver is off:
/// macOS drops the notification.
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.
unsafe {
if NSApp.is_null() {
return;
}
let message =
CFStringCreateWithCString(std::ptr::null(), text.as_ptr(), CF_STRING_ENCODING_UTF8);
let priority = CFNumberCreate(
std::ptr::null(),
CF_NUMBER_CF_INDEX_TYPE,
(&priority_value as *const isize).cast(),
);
if message.is_null() || priority.is_null() {
for object in [message, priority] {
if !object.is_null() {
CFRelease(object);
}
}
return;
}
let keys = [NSAccessibilityAnnouncementKey, NSAccessibilityPriorityKey];
let values = [message, priority];
let user_info = CFDictionaryCreate(
std::ptr::null(),
keys.as_ptr(),
values.as_ptr(),
keys.len() as isize,
&kCFTypeDictionaryKeyCallBacks,
&kCFTypeDictionaryValueCallBacks,
);
if !user_info.is_null() {
NSAccessibilityPostNotificationWithUserInfo(
NSApp,
NSAccessibilityAnnouncementRequestedNotification,
user_info,
);
CFRelease(user_info);
}
CFRelease(message);
CFRelease(priority);
}
}
Loading
Loading