//! 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, 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, /// 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, /// 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, /// 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) -> 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) -> 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) { 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) { 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>, _available: taffy::Size, ) -> taffy::Size { 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 { 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 { 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 { 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 { 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;