rproj 0.13.1

Guided bootstrap-to-game-dev CLI for Roblox: takes a fresh Windows PC to a working Roblox/Luau setup, then scaffolds projects on it
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::Frame;
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Color, Style};
use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, Wrap};
use unicode_width::UnicodeWidthStr;

use super::{ACCENT, ERROR, MUTED, WARNING, centered};

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct InputState {
    text: String,
    cursor: usize,
    pub error: Option<String>,
}

impl InputState {
    pub fn new(text: impl Into<String>) -> Self {
        let text = text.into();
        let cursor = text.chars().count();
        Self {
            text,
            cursor,
            error: None,
        }
    }

    pub fn text(&self) -> &str {
        &self.text
    }

    pub fn cursor(&self) -> usize {
        self.cursor
    }

    pub fn handle_key(&mut self, key: KeyEvent) -> bool {
        match key.code {
            KeyCode::Left => self.cursor = self.cursor.saturating_sub(1),
            KeyCode::Right => self.cursor = (self.cursor + 1).min(self.text.chars().count()),
            KeyCode::Home => self.cursor = 0,
            KeyCode::End => self.cursor = self.text.chars().count(),
            KeyCode::Backspace if self.cursor > 0 => {
                let start = byte_index(&self.text, self.cursor - 1);
                let end = byte_index(&self.text, self.cursor);
                self.text.replace_range(start..end, "");
                self.cursor -= 1;
                self.error = None;
            }
            KeyCode::Delete if self.cursor < self.text.chars().count() => {
                let start = byte_index(&self.text, self.cursor);
                let end = byte_index(&self.text, self.cursor + 1);
                self.text.replace_range(start..end, "");
                self.error = None;
            }
            KeyCode::Char(character) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
                let index = byte_index(&self.text, self.cursor);
                self.text.insert(index, character);
                self.cursor += 1;
                self.error = None;
            }
            _ => return false,
        }
        true
    }
}

fn byte_index(text: &str, char_index: usize) -> usize {
    text.char_indices()
        .nth(char_index)
        .map(|(index, _)| index)
        .unwrap_or(text.len())
}

#[derive(Clone, Debug)]
pub struct PickerItem<T> {
    pub label: String,
    pub detail: String,
    pub value: T,
}

#[derive(Clone, Debug)]
pub struct PickerState<T> {
    pub items: Vec<PickerItem<T>>,
    pub query: InputState,
    pub selected: usize,
}

impl<T> PickerState<T> {
    pub fn new(items: Vec<PickerItem<T>>) -> Self {
        Self {
            items,
            query: InputState::new(""),
            selected: 0,
        }
    }

    pub fn filtered(&self) -> Vec<&PickerItem<T>> {
        let query = self.query.text().to_lowercase();
        self.items
            .iter()
            .filter(|item| {
                query.is_empty()
                    || item.label.to_lowercase().contains(&query)
                    || item.detail.to_lowercase().contains(&query)
            })
            .collect()
    }

    pub fn selected_value(&self) -> Option<&T> {
        self.filtered().get(self.selected).map(|item| &item.value)
    }

    pub fn handle_key(&mut self, key: KeyEvent) -> bool {
        match key.code {
            KeyCode::Up => self.selected = self.selected.saturating_sub(1),
            KeyCode::Down => {
                self.selected = (self.selected + 1).min(self.filtered().len().saturating_sub(1));
            }
            _ if self.query.handle_key(key) => self.selected = 0,
            _ => return false,
        }
        true
    }
}

#[derive(Clone, Debug)]
pub struct ConfirmState {
    pub prompt: String,
}

impl ConfirmState {
    pub fn new(prompt: impl Into<String>) -> Self {
        Self {
            prompt: prompt.into(),
        }
    }
}

pub fn render_footer(frame: &mut Frame<'_>, area: Rect, status: &str, keys: &str, error: bool) {
    frame.render_widget(
        Paragraph::new(format!("{status}\n{keys}"))
            .wrap(Wrap { trim: true })
            .style(Style::default().fg(if error { ERROR } else { MUTED })),
        area,
    );
}

