text_input.rs raw

//! The TextInput primitive: single-style editable text on parley's
//! `PlainEditor`, which owns the hard parts — cursor and selection
//! semantics, bidi-aware movement, and IME composition.
//!
//! The field owns *behavior and geometry* — the box, the clip, where the
//! text sits, and the blinking caret's phase — while its *appearance tokens*
//! (background, border, caret, selection, corner radius) come from the theme,
//! resolved per interaction state by the style pass. The focused field's
//! thicker accent border is a `(text-input :focus)` rule, so the widget never
//! consults its own focus state to decide how to paint; whether to draw the
//! caret at all remains the widget's call, being blink phase rather than
//! appearance.
//!
//! Accessibility is full-fidelity: parley contributes text-run child nodes
//! with character metrics and the live selection through
//! [`Widget::accessibility_extended`], so AT-SPI exposes the Text interface
//! (content, caret, selection).

use guiduck_scene::Fragment;
use guiduck_scene::geom::{Point, Rect, Size, Stroke};
use guiduck_scene::paint::{Brush, Color};
use parley::StyleProperty;
use parley::editing::PlainEditor;
use taffy::AvailableSpace;

use super::Widget;
use crate::clipboard::{Clipboard, Selection, TextRequest};
use crate::dirty::Dirt;
use crate::event::{EventData, EventKind, ImeInput, Key, KeyInput, PointerButton, PointerEvent};
use crate::style::{ComputedStyle, StyleReader};
use crate::text::TextContext;

/// Inner padding between the box edge and the text.
const PAD_X: f64 = 8.0;
const PAD_Y: f64 = 6.0;
const CARET_WIDTH: f32 = 1.5;

/// How long the caret stays in each blink phase.
const BLINK_INTERVAL: std::time::Duration = std::time::Duration::from_millis(530);

/// The appearance tokens a text input reads from the theme.
struct Tokens {
    background: Brush,
    text: Brush,
    caret: Brush,
    selection: Brush,
    border: Brush,
    border_width: f64,
    corner_radius: f64,
}

impl Tokens {
    /// The pre-fill state. Every slot is filled from the fallback theme in
    /// [`Default`], so these values are never painted; the two-way coverage
    /// test in `style::tests` is what guarantees it.
    fn blank() -> Self {
        Self {
            background: Color::TRANSPARENT.into(),
            text: Color::TRANSPARENT.into(),
            caret: Color::TRANSPARENT.into(),
            selection: Color::TRANSPARENT.into(),
            border: Color::TRANSPARENT.into(),
            border_width: 0.0,
            corner_radius: 0.0,
        }
    }

    /// Read every token out of a computed style, reporting whether any
    /// changed.
    fn read(&mut self, style: &ComputedStyle) -> bool {
        let mut reader = StyleReader::new(style);
        reader.brush(&mut self.background, "background");
        reader.brush(&mut self.text, "color");
        reader.brush(&mut self.caret, "caret-color");
        reader.brush(&mut self.selection, "selection-color");
        reader.brush(&mut self.border, "border-color");
        reader.number(&mut self.border_width, "border-width");
        reader.number(&mut self.corner_radius, "corner-radius");
        reader.changed()
    }
}

impl Default for Tokens {
    fn default() -> Self {
        let mut tokens = Self::blank();
        tokens.read(&super::fallback_style("text-input"));
        tokens
    }
}

pub struct TextInput {
    editor: PlainEditor<Brush>,
    focused: bool,
    /// The caret's blink phase; edits and caret moves reset it to visible.
    caret_visible: bool,
    /// A pending wake request (the next blink toggle), collected by the
    /// tree beside dirt.
    wake: Option<std::time::Duration>,
    /// A left-button press landed here and moves are extending a drag
    /// selection; middle- and right-button presses never select.
    drag_selecting: bool,
    dirt: Dirt,
    /// Semantic events pending collection by the tree (user edits).
    emitted: Vec<EventData>,
    /// The content as of the last Changed emission (or programmatic set);
    /// the comparison that makes emission fire once per actual user edit.
    last_emitted: String,
    font_size: f32,
    family: Option<String>,
    /// When set, Enter inserts a line break and pasted or committed newlines
    /// survive; the field reports the accessible role of a multi-line editor.
    /// The field wraps and grows vertically either way — this governs only
    /// what may put a `\n` into it, and how it names itself.
    multiline: bool,
    tokens: Tokens,
}

/// The font size a text input has when nothing sets one.
///
/// The widget's own default, so the compiled and interpreted builds of a
/// `.gdc` cannot disagree about it: both reach it through [`Default`].
pub const DEFAULT_FONT_SIZE: f32 = 14.0;

