Skip to main content

revue/widget/display/
avatar.rs

1//! Avatar widget for user/entity representation
2
3use crate::render::{Cell, Modifier};
4use crate::style::Color;
5use crate::widget::traits::{RenderContext, View, WidgetProps};
6use crate::{impl_props_builders, impl_styled_view};
7
8/// Avatar size
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
10pub enum AvatarSize {
11    /// Small (1 char)
12    Small,
13    /// Medium (3 chars)
14    #[default]
15    Medium,
16    /// Large (5 chars with border)
17    Large,
18}
19
20/// Avatar shape
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
22pub enum AvatarShape {
23    /// Circle (using Unicode characters)
24    #[default]
25    Circle,
26    /// Square/box
27    Square,
28    /// Rounded square
29    Rounded,
30}
31
32/// An avatar widget for user representation
33///
34/// # Example
35///
36/// ```rust,ignore
37/// use revue::prelude::*;
38///
39/// hstack()
40///     .child(avatar("John Doe").circle())
41///     .child(text("John Doe"))
42/// ```
43pub struct Avatar {
44    /// Name to derive initials from
45    name: String,
46    /// Custom initials (overrides name-derived)
47    initials: Option<String>,
48    /// Size
49    size: AvatarSize,
50    /// Shape
51    shape: AvatarShape,
52    /// Background color
53    bg_color: Option<Color>,
54    /// Foreground color
55    fg_color: Option<Color>,
56    /// Status indicator color (online/offline dot)
57    status: Option<Color>,
58    /// Icon character (instead of initials)
59    icon: Option<char>,
60    /// Widget props for CSS integration
61    props: WidgetProps,
62}
63
64impl Avatar {
65    /// Create a new avatar from a name
66    pub fn new(name: impl Into<String>) -> Self {
67        Self {
68            name: name.into(),
69            initials: None,
70            size: AvatarSize::Medium,
71            shape: AvatarShape::Circle,
72            bg_color: None,
73            fg_color: None,
74            status: None,
75            icon: None,
76            props: WidgetProps::new(),
77        }
78    }
79
80    /// Create an avatar with custom initials
81    pub fn from_initials(initials: impl Into<String>) -> Self {
82        Self {
83            name: String::new(),
84            initials: Some(initials.into()),
85            size: AvatarSize::Medium,
86            shape: AvatarShape::Circle,
87            bg_color: None,
88            fg_color: None,
89            status: None,
90            icon: None,
91            props: WidgetProps::new(),
92        }
93    }
94
95    /// Create an avatar with an icon
96    pub fn from_icon(icon: char) -> Self {
97        Self {
98            name: String::new(),
99            initials: None,
100            size: AvatarSize::Medium,
101            shape: AvatarShape::Circle,
102            bg_color: None,
103            fg_color: None,
104            status: None,
105            icon: Some(icon),
106            props: WidgetProps::new(),
107        }
108    }
109
110    /// Set size
111    pub fn size(mut self, size: AvatarSize) -> Self {
112        self.size = size;
113        self
114    }
115
116    /// Small size shorthand
117    pub fn small(mut self) -> Self {
118        self.size = AvatarSize::Small;
119        self
120    }
121
122    /// Medium size shorthand
123    pub fn medium(mut self) -> Self {
124        self.size = AvatarSize::Medium;
125        self
126    }
127
128    /// Large size shorthand
129    pub fn large(mut self) -> Self {
130        self.size = AvatarSize::Large;
131        self
132    }
133
134    /// Set shape
135    pub fn shape(mut self, shape: AvatarShape) -> Self {
136        self.shape = shape;
137        self
138    }
139
140    /// Circle shape shorthand
141    pub fn circle(mut self) -> Self {
142        self.shape = AvatarShape::Circle;
143        self
144    }
145
146    /// Square shape shorthand
147    pub fn square(mut self) -> Self {
148        self.shape = AvatarShape::Square;
149        self
150    }
151
152    /// Rounded shape shorthand
153    pub fn rounded(mut self) -> Self {
154        self.shape = AvatarShape::Rounded;
155        self
156    }
157
158    /// Set background color
159    pub fn bg(mut self, color: Color) -> Self {
160        self.bg_color = Some(color);
161        self
162    }
163
164    /// Set foreground color
165    pub fn fg(mut self, color: Color) -> Self {
166        self.fg_color = Some(color);
167        self
168    }
169
170    /// Set colors
171    pub fn colors(mut self, bg: Color, fg: Color) -> Self {
172        self.bg_color = Some(bg);
173        self.fg_color = Some(fg);
174        self
175    }
176
177    /// Set online status
178    pub fn online(mut self) -> Self {
179        self.status = Some(Color::rgb(40, 200, 80));
180        self
181    }
182
183    /// Set offline status
184    pub fn offline(mut self) -> Self {
185        self.status = Some(Color::rgb(100, 100, 100));
186        self
187    }
188
189    /// Set away status
190    pub fn away(mut self) -> Self {
191        self.status = Some(Color::rgb(200, 180, 40));
192        self
193    }
194
195    /// Set busy status
196    pub fn busy(mut self) -> Self {
197        self.status = Some(Color::rgb(200, 60, 60));
198        self
199    }
200
201    /// Set custom status color
202    pub fn status(mut self, color: Color) -> Self {
203        self.status = Some(color);
204        self
205    }
206
207    /// Set icon
208    pub fn icon(mut self, icon: char) -> Self {
209        self.icon = Some(icon);
210        self
211    }
212
213    /// Get initials from name
214    fn get_initials(&self) -> String {
215        if let Some(ref initials) = self.initials {
216            return initials.clone();
217        }
218
219        if let Some(icon) = self.icon {
220            return icon.to_string();
221        }
222
223        // Derive initials from name
224        self.name
225            .split_whitespace()
226            .filter_map(|word| word.chars().next())
227            .take(2)
228            .collect::<String>()
229            .to_uppercase()
230    }
231
232    /// Get background color (auto-generate from name if not set)
233    fn get_bg_color(&self) -> Color {
234        if let Some(color) = self.bg_color {
235            return color;
236        }
237
238        // Generate color from name hash
239        let hash: u32 = self
240            .name
241            .bytes()
242            .fold(0u32, |acc, b| acc.wrapping_add(b as u32));
243        let hue = (hash % 360) as u8;
244
245        // Convert HSL to RGB (simplified)
246        let h = hue as f32 / 60.0;
247        let s = 0.6_f32;
248        let l = 0.4_f32;
249
250        let c = (1.0 - (2.0 * l - 1.0).abs()) * s;
251        let x = c * (1.0 - ((h % 2.0) - 1.0).abs());
252        let m = l - c / 2.0;
253
254        let (r1, g1, b1) = match h as u8 {
255            0 => (c, x, 0.0),
256            1 => (x, c, 0.0),
257            2 => (0.0, c, x),
258            3 => (0.0, x, c),
259            4 => (x, 0.0, c),
260            _ => (c, 0.0, x),
261        };
262
263        Color::rgb(
264            ((r1 + m) * 255.0) as u8,
265            ((g1 + m) * 255.0) as u8,
266            ((b1 + m) * 255.0) as u8,
267        )
268    }
269}
270
271impl Default for Avatar {
272    fn default() -> Self {
273        Self::new("")
274    }
275}
276
277impl View for Avatar {
278    crate::impl_view_meta!("Avatar");
279
280    fn render(&self, ctx: &mut RenderContext) {
281        let area = ctx.area;
282        let initials = self.get_initials();
283        let bg = self.get_bg_color();
284        let fg = self.fg_color.unwrap_or(Color::WHITE);
285
286        match self.size {
287            AvatarSize::Small => {
288                // Single character
289                let ch = initials.chars().next().unwrap_or('?');
290                let mut cell = Cell::new(ch);
291                cell.fg = Some(fg);
292                cell.bg = Some(bg);
293                cell.modifier |= Modifier::BOLD;
294                ctx.buffer.set(area.x, area.y, cell);
295
296                // Status dot
297                if let Some(status_color) = self.status {
298                    let mut dot = Cell::new('●');
299                    dot.fg = Some(status_color);
300                    ctx.buffer.set(area.x + 1, area.y, dot);
301                }
302            }
303            AvatarSize::Medium => {
304                // 3 chars wide: [XY] or ⬤XY⬤ for circle
305                match self.shape {
306                    AvatarShape::Circle => {
307                        // Use half-blocks for pseudo-circle: ◖XY◗
308                        let mut left = Cell::new('◖');
309                        left.fg = Some(bg);
310                        ctx.buffer.set(area.x, area.y, left);
311
312                        for (i, ch) in initials.chars().take(2).enumerate() {
313                            let mut cell = Cell::new(ch);
314                            cell.fg = Some(fg);
315                            cell.bg = Some(bg);
316                            cell.modifier |= Modifier::BOLD;
317                            ctx.buffer.set(area.x + 1 + i as u16, area.y, cell);
318                        }
319
320                        let mut right = Cell::new('◗');
321                        right.fg = Some(bg);
322                        ctx.buffer.set(area.x + 3, area.y, right);
323
324                        // Status dot
325                        if let Some(status_color) = self.status {
326                            let mut dot = Cell::new('●');
327                            dot.fg = Some(status_color);
328                            ctx.buffer.set(area.x + 4, area.y, dot);
329                        }
330                    }
331                    AvatarShape::Square | AvatarShape::Rounded => {
332                        // [XY] format
333                        let left = if self.shape == AvatarShape::Rounded {
334                            '('
335                        } else {
336                            '['
337                        };
338                        let right = if self.shape == AvatarShape::Rounded {
339                            ')'
340                        } else {
341                            ']'
342                        };
343
344                        let mut lc = Cell::new(left);
345                        lc.fg = Some(bg);
346                        ctx.buffer.set(area.x, area.y, lc);
347
348                        for (i, ch) in initials.chars().take(2).enumerate() {
349                            let mut cell = Cell::new(ch);
350                            cell.fg = Some(fg);
351                            cell.bg = Some(bg);
352                            cell.modifier |= Modifier::BOLD;
353                            ctx.buffer.set(area.x + 1 + i as u16, area.y, cell);
354                        }
355
356                        let mut rc = Cell::new(right);
357                        rc.fg = Some(bg);
358                        ctx.buffer.set(area.x + 3, area.y, rc);
359
360                        // Status dot
361                        if let Some(status_color) = self.status {
362                            let mut dot = Cell::new('●');
363                            dot.fg = Some(status_color);
364                            ctx.buffer.set(area.x + 4, area.y, dot);
365                        }
366                    }
367                }
368            }
369            AvatarSize::Large => {
370                // 3 lines tall, 5+ chars wide
371                if area.height < 3 {
372                    // Fall back to medium
373                    let mut cell = Cell::new(initials.chars().next().unwrap_or('?'));
374                    cell.fg = Some(fg);
375                    cell.bg = Some(bg);
376                    ctx.buffer.set(area.x, area.y, cell);
377                    return;
378                }
379
380                match self.shape {
381                    AvatarShape::Circle => {
382                        // Top: ╭───╮
383                        // Mid: │XY │
384                        // Bot: ╰───╯
385                        let chars_top = ['╭', '─', '─', '─', '╮'];
386                        let chars_bot = ['╰', '─', '─', '─', '╯'];
387
388                        for (i, ch) in chars_top.iter().enumerate() {
389                            let mut cell = Cell::new(*ch);
390                            cell.fg = Some(bg);
391                            ctx.buffer.set(area.x + i as u16, area.y, cell);
392                        }
393
394                        // Middle row
395                        let mut left = Cell::new('│');
396                        left.fg = Some(bg);
397                        ctx.buffer.set(area.x, area.y + 1, left);
398
399                        // Pre-collect initials chars for O(1) access
400                        let initials_chars: Vec<char> = initials.chars().collect();
401                        for i in 1..4 {
402                            let ch = if i == 1 || i == 2 {
403                                initials_chars.get(i - 1).copied().unwrap_or(' ')
404                            } else {
405                                ' '
406                            };
407                            let mut cell = Cell::new(ch);
408                            cell.fg = Some(fg);
409                            cell.bg = Some(bg);
410                            cell.modifier |= Modifier::BOLD;
411                            ctx.buffer.set(area.x + i as u16, area.y + 1, cell);
412                        }
413
414                        let mut right = Cell::new('│');
415                        right.fg = Some(bg);
416                        ctx.buffer.set(area.x + 4, area.y + 1, right);
417
418                        for (i, ch) in chars_bot.iter().enumerate() {
419                            let mut cell = Cell::new(*ch);
420                            cell.fg = Some(bg);
421                            ctx.buffer.set(area.x + i as u16, area.y + 2, cell);
422                        }
423
424                        // Status dot
425                        if let Some(status_color) = self.status {
426                            let mut dot = Cell::new('●');
427                            dot.fg = Some(status_color);
428                            ctx.buffer.set(area.x + 5, area.y + 2, dot);
429                        }
430                    }
431                    AvatarShape::Square => {
432                        // Top: ┌───┐
433                        let chars_top = ['┌', '─', '─', '─', '┐'];
434                        let chars_bot = ['└', '─', '─', '─', '┘'];
435
436                        for (i, ch) in chars_top.iter().enumerate() {
437                            let mut cell = Cell::new(*ch);
438                            cell.fg = Some(bg);
439                            ctx.buffer.set(area.x + i as u16, area.y, cell);
440                        }
441
442                        let mut left = Cell::new('│');
443                        left.fg = Some(bg);
444                        ctx.buffer.set(area.x, area.y + 1, left);
445
446                        // Pre-collect initials chars for O(1) access
447                        let initials_chars: Vec<char> = initials.chars().collect();
448                        for i in 1..4 {
449                            let ch = if i == 1 || i == 2 {
450                                initials_chars.get(i - 1).copied().unwrap_or(' ')
451                            } else {
452                                ' '
453                            };
454                            let mut cell = Cell::new(ch);
455                            cell.fg = Some(fg);
456                            cell.bg = Some(bg);
457                            cell.modifier |= Modifier::BOLD;
458                            ctx.buffer.set(area.x + i as u16, area.y + 1, cell);
459                        }
460
461                        let mut right = Cell::new('│');
462                        right.fg = Some(bg);
463                        ctx.buffer.set(area.x + 4, area.y + 1, right);
464
465                        for (i, ch) in chars_bot.iter().enumerate() {
466                            let mut cell = Cell::new(*ch);
467                            cell.fg = Some(bg);
468                            ctx.buffer.set(area.x + i as u16, area.y + 2, cell);
469                        }
470
471                        if let Some(status_color) = self.status {
472                            let mut dot = Cell::new('●');
473                            dot.fg = Some(status_color);
474                            ctx.buffer.set(area.x + 5, area.y + 2, dot);
475                        }
476                    }
477                    AvatarShape::Rounded => {
478                        // Same as circle for large
479                        let chars_top = ['╭', '─', '─', '─', '╮'];
480                        let chars_bot = ['╰', '─', '─', '─', '╯'];
481
482                        for (i, ch) in chars_top.iter().enumerate() {
483                            let mut cell = Cell::new(*ch);
484                            cell.fg = Some(bg);
485                            ctx.buffer.set(area.x + i as u16, area.y, cell);
486                        }
487
488                        let mut left = Cell::new('│');
489                        left.fg = Some(bg);
490                        ctx.buffer.set(area.x, area.y + 1, left);
491
492                        // Pre-collect initials chars for O(1) access
493                        let initials_chars: Vec<char> = initials.chars().collect();
494                        for i in 1..4 {
495                            let ch = if i == 1 || i == 2 {
496                                initials_chars.get(i - 1).copied().unwrap_or(' ')
497                            } else {
498                                ' '
499                            };
500                            let mut cell = Cell::new(ch);
501                            cell.fg = Some(fg);
502                            cell.bg = Some(bg);
503                            cell.modifier |= Modifier::BOLD;
504                            ctx.buffer.set(area.x + i as u16, area.y + 1, cell);
505                        }
506
507                        let mut right = Cell::new('│');
508                        right.fg = Some(bg);
509                        ctx.buffer.set(area.x + 4, area.y + 1, right);
510
511                        for (i, ch) in chars_bot.iter().enumerate() {
512                            let mut cell = Cell::new(*ch);
513                            cell.fg = Some(bg);
514                            ctx.buffer.set(area.x + i as u16, area.y + 2, cell);
515                        }
516
517                        if let Some(status_color) = self.status {
518                            let mut dot = Cell::new('●');
519                            dot.fg = Some(status_color);
520                            ctx.buffer.set(area.x + 5, area.y + 2, dot);
521                        }
522                    }
523                }
524            }
525        }
526    }
527}
528
529impl_styled_view!(Avatar);
530impl_props_builders!(Avatar);
531
532/// Create a new avatar from a name
533pub fn avatar(name: impl Into<String>) -> Avatar {
534    Avatar::new(name)
535}
536
537/// Create an avatar with an icon
538pub fn avatar_icon(icon: char) -> Avatar {
539    Avatar::from_icon(icon)
540}
541
542// Most tests moved to tests/widget_tests.rs
543// Tests below access private fields and must stay inline
544
545#[cfg(test)]
546mod tests {
547    use super::*;
548
549    #[test]
550    fn test_avatar_new() {
551        let a = Avatar::new("John Doe");
552        assert_eq!(a.name, "John Doe");
553        assert_eq!(a.get_initials(), "JD");
554    }
555
556    #[test]
557    fn test_avatar_initials() {
558        let a = Avatar::new("Alice Bob Charlie");
559        assert_eq!(a.get_initials(), "AB"); // Only first 2
560
561        let a = Avatar::new("SingleName");
562        assert_eq!(a.get_initials(), "S");
563
564        let a = Avatar::from_initials("XY");
565        assert_eq!(a.get_initials(), "XY");
566    }
567
568    #[test]
569    fn test_avatar_icon() {
570        let a = Avatar::from_icon('🤖');
571        assert_eq!(a.get_initials(), "🤖");
572    }
573
574    #[test]
575    fn test_avatar_sizes() {
576        let a = avatar("John").small();
577        assert_eq!(a.size, AvatarSize::Small);
578
579        let a = avatar("John").large();
580        assert_eq!(a.size, AvatarSize::Large);
581    }
582
583    #[test]
584    fn test_avatar_shapes() {
585        let a = avatar("John").circle();
586        assert_eq!(a.shape, AvatarShape::Circle);
587
588        let a = avatar("John").square();
589        assert_eq!(a.shape, AvatarShape::Square);
590    }
591
592    #[test]
593    fn test_avatar_status() {
594        let a = avatar("John").online();
595        assert!(a.status.is_some());
596
597        let a = avatar("John").busy();
598        assert!(a.status.is_some());
599    }
600
601    #[test]
602    fn test_avatar_color_generation() {
603        let a1 = Avatar::new("Alice");
604        let a2 = Avatar::new("Bob");
605
606        // Different names should generate different colors
607        let c1 = a1.get_bg_color();
608        let c2 = a2.get_bg_color();
609        // May or may not be different due to hash collisions, but should work
610        let _ = (c1, c2);
611    }
612
613    #[test]
614    fn test_helper_functions() {
615        let a = avatar("Test");
616        assert_eq!(a.name, "Test");
617
618        let a = avatar_icon('🎨');
619        assert!(a.icon.is_some());
620    }
621}