pub fn render_input(
    frame: &mut Frame<'_>,
    area: Rect,
    title: &str,
    hint: &str,
    state: &InputState,
) {
    let popup = centered(area, 72.min(area.width.saturating_sub(4)), 9);
    frame.render_widget(Clear, popup);
    let body = format!(
        "{hint}\n\n> {}\n{}",
        state.text(),
        state
            .error
            .as_deref()
            .unwrap_or("Enter confirms; Esc cancels")
    );
    frame.render_widget(
        Paragraph::new(body).wrap(Wrap { trim: false }).block(
            Block::default()
                .title(format!(" {title} "))
                .borders(Borders::ALL)
                .border_style(Style::default().fg(if state.error.is_some() {
                    ERROR
                } else {
                    ACCENT
                })),
        ),
        popup,
    );
    let prefix: String = state.text().chars().take(state.cursor()).collect();
    frame.set_cursor_position((
        (popup.x + 3 + UnicodeWidthStr::width(prefix.as_str()) as u16)
            .min(popup.right().saturating_sub(2)),
        popup.y + 3,
    ));
}

pub fn render_picker<T>(frame: &mut Frame<'_>, area: Rect, title: &str, state: &PickerState<T>) {
    let popup = centered(area, 72.min(area.width.saturating_sub(4)), 18);
    frame.render_widget(Clear, popup);
    frame.render_widget(
        Block::default()
            .title(format!(" {title} "))
            .borders(Borders::ALL)
            .border_style(Style::default().fg(ACCENT)),
        popup,
    );
    let inner = Layout::vertical([
        Constraint::Length(2),
        Constraint::Min(4),
        Constraint::Length(1),
    ])
    .split(popup);
    frame.render_widget(
        Paragraph::new(format!(" Search: {}", state.query.text())),
        inner[0],
    );
    let filtered = state.filtered();
    let start = state.selected.saturating_sub(11);
    let items: Vec<_> = filtered
        .iter()
        .skip(start)
        .take(12)
        .map(|item| ListItem::new(format!("{}  {}", item.label, item.detail)))
        .collect();
    let mut list_state = ListState::default().with_selected(if filtered.is_empty() {
        None
    } else {
        Some(state.selected.min(filtered.len() - 1).saturating_sub(start))
    });
    frame.render_stateful_widget(
        List::new(items).highlight_style(Style::default().fg(Color::Black).bg(ACCENT)),
        inner[1],
        &mut list_state,
    );
    frame.render_widget(
        Paragraph::new("Type to filter; Enter selects; Esc cancels"),
        inner[2],
    );
}

pub fn render_confirm(frame: &mut Frame<'_>, area: Rect, state: &ConfirmState) {
    let popup = centered(area, 72.min(area.width.saturating_sub(4)), 9);
    frame.render_widget(Clear, popup);
    frame.render_widget(
        Paragraph::new(format!(
            "{}\n\nEnter/Y confirm   Esc/N cancel",
            state.prompt
        ))
        .wrap(Wrap { trim: true })
        .block(
            Block::default()
                .title(" Confirm ")
                .borders(Borders::ALL)
                .border_style(Style::default().fg(WARNING)),
        ),
        popup,
    );
}

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

    #[test]
    fn input_edits_unicode_by_character() {
        let mut input = InputState::new("aé");
        input.handle_key(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE));
        input.handle_key(KeyEvent::new(KeyCode::Char('中'), KeyModifiers::NONE));
        assert_eq!(input.text(), "a中é");
        input.handle_key(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
        assert_eq!(input.text(), "aé");
    }

    #[test]
    fn picker_filters_labels_and_details_and_clamps_selection() {
        let mut picker = PickerState::new(vec![
            PickerItem {
                label: "Rojo".into(),
                detail: "tool".into(),
                value: 1,
            },
            PickerItem {
                label: "Asphalt".into(),
                detail: "assets".into(),
                value: 2,
            },
        ]);
        picker.handle_key(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE));
        picker.handle_key(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE));
        assert_eq!(picker.selected_value(), Some(&2));
        picker.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
        assert_eq!(picker.selected, 0);
    }
}