carch-core 6.1.0

Core library for carch, providing script management and UI components.
Documentation
use std::io::{self, Stdout};
use std::path::Path;
use std::time::Duration;

use log::{debug, info};
use ratatui::prelude::*;

use crossterm::event::{self, Event, KeyCode, KeyEventKind};
use crossterm::execute;
use crossterm::terminal::{
    Clear, ClearType, EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
};
use ratatui::backend::CrosstermBackend;
use ratatui::layout::{Constraint, Direction, Layout};
use ratatui::{Frame, Terminal};

use super::popups::run_script::RunScriptPopup;
use super::state::{App, AppMode, UiOptions};
use super::widgets::category_list::render_category_list;
use super::widgets::header::render_header;
use super::widgets::script_list::render_script_list;
use super::widgets::status_bar::render_status_bar;
use crate::error::Result;
use crate::ui::popups;

fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
    let mut popup_w = r.width * percent_x / 100;
    let mut popup_h = r.height * percent_y / 100;
    if !(r.width - popup_w).is_multiple_of(2) {
        popup_w += 1;
    }
    if !(r.height - popup_h).is_multiple_of(2) {
        popup_h += 1;
    }
    let offset_x = (r.width - popup_w) / 2;
    let offset_y = (r.height - popup_h) / 2;
    Rect { x: r.x + offset_x, y: r.y + offset_y, width: popup_w, height: popup_h }
}

fn render_normal_ui(f: &mut Frame, app: &mut App) {
    let area = Layout::default()
        .direction(Direction::Vertical)
        .margin(1)
        .constraints([Constraint::Min(0)])
        .split(f.area())[0];

    let chunks = Layout::default()
        .direction(Direction::Vertical)
        .constraints([Constraint::Length(3), Constraint::Min(0), Constraint::Length(1)])
        .split(area);

    render_header(f, app, chunks[0]);

    let main_chunks = Layout::default()
        .direction(Direction::Horizontal)
        .constraints([Constraint::Percentage(20), Constraint::Percentage(80)])
        .split(chunks[1]);

    app.script_panel_area = main_chunks[1];

    render_category_list(f, app, main_chunks[0]);
    render_script_list(f, app, main_chunks[1]);

    render_status_bar(f, app, chunks[2]);
}

fn ui(f: &mut Frame, app: &mut App) {
    render_normal_ui(f, app);

    match app.mode {
        AppMode::RunScript => {
            if let Some(popup) = &mut app.run_script_popup {
                let area = app.script_panel_area;
                let popup_area = centered_rect(98, 96, area);
                f.render_widget(popup, popup_area);
            }
        }
        AppMode::Search => {
            let area = app.script_panel_area;
            let popup_width = std::cmp::min(70, area.width.saturating_sub(8));
            let popup_height = std::cmp::min(16, area.height.saturating_sub(6));

            let percent_x = (popup_width * 100).checked_div(area.width).unwrap_or(100);
            let percent_y = (popup_height * 100).checked_div(area.height).unwrap_or(100);

            let popup_area = centered_rect(percent_x, percent_y, area);
            popups::search::render_search_popup(f, app, popup_area);
        }
        AppMode::Confirm => {
            let area = app.script_panel_area;
            let popup_width = std::cmp::min(60, area.width.saturating_sub(8));
            let popup_height = if app.multi_select.enabled && !app.multi_select.scripts.is_empty() {
                std::cmp::min(20, area.height.saturating_sub(6))
            } else {
                11
            };

            let percent_x = (popup_width * 100).checked_div(area.width).unwrap_or(100);
            let percent_y = (popup_height * 100).checked_div(area.height).unwrap_or(100);

            let popup_area = centered_rect(percent_x, percent_y, area);
            popups::confirmation::render_confirmation_popup(f, app, popup_area);
        }
        AppMode::Help => {
            let area = app.script_panel_area;
            let popup_area = centered_rect(98, 96, area);
            let max_scroll = popups::help::render_help_popup(f, app, popup_area);
            app.help.max_scroll = max_scroll;
        }
        AppMode::Preview => {
            let area = app.script_panel_area;
            let popup_area = centered_rect(98, 96, area);
            popups::preview::render_preview_popup(f, app, popup_area);
        }
        AppMode::Description => {
            let area = app.script_panel_area;
            let popup_area = centered_rect(98, 96, area);
            popups::description::render_description_popup(f, app, popup_area);
        }
        AppMode::ThemeSelector => {
            let area = app.script_panel_area;
            let popup_area = centered_rect(50, 40, area);
            popups::theme_selector::render_theme_selector_popup(f, app, popup_area);
        }
        AppMode::Normal => {}
        AppMode::RootWarning => {
            let area = app.script_panel_area;
            let popup_area = centered_rect(98, 96, area);
            popups::root_warning::render_root_warning_popup(f, app, popup_area);
        }
        AppMode::TermuxWarning => {
            let area = app.script_panel_area;
            let popup_area = centered_rect(98, 96, area);
            popups::termux_warning::render_termux_warning_popup(f, app, popup_area);
        }
    }
}

