Skip to main content

carch_core/ui/
render.rs

1use log::{debug, info};
2use ratatui::prelude::*;
3use std::io::{self, Stdout};
4use std::path::Path;
5use std::time::Duration;
6
7use crossterm::event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode};
8use crossterm::execute;
9use crossterm::terminal::{
10    Clear, ClearType, EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
11};
12use ratatui::backend::CrosstermBackend;
13use ratatui::layout::{Constraint, Direction, Layout};
14use ratatui::{Frame, Terminal};
15
16use super::popups::run_script::RunScriptPopup;
17use super::state::{App, AppMode, UiOptions};
18use super::widgets::category_list::render_category_list;
19use super::widgets::header::render_header;
20use super::widgets::script_list::render_script_list;
21use super::widgets::status_bar::render_status_bar;
22use crate::error::Result;
23use crate::ui::popups;
24
25fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
26    let popup_layout = Layout::default()
27        .direction(Direction::Vertical)
28        .constraints([
29            Constraint::Percentage((100 - percent_y) / 2),
30            Constraint::Percentage(percent_y),
31            Constraint::Percentage((100 - percent_y) / 2),
32        ])
33        .split(r);
34
35    Layout::default()
36        .direction(Direction::Horizontal)
37        .constraints([
38            Constraint::Percentage((100 - percent_x) / 2),
39            Constraint::Percentage(percent_x),
40            Constraint::Percentage((100 - percent_x) / 2),
41        ])
42        .split(popup_layout[1])[1]
43}
44
45fn render_normal_ui(f: &mut Frame, app: &mut App, _options: &UiOptions) {
46    let area = Layout::default()
47        .direction(Direction::Vertical)
48        .margin(1)
49        .constraints([Constraint::Min(0)])
50        .split(f.area())[0];
51
52    let chunks = Layout::default()
53        .direction(Direction::Vertical)
54        .constraints([Constraint::Length(3), Constraint::Min(0), Constraint::Length(1)])
55        .split(area);
56
57    render_header(f, app, chunks[0]);
58
59    let main_chunks = Layout::default()
60        .direction(Direction::Horizontal)
61        .constraints([Constraint::Percentage(20), Constraint::Percentage(80)])
62        .split(chunks[1]);
63
64    app.script_panel_area = main_chunks[1];
65
66    render_category_list(f, app, main_chunks[0]);
67    render_script_list(f, app, main_chunks[1]);
68
69    render_status_bar(f, app, chunks[2]);
70}
71
72fn ui(f: &mut Frame, app: &mut App, options: &UiOptions) {
73    render_normal_ui(f, app, options);
74
75    match app.mode {
76        AppMode::RunScript => {
77            if let Some(popup) = &mut app.run_script_popup {
78                let area = app.script_panel_area;
79                let popup_area = centered_rect(83, 80, area);
80                f.render_widget(popup, popup_area);
81            }
82        }
83        AppMode::Search => {
84            let area = app.script_panel_area;
85            let popup_width = std::cmp::min(70, area.width.saturating_sub(8));
86            let popup_height = std::cmp::min(16, area.height.saturating_sub(6));
87
88            let percent_x = (popup_width * 100).checked_div(area.width).unwrap_or(100);
89            let percent_y = (popup_height * 100).checked_div(area.height).unwrap_or(100);
90
91            let popup_area = centered_rect(percent_x, percent_y, area);
92            popups::search::render_search_popup(f, app, popup_area);
93        }
94        AppMode::Confirm => {
95            let area = app.script_panel_area;
96            let popup_width = std::cmp::min(60, area.width.saturating_sub(8));
97            let popup_height = if app.multi_select.enabled && !app.multi_select.scripts.is_empty() {
98                std::cmp::min(20, area.height.saturating_sub(6))
99            } else {
100                11
101            };
102
103            let percent_x = (popup_width * 100).checked_div(area.width).unwrap_or(100);
104            let percent_y = (popup_height * 100).checked_div(area.height).unwrap_or(100);
105
106            let popup_area = centered_rect(percent_x, percent_y, area);
107            popups::confirmation::render_confirmation_popup(f, app, popup_area);
108        }
109        AppMode::Help => {
110            let area = app.script_panel_area;
111            let popup_width = std::cmp::min(80, area.width.saturating_sub(4));
112            let popup_height = std::cmp::min(20, area.height.saturating_sub(4));
113
114            let percent_x = (popup_width * 100).checked_div(area.width).unwrap_or(100);
115            let percent_y = (popup_height * 100).checked_div(area.height).unwrap_or(100);
116
117            let popup_area = centered_rect(percent_x, percent_y, area);
118            let max_scroll = popups::help::render_help_popup(f, app, popup_area);
119            app.help.max_scroll = max_scroll;
120        }
121        AppMode::Preview => {
122            let area = app.script_panel_area;
123            let popup_area = centered_rect(83, 80, area);
124            popups::preview::render_preview_popup(f, app, popup_area);
125        }
126        AppMode::Description => {
127            let area = app.script_panel_area;
128            let popup_area = centered_rect(80, 80, area);
129            popups::description::render_description_popup(f, &mut *app, popup_area);
130        }
131        AppMode::Normal => {
132            // no pop-up
133        }
134        AppMode::RootWarning => {
135            let area = app.script_panel_area;
136            let popup_area = centered_rect(80, 50, area);
137            popups::root_warning::render_root_warning_popup(f, app, popup_area);
138        }
139    }
140}
141
142fn setup_terminal() -> Result<Terminal<CrosstermBackend<Stdout>>> {
143    enable_raw_mode()?;
144    let mut stdout = io::stdout();
145    execute!(stdout, EnterAlternateScreen, EnableMouseCapture, Clear(ClearType::All))?;
146    let backend = CrosstermBackend::new(stdout);
147    Terminal::new(backend).map_err(Into::into)
148}
149
150fn cleanup_terminal(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()> {
151    disable_raw_mode()?;
152    execute!(terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture)?;
153    terminal.show_cursor()?;
154    Ok(())
155}
156
157pub fn run_ui_with_options(modules_dir: &Path, options: UiOptions) -> Result<()> {
158    if options.log_mode {
159        info!("UI initialization started");
160    }
161
162    let mut terminal = setup_terminal()?;
163    let mut app = App::new(&options);
164    app.log_mode = options.log_mode;
165    app.modules_dir = modules_dir.to_path_buf();
166
167    if options.log_mode {
168        info!("Loading scripts from modules directory");
169    }
170
171    app.load_scripts(modules_dir)?;
172
173    if options.log_mode {
174        info!(
175            "Loaded {} scripts in {} categories",
176            app.all_scripts.values().map(|v| v.len()).sum::<usize>(),
177            app.categories.items.len()
178        );
179    }
180
181    while !app.quit {
182        terminal.autoresize()?;
183        terminal.draw(|f| ui(f, &mut app, &options))?;
184
185        let poll_duration = if app.mode == AppMode::RunScript {
186            Duration::from_millis(16)
187        } else {
188            Duration::from_millis(100)
189        };
190
191        if event::poll(poll_duration)?
192            && let Ok(event) = event::read()
193        {
194            handle_event(&mut app, event, &options)?;
195        }
196    }
197
198    cleanup_terminal(&mut terminal)?;
199
200    if options.log_mode {
201        info!("UI terminated normally");
202    }
203
204    Ok(())
205}
206
207fn handle_event(app: &mut App, event: Event, options: &UiOptions) -> Result<()> {
208    match event {
209        Event::Key(key) => {
210            if options.log_mode {
211                let key_name = match key.code {
212                    KeyCode::Char(c) => format!("Char('{c}')"),
213                    _ => format!("{:?}", key.code),
214                };
215                debug!("Key pressed: {} in mode: {:?}", key_name, app.mode);
216            }
217
218            if app.mode == AppMode::RunScript {
219                if let Some(popup) = &mut app.run_script_popup {
220                    match popup.handle_key_event(key) {
221                        crate::ui::popups::run_script::PopupEvent::Close => {
222                            app.run_script_popup = None;
223                            if let Some(script_path) = app.script_execution_queue.pop() {
224                                let next_popup = RunScriptPopup::new(
225                                    script_path,
226                                    app.log_mode,
227                                    app.theme.clone(),
228                                );
229                                app.run_script_popup = Some(next_popup);
230                            } else {
231                                app.mode = AppMode::Normal;
232                            }
233                        }
234                        crate::ui::popups::run_script::PopupEvent::None => {}
235                    }
236                }
237            } else {
238                match app.mode {
239                    AppMode::Normal => app.handle_key_normal_mode(key),
240                    AppMode::Preview => app.handle_key_preview_mode(key),
241                    AppMode::Search => app.handle_search_input(key),
242                    AppMode::Confirm => app.handle_key_confirmation_mode(key),
243                    AppMode::Help => app.handle_key_help_mode(key),
244                    AppMode::Description => app.handle_key_description_mode(key),
245                    AppMode::RootWarning => app.handle_key_root_warning_mode(key),
246                    AppMode::RunScript => {
247                        // already handled above
248                    }
249                }
250            }
251        }
252        Event::Mouse(mouse_event) => {
253            if options.log_mode {
254                debug!("Mouse event: {mouse_event:?}");
255            }
256            app.handle_mouse(mouse_event);
257        }
258        _ => {}
259    }
260    Ok(())
261}