220 lines
7.8 KiB
Rust
220 lines
7.8 KiB
Rust
use gtk::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
|
|
}
|
|
|
|
/// 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);
|
|
});
|
|
}
|
|
}
|