use std::cell::Cell;
use std::rc::Rc;
use std::time::Duration;
use frust::Theme;
use frust::authoring::Role;
use frust::authoring::text::{FontWeight, LineHeight};
use frust::authoring::{
AnyView, BoxConstraints, BuildCtx, ChangeFlags, ChildPod, EditingState, EventCtx, EventResult,
ImeState, InputEvent, Key, LayoutCtx, NamedKey, PaintCtx, PaintScene, PointerPhase,
SemanticsCtx, View, Widget, any,
};
use frust::{Curve, FrameTime};
use kurbo::{Affine, Point, Rect, RoundedRect, Shape, Size};
use peniko::{Brush, Color};
use frust::Timing;
use frust::authoring::{ThemeTextColor, ThemeTextType};
use frust::text;
use frust::{BackPolicy, NavigatorController, PopResult, PushOptions};
use frust::{TransitionDriver, TransitionSpec, make_driver};
use crate::press::presses;
const SCRIM_ALPHA: f32 = 0.6;
const DIALOG_PADDING: f64 = 20.0;
const TITLE_BODY_GAP: f64 = 10.0;
const BODY_ACTIONS_GAP: f64 = 20.0;
const ACTION_GAP: f64 = 10.0;
const MIN_WIDTH: f64 = 300.0;
const MAX_WIDTH: f64 = 480.0;
const SCREEN_MARGIN: f64 = 24.0;
const RADIUS: f64 = 14.0;
const CONTAINER: Color = Color::from_rgb8(0x1e, 0x24, 0x30);
const BORDER: Color = Color::from_rgb8(0x3e, 0x3f, 0x44);
const BORDER_WIDTH: f64 = 1.0;
const PATH_TOLERANCE: f64 = 0.1;
const SCRIM: Color = Color::from_rgb8(0x00, 0x00, 0x00);
const SHADOW_Y: f64 = 12.0;
const SHADOW_BLUR: f64 = 32.0;
const SHADOW_ALPHA: f32 = 0.45;
const TITLE_SIZE: f32 = 15.0;
const TITLE_WEIGHT: FontWeight = FontWeight::BOLD;
const TITLE_LINE_HEIGHT: f32 = 20.0;
const BODY_SIZE: f32 = 12.5;
const BODY_LINE_HEIGHT: f32 = 18.0;
const ENTER_SCALE_START: f64 = 0.94;
const ENTER_DURATION: Duration = Duration::from_millis(220);
const ENTER_CURVE: Curve = Curve::Cubic(0.34, 1.35, 0.64, 1.0);
const EXIT_DURATION: Duration = Duration::from_millis(150);
const EXIT_CURVE: Curve = Curve::Cubic(0.4, 0.0, 1.0, 1.0);
const REDUCE_MOTION_DURATION: Duration = Duration::from_millis(120);
fn cleared_ime_state() -> ImeState {
ImeState {
active: false,
editing: EditingState {
text: String::new(),
selection_base: -1,
selection_extent: -1,
composing_base: -1,
composing_extent: -1,
},
caret: None,
content_type: Default::default(),
suppress_soft_keyboard: false,
}
}
fn with_alpha(color: Color, alpha: f32) -> Color {
let c = color.components;
Color::new([c[0], c[1], c[2], alpha])
}
fn resolve_timings(theme: Option<&Theme>) -> (Timing, Timing) {
if theme.map(|t| t.motion.reduce_motion).unwrap_or(false) {
let t = Timing::Duration(REDUCE_MOTION_DURATION, Curve::Linear);
return (t, t);
}
match theme {
Some(t) => (
Timing::Duration(
Duration::from_secs_f64(t.motion.durations.base / 1000.0),
t.motion.easing.spatial,
),
Timing::Duration(
Duration::from_secs_f64(t.motion.durations.fast / 1000.0),
t.motion.easing.exit,
),
),
None => (
Timing::Duration(ENTER_DURATION, ENTER_CURVE),
Timing::Duration(EXIT_DURATION, EXIT_CURVE),
),
}
}
fn resolve_container(theme: Option<&Theme>) -> Color {
match theme {
Some(t) => t.scheme().surface_container_high,
None => CONTAINER,
}
}
fn resolve_border(theme: Option<&Theme>) -> Color {
match theme {
Some(t) => t.scheme().outline,
None => BORDER,
}
}
fn resolve_radius(theme: Option<&Theme>) -> f64 {
match theme {
Some(t) => t.shape.large,
None => RADIUS,
}
}
fn resolve_scrim(theme: Option<&Theme>) -> Color {
match theme {
Some(t) => t.scheme().scrim,
None => SCRIM,
}
}
fn resolve_shadow(theme: Option<&Theme>) -> (f64, f64, Color) {
match theme {
Some(t) => {
let s = t.elevation.level5.shadow(t.brightness);
(
s.y_offset,
s.blur_std_dev,
with_alpha(t.scheme().shadow, s.color_alpha),
)
}
None => (SHADOW_Y, SHADOW_BLUR, with_alpha(SCRIM, SHADOW_ALPHA)),
}
}
fn finite_or_zero(v: f64) -> f64 {
if v.is_finite() { v } else { 0.0 }
}
fn title_view<State: 'static>(s: &str) -> AnyView<State> {
any::<State, _>(
text(s.to_string())
.size(TITLE_SIZE)
.weight(TITLE_WEIGHT)
.line_height(LineHeight::Absolute(TITLE_LINE_HEIGHT))
.themed_family(ThemeTextType::HeadlineSmall),
)
}
fn body_view<State: 'static>(s: &str) -> AnyView<State> {
any::<State, _>(
text(s.to_string())
.size(BODY_SIZE)
.line_height(LineHeight::Absolute(BODY_LINE_HEIGHT))
.themed_role(ThemeTextColor::OnSurfaceVariant)
.themed_family(ThemeTextType::BodyMedium),
)
}
type OnClose = Rc<dyn Fn()>;
pub struct GlyphDialogView<State: 'static> {
title: Option<String>,
body: Option<String>,
content: Option<AnyView<State>>,
actions: Vec<AnyView<State>>,
scrim_dismissible: bool,
on_close: Option<OnClose>,
dismissable: bool,
dismiss_signal: Option<Rc<Cell<u64>>>,
}
enum BodySlot<'a, State: 'static> {
None,
Text(&'a str),
Content(&'a AnyView<State>),
}
impl<State: 'static> GlyphDialogView<State> {
fn body_slot(&self) -> BodySlot<'_, State> {
if let Some(c) = &self.content {
BodySlot::Content(c)
} else if let Some(b) = &self.body {
BodySlot::Text(b.as_str())
} else {
BodySlot::None
}
}
}
pub fn glyph_dialog<State: 'static>() -> GlyphDialogView<State> {
GlyphDialogView {
title: None,
body: None,
content: None,
actions: Vec::new(),
scrim_dismissible: true,
on_close: None,
dismissable: true,
dismiss_signal: None,
}
}
#[allow(non_snake_case)]
pub fn GlyphDialog<State: 'static>() -> GlyphDialogView<State> {
glyph_dialog()
}
impl<State: 'static> GlyphDialogView<State> {
pub fn title(mut self, title: impl Into<String>) -> Self {
self.title = Some(title.into());
self
}
pub fn body(mut self, body: impl Into<String>) -> Self {
self.body = Some(body.into());
self
}
pub fn content(mut self, content: AnyView<State>) -> Self {
self.content = Some(content);
self
}
pub fn actions(mut self, actions: Vec<AnyView<State>>) -> Self {
self.actions = actions;
self
}
pub fn action(mut self, action: AnyView<State>) -> Self {
self.actions.push(action);
self
}
pub fn scrim_dismissible(mut self, dismissible: bool) -> Self {
self.scrim_dismissible = dismissible;
self
}
pub fn dismissable(mut self, dismissable: bool) -> Self {
self.dismissable = dismissable;
self
}
pub fn on_close<F: Fn() + 'static>(mut self, on_close: F) -> Self {
self.on_close = Some(Rc::new(on_close));
self
}
}
pub fn show_glyph_dialog<State, B, R>(
controller: &NavigatorController<State>,
build: B,
on_result: R,
) where
State: 'static,
B: Fn() -> GlyphDialogView<State> + 'static,
R: Fn(&mut State, PopResult) + 'static,
{
let close_ctrl = controller.clone();
let dismissable = build().dismissable;
let signal = dismissable.then(|| Rc::new(Cell::new(0u64)));
let widget_signal = signal.clone();
let mut options = PushOptions::transparent()
.transition(TransitionSpec::NONE)
.back(if dismissable {
BackPolicy::DismissAnimated
} else {
BackPolicy::Veto
})
.on_result(on_result);
if let Some(sig) = &signal {
options = options.dismiss_signal(sig.clone());
}
controller.push_with_options(
move || {
let ctrl = close_ctrl.clone();
let mut view = build().on_close(move || ctrl.pop());
view.dismiss_signal = widget_signal.clone();
any::<State, _>(view)
},
options,
);
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Phase {
Enter,
Shown,
Exit,
Dismissed,
}
pub struct GlyphDialogWidget {
title: Option<ChildPod>,
title_text: Option<String>,
body: Option<ChildPod>,
body_text: Option<String>,
actions: Vec<ChildPod>,
scrim_dismissible: bool,
on_close: Option<OnClose>,
dismissable: bool,
dismiss_signal: Option<Rc<Cell<u64>>>,
last_seen_dismiss: u64,
panel: Rect,
phase: Phase,
driver: Option<TransitionDriver>,
scrim_captured: bool,
scrim_down_outside: bool,
}
fn reconcile_optional<State: 'static>(
prev: Option<&String>,
next: Option<&String>,
pod: &mut Option<ChildPod>,
make: impl Fn(&str) -> AnyView<State>,
ctx: &mut BuildCtx<'_>,
) -> ChangeFlags {
match (prev, next) {
(None, Some(s)) => {
*pod = Some(frust::authoring::build_child(&make(s.as_str()), ctx));
ChangeFlags::LAYOUT | ChangeFlags::PAINT
}
(Some(s), None) => {
if let Some(p) = pod.as_mut() {
frust::authoring::teardown_child(&make(s.as_str()), p, ctx);
}
*pod = None;
ChangeFlags::LAYOUT | ChangeFlags::PAINT
}
(Some(a), Some(b)) => match pod.as_mut() {
Some(p) => {
frust::authoring::rebuild_child(&make(a.as_str()), &make(b.as_str()), p, ctx)
}
None => ChangeFlags::NONE,
},
(None, None) => ChangeFlags::NONE,
}
}
fn reconcile_body_slot<State: 'static>(
prev: &GlyphDialogView<State>,
next: &GlyphDialogView<State>,
pod: &mut Option<ChildPod>,
ctx: &mut BuildCtx<'_>,
) -> ChangeFlags {
match (prev.body_slot(), next.body_slot()) {
(BodySlot::None, BodySlot::None) => ChangeFlags::NONE,
(BodySlot::None, BodySlot::Text(s)) => {
*pod = Some(frust::authoring::build_child(&body_view::<State>(s), ctx));
ChangeFlags::LAYOUT | ChangeFlags::PAINT
}
(BodySlot::None, BodySlot::Content(v)) => {
*pod = Some(frust::authoring::build_child(v, ctx));
ChangeFlags::LAYOUT | ChangeFlags::PAINT
}
(BodySlot::Text(s), BodySlot::None) => {
if let Some(p) = pod.as_mut() {
frust::authoring::teardown_child(&body_view::<State>(s), p, ctx);
}
*pod = None;
ChangeFlags::LAYOUT | ChangeFlags::PAINT
}
(BodySlot::Content(_), BodySlot::None) => {
if let (Some(v), Some(p)) = (&prev.content, pod.as_mut()) {
frust::authoring::teardown_child(v, p, ctx);
}
*pod = None;
ChangeFlags::LAYOUT | ChangeFlags::PAINT
}
(BodySlot::Text(a), BodySlot::Text(b)) => match pod.as_mut() {
Some(p) => frust::authoring::rebuild_child(
&body_view::<State>(a),
&body_view::<State>(b),
p,
ctx,
),
None => ChangeFlags::NONE,
},
(BodySlot::Text(a), BodySlot::Content(v)) => match pod.as_mut() {
Some(p) => frust::authoring::rebuild_child(&body_view::<State>(a), v, p, ctx),
None => ChangeFlags::NONE,
},
(BodySlot::Content(_), BodySlot::Text(b)) => match pod.as_mut() {
Some(p) => {
let pv = prev
.content
.as_ref()
.expect("BodySlot::Content implies `content` is set");
frust::authoring::rebuild_child(pv, &body_view::<State>(b), p, ctx)
}
None => ChangeFlags::NONE,
},
(BodySlot::Content(_), BodySlot::Content(_)) => match pod.as_mut() {
Some(p) => {
let pv = prev
.content
.as_ref()
.expect("BodySlot::Content implies `content` is set");
let nv = next
.content
.as_ref()
.expect("BodySlot::Content implies `content` is set");
frust::authoring::rebuild_child(pv, nv, p, ctx)
}
None => ChangeFlags::NONE,
},
}
}
impl<State: 'static> View<State> for GlyphDialogView<State> {
type Element = GlyphDialogWidget;
fn build(&self, ctx: &mut BuildCtx<'_>) -> GlyphDialogWidget {
GlyphDialogWidget {
title: self
.title
.as_ref()
.map(|s| frust::authoring::build_child(&title_view::<State>(s), ctx)),
title_text: self.title.clone(),
body: match self.body_slot() {
BodySlot::None => None,
BodySlot::Text(s) => {
Some(frust::authoring::build_child(&body_view::<State>(s), ctx))
}
BodySlot::Content(v) => Some(frust::authoring::build_child(v, ctx)),
},
body_text: match self.body_slot() {
BodySlot::Text(s) => Some(s.to_string()),
_ => None,
},
actions: self
.actions
.iter()
.map(|v| frust::authoring::build_child(v, ctx))
.collect(),
scrim_dismissible: self.scrim_dismissible,
on_close: self.on_close.clone(),
dismissable: self.dismissable,
dismiss_signal: self.dismiss_signal.clone(),
last_seen_dismiss: self.dismiss_signal.as_ref().map(|s| s.get()).unwrap_or(0),
panel: Rect::ZERO,
phase: Phase::Enter,
driver: None,
scrim_captured: false,
scrim_down_outside: false,
}
}
fn rebuild(
&self,
prev: &Self,
element: &mut GlyphDialogWidget,
ctx: &mut BuildCtx<'_>,
) -> ChangeFlags {
let mut flags = ChangeFlags::NONE;
flags |= reconcile_optional(
prev.title.as_ref(),
self.title.as_ref(),
&mut element.title,
title_view::<State>,
ctx,
);
element.title_text = self.title.clone();
flags |= reconcile_body_slot(prev, self, &mut element.body, ctx);
element.body_text = match self.body_slot() {
BodySlot::Text(s) => Some(s.to_string()),
_ => None,
};
let prev_views: Vec<&AnyView<State>> = prev.actions.iter().collect();
let next_views: Vec<&AnyView<State>> = self.actions.iter().collect();
flags |= frust::authoring::rebuild_children(
&prev_views,
&next_views,
&mut element.actions,
ctx,
|view: &&AnyView<State>| *view,
|_| None,
);
element.scrim_dismissible = self.scrim_dismissible;
element.on_close = self.on_close.clone();
element.dismissable = self.dismissable;
element.dismiss_signal = self.dismiss_signal.clone();
flags
}
fn teardown(&self, element: &mut GlyphDialogWidget, ctx: &mut BuildCtx<'_>) {
if let (Some(s), Some(p)) = (&self.title, element.title.as_mut()) {
frust::authoring::teardown_child(&title_view::<State>(s), p, ctx);
}
match (self.body_slot(), element.body.as_mut()) {
(BodySlot::Text(s), Some(p)) => {
frust::authoring::teardown_child(&body_view::<State>(s), p, ctx);
}
(BodySlot::Content(v), Some(p)) => {
frust::authoring::teardown_child(v, p, ctx);
}
_ => {}
}
for (view, pod) in self.actions.iter().zip(element.actions.iter_mut()) {
frust::authoring::teardown_child(view, pod, ctx);
}
}
}
impl GlyphDialogWidget {
fn begin_exit(&mut self) {
if matches!(self.phase, Phase::Enter | Phase::Shown) {
self.phase = Phase::Exit;
self.driver = None;
}
}
fn observe_dismiss_signal(&mut self) {
if let Some(signal) = &self.dismiss_signal {
let current = signal.get();
if current != self.last_seen_dismiss {
self.last_seen_dismiss = current;
self.begin_exit();
}
}
}
fn advance(&mut self, now: FrameTime, enter: Timing, exit: Timing) -> (f64, f32, bool) {
match self.phase {
Phase::Enter => {
let adv = self
.driver
.get_or_insert_with(|| make_driver(enter).0)
.advance(now);
if adv.done {
self.phase = Phase::Shown;
self.driver = None;
}
let p = adv.value;
let scale = ENTER_SCALE_START + (1.0 - ENTER_SCALE_START) * p;
(scale, p.clamp(0.0, 1.0) as f32, !adv.done)
}
Phase::Shown => (1.0, 1.0, false),
Phase::Exit => {
let adv = self
.driver
.get_or_insert_with(|| make_driver(exit).0)
.advance(now);
let q = adv.value.clamp(0.0, 1.0);
let scale = 1.0 - (1.0 - ENTER_SCALE_START) * q;
if adv.done {
self.phase = Phase::Dismissed;
self.driver = None;
if let Some(on_close) = &self.on_close {
on_close();
}
}
(scale, (1.0 - q) as f32, !adv.done)
}
Phase::Dismissed => (ENTER_SCALE_START, 0.0, false),
}
}
}
impl Widget for GlyphDialogWidget {
fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
let area_w = finite_or_zero(bc.max().width);
let area_h = finite_or_zero(bc.max().height);
let view_insets = ctx.window_insets().view_insets;
let free_top = view_insets.top.clamp(0.0, area_h);
let free_bottom = (area_h - view_insets.bottom).clamp(free_top, area_h);
let free_h = free_bottom - free_top;
let panel_max_w = (area_w - 2.0 * SCREEN_MARGIN).clamp(0.0, MAX_WIDTH);
let content_max_w = (panel_max_w - 2.0 * DIALOG_PADDING).max(0.0);
let child_bc = BoxConstraints::loose(Size::new(content_max_w, f64::INFINITY));
let mut content_w: f64 = 0.0;
let title_size = self.title.as_mut().map(|p| {
let s = p.layout_child(ctx, &child_bc);
content_w = content_w.max(s.width);
s
});
let body_size = self.body.as_mut().map(|p| {
let s = p.layout_child(ctx, &child_bc);
content_w = content_w.max(s.width);
s
});
let mut actions_w: f64 = 0.0;
let mut actions_h: f64 = 0.0;
for (i, pod) in self.actions.iter_mut().enumerate() {
let s = pod.layout_child(ctx, &child_bc);
if i > 0 {
actions_w += ACTION_GAP;
}
actions_w += s.width;
actions_h = actions_h.max(s.height);
}
if !self.actions.is_empty() {
content_w = content_w.max(actions_w);
}
let lower = MIN_WIDTH.min(panel_max_w);
let panel_w = (content_w + 2.0 * DIALOG_PADDING).clamp(lower, panel_max_w);
let mut y = DIALOG_PADDING;
let mut title_y = 0.0;
let mut body_y = 0.0;
let mut actions_y = 0.0;
if let Some(s) = title_size {
title_y = y;
y += s.height;
}
if let Some(s) = body_size {
if title_size.is_some() {
y += TITLE_BODY_GAP;
}
body_y = y;
y += s.height;
}
if !self.actions.is_empty() {
if title_size.is_some() || body_size.is_some() {
y += BODY_ACTIONS_GAP;
}
actions_y = y;
y += actions_h;
}
y += DIALOG_PADDING;
let panel_h = y;
let panel_x = ((area_w - panel_w) / 2.0).max(0.0);
let panel_y = (free_top + (free_h - panel_h) / 2.0).max(0.0);
self.panel = Rect::new(panel_x, panel_y, panel_x + panel_w, panel_y + panel_h);
if let Some(p) = self.title.as_mut() {
p.set_origin(Point::new(panel_x + DIALOG_PADDING, panel_y + title_y));
}
if let Some(p) = self.body.as_mut() {
p.set_origin(Point::new(panel_x + DIALOG_PADDING, panel_y + body_y));
}
let mut ax = panel_x + panel_w - DIALOG_PADDING;
for pod in self.actions.iter_mut().rev() {
let w = pod.size().width;
ax -= w;
pod.set_origin(Point::new(ax, panel_y + actions_y));
ax -= ACTION_GAP;
}
bc.constrain(Size::new(area_w, area_h))
}
fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
self.observe_dismiss_signal();
let theme = Theme::from_paint_ctx(ctx);
let (enter, exit) = resolve_timings(theme);
let (scale, scrim_frac, animating) = self.advance(ctx.frame_time(), enter, exit);
let scrim = with_alpha(resolve_scrim(theme), SCRIM_ALPHA * scrim_frac);
scene.fill_rect(ctx.origin(), ctx.size(), scrim);
let panel_origin = Point::new(
ctx.origin().x + self.panel.x0,
ctx.origin().y + self.panel.y0,
);
let panel_size = Size::new(self.panel.width(), self.panel.height());
let center = Point::new(
panel_origin.x + panel_size.width / 2.0,
panel_origin.y + panel_size.height / 2.0,
);
let transform = Affine::translate((center.x, center.y))
* Affine::scale(scale)
* Affine::translate((-center.x, -center.y));
scene.push_transform(transform);
let (shadow_y, shadow_blur, shadow_color) = resolve_shadow(theme);
let radius = resolve_radius(theme);
scene.draw_shadow(
Point::new(panel_origin.x, panel_origin.y + shadow_y),
panel_size,
radius,
shadow_blur,
shadow_color,
);
scene.fill_rounded_rect(panel_origin, panel_size, radius, resolve_container(theme));
let rr = RoundedRect::from_rect(Rect::from_origin_size(Point::ORIGIN, panel_size), radius);
let path = rr.to_path(PATH_TOLERANCE);
scene.stroke_path(
panel_origin,
&path,
BORDER_WIDTH,
&Brush::Solid(resolve_border(theme)),
);
if let Some(p) = self.title.as_mut() {
p.paint_child(ctx, scene);
}
if let Some(p) = self.body.as_mut() {
p.paint_child(ctx, scene);
}
for pod in &mut self.actions {
pod.paint_child(ctx, scene);
}
scene.pop_transform();
if animating || self.phase == Phase::Dismissed {
ctx.request_frame();
}
}
fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
let pointer_down = matches!(event, InputEvent::Pointer(p) if p.phase == PointerPhase::Down);
if pointer_down {
ctx.request_focus();
}
let content_held_session =
pointer_down && ctx.has_focus() && self.body.as_ref().is_some_and(|p| p.is_focused());
if frust::authoring::route_event(&mut self.actions, ctx, event) == EventResult::Handled {
return EventResult::Handled;
}
if let Some(pod) = self.body.as_mut()
&& frust::authoring::route_event_single(pod, ctx, event) == EventResult::Handled
{
return EventResult::Handled;
}
if let InputEvent::Key(key_event) = event {
if self.dismissable && key_event.key == Key::Named(NamedKey::Escape) {
self.begin_exit();
return EventResult::Handled;
}
return EventResult::Ignored;
}
let InputEvent::Pointer(p) = event else {
return EventResult::Ignored;
};
match p.phase {
PointerPhase::Down => {
let down_outside = !self.panel.contains(p.position);
if content_held_session && !down_outside {
ctx.publish_ime_state(cleared_ime_state());
}
if !presses(p) {
return EventResult::Handled;
}
self.scrim_captured = true;
self.scrim_down_outside = down_outside;
ctx.capture_pointer();
EventResult::Handled
}
PointerPhase::Move => {
if self.scrim_captured {
EventResult::Handled
} else {
EventResult::Ignored
}
}
PointerPhase::Up => {
if !self.scrim_captured {
return EventResult::Ignored;
}
let released_outside = !self.panel.contains(p.position);
if self.dismissable
&& self.scrim_dismissible
&& self.scrim_down_outside
&& released_outside
{
self.begin_exit();
}
self.scrim_captured = false;
EventResult::Handled
}
PointerPhase::Cancel => {
self.scrim_captured = false;
EventResult::Handled
}
}
}
fn semantics(&self, ctx: &mut SemanticsCtx) {
let label = self.title_text.clone();
ctx.push_container(
Role::Dialog,
|node| {
node.set_modal();
if let Some(label) = &label {
node.set_label(label.as_str());
}
},
|ctx| {
if let Some(p) = &self.title {
p.semantics_child(ctx);
}
if let Some(p) = &self.body {
p.semantics_child(ctx);
}
for pod in &self.actions {
pod.semantics_child(ctx);
}
},
);
}
frust::authoring::visit_children!(title, body, actions);
}
#[cfg(test)]
mod tests {
use super::*;
use frust::NavigatorView;
use frust::authoring::text::TextContext;
use frust::authoring::{
BuildCtx, KeyEvent, Modifiers, PointerButton, PointerEvent, WindowEdgeInsets, WindowInsets,
any as core_any,
};
use frust::text_input;
use frust_core::RenderRoot;
use frust_widgets::navigator;
use std::any::Any;
use std::cell::Cell;
fn ft_ms(ms: f64) -> FrameTime {
FrameTime::from_nanos((ms * 1_000_000.0) as u64)
}
fn ev(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
InputEvent::Pointer(PointerEvent {
phase,
position: Point::new(x, y),
button: PointerButton::Primary,
})
}
fn secondary_ev(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
InputEvent::Pointer(PointerEvent {
phase,
position: Point::new(x, y),
button: PointerButton::Secondary,
})
}
fn escape_event() -> InputEvent {
InputEvent::Key(KeyEvent {
key: Key::Named(NamedKey::Escape),
modifiers: Modifiers::default(),
repeat: false,
})
}
fn build<S: 'static>(view: &GlyphDialogView<S>) -> GlyphDialogWidget {
let mut counter = 0u64;
View::<S>::build(view, &mut BuildCtx::new(&mut counter))
}
#[derive(Default)]
struct Recorder {
rects: Vec<(Point, Size, Color)>,
rrects: Vec<(Point, Size, f64, Color)>,
shadows: usize,
strokes: Vec<Color>,
transforms: Vec<Affine>,
transform_pops: u32,
}
impl PaintScene for Recorder {
fn fill_rect(&mut self, o: Point, s: Size, c: Color) {
self.rects.push((o, s, c));
}
fn draw_text(&mut self, _o: Point, _t: &str) {}
fn fill_rounded_rect(&mut self, o: Point, s: Size, radius: f64, color: Color) {
self.rrects.push((o, s, radius, color));
}
fn draw_shadow(&mut self, _o: Point, _s: Size, _r: f64, _b: f64, _c: Color) {
self.shadows += 1;
}
fn stroke_path(&mut self, _o: Point, _p: &kurbo::BezPath, _w: f64, brush: &Brush) {
if let Brush::Solid(c) = brush {
self.strokes.push(*c);
}
}
fn push_transform(&mut self, t: Affine) {
self.transforms.push(t);
}
fn pop_transform(&mut self) {
self.transform_pops += 1;
}
}
#[test]
fn enter_staging_scales_panel_094_to_1_and_fades_scrim_in() {
let view: GlyphDialogView<()> = glyph_dialog().title("Hi");
let mut w = build(&view);
let (enter, exit) = resolve_timings(None);
let (scale0, scrim0, animating0) = w.advance(ft_ms(0.0), enter, exit);
assert!((scale0 - ENTER_SCALE_START).abs() < 1e-6);
assert!(scrim0.abs() < 1e-6);
assert!(animating0);
assert_eq!(w.phase, Phase::Enter);
let (scale1, scrim1, animating1) = w.advance(ft_ms(400.0), enter, exit);
assert!((scale1 - 1.0).abs() < 1e-6);
assert!((scrim1 - 1.0).abs() < 1e-6);
assert!(!animating1);
assert_eq!(w.phase, Phase::Shown);
}
#[test]
fn exit_is_faster_than_enter_and_reverses_the_staging() {
let view: GlyphDialogView<()> = glyph_dialog().title("Hi");
let mut w = build(&view);
let (enter, exit) = resolve_timings(None);
w.advance(ft_ms(0.0), enter, exit);
w.advance(ft_ms(400.0), enter, exit); w.begin_exit();
assert_eq!(w.phase, Phase::Exit);
let (scale_seed, scrim_seed, _) = w.advance(ft_ms(400.0), enter, exit);
assert!((scale_seed - 1.0).abs() < 1e-6);
assert!((scrim_seed - 1.0).abs() < 1e-6);
let (_, _, animating_mid) = w.advance(ft_ms(400.0 + 149.0), enter, exit);
assert!(animating_mid);
let (scale_end, scrim_end, animating_end) = w.advance(ft_ms(400.0 + 200.0), enter, exit);
assert!((scale_end - ENTER_SCALE_START).abs() < 1e-6);
assert!(scrim_end.abs() < 1e-6);
assert!(!animating_end);
assert_eq!(w.phase, Phase::Dismissed);
}
#[test]
fn on_close_fires_once_when_the_exit_completes() {
let count = Rc::new(Cell::new(0u32));
let c = count.clone();
let view: GlyphDialogView<()> = glyph_dialog()
.title("Hi")
.on_close(move || c.set(c.get() + 1));
let mut w = build(&view);
let (enter, exit) = resolve_timings(None);
w.advance(ft_ms(0.0), enter, exit);
w.advance(ft_ms(400.0), enter, exit);
w.begin_exit();
w.advance(ft_ms(400.0), enter, exit);
assert_eq!(count.get(), 0, "on_close waits for the exit to finish");
w.advance(ft_ms(700.0), enter, exit); assert_eq!(count.get(), 1);
w.advance(ft_ms(900.0), enter, exit);
assert_eq!(count.get(), 1);
}
#[test]
fn scrim_uses_own_fade_while_panel_scales_under_a_transform() {
let view: GlyphDialogView<()> = glyph_dialog().title("Hi");
let mut w = build(&view);
let mut tcx = TextContext::new();
let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
let area = Size::new(400.0, 600.0);
w.layout(&mut lctx, &BoxConstraints::tight(area));
let mut rec = Recorder::default();
let mut pctx = PaintCtx::new(Point::ZERO, area);
w.paint(&mut pctx, &mut rec);
assert_eq!(rec.rects[0].1, area);
assert!(
rec.rects[0].2.components[3] < 1e-3,
"scrim starts transparent"
);
assert_eq!(rec.transforms.len(), 1);
assert_eq!(rec.transform_pops, 1);
assert_eq!(rec.rrects.len(), 1, "the panel surface fill");
assert_eq!(rec.strokes.len(), 1, "the panel border stroke");
assert_eq!(rec.shadows, 1, "the chrome-level shadow");
assert!(
pctx.needs_frame(),
"an in-flight enter requests another frame"
);
}
#[test]
fn glyph_dark_and_light_resolve_panel_ring_and_scrim() {
let dark = crate::baseline();
let light = dark.clone().with_brightness(frust::Brightness::Light);
for theme in [&dark, &light] {
assert_eq!(
resolve_container(Some(theme)),
theme.scheme().surface_container_high
);
assert_eq!(resolve_border(Some(theme)), theme.scheme().outline);
assert_eq!(resolve_scrim(Some(theme)), theme.scheme().scrim);
assert_eq!(resolve_radius(Some(theme)), theme.shape.large);
}
assert_ne!(
resolve_container(Some(&dark)),
resolve_container(Some(&light))
);
}
#[test]
fn themed_paint_fills_panel_with_glyph_surface() {
let theme = crate::baseline();
let view: GlyphDialogView<()> = glyph_dialog().title("Confirm");
let mut w = build(&view);
let mut tcx = TextContext::new();
let area = Size::new(400.0, 600.0);
let mut lctx =
LayoutCtx::with_resources(Some(&mut tcx as &mut dyn Any), Some(&theme as &dyn Any));
w.layout(&mut lctx, &BoxConstraints::tight(area));
let mut rec = Recorder::default();
let mut pctx = PaintCtx::new(Point::ZERO, area).with_theme(&theme);
w.phase = Phase::Shown;
w.paint(&mut pctx, &mut rec);
assert_eq!(rec.rrects[0].3, theme.scheme().surface_container_high);
assert_eq!(rec.rrects[0].2, theme.shape.large);
assert_eq!(rec.strokes[0], theme.scheme().outline);
}
#[derive(Default)]
struct NavState {
results: Vec<Option<bool>>,
}
fn app_page(
controller: &NavigatorController<NavState>,
) -> impl FnMut(&mut NavState) -> NavigatorView<NavState> {
let ctrl = controller.clone();
move |_: &mut NavState| navigator(&ctrl, || core_any::<NavState, _>(sized(400.0, 600.0)))
}
struct Sized {
size: Size,
}
struct SizedW {
size: Size,
}
impl View<NavState> for Sized {
type Element = SizedW;
fn build(&self, _c: &mut BuildCtx<'_>) -> SizedW {
SizedW { size: self.size }
}
fn rebuild(&self, _p: &Self, _e: &mut SizedW, _c: &mut BuildCtx<'_>) -> ChangeFlags {
ChangeFlags::NONE
}
}
impl Widget for SizedW {
fn layout(&mut self, _c: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
bc.constrain(self.size)
}
fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
scene.fill_rect(ctx.origin(), ctx.size(), Color::BLACK);
}
}
fn sized(w: f64, h: f64) -> Sized {
Sized {
size: Size::new(w, h),
}
}
#[test]
fn scrim_cancel_animates_then_pops_dialog_with_empty_result() {
let controller: NavigatorController<NavState> = NavigatorController::new();
let mut root: RenderRoot<NavState, NavigatorView<NavState>> = RenderRoot::new();
let mut app = app_page(&controller);
let mut state = NavState::default();
let area = Size::new(400.0, 600.0);
root.rebuild(&mut app, &mut state);
let mut tcx = TextContext::new();
root.layout_with_text(area, &mut tcx as &mut dyn Any);
show_glyph_dialog(
&controller,
|| glyph_dialog().title("Confirm"),
|state: &mut NavState, result: PopResult| {
state.results.push(result.take::<bool>());
},
);
root.rebuild(&mut app, &mut state);
root.layout_with_text(area, &mut tcx as &mut dyn Any);
root.paint(&mut Recorder::default(), ft_ms(0.0));
root.paint(&mut Recorder::default(), ft_ms(400.0));
root.event(&mut state, &ev(PointerPhase::Down, 5.0, 5.0));
root.event(&mut state, &ev(PointerPhase::Up, 5.0, 5.0));
assert!(
state.results.is_empty(),
"the exit animation has not finished yet"
);
root.paint(&mut Recorder::default(), ft_ms(500.0));
root.paint(&mut Recorder::default(), ft_ms(800.0));
root.rebuild(&mut app, &mut state);
root.layout_with_text(area, &mut tcx as &mut dyn Any);
root.event(&mut state, &ev(PointerPhase::Move, 5.0, 5.0));
assert_eq!(
state.results,
vec![None],
"scrim cancel pops with an empty result"
);
}
#[test]
fn non_dismissible_scrim_does_not_cancel() {
let view: GlyphDialogView<()> = glyph_dialog().title("Hi").scrim_dismissible(false);
let mut w = build(&view);
let mut tcx = TextContext::new();
let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
w.layout(&mut lctx, &BoxConstraints::tight(Size::new(400.0, 600.0)));
let mut dummy = ();
let dispatch = |w: &mut GlyphDialogWidget, dummy: &mut (), e: &InputEvent| {
let s: &mut dyn Any = dummy;
let mut ctx = EventCtx::new(s, Point::ZERO, Size::new(400.0, 600.0));
w.event(&mut ctx, e)
};
dispatch(&mut w, &mut dummy, &ev(PointerPhase::Down, 5.0, 5.0));
dispatch(&mut w, &mut dummy, &ev(PointerPhase::Up, 5.0, 5.0));
assert_eq!(
w.phase,
Phase::Enter,
"a non-dismissible scrim never begins exit"
);
}
#[test]
fn a_secondary_scrim_press_is_swallowed_but_never_dismisses() {
let view: GlyphDialogView<()> = glyph_dialog().title("Hi");
let mut w = build(&view);
let mut tcx = TextContext::new();
let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
w.layout(&mut lctx, &BoxConstraints::tight(Size::new(400.0, 600.0)));
let mut dummy = ();
let dispatch = |w: &mut GlyphDialogWidget, dummy: &mut (), e: &InputEvent| {
let s: &mut dyn Any = dummy;
let mut ctx = EventCtx::new(s, Point::ZERO, Size::new(400.0, 600.0));
w.event(&mut ctx, e)
};
let r = dispatch(
&mut w,
&mut dummy,
&secondary_ev(PointerPhase::Down, 5.0, 5.0),
);
assert_eq!(
r,
EventResult::Handled,
"the barrier still swallows it — the page behind stays blocked"
);
assert!(!w.scrim_captured, "but arms no scrim dismiss");
dispatch(
&mut w,
&mut dummy,
&secondary_ev(PointerPhase::Up, 5.0, 5.0),
);
assert_eq!(
w.phase,
Phase::Enter,
"a right-click never begins the dialog's exit"
);
dispatch(&mut w, &mut dummy, &ev(PointerPhase::Down, 5.0, 5.0));
dispatch(&mut w, &mut dummy, &ev(PointerPhase::Up, 5.0, 5.0));
assert_eq!(w.phase, Phase::Exit);
}
#[test]
fn dismissable_false_gates_scrim_tap() {
let view: GlyphDialogView<()> = glyph_dialog().title("Hi").dismissable(false);
let mut w = build(&view);
let mut tcx = TextContext::new();
let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
w.layout(&mut lctx, &BoxConstraints::tight(Size::new(400.0, 600.0)));
let mut dummy = ();
let dispatch = |w: &mut GlyphDialogWidget, dummy: &mut (), e: &InputEvent| {
let s: &mut dyn Any = dummy;
let mut ctx = EventCtx::new(s, Point::ZERO, Size::new(400.0, 600.0));
w.event(&mut ctx, e)
};
dispatch(&mut w, &mut dummy, &ev(PointerPhase::Down, 5.0, 5.0));
dispatch(&mut w, &mut dummy, &ev(PointerPhase::Up, 5.0, 5.0));
assert_eq!(
w.phase,
Phase::Enter,
"dismissable(false) gates the scrim tap too"
);
}
#[test]
fn dismissable_false_gates_escape() {
let view: GlyphDialogView<()> = glyph_dialog().title("Hi").dismissable(false);
let mut w = build(&view);
let mut tcx = TextContext::new();
let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
w.layout(&mut lctx, &BoxConstraints::tight(Size::new(400.0, 600.0)));
let mut dummy = ();
let dispatch = |w: &mut GlyphDialogWidget, dummy: &mut (), e: &InputEvent| {
let s: &mut dyn Any = dummy;
let mut ctx = EventCtx::new(s, Point::ZERO, Size::new(400.0, 600.0));
w.event(&mut ctx, e)
};
dispatch(&mut w, &mut dummy, &ev(PointerPhase::Down, 5.0, 5.0));
dispatch(&mut w, &mut dummy, &ev(PointerPhase::Up, 5.0, 5.0));
dispatch(&mut w, &mut dummy, &escape_event());
assert_eq!(w.phase, Phase::Enter, "dismissable(false) gates Escape");
}
#[test]
fn back_request_dismissable_true_animates_exit_then_pops() {
let controller: NavigatorController<NavState> = NavigatorController::new();
let mut root: RenderRoot<NavState, NavigatorView<NavState>> = RenderRoot::new();
let mut app = app_page(&controller);
let mut state = NavState::default();
let area = Size::new(400.0, 600.0);
root.rebuild(&mut app, &mut state);
let mut tcx = TextContext::new();
root.layout_with_text(area, &mut tcx as &mut dyn Any);
show_glyph_dialog(
&controller,
|| glyph_dialog().title("Confirm"),
|state: &mut NavState, result: PopResult| state.results.push(result.take::<bool>()),
);
root.rebuild(&mut app, &mut state);
root.layout_with_text(area, &mut tcx as &mut dyn Any);
root.paint(&mut Recorder::default(), ft_ms(0.0));
root.paint(&mut Recorder::default(), ft_ms(400.0));
assert!(
controller.back_interest(),
"a dismissable dialog claims back interest"
);
controller.request_back();
root.rebuild(&mut app, &mut state);
assert_eq!(
controller.depth(),
2,
"DismissAnimated: the stack is unchanged immediately"
);
root.paint(&mut Recorder::default(), ft_ms(400.0));
assert!(
state.results.is_empty(),
"the exit animation has not finished yet"
);
root.paint(&mut Recorder::default(), ft_ms(500.0));
root.paint(&mut Recorder::default(), ft_ms(800.0));
root.rebuild(&mut app, &mut state);
root.layout_with_text(area, &mut tcx as &mut dyn Any);
root.event(&mut state, &ev(PointerPhase::Move, 5.0, 5.0));
assert_eq!(
state.results,
vec![None],
"a back request animates the exit then pops"
);
assert_eq!(controller.depth(), 1);
}
#[test]
fn back_request_dismissable_false_vetoes_stack_unchanged() {
let controller: NavigatorController<NavState> = NavigatorController::new();
let mut root: RenderRoot<NavState, NavigatorView<NavState>> = RenderRoot::new();
let mut app = app_page(&controller);
let mut state = NavState::default();
let area = Size::new(400.0, 600.0);
root.rebuild(&mut app, &mut state);
let mut tcx = TextContext::new();
root.layout_with_text(area, &mut tcx as &mut dyn Any);
show_glyph_dialog(
&controller,
|| glyph_dialog().title("Confirm").dismissable(false),
|state: &mut NavState, result: PopResult| state.results.push(result.take::<bool>()),
);
root.rebuild(&mut app, &mut state);
root.layout_with_text(area, &mut tcx as &mut dyn Any);
root.paint(&mut Recorder::default(), ft_ms(0.0));
root.paint(&mut Recorder::default(), ft_ms(400.0));
assert!(
controller.back_interest(),
"a Veto (non-dismissable) dialog still claims back interest"
);
controller.request_back();
root.rebuild(&mut app, &mut state);
assert_eq!(controller.depth(), 2, "Veto leaves the stack unchanged");
root.paint(&mut Recorder::default(), ft_ms(450.0));
root.paint(&mut Recorder::default(), ft_ms(900.0));
assert!(
state.results.is_empty(),
"a non-dismissable dialog never pops on a back request"
);
assert_eq!(controller.depth(), 2);
}
#[test]
fn escape_begins_exit_after_a_press_claims_focus() {
let controller: NavigatorController<NavState> = NavigatorController::new();
let mut root: RenderRoot<NavState, NavigatorView<NavState>> = RenderRoot::new();
let mut app = app_page(&controller);
let mut state = NavState::default();
let area = Size::new(400.0, 600.0);
root.rebuild(&mut app, &mut state);
let mut tcx = TextContext::new();
root.layout_with_text(area, &mut tcx as &mut dyn Any);
show_glyph_dialog(
&controller,
|| glyph_dialog().title("Confirm"),
|state: &mut NavState, result: PopResult| state.results.push(result.take::<bool>()),
);
root.rebuild(&mut app, &mut state);
root.layout_with_text(area, &mut tcx as &mut dyn Any);
root.paint(&mut Recorder::default(), ft_ms(0.0));
root.paint(&mut Recorder::default(), ft_ms(400.0));
let (cx, cy) = (area.width / 2.0, area.height / 2.0);
root.event(&mut state, &ev(PointerPhase::Down, cx, cy));
root.event(&mut state, &ev(PointerPhase::Up, cx, cy));
root.event(&mut state, &escape_event());
root.paint(&mut Recorder::default(), ft_ms(500.0));
root.paint(&mut Recorder::default(), ft_ms(800.0));
root.rebuild(&mut app, &mut state);
root.layout_with_text(area, &mut tcx as &mut dyn Any);
root.event(&mut state, &ev(PointerPhase::Move, 5.0, 5.0));
assert_eq!(
state.results,
vec![None],
"Escape dismisses the focused dialog"
);
}
#[test]
fn a_down_reclaims_focus_after_an_external_blur() {
use frust::authoring::ChildPod;
let view: GlyphDialogView<()> = glyph_dialog().title("Hi");
let area = Size::new(400.0, 600.0);
let w = build(&view);
let mut pod = ChildPod::new(Box::new(w));
let mut tcx = TextContext::new();
let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
pod.layout_child(&mut lctx, &BoxConstraints::tight(area));
let mut dummy = ();
{
let s: &mut dyn Any = &mut dummy;
let mut ctx = EventCtx::new(s, Point::ZERO, area);
pod.event_child(&mut ctx, &ev(PointerPhase::Down, 5.0, 5.0));
}
assert!(pod.is_focused(), "the first Down claims focus");
{
let s: &mut dyn Any = &mut dummy;
let mut ctx = EventCtx::new(s, Point::ZERO, area);
pod.event_child(&mut ctx, &ev(PointerPhase::Up, 5.0, 5.0));
}
pod.set_focused(false);
{
let s: &mut dyn Any = &mut dummy;
let mut ctx = EventCtx::new(s, Point::ZERO, area);
pod.event_child(&mut ctx, &ev(PointerPhase::Down, 5.0, 5.0));
}
assert!(
pod.is_focused(),
"a second Down must re-claim focus after an external blur — this is what \
keeps the root's focus/IME session alive while the dialog is up"
);
}
#[test]
fn action_pops_with_a_value_via_navigator() {
let controller: NavigatorController<NavState> = NavigatorController::new();
let mut root: RenderRoot<NavState, NavigatorView<NavState>> = RenderRoot::new();
let mut app = app_page(&controller);
let mut state = NavState::default();
let area = Size::new(400.0, 600.0);
root.rebuild(&mut app, &mut state);
let mut tcx = TextContext::new();
root.layout_with_text(area, &mut tcx as &mut dyn Any);
let action_w = 80.0;
let action_h = 36.0;
{
let confirm = controller.clone();
controller.push_transparent_for_result(
move || {
let c = confirm.clone();
core_any::<NavState, _>(glyph_dialog().action(tap_action(
action_w,
action_h,
move |_s: &mut NavState| c.pop_with_result(PopResult::of(true)),
)))
},
TransitionSpec::NONE,
|state: &mut NavState, result: PopResult| state.results.push(result.take::<bool>()),
);
}
root.rebuild(&mut app, &mut state);
root.layout_with_text(area, &mut tcx as &mut dyn Any);
root.paint(&mut Recorder::default(), ft_ms(0.0));
root.paint(&mut Recorder::default(), ft_ms(400.0));
let panel_x = (area.width - MIN_WIDTH) / 2.0;
let panel_h = 2.0 * DIALOG_PADDING + action_h;
let panel_y = (area.height - panel_h) / 2.0;
let action_cx = panel_x + MIN_WIDTH - DIALOG_PADDING - action_w / 2.0;
let action_cy = panel_y + DIALOG_PADDING + action_h / 2.0;
root.event(&mut state, &ev(PointerPhase::Down, action_cx, action_cy));
root.event(&mut state, &ev(PointerPhase::Up, action_cx, action_cy));
root.rebuild(&mut app, &mut state);
root.layout_with_text(area, &mut tcx as &mut dyn Any);
root.event(&mut state, &ev(PointerPhase::Move, 5.0, 5.0));
assert_eq!(state.results, vec![Some(true)], "the action pops with true");
}
#[test]
fn no_dangling_capture_after_dismiss() {
let controller: NavigatorController<NavState> = NavigatorController::new();
let mut root: RenderRoot<NavState, NavigatorView<NavState>> = RenderRoot::new();
let mut app = app_page(&controller);
let mut state = NavState::default();
let area = Size::new(400.0, 600.0);
root.rebuild(&mut app, &mut state);
let mut tcx = TextContext::new();
root.layout_with_text(area, &mut tcx as &mut dyn Any);
show_glyph_dialog(
&controller,
|| glyph_dialog().title("Confirm"),
|state: &mut NavState, result: PopResult| state.results.push(result.take::<bool>()),
);
root.rebuild(&mut app, &mut state);
root.layout_with_text(area, &mut tcx as &mut dyn Any);
root.paint(&mut Recorder::default(), ft_ms(0.0));
root.paint(&mut Recorder::default(), ft_ms(400.0));
root.event(&mut state, &ev(PointerPhase::Down, 5.0, 5.0));
root.event(&mut state, &ev(PointerPhase::Up, 5.0, 5.0));
root.paint(&mut Recorder::default(), ft_ms(500.0));
root.paint(&mut Recorder::default(), ft_ms(800.0));
root.rebuild(&mut app, &mut state);
root.layout_with_text(area, &mut tcx as &mut dyn Any);
assert!(
!root.is_pointer_captured(),
"no capture survives the dismiss"
);
root.event(&mut state, &ev(PointerPhase::Up, 5.0, 5.0));
assert_eq!(state.results, vec![None]);
}
#[test]
fn semantics_is_a_modal_dialog_labelled_by_title() {
fn logic(_s: &mut ()) -> GlyphDialogView<()> {
glyph_dialog().title("Confirm").body("Sure?")
}
let mut root: RenderRoot<(), GlyphDialogView<()>> = RenderRoot::new();
let mut state = ();
root.rebuild(&mut logic, &mut state);
let mut tcx = TextContext::new();
root.layout_with_text(Size::new(400.0, 600.0), &mut tcx as &mut dyn Any);
let update = root.semantics();
let (_, node) = update
.nodes
.iter()
.find(|(_, n)| n.role() == Role::Dialog)
.expect("a Role::Dialog node is contributed");
assert!(node.is_modal(), "the dialog node sets the modal flag");
assert_eq!(node.label(), Some("Confirm"));
assert!(
!node.children().is_empty(),
"title/body are semantics children"
);
}
#[test]
fn body_only_dialog_still_builds_a_text_child_and_records_body_text() {
let view: GlyphDialogView<()> = glyph_dialog().title("Hi").body("Sure?");
let w = build(&view);
assert!(w.body.is_some(), "body text still builds a child pod");
assert_eq!(w.body_text.as_deref(), Some("Sure?"));
}
#[test]
fn content_wins_over_body_when_both_are_set() {
let view: GlyphDialogView<()> = glyph_dialog()
.title("Both")
.body("ignored text")
.content(tap_action(50.0, 20.0, |_s: &mut ()| {}));
let mut w = build(&view);
let mut tcx = TextContext::new();
let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
w.layout(&mut lctx, &BoxConstraints::tight(Size::new(400.0, 600.0)));
assert_eq!(
w.body_text, None,
"content wins: the body text is not the active slot"
);
let slot_size = w.body.as_ref().expect("slot built").size();
assert_eq!(
slot_size,
Size::new(50.0, 20.0),
"the slot holds the fixed-size content view, not laid-out body text"
);
}
#[test]
fn content_taller_than_viewport_is_not_clamped_or_scrolled() {
let view: GlyphDialogView<()> =
glyph_dialog()
.title("Tall")
.content(tap_action(300.0, 2000.0, |_s: &mut ()| {}));
let mut w = build(&view);
let mut tcx = TextContext::new();
let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
let area = Size::new(400.0, 600.0);
w.layout(&mut lctx, &BoxConstraints::tight(area));
let content_size = w.body.as_ref().expect("content pod built").size();
assert_eq!(
content_size.height, 2000.0,
"the content slot gets unbounded (loose) height, matching body's own sizing"
);
assert!(
w.panel.height() > area.height,
"an oversized content slot is not clipped or scrolled — the panel simply grows \
past the viewport; wrap unbounded content in your own scroll view instead"
);
}
#[test]
fn content_text_field_takes_focus_and_accepts_typing() {
#[derive(Default)]
struct FieldState {
text: String,
}
let view: GlyphDialogView<FieldState> = glyph_dialog().title("Rename").content(core_any::<
FieldState,
_,
>(
text_input(String::new(), |s: &mut FieldState, v| {
s.text = v;
}),
));
let mut w = build(&view);
let mut tcx = TextContext::new();
let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
let area = Size::new(400.0, 600.0);
w.layout(&mut lctx, &BoxConstraints::tight(area));
let content_pod = w.body.as_ref().expect("content pod is built");
let tap = Point::new(
content_pod.origin().x + content_pod.size().width / 2.0,
content_pod.origin().y + content_pod.size().height / 2.0,
);
let mut state = FieldState::default();
let dispatch = |w: &mut GlyphDialogWidget, state: &mut FieldState, e: &InputEvent| {
let s: &mut dyn Any = state;
let mut ctx = EventCtx::new(s, Point::ZERO, area);
w.event(&mut ctx, e)
};
dispatch(&mut w, &mut state, &ev(PointerPhase::Down, tap.x, tap.y));
assert!(
w.body.as_ref().unwrap().is_focused(),
"tapping the content field focuses it (not swallowed by the scrim barrier)"
);
dispatch(&mut w, &mut state, &ev(PointerPhase::Up, tap.x, tap.y));
dispatch(
&mut w,
&mut state,
&InputEvent::Key(KeyEvent {
key: Key::Character("a".to_string()),
modifiers: Modifiers::default(),
repeat: false,
}),
);
assert_eq!(
state.text, "a",
"the focused content field received the keystroke instead of it being \
swallowed by the dialog's own Key handling"
);
}
#[test]
fn narrow_window_keeps_a_screen_margin_on_both_sides_even_with_stretching_content() {
let view: GlyphDialogView<()> =
glyph_dialog()
.title("Hi")
.content(tap_action(10_000.0, 20.0, |_s: &mut ()| {}));
let mut w = build(&view);
let mut tcx = TextContext::new();
let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
let area = Size::new(393.0, 800.0);
w.layout(&mut lctx, &BoxConstraints::tight(area));
assert_eq!(w.panel.width(), area.width - 2.0 * SCREEN_MARGIN);
assert_eq!(w.panel.x0, SCREEN_MARGIN, "margin on the leading edge");
assert_eq!(
area.width - (w.panel.x0 + w.panel.width()),
SCREEN_MARGIN,
"margin on the trailing edge"
);
}
#[test]
fn window_narrower_than_min_width_plus_margins_still_gets_the_margin() {
let view: GlyphDialogView<()> = glyph_dialog().title("Hi");
let mut w = build(&view);
let mut tcx = TextContext::new();
let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
let area = Size::new(320.0, 600.0);
w.layout(&mut lctx, &BoxConstraints::tight(area));
assert_eq!(w.panel.width(), 272.0);
assert_eq!(w.panel.x0, SCREEN_MARGIN);
}
#[test]
fn pathologically_tiny_window_does_not_panic_and_stays_non_negative() {
let view: GlyphDialogView<()> = glyph_dialog().title("Hi");
let mut w = build(&view);
let mut tcx = TextContext::new();
let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
let area = Size::new(30.0, 60.0);
w.layout(&mut lctx, &BoxConstraints::tight(area));
assert!(w.panel.width() >= 0.0);
assert!(w.panel.x0 >= 0.0);
}
#[test]
fn wide_window_still_caps_at_max_width_unaffected_by_the_margin() {
let view: GlyphDialogView<()> =
glyph_dialog()
.title("Hi")
.content(tap_action(10_000.0, 20.0, |_s: &mut ()| {}));
let mut w = build(&view);
let mut tcx = TextContext::new();
let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
let area = Size::new(600.0, 800.0);
w.layout(&mut lctx, &BoxConstraints::tight(area));
assert_eq!(w.panel.width(), MAX_WIDTH);
assert_eq!(w.panel.x0, (area.width - MAX_WIDTH) / 2.0);
}
fn dialog_widget<S: 'static>(root: &RenderRoot<S, GlyphDialogView<S>>) -> &GlyphDialogWidget {
let id = root.root_id().expect("root built");
(root.tree().pod(id).expect("root pod").widget() as &dyn Any)
.downcast_ref::<GlyphDialogWidget>()
.expect("the root is a GlyphDialogWidget")
}
fn ime_insets(bottom: f64) -> WindowInsets {
WindowInsets::new(
WindowEdgeInsets::ZERO,
WindowEdgeInsets::new(0.0, 0.0, 0.0, bottom),
)
}
const TITLED_ACTION_PANEL_H: f64 = 116.0;
fn titled_action_dialog(_s: &mut ()) -> GlyphDialogView<()> {
glyph_dialog()
.title("Confirm")
.action(tap_action(80.0, 36.0, |_s: &mut ()| {}))
}
#[test]
fn keyboard_inset_centers_the_panel_in_the_free_area() {
let area = Size::new(375.0, 667.0);
let ime = 300.0;
let free_bottom = area.height - ime;
let mut root: RenderRoot<(), GlyphDialogView<()>> = RenderRoot::new();
let mut state = ();
let mut tcx = TextContext::new();
root.rebuild(&mut titled_action_dialog, &mut state);
root.set_insets(ime_insets(ime));
root.layout_with_text(area, &mut tcx as &mut dyn Any);
let w = dialog_widget(&root);
assert_eq!(w.panel.height(), TITLED_ACTION_PANEL_H);
assert_eq!(
w.panel.y0,
(free_bottom - TITLED_ACTION_PANEL_H) / 2.0,
"the panel centers in the keyboard-free band, not the window"
);
assert!(
w.panel.y1 <= free_bottom,
"the whole panel clears the keyboard ({} vs {free_bottom})",
w.panel.y1
);
let action = w.actions.first().expect("one action");
assert!(
action.origin().y + action.size().height <= free_bottom,
"the action row is visible above the keyboard"
);
assert!(
(area.height - TITLED_ACTION_PANEL_H) / 2.0 + TITLED_ACTION_PANEL_H > free_bottom,
"sanity: the old full-window centering really did overlap the keyboard"
);
}
#[test]
fn keyboard_retraction_recenters_the_panel() {
let area = Size::new(375.0, 667.0);
let mut root: RenderRoot<(), GlyphDialogView<()>> = RenderRoot::new();
let mut state = ();
let mut tcx = TextContext::new();
root.rebuild(&mut titled_action_dialog, &mut state);
root.set_insets(ime_insets(300.0));
root.layout_with_text(area, &mut tcx as &mut dyn Any);
let raised_y = dialog_widget(&root).panel.y0;
root.set_insets(WindowInsets::default());
root.layout_with_text(area, &mut tcx as &mut dyn Any);
let settled_y = dialog_widget(&root).panel.y0;
assert_eq!(
settled_y,
(area.height - TITLED_ACTION_PANEL_H) / 2.0,
"with the keyboard gone the band is the window again"
);
assert!(settled_y > raised_y, "the panel moved back down");
}
#[test]
fn panel_taller_than_the_free_area_pins_to_the_top() {
fn logic(_s: &mut ()) -> GlyphDialogView<()> {
glyph_dialog()
.title("Tall")
.content(tap_action(200.0, 500.0, |_s: &mut ()| {}))
}
let mut root: RenderRoot<(), GlyphDialogView<()>> = RenderRoot::new();
let mut state = ();
let mut tcx = TextContext::new();
root.rebuild(&mut logic, &mut state);
root.set_insets(ime_insets(400.0));
root.layout_with_text(Size::new(375.0, 667.0), &mut tcx as &mut dyn Any);
let w = dialog_widget(&root);
assert_eq!(w.panel.y0, 0.0, "pinned to the top, never negative");
assert!(
w.panel.height() > 667.0 - 400.0,
"sanity: taller than the band"
);
}
#[test]
fn keyboard_inset_leaves_the_screen_margin_and_max_width_alone() {
fn logic(_s: &mut ()) -> GlyphDialogView<()> {
glyph_dialog()
.title("Hi")
.content(tap_action(10_000.0, 20.0, |_s: &mut ()| {}))
}
let area = Size::new(393.0, 800.0);
let mut root: RenderRoot<(), GlyphDialogView<()>> = RenderRoot::new();
let mut state = ();
let mut tcx = TextContext::new();
root.rebuild(&mut logic, &mut state);
root.set_insets(ime_insets(336.0));
root.layout_with_text(area, &mut tcx as &mut dyn Any);
let w = dialog_widget(&root);
assert_eq!(w.panel.width(), area.width - 2.0 * SCREEN_MARGIN);
assert_eq!(w.panel.x0, SCREEN_MARGIN);
assert!(
w.panel.y1 <= area.height - 336.0,
"still clears the keyboard"
);
}
#[derive(Default)]
struct FieldState {
text: String,
}
fn field_dialog(s: &mut FieldState) -> GlyphDialogView<FieldState> {
glyph_dialog()
.title("New token")
.content(core_any::<FieldState, _>(text_input(
s.text.clone(),
|s: &mut FieldState, v| s.text = v,
)))
.action(tap_action(80.0, 36.0, |_s: &mut FieldState| {}))
}
struct FieldFixture {
root: RenderRoot<FieldState, GlyphDialogView<FieldState>>,
state: FieldState,
dead: Point,
action: Point,
}
fn focused_field_dialog(area: Size) -> FieldFixture {
let mut root: RenderRoot<FieldState, GlyphDialogView<FieldState>> = RenderRoot::new();
let mut state = FieldState::default();
let mut tcx = TextContext::new();
root.rebuild(&mut field_dialog, &mut state);
root.layout_with_text(area, &mut tcx as &mut dyn Any);
root.paint(&mut Recorder::default(), ft_ms(0.0));
root.paint(&mut Recorder::default(), ft_ms(400.0));
let (field, dead, action) = {
let w = dialog_widget(&root);
let f = w.body.as_ref().expect("the content field is built");
let a = w.actions.first().expect("one action");
(
Point::new(
f.origin().x + f.size().width / 2.0,
f.origin().y + f.size().height / 2.0,
),
Point::new(w.panel.x0 + 2.0, w.panel.y0 + 2.0),
Point::new(
a.origin().x + a.size().width / 2.0,
a.origin().y + a.size().height / 2.0,
),
)
};
root.event(&mut state, &ev(PointerPhase::Down, field.x, field.y));
root.event(&mut state, &ev(PointerPhase::Up, field.x, field.y));
assert!(
root.ime_state().is_some(),
"precondition: the field owns a live IME session"
);
assert!(root.is_focus_active());
FieldFixture {
root,
state,
dead,
action,
}
}
#[test]
fn dead_space_down_ends_the_field_session_and_keeps_the_dialog_open() {
let area = Size::new(375.0, 667.0);
let mut f = focused_field_dialog(area);
f.root
.event(&mut f.state, &ev(PointerPhase::Down, f.dead.x, f.dead.y));
assert!(
f.root.ime_state().is_none(),
"a Down on dead panel space ends the field's IME session — the keyboard retracts"
);
let w = dialog_widget(&f.root);
assert!(
!w.body.as_ref().expect("field").is_focused(),
"the field lost the recorded focus path (it blurred)"
);
assert_ne!(w.phase, Phase::Exit, "the dialog is not dismissing");
assert_ne!(w.phase, Phase::Dismissed);
f.root
.event(&mut f.state, &ev(PointerPhase::Up, f.dead.x, f.dead.y));
f.root.paint(&mut Recorder::default(), ft_ms(500.0));
assert!(
f.root.ime_state().is_none(),
"no resurrection on the next paint"
);
assert_eq!(
dialog_widget(&f.root).phase,
Phase::Shown,
"the dialog is still open after the blur"
);
}
#[test]
fn escape_still_dismisses_after_a_dead_space_down() {
let area = Size::new(375.0, 667.0);
let mut f = focused_field_dialog(area);
f.root
.event(&mut f.state, &ev(PointerPhase::Down, f.dead.x, f.dead.y));
f.root
.event(&mut f.state, &ev(PointerPhase::Up, f.dead.x, f.dead.y));
assert!(
f.root.is_focus_active(),
"the dialog keeps the focus link the same dispatch's claim re-applied"
);
f.root.event(&mut f.state, &escape_event());
assert_eq!(
dialog_widget(&f.root).phase,
Phase::Exit,
"Escape still reaches the dialog after a dead-space blur"
);
}
#[test]
fn action_down_leaves_the_field_session_intact() {
let area = Size::new(375.0, 667.0);
let mut f = focused_field_dialog(area);
let before = f.root.focus_ime_generation();
f.root.event(
&mut f.state,
&ev(PointerPhase::Down, f.action.x, f.action.y),
);
assert!(
f.root.ime_state().is_some(),
"an action Down never ends the field's session"
);
assert_eq!(
f.root.focus_ime_generation(),
before,
"and fires no focus/IME edge at all"
);
f.root
.event(&mut f.state, &ev(PointerPhase::Up, f.action.x, f.action.y));
assert_eq!(dialog_widget(&f.root).phase, Phase::Shown);
}
#[test]
fn dead_space_down_with_no_focused_content_moves_nothing() {
let area = Size::new(375.0, 667.0);
let mut root: RenderRoot<FieldState, GlyphDialogView<FieldState>> = RenderRoot::new();
let mut state = FieldState::default();
let mut tcx = TextContext::new();
root.rebuild(&mut field_dialog, &mut state);
root.layout_with_text(area, &mut tcx as &mut dyn Any);
root.paint(&mut Recorder::default(), ft_ms(0.0));
root.paint(&mut Recorder::default(), ft_ms(400.0));
let dead = {
let w = dialog_widget(&root);
Point::new(w.panel.x0 + 2.0, w.panel.y0 + 2.0)
};
let before = root.focus_ime_generation();
root.event(&mut state, &ev(PointerPhase::Down, dead.x, dead.y));
assert!(root.ime_state().is_none());
assert_eq!(
root.focus_ime_generation(),
before + 1,
"exactly one edge: the dialog's own first focus claim, no spurious release"
);
assert!(root.is_focus_active());
}
#[test]
fn dead_space_down_ends_the_session_with_the_dialog_pushed_on_a_navigator() {
let controller: NavigatorController<NavState> = NavigatorController::new();
let mut root: RenderRoot<NavState, NavigatorView<NavState>> = RenderRoot::new();
let mut app = app_page(&controller);
let mut state = NavState::default();
let area = Size::new(375.0, 667.0);
root.rebuild(&mut app, &mut state);
let mut tcx = TextContext::new();
root.layout_with_text(area, &mut tcx as &mut dyn Any);
let (field_w, field_h) = (200.0, 40.0);
show_glyph_dialog(
&controller,
move || {
glyph_dialog()
.title("Confirm")
.content(focus_field(field_w, field_h))
},
|state: &mut NavState, result: PopResult| state.results.push(result.take::<bool>()),
);
root.rebuild(&mut app, &mut state);
root.layout_with_text(area, &mut tcx as &mut dyn Any);
root.paint(&mut Recorder::default(), ft_ms(0.0));
root.paint(&mut Recorder::default(), ft_ms(400.0));
let panel_h = 2.0 * DIALOG_PADDING + TITLE_LINE_HEIGHT as f64 + TITLE_BODY_GAP + field_h;
let panel_x = (area.width - MIN_WIDTH) / 2.0;
let panel_y = (area.height - panel_h) / 2.0;
let field = Point::new(
panel_x + DIALOG_PADDING + field_w / 2.0,
panel_y + DIALOG_PADDING + TITLE_LINE_HEIGHT as f64 + TITLE_BODY_GAP + field_h / 2.0,
);
let dead = Point::new(panel_x + 2.0, panel_y + 2.0);
root.event(&mut state, &ev(PointerPhase::Down, field.x, field.y));
root.event(&mut state, &ev(PointerPhase::Up, field.x, field.y));
assert!(
root.ime_state().is_some(),
"precondition: the field owns a live IME session"
);
root.event(&mut state, &ev(PointerPhase::Down, dead.x, dead.y));
root.event(&mut state, &ev(PointerPhase::Up, dead.x, dead.y));
assert!(
root.ime_state().is_none(),
"the dead-space Down ended the session under the navigator too"
);
assert_eq!(controller.depth(), 2, "the dialog page is still pushed");
root.paint(&mut Recorder::default(), ft_ms(500.0));
root.paint(&mut Recorder::default(), ft_ms(800.0));
root.rebuild(&mut app, &mut state);
assert_eq!(controller.depth(), 2, "a dead-space tap is not a dismiss");
assert!(state.results.is_empty());
assert!(root.ime_state().is_none());
}
#[test]
fn semantics_forwards_the_content_subtree() {
fn logic(_s: &mut ()) -> GlyphDialogView<()> {
glyph_dialog()
.title("Confirm")
.content(any::<(), _>(text("hello".to_string())))
}
let mut root: RenderRoot<(), GlyphDialogView<()>> = RenderRoot::new();
let mut state = ();
root.rebuild(&mut logic, &mut state);
let mut tcx = TextContext::new();
root.layout_with_text(Size::new(400.0, 600.0), &mut tcx as &mut dyn Any);
let update = root.semantics();
let (_, dialog_node) = update
.nodes
.iter()
.find(|(_, n)| n.role() == Role::Dialog)
.expect("a Role::Dialog node is contributed");
let (content_id, _) = update
.nodes
.iter()
.find(|(_, n)| n.role() == Role::Label && n.value() == Some("hello"))
.expect("the content subtree's own semantics reached the tree — it was forwarded");
assert!(
dialog_node.children().contains(content_id),
"the content node is a direct semantics child of the dialog container"
);
}
struct TapView<State: 'static> {
size: Size,
on_tap: Rc<dyn Fn(&mut State)>,
}
struct TapWidget {
size: Size,
on_tap: frust::authoring::ErasedCallback,
captured: bool,
}
impl<State: 'static> View<State> for TapView<State> {
type Element = TapWidget;
fn build(&self, _ctx: &mut BuildCtx<'_>) -> TapWidget {
TapWidget {
size: self.size,
on_tap: frust::authoring::erase_callback(&self.on_tap),
captured: false,
}
}
fn rebuild(&self, _p: &Self, e: &mut TapWidget, _c: &mut BuildCtx<'_>) -> ChangeFlags {
e.on_tap = frust::authoring::erase_callback(&self.on_tap);
ChangeFlags::NONE
}
}
impl Widget for TapWidget {
fn layout(&mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
bc.constrain(self.size)
}
fn paint(&mut self, _ctx: &mut PaintCtx, _scene: &mut dyn PaintScene) {}
fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
let InputEvent::Pointer(p) = event else {
return EventResult::Ignored;
};
match p.phase {
PointerPhase::Down => {
self.captured = true;
ctx.capture_pointer();
EventResult::Handled
}
PointerPhase::Up => {
if self.captured
&& p.position.x >= 0.0
&& p.position.y >= 0.0
&& p.position.x < self.size.width
&& p.position.y < self.size.height
{
(self.on_tap)(ctx);
}
self.captured = false;
EventResult::Handled
}
PointerPhase::Move => EventResult::Handled,
PointerPhase::Cancel => {
self.captured = false;
EventResult::Handled
}
}
}
}
fn tap_action<State: 'static>(
w: f64,
h: f64,
on_tap: impl Fn(&mut State) + 'static,
) -> AnyView<State> {
core_any::<State, _>(TapView {
size: Size::new(w, h),
on_tap: Rc::new(on_tap),
})
}
struct FocusFieldView<State: 'static> {
size: Size,
_state: std::marker::PhantomData<State>,
}
struct FocusFieldWidget {
size: Size,
}
impl<State: 'static> View<State> for FocusFieldView<State> {
type Element = FocusFieldWidget;
fn build(&self, _ctx: &mut BuildCtx<'_>) -> FocusFieldWidget {
FocusFieldWidget { size: self.size }
}
fn rebuild(
&self,
_p: &Self,
_e: &mut FocusFieldWidget,
_c: &mut BuildCtx<'_>,
) -> ChangeFlags {
ChangeFlags::NONE
}
}
impl Widget for FocusFieldWidget {
fn layout(&mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
bc.constrain(self.size)
}
fn paint(&mut self, _ctx: &mut PaintCtx, _scene: &mut dyn PaintScene) {}
fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
let InputEvent::Pointer(p) = event else {
return EventResult::Ignored;
};
if p.phase == PointerPhase::Down {
ctx.request_focus();
ctx.publish_ime_state(ImeState {
active: true,
editing: EditingState {
text: "hi".to_string(),
selection_base: 2,
selection_extent: 2,
composing_base: -1,
composing_extent: -1,
},
caret: Some(Rect::new(0.0, 0.0, 1.0, 12.0)),
content_type: Default::default(),
suppress_soft_keyboard: false,
});
}
EventResult::Handled
}
}
fn focus_field<State: 'static>(w: f64, h: f64) -> AnyView<State> {
core_any::<State, _>(FocusFieldView {
size: Size::new(w, h),
_state: std::marker::PhantomData,
})
}
#[cfg(feature = "bundled-fonts")]
fn confirm(_: &mut ()) -> GlyphDialogView<()> {
glyph_dialog().title("Confirm").body("Sure?")
}
#[cfg(feature = "bundled-fonts")]
#[test]
fn title_and_body_paint_in_their_role_faces_under_the_glyph_theme() {
use crate::badge::typeface_probe::{Face, painted_faces};
let faces = painted_faces(confirm, crate::baseline(), Size::new(400.0, 600.0));
assert_eq!(faces, [Face::SpaceMono, Face::PlexMono]);
}
#[cfg(feature = "bundled-fonts")]
#[test]
fn title_and_body_follow_a_live_theme_family_swap() {
use crate::badge::typeface_probe::{Face, faces_across_a_live_swap};
let (before, after) = faces_across_a_live_swap(confirm, Size::new(400.0, 600.0));
assert_eq!(before, [Face::SpaceMono, Face::PlexMono]);
assert_eq!(after, [Face::PlexMono, Face::SpaceMono]);
}
}