Skip to content

Commit c5fd231

Browse files
committed
Add lock toggle to inspector Name section header
Pin the inspector to a specific entity so it stops following selection. Click the lock icon at the right of the Name section header to toggle; the lock auto-clears if the locked entity is despawned.
1 parent c496a4f commit c5fd231

4 files changed

Lines changed: 169 additions & 8 deletions

File tree

crates/renzora_inspector/src/lib.rs

Lines changed: 66 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,12 @@ mod state;
66
use std::sync::RwLock;
77

88
use bevy::prelude::*;
9-
use bevy_egui::egui::{self, RichText};
9+
use bevy_egui::egui::{self, Color32, CursorIcon, RichText};
1010
use egui_phosphor::regular;
1111
use renzora_editor_framework::{
12-
collapsible_section, collapsible_section_removable, empty_state, search_overlay,
13-
AppEditorExt, EditorCommands, EditorPanel, EditorSelection, InspectorRegistry,
14-
OverlayAction, OverlayEntry, PanelLocation,
12+
collapsible_section, collapsible_section_removable, collapsible_section_with_actions,
13+
empty_state, search_overlay, AppEditorExt, EditorCommands, EditorPanel, EditorSelection,
14+
InspectorRegistry, OverlayAction, OverlayEntry, PanelLocation,
1515
};
1616
use renzora_theme::ThemeManager;
1717

@@ -49,8 +49,19 @@ impl EditorPanel for InspectorPanel {
4949
None => return,
5050
};
5151

52+
let mut state = self._state.write().unwrap();
53+
54+
// If a locked entity was despawned, drop the lock so we resume following selection.
55+
if let Some(locked) = state.locked_entity {
56+
if world.get_entity(locked).is_err() {
57+
state.locked_entity = None;
58+
}
59+
}
60+
5261
let selection = world.get_resource::<EditorSelection>();
53-
let entity = selection.and_then(|s| s.get());
62+
let entity = state
63+
.locked_entity
64+
.or_else(|| selection.and_then(|s| s.get()));
5465

