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, 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    frame.render_widget(
250        List::new(dropdown_items)
251            .block(
252                Block::default()
253                    .borders(Borders::ALL)
254                    .border_type(BorderType::Rounded)
255                    .border_style(Style::default().fg(Color::Yellow)),
256            )
257            .style(Style::default().bg(Color::Black)),
258        dropdown_area,
259    );
260}
261
262pub struct FilterConfig<'a> {
263    pub text: &'a str,
264    pub placeholder: &'a str,
265    pub is_active: bool,
266    pub right_content: Vec<(&'a str, &'a str)>,
267    pub area: Rect,
268}
269
270pub struct FilterAreaConfig<'a> {
271    pub filter_text: &'a str,
272    pub placeholder: &'a str,
273    pub mode: crate::keymap::Mode,
274    pub input_focus: FilterFocusType,
275    pub controls: Vec<FilterControl>,
276    pub area: Rect,
277}
278
279#[derive(Debug, Clone, Copy, PartialEq, Default)]
280pub enum SortDirection {
281    #[default]
282    Asc,
283    Desc,
284}
285
286impl SortDirection {
287    pub fn as_str(&self) -> &'static str {
288        match self {
289            SortDirection::Asc => "ASC",
290            SortDirection::Desc => "DESC",
291        }
292    }
293}
294
295#[derive(Debug, Clone, Copy, PartialEq, Default)]
296pub enum InputFocus {
297    #[default]
298    Filter,
299    Pagination,
300    Dropdown(&'static str),
301    Checkbox(&'static str),
302}
303
304impl InputFocus {
305    pub fn next(&self, controls: &[InputFocus]) -> Self {
306        if controls.is_empty() {
307            return *self;
308        }
309        let idx = controls.iter().position(|f| f == self).unwrap_or(0);
310        controls[(idx + 1) % controls.len()]
311    }
312
313    pub fn prev(&self, controls: &[InputFocus]) -> Self {
314        if controls.is_empty() {
315            return *self;
316        }
317        let idx = controls.iter().position(|f| f == self).unwrap_or(0);
318        controls[(idx + controls.len() - 1) % controls.len()]
319    }
320
321    /// Navigate to next page when pagination is focused
322    pub fn handle_page_down(
323        &self,
324        selected: &mut usize,
325        scroll_offset: &mut usize,
326        page_size: usize,
327        filtered_count: usize,
328    ) {
329        if *self == InputFocus::Pagination {
330            let max_offset = filtered_count.saturating_sub(page_size);
331            *selected = (*selected + page_size).min(max_offset);
332            *scroll_offset = *selected;
333        }
334    }
335
336    /// Navigate to previous page when pagination is focused
337    pub fn handle_page_up(
338        &self,
339        selected: &mut usize,
340        scroll_offset: &mut usize,
341        page_size: usize,
342    ) {
343        if *self == InputFocus::Pagination {
344            *selected = selected.saturating_sub(page_size);
345            *scroll_offset = *selected;
346        }
347    }
348}
349
350pub trait CyclicEnum: Copy + PartialEq + Sized + 'static {
351    const ALL: &'static [Self];
352
353    fn next(&self) -> Self {
354        let idx = Self::ALL.iter().position(|x| x == self).unwrap_or(0);
355        Self::ALL[(idx + 1) % Self::ALL.len()]
356    }
357
358    fn prev(&self) -> Self {
359        let idx = Self::ALL.iter().position(|x| x == self).unwrap_or(0);
360        Self::ALL[(idx + Self::ALL.len() - 1) % Self::ALL.len()]
361    }
362}
363
364#[derive(PartialEq)]
365pub enum FilterFocusType {
366    Input,
367    Control(usize),
368}
369
370pub struct FilterControl {
371    pub text: String,
372    pub is_focused: bool,
373    pub style: ratatui::style::Style,
374}
375
376pub fn render_filter_area(frame: &mut Frame, config: FilterAreaConfig) {
377    use crate::keymap::Mode;
378    use crate::ui::get_cursor;
379    use ratatui::prelude::*;
380
381    let cursor = get_cursor(
382        config.mode == Mode::FilterInput && config.input_focus == FilterFocusType::Input,
383    );
384    let filter_width = config.area.width.saturating_sub(4) as usize;
385
386    // Calculate controls text
387    let controls_text: String = config
388        .controls
389        .iter()
390        .map(|c| c.text.as_str())
391        .collect::<Vec<_>>()
392        .join(" ⋮ ");
393    let controls_len = controls_text.len();
394
395    let placeholder_len = config.placeholder.len();
396    let content_len =
397        if config.filter_text.is_empty() && config.mode != Mode::FilterInput {
398            placeholder_len
399        } else {
400            config.filter_text.len()
401        } + if config.mode == Mode::FilterInput && config.input_focus == FilterFocusType::Input {
402            cursor.len()
403        } else {
404            0
405        };
406
407    let available_space = filter_width.saturating_sub(controls_len + 1);
408
409    let mut line_spans = vec![];
410    if config.filter_text.is_empty() {
411        if config.mode == Mode::FilterInput {
412            line_spans.push(Span::raw(""));
413        } else {
414            line_spans.push(Span::styled(
415                config.placeholder,
416                Style::default().fg(Color::DarkGray),
417            ));
418        }
419    } else {
420        line_spans.push(Span::raw(config.filter_text));
421    }
422
423    if config.mode == Mode::FilterInput && config.input_focus == FilterFocusType::Input {
424        line_spans.push(Span::styled(cursor, Style::default().fg(Color::Yellow)));
425    }
426
427    if content_len < available_space {
428        line_spans.push(Span::raw(" ".repeat(available_space - content_len)));
429    }
430
431    if config.mode == Mode::FilterInput {
432        for control in &config.controls {
433            line_spans.push(Span::raw(" ⋮ "));
434            line_spans.push(Span::styled(&control.text, control.style));
435        }
436    } else {
437        line_spans.push(Span::styled(
438            format!(" ⋮ {}", controls_text),
439            Style::default(),
440        ));
441    }
442
443    let filter = filter_area(line_spans, config.mode == Mode::FilterInput);
444    frame.render_widget(filter, config.area);
445}
446
447#[cfg(test)]
448mod tests {
449    use super::*;
450    use chrono::TimeZone;
451
452    #[test]
453    fn test_format_timestamp() {
454        let dt = Utc.with_ymd_and_hms(2025, 11, 12, 14, 30, 45).unwrap();
455        assert_eq!(format_timestamp(&dt), "2025-11-12 14:30:45 (UTC)");
456    }
457
458    #[test]
459    fn test_format_optional_timestamp_some() {
460        let dt = Utc.with_ymd_and_hms(2025, 11, 12, 14, 30, 45).unwrap();
461        assert_eq!(
462            format_optional_timestamp(Some(dt)),
463            "2025-11-12 14:30:45 (UTC)"
464        );
465    }
466
467    #[test]
468    fn test_format_optional_timestamp_none() {
469        assert_eq!(format_optional_timestamp(None), "-");
470    }
471
472    #[test]
473    fn test_format_bytes() {
474        assert_eq!(format_bytes(500), "500 B");
475        assert_eq!(format_bytes(1500), "1.50 KB");
476        assert_eq!(format_bytes(1_500_000), "1.50 MB");
477        assert_eq!(format_bytes(1_500_000_000), "1.50 GB");
478        assert_eq!(format_bytes(1_500_000_000_000), "1.50 TB");
479    }
480
481    #[test]
482    fn test_format_duration_seconds_zero() {
483        assert_eq!(format_duration_seconds(0), "0s");
484    }
485
486    #[test]
487    fn test_format_duration_seconds_only_seconds() {
488        assert_eq!(format_duration_seconds(30), "30s");
489    }
490
491    #[test]
492    fn test_format_duration_seconds_minutes_and_seconds() {
493        assert_eq!(format_duration_seconds(120), "2m");
494        assert_eq!(format_duration_seconds(150), "2m 30s");
495    }
496
497    #[test]
498    fn test_format_duration_seconds_hours() {
499        assert_eq!(format_duration_seconds(3630), "1h 30s");
500        assert_eq!(format_duration_seconds(10800), "3h");
501    }
502
503    #[test]
504    fn test_format_duration_seconds_days() {
505        assert_eq!(format_duration_seconds(90061), "1d 1h 1m 1s");
506        assert_eq!(format_duration_seconds(345600), "4d");
507    }
508
509    #[test]
510    fn test_format_duration_seconds_complex() {
511        assert_eq!(format_duration_seconds(1800), "30m");
512        assert_eq!(format_duration_seconds(86400), "1d");
513    }
514
515    #[test]
516    fn test_render_pagination_single_page() {
517        assert_eq!(render_pagination(0, 1), "[1]");
518    }
519
520    #[test]
521    fn test_render_pagination_two_pages() {
522        assert_eq!(render_pagination(0, 2), "[1] 2");
523        assert_eq!(render_pagination(1, 2), "1 [2]");
524    }
525
526    #[test]
527    fn test_render_pagination_ten_pages() {
528        assert_eq!(render_pagination(0, 10), "[1] 2 3 4 5 6 7 8 9 10");
529        assert_eq!(render_pagination(5, 10), "1 2 3 4 5 [6] 7 8 9 10");
530        assert_eq!(render_pagination(9, 10), "1 2 3 4 5 6 7 8 9 [10]");
531    }
532
533    #[test]
534    fn test_format_memory_mb() {
535        assert_eq!(format_memory_mb(128), "128 MB");
536        assert_eq!(format_memory_mb(512), "512 MB");
537        assert_eq!(format_memory_mb(1024), "1 GB");
538        assert_eq!(format_memory_mb(2048), "2 GB");
539    }
540
541    #[test]
542    fn test_render_pagination_many_pages() {
543        assert_eq!(render_pagination(0, 20), "[1] 2 3 4 5 6 7 8 9");
544        assert_eq!(render_pagination(5, 20), "2 3 4 5 [6] 7 8 9 10");
545        assert_eq!(render_pagination(15, 20), "12 13 14 15 [16] 17 18 19 20");
546        assert_eq!(render_pagination(19, 20), "12 13 14 15 16 17 18 19 [20]");
547    }
548
549    #[test]
550    fn test_render_pagination_zero_total() {
551        assert_eq!(render_pagination(0, 0), "[1]");
552    }
553
554    #[test]
555    fn test_render_dropdown_items_format() {
556        let items = ["us-east-1", "us-west-2", "eu-west-1"];
557        assert_eq!(items.len(), 3);
558        assert_eq!(items[0], "us-east-1");
559        assert_eq!(items[2], "eu-west-1");
560    }
561
562    #[test]
563    fn test_render_dropdown_selected_index() {
564        let items = ["item1", "item2", "item3"];
565        let selected = 1;
566        assert_eq!(items[selected], "item2");
567    }
568
569    #[test]
570    fn test_render_dropdown_controls_after_width() {
571        let pagination_len = 10;
572        let separator = 3;
573        let controls_after = pagination_len + separator;
574        assert_eq!(controls_after, 13);
575    }
576
577    #[test]
578    fn test_render_dropdown_multiple_controls_after() {
579        let view_nested_width = 15;
580        let pagination_len = 10;
581        let controls_after = view_nested_width + 3 + pagination_len + 3;
582        assert_eq!(controls_after, 31);
583    }
584}
585
586pub fn render_filter(frame: &mut Frame, config: FilterConfig) {
587    let cursor = if config.is_active { "█" } else { "" };
588    let content = if config.text.is_empty() && !config.is_active {
589        config.placeholder
590    } else {
591        config.text
592    };
593
594    let right_text = config
595        .right_content
596        .iter()
597        .map(|(k, v)| format!("{}: {}", k, v))
598        .collect::<Vec<_>>()
599        .join(" ⋮ ");
600
601    let width = (config.area.width as usize).saturating_sub(4);
602    let right_len = right_text.len();
603    let content_len = content.len() + if config.is_active { cursor.len() } else { 0 };
604    let available = width.saturating_sub(right_len + 3);
605
606    let display = if content_len > available {
607        &content[content_len.saturating_sub(available)..]
608    } else {
609        content
610    };
611
612    let style = if config.is_active {
613        styles::yellow()
614    } else {
615        styles::placeholder()
616    };
617
618    let mut spans = vec![Span::styled(display, style)];
619    if config.is_active {
620        spans.push(Span::styled(cursor, styles::cursor()));
621    }
622
623    let padding = " ".repeat(
624        width
625            .saturating_sub(display.len())
626            .saturating_sub(if config.is_active { cursor.len() } else { 0 })
627            .saturating_sub(right_len)
628            .saturating_sub(3),
629    );
630
631    spans.push(Span::raw(padding));
632    spans.push(Span::styled(format!(" {}", right_text), styles::cyan()));
633
634    frame.render_widget(
635        Paragraph::new(Line::from(spans)).block(
636            Block::default()
637                .borders(Borders::ALL)
638                .border_style(border_style(config.is_active)),
639        ),
640        config.area,
641    );
642}
643
644#[derive(Debug, Clone, Copy, PartialEq)]
645pub enum PageSize {
646    Ten,
647    TwentyFive,
648    Fifty,
649    OneHundred,
650}