rusticity_term/
common.rs

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
32// Width for UTC timestamp format: "YYYY-MM-DD HH:MM:SS (UTC)"
33pub 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    // Parse ISO 8601 format (e.g., "2024-01-01T12:30:45.123Z")
50    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_pagination_text(current: usize, total: usize) -> String {
202    render_pagination(current, total)
203}
204
205pub fn render_dropdown<T: AsRef<str>>(
206    frame: &mut ratatui::Frame,
207    items: &[T],
208    selected_index: usize,
209    filter_area: ratatui::prelude::Rect,
210    controls_after_width: u16,
211) {
212    use ratatui::prelude::*;
213    use ratatui::widgets::{Block, BorderType, Borders, Clear, List, ListItem};
214
215    let max_width = items
216        .iter()
217        .map(|item| item.as_ref().len())
218        .max()
219        .unwrap_or(10) as u16
220        + 4;
221
222    let dropdown_items: Vec<ListItem> = items
223        .iter()
224        .enumerate()
225        .map(|(idx, item)| {
226            let style = if idx == selected_index {
227                Style::default().fg(Color::Yellow).bold()
228            } else {
229                Style::default().fg(Color::White)
230            };
231            ListItem::new(format!(" {} ", item.as_ref())).style(style)
232        })
233        .collect();
234
235    let dropdown_height = dropdown_items.len() as u16 + 2;
236    let dropdown_width = max_width;
237    let dropdown_x = filter_area
238        .x
239        .saturating_add(filter_area.width)
240        .saturating_sub(controls_after_width + dropdown_width);
241
242    let dropdown_area = Rect {
243        x: dropdown_x,
244        y: filter_area.y + filter_area.height,
245        width: dropdown_width,
246        height: dropdown_height.min(10),
247    };
248
249    // Clear the background first
250    frame.render_widget(Clear, dropdown_area);
251
252    frame.render_widget(
253        List::new(dropdown_items)
254            .block(
255                Block::default()
256                    .borders(Borders::ALL)
257                    .border_type(BorderType::Rounded)
258                    .border_style(Style::default().fg(Color::Yellow)),
259            )
260            .style(Style::default().bg(Color::Black)),
261        dropdown_area,
262    );
263}
264
265pub struct FilterConfig<'a> {
266    pub text: &'a str,
267    pub placeholder: &'a str,
268    pub is_active: bool,
269    pub right_content: Vec<(&'a str, &'a str)>,
270    pub area: Rect,
271}
272
273pub struct FilterAreaConfig<'a> {
274    pub filter_text: &'a str,
275    pub placeholder: &'a str,
276    pub mode: crate::keymap::Mode,
277    pub input_focus: FilterFocusType,
278    pub controls: Vec<FilterControl>,
279    pub area: Rect,
280}
281
282#[derive(Debug, Clone, Copy, PartialEq, Default)]
283pub enum SortDirection {
284    #[default]
285    Asc,
286    Desc,
287}
288
289impl SortDirection {
290    pub fn as_str(&self) -> &'static str {
291        match self {
292            SortDirection::Asc => "ASC",
293            SortDirection::Desc => "DESC",
294        }
295    }
296}
297
298#[derive(Debug, Clone, Copy, PartialEq, Default)]
299pub enum InputFocus {
300    #[default]
301    Filter,
302    Pagination,
303    Dropdown(&'static str),
304    Checkbox(&'static str),
305}
306
307impl InputFocus {
308    pub fn next(&self, controls: &[InputFocus]) -> Self {
309        if controls.is_empty() {
310            return *self;
311        }
312        let idx = controls.iter().position(|f| f == self).unwrap_or(0);
313        controls[(idx + 1) % controls.len()]
314    }
315
316    pub fn prev(&self, controls: &[InputFocus]) -> Self {
317        if controls.is_empty() {
318            return *self;
319        }
320        let idx = controls.iter().position(|f| f == self).unwrap_or(0);
321        controls[(idx + controls.len() - 1) % controls.len()]
322    }
323
324    /// Navigate to next page when pagination is focused
325    pub fn handle_page_down(
326        &self,
327        selected: &mut usize,
328        scroll_offset: &mut usize,
329        page_size: usize,
330        filtered_count: usize,
331    ) {
332        if *self == InputFocus::Pagination {
333            let max_offset = filtered_count.saturating_sub(page_size);
334            *selected = (*selected + page_size).min(max_offset);
335            *scroll_offset = *selected;
336        }
337    }
338
339    /// Navigate to previous page when pagination is focused
340    pub fn handle_page_up(
341        &self,
342        selected: &mut usize,
343        scroll_offset: &mut usize,
344        page_size: usize,
345    ) {
346        if *self == InputFocus::Pagination {
347            *selected = selected.saturating_sub(page_size);
348            *scroll_offset = *selected;
349        }
350    }
351}
352
353pub trait CyclicEnum: Copy + PartialEq + Sized + 'static {
354    const ALL: &'static [Self];
355
356    fn next(&self) -> Self {
357        let idx = Self::ALL.iter().position(|x| x == self).unwrap_or(0);
358        Self::ALL[(idx + 1) % Self::ALL.len()]
359    }
360
361    fn prev(&self) -> Self {
362        let idx = Self::ALL.iter().position(|x| x == self).unwrap_or(0);
363        Self::ALL[(idx + Self::ALL.len() - 1) % Self::ALL.len()]
364    }
365}
366
367#[derive(PartialEq)]
368pub enum FilterFocusType {
369    Input,
370    Control(usize),
371}
372
373pub struct FilterControl {
374    pub text: String,
375    pub is_focused: bool,
376    pub style: ratatui::style::Style,
377}
378
379pub fn render_filter_area(frame: &mut Frame, config: FilterAreaConfig) {
380    use crate::keymap::Mode;
381    use crate::ui::get_cursor;
382    use ratatui::prelude::*;
383
384    let cursor = get_cursor(
385        config.mode == Mode::FilterInput && config.input_focus == FilterFocusType::Input,
386    );
387    let filter_width = config.area.width.saturating_sub(4) as usize;
388
389    // Calculate controls text
390    let controls_text: String = config
391        .controls
392        .iter()
393        .map(|c| c.text.as_str())
394        .collect::<Vec<_>>()
395        .join(" ⋮ ");
396    let controls_len = controls_text.len();
397
398    let placeholder_len = config.placeholder.len();
399    let content_len =
400        if config.filter_text.is_empty() && config.mode != Mode::FilterInput {
401            placeholder_len
402        } else {
403            config.filter_text.len()
404        } + if config.mode == Mode::FilterInput && config.input_focus == FilterFocusType::Input {
405            cursor.len()
406        } else {
407            0
408        };
409
410    let available_space = filter_width.saturating_sub(controls_len + 1);
411
412    let mut line_spans = vec![];
413    if config.filter_text.is_empty() {
414        if config.mode == Mode::FilterInput {
415            line_spans.push(Span::raw(""));
416        } else {
417            line_spans.push(Span::styled(
418                config.placeholder,
419                Style::default().fg(Color::DarkGray),
420            ));
421        }
422    } else {
423        line_spans.push(Span::raw(config.filter_text));
424    }
425
426    if config.mode == Mode::FilterInput && config.input_focus == FilterFocusType::Input {
427        line_spans.push(Span::styled(cursor, Style::default().fg(Color::Yellow)));
428    }
429
430    if content_len < available_space {
431        line_spans.push(Span::raw(" ".repeat(available_space - content_len)));
432    }
433
434    if config.mode == Mode::FilterInput {
435        for control in &config.controls {
436            line_spans.push(Span::raw(" ⋮ "));
437            line_spans.push(Span::styled(&control.text, control.style));
438        }
439    } else {
440        line_spans.push(Span::styled(
441            format!(" ⋮ {}", controls_text),
442            Style::default(),
443        ));
444    }
445
446    let filter = filter_area(line_spans, config.mode == Mode::FilterInput);
447    frame.render_widget(filter, config.area);
448}
449
450#[cfg(test)]
451mod tests {
452    use super::*;
453    use chrono::TimeZone;
454
455    #[test]
456    fn test_format_timestamp() {
457        let dt = Utc.with_ymd_and_hms(2025, 11, 12, 14, 30, 45).unwrap();
458        assert_eq!(format_timestamp(&dt), "2025-11-12 14:30:45 (UTC)");
459    }
460
461    #[test]
462    fn test_format_optional_timestamp_some() {
463        let dt = Utc.with_ymd_and_hms(2025, 11, 12, 14, 30, 45).unwrap();
464        assert_eq!(
465            format_optional_timestamp(Some(dt)),
466            "2025-11-12 14:30:45 (UTC)"
467        );
468    }
469
470    #[test]
471    fn test_format_optional_timestamp_none() {
472        assert_eq!(format_optional_timestamp(None), "-");
473    }
474
475    #[test]
476    fn test_format_bytes() {
477        assert_eq!(format_bytes(500), "500 B");
478        assert_eq!(format_bytes(1500), "1.50 KB");
479        assert_eq!(format_bytes(1_500_000), "1.50 MB");
480        assert_eq!(format_bytes(1_500_000_000), "1.50 GB");
481        assert_eq!(format_bytes(1_500_000_000_000), "1.50 TB");
482    }
483
484    #[test]
485    fn test_format_duration_seconds_zero() {
486        assert_eq!(format_duration_seconds(0), "0s");
487    }
488
489    #[test]
490    fn test_format_duration_seconds_only_seconds() {
491        assert_eq!(format_duration_seconds(30), "30s");
492    }
493
494    #[test]
495    fn test_format_duration_seconds_minutes_and_seconds() {
496        assert_eq!(format_duration_seconds(120), "2m");
497        assert_eq!(format_duration_seconds(150), "2m 30s");
498    }
499
500    #[test]
501    fn test_format_duration_seconds_hours() {
502        assert_eq!(format_duration_seconds(3630), "1h 30s");
503        assert_eq!(format_duration_seconds(10800), "3h");
504    }
505
506    #[test]
507    fn test_format_duration_seconds_days() {
508        assert_eq!(format_duration_seconds(90061), "1d 1h 1m 1s");
509        assert_eq!(format_duration_seconds(345600), "4d");
510    }
511
512    #[test]
513    fn test_format_duration_seconds_complex() {
514        assert_eq!(format_duration_seconds(1800), "30m");
515        assert_eq!(format_duration_seconds(86400), "1d");
516    }
517
518    #[test]
519    fn test_render_pagination_single_page() {
520        assert_eq!(render_pagination(0, 1), "[1]");
521    }
522
523    #[test]
524    fn test_render_pagination_two_pages() {
525        assert_eq!(render_pagination(0, 2), "[1] 2");
526        assert_eq!(render_pagination(1, 2), "1 [2]");
527    }
528
529    #[test]
530    fn test_render_pagination_ten_pages() {
531        assert_eq!(render_pagination(0, 10), "[1] 2 3 4 5 6 7 8 9 10");
532        assert_eq!(render_pagination(5, 10), "1 2 3 4 5 [6] 7 8 9 10");
533        assert_eq!(render_pagination(9, 10), "1 2 3 4 5 6 7 8 9 [10]");
534    }
535
536    #[test]
537    fn test_format_memory_mb() {
538        assert_eq!(format_memory_mb(128), "128 MB");
539        assert_eq!(format_memory_mb(512), "512 MB");
540        assert_eq!(format_memory_mb(1024), "1 GB");
541        assert_eq!(format_memory_mb(2048), "2 GB");
542    }
543
544    #[test]
545    fn test_render_pagination_many_pages() {
546        assert_eq!(render_pagination(0, 20), "[1] 2 3 4 5 6 7 8 9");
547        assert_eq!(render_pagination(5, 20), "2 3 4 5 [6] 7 8 9 10");
548        assert_eq!(render_pagination(15, 20), "12 13 14 15 [16] 17 18 19 20");
549        assert_eq!(render_pagination(19, 20), "12 13 14 15 16 17 18 19 [20]");
550    }
551
552    #[test]
553    fn test_render_pagination_zero_total() {
554        assert_eq!(render_pagination(0, 0), "[1]");
555    }
556
557    #[test]
558    fn test_render_dropdown_items_format() {
559        let items = ["us-east-1", "us-west-2", "eu-west-1"];
560        assert_eq!(items.len(), 3);
561        assert_eq!(items[0], "us-east-1");
562        assert_eq!(items[2], "eu-west-1");
563    }
564
565    #[test]
566    fn test_render_dropdown_selected_index() {
567        let items = ["item1", "item2", "item3"];
568        let selected = 1;
569        assert_eq!(items[selected], "item2");
570    }
571
572    #[test]
573    fn test_render_dropdown_controls_after_width() {
574        let pagination_len = 10;
575        let separator = 3;
576        let controls_after = pagination_len + separator;
577        assert_eq!(controls_after, 13);
578    }
579
580    #[test]
581    fn test_render_dropdown_multiple_controls_after() {
582        let view_nested_width = 15;
583        let pagination_len = 10;
584        let controls_after = view_nested_width + 3 + pagination_len + 3;
585        assert_eq!(controls_after, 31);
586    }
587
588    #[test]
589    fn test_render_dropdown_clears_background() {
590        // This test verifies that render_dropdown uses Clear widget
591        // The actual rendering is tested via integration tests
592        // Here we just verify the function can be called with valid parameters
593        use ratatui::backend::TestBackend;
594        use ratatui::Terminal;
595
596        let backend = TestBackend::new(80, 24);
597        let mut terminal = Terminal::new(backend).unwrap();
598
599        terminal
600            .draw(|frame| {
601                let area = ratatui::prelude::Rect {
602                    x: 0,
603                    y: 0,
604                    width: 80,
605                    height: 3,
606                };
607                let items = ["Running", "Stopped", "Terminated"];
608                render_dropdown(frame, &items, 0, area, 10);
609            })
610            .unwrap();
611
612        // If we get here without panic, the function works correctly
613        // The Clear widget is used internally to clear the background
614    }
615}
616
617pub fn render_filter(frame: &mut Frame, config: FilterConfig) {
618    let cursor = if config.is_active { "█" } else { "" };
619    let content = if config.text.is_empty() && !config.is_active {
620        config.placeholder
621    } else {
622        config.text
623    };
624
625    let right_text = config
626        .right_content
627        .iter()
628        .map(|(k, v)| format!("{}: {}", k, v))
629        .collect::<Vec<_>>()
630        .join(" ⋮ ");
631
632    let width = (config.area.width as usize).saturating_sub(4);
633    let right_len = right_text.len();
634    let content_len = content.len() + if config.is_active { cursor.len() } else { 0 };
635    let available = width.saturating_sub(right_len + 3);
636
637    let display = if content_len > available {
638        &content[content_len.saturating_sub(available)..]
639    } else {
640        content
641    };
642
643    let style = if config.is_active {
644        styles::yellow()
645    } else {
646        styles::placeholder()
647    };
648
649    let mut spans = vec![Span::styled(display, style)];
650    if config.is_active {
651        spans.push(Span::styled(cursor, styles::cursor()));
652    }
653
654    let padding = " ".repeat(
655        width
656            .saturating_sub(display.len())
657            .saturating_sub(if config.is_active { cursor.len() } else { 0 })
658            .saturating_sub(right_len)
659            .saturating_sub(3),
660    );
661
662    spans.push(Span::raw(padding));
663    spans.push(Span::styled(format!(" {}", right_text), styles::cyan()));
664
665    frame.render_widget(
666        Paragraph::new(Line::from(spans)).block(
667            Block::default()
668                .borders(Borders::ALL)
669                .border_style(border_style(config.is_active)),
670        ),
671        config.area,
672    );
673}
674
675#[derive(Debug, Clone, Copy, PartialEq)]
676pub enum PageSize {
677    Ten,
678    TwentyFive,
679    Fifty,
680    OneHundred,
681}
682
683/// Generic helper to filter items by a field that matches a filter string (case-insensitive contains)
684pub fn filter_by_field<'a, T, F>(items: &'a [T], filter: &str, get_field: F) -> Vec<&'a T>
685where
686    F: Fn(&T) -> &str,
687{
688    if filter.is_empty() {
689        items.iter().collect()
690    } else {
691        let filter_lower = filter.to_lowercase();
692        items
693            .iter()
694            .filter(|item| get_field(item).to_lowercase().contains(&filter_lower))
695            .collect()
696    }
697}
698
699/// Generic helper to filter items by multiple fields (case-insensitive contains on any field)
700pub fn filter_by_fields<'a, T, F>(items: &'a [T], filter: &str, get_fields: F) -> Vec<&'a T>
701where
702    F: Fn(&T) -> Vec<&str>,
703{
704    if filter.is_empty() {
705        items.iter().collect()
706    } else {
707        let filter_lower = filter.to_lowercase();
708        items
709            .iter()
710            .filter(|item| {
711                get_fields(item)
712                    .iter()
713                    .any(|field| field.to_lowercase().contains(&filter_lower))
714            })
715            .collect()
716    }
717}