Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 23 additions & 15 deletions crates/bevy_feathers/src/controls/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ mod menu;
mod number_input;
mod radio;
mod scrollbar;
mod select;
mod slider;
mod text_input;
mod toggle_switch;
Expand All @@ -30,6 +31,7 @@ pub use menu::*;
pub use number_input::*;
pub use radio::*;
pub use scrollbar::*;
pub use select::*;
pub use slider::*;
pub use text_input::*;
pub use toggle_switch::*;
Expand All @@ -43,22 +45,28 @@ pub struct ControlsPlugin;

impl Plugin for ControlsPlugin {
fn build(&self, app: &mut bevy_app::App) {
// arbitrary split as too many for one set
Comment thread
gagnus marked this conversation as resolved.
app.add_plugins((
AlphaPatternPlugin,
ButtonPlugin,
CheckboxPlugin,
ColorPlanePlugin,
ColorSliderPlugin,
ColorSwatchPlugin,
DisclosureTogglePlugin,
ListViewPlugin,
MenuPlugin,
NumberInputPlugin,
RadioPlugin,
ScrollbarPlugin,
SliderPlugin,
TextInputPlugin,
ToggleSwitchPlugin,
(
ButtonPlugin,
CheckboxPlugin,
DisclosureTogglePlugin,
ListViewPlugin,
MenuPlugin,
NumberInputPlugin,
RadioPlugin,
ScrollbarPlugin,
SelectPlugin,
SliderPlugin,
TextInputPlugin,
ToggleSwitchPlugin,
),
(
AlphaPatternPlugin,
ColorPlanePlugin,
ColorSliderPlugin,
ColorSwatchPlugin,
),
));
}
}
292 changes: 292 additions & 0 deletions crates/bevy_feathers/src/controls/select.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,292 @@
use bevy_app::{Plugin, Update};
use bevy_camera::visibility::Visibility;
use bevy_ecs::{
component::Component,
entity::Entity,
hierarchy::{ChildOf, Children},
observer::On,
query::{Added, Changed, With, Without},
reflect::ReflectComponent,
system::{Commands, Query, ResMut},
};
use bevy_input_focus::{FocusCause, InputFocus, InputFocusVisible};
use bevy_reflect::{prelude::ReflectDefault, Reflect};
use bevy_scene::prelude::*;
use bevy_ui::{px, widget::Text, ComputedNode, Node, Selected};
use bevy_ui_widgets::{ListBox, ValueChange};

use super::{
FeathersListRow, FeathersListView, FeathersMenu, FeathersMenuButton, FeathersMenuPopup,
};
use crate::{display::caption, rounded_corners::RoundedCorners};

const SELECT_ROW_PX: f32 = 28.0;

/// Select control which spawns a menu popup with a list of string options
/// # Emitted events
/// * [`ValueChange<Entity>`](bevy_ui_widgets::ValueChange) when the selected option is changed.
#[derive(SceneComponent, Default, Clone)]
#[scene(FeathersSelectProps)]
#[derive(Reflect)]
#[reflect(Component, Default, Clone)]
pub struct FeathersSelect;

/// Entirely optional component to store a usize on a `FeathersListRow`
/// Added by [`list_rows_from_strings`] so there's a value
/// on a string based select you can use to work out which of the array
/// of strings was selected
#[derive(Component, Default, Clone, Copy, Reflect)]
#[reflect(Component, Default)]
pub struct OptionIndex(pub usize);

/// Convert an iterator of strings into `FeathersListRow` scenes with `OptionIndex`
/// on each one containing its index, optionally mark one selected
pub fn list_rows_from_strings(
options: impl IntoIterator<Item: AsRef<str>>,
selected: Option<usize>,
) -> Box<dyn SceneList> {
Box::new(
options
.into_iter()
.enumerate()
.map(|(i, label)| -> Box<dyn SceneList> {
let label: String = label.as_ref().into();
if Some(i) == selected {
bsn! { @FeathersListRow Selected OptionIndex(i) Children [ caption(label) ] }
.into()
} else {
bsn! { @FeathersListRow OptionIndex(i) Children [ caption(label) ] }.into()
}
})
.collect::<Vec<_>>(),
)
}

/// Marker for the caption which changes with selected item
#[derive(Component, Default, Clone, Reflect)]
#[reflect(Component, Default)]
struct SelectCaption;

/// Props for the control
pub struct FeathersSelectProps {
/// String options
pub options: Box<dyn SceneList>,
/// Corner roundedness
pub corners: RoundedCorners,
/// Maximum visible options before it scrolls
pub max_visible: usize,
}

impl Default for FeathersSelectProps {
fn default() -> Self {
Self {
options: Box::new(bsn_list!()),
corners: Default::default(),
max_visible: 8,
}
}
}

// Implements as a [`FeathersMenu`] under the hood with a row per option
impl FeathersSelect {
Comment thread
gagnus marked this conversation as resolved.
fn scene(props: FeathersSelectProps) -> impl Scene {
let max_visible = props.max_visible.max(1);
let max_height = px(max_visible as f32 * SELECT_ROW_PX);

bsn! {
@FeathersMenu
FeathersSelect
Children [
(
@FeathersMenuButton {
@caption: bsn! { caption("") SelectCaption },
@corners: {props.corners},
}
Node {
flex_grow: 1.0,
}
),
(
@FeathersMenuPopup
Children [
(
@FeathersListView {
@rows: {props.options}
}
Node {
max_height: {max_height},
}
)
]
)
]
}
}
}

