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.
//! Vista del `TextInput`: atajos en el contexto `TextInput` y elemento que lo pinta.

use crate::core::px;
use crate::view::keymap::Keymap;
use crate::view::prelude::*;
use crate::widgets::text_input::{InputChanged, InputLayout, TextInput};
use crate::widgets::text_input_element::InputElement;

macro_rules! input_actions {
    ($($name:ident = $label:literal => $key:literal, |$input:ident, $cx:ident| $body:expr;)*) => {
        $( pub const $name: Action = Action(concat!("input::", $label)); )*

        /// `(acción, operación)`; la operación devuelve si cambió el texto.
        #[allow(unused_variables)]
        const OPERATIONS: &[(Action, fn(&mut TextInput, &mut ListenerContext<TextInput>) -> bool)] = &[
            $( ($name, |$input, $cx| $body), )*
        ];

        /// Atajos por defecto del campo de texto.
        pub fn bind_text_input_keys(keymap: &mut Keymap) {
            $(
                if let Err(error) = keymap.bind($key, Some($name), Some("TextInput")) {
                    eprintln!("atajo inválido: {error}");
                }
            )*
        }
    };
}

input_actions! {
    BACKSPACE = "Backspace" => "backspace", |input, cx| { input.backspace(false); true };
    DELETE_WORD_LEFT = "DeleteWordLeft" => "alt-backspace", |input, cx| { input.backspace(true); true };
    DELETE = "Delete" => "delete", |input, cx| { input.delete(false); true };
    LEFT = "Left" => "left", |input, cx| { input.move_left(false); false };
    RIGHT = "Right" => "right", |input, cx| { input.move_right(false); false };
    SELECT_LEFT = "SelectLeft" => "shift-left", |input, cx| { input.move_left(true); false };
    SELECT_RIGHT = "SelectRight" => "shift-right", |input, cx| { input.move_right(true); false };
    WORD_LEFT = "WordLeft" => "alt-left", |input, cx| { input.move_word(false, false); false };
    WORD_RIGHT = "WordRight" => "alt-right", |input, cx| { input.move_word(true, false); false };
    HOME = "Home" => "home", |input, cx| { input.move_home(false); false };
    END = "End" => "end", |input, cx| { input.move_end(false); false };
    SELECT_HOME = "SelectHome" => "shift-home", |input, cx| { input.move_home(true); false };
    SELECT_END = "SelectEnd" => "shift-end", |input, cx| { input.move_end(true); false };
    SELECT_ALL = "SelectAll" => "mod-a", |input, cx| { input.select_all(); false };
    UNDO = "Undo" => "mod-z", |input, cx| { input.undo(); true };
    REDO = "Redo" => "mod-shift-z", |input, cx| { input.redo(); true };
    COPY = "Copy" => "mod-c", |input, cx| { copy(input, cx); false };
    CUT = "Cut" => "mod-x", |input, cx| { copy(input, cx); input.insert(""); true };
    PASTE = "Paste" => "mod-v", |input, cx| { paste(input, cx) };
}

fn copy(input: &TextInput, cx: &mut ListenerContext<TextInput>) {
    let selected = input.selected_text().to_owned();
    if selected.is_empty() {
        return;
    }
    if let Err(error) = cx.clipboard.write(selected) {
        eprintln!("no se pudo copiar: {error}");
    }
}

fn paste(input: &mut TextInput, cx: &mut ListenerContext<TextInput>) -> bool {
    match cx.clipboard.read() {
        Some(text) => {
            input.insert(&text);
            true
        }
        None => false,
    }
}

/// Acciones que no modifican el texto: las únicas que atiende un campo de solo lectura.
const READ_ONLY_ACTIONS: &[Action] = &[
    LEFT,
    RIGHT,
    SELECT_LEFT,
    SELECT_RIGHT,
    WORD_LEFT,
    WORD_RIGHT,
    HOME,
    END,
    SELECT_HOME,
    SELECT_END,
    SELECT_ALL,
    COPY,
];

impl Render for TextInput {
    fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement {
        let focused = cx.is_focused(&self.focus);
        let read_only = self.read_only;
        let mut field = div()
            .role(if read_only { Role::Label } else { Role::TextInput })
            .label(if self.text.is_empty() { self.placeholder.clone() } else { self.text.clone() })
            .key_context("TextInput")
            .track_focus(&self.focus)
            .flex_row()
            .items_center()
            .when(self.plain && !read_only, |field| field.w_full().h(px(32.0)))
            .when(!self.plain && !self.compact, |field| {
                field
                    .w_full()
                    .h(px(32.0))
                    .px_3()
                    .rounded_md()
                    .border_1()
                    .border_color(Token::Border)
                    .focus(|style| style.border_color(Token::BorderFocused))
                    .bg(Token::Canvas)
            })
            .when(self.compact, |field| {
                field
                    .w_full()
                    .h(px(20.0))
                    .px(px(4.0))
                    .rounded_sm()
                    .border_1()
                    .border_color(Token::BorderFocused)
                    .bg(Token::Canvas)
            })
            .text_sm()
            .on_text_input(cx.listener(|this: &mut TextInput, event: &TextInputEvent, cx| {
                if this.read_only {
                    return;
                }
                match event {
                    TextInputEvent::Insert(text) => {
                        this.insert(text);
                        cx.emit(InputChanged(this.text.clone()));
                    }
                    TextInputEvent::Preedit { text, .. } => this.set_preedit(Some(text.clone())),
                }
                cx.notify();
            }))
            .cursor_text()
            .on_mouse_down(cx.listener(|this: &mut TextInput, event: &ClickEvent, cx| {
                let byte = this.byte_at(event.position.x.0);
                this.click(byte, event.modifiers.shift, event.click_count);
                cx.notify();
            }))
            .on_drag(cx.listener(|this: &mut TextInput, event: &DragEvent, cx| {
                let byte = this.byte_at(event.position.x.0);
                this.click(byte, true, 1);
                cx.notify();
            }));
        for (action, operation) in
            OPERATIONS.iter().filter(|(action, _)| !read_only || READ_ONLY_ACTIONS.contains(action))
        {
            let operation = *operation;
            field = field.on_action(
                *action,
                cx.listener(move |this: &mut TextInput, _: &ActionEvent, cx| {
                    if operation(this, cx) {
                        cx.emit(InputChanged(this.text.clone()));
                    }
                    cx.notify();
                }),
            );
        }
        let layout: std::rc::Rc<std::cell::RefCell<InputLayout>> = self.layout.clone();
        // Enmascarado: un asterisco por carácter y los desplazamientos pasan de bytes a caracteres.
        let (shown, selection, head) = if self.masked {
            let chars = |end: usize| self.text[..end.min(self.text.len())].chars().count();
            let selection = self.selection();
            ("*".repeat(self.text.chars().count()), chars(selection.start)..chars(selection.end), chars(self.head))
        } else {
            (self.text.clone(), self.selection(), self.head)
        };
        field
            .child(
                InputElement::new(
                    shown,
                    selection,
                    head,
                    self.preedit.clone(),
                    self.placeholder.clone(),
                    focused,
                    layout,
                )
                .read_only(read_only)
                .appearance(self.appearance.clone()),
            )
            .into_element()
    }
}