Skip to main content

khora_editor/widgets/inspector/
card.rs

1// Copyright 2025 eraflo
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8
9//! Component group — one collapsible section per component on the entity.
10//!
11//! Deliberately **flat**: a chevron, an icon, a name, a category tag, and a
12//! hairline. It used to be a bordered, filled card per component, which turned
13//! a five-component entity into boxes inside boxes inside a panel — the
14//! structure stopped being readable exactly when there was enough of it to
15//! need reading. Hierarchy now comes from type and spacing.
16//!
17//! The row actions (enable toggle, remove) appear on hover. They are rare and
18//! destructive-adjacent; the resting state should read as data, not as a
19//! toolbar.
20
21use 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
31/// The domain a component belongs to, shown as a small right-aligned tag.
32///
33/// Derived from the type name so a newly-added component is tagged without
34/// anyone having to register it anywhere; unknown components simply carry no
35/// tag rather than a wrong one.
36fn 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/// Renders one component as a flat collapsible group and calls `body` for its
48/// content. Open/closed state persists in `EditorState::inspector_card_open`,
49/// keyed by entity index + component title.
50#[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    // Keyed on index AND generation: entity slots are recycled, so a key built
65    // from the index alone would hand a freshly spawned entity the collapse
66    // state of whatever previously occupied that slot.
67    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    // ── Row actions, revealed on hover ──
81    // Allocated *after* the header so their hit-rects win the click, and laid
82    // out to the left of the category tag so they never sit on top of it.
83    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        // The component enable/disable switch used to be painted here. It had
122        // no `interact_rect` at all, so it was a picture of a switch; and every
123        // caller passed `enabled: None`, so it never even appeared.
124        //
125        // Turning a component off only means something once the ECS honours it
126        // — every query that consumes the component has to check the flag, or
127        // it is decorative. That is a data-layer feature, not a card one.
128        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    // A hairline closes the group — the only rule the flat design needs.
147    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    /// An unregistered component must render with *no* tag rather than a
170    /// wrong one — a confidently incorrect label is worse than none.
171    #[test]
172    fn unknown_components_get_no_tag() {
173        assert_eq!(category_for("PlayerController"), "");
174        assert_eq!(category_for(""), "");
175    }
176}