impl Default for TextInput {
    fn default() -> Self {
        Self::new(DEFAULT_FONT_SIZE)
    }
}

impl TextInput {
    pub fn new(font_size: f32) -> Self {
        let tokens = Tokens::default();
        let mut editor = PlainEditor::new(font_size);
        editor
            .edit_styles()
            .insert(StyleProperty::Brush(tokens.text.clone()));
        Self {
            editor,
            focused: false,
            caret_visible: true,
            wake: None,
            drag_selecting: false,
            dirt: Dirt::CLEAN,
            emitted: Vec::new(),
            last_emitted: String::new(),
            font_size,
            family: None,
            multiline: false,
            tokens,
        }
    }

    pub fn text(mut self, text: impl Into<String>) -> Self {
        self.set_content(text);
        self
    }

    /// Use a specific font family instead of the collection's default.
    pub fn family(mut self, family: impl Into<String>) -> Self {
        self.set_family(family);
        self
    }

    /// Accept line breaks: Enter inserts one, and pasted or committed newlines
    /// are kept rather than flattened.
    pub fn multiline(mut self, multiline: bool) -> Self {
        self.set_multiline(multiline);
        self
    }

    /// Set whether the field accepts line breaks. See [`multiline`](Self::multiline).
    pub fn set_multiline(&mut self, multiline: bool) {
        self.multiline = multiline;
    }

    /// The current content (excluding any in-progress IME preedit).
    pub fn content(&self) -> String {
        self.editor.text().to_string()
    }

    /// The displayed text, including any in-progress IME preedit.
    pub fn display_content(&self) -> &str {
        self.editor.raw_text()
    }

    /// Replace the editor's buffer.
    ///
    /// `content`, not `text`: this widget distinguishes the committed buffer
    /// from what is on screen mid-IME (see [`display_content`](Self::display_content)),
    /// and that distinction is the whole reason the pair is named this way.
    /// A `.gdc`'s `:text` reaches it through the manifest's `:setter`
    /// override, which exists for exactly this.
    pub fn set_content(&mut self, text: impl Into<String>) {
        let text = text.into();
        if self.content() != text {
            self.editor.set_text(&text);
            // Programmatic changes never raise Changed (bindings would
            // loop), but they do reset the edit-comparison baseline.
            self.last_emitted = text;
            self.dirt.mark_layout();
        }
    }

    /// Change the font size; layout-affecting, equality-gated.
    pub fn set_font_size(&mut self, size: f32) {
        if self.font_size != size {
            self.font_size = size;
            self.editor
                .edit_styles()
                .insert(StyleProperty::FontSize(size));
            self.dirt.mark_layout();
        }
    }

    /// Change the font family; layout-affecting, equality-gated.
    pub fn set_family(&mut self, family: impl Into<String>) {
        let family: String = family.into();
        if self.family.as_deref() != Some(family.as_str()) {
            self.editor.edit_styles().insert(StyleProperty::FontFamily(
                crate::text::font_family(&family).into_owned(),
            ));
            self.family = Some(family);
            self.dirt.mark_layout();
        }
    }

    /// Queue a Changed emission if the content differs from the last
    /// emitted (or programmatically set) value. Called at the end of every
    /// user-input hook; the comparison keeps it one event per actual edit.
    fn emit_if_edited(&mut self) {
        let content = self.editor.text();
        if content != self.last_emitted.as_str() {
            self.last_emitted = content.to_string();
            self.emitted
                .push(EventData::Changed(self.last_emitted.clone()));
        }
    }

    /// The text admissible for insertion, with control characters removed —
    /// except line breaks when the field is `multiline`. Every route that
    /// admits text (typing, clipboard paste, primary-selection paste, IME
    /// commit) filters through here, so a single-line field cannot come to
    /// hold via one route what another forbids: pasting or committing
    /// multi-line text lands it as one line. A multi-line field keeps the
    /// `\n`s and drops the rest.
    fn sanitize_insert(s: &str, multiline: bool) -> String {
        s.chars()
            .filter(|ch| !ch.is_control() || (multiline && *ch == '\n'))
            .collect()
    }

    fn inner_origin(&self) -> Point {
        Point::new(PAD_X, PAD_Y)
    }

    /// Return the caret to its solid phase and, while focused, schedule the
    /// next blink toggle. Focus loss clears the schedule — an unfocused
    /// input requests no wakes, preserving the zero-idle-frames guarantee.
    fn reset_blink(&mut self) {
        self.caret_visible = true;
        self.wake = self.focused.then_some(BLINK_INTERVAL);
    }

