use bevy_ecs::prelude::*;
use super::color::Color;
use super::geometry::{Background, Icon, Label, LabelSize, Position, Size};
use super::layout::{self, Anchor, Metrics};
use super::panel::{Closed, ClosesPanel, Dragging, Movable, PanelChild, PanelMarker, TitleBar};
use super::rect::Rect;
use super::resources::ScreenSize;
use crate::sceneobjects::{Category, SceneItem};
const ROW_HEIGHT: f32 = 17.0;
const ICON_SIZE: f32 = 13.0;
const PADDING: f32 = 5.0;
const LABEL_SIZE: f32 = 1.5;
const WIDTH: f32 = 200.0;
const TOP: f32 = 64.0;
const OVERFLOW_ROW: f32 = 1.0;
#[derive(Component, Clone, Copy, Debug)]
pub struct OutlinerEntity;
#[derive(Resource, Default)]
pub struct Outliner {
pub open: bool,
origin: Option<(f32, f32)>,
pub scroll: usize,
entities: Vec<Entity>,
listed: Vec<Entity>,
scrolled: usize,
}
impl Outliner {
pub fn is_open(&self) -> bool {
self.open
}
pub fn toggle(&mut self) {
self.open = !self.open;
}
}
pub fn rebuild_outliner_system(world: &mut World) {
let mut outliner = world.remove_resource::<Outliner>().unwrap_or_default();
let items: Vec<Entity> = {
let mut query = world.query::<(Entity, &SceneItem)>();
let mut items: Vec<(Entity, Category, &str)> = query
.iter(world)
.map(|(entity, item)| (entity, item.category(), item.name.as_str()))
.collect();
items.sort_by(|a, b| (a.1 as u8, a.2).cmp(&(b.1 as u8, b.2)));
items.into_iter().map(|(entity, _, _)| entity).collect()
};
let was = outliner.origin;
let mut dragging = false;
if let Some(&panel) = outliner.entities.first() {
if world.get::<Closed>(panel).is_some() {
outliner.open = false;
}
if let Some(pos) = world.get::<Position>(panel) {
outliner.origin = Some((pos.x, pos.y));
}
dragging = world.get::<Dragging>(panel).is_some();
}
let wanted = outliner.open;
let changed = items != outliner.listed
|| outliner.scroll != outliner.scrolled
|| (outliner.origin != was && !dragging);
if wanted && (!changed || dragging) && !outliner.entities.is_empty() {
world.insert_resource(outliner);
return;
}
for entity in outliner.entities.drain(..) {
if let Ok(entity) = world.get_entity_mut(entity) {
entity.despawn();
}
}
outliner.listed = items.clone();
outliner.scrolled = outliner.scroll;
if wanted {
let (width, height) = {
let screen = world.resource::<ScreenSize>();
(screen.width, screen.height)
};
let origin = outliner.origin.unwrap_or((layout::SCREEN_MARGIN, TOP));
let (entities, clamped) = spawn_list(world, &items, outliner.scroll, origin, width, height);
outliner.entities = entities;
outliner.scroll = clamped;
outliner.scrolled = clamped;
}
world.insert_resource(outliner);
}
fn spawn_list(
world: &mut World,
items: &[Entity],
scroll: usize,
origin: (f32, f32),
width: f32,
height: f32,
) -> (Vec<Entity>, usize) {
let all: Vec<(String, &'static str, Color)> = items
.iter()
.filter_map(|&entity| {
let item = world.get::<SceneItem>(entity)?;
Some((item.name.clone(), item.icon(), item.category().color()))
})
.collect();
let room =
(((height - origin.1 - layout::SCREEN_MARGIN - PADDING * 2.0 - layout::TITLE_HEIGHT)
/ ROW_HEIGHT)
.floor()
- OVERFLOW_ROW)
.max(1.0) as usize;
let scroll = scroll.min(all.len().saturating_sub(room));
let below = all.len().saturating_sub(scroll + room);
let mut rows: Vec<(String, &'static str, Color)> =
all.into_iter().skip(scroll).take(room).collect();
if scroll > 0 || below > 0 {
rows.push((
format!("{scroll} ABOVE, {below} BELOW"),
super::icons::path::LIST_BULLETS,
Color::srgb(0.55, 0.55, 0.60),
));
}
let panel_height = rows.len() as f32 * ROW_HEIGHT + PADDING * 2.0 + layout::TITLE_HEIGHT;
let panel = Rect::new(origin.0, origin.1, WIDTH, panel_height);
let (pos, size): (Position, Size) = panel.into();
let panel_entity = world
.spawn((
pos,
size,
Background(Color::srgba(0.08, 0.08, 0.11, 0.94)),
PanelMarker,
Movable,
OutlinerEntity,
Name::new("Outliner"),
))
.id();
let mut spawned = vec![panel_entity];
let bar = Rect::new(panel.x, panel.y, panel.width, layout::TITLE_HEIGHT);
let (bar_pos, bar_size): (Position, Size) = bar.into();
spawned.push(
world
.spawn((
bar_pos,
bar_size,
Background(Color::srgba(0.16, 0.16, 0.21, 0.96)),
Label("OUTLINER".into()),
LabelSize(layout::TITLE_LABEL_SIZE),
TitleBar {
panel: panel_entity,
},
PanelChild(panel_entity),
OutlinerEntity,
))
.id(),
);
let side = layout::TITLE_HEIGHT - layout::TITLE_INSET * 2.0;
let close = Rect::new(
bar.x + bar.width - side - layout::TITLE_INSET,
bar.y + layout::TITLE_INSET,
side,
side,
);
let (close_pos, close_size): (Position, Size) = close.into();
spawned.push(
world
.spawn((
close_pos,
close_size,
super::button::ButtonColors::close(),
Icon::new(super::icons::path::X),
super::interaction::Interaction::default(),
super::button::ButtonMarker,
ClosesPanel(panel_entity),
PanelChild(panel_entity),
OutlinerEntity,
))
.id(),
);
for (index, (name, icon, color)) in rows.into_iter().enumerate() {
let y = panel.y + layout::TITLE_HEIGHT + PADDING + index as f32 * ROW_HEIGHT;
spawned.push(
world
.spawn((
Position {
x: panel.x + PADDING,
y: y + (ROW_HEIGHT - ICON_SIZE) * 0.5,
},
Size {
width: ICON_SIZE,
height: ICON_SIZE,
},
Icon::tinted(icon, color),
PanelChild(panel_entity),
OutlinerEntity,
))
.id(),
);
spawned.push(
world
.spawn((
Position {
x: panel.x + PADDING * 2.0 + ICON_SIZE,
y,
},
Size {
width: WIDTH - ICON_SIZE - PADDING * 3.0,
height: ROW_HEIGHT,
},
Label(name),
LabelSize(LABEL_SIZE),
OutlinerRow,
PanelChild(panel_entity),
OutlinerEntity,
))
.id(),
);
}
let _ = width;
(spawned, scroll)
}
pub fn scroll_outliner_system(
mouse: Res<super::resources::MouseInput>,
cursor: Res<super::resources::CursorPosition>,
panels: Query<(&Position, &Size), (With<PanelMarker>, With<OutlinerEntity>)>,
mut outliner: ResMut<Outliner>,
) {
if mouse.scroll == 0.0 || !outliner.open {
return;
}
let over = panels
.iter()
.any(|(pos, size)| pos.rect(size).contains(cursor.x, cursor.y));
if !over {
return;
}
let by = mouse.scroll.round() as i32 * 3;
outliner.scroll = (outliner.scroll as i32 - by).max(0) as usize;
}
#[derive(Component, Clone, Copy, Debug)]
pub struct OutlinerRow;
pub fn row_metrics() -> Metrics {
Metrics {
button_width: WIDTH,
button_height: ROW_HEIGHT,
spacing: 0.0,
label_size: LABEL_SIZE,
}
}
pub fn anchor() -> Anchor {
Anchor::TopLeft
}
#[cfg(test)]
mod tests {
use super::*;
use crate::AppState;
use crate::ecs::{Application, Entity};
use crate::sceneobjects::{Category, SceneObject};
use crate::ui::icons::path;
use crate::ui::plugin::UiPlugin;
struct Lamp;
impl SceneObject for Lamp {
fn label(&self) -> &'static str {
"Lamp"
}
fn icon(&self) -> &'static str {
path::LAMP
}
fn category(&self) -> Category {
Category::Light
}
fn spawn(&self, app: &mut AppState) -> Entity {
app.spawn_entity(())
}
}
struct Block;
impl SceneObject for Block {
fn label(&self) -> &'static str {
"Block"
}
fn icon(&self) -> &'static str {
path::CUBE
}
fn category(&self) -> Category {
Category::Mesh
}
fn spawn(&self, app: &mut AppState) -> Entity {
app.spawn_entity(())
}
}
fn app() -> Application {
let mut app = Application::new();
app.add_plugin(UiPlugin);
app.world.insert_resource(ScreenSize {
width: 1280.0,
height: 800.0,
});
app
}
fn names(app: &mut Application) -> Vec<String> {
let mut rows: Vec<(f32, String)> = app
.world
.query_filtered::<(&Position, &Label), With<OutlinerRow>>()
.iter(&app.world)
.map(|(pos, label)| (pos.y, label.0.clone()))
.collect();
rows.sort_by(|a, b| a.0.total_cmp(&b.0));
rows.into_iter().map(|(_, name)| name).collect()
}
#[test]
fn nothing_is_listed_until_it_is_opened() {
let mut app = app();
app.world.spawn(SceneItem::new(&Lamp, "Lamp 1"));
app.update();
assert!(names(&mut app).is_empty());
app.world.resource_mut::<Outliner>().toggle();
app.update();
assert_eq!(names(&mut app), ["Lamp 1"]);
}
#[test]
fn rows_are_grouped_by_category_then_named_in_order() {
let mut app = app();
app.world.spawn(SceneItem::new(&Block, "Zebra"));
app.world.spawn(SceneItem::new(&Lamp, "Key"));
app.world.spawn(SceneItem::new(&Block, "Apple"));
app.world.spawn(SceneItem::new(&Lamp, "Fill"));
app.world.resource_mut::<Outliner>().toggle();
app.update();
assert_eq!(names(&mut app), ["Fill", "Key", "Apple", "Zebra"]);
}
#[test]
fn a_row_carries_its_kind_s_icon_tinted_by_its_category() {
let mut app = app();
app.world.spawn(SceneItem::new(&Lamp, "Lamp 1"));
app.world.resource_mut::<Outliner>().toggle();
app.update();
let icons: Vec<Icon> = app
.world
.query_filtered::<&Icon, Without<crate::ui::panel::ClosesPanel>>()
.iter(&app.world)
.copied()
.collect();
assert_eq!(icons.len(), 1);
assert_eq!(icons[0].path, path::LAMP);
assert_eq!(icons[0].color, Category::Light.color());
}
#[test]
fn the_list_follows_the_scene() {
let mut app = app();
let lamp = app.world.spawn(SceneItem::new(&Lamp, "Lamp 1")).id();
app.world.resource_mut::<Outliner>().toggle();
app.update();
assert_eq!(names(&mut app).len(), 1);
app.world.spawn(SceneItem::new(&Block, "Block 1"));
app.update();
assert_eq!(names(&mut app).len(), 2, "a new object appears");
app.world.despawn(lamp);
app.update();
assert_eq!(names(&mut app), ["Block 1"], "and a gone one leaves");
}
#[test]
fn closing_it_takes_every_row_with_it() {
let mut app = app();
app.world.spawn(SceneItem::new(&Lamp, "Lamp 1"));
app.world.resource_mut::<Outliner>().toggle();
app.update();
assert!(!names(&mut app).is_empty());
app.world.resource_mut::<Outliner>().toggle();
app.update();
assert!(names(&mut app).is_empty());
assert_eq!(
app.world.query::<&Icon>().iter(&app.world).count(),
0,
"and its icons, and the X in its bar",
);
}
#[test]
fn the_wheel_over_the_panel_is_the_panel_s() {
use crate::ui::resources::{CursorPosition, PointerCapture};
let mut app = app();
app.world.spawn(SceneItem::new(&Lamp, "Lamp 1"));
app.world.resource_mut::<Outliner>().toggle();
app.update();
let panel = app
.world
.query_filtered::<(&Position, &Size), (With<PanelMarker>, With<OutlinerEntity>)>()
.iter(&app.world)
.next()
.map(|(pos, size)| pos.rect(size))
.expect("the outliner panel");
app.world.insert_resource(CursorPosition {
x: panel.center_x(),
y: panel.center_y(),
});
app.update();
assert!(
app.world.resource::<PointerCapture>().taken(),
"the pointer is on the panel, so the mouse is the UI's",
);
app.world.insert_resource(CursorPosition {
x: panel.x + panel.width + 50.0,
y: panel.y,
});
app.update();
assert!(!app.world.resource::<PointerCapture>().taken());
}
#[test]
fn the_x_in_its_bar_closes_it() {
let mut app = app();
app.world.spawn(SceneItem::new(&Lamp, "Lamp 1"));
app.world.resource_mut::<Outliner>().toggle();
app.update();
assert!(!names(&mut app).is_empty());
let x = app
.world
.query::<(Entity, &ClosesPanel)>()
.iter(&app.world)
.next()
.map(|(entity, _)| entity)
.expect("an X in the bar");
let rect = {
let pos = app.world.get::<Position>(x).unwrap();
let size = app.world.get::<Size>(x).unwrap();
pos.rect(size)
};
app.world
.insert_resource(crate::ui::resources::CursorPosition {
x: rect.center_x(),
y: rect.center_y(),
});
app.world.insert_resource(crate::ui::resources::MouseInput {
left_down: true,
just_pressed: true,
..Default::default()
});
app.update();
app.update();
assert!(!app.world.resource::<Outliner>().is_open());
assert!(names(&mut app).is_empty(), "and its rows went with it");
}
#[test]
fn a_long_list_scrolls_and_says_what_it_is_hiding() {
let mut app = app();
for i in 0..100 {
app.world
.spawn(SceneItem::new(&Block, format!("Block {i:03}")));
}
app.world.resource_mut::<Outliner>().toggle();
app.update();
let first = names(&mut app);
assert!(
first.len() < 100,
"the whole list cannot fit: {}",
first.len()
);
assert_eq!(first[0], "Block 000");
let counts = first.last().expect("a row saying what is hidden");
assert!(counts.starts_with("0 ABOVE, "), "{counts}");
app.world.resource_mut::<Outliner>().scroll = 10;
app.update();
let scrolled = names(&mut app);
assert_eq!(scrolled[0], "Block 010", "it moved down the list");
assert!(scrolled.last().unwrap().starts_with("10 ABOVE, "));
app.world.resource_mut::<Outliner>().scroll = 1000;
app.update();
let end = names(&mut app);
assert!(!end.is_empty(), "scrolling past the end left nothing");
assert!(end.last().unwrap().ends_with("0 BELOW"), "{:?}", end.last());
}
}