clap-tui 0.1.0

Auto-generate a TUI from clap commands
Documentation
use std::collections::HashMap;

use tui_textarea::{CursorMove, Input, Key, TextArea};

use crate::runtime::{AppKeyCode, AppKeyEvent};
use crate::spec::CommandPath;

#[derive(Debug, Default)]
pub struct EditorState {
    editors: HashMap<String, HashMap<String, TextEditor>>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub(crate) struct TextPosition {
    pub(crate) row: usize,
    pub(crate) col: usize,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct TextEditor {
    lines: Vec<String>,
    cursor: TextPosition,
    selection_anchor: Option<TextPosition>,
}

impl Default for TextEditor {
    fn default() -> Self {
        Self::from_displayed("")
    }
}

impl EditorState {
    pub fn editor(&self, command_key: &CommandPath, arg_id: &str) -> Option<&TextEditor> {
        self.editors
            .get(&command_key.storage_key())
            .and_then(|editors| editors.get(arg_id))
    }

    pub fn ensure_editor_with<'a, F>(
        &'a mut self,
        command_key: &CommandPath,
        arg_id: &str,
        displayed: &str,
        matches_displayed: F,
    ) -> &'a mut TextEditor
    where
        F: Fn(&TextEditor, &str) -> bool,
    {
        let key = command_key.storage_key();
        let editors = self.editors.entry(key).or_default();
        let editor = editors
            .entry(arg_id.to_string())
            .or_insert_with(|| TextEditor::from_displayed(displayed));
        if !matches_displayed(editor, displayed) {
            *editor = TextEditor::from_displayed(displayed);
        }
        editor
    }
}

impl TextEditor {
    pub(crate) fn from_displayed(displayed: &str) -> Self {
        let lines = if displayed.is_empty() {
            vec![String::new()]
        } else {
            displayed.split('\n').map(ToString::to_string).collect()
        };
        Self {
            lines,
            cursor: TextPosition::default(),
            selection_anchor: None,
        }
    }

    pub(crate) fn text(&self) -> String {
        self.lines.join("\n")
    }

    pub(crate) fn row_count(&self) -> usize {
        self.lines.len()
    }

    pub(crate) fn current_row(&self) -> usize {
        self.cursor.row.min(self.lines.len().saturating_sub(1))
    }

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

    pub(crate) fn current_line_len(&self) -> usize {
        self.lines
            .get(self.current_row())
            .map_or(0, std::string::String::len)
    }

    pub(crate) fn lines(&self) -> &[String] {
        &self.lines
    }

    pub(crate) fn selection_anchor(&self) -> Option<TextPosition> {
        self.selection_anchor
    }

    pub(crate) fn cancel_selection(&mut self) {
        self.selection_anchor = None;
    }

    pub(crate) fn move_cursor_to(&mut self, row: u16, col: u16) {
        self.cursor = TextPosition {
            row: usize::from(row),
            col: usize::from(col),
        };
    }

    pub(crate) fn start_selection(&mut self, row: u16, col: u16) {
        self.cursor = TextPosition {
            row: usize::from(row),
            col: usize::from(col),
        };
        self.selection_anchor = Some(self.cursor);
    }

    pub(crate) fn apply_key(&mut self, key: AppKeyEvent) -> bool {
        let cursor_before = self.cursor;
        let selection_anchor = selection_anchor_for_key(self.selection_anchor, cursor_before, key);

        let mut textarea = self.to_textarea(selection_anchor);
        let modified = textarea.input(Input::from(key));
        let cursor = textarea.cursor();
        self.lines = textarea.lines().to_vec();
        self.cursor = TextPosition {
            row: cursor.0,
            col: cursor.1,
        };
        self.selection_anchor = if textarea.is_selecting() {
            selection_anchor.filter(|anchor| *anchor != self.cursor)
        } else {
            None
        };
        modified
    }

    pub(crate) fn insert_str(&mut self, text: &str) -> bool {
        let mut textarea = self.to_textarea(self.selection_anchor);
        let modified = textarea.insert_str(text);
        let cursor = textarea.cursor();
        self.lines = textarea.lines().to_vec();
        self.cursor = TextPosition {
            row: cursor.0,
            col: cursor.1,
        };
        self.selection_anchor = if textarea.is_selecting() {
            self.selection_anchor
                .filter(|anchor| *anchor != self.cursor)
        } else {
            None
        };
        modified
    }

