// 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 &'static str>, } /// All 5 power action definitions. pub fn action_definitions() -> Vec { 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) -> 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 { 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 { 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 { 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> { 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>> = 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>>, 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>>, 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>>, 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>>) { 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>>, 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()); } }