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