thoth_cli/
ui.rs

1use crate::{ThemeColors, TitlePopup, TitleSelectPopup, BORDER_PADDING_SIZE, DARK_MODE_COLORS};
2use ratatui::{
3    layout::{Constraint, Direction, Layout, Rect},
4    style::{Modifier, Style},
5    text::{Line, Span},
6    widgets::{Block, Borders, Cell, Paragraph, Row, Table, Tabs},
7    Frame,
8};
9use unicode_width::UnicodeWidthStr;
10
11pub struct EditCommandsPopup {
12    pub visible: bool,
13}
14
15impl EditCommandsPopup {
16    pub fn new() -> Self {
17        EditCommandsPopup { visible: false }
18    }
19}
20impl Default for EditCommandsPopup {
21    fn default() -> Self {
22        Self::new()
23    }
24}
25
26pub struct UiPopup {
27    pub message: String,
28    pub popup_title: String,
29    pub visible: bool,
30    pub percent_x: u16,
31    pub percent_y: u16,
32}
33
34impl UiPopup {
35    pub fn new(popup_title: String, percent_x: u16, percent_y: u16) -> Self {
36        UiPopup {
37            message: String::new(),
38            visible: false,
39            popup_title,
40            percent_x,
41            percent_y,
42        }
43    }
44
45    pub fn show(&mut self, message: String) {
46        self.message = message;
47        self.visible = true;
48    }
49
50    pub fn hide(&mut self) {
51        self.visible = false;
52    }
53}
54
55impl Default for UiPopup {
56    fn default() -> Self {
57        Self::new("".to_owned(), 60, 20)
58    }
59}
60
61pub fn render_edit_commands_popup(f: &mut Frame, theme: &ThemeColors) {
62    let area = centered_rect(80, 80, f.size());
63    f.render_widget(ratatui::widgets::Clear, area);
64
65    let block = Block::default()
66        .borders(Borders::ALL)
67        .border_style(Style::default().fg(theme.primary))
68        .title("Editing Commands - Esc to exit");
69
70    let header = Row::new(vec![
71        Cell::from("MAPPINGS").style(
72            Style::default()
73                .fg(theme.accent)
74                .add_modifier(Modifier::BOLD),
75        ),
76        Cell::from("DESCRIPTIONS").style(
77            Style::default()
78                .fg(theme.accent)
79                .add_modifier(Modifier::BOLD),
80        ),
81    ])
82    .height(BORDER_PADDING_SIZE as u16);
83
84    let commands: Vec<Row> = vec![
85        Row::new(vec![
86            "Ctrl+H, Backspace",
87            "Delete one character before cursor",
88        ]),
89        Row::new(vec!["Ctrl+K", "Delete from cursor until the end of line"]),
90        Row::new(vec![
91            "Ctrl+W, Alt+Backspace",
92            "Delete one word before cursor",
93        ]),
94        Row::new(vec!["Alt+D, Alt+Delete", "Delete one word next to cursor"]),
95        Row::new(vec!["Ctrl+U", "Undo"]),
96        Row::new(vec!["Ctrl+R", "Redo"]),
97        Row::new(vec!["Ctrl+C, Copy", "Copy selected text"]),
98        Row::new(vec!["Ctrl+X, Cut", "Cut selected text"]),
99        Row::new(vec!["Ctrl+P, ↑", "Move cursor up by one line"]),
100        Row::new(vec!["Ctrl+→", "Move cursor forward by word"]),
101        Row::new(vec!["Ctrl+←", "Move cursor backward by word"]),
102        Row::new(vec!["Ctrl+↑", "Move cursor up by paragraph"]),
103        Row::new(vec!["Ctrl+↓", "Move cursor down by paragraph"]),
104        Row::new(vec![
105            "Ctrl+E, End, Ctrl+Alt+F, Ctrl+Alt+→",
106            "Move cursor to the end of line",
107        ]),
108        Row::new(vec![
109            "Ctrl+A, Home, Ctrl+Alt+B, Ctrl+Alt+←",
110            "Move cursor to the head of line",
111        ]),
112        Row::new(vec!["Ctrl+L", "Toggle between light and dark mode"]),
113        Row::new(vec!["Ctrl+K", "Format markdown block"]),
114        Row::new(vec!["Ctrl+J", "Format JSON"]),
115    ];
116
117    let table = Table::new(commands, [Constraint::Length(5), Constraint::Length(5)])
118        .header(header)
119        .block(block)
120        .widths([Constraint::Percentage(30), Constraint::Percentage(70)])
121        .column_spacing(BORDER_PADDING_SIZE as u16)
122        .highlight_style(Style::default().fg(theme.accent))
123        .highlight_symbol(">> ");
124
125    f.render_widget(table, area);
126}
127
128pub fn render_header(f: &mut Frame, area: Rect, is_edit_mode: bool, theme: &ThemeColors) {
129    let available_width = area.width as usize;
130    let normal_commands = vec![
131        "q:Quit",
132        "^h:Help",
133        "^n:Add",
134        "^d:Del",
135        "^y:Copy",
136        "^v:Paste",
137        "Enter:Edit",
138        "^f:Focus",
139        "Esc:Exit",
140        "^t:Title",
141        "^s:Select",
142        "^l:Toggle Theme",
143        "^j:Format JSON",
144        "^k:Format Markdown",
145    ];
146    let edit_commands = vec![
147        "Esc:Exit Edit",
148        "^g:Move Cursor Top",
149        "^b:Copy Sel",
150        "Shift+↑↓:Sel",
151        "^y:Copy All",
152        "^t:Title",
153        "^s:Select",
154        "^e:External Editor",
155        "^h:Help",
156        "^l:Toggle Theme",
157    ];
158    let commands = if is_edit_mode {
159        &edit_commands
160    } else {
161        &normal_commands
162    };
163    let thoth = "Thoth  ";
164    let separator = " | ";
165
166    let thoth_width = thoth.width();
167    let separator_width = separator.width();
168    let reserved_width = thoth_width + BORDER_PADDING_SIZE; // 2 extra spaces for padding
169
170    let mut display_commands = Vec::new();
171    let mut current_width = 0;
172
173    for cmd in commands {
174        let cmd_width = cmd.width();
175        if current_width + cmd_width + separator_width > available_width - reserved_width {
176            break;
177        }
178        display_commands.push(*cmd);
179        current_width += cmd_width + separator_width;
180    }
181
182    let command_string = display_commands.join(separator);
183    let command_width = command_string.width();
184
185    let padding = " ".repeat(available_width - command_width - thoth_width - BORDER_PADDING_SIZE);
186
187    let header = Line::from(vec![
188        Span::styled(command_string, Style::default().fg(theme.accent)),
189        Span::styled(padding, Style::default().fg(theme.accent)),
190        Span::styled(format!(" {} ", thoth), Style::default().fg(theme.accent)),
191    ]);
192
193    let tabs = Tabs::new(vec![header])
194        .style(Style::default().bg(theme.header_bg))
195        .divider(Span::styled("|", Style::default().fg(theme.accent)));
196
197    f.render_widget(tabs, area);
198}
199
200pub fn render_help_popup(f: &mut Frame, popup: &UiPopup, theme: ThemeColors) {
201    if !popup.visible {
202        return;
203    }
204
205    let area = centered_rect(80, 80, f.size());
206    f.render_widget(ratatui::widgets::Clear, area);
207
208    let border_color = if theme == *DARK_MODE_COLORS {
209        theme.accent
210    } else {
211        theme.primary
212    };
213
214    let text = Paragraph::new(popup.message.as_str())
215        .style(Style::default().fg(theme.foreground))
216        .block(
217            Block::default()
218                .borders(Borders::ALL)
219                .border_style(Style::default().fg(border_color))
220                .title(format!("{} - Esc to exit", popup.popup_title)),
221        )
222        .wrap(ratatui::widgets::Wrap { trim: true });
223
224    f.render_widget(text, area);
225}
226
227pub fn render_title_popup(f: &mut Frame, popup: &TitlePopup, theme: &ThemeColors) {
228    let area = centered_rect(60, 20, f.size());
229    f.render_widget(ratatui::widgets::Clear, area);
230
231    let text = Paragraph::new(popup.title.as_str())
232        .style(Style::default().bg(theme.background).fg(theme.foreground))
233        .block(
234            Block::default()
235                .borders(Borders::ALL)
236                .border_style(Style::default().fg(theme.primary))
237                .title("Change Title"),
238        );
239    f.render_widget(text, area);
240}
241
242pub fn render_title_select_popup(f: &mut Frame, popup: &TitleSelectPopup, theme: &ThemeColors) {
243    let area = centered_rect(80, 80, f.size());
244    f.render_widget(ratatui::widgets::Clear, area);
245
246    let constraints = vec![Constraint::Min(1), Constraint::Length(3)];
247
248    let chunks = Layout::default()
249        .direction(Direction::Vertical)
250        .constraints(constraints)
251        .split(area);
252
253    let main_area = chunks[0];
254    let search_box = chunks[1];
255
256    let visible_height = main_area.height.saturating_sub(BORDER_PADDING_SIZE as u16) as usize;
257
258    let start_idx = popup.scroll_offset;
259    let end_idx = (popup.scroll_offset + visible_height).min(popup.filtered_titles.len());
260    let visible_titles = &popup.filtered_titles[start_idx..end_idx];
261
262    let items: Vec<Line> = visible_titles
263        .iter()
264        .enumerate()
265        .map(|(i, title_match)| {
266            let absolute_idx = i + popup.scroll_offset;
267            if absolute_idx == popup.selected_index {
268                Line::from(vec![Span::styled(
269                    format!("> {}", title_match.title),
270                    Style::default().fg(theme.accent),
271                )])
272            } else {
273                Line::from(vec![Span::raw(format!("  {}", title_match.title))])
274            }
275        })
276        .collect();
277
278    let block = Block::default()
279        .borders(Borders::ALL)
280        .border_style(Style::default().fg(theme.primary))
281        .title("Select Title");
282
283    let paragraph = Paragraph::new(items)
284        .block(block)
285        .wrap(ratatui::widgets::Wrap { trim: true });
286
287    f.render_widget(paragraph, main_area);
288
289    let search_block = Block::default()
290        .borders(Borders::ALL)
291        .border_style(Style::default().fg(theme.primary))
292        .title("Search");
293
294    let search_text = Paragraph::new(popup.search_query.as_str()).block(search_block);
295
296    f.render_widget(search_text, search_box);
297}
298
299pub fn render_ui_popup(f: &mut Frame, popup: &UiPopup, theme: &ThemeColors) {
300    if !popup.visible {
301        return;
302    }
303
304    let area = centered_rect(popup.percent_x, popup.percent_y, f.size());
305    f.render_widget(ratatui::widgets::Clear, area);
306
307    let text = Paragraph::new(popup.message.as_str())
308        .style(Style::default().fg(theme.error))
309        .block(
310            Block::default()
311                .borders(Borders::ALL)
312                .border_style(Style::default().fg(theme.error))
313                .title(format!("{} - Esc to exit", popup.popup_title)),
314        )
315        .wrap(ratatui::widgets::Wrap { trim: true }); // Enable text wrapping
316
317    f.render_widget(text, area);
318}
319
320pub fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
321    let popup_layout = Layout::default()
322        .direction(Direction::Vertical)
323        .constraints(
324            [
325                Constraint::Percentage((100 - percent_y) / 2),
326                Constraint::Percentage(percent_y),
327                Constraint::Percentage((100 - percent_y) / 2),
328            ]
329            .as_ref(),
330        )
331        .split(r);
332
333    Layout::default()
334        .direction(Direction::Horizontal)
335        .constraints(
336            [
337                Constraint::Percentage((100 - percent_x) / 2),
338                Constraint::Percentage(percent_x),
339                Constraint::Percentage((100 - percent_x) / 2),
340            ]
341            .as_ref(),
342        )
343        .split(popup_layout[1])[1]
344}
345
346#[cfg(test)]
347mod tests {
348    use ratatui::{backend::TestBackend, Terminal};
349
350    use crate::DARK_MODE_COLORS;
351    use crate::ORANGE;
352
353    use super::*;
354
355    #[test]
356    fn test_centered_rect() {
357        let r = Rect::new(0, 0, 100, 100);
358        let centered = centered_rect(50, 50, r);
359        assert_eq!(centered.width, 50);
360        assert_eq!(centered.height, 50);
361        assert_eq!(centered.x, 25);
362        assert_eq!(centered.y, 25);
363    }
364
365    #[test]
366    fn test_render_header() {
367        let backend = TestBackend::new(100, 1);
368        let mut terminal = Terminal::new(backend).unwrap();
369
370        terminal
371            .draw(|f| {
372                let area = f.size();
373                render_header(f, area, false, &DARK_MODE_COLORS);
374            })
375            .unwrap();
376
377        let buffer = terminal.backend().buffer();
378
379        assert!(buffer
380            .content
381            .iter()
382            .any(|cell| cell.symbol().contains("Q")));
383        assert!(buffer
384            .content
385            .iter()
386            .any(|cell| cell.symbol().contains("u")));
387        assert!(buffer
388            .content
389            .iter()
390            .any(|cell| cell.symbol().contains("i")));
391        assert!(buffer
392            .content
393            .iter()
394            .any(|cell| cell.symbol().contains("t")));
395
396        assert!(buffer.content.iter().any(|cell| cell.fg == ORANGE));
397    }
398
399    #[test]
400    fn test_render_title_popup() {
401        let backend = TestBackend::new(100, 30);
402        let mut terminal = Terminal::new(backend).unwrap();
403        let popup = TitlePopup {
404            title: "Test Title".to_string(),
405            visible: true,
406        };
407
408        terminal
409            .draw(|f| {
410                render_title_popup(f, &popup, &DARK_MODE_COLORS);
411            })
412            .unwrap();
413
414        let buffer = terminal.backend().buffer();
415
416        assert!(buffer
417            .content
418            .iter()
419            .any(|cell| cell.symbol().contains("T")));
420
421        assert!(buffer
422            .content
423            .iter()
424            .any(|cell| cell.symbol().contains("e")));
425
426        assert!(buffer
427            .content
428            .iter()
429            .any(|cell| cell.symbol().contains("s")));
430
431        assert!(buffer
432            .content
433            .iter()
434            .any(|cell| cell.symbol().contains("t")));
435
436        assert!(buffer
437            .content
438            .iter()
439            .any(|cell| cell.symbol() == "─" || cell.symbol() == "│"));
440    }
441
442    #[test]
443    fn test_render_title_select_popup() {
444        let backend = TestBackend::new(100, 30);
445        let mut terminal = Terminal::new(backend).unwrap();
446        let mut popup = TitleSelectPopup {
447            titles: Vec::new(),
448            selected_index: 0,
449            visible: true,
450            scroll_offset: 0,
451            search_query: "".to_string(),
452            filtered_titles: Vec::new(),
453        };
454
455        popup.set_titles(vec!["Title1".to_string(), "Title2".to_string()]);
456
457        terminal
458            .draw(|f| {
459                render_title_select_popup(f, &popup, &DARK_MODE_COLORS);
460            })
461            .unwrap();
462
463        let buffer = terminal.backend().buffer();
464
465        assert!(buffer
466            .content
467            .iter()
468            .any(|cell| cell.symbol().contains(">")));
469        assert!(buffer
470            .content
471            .iter()
472            .any(|cell| cell.symbol().contains("2")));
473
474        assert!(buffer
475            .content
476            .iter()
477            .any(|cell| cell.symbol().contains("1")));
478    }
479
480    #[test]
481    fn test_render_edit_commands_popup() {
482        let backend = TestBackend::new(100, 30);
483        let mut terminal = Terminal::new(backend).unwrap();
484
485        terminal
486            .draw(|f| {
487                render_edit_commands_popup(f, &DARK_MODE_COLORS);
488            })
489            .unwrap();
490
491        let buffer = terminal.backend().buffer();
492
493        assert!(buffer
494            .content
495            .iter()
496            .any(|cell| cell.symbol().contains("E")));
497
498        assert!(buffer
499            .content
500            .iter()
501            .any(|cell| cell.symbol().contains("H")));
502        assert!(buffer
503            .content
504            .iter()
505            .any(|cell| cell.symbol().contains("K")));
506
507        assert!(buffer
508            .content
509            .iter()
510            .any(|cell| cell.symbol().contains("I") && cell.fg == ORANGE));
511    }
512}