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