| 1 | //! User list widget with avatars |
| 2 | //! |
| 3 | //! Displays available users with circular avatars for quick selection. |
| 4 | |
| 5 | use crate::avatar::{ |
| 6 | get_initials, render_avatar_border, render_avatar_fallback, render_avatar_image, |
| 7 | string_to_hue, AvatarCache, |
| 8 | }; |
| 9 | use crate::theme::Theme; |
| 10 | use anyhow::Result; |
| 11 | use cairo::Context; |
| 12 | use gardm_ipc::UserInfo; |
| 13 | use pango::{FontDescription, Layout}; |
| 14 | |
| 15 | /// User list widget showing avatars in a horizontal row |
| 16 | pub struct UserList { |
| 17 | users: Vec<UserInfo>, |
| 18 | selected_index: Option<usize>, |
| 19 | hovered_index: Option<usize>, |
| 20 | avatar_cache: AvatarCache, |
| 21 | |
| 22 | // Layout |
| 23 | x: f64, |
| 24 | y: f64, |
| 25 | avatar_size: f64, |
| 26 | spacing: f64, |
| 27 | } |
| 28 | |
| 29 | impl UserList { |
| 30 | /// Create a new user list centered above the login form |
| 31 | /// center_x, center_y is the center point of the primary monitor |
| 32 | pub fn new(users: Vec<UserInfo>, center_x: f64, center_y: f64) -> Self { |
| 33 | let avatar_size = 64.0; |
| 34 | let spacing = 24.0; |
| 35 | let total_width = if users.is_empty() { |
| 36 | 0.0 |
| 37 | } else { |
| 38 | users.len() as f64 * (avatar_size + spacing) - spacing |
| 39 | }; |
| 40 | |
| 41 | // Center horizontally on primary monitor, position above center |
| 42 | // Login form is 320px tall, centered, so top is at center_y - 160 |
| 43 | // Avatar (64px) + gap (8px) + name text (~20px) = ~92px |
| 44 | // Need enough clearance above the login form |
| 45 | let x = center_x - total_width / 2.0; |
| 46 | let y = center_y - 280.0; // Above the login form with breathing room |
| 47 | |
| 48 | Self { |
| 49 | users, |
| 50 | selected_index: None, |
| 51 | hovered_index: None, |
| 52 | avatar_cache: AvatarCache::new(avatar_size as u32 * 2), // 2x for quality |
| 53 | x, |
| 54 | y, |
| 55 | avatar_size, |
| 56 | spacing, |
| 57 | } |
| 58 | } |
| 59 | |
| 60 | /// Check if the user list has any users |
| 61 | pub fn is_empty(&self) -> bool { |
| 62 | self.users.is_empty() |
| 63 | } |
| 64 | |
| 65 | /// Select the first user and return their username |
| 66 | pub fn select_first(&mut self) -> Option<String> { |
| 67 | if self.users.is_empty() { |
| 68 | None |
| 69 | } else { |
| 70 | self.selected_index = Some(0); |
| 71 | Some(self.users[0].name.clone()) |
| 72 | } |
| 73 | } |
| 74 | |
| 75 | /// Get the selected user's username |
| 76 | pub fn selected_username(&self) -> Option<&str> { |
| 77 | self.selected_index |
| 78 | .and_then(|i| self.users.get(i)) |
| 79 | .map(|u| u.name.as_str()) |
| 80 | } |
| 81 | |
| 82 | /// Select a user by index |
| 83 | pub fn select(&mut self, index: usize) { |
| 84 | if index < self.users.len() { |
| 85 | self.selected_index = Some(index); |
| 86 | } |
| 87 | } |
| 88 | |
| 89 | /// Clear selection |
| 90 | pub fn clear_selection(&mut self) { |
| 91 | self.selected_index = None; |
| 92 | } |
| 93 | |
| 94 | /// Render the user list |
| 95 | pub fn render(&mut self, ctx: &Context, pango_ctx: &pango::Context, theme: &Theme) -> Result<()> { |
| 96 | if self.users.is_empty() { |
| 97 | return Ok(()); |
| 98 | } |
| 99 | |
| 100 | for (i, user) in self.users.iter().enumerate() { |
| 101 | let item_x = self.x + i as f64 * (self.avatar_size + self.spacing); |
| 102 | let item_y = self.y; |
| 103 | |
| 104 | let is_selected = self.selected_index == Some(i); |
| 105 | |
| 106 | // Avatar |
| 107 | let home = user.home.to_str(); |
| 108 | if let Some(avatar_img) = self.avatar_cache.get(&user.name, home) { |
| 109 | render_avatar_image(ctx, avatar_img, item_x, item_y, self.avatar_size)?; |
| 110 | } else { |
| 111 | // Fallback to initials |
| 112 | let display_name = user.full_name.as_deref().unwrap_or(&user.name); |
| 113 | let initials = get_initials(display_name); |
| 114 | let hue = string_to_hue(&user.name); |
| 115 | render_avatar_fallback(ctx, pango_ctx, &initials, item_x, item_y, self.avatar_size, hue)?; |
| 116 | } |
| 117 | |
| 118 | // Selection border |
| 119 | render_avatar_border(ctx, item_x, item_y, self.avatar_size, is_selected)?; |
| 120 | |
| 121 | // Username below avatar |
| 122 | let mut font = FontDescription::new(); |
| 123 | font.set_family(&theme.font_family); |
| 124 | font.set_size(11 * pango::SCALE); |
| 125 | |
| 126 | let layout = Layout::new(pango_ctx); |
| 127 | layout.set_font_description(Some(&font)); |
| 128 | |
| 129 | // Use display name if available, otherwise username |
| 130 | let display_name = user |
| 131 | .full_name |
| 132 | .as_ref() |
| 133 | .and_then(|n| n.split_whitespace().next()) |
| 134 | .unwrap_or(&user.name); |
| 135 | layout.set_text(display_name); |
| 136 | |
| 137 | let (text_w, _) = layout.pixel_size(); |
| 138 | let text_x = item_x + (self.avatar_size - text_w as f64) / 2.0; |
| 139 | let text_y = item_y + self.avatar_size + 8.0; |
| 140 | |
| 141 | let tc = &theme.text_primary; |
| 142 | if is_selected { |
| 143 | ctx.set_source_rgb(tc.r, tc.g, tc.b); |
| 144 | } else { |
| 145 | ctx.set_source_rgba(tc.r, tc.g, tc.b, 0.9); |
| 146 | } |
| 147 | ctx.move_to(text_x, text_y); |
| 148 | pangocairo::functions::show_layout(ctx, &layout); |
| 149 | } |
| 150 | |
| 151 | Ok(()) |
| 152 | } |
| 153 | |
| 154 | /// Update hover state based on mouse position |
| 155 | pub fn update_hover(&mut self, mouse_x: f64, mouse_y: f64) -> bool { |
| 156 | let old_hover = self.hovered_index; |
| 157 | |
| 158 | self.hovered_index = None; |
| 159 | |
| 160 | for (i, _) in self.users.iter().enumerate() { |
| 161 | let item_x = self.x + i as f64 * (self.avatar_size + self.spacing); |
| 162 | let item_y = self.y; |
| 163 | |
| 164 | // Check if mouse is over this avatar (with some padding for the name) |
| 165 | let hit_width = self.avatar_size; |
| 166 | let hit_height = self.avatar_size + 28.0; |
| 167 | |
| 168 | if mouse_x >= item_x |
| 169 | && mouse_x <= item_x + hit_width |
| 170 | && mouse_y >= item_y |
| 171 | && mouse_y <= item_y + hit_height |
| 172 | { |
| 173 | self.hovered_index = Some(i); |
| 174 | break; |
| 175 | } |
| 176 | } |
| 177 | |
| 178 | self.hovered_index != old_hover |
| 179 | } |
| 180 | |
| 181 | /// Handle click and return selected username if a user was clicked |
| 182 | pub fn handle_click(&mut self, click_x: f64, click_y: f64) -> Option<String> { |
| 183 | for (i, user) in self.users.iter().enumerate() { |
| 184 | let item_x = self.x + i as f64 * (self.avatar_size + self.spacing); |
| 185 | let item_y = self.y; |
| 186 | |
| 187 | let hit_width = self.avatar_size; |
| 188 | let hit_height = self.avatar_size + 28.0; |
| 189 | |
| 190 | if click_x >= item_x |
| 191 | && click_x <= item_x + hit_width |
| 192 | && click_y >= item_y |
| 193 | && click_y <= item_y + hit_height |
| 194 | { |
| 195 | self.selected_index = Some(i); |
| 196 | return Some(user.name.clone()); |
| 197 | } |
| 198 | } |
| 199 | |
| 200 | None |
| 201 | } |
| 202 | |
| 203 | /// Check if a point is within the user list bounds |
| 204 | pub fn contains(&self, x: f64, y: f64) -> bool { |
| 205 | if self.users.is_empty() { |
| 206 | return false; |
| 207 | } |
| 208 | |
| 209 | let total_width = self.users.len() as f64 * (self.avatar_size + self.spacing) - self.spacing; |
| 210 | let total_height = self.avatar_size + 28.0; |
| 211 | |
| 212 | x >= self.x && x <= self.x + total_width && y >= self.y && y <= self.y + total_height |
| 213 | } |
| 214 | } |
| 215 |