    pub(crate) fn insert_row_below(&mut self) {
        let insert_at = self.current_row().saturating_add(1).min(self.lines.len());
        self.lines.insert(insert_at, String::new());
        self.cursor = TextPosition {
            row: insert_at,
            col: 0,
        };
        self.selection_anchor = None;
    }

    pub(crate) fn remove_current_row(&mut self) {
        if self.lines.is_empty() {
            self.lines.push(String::new());
            self.cursor = TextPosition::default();
            self.selection_anchor = None;
            return;
        }

        let row = self.current_row();
        self.lines.remove(row);
        if self.lines.is_empty() {
            self.lines.push(String::new());
        }
        let next_row = row.min(self.lines.len().saturating_sub(1));
        let next_col = self.cursor.col.min(self.lines[next_row].len());
        self.cursor = TextPosition {
            row: next_row,
            col: next_col,
        };
        self.selection_anchor = None;
    }

    pub(crate) fn move_current_row_up(&mut self) {
        let row = self.current_row();
        if row == 0 || row >= self.lines.len() {
            return;
        }
        self.lines.swap(row, row - 1);
        self.cursor = TextPosition {
            row: row - 1,
            col: self.cursor.col.min(self.lines[row - 1].len()),
        };
        self.selection_anchor = None;
    }

    pub(crate) fn move_current_row_down(&mut self) {
        let row = self.current_row();
        if row + 1 >= self.lines.len() {
            return;
        }
        self.lines.swap(row, row + 1);
        self.cursor = TextPosition {
            row: row + 1,
            col: self.cursor.col.min(self.lines[row + 1].len()),
        };
        self.selection_anchor = None;
    }

    pub(crate) fn to_textarea(&self, selection_anchor: Option<TextPosition>) -> TextArea<'static> {
        let mut textarea = TextArea::new(self.lines.clone());
        if let Some(anchor) = selection_anchor {
            textarea.move_cursor(CursorMove::Jump(
                u16::try_from(anchor.row).unwrap_or(u16::MAX),
                u16::try_from(anchor.col).unwrap_or(u16::MAX),
            ));
            textarea.start_selection();
        }
        textarea.move_cursor(CursorMove::Jump(
            u16::try_from(self.cursor.row).unwrap_or(u16::MAX),
            u16::try_from(self.cursor.col).unwrap_or(u16::MAX),
        ));
        textarea
    }
}

impl From<AppKeyEvent> for Input {
    fn from(value: AppKeyEvent) -> Self {
        Self {
            key: Key::from(value.code),
            ctrl: value.modifiers.control,
            alt: value.modifiers.alt,
            shift: value.modifiers.shift,
        }
    }
}

impl From<AppKeyCode> for Key {
    fn from(value: AppKeyCode) -> Self {
        match value {
            AppKeyCode::Char(value) => Self::Char(value),
            AppKeyCode::F(value) => Self::F(value),
            AppKeyCode::Backspace => Self::Backspace,
            AppKeyCode::Enter => Self::Enter,
            AppKeyCode::Left => Self::Left,
            AppKeyCode::Right => Self::Right,
            AppKeyCode::Up => Self::Up,
            AppKeyCode::Down => Self::Down,
            AppKeyCode::Tab | AppKeyCode::BackTab => Self::Tab,
            AppKeyCode::Delete => Self::Delete,
            AppKeyCode::Home => Self::Home,
            AppKeyCode::End => Self::End,
            AppKeyCode::PageUp => Self::PageUp,
            AppKeyCode::PageDown => Self::PageDown,
            AppKeyCode::Esc => Self::Esc,
            AppKeyCode::Null => Self::Null,
        }
    }
}

fn extends_selection(key: AppKeyEvent) -> bool {
    if !key.modifiers.shift {
        return false;
    }
    matches!(
        key.code,
        AppKeyCode::Left
            | AppKeyCode::Right
            | AppKeyCode::Up
            | AppKeyCode::Down
            | AppKeyCode::Home
            | AppKeyCode::End
            | AppKeyCode::PageUp
            | AppKeyCode::PageDown
    )
}

