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    f.render_widget(text, area);
273}
274
275pub fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
276    let popup_layout = Layout::default()
277        .direction(Direction::Vertical)
278        .constraints(
279            [
280                Constraint::Percentage((100 - percent_y) / 2),
281                Constraint::Percentage(percent_y),
282                Constraint::Percentage((100 - percent_y) / 2),
283            ]
284            .as_ref(),
285        )
286        .split(r);
287
288    Layout::default()
289        .direction(Direction::Horizontal)
290        .constraints(
291            [
292                Constraint::Percentage((100 - percent_x) / 2),
293                Constraint::Percentage(percent_x),
294                Constraint::Percentage((100 - percent_x) / 2),
295            ]
296            .as_ref(),
297        )
298        .split(popup_layout[1])[1]
299}
300
301#[cfg(test)]
302mod tests {
303    use ratatui::{backend::TestBackend, Terminal};
304
305    use super::*;
306
307    #[test]
308    fn test_centered_rect() {
309        let r = Rect::new(0, 0, 100, 100);
310        let centered = centered_rect(50, 50, r);
311        assert_eq!(centered.width, 50);
312        assert_eq!(centered.height, 50);
313        assert_eq!(centered.x, 25);
314        assert_eq!(centered.y, 25);
315    }
316
317    #[test]
318    fn test_render_header() {
319        let backend = TestBackend::new(100, 1);
320        let mut terminal = Terminal::new(backend).unwrap();
321
322        terminal
323            .draw(|f| {
324                let area = f.size();
325                render_header(f, area, false);
326            })
327            .unwrap();
328
329        let buffer = terminal.backend().buffer();
330
331        assert!(buffer
332            .content
333            .iter()
334            .any(|cell| cell.symbol().contains("Q")));
335        assert!(buffer
336            .content
337            .iter()
338            .any(|cell| cell.symbol().contains("u")));
339        assert!(buffer
340            .content
341            .iter()
342            .any(|cell| cell.symbol().contains("i")));
343        assert!(buffer
344            .content
345            .iter()
346            .any(|cell| cell.symbol().contains("t")));
347
348        assert!(buffer.content.iter().any(|cell| cell.fg == ORANGE));
349    }
350
351    #[test]
352    fn test_render_title_popup() {
353        let backend = TestBackend::new(100, 30);
354        let mut terminal = Terminal::new(backend).unwrap();
355        let popup = TitlePopup {
356            title: "Test Title".to_string(),
357            visible: true,
358        };
359
360        terminal
361            .draw(|f| {
362                render_title_popup(f, &popup);
363            })
364            .unwrap();
365
366        let buffer = terminal.backend().buffer();
367
368        assert!(buffer
369            .content
370            .iter()
371            .any(|cell| cell.symbol().contains("T")));
372
373        assert!(buffer
374            .content
375            .iter()
376            .any(|cell| cell.symbol().contains("e")));
377
378        assert!(buffer
379            .content
380            .iter()
381            .any(|cell| cell.symbol().contains("s")));
382
383        assert!(buffer
384            .content
385            .iter()
386            .any(|cell| cell.symbol().contains("t")));
387
388        assert!(buffer
389            .content
390            .iter()
391            .any(|cell| cell.symbol() == "─" || cell.symbol() == "│"));
392    }
393
394    #[test]
395    fn test_render_title_select_popup() {
396        let backend = TestBackend::new(100, 30);
397        let mut terminal = Terminal::new(backend).unwrap();
398        let mut popup = TitleSelectPopup {
399            titles: Vec::new(),
400            selected_index: 0,
401            visible: true,
402            scroll_offset: 0,
403            search_query: "".to_string(),
404            filtered_titles: Vec::new(),
405        };
406
407        popup.set_titles(vec!["Title1".to_string(), "Title2".to_string()]);
408
409        terminal
410            .draw(|f| {
411                render_title_select_popup(f, &popup);
412            })
413            .unwrap();
414
415        let buffer = terminal.backend().buffer();
416
417        assert!(buffer
418            .content
419            .iter()
420            .any(|cell| cell.symbol().contains(">")));
421        assert!(buffer
422            .content
423            .iter()
424            .any(|cell| cell.symbol().contains("2")));
425
426        assert!(buffer
427            .content
428            .iter()
429            .any(|cell| cell.symbol().contains("1")));
430    }
431
432    #[test]
433    fn test_render_edit_commands_popup() {
434        let backend = TestBackend::new(100, 30);
435        let mut terminal = Terminal::new(backend).unwrap();
436
437        terminal
438            .draw(|f| {
439                render_edit_commands_popup(f);
440            })
441            .unwrap();
442
443        let buffer = terminal.backend().buffer();
444
445        assert!(buffer
446            .content
447            .iter()
448            .any(|cell| cell.symbol().contains("E")));
449
450        assert!(buffer
451            .content
452            .iter()
453            .any(|cell| cell.symbol().contains("H")));
454        assert!(buffer
455            .content
456            .iter()
457            .any(|cell| cell.symbol().contains("K")));
458
459        assert!(buffer
460            .content
461            .iter()
462            .any(|cell| cell.symbol().contains("I") && cell.fg == ORANGE));
463    }
464}