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