Skip to content
Draft
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
1 change: 1 addition & 0 deletions crates/bevy_feathers/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ bevy_ui_render = { path = "../bevy_ui_render", version = "0.19.0-dev" }
bevy_window = { path = "../bevy_window", version = "0.19.0-dev" }
bevy_derive = { path = "../bevy_derive", version = "0.19.0-dev" }
smol_str = { version = "0.2", default-features = false }
bevy_time = { path = "../bevy_time", version = "0.19.0-dev" }

# other
accesskit = "0.24"
Expand Down
15 changes: 15 additions & 0 deletions crates/bevy_feathers/src/feedback/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
//! Widgets that provide feedback to the user, such as toasts and modals.

mod toast;

use bevy_app::Plugin;
pub use toast::*;

/// Plugin which registers all `bevy_feathers` feedback widgets.
pub struct FeedbackPlugin;

impl Plugin for FeedbackPlugin {
fn build(&self, app: &mut bevy_app::App) {
app.add_plugins((ToastsPlugin,));
}
}
313 changes: 313 additions & 0 deletions crates/bevy_feathers/src/feedback/toast.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,313 @@
use core::time::Duration;

use bevy_app::{Plugin, PreUpdate};
use bevy_asset::AssetServer;
use bevy_ecs::{
component::Component,
entity::Entity,
hierarchy::{ChildOf, Children},
lifecycle::HookContext,
observer::On,
reflect::{ReflectComponent, ReflectResource},
resource::Resource,
system::{Commands, Query, Res},
template::{template, FromTemplate, ScopedEntityIndex},
world::DeferredWorld,
};
use bevy_picking::Pickable;
use bevy_platform::collections::HashMap;
use bevy_reflect::{prelude::ReflectDefault, Reflect};
use bevy_scene::{prelude::*, template_value};
use bevy_text::{FontWeight, LineBreak, TextColor, TextLayout};
use bevy_time::{Fixed, Time, Timer, TimerMode};
use bevy_ui::{
percent, px,
widget::{ImageNode, Text},
AlignItems, BackgroundColor, FlexDirection, JustifyContent, Node, Overflow, PositionType,
UiRect,
};
use bevy_ui_widgets::{Activate, Button};

use crate::{
constants::{fonts, icons, size},
font_styles::InheritableFont,
palette,
rounded_corners::RoundedCorners,
theme::ThemedText,
};

const TOAST_HEIGHT_PX: f32 = 60.0;
const TOAST_MARGIN_PX: f32 = 10.0;

/// Keeps track of currently spawned toasts in their respective positions.
///
/// This is used to determine
/// - initial positioning for each toast relative to other toasts in the same position and
/// - which toasts need to be repositioned when a toast is despawned
#[derive(Resource, Default, Reflect)]
#[reflect(Resource, Default)]
pub struct ToastPositions(pub HashMap<ToastPosition, Vec<Entity>>);

/// Severity variants for toasts. This determines the background and text color of the toast.
#[derive(Component, Default, Clone, Copy, Reflect, Debug, PartialEq, Eq)]
#[reflect(Component, Clone, Default)]
pub enum ToastVariant {
/// Uses [`palette::INFO`] for background and [`palette::WHITE`] for text color.
#[default]
Info,
/// Uses [`palette::SUCCESS`] for background and [`palette::WHITE`] for text color.
Success,
/// Uses [`palette::WARNING`] for background and [`palette::WHITE`] for text color.
Warning,
/// Uses [`palette::ERROR`] for background and [`palette::WHITE`] for text color.
Error,
}

