Files
driftwood/src/ui/widgets.rs
lashman d493516efa Add launch crash detection with detailed error dialog, fix all warnings
Detect AppImages that crash immediately after spawning (within 1.5s) by
capturing stderr and using try_wait(). Show a full AlertDialog with a
plain-text explanation, scrollable error output, and a copy-to-clipboard
button. Covers Qt plugin errors, missing libraries, segfaults, permission
issues, and display connection failures.

Move launch operations to background threads in both the detail view and
context menu to avoid blocking the UI during the 1.5s crash detection
window.

Suppress all 57 compiler warnings across future-use modules (backup,
notification, report, watcher) and individual unused fields/variants in
other core modules.
2026-02-27 20:23:10 +02:00

355 lines
13 KiB
Rust

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<bool> = 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!(
"<span size='{}pt'>{}</span>",
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<gtk::Widget>,
app_name: &str,
exit_code: Option<i32>,
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(&copy_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<gtk::Widget>, 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::<gtk::Box>() {
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);
});
}
}