1use crate::inspector::Inspector;
7use crate::task::{TaskInfo, TaskState};
8use crossterm::{
9 event::{
10 self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, MouseEvent, MouseEventKind,
11 },
12 execute,
13 terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
14};
15use ratatui::{
16 backend::CrosstermBackend,
17 layout::{Constraint, Direction, Layout, Rect},
18 style::{Color, Modifier, Style, Stylize},
19 text::{Line, Span},
20 widgets::{Block, Borders, Paragraph, Row, Table},
21 Frame, Terminal,
22};
23use std::io;
24use std::time::{Duration, Instant};
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum SortMode {
29 Id,
31 Name,
33 Duration,
35 State,
37 PollCount,
39}
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum FilterMode {
44 All,
46 Running,
48 Completed,
50 Failed,
52 Blocked,
54}
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum ViewMode {
59 TaskList,
61 DependencyGraph,
63}
64
65pub struct TuiApp {
67 inspector: Inspector,
69
70 view_mode: ViewMode,
72
73 sort_mode: SortMode,
75
76 filter_mode: FilterMode,
78
79 selected: usize,
81
82 show_help: bool,
84
85 search_query: String,
87
88 search_active: bool,
90
91 last_update: Instant,
93
94 update_interval: Duration,
96}
97
98impl TuiApp {
99 #[must_use]
101 pub fn new(inspector: Inspector) -> Self {
102 Self {
103 inspector,
104 view_mode: ViewMode::TaskList,
105 sort_mode: SortMode::Duration,
106 filter_mode: FilterMode::All,
107 selected: 0,
108 show_help: false,
109 search_query: String::new(),
110 search_active: false,
111 last_update: Instant::now(),
112 update_interval: Duration::from_millis(100),
113 }
114 }
115
116 pub fn set_update_interval(&mut self, interval: Duration) {
118 self.update_interval = interval;
119 }
120
121 fn get_tasks(&self) -> Vec<TaskInfo> {
123 let mut tasks = self.inspector.get_all_tasks();
124
125 if !self.search_query.is_empty() {
127 let query = self.search_query.to_lowercase();
128 tasks.retain(|task| {
129 task.name.to_lowercase().contains(&query)
130 || format!("{}", task.id.as_u64()).contains(&query)
131 });
132 }
133
134 tasks.retain(|task| match self.filter_mode {
136 FilterMode::All => true,
137 FilterMode::Running => matches!(task.state, TaskState::Running),
138 FilterMode::Completed => matches!(task.state, TaskState::Completed),
139 FilterMode::Failed => matches!(task.state, TaskState::Failed),
140 FilterMode::Blocked => matches!(task.state, TaskState::Blocked { .. }),
141 });
142
143 match self.sort_mode {
145 SortMode::Id => tasks.sort_by_key(|t| t.id.as_u64()),
146 SortMode::Name => tasks.sort_by(|a, b| a.name.cmp(&b.name)),
147 SortMode::Duration => tasks.sort_by(|a, b| b.age().cmp(&a.age())),
148 SortMode::State => {
149 tasks.sort_by(|a, b| format!("{:?}", a.state).cmp(&format!("{:?}", b.state)));
150 }
151 SortMode::PollCount => tasks.sort_by(|a, b| b.poll_count.cmp(&a.poll_count)),
152 }
153
154 tasks
155 }
156
157 fn select_previous(&mut self) {
159 if self.selected > 0 {
160 self.selected -= 1;
161 }
162 }
163
164 fn select_next(&mut self, max: usize) {
166 if self.selected < max.saturating_sub(1) {
167 self.selected += 1;
168 }
169 }
170
171 fn next_sort_mode(&mut self) {
173 self.sort_mode = match self.sort_mode {
174 SortMode::Id => SortMode::Name,
175 SortMode::Name => SortMode::Duration,
176 SortMode::Duration => SortMode::State,
177 SortMode::State => SortMode::PollCount,
178 SortMode::PollCount => SortMode::Id,
179 };
180 self.selected = 0;
181 }
182
183 fn next_filter_mode(&mut self) {
185 self.filter_mode = match self.filter_mode {
186 FilterMode::All => FilterMode::Running,
187 FilterMode::Running => FilterMode::Completed,
188 FilterMode::Completed => FilterMode::Failed,
189 FilterMode::Failed => FilterMode::Blocked,
190 FilterMode::Blocked => FilterMode::All,
191 };
192 self.selected = 0;
193 }
194
195 fn toggle_help(&mut self) {
197 self.show_help = !self.show_help;
198 }
199
200 fn toggle_view_mode(&mut self) {
202 self.view_mode = match self.view_mode {
203 ViewMode::TaskList => ViewMode::DependencyGraph,
204 ViewMode::DependencyGraph => ViewMode::TaskList,
205 };
206 self.selected = 0;
207 }
208
209 fn activate_search(&mut self) {
211 self.search_active = true;
212 }
213
214 fn deactivate_search(&mut self) {
216 self.search_active = false;
217 }
218
219 fn clear_search(&mut self) {
221 self.search_query.clear();
222 self.selected = 0;
223 }
224
225 fn add_to_search(&mut self, c: char) {
227 self.search_query.push(c);
228 self.selected = 0;
229 }
230
231 fn backspace_search(&mut self) {
233 self.search_query.pop();
234 self.selected = 0;
235 }
236
237 fn export_data(&mut self) -> io::Result<()> {
239 use crate::export::{ChromeTraceExporter, CsvExporter, JsonExporter};
240 use std::fs;
241
242 let export_dir = "tui_exports";
244 fs::create_dir_all(export_dir)?;
245
246 let timestamp = std::time::SystemTime::now()
247 .duration_since(std::time::UNIX_EPOCH)
248 .unwrap()
249 .as_secs();
250
251 JsonExporter::export_to_file(
253 &self.inspector,
254 format!("{export_dir}/export_{timestamp}.json"),
255 )?;
256
257 CsvExporter::export_tasks_to_file(
258 &self.inspector,
259 format!("{export_dir}/tasks_{timestamp}.csv"),
260 )?;
261
262 CsvExporter::export_events_to_file(
263 &self.inspector,
264 format!("{export_dir}/events_{timestamp}.csv"),
265 )?;
266
267 ChromeTraceExporter::export_to_file(
268 &self.inspector,
269 format!("{export_dir}/trace_{timestamp}.json"),
270 )?;
271
272 Ok(())
273 }
274}
275
276pub fn run_tui(inspector: Inspector) -> io::Result<()> {
278 enable_raw_mode()?;
280 let mut stdout = io::stdout();
281 execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
282 let backend = CrosstermBackend::new(stdout);
283 let mut terminal = Terminal::new(backend)?;
284
285 let mut app = TuiApp::new(inspector);
287
288 let result = run_app(&mut terminal, &mut app);
290
291 disable_raw_mode()?;
293 execute!(
294 terminal.backend_mut(),
295 LeaveAlternateScreen,
296 DisableMouseCapture
297 )?;
298 terminal.show_cursor()?;
299
300 result
301}
302
303fn run_app<B: ratatui::backend::Backend>(
305 terminal: &mut Terminal<B>,
306 app: &mut TuiApp,
307) -> io::Result<()> {
308 loop {
309 terminal.draw(|f| ui(f, app))?;
310
311 if event::poll(app.update_interval)? {
313 match event::read()? {
314 Event::Key(key) => {
315 if app.search_active {
317 match key.code {
318 KeyCode::Esc => {
319 app.deactivate_search();
320 app.clear_search();
321 }
322 KeyCode::Enter => app.deactivate_search(),
323 KeyCode::Backspace => app.backspace_search(),
324 KeyCode::Char(c) => app.add_to_search(c),
325 _ => {}
326 }
327 } else {
328 match key.code {
329 KeyCode::Char('q') => return Ok(()),
330 KeyCode::Char('h' | '?') => app.toggle_help(),
331 KeyCode::Char('s') => app.next_sort_mode(),
332 KeyCode::Char('f') => app.next_filter_mode(),
333 KeyCode::Char('v') => app.toggle_view_mode(),
334 KeyCode::Char('/') => app.activate_search(),
335 KeyCode::Char('c') => app.clear_search(),
336 KeyCode::Char('e') => {
337 if let Err(e) = app.export_data() {
338 eprintln!("Export failed: {e}");
340 }
341 }
342 KeyCode::Up => app.select_previous(),
343 KeyCode::Down => {
344 let tasks = app.get_tasks();
345 app.select_next(tasks.len());
346 }
347 KeyCode::Char('r') => app.selected = 0, _ => {}
349 }
350 }
351 }
352 Event::Mouse(mouse) => {
353 handle_mouse_event(app, mouse);
354 }
355 _ => {}
356 }
357 }
358
359 app.last_update = Instant::now();
360 }
361}
362
363fn handle_mouse_event(app: &mut TuiApp, mouse: MouseEvent) {
365 match mouse.kind {
366 MouseEventKind::ScrollDown => {
367 let tasks = app.get_tasks();
368 app.select_next(tasks.len());
369 }
370 MouseEventKind::ScrollUp => {
371 app.select_previous();
372 }
373 MouseEventKind::Down(_button) => {
374 }
378 _ => {}
379 }
380}
381
382fn ui(f: &mut Frame, app: &mut TuiApp) {
384 if app.show_help {
385 draw_help(f);
386 return;
387 }
388
389 let mut constraints = vec![
391 Constraint::Length(3), Constraint::Length(7), ];
394
395 if app.search_active || !app.search_query.is_empty() {
397 constraints.push(Constraint::Length(3)); }
399
400 constraints.push(Constraint::Min(10)); constraints.push(Constraint::Length(3)); let chunks = Layout::default()
404 .direction(Direction::Vertical)
405 .constraints(constraints)
406 .split(f.area());
407
408 let mut idx = 0;
409 draw_header(f, chunks[idx], app);
410 idx += 1;
411
412 draw_stats(f, chunks[idx], app);
413 idx += 1;
414
415 if app.search_active || !app.search_query.is_empty() {
416 draw_search_bar(f, chunks[idx], app);
417 idx += 1;
418 }
419
420 match app.view_mode {
422 ViewMode::TaskList => draw_tasks(f, chunks[idx], app),
423 ViewMode::DependencyGraph => draw_dependency_graph(f, chunks[idx], app),
424 }
425 idx += 1;
426
427 draw_footer(f, chunks[idx], app);
428}
429
430fn draw_header(f: &mut Frame, area: Rect, _app: &TuiApp) {
432 let title = vec![Line::from(vec![
433 Span::styled(
434 "async-inspect",
435 Style::default()
436 .fg(Color::Cyan)
437 .add_modifier(Modifier::BOLD),
438 ),
439 Span::raw(" - Real-time Async Task Monitor"),
440 ])];
441
442 let header = Paragraph::new(title)
443 .block(Block::default().borders(Borders::ALL).title("Dashboard"))
444 .style(Style::default());
445
446 f.render_widget(header, area);
447}
448
449fn draw_stats(f: &mut Frame, area: Rect, app: &TuiApp) {
451 let stats = app.inspector.stats();
452
453 let stats_text = vec![
454 Line::from(vec![
455 Span::styled("Total: ", Style::default().fg(Color::Gray)),
456 Span::styled(
457 format!("{}", stats.total_tasks),
458 Style::default()
459 .fg(Color::White)
460 .add_modifier(Modifier::BOLD),
461 ),
462 Span::raw(" "),
463 Span::styled("Running: ", Style::default().fg(Color::Blue)),
464 Span::styled(
465 format!("{}", stats.running_tasks),
466 Style::default()
467 .fg(Color::Blue)
468 .add_modifier(Modifier::BOLD),
469 ),
470 Span::raw(" "),
471 Span::styled("Blocked: ", Style::default().fg(Color::Yellow)),
472 Span::styled(
473 format!("{}", stats.blocked_tasks),
474 Style::default()
475 .fg(Color::Yellow)
476 .add_modifier(Modifier::BOLD),
477 ),
478 ]),
479 Line::from(vec![
480 Span::styled("Completed: ", Style::default().fg(Color::Green)),
481 Span::styled(
482 format!("{}", stats.completed_tasks),
483 Style::default()
484 .fg(Color::Green)
485 .add_modifier(Modifier::BOLD),
486 ),
487 Span::raw(" "),
488 Span::styled("Failed: ", Style::default().fg(Color::Red)),
489 Span::styled(
490 format!("{}", stats.failed_tasks),
491 Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
492 ),
493 Span::raw(" "),
494 Span::styled("Events: ", Style::default().fg(Color::Gray)),
495 Span::styled(
496 format!("{}", stats.total_events),
497 Style::default().fg(Color::White),
498 ),
499 ]),
500 Line::from(vec![
501 Span::styled("Duration: ", Style::default().fg(Color::Gray)),
502 Span::styled(
503 format!("{:.2}s", stats.timeline_duration.as_secs_f64()),
504 Style::default().fg(Color::Cyan),
505 ),
506 ]),
507 ];
508
509 let stats_widget = Paragraph::new(stats_text)
510 .block(Block::default().borders(Borders::ALL).title("Statistics"))
511 .style(Style::default());
512
513 f.render_widget(stats_widget, area);
514}
515
516fn draw_tasks(f: &mut Frame, area: Rect, app: &TuiApp) {
518 let tasks = app.get_tasks();
519
520 let rows: Vec<Row> = tasks
521 .iter()
522 .enumerate()
523 .map(|(i, task)| {
524 let state_color = match task.state {
525 TaskState::Pending => Color::Gray,
526 TaskState::Running => Color::Blue,
527 TaskState::Blocked { .. } => Color::Yellow,
528 TaskState::Completed => Color::Green,
529 TaskState::Failed => Color::Red,
530 };
531
532 let state_str = match &task.state {
533 TaskState::Pending => "PENDING",
534 TaskState::Running => "RUNNING",
535 TaskState::Blocked { .. } => "BLOCKED",
536 TaskState::Completed => "DONE",
537 TaskState::Failed => "FAILED",
538 };
539
540 let style = if i == app.selected {
541 Style::default().bg(Color::DarkGray).fg(Color::White)
542 } else {
543 Style::default()
544 };
545
546 Row::new(vec![
547 format!("#{}", task.id.as_u64()),
548 format!("{:.20}", task.name),
549 state_str.to_string(),
550 format!("{:.2}ms", task.age().as_secs_f64() * 1000.0),
551 format!("{}", task.poll_count),
552 format!("{:.2}ms", task.total_run_time.as_secs_f64() * 1000.0),
553 ])
554 .style(style)
555 .fg(state_color)
556 })
557 .collect();
558
559 let title = format!(
560 "Tasks (Sort: {:?} | Filter: {:?}) - {} shown",
561 app.sort_mode,
562 app.filter_mode,
563 tasks.len()
564 );
565
566 let table = Table::new(
567 rows,
568 [
569 Constraint::Length(8), Constraint::Min(20), Constraint::Length(10), Constraint::Length(12), Constraint::Length(8), Constraint::Length(12), ],
576 )
577 .header(
578 Row::new(vec!["ID", "Name", "State", "Duration", "Polls", "Run Time"])
579 .style(
580 Style::default()
581 .fg(Color::Yellow)
582 .add_modifier(Modifier::BOLD),
583 )
584 .bottom_margin(1),
585 )
586 .block(Block::default().borders(Borders::ALL).title(title))
587 .row_highlight_style(Style::default().bg(Color::DarkGray));
588
589 f.render_widget(table, area);
590}
591
592fn draw_search_bar(f: &mut Frame, area: Rect, app: &TuiApp) {
594 let search_text = if app.search_active {
595 format!("Search: {}█", app.search_query)
596 } else {
597 format!("Search: {} (Press / to edit, c to clear)", app.search_query)
598 };
599
600 let search = Paragraph::new(search_text)
601 .block(
602 Block::default()
603 .borders(Borders::ALL)
604 .title("Search")
605 .border_style(if app.search_active {
606 Style::default().fg(Color::Green)
607 } else {
608 Style::default()
609 }),
610 )
611 .style(Style::default().fg(if app.search_active {
612 Color::Green
613 } else {
614 Color::White
615 }));
616
617 f.render_widget(search, area);
618}
619
620fn draw_dependency_graph(f: &mut Frame, area: Rect, app: &TuiApp) {
622 let tasks = app.get_tasks();
623
624 let mut tree_lines = Vec::new();
626 let mut root_tasks: Vec<_> = tasks.iter().filter(|t| t.parent.is_none()).collect();
627 root_tasks.sort_by_key(|t| t.id.as_u64());
628
629 for root in &root_tasks {
630 build_tree_lines(&tasks, root, 0, &mut tree_lines);
631 }
632
633 let rows: Vec<Row> = tree_lines
634 .iter()
635 .enumerate()
636 .map(|(i, (indent, task))| {
637 let state_color = match task.state {
638 TaskState::Pending => Color::Gray,
639 TaskState::Running => Color::Blue,
640 TaskState::Blocked { .. } => Color::Yellow,
641 TaskState::Completed => Color::Green,
642 TaskState::Failed => Color::Red,
643 };
644
645 let state_str = match &task.state {
646 TaskState::Pending => "PENDING",
647 TaskState::Running => "RUNNING",
648 TaskState::Blocked { .. } => "BLOCKED",
649 TaskState::Completed => "DONE",
650 TaskState::Failed => "FAILED",
651 };
652
653 let tree_prefix = " ".repeat(*indent);
654 let tree_symbol = if *indent > 0 { "└─ " } else { "" };
655
656 let style = if i == app.selected {
657 Style::default().bg(Color::DarkGray).fg(Color::White)
658 } else {
659 Style::default()
660 };
661
662 Row::new(vec![
663 format!("#{}", task.id.as_u64()),
664 format!("{}{}{}", tree_prefix, tree_symbol, task.name),
665 state_str.to_string(),
666 format!("{:.2}ms", task.age().as_secs_f64() * 1000.0),
667 ])
668 .style(style)
669 .fg(state_color)
670 })
671 .collect();
672
673 let title = format!("Dependency Graph - {} tasks", tasks.len());
674
675 let table = Table::new(
676 rows,
677 [
678 Constraint::Length(8), Constraint::Min(30), Constraint::Length(10), Constraint::Length(12), ],
683 )
684 .header(
685 Row::new(vec!["ID", "Task Tree", "State", "Duration"])
686 .style(
687 Style::default()
688 .fg(Color::Yellow)
689 .add_modifier(Modifier::BOLD),
690 )
691 .bottom_margin(1),
692 )
693 .block(Block::default().borders(Borders::ALL).title(title))
694 .row_highlight_style(Style::default().bg(Color::DarkGray));
695
696 f.render_widget(table, area);
697}
698
699fn build_tree_lines<'a>(
701 all_tasks: &'a [TaskInfo],
702 task: &'a TaskInfo,
703 indent: usize,
704 lines: &mut Vec<(usize, &'a TaskInfo)>,
705) {
706 lines.push((indent, task));
707
708 let mut children: Vec<_> = all_tasks
710 .iter()
711 .filter(|t| t.parent.is_some_and(|p| p == task.id))
712 .collect();
713 children.sort_by_key(|t| t.id.as_u64());
714
715 for child in children {
716 build_tree_lines(all_tasks, child, indent + 1, lines);
717 }
718}
719
720fn draw_footer(f: &mut Frame, area: Rect, app: &TuiApp) {
722 let view_mode_str = match app.view_mode {
723 ViewMode::TaskList => "List",
724 ViewMode::DependencyGraph => "Graph",
725 };
726
727 let help_text = vec![Line::from(vec![
728 Span::styled("[q]", Style::default().fg(Color::Yellow)),
729 Span::raw(" Quit "),
730 Span::styled("[v]", Style::default().fg(Color::Yellow)),
731 Span::raw(format!(" View:{view_mode_str} ")),
732 Span::styled("[/]", Style::default().fg(Color::Yellow)),
733 Span::raw(" Search "),
734 Span::styled("[e]", Style::default().fg(Color::Yellow)),
735 Span::raw(" Export "),
736 Span::styled("[h/?]", Style::default().fg(Color::Yellow)),
737 Span::raw(" Help"),
738 ])];
739
740 let footer = Paragraph::new(help_text)
741 .block(Block::default().borders(Borders::ALL))
742 .style(Style::default());
743
744 f.render_widget(footer, area);
745}
746
747fn draw_help(f: &mut Frame) {
749 let help_text = vec![
750 Line::from(""),
751 Line::from(Span::styled(
752 " Keyboard Shortcuts",
753 Style::default()
754 .fg(Color::Cyan)
755 .add_modifier(Modifier::BOLD),
756 )),
757 Line::from(""),
758 Line::from(Span::styled(
759 " Navigation & Control:",
760 Style::default()
761 .fg(Color::Green)
762 .add_modifier(Modifier::BOLD),
763 )),
764 Line::from(vec![
765 Span::styled(" q", Style::default().fg(Color::Yellow)),
766 Span::raw(" Quit the application"),
767 ]),
768 Line::from(vec![
769 Span::styled(" h or ?", Style::default().fg(Color::Yellow)),
770 Span::raw(" Toggle this help screen"),
771 ]),
772 Line::from(vec![
773 Span::styled(" ↑/↓", Style::default().fg(Color::Yellow)),
774 Span::raw(" Navigate task list (or use mouse scroll)"),
775 ]),
776 Line::from(vec![
777 Span::styled(" r", Style::default().fg(Color::Yellow)),
778 Span::raw(" Reset selection to top"),
779 ]),
780 Line::from(vec![
781 Span::styled(" Mouse", Style::default().fg(Color::Yellow)),
782 Span::raw(" Scroll wheel to navigate tasks"),
783 ]),
784 Line::from(""),
785 Line::from(Span::styled(
786 " View & Display:",
787 Style::default()
788 .fg(Color::Green)
789 .add_modifier(Modifier::BOLD),
790 )),
791 Line::from(vec![
792 Span::styled(" v", Style::default().fg(Color::Yellow)),
793 Span::raw(" Toggle view mode (List ↔ Dependency Graph)"),
794 ]),
795 Line::from(vec![
796 Span::styled(" s", Style::default().fg(Color::Yellow)),
797 Span::raw(" Cycle sort mode (ID → Name → Duration → State → Polls)"),
798 ]),
799 Line::from(vec![
800 Span::styled(" f", Style::default().fg(Color::Yellow)),
801 Span::raw(
802 " Cycle filter mode (All → Running → Completed → Failed → Blocked)",
803 ),
804 ]),
805 Line::from(""),
806 Line::from(Span::styled(
807 " Search & Export:",
808 Style::default()
809 .fg(Color::Green)
810 .add_modifier(Modifier::BOLD),
811 )),
812 Line::from(vec![
813 Span::styled(" /", Style::default().fg(Color::Yellow)),
814 Span::raw(" Activate search mode (type to filter tasks)"),
815 ]),
816 Line::from(vec![
817 Span::styled(" c", Style::default().fg(Color::Yellow)),
818 Span::raw(" Clear search query"),
819 ]),
820 Line::from(vec![
821 Span::styled(" ESC", Style::default().fg(Color::Yellow)),
822 Span::raw(" Exit search mode (while searching)"),
823 ]),
824 Line::from(vec![
825 Span::styled(" e", Style::default().fg(Color::Yellow)),
826 Span::raw(" Export data (JSON, CSV, Chrome Trace to tui_exports/)"),
827 ]),
828 Line::from(""),
829 Line::from(Span::styled(
830 " View Modes:",
831 Style::default()
832 .fg(Color::Magenta)
833 .add_modifier(Modifier::BOLD),
834 )),
835 Line::from(" • Task List: Standard sortable task list"),
836 Line::from(" • Dependency Graph: Hierarchical tree showing parent-child relationships"),
837 Line::from(""),
838 Line::from(Span::styled(
839 " Press h or ? to return",
840 Style::default().fg(Color::Yellow),
841 )),
842 ];
843
844 let help = Paragraph::new(help_text)
845 .block(
846 Block::default()
847 .borders(Borders::ALL)
848 .title("Help")
849 .border_style(Style::default().fg(Color::Cyan)),
850 )
851 .style(Style::default());
852
853 let area = centered_rect(60, 80, f.area());
855 f.render_widget(help, area);
856}
857
858fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
860 let popup_layout = Layout::default()
861 .direction(Direction::Vertical)
862 .constraints([
863 Constraint::Percentage((100 - percent_y) / 2),
864 Constraint::Percentage(percent_y),
865 Constraint::Percentage((100 - percent_y) / 2),
866 ])
867 .split(r);
868
869 Layout::default()
870 .direction(Direction::Horizontal)
871 .constraints([
872 Constraint::Percentage((100 - percent_x) / 2),
873 Constraint::Percentage(percent_x),
874 Constraint::Percentage((100 - percent_x) / 2),
875 ])
876 .split(popup_layout[1])[1]
877}