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;