presentar_terminal/widgets/
ux.rs1use std::borrow::Cow;
9
10#[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
37pub 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 let start_len = (max - 1) / 3; let end_len = max - 1 - start_len; 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
66pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
92pub enum HealthStatus {
93 Healthy,
95 Warning,
97 Critical,
99 Unknown,
101}
102
103impl HealthStatus {
104 #[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 pub fn colored_symbol(&self) -> &'static str {
118 match self {
119 Self::Healthy => "\x1b[32m✓\x1b[0m", Self::Warning => "\x1b[33m⚠\x1b[0m", Self::Critical => "\x1b[31m✗\x1b[0m", Self::Unknown => "\x1b[90m?\x1b[0m", }
124 }
125
126 #[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 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 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#[derive(Debug, Clone)]
187pub struct EmptyState {
188 pub icon: Option<String>,
190 pub title: String,
192 pub hint: Option<String>,
194 pub center_vertical: bool,
196}
197
198impl EmptyState {
199 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 pub fn icon(mut self, icon: impl Into<String>) -> Self {
211 self.icon = Some(icon.into());
212 self
213 }
214
215 pub fn hint(mut self, hint: impl Into<String>) -> Self {
217 self.hint = Some(hint.into());
218 self
219 }
220
221 pub fn top_aligned(mut self) -> Self {
223 self.center_vertical = false;
224 self
225 }
226
227 pub fn render_lines(&self, available_height: u16) -> (Vec<String>, u16) {
232 contract_pre_render!();
233 let mut lines = Vec::new();
234
235 if let Some(ref icon) = self.icon {
237 lines.push(icon.clone());
238 lines.push(String::new()); }
240
241 lines.push(self.title.clone());
243
244 if let Some(ref hint) = self.hint {
246 lines.push(String::new()); lines.push(hint.clone());
248 }
249
250 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 };
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#[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 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); assert!(offset > 0); }
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 assert_eq!(truncate("你好世界", 3), "你好…");
357 assert_eq!(truncate("日本語", 5), "日本語");
358 }
359
360 #[test]
361 fn test_truncate_middle_short() {
362 assert_eq!(truncate_middle("abc", 10), "abc");
364 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 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 let healthy = HealthStatus::Healthy.colored_symbol();
394 assert!(healthy.contains("\x1b[32m")); assert!(healthy.contains("✓"));
396
397 let warning = HealthStatus::Warning.colored_symbol();
398 assert!(warning.contains("\x1b[33m")); assert!(warning.contains("⚠"));
400
401 let critical = HealthStatus::Critical.colored_symbol();
402 assert!(critical.contains("\x1b[31m")); assert!(critical.contains("✗"));
404
405 let unknown = HealthStatus::Unknown.colored_symbol();
406 assert!(unknown.contains("\x1b[90m")); 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); 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); 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); 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); assert_eq!(lines.len(), 5);
460 assert_eq!(offset, 0); }
462
463 #[test]
464 fn test_truncate_middle_exact_boundary() {
465 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 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}