Skip to main content

revue/widget/display/
status_indicator.rs

1//! Status Indicator widget for displaying online/offline/busy states
2//!
3//! Provides visual feedback for connection status, user availability, or system health.
4//!
5//! # Example
6//!
7//! ```rust,ignore
8//! use revue::widget::{StatusIndicator, Status, status_indicator};
9//!
10//! // Basic online indicator
11//! StatusIndicator::online();
12//!
13//! // With label
14//! StatusIndicator::busy().label("Do not disturb");
15//!
16//! // Custom status
17//! status_indicator(Status::Away)
18//!     .size(StatusSize::Large)
19//!     .pulsing(true);
20//! ```
21
22use crate::render::Cell;
23use crate::style::Color;
24use crate::widget::traits::{RenderContext, View, WidgetProps, WidgetState};
25use crate::{impl_styled_view, impl_widget_builders};
26use unicode_width::UnicodeWidthChar;
27
28/// Predefined status states
29#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
30pub enum Status {
31    /// Online/available (green)
32    #[default]
33    Online,
34    /// Offline/disconnected (gray)
35    Offline,
36    /// Busy/do not disturb (red)
37    Busy,
38    /// Away/idle (yellow)
39    Away,
40    /// Unknown/connecting (gray with question)
41    Unknown,
42    /// Error state (red with warning)
43    Error,
44    /// Custom status with a color
45    Custom(Color),
46}
47
48impl Status {
49    /// Get the color for this status
50    pub fn color(&self) -> Color {
51        match self {
52            Status::Online => Color::rgb(34, 197, 94),    // Green
53            Status::Offline => Color::rgb(107, 114, 128), // Gray
54            Status::Busy => Color::rgb(239, 68, 68),      // Red
55            Status::Away => Color::rgb(234, 179, 8),      // Yellow
56            Status::Unknown => Color::rgb(156, 163, 175), // Light gray
57            Status::Error => Color::rgb(220, 38, 38),     // Darker red
58            Status::Custom(color) => *color,
59        }
60    }
61
62    /// Get the default label for this status
63    pub fn label(&self) -> &'static str {
64        match self {
65            Status::Online => "Online",
66            Status::Offline => "Offline",
67            Status::Busy => "Busy",
68            Status::Away => "Away",
69            Status::Unknown => "Unknown",
70            Status::Error => "Error",
71            Status::Custom(_) => "Custom",
72        }
73    }
74
75    /// Get the icon for this status
76    pub fn icon(&self) -> char {
77        match self {
78            Status::Online => '●',
79            Status::Offline => '○',
80            Status::Busy => '⊘',
81            Status::Away => '◐',
82            Status::Unknown => '?',
83            Status::Error => '!',
84            Status::Custom(_) => '●',
85        }
86    }
87}
88
89/// Size variants for the status indicator
90#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
91pub enum StatusSize {
92    /// Small dot (1 char)
93    Small,
94    /// Medium dot (default)
95    #[default]
96    Medium,
97    /// Large dot with more visual presence
98    Large,
99}
100
101impl StatusSize {
102    /// Get the dot character for this size
103    pub fn dot(&self) -> char {
104        match self {
105            StatusSize::Small => '•',
106            StatusSize::Medium => '●',
107            StatusSize::Large => '⬤',
108        }
109    }
110
111    /// Get the width for this size (for rendering with label)
112    pub fn width(&self) -> u16 {
113        match self {
114            StatusSize::Small => 1,
115            StatusSize::Medium => 1,
116            StatusSize::Large => 2,
117        }
118    }
119}
120
121/// Status indicator style
122#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
123pub enum StatusStyle {
124    /// Just the dot indicator
125    #[default]
126    Dot,
127    /// Dot with text label
128    DotWithLabel,
129    /// Text label only
130    LabelOnly,
131    /// Badge style (filled background)
132    Badge,
133}
134
135/// A status indicator widget for displaying availability/connection states
136///
137/// Shows online/offline/busy states with consistent visual styling.
138#[derive(Clone)]
139pub struct StatusIndicator {
140    /// Current status
141    status: Status,
142    /// Size variant
143    size: StatusSize,
144    /// Display style
145    style: StatusStyle,
146    /// Custom label (overrides default)
147    custom_label: Option<String>,
148    /// Enable pulsing animation
149    pulsing: bool,
150    /// Animation frame counter
151    frame: usize,
152    /// Widget state
153    state: WidgetState,
154    /// Widget properties
155    props: WidgetProps,
156}
157
158impl StatusIndicator {
159    /// Create a new status indicator with the given status
160    pub fn new(status: Status) -> Self {
161        Self {
162            status,
163            size: StatusSize::default(),
164            style: StatusStyle::default(),
165            custom_label: None,
166            pulsing: false,
167            frame: 0,
168            state: WidgetState::new(),
169            props: WidgetProps::new(),
170        }
171    }
172
173    /// Create an online status indicator
174    pub fn online() -> Self {
175        Self::new(Status::Online)
176    }
177
178    /// Create an offline status indicator
179    pub fn offline() -> Self {
180        Self::new(Status::Offline)
181    }
182
183    /// Create a busy status indicator
184    pub fn busy() -> Self {
185        Self::new(Status::Busy)
186    }
187
188    /// Create an away status indicator
189    pub fn away() -> Self {
190        Self::new(Status::Away)
191    }
192
193    /// Create an unknown status indicator
194    pub fn unknown() -> Self {
195        Self::new(Status::Unknown)
196    }
197
198    /// Create an error status indicator
199    pub fn error() -> Self {
200        Self::new(Status::Error)
201    }
202
203    /// Create a custom status indicator with a specific color
204    pub fn custom(color: Color) -> Self {
205        Self::new(Status::Custom(color))
206    }
207
208    /// Set the status
209    pub fn status(mut self, status: Status) -> Self {
210        self.status = status;
211        self
212    }
213
214    /// Set the size
215    pub fn size(mut self, size: StatusSize) -> Self {
216        self.size = size;
217        self
218    }
219
220    /// Set the display style
221    pub fn indicator_style(mut self, style: StatusStyle) -> Self {
222        self.style = style;
223        self
224    }
225
226    /// Set a custom label
227    pub fn label(mut self, label: impl Into<String>) -> Self {
228        self.custom_label = Some(label.into());
229        self
230    }
231
232    /// Enable/disable pulsing animation
233    pub fn pulsing(mut self, pulsing: bool) -> Self {
234        self.pulsing = pulsing;
235        self
236    }
237
238    /// Update animation frame
239    pub fn tick(&mut self) {
240        self.frame = self.frame.wrapping_add(1);
241    }
242
243    /// Get current status
244    pub fn get_status(&self) -> Status {
245        self.status
246    }
247
248    /// Set status mutably
249    pub fn set_status(&mut self, status: Status) {
250        self.status = status;
251    }
252
253    /// Get the label to display
254    fn get_label(&self) -> &str {
255        self.custom_label
256            .as_deref()
257            .unwrap_or_else(|| self.status.label())
258    }
259
260    /// Check if currently visible (for pulsing animation)
261    fn is_visible(&self) -> bool {
262        if !self.pulsing {
263            return true;
264        }
265        // Pulse every 8 frames (visible for 6, hidden for 2)
266        (self.frame % 8) < 6
267    }
268
269    /// Calculate total width needed
270    pub fn width(&self) -> u16 {
271        match self.style {
272            StatusStyle::Dot => self.size.width(),
273            StatusStyle::DotWithLabel => {
274                let label_len = self.get_label().chars().count() as u16;
275                self.size.width() + 1 + label_len // dot + space + label
276            }
277            StatusStyle::LabelOnly => self.get_label().chars().count() as u16,
278            StatusStyle::Badge => {
279                let label_len = self.get_label().chars().count() as u16;
280                label_len + 4 // padding + dot + space + label + padding
281            }
282        }
283    }
284}
285
286impl Default for StatusIndicator {
287    fn default() -> Self {
288        Self::new(Status::Online)
289    }
290}
291
292impl View for StatusIndicator {
293    crate::impl_view_meta!("StatusIndicator");
294
295    fn render(&self, ctx: &mut RenderContext) {
296        let area = ctx.area;
297        if area.width < 1 || area.height < 1 {
298            return;
299        }
300
301        let color = self.status.color();
302        let visible = self.is_visible();
303
304        match self.style {
305            StatusStyle::Dot => {
306                self.render_dot(ctx, color, visible);
307            }
308            StatusStyle::DotWithLabel => {
309                self.render_dot_with_label(ctx, color, visible);
310            }
311            StatusStyle::LabelOnly => {
312                self.render_label_only(ctx, color);
313            }
314            StatusStyle::Badge => {
315                self.render_badge(ctx, color, visible);
316            }
317        }
318    }
319}
320
321impl StatusIndicator {
322    fn render_dot(&self, ctx: &mut RenderContext, color: Color, visible: bool) {
323        let area = ctx.area;
324        let dot = if visible { self.size.dot() } else { ' ' };
325
326        let mut cell = Cell::new(dot);
327        cell.fg = Some(color);
328        ctx.buffer.set(area.x, area.y, cell);
329
330        // For large size, add extra visual
331        if self.size == StatusSize::Large && area.width > 1 {
332            let mut cell2 = Cell::new(' ');
333            cell2.bg = Some(color);
334            ctx.buffer.set(area.x + 1, area.y, cell2);
335        }
336    }
337
338    fn render_dot_with_label(&self, ctx: &mut RenderContext, color: Color, visible: bool) {
339        let area = ctx.area;
340
341        // Render dot
342        let dot = if visible { self.size.dot() } else { ' ' };
343        let mut dot_cell = Cell::new(dot);
344        dot_cell.fg = Some(color);
345        ctx.buffer.set(area.x, area.y, dot_cell);
346
347        // Render label
348        let label = self.get_label();
349        let label_start = area.x + self.size.width() + 1;
350        let max_label_width = area.width.saturating_sub(self.size.width() + 1);
351
352        let mut offset = 0u16;
353        for ch in label.chars() {
354            let char_width = ch.width().unwrap_or(0) as u16;
355            if char_width == 0 {
356                continue;
357            }
358            if offset + char_width > max_label_width {
359                break;
360            }
361            let mut cell = Cell::new(ch);
362            cell.fg = Some(Color::rgb(200, 200, 200));
363            ctx.buffer.set(label_start + offset, area.y, cell);
364            for i in 1..char_width {
365                ctx.buffer
366                    .set(label_start + offset + i, area.y, Cell::continuation());
367            }
368            offset += char_width;
369        }
370    }
371
372    fn render_label_only(&self, ctx: &mut RenderContext, color: Color) {
373        let area = ctx.area;
374        let label = self.get_label();
375
376        let mut offset = 0u16;
377        for ch in label.chars() {
378            let char_width = ch.width().unwrap_or(0) as u16;
379            if char_width == 0 {
380                continue;
381            }
382            if offset + char_width > area.width {
383                break;
384            }
385            let mut cell = Cell::new(ch);
386            cell.fg = Some(color);
387            ctx.buffer.set(area.x + offset, area.y, cell);
388            for i in 1..char_width {
389                ctx.buffer
390                    .set(area.x + offset + i, area.y, Cell::continuation());
391            }
392            offset += char_width;
393        }
394    }
395
396    fn render_badge(&self, ctx: &mut RenderContext, color: Color, visible: bool) {
397        let area = ctx.area;
398        let label = self.get_label();
399
400        // Background
401        let bg_color = Color::rgb(40, 40, 40);
402        let total_width = self.width().min(area.width);
403
404        for i in 0..total_width {
405            let mut cell = Cell::new(' ');
406            cell.bg = Some(bg_color);
407            ctx.buffer.set(area.x + i, area.y, cell);
408        }
409
410        // Dot
411        let dot = if visible { '●' } else { ' ' };
412        let mut dot_cell = Cell::new(dot);
413        dot_cell.fg = Some(color);
414        dot_cell.bg = Some(bg_color);
415        ctx.buffer.set(area.x + 1, area.y, dot_cell);
416
417        // Label
418        let label_start = area.x + 3;
419        let max_label_width = total_width.saturating_sub(4);
420        let mut offset = 0u16;
421        for ch in label.chars() {
422            let char_width = ch.width().unwrap_or(0) as u16;
423            if char_width == 0 {
424                continue;
425            }
426            if offset + char_width > max_label_width {
427                break;
428            }
429            let mut cell = Cell::new(ch);
430            cell.fg = Some(Color::WHITE);
431            cell.bg = Some(bg_color);
432            ctx.buffer.set(label_start + offset, area.y, cell);
433            for i in 1..char_width {
434                let mut cont = Cell::continuation();
435                cont.bg = Some(bg_color);
436                ctx.buffer.set(label_start + offset + i, area.y, cont);
437            }
438            offset += char_width;
439        }
440    }
441}
442
443impl_styled_view!(StatusIndicator);
444impl_widget_builders!(StatusIndicator);
445
446/// Helper function to create a StatusIndicator
447pub fn status_indicator(status: Status) -> StatusIndicator {
448    StatusIndicator::new(status)
449}
450
451/// Helper function to create an online indicator
452pub fn online() -> StatusIndicator {
453    StatusIndicator::online()
454}
455
456/// Helper function to create an offline indicator
457pub fn offline() -> StatusIndicator {
458    StatusIndicator::offline()
459}
460
461/// Helper function to create a busy indicator
462pub fn busy_indicator() -> StatusIndicator {
463    StatusIndicator::busy()
464}
465
466/// Helper function to create an away indicator
467pub fn away_indicator() -> StatusIndicator {
468    StatusIndicator::away()
469}
470
471#[cfg(test)]
472mod tests {
473    use super::*;
474    use crate::layout::Rect;
475    use crate::render::Buffer;
476
477    #[test]
478    fn test_status_indicator_new() {
479        let s = StatusIndicator::new(Status::Online);
480        assert_eq!(s.status, Status::Online);
481        assert_eq!(s.size, StatusSize::Medium);
482        assert_eq!(s.style, StatusStyle::Dot);
483    }
484
485    #[test]
486    fn test_status_helpers() {
487        assert_eq!(StatusIndicator::online().status, Status::Online);
488        assert_eq!(StatusIndicator::offline().status, Status::Offline);
489        assert_eq!(StatusIndicator::busy().status, Status::Busy);
490        assert_eq!(StatusIndicator::away().status, Status::Away);
491        assert_eq!(StatusIndicator::unknown().status, Status::Unknown);
492        assert_eq!(StatusIndicator::error().status, Status::Error);
493    }
494
495    #[test]
496    fn test_status_custom() {
497        let custom = StatusIndicator::custom(Color::MAGENTA);
498        assert!(matches!(custom.status, Status::Custom(_)));
499    }
500
501    #[test]
502    fn test_status_builders() {
503        let s = StatusIndicator::new(Status::Online)
504            .size(StatusSize::Large)
505            .indicator_style(StatusStyle::DotWithLabel)
506            .label("Available")
507            .pulsing(true);
508
509        assert_eq!(s.size, StatusSize::Large);
510        assert_eq!(s.style, StatusStyle::DotWithLabel);
511        assert_eq!(s.custom_label, Some("Available".to_string()));
512        assert!(s.pulsing);
513    }
514
515    #[test]
516    fn test_status_colors() {
517        assert_eq!(Status::Online.color(), Color::rgb(34, 197, 94));
518        assert_eq!(Status::Offline.color(), Color::rgb(107, 114, 128));
519        assert_eq!(Status::Busy.color(), Color::rgb(239, 68, 68));
520        assert_eq!(Status::Away.color(), Color::rgb(234, 179, 8));
521    }
522
523    #[test]
524    fn test_status_labels() {
525        assert_eq!(Status::Online.label(), "Online");
526        assert_eq!(Status::Offline.label(), "Offline");
527        assert_eq!(Status::Busy.label(), "Busy");
528        assert_eq!(Status::Away.label(), "Away");
529        assert_eq!(Status::Unknown.label(), "Unknown");
530        assert_eq!(Status::Error.label(), "Error");
531    }
532
533    #[test]
534    fn test_status_icons() {
535        assert_eq!(Status::Online.icon(), '●');
536        assert_eq!(Status::Offline.icon(), '○');
537        assert_eq!(Status::Busy.icon(), '⊘');
538        assert_eq!(Status::Away.icon(), '◐');
539    }
540
541    #[test]
542    fn test_size_dots() {
543        assert_eq!(StatusSize::Small.dot(), '•');
544        assert_eq!(StatusSize::Medium.dot(), '●');
545        assert_eq!(StatusSize::Large.dot(), '⬤');
546    }
547
548    #[test]
549    fn test_status_width() {
550        let dot_only = StatusIndicator::online();
551        assert_eq!(dot_only.width(), 1);
552
553        let with_label = StatusIndicator::online().indicator_style(StatusStyle::DotWithLabel);
554        assert!(with_label.width() > 1);
555
556        let label_only = StatusIndicator::online().indicator_style(StatusStyle::LabelOnly);
557        assert_eq!(label_only.width(), "Online".len() as u16);
558    }
559
560    #[test]
561    fn test_status_tick() {
562        let mut s = StatusIndicator::online().pulsing(true);
563        assert_eq!(s.frame, 0);
564        s.tick();
565        assert_eq!(s.frame, 1);
566    }
567
568    #[test]
569    fn test_status_pulsing_visibility() {
570        let mut s = StatusIndicator::online().pulsing(true);
571        assert!(s.is_visible()); // frame 0 is visible
572
573        for _ in 0..6 {
574            s.tick();
575        }
576        assert!(!s.is_visible()); // frame 6 is not visible
577
578        s.tick();
579        s.tick();
580        assert!(s.is_visible()); // frame 8 wraps to visible again
581    }
582
583    #[test]
584    fn test_status_set_get() {
585        let mut s = StatusIndicator::online();
586        assert_eq!(s.get_status(), Status::Online);
587
588        s.set_status(Status::Busy);
589        assert_eq!(s.get_status(), Status::Busy);
590    }
591
592    #[test]
593    fn test_status_render_dot() {
594        let mut buffer = Buffer::new(10, 1);
595        let area = Rect::new(0, 0, 10, 1);
596        let mut ctx = RenderContext::new(&mut buffer, area);
597
598        let s = StatusIndicator::online();
599        s.render(&mut ctx);
600
601        assert_eq!(buffer.get(0, 0).unwrap().symbol, '●');
602    }
603
604    #[test]
605    fn test_status_render_with_label() {
606        let mut buffer = Buffer::new(20, 1);
607        let area = Rect::new(0, 0, 20, 1);
608        let mut ctx = RenderContext::new(&mut buffer, area);
609
610        let s = StatusIndicator::online().indicator_style(StatusStyle::DotWithLabel);
611        s.render(&mut ctx);
612
613        // Should have dot and label
614        assert_eq!(buffer.get(0, 0).unwrap().symbol, '●');
615        assert_eq!(buffer.get(2, 0).unwrap().symbol, 'O'); // "Online" starts
616    }
617
618    #[test]
619    fn test_status_render_label_only() {
620        let mut buffer = Buffer::new(20, 1);
621        let area = Rect::new(0, 0, 20, 1);
622        let mut ctx = RenderContext::new(&mut buffer, area);
623
624        let s = StatusIndicator::busy().indicator_style(StatusStyle::LabelOnly);
625        s.render(&mut ctx);
626
627        assert_eq!(buffer.get(0, 0).unwrap().symbol, 'B'); // "Busy" starts
628    }
629
630    #[test]
631    fn test_status_render_badge() {
632        let mut buffer = Buffer::new(20, 1);
633        let area = Rect::new(0, 0, 20, 1);
634        let mut ctx = RenderContext::new(&mut buffer, area);
635
636        let s = StatusIndicator::online().indicator_style(StatusStyle::Badge);
637        s.render(&mut ctx);
638
639        // Badge has background and dot
640        assert_eq!(buffer.get(1, 0).unwrap().symbol, '●');
641    }
642
643    #[test]
644    fn test_helper_functions() {
645        let s = status_indicator(Status::Away);
646        assert_eq!(s.status, Status::Away);
647
648        let o = online();
649        assert_eq!(o.status, Status::Online);
650
651        let off = offline();
652        assert_eq!(off.status, Status::Offline);
653
654        let b = busy_indicator();
655        assert_eq!(b.status, Status::Busy);
656
657        let a = away_indicator();
658        assert_eq!(a.status, Status::Away);
659    }
660
661    #[test]
662    fn test_status_default() {
663        let s = StatusIndicator::default();
664        assert_eq!(s.status, Status::Online);
665    }
666
667    #[test]
668    fn test_custom_label() {
669        let s = StatusIndicator::online().label("Available now");
670        assert_eq!(s.get_label(), "Available now");
671
672        let s2 = StatusIndicator::online();
673        assert_eq!(s2.get_label(), "Online");
674    }
675}