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; 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}