fn selection_anchor_for_key(
    selection_anchor: Option<TextPosition>,
    cursor_before: TextPosition,
    key: AppKeyEvent,
) -> Option<TextPosition> {
    if extends_selection(key) {
        return selection_anchor.or(Some(cursor_before));
    }
    if consumes_selection(key) {
        return selection_anchor;
    }
    None
}

fn consumes_selection(key: AppKeyEvent) -> bool {
    match key.code {
        AppKeyCode::Backspace | AppKeyCode::Delete => true,
        AppKeyCode::Char(_) if !key.modifiers.control && !key.modifiers.alt => true,
        AppKeyCode::Char(c) if key.modifiers.control && !key.modifiers.alt => {
            matches!(c, 'c' | 'd' | 'h' | 'j' | 'k' | 'm' | 'w' | 'x' | 'y')
        }
        AppKeyCode::Char(c) if !key.modifiers.control && key.modifiers.alt => {
            matches!(c, 'd' | 'h')
        }
        _ => false,
    }
}

#[cfg(test)]
mod tests {
    use super::{TextEditor, TextPosition};
    use crate::runtime::{AppKeyCode, AppKeyEvent, AppKeyModifiers};

    fn key(code: AppKeyCode) -> AppKeyEvent {
        AppKeyEvent::new(code, AppKeyModifiers::default())
    }

    #[test]
    fn editor_tracks_text_and_cursor_without_widget_storage() {
        let mut editor = TextEditor::from_displayed("abc");
        editor.apply_key(key(AppKeyCode::End));
        editor.apply_key(key(AppKeyCode::Char('d')));

        assert_eq!(editor.text(), "abcd");
        assert_eq!(editor.cursor(), TextPosition { row: 0, col: 4 });
        assert_eq!(editor.selection_anchor(), None);
    }

    #[test]
    fn editor_tracks_mouse_selection_anchor() {
        let mut editor = TextEditor::from_displayed("alpha");
        editor.start_selection(0, 1);
        editor.move_cursor_to(0, 4);

        assert_eq!(
            editor.selection_anchor(),
            Some(TextPosition { row: 0, col: 1 })
        );
        assert_eq!(editor.cursor(), TextPosition { row: 0, col: 4 });
    }

    #[test]
    fn backspace_deletes_the_entire_selected_range() {
        let mut editor = TextEditor::from_displayed("alpha");
        editor.start_selection(0, 1);
        editor.move_cursor_to(0, 4);

        editor.apply_key(key(AppKeyCode::Backspace));

        assert_eq!(editor.text(), "aa");
        assert_eq!(editor.cursor(), TextPosition { row: 0, col: 1 });
        assert_eq!(editor.selection_anchor(), None);
    }

    #[test]
    fn typing_replaces_the_current_selection() {
        let mut editor = TextEditor::from_displayed("alpha");
        editor.start_selection(0, 1);
        editor.move_cursor_to(0, 4);

        editor.apply_key(key(AppKeyCode::Char('x')));

        assert_eq!(editor.text(), "axa");
        assert_eq!(editor.cursor(), TextPosition { row: 0, col: 2 });
        assert_eq!(editor.selection_anchor(), None);
    }

    #[test]
    fn row_operations_insert_remove_and_reorder_lines() {
        let mut editor = TextEditor::from_displayed("alpha\nbeta\ngamma");
        editor.move_cursor_to(1, 2);

        editor.insert_row_below();
        assert_eq!(editor.text(), "alpha\nbeta\n\ngamma");
        assert_eq!(editor.cursor(), TextPosition { row: 2, col: 0 });

        editor.remove_current_row();
        assert_eq!(editor.text(), "alpha\nbeta\ngamma");
        assert_eq!(editor.cursor(), TextPosition { row: 2, col: 0 });

        editor.move_current_row_up();
        assert_eq!(editor.text(), "alpha\ngamma\nbeta");
        assert_eq!(editor.cursor(), TextPosition { row: 1, col: 0 });

        editor.move_current_row_down();
        assert_eq!(editor.text(), "alpha\nbeta\ngamma");
        assert_eq!(editor.cursor(), TextPosition { row: 2, col: 0 });
    }
}