Rust · 6975 bytes Raw Blame History
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