carch-core 5.3.4

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

use crossterm::event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode};
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 popup_layout = Layout::default()
        .direction(Direction::Vertical)
        .constraints([
            Constraint::Percentage((100 - percent_y) / 2),
            Constraint::Percentage(percent_y),
            Constraint::Percentage((100 - percent_y) / 2),
        ])
        .split(r);

    Layout::default()
        .direction(Direction::Horizontal)
        .constraints([
            Constraint::Percentage((100 - percent_x) / 2),
            Constraint::Percentage(percent_x),
            Constraint::Percentage((100 - percent_x) / 2),
        ])
        .split(popup_layout[1])[1]
}

fn render_normal_ui(f: &mut Frame, app: &mut App, _options: &UiOptions) {
    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, options: &UiOptions) {
    render_normal_ui(f, app, options);

    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(83, 80, 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_width = std::cmp::min(80, area.width.saturating_sub(4));
            let popup_height = std::cmp::min(20, area.height.saturating_sub(4));

            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);
            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(83, 80, area);
            popups::preview::render_preview_popup(f, app, popup_area);
        }
        AppMode::Description => {
            let area = app.script_panel_area;
            let popup_area = centered_rect(80, 80, area);
            popups::description::render_description_popup(f, &mut *app, popup_area);
        }
        AppMode::Normal => {
            // no pop-up
        }
        AppMode::RootWarning => {
            let area = app.script_panel_area;
            let popup_area = centered_rect(80, 50, area);
            popups::root_warning::render_root_warning_popup(f, app, popup_area);
        }
    }
}

fn setup_terminal() -> Result<Terminal<CrosstermBackend<Stdout>>> {
    enable_raw_mode()?;
    let mut stdout = io::stdout();
    execute!(stdout, EnterAlternateScreen, EnableMouseCapture, 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, DisableMouseCapture)?;
    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()?;
    let mut app = App::new(&options);
    app.log_mode = options.log_mode;
    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(|v| v.len()).sum::<usize>(),
            app.categories.items.len()
        );
    }

    while !app.quit {
        terminal.autoresize()?;
        terminal.draw(|f| ui(f, &mut app, &options))?;

        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()
        {
            handle_event(&mut app, event, &options)?;
        }
    }

    cleanup_terminal(&mut terminal)?;

    if options.log_mode {
        info!("UI terminated normally");
    }

    Ok(())
}

fn handle_event(app: &mut App, event: Event, options: &UiOptions) -> Result<()> {
    match event {
        Event::Key(key) => {
            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() {
                                let next_popup = RunScriptPopup::new(
                                    script_path,
                                    app.log_mode,
                                    app.theme.clone(),
                                );
                                app.run_script_popup = Some(next_popup);
                            } 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::RootWarning => app.handle_key_root_warning_mode(key),
                    AppMode::RunScript => {
                        // already handled above
                    }
                }
            }
        }
        Event::Mouse(mouse_event) => {
            if options.log_mode {
                debug!("Mouse event: {mouse_event:?}");
            }
            app.handle_mouse(mouse_event);
        }
        _ => {}
    }
    Ok(())
}