From 1ac5f41d694b2d393efaa89ea49897f59f14c279 Mon Sep 17 00:00:00 2001 From: lashman Date: Fri, 27 Feb 2026 10:03:28 +0200 Subject: [PATCH] Add WCAG accessible labels to library view buttons, list box, and search bar --- src/ui/library_view.rs | 329 ++++++++++++++++++++++++++++------------- 1 file changed, 223 insertions(+), 106 deletions(-) diff --git a/src/ui/library_view.rs b/src/ui/library_view.rs index acd2948..7175be9 100644 --- a/src/ui/library_view.rs +++ b/src/ui/library_view.rs @@ -1,8 +1,12 @@ use adw::prelude::*; +use gtk::accessible::Property as AccessibleProperty; use std::cell::{Cell, RefCell}; use std::rc::Rc; use crate::core::database::AppImageRecord; +use crate::core::fuse::FuseStatus; +use crate::core::wayland::WaylandStatus; +use crate::i18n::{i18n, i18n_f, ni18n_f}; use super::app_card; use super::widgets; @@ -28,9 +32,10 @@ pub struct LibraryView { list_box: gtk::ListBox, search_bar: gtk::SearchBar, search_entry: gtk::SearchEntry, - subtitle_label: gtk::Label, + title_widget: adw::WindowTitle, view_mode: Rc>, - view_toggle: gtk::ToggleButton, + grid_button: gtk::ToggleButton, + list_button: gtk::ToggleButton, records: Rc>>, search_empty_page: adw::StatusPage, } @@ -38,32 +43,56 @@ pub struct LibraryView { impl LibraryView { pub fn new(menu: >k::gio::Menu) -> Self { let records: Rc>> = Rc::new(RefCell::new(Vec::new())); - let view_mode = Rc::new(Cell::new(ViewMode::Grid)); + + // Read initial view mode from GSettings + let settings = gtk::gio::Settings::new(crate::config::APP_ID); + let saved_view = settings.string("view-mode"); + let initial_mode = if saved_view.as_str() == "list" { + ViewMode::List + } else { + ViewMode::Grid + }; + let view_mode = Rc::new(Cell::new(initial_mode)); // --- Header bar --- let menu_button = gtk::MenuButton::builder() .icon_name("open-menu-symbolic") .menu_model(menu) - .tooltip_text("Menu") + .tooltip_text(&i18n("Menu")) .primary(true) .build(); menu_button.add_css_class("flat"); + menu_button.update_property(&[AccessibleProperty::Label("Main menu")]); let search_button = gtk::ToggleButton::builder() .icon_name("system-search-symbolic") - .tooltip_text("Search") + .tooltip_text(&i18n("Search")) .build(); search_button.add_css_class("flat"); + search_button.update_property(&[AccessibleProperty::Label("Toggle search")]); - let view_toggle = gtk::ToggleButton::builder() + // Linked view toggle (segmented control) + let grid_button = gtk::ToggleButton::builder() + .icon_name("view-grid-symbolic") + .tooltip_text(&i18n("Grid view")) + .active(initial_mode == ViewMode::Grid) + .build(); + grid_button.update_property(&[AccessibleProperty::Label("Switch to grid view")]); + + let list_button = gtk::ToggleButton::builder() .icon_name("view-list-symbolic") - .tooltip_text("Toggle list view") + .tooltip_text(&i18n("List view")) + .active(initial_mode == ViewMode::List) + .group(&grid_button) .build(); - view_toggle.add_css_class("flat"); + list_button.update_property(&[AccessibleProperty::Label("Switch to list view")]); - let subtitle_label = gtk::Label::builder() - .css_classes(["dim-label"]) + let view_toggle_box = gtk::Box::builder() + .orientation(gtk::Orientation::Horizontal) .build(); + view_toggle_box.add_css_class("linked"); + view_toggle_box.append(&grid_button); + view_toggle_box.append(&list_button); let title_widget = adw::WindowTitle::builder() .title("Driftwood") @@ -74,11 +103,11 @@ impl LibraryView { .build(); header_bar.pack_end(&menu_button); header_bar.pack_end(&search_button); - header_bar.pack_end(&view_toggle); + header_bar.pack_end(&view_toggle_box); // --- Search bar --- let search_entry = gtk::SearchEntry::builder() - .placeholder_text("Search AppImages...") + .placeholder_text(&i18n("Search AppImages...")) .hexpand(true) .build(); @@ -92,6 +121,7 @@ impl LibraryView { .search_mode_enabled(false) .build(); search_bar.connect_entry(&search_entry); + search_bar.set_accessible_role(gtk::AccessibleRole::Search); // Bind search button to search bar search_button @@ -107,10 +137,9 @@ impl LibraryView { // Loading state let loading_page = adw::StatusPage::builder() - .title("Scanning for AppImages...") + .title(&i18n("Scanning for AppImages...")) .build(); - let spinner = gtk::Spinner::builder() - .spinning(true) + let spinner = adw::Spinner::builder() .width_request(32) .height_request(32) .halign(gtk::Align::Center) @@ -126,27 +155,31 @@ impl LibraryView { .build(); let scan_now_btn = gtk::Button::builder() - .label("Scan Now") + .label(&i18n("Scan Now")) .build(); scan_now_btn.add_css_class("suggested-action"); scan_now_btn.add_css_class("pill"); + scan_now_btn.update_property(&[AccessibleProperty::Label("Scan for AppImages")]); let prefs_btn = gtk::Button::builder() - .label("Preferences") + .label(&i18n("Preferences")) .build(); prefs_btn.add_css_class("flat"); prefs_btn.add_css_class("pill"); + prefs_btn.update_property(&[AccessibleProperty::Label("Open preferences")]); empty_button_box.append(&scan_now_btn); empty_button_box.append(&prefs_btn); let empty_page = adw::StatusPage::builder() - .icon_name("folder-saved-search-symbolic") - .title("No AppImages Found") - .description( - "Driftwood looks for AppImages in ~/Applications and ~/Downloads.\n\ - Drop an AppImage file here, or add more scan locations in Preferences.", - ) + .icon_name("application-x-executable-symbolic") + .title(&i18n("No AppImages Found")) + .description(&i18n( + "Driftwood manages your AppImage collection - scanning for apps, \ + integrating them into your desktop, and keeping them up to date.\n\n\ + Add AppImages to ~/Applications or ~/Downloads, or configure \ + custom scan locations in Preferences.", + )) .child(&empty_button_box) .build(); stack.add_named(&empty_page, Some("empty")); @@ -154,8 +187,8 @@ impl LibraryView { // Search empty state let search_empty_page = adw::StatusPage::builder() .icon_name("system-search-symbolic") - .title("No Results") - .description("No AppImages match your search. Try a different search term.") + .title(&i18n("No Results")) + .description(&i18n("No AppImages match your search. Try a different search term.")) .build(); stack.add_named(&search_empty_page, Some("search-empty")); @@ -173,6 +206,7 @@ impl LibraryView { .margin_start(12) .margin_end(12) .build(); + flow_box.update_property(&[AccessibleProperty::Label("AppImage library grid")]); let grid_scroll = gtk::ScrolledWindow::builder() .child(&flow_box) @@ -185,6 +219,7 @@ impl LibraryView { .selection_mode(gtk::SelectionMode::Single) .build(); list_box.add_css_class("boxed-list"); + list_box.update_property(&[AccessibleProperty::Label("AppImage library list")]); let list_clamp = adw::Clamp::builder() .maximum_size(900) @@ -218,27 +253,33 @@ impl LibraryView { .child(&toolbar_view) .build(); - // --- Wire up view toggle --- + // --- Wire up view toggle (linked buttons) --- { let stack_ref = stack.clone(); let view_mode_ref = view_mode.clone(); - let toggle_ref = view_toggle.clone(); - view_toggle.connect_toggled(move |btn| { + let settings_ref = settings.clone(); + grid_button.connect_toggled(move |btn| { + if btn.is_active() { + view_mode_ref.set(ViewMode::Grid); + stack_ref.set_visible_child_name("grid"); + settings_ref.set_string("view-mode", "grid").ok(); + } + }); + } + { + let stack_ref = stack.clone(); + let view_mode_ref = view_mode.clone(); + let settings_ref = settings; + list_button.connect_toggled(move |btn| { if btn.is_active() { view_mode_ref.set(ViewMode::List); - toggle_ref.set_icon_name("view-grid-symbolic"); - toggle_ref.set_tooltip_text(Some("Toggle grid view")); stack_ref.set_visible_child_name("list"); - } else { - view_mode_ref.set(ViewMode::Grid); - toggle_ref.set_icon_name("view-list-symbolic"); - toggle_ref.set_tooltip_text(Some("Toggle list view")); - stack_ref.set_visible_child_name("grid"); + settings_ref.set_string("view-mode", "list").ok(); } }); } - // --- Wire up search filtering --- + // --- Wire up search filtering (debounced at 150ms) --- { let flow_box_ref = flow_box.clone(); let list_box_ref = list_box.clone(); @@ -246,9 +287,17 @@ impl LibraryView { let stack_ref = stack.clone(); let view_mode_ref = view_mode.clone(); let search_empty_ref = search_empty_page.clone(); + let debounce_source: Rc>> = Rc::new(Cell::new(None)); + search_entry.connect_search_changed(move |entry| { + // Cancel any pending debounce timer + if let Some(source_id) = debounce_source.take() { + source_id.remove(); + } + let query = entry.text().to_string().to_lowercase(); + // Immediate clear when search is empty (no debounce needed) if query.is_empty() { flow_box_ref.set_filter_func(|_| true); let mut i = 0; @@ -267,51 +316,63 @@ impl LibraryView { return; } - // Build a snapshot of match results for the filter closure - let recs = records_ref.borrow(); - let match_flags: Vec = recs - .iter() - .map(|rec| { - let name = rec.app_name.as_deref().unwrap_or(&rec.filename).to_lowercase(); - let path = rec.path.to_lowercase(); - name.contains(&query) || path.contains(&query) - }) - .collect(); + // Debounce: schedule filter after 150ms of no typing + let flow_box_d = flow_box_ref.clone(); + let list_box_d = list_box_ref.clone(); + let records_d = records_ref.clone(); + let stack_d = stack_ref.clone(); + let view_mode_d = view_mode_ref.clone(); + let search_empty_d = search_empty_ref.clone(); - let flags_clone = match_flags.clone(); - flow_box_ref.set_filter_func(move |child| { - let idx = child.index() as usize; - flags_clone.get(idx).copied().unwrap_or(false) - }); + let source_id = glib::timeout_add_local_once( + std::time::Duration::from_millis(150), + move || { + let recs = records_d.borrow(); + let match_flags: Vec = recs + .iter() + .map(|rec| { + let name = rec.app_name.as_deref().unwrap_or(&rec.filename).to_lowercase(); + let path = rec.path.to_lowercase(); + name.contains(&query) || path.contains(&query) + }) + .collect(); - let mut visible_count = 0; - for (i, matches) in match_flags.iter().enumerate() { - if let Some(row) = list_box_ref.row_at_index(i as i32) { - row.set_visible(*matches); - } - if *matches { - visible_count += 1; - } - } + let flags_clone = match_flags.clone(); + flow_box_d.set_filter_func(move |child| { + let idx = child.index() as usize; + flags_clone.get(idx).copied().unwrap_or(false) + }); - if visible_count == 0 && !recs.is_empty() { - search_empty_ref.set_description(Some( - &format!("No AppImages match '{}'. Try a different search term.", query) - )); - stack_ref.set_visible_child_name("search-empty"); - } else { - let view_name = if view_mode_ref.get() == ViewMode::Grid { - "grid" - } else { - "list" - }; - stack_ref.set_visible_child_name(view_name); - } + let mut visible_count = 0; + for (i, matches) in match_flags.iter().enumerate() { + if let Some(row) = list_box_d.row_at_index(i as i32) { + row.set_visible(*matches); + } + if *matches { + visible_count += 1; + } + } + + if visible_count == 0 && !recs.is_empty() { + search_empty_d.set_description(Some( + &i18n_f("No AppImages match '{}'. Try a different search term.", &[("{}", &query)]) + )); + stack_d.set_visible_child_name("search-empty"); + } else { + let view_name = if view_mode_d.get() == ViewMode::Grid { + "grid" + } else { + "list" + }; + stack_d.set_visible_child_name(view_name); + } + }, + ); + debounce_source.set(Some(source_id)); }); } // --- Wire up empty state buttons --- - // These will be connected to actions externally via the public methods scan_now_btn.set_action_name(Some("win.scan")); prefs_btn.set_action_name(Some("win.preferences")); @@ -323,9 +384,10 @@ impl LibraryView { list_box, search_bar, search_entry, - subtitle_label, + title_widget, view_mode, - view_toggle, + grid_button, + list_button, records, search_empty_page, } @@ -338,6 +400,7 @@ impl LibraryView { } LibraryState::Empty => { self.stack.set_visible_child_name("empty"); + self.title_widget.set_subtitle(""); } LibraryState::Populated => { let view_name = if self.view_mode.get() == ViewMode::Grid { @@ -348,6 +411,7 @@ impl LibraryView { self.stack.set_visible_child_name(view_name); } LibraryState::SearchEmpty => { + self.search_empty_page.set_title(&i18n("No Results")); self.stack.set_visible_child_name("search-empty"); } } @@ -376,19 +440,45 @@ impl LibraryView { *self.records.borrow_mut() = new_records; let count = self.records.borrow().len(); + // Update subtitle with count using i18n plurals if count == 0 { + self.title_widget.set_subtitle(""); self.set_state(LibraryState::Empty); } else { + let subtitle = ni18n_f( + "{} AppImage", + "{} AppImages", + count as u32, + &[("{}", &count.to_string())], + ); + self.title_widget.set_subtitle(&subtitle); self.set_state(LibraryState::Populated); } } fn build_list_row(&self, record: &AppImageRecord) -> adw::ActionRow { let name = record.app_name.as_deref().unwrap_or(&record.filename); - let subtitle = if let Some(ref ver) = record.app_version { - format!("{} - {}", ver, widgets::format_size(record.size_bytes)) - } else { - widgets::format_size(record.size_bytes) + + // Richer subtitle with description snippet when available + let subtitle = { + let mut parts = Vec::new(); + if let Some(ref ver) = record.app_version { + parts.push(ver.clone()); + } + parts.push(widgets::format_size(record.size_bytes)); + if let Some(ref desc) = record.description { + if !desc.is_empty() { + // Truncate description to first sentence or 60 chars + let snippet: String = desc.chars().take(60).collect(); + let snippet = if snippet.len() < desc.len() { + format!("{}...", snippet.trim_end()) + } else { + snippet + }; + parts.push(snippet); + } + } + parts.join(" - ") }; let row = adw::ActionRow::builder() @@ -397,35 +487,54 @@ impl LibraryView { .activatable(true) .build(); - // Icon prefix - if let Some(ref icon_path) = record.icon_path { - let path = std::path::Path::new(icon_path); - if path.exists() { - if let Ok(texture) = gtk::gdk::Texture::from_filename(path) { - let image = gtk::Image::builder() - .pixel_size(32) - .build(); - image.set_paintable(Some(&texture)); - row.add_prefix(&image); - } - } else { - let image = gtk::Image::builder() - .icon_name("application-x-executable-symbolic") - .pixel_size(32) - .build(); - row.add_prefix(&image); + // Icon prefix (40x40 with letter fallback) + let icon = widgets::app_icon( + record.icon_path.as_deref(), + name, + 40, + ); + row.add_prefix(&icon); + + // Status badges as suffix + let badge_box = gtk::Box::builder() + .orientation(gtk::Orientation::Horizontal) + .spacing(4) + .valign(gtk::Align::Center) + .build(); + + // Wayland badge + if let Some(ref ws) = record.wayland_status { + let status = WaylandStatus::from_str(ws); + if status != WaylandStatus::Unknown && status != WaylandStatus::Native { + let badge = widgets::status_badge(status.label(), status.badge_class()); + badge_box.append(&badge); } - } else { - let image = gtk::Image::builder() - .icon_name("application-x-executable-symbolic") - .pixel_size(32) - .build(); - row.add_prefix(&image); } - // Integration badge suffix - let badge = widgets::integration_badge(record.integrated); - row.add_suffix(&badge); + // FUSE badge + if let Some(ref fs) = record.fuse_status { + let status = FuseStatus::from_str(fs); + if !status.is_functional() { + let badge = widgets::status_badge(status.label(), status.badge_class()); + badge_box.append(&badge); + } + } + + // Update badge + if let (Some(ref latest), Some(ref current)) = + (&record.latest_version, &record.app_version) + { + if crate::core::updater::version_is_newer(latest, current) { + let badge = widgets::status_badge("Update", "info"); + badge_box.append(&badge); + } + } + + // Integration badge + let int_badge = widgets::integration_badge(record.integrated); + badge_box.append(&int_badge); + + row.add_suffix(&badge_box); // Navigate arrow let arrow = gtk::Image::from_icon_name("go-next-symbolic"); @@ -477,4 +586,12 @@ impl LibraryView { self.search_entry.grab_focus(); } } + + /// Programmatically set the view mode by toggling the linked buttons. + pub fn set_view_mode(&self, mode: ViewMode) { + match mode { + ViewMode::Grid => self.grid_button.set_active(true), + ViewMode::List => self.list_button.set_active(true), + } + } }