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.
//! Elemento que pinta el texto, la selección, el cursor y la composición IME de un campo.

use crate::core::{Bounds, Point, Size, px};
use crate::view::NodeId;
use crate::view::context::ElementContext;
use crate::view::element::Element;
use crate::view::style::{Length, Style};
use crate::view::theme::Token;
use crate::widgets::text_input::{InputAppearance, InputLayout};
use std::cell::RefCell;
use std::ops::Range;
use std::rc::Rc;

pub struct InputElement {
    text: String,
    selection: Range<usize>,
    head: usize,
    preedit: Option<String>,
    placeholder: String,
    focused: bool,
    layout: Rc<RefCell<InputLayout>>,
    /// Etiqueta seleccionable: mide lo que ocupa su texto y no dibuja cursor.
    read_only: bool,
    appearance: Option<InputAppearance>,
    node: Option<NodeId>,
}

impl InputElement {
    pub fn new(
        text: String,
        selection: Range<usize>,
        head: usize,
        preedit: Option<String>,
        placeholder: String,
        focused: bool,
        layout: Rc<RefCell<InputLayout>>,
    ) -> Self {
        Self {
            text,
            selection,
            head,
            preedit,
            placeholder,
            focused,
            layout,
            read_only: false,
            appearance: None,
            node: None,
        }
    }

    pub fn read_only(mut self, read_only: bool) -> Self {
        self.read_only = read_only;
        self
    }

    pub fn appearance(mut self, appearance: Option<InputAppearance>) -> Self {
        self.appearance = appearance;
        self
    }

    /// Texto mostrado: la composición IME se inserta en la posición del cursor.
    fn display_text(&self) -> String {
        match &self.preedit {
            Some(preedit) => {
                let mut shown = self.text.clone();
                shown.insert_str(self.head, preedit);
                shown
            }
            None => self.text.clone(),
        }
    }
}

impl Element for InputElement {
    fn request_layout(&mut self, cx: &mut ElementContext) -> NodeId {
        let mut style = Style { flex_grow: Some(1.0), height: Some(Length::Percent(1.0)), ..Default::default() };
        if self.read_only {
            // Una etiqueta ocupa lo que mide su texto (más un píxel para el borde de la selección).
            let size = cx.text_style().size;
            let shaped = cx.shape(&self.text, size, false);
            style = Style {
                width: Some(Length::Px(px(shaped.size.width.0.ceil() + 1.0))),
                height: Some(Length::Px(px(shaped.line_height))),
                flex_shrink: Some(0.0),
                ..Default::default()
            };
        }
        let node = cx.request_node(&style, &[]);
        self.node = Some(node);
        node
    }

    fn prepaint(&mut self, _cx: &mut ElementContext) {}

    fn paint(&mut self, cx: &mut ElementContext) {
        let Some(node) = self.node else { return };
        let bounds = cx.node_bounds(node);
        let shown = self.display_text();
        let empty = shown.is_empty();
        let content = if empty { &self.placeholder } else { &shown };
        let shaped = match &self.appearance {
            Some(look) => {
                let family = cx.font_family(&look.family);
                cx.shape_in(content, px(look.font_size), true, family)
            }
            None => cx.shape(content, cx.text_style().size, false),
        };
        let line_height = shaped.line_height;
        let top = bounds.origin.y.0 + (bounds.size.height.0 - line_height) / 2.0;

        let caret_byte = self.head + self.preedit.as_ref().map_or(0, String::len);
        let caret_x = if empty { 0.0 } else { shaped.caret_x(caret_byte) };
        let width = bounds.size.width.0;
        let mut layout = self.layout.borrow_mut();
        if caret_x - layout.scroll_x > width - 2.0 {
            layout.scroll_x = caret_x - width + 2.0;
        } else if caret_x < layout.scroll_x {
            layout.scroll_x = caret_x;
        }
        let left = bounds.origin.x.0 - layout.scroll_x;

        cx.with_mask(bounds, |cx| {
            if !self.selection.is_empty() && !empty {
                let start = left + shaped.caret_x(self.selection.start);
                let end = left + shaped.caret_x(self.selection.end);
                let rect = Bounds::new(Point::new(px(start), px(top)), Size::new(px(end - start), px(line_height)));
                let color = cx.theme.color(Token::Selection);
                cx.fill_rect(rect, color);
            }
            let custom = self.appearance.as_ref().and_then(|look| look.text).filter(|_| !empty);
            let color = custom.unwrap_or_else(|| cx.theme.color(if empty { Token::TextMuted } else { Token::Text }));
            cx.paint_text(&shaped, Point::new(px(left), px(top)), color);
            if let (Some(preedit), false) = (&self.preedit, empty) {
                let start = left + shaped.caret_x(self.head);
                let end = left + shaped.caret_x(self.head + preedit.len());
                let underline = Bounds::new(
                    Point::new(px(start), px(top + line_height - 2.0)),
                    Size::new(px(end - start), px(1.0)),
                );
                let color = cx.theme.color(Token::Text);
                cx.fill_rect(underline, color);
            }
            if self.focused && !self.read_only {
                let caret = Bounds::new(Point::new(px(left + caret_x), px(top)), Size::new(px(1.5), px(line_height)));
                let custom = self.appearance.as_ref().and_then(|look| look.caret);
                let color = custom.unwrap_or_else(|| cx.theme.color(Token::Accent));
                cx.fill_rect(caret, color);
                cx.set_ime_area(caret);
            }
        });
        layout.text_left = left;
        layout.shaped = if empty { None } else { Some(shaped) };
    }
}