Skip to main content

presentar_terminal/widgets/
ux.rs

1//! UX Utilities for presentar widgets.
2//!
3//! Implements requirements from lltop UX falsification checklist:
4//! - UX-001: Text truncation with ellipsis
5//! - UX-002: Health status indicators
6//! - UX-003: Empty state widget
7
8use std::borrow::Cow;
9
10// ============================================================================
11// UX-001: Text Truncation
12// ============================================================================
13
14/// Truncate text with ellipsis when it exceeds max characters.
15///
16/// # Examples
17/// ```
18/// use presentar_terminal::widgets::truncate;
19/// assert_eq!(truncate("Hello World", 8), "Hello W…");
20/// assert_eq!(truncate("Short", 10), "Short");
21/// ```
22#[inline]
23pub fn truncate(s: &str, max: usize) -> Cow<'_, str> {
24    let char_count = s.chars().count();
25    if char_count <= max {
26        Cow::Borrowed(s)
27    } else if max == 0 {
28        Cow::Borrowed("")
29    } else if max == 1 {
30        Cow::Borrowed("…")
31    } else {
32        let truncated: String = s.chars().take(max - 1).collect();
33        Cow::Owned(format!("{truncated}…"))
34    }
35}
36
37/// Truncate text from the middle, preserving start and end.
38///
39/// Useful for file paths: `/home/user/very/long/path` -> `/hom…ng/path`
40///
41/// # Examples
42/// ```
43/// use presentar_terminal::widgets::truncate_middle;
44/// // 25 char input, max 15: start=4 "/hom", end=10 "ects/myapp"
45/// assert_eq!(truncate_middle("/home/user/projects/myapp", 15), "/hom…ects/myapp");
46/// ```
47pub fn truncate_middle(s: &str, max: usize) -> Cow<'_, str> {
48    let char_count = s.chars().count();
49    if char_count <= max {
50        return Cow::Borrowed(s);
51    }
52    if max <= 3 {
53        return truncate(s, max);
54    }
55
56    // Split: keep more of the end (filename usually more important)
57    let start_len = (max - 1) / 3; // ~1/3 for start
58    let end_len = max - 1 - start_len; // ~2/3 for end
59
60    let start: String = s.chars().take(start_len).collect();
61    let end: String = s.chars().skip(char_count - end_len).collect();
62
63    Cow::Owned(format!("{start}…{end}"))
64}
65
66/// Truncate text with custom ellipsis string.
67pub fn truncate_with<'a>(s: &'a str, max: usize, ellipsis: &str) -> Cow<'a, str> {
68    let char_count = s.chars().count();
69    let ellipsis_len = ellipsis.chars().count();
70
71    if char_count <= max {
72        Cow::Borrowed(s)
73    } else if max <= ellipsis_len {
74        Cow::Owned(ellipsis.chars().take(max).collect())
75    } else {
76        let truncated: String = s.chars().take(max - ellipsis_len).collect();
77        Cow::Owned(format!("{truncated}{ellipsis}"))
78    }
79}
80
81// ============================================================================
82// UX-002: Health Status Indicators
83// ============================================================================
84
85/// Health status for visual indicators.
86///
87/// Uses distinct Unicode symbols for accessibility:
88/// - Healthy: ✓ (check mark)
89/// - Warning: ⚠ (warning sign)
90/// - Critical: ✗ (x mark)
91#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
92pub enum HealthStatus {
93    /// System is healthy - displays ✓
94    Healthy,
95    /// System has warnings - displays ⚠
96    Warning,
97    /// System is critical - displays ✗
98    Critical,
99    /// Status unknown - displays ?
100    Unknown,
101}
102
103impl HealthStatus {
104    /// Get the Unicode symbol for this status.
105    #[inline]
106    pub const fn symbol(&self) -> &'static str {
107        match self {
108            Self::Healthy => "✓",
109            Self::Warning => "⚠",
110            Self::Critical => "✗",
111            Self::Unknown => "?",
112        }
113    }
114
115    /// Get a colored symbol (ANSI escape codes).
116    /// Returns symbol with appropriate color prefix.
117    pub fn colored_symbol(&self) -> &'static str {
118        match self {
119            Self::Healthy => "\x1b[32m✓\x1b[0m",  // Green
120            Self::Warning => "\x1b[33m⚠\x1b[0m",  // Yellow
121            Self::Critical => "\x1b[31m✗\x1b[0m", // Red
122            Self::Unknown => "\x1b[90m?\x1b[0m",  // Gray
123        }
124    }
125
126    /// Get the label for this status.
127    #[inline]
128    pub const fn label(&self) -> &'static str {
129        match self {
130            Self::Healthy => "Healthy",
131            Self::Warning => "Warning",
132            Self::Critical => "Critical",
133            Self::Unknown => "Unknown",
134        }
135    }
136
137    /// Create from a percentage (0-100).
138    /// - >= 80%: Healthy
139    /// - >= 50%: Warning
140    /// - < 50%: Critical
141    pub fn from_percentage(pct: f64) -> Self {
142        if pct >= 80.0 {
143            Self::Healthy
144        } else if pct >= 50.0 {
145            Self::Warning
146        } else {
147            Self::Critical
148        }
149    }
150
151    /// Create from a score and maximum.
152    pub fn from_score(score: u32, max: u32) -> Self {
153        if max == 0 {
154            return Self::Unknown;
155        }
156        let pct = (score as f64 / max as f64) * 100.0;
157        Self::from_percentage(pct)
158    }
159}
160
161impl std::fmt::Display for HealthStatus {
162    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
163        write!(f, "{}", self.symbol())
164    }
165}
166
167// ============================================================================
168// UX-003: Empty State Widget
169// ============================================================================
170
171/// Empty state display for panes with no data.
172///
173/// Renders a centered message with:
174/// - Optional icon (emoji or Unicode)
175/// - Title text
176/// - Action hint (how to get data)
177///
178/// # Example
179/// ```
180/// use presentar_terminal::widgets::EmptyState;
181///
182/// let empty = EmptyState::new("No traces available")
183///     .icon("📊")
184///     .hint("Enable tracing with --trace flag");
185/// ```
186#[derive(Debug, Clone)]
187pub struct EmptyState {
188    /// Icon to display (emoji or Unicode symbol)
189    pub icon: Option<String>,
190    /// Main message title
191    pub title: String,
192    /// Action hint for user
193    pub hint: Option<String>,
194    /// Whether to center vertically
195    pub center_vertical: bool,
196}
197
198impl EmptyState {
199    /// Create a new empty state with title.
200    pub fn new(title: impl Into<String>) -> Self {
201        Self {
202            icon: None,
203            title: title.into(),
204            hint: None,
205            center_vertical: true,
206        }
207    }
208
209    /// Add an icon.
210    pub fn icon(mut self, icon: impl Into<String>) -> Self {
211        self.icon = Some(icon.into());
212        self
213    }
214
215    /// Add an action hint.
216    pub fn hint(mut self, hint: impl Into<String>) -> Self {
217        self.hint = Some(hint.into());
218        self
219    }
220
221    /// Disable vertical centering.
222    pub fn top_aligned(mut self) -> Self {
223        self.center_vertical = false;
224        self
225    }
226
227    /// Render to lines for display.
228    ///
229    /// Returns lines that should be rendered, with the starting y offset
230    /// for vertical centering.
231    pub fn render_lines(&self, available_height: u16) -> (Vec<String>, u16) {
232        contract_pre_render!();
233        let mut lines = Vec::new();
234
235        // Add icon line
236        if let Some(ref icon) = self.icon {
237            lines.push(icon.clone());
238            lines.push(String::new()); // Spacer
239        }
240
241        // Add title
242        lines.push(self.title.clone());
243
244        // Add hint
245        if let Some(ref hint) = self.hint {
246            lines.push(String::new()); // Spacer
247            lines.push(hint.clone());
248        }
249
250        // Calculate y offset for centering
251        let y_offset = if self.center_vertical {
252            let content_height = lines.len() as u16;
253            if available_height > content_height {
254                (available_height - content_height) / 2
255            } else {
256                0
257            }
258        } else {
259            1 // Small top margin
260        };
261
262        (lines, y_offset)
263    }
264}
265
266impl Default for EmptyState {
267    fn default() -> Self {
268        Self::new("No data available")
269    }
270}
271
272// ============================================================================
273// Tests
274// ============================================================================
275
276#[cfg(test)]
277mod tests {
278    use super::*;
279
280    #[test]
281    fn test_truncate_short() {
282        assert_eq!(truncate("Hello", 10), "Hello");
283        assert_eq!(truncate("", 5), "");
284    }
285
286    #[test]
287    fn test_truncate_exact() {
288        assert_eq!(truncate("Hello", 5), "Hello");
289    }
290
291    #[test]
292    fn test_truncate_long() {
293        assert_eq!(truncate("Hello World", 8), "Hello W…");
294        assert_eq!(truncate("Hello World", 6), "Hello…");
295        assert_eq!(truncate("Hello World", 1), "…");
296        assert_eq!(truncate("Hello World", 0), "");
297    }
298
299    #[test]
300    fn test_truncate_middle() {
301        assert_eq!(truncate_middle("/home/user/path", 20), "/home/user/path");
302        // 28 char input -> max 15: start=4 "/hom", end=10 "th/file.rs"
303        assert_eq!(
304            truncate_middle("/home/user/long/path/file.rs", 15),
305            "/hom…th/file.rs"
306        );
307    }
308
309    #[test]
310    fn test_health_status_symbol() {
311        assert_eq!(HealthStatus::Healthy.symbol(), "✓");
312        assert_eq!(HealthStatus::Warning.symbol(), "⚠");
313        assert_eq!(HealthStatus::Critical.symbol(), "✗");
314        assert_eq!(HealthStatus::Unknown.symbol(), "?");
315    }
316
317    #[test]
318    fn test_health_from_percentage() {
319        assert_eq!(HealthStatus::from_percentage(100.0), HealthStatus::Healthy);
320        assert_eq!(HealthStatus::from_percentage(80.0), HealthStatus::Healthy);
321        assert_eq!(HealthStatus::from_percentage(79.0), HealthStatus::Warning);
322        assert_eq!(HealthStatus::from_percentage(50.0), HealthStatus::Warning);
323        assert_eq!(HealthStatus::from_percentage(49.0), HealthStatus::Critical);
324        assert_eq!(HealthStatus::from_percentage(0.0), HealthStatus::Critical);
325    }
326
327    #[test]
328    fn test_health_from_score() {
329        assert_eq!(HealthStatus::from_score(20, 20), HealthStatus::Healthy);
330        assert_eq!(HealthStatus::from_score(16, 20), HealthStatus::Healthy);
331        assert_eq!(HealthStatus::from_score(15, 20), HealthStatus::Warning);
332        assert_eq!(HealthStatus::from_score(10, 20), HealthStatus::Warning);
333        assert_eq!(HealthStatus::from_score(9, 20), HealthStatus::Critical);
334        assert_eq!(HealthStatus::from_score(0, 0), HealthStatus::Unknown);
335    }
336
337    #[test]
338    fn test_empty_state_render() {
339        let empty = EmptyState::new("No data").icon("📊").hint("Try refreshing");
340
341        let (lines, offset) = empty.render_lines(20);
342        assert_eq!(lines.len(), 5); // icon, spacer, title, spacer, hint
343        assert!(offset > 0); // Should be centered
344    }
345
346    #[test]
347    fn test_empty_state_top_aligned() {
348        let empty = EmptyState::new("No data").top_aligned();
349        let (_, offset) = empty.render_lines(20);
350        assert_eq!(offset, 1);
351    }
352
353    #[test]
354    fn test_truncate_unicode() {
355        // Test with multi-byte Unicode characters
356        assert_eq!(truncate("你好世界", 3), "你好…");
357        assert_eq!(truncate("日本語", 5), "日本語");
358    }
359
360    #[test]
361    fn test_truncate_middle_short() {
362        // Short string - no truncation needed
363        assert_eq!(truncate_middle("abc", 10), "abc");
364        // Very short max - falls back to truncate
365        assert_eq!(truncate_middle("abcdefgh", 3), "ab…");
366        assert_eq!(truncate_middle("abcdefgh", 2), "a…");
367    }
368
369    #[test]
370    fn test_truncate_with_custom_ellipsis() {
371        assert_eq!(truncate_with("Hello World", 10, "..."), "Hello W...");
372        assert_eq!(truncate_with("Hello", 10, "..."), "Hello");
373        // Ellipsis longer than max
374        assert_eq!(truncate_with("Hello World", 2, "..."), "..");
375    }
376
377    #[test]
378    fn test_truncate_with_empty_ellipsis() {
379        assert_eq!(truncate_with("Hello World", 5, ""), "Hello");
380    }
381
382    #[test]
383    fn test_health_status_label() {
384        assert_eq!(HealthStatus::Healthy.label(), "Healthy");
385        assert_eq!(HealthStatus::Warning.label(), "Warning");
386        assert_eq!(HealthStatus::Critical.label(), "Critical");
387        assert_eq!(HealthStatus::Unknown.label(), "Unknown");
388    }
389
390    #[test]
391    fn test_health_status_colored_symbol() {
392        // Just test that colored symbols contain ANSI codes
393        let healthy = HealthStatus::Healthy.colored_symbol();
394        assert!(healthy.contains("\x1b[32m")); // Green
395        assert!(healthy.contains("✓"));
396
397        let warning = HealthStatus::Warning.colored_symbol();
398        assert!(warning.contains("\x1b[33m")); // Yellow
399        assert!(warning.contains("⚠"));
400
401        let critical = HealthStatus::Critical.colored_symbol();
402        assert!(critical.contains("\x1b[31m")); // Red
403        assert!(critical.contains("✗"));
404
405        let unknown = HealthStatus::Unknown.colored_symbol();
406        assert!(unknown.contains("\x1b[90m")); // Gray
407        assert!(unknown.contains('?'));
408    }
409
410    #[test]
411    fn test_health_status_display() {
412        assert_eq!(format!("{}", HealthStatus::Healthy), "✓");
413        assert_eq!(format!("{}", HealthStatus::Warning), "⚠");
414        assert_eq!(format!("{}", HealthStatus::Critical), "✗");
415        assert_eq!(format!("{}", HealthStatus::Unknown), "?");
416    }
417
418    #[test]
419    fn test_empty_state_default() {
420        let empty = EmptyState::default();
421        assert_eq!(empty.title, "No data available");
422        assert!(empty.icon.is_none());
423        assert!(empty.hint.is_none());
424        assert!(empty.center_vertical);
425    }
426
427    #[test]
428    fn test_empty_state_no_icon_no_hint() {
429        let empty = EmptyState::new("Test message");
430        let (lines, _) = empty.render_lines(10);
431        assert_eq!(lines.len(), 1); // Only title
432        assert_eq!(lines[0], "Test message");
433    }
434
435    #[test]
436    fn test_empty_state_with_icon_only() {
437        let empty = EmptyState::new("Test message").icon("🔍");
438        let (lines, _) = empty.render_lines(10);
439        assert_eq!(lines.len(), 3); // icon, spacer, title
440        assert_eq!(lines[0], "🔍");
441        assert_eq!(lines[1], "");
442        assert_eq!(lines[2], "Test message");
443    }
444
445    #[test]
446    fn test_empty_state_with_hint_only() {
447        let empty = EmptyState::new("Test message").hint("Try again");
448        let (lines, _) = empty.render_lines(10);
449        assert_eq!(lines.len(), 3); // title, spacer, hint
450        assert_eq!(lines[0], "Test message");
451        assert_eq!(lines[1], "");
452        assert_eq!(lines[2], "Try again");
453    }
454
455    #[test]
456    fn test_empty_state_render_small_height() {
457        let empty = EmptyState::new("Title").icon("📊").hint("Hint");
458        let (lines, offset) = empty.render_lines(3); // Height smaller than content
459        assert_eq!(lines.len(), 5);
460        assert_eq!(offset, 0); // Can't center, content is bigger
461    }
462
463    #[test]
464    fn test_truncate_middle_exact_boundary() {
465        // Test exactly 4 chars which is the boundary (max <= 3 falls back)
466        let result = truncate_middle("abcdefghij", 4);
467        assert!(result.len() <= 4 || result.chars().count() <= 4);
468    }
469
470    #[test]
471    fn test_health_from_percentage_edge_cases() {
472        // Test exact boundaries
473        assert_eq!(HealthStatus::from_percentage(80.0), HealthStatus::Healthy);
474        assert_eq!(HealthStatus::from_percentage(79.999), HealthStatus::Warning);
475        assert_eq!(HealthStatus::from_percentage(50.0), HealthStatus::Warning);
476        assert_eq!(
477            HealthStatus::from_percentage(49.999),
478            HealthStatus::Critical
479        );
480    }
481
482    #[test]
483    fn test_empty_state_builder_chain() {
484        let empty = EmptyState::new("Test")
485            .icon("🔧")
486            .hint("Fix it")
487            .top_aligned();
488
489        assert_eq!(empty.title, "Test");
490        assert_eq!(empty.icon, Some("🔧".to_string()));
491        assert_eq!(empty.hint, Some("Fix it".to_string()));
492        assert!(!empty.center_vertical);
493    }
494}