use crate::services::image_service::{DEFAULT_CHOICE_BOX_KEY, get_or_load_image};
use crate::styles::components::UiStyle;
use crate::styles::paint::Colored;
use crate::styles::{CssClass, CssSource, FontVal, TagName};
use crate::widgets::{
BindToID, ChoiceBox, ChoiceOption, IgnoreParentState, UIGenID, UIWidgetState, WidgetId,
WidgetKind,
};
use crate::{CurrentWidgetState, ExtendedUiConfiguration, ImageCache};
use bevy::camera::visibility::RenderLayers;
use bevy::ecs::relationship::RelatedSpawnerCommands;
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
use bevy::prelude::*;
#[derive(Component)]
struct ChoiceBase;
#[derive(Component)]
struct ChoiceOptionBase;
#[derive(Component)]
struct SelectedOptionBase;
#[derive(Component)]
struct DropBase;
#[derive(Component)]
struct OverlayLabel;
#[derive(Component)]
struct ChoiceLayoutBoxBase;
pub struct ChoiceBoxWidget;
impl Plugin for ChoiceBoxWidget {
fn build(&self, app: &mut App) {
app.add_systems(
Update,
(
update_content_box_visibility,
internal_node_creation_system,
handle_scroll_events,
handle_overlay_label,
)
.chain(),
);
}
}
fn internal_node_creation_system(
mut commands: Commands,
query: Query<
(Entity, &UIGenID, &ChoiceBox, Option<&CssSource>),
(With<ChoiceBox>, Without<ChoiceBase>),
>,
config: Res<ExtendedUiConfiguration>,
asset_server: Res<AssetServer>,
mut image_cache: ResMut<ImageCache>,
mut images: ResMut<Assets<Image>>,
) {
let layer = config.render_layers.first().unwrap_or(&1);
for (entity, id, choice_box, source_opt) in query.iter() {
let mut css_source = CssSource::default();
if let Some(source) = source_opt {
css_source = source.clone();
}
commands
.entity(entity)
.insert((
Name::new(format!("Choice-Box-{}", choice_box.entry)),
Node::default(),
WidgetId {
id: choice_box.entry,
kind: WidgetKind::ChoiceBox,
},
BackgroundColor::default(),
ImageNode::default(),
BorderColor::default(),
BoxShadow::new(
Colored::TRANSPARENT,
Val::Px(0.),
Val::Px(0.),
Val::Px(0.),
Val::Px(0.),
),
ZIndex::default(),
Pickable::default(),
css_source.clone(),
TagName("select".to_string()),
RenderLayers::layer(*layer),
ChoiceBase,
))
.observe(on_internal_click)
.observe(on_internal_cursor_entered)
.observe(on_internal_cursor_leave)
.with_children(|builder| {
builder.spawn((
Name::new(format!("Select-Label-{}", choice_box.entry)),
Node::default(),
Text::new(choice_box.label.clone()),
TextColor::default(),
TextLayout::default(),
TextFont::default(),
ZIndex::default(),
UIWidgetState::default(),
css_source.clone(),
CssClass(vec!["select-label".to_string()]),
Pickable::IGNORE,
RenderLayers::layer(*layer),
OverlayLabel,
BindToID(id.0),
));
generate_child_selected_option(
builder,
&css_source.clone(),
choice_box,
layer,
&id.0,
&mut *image_cache,
&mut images,
&asset_server,
);
builder
.spawn((
Name::new(format!("Choice-Content-{}", choice_box.entry)),
Node::default(),
BackgroundColor::default(),
ImageNode::default(),
BorderColor::default(),
BoxShadow::new(
Colored::TRANSPARENT,
Val::Px(0.),
Val::Px(0.),
Val::Px(0.),
Val::Px(0.),
),
ZIndex::default(),
UIWidgetState::default(),
css_source.clone(),
CssClass(vec![String::from("choice-content-box")]),
RenderLayers::layer(*layer),
Visibility::Hidden,
ChoiceLayoutBoxBase,
BindToID(id.0),
))
.insert(ScrollPosition::default())
.with_children(|builder| {
let mut selected_assigned = false;
for option in choice_box.options.iter() {
let is_selected = if !selected_assigned {
if !choice_box.value.internal_value.is_empty() {
choice_box.value.internal_value == option.internal_value
} else if !choice_box.value.text.is_empty() {
choice_box.value.text == option.text
} else {
false
}
} else {
false
};
if is_selected {
selected_assigned = true;
}
let state = UIWidgetState {
checked: is_selected,
..default()
};
builder
.spawn((
Name::new(format!("Option-{}", choice_box.entry)),
Node::default(),
BackgroundColor::default(),
ImageNode::default(),
BorderColor::default(),
ZIndex::default(),
state.clone(),
IgnoreParentState,
option.clone(),
css_source.clone(),
CssClass(vec![String::from("choice-option")]),
RenderLayers::layer(*layer),
ChoiceOptionBase,
BindToID(id.0),
))
.observe(on_internal_option_click)
.observe(on_internal_option_cursor_entered)
.observe(on_internal_option_cursor_leave)
.with_children(|builder| {
if let Some(icon_path) = option.icon_path.as_deref() {
let handle = get_or_load_image(
icon_path,
&mut image_cache,
&mut images,
&asset_server,
);
builder.spawn((
Name::new(format!("Option-Icon-{}", choice_box.entry)),
ImageNode {
image: handle,
..default()
},
ZIndex::default(),
state.clone(),
IgnoreParentState,
css_source.clone(),
CssClass(vec![
String::from("option-icon"),
String::from("option-text"),
]),
Pickable::IGNORE,
RenderLayers::layer(*layer),
BindToID(id.0),
));
}
let text;
if option.text.trim().is_empty() {
text = Text::new("Select...");
} else {
text = Text::new(option.text.clone());
}
builder.spawn((
Name::new(format!("Option-Text-{}", choice_box.entry)),
text,
TextColor::default(),
TextFont::default(),
TextLayout::default(),
ZIndex::default(),
state.clone(),
IgnoreParentState,
css_source.clone(),
CssClass(vec![String::from("option-text")]),
Pickable::IGNORE,
RenderLayers::layer(*layer),
BindToID(id.0),
));
});
}
});
});
}
}
fn handle_overlay_label(
query: Query<(&UIWidgetState, &UIGenID, &ChoiceBox, &Children), With<ChoiceBox>>,
mut label_query: Query<(&BindToID, &mut Node, &mut TextFont, &mut UiStyle), With<OverlayLabel>>,
) {
for (state, gen_id, choice_box, children) in query.iter() {
for child in children.iter() {
if let Ok((bind_to, mut node, mut text_font, mut styles)) = label_query.get_mut(child) {
if bind_to.0 != gen_id.0 {
continue;
}
if state.focused {
node.top = Val::Px(5.);
text_font.font_size = 10.;
} else {
if choice_box.value.text.is_empty() && choice_box.value.icon_path.is_none() {
node.top = Val::Px(19.5);
text_font.font_size = 14.;
} else {
node.top = Val::Px(5.);
text_font.font_size = 10.;
}
}
for (_, style) in styles.styles.iter_mut() {
style.normal.top = Some(node.top);
style.normal.font_size = Some(FontVal::Px(text_font.font_size));
}
}
}
}
}
fn update_content_box_visibility(
mut query: Query<(&mut UIWidgetState, &UIGenID), (With<ChoiceBox>, Changed<UIWidgetState>)>,
mut content_query: Query<(&mut Visibility, &BindToID), With<ChoiceLayoutBoxBase>>,
) {
for (mut state, gen_id) in query.iter_mut() {
for (mut visibility, bind_to_id) in content_query.iter_mut() {
if bind_to_id.0 != gen_id.0 {
continue;
}
if !state.disabled {
if !state.focused {
state.open = false;
}
} else {
state.open = false;
}
if state.open {
*visibility = Visibility::Inherited;
} else {
*visibility = Visibility::Hidden;
}
}
}
}
fn handle_scroll_events(
mut scroll_events: MessageReader<MouseWheel>,
mut layout_query: Query<
(
Entity,
&Visibility,
&Children,
&mut ScrollPosition,
&ComputedNode,
),
With<ChoiceLayoutBoxBase>,
>,
option_query: Query<(&ComputedNode, &ChildOf), With<ChoiceOptionBase>>,
time: Res<Time>,
) {
let smooth_factor = 30.0;
for event in scroll_events.read() {
for (layout_entity, visibility, children, mut scroll, layout_computed) in
layout_query.iter_mut()
{
let is_visible = matches!(*visibility, Visibility::Visible | Visibility::Inherited);
if !is_visible {
continue;
}
let inv_sf = layout_computed.inverse_scale_factor.max(f32::EPSILON);
let delta = -wheel_delta_y(event, inv_sf);
if children.len() <= 3 {
scroll.y = 0.0;
continue;
}
let mut option_height = None;
for (opt_computed, parent) in option_query.iter() {
if parent.parent() == layout_entity {
let opt_inv_sf = opt_computed.inverse_scale_factor.max(f32::EPSILON);
option_height = Some((opt_computed.size().y * opt_inv_sf).max(1.0));
break;
}
}
let option_h = option_height.unwrap_or(50.0);
let measured_viewport = (layout_computed.size().y * inv_sf).max(1.0);
let viewport_h = measured_viewport.min(option_h * 3.0);
let content_h = children.len() as f32 * option_h;
let max_scroll = (content_h - viewport_h).max(0.0);
let target = (scroll.y + delta).clamp(0.0, max_scroll);
let smoothed = scroll.y + (target - scroll.y) * smooth_factor * time.delta_secs();
scroll.y = smoothed.clamp(0.0, max_scroll);
}
}
}
fn wheel_delta_y(event: &MouseWheel, inv_scale_factor: f32) -> f32 {
match event.unit {
MouseScrollUnit::Line => {
let line_delta = event.y;
if line_delta.abs() > 10.0 {
line_delta * inv_scale_factor
} else {
line_delta * 25.0
}
}
MouseScrollUnit::Pixel => event.y * inv_scale_factor,
}
}
fn on_internal_click(
mut trigger: On<Pointer<Click>>,
mut query: Query<(&mut UIWidgetState, &UIGenID), With<ChoiceBox>>,
mut current_widget_state: ResMut<CurrentWidgetState>,
) {
if let Ok((mut state, gen_id)) = query.get_mut(trigger.entity) {
state.focused = true;
state.open = !state.open;
current_widget_state.widget_id = gen_id.0;
}
trigger.propagate(false);
}
fn on_internal_cursor_entered(
mut trigger: On<Pointer<Over>>,
mut query: Query<&mut UIWidgetState, With<ChoiceBox>>,
) {
if let Ok(mut state) = query.get_mut(trigger.entity) {
state.hovered = true;
}
trigger.propagate(false);
}
fn on_internal_cursor_leave(
mut trigger: On<Pointer<Out>>,
mut query: Query<&mut UIWidgetState, With<ChoiceBox>>,
) {
if let Ok(mut state) = query.get_mut(trigger.entity) {
state.hovered = false;
}
trigger.propagate(false);
}
fn on_internal_option_click(
trigger: On<Pointer<Click>>,
mut option_query: Query<
(
Entity,
&mut UIWidgetState,
&ChoiceOption,
&BindToID,
&Children,
),
(With<ChoiceOptionBase>, Without<ChoiceBox>),
>,
mut parent_query: Query<
(Entity, &mut UIWidgetState, &UIGenID, &mut ChoiceBox),
(With<ChoiceBox>, Without<ChoiceOptionBase>),
>,
mut selected_query: Query<(&BindToID, &Children), With<SelectedOptionBase>>,
mut text_query: Query<&mut Text>,
mut inner_query: Query<&mut UIWidgetState, (Without<ChoiceOptionBase>, Without<ChoiceBox>)>,
) {
let clicked_entity = trigger.entity;
let (clicked_parent_id, clicked_option_text, clicked_option_icon) =
if let Ok((_, _, option, bind_id, _)) = option_query.get(clicked_entity) {
(
bind_id.0.clone(),
option.text.clone(),
option.icon_path.clone(),
)
} else {
return;
};
for (entity, mut state, _, bind_id, children) in option_query.iter_mut() {
if bind_id.0 == clicked_parent_id {
state.checked = entity == clicked_entity;
for child in children.iter() {
if let Ok(mut inner_state) = inner_query.get_mut(child) {
inner_state.checked = state.checked;
}
}
}
}
for (_, mut parent_state, id, mut choice_box) in parent_query.iter_mut() {
if id.0 == clicked_parent_id {
choice_box.value.text = clicked_option_text.clone();
choice_box.value.icon_path = clicked_option_icon.clone();
parent_state.open = false;
if clicked_option_text.is_empty() && clicked_option_icon.is_none() {
if let Ok((_, mut state, _, _, _)) = option_query.get_mut(clicked_entity) {
state.focused = false;
}
}
for (bind_id, selected_children) in selected_query.iter_mut() {
if bind_id.0 == clicked_parent_id {
for child in selected_children.iter() {
if let Ok(mut text) = text_query.get_mut(child) {
text.0 = clicked_option_text.clone();
}
}
}
}
}
}
}
fn on_internal_option_cursor_entered(
trigger: On<Pointer<Over>>,
mut query: Query<(&mut UIWidgetState, &Children), With<ChoiceOptionBase>>,
mut inner_query: Query<&mut UIWidgetState, Without<ChoiceOptionBase>>,
) {
if let Ok((mut state, children)) = query.get_mut(trigger.entity) {
state.hovered = true;
for child in children.iter() {
if let Ok(mut inner_state) = inner_query.get_mut(child) {
inner_state.hovered = true;
}
}
}
}
fn on_internal_option_cursor_leave(
trigger: On<Pointer<Out>>,
mut query: Query<(&mut UIWidgetState, &Children), With<ChoiceOptionBase>>,
mut inner_query: Query<&mut UIWidgetState, Without<ChoiceOptionBase>>,
) {
if let Ok((mut state, children)) = query.get_mut(trigger.entity) {
state.hovered = false;
for child in children.iter() {
if let Ok(mut inner_state) = inner_query.get_mut(child) {
inner_state.hovered = false;
}
}
}
}
fn generate_child_selected_option(
builder: &mut RelatedSpawnerCommands<ChildOf>,
css_source: &CssSource,
choice_box: &ChoiceBox,
layer: &usize,
id: &usize,
image_cache: &mut ImageCache,
images: &mut ResMut<Assets<Image>>,
asset_server: &Res<AssetServer>,
) {
builder
.spawn((
Name::new(format!("Option-Selected-{}", choice_box.entry)),
Node::default(),
BackgroundColor::default(),
ImageNode::default(),
BorderColor::default(),
UIWidgetState::default(),
css_source.clone(),
CssClass(vec![String::from("option-selected")]),
RenderLayers::layer(*layer),
Pickable::IGNORE,
BindToID(*id),
SelectedOptionBase,
))
.with_children(|builder| {
builder.spawn((
Name::new(format!("Option-Sel-Text-{}", choice_box.entry)),
Text::new(choice_box.value.text.clone()),
TextColor::default(),
TextFont::default(),
TextLayout::default(),
ZIndex::default(),
UIWidgetState::default(),
IgnoreParentState,
css_source.clone(),
CssClass(vec![String::from("option-sel-text")]),
Pickable::IGNORE,
RenderLayers::layer(*layer),
BindToID(*id),
));
});
builder
.spawn((
Name::new(format!("Arrow-Box-{}", choice_box.entry)),
Node::default(),
BackgroundColor::default(),
ImageNode::default(),
BorderColor::default(),
UIWidgetState::default(),
css_source.clone(),
CssClass(vec![String::from("option-drop-box")]),
RenderLayers::layer(*layer),
Pickable::IGNORE,
BindToID(*id),
DropBase,
))
.with_children(|builder| {
let handle = get_or_load_image(
choice_box
.icon_path
.as_deref()
.unwrap_or(DEFAULT_CHOICE_BOX_KEY),
image_cache,
images,
&asset_server,
);
builder.spawn((
Name::new(format!("Drop-Icon-{}", choice_box.entry)),
ImageNode {
image: handle,
..default()
},
ZIndex::default(),
UIWidgetState::default(),
css_source.clone(),
CssClass(vec![String::from("option-drop-icon")]),
Pickable::IGNORE,
RenderLayers::layer(*layer),
BindToID(*id),
));
});
}