First launch with blur blurs and saves to ~/.cache/moonset/. Subsequent starts load the cached PNG directly (~9x faster). Cache invalidates when wallpaper path, size, mtime, or sigma changes.
799 lines
25 KiB
Rust
799 lines
25 KiB
Rust
// ABOUTME: UI module for the power menu — action buttons, confirmation flow, wallpaper windows.
|
|
// ABOUTME: Defines PanelWindow (primary monitor) and WallpaperWindow (secondary monitors).
|
|
|
|
use gdk4 as gdk;
|
|
use gdk_pixbuf::Pixbuf;
|
|
use glib::clone;
|
|
use gtk4::prelude::*;
|
|
use gtk4::{self as gtk, gio};
|
|
use image::imageops;
|
|
use std::cell::RefCell;
|
|
use std::fs;
|
|
use std::io::Write;
|
|
use std::os::unix::fs::OpenOptionsExt;
|
|
use std::path::{Path, PathBuf};
|
|
use std::rc::Rc;
|
|
use std::time::SystemTime;
|
|
|
|
use crate::i18n::{load_strings, Strings};
|
|
use crate::power::{self, PowerError};
|
|
use crate::users;
|
|
|
|
const AVATAR_SIZE: i32 = 128;
|
|
|
|
/// Definition for a single power action button.
|
|
#[derive(Clone)]
|
|
pub struct ActionDef {
|
|
pub name: &'static str,
|
|
pub icon_name: &'static str,
|
|
pub needs_confirm: bool,
|
|
pub action_fn: fn() -> Result<(), PowerError>,
|
|
pub label_attr: fn(&Strings) -> &'static str,
|
|
pub error_attr: fn(&Strings) -> &'static str,
|
|
pub confirm_attr: Option<fn(&Strings) -> &'static str>,
|
|
}
|
|
|
|
/// All 5 power action definitions.
|
|
pub fn action_definitions() -> Vec<ActionDef> {
|
|
vec![
|
|
ActionDef {
|
|
name: "lock",
|
|
icon_name: "system-lock-screen-symbolic",
|
|
needs_confirm: false,
|
|
action_fn: power::lock,
|
|
label_attr: |s| s.lock_label,
|
|
error_attr: |s| s.lock_failed,
|
|
confirm_attr: None,
|
|
},
|
|
ActionDef {
|
|
name: "logout",
|
|
icon_name: "system-log-out-symbolic",
|
|
needs_confirm: true,
|
|
action_fn: power::logout,
|
|
label_attr: |s| s.logout_label,
|
|
error_attr: |s| s.logout_failed,
|
|
confirm_attr: Some(|s| s.logout_confirm),
|
|
},
|
|
ActionDef {
|
|
name: "hibernate",
|
|
icon_name: "system-hibernate-symbolic",
|
|
needs_confirm: true,
|
|
action_fn: power::hibernate,
|
|
label_attr: |s| s.hibernate_label,
|
|
error_attr: |s| s.hibernate_failed,
|
|
confirm_attr: Some(|s| s.hibernate_confirm),
|
|
},
|
|
ActionDef {
|
|
name: "reboot",
|
|
icon_name: "system-reboot-symbolic",
|
|
needs_confirm: true,
|
|
action_fn: power::reboot,
|
|
label_attr: |s| s.reboot_label,
|
|
error_attr: |s| s.reboot_failed,
|
|
confirm_attr: Some(|s| s.reboot_confirm),
|
|
},
|
|
ActionDef {
|
|
name: "shutdown",
|
|
icon_name: "system-shutdown-symbolic",
|
|
needs_confirm: true,
|
|
action_fn: power::shutdown,
|
|
label_attr: |s| s.shutdown_label,
|
|
error_attr: |s| s.shutdown_failed,
|
|
confirm_attr: Some(|s| s.shutdown_confirm),
|
|
},
|
|
]
|
|
}
|
|
|
|
/// Load the wallpaper as a texture once, for sharing across all windows.
|
|
/// When `blur_radius` is `Some(sigma)` with sigma > 0, a Gaussian blur is applied.
|
|
pub fn load_background_texture(bg_path: &Path, blur_radius: Option<f32>) -> gdk::Texture {
|
|
let fallback = format!("{}/wallpaper.jpg", crate::GRESOURCE_PREFIX);
|
|
|
|
let texture = if bg_path.starts_with(crate::GRESOURCE_PREFIX) {
|
|
let resource_path = bg_path.to_str().unwrap_or(&fallback);
|
|
gdk::Texture::from_resource(resource_path)
|
|
} else {
|
|
let file = gio::File::for_path(bg_path);
|
|
gdk::Texture::from_file(&file).unwrap_or_else(|_| {
|
|
gdk::Texture::from_resource(&fallback)
|
|
})
|
|
};
|
|
|
|
match blur_radius {
|
|
Some(sigma) if sigma > 0.0 => load_blurred_with_cache(bg_path, &texture, sigma),
|
|
_ => texture,
|
|
}
|
|
}
|
|
|
|
// -- Blur cache ----------------------------------------------------------------
|
|
|
|
const CACHE_PNG: &str = "blur-cache.png";
|
|
const CACHE_META: &str = "blur-cache.meta";
|
|
|
|
fn blur_cache_dir() -> Option<PathBuf> {
|
|
dirs::cache_dir().map(|d| d.join("moonset"))
|
|
}
|
|
|
|
/// Build the cache key string for the current wallpaper + sigma.
|
|
fn build_cache_meta(bg_path: &Path, sigma: f32) -> Option<String> {
|
|
if bg_path.starts_with("/dev/moonarch/") {
|
|
let binary = std::env::current_exe().ok()?;
|
|
let binary_mtime = fs::metadata(&binary)
|
|
.ok()?
|
|
.modified()
|
|
.ok()?
|
|
.duration_since(SystemTime::UNIX_EPOCH)
|
|
.ok()?
|
|
.as_secs();
|
|
Some(format!(
|
|
"path={}\nbinary_mtime={}\nsigma={}\n",
|
|
bg_path.display(), binary_mtime, sigma,
|
|
))
|
|
} else {
|
|
let meta = fs::metadata(bg_path).ok()?;
|
|
let mtime = meta
|
|
.modified()
|
|
.ok()?
|
|
.duration_since(SystemTime::UNIX_EPOCH)
|
|
.ok()?
|
|
.as_secs();
|
|
Some(format!(
|
|
"path={}\nsize={}\nmtime={}\nsigma={}\n",
|
|
bg_path.display(), meta.len(), mtime, sigma,
|
|
))
|
|
}
|
|
}
|
|
|
|
/// Try to load a cached blurred texture if the cache key matches.
|
|
fn load_cached_blur(cache_dir: &Path, expected_meta: &str) -> Option<gdk::Texture> {
|
|
let stored_meta = fs::read_to_string(cache_dir.join(CACHE_META)).ok()?;
|
|
if stored_meta != expected_meta {
|
|
log::debug!("Blur cache meta mismatch, will re-blur");
|
|
return None;
|
|
}
|
|
let file = gio::File::for_path(cache_dir.join(CACHE_PNG));
|
|
match gdk::Texture::from_file(&file) {
|
|
Ok(texture) => {
|
|
log::debug!("Loaded blurred wallpaper from cache");
|
|
Some(texture)
|
|
}
|
|
Err(e) => {
|
|
log::debug!("Failed to load cached blur PNG: {e}");
|
|
None
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Save a blurred texture to the cache directory.
|
|
fn save_blur_cache(cache_dir: &Path, texture: &gdk::Texture, meta: &str) {
|
|
if let Err(e) = save_blur_cache_inner(cache_dir, texture, meta) {
|
|
log::debug!("Failed to save blur cache: {e}");
|
|
}
|
|
}
|
|
|
|
fn save_blur_cache_inner(
|
|
cache_dir: &Path,
|
|
texture: &gdk::Texture,
|
|
meta: &str,
|
|
) -> Result<(), Box<dyn std::error::Error>> {
|
|
fs::create_dir_all(cache_dir)?;
|
|
|
|
let png_bytes = texture.save_to_png_bytes();
|
|
|
|
let mut f = fs::OpenOptions::new()
|
|
.create(true).write(true).truncate(true).mode(0o600)
|
|
.open(cache_dir.join(CACHE_PNG))?;
|
|
f.write_all(&png_bytes)?;
|
|
|
|
// Meta last — incomplete cache is treated as a miss on next start
|
|
let mut f = fs::OpenOptions::new()
|
|
.create(true).write(true).truncate(true).mode(0o600)
|
|
.open(cache_dir.join(CACHE_META))?;
|
|
f.write_all(meta.as_bytes())?;
|
|
|
|
log::debug!("Saved blur cache to {}", cache_dir.display());
|
|
Ok(())
|
|
}
|
|
|
|
/// Load blurred texture, using disk cache when available.
|
|
fn load_blurred_with_cache(bg_path: &Path, texture: &gdk::Texture, sigma: f32) -> gdk::Texture {
|
|
if let Some(cache_dir) = blur_cache_dir() {
|
|
if let Some(meta) = build_cache_meta(bg_path, sigma) {
|
|
if let Some(cached) = load_cached_blur(&cache_dir, &meta) {
|
|
return cached;
|
|
}
|
|
let blurred = apply_blur(texture, sigma);
|
|
save_blur_cache(&cache_dir, &blurred, &meta);
|
|
return blurred;
|
|
}
|
|
}
|
|
apply_blur(texture, sigma)
|
|
}
|
|
|
|
// -- Blur implementation -------------------------------------------------------
|
|
|
|
/// Apply Gaussian blur to a texture and return a blurred texture.
|
|
fn apply_blur(texture: &gdk::Texture, sigma: f32) -> gdk::Texture {
|
|
let width = texture.width() as u32;
|
|
let height = texture.height() as u32;
|
|
let stride = width as usize * 4;
|
|
let mut pixel_data = vec![0u8; stride * height as usize];
|
|
texture.download(&mut pixel_data, stride);
|
|
|
|
let img = image::RgbaImage::from_raw(width, height, pixel_data)
|
|
.expect("pixel buffer size matches texture dimensions");
|
|
let blurred = imageops::blur(&image::DynamicImage::ImageRgba8(img), sigma);
|
|
|
|
let bytes = glib::Bytes::from(blurred.as_raw());
|
|
let mem_texture = gdk::MemoryTexture::new(
|
|
width as i32,
|
|
height as i32,
|
|
gdk::MemoryFormat::B8g8r8a8Premultiplied,
|
|
&bytes,
|
|
stride,
|
|
);
|
|
mem_texture.upcast()
|
|
}
|
|
|
|
/// Create a wallpaper-only window for secondary monitors.
|
|
pub fn create_wallpaper_window(texture: &gdk::Texture, app: >k::Application) -> gtk::ApplicationWindow {
|
|
let window = gtk::ApplicationWindow::builder()
|
|
.application(app)
|
|
.build();
|
|
window.add_css_class("wallpaper");
|
|
|
|
let background = create_background_picture(texture);
|
|
window.set_child(Some(&background));
|
|
|
|
// Fade-in on map
|
|
window.connect_map(|w| {
|
|
glib::idle_add_local_once(clone!(
|
|
#[weak]
|
|
w,
|
|
move || {
|
|
w.add_css_class("visible");
|
|
}
|
|
));
|
|
});
|
|
|
|
window
|
|
}
|
|
|
|
/// Create the main panel window with action buttons and confirm flow.
|
|
pub fn create_panel_window(texture: &gdk::Texture, app: >k::Application) -> gtk::ApplicationWindow {
|
|
let window = gtk::ApplicationWindow::builder()
|
|
.application(app)
|
|
.build();
|
|
window.add_css_class("panel");
|
|
|
|
let strings = load_strings(None);
|
|
let user = users::get_current_user().unwrap_or_else(|| users::User {
|
|
username: "user".to_string(),
|
|
display_name: "User".to_string(),
|
|
home: dirs::home_dir().unwrap_or_default(),
|
|
uid: u32::MAX,
|
|
});
|
|
|
|
// State for confirm box
|
|
let confirm_box: Rc<RefCell<Option<gtk::Box>>> = Rc::new(RefCell::new(None));
|
|
|
|
// Main overlay for background + centered content
|
|
let overlay = gtk::Overlay::new();
|
|
window.set_child(Some(&overlay));
|
|
|
|
// Background wallpaper
|
|
let background = create_background_picture(texture);
|
|
overlay.set_child(Some(&background));
|
|
|
|
// Click on background dismisses the menu
|
|
let click_controller = gtk::GestureClick::new();
|
|
click_controller.connect_released(clone!(
|
|
#[weak]
|
|
app,
|
|
move |_, _, _, _| {
|
|
app.quit();
|
|
}
|
|
));
|
|
background.add_controller(click_controller);
|
|
|
|
// Centered content box
|
|
let content_box = gtk::Box::new(gtk::Orientation::Vertical, 0);
|
|
content_box.set_halign(gtk::Align::Center);
|
|
content_box.set_valign(gtk::Align::Center);
|
|
overlay.add_overlay(&content_box);
|
|
|
|
// Avatar
|
|
let avatar_frame = gtk::Box::new(gtk::Orientation::Horizontal, 0);
|
|
avatar_frame.set_size_request(AVATAR_SIZE, AVATAR_SIZE);
|
|
avatar_frame.set_halign(gtk::Align::Center);
|
|
avatar_frame.set_overflow(gtk::Overflow::Hidden);
|
|
avatar_frame.add_css_class("avatar");
|
|
let avatar_image = gtk::Image::new();
|
|
avatar_image.set_pixel_size(AVATAR_SIZE);
|
|
avatar_frame.append(&avatar_image);
|
|
content_box.append(&avatar_frame);
|
|
|
|
// Load avatar (file-based avatars load asynchronously)
|
|
load_avatar_async(&avatar_image, &window, &user);
|
|
|
|
// Username label
|
|
let username_label = gtk::Label::new(Some(&user.display_name));
|
|
username_label.add_css_class("username-label");
|
|
content_box.append(&username_label);
|
|
|
|
// Action buttons row
|
|
let button_box = gtk::Box::new(gtk::Orientation::Horizontal, 24);
|
|
button_box.set_halign(gtk::Align::Center);
|
|
content_box.append(&button_box);
|
|
|
|
// Confirmation area (below buttons)
|
|
let confirm_area = gtk::Box::new(gtk::Orientation::Vertical, 0);
|
|
confirm_area.set_halign(gtk::Align::Center);
|
|
confirm_area.set_margin_top(24);
|
|
content_box.append(&confirm_area);
|
|
|
|
// Error label
|
|
let error_label = gtk::Label::new(None);
|
|
error_label.add_css_class("error-label");
|
|
error_label.set_visible(false);
|
|
error_label.set_margin_top(16);
|
|
content_box.append(&error_label);
|
|
|
|
// Create action buttons
|
|
for action_def in action_definitions() {
|
|
let button = create_action_button(
|
|
&action_def,
|
|
strings,
|
|
app,
|
|
&confirm_area,
|
|
&confirm_box,
|
|
&error_label,
|
|
);
|
|
button_box.append(&button);
|
|
}
|
|
|
|
// Keyboard handling — Escape dismisses
|
|
let key_controller = gtk::EventControllerKey::new();
|
|
key_controller.connect_key_pressed(clone!(
|
|
#[weak]
|
|
app,
|
|
#[upgrade_or]
|
|
glib::Propagation::Proceed,
|
|
move |_, keyval, _, _| {
|
|
if keyval == gdk::Key::Escape {
|
|
app.quit();
|
|
glib::Propagation::Stop
|
|
} else {
|
|
glib::Propagation::Proceed
|
|
}
|
|
}
|
|
));
|
|
window.add_controller(key_controller);
|
|
|
|
// Focus first button + fade-in on map
|
|
let button_box_clone = button_box.clone();
|
|
window.connect_map(move |w| {
|
|
let w = w.clone();
|
|
let bb = button_box_clone.clone();
|
|
glib::idle_add_local_once(move || {
|
|
w.add_css_class("visible");
|
|
if let Some(first) = bb.first_child() {
|
|
first.grab_focus();
|
|
}
|
|
});
|
|
});
|
|
|
|
window
|
|
}
|
|
|
|
/// Create a Picture widget for the wallpaper background from a shared texture.
|
|
fn create_background_picture(texture: &gdk::Texture) -> gtk::Picture {
|
|
let background = gtk::Picture::for_paintable(texture);
|
|
background.set_content_fit(gtk::ContentFit::Cover);
|
|
background.set_hexpand(true);
|
|
background.set_vexpand(true);
|
|
background
|
|
}
|
|
|
|
/// Create a single action button with icon and label.
|
|
fn create_action_button(
|
|
action_def: &ActionDef,
|
|
strings: &'static Strings,
|
|
app: >k::Application,
|
|
confirm_area: >k::Box,
|
|
confirm_box: &Rc<RefCell<Option<gtk::Box>>>,
|
|
error_label: >k::Label,
|
|
) -> gtk::Button {
|
|
let button_content = gtk::Box::new(gtk::Orientation::Vertical, 4);
|
|
button_content.set_halign(gtk::Align::Center);
|
|
button_content.set_valign(gtk::Align::Center);
|
|
|
|
// Look up the 22px icon variant, render at 64px (matches moonlock)
|
|
let icon = load_scaled_icon(action_def.icon_name);
|
|
icon.add_css_class("action-icon");
|
|
button_content.append(&icon);
|
|
|
|
let label_text = (action_def.label_attr)(strings);
|
|
let label = gtk::Label::new(Some(label_text));
|
|
label.add_css_class("action-label");
|
|
button_content.append(&label);
|
|
|
|
let button = gtk::Button::new();
|
|
button.set_child(Some(&button_content));
|
|
button.add_css_class("action-button");
|
|
|
|
let action_def = action_def.clone();
|
|
button.connect_clicked(clone!(
|
|
#[weak]
|
|
app,
|
|
#[weak]
|
|
confirm_area,
|
|
#[strong]
|
|
confirm_box,
|
|
#[weak]
|
|
error_label,
|
|
move |_| {
|
|
on_action_clicked(
|
|
&action_def,
|
|
strings,
|
|
&app,
|
|
&confirm_area,
|
|
&confirm_box,
|
|
&error_label,
|
|
);
|
|
}
|
|
));
|
|
|
|
button
|
|
}
|
|
|
|
/// Load a symbolic icon using native GTK4 rendering at the target size.
|
|
fn load_scaled_icon(icon_name: &str) -> gtk::Image {
|
|
let icon = gtk::Image::from_icon_name(icon_name);
|
|
icon.set_pixel_size(64);
|
|
icon
|
|
}
|
|
|
|
/// Handle an action button click.
|
|
fn on_action_clicked(
|
|
action_def: &ActionDef,
|
|
strings: &'static Strings,
|
|
app: >k::Application,
|
|
confirm_area: >k::Box,
|
|
confirm_box: &Rc<RefCell<Option<gtk::Box>>>,
|
|
error_label: >k::Label,
|
|
) {
|
|
dismiss_confirm(confirm_area, confirm_box);
|
|
error_label.set_visible(false);
|
|
|
|
if !action_def.needs_confirm {
|
|
execute_action(action_def, strings, app, confirm_area, confirm_box, error_label);
|
|
return;
|
|
}
|
|
|
|
show_confirm(action_def, strings, app, confirm_area, confirm_box, error_label);
|
|
}
|
|
|
|
/// Show inline confirmation below the action buttons.
|
|
fn show_confirm(
|
|
action_def: &ActionDef,
|
|
strings: &'static Strings,
|
|
app: >k::Application,
|
|
confirm_area: >k::Box,
|
|
confirm_box: &Rc<RefCell<Option<gtk::Box>>>,
|
|
error_label: >k::Label,
|
|
) {
|
|
let new_box = gtk::Box::new(gtk::Orientation::Vertical, 8);
|
|
new_box.set_halign(gtk::Align::Center);
|
|
new_box.add_css_class("confirm-box");
|
|
|
|
if let Some(prompt_fn) = action_def.confirm_attr {
|
|
let prompt_text = prompt_fn(strings);
|
|
let confirm_label = gtk::Label::new(Some(prompt_text));
|
|
confirm_label.add_css_class("confirm-label");
|
|
new_box.append(&confirm_label);
|
|
}
|
|
|
|
let button_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);
|
|
button_row.set_halign(gtk::Align::Center);
|
|
|
|
let yes_btn = gtk::Button::with_label(strings.confirm_yes);
|
|
yes_btn.add_css_class("confirm-yes");
|
|
let action_def_clone = action_def.clone();
|
|
yes_btn.connect_clicked(clone!(
|
|
#[weak]
|
|
app,
|
|
#[weak]
|
|
confirm_area,
|
|
#[strong]
|
|
confirm_box,
|
|
#[weak]
|
|
error_label,
|
|
move |_| {
|
|
execute_action(
|
|
&action_def_clone,
|
|
strings,
|
|
&app,
|
|
&confirm_area,
|
|
&confirm_box,
|
|
&error_label,
|
|
);
|
|
}
|
|
));
|
|
button_row.append(&yes_btn);
|
|
|
|
let no_btn = gtk::Button::with_label(strings.confirm_no);
|
|
no_btn.add_css_class("confirm-no");
|
|
no_btn.connect_clicked(clone!(
|
|
#[weak]
|
|
confirm_area,
|
|
#[strong]
|
|
confirm_box,
|
|
move |_| {
|
|
dismiss_confirm(&confirm_area, &confirm_box);
|
|
}
|
|
));
|
|
button_row.append(&no_btn);
|
|
|
|
new_box.append(&button_row);
|
|
confirm_area.append(&new_box);
|
|
|
|
*confirm_box.borrow_mut() = Some(new_box);
|
|
|
|
// Focus the "No" button — safe default for keyboard navigation
|
|
no_btn.grab_focus();
|
|
}
|
|
|
|
/// Remove the confirmation prompt.
|
|
fn dismiss_confirm(confirm_area: >k::Box, confirm_box: &Rc<RefCell<Option<gtk::Box>>>) {
|
|
if let Some(box_widget) = confirm_box.borrow_mut().take() {
|
|
confirm_area.remove(&box_widget);
|
|
}
|
|
}
|
|
|
|
/// Execute a power action in a background thread.
|
|
fn execute_action(
|
|
action_def: &ActionDef,
|
|
strings: &'static Strings,
|
|
app: >k::Application,
|
|
confirm_area: >k::Box,
|
|
confirm_box: &Rc<RefCell<Option<gtk::Box>>>,
|
|
error_label: >k::Label,
|
|
) {
|
|
dismiss_confirm(confirm_area, confirm_box);
|
|
|
|
let action_fn = action_def.action_fn;
|
|
let action_name = action_def.name;
|
|
let error_message = (action_def.error_attr)(strings).to_string();
|
|
|
|
// Use glib::spawn_future_local + gio::spawn_blocking to avoid Send issues
|
|
// with GTK objects. The blocking closure runs on a thread pool, the result
|
|
// is handled back on the main thread.
|
|
glib::spawn_future_local(clone!(
|
|
#[weak]
|
|
app,
|
|
#[weak]
|
|
error_label,
|
|
async move {
|
|
let result = gio::spawn_blocking(move || action_fn()).await;
|
|
|
|
match result {
|
|
Ok(Ok(())) => {
|
|
// Lock action: quit after successful execution
|
|
if action_name == "lock" {
|
|
app.quit();
|
|
}
|
|
}
|
|
Ok(Err(e)) => {
|
|
log::error!("Power action '{}' failed: {}", action_name, e);
|
|
error_label.set_text(&error_message);
|
|
error_label.set_visible(true);
|
|
}
|
|
Err(_) => {
|
|
log::error!("Power action '{}' panicked", action_name);
|
|
error_label.set_text(&error_message);
|
|
error_label.set_visible(true);
|
|
}
|
|
}
|
|
}
|
|
));
|
|
}
|
|
|
|
/// Load the avatar asynchronously. File-based avatars are decoded off the UI thread.
|
|
fn load_avatar_async(image: >k::Image, window: >k::ApplicationWindow, user: &users::User) {
|
|
let avatar_path = users::get_avatar_path(&user.home, Some(&user.username));
|
|
|
|
match avatar_path {
|
|
Some(path) => {
|
|
// File-based avatar: load and scale in background thread
|
|
glib::spawn_future_local(clone!(
|
|
#[weak]
|
|
image,
|
|
async move {
|
|
let result = gio::spawn_blocking(move || {
|
|
Pixbuf::from_file_at_scale(
|
|
path.to_str().unwrap_or(""),
|
|
AVATAR_SIZE,
|
|
AVATAR_SIZE,
|
|
true,
|
|
)
|
|
.ok()
|
|
.map(|pb| gdk::Texture::for_pixbuf(&pb))
|
|
})
|
|
.await;
|
|
|
|
match result {
|
|
Ok(Some(texture)) => image.set_paintable(Some(&texture)),
|
|
_ => image.set_icon_name(Some("avatar-default-symbolic")),
|
|
}
|
|
}
|
|
));
|
|
}
|
|
None => {
|
|
// Default SVG avatar: needs widget color, keep synchronous
|
|
set_default_avatar(image, window);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Load the default avatar SVG from GResources, tinted with the foreground color.
|
|
fn set_default_avatar(image: >k::Image, window: >k::ApplicationWindow) {
|
|
let resource_path = users::get_default_avatar_path();
|
|
|
|
// Try loading from GResource
|
|
if let Ok(bytes) = gio::resources_lookup_data(&resource_path, gio::ResourceLookupFlags::NONE) {
|
|
let svg_text = String::from_utf8_lossy(&bytes);
|
|
|
|
// Get foreground color from widget's style context
|
|
let rgba = window.color();
|
|
let fg_color = format!(
|
|
"#{:02x}{:02x}{:02x}",
|
|
(rgba.red() * 255.0) as u8,
|
|
(rgba.green() * 255.0) as u8,
|
|
(rgba.blue() * 255.0) as u8,
|
|
);
|
|
let tinted = svg_text.replace("#PLACEHOLDER", &fg_color);
|
|
let svg_bytes = tinted.as_bytes();
|
|
|
|
if let Ok(loader) = gdk_pixbuf::PixbufLoader::with_type("svg") {
|
|
loader.set_size(AVATAR_SIZE, AVATAR_SIZE);
|
|
if loader.write(svg_bytes).is_ok() {
|
|
let _ = loader.close();
|
|
if let Some(pixbuf) = loader.pixbuf() {
|
|
let texture = gdk::Texture::for_pixbuf(&pixbuf);
|
|
image.set_paintable(Some(&texture));
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Fallback
|
|
image.set_icon_name(Some("avatar-default-symbolic"));
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn action_definitions_count() {
|
|
let defs = action_definitions();
|
|
assert_eq!(defs.len(), 5);
|
|
}
|
|
|
|
#[test]
|
|
fn action_definitions_names() {
|
|
let defs = action_definitions();
|
|
let names: Vec<&str> = defs.iter().map(|d| d.name).collect();
|
|
assert_eq!(names, vec!["lock", "logout", "hibernate", "reboot", "shutdown"]);
|
|
}
|
|
|
|
#[test]
|
|
fn action_definitions_icons() {
|
|
let defs = action_definitions();
|
|
assert_eq!(defs[0].icon_name, "system-lock-screen-symbolic");
|
|
assert_eq!(defs[1].icon_name, "system-log-out-symbolic");
|
|
assert_eq!(defs[2].icon_name, "system-hibernate-symbolic");
|
|
assert_eq!(defs[3].icon_name, "system-reboot-symbolic");
|
|
assert_eq!(defs[4].icon_name, "system-shutdown-symbolic");
|
|
}
|
|
|
|
#[test]
|
|
fn lock_does_not_need_confirm() {
|
|
let defs = action_definitions();
|
|
assert!(!defs[0].needs_confirm);
|
|
assert!(defs[0].confirm_attr.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn other_actions_need_confirm() {
|
|
let defs = action_definitions();
|
|
for def in &defs[1..] {
|
|
assert!(def.needs_confirm, "{} should need confirm", def.name);
|
|
assert!(def.confirm_attr.is_some(), "{} should have confirm_attr", def.name);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn action_labels_from_strings() {
|
|
let strings = load_strings(Some("en"));
|
|
let defs = action_definitions();
|
|
assert_eq!((defs[0].label_attr)(strings), "Lock");
|
|
assert_eq!((defs[4].label_attr)(strings), "Shut down");
|
|
}
|
|
|
|
#[test]
|
|
fn action_error_messages_from_strings() {
|
|
let strings = load_strings(Some("en"));
|
|
let defs = action_definitions();
|
|
assert_eq!((defs[0].error_attr)(strings), "Lock failed");
|
|
assert_eq!((defs[4].error_attr)(strings), "Shutdown failed");
|
|
}
|
|
|
|
#[test]
|
|
fn action_confirm_prompts_from_strings() {
|
|
let strings = load_strings(Some("de"));
|
|
let defs = action_definitions();
|
|
let confirm_fn = defs[1].confirm_attr.unwrap();
|
|
assert_eq!(confirm_fn(strings), "Wirklich abmelden?");
|
|
}
|
|
|
|
// -- Blur cache tests --
|
|
|
|
#[test]
|
|
fn build_cache_meta_for_file() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let file = dir.path().join("wallpaper.jpg");
|
|
fs::write(&file, b"fake image").unwrap();
|
|
let meta = build_cache_meta(&file, 20.0);
|
|
assert!(meta.is_some());
|
|
let meta = meta.unwrap();
|
|
assert!(meta.contains("path="));
|
|
assert!(meta.contains("size=10"));
|
|
assert!(meta.contains("sigma=20"));
|
|
assert!(meta.contains("mtime="));
|
|
}
|
|
|
|
#[test]
|
|
fn build_cache_meta_for_gresource() {
|
|
let path = Path::new("/dev/moonarch/moonset/wallpaper.jpg");
|
|
let meta = build_cache_meta(path, 15.0);
|
|
assert!(meta.is_some());
|
|
let meta = meta.unwrap();
|
|
assert!(meta.contains("binary_mtime="));
|
|
assert!(meta.contains("sigma=15"));
|
|
assert!(!meta.contains("size="));
|
|
}
|
|
|
|
#[test]
|
|
fn build_cache_meta_missing_file() {
|
|
let meta = build_cache_meta(Path::new("/nonexistent/wallpaper.jpg"), 20.0);
|
|
assert!(meta.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn cache_meta_mismatch_returns_none() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
fs::write(
|
|
dir.path().join(CACHE_META),
|
|
"path=/old.jpg\nsize=100\nmtime=1\nsigma=20\n",
|
|
).unwrap();
|
|
let result = load_cached_blur(
|
|
dir.path(),
|
|
"path=/new.jpg\nsize=200\nmtime=2\nsigma=20\n",
|
|
);
|
|
assert!(result.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn cache_missing_meta_returns_none() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let result = load_cached_blur(
|
|
dir.path(),
|
|
"path=/any.jpg\nsize=1\nmtime=1\nsigma=20\n",
|
|
);
|
|
assert!(result.is_none());
|
|
}
|
|
}
|