rustact 0.1.0

Async terminal UI framework inspired by React, built on top of ratatui and tokio.
Documentation
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::OnceLock;

use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEventKind};
use parking_lot::{Mutex, RwLock};

use crate::events::{FrameworkEvent, mouse_position};
use crate::interactions::Hitbox;
use crate::runtime::Dispatcher;

use super::state::TextInputState;

pub struct TextInputRegistry {
    bindings: RwLock<HashMap<String, Arc<Mutex<TextInputState>>>>,
    order: RwLock<Vec<String>>,
    hitboxes: RwLock<HashMap<String, Hitbox>>,
    focused: Mutex<Option<String>>,
    cursor_visible: Mutex<bool>,
}

impl TextInputRegistry {
    fn new() -> Self {
        Self {
            bindings: RwLock::new(HashMap::new()),
            order: RwLock::new(Vec::new()),
            hitboxes: RwLock::new(HashMap::new()),
            focused: Mutex::new(None),
            cursor_visible: Mutex::new(true),
        }
    }

    fn singleton() -> &'static Self {
        static REGISTRY: OnceLock<TextInputRegistry> = OnceLock::new();
        REGISTRY.get_or_init(Self::new)
    }

    pub(crate) fn register_binding(id: &str, state: Arc<Mutex<TextInputState>>) {
        let registry = Self::singleton();
        registry.bindings.write().insert(id.to_string(), state);
        let mut order = registry.order.write();
        if !order.iter().any(|existing| existing == id) {
            order.push(id.to_string());
        }
    }

    pub(crate) fn unregister_binding(id: &str) {
        let registry = Self::singleton();
        registry.bindings.write().remove(id);
        registry.hitboxes.write().remove(id);
        let mut order = registry.order.write();
        if let Some(index) = order.iter().position(|existing| existing == id) {
            order.remove(index);
        }
        let mut focused = registry.focused.lock();
        if focused.as_deref() == Some(id) {
            *focused = None;
        }
    }

    fn register_hitbox_internal(id: &str, hitbox: Hitbox) {
        let registry = Self::singleton();
        registry.hitboxes.write().insert(id.to_string(), hitbox);
    }

    fn reset_hitboxes_internal() {
        let registry = Self::singleton();
        registry.hitboxes.write().clear();
    }

    fn hitbox_contains(&self, column: u16, row: u16) -> Option<String> {
        self.hitboxes.read().iter().find_map(|(id, hitbox)| {
            if column >= hitbox.x
                && column < hitbox.x.saturating_add(hitbox.width)
                && row >= hitbox.y
                && row < hitbox.y.saturating_add(hitbox.height)
            {
                Some(id.clone())
            } else {
                None
            }
        })
    }

    fn focus(&self, id: Option<&str>, dispatcher: &Dispatcher) {
        let mut guard = self.focused.lock();
        let next = id.map(|value| value.to_string());
        if guard.as_ref() != next.as_ref() {
            *guard = next;
            *self.cursor_visible.lock() = true;
            dispatcher.request_render();
        }
    }

    fn focused(&self) -> Option<String> {
        self.focused.lock().clone()
    }

    fn binding(&self, id: &str) -> Option<Arc<Mutex<TextInputState>>> {
        self.bindings.read().get(id).cloned()
    }

    fn focus_next(&self, reverse: bool, dispatcher: &Dispatcher) {
        let order = self.order.read();
        if order.is_empty() {
            return;
        }
        let current = self.focused();
        let next_index = if current.is_none() {
            if reverse {
                order.len().saturating_sub(1)
            } else {
                0
            }
        } else {
            let current_index = current
                .as_ref()
                .and_then(|id| order.iter().position(|existing| existing == id))
                .unwrap_or(0);
            if reverse {
                if current_index == 0 {
                    order.len() - 1
                } else {
                    current_index - 1
                }
            } else {
                (current_index + 1) % order.len()
            }
        };
        if let Some(next_id) = order.get(next_index) {
            self.focus(Some(next_id), dispatcher);
        }
    }

    fn cursor_visible(&self, id: &str) -> bool {
        if self.focused().as_deref() != Some(id) {
            return false;
        }
        *self.cursor_visible.lock()
    }

    fn tick(&self, dispatcher: &Dispatcher) {
        if self.focused().is_none() {
            let mut visible = self.cursor_visible.lock();
            if *visible {
                *visible = false;
                dispatcher.request_render();
            }
            return;
        }
        {
            let mut visible = self.cursor_visible.lock();
            *visible = !*visible;
        }
        dispatcher.request_render();
    }
}

