carch-core 6.1.1

Core library for carch, providing script management and UI components.
Documentation
use std::collections::{HashMap, VecDeque};
use std::path::PathBuf;

use fuzzy_matcher::skim::SkimMatcherV2;
use ratatui::layout::Rect;
use ratatui::text::Text;

use super::popups::run_script::RunScriptPopup;
use super::theme::Theme;

#[derive(PartialEq, Eq, Debug, Clone, Copy)]
pub enum AppMode {
    Normal,
    Search,
    Confirm,
    Help,
    Preview,
    RunScript,
    Description,
    ThemeSelector,
    RootWarning,
    TermuxWarning,
}

#[derive(PartialEq, Eq, Debug, Clone, Copy)]
pub enum FocusedPanel {
    Categories,
    Scripts,
}

pub struct StatefulList<T> {
    pub state: ratatui::widgets::ListState,
    pub items: Vec<T>,
}

impl<T> Default for StatefulList<T> {
    fn default() -> Self {
        Self::new()
    }
}

impl<T> StatefulList<T> {
    pub fn new() -> StatefulList<T> {
        StatefulList { state: ratatui::widgets::ListState::default(), items: Vec::new() }
    }

    pub fn with_items(items: Vec<T>) -> StatefulList<T> {
        let mut list = StatefulList::new();
        list.items = items;
        if !list.items.is_empty() {
            list.state.select(Some(0));
        }
        list
    }

    pub fn next(&mut self) {
        if self.items.is_empty() {
            self.state.select(None);
            return;
        }
        let i = match self.state.selected() {
            Some(i) if i + 1 < self.items.len() => i + 1,
            _ => 0,
        };
        self.state.select(Some(i));
    }

    pub fn previous(&mut self) {
        if self.items.is_empty() {
            self.state.select(None);
            return;
        }
        let last = self.items.len() - 1;
        let i = match self.state.selected() {
            Some(0) | None => last,
            Some(i) => i - 1,
        };
        self.state.select(Some(i));
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn next_on_empty_does_not_panic() {
        let mut list: StatefulList<u32> = StatefulList::new();
        list.next();
        list.next();
        list.next();
        assert_eq!(list.state.selected(), None);
    }

    #[test]
    fn previous_on_empty_does_not_panic() {
        let mut list: StatefulList<u32> = StatefulList::new();
        list.previous();
        list.previous();
        assert_eq!(list.state.selected(), None);
    }

    #[test]
    fn next_wraps_to_start() {
        let mut list = StatefulList::with_items(vec!["a", "b", "c"]);
        assert_eq!(list.state.selected(), Some(0));
        list.next();
        assert_eq!(list.state.selected(), Some(1));
        list.next();
        assert_eq!(list.state.selected(), Some(2));
        list.next();
        assert_eq!(list.state.selected(), Some(0));
    }

    #[test]
    fn previous_wraps_to_end() {
        let mut list = StatefulList::with_items(vec!["a", "b", "c"]);
        list.previous();
        assert_eq!(list.state.selected(), Some(2));
        list.previous();
        assert_eq!(list.state.selected(), Some(1));
    }

    #[test]
    fn single_item_next_keeps_selection() {
        let mut list = StatefulList::with_items(vec!["only"]);
        list.next();
        assert_eq!(list.state.selected(), Some(0));
        list.previous();
        assert_eq!(list.state.selected(), Some(0));
    }
}

#[derive(Clone, Debug)]
pub struct ScriptItem {
    pub category: String,
    pub name:     String,
    pub path:     PathBuf,
}

#[derive(Default, Clone)]
pub struct UiOptions {
    pub log_mode:     bool,
    pub theme:        String,
    pub theme_locked: bool,
    pub is_root:      bool,
    pub log_path:     Option<String>,
}

#[derive(Default)]
pub struct PreviewState {
    pub content:    String,
    pub scroll:     u16,
    pub max_scroll: u16,
    pub cache:      HashMap<PathBuf, Text<'static>>,
}

#[derive(Clone, Debug)]
pub struct SearchResult {
    pub item:    ScriptItem,
    pub score:   i64,
    pub indices: Vec<usize>,
}

#[derive(Default)]
pub struct SearchState {
    pub input:           String,
    pub results:         Vec<SearchResult>,
    pub cursor_position: usize,
    pub selected_idx:    usize,
    pub autocomplete:    Option<String>,
    pub matcher:         SkimMatcherV2,
}

#[derive(Default)]
pub struct MultiSelectState {
    pub enabled: bool,
    pub scripts: Vec<PathBuf>,
}

#[derive(Default)]
pub struct HelpState {
    pub scroll:     u16,
    pub max_scroll: u16,
}

#[derive(Default)]
pub struct DescriptionState {
    pub content:    Option<String>,
    pub scroll:     u16,
    pub max_scroll: u16,
}

#[derive(Default)]
pub struct ThemeSelectorState {
    pub selected: usize,
}

pub struct App {
    pub mode:          AppMode,
    pub quit:          bool,
    pub focused_panel: FocusedPanel,
    pub log_mode:      bool,
    pub modules_dir:   PathBuf,
    pub theme:         Theme,
    pub theme_locked:  bool,
    pub distro:        String,

    pub scripts:     StatefulList<ScriptItem>,
    pub categories:  StatefulList<String>,
    pub all_scripts: HashMap<String, Vec<ScriptItem>>,

    pub script_panel_area:      Rect,
    pub preview:                PreviewState,
    pub search:                 SearchState,
    pub multi_select:           MultiSelectState,
    pub help:                   HelpState,
    pub description:            DescriptionState,
    pub run_script_popup:       Option<RunScriptPopup>,
    pub script_execution_queue: VecDeque<PathBuf>,
    pub theme_selector:         ThemeSelectorState,

    pub needs_redraw: bool,
    pub last_size:    Rect,
    pub log_path:     Option<String>,
}