rust_kanban/ui/rendering/
common.rs

1use crate::{
2    app::{
3        app_helper::reset_card_drag_mode,
4        kanban::{Boards, Card, CardPriority, CardStatus},
5        state::{Focus, KeyBindingEnum},
6        App, DateTimeFormat,
7    },
8    constants::{
9        APP_TITLE, DEFAULT_BOARD_TITLE_LENGTH, DEFAULT_CARD_TITLE_LENGTH, FIELD_NOT_SET,
10        HIDDEN_PASSWORD_SYMBOL, LIST_SELECTED_SYMBOL, MOUSE_OUT_OF_BOUNDS_COORDINATES,
11        PATTERN_CHANGE_INTERVAL, SCROLLBAR_BEGIN_SYMBOL, SCROLLBAR_END_SYMBOL,
12        SCROLLBAR_TRACK_SYMBOL,
13    },
14    io::logger::{get_logs, get_selected_index, RUST_KANBAN_LOGGER},
15    ui::{
16        rendering::utils::{
17            centered_rect_with_length, check_for_card_drag_and_get_style,
18            check_if_active_and_get_style, check_if_mouse_is_in_area,
19            get_mouse_focusable_field_style,
20        },
21        theme::Theme,
22    },
23    util::{date_format_converter, date_format_finder},
24};
25use chrono::{Local, NaiveDate, NaiveDateTime};
26use log::Level;
27use ratatui::{
28    layout::{Alignment, Constraint, Direction, Layout, Margin, Rect},
29    style::{Color, Modifier, Style},
30    text::{Line, Span},
31    widgets::{
32        Block, BorderType, Borders, Cell, Gauge, Paragraph, Row, Scrollbar, ScrollbarOrientation,
33        ScrollbarState, Table,
34    },
35    Frame,
36};
37use std::{
38    cmp::Ordering,
39    time::{SystemTime, UNIX_EPOCH},
40};
41
42pub fn render_body(
43    rect: &mut Frame,
44    area: Rect,
45    app: &mut App,
46    preview_mode: bool,
47    is_active: bool,
48) {
49    let mut current_board_set = false;
50    let mut current_card_set = false;
51    let app_preview_boards_and_cards = app.preview_boards_and_cards.clone().unwrap_or_default();
52    let boards = if preview_mode {
53        if app_preview_boards_and_cards.is_empty() {
54            Boards::default()
55        } else {
56            app_preview_boards_and_cards
57        }
58    } else if !app.filtered_boards.is_empty() {
59        app.filtered_boards.clone()
60    } else {
61        app.boards.clone()
62    };
63    let scrollbar_style = check_for_card_drag_and_get_style(
64        app.state.card_drag_mode,
65        is_active,
66        app.current_theme.inactive_text_style,
67        app.current_theme.progress_bar_style,
68    );
69    let error_text_style = check_for_card_drag_and_get_style(
70        app.state.card_drag_mode,
71        is_active,
72        app.current_theme.inactive_text_style,
73        app.current_theme.error_text_style,
74    );
75    let general_style = check_for_card_drag_and_get_style(
76        app.state.card_drag_mode,
77        is_active,
78        app.current_theme.inactive_text_style,
79        app.current_theme.general_style,
80    );
81    let help_key_style = check_for_card_drag_and_get_style(
82        app.state.card_drag_mode,
83        is_active,
84        app.current_theme.inactive_text_style,
85        app.current_theme.help_key_style,
86    );
87    let current_board_id = &app.state.current_board_id.unwrap_or((0, 0));
88
89    let new_board_key = app
90        .get_first_keybinding(KeyBindingEnum::NewBoard)
91        .unwrap_or("".to_string());
92    let new_card_key = app
93        .get_first_keybinding(KeyBindingEnum::NewCard)
94        .unwrap_or("".to_string());
95
96    if preview_mode {
97        if app.preview_boards_and_cards.is_none()
98            || app
99                .preview_boards_and_cards
100                .as_ref()
101                .map_or(false, |v| v.is_empty())
102        {
103            let empty_paragraph = Paragraph::new("No boards found".to_string())
104                .alignment(Alignment::Center)
105                .block(
106                    Block::default()
107                        .title("Boards")
108                        .borders(Borders::ALL)
109                        .border_type(BorderType::Rounded),
110                )
111                .style(error_text_style);
112            rect.render_widget(empty_paragraph, area);
113            return;
114        }
115    } else if app.visible_boards_and_cards.is_empty() {
116        let empty_paragraph = Paragraph::new(
117            [
118                "No boards found, press ".to_string(),
119                new_board_key,
120                " to add a new board".to_string(),
121            ]
122            .concat(),
123        )
124        .alignment(Alignment::Center)
125        .block(
126            Block::default()
127                .title("Boards")
128                .borders(Borders::ALL)
129                .border_type(BorderType::Rounded),
130        )
131        .style(error_text_style);
132        rect.render_widget(empty_paragraph, area);
133        return;
134    }
135
136    let filter_chunks = if app.filtered_boards.is_empty() {
137        Layout::default()
138            .direction(Direction::Vertical)
139            .constraints([Constraint::Percentage(0), Constraint::Fill(1)].as_ref())
140            .split(area)
141    } else {
142        Layout::default()
143            .direction(Direction::Vertical)
144            .constraints([Constraint::Length(1), Constraint::Fill(1)].as_ref())
145            .split(area)
146    };
147
148    let chunks = if app.config.disable_scroll_bar {
149        Layout::default()
150            .direction(Direction::Vertical)
151            .constraints([Constraint::Fill(1)].as_ref())
152            .split(filter_chunks[1])
153    } else {
154        Layout::default()
155            .direction(Direction::Vertical)
156            .constraints([Constraint::Fill(1), Constraint::Length(1)].as_ref())
157            .split(filter_chunks[1])
158    };
159
160    if !app.filtered_boards.is_empty() {
161        let filtered_text = "This is a filtered view, Clear filter to see all boards and cards";
162        let filtered_paragraph = Paragraph::new(filtered_text.to_string())
163            .alignment(Alignment::Center)
164            .block(Block::default())
165            .style(error_text_style);
166        rect.render_widget(filtered_paragraph, filter_chunks[0]);
167    }
168
169    let mut constraints = vec![];
170    if boards.len() > app.config.no_of_boards_to_show.into() {
171        for _i in 0..app.config.no_of_boards_to_show {
172            constraints.push(Constraint::Fill(1));
173        }
174    } else {
175        for _i in 0..boards.len() {
176            constraints.push(Constraint::Fill(1));
177        }
178    }
179    let board_chunks = Layout::default()
180        .direction(Direction::Horizontal)
181        .constraints(AsRef::<[Constraint]>::as_ref(&constraints))
182        .split(chunks[0]);
183    let visible_boards_and_cards = if preview_mode {
184        app.state.preview_visible_boards_and_cards.clone()
185    } else {
186        app.visible_boards_and_cards.clone()
187    };
188    for (board_index, board_and_card_tuple) in visible_boards_and_cards.iter().enumerate() {
189        let board_id = board_and_card_tuple.0;
190        let board = boards.get_board_with_id(*board_id);
191        if board.is_none() {
192            continue;
193        }
194        let board = board.unwrap();
195        let board_title = board.name.clone();
196        let board_cards = board_and_card_tuple.1;
197        let board_title = if board_title.len() > DEFAULT_BOARD_TITLE_LENGTH.into() {
198            format!(
199                "{}...",
200                &board_title[0..DEFAULT_BOARD_TITLE_LENGTH as usize]
201            )
202        } else {
203            board_title
204        };
205        let board_title = format!("{} ({})", board_title, board.cards.len());
206        let board_title = if board_id == current_board_id {
207            format!("{} {}", ">>", board_title)
208        } else {
209            board_title
210        };
211
212        let mut card_constraints = vec![];
213        if board_cards.len() > app.config.no_of_cards_to_show.into() {
214            for _i in 0..app.config.no_of_cards_to_show {
215                card_constraints.push(Constraint::Fill(1));
216            }
217        } else if board_cards.is_empty() {
218            card_constraints.push(Constraint::Fill(1));
219        } else {
220            for _i in 0..board_cards.len() {
221                card_constraints.push(Constraint::Fill(1));
222            }
223        }
224
225        if board_index >= board_chunks.len() {
226            continue;
227        }
228
229        let board_style = check_for_card_drag_and_get_style(
230            app.state.card_drag_mode,
231            is_active,
232            app.current_theme.inactive_text_style,
233            app.current_theme.general_style,
234        );
235        // Exception to not using check_for_card_drag_and_get_style as we have to manage other state
236        let board_border_style = if !is_active {
237            app.current_theme.inactive_text_style
238        } else if check_if_mouse_is_in_area(
239            &app.state.current_mouse_coordinates,
240            &board_chunks[board_index],
241        ) {
242            app.state.mouse_focus = Some(Focus::Body);
243            app.state.set_focus(Focus::Body);
244            if !current_board_set {
245                app.state.current_board_id = Some(*board_id);
246                current_board_set = true;
247            }
248            app.state.hovered_board = Some(*board_id);
249            app.current_theme.mouse_focus_style
250        } else if (app.state.current_board_id.unwrap_or((0, 0)) == *board_id)
251            && app.state.current_card_id.is_none()
252            && matches!(app.state.focus, Focus::Body)
253        {
254            app.current_theme.keyboard_focus_style
255        } else if app.state.card_drag_mode {
256            app.current_theme.inactive_text_style
257        } else {
258            app.current_theme.general_style
259        };
260
261        let board_block = Block::default()
262            .title(&*board_title)
263            .borders(Borders::ALL)
264            .style(board_style)
265            .border_style(board_border_style)
266            .border_type(BorderType::Rounded);
267        rect.render_widget(board_block, board_chunks[board_index]);
268
269        let card_area_chunks = Layout::default()
270            .direction(Direction::Horizontal)
271            .constraints([Constraint::Fill(1)].as_ref())
272            .split(board_chunks[board_index]);
273
274        let card_chunks = Layout::default()
275            .direction(Direction::Vertical)
276            .margin(1)
277            .constraints(AsRef::<[Constraint]>::as_ref(&card_constraints))
278            .split(card_area_chunks[0]);
279        if board_cards.is_empty() {
280            let available_width = card_chunks[0].width - 2;
281            let empty_card_line = if preview_mode {
282                Line::from(Span::styled("No cards found", general_style))
283            } else {
284                Line::from(vec![
285                    Span::styled("No cards found, press ", general_style),
286                    Span::styled(&new_card_key, help_key_style),
287                    Span::styled(" to add a new card", general_style),
288                ])
289            };
290            let empty_card_line_length = empty_card_line
291                .spans
292                .iter()
293                .fold(0, |acc, span| acc + span.content.chars().count());
294            let mut usable_length = empty_card_line_length as u16;
295            let mut usable_height = 1.0;
296            if empty_card_line_length > available_width.into() {
297                usable_length = available_width;
298                usable_height = empty_card_line_length as f32 / available_width as f32;
299                usable_height = usable_height.ceil();
300            }
301            let message_centered_rect =
302                centered_rect_with_length(usable_length, usable_height as u16, card_chunks[0]);
303            let empty_card_paragraph = Paragraph::new(empty_card_line)
304                .alignment(Alignment::Center)
305                .block(Block::default())
306                .style(board_style)
307                .wrap(ratatui::widgets::Wrap { trim: true });
308            rect.render_widget(empty_card_paragraph, message_centered_rect);
309            continue;
310        }
311        if !app.config.disable_scroll_bar && !board_cards.is_empty() && board_cards.len() > 1 {
312            let current_card_index = board
313                .cards
314                .get_card_index(app.state.current_card_id.unwrap_or((0, 0)))
315                .unwrap_or(0);
316            let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalLeft)
317                .begin_symbol(SCROLLBAR_BEGIN_SYMBOL)
318                .style(scrollbar_style)
319                .end_symbol(SCROLLBAR_END_SYMBOL)
320                .track_symbol(SCROLLBAR_TRACK_SYMBOL)
321                .track_style(app.current_theme.inactive_text_style);
322            let mut scrollbar_state = ScrollbarState::new(board.cards.len())
323                .position(current_card_index)
324                .viewport_content_length((card_chunks[0].height) as usize);
325            let scrollbar_area = card_area_chunks[0].inner(Margin {
326                vertical: 1,
327                horizontal: 0,
328            });
329            rect.render_stateful_widget(scrollbar, scrollbar_area, &mut scrollbar_state);
330        };
331        for (card_index, card_id) in board_cards.iter().enumerate() {
332            if app.state.hovered_card.is_some()
333                && app.state.card_drag_mode
334                && app.state.hovered_card.unwrap().1 == *card_id
335            {
336                continue;
337            }
338            let card = board.cards.get_card_with_id(*card_id);
339            if card.is_none() {
340                continue;
341            }
342            let card = card.unwrap();
343            // Exception to not using get_button_style as we have to manage other state
344            let card_style = if !is_active {
345                app.current_theme.inactive_text_style
346            } else if check_if_mouse_is_in_area(
347                &app.state.current_mouse_coordinates,
348                &card_chunks[card_index],
349            ) {
350                app.state.mouse_focus = Some(Focus::Body);
351                app.state.set_focus(Focus::Body);
352                if !current_card_set {
353                    app.state.current_card_id = Some(card.id);
354                    current_card_set = true;
355                }
356                if !app.state.card_drag_mode {
357                    app.state.hovered_card = Some((*board_id, card.id));
358                    app.state.hovered_card_dimensions = Some((
359                        card_chunks[card_index].width,
360                        card_chunks[card_index].height,
361                    ));
362                }
363                app.current_theme.mouse_focus_style
364            } else if app.state.current_card_id.unwrap_or((0, 0)) == card.id
365                && matches!(app.state.focus, Focus::Body)
366                && *board_id == *current_board_id
367            {
368                app.current_theme.keyboard_focus_style
369            } else if app.state.card_drag_mode {
370                app.current_theme.inactive_text_style
371            } else {
372                app.current_theme.general_style
373            };
374            render_a_single_card(
375                app,
376                card_chunks[card_index],
377                card_style,
378                card,
379                rect,
380                is_active,
381            );
382        }
383
384        if app.state.card_drag_mode {
385            // TODO: add up and down hover zones to scroll while dragging a card
386        }
387    }
388
389    if !app.config.disable_scroll_bar {
390        let current_board_index = boards.get_board_index(*current_board_id).unwrap_or(0) + 1;
391        let percentage = {
392            let temp_percent = (current_board_index as f64 / boards.len() as f64) * 100.0;
393            if temp_percent.is_nan() {
394                0
395            } else if temp_percent > 100.0 {
396                100
397            } else {
398                temp_percent as u16
399            }
400        };
401        let line_gauge = Gauge::default()
402            .block(Block::default())
403            .gauge_style(scrollbar_style)
404            .percent(percentage)
405            .label(format!("{} / {}", current_board_index, boards.len()));
406        rect.render_widget(line_gauge, chunks[1]);
407    }
408}
409
410pub fn render_card_being_dragged(
411    parent_body_area: Rect,
412    app: &mut App<'_>,
413    rect: &mut Frame<'_>,
414    is_active: bool,
415) {
416    if app.state.card_drag_mode {
417        if app.state.hovered_card.is_none() {
418            log::debug!("Hovered card is none");
419            return;
420        }
421        if app.state.hovered_card_dimensions.is_none() {
422            log::debug!("Hovered card dimensions are none");
423            return;
424        }
425
426        let current_mouse_coordinates = app.state.current_mouse_coordinates;
427        if current_mouse_coordinates == MOUSE_OUT_OF_BOUNDS_COORDINATES
428            || current_mouse_coordinates.0 < parent_body_area.x
429            || current_mouse_coordinates.1 < parent_body_area.y
430            || current_mouse_coordinates.0 > parent_body_area.x + parent_body_area.width
431            || current_mouse_coordinates.1 > parent_body_area.y + parent_body_area.height
432        {
433            log::debug!("Mouse is out of bounds");
434            reset_card_drag_mode(app);
435            return;
436        }
437        let card_dimensions = app.state.hovered_card_dimensions.unwrap();
438        let card_width = card_dimensions.0;
439        let card_height = card_dimensions.1;
440        let mut card_x = current_mouse_coordinates.0.saturating_sub(card_width / 2);
441        let mut card_y = current_mouse_coordinates.1.saturating_sub(card_height / 2);
442
443        if card_x < parent_body_area.x {
444            card_x = parent_body_area.x;
445        }
446        if card_y < parent_body_area.y {
447            card_y = parent_body_area.y;
448        }
449        if card_x + card_width > parent_body_area.x + parent_body_area.width {
450            card_x = parent_body_area.x + parent_body_area.width - card_width;
451        }
452        if card_y + card_height > parent_body_area.y + parent_body_area.height {
453            card_y = parent_body_area.y + parent_body_area.height - card_height;
454        }
455
456        let render_area = Rect::new(card_x, card_y, card_width, card_height);
457
458        let board_id = app.state.hovered_card.unwrap().0;
459        let card_id = app.state.hovered_card.unwrap().1;
460
461        let card = {
462            let board = app.boards.get_board_with_id(board_id);
463            if let Some(board) = board {
464                board.cards.get_card_with_id(card_id)
465            } else {
466                None
467            }
468        }
469        .cloned();
470
471        if card.is_none() {
472            log::debug!("Card is none");
473            return;
474        }
475        let card = card.unwrap();
476
477        render_blank_styled_canvas(rect, &app.current_theme, render_area, is_active);
478        render_a_single_card(
479            app,
480            render_area,
481            app.current_theme.error_text_style,
482            &card,
483            rect,
484            is_active,
485        )
486    }
487}
488
489pub fn render_close_button(rect: &mut Frame, app: &mut App, is_active: bool) {
490    let close_btn_area = Rect::new(rect.area().width - 3, 0, 3, 3);
491    // Exception to not using get_button_style as we have to manage other state
492    let close_btn_style = if is_active
493        && check_if_mouse_is_in_area(&app.state.current_mouse_coordinates, &close_btn_area)
494    {
495        app.state.mouse_focus = Some(Focus::CloseButton);
496        app.state.set_focus(Focus::CloseButton);
497        let close_button_color = app.widgets.close_button.color;
498        let fg_color = app
499            .current_theme
500            .error_text_style
501            .fg
502            .unwrap_or(Color::White);
503        Style::default().fg(fg_color).bg(Color::Rgb(
504            close_button_color.0,
505            close_button_color.1,
506            close_button_color.2,
507        ))
508    } else if is_active {
509        app.current_theme.general_style
510    } else {
511        app.current_theme.inactive_text_style
512    };
513    let close_btn = Paragraph::new(vec![Line::from("X")])
514        .block(
515            Block::default()
516                .borders(Borders::ALL)
517                .border_type(BorderType::Rounded)
518                .style(close_btn_style),
519        )
520        .alignment(Alignment::Right);
521
522    render_blank_styled_canvas(rect, &app.current_theme, close_btn_area, is_active);
523    rect.render_widget(close_btn, close_btn_area);
524}
525
526pub fn render_blank_styled_canvas(
527    rect: &mut Frame,
528    current_theme: &Theme,
529    render_area: Rect,
530    is_active: bool,
531) {
532    // Preallocate the vectors
533    let mut styled_text = String::with_capacity((render_area.width + 1) as usize);
534    for _ in 0..render_area.width + 1 {
535        styled_text.push(' ');
536    }
537    styled_text.push('\n');
538
539    let mut render_text =
540        String::with_capacity((render_area.height * (render_area.width + 1)) as usize);
541    for _ in 0..render_area.height {
542        render_text.push_str(&styled_text);
543    }
544
545    let styled_text = if is_active {
546        let mut style = current_theme.general_style;
547        style.add_modifier = Modifier::empty();
548        style.sub_modifier = Modifier::all();
549        Paragraph::new(render_text)
550            .style(style)
551            .block(Block::default())
552    } else {
553        let mut style = current_theme.inactive_text_style;
554        style.add_modifier = Modifier::empty();
555        style.sub_modifier = Modifier::all();
556        Paragraph::new(render_text)
557            .style(style)
558            .block(Block::default())
559    };
560    rect.render_widget(styled_text, render_area);
561}
562
563pub fn render_logs(
564    app: &mut App,
565    enable_focus_highlight: bool,
566    render_area: Rect,
567    rect: &mut Frame,
568    is_active: bool,
569) {
570    let log_box_border_style = if enable_focus_highlight {
571        get_mouse_focusable_field_style(app, Focus::Log, &render_area, is_active, false)
572    } else {
573        check_if_active_and_get_style(
574            is_active,
575            app.current_theme.inactive_text_style,
576            app.current_theme.general_style,
577        )
578    };
579    let date_format = app.config.date_time_format.to_parser_string();
580    let theme = &app.current_theme;
581    let all_logs = get_logs();
582    let mut highlight_style = check_if_active_and_get_style(
583        is_active,
584        theme.inactive_text_style,
585        theme.list_select_style,
586    );
587    let mut items = vec![];
588    let date_length = date_format.len() + 5;
589    let wrap_length = render_area.width as usize - date_length - 6; // Border + arrow + padding
590    for log_record in all_logs.buffer {
591        let mut push_vec = vec![format!("[{}] - ", log_record.timestamp.format(date_format))];
592        let wrapped_text = textwrap::fill(&log_record.msg, wrap_length);
593        push_vec.push(wrapped_text);
594        push_vec.push(log_record.level.to_string());
595        items.push(push_vec);
596    }
597    // TODO: Optimize this by using the log state to avoid going through all the logs and only go through the ones that can fit in the render area
598    let rows = items.iter().enumerate().map(|(index, item_og)| {
599        let mut item = item_og.clone();
600        let mut height = item
601            .iter()
602            .map(|content| content.chars().filter(|c| *c == '\n').count())
603            .max()
604            .unwrap_or(0)
605            + 1;
606        if height > (render_area.height as usize - 2) {
607            height = render_area.height as usize - 2;
608        }
609        let style = if !is_active {
610            theme.inactive_text_style
611        } else {
612            let style = match item[2].parse::<Level>().unwrap() {
613                Level::Error => theme.log_error_style,
614                Level::Warn => theme.log_warn_style,
615                Level::Info => theme.log_info_style,
616                Level::Debug => theme.log_debug_style,
617                Level::Trace => theme.log_trace_style,
618            };
619            if index == get_selected_index() {
620                highlight_style = style.add_modifier(Modifier::REVERSED);
621            };
622            style
623        };
624        item.remove(2);
625        let cells = item.iter().map(|c| Cell::from(c.to_string()).style(style));
626        Row::new(cells).height(height as u16)
627    });
628
629    let log_box_style = check_if_active_and_get_style(
630        is_active,
631        app.current_theme.inactive_text_style,
632        app.current_theme.general_style,
633    );
634
635    let log_list = Table::new(
636        rows,
637        [
638            Constraint::Length(date_length as u16),
639            Constraint::Length(wrap_length as u16),
640        ],
641    )
642    .block(
643        Block::default()
644            .title("Logs")
645            .style(log_box_style)
646            .border_style(log_box_border_style)
647            .borders(Borders::ALL)
648            .border_type(BorderType::Rounded),
649    )
650    .row_highlight_style(highlight_style)
651    .highlight_symbol(LIST_SELECTED_SYMBOL);
652
653    rect.render_stateful_widget(
654        log_list,
655        render_area,
656        &mut RUST_KANBAN_LOGGER.hot_log.lock().state,
657    );
658}
659
660fn render_a_single_card(
661    app: &mut App,
662    render_area: Rect,
663    card_style: Style,
664    card: &Card,
665    frame_to_render_on: &mut Frame,
666    is_active: bool,
667) {
668    let inner_card_chunks = Layout::default()
669        .direction(Direction::Vertical)
670        .constraints([Constraint::Fill(1), Constraint::Length(3)].as_ref())
671        .margin(1)
672        .split(render_area);
673
674    let card_title = if card.name.len() > DEFAULT_CARD_TITLE_LENGTH.into() {
675        format!("{}...", &card.name[0..DEFAULT_CARD_TITLE_LENGTH as usize])
676    } else {
677        card.name.clone()
678    };
679    let card_title = if app.state.current_card_id.unwrap_or((0, 0)) == card.id {
680        format!("{} {}", ">>", card_title)
681    } else {
682        card_title
683    };
684
685    let card_description = if card.description == FIELD_NOT_SET {
686        format!("Description: {}", FIELD_NOT_SET)
687    } else {
688        card.description.clone()
689    };
690
691    let card_due_default_style = check_if_active_and_get_style(
692        is_active,
693        app.current_theme.inactive_text_style,
694        app.current_theme.card_due_default_style,
695    );
696    let card_due_warning_style = check_if_active_and_get_style(
697        is_active,
698        app.current_theme.inactive_text_style,
699        app.current_theme.card_due_warning_style,
700    );
701    let card_due_overdue_style = check_if_active_and_get_style(
702        is_active,
703        app.current_theme.inactive_text_style,
704        app.current_theme.card_due_overdue_style,
705    );
706    let general_style = check_if_active_and_get_style(
707        is_active,
708        app.current_theme.inactive_text_style,
709        app.current_theme.general_style,
710    );
711
712    let mut card_extra_info = vec![Line::from("")];
713    if card.due_date == FIELD_NOT_SET {
714        card_extra_info.push(Line::from(Span::styled(
715            format!("Due: {}", FIELD_NOT_SET),
716            card_due_default_style,
717        )))
718    } else {
719        let card_due_date = card.due_date.clone();
720        let parsed_due_date =
721            date_format_converter(card_due_date.trim(), app.config.date_time_format);
722        let card_due_date_styled = if let Ok(parsed_due_date) = parsed_due_date {
723            if parsed_due_date == FIELD_NOT_SET || parsed_due_date.is_empty() {
724                Line::from(Span::styled(
725                    format!("Due: {}", parsed_due_date),
726                    card_due_default_style,
727                ))
728            } else {
729                let formatted_date_format = date_format_finder(&parsed_due_date).unwrap();
730                let (days_left, parsed_due_date) = match formatted_date_format {
731                    DateTimeFormat::DayMonthYear
732                    | DateTimeFormat::MonthDayYear
733                    | DateTimeFormat::YearMonthDay => {
734                        let today = Local::now().date_naive();
735                        let string_to_naive_date_format = NaiveDate::parse_from_str(
736                            &parsed_due_date,
737                            app.config.date_time_format.to_parser_string(),
738                        )
739                        .unwrap();
740                        let days_left = string_to_naive_date_format
741                            .signed_duration_since(today)
742                            .num_days();
743                        let parsed_due_date = string_to_naive_date_format
744                            .format(app.config.date_time_format.to_parser_string())
745                            .to_string();
746                        (days_left, parsed_due_date)
747                    }
748                    DateTimeFormat::DayMonthYearTime
749                    | DateTimeFormat::MonthDayYearTime
750                    | DateTimeFormat::YearMonthDayTime {} => {
751                        let today = Local::now().naive_local();
752                        let string_to_naive_date_format = NaiveDateTime::parse_from_str(
753                            &parsed_due_date,
754                            app.config.date_time_format.to_parser_string(),
755                        )
756                        .unwrap();
757                        let days_left = string_to_naive_date_format
758                            .signed_duration_since(today)
759                            .num_days();
760                        let parsed_due_date = string_to_naive_date_format
761                            .format(app.config.date_time_format.to_parser_string())
762                            .to_string();
763                        (days_left, parsed_due_date)
764                    }
765                };
766                if days_left >= 0 {
767                    match days_left.cmp(&(app.config.warning_delta as i64)) {
768                        Ordering::Less | Ordering::Equal => Line::from(Span::styled(
769                            format!("Due: {}", parsed_due_date),
770                            card_due_warning_style,
771                        )),
772                        Ordering::Greater => Line::from(Span::styled(
773                            format!("Due: {}", parsed_due_date),
774                            card_due_default_style,
775                        )),
776                    }
777                } else {
778                    Line::from(Span::styled(
779                        format!("Due: {}", parsed_due_date),
780                        card_due_overdue_style,
781                    ))
782                }
783            }
784        } else {
785            Line::from(Span::styled(
786                format!("Due: {}", card_due_date),
787                card_due_default_style,
788            ))
789        };
790        card_extra_info.extend(vec![card_due_date_styled]);
791    }
792
793    let mut card_status = format!("Status: {}", card.card_status.clone());
794    let mut card_priority = format!("Priority: {}", card.priority.clone());
795    let required_space = card_status.len() + 3 + card_priority.len(); // 3 is for the " | " separator
796
797    // if required space is not available abbreviate the card status and priority
798    if required_space > (render_area.width - 2) as usize {
799        // accounting for border
800        card_status = format!("S: {}", card.card_status.clone());
801        card_priority = format!("P: {}", card.priority.clone());
802    }
803    let spacer_span = Span::styled(" | ", general_style);
804    let card_status = if !is_active {
805        Span::styled(card_status, app.current_theme.inactive_text_style)
806    } else {
807        match card.card_status {
808            CardStatus::Active => {
809                Span::styled(card_status, app.current_theme.card_status_active_style)
810            }
811            CardStatus::Complete => {
812                Span::styled(card_status, app.current_theme.card_status_completed_style)
813            }
814            CardStatus::Stale => {
815                Span::styled(card_status, app.current_theme.card_status_stale_style)
816            }
817        }
818    };
819    let card_priority = if !is_active {
820        Span::styled(card_priority, app.current_theme.inactive_text_style)
821    } else {
822        match card.priority {
823            CardPriority::High => {
824                Span::styled(card_priority, app.current_theme.card_priority_high_style)
825            }
826            CardPriority::Medium => {
827                Span::styled(card_priority, app.current_theme.card_priority_medium_style)
828            }
829            CardPriority::Low => {
830                Span::styled(card_priority, app.current_theme.card_priority_low_style)
831            }
832        }
833    };
834    let status_line = Line::from(vec![card_priority, spacer_span, card_status]);
835    card_extra_info.extend(vec![status_line]);
836
837    let card_block = Block::default()
838        .title(&*card_title)
839        .borders(Borders::ALL)
840        .border_style(card_style)
841        .border_type(BorderType::Rounded);
842    let card_paragraph = Paragraph::new(card_description)
843        .alignment(Alignment::Left)
844        .block(Block::default())
845        .wrap(ratatui::widgets::Wrap { trim: false });
846    let card_extra_info = Paragraph::new(card_extra_info)
847        .alignment(Alignment::Left)
848        .block(Block::default())
849        .wrap(ratatui::widgets::Wrap { trim: false });
850
851    frame_to_render_on.render_widget(card_block, render_area);
852    frame_to_render_on.render_widget(card_paragraph, inner_card_chunks[0]);
853    frame_to_render_on.render_widget(card_extra_info, inner_card_chunks[1]);
854}
855
856pub fn draw_title<'a>(app: &mut App, render_area: Rect, is_active: bool) -> Paragraph<'a> {
857    let title_style = check_if_active_and_get_style(
858        is_active,
859        app.current_theme.inactive_text_style,
860        app.current_theme.general_style,
861    );
862    let border_style =
863        get_mouse_focusable_field_style(app, Focus::Title, &render_area, is_active, false);
864    Paragraph::new(APP_TITLE)
865        .alignment(Alignment::Center)
866        .block(
867            Block::default()
868                .style(title_style)
869                .borders(Borders::ALL)
870                .border_style(border_style)
871                .border_type(BorderType::Rounded),
872        )
873}
874
875pub fn draw_help<'a>(
876    app: &mut App,
877    render_area: Rect,
878    is_active: bool,
879) -> (Block<'a>, Table<'a>, Table<'a>) {
880    let border_style =
881        get_mouse_focusable_field_style(app, Focus::Help, &render_area, is_active, false);
882    let help_key_style = check_if_active_and_get_style(
883        is_active,
884        app.current_theme.inactive_text_style,
885        app.current_theme.help_key_style,
886    );
887    let help_text_style = check_if_active_and_get_style(
888        is_active,
889        app.current_theme.inactive_text_style,
890        app.current_theme.help_text_style,
891    );
892    let current_element_style = check_if_active_and_get_style(
893        is_active,
894        app.current_theme.inactive_text_style,
895        app.current_theme.list_select_style,
896    );
897
898    let rows: Vec<Row> = app
899        .config
900        .keybindings
901        .iter()
902        .map(|item| {
903            let keys = item
904                .1
905                .iter()
906                .map(|key| key.to_string())
907                .collect::<Vec<String>>()
908                .join(", ");
909            let cells = vec![
910                Cell::from(item.0.to_string()).style(help_text_style),
911                Cell::from(keys).style(help_key_style),
912            ];
913            Row::new(cells)
914        })
915        .collect();
916
917    let mid_point = rows.len() / 2;
918    let left_rows = rows[..mid_point].to_vec();
919    let right_rows = rows[mid_point..].to_vec();
920
921    let left_table = Table::new(
922        left_rows,
923        [Constraint::Percentage(70), Constraint::Percentage(30)],
924    )
925    .block(Block::default().style(help_text_style))
926    .row_highlight_style(current_element_style)
927    .highlight_symbol(">> ")
928    .style(border_style);
929
930    let right_table = Table::new(
931        right_rows,
932        [Constraint::Percentage(70), Constraint::Percentage(30)],
933    )
934    .block(Block::default().style(help_text_style))
935    .row_highlight_style(current_element_style)
936    .highlight_symbol(">> ")
937    .style(border_style);
938
939    let border_block = Block::default()
940        .title("Help")
941        .borders(Borders::ALL)
942        .style(help_text_style)
943        .border_style(border_style)
944        .border_type(BorderType::Rounded);
945
946    (border_block, left_table, right_table)
947}
948
949// TODO: Make this a widget instead
950pub fn draw_crab_pattern(
951    render_area: Rect,
952    style: Style,
953    is_active: bool,
954    disable_animations: bool,
955) -> Paragraph<'static> {
956    let crab_pattern = if !is_active || disable_animations {
957        create_crab_pattern_1(render_area.width, render_area.height, is_active)
958    } else {
959        let patterns = [
960            create_crab_pattern_1(render_area.width, render_area.height, is_active),
961            create_crab_pattern_2(render_area.width, render_area.height, is_active),
962            create_crab_pattern_3(render_area.width, render_area.height, is_active),
963        ];
964        // get_time_offset() gives offset from unix epoch use this to give different patterns every 1000ms
965        let index = (get_time_offset() / PATTERN_CHANGE_INTERVAL) as usize % patterns.len();
966        patterns[index].clone()
967    };
968    Paragraph::new(crab_pattern)
969        .style(style)
970        .block(Block::default())
971}
972
973fn create_crab_pattern_1(width: u16, height: u16, is_active: bool) -> String {
974    let mut pattern = String::new();
975    for row in 0..height {
976        for col in 0..width {
977            if (row + col) % 2 == 0 {
978                if is_active {
979                    pattern.push('🦀');
980                } else {
981                    pattern.push_str(HIDDEN_PASSWORD_SYMBOL.to_string().as_str());
982                }
983            } else {
984                pattern.push_str("  ");
985            }
986        }
987        pattern.push('\n');
988    }
989    pattern
990}
991
992fn create_crab_pattern_2(width: u16, height: u16, is_active: bool) -> String {
993    let mut pattern = String::new();
994    let block_size = 4;
995
996    for row in 0..height {
997        let block_row = row % block_size;
998
999        for col in 0..width {
1000            let block_col = col % block_size;
1001
1002            if (block_row == 0 && block_col <= 1)
1003                || (block_row == 1 && block_col >= 2)
1004                || (block_row == 2 && block_col <= 1)
1005            {
1006                if is_active {
1007                    pattern.push_str(" 🦀 ");
1008                } else {
1009                    pattern.push_str(HIDDEN_PASSWORD_SYMBOL.to_string().as_str());
1010                }
1011            } else {
1012                pattern.push_str("   ");
1013            }
1014        }
1015        pattern.push('\n');
1016    }
1017    pattern
1018}
1019
1020fn create_crab_pattern_3(width: u16, height: u16, is_active: bool) -> String {
1021    let mut pattern = String::new();
1022    for row in 0..height {
1023        for col in 0..width {
1024            if (row % 2 == 0 && col % 2 == 0) || (row % 2 == 1 && col % 2 == 1) {
1025                if is_active {
1026                    pattern.push_str(" 🦀 ");
1027                } else {
1028                    pattern.push_str(HIDDEN_PASSWORD_SYMBOL.to_string().as_str());
1029                }
1030            } else {
1031                pattern.push_str("   ");
1032            }
1033        }
1034        pattern.push('\n');
1035    }
1036    pattern
1037}
1038
1039fn get_time_offset() -> u64 {
1040    let start_time = SystemTime::now();
1041    let since_epoch = start_time.duration_since(UNIX_EPOCH).unwrap();
1042    since_epoch.as_millis() as u64
1043}
1044
1045pub fn render_blank_styled_canvas_with_margin(
1046    rect: &mut Frame,
1047    app: &mut App,
1048    render_area: Rect,
1049    is_active: bool,
1050    margin: i16,
1051) {
1052    let general_style = check_if_active_and_get_style(
1053        is_active,
1054        app.current_theme.inactive_text_style,
1055        app.current_theme.general_style,
1056    );
1057
1058    let x = render_area.x as i16 + margin;
1059    let x = if x < 0 { 0 } else { x };
1060    let y = render_area.y as i16 + margin;
1061    let y = if y < 0 { 0 } else { y };
1062    let width = render_area.width as i16 - margin * 2;
1063    let width = if width < 0 { 0 } else { width };
1064    let height = render_area.height as i16 - margin * 2;
1065    let height = if height < 0 { 0 } else { height };
1066
1067    let new_render_area = Rect::new(x as u16, y as u16, width as u16, height as u16);
1068
1069    let mut styled_text = vec![];
1070    for _ in 0..new_render_area.width + 1 {
1071        styled_text.push(" ".to_string());
1072    }
1073    let mut render_text = vec![];
1074    for _ in 0..new_render_area.height {
1075        render_text.push(format!("{}\n", styled_text.join("")));
1076    }
1077    let styled_text = Paragraph::new(render_text.join(""))
1078        .style(general_style)
1079        .block(Block::default());
1080    rect.render_widget(styled_text, new_render_area);
1081}