Files
driftwood/src/ui/widgets.rs

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(&copy_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);
});
}
}