fn setup_terminal() -> Result<Terminal<CrosstermBackend<Stdout>>> {
    enable_raw_mode()?;
    let mut stdout = io::stdout();
    execute!(stdout, EnterAlternateScreen, Clear(ClearType::All))?;
    let backend = CrosstermBackend::new(stdout);
    Terminal::new(backend).map_err(Into::into)
}

fn cleanup_terminal(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()> {
    disable_raw_mode()?;
    execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
    terminal.show_cursor()?;
    Ok(())
}

pub fn run_ui_with_options(modules_dir: &Path, options: &UiOptions) -> Result<()> {
    if options.log_mode {
        info!("UI initialization started");
    }

    let mut terminal = setup_terminal()?;
    install_panic_hook();

    let result = run_ui_loop(modules_dir, options, &mut terminal);

    cleanup_terminal(&mut terminal)?;

    if options.log_mode {
        match &result {
            Ok(()) => info!("UI terminated normally"),
            Err(e) => log::error!("UI terminated with error: {e}"),
        }
    }

    result
}

fn run_ui_loop(
    modules_dir: &Path,
    options: &UiOptions,
    terminal: &mut Terminal<CrosstermBackend<Stdout>>,
) -> Result<()> {
    let mut app = App::new(options);
    app.modules_dir = modules_dir.to_path_buf();

    if options.log_mode {
        info!("Loading scripts from modules directory");
    }

    app.load_scripts(modules_dir)?;

    if options.log_mode {
        info!(
            "Loaded {} scripts in {} categories",
            app.all_scripts.values().map(Vec::len).sum::<usize>(),
            app.categories.items.len()
        );
    }

    while !app.quit {
        let popup_has_new_data =
            app.run_script_popup.as_mut().is_some_and(RunScriptPopup::has_new_data);

        if app.needs_redraw || popup_has_new_data {
            if app.last_size == Rect::default() {
                terminal.autoresize()?;
            }

            terminal.draw(|f| ui(f, &mut app))?;
            app.last_size = terminal.get_frame().area();
            app.needs_redraw = false;

            if let Some(popup) = app.run_script_popup.as_mut() {
                popup.acknowledge_data();
            }
        }

        let poll_duration = if app.mode == AppMode::RunScript {
            Duration::from_millis(16)
        } else {
            Duration::from_millis(100)
        };

        if event::poll(poll_duration)?
            && let Ok(event) = event::read()
        {
            app.needs_redraw = true;
            handle_event(&mut app, event, options)?;
        }
    }

    Ok(())
}

fn install_panic_hook() {
    use std::sync::Once;
    static ONCE: Once = Once::new();
    ONCE.call_once(|| {
        let original = std::panic::take_hook();
        std::panic::set_hook(Box::new(move |info| {
            let _ = disable_raw_mode();
            let _ = execute!(io::stdout(), LeaveAlternateScreen);
            original(info);
        }));
    });
}

fn handle_event(app: &mut App, event: Event, options: &UiOptions) -> Result<()> {
    match event {
        Event::Key(key) => {
            if matches!(key.kind, KeyEventKind::Release | KeyEventKind::Repeat) {
                return Ok(());
            }

            if options.log_mode {
                let key_name = match key.code {
                    KeyCode::Char(c) => format!("Char('{c}')"),
                    _ => format!("{:?}", key.code),
                };
                debug!("Key pressed: {} in mode: {:?}", key_name, app.mode);
            }

            if app.mode == AppMode::RunScript {
                if let Some(popup) = &mut app.run_script_popup {
                    match popup.handle_key_event(key) {
                        crate::ui::popups::run_script::PopupEvent::Close => {
                            app.run_script_popup = None;
                            if let Some(script_path) = app.script_execution_queue.pop_front() {
                                match RunScriptPopup::new(
                                    script_path,
                                    app.log_mode,
                                    app.theme.clone(),
                                    app.log_path.clone(),
                                ) {
                                    Ok(next_popup) => {
                                        app.run_script_popup = Some(next_popup);
                                    }
                                    Err(e) => {
                                        log::error!("Failed to start next script popup: {e}");
                                        app.run_script_popup = None;
                                        app.mode = AppMode::Normal;
                                    }
                                }
                            } else {
                                app.mode = AppMode::Normal;
                            }
                        }
                        crate::ui::popups::run_script::PopupEvent::None => {}
                    }
                }
            } else {
                match app.mode {
                    AppMode::Normal => app.handle_key_normal_mode(key),
                    AppMode::Preview => app.handle_key_preview_mode(key),
                    AppMode::Search => app.handle_search_input(key),
                    AppMode::Confirm => app.handle_key_confirmation_mode(key),
                    AppMode::Help => app.handle_key_help_mode(key),
                    AppMode::Description => app.handle_key_description_mode(key),
                    AppMode::ThemeSelector => app.handle_key_theme_selector_mode(key),
                    AppMode::RootWarning => app.handle_key_root_warning_mode(key),
                    AppMode::TermuxWarning => app.handle_key_termux_warning_mode(key),
                    AppMode::RunScript => {}
                }
            }
        }
        Event::Resize(_, _) => {
            app.needs_redraw = true;
        }
        _ => {}
    }
    Ok(())
}