use super::registry::{self, PanelKey};
use super::theme;
use super::widget::{self, place_rounded, point_in};
use crate::components::TextAlign;
use crate::ecs::World;
use crate::ecs::asset_id::AssetId;
const BASE: u32 = registry::base(PanelKey::Import);
pub(crate) const PANEL_BG: AssetId = AssetId(BASE);
pub(crate) const TITLE_LABEL: AssetId = AssetId(BASE + 2);
pub(crate) const CLOSE_BG: AssetId = AssetId(BASE + 3);
pub(crate) const CLOSE_LABEL: AssetId = AssetId(BASE + 4);
pub(crate) const ADD_BG: AssetId = AssetId(BASE + 5);
pub(crate) const ADD_LABEL: AssetId = AssetId(BASE + 6);
pub(crate) const HINT_LABEL: AssetId = AssetId(BASE + 7);
pub(crate) const STATUS_LABEL: AssetId = AssetId(BASE + 8);
pub(crate) const LIST_HEADER: AssetId = AssetId(BASE + 9);
pub(crate) const LIST_TRACK: AssetId = AssetId(BASE + 10);
pub(crate) const LIST_THUMB: AssetId = AssetId(BASE + 11);
pub(crate) const PATH_INPUT: AssetId = AssetId(BASE + 12);
pub(crate) const BROWSE_BG: AssetId = AssetId(BASE + 13);
pub(crate) const BROWSE_LABEL: AssetId = AssetId(BASE + 14);
pub(crate) fn row_bg(i: usize) -> AssetId {
AssetId(BASE + 0x20 + i as u32)
}
pub(crate) fn row_label(i: usize) -> AssetId {
AssetId(BASE + 0x40 + i as u32)
}
pub(crate) const IMPORT_TYPES: &[&str] = &[
"SceneImport",
"StoryImport",
"File",
"EnvironmentMap",
"AudioClip",
"Font",
"Shader",
"LLM",
];
pub(crate) fn source_of(args: &serde_json::Value) -> String {
for key in ["source", "path", "model_path"] {
if let Some(s) = args.get(key).and_then(|v| v.as_str())
&& !s.is_empty()
{
return s.to_string();
}
}
String::new()
}
pub(crate) const IMPORT_W: f32 = 520.0;
const PAD: f32 = 10.0;
const HEADER_H: f32 = 32.0;
const HINT_H: f32 = 20.0;
const STATUS_H: f32 = 2.0 * widget::LINE_H + 6.0;
const LIST_HEADER_H: f32 = 24.0;
const BTN_W: f32 = 70.0;
const BROWSE_W: f32 = 84.0;
const GAP: f32 = 6.0;
const ROW_H: f32 = 26.0;
const SCROLLBAR_W: f32 = 5.0;
pub(crate) const IMPORT_POOL: usize = 10;
pub(crate) const IMPORT_POOL_MAX: usize = 28;
const MAX_ROW_CHARS: usize = 56;
const CHAR_W: f32 = 8.5;
const CHROME_H: f32 = widget::TITLE_H + HEADER_H + HINT_H + STATUS_H + LIST_HEADER_H;
pub(crate) fn visible_rows(h: f32) -> usize {
(((h - CHROME_H - PAD) / ROW_H).floor() as usize).clamp(IMPORT_POOL, IMPORT_POOL_MAX)
}
fn max_row_chars(w: f32) -> usize {
(MAX_ROW_CHARS as f32 + (w - IMPORT_W) / CHAR_W).max(8.0) as usize
}
const ROW_TINT: [f32; 4] = [0.0, 0.0, 0.0, 0.0];
const ROW_TINT_HOVER: [f32; 4] = theme::HOVER_TINT;
const BTN_TINT: [f32; 4] = [0.20, 0.44, 0.30, 1.0];
const BTN_TINT_HOVER: [f32; 4] = [0.28, 0.56, 0.38, 1.0];
const BROWSE_TINT: [f32; 4] = theme::BUTTON_TINT;
const TRACK_TINT: [f32; 4] = [0.12, 0.12, 0.15, 0.9];
const THUMB_TINT: [f32; 4] = [0.40, 0.44, 0.56, 0.95];
const LABEL: [f32; 3] = [0.90, 0.90, 0.92];
const HINT_COLOR: [f32; 3] = [0.55, 0.58, 0.66];
const HEADER_LABEL: [f32; 3] = [0.70, 0.74, 0.84];
const ERROR_LABEL: [f32; 3] = [0.95, 0.55, 0.55];
const NOTICE_LABEL: [f32; 3] = [0.55, 0.85, 0.65];
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum ImportStatus {
Error(String),
Notice(String),
}
impl ImportStatus {
pub(crate) fn text(&self) -> &str {
match self {
Self::Error(s) | Self::Notice(s) => s,
}
}
fn color(&self) -> [f32; 3] {
match self {
Self::Error(_) => ERROR_LABEL,
Self::Notice(_) => NOTICE_LABEL,
}
}
}
pub(crate) struct ImportRow {
pub entry: usize,
pub caption: String,
}
pub(crate) struct ImportView<'a> {
pub rows: &'a [ImportRow],
pub scroll: usize,
pub focus: bool,
pub status: Option<&'a ImportStatus>,
pub mouse: [f32; 2],
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ImportAction {
FocusPath,
Browse,
Add,
Open(usize),
Consume,
}
pub(crate) fn default_origin(vw: f32) -> [f32; 2] {
[(vw - IMPORT_W) * 0.5, super::hud::body_top() + 40.0]
}
pub(crate) fn size() -> [f32; 2] {
[
IMPORT_W,
widget::TITLE_H
+ HEADER_H
+ HINT_H
+ STATUS_H
+ LIST_HEADER_H
+ IMPORT_POOL as f32 * ROW_H
+ PAD,
]
}
pub(crate) fn max_size() -> [f32; 2] {
[
f32::INFINITY,
size()[1] + (IMPORT_POOL_MAX - IMPORT_POOL) as f32 * ROW_H,
]
}
const CTRL_H: f32 = HEADER_H - 8.0;
pub(crate) fn add_rect(o: [f32; 2], w: f32) -> [f32; 4] {
[
o[0] + w - PAD - BTN_W,
widget::header_y(o, 4.0),
BTN_W,
CTRL_H,
]
}
pub(crate) fn browse_rect(o: [f32; 2], w: f32) -> [f32; 4] {
[
add_rect(o, w)[0] - GAP - BROWSE_W,
widget::header_y(o, 4.0),
BROWSE_W,
CTRL_H,
]
}
pub(crate) fn path_rect(o: [f32; 2], w: f32) -> [f32; 4] {
let left = o[0] + PAD;
[
left,
widget::header_y(o, 4.0),
(browse_rect(o, w)[0] - GAP - left).max(0.0),
CTRL_H,
]
}
fn list_top(o: [f32; 2]) -> f32 {
o[1] + CHROME_H
}
pub(crate) fn row_rect(o: [f32; 2], w: f32, slot: usize) -> [f32; 4] {
[
o[0],
list_top(o) + slot as f32 * ROW_H,
w - SCROLLBAR_W - 2.0,
ROW_H,
]
}
pub(crate) fn cursor_over_list(mx: f32, my: f32, o: [f32; 2], s: [f32; 2]) -> bool {
let p = widget::outer_rect(o, s);
mx >= p[0] && mx < p[0] + p[2] && my >= list_top(o) && my < p[1] + p[3]
}
pub(crate) fn hit_test(
view: &ImportView,
mx: f32,
my: f32,
o: [f32; 2],
s: [f32; 2],
) -> Option<ImportAction> {
let w = s[0];
if point_in(mx, my, add_rect(o, w)) {
return Some(ImportAction::Add);
}
if point_in(mx, my, browse_rect(o, w)) {
return Some(ImportAction::Browse);
}
if point_in(mx, my, path_rect(o, w)) {
return Some(ImportAction::FocusPath);
}
for slot in 0..visible_rows(s[1]) {
if !point_in(mx, my, row_rect(o, w, slot)) {
continue;
}
let i = view.scroll + slot;
return Some(if i < view.rows.len() {
ImportAction::Open(i)
} else {
ImportAction::Consume
});
}
point_in(mx, my, widget::outer_rect(o, s)).then_some(ImportAction::Consume)
}
pub(crate) fn apply(world: &mut World, view: Option<&ImportView>, o: [f32; 2], s: [f32; 2]) {
let Some(view) = view else {
hide_all(world);
return;
};
let w = s[0];
let rows_shown = visible_rows(s[1]);
widget::place_panel(world, PANEL_BG, widget::outer_rect(o, s));
let title = widget::title_rect(o, w);
widget::place_heading(world, TITLE_LABEL, title, "Import");
let close_hover = point_in(view.mouse[0], view.mouse[1], widget::close_rect(title));
widget::place_close(world, CLOSE_BG, CLOSE_LABEL, title, close_hover);
widget::show_field(world, PATH_INPUT, path_rect(o, w), view.focus);
let browse = browse_rect(o, w);
let hover = point_in(view.mouse[0], view.mouse[1], browse);
let tint = if hover {
theme::HOVER_TINT
} else {
BROWSE_TINT
};
place_rounded(world, BROWSE_BG, browse, tint, theme::CONTROL_RADIUS, true);
if let Some(l) = widget::label_mut(world, BROWSE_LABEL) {
l.x = browse[0] + browse[2] * 0.5;
l.y = browse[1] + browse[3] * 0.5 - theme::TEXT_HALF;
l.align = TextAlign::Center;
l.color = [1.0, 1.0, 1.0];
l.visible = true;
l.content = "Browse...".to_string();
}
let add = add_rect(o, w);
let hover = point_in(view.mouse[0], view.mouse[1], add);
let tint = if hover { BTN_TINT_HOVER } else { BTN_TINT };
place_rounded(world, ADD_BG, add, tint, theme::CONTROL_RADIUS, true);
if let Some(l) = widget::label_mut(world, ADD_LABEL) {
l.x = add[0] + add[2] * 0.5;
l.y = add[1] + add[3] * 0.5 - theme::TEXT_HALF;
l.align = TextAlign::Center;
l.color = [1.0, 1.0, 1.0];
l.visible = true;
l.content = "Add".to_string();
}
widget::place_message(
world,
HINT_LABEL,
[
o[0] + PAD,
o[1] + widget::TITLE_H + HEADER_H,
(w - 2.0 * PAD).max(0.0),
HINT_H,
],
"scenes (glb, fbx) - stories (md) - images - hdr - audio - fonts - shaders - text",
HINT_COLOR,
true,
);
widget::place_message(
world,
STATUS_LABEL,
[
o[0] + PAD,
o[1] + widget::TITLE_H + HEADER_H + HINT_H,
(w - 2.0 * PAD).max(0.0),
STATUS_H - 4.0,
],
view.status.map(ImportStatus::text).unwrap_or(""),
view.status.map_or(ERROR_LABEL, ImportStatus::color),
view.status.is_some(),
);
if let Some(l) = widget::label_mut(world, LIST_HEADER) {
l.x = o[0] + PAD;
l.y = o[1] + widget::TITLE_H + HEADER_H + HINT_H + STATUS_H + 2.0;
l.align = TextAlign::Left;
l.color = HEADER_LABEL;
l.visible = true;
l.content = if view.rows.is_empty() {
"Imports (none yet)".to_string()
} else {
format!("Imports ({})", view.rows.len())
};
}
for slot in 0..IMPORT_POOL_MAX {
let i = view.scroll + slot;
if slot >= rows_shown || i >= view.rows.len() {
widget::set_sprite_visible(world, row_bg(slot), false);
widget::set_label_visible(world, row_label(slot), false);
continue;
}
let r = row_rect(o, w, slot);
let hovered = point_in(view.mouse[0], view.mouse[1], r);
let tint = if hovered { ROW_TINT_HOVER } else { ROW_TINT };
place_rounded(
world,
row_bg(slot),
theme::highlight_rect(r),
tint,
theme::CONTROL_RADIUS,
true,
);
if let Some(l) = widget::label_mut(world, row_label(slot)) {
l.x = r[0] + PAD;
l.y = r[1] + ROW_H * 0.5 - theme::TEXT_HALF;
l.align = TextAlign::Left;
l.color = LABEL;
l.visible = true;
l.content = widget::clip_text(&view.rows[i].caption, max_row_chars(w));
}
}
layout_scrollbar(world, view, o, w, rows_shown);
}
fn layout_scrollbar(world: &mut World, view: &ImportView, o: [f32; 2], w: f32, rows_shown: usize) {
let total = view.rows.len();
if total <= rows_shown {
widget::set_sprite_visible(world, LIST_TRACK, false);
widget::set_sprite_visible(world, LIST_THUMB, false);
return;
}
let x = o[0] + w - SCROLLBAR_W;
let top = list_top(o);
let h = rows_shown as f32 * ROW_H;
place_rounded(
world,
LIST_TRACK,
[x, top, SCROLLBAR_W, h],
TRACK_TINT,
SCROLLBAR_W * 0.5,
true,
);
let thumb_h = (h * rows_shown as f32 / total as f32).max(18.0);
let max_scroll = (total - rows_shown) as f32;
let off = (h - thumb_h) * (view.scroll as f32 / max_scroll);
place_rounded(
world,
LIST_THUMB,
[x, top + off, SCROLLBAR_W, thumb_h],
THUMB_TINT,
SCROLLBAR_W * 0.5,
true,
);
}
pub(crate) fn hide_all(world: &mut World) {
widget::hide_all(world, &all_sprite_ids(), &all_label_ids(), &all_field_ids());
}
pub(crate) fn all_sprite_ids() -> Vec<AssetId> {
let mut ids = vec![PANEL_BG, CLOSE_BG, BROWSE_BG, ADD_BG];
ids.extend((0..IMPORT_POOL_MAX).map(row_bg));
ids.extend([LIST_TRACK, LIST_THUMB]);
ids
}
pub(crate) fn all_label_ids() -> Vec<AssetId> {
let mut ids = vec![
TITLE_LABEL,
CLOSE_LABEL,
BROWSE_LABEL,
ADD_LABEL,
HINT_LABEL,
STATUS_LABEL,
LIST_HEADER,
];
ids.extend((0..IMPORT_POOL_MAX).map(row_label));
ids
}
pub(crate) fn all_field_ids() -> Vec<AssetId> {
vec![PATH_INPUT]
}
#[cfg(test)]
mod tests {
use super::*;
use crate::components::{Sprite, TextInput, TextLabel};
fn injected_world() -> World {
let mut world = World::new();
for id in all_sprite_ids() {
world.add_component(Sprite {
asset_id: id,
..Default::default()
});
}
for id in all_label_ids() {
world.add_component(TextLabel {
asset_id: id,
..Default::default()
});
}
for id in all_field_ids() {
world.add_component(TextInput {
asset_id: id,
..Default::default()
});
}
world
}
fn rows(n: usize) -> Vec<ImportRow> {
(0..n)
.map(|i| ImportRow {
entry: i,
caption: format!("import {i}"),
})
.collect()
}
fn view<'a>(rows: &'a [ImportRow], scroll: usize) -> ImportView<'a> {
ImportView {
rows,
scroll,
focus: true,
status: None,
mouse: [0.0, 0.0],
}
}
#[test]
fn source_of_reads_the_per_type_path_key() {
assert_eq!(source_of(&serde_json::json!({"source": "a.glb"})), "a.glb");
assert_eq!(source_of(&serde_json::json!({"path": "f.ttf"})), "f.ttf");
assert_eq!(
source_of(&serde_json::json!({"model_path": "m.gguf"})),
"m.gguf"
);
assert_eq!(source_of(&serde_json::json!({"other": 1})), "");
}
#[test]
fn header_controls_pack_without_overlap() {
let o = [40.0, 40.0];
let (p, b, a) = (
path_rect(o, IMPORT_W),
browse_rect(o, IMPORT_W),
add_rect(o, IMPORT_W),
);
assert_eq!(a[0] + a[2], o[0] + IMPORT_W - PAD, "Add pinned right");
assert_eq!(b[0] + b[2] + GAP, a[0], "Browse sits left of Add");
assert_eq!(p[0] + p[2] + GAP, b[0], "the field fills up to Browse");
assert!(p[2] > 200.0, "the path field stays usable: {}", p[2]);
for r in [p, b, a] {
assert_eq!(r[1], widget::header_y(o, 4.0));
assert_eq!(r[3], CTRL_H);
}
}
#[test]
fn hit_test_resolves_path_browse_add_and_rows() {
let r = rows(3);
let v = view(&r, 0);
let o = [40.0, 40.0];
let s = size();
let a = add_rect(o, IMPORT_W);
assert_eq!(
hit_test(&v, a[0] + 5.0, a[1] + 5.0, o, s),
Some(ImportAction::Add)
);
let b = browse_rect(o, IMPORT_W);
assert_eq!(
hit_test(&v, b[0] + 5.0, b[1] + 5.0, o, s),
Some(ImportAction::Browse)
);
let p = path_rect(o, IMPORT_W);
assert_eq!(
hit_test(&v, p[0] + 5.0, p[1] + 5.0, o, s),
Some(ImportAction::FocusPath)
);
let r1 = row_rect(o, IMPORT_W, 1);
assert_eq!(
hit_test(&v, r1[0] + 5.0, r1[1] + 5.0, o, s),
Some(ImportAction::Open(1))
);
let r5 = row_rect(o, IMPORT_W, 5);
assert_eq!(
hit_test(&v, r5[0] + 5.0, r5[1] + 5.0, o, s),
Some(ImportAction::Consume),
"past the last row the click is swallowed"
);
assert_eq!(hit_test(&v, 5000.0, 5000.0, o, s), None);
}
#[test]
fn apply_draws_header_rows_and_empty_list_note() {
let mut world = injected_world();
let r = rows(2);
apply(&mut world, Some(&view(&r, 0)), [20.0, 20.0], size());
let title = world
.query::<TextLabel>()
.find(|l| l.asset_id == TITLE_LABEL)
.unwrap();
assert!(title.visible && title.content == "Import");
let header = world
.query::<TextLabel>()
.find(|l| l.asset_id == LIST_HEADER)
.unwrap();
assert_eq!(header.content, "Imports (2)");
let first = world
.query::<TextLabel>()
.find(|l| l.asset_id == row_label(0))
.unwrap();
assert!(first.visible && first.content == "import 0");
let input = world
.query::<TextInput>()
.find(|t| t.asset_id == PATH_INPUT)
.unwrap();
assert!(input.visible && input.focused);
let browse = world
.query::<TextLabel>()
.find(|l| l.asset_id == BROWSE_LABEL)
.unwrap();
assert!(browse.visible && browse.content == "Browse...");
apply(&mut world, Some(&view(&[], 0)), [20.0, 20.0], size());
let header = world
.query::<TextLabel>()
.find(|l| l.asset_id == LIST_HEADER)
.unwrap();
assert_eq!(header.content, "Imports (none yet)");
assert!(
!world
.query::<Sprite>()
.find(|s| s.asset_id == row_bg(0))
.unwrap()
.visible
);
}
#[test]
fn long_list_shows_the_scrollbar_and_maps_scrolled_rows() {
let mut world = injected_world();
let r = rows(30);
let v = view(&r, 10);
apply(&mut world, Some(&v), [20.0, 20.0], size());
assert!(
world
.query::<Sprite>()
.find(|s| s.asset_id == LIST_THUMB)
.unwrap()
.visible
);
let o = [20.0, 20.0];
let r0 = row_rect(o, IMPORT_W, 0);
assert_eq!(
hit_test(&v, r0[0] + 5.0, r0[1] + 5.0, o, size()),
Some(ImportAction::Open(10))
);
}
#[test]
fn taller_size_reveals_more_rows_up_to_the_pool() {
assert_eq!(visible_rows(size()[1]), IMPORT_POOL);
assert_eq!(visible_rows(size()[1] + 5.0 * ROW_H), IMPORT_POOL + 5);
assert_eq!(visible_rows(max_size()[1]), IMPORT_POOL_MAX);
assert_eq!(
visible_rows(10_000.0),
IMPORT_POOL_MAX,
"capped at the pool"
);
}
#[test]
fn wider_width_grows_the_row_caption_budget() {
assert_eq!(max_row_chars(IMPORT_W), MAX_ROW_CHARS);
assert!(max_row_chars(IMPORT_W + 200.0) > MAX_ROW_CHARS);
}
#[test]
fn taller_panel_draws_rows_past_the_default_pool() {
let mut world = injected_world();
let r = rows(IMPORT_POOL_MAX);
let tall = [IMPORT_W, max_size()[1]];
apply(&mut world, Some(&view(&r, 0)), [20.0, 20.0], tall);
assert!(
world
.query::<TextLabel>()
.find(|l| l.asset_id == row_label(IMPORT_POOL))
.unwrap()
.visible
);
}
#[test]
fn hide_all_blanks_every_element() {
let mut world = injected_world();
let r = rows(2);
apply(&mut world, Some(&view(&r, 0)), [20.0, 20.0], size());
apply(&mut world, None, [0.0, 0.0], size());
assert!(world.query::<Sprite>().all(|s| !s.visible));
assert!(world.query::<TextLabel>().all(|l| !l.visible));
assert!(world.query::<TextInput>().all(|t| !t.visible));
}
}