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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,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
111 changes: 111 additions & 0 deletions crates/rabbit-ui-wxdragon/src/voiceover.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
//! 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;
/// `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;

// 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) {
let Ok(text) = CString::new(text) else {
return;
};
// 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,
(&NS_ACCESSIBILITY_PRIORITY_HIGH 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