417 lines
15 KiB
Rust
417 lines
15 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.
|
|
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| {
|
|
let clipboard = button.display().clipboard();
|
|
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());
|
|
|
|
// 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(),
|
|
);
|
|
|
|
// Use adw::Dialog (not AlertDialog) for full size control
|
|
let dialog = adw::Dialog::builder()
|
|
.title(&format!("{} failed to start", app_name))
|
|
.content_width(900)
|
|
.content_height(550)
|
|
.build();
|
|
|
|
let toolbar = adw::ToolbarView::new();
|
|
let header = adw::HeaderBar::new();
|
|
toolbar.add_top_bar(&header);
|
|
|
|
let content = gtk::Box::builder()
|
|
.orientation(gtk::Orientation::Vertical)
|
|
.spacing(12)
|
|
.margin_top(16)
|
|
.margin_bottom(16)
|
|
.margin_start(24)
|
|
.margin_end(24)
|
|
.build();
|
|
|
|
// Explanation
|
|
let explanation_label = gtk::Label::builder()
|
|
.label(&format!("{}\n\nExit code: {}", explanation, exit_str))
|
|
.wrap(true)
|
|
.xalign(0.0)
|
|
.build();
|
|
content.append(&explanation_label);
|
|
|
|
// Error output
|
|
if !stderr.trim().is_empty() {
|
|
let heading = gtk::Label::builder()
|
|
.label("Error output:")
|
|
.xalign(0.0)
|
|
.build();
|
|
heading.add_css_class("heading");
|
|
content.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)
|
|
.vexpand(true)
|
|
.build();
|
|
content.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);
|
|
});
|
|
content.append(©_btn);
|
|
}
|
|
|
|
toolbar.set_content(Some(&content));
|
|
dialog.set_child(Some(&toolbar));
|
|
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()
|
|
}
|
|
|
|
/// Format a timestamp string as a human-readable relative time.
|
|
/// Accepts RFC3339, "YYYY-MM-DD HH:MM:SS", or ISO8601 formats.
|
|
pub fn relative_time(timestamp: &str) -> String {
|
|
let parsed = chrono::DateTime::parse_from_rfc3339(timestamp)
|
|
.map(|dt| dt.with_timezone(&chrono::Utc))
|
|
.or_else(|_| {
|
|
chrono::NaiveDateTime::parse_from_str(timestamp, "%Y-%m-%d %H:%M:%S")
|
|
.map(|ndt| ndt.and_utc())
|
|
});
|
|
|
|
let dt = match parsed {
|
|
Ok(dt) => dt,
|
|
Err(_) => return timestamp.to_string(),
|
|
};
|
|
|
|
let now = chrono::Utc::now();
|
|
let duration = now.signed_duration_since(dt);
|
|
|
|
if duration.num_seconds() < 0 {
|
|
return "Just now".to_string();
|
|
}
|
|
|
|
if duration.num_seconds() < 60 {
|
|
"Just now".to_string()
|
|
} else if duration.num_minutes() < 60 {
|
|
let m = duration.num_minutes();
|
|
format!("{} min ago", m)
|
|
} else if duration.num_hours() < 24 {
|
|
let h = duration.num_hours();
|
|
format!("{} hr ago", h)
|
|
} else if duration.num_days() == 1 {
|
|
"Yesterday".to_string()
|
|
} else if duration.num_days() < 7 {
|
|
format!("{} days ago", duration.num_days())
|
|
} else if duration.num_weeks() == 1 {
|
|
"Last week".to_string()
|
|
} else if duration.num_days() < 30 {
|
|
format!("{} weeks ago", duration.num_weeks())
|
|
} else if duration.num_days() < 60 {
|
|
"Last month".to_string()
|
|
} else {
|
|
format!("{} months ago", duration.num_days() / 30)
|
|
}
|
|
}
|
|
|
|
/// 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.
|
|
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)]);
|
|
|
|
// Try to find a suitable Box container to attach the label to
|
|
let target_box = container.dynamic_cast_ref::<gtk::Box>().cloned()
|
|
.or_else(|| {
|
|
// For Stack widgets, use the visible child if it's a Box
|
|
container.dynamic_cast_ref::<gtk::Stack>()
|
|
.and_then(|s| s.visible_child())
|
|
.and_then(|c| c.downcast::<gtk::Box>().ok())
|
|
});
|
|
|
|
if let Some(box_widget) = target_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);
|
|
});
|
|
}
|
|
}
|