Skip to main content

carch_core/ui/
render.rs

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