    fn to_local(bounds: parley::BoundingBox) -> Rect {
        Rect::new(
            bounds.x0 + PAD_X,
            bounds.y0 + PAD_Y,
            bounds.x1 + PAD_X,
            bounds.y1 + PAD_Y,
        )
    }

    /// Publish the current selection to the primary selection. Called after
    /// every selection-changing operation; the shared helper leaves a
    /// collapsed selection alone.
    fn sync_primary(&self, clipboard: &mut dyn Clipboard) {
        super::sync_primary_selection(clipboard, self.editor.selected_text());
    }

    /// Insert pasted text at the caret: the one tail shared by both paste
    /// gestures, whether the clipboard answered on the spot or the text
    /// arrived later through [`Widget::on_paste`].
    fn insert_pasted(&mut self, pasted: &str, text: &mut TextContext) {
        let clean = Self::sanitize_insert(pasted, self.multiline);
        self.editor
            .driver(&mut text.font_cx, &mut text.layout_cx)
            .insert_or_replace_selection(&clean);
        self.emit_if_edited();
        self.dirt.mark_layout();
    }
}

impl Widget for TextInput {
    fn measure(
        &mut self,
        text: &mut TextContext,
        known: taffy::Size<Option<f32>>,
        _available: taffy::Size<AvailableSpace>,
    ) -> taffy::Size<f32> {
        let width = known.width.map(|w| (w - (PAD_X * 2.0) as f32).max(0.0));
        self.editor.set_width(width);
        let layout = self.editor.layout(&mut text.font_cx, &mut text.layout_cx);
        // An empty editor still lays out one line, so height is the line
        // height even with no text.
        taffy::Size {
            width: known.width.unwrap_or(layout.width() + (PAD_X * 2.0) as f32),
            height: layout.height() + (PAD_Y * 2.0) as f32,
        }
    }

    fn finalize_layout(&mut self, text: &mut TextContext, size: Size, _content_size: Size) {
        self.editor
            .set_width(Some((size.width - PAD_X * 2.0).max(0.0) as f32));
        self.editor
            .refresh_layout(&mut text.font_cx, &mut text.layout_cx);
    }

    fn paint(&mut self, fragment: &mut Fragment, size: Size) {
        let t = &self.tokens;
        let bounds = Rect::from_origin_size((0.0, 0.0), size);
        fragment.fill(
            bounds.to_rounded_rect(t.corner_radius),
            t.background.clone(),
        );
        // The `:focus` rule supplies the focused border's color and width, so
        // this is one unconditional stroke rather than a state check.
        let width = t.border_width;
        fragment.stroke(
            bounds
                .inset(-width / 2.0)
                .to_rounded_rect(t.corner_radius - width / 2.0),
            t.border.clone(),
            Stroke::new(width),
        );

        // Clip content to the box.
        fragment.push_clip(bounds.inset(-2.0));

        for (selection_bounds, _line) in self.editor.selection_geometry() {
            fragment.fill(Self::to_local(selection_bounds), t.selection.clone());
        }

        if let Some(layout) = self.editor.try_layout() {
            crate::text::append_layout(fragment, layout, self.inner_origin());
        }

        // Whether there is a caret to draw is blink phase, not appearance —
        // the widget's own business. Its color is the theme's.
        if self.focused
            && self.caret_visible
            && let Some(caret) = self.editor.cursor_geometry(CARET_WIDTH)
        {
            fragment.fill(Self::to_local(caret), t.caret.clone());
        }

        fragment.pop_clip();
    }

    fn focusable(&self) -> bool {
        true
    }

    fn cursor(&self, _local: Point) -> crate::widget::CursorShape {
        crate::widget::CursorShape::Text
    }

    fn on_focus_changed(&mut self, focused: bool) {
        self.focused = focused;
        self.reset_blink();
        self.dirt.mark_paint();
    }

    fn take_wake(&mut self) -> Option<std::time::Duration> {
        self.wake.take()
    }

    fn on_timer(&mut self) {
        if !self.focused {
            // The blink stops with focus; a stale timer changes nothing.
            return;
        }
        self.caret_visible = !self.caret_visible;
        self.wake = Some(BLINK_INTERVAL);
        self.dirt.mark_paint();
    }

