mod keyboard;
pub mod mask;
mod mouse;
pub(crate) mod state;
pub mod validator;
use std::rc::Rc;
use teksilo_i18n::tr_widget;
use teksilo_canvas::{Canvas, Point, Rect, Size, SizeProposal};
use teksilo_core::accessibility::AccessNodeBuilder;
use teksilo_core::build_context::BuildContext;
use teksilo_core::event::{EventResponse, Key};
use teksilo_core::shortcut::KeyStroke;
use teksilo_core::signal::{Prop, Signal};
use teksilo_core::widget::{
CursorIcon, EventContext, LayoutContext, PaintContext, Widget, WidgetPlacement,
};
use teksilo_core::widget_builder::HandlerSet;
use teksilo_core::widget_id::WidgetId;
use teksilo_text::text_document::{SelectionType, TextDocument};
use teksilo_text::{CursorAffinity, CursorDisplay, RichTextEngine, SharedTypesetter};
use teksilo_tokens::TextStyle;
use crate::button::InteractionState;
use crate::keystroke_format::format_keystroke;
use crate::menu_item::MenuItem;
use crate::menu_list::{MenuList, MenuSeparator};
use crate::rich_text::paint::{PaintParams, paint_frame};
pub(crate) use self::state::{CharFilter, CommandFactory};
use self::state::{SharedState, TextInputConfig, TextInputState, sync_cursor_signals};
pub use self::mask::{InputMask, MaskClass, MaskError, MaskPosition};
pub use self::validator::{ValidationFeedback, ValidationOutcome, ValidatorFn};
use crate::common::editor_runtime::CaretPolicy;
const SCROLL_MARGIN: f32 = 4.0;
const DEFAULT_TEXT_HEIGHT: f32 = 20.0;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum InputPurpose {
#[default]
Normal,
Email,
Phone,
Url,
Number,
Search,
}
impl InputPurpose {
pub(crate) fn to_role(self) -> teksilo_core::accesskit::Role {
use teksilo_core::accesskit::Role;
match self {
InputPurpose::Normal => Role::TextInput,
InputPurpose::Email => Role::EmailInput,
InputPurpose::Phone => Role::PhoneNumberInput,
InputPurpose::Url => Role::UrlInput,
InputPurpose::Number => Role::NumberInput,
InputPurpose::Search => Role::SearchInput,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum EchoMode {
#[default]
Masked,
NoEcho,
RevealWhileTyping,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum AtRevealPolicy {
#[default]
SwapRole,
AlwaysProtected,
}
pub struct TextInputField {
text: Signal<String>,
enabled: Prop<bool>,
read_only: bool,
max_length: Option<usize>,
placeholder: String,
on_submit: Option<CommandFactory>,
on_blur: Option<CommandFactory>,
char_filter: Option<CharFilter>,
suffix: Prop<String>,
text_height: Option<f32>,
external_interaction: Option<Signal<InteractionState>>,
mask: Option<InputMask>,
mask_placeholder_override: Option<char>,
validator: Option<ValidatorFn>,
feedback: Signal<ValidationFeedback>,
secure: bool,
echo_mode: EchoMode,
echo_char: char,
revealed: Option<Signal<bool>>,
at_reveal_policy: AtRevealPolicy,
allow_copy: bool,
input_purpose: InputPurpose,
active_descendant: Option<Signal<Option<WidgetId>>>,
controls: Option<Signal<Option<WidgetId>>>,
state: Option<SharedState>,
interaction: Signal<InteractionState>,
caret_position: Signal<usize>,
state_slot: std::rc::Rc<std::cell::RefCell<Option<SharedState>>>,
focus_signal: Signal<bool>,
natural_width: f32,
}
impl std::fmt::Debug for TextInputField {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TextInputField")
.field("placeholder", &self.placeholder)
.field("enabled", &self.enabled.get())
.field("read_only", &self.read_only)
.finish_non_exhaustive()
}
}
impl TextInputField {
pub fn new(text: Signal<String>) -> Self {
Self {
text,
enabled: Prop::Static(true),
read_only: false,
max_length: None,
placeholder: String::new(),
on_submit: None,
on_blur: None,
char_filter: None,
suffix: Prop::Static(String::new()),
text_height: None,
external_interaction: None,
mask: None,
mask_placeholder_override: None,
validator: None,
feedback: Signal::new(ValidationFeedback::Pristine),
secure: false,
echo_mode: EchoMode::Masked,
echo_char: '\u{2022}',
revealed: None,
at_reveal_policy: AtRevealPolicy::SwapRole,
allow_copy: true,
input_purpose: InputPurpose::Normal,
active_descendant: None,
controls: None,
state: None,
interaction: Signal::new(InteractionState::Idle),
caret_position: Signal::new(0),
state_slot: std::rc::Rc::new(std::cell::RefCell::new(None)),
focus_signal: Signal::new(false),
natural_width: 200.0,
}
}
pub fn placeholder(mut self, text: impl Into<String>) -> Self {
self.placeholder = text.into();
self
}
pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
self.enabled = enabled.into();
self
}
pub fn read_only(mut self, read_only: bool) -> Self {
self.read_only = read_only;
self
}
pub fn max_length(mut self, max_length: usize) -> Self {
self.max_length = Some(max_length);
self
}
pub fn on_submit_fn(mut self, f: impl Fn(&mut EventContext) + 'static) -> Self {
self.on_submit = Some(Box::new(f));
self
}
pub fn on_blur_fn(mut self, f: impl Fn(&mut EventContext) + 'static) -> Self {
self.on_blur = Some(Box::new(f));
self
}
pub fn char_filter(mut self, f: impl Fn(char) -> bool + 'static) -> Self {
self.char_filter = Some(Rc::new(f));
self
}
pub fn suffix(mut self, text: impl Into<Prop<String>>) -> Self {
self.suffix = text.into();
self
}
pub fn text_height(mut self, height: f32) -> Self {
self.text_height = Some(height);
self
}
pub fn interaction_signal(mut self, signal: Signal<InteractionState>) -> Self {
self.external_interaction = Some(signal);
self
}
pub fn input_mask(mut self, mask: impl AsRef<str>) -> Self {
match InputMask::parse(mask.as_ref()) {
Ok(m) => self.mask = Some(m),
Err(_) => self.mask = None,
}
self
}
pub fn mask_placeholder(mut self, c: char) -> Self {
self.mask_placeholder_override = Some(c);
self
}
pub fn validator(mut self, f: impl Fn(&str) -> ValidationOutcome + 'static) -> Self {
self.validator = Some(Rc::new(f));
self
}
pub fn secure(mut self, echo_mode: EchoMode) -> Self {
self.secure = true;
self.echo_mode = echo_mode;
self.allow_copy = false;
self
}
pub fn input_purpose(mut self, purpose: InputPurpose) -> Self {
self.input_purpose = purpose;
self
}
pub fn active_descendant(mut self, active: Signal<Option<WidgetId>>) -> Self {
self.active_descendant = Some(active);
self
}
pub fn controls(mut self, listbox: Signal<Option<WidgetId>>) -> Self {
self.controls = Some(listbox);
self
}
pub fn echo_char(mut self, c: char) -> Self {
self.echo_char = c;
self
}
pub fn revealed(mut self, revealed: Signal<bool>) -> Self {
self.revealed = Some(revealed);
self
}
pub fn at_reveal_policy(mut self, policy: AtRevealPolicy) -> Self {
self.at_reveal_policy = policy;
self
}
pub fn allow_copy(mut self, allow: bool) -> Self {
self.allow_copy = allow;
self
}
pub fn validation_feedback_signal(&self) -> Signal<ValidationFeedback> {
self.feedback.clone()
}
pub fn text(&self) -> Signal<String> {
self.text.clone()
}
pub fn share_handle(mut self, handle: &TextFieldHandle) -> Self {
self.state_slot = handle.slot.clone();
self.focus_signal = handle.focus_signal.clone();
self
}
pub fn handle(&self) -> TextFieldHandle {
TextFieldHandle {
slot: self.state_slot.clone(),
focus_signal: self.focus_signal.clone(),
}
}
pub fn interaction(&self) -> Signal<InteractionState> {
self.interaction.clone()
}
pub fn caret_position(&self) -> Signal<usize> {
self.caret_position.clone()
}
pub fn caret_setter(&self) -> std::rc::Rc<dyn Fn(usize)> {
let slot = self.state_slot.clone();
std::rc::Rc::new(move |position: usize| {
if let Some(state) = slot.borrow().as_ref() {
let st = state.borrow();
st.cursor
.set_position(position, teksilo_text::text_document::MoveMode::MoveAnchor);
let actual = st.cursor.position();
if st.cursor_position.get() != actual {
st.cursor_position.set(actual);
}
}
})
}
}
impl Widget for TextInputField {
fn as_any(&self) -> Option<&dyn std::any::Any> {
Some(self)
}
fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
ctx.register_text_surface(std::rc::Rc::new(self.handle()));
if let Some(signal) = self.external_interaction.take() {
self.interaction = signal;
}
let theme_snapshot = ctx.theme_signal().get();
let mask_placeholder_char = self
.mask_placeholder_override
.unwrap_or(crate::styles::recipe_text_input_style::TEXT_FIELD_MASK_PLACEHOLDER_CHAR);
if self.placeholder.is_empty()
&& let Some(ref m) = self.mask
{
self.placeholder = m.empty_template(mask_placeholder_char);
}
if let Some(ref m) = self.mask {
let mut widest = worst_case_template(m);
widest.push('M');
let style = &theme_snapshot.typography.body;
let measured = measure_width_px(ctx, &widest, style);
let slack = style.size;
self.natural_width = measured + slack;
}
if let Some(ref mask) = self.mask {
let mask_for_filter = mask.clone();
let user_filter = self.char_filter.take();
let combined: CharFilter = Rc::new(move |c: char| {
let in_mask_class = mask_for_filter.positions().any(|p| match p {
MaskPosition::Editable { class, .. } => class.accepts(c),
MaskPosition::Fixed(sep) => *sep == c,
});
if !in_mask_class {
return false;
}
match user_filter.as_ref() {
Some(f) => f(c),
None => true,
}
});
self.char_filter = Some(combined);
}
let mut on_submit = self.on_submit.take().map(Rc::new);
let mut on_blur = self.on_blur.take().map(Rc::new);
if let Some(validator) = self.validator.clone() {
let bound_text = self.text.clone();
let feedback = self.feedback.clone();
let prev_on_blur = on_blur.take();
on_blur = Some(Rc::new(Box::new({
let validator = validator.clone();
let feedback = feedback.clone();
let bound_text = bound_text.clone();
move |evt_ctx: &mut EventContext| {
run_validator_and_apply(&validator, &bound_text, &feedback);
if let Some(cb) = prev_on_blur.as_ref() {
cb(evt_ctx);
}
}
}) as CommandFactory));
let prev_on_submit = on_submit.take();
on_submit = Some(Rc::new(Box::new({
let validator = validator.clone();
let feedback = feedback.clone();
let bound_text = bound_text.clone();
move |evt_ctx: &mut EventContext| {
run_validator_and_apply(&validator, &bound_text, &feedback);
if let Some(cb) = prev_on_submit.as_ref() {
cb(evt_ctx);
}
}
}) as CommandFactory));
}
let initial_text = self.text.get();
let read_only_effective = self.read_only || !self.enabled.get();
let initial_suffix = self.suffix.get();
let shared_state = TextInputState::new(TextInputConfig {
initial_text,
max_length: self.max_length,
read_only: read_only_effective,
on_submit,
on_blur,
char_filter: self.char_filter.take(),
placeholder: self.placeholder.clone(),
suffix: initial_suffix,
secure: self.secure,
echo_mode: self.echo_mode,
echo_char: self.echo_char,
revealed: self.revealed.clone(),
at_reveal_policy: self.at_reveal_policy,
allow_copy: self.allow_copy,
focus_signal: self.focus_signal.clone(),
});
self.state = Some(shared_state.clone());
*self.state_slot.borrow_mut() = Some(shared_state.clone());
{
let feedback = self.feedback.clone();
ctx.effect(&self.text, move |_| {
if !matches!(feedback.get(), ValidationFeedback::Pristine) {
feedback.set(ValidationFeedback::Pristine);
}
});
}
{
let inner = shared_state.borrow().cursor_position.clone();
let outer = self.caret_position.clone();
outer.set(inner.get());
ctx.effect(&inner, move |pos| {
if outer.get() != *pos {
outer.set(*pos);
}
});
}
{
let self_id = ctx.self_id();
self.feedback.bind_to(
self_id,
ctx.binding_registry(),
teksilo_core::binding::BindingLevel::AccessibilityOnly,
);
}
for sig in [self.active_descendant.as_ref(), self.controls.as_ref()]
.into_iter()
.flatten()
{
sig.bind_to(
ctx.self_id(),
ctx.binding_registry(),
teksilo_core::binding::BindingLevel::AccessibilityOnly,
);
}
if self.secure
&& let Some(revealed) = self.revealed.clone()
{
let id = ctx.self_id();
let reg = ctx.binding_registry();
revealed.bind_to(id, reg, teksilo_core::binding::BindingLevel::RepaintOnly);
revealed.bind_to(
id,
reg,
teksilo_core::binding::BindingLevel::AccessibilityOnly,
);
}
let text_signal = shared_state.borrow().text_signal.clone();
{
let ext = self.text.clone();
let state_for_sync = shared_state.clone();
ctx.effect(&ext, move |new_text| {
let st = state_for_sync.borrow();
let current = st.document.to_plain_text().unwrap_or_default();
if current != *new_text {
st.cursor.select(SelectionType::Document);
let _ = st.cursor.insert_text(new_text);
if let Some(handle) = &st.frame_request {
handle.set(true);
}
}
});
}
{
let ext = self.text.clone();
ctx.effect(&text_signal, move |new_text| {
if ext.get() != *new_text {
ext.set(new_text.clone());
}
});
}
if self.secure
&& let Some(revealed) = self.revealed.clone()
{
let state_for_reveal = shared_state.clone();
ctx.effect(&revealed, move |_| {
let mut st = state_for_reveal.borrow_mut();
st.needs_full_layout = true;
if let Some(handle) = &st.frame_request {
handle.set(true);
}
});
}
if let Some(shared) = ctx.app_state::<SharedTypesetter>() {
let mut st = self.state().borrow_mut();
let mut engine = RichTextEngine::from_shared(shared.clone());
engine.set_wrap_mode(teksilo_text::WrapMode::None);
st.engine = engine;
st.needs_full_layout = true;
}
let theme_signal = ctx.theme_signal();
{
let theme = theme_signal.get();
let colors = &theme.colors;
let mut st = self.state().borrow_mut();
let tint = field_selection_color(colors, ctx.window_active(), st.has_focus);
st.selection_tint = tint;
st.engine.set_selection_color(tint);
}
{
let state = self.state().clone();
let wa_signal = ctx.window_active_signal();
ctx.effect(&theme_signal, move |theme| {
let colors = &theme.colors;
let mut st = state.borrow_mut();
let tint = field_selection_color(colors, wa_signal.get(), st.has_focus);
st.selection_tint = tint;
st.engine.set_selection_color(tint);
});
}
let text_area_height = self.text_height.unwrap_or(DEFAULT_TEXT_HEIGHT).max(1.0);
let needs_suffix_engine = matches!(self.suffix, Prop::Bound(_)) || {
let st = self.state().borrow();
!st.suffix.is_empty()
};
if needs_suffix_engine {
let mut suffix_engine = if let Some(shared) = ctx.app_state::<SharedTypesetter>() {
RichTextEngine::from_shared(shared.clone())
} else {
RichTextEngine::private_default()
};
suffix_engine.set_wrap_mode(teksilo_text::WrapMode::None);
{
let theme = theme_signal.get();
let secondary = theme.colors.text_secondary.to_array();
suffix_engine.set_text_color(secondary);
suffix_engine.set_cursor_color(secondary);
suffix_engine.set_selection_color([0.0, 0.0, 0.0, 0.0]);
}
suffix_engine.set_viewport(10_000.0, text_area_height);
{
let mut st = self.state().borrow_mut();
st.suffix_engine = Some(suffix_engine);
}
let initial = self.state().borrow().suffix.clone();
relayout_suffix(self.state(), &initial);
}
if let Prop::Bound(signal) = &self.suffix {
let self_id = ctx.self_id();
signal.bind_to(
self_id,
ctx.binding_registry(),
teksilo_core::binding::BindingLevel::Relayout,
);
let state_for_effect = self.state().clone();
ctx.effect(signal, move |new_text| {
relayout_suffix(&state_for_effect, new_text);
});
}
{
let st = self.state().borrow();
let caret_visible = st.caret_visible.clone();
drop(st);
let self_id = ctx.self_id();
caret_visible.bind_to(
self_id,
ctx.binding_registry(),
teksilo_core::binding::BindingLevel::RepaintOnly,
);
}
{
let st = self.state().borrow();
let text_signal = st.text_signal.clone();
drop(st);
let self_id = ctx.self_id();
let registry = ctx.binding_registry();
text_signal.bind_to(
self_id,
registry,
teksilo_core::binding::BindingLevel::RepaintOnly,
);
text_signal.bind_to(
self_id,
registry,
teksilo_core::binding::BindingLevel::AccessibilityOnly,
);
}
{
let mut st = self.state().borrow_mut();
st.frame_request = Some(ctx.frame_request_handle());
st.frame_wake_at = Some(ctx.wake_at_handle());
st.field_widget_id = Some(ctx.self_id());
}
let activation = ctx.activation_signal(ctx.self_id());
if activation.get() {
ctx.request_frame();
}
{
let state = self.state().clone();
let interaction = self.interaction.clone();
ctx.effect(&activation, move |&active| {
if active {
let st = state.borrow();
if let Some(handle) = &st.frame_request {
handle.set(true);
}
return;
}
let mut st = state.borrow_mut();
if st.has_focus {
st.has_focus = false;
st.focus_signal.set(false);
interaction.set(InteractionState::Idle);
}
if st.caret_visible.get() {
st.caret_visible.set(false);
}
st.blink.reset();
});
}
{
let state = self.state().clone();
let active = activation.clone();
let tick_signal = ctx.frame_tick();
ctx.effect(&tick_signal, move |delta| {
if !active.get() {
return;
}
let (more, pending_text) = {
let mut st = state.borrow_mut();
let more = tick(&mut st, *delta);
st.has_selection.set(st.cursor.has_selection());
let pending = st.deferred_text_update.take();
(more, pending)
};
if let Some(text) = pending_text {
let st = state.borrow();
if st.text_signal.get() != text {
st.text_signal.set(text);
}
}
if more {
let st = state.borrow();
if let Some(handle) = &st.frame_request {
handle.set(true);
}
}
});
}
{
let state = self.state().clone();
let active = activation.clone();
let wa_signal = ctx.window_active_signal();
let theme_for_sel = theme_signal.clone();
ctx.effect(&wa_signal, move |&window_active| {
let mut st = state.borrow_mut();
st.window_active = window_active;
let theme = theme_for_sel.get();
let tint = field_selection_color(&theme.colors, window_active, st.has_focus);
st.selection_tint = tint;
st.engine.set_selection_color(tint);
if window_active {
if st.has_focus && !st.caret_visible.get() {
st.caret_visible.set(true);
}
st.blink.reset();
} else {
if st.caret_visible.get() {
st.caret_visible.set(false);
}
st.blink.reset();
}
if active.get()
&& let Some(handle) = &st.frame_request
{
handle.set(true);
}
});
}
let self_id = ctx.self_id();
ctx.enabled_when(self_id, self.enabled.clone());
let hovered = std::rc::Rc::new(std::cell::Cell::new(false));
let hovered_for_focus = hovered.clone();
let hovered_for_hover = hovered.clone();
let state_for_focus = self.state().clone();
let interaction_for_focus = self.interaction.clone();
let theme_for_focus = theme_signal.clone();
let state_for_pointer = self.state().clone();
let state_for_key = self.state().clone();
let state_for_double = self.state().clone();
let state_for_triple = self.state().clone();
let state_for_access = self.state().clone();
let state_for_menu = self.state().clone();
let handlers = HandlerSet::new()
.focusable(true)
.cursor(CursorIcon::Text)
.ime_input(if self.secure {
teksilo_core::ime::ImeContext::password()
} else {
teksilo_core::ime::ImeContext::text()
})
.on_hover(move |entered, _ctx| {
hovered_for_hover.set(entered);
})
.on_focus(move |gained, ctx| {
interaction_for_focus.set(if gained {
InteractionState::Focused
} else {
InteractionState::Idle
});
let mut st = state_for_focus.borrow_mut();
st.has_focus = gained;
st.focus_signal.set(gained);
let sel_theme = theme_for_focus.get();
let tint = field_selection_color(&sel_theme.colors, st.window_active, gained);
st.selection_tint = tint;
st.engine.set_selection_color(tint);
if st.secure && st.echo_mode == EchoMode::RevealWhileTyping {
st.needs_full_layout = true;
}
let mut blur_callback: Option<Rc<CommandFactory>> = None;
if gained {
st.blink.restart();
st.caret_visible.set(true);
let is_keyboard = !hovered_for_focus.get();
drop(st);
if is_keyboard {
let st = state_for_focus.borrow();
st.cursor.select(SelectionType::Document);
drop(st);
sync_cursor_signals(&state_for_focus);
}
keyboard::report_ime_cursor_area(&state_for_focus, ctx);
} else {
st.scroll_x = 0.0;
st.caret_visible.set(false);
st.drag_state = state::DragState::Idle;
st.last_ime_area = None;
blur_callback = st.on_blur.clone();
drop(st);
keyboard::clear_ime_preedit(&state_for_focus);
sync_cursor_signals(&state_for_focus);
}
if let Some(cb) = blur_callback {
cb(ctx);
}
ctx.request_frame();
})
.on_pointer_event(move |event, ctx| {
mouse::handle_pointer_event(&state_for_pointer, event, ctx)
})
.on_key(move |event, ctx| keyboard::handle_key(&state_for_key, event, ctx))
.on_double_tap(move |event, ctx| {
mouse::handle_double_tap(&state_for_double, event.position, ctx)
})
.on_triple_tap(move |event, ctx| {
mouse::handle_triple_tap(&state_for_triple, event.position, ctx)
})
.on_access_action_request(move |action, _target_node, data, ctx| {
handle_access_action(&state_for_access, action, data, ctx)
})
.context_menu(move |position, ctx| {
let _ = ctx;
mouse::reposition_caret_for_context_menu(&state_for_menu, position);
Some(build_context_menu_widget(&state_for_menu))
});
ctx.apply_self_handlers(handlers);
Vec::new()
}
fn layout_response(
&self,
proposal: SizeProposal,
ctx: &LayoutContext,
) -> teksilo_core::widget::LayoutResponse {
let scale = ctx.text_scale;
let w = proposal
.width
.unwrap_or(self.natural_width * scale)
.max(0.0);
let h = (self.text_height.unwrap_or(DEFAULT_TEXT_HEIGHT) * scale).max(0.0);
Size::new(w, h).into()
}
fn place_children(
&self,
bounds: Rect,
_proposal: SizeProposal,
_children: &mut [WidgetPlacement],
_ctx: &LayoutContext,
) {
if let Some(state) = self.state.as_ref() {
state.borrow_mut().sync_viewport(bounds);
}
}
fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
let Some(state) = self.state.as_ref() else {
return;
};
let mut st = state.borrow_mut();
st.apply_font_scale(ctx.text_scale);
st.sync_viewport(bounds);
let text_color = if ctx.effective_enabled {
ctx.theme.colors.text_primary
} else {
ctx.theme.colors.text_disabled
};
st.engine.set_text_color(text_color.to_array());
st.engine.set_cursor_color(text_color.to_array());
let suffix_width = st.suffix_width;
let text_viewport_width = (bounds.width - suffix_width).max(0.0);
st.engine.set_viewport(10_000.0, bounds.height);
if st.needs_full_layout || !st.engine.has_full_layout() {
st.layout_full_masked();
st.needs_full_layout = false;
st.content_dirty = true;
}
let caret_on = st.caret_visible.get() && st.has_focus && st.window_active;
let hide_all = st.echo_mode == EchoMode::NoEcho && st.should_mask();
let (disp_pos, disp_anchor) = if hide_all {
(0, 0)
} else {
(st.cursor.position(), st.cursor.anchor())
};
let cursor_display = CursorDisplay {
position: disp_pos,
anchor: disp_anchor,
affinity: CursorAffinity::Downstream,
visible: caret_on,
selected_cells: Vec::new(),
};
st.engine.set_cursor(&cursor_display);
ensure_caret_visible_h(&mut st, text_viewport_width);
let scroll_x = st.scroll_x;
let text_clip = Rect::new(bounds.x, bounds.y, text_viewport_width, bounds.height);
canvas.set_clip(text_clip);
{
let state_ref: &mut TextInputState = &mut st;
let TextInputState {
ref mut engine,
ref document,
ref mut image_cache,
..
} = *state_ref;
engine.with_render_frame(|frame| {
paint_frame(
canvas,
PaintParams {
frame,
origin: Point::new(bounds.x - scroll_x, bounds.y),
document,
image_cache,
image_resolver: None,
selection: None,
selection_color: [0.0; 4],
selected_image_out: None,
resize_preview: None,
draw_caret: caret_on,
},
);
});
}
if let Some(range) = st.ime_preedit_range.clone()
&& st.engine.has_full_layout()
&& range.start < range.end
{
let start_c = st
.engine
.caret_rect(range.start, CursorAffinity::Downstream);
let end_c = st.engine.caret_rect(range.end, CursorAffinity::Downstream);
let x0 = bounds.x - scroll_x + start_c[0];
let x1 = bounds.x - scroll_x + end_c[0];
let y = bounds.y + start_c[1] + start_c[3] - 1.0;
canvas.draw_line(
Point::new(x0, y),
Point::new(x1, y),
ctx.theme.colors.text_primary,
teksilo_canvas::StrokeStyle::solid(1.0),
);
}
canvas.clear_clip();
if suffix_width > 0.0
&& let Some(suffix_engine) = st.suffix_engine.as_mut()
{
let suffix_color = if ctx.effective_enabled {
ctx.theme.colors.text_secondary
} else {
ctx.theme.colors.text_disabled
};
suffix_engine.set_text_color(suffix_color.to_array());
let suffix_clip = Rect::new(
bounds.x + text_viewport_width,
bounds.y,
suffix_width,
bounds.height,
);
canvas.set_clip(suffix_clip);
let suffix_origin = Point::new(bounds.x + text_viewport_width, bounds.y);
suffix_engine.with_render_frame(|frame| {
paint_suffix_glyphs(canvas, frame, suffix_origin);
});
canvas.clear_clip();
}
}
fn accessibility(&self, builder: &mut AccessNodeBuilder) {
use teksilo_core::accesskit::{Action, Role};
let Some(state) = self.state.as_ref() else {
return;
};
let st = state.borrow();
let text = st.document.to_plain_text().unwrap_or_default();
let explicitly_revealed = st.revealed.as_ref().is_some_and(|s| s.get());
let protected = st.secure
&& match st.at_reveal_policy {
AtRevealPolicy::AlwaysProtected => true,
AtRevealPolicy::SwapRole => !explicitly_revealed,
};
if protected {
builder.set_role(Role::PasswordInput);
if st.echo_mode != EchoMode::NoEcho {
let count = text.chars().count();
if count > 0 {
builder.set_value(st.echo_char.to_string().repeat(count));
}
}
} else {
builder.set_role(self.input_purpose.to_role());
if !text.is_empty() {
builder.set_value(&text);
}
let char_lengths: Vec<u8> = text.chars().map(|c| c.len_utf8() as u8).collect();
let word_starts = compute_word_starts(&text);
let word_starts = (!word_starts.is_empty()).then_some(word_starts);
let run_id =
builder.push_text_run_child_on_self(0, text.clone(), char_lengths, word_starts);
let (anchor, pos) = match st.ime_preedit_range.clone() {
Some(range) => (range.start, range.end),
None => (st.cursor.anchor(), st.cursor.position()),
};
builder.set_text_selection_to((run_id, anchor), (run_id, pos));
}
if !st.placeholder.is_empty() {
builder.set_placeholder(st.placeholder.clone());
}
if st.read_only {
builder.set_read_only();
}
builder.add_action(Action::Focus);
if !st.read_only {
builder.add_action(Action::SetValue);
builder.add_action(Action::ReplaceSelectedText);
}
if !protected {
builder.add_action(Action::SetTextSelection);
}
if self.feedback.get().is_invalid() {
builder
.inner_mut()
.set_invalid(teksilo_core::accesskit::Invalid::True);
}
if let Some(listbox) = self.controls.as_ref().and_then(|s| s.get()) {
builder.push_controlled(teksilo_core::accessibility::widget_id_to_node_id(listbox));
}
if let Some(active) = self.active_descendant.as_ref().and_then(|s| s.get()) {
builder
.inner_mut()
.set_active_descendant(teksilo_core::accessibility::widget_id_to_node_id(active));
}
}
}
impl TextInputField {
fn state(&self) -> &SharedState {
self.state
.as_ref()
.expect("TextInputField::state called before build")
}
}
fn ensure_caret_visible_h(st: &mut TextInputState, text_viewport_width: f32) {
if !st.engine.has_full_layout() || text_viewport_width <= 0.0 {
return;
}
let pos = st.cursor.position();
let caret = st.engine.caret_rect(pos, CursorAffinity::Downstream);
let caret_x = caret[0];
let caret_w = caret[2].max(1.0);
let vw = text_viewport_width;
if caret_x - st.scroll_x < SCROLL_MARGIN {
st.scroll_x = (caret_x - SCROLL_MARGIN).max(0.0);
} else if caret_x + caret_w - st.scroll_x > vw - SCROLL_MARGIN {
st.scroll_x = caret_x + caret_w - vw + SCROLL_MARGIN;
}
}
fn relayout_suffix(state: &SharedState, new_text: &str) {
let mut st = state.borrow_mut();
st.suffix = new_text.to_string();
if new_text.is_empty() {
st.suffix_width = 0.0;
return;
}
let Some(engine) = st.suffix_engine.as_mut() else {
return;
};
let doc = TextDocument::new();
let _ = doc.set_plain_text(new_text);
let flow = doc.snapshot_flow();
engine.layout_full(&flow);
st.suffix_width = engine.max_content_width();
}
fn paint_suffix_glyphs(canvas: &mut Canvas, frame: &teksilo_text::RenderFrame, origin: Point) {
use teksilo_canvas::GlyphQuad as CanvasGlyphQuad;
for g in frame.glyphs.iter() {
let quad = CanvasGlyphQuad {
screen: [
g.screen[0] + origin.x,
g.screen[1] + origin.y,
g.screen[2],
g.screen[3],
],
atlas: g.atlas,
color: g.color,
is_color: g.is_color,
};
canvas.draw_glyph_quad(quad);
}
}
fn field_selection_color(
colors: &teksilo_tokens::ColorTokens,
window_active: bool,
has_focus: bool,
) -> [f32; 4] {
if window_active && has_focus {
colors.selection_bg_active.to_array()
} else {
colors.selection_bg_inactive.to_array()
}
}
fn tick(state: &mut TextInputState, delta: f32) -> bool {
if !state.pending_chars.is_empty() {
let batch = std::mem::take(&mut state.pending_chars);
let _ = state.cursor.insert_text(&batch);
state.pending_text_changed = true;
}
let had_events = state.drain_events();
let caret_active = state.has_focus && state.window_active;
let caret_visible = state.caret_visible.clone();
let wake = state.frame_wake_at.clone();
state.blink.tick(
CaretPolicy::Blinking,
caret_active,
&caret_visible,
wake.as_ref(),
);
if state.needs_full_layout && state.viewport_width > 0.0 {
state.layout_full_masked();
state.needs_full_layout = false;
state.content_dirty = true;
}
if state.pending_text_changed {
let new_text = state.document.to_plain_text().unwrap_or_default();
if state.text_signal.get() != new_text {
state.deferred_text_update = Some(new_text);
}
}
if state.debounce.tick(delta) {
if state.pending_text_changed {
state.pending_text_changed = false;
}
if let Some((cu, cr)) = state.pending_undo_redo.take() {
if state.can_undo.get() != cu {
state.can_undo.set(cu);
}
if state.can_redo.get() != cr {
state.can_redo.set(cr);
}
}
}
let debounce_work = state.pending_text_changed || state.pending_undo_redo.is_some();
had_events || debounce_work
}
fn handle_access_action(
state: &SharedState,
action: teksilo_core::accesskit::Action,
data: Option<teksilo_core::accesskit::ActionData>,
ctx: &mut EventContext,
) -> EventResponse {
use teksilo_core::accesskit::{Action, ActionData};
match (action, data) {
(Action::SetTextSelection, Some(ActionData::SetTextSelection(sel))) => {
let st = state.borrow();
st.cursor.set_position(
sel.anchor.character_index,
teksilo_text::text_document::MoveMode::MoveAnchor,
);
st.cursor.set_position(
sel.focus.character_index,
teksilo_text::text_document::MoveMode::KeepAnchor,
);
drop(st);
sync_cursor_signals(state);
ctx.request_frame();
EventResponse::Handled
}
(Action::SetValue, Some(ActionData::Value(value))) => {
let st = state.borrow();
st.cursor.select(SelectionType::Document);
let _ = st.cursor.insert_text(value.as_ref());
drop(st);
sync_cursor_signals(state);
ctx.request_frame();
EventResponse::Handled
}
(Action::ReplaceSelectedText, Some(ActionData::Value(value))) => {
let st = state.borrow();
let _ = st.cursor.insert_text(value.as_ref());
drop(st);
sync_cursor_signals(state);
ctx.request_frame();
EventResponse::Handled
}
(Action::Focus, _) => {
if let Some(id) = state.borrow().field_widget_id {
ctx.request_focus(id);
}
EventResponse::Handled
}
_ => EventResponse::Ignored,
}
}
fn compute_word_starts(text: &str) -> Vec<u8> {
let mut starts = Vec::new();
let mut in_word = false;
for (char_index, ch) in text.chars().enumerate() {
let is_word_char = ch.is_alphanumeric() || ch == '_';
if is_word_char
&& !in_word
&& let Ok(idx) = u8::try_from(char_index)
{
starts.push(idx);
}
in_word = is_word_char;
}
starts
}
fn build_context_menu_widget(state: &SharedState) -> Box<dyn Widget> {
let st = state.borrow();
let has_selection = st.cursor.has_selection();
let doc_non_empty = !st.document.to_plain_text().unwrap_or_default().is_empty();
let copy_allowed = st.copy_allowed();
drop(st);
let state_cut = state.clone();
let state_copy = state.clone();
let state_paste = state.clone();
let state_select_all = state.clone();
Box::new(
MenuList::new()
.item(
MenuItem::new(tr_widget!(menu_cut()))
.shortcut_label(format_keystroke(KeyStroke::command(Key::X)))
.enabled(has_selection && copy_allowed)
.on_activate_fn(move |ctx| {
{
let mut st = state_cut.borrow_mut();
keyboard::clipboard_cut(&mut st, ctx);
}
sync_cursor_signals(&state_cut);
ctx.request_frame();
}),
)
.item(
MenuItem::new(tr_widget!(menu_copy()))
.shortcut_label(format_keystroke(KeyStroke::command(Key::C)))
.enabled(has_selection && copy_allowed)
.on_activate_fn(move |ctx| {
let mut st = state_copy.borrow_mut();
keyboard::clipboard_copy(&mut st, ctx);
}),
)
.item(
MenuItem::new(tr_widget!(menu_paste()))
.shortcut_label(format_keystroke(KeyStroke::command(Key::V)))
.on_activate_fn(move |ctx| {
{
let mut st = state_paste.borrow_mut();
keyboard::clipboard_paste(&mut st, ctx);
}
sync_cursor_signals(&state_paste);
ctx.request_frame();
}),
)
.item(MenuSeparator)
.item(
MenuItem::new(tr_widget!(menu_select_all()))
.shortcut_label(format_keystroke(KeyStroke::command(Key::A)))
.enabled(doc_non_empty)
.on_activate_fn(move |ctx| {
{
let st = state_select_all.borrow();
st.cursor.select(SelectionType::Document);
}
sync_cursor_signals(&state_select_all);
ctx.request_frame();
}),
),
)
}
fn run_validator_and_apply(
validator: &ValidatorFn,
bound_text: &Signal<String>,
feedback: &Signal<ValidationFeedback>,
) {
let raw = bound_text.get();
match validator(&raw) {
ValidationOutcome::Valid => {
feedback.set(ValidationFeedback::Valid);
}
ValidationOutcome::Corrected { corrected, message } => {
if bound_text.get() != corrected {
bound_text.set(corrected);
}
feedback.set(ValidationFeedback::Corrected {
message,
since: std::time::Instant::now(),
});
}
ValidationOutcome::Invalid { message } => {
feedback.set(ValidationFeedback::Invalid { message });
}
}
}
fn worst_case_template(mask: &InputMask) -> String {
let mut s = String::with_capacity(mask.len());
for pos in mask.positions() {
match pos {
MaskPosition::Editable { class, .. } => {
s.push(match class {
MaskClass::Digit | MaskClass::HexDigit => '0',
MaskClass::Letter | MaskClass::Alphanumeric | MaskClass::Any => 'M',
});
}
MaskPosition::Fixed(c) => s.push(*c),
}
}
s
}
fn measure_width_px(ctx: &mut BuildContext, text: &str, style: &TextStyle) -> f32 {
if text.is_empty() {
return 0.0;
}
if let Some(ts) = ctx.app_state::<SharedTypesetter>() {
let backend = ts.as_text_backend();
let layout = backend.borrow_mut().layout_single_line(text, style, None);
return layout.width;
}
let em = style.size;
text.chars()
.map(|c| match c {
' ' => 0.30,
'_' => 0.45,
':' | '.' | ',' | ';' | '/' | '|' | '!' | 'i' | 'l' | 'I' => 0.30,
'0'..='9' => 0.55,
'M' | 'W' | 'm' | 'w' => 0.85,
'A'..='Z' => 0.65,
'a'..='z' => 0.50,
_ => 0.55,
})
.map(|w: f32| w * em)
.sum()
}
#[cfg(test)]
mod window_active_tests {
use super::*;
use teksilo_canvas::{Point, SizeProposal};
use teksilo_core::event::{Modifiers, PointerButton, WidgetEvent};
use teksilo_core::signal::Signal;
use teksilo_core::widget_tree::WidgetTree;
#[test]
fn field_selection_color_swaps_on_window_active() {
let colors = teksilo_core::presets::intui::light().colors;
assert_eq!(
field_selection_color(&colors, true, true),
colors.selection_bg_active.to_array(),
"active window uses the vivid selection colour"
);
assert_eq!(
field_selection_color(&colors, false, true),
colors.selection_bg_inactive.to_array(),
"inactive window uses the muted selection colour"
);
assert_ne!(
field_selection_color(&colors, true, true),
field_selection_color(&colors, false, true)
);
}
#[test]
fn field_selection_color_dims_when_the_field_is_not_focused() {
let colors = teksilo_core::presets::intui::light().colors;
assert_eq!(
field_selection_color(&colors, true, false),
colors.selection_bg_inactive.to_array(),
"an unfocused field must dim its selection even in an active window"
);
assert_eq!(
field_selection_color(&colors, false, false),
colors.selection_bg_inactive.to_array()
);
}
#[test]
fn a_field_re_tints_its_selection_when_focus_leaves_it() {
let colors = teksilo_core::presets::intui::light().colors;
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
let a = tree.add(TextInputField::new(Signal::new("hello".to_string())));
let b = tree.add(TextInputField::new(Signal::new("world".to_string())));
tree.layout(SizeProposal::exact(200.0, 40.0));
let tint = |tree: &WidgetTree, id| {
tree.widget_as_any(id)
.and_then(|w| w.downcast_ref::<TextInputField>())
.and_then(|f| f.state.as_ref())
.map(|st| st.borrow().selection_tint)
.expect("a built field")
};
tree.focus(a);
assert_eq!(
tint(&tree, a),
colors.selection_bg_active.to_array(),
"the focused field paints its selection live"
);
tree.focus(b);
assert_eq!(
tint(&tree, a),
colors.selection_bg_inactive.to_array(),
"focus moved to another field and the first kept a lit selection"
);
assert_eq!(tint(&tree, b), colors.selection_bg_active.to_array());
}
#[test]
fn caret_hidden_when_window_inactive() {
let text = Signal::new("hello".to_string());
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
let id = tree.add(TextInputField::new(text));
tree.layout(SizeProposal::exact(200.0, 40.0));
let _ = tree.render();
let state = tree
.widget_as_any(id)
.and_then(|a| a.downcast_ref::<TextInputField>())
.map(|f| f.state().clone())
.expect("built TextInputField is reachable via as_any");
let b = tree.bounds(id);
tree.dispatch_event(WidgetEvent::PointerDown {
position: Point::new(b.x + b.width / 2.0, b.y + b.height / 2.0),
button: PointerButton::Primary,
modifiers: Modifiers::NONE,
});
tree.request_frame();
tree.tick_animations(std::time::Duration::from_millis(16));
tree.layout(SizeProposal::exact(200.0, 40.0));
assert!(state.borrow().has_focus, "field took focus");
assert!(state.borrow().window_active);
assert!(
state.borrow().caret_visible.get(),
"caret visible when focused in an active window"
);
tree.set_window_active(false);
assert!(!state.borrow().window_active);
assert!(
!state.borrow().caret_visible.get(),
"caret hidden while the window is inactive"
);
tree.set_window_active(true);
assert!(
state.borrow().caret_visible.get(),
"caret restored on window reactivate"
);
}
}
#[cfg(test)]
mod key_text_bubbling_tests {
use super::*;
use std::cell::Cell;
use teksilo_canvas::{Point, SizeProposal};
use teksilo_core::WidgetBuilder;
use teksilo_core::event::{Modifiers, PointerButton, WidgetEvent};
use teksilo_core::signal::Signal;
use teksilo_core::widget_tree::WidgetTree;
fn outer_handler_sees(key: Key, text: Option<&str>, field: TextInputField) -> bool {
let seen = Rc::new(Cell::new(false));
let seen_for_handler = seen.clone();
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
let outer = tree.add(crate::primitives::VStack::new().child(field).on_key(
move |_ev, _ctx| {
seen_for_handler.set(true);
EventResponse::Handled
},
));
tree.layout(SizeProposal::exact(200.0, 40.0));
let b = tree.bounds(outer);
let centre = Point::new(b.x + b.width / 2.0, b.y + b.height / 2.0);
tree.dispatch_event(WidgetEvent::PointerDown {
position: centre,
button: PointerButton::Primary,
modifiers: Modifiers::NONE,
});
tree.dispatch_event(WidgetEvent::PointerUp {
position: centre,
button: PointerButton::Primary,
modifiers: Modifiers::NONE,
});
let focused = tree.focused().expect("the click focused something");
assert_ne!(
focused, outer,
"focus must land on the field, or nothing below the outer handler is being tested"
);
tree.dispatch_event(WidgetEvent::KeyDown {
key,
modifiers: Modifiers::NONE,
text: text.map(str::to_string),
});
seen.get()
}
#[test]
fn escape_bubbles_out_of_a_field_even_carrying_its_control_text() {
assert!(
outer_handler_sees(
Key::Escape,
Some("\u{1b}"),
TextInputField::new(Signal::new("hello".to_string()))
),
"Escape must reach the widget above the field"
);
}
#[test]
fn escape_bubbles_out_of_a_field_without_text() {
assert!(outer_handler_sees(
Key::Escape,
None,
TextInputField::new(Signal::new("hello".to_string()))
));
}
#[test]
fn a_filter_rejected_character_is_still_swallowed() {
let digits_only = TextInputField::new(Signal::new(String::new()))
.char_filter(|c: char| c.is_ascii_digit());
assert!(
!outer_handler_sees(Key::A, Some("a"), digits_only),
"a rejected letter must not bubble into a shortcut match"
);
}
}
#[derive(Clone)]
pub struct TextFieldHandle {
slot: std::rc::Rc<std::cell::RefCell<Option<SharedState>>>,
focus_signal: Signal<bool>,
}
impl std::fmt::Debug for TextFieldHandle {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TextFieldHandle")
.field("live", &self.slot.borrow().is_some())
.field("focused", &self.focus_signal.get())
.finish()
}
}
impl TextFieldHandle {
pub fn detached() -> Self {
Self {
slot: std::rc::Rc::new(std::cell::RefCell::new(None)),
focus_signal: Signal::new(false),
}
}
pub fn focused_signal(&self) -> Signal<bool> {
self.focus_signal.clone()
}
pub fn is_live(&self) -> bool {
self.slot.borrow().is_some()
}
fn with<R>(&self, f: impl FnOnce(&mut TextInputState) -> R) -> Option<R> {
let slot = self.slot.borrow();
let state = slot.as_ref()?;
let mut st = state.borrow_mut();
Some(f(&mut st))
}
pub fn text(&self) -> String {
self.with(|st| st.document.to_plain_text().unwrap_or_default())
.unwrap_or_default()
}
pub fn has_selection(&self) -> bool {
self.with(|st| st.cursor.has_selection()).unwrap_or(false)
}
pub fn allows_copy(&self) -> bool {
self.with(|st| st.allow_copy).unwrap_or(false)
}
pub fn is_read_only(&self) -> bool {
self.with(|st| st.read_only).unwrap_or(true)
}
pub fn select_all(&self) {
self.with(|st| st.cursor.select(SelectionType::Document));
}
pub fn copy(&self, ctx: &EventContext) {
self.with(|st| keyboard::clipboard_copy(st, ctx));
}
pub fn cut(&self, ctx: &EventContext) {
self.with(|st| keyboard::clipboard_cut(st, ctx));
}
pub fn paste(&self, ctx: &EventContext) {
self.with(|st| keyboard::clipboard_paste(st, ctx));
}
pub fn undo(&self) {
self.with(|st| {
let _ = st.document.undo();
});
}
pub fn redo(&self) {
self.with(|st| {
let _ = st.document.redo();
});
}
pub fn can_undo(&self) -> Signal<bool> {
self.with(|st| st.can_undo.clone())
.unwrap_or_else(|| Signal::new(false))
}
pub fn can_redo(&self) -> Signal<bool> {
self.with(|st| st.can_redo.clone())
.unwrap_or_else(|| Signal::new(false))
}
}
impl teksilo_core::text_surface::TextSurface for TextFieldHandle {
fn can_undo(&self) -> bool {
TextFieldHandle::can_undo(self).get()
}
fn can_redo(&self) -> bool {
TextFieldHandle::can_redo(self).get()
}
fn undo(&self) {
TextFieldHandle::undo(self);
}
fn redo(&self) {
TextFieldHandle::redo(self);
}
fn has_selection(&self) -> bool {
TextFieldHandle::has_selection(self)
}
fn is_read_only(&self) -> bool {
TextFieldHandle::is_read_only(self)
}
fn allows_copy(&self) -> bool {
TextFieldHandle::allows_copy(self)
}
fn cut(&self, ctx: &teksilo_core::widget::EventContext<'_>) {
TextFieldHandle::cut(self, ctx);
}
fn copy(&self, ctx: &teksilo_core::widget::EventContext<'_>) {
TextFieldHandle::copy(self, ctx);
}
fn paste(&self, ctx: &teksilo_core::widget::EventContext<'_>) {
TextFieldHandle::paste(self, ctx);
}
fn paste_plain(&self, ctx: &teksilo_core::widget::EventContext<'_>) {
TextFieldHandle::paste(self, ctx);
}
fn select_all(&self) {
TextFieldHandle::select_all(self);
}
}