fn on_select(
ev: On<ValueChange<Entity>>,
q_listbox: Query<(), With<ListBox>>,
q_select: Query<(), With<FeathersSelect>>,
q_parents: Query<&ChildOf>,
q_children: Query<&Children>,
q_rows: Query<(), With<FeathersListRow>>,
q_popup: Query<(), With<FeathersMenuPopup>>,
mut commands: Commands,
) {
if !q_listbox.contains(ev.source) {
return;
} // ignore our own re-emit

let row = ev.event().value;

let mut select_ent = None;
for ancestor in q_parents.iter_ancestors(row) {
if q_select.contains(ancestor) {
select_ent = Some(ancestor);
break;
}
}
let Some(select_ent) = select_ent else {
return;
};

// Close popup and mark the correct row option selected
// and others as not
for descendant in q_children.iter_descendants(select_ent) {
if q_rows.contains(descendant) {
if descendant == row {
commands.entity(descendant).insert(Selected);
} else {
commands.entity(descendant).remove::<Selected>();
}
} else if q_popup.contains(descendant) {
commands.entity(descendant).insert(Visibility::Hidden);
}
}

commands.trigger(ValueChange {
source: select_ent,
value: row,
is_final: true,
});
}

fn sync_caption(
q_newly_selected: Query<Entity, (Added<Selected>, With<FeathersListRow>)>,
q_parents: Query<&ChildOf>,
q_children: Query<&Children>,
q_text: Query<&Text, Without<SelectCaption>>,
q_select: Query<(), With<FeathersSelect>>,
mut q_caption: Query<&mut Text, With<SelectCaption>>,
) {
for row in q_newly_selected.iter() {
let Some(text) = q_children
.iter_descendants(row)
.find_map(|descendant| q_text.get(descendant).ok())
.map(|text| text.0.clone())
else {
continue;
};

let Some(select_ent) = q_parents
.iter_ancestors(row)
.find(|&ancestor| q_select.contains(ancestor))
else {
continue;
};

for descendant in q_children.iter_descendants(select_ent) {
if let Ok(mut caption) = q_caption.get_mut(descendant) {
if caption.0 != text {
caption.0 = text.clone();
}
break;
}
}
}
}

fn focus_select_popup(
q_popups: Query<(Entity, &Visibility), (With<FeathersMenuPopup>, Changed<Visibility>)>,
q_select: Query<(), With<FeathersSelect>>,
q_listbox: Query<(), With<ListBox>>,
q_button: Query<(), With<FeathersMenuButton>>,
q_parents: Query<&ChildOf>,
q_children: Query<&Children>,
mut focus: ResMut<InputFocus>,
mut focus_visible: ResMut<InputFocusVisible>,
) {
for (popup, visibility) in q_popups.iter() {
let mut select_ent = None;
for ancestor in q_parents.iter_ancestors(popup) {
if q_select.contains(ancestor) {
select_ent = Some(ancestor);
break;
}
}
let Some(select_ent) = select_ent else {
continue;
};

if *visibility == Visibility::Visible {
for descendant in q_children.iter_descendants(popup) {
if q_listbox.contains(descendant) {
focus.set(descendant, FocusCause::Navigated);
focus_visible.0 = true;
break;
}
}
} else {
let focus_in_select = focus.get().is_some_and(|focused| {
focused == select_ent || q_parents.iter_ancestors(focused).any(|a| a == select_ent)
});
if focus_in_select {
for descendant in q_children.iter_descendants(select_ent) {
if q_button.contains(descendant) {
focus.set(descendant, FocusCause::Navigated);
break;
}
}
}
}
}
}

fn sync_select_width(
q_selects: Query<(Entity, &ComputedNode), With<FeathersSelect>>,
q_children: Query<&Children>,
q_popup: Query<(), With<FeathersMenuPopup>>,
mut q_node: Query<&mut Node>,
) {
for (select_ent, computed) in q_selects.iter() {
let width = (computed.size().x * computed.inverse_scale_factor()).round();
if width <= 0.0 {
continue;
}
for descendant in q_children.iter_descendants(select_ent) {
if q_popup.contains(descendant) {
if let Ok(mut node) = q_node.get_mut(descendant) {
let target = px(width);
if node.min_width != target {
node.min_width = target;
}
}
break;
}
}
}
}

/// Plugin which runs the [`FeathersSelect`] control
pub struct SelectPlugin;

impl Plugin for SelectPlugin {
fn build(&self, app: &mut bevy_app::App) {
app.add_systems(
Update,
(sync_caption, focus_select_popup, sync_select_width),
);
app.add_observer(on_select);
}
}
3 changes: 2 additions & 1 deletion crates/bevy_feathers/src/controls/slider.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ use bevy_ui_widgets::{
use crate::{
constants::{fonts, size},
cursor::EntityCursor,
display::caption,
focus::FocusIndicator,
font_styles::InheritableFont,
rounded_corners::RoundedCorners,
Expand Down Expand Up @@ -122,7 +123,7 @@ impl FeathersSlider {
font_size: size::SMALL_FONT,
weight: FontWeight::NORMAL,
}
Children [(Text("10.0") ThemedText SliderValueText)]
Children [(caption("10.0") SliderValueText)]
)]
}
}
Expand Down
10 changes: 9 additions & 1 deletion crates/bevy_feathers/src/display/label.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,18 @@ use bevy_ui::widget::Text;

use crate::{
constants::{fonts, size},
theme::ThemeTextColor,
theme::{ThemeTextColor, ThemedText},
tokens,
};

/// A caption within, say, a button.
pub fn caption(text: impl Into<String>) -> impl Scene {
Comment thread
gagnus marked this conversation as resolved.
bsn! {
Text(text)
ThemedText
}
}

/// A text label.
pub fn label(text: impl Into<String>) -> impl Scene {
bsn! {
Expand Down
Loading
Loading