diff --git a/crates/bevy_ui/src/ui_node.rs b/crates/bevy_ui/src/ui_node.rs index ac086d02a5556..c58f80b1c9714 100644 --- a/crates/bevy_ui/src/ui_node.rs +++ b/crates/bevy_ui/src/ui_node.rs @@ -3,7 +3,7 @@ use crate::{ FocusPolicy, UiRect, Val, }; use bevy_camera::{visibility::Visibility, Camera, RenderTarget}; -use bevy_color::{Alpha, Color}; +use bevy_color::{palettes::css::GRAY, Alpha, Color}; use bevy_derive::{Deref, DerefMut}; use bevy_ecs::{prelude::*, system::SystemParam}; use bevy_math::{vec4, BVec2, Rect, UVec2, Vec2, Vec4Swizzles}; @@ -298,6 +298,52 @@ impl ComputedNode { clip_rect } + + /// Compute the size and position of the horizontal scrollbar's gutter + pub fn horizontal_scrollbar_gutter(&self) -> Rect { + let content_inset = self.content_inset(); + let min_x = content_inset.left; + let max_x = self.size.x - content_inset.right - self.scrollbar_size.x; + let max_y = self.size.y - content_inset.bottom; + let min_y = max_y - self.scrollbar_size.y; + Rect { + min: (min_x, min_y).into(), + max: (max_x, max_y).into(), + } + } + + /// Compute the size and position of the vertical scrollbar's gutter + pub fn vertical_scrollbar_gutter(&self) -> Rect { + let content_inset = self.content_inset(); + let max_x = self.size.x - content_inset.right; + let min_x = max_x - self.scrollbar_size.x; + let min_y = content_inset.top; + let max_y = self.size.y - content_inset.bottom - self.scrollbar_size.y; + Rect { + min: (min_x, min_y).into(), + max: (max_x, max_y).into(), + } + } + + // Compute the size and position of the horizontal scrollbar's thumb + pub fn horizontal_scrollbar_thumb(&self) -> Rect { + let gutter = self.horizontal_scrollbar_gutter(); + let width = gutter.size().x * gutter.size().x / self.content_size.x; + let min_x = gutter.size().x * self.scroll_position.x / self.content_size.x; + let min = (min_x, gutter.min.y).into(); + let max = min + Vec2::new(width, gutter.size().y); + Rect { min, max } + } + + // Compute the size and position of the vertical scrollbar's thumb + pub fn vertical_scrollbar_thumb(&self) -> Rect { + let gutter = self.vertical_scrollbar_gutter(); + let height = gutter.size().y * gutter.size().y / self.content_size.y; + let min_y = gutter.size().y * self.scroll_position.y / self.content_size.y; + let min = (gutter.min.x, min_y).into(); + let max = (gutter.max.x, min_y + height).into(); + Rect { min, max } + } } impl ComputedNode { @@ -2914,6 +2960,33 @@ impl ComputedUiRenderTargetInfo { } } +/// Styling for an automatic scrollbar +#[derive(Component, Clone, Copy, Debug, Reflect, PartialEq)] +#[reflect(Component, Default, PartialEq, Clone)] +#[cfg_attr( + feature = "serialize", + derive(serde::Serialize, serde::Deserialize), + reflect(Serialize, Deserialize) +)] +pub struct ScrollbarStyle { + /// Color of the scrollbar's thumb + pub thumb: Color, + /// Color of the scrollbar's gutter + pub gutter: Color, + /// Color of the scrollbar's corner section + pub corner: Color, +} + +impl Default for ScrollbarStyle { + fn default() -> Self { + Self { + thumb: Color::WHITE, + gutter: GRAY.into(), + corner: Color::BLACK, + } + } +} + #[cfg(test)] mod tests { use crate::GridPlacement; diff --git a/crates/bevy_ui_render/src/lib.rs b/crates/bevy_ui_render/src/lib.rs index 4a292d86ba322..f849a7bc17028 100644 --- a/crates/bevy_ui_render/src/lib.rs +++ b/crates/bevy_ui_render/src/lib.rs @@ -28,7 +28,7 @@ use bevy_sprite_render::SpriteAssetEvents; use bevy_ui::widget::{ImageNode, TextShadow, ViewportNode}; use bevy_ui::{ BackgroundColor, BorderColor, CalculatedClip, ComputedNode, ComputedUiTargetCamera, Display, - Node, Outline, ResolvedBorderRadius, UiGlobalTransform, + Node, Outline, ResolvedBorderRadius, ScrollbarStyle, UiGlobalTransform, }; use bevy_app::prelude::*; @@ -122,6 +122,7 @@ pub mod stack_z_offsets { pub const MATERIAL: f32 = 0.05; pub const TEXT: f32 = 0.06; pub const TEXT_STRIKETHROUGH: f32 = 0.07; + pub const SCROLLBARS: f32 = 0.1; } #[derive(Debug, Hash, PartialEq, Eq, Clone, SystemSet)] @@ -138,6 +139,7 @@ pub enum RenderUiSystems { ExtractText, ExtractDebug, ExtractGradient, + ExtractScrollbars, } /// Marker for controlling whether UI is rendered with or without anti-aliasing @@ -232,6 +234,7 @@ impl Plugin for UiRenderPlugin { RenderUiSystems::ExtractTextBackgrounds, RenderUiSystems::ExtractTextShadows, RenderUiSystems::ExtractText, + RenderUiSystems::ExtractScrollbars, RenderUiSystems::ExtractDebug, ) .chain(), @@ -248,6 +251,7 @@ impl Plugin for UiRenderPlugin { extract_text_decorations.in_set(RenderUiSystems::ExtractTextBackgrounds), extract_text_shadows.in_set(RenderUiSystems::ExtractTextShadows), extract_text_sections.in_set(RenderUiSystems::ExtractText), + extract_scrollbars.in_set(RenderUiSystems::ExtractScrollbars), #[cfg(feature = "bevy_ui_debug")] debug_overlay::extract_debug_overlay.in_set(RenderUiSystems::ExtractDebug), ), @@ -1304,6 +1308,132 @@ pub fn extract_text_decorations( } } +pub fn extract_scrollbars( + mut commands: Commands, + mut extracted_uinodes: ResMut, + uinode_query: Extract< + Query<( + Entity, + &ComputedNode, + &UiGlobalTransform, + &InheritedVisibility, + Option<&CalculatedClip>, + &ComputedUiTargetCamera, + Option<&ScrollbarStyle>, + )>, + >, + camera_map: Extract, +) { + let mut camera_mapper = camera_map.get_mapper(); + + for (entity, uinode, transform, inherited_visibility, clip, camera, colors) in &uinode_query { + // Skip invisible backgrounds + if !inherited_visibility.get() || uinode.is_empty() { + continue; + } + + let Some(extracted_camera_entity) = camera_mapper.map(camera) else { + continue; + }; + + if uinode.scrollbar_size.cmple(Vec2::ZERO).all() { + continue; + } + + let colors = colors.copied().unwrap_or_default(); + + let top_left = transform.affine() * Affine2::from_translation(-0.5 * uinode.size); + + let h_bar = uinode.horizontal_scrollbar_gutter(); + let v_bar = uinode.vertical_scrollbar_gutter(); + + let corner = Rect::from_corners( + Vec2::new(v_bar.min.x, h_bar.min.y), + Vec2::new(v_bar.max.x, h_bar.max.y), + ); + if !corner.is_empty() { + extracted_uinodes.uinodes.push(ExtractedUiNode { + render_entity: commands.spawn(TemporaryRenderEntity).id(), + z_order: uinode.stack_index as f32 + stack_z_offsets::SCROLLBARS, + clip: clip.map(|clip| clip.clip), + image: AssetId::default(), + extracted_camera_entity, + transform: top_left * Affine2::from_translation(corner.center()), + item: ExtractedUiItem::Node { + color: colors.corner.into(), + rect: Rect { + min: Vec2::ZERO, + max: corner.size(), + }, + atlas_scaling: None, + flip_x: false, + flip_y: false, + border: BorderRect::ZERO, + border_radius: ResolvedBorderRadius::ZERO, + node_type: NodeType::Rect, + }, + main_entity: entity.into(), + }); + } + + for (gutter, thumb) in [ + (h_bar, uinode.horizontal_scrollbar_thumb()), + (v_bar, uinode.vertical_scrollbar_thumb()), + ] { + if gutter.is_empty() { + continue; + } + let transform = top_left * Affine2::from_translation(gutter.center()); + extracted_uinodes.uinodes.push(ExtractedUiNode { + render_entity: commands.spawn(TemporaryRenderEntity).id(), + z_order: uinode.stack_index as f32 + stack_z_offsets::SCROLLBARS, + clip: clip.map(|clip| clip.clip), + image: AssetId::default(), + extracted_camera_entity, + transform, + item: ExtractedUiItem::Node { + color: colors.gutter.into(), + rect: Rect { + min: Vec2::ZERO, + max: gutter.size(), + }, + atlas_scaling: None, + flip_x: false, + flip_y: false, + border: BorderRect::ZERO, + border_radius: ResolvedBorderRadius::ZERO, + node_type: NodeType::Rect, + }, + main_entity: entity.into(), + }); + + let transform = top_left * Affine2::from_translation(thumb.center()); + extracted_uinodes.uinodes.push(ExtractedUiNode { + render_entity: commands.spawn(TemporaryRenderEntity).id(), + z_order: uinode.stack_index as f32 + stack_z_offsets::SCROLLBARS, + clip: clip.map(|clip| clip.clip), + image: AssetId::default(), + extracted_camera_entity, + transform, + item: ExtractedUiItem::Node { + color: colors.thumb.into(), + rect: Rect { + min: Vec2::ZERO, + max: thumb.size(), + }, + atlas_scaling: None, + flip_x: false, + flip_y: false, + border: BorderRect::ZERO, + border_radius: ResolvedBorderRadius::ZERO, + node_type: NodeType::Rect, + }, + main_entity: entity.into(), + }); + } + } +} + #[repr(C)] #[derive(Copy, Clone, Pod, Zeroable)] struct UiVertex { diff --git a/examples/ui/scroll.rs b/examples/ui/scroll.rs index e81644708b9a4..44e99caa67b08 100644 --- a/examples/ui/scroll.rs +++ b/examples/ui/scroll.rs @@ -230,6 +230,7 @@ fn vertically_scrolling_list(font_handle: Handle) -> impl Bundle { align_self: AlignSelf::Stretch, height: percent(50), overflow: Overflow::scroll_y(), // n.b. + scrollbar_width: 20., ..default() }, BackgroundColor(Color::srgb(0.10, 0.10, 0.10)), @@ -281,6 +282,7 @@ fn bidirectional_scrolling_list(font_handle: Handle) -> impl Bundle { align_self: AlignSelf::Stretch, height: percent(50), overflow: Overflow::scroll(), // n.b. + scrollbar_width: 20., ..default() }, BackgroundColor(Color::srgb(0.10, 0.10, 0.10)), @@ -336,6 +338,7 @@ fn bidirectional_scrolling_list_with_sticky(font_handle: Handle) -> impl B align_self: AlignSelf::Stretch, height: percent(50), overflow: Overflow::scroll(), // n.b. + scrollbar_width: 20., grid_template_columns: RepeatedGridTrack::auto(30), ..default() }, @@ -408,6 +411,7 @@ fn nested_scrolling_list(font_handle: Handle) -> impl Bundle { align_self: AlignSelf::Stretch, height: percent(50), overflow: Overflow::scroll(), + scrollbar_width: 20., ..default() }, BackgroundColor(Color::srgb(0.10, 0.10, 0.10)), @@ -419,6 +423,7 @@ fn nested_scrolling_list(font_handle: Handle) -> impl Bundle { align_self: AlignSelf::Stretch, height: percent(200. / 5. * (oi as f32 + 1.)), overflow: Overflow::scroll_y(), + scrollbar_width: 20., ..default() }, BackgroundColor(Color::srgb(0.05, 0.05, 0.05)), diff --git a/release-content/release-notes/automatic_scrollbar_rendering.md b/release-content/release-notes/automatic_scrollbar_rendering.md new file mode 100644 index 0000000000000..df15751be7401 --- /dev/null +++ b/release-content/release-notes/automatic_scrollbar_rendering.md @@ -0,0 +1,16 @@ +--- +title: Automatic scrollbar rendering +authors: ["@ickshonpe"] +pull_requests: [21897] +--- + +Scrollbars are now drawn automatically for nodes with scrolled content and a `scrollbar_width` greater than 0. + +Styling can be set using the `ScrollbarStyle` component. For now, it only supports changing the scrollbar's colors. + +`ComputedNode` has new methods that can be used to compute the geometry for a UI node's scrollbars: + +* `horizontal_scrollbar_gutter` +* `vertical_scrollbar_gutter` +* `horizontal_scrollbar_thumb`, +* `vertical_scrollbar_thumb`