1use chrono::{DateTime, Utc};
2use ratatui::{prelude::*, widgets::*};
3
4use crate::ui::styles;
5
6pub 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 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 fn column_type(&self) -> ColumnType {
36 ColumnType::String
37 }
38
39 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 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 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 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}