aura-gui 0.2.2

UI nativa acelerada por GPU para escritorio, Android e iOS, en un solo crate: estilo tipo Tailwind, componentes, sombras, animaciones y agentes de IA. Reposo a cero. Licencia AGPL-3.0 o comercial.
//! Estado y edición de un campo de texto de una línea (índices en bytes UTF-8).

use crate::core::FocusHandle;
use std::cell::RefCell;
use std::ops::Range;
use std::rc::Rc;

const UNDO_LIMIT: usize = 64;

/// Se emite cuando cambia el texto; los dueños (pickers, buscadores) se suscriben.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct InputChanged(pub String);

/// Lo que se pintó en el último frame, para traducir clics a posiciones.
#[derive(Clone, Default)]
pub struct InputLayout {
    pub text_left: f32,
    pub shaped: Option<Rc<crate::gpu::ShapedText>>,
    pub scroll_x: f32,
}

/// Aspecto propio de entradas tipo terminal: monoespaciada, con tamaño, familia y colores.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct InputAppearance {
    pub font_size: f32,
    /// Familia instalada; vacía usa la monoespaciada por defecto.
    pub family: String,
    pub text: Option<crate::core::Rgba>,
    pub caret: Option<crate::core::Rgba>,
}

pub struct TextInput {
    pub(crate) text: String,
    pub(crate) anchor: usize,
    pub(crate) head: usize,
    pub(crate) preedit: Option<String>,
    pub(crate) placeholder: String,
    pub(crate) focus: FocusHandle,
    /// Solo se puede seleccionar y copiar (etiquetas seleccionables).
    pub(crate) read_only: bool,
    /// Sin borde, fondo ni relleno: se integra como una etiqueta.
    pub(crate) plain: bool,
    /// Campo de secretos: se pinta con asteriscos y no se puede copiar.
    pub(crate) masked: bool,
    /// Bajo y con borde de foco: para editar en línea dentro de una fila (renombrar en el explorador).
    pub(crate) compact: bool,
    pub(crate) layout: Rc<RefCell<InputLayout>>,
    pub(crate) appearance: Option<InputAppearance>,
    undo: Vec<(String, usize)>,
    redo: Vec<(String, usize)>,
}

impl TextInput {
    pub fn new(focus: FocusHandle) -> Self {
        Self {
            text: String::new(),
            anchor: 0,
            head: 0,
            preedit: None,
            placeholder: String::new(),
            focus,
            read_only: false,
            plain: false,
            masked: false,
            compact: false,
            layout: Rc::default(),
            appearance: None,
            undo: Vec::new(),
            redo: Vec::new(),
        }
    }

    pub fn placeholder(mut self, placeholder: impl Into<String>) -> Self {
        self.placeholder = placeholder.into();
        self
    }

    /// Campo editable sin borde ni fondo, para componer entradas de terminal o barras propias.
    pub fn embedded(mut self) -> Self {
        self.plain = true;
        self
    }

    /// Variante baja para editar dentro de una fila de una lista.
    pub fn compact(mut self) -> Self {
        self.compact = true;
        self
    }

    /// Etiqueta de texto que se puede seleccionar con el ratón y copiar con `mod-c`.
    pub fn selectable_label(focus: FocusHandle, text: impl Into<String>) -> Self {
        let mut input = Self::new(focus);
        (input.read_only, input.plain) = (true, true);
        input.set_text(text);
        input.anchor = 0;
        input.head = 0;
        input
    }

    /// Fuente y colores propios (`None` vuelve a los del tema).
    pub fn set_appearance(&mut self, appearance: Option<InputAppearance>) {
        self.appearance = appearance;
    }

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

    pub fn focus_handle(&self) -> FocusHandle {
        self.focus
    }

    pub fn set_text(&mut self, text: impl Into<String>) {
        self.text = text.into();
        self.anchor = self.text.len();
        self.head = self.text.len();
    }

    pub fn selection(&self) -> Range<usize> {
        self.anchor.min(self.head)..self.anchor.max(self.head)
    }

    fn previous_boundary(&self, index: usize) -> usize {
        self.text[..index].char_indices().next_back().map_or(0, |(position, _)| position)
    }

    fn next_boundary(&self, index: usize) -> usize {
        self.text[index..].chars().next().map_or(index, |character| index + character.len_utf8())
    }

    fn word_left(&self, index: usize) -> usize {
        let prefix = &self.text[..index];
        let trimmed = prefix.trim_end_matches(|c: char| !c.is_alphanumeric());
        trimmed.trim_end_matches(|c: char| c.is_alphanumeric() || c == '_').len()
    }

