thoth_cli/
ui.rs

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