khora_editor/widgets/inspector/
card.rs1use khora_sdk::editor_ui::{EditorState, Icon, PropertyEdit, UiBuilder, UiTheme};
22use khora_sdk::prelude::ecs::EntityId;
23use khora_tool_ui::widgets::{
24 self, group_header,
25 paint::{icon_centered, tint},
26 Group,
27};
28
29const HEADER_H: f32 = 32.0;
30
31fn category_for(title: &str) -> &'static str {
37 match title {
38 "Transform" | "GlobalTransform" | "Parent" | "Children" => "spatial",
39 "MeshRef" | "MaterialRef" | "Visibility" | "Camera" | "Light" => "render",
40 "RigidBody" | "Collider" | "Velocity" => "physics",
41 "AudioSource" | "AudioListener" => "audio",
42 "Name" | "Tags" => "meta",
43 _ => "",
44 }
45}
46
47#[allow(clippy::too_many_arguments)]
51pub fn render_card(
52 ui: &mut dyn UiBuilder,
53 entity: EntityId,
54 title: &str,
55 icon: Icon,
56 enabled: Option<bool>,
57 removable: bool,
58 card_x: f32,
59 card_w: f32,
60 theme: &UiTheme,
61 state: &mut EditorState,
62 body: &mut dyn FnMut(&mut dyn UiBuilder),
63) {
64 let card_id = format!("{}.{}::{}", entity.index, entity.generation, title);
68 let open = *state
69 .inspector_card_open
70 .entry(card_id.clone())
71 .or_insert(true);
72
73 let y = ui.cursor_pos()[1];
74 let header = [card_x, y, card_w, HEADER_H];
75
76 let cat = category_for(title);
77 let spec = Group::new(title, icon).open(open).category(cat);
78 let head = group_header(ui, theme, header, &format!("card-hdr-{card_id}"), spec);
79
80 if head.hovered {
84 let tag_w = if cat.is_empty() {
85 0.0
86 } else {
87 ui.measure_text(
88 &cat.to_uppercase(),
89 theme.font_size_caption - 1.5,
90 khora_sdk::editor_ui::FontFamilyHint::Monospace,
91 )[0] + 10.0
92 };
93 let right = card_x + card_w - 12.0 - tag_w;
94
95 if removable {
96 let size = 22.0;
97 let rect = [right - size, y + (HEADER_H - size) * 0.5, size, size];
98 let hit = ui.interact_rect(&format!("card-rm-{card_id}"), rect);
99 if hit.hovered {
100 widgets::fill(ui, rect, tint(theme.error, 0.14), theme.radius_sm);
101 }
102 icon_centered(
103 ui,
104 rect,
105 Icon::Trash,
106 12.0,
107 if hit.hovered {
108 theme.error
109 } else {
110 theme.text_muted
111 },
112 );
113 if hit.clicked {
114 state.pending_edits.push(PropertyEdit::RemoveComponent {
115 entity,
116 type_name: title.to_string(),
117 });
118 }
119 }
120
121 debug_assert!(
129 enabled.is_none(),
130 "component enable/disable is not implemented; \
131 see the component-activation work before passing Some(_)"
132 );
133 }
134
135 if head.clicked {
136 state.inspector_card_open.insert(card_id, !open);
137 }
138
139 ui.spacing(HEADER_H + 2.0);
140
141 if open {
142 ui.indent("card-body", &mut |ui_b| body(ui_b));
143 ui.spacing(8.0);
144 }
145
146 let end_y = ui.cursor_pos()[1];
148 ui.paint_line(
149 [card_x, end_y],
150 [card_x + card_w, end_y],
151 theme.separator,
152 1.0,
153 );
154 ui.spacing(6.0);
155}
156
157#[cfg(test)]
158mod tests {
159 use super::category_for;
160
161 #[test]
162 fn known_components_get_their_domain_tag() {
163 assert_eq!(category_for("Transform"), "spatial");
164 assert_eq!(category_for("MeshRef"), "render");
165 assert_eq!(category_for("RigidBody"), "physics");
166 assert_eq!(category_for("AudioSource"), "audio");
167 }
168
169 #[test]
172 fn unknown_components_get_no_tag() {
173 assert_eq!(category_for("PlayerController"), "");
174 assert_eq!(category_for(""), "");
175 }
176}