    fn word_right(&self, index: usize) -> usize {
        let suffix = &self.text[index..];
        let skipped = suffix.len() - suffix.trim_start_matches(|c: char| !c.is_alphanumeric()).len();
        let rest = &suffix[skipped..];
        index + skipped + (rest.len() - rest.trim_start_matches(|c: char| c.is_alphanumeric() || c == '_').len())
    }

    fn move_to(&mut self, index: usize, extend: bool) {
        self.head = index.min(self.text.len());
        if !extend {
            self.anchor = self.head;
        }
    }

    pub fn move_left(&mut self, extend: bool) {
        let selection = self.selection();
        let target = if !extend && !selection.is_empty() { selection.start } else { self.previous_boundary(self.head) };
        self.move_to(target, extend);
    }

    pub fn move_right(&mut self, extend: bool) {
        let selection = self.selection();
        let target = if !extend && !selection.is_empty() { selection.end } else { self.next_boundary(self.head) };
        self.move_to(target, extend);
    }

    pub fn move_word(&mut self, forward: bool, extend: bool) {
        let target = if forward { self.word_right(self.head) } else { self.word_left(self.head) };
        self.move_to(target, extend);
    }

    pub fn move_home(&mut self, extend: bool) {
        self.move_to(0, extend);
    }

    pub fn move_end(&mut self, extend: bool) {
        self.move_to(self.text.len(), extend);
    }

    pub fn select_all(&mut self) {
        self.anchor = 0;
        self.head = self.text.len();
    }

    /// Selecciona `range` (en bytes, ajustado al texto y a límites de carácter); el cursor queda al final.
    pub fn select_range(&mut self, range: Range<usize>) {
        let clamp = |index: usize| {
            let index = index.min(self.text.len());
            (0..=index).rev().find(|&candidate| self.text.is_char_boundary(candidate)).unwrap_or(0)
        };
        (self.anchor, self.head) = (clamp(range.start), clamp(range.end));
    }

    fn remember(&mut self) {
        self.undo.push((self.text.clone(), self.head));
        if self.undo.len() > UNDO_LIMIT {
            self.undo.remove(0);
        }
        self.redo.clear();
    }

    /// Reemplaza la selección por `inserted`.
    pub fn insert(&mut self, inserted: &str) {
        let inserted: String = inserted.chars().filter(|c| *c != '\n' && *c != '\r').collect();
        self.remember();
        let selection = self.selection();
        self.text.replace_range(selection.clone(), &inserted);
        self.move_to(selection.start + inserted.len(), false);
        self.preedit = None;
    }

    pub fn backspace(&mut self, by_word: bool) {
        if self.selection().is_empty() {
            let start = if by_word { self.word_left(self.head) } else { self.previous_boundary(self.head) };
            self.anchor = start;
        }
        self.insert("");
    }

    pub fn delete(&mut self, by_word: bool) {
        if self.selection().is_empty() {
            self.anchor = if by_word { self.word_right(self.head) } else { self.next_boundary(self.head) };
        }
        self.insert("");
    }

    /// Texto seleccionado; un campo enmascarado no lo entrega (no se copia un secreto).
    pub fn selected_text(&self) -> &str {
        if self.masked { "" } else { &self.text[self.selection()] }
    }

    pub fn set_masked(&mut self, masked: bool) {
        self.masked = masked;
    }

    pub fn set_preedit(&mut self, preedit: Option<String>) {
        self.preedit = preedit.filter(|text| !text.is_empty());
    }

    pub fn undo(&mut self) {
        if let Some((text, head)) = self.undo.pop() {
            self.redo.push((std::mem::replace(&mut self.text, text), self.head));
            self.move_to(head, false);
        }
    }

    pub fn redo(&mut self) {
        if let Some((text, head)) = self.redo.pop() {
            self.undo.push((std::mem::replace(&mut self.text, text), self.head));
            self.move_to(head, false);
        }
    }

    /// Coloca el cursor según la coordenada x (en la ventana) de un clic.
    pub fn byte_at(&self, window_x: f32) -> usize {
        let layout = self.layout.borrow();
        let Some(shaped) = layout.shaped.as_ref() else { return self.text.len() };
        shaped.byte_at_x(window_x - layout.text_left).min(self.text.len())
    }

    pub fn click(&mut self, byte: usize, extend: bool, click_count: u32) {
        match click_count {
            2 => {
                self.anchor = self.word_left(self.next_boundary(byte).min(self.text.len()));
                self.head = self.word_right(self.anchor);
            }
            3 => self.select_all(),
            _ => self.move_to(byte, extend),
        }
    }
}

#[cfg(test)]
#[path = "text_input_tests.rs"]
mod tests;