Bevy Feathers: an opinionated widget toolkit for building Bevy tooling (#19730)

# Objective

This PR introduces Bevy Feathers, an opinionated widget toolkit and
theming system intended for use by the Bevy Editor, World Inspector, and
other tools.

The `bevy_feathers` crate is incomplete and hidden behind an
experimental feature flag. The API is going to change significantly
before release.

---------

Co-authored-by: Alice Cecile <alice.i.cecile@gmail.com>
This commit is contained in:
Talin 2025-06-28 12:52:13 -07:00 committed by GitHub
parent fa9e303e61
commit 65bddbd3e4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
30 changed files with 1547 additions and 2 deletions

View File

@ -306,6 +306,9 @@ bevy_input_focus = ["bevy_internal/bevy_input_focus"]
# Headless widget collection for Bevy UI.
bevy_core_widgets = ["bevy_internal/bevy_core_widgets"]
# Feathers widget collection.
experimental_bevy_feathers = ["bevy_internal/bevy_feathers"]
# Enable passthrough loading for SPIR-V shaders (Only supported on Vulkan, shader capabilities and extensions must agree with the platform implementation)
spirv_shader_passthrough = ["bevy_internal/spirv_shader_passthrough"]
@ -4526,3 +4529,16 @@ name = "Core Widgets (w/Observers)"
description = "Demonstrates use of core (headless) widgets in Bevy UI, with Observers"
category = "UI (User Interface)"
wasm = true
[[example]]
name = "feathers"
path = "examples/ui/feathers.rs"
doc-scrape-examples = true
required-features = ["experimental_bevy_feathers"]
[package.metadata.example.feathers]
name = "Feathers Widgets"
description = "Gallery of Feathers Widgets"
category = "UI (User Interface)"
wasm = true
hidden = true

View File

@ -18,7 +18,7 @@ use bevy_ui::{InteractionDisabled, Pressed};
/// Headless button widget. This widget maintains a "pressed" state, which is used to
/// indicate whether the button is currently being pressed by the user. It emits a `ButtonClicked`
/// event when the button is un-pressed.
#[derive(Component, Debug)]
#[derive(Component, Default, Debug)]
#[require(AccessibilityNode(accesskit::Node::new(Role::Button)))]
pub struct CoreButton {
/// Optional system to run when the button is clicked, or when the Enter or Space key

View File

@ -92,7 +92,9 @@ pub struct SliderValue(pub f32);
#[derive(Component, Debug, PartialEq, Clone, Copy)]
#[component(immutable)]
pub struct SliderRange {
/// The beginning of the allowed range for the slider value.
start: f32,
/// The end of the allowed range for the slider value.
end: f32,
}

View File

@ -0,0 +1,40 @@
[package]
name = "bevy_feathers"
version = "0.17.0-dev"
edition = "2024"
description = "A collection of UI widgets for building editors and utilities in Bevy"
homepage = "https://bevyengine.org"
repository = "https://github.com/bevyengine/bevy"
license = "MIT OR Apache-2.0"
keywords = ["bevy"]
[dependencies]
# bevy
bevy_app = { path = "../bevy_app", version = "0.17.0-dev" }
bevy_asset = { path = "../bevy_asset", version = "0.17.0-dev" }
bevy_color = { path = "../bevy_color", version = "0.17.0-dev" }
bevy_core_widgets = { path = "../bevy_core_widgets", version = "0.17.0-dev" }
bevy_ecs = { path = "../bevy_ecs", version = "0.17.0-dev" }
bevy_input_focus = { path = "../bevy_input_focus", version = "0.17.0-dev" }
bevy_log = { path = "../bevy_log", version = "0.17.0-dev" }
bevy_picking = { path = "../bevy_picking", version = "0.17.0-dev" }
bevy_platform = { path = "../bevy_platform", version = "0.17.0-dev" }
bevy_text = { path = "../bevy_text", version = "0.17.0-dev" }
bevy_ui = { path = "../bevy_ui", version = "0.17.0-dev", features = [
"bevy_ui_picking_backend",
] }
bevy_window = { path = "../bevy_window", version = "0.17.0-dev" }
bevy_winit = { path = "../bevy_winit", version = "0.17.0-dev" }
# other
accesskit = "0.19"
[features]
default = []
[lints]
workspace = true
[package.metadata.docs.rs]
rustdoc-args = ["-Zunstable-options", "--generate-link-to-definition"]
all-features = true

View File

@ -0,0 +1,93 @@
Digitized data copyright (c) 2012-2015, The Mozilla Foundation and Telefonica S.A.
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

Binary file not shown.

View File

@ -0,0 +1,93 @@
Copyright (c) 2012-2015, The Mozilla Foundation and Telefonica S.A.
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

View File

@ -0,0 +1,23 @@
//! Various non-themable constants for the Feathers look and feel.
/// Font asset paths
pub mod fonts {
/// Default regular font path
pub const REGULAR: &str = "embedded://bevy_feathers/assets/fonts/FiraSans-Regular.ttf";
/// Regular italic font path
pub const ITALIC: &str = "embedded://bevy_feathers/assets/fonts/FiraSans-Italic.ttf";
/// Bold font path
pub const BOLD: &str = "embedded://bevy_feathers/assets/fonts/FiraSans-Bold.ttf";
/// Bold italic font path
pub const BOLD_ITALIC: &str = "embedded://bevy_feathers/assets/fonts/FiraSans-BoldItalic.ttf";
/// Monospace font path
pub const MONO: &str = "embedded://bevy_feathers/assets/fonts/FiraMono-Medium.ttf";
}
/// Size constants
pub mod size {
use bevy_ui::Val;
/// Common row size for buttons, sliders, spinners, etc.
pub const ROW_HEIGHT: Val = Val::Px(22.0);
}

View File

@ -0,0 +1,208 @@
use bevy_app::{Plugin, PreUpdate};
use bevy_core_widgets::CoreButton;
use bevy_ecs::{
bundle::Bundle,
component::Component,
entity::Entity,
hierarchy::{ChildOf, Children},
lifecycle::RemovedComponents,
query::{Added, Changed, Has, Or},
schedule::IntoScheduleConfigs,
spawn::{SpawnRelated, SpawnableList},
system::{Commands, Query, SystemId},
};
use bevy_input_focus::tab_navigation::TabIndex;
use bevy_picking::{hover::Hovered, PickingSystems};
use bevy_ui::{AlignItems, InteractionDisabled, JustifyContent, Node, Pressed, UiRect, Val};
use bevy_winit::cursor::CursorIcon;
use crate::{
constants::{fonts, size},
font_styles::InheritableFont,
handle_or_path::HandleOrPath,
rounded_corners::RoundedCorners,
theme::{ThemeBackgroundColor, ThemeFontColor},
tokens,
};
/// Color variants for buttons. This also functions as a component used by the dynamic styling
/// system to identify which entities are buttons.
#[derive(Component, Default, Clone)]
pub enum ButtonVariant {
/// The standard button appearance
#[default]
Normal,
/// A button with a more prominent color, this is used for "call to action" buttons,
/// default buttons for dialog boxes, and so on.
Primary,
}
/// Parameters for the button template, passed to [`button`] function.
#[derive(Default, Clone)]
pub struct ButtonProps {
/// Color variant for the button.
pub variant: ButtonVariant,
/// Rounded corners options
pub corners: RoundedCorners,
/// Click handler
pub on_click: Option<SystemId>,
}
/// Template function to spawn a button.
///
/// # Arguments
/// * `props` - construction properties for the button.
/// * `overrides` - a bundle of components that are merged in with the normal button components.
/// * `children` - a [`SpawnableList`] of child elements, such as a label or icon for the button.
pub fn button<C: SpawnableList<ChildOf> + Send + Sync + 'static, B: Bundle>(
props: ButtonProps,
overrides: B,
children: C,
) -> impl Bundle {
(
Node {
height: size::ROW_HEIGHT,
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
padding: UiRect::axes(Val::Px(8.0), Val::Px(0.)),
flex_grow: 1.0,
..Default::default()
},
CoreButton {
on_click: props.on_click,
},
props.variant,
Hovered::default(),
CursorIcon::System(bevy_window::SystemCursorIcon::Pointer),
TabIndex(0),
props.corners.to_border_radius(4.0),
ThemeBackgroundColor(tokens::BUTTON_BG),
ThemeFontColor(tokens::BUTTON_TEXT),
InheritableFont {
font: HandleOrPath::Path(fonts::REGULAR.to_owned()),
font_size: 14.0,
},
overrides,
Children::spawn(children),
)
}
fn update_button_styles(
q_buttons: Query<
(
Entity,
&ButtonVariant,
Has<InteractionDisabled>,
Has<Pressed>,
&Hovered,
&ThemeBackgroundColor,
&ThemeFontColor,
),
Or<(Changed<Hovered>, Added<Pressed>, Added<InteractionDisabled>)>,
>,
mut commands: Commands,
) {
for (button_ent, variant, disabled, pressed, hovered, bg_color, font_color) in q_buttons.iter()
{
set_button_colors(
button_ent,
variant,
disabled,
pressed,
hovered.0,
bg_color,
font_color,
&mut commands,
);
}
}
fn update_button_styles_remove(
q_buttons: Query<(
Entity,
&ButtonVariant,
Has<InteractionDisabled>,
Has<Pressed>,
&Hovered,
&ThemeBackgroundColor,
&ThemeFontColor,
)>,
mut removed_disabled: RemovedComponents<InteractionDisabled>,
mut removed_pressed: RemovedComponents<Pressed>,
mut commands: Commands,
) {
removed_disabled
.read()
.chain(removed_pressed.read())
.for_each(|ent| {
if let Ok((button_ent, variant, disabled, pressed, hovered, bg_color, font_color)) =
q_buttons.get(ent)
{
set_button_colors(
button_ent,
variant,
disabled,
pressed,
hovered.0,
bg_color,
font_color,
&mut commands,
);
}
});
}
fn set_button_colors(
button_ent: Entity,
variant: &ButtonVariant,
disabled: bool,
pressed: bool,
hovered: bool,
bg_color: &ThemeBackgroundColor,
font_color: &ThemeFontColor,
commands: &mut Commands,
) {
let bg_token = match (variant, disabled, pressed, hovered) {
(ButtonVariant::Normal, true, _, _) => tokens::BUTTON_BG_DISABLED,
(ButtonVariant::Normal, false, true, _) => tokens::BUTTON_BG_PRESSED,
(ButtonVariant::Normal, false, false, true) => tokens::BUTTON_BG_HOVER,
(ButtonVariant::Normal, false, false, false) => tokens::BUTTON_BG,
(ButtonVariant::Primary, true, _, _) => tokens::BUTTON_PRIMARY_BG_DISABLED,
(ButtonVariant::Primary, false, true, _) => tokens::BUTTON_PRIMARY_BG_PRESSED,
(ButtonVariant::Primary, false, false, true) => tokens::BUTTON_PRIMARY_BG_HOVER,
(ButtonVariant::Primary, false, false, false) => tokens::BUTTON_PRIMARY_BG,
};
let font_color_token = match (variant, disabled) {
(ButtonVariant::Normal, true) => tokens::BUTTON_TEXT_DISABLED,
(ButtonVariant::Normal, false) => tokens::BUTTON_TEXT,
(ButtonVariant::Primary, true) => tokens::BUTTON_PRIMARY_TEXT_DISABLED,
(ButtonVariant::Primary, false) => tokens::BUTTON_PRIMARY_TEXT,
};
// Change background color
if bg_color.0 != bg_token {
commands
.entity(button_ent)
.insert(ThemeBackgroundColor(bg_token));
}
// Change font color
if font_color.0 != font_color_token {
commands
.entity(button_ent)
.insert(ThemeFontColor(font_color_token));
}
}
/// Plugin which registers the systems for updating the button styles.
pub struct ButtonPlugin;
impl Plugin for ButtonPlugin {
fn build(&self, app: &mut bevy_app::App) {
app.add_systems(
PreUpdate,
(update_button_styles, update_button_styles_remove).in_set(PickingSystems::Last),
);
}
}

View File

@ -0,0 +1,17 @@
//! Meta-module containing all feathers controls (widgets that are interactive).
use bevy_app::Plugin;
mod button;
mod slider;
pub use button::{button, ButtonPlugin, ButtonProps, ButtonVariant};
pub use slider::{slider, SliderPlugin, SliderProps};
/// Plugin which registers all `bevy_feathers` controls.
pub struct ControlsPlugin;
impl Plugin for ControlsPlugin {
fn build(&self, app: &mut bevy_app::App) {
app.add_plugins((ButtonPlugin, SliderPlugin));
}
}

View File

@ -0,0 +1,209 @@
use core::f32::consts::PI;
use bevy_app::{Plugin, PreUpdate};
use bevy_color::Color;
use bevy_core_widgets::{CoreSlider, SliderRange, SliderValue, TrackClick};
use bevy_ecs::{
bundle::Bundle,
children,
component::Component,
entity::Entity,
hierarchy::Children,
lifecycle::RemovedComponents,
query::{Added, Changed, Has, Or, Spawned, With},
schedule::IntoScheduleConfigs,
spawn::SpawnRelated,
system::{In, Query, Res, SystemId},
};
use bevy_input_focus::tab_navigation::TabIndex;
use bevy_picking::PickingSystems;
use bevy_ui::{
widget::Text, AlignItems, BackgroundGradient, ColorStop, Display, FlexDirection, Gradient,
InteractionDisabled, InterpolationColorSpace, JustifyContent, LinearGradient, Node, UiRect,
Val,
};
use bevy_winit::cursor::CursorIcon;
use crate::{
constants::{fonts, size},
font_styles::InheritableFont,
handle_or_path::HandleOrPath,
rounded_corners::RoundedCorners,
theme::{ThemeFontColor, ThemedText, UiTheme},
tokens,
};
/// Slider template properties, passed to [`slider`] function.
#[derive(Clone)]
pub struct SliderProps {
/// Slider current value
pub value: f32,
/// Slider minimum value
pub min: f32,
/// Slider maximum value
pub max: f32,
/// On-change handler
pub on_change: Option<SystemId<In<f32>>>,
}
impl Default for SliderProps {
fn default() -> Self {
Self {
value: 0.0,
min: 0.0,
max: 1.0,
on_change: None,
}
}
}
#[derive(Component, Default, Clone)]
#[require(CoreSlider)]
struct SliderStyle;
/// Marker for the text
#[derive(Component, Default, Clone)]
struct SliderValueText;
/// Spawn a new slider widget.
///
/// # Arguments
///
/// * `props` - construction properties for the slider.
/// * `overrides` - a bundle of components that are merged in with the normal slider components.
pub fn slider<B: Bundle>(props: SliderProps, overrides: B) -> impl Bundle {
(
Node {
height: size::ROW_HEIGHT,
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
padding: UiRect::axes(Val::Px(8.0), Val::Px(0.)),
flex_grow: 1.0,
..Default::default()
},
CoreSlider {
on_change: props.on_change,
track_click: TrackClick::Drag,
},
SliderStyle,
SliderValue(props.value),
SliderRange::new(props.min, props.max),
CursorIcon::System(bevy_window::SystemCursorIcon::EwResize),
TabIndex(0),
RoundedCorners::All.to_border_radius(6.0),
// Use a gradient to draw the moving bar
BackgroundGradient(vec![Gradient::Linear(LinearGradient {
angle: PI * 0.5,
stops: vec![
ColorStop::new(Color::NONE, Val::Percent(0.)),
ColorStop::new(Color::NONE, Val::Percent(50.)),
ColorStop::new(Color::NONE, Val::Percent(50.)),
ColorStop::new(Color::NONE, Val::Percent(100.)),
],
color_space: InterpolationColorSpace::Srgb,
})]),
overrides,
children![(
// Text container
Node {
display: Display::Flex,
flex_direction: FlexDirection::Row,
align_items: AlignItems::Center,
justify_content: JustifyContent::Center,
..Default::default()
},
ThemeFontColor(tokens::SLIDER_TEXT),
InheritableFont {
font: HandleOrPath::Path(fonts::MONO.to_owned()),
font_size: 12.0,
},
children![(Text::new("10.0"), ThemedText, SliderValueText,)],
)],
)
}
fn update_slider_colors(
mut q_sliders: Query<
(Has<InteractionDisabled>, &mut BackgroundGradient),
(With<SliderStyle>, Or<(Spawned, Added<InteractionDisabled>)>),
>,
theme: Res<UiTheme>,
) {
for (disabled, mut gradient) in q_sliders.iter_mut() {
set_slider_colors(&theme, disabled, gradient.as_mut());
}
}
fn update_slider_colors_remove(
mut q_sliders: Query<(Has<InteractionDisabled>, &mut BackgroundGradient)>,
mut removed_disabled: RemovedComponents<InteractionDisabled>,
theme: Res<UiTheme>,
) {
removed_disabled.read().for_each(|ent| {
if let Ok((disabled, mut gradient)) = q_sliders.get_mut(ent) {
set_slider_colors(&theme, disabled, gradient.as_mut());
}
});
}
fn set_slider_colors(theme: &Res<'_, UiTheme>, disabled: bool, gradient: &mut BackgroundGradient) {
let bar_color = theme.color(match disabled {
true => tokens::SLIDER_BAR_DISABLED,
false => tokens::SLIDER_BAR,
});
let bg_color = theme.color(tokens::SLIDER_BG);
if let [Gradient::Linear(linear_gradient)] = &mut gradient.0[..] {
linear_gradient.stops[0].color = bar_color;
linear_gradient.stops[1].color = bar_color;
linear_gradient.stops[2].color = bg_color;
linear_gradient.stops[3].color = bg_color;
}
}
fn update_slider_pos(
mut q_sliders: Query<
(Entity, &SliderValue, &SliderRange, &mut BackgroundGradient),
(
With<SliderStyle>,
Or<(
Changed<SliderValue>,
Changed<SliderRange>,
Changed<Children>,
)>,
),
>,
q_children: Query<&Children>,
mut q_slider_text: Query<&mut Text, With<SliderValueText>>,
) {
for (slider_ent, value, range, mut gradient) in q_sliders.iter_mut() {
if let [Gradient::Linear(linear_gradient)] = &mut gradient.0[..] {
let percent_value = range.thumb_position(value.0) * 100.0;
linear_gradient.stops[1].point = Val::Percent(percent_value);
linear_gradient.stops[2].point = Val::Percent(percent_value);
}
// Find slider text child entity and update its text with the formatted value
q_children.iter_descendants(slider_ent).for_each(|child| {
if let Ok(mut text) = q_slider_text.get_mut(child) {
text.0 = format!("{}", value.0);
}
});
}
}
/// Plugin which registers the systems for updating the slider styles.
pub struct SliderPlugin;
impl Plugin for SliderPlugin {
fn build(&self, app: &mut bevy_app::App) {
app.add_systems(
PreUpdate,
(
update_slider_colors,
update_slider_colors_remove,
update_slider_pos,
)
.in_set(PickingSystems::Last),
);
}
}

View File

@ -0,0 +1,70 @@
//! Provides a way to automatically set the mouse cursor based on hovered entity.
use bevy_app::{App, Plugin, PreUpdate};
use bevy_ecs::{
entity::Entity,
hierarchy::ChildOf,
resource::Resource,
schedule::IntoScheduleConfigs,
system::{Commands, Query, Res},
};
use bevy_picking::{hover::HoverMap, pointer::PointerId, PickingSystems};
use bevy_window::Window;
use bevy_winit::cursor::CursorIcon;
/// A component that specifies the cursor icon to be used when the mouse is not hovering over
/// any other entity. This is used to set the default cursor icon for the window.
#[derive(Resource, Debug, Clone, Default)]
pub struct DefaultCursorIcon(pub CursorIcon);
/// System which updates the window cursor icon whenever the mouse hovers over an entity with
/// a [`CursorIcon`] component. If no entity is hovered, the cursor icon is set to
/// the cursor in the [`DefaultCursorIcon`] resource.
pub(crate) fn update_cursor(
mut commands: Commands,
hover_map: Option<Res<HoverMap>>,
parent_query: Query<&ChildOf>,
cursor_query: Query<&CursorIcon>,
mut q_windows: Query<(Entity, &mut Window, Option<&CursorIcon>)>,
r_default_cursor: Res<DefaultCursorIcon>,
) {
let cursor = hover_map.and_then(|hover_map| match hover_map.get(&PointerId::Mouse) {
Some(hover_set) => hover_set.keys().find_map(|entity| {
cursor_query.get(*entity).ok().or_else(|| {
parent_query
.iter_ancestors(*entity)
.find_map(|e| cursor_query.get(e).ok())
})
}),
None => None,
});
let mut windows_to_change: Vec<Entity> = Vec::new();
for (entity, _window, prev_cursor) in q_windows.iter_mut() {
match (cursor, prev_cursor) {
(Some(cursor), Some(prev_cursor)) if cursor == prev_cursor => continue,
(None, None) => continue,
_ => {
windows_to_change.push(entity);
}
}
}
windows_to_change.iter().for_each(|entity| {
if let Some(cursor) = cursor {
commands.entity(*entity).insert(cursor.clone());
} else {
commands.entity(*entity).insert(r_default_cursor.0.clone());
}
});
}
/// Plugin that supports automatically changing the cursor based on the hovered entity.
pub struct CursorIconPlugin;
impl Plugin for CursorIconPlugin {
fn build(&self, app: &mut App) {
if app.world().get_resource::<DefaultCursorIcon>().is_none() {
app.init_resource::<DefaultCursorIcon>();
}
app.add_systems(PreUpdate, update_cursor.in_set(PickingSystems::Last));
}
}

View File

@ -0,0 +1,53 @@
//! The standard `bevy_feathers` dark theme.
use crate::{palette, tokens};
use bevy_color::{Alpha, Luminance};
use bevy_platform::collections::HashMap;
use crate::theme::ThemeProps;
/// Create a [`ThemeProps`] object and populate it with the colors for the default dark theme.
pub fn create_dark_theme() -> ThemeProps {
ThemeProps {
color: HashMap::from([
(tokens::WINDOW_BG.into(), palette::GRAY_0),
(tokens::BUTTON_BG.into(), palette::GRAY_3),
(
tokens::BUTTON_BG_HOVER.into(),
palette::GRAY_3.lighter(0.05),
),
(
tokens::BUTTON_BG_PRESSED.into(),
palette::GRAY_3.lighter(0.1),
),
(tokens::BUTTON_BG_DISABLED.into(), palette::GRAY_2),
(tokens::BUTTON_PRIMARY_BG.into(), palette::ACCENT),
(
tokens::BUTTON_PRIMARY_BG_HOVER.into(),
palette::ACCENT.lighter(0.05),
),
(
tokens::BUTTON_PRIMARY_BG_PRESSED.into(),
palette::ACCENT.lighter(0.1),
),
(tokens::BUTTON_PRIMARY_BG_DISABLED.into(), palette::GRAY_2),
(tokens::BUTTON_TEXT.into(), palette::WHITE),
(
tokens::BUTTON_TEXT_DISABLED.into(),
palette::WHITE.with_alpha(0.5),
),
(tokens::BUTTON_PRIMARY_TEXT.into(), palette::WHITE),
(
tokens::BUTTON_PRIMARY_TEXT_DISABLED.into(),
palette::WHITE.with_alpha(0.5),
),
(tokens::SLIDER_BG.into(), palette::GRAY_1),
(tokens::SLIDER_BAR.into(), palette::ACCENT),
(tokens::SLIDER_BAR_DISABLED.into(), palette::GRAY_2),
(tokens::SLIDER_TEXT.into(), palette::WHITE),
(
tokens::SLIDER_TEXT_DISABLED.into(),
palette::WHITE.with_alpha(0.5),
),
]),
}
}

View File

@ -0,0 +1,62 @@
//! A framework for inheritable font styles.
use bevy_app::Propagate;
use bevy_asset::{AssetServer, Handle};
use bevy_ecs::{
component::Component,
lifecycle::Insert,
observer::On,
system::{Commands, Query, Res},
};
use bevy_text::{Font, TextFont};
use crate::handle_or_path::HandleOrPath;
/// A component which, when inserted on an entity, will load the given font and propagate it
/// downward to any child text entity that has the [`ThemedText`](crate::theme::ThemedText) marker.
#[derive(Component, Default, Clone, Debug)]
pub struct InheritableFont {
/// The font handle or path.
pub font: HandleOrPath<Font>,
/// The desired font size.
pub font_size: f32,
}
impl InheritableFont {
/// Create a new `InheritableFont` from a handle.
pub fn from_handle(handle: Handle<Font>) -> Self {
Self {
font: HandleOrPath::Handle(handle),
font_size: 16.0,
}
}
/// Create a new `InheritableFont` from a path.
pub fn from_path(path: &str) -> Self {
Self {
font: HandleOrPath::Path(path.to_string()),
font_size: 16.0,
}
}
}
/// An observer which looks for changes to the `InheritableFont` component on an entity, and
/// propagates downward the font to all participating text entities.
pub(crate) fn on_changed_font(
ev: On<Insert, InheritableFont>,
font_style: Query<&InheritableFont>,
assets: Res<AssetServer>,
mut commands: Commands,
) {
if let Ok(style) = font_style.get(ev.target()) {
if let Some(font) = match style.font {
HandleOrPath::Handle(ref h) => Some(h.clone()),
HandleOrPath::Path(ref p) => Some(assets.load::<Font>(p)),
} {
commands.entity(ev.target()).insert(Propagate(TextFont {
font,
font_size: style.font_size,
..Default::default()
}));
}
}
}

View File

@ -0,0 +1,61 @@
//! Provides a way to specify assets either by handle or by path.
use bevy_asset::{Asset, Handle};
/// Enum that represents a reference to an asset as either a [`Handle`] or a [`String`] path.
///
/// This is useful for when you want to specify an asset, but don't always have convenient
/// access to an asset server reference.
#[derive(Clone, Debug)]
pub enum HandleOrPath<T: Asset> {
/// Specify the asset reference as a handle.
Handle(Handle<T>),
/// Specify the asset reference as a [`String`].
Path(String),
}
impl<T: Asset> Default for HandleOrPath<T> {
fn default() -> Self {
Self::Path("".to_string())
}
}
// Necessary because we don't want to require T: PartialEq
impl<T: Asset> PartialEq for HandleOrPath<T> {
fn eq(&self, other: &Self) -> bool {
match (self, other) {
(HandleOrPath::Handle(h1), HandleOrPath::Handle(h2)) => h1 == h2,
(HandleOrPath::Path(p1), HandleOrPath::Path(p2)) => p1 == p2,
_ => false,
}
}
}
impl<T: Asset> From<Handle<T>> for HandleOrPath<T> {
fn from(h: Handle<T>) -> Self {
HandleOrPath::Handle(h)
}
}
impl<T: Asset> From<&str> for HandleOrPath<T> {
fn from(p: &str) -> Self {
HandleOrPath::Path(p.to_string())
}
}
impl<T: Asset> From<String> for HandleOrPath<T> {
fn from(p: String) -> Self {
HandleOrPath::Path(p.clone())
}
}
impl<T: Asset> From<&String> for HandleOrPath<T> {
fn from(p: &String) -> Self {
HandleOrPath::Path(p.to_string())
}
}
impl<T: Asset + Clone> From<&HandleOrPath<T>> for HandleOrPath<T> {
fn from(p: &HandleOrPath<T>) -> Self {
p.to_owned()
}
}

View File

@ -0,0 +1,73 @@
//! `bevy_feathers` is a collection of styled and themed widgets for building editors and
//! inspectors.
//!
//! The aesthetic choices made here are designed with a future Bevy Editor in mind,
//! but this crate is deliberately exposed to the public to allow the broader ecosystem to easily create
//! tooling for themselves and others that fits cohesively together.
//!
//! While it may be tempting to use this crate for your game's UI, it's deliberately not intended for that.
//! We've opted for a clean, functional style, and prioritized consistency over customization.
//! That said, if you like what you see, it can be a helpful learning tool.
//! Consider copying this code into your own project,
//! and refining the styles and abstractions provided to meet your needs.
//!
//! ## Warning: Experimental!
//! All that said, this crate is still experimental and unfinished!
//! It will change in breaking ways, and there will be both bugs and limitations.
//!
//! Please report issues, submit fixes and propose changes.
//! Thanks for stress-testing; let's build something better together.
use bevy_app::{HierarchyPropagatePlugin, Plugin, PostUpdate};
use bevy_asset::embedded_asset;
use bevy_ecs::query::With;
use bevy_text::{TextColor, TextFont};
use bevy_winit::cursor::CursorIcon;
use crate::{
controls::ControlsPlugin,
cursor::{CursorIconPlugin, DefaultCursorIcon},
theme::{ThemedText, UiTheme},
};
pub mod constants;
pub mod controls;
pub mod cursor;
pub mod dark_theme;
pub mod font_styles;
pub mod handle_or_path;
pub mod palette;
pub mod rounded_corners;
pub mod theme;
pub mod tokens;
/// Plugin which installs observers and systems for feathers themes, cursors, and all controls.
pub struct FeathersPlugin;
impl Plugin for FeathersPlugin {
fn build(&self, app: &mut bevy_app::App) {
app.init_resource::<UiTheme>();
embedded_asset!(app, "assets/fonts/FiraSans-Bold.ttf");
embedded_asset!(app, "assets/fonts/FiraSans-BoldItalic.ttf");
embedded_asset!(app, "assets/fonts/FiraSans-Regular.ttf");
embedded_asset!(app, "assets/fonts/FiraSans-Italic.ttf");
embedded_asset!(app, "assets/fonts/FiraMono-Medium.ttf");
app.add_plugins((
ControlsPlugin,
CursorIconPlugin,
HierarchyPropagatePlugin::<TextColor, With<ThemedText>>::default(),
HierarchyPropagatePlugin::<TextFont, With<ThemedText>>::default(),
));
app.insert_resource(DefaultCursorIcon(CursorIcon::System(
bevy_window::SystemCursorIcon::Default,
)));
app.add_systems(PostUpdate, theme::update_theme)
.add_observer(theme::on_changed_background)
.add_observer(theme::on_changed_font_color)
.add_observer(font_styles::on_changed_font);
}
}

View File

@ -0,0 +1,29 @@
//! The Feathers standard color palette.
use bevy_color::Color;
/// <div style="background-color: #000000; width: 10px; padding: 10px; border: 1px solid;"></div>
pub const BLACK: Color = Color::oklcha(0.0, 0.0, 0.0, 1.0);
/// <div style="background-color: #1F1F24; width: 10px; padding: 10px; border: 1px solid;"></div> - window background
pub const GRAY_0: Color = Color::oklcha(0.2414, 0.0095, 285.67, 1.0);
/// <div style="background-color: #2A2A2E; width: 10px; padding: 10px; border: 1px solid;"></div> - pane background
pub const GRAY_1: Color = Color::oklcha(0.2866, 0.0072, 285.93, 1.0);
/// <div style="background-color: #36373B; width: 10px; padding: 10px; border: 1px solid;"></div> - item background
pub const GRAY_2: Color = Color::oklcha(0.3373, 0.0071, 274.77, 1.0);
/// <div style="background-color: #46474D; width: 10px; padding: 10px; border: 1px solid;"></div> - item background (active)
pub const GRAY_3: Color = Color::oklcha(0.3992, 0.0101, 278.38, 1.0);
/// <div style="background-color: #414142; width: 10px; padding: 10px; border: 1px solid;"></div> - border
pub const WARM_GRAY_1: Color = Color::oklcha(0.3757, 0.0017, 286.32, 1.0);
/// <div style="background-color: #B1B1B2; width: 10px; padding: 10px; border: 1px solid;"></div> - bright label text
pub const LIGHT_GRAY_1: Color = Color::oklcha(0.7607, 0.0014, 286.37, 1.0);
/// <div style="background-color: #838385; width: 10px; padding: 10px; border: 1px solid;"></div> - dim label text
pub const LIGHT_GRAY_2: Color = Color::oklcha(0.6106, 0.003, 286.31, 1.0);
/// <div style="background-color: #FFFFFF; width: 10px; padding: 10px; border: 1px solid;"></div> - button label text
pub const WHITE: Color = Color::oklcha(1.0, 0.000000059604645, 90.0, 1.0);
/// <div style="background-color: #206EC9; width: 10px; padding: 10px; border: 1px solid;"></div> - call-to-action and selection color
pub const ACCENT: Color = Color::oklcha(0.542, 0.1594, 255.4, 1.0);
/// <div style="background-color: #AB4051; width: 10px; padding: 10px; border: 1px solid;"></div> - for X-axis inputs and drag handles
pub const X_AXIS: Color = Color::oklcha(0.5232, 0.1404, 13.84, 1.0);
/// <div style="background-color: #5D8D0A; width: 10px; padding: 10px; border: 1px solid;"></div> - for Y-axis inputs and drag handles
pub const Y_AXIS: Color = Color::oklcha(0.5866, 0.1543, 129.84, 1.0);
/// <div style="background-color: #2160A3; width: 10px; padding: 10px; border: 1px solid;"></div> - for Z-axis inputs and drag handles
pub const Z_AXIS: Color = Color::oklcha(0.4847, 0.1249, 253.08, 1.0);

View File

@ -0,0 +1,96 @@
//! Mechanism for specifying which corners of a widget are rounded, used for segmented buttons
//! and control groups.
use bevy_ui::{BorderRadius, Val};
/// Allows specifying which corners are rounded and which are sharp. All rounded corners
/// have the same radius. Not all combinations are supported, only the ones that make
/// sense for a segmented buttons.
///
/// A typical use case would be a segmented button consisting of 3 individual buttons in a
/// row. In that case, you would have the leftmost button have rounded corners on the left,
/// the right-most button have rounded corners on the right, and the center button have
/// only sharp corners.
#[derive(Debug, Clone, Copy, Default, PartialEq)]
pub enum RoundedCorners {
/// No corners are rounded.
None,
#[default]
/// All corners are rounded.
All,
/// Top-left corner is rounded.
TopLeft,
/// Top-right corner is rounded.
TopRight,
/// Bottom-right corner is rounded.
BottomRight,
/// Bottom-left corner is rounded.
BottomLeft,
/// Top corners are rounded.
Top,
/// Right corners are rounded.
Right,
/// Bottom corners are rounded.
Bottom,
/// Left corners are rounded.
Left,
}
impl RoundedCorners {
/// Convert the `RoundedCorners` to a `BorderRadius` for use in a `Node`.
pub fn to_border_radius(&self, radius: f32) -> BorderRadius {
let radius = Val::Px(radius);
let zero = Val::ZERO;
match self {
RoundedCorners::None => BorderRadius::all(zero),
RoundedCorners::All => BorderRadius::all(radius),
RoundedCorners::TopLeft => BorderRadius {
top_left: radius,
top_right: zero,
bottom_right: zero,
bottom_left: zero,
},
RoundedCorners::TopRight => BorderRadius {
top_left: zero,
top_right: radius,
bottom_right: zero,
bottom_left: zero,
},
RoundedCorners::BottomRight => BorderRadius {
top_left: zero,
top_right: zero,
bottom_right: radius,
bottom_left: zero,
},
RoundedCorners::BottomLeft => BorderRadius {
top_left: zero,
top_right: zero,
bottom_right: zero,
bottom_left: radius,
},
RoundedCorners::Top => BorderRadius {
top_left: radius,
top_right: radius,
bottom_right: zero,
bottom_left: zero,
},
RoundedCorners::Right => BorderRadius {
top_left: zero,
top_right: radius,
bottom_right: radius,
bottom_left: zero,
},
RoundedCorners::Bottom => BorderRadius {
top_left: zero,
top_right: zero,
bottom_right: radius,
bottom_left: radius,
},
RoundedCorners::Left => BorderRadius {
top_left: radius,
top_right: zero,
bottom_right: zero,
bottom_left: radius,
},
}
}
}

View File

@ -0,0 +1,114 @@
//! A framework for theming.
use bevy_app::Propagate;
use bevy_color::{palettes, Color};
use bevy_ecs::{
change_detection::DetectChanges,
component::Component,
lifecycle::Insert,
observer::On,
query::Changed,
resource::Resource,
system::{Commands, Query, Res},
};
use bevy_log::warn_once;
use bevy_platform::collections::HashMap;
use bevy_text::TextColor;
use bevy_ui::{BackgroundColor, BorderColor};
/// A collection of properties that make up a theme.
#[derive(Default, Clone)]
pub struct ThemeProps {
/// Map of design tokens to colors.
pub color: HashMap<String, Color>,
// Other style property types to be added later.
}
/// The currently selected user interface theme. Overwriting this resource changes the theme.
#[derive(Resource, Default)]
pub struct UiTheme(pub ThemeProps);
impl UiTheme {
/// Lookup a color by design token. If the theme does not have an entry for that token,
/// logs a warning and returns an error color.
pub fn color<'a>(&self, token: &'a str) -> Color {
let color = self.0.color.get(token);
match color {
Some(c) => *c,
None => {
warn_once!("Theme color {} not found.", token);
// Return a bright obnoxious color to make the error obvious.
palettes::basic::FUCHSIA.into()
}
}
}
/// Associate a design token with a given color.
pub fn set_color(&mut self, token: impl Into<String>, color: Color) {
self.0.color.insert(token.into(), color);
}
}
/// Component which causes the background color of an entity to be set based on a theme color.
#[derive(Component, Clone, Copy)]
#[require(BackgroundColor)]
#[component(immutable)]
pub struct ThemeBackgroundColor(pub &'static str);
/// Component which causes the border color of an entity to be set based on a theme color.
/// Only supports setting all borders to the same color.
#[derive(Component, Clone, Copy)]
#[require(BorderColor)]
#[component(immutable)]
pub struct ThemeBorderColor(pub &'static str);
/// Component which causes the inherited text color of an entity to be set based on a theme color.
#[derive(Component, Clone, Copy)]
#[component(immutable)]
pub struct ThemeFontColor(pub &'static str);
/// A marker component that is used to indicate that the text entity wants to opt-in to using
/// inherited text styles.
#[derive(Component)]
pub struct ThemedText;
pub(crate) fn update_theme(
mut q_background: Query<(&mut BackgroundColor, &ThemeBackgroundColor)>,
theme: Res<UiTheme>,
) {
if theme.is_changed() {
// Update all background colors
for (mut bg, theme_bg) in q_background.iter_mut() {
bg.0 = theme.color(theme_bg.0);
}
}
}
pub(crate) fn on_changed_background(
ev: On<Insert, ThemeBackgroundColor>,
mut q_background: Query<
(&mut BackgroundColor, &ThemeBackgroundColor),
Changed<ThemeBackgroundColor>,
>,
theme: Res<UiTheme>,
) {
// Update background colors where the design token has changed.
if let Ok((mut bg, theme_bg)) = q_background.get_mut(ev.target()) {
bg.0 = theme.color(theme_bg.0);
}
}
/// An observer which looks for changes to the [`ThemeFontColor`] component on an entity, and
/// propagates downward the text color to all participating text entities.
pub(crate) fn on_changed_font_color(
ev: On<Insert, ThemeFontColor>,
font_color: Query<&ThemeFontColor>,
theme: Res<UiTheme>,
mut commands: Commands,
) {
if let Ok(token) = font_color.get(ev.target()) {
let color = theme.color(token.0);
commands
.entity(ev.target())
.insert(Propagate(TextColor(color)));
}
}

View File

@ -0,0 +1,76 @@
//! Design tokens used by Feathers themes.
//!
//! The term "design token" is commonly used in UX design to mean the smallest unit of a theme,
//! similar in concept to a CSS variable. Each token represents an assignment of a color or
//! value to a specific visual aspect of a widget, such as background or border.
/// Window background
pub const WINDOW_BG: &str = "feathers.window.bg";
/// Focus ring
pub const FOCUS_RING: &str = "feathers.focus";
/// Regular text
pub const TEXT_MAIN: &str = "feathers.text.main";
/// Dim text
pub const TEXT_DIM: &str = "feathers.text.dim";
// Normal buttons
/// Regular button background
pub const BUTTON_BG: &str = "feathers.button.bg";
/// Regular button background (hovered)
pub const BUTTON_BG_HOVER: &str = "feathers.button.bg.hover";
/// Regular button background (disabled)
pub const BUTTON_BG_DISABLED: &str = "feathers.button.bg.disabled";
/// Regular button background (pressed)
pub const BUTTON_BG_PRESSED: &str = "feathers.button.bg.pressed";
/// Regular button text
pub const BUTTON_TEXT: &str = "feathers.button.txt";
/// Regular button text (disabled)
pub const BUTTON_TEXT_DISABLED: &str = "feathers.button.txt.disabled";
// Primary ("default") buttons
/// Primary button background
pub const BUTTON_PRIMARY_BG: &str = "feathers.button.primary.bg";
/// Primary button background (hovered)
pub const BUTTON_PRIMARY_BG_HOVER: &str = "feathers.button.primary.bg.hover";
/// Primary button background (disabled)
pub const BUTTON_PRIMARY_BG_DISABLED: &str = "feathers.button.primary.bg.disabled";
/// Primary button background (pressed)
pub const BUTTON_PRIMARY_BG_PRESSED: &str = "feathers.button.primary.bg.pressed";
/// Primary button text
pub const BUTTON_PRIMARY_TEXT: &str = "feathers.button.primary.txt";
/// Primary button text (disabled)
pub const BUTTON_PRIMARY_TEXT_DISABLED: &str = "feathers.button.primary.txt.disabled";
// Slider
/// Background for slider
pub const SLIDER_BG: &str = "feathers.slider.bg";
/// Background for slider moving bar
pub const SLIDER_BAR: &str = "feathers.slider.bar";
/// Background for slider moving bar (disabled)
pub const SLIDER_BAR_DISABLED: &str = "feathers.slider.bar.disabled";
/// Background for slider text
pub const SLIDER_TEXT: &str = "feathers.slider.text";
/// Background for slider text (disabled)
pub const SLIDER_TEXT_DISABLED: &str = "feathers.slider.text.disabled";
// Checkbox
/// Checkbox border around the checkmark
pub const CHECKBOX_BORDER: &str = "feathers.checkbox.border";
/// Checkbox border around the checkmark (hovered)
pub const CHECKBOX_BORDER_HOVER: &str = "feathers.checkbox.border.hover";
/// Checkbox border around the checkmark (disabled)
pub const CHECKBOX_BORDER_DISABLED: &str = "feathers.checkbox.border.disabled";
/// Checkbox check mark
pub const CHECKBOX_MARK: &str = "feathers.checkbox.mark";
/// Checkbox check mark (disabled)
pub const CHECKBOX_MARK_DISABLED: &str = "feathers.checkbox.mark.disabled";
/// Checkbox label text
pub const CHECKBOX_TEXT: &str = "feathers.checkbox.text";
/// Checkbox label text (disabled)
pub const CHECKBOX_TEXT_DISABLED: &str = "feathers.checkbox.text.disabled";

View File

@ -416,6 +416,7 @@ bevy_dev_tools = { path = "../bevy_dev_tools", optional = true, version = "0.17.
bevy_gilrs = { path = "../bevy_gilrs", optional = true, version = "0.17.0-dev" }
bevy_gizmos = { path = "../bevy_gizmos", optional = true, version = "0.17.0-dev", default-features = false }
bevy_gltf = { path = "../bevy_gltf", optional = true, version = "0.17.0-dev" }
bevy_feathers = { path = "../bevy_feathers", optional = true, version = "0.17.0-dev" }
bevy_image = { path = "../bevy_image", optional = true, version = "0.17.0-dev" }
bevy_input_focus = { path = "../bevy_input_focus", optional = true, version = "0.17.0-dev", default-features = false, features = [
"bevy_reflect",

View File

@ -35,6 +35,8 @@ pub use bevy_core_widgets as core_widgets;
pub use bevy_dev_tools as dev_tools;
pub use bevy_diagnostic as diagnostic;
pub use bevy_ecs as ecs;
#[cfg(feature = "bevy_feathers")]
pub use bevy_feathers as feathers;
#[cfg(feature = "bevy_gilrs")]
pub use bevy_gilrs as gilrs;
#[cfg(feature = "bevy_gizmos")]

View File

@ -276,7 +276,7 @@ impl From<Justify> for cosmic_text::Align {
/// `TextFont` determines the style of a text span within a [`ComputedTextBlock`], specifically
/// the font face, the font size, and the color.
#[derive(Component, Clone, Debug, Reflect)]
#[derive(Component, Clone, Debug, Reflect, PartialEq)]
#[reflect(Component, Default, Debug, Clone)]
pub struct TextFont {
/// The specific font face to use, as a `Handle` to a [`Font`] asset.

View File

@ -81,6 +81,7 @@ The default feature set enables most of the expected features of a game engine,
|detailed_trace|Enable detailed trace event logging. These trace events are expensive even when off, thus they require compile time opt-in|
|dynamic_linking|Force dynamic linking, which improves iterative compile times|
|embedded_watcher|Enables watching in memory asset providers for Bevy Asset hot-reloading|
|experimental_bevy_feathers|Feathers widget collection.|
|experimental_pbr_pcss|Enable support for PCSS, at the risk of blowing past the global, per-shader sampler limit on older/lower-end GPUs|
|exr|EXR image format support|
|ff|Farbfeld image format support|

180
examples/ui/feathers.rs Normal file
View File

@ -0,0 +1,180 @@
//! This example shows off the various Bevy Feathers widgets.
use bevy::{
core_widgets::{CoreWidgetsPlugin, SliderStep},
feathers::{
controls::{button, slider, ButtonProps, ButtonVariant, SliderProps},
dark_theme::create_dark_theme,
rounded_corners::RoundedCorners,
theme::{ThemeBackgroundColor, ThemedText, UiTheme},
tokens, FeathersPlugin,
},
input_focus::{
tab_navigation::{TabGroup, TabNavigationPlugin},
InputDispatchPlugin,
},
prelude::*,
ui::InteractionDisabled,
winit::WinitSettings,
};
fn main() {
App::new()
.add_plugins((
DefaultPlugins,
CoreWidgetsPlugin,
InputDispatchPlugin,
TabNavigationPlugin,
FeathersPlugin,
))
.insert_resource(UiTheme(create_dark_theme()))
// Only run the app when there is user input. This will significantly reduce CPU/GPU use.
.insert_resource(WinitSettings::desktop_app())
.add_systems(Startup, setup)
.run();
}
fn setup(mut commands: Commands) {
// ui camera
commands.spawn(Camera2d);
let root = demo_root(&mut commands);
commands.spawn(root);
}
fn demo_root(commands: &mut Commands) -> impl Bundle {
(
Node {
width: Val::Percent(100.0),
height: Val::Percent(100.0),
align_items: AlignItems::Start,
justify_content: JustifyContent::Start,
display: Display::Flex,
flex_direction: FlexDirection::Column,
row_gap: Val::Px(10.0),
..default()
},
TabGroup::default(),
ThemeBackgroundColor(tokens::WINDOW_BG),
children![(
Node {
display: Display::Flex,
flex_direction: FlexDirection::Column,
align_items: AlignItems::Stretch,
justify_content: JustifyContent::Start,
padding: UiRect::all(Val::Px(8.0)),
row_gap: Val::Px(8.0),
width: Val::Percent(30.),
min_width: Val::Px(200.),
..default()
},
children![
(
Node {
display: Display::Flex,
flex_direction: FlexDirection::Row,
align_items: AlignItems::Center,
justify_content: JustifyContent::Start,
column_gap: Val::Px(8.0),
..default()
},
children![
button(
ButtonProps {
on_click: Some(commands.register_system(|| {
info!("Normal button clicked!");
})),
..default()
},
(),
Spawn((Text::new("Normal"), ThemedText))
),
button(
ButtonProps {
on_click: Some(commands.register_system(|| {
info!("Disabled button clicked!");
})),
..default()
},
InteractionDisabled,
Spawn((Text::new("Disabled"), ThemedText))
),
button(
ButtonProps {
on_click: Some(commands.register_system(|| {
info!("Primary button clicked!");
})),
variant: ButtonVariant::Primary,
..default()
},
(),
Spawn((Text::new("Primary"), ThemedText))
),
]
),
(
Node {
display: Display::Flex,
flex_direction: FlexDirection::Row,
align_items: AlignItems::Center,
justify_content: JustifyContent::Start,
column_gap: Val::Px(1.0),
..default()
},
children![
button(
ButtonProps {
on_click: Some(commands.register_system(|| {
info!("Left button clicked!");
})),
corners: RoundedCorners::Left,
..default()
},
(),
Spawn((Text::new("Left"), ThemedText))
),
button(
ButtonProps {
on_click: Some(commands.register_system(|| {
info!("Center button clicked!");
})),
corners: RoundedCorners::None,
..default()
},
(),
Spawn((Text::new("Center"), ThemedText))
),
button(
ButtonProps {
on_click: Some(commands.register_system(|| {
info!("Right button clicked!");
})),
variant: ButtonVariant::Primary,
corners: RoundedCorners::Right,
},
(),
Spawn((Text::new("Right"), ThemedText))
),
]
),
button(
ButtonProps {
on_click: Some(commands.register_system(|| {
info!("Wide button clicked!");
})),
..default()
},
(),
Spawn((Text::new("Button"), ThemedText))
),
slider(
SliderProps {
max: 100.0,
value: 20.0,
..default()
},
SliderStep(10.)
),
]
),],
)
}

View File

@ -0,0 +1,26 @@
---
title: Bevy Feathers
authors: ["@viridia", "@Atlas16A"]
pull_requests: [19730]
---
To make it easier for Bevy engine developers and third-party tool creators to make comfortable, visually cohesive tooling,
we're pleased to introduce "Feathers" - a comprehensive widget set that offers:
- Standard widgets designed to match the look and feel of the planned Bevy Editor
- Components that can be leveraged to build custom editors, inspectors, and utility interfaces
- Essential UI elements including buttons, sliders, checkboxes, menu buttons, and more
- Layout containers for organizing and structuring UI elements
- Decorative elements such as icons for visual enhancement
- Robust theming support ensuring consistent visual styling across applications
- Accessibility features with built-in screen reader and assistive technology support
- Interactive cursor behavior that changes appropriately when hovering over widgets
Feathers isn't meant as a toolkit for building exciting and cool game UIs: it has a somewhat plain
and utilitarian look and feel suitable for editors and graphical utilities. That being said, using
the themeing framework, you can spice up the colors quite a bit.
It can also serve as a helpful base to understand how to extend and style `bevy_ui` and our new core widgets;
copy the code into your project and start hacking!
Feathers is still in development, and is currently hidden behind an experimental feature flag,
`experimental_bevy_feathers`.