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_partial_pagination(current: usize, loaded_pages: usize) -> String {
239 if loaded_pages == 0 {
240 "...".to_string()
241 } else {
242 format!("{} ...", render_pagination_text(current, loaded_pages))
243 }
244}
245
246pub fn render_dropdown<T: AsRef<str>>(
247 frame: &mut ratatui::Frame,
248 items: &[T],
249 selected_index: usize,
250 filter_area: ratatui::prelude::Rect,
251 controls_after_width: u16,
252) {
253 use ratatui::prelude::*;
254 use ratatui::widgets::{Block, BorderType, Borders, Clear, List, ListItem};
255
256 let max_width = items
257 .iter()
258 .map(|item| item.as_ref().len())
259 .max()
260 .unwrap_or(10) as u16
261 + 4;
262
263 let dropdown_items: Vec<ListItem> = items
264 .iter()
265 .enumerate()
266 .map(|(idx, item)| {
267 let style = if idx == selected_index {
268 Style::default().fg(Color::Yellow).bold()
269 } else {
270 Style::default().fg(Color::White)
271 };
272 ListItem::new(format!(" {} ", item.as_ref())).style(style)
273 })
274 .collect();
275
276 let dropdown_height = dropdown_items.len() as u16 + 2;
277 let dropdown_width = max_width;
278 let dropdown_x = filter_area
279 .x
280 .saturating_add(filter_area.width)
281 .saturating_sub(controls_after_width + dropdown_width);
282
283 let y = filter_area.y + filter_area.height;
286 let space_below = frame.area().height.saturating_sub(y);
287 let dropdown_area = Rect {
288 x: dropdown_x,
289 y,
290 width: dropdown_width,
291 height: dropdown_height.min(space_below.max(3)),
292 };
293
294 frame.render_widget(Clear, dropdown_area);
296
297 frame.render_widget(
298 List::new(dropdown_items)
299 .block(
300 Block::default()
301 .borders(Borders::ALL)
302 .border_type(BorderType::Rounded)
303 .border_style(Style::default().fg(Color::Yellow)),
304 )
305 .style(Style::default().bg(Color::Black)),
306 dropdown_area,
307 );
308}
309
310pub struct FilterConfig<'a> {
311 pub text: &'a str,
312 pub placeholder: &'a str,
313 pub is_active: bool,
314 pub right_content: Vec<(&'a str, &'a str)>,
315 pub area: Rect,
316}
317
318pub struct FilterAreaConfig<'a> {
319 pub filter_text: &'a str,
320 pub placeholder: &'a str,
321 pub mode: crate::keymap::Mode,
322 pub input_focus: FilterFocusType,
323 pub controls: Vec<FilterControl>,
324 pub area: Rect,
325}
326
327#[derive(Debug, Clone, Copy, PartialEq, Default)]
328pub enum SortDirection {
329 #[default]
330 Asc,
331 Desc,
332}
333
334impl SortDirection {
335 pub fn as_str(&self) -> &'static str {
336 match self {
337 SortDirection::Asc => "ASC",
338 SortDirection::Desc => "DESC",
339 }
340 }
341}
342
343#[derive(Debug, Clone, Copy, PartialEq, Default)]
344pub enum InputFocus {
345 #[default]
346 Filter,
347 Pagination,
348 Dropdown(&'static str),
349 Checkbox(&'static str),
350}
351
352impl InputFocus {
353 pub fn next(&self, controls: &[InputFocus]) -> Self {
354 if controls.is_empty() {
355 return *self;
356 }
357 let idx = controls.iter().position(|f| f == self).unwrap_or(0);
358 controls[(idx + 1) % controls.len()]
359 }
360
361 pub fn prev(&self, controls: &[InputFocus]) -> Self {
362 if controls.is_empty() {
363 return *self;
364 }
365 let idx = controls.iter().position(|f| f == self).unwrap_or(0);
366 controls[(idx + controls.len() - 1) % controls.len()]
367 }
368
369 pub fn handle_page_down(
371 &self,
372 selected: &mut usize,
373 scroll_offset: &mut usize,
374 page_size: usize,
375 filtered_count: usize,
376 ) {
377 if *self == InputFocus::Pagination {
378 let last_page_start = if filtered_count > page_size {
380 ((filtered_count - 1) / page_size) * page_size
381 } else {
382 0
383 };
384 *selected = (*selected + page_size).min(last_page_start);
385 *scroll_offset = *selected;
386 }
387 }
388
389 pub fn handle_page_up(
391 &self,
392 selected: &mut usize,
393 scroll_offset: &mut usize,
394 page_size: usize,
395 ) {
396 if *self == InputFocus::Pagination {
397 *selected = selected.saturating_sub(page_size);
398 *scroll_offset = *selected;
399 }
400 }
401}
402
403pub trait CyclicEnum: Copy + PartialEq + Sized + 'static {
404 const ALL: &'static [Self];
405
406 fn next(&self) -> Self {
407 let idx = Self::ALL.iter().position(|x| x == self).unwrap_or(0);
408 Self::ALL[(idx + 1) % Self::ALL.len()]
409 }
410
411 fn prev(&self) -> Self {
412 let idx = Self::ALL.iter().position(|x| x == self).unwrap_or(0);
413 Self::ALL[(idx + Self::ALL.len() - 1) % Self::ALL.len()]
414 }
415}
416
417#[derive(PartialEq)]
418pub enum FilterFocusType {
419 Input,
420 Control(usize),
421}
422
423pub struct FilterControl {
424 pub text: String,
425 pub is_focused: bool,
426 pub style: ratatui::style::Style,
427}
428
429pub fn render_filter_area(frame: &mut Frame, config: FilterAreaConfig) {
430 use crate::keymap::Mode;
431 use crate::ui::get_cursor;
432 use ratatui::prelude::*;
433
434 let cursor = get_cursor(
435 config.mode == Mode::FilterInput && config.input_focus == FilterFocusType::Input,
436 );
437 let filter_width = config.area.width.saturating_sub(4) as usize;
438
439 let controls_text: String = config
441 .controls
442 .iter()
443 .map(|c| c.text.as_str())
444 .collect::<Vec<_>>()
445 .join(" ⋮ ");
446 let controls_len = controls_text.len();
447
448 let placeholder_len = config.placeholder.len();
449 let content_len =
450 if config.filter_text.is_empty() && config.mode != Mode::FilterInput {
451 placeholder_len
452 } else {
453 config.filter_text.len()
454 } + if config.mode == Mode::FilterInput && config.input_focus == FilterFocusType::Input {
455 cursor.len()
456 } else {
457 0
458 };
459
460 let available_space = filter_width.saturating_sub(controls_len + 1);
461
462 let mut line_spans = vec![];
463 if config.filter_text.is_empty() {
464 if config.mode == Mode::FilterInput {
465 line_spans.push(Span::raw(""));
466 } else {
467 line_spans.push(Span::styled(
468 config.placeholder,
469 Style::default().fg(Color::DarkGray),
470 ));
471 }
472 } else {
473 line_spans.push(Span::raw(config.filter_text));
474 }
475
476 if config.mode == Mode::FilterInput && config.input_focus == FilterFocusType::Input {
477 line_spans.push(Span::styled(cursor, Style::default().fg(Color::Yellow)));
478 }
479
480 if content_len < available_space {
481 line_spans.push(Span::raw(" ".repeat(available_space - content_len)));
482 }
483
484 if config.mode == Mode::FilterInput {
485 for control in &config.controls {
486 line_spans.push(Span::raw(" ⋮ "));
487 line_spans.push(Span::styled(&control.text, control.style));
488 }
489 } else {
490 line_spans.push(Span::styled(
491 format!(" ⋮ {}", controls_text),
492 Style::default(),
493 ));
494 }
495
496 let filter = filter_area(line_spans, config.mode == Mode::FilterInput);
497 frame.render_widget(filter, config.area);
498}
499
500#[cfg(test)]
501mod tests {
502 use super::*;
503 use chrono::TimeZone;
504
505 #[test]
506 fn test_format_timestamp() {
507 let dt = Utc.with_ymd_and_hms(2025, 11, 12, 14, 30, 45).unwrap();
508 assert_eq!(format_timestamp(&dt), "2025-11-12 14:30:45 (UTC)");
509 }
510
511 #[test]
512 fn test_format_optional_timestamp_some() {
513 let dt = Utc.with_ymd_and_hms(2025, 11, 12, 14, 30, 45).unwrap();
514 assert_eq!(
515 format_optional_timestamp(Some(dt)),
516 "2025-11-12 14:30:45 (UTC)"
517 );
518 }
519
520 #[test]
521 fn test_format_optional_timestamp_none() {
522 assert_eq!(format_optional_timestamp(None), "-");
523 }
524
525 #[test]
526 fn test_format_bytes() {
527 assert_eq!(format_bytes(500), "500 B");
528 assert_eq!(format_bytes(1500), "1.50 KB");
529 assert_eq!(format_bytes(1_500_000), "1.50 MB");
530 assert_eq!(format_bytes(1_500_000_000), "1.50 GB");
531 assert_eq!(format_bytes(1_500_000_000_000), "1.50 TB");
532 }
533
534 #[test]
535 fn test_format_duration_seconds_zero() {
536 assert_eq!(format_duration_seconds(0), "0s");
537 }
538
539 #[test]
540 fn test_render_infinite_pagination_page_1() {
541 let result = render_infinite_pagination(0);
543 assert_eq!(result, "[1] 2 ...");
544 }
545
546 #[test]
547 fn test_render_infinite_pagination_page_5() {
548 let result = render_infinite_pagination(4);
550 assert_eq!(result, "1 2 3 4 [5] 6 ...");
551 }
552
553 #[test]
554 fn test_render_infinite_pagination_page_6() {
555 let result = render_infinite_pagination(5);
557 assert_eq!(result, "2 3 4 5 [6] 7 ...");
558 }
559
560 #[test]
561 fn test_render_infinite_pagination_page_7() {
562 let result = render_infinite_pagination(6);
564 assert_eq!(result, "1 ... 3 4 5 6 [7] 8 ...");
565 }
566
567 #[test]
568 fn test_render_infinite_pagination_page_10() {
569 let result = render_infinite_pagination(9);
571 assert_eq!(result, "1 ... 6 7 8 9 [10] 11 ...");
572 }
573
574 #[test]
575 fn test_render_infinite_pagination_page_100() {
576 let result = render_infinite_pagination(99);
578 assert_eq!(result, "1 ... 96 97 98 99 [100] 101 ...");
579 }
580
581 #[test]
582 fn test_format_duration_seconds_only_seconds() {
583 assert_eq!(format_duration_seconds(30), "30s");
584 }
585
586 #[test]
587 fn test_format_duration_seconds_minutes_and_seconds() {
588 assert_eq!(format_duration_seconds(120), "2m");
589 assert_eq!(format_duration_seconds(150), "2m 30s");
590 }
591
592 #[test]
593 fn test_format_duration_seconds_hours() {
594 assert_eq!(format_duration_seconds(3630), "1h 30s");
595 assert_eq!(format_duration_seconds(10800), "3h");
596 }
597
598 #[test]
599 fn test_format_duration_seconds_days() {
600 assert_eq!(format_duration_seconds(90061), "1d 1h 1m 1s");
601 assert_eq!(format_duration_seconds(345600), "4d");
602 }
603
604 #[test]
605 fn test_format_duration_seconds_complex() {
606 assert_eq!(format_duration_seconds(1800), "30m");
607 assert_eq!(format_duration_seconds(86400), "1d");
608 }
609
610 #[test]
611 fn test_render_pagination_single_page() {
612 assert_eq!(render_pagination(0, 1), "[1]");
613 }
614
615 #[test]
616 fn test_render_pagination_two_pages() {
617 assert_eq!(render_pagination(0, 2), "[1] 2");
618 assert_eq!(render_pagination(1, 2), "1 [2]");
619 }
620
621 #[test]
622 fn test_render_pagination_ten_pages() {
623 assert_eq!(render_pagination(0, 10), "[1] 2 3 4 5 6 7 8 9 10");
624 assert_eq!(render_pagination(5, 10), "1 2 3 4 5 [6] 7 8 9 10");
625 assert_eq!(render_pagination(9, 10), "1 2 3 4 5 6 7 8 9 [10]");
626 }
627
628 #[test]
629 fn test_format_memory_mb() {
630 assert_eq!(format_memory_mb(128), "128 MB");
631 assert_eq!(format_memory_mb(512), "512 MB");
632 assert_eq!(format_memory_mb(1024), "1 GB");
633 assert_eq!(format_memory_mb(2048), "2 GB");
634 }
635
636 #[test]
637 fn test_render_pagination_many_pages() {
638 assert_eq!(render_pagination(0, 20), "[1] 2 3 4 5 6 7 8 9");
639 assert_eq!(render_pagination(5, 20), "2 3 4 5 [6] 7 8 9 10");
640 assert_eq!(render_pagination(15, 20), "12 13 14 15 [16] 17 18 19 20");
641 assert_eq!(render_pagination(19, 20), "12 13 14 15 16 17 18 19 [20]");
642 }
643
644 #[test]
645 fn test_render_pagination_zero_total() {
646 assert_eq!(render_pagination(0, 0), "[1]");
647 }
648
649 #[test]
650 fn test_render_dropdown_items_format() {
651 let items = ["us-east-1", "us-west-2", "eu-west-1"];
652 assert_eq!(items.len(), 3);
653 assert_eq!(items[0], "us-east-1");
654 assert_eq!(items[2], "eu-west-1");
655 }
656
657 #[test]
658 fn test_render_dropdown_selected_index() {
659 let items = ["item1", "item2", "item3"];
660 let selected = 1;
661 assert_eq!(items[selected], "item2");
662 }
663
664 #[test]
665 fn test_render_dropdown_controls_after_width() {
666 let pagination_len = 10;
667 let separator = 3;
668 let controls_after = pagination_len + separator;
669 assert_eq!(controls_after, 13);
670 }
671
672 #[test]
673 fn test_render_dropdown_multiple_controls_after() {
674 let view_nested_width = 15;
675 let pagination_len = 10;
676 let controls_after = view_nested_width + 3 + pagination_len + 3;
677 assert_eq!(controls_after, 31);
678 }
679
680 #[test]
681 fn test_render_dropdown_clears_background() {
682 use ratatui::backend::TestBackend;
686 use ratatui::Terminal;
687
688 let backend = TestBackend::new(80, 24);
689 let mut terminal = Terminal::new(backend).unwrap();
690
691 terminal
692 .draw(|frame| {
693 let area = ratatui::prelude::Rect {
694 x: 0,
695 y: 0,
696 width: 80,
697 height: 3,
698 };
699 let items = ["Running", "Stopped", "Terminated"];
700 render_dropdown(frame, &items, 0, area, 10);
701 })
702 .unwrap();
703
704 }
707}
708
709pub fn render_filter(frame: &mut Frame, config: FilterConfig) {
710 let cursor = if config.is_active { "█" } else { "" };
711 let content = if config.text.is_empty() && !config.is_active {
712 config.placeholder
713 } else {
714 config.text
715 };
716
717 let right_text = config
718 .right_content
719 .iter()
720 .map(|(k, v)| format!("{}: {}", k, v))
721 .collect::<Vec<_>>()
722 .join(" ⋮ ");
723
724 let width = (config.area.width as usize).saturating_sub(4);
725 let right_len = right_text.len();
726 let content_len = content.len() + if config.is_active { cursor.len() } else { 0 };
727 let available = width.saturating_sub(right_len + 3);
728
729 let display = if content_len > available {
730 &content[content_len.saturating_sub(available)..]
731 } else {
732 content
733 };
734
735 let style = if config.is_active {
736 styles::yellow()
737 } else {
738 styles::placeholder()
739 };
740
741 let mut spans = vec![Span::styled(display, style)];
742 if config.is_active {
743 spans.push(Span::styled(cursor, styles::cursor()));
744 }
745
746 let padding = " ".repeat(
747 width
748 .saturating_sub(display.len())
749 .saturating_sub(if config.is_active { cursor.len() } else { 0 })
750 .saturating_sub(right_len)
751 .saturating_sub(3),
752 );
753
754 spans.push(Span::raw(padding));
755 spans.push(Span::styled(format!(" {}", right_text), styles::cyan()));
756
757 frame.render_widget(
758 Paragraph::new(Line::from(spans)).block(
759 Block::default()
760 .borders(Borders::ALL)
761 .border_style(border_style(config.is_active)),
762 ),
763 config.area,
764 );
765}
766
767#[derive(Debug, Clone, Copy, PartialEq)]
768pub enum PageSize {
769 Ten,
770 TwentyFive,
771 Fifty,
772 OneHundred,
773}
774
775pub fn filter_by_field<'a, T, F>(items: &'a [T], filter: &str, get_field: F) -> Vec<&'a T>
777where
778 F: Fn(&T) -> &str,
779{
780 if filter.is_empty() {
781 items.iter().collect()
782 } else {
783 let filter_lower = filter.to_lowercase();
784 items
785 .iter()
786 .filter(|item| get_field(item).to_lowercase().contains(&filter_lower))
787 .collect()
788 }
789}
790
791pub fn filter_by_fields<'a, T, F>(items: &'a [T], filter: &str, get_fields: F) -> Vec<&'a T>
793where
794 F: Fn(&T) -> Vec<&str>,
795{
796 if filter.is_empty() {
797 items.iter().collect()
798 } else {
799 let filter_lower = filter.to_lowercase();
800 items
801 .iter()
802 .filter(|item| {
803 get_fields(item)
804 .iter()
805 .any(|field| field.to_lowercase().contains(&filter_lower))
806 })
807 .collect()
808 }
809}
810
811#[cfg(test)]
812mod partial_pagination_tests {
813 use super::*;
814
815 #[test]
816 fn partial_pagination_never_invents_an_unloaded_page() {
817 assert_eq!(render_partial_pagination(0, 0), "...");
818 assert_eq!(render_partial_pagination(0, 1), "[1] ...");
819 assert_eq!(render_partial_pagination(0, 2), "[1] 2 ...");
820 assert_eq!(render_partial_pagination(1, 2), "1 [2] ...");
821 assert_eq!(
822 render_partial_pagination(19, 20),
823 "12 13 14 15 16 17 18 19 [20] ..."
824 );
825 }
826
827 #[test]
828 fn finite_pagination_is_unchanged() {
829 assert_eq!(render_pagination_text(0, 2), "[1] 2");
830 assert_eq!(render_pagination_text(3, 4), "1 2 3 [4]");
831 assert_eq!(render_pagination_text(0, 0), "[1]");
832 }
833}