From 6f1003462e2e167dc014aae90dd966d881436381 Mon Sep 17 00:00:00 2001 From: math65 Date: Fri, 25 Sep 2026 19:36:54 +0200 Subject: [PATCH 1/2] fix(ui): let Space tick package rows on macOS and have VoiceOver say so On macOS the packages list is a wxDataViewCtrl whose checkbox column only changes on a click. VoiceOver sends one for VO+Space while interacting with the list, but a plain Space reached no handler at all, so a keyboard user could not tick anything (#28). Space on the selected row now runs the same toggle as a click: the model's set_value body moved into a shared closure that both call, and the toggle column's state is read through one helper, which also replaces the four copies of the group aggregate in get_value. Disabled rows are left alone, as with a click. A toggle made from code gives VoiceOver nothing to read, because the row it is on is rebuilt behind it. A small voiceover module posts NSAccessibilityAnnouncementRequestedNotification with the row's new state ("Checked" / "Unchecked", in all five locales). It uses CoreFoundation's toll-free bridged types, so no Objective-C runtime calls or new crates. Fixes #28 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 5 + crates/rabbit-ui-wxdragon/src/lib.rs | 6 + crates/rabbit-ui-wxdragon/src/voiceover.rs | 111 ++++ crates/rabbit-ui-wxdragon/src/wx_app.rs | 620 +++++++++++---------- 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 + 9 files changed, 463 insertions(+), 289 deletions(-) create mode 100644 crates/rabbit-ui-wxdragon/src/voiceover.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 25bded1..e4ce6f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/crates/rabbit-ui-wxdragon/src/lib.rs b/crates/rabbit-ui-wxdragon/src/lib.rs index 262eef8..3092ab4 100644 --- a/crates/rabbit-ui-wxdragon/src/lib.rs +++ b/crates/rabbit-ui-wxdragon/src/lib.rs @@ -1,3 +1,5 @@ +#[cfg(all(feature = "gui", target_os = "macos"))] +mod voiceover; #[cfg(feature = "gui")] mod wx_app; @@ -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, @@ -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") diff --git a/crates/rabbit-ui-wxdragon/src/voiceover.rs b/crates/rabbit-ui-wxdragon/src/voiceover.rs new file mode 100644 index 0000000..85e5ca1 --- /dev/null +++ b/crates/rabbit-ui-wxdragon/src/voiceover.rs @@ -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); + } +} diff --git a/crates/rabbit-ui-wxdragon/src/wx_app.rs b/crates/rabbit-ui-wxdragon/src/wx_app.rs index 1d13ee7..8289232 100644 --- a/crates/rabbit-ui-wxdragon/src/wx_app.rs +++ b/crates/rabbit-ui-wxdragon/src/wx_app.rs @@ -5694,7 +5694,7 @@ fn build_packages_page( model.text.configuration_tree_group_label.clone(), ); let side_widgets: PackagesSideWidgetsCell = Rc::new(RefCell::new(None)); - let dv_model = build_packages_tree_model( + let (dv_model, toggle) = build_packages_tree_model( tree_data, Rc::clone(&package_rows), Rc::clone(&configuration_rows), @@ -5904,6 +5904,77 @@ fn build_packages_page( }); } + // Space ticks the selected row. wx on macOS only toggles a checkbox cell + // on a click, which VoiceOver sends for VO+Space while interacting with + // the list; a plain Space reached no handler at all, so the list could + // not be ticked from the keyboard (issue #28). + { + let package_items = Rc::clone(&package_items); + let model_text = model.clone(); + tree.on_key_down(move |event| { + let WindowEventData::Keyboard(kbd) = &event else { + return; + }; + if kbd.get_key_code() != Some(WXK_SPACE) + || kbd.cmd_down() + || kbd.control_down() + || kbd.alt_down() + || kbd.shift_down() + { + return; + } + let Some(node_ptr) = tree.get_selection().and_then(|item| item.get_id::()) else { + return; + }; + if node_ptr.is_null() { + return; + } + // Consume the key either way: the list has nothing else to do + // with Space, and letting it through beeps. + event.skip(false); + let Some(dv_model) = package_items.borrow().clone() else { + return; + }; + // Only hold the userdata borrow long enough to take its address. + // The toggle notifies the view, which reads the model back through + // that same RefCell, so it must run with the borrow released. + let Some(data_ptr) = dv_model + .with_userdata_mut::(|data| data as *const PackageTreeData) + else { + return; + }; + // SAFETY: both pointers address heap storage owned by the model's + // userdata, which is only replaced by a rebuild, and no rebuild + // can run inside this synchronous handler. node_ptr came from + // that same userdata (see the on_selection_changed handler). + let (data, node) = unsafe { (&*data_ptr, &*node_ptr) }; + let checked = packages_tree_toggle_state( + &data.rows.borrow(), + &data.configuration_rows.borrow(), + node.kind, + ); + if !toggle(data, node, !checked) { + 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 + }); + } + }); + } + page.set_sizer(sizer, true); ( tree, @@ -5935,6 +6006,47 @@ struct PackagesSideWidgets { #[cfg(not(target_os = "windows"))] type PackagesSideWidgetsCell = Rc>>; +/// Non-Windows: tick or untick one node of the packages tree, with every +/// side effect, and report whether anything changed. +#[cfg(not(target_os = "windows"))] +type PackagesTreeToggle = Rc bool>; + +/// 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 +/// renderer has no third state, so a partly ticked group reads unticked. +#[cfg(not(target_os = "windows"))] +fn packages_tree_toggle_state( + rows: &[crate::PackageRow], + configuration_rows: &[crate::ConfigurationRow], + kind: NodeKind, +) -> bool { + let category = match kind { + NodeKind::PackagesGroup => rabbit_core::package::PackageCategory::Core, + NodeKind::AdditionalSoftwareGroup => rabbit_core::package::PackageCategory::Additional, + NodeKind::LanguageGroup => rabbit_core::package::PackageCategory::Language, + NodeKind::Package(idx) => return rows.get(idx).is_some_and(|row| row.selected), + NodeKind::Configuration(idx) => { + return configuration_rows.get(idx).is_some_and(|row| row.selected); + } + NodeKind::ConfigurationGroup => { + // Already-applied rows are excluded, exactly as + // compute_configuration_group_tristate does on Windows: they are + // forced unselected and would otherwise pin the group to + // "unchecked" forever. + let mut changeable = configuration_rows + .iter() + .filter(|r| r.available_for_target && !r.already_applied) + .peekable(); + return changeable.peek().is_some() && changeable.all(|r| r.selected); + } + }; + let mut changeable = rows + .iter() + .filter(|r| r.category == category && r.available_for_target) + .peekable(); + changeable.peek().is_some() && changeable.all(|r| r.selected) +} + /// 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 @@ -5950,7 +6062,7 @@ fn build_packages_tree_model( side_widgets: PackagesSideWidgetsCell, can_install: Rc>, wizard_model: WizardModel, -) -> CustomDataViewTreeModel { +) -> (CustomDataViewTreeModel, PackagesTreeToggle) { type CompareFn = fn(&PackageTreeData, &Node, &Node, u32, bool) -> i32; let rows_for_get_value = Rc::clone(&rows); @@ -5964,7 +6076,199 @@ fn build_packages_tree_model( let model_cell_for_set_value = Rc::clone(&model_cell); let side_widgets_for_set_value = Rc::clone(&side_widgets); - CustomDataViewTreeModel::new( + // Everything a checkbox toggle does, shared by the model's `set_value` + // (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 { + match node.kind { + NodeKind::PackagesGroup + | NodeKind::AdditionalSoftwareGroup + | NodeKind::LanguageGroup => { + // Group toggle propagates to every available leaf in + // this group's category; unavailable rows stay + // untouched so the install plan never carries + // something we can't honor. + let category = match node.kind { + NodeKind::AdditionalSoftwareGroup => { + rabbit_core::package::PackageCategory::Additional + } + NodeKind::LanguageGroup => rabbit_core::package::PackageCategory::Language, + _ => rabbit_core::package::PackageCategory::Core, + }; + let mut rows = rows_for_set_value.borrow_mut(); + for row in rows.iter_mut() { + if row.category == category && row.available_for_target { + let _ = + apply_checkbox_state_to_package_row(&wizard_model, row, new_state); + } + } + } + NodeKind::Package(idx) => { + let mut rows = rows_for_set_value.borrow_mut(); + let Some(row) = rows.get_mut(idx) else { + return false; + }; + if !row.available_for_target { + return false; + } + let _ = apply_checkbox_state_to_package_row(&wizard_model, row, new_state); + } + NodeKind::ConfigurationGroup => { + let mut cfg_rows = configuration_rows_for_set_value.borrow_mut(); + for row in cfg_rows.iter_mut() { + if row.available_for_target && !row.already_applied { + row.selected = new_state; + } + } + } + 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; + }; + if !row.available_for_target || row.already_applied { + return false; + } + row.selected = new_state; + } + } + + let any_install_or_update = rows_for_set_value.borrow().iter().any(|row| { + row.available_for_target + && matches!(row.action, PlanActionKind::Install | PlanActionKind::Update) + }); + can_install.set(any_install_or_update); + + // Recompute configuration row availability whenever a + // package toggle could have flipped a dependency state. + let recomputed_configuration = matches!( + node.kind, + NodeKind::PackagesGroup + | NodeKind::AdditionalSoftwareGroup + | NodeKind::LanguageGroup + | NodeKind::Package(_) + ); + if recomputed_configuration { + if let Ok(localizer) = + crate::localizer_from_options(&wizard_model_for_recompute.bootstrap_options) + { + let package_rows_snapshot = rows_for_set_value.borrow(); + let mut cfg_rows = configuration_rows_for_recompute.borrow_mut(); + // None for the resource-path argument: a package + // toggle can't change `reapack.ini`, so preserve + // each row's existing `already_applied` flag. + crate::recompute_configuration_row_availability( + &localizer, + &package_rows_snapshot, + None, + &mut cfg_rows, + ); + } + + // Same tail as the Windows `refresh_after_packages_toggle`: + // ticking a package can change which OSARA keymap note + // applies, whether the Spanish variant picker is usable, + // and — because it owns the dropdown's *contents*, not + // just its enabled state — what the REAPER-language + // dropdown offers. Without this, toggling a row that is + // already selected (Space, or clicking its checkbox) + // leaves all three stale, because `on_selection_changed` + // never fires. + if let Some(widgets) = *side_widgets_for_set_value.borrow() { + let rows = rows_for_set_value.borrow(); + sync_osara_keymap_widgets( + &wizard_model_for_recompute, + &rows, + &widgets.osara_checkbox, + &widgets.osara_note, + ); + sync_spanish_variant_widget(&rows, &widgets.spanish_choice); + sync_reaper_language_widget(&rows, &widgets.language_choice); + } + } + + // Push the cell changes back into the view. SetValue's + // true return only auto-refreshes the (item, col) we set; + // we also need to refresh the row's label cell (the action + // text flips Install/Update/Keep) and the parent group's + // aggregate cell. + if let Some(model) = model_cell_for_set_value.borrow().as_ref() { + match node.kind { + NodeKind::PackagesGroup => { + let parent_ptr = data.packages_group_ptr(); + let leaf_ptrs = data + .package_ptrs_in_category(rabbit_core::package::PackageCategory::Core); + model.items_changed(&leaf_ptrs); + model.item_value_changed(parent_ptr, PACKAGE_COL_TOGGLE); + } + NodeKind::AdditionalSoftwareGroup => { + let parent_ptr = data.additional_software_group_ptr(); + let leaf_ptrs = data.package_ptrs_in_category( + rabbit_core::package::PackageCategory::Additional, + ); + model.items_changed(&leaf_ptrs); + model.item_value_changed(parent_ptr, PACKAGE_COL_TOGGLE); + } + NodeKind::LanguageGroup => { + let parent_ptr = data.language_group_ptr(); + let leaf_ptrs = data.package_ptrs_in_category( + rabbit_core::package::PackageCategory::Language, + ); + model.items_changed(&leaf_ptrs); + model.item_value_changed(parent_ptr, PACKAGE_COL_TOGGLE); + } + NodeKind::Package(idx) => { + let leaf_ptr = data.package_ptr(idx); + model.item_value_changed(leaf_ptr, PACKAGE_COL_LABEL); + // Refresh the aggregate cell of whichever group + // this package hangs under. + let category = data + .rows + .borrow() + .get(idx) + .map(|r| r.category) + .unwrap_or_default(); + let parent_ptr = match category { + rabbit_core::package::PackageCategory::Additional => { + data.additional_software_group_ptr() + } + rabbit_core::package::PackageCategory::Language => { + data.language_group_ptr() + } + rabbit_core::package::PackageCategory::Core => { + data.packages_group_ptr() + } + }; + model.item_value_changed(parent_ptr, PACKAGE_COL_TOGGLE); + } + NodeKind::ConfigurationGroup => { + let parent_ptr = data.configuration_group_ptr(); + let leaf_ptrs = data.all_configuration_ptrs(); + model.items_changed(&leaf_ptrs); + model.item_value_changed(parent_ptr, PACKAGE_COL_TOGGLE); + } + NodeKind::Configuration(idx) => { + let leaf_ptr = data.configuration_ptr(idx); + model.item_value_changed(leaf_ptr, PACKAGE_COL_LABEL); + model + .item_value_changed(data.configuration_group_ptr(), PACKAGE_COL_TOGGLE); + } + } + + if recomputed_configuration { + let cfg_leaf_ptrs = data.all_configuration_ptrs(); + model.items_changed(&cfg_leaf_ptrs); + model.item_value_changed(data.configuration_group_ptr(), PACKAGE_COL_TOGGLE); + } + } + + true + }, + ); + let toggle_for_model = Rc::clone(&toggle); + + let dv_model = CustomDataViewTreeModel::new( data, // get_parent |data: &PackageTreeData, item: Option<&Node>| -> Option<*mut Node> { @@ -6058,102 +6362,32 @@ fn build_packages_tree_model( let Some(node) = item else { return Variant::from_string(""); }; + if col == PACKAGE_COL_TOGGLE { + return Variant::from_bool(packages_tree_toggle_state( + &rows_for_get_value.borrow(), + &configuration_rows_for_get_value.borrow(), + node.kind, + )); + } match node.kind { - NodeKind::PackagesGroup => { - if col == PACKAGE_COL_TOGGLE { - // Aggregate state: true only if every available row - // in this group's category is selected. The standard - // toggle renderer can't show a tristate, so a - // partially-selected group reads as unchecked. - let rows = rows_for_get_value.borrow(); - let mut any_available = false; - let all_checked = rows - .iter() - .filter(|r| { - r.category == rabbit_core::package::PackageCategory::Core - && r.available_for_target - }) - .inspect(|_| any_available = true) - .all(|r| r.selected); - Variant::from_bool(any_available && all_checked) - } else { - Variant::from_string(&data.packages_group_label) - } - } + NodeKind::PackagesGroup => Variant::from_string(&data.packages_group_label), NodeKind::AdditionalSoftwareGroup => { - if col == PACKAGE_COL_TOGGLE { - let rows = rows_for_get_value.borrow(); - let mut any_available = false; - let all_checked = rows - .iter() - .filter(|r| { - r.category == rabbit_core::package::PackageCategory::Additional - && r.available_for_target - }) - .inspect(|_| any_available = true) - .all(|r| r.selected); - Variant::from_bool(any_available && all_checked) - } else { - Variant::from_string(&data.additional_software_group_label) - } - } - NodeKind::LanguageGroup => { - if col == PACKAGE_COL_TOGGLE { - let rows = rows_for_get_value.borrow(); - let mut any_available = false; - let all_checked = rows - .iter() - .filter(|r| { - r.category == rabbit_core::package::PackageCategory::Language - && r.available_for_target - }) - .inspect(|_| any_available = true) - .all(|r| r.selected); - Variant::from_bool(any_available && all_checked) - } else { - Variant::from_string(&data.language_group_label) - } - } - NodeKind::Package(idx) => { - let rows = rows_for_get_value.borrow(); - let Some(row) = rows.get(idx) else { - return Variant::from_string(""); - }; - if col == PACKAGE_COL_TOGGLE { - Variant::from_bool(row.selected) - } else { - Variant::from_string(&row.summary) - } + Variant::from_string(&data.additional_software_group_label) } + NodeKind::LanguageGroup => Variant::from_string(&data.language_group_label), NodeKind::ConfigurationGroup => { - if col == PACKAGE_COL_TOGGLE { - let cfg_rows = configuration_rows_for_get_value.borrow(); - let mut any_available = false; - let all_checked = cfg_rows - .iter() - // already-applied rows are excluded, exactly as - // compute_configuration_group_tristate does on - // Windows: they are forced unselected and would - // otherwise pin the group to "unchecked" forever. - .filter(|r| r.available_for_target && !r.already_applied) - .inspect(|_| any_available = true) - .all(|r| r.selected); - Variant::from_bool(any_available && all_checked) - } else { - Variant::from_string(&data.configuration_group_label) - } - } - NodeKind::Configuration(idx) => { - let cfg_rows = configuration_rows_for_get_value.borrow(); - let Some(row) = cfg_rows.get(idx) else { - return Variant::from_string(""); - }; - if col == PACKAGE_COL_TOGGLE { - Variant::from_bool(row.selected) - } else { - Variant::from_string(&row.summary) - } + Variant::from_string(&data.configuration_group_label) } + NodeKind::Package(idx) => rows_for_get_value + .borrow() + .get(idx) + .map(|row| Variant::from_string(&row.summary)) + .unwrap_or_else(|| Variant::from_string("")), + NodeKind::Configuration(idx) => configuration_rows_for_get_value + .borrow() + .get(idx) + .map(|row| Variant::from_string(&row.summary)) + .unwrap_or_else(|| Variant::from_string("")), } }, // set_value @@ -6165,200 +6399,7 @@ fn build_packages_tree_model( let Some(node) = item else { return false; }; - let new_state = var.get_bool().unwrap_or(false); - - match node.kind { - NodeKind::PackagesGroup - | NodeKind::AdditionalSoftwareGroup - | NodeKind::LanguageGroup => { - // Group toggle propagates to every available leaf in - // this group's category; unavailable rows stay - // untouched so the install plan never carries - // something we can't honor. - let category = match node.kind { - NodeKind::AdditionalSoftwareGroup => { - rabbit_core::package::PackageCategory::Additional - } - NodeKind::LanguageGroup => { - rabbit_core::package::PackageCategory::Language - } - _ => rabbit_core::package::PackageCategory::Core, - }; - let mut rows = rows_for_set_value.borrow_mut(); - for row in rows.iter_mut() { - if row.category == category && row.available_for_target { - let _ = apply_checkbox_state_to_package_row( - &wizard_model, - row, - new_state, - ); - } - } - } - NodeKind::Package(idx) => { - let mut rows = rows_for_set_value.borrow_mut(); - let Some(row) = rows.get_mut(idx) else { - return false; - }; - if !row.available_for_target { - return false; - } - let _ = apply_checkbox_state_to_package_row(&wizard_model, row, new_state); - } - NodeKind::ConfigurationGroup => { - let mut cfg_rows = configuration_rows_for_set_value.borrow_mut(); - for row in cfg_rows.iter_mut() { - if row.available_for_target && !row.already_applied { - row.selected = new_state; - } - } - } - 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; - }; - if !row.available_for_target || row.already_applied { - return false; - } - row.selected = new_state; - } - } - - let any_install_or_update = rows_for_set_value.borrow().iter().any(|row| { - row.available_for_target - && matches!(row.action, PlanActionKind::Install | PlanActionKind::Update) - }); - can_install.set(any_install_or_update); - - // Recompute configuration row availability whenever a - // package toggle could have flipped a dependency state. - let recomputed_configuration = matches!( - node.kind, - NodeKind::PackagesGroup - | NodeKind::AdditionalSoftwareGroup - | NodeKind::LanguageGroup - | NodeKind::Package(_) - ); - if recomputed_configuration { - if let Ok(localizer) = - crate::localizer_from_options(&wizard_model_for_recompute.bootstrap_options) - { - let package_rows_snapshot = rows_for_set_value.borrow(); - let mut cfg_rows = configuration_rows_for_recompute.borrow_mut(); - // None for the resource-path argument: a package - // toggle can't change `reapack.ini`, so preserve - // each row's existing `already_applied` flag. - crate::recompute_configuration_row_availability( - &localizer, - &package_rows_snapshot, - None, - &mut cfg_rows, - ); - } - - // Same tail as the Windows `refresh_after_packages_toggle`: - // ticking a package can change which OSARA keymap note - // applies, whether the Spanish variant picker is usable, - // and — because it owns the dropdown's *contents*, not - // just its enabled state — what the REAPER-language - // dropdown offers. Without this, toggling a row that is - // already selected (Space, or clicking its checkbox) - // leaves all three stale, because `on_selection_changed` - // never fires. - if let Some(widgets) = *side_widgets_for_set_value.borrow() { - let rows = rows_for_set_value.borrow(); - sync_osara_keymap_widgets( - &wizard_model_for_recompute, - &rows, - &widgets.osara_checkbox, - &widgets.osara_note, - ); - sync_spanish_variant_widget(&rows, &widgets.spanish_choice); - sync_reaper_language_widget(&rows, &widgets.language_choice); - } - } - - // Push the cell changes back into the view. SetValue's - // true return only auto-refreshes the (item, col) we set; - // we also need to refresh the row's label cell (the action - // text flips Install/Update/Keep) and the parent group's - // aggregate cell. - if let Some(model) = model_cell_for_set_value.borrow().as_ref() { - match node.kind { - NodeKind::PackagesGroup => { - let parent_ptr = data.packages_group_ptr(); - let leaf_ptrs = data.package_ptrs_in_category( - rabbit_core::package::PackageCategory::Core, - ); - model.items_changed(&leaf_ptrs); - model.item_value_changed(parent_ptr, PACKAGE_COL_TOGGLE); - } - NodeKind::AdditionalSoftwareGroup => { - let parent_ptr = data.additional_software_group_ptr(); - let leaf_ptrs = data.package_ptrs_in_category( - rabbit_core::package::PackageCategory::Additional, - ); - model.items_changed(&leaf_ptrs); - model.item_value_changed(parent_ptr, PACKAGE_COL_TOGGLE); - } - NodeKind::LanguageGroup => { - let parent_ptr = data.language_group_ptr(); - let leaf_ptrs = data.package_ptrs_in_category( - rabbit_core::package::PackageCategory::Language, - ); - model.items_changed(&leaf_ptrs); - model.item_value_changed(parent_ptr, PACKAGE_COL_TOGGLE); - } - NodeKind::Package(idx) => { - let leaf_ptr = data.package_ptr(idx); - model.item_value_changed(leaf_ptr, PACKAGE_COL_LABEL); - // Refresh the aggregate cell of whichever group - // this package hangs under. - let category = data - .rows - .borrow() - .get(idx) - .map(|r| r.category) - .unwrap_or_default(); - let parent_ptr = match category { - rabbit_core::package::PackageCategory::Additional => { - data.additional_software_group_ptr() - } - rabbit_core::package::PackageCategory::Language => { - data.language_group_ptr() - } - rabbit_core::package::PackageCategory::Core => { - data.packages_group_ptr() - } - }; - model.item_value_changed(parent_ptr, PACKAGE_COL_TOGGLE); - } - NodeKind::ConfigurationGroup => { - let parent_ptr = data.configuration_group_ptr(); - let leaf_ptrs = data.all_configuration_ptrs(); - model.items_changed(&leaf_ptrs); - model.item_value_changed(parent_ptr, PACKAGE_COL_TOGGLE); - } - NodeKind::Configuration(idx) => { - let leaf_ptr = data.configuration_ptr(idx); - model.item_value_changed(leaf_ptr, PACKAGE_COL_LABEL); - model.item_value_changed( - data.configuration_group_ptr(), - PACKAGE_COL_TOGGLE, - ); - } - } - - if recomputed_configuration { - let cfg_leaf_ptrs = data.all_configuration_ptrs(); - model.items_changed(&cfg_leaf_ptrs); - model - .item_value_changed(data.configuration_group_ptr(), PACKAGE_COL_TOGGLE); - } - } - - true + toggle_for_model(data, node, var.get_bool().unwrap_or(false)) }, ), // is_enabled — gray out the checkbox + label of unavailable rows. @@ -6389,7 +6430,8 @@ fn build_packages_tree_model( // because the closure-based `Option` pattern doesn't infer // without it. None::, - ) + ); + (dv_model, toggle) } /// Non-Windows: expand both synthetic group nodes ("Packages" and diff --git a/locales/de-DE/rabbit.ftl b/locales/de-DE/rabbit.ftl index b4959f9..3b30da1 100644 --- a/locales/de-DE/rabbit.ftl +++ b/locales/de-DE/rabbit.ftl @@ -112,6 +112,8 @@ wizard-target-details = REAPER-Anwendung: { $app_path } 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-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 b297957..619663f 100644 --- a/locales/en-US/rabbit.ftl +++ b/locales/en-US/rabbit.ftl @@ -112,6 +112,8 @@ wizard-target-details = REAPER installation path: { $app_path } 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-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 544493b..c1ae2d6 100644 --- a/locales/es-ES/rabbit.ftl +++ b/locales/es-ES/rabbit.ftl @@ -112,6 +112,8 @@ wizard-target-details = Carpeta de instalación de Reaper: { $app_path } 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-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 aab0467..972a8ce 100644 --- a/locales/fr-FR/rabbit.ftl +++ b/locales/fr-FR/rabbit.ftl @@ -112,6 +112,8 @@ wizard-target-details = Chemin d'installation de REAPER : { $app_path } 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-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 d8afba5..5b0af72 100644 --- a/locales/it-IT/rabbit.ftl +++ b/locales/it-IT/rabbit.ftl @@ -112,6 +112,8 @@ wizard-target-details = Percorso di installazione di REAPER: { $app_path } 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-tree-group-label = Pacchetti wizard-additional-software-tree-group-label = Software aggiuntivo wizard-language-tree-group-label = Pacchetti lingua From 8d851ee0909141e286de2397df8ece94612da704 Mon Sep 17 00:00:00 2001 From: math65 Date: Fri, 25 Sep 2026 19:44:47 +0200 Subject: [PATCH 2/2] 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