/// Available positions for toasts.
#[derive(Component, Clone, Default, Reflect, Debug, PartialEq, Eq, Hash)]
#[reflect(Component, Clone, Default)]
#[component(on_add, on_despawn)]
pub enum ToastPosition {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel TopCenter and BottomCenter would also be useful. My main experience with toasts (Paradox's Crusader Kings game) has them top center.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Imo and afaik the Feathers implementation should focus on editor/tooling UI. Should there be a need for Bevy provided toasts in games, that should reside in bevy_ui_widgets as a headless widget. I'll leave this open and not make any changes should we get more opinions on this. I'm more than okay with moving these to bevy_ui_widgets and provide a Feathers reference implementation tho!

/// Bottom right corner of the screen. Siblings stack upwards.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This says "of the screen", but if a ToastPosition has a parent entity, does it get laid out inside the parent? (Which would be a useful feature, if it isn't already)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The intended usage of these is only via the FeathersToast SceneComponent and therefore this implementation hasn't even considered such usage.

#[default]
BottomRight,
/// Bottom left corner of the screen. Siblings stack upwards.
BottomLeft,
/// Top left corner of the screen. Siblings stack downwards.
TopLeft,
/// Top right corner of the screen. Siblings stack downwards.
TopRight,
}

impl ToastPosition {
fn on_add(mut world: DeferredWorld, HookContext { entity, .. }: HookContext) {
let position = world
.entity(entity)
.get::<ToastPosition>()
.expect("ToastPosition should be present in ToastPosition on_add")
.clone();
let mut toast_positions = world.resource_mut::<ToastPositions>();
let idx = toast_positions.0.get(&position).unwrap_or(&vec![]).len();
toast_positions
.0
.entry(position.clone())
.or_default()
.push(entity);
let mut entity_mut = world.entity_mut(entity);
let mut node = entity_mut
.get_mut::<Node>()
.expect("Node should be present in ToastPosition on_add");
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this guaranteed? I've had issues with on_add firing on a component before other components have been added to an entity.

Also, ToolPosition is pub and doesn't have require(Node) so you're relying on the user to make sure there is a Node.

Panicking may be extreme in this situation. Can you insert the Node if it's missing, instead?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To be honest I have no idea if it's guaranteed but at least I've never personally experienced it not being there.
The only intended way to spawn a Toast is via the FeathersToast SceneComponent and it's fn scene adds the Node.. E.g. as described in the description

bsn! {
    :FeathersToast {
        @message: "Default success toast in bottom right",
    }
};

let offset = (TOAST_HEIGHT_PX + TOAST_MARGIN_PX) * idx as f32;
match position {
ToastPosition::BottomRight => {
node.bottom = px(offset);
node.right = px(TOAST_MARGIN_PX);
}
ToastPosition::BottomLeft => {
node.bottom = px(offset);
node.left = px(TOAST_MARGIN_PX);
}
ToastPosition::TopLeft => {
node.top = px(offset);
node.left = px(TOAST_MARGIN_PX);
}
ToastPosition::TopRight => {
node.top = px(offset);
node.right = px(TOAST_MARGIN_PX);
}
}
}

fn on_despawn(mut world: DeferredWorld, HookContext { entity, .. }: HookContext) {
let position = world
.entity(entity)
.get::<ToastPosition>()
.expect("ToastPosition component should be present on entity when on_despawn is called")
.clone();
let mut toast_positions = world.resource_mut::<ToastPositions>();
let removed_idx = toast_positions
.0
.get(&position)
.unwrap()
.iter()
.position(|e| *e == entity)
.unwrap();
if let Some(entities) = toast_positions.0.get_mut(&position) {
entities.retain(|&e| e != entity);
}
let dirty_nodes = toast_positions
.0
.get(&position)
.unwrap()
.iter()
.skip(removed_idx)
.cloned()
.collect::<Vec<_>>();
for (idx, entity) in dirty_nodes.iter().enumerate() {
let mut entity_mut = world.entity_mut(*entity);
let mut node = entity_mut
.get_mut::<Node>()
.expect("Node should be present in ToastPosition on_despawn");
let offset = (TOAST_HEIGHT_PX + TOAST_MARGIN_PX) * (removed_idx + idx) as f32;
match position {
ToastPosition::BottomRight | ToastPosition::BottomLeft => {
node.bottom = px(offset);
}
ToastPosition::TopLeft | ToastPosition::TopRight => {
node.top = px(offset);
}
}
}
}
}

/// Component for keeping track of the progress bar of a toast with a duration.
/// This is used to update the width of the progress bar and despawn the toast when the timer finishes.
#[derive(Component, FromTemplate, Clone, Reflect, Debug, PartialEq, Eq)]
#[reflect(Component, Clone)]
pub struct ToastProgressBar {
/// [Timer] for the toast duration. The progress bar width is updated based on the remaining time of this timer, and the toast is despawned when this timer finishes.
pub timer: Timer,
/// The root entity of the toast. This is used to despawn the toast when the timer finishes.
pub root_entity: Entity,
}

/// A toast widget.
///
/// This is spawnable by inheriting it as a "scene component" with optional [`FeathersToastProps`].
#[derive(SceneComponent, Default, Clone)]
#[scene(FeathersToastProps)]
pub struct FeathersToast;

/// Props used for construct a [`FeathersToast`] scene.
pub struct FeathersToastProps {
/// The message to display in the toast.
pub message: String,
/// The severity variant of the toast, which determines the background and text color.
pub variant: ToastVariant,
/// Optional duration for the toast. If `Some`, a progress bar is shown and the toast is automatically despawned after the duration. If `None`, the toast will stay until manually despawned.
pub duration: Option<Duration>,
/// The position of the toast on the screen, which determines where the toast is spawned and how it is stacked with other toasts.
pub position: ToastPosition,
}

impl Default for FeathersToastProps {
fn default() -> Self {
Self {
message: "".to_string(), // TODO: Could multiline messages be supported by passing a [`SceneList`]?
variant: ToastVariant::default(),
duration: Some(Duration::from_secs(3)),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Opinion

I feel that None would be a better default than picking a number of seconds, which is by nature arbitrary.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't have a strong opinion in either direction. I'll keep this open an unchanged should more people want to weigh their opinions.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

However the duration of 3 sec came from Androids Snackbar.LENGTH_SHORT

position: ToastPosition::default(),
}
}
}

impl FeathersToast {
fn scene(props: FeathersToastProps) -> impl Scene {
bsn! {
#Toast
Node {
width: px(300),
height: px(60),
margin: UiRect::all(px(5)),
padding: UiRect::all(px(10)),
border_radius: {RoundedCorners::All.to_border_radius(4.0)},
align_items: AlignItems::Center,
justify_content: JustifyContent::SpaceBetween,
flex_direction: FlexDirection::Row,
position_type: PositionType::Absolute
}
Pickable::IGNORE
template_value(props.variant)
template_value(props.position)
template(move |_| {
let background_color = match props.variant {
ToastVariant::Info => palette::INFO,
ToastVariant::Success => palette::SUCCESS,
ToastVariant::Warning => palette::WARNING,
ToastVariant::Error => palette::ERROR,
};
Ok(BackgroundColor(background_color))
})
Children[(
Node {
width: percent(90),
overflow: Overflow::clip_x()
}
InheritableFont {
font: fonts::REGULAR,
font_size: size::COMPACT_FONT,
weight: FontWeight::NORMAL,
}
Children[(
Text({props.message})
ThemedText
TextLayout {linebreak: LineBreak::NoWrap}
TextColor(palette::WHITE)
)]
), (
Node {
width: px(30),
height: px(30),
}
Button
template(|ctx| {
let handle = ctx.resource::<AssetServer>().load(icons::X);
Ok(ImageNode::new(handle))
})
on(|trigger: On<Activate>, mut commands: Commands, child_of: Query<&ChildOf>| {
if let Ok(parent) = child_of.get(trigger.entity) {
commands.entity(parent.0).despawn();
}
})
), ({ if let Some(duration) = props.duration {
Box::new(bsn! {
Node {
width: percent(100),
height: px(10),
position_type: PositionType::Absolute,
bottom: px(0),
left: px(0),
}
BackgroundColor(palette::WHITE)
template(move |ctx| {
let root_entity = ctx.get_scoped_entity(ScopedEntityIndex { scope: 1, index: 0}); // TODO: Why is the scope 1 here? Before #24008 this was in 0.
Ok(ToastProgressBar { timer: Timer::new(duration, TimerMode::Once), root_entity })
})
// ToastProgressBar { timer: Timer::new(props.duration.unwrap(), TimerMode::Once), root_entity: #Toast } // TODO: This panics if the EntityReference is there
}) as Box<dyn Scene>
} else {
Box::new(bsn!()) as Box<dyn Scene>
}
})]
}
}
}

fn tick_toasts_progress_bars(
mut commands: Commands,
mut toast_progress_bars: Query<(&mut Node, &mut ToastProgressBar)>,
time: Res<Time<Fixed>>,
) {
for (mut node, mut toast_progress_bar) in &mut toast_progress_bars {
let timer = &mut toast_progress_bar.timer;
timer.tick(time.delta());
let remaining_secs = timer.remaining_secs();
let duration_secs = timer.duration().as_secs() as f32;
let remaining = remaining_secs / duration_secs;
node.width = percent(remaining * 100.);
if timer.is_finished() {
commands.entity(toast_progress_bar.root_entity).despawn();
}
}
}

/// Plugin which registers the systems for managing toasts.
pub struct ToastsPlugin;

impl Plugin for ToastsPlugin {
fn build(&self, app: &mut bevy_app::App) {
app.init_resource::<ToastPositions>();
app.add_systems(PreUpdate, tick_toasts_progress_bars);
}
}
3 changes: 3 additions & 0 deletions crates/bevy_feathers/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ use crate::{
alpha_pattern::{AlphaPatternMaterial, AlphaPatternResource},
controls::ControlsPlugin,
cursor::{CursorIconPlugin, DefaultCursor, EntityCursor},
feedback::FeedbackPlugin,
theme::{ThemedText, UiTheme},
};

Expand All @@ -52,6 +53,7 @@ pub mod controls;
pub mod cursor;
pub mod dark_theme;
pub mod display;
pub mod feedback;
pub mod focus;
pub mod font_styles;
pub mod palette;
Expand Down Expand Up @@ -85,6 +87,7 @@ impl Plugin for FeathersCorePlugin {
app.add_plugins((
ControlsPlugin,
CursorIconPlugin,
FeedbackPlugin,
HierarchyPropagatePlugin::<TextColor, With<ThemedText>>::new(PostUpdate),
HierarchyPropagatePlugin::<TextFont, With<ThemedText>>::new(PostUpdate),
UiMaterialPlugin::<AlphaPatternMaterial>::default(),
Expand Down
8 changes: 8 additions & 0 deletions crates/bevy_feathers/src/palette.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,11 @@ pub const X_AXIS: Color = Color::oklcha(0.5232, 0.1404, 13.84, 1.0);
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);
/// <div style="background-color: #206EC9; width: 10px; padding: 10px; border: 1px solid;"></div> - for info messages and indicators
pub const INFO: Color = Color::oklcha(0.26, 0.04, 250.0, 1.0);
/// <div style="background-color: #2FA843; width: 10px; padding: 10px; border: 1px solid;"></div> - for success messages and indicators
pub const SUCCESS: Color = Color::oklcha(0.27, 0.04, 140.0, 1.0);
/// <div style="background-color: #E5B000; width: 10px; padding: 10px; border: 1px solid;"></div> - for warnings and cautionary indicators
pub const WARNING: Color = Color::oklcha(0.28, 0.05, 75.0, 1.0);
/// <div style="background-color: #AB4051; width: 10px; padding: 10px; border: 1px solid;"></div> - for error messages and indicators
pub const ERROR: Color = Color::oklcha(0.27, 0.05, 10.0, 1.0);
Loading
Loading