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