use taffy::prelude::{
AlignItems, Dimension, Display, FlexDirection, LengthPercentageAuto, Position,
Rect as TaffyRect, Size as TaffySize, Style,
};
use crate::{
length, AccessibilityAction, AccessibilityLiveRegion, AccessibilityMeta, AccessibilityRole,
AnimationMachine, ClipBehavior, ColorRgba, CommandId, CommandRegistry, CommandScope,
CommandTooltipResolver, ImageContent, InputBehavior, KeyCode, KeyModifiers, LayoutStyle,
ScrollAxes, ShaderEffect, ShortcutFormatter, StrokeStyle, TextStyle, UiDocument, UiInputEvent,
UiNode, UiNodeId, UiNodeStyle, UiRect, UiSize, UiVisual,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PopupSide {
Top,
Bottom,
Left,
Right,
}
impl PopupSide {
pub const fn opposite(self) -> Self {
match self {
Self::Top => Self::Bottom,
Self::Bottom => Self::Top,
Self::Left => Self::Right,
Self::Right => Self::Left,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PopupAlign {
Start,
Center,
End,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct PopupPlacement {
pub side: PopupSide,
pub align: PopupAlign,
pub offset: f32,
pub viewport_margin: f32,
pub flip: bool,
pub constrain_to_viewport: bool,
}
impl PopupPlacement {
pub const fn new(side: PopupSide, align: PopupAlign) -> Self {
Self {
side,
align,
offset: 4.0,
viewport_margin: 4.0,
flip: true,
constrain_to_viewport: true,
}
}
pub const fn with_offset(mut self, offset: f32) -> Self {
self.offset = offset;
self
}
pub const fn with_viewport_margin(mut self, margin: f32) -> Self {
self.viewport_margin = margin;
self
}
pub const fn with_flip(mut self, flip: bool) -> Self {
self.flip = flip;
self
}
pub const fn with_viewport_constraint(mut self, constrain: bool) -> Self {
self.constrain_to_viewport = constrain;
self
}
}
impl Default for PopupPlacement {
fn default() -> Self {
Self::new(PopupSide::Bottom, PopupAlign::Start)
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct PopupLayout {
pub rect: UiRect,
pub side: PopupSide,
pub flipped: bool,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct AnchoredPopup {
pub anchor: UiRect,
pub viewport: UiRect,
pub placement: PopupPlacement,
}
impl AnchoredPopup {
pub const fn new(anchor: UiRect, viewport: UiRect, placement: PopupPlacement) -> Self {
Self {
anchor,
viewport,
placement,
}
}
}
#[derive(Debug, Clone)]
pub struct PopupOptions {
pub visual: UiVisual,
pub z_index: i16,
pub clip: ClipBehavior,
pub scroll_axes: ScrollAxes,
pub accessibility: Option<AccessibilityMeta>,
pub shader: Option<ShaderEffect>,
pub animation: Option<AnimationMachine>,
}
impl Default for PopupOptions {
fn default() -> Self {
Self {
visual: UiVisual::panel(
ColorRgba::new(26, 31, 39, 255),
Some(StrokeStyle::new(ColorRgba::new(77, 90, 111, 255), 1.0)),
4.0,
),
z_index: 100,
clip: ClipBehavior::Clip,
scroll_axes: ScrollAxes::NONE,
accessibility: Some(AccessibilityMeta::new(AccessibilityRole::Dialog)),
shader: None,
animation: None,
}
}
}
pub fn place_popup(
anchor: UiRect,
popup_size: UiSize,
viewport: UiRect,
placement: PopupPlacement,
) -> PopupLayout {
let inner_viewport = inset_viewport(viewport, placement.viewport_margin.max(0.0));
let primary = popup_rect_for_anchor(anchor, popup_size, placement.side, placement);
let mut rect = primary;
let mut side = placement.side;
let mut flipped = false;
if placement.flip {
let opposite_side = placement.side.opposite();
let opposite = popup_rect_for_anchor(anchor, popup_size, opposite_side, placement);
if overflow_amount(opposite, inner_viewport) < overflow_amount(primary, inner_viewport) {
rect = opposite;
side = opposite_side;
flipped = true;
}
}
if placement.constrain_to_viewport {
rect = constrain_rect_to_viewport(rect, inner_viewport);
}
PopupLayout {
rect,
side,
flipped,
}
}
pub fn centered_popup_rect(viewport: UiRect, popup_size: UiSize, viewport_margin: f32) -> UiRect {
let inner = inset_viewport(viewport, viewport_margin.max(0.0));
constrain_rect_to_viewport(
UiRect::new(
inner.x + (inner.width - popup_size.width) * 0.5,
inner.y + (inner.height - popup_size.height) * 0.5,
popup_size.width,
popup_size.height,
),
inner,
)
}
pub fn popup_panel(
document: &mut UiDocument,
parent: UiNodeId,
name: impl Into<String>,
rect: UiRect,
options: PopupOptions,
) -> UiNodeId {
let PopupOptions {
visual,
z_index,
clip,
scroll_axes,
accessibility,
shader,
animation,
} = options;
let mut node = UiNode::container(
name,
UiNodeStyle {
layout: absolute_rect_style(rect).style,
clip,
z_index,
..Default::default()
},
)
.with_visual(visual);
if scroll_axes != ScrollAxes::NONE {
node = node.with_scroll(scroll_axes);
}
if let Some(accessibility) = accessibility {
node = node.with_accessibility(accessibility);
}
if let Some(shader) = shader {
node = node.with_shader(shader);
}
if let Some(animation) = animation {
node = node.with_animation(animation);
}
document.add_child(parent, node)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MenuItemKind {
Command,
Check { checked: bool },
Separator,
Submenu { items: Vec<MenuItem> },
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MenuItem {
pub id: Option<String>,
pub label: String,
pub enabled: bool,
pub shortcut: Option<String>,
pub image: Option<ImageContent>,
pub destructive: bool,
pub accessibility_label: Option<String>,
pub kind: MenuItemKind,
}
impl MenuItem {
pub fn command(id: impl Into<String>, label: impl Into<String>) -> Self {
Self {
id: Some(id.into()),
label: label.into(),
enabled: true,
shortcut: None,
image: None,
destructive: false,
accessibility_label: None,
kind: MenuItemKind::Command,
}
}
pub fn check(id: impl Into<String>, label: impl Into<String>, checked: bool) -> Self {
Self {
id: Some(id.into()),
label: label.into(),
enabled: true,
shortcut: None,
image: None,
destructive: false,
accessibility_label: None,
kind: MenuItemKind::Check { checked },
}
}
pub fn submenu(id: impl Into<String>, label: impl Into<String>, items: Vec<MenuItem>) -> Self {
Self {
id: Some(id.into()),
label: label.into(),
enabled: true,
shortcut: None,
image: None,
destructive: false,
accessibility_label: None,
kind: MenuItemKind::Submenu { items },
}
}
pub fn separator() -> Self {
Self {
id: None,
label: String::new(),
enabled: false,
shortcut: None,
image: None,
destructive: false,
accessibility_label: None,
kind: MenuItemKind::Separator,
}
}
pub fn disabled(mut self) -> Self {
self.enabled = false;
self
}
pub fn shortcut(mut self, shortcut: impl Into<String>) -> Self {
self.shortcut = Some(shortcut.into());
self
}
pub fn image(mut self, image: ImageContent) -> Self {
self.image = Some(image);
self
}
pub fn image_key(self, key: impl Into<String>) -> Self {
self.image(ImageContent::new(key))
}
pub fn destructive(mut self) -> Self {
self.destructive = true;
self
}
pub fn accessibility_label(mut self, label: impl Into<String>) -> Self {
self.accessibility_label = Some(label.into());
self
}
pub fn is_separator(&self) -> bool {
matches!(self.kind, MenuItemKind::Separator)
}
pub fn is_navigable(&self) -> bool {
self.enabled && !self.is_separator()
}
pub fn is_action(&self) -> bool {
self.enabled
&& matches!(
self.kind,
MenuItemKind::Command | MenuItemKind::Check { .. }
)
}
pub fn children(&self) -> Option<&[MenuItem]> {
match &self.kind {
MenuItemKind::Submenu { items } => Some(items),
MenuItemKind::Command | MenuItemKind::Check { .. } | MenuItemKind::Separator => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MenuSelection {
pub id: Option<String>,
pub index_path: Vec<usize>,
}
impl MenuSelection {
pub fn command_id(&self) -> Option<CommandId> {
self.id.as_deref().map(CommandId::from)
}
pub fn command_selection(&self) -> Option<MenuCommandSelection> {
Some(MenuCommandSelection {
command: self.command_id()?,
index_path: self.index_path.clone(),
})
}
pub fn into_command_selection(self) -> Option<MenuCommandSelection> {
Some(MenuCommandSelection {
command: self.id.map(CommandId::from)?,
index_path: self.index_path,
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MenuCommandSelection {
pub command: CommandId,
pub index_path: Vec<usize>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NavigationDirection {
Next,
Previous,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct MenuNavigationState {
pub active_path: Vec<usize>,
}
impl MenuNavigationState {
pub fn new() -> Self {
Self::default()
}
pub fn with_active_path(active_path: impl Into<Vec<usize>>) -> Self {
Self {
active_path: active_path.into(),
}
}
pub fn clear(&mut self) {
self.active_path.clear();
}
pub fn open_root(&mut self, items: &[MenuItem]) -> Option<Vec<usize>> {
let index = first_navigable_index(items)?;
self.active_path = vec![index];
Some(self.active_path.clone())
}
pub fn active_item<'a>(&self, items: &'a [MenuItem]) -> Option<&'a MenuItem> {
menu_item_at_path(items, &self.active_path)
}
pub fn active_parent_path(&self) -> &[usize] {
self.active_path
.split_last()
.map(|(_, parent)| parent)
.unwrap_or(&[])
}
pub fn move_active(
&mut self,
items: &[MenuItem],
direction: NavigationDirection,
) -> Option<Vec<usize>> {
let (level_items, parent_path, current) = self.current_level(items)?;
let index = next_navigable_index(level_items, current, direction)?;
self.active_path = parent_path;
self.active_path.push(index);
Some(self.active_path.clone())
}
pub fn open_submenu(&mut self, items: &[MenuItem]) -> Option<Vec<usize>> {
let item = self.active_item(items)?;
let child_index = first_navigable_index(item.children()?)?;
self.active_path.push(child_index);
Some(self.active_path.clone())
}
pub fn close_submenu(&mut self) -> Option<Vec<usize>> {
if self.active_path.len() <= 1 {
return None;
}
self.active_path.pop();
Some(self.active_path.clone())
}
pub fn select_active(&self, items: &[MenuItem]) -> Option<MenuSelection> {
menu_selection_at_path(items, &self.active_path)
}
pub fn activate_active(&mut self, items: &[MenuItem]) -> MenuNavigationOutcome {
let mut outcome = MenuNavigationOutcome::default();
if let Some(selection) = self.select_active(items) {
outcome.selected = Some(selection);
outcome.closed = true;
self.clear();
} else if let Some(path) = self.open_submenu(items) {
outcome.active_path = Some(path);
outcome.opened_submenu = true;
}
outcome
}
pub fn handle_event(
&mut self,
items: &[MenuItem],
event: &UiInputEvent,
) -> MenuNavigationOutcome {
let mut outcome = MenuNavigationOutcome::default();
if let UiInputEvent::Key {
key: KeyCode::Escape,
..
} = event
{
self.clear();
outcome.closed = true;
return outcome;
}
if self.active_path.is_empty() {
outcome.active_path = self.open_root(items);
if self.active_path.is_empty() {
return outcome;
}
}
if let UiInputEvent::TextInput(text) = event {
if let Some(character) = first_typeahead_character(text) {
outcome.active_path = self.move_active_to_match(items, character);
}
return outcome;
}
let UiInputEvent::Key { key, .. } = event else {
return outcome;
};
match *key {
KeyCode::ArrowDown => {
outcome.active_path = self.move_active(items, NavigationDirection::Next);
}
KeyCode::ArrowUp => {
outcome.active_path = self.move_active(items, NavigationDirection::Previous);
}
KeyCode::Home => {
outcome.active_path = self.move_to_edge(items, NavigationDirection::Next);
}
KeyCode::End => {
outcome.active_path = self.move_to_edge(items, NavigationDirection::Previous);
}
KeyCode::ArrowRight => {
outcome.active_path = self.open_submenu(items);
outcome.opened_submenu = outcome.active_path.is_some();
}
KeyCode::ArrowLeft => {
outcome.active_path = self.close_submenu();
outcome.closed_submenu = outcome.active_path.is_some();
}
KeyCode::Enter | KeyCode::Character(' ') => {
outcome = self.activate_active(items);
}
KeyCode::Character(character) if is_typeahead_character(character) => {
outcome.active_path = self.move_active_to_match(items, character);
}
_ => {}
}
outcome
}
fn current_level<'a>(
&self,
items: &'a [MenuItem],
) -> Option<(&'a [MenuItem], Vec<usize>, Option<usize>)> {
let (current, parent_path) = self
.active_path
.split_last()
.map(|(current, parent)| (Some(*current), parent.to_vec()))
.unwrap_or((None, Vec::new()));
let level_items = menu_items_at_path(items, &parent_path)?;
Some((level_items, parent_path, current))
}
fn move_to_edge(
&mut self,
items: &[MenuItem],
direction: NavigationDirection,
) -> Option<Vec<usize>> {
let (level_items, mut parent_path, _) = self.current_level(items)?;
let index = match direction {
NavigationDirection::Next => first_navigable_index(level_items),
NavigationDirection::Previous => last_navigable_index(level_items),
}?;
parent_path.push(index);
self.active_path = parent_path;
Some(self.active_path.clone())
}
fn move_active_to_match(&mut self, items: &[MenuItem], character: char) -> Option<Vec<usize>> {
let (level_items, mut parent_path, current) = self.current_level(items)?;
let index = next_menu_typeahead_index(level_items, current, character)?;
parent_path.push(index);
self.active_path = parent_path;
Some(self.active_path.clone())
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct MenuNavigationOutcome {
pub active_path: Option<Vec<usize>>,
pub opened_submenu: bool,
pub closed_submenu: bool,
pub closed: bool,
pub selected: Option<MenuSelection>,
}
impl MenuNavigationOutcome {
pub fn selected_command(&self) -> Option<MenuCommandSelection> {
self.selected
.as_ref()
.and_then(MenuSelection::command_selection)
}
}
pub fn menu_item_at_path<'a>(items: &'a [MenuItem], path: &[usize]) -> Option<&'a MenuItem> {
let (first, rest) = path.split_first()?;
let item = items.get(*first)?;
if rest.is_empty() {
return Some(item);
}
menu_item_at_path(item.children()?, rest)
}
pub fn menu_items_at_path<'a>(items: &'a [MenuItem], path: &[usize]) -> Option<&'a [MenuItem]> {
if path.is_empty() {
return Some(items);
}
menu_item_at_path(items, path)?.children()
}
pub fn menu_selection_at_path(items: &[MenuItem], path: &[usize]) -> Option<MenuSelection> {
let item = menu_item_at_path(items, path)?;
if !item.is_action() {
return None;
}
Some(MenuSelection {
id: item.id.clone(),
index_path: path.to_vec(),
})
}
pub fn menu_command_selection_at_path(
items: &[MenuItem],
path: &[usize],
) -> Option<MenuCommandSelection> {
menu_selection_at_path(items, path)?.into_command_selection()
}
pub fn menu_item_from_command(
registry: &CommandRegistry,
command: impl Into<CommandId>,
active_scopes: &[CommandScope],
formatter: &ShortcutFormatter,
) -> Option<MenuItem> {
let command_id = command.into();
let command = registry.command(&command_id)?;
let mut item = MenuItem::command(command_id.as_str(), command.meta.label.clone());
if let Some(shortcut) = command_shortcut_label(registry, &command_id, active_scopes, formatter)
{
item = item.shortcut(shortcut);
}
if !command.enabled {
item = item.disabled();
}
Some(item)
}
pub fn first_navigable_index(items: &[MenuItem]) -> Option<usize> {
items.iter().position(MenuItem::is_navigable)
}
pub fn last_navigable_index(items: &[MenuItem]) -> Option<usize> {
items.iter().rposition(MenuItem::is_navigable)
}
pub fn next_navigable_index(
items: &[MenuItem],
current: Option<usize>,
direction: NavigationDirection,
) -> Option<usize> {
let len = items.len();
if len == 0 {
return None;
}
let start = match (current.filter(|index| *index < len), direction) {
(Some(index), NavigationDirection::Next) => (index + 1) % len,
(Some(index), NavigationDirection::Previous) => (index + len - 1) % len,
(None, NavigationDirection::Next) => 0,
(None, NavigationDirection::Previous) => len - 1,
};
for offset in 0..len {
let index = match direction {
NavigationDirection::Next => (start + offset) % len,
NavigationDirection::Previous => (start + len - offset) % len,
};
if items[index].is_navigable() {
return Some(index);
}
}
None
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SearchFilterTiming {
Immediate,
Debounced { delay_ms: u64 },
}
impl SearchFilterTiming {
pub const IMMEDIATE: Self = Self::Immediate;
pub const fn debounced(delay_ms: u64) -> Self {
Self::Debounced { delay_ms }
}
}
impl Default for SearchFilterTiming {
fn default() -> Self {
Self::Immediate
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SearchFilterRequest {
pub query: String,
pub revision: u64,
pub elapsed_ms: u64,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct SearchFieldOutcome {
pub query_changed: bool,
pub cleared: bool,
pub filter_pending: bool,
pub close_requested: bool,
}
impl SearchFieldOutcome {
pub fn is_empty(&self) -> bool {
!self.query_changed && !self.cleared && !self.filter_pending && !self.close_requested
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SearchClearButtonMeta {
pub id: String,
pub label: String,
pub accessibility_label: String,
pub shortcut: Option<String>,
pub enabled: bool,
}
impl SearchClearButtonMeta {
pub fn new(
id: impl Into<String>,
label: impl Into<String>,
accessibility_label: impl Into<String>,
) -> Self {
Self {
id: id.into(),
label: label.into(),
accessibility_label: accessibility_label.into(),
shortcut: Some("Escape".to_string()),
enabled: true,
}
}
pub fn disabled(mut self) -> Self {
self.enabled = false;
self
}
pub fn shortcut(mut self, shortcut: impl Into<String>) -> Self {
self.shortcut = Some(shortcut.into());
self
}
pub fn without_shortcut(mut self) -> Self {
self.shortcut = None;
self
}
pub fn accessibility(&self) -> AccessibilityMeta {
let mut accessibility = AccessibilityMeta::new(AccessibilityRole::Button)
.label(self.accessibility_label.clone());
if let Some(shortcut) = &self.shortcut {
accessibility = accessibility.shortcut(shortcut.clone());
}
if self.enabled {
accessibility.focusable()
} else {
accessibility.disabled()
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SearchStatusText {
pub query: String,
pub visible_count: usize,
pub total_count: usize,
pub text: String,
}
impl SearchStatusText {
pub fn new(
query: impl AsRef<str>,
visible_count: usize,
total_count: usize,
singular: impl AsRef<str>,
plural: impl AsRef<str>,
) -> Self {
let query = query.as_ref().trim().to_string();
let singular = singular.as_ref();
let plural = plural.as_ref();
let text = if query.is_empty() {
format!(
"{} available",
search_count_label(total_count, singular, plural)
)
} else if visible_count == 0 {
format!("No {plural} match \"{query}\"")
} else {
let verb = if visible_count == 1 {
"matches"
} else {
"match"
};
format!(
"{} {verb} \"{query}\"",
search_count_label(visible_count, singular, plural)
)
};
Self {
query,
visible_count,
total_count,
text,
}
}
pub fn accessibility(&self, label: impl Into<String>) -> AccessibilityMeta {
AccessibilityMeta::new(AccessibilityRole::Status)
.label(label)
.value(self.text.clone())
.live_region(AccessibilityLiveRegion::Polite)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SearchFieldState {
pub query: String,
pub revision: u64,
pub filtered_revision: u64,
pub last_changed_at_ms: Option<u64>,
}
impl SearchFieldState {
pub fn new() -> Self {
Self {
query: String::new(),
revision: 0,
filtered_revision: 0,
last_changed_at_ms: None,
}
}
pub fn with_query(mut self, query: impl Into<String>) -> Self {
self.query = query.into();
self
}
pub fn from_query(query: impl Into<String>) -> Self {
Self::new().with_query(query)
}
pub fn is_empty(&self) -> bool {
self.query.is_empty()
}
pub fn is_filter_pending(&self) -> bool {
self.filtered_revision != self.revision
}
pub fn clear_button(&self) -> Option<SearchClearButtonMeta> {
(!self.query.is_empty())
.then(|| SearchClearButtonMeta::new("clear-search", "Clear", "Clear search"))
}
pub fn input_accessibility(&self, label: impl Into<String>) -> AccessibilityMeta {
let mut accessibility = AccessibilityMeta::new(AccessibilityRole::SearchBox)
.label(label)
.value(self.query.clone())
.focusable();
if let Some(clear_button) = self.clear_button() {
let mut action =
AccessibilityAction::new(clear_button.id, clear_button.accessibility_label);
if let Some(shortcut) = clear_button.shortcut {
action = action.shortcut(shortcut);
}
accessibility = accessibility.action(action);
}
accessibility
}
pub fn status(
&self,
visible_count: usize,
total_count: usize,
singular: impl AsRef<str>,
plural: impl AsRef<str>,
) -> SearchStatusText {
SearchStatusText::new(&self.query, visible_count, total_count, singular, plural)
}
pub fn status_accessibility(
&self,
label: impl Into<String>,
visible_count: usize,
total_count: usize,
singular: impl AsRef<str>,
plural: impl AsRef<str>,
) -> AccessibilityMeta {
self.status(visible_count, total_count, singular, plural)
.accessibility(label)
}
pub fn set_query(&mut self, query: impl Into<String>, now_ms: u64) -> SearchFieldOutcome {
let query = query.into();
if self.query == query {
return SearchFieldOutcome::default();
}
let cleared = !self.query.is_empty() && query.is_empty();
self.query = query;
self.revision = self.revision.saturating_add(1);
self.last_changed_at_ms = Some(now_ms);
SearchFieldOutcome {
query_changed: true,
cleared,
filter_pending: true,
close_requested: false,
}
}
pub fn push_text(&mut self, text: impl AsRef<str>, now_ms: u64) -> SearchFieldOutcome {
let text = text.as_ref();
if text.is_empty() {
return SearchFieldOutcome::default();
}
let mut query = self.query.clone();
query.push_str(text);
self.set_query(query, now_ms)
}
pub fn backspace(&mut self, now_ms: u64) -> SearchFieldOutcome {
let mut query = self.query.clone();
if pop_last_char(&mut query) {
self.set_query(query, now_ms)
} else {
SearchFieldOutcome::default()
}
}
pub fn clear(&mut self, now_ms: u64) -> SearchFieldOutcome {
self.set_query(String::new(), now_ms)
}
pub fn handle_event(&mut self, event: &UiInputEvent, now_ms: u64) -> SearchFieldOutcome {
match event {
UiInputEvent::TextInput(text) => self.push_text(text, now_ms),
UiInputEvent::Key {
key: KeyCode::Backspace,
modifiers,
} if search_field_clear_modifier(*modifiers) => self.clear(now_ms),
UiInputEvent::Key {
key: KeyCode::Backspace,
..
} => self.backspace(now_ms),
UiInputEvent::Key {
key: KeyCode::Delete,
modifiers,
} if search_field_clear_modifier(*modifiers) => self.clear(now_ms),
UiInputEvent::Key {
key: KeyCode::Escape,
..
} if self.query.is_empty() => SearchFieldOutcome {
close_requested: true,
..Default::default()
},
UiInputEvent::Key {
key: KeyCode::Escape,
..
} => self.clear(now_ms),
_ => SearchFieldOutcome::default(),
}
}
pub fn filter_request(
&self,
now_ms: u64,
timing: SearchFilterTiming,
) -> Option<SearchFilterRequest> {
if !self.is_filter_pending() {
return None;
}
let elapsed_ms = self
.last_changed_at_ms
.map(|changed_at| now_ms.saturating_sub(changed_at))
.unwrap_or(0);
match timing {
SearchFilterTiming::Immediate => {}
SearchFilterTiming::Debounced { delay_ms } if elapsed_ms >= delay_ms => {}
SearchFilterTiming::Debounced { .. } => return None,
}
Some(SearchFilterRequest {
query: self.query.clone(),
revision: self.revision,
elapsed_ms,
})
}
pub fn take_filter_request(
&mut self,
now_ms: u64,
timing: SearchFilterTiming,
) -> Option<SearchFilterRequest> {
let request = self.filter_request(now_ms, timing)?;
self.mark_filter_applied(request.revision);
Some(request)
}
pub fn mark_filter_applied(&mut self, revision: u64) {
if revision > self.filtered_revision && revision <= self.revision {
self.filtered_revision = revision;
}
}
}
impl Default for SearchFieldState {
fn default() -> Self {
Self::new()
}
}
fn popup_rect_for_anchor(
anchor: UiRect,
popup_size: UiSize,
side: PopupSide,
placement: PopupPlacement,
) -> UiRect {
let offset = placement.offset.max(0.0);
match side {
PopupSide::Top => UiRect::new(
aligned_x(anchor, popup_size.width, placement.align),
anchor.y - popup_size.height - offset,
popup_size.width,
popup_size.height,
),
PopupSide::Bottom => UiRect::new(
aligned_x(anchor, popup_size.width, placement.align),
anchor.bottom() + offset,
popup_size.width,
popup_size.height,
),
PopupSide::Left => UiRect::new(
anchor.x - popup_size.width - offset,
aligned_y(anchor, popup_size.height, placement.align),
popup_size.width,
popup_size.height,
),
PopupSide::Right => UiRect::new(
anchor.right() + offset,
aligned_y(anchor, popup_size.height, placement.align),
popup_size.width,
popup_size.height,
),
}
}
fn aligned_x(anchor: UiRect, width: f32, align: PopupAlign) -> f32 {
match align {
PopupAlign::Start => anchor.x,
PopupAlign::Center => anchor.x + (anchor.width - width) * 0.5,
PopupAlign::End => anchor.right() - width,
}
}
fn aligned_y(anchor: UiRect, height: f32, align: PopupAlign) -> f32 {
match align {
PopupAlign::Start => anchor.y,
PopupAlign::Center => anchor.y + (anchor.height - height) * 0.5,
PopupAlign::End => anchor.bottom() - height,
}
}
fn inset_viewport(viewport: UiRect, margin: f32) -> UiRect {
let x_margin = margin.min(viewport.width * 0.5);
let y_margin = margin.min(viewport.height * 0.5);
UiRect::new(
viewport.x + x_margin,
viewport.y + y_margin,
(viewport.width - x_margin * 2.0).max(0.0),
(viewport.height - y_margin * 2.0).max(0.0),
)
}
fn overflow_amount(rect: UiRect, viewport: UiRect) -> f32 {
(viewport.x - rect.x).max(0.0)
+ (rect.right() - viewport.right()).max(0.0)
+ (viewport.y - rect.y).max(0.0)
+ (rect.bottom() - viewport.bottom()).max(0.0)
}
fn constrain_rect_to_viewport(rect: UiRect, viewport: UiRect) -> UiRect {
let (x, width) = constrain_axis(rect.x, rect.width, viewport.x, viewport.right());
let (y, height) = constrain_axis(rect.y, rect.height, viewport.y, viewport.bottom());
UiRect::new(x, y, width, height)
}
fn constrain_axis(start: f32, size: f32, min: f32, max: f32) -> (f32, f32) {
let available = (max - min).max(0.0);
let size = size.max(0.0).min(available);
let max_start = max - size;
let start = if max_start <= min {
min
} else {
start.clamp(min, max_start)
};
(start, size)
}
fn absolute_rect_style(rect: UiRect) -> LayoutStyle {
LayoutStyle::from_taffy_style(Style {
position: Position::Absolute,
inset: TaffyRect {
left: LengthPercentageAuto::length(rect.x),
right: LengthPercentageAuto::auto(),
top: LengthPercentageAuto::length(rect.y),
bottom: LengthPercentageAuto::auto(),
},
size: TaffySize {
width: length(rect.width),
height: length(rect.height),
},
..Default::default()
})
}
pub(in crate::widget_ext) fn next_menu_typeahead_index(
items: &[MenuItem],
current: Option<usize>,
character: char,
) -> Option<usize> {
let query = normalized_character(character)?;
next_matching_index(items.len(), current, NavigationDirection::Next, |index| {
let item = &items[index];
item.is_navigable() && normalize(&item.label).starts_with(&query)
})
}
pub(in crate::widget_ext) fn visible_row_count(count: usize, max_visible_rows: usize) -> usize {
if max_visible_rows == 0 {
0
} else {
count.min(max_visible_rows)
}
}
pub(in crate::widget_ext) fn visible_match_range(
count: usize,
active: Option<usize>,
max_visible_rows: usize,
) -> std::ops::Range<usize> {
let visible_rows = visible_row_count(count, max_visible_rows);
if visible_rows == 0 {
return 0..0;
}
let active = active.filter(|index| *index < count).unwrap_or(0);
let half_window = visible_rows / 2;
let max_start = count.saturating_sub(visible_rows);
let start = active.saturating_sub(half_window).min(max_start);
start..(start + visible_rows)
}
pub(in crate::widget_ext) fn row_style(height: f32) -> UiNodeStyle {
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(height.max(0.0)),
},
padding: TaffyRect {
left: taffy::prelude::LengthPercentage::length(10.0),
right: taffy::prelude::LengthPercentage::length(10.0),
top: taffy::prelude::LengthPercentage::length(6.0),
bottom: taffy::prelude::LengthPercentage::length(6.0),
},
flex_shrink: 0.0,
..Default::default()
})
.style,
clip: ClipBehavior::Clip,
..Default::default()
}
}
pub(in crate::widget_ext) fn leading_image(
document: &mut UiDocument,
parent: UiNodeId,
name: impl Into<String>,
image: ImageContent,
accessibility_label: &str,
image_size: UiSize,
) -> UiNodeId {
document.add_child(
parent,
UiNode::image(
name,
image,
LayoutStyle::from_taffy_style(Style {
size: TaffySize {
width: length(image_size.width.max(0.0)),
height: length(image_size.height.max(0.0)),
},
margin: TaffyRect {
left: LengthPercentageAuto::length(0.0),
right: LengthPercentageAuto::length(6.0),
top: LengthPercentageAuto::length(0.0),
bottom: LengthPercentageAuto::length(0.0),
},
flex_shrink: 0.0,
..Default::default()
}),
)
.with_accessibility(
AccessibilityMeta::new(AccessibilityRole::Image).label(accessibility_label),
),
)
}
pub(in crate::widget_ext) fn menu_accessibility_label(
name: &str,
explicit: Option<&String>,
) -> String {
explicit.cloned().unwrap_or_else(|| name.to_string())
}
pub(in crate::widget_ext) fn set_active_descendant(
document: &mut UiDocument,
owner: UiNodeId,
active_descendant: Option<UiNodeId>,
) {
if let Some(active_descendant) = active_descendant {
if let Some(accessibility) = document.node_mut(owner).accessibility.as_mut() {
accessibility.relations.active_descendant = Some(active_descendant);
}
}
}
pub(in crate::widget_ext) fn command_shortcut_label(
registry: &CommandRegistry,
command: &CommandId,
active_scopes: &[CommandScope],
formatter: &ShortcutFormatter,
) -> Option<String> {
CommandTooltipResolver::new(registry)
.formatter(formatter.clone())
.shortcut_for(command, active_scopes)
.map(|shortcut| formatter.format(shortcut))
}
pub(in crate::widget_ext) fn button_like(
document: &mut UiDocument,
parent: UiNodeId,
name: impl Into<String>,
label_text: impl Into<String>,
layout: LayoutStyle,
visual: UiVisual,
text_style: TextStyle,
) -> UiNodeId {
button_like_with_input(
document,
parent,
name,
label_text,
layout,
visual,
text_style,
InputBehavior::BUTTON,
)
}
#[allow(clippy::too_many_arguments)]
pub(in crate::widget_ext) fn button_like_with_input(
document: &mut UiDocument,
parent: UiNodeId,
name: impl Into<String>,
label_text: impl Into<String>,
layout: LayoutStyle,
visual: UiVisual,
text_style: TextStyle,
input: InputBehavior,
) -> UiNodeId {
let name = name.into();
let mut layout_style = layout.style;
layout_style.display = Display::Flex;
layout_style.flex_direction = FlexDirection::Row;
layout_style.align_items = Some(AlignItems::Center);
layout_style.justify_content = layout_style
.justify_content
.or(Some(taffy::prelude::JustifyContent::Center));
let root = document.add_child(
parent,
UiNode::container(
name.clone(),
UiNodeStyle {
layout: layout_style,
clip: ClipBehavior::Clip,
..Default::default()
},
)
.with_input(input)
.with_visual(visual),
);
label(
document,
root,
format!("{name}.label"),
label_text,
text_style,
LayoutStyle::from_taffy_style(Style {
size: TaffySize {
width: Dimension::auto(),
height: Dimension::auto(),
},
..Default::default()
}),
);
root
}
pub(in crate::widget_ext) fn label(
document: &mut UiDocument,
parent: UiNodeId,
name: impl Into<String>,
text: impl Into<String>,
text_style: TextStyle,
layout: LayoutStyle,
) -> UiNodeId {
document.add_child(parent, UiNode::text(name, text, text_style, layout))
}
pub(in crate::widget_ext) fn length_percentage(value: f32) -> taffy::prelude::LengthPercentage {
taffy::prelude::LengthPercentage::length(value)
}
pub(in crate::widget_ext) fn normalize(value: &str) -> String {
value.to_lowercase()
}
pub(in crate::widget_ext) fn first_typeahead_character(text: &str) -> Option<char> {
text.chars()
.find(|character| is_typeahead_character(*character))
}
pub(in crate::widget_ext) fn is_typeahead_character(character: char) -> bool {
!character.is_control() && !character.is_whitespace()
}
pub(in crate::widget_ext) fn normalized_character(character: char) -> Option<String> {
is_typeahead_character(character).then(|| character.to_lowercase().collect())
}
pub(in crate::widget_ext) fn next_matching_index(
len: usize,
current: Option<usize>,
direction: NavigationDirection,
mut matches: impl FnMut(usize) -> bool,
) -> Option<usize> {
if len == 0 {
return None;
}
let start = match (current.filter(|index| *index < len), direction) {
(Some(index), NavigationDirection::Next) => (index + 1) % len,
(Some(index), NavigationDirection::Previous) => (index + len - 1) % len,
(None, NavigationDirection::Next) => 0,
(None, NavigationDirection::Previous) => len - 1,
};
for offset in 0..len {
let index = match direction {
NavigationDirection::Next => (start + offset) % len,
NavigationDirection::Previous => (start + len - offset) % len,
};
if matches(index) {
return Some(index);
}
}
None
}
fn search_count_label(count: usize, singular: &str, plural: &str) -> String {
if count == 1 {
format!("1 {singular}")
} else {
format!("{count} {plural}")
}
}
fn search_field_clear_modifier(modifiers: KeyModifiers) -> bool {
modifiers.ctrl || modifiers.meta
}
pub(in crate::widget_ext) fn pop_last_char(text: &mut String) -> bool {
let Some((index, _)) = text.char_indices().next_back() else {
return false;
};
text.truncate(index);
true
}
#[cfg(test)]
mod tests {
use taffy::prelude::{Size as TaffySize, Style};
use super::*;
use crate::widget_ext::{
command_palette::*, context_menu::*, dropdown::*, menu_bar::*, menu_list::*,
};
use crate::{
root_style, AccessibilityMeta, AccessibilityRole, AnimatedValues, AnimationMachine,
AnimationState, AnimationTransition, AnimationTrigger, ApproxTextMeasurer, Command,
CommandId, CommandMeta, CommandRegistry, CommandScope, ContextMenuSuppressedReason,
HelpItemState, KeyModifiers, PointerButton, PointerEventKind, RawPointerEvent,
ShaderEffect, Shortcut, ShortcutFormatter, UiContent, UiPoint,
};
fn test_animation() -> AnimationMachine {
AnimationMachine::new(
vec![
AnimationState::new(
"hidden",
AnimatedValues::new(0.0, UiPoint::new(0.0, 0.0), 0.98),
),
AnimationState::new("shown", AnimatedValues::default()),
],
vec![AnimationTransition::new(
"hidden",
"shown",
AnimationTrigger::Custom("show".to_string()),
0.12,
)],
"hidden",
)
.expect("animation")
}
#[test]
fn popup_placement_flips_and_clamps_to_viewport() {
let layout = place_popup(
UiRect::new(260.0, 170.0, 32.0, 24.0),
UiSize::new(140.0, 80.0),
UiRect::new(0.0, 0.0, 300.0, 220.0),
PopupPlacement::new(PopupSide::Bottom, PopupAlign::End)
.with_offset(6.0)
.with_viewport_margin(8.0),
);
assert_eq!(layout.side, PopupSide::Top);
assert!(layout.flipped);
assert!(layout.rect.x >= 8.0, "{layout:?}");
assert!(layout.rect.right() <= 292.0, "{layout:?}");
assert!(layout.rect.y >= 8.0, "{layout:?}");
assert!(layout.rect.bottom() <= 212.0, "{layout:?}");
}
#[test]
fn popup_panel_uses_absolute_layout_and_optional_scroll() {
let mut document = UiDocument::new(root_style(300.0, 200.0));
let root = document.root;
let popup = popup_panel(
&mut document,
root,
"popup",
UiRect::new(16.0, 20.0, 120.0, 80.0),
PopupOptions {
scroll_axes: ScrollAxes::VERTICAL,
..Default::default()
},
);
let node = document.node(popup);
assert_eq!(node.style.layout.position, Position::Absolute);
assert_eq!(node.style.z_index, 100);
assert!(node.scroll.is_some());
}
#[test]
fn popup_panel_exposes_accessibility_shader_and_animation_metadata() {
let mut document = UiDocument::new(root_style(300.0, 200.0));
let root = document.root;
let popup = popup_panel(
&mut document,
root,
"popup",
UiRect::new(16.0, 20.0, 120.0, 80.0),
PopupOptions {
accessibility: Some(
AccessibilityMeta::new(AccessibilityRole::Dialog)
.label("Inspector")
.focusable(),
),
shader: Some(ShaderEffect::new("ui.popup.shadow").uniform("elevation", 8.0)),
animation: Some(test_animation()),
..Default::default()
},
);
let node = document.node(popup);
let accessibility = node.accessibility.as_ref().expect("accessibility");
assert_eq!(accessibility.role, AccessibilityRole::Dialog);
assert_eq!(accessibility.label.as_deref(), Some("Inspector"));
assert!(accessibility.focusable);
assert_eq!(node.shader.as_ref().unwrap().key, "ui.popup.shadow");
assert_eq!(
node.animation.as_ref().unwrap().current_state_name(),
"hidden"
);
}
#[test]
fn select_menu_keyboard_navigation_skips_disabled_options() {
let options = vec![
SelectOption::new("a", "Alpha").disabled(),
SelectOption::new("b", "Beta"),
SelectOption::new("c", "Gamma"),
];
let mut state = SelectMenuState::new();
let outcome = state.handle_event(
&options,
&UiInputEvent::Key {
key: KeyCode::ArrowDown,
modifiers: KeyModifiers::NONE,
},
);
assert!(outcome.opened);
assert_eq!(state.active, Some(1));
let outcome = state.handle_event(
&options,
&UiInputEvent::Key {
key: KeyCode::Enter,
modifiers: KeyModifiers::NONE,
},
);
assert_eq!(
outcome.selected,
Some(SelectSelection {
index: 1,
id: "b".to_string(),
})
);
assert_eq!(state.selected_id(&options), Some("b"));
assert!(!state.open);
}
#[test]
fn select_menu_typeahead_selects_closed_and_moves_open_active_option() {
let options = vec![
SelectOption::new("alpha", "Alpha"),
SelectOption::new("beta", "Beta").disabled(),
SelectOption::new("gamma", "Gamma"),
];
let mut state = SelectMenuState::with_selected(0);
let outcome = state.handle_event(
&options,
&UiInputEvent::Key {
key: KeyCode::Character('g'),
modifiers: KeyModifiers::NONE,
},
);
assert_eq!(
outcome.selected,
Some(SelectSelection {
index: 2,
id: "gamma".to_string(),
})
);
assert_eq!(state.selected, Some(2));
state.open(&options);
let outcome = state.handle_event(&options, &UiInputEvent::TextInput("a".to_string()));
assert_eq!(outcome.active, Some(0));
assert_eq!(state.active, Some(0));
}
#[test]
fn search_field_tracks_clear_button_debounce_status_and_keyboard_clear() {
let mut field = SearchFieldState::new();
assert!(field.clear_button().is_none());
let outcome = field.handle_event(&UiInputEvent::TextInput("alp".to_string()), 100);
assert!(outcome.query_changed);
assert!(outcome.filter_pending);
assert_eq!(field.query, "alp");
assert!(field.is_filter_pending());
let clear_button = field.clear_button().expect("clear button");
assert_eq!(clear_button.id, "clear-search");
assert_eq!(clear_button.label, "Clear");
assert_eq!(clear_button.shortcut.as_deref(), Some("Escape"));
let clear_accessibility = clear_button.accessibility();
assert_eq!(clear_accessibility.role, AccessibilityRole::Button);
assert!(clear_accessibility.focusable);
let input_accessibility = field.input_accessibility("Search commands");
assert_eq!(input_accessibility.role, AccessibilityRole::SearchBox);
assert_eq!(input_accessibility.value.as_deref(), Some("alp"));
assert_eq!(input_accessibility.actions.len(), 1);
assert_eq!(input_accessibility.actions[0].id, "clear-search");
assert!(field
.filter_request(149, SearchFilterTiming::debounced(50))
.is_none());
let request = field
.filter_request(150, SearchFilterTiming::debounced(50))
.expect("debounced filter");
assert_eq!(request.query, "alp");
assert_eq!(request.revision, field.revision);
assert_eq!(request.elapsed_ms, 50);
let request = field
.take_filter_request(150, SearchFilterTiming::debounced(50))
.expect("taken filter");
assert_eq!(request.query, "alp");
assert!(!field.is_filter_pending());
let status = field.status(2, 4, "option", "options");
assert_eq!(status.text, "2 options match \"alp\"");
let status_accessibility = status.accessibility("Search results");
assert_eq!(status_accessibility.role, AccessibilityRole::Status);
assert_eq!(
status_accessibility.live_region,
AccessibilityLiveRegion::Polite
);
let outcome = field.handle_event(
&UiInputEvent::Key {
key: KeyCode::Escape,
modifiers: KeyModifiers::NONE,
},
200,
);
assert!(outcome.cleared);
assert_eq!(field.query, "");
assert!(field.is_filter_pending());
let outcome = field.handle_event(
&UiInputEvent::Key {
key: KeyCode::Escape,
modifiers: KeyModifiers::NONE,
},
250,
);
assert!(outcome.close_requested);
}
#[test]
fn select_option_filter_accepts_search_field_and_reports_accessible_status() {
let options = vec![
SelectOption::new("alpha", "Alpha"),
SelectOption::new("alpine", "Alpine"),
SelectOption::new("beta", "Beta"),
];
let mut field = SearchFieldState::new();
field.set_query("alp", 10);
let mut state = SelectOptionFilterState::new();
let outcome = state.apply_search_field(&field, &options);
assert!(outcome.query_changed);
assert_eq!(state.query, "alp");
assert_eq!(state.filtered_indices(&options), vec![0, 1]);
assert_eq!(state.active_match, Some(0));
assert_eq!(
state.search_status(&options).text,
"2 options match \"alp\""
);
assert_eq!(
state.search_status_accessibility(&options).live_region,
AccessibilityLiveRegion::Polite
);
assert_eq!(state.search_field().query, "alp");
let outcome = state.clear_query(&options);
assert!(outcome.query_changed);
assert_eq!(state.query, "");
assert_eq!(state.active_match, Some(0));
assert_eq!(state.search_status(&options).text, "3 options available");
}
#[test]
fn select_option_filter_preserves_option_indices_and_searches_option_metadata() {
let options = vec![
SelectOption::new("lofi", "Low fidelity").accessibility_label("Preview quality"),
SelectOption::new("studio", "Studio").disabled(),
SelectOption::new("high", "High fidelity"),
];
let mut state = SelectOptionFilterState::new();
state.set_query("fidelity", &options);
assert_eq!(state.filtered_indices(&options), vec![0, 2]);
assert_eq!(state.active_match, Some(0));
assert_eq!(state.active_option_index(&options), Some(0));
assert_eq!(state.active_id(&options), Some("lofi"));
assert_eq!(
state.active_accessibility_label(&options).as_deref(),
Some("Preview quality")
);
assert_eq!(state.accessibility_value(&options), "2 filtered options");
state.set_query("studio", &options);
assert_eq!(state.filtered_indices(&options), vec![1]);
assert_eq!(state.active_match, None);
assert_eq!(state.select_active(&options), None);
}
#[test]
fn select_option_filter_moves_through_filtered_enabled_options_and_selects_existing_shape() {
let options = vec![
SelectOption::new("alpha", "Alpha"),
SelectOption::new("alpine", "Alpine").disabled(),
SelectOption::new("atlas", "Atlas"),
SelectOption::new("beta", "Beta"),
];
let mut state = SelectOptionFilterState::new().with_query("a");
state.set_query("a", &options);
assert_eq!(state.filtered_indices(&options), vec![0, 1, 2, 3]);
assert_eq!(state.active_match, Some(0));
let active = state.move_active(&options, NavigationDirection::Next);
assert_eq!(active, Some(2));
assert_eq!(
state.select_active(&options),
Some(SelectSelection {
index: 2,
id: "atlas".to_string(),
})
);
let active = state.move_active(&options, NavigationDirection::Previous);
assert_eq!(active, Some(0));
}
#[test]
fn select_option_filter_handles_text_input_backspace_empty_and_escape() {
let options = vec![
SelectOption::new("alpha", "Alpha"),
SelectOption::new("beta", "Beta"),
];
let mut state = SelectOptionFilterState::new().with_empty_label("No matching options");
let outcome = state.handle_event(&options, &UiInputEvent::TextInput("zz".to_string()));
assert!(outcome.query_changed);
assert_eq!(state.query, "zz");
assert_eq!(
state.empty_state(&options),
Some(SelectOptionFilterEmptyState {
query: "zz".to_string(),
label: "No matching options".to_string(),
})
);
assert_eq!(state.accessibility_value(&options), "No matching options");
let outcome = state.handle_event(
&options,
&UiInputEvent::Key {
key: KeyCode::Backspace,
modifiers: KeyModifiers::NONE,
},
);
assert!(outcome.query_changed);
assert_eq!(state.query, "z");
state.set_query("beta", &options);
let outcome = state.handle_event(
&options,
&UiInputEvent::Key {
key: KeyCode::Enter,
modifiers: KeyModifiers::NONE,
},
);
assert_eq!(
outcome.selected,
Some(SelectSelection {
index: 1,
id: "beta".to_string(),
})
);
let outcome = state.handle_event(
&options,
&UiInputEvent::Key {
key: KeyCode::Escape,
modifiers: KeyModifiers::NONE,
},
);
assert!(outcome.closed);
}
#[test]
fn searchable_select_state_composes_open_filter_select_and_dismiss_outcomes() {
let options = vec![
SelectOption::new("alpha", "Alpha"),
SelectOption::new("alpine", "Alpine").disabled(),
SelectOption::new("atlas", "Atlas"),
SelectOption::new("beta", "Beta"),
];
let mut state = SearchableSelectState::new();
let outcome = state.handle_event(
&options,
&UiInputEvent::Key {
key: KeyCode::ArrowDown,
modifiers: KeyModifiers::NONE,
},
);
assert!(outcome.opened);
assert_eq!(state.filter.active_match, Some(0));
let outcome = state.handle_event(&options, &UiInputEvent::TextInput("a".to_string()));
assert!(outcome.query_changed);
assert_eq!(state.filter.query, "a");
assert_eq!(state.filter.active_match, Some(0));
let outcome = state.handle_event(
&options,
&UiInputEvent::Key {
key: KeyCode::ArrowDown,
modifiers: KeyModifiers::NONE,
},
);
assert_eq!(outcome.active_match, Some(2));
let outcome = state.handle_event(
&options,
&UiInputEvent::Key {
key: KeyCode::Enter,
modifiers: KeyModifiers::NONE,
},
);
assert_eq!(
outcome.selected,
Some(SelectSelection {
index: 2,
id: "atlas".to_string(),
})
);
assert_eq!(
outcome.close_reason,
Some(SearchableSelectCloseReason::Selection)
);
assert_eq!(state.selected, Some(2));
assert!(!state.open);
state.open(&options);
state.filter.set_query("alp", &options);
let outcome = state.handle_event(
&options,
&UiInputEvent::Key {
key: KeyCode::Escape,
modifiers: KeyModifiers::NONE,
},
);
assert!(outcome.query_changed);
assert!(state.open);
assert_eq!(state.filter.query, "");
let outcome = state.handle_event(
&options,
&UiInputEvent::Key {
key: KeyCode::Escape,
modifiers: KeyModifiers::NONE,
},
);
assert!(outcome.closed);
assert_eq!(
outcome.close_reason,
Some(SearchableSelectCloseReason::Escape)
);
assert!(!state.open);
state.open(&options);
let outcome = state.handle_outside_dismiss();
assert!(outcome.closed);
assert_eq!(
outcome.close_reason,
Some(SearchableSelectCloseReason::Outside)
);
assert!(!state.open);
}
#[test]
fn searchable_select_contract_exports_bounded_rows_and_accessibility_metadata() {
let options = (0..6)
.map(|index| SelectOption::new(format!("id-{index}"), format!("Item {index}")))
.collect::<Vec<_>>();
let mut state = SearchableSelectState::with_selected(4);
state.open(&options);
let contract = searchable_select_contract(
"fabricad.filter",
&options,
&state,
SearchableSelectSpec::new()
.max_visible_rows(3)
.placeholder("Choose")
.accessibility_label("Fabricad filter")
.search_label("Filter options")
.list_label("Filter results"),
);
assert!(contract.open);
assert_eq!(contract.selected_id.as_deref(), Some("id-4"));
assert_eq!(contract.match_count, 6);
assert_eq!(contract.visible_range, 3..6);
assert_eq!(contract.rows.len(), 3);
assert_eq!(
contract.active_descendant_id.as_deref(),
Some("fabricad.filter.option.4")
);
assert_eq!(
contract.trigger_accessibility.role,
AccessibilityRole::ComboBox
);
assert_eq!(
contract.trigger_accessibility.label.as_deref(),
Some("Fabricad filter")
);
assert_eq!(
contract.trigger_accessibility.value.as_deref(),
Some("Item 4")
);
assert_eq!(contract.trigger_accessibility.expanded, Some(true));
assert_eq!(
contract.search_accessibility.role,
AccessibilityRole::SearchBox
);
assert_eq!(
contract.search_accessibility.label.as_deref(),
Some("Filter options")
);
assert_eq!(contract.listbox_accessibility.role, AccessibilityRole::List);
assert_eq!(
contract.listbox_accessibility.value.as_deref(),
Some("6 options available")
);
assert_eq!(contract.rows[1].option_index, 4);
assert!(contract.rows[1].active);
assert!(contract.rows[1].selected);
assert_eq!(
contract.rows[1].accessibility.hint.as_deref(),
Some("Active option")
);
}
#[test]
fn select_menu_builds_scrollable_renderer_neutral_rows() {
let mut document = UiDocument::new(root_style(320.0, 240.0));
let root = document.root;
let options = (0..6)
.map(|index| SelectOption::new(format!("id-{index}"), format!("Item {index}")))
.collect::<Vec<_>>();
let state = SelectMenuState {
open: true,
selected: Some(1),
active: Some(2),
};
let nodes = select_menu(
&mut document,
root,
"select",
&options,
&state,
SelectMenuOptions {
max_visible_rows: 3,
row_height: 20.0,
..Default::default()
},
);
assert_eq!(nodes.rows.len(), 6);
assert_eq!(
document.node(nodes.root).scroll.unwrap().axes,
ScrollAxes::VERTICAL
);
assert!(document.node(nodes.rows[2]).input.focusable);
}
#[test]
fn select_menu_exports_accessible_rows_images_and_active_shader() {
let mut document = UiDocument::new(root_style(320.0, 240.0));
let root = document.root;
let options = vec![
SelectOption::new("compact", "Compact").image_key("icons.compact"),
SelectOption::new("comfortable", "Comfortable")
.accessibility_label("Comfortable density"),
];
let state = SelectMenuState {
open: true,
selected: Some(0),
active: Some(1),
};
let nodes = select_menu(
&mut document,
root,
"density",
&options,
&state,
SelectMenuOptions {
accessibility_label: Some("Density choices".to_string()),
active_shader: Some(ShaderEffect::new("ui.option.active")),
..Default::default()
},
);
let root_accessibility = document.node(nodes.root).accessibility.as_ref().unwrap();
assert_eq!(root_accessibility.role, AccessibilityRole::List);
assert_eq!(root_accessibility.label.as_deref(), Some("Density choices"));
assert_eq!(
root_accessibility.relations.active_descendant,
Some(nodes.rows[1])
);
let first_row = document.node(nodes.rows[0]);
let first_accessibility = first_row.accessibility.as_ref().unwrap();
assert_eq!(first_accessibility.value.as_deref(), Some("selected"));
assert_eq!(first_accessibility.selected, Some(true));
assert!(first_row.children.iter().any(|child| matches!(
&document.node(*child).content,
UiContent::Image(image) if image.key == "icons.compact"
)));
let active_accessibility = document.node(nodes.rows[1]).accessibility.as_ref().unwrap();
assert_eq!(active_accessibility.selected, Some(false));
assert_eq!(
active_accessibility.label.as_deref(),
Some("Comfortable density")
);
assert_eq!(
document.node(nodes.rows[1]).shader.as_ref().unwrap().key,
"ui.option.active"
);
}
#[test]
fn nested_menu_selection_returns_index_path_and_id() {
let items = vec![MenuItem::submenu(
"file",
"File",
vec![
MenuItem::command("new", "New"),
MenuItem::separator(),
MenuItem::command("open", "Open").shortcut("Ctrl+O"),
],
)];
assert_eq!(
menu_selection_at_path(&items, &[0, 2]),
Some(MenuSelection {
id: Some("open".to_string()),
index_path: vec![0, 2],
})
);
assert!(menu_selection_at_path(&items, &[0, 1]).is_none());
assert_eq!(
next_navigable_index(&items, None, NavigationDirection::Next),
Some(0)
);
}
#[test]
fn nested_menu_navigation_opens_submenus_and_selects_index_path() {
let items = vec![MenuItem::submenu(
"file",
"File",
vec![
MenuItem::command("new", "New"),
MenuItem::separator(),
MenuItem::submenu(
"recent",
"Recent",
vec![
MenuItem::command("recent-a", "Recent A").disabled(),
MenuItem::command("recent-b", "Recent B"),
],
),
],
)];
let mut state = MenuNavigationState::new();
assert_eq!(state.open_root(&items), Some(vec![0]));
let outcome = state.handle_event(
&items,
&UiInputEvent::Key {
key: KeyCode::ArrowRight,
modifiers: KeyModifiers::NONE,
},
);
assert!(outcome.opened_submenu);
assert_eq!(outcome.active_path, Some(vec![0, 0]));
let outcome = state.handle_event(
&items,
&UiInputEvent::Key {
key: KeyCode::ArrowDown,
modifiers: KeyModifiers::NONE,
},
);
assert_eq!(outcome.active_path, Some(vec![0, 2]));
let outcome = state.handle_event(
&items,
&UiInputEvent::Key {
key: KeyCode::ArrowRight,
modifiers: KeyModifiers::NONE,
},
);
assert!(outcome.opened_submenu);
assert_eq!(outcome.active_path, Some(vec![0, 2, 1]));
let outcome = state.handle_event(
&items,
&UiInputEvent::Key {
key: KeyCode::Enter,
modifiers: KeyModifiers::NONE,
},
);
assert_eq!(
outcome.selected,
Some(MenuSelection {
id: Some("recent-b".to_string()),
index_path: vec![0, 2, 1],
})
);
assert!(outcome.closed);
assert!(state.active_path.is_empty());
}
#[test]
fn nested_menu_navigation_typeahead_scopes_to_current_menu_level() {
let items = vec![
MenuItem::submenu(
"file",
"File",
vec![
MenuItem::command("new", "New"),
MenuItem::command("open", "Open"),
],
),
MenuItem::command("view", "View"),
];
let mut state = MenuNavigationState::new();
assert_eq!(state.open_root(&items), Some(vec![0]));
state.handle_event(
&items,
&UiInputEvent::Key {
key: KeyCode::ArrowRight,
modifiers: KeyModifiers::NONE,
},
);
let outcome = state.handle_event(&items, &UiInputEvent::TextInput("o".to_string()));
assert_eq!(outcome.active_path, Some(vec![0, 1]));
let outcome = state.handle_event(
&items,
&UiInputEvent::Key {
key: KeyCode::ArrowLeft,
modifiers: KeyModifiers::NONE,
},
);
assert!(outcome.closed_submenu);
assert_eq!(outcome.active_path, Some(vec![0]));
let outcome = state.handle_event(&items, &UiInputEvent::TextInput("v".to_string()));
assert_eq!(outcome.active_path, Some(vec![1]));
}
#[test]
fn menu_list_exports_accessible_rich_rows_and_active_shader() {
let mut document = UiDocument::new(root_style(360.0, 240.0));
let root = document.root;
let items = vec![
MenuItem::check("snap", "Snap to Grid", true).image_key("icons.grid"),
MenuItem::command("delete", "Delete")
.shortcut("Del")
.destructive()
.accessibility_label("Delete selection"),
MenuItem::submenu(
"arrange",
"Arrange",
vec![MenuItem::command("front", "Bring to Front")],
),
];
let nodes = menu_list(
&mut document,
root,
"context",
&items,
Some(1),
MenuListOptions {
accessibility_label: Some("Context actions".to_string()),
active_shader: Some(ShaderEffect::new("ui.menu.active")),
..Default::default()
},
);
let root_accessibility = document.node(nodes.root).accessibility.as_ref().unwrap();
assert_eq!(root_accessibility.role, AccessibilityRole::Menu);
assert_eq!(root_accessibility.label.as_deref(), Some("Context actions"));
assert_eq!(
root_accessibility.relations.active_descendant,
Some(nodes.rows[1])
);
let check_accessibility = document.node(nodes.rows[0]).accessibility.as_ref().unwrap();
assert_eq!(check_accessibility.role, AccessibilityRole::MenuItem);
assert_eq!(check_accessibility.value.as_deref(), Some("checked"));
assert!(document
.node(nodes.rows[0])
.children
.iter()
.any(|child| matches!(
&document.node(*child).content,
UiContent::Image(image) if image.key == "icons.grid"
)));
let delete_accessibility = document.node(nodes.rows[1]).accessibility.as_ref().unwrap();
assert_eq!(
delete_accessibility.label.as_deref(),
Some("Delete selection")
);
assert_eq!(
delete_accessibility.hint.as_deref(),
Some("Destructive action")
);
assert_eq!(
document.node(nodes.rows[1]).shader.as_ref().unwrap().key,
"ui.menu.active"
);
assert!(document
.node(nodes.rows[1])
.children
.iter()
.any(|child| matches!(
&document.node(*child).content,
UiContent::Text(text) if text.text == "Del"
)));
let submenu_accessibility = document.node(nodes.rows[2]).accessibility.as_ref().unwrap();
assert_eq!(submenu_accessibility.hint.as_deref(), Some("Opens submenu"));
}
#[test]
fn context_menu_keyboard_outcome_selects_ids_not_commands() {
let items = vec![
MenuItem::separator(),
MenuItem::command("copy", "Copy"),
MenuItem::command("paste", "Paste").disabled(),
];
let mut state = ContextMenuState::open_at(UiPoint::new(20.0, 30.0));
let outcome = state.handle_event(
&items,
&UiInputEvent::Key {
key: KeyCode::ArrowDown,
modifiers: KeyModifiers::NONE,
},
);
assert_eq!(outcome.active, Some(1));
let outcome = state.handle_event(
&items,
&UiInputEvent::Key {
key: KeyCode::Enter,
modifiers: KeyModifiers::NONE,
},
);
assert_eq!(
outcome.selected,
Some(MenuSelection {
id: Some("copy".to_string()),
index_path: vec![1],
})
);
assert!(outcome.closed);
}
#[test]
fn context_menu_state_opens_from_pointer_and_keyboard_requests() {
let items = vec![
MenuItem::separator(),
MenuItem::command("copy", "Copy"),
MenuItem::command("paste", "Paste"),
];
let anchor = UiRect::new(12.0, 18.0, 40.0, 20.0);
let pointer = RawPointerEvent::new(
PointerEventKind::Down(PointerButton::Secondary),
UiPoint::new(32.0, 44.0),
7,
);
let mut state = ContextMenuState::closed();
let opened = state.open_from_pointer_event(
UiNodeId(4),
anchor,
pointer,
HelpItemState::ENABLED,
&items,
);
assert!(opened.menu.opened);
assert_eq!(opened.menu.active, Some(1));
assert_eq!(opened.resolution.suppressed_reason, None);
assert_eq!(state.anchor, UiPoint::new(32.0, 44.0));
assert_eq!(state.active, Some(1));
let primary = RawPointerEvent::new(
PointerEventKind::Down(PointerButton::Primary),
UiPoint::new(18.0, 22.0),
8,
);
let ignored = state.open_from_pointer_event(
UiNodeId(4),
anchor,
primary,
HelpItemState::ENABLED,
&items,
);
assert!(!ignored.menu.opened);
assert_eq!(ignored.resolution.request, None);
let mut keyboard_state = ContextMenuState::closed();
let keyboard =
keyboard_state.open_from_keyboard(UiNodeId(4), anchor, HelpItemState::ENABLED, &items);
assert!(keyboard.menu.opened);
assert_eq!(keyboard.menu.active, Some(1));
assert!(keyboard_state.open);
let mut key_state = ContextMenuState::closed();
let key = key_state.open_from_key_event(
UiNodeId(4),
anchor,
KeyCode::F10,
KeyModifiers {
shift: true,
..KeyModifiers::NONE
},
HelpItemState::ENABLED,
&items,
);
assert!(key.menu.opened);
assert_eq!(key.menu.active, Some(1));
let ignored_key = key_state.open_from_key_event(
UiNodeId(4),
anchor,
KeyCode::F10,
KeyModifiers::NONE,
HelpItemState::ENABLED,
&items,
);
assert!(!ignored_key.menu.opened);
assert_eq!(ignored_key.resolution.request, None);
}
#[test]
fn context_menu_state_reports_suppressed_open_requests() {
let items = vec![MenuItem::command("copy", "Copy")];
let mut state = ContextMenuState::closed();
let disabled = state.open_from_keyboard(
UiNodeId(4),
UiRect::new(10.0, 10.0, 20.0, 20.0),
HelpItemState::disabled(),
&items,
);
assert!(!disabled.menu.opened);
assert!(!state.open);
assert_eq!(
disabled.resolution.suppressed_reason,
Some(ContextMenuSuppressedReason::Disabled)
);
}
#[test]
fn menu_command_helpers_return_typed_command_ids_and_shortcuts() {
let mut registry = CommandRegistry::new();
registry
.register(Command::new(
CommandMeta::new("file.save", "Save Project")
.description("Save the current project")
.category("File"),
))
.unwrap();
registry
.register(
Command::new(CommandMeta::new("file.export", "Export Audio").category("File"))
.disabled("No mixdown target"),
)
.unwrap();
registry
.bind_shortcut(CommandScope::Global, Shortcut::ctrl('s'), "file.save")
.unwrap();
let formatter = ShortcutFormatter::default();
let save = menu_item_from_command(
®istry,
"file.save",
&[CommandScope::Workspace],
&formatter,
)
.unwrap();
assert_eq!(save.id.as_deref(), Some("file.save"));
assert_eq!(save.shortcut.as_deref(), Some("Ctrl+S"));
assert!(save.enabled);
let export = menu_item_from_command(
®istry,
"file.export",
&[CommandScope::Workspace],
&formatter,
)
.unwrap();
assert_eq!(export.id.as_deref(), Some("file.export"));
assert!(!export.enabled);
let items = vec![save, export];
let selection = menu_command_selection_at_path(&items, &[0]).unwrap();
assert_eq!(selection.command, CommandId::from("file.save"));
assert_eq!(selection.index_path, vec![0]);
assert!(menu_command_selection_at_path(&items, &[1]).is_none());
let outcome = MenuOutcome {
selected: Some(MenuSelection {
id: Some("file.save".to_string()),
index_path: vec![0],
}),
..Default::default()
};
assert_eq!(
outcome.selected_command().unwrap().command,
CommandId::from("file.save")
);
}
#[test]
fn context_menu_typeahead_wraps_and_skips_disabled_items() {
let items = vec![
MenuItem::command("copy", "Copy"),
MenuItem::command("paste", "Paste").disabled(),
MenuItem::command("prefs", "Preferences"),
MenuItem::command("save", "Save"),
];
let mut state = ContextMenuState::open_at(UiPoint::new(20.0, 30.0));
let outcome = state.handle_event(
&items,
&UiInputEvent::Key {
key: KeyCode::Character('p'),
modifiers: KeyModifiers::NONE,
},
);
assert_eq!(outcome.active, Some(2));
let outcome = state.handle_event(&items, &UiInputEvent::TextInput("c".to_string()));
assert_eq!(outcome.active, Some(0));
assert_eq!(state.active, Some(0));
}
#[test]
fn menu_bar_state_skips_disabled_menus_and_selects_active_item() {
let menus = vec![
MenuBarMenu::new("file", "File", vec![MenuItem::command("new", "New")]),
MenuBarMenu::new("edit", "Edit", vec![MenuItem::command("undo", "Undo")]).disabled(),
MenuBarMenu::new("view", "View", vec![MenuItem::check("grid", "Grid", true)]),
];
let mut state = MenuBarState::default();
assert!(state.open(&menus, 0));
assert_eq!(state.move_menu(&menus, NavigationDirection::Next), Some(2));
assert_eq!(
state.select_active(&menus),
Some(MenuSelection {
id: Some("grid".to_string()),
index_path: vec![2, 0],
})
);
}
#[test]
fn menu_bar_exports_menubar_and_menuitem_accessibility() {
let mut document = UiDocument::new(root_style(480.0, 80.0));
let root = document.root;
let menus = vec![
MenuBarMenu::new("file", "File", vec![MenuItem::command("new", "New")]),
MenuBarMenu::new("edit", "Edit", vec![MenuItem::command("undo", "Undo")]).disabled(),
];
let state = MenuBarState {
open_menu: Some(0),
active_item: Some(0),
};
let nodes = menu_bar(
&mut document,
root,
"main-menu",
&menus,
&state,
None,
MenuBarOptions::default(),
);
let root_accessibility = document.node(nodes.root).accessibility.as_ref().unwrap();
assert_eq!(root_accessibility.role, AccessibilityRole::MenuBar);
assert_eq!(root_accessibility.label.as_deref(), Some("main-menu"));
assert_eq!(
root_accessibility.relations.active_descendant,
Some(nodes.buttons[0])
);
let file_accessibility = document
.node(nodes.buttons[0])
.accessibility
.as_ref()
.unwrap();
assert_eq!(file_accessibility.role, AccessibilityRole::MenuItem);
assert_eq!(file_accessibility.value.as_deref(), Some("open"));
assert!(file_accessibility.enabled);
assert!(file_accessibility.focusable);
let edit_accessibility = document
.node(nodes.buttons[1])
.accessibility
.as_ref()
.unwrap();
assert_eq!(edit_accessibility.value.as_deref(), Some("closed"));
assert!(!edit_accessibility.enabled);
}
#[test]
fn command_palette_filter_ranks_title_matches_and_selects_ids() {
let items = vec![
CommandPaletteItem::new("open", "Open File").keyword("recent"),
CommandPaletteItem::new("save", "Save Project").shortcut("Ctrl+S"),
CommandPaletteItem::new("export", "Export Audio").disabled(),
];
let matches = filter_command_palette(&items, "save", 10);
assert_eq!(matches[0].id, "save");
let mut state = CommandPaletteState::new().with_query("project");
state.active_match = first_enabled_palette_match(&items, &state.matches(&items));
assert_eq!(
state.select_active(&items),
Some(CommandPaletteSelection {
index: 1,
id: "save".to_string(),
})
);
}
#[test]
fn command_palette_helpers_build_from_registry_and_select_command_ids() {
let mut registry = CommandRegistry::new();
registry
.register(Command::new(
CommandMeta::new("file.save", "Save Project")
.description("Save the current project")
.category("File"),
))
.unwrap();
registry
.register(
Command::new(CommandMeta::new("edit.quantize", "Quantize Clip").category("Edit"))
.disabled("No clip selected"),
)
.unwrap();
registry
.bind_shortcut(CommandScope::Global, Shortcut::ctrl('s'), "file.save")
.unwrap();
let formatter = ShortcutFormatter::default();
let items = command_palette_items_from_registry(®istry, &[], &formatter);
assert_eq!(
items
.iter()
.map(|item| item.id.as_str())
.collect::<Vec<_>>(),
vec!["edit.quantize", "file.save"]
);
let save = items.iter().find(|item| item.id == "file.save").unwrap();
assert_eq!(save.shortcut.as_deref(), Some("Ctrl+S"));
assert_eq!(save.subtitle.as_deref(), Some("Save the current project"));
let quantize = items
.iter()
.find(|item| item.id == "edit.quantize")
.unwrap();
assert!(!quantize.enabled);
assert!(filter_command_palette(&items, "edit", 10)
.iter()
.any(|palette_match| palette_match.id == "edit.quantize"));
let selection = CommandPaletteSelection {
index: 1,
id: "file.save".to_string(),
};
assert_eq!(selection.command_id(), CommandId::from("file.save"));
let outcome = CommandPaletteOutcome {
selected: Some(selection),
..Default::default()
};
assert_eq!(
outcome.selected_command().unwrap().command,
CommandId::from("file.save")
);
}
#[test]
fn command_palette_state_accepts_search_field_clear_and_status() {
let items = vec![
CommandPaletteItem::new("open", "Open File"),
CommandPaletteItem::new("save", "Save Project"),
CommandPaletteItem::new("close", "Close Project"),
];
let mut field = SearchFieldState::new();
field.set_query("save", 25);
let mut state = CommandPaletteState::new();
let outcome = state.apply_search_field(&field, &items);
assert!(outcome.query_changed);
assert_eq!(state.query, "save");
assert_eq!(state.matches(&items).len(), 1);
assert_eq!(state.active_match, Some(0));
assert_eq!(
state.search_status(&items).text,
"1 command matches \"save\""
);
assert_eq!(
state.search_status_accessibility(&items).live_region,
AccessibilityLiveRegion::Polite
);
let outcome = state.clear_query(&items);
assert!(outcome.query_changed);
assert_eq!(state.query, "");
assert_eq!(state.active_match, Some(0));
assert_eq!(state.visible_count(&items), 3);
assert_eq!(state.search_status(&items).text, "3 commands available");
}
#[test]
fn command_palette_builder_creates_input_and_result_rows() {
let mut document = UiDocument::new(root_style(600.0, 400.0));
let root = document.root;
let items = vec![
CommandPaletteItem::new("open", "Open File")
.subtitle("Recent documents")
.image_key("icons.open")
.accessibility_label("Open a file"),
CommandPaletteItem::new("save", "Save Project"),
];
let mut state = CommandPaletteState::new().with_query("o");
state.active_match = Some(0);
let nodes = command_palette(
&mut document,
root,
"palette",
&items,
&state,
None,
CommandPaletteOptions {
accessibility_label: Some("Command palette".to_string()),
panel_shader: Some(ShaderEffect::new("ui.palette.panel")),
active_row_shader: Some(ShaderEffect::new("ui.palette.active")),
..Default::default()
},
);
document
.compute_layout(UiSize::new(600.0, 400.0), &mut ApproxTextMeasurer)
.expect("layout");
assert_eq!(nodes.rows.len(), 2);
assert!(document.node(nodes.input).input.focusable);
assert!(document.node(nodes.root).layout.rect.width > 0.0);
assert_eq!(
document
.node(nodes.root)
.accessibility
.as_ref()
.unwrap()
.label
.as_deref(),
Some("Command palette")
);
assert_eq!(
document.node(nodes.root).shader.as_ref().unwrap().key,
"ui.palette.panel"
);
assert_eq!(
document
.node(nodes.input)
.accessibility
.as_ref()
.unwrap()
.role,
AccessibilityRole::SearchBox
);
assert_eq!(
document
.node(nodes.input)
.accessibility
.as_ref()
.unwrap()
.actions[0]
.id,
"clear-search"
);
assert_eq!(
document
.node(nodes.input)
.accessibility
.as_ref()
.unwrap()
.relations
.active_descendant,
Some(nodes.rows[0])
);
let result_list = document.node(nodes.root).children[1];
assert_eq!(
document
.node(result_list)
.accessibility
.as_ref()
.unwrap()
.value
.as_deref(),
Some("2 commands match \"o\"")
);
assert_eq!(
document
.node(result_list)
.accessibility
.as_ref()
.unwrap()
.relations
.active_descendant,
Some(nodes.rows[0])
);
let active_row = document.node(nodes.rows[0]);
let active_accessibility = active_row.accessibility.as_ref().unwrap();
assert_eq!(active_accessibility.role, AccessibilityRole::ListItem);
assert_eq!(active_accessibility.label.as_deref(), Some("Open a file"));
assert_eq!(active_accessibility.value.as_deref(), Some("open"));
assert_eq!(active_row.shader.as_ref().unwrap().key, "ui.palette.active");
assert!(active_row.children.iter().any(|child| matches!(
&document.node(*child).content,
UiContent::Image(image) if image.key == "icons.open"
)));
}
#[test]
fn dropdown_select_can_build_trigger_without_popup_anchor() {
let mut document = UiDocument::new(root_style(320.0, 160.0));
let root = document.root;
let options = vec![
SelectOption::new("low", "Low"),
SelectOption::new("high", "High"),
];
let state = SelectMenuState::with_selected(1);
let nodes = dropdown_select(
&mut document,
root,
"quality",
&options,
&state,
None,
DropdownSelectOptions {
trigger_layout: LayoutStyle::from_taffy_style(Style {
size: TaffySize {
width: length(120.0),
height: length(30.0),
},
..Default::default()
}),
..Default::default()
},
);
assert!(document.node(nodes.trigger).input.focusable);
let accessibility = document.node(nodes.trigger).accessibility.as_ref().unwrap();
assert_eq!(accessibility.role, AccessibilityRole::ComboBox);
assert_eq!(accessibility.value.as_deref(), Some("High"));
assert!(nodes.popup.is_none());
let mut open_document = UiDocument::new(root_style(320.0, 160.0));
let open_root = open_document.root;
let open_state = SelectMenuState {
open: true,
selected: Some(1),
active: Some(0),
};
let open_nodes = dropdown_select(
&mut open_document,
open_root,
"quality-open",
&options,
&open_state,
Some(AnchoredPopup::new(
UiRect::new(10.0, 10.0, 120.0, 30.0),
UiRect::new(0.0, 0.0, 320.0, 160.0),
PopupPlacement::default(),
)),
DropdownSelectOptions::default(),
);
let popup = open_nodes.popup.as_ref().unwrap();
let trigger_accessibility = open_document
.node(open_nodes.trigger)
.accessibility
.as_ref()
.unwrap();
assert_eq!(
trigger_accessibility.relations.active_descendant,
Some(popup.rows[0])
);
}
}