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