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, TagName};
use crate::widgets::widget_util::{
apply_overlay_state_for_bind, clear_active_scroll_target_for_entity,
first_child_logical_height, set_z_index_pair, wheel_delta_y,
};
use crate::widgets::{
ActiveScrollTarget, 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::MouseWheel;
use bevy::prelude::*;
use bevy::ui::RelativeCursorPosition;
const CHOICE_BOX_OVERLAY_ROOT_Z: i32 = 40_000;
const CHOICE_BOX_OVERLAY_CONTENT_Z: i32 = 40_001;
#[derive(Component)]
struct ChoiceBase;
#[derive(Component)]
struct ChoiceOptionBase;
#[derive(Component)]
struct SelectedOptionBase;
#[derive(Component)]
struct DropBase;
#[derive(Component)]
struct OverlayLabel;
#[derive(Component)]
pub 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,
))
.insert(GlobalZIndex::default())
.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,
RelativeCursorPosition::default(),
ChoiceLayoutBoxBase,
BindToID(id.0),
))
.observe(on_layout_cursor_entered)
.observe(on_layout_cursor_leave)
.insert(GlobalZIndex::default())
.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
.value
.as_str()
.map_or(false, |s| !s.is_empty())
{
choice_box.value.value.as_str() == option.value.as_str()
} 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 = FontSize::Px(10.0);
} 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 = FontSize::Px(14.0);
} else {
node.top = Val::Px(5.);
text_font.font_size = FontSize::Px(10.0);
}
}
for (_, style) in styles.styles.iter_mut() {
style.normal.top = Some(node.top);
style.normal.font_size = Some(text_font.font_size);
}
}
}
}
}
fn update_content_box_visibility(
mut query: Query<(&mut UIWidgetState, &UIGenID), (With<ChoiceBox>, Changed<UIWidgetState>)>,
mut root_query: Query<
(&mut ZIndex, &mut GlobalZIndex, &UIGenID),
(With<ChoiceBox>, Without<ChoiceLayoutBoxBase>),
>,
mut content_query: Query<
(&mut Visibility, &mut ZIndex, &mut GlobalZIndex, &BindToID),
(With<ChoiceLayoutBoxBase>, Without<ChoiceBox>),
>,
) {
for (mut state, gen_id) in query.iter_mut() {
if !state.disabled {
if !state.focused {
state.open = false;
}
} else {
state.open = false;
}
for (mut root_z, mut root_global_z, root_id) in root_query.iter_mut() {
if root_id.0 != gen_id.0 {
continue;
}
set_z_index_pair(
&mut root_z,
&mut root_global_z,
state.open,
CHOICE_BOX_OVERLAY_ROOT_Z,
);
}
apply_overlay_state_for_bind(
gen_id.0,
state.open,
CHOICE_BOX_OVERLAY_CONTENT_Z,
&mut content_query,
);
}
}
fn handle_scroll_events(
mut scroll_events: MessageReader<MouseWheel>,
active_scroll_target: Res<ActiveScrollTarget>,
mut layout_query: Query<
(
Entity,
&Visibility,
&Children,
&mut ScrollPosition,
&ComputedNode,
&RelativeCursorPosition,
),
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, cursor_pos) in
layout_query.iter_mut()
{
let is_visible = matches!(*visibility, Visibility::Visible | Visibility::Inherited);
if !is_visible || cursor_pos.normalized.is_none() {
continue;
}
if active_scroll_target.entity != Some(layout_entity) {
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 option_height = first_child_logical_height(layout_entity, &option_query, 1.0);
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 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_layout_cursor_entered(
trigger: On<Pointer<Over>>,
mut query: Query<&mut UIWidgetState, With<ChoiceLayoutBoxBase>>,
mut active_scroll_target: ResMut<ActiveScrollTarget>,
) {
if let Ok(mut state) = query.get_mut(trigger.entity) {
state.hovered = true;
active_scroll_target.entity = Some(trigger.entity);
}
}
fn on_layout_cursor_leave(
trigger: On<Pointer<Out>>,
mut query: Query<&mut UIWidgetState, With<ChoiceLayoutBoxBase>>,
mut active_scroll_target: ResMut<ActiveScrollTarget>,
) {
if let Ok(mut state) = query.get_mut(trigger.entity) {
state.hovered = false;
clear_active_scroll_target_for_entity(&mut active_scroll_target, trigger.entity);
}
}
fn set_option_hover_state(
entity: Entity,
hovered: bool,
query: &mut Query<(&mut UIWidgetState, &Children), With<ChoiceOptionBase>>,
inner_query: &mut Query<&mut UIWidgetState, Without<ChoiceOptionBase>>,
) {
if let Ok((mut state, children)) = query.get_mut(entity) {
state.hovered = hovered;
for child in children.iter() {
if let Ok(mut inner_state) = inner_query.get_mut(child) {
inner_state.hovered = hovered;
}
}
}
}
fn on_internal_option_click(
mut 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>)>,
mut active_scroll_target: ResMut<ActiveScrollTarget>,
) {
let clicked_entity = trigger.entity;
let (clicked_parent_id, clicked_option) =
if let Ok((_, _, option, bind_id, _)) = option_query.get(clicked_entity) {
(bind_id.0, option.clone())
} else {
return;
};
let clicked_option_text = clicked_option.text.clone();
let clicked_option_icon = clicked_option.icon_path.clone();
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 = clicked_option.clone();
parent_state.open = false;
active_scroll_target.entity = None;
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();
}
}
}
}
}
}
trigger.propagate(false);
}
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>>,
) {
set_option_hover_state(trigger.entity, true, &mut query, &mut inner_query);
}
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>>,
) {
set_option_hover_state(trigger.entity, false, &mut query, &mut inner_query);
}
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),
));
});
}