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::ThemeSelector => {
121            let area = app.script_panel_area;
122            let popup_area = centered_rect(50, 40, area);
123            popups::theme_selector::render_theme_selector_popup(f, app, popup_area);
124        }
125        AppMode::Normal => {}
126        AppMode::RootWarning => {
127            let area = app.script_panel_area;
128            let popup_area = centered_rect(98, 96, area);
129            popups::root_warning::render_root_warning_popup(f, app, popup_area);
130        }
131        AppMode::TermuxWarning => {
132            let area = app.script_panel_area;
133            let popup_area = centered_rect(98, 96, area);
134            popups::termux_warning::render_termux_warning_popup(f, app, popup_area);
135        }
136    }
137}
138
139fn setup_terminal() -> Result<Terminal<CrosstermBackend<Stdout>>> {
140    enable_raw_mode()?;
141    let mut stdout = io::stdout();
142    execute!(stdout, EnterAlternateScreen, Clear(ClearType::All))?;
143    let backend = CrosstermBackend::new(stdout);
144    Terminal::new(backend).map_err(Into::into)
145}
146
147fn cleanup_terminal(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()> {
148    disable_raw_mode()?;
149    execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
150    terminal.show_cursor()?;
151    Ok(())
152}
153
154pub fn run_ui_with_options(modules_dir: &Path, options: &UiOptions) -> Result<()> {
155    if options.log_mode {
156        info!("UI initialization started");
157    }
158
159    let mut terminal = setup_terminal()?;
160    install_panic_hook();
161
162    let result = run_ui_loop(modules_dir, options, &mut terminal);
163
164    cleanup_terminal(&mut terminal)?;
165
166    if options.log_mode {
167        match &result {
168            Ok(()) => info!("UI terminated normally"),
169            Err(e) => log::error!("UI terminated with error: {e}"),
170        }
171    }
172
173    result
174}
175
176fn run_ui_loop(
177    modules_dir: &Path,
178    options: &UiOptions,
179    terminal: &mut Terminal<CrosstermBackend<Stdout>>,
180) -> Result<()> {
181    let mut app = App::new(options);
182    app.modules_dir = modules_dir.to_path_buf();
183
184    if options.log_mode {
185        info!("Loading scripts from modules directory");
186    }
187
188    app.load_scripts(modules_dir)?;
189
190    if options.log_mode {
191        info!(
192            "Loaded {} scripts in {} categories",
193            app.all_scripts.values().map(Vec::len).sum::<usize>(),
194            app.categories.items.len()
195        );
196    }
197
198    while !app.quit {
199        let popup_has_new_data =
200            app.run_script_popup.as_mut().is_some_and(RunScriptPopup::has_new_data);
201
202        if app.needs_redraw || popup_has_new_data {
203            if app.last_size == Rect::default() {
204                terminal.autoresize()?;
205            }
206
207            terminal.draw(|f| ui(f, &mut app))?;
208            app.last_size = terminal.get_frame().area();
209            app.needs_redraw = false;
210
211            if let Some(popup) = app.run_script_popup.as_mut() {
212                popup.acknowledge_data();
213            }
214        }
215
216        let poll_duration = if app.mode == AppMode::RunScript {
217            Duration::from_millis(16)
218        } else {
219            Duration::from_millis(100)
220        };
221
222        if event::poll(poll_duration)?
223            && let Ok(event) = event::read()
224        {
225            app.needs_redraw = true;
226            handle_event(&mut app, event, options)?;
227        }
228    }
229
230    Ok(())
231}
232
233fn install_panic_hook() {
234    use std::sync::Once;
235    static ONCE: Once = Once::new();
236    ONCE.call_once(|| {
237        let original = std::panic::take_hook();
238        std::panic::set_hook(Box::new(move |info| {
239            let _ = disable_raw_mode();
240            let _ = execute!(io::stdout(), LeaveAlternateScreen);
241            original(info);
242        }));
243    });
244}
245
246fn handle_event(app: &mut App, event: Event, options: &UiOptions) -> Result<()> {
247    match event {
248        Event::Key(key) => {
249            if matches!(key.kind, KeyEventKind::Release | KeyEventKind::Repeat) {
250                return Ok(());
251            }
252
253            if options.log_mode {
254                let key_name = match key.code {
255                    KeyCode::Char(c) => format!("Char('{c}')"),
256                    _ => format!("{:?}", key.code),
257                };
258                debug!("Key pressed: {} in mode: {:?}", key_name, app.mode);
259            }
260
261            if app.mode == AppMode::RunScript {
262                if let Some(popup) = &mut app.run_script_popup {
263                    match popup.handle_key_event(key) {
264                        crate::ui::popups::run_script::PopupEvent::Close => {
265                            app.run_script_popup = None;
266                            if let Some(script_path) = app.script_execution_queue.pop_front() {
267                                match RunScriptPopup::new(
268                                    script_path,
269                                    app.log_mode,
270                                    app.theme.clone(),
271                                    app.log_path.clone(),
272                                ) {
273                                    Ok(next_popup) => {
274                                        app.run_script_popup = Some(next_popup);
275                                    }
276                                    Err(e) => {
277                                        log::error!("Failed to start next script popup: {e}");
278                                        app.run_script_popup = None;
279                                        app.mode = AppMode::Normal;
280                                    }
281                                }
282                            } else {
283                                app.mode = AppMode::Normal;
284                            }
285                        }
286                        crate::ui::popups::run_script::PopupEvent::None => {}
287                    }
288                }
289            } else {
290                match app.mode {
291                    AppMode::Normal => app.handle_key_normal_mode(key),
292                    AppMode::Preview => app.handle_key_preview_mode(key),
293                    AppMode::Search => app.handle_search_input(key),
294                    AppMode::Confirm => app.handle_key_confirmation_mode(key),
295                    AppMode::Help => app.handle_key_help_mode(key),
296                    AppMode::Description => app.handle_key_description_mode(key),
297                    AppMode::ThemeSelector => app.handle_key_theme_selector_mode(key),
298                    AppMode::RootWarning => app.handle_key_root_warning_mode(key),
299                    AppMode::TermuxWarning => app.handle_key_termux_warning_mode(key),
300                    AppMode::RunScript => {}
301                }
302            }
303        }
304        Event::Resize(_, _) => {
305            app.needs_redraw = true;
306        }
307        _ => {}
308    }
309    Ok(())
310}