5566
let Some(entity) = entity else {
5667
empty_state(
@@ -83,8 +94,6 @@ impl EditorPanel for InspectorPanel {
8394
return;
8495
};
8596

86-
let mut state = self._state.write().unwrap();
87-
8897
// Add Component overlay
8998
if state.show_add_overlay {
9099
let entries: Vec<OverlayEntry> = registry
@@ -217,6 +226,56 @@ impl EditorPanel for InspectorPanel {
217226
});
218227
}
219228
}
229+
} else if entry.type_id == "name" {
230+
let is_locked = state.locked_entity == Some(entity);
231+
let mut lock_clicked = false;
232+
let accent = theme.semantic.accent.to_color32();
233+
let muted = theme.text.muted.to_color32();
234+
collapsible_section_with_actions(
235+
ui,
236+
entry.icon,
237+
entry.display_name,
238+
entry.category,
239+
&theme,
240+
&format!("inspector_{}", entry.type_id),
241+
true,
242+
|ui| {
243+
let (icon, color, tooltip) = if is_locked {
244+
(regular::LOCK_SIMPLE, accent, "Unlock — resume following selection")
245+
} else {
246+
(regular::LOCK_SIMPLE_OPEN, muted, "Lock inspector to this entity")
247+
};
248+
let btn = ui
249+
.add(
250+
egui::Button::new(
251+
RichText::new(icon).size(14.0).color(color),
252+
)
253+
.fill(Color32::TRANSPARENT)
254+
.frame(false),
255+
)
256+
.on_hover_text(tooltip);
257+
if btn.hovered() {
258+
ui.ctx().set_cursor_icon(CursorIcon::PointingHand);
259+
}
260+
if btn.clicked() {
261+
lock_clicked = true;
262+
}
263+
},
264+
|ui| {
265+
if let Some(custom_fn) = entry.custom_ui_fn {
266+
custom_fn(ui, world, entity, cmds, &theme);
267+
} else {
268+
for (i, field) in entry.fields.iter().enumerate() {
269+
field_widget::render_field(
270+
ui, field, world, entity, cmds, &theme, i,
271+
);
272+
}
273+
}
274+
},
275+
);
276+
if lock_clicked {
277+
state.locked_entity = if is_locked { None } else { Some(entity) };
278+
}
220279
} else {
221280
collapsible_section(
222281
ui,
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
1+
use bevy::prelude::Entity;
2+
13
/// Persistent UI state for the inspector panel.
24
#[derive(Default)]
35
pub struct InspectorState {
46
pub show_add_overlay: bool,
57
pub add_search: String,
68
pub component_filter: String,
9+
/// When set, the inspector pins to this entity and ignores selection changes.
10+
pub locked_entity: Option<Entity>,
711
}

crates/renzora_ui/src/widgets/category.rs

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,103 @@ pub fn collapsible_section(
9393
state.store(ui.ctx());
9494
}
9595

96+
/// Render a collapsible category section with custom right-aligned header actions.
97+
///
98+
/// Identical to [`collapsible_section`] but reserves space on the right side of the
99+
/// header bar for caller-supplied widgets (e.g. a lock toggle). The `header_actions`
100+
/// closure is rendered inside a right-to-left layout, so the first widget you add
101+
/// sits at the far right.
102+
pub fn collapsible_section_with_actions(
103+
ui: &mut egui::Ui,
104+
icon: &str,
105+
label: &str,
106+
category: &str,
107+
theme: &Theme,
108+
id_source: &str,
109+
default_open: bool,
110+
header_actions: impl FnOnce(&mut egui::Ui),
111+
add_contents: impl FnOnce(&mut egui::Ui),
112+
) {
113+
let id = ui.make_persistent_id(id_source);
114+
let mut state =
115+
egui::collapsing_header::CollapsingState::load_with_default_open(ui.ctx(), id, default_open);
116+
117+
let (accent_color, header_bg) = category_colors(theme, category);
118+
let frame_bg = theme.panels.category_frame_bg.to_color32();
119+
let text_muted = theme.text.muted.to_color32();
120+
let text_primary = theme.text.primary.to_color32();
121+
122+
egui::Frame::new()
123+
.fill(frame_bg)
124+
.corner_radius(CornerRadius::ZERO)
125+
.outer_margin(egui::Margin::ZERO)
126+
.inner_margin(egui::Margin::ZERO)
127+
.show(ui, |ui| {
128+
ui.spacing_mut().item_spacing.y = 0.0;
129+
130+
// Header bar
131+
let header_inner = egui::Frame::new()
132+
.fill(header_bg)
133+
.corner_radius(CornerRadius::ZERO)
134+
.inner_margin(egui::Margin::symmetric(8, 6))
135+
.show(ui, |ui| {
136+
ui.horizontal(|ui| {
137+
let left_response = ui.scope(|ui| {
138+
ui.set_max_width(
139+
(ui.available_width() - 32.0).max(20.0),
140+
);
141+
142+
let caret = if state.is_open() { CARET_DOWN } else { CARET_RIGHT };
143+
ui.label(RichText::new(caret).size(12.0).color(text_muted));
144+
ui.label(RichText::new(icon).size(15.0).color(accent_color));
145+
ui.add_space(4.0);
146+
ui.add(
147+
egui::Label::new(
148+
RichText::new(label)
149+
.size(13.0)
150+
.strong()
151+
.color(text_primary),
152+
)
153+
.truncate(),
154+
);
155+
}).response;
156+
157+
ui.with_layout(
158+
egui::Layout::right_to_left(egui::Align::Center),
159+
|ui| {
160+
header_actions(ui);
161+
},
162+
);
163+
164+
left_response.rect
165+
}).inner
166+
}).inner;
167+
168+
let click = ui.interact(header_inner, id.with("header_click"), Sense::click());
169+
if click.hovered() {
170+
ui.ctx().set_cursor_icon(CursorIcon::PointingHand);
171+
}
172+
if click.clicked() {
173+
state.toggle(ui);
174+
}
175+
176+
if state.is_open() {
177+
egui::Frame::new()
178+
.inner_margin(egui::Margin {
179+
left: 4,
180+
right: 4,
181+
top: 4,
182+
bottom: 4,
183+
})
184+
.show(ui, |ui| {
185+
add_contents(ui);
186+
});
187+
}
188+
});
189+
190+
state.store(ui.ctx());
191+
}
192+
96193
/// Render a collapsible category with toggle switch, remove button, and drag handle.
97194
///
98195
/// Returns a [`CategoryHeaderAction`] indicating which buttons were clicked.

crates/renzora_ui/src/widgets/mod.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,8 @@ pub mod tree;
5757

5858
pub use buttons::icon_button;
5959
pub use category::{
60-
category_colors, collapsible_section, collapsible_section_removable, CategoryHeaderAction,
60+
category_colors, collapsible_section, collapsible_section_removable,
61+
collapsible_section_with_actions, CategoryHeaderAction,
6162
};
6263
pub use colors::{checkerboard, dim_color, get_theme_colors, set_theme_colors, ThemeColors};
6364
pub use drop_zone::{file_drop_zone, FileDropResult};

0 commit comments

Comments
 (0)