    fn on_key(
        &mut self,
        key: &KeyInput,
        text: &mut TextContext,
        clipboard: &mut dyn Clipboard,
    ) -> bool {
        // Any keystroke resets the blink phase: a moving caret stays solid.
        self.reset_blink();
        let modifiers = key.modifiers;
        let shift = modifiers.shift;
        let mut driver = self.editor.driver(&mut text.font_cx, &mut text.layout_cx);
        let mut layout_change = true;
        match &key.key {
            Key::Character(c) if modifiers.ctrl => match c.as_str() {
                "a" | "A" => {
                    driver.select_all();
                    layout_change = false;
                }
                "c" | "C" => {
                    drop(driver);
                    if let Some(selected) = self.editor.selected_text() {
                        clipboard.set_text(Selection::Clipboard, selected);
                    }
                    return true;
                }
                "x" | "X" => {
                    drop(driver);
                    if let Some(selected) = self.editor.selected_text() {
                        clipboard.set_text(Selection::Clipboard, selected);
                        self.editor
                            .driver(&mut text.font_cx, &mut text.layout_cx)
                            .delete_selection();
                        self.emit_if_edited();
                        self.dirt.mark_layout();
                    }
                    return true;
                }
                "v" | "V" => {
                    drop(driver);
                    // A Pending answer needs nothing done here: the text
                    // arrives through `on_paste` once the platform has it.
                    if let TextRequest::Ready(Some(pasted)) =
                        clipboard.request_text(Selection::Clipboard)
                    {
                        self.insert_pasted(&pasted, text);
                    }
                    return true;
                }
                _ => return false,
            },
            Key::Character(c) if !modifiers.alt && !modifiers.logo => {
                let clean = Self::sanitize_insert(c, self.multiline);
                if clean.is_empty() {
                    return false;
                }
                driver.insert_or_replace_selection(&clean);
            }
            Key::Enter
                if self.multiline && !modifiers.ctrl && !modifiers.alt && !modifiers.logo =>
            {
                driver.insert_or_replace_selection("\n");
            }
            Key::Backspace if modifiers.ctrl => driver.backdelete_word(),
            Key::Backspace => driver.backdelete(),
            Key::Delete if modifiers.ctrl => driver.delete_word(),
            Key::Delete => driver.delete(),
            Key::Left => {
                layout_change = false;
                match (modifiers.ctrl, shift) {
                    (true, true) => driver.select_word_left(),
                    (true, false) => driver.move_word_left(),
                    (false, true) => driver.select_left(),
                    (false, false) => driver.move_left(),
                }
            }
            Key::Right => {
                layout_change = false;
                match (modifiers.ctrl, shift) {
                    (true, true) => driver.select_word_right(),
                    (true, false) => driver.move_word_right(),
                    (false, true) => driver.select_right(),
                    (false, false) => driver.move_right(),
                }
            }
            Key::Up => {
                layout_change = false;
                if shift {
                    driver.select_up();
                } else {
                    driver.move_up();
                }
            }
            Key::Down => {
                layout_change = false;
                if shift {
                    driver.select_down();
                } else {
                    driver.move_down();
                }
            }
            Key::Home => {
                layout_change = false;
                if shift {
                    driver.select_to_line_start();
                } else {
                    driver.move_to_line_start();
                }
            }
            Key::End => {
                layout_change = false;
                if shift {
                    driver.select_to_line_end();
                } else {
                    driver.move_to_line_end();
                }
            }
            _ => return false,
        }
        self.sync_primary(clipboard);
        self.emit_if_edited();
        if layout_change {
            self.dirt.mark_layout();
        } else {
            self.dirt.mark_paint();
        }
        true
    }

    fn on_paste(&mut self, pasted: &str, text: &mut TextContext) {
        self.reset_blink();
        self.insert_pasted(pasted, text);
    }

    fn on_ime(&mut self, ime: &ImeInput, text: &mut TextContext) {
        self.reset_blink();
        let mut driver = self.editor.driver(&mut text.font_cx, &mut text.layout_cx);
        match ime {
            ImeInput::Enabled => return,
            ImeInput::Preedit { text, cursor } => {
                if text.is_empty() {
                    driver.clear_compose();
                } else {
                    driver.set_compose(text, *cursor);
                }
            }
            ImeInput::Commit(text) => {
                let clean = Self::sanitize_insert(text, self.multiline);
                driver.insert_or_replace_selection(&clean);
            }
            ImeInput::Disabled => driver.clear_compose(),
        }
        // Preedit alone leaves the committed content untouched (`text()`
        // excludes it), so the comparison fires only on actual commits.
        self.emit_if_edited();
        self.dirt.mark_layout();
    }

