diff --git a/Cargo.toml b/Cargo.toml index 70e9a4ca7cb15..dbbc69f3c433d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -112,6 +112,9 @@ bevy_ui = [ "bevy_sprite", ] +# Enable the bevy_ui debug overlay +bevy_ui_debug = ["bevy_internal/bevy_ui_debug"] + # winit window and input backend bevy_winit = ["bevy_internal/bevy_winit"] diff --git a/crates/bevy_internal/Cargo.toml b/crates/bevy_internal/Cargo.toml index a9c46dd06a615..cf47f8b42954b 100644 --- a/crates/bevy_internal/Cargo.toml +++ b/crates/bevy_internal/Cargo.toml @@ -118,6 +118,9 @@ file_watcher = ["bevy_asset?/file_watcher"] # Enables watching embedded files for Bevy Asset hot-reloading embedded_watcher = ["bevy_asset?/embedded_watcher"] +# Enable the bevy_ui debug overlay +bevy_ui_debug = ["bevy_ui?/debug"] + [dependencies] # bevy bevy_a11y = { path = "../bevy_a11y", version = "0.12.0" } diff --git a/crates/bevy_ui/Cargo.toml b/crates/bevy_ui/Cargo.toml index b6dd346f955ec..5ba089af35f22 100644 --- a/crates/bevy_ui/Cargo.toml +++ b/crates/bevy_ui/Cargo.toml @@ -8,27 +8,31 @@ repository = "https://github.com/bevyengine/bevy" license = "MIT OR Apache-2.0" keywords = ["bevy"] +[features] +default = [] +debug = ["bevy_gizmos", "bevy_core"] + [dependencies] # bevy bevy_a11y = { path = "../bevy_a11y", version = "0.12.0" } bevy_app = { path = "../bevy_app", version = "0.12.0" } bevy_asset = { path = "../bevy_asset", version = "0.12.0" } +bevy_core = { path = "../bevy_core", version = "0.12.0", optional = true } bevy_core_pipeline = { path = "../bevy_core_pipeline", version = "0.12.0" } bevy_derive = { path = "../bevy_derive", version = "0.12.0" } bevy_ecs = { path = "../bevy_ecs", version = "0.12.0" } +bevy_gizmos = { path = "../bevy_gizmos", version = "0.12.0", optional = true } bevy_hierarchy = { path = "../bevy_hierarchy", version = "0.12.0" } bevy_input = { path = "../bevy_input", version = "0.12.0" } bevy_log = { path = "../bevy_log", version = "0.12.0" } bevy_math = { path = "../bevy_math", version = "0.12.0" } -bevy_reflect = { path = "../bevy_reflect", version = "0.12.0", features = [ - "bevy", -] } +bevy_reflect = { path = "../bevy_reflect", version = "0.12.0", features = ["bevy"] } bevy_render = { path = "../bevy_render", version = "0.12.0" } bevy_sprite = { path = "../bevy_sprite", version = "0.12.0" } bevy_text = { path = "../bevy_text", version = "0.12.0", optional = true } bevy_transform = { path = "../bevy_transform", version = "0.12.0" } -bevy_window = { path = "../bevy_window", version = "0.12.0" } bevy_utils = { path = "../bevy_utils", version = "0.12.0" } +bevy_window = { path = "../bevy_window", version = "0.12.0" } # other taffy = { version = "0.3.10" } diff --git a/crates/bevy_ui/src/debug_overlay/inset.rs b/crates/bevy_ui/src/debug_overlay/inset.rs new file mode 100644 index 0000000000000..276bcfd639f1d --- /dev/null +++ b/crates/bevy_ui/src/debug_overlay/inset.rs @@ -0,0 +1,189 @@ +use bevy_gizmos::prelude::Gizmos; +use bevy_math::{Vec2, Vec2Swizzles}; +use bevy_render::prelude::Color; +use bevy_transform::prelude::GlobalTransform; +use bevy_utils::HashMap; + +use super::{CameraQuery, LayoutRect}; + +trait ApproxF32 { + fn is(self, other: f32) -> bool; +} +impl ApproxF32 for f32 { + fn is(self, other: f32) -> bool { + let diff = (self - other).abs(); + diff < 0.001 + } +} + +fn rect_border_axis(rect: LayoutRect) -> (f32, f32, f32, f32) { + let pos = rect.pos; + let size = rect.size; + let offset = pos + size; + (pos.x, offset.x, pos.y, offset.y) +} + +#[derive(PartialEq, Eq, PartialOrd, Ord, Clone, Copy, Debug)] +enum Dir { + Start, + End, +} +impl Dir { + const fn increments(self) -> i64 { + match self { + Dir::Start => 1, + Dir::End => -1, + } + } +} +impl From for Dir { + fn from(value: i64) -> Self { + if value.is_positive() { + Dir::Start + } else { + Dir::End + } + } +} +/// Collection of axis aligned "lines" (actually just their coordinate on +/// a given axis). +#[derive(Debug, Clone)] +struct DrawnLines { + lines: HashMap, + width: f32, +} +#[allow(clippy::cast_precision_loss, clippy::cast_possible_truncation)] +impl DrawnLines { + fn new(width: f32) -> Self { + DrawnLines { + lines: HashMap::new(), + width, + } + } + /// Return `value` offset by as many `increment`s as necessary to make it + /// not overlap with already drawn lines. + fn inset(&self, value: f32) -> f32 { + let scaled = value / self.width; + let fract = scaled.fract(); + let mut on_grid = scaled.floor() as i64; + for _ in 0..10 { + let Some(dir) = self.lines.get(&on_grid) else { + break; + }; + // TODO(clean): This fixes a panic, but I'm not sure how valid this is + let Some(added) = on_grid.checked_add(dir.increments()) else { + break; + }; + on_grid = added; + } + ((on_grid as f32) + fract) * self.width + } + /// Remove a line from the collection of drawn lines. + /// + /// Typically, we only care for pre-existing lines when drawing the children + /// of a container, nothing more. So we remove it after we are done with + /// the children. + fn remove(&mut self, value: f32, increment: i64) { + let mut on_grid = (value / self.width).floor() as i64; + loop { + // TODO(clean): This fixes a panic, but I'm not sure how valid this is + let Some(next_cell) = on_grid.checked_add(increment) else { + return; + }; + if !self.lines.contains_key(&next_cell) { + self.lines.remove(&on_grid); + return; + } + on_grid = next_cell; + } + } + /// Add a line from the collection of drawn lines. + fn add(&mut self, value: f32, increment: i64) { + let mut on_grid = (value / self.width).floor() as i64; + loop { + let old_value = self.lines.insert(on_grid, increment.into()); + if old_value.is_none() { + return; + } + // TODO(clean): This fixes a panic, but I'm not sure how valid this is + let Some(added) = on_grid.checked_add(increment) else { + return; + }; + on_grid = added; + } + } +} + +pub(super) struct InsetGizmo<'w, 's> { + draw: Gizmos<'s>, + cam: CameraQuery<'w, 's>, + known_y: DrawnLines, + known_x: DrawnLines, +} +impl<'w, 's> InsetGizmo<'w, 's> { + pub(super) fn new(draw: Gizmos<'s>, cam: CameraQuery<'w, 's>, line_width: f32) -> Self { + InsetGizmo { + draw, + cam, + known_y: DrawnLines::new(line_width), + known_x: DrawnLines::new(line_width), + } + } + fn relative(&self, mut position: Vec2) -> Vec2 { + let zero = GlobalTransform::IDENTITY; + let Ok(cam) = self.cam.get_single() else { + return Vec2::ZERO; + }; + if let Some(new_position) = cam.world_to_viewport(&zero, position.extend(0.)) { + position = new_position; + }; + position.xy() + } + fn line_2d(&mut self, mut start: Vec2, mut end: Vec2, color: Color) { + if start.x.is(end.x) { + start.x = self.known_x.inset(start.x); + end.x = start.x; + } else if start.y.is(end.y) { + start.y = self.known_y.inset(start.y); + end.y = start.y; + } + let (start, end) = (self.relative(start), self.relative(end)); + self.draw.line_2d(start, end, color); + } + pub(super) fn set_scope(&mut self, rect: LayoutRect) { + let (left, right, top, bottom) = rect_border_axis(rect); + self.known_x.add(left, 1); + self.known_x.add(right, -1); + self.known_y.add(top, 1); + self.known_y.add(bottom, -1); + } + pub(super) fn clear_scope(&mut self, rect: LayoutRect) { + let (left, right, top, bottom) = rect_border_axis(rect); + self.known_x.remove(left, 1); + self.known_x.remove(right, -1); + self.known_y.remove(top, 1); + self.known_y.remove(bottom, -1); + } + pub(super) fn rect_2d(&mut self, rect: LayoutRect, color: Color) { + let (left, right, top, bottom) = rect_border_axis(rect); + if left.is(right) { + self.line_2d(Vec2::new(left, top), Vec2::new(left, bottom), color); + } else if top.is(bottom) { + self.line_2d(Vec2::new(left, top), Vec2::new(right, top), color); + } else { + let inset_x = |v| self.known_x.inset(v); + let inset_y = |v| self.known_y.inset(v); + let (left, right) = (inset_x(left), inset_x(right)); + let (top, bottom) = (inset_y(top), inset_y(bottom)); + let strip = [ + Vec2::new(left, top), + Vec2::new(left, bottom), + Vec2::new(right, bottom), + Vec2::new(right, top), + Vec2::new(left, top), + ]; + self.draw + .linestrip_2d(strip.map(|v| self.relative(v)), color); + } + } +} diff --git a/crates/bevy_ui/src/debug_overlay/mod.rs b/crates/bevy_ui/src/debug_overlay/mod.rs new file mode 100644 index 0000000000000..6f4c6ee6247dc --- /dev/null +++ b/crates/bevy_ui/src/debug_overlay/mod.rs @@ -0,0 +1,247 @@ +//! A visual representation of UI node sizes. +use bevy_app::{App, Plugin, PostUpdate}; +use bevy_core::Name; +use bevy_core_pipeline::clear_color::ClearColorConfig; +use bevy_core_pipeline::core_2d::{Camera2d, Camera2dBundle}; +use bevy_ecs::{prelude::*, system::SystemParam}; +use bevy_gizmos::prelude::{GizmoConfig, Gizmos}; +use bevy_hierarchy::{Children, Parent}; +use bevy_input::prelude::{Input, KeyCode}; +use bevy_log::{info, warn}; +use bevy_math::{Vec2, Vec3Swizzles}; +use bevy_render::{prelude::*, view::RenderLayers}; +use bevy_transform::{prelude::GlobalTransform, TransformSystem}; +use bevy_utils::default; +use bevy_window::{PrimaryWindow, Window}; + +use crate::prelude::UiCameraConfig; +use crate::{Display, Node, Style}; +use inset::InsetGizmo; + +mod inset; + +/// The [`Camera::order`] index used by the layout debug camera. +pub const LAYOUT_DEBUG_CAMERA_ORDER: isize = 255; +/// The [`RenderLayers`] used by the debug gizmos and the debug camera. +pub const LAYOUT_DEBUG_LAYERS: RenderLayers = RenderLayers::none().with(16); + +const NODE_LIGHTNESS: f32 = 0.7; +const NODE_SATURATION: f32 = 0.8; + +fn hue_from_entity(entity: Entity) -> f32 { + const FRAC_U32MAX_GOLDEN_RATIO: u32 = 2_654_435_769; // (u32::MAX / Φ) rounded up + const RATIO_360: f32 = 360.0 / u32::MAX as f32; + entity.index().wrapping_mul(FRAC_U32MAX_GOLDEN_RATIO) as f32 * RATIO_360 +} + +#[derive(Clone, Copy)] +struct LayoutRect { + pos: Vec2, + size: Vec2, +} + +impl LayoutRect { + fn new(trans: &GlobalTransform, node: &Node) -> Self { + let mut this = Self { + pos: trans.translation().xy(), + size: node.size(), + }; + this.pos -= this.size / 2.; + this + } +} + +/// The inputs used by the `bevy_ui` debug overlay. +#[derive(Clone)] +pub struct InputMap { + /// The key used for enabling/disabling the debug overlay, default is [`KeyCode::F9`]. + pub toggle_key: KeyCode, +} +impl Default for InputMap { + fn default() -> Self { + InputMap { + toggle_key: KeyCode::F9, + } + } +} + +#[derive(Component, Debug, Clone, Default)] +struct DebugOverlayCamera; + +/// The debug overlay options. +#[derive(Resource, Clone, Default)] +pub struct Options { + /// Whether the overlay is enabled. + pub enabled: bool, + /// The inputs used by the debug overlay. + pub input_map: InputMap, + layout_gizmos_camera: Option, +} + +fn update_debug_camera( + mut gizmo_config: ResMut, + mut options: ResMut, + mut cmds: Commands, + mut debug_cams: Query<&mut Camera, With>, +) { + if !options.is_changed() && !gizmo_config.is_changed() { + return; + } + if !options.enabled { + let Some(cam) = options.layout_gizmos_camera else { + return; + }; + let Ok(mut cam) = debug_cams.get_mut(cam) else { + return; + }; + cam.is_active = false; + gizmo_config.render_layers = RenderLayers::all(); + } else { + let spawn_cam = || { + cmds.spawn(( + UiCameraConfig { show_ui: false }, + Camera2dBundle { + projection: OrthographicProjection { + far: 1000.0, + viewport_origin: Vec2::new(0.0, 0.0), + ..default() + }, + camera: Camera { + order: LAYOUT_DEBUG_CAMERA_ORDER, + ..default() + }, + camera_2d: Camera2d { + clear_color: ClearColorConfig::None, + }, + ..default() + }, + LAYOUT_DEBUG_LAYERS, + DebugOverlayCamera, + Name::new("Layout Debug Camera"), + )) + .id() + }; + gizmo_config.enabled = true; + gizmo_config.render_layers = LAYOUT_DEBUG_LAYERS; + let cam = *options.layout_gizmos_camera.get_or_insert_with(spawn_cam); + let Ok(mut cam) = debug_cams.get_mut(cam) else { + return; + }; + cam.is_active = true; + } +} + +fn toggle_overlay(input: Res>, mut options: ResMut) { + let map = &options.input_map; + if input.just_pressed(map.toggle_key) { + options.enabled = !options.enabled; + let mode = if options.enabled { + "Enabled" + } else { + "Disabled" + }; + info!("{mode} UI node preview"); + } +} + +fn outline_nodes(outline: &OutlineParam, draw: &mut InsetGizmo, this_entity: Entity) { + let Ok(to_iter) = outline.children.get(this_entity) else { + return; + }; + for (entity, trans, node, style, children) in outline.nodes.iter_many(to_iter) { + if style.is_none() || style.is_some_and(|s| matches!(s.display, Display::None)) { + continue; + } + let rect = LayoutRect::new(trans, node); + outline_node(entity, rect, draw); + if children.is_some() { + outline_nodes(outline, draw, entity); + } + draw.clear_scope(rect); + } +} + +type NodesQuery = ( + Entity, + &'static GlobalTransform, + &'static Node, + Option<&'static Style>, + Option<&'static Children>, +); + +#[derive(SystemParam)] +struct OutlineParam<'w, 's> { + gizmo_config: Res<'w, GizmoConfig>, + children: Query<'w, 's, &'static Children>, + nodes: Query<'w, 's, NodesQuery>, +} + +type CameraQuery<'w, 's> = Query<'w, 's, &'static Camera, With>; + +fn outline_roots( + outline: OutlineParam, + draw: Gizmos, + cam: CameraQuery, + roots: Query<(Entity, &GlobalTransform, &Node), Without>, + window: Query<&Window, With>, + nonprimary_windows: Query<&Window, Without>, + options: Res, +) { + if !options.enabled { + return; + } + if !nonprimary_windows.is_empty() { + warn!( + "The layout debug view only uses the primary window scale, \ + you might notice gaps between container lines" + ); + } + let scale_factor = Window::scale_factor; + let window_scale = window.get_single().map_or(1., scale_factor) as f32; + let line_width = outline.gizmo_config.line_width / window_scale; + let mut draw = InsetGizmo::new(draw, cam, line_width); + for (entity, trans, node) in &roots { + let rect = LayoutRect::new(trans, node); + outline_node(entity, rect, &mut draw); + outline_nodes(&outline, &mut draw, entity); + } +} +fn outline_node(entity: Entity, rect: LayoutRect, draw: &mut InsetGizmo) { + let hue = hue_from_entity(entity); + let color = Color::hsl(hue, NODE_SATURATION, NODE_LIGHTNESS); + + draw.rect_2d(rect, color); + draw.set_scope(rect); +} + +/// The debug overlay plugin. +/// +/// This spawns a new camera with a low order, and draws gizmo. +/// +/// Note that while the debug plugin is enabled, gizmos cannot be used by other +/// cameras. +/// +/// disabling the plugin will give you back gizmo control. +pub struct DebugUiPlugin; +impl Plugin for DebugUiPlugin { + fn build(&self, app: &mut App) { + app.init_resource::().add_systems( + PostUpdate, + ( + toggle_overlay, + update_debug_camera, + outline_roots.after(TransformSystem::TransformPropagate), + ) + .chain(), + ); + } + fn finish(&self, _app: &mut App) { + info!( + "The bevy_ui debug overlay is active!\n\ + ----------------------------------------------\n\ + \n\ + This will show the outline of UI nodes.\n\ + Press `F9` to switch between debug mods." + ); + } +} diff --git a/crates/bevy_ui/src/lib.rs b/crates/bevy_ui/src/lib.rs index e72eb5ea777d2..fc328797862f8 100644 --- a/crates/bevy_ui/src/lib.rs +++ b/crates/bevy_ui/src/lib.rs @@ -12,6 +12,8 @@ pub mod ui_material; pub mod update; pub mod widget; +#[cfg(feature = "debug")] +mod debug_overlay; use bevy_derive::{Deref, DerefMut}; use bevy_reflect::Reflect; #[cfg(feature = "bevy_text")] @@ -160,6 +162,9 @@ impl Plugin for UiPlugin { .before(Assets::::track_assets), ), ); + #[cfg(feature = "debug")] + app.add_plugins(debug_overlay::DebugUiPlugin); + #[cfg(feature = "bevy_text")] app.add_plugins(accessibility::AccessibilityPlugin); app.add_systems(PostUpdate, { diff --git a/docs/cargo_features.md b/docs/cargo_features.md index 1b2258fbf6b63..c776502edd895 100644 --- a/docs/cargo_features.md +++ b/docs/cargo_features.md @@ -48,6 +48,7 @@ The default feature set enables most of the expected features of a game engine, |basis-universal|Basis Universal compressed texture support| |bevy_ci_testing|Enable systems that allow for automated testing on CI| |bevy_dynamic_plugin|Plugin for dynamic loading (using [libloading](https://crates.io/crates/libloading))| +|bevy_ui_debug|Enable the bevy_ui debug overlay| |bmp|BMP image format support| |dds|DDS compressed texture support| |debug_glam_assert|Enable assertions in debug builds to check the validity of parameters passed to glam|