khora_editor/widgets/inspector/
asset_pane.rs1use 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
23fn 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
50pub 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 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 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 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
179pub 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 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
231fn 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}