Files
bevy/examples/ui/styling/box_shadow.rs
T
ickshonpe 6ca4769128 Minimal responsive FontSize support (#22614)
# Objective

Add responsive font sizes supporting rem and viewport units to
`bevy_text` with minimal changes to the APIs and systems.

## Solution

Introduce a new `FontSize` enum:

```rust
pub enum FontSize {
    /// Font Size in logical pixels.
    Px(f32),
    /// Font size as a percentage of the viewport width.
    Vw(f32),
    /// Font size as a percentage of the viewport height.
    Vh(f32),
    /// Font size as a percentage of the smaller of the viewport width and height.
    VMin(f32),
    /// Font size as a percentage of the larger of the viewport width and height.
    VMax(f32),
    /// Font Size relative to the value of the `RemSize` resource.
    Rem(f32),
}
```

This replaces the `f32` value of `TextFont`'s `font_size` field.

The viewport variants work the same way as their respective `Val`
counterparts.

`Rem` values are multiplied by the value of the `RemSize` resource
(which newtypes an `f32`).

`FontSize` provides an `eval` method that takes a logical viewport size
and rem base size and returns an `f32` logical font size. The resolved
logical font size is then written into the `Attributes` passed to Cosmic
Text by `TextPipeline::update_buffer`.

Any text implementation using `bevy_text` must now provide viewport and
rem base values when calling `TextPipeline::update_buffer` or
`create_measure`.

`Text2d` uses the size of the primary window to resolve viewport values
(or `Vec2::splat(1000)` if no primary window is found). This is a
deliberate compromise, a single `Text2d` can be rendered to multiple
viewports using `RenderLayers`, so it's difficult to find a rule for
which viewport size should be chosen.

### Change detection 

`ComputedTextBlock` has two new fields: `uses_viewport_sizes` and
`uses_rem_sizes`, which are set to true in `TextPipeline::update_buffer`
iff any text section in the block uses viewport or rem font sizes,
respectively.

The `ComputedTextBlock::needs_rerender` method has been modified to take
take two bool parameters:
```rust
    pub fn needs_rerender(
        &self,
        is_viewport_size_changed: bool,
        is_rem_size_changed: bool,
    ) -> bool {
        self.needs_rerender
            || (is_viewport_size_changed && self.uses_viewport_sizes)
            || (is_rem_size_changed && self.uses_rem_sizes)
    }
 ```
This ensures that text reupdates will also be scheduled if one of the text section's uses a viewport font size and the local viewport size changed, or if one of the text section's uses a rem font size and the rem size changed.

#### Limitations

There are some limitations because we don't have any sort of font style inheritance yet:

* "rem" units aren't proper rem units, and just based on the value of a resource. 
* "em" units are resolved based on inherited font size, so can't be implemented without inheritance support.

#### Notes

* This PR is quite small and not very technical. Reviewers don't need to be especially familiar with `bevy_text`. Most of the changes are to the examples.

* We could consider using `Val` instead of `FontSize`, then we could use `Val`'s constructor functions which would be much nicer, but some variants might not have sensible interpretations in both UI and Text2d contexts. Also we'd have to make `Val` accessible to `bevy_text`.

## Testing

The changes to the text systems are relatively trivial and easy to understand.  I already added a minor change to the `text` example to use `Vh` font size for the "hello bevy" text in the bottom right corner. If you change the size of the window, you should see the text change size in response. The text initially flickers before it updates because of some unrelated asset/image changes that mean that font textures aren't ready until the frame after the text update that changes the font size.

Most of the example migrations were automated using regular expressions, and there are bound to be mistakes in those changes. It's infeasible to check every single example thoroughly, but it's early enough in the release cycle that I don't think we should be too worried if a few bugs slip in.

---------

Co-authored-by: Kevin Chen <chen.kevin.f@gmail.com>
2026-02-02 22:52:33 +00:00

636 lines
19 KiB
Rust

//! This example shows how to create a node with a shadow and adjust its settings interactively.
use bevy::{color::palettes::css::*, prelude::*, time::Time, window::RequestRedraw};
const NORMAL_BUTTON: Color = Color::srgb(0.15, 0.15, 0.15);
const HOVERED_BUTTON: Color = Color::srgb(0.25, 0.25, 0.25);
const PRESSED_BUTTON: Color = Color::srgb(0.35, 0.75, 0.35);
const SHAPE_DEFAULT_SETTINGS: ShapeSettings = ShapeSettings { index: 0 };
const SHADOW_DEFAULT_SETTINGS: ShadowSettings = ShadowSettings {
x_offset: 20.0,
y_offset: 20.0,
blur: 10.0,
spread: 15.0,
count: 1,
samples: 6,
};
const SHAPES: &[(&str, fn(&mut Node))] = &[
("1", |node| {
node.width = px(164);
node.height = px(164);
node.border_radius = BorderRadius::ZERO;
}),
("2", |node| {
node.width = px(164);
node.height = px(164);
node.border_radius = BorderRadius::all(px(41));
}),
("3", |node| {
node.width = px(164);
node.height = px(164);
node.border_radius = BorderRadius::MAX;
}),
("4", |node| {
node.width = px(240);
node.height = px(80);
node.border_radius = BorderRadius::all(px(32));
}),
("5", |node| {
node.width = px(80);
node.height = px(240);
node.border_radius = BorderRadius::all(px(32));
}),
];
#[derive(Resource, Default)]
struct ShapeSettings {
index: usize,
}
#[derive(Resource, Default)]
struct ShadowSettings {
x_offset: f32,
y_offset: f32,
blur: f32,
spread: f32,
count: usize,
samples: u32,
}
#[derive(Component)]
struct ShadowNode;
#[derive(Component, PartialEq, Clone, Copy)]
enum SettingsButton {
XOffsetInc,
XOffsetDec,
YOffsetInc,
YOffsetDec,
BlurInc,
BlurDec,
SpreadInc,
SpreadDec,
CountInc,
CountDec,
ShapePrev,
ShapeNext,
Reset,
SamplesInc,
SamplesDec,
}
#[derive(Component, Clone, Copy, PartialEq, Eq, Debug)]
enum SettingType {
XOffset,
YOffset,
Blur,
Spread,
Count,
Shape,
Samples,
}
impl SettingType {
fn label(&self) -> &str {
match self {
SettingType::XOffset => "X Offset",
SettingType::YOffset => "Y Offset",
SettingType::Blur => "Blur",
SettingType::Spread => "Spread",
SettingType::Count => "Count",
SettingType::Shape => "Shape",
SettingType::Samples => "Samples",
}
}
}
#[derive(Resource, Default)]
struct HeldButton {
button: Option<SettingsButton>,
pressed_at: Option<f64>,
last_repeat: Option<f64>,
}
fn main() {
App::new()
.add_plugins(DefaultPlugins)
.insert_resource(SHADOW_DEFAULT_SETTINGS)
.insert_resource(SHAPE_DEFAULT_SETTINGS)
.insert_resource(HeldButton::default())
.add_systems(Startup, setup)
.add_systems(
Update,
(
button_system,
button_color_system,
update_shape.run_if(resource_changed::<ShapeSettings>),
update_shadow.run_if(resource_changed::<ShadowSettings>),
update_shadow_samples.run_if(resource_changed::<ShadowSettings>),
button_repeat_system,
),
)
.run();
}
// --- UI Setup ---
fn setup(
mut commands: Commands,
asset_server: Res<AssetServer>,
shadow: Res<ShadowSettings>,
shape: Res<ShapeSettings>,
) {
commands.spawn((Camera2d, BoxShadowSamples(shadow.samples)));
// Spawn shape node
commands
.spawn((
Node {
width: percent(100),
height: percent(100),
align_items: AlignItems::Center,
justify_content: JustifyContent::Center,
..default()
},
BackgroundColor(GRAY.into()),
))
.insert(children![{
let mut node = Node {
width: px(164),
height: px(164),
border: UiRect::all(px(1)),
align_items: AlignItems::Center,
justify_content: JustifyContent::Center,
border_radius: BorderRadius::ZERO,
..default()
};
SHAPES[shape.index % SHAPES.len()].1(&mut node);
(
node,
BorderColor::all(WHITE),
BackgroundColor(Color::srgb(0.21, 0.21, 0.21)),
BoxShadow(vec![ShadowStyle {
color: Color::BLACK.with_alpha(0.8),
x_offset: px(shadow.x_offset),
y_offset: px(shadow.y_offset),
spread_radius: px(shadow.spread),
blur_radius: px(shadow.blur),
}]),
ShadowNode,
)
}]);
// Settings Panel
commands
.spawn((
Node {
flex_direction: FlexDirection::Column,
position_type: PositionType::Absolute,
left: px(24),
bottom: px(24),
width: px(270),
padding: UiRect::all(px(16)),
border_radius: BorderRadius::all(px(12)),
..default()
},
BackgroundColor(Color::srgb(0.12, 0.12, 0.12).with_alpha(0.85)),
BorderColor::all(Color::WHITE.with_alpha(0.15)),
ZIndex(10),
))
.insert(children![
build_setting_row(
SettingType::Shape,
SettingsButton::ShapePrev,
SettingsButton::ShapeNext,
shape.index as f32,
&asset_server,
),
build_setting_row(
SettingType::XOffset,
SettingsButton::XOffsetDec,
SettingsButton::XOffsetInc,
shadow.x_offset,
&asset_server,
),
build_setting_row(
SettingType::YOffset,
SettingsButton::YOffsetDec,
SettingsButton::YOffsetInc,
shadow.y_offset,
&asset_server,
),
build_setting_row(
SettingType::Blur,
SettingsButton::BlurDec,
SettingsButton::BlurInc,
shadow.blur,
&asset_server,
),
build_setting_row(
SettingType::Spread,
SettingsButton::SpreadDec,
SettingsButton::SpreadInc,
shadow.spread,
&asset_server,
),
build_setting_row(
SettingType::Count,
SettingsButton::CountDec,
SettingsButton::CountInc,
shadow.count as f32,
&asset_server,
),
// Add BoxShadowSamples as a setting row
build_setting_row(
SettingType::Samples,
SettingsButton::SamplesDec,
SettingsButton::SamplesInc,
shadow.samples as f32,
&asset_server,
),
// Reset button
(
Node {
flex_direction: FlexDirection::Row,
align_items: AlignItems::Center,
height: px(36),
margin: UiRect::top(px(12)),
..default()
},
children![(
Button,
Node {
width: px(90),
height: px(32),
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
border_radius: BorderRadius::all(px(8)),
..default()
},
BackgroundColor(NORMAL_BUTTON),
SettingsButton::Reset,
children![(
Text::new("Reset"),
TextFont {
font: asset_server.load("fonts/FiraSans-Bold.ttf").into(),
font_size: FontSize::Px(16.0),
..default()
},
)],
)],
),
]);
}
// --- UI Helper Functions ---
// Helper to return an input to the children! macro for a setting row
fn build_setting_row(
setting_type: SettingType,
dec: SettingsButton,
inc: SettingsButton,
value: f32,
asset_server: &Res<AssetServer>,
) -> impl Bundle {
let value_text = match setting_type {
SettingType::Shape => SHAPES[value as usize % SHAPES.len()].0.to_string(),
SettingType::Count => format!("{}", value as usize),
_ => format!("{value:.1}"),
};
(
Node {
flex_direction: FlexDirection::Row,
align_items: AlignItems::Center,
height: px(32),
..default()
},
children![
(
Node {
width: px(80),
justify_content: JustifyContent::FlexEnd,
align_items: AlignItems::Center,
..default()
},
// Attach SettingType to the value label node, not the parent row
children![(
Text::new(setting_type.label()),
TextFont {
font: asset_server.load("fonts/FiraSans-Bold.ttf").into(),
font_size: FontSize::Px(16.0),
..default()
},
)],
),
(
Button,
Node {
width: px(28),
height: px(28),
margin: UiRect::left(px(8)),
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
border_radius: BorderRadius::all(px(6)),
..default()
},
BackgroundColor(Color::WHITE),
dec,
children![(
Text::new(if setting_type == SettingType::Shape {
"<"
} else {
"-"
}),
TextFont {
font: asset_server.load("fonts/FiraSans-Bold.ttf").into(),
font_size: FontSize::Px(18.0),
..default()
},
)],
),
(
Node {
width: px(48),
height: px(28),
margin: UiRect::horizontal(px(8)),
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
border_radius: BorderRadius::all(px(6)),
..default()
},
children![{
(
Text::new(value_text),
TextFont {
font: asset_server.load("fonts/FiraSans-Bold.ttf").into(),
font_size: FontSize::Px(16.0),
..default()
},
setting_type,
)
}],
),
(
Button,
Node {
width: px(28),
height: px(28),
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
border_radius: BorderRadius::all(px(6)),
..default()
},
BackgroundColor(Color::WHITE),
inc,
children![(
Text::new(if setting_type == SettingType::Shape {
">"
} else {
"+"
}),
TextFont {
font: asset_server.load("fonts/FiraSans-Bold.ttf").into(),
font_size: FontSize::Px(18.0),
..default()
},
)],
),
],
)
}
// --- SYSTEMS ---
// Update the shadow node's BoxShadow on resource changes
fn update_shadow(
shadow: Res<ShadowSettings>,
mut query: Query<&mut BoxShadow, With<ShadowNode>>,
mut label_query: Query<(&mut Text, &SettingType)>,
) {
for mut box_shadow in &mut query {
*box_shadow = BoxShadow(generate_shadows(&shadow));
}
// Update value labels for shadow settings
for (mut text, setting) in &mut label_query {
let value = match setting {
SettingType::XOffset => format!("{:.1}", shadow.x_offset),
SettingType::YOffset => format!("{:.1}", shadow.y_offset),
SettingType::Blur => format!("{:.1}", shadow.blur),
SettingType::Spread => format!("{:.1}", shadow.spread),
SettingType::Count => format!("{}", shadow.count),
SettingType::Shape => continue,
SettingType::Samples => format!("{}", shadow.samples),
};
*text = Text::new(value);
}
}
fn update_shadow_samples(
shadow: Res<ShadowSettings>,
mut query: Query<&mut BoxShadowSamples, With<Camera2d>>,
) {
for mut samples in &mut query {
samples.0 = shadow.samples;
}
}
fn generate_shadows(shadow: &ShadowSettings) -> Vec<ShadowStyle> {
match shadow.count {
1 => vec![make_shadow(
BLACK.into(),
shadow.x_offset,
shadow.y_offset,
shadow.spread,
shadow.blur,
)],
2 => vec![
make_shadow(
BLUE.into(),
shadow.x_offset,
shadow.y_offset,
shadow.spread,
shadow.blur,
),
make_shadow(
YELLOW.into(),
-shadow.x_offset,
-shadow.y_offset,
shadow.spread,
shadow.blur,
),
],
3 => vec![
make_shadow(
BLUE.into(),
shadow.x_offset,
shadow.y_offset,
shadow.spread,
shadow.blur,
),
make_shadow(
YELLOW.into(),
-shadow.x_offset,
-shadow.y_offset,
shadow.spread,
shadow.blur,
),
make_shadow(
RED.into(),
shadow.y_offset,
-shadow.x_offset,
shadow.spread,
shadow.blur,
),
],
_ => vec![],
}
}
fn make_shadow(color: Color, x_offset: f32, y_offset: f32, spread: f32, blur: f32) -> ShadowStyle {
ShadowStyle {
color: color.with_alpha(0.8),
x_offset: px(x_offset),
y_offset: px(y_offset),
spread_radius: px(spread),
blur_radius: px(blur),
}
}
// Update shape of ShadowNode if shape selection changed
fn update_shape(
shape: Res<ShapeSettings>,
mut query: Query<&mut Node, With<ShadowNode>>,
mut label_query: Query<(&mut Text, &SettingType)>,
) {
for mut node in &mut query {
SHAPES[shape.index % SHAPES.len()].1(&mut node);
}
for (mut text, kind) in &mut label_query {
if *kind == SettingType::Shape {
*text = Text::new(SHAPES[shape.index % SHAPES.len()].0);
}
}
}
// Handles button interactions for all settings
fn button_system(
mut interaction_query: Query<
(&Interaction, &SettingsButton),
(Changed<Interaction>, With<Button>),
>,
mut shadow: ResMut<ShadowSettings>,
mut shape: ResMut<ShapeSettings>,
mut held: ResMut<HeldButton>,
time: Res<Time>,
) {
let now = time.elapsed_secs_f64();
for (interaction, btn) in &mut interaction_query {
match *interaction {
Interaction::Pressed => {
trigger_button_action(btn, &mut shadow, &mut shape);
held.button = Some(*btn);
held.pressed_at = Some(now);
held.last_repeat = Some(now);
}
Interaction::None | Interaction::Hovered => {
if held.button == Some(*btn) {
held.button = None;
held.pressed_at = None;
held.last_repeat = None;
}
}
}
}
}
fn trigger_button_action(
btn: &SettingsButton,
shadow: &mut ShadowSettings,
shape: &mut ShapeSettings,
) {
match btn {
SettingsButton::XOffsetInc => shadow.x_offset += 1.0,
SettingsButton::XOffsetDec => shadow.x_offset -= 1.0,
SettingsButton::YOffsetInc => shadow.y_offset += 1.0,
SettingsButton::YOffsetDec => shadow.y_offset -= 1.0,
SettingsButton::BlurInc => shadow.blur = (shadow.blur + 1.0).max(0.0),
SettingsButton::BlurDec => shadow.blur = (shadow.blur - 1.0).max(0.0),
SettingsButton::SpreadInc => shadow.spread += 1.0,
SettingsButton::SpreadDec => shadow.spread -= 1.0,
SettingsButton::CountInc => {
if shadow.count < 3 {
shadow.count += 1;
}
}
SettingsButton::CountDec => {
if shadow.count > 1 {
shadow.count -= 1;
}
}
SettingsButton::ShapePrev => {
if shape.index == 0 {
shape.index = SHAPES.len() - 1;
} else {
shape.index -= 1;
}
}
SettingsButton::ShapeNext => {
shape.index = (shape.index + 1) % SHAPES.len();
}
SettingsButton::Reset => {
*shape = SHAPE_DEFAULT_SETTINGS;
*shadow = SHADOW_DEFAULT_SETTINGS;
}
SettingsButton::SamplesInc => shadow.samples += 1,
SettingsButton::SamplesDec => {
if shadow.samples > 1 {
shadow.samples -= 1;
}
}
}
}
// System to repeat button action while held
fn button_repeat_system(
time: Res<Time>,
mut held: ResMut<HeldButton>,
mut shadow: ResMut<ShadowSettings>,
mut shape: ResMut<ShapeSettings>,
mut request_redraw_writer: MessageWriter<RequestRedraw>,
) {
if held.button.is_some() {
request_redraw_writer.write(RequestRedraw);
}
const INITIAL_DELAY: f64 = 0.15;
const REPEAT_RATE: f64 = 0.08;
if let (Some(btn), Some(pressed_at)) = (held.button, held.pressed_at) {
let now = time.elapsed_secs_f64();
let since_pressed = now - pressed_at;
let last_repeat = held.last_repeat.unwrap_or(pressed_at);
let since_last = now - last_repeat;
if since_pressed > INITIAL_DELAY && since_last > REPEAT_RATE {
trigger_button_action(&btn, &mut shadow, &mut shape);
held.last_repeat = Some(now);
}
}
}
// Changes color of button on hover and on pressed
fn button_color_system(
mut query: Query<
(&Interaction, &mut BackgroundColor),
(Changed<Interaction>, With<Button>, With<SettingsButton>),
>,
) {
for (interaction, mut color) in &mut query {
match *interaction {
Interaction::Pressed => *color = PRESSED_BUTTON.into(),
Interaction::Hovered => *color = HOVERED_BUTTON.into(),
Interaction::None => *color = NORMAL_BUTTON.into(),
}
}
}