use std::rc::Rc;
use frust::authoring::Role;
use frust::authoring::text::{TextContext, TextLayout, TextStyle};
use frust::authoring::{
AnyView, BoxConstraints, BuildCtx, ChangeFlags, ChildPod, CursorIcon, EventCtx, EventResult,
InputEvent, LayoutCtx, PaintCtx, PaintScene, PointerPhase, SemanticsCtx, View, Widget, any,
};
use frust::{AnimationController, Theme, text_input};
use kurbo::{Point, RoundedRect, Shape, Size};
use peniko::{Brush, Color};
use super::interaction::{DISABLED_CONTAINER_OPACITY, DISABLED_CONTENT_OPACITY};
use crate::tokens::{MaterialDimensions, MaterialMotion};
const DECORATION_HEIGHT: f64 = 56.0;
const HPAD: f64 = 16.0;
const ICON_SIZE: f64 = 24.0;
const ICON_GAP: f64 = 12.0;
const LABEL_FLOAT_TOP: f64 = 8.0;
const TEXT_TOP_WITH_LABEL: f64 = 24.0;
const SUPPORTING_GAP: f64 = 4.0;
const RADIUS: f64 = MaterialDimensions::RADIUS_EXTRA_SMALL;
const STROKE_REST: f64 = 1.0;
const STROKE_FOCUSED: f64 = 2.0;
const STROKE_TOLERANCE: f64 = 0.1;
const UNBOUNDED_WIDTH: f64 = 200.0;
const PRIMARY: Color = Color::from_rgb8(0x67, 0x50, 0xA4);
const ERROR: Color = Color::from_rgb8(0xB3, 0x26, 0x1E);
const ON_SURFACE: Color = Color::from_rgb8(0x1D, 0x1B, 0x20);
const ON_SURFACE_VARIANT: Color = Color::from_rgb8(0x49, 0x45, 0x4F);
const OUTLINE: Color = Color::from_rgb8(0x79, 0x74, 0x7E);
const BODY_LARGE_SIZE: f32 = 16.0;
const BODY_SMALL_SIZE: f32 = 12.0;
const SHAPING_INK: Color = Color::BLACK;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum TextFieldVariant {
#[default]
Filled,
Outlined,
}
type OnText<State> = Rc<dyn Fn(&mut State, String)>;
pub struct TextFieldView<State: 'static> {
value: String,
label: Option<String>,
supporting_text: Option<String>,
error_text: Option<String>,
leading: Option<AnyView<State>>,
trailing: Option<AnyView<State>>,
variant: TextFieldVariant,
obscured: bool,
enabled: bool,
on_change: OnText<State>,
on_submit: Option<OnText<State>>,
}
pub fn text_field<State: 'static, F: Fn(&mut State, String) + 'static>(
value: impl Into<String>,
on_change: F,
) -> TextFieldView<State> {
TextFieldView {
value: value.into(),
label: None,
supporting_text: None,
error_text: None,
leading: None,
trailing: None,
variant: TextFieldVariant::Filled,
obscured: false,
enabled: true,
on_change: Rc::new(on_change),
on_submit: None,
}
}
#[allow(non_snake_case)]
pub fn TextField<State: 'static, F: Fn(&mut State, String) + 'static>(
value: impl Into<String>,
on_change: F,
) -> TextFieldView<State> {
text_field(value, on_change)
}
impl<State: 'static> TextFieldView<State> {
pub fn label(mut self, label: impl Into<String>) -> Self {
self.label = Some(label.into());
self
}
pub fn supporting_text(mut self, text: impl Into<String>) -> Self {
self.supporting_text = Some(text.into());
self
}
pub fn error_text(mut self, text: impl Into<String>) -> Self {
self.error_text = Some(text.into());
self
}
pub fn leading(mut self, view: AnyView<State>) -> Self {
self.leading = Some(view);
self
}
pub fn trailing(mut self, view: AnyView<State>) -> Self {
self.trailing = Some(view);
self
}
pub fn variant(mut self, variant: TextFieldVariant) -> Self {
self.variant = variant;
self
}
pub fn obscured(mut self, obscured: bool) -> Self {
self.obscured = obscured;
self
}
pub fn enabled(mut self, enabled: bool) -> Self {
self.enabled = enabled;
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
}
fn has_error(&self) -> bool {
self.error_text.is_some()
}
fn supporting_line(&self) -> Option<&str> {
self.error_text
.as_deref()
.or(self.supporting_text.as_deref())
}
fn text_left(&self) -> f64 {
if self.leading.is_some() {
HPAD + ICON_SIZE + ICON_GAP
} else {
HPAD
}
}
fn text_top(&self) -> f64 {
if self.label.is_some() {
TEXT_TOP_WITH_LABEL
} else {
0.0
}
}
fn shape(&self) -> (bool, bool) {
(self.leading.is_some(), self.trailing.is_some())
}
fn field_view(&self) -> AnyView<State> {
let on_change = self.on_change.clone();
let mut field = text_input(self.value.clone(), move |state: &mut State, text| {
on_change(state, text)
})
.enabled(self.enabled)
.obscured(self.obscured)
.padding(self.text_left(), self.text_top())
.border_width(0.0)
.focus_ring_width(0.0)
.corner_radius(RADIUS);
if let Some(on_submit) = self.on_submit.clone() {
field = field.on_submit(move |state: &mut State, text| on_submit(state, text));
}
any(field)
}
}
struct Run {
content: String,
layout: Option<TextLayout>,
shaped_style: Option<TextStyle>,
}
impl Run {
fn new(content: impl Into<String>) -> Self {
Self {
content: content.into(),
layout: None,
shaped_style: None,
}
}
fn set_content(&mut self, content: &str) {
if self.content != content {
self.content = content.to_string();
self.layout = None;
}
}
fn content(&self) -> &str {
&self.content
}
fn size(&self) -> Size {
self.layout.as_ref().map_or(Size::ZERO, |l| l.size())
}
fn layout(&mut self, ctx: &mut LayoutCtx, style: &TextStyle) -> Size {
if let Some(cached) = &self.layout
&& self.shaped_style.as_ref() == Some(style)
{
return cached.size();
}
let text_ctx = ctx.text_context::<TextContext>();
let laid = text_ctx.layout(&self.content, style, None);
let size = laid.size();
self.layout = Some(laid);
self.shaped_style = Some(style.clone());
size
}
fn paint(&self, origin: Point, color: Color, scene: &mut dyn PaintScene) {
let Some(layout) = &self.layout else {
return;
};
for mut run in layout.to_scene_runs(origin) {
run.brush = Brush::Solid(color);
scene.draw_glyph_run(run);
}
}
}
fn body_large(theme: Option<&Theme>) -> TextStyle {
let mut style = match theme {
Some(theme) => theme.type_scale.body_large.clone(),
None => TextStyle::new(BODY_LARGE_SIZE, SHAPING_INK),
};
style.color = SHAPING_INK;
style
}
fn body_small(theme: Option<&Theme>) -> TextStyle {
let mut style = match theme {
Some(theme) => theme.type_scale.body_small.clone(),
None => TextStyle::new(BODY_SMALL_SIZE, SHAPING_INK),
};
style.color = SHAPING_INK;
style
}
#[derive(Clone, Copy, Debug, PartialEq)]
struct Colors {
stroke: Color,
label_float: Color,
label_rest: Color,
supporting: Color,
}
fn resolve_colors(
theme: Option<&Theme>,
variant: TextFieldVariant,
enabled: bool,
has_error: bool,
focused: bool,
) -> Colors {
let (primary, error, on_surface, on_surface_variant, outline) = match theme {
Some(theme) => {
let s = theme.scheme();
(
s.primary,
s.error,
s.on_surface,
s.on_surface_variant,
s.outline,
)
}
None => (PRIMARY, ERROR, ON_SURFACE, ON_SURFACE_VARIANT, OUTLINE),
};
if !enabled {
let container = on_surface.multiply_alpha(DISABLED_CONTAINER_OPACITY);
let content = on_surface.multiply_alpha(DISABLED_CONTENT_OPACITY);
return Colors {
stroke: container,
label_float: content,
label_rest: content,
supporting: content,
};
}
let accent = if has_error { error } else { primary };
let idle = match variant {
TextFieldVariant::Filled => on_surface_variant,
TextFieldVariant::Outlined => outline,
};
Colors {
stroke: if focused || has_error { accent } else { idle },
label_float: accent,
label_rest: on_surface_variant,
supporting: if has_error { error } else { on_surface_variant },
}
}
#[derive(Clone, Copy)]
struct Slots {
field: usize,
leading: Option<usize>,
trailing: Option<usize>,
}
pub struct TextFieldWidget {
children: Vec<ChildPod>,
slots: Slots,
variant: TextFieldVariant,
enabled: bool,
has_error: bool,
value_empty: bool,
text_left: f64,
label_rest: Option<Run>,
label_float: Option<Run>,
supporting: Option<Run>,
supporting_height: f64,
float: AnimationController,
float_target: bool,
float_priming: bool,
}
fn build_children<State: 'static>(
view: &TextFieldView<State>,
ctx: &mut BuildCtx<'_>,
) -> (Vec<ChildPod>, Slots) {
let mut children = vec![frust::authoring::build_child(&view.field_view(), ctx)];
let field = children.len() - 1;
let leading = view.leading.as_ref().map(|v| {
children.push(frust::authoring::build_child(v, ctx));
children.len() - 1
});
let trailing = view.trailing.as_ref().map(|v| {
children.push(frust::authoring::build_child(v, ctx));
children.len() - 1
});
(
children,
Slots {
field,
leading,
trailing,
},
)
}
fn teardown_children<State: 'static>(
view: &TextFieldView<State>,
slots: Slots,
children: &mut [ChildPod],
ctx: &mut BuildCtx<'_>,
) {
for (index, pod) in children.iter_mut().enumerate() {
if index == slots.field {
frust::authoring::teardown_child(&view.field_view(), pod, ctx);
} else if Some(index) == slots.leading
&& let Some(v) = view.leading.as_ref()
{
frust::authoring::teardown_child(v, pod, ctx);
} else if Some(index) == slots.trailing
&& let Some(v) = view.trailing.as_ref()
{
frust::authoring::teardown_child(v, pod, ctx);
}
}
}
fn sync_run(slot: &mut Option<Run>, content: Option<&str>) -> bool {
match (slot.as_mut(), content) {
(Some(run), Some(text)) => {
if run.content() == text {
return false;
}
run.set_content(text);
true
}
(None, Some(text)) => {
*slot = Some(Run::new(text));
true
}
(Some(_), None) => {
*slot = None;
true
}
(None, None) => false,
}
}
fn inside(pos: Point, size: Size) -> bool {
pos.x >= 0.0 && pos.y >= 0.0 && pos.x < size.width && pos.y < size.height
}
impl<State: 'static> View<State> for TextFieldView<State> {
type Element = TextFieldWidget;
fn build(&self, ctx: &mut BuildCtx<'_>) -> TextFieldWidget {
let (children, slots) = build_children(self, ctx);
let float_target = !self.value.is_empty();
let mut float =
AnimationController::new(MaterialMotion::SHORT_3).with_curve(MaterialMotion::STANDARD);
if float_target {
float.forward();
}
TextFieldWidget {
children,
slots,
variant: self.variant,
enabled: self.enabled,
has_error: self.has_error(),
value_empty: self.value.is_empty(),
text_left: self.text_left(),
label_rest: self.label.as_deref().map(Run::new),
label_float: self.label.as_deref().map(Run::new),
supporting: self.supporting_line().map(Run::new),
supporting_height: 0.0,
float,
float_target,
float_priming: true,
}
}
fn rebuild(
&self,
prev: &Self,
element: &mut TextFieldWidget,
ctx: &mut BuildCtx<'_>,
) -> ChangeFlags {
let mut flags = ChangeFlags::NONE;
if self.shape() != prev.shape() {
teardown_children(prev, element.slots, &mut element.children, ctx);
let (children, slots) = build_children(self, ctx);
element.children = children;
element.slots = slots;
flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
} else {
flags |= frust::authoring::rebuild_child(
&prev.field_view(),
&self.field_view(),
&mut element.children[element.slots.field],
ctx,
);
if let (Some(pv), Some(nv), Some(idx)) = (
prev.leading.as_ref(),
self.leading.as_ref(),
element.slots.leading,
) {
flags |= frust::authoring::rebuild_child(pv, nv, &mut element.children[idx], ctx);
}
if let (Some(pv), Some(nv), Some(idx)) = (
prev.trailing.as_ref(),
self.trailing.as_ref(),
element.slots.trailing,
) {
flags |= frust::authoring::rebuild_child(pv, nv, &mut element.children[idx], ctx);
}
}
if sync_run(&mut element.label_rest, self.label.as_deref())
| sync_run(&mut element.label_float, self.label.as_deref())
| sync_run(&mut element.supporting, self.supporting_line())
{
flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
}
if element.text_left != self.text_left() {
element.text_left = self.text_left();
flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
}
if element.variant != self.variant {
element.variant = self.variant;
flags |= ChangeFlags::PAINT;
}
if element.enabled != self.enabled {
element.enabled = self.enabled;
flags |= ChangeFlags::PAINT;
}
if element.has_error != self.has_error() {
element.has_error = self.has_error();
flags |= ChangeFlags::PAINT;
}
if element.value_empty != self.value.is_empty() {
element.value_empty = self.value.is_empty();
flags |= ChangeFlags::PAINT;
}
flags
}
fn teardown(&self, element: &mut TextFieldWidget, ctx: &mut BuildCtx<'_>) {
teardown_children(self, element.slots, &mut element.children, ctx);
}
}
impl Widget for TextFieldWidget {
fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
let width = if bc.max().width.is_finite() {
bc.max().width
} else {
UNBOUNDED_WIDTH
};
let (rest_style, small_style) = {
let theme = Theme::from_layout_ctx(ctx);
(body_large(theme), body_small(theme))
};
if let Some(run) = &mut self.label_rest {
run.layout(ctx, &rest_style);
}
if let Some(run) = &mut self.label_float {
run.layout(ctx, &small_style);
}
self.supporting_height = match &mut self.supporting {
Some(run) => SUPPORTING_GAP + run.layout(ctx, &small_style).height,
None => 0.0,
};
let field = self.slots.field;
self.children[field].layout_child(
ctx,
&BoxConstraints::tight(Size::new(width, DECORATION_HEIGHT)),
);
self.children[field].set_origin(Point::ORIGIN);
let slot_bc = BoxConstraints::new(Size::ZERO, Size::new(ICON_SIZE, ICON_SIZE));
if let Some(idx) = self.slots.leading {
let size = self.children[idx].layout_child(ctx, &slot_bc);
self.children[idx].set_origin(icon_origin(HPAD, size));
}
if let Some(idx) = self.slots.trailing {
let size = self.children[idx].layout_child(ctx, &slot_bc);
self.children[idx].set_origin(icon_origin(width - HPAD - ICON_SIZE, size));
}
bc.constrain(Size::new(width, DECORATION_HEIGHT + self.supporting_height))
}
fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
let focused = ctx.has_focus() && self.enabled;
let frame_time = ctx.frame_time();
let origin = ctx.origin();
let width = ctx.size().width;
let (colors, reduce_motion) = {
let theme = Theme::from_paint_ctx(ctx);
(
resolve_colors(theme, self.variant, self.enabled, self.has_error, focused),
theme.is_some_and(|t| t.motion.reduce_motion),
)
};
let floated = focused || !self.value_empty;
if self.float_target != floated {
self.float_target = floated;
if floated {
self.float.forward();
} else {
self.float.reverse();
}
}
let float_t = if reduce_motion {
if floated { 1.0 } else { 0.0 }
} else {
if self.float.advance(frame_time) {
ctx.request_frame();
}
if self.float_priming {
if !self.float.is_animating() {
self.float_priming = false;
}
if floated { 1.0 } else { 0.0 }
} else {
self.float.value_clamped()
}
};
self.children[self.slots.field].paint_child(ctx, scene);
if let Some(idx) = self.slots.leading {
self.children[idx].paint_child(ctx, scene);
}
if let Some(idx) = self.slots.trailing {
self.children[idx].paint_child(ctx, scene);
}
let stroke_w = if focused { STROKE_FOCUSED } else { STROKE_REST };
match self.variant {
TextFieldVariant::Filled => scene.fill_rect(
Point::new(origin.x, origin.y + DECORATION_HEIGHT - stroke_w),
Size::new(width, stroke_w),
colors.stroke,
),
TextFieldVariant::Outlined => {
let half = stroke_w / 2.0;
let rr = RoundedRect::new(
half,
half,
width - half,
DECORATION_HEIGHT - half,
(RADIUS - half).max(0.0),
);
scene.stroke_path(
origin,
&rr.to_path(STROKE_TOLERANCE),
stroke_w,
&Brush::Solid(colors.stroke),
);
}
}
if let (Some(rest), Some(float)) = (&self.label_rest, &self.label_float) {
let x = origin.x + self.text_left;
let (rest_h, float_h) = (rest.size().height, float.size().height);
let rest_center = origin.y + DECORATION_HEIGHT / 2.0;
let float_center = origin.y + LABEL_FLOAT_TOP + float_h / 2.0;
let center = rest_center + (float_center - rest_center) * float_t;
if float_t < 1.0 {
rest.paint(
Point::new(x, center - rest_h / 2.0),
colors.label_rest.multiply_alpha(1.0 - float_t as f32),
scene,
);
}
if float_t > 0.0 {
float.paint(
Point::new(x, center - float_h / 2.0),
colors.label_float.multiply_alpha(float_t as f32),
scene,
);
}
}
if let Some(run) = &self.supporting {
run.paint(
Point::new(
origin.x + HPAD,
origin.y + DECORATION_HEIGHT + SUPPORTING_GAP,
),
colors.supporting,
scene,
);
}
}
fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
let result = frust::authoring::route_event(&mut self.children, ctx, event);
if let InputEvent::Pointer(p) = event
&& matches!(p.phase, PointerPhase::Move)
&& inside(p.position, Size::new(ctx.size().width, DECORATION_HEIGHT))
{
ctx.set_cursor(if self.enabled {
CursorIcon::Text
} else {
CursorIcon::NotAllowed
});
}
result
}
fn semantics(&self, ctx: &mut SemanticsCtx) {
ctx.push_container(
Role::Group,
|node| {
if let Some(label) = &self.label_rest {
node.set_label(label.content());
}
if let Some(supporting) = &self.supporting {
node.set_description(supporting.content());
}
},
|ctx| {
for pod in &self.children {
pod.semantics_child(ctx);
}
},
);
}
frust::authoring::visit_children!(children);
}
fn icon_origin(x: f64, measured: Size) -> Point {
Point::new(
x + ((ICON_SIZE - measured.width) / 2.0).max(0.0),
((DECORATION_HEIGHT - measured.height) / 2.0).max(0.0),
)
}
#[cfg(test)]
mod tests {
use super::*;
use frust::authoring::scene::GlyphRun;
use frust::authoring::{BezPath, Key, KeyEvent, Modifiers, PointerButton, PointerEvent, Rect};
use frust::{Brightness, FrameTime};
use frust_core::RenderRoot;
use std::any::Any;
const WINDOW: Size = Size::new(320.0, 160.0);
const IN_FIELD: Point = Point::new(120.0, 28.0);
#[derive(Default)]
struct Recorder {
rrects: Vec<(Point, Size, f64, Color)>,
rects: Vec<(Point, Size, Color)>,
strokes: Vec<(Rect, f64, Color)>,
runs: Vec<(f32, Point, Color)>,
chrome_start: usize,
}
impl PaintScene for Recorder {
fn fill_rect(&mut self, origin: Point, size: Size, color: Color) {
self.rects.push((origin, size, color));
self.chrome_start = self.runs.len();
}
fn draw_text(&mut self, _origin: Point, _text: &str) {}
fn fill_rounded_rect(&mut self, origin: Point, size: Size, radius: f64, color: Color) {
self.rrects.push((origin, size, radius, color));
}
fn stroke_path(&mut self, origin: Point, path: &BezPath, width: f64, brush: &Brush) {
let bbox = path.bounding_box() + origin.to_vec2();
let color = match brush {
Brush::Solid(c) => *c,
_ => Color::TRANSPARENT,
};
self.strokes.push((bbox, width, color));
self.chrome_start = self.runs.len();
}
fn draw_glyph_run(&mut self, run: GlyphRun) {
let color = match run.brush {
Brush::Solid(c) => c,
_ => Color::TRANSPARENT,
};
let t = run.transform.translation();
self.runs.push((run.font_size, Point::new(t.x, t.y), color));
}
}
impl Recorder {
fn indicator(&self) -> (Point, Size, Color) {
*self
.rects
.last()
.expect("a filled field paints an indicator")
}
fn outline(&self) -> (Rect, f64, Color) {
*self
.strokes
.last()
.expect("an outlined field strokes an outline")
}
fn runs_at(&self, font_size: f32) -> Vec<(Point, Color)> {
self.runs[self.chrome_start..]
.iter()
.filter(|(size, _, _)| (*size - font_size).abs() < 1e-3)
.map(|(_, origin, color)| (*origin, *color))
.collect()
}
}
struct Harness {
root: RenderRoot<String, TextFieldView<String>>,
state: String,
tcx: TextContext,
variant: TextFieldVariant,
label: Option<String>,
supporting: Option<String>,
error: Option<String>,
enabled: bool,
}
impl Harness {
fn new(variant: TextFieldVariant) -> Self {
let mut h = Harness {
root: RenderRoot::new(),
state: String::new(),
tcx: TextContext::new(),
variant,
label: Some("Email".to_string()),
supporting: None,
error: None,
enabled: true,
};
h.pass();
h
}
fn theme(&mut self, brightness: Brightness) -> Theme {
let theme = crate::baseline().with_brightness(brightness);
self.root.set_theme(Box::new(theme.clone()));
self.pass();
theme
}
fn pass(&mut self) {
let (variant, enabled) = (self.variant, self.enabled);
let (label, supporting, error) = (
self.label.clone(),
self.supporting.clone(),
self.error.clone(),
);
let mut logic = move |state: &mut String| {
let mut view = text_field::<String, _>(state.clone(), |s: &mut String, t| *s = t)
.variant(variant)
.enabled(enabled);
if let Some(label) = &label {
view = view.label(label.clone());
}
if let Some(supporting) = &supporting {
view = view.supporting_text(supporting.clone());
}
if let Some(error) = &error {
view = view.error_text(error.clone());
}
view
};
self.root.rebuild(&mut logic, &mut self.state);
self.root
.layout_with_text(WINDOW, &mut self.tcx as &mut dyn Any);
}
fn paint(&mut self) -> Recorder {
self.paint_at(FrameTime::ZERO)
}
fn paint_at(&mut self, now: FrameTime) -> Recorder {
let mut rec = Recorder::default();
self.root.paint(&mut rec, now);
rec
}
fn pointer(&mut self, phase: PointerPhase, at: Point) {
self.root.event(
&mut self.state,
&InputEvent::Pointer(PointerEvent {
phase,
position: at,
button: PointerButton::Primary,
}),
);
}
fn focus(&mut self) {
self.pointer(PointerPhase::Down, IN_FIELD);
self.pointer(PointerPhase::Up, IN_FIELD);
}
fn blur(&mut self) {
self.pointer(PointerPhase::Down, Point::new(160.0, 140.0));
self.pointer(PointerPhase::Up, Point::new(160.0, 140.0));
}
fn key(&mut self, ch: char) {
self.root.event(
&mut self.state,
&InputEvent::Key(KeyEvent {
key: Key::Character(ch.to_string()),
modifiers: Modifiers::default(),
repeat: false,
}),
);
}
}
fn secs(s: f64) -> FrameTime {
FrameTime::from_nanos((s * 1_000_000_000.0) as u64)
}
fn build<S: 'static>(view: &TextFieldView<S>) -> TextFieldWidget {
let mut counter = 0u64;
View::<S>::build(view, &mut BuildCtx::new(&mut counter))
}
fn layout_widget(w: &mut TextFieldWidget, width: f64) -> Size {
let mut tcx = TextContext::new();
let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
w.layout(&mut lctx, &BoxConstraints::loose(Size::new(width, 400.0)))
}
#[test]
fn the_wrapped_field_covers_the_whole_decoration_box() {
let view: TextFieldView<String> = text_field("", |_s: &mut String, _t| {}).label("Email");
let mut w = build(&view);
let size = layout_widget(&mut w, 300.0);
assert_eq!(
size,
Size::new(300.0, DECORATION_HEIGHT),
"no supporting line"
);
let field = &w.children[w.slots.field];
assert_eq!(field.origin(), Point::ORIGIN);
assert_eq!(
field.size(),
Size::new(300.0, DECORATION_HEIGHT),
"tight constraints make the baseline's background the container fill"
);
}
#[test]
fn a_supporting_line_grows_the_widget_below_the_decoration_box() {
let view: TextFieldView<String> =
text_field("", |_s: &mut String, _t| {}).supporting_text("Required");
let mut w = build(&view);
let size = layout_widget(&mut w, 300.0);
assert!(size.height > DECORATION_HEIGHT);
assert_eq!(size.height, DECORATION_HEIGHT + w.supporting_height);
assert!(
w.supporting_height > SUPPORTING_GAP,
"gap plus a shaped line"
);
assert_eq!(
w.children[w.slots.field].size(),
Size::new(300.0, DECORATION_HEIGHT)
);
}
#[test]
fn an_unbounded_width_falls_back_to_the_baseline_default() {
let view: TextFieldView<String> = text_field("", |_s: &mut String, _t| {});
let mut w = build(&view);
let mut tcx = TextContext::new();
let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
let size = w.layout(
&mut lctx,
&BoxConstraints::loose(Size::new(f64::INFINITY, 400.0)),
);
assert_eq!(size.width, UNBOUNDED_WIDTH);
}
#[test]
fn a_leading_slot_insets_the_text_and_both_slots_are_placed_and_centered() {
let plain: TextFieldView<String> = text_field("", |_s: &mut String, _t| {});
assert_eq!(plain.text_left(), HPAD);
assert_eq!(plain.text_top(), 0.0, "no label leaves the field centered");
let view: TextFieldView<String> = text_field("", |_s: &mut String, _t| {})
.label("Email")
.leading(any(frust::SizedBox(Some(20.0), Some(20.0))))
.trailing(any(frust::SizedBox(Some(20.0), Some(20.0))));
assert_eq!(view.text_left(), HPAD + ICON_SIZE + ICON_GAP);
assert_eq!(view.text_top(), TEXT_TOP_WITH_LABEL);
let mut w = build(&view);
layout_widget(&mut w, 300.0);
let leading = &w.children[w.slots.leading.expect("leading slot")];
let trailing = &w.children[w.slots.trailing.expect("trailing slot")];
assert_eq!(leading.origin(), Point::new(HPAD + 2.0, 18.0));
assert_eq!(
trailing.origin(),
Point::new(300.0 - HPAD - ICON_SIZE + 2.0, 18.0)
);
}
#[test]
fn a_filled_field_paints_an_indicator_and_an_outlined_one_an_outline() {
let mut filled = Harness::new(TextFieldVariant::Filled);
let theme = filled.theme(Brightness::Light);
let rec = filled.paint();
let (origin, size, color) = rec.indicator();
assert_eq!(origin, Point::new(0.0, DECORATION_HEIGHT - STROKE_REST));
assert_eq!(size, Size::new(WINDOW.width, STROKE_REST));
assert_eq!(
color,
theme.scheme().on_surface_variant,
"an idle filled indicator is onSurfaceVariant"
);
assert!(rec.strokes.is_empty(), "the filled variant strokes nothing");
assert!(
rec.rrects
.iter()
.any(|(_, _, _, c)| *c == theme.scheme().surface),
"the wrapped field painted its background"
);
let mut outlined = Harness::new(TextFieldVariant::Outlined);
let theme = outlined.theme(Brightness::Light);
let rec = outlined.paint();
let (bbox, width, color) = rec.outline();
assert_eq!(width, STROKE_REST);
assert_eq!(
color,
theme.scheme().outline,
"an idle outline is `outline`"
);
assert_eq!(bbox.width(), WINDOW.width - STROKE_REST);
assert_eq!(bbox.height(), DECORATION_HEIGHT - STROKE_REST);
assert!(
rec.rects.is_empty(),
"the outlined variant fills no indicator"
);
}
#[test]
fn focus_thickens_and_accents_both_variants() {
for variant in [TextFieldVariant::Filled, TextFieldVariant::Outlined] {
let mut h = Harness::new(variant);
let theme = h.theme(Brightness::Light);
h.focus();
let rec = h.paint();
let (width, color) = match variant {
TextFieldVariant::Filled => {
let (origin, size, color) = rec.indicator();
assert_eq!(origin.y, DECORATION_HEIGHT - STROKE_FOCUSED);
(size.height, color)
}
TextFieldVariant::Outlined => {
let (_, width, color) = rec.outline();
(width, color)
}
};
assert_eq!(width, STROKE_FOCUSED, "{variant:?} thickens on focus");
assert_eq!(
color,
theme.scheme().primary,
"{variant:?} takes the accent"
);
}
}
#[test]
fn an_error_field_accents_the_stroke_even_unfocused() {
for variant in [TextFieldVariant::Filled, TextFieldVariant::Outlined] {
let mut h = Harness::new(variant);
h.error = Some("Enter a valid address".to_string());
let theme = h.theme(Brightness::Light);
let rec = h.paint();
let (width, color) = match variant {
TextFieldVariant::Filled => {
let (_, size, color) = rec.indicator();
(size.height, color)
}
TextFieldVariant::Outlined => {
let (_, width, color) = rec.outline();
(width, color)
}
};
assert_eq!(color, theme.scheme().error, "{variant:?} strokes `error`");
assert_eq!(
width, STROKE_REST,
"error alone does not thicken the stroke"
);
}
}
#[test]
fn a_disabled_field_dims_container_and_content_and_refuses_focus() {
for variant in [TextFieldVariant::Filled, TextFieldVariant::Outlined] {
let mut h = Harness::new(variant);
h.enabled = false;
h.supporting = Some("Required".to_string());
let theme = h.theme(Brightness::Light);
let on_surface = theme.scheme().on_surface;
let rec = h.paint();
let stroke = match variant {
TextFieldVariant::Filled => rec.indicator().2,
TextFieldVariant::Outlined => rec.outline().2,
};
assert_eq!(
stroke,
on_surface.multiply_alpha(DISABLED_CONTAINER_OPACITY),
"{variant:?} dims its container ink to 12%"
);
let content = on_surface.multiply_alpha(DISABLED_CONTENT_OPACITY);
assert!(
rec.runs.iter().any(|(_, _, c)| *c == content),
"{variant:?} dims its label/supporting ink to 38%"
);
h.focus();
let rec = h.paint();
let width = match variant {
TextFieldVariant::Filled => rec.indicator().1.height,
TextFieldVariant::Outlined => rec.outline().1,
};
assert_eq!(width, STROKE_REST);
}
}
#[test]
fn an_unthemed_pass_paints_the_fallback_roles() {
let view: TextFieldView<String> = text_field("", |_s: &mut String, _t| {}).label("Email");
let mut w = build(&view);
let size = layout_widget(&mut w, 300.0);
let mut rec = Recorder::default();
let mut ctx = PaintCtx::new(Point::ORIGIN, size);
w.paint(&mut ctx, &mut rec);
assert_eq!(rec.indicator().2, ON_SURFACE_VARIANT);
assert_eq!(
rec.runs_at(BODY_LARGE_SIZE)[0].1,
ON_SURFACE_VARIANT,
"a resting label is onSurfaceVariant"
);
}
#[test]
fn the_label_floats_on_focus_or_content_and_rests_only_when_neither() {
let mut h = Harness::new(TextFieldVariant::Filled);
h.theme(Brightness::Light);
let rec = h.paint();
assert_eq!(rec.runs_at(BODY_SMALL_SIZE).len(), 0, "no floated run");
assert_eq!(rec.runs_at(BODY_LARGE_SIZE).len(), 1, "the resting run");
h.focus();
h.paint_at(secs(0.0));
let rec = h.paint_at(secs(1.0));
assert_eq!(rec.runs_at(BODY_LARGE_SIZE).len(), 0);
assert_eq!(rec.runs_at(BODY_SMALL_SIZE).len(), 1);
h.key('a');
h.pass();
assert_eq!(h.state, "a");
let rec = h.paint_at(secs(2.0));
assert_eq!(rec.runs_at(BODY_SMALL_SIZE).len(), 1);
h.blur();
let rec = h.paint_at(secs(3.0));
assert_eq!(
rec.runs_at(BODY_SMALL_SIZE).len(),
1,
"content alone keeps the label up"
);
assert_eq!(rec.runs_at(BODY_LARGE_SIZE).len(), 0);
}
#[test]
fn the_float_transition_crossfades_and_travels_upward() {
let mut h = Harness::new(TextFieldVariant::Filled);
h.theme(Brightness::Light);
let resting = h.paint_at(secs(0.0)).runs_at(BODY_LARGE_SIZE)[0].0;
h.focus();
h.paint_at(secs(10.0));
let mid = h.paint_at(secs(10.0 + MaterialMotion::SHORT_3.as_secs_f64() / 2.0));
let rest_run = mid.runs_at(BODY_LARGE_SIZE);
let float_run = mid.runs_at(BODY_SMALL_SIZE);
assert_eq!(rest_run.len(), 1, "both layers are live mid-crossfade");
assert_eq!(float_run.len(), 1);
let rest_alpha = rest_run[0].1.components[3];
let float_alpha = float_run[0].1.components[3];
assert!(rest_alpha > 0.0 && rest_alpha < 1.0, "{rest_alpha}");
assert!(float_alpha > 0.0 && float_alpha < 1.0, "{float_alpha}");
assert!(
(rest_alpha + float_alpha - 1.0).abs() < 1e-3,
"the crossfade is complementary"
);
assert!(
rest_run[0].0.y < resting.y,
"the label has started travelling"
);
let done = h.paint_at(secs(11.0));
let floated = done.runs_at(BODY_SMALL_SIZE);
assert_eq!(floated.len(), 1);
assert!(done.runs_at(BODY_LARGE_SIZE).is_empty());
assert!(floated[0].0.y >= LABEL_FLOAT_TOP);
assert!(floated[0].0.y < resting.y);
assert_eq!(floated[0].1.components[3], 1.0, "fully opaque once settled");
}
#[test]
fn a_floated_label_takes_the_accent_and_a_resting_one_does_not() {
let mut h = Harness::new(TextFieldVariant::Filled);
let theme = h.theme(Brightness::Light);
let resting = h.paint().runs_at(BODY_LARGE_SIZE)[0].1;
assert_eq!(resting, theme.scheme().on_surface_variant);
h.focus();
h.paint_at(secs(0.0));
let floated = h.paint_at(secs(1.0)).runs_at(BODY_SMALL_SIZE)[0].1;
assert_eq!(floated, theme.scheme().primary);
h.error = Some("Bad".to_string());
h.pass();
let floated = h.paint_at(secs(2.0)).runs_at(BODY_SMALL_SIZE)[0].1;
assert_eq!(floated, theme.scheme().error);
}
#[test]
fn reduce_motion_collapses_the_float_and_asks_for_no_frames() {
let mut theme = crate::baseline().with_brightness(Brightness::Light);
theme.motion.reduce_motion = true;
let view: TextFieldView<String> = text_field("", |_s: &mut String, _t| {}).label("Email");
let mut w = build(&view);
let size = layout_widget(&mut w, 300.0);
let mut rec = Recorder::default();
let mut ctx = PaintCtx::new(Point::ORIGIN, size).with_theme(&theme as &dyn Any);
w.paint(&mut ctx, &mut rec);
assert_eq!(rec.runs_at(BODY_LARGE_SIZE).len(), 1);
assert!(!ctx.needs_frame(), "a collapsed float asks for no frame");
w.value_empty = false;
let mut rec = Recorder::default();
let mut ctx = PaintCtx::new(Point::ORIGIN, size).with_theme(&theme as &dyn Any);
w.paint(&mut ctx, &mut rec);
let floated = rec.runs_at(BODY_SMALL_SIZE);
assert_eq!(floated.len(), 1);
assert_eq!(floated[0].1.components[3], 1.0);
assert!(rec.runs_at(BODY_LARGE_SIZE).is_empty());
assert!(!ctx.needs_frame());
}
#[test]
fn a_field_built_with_content_starts_floated() {
let view: TextFieldView<String> =
text_field("already here", |_s: &mut String, _t| {}).label("Email");
let mut w = build(&view);
let size = layout_widget(&mut w, 300.0);
let mut rec = Recorder::default();
let mut ctx = PaintCtx::new(Point::ORIGIN, size);
w.paint(&mut ctx, &mut rec);
assert_eq!(rec.runs_at(BODY_SMALL_SIZE).len(), 1, "no fade-in on mount");
assert!(rec.runs_at(BODY_LARGE_SIZE).is_empty());
}
#[test]
fn the_error_text_outranks_the_helper_text() {
let helper: TextFieldView<String> =
text_field("", |_s: &mut String, _t| {}).supporting_text("Helper");
assert_eq!(helper.supporting_line(), Some("Helper"));
let both: TextFieldView<String> = text_field("", |_s: &mut String, _t| {})
.supporting_text("Helper")
.error_text("Wrong");
assert_eq!(both.supporting_line(), Some("Wrong"), "error wins");
assert!(both.has_error());
let neither: TextFieldView<String> = text_field("", |_s: &mut String, _t| {});
assert_eq!(neither.supporting_line(), None);
let mut h = Harness::new(TextFieldVariant::Filled);
h.label = None;
h.supporting = Some("Helper".to_string());
let theme = h.theme(Brightness::Light);
let helper_run = h.paint().runs_at(BODY_SMALL_SIZE);
assert_eq!(helper_run.len(), 1);
assert_eq!(helper_run[0].1, theme.scheme().on_surface_variant);
h.error = Some("Wrong".to_string());
h.pass();
let error_run = h.paint().runs_at(BODY_SMALL_SIZE);
assert_eq!(error_run.len(), 1, "one line, not two");
assert_eq!(error_run[0].1, theme.scheme().error);
assert_eq!(
error_run[0].0,
Point::new(HPAD, DECORATION_HEIGHT + SUPPORTING_GAP),
"placed below the decoration box"
);
}
#[test]
fn typing_round_trips_through_the_callback_and_never_self_mutates() {
let mut h = Harness::new(TextFieldVariant::Filled);
h.focus();
h.key('h');
h.key('i');
assert_eq!(h.state, "hi", "each edit reported through on_change");
h.state = "frozen".to_string();
h.pass();
assert_eq!(h.state, "frozen");
}
#[test]
fn the_cursor_is_text_over_the_field_and_not_allowed_when_disabled() {
let mut h = Harness::new(TextFieldVariant::Filled);
h.supporting = Some("Required".to_string());
h.pass();
h.pointer(PointerPhase::Move, IN_FIELD);
assert_eq!(h.root.cursor(), CursorIcon::Text);
h.pointer(
PointerPhase::Move,
Point::new(120.0, DECORATION_HEIGHT + 6.0),
);
assert_eq!(h.root.cursor(), CursorIcon::Default);
h.enabled = false;
h.pass();
h.pointer(PointerPhase::Move, IN_FIELD);
assert_eq!(h.root.cursor(), CursorIcon::NotAllowed);
}
#[test]
fn visit_children_publishes_every_mounted_pod() {
let bare: TextFieldView<String> = text_field("", |_s: &mut String, _t| {});
let w = build(&bare);
let mut seen = 0usize;
Widget::visit_children(&w, &mut |_pod| seen += 1);
assert_eq!(seen, 1, "the wrapped field alone");
let slotted: TextFieldView<String> = text_field("", |_s: &mut String, _t| {})
.leading(any(frust::SizedBox(Some(20.0), Some(20.0))))
.trailing(any(frust::SizedBox(Some(20.0), Some(20.0))));
let w = build(&slotted);
let mut seen = 0usize;
Widget::visit_children(&w, &mut |_pod| seen += 1);
assert_eq!(seen, 3);
}
#[test]
fn semantics_is_a_labelled_group_around_the_editables_own_node() {
let mut logic = |state: &mut String| {
text_field(state.clone(), |s: &mut String, t| *s = t)
.label("Email")
.error_text("Enter a valid address")
};
let mut root: RenderRoot<String, TextFieldView<String>> = RenderRoot::new();
let mut state = String::new();
root.rebuild(&mut logic, &mut state);
let mut tcx = TextContext::new();
root.layout_with_text(WINDOW, &mut tcx as &mut dyn Any);
let update = root.semantics();
let group = update
.nodes
.iter()
.find(|(_, n)| n.role() == Role::Group)
.expect("a group node");
assert_eq!(group.1.label(), Some("Email"));
assert_eq!(group.1.description(), Some("Enter a valid address"));
assert!(
update
.nodes
.iter()
.any(|(_, n)| n.role() == frust::authoring::Role::TextInput),
"the wrapped editable's node reaches the tree through the chrome"
);
}
#[test]
fn adding_a_slot_rebuilds_the_pod_set() {
let mut root: RenderRoot<bool, TextFieldView<bool>> = RenderRoot::new();
let mut state = false;
let mut logic = |with_slot: &mut bool| {
let view: TextFieldView<bool> = text_field("", |_s: &mut bool, _t| {});
if *with_slot {
view.leading(any(frust::SizedBox(Some(20.0), Some(20.0))))
} else {
view
}
};
root.rebuild(&mut logic, &mut state);
let mut tcx = TextContext::new();
root.layout_with_text(WINDOW, &mut tcx as &mut dyn Any);
state = true;
root.rebuild(&mut logic, &mut state);
root.layout_with_text(WINDOW, &mut tcx as &mut dyn Any);
state = false;
root.rebuild(&mut logic, &mut state);
root.layout_with_text(WINDOW, &mut tcx as &mut dyn Any);
}
}