    fn on_pointer(
        &mut self,
        kind: EventKind,
        event: &PointerEvent,
        text: &mut TextContext,
        clipboard: &mut dyn Clipboard,
    ) -> bool {
        let x = (event.local.x - PAD_X) as f32;
        let y = (event.local.y - PAD_Y) as f32;
        // Presses and selection drags move the caret; hover alone must not
        // reset the blink (it would hold the caret solid indefinitely).
        if kind == EventKind::PointerDown || (kind == EventKind::PointerMove && self.drag_selecting)
        {
            self.reset_blink();
        }
        let mut driver = self.editor.driver(&mut text.font_cx, &mut text.layout_cx);
        match kind {
            EventKind::PointerDown => match event.button {
                Some(PointerButton::Left) => {
                    match event.click_count {
                        0 | 1 => {
                            driver.move_to_point(x, y);
                            self.drag_selecting = true;
                        }
                        2 => driver.select_word_at_point(x, y),
                        _ => driver.select_line_at_point(x, y),
                    }
                    drop(driver);
                    if event.click_count >= 2 {
                        // A multi-click selection is complete at the press;
                        // it does not start a drag (extending it would be
                        // character-wise, not word-wise, so it extends not
                        // at all).
                        self.drag_selecting = false;
                        self.sync_primary(clipboard);
                    }
                    self.dirt.mark_paint();
                    true
                }
                Some(PointerButton::Middle) => {
                    // Primary-selection paste, at the click position. The
                    // caret is placed now; a Pending answer inserts there
                    // when it arrives through `on_paste`. Meanwhile the
                    // moved caret still needs painting.
                    driver.move_to_point(x, y);
                    drop(driver);
                    match clipboard.request_text(Selection::Primary) {
                        TextRequest::Ready(Some(pasted)) => self.insert_pasted(&pasted, text),
                        TextRequest::Ready(None) | TextRequest::Pending => self.dirt.mark_paint(),
                    }
                    true
                }
                _ => false,
            },
            EventKind::PointerMove => {
                // Only delivered while captured; selection follows the drag
                // only for a left-button press.
                if self.drag_selecting {
                    driver.extend_selection_to_point(x, y);
                    self.dirt.mark_paint();
                }
                true
            }
            EventKind::PointerUp => {
                drop(driver);
                if self.drag_selecting {
                    self.drag_selecting = false;
                    // The finished drag's selection becomes the primary.
                    self.sync_primary(clipboard);
                }
                true
            }
            _ => false,
        }
    }

    fn ime_cursor_area(&self) -> Option<Rect> {
        Some(Self::to_local(self.editor.ime_cursor_area()))
    }

    fn role(&self) -> accesskit::Role {
        if self.multiline {
            accesskit::Role::MultilineTextInput
        } else {
            accesskit::Role::TextInput
        }
    }

    /// Keep the caret on screen: a height-constrained multi-line field inside
    /// a scroll area would otherwise let the caret walk out of view. Only
    /// while focused — an unfocused field paints no caret to reveal.
    fn reveal_rect(&self) -> Option<Rect> {
        if !self.focused {
            return None;
        }
        self.editor.cursor_geometry(CARET_WIDTH).map(Self::to_local)
    }

    fn accessibility(&self, node: &mut accesskit::Node) {
        node.set_value(self.content());
    }

    fn accessibility_extended(
        &mut self,
        node: &mut accesskit::Node,
        update: &mut accesskit::TreeUpdate,
        next_id: &mut dyn FnMut() -> accesskit::NodeId,
        _origin: Point,
        text: &mut TextContext,
    ) {
        node.set_value(self.content());
        // parley contributes text-run child nodes with character metrics
        // and the live selection, giving AT-SPI its Text interface. Run
        // bounds are widget-relative (accesskit child bounds are relative
        // to the parent node).
        self.editor
            .driver(&mut text.font_cx, &mut text.layout_cx)
            .accessibility(update, node, next_id, PAD_X, PAD_Y, |_, _| {});
    }

    fn take_dirt(&mut self) -> Dirt {
        std::mem::take(&mut self.dirt)
    }

    fn take_emitted(&mut self) -> Vec<EventData> {
        std::mem::take(&mut self.emitted)
    }

    fn type_name(&self) -> &'static str {
        "text-input"
    }

    fn apply_style(&mut self, style: &ComputedStyle) {
        let previous_text = self.tokens.text.clone();
        if self.tokens.read(style) {
            // The text brush lives in the editor's style set, not in the
            // paint call, so a change has to be pushed through.
            if self.tokens.text != previous_text {
                self.editor
                    .edit_styles()
                    .insert(StyleProperty::Brush(self.tokens.text.clone()));
            }
            self.dirt.mark_paint();
        }
        if let Some(size) = style.number("font-size") {
            self.set_font_size(size as f32);
        }
        if let Some(family) = style.string("font-family") {
            self.set_family(family.to_owned());
        }
    }
}

#[cfg(test)]
mod tests;