1use 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#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
30pub enum Status {
31 #[default]
33 Online,
34 Offline,
36 Busy,
38 Away,
40 Unknown,
42 Error,
44 Custom(Color),
46}
47
48impl Status {
49 pub fn color(&self) -> Color {
51 match self {
52 Status::Online => Color::rgb(34, 197, 94), Status::Offline => Color::rgb(107, 114, 128), Status::Busy => Color::rgb(239, 68, 68), Status::Away => Color::rgb(234, 179, 8), Status::Unknown => Color::rgb(156, 163, 175), Status::Error => Color::rgb(220, 38, 38), Status::Custom(color) => *color,
59 }
60 }
61
62 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 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#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
91pub enum StatusSize {
92 Small,
94 #[default]
96 Medium,
97 Large,
99}
100
101impl StatusSize {
102 pub fn dot(&self) -> char {
104 match self {
105 StatusSize::Small => '•',
106 StatusSize::Medium => '●',
107 StatusSize::Large => '⬤',
108 }
109 }
110
111 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#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
123pub enum StatusStyle {
124 #[default]
126 Dot,
127 DotWithLabel,
129 LabelOnly,
131 Badge,
133}
134
135#[derive(Clone)]
139pub struct StatusIndicator {
140 status: Status,
142 size: StatusSize,
144 style: StatusStyle,
146 custom_label: Option<String>,
148 pulsing: bool,
150 frame: usize,
152 state: WidgetState,
154 props: WidgetProps,
156}
157
158impl StatusIndicator {
159 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 pub fn online() -> Self {
175 Self::new(Status::Online)
176 }
177
178 pub fn offline() -> Self {
180 Self::new(Status::Offline)
181 }
182
183 pub fn busy() -> Self {
185 Self::new(Status::Busy)
186 }
187
188 pub fn away() -> Self {
190 Self::new(Status::Away)
191 }
192
193 pub fn unknown() -> Self {
195 Self::new(Status::Unknown)
196 }
197
198 pub fn error() -> Self {
200 Self::new(Status::Error)
201 }
202
203 pub fn custom(color: Color) -> Self {
205 Self::new(Status::Custom(color))
206 }
207
208 pub fn status(mut self, status: Status) -> Self {
210 self.status = status;
211 self
212 }
213
214 pub fn size(mut self, size: StatusSize) -> Self {
216 self.size = size;
217 self
218 }
219
220 pub fn indicator_style(mut self, style: StatusStyle) -> Self {
222 self.style = style;
223 self
224 }
225
226 pub fn label(mut self, label: impl Into<String>) -> Self {
228 self.custom_label = Some(label.into());
229 self
230 }
231
232 pub fn pulsing(mut self, pulsing: bool) -> Self {
234 self.pulsing = pulsing;
235 self
236 }
237
238 pub fn tick(&mut self) {
240 self.frame = self.frame.wrapping_add(1);
241 }
242
243 pub fn get_status(&self) -> Status {
245 self.status
246 }
247
248 pub fn set_status(&mut self, status: Status) {
250 self.status = status;
251 }
252
253 fn get_label(&self) -> &str {
255 self.custom_label
256 .as_deref()
257 .unwrap_or_else(|| self.status.label())
258 }
259
260 fn is_visible(&self) -> bool {
262 if !self.pulsing {
263 return true;
264 }
265 (self.frame % 8) < 6
267 }
268
269 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 }
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 }
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 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 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 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 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 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 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
446pub fn status_indicator(status: Status) -> StatusIndicator {
448 StatusIndicator::new(status)
449}
450
451pub fn online() -> StatusIndicator {
453 StatusIndicator::online()
454}
455
456pub fn offline() -> StatusIndicator {
458 StatusIndicator::offline()
459}
460
461pub fn busy_indicator() -> StatusIndicator {
463 StatusIndicator::busy()
464}
465
466pub 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()); for _ in 0..6 {
574 s.tick();
575 }
576 assert!(!s.is_visible()); s.tick();
579 s.tick();
580 assert!(s.is_visible()); }
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 assert_eq!(buffer.get(0, 0).unwrap().symbol, '●');
615 assert_eq!(buffer.get(2, 0).unwrap().symbol, 'O'); }
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'); }
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 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}