| 1 | //! User avatar loading and rendering |
| 2 | //! |
| 3 | //! Loads user avatars from standard locations and renders them as circles. |
| 4 | |
| 5 | use anyhow::Result; |
| 6 | use cairo::Context; |
| 7 | use image::{imageops, RgbaImage}; |
| 8 | use std::collections::HashMap; |
| 9 | use std::path::PathBuf; |
| 10 | |
| 11 | /// Avatar cache to avoid reloading images |
| 12 | pub struct AvatarCache { |
| 13 | avatars: HashMap<String, Option<RgbaImage>>, |
| 14 | size: u32, |
| 15 | } |
| 16 | |
| 17 | impl AvatarCache { |
| 18 | /// Create a new avatar cache with specified avatar size |
| 19 | pub fn new(size: u32) -> Self { |
| 20 | Self { |
| 21 | avatars: HashMap::new(), |
| 22 | size, |
| 23 | } |
| 24 | } |
| 25 | |
| 26 | /// Get or load avatar for a user |
| 27 | pub fn get(&mut self, username: &str, home: Option<&str>) -> Option<&RgbaImage> { |
| 28 | if !self.avatars.contains_key(username) { |
| 29 | let avatar = load_avatar(username, home, self.size); |
| 30 | self.avatars.insert(username.to_string(), avatar); |
| 31 | } |
| 32 | self.avatars.get(username).and_then(|a| a.as_ref()) |
| 33 | } |
| 34 | } |
| 35 | |
| 36 | /// Load avatar for a user from standard locations |
| 37 | fn load_avatar(username: &str, home: Option<&str>, size: u32) -> Option<RgbaImage> { |
| 38 | let paths = avatar_paths(username, home); |
| 39 | |
| 40 | for path in paths { |
| 41 | if path.exists() { |
| 42 | if let Ok(img) = image::open(&path) { |
| 43 | let rgba = img.to_rgba8(); |
| 44 | // Scale to size (square, will be masked to circle when rendering) |
| 45 | let scaled = scale_to_square(&rgba, size); |
| 46 | return Some(scaled); |
| 47 | } |
| 48 | } |
| 49 | } |
| 50 | |
| 51 | None |
| 52 | } |
| 53 | |
| 54 | /// Get possible avatar paths for a user |
| 55 | fn avatar_paths(username: &str, home: Option<&str>) -> Vec<PathBuf> { |
| 56 | let mut paths = Vec::new(); |
| 57 | |
| 58 | // User home directory paths |
| 59 | if let Some(home) = home { |
| 60 | let home = PathBuf::from(home); |
| 61 | // freedesktop standard |
| 62 | paths.push(home.join(".face")); |
| 63 | paths.push(home.join(".face.icon")); |
| 64 | // KDE |
| 65 | paths.push(home.join(".face.png")); |
| 66 | // GNOME/config |
| 67 | paths.push(home.join(".config/face.png")); |
| 68 | } |
| 69 | |
| 70 | // AccountsService (system-wide) |
| 71 | paths.push(PathBuf::from(format!( |
| 72 | "/var/lib/AccountsService/icons/{}", |
| 73 | username |
| 74 | ))); |
| 75 | |
| 76 | paths |
| 77 | } |
| 78 | |
| 79 | /// Scale image to a square of given size |
| 80 | fn scale_to_square(img: &RgbaImage, size: u32) -> RgbaImage { |
| 81 | let (w, h) = img.dimensions(); |
| 82 | |
| 83 | // Crop to square first (center crop) |
| 84 | let min_dim = w.min(h); |
| 85 | let x = (w - min_dim) / 2; |
| 86 | let y = (h - min_dim) / 2; |
| 87 | let cropped = imageops::crop_imm(img, x, y, min_dim, min_dim).to_image(); |
| 88 | |
| 89 | // Scale to target size |
| 90 | imageops::resize(&cropped, size, size, imageops::FilterType::Lanczos3) |
| 91 | } |
| 92 | |
| 93 | /// Render an avatar image as a circle |
| 94 | pub fn render_avatar_image( |
| 95 | ctx: &Context, |
| 96 | img: &RgbaImage, |
| 97 | x: f64, |
| 98 | y: f64, |
| 99 | size: f64, |
| 100 | ) -> Result<()> { |
| 101 | let (width, height) = img.dimensions(); |
| 102 | |
| 103 | // Convert RGBA to BGRA for Cairo |
| 104 | let bgra_data = rgba_to_bgra(img); |
| 105 | |
| 106 | let surface = cairo::ImageSurface::create_for_data( |
| 107 | bgra_data, |
| 108 | cairo::Format::ARgb32, |
| 109 | width as i32, |
| 110 | height as i32, |
| 111 | (width * 4) as i32, |
| 112 | )?; |
| 113 | |
| 114 | // Create circular clip |
| 115 | ctx.save()?; |
| 116 | ctx.new_path(); // Clear any previous path |
| 117 | let radius = size / 2.0; |
| 118 | ctx.arc(x + radius, y + radius, radius, 0.0, 2.0 * std::f64::consts::PI); |
| 119 | ctx.clip(); |
| 120 | |
| 121 | // Draw the image scaled to size |
| 122 | let scale = size / width as f64; |
| 123 | ctx.translate(x, y); |
| 124 | ctx.scale(scale, scale); |
| 125 | ctx.set_source_surface(&surface, 0.0, 0.0)?; |
| 126 | ctx.paint()?; |
| 127 | |
| 128 | ctx.restore()?; |
| 129 | |
| 130 | Ok(()) |
| 131 | } |
| 132 | |
| 133 | /// Render a fallback avatar with initials |
| 134 | pub fn render_avatar_fallback( |
| 135 | ctx: &Context, |
| 136 | pango_ctx: &pango::Context, |
| 137 | initials: &str, |
| 138 | x: f64, |
| 139 | y: f64, |
| 140 | size: f64, |
| 141 | hue: f64, // 0.0-1.0 for color variety |
| 142 | ) -> Result<()> { |
| 143 | let radius = size / 2.0; |
| 144 | let cx = x + radius; |
| 145 | let cy = y + radius; |
| 146 | |
| 147 | // Clear any previous path |
| 148 | ctx.new_path(); |
| 149 | |
| 150 | // Background circle with hue-based color |
| 151 | let (r, g, b) = hue_to_rgb(hue); |
| 152 | ctx.set_source_rgb(r * 0.6, g * 0.6, b * 0.6); |
| 153 | ctx.arc(cx, cy, radius, 0.0, 2.0 * std::f64::consts::PI); |
| 154 | ctx.fill()?; |
| 155 | |
| 156 | // Initials text |
| 157 | let mut font = pango::FontDescription::new(); |
| 158 | font.set_family("Sans"); |
| 159 | font.set_weight(pango::Weight::Bold); |
| 160 | font.set_size((size * 0.4) as i32 * pango::SCALE); |
| 161 | |
| 162 | let layout = pango::Layout::new(pango_ctx); |
| 163 | layout.set_font_description(Some(&font)); |
| 164 | layout.set_text(initials); |
| 165 | |
| 166 | let (text_w, text_h) = layout.pixel_size(); |
| 167 | |
| 168 | ctx.set_source_rgb(1.0, 1.0, 1.0); |
| 169 | ctx.move_to(cx - text_w as f64 / 2.0, cy - text_h as f64 / 2.0); |
| 170 | pangocairo::functions::show_layout(ctx, &layout); |
| 171 | |
| 172 | Ok(()) |
| 173 | } |
| 174 | |
| 175 | /// Render avatar with border (for selected state) |
| 176 | pub fn render_avatar_border(ctx: &Context, x: f64, y: f64, size: f64, selected: bool) -> Result<()> { |
| 177 | // Clear any previous path |
| 178 | ctx.new_path(); |
| 179 | |
| 180 | let radius = size / 2.0; |
| 181 | let cx = x + radius; |
| 182 | let cy = y + radius; |
| 183 | |
| 184 | if selected { |
| 185 | // Highlight ring |
| 186 | ctx.set_source_rgba(0.3, 0.6, 1.0, 1.0); |
| 187 | ctx.set_line_width(3.0); |
| 188 | ctx.arc(cx, cy, radius + 2.0, 0.0, 2.0 * std::f64::consts::PI); |
| 189 | ctx.stroke()?; |
| 190 | } |
| 191 | |
| 192 | Ok(()) |
| 193 | } |
| 194 | |
| 195 | /// Convert RGBA to BGRA for Cairo |
| 196 | fn rgba_to_bgra(img: &RgbaImage) -> Vec<u8> { |
| 197 | let (width, height) = img.dimensions(); |
| 198 | let mut data = vec![0u8; (width * height * 4) as usize]; |
| 199 | |
| 200 | for (y, row) in img.rows().enumerate() { |
| 201 | for (x, pixel) in row.enumerate() { |
| 202 | let offset = y * (width as usize * 4) + x * 4; |
| 203 | data[offset] = pixel[2]; // B |
| 204 | data[offset + 1] = pixel[1]; // G |
| 205 | data[offset + 2] = pixel[0]; // R |
| 206 | data[offset + 3] = pixel[3]; // A |
| 207 | } |
| 208 | } |
| 209 | |
| 210 | data |
| 211 | } |
| 212 | |
| 213 | /// Convert hue (0.0-1.0) to RGB for avatar colors |
| 214 | fn hue_to_rgb(h: f64) -> (f64, f64, f64) { |
| 215 | let h = h * 6.0; |
| 216 | let x = 1.0 - (h % 2.0 - 1.0).abs(); |
| 217 | |
| 218 | match h as i32 { |
| 219 | 0 => (1.0, x, 0.0), |
| 220 | 1 => (x, 1.0, 0.0), |
| 221 | 2 => (0.0, 1.0, x), |
| 222 | 3 => (0.0, x, 1.0), |
| 223 | 4 => (x, 0.0, 1.0), |
| 224 | _ => (1.0, 0.0, x), |
| 225 | } |
| 226 | } |
| 227 | |
| 228 | /// Get initials from a name |
| 229 | pub fn get_initials(name: &str) -> String { |
| 230 | let parts: Vec<&str> = name.split_whitespace().collect(); |
| 231 | match parts.len() { |
| 232 | 0 => "?".to_string(), |
| 233 | 1 => parts[0].chars().next().map(|c| c.to_uppercase().to_string()).unwrap_or("?".to_string()), |
| 234 | _ => { |
| 235 | let first = parts[0].chars().next().map(|c| c.to_uppercase().to_string()).unwrap_or_default(); |
| 236 | let last = parts.last().and_then(|s| s.chars().next()).map(|c| c.to_uppercase().to_string()).unwrap_or_default(); |
| 237 | format!("{}{}", first, last) |
| 238 | } |
| 239 | } |
| 240 | } |
| 241 | |
| 242 | /// Generate a consistent hue from a string (for fallback avatar colors) |
| 243 | pub fn string_to_hue(s: &str) -> f64 { |
| 244 | let hash: u32 = s.bytes().fold(0u32, |acc, b| acc.wrapping_mul(31).wrapping_add(b as u32)); |
| 245 | (hash % 360) as f64 / 360.0 |
| 246 | } |
| 247 |