use std::collections::VecDeque;
use astrelis_core::alloc::{HashMap, HashSet};
use astrelis_core::math::Vec2;
use astrelis_render::Color;
use crate::dirty::DirtyFlags;
use crate::metrics_collector::{FrameTimingMetrics, MetricsCollector};
use crate::tree::{NodeId, UiTree};
use crate::widget_id::{WidgetId, WidgetIdRegistry};
#[derive(Debug, Clone)]
pub struct InspectorConfig {
pub show_bounds: bool,
pub show_dirty_flags: bool,
pub show_graphs: bool,
pub show_tree_view: bool,
pub show_properties: bool,
pub max_tree_depth: usize,
pub graph_history_size: usize,
pub highlight_hover: bool,
pub show_layout_details: bool,
}
impl Default for InspectorConfig {
fn default() -> Self {
Self {
show_bounds: true,
show_dirty_flags: true,
show_graphs: true,
show_tree_view: true,
show_properties: true,
max_tree_depth: 0,
graph_history_size: 120,
highlight_hover: true,
show_layout_details: false,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum WidgetKind {
Container,
Text,
Button,
Image,
TextInput,
Checkbox,
Slider,
ScrollView,
Custom,
Unknown,
}
impl WidgetKind {
pub fn color(&self) -> Color {
match self {
Self::Container => Color::rgba(0.2, 0.5, 0.9, 0.25),
Self::Text => Color::rgba(0.2, 0.8, 0.3, 0.25),
Self::Button => Color::rgba(0.9, 0.5, 0.2, 0.25),
Self::Image => Color::rgba(0.8, 0.2, 0.6, 0.25),
Self::TextInput => Color::rgba(0.2, 0.8, 0.8, 0.25),
Self::Checkbox => Color::rgba(0.8, 0.8, 0.2, 0.25),
Self::Slider => Color::rgba(0.6, 0.2, 0.8, 0.25),
Self::ScrollView => Color::rgba(0.4, 0.6, 0.8, 0.25),
Self::Custom => Color::rgba(0.6, 0.6, 0.6, 0.25),
Self::Unknown => Color::rgba(0.5, 0.5, 0.5, 0.25),
}
}
pub fn border_color(&self) -> Color {
let mut c = self.color();
c.a = 0.8;
c
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum PropertyValue {
Float(f32),
Int(i32),
Bool(bool),
Color(Color),
String(String),
Vec2(Vec2),
}
#[derive(Debug, Clone)]
pub struct EditableProperty {
pub name: String,
pub category: PropertyCategory,
pub value: PropertyValue,
pub affects_layout: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PropertyCategory {
Layout,
Style,
Text,
Transform,
Behavior,
}
#[derive(Debug, Clone)]
pub struct TreeNodeInfo {
pub node_id: NodeId,
pub widget_id: Option<WidgetId>,
pub kind: WidgetKind,
pub label: String,
pub depth: usize,
pub child_count: usize,
pub bounds: (f32, f32, f32, f32),
pub dirty_flags: DirtyFlags,
pub is_expanded: bool,
pub is_visible: bool,
}
#[derive(Debug, Default)]
pub struct TreeViewState {
nodes: Vec<TreeNodeInfo>,
expanded: HashSet<NodeId>,
_scroll_offset: f32,
filter: String,
}
impl TreeViewState {
pub fn new() -> Self {
Self::default()
}
pub fn toggle_expand(&mut self, node_id: NodeId) {
if self.expanded.contains(&node_id) {
self.expanded.remove(&node_id);
} else {
self.expanded.insert(node_id);
}
}
pub fn expand_all(&mut self) {
for node in &self.nodes {
self.expanded.insert(node.node_id);
}
}
pub fn collapse_all(&mut self) {
self.expanded.clear();
}
pub fn set_filter(&mut self, filter: String) {
self.filter = filter;
}
pub fn visible_nodes(&self) -> impl Iterator<Item = &TreeNodeInfo> {
self.nodes.iter().filter(|n| n.is_visible)
}
}
#[derive(Debug, Clone)]
pub struct PropertyEditor {
pub target_node: NodeId,
pub properties: Vec<EditableProperty>,
pub pending_changes: Vec<(String, PropertyValue)>,
}
impl PropertyEditor {
pub fn new(node_id: NodeId) -> Self {
Self {
target_node: node_id,
properties: Vec::new(),
pending_changes: Vec::new(),
}
}
pub fn set_property(&mut self, name: String, value: PropertyValue) {
self.pending_changes.push((name, value));
}
pub fn has_pending_changes(&self) -> bool {
!self.pending_changes.is_empty()
}
pub fn clear_pending(&mut self) {
self.pending_changes.clear();
}
}
#[derive(Debug, Clone, Copy)]
pub struct GraphPoint {
pub frame: u64,
pub value: f32,
}
#[derive(Debug)]
pub struct InspectorGraphs {
pub frame_times: VecDeque<GraphPoint>,
pub layout_times: VecDeque<GraphPoint>,
pub text_times: VecDeque<GraphPoint>,
pub dirty_counts: VecDeque<GraphPoint>,
pub max_size: usize,
}
impl InspectorGraphs {
pub fn new(max_size: usize) -> Self {
Self {
frame_times: VecDeque::with_capacity(max_size),
layout_times: VecDeque::with_capacity(max_size),
text_times: VecDeque::with_capacity(max_size),
dirty_counts: VecDeque::with_capacity(max_size),
max_size,
}
}
pub fn update(&mut self, metrics: &FrameTimingMetrics) {
let frame_id = metrics.frame_id;
self.frame_times.push_back(GraphPoint {
frame: frame_id,
value: metrics.total_frame_time().as_secs_f32() * 1000.0,
});
self.layout_times.push_back(GraphPoint {
frame: frame_id,
value: metrics.total_layout_time.as_secs_f32() * 1000.0,
});
self.text_times.push_back(GraphPoint {
frame: frame_id,
value: metrics.text_shaping_time.as_secs_f32() * 1000.0,
});
let total_dirty = (metrics.nodes_layout_dirty
+ metrics.nodes_text_dirty
+ metrics.nodes_paint_dirty) as f32;
self.dirty_counts.push_back(GraphPoint {
frame: frame_id,
value: total_dirty,
});
while self.frame_times.len() > self.max_size {
self.frame_times.pop_front();
}
while self.layout_times.len() > self.max_size {
self.layout_times.pop_front();
}
while self.text_times.len() > self.max_size {
self.text_times.pop_front();
}
while self.dirty_counts.len() > self.max_size {
self.dirty_counts.pop_front();
}
}
pub fn max_frame_time(&self) -> f32 {
self.frame_times
.iter()
.map(|p| p.value)
.fold(16.67, f32::max)
}
pub fn avg_frame_time(&self) -> f32 {
if self.frame_times.is_empty() {
return 0.0;
}
let sum: f32 = self.frame_times.iter().map(|p| p.value).sum();
sum / self.frame_times.len() as f32
}
pub fn clear(&mut self) {
self.frame_times.clear();
self.layout_times.clear();
self.text_times.clear();
self.dirty_counts.clear();
}
}
#[derive(Debug, Default)]
pub struct SearchState {
pub query: String,
pub results: Vec<NodeId>,
pub current_result_index: usize,
}
impl SearchState {
pub fn search(&mut self, nodes: &[TreeNodeInfo]) {
self.results.clear();
self.current_result_index = 0;
if self.query.is_empty() {
return;
}
let query_lower = self.query.to_lowercase();
for node in nodes {
if node.label.to_lowercase().contains(&query_lower) {
self.results.push(node.node_id);
}
}
}
pub fn next_result(&mut self) -> Option<NodeId> {
if self.results.is_empty() {
return None;
}
self.current_result_index = (self.current_result_index + 1) % self.results.len();
Some(self.results[self.current_result_index])
}
pub fn prev_result(&mut self) -> Option<NodeId> {
if self.results.is_empty() {
return None;
}
if self.current_result_index == 0 {
self.current_result_index = self.results.len() - 1;
} else {
self.current_result_index -= 1;
}
Some(self.results[self.current_result_index])
}
}
#[derive(Debug, Clone)]
pub struct OverlayQuad {
pub position: Vec2,
pub size: Vec2,
pub fill_color: Color,
pub border_color: Option<Color>,
pub border_width: f32,
}
pub struct UiInspector {
config: InspectorConfig,
enabled: bool,
tree_view: TreeViewState,
selected: Option<NodeId>,
hovered: Option<NodeId>,
property_editor: Option<PropertyEditor>,
graphs: InspectorGraphs,
search: SearchState,
widget_kinds: HashMap<NodeId, WidgetKind>,
node_to_widget_id: HashMap<NodeId, WidgetId>,
}
impl UiInspector {
pub fn new(config: InspectorConfig) -> Self {
Self {
graphs: InspectorGraphs::new(config.graph_history_size),
config,
enabled: false,
tree_view: TreeViewState::new(),
selected: None,
hovered: None,
property_editor: None,
search: SearchState::default(),
widget_kinds: HashMap::new(),
node_to_widget_id: HashMap::new(),
}
}
pub fn toggle(&mut self) {
self.enabled = !self.enabled;
}
pub fn is_enabled(&self) -> bool {
self.enabled
}
pub fn enable(&mut self) {
self.enabled = true;
}
pub fn disable(&mut self) {
self.enabled = false;
}
pub fn config(&self) -> &InspectorConfig {
&self.config
}
pub fn config_mut(&mut self) -> &mut InspectorConfig {
&mut self.config
}
pub fn selected(&self) -> Option<NodeId> {
self.selected
}
pub fn select(&mut self, node_id: Option<NodeId>) {
self.selected = node_id;
if let Some(id) = node_id {
self.property_editor = Some(PropertyEditor::new(id));
} else {
self.property_editor = None;
}
}
pub fn set_hovered(&mut self, node_id: Option<NodeId>) {
self.hovered = node_id;
}
pub fn tree_view(&self) -> &TreeViewState {
&self.tree_view
}
pub fn tree_view_mut(&mut self) -> &mut TreeViewState {
&mut self.tree_view
}
pub fn property_editor(&self) -> Option<&PropertyEditor> {
self.property_editor.as_ref()
}
pub fn property_editor_mut(&mut self) -> Option<&mut PropertyEditor> {
self.property_editor.as_mut()
}
pub fn graphs(&self) -> &InspectorGraphs {
&self.graphs
}
pub fn search(&self) -> &SearchState {
&self.search
}
pub fn search_mut(&mut self) -> &mut SearchState {
&mut self.search
}
pub fn update(
&mut self,
tree: &UiTree,
registry: &WidgetIdRegistry,
metrics: Option<&MetricsCollector>,
) {
if !self.enabled {
return;
}
self.update_tree_view(tree, registry);
if let Some(collector) = metrics
&& let Some(frame_metrics) = collector.current_metrics()
{
self.graphs.update(frame_metrics);
}
let update_node = self
.property_editor
.as_ref()
.filter(|editor| tree.get_node(editor.target_node).is_some())
.map(|editor| editor.target_node);
if let Some(node_id) = update_node {
self.update_properties(tree, node_id);
}
if !self.search.query.is_empty() {
self.search.search(&self.tree_view.nodes);
}
}
fn update_tree_view(&mut self, tree: &UiTree, registry: &WidgetIdRegistry) {
self.tree_view.nodes.clear();
self.widget_kinds.clear();
self.node_to_widget_id.clear();
if let Some(root_id) = tree.root() {
self.collect_tree_nodes(tree, registry, root_id, 0);
}
self.update_node_visibility();
}
fn collect_tree_nodes(
&mut self,
tree: &UiTree,
registry: &WidgetIdRegistry,
node_id: NodeId,
depth: usize,
) {
let Some(node) = tree.get_node(node_id) else {
return;
};
let kind = self.classify_widget(tree, node_id);
self.widget_kinds.insert(node_id, kind);
let widget_id = registry.find_by_node(node_id);
if let Some(wid) = widget_id {
self.node_to_widget_id.insert(node_id, wid);
}
let bounds = if let Some(layout) = tree.get_layout(node_id) {
(layout.x, layout.y, layout.width, layout.height)
} else {
(0.0, 0.0, 0.0, 0.0)
};
let label = self.generate_node_label(kind, widget_id, node_id);
let info = TreeNodeInfo {
node_id,
widget_id,
kind,
label,
depth,
child_count: node.children.len(),
bounds,
dirty_flags: node.dirty_flags,
is_expanded: self.tree_view.expanded.contains(&node_id),
is_visible: true, };
self.tree_view.nodes.push(info);
if self.config.max_tree_depth == 0 || depth < self.config.max_tree_depth {
for &child_id in &node.children {
self.collect_tree_nodes(tree, registry, child_id, depth + 1);
}
}
}
fn classify_widget(&self, tree: &UiTree, node_id: NodeId) -> WidgetKind {
let Some(widget) = tree.get_widget(node_id) else {
return WidgetKind::Unknown;
};
let any = widget.as_any();
if any.is::<crate::widgets::Container>() {
WidgetKind::Container
} else if any.is::<crate::widgets::Text>() {
WidgetKind::Text
} else if any.is::<crate::widgets::Button>() {
WidgetKind::Button
} else if any.is::<crate::widgets::Image>() {
WidgetKind::Image
} else if any.is::<crate::widgets::TextInput>() {
WidgetKind::TextInput
} else {
WidgetKind::Custom
}
}
fn generate_node_label(
&self,
kind: WidgetKind,
widget_id: Option<WidgetId>,
node_id: NodeId,
) -> String {
let kind_str = match kind {
WidgetKind::Container => "Container",
WidgetKind::Text => "Text",
WidgetKind::Button => "Button",
WidgetKind::Image => "Image",
WidgetKind::TextInput => "TextInput",
WidgetKind::Checkbox => "Checkbox",
WidgetKind::Slider => "Slider",
WidgetKind::ScrollView => "ScrollView",
WidgetKind::Custom => "Custom",
WidgetKind::Unknown => "Unknown",
};
if let Some(wid) = widget_id {
format!("{} {}", kind_str, wid)
} else {
format!("{} #{}", kind_str, node_id.0)
}
}
fn update_node_visibility(&mut self) {
let mut visible_depths: HashMap<usize, bool> = HashMap::new();
visible_depths.insert(0, true);
let filter_lower = self.tree_view.filter.to_lowercase();
let has_filter = !filter_lower.is_empty();
for node in &mut self.tree_view.nodes {
let parent_visible = visible_depths.get(&node.depth).copied().unwrap_or(false);
let matches_filter = !has_filter || node.label.to_lowercase().contains(&filter_lower);
node.is_visible = parent_visible && matches_filter;
let children_visible = parent_visible && node.is_expanded;
visible_depths.insert(node.depth + 1, children_visible);
}
}
fn update_properties(&mut self, tree: &UiTree, node_id: NodeId) {
let Some(widget) = tree.get_widget(node_id) else {
return;
};
let Some(editor) = &mut self.property_editor else {
return;
};
editor.properties.clear();
let style = widget.style();
editor.properties.push(EditableProperty {
name: "background_color".to_string(),
category: PropertyCategory::Style,
value: PropertyValue::Color(style.background_color.unwrap_or(Color::TRANSPARENT)),
affects_layout: false,
});
editor.properties.push(EditableProperty {
name: "border_radius".to_string(),
category: PropertyCategory::Style,
value: PropertyValue::Float(style.border_radius),
affects_layout: false,
});
editor.properties.push(EditableProperty {
name: "border_width".to_string(),
category: PropertyCategory::Style,
value: PropertyValue::Float(style.border_width),
affects_layout: true,
});
if let Some(layout) = tree.get_layout(node_id) {
editor.properties.push(EditableProperty {
name: "position".to_string(),
category: PropertyCategory::Layout,
value: PropertyValue::Vec2(Vec2::new(layout.x, layout.y)),
affects_layout: false, });
editor.properties.push(EditableProperty {
name: "size".to_string(),
category: PropertyCategory::Layout,
value: PropertyValue::Vec2(Vec2::new(layout.width, layout.height)),
affects_layout: false, });
}
}
pub fn hit_test(&self, tree: &UiTree, pos: Vec2) -> Option<NodeId> {
let mut result = None;
for node_info in &self.tree_view.nodes {
let (_x, _y, w, h) = node_info.bounds;
if w <= 0.0 || h <= 0.0 {
continue;
}
let abs_bounds = self.calculate_absolute_bounds(tree, node_info.node_id)?;
let (ax, ay, aw, ah) = abs_bounds;
if pos.x >= ax && pos.x <= ax + aw && pos.y >= ay && pos.y <= ay + ah {
result = Some(node_info.node_id);
}
}
result
}
fn calculate_absolute_bounds(
&self,
tree: &UiTree,
node_id: NodeId,
) -> Option<(f32, f32, f32, f32)> {
let layout = tree.get_layout(node_id)?;
let mut abs_x = layout.x;
let mut abs_y = layout.y;
let mut current = tree.get_node(node_id)?.parent;
while let Some(parent_id) = current {
if let Some(parent_layout) = tree.get_layout(parent_id) {
abs_x += parent_layout.x;
abs_y += parent_layout.y;
}
current = tree.get_node(parent_id)?.parent;
}
Some((abs_x, abs_y, layout.width, layout.height))
}
pub fn generate_overlay_quads(&self, tree: &UiTree) -> Vec<OverlayQuad> {
if !self.enabled {
return Vec::new();
}
let mut quads = Vec::new();
for node_info in &self.tree_view.nodes {
let Some((abs_x, abs_y, width, height)) =
self.calculate_absolute_bounds(tree, node_info.node_id)
else {
continue;
};
if width <= 0.0 || height <= 0.0 {
continue;
}
let is_selected = self.selected == Some(node_info.node_id);
let is_hovered = self.hovered == Some(node_info.node_id);
if self.config.show_bounds {
let mut fill_color = node_info.kind.color();
let mut border_color = node_info.kind.border_color();
if is_selected {
fill_color = Color::rgba(1.0, 0.8, 0.0, 0.3);
border_color = Color::rgba(1.0, 0.8, 0.0, 1.0);
} else if is_hovered && self.config.highlight_hover {
fill_color = Color::rgba(0.3, 0.7, 1.0, 0.3);
border_color = Color::rgba(0.3, 0.7, 1.0, 1.0);
}
quads.push(OverlayQuad {
position: Vec2::new(abs_x, abs_y),
size: Vec2::new(width, height),
fill_color,
border_color: Some(border_color),
border_width: 1.0,
});
}
if self.config.show_dirty_flags && !node_info.dirty_flags.is_empty() {
let color = dirty_flags_to_color(node_info.dirty_flags);
quads.push(OverlayQuad {
position: Vec2::new(abs_x + 2.0, abs_y + 2.0),
size: Vec2::new(8.0, 8.0),
fill_color: color,
border_color: None,
border_width: 0.0,
});
}
}
quads
}
pub fn export_tree_snapshot(&self) -> String {
let mut result = String::new();
result.push_str("=== UI Tree Snapshot ===\n\n");
for node in &self.tree_view.nodes {
let indent = " ".repeat(node.depth);
let dirty = if !node.dirty_flags.is_empty() {
format!(" [DIRTY: {:?}]", node.dirty_flags)
} else {
String::new()
};
let (x, y, w, h) = node.bounds;
result.push_str(&format!(
"{}{} @ ({:.1}, {:.1}) {}x{}{}\n",
indent, node.label, x, y, w as i32, h as i32, dirty
));
}
result
}
pub fn generate_summary_text(&self) -> String {
let total_nodes = self.tree_view.nodes.len();
let dirty_nodes = self
.tree_view
.nodes
.iter()
.filter(|n| !n.dirty_flags.is_empty())
.count();
let avg_frame_time = self.graphs.avg_frame_time();
format!(
"Nodes: {} | Dirty: {} | Avg Frame: {:.2}ms | FPS: {:.1}",
total_nodes,
dirty_nodes,
avg_frame_time,
if avg_frame_time > 0.0 {
1000.0 / avg_frame_time
} else {
0.0
}
)
}
pub fn generate_tree_text(&self) -> String {
let mut result = String::new();
for node in self.tree_view.visible_nodes() {
let indent = " ".repeat(node.depth);
let expand_marker = if node.child_count > 0 {
if node.is_expanded { "â–¼ " } else { "â–¶ " }
} else {
" "
};
let selected_marker = if self.selected == Some(node.node_id) {
" â—„"
} else {
""
};
result.push_str(&format!(
"{}{}{}{}\n",
indent, expand_marker, node.label, selected_marker
));
}
result
}
pub fn generate_properties_text(&self) -> String {
let Some(editor) = &self.property_editor else {
return "No widget selected".to_string();
};
let mut result = format!("=== Node {} Properties ===\n\n", editor.target_node.0);
let mut current_category: Option<PropertyCategory> = None;
for prop in &editor.properties {
if current_category != Some(prop.category) {
current_category = Some(prop.category);
result.push_str(&format!("\n[{:?}]\n", prop.category));
}
let value_str = match &prop.value {
PropertyValue::Float(f) => format!("{:.2}", f),
PropertyValue::Int(i) => format!("{}", i),
PropertyValue::Bool(b) => format!("{}", b),
PropertyValue::Color(c) => format!(
"#{:02X}{:02X}{:02X}{:02X}",
(c.r * 255.0) as u8,
(c.g * 255.0) as u8,
(c.b * 255.0) as u8,
(c.a * 255.0) as u8
),
PropertyValue::String(s) => format!("\"{}\"", s),
PropertyValue::Vec2(v) => format!("({:.1}, {:.1})", v.x, v.y),
};
result.push_str(&format!(" {}: {}\n", prop.name, value_str));
}
result
}
}
impl Default for UiInspector {
fn default() -> Self {
Self::new(InspectorConfig::default())
}
}
fn dirty_flags_to_color(flags: DirtyFlags) -> Color {
if flags.contains(DirtyFlags::LAYOUT) {
Color::rgba(1.0, 0.0, 0.0, 0.8) } else if flags.contains(DirtyFlags::TEXT_SHAPING) {
Color::rgba(1.0, 0.5, 0.0, 0.8) } else if flags.contains(DirtyFlags::GEOMETRY) {
Color::rgba(1.0, 1.0, 0.0, 0.8) } else if flags.contains(DirtyFlags::COLOR) {
Color::rgba(0.0, 1.0, 0.0, 0.8) } else if flags.contains(DirtyFlags::OPACITY) {
Color::rgba(0.0, 0.8, 0.8, 0.8) } else {
Color::rgba(0.5, 0.5, 0.5, 0.8) }
}
pub trait WidgetIdRegistryExt {
fn find_by_node(&self, node_id: NodeId) -> Option<WidgetId>;
}
impl WidgetIdRegistryExt for WidgetIdRegistry {
fn find_by_node(&self, node_id: NodeId) -> Option<WidgetId> {
self.iter()
.find(|(_, nid)| *nid == node_id)
.map(|(wid, _)| wid)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_inspector_toggle() {
let mut inspector = UiInspector::new(InspectorConfig::default());
assert!(!inspector.is_enabled());
inspector.toggle();
assert!(inspector.is_enabled());
inspector.toggle();
assert!(!inspector.is_enabled());
}
#[test]
fn test_inspector_selection() {
let mut inspector = UiInspector::new(InspectorConfig::default());
let node_id = NodeId(42);
inspector.select(Some(node_id));
assert_eq!(inspector.selected(), Some(node_id));
assert!(inspector.property_editor().is_some());
inspector.select(None);
assert_eq!(inspector.selected(), None);
assert!(inspector.property_editor().is_none());
}
#[test]
fn test_tree_view_expansion() {
let mut tree_view = TreeViewState::new();
let node_id = NodeId(1);
assert!(!tree_view.expanded.contains(&node_id));
tree_view.toggle_expand(node_id);
assert!(tree_view.expanded.contains(&node_id));
tree_view.toggle_expand(node_id);
assert!(!tree_view.expanded.contains(&node_id));
}
#[test]
fn test_search_state() {
let mut search = SearchState::default();
let nodes = vec![
TreeNodeInfo {
node_id: NodeId(1),
widget_id: None,
kind: WidgetKind::Button,
label: "Button \"submit\"".to_string(),
depth: 0,
child_count: 0,
bounds: (0.0, 0.0, 100.0, 50.0),
dirty_flags: DirtyFlags::NONE,
is_expanded: false,
is_visible: true,
},
TreeNodeInfo {
node_id: NodeId(2),
widget_id: None,
kind: WidgetKind::Text,
label: "Text \"hello\"".to_string(),
depth: 0,
child_count: 0,
bounds: (0.0, 0.0, 100.0, 20.0),
dirty_flags: DirtyFlags::NONE,
is_expanded: false,
is_visible: true,
},
];
search.query = "button".to_string();
search.search(&nodes);
assert_eq!(search.results.len(), 1);
assert_eq!(search.results[0], NodeId(1));
}
#[test]
fn test_graph_update() {
let mut graphs = InspectorGraphs::new(10);
for i in 0..15 {
let metrics = FrameTimingMetrics {
frame_id: i,
nodes_layout_dirty: (i % 5) as usize,
nodes_text_dirty: 0,
nodes_paint_dirty: 0,
..Default::default()
};
graphs.update(&metrics);
}
assert_eq!(graphs.frame_times.len(), 10);
assert_eq!(graphs.dirty_counts.len(), 10);
}
#[test]
fn test_widget_kind_colors() {
let kinds = [
WidgetKind::Container,
WidgetKind::Text,
WidgetKind::Button,
WidgetKind::Image,
];
for (i, kind1) in kinds.iter().enumerate() {
for (j, kind2) in kinds.iter().enumerate() {
if i != j {
assert_ne!(kind1.color(), kind2.color());
}
}
}
}
#[test]
fn test_dirty_flags_color() {
let layout_color = dirty_flags_to_color(DirtyFlags::LAYOUT);
let text_color = dirty_flags_to_color(DirtyFlags::TEXT_SHAPING);
let color_only = dirty_flags_to_color(DirtyFlags::COLOR);
assert_ne!(layout_color, text_color);
assert_ne!(text_color, color_only);
}
#[test]
fn test_property_editor() {
let node_id = NodeId(1);
let mut editor = PropertyEditor::new(node_id);
assert_eq!(editor.target_node, node_id);
assert!(!editor.has_pending_changes());
editor.set_property("color".to_string(), PropertyValue::Color(Color::RED));
assert!(editor.has_pending_changes());
editor.clear_pending();
assert!(!editor.has_pending_changes());
}
#[test]
fn test_search_navigation() {
let mut search = SearchState::default();
let nodes = vec![
TreeNodeInfo {
node_id: NodeId(1),
widget_id: None,
kind: WidgetKind::Button,
label: "Button 1".to_string(),
depth: 0,
child_count: 0,
bounds: (0.0, 0.0, 100.0, 50.0),
dirty_flags: DirtyFlags::NONE,
is_expanded: false,
is_visible: true,
},
TreeNodeInfo {
node_id: NodeId(2),
widget_id: None,
kind: WidgetKind::Button,
label: "Button 2".to_string(),
depth: 0,
child_count: 0,
bounds: (0.0, 0.0, 100.0, 50.0),
dirty_flags: DirtyFlags::NONE,
is_expanded: false,
is_visible: true,
},
TreeNodeInfo {
node_id: NodeId(3),
widget_id: None,
kind: WidgetKind::Button,
label: "Button 3".to_string(),
depth: 0,
child_count: 0,
bounds: (0.0, 0.0, 100.0, 50.0),
dirty_flags: DirtyFlags::NONE,
is_expanded: false,
is_visible: true,
},
];
search.query = "Button".to_string();
search.search(&nodes);
assert_eq!(search.results.len(), 3);
assert_eq!(search.next_result(), Some(NodeId(2)));
assert_eq!(search.next_result(), Some(NodeId(3)));
assert_eq!(search.next_result(), Some(NodeId(1)));
assert_eq!(search.prev_result(), Some(NodeId(3)));
assert_eq!(search.prev_result(), Some(NodeId(2)));
}
#[test]
fn test_search_empty_query() {
let mut search = SearchState::default();
let nodes = vec![TreeNodeInfo {
node_id: NodeId(1),
widget_id: None,
kind: WidgetKind::Button,
label: "Button".to_string(),
depth: 0,
child_count: 0,
bounds: (0.0, 0.0, 100.0, 50.0),
dirty_flags: DirtyFlags::NONE,
is_expanded: false,
is_visible: true,
}];
search.query = "".to_string();
search.search(&nodes);
assert!(search.results.is_empty());
assert_eq!(search.next_result(), None);
assert_eq!(search.prev_result(), None);
}
#[test]
fn test_inspector_config() {
let config = InspectorConfig {
show_bounds: false,
show_dirty_flags: true,
show_graphs: false,
show_tree_view: true,
show_properties: false,
max_tree_depth: 5,
graph_history_size: 60,
highlight_hover: false,
show_layout_details: true,
};
let inspector = UiInspector::new(config.clone());
assert_eq!(inspector.config().max_tree_depth, 5);
assert_eq!(inspector.config().graph_history_size, 60);
assert!(!inspector.config().show_bounds);
}
#[test]
fn test_inspector_enable_disable() {
let mut inspector = UiInspector::new(InspectorConfig::default());
inspector.enable();
assert!(inspector.is_enabled());
inspector.disable();
assert!(!inspector.is_enabled());
}
#[test]
fn test_inspector_hover() {
let mut inspector = UiInspector::new(InspectorConfig::default());
inspector.set_hovered(Some(NodeId(42)));
inspector.set_hovered(None);
}
#[test]
fn test_tree_view_expand_collapse_all() {
let mut tree_view = TreeViewState::new();
tree_view.nodes = vec![
TreeNodeInfo {
node_id: NodeId(1),
widget_id: None,
kind: WidgetKind::Container,
label: "Root".to_string(),
depth: 0,
child_count: 2,
bounds: (0.0, 0.0, 100.0, 100.0),
dirty_flags: DirtyFlags::NONE,
is_expanded: false,
is_visible: true,
},
TreeNodeInfo {
node_id: NodeId(2),
widget_id: None,
kind: WidgetKind::Container,
label: "Child 1".to_string(),
depth: 1,
child_count: 1,
bounds: (0.0, 0.0, 50.0, 50.0),
dirty_flags: DirtyFlags::NONE,
is_expanded: false,
is_visible: true,
},
];
tree_view.expand_all();
assert!(tree_view.expanded.contains(&NodeId(1)));
assert!(tree_view.expanded.contains(&NodeId(2)));
tree_view.collapse_all();
assert!(tree_view.expanded.is_empty());
}
#[test]
fn test_tree_view_filter() {
let mut tree_view = TreeViewState::new();
tree_view.set_filter("test".to_string());
assert_eq!(tree_view.filter, "test");
}
#[test]
fn test_property_value_types() {
let float_val = PropertyValue::Float(3.15);
let int_val = PropertyValue::Int(42);
let bool_val = PropertyValue::Bool(true);
let color_val = PropertyValue::Color(Color::RED);
let string_val = PropertyValue::String("hello".to_string());
let vec2_val = PropertyValue::Vec2(Vec2::new(1.0, 2.0));
assert!(matches!(float_val, PropertyValue::Float(_)));
assert!(matches!(int_val, PropertyValue::Int(_)));
assert!(matches!(bool_val, PropertyValue::Bool(_)));
assert!(matches!(color_val, PropertyValue::Color(_)));
assert!(matches!(string_val, PropertyValue::String(_)));
assert!(matches!(vec2_val, PropertyValue::Vec2(_)));
}
#[test]
fn test_editable_property() {
let prop = EditableProperty {
name: "width".to_string(),
category: PropertyCategory::Layout,
value: PropertyValue::Float(100.0),
affects_layout: true,
};
assert_eq!(prop.name, "width");
assert_eq!(prop.category, PropertyCategory::Layout);
assert!(prop.affects_layout);
}
#[test]
fn test_widget_kind_all_variants() {
let kinds = [
WidgetKind::Container,
WidgetKind::Text,
WidgetKind::Button,
WidgetKind::Image,
WidgetKind::TextInput,
WidgetKind::Checkbox,
WidgetKind::Slider,
WidgetKind::ScrollView,
WidgetKind::Custom,
WidgetKind::Unknown,
];
for kind in &kinds {
let color = kind.color();
assert!(color.a > 0.0);
}
for kind in &kinds {
let border = kind.border_color();
assert!(border.a > kind.color().a); }
}
#[test]
fn test_inspector_graphs_clear() {
let mut graphs = InspectorGraphs::new(10);
let metrics = FrameTimingMetrics {
frame_id: 1,
nodes_layout_dirty: 5,
..Default::default()
};
graphs.update(&metrics);
assert!(!graphs.frame_times.is_empty());
graphs.clear();
assert!(graphs.frame_times.is_empty());
assert!(graphs.layout_times.is_empty());
assert!(graphs.text_times.is_empty());
assert!(graphs.dirty_counts.is_empty());
}
#[test]
fn test_inspector_graphs_averages() {
let mut graphs = InspectorGraphs::new(10);
for i in 0..5 {
let mut metrics = FrameTimingMetrics::new(i);
metrics.total_layout_time = std::time::Duration::from_millis(10);
graphs.update(&metrics);
}
let avg = graphs.avg_frame_time();
assert!(avg >= 10.0); }
#[test]
fn test_inspector_default() {
let inspector = UiInspector::default();
assert!(!inspector.is_enabled());
assert!(inspector.selected().is_none());
}
#[test]
fn test_overlay_quad() {
let quad = OverlayQuad {
position: Vec2::new(10.0, 20.0),
size: Vec2::new(100.0, 50.0),
fill_color: Color::RED,
border_color: Some(Color::WHITE),
border_width: 2.0,
};
assert_eq!(quad.position.x, 10.0);
assert_eq!(quad.size.y, 50.0);
assert!(quad.border_color.is_some());
}
#[test]
fn test_dirty_flags_to_color_opacity() {
let layout = dirty_flags_to_color(DirtyFlags::LAYOUT);
let opacity = dirty_flags_to_color(DirtyFlags::OPACITY);
assert!(layout.a >= 0.8);
assert!(opacity.a >= 0.8);
}
}