1use chrono::{DateTime, Utc};
2use ratatui::{prelude::*, widgets::*};
3use std::collections::HashMap;
4use std::sync::OnceLock;
5
6use crate::ui::{filter_area, styles};
7
8pub type ColumnId = &'static str;
9
10static I18N: OnceLock<HashMap<String, String>> = OnceLock::new();
11
12pub fn set_i18n(map: HashMap<String, String>) {
13 I18N.set(map).ok();
14}
15
16pub fn t(key: &str) -> String {
17 I18N.get()
18 .and_then(|map| map.get(key))
19 .cloned()
20 .unwrap_or_else(|| key.to_string())
21}
22
23pub fn translate_column(key: &str, default: &str) -> String {
24 let translated = t(key);
25 if translated == key {
26 default.to_string()
27 } else {
28 translated
29 }
30}
31
32pub const UTC_TIMESTAMP_WIDTH: u16 = 27;
34
35pub fn format_timestamp(dt: &DateTime<Utc>) -> String {
36 format!("{} (UTC)", dt.format("%Y-%m-%d %H:%M:%S"))
37}
38
39pub fn format_optional_timestamp(dt: Option<DateTime<Utc>>) -> String {
40 dt.map(|t| format_timestamp(&t))
41 .unwrap_or_else(|| "-".to_string())
42}
43
44pub fn format_iso_timestamp(iso_string: &str) -> String {
45 if iso_string.is_empty() {
46 return "-".to_string();
47 }
48
49 if let Ok(dt) = DateTime::parse_from_rfc3339(iso_string) {
51 format_timestamp(&dt.with_timezone(&Utc))
52 } else {
53 iso_string.to_string()
54 }
55}
56
57pub fn format_unix_timestamp(unix_string: &str) -> String {
58 if unix_string.is_empty() {
59 return "-".to_string();
60 }
61
62 if let Ok(timestamp) = unix_string.parse::<i64>() {
63 if let Some(dt) = DateTime::from_timestamp(timestamp, 0) {
64 format_timestamp(&dt)
65 } else {
66 unix_string.to_string()
67 }
68 } else {
69 unix_string.to_string()
70 }
71}
72
73#[derive(Debug, Clone, Copy, PartialEq)]
74pub enum ColumnType {
75 String,
76 Number,
77 DateTime,
78 Boolean,
79}
80
81pub fn format_bytes(bytes: i64) -> String {
82 const KB: i64 = 1000;
83 const MB: i64 = KB * 1000;
84 const GB: i64 = MB * 1000;
85 const TB: i64 = GB * 1000;
86
87 if bytes >= TB {
88 format!("{:.2} TB", bytes as f64 / TB as f64)
89 } else if bytes >= GB {
90 format!("{:.2} GB", bytes as f64 / GB as f64)
91 } else if bytes >= MB {
92 format!("{:.2} MB", bytes as f64 / MB as f64)
93 } else if bytes >= KB {
94 format!("{:.2} KB", bytes as f64 / KB as f64)
95 } else {
96 format!("{} B", bytes)
97 }
98}
99
100pub fn format_memory_mb(mb: i32) -> String {
101 if mb >= 1024 {
102 format!("{} GB", mb / 1024)
103 } else {
104 format!("{} MB", mb)
105 }
106}
107
108pub fn format_duration_seconds(seconds: i32) -> String {
109 if seconds == 0 {
110 return "0s".to_string();
111 }
112
113 let days = seconds / 86400;
114 let hours = (seconds % 86400) / 3600;
115 let minutes = (seconds % 3600) / 60;
116 let secs = seconds % 60;
117
118 let mut parts = Vec::new();
119 if days > 0 {
120 parts.push(format!("{}d", days));
121 }
122 if hours > 0 {
123 parts.push(format!("{}h", hours));
124 }
125 if minutes > 0 {
126 parts.push(format!("{}m", minutes));
127 }
128 if secs > 0 {
129 parts.push(format!("{}s", secs));
130 }
131
132 parts.join(" ")
133}
134
135pub fn border_style(is_active: bool) -> Style {
136 if is_active {
137 styles::active_border()
138 } else {
139 Style::default()
140 }
141}
142
143pub fn render_scrollbar(frame: &mut Frame, area: Rect, total: usize, position: usize) {
144 if total == 0 {
145 return;
146 }
147 let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
148 .begin_symbol(Some("↑"))
149 .end_symbol(Some("↓"));
150 let mut state = ScrollbarState::new(total).position(position);
151 frame.render_stateful_widget(scrollbar, area, &mut state);
152}
153
154pub fn render_vertical_scrollbar(frame: &mut Frame, area: Rect, total: usize, position: usize) {
155 render_scrollbar(frame, area, total, position);
156}
157
158pub fn render_horizontal_scrollbar(frame: &mut Frame, area: Rect, position: usize, total: usize) {
159 let scrollbar = Scrollbar::new(ScrollbarOrientation::HorizontalBottom)
160 .begin_symbol(Some("◀"))
161 .end_symbol(Some("▶"));
162 let mut state = ScrollbarState::new(total).position(position);
163 frame.render_stateful_widget(scrollbar, area, &mut state);
164}
165
166pub fn render_pagination(current: usize, total: usize) -> String {
167 if total == 0 {
168 return "[1]".to_string();
169 }
170 if total <= 10 {
171 return (0..total)
172 .map(|i| {
173 if i == current {
174 format!("[{}]", i + 1)
175 } else {
176 format!("{}", i + 1)
177 }
178 })
179 .collect::<Vec<_>>()
180 .join(" ");
181 }
182 let start = current.saturating_sub(4);
183 let end = (start + 9).min(total);
184 let start = if end == total {
185 total.saturating_sub(9)
186 } else {
187 start
188 };
189 (start..end)
190 .map(|i| {
191 if i == current {
192 format!("[{}]", i + 1)
193 } else {
194 format!("{}", i + 1)
195 }
196 })
197 .collect::<Vec<_>>()
198 .join(" ")
199}
200
201pub fn render_infinite_pagination(current: usize) -> String {
204 let mut parts = Vec::new();
205
206 let start = current.saturating_sub(4);
208
209 if start > 1 {
211 parts.push("1".to_string());
212 parts.push("...".to_string());
213 }
214
215 for i in start..current {
217 parts.push(format!("{}", i + 1));
218 }
219
220 parts.push(format!("[{}]", current + 1));
222
223 parts.push(format!("{}", current + 2));
225
226 parts.push("...".to_string());
228
229 parts.join(" ")
230}
231
232pub fn render_pagination_text(current: usize, total: usize) -> String {
233 render_pagination(current, total)
234}
235
236pub fn render_dropdown<T: AsRef<str>>(
237 frame: &mut ratatui::Frame,
238 items: &[T],
239 selected_index: usize,
240 filter_area: ratatui::prelude::Rect,
241 controls_after_width: u16,
242) {
243 use ratatui::prelude::*;
244 use ratatui::widgets::{Block, BorderType, Borders, Clear, List, ListItem};
245
246 let max_width = items
247 .iter()
248 .map(|item| item.as_ref().len())
249 .max()
250 .unwrap_or(10) as u16
251 + 4;
252
253 let dropdown_items: Vec<ListItem> = items
254 .iter()
255 .enumerate()
256 .map(|(idx, item)| {
257 let style = if idx == selected_index {
258 Style::default().fg(Color::Yellow).bold()
259 } else {
260 Style::default().fg(Color::White)
261 };
262 ListItem::new(format!(" {} ", item.as_ref())).style(style)
263 })
264 .collect();
265
266 let dropdown_height = dropdown_items.len() as u16 + 2;
267 let dropdown_width = max_width;
268 let dropdown_x = filter_area
269 .x
270 .saturating_add(filter_area.width)
271 .saturating_sub(controls_after_width + dropdown_width);
272
273 let dropdown_area = Rect {
274 x: dropdown_x,
275 y: filter_area.y + filter_area.height,
276 width: dropdown_width,
277 height: dropdown_height.min(10),
278 };
279
280 frame.render_widget(Clear, dropdown_area);
282
283 frame.render_widget(
284 List::new(dropdown_items)
285 .block(
286 Block::default()
287 .borders(Borders::ALL)
288 .border_type(BorderType::Rounded)
289 .border_style(Style::default().fg(Color::Yellow)),
290 )
291 .style(Style::default().bg(Color::Black)),
292 dropdown_area,
293 );
294}
295
296pub struct FilterConfig<'a> {
297 pub text: &'a str,
298 pub placeholder: &'a str,
299 pub is_active: bool,
300 pub right_content: Vec<(&'a str, &'a str)>,
301 pub area: Rect,
302}
303
304pub struct FilterAreaConfig<'a> {
305 pub filter_text: &'a str,
306 pub placeholder: &'a str,
307 pub mode: crate::keymap::Mode,
308 pub input_focus: FilterFocusType,
309 pub controls: Vec<FilterControl>,
310 pub area: Rect,
311}
312
313#[derive(Debug, Clone, Copy, PartialEq, Default)]
314pub enum SortDirection {
315 #[default]
316 Asc,
317 Desc,
318}
319
320impl SortDirection {
321 pub fn as_str(&self) -> &'static str {
322 match self {
323 SortDirection::Asc => "ASC",
324 SortDirection::Desc => "DESC",
325 }
326 }
327}
328
329#[derive(Debug, Clone, Copy, PartialEq, Default)]
330pub enum InputFocus {
331 #[default]
332 Filter,
333 Pagination,
334 Dropdown(&'static str),
335 Checkbox(&'static str),
336}
337
338impl InputFocus {
339 pub fn next(&self, controls: &[InputFocus]) -> Self {
340 if controls.is_empty() {
341 return *self;
342 }
343 let idx = controls.iter().position(|f| f == self).unwrap_or(0);
344 controls[(idx + 1) % controls.len()]
345 }
346
347 pub fn prev(&self, controls: &[InputFocus]) -> Self {
348 if controls.is_empty() {
349 return *self;
350 }
351 let idx = controls.iter().position(|f| f == self).unwrap_or(0);
352 controls[(idx + controls.len() - 1) % controls.len()]
353 }
354
355 pub fn handle_page_down(
357 &self,
358 selected: &mut usize,
359 scroll_offset: &mut usize,
360 page_size: usize,
361 filtered_count: usize,
362 ) {
363 if *self == InputFocus::Pagination {
364 let last_page_start = if filtered_count > page_size {
366 ((filtered_count - 1) / page_size) * page_size
367 } else {
368 0
369 };
370 *selected = (*selected + page_size).min(last_page_start);
371 *scroll_offset = *selected;
372 }
373 }
374
375 pub fn handle_page_up(
377 &self,
378 selected: &mut usize,
379 scroll_offset: &mut usize,
380 page_size: usize,
381 ) {
382 if *self == InputFocus::Pagination {
383 *selected = selected.saturating_sub(page_size);
384 *scroll_offset = *selected;
385 }
386 }
387}
388
389pub trait CyclicEnum: Copy + PartialEq + Sized + 'static {
390 const ALL: &'static [Self];
391
392 fn next(&self) -> Self {
393 let idx = Self::ALL.iter().position(|x| x == self).unwrap_or(0);
394 Self::ALL[(idx + 1) % Self::ALL.len()]
395 }
396
397 fn prev(&self) -> Self {
398 let idx = Self::ALL.iter().position(|x| x == self).unwrap_or(0);
399 Self::ALL[(idx + Self::ALL.len() - 1) % Self::ALL.len()]
400 }
401}
402
403#[derive(PartialEq)]
404pub enum FilterFocusType {
405 Input,
406 Control(usize),
407}
408
409pub struct FilterControl {
410 pub text: String,
411 pub is_focused: bool,
412 pub style: ratatui::style::Style,
413}
414
415pub fn render_filter_area(frame: &mut Frame, config: FilterAreaConfig) {
416 use crate::keymap::Mode;
417 use crate::ui::get_cursor;
418 use ratatui::prelude::*;
419
420 let cursor = get_cursor(
421 config.mode == Mode::FilterInput && config.input_focus == FilterFocusType::Input,
422 );
423 let filter_width = config.area.width.saturating_sub(4) as usize;
424
425 let controls_text: String = config
427 .controls
428 .iter()
429 .map(|c| c.text.as_str())
430 .collect::<Vec<_>>()
431 .join(" ⋮ ");
432 let controls_len = controls_text.len();
433
434 let placeholder_len = config.placeholder.len();
435 let content_len =
436 if config.filter_text.is_empty() && config.mode != Mode::FilterInput {
437 placeholder_len
438 } else {
439 config.filter_text.len()
440 } + if config.mode == Mode::FilterInput && config.input_focus == FilterFocusType::Input {
441 cursor.len()
442 } else {
443 0
444 };
445
446 let available_space = filter_width.saturating_sub(controls_len + 1);
447
448 let mut line_spans = vec![];
449 if config.filter_text.is_empty() {
450 if config.mode == Mode::FilterInput {
451 line_spans.push(Span::raw(""));
452 } else {
453 line_spans.push(Span::styled(
454 config.placeholder,
455 Style::default().fg(Color::DarkGray),
456 ));
457 }
458 } else {
459 line_spans.push(Span::raw(config.filter_text));
460 }
461
462 if config.mode == Mode::FilterInput && config.input_focus == FilterFocusType::Input {
463 line_spans.push(Span::styled(cursor, Style::default().fg(Color::Yellow)));
464 }
465
466 if content_len < available_space {
467 line_spans.push(Span::raw(" ".repeat(available_space - content_len)));
468 }
469
470 if config.mode == Mode::FilterInput {
471 for control in &config.controls {
472 line_spans.push(Span::raw(" ⋮ "));
473 line_spans.push(Span::styled(&control.text, control.style));
474 }
475 } else {
476 line_spans.push(Span::styled(
477 format!(" ⋮ {}", controls_text),
478 Style::default(),
479 ));
480 }
481
482 let filter = filter_area(line_spans, config.mode == Mode::FilterInput);
483 frame.render_widget(filter, config.area);
484}
485
486#[cfg(test)]
487mod tests {
488 use super::*;
489 use chrono::TimeZone;
490
491 #[test]
492 fn test_format_timestamp() {
493 let dt = Utc.with_ymd_and_hms(2025, 11, 12, 14, 30, 45).unwrap();
494 assert_eq!(format_timestamp(&dt), "2025-11-12 14:30:45 (UTC)");
495 }
496
497 #[test]
498 fn test_format_optional_timestamp_some() {
499 let dt = Utc.with_ymd_and_hms(2025, 11, 12, 14, 30, 45).unwrap();
500 assert_eq!(
501 format_optional_timestamp(Some(dt)),
502 "2025-11-12 14:30:45 (UTC)"
503 );
504 }
505
506 #[test]
507 fn test_format_optional_timestamp_none() {
508 assert_eq!(format_optional_timestamp(None), "-");
509 }
510
511 #[test]
512 fn test_format_bytes() {
513 assert_eq!(format_bytes(500), "500 B");
514 assert_eq!(format_bytes(1500), "1.50 KB");
515 assert_eq!(format_bytes(1_500_000), "1.50 MB");
516 assert_eq!(format_bytes(1_500_000_000), "1.50 GB");
517 assert_eq!(format_bytes(1_500_000_000_000), "1.50 TB");
518 }
519
520 #[test]
521 fn test_format_duration_seconds_zero() {
522 assert_eq!(format_duration_seconds(0), "0s");
523 }
524
525 #[test]
526 fn test_render_infinite_pagination_page_1() {
527 let result = render_infinite_pagination(0);
529 assert_eq!(result, "[1] 2 ...");
530 }
531
532 #[test]
533 fn test_render_infinite_pagination_page_5() {
534 let result = render_infinite_pagination(4);
536 assert_eq!(result, "1 2 3 4 [5] 6 ...");
537 }
538
539 #[test]
540 fn test_render_infinite_pagination_page_6() {
541 let result = render_infinite_pagination(5);
543 assert_eq!(result, "2 3 4 5 [6] 7 ...");
544 }
545
546 #[test]
547 fn test_render_infinite_pagination_page_7() {
548 let result = render_infinite_pagination(6);
550 assert_eq!(result, "1 ... 3 4 5 6 [7] 8 ...");
551 }
552
553 #[test]
554 fn test_render_infinite_pagination_page_10() {
555 let result = render_infinite_pagination(9);
557 assert_eq!(result, "1 ... 6 7 8 9 [10] 11 ...");
558 }
559
560 #[test]
561 fn test_render_infinite_pagination_page_100() {
562 let result = render_infinite_pagination(99);
564 assert_eq!(result, "1 ... 96 97 98 99 [100] 101 ...");
565 }
566
567 #[test]
568 fn test_format_duration_seconds_only_seconds() {
569 assert_eq!(format_duration_seconds(30), "30s");
570 }
571
572 #[test]
573 fn test_format_duration_seconds_minutes_and_seconds() {
574 assert_eq!(format_duration_seconds(120), "2m");
575 assert_eq!(format_duration_seconds(150), "2m 30s");
576 }
577
578 #[test]
579 fn test_format_duration_seconds_hours() {
580 assert_eq!(format_duration_seconds(3630), "1h 30s");
581 assert_eq!(format_duration_seconds(10800), "3h");
582 }
583
584 #[test]
585 fn test_format_duration_seconds_days() {
586 assert_eq!(format_duration_seconds(90061), "1d 1h 1m 1s");
587 assert_eq!(format_duration_seconds(345600), "4d");
588 }
589
590 #[test]
591 fn test_format_duration_seconds_complex() {
592 assert_eq!(format_duration_seconds(1800), "30m");
593 assert_eq!(format_duration_seconds(86400), "1d");
594 }
595
596 #[test]
597 fn test_render_pagination_single_page() {
598 assert_eq!(render_pagination(0, 1), "[1]");
599 }
600
601 #[test]
602 fn test_render_pagination_two_pages() {
603 assert_eq!(render_pagination(0, 2), "[1] 2");
604 assert_eq!(render_pagination(1, 2), "1 [2]");
605 }
606
607 #[test]
608 fn test_render_pagination_ten_pages() {
609 assert_eq!(render_pagination(0, 10), "[1] 2 3 4 5 6 7 8 9 10");
610 assert_eq!(render_pagination(5, 10), "1 2 3 4 5 [6] 7 8 9 10");
611 assert_eq!(render_pagination(9, 10), "1 2 3 4 5 6 7 8 9 [10]");
612 }
613
614 #[test]
615 fn test_format_memory_mb() {
616 assert_eq!(format_memory_mb(128), "128 MB");
617 assert_eq!(format_memory_mb(512), "512 MB");
618 assert_eq!(format_memory_mb(1024), "1 GB");
619 assert_eq!(format_memory_mb(2048), "2 GB");
620 }
621
622 #[test]
623 fn test_render_pagination_many_pages() {
624 assert_eq!(render_pagination(0, 20), "[1] 2 3 4 5 6 7 8 9");
625 assert_eq!(render_pagination(5, 20), "2 3 4 5 [6] 7 8 9 10");
626 assert_eq!(render_pagination(15, 20), "12 13 14 15 [16] 17 18 19 20");
627 assert_eq!(render_pagination(19, 20), "12 13 14 15 16 17 18 19 [20]");
628 }
629
630 #[test]
631 fn test_render_pagination_zero_total() {
632 assert_eq!(render_pagination(0, 0), "[1]");
633 }
634
635 #[test]
636 fn test_render_dropdown_items_format() {
637 let items = ["us-east-1", "us-west-2", "eu-west-1"];
638 assert_eq!(items.len(), 3);
639 assert_eq!(items[0], "us-east-1");
640 assert_eq!(items[2], "eu-west-1");
641 }
642
643 #[test]
644 fn test_render_dropdown_selected_index() {
645 let items = ["item1", "item2", "item3"];
646 let selected = 1;
647 assert_eq!(items[selected], "item2");
648 }
649
650 #[test]
651 fn test_render_dropdown_controls_after_width() {
652 let pagination_len = 10;
653 let separator = 3;
654 let controls_after = pagination_len + separator;
655 assert_eq!(controls_after, 13);
656 }
657
658 #[test]
659 fn test_render_dropdown_multiple_controls_after() {
660 let view_nested_width = 15;
661 let pagination_len = 10;
662 let controls_after = view_nested_width + 3 + pagination_len + 3;
663 assert_eq!(controls_after, 31);
664 }
665
666 #[test]
667 fn test_render_dropdown_clears_background() {
668 use ratatui::backend::TestBackend;
672 use ratatui::Terminal;
673
674 let backend = TestBackend::new(80, 24);
675 let mut terminal = Terminal::new(backend).unwrap();
676
677 terminal
678 .draw(|frame| {
679 let area = ratatui::prelude::Rect {
680 x: 0,
681 y: 0,
682 width: 80,
683 height: 3,
684 };
685 let items = ["Running", "Stopped", "Terminated"];
686 render_dropdown(frame, &items, 0, area, 10);
687 })
688 .unwrap();
689
690 }
693}
694
695pub fn render_filter(frame: &mut Frame, config: FilterConfig) {
696 let cursor = if config.is_active { "█" } else { "" };
697 let content = if config.text.is_empty() && !config.is_active {
698 config.placeholder
699 } else {
700 config.text
701 };
702
703 let right_text = config
704 .right_content
705 .iter()
706 .map(|(k, v)| format!("{}: {}", k, v))
707 .collect::<Vec<_>>()
708 .join(" ⋮ ");
709
710 let width = (config.area.width as usize).saturating_sub(4);
711 let right_len = right_text.len();
712 let content_len = content.len() + if config.is_active { cursor.len() } else { 0 };
713 let available = width.saturating_sub(right_len + 3);
714
715 let display = if content_len > available {
716 &content[content_len.saturating_sub(available)..]
717 } else {
718 content
719 };
720
721 let style = if config.is_active {
722 styles::yellow()
723 } else {
724 styles::placeholder()
725 };
726
727 let mut spans = vec![Span::styled(display, style)];
728 if config.is_active {
729 spans.push(Span::styled(cursor, styles::cursor()));
730 }
731
732 let padding = " ".repeat(
733 width
734 .saturating_sub(display.len())
735 .saturating_sub(if config.is_active { cursor.len() } else { 0 })
736 .saturating_sub(right_len)
737 .saturating_sub(3),
738 );
739
740 spans.push(Span::raw(padding));
741 spans.push(Span::styled(format!(" {}", right_text), styles::cyan()));
742
743 frame.render_widget(
744 Paragraph::new(Line::from(spans)).block(
745 Block::default()
746 .borders(Borders::ALL)
747 .border_style(border_style(config.is_active)),
748 ),
749 config.area,
750 );
751}
752
753#[derive(Debug, Clone, Copy, PartialEq)]
754pub enum PageSize {
755 Ten,
756 TwentyFive,
757 Fifty,
758 OneHundred,
759}
760
761pub fn filter_by_field<'a, T, F>(items: &'a [T], filter: &str, get_field: F) -> Vec<&'a T>
763where
764 F: Fn(&T) -> &str,
765{
766 if filter.is_empty() {
767 items.iter().collect()
768 } else {
769 let filter_lower = filter.to_lowercase();
770 items
771 .iter()
772 .filter(|item| get_field(item).to_lowercase().contains(&filter_lower))
773 .collect()
774 }
775}
776
777pub fn filter_by_fields<'a, T, F>(items: &'a [T], filter: &str, get_fields: F) -> Vec<&'a T>
779where
780 F: Fn(&T) -> Vec<&str>,
781{
782 if filter.is_empty() {
783 items.iter().collect()
784 } else {
785 let filter_lower = filter.to_lowercase();
786 items
787 .iter()
788 .filter(|item| {
789 get_fields(item)
790 .iter()
791 .any(|field| field.to_lowercase().contains(&filter_lower))
792 })
793 .collect()
794 }
795}