use taffy::prelude::{
AlignItems, Dimension, Display, FlexDirection, LengthPercentageAuto, Position, Rect,
Size as TaffySize, Style,
};
use crate::accessibility::{
AccessibilityAdapterRequest, AccessibilityCapabilities, FocusRestoreTarget, FocusTrap,
};
use crate::{
length, AccessibilityLiveRegion, AccessibilityMeta, AccessibilityRole, AccessibilityValueRange,
AnimatedValues, AnimationMachine, AnimationState, AnimationTransition, AnimationTrigger,
ClipBehavior, ColorRgba, ImageContent, InputBehavior, LayoutStyle, ScenePrimitive,
ShaderEffect, StrokeStyle, TextStyle, UiDocument, UiNode, UiNodeId, UiNodeStyle, UiPoint,
UiRect, UiSize, UiVisual,
};
const DEFAULT_SURFACE_BG: ColorRgba = ColorRgba::new(24, 29, 36, 255);
const DEFAULT_SURFACE_STROKE: ColorRgba = ColorRgba::new(70, 82, 101, 255);
const DEFAULT_ACCENT: ColorRgba = ColorRgba::new(108, 180, 255, 255);
pub const SURFACE_OPEN_TRIGGER: &str = "surface.open";
pub const SURFACE_CLOSE_TRIGGER: &str = "surface.close";
pub const TOAST_ENTER_TRIGGER: &str = "toast.enter";
pub const TOAST_EXIT_TRIGGER: &str = "toast.exit";
pub fn surface_open_close_animation(initially_open: bool) -> AnimationMachine {
AnimationMachine::new(
vec![
AnimationState::new(
"closed",
AnimatedValues::new(0.0, UiPoint::new(0.0, 12.0), 0.98),
),
AnimationState::new(
"open",
AnimatedValues::new(1.0, UiPoint::new(0.0, 0.0), 1.0),
),
],
vec![
AnimationTransition::new(
"closed",
"open",
surface_animation_trigger(SURFACE_OPEN_TRIGGER),
0.16,
),
AnimationTransition::new(
"open",
"closed",
surface_animation_trigger(SURFACE_CLOSE_TRIGGER),
0.12,
),
],
if initially_open { "open" } else { "closed" },
)
.expect("surface open/close animation preset should be internally valid")
}
pub fn toast_enter_exit_animation(initially_visible: bool) -> AnimationMachine {
AnimationMachine::new(
vec![
AnimationState::new(
"hidden",
AnimatedValues::new(0.0, UiPoint::new(18.0, 0.0), 0.98),
),
AnimationState::new(
"visible",
AnimatedValues::new(1.0, UiPoint::new(0.0, 0.0), 1.0),
),
],
vec![
AnimationTransition::new(
"hidden",
"visible",
surface_animation_trigger(TOAST_ENTER_TRIGGER),
0.18,
),
AnimationTransition::new(
"visible",
"hidden",
surface_animation_trigger(TOAST_EXIT_TRIGGER),
0.12,
),
],
if initially_visible {
"visible"
} else {
"hidden"
},
)
.expect("toast enter/exit animation preset should be internally valid")
}
fn surface_animation_trigger(name: &str) -> AnimationTrigger {
AnimationTrigger::Custom(name.to_string())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SplitAxis {
Horizontal,
Vertical,
}
impl SplitAxis {
pub const fn flex_direction(self) -> FlexDirection {
match self {
Self::Horizontal => FlexDirection::Row,
Self::Vertical => FlexDirection::Column,
}
}
pub const fn is_horizontal(self) -> bool {
matches!(self, Self::Horizontal)
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SplitPaneSizes {
pub first: f32,
pub handle: f32,
pub second: f32,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SplitPaneState {
pub fraction: f32,
pub min_first: f32,
pub min_second: f32,
}
impl SplitPaneState {
pub fn new(fraction: f32) -> Self {
Self {
fraction: fraction.clamp(0.0, 1.0),
..Default::default()
}
}
pub fn with_min_sizes(mut self, first: f32, second: f32) -> Self {
self.min_first = first.max(0.0);
self.min_second = second.max(0.0);
self
}
pub fn set_fraction(&mut self, fraction: f32) -> bool {
if !fraction.is_finite() {
return false;
}
let fraction = fraction.clamp(0.0, 1.0);
if (self.fraction - fraction).abs() <= f32::EPSILON {
return false;
}
self.fraction = fraction;
true
}
pub fn resolved_sizes(self, total_extent: f32, handle_thickness: f32) -> SplitPaneSizes {
let total = total_extent.max(0.0);
let handle = handle_thickness.max(0.0).min(total);
let available = (total - handle).max(0.0);
if available <= f32::EPSILON {
return SplitPaneSizes {
first: 0.0,
handle,
second: 0.0,
};
}
let mut min_first = self.min_first.max(0.0);
let mut min_second = self.min_second.max(0.0);
let min_total = min_first + min_second;
if min_total > available && min_total > f32::EPSILON {
let scale = available / min_total;
min_first *= scale;
min_second *= scale;
}
let lower = min_first.min(available);
let upper = (available - min_second).max(lower);
let desired = available * self.fraction.clamp(0.0, 1.0);
let first = desired.clamp(lower, upper);
SplitPaneSizes {
first,
handle,
second: (available - first).max(0.0),
}
}
pub fn resize_by(&mut self, delta: f32, total_extent: f32, handle_thickness: f32) -> bool {
if !delta.is_finite() || !total_extent.is_finite() || !handle_thickness.is_finite() {
return false;
}
let available = (total_extent.max(0.0) - handle_thickness.max(0.0)).max(0.0);
if available <= f32::EPSILON {
return false;
}
let sizes = self.resolved_sizes(total_extent, handle_thickness);
let next_first = (sizes.first + delta).clamp(0.0, available);
self.set_fraction(next_first / available)
}
}
impl Default for SplitPaneState {
fn default() -> Self {
Self {
fraction: 0.5,
min_first: 48.0,
min_second: 48.0,
}
}
}
#[derive(Debug, Clone)]
pub struct SplitPaneOptions {
pub layout: LayoutStyle,
pub handle_thickness: f32,
pub root_visual: UiVisual,
pub pane_visual: UiVisual,
pub handle_visual: UiVisual,
}
impl Default for SplitPaneOptions {
fn default() -> Self {
Self {
layout: LayoutStyle::from_taffy_style(Style {
display: Display::Flex,
size: TaffySize {
width: Dimension::percent(1.0),
height: Dimension::percent(1.0),
},
..Default::default()
}),
handle_thickness: 6.0,
root_visual: UiVisual::TRANSPARENT,
pane_visual: UiVisual::TRANSPARENT,
handle_visual: UiVisual::panel(DEFAULT_SURFACE_STROKE, None, 2.0),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SplitPaneNodes {
pub root: UiNodeId,
pub first: UiNodeId,
pub handle: UiNodeId,
pub second: UiNodeId,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ProgressIndicatorKind {
Progress,
Meter,
}
impl ProgressIndicatorKind {
pub const fn accessibility_role(self) -> AccessibilityRole {
match self {
Self::Progress => AccessibilityRole::ProgressBar,
Self::Meter => AccessibilityRole::Meter,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ProgressIndicatorValue {
pub value: Option<f32>,
pub min: f32,
pub max: f32,
}
impl ProgressIndicatorValue {
pub fn new(value: f32, min: f32, max: f32) -> Self {
let (min, max) = ordered_progress_range(min, max);
Self {
value: value.is_finite().then_some(value.clamp(min, max)),
min,
max,
}
}
pub fn percent(percent: f32) -> Self {
Self::new(percent, 0.0, 100.0)
}
pub fn indeterminate(min: f32, max: f32) -> Self {
let (min, max) = ordered_progress_range(min, max);
Self {
value: None,
min,
max,
}
}
pub fn normalized(self) -> Option<f32> {
let value = self.value?;
let span = (self.max - self.min).max(f32::EPSILON);
Some(((value - self.min) / span).clamp(0.0, 1.0))
}
pub fn value_text(self, unit: Option<&str>) -> String {
let Some(value) = self.value else {
return "Indeterminate".to_string();
};
if let Some(unit) = unit.filter(|unit| !unit.is_empty()) {
format!("{} {}", format_progress_number(value), unit)
} else if self.min == 0.0 && self.max == 100.0 {
format!("{}%", format_progress_number(value))
} else {
format_progress_number(value)
}
}
pub fn fill_rect(self, track: UiRect) -> UiRect {
let normalized = self.normalized().unwrap_or(0.0);
UiRect::new(track.x, track.y, track.width * normalized, track.height)
}
pub fn accessibility_meta(
self,
label: impl Into<String>,
kind: ProgressIndicatorKind,
unit: Option<&str>,
) -> AccessibilityMeta {
let mut meta = AccessibilityMeta::new(kind.accessibility_role())
.label(label)
.value(self.value_text(unit));
if self.value.is_some() {
meta = meta.value_range(AccessibilityValueRange::new(
self.min as f64,
self.max as f64,
));
} else {
meta = meta.hint("Value is not currently available");
}
meta
}
}
#[derive(Debug, Clone)]
pub struct ProgressIndicatorOptions {
pub layout: LayoutStyle,
pub kind: ProgressIndicatorKind,
pub track_visual: UiVisual,
pub fill_visual: UiVisual,
pub shader: Option<ShaderEffect>,
pub fill_shader: Option<ShaderEffect>,
pub accessibility_label: Option<String>,
pub accessibility_unit: Option<String>,
}
impl Default for ProgressIndicatorOptions {
fn default() -> Self {
Self {
layout: LayoutStyle::from_taffy_style(Style {
size: TaffySize {
width: Dimension::percent(1.0),
height: length(8.0),
},
..Default::default()
}),
kind: ProgressIndicatorKind::Progress,
track_visual: UiVisual::panel(DEFAULT_SURFACE_BG, None, 3.0),
fill_visual: UiVisual::panel(DEFAULT_ACCENT, None, 3.0),
shader: None,
fill_shader: None,
accessibility_label: None,
accessibility_unit: None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ProgressIndicatorNodes {
pub root: UiNodeId,
pub fill: UiNodeId,
}
pub fn progress_indicator(
document: &mut UiDocument,
parent: UiNodeId,
name: impl Into<String>,
value: ProgressIndicatorValue,
options: ProgressIndicatorOptions,
) -> ProgressIndicatorNodes {
let name = name.into();
let label = options
.accessibility_label
.clone()
.unwrap_or_else(|| name.clone());
let mut root = UiNode::container(
name.clone(),
UiNodeStyle {
layout: options.layout.style,
clip: ClipBehavior::Clip,
..Default::default()
},
)
.with_visual(options.track_visual)
.with_accessibility(value.accessibility_meta(
label,
options.kind,
options.accessibility_unit.as_deref(),
));
if let Some(shader) = options.shader {
root = root.with_shader(shader);
}
let root = document.add_child(parent, root);
let mut fill = UiNode::container(
format!("{name}.fill"),
UiNodeStyle {
layout: LayoutStyle::from_taffy_style(Style {
size: TaffySize {
width: Dimension::percent(value.normalized().unwrap_or(0.0)),
height: Dimension::percent(1.0),
},
..Default::default()
})
.style,
..Default::default()
},
)
.with_visual(options.fill_visual);
if let Some(shader) = options.fill_shader {
fill = fill.with_shader(shader);
}
let fill = document.add_child(root, fill);
ProgressIndicatorNodes { root, fill }
}
#[allow(clippy::too_many_arguments)]
pub fn split_pane(
document: &mut UiDocument,
parent: UiNodeId,
name: impl Into<String>,
axis: SplitAxis,
state: SplitPaneState,
options: SplitPaneOptions,
build_first: impl FnOnce(&mut UiDocument, UiNodeId),
build_second: impl FnOnce(&mut UiDocument, UiNodeId),
) -> SplitPaneNodes {
let name = name.into();
let mut layout = options.layout;
{
let layout = layout.as_taffy_style_mut();
layout.display = Display::Flex;
layout.flex_direction = axis.flex_direction();
}
let root = document.add_child(
parent,
UiNode::container(
name.clone(),
UiNodeStyle {
layout: layout.style,
clip: ClipBehavior::Clip,
..Default::default()
},
)
.with_visual(options.root_visual)
.with_accessibility(
AccessibilityMeta::new(AccessibilityRole::Application)
.label(format!("{name} split pane"))
.hint("Contains two resizable panes"),
),
);
let first = document.add_child(
root,
UiNode::container(
format!("{name}.first"),
split_pane_child_style(axis, state.fraction, state.min_first),
)
.with_visual(options.pane_visual),
);
build_first(document, first);
let handle = document.add_child(
root,
UiNode::container(
format!("{name}.handle"),
split_pane_handle_style(axis, options.handle_thickness),
)
.with_input(InputBehavior::BUTTON)
.with_visual(options.handle_visual)
.with_accessibility(
AccessibilityMeta::new(AccessibilityRole::Slider)
.label(format!("{name} splitter"))
.value(format!("{:.0}%", state.fraction.clamp(0.0, 1.0) * 100.0))
.hint(match axis {
SplitAxis::Horizontal => "Resize the left and right panes",
SplitAxis::Vertical => "Resize the upper and lower panes",
})
.focusable(),
),
);
let second = document.add_child(
root,
UiNode::container(
format!("{name}.second"),
split_pane_child_style(axis, 1.0 - state.fraction, state.min_second),
)
.with_visual(options.pane_visual),
);
build_second(document, second);
SplitPaneNodes {
root,
first,
handle,
second,
}
}
fn split_pane_child_style(axis: SplitAxis, grow: f32, min_extent: f32) -> UiNodeStyle {
let mut layout = Style {
display: Display::Flex,
flex_direction: FlexDirection::Column,
flex_basis: length(0.0),
flex_grow: grow.max(0.0),
flex_shrink: 1.0,
..Default::default()
};
if axis.is_horizontal() {
layout.size.height = Dimension::percent(1.0);
layout.min_size.width = length(min_extent.max(0.0));
} else {
layout.size.width = Dimension::percent(1.0);
layout.min_size.height = length(min_extent.max(0.0));
}
UiNodeStyle {
layout: LayoutStyle::from_taffy_style(layout).style,
clip: ClipBehavior::Clip,
..Default::default()
}
}
fn ordered_progress_range(min: f32, max: f32) -> (f32, f32) {
let min = if min.is_finite() { min } else { 0.0 };
let max = if max.is_finite() { max } else { 1.0 };
if (max - min).abs() <= f32::EPSILON {
(min, min + 1.0)
} else if min <= max {
(min, max)
} else {
(max, min)
}
}
fn format_progress_number(value: f32) -> String {
if value.fract().abs() <= 0.0001 {
format!("{value:.0}")
} else {
format!("{value:.1}")
}
}
fn split_pane_handle_style(axis: SplitAxis, thickness: f32) -> UiNodeStyle {
let thickness = thickness.max(0.0);
let size = if axis.is_horizontal() {
TaffySize {
width: length(thickness),
height: Dimension::percent(1.0),
}
} else {
TaffySize {
width: Dimension::percent(1.0),
height: length(thickness),
}
};
UiNodeStyle {
layout: LayoutStyle::from_taffy_style(Style {
flex_shrink: 0.0,
size,
..Default::default()
})
.style,
clip: ClipBehavior::Clip,
..Default::default()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum DockSide {
Top,
Bottom,
Left,
Right,
Center,
}
impl DockSide {
pub const fn is_horizontal_edge(self) -> bool {
matches!(self, Self::Top | Self::Bottom)
}
pub const fn is_vertical_edge(self) -> bool {
matches!(self, Self::Left | Self::Right)
}
}
fn dock_side_name(side: DockSide) -> &'static str {
match side {
DockSide::Top => "Top",
DockSide::Bottom => "Bottom",
DockSide::Left => "Left",
DockSide::Right => "Right",
DockSide::Center => "Center",
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct DockPanelDescriptor {
pub id: String,
pub title: String,
pub side: DockSide,
pub size: f32,
pub min_size: f32,
pub visible: bool,
pub resizable: bool,
pub title_image: Option<ImageContent>,
pub shader: Option<ShaderEffect>,
pub accessibility_label: Option<String>,
pub accessibility_hint: Option<String>,
}
impl DockPanelDescriptor {
pub fn new(id: impl Into<String>, title: impl Into<String>, side: DockSide, size: f32) -> Self {
Self {
id: id.into(),
title: title.into(),
side,
size: size.max(0.0),
min_size: 48.0,
visible: true,
resizable: false,
title_image: None,
shader: None,
accessibility_label: None,
accessibility_hint: None,
}
}
pub fn center(id: impl Into<String>, title: impl Into<String>) -> Self {
Self {
id: id.into(),
title: title.into(),
side: DockSide::Center,
size: 1.0,
min_size: 0.0,
visible: true,
resizable: false,
title_image: None,
shader: None,
accessibility_label: None,
accessibility_hint: None,
}
}
pub fn with_min_size(mut self, min_size: f32) -> Self {
self.min_size = min_size.max(0.0);
self
}
pub fn resizable(mut self, resizable: bool) -> Self {
self.resizable = resizable;
self
}
pub fn visible(mut self, visible: bool) -> Self {
self.visible = visible;
self
}
pub fn title_image(mut self, image: ImageContent) -> Self {
self.title_image = Some(image);
self
}
pub fn shader(mut self, shader: ShaderEffect) -> Self {
self.shader = Some(shader);
self
}
pub fn accessibility_label(mut self, label: impl Into<String>) -> Self {
self.accessibility_label = Some(label.into());
self
}
pub fn accessibility_hint(mut self, hint: impl Into<String>) -> Self {
self.accessibility_hint = Some(hint.into());
self
}
pub fn accessibility(&self) -> AccessibilityMeta {
let label = self
.accessibility_label
.clone()
.or_else(|| (!self.title.is_empty()).then(|| self.title.clone()))
.unwrap_or_else(|| self.id.clone());
let hint = self.accessibility_hint.clone().unwrap_or_else(|| {
if self.resizable {
format!("{} dock panel, resizable", dock_side_name(self.side))
} else {
format!("{} dock panel", dock_side_name(self.side))
}
});
AccessibilityMeta::new(AccessibilityRole::TabPanel)
.label(label)
.hint(hint)
}
}
#[derive(Debug, Clone)]
pub struct DockWorkspaceOptions {
pub layout: LayoutStyle,
pub panel_visual: UiVisual,
pub center_visual: UiVisual,
pub resize_handle_visual: UiVisual,
pub title_style: TextStyle,
pub show_titles: bool,
pub handle_thickness: f32,
pub title_image_size: UiSize,
}
impl Default for DockWorkspaceOptions {
fn default() -> Self {
Self {
layout: LayoutStyle::from_taffy_style(Style {
display: Display::Flex,
flex_direction: FlexDirection::Column,
size: TaffySize {
width: Dimension::percent(1.0),
height: Dimension::percent(1.0),
},
..Default::default()
}),
panel_visual: UiVisual::panel(
DEFAULT_SURFACE_BG,
Some(StrokeStyle::new(DEFAULT_SURFACE_STROKE, 1.0)),
0.0,
),
center_visual: UiVisual::TRANSPARENT,
resize_handle_visual: UiVisual::panel(DEFAULT_ACCENT, None, 0.0),
title_style: TextStyle {
font_size: 13.0,
line_height: 18.0,
..Default::default()
},
show_titles: true,
handle_thickness: 5.0,
title_image_size: UiSize::new(16.0, 16.0),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DockPanelNode {
pub id: String,
pub side: DockSide,
pub root: UiNodeId,
pub title: Option<UiNodeId>,
pub content: UiNodeId,
pub resize_handle: Option<UiNodeId>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DockWorkspaceNodes {
pub root: UiNodeId,
pub body: UiNodeId,
pub center: Option<UiNodeId>,
pub panels: Vec<DockPanelNode>,
}
pub fn dock_workspace(
document: &mut UiDocument,
parent: UiNodeId,
name: impl Into<String>,
panels: &[DockPanelDescriptor],
options: DockWorkspaceOptions,
mut build_panel: impl FnMut(&mut UiDocument, UiNodeId, &DockPanelDescriptor),
) -> DockWorkspaceNodes {
let name = name.into();
let root = document.add_child(
parent,
UiNode::container(
name.clone(),
UiNodeStyle {
layout: options.layout.style.clone(),
clip: ClipBehavior::Clip,
..Default::default()
},
)
.with_accessibility(
AccessibilityMeta::new(AccessibilityRole::Application)
.label(format!("{name} docking workspace"))
.hint("Contains docked panels and a center workspace"),
),
);
let mut panel_nodes = Vec::new();
for panel in panels_for_side(panels, DockSide::Top) {
panel_nodes.push(add_dock_panel(document, root, &name, panel, &options));
if let Some(node) = panel_nodes.last() {
build_panel(document, node.content, panel);
}
}
let body = document.add_child(
root,
UiNode::container(
format!("{name}.body"),
UiNodeStyle {
layout: LayoutStyle::from_taffy_style(Style {
display: Display::Flex,
flex_direction: FlexDirection::Row,
flex_grow: 1.0,
flex_shrink: 1.0,
flex_basis: length(0.0),
size: TaffySize {
width: Dimension::percent(1.0),
height: Dimension::percent(1.0),
},
..Default::default()
})
.style,
clip: ClipBehavior::Clip,
..Default::default()
},
),
);
for side in [DockSide::Left, DockSide::Center, DockSide::Right] {
for panel in panels_for_side(panels, side) {
panel_nodes.push(add_dock_panel(document, body, &name, panel, &options));
if let Some(node) = panel_nodes.last() {
build_panel(document, node.content, panel);
}
}
}
let center = panel_nodes
.iter()
.find(|panel| panel.side == DockSide::Center)
.map(|panel| panel.root)
.or_else(|| {
let fallback = DockPanelDescriptor::center("center", "");
let node = add_dock_panel(document, body, &name, &fallback, &options);
let root = node.root;
panel_nodes.push(node);
Some(root)
});
for panel in panels_for_side(panels, DockSide::Bottom) {
panel_nodes.push(add_dock_panel(document, root, &name, panel, &options));
if let Some(node) = panel_nodes.last() {
build_panel(document, node.content, panel);
}
}
DockWorkspaceNodes {
root,
body,
center,
panels: panel_nodes,
}
}
fn panels_for_side(
panels: &[DockPanelDescriptor],
side: DockSide,
) -> impl Iterator<Item = &DockPanelDescriptor> {
panels
.iter()
.filter(move |panel| panel.visible && panel.side == side)
}
fn add_dock_panel(
document: &mut UiDocument,
parent: UiNodeId,
workspace_name: &str,
panel: &DockPanelDescriptor,
options: &DockWorkspaceOptions,
) -> DockPanelNode {
let mut root_node = UiNode::container(
format!("{workspace_name}.panel.{}", panel.id),
dock_panel_style(panel),
)
.with_visual(if panel.side == DockSide::Center {
options.center_visual
} else {
options.panel_visual
})
.with_accessibility(panel.accessibility());
if let Some(shader) = &panel.shader {
root_node = root_node.with_shader(shader.clone());
}
let root = document.add_child(parent, root_node);
let title = if options.show_titles && (!panel.title.is_empty() || panel.title_image.is_some()) {
let title_bar = document.add_child(
root,
UiNode::container(
format!("{workspace_name}.panel.{}.title", panel.id),
UiNodeStyle {
layout: LayoutStyle::from_taffy_style(Style {
display: Display::Flex,
flex_direction: FlexDirection::Row,
align_items: Some(AlignItems::Center),
size: TaffySize {
width: Dimension::percent(1.0),
height: length(24.0),
},
padding: Rect::length(4.0),
flex_shrink: 0.0,
..Default::default()
})
.style,
..Default::default()
},
),
);
if let Some(image) = &panel.title_image {
document.add_child(
title_bar,
UiNode::image(
format!("{workspace_name}.panel.{}.title.image", panel.id),
image.clone(),
LayoutStyle::from_taffy_style(Style {
size: TaffySize {
width: length(options.title_image_size.width),
height: length(options.title_image_size.height),
},
margin: Rect {
right: LengthPercentageAuto::length(6.0),
..Rect::length(0.0)
},
flex_shrink: 0.0,
..Default::default()
}),
)
.with_accessibility(
AccessibilityMeta::new(AccessibilityRole::Image).label(panel.title.clone()),
),
);
}
if !panel.title.is_empty() {
document.add_child(
title_bar,
UiNode::text(
format!("{workspace_name}.panel.{}.title.label", panel.id),
panel.title.clone(),
options.title_style.clone(),
LayoutStyle::from_taffy_style(Style {
size: TaffySize {
width: Dimension::percent(1.0),
height: Dimension::auto(),
},
..Default::default()
}),
)
.with_accessibility(
AccessibilityMeta::new(AccessibilityRole::Label).label(panel.title.clone()),
),
);
}
Some(title_bar)
} else {
None
};
let content = document.add_child(
root,
UiNode::container(
format!("{workspace_name}.panel.{}.content", panel.id),
UiNodeStyle {
layout: LayoutStyle::from_taffy_style(Style {
display: Display::Flex,
flex_direction: FlexDirection::Column,
flex_grow: 1.0,
flex_shrink: 1.0,
flex_basis: length(0.0),
size: TaffySize {
width: Dimension::percent(1.0),
height: Dimension::percent(1.0),
},
..Default::default()
})
.style,
clip: ClipBehavior::Clip,
..Default::default()
},
),
);
let resize_handle = panel
.resizable
.then(|| add_dock_resize_handle(document, root, workspace_name, panel, options));
DockPanelNode {
id: panel.id.clone(),
side: panel.side,
root,
title,
content,
resize_handle,
}
}
fn dock_panel_style(panel: &DockPanelDescriptor) -> UiNodeStyle {
let mut layout = Style {
display: Display::Flex,
flex_direction: FlexDirection::Column,
flex_shrink: 0.0,
..Default::default()
};
match panel.side {
DockSide::Top | DockSide::Bottom => {
layout.size = TaffySize {
width: Dimension::percent(1.0),
height: length(panel.size),
};
layout.min_size.height = length(panel.min_size);
}
DockSide::Left | DockSide::Right => {
layout.size = TaffySize {
width: length(panel.size),
height: Dimension::percent(1.0),
};
layout.min_size.width = length(panel.min_size);
}
DockSide::Center => {
layout.flex_grow = panel.size.max(0.0);
layout.flex_shrink = 1.0;
layout.flex_basis = length(0.0);
layout.size = TaffySize {
width: Dimension::percent(1.0),
height: Dimension::percent(1.0),
};
layout.min_size.width = length(panel.min_size);
}
}
UiNodeStyle {
layout: LayoutStyle::from_taffy_style(layout).style,
clip: ClipBehavior::Clip,
..Default::default()
}
}
fn add_dock_resize_handle(
document: &mut UiDocument,
parent: UiNodeId,
workspace_name: &str,
panel: &DockPanelDescriptor,
options: &DockWorkspaceOptions,
) -> UiNodeId {
let mut inset = Rect::length(0.0);
let size = match panel.side {
DockSide::Top => {
inset.top = LengthPercentageAuto::auto();
TaffySize {
width: Dimension::percent(1.0),
height: length(options.handle_thickness),
}
}
DockSide::Bottom => {
inset.bottom = LengthPercentageAuto::auto();
TaffySize {
width: Dimension::percent(1.0),
height: length(options.handle_thickness),
}
}
DockSide::Left => {
inset.left = LengthPercentageAuto::auto();
TaffySize {
width: length(options.handle_thickness),
height: Dimension::percent(1.0),
}
}
DockSide::Right => {
inset.right = LengthPercentageAuto::auto();
TaffySize {
width: length(options.handle_thickness),
height: Dimension::percent(1.0),
}
}
DockSide::Center => TaffySize {
width: length(0.0),
height: length(0.0),
},
};
document.add_child(
parent,
UiNode::container(
format!("{workspace_name}.panel.{}.resize", panel.id),
UiNodeStyle {
layout: LayoutStyle::from_taffy_style(Style {
position: Position::Absolute,
inset,
size,
..Default::default()
})
.style,
z_index: 1,
..Default::default()
},
)
.with_input(InputBehavior::BUTTON)
.with_visual(options.resize_handle_visual)
.with_accessibility(
AccessibilityMeta::new(AccessibilityRole::Slider)
.label(format!("Resize {} panel", panel.title))
.hint(format!(
"Drag to resize the {} dock panel",
dock_side_name(panel.side).to_lowercase()
))
.focusable(),
),
)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DialogDismissReason {
EscapeKey,
OutsidePointer,
CloseButton,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct DialogDismissal {
pub escape_key: bool,
pub outside_pointer: bool,
pub close_button: bool,
}
impl DialogDismissal {
pub const NONE: Self = Self {
escape_key: false,
outside_pointer: false,
close_button: false,
};
pub const STANDARD: Self = Self {
escape_key: true,
outside_pointer: true,
close_button: true,
};
pub const MODAL: Self = Self {
escape_key: true,
outside_pointer: false,
close_button: true,
};
pub const fn allows(self, reason: DialogDismissReason) -> bool {
match reason {
DialogDismissReason::EscapeKey => self.escape_key,
DialogDismissReason::OutsidePointer => self.outside_pointer,
DialogDismissReason::CloseButton => self.close_button,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DialogDescriptor {
pub id: String,
pub title: String,
pub modal: bool,
pub trap_focus: bool,
pub dismissal: DialogDismissal,
pub accessibility_hint: Option<String>,
}
impl DialogDescriptor {
pub fn new(id: impl Into<String>, title: impl Into<String>) -> Self {
Self {
id: id.into(),
title: title.into(),
modal: false,
trap_focus: false,
dismissal: DialogDismissal::STANDARD,
accessibility_hint: None,
}
}
pub fn modal(mut self, modal: bool) -> Self {
self.modal = modal;
self.trap_focus = modal;
if modal {
self.dismissal = DialogDismissal::MODAL;
}
self
}
pub fn trap_focus(mut self, trap_focus: bool) -> Self {
self.trap_focus = trap_focus;
self
}
pub fn dismissal(mut self, dismissal: DialogDismissal) -> Self {
self.dismissal = dismissal;
self
}
pub fn accessibility_hint(mut self, hint: impl Into<String>) -> Self {
self.accessibility_hint = Some(hint.into());
self
}
pub fn accessibility(&self) -> AccessibilityMeta {
let label = if self.title.is_empty() {
self.id.clone()
} else {
self.title.clone()
};
let hint = self.accessibility_hint.clone().unwrap_or_else(|| {
let modality = if self.modal { "Modal dialog" } else { "Dialog" };
if self.dismissal.escape_key {
format!("{modality}; press Escape to dismiss")
} else {
format!("{modality}; dismissal is controlled by the application")
}
});
let meta = AccessibilityMeta::new(AccessibilityRole::Dialog)
.label(label)
.hint(hint)
.focusable();
if self.modal {
meta.modal()
} else {
meta
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct DialogStack {
pub dialogs: Vec<DialogDescriptor>,
}
impl DialogStack {
pub fn open(&mut self, dialog: DialogDescriptor) {
self.close(&dialog.id);
self.dialogs.push(dialog);
}
pub fn close(&mut self, id: &str) -> Option<DialogDescriptor> {
let index = self.dialogs.iter().position(|dialog| dialog.id == id)?;
Some(self.dialogs.remove(index))
}
pub fn dismiss_top(&mut self, reason: DialogDismissReason) -> Option<DialogDescriptor> {
let top = self.dialogs.last()?;
if !top.dismissal.allows(reason) {
return None;
}
self.dialogs.pop()
}
pub fn top(&self) -> Option<&DialogDescriptor> {
self.dialogs.last()
}
pub fn is_open(&self, id: &str) -> bool {
self.dialogs.iter().any(|dialog| dialog.id == id)
}
pub fn traps_focus(&self) -> bool {
self.dialogs
.iter()
.any(|dialog| dialog.modal || dialog.trap_focus)
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum PopoverAnchor {
Node(UiNodeId),
Rect(UiRect),
Point(UiPoint),
}
impl PopoverAnchor {
pub fn resolve(self, document: &UiDocument) -> Option<UiRect> {
match self {
Self::Node(id) => document.nodes().get(id.0).map(|node| node.layout.rect),
Self::Rect(rect) => Some(rect),
Self::Point(point) => Some(UiRect::new(point.x, point.y, 0.0, 0.0)),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PopoverPlacement {
Top,
Bottom,
Left,
Right,
}
impl PopoverPlacement {
pub const fn as_str(self) -> &'static str {
match self {
Self::Top => "top",
Self::Bottom => "bottom",
Self::Left => "left",
Self::Right => "right",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PopoverDismissReason {
EscapeKey,
OutsidePointer,
Toggle,
}
#[derive(Debug, Clone, PartialEq)]
pub struct PopoverDescriptor {
pub id: String,
pub anchor: PopoverAnchor,
pub placement: PopoverPlacement,
pub modal: bool,
pub close_on_outside: bool,
pub close_on_escape: bool,
pub accessibility_label: Option<String>,
pub accessibility_hint: Option<String>,
}
impl PopoverDescriptor {
pub fn new(id: impl Into<String>, anchor: PopoverAnchor, placement: PopoverPlacement) -> Self {
Self {
id: id.into(),
anchor,
placement,
modal: false,
close_on_outside: true,
close_on_escape: true,
accessibility_label: None,
accessibility_hint: None,
}
}
pub fn modal(mut self, modal: bool) -> Self {
self.modal = modal;
self
}
pub fn close_on_outside(mut self, close_on_outside: bool) -> Self {
self.close_on_outside = close_on_outside;
self
}
pub fn close_on_escape(mut self, close_on_escape: bool) -> Self {
self.close_on_escape = close_on_escape;
self
}
pub fn accessibility_label(mut self, label: impl Into<String>) -> Self {
self.accessibility_label = Some(label.into());
self
}
pub fn accessibility_hint(mut self, hint: impl Into<String>) -> Self {
self.accessibility_hint = Some(hint.into());
self
}
pub fn accessibility(&self) -> AccessibilityMeta {
let label = self
.accessibility_label
.clone()
.unwrap_or_else(|| self.id.clone());
let hint = self.accessibility_hint.clone().unwrap_or_else(|| {
let dismiss_hint = if self.close_on_escape {
"; press Escape to dismiss"
} else {
"; dismissal is controlled by the application"
};
format!(
"Popover anchored to the {}{}",
self.placement.as_str(),
dismiss_hint
)
});
let meta = AccessibilityMeta::new(if self.modal {
AccessibilityRole::Dialog
} else {
AccessibilityRole::Menu
})
.label(label)
.hint(hint)
.expanded(true)
.focusable();
if self.modal {
meta.modal()
} else {
meta
}
}
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct PopoverState {
pub current: Option<PopoverDescriptor>,
}
impl PopoverState {
pub fn open(&mut self, popover: PopoverDescriptor) {
self.current = Some(popover);
}
pub fn close(&mut self) -> Option<PopoverDescriptor> {
self.current.take()
}
pub fn toggle(&mut self, popover: PopoverDescriptor) {
if self.is_open(&popover.id) {
self.close();
} else {
self.open(popover);
}
}
pub fn is_open(&self, id: &str) -> bool {
self.current
.as_ref()
.is_some_and(|popover| popover.id == id)
}
pub fn dismiss_for_outside_pointer(&mut self) -> Option<PopoverDescriptor> {
self.dismiss(PopoverDismissReason::OutsidePointer)
}
pub fn dismiss(&mut self, reason: PopoverDismissReason) -> Option<PopoverDescriptor> {
let should_close = self.current.as_ref().is_some_and(|popover| match reason {
PopoverDismissReason::EscapeKey => popover.close_on_escape,
PopoverDismissReason::OutsidePointer => popover.close_on_outside,
PopoverDismissReason::Toggle => true,
});
should_close.then(|| self.close()).flatten()
}
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct OverlayFrameState {
pub dialogs: DialogStack,
pub popover: PopoverState,
pub focus_trap: Option<FocusTrap>,
}
impl OverlayFrameState {
pub fn new() -> Self {
Self::default()
}
pub fn has_overlay(&self) -> bool {
!self.dialogs.dialogs.is_empty() || self.popover.current.is_some()
}
pub fn traps_focus(&self) -> bool {
self.focus_trap.is_some() || self.dialogs.traps_focus() || self.popover_modal()
}
pub fn popover_modal(&self) -> bool {
self.popover
.current
.as_ref()
.is_some_and(|popover| popover.modal)
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum OverlayFrameEvent {
OpenDialog {
dialog: DialogDescriptor,
focus_trap: Option<FocusTrap>,
},
CloseDialog {
id: String,
},
DismissDialog {
reason: DialogDismissReason,
},
OpenPopover {
popover: PopoverDescriptor,
focus_trap: Option<FocusTrap>,
},
TogglePopover {
popover: PopoverDescriptor,
},
DismissPopover {
reason: PopoverDismissReason,
},
SetFocusTrap(FocusTrap),
ClearFocusTrap {
restore: FocusRestoreTarget,
},
}
impl OverlayFrameEvent {
pub fn open_dialog(dialog: DialogDescriptor) -> Self {
Self::OpenDialog {
dialog,
focus_trap: None,
}
}
pub fn open_dialog_with_focus_trap(dialog: DialogDescriptor, focus_trap: FocusTrap) -> Self {
Self::OpenDialog {
dialog,
focus_trap: Some(focus_trap),
}
}
pub fn close_dialog(id: impl Into<String>) -> Self {
Self::CloseDialog { id: id.into() }
}
pub const fn dismiss_dialog(reason: DialogDismissReason) -> Self {
Self::DismissDialog { reason }
}
pub fn open_popover(popover: PopoverDescriptor) -> Self {
Self::OpenPopover {
popover,
focus_trap: None,
}
}
pub fn open_popover_with_focus_trap(popover: PopoverDescriptor, focus_trap: FocusTrap) -> Self {
Self::OpenPopover {
popover,
focus_trap: Some(focus_trap),
}
}
pub fn toggle_popover(popover: PopoverDescriptor) -> Self {
Self::TogglePopover { popover }
}
pub const fn dismiss_popover(reason: PopoverDismissReason) -> Self {
Self::DismissPopover { reason }
}
pub const fn set_focus_trap(focus_trap: FocusTrap) -> Self {
Self::SetFocusTrap(focus_trap)
}
pub const fn clear_focus_trap(restore: FocusRestoreTarget) -> Self {
Self::ClearFocusTrap { restore }
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct OverlayFrameRequest {
pub state: OverlayFrameState,
pub events: Vec<OverlayFrameEvent>,
pub accessibility_capabilities: AccessibilityCapabilities,
}
impl OverlayFrameRequest {
pub fn new(state: OverlayFrameState) -> Self {
Self {
state,
events: Vec::new(),
accessibility_capabilities: AccessibilityCapabilities::NONE,
}
}
pub fn event(mut self, event: OverlayFrameEvent) -> Self {
self.events.push(event);
self
}
pub fn events(mut self, events: impl IntoIterator<Item = OverlayFrameEvent>) -> Self {
self.events.extend(events);
self
}
pub const fn accessibility_capabilities(
mut self,
capabilities: AccessibilityCapabilities,
) -> Self {
self.accessibility_capabilities = capabilities;
self
}
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct OverlayFrameOutput {
pub state: OverlayFrameState,
pub dismissed_dialogs: Vec<DialogDescriptor>,
pub dismissed_popover: Option<PopoverDescriptor>,
pub accessibility_requests: Vec<AccessibilityAdapterRequest>,
pub changed: bool,
}
pub fn process_overlay_frame(request: OverlayFrameRequest) -> OverlayFrameOutput {
let OverlayFrameRequest {
mut state,
events,
accessibility_capabilities,
} = request;
let mut output = OverlayFrameOutput {
state: OverlayFrameState::new(),
dismissed_dialogs: Vec::new(),
dismissed_popover: None,
accessibility_requests: Vec::new(),
changed: false,
};
for event in events {
apply_overlay_event(&mut state, &mut output, event, accessibility_capabilities);
}
output.state = state;
output
}
fn apply_overlay_event(
state: &mut OverlayFrameState,
output: &mut OverlayFrameOutput,
event: OverlayFrameEvent,
capabilities: AccessibilityCapabilities,
) {
match event {
OverlayFrameEvent::OpenDialog { dialog, focus_trap } => {
state.dialogs.open(dialog);
output.changed = true;
if let Some(focus_trap) = focus_trap {
set_overlay_focus_trap(state, output, focus_trap, capabilities);
}
}
OverlayFrameEvent::CloseDialog { id } => {
if let Some(dialog) = state.dialogs.close(&id) {
output.dismissed_dialogs.push(dialog);
output.changed = true;
}
}
OverlayFrameEvent::DismissDialog { reason } => {
if let Some(dialog) = state.dialogs.dismiss_top(reason) {
output.dismissed_dialogs.push(dialog);
output.changed = true;
}
}
OverlayFrameEvent::OpenPopover {
popover,
focus_trap,
} => {
state.popover.open(popover);
output.changed = true;
if let Some(focus_trap) = focus_trap {
set_overlay_focus_trap(state, output, focus_trap, capabilities);
}
}
OverlayFrameEvent::TogglePopover { popover } => {
let was_open = state.popover.is_open(&popover.id);
let dismissed = was_open.then(|| state.popover.current.clone()).flatten();
state.popover.toggle(popover);
output.dismissed_popover = dismissed;
output.changed = true;
}
OverlayFrameEvent::DismissPopover { reason } => {
if let Some(popover) = state.popover.dismiss(reason) {
output.dismissed_popover = Some(popover);
output.changed = true;
}
}
OverlayFrameEvent::SetFocusTrap(focus_trap) => {
set_overlay_focus_trap(state, output, focus_trap, capabilities);
}
OverlayFrameEvent::ClearFocusTrap { restore } => {
clear_overlay_focus_trap(state, output, restore, capabilities);
}
}
}
fn set_overlay_focus_trap(
state: &mut OverlayFrameState,
output: &mut OverlayFrameOutput,
focus_trap: FocusTrap,
capabilities: AccessibilityCapabilities,
) {
if state.focus_trap == Some(focus_trap) {
return;
}
state.focus_trap = Some(focus_trap);
output.changed = true;
push_accessibility_request(
&mut output.accessibility_requests,
capabilities,
AccessibilityAdapterRequest::SetFocusTrap(focus_trap),
);
}
fn clear_overlay_focus_trap(
state: &mut OverlayFrameState,
output: &mut OverlayFrameOutput,
restore: FocusRestoreTarget,
capabilities: AccessibilityCapabilities,
) {
if state.focus_trap.take().is_none() {
return;
}
output.changed = true;
push_accessibility_request(
&mut output.accessibility_requests,
capabilities,
AccessibilityAdapterRequest::ClearFocusTrap { restore },
);
}
fn push_accessibility_request(
requests: &mut Vec<AccessibilityAdapterRequest>,
capabilities: AccessibilityCapabilities,
request: AccessibilityAdapterRequest,
) {
if capabilities.supports(request.kind()) {
requests.push(request);
}
}
pub fn resolve_popover_rect(
anchor: UiRect,
popover_size: UiSize,
viewport: UiRect,
placement: PopoverPlacement,
offset: f32,
) -> UiRect {
let offset = if offset.is_finite() {
offset.max(0.0)
} else {
0.0
};
let popover_size = UiSize::new(
if popover_size.width.is_finite() {
popover_size.width.max(0.0)
} else {
0.0
},
if popover_size.height.is_finite() {
popover_size.height.max(0.0)
} else {
0.0
},
);
let mut rect = match placement {
PopoverPlacement::Top => UiRect::new(
anchor.x,
anchor.y - popover_size.height - offset,
popover_size.width,
popover_size.height,
),
PopoverPlacement::Bottom => UiRect::new(
anchor.x,
anchor.bottom() + offset,
popover_size.width,
popover_size.height,
),
PopoverPlacement::Left => UiRect::new(
anchor.x - popover_size.width - offset,
anchor.y,
popover_size.width,
popover_size.height,
),
PopoverPlacement::Right => UiRect::new(
anchor.right() + offset,
anchor.y,
popover_size.width,
popover_size.height,
),
};
rect.x = clamp_to_viewport(rect.x, rect.width, viewport.x, viewport.right());
rect.y = clamp_to_viewport(rect.y, rect.height, viewport.y, viewport.bottom());
rect
}
fn clamp_to_viewport(value: f32, extent: f32, min: f32, max: f32) -> f32 {
let min = if min.is_finite() { min } else { 0.0 };
let max = if max.is_finite() { max.max(min) } else { min };
let extent = if extent.is_finite() {
extent.max(0.0)
} else {
0.0
};
let value = if value.is_finite() { value } else { min };
let upper = (max - extent).max(min);
value.clamp(min, upper)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct ToastId(pub u64);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ToastSeverity {
Info,
Success,
Warning,
Error,
}
impl ToastSeverity {
pub const fn as_str(self) -> &'static str {
match self {
Self::Info => "Info",
Self::Success => "Success",
Self::Warning => "Warning",
Self::Error => "Error",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ToastAction {
pub id: String,
pub label: String,
}
impl ToastAction {
pub fn new(id: impl Into<String>, label: impl Into<String>) -> Self {
Self {
id: id.into(),
label: label.into(),
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct Toast {
pub id: ToastId,
pub severity: ToastSeverity,
pub title: String,
pub body: Option<String>,
pub timeout_seconds: Option<f32>,
pub age_seconds: f32,
pub actions: Vec<ToastAction>,
pub icon: Option<ImageContent>,
pub shader: Option<ShaderEffect>,
pub accessibility_hint: Option<String>,
}
impl Toast {
pub fn new(
id: ToastId,
severity: ToastSeverity,
title: impl Into<String>,
body: Option<String>,
timeout_seconds: Option<f32>,
) -> Self {
Self {
id,
severity,
title: title.into(),
body,
timeout_seconds: timeout_seconds
.filter(|timeout| timeout.is_finite())
.map(|timeout| timeout.max(0.0)),
age_seconds: 0.0,
actions: Vec::new(),
icon: None,
shader: None,
accessibility_hint: None,
}
}
pub fn with_action(mut self, action: ToastAction) -> Self {
self.actions.push(action);
self
}
pub fn with_icon(mut self, icon: ImageContent) -> Self {
self.icon = Some(icon);
self
}
pub fn with_shader(mut self, shader: ShaderEffect) -> Self {
self.shader = Some(shader);
self
}
pub fn accessibility_hint(mut self, hint: impl Into<String>) -> Self {
self.accessibility_hint = Some(hint.into());
self
}
pub fn expired(&self) -> bool {
self.timeout_seconds
.is_some_and(|timeout| self.age_seconds >= timeout)
}
pub fn remaining_seconds(&self) -> Option<f32> {
self.timeout_seconds
.map(|timeout| (timeout - self.age_seconds).max(0.0))
}
pub fn accessibility(&self) -> AccessibilityMeta {
let mut label = format!("{}: {}", self.severity.as_str(), self.title);
if let Some(body) = &self.body {
if !body.is_empty() {
label.push_str(". ");
label.push_str(body);
}
}
let hint = self.accessibility_hint.clone().unwrap_or_else(|| {
if self.actions.is_empty() {
"Notification".to_string()
} else {
format!("Notification with {} action(s)", self.actions.len())
}
});
AccessibilityMeta::new(AccessibilityRole::ListItem)
.label(label)
.hint(hint)
.live_region(match self.severity {
ToastSeverity::Error | ToastSeverity::Warning => AccessibilityLiveRegion::Assertive,
ToastSeverity::Info | ToastSeverity::Success => AccessibilityLiveRegion::Polite,
})
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct ToastStack {
pub toasts: Vec<Toast>,
pub max_visible: usize,
next_id: u64,
}
impl ToastStack {
pub fn new(max_visible: usize) -> Self {
Self {
toasts: Vec::new(),
max_visible,
next_id: 1,
}
}
pub fn push(
&mut self,
severity: ToastSeverity,
title: impl Into<String>,
body: Option<String>,
timeout_seconds: Option<f32>,
) -> ToastId {
let id = ToastId(self.next_id);
self.next_id = self.next_id.saturating_add(1);
self.toasts
.push(Toast::new(id, severity, title, body, timeout_seconds));
id
}
pub fn push_toast(&mut self, mut toast: Toast) -> ToastId {
if toast.id.0 == 0 {
toast.id = ToastId(self.next_id);
self.next_id = self.next_id.saturating_add(1);
} else {
self.next_id = self.next_id.max(toast.id.0.saturating_add(1));
}
let id = toast.id;
self.toasts.push(toast);
id
}
pub fn dismiss(&mut self, id: ToastId) -> Option<Toast> {
let index = self.toasts.iter().position(|toast| toast.id == id)?;
Some(self.toasts.remove(index))
}
pub fn tick(&mut self, dt_seconds: f32) {
if !dt_seconds.is_finite() {
return;
}
let dt = dt_seconds.max(0.0);
for toast in &mut self.toasts {
toast.age_seconds += dt;
}
self.toasts.retain(|toast| !toast.expired());
}
pub fn visible(&self) -> &[Toast] {
let start = self.toasts.len().saturating_sub(self.max_visible);
&self.toasts[start..]
}
}
impl Default for ToastStack {
fn default() -> Self {
Self::new(4)
}
}
#[derive(Debug, Clone)]
pub struct ToastStackOptions {
pub layout: LayoutStyle,
pub info_visual: UiVisual,
pub success_visual: UiVisual,
pub warning_visual: UiVisual,
pub error_visual: UiVisual,
pub action_visual: UiVisual,
pub title_style: TextStyle,
pub body_style: TextStyle,
pub toast_animation: Option<AnimationMachine>,
}
impl Default for ToastStackOptions {
fn default() -> Self {
Self {
layout: LayoutStyle::from_taffy_style(Style {
display: Display::Flex,
flex_direction: FlexDirection::Column,
align_items: Some(AlignItems::FlexEnd),
size: TaffySize {
width: length(320.0),
height: Dimension::auto(),
},
..Default::default()
}),
info_visual: UiVisual::panel(
ColorRgba::new(31, 39, 50, 245),
Some(StrokeStyle::new(DEFAULT_SURFACE_STROKE, 1.0)),
4.0,
),
success_visual: UiVisual::panel(
ColorRgba::new(22, 58, 44, 245),
Some(StrokeStyle::new(ColorRgba::new(74, 160, 118, 255), 1.0)),
4.0,
),
warning_visual: UiVisual::panel(
ColorRgba::new(70, 54, 24, 245),
Some(StrokeStyle::new(ColorRgba::new(190, 148, 62, 255), 1.0)),
4.0,
),
error_visual: UiVisual::panel(
ColorRgba::new(73, 31, 35, 245),
Some(StrokeStyle::new(ColorRgba::new(205, 91, 102, 255), 1.0)),
4.0,
),
action_visual: UiVisual::panel(
ColorRgba::new(48, 58, 72, 255),
Some(StrokeStyle::new(DEFAULT_ACCENT, 1.0)),
3.0,
),
title_style: TextStyle {
font_size: 14.0,
line_height: 18.0,
..Default::default()
},
body_style: TextStyle {
font_size: 13.0,
line_height: 17.0,
color: ColorRgba::new(218, 226, 238, 255),
..Default::default()
},
toast_animation: Some(toast_enter_exit_animation(true)),
}
}
}
pub fn toast_stack(
document: &mut UiDocument,
parent: UiNodeId,
name: impl Into<String>,
stack: &ToastStack,
options: ToastStackOptions,
) -> UiNodeId {
let name = name.into();
let root = document.add_child(
parent,
UiNode::container(
name.clone(),
UiNodeStyle {
layout: options.layout.style.clone(),
z_index: 60,
..Default::default()
},
)
.with_accessibility(
AccessibilityMeta::new(AccessibilityRole::List)
.label("Notifications")
.hint("Most recent notifications"),
),
);
for toast in stack.visible() {
add_toast_node(document, root, &name, toast, &options);
}
root
}
fn add_toast_node(
document: &mut UiDocument,
parent: UiNodeId,
stack_name: &str,
toast: &Toast,
options: &ToastStackOptions,
) -> UiNodeId {
let visual = match toast.severity {
ToastSeverity::Info => options.info_visual,
ToastSeverity::Success => options.success_visual,
ToastSeverity::Warning => options.warning_visual,
ToastSeverity::Error => options.error_visual,
};
let mut root_node = UiNode::container(
format!("{stack_name}.toast.{}", toast.id.0),
UiNodeStyle {
layout: LayoutStyle::from_taffy_style(Style {
display: Display::Flex,
flex_direction: FlexDirection::Column,
size: TaffySize {
width: Dimension::percent(1.0),
height: Dimension::auto(),
},
padding: Rect::length(8.0),
margin: Rect {
bottom: LengthPercentageAuto::length(8.0),
..Rect::length(0.0)
},
..Default::default()
})
.style,
clip: ClipBehavior::Clip,
..Default::default()
},
)
.with_visual(visual)
.with_accessibility(toast.accessibility());
if let Some(shader) = &toast.shader {
root_node = root_node.with_shader(shader.clone());
}
if let Some(animation) = &options.toast_animation {
root_node = root_node.with_animation(animation.clone());
}
let root = document.add_child(parent, root_node);
let title_parent = if let Some(icon) = &toast.icon {
let header = document.add_child(
root,
UiNode::container(
format!("{stack_name}.toast.{}.header", toast.id.0),
UiNodeStyle {
layout: LayoutStyle::from_taffy_style(Style {
display: Display::Flex,
flex_direction: FlexDirection::Row,
align_items: Some(AlignItems::Center),
size: TaffySize {
width: Dimension::percent(1.0),
height: Dimension::auto(),
},
..Default::default()
})
.style,
..Default::default()
},
),
);
document.add_child(
header,
UiNode::image(
format!("{stack_name}.toast.{}.icon", toast.id.0),
icon.clone(),
LayoutStyle::from_taffy_style(Style {
size: TaffySize {
width: length(18.0),
height: length(18.0),
},
margin: Rect {
right: LengthPercentageAuto::length(6.0),
..Rect::length(0.0)
},
flex_shrink: 0.0,
..Default::default()
}),
)
.with_accessibility(
AccessibilityMeta::new(AccessibilityRole::Image)
.label(format!("{} notification icon", toast.severity.as_str())),
),
);
header
} else {
root
};
document.add_child(
title_parent,
UiNode::text(
format!("{stack_name}.toast.{}.title", toast.id.0),
toast.title.clone(),
options.title_style.clone(),
LayoutStyle::from_taffy_style(Style {
size: TaffySize {
width: Dimension::percent(1.0),
height: Dimension::auto(),
},
..Default::default()
}),
)
.with_accessibility(
AccessibilityMeta::new(AccessibilityRole::Label).label(toast.title.clone()),
),
);
if let Some(body) = &toast.body {
document.add_child(
root,
UiNode::text(
format!("{stack_name}.toast.{}.body", toast.id.0),
body.clone(),
options.body_style.clone(),
LayoutStyle::from_taffy_style(Style {
size: TaffySize {
width: Dimension::percent(1.0),
height: Dimension::auto(),
},
..Default::default()
}),
),
);
}
if !toast.actions.is_empty() {
let actions = document.add_child(
root,
UiNode::container(
format!("{stack_name}.toast.{}.actions", toast.id.0),
UiNodeStyle {
layout: LayoutStyle::from_taffy_style(Style {
display: Display::Flex,
flex_direction: FlexDirection::Row,
size: TaffySize {
width: Dimension::percent(1.0),
height: Dimension::auto(),
},
margin: Rect {
top: LengthPercentageAuto::length(6.0),
..Rect::length(0.0)
},
..Default::default()
})
.style,
..Default::default()
},
),
);
for action in &toast.actions {
let button = document.add_child(
actions,
UiNode::container(
format!("{stack_name}.toast.{}.action.{}", toast.id.0, action.id),
UiNodeStyle {
layout: LayoutStyle::from_taffy_style(Style {
display: Display::Flex,
size: TaffySize {
width: Dimension::auto(),
height: length(24.0),
},
padding: Rect::length(6.0),
margin: Rect {
right: LengthPercentageAuto::length(6.0),
..Rect::length(0.0)
},
..Default::default()
})
.style,
clip: ClipBehavior::Clip,
..Default::default()
},
)
.with_input(InputBehavior::BUTTON)
.with_visual(options.action_visual)
.with_accessibility(
AccessibilityMeta::new(AccessibilityRole::Button)
.label(action.label.clone())
.hint(format!("Activate {} notification action", toast.title))
.focusable(),
),
);
document.add_child(
button,
UiNode::text(
format!(
"{stack_name}.toast.{}.action.{}.label",
toast.id.0, action.id
),
action.label.clone(),
options.body_style.clone(),
LayoutStyle::from_taffy_style(Style {
size: TaffySize {
width: Dimension::auto(),
height: Dimension::auto(),
},
..Default::default()
}),
),
);
}
}
root
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct TimelineRange {
pub start: f64,
pub end: f64,
}
impl TimelineRange {
pub const fn new(start: f64, end: f64) -> Self {
Self { start, end }
}
pub fn ordered(self) -> Self {
if !self.start.is_finite() || !self.end.is_finite() {
return Self {
start: 0.0,
end: 0.0,
};
}
if self.start <= self.end {
self
} else {
Self {
start: self.end,
end: self.start,
}
}
}
pub fn duration(self) -> f64 {
let ordered = self.ordered();
(ordered.end - ordered.start).max(0.0)
}
pub fn contains(self, value: f64) -> bool {
let ordered = self.ordered();
value.is_finite() && value >= ordered.start && value <= ordered.end
}
pub fn normalized(self, value: f64) -> f32 {
let ordered = self.ordered();
let duration = ordered.duration();
if duration <= f64::EPSILON {
return 0.0;
}
if !value.is_finite() {
return 0.0;
}
((value - ordered.start) / duration).clamp(0.0, 1.0) as f32
}
pub fn value_to_x(self, value: f64, width: f32) -> f32 {
let width = if width.is_finite() {
width.max(0.0)
} else {
0.0
};
self.normalized(value) * width
}
pub fn x_to_value(self, x: f32, width: f32) -> f64 {
let ordered = self.ordered();
let width = if width.is_finite() && width > f32::EPSILON {
width
} else {
1.0
};
let x = if x.is_finite() {
x.clamp(0.0, width)
} else {
0.0
};
ordered.start + ordered.duration() * (x as f64 / width as f64)
}
pub fn pan(self, delta: f64) -> Self {
if !delta.is_finite() {
return self;
}
Self {
start: self.start + delta,
end: self.end + delta,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RulerTickKind {
Major,
Minor,
}
#[derive(Debug, Clone, PartialEq)]
pub struct RulerTick {
pub value: f64,
pub x: f32,
pub kind: RulerTickKind,
pub label: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct RulerSpec {
pub range: TimelineRange,
pub width: f32,
pub major_step: f64,
pub minor_step: f64,
pub label_every: usize,
}
impl RulerSpec {
pub fn ticks(self) -> Vec<RulerTick> {
let range = self.range.ordered();
if range.duration() <= f64::EPSILON
|| !self.width.is_finite()
|| self.width <= f32::EPSILON
|| !self.major_step.is_finite()
|| !self.minor_step.is_finite()
|| self.major_step <= f64::EPSILON
|| self.minor_step <= f64::EPSILON
{
return Vec::new();
}
let start_index = (range.start / self.minor_step).ceil() as i64;
let end_index = (range.end / self.minor_step).floor() as i64;
let label_every = self.label_every.max(1);
let mut major_count = 0_usize;
let mut ticks = Vec::new();
for index in start_index..=end_index {
if ticks.len() >= 10_000 {
break;
}
let value = index as f64 * self.minor_step;
let major_ratio = value / self.major_step;
let is_major = (major_ratio - major_ratio.round()).abs() < 0.000_001;
let label = if is_major {
let should_label = major_count % label_every == 0;
major_count += 1;
should_label.then(|| format_ruler_label(value))
} else {
None
};
ticks.push(RulerTick {
value,
x: self.range.value_to_x(value, self.width),
kind: if is_major {
RulerTickKind::Major
} else {
RulerTickKind::Minor
},
label,
});
}
ticks
}
}
fn format_ruler_label(value: f64) -> String {
if value.fract().abs() < 0.000_001 {
return format!("{}", value.round() as i64);
}
let mut label = format!("{value:.3}");
while label.contains('.') && label.ends_with('0') {
label.pop();
}
if label.ends_with('.') {
label.pop();
}
label
}
#[derive(Debug, Clone)]
pub struct TimelineRulerOptions {
pub layout: LayoutStyle,
pub height: f32,
pub background_visual: UiVisual,
pub major_stroke: StrokeStyle,
pub minor_stroke: StrokeStyle,
pub label_style: TextStyle,
pub shader: Option<ShaderEffect>,
pub accessibility_label: Option<String>,
pub accessibility_hint: Option<String>,
}
impl Default for TimelineRulerOptions {
fn default() -> Self {
Self {
layout: LayoutStyle::from_taffy_style(Style {
size: TaffySize {
width: Dimension::percent(1.0),
height: length(32.0),
},
..Default::default()
}),
height: 32.0,
background_visual: UiVisual::panel(
ColorRgba::new(20, 24, 30, 255),
Some(StrokeStyle::new(DEFAULT_SURFACE_STROKE, 1.0)),
0.0,
),
major_stroke: StrokeStyle::new(ColorRgba::new(180, 190, 205, 255), 1.0),
minor_stroke: StrokeStyle::new(ColorRgba::new(86, 98, 116, 255), 1.0),
label_style: TextStyle {
font_size: 11.0,
line_height: 14.0,
color: ColorRgba::new(218, 226, 238, 255),
..Default::default()
},
shader: None,
accessibility_label: None,
accessibility_hint: None,
}
}
}
pub fn timeline_ruler(
document: &mut UiDocument,
parent: UiNodeId,
name: impl Into<String>,
spec: RulerSpec,
options: TimelineRulerOptions,
) -> UiNodeId {
let name = name.into();
let mut layout = options.layout;
let height = if options.height.is_finite() {
options.height.max(0.0)
} else {
0.0
};
let scene_width = if spec.width.is_finite() {
spec.width.max(0.0)
} else {
0.0
};
layout.as_taffy_style_mut().size.height = length(height);
let range = spec.range.ordered();
let mut root_node = UiNode::container(
name.clone(),
UiNodeStyle {
layout: layout.style,
clip: ClipBehavior::Clip,
..Default::default()
},
)
.with_visual(options.background_visual)
.with_accessibility(
AccessibilityMeta::new(AccessibilityRole::Slider)
.label(
options
.accessibility_label
.clone()
.unwrap_or_else(|| format!("{name} timeline ruler")),
)
.value(format!(
"{} to {}",
format_ruler_label(range.start),
format_ruler_label(range.end)
))
.value_range(AccessibilityValueRange::new(range.start, range.end))
.hint(
options
.accessibility_hint
.clone()
.unwrap_or_else(|| "Shows timeline tick marks and labels".to_string()),
),
);
if let Some(shader) = &options.shader {
root_node = root_node.with_shader(shader.clone());
}
let root = document.add_child(parent, root_node);
let ticks = spec.ticks();
let primitives = ticks
.iter()
.map(|tick| {
let tick_height = match tick.kind {
RulerTickKind::Major => height,
RulerTickKind::Minor => height * 0.5,
};
ScenePrimitive::Line {
from: UiPoint::new(tick.x, height),
to: UiPoint::new(tick.x, height - tick_height),
stroke: match tick.kind {
RulerTickKind::Major => options.major_stroke,
RulerTickKind::Minor => options.minor_stroke,
},
}
})
.collect::<Vec<_>>();
document.add_child(
root,
UiNode::scene(
format!("{name}.ticks"),
primitives,
LayoutStyle::from_taffy_style(Style {
position: Position::Absolute,
size: TaffySize {
width: length(scene_width),
height: length(height),
},
..Default::default()
}),
),
);
for tick in ticks.iter().filter(|tick| tick.label.is_some()) {
let mut inset = Rect::length(0.0);
inset.left = LengthPercentageAuto::length(tick.x + 3.0);
inset.top = LengthPercentageAuto::length(2.0);
document.add_child(
root,
UiNode::text(
format!("{name}.label.{}", tick.value),
tick.label.clone().unwrap_or_default(),
options.label_style.clone(),
LayoutStyle::from_taffy_style(Style {
position: Position::Absolute,
inset,
size: TaffySize {
width: length(64.0),
height: Dimension::auto(),
},
..Default::default()
}),
),
);
}
root
}
#[cfg(test)]
mod tests {
use crate::{root_style, ApproxTextMeasurer, TextContent, UiContent};
use super::*;
#[test]
fn progress_indicator_values_accessibility_and_fill_geometry() {
let value = ProgressIndicatorValue::percent(42.0);
assert_eq!(value.normalized(), Some(0.42));
assert_eq!(
value.fill_rect(UiRect::new(10.0, 20.0, 200.0, 12.0)),
UiRect::new(10.0, 20.0, 84.0, 12.0)
);
let accessibility =
value.accessibility_meta("Recipe load", ProgressIndicatorKind::Progress, None);
assert_eq!(accessibility.role, AccessibilityRole::ProgressBar);
assert_eq!(accessibility.label.as_deref(), Some("Recipe load"));
assert_eq!(accessibility.value.as_deref(), Some("42%"));
assert_eq!(
accessibility.value_range,
Some(AccessibilityValueRange::new(0.0, 100.0))
);
let meter = ProgressIndicatorValue::new(18.5, 0.0, 100.0).accessibility_meta(
"CPU",
ProgressIndicatorKind::Meter,
Some("%"),
);
assert_eq!(meter.role, AccessibilityRole::Meter);
assert_eq!(meter.value.as_deref(), Some("18.5 %"));
let indeterminate = ProgressIndicatorValue::indeterminate(0.0, 1.0).accessibility_meta(
"Sync",
ProgressIndicatorKind::Progress,
None,
);
assert_eq!(indeterminate.value.as_deref(), Some("Indeterminate"));
assert_eq!(
indeterminate.hint.as_deref(),
Some("Value is not currently available")
);
}
#[test]
fn progress_indicator_builds_accessible_fill_node() {
let mut doc = UiDocument::new(root_style(240.0, 40.0));
let root = doc.root;
let nodes = progress_indicator(
&mut doc,
root,
"cpu",
ProgressIndicatorValue::new(18.5, 0.0, 100.0),
ProgressIndicatorOptions {
kind: ProgressIndicatorKind::Meter,
accessibility_label: Some("CPU load".to_string()),
accessibility_unit: Some("%".to_string()),
fill_shader: Some(ShaderEffect::new("meter.fill")),
..Default::default()
},
);
doc.compute_layout(UiSize::new(240.0, 40.0), &mut ApproxTextMeasurer)
.expect("layout");
let root_accessibility = doc.node(nodes.root).accessibility.as_ref().unwrap();
assert_eq!(root_accessibility.role, AccessibilityRole::Meter);
assert_eq!(root_accessibility.label.as_deref(), Some("CPU load"));
assert_eq!(root_accessibility.value.as_deref(), Some("18.5 %"));
assert_eq!(
doc.node(nodes.fill)
.shader
.as_ref()
.map(|shader| shader.key.as_str()),
Some("meter.fill")
);
assert!(doc.node(nodes.fill).layout.rect.width > 40.0);
assert!(doc.node(nodes.fill).layout.rect.width < 50.0);
}
#[test]
fn split_pane_state_clamps_resizes_and_builds_nodes() {
let mut state = SplitPaneState::new(0.25).with_min_sizes(120.0, 80.0);
let sizes = state.resolved_sizes(300.0, 10.0);
assert_eq!(sizes.handle, 10.0);
assert_eq!(sizes.first, 120.0);
assert_eq!(sizes.second, 170.0);
assert!(state.resize_by(80.0, 300.0, 10.0));
assert!(state.fraction > 0.6 && state.fraction < 0.7);
let mut doc = UiDocument::new(root_style(400.0, 200.0));
let root = doc.root;
let nodes = split_pane(
&mut doc,
root,
"workspace",
SplitAxis::Horizontal,
state,
SplitPaneOptions::default(),
|document, parent| {
document.add_child(
parent,
UiNode::text(
"left.label",
"Left",
TextStyle::default(),
LayoutStyle::from_taffy_style(Style {
size: TaffySize {
width: Dimension::auto(),
height: Dimension::auto(),
},
..Default::default()
}),
),
);
},
|document, parent| {
document.add_child(
parent,
UiNode::text(
"right.label",
"Right",
TextStyle::default(),
LayoutStyle::from_taffy_style(Style {
size: TaffySize {
width: Dimension::auto(),
height: Dimension::auto(),
},
..Default::default()
}),
),
);
},
);
doc.compute_layout(UiSize::new(400.0, 200.0), &mut ApproxTextMeasurer)
.expect("layout");
assert!(doc.node(nodes.handle).input.focusable);
assert!(doc.node(nodes.first).layout.rect.width >= state.min_first);
assert_eq!(doc.node(nodes.root).children.len(), 3);
let accessibility = doc.accessibility_tree();
let splitter = accessibility
.iter()
.find(|node| node.id == nodes.handle)
.expect("splitter accessibility");
assert_eq!(splitter.role, AccessibilityRole::Slider);
assert_eq!(splitter.label.as_deref(), Some("workspace splitter"));
assert_eq!(splitter.value.as_deref(), Some("69%"));
assert!(splitter.focusable);
}
#[test]
fn dock_workspace_builds_visible_panels_and_center() {
let panels = vec![
DockPanelDescriptor::new("top", "Toolbar", DockSide::Top, 40.0),
DockPanelDescriptor::new("left", "Browser", DockSide::Left, 120.0)
.resizable(true)
.title_image(ImageContent::new("icons.browser"))
.shader(ShaderEffect::new("dock.panel.blur").uniform("radius", 12.0))
.accessibility_hint("Project browser panel"),
DockPanelDescriptor::center("editor", "Editor"),
DockPanelDescriptor::new("right", "Inspector", DockSide::Right, 90.0).visible(false),
];
let mut doc = UiDocument::new(root_style(500.0, 320.0));
let root = doc.root;
let nodes = dock_workspace(
&mut doc,
root,
"dock",
&panels,
DockWorkspaceOptions::default(),
|document, parent, panel| {
document.add_child(
parent,
UiNode::text(
format!("{}.body", panel.id),
panel.id.clone(),
TextStyle::default(),
LayoutStyle::from_taffy_style(Style {
size: TaffySize {
width: Dimension::auto(),
height: Dimension::auto(),
},
..Default::default()
}),
),
);
},
);
doc.compute_layout(UiSize::new(500.0, 320.0), &mut ApproxTextMeasurer)
.expect("layout");
assert!(nodes.center.is_some());
assert_eq!(nodes.panels.len(), 3);
assert!(nodes
.panels
.iter()
.any(|panel| panel.id == "left" && panel.resize_handle.is_some()));
let left = nodes
.panels
.iter()
.find(|panel| panel.id == "left")
.expect("left panel");
assert_eq!(doc.node(left.root).layout.rect.width, 120.0);
assert_eq!(
doc.node(left.root)
.shader
.as_ref()
.map(|shader| shader.key.as_str()),
Some("dock.panel.blur")
);
let title = left.title.expect("left title");
assert!(doc.node(title).children.iter().any(|child| {
matches!(
doc.node(*child).content,
UiContent::Image(ref image) if image.key == "icons.browser"
)
}));
let accessibility = doc.accessibility_tree();
let panel_accessibility = accessibility
.iter()
.find(|node| node.id == left.root)
.expect("left panel accessibility");
assert_eq!(panel_accessibility.role, AccessibilityRole::TabPanel);
assert_eq!(panel_accessibility.label.as_deref(), Some("Browser"));
assert_eq!(
panel_accessibility.hint.as_deref(),
Some("Project browser panel")
);
let resize_handle = left.resize_handle.expect("left resize handle");
let resize_accessibility = accessibility
.iter()
.find(|node| node.id == resize_handle)
.expect("resize accessibility");
assert_eq!(resize_accessibility.role, AccessibilityRole::Slider);
assert!(resize_accessibility.focusable);
}
#[test]
fn dialog_and_popover_state_track_dismissal_rules() {
let mut dialogs = DialogStack::default();
let settings = DialogDescriptor::new("settings", "Settings")
.modal(true)
.accessibility_hint("Configure workspace settings");
let settings_accessibility = settings.accessibility();
assert_eq!(settings_accessibility.role, AccessibilityRole::Dialog);
assert_eq!(settings_accessibility.label.as_deref(), Some("Settings"));
assert_eq!(
settings_accessibility.hint.as_deref(),
Some("Configure workspace settings")
);
assert!(settings_accessibility.focusable);
dialogs.open(settings);
dialogs.open(DialogDescriptor::new("confirm", "Confirm").dismissal(DialogDismissal::NONE));
assert!(dialogs.traps_focus());
assert_eq!(dialogs.top().unwrap().id, "confirm");
assert!(dialogs
.dismiss_top(DialogDismissReason::EscapeKey)
.is_none());
assert!(dialogs.close("confirm").is_some());
assert_eq!(
dialogs
.dismiss_top(DialogDismissReason::EscapeKey)
.unwrap()
.id,
"settings"
);
let mut popovers = PopoverState::default();
let popover = PopoverDescriptor::new(
"tools",
PopoverAnchor::Rect(UiRect::new(90.0, 90.0, 20.0, 20.0)),
PopoverPlacement::Bottom,
)
.accessibility_label("Tool menu")
.close_on_escape(false);
let popover_accessibility = popover.accessibility();
assert_eq!(popover_accessibility.role, AccessibilityRole::Menu);
assert_eq!(popover_accessibility.label.as_deref(), Some("Tool menu"));
popovers.toggle(popover.clone());
assert!(popovers.is_open("tools"));
popovers.toggle(popover);
assert!(!popovers.is_open("tools"));
popovers.open(
PopoverDescriptor::new(
"sticky",
PopoverAnchor::Point(UiPoint::new(2.0, 3.0)),
PopoverPlacement::Right,
)
.close_on_escape(false),
);
assert!(popovers.dismiss(PopoverDismissReason::EscapeKey).is_none());
assert_eq!(
popovers
.dismiss(PopoverDismissReason::OutsidePointer)
.unwrap()
.id,
"sticky"
);
let rect = resolve_popover_rect(
UiRect::new(180.0, 180.0, 20.0, 20.0),
UiSize::new(80.0, 50.0),
UiRect::new(0.0, 0.0, 220.0, 220.0),
PopoverPlacement::Bottom,
6.0,
);
assert_eq!(rect.x, 140.0);
assert_eq!(rect.y, 170.0);
let guarded = resolve_popover_rect(
UiRect::new(f32::NAN, 0.0, 10.0, 10.0),
UiSize::new(f32::NAN, 24.0),
UiRect::new(0.0, 0.0, 100.0, 100.0),
PopoverPlacement::Right,
f32::NAN,
);
assert!(guarded.x.is_finite());
assert_eq!(guarded.width, 0.0);
}
#[test]
fn overlay_frame_opens_dialog_with_focus_trap_request() {
let focus_trap =
FocusTrap::new(UiNodeId(9)).restore_focus(FocusRestoreTarget::Node(UiNodeId(2)));
let output = process_overlay_frame(
OverlayFrameRequest::new(OverlayFrameState::new())
.accessibility_capabilities(AccessibilityCapabilities::FULL)
.event(OverlayFrameEvent::open_dialog_with_focus_trap(
DialogDescriptor::new("settings", "Settings").modal(true),
focus_trap,
)),
);
assert!(output.changed);
assert!(output.state.dialogs.is_open("settings"));
assert_eq!(output.state.focus_trap, Some(focus_trap));
assert!(output.state.traps_focus());
assert_eq!(
output.accessibility_requests,
vec![AccessibilityAdapterRequest::SetFocusTrap(focus_trap)]
);
}
#[test]
fn overlay_frame_dismisses_dialog_and_popover_by_policy() {
let mut state = OverlayFrameState::new();
state
.dialogs
.open(DialogDescriptor::new("confirm", "Confirm"));
state.popover.open(PopoverDescriptor::new(
"tools",
PopoverAnchor::Point(UiPoint::new(2.0, 3.0)),
PopoverPlacement::Right,
));
let output = process_overlay_frame(OverlayFrameRequest::new(state).events([
OverlayFrameEvent::dismiss_dialog(DialogDismissReason::EscapeKey),
OverlayFrameEvent::dismiss_popover(PopoverDismissReason::OutsidePointer),
]));
assert!(output.changed);
assert_eq!(output.dismissed_dialogs.len(), 1);
assert_eq!(output.dismissed_dialogs[0].id, "confirm");
assert_eq!(
output
.dismissed_popover
.as_ref()
.map(|popover| popover.id.as_str()),
Some("tools")
);
assert!(!output.state.has_overlay());
}
#[test]
fn overlay_frame_respects_focus_trap_capabilities_and_clear_restore() {
let focus_trap = FocusTrap::new(UiNodeId(7));
let unsupported = process_overlay_frame(
OverlayFrameRequest::new(OverlayFrameState::new())
.event(OverlayFrameEvent::set_focus_trap(focus_trap)),
);
assert_eq!(unsupported.state.focus_trap, Some(focus_trap));
assert!(unsupported.accessibility_requests.is_empty());
let output = process_overlay_frame(
OverlayFrameRequest::new(unsupported.state)
.accessibility_capabilities(AccessibilityCapabilities::FULL)
.event(OverlayFrameEvent::clear_focus_trap(
FocusRestoreTarget::Previous,
)),
);
assert!(output.changed);
assert_eq!(output.state.focus_trap, None);
assert_eq!(
output.accessibility_requests,
vec![AccessibilityAdapterRequest::ClearFocusTrap {
restore: FocusRestoreTarget::Previous
}]
);
}
#[test]
fn overlay_frame_toggle_popover_reports_dismissed_popover() {
let popover = PopoverDescriptor::new(
"toolbox",
PopoverAnchor::Rect(UiRect::new(10.0, 10.0, 20.0, 20.0)),
PopoverPlacement::Bottom,
);
let output = process_overlay_frame(
OverlayFrameRequest::new(OverlayFrameState::new())
.event(OverlayFrameEvent::toggle_popover(popover.clone())),
);
assert!(output.state.popover.is_open("toolbox"));
assert!(output.dismissed_popover.is_none());
let output = process_overlay_frame(
OverlayFrameRequest::new(output.state)
.event(OverlayFrameEvent::toggle_popover(popover)),
);
assert!(!output.state.popover.is_open("toolbox"));
assert_eq!(
output
.dismissed_popover
.as_ref()
.map(|popover| popover.id.as_str()),
Some("toolbox")
);
}
#[test]
fn toast_stack_expires_limits_and_builds_action_nodes() {
let mut stack = ToastStack::new(2);
stack.push(ToastSeverity::Info, "One", None, Some(1.0));
stack.push(ToastSeverity::Success, "Two", None, None);
let action_toast = Toast::new(
ToastId(99),
ToastSeverity::Warning,
"Three",
Some("Body".to_string()),
None,
)
.with_action(ToastAction::new("retry", "Retry"))
.with_icon(ImageContent::new("icons.warning"))
.with_shader(ShaderEffect::new("toast.warning.glow").uniform("strength", 0.8))
.accessibility_hint("Requires attention");
stack.push_toast(action_toast);
assert_eq!(
stack
.visible()
.iter()
.map(|toast| toast.title.as_str())
.collect::<Vec<_>>(),
vec!["Two", "Three"]
);
stack.tick(f32::NAN);
assert_eq!(
stack
.toasts
.iter()
.find(|toast| toast.title == "One")
.unwrap()
.remaining_seconds(),
Some(1.0)
);
stack.tick(1.1);
assert!(!stack.toasts.iter().any(|toast| toast.title == "One"));
let mut doc = UiDocument::new(root_style(400.0, 240.0));
let root = doc.root;
let stack_node = toast_stack(
&mut doc,
root,
"toasts",
&stack,
ToastStackOptions::default(),
);
doc.compute_layout(UiSize::new(400.0, 240.0), &mut ApproxTextMeasurer)
.expect("layout");
assert_eq!(doc.node(stack_node).children.len(), 2);
assert!(doc.nodes().iter().any(|node| node.input.focusable));
let warning_node = doc
.node(stack_node)
.children
.last()
.copied()
.expect("warning toast");
assert_eq!(
doc.node(warning_node)
.shader
.as_ref()
.map(|shader| shader.key.as_str()),
Some("toast.warning.glow")
);
assert_eq!(
doc.node(warning_node)
.animation
.as_ref()
.map(AnimationMachine::current_state_name),
Some("visible")
);
assert!(doc.nodes().iter().any(|node| {
matches!(
node.content,
UiContent::Image(ref image) if image.key == "icons.warning"
)
}));
let accessibility = doc.accessibility_tree();
assert!(accessibility.iter().any(|node| {
node.id == stack_node
&& node.role == AccessibilityRole::List
&& node.label.as_deref() == Some("Notifications")
}));
assert!(accessibility.iter().any(|node| {
node.role == AccessibilityRole::ListItem
&& node.label.as_deref() == Some("Warning: Three. Body")
&& node.hint.as_deref() == Some("Requires attention")
}));
assert!(accessibility.iter().any(|node| {
node.role == AccessibilityRole::Button && node.label.as_deref() == Some("Retry")
}));
}
#[test]
fn timeline_range_and_ruler_ticks_are_renderer_neutral() {
let range = TimelineRange::new(10.0, 14.0);
assert_eq!(range.value_to_x(12.0, 400.0), 200.0);
assert_eq!(range.x_to_value(100.0, 400.0), 11.0);
let reversed = TimelineRange::new(14.0, 10.0);
assert_eq!(reversed.ordered(), range);
assert!(reversed.contains(12.0));
assert_eq!(reversed.x_to_value(100.0, 400.0), 11.0);
let spec = RulerSpec {
range,
width: 400.0,
major_step: 1.0,
minor_step: 0.25,
label_every: 2,
};
let ticks = spec.ticks();
assert_eq!(ticks.first().unwrap().value, 10.0);
assert!(ticks
.iter()
.any(|tick| tick.kind == RulerTickKind::Minor && tick.label.is_none()));
assert_eq!(
ticks
.iter()
.filter_map(|tick| tick.label.as_deref())
.collect::<Vec<_>>(),
vec!["10", "12", "14"]
);
assert!(RulerSpec {
range,
width: f32::NAN,
major_step: 1.0,
minor_step: 0.25,
label_every: 1,
}
.ticks()
.is_empty());
let mut doc = UiDocument::new(root_style(400.0, 80.0));
let root = doc.root;
let options = TimelineRulerOptions {
shader: Some(ShaderEffect::new("timeline.scanline")),
accessibility_label: Some("Transport timeline".to_string()),
..TimelineRulerOptions::default()
};
let ruler = timeline_ruler(&mut doc, root, "ruler", spec, options);
doc.compute_layout(UiSize::new(400.0, 80.0), &mut ApproxTextMeasurer)
.expect("layout");
assert_eq!(
doc.node(ruler)
.shader
.as_ref()
.map(|shader| shader.key.as_str()),
Some("timeline.scanline")
);
let has_scene = doc.node(ruler).children.iter().any(|child| {
matches!(
doc.node(*child).content,
UiContent::Scene(ref primitives) if !primitives.is_empty()
)
});
let has_label_text = doc.node(ruler).children.iter().any(|child| {
matches!(
doc.node(*child).content,
UiContent::Text(TextContent { .. })
)
});
assert!(has_scene);
assert!(has_label_text);
let ruler_accessibility = doc
.accessibility_tree()
.into_iter()
.find(|node| node.id == ruler)
.expect("ruler accessibility");
assert_eq!(ruler_accessibility.role, AccessibilityRole::Slider);
assert_eq!(
ruler_accessibility.label.as_deref(),
Some("Transport timeline")
);
assert_eq!(ruler_accessibility.value.as_deref(), Some("10 to 14"));
}
#[test]
fn surface_animation_presets_transition_between_states() {
let mut open_close = surface_open_close_animation(false);
assert_eq!(open_close.current_state_name(), "closed");
assert!(open_close.trigger(surface_animation_trigger(SURFACE_OPEN_TRIGGER)));
open_close.tick(0.16);
assert_eq!(open_close.current_state_name(), "open");
assert!(open_close.trigger(surface_animation_trigger(SURFACE_CLOSE_TRIGGER)));
open_close.tick(0.12);
assert_eq!(open_close.current_state_name(), "closed");
let mut toast = toast_enter_exit_animation(false);
assert_eq!(toast.current_state_name(), "hidden");
assert!(toast.trigger(surface_animation_trigger(TOAST_ENTER_TRIGGER)));
toast.tick(0.18);
assert_eq!(toast.current_state_name(), "visible");
assert!(toast.trigger(surface_animation_trigger(TOAST_EXIT_TRIGGER)));
toast.tick(0.12);
assert_eq!(toast.current_state_name(), "hidden");
}
}