Skip to main content

khora_editor/widgets/
tile.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// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Asset-browser tile widget — type-coloured gradient thumbnails.
16
17use khora_sdk::editor_ui::{FontFamilyHint, Icon, TextAlign, UiBuilder, UiTheme};
18
19use super::paint::{paint_icon, paint_text_size, with_alpha};
20
21/// Visual category for an asset tile. Drives the gradient + icon + format
22/// glyph. Variants map onto the canonical type names produced by
23/// `khora_io::asset::IndexBuilder::asset_type_for_extension`. Add a new
24/// variant when a new asset *category* lands engine-side; per-extension
25/// distinctions belong in the IndexBuilder.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum AssetTileKind {
28    Mesh,
29    Texture,
30    Audio,
31    Shader,
32    Scene,
33    /// Authored materials (`.kmat`) — RON material definitions referenced
34    /// by entities through `MaterialRef::Asset`.
35    Material,
36    /// Gameplay scripts (`.kscript`) — data-driven, hot-reloadable. Tier
37    /// 3 of the project's three "code" tiers; the scripting language
38    /// runtime itself isn't implemented yet, but the asset browser
39    /// surfaces them so authors can see where they live and the watcher
40    /// can hot-reload them.
41    Script,
42    Unknown,
43}
44
45impl AssetTileKind {
46    pub(crate) fn icon(self) -> Icon {
47        match self {
48            Self::Mesh => Icon::Cube,
49            Self::Texture => Icon::Image,
50            Self::Audio => Icon::Music,
51            Self::Shader => Icon::Zap,
52            Self::Scene => Icon::Globe,
53            Self::Material => Icon::Circle,
54            Self::Script => Icon::Code,
55            Self::Unknown => Icon::Box,
56        }
57    }
58
59    /// Returns the top accent colour for the tile gradient (the bottom
60    /// always falls into `theme.surface` so the tile blends with the panel
61    /// background regardless of category).
62    pub(crate) fn accent(self, theme: &UiTheme) -> [f32; 4] {
63        let raw = match self {
64            Self::Mesh => theme.accent_a,
65            Self::Texture => theme.accent_c,
66            Self::Audio => theme.success,
67            Self::Shader => theme.accent_a,
68            Self::Scene => theme.accent_a,
69            Self::Material => theme.accent_c,
70            Self::Script => theme.warning,
71            Self::Unknown => theme.surface_active,
72        };
73        with_alpha(raw, 0.85)
74    }
75
76    fn glyph_label(self) -> &'static str {
77        match self {
78            Self::Mesh => "FBX",
79            Self::Texture => "TEX",
80            Self::Audio => "OGG",
81            Self::Shader => "GLSL",
82            Self::Scene => "SCN",
83            Self::Material => "MAT",
84            Self::Script => "KSCR",
85            Self::Unknown => "—",
86        }
87    }
88}
89
90/// What the user did to an asset tile this frame.
91///
92/// Tiles surface both single- and double-click distinctly: single click
93/// selects (asset browser shows metadata), double-click is the "primary
94/// action" (load a scene, open a script in OS-default editor, etc.).
95#[derive(Debug, Default, Clone, Copy)]
96pub struct AssetTileInteraction {
97    pub clicked: bool,
98    pub double_clicked: bool,
99    /// Pointer is over the tile — kept here for callers that want to
100    /// surface a tooltip or preview without re-running the rect
101    /// interaction. Currently unused by the asset browser, but the
102    /// future inspector preview will read it.
103    #[allow(dead_code)]
104    pub hovered: bool,
105}
106
107/// Paints an asset tile (thumbnail + name) and reports interaction.
108///
109/// `origin` is the top-left in screen-space, `size` is the total tile box
110/// (thumbnail + name strip below). The tile self-measures so the caller only
111/// has to grid-place identical-size boxes.
112#[allow(clippy::too_many_arguments)] // UI paint helper — splitting hurts readability.
113pub fn paint_asset_tile(
114    ui: &mut dyn UiBuilder,
115    id_salt: &str,
116    origin: [f32; 2],
117    size: [f32; 2],
118    name: &str,
119    kind: AssetTileKind,
120    selected: bool,
121    theme: &UiTheme,
122) -> AssetTileInteraction {
123    let [w, h] = size;
124    let thumb_h = w; // square
125    let name_y = origin[1] + thumb_h + 4.0;
126
127    // Outer hit area (used for hover background)
128    let outer = [origin[0], origin[1], w, h];
129    let interaction = ui.interact_rect(id_salt, outer);
130
131    // Gold marks the selection, here as everywhere else in the editor.
132    if selected {
133        ui.paint_rect_filled(origin, [w, h], with_alpha(theme.accent_c, 0.12), 6.0);
134    } else if interaction.hovered {
135        ui.paint_rect_filled(origin, [w, h], with_alpha(theme.surface_elevated, 0.4), 6.0);
136    }
137
138    // Thumbnail
139    let thumb_x = origin[0] + 4.0;
140    let thumb_y = origin[1] + 4.0;
141    let tw = w - 8.0;
142    let th = thumb_h - 8.0;
143    let accent = kind.accent(theme);
144    ui.paint_rect_filled([thumb_x, thumb_y], [tw, th], accent, 4.0);
145    ui.paint_rect_stroke(
146        [thumb_x, thumb_y],
147        [tw, th],
148        with_alpha(theme.separator, 0.55),
149        4.0,
150        1.0,
151    );
152    if selected {
153        ui.paint_rect_stroke([thumb_x, thumb_y], [tw, th], theme.accent_c, 4.0, 1.5);
154    }
155
156    // Centered icon
157    let icon_size = (tw * 0.45).clamp(20.0, 36.0);
158    paint_icon(
159        ui,
160        [
161            thumb_x + (tw - icon_size) * 0.5,
162            thumb_y + (th - icon_size) * 0.5,
163        ],
164        kind.icon(),
165        icon_size,
166        with_alpha(theme.text, 0.85),
167    );
168
169    // Format glyph bottom-right
170    ui.paint_text_styled(
171        [thumb_x + tw - 4.0, thumb_y + th - 14.0],
172        kind.glyph_label(),
173        9.5,
174        with_alpha([1.0, 1.0, 1.0, 1.0], 0.55),
175        FontFamilyHint::Monospace,
176        TextAlign::Right,
177    );
178
179    // Name (clipped if too long — egui handles eg_text overflow per-character)
180    let truncated = if name.chars().count() > 14 {
181        let mut s: String = name.chars().take(13).collect();
182        s.push('…');
183        s
184    } else {
185        name.to_owned()
186    };
187    paint_text_size(
188        ui,
189        [origin[0] + 4.0, name_y],
190        &truncated,
191        11.0,
192        if selected { theme.text } else { theme.text_dim },
193    );
194
195    AssetTileInteraction {
196        clicked: interaction.clicked,
197        double_clicked: interaction.double_clicked,
198        hovered: interaction.hovered,
199    }
200}