thoth_cli/
ui.rs

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