Add WCAG 2.2 AAA compliance and automated AT-SPI audit tool
- Bring all UI widgets to WCAG 2.2 AAA conformance across all views - Add accessible labels, roles, descriptions, and announcements - Bump focus outlines to 3px, target sizes to 44px AAA minimum - Fix announce()/announce_result() to walk widget tree via parent() - Add AT-SPI accessibility audit script (tools/a11y-audit.py) that checks SC 4.1.2, 1.1.1, 1.3.1, 2.1.1, 2.5.5, 2.5.8, 2.4.8, 2.4.9, 2.4.10, 2.1.3 with JSON report output for CI - Clean up project structure, archive old plan documents
This commit is contained in:
@@ -1,12 +1,15 @@
|
||||
use adw::prelude::*;
|
||||
use std::cell::Cell;
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::collections::BTreeMap;
|
||||
use std::io::Read as _;
|
||||
use std::rc::Rc;
|
||||
|
||||
use gtk::gio;
|
||||
|
||||
use crate::core::backup;
|
||||
use crate::core::catalog;
|
||||
use crate::core::database::{AppImageRecord, Database};
|
||||
use crate::i18n::{i18n, i18n_f};
|
||||
use crate::core::footprint;
|
||||
use crate::core::fuse::{self, FuseStatus};
|
||||
use crate::core::integrator;
|
||||
@@ -35,7 +38,7 @@ pub fn build_detail_page(record: &AppImageRecord, db: &Rc<Database>) -> adw::Nav
|
||||
|
||||
// Build tab pages (2-tab layout: About + Details)
|
||||
let about_page = build_overview_tab(record, db);
|
||||
view_stack.add_titled(&about_page, Some("about"), "About");
|
||||
view_stack.add_titled(&about_page, Some("about"), &i18n("About"));
|
||||
view_stack.page(&about_page).set_icon_name(Some("info-symbolic"));
|
||||
|
||||
// Details tab combines System + Security + Storage
|
||||
@@ -50,7 +53,7 @@ pub fn build_detail_page(record: &AppImageRecord, db: &Rc<Database>) -> adw::Nav
|
||||
details_page.append(&security_content);
|
||||
details_page.append(&storage_content);
|
||||
|
||||
view_stack.add_titled(&details_page, Some("details"), "Details");
|
||||
view_stack.add_titled(&details_page, Some("details"), &i18n("Details"));
|
||||
view_stack.page(&details_page).set_icon_name(Some("applications-system-symbolic"));
|
||||
|
||||
// Restore last-used tab from GSettings (map old tab names to new ones)
|
||||
@@ -514,6 +517,7 @@ fn build_overview_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
|
||||
.halign(gtk::Align::Center)
|
||||
.valign(gtk::Align::Center)
|
||||
.build();
|
||||
spinner.update_property(&[gtk::accessible::Property::Label("Loading screenshot")]);
|
||||
overlay.add_overlay(&spinner);
|
||||
|
||||
// Don't let overlays steal focus (prevents scroll jump on dialog close)
|
||||
@@ -533,6 +537,8 @@ fn build_overview_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
|
||||
&window,
|
||||
&textures_click,
|
||||
idx,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -573,6 +579,36 @@ fn build_overview_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
|
||||
});
|
||||
}
|
||||
|
||||
// Keyboard activation: Enter/Space opens lightbox from the carousel
|
||||
carousel.set_focusable(true);
|
||||
carousel.update_property(&[gtk::accessible::Property::Label("App screenshots")]);
|
||||
let textures_key = textures.clone();
|
||||
let carousel_key = carousel.clone();
|
||||
let key_ctrl = gtk::EventControllerKey::new();
|
||||
key_ctrl.connect_key_pressed(move |_ctrl, key, _code, _mods| {
|
||||
if matches!(key, gtk::gdk::Key::Return | gtk::gdk::Key::KP_Enter | gtk::gdk::Key::space) {
|
||||
let idx = carousel_key.position().round() as usize;
|
||||
let textures_ref = textures_key.borrow();
|
||||
if textures_ref.iter().any(|t| t.is_some()) {
|
||||
if let Some(root) = gtk::prelude::WidgetExt::root(&carousel_key) {
|
||||
if let Ok(window) = root.downcast::<gtk::Window>() {
|
||||
show_screenshot_lightbox(
|
||||
&window,
|
||||
&textures_key,
|
||||
idx,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
glib::Propagation::Stop
|
||||
} else {
|
||||
glib::Propagation::Proceed
|
||||
}
|
||||
});
|
||||
carousel.add_controller(key_ctrl);
|
||||
|
||||
let carousel_box = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Vertical)
|
||||
.spacing(8)
|
||||
@@ -624,12 +660,14 @@ fn build_overview_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
|
||||
|
||||
let icon = gtk::Image::from_icon_name("external-link-symbolic");
|
||||
icon.set_valign(gtk::Align::Center);
|
||||
icon.set_accessible_role(gtk::AccessibleRole::Presentation);
|
||||
row.add_suffix(&icon);
|
||||
|
||||
// Start with the fallback icon, then try to load favicon
|
||||
let prefix_icon = gtk::Image::from_icon_name(*icon_name);
|
||||
prefix_icon.set_valign(gtk::Align::Center);
|
||||
prefix_icon.set_pixel_size(16);
|
||||
prefix_icon.set_accessible_role(gtk::AccessibleRole::Presentation);
|
||||
row.add_prefix(&prefix_icon);
|
||||
fetch_favicon_async(url, &prefix_icon);
|
||||
|
||||
@@ -785,7 +823,7 @@ fn build_overview_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
|
||||
if let Some(desc_text) = desc {
|
||||
let row = adw::ExpanderRow::builder()
|
||||
.title(&title)
|
||||
.subtitle("Click to see changes")
|
||||
.subtitle("Expand to see changes")
|
||||
.build();
|
||||
|
||||
let label = gtk::Label::builder()
|
||||
@@ -1032,13 +1070,12 @@ fn build_overview_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
|
||||
label.add_css_class("caption");
|
||||
chip.append(&label);
|
||||
|
||||
let remove_btn = gtk::Button::builder()
|
||||
.icon_name("window-close-symbolic")
|
||||
.css_classes(["flat", "circular"])
|
||||
.valign(gtk::Align::Center)
|
||||
.build();
|
||||
remove_btn.set_width_request(20);
|
||||
remove_btn.set_height_request(20);
|
||||
let remove_btn = widgets::accessible_icon_button(
|
||||
"window-close-symbolic",
|
||||
&format!("Remove tag {}", tag_text),
|
||||
&format!("Remove tag {}", tag_text),
|
||||
);
|
||||
remove_btn.add_css_class("circular");
|
||||
|
||||
let tag_to_remove = tag_text.clone();
|
||||
let state_r = state.clone();
|
||||
@@ -1077,12 +1114,12 @@ fn build_overview_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
|
||||
}
|
||||
|
||||
// "+" add button
|
||||
let add_btn = gtk::Button::builder()
|
||||
.icon_name("list-add-symbolic")
|
||||
.css_classes(["flat", "circular"])
|
||||
.valign(gtk::Align::Center)
|
||||
.tooltip_text("Add tag")
|
||||
.build();
|
||||
let add_btn = widgets::accessible_icon_button(
|
||||
"list-add-symbolic",
|
||||
"Add tag",
|
||||
"Add a new tag to this app",
|
||||
);
|
||||
add_btn.add_css_class("circular");
|
||||
|
||||
let state_a = state.clone();
|
||||
let db_a = db_ref.clone();
|
||||
@@ -1093,6 +1130,7 @@ fn build_overview_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
|
||||
.placeholder_text("New tag")
|
||||
.width_chars(12)
|
||||
.build();
|
||||
entry.update_property(&[gtk::accessible::Property::Label("New tag name")]);
|
||||
let parent = tag_box_a.clone();
|
||||
parent.remove(btn);
|
||||
parent.append(&entry);
|
||||
@@ -1122,13 +1160,13 @@ fn build_overview_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
|
||||
let badge = widgets::status_badge(t, "info");
|
||||
parent_e.append(&badge);
|
||||
}
|
||||
// We lose the add button here but it refreshes on detail reopen
|
||||
let new_add = gtk::Button::builder()
|
||||
.icon_name("list-add-symbolic")
|
||||
.css_classes(["flat", "circular"])
|
||||
.valign(gtk::Align::Center)
|
||||
.tooltip_text("Add tag")
|
||||
.build();
|
||||
// Re-add the "+" button (placeholder; fully functional on detail reopen)
|
||||
let new_add = widgets::accessible_icon_button(
|
||||
"list-add-symbolic",
|
||||
"Add tag",
|
||||
"Add a new tag to this app",
|
||||
);
|
||||
new_add.add_css_class("circular");
|
||||
parent_e.append(&new_add);
|
||||
});
|
||||
});
|
||||
@@ -1290,16 +1328,16 @@ fn build_system_tab(record: &AppImageRecord, db: &Rc<Database>, toast_overlay: &
|
||||
match integrator::enable_autostart(&db_autostart, &record_autostart) {
|
||||
Ok(path) => {
|
||||
log::info!("Autostart enabled: {}", path.display());
|
||||
toast_autostart.add_toast(adw::Toast::new("Will start at login"));
|
||||
toast_autostart.add_toast(widgets::info_toast(&i18n("Will start at login")));
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to enable autostart: {}", e);
|
||||
toast_autostart.add_toast(adw::Toast::new("Failed to enable autostart"));
|
||||
toast_autostart.add_toast(widgets::error_toast(&i18n("Failed to enable autostart")));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
integrator::disable_autostart(&db_autostart, record_id_as).ok();
|
||||
toast_autostart.add_toast(adw::Toast::new("Autostart disabled"));
|
||||
toast_autostart.add_toast(widgets::info_toast(&i18n("Autostart disabled")));
|
||||
}
|
||||
});
|
||||
integration_group.add(&autostart_row);
|
||||
@@ -1317,10 +1355,10 @@ fn build_system_tab(record: &AppImageRecord, db: &Rc<Database>, toast_overlay: &
|
||||
let text = row.text().to_string();
|
||||
let value = if text.is_empty() { None } else { Some(text.as_str()) };
|
||||
match db_wm.set_startup_wm_class(record_id_wm, value) {
|
||||
Ok(()) => toast_wm.add_toast(adw::Toast::new("WM class updated")),
|
||||
Ok(()) => toast_wm.add_toast(widgets::info_toast(&i18n("WM class updated"))),
|
||||
Err(e) => {
|
||||
log::error!("Failed to set WM class: {}", e);
|
||||
toast_wm.add_toast(adw::Toast::new("Failed to update WM class"));
|
||||
toast_wm.add_toast(widgets::error_toast(&i18n("Failed to update WM class")));
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1356,22 +1394,22 @@ fn build_system_tab(record: &AppImageRecord, db: &Rc<Database>, toast_overlay: &
|
||||
if row.is_active() {
|
||||
match integrator::install_system_wide(&record_sw, &db_sw) {
|
||||
Ok(()) => {
|
||||
toast_sw.add_toast(adw::Toast::new("Installed system-wide"));
|
||||
toast_sw.add_toast(widgets::info_toast(&i18n("Installed system-wide")));
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("System-wide install failed: {}", e);
|
||||
toast_sw.add_toast(adw::Toast::new("System-wide install failed"));
|
||||
toast_sw.add_toast(widgets::error_toast(&i18n("System-wide install failed")));
|
||||
row.set_active(false);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
match integrator::remove_system_wide(&db_sw, record_id_sw) {
|
||||
Ok(()) => {
|
||||
toast_sw.add_toast(adw::Toast::new("System-wide install removed"));
|
||||
toast_sw.add_toast(widgets::info_toast(&i18n("System-wide install removed")));
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to remove system-wide install: {}", e);
|
||||
toast_sw.add_toast(adw::Toast::new("Failed to remove system-wide install"));
|
||||
toast_sw.add_toast(widgets::error_toast(&i18n("Failed to remove system-wide install")));
|
||||
row.set_active(true);
|
||||
}
|
||||
}
|
||||
@@ -1398,7 +1436,11 @@ fn build_system_tab(record: &AppImageRecord, db: &Rc<Database>, toast_overlay: &
|
||||
.label("Rollback")
|
||||
.valign(gtk::Align::Center)
|
||||
.css_classes(["destructive-action"])
|
||||
.tooltip_text("Roll back to the previous version")
|
||||
.build();
|
||||
rollback_btn.update_property(&[gtk::accessible::Property::Description(
|
||||
"Roll back to the previous version",
|
||||
)]);
|
||||
rollback_row.add_suffix(&rollback_btn);
|
||||
|
||||
let current_path = record.path.clone();
|
||||
@@ -1417,12 +1459,12 @@ fn build_system_tab(record: &AppImageRecord, db: &Rc<Database>, toast_overlay: &
|
||||
match result {
|
||||
Ok(()) => {
|
||||
db_rb.set_previous_version(record_id_rb, Some(&prev_path_owned)).ok();
|
||||
toast_rb.add_toast(adw::Toast::new("Rolled back to previous version"));
|
||||
toast_rb.add_toast(widgets::info_toast(&i18n("Rolled back to previous version")));
|
||||
btn.set_sensitive(false);
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Rollback failed: {}", e);
|
||||
toast_rb.add_toast(adw::Toast::new("Rollback failed"));
|
||||
toast_rb.add_toast(widgets::error_toast(&i18n("Rollback failed")));
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1432,10 +1474,36 @@ fn build_system_tab(record: &AppImageRecord, db: &Rc<Database>, toast_overlay: &
|
||||
}
|
||||
}
|
||||
|
||||
// File type associations group
|
||||
// File type associations group (grouped by category)
|
||||
if let Some(ref mime_str) = record.mime_types {
|
||||
let types: Vec<&str> = mime_str.split(';').filter(|s| !s.is_empty()).collect();
|
||||
if !types.is_empty() {
|
||||
// Group MIME types by category prefix (audio/, video/, etc.)
|
||||
let mut groups: BTreeMap<String, Vec<String>> = BTreeMap::new();
|
||||
for mime_type in &types {
|
||||
let category = mime_type
|
||||
.split('/')
|
||||
.next()
|
||||
.unwrap_or("other")
|
||||
.to_string();
|
||||
groups
|
||||
.entry(category)
|
||||
.or_default()
|
||||
.push(mime_type.to_string());
|
||||
}
|
||||
// Sort MIME types within each group
|
||||
for mimes in groups.values_mut() {
|
||||
mimes.sort();
|
||||
}
|
||||
|
||||
// Check which MIME types are already set as default by this app
|
||||
let existing_mods = db.get_modifications(record.id).unwrap_or_default();
|
||||
let already_set: std::collections::HashSet<String> = existing_mods
|
||||
.iter()
|
||||
.filter(|m| m.mod_type == "mime_default")
|
||||
.map(|m| m.file_path.clone())
|
||||
.collect();
|
||||
|
||||
let mime_group = adw::PreferencesGroup::builder()
|
||||
.title("Opens these file types")
|
||||
.description("File types this app can handle. Set as default to always open them with this app.")
|
||||
@@ -1445,43 +1513,225 @@ fn build_system_tab(record: &AppImageRecord, db: &Rc<Database>, toast_overlay: &
|
||||
record.app_name.as_deref().unwrap_or(&record.filename),
|
||||
);
|
||||
|
||||
for mime_type in &types {
|
||||
let row = adw::ActionRow::builder()
|
||||
.title(*mime_type)
|
||||
// Shared list of all buttons so "Set/Unset All" can toggle everything
|
||||
let all_buttons: Rc<RefCell<Vec<gtk::Button>>> = Rc::new(RefCell::new(Vec::new()));
|
||||
|
||||
// "Set Default for All" / "Unset Default for All" header button
|
||||
let all_already_set = types.iter().all(|m| already_set.contains(*m));
|
||||
let all_btn = gtk::Button::builder()
|
||||
.label(if all_already_set { "Unset Default for All" } else { "Set Default for All" })
|
||||
.valign(gtk::Align::Center)
|
||||
.build();
|
||||
all_btn.add_css_class("flat");
|
||||
all_btn.update_property(&[gtk::accessible::Property::Description("Set or unset default handler for all file types")]);
|
||||
all_buttons.borrow_mut().push(all_btn.clone());
|
||||
|
||||
let all_mimes: Vec<String> = types.iter().map(|s| s.to_string()).collect();
|
||||
let db_all = db.clone();
|
||||
let record_id_all = record.id;
|
||||
let app_id_all = app_id.clone();
|
||||
let toast_all = toast_overlay.clone();
|
||||
let all_buttons_ref = all_buttons.clone();
|
||||
all_btn.connect_clicked(move |btn| {
|
||||
let setting = btn.label().map_or(true, |l| l.as_str().starts_with("Set"));
|
||||
let mut success_count: usize = 0;
|
||||
if setting {
|
||||
for mime in &all_mimes {
|
||||
if integrator::set_mime_default(
|
||||
&db_all, record_id_all, &app_id_all, mime,
|
||||
).is_ok() {
|
||||
success_count += 1;
|
||||
}
|
||||
}
|
||||
toast_all.add_toast(widgets::info_toast(
|
||||
&i18n_f(
|
||||
"Set as default for {count} file types",
|
||||
&[("{count}", &success_count.to_string())],
|
||||
),
|
||||
));
|
||||
for b in all_buttons_ref.borrow().iter() {
|
||||
if b.label().map_or(false, |l| l.as_str() == "Set Default for All") {
|
||||
b.set_label("Unset Default for All");
|
||||
} else {
|
||||
b.set_label("Unset Default");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for mime in &all_mimes {
|
||||
if integrator::unset_mime_default(
|
||||
&db_all, record_id_all, mime,
|
||||
).is_ok() {
|
||||
success_count += 1;
|
||||
}
|
||||
}
|
||||
toast_all.add_toast(widgets::info_toast(
|
||||
&i18n_f(
|
||||
"Unset default for {count} file types",
|
||||
&[("{count}", &success_count.to_string())],
|
||||
),
|
||||
));
|
||||
for b in all_buttons_ref.borrow().iter() {
|
||||
if b.label().map_or(false, |l| l.as_str() == "Unset Default for All") {
|
||||
b.set_label("Set Default for All");
|
||||
} else {
|
||||
b.set_label("Set Default");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
mime_group.set_header_suffix(Some(&all_btn));
|
||||
|
||||
// Build per-category expander rows
|
||||
for (category, mimes) in &groups {
|
||||
let cat_label = {
|
||||
let mut chars = category.chars();
|
||||
match chars.next() {
|
||||
Some(c) => c.to_uppercase().to_string() + chars.as_str(),
|
||||
None => category.clone(),
|
||||
}
|
||||
};
|
||||
|
||||
let expander = adw::ExpanderRow::builder()
|
||||
.title(&cat_label)
|
||||
.subtitle(&i18n_f("{count} file types", &[("{count}", &mimes.len().to_string())]))
|
||||
.build();
|
||||
|
||||
let set_btn = gtk::Button::builder()
|
||||
.label("Set Default")
|
||||
// Category button - check if all types in this category are already set
|
||||
let cat_already_set = mimes.iter().all(|m| already_set.contains(m));
|
||||
let cat_btn = gtk::Button::builder()
|
||||
.label(if cat_already_set { "Unset Default" } else { "Set Default" })
|
||||
.valign(gtk::Align::Center)
|
||||
.build();
|
||||
set_btn.add_css_class("flat");
|
||||
cat_btn.add_css_class("flat");
|
||||
cat_btn.update_property(&[gtk::accessible::Property::Description(&format!("Set or unset default for {} file types", cat_label))]);
|
||||
all_buttons.borrow_mut().push(cat_btn.clone());
|
||||
|
||||
let db_mime = db.clone();
|
||||
let record_id = record.id;
|
||||
let app_id_clone = app_id.clone();
|
||||
let mime = mime_type.to_string();
|
||||
let toast_mime = toast_overlay.clone();
|
||||
set_btn.connect_clicked(move |btn| {
|
||||
match integrator::set_mime_default(
|
||||
&db_mime, record_id, &app_id_clone, &mime,
|
||||
) {
|
||||
Ok(()) => {
|
||||
toast_mime.add_toast(adw::Toast::new(
|
||||
&format!("Set as default for {}", mime),
|
||||
));
|
||||
btn.set_sensitive(false);
|
||||
btn.set_label("Default");
|
||||
// Collect buttons for this category so the category handler can toggle them
|
||||
let cat_buttons: Rc<RefCell<Vec<gtk::Button>>> = Rc::new(RefCell::new(Vec::new()));
|
||||
cat_buttons.borrow_mut().push(cat_btn.clone());
|
||||
|
||||
// Individual MIME type rows
|
||||
for mime in mimes {
|
||||
let row = adw::ActionRow::builder()
|
||||
.title(mime)
|
||||
.build();
|
||||
|
||||
let ind_already_set = already_set.contains(mime);
|
||||
let set_btn = gtk::Button::builder()
|
||||
.label(if ind_already_set { "Unset Default" } else { "Set Default" })
|
||||
.valign(gtk::Align::Center)
|
||||
.build();
|
||||
set_btn.add_css_class("flat");
|
||||
set_btn.update_property(&[gtk::accessible::Property::Description(&format!("Set or unset default for {}", mime))]);
|
||||
all_buttons.borrow_mut().push(set_btn.clone());
|
||||
cat_buttons.borrow_mut().push(set_btn.clone());
|
||||
|
||||
let db_ind = db.clone();
|
||||
let record_id_ind = record.id;
|
||||
let app_id_ind = app_id.clone();
|
||||
let mime_ind = mime.clone();
|
||||
let toast_ind = toast_overlay.clone();
|
||||
set_btn.connect_clicked(move |btn| {
|
||||
let setting = btn.label().map_or(true, |l| l.as_str().starts_with("Set"));
|
||||
if setting {
|
||||
match integrator::set_mime_default(
|
||||
&db_ind, record_id_ind, &app_id_ind, &mime_ind,
|
||||
) {
|
||||
Ok(()) => {
|
||||
toast_ind.add_toast(widgets::info_toast(
|
||||
&i18n_f("Set as default for {type}", &[("{type}", &mime_ind)]),
|
||||
));
|
||||
btn.set_label("Unset Default");
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to set MIME default: {}", e);
|
||||
toast_ind.add_toast(widgets::error_toast(
|
||||
&i18n("Failed to set default"),
|
||||
));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
match integrator::unset_mime_default(
|
||||
&db_ind, record_id_ind, &mime_ind,
|
||||
) {
|
||||
Ok(()) => {
|
||||
toast_ind.add_toast(widgets::info_toast(
|
||||
&i18n_f("Unset default for {type}", &[("{type}", &mime_ind)]),
|
||||
));
|
||||
btn.set_label("Set Default");
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to unset MIME default: {}", e);
|
||||
toast_ind.add_toast(widgets::error_toast(
|
||||
&i18n("Failed to unset default"),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to set MIME default: {}", e);
|
||||
toast_mime.add_toast(adw::Toast::new("Failed to set default"));
|
||||
});
|
||||
|
||||
row.add_suffix(&set_btn);
|
||||
expander.add_row(&row);
|
||||
}
|
||||
|
||||
// Category button click handler
|
||||
let cat_mimes: Vec<String> = mimes.clone();
|
||||
let db_cat = db.clone();
|
||||
let record_id_cat = record.id;
|
||||
let app_id_cat = app_id.clone();
|
||||
let toast_cat = toast_overlay.clone();
|
||||
let cat_label_toast = cat_label.clone();
|
||||
let cat_buttons_ref = cat_buttons.clone();
|
||||
cat_btn.connect_clicked(move |btn| {
|
||||
let setting = btn.label().map_or(true, |l| l.as_str().starts_with("Set"));
|
||||
let mut success_count: usize = 0;
|
||||
if setting {
|
||||
for mime in &cat_mimes {
|
||||
if integrator::set_mime_default(
|
||||
&db_cat, record_id_cat, &app_id_cat, mime,
|
||||
).is_ok() {
|
||||
success_count += 1;
|
||||
}
|
||||
}
|
||||
toast_cat.add_toast(widgets::info_toast(
|
||||
&i18n_f(
|
||||
"Set as default for {count} {category} file types",
|
||||
&[
|
||||
("{count}", &success_count.to_string()),
|
||||
("{category}", &cat_label_toast),
|
||||
],
|
||||
),
|
||||
));
|
||||
for b in cat_buttons_ref.borrow().iter() {
|
||||
b.set_label("Unset Default");
|
||||
}
|
||||
} else {
|
||||
for mime in &cat_mimes {
|
||||
if integrator::unset_mime_default(
|
||||
&db_cat, record_id_cat, mime,
|
||||
).is_ok() {
|
||||
success_count += 1;
|
||||
}
|
||||
}
|
||||
toast_cat.add_toast(widgets::info_toast(
|
||||
&i18n_f(
|
||||
"Unset default for {count} {category} file types",
|
||||
&[
|
||||
("{count}", &success_count.to_string()),
|
||||
("{category}", &cat_label_toast),
|
||||
],
|
||||
),
|
||||
));
|
||||
for b in cat_buttons_ref.borrow().iter() {
|
||||
b.set_label("Set Default");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
row.add_suffix(&set_btn);
|
||||
mime_group.add(&row);
|
||||
expander.add_suffix(&cat_btn);
|
||||
mime_group.add(&expander);
|
||||
}
|
||||
|
||||
inner.append(&mime_group);
|
||||
}
|
||||
}
|
||||
@@ -1511,6 +1761,9 @@ fn build_system_tab(record: &AppImageRecord, db: &Rc<Database>, toast_overlay: &
|
||||
.valign(gtk::Align::Center)
|
||||
.build();
|
||||
set_btn.add_css_class("flat");
|
||||
set_btn.update_property(&[gtk::accessible::Property::Description(
|
||||
&format!("Set as default for {}", cap.label()),
|
||||
)]);
|
||||
|
||||
let db_def = db.clone();
|
||||
let record_id = record.id;
|
||||
@@ -1522,15 +1775,16 @@ fn build_system_tab(record: &AppImageRecord, db: &Rc<Database>, toast_overlay: &
|
||||
&db_def, record_id, &app_id_clone, &cap_clone,
|
||||
) {
|
||||
Ok(()) => {
|
||||
toast_def.add_toast(adw::Toast::new(
|
||||
&format!("Set as default {}", cap_clone.label().to_lowercase()),
|
||||
toast_def.add_toast(widgets::info_toast(
|
||||
&i18n_f("Set as default {capability}", &[("{capability}", &cap_clone.label().to_lowercase())]),
|
||||
));
|
||||
btn.set_sensitive(false);
|
||||
btn.set_label("Default");
|
||||
btn.update_property(&[gtk::accessible::Property::Label("Default (already set)")]);
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to set default app: {}", e);
|
||||
toast_def.add_toast(adw::Toast::new("Failed to set default"));
|
||||
toast_def.add_toast(widgets::error_toast(&i18n("Failed to set default")));
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1582,9 +1836,7 @@ fn build_system_tab(record: &AppImageRecord, db: &Rc<Database>, toast_overlay: &
|
||||
your display system."
|
||||
)
|
||||
.build();
|
||||
let analyze_icon = gtk::Image::from_icon_name("system-search-symbolic");
|
||||
analyze_icon.set_valign(gtk::Align::Center);
|
||||
analyze_row.add_suffix(&analyze_icon);
|
||||
analyze_row.add_suffix(&widgets::accessible_suffix_icon("system-search-symbolic", "Analyze"));
|
||||
|
||||
let record_path_wayland = record.path.clone();
|
||||
analyze_row.connect_activated(move |row| {
|
||||
@@ -1606,13 +1858,13 @@ fn build_system_tab(record: &AppImageRecord, db: &Rc<Database>, toast_overlay: &
|
||||
Ok(analysis) => {
|
||||
let toolkit_label = analysis.toolkit.label();
|
||||
let _lib_count = analysis.libraries_found.len();
|
||||
row_clone.set_subtitle(&format!(
|
||||
"Built with: {}",
|
||||
toolkit_label,
|
||||
));
|
||||
let msg = format!("Built with: {}", toolkit_label);
|
||||
row_clone.set_subtitle(&msg);
|
||||
widgets::announce_result(row_clone.upcast_ref::<gtk::Widget>(), true, &format!("Analysis complete: {}", toolkit_label));
|
||||
}
|
||||
Err(_) => {
|
||||
row_clone.set_subtitle("Analysis failed - could not read the app's contents");
|
||||
widgets::announce_result(row_clone.upcast_ref::<gtk::Widget>(), false, "Framework analysis failed");
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1830,6 +2082,7 @@ fn build_system_tab(record: &AppImageRecord, db: &Rc<Database>, toast_overlay: &
|
||||
.valign(gtk::Align::Center)
|
||||
.css_classes(["flat"])
|
||||
.build();
|
||||
reset_btn.update_property(&[gtk::accessible::Property::Label("Reset sandbox profile to default")]);
|
||||
let db_reset = db.clone();
|
||||
let reset_name = app_name_for_sandbox.clone();
|
||||
reset_btn.connect_clicked(move |_btn| {
|
||||
@@ -1982,6 +2235,8 @@ fn build_security_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
|
||||
let label = status.label();
|
||||
row_ref.set_title(&format!("Verify SHA256 - {}", label));
|
||||
db_ref.set_verification_status(record_id_v, status.as_str()).ok();
|
||||
let success = status.as_str() == "verified";
|
||||
widgets::announce_result(row_ref.upcast_ref::<gtk::Widget>(), success, &format!("SHA-256 verification: {}", label));
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1993,7 +2248,7 @@ fn build_security_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
|
||||
.subtitle("Verify GPG signature if present")
|
||||
.activatable(true)
|
||||
.build();
|
||||
let sig_arrow = gtk::Image::from_icon_name("go-next-symbolic");
|
||||
let sig_arrow = widgets::accessible_suffix_icon("go-next-symbolic", "Check signature");
|
||||
sig_arrow.set_valign(gtk::Align::Center);
|
||||
check_sig_row.add_suffix(&sig_arrow);
|
||||
let record_path_sig = record.path.clone();
|
||||
@@ -2002,6 +2257,7 @@ fn build_security_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
|
||||
check_sig_row.connect_activated(move |row| {
|
||||
row.set_sensitive(false);
|
||||
row.set_subtitle("Checking...");
|
||||
row.update_state(&[gtk::accessible::State::Busy(true)]);
|
||||
let path = record_path_sig.clone();
|
||||
let db_ref = db_sig.clone();
|
||||
let row_ref = row.clone();
|
||||
@@ -2013,19 +2269,21 @@ fn build_security_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
|
||||
.await;
|
||||
|
||||
if let Ok(status) = result {
|
||||
row_ref.set_subtitle(&status.label());
|
||||
let label = status.label();
|
||||
row_ref.set_subtitle(&label);
|
||||
db_ref.set_verification_status(record_id_sig, status.as_str()).ok();
|
||||
let result_badge = widgets::status_badge(
|
||||
match status.badge_class() {
|
||||
"success" => "Verified",
|
||||
"error" => "Failed",
|
||||
_ => "Unknown",
|
||||
},
|
||||
status.badge_class(),
|
||||
);
|
||||
let badge_text = match status.badge_class() {
|
||||
"success" => "Verified",
|
||||
"error" => "Failed",
|
||||
_ => "Unknown",
|
||||
};
|
||||
let result_badge = widgets::status_badge(badge_text, status.badge_class());
|
||||
result_badge.set_valign(gtk::Align::Center);
|
||||
row_ref.add_suffix(&result_badge);
|
||||
let success = status.badge_class() == "success";
|
||||
widgets::announce_result(row_ref.upcast_ref::<gtk::Widget>(), success, &format!("Signature check: {}", badge_text));
|
||||
}
|
||||
row_ref.update_state(&[gtk::accessible::State::Busy(false)]);
|
||||
row_ref.set_sensitive(true);
|
||||
});
|
||||
});
|
||||
@@ -2103,9 +2361,7 @@ fn build_security_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
|
||||
of known security issues to see if any are outdated or vulnerable."
|
||||
)
|
||||
.build();
|
||||
let scan_icon = gtk::Image::from_icon_name("security-medium-symbolic");
|
||||
scan_icon.set_valign(gtk::Align::Center);
|
||||
scan_row.add_suffix(&scan_icon);
|
||||
scan_row.add_suffix(&widgets::accessible_suffix_icon("security-medium-symbolic", "Scan"));
|
||||
|
||||
let record_id = record.id;
|
||||
let record_path = record.path.clone();
|
||||
@@ -2130,16 +2386,19 @@ fn build_security_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
|
||||
let total = scan_result.total_cves();
|
||||
if total == 0 {
|
||||
row_clone.set_subtitle("No vulnerabilities found - looking good!");
|
||||
widgets::announce_result(row_clone.upcast_ref::<gtk::Widget>(), true, "Security scan complete: no vulnerabilities found");
|
||||
} else {
|
||||
row_clone.set_subtitle(&format!(
|
||||
"Found {} known issue{}. Check for app updates.",
|
||||
total,
|
||||
if total == 1 { "" } else { "s" },
|
||||
));
|
||||
widgets::announce_result(row_clone.upcast_ref::<gtk::Widget>(), false, &format!("Security scan complete: {} issues found", total));
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
row_clone.set_subtitle("Check failed - could not read the app's contents");
|
||||
widgets::announce_result(row_clone.upcast_ref::<gtk::Widget>(), false, "Security scan failed");
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -2284,15 +2543,14 @@ fn build_storage_tab(
|
||||
.subtitle("Search for files this app has saved")
|
||||
.activatable(true)
|
||||
.build();
|
||||
let discover_icon = gtk::Image::from_icon_name("folder-saved-search-symbolic");
|
||||
discover_icon.set_valign(gtk::Align::Center);
|
||||
discover_row.add_suffix(&discover_icon);
|
||||
discover_row.add_suffix(&widgets::accessible_suffix_icon("folder-saved-search-symbolic", "Search"));
|
||||
|
||||
let record_clone = record.clone();
|
||||
let record_id = record.id;
|
||||
discover_row.connect_activated(move |row| {
|
||||
row.set_sensitive(false);
|
||||
row.set_subtitle("Searching...");
|
||||
row.update_state(&[gtk::accessible::State::Busy(true)]);
|
||||
let row_clone = row.clone();
|
||||
let rec = record_clone.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
@@ -2304,6 +2562,7 @@ fn build_storage_tab(
|
||||
.await;
|
||||
|
||||
row_clone.set_sensitive(true);
|
||||
row_clone.update_state(&[gtk::accessible::State::Busy(false)]);
|
||||
match result {
|
||||
Ok(fp) => {
|
||||
let count = fp.paths.len();
|
||||
@@ -2336,6 +2595,7 @@ fn build_storage_tab(
|
||||
.build();
|
||||
let icon = gtk::Image::from_icon_name(dp.path_type.icon_name());
|
||||
icon.set_pixel_size(16);
|
||||
icon.set_accessible_role(gtk::AccessibleRole::Presentation);
|
||||
row.add_prefix(&icon);
|
||||
let conf_badge = widgets::status_badge(
|
||||
dp.confidence.as_str(),
|
||||
@@ -2351,12 +2611,11 @@ fn build_storage_tab(
|
||||
row.add_suffix(&size_label);
|
||||
|
||||
// Open folder button
|
||||
let open_btn = gtk::Button::builder()
|
||||
.icon_name("folder-open-symbolic")
|
||||
.tooltip_text("Open in file manager")
|
||||
.valign(gtk::Align::Center)
|
||||
.build();
|
||||
open_btn.add_css_class("flat");
|
||||
let open_btn = widgets::accessible_icon_button(
|
||||
"folder-open-symbolic",
|
||||
"Open in file manager",
|
||||
"Open in file manager",
|
||||
);
|
||||
let path_str = dp.path.to_string_lossy().to_string();
|
||||
open_btn.connect_clicked(move |_| {
|
||||
let file = gio::File::for_path(&path_str);
|
||||
@@ -2493,6 +2752,7 @@ fn build_backup_group(record_id: i64, toast_overlay: &adw::ToastOverlay) -> adw:
|
||||
let empty_icon = gtk::Image::from_icon_name("document-open-symbolic");
|
||||
empty_icon.set_valign(gtk::Align::Center);
|
||||
empty_icon.add_css_class("dim-label");
|
||||
empty_icon.set_accessible_role(gtk::AccessibleRole::Presentation);
|
||||
empty_row.add_prefix(&empty_icon);
|
||||
group.add(&empty_row);
|
||||
} else {
|
||||
@@ -2521,6 +2781,7 @@ fn build_backup_group(record_id: i64, toast_overlay: &adw::ToastOverlay) -> adw:
|
||||
.build();
|
||||
let icon = gtk::Image::from_icon_name("emblem-ok-symbolic");
|
||||
icon.add_css_class("success");
|
||||
icon.set_accessible_role(gtk::AccessibleRole::Presentation);
|
||||
let label = gtk::Label::new(Some("Exists"));
|
||||
label.add_css_class("caption");
|
||||
label.add_css_class("success");
|
||||
@@ -2535,6 +2796,7 @@ fn build_backup_group(record_id: i64, toast_overlay: &adw::ToastOverlay) -> adw:
|
||||
.build();
|
||||
let icon = gtk::Image::from_icon_name("dialog-warning-symbolic");
|
||||
icon.add_css_class("warning");
|
||||
icon.set_accessible_role(gtk::AccessibleRole::Presentation);
|
||||
let label = gtk::Label::new(Some("Missing"));
|
||||
label.add_css_class("caption");
|
||||
label.add_css_class("warning");
|
||||
@@ -2553,6 +2815,7 @@ fn build_backup_group(record_id: i64, toast_overlay: &adw::ToastOverlay) -> adw:
|
||||
.build();
|
||||
let restore_icon = gtk::Image::from_icon_name("edit-undo-symbolic");
|
||||
restore_icon.set_valign(gtk::Align::Center);
|
||||
restore_icon.set_accessible_role(gtk::AccessibleRole::Presentation);
|
||||
restore_row.add_prefix(&restore_icon);
|
||||
restore_row.update_property(&[
|
||||
gtk::accessible::Property::Label("Restore this backup"),
|
||||
@@ -2592,7 +2855,8 @@ fn build_backup_group(record_id: i64, toast_overlay: &adw::ToastOverlay) -> adw:
|
||||
if res.paths_restored == 1 { "" } else { "s" },
|
||||
skip_note,
|
||||
);
|
||||
toast.add_toast(adw::Toast::new(&toast_msg));
|
||||
toast.add_toast(widgets::info_toast(&toast_msg));
|
||||
widgets::announce_result(row_clone.upcast_ref::<gtk::Widget>(), true, &toast_msg);
|
||||
log::info!(
|
||||
"Backup restored: app={}, paths_restored={}, paths_skipped={}",
|
||||
res.manifest.app_name, res.paths_restored, res.paths_skipped,
|
||||
@@ -2600,7 +2864,8 @@ fn build_backup_group(record_id: i64, toast_overlay: &adw::ToastOverlay) -> adw:
|
||||
}
|
||||
_ => {
|
||||
row_clone.set_subtitle("Restore failed");
|
||||
toast.add_toast(adw::Toast::new("Failed to restore backup"));
|
||||
toast.add_toast(widgets::error_toast(&i18n("Failed to restore backup")));
|
||||
widgets::announce_result(row_clone.upcast_ref::<gtk::Widget>(), false, "Restore failed");
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -2616,6 +2881,7 @@ fn build_backup_group(record_id: i64, toast_overlay: &adw::ToastOverlay) -> adw:
|
||||
.build();
|
||||
let delete_icon = gtk::Image::from_icon_name("edit-delete-symbolic");
|
||||
delete_icon.set_valign(gtk::Align::Center);
|
||||
delete_icon.set_accessible_role(gtk::AccessibleRole::Presentation);
|
||||
delete_row.add_prefix(&delete_icon);
|
||||
delete_row.update_property(&[
|
||||
gtk::accessible::Property::Label("Delete this backup"),
|
||||
@@ -2642,12 +2908,13 @@ fn build_backup_group(record_id: i64, toast_overlay: &adw::ToastOverlay) -> adw:
|
||||
match result {
|
||||
Ok(Ok(())) => {
|
||||
group_del.remove(&expander_del);
|
||||
toast.add_toast(adw::Toast::new("Backup deleted"));
|
||||
toast.add_toast(widgets::info_toast(&i18n("Backup deleted")));
|
||||
}
|
||||
_ => {
|
||||
row_clone.set_sensitive(true);
|
||||
row_clone.set_subtitle("Delete failed");
|
||||
toast.add_toast(adw::Toast::new("Failed to delete backup"));
|
||||
toast.add_toast(widgets::error_toast(&i18n("Failed to delete backup")));
|
||||
widgets::announce_result(row_clone.upcast_ref::<gtk::Widget>(), false, "Delete failed");
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -2667,6 +2934,7 @@ fn build_backup_group(record_id: i64, toast_overlay: &adw::ToastOverlay) -> adw:
|
||||
.build();
|
||||
let create_icon = gtk::Image::from_icon_name("list-add-symbolic");
|
||||
create_icon.set_valign(gtk::Align::Center);
|
||||
create_icon.set_accessible_role(gtk::AccessibleRole::Presentation);
|
||||
create_row.add_prefix(&create_icon);
|
||||
create_row.update_property(&[
|
||||
gtk::accessible::Property::Label("Create a new backup"),
|
||||
@@ -2692,15 +2960,15 @@ fn build_backup_group(record_id: i64, toast_overlay: &adw::ToastOverlay) -> adw:
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("backup");
|
||||
row_clone.set_subtitle(&format!("Created {}", filename));
|
||||
toast.add_toast(adw::Toast::new("Backup created"));
|
||||
toast.add_toast(widgets::info_toast(&i18n("Backup created")));
|
||||
}
|
||||
Ok(Err(backup::BackupError::NoPaths)) => {
|
||||
row_clone.set_subtitle("Try discovering app data first");
|
||||
toast.add_toast(adw::Toast::new("No data paths found to back up"));
|
||||
toast.add_toast(widgets::error_toast(&i18n("No data paths found to back up")));
|
||||
}
|
||||
_ => {
|
||||
row_clone.set_subtitle("Backup failed");
|
||||
toast.add_toast(adw::Toast::new("Failed to create backup"));
|
||||
toast.add_toast(widgets::error_toast(&i18n("Failed to create backup")));
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -2762,10 +3030,14 @@ fn fuse_install_command(status: &FuseStatus) -> Option<&'static str> {
|
||||
|
||||
/// Show a screenshot in a fullscreen lightbox window with prev/next navigation.
|
||||
/// Uses a separate gtk::Window to avoid parent scroll position interference.
|
||||
/// Pass `screenshot_paths` and `app_name` for catalog detail views to preload
|
||||
/// screenshots that haven't been loaded yet (paged carousel lazy-loading).
|
||||
pub fn show_screenshot_lightbox(
|
||||
parent: >k::Window,
|
||||
textures: &Rc<std::cell::RefCell<Vec<Option<gtk::gdk::Texture>>>>,
|
||||
initial_index: usize,
|
||||
screenshot_paths: Option<&Rc<Vec<String>>>,
|
||||
app_name: Option<&str>,
|
||||
) {
|
||||
let current = Rc::new(std::cell::Cell::new(initial_index));
|
||||
let textures = textures.clone();
|
||||
@@ -2777,6 +3049,7 @@ pub fn show_screenshot_lightbox(
|
||||
.transient_for(parent)
|
||||
.modal(true)
|
||||
.decorated(false)
|
||||
.title("Screenshot viewer")
|
||||
.default_width(parent.width())
|
||||
.default_height(parent.height())
|
||||
.build();
|
||||
@@ -2791,6 +3064,7 @@ pub fn show_screenshot_lightbox(
|
||||
.margin_end(72)
|
||||
.margin_top(56)
|
||||
.margin_bottom(56)
|
||||
.alternative_text(&format!("Screenshot {} of {}", initial_index + 1, count))
|
||||
.build();
|
||||
picture.set_can_shrink(true);
|
||||
|
||||
@@ -2802,6 +3076,43 @@ pub fn show_screenshot_lightbox(
|
||||
}
|
||||
}
|
||||
|
||||
// Preload unloaded screenshots (for paged carousel that lazy-loads)
|
||||
if let (Some(paths), Some(name)) = (screenshot_paths, app_name) {
|
||||
let textures_preload = textures.clone();
|
||||
let picture_preload = picture.clone();
|
||||
let current_preload = current.clone();
|
||||
let paths = paths.clone();
|
||||
let name = name.to_string();
|
||||
for i in 0..count {
|
||||
let is_none = textures_preload.borrow().get(i).is_some_and(|t| t.is_none());
|
||||
if !is_none {
|
||||
continue;
|
||||
}
|
||||
let tex_ref = textures_preload.clone();
|
||||
let pic_ref = picture_preload.clone();
|
||||
let cur_ref = current_preload.clone();
|
||||
let path = paths[i].clone();
|
||||
let app = name.clone();
|
||||
let idx = i;
|
||||
glib::spawn_future_local(async move {
|
||||
let p = path.clone();
|
||||
let a = app.clone();
|
||||
let result = gio::spawn_blocking(move || {
|
||||
catalog::cache_screenshot(&a, &p, idx)
|
||||
.map_err(|e| e.to_string())
|
||||
}).await;
|
||||
if let Ok(Ok(local_path)) = result {
|
||||
if let Ok(texture) = gtk::gdk::Texture::from_filename(&local_path) {
|
||||
tex_ref.borrow_mut()[idx] = Some(texture.clone());
|
||||
if cur_ref.get() == idx {
|
||||
pic_ref.set_paintable(Some(&texture));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Navigation buttons
|
||||
let prev_btn = gtk::Button::builder()
|
||||
.icon_name("go-previous-symbolic")
|
||||
@@ -2809,7 +3120,9 @@ pub fn show_screenshot_lightbox(
|
||||
.valign(gtk::Align::Center)
|
||||
.halign(gtk::Align::Start)
|
||||
.margin_start(16)
|
||||
.tooltip_text("Previous screenshot")
|
||||
.build();
|
||||
prev_btn.update_property(&[gtk::accessible::Property::Label("Previous screenshot")]);
|
||||
|
||||
let next_btn = gtk::Button::builder()
|
||||
.icon_name("go-next-symbolic")
|
||||
@@ -2817,15 +3130,18 @@ pub fn show_screenshot_lightbox(
|
||||
.valign(gtk::Align::Center)
|
||||
.halign(gtk::Align::End)
|
||||
.margin_end(16)
|
||||
.tooltip_text("Next screenshot")
|
||||
.build();
|
||||
next_btn.update_property(&[gtk::accessible::Property::Label("Next screenshot")]);
|
||||
|
||||
// Counter label (e.g. "2 / 5")
|
||||
// Counter label (e.g. "2 / 5") - Status role for live region announcements
|
||||
let counter = gtk::Label::builder()
|
||||
.label(&format!("{} / {}", initial_index + 1, count))
|
||||
.css_classes(["lightbox-counter"])
|
||||
.halign(gtk::Align::Center)
|
||||
.valign(gtk::Align::End)
|
||||
.margin_bottom(16)
|
||||
.accessible_role(gtk::AccessibleRole::Status)
|
||||
.build();
|
||||
|
||||
// Close button (top-right)
|
||||
@@ -2837,6 +3153,7 @@ pub fn show_screenshot_lightbox(
|
||||
.margin_top(16)
|
||||
.margin_end(16)
|
||||
.build();
|
||||
close_btn.update_property(&[gtk::accessible::Property::Label("Close lightbox")]);
|
||||
|
||||
// Build overlay: picture as child, buttons + counter as overlays
|
||||
let overlay = gtk::Overlay::builder()
|
||||
@@ -3091,8 +3408,8 @@ pub fn show_uninstall_dialog_with_callback(
|
||||
|
||||
// Show undo toast - file deletion is deferred until toast dismisses
|
||||
let toast = adw::Toast::builder()
|
||||
.title(&format!("{} uninstalled", name))
|
||||
.button_label("Undo")
|
||||
.title(i18n_f("{name} uninstalled", &[("{name}", &name)]))
|
||||
.button_label(i18n("Undo"))
|
||||
.timeout(7)
|
||||
.build();
|
||||
|
||||
@@ -3117,7 +3434,7 @@ pub fn show_uninstall_dialog_with_callback(
|
||||
if let Some(ref cb) = on_complete_undo {
|
||||
cb();
|
||||
}
|
||||
toast_undo.add_toast(adw::Toast::new(&format!("{} restored", name_undo)));
|
||||
toast_undo.add_toast(widgets::info_toast(&i18n_f("{name} restored", &[("{name}", &name_undo)])));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3219,11 +3536,7 @@ fn do_launch(
|
||||
}
|
||||
Ok(launcher::LaunchResult::Failed(msg)) => {
|
||||
log::error!("Failed to launch: {}", msg);
|
||||
let toast = adw::Toast::builder()
|
||||
.title(&format!("Could not launch: {}", msg))
|
||||
.timeout(5)
|
||||
.build();
|
||||
toast_ref.add_toast(toast);
|
||||
toast_ref.add_toast(widgets::error_toast(&i18n_f("Could not launch: {error}", &[("{error}", &msg)])));
|
||||
}
|
||||
Err(_) => {
|
||||
log::error!("Launch task panicked");
|
||||
|
||||
Reference in New Issue
Block a user