pub struct TextInputs;

impl TextInputs {
    pub(crate) fn register_binding(id: &str, state: Arc<Mutex<TextInputState>>) {
        TextInputRegistry::register_binding(id, state);
    }

    pub(crate) fn unregister_binding(id: &str) {
        TextInputRegistry::unregister_binding(id);
    }

    pub fn register_hitbox(id: &str, hitbox: Hitbox) {
        TextInputRegistry::register_hitbox_internal(id, hitbox);
    }

    pub fn reset_hitboxes() {
        TextInputRegistry::reset_hitboxes_internal();
    }

    pub fn is_focused(id: &str) -> bool {
        let registry = TextInputRegistry::singleton();
        registry.focused().as_deref() == Some(id)
    }

    pub fn cursor_visible(id: &str) -> bool {
        let registry = TextInputRegistry::singleton();
        registry.cursor_visible(id)
    }

    pub fn focus(id: Option<&str>, dispatcher: &Dispatcher) {
        let registry = TextInputRegistry::singleton();
        registry.focus(id, dispatcher);
    }

    pub fn handle_event(event: &FrameworkEvent, dispatcher: &Dispatcher) {
        match event {
            FrameworkEvent::Mouse(mouse)
                if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) =>
            {
                let registry = TextInputRegistry::singleton();
                if let Some((col, row)) = mouse_position(event) {
                    if let Some(id) = registry.hitbox_contains(col, row) {
                        registry.focus(Some(&id), dispatcher);
                    } else {
                        registry.focus(None, dispatcher);
                    }
                }
            }
            FrameworkEvent::Key(key) => Self::handle_key(key, dispatcher),
            FrameworkEvent::Tick => {
                let registry = TextInputRegistry::singleton();
                registry.tick(dispatcher);
            }
            _ => {}
        }
    }

    fn handle_key(key: &KeyEvent, dispatcher: &Dispatcher) {
        let registry = TextInputRegistry::singleton();
        if matches!(key.code, KeyCode::Tab) {
            let reverse = key.modifiers.contains(KeyModifiers::SHIFT);
            registry.focus_next(reverse, dispatcher);
            return;
        }
        let Some(focused_id) = registry.focused() else {
            return;
        };
        if let Some(binding) = registry.binding(&focused_id) {
            let mut state = binding.lock();
            match key.code {
                KeyCode::Char(c) => {
                    if key.modifiers.contains(KeyModifiers::CONTROL)
                        || key.modifiers.contains(KeyModifiers::ALT)
                    {
                        return;
                    }
                    let cursor = state.cursor;
                    state.value.insert(cursor, c);
                    state.cursor = cursor + c.len_utf8();
                }
                KeyCode::Backspace => {
                    if state.cursor > 0 {
                        let cursor = state.cursor;
                        if let Some(prev_index) = prev_char_boundary(&state.value, cursor) {
                            state.value.replace_range(prev_index..cursor, "");
                            state.cursor = prev_index;
                        }
                    }
                }
                KeyCode::Delete => {
                    if state.cursor < state.value.len() {
                        let cursor = state.cursor;
                        if let Some(next_index) = next_char_boundary(&state.value, cursor) {
                            state.value.replace_range(cursor..next_index, "");
                        }
                    }
                }
                KeyCode::Left => {
                    if let Some(prev) = prev_char_boundary(&state.value, state.cursor) {
                        state.cursor = prev;
                    }
                }
                KeyCode::Right => {
                    if let Some(next) = next_char_boundary(&state.value, state.cursor) {
                        state.cursor = next;
                    }
                }
                KeyCode::Home => state.cursor = 0,
                KeyCode::End => state.cursor = state.value.len(),
                KeyCode::Esc => {
                    registry.focus(None, dispatcher);
                    return;
                }
                _ => return,
            }
            dispatcher.request_render();
        }
    }
}

fn prev_char_boundary(value: &str, index: usize) -> Option<usize> {
    value[..index].char_indices().last().map(|(idx, _)| idx)
}

fn next_char_boundary(value: &str, index: usize) -> Option<usize> {
    if index >= value.len() {
        return None;
    }
    let mut chars = value[index..].chars();
    let ch = chars.next()?;
    Some(index + ch.len_utf8())
}