use std::rc::Rc;
use std::time::Duration;
use frust_core::accesskit::{Action, CustomAction, Role};
use frust_core::{
AnyView, BoxConstraints, BuildCtx, ChangeFlags, EditCommand, EditingState, EventCtx,
EventResult, FrameTime, ImeContentType, ImeEvent, ImeState, InputEvent, Key, LayoutCtx,
NamedKey, OutsideTap, OverlayBand, OverlayInput, PaintCtx, PaintScene, PointerPhase,
SelectionToolbarActions, SelectionToolbarPolicy, SelectionToolbarRequest, SemanticsCtx,
TOUCH_SLOP, View, Widget, selection_toolbar_builder, selection_toolbar_policy,
};
use frust_text::{
EditOp, EditingStateBytes, TextContext, TextEditor, TextStyle, sanitize_paste, utf16_to_byte,
};
use frust_theme::Theme;
use kurbo::{Point, Rect, Size, Vec2};
use peniko::Color;
use crate::authoring::presses;
use crate::gesture::{DOUBLE_TAP_MS, LONG_PRESS_MS};
use crate::overlay::{OverlayAlign, OverlayAnchor, OverlayPlacement, OverlaySide, OverlaySlot};
const RADIUS: f64 = 6.0;
const BORDER_W: f64 = 1.5;
const PAD_X: f64 = 8.0;
const PAD_Y: f64 = 6.0;
const CARET_W: f32 = 1.5;
const BLINK_MS: f64 = 500.0;
const DEFAULT_WIDTH: f64 = 200.0;
const TOOLBAR_GAP: f64 = 8.0;
const BG: Color = Color::WHITE;
const BORDER: Color = Color::from_rgb8(0xD1, 0xD5, 0xDB);
const ACCENT: Color = Color::from_rgb8(0x3B, 0x82, 0xF6);
const PLACEHOLDER: Color = Color::from_rgb8(0x9C, 0xA3, 0xAF);
const SELECTION: Color = Color::from_rgb8(0xBF, 0xDB, 0xFE);
const CARET: Color = Color::from_rgb8(0x1D, 0x4E, 0xD8);
const SELECTION_ALPHA: f32 = 0.30;
const DISABLED_CONTENT_ALPHA: f32 = 0.38;
const DISABLED_CONTAINER_ALPHA: f32 = 0.12;
const MASK_CHAR: char = '\u{2022}';
struct Chrome {
bg: Color,
border: Color,
accent: Color,
placeholder: Color,
selection: Color,
caret: Color,
}
impl Chrome {
fn resolve(theme: Option<&Theme>, enabled: bool) -> Self {
let mut chrome = match theme {
Some(theme) => {
let s = theme.scheme();
Chrome {
bg: s.surface,
border: s.outline,
accent: s.primary,
placeholder: s.on_surface_variant,
selection: s.primary.with_alpha(SELECTION_ALPHA),
caret: s.primary,
}
}
None => Chrome {
bg: BG,
border: BORDER,
accent: ACCENT,
placeholder: PLACEHOLDER,
selection: SELECTION,
caret: CARET,
},
};
if !enabled {
chrome.border = chrome.border.multiply_alpha(DISABLED_CONTAINER_ALPHA);
chrome.accent = chrome.accent.multiply_alpha(DISABLED_CONTAINER_ALPHA);
chrome.placeholder = chrome.placeholder.multiply_alpha(DISABLED_CONTENT_ALPHA);
chrome.selection = chrome.selection.multiply_alpha(DISABLED_CONTENT_ALPHA);
chrome.caret = chrome.caret.multiply_alpha(DISABLED_CONTENT_ALPHA);
}
chrome
}
}
fn mask_text(text: &str) -> String {
text.chars().map(mask_char).collect()
}
fn mask_char(ch: char) -> char {
if ch == '\n' { '\n' } else { MASK_CHAR }
}
fn real_to_masked(text: &str, byte: usize) -> usize {
let mut masked = 0usize;
for (off, ch) in text.char_indices() {
if byte < off + ch.len_utf8() {
return masked;
}
masked += mask_char(ch).len_utf8();
}
masked
}
fn masked_to_real(text: &str, masked_byte: usize) -> usize {
let mut masked = 0usize;
for (off, ch) in text.char_indices() {
let next = masked + mask_char(ch).len_utf8();
if masked_byte < next {
return off;
}
masked = next;
}
text.len()
}
type OnText<State> = Rc<dyn Fn(&mut State, String)>;
pub struct TextInputView<State: 'static> {
value: String,
placeholder: String,
text_style: TextStyle,
text_style_explicit: bool,
max_visible_lines: Option<usize>,
submit_on_enter: Option<bool>,
enabled: bool,
read_only: bool,
obscured: bool,
pad_x: f64,
pad_y: f64,
border_width: f64,
corner_radius: f64,
caret_width: f32,
focus_ring_width: Option<f64>,
on_change: OnText<State>,
on_submit: Option<OnText<State>>,
}
pub fn text_input<State: 'static, F: Fn(&mut State, String) + 'static>(
value: impl Into<String>,
on_change: F,
) -> TextInputView<State> {
TextInputView {
value: value.into(),
placeholder: String::new(),
text_style: TextStyle::default(),
text_style_explicit: false,
max_visible_lines: None,
submit_on_enter: None,
enabled: true,
read_only: false,
obscured: false,
pad_x: PAD_X,
pad_y: PAD_Y,
border_width: BORDER_W,
corner_radius: RADIUS,
caret_width: CARET_W,
focus_ring_width: None,
on_change: Rc::new(on_change),
on_submit: None,
}
}
#[allow(non_snake_case)]
pub fn TextInput<State: 'static, F: Fn(&mut State, String) + 'static>(
value: impl Into<String>,
on_change: F,
) -> TextInputView<State> {
text_input(value, on_change)
}
impl<State: 'static> TextInputView<State> {
pub fn placeholder(mut self, placeholder: impl Into<String>) -> Self {
self.placeholder = placeholder.into();
self
}
pub fn on_submit<F: Fn(&mut State, String) + 'static>(mut self, on_submit: F) -> Self {
self.on_submit = Some(Rc::new(on_submit));
self
}
pub fn text_style(mut self, text_style: TextStyle) -> Self {
self.text_style = text_style;
self.text_style_explicit = true;
self
}
pub fn multiline(mut self, max_visible_lines: usize) -> Self {
self.max_visible_lines = Some(max_visible_lines.max(1));
self
}
pub fn submit_on_enter(mut self, submit_on_enter: bool) -> Self {
self.submit_on_enter = Some(submit_on_enter);
self
}
pub fn enabled(mut self, enabled: bool) -> Self {
self.enabled = enabled;
self
}
pub fn read_only(mut self, read_only: bool) -> Self {
self.read_only = read_only;
self
}
pub fn obscured(mut self, obscured: bool) -> Self {
self.obscured = obscured;
self
}
pub fn padding(mut self, x: f64, y: f64) -> Self {
self.pad_x = x;
self.pad_y = y;
self
}
pub fn border_width(mut self, width: f64) -> Self {
self.border_width = width;
self
}
pub fn corner_radius(mut self, radius: f64) -> Self {
self.corner_radius = radius;
self
}
pub fn caret_width(mut self, width: f32) -> Self {
self.caret_width = width;
self
}
pub fn focus_ring_width(mut self, width: f64) -> Self {
self.focus_ring_width = Some(width);
self
}
}
const A11Y_CUT_ID: i32 = 1;
const A11Y_COPY_ID: i32 = 2;
const A11Y_PASTE_ID: i32 = 3;
const A11Y_SELECT_ALL_ID: i32 = 4;
#[derive(Clone, Copy)]
struct HoldState {
started_at: Option<FrameTime>,
elapsed: bool,
}
#[derive(Clone, Copy, PartialEq, Debug)]
enum Gesture {
None,
Tap {
at: Point,
},
Drag,
HoldFired {
at: Point,
},
}
impl Gesture {
fn tap_point(self) -> Option<Point> {
match self {
Gesture::Tap { at } => Some(at),
Gesture::None | Gesture::Drag | Gesture::HoldFired { .. } => None,
}
}
}
pub struct TextInputWidget {
editor: TextEditor,
mask_editor: Option<TextEditor>,
text_ctx: TextContext,
style: TextStyle,
text_style_explicit: bool,
applied_style: TextStyle,
placeholder: String,
max_visible_lines: Option<usize>,
applied_wrap_width: Option<Option<f32>>,
submit_on_enter: bool,
enabled: bool,
read_only: bool,
obscured: bool,
release_focus_pending: bool,
focused: bool,
captured: bool,
blink_epoch: FrameTime,
blink_reset_pending: bool,
pad_x: f64,
pad_y: f64,
border_width: f64,
corner_radius: f64,
caret_width: f32,
focus_ring_width: Option<f64>,
toolbar: OverlaySlot<()>,
toolbar_view: Option<AnyView<()>>,
toolbar_view_actions: Option<SelectionToolbarActions>,
toolbar_open: bool,
toolbar_anchor: Rect,
window_size: Size,
gesture: Gesture,
hold: Option<HoldState>,
tap_in_selection: Option<bool>,
outside_press_dismissed: bool,
last_tap: Option<(Point, FrameTime)>,
last_frame_time: FrameTime,
on_change: crate::authoring::ErasedArgCallback<String>,
on_submit: Option<crate::authoring::ErasedArgCallback<String>>,
}
fn new_toolbar_slot() -> OverlaySlot<()> {
let mut slot = OverlaySlot::new();
slot.set_band(OverlayBand::Floating);
slot.set_input(OverlayInput::Interactive);
slot.set_outside_tap(OutsideTap::Notify { consume: false });
slot.set_placement(
OverlayPlacement::on(OverlaySide::Top)
.align(OverlayAlign::Center)
.offset(TOOLBAR_GAP),
);
slot
}
fn inside(pos: Point, size: Size) -> bool {
pos.x >= 0.0 && pos.y >= 0.0 && pos.x < size.width && pos.y < size.height
}
fn answered_while_read_only(named: NamedKey, modifiers: frust_core::Modifiers) -> bool {
match named {
NamedKey::Copy | NamedKey::Cut | NamedKey::Paste | NamedKey::Insert | NamedKey::Escape => {
true
}
NamedKey::Delete => modifiers.shift && !modifiers.ctrl && !modifiers.meta,
NamedKey::Enter
| NamedKey::Backspace
| NamedKey::ArrowLeft
| NamedKey::ArrowRight
| NamedKey::ArrowUp
| NamedKey::ArrowDown
| NamedKey::Home
| NamedKey::End
| NamedKey::Tab => false,
}
}
impl TextInputWidget {
fn focusable(&self) -> bool {
self.enabled
}
fn editable(&self) -> bool {
self.enabled && !self.read_only
}
fn effective_style(&self, theme: Option<&Theme>) -> TextStyle {
let mut style = if self.text_style_explicit {
self.style.clone()
} else {
match theme {
Some(theme) => {
let mut style = self.style.clone();
style.color = theme.scheme().on_surface;
style
}
None => self.style.clone(),
}
};
if !self.enabled {
style.color = style.color.multiply_alpha(DISABLED_CONTENT_ALPHA);
}
style
}
fn display(&self) -> &TextEditor {
self.mask_editor.as_ref().unwrap_or(&self.editor)
}
fn rebuild_mask_editor(&mut self) {
let style = self.applied_style.clone();
self.mask_editor = self.obscured.then(|| TextEditor::new(&style));
self.applied_wrap_width = None;
self.sync_mask();
}
fn sync_mask(&mut self) {
let Some(mask) = self.mask_editor.as_mut() else {
return;
};
let real = self.editor.editing_state_bytes();
let state = EditingStateBytes {
text: mask_text(&real.text),
base: real_to_masked(&real.text, real.base),
extent: real_to_masked(&real.text, real.extent),
composing: real
.composing
.map(|r| real_to_masked(&real.text, r.start)..real_to_masked(&real.text, r.end)),
};
mask.apply(EditOp::ApplyEditingState(state), &mut self.text_ctx);
}
fn apply_style(&mut self, style: TextStyle) {
let state = self.editor.editing_state_bytes();
self.editor = TextEditor::new(&style);
self.editor
.apply(EditOp::ApplyEditingState(state), &mut self.text_ctx);
self.applied_style = style;
self.applied_wrap_width = None;
self.rebuild_mask_editor();
}
fn content_height(&self) -> f64 {
self.display()
.layout_size()
.height
.max(self.style.size as f64 * 1.25)
}
fn text_top(&self, height: f64) -> f64 {
((height - self.content_height()) / 2.0).max(self.pad_y)
}
fn line_height(&self) -> f64 {
let h = self.display().layout_size().height;
let n = self.display().line_count();
if h > 0.0 && n > 0 {
h / n as f64
} else {
self.style.size as f64 * 1.25
}
}
fn scroll_y(&self, field_height: f64) -> f64 {
if self.max_visible_lines.is_none() {
return 0.0;
}
let visible = (field_height - 2.0 * self.pad_y).max(0.0);
let content = self.display().layout_size().height;
if content <= visible {
return 0.0;
}
let max_off = content - visible;
let (y0, y1) = match self.display().cursor_rect(self.caret_width) {
Some(c) => (c.y0, c.y1),
None => (0.0, 0.0),
};
let mut off = if y1 > visible { y1 - visible } else { 0.0 };
off = off.clamp(0.0, max_off);
if y0 < off {
off = y0.clamp(0.0, max_off);
}
off
}
fn content_origin_y(&self, height: f64) -> f64 {
if self.max_visible_lines.is_some() {
self.pad_y - self.scroll_y(height)
} else {
self.text_top(height)
}
}
fn reset_blink(&mut self) {
self.blink_reset_pending = true;
}
fn caret_visible_at(&self, now: FrameTime) -> bool {
let elapsed_ms = now.saturating_sub(self.blink_epoch).as_secs_f64() * 1000.0;
((elapsed_ms / BLINK_MS) as u64).is_multiple_of(2)
}
fn set_controlled_value(&mut self, value: &str) {
let op = EditOp::ApplyEditingState(EditingStateBytes {
text: value.to_string(),
base: value.len(),
extent: value.len(),
composing: None,
});
self.editor.apply(op, &mut self.text_ctx);
self.sync_mask();
}
fn apply_edit(&mut self, ctx: &mut EventCtx, op: EditOp) {
let before = self.editor.text().to_string();
self.editor.apply(op, &mut self.text_ctx);
self.finish_edit(ctx, before);
}
fn finish_edit(&mut self, ctx: &mut EventCtx, before: String) {
self.sync_mask();
self.reset_blink();
let after = self.editor.text().to_string();
if after != before {
(self.on_change)(ctx, after);
self.hide_toolbar(ctx);
}
self.publish(ctx);
ctx.request_redraw();
}
fn current_ime_state(&self, origin: Point, size: Size) -> ImeState {
let es = self.editor.editing_state_utf16();
let editing = EditingState {
text: es.text,
selection_base: es.selection_base,
selection_extent: es.selection_extent,
composing_base: es.composing_base,
composing_extent: es.composing_extent,
};
let offset = origin.to_vec2() + Vec2::new(self.pad_x, self.content_origin_y(size.height));
let caret = self.display().cursor_rect(self.caret_width).map(|c| {
Rect::new(
c.x0 + offset.x,
c.y0 + offset.y,
c.x1 + offset.x,
c.y1 + offset.y,
)
});
ImeState {
active: true,
editing,
caret,
content_type: if self.obscured {
ImeContentType::Password
} else {
ImeContentType::Normal
},
suppress_soft_keyboard: !self.editable(),
}
}
fn publish(&self, ctx: &mut EventCtx) {
ctx.publish_ime_state(self.current_ime_state(ctx.origin(), ctx.size()));
}
fn editor_point(&self, pos: Point, height: f64) -> (f32, f32) {
(
(pos.x - self.pad_x) as f32,
(pos.y - self.content_origin_y(height)) as f32,
)
}
fn apply_display_op(&mut self, ctx: &mut EventCtx, op: EditOp) {
if self.mask_editor.is_none() {
self.apply_edit(ctx, op);
return;
}
let (masked_base, masked_extent) = {
let mask = self
.mask_editor
.as_mut()
.expect("obscured field has a mask editor");
mask.apply(op, &mut self.text_ctx);
let m = mask.editing_state_bytes();
(m.base, m.extent)
};
let real = self.editor.editing_state_bytes();
let before = real.text.clone();
let state = EditingStateBytes {
base: masked_to_real(&real.text, masked_base),
extent: masked_to_real(&real.text, masked_extent),
..real
};
self.editor
.apply(EditOp::ApplyEditingState(state), &mut self.text_ctx);
self.finish_edit(ctx, before);
}
fn move_to_point(&mut self, ctx: &mut EventCtx, x: f32, y: f32, select: bool) {
self.apply_display_op(ctx, EditOp::MoveToPoint { x, y, select });
}
fn select_word_at_point(&mut self, ctx: &mut EventCtx, x: f32, y: f32) {
self.apply_display_op(ctx, EditOp::SelectWordAtPoint { x, y });
}
fn hide_toolbar(&mut self, ctx: &mut EventCtx) {
if self.toolbar_open {
self.toolbar_open = false;
ctx.request_redraw();
}
}
fn clear_gesture(&mut self) {
self.hold = None;
self.gesture = Gesture::None;
self.tap_in_selection = None;
}
fn is_double_tap(&self, pos: Point) -> bool {
self.last_tap.is_some_and(|(prev, at)| {
(pos - prev).hypot() <= TOUCH_SLOP
&& self.last_frame_time.saturating_sub(at).as_secs_f64() * 1000.0 <= DOUBLE_TAP_MS
})
}
fn content_offset(&self, height: f64) -> Vec2 {
Vec2::new(self.pad_x, self.content_origin_y(height))
}
fn point_in_selection(&self, pos: Point, height: f64) -> bool {
let off = self.content_offset(height);
self.display()
.selection_rects()
.iter()
.any(|r| (*r + off).contains(pos))
}
fn selection_anchor(&self, height: f64) -> Rect {
let rects = self.display().selection_rects();
let bounds = rects
.into_iter()
.reduce(|a, b| a.union(b))
.or_else(|| self.display().cursor_rect(self.caret_width))
.unwrap_or(Rect::ZERO);
bounds + self.content_offset(height)
}
fn toolbar_actions(&self) -> SelectionToolbarActions {
let text = self.editor.text();
let selected = self.editor.selected_text();
let has_selection = selected.is_some();
let all_selected = selected.is_some_and(|s| s.len() == text.len());
SelectionToolbarActions {
copy: has_selection && !self.obscured,
cut: has_selection && !self.obscured && self.editable(),
paste: self.editable(),
select_all: !text.is_empty() && !all_selected,
}
}
fn fire_hold(&mut self, ctx: &mut EventCtx) -> bool {
if !self.hold.is_some_and(|hold| hold.elapsed) {
return false;
}
self.hold = None;
self.tap_in_selection = None;
let Some(pos) = self.gesture.tap_point() else {
return false;
};
self.gesture = Gesture::HoldFired { at: pos };
let (x, y) = self.editor_point(pos, ctx.size().height);
self.select_word_at_point(ctx, x, y);
self.toolbar_open = true;
ctx.request_redraw();
true
}
fn sync_toolbar(&mut self, ctx: &mut BuildCtx<'_>) -> ChangeFlags {
let wanted = (self.toolbar_open
&& self.focused
&& self.focusable()
&& selection_toolbar_policy() == SelectionToolbarPolicy::Framework)
.then(|| self.toolbar_actions());
if wanted == self.toolbar_view_actions {
return ChangeFlags::NONE;
}
let next = wanted.and_then(|actions| match selection_toolbar_builder() {
Some(builder) => Some(builder(
&SelectionToolbarRequest {
anchor: self.toolbar_anchor,
actions,
present_menu: true,
},
self.window_size,
)),
None => {
#[cfg(debug_assertions)]
eprintln!(
"frust-widgets: a text field wants a selection toolbar but no builder is \
installed; nothing will float (install one with \
frust_core::set_selection_toolbar_builder)"
);
None
}
});
let prev = self.toolbar_view.take();
let flags = self.toolbar.rebuild(prev.as_ref(), next.as_ref(), ctx);
self.toolbar_view = next;
self.toolbar_view_actions = wanted;
flags
}
fn handle_key(
&mut self,
ctx: &mut EventCtx,
key: &Key,
modifiers: frust_core::Modifiers,
) -> EventResult {
match key {
Key::Character(s) => {
if modifiers.ctrl || modifiers.meta {
if s.eq_ignore_ascii_case("c") {
return self.handle_command(ctx, &EditCommand::Copy);
}
if s.eq_ignore_ascii_case("x") {
return self.handle_command(ctx, &EditCommand::Cut);
}
if s.eq_ignore_ascii_case("v") {
self.request_paste_if_editable(ctx);
return EventResult::Handled;
}
if s.eq_ignore_ascii_case("a") {
return self.handle_command(ctx, &EditCommand::SelectAll);
}
return EventResult::Handled;
}
if !self.editable() {
return EventResult::Ignored;
}
self.apply_edit(ctx, EditOp::Insert(s.clone()));
EventResult::Handled
}
Key::Named(named) => {
if !self.editable() && !answered_while_read_only(*named, modifiers) {
return EventResult::Ignored;
}
let select = modifiers.shift;
match named {
NamedKey::Backspace => self.apply_edit(ctx, EditOp::Backdelete),
NamedKey::Delete => {
if modifiers.shift && !modifiers.ctrl && !modifiers.meta {
return self.handle_command(ctx, &EditCommand::Cut);
}
self.apply_edit(ctx, EditOp::Delete)
}
NamedKey::ArrowLeft => self.apply_edit(ctx, EditOp::MoveLeft { select }),
NamedKey::ArrowRight => self.apply_edit(ctx, EditOp::MoveRight { select }),
NamedKey::Home => self.apply_edit(ctx, EditOp::Home { select }),
NamedKey::End => self.apply_edit(ctx, EditOp::End { select }),
NamedKey::Enter => {
let submit = if self.max_visible_lines.is_some() {
self.submit_on_enter ^ modifiers.shift
} else {
true
};
if submit {
let text = self.editor.text().to_string();
if let Some(cb) = &mut self.on_submit {
cb(ctx, text);
}
ctx.request_redraw();
} else {
self.apply_edit(ctx, EditOp::InsertNewline);
}
}
NamedKey::Escape => {
ctx.release_focus();
self.focused = false;
self.hide_toolbar(ctx);
ctx.request_redraw();
}
NamedKey::ArrowUp => {
if self.max_visible_lines.is_some() {
self.apply_edit(ctx, EditOp::MoveUp { select });
} else {
return EventResult::Ignored;
}
}
NamedKey::ArrowDown => {
if self.max_visible_lines.is_some() {
self.apply_edit(ctx, EditOp::MoveDown { select });
} else {
return EventResult::Ignored;
}
}
NamedKey::Tab => {
return EventResult::Ignored;
}
NamedKey::Copy => return self.handle_command(ctx, &EditCommand::Copy),
NamedKey::Cut => return self.handle_command(ctx, &EditCommand::Cut),
NamedKey::Paste => self.request_paste_if_editable(ctx),
NamedKey::Insert => {
if modifiers.shift {
self.request_paste_if_editable(ctx);
} else if modifiers.ctrl {
return self.handle_command(ctx, &EditCommand::Copy);
} else {
return EventResult::Ignored;
}
}
}
EventResult::Handled
}
}
}
fn request_paste_if_editable(&self, ctx: &mut EventCtx) {
if self.editable() {
ctx.request_paste();
}
}
fn clipboard_selection(&self) -> Option<String> {
if self.obscured {
return None;
}
self.editor.selected_text().map(str::to_owned)
}
fn handle_command(&mut self, ctx: &mut EventCtx, cmd: &EditCommand) -> EventResult {
debug_assert!(self.focusable(), "an EditCommand reached a disabled field");
let before = self.editor.text().to_string();
match cmd {
EditCommand::Copy => {
if let Some(text) = self.clipboard_selection() {
ctx.write_clipboard(text);
}
}
EditCommand::Cut => {
if self.editable()
&& let Some(text) = self.clipboard_selection()
{
ctx.write_clipboard(text);
self.editor.apply(EditOp::Backdelete, &mut self.text_ctx);
}
}
EditCommand::Paste(text) => {
let text = sanitize_paste(text, self.max_visible_lines.is_none());
if self.editable() && !text.is_empty() {
self.editor
.apply(EditOp::Insert(text.into_owned()), &mut self.text_ctx);
}
}
EditCommand::SelectAll => {
self.editor.apply(EditOp::SelectAll, &mut self.text_ctx);
}
}
self.finish_edit(ctx, before);
self.hide_toolbar(ctx);
EventResult::Handled
}
fn handle_ime(&mut self, ctx: &mut EventCtx, event: &ImeEvent) -> EventResult {
match event {
ImeEvent::Compose { text, .. } if text.is_empty() => {
self.apply_edit(ctx, EditOp::ClearCompose);
EventResult::Handled
}
ImeEvent::Compose { text, cursor } => {
self.apply_edit(
ctx,
EditOp::Compose {
text: text.clone(),
cursor: *cursor,
},
);
EventResult::Handled
}
ImeEvent::Commit(s) => {
if s == "\n" || s == "\r" || s == "\r\n" {
if self.max_visible_lines.is_some() && !self.submit_on_enter {
self.apply_edit(ctx, EditOp::InsertNewline);
return EventResult::Handled;
}
let text = self.editor.text().to_string();
if let Some(cb) = &mut self.on_submit {
cb(ctx, text);
}
ctx.request_redraw();
return EventResult::Handled;
}
if s.is_empty() {
self.apply_edit(ctx, EditOp::ClearCompose);
return EventResult::Handled;
}
let before = self.editor.text().to_string();
self.editor.apply(
EditOp::Compose {
text: s.clone(),
cursor: None,
},
&mut self.text_ctx,
);
self.editor.apply(EditOp::FinishCompose, &mut self.text_ctx);
self.finish_edit(ctx, before);
EventResult::Handled
}
ImeEvent::ApplyEditingState(state) => {
self.apply_edit(ctx, editing_state_to_op(state));
EventResult::Handled
}
ImeEvent::Enabled | ImeEvent::Disabled => EventResult::Handled,
}
}
}
fn resolve_submit_on_enter(
max_visible_lines: Option<usize>,
submit_on_enter: Option<bool>,
) -> bool {
submit_on_enter.unwrap_or(max_visible_lines.is_none())
}
fn editing_state_to_op(state: &EditingState) -> EditOp {
let text = &state.text;
let base = utf16_to_byte(text, state.selection_base.max(0) as usize);
let extent = utf16_to_byte(text, state.selection_extent.max(0) as usize);
let composing = if state.composing_base >= 0 && state.composing_extent >= 0 {
Some(
utf16_to_byte(text, state.composing_base as usize)
..utf16_to_byte(text, state.composing_extent as usize),
)
} else {
None
};
EditOp::ApplyEditingState(EditingStateBytes {
text: text.clone(),
base,
extent,
composing,
})
}
impl<State: 'static> View<State> for TextInputView<State> {
type Element = TextInputWidget;
fn build(&self, _ctx: &mut BuildCtx<'_>) -> TextInputWidget {
let style = self.text_style.clone();
let mut text_ctx = TextContext::new();
let mut editor = TextEditor::new(&style);
editor.apply(
EditOp::ApplyEditingState(EditingStateBytes {
text: self.value.clone(),
base: self.value.len(),
extent: self.value.len(),
composing: None,
}),
&mut text_ctx,
);
let mut widget = TextInputWidget {
editor,
mask_editor: None,
text_ctx,
style: style.clone(),
text_style_explicit: self.text_style_explicit,
applied_style: style,
placeholder: self.placeholder.clone(),
max_visible_lines: self.max_visible_lines,
applied_wrap_width: None,
submit_on_enter: resolve_submit_on_enter(self.max_visible_lines, self.submit_on_enter),
enabled: self.enabled,
read_only: self.read_only,
obscured: self.obscured,
release_focus_pending: false,
focused: false,
captured: false,
blink_epoch: FrameTime::ZERO,
blink_reset_pending: true,
pad_x: self.pad_x,
pad_y: self.pad_y,
border_width: self.border_width,
corner_radius: self.corner_radius,
caret_width: self.caret_width,
focus_ring_width: self.focus_ring_width,
toolbar: new_toolbar_slot(),
toolbar_view: None,
toolbar_view_actions: None,
toolbar_open: false,
toolbar_anchor: Rect::ZERO,
window_size: Size::ZERO,
gesture: Gesture::None,
hold: None,
tap_in_selection: None,
outside_press_dismissed: false,
last_tap: None,
last_frame_time: FrameTime::ZERO,
on_change: crate::authoring::erase_callback_arg(&self.on_change),
on_submit: self
.on_submit
.as_ref()
.map(crate::authoring::erase_callback_arg::<State, String>),
};
widget.rebuild_mask_editor();
widget
}
fn rebuild(
&self,
prev: &Self,
element: &mut TextInputWidget,
ctx: &mut BuildCtx<'_>,
) -> ChangeFlags {
element.on_change = crate::authoring::erase_callback_arg(&self.on_change);
element.on_submit = self
.on_submit
.as_ref()
.map(crate::authoring::erase_callback_arg::<State, String>);
let mut flags = ChangeFlags::NONE;
if prev.placeholder != self.placeholder {
element.placeholder = self.placeholder.clone();
flags |= ChangeFlags::PAINT;
}
if prev.max_visible_lines != self.max_visible_lines {
element.max_visible_lines = self.max_visible_lines;
flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
}
element.submit_on_enter =
resolve_submit_on_enter(self.max_visible_lines, self.submit_on_enter);
if prev.enabled != self.enabled {
element.enabled = self.enabled;
if !self.enabled {
element.release_focus_pending = element.focused;
element.focused = false;
element.captured = false;
element.clear_gesture();
element.toolbar_open = false;
}
flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
}
if prev.read_only != self.read_only {
element.read_only = self.read_only;
flags |= ChangeFlags::PAINT;
}
if prev.obscured != self.obscured {
element.obscured = self.obscured;
element.rebuild_mask_editor();
flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
}
if prev.text_style != self.text_style
|| prev.text_style_explicit != self.text_style_explicit
{
element.style = self.text_style.clone();
element.text_style_explicit = self.text_style_explicit;
flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
}
if self.value != element.editor.text() {
element.set_controlled_value(&self.value);
flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
}
if prev.pad_x != self.pad_x || prev.pad_y != self.pad_y {
element.pad_x = self.pad_x;
element.pad_y = self.pad_y;
flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
}
if prev.border_width != self.border_width {
element.border_width = self.border_width;
flags |= ChangeFlags::PAINT;
}
if prev.corner_radius != self.corner_radius {
element.corner_radius = self.corner_radius;
flags |= ChangeFlags::PAINT;
}
if prev.caret_width != self.caret_width {
element.caret_width = self.caret_width;
flags |= ChangeFlags::PAINT;
}
if prev.focus_ring_width != self.focus_ring_width {
element.focus_ring_width = self.focus_ring_width;
flags |= ChangeFlags::PAINT;
}
flags |= element.sync_toolbar(ctx);
flags
}
fn teardown(&self, element: &mut TextInputWidget, ctx: &mut BuildCtx<'_>) {
let prev = element.toolbar_view.take();
element.toolbar.rebuild(prev.as_ref(), None, ctx);
element.toolbar_view_actions = None;
}
}
impl Widget for TextInputWidget {
fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
let fonts_changed = self.text_ctx.sync_app_fonts();
let effective = self.effective_style(Theme::from_layout_ctx(ctx));
if fonts_changed || effective != self.applied_style {
self.apply_style(effective);
}
self.sync_mask();
let width = if bc.max().width.is_finite() {
bc.max().width
} else {
DEFAULT_WIDTH
};
let desired_wrap: Option<f32> = self
.max_visible_lines
.map(|_| (width - 2.0 * self.pad_x).max(0.0) as f32);
if self.applied_wrap_width != Some(desired_wrap) {
self.editor.set_wrap_width(desired_wrap, &mut self.text_ctx);
if let Some(mask) = self.mask_editor.as_mut() {
mask.set_wrap_width(desired_wrap, &mut self.text_ctx);
}
self.applied_wrap_width = Some(desired_wrap);
}
let height = match self.max_visible_lines {
Some(max_lines) => {
let line_h = self.line_height();
let content = self.display().layout_size().height.max(line_h);
let capped = content.min(line_h * max_lines as f64);
capped + 2.0 * self.pad_y
}
None => self.content_height() + 2.0 * self.pad_y,
};
self.window_size = ctx.window_size();
self.toolbar.layout(ctx);
bc.constrain(Size::new(width, height))
}
fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
let origin = ctx.origin();
let size = ctx.size();
let theme = Theme::from_paint_ctx(ctx);
let reduce_motion = theme.map(|t| t.motion.reduce_motion).unwrap_or(false);
let chrome = Chrome::resolve(theme, self.enabled);
let now = ctx.frame_time();
if self.blink_reset_pending {
self.blink_epoch = now;
self.blink_reset_pending = false;
}
self.last_frame_time = now;
if self.captured
&& let Some(hold) = self.hold.as_mut()
{
let start = *hold.started_at.get_or_insert(now);
if !hold.elapsed {
let held_ms = now.saturating_sub(start).as_secs_f64() * 1000.0;
if held_ms >= LONG_PRESS_MS {
hold.elapsed = true;
frust_core::mark_pending_result_flush();
}
ctx.request_frame();
}
}
if reduce_motion
&& let Some((_, at)) = self.last_tap
&& now.saturating_sub(at).as_secs_f64() * 1000.0 <= DOUBLE_TAP_MS
{
ctx.request_frame();
}
let focused = ctx.has_focus() && self.focusable();
if self.focused && !focused {
self.focused = false;
self.toolbar_open = false;
self.outside_press_dismissed = false;
}
let border_color = if focused {
chrome.accent
} else {
chrome.border
};
let border_w = if focused {
self.focus_ring_width.unwrap_or(self.border_width)
} else {
self.border_width
};
scene.fill_rounded_rect(origin, size, self.corner_radius, border_color);
scene.fill_rounded_rect(
Point::new(origin.x + border_w, origin.y + border_w),
Size::new(
(size.width - 2.0 * border_w).max(0.0),
(size.height - 2.0 * border_w).max(0.0),
),
(self.corner_radius - border_w).max(0.0),
chrome.bg,
);
let text_origin = Point::new(
origin.x + self.pad_x,
origin.y + self.content_origin_y(size.height),
);
let clip_content = self.max_visible_lines.is_some();
if clip_content {
scene.push_clip(
Point::new(origin.x + self.border_width, origin.y + self.pad_y),
Size::new(
(size.width - 2.0 * self.border_width).max(0.0),
(size.height - 2.0 * self.pad_y).max(0.0),
),
);
}
if self.editor.text().is_empty() && !focused {
if !self.placeholder.is_empty() {
let mut ph_style = self.style.clone();
ph_style.color = chrome.placeholder;
let layout = self.text_ctx.layout(&self.placeholder, &ph_style, None);
for run in layout.to_scene_runs(text_origin) {
scene.draw_glyph_run(run);
}
}
} else {
let off = text_origin.to_vec2();
if focused {
for r in self.display().selection_rects() {
scene.fill_rect(
Point::new(r.x0 + off.x, r.y0 + off.y),
Size::new(r.width(), r.height()),
chrome.selection,
);
}
}
for run in self.display().to_scene_runs(text_origin) {
scene.draw_glyph_run(run);
}
}
if focused {
let blinks = self.editable() && !reduce_motion;
if blinks {
ctx.request_frame_paced_at(Duration::from_millis(BLINK_MS as u64));
}
ctx.publish_ime_state(self.current_ime_state(origin, size));
let caret_visible = !blinks || self.caret_visible_at(now);
if caret_visible && let Some(c) = self.display().cursor_rect(self.caret_width) {
let off = text_origin.to_vec2();
scene.fill_rect(
Point::new(c.x0 + off.x, c.y0 + off.y),
Size::new(c.width(), c.height()),
chrome.caret,
);
}
} else if !self.focusable() && ctx.has_focus() {
let mut ime = self.current_ime_state(origin, size);
ime.active = false;
ime.caret = None;
ctx.publish_ime_state(ime);
}
if clip_content {
scene.pop_clip();
}
if focused {
let local_anchor = self.selection_anchor(size.height);
self.toolbar_anchor = local_anchor + origin.to_vec2();
ctx.publish_selection_toolbar(SelectionToolbarRequest {
anchor: self.toolbar_anchor,
actions: self.toolbar_actions(),
present_menu: self.toolbar_open,
});
if self.toolbar_open && selection_toolbar_policy() == SelectionToolbarPolicy::Framework
{
self.toolbar.set_anchor(OverlayAnchor::Rect(local_anchor));
self.toolbar.paint(ctx, size);
}
}
}
fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
if !self.focusable() {
if ctx.has_focus() || self.release_focus_pending {
ctx.release_focus();
self.release_focus_pending = false;
self.focused = false;
self.captured = false;
ctx.request_redraw();
}
return EventResult::Ignored;
}
if let Some(result) = self.toolbar.event(ctx, event, &mut ()) {
let commands = ctx.take_edit_commands();
if !commands.is_empty() && ctx.has_focus() {
self.focused = true;
self.last_tap = None;
for cmd in &commands {
self.handle_command(ctx, cmd);
}
if ctx.has_focus() {
ctx.request_focus();
}
}
if self.toolbar.take_outside_down() {
self.outside_press_dismissed = self.toolbar_open;
self.hide_toolbar(ctx);
}
return result;
}
match event {
InputEvent::Pointer(p) => match p.phase {
PointerPhase::Down => {
if inside(p.position, ctx.size()) {
if !presses(p) {
ctx.request_focus();
self.focused = true;
self.toolbar_open = !self.toolbar_open;
self.clear_gesture();
self.last_tap = None;
self.reset_blink();
self.publish(ctx);
ctx.request_redraw();
return EventResult::Handled;
}
ctx.request_focus();
ctx.capture_pointer();
self.focused = true;
self.captured = true;
let was_open =
self.toolbar_open || std::mem::take(&mut self.outside_press_dismissed);
self.toolbar_open = false;
self.gesture = Gesture::Tap { at: p.position };
self.hold = None;
self.tap_in_selection = None;
let (x, y) = self.editor_point(p.position, ctx.size().height);
if self.is_double_tap(p.position) {
self.last_tap = None;
self.select_word_at_point(ctx, x, y);
return EventResult::Handled;
}
self.last_tap = None;
self.hold = Some(HoldState {
started_at: None,
elapsed: false,
});
if self.point_in_selection(p.position, ctx.size().height) {
self.tap_in_selection = Some(was_open);
ctx.request_redraw();
return EventResult::Handled;
}
self.move_to_point(ctx, x, y, false);
EventResult::Handled
} else {
if self.focused {
ctx.release_focus();
self.focused = false;
self.hide_toolbar(ctx);
ctx.request_redraw();
}
self.clear_gesture();
self.last_tap = None;
self.outside_press_dismissed = false;
EventResult::Ignored
}
}
PointerPhase::Move => {
if !self.captured {
return EventResult::Ignored;
}
match self.gesture {
Gesture::Tap { at } => {
if (p.position - at).hypot() <= TOUCH_SLOP {
self.fire_hold(ctx);
return EventResult::Handled;
}
self.hold = None;
self.tap_in_selection = None;
self.gesture = Gesture::Drag;
}
Gesture::HoldFired { at } => {
if (p.position - at).hypot() <= TOUCH_SLOP {
return EventResult::Handled;
}
self.gesture = Gesture::Drag;
}
Gesture::Drag | Gesture::None => {}
}
let (x, y) = self.editor_point(p.position, ctx.size().height);
self.move_to_point(ctx, x, y, true);
EventResult::Handled
}
PointerPhase::Up => {
if !self.captured {
return EventResult::Ignored;
}
self.captured = false;
if !self.fire_hold(ctx) {
if let Some(was_open) = self.tap_in_selection.take()
&& self.gesture.tap_point().is_some()
{
self.toolbar_open = !was_open;
}
if let Some(down) = self.gesture.tap_point() {
self.last_tap = Some((down, self.last_frame_time));
}
}
self.clear_gesture();
ctx.request_redraw();
EventResult::Handled
}
PointerPhase::Cancel => {
if !self.captured {
return EventResult::Ignored;
}
self.captured = false;
self.clear_gesture();
self.last_tap = None;
ctx.request_redraw();
EventResult::Handled
}
},
InputEvent::Key(k) => {
if !ctx.has_focus() {
return EventResult::Ignored;
}
self.focused = true;
self.last_tap = None;
self.handle_key(ctx, &k.key, k.modifiers)
}
InputEvent::Ime(e) => {
if !ctx.has_focus() {
return EventResult::Ignored;
}
if !self.editable() {
return EventResult::Ignored;
}
self.focused = true;
self.last_tap = None;
self.handle_ime(ctx, e)
}
InputEvent::EditCommand(cmd) => {
if !ctx.has_focus() {
return EventResult::Ignored;
}
self.focused = true;
self.last_tap = None;
self.handle_command(ctx, cmd)
}
InputEvent::Scroll { .. } => {
self.hide_toolbar(ctx);
self.last_tap = None;
EventResult::Ignored
}
InputEvent::Housekeeping => {
self.fire_hold(ctx);
EventResult::Ignored
}
InputEvent::Overlay(_) => EventResult::Ignored,
_ => EventResult::Ignored,
}
}
fn semantics(&self, ctx: &mut SemanticsCtx) {
let role = if self.obscured {
Role::PasswordInput
} else {
Role::TextInput
};
let value = if self.obscured {
mask_text(self.editor.text())
} else {
self.editor.text().to_string()
};
let verbs = self.toolbar_actions();
let offered: Vec<CustomAction> = [
(A11Y_CUT_ID, "Cut", verbs.cut),
(A11Y_COPY_ID, "Copy", verbs.copy),
(A11Y_PASTE_ID, "Paste", verbs.paste),
(A11Y_SELECT_ALL_ID, "Select all", verbs.select_all),
]
.into_iter()
.filter(|(_, _, enabled)| *enabled && self.focusable())
.map(|(id, description, _)| CustomAction {
id,
description: description.into(),
})
.collect();
let id = ctx.push_node(role, |node| {
node.set_value(value);
if !self.enabled {
node.set_disabled();
} else if self.read_only {
node.set_read_only();
}
if !offered.is_empty() {
node.add_action(Action::CustomAction);
node.set_custom_actions(offered);
}
});
if self.focused {
ctx.set_focused(id);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use frust_core::{
FrameTime, KeyEvent, Modifiers, PointerButton, PointerEvent, RenderRoot, ScrollDelta,
SelectionToolbarBuilder, set_selection_toolbar_builder, set_selection_toolbar_policy,
};
use std::any::Any;
use std::sync::{Arc, Mutex};
#[derive(Default)]
struct AppState {
value: String,
changes: u32,
submits: u32,
last_submit: String,
reject: bool,
}
fn build(state: &mut AppState) -> TextInputView<AppState> {
text_input(state.value.clone(), |s: &mut AppState, v: String| {
s.changes += 1;
if !s.reject {
s.value = v;
}
})
.placeholder("type here")
.on_submit(|s: &mut AppState, v: String| {
s.submits += 1;
s.last_submit = v;
})
}
fn harness(state: &mut AppState) -> RenderRoot<AppState, TextInputView<AppState>> {
let mut root = RenderRoot::new();
root.rebuild(&mut build, state);
root.layout(Size::new(300.0, 200.0));
root
}
fn widget(root: &RenderRoot<AppState, TextInputView<AppState>>) -> &TextInputWidget {
let id = root.root_id().expect("root built");
let w = root.tree().pod(id).expect("root pod").widget();
(w as &dyn Any)
.downcast_ref::<TextInputWidget>()
.expect("root is a TextInputWidget")
}
fn pointer(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
InputEvent::Pointer(PointerEvent {
phase,
position: Point::new(x, y),
button: PointerButton::Primary,
})
}
fn secondary_pointer(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
InputEvent::Pointer(PointerEvent {
phase,
position: Point::new(x, y),
button: PointerButton::Secondary,
})
}
fn ch(text: &str) -> InputEvent {
InputEvent::Key(KeyEvent {
key: Key::Character(text.to_string()),
modifiers: Modifiers::default(),
repeat: false,
})
}
fn named(key: NamedKey, modifiers: Modifiers) -> InputEvent {
InputEvent::Key(KeyEvent {
key: Key::Named(key),
modifiers,
repeat: false,
})
}
#[test]
fn tap_focuses_and_publishes_ime_state() {
let mut state = AppState::default();
let mut root = harness(&mut state);
assert!(!root.is_focus_active());
assert!(root.ime_state().is_none());
root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
assert!(root.is_focus_active(), "tap inside focuses the field");
assert!(widget(&root).focused);
let ime = root.ime_state().expect("focus publishes an IME surface");
assert!(ime.active);
assert!(ime.caret.is_some(), "an IME surface carries a caret rect");
}
#[test]
fn a_secondary_press_focuses_opens_the_toolbar_and_moves_no_caret() {
let mut state = AppState::default();
let mut root = harness(&mut state);
root.event(
&mut state,
&secondary_pointer(PointerPhase::Down, 10.0, 10.0),
);
assert!(root.is_focus_active(), "a context press claims focus");
assert!(widget(&root).focused);
assert!(
root.ime_state().is_some_and(|ime| ime.active),
"and publishes the session's IME surface like any other focus claim"
);
assert!(widget(&root).toolbar_open, "and opens the toolbar");
root.event(
&mut state,
&secondary_pointer(PointerPhase::Down, 10.0, 10.0),
);
assert!(
!widget(&root).toolbar_open,
"a second context press closes it"
);
assert!(root.is_focus_active(), "and never blurs the field");
root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
root.event(&mut state, &pointer(PointerPhase::Up, 10.0, 10.0));
for c in ["a", "b", "c"] {
root.event(&mut state, &ch(c));
}
let caret = widget(&root).editor.editing_state_bytes().extent;
assert_eq!(caret, 3, "the caret sits after the typed text");
let outcome = root.event(
&mut state,
&secondary_pointer(PointerPhase::Down, 1.0, 10.0),
);
assert!(outcome.handled, "the field still swallows the press");
assert_eq!(
widget(&root).editor.editing_state_bytes().extent,
caret,
"a right-click places no caret"
);
assert!(!widget(&root).captured, "and starts no selection drag");
assert!(root.is_focus_active(), "the typing session survives it");
assert!(widget(&root).focused);
assert!(widget(&root).toolbar_open, "and it opens the toolbar");
}
#[test]
fn typing_inserts_and_fires_on_change() {
let mut state = AppState::default();
let mut root = harness(&mut state);
root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
root.event(&mut state, &ch("h"));
root.event(&mut state, &ch("i"));
assert_eq!(state.value, "hi", "on_change fed each char into app state");
assert_eq!(state.changes, 2);
assert_eq!(widget(&root).editor.text(), "hi");
}
#[test]
fn keys_ignored_while_unfocused() {
let mut state = AppState::default();
let mut root = harness(&mut state);
let outcome = root.event(&mut state, &ch("x"));
assert!(!outcome.handled);
assert_eq!(state.changes, 0);
assert_eq!(widget(&root).editor.text(), "");
}
#[test]
fn backspace_over_emoji_removes_grapheme() {
let mut state = AppState::default();
let mut root = harness(&mut state);
root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
root.event(&mut state, &ch("a"));
root.event(&mut state, &ch("\u{1F600}")); assert_eq!(widget(&root).editor.text(), "a\u{1F600}");
root.event(
&mut state,
&named(NamedKey::Backspace, Modifiers::default()),
);
assert_eq!(widget(&root).editor.text(), "a");
assert_eq!(state.value, "a");
}
#[test]
fn arrows_with_shift_extend_selection() {
let mut state = AppState::default();
let mut root = harness(&mut state);
root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
for c in ["a", "b", "c"] {
root.event(&mut state, &ch(c));
}
let changes_before = state.changes;
let shift = Modifiers {
shift: true,
..Modifiers::default()
};
root.event(&mut state, &named(NamedKey::ArrowLeft, shift));
let es = widget(&root).editor.editing_state_bytes();
assert_ne!(es.base, es.extent, "shift+arrow extends the selection");
assert_eq!(
state.changes, changes_before,
"a selection-only move does not fire on_change"
);
}
#[test]
fn enter_fires_on_submit_once_and_keeps_focus() {
let mut state = AppState::default();
let mut root = harness(&mut state);
root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
root.event(&mut state, &ch("h"));
root.event(&mut state, &ch("i"));
root.event(&mut state, &named(NamedKey::Enter, Modifiers::default()));
assert_eq!(state.submits, 1, "Enter fires on_submit exactly once");
assert_eq!(state.last_submit, "hi");
assert!(root.is_focus_active(), "submit keeps focus");
}
#[test]
fn newline_commit_submits_instead_of_inserting() {
let mut state = AppState::default();
let mut root = harness(&mut state);
root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
root.event(&mut state, &ch("h"));
root.event(&mut state, &ch("i"));
root.event(
&mut state,
&InputEvent::Ime(ImeEvent::Commit("\n".to_string())),
);
assert_eq!(state.submits, 1, "newline commit fires on_submit once");
assert_eq!(state.last_submit, "hi");
assert!(
!widget(&root).editor.text().contains('\n'),
"no literal newline lands in the single-line field"
);
assert!(root.is_focus_active(), "submit keeps focus");
root.event(
&mut state,
&InputEvent::Ime(ImeEvent::Commit("\r\n".to_string())),
);
assert_eq!(state.submits, 2);
assert!(!widget(&root).editor.text().contains('\r'));
}
#[test]
fn escape_releases_focus() {
let mut state = AppState::default();
let mut root = harness(&mut state);
root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
assert!(root.is_focus_active());
root.event(&mut state, &named(NamedKey::Escape, Modifiers::default()));
assert!(!root.is_focus_active(), "Escape blurs the field");
assert!(root.ime_state().is_none());
assert!(!widget(&root).focused);
}
#[test]
fn blur_via_outside_tap_unpublishes_ime_state() {
let mut state = AppState::default();
let mut root = harness(&mut state);
root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
assert!(root.ime_state().is_some());
root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 150.0));
assert!(!root.is_focus_active(), "outside tap blurs the field");
assert!(root.ime_state().is_none());
assert!(!widget(&root).focused);
}
#[test]
fn blurred_selection_stops_painting_its_highlight_but_survives_for_refocus() {
let mut state = AppState::default();
let mut root = harness(&mut state);
focused_with_selection(&mut state, &mut root, "abc");
assert_eq!(
widget(&root).editor.selected_text(),
Some("abc"),
"the drag produced a non-empty selection"
);
let mut focused_rec = ChromeRecorder::default();
root.paint(&mut focused_rec, FrameTime::ZERO);
assert!(
focused_rec.rects.contains(&SELECTION),
"a focused field with a non-empty selection must paint the highlight"
);
root.event(&mut state, &named(NamedKey::Escape, Modifiers::default()));
assert!(!root.is_focus_active(), "Escape blurs the field");
assert_eq!(
widget(&root).editor.selected_text(),
Some("abc"),
"the selection must survive blur so a later refocus restores the highlight"
);
let mut blurred_rec = ChromeRecorder::default();
root.paint(&mut blurred_rec, FrameTime::ZERO);
assert!(
!blurred_rec.rects.contains(&SELECTION),
"a blurred field must stop painting its selection highlight"
);
}
#[test]
fn meta_a_selects_all_without_typing() {
let mut state = AppState::default();
let mut root = harness(&mut state);
root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
for c in ["a", "b", "c"] {
root.event(&mut state, &ch(c));
}
let changes_before = state.changes;
let meta = Modifiers {
meta: true,
..Modifiers::default()
};
root.event(
&mut state,
&InputEvent::Key(KeyEvent {
key: Key::Character("a".to_string()),
modifiers: meta,
repeat: false,
}),
);
let es = widget(&root).editor.editing_state_bytes();
assert_eq!(es.base.min(es.extent), 0);
assert_eq!(es.base.max(es.extent), 3, "whole buffer selected");
assert_eq!(
state.changes, changes_before,
"select-all does not type an 'a'"
);
assert_eq!(widget(&root).editor.text(), "abc");
}
#[test]
fn controlled_reconcile_rejected_value_shows_app_value() {
let mut state = AppState {
reject: true,
..AppState::default()
};
let mut root = harness(&mut state);
root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
root.event(&mut state, &ch("x"));
assert_eq!(state.changes, 1);
assert_eq!(state.value, "");
assert_eq!(widget(&root).editor.text(), "x");
root.rebuild(&mut build, &mut state);
assert_eq!(
widget(&root).editor.text(),
"",
"a rejected edit is pulled back to the app's value on rebuild"
);
}
#[test]
fn controlled_reconcile_same_value_preserves_selection() {
let mut state = AppState {
value: "ab".to_string(),
..AppState::default()
};
let mut root = harness(&mut state);
root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
let meta = Modifiers {
meta: true,
..Modifiers::default()
};
root.event(
&mut state,
&InputEvent::Key(KeyEvent {
key: Key::Character("a".to_string()),
modifiers: meta,
repeat: false,
}),
);
let before = widget(&root).editor.editing_state_bytes();
assert_ne!(before.base, before.extent);
root.rebuild(&mut build, &mut state);
let after = widget(&root).editor.editing_state_bytes();
assert_eq!(
(before.base, before.extent),
(after.base, after.extent),
"an unchanged value leaves the selection intact"
);
}
#[test]
fn ime_apply_editing_state_syncs_value() {
let mut state = AppState::default();
let mut root = harness(&mut state);
root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
root.event(
&mut state,
&InputEvent::Ime(ImeEvent::ApplyEditingState(EditingState {
text: "hello".to_string(),
selection_base: 5,
selection_extent: 5,
composing_base: -1,
composing_extent: -1,
})),
);
assert_eq!(widget(&root).editor.text(), "hello");
assert_eq!(state.value, "hello", "on_change reflects the synced value");
}
#[test]
fn ime_empty_compose_retracts_the_preedit_without_panicking() {
let mut state = AppState::default();
let mut root = harness(&mut state);
root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
root.event(
&mut state,
&InputEvent::Ime(ImeEvent::Compose {
text: "ni".to_string(),
cursor: Some((2, 2)),
}),
);
assert_eq!(widget(&root).editor.text(), "ni", "the preedit is live");
root.event(
&mut state,
&InputEvent::Ime(ImeEvent::Compose {
text: String::new(),
cursor: None,
}),
);
assert_eq!(
widget(&root).editor.text(),
"",
"the marked text is retracted, not replaced with an empty preedit"
);
assert_eq!(
widget(&root).editor.editing_state_bytes().composing,
None,
"and no composing region is left behind"
);
assert_eq!(state.value, "");
root.event(
&mut state,
&InputEvent::Ime(ImeEvent::Compose {
text: String::new(),
cursor: None,
}),
);
assert_eq!(widget(&root).editor.text(), "");
}
#[test]
fn ime_empty_commit_retracts_the_preedit_without_panicking() {
let mut state = AppState::default();
let mut root = harness(&mut state);
root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
root.event(
&mut state,
&InputEvent::Ime(ImeEvent::Compose {
text: "ni".to_string(),
cursor: None,
}),
);
root.event(
&mut state,
&InputEvent::Ime(ImeEvent::Commit(String::new())),
);
assert_eq!(widget(&root).editor.text(), "");
assert_eq!(widget(&root).editor.editing_state_bytes().composing, None);
}
#[test]
fn ime_commit_inserts_text() {
let mut state = AppState::default();
let mut root = harness(&mut state);
root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
root.event(
&mut state,
&InputEvent::Ime(ImeEvent::Commit("ni".to_string())),
);
assert_eq!(widget(&root).editor.text(), "ni");
assert_eq!(state.value, "ni");
}
#[test]
fn cancel_disarms_drag_without_touching_state() {
let mut state = AppState::default();
let mut root = harness(&mut state);
root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
assert!(widget(&root).captured);
let changes_before = state.changes;
root.event(&mut state, &pointer(PointerPhase::Cancel, 10.0, 10.0));
assert!(!widget(&root).captured, "Cancel disarms the drag");
assert_eq!(
state.changes, changes_before,
"Cancel must not fire on_change"
);
}
#[test]
fn blink_requests_frame_only_while_focused() {
let mut state = AppState::default();
let mut root = harness(&mut state);
let mut sink = NullScene;
assert!(
!root.paint(&mut sink, FrameTime::ZERO).needs_frame,
"an unfocused field is at rest"
);
root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
root.event(&mut state, &pointer(PointerPhase::Up, 10.0, 10.0));
let outcome = root.paint(&mut sink, FrameTime::ZERO);
assert!(outcome.needs_frame, "a focused field blinks its caret");
assert!(
outcome.needs_frame_paced_only,
"the caret blink is a CosmeticLoop request — the frame gate must be able to pace it"
);
}
#[test]
fn reduce_motion_keeps_the_double_tap_window_pumping_its_own_clock() {
let mut state = AppState {
value: "hello world".to_string(),
..Default::default()
};
let mut root = harness(&mut state);
let mut theme = Theme::neutral();
theme.motion.reduce_motion = true;
root.set_theme(Box::new(theme));
root.paint(&mut NullScene, ft_ms(0.0));
tap(&mut root, &mut state, 20.0, 10.0);
assert!(
widget(&root).last_tap.is_some(),
"sanity: the tap seeded a double-tap window"
);
let inside = root.paint(&mut NullScene, ft_ms(50.0));
assert!(
inside.needs_frame,
"a live double-tap window must pump its own clock under reduce_motion"
);
let outside = root.paint(&mut NullScene, ft_ms(DOUBLE_TAP_MS + 100.0));
assert!(
!outside.needs_frame,
"an elapsed window stops asking — the request is bounded by the window"
);
root.event(&mut state, &pointer(PointerPhase::Down, 20.0, 10.0));
assert_eq!(
selection(&root),
None,
"past DOUBLE_TAP_MS the press is just a press"
);
}
#[test]
fn blink_paces_at_its_own_500ms_interval() {
let mut state = AppState::default();
let mut root = harness(&mut state);
let mut sink = NullScene;
root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
root.event(&mut state, &pointer(PointerPhase::Up, 10.0, 10.0));
let outcome = root.paint(&mut sink, FrameTime::ZERO);
assert!(outcome.needs_frame_paced_only);
assert_eq!(
outcome.paced_interval,
Some(Duration::from_millis(BLINK_MS as u64)),
"the caret paces at its own 500ms half-period, not the theme cap"
);
}
#[test]
fn reduce_motion_freezes_the_caret_visible_and_stops_requesting_frames() {
let mut state = AppState::default();
let mut root = harness(&mut state);
let mut theme = Theme::neutral();
theme.motion.reduce_motion = true;
root.set_theme(Box::new(theme));
let caret_color = Theme::neutral().scheme().primary;
root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
root.event(&mut state, &pointer(PointerPhase::Up, 10.0, 10.0));
root.paint(&mut NullScene, ft_ms(0.0));
let mut mid = CaretRecorder {
caret_color: Some(caret_color),
caret_fills: 0,
};
let outcome = root.paint(&mut mid, ft_ms(BLINK_MS + 10.0));
assert_eq!(
mid.caret_fills, 1,
"reduce_motion freezes the caret visible, even mid-blink-cycle"
);
assert!(
!outcome.needs_frame,
"a frozen caret must not request a continuation frame"
);
let mut theme = Theme::neutral();
theme.motion.reduce_motion = false;
root.set_theme(Box::new(theme));
let resumed = root.paint(&mut NullScene, ft_ms(2.0 * BLINK_MS + 20.0));
assert!(
resumed.needs_frame_paced_only,
"blinking resumes once reduce_motion clears"
);
assert_eq!(
resumed.paced_interval,
Some(Duration::from_millis(BLINK_MS as u64))
);
}
fn ft_ms(ms: f64) -> FrameTime {
FrameTime::from_nanos((ms * 1_000_000.0) as u64)
}
#[test]
fn caret_visibility_toggles_across_the_blink_period() {
let mut state = AppState::default();
let root = harness(&mut state);
let w = widget(&root);
assert!(
w.caret_visible_at(ft_ms(0.0)),
"visible at the start of the cycle"
);
assert!(w.caret_visible_at(ft_ms(BLINK_MS - 1.0)));
assert!(
!w.caret_visible_at(ft_ms(BLINK_MS + 1.0)),
"hidden mid-cycle"
);
assert!(
w.caret_visible_at(ft_ms(2.0 * BLINK_MS + 1.0)),
"visible again"
);
}
#[derive(Default)]
struct CaretRecorder {
caret_color: Option<Color>,
caret_fills: usize,
}
impl PaintScene for CaretRecorder {
fn fill_rect(&mut self, _o: Point, _s: Size, color: Color) {
if Some(color) == self.caret_color {
self.caret_fills += 1;
}
}
fn fill_rounded_rect(&mut self, _o: Point, _s: Size, _r: f64, _c: Color) {}
fn draw_text(&mut self, _o: Point, _t: &str) {}
}
#[test]
fn caret_blink_phase_advances_from_paint_frame_time() {
let mut state = AppState::default();
let mut root = harness(&mut state);
root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
let mut f1 = CaretRecorder {
caret_color: Some(CARET),
caret_fills: 0,
};
root.paint(&mut f1, ft_ms(0.0));
assert_eq!(f1.caret_fills, 1, "caret visible at the start of the cycle");
let mut f2 = CaretRecorder {
caret_color: Some(CARET),
caret_fills: 0,
};
root.paint(&mut f2, ft_ms(BLINK_MS + 10.0));
assert_eq!(
f2.caret_fills, 0,
"caret hidden mid-cycle (phase from paint time)"
);
let mut f3 = CaretRecorder {
caret_color: Some(CARET),
caret_fills: 0,
};
root.paint(&mut f3, ft_ms(2.0 * BLINK_MS + 10.0));
assert_eq!(f3.caret_fills, 1, "caret visible again in the next cycle");
}
#[derive(Default)]
struct ChromeRecorder {
rrects: Vec<Color>,
rects: Vec<Color>,
}
impl PaintScene for ChromeRecorder {
fn fill_rect(&mut self, _o: Point, _s: Size, color: Color) {
self.rects.push(color);
}
fn fill_rounded_rect(&mut self, _o: Point, _s: Size, _r: f64, color: Color) {
self.rrects.push(color);
}
fn draw_text(&mut self, _o: Point, _t: &str) {}
fn draw_glyph_run(&mut self, _run: frust_scene::GlyphRun) {}
}
fn paint_chrome(root: &mut RenderRoot<AppState, TextInputView<AppState>>) -> ChromeRecorder {
let mut rec = ChromeRecorder::default();
root.paint(&mut rec, FrameTime::ZERO);
rec
}
#[test]
fn unthemed_chrome_uses_fallback_constants() {
let mut state = AppState::default();
let mut root = harness(&mut state);
root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
let rec = paint_chrome(&mut root);
assert_eq!(rec.rrects, vec![ACCENT, BG]);
assert_eq!(rec.rects, vec![CARET]);
}
#[test]
fn themed_chrome_resolves_roles() {
let mut state = AppState::default();
let mut root = harness(&mut state);
root.set_theme(Box::new(Theme::neutral()));
let theme = Theme::neutral();
let scheme = theme.scheme();
root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
let rec = paint_chrome(&mut root);
assert_eq!(
rec.rrects,
vec![scheme.primary, scheme.surface],
"focused border is primary, background is surface"
);
assert_eq!(rec.rects, vec![scheme.primary], "caret is primary");
}
#[derive(Default)]
struct ChromeGeometryRecorder {
rrects: Vec<(Point, Size, f64, Color)>,
rects: Vec<(Point, Size, Color)>,
}
impl PaintScene for ChromeGeometryRecorder {
fn fill_rect(&mut self, origin: Point, size: Size, color: Color) {
self.rects.push((origin, size, color));
}
fn fill_rounded_rect(&mut self, origin: Point, size: Size, radius: f64, color: Color) {
self.rrects.push((origin, size, radius, color));
}
fn draw_text(&mut self, _o: Point, _t: &str) {}
fn draw_glyph_run(&mut self, _run: frust_scene::GlyphRun) {}
}
#[test]
fn default_chrome_geometry_matches_the_unthemed_constants() {
let mut state = AppState::default();
let mut root = harness(&mut state);
let field_size = root.layout(Size::new(300.0, 200.0));
root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
let mut rec = ChromeGeometryRecorder::default();
root.paint(&mut rec, FrameTime::ZERO);
let (outer_origin, outer_size, outer_radius, _) = rec.rrects[0];
let (inner_origin, inner_size, inner_radius, _) = rec.rrects[1];
assert_eq!(outer_size, field_size, "outer chrome rect covers the field");
assert_eq!(outer_radius, RADIUS, "default corner radius is RADIUS");
assert_eq!(
inner_origin.x - outer_origin.x,
BORDER_W,
"default border width is BORDER_W"
);
assert_eq!(inner_origin.y - outer_origin.y, BORDER_W);
assert_eq!(
inner_size,
Size::new(
outer_size.width - 2.0 * BORDER_W,
outer_size.height - 2.0 * BORDER_W
)
);
assert_eq!(inner_radius, RADIUS - BORDER_W);
let (_, caret_size, _) = *rec.rects.last().expect("caret painted while focused");
assert!(
(caret_size.width - CARET_W as f64).abs() < 1e-6,
"default caret width is CARET_W"
);
}
#[test]
fn custom_padding_changes_the_resolved_height_and_published_caret_offset() {
let mut default_state = AppState::default();
let mut default_root = harness(&mut default_state);
let default_size = default_root.layout(Size::new(300.0, 200.0));
default_root.event(&mut default_state, &pointer(PointerPhase::Down, 10.0, 10.0));
let default_caret_x = default_root
.ime_state()
.expect("focused")
.caret
.expect("caret rect")
.x0;
let mut state = AppState::default();
let mut logic = |s: &mut AppState| {
text_input(s.value.clone(), |s: &mut AppState, v: String| s.value = v)
.padding(30.0, 40.0)
};
let mut root = RenderRoot::new();
root.rebuild(&mut logic, &mut state);
let custom_size = root.layout(Size::new(300.0, 200.0));
root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
let custom_caret_x = root
.ime_state()
.expect("focused")
.caret
.expect("caret rect")
.x0;
assert_eq!(
custom_size.height - default_size.height,
2.0 * (40.0 - PAD_Y),
"vertical padding is reflected in the resolved field height, not just accepted"
);
assert!(
(custom_caret_x - default_caret_x - (30.0 - PAD_X)).abs() < 1e-6,
"horizontal padding shifts the published caret rect"
);
}
#[test]
fn custom_border_width_and_corner_radius_resize_the_painted_chrome() {
let mut state = AppState::default();
let mut logic = |s: &mut AppState| {
text_input(s.value.clone(), |s: &mut AppState, v: String| s.value = v)
.border_width(4.0)
.corner_radius(2.0)
};
let mut root = RenderRoot::new();
root.rebuild(&mut logic, &mut state);
root.layout(Size::new(300.0, 200.0));
let mut rec = ChromeGeometryRecorder::default();
root.paint(&mut rec, FrameTime::ZERO);
let (outer_origin, outer_size, outer_radius, _) = rec.rrects[0];
let (inner_origin, inner_size, inner_radius, _) = rec.rrects[1];
assert_eq!(
outer_radius, 2.0,
"corner_radius reaches the painted outer rect"
);
assert_eq!(
inner_radius, 0.0,
"inner radius clamps at 0 once border_width exceeds corner_radius"
);
assert_eq!(
inner_origin.x - outer_origin.x,
4.0,
"border_width insets the fill"
);
assert_eq!(inner_origin.y - outer_origin.y, 4.0);
assert_eq!(
inner_size,
Size::new(outer_size.width - 8.0, outer_size.height - 8.0)
);
}
#[test]
fn custom_caret_width_changes_the_painted_caret_rect() {
let mut state = AppState::default();
let mut logic = |s: &mut AppState| {
text_input(s.value.clone(), |s: &mut AppState, v: String| s.value = v).caret_width(6.0)
};
let mut root = RenderRoot::new();
root.rebuild(&mut logic, &mut state);
root.layout(Size::new(300.0, 200.0));
root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
let mut rec = ChromeGeometryRecorder::default();
root.paint(&mut rec, FrameTime::ZERO);
let (_, caret_size, _) = *rec.rects.last().expect("caret painted");
assert!(
(caret_size.width - 6.0).abs() < 1e-6,
"caret_width is reflected in the painted caret rect, not just accepted by the builder"
);
}
#[test]
fn custom_focus_ring_width_only_widens_the_border_while_focused() {
let mut state = AppState::default();
let mut logic = |s: &mut AppState| {
text_input(s.value.clone(), |s: &mut AppState, v: String| s.value = v)
.focus_ring_width(5.0)
};
let mut root = RenderRoot::new();
root.rebuild(&mut logic, &mut state);
root.layout(Size::new(300.0, 200.0));
let mut idle = ChromeGeometryRecorder::default();
root.paint(&mut idle, FrameTime::ZERO);
let (idle_outer, _, _, _) = idle.rrects[0];
let (idle_inner, _, _, _) = idle.rrects[1];
assert_eq!(
idle_inner.x - idle_outer.x,
BORDER_W,
"idle border stays the default width when unfocused"
);
root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
let mut focused = ChromeGeometryRecorder::default();
root.paint(&mut focused, FrameTime::ZERO);
let (focused_outer, _, _, _) = focused.rrects[0];
let (focused_inner, _, _, _) = focused.rrects[1];
assert_eq!(
focused_inner.x - focused_outer.x,
5.0,
"the focused appearance responds to focus_ring_width"
);
}
#[test]
fn geometry_field_changes_request_the_right_change_flags() {
let mut state = AppState::default();
let mut root = harness(&mut state);
let mut padded = |s: &mut AppState| {
text_input(s.value.clone(), |s: &mut AppState, v: String| s.value = v)
.padding(20.0, 20.0)
};
let flags = root.rebuild(&mut padded, &mut state);
assert!(flags.needs_layout(), "a padding change must relayout");
let mut bordered = |s: &mut AppState| {
text_input(s.value.clone(), |s: &mut AppState, v: String| s.value = v)
.padding(20.0, 20.0)
.border_width(4.0)
};
let flags = root.rebuild(&mut bordered, &mut state);
assert!(flags.needs_paint());
assert!(
!flags.needs_layout(),
"border_width alone does not resize the field"
);
}
#[test]
fn paint_refreshes_ime_state_after_a_controlled_clear() {
let mut state = AppState::default();
let mut root = harness(&mut state);
let mut sink = NullScene;
root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
root.event(&mut state, &ch("h"));
root.event(&mut state, &ch("i"));
assert_eq!(root.ime_state().expect("focused").editing.text, "hi");
state.value.clear();
root.rebuild(&mut build, &mut state);
root.layout(Size::new(300.0, 200.0));
root.paint(&mut sink, FrameTime::ZERO);
let ime = root
.ime_state()
.expect("still focused, so still publishing");
assert_eq!(
ime.editing.text, "",
"paint refreshes the shell-facing IME state to the controlled-cleared value \
(without this, the mobile IME mirror re-pushes the stale text)"
);
}
#[derive(Default)]
struct TextGlyphRecorder {
colors: Vec<Color>,
}
impl PaintScene for TextGlyphRecorder {
fn fill_rect(&mut self, _o: Point, _s: Size, _c: Color) {}
fn fill_rounded_rect(&mut self, _o: Point, _s: Size, _r: f64, _c: Color) {}
fn draw_text(&mut self, _o: Point, _t: &str) {}
fn draw_glyph_run(&mut self, run: frust_scene::GlyphRun) {
if let peniko::Brush::Solid(color) = run.brush {
self.colors.push(color);
}
}
}
fn painted_text_color(root: &mut RenderRoot<AppState, TextInputView<AppState>>) -> Color {
let mut rec = TextGlyphRecorder::default();
root.paint(&mut rec, FrameTime::ZERO);
*rec.colors.first().expect("one glyph run painted")
}
#[derive(Default)]
struct PlaceholderFontRecorder {
font_bytes: Vec<Vec<u8>>,
}
impl PaintScene for PlaceholderFontRecorder {
fn fill_rect(&mut self, _o: Point, _s: Size, _c: Color) {}
fn fill_rounded_rect(&mut self, _o: Point, _s: Size, _r: f64, _c: Color) {}
fn draw_text(&mut self, _o: Point, _t: &str) {}
fn draw_glyph_run(&mut self, run: frust_scene::GlyphRun) {
self.font_bytes.push(run.font.font().data.as_ref().to_vec());
}
}
fn painted_placeholder_font_bytes(
root: &mut RenderRoot<AppState, TextInputView<AppState>>,
) -> Vec<u8> {
let mut rec = PlaceholderFontRecorder::default();
root.paint(&mut rec, FrameTime::ZERO);
rec.font_bytes
.first()
.expect("one glyph run painted")
.clone()
}
#[test]
fn unthemed_text_input_keeps_black_default() {
let mut state = AppState {
value: "hi".to_string(),
..AppState::default()
};
let mut root = harness(&mut state);
assert_eq!(painted_text_color(&mut root), Color::BLACK);
}
#[test]
fn themed_dark_text_input_resolves_on_surface() {
let mut state = AppState {
value: "hi".to_string(),
..AppState::default()
};
let mut root = harness(&mut state);
let mut theme = Theme::neutral();
theme.brightness = frust_theme::Brightness::Dark;
let expected = theme.scheme().on_surface;
assert_ne!(
expected,
Color::BLACK,
"fixture sanity: dark on_surface must differ from the black fallback"
);
root.set_theme(Box::new(theme));
root.layout(Size::new(300.0, 200.0));
assert_eq!(
painted_text_color(&mut root),
expected,
"a themed field's glyphs resolve to on_surface(dark), not black"
);
}
#[test]
fn explicit_text_style_wins_over_theme() {
let custom = Color::from_rgb8(10, 20, 30);
fn logic(state: &mut AppState) -> TextInputView<AppState> {
text_input(state.value.clone(), |s: &mut AppState, v: String| {
s.value = v;
})
.text_style(TextStyle::new(16.0, Color::from_rgb8(10, 20, 30)))
}
let mut state = AppState {
value: "hi".to_string(),
..AppState::default()
};
let mut root: RenderRoot<AppState, TextInputView<AppState>> = RenderRoot::new();
root.rebuild(&mut logic, &mut state);
root.set_theme(Box::new(Theme::neutral()));
root.layout(Size::new(300.0, 200.0));
assert_eq!(
painted_text_color(&mut root),
custom,
"an explicit .text_style() color wins over the themed default"
);
}
#[test]
fn rebuild_with_new_text_style_relayouts_with_new_metrics() {
fn logic_a(state: &mut AppState) -> TextInputView<AppState> {
text_input(state.value.clone(), |s: &mut AppState, v: String| {
s.value = v;
})
.text_style(TextStyle::new(16.0, Color::BLACK))
}
fn logic_b(state: &mut AppState) -> TextInputView<AppState> {
text_input(state.value.clone(), |s: &mut AppState, v: String| {
s.value = v;
})
.text_style(TextStyle::new(40.0, Color::BLACK))
}
let mut state = AppState::default();
let mut root: RenderRoot<AppState, TextInputView<AppState>> = RenderRoot::new();
root.rebuild(&mut logic_a, &mut state);
let size_a = root.layout(Size::new(300.0, 200.0));
root.rebuild(&mut logic_b, &mut state);
let size_b = root.layout(Size::new(300.0, 200.0));
assert!(
size_b.height > size_a.height,
"a rebuild with a larger text_style size grows the field height \
({size_a:?} -> {size_b:?})"
);
}
#[test]
fn theme_swap_with_no_view_change_repaints_new_glyph_color() {
let mut state = AppState {
value: "hi".to_string(),
..AppState::default()
};
let mut root = harness(&mut state);
let mut theme_a = Theme::neutral();
theme_a.brightness = frust_theme::Brightness::Light;
let color_a = theme_a.scheme().on_surface;
root.set_theme(Box::new(theme_a));
root.layout(Size::new(300.0, 200.0));
assert_eq!(painted_text_color(&mut root), color_a);
let mut theme_b = Theme::neutral();
theme_b.brightness = frust_theme::Brightness::Dark;
let color_b = theme_b.scheme().on_surface;
assert_ne!(
color_a, color_b,
"fixture sanity: themes must actually differ"
);
root.set_theme(Box::new(theme_b));
root.layout(Size::new(300.0, 200.0));
assert_eq!(
painted_text_color(&mut root),
color_b,
"a bare theme swap (no view change) must re-resolve the themed glyph \
color at the next layout"
);
}
#[derive(Default)]
struct NestedState {
value: String,
}
fn nested_logic(state: &mut NestedState) -> crate::FlexView<NestedState> {
use frust_core::any;
crate::Column(vec![
any(text_input(
state.value.clone(),
|s: &mut NestedState, v: String| {
s.value = v;
},
)),
any(crate::button("ok", |_s: &mut NestedState| {})),
])
}
#[test]
fn nested_blur_clears_ime_and_idles_paint() {
let mut state = NestedState::default();
let mut root: RenderRoot<NestedState, crate::FlexView<NestedState>> = RenderRoot::new();
root.rebuild(&mut nested_logic, &mut state);
let mut tcx = frust_text::TextContext::new();
root.layout_with_text(Size::new(300.0, 200.0), &mut tcx as &mut dyn Any);
root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
root.event(&mut state, &pointer(PointerPhase::Up, 10.0, 10.0));
assert!(root.is_focus_active(), "tap inside the field focuses it");
assert!(
root.ime_state().is_some(),
"focusing the nested field publishes an IME surface"
);
root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 45.0));
root.event(&mut state, &pointer(PointerPhase::Up, 10.0, 45.0));
assert!(!root.is_focus_active(), "the sibling tap blurs the field");
assert!(
root.ime_state().is_none(),
"container-routed blur clears the IME surface"
);
let mut sink = NullScene;
let outcome = root.paint(&mut sink, FrameTime::ZERO);
assert!(
root.ime_state().is_none(),
"paint must not republish the cleared IME surface (F1 resurrection)"
);
assert!(
!outcome.needs_frame,
"a blurred field paints at rest — the desktop wait-loop idles"
);
}
fn deep_nested_logic(state: &mut NestedState) -> crate::FlexView<NestedState> {
use frust_core::any;
crate::Column(vec![
any(crate::Column(vec![any(text_input(
state.value.clone(),
|s: &mut NestedState, v: String| {
s.value = v;
},
))])),
any(crate::button("ok", |_s: &mut NestedState| {})),
])
}
#[test]
fn deep_nested_blur_idles_paint_despite_stale_inner_focus_flag() {
let mut state = NestedState::default();
let mut root: RenderRoot<NestedState, crate::FlexView<NestedState>> = RenderRoot::new();
root.rebuild(&mut deep_nested_logic, &mut state);
let mut tcx = frust_text::TextContext::new();
root.layout_with_text(Size::new(300.0, 200.0), &mut tcx as &mut dyn Any);
root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
root.event(&mut state, &pointer(PointerPhase::Up, 10.0, 10.0));
assert!(root.is_focus_active());
assert!(root.ime_state().is_some());
root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 45.0));
root.event(&mut state, &pointer(PointerPhase::Up, 10.0, 45.0));
assert!(!root.is_focus_active());
assert!(root.ime_state().is_none());
let mut sink = NullScene;
let outcome = root.paint(&mut sink, FrameTime::ZERO);
assert!(
root.ime_state().is_none(),
"deep-nested stale focus flag must not resurrect the IME surface"
);
assert!(
!outcome.needs_frame,
"deep-nested stale focus flag must not busy-loop the desktop shell"
);
}
#[derive(Default)]
struct BranchState {
row: String,
composer: String,
show_row: bool,
show_composer: bool,
}
fn branches_logic(state: &mut BranchState) -> crate::FlexView<BranchState> {
use frust_core::{AnyView, any};
let mut rows: Vec<AnyView<BranchState>> = Vec::new();
if state.show_row {
rows.push(any(text_input(
state.row.clone(),
|s: &mut BranchState, v: String| s.row = v,
)));
}
let mut outer: Vec<AnyView<BranchState>> = vec![any(crate::Column(rows))];
if state.show_composer {
outer.push(any(text_input(
state.composer.clone(),
|s: &mut BranchState, v: String| s.composer = v,
)));
}
crate::Column(outer)
}
fn focus_row_then_composer() -> (
RenderRoot<BranchState, crate::FlexView<BranchState>>,
frust_text::TextContext,
BranchState,
) {
let mut state = BranchState {
row: "row".to_string(),
composer: "composer".to_string(),
show_row: true,
show_composer: true,
};
let mut root: RenderRoot<BranchState, crate::FlexView<BranchState>> = RenderRoot::new();
root.rebuild(&mut branches_logic, &mut state);
let mut tcx = frust_text::TextContext::new();
root.layout_with_text(Size::new(300.0, 200.0), &mut tcx as &mut dyn Any);
root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
root.event(&mut state, &pointer(PointerPhase::Up, 10.0, 10.0));
assert_eq!(
root.ime_state().map(|s| s.editing.text),
Some("row".to_string()),
"the row field owns the session first"
);
root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 50.0));
root.event(&mut state, &pointer(PointerPhase::Up, 10.0, 50.0));
assert!(root.is_focus_active());
assert_eq!(
root.ime_state().map(|s| s.editing.text),
Some("composer".to_string()),
"the composer now owns the session"
);
(root, tcx, state)
}
#[test]
fn tearing_down_a_stale_focused_branch_leaves_the_live_session_alone() {
let (mut root, _tcx, mut state) = focus_row_then_composer();
let ime_before = root.ime_state();
let gen_before = root.focus_ime_generation();
state.show_row = false;
root.rebuild(&mut branches_logic, &mut state);
assert!(
root.is_focus_active(),
"unmounting a stale-flagged row must not blur the composer the user is typing in"
);
assert_eq!(
root.ime_state(),
ime_before,
"the live IME surface is untouched by an unrelated branch's unmount"
);
assert_eq!(
root.focus_ime_generation(),
gen_before,
"zero spurious edges: the shell's frame gate must see no focus/IME change at all"
);
}
#[test]
fn tearing_down_the_live_focused_branch_still_releases_the_session() {
let (mut root, _tcx, mut state) = focus_row_then_composer();
let gen_before = root.focus_ime_generation();
state.show_composer = false;
root.rebuild(&mut branches_logic, &mut state);
assert!(
!root.is_focus_active(),
"the focused composer's unmount releases the session"
);
assert_eq!(
root.ime_state(),
None,
"the shell-facing surface dies with the widget that published it"
);
assert_eq!(
root.focus_ime_generation(),
gen_before.wrapping_add(1),
"one orphaned live focus path is exactly one edge"
);
}
#[derive(Default)]
struct WrapState {
row: String,
composer: String,
row_editing: bool,
}
fn wrapped_slot_logic(state: &mut WrapState) -> crate::FlexView<WrapState> {
use frust_core::{AnyView, any};
let slot: AnyView<WrapState> = if state.row_editing {
any(crate::Padding(
crate::EdgeInsets::all(0.0),
text_input(state.row.clone(), |s: &mut WrapState, v: String| s.row = v),
))
} else {
any(crate::Padding(
crate::EdgeInsets::all(0.0),
crate::text(state.row.clone()),
))
};
crate::Column(vec![
slot,
any(text_input(
state.composer.clone(),
|s: &mut WrapState, v: String| s.composer = v,
)),
])
}
fn focus_the_wrapped_field() -> (
RenderRoot<WrapState, crate::FlexView<WrapState>>,
frust_text::TextContext,
WrapState,
) {
let mut state = WrapState {
row: "row".to_string(),
composer: "composer".to_string(),
row_editing: true,
};
let mut root: RenderRoot<WrapState, crate::FlexView<WrapState>> = RenderRoot::new();
root.rebuild(&mut wrapped_slot_logic, &mut state);
let mut tcx = frust_text::TextContext::new();
root.layout_with_text(Size::new(300.0, 200.0), &mut tcx as &mut dyn Any);
root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
root.event(&mut state, &pointer(PointerPhase::Up, 10.0, 10.0));
assert_eq!(
root.ime_state().map(|s| s.editing.text),
Some("row".to_string()),
"the wrapped field owns the session"
);
(root, tcx, state)
}
#[test]
fn swapping_a_live_focused_wrapper_child_releases_the_session() {
let (mut root, _tcx, mut state) = focus_the_wrapped_field();
let gen_before = root.focus_ime_generation();
state.row_editing = false;
root.rebuild(&mut wrapped_slot_logic, &mut state);
assert!(
!root.is_focus_active(),
"the root's focus mirror must not outlive the type-swapped field"
);
assert_eq!(
root.ime_state(),
None,
"the shell-facing surface dies with the widget that published it"
);
assert_eq!(
root.focus_ime_generation(),
gen_before.wrapping_add(1),
"one severed live focus path is exactly one edge"
);
}
#[test]
fn swapping_a_stale_focused_wrapper_child_leaves_the_live_session_alone() {
let (mut root, _tcx, mut state) = focus_the_wrapped_field();
root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 50.0));
root.event(&mut state, &pointer(PointerPhase::Up, 10.0, 50.0));
assert_eq!(
root.ime_state().map(|s| s.editing.text),
Some("composer".to_string()),
"the composer now owns the session"
);
let ime_before = root.ime_state();
let gen_before = root.focus_ime_generation();
state.row_editing = false;
root.rebuild(&mut wrapped_slot_logic, &mut state);
assert!(
root.is_focus_active(),
"swapping a stale-flagged wrapper child must not blur the composer \
the user is typing in"
);
assert_eq!(
root.ime_state(),
ime_before,
"the live IME surface is untouched by an unrelated branch's swap"
);
assert_eq!(
root.focus_ime_generation(),
gen_before,
"zero spurious edges: the shell's frame gate must see no focus/IME change"
);
}
struct NullScene;
impl PaintScene for NullScene {
fn fill_rect(&mut self, _o: Point, _s: Size, _c: Color) {}
fn draw_text(&mut self, _o: Point, _t: &str) {}
}
fn multiline_logic(state: &mut AppState) -> TextInputView<AppState> {
text_input(state.value.clone(), |s: &mut AppState, v: String| {
s.changes += 1;
if !s.reject {
s.value = v;
}
})
.multiline(3)
.on_submit(|s: &mut AppState, v: String| {
s.submits += 1;
s.last_submit = v;
})
}
fn multiline_submit_logic(state: &mut AppState) -> TextInputView<AppState> {
text_input(state.value.clone(), |s: &mut AppState, v: String| {
s.changes += 1;
s.value = v;
})
.multiline(3)
.submit_on_enter(true)
.on_submit(|s: &mut AppState, v: String| {
s.submits += 1;
s.last_submit = v;
})
}
fn relayout(root: &mut RenderRoot<AppState, TextInputView<AppState>>) -> f64 {
root.layout(Size::new(300.0, 800.0)).height
}
#[test]
fn multiline_height_grows_per_line_up_to_cap_then_stops() {
let mut state = AppState::default();
let mut root = RenderRoot::new();
root.rebuild(&mut multiline_logic, &mut state);
let h1 = relayout(&mut root);
root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
root.event(&mut state, &named(NamedKey::Enter, Modifiers::default()));
let h2 = relayout(&mut root); root.event(&mut state, &named(NamedKey::Enter, Modifiers::default()));
let h3 = relayout(&mut root);
assert!(h2 > h1, "a second line grows the field: {h1} -> {h2}");
assert!(h3 > h2, "a third line grows the field: {h2} -> {h3}");
root.event(&mut state, &named(NamedKey::Enter, Modifiers::default()));
let h4 = relayout(&mut root);
root.event(&mut state, &named(NamedKey::Enter, Modifiers::default()));
let h5 = relayout(&mut root);
assert_eq!(h4, h3, "height stops growing at the visible-line cap");
assert_eq!(h5, h4, "still capped past the cap");
}
#[test]
fn multiline_scroll_offset_engages_only_past_the_cap() {
let mut state = AppState::default();
let mut root = RenderRoot::new();
root.rebuild(&mut multiline_logic, &mut state);
root.layout(Size::new(300.0, 800.0));
root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
root.event(&mut state, &named(NamedKey::Enter, Modifiers::default()));
let h = relayout(&mut root);
assert_eq!(
widget(&root).scroll_y(h),
0.0,
"content within the cap never scrolls"
);
for _ in 0..4 {
root.event(&mut state, &named(NamedKey::Enter, Modifiers::default()));
}
let h = relayout(&mut root);
assert!(
widget(&root).scroll_y(h) > 0.0,
"content past the cap scrolls to keep the caret in view"
);
}
#[test]
fn enter_inserts_newline_in_multiline_by_default() {
let mut state = AppState::default();
let mut root = RenderRoot::new();
root.rebuild(&mut multiline_logic, &mut state);
root.layout(Size::new(300.0, 800.0));
root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
root.event(&mut state, &ch("a"));
root.event(&mut state, &named(NamedKey::Enter, Modifiers::default()));
root.event(&mut state, &ch("b"));
assert_eq!(
widget(&root).editor.text(),
"a\nb",
"Enter inserts a newline"
);
assert_eq!(state.submits, 0, "a newline-on-Enter field does not submit");
assert_eq!(state.value, "a\nb", "the newline flows through on_change");
}
#[test]
fn shift_enter_submits_in_a_newline_on_enter_multiline() {
let mut state = AppState::default();
let mut root = RenderRoot::new();
root.rebuild(&mut multiline_logic, &mut state);
root.layout(Size::new(300.0, 800.0));
root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
root.event(&mut state, &ch("h"));
root.event(&mut state, &ch("i"));
let shift = Modifiers {
shift: true,
..Modifiers::default()
};
root.event(&mut state, &named(NamedKey::Enter, shift));
assert_eq!(state.submits, 1, "Shift+Enter inverts the default → submit");
assert_eq!(state.last_submit, "hi");
assert!(
!widget(&root).editor.text().contains('\n'),
"Shift+Enter must not insert a newline here"
);
}
#[test]
fn submit_on_enter_multiline_submits_and_shift_enter_inserts_newline() {
let mut state = AppState::default();
let mut root = RenderRoot::new();
root.rebuild(&mut multiline_submit_logic, &mut state);
root.layout(Size::new(300.0, 800.0));
root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
root.event(&mut state, &ch("h"));
root.event(&mut state, &ch("i"));
root.event(&mut state, &named(NamedKey::Enter, Modifiers::default()));
assert_eq!(
state.submits, 1,
"Enter submits when submit_on_enter is set"
);
assert!(!widget(&root).editor.text().contains('\n'));
let shift = Modifiers {
shift: true,
..Modifiers::default()
};
root.event(&mut state, &named(NamedKey::Enter, shift));
assert_eq!(state.submits, 1, "Shift+Enter does not submit here");
assert!(
widget(&root).editor.text().contains('\n'),
"Shift+Enter inserts a newline when submit_on_enter is set"
);
}
#[test]
fn ime_newline_commit_inserts_newline_in_multiline() {
let mut state = AppState::default();
let mut root = RenderRoot::new();
root.rebuild(&mut multiline_logic, &mut state);
root.layout(Size::new(300.0, 800.0));
root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
root.event(&mut state, &ch("a"));
root.event(
&mut state,
&InputEvent::Ime(ImeEvent::Commit("\n".to_string())),
);
assert!(
widget(&root).editor.text().contains('\n'),
"a newline commit lands a literal newline in a multi-line field"
);
assert_eq!(state.submits, 0, "the newline commit does not submit");
}
#[test]
fn arrow_up_down_move_caret_across_lines_in_multiline() {
let mut state = AppState::default();
let mut root = RenderRoot::new();
root.rebuild(&mut multiline_logic, &mut state);
root.layout(Size::new(300.0, 800.0));
root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
root.event(&mut state, &ch("a"));
root.event(&mut state, &ch("b"));
root.event(&mut state, &named(NamedKey::Enter, Modifiers::default()));
root.event(&mut state, &ch("c"));
root.event(&mut state, &ch("d")); assert_eq!(widget(&root).editor.editing_state_bytes().extent, 5);
root.event(&mut state, &named(NamedKey::ArrowUp, Modifiers::default()));
let up = widget(&root).editor.editing_state_bytes().extent;
assert!(up <= 2, "ArrowUp moves the caret onto line 1, got {up}");
root.event(
&mut state,
&named(NamedKey::ArrowDown, Modifiers::default()),
);
let down = widget(&root).editor.editing_state_bytes().extent;
assert!(
down >= 3,
"ArrowDown moves the caret back to line 2, got {down}"
);
}
#[test]
fn multiline_to_single_line_rebuild_resets_wrap_width() {
let long_text = "one two three four five six seven eight nine ten".to_string();
let mut state = AppState {
value: long_text.clone(),
..AppState::default()
};
let mut root = RenderRoot::new();
root.rebuild(&mut multiline_logic, &mut state);
let multi_height = root.layout(Size::new(120.0, 800.0)).height;
assert!(
widget(&root).editor.line_count() > 1,
"seed text must actually wrap for this regression to be meaningful"
);
root.rebuild(&mut build, &mut state);
let single_height = root.layout(Size::new(120.0, 800.0)).height;
assert_eq!(
widget(&root).editor.line_count(),
1,
"single-line relayout must reset the wrap width so the editor \
reflows back onto one line"
);
assert!(
single_height < multi_height,
"single-line relayout must collapse the stale wrapped height: \
multi={multi_height}, single={single_height}"
);
let mut fresh_state = AppState {
value: long_text,
..AppState::default()
};
let mut fresh_root = RenderRoot::new();
fresh_root.rebuild(&mut build, &mut fresh_state);
let fresh_height = fresh_root.layout(Size::new(120.0, 800.0)).height;
assert_eq!(
single_height, fresh_height,
"a multiline->single-line rebuild must match a field built \
single-line from scratch"
);
let w = widget(&root);
assert_eq!(
w.content_origin_y(single_height),
w.text_top(single_height),
"single-line mode must use the centered single-line placement"
);
}
#[test]
fn placeholder_inherits_font_family_from_style() {
use frust_text::{FontFamily, FontWeight};
let mut state = AppState::default();
let mut root = RenderRoot::new();
let custom_family = FontFamily::named("Monospace");
let custom_style = TextStyle {
family: custom_family.clone(),
weight: FontWeight::BOLD,
size: 18.0,
letter_spacing: 1.5,
..TextStyle::default()
};
root.rebuild(
&mut |s: &mut AppState| {
text_input(s.value.clone(), |_s: &mut AppState, _v: String| {})
.placeholder("Enter text")
.text_style(custom_style.clone())
},
&mut state,
);
root.layout(Size::new(300.0, 200.0));
let w = widget(&root);
assert_eq!(
w.style.family, custom_family,
"widget style should have the custom family"
);
assert_eq!(
w.style.weight,
FontWeight::BOLD,
"widget style should have the custom weight"
);
assert_eq!(
w.style.letter_spacing, 1.5,
"widget style should have the custom letter-spacing"
);
let mut sink = NullScene;
root.paint(&mut sink, FrameTime::ZERO);
}
#[test]
fn placeholder_font_reflects_configured_style_family() {
use frust_text::{FontFamily, GenericSlot};
let mut state_default = AppState::default();
let mut root_default = RenderRoot::new();
root_default.rebuild(
&mut |s: &mut AppState| {
text_input(s.value.clone(), |_s: &mut AppState, _v: String| {}).placeholder("test")
},
&mut state_default,
);
root_default.layout(Size::new(300.0, 200.0));
let default_font = painted_placeholder_font_bytes(&mut root_default);
let monospace_style = TextStyle {
family: FontFamily::stack_with_generic(Vec::<String>::new(), GenericSlot::Monospace),
..TextStyle::default()
};
let mut state_monospace = AppState::default();
let mut root_monospace = RenderRoot::new();
root_monospace.rebuild(
&mut |s: &mut AppState| {
text_input(s.value.clone(), |_s: &mut AppState, _v: String| {})
.placeholder("test")
.text_style(monospace_style.clone())
},
&mut state_monospace,
);
root_monospace.layout(Size::new(300.0, 200.0));
let monospace_font = painted_placeholder_font_bytes(&mut root_monospace);
assert_ne!(
default_font, monospace_font,
"placeholder with Monospace family should resolve to a different font \
than the default SystemUi family; if this fails, ph_style likely \
regressed to not preserving the configured family"
);
}
const TUFFY: &[u8] = include_bytes!("../../frust-text/tests/fonts/Tuffy-Subset.ttf");
const TUFFY_AS_HELVETICA: &[u8] =
include_bytes!("../../frust-text/tests/fonts/Tuffy-As-Helvetica.ttf");
const TUFFY_DIGIT_ADVANCE_24PX: f32 = 13.3125;
const ADVANCE_TOLERANCE: f32 = 0.25;
const DIGITS: &str = "0123456789";
#[derive(Default)]
struct ShapedRunRecorder {
runs: Vec<(Vec<u8>, Vec<f32>)>,
}
impl PaintScene for ShapedRunRecorder {
fn fill_rect(&mut self, _o: Point, _s: Size, _c: Color) {}
fn fill_rounded_rect(&mut self, _o: Point, _s: Size, _r: f64, _c: Color) {}
fn draw_text(&mut self, _o: Point, _t: &str) {}
fn draw_glyph_run(&mut self, run: frust_scene::GlyphRun) {
let advances = run.glyphs.windows(2).map(|w| w[1].x - w[0].x).collect();
self.runs
.push((run.font.font().data.as_ref().to_vec(), advances));
}
}
fn painted_shaped_run(
root: &mut RenderRoot<AppState, TextInputView<AppState>>,
) -> (Vec<u8>, Vec<f32>) {
let mut rec = ShapedRunRecorder::default();
root.paint(&mut rec, FrameTime::ZERO);
rec.runs.first().expect("one glyph run painted").clone()
}
fn family_style(family: frust_text::FontFamily) -> TextStyle {
TextStyle {
family,
..TextStyle::new(24.0, Color::BLACK)
}
}
fn field_in_family(
state: &mut AppState,
family: frust_text::FontFamily,
) -> RenderRoot<AppState, TextInputView<AppState>> {
let style = family_style(family);
let mut root = RenderRoot::new();
root.rebuild(
&mut |s: &mut AppState| {
text_input(s.value.clone(), |_s: &mut AppState, _v: String| {})
.placeholder(DIGITS)
.text_style(style.clone())
},
state,
);
root.layout(Size::new(600.0, 200.0));
root
}
fn assert_same_font(font: &[u8], expected: &[u8], what: &str) {
assert!(
font == expected,
"{what}: shaped against a different face than the registered app \
font ({} bytes shaped vs {} expected)",
font.len(),
expected.len()
);
}
fn assert_other_font(font: &[u8], other: &[u8], what: &str) {
assert!(
font != other,
"{what}: expected a different face, got the same {} bytes",
font.len()
);
}
fn assert_uniform_advance(advances: &[f32], expected: f32, what: &str) {
assert!(!advances.is_empty(), "{what}: no advances measured");
for a in advances {
assert!(
(a - expected).abs() <= ADVANCE_TOLERANCE,
"{what}: shaped advance {a} != the registered font's own {expected} \
(all advances: {advances:?})"
);
}
}
#[test]
fn app_registered_font_shapes_the_field_content() {
let mut shell_ctx = frust_text::TextContext::new();
shell_ctx
.register_fonts(TUFFY.to_vec())
.expect("valid TTF bytes must register");
let mut state = AppState {
value: DIGITS.to_string(),
..AppState::default()
};
let mut root = field_in_family(&mut state, frust_text::FontFamily::named("Tuffy"));
let (font, advances) = painted_shaped_run(&mut root);
assert_same_font(&font, TUFFY, "content");
assert_uniform_advance(&advances, TUFFY_DIGIT_ADVANCE_24PX, "content");
let mut fallback_state = AppState {
value: DIGITS.to_string(),
..AppState::default()
};
let mut fallback_root = field_in_family(
&mut fallback_state,
frust_text::FontFamily::named("Frust No Such Family"),
);
let (fallback_font, fallback_advances) = painted_shaped_run(&mut fallback_root);
assert_other_font(&fallback_font, TUFFY, "unregistered-family control");
assert_ne!(
fallback_advances, advances,
"fixture sanity: the fallback face must shape these digits to \
different metrics, or this test could pass without the app font"
);
}
#[test]
fn app_registered_font_shapes_the_placeholder() {
let mut shell_ctx = frust_text::TextContext::new();
shell_ctx
.register_fonts(TUFFY.to_vec())
.expect("valid TTF bytes must register");
let mut state = AppState::default();
let mut root = field_in_family(&mut state, frust_text::FontFamily::named("Tuffy"));
let (font, advances) = painted_shaped_run(&mut root);
assert_same_font(&font, TUFFY, "placeholder");
assert_uniform_advance(&advances, TUFFY_DIGIT_ADVANCE_24PX, "placeholder");
}
#[test]
fn font_registered_after_build_reshapes_the_field_at_the_next_layout() {
let mut state = AppState {
value: DIGITS.to_string(),
..AppState::default()
};
let mut root = field_in_family(&mut state, frust_text::FontFamily::named("Helvetica"));
let (before_font, before_advances) = painted_shaped_run(&mut root);
assert_other_font(
&before_font,
TUFFY_AS_HELVETICA,
"pre-registration control (nothing has registered this face yet)",
);
let mut shell_ctx = frust_text::TextContext::new();
shell_ctx
.register_fonts(TUFFY_AS_HELVETICA.to_vec())
.expect("valid TTF bytes must register");
root.layout(Size::new(600.0, 200.0));
let (after_font, after_advances) = painted_shaped_run(&mut root);
assert_same_font(&after_font, TUFFY_AS_HELVETICA, "late-registered");
assert_uniform_advance(&after_advances, TUFFY_DIGIT_ADVANCE_24PX, "late-registered");
assert_ne!(
after_advances, before_advances,
"the late registration must actually change the shaped metrics"
);
}
fn options_logic(
enabled: bool,
obscured: bool,
read_only: bool,
) -> impl FnMut(&mut AppState) -> TextInputView<AppState> {
move |state: &mut AppState| {
text_input(state.value.clone(), |s: &mut AppState, v: String| {
s.changes += 1;
s.value = v;
})
.placeholder("type here")
.enabled(enabled)
.obscured(obscured)
.read_only(read_only)
}
}
fn options_root(
logic: &mut impl FnMut(&mut AppState) -> TextInputView<AppState>,
state: &mut AppState,
) -> RenderRoot<AppState, TextInputView<AppState>> {
let mut root = RenderRoot::new();
root.rebuild(logic, state);
root.layout(Size::new(300.0, 200.0));
root
}
#[test]
fn disabled_field_refuses_focus_and_stays_inert() {
let mut state = AppState::default();
let mut logic = options_logic(false, false, false);
let mut root = options_root(&mut logic, &mut state);
let outcome = root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
assert!(!outcome.handled, "a disabled field consumes nothing");
assert!(
!root.is_focus_active(),
"a tap must not focus a disabled field"
);
assert!(!widget(&root).focused);
assert!(!widget(&root).captured, "no drag capture either");
assert!(root.ime_state().is_none(), "no IME surface is published");
root.event(&mut state, &ch("x"));
root.event(
&mut state,
&InputEvent::Ime(ImeEvent::Commit("ni".to_string())),
);
assert_eq!(widget(&root).editor.text(), "");
assert_eq!(state.changes, 0);
let mut rec = CaretRecorder {
caret_color: Some(CARET.multiply_alpha(DISABLED_CONTENT_ALPHA)),
caret_fills: 0,
};
let outcome = root.paint(&mut rec, FrameTime::ZERO);
assert_eq!(rec.caret_fills, 0, "a disabled field paints no caret");
assert!(!outcome.needs_frame, "a disabled field never blinks");
}
#[test]
fn disabling_a_focused_field_releases_focus_and_deactivates_ime() {
let mut state = AppState::default();
let mut enabled_logic = options_logic(true, false, false);
let mut root = options_root(&mut enabled_logic, &mut state);
root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
assert!(root.is_focus_active());
assert!(root.ime_state().expect("focused").active);
let mut disabled_logic = options_logic(false, false, false);
let flags = root.rebuild(&mut disabled_logic, &mut state);
assert!(
flags.needs_layout(),
"the disabled dim is baked at layout, so the flip must relayout"
);
root.layout(Size::new(300.0, 200.0));
assert!(
!widget(&root).focused,
"the widget stops considering itself focused immediately"
);
let mut sink = NullScene;
let outcome = root.paint(&mut sink, FrameTime::ZERO);
assert!(!outcome.needs_frame, "a disabled field paints at rest");
assert!(
root.ime_state().is_none(),
"the published inactive surface releases the session"
);
assert!(
!root.is_focus_active(),
"the root's focus mirror goes with it"
);
root.event(&mut state, &ch("x"));
assert!(
!root.is_focus_active(),
"the stranded focus path is released"
);
assert!(root.ime_state().is_none());
assert_eq!(widget(&root).editor.text(), "");
}
fn translucent_role_theme() -> Theme {
Theme::builder(Theme::neutral())
.design_language(frust_theme::DesignLanguage::Cupertino)
.map_colors_light(|c| frust_theme::ColorScheme {
on_surface_variant: c.on_surface_variant.multiply_alpha(0.6),
..c
})
.build()
}
#[test]
fn disabled_dims_content_and_outline_in_every_design_language() {
let translucent = translucent_role_theme();
assert!(
translucent.scheme().on_surface_variant.components[3] < 1.0,
"fixture sanity: the third arm's role must really be translucent"
);
let languages: Vec<(&str, Option<Theme>)> = vec![
("unthemed", None),
("neutral", Some(Theme::neutral())),
("translucent-role", Some(translucent)),
];
for (name, theme) in languages {
let enabled = Chrome::resolve(theme.as_ref(), true);
let disabled = Chrome::resolve(theme.as_ref(), false);
assert_eq!(
disabled.placeholder,
enabled.placeholder.multiply_alpha(DISABLED_CONTENT_ALPHA),
"{name}: disabled placeholder is the enabled role at 38%"
);
assert_eq!(
disabled.border,
enabled.border.multiply_alpha(DISABLED_CONTAINER_ALPHA),
"{name}: disabled outline is the enabled role at 12%"
);
assert!(
disabled.placeholder.components[3] < enabled.placeholder.components[3],
"{name}: the disabled placeholder must actually be more transparent"
);
assert_eq!(
disabled.bg, enabled.bg,
"{name}: the container fill stays opaque (see Chrome::resolve)"
);
}
}
#[test]
fn disabled_dims_the_layout_baked_glyph_color() {
let mut state = AppState {
value: "hi".to_string(),
..AppState::default()
};
let mut logic = options_logic(false, false, false);
let mut root = options_root(&mut logic, &mut state);
root.set_theme(Box::new(Theme::neutral()));
root.layout(Size::new(300.0, 200.0));
let expected = Theme::neutral()
.scheme()
.on_surface
.multiply_alpha(DISABLED_CONTENT_ALPHA);
assert_eq!(
painted_text_color(&mut root),
expected,
"a disabled field's glyphs are on_surface at 38%"
);
}
#[test]
fn disabled_dims_the_unthemed_fallback_glyph_color() {
let mut state = AppState {
value: "hi".to_string(),
..AppState::default()
};
let mut logic = options_logic(false, false, false);
let mut root = options_root(&mut logic, &mut state);
assert_eq!(
painted_text_color(&mut root),
Color::BLACK.multiply_alpha(DISABLED_CONTENT_ALPHA),
"with no theme threaded the black fallback dims by the same rule"
);
}
fn read_only_root(
state: &mut AppState,
logic: &mut impl FnMut(&mut AppState) -> TextInputView<AppState>,
) -> RenderRoot<AppState, TextInputView<AppState>> {
options_root(logic, state)
}
fn drag_select_all(
state: &mut AppState,
root: &mut RenderRoot<AppState, TextInputView<AppState>>,
) {
root.event(state, &pointer(PointerPhase::Down, 0.0, 10.0));
root.event(state, &pointer(PointerPhase::Move, 290.0, 10.0));
root.event(state, &pointer(PointerPhase::Up, 290.0, 10.0));
}
#[test]
fn read_only_takes_focus_on_a_press_and_paints_focused() {
let mut state = AppState {
value: "abc".to_string(),
..AppState::default()
};
let mut logic = options_logic(true, false, true);
let mut root = read_only_root(&mut state, &mut logic);
let outcome = root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
assert!(
outcome.handled,
"the press is consumed, like any focus claim"
);
assert!(root.is_focus_active(), "a press focuses a read-only field");
assert!(widget(&root).focused);
assert!(widget(&root).captured, "and captures, so a drag can select");
let rec = paint_chrome(&mut root);
assert_eq!(
rec.rrects[0], ACCENT,
"a focused read-only field paints the focused accent border"
);
assert_eq!(rec.rects, vec![CARET], "and draws its caret");
}
#[test]
fn a_read_only_caret_is_drawn_steady_and_asks_for_no_blink_frames() {
let mut state = AppState {
value: "abc".to_string(),
..AppState::default()
};
let mut logic = options_logic(true, false, true);
let mut root = read_only_root(&mut state, &mut logic);
root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
root.event(&mut state, &pointer(PointerPhase::Up, 10.0, 10.0));
for ms in [0.0, BLINK_MS + 10.0, 2.0 * BLINK_MS + 10.0] {
let mut rec = CaretRecorder {
caret_color: Some(CARET),
caret_fills: 0,
};
let outcome = root.paint(&mut rec, ft_ms(ms));
assert_eq!(rec.caret_fills, 1, "the caret stays lit at t={ms}ms");
assert!(
!outcome.needs_frame,
"no continuation frame is asked for at t={ms}ms"
);
}
}
#[test]
fn a_focused_read_only_field_publishes_an_active_keyboard_suppressed_surface() {
let mut state = AppState {
value: "abc".to_string(),
..AppState::default()
};
let mut logic = options_logic(true, false, true);
let mut root = read_only_root(&mut state, &mut logic);
root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
let ime = root.ime_state().expect("a focused read-only field has one");
assert!(ime.active, "the surface is live, not released");
assert!(ime.suppress_soft_keyboard);
assert_eq!(ime.editing.text, "abc");
let mut state = AppState::default();
let mut editable_logic = options_logic(true, false, false);
let mut root = options_root(&mut editable_logic, &mut state);
root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
let ime = root.ime_state().expect("a focused editable field has one");
assert!(ime.active);
assert!(
!ime.suppress_soft_keyboard,
"an editable field wants its keyboard"
);
}
#[test]
fn read_only_copies_a_drag_selection_and_selects_all_from_the_chords() {
let mut state = AppState {
value: "abc".to_string(),
..AppState::default()
};
let mut logic = options_logic(true, false, true);
let mut root = read_only_root(&mut state, &mut logic);
drag_select_all(&mut state, &mut root);
assert_eq!(
selection(&root).as_deref(),
Some("abc"),
"a drag selects on a read-only field"
);
root.event(&mut state, &chord("c", meta()));
assert_eq!(
root.take_clipboard_write().as_deref(),
Some("abc"),
"copy is the verb read-only exists to allow"
);
assert_eq!(state.changes, 0, "a copy is not an edit");
let mut state = AppState {
value: "abc".to_string(),
..AppState::default()
};
let mut logic = options_logic(true, false, true);
let mut root = read_only_root(&mut state, &mut logic);
root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
root.event(&mut state, &pointer(PointerPhase::Up, 10.0, 10.0));
assert_eq!(selection(&root), None, "sanity: nothing selected yet");
root.event(&mut state, &chord("a", meta()));
assert_eq!(selection(&root).as_deref(), Some("abc"));
assert_eq!(state.changes, 0);
}
#[test]
fn read_only_answers_cut_and_paste_with_nothing() {
let mut state = AppState {
value: "abc".to_string(),
..AppState::default()
};
let mut logic = options_logic(true, false, true);
let mut root = read_only_root(&mut state, &mut logic);
drag_select_all(&mut state, &mut root);
let outcome = root.event(&mut state, &chord("x", meta()));
assert!(outcome.handled, "the cut chord is consumed");
assert_eq!(
root.take_clipboard_write(),
None,
"a refused cut writes nothing — half a cut is not a copy"
);
let outcome = root.event(&mut state, &edit(EditCommand::Paste("zz".to_string())));
assert!(outcome.handled, "the paste command is consumed");
assert_eq!(
widget(&root).editor.text(),
"abc",
"the buffer is untouched"
);
assert_eq!(state.changes, 0, "and no on_change fires for either");
assert_eq!(state.value, "abc");
}
#[test]
fn read_only_still_refuses_typing_and_ime_commits() {
let mut state = AppState {
value: "abc".to_string(),
..AppState::default()
};
let mut logic = options_logic(true, false, true);
let mut root = read_only_root(&mut state, &mut logic);
root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
assert!(root.is_focus_active(), "refused while genuinely focused");
for event in [
ch("x"),
InputEvent::Ime(ImeEvent::Commit("ni".to_string())),
named(NamedKey::Backspace, Modifiers::default()),
named(NamedKey::Delete, Modifiers::default()),
named(NamedKey::Enter, Modifiers::default()),
] {
let outcome = root.event(&mut state, &event);
assert!(
!outcome.handled,
"an edit is refused unconsumed, as it was when the field \
refused focus outright: {event:?}"
);
}
assert_eq!(widget(&root).editor.text(), "abc");
assert_eq!(state.changes, 0);
assert_eq!(state.submits, 0, "Enter submits nothing either");
}
#[test]
fn escape_ends_a_read_only_session_without_touching_the_text() {
let mut state = AppState {
value: "abc".to_string(),
..AppState::default()
};
let mut logic = options_logic(true, false, true);
let mut root = read_only_root(&mut state, &mut logic);
root.event(
&mut state,
&secondary_pointer(PointerPhase::Down, 20.0, 10.0),
);
assert!(root.is_focus_active(), "the context press opened a session");
assert!(widget(&root).toolbar_open, "…with the bar up");
let outcome = root.event(&mut state, &named(NamedKey::Escape, Modifiers::default()));
assert!(
outcome.handled,
"Escape is answered, not left for an ancestor"
);
assert!(!root.is_focus_active(), "the session is over");
assert!(!widget(&root).focused);
assert!(
!widget(&root).toolbar_open,
"the toolbar goes with the session it belonged to"
);
assert_eq!(widget(&root).editor.text(), "abc", "and nothing was edited");
assert_eq!(state.changes, 0);
}
#[test]
fn disabled_still_refuses_focus_where_read_only_no_longer_does() {
let mut state = AppState {
value: "abc".to_string(),
..AppState::default()
};
let mut logic = options_logic(false, false, false);
let mut root = options_root(&mut logic, &mut state);
let outcome = root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
assert!(!outcome.handled, "a disabled field consumes nothing");
assert!(!root.is_focus_active(), "and takes no focus");
assert!(root.ime_state().is_none(), "so it publishes no surface");
let mut rec = CaretRecorder {
caret_color: Some(CARET),
caret_fills: 0,
};
let outcome = root.paint(&mut rec, FrameTime::ZERO);
assert_eq!(rec.caret_fills, 0, "a disabled field paints no caret");
assert!(!outcome.needs_frame);
}
#[test]
fn making_a_focused_field_read_only_keeps_the_session_and_drops_the_keyboard() {
let mut state = AppState {
value: "abc".to_string(),
..AppState::default()
};
let mut live_logic = options_logic(true, false, false);
let mut root = options_root(&mut live_logic, &mut state);
root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
assert!(root.is_focus_active());
let ime = root.ime_state().expect("focused");
assert!(ime.active && !ime.suppress_soft_keyboard);
let mut read_only_logic = options_logic(true, false, true);
let flags = root.rebuild(&mut read_only_logic, &mut state);
assert!(
!flags.needs_layout(),
"read-only never dims, so the flip is PAINT-only, unlike disabled"
);
root.layout(Size::new(300.0, 200.0));
assert!(
widget(&root).focused,
"the widget keeps considering itself focused"
);
let mut rec = CaretRecorder {
caret_color: Some(CARET),
caret_fills: 0,
};
root.paint(&mut rec, FrameTime::ZERO);
assert_eq!(rec.caret_fills, 1, "the caret survives the flip");
let ime = root.ime_state().expect("the session survives the flip");
assert!(ime.active, "the surface stays live for the clipboard route");
assert!(ime.suppress_soft_keyboard, "but the keyboard is asked down");
assert!(root.is_focus_active(), "the root's focus mirror stays");
root.event(&mut state, &ch("x"));
assert!(root.is_focus_active(), "a refused key does not blur");
assert_eq!(widget(&root).editor.text(), "abc");
assert_eq!(state.changes, 0);
}
#[test]
fn read_only_paints_full_alpha_chrome_in_every_design_language() {
let languages: Vec<(&str, Option<Theme>)> = vec![
("unthemed", None),
("neutral", Some(Theme::neutral())),
("translucent-role", Some(translucent_role_theme())),
];
for (name, theme) in languages {
let mut state = AppState::default();
let mut logic = options_logic(true, false, true);
let mut root = options_root(&mut logic, &mut state);
if let Some(theme) = theme.clone() {
root.set_theme(Box::new(theme));
}
let live_border = Chrome::resolve(theme.as_ref(), true).border;
let rec = paint_chrome(&mut root);
assert_eq!(
rec.rrects[0], live_border,
"{name}: a read-only field's idle border matches the fully-\
enabled resolved border — not `DISABLED_CONTAINER_ALPHA`-dimmed"
);
}
}
#[test]
fn read_only_paints_full_alpha_layout_baked_glyph_color() {
let mut state = AppState {
value: "hi".to_string(),
..AppState::default()
};
let mut logic = options_logic(true, false, true);
let mut root = options_root(&mut logic, &mut state);
root.set_theme(Box::new(Theme::neutral()));
root.layout(Size::new(300.0, 200.0));
assert_eq!(
painted_text_color(&mut root),
Theme::neutral().scheme().on_surface,
"a read-only field's glyphs stay full-alpha on_surface, not dimmed"
);
}
#[test]
fn read_only_paints_full_alpha_unthemed_fallback_glyph_color() {
let mut state = AppState {
value: "hi".to_string(),
..AppState::default()
};
let mut logic = options_logic(true, false, true);
let mut root = options_root(&mut logic, &mut state);
assert_eq!(
painted_text_color(&mut root),
Color::BLACK,
"with no theme threaded, read-only stays the undimmed black fallback"
);
}
#[test]
fn switching_a_read_only_field_to_live_shows_no_alpha_change() {
let mut state = AppState {
value: "hi".to_string(),
..AppState::default()
};
let mut read_only_logic = options_logic(true, false, true);
let mut root = options_root(&mut read_only_logic, &mut state);
let before_glyph = painted_text_color(&mut root);
let before_border = paint_chrome(&mut root).rrects[0];
let mut live_logic = options_logic(true, false, false);
root.rebuild(&mut live_logic, &mut state);
root.layout(Size::new(300.0, 200.0));
let after_glyph = painted_text_color(&mut root);
let after_border = paint_chrome(&mut root).rrects[0];
assert_eq!(
before_glyph, after_glyph,
"glyph color must not change across the read-only -> live handoff"
);
assert_eq!(
before_border, after_border,
"border color must not change across the read-only -> live handoff"
);
assert_eq!(
before_glyph,
Color::BLACK,
"sanity: read-only starts undimmed"
);
}
fn a11y_verbs(
root: &RenderRoot<AppState, TextInputView<AppState>>,
) -> (bool, Vec<(i32, String)>) {
let update = root.semantics();
let (_, node) = update
.nodes
.iter()
.find(|(_, n)| matches!(n.role(), Role::TextInput | Role::PasswordInput))
.expect("a text field node");
(
node.supports_action(Action::CustomAction),
node.custom_actions()
.iter()
.map(|a| (a.id, a.description.to_string()))
.collect(),
)
}
#[test]
fn the_clipboard_verbs_ride_the_field_s_own_node_with_the_toolbar_closed() {
let mut state = AppState::default();
let mut root = harness(&mut state);
focused_with_selection(&mut state, &mut root, "abc");
assert!(
!widget(&root).toolbar_open,
"sanity: a drag-select raises no bar, so this is the closed case"
);
let (supports, offered) = a11y_verbs(&root);
assert!(
supports,
"the field advertises that it takes custom actions at all"
);
assert_eq!(
offered,
vec![
(A11Y_CUT_ID, "Cut".to_string()),
(A11Y_COPY_ID, "Copy".to_string()),
(A11Y_PASTE_ID, "Paste".to_string()),
],
"an interactive field with all of its text selected offers cut/copy/\
paste, and no select-all — exactly the bar's own enabled set"
);
let copy_id = offered
.iter()
.find(|(_, label)| label == "Copy")
.expect("copy was offered")
.0;
let cmd = match copy_id {
A11Y_CUT_ID => EditCommand::Cut,
A11Y_COPY_ID => EditCommand::Copy,
A11Y_SELECT_ALL_ID => EditCommand::SelectAll,
other => panic!("the published id {other} resolves to no verb"),
};
root.event(&mut state, &edit(cmd));
assert_eq!(
root.take_clipboard_write().as_deref(),
Some("abc"),
"the advertised id resolves to a verb that reaches handle_command"
);
}
#[test]
fn the_published_verbs_track_the_field_s_own_refusals() {
let mut state = AppState::default();
let mut logic = options_logic(true, false, false);
let root = options_root(&mut logic, &mut state);
assert_eq!(
a11y_verbs(&root).1,
vec![(A11Y_PASTE_ID, "Paste".to_string())],
"an empty field offers only paste"
);
let mut state = AppState {
value: "hunter2".to_string(),
..Default::default()
};
let mut logic = options_logic(true, true, false);
let mut root = options_root(&mut logic, &mut state);
focused_with_selection(&mut state, &mut root, "");
assert!(
selection(&root).is_some(),
"sanity: the refusal below is only meaningful over a real selection"
);
assert_eq!(
a11y_verbs(&root).1,
vec![(A11Y_PASTE_ID, "Paste".to_string())],
"an obscured field hands out neither the buffer nor its bullets"
);
let mut state = AppState {
value: "abc".to_string(),
..Default::default()
};
let mut logic = options_logic(true, false, true);
let mut root = options_root(&mut logic, &mut state);
drag_select_all(&mut state, &mut root);
assert_eq!(
a11y_verbs(&root).1,
vec![(A11Y_COPY_ID, "Copy".to_string())],
"a read-only field offers copy over its selection, never cut or paste"
);
let mut state = AppState {
value: "abc".to_string(),
..Default::default()
};
let mut logic = options_logic(false, false, false);
let root = options_root(&mut logic, &mut state);
assert!(
a11y_verbs(&root).1.is_empty(),
"a disabled field advertises no verbs either"
);
}
#[test]
fn read_only_reports_read_only_not_disabled_semantics() {
let mut state = AppState::default();
let mut logic = options_logic(true, false, true);
let root = options_root(&mut logic, &mut state);
let update = root.semantics();
let (_, node) = update
.nodes
.iter()
.find(|(_, n)| n.role() == Role::TextInput)
.expect("a text field node");
assert!(
node.is_read_only(),
"a read-only field says so to a11y, distinct from disabled"
);
assert!(
!node.is_disabled(),
"read-only is not the same claim as disabled"
);
}
#[test]
fn disabled_wins_semantics_over_read_only_when_both_are_set() {
let mut state = AppState::default();
let mut logic = options_logic(false, false, true);
let root = options_root(&mut logic, &mut state);
let update = root.semantics();
let (_, node) = update
.nodes
.iter()
.find(|(_, n)| n.role() == Role::TextInput)
.expect("a text field node");
assert!(node.is_disabled(), "disabled is the stronger claim");
assert!(
!node.is_read_only(),
"disabled and read-only are never both reported"
);
}
#[test]
fn read_only_obscured_field_stays_password_role_and_copies_nothing() {
let mut state = AppState {
value: "hunter2".to_string(),
..AppState::default()
};
let mut logic = options_logic(true, true, true);
let mut root = options_root(&mut logic, &mut state);
let update = root.semantics();
let (_, node) = update
.nodes
.iter()
.find(|(_, n)| n.role() == Role::PasswordInput)
.expect("read-only + obscured still contributes Role::PasswordInput");
assert_eq!(node.value(), Some("\u{2022}".repeat(7).as_str()));
assert!(node.is_read_only());
drag_select_all(&mut state, &mut root);
assert!(
root.is_focus_active(),
"it focuses like any read-only field"
);
assert!(
selection(&root).is_some(),
"sanity: the refusal below is only meaningful over a real selection"
);
let ime = root.ime_state().expect("focused");
assert_eq!(
ime.content_type,
ImeContentType::Password,
"the content-type hint is read-only's business to leave alone"
);
assert!(ime.suppress_soft_keyboard);
root.event(&mut state, &chord("c", meta()));
assert_eq!(
root.take_clipboard_write(),
None,
"an obscured field copies nothing, read-only or not"
);
assert!(
a11y_verbs(&root).1.is_empty(),
"and advertises no verb it would refuse"
);
}
#[test]
fn mask_offsets_map_1_to_1_per_char_across_a_multi_byte_grapheme() {
let text = "a\u{1F600}b";
assert_eq!(mask_text(text), "\u{2022}\u{2022}\u{2022}");
for (real, masked) in [(0, 0), (1, 3), (5, 6), (6, 9)] {
assert_eq!(real_to_masked(text, real), masked, "real {real} -> masked");
assert_eq!(
masked_to_real(text, masked),
real,
"masked {masked} -> real"
);
}
assert_eq!(real_to_masked(text, 3), 3, "mid-emoji snaps to its start");
assert_eq!(masked_to_real(text, 4), 1, "mid-mask snaps to its start");
assert_eq!(mask_text("a\nb"), "\u{2022}\n\u{2022}");
}
#[test]
fn obscured_paints_bullets_and_keeps_the_real_value() {
#[derive(Default)]
struct GlyphIdRecorder {
ids: Vec<u16>,
}
impl PaintScene for GlyphIdRecorder {
fn fill_rect(&mut self, _o: Point, _s: Size, _c: Color) {}
fn fill_rounded_rect(&mut self, _o: Point, _s: Size, _r: f64, _c: Color) {}
fn draw_text(&mut self, _o: Point, _t: &str) {}
fn draw_glyph_run(&mut self, run: frust_scene::GlyphRun) {
self.ids.extend(run.glyphs.iter().map(|g| g.id as u16));
}
}
fn ids(root: &mut RenderRoot<AppState, TextInputView<AppState>>) -> Vec<u16> {
let mut rec = GlyphIdRecorder::default();
root.paint(&mut rec, FrameTime::ZERO);
rec.ids
}
let mut secret_state = AppState {
value: "abc".to_string(),
..AppState::default()
};
let mut secret_logic = options_logic(true, true, false);
let mut secret = options_root(&mut secret_logic, &mut secret_state);
let mut bullets_state = AppState {
value: "\u{2022}\u{2022}\u{2022}".to_string(),
..AppState::default()
};
let mut plain_logic = options_logic(true, false, false);
let mut bullets = options_root(&mut plain_logic, &mut bullets_state);
let mut clear_state = AppState {
value: "abc".to_string(),
..AppState::default()
};
let mut clear = options_root(&mut plain_logic, &mut clear_state);
assert_eq!(
ids(&mut secret),
ids(&mut bullets),
"an obscured field paints bullets"
);
assert_ne!(
ids(&mut secret),
ids(&mut clear),
"…and not the real characters"
);
assert_eq!(
widget(&secret).editor.text(),
"abc",
"the model text is untouched by masking"
);
}
#[test]
fn obscured_measures_from_the_masked_text_not_the_real_one() {
let mut secret_state = AppState {
value: "iiiiiiiiii".to_string(),
..AppState::default()
};
let mut secret_logic = options_logic(true, true, false);
let secret = options_root(&mut secret_logic, &mut secret_state);
let mut bullets_state = AppState {
value: "\u{2022}".repeat(10),
..AppState::default()
};
let mut plain_logic = options_logic(true, false, false);
let bullets = options_root(&mut plain_logic, &mut bullets_state);
let masked_w = widget(&secret).display().layout_size().width;
let bullets_w = widget(&bullets).display().layout_size().width;
let real_w = widget(&secret).editor.layout_size().width;
assert_eq!(masked_w, bullets_w, "measured from the masked mirror");
assert!(
masked_w > real_w,
"sanity: bullets are wider than 'i's ({masked_w} vs {real_w})"
);
}
#[test]
fn obscured_editing_matches_unobscured_across_a_multi_byte_grapheme() {
let mut secret_state = AppState::default();
let mut secret_logic = options_logic(true, true, false);
let mut secret = options_root(&mut secret_logic, &mut secret_state);
let mut clear_state = AppState::default();
let mut clear_logic = options_logic(true, false, false);
let mut clear = options_root(&mut clear_logic, &mut clear_state);
let plain = Modifiers::default();
let meta = Modifiers {
meta: true,
..Modifiers::default()
};
let events = vec![
pointer(PointerPhase::Down, 10.0, 10.0),
ch("a"),
ch("\u{1F600}"),
ch("b"),
named(NamedKey::ArrowLeft, plain),
named(NamedKey::Backspace, plain),
named(NamedKey::End, plain),
named(NamedKey::Backspace, plain),
InputEvent::Key(KeyEvent {
key: Key::Character("a".to_string()),
modifiers: meta,
repeat: false,
}),
];
for (i, event) in events.iter().enumerate() {
secret.event(&mut secret_state, event);
clear.event(&mut clear_state, event);
assert_eq!(
widget(&secret).editor.editing_state_bytes(),
widget(&clear).editor.editing_state_bytes(),
"editing state diverged at step {i}"
);
}
assert_eq!(secret_state.value, "a");
assert_eq!(secret_state.value, clear_state.value);
assert_eq!(secret_state.changes, clear_state.changes);
}
#[test]
fn obscured_caret_rect_follows_the_masked_layout() {
let mut secret_state = AppState {
value: "iiii".to_string(),
..AppState::default()
};
let mut secret_logic = options_logic(true, true, false);
let mut secret = options_root(&mut secret_logic, &mut secret_state);
secret.event(&mut secret_state, &pointer(PointerPhase::Down, 290.0, 10.0));
let masked_caret = secret
.ime_state()
.expect("focused")
.caret
.expect("a caret rect");
let mut bullets_state = AppState {
value: "\u{2022}".repeat(4),
..AppState::default()
};
let mut plain_logic = options_logic(true, false, false);
let mut bullets = options_root(&mut plain_logic, &mut bullets_state);
bullets.event(
&mut bullets_state,
&pointer(PointerPhase::Down, 290.0, 10.0),
);
let bullets_caret = bullets
.ime_state()
.expect("focused")
.caret
.expect("a caret rect");
assert_eq!(
masked_caret, bullets_caret,
"the obscured caret tracks the masked glyphs"
);
assert_eq!(
secret.ime_state().expect("focused").editing.text,
"iiii",
"the IME surface still carries the real text (see the module docs)"
);
}
#[test]
fn obscured_tap_places_the_caret_from_the_masked_hit_test() {
let mut state = AppState {
value: "iiiiiiii".to_string(),
..AppState::default()
};
let mut logic = options_logic(true, true, false);
let mut root = options_root(&mut logic, &mut state);
let half_bullet = widget(&root).display().layout_size().width / 16.0;
root.event(
&mut state,
&pointer(PointerPhase::Down, PAD_X + half_bullet, 10.0),
);
let extent = widget(&root).editor.editing_state_bytes().extent;
assert!(
extent <= 1,
"a tap inside the first mask glyph lands at byte 0 or 1, got {extent}"
);
}
#[test]
fn obscured_reports_password_role_with_a_masked_value() {
let mut state = AppState {
value: "hunter2".to_string(),
..AppState::default()
};
let mut logic = options_logic(true, true, false);
let root = options_root(&mut logic, &mut state);
let update = root.semantics();
let (_, node) = update
.nodes
.iter()
.find(|(_, n)| n.role() == Role::PasswordInput)
.expect("an obscured field contributes a Role::PasswordInput node");
assert_eq!(
node.value(),
Some("\u{2022}".repeat(7).as_str()),
"the a11y value is masked too — a client reads it verbatim"
);
let mut plain_logic = options_logic(true, false, false);
let plain = options_root(&mut plain_logic, &mut state);
let update = plain.semantics();
let (_, node) = update
.nodes
.iter()
.find(|(_, n)| n.role() == Role::TextInput)
.expect("a clear field stays Role::TextInput");
assert_eq!(node.value(), Some("hunter2"));
assert!(!node.is_disabled(), "an enabled field is not disabled");
}
#[test]
fn disabled_reports_disabled_semantics() {
let mut state = AppState::default();
let mut logic = options_logic(false, false, false);
let root = options_root(&mut logic, &mut state);
let update = root.semantics();
let (_, node) = update
.nodes
.iter()
.find(|(_, n)| n.role() == Role::TextInput)
.expect("a text field node");
assert!(node.is_disabled(), "a disabled field says so to a11y");
}
#[test]
fn toggling_obscured_relayouts_and_keeps_the_value() {
let mut state = AppState {
value: "iiiiiiii".to_string(),
..AppState::default()
};
let mut clear_logic = options_logic(true, false, false);
let mut root = options_root(&mut clear_logic, &mut state);
let clear_width = widget(&root).display().layout_size().width;
let mut secret_logic = options_logic(true, true, false);
let flags = root.rebuild(&mut secret_logic, &mut state);
assert!(
flags.needs_layout(),
"masking changes the measured text, so the flip must relayout"
);
root.layout(Size::new(300.0, 200.0));
assert!(
widget(&root).display().layout_size().width > clear_width,
"the masked mirror is measured after the flip"
);
assert_eq!(widget(&root).editor.text(), "iiiiiiii");
root.rebuild(&mut clear_logic, &mut state);
root.layout(Size::new(300.0, 200.0));
assert_eq!(widget(&root).display().layout_size().width, clear_width);
assert!(widget(&root).mask_editor.is_none());
}
#[test]
fn obscured_publishes_password_content_type_across_focus_edit_and_refocus() {
let mut state = AppState {
value: "hunter2".to_string(),
..AppState::default()
};
let mut logic = options_logic(true, true, false);
let mut root = options_root(&mut logic, &mut state);
root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
assert_eq!(
root.ime_state().expect("focused").content_type,
ImeContentType::Password,
"an obscured field's very first published IME surface must already \
be Password"
);
root.event(&mut state, &ch("x"));
assert_eq!(
root.ime_state().expect("still focused").content_type,
ImeContentType::Password,
"content type must not drop on edit"
);
root.event(&mut state, &named(NamedKey::Escape, Modifiers::default()));
assert!(root.ime_state().is_none(), "blur unpublishes the surface");
root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
assert_eq!(
root.ime_state().expect("re-focused").content_type,
ImeContentType::Password,
"content type must be correct again on re-focus, not just the first time"
);
}
#[test]
fn unobscured_field_publishes_the_default_no_hint_content_type() {
let mut state = AppState::default();
let mut root = harness(&mut state);
root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
assert_eq!(
root.ime_state().expect("focused").content_type,
ImeContentType::Normal
);
root.event(&mut state, &ch("h"));
assert_eq!(
root.ime_state().expect("still focused").content_type,
ImeContentType::Normal
);
}
#[test]
fn obscured_content_type_is_correct_on_the_first_publication_after_focus() {
let mut state = AppState {
value: "hunter2".to_string(),
..AppState::default()
};
let mut logic = options_logic(true, true, false);
let mut root = options_root(&mut logic, &mut state);
assert!(
root.ime_state().is_none(),
"an unfocused field publishes nothing yet"
);
root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
let ime = root
.ime_state()
.expect("focusing publishes the first IME surface");
assert_eq!(
ime.content_type,
ImeContentType::Password,
"the first-ever publication for an obscured field must already \
carry Password"
);
assert_eq!(
ime.editing.text, "hunter2",
"sanity: this is the real first publication, not a stale one"
);
}
fn edit(cmd: EditCommand) -> InputEvent {
InputEvent::EditCommand(cmd)
}
fn focused_with_selection(
state: &mut AppState,
root: &mut RenderRoot<AppState, TextInputView<AppState>>,
text: &str,
) {
root.event(state, &pointer(PointerPhase::Down, 10.0, 10.0));
for c in text.chars() {
root.event(state, &ch(&c.to_string()));
}
root.event(state, &pointer(PointerPhase::Down, 0.0, 10.0));
root.event(state, &pointer(PointerPhase::Move, 290.0, 10.0));
root.event(state, &pointer(PointerPhase::Up, 290.0, 10.0));
}
fn meta() -> Modifiers {
Modifiers {
meta: true,
..Modifiers::default()
}
}
fn ctrl() -> Modifiers {
Modifiers {
ctrl: true,
..Modifiers::default()
}
}
fn shift() -> Modifiers {
Modifiers {
shift: true,
..Modifiers::default()
}
}
fn chord(text: &str, modifiers: Modifiers) -> InputEvent {
InputEvent::Key(KeyEvent {
key: Key::Character(text.to_string()),
modifiers,
repeat: false,
})
}
#[test]
fn copy_writes_the_selection_and_edits_nothing() {
let mut state = AppState::default();
let mut root = harness(&mut state);
focused_with_selection(&mut state, &mut root, "abc");
assert_eq!(
widget(&root).editor.selected_text(),
Some("abc"),
"the drag selected the whole buffer"
);
let changes = state.changes;
root.event(&mut state, &edit(EditCommand::Copy));
assert_eq!(root.take_clipboard_write().as_deref(), Some("abc"));
assert_eq!(widget(&root).editor.text(), "abc", "a copy mutates nothing");
assert_eq!(state.changes, changes, "a copy is not an edit");
assert_eq!(state.value, "abc");
}
#[test]
fn cut_writes_the_selection_removes_it_and_reports_one_change() {
let mut state = AppState::default();
let mut root = harness(&mut state);
focused_with_selection(&mut state, &mut root, "abc");
let changes = state.changes;
root.event(&mut state, &edit(EditCommand::Cut));
assert_eq!(root.take_clipboard_write().as_deref(), Some("abc"));
assert_eq!(widget(&root).editor.text(), "", "the selection is gone");
assert_eq!(state.changes, changes + 1, "one on_change, not two");
assert_eq!(state.value, "");
}
#[test]
fn copy_and_cut_do_nothing_without_a_selection() {
let mut state = AppState::default();
let mut root = harness(&mut state);
root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
for c in ["a", "b"] {
root.event(&mut state, &ch(c));
}
let changes = state.changes;
root.event(&mut state, &edit(EditCommand::Copy));
assert!(root.take_clipboard_write().is_none());
root.event(&mut state, &edit(EditCommand::Cut));
assert!(root.take_clipboard_write().is_none());
assert_eq!(widget(&root).editor.text(), "ab");
assert_eq!(state.changes, changes, "neither verb fired on_change");
}
#[test]
fn paste_into_a_single_line_field_strips_newlines_and_replaces_the_selection() {
let mut state = AppState::default();
let mut root = harness(&mut state);
focused_with_selection(&mut state, &mut root, "xy");
let changes = state.changes;
root.event(&mut state, &edit(EditCommand::Paste("a\nb".to_string())));
assert_eq!(
widget(&root).editor.text(),
"ab",
"the newline is denied and the selection replaced"
);
assert_eq!(state.changes, changes + 1, "one edit, one on_change");
assert_eq!(state.value, "ab");
}
#[test]
fn paste_into_a_multiline_field_keeps_the_newline() {
let mut state = AppState::default();
let mut root = RenderRoot::new();
root.rebuild(&mut multiline_logic, &mut state);
root.layout(Size::new(300.0, 200.0));
root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
root.event(&mut state, &edit(EditCommand::Paste("a\nb".to_string())));
assert_eq!(widget(&root).editor.text(), "a\nb");
assert_eq!(state.changes, 1);
}
#[test]
fn a_paste_sanitised_to_nothing_changes_nothing() {
let mut state = AppState::default();
let mut root = harness(&mut state);
root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
root.event(&mut state, &ch("a"));
let changes = state.changes;
let outcome = root.event(&mut state, &edit(EditCommand::Paste("\n".to_string())));
assert!(outcome.handled, "the verb was understood, and answered");
assert_eq!(widget(&root).editor.text(), "a", "nothing was inserted");
assert_eq!(state.changes, changes, "an empty paste is not an edit");
}
#[test]
fn select_all_via_the_command_selects_the_whole_buffer_without_typing() {
let mut state = AppState::default();
let mut root = harness(&mut state);
root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
for c in ["a", "b", "c"] {
root.event(&mut state, &ch(c));
}
let changes = state.changes;
root.event(&mut state, &edit(EditCommand::SelectAll));
assert_eq!(widget(&root).editor.selected_text(), Some("abc"));
assert_eq!(state.changes, changes);
assert_eq!(widget(&root).editor.text(), "abc");
}
#[test]
fn an_obscured_field_refuses_copy_and_cut_but_still_pastes() {
let mut state = AppState::default();
let mut logic = options_logic(true, true, false);
let mut root = options_root(&mut logic, &mut state);
root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
for c in ["a", "b", "c"] {
root.event(&mut state, &ch(c));
}
root.event(&mut state, &edit(EditCommand::SelectAll));
assert_eq!(widget(&root).editor.selected_text(), Some("abc"));
let changes = state.changes;
root.event(&mut state, &edit(EditCommand::Copy));
assert!(root.take_clipboard_write().is_none());
root.event(&mut state, &edit(EditCommand::Cut));
assert!(root.take_clipboard_write().is_none());
assert_eq!(
widget(&root).editor.text(),
"abc",
"a refused cut deletes nothing"
);
assert_eq!(state.changes, changes);
root.event(&mut state, &edit(EditCommand::Paste("zz".to_string())));
assert_eq!(widget(&root).editor.text(), "zz");
assert_eq!(state.changes, changes + 1);
}
#[test]
fn meta_c_copies_and_meta_x_cuts() {
let mut state = AppState::default();
let mut root = harness(&mut state);
focused_with_selection(&mut state, &mut root, "abc");
root.event(&mut state, &chord("c", meta()));
assert_eq!(root.take_clipboard_write().as_deref(), Some("abc"));
assert_eq!(widget(&root).editor.text(), "abc");
root.event(&mut state, &chord("X", meta()));
assert_eq!(
root.take_clipboard_write().as_deref(),
Some("abc"),
"the chord is ASCII case-insensitive"
);
assert_eq!(widget(&root).editor.text(), "");
}
#[test]
fn ctrl_v_and_shift_insert_request_a_paste_and_type_nothing() {
let mut state = AppState::default();
let mut root = harness(&mut state);
root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
assert!(!root.take_paste_request());
root.event(&mut state, &chord("v", ctrl()));
assert!(root.take_paste_request(), "Ctrl+V asks the shell to read");
assert_eq!(widget(&root).editor.text(), "", "and types no 'v'");
root.event(&mut state, &named(NamedKey::Insert, shift()));
assert!(
root.take_paste_request(),
"Shift+Insert is the legacy paste"
);
root.event(&mut state, &named(NamedKey::Paste, Modifiers::default()));
assert!(root.take_paste_request(), "so is the hardware Paste key");
assert_eq!(state.changes, 0, "asking for a paste is not an edit");
}
#[test]
fn ctrl_insert_copies_and_shift_delete_cuts() {
let mut state = AppState::default();
let mut root = harness(&mut state);
focused_with_selection(&mut state, &mut root, "abc");
root.event(&mut state, &named(NamedKey::Insert, ctrl()));
assert_eq!(root.take_clipboard_write().as_deref(), Some("abc"));
assert_eq!(widget(&root).editor.text(), "abc");
root.event(&mut state, &named(NamedKey::Delete, shift()));
assert_eq!(root.take_clipboard_write().as_deref(), Some("abc"));
assert_eq!(widget(&root).editor.text(), "", "Shift+Delete is a cut");
}
#[test]
fn ctrl_shift_delete_stays_a_forward_delete() {
let mut state = AppState::default();
let mut root = harness(&mut state);
root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
for c in ["a", "b"] {
root.event(&mut state, &ch(c));
}
root.event(&mut state, &named(NamedKey::Home, Modifiers::default()));
root.event(
&mut state,
&named(
NamedKey::Delete,
Modifiers {
shift: true,
ctrl: true,
..Modifiers::default()
},
),
);
assert!(root.take_clipboard_write().is_none(), "nothing was copied");
assert_eq!(widget(&root).editor.text(), "b", "the grapheme ahead went");
}
#[test]
fn the_hardware_copy_and_cut_keys_resolve_to_the_same_verbs() {
let mut state = AppState::default();
let mut root = harness(&mut state);
focused_with_selection(&mut state, &mut root, "abc");
root.event(&mut state, &named(NamedKey::Copy, Modifiers::default()));
assert_eq!(root.take_clipboard_write().as_deref(), Some("abc"));
assert_eq!(widget(&root).editor.text(), "abc");
root.event(&mut state, &named(NamedKey::Cut, Modifiers::default()));
assert_eq!(root.take_clipboard_write().as_deref(), Some("abc"));
assert_eq!(widget(&root).editor.text(), "");
}
#[test]
fn a_bare_insert_edits_nothing_and_is_left_unconsumed() {
let mut state = AppState::default();
let mut root = harness(&mut state);
root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
root.event(&mut state, &ch("a"));
let outcome = root.event(&mut state, &named(NamedKey::Insert, Modifiers::default()));
assert!(!outcome.handled, "no overtype mode to toggle: not ours");
assert!(!root.take_paste_request());
assert_eq!(widget(&root).editor.text(), "a");
}
#[test]
fn an_unrecognized_chord_is_still_consumed_and_never_typed() {
let mut state = AppState::default();
let mut root = harness(&mut state);
root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
root.event(&mut state, &ch("a"));
let changes = state.changes;
let outcome = root.event(&mut state, &chord("z", meta()));
assert!(
outcome.handled,
"an undecoded chord is swallowed, not typed"
);
assert_eq!(widget(&root).editor.text(), "a", "no 'z' was inserted");
assert_eq!(state.changes, changes);
assert!(root.take_clipboard_write().is_none());
assert!(!root.take_paste_request());
}
#[test]
fn an_unfocused_field_ignores_every_edit_command() {
let mut state = AppState::default();
let mut root = harness(&mut state);
for cmd in [
EditCommand::SelectAll,
EditCommand::Paste("hi".to_string()),
EditCommand::Copy,
EditCommand::Cut,
] {
let outcome = root.event(&mut state, &edit(cmd));
assert!(!outcome.handled, "a focus-routed verb reaches nobody");
}
assert!(root.take_clipboard_write().is_none());
assert_eq!(widget(&root).editor.text(), "");
assert_eq!(state.changes, 0);
assert!(!widget(&root).focused);
}
static TOOLBAR_LOCK: Mutex<()> = Mutex::new(());
type ToolbarLog = Arc<Mutex<Vec<String>>>;
const PROBE_SIZE: Size = Size::new(120.0, 40.0);
const PROBE_COLOR: Color = Color::from_rgb8(0x11, 0x22, 0x33);
const TOOLBAR_WINDOW: Size = Size::new(300.0, 200.0);
#[derive(Default)]
struct SceneLog {
fills: Vec<(Point, Size, Color)>,
rounded: Vec<(Point, Size)>,
}
impl PaintScene for SceneLog {
fn fill_rect(&mut self, o: Point, s: Size, c: Color) {
self.fills.push((o, s, c));
}
fn fill_rounded_rect(&mut self, o: Point, s: Size, _r: f64, _c: Color) {
self.rounded.push((o, s));
}
fn draw_text(&mut self, _o: Point, _t: &str) {}
}
impl SceneLog {
fn probe(&self) -> Option<(Point, Size)> {
self.fills
.iter()
.find(|(_, _, c)| *c == PROBE_COLOR)
.map(|(o, s, _)| (*o, *s))
}
fn probe_painted_last(&self) -> bool {
self.fills.last().is_some_and(|(_, _, c)| *c == PROBE_COLOR)
}
fn field_rect(&self) -> Rect {
let (o, s) = self
.rounded
.first()
.copied()
.expect("the field always paints its chrome");
Rect::from_origin_size(o, s)
}
}
struct ProbeToolbar {
log: ToolbarLog,
}
struct ProbeToolbarWidget {
log: ToolbarLog,
}
impl View<()> for ProbeToolbar {
type Element = ProbeToolbarWidget;
fn build(&self, _ctx: &mut BuildCtx<'_>) -> ProbeToolbarWidget {
ProbeToolbarWidget {
log: Arc::clone(&self.log),
}
}
fn rebuild(
&self,
_prev: &Self,
element: &mut ProbeToolbarWidget,
_ctx: &mut BuildCtx<'_>,
) -> ChangeFlags {
element.log = Arc::clone(&self.log);
ChangeFlags::NONE
}
}
impl Widget for ProbeToolbarWidget {
fn layout(&mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
bc.constrain(PROBE_SIZE)
}
fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
scene.fill_rect(ctx.origin(), ctx.size(), PROBE_COLOR);
}
fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
if let InputEvent::Pointer(p) = event {
self.log
.lock()
.unwrap_or_else(|e| e.into_inner())
.push(format!("{:?}@{},{}", p.phase, p.position.x, p.position.y));
if p.phase == PointerPhase::Up {
ctx.dispatch_edit_command(EditCommand::Copy);
}
return EventResult::Handled;
}
EventResult::Ignored
}
}
fn install_probe_toolbar() -> ToolbarLog {
let log: ToolbarLog = Arc::new(Mutex::new(Vec::new()));
let captured = Arc::clone(&log);
let builder: SelectionToolbarBuilder =
Arc::new(move |_request: &SelectionToolbarRequest, _window: Size| {
frust_core::any(ProbeToolbar {
log: Arc::clone(&captured),
})
});
set_selection_toolbar_builder(builder);
set_selection_toolbar_policy(SelectionToolbarPolicy::Framework);
log
}
fn probe_presses(log: &ToolbarLog) -> usize {
log.lock().unwrap_or_else(|e| e.into_inner()).len()
}
fn toolbar_frame(
root: &mut RenderRoot<AppState, TextInputView<AppState>>,
logic: &mut impl FnMut(&mut AppState) -> TextInputView<AppState>,
state: &mut AppState,
ms: f64,
) -> SceneLog {
root.rebuild(logic, state);
root.layout(TOOLBAR_WINDOW);
let mut scene = SceneLog::default();
root.paint(&mut scene, ft_ms(ms));
scene
}
fn tap(
root: &mut RenderRoot<AppState, TextInputView<AppState>>,
state: &mut AppState,
x: f64,
y: f64,
) {
root.event(state, &pointer(PointerPhase::Down, x, y));
root.event(state, &pointer(PointerPhase::Up, x, y));
}
fn selection(root: &RenderRoot<AppState, TextInputView<AppState>>) -> Option<String> {
widget(root).editor.selected_text().map(str::to_owned)
}
fn open_toolbar(
root: &mut RenderRoot<AppState, TextInputView<AppState>>,
logic: &mut impl FnMut(&mut AppState) -> TextInputView<AppState>,
state: &mut AppState,
ms: f64,
) {
root.event(state, &secondary_pointer(PointerPhase::Down, 20.0, 10.0));
assert!(widget(root).toolbar_open, "the leg starts with the bar up");
let scene = toolbar_frame(root, logic, state, ms);
assert!(scene.probe().is_some(), "…and with a pod really registered");
}
#[test]
fn a_stationary_long_press_selects_the_word_and_floats_the_toolbar() {
let _guard = TOOLBAR_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let _log = install_probe_toolbar();
let mut state = AppState {
value: "hello world".to_string(),
..Default::default()
};
let mut logic = options_logic(true, false, false);
let mut root = options_root(&mut logic, &mut state);
toolbar_frame(&mut root, &mut logic, &mut state, 0.0);
root.event(&mut state, &pointer(PointerPhase::Down, 20.0, 10.0));
toolbar_frame(&mut root, &mut logic, &mut state, 0.0);
assert!(
!widget(&root).toolbar_open,
"nothing fires before the threshold"
);
toolbar_frame(&mut root, &mut logic, &mut state, LONG_PRESS_MS + 50.0);
assert!(
frust_core::has_pending_result_flush(),
"the crossing paint latches the deferred-callback flush"
);
root.event(&mut state, &InputEvent::Housekeeping);
assert_eq!(
selection(&root).as_deref(),
Some("hello"),
"the word under the press point is selected"
);
assert!(widget(&root).toolbar_open, "and the toolbar is asked for");
let scene = toolbar_frame(&mut root, &mut logic, &mut state, LONG_PRESS_MS + 70.0);
assert!(
scene.probe_painted_last(),
"the floated pod paints after the main tree: {:?}",
scene.fills
);
let field = scene.field_rect();
let w = widget(&root);
let expected = w
.display()
.selection_rects()
.into_iter()
.reduce(|a, b| a.union(b))
.expect("a non-collapsed selection has rects")
+ Vec2::new(w.pad_x, w.content_origin_y(field.height()))
+ field.origin().to_vec2();
let published = root
.selection_toolbar()
.expect("an open toolbar publishes its request under either policy");
assert_eq!(published.anchor, expected);
assert_eq!(
published.actions,
SelectionToolbarActions {
copy: true,
cut: true,
paste: true,
select_all: true,
},
"a word selected out of a longer line enables all four verbs"
);
}
fn held_word(
logic: &mut impl FnMut(&mut AppState) -> TextInputView<AppState>,
state: &mut AppState,
press_x: f64,
) -> RenderRoot<AppState, TextInputView<AppState>> {
let mut root = options_root(logic, state);
toolbar_frame(&mut root, logic, state, 0.0);
root.event(state, &pointer(PointerPhase::Down, press_x, 10.0));
toolbar_frame(&mut root, logic, state, 0.0);
toolbar_frame(&mut root, logic, state, LONG_PRESS_MS + 50.0);
root.event(state, &InputEvent::Housekeeping);
assert_eq!(
selection(&root).as_deref(),
Some("hello"),
"the leg starts from a fired hold over the first word"
);
root
}
#[test]
fn an_in_slop_move_after_the_hold_fired_leaves_the_selected_word_alone() {
let _guard = TOOLBAR_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let _log = install_probe_toolbar();
let mut state = AppState {
value: "hello world".to_string(),
..Default::default()
};
let mut logic = options_logic(true, false, false);
let mut root = held_word(&mut logic, &mut state, 30.0);
assert!(widget(&root).toolbar_open, "and with the bar raised");
assert_eq!(
widget(&root).gesture,
Gesture::HoldFired {
at: Point::new(30.0, 10.0)
},
"a fired hold keeps its press point rather than forgetting it"
);
let jitter = Point::new(44.0, 10.0);
assert!(
(jitter - Point::new(30.0, 10.0)).hypot() <= TOUCH_SLOP,
"the leg is only meaningful while the jitter stays in slop"
);
root.event(&mut state, &pointer(PointerPhase::Move, jitter.x, jitter.y));
assert_eq!(
selection(&root).as_deref(),
Some("hello"),
"a held finger jittering in slop must not re-resolve the selection"
);
assert_eq!(
widget(&root).gesture,
Gesture::HoldFired {
at: Point::new(30.0, 10.0)
},
"and the press stays resolved-but-stationary, not promoted to a drag"
);
assert!(
widget(&root).toolbar_open,
"so the bar still stands over the selection its verbs were built from"
);
root.event(&mut state, &pointer(PointerPhase::Up, jitter.x, jitter.y));
assert!(
widget(&root).last_tap.is_none(),
"a long-press is not a tap"
);
assert!(
widget(&root).toolbar_open,
"and the release does not close it"
);
}
#[test]
fn a_drag_out_of_a_fired_hold_extends_by_word_and_tracks_back() {
let _guard = TOOLBAR_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let _log = install_probe_toolbar();
let mut state = AppState {
value: "hello world".to_string(),
..Default::default()
};
let mut logic = options_logic(true, false, false);
let mut root = held_word(&mut logic, &mut state, 30.0);
let out = 30.0 + TOUCH_SLOP + 20.0;
root.event(&mut state, &pointer(PointerPhase::Move, out, 10.0));
assert_eq!(
widget(&root).gesture,
Gesture::Drag,
"leaving the slop promotes the resolved hold to a drag"
);
assert_eq!(
selection(&root).as_deref(),
Some("hello world"),
"the drag extends by whole words, not to the cluster under the pointer"
);
root.event(&mut state, &pointer(PointerPhase::Move, 30.0, 10.0));
assert_eq!(
selection(&root).as_deref(),
Some("hello"),
"a finger brought back shrinks the selection rather than sticking"
);
}
#[test]
fn a_drag_past_the_slop_cancels_the_hold_and_selects_instead() {
let _guard = TOOLBAR_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let _log = install_probe_toolbar();
let mut state = AppState {
value: "hello world".to_string(),
..Default::default()
};
let mut logic = options_logic(true, false, false);
let mut root = options_root(&mut logic, &mut state);
toolbar_frame(&mut root, &mut logic, &mut state, 0.0);
root.event(&mut state, &pointer(PointerPhase::Down, 20.0, 10.0));
toolbar_frame(&mut root, &mut logic, &mut state, 0.0);
root.event(
&mut state,
&pointer(PointerPhase::Move, 20.0 + TOUCH_SLOP + 80.0, 10.0),
);
toolbar_frame(&mut root, &mut logic, &mut state, 200.0);
toolbar_frame(&mut root, &mut logic, &mut state, LONG_PRESS_MS + 100.0);
assert!(
!frust_core::has_pending_result_flush(),
"a cancelled hold latches nothing, however long the finger stays down"
);
root.event(&mut state, &InputEvent::Housekeeping);
assert!(
!widget(&root).toolbar_open,
"a drag raises no toolbar of its own"
);
let selected = selection(&root).expect("the drag extended a selection");
assert!(
selected.ends_with("world"),
"the selection followed the pointer to the end of the line, rather than \
snapping to the word under the press: {selected:?}"
);
}
#[test]
fn a_double_tap_selects_the_word_and_a_late_second_tap_only_places_the_caret() {
let _guard = TOOLBAR_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let _log = install_probe_toolbar();
let mut state = AppState {
value: "hello world".to_string(),
..Default::default()
};
let mut logic = options_logic(true, false, false);
let mut root = options_root(&mut logic, &mut state);
toolbar_frame(&mut root, &mut logic, &mut state, 0.0);
tap(&mut root, &mut state, 20.0, 10.0);
toolbar_frame(&mut root, &mut logic, &mut state, 900.0);
root.event(&mut state, &pointer(PointerPhase::Down, 21.0, 10.0));
assert_eq!(
selection(&root),
None,
"past DOUBLE_TAP_MS the press is just a press"
);
root.event(&mut state, &pointer(PointerPhase::Up, 21.0, 10.0));
toolbar_frame(&mut root, &mut logic, &mut state, 1000.0);
root.event(&mut state, &pointer(PointerPhase::Down, 21.0, 10.0));
assert_eq!(
selection(&root).as_deref(),
Some("hello"),
"the second press of a double-tap selects the word"
);
assert!(
!widget(&root).toolbar_open,
"and deliberately raises nothing over the word it just picked"
);
}
#[test]
fn a_tap_inside_the_selection_toggles_the_toolbar_and_keeps_the_selection() {
let _guard = TOOLBAR_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let _log = install_probe_toolbar();
let mut state = AppState {
value: "hello world".to_string(),
..Default::default()
};
let mut logic = options_logic(true, false, false);
let mut root = options_root(&mut logic, &mut state);
toolbar_frame(&mut root, &mut logic, &mut state, 0.0);
tap(&mut root, &mut state, 20.0, 10.0);
toolbar_frame(&mut root, &mut logic, &mut state, 100.0);
tap(&mut root, &mut state, 21.0, 10.0);
assert_eq!(selection(&root).as_deref(), Some("hello"));
toolbar_frame(&mut root, &mut logic, &mut state, 600.0);
root.event(&mut state, &pointer(PointerPhase::Down, 20.0, 10.0));
assert_eq!(
selection(&root).as_deref(),
Some("hello"),
"the press neither collapses the selection nor moves the caret"
);
assert!(
!widget(&root).toolbar_open,
"and nothing opens on the press itself"
);
root.event(&mut state, &pointer(PointerPhase::Up, 20.0, 10.0));
assert!(widget(&root).toolbar_open, "the release opens the toolbar");
assert_eq!(selection(&root).as_deref(), Some("hello"));
toolbar_frame(&mut root, &mut logic, &mut state, 1200.0);
tap(&mut root, &mut state, 20.0, 10.0);
assert!(
!widget(&root).toolbar_open,
"a second tap inside the selection closes it"
);
assert_eq!(selection(&root).as_deref(), Some("hello"));
}
#[test]
fn a_press_inside_the_floated_toolbar_reaches_the_pod_and_its_copy_reaches_the_field() {
let _guard = TOOLBAR_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let log = install_probe_toolbar();
let mut state = AppState {
value: "hello world".to_string(),
..Default::default()
};
let mut logic = options_logic(true, false, false);
let mut root = options_root(&mut logic, &mut state);
toolbar_frame(&mut root, &mut logic, &mut state, 0.0);
tap(&mut root, &mut state, 20.0, 10.0);
toolbar_frame(&mut root, &mut logic, &mut state, 100.0);
tap(&mut root, &mut state, 21.0, 10.0);
assert_eq!(selection(&root).as_deref(), Some("hello"));
root.event(
&mut state,
&secondary_pointer(PointerPhase::Down, 20.0, 10.0),
);
let scene = toolbar_frame(&mut root, &mut logic, &mut state, 200.0);
assert!(
scene.probe_painted_last(),
"the pod is registered and floated"
);
let placed = widget(&root).toolbar.window_rect();
let anchor = root
.selection_toolbar()
.expect("the request is published while the bar is up")
.anchor;
assert_eq!(placed.size(), PROBE_SIZE, "placement never resizes the pod");
assert_eq!(
placed.y0,
anchor.y1 + TOOLBAR_GAP,
"flipped below the selection and the gap clear of it — a field at the \
top of the window has no room above it"
);
assert_eq!(
placed.x0,
crate::DEFAULT_PADDING,
"centred on the selection where it fits and shifted back inside the \
window's padding where it does not — a 120px bar centred on a word \
near the leading edge hangs outside"
);
let hit = placed.center();
root.event(&mut state, &pointer(PointerPhase::Down, hit.x, hit.y));
assert_eq!(probe_presses(&log), 1, "the press was routed into the pod");
assert!(
root.is_focus_active(),
"a press on the bar never blurs the field it acts on"
);
assert!(
widget(&root).toolbar_open,
"and a press alone takes no verb"
);
root.event(&mut state, &pointer(PointerPhase::Up, hit.x, hit.y));
assert_eq!(probe_presses(&log), 2);
assert_eq!(
root.take_clipboard_write().as_deref(),
Some("hello"),
"the pod's dispatched verb was applied by the field, not by the pod"
);
assert!(
!widget(&root).toolbar_open,
"a verb taken closes the bar that offered it"
);
assert!(root.is_focus_active(), "with the session still standing");
assert!(
toolbar_frame(&mut root, &mut logic, &mut state, 300.0)
.probe()
.is_none(),
"and the pod is gone from the very next paint"
);
}
#[test]
fn typing_escape_scrolling_and_an_outside_press_each_put_the_toolbar_away() {
let _guard = TOOLBAR_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let _log = install_probe_toolbar();
let mut state = AppState {
value: "hello world".to_string(),
..Default::default()
};
let mut logic = options_logic(true, false, false);
let mut root = options_root(&mut logic, &mut state);
toolbar_frame(&mut root, &mut logic, &mut state, 0.0);
open_toolbar(&mut root, &mut logic, &mut state, 100.0);
root.event(&mut state, &ch("x"));
assert!(!widget(&root).toolbar_open, "typing puts it away");
assert!(
toolbar_frame(&mut root, &mut logic, &mut state, 120.0)
.probe()
.is_none(),
"and nothing is registered on the next paint"
);
open_toolbar(&mut root, &mut logic, &mut state, 200.0);
root.event(&mut state, &named(NamedKey::Escape, Modifiers::default()));
assert!(!widget(&root).toolbar_open, "Escape puts it away");
assert!(!root.is_focus_active());
assert!(
toolbar_frame(&mut root, &mut logic, &mut state, 220.0)
.probe()
.is_none()
);
open_toolbar(&mut root, &mut logic, &mut state, 300.0);
root.event(
&mut state,
&InputEvent::Scroll {
position: Point::new(20.0, 10.0),
delta: ScrollDelta::Pixels(0.0, -40.0),
},
);
assert!(!widget(&root).toolbar_open, "a scroll puts it away");
assert!(
toolbar_frame(&mut root, &mut logic, &mut state, 320.0)
.probe()
.is_none()
);
open_toolbar(&mut root, &mut logic, &mut state, 400.0);
root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 150.0));
assert!(!widget(&root).toolbar_open, "an outside press puts it away");
assert!(
!root.is_focus_active(),
"and blurs the field as it always did"
);
assert!(
toolbar_frame(&mut root, &mut logic, &mut state, 420.0)
.probe()
.is_none()
);
}
#[test]
fn an_obscured_field_offers_neither_copy_nor_cut_but_still_offers_paste() {
let _guard = TOOLBAR_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let _log = install_probe_toolbar();
let mut state = AppState {
value: "secret".to_string(),
..Default::default()
};
let mut logic = options_logic(true, true, false);
let mut root = options_root(&mut logic, &mut state);
toolbar_frame(&mut root, &mut logic, &mut state, 0.0);
tap(&mut root, &mut state, 20.0, 10.0);
root.event(&mut state, &edit(EditCommand::SelectAll));
assert_eq!(selection(&root).as_deref(), Some("secret"));
root.event(
&mut state,
&secondary_pointer(PointerPhase::Down, 20.0, 10.0),
);
toolbar_frame(&mut root, &mut logic, &mut state, 100.0);
let published = root
.selection_toolbar()
.expect("an obscured field publishes its request like any other");
assert_eq!(
published.actions,
SelectionToolbarActions {
copy: false,
cut: false,
paste: true,
select_all: false,
},
"the secret is not this widget's to hand out, but writing into it is \
ordinary — and everything is already selected"
);
}
#[test]
fn a_focused_field_publishes_its_verbs_with_no_bar_up() {
let _guard = TOOLBAR_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let _log = install_probe_toolbar();
let mut state = AppState {
value: "hello world".to_string(),
..Default::default()
};
let mut logic = options_logic(true, false, false);
let mut root = options_root(&mut logic, &mut state);
toolbar_frame(&mut root, &mut logic, &mut state, 0.0);
tap(&mut root, &mut state, 20.0, 10.0);
let scene = toolbar_frame(&mut root, &mut logic, &mut state, 100.0);
assert!(widget(&root).focused, "the tap focused the field");
assert!(!widget(&root).toolbar_open, "and raised no bar");
assert!(scene.probe().is_none(), "so nothing floated either");
assert_eq!(selection(&root), None, "a plain tap selects nothing");
let published = root
.selection_toolbar()
.expect("a focused field publishes whether or not a bar is up");
assert_eq!(
published.actions,
widget(&root).toolbar_actions(),
"the published verbs are the field's own, computed from its state \
rather than from the bar — the rule the accesskit route already kept"
);
assert!(
published.actions.paste,
"a bare caret in an interactive field is exactly what paste is for"
);
assert!(
!published.present_menu,
"…while nothing asked for a menu, so nothing asks a shell to present one"
);
assert_eq!(
published.anchor,
widget(&root).toolbar_anchor,
"anchored on the caret rect with the selection collapsed"
);
root.event(
&mut state,
&secondary_pointer(PointerPhase::Down, 20.0, 10.0),
);
toolbar_frame(&mut root, &mut logic, &mut state, 200.0);
assert!(
root.selection_toolbar()
.expect("still focused, still publishing")
.present_menu
);
}
#[test]
fn the_native_policy_publishes_the_request_and_floats_nothing() {
let _guard = TOOLBAR_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let _log = install_probe_toolbar();
set_selection_toolbar_policy(SelectionToolbarPolicy::Native);
let mut state = AppState {
value: "hello world".to_string(),
..Default::default()
};
let mut logic = options_logic(true, false, false);
let mut root = options_root(&mut logic, &mut state);
toolbar_frame(&mut root, &mut logic, &mut state, 0.0);
tap(&mut root, &mut state, 20.0, 10.0);
toolbar_frame(&mut root, &mut logic, &mut state, 100.0);
tap(&mut root, &mut state, 21.0, 10.0);
assert_eq!(selection(&root).as_deref(), Some("hello"));
root.event(
&mut state,
&secondary_pointer(PointerPhase::Down, 20.0, 10.0),
);
assert!(widget(&root).toolbar_open);
let scene = toolbar_frame(&mut root, &mut logic, &mut state, 200.0);
assert!(
scene.probe().is_none(),
"the platform draws it: the field floats nothing at all"
);
assert!(
!widget(&root).toolbar.is_open(),
"and mounts no pod to float"
);
assert!(
root.selection_toolbar().is_some(),
"…while the request a shell hands the platform is published all the same"
);
set_selection_toolbar_policy(SelectionToolbarPolicy::Framework);
}
#[test]
fn the_long_press_timer_requests_plain_frames_while_the_blink_paces() {
let mut state = AppState::default();
let mut root = harness(&mut state);
root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
let held = root.paint(&mut NullScene, ft_ms(0.0));
assert!(held.needs_frame, "a live hold keeps the frames coming");
assert!(
!held.needs_frame_paced_only,
"a gesture clock is not a cosmetic loop the frame gate may pace"
);
root.event(&mut state, &pointer(PointerPhase::Up, 10.0, 10.0));
let idle = root.paint(&mut NullScene, ft_ms(20.0));
assert!(
idle.needs_frame_paced_only,
"with no hold in flight the blink is the only thing asking"
);
let mut theme = Theme::neutral();
theme.motion.reduce_motion = true;
root.set_theme(Box::new(theme));
root.paint(&mut NullScene, ft_ms(400.0));
root.event(&mut state, &pointer(PointerPhase::Down, 10.0, 10.0));
let frozen = root.paint(&mut NullScene, ft_ms(420.0));
assert!(
frozen.needs_frame,
"a frozen blink must not freeze the long-press timer"
);
assert!(!frozen.needs_frame_paced_only);
}
#[test]
fn a_cancel_clears_the_hold_without_touching_the_selection_or_the_toolbar() {
let _guard = TOOLBAR_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let _log = install_probe_toolbar();
let mut state = AppState {
value: "hello world".to_string(),
..Default::default()
};
let mut logic = options_logic(true, false, false);
let mut root = options_root(&mut logic, &mut state);
toolbar_frame(&mut root, &mut logic, &mut state, 0.0);
tap(&mut root, &mut state, 20.0, 10.0);
toolbar_frame(&mut root, &mut logic, &mut state, 100.0);
tap(&mut root, &mut state, 21.0, 10.0);
assert_eq!(selection(&root).as_deref(), Some("hello"));
open_toolbar(&mut root, &mut logic, &mut state, 600.0);
root.event(&mut state, &pointer(PointerPhase::Cancel, 20.0, 10.0));
assert!(
widget(&root).toolbar_open,
"a Cancel never puts the toolbar away"
);
root.event(&mut state, &pointer(PointerPhase::Down, 20.0, 10.0));
root.event(&mut state, &pointer(PointerPhase::Cancel, 20.0, 10.0));
assert_eq!(
selection(&root).as_deref(),
Some("hello"),
"a Cancel never touches application state"
);
assert!(!widget(&root).captured, "it does disarm the drag");
assert!(widget(&root).hold.is_none(), "and the hold timer with it");
assert_eq!(widget(&root).gesture, Gesture::None);
assert!(widget(&root).tap_in_selection.is_none());
}
}