Skip to main content

khora_editor/widgets/inspector/
asset_pane.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//! Inspector asset-metadata pane.
10//!
11//! When the user selects an asset in the browser, the Properties panel
12//! switches from per-entity component cards to this pane: file
13//! metadata, source path, and a couple of OS-level actions
14//! (Reveal in Explorer, Open Externally). Phase 5 entry — per-type
15//! preview cards (texture image, mesh stats, audio duration) come in
16//! a follow-up.
17
18use khora_sdk::editor_ui::{FontFamilyHint, Icon, TextAlign, UiBuilder, UiTheme};
19
20use super::header::paint_inspector_header;
21use crate::widgets::paint::{paint_icon, paint_text_size, with_alpha};
22
23/// One row of "key — value" inside the metadata card.
24fn paint_kv_row(
25    ui: &mut dyn UiBuilder,
26    origin: [f32; 2],
27    width: f32,
28    key: &str,
29    value: &str,
30    theme: &UiTheme,
31) {
32    ui.paint_text_styled(
33        [origin[0], origin[1]],
34        key,
35        11.0,
36        theme.text_muted,
37        FontFamilyHint::Monospace,
38        TextAlign::Left,
39    );
40    ui.paint_text_styled(
41        [origin[0] + width - 6.0, origin[1]],
42        value,
43        11.0,
44        theme.text,
45        FontFamilyHint::Monospace,
46        TextAlign::Right,
47    );
48}
49
50/// Render the asset metadata pane. Returns the y coordinate after the
51/// last painted block.
52pub fn render_asset_pane(
53    ui: &mut dyn UiBuilder,
54    body_rect: [f32; 4],
55    rel_path: &str,
56    project_folder: &str,
57    theme: &UiTheme,
58) {
59    let [bx, by, bw, _] = body_rect;
60    let mut y = by;
61
62    // Resolve the absolute path + on-disk metadata.
63    let abs = std::path::Path::new(project_folder)
64        .join("assets")
65        .join(rel_path.replace('/', std::path::MAIN_SEPARATOR_STR));
66    let meta = std::fs::metadata(&abs).ok();
67    let size = meta.as_ref().map(|m| m.len()).unwrap_or(0);
68    let modified = meta.as_ref().and_then(|m| m.modified().ok()).and_then(|t| {
69        t.duration_since(std::time::UNIX_EPOCH)
70            .ok()
71            .map(|d| d.as_secs())
72    });
73    let extension = std::path::Path::new(rel_path)
74        .extension()
75        .and_then(|e| e.to_str())
76        .unwrap_or("");
77
78    // ── Card: file info ────────────────────────────────
79    let card_x = bx;
80    let card_w = bw;
81    let card_h = 130.0;
82    ui.paint_rect_filled(
83        [card_x, y],
84        [card_w, card_h],
85        theme.surface_elevated,
86        theme.radius_md,
87    );
88    ui.paint_rect_stroke(
89        [card_x, y],
90        [card_w, card_h],
91        with_alpha(theme.separator, 0.55),
92        theme.radius_md,
93        1.0,
94    );
95    paint_text_size(ui, [card_x + 12.0, y + 10.0], "File", 12.0, theme.text);
96
97    let row_x = card_x + 12.0;
98    let row_w = card_w - 24.0;
99    let mut row_y = y + 32.0;
100    paint_kv_row(ui, [row_x, row_y], row_w, "PATH", rel_path, theme);
101    row_y += 18.0;
102    paint_kv_row(
103        ui,
104        [row_x, row_y],
105        row_w,
106        "TYPE",
107        extension.to_uppercase().as_str(),
108        theme,
109    );
110    row_y += 18.0;
111    paint_kv_row(ui, [row_x, row_y], row_w, "SIZE", &format_size(size), theme);
112    row_y += 18.0;
113    paint_kv_row(
114        ui,
115        [row_x, row_y],
116        row_w,
117        "MTIME",
118        &format_mtime(modified),
119        theme,
120    );
121
122    y += card_h + 10.0;
123
124    // ── Card: actions ──────────────────────────────────
125    let actions_h = 40.0;
126    ui.paint_rect_filled(
127        [card_x, y],
128        [card_w, actions_h],
129        theme.surface_elevated,
130        theme.radius_md,
131    );
132    ui.paint_rect_stroke(
133        [card_x, y],
134        [card_w, actions_h],
135        with_alpha(theme.separator, 0.55),
136        theme.radius_md,
137        1.0,
138    );
139    let abs_string = abs.to_string_lossy().to_string();
140    let buttons = [
141        ("Open Externally", Icon::More, "asset-open-ext"),
142        ("Reveal in Folder", Icon::Database, "asset-reveal"),
143    ];
144    let btn_w = (card_w - 24.0 - 8.0) / 2.0;
145    for (i, (label, icon, salt)) in buttons.iter().enumerate() {
146        let bx = card_x + 12.0 + i as f32 * (btn_w + 8.0);
147        let by_btn = y + 8.0;
148        let int = ui.interact_rect(salt, [bx, by_btn, btn_w, 24.0]);
149        let bg = if int.hovered {
150            theme.surface_active
151        } else {
152            theme.background
153        };
154        ui.paint_rect_filled([bx, by_btn], [btn_w, 24.0], bg, theme.radius_sm);
155        paint_icon(ui, [bx + 8.0, by_btn + 6.0], *icon, 12.0, theme.text_dim);
156        paint_text_size(ui, [bx + 28.0, by_btn + 6.0], label, 11.0, theme.text);
157        if int.clicked {
158            match *salt {
159                "asset-open-ext" => match open::that(&abs_string) {
160                    Ok(()) => log::info!("asset pane: opened '{}'", rel_path),
161                    Err(e) => log::warn!(
162                        "asset pane: failed to open '{}' externally: {}",
163                        rel_path,
164                        e
165                    ),
166                },
167                "asset-reveal" => {
168                    let parent = abs.parent().map(|p| p.to_path_buf());
169                    if let Some(parent) = parent {
170                        let _ = open::that(&parent);
171                    }
172                }
173                _ => {}
174            }
175        }
176    }
177}
178
179/// Header line for asset mode — re-uses the entity Inspector header
180/// widget so the visual rhythm matches.
181pub fn paint_asset_header(
182    ui: &mut dyn UiBuilder,
183    origin: [f32; 2],
184    width: f32,
185    rel_path: &str,
186    theme: &UiTheme,
187) -> f32 {
188    let name = rel_path.rsplit('/').next().unwrap_or(rel_path).to_string();
189    let extension = std::path::Path::new(rel_path)
190        .extension()
191        .and_then(|e| e.to_str())
192        .unwrap_or("file")
193        .to_uppercase();
194    paint_inspector_header(
195        ui,
196        origin,
197        width,
198        Icon::Database,
199        &name,
200        &extension,
201        "Asset",
202        theme.primary,
203        None,
204        theme,
205    )
206}
207
208fn format_size(bytes: u64) -> String {
209    const KB: u64 = 1024;
210    const MB: u64 = 1024 * 1024;
211    if bytes < KB {
212        format!("{} B", bytes)
213    } else if bytes < MB {
214        format!("{:.1} KB", bytes as f64 / KB as f64)
215    } else {
216        format!("{:.2} MB", bytes as f64 / MB as f64)
217    }
218}
219
220fn format_mtime(secs: Option<u64>) -> String {
221    let Some(secs) = secs else {
222        return "—".into();
223    };
224    // Rough compact `YYYY-MM-DD` from Unix seconds — avoids pulling in
225    // chrono just for an Inspector display.
226    let days_since_epoch = (secs / 86_400) as i64;
227    let (y, m, d) = days_to_ymd(days_since_epoch);
228    format!("{:04}-{:02}-{:02}", y, m, d)
229}
230
231/// Convert days since the Unix epoch to (year, month, day). Used only
232/// for the Inspector mtime label.
233fn days_to_ymd(mut days: i64) -> (i32, u32, u32) {
234    days += 719_468;
235    let era = days.div_euclid(146_097);
236    let doe = days.rem_euclid(146_097) as u64;
237    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
238    let y = (yoe as i64 + era * 400) as i32;
239    let doy = (doe - (365 * yoe + yoe / 4 - yoe / 100)) as u32;
240    let mp = (5 * doy + 2) / 153;
241    let d = doy - (153 * mp + 2) / 5 + 1;
242    let m = if mp < 10 { mp + 3 } else { mp - 9 };
243    let y = if m <= 2 { y + 1 } else { y };
244    (y, m, d)
245}