use super::edit::TextEditState;
use super::range::{spawn_range_fill, spawn_range_thumb, spawn_range_track};
use super::text::{
apply_check_input_shape, default_input_node,
default_textarea_node, input_text_bundle, input_text_marker, input_text_node,
};
use super::{
AddInput, CheckboxMark, DisabledInput, InputCaret, InputClickState, InputField,
InputScrollOffset, InputSelection, InputType, InputViewport, RangeState, ToggleFill,
ToggleIndicator, UndoStack,
};
use crate::build_pending::UiBuildPending;
use crate::focus::{UiFocusable, hidden_outline};
use crate::interaction_style::UiDisabled;
use crate::style::{
apply_utility_patch, checkbox_border_color, input_selection_color,
resolve_classes_with_fallback, root_visual_styles_from_patch,
};
use crate::text::{AddText, control_typography, typography_from_patch};
use bevy::picking::Pickable;
use bevy::prelude::*;
use core::f32::consts::PI;
const DEFAULT_INPUT_CLASS: &str = "input-root";
const DEFAULT_TEXTAREA_CLASS: &str = "textarea-root";
const DEFAULT_RANGE_CLASS: &str = "input-range-root";
const DEFAULT_CHECKBOX_CLASS: &str = "checkbox-root";
const DEFAULT_RADIO_CLASS: &str = "radio-root";
fn checkbox_mark_stroke(
width: f32,
height: f32,
left: f32,
top: f32,
rotation: f32,
) -> impl Bundle {
(
CheckboxMark,
Pickable::IGNORE,
Node {
position_type: PositionType::Absolute,
width: Val::Px(width),
height: Val::Px(height),
left: Val::Px(left),
top: Val::Px(top),
..default()
},
UiTransform::from_rotation(Rot2::radians(rotation)),
BackgroundColor(crate::style::text_primary_color()),
)
}
pub(super) fn add_input(mut commands: Commands, query: Query<(Entity, &AddInput)>) {
for (entity, add_input) in query {
let add_input = add_input.clone();
let default_root_class = match add_input.input_type {
InputType::Range => DEFAULT_RANGE_CLASS,
InputType::Textarea => DEFAULT_TEXTAREA_CLASS,
InputType::Checkbox => DEFAULT_CHECKBOX_CLASS,
InputType::Radio => DEFAULT_RADIO_CLASS,
_ => DEFAULT_INPUT_CLASS,
};
let root_patch = resolve_classes_with_fallback(
default_root_class,
add_input.class.as_deref(),
"input root",
);
let root_styles = root_visual_styles_from_patch(&root_patch);
let mut root_node = match add_input.input_type {
InputType::Range => Node {
min_width: Val::Px(120.0),
flex_grow: 1.0,
padding: UiRect::ZERO,
border: UiRect::ZERO,
..default()
},
InputType::Textarea => default_textarea_node(add_input.size_chars, add_input.rows),
InputType::Checkbox | InputType::Radio => Node {
width: Val::Px(20.0),
height: Val::Px(20.0),
min_width: Val::Px(20.0),
min_height: Val::Px(20.0),
align_items: AlignItems::Center,
justify_content: JustifyContent::Center,
..default()
},
_ => default_input_node(add_input.size_chars),
};
if matches!(add_input.input_type, InputType::Checkbox | InputType::Radio) {
apply_check_input_shape(&mut root_node, add_input.input_type);
}
apply_utility_patch(&mut root_node, &root_patch);
commands
.entity(entity)
.queue_silenced(move |mut entity_commands: EntityWorldMut| {
let input_entity = entity_commands.id();
let normalized_value = if add_input.input_type == InputType::Range {
super::value::normalize_numeric_value(
&add_input.value,
add_input.min,
add_input.max,
add_input.step,
)
} else {
add_input.value.clone()
};
entity_commands.insert((
Name::new(add_input.name.clone()),
root_node,
Visibility::Visible,
BackgroundColor(if matches!(add_input.input_type, InputType::Checkbox | InputType::Radio) {
Color::WHITE
} else {
Color::NONE
}),
BorderColor::all(if matches!(add_input.input_type, InputType::Checkbox | InputType::Radio) {
checkbox_border_color()
} else {
Color::NONE
}),
InputField {
name: add_input.name.clone(),
input_type: add_input.input_type,
checked: add_input.checked,
placeholder: add_input.placeholder.clone(),
viewport_entity: None,
text_entity: None,
selection_entity: None,
caret_entity: None,
edit_state: TextEditState::with_text(normalized_value.clone()),
initial_value: normalized_value.clone(),
initial_checked: add_input.checked,
min: add_input.min,
max: add_input.max,
step: add_input.step,
caret_blink_resume_at: 0.0,
preferred_caret_x: None,
undo_stack: UndoStack::default(),
},
InputClickState::default(),
));
if add_input.input_type != InputType::Range {
entity_commands.insert(hidden_outline());
}
if let Some(styles) = root_styles.clone() {
entity_commands.insert(styles);
}
if add_input.input_type == InputType::Range {
entity_commands.world_scope(|world| {
let track = spawn_range_track(world, input_entity);
let fill = spawn_range_fill(world);
let thumb = spawn_range_thumb(world);
world.entity_mut(track).add_children(&[fill, thumb]);
world.entity_mut(input_entity).add_child(track);
world.entity_mut(input_entity).insert(RangeState {
track,
fill,
thumb,
drag_start_value: 0.0,
});
});
} else if matches!(add_input.input_type, InputType::Checkbox | InputType::Radio) {
entity_commands.world_scope(|world| {
let indicator = world
.spawn((
ToggleIndicator,
Pickable::IGNORE,
Node {
width: Val::Percent(100.0),
height: Val::Percent(100.0),
align_items: AlignItems::Center,
justify_content: JustifyContent::Center,
..default()
},
BackgroundColor(Color::NONE),
))
.id();
if add_input.input_type == InputType::Radio {
let fill = world
.spawn((
ToggleFill,
Pickable::IGNORE,
Node {
width: Val::Px(8.0),
height: Val::Px(8.0),
border_radius: BorderRadius::all(Val::Px(999.0)),
..default()
},
Visibility::Hidden,
BackgroundColor(crate::style::text_primary_color()),
))
.id();
world.entity_mut(indicator).add_child(fill);
} else {
let short_stroke = world
.spawn((
checkbox_mark_stroke(3.0, 8.0, 4.5, 8.5, -PI / 4.0),
Visibility::Hidden,
))
.id();
let long_stroke = world
.spawn((
checkbox_mark_stroke(3.0, 13.0, 9.5, 4.5, PI / 4.0),
Visibility::Hidden,
))
.id();
world.entity_mut(indicator).add_children(&[short_stroke, long_stroke]);
}
world.entity_mut(input_entity).add_child(indicator);
});
} else {
let text_patch = resolve_classes_with_fallback(
"input-text",
add_input.text_class.as_deref(),
"input text",
);
let mut text_node = input_text_node();
apply_utility_patch(&mut text_node, &text_patch);
let text_add_input = AddInput {
value: add_input.value.clone(),
..add_input.clone()
};
entity_commands.world_scope(|world| {
let selection_color = input_selection_color();
let viewport_entity = world
.spawn((
InputViewport,
Pickable::IGNORE,
Node {
position_type: PositionType::Relative,
width: Val::Percent(100.0),
height: Val::Percent(100.0),
min_width: Val::Px(0.0),
overflow: Overflow::clip(),
..default()
},
))
.id();
let selection_entity = world
.spawn((
InputSelection,
Pickable::IGNORE,
Visibility::Hidden,
Node {
position_type: PositionType::Absolute,
width: Val::Px(0.0),
height: Val::Px(0.0),
..default()
},
BackgroundColor(selection_color),
))
.id();
world.entity_mut(selection_entity).with_children(|_parent| {
});
let text_entity = world
.spawn((
input_text_marker(),
Pickable::IGNORE,
AddText {
size: text_patch
.font_size
.unwrap_or_else(crate::style::font_size_control),
..input_text_bundle(&text_add_input)
}
.typography(typography_from_patch(
&text_patch,
control_typography(),
)),
InputScrollOffset::default(),
text_node,
))
.id();
let caret_entity = world
.spawn((
InputCaret,
Pickable::IGNORE,
Visibility::Hidden,
Node {
position_type: PositionType::Absolute,
width: Val::Px(crate::style::input_caret_width()),
height: Val::Px(0.0),
..default()
},
BackgroundColor(crate::style::input_caret_color()),
))
.id();
world.entity_mut(viewport_entity).add_children(&[
selection_entity,
text_entity,
caret_entity,
]);
world.entity_mut(input_entity).add_child(viewport_entity);
let mut input = world
.get_mut::<InputField>(input_entity)
.expect("input just inserted");
input.viewport_entity = Some(viewport_entity);
input.text_entity = Some(text_entity);
input.selection_entity = Some(selection_entity);
input.caret_entity = Some(caret_entity);
});
}
if add_input.disabled {
entity_commands.insert((DisabledInput, UiDisabled));
} else {
if add_input.input_type != InputType::Range {
entity_commands.insert(UiFocusable);
}
}
entity_commands.observe(super::state::input_click);
entity_commands.observe(super::state::input_drag_start);
entity_commands.observe(super::state::input_drag);
entity_commands.observe(super::state::input_drag_end);
entity_commands
.remove::<AddInput>()
.remove::<UiBuildPending>();
});
}
}
pub(super) fn sync_toggle_visuals(
fields: Query<(&InputField, &Children), Or<(Changed<InputField>, Added<InputField>)>>,
indicators: Query<&Children, With<ToggleIndicator>>,
mut fills: Query<
(&mut Visibility, &mut Node, &mut BackgroundColor),
(With<ToggleFill>, Without<CheckboxMark>),
>,
mut marks: Query<
(&mut Visibility, &mut BackgroundColor),
(With<CheckboxMark>, Without<ToggleFill>),
>,
) {
for (field, children) in &fields {
if !field.is_toggle() {
continue;
}
for child in children.iter() {
let Ok(fill_children) = indicators.get(child) else {
continue;
};
for fill in fill_children.iter() {
if let Ok((mut visibility, _node, mut color)) = fills.get_mut(fill) {
*visibility = if field.checked {
Visibility::Visible
} else {
Visibility::Hidden
};
color.0 = crate::style::text_primary_color();
continue;
}
let Ok((mut visibility, mut color)) = marks.get_mut(fill) else {
continue;
};
*visibility = if field.checked {
Visibility::Visible
} else {
Visibility::Hidden
};
color.0 = crate::style::text_primary_color();
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn checkbox_builds_checkmark_strokes_instead_of_fill_dot() {
let mut app = App::new();
app.add_systems(Update, add_input);
let entity = app
.world_mut()
.spawn(AddInput {
input_type: InputType::Checkbox,
checked: true,
..Default::default()
})
.id();
app.update();
let children = app
.world()
.entity(entity)
.get::<Children>()
.expect("checkbox should spawn indicator child");
let indicator = children[0];
let indicator_children = app
.world()
.entity(indicator)
.get::<Children>()
.expect("indicator should have visual children");
let mark_count = indicator_children
.iter()
.filter(|child| app.world().entity(*child).contains::<CheckboxMark>())
.count();
let fill_count = indicator_children
.iter()
.filter(|child| app.world().entity(*child).contains::<ToggleFill>())
.count();
assert_eq!(mark_count, 2);
assert_eq!(fill_count, 0);
}
#[test]
fn radio_builds_round_fill_dot() {
let mut app = App::new();
app.add_systems(Update, add_input);
let entity = app
.world_mut()
.spawn(AddInput {
input_type: InputType::Radio,
checked: true,
..Default::default()
})
.id();
app.update();
let children = app
.world()
.entity(entity)
.get::<Children>()
.expect("radio should spawn indicator child");
let indicator = children[0];
let indicator_children = app
.world()
.entity(indicator)
.get::<Children>()
.expect("indicator should have visual children");
let fill = indicator_children[0];
let node = app
.world()
.entity(fill)
.get::<Node>()
.expect("radio fill should have node");
assert!(app.world().entity(fill).contains::<ToggleFill>());
assert_eq!(node.border_radius, BorderRadius::all(Val::Px(999.0)));
}
}