rusticity_term/
common.rs

1use chrono::{DateTime, Utc};
2use ratatui::{prelude::*, widgets::*};
3
4use crate::ui::styles;
5
6// Width for UTC timestamp format: "YYYY-MM-DD HH:MM:SS (UTC)"
7pub const UTC_TIMESTAMP_WIDTH: u16 = 27;
8
9pub fn format_timestamp(dt: &DateTime<Utc>) -> String {
10    format!("{} (UTC)", dt.format("%Y-%m-%d %H:%M:%S"))
11}
12
13pub fn format_optional_timestamp(dt: Option<DateTime<Utc>>) -> String {
14    dt.map(|t| format_timestamp(&t))
15        .unwrap_or_else(|| "-".to_string())
16}
17
18pub fn format_iso_timestamp(iso_string: &str) -> String {
19    if iso_string.is_empty() {
20        return "-".to_string();
21    }
22
23    // Parse ISO 8601 format (e.g., "2024-01-01T12:30:45.123Z")
24    if let Ok(dt) = DateTime::parse_from_rfc3339(iso_string) {
25        format_timestamp(&dt.with_timezone(&Utc))
26    } else {
27        iso_string.to_string()
28    }
29}
30
31pub trait ColumnTrait {
32    fn name(&self) -> &'static str;
33
34    // Future: column type for formatting
35    fn column_type(&self) -> ColumnType {
36        ColumnType::String
37    }
38
39    // Future: translation key for i18n
40    fn translation_key(&self) -> Option<&'static str> {
41        None
42    }
43}
44
45#[derive(Debug, Clone, Copy, PartialEq)]
46pub enum ColumnType {
47    String,
48    Number,
49    DateTime,
50    Boolean,
51}
52
53#[macro_export]
54macro_rules! column {
55    (name=$name:expr, width=$width:expr, type=$item_type:ty, render_styled=$render:expr) => {{
56        struct __Column;
57        impl $crate::ui::table::Column<$item_type> for __Column {
58            fn name(&self) -> &str {
59                $name
60            }
61            fn width(&self) -> u16 {
62                ($width).max($name.len() as u16)
63            }
64            fn render(&self, item: &$item_type) -> (String, Style) {
65                $render(item)
66            }
67        }
68        __Column
69    }};
70    (name=$name:expr, type=$item_type:ty, render_styled=$render:expr) => {{
71        column!(name=$name, width=0, type=$item_type, render_styled=$render)
72    }};
73    (name=$name:expr, width=$width:expr, type=$item_type:ty, render_expanded=$render:expr) => {{
74        struct __Column;
75        impl $crate::ui::table::Column<$item_type> for __Column {
76            fn name(&self) -> &str {
77                $name
78            }
79            fn width(&self) -> u16 {
80                ($width).max($name.len() as u16)
81            }
82            fn render(&self, item: &$item_type) -> (String, Style) {
83                ($render(item), Style::default())
84            }
85        }
86        __Column
87    }};
88    (name=$name:expr, type=$item_type:ty, render_expanded=$render:expr) => {{
89        column!(name=$name, width=0, type=$item_type, render_expanded=$render)
90    }};
91    (name=$name:expr, width=$width:expr, type=$item_type:ty, render=$render:expr) => {{
92        struct __Column;
93        impl $crate::ui::table::Column<$item_type> for __Column {
94            fn name(&self) -> &str {
95                $name
96            }
97            fn width(&self) -> u16 {
98                ($width).max($name.len() as u16)
99            }
100            fn render(&self, item: &$item_type) -> (String, Style) {
101                ($render(item), Style::default())
102            }
103        }
104        __Column
105    }};
106    (name=$name:expr, type=$item_type:ty, render=$render:expr) => {{
107        column!(name=$name, width=0, type=$item_type, render=$render)
108    }};
109    (name=$name:expr, width=$width:expr, type=$item_type:ty, field=$field:ident) => {{
110        struct __Column;
111        impl $crate::ui::table::Column<$item_type> for __Column {
112            fn name(&self) -> &str {
113                $name
114            }
115            fn width(&self) -> u16 {
116                ($width).max($name.len() as u16)
117            }
118            fn render(&self, item: &$item_type) -> (String, Style) {
119                (item.$field.clone(), Style::default())
120            }
121        }
122        __Column
123    }};
124    (name=$name:expr, type=$item_type:ty, field=$field:ident) => {{
125        column!(name=$name, width=0, type=$item_type, field=$field)
126    }};
127    ($name:expr, $width:expr, $item_type:ty, $field:ident) => {{
128        struct __Column;
129        impl $crate::ui::table::Column<$item_type> for __Column {
130            fn name(&self) -> &str {
131                $name
132            }
133            fn width(&self) -> u16 {
134                ($width).max($name.len() as u16)
135            }
136            fn render(&self, item: &$item_type) -> (String, Style) {
137                (item.$field.clone(), Style::default())
138            }
139        }
140        __Column
141    }};
142}
143
144pub fn format_bytes(bytes: i64) -> String {
145    const KB: i64 = 1000;
146    const MB: i64 = KB * 1000;
147    const GB: i64 = MB * 1000;
148    const TB: i64 = GB * 1000;
149
150    if bytes >= TB {
151        format!("{:.2} TB", bytes as f64 / TB as f64)
152    } else if bytes >= GB {
153        format!("{:.2} GB", bytes as f64 / GB as f64)
154    } else if bytes >= MB {
155        format!("{:.2} MB", bytes as f64 / MB as f64)
156    } else if bytes >= KB {
157        format!("{:.2} KB", bytes as f64 / KB as f64)
158    } else {
159        format!("{} B", bytes)
160    }
161}
162
163pub fn format_memory_mb(mb: i32) -> String {
164    if mb >= 1024 {
165        format!("{} GB", mb / 1024)
166    } else {
167        format!("{} MB", mb)
168    }
169}
170
171pub fn format_duration_seconds(seconds: i32) -> String {
172    let minutes = seconds / 60;
173    let secs = seconds % 60;
174    if minutes > 0 {
175        format!("{}min {}sec", minutes, secs)
176    } else {
177        format!("{}sec", secs)
178    }
179}
180
181pub fn border_style(is_active: bool) -> Style {
182    if is_active {
183        styles::active_border()
184    } else {
185        Style::default()
186    }
187}
188
189pub fn render_scrollbar(frame: &mut Frame, area: Rect, total: usize, position: usize) {
190    if total == 0 {
191        return;
192    }
193    let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
194        .begin_symbol(Some("↑"))
195        .end_symbol(Some("↓"));
196    let mut state = ScrollbarState::new(total).position(position);
197    frame.render_stateful_widget(scrollbar, area, &mut state);
198}
199
200pub fn render_vertical_scrollbar(frame: &mut Frame, area: Rect, total: usize, position: usize) {
201    render_scrollbar(frame, area, total, position);
202}
203
204pub fn render_horizontal_scrollbar(frame: &mut Frame, area: Rect, position: usize, total: usize) {
205    let scrollbar = Scrollbar::new(ScrollbarOrientation::HorizontalBottom)
206        .begin_symbol(Some("◀"))
207        .end_symbol(Some("▶"));
208    let mut state = ScrollbarState::new(total).position(position);
209    frame.render_stateful_widget(scrollbar, area, &mut state);
210}
211
212pub fn render_pagination(current: usize, total: usize) -> String {
213    if total == 0 {
214        return "[1]".to_string();
215    }
216    if total <= 10 {
217        return (0..total)
218            .map(|i| {
219                if i == current {
220                    format!("[{}]", i + 1)
221                } else {
222                    format!("{}", i + 1)
223                }
224            })
225            .collect::<Vec<_>>()
226            .join(" ");
227    }
228    let start = current.saturating_sub(4);
229    let end = (start + 9).min(total);
230    let start = if end == total {
231        total.saturating_sub(9)
232    } else {
233        start
234    };
235    (start..end)
236        .map(|i| {
237            if i == current {
238                format!("[{}]", i + 1)
239            } else {
240                format!("{}", i + 1)
241            }
242        })
243        .collect::<Vec<_>>()
244        .join(" ")
245}
246
247pub fn render_pagination_text(current: usize, total: usize) -> String {
248    render_pagination(current, total)
249}
250
251pub struct FilterConfig<'a> {
252    pub text: &'a str,
253    pub placeholder: &'a str,
254    pub is_active: bool,
255    pub right_content: Vec<(&'a str, &'a str)>,
256    pub area: Rect,
257}
258
259pub struct FilterAreaConfig<'a> {
260    pub filter_text: &'a str,
261    pub placeholder: &'a str,
262    pub mode: crate::keymap::Mode,
263    pub input_focus: FilterFocusType,
264    pub controls: Vec<FilterControl>,
265    pub area: Rect,
266}
267
268#[derive(Debug, Clone, Copy, PartialEq, Default)]
269pub enum SortDirection {
270    #[default]
271    Asc,
272    Desc,
273}
274
275impl SortDirection {
276    pub fn as_str(&self) -> &'static str {
277        match self {
278            SortDirection::Asc => "ASC",
279            SortDirection::Desc => "DESC",
280        }
281    }
282}
283
284#[derive(Debug, Clone, Copy, PartialEq, Default)]
285pub enum InputFocus {
286    #[default]
287    Filter,
288    Pagination,
289    Dropdown(&'static str),
290    Checkbox(&'static str),
291}
292
293impl InputFocus {
294    pub fn next(&self, controls: &[InputFocus]) -> Self {
295        if controls.is_empty() {
296            return *self;
297        }
298        let idx = controls.iter().position(|f| f == self).unwrap_or(0);
299        controls[(idx + 1) % controls.len()]
300    }
301
302    pub fn prev(&self, controls: &[InputFocus]) -> Self {
303        if controls.is_empty() {
304            return *self;
305        }
306        let idx = controls.iter().position(|f| f == self).unwrap_or(0);
307        controls[(idx + controls.len() - 1) % controls.len()]
308    }
309
310    /// Navigate to next page when pagination is focused
311    pub fn handle_page_down(
312        &self,
313        selected: &mut usize,
314        scroll_offset: &mut usize,
315        page_size: usize,
316        filtered_count: usize,
317    ) {
318        if *self == InputFocus::Pagination {
319            let max_offset = filtered_count.saturating_sub(page_size);
320            *selected = (*selected + page_size).min(max_offset);
321            *scroll_offset = *selected;
322        }
323    }
324
325    /// Navigate to previous page when pagination is focused
326    pub fn handle_page_up(
327        &self,
328        selected: &mut usize,
329        scroll_offset: &mut usize,
330        page_size: usize,
331    ) {
332        if *self == InputFocus::Pagination {
333            *selected = selected.saturating_sub(page_size);
334            *scroll_offset = *selected;
335        }
336    }
337}
338
339pub trait CyclicEnum: Copy + PartialEq + Sized + 'static {
340    const ALL: &'static [Self];
341
342    fn next(&self) -> Self {
343        let idx = Self::ALL.iter().position(|x| x == self).unwrap_or(0);
344        Self::ALL[(idx + 1) % Self::ALL.len()]
345    }
346
347    fn prev(&self) -> Self {
348        let idx = Self::ALL.iter().position(|x| x == self).unwrap_or(0);
349        Self::ALL[(idx + Self::ALL.len() - 1) % Self::ALL.len()]
350    }
351}
352
353#[derive(PartialEq)]
354pub enum FilterFocusType {
355    Input,
356    Control(usize),
357}
358
359pub struct FilterControl {
360    pub text: String,
361    pub is_focused: bool,
362    pub style: ratatui::style::Style,
363}
364
365pub fn render_filter_area(frame: &mut Frame, config: FilterAreaConfig) {
366    use crate::keymap::Mode;
367    use crate::ui::{get_cursor, SEARCH_ICON};
368    use ratatui::{prelude::*, widgets::*};
369
370    let cursor = get_cursor(
371        config.mode == Mode::FilterInput && config.input_focus == FilterFocusType::Input,
372    );
373    let filter_width = config.area.width.saturating_sub(4) as usize;
374
375    // Calculate controls text
376    let controls_text: String = config
377        .controls
378        .iter()
379        .map(|c| c.text.as_str())
380        .collect::<Vec<_>>()
381        .join(" ⋮ ");
382    let controls_len = controls_text.len();
383
384    let placeholder_len = config.placeholder.len();
385    let content_len =
386        if config.filter_text.is_empty() && config.mode != Mode::FilterInput {
387            placeholder_len
388        } else {
389            config.filter_text.len()
390        } + if config.mode == Mode::FilterInput && config.input_focus == FilterFocusType::Input {
391            cursor.len()
392        } else {
393            0
394        };
395
396    let available_space = filter_width.saturating_sub(controls_len + 1);
397
398    let mut line_spans = vec![];
399    if config.filter_text.is_empty() {
400        if config.mode == Mode::FilterInput {
401            line_spans.push(Span::raw(""));
402        } else {
403            line_spans.push(Span::styled(
404                config.placeholder,
405                Style::default().fg(Color::DarkGray),
406            ));
407        }
408    } else {
409        line_spans.push(Span::raw(config.filter_text));
410    }
411
412    if config.mode == Mode::FilterInput && config.input_focus == FilterFocusType::Input {
413        line_spans.push(Span::styled(cursor, Style::default().fg(Color::Yellow)));
414    }
415
416    if content_len < available_space {
417        line_spans.push(Span::raw(" ".repeat(available_space - content_len)));
418    }
419
420    if config.mode == Mode::FilterInput {
421        for control in &config.controls {
422            line_spans.push(Span::raw(" ⋮ "));
423            line_spans.push(Span::styled(&control.text, control.style));
424        }
425    } else {
426        line_spans.push(Span::styled(
427            format!(" ⋮ {}", controls_text),
428            Style::default(),
429        ));
430    }
431
432    frame.render_widget(
433        Paragraph::new(Line::from(line_spans)).block(
434            Block::default()
435                .title(SEARCH_ICON)
436                .borders(Borders::ALL)
437                .border_style(if config.mode == Mode::FilterInput {
438                    Style::default().fg(Color::Yellow)
439                } else {
440                    Style::default()
441                }),
442        ),
443        config.area,
444    );
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_render_pagination_single_page() {
483        assert_eq!(render_pagination(0, 1), "[1]");
484    }
485
486    #[test]
487    fn test_render_pagination_two_pages() {
488        assert_eq!(render_pagination(0, 2), "[1] 2");
489        assert_eq!(render_pagination(1, 2), "1 [2]");
490    }
491
492    #[test]
493    fn test_render_pagination_ten_pages() {
494        assert_eq!(render_pagination(0, 10), "[1] 2 3 4 5 6 7 8 9 10");
495        assert_eq!(render_pagination(5, 10), "1 2 3 4 5 [6] 7 8 9 10");
496        assert_eq!(render_pagination(9, 10), "1 2 3 4 5 6 7 8 9 [10]");
497    }
498
499    #[test]
500    fn test_format_memory_mb() {
501        assert_eq!(format_memory_mb(128), "128 MB");
502        assert_eq!(format_memory_mb(512), "512 MB");
503        assert_eq!(format_memory_mb(1024), "1 GB");
504        assert_eq!(format_memory_mb(2048), "2 GB");
505    }
506
507    #[test]
508    fn test_format_duration_seconds() {
509        assert_eq!(format_duration_seconds(30), "30sec");
510        assert_eq!(format_duration_seconds(40), "40sec");
511        assert_eq!(format_duration_seconds(60), "1min 0sec");
512        assert_eq!(format_duration_seconds(90), "1min 30sec");
513        assert_eq!(format_duration_seconds(900), "15min 0sec");
514    }
515
516    #[test]
517    fn test_render_pagination_many_pages() {
518        assert_eq!(render_pagination(0, 20), "[1] 2 3 4 5 6 7 8 9");
519        assert_eq!(render_pagination(5, 20), "2 3 4 5 [6] 7 8 9 10");
520        assert_eq!(render_pagination(15, 20), "12 13 14 15 [16] 17 18 19 20");
521        assert_eq!(render_pagination(19, 20), "12 13 14 15 16 17 18 19 [20]");
522    }
523
524    #[test]
525    fn test_render_pagination_zero_total() {
526        assert_eq!(render_pagination(0, 0), "[1]");
527    }
528}
529
530pub fn render_filter(frame: &mut Frame, config: FilterConfig) {
531    let cursor = if config.is_active { "█" } else { "" };
532    let content = if config.text.is_empty() && !config.is_active {
533        config.placeholder
534    } else {
535        config.text
536    };
537
538    let right_text = config
539        .right_content
540        .iter()
541        .map(|(k, v)| format!("{}: {}", k, v))
542        .collect::<Vec<_>>()
543        .join(" ⋮ ");
544
545    let width = (config.area.width as usize).saturating_sub(4);
546    let right_len = right_text.len();
547    let content_len = content.len() + if config.is_active { cursor.len() } else { 0 };
548    let available = width.saturating_sub(right_len + 3);
549
550    let display = if content_len > available {
551        &content[content_len.saturating_sub(available)..]
552    } else {
553        content
554    };
555
556    let style = if config.is_active {
557        styles::yellow()
558    } else {
559        styles::placeholder()
560    };
561
562    let mut spans = vec![Span::styled(display, style)];
563    if config.is_active {
564        spans.push(Span::styled(cursor, styles::cursor()));
565    }
566
567    let padding = " ".repeat(
568        width
569            .saturating_sub(display.len())
570            .saturating_sub(if config.is_active { cursor.len() } else { 0 })
571            .saturating_sub(right_len)
572            .saturating_sub(3),
573    );
574
575    spans.push(Span::raw(padding));
576    spans.push(Span::styled(format!(" {}", right_text), styles::cyan()));
577
578    frame.render_widget(
579        Paragraph::new(Line::from(spans)).block(
580            Block::default()
581                .borders(Borders::ALL)
582                .border_style(border_style(config.is_active)),
583        ),
584        config.area,
585    );
586}
587
588#[derive(Debug, Clone, Copy, PartialEq)]
589pub enum PageSize {
590    Ten,
591    TwentyFive,
592    Fifty,
593    OneHundred,
594}