use adw::prelude::*; use std::sync::OnceLock; /// Ensures the shared letter-icon CSS provider is registered on the default /// display exactly once. The provider defines `.letter-icon-a` through /// `.letter-icon-z` (and `.letter-icon-other`) with distinct hue-based /// background/foreground colors so that individual `build_letter_icon` calls /// never need to create their own CssProvider. fn ensure_letter_icon_css() { static REGISTERED: OnceLock = OnceLock::new(); REGISTERED.get_or_init(|| { let provider = gtk::CssProvider::new(); provider.load_from_string(&generate_letter_icon_css()); if let Some(display) = gtk::gdk::Display::default() { gtk::style_context_add_provider_for_display( &display, &provider, gtk::STYLE_PROVIDER_PRIORITY_APPLICATION + 1, ); } true }); } /// Generate CSS rules for `.letter-icon-a` through `.letter-icon-z` and a /// `.letter-icon-other` fallback. Each letter gets a unique hue evenly /// distributed around the color wheel (saturation 55%, lightness 45% for /// the background, lightness 97% for the foreground text) so that the 26 /// letter icons are visually distinct while remaining legible. fn generate_letter_icon_css() -> String { let mut css = String::with_capacity(4096); for i in 0u32..26 { let letter = (b'a' + i as u8) as char; let hue = (i * 360) / 26; // HSL background: moderate saturation, medium lightness // HSL foreground: same hue, very light for contrast css.push_str(&format!( "label.letter-icon-{letter} {{ \ background: hsl({hue}, 55%, 45%); \ color: hsl({hue}, 100%, 97%); \ border-radius: 50%; \ font-weight: 700; \ }}\n" )); } // Fallback for non-alphabetic first characters css.push_str( "label.letter-icon-other { \ background: hsl(0, 0%, 50%); \ color: hsl(0, 0%, 97%); \ border-radius: 50%; \ font-weight: 700; \ }\n" ); css } /// Create a status badge pill label with the given text and style class. /// Style classes: "success", "warning", "error", "info", "neutral" pub fn status_badge(text: &str, style_class: &str) -> gtk::Label { let label = gtk::Label::new(Some(text)); label.add_css_class("status-badge"); label.add_css_class(style_class); label.set_accessible_role(gtk::AccessibleRole::Status); label } /// Create a status badge with a symbolic icon prefix for accessibility. /// The badge contains both an icon and text so information is not color-dependent. pub fn status_badge_with_icon(icon_name: &str, text: &str, style_class: &str) -> gtk::Box { let hbox = gtk::Box::builder() .orientation(gtk::Orientation::Horizontal) .spacing(4) .accessible_role(gtk::AccessibleRole::Status) .build(); hbox.add_css_class("status-badge-with-icon"); hbox.add_css_class(style_class); hbox.update_property(&[gtk::accessible::Property::Label(text)]); let icon = gtk::Image::from_icon_name(icon_name); icon.set_pixel_size(12); hbox.append(&icon); let label = gtk::Label::new(Some(text)); hbox.append(&label); hbox } /// Create a badge showing integration status. #[allow(dead_code)] pub fn integration_badge(integrated: bool) -> gtk::Label { if integrated { status_badge("Integrated", "success") } else { status_badge("Not integrated", "neutral") } } /// Format bytes into a human-readable string. pub fn format_size(bytes: i64) -> String { humansize::format_size(bytes as u64, humansize::BINARY) } /// Build an app icon widget with letter-circle fallback. /// If the icon_path exists and is loadable, show the real icon. /// Otherwise, generate a colored circle with the first letter of the app name. pub fn app_icon(icon_path: Option<&str>, app_name: &str, pixel_size: i32) -> gtk::Widget { // Try to load from path if let Some(icon_path) = 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(pixel_size) .build(); image.set_paintable(Some(&texture)); return image.upcast(); } } } // Letter-circle fallback build_letter_icon(app_name, pixel_size) } /// Build a colored circle with the first letter of the name as a fallback icon. /// /// The color CSS classes (`.letter-icon-a` .. `.letter-icon-z`) are registered /// once via a shared CssProvider. This function only needs to pick the right /// class and set per-widget sizing, avoiding a new provider per icon. fn build_letter_icon(name: &str, size: i32) -> gtk::Widget { // Ensure the shared CSS for all 26 letter classes is loaded ensure_letter_icon_css(); let first_char = name .chars() .find(|c| c.is_alphanumeric()) .unwrap_or('?'); let letter_upper = first_char.to_uppercase().next().unwrap_or('?'); // Determine the CSS class: letter-icon-a .. letter-icon-z, or letter-icon-other let css_class = if first_char.is_ascii_alphabetic() { format!("letter-icon-{}", first_char.to_ascii_lowercase()) } else { "letter-icon-other".to_string() }; // Font size scales with the icon (40% of the circle diameter). let font_size_pt = size * 4 / 10; let label = gtk::Label::builder() .use_markup(true) .halign(gtk::Align::Center) .valign(gtk::Align::Center) .width_request(size) .height_request(size) .build(); // Use Pango markup to set the font size without a per-widget CssProvider. let markup = format!( "{}", font_size_pt, glib::markup_escape_text(&letter_upper.to_string()), ); label.set_markup(&markup); label.add_css_class(&css_class); label.upcast() } /// Create a copy-to-clipboard button that shows a toast on success. pub fn copy_button(text_to_copy: &str, toast_overlay: Option<&adw::ToastOverlay>) -> gtk::Button { let btn = gtk::Button::builder() .icon_name("edit-copy-symbolic") .tooltip_text("Copy to clipboard") .valign(gtk::Align::Center) .build(); btn.add_css_class("flat"); btn.update_property(&[gtk::accessible::Property::Label("Copy to clipboard")]); let text = text_to_copy.to_string(); let toast = toast_overlay.cloned(); btn.connect_clicked(move |button| { if let Some(display) = button.display().into() { let clipboard = gtk::gdk::Display::clipboard(&display); clipboard.set_text(&text); if let Some(ref overlay) = toast { overlay.add_toast(adw::Toast::new("Copied to clipboard")); } } }); btn } /// Show a detailed crash dialog when an AppImage fails to start. /// Includes a plain-text explanation, the full error output in a copyable text view, /// and a button to copy the full error to clipboard. pub fn show_crash_dialog( parent: &impl gtk::prelude::IsA, app_name: &str, exit_code: Option, stderr: &str, ) { let explanation = crash_explanation(stderr); let exit_str = exit_code .map(|c| c.to_string()) .unwrap_or_else(|| "unknown".to_string()); let body = format!("{}\n\nExit code: {}", explanation, exit_str); let dialog = adw::AlertDialog::builder() .heading(&format!("{} failed to start", app_name)) .body(&body) .close_response("close") .default_response("close") .build(); // Build the full text that gets copied let full_error = format!( "App: {}\nExit code: {}\n\n{}\n\nError output:\n{}", app_name, exit_str, explanation, stderr.trim(), ); // Extra content: scrollable text view with full stderr + copy button if !stderr.trim().is_empty() { let vbox = gtk::Box::builder() .orientation(gtk::Orientation::Vertical) .spacing(8) .build(); let heading = gtk::Label::builder() .label("Error output:") .xalign(0.0) .build(); heading.add_css_class("heading"); vbox.append(&heading); let text_view = gtk::TextView::builder() .editable(false) .cursor_visible(false) .monospace(true) .wrap_mode(gtk::WrapMode::WordChar) .top_margin(8) .bottom_margin(8) .left_margin(8) .right_margin(8) .build(); text_view.buffer().set_text(stderr.trim()); text_view.add_css_class("card"); let scrolled = gtk::ScrolledWindow::builder() .child(&text_view) .min_content_height(120) .max_content_height(300) .build(); vbox.append(&scrolled); let copy_btn = gtk::Button::builder() .label("Copy to clipboard") .halign(gtk::Align::Start) .build(); copy_btn.add_css_class("pill"); let full_error_copy = full_error.clone(); copy_btn.connect_clicked(move |btn| { let clipboard = btn.display().clipboard(); clipboard.set_text(&full_error_copy); btn.set_label("Copied!"); btn.set_sensitive(false); }); vbox.append(©_btn); dialog.set_extra_child(Some(&vbox)); } dialog.add_response("close", "Close"); dialog.present(Some(parent)); } /// Generate a plain-text explanation of why an app crashed based on stderr patterns. fn crash_explanation(stderr: &str) -> String { if stderr.contains("Could not find the Qt platform plugin") || stderr.contains("qt.qpa.plugin") { return "The app couldn't find a required display plugin. This usually means \ it needs a Qt library that isn't bundled inside the AppImage or \ available on your system.".to_string(); } if stderr.contains("cannot open shared object file") { if let Some(pos) = stderr.find("cannot open shared object file") { let before = &stderr[..pos]; if let Some(start) = before.rfind(": ") { let lib = before[start + 2..].trim(); if !lib.is_empty() { return format!( "The app needs a system library ({}) that isn't installed. \ You may be able to fix this by installing the missing package.", lib, ); } } } return "The app needs a system library that isn't installed on your system.".to_string(); } if stderr.contains("Segmentation fault") || stderr.contains("SIGSEGV") { return "The app crashed due to a memory error. This is usually a bug \ in the app itself, not something you can fix.".to_string(); } if stderr.contains("Permission denied") { return "The app was blocked from accessing something it needs. \ Check that the AppImage file has the right permissions.".to_string(); } if stderr.contains("fatal IO error") || stderr.contains("display connection") { return "The app lost its connection to the display server. This can happen \ with apps that don't fully support your display system.".to_string(); } if stderr.contains("FATAL:") || stderr.contains("Aborted") { return "The app hit a fatal error and had to stop. The error details \ below may help identify the cause.".to_string(); } if stderr.contains("Failed to initialize") { return "The app couldn't set itself up properly. It may need additional \ system components to run.".to_string(); } "The app exited immediately after starting. The error details below \ may help identify the cause.".to_string() } /// Create a screen-reader live region announcement. /// Inserts a hidden label with AccessibleRole::Alert into the given container, /// which causes AT-SPI to announce the text to screen readers. /// The label auto-removes after a short delay. #[allow(dead_code)] pub fn announce(container: &impl gtk::prelude::IsA, text: &str) { let label = gtk::Label::builder() .label(text) .visible(false) .accessible_role(gtk::AccessibleRole::Alert) .build(); label.update_property(&[gtk::accessible::Property::Label(text)]); if let Some(box_widget) = container.dynamic_cast_ref::() { box_widget.append(&label); label.set_visible(true); let label_clone = label.clone(); let box_clone = box_widget.clone(); glib::timeout_add_local_once(std::time::Duration::from_millis(500), move || { box_clone.remove(&label_clone); }); } }