use super::expand::{asset_name, type_norm};
use super::ui_spec::{font_sizes, label_value};
use concinnity_asset::cook::OptionSelect;
use concinnity_world::spec::{asset, spec_to_value};
fn is_dropdown(setting: &str) -> bool {
concinnity_core::gfx::settings::options(setting).is_some_and(|o| o.len() > 2)
|| concinnity_core::gfx::settings::is_dynamic_dropdown(setting)
}
const CONTROL_FRAC: f32 = 0.42;
const MAX_CONTROL_WIDTH: f32 = 360.0;
const AVG_ADVANCE_RATIO: f32 = 0.5;
const GLYPH_PAD: f32 = 8.0;
const VALUE_PLACEHOLDER: &str = "--";
pub(crate) fn expand_option_selects(assets: &mut Vec<serde_json::Value>) -> Result<(), String> {
if !assets.iter().any(|v| type_norm(v) == "optionselect") {
return Ok(());
}
let font_px_by_name = font_sizes(assets);
let mut result: Vec<serde_json::Value> = Vec::new();
for value in assets.drain(..) {
if type_norm(&value) != "optionselect" {
result.push(value);
continue;
}
let name = asset_name(&value);
if name.is_empty() {
return Err("OptionSelect: missing `name`".to_string());
}
let args = value
.get("args")
.cloned()
.unwrap_or_else(|| serde_json::json!({}));
let select: OptionSelect = serde_json::from_value(args)
.map_err(|e| format!("OptionSelect '{}': invalid args: {}", name, e))?;
let default_px = select.font_px;
let font_px = if select.font.is_empty() {
default_px
} else {
*font_px_by_name.get(&select.font).unwrap_or(&default_px)
};
result.extend(expand_one(&name, &select, font_px));
}
*assets = result;
Ok(())
}
pub(crate) fn element_names(base: &str, setting: &str) -> Vec<String> {
if is_dropdown(setting) {
vec![
format!("{base}_label"),
format!("{base}_value"),
format!("{base}_chevron"),
]
} else {
vec![
format!("{base}_label"),
format!("{base}_prev_glyph"),
format!("{base}_value"),
format!("{base}_next_glyph"),
]
}
}
fn expand_one(name: &str, s: &OptionSelect, font_px: f32) -> Vec<serde_json::Value> {
let line_h = font_px * s.text_scale;
let text_y = s.y + (s.height - line_h) / 2.0;
let value_name = format!("{}_value", name);
let glyph_w = font_px * AVG_ADVANCE_RATIO * s.text_scale;
let ctrl_x = (s.x + s.width * CONTROL_FRAC).max(s.x + s.width - MAX_CONTROL_WIDTH);
let right = s.x + s.width;
if is_dropdown(&s.setting) {
return vec![
label_value(
&format!("{}_label", name),
&s.label,
&s.font,
s.x,
text_y,
s.text_color,
s.text_scale,
),
label_value(
&value_name,
VALUE_PLACEHOLDER,
&s.font,
ctrl_x + GLYPH_PAD,
text_y,
s.value_color,
s.text_scale,
),
label_value(
&format!("{}_chevron", name),
"v",
&s.font,
right - glyph_w,
text_y,
s.value_color,
s.text_scale,
),
region(
&format!("{}_open", name),
Rect {
x: ctrl_x,
y: s.y,
width: right - ctrl_x,
height: s.height,
},
&value_name,
s,
&format!("setting:{}:open", s.setting),
),
];
}
let sw = s.stepper_width;
let next_x = ctrl_x + sw;
vec![
label_value(
&format!("{}_label", name),
&s.label,
&s.font,
s.x,
text_y,
s.text_color,
s.text_scale,
),
label_value(
&format!("{}_prev_glyph", name),
"<",
&s.font,
ctrl_x + (sw - glyph_w) / 2.0,
text_y,
s.value_color,
s.text_scale,
),
label_value(
&value_name,
VALUE_PLACEHOLDER,
&s.font,
next_x + GLYPH_PAD,
text_y,
s.value_color,
s.text_scale,
),
label_value(
&format!("{}_next_glyph", name),
">",
&s.font,
right - glyph_w,
text_y,
s.value_color,
s.text_scale,
),
region(
&format!("{}_prev", name),
Rect {
x: ctrl_x,
y: s.y,
width: sw,
height: s.height,
},
&value_name,
s,
&format!("setting:{}:prev", s.setting),
),
region(
&format!("{}_next", name),
Rect {
x: next_x,
y: s.y,
width: right - next_x,
height: s.height,
},
&value_name,
s,
&format!("setting:{}:next", s.setting),
),
]
}
#[derive(Clone, Copy)]
struct Rect {
x: f32,
y: f32,
width: f32,
height: f32,
}
fn region(
name: &str,
rect: Rect,
value_label: &str,
s: &OptionSelect,
action: &str,
) -> serde_json::Value {
spec_to_value(
&asset::hit_region(name, [rect.x, rect.y, rect.width, rect.height], action)
.set("label", value_label)
.set("hover_color", s.hover_color)
.set("hover_scale", s.hover_scale),
)
}
#[cfg(test)]
mod tests {
use super::*;
fn by_name<'a>(assets: &'a [serde_json::Value], name: &str) -> &'a serde_json::Value {
assets
.iter()
.find(|v| asset_name(v) == name)
.unwrap_or_else(|| panic!("no asset named {name}"))
}
#[test]
fn passes_through_without_selects() {
let mut assets = vec![serde_json::json!({"name":"x","type":"Window","args":{}})];
expand_option_selects(&mut assets).unwrap();
assert_eq!(assets.len(), 1);
}
#[test]
fn expands_to_name_value_glyphs_and_two_stepper_regions() {
let mut assets = vec![serde_json::json!({
"name": "opt_vsync",
"type": "OptionSelect",
"args": {
"setting": "vsync", "label": "Vsync",
"x": 100.0, "y": 200.0, "width": 300.0, "stepper_width": 40.0
}
})];
expand_option_selects(&mut assets).unwrap();
assert!(!assets.iter().any(|v| type_norm(v) == "optionselect"));
let lbl = by_name(&assets, "opt_vsync_label");
assert_eq!(lbl["type"], "TextLabel");
assert_eq!(lbl["args"]["content"], "Vsync");
assert_eq!(lbl["args"]["centered"], false);
assert_eq!(lbl["args"]["x"], 100.0);
let val = by_name(&assets, "opt_vsync_value");
assert_eq!(val["args"]["content"], VALUE_PLACEHOLDER);
assert_eq!(
by_name(&assets, "opt_vsync_prev_glyph")["args"]["content"],
"<"
);
assert_eq!(
by_name(&assets, "opt_vsync_next_glyph")["args"]["content"],
">"
);
let prev = by_name(&assets, "opt_vsync_prev");
assert_eq!(prev["type"], "HitRegion");
assert_eq!(prev["args"]["action"], "setting:vsync:prev");
assert_eq!(prev["args"]["label"], "opt_vsync_value");
assert_eq!(prev["args"]["x"], 226.0);
assert_eq!(prev["args"]["width"], 40.0);
let next = by_name(&assets, "opt_vsync_next");
assert_eq!(next["args"]["action"], "setting:vsync:next");
assert_eq!(next["args"]["label"], "opt_vsync_value");
assert_eq!(next["args"]["x"], 266.0);
assert_eq!(next["args"]["width"], 134.0);
}
#[test]
fn missing_name_is_an_error() {
let mut assets = vec![serde_json::json!({"type":"OptionSelect","args":{}})];
assert!(expand_option_selects(&mut assets).is_err());
}
#[test]
fn invalid_args_name_the_select() {
let mut assets = vec![serde_json::json!({
"name": "opt", "type": "OptionSelect", "args": {"width": "wide"}
})];
let err = expand_option_selects(&mut assets).unwrap_err();
assert!(err.contains("OptionSelect 'opt'"), "{err}");
assert!(err.contains("invalid args"), "{err}");
}
#[test]
fn select_without_args_uses_type_defaults() {
let mut assets = vec![serde_json::json!({"name":"opt","type":"OptionSelect"})];
expand_option_selects(&mut assets).unwrap();
let defaults = OptionSelect::default();
assert_eq!(by_name(&assets, "opt_label")["args"]["x"], defaults.x);
assert_eq!(
by_name(&assets, "opt_label")["args"]["content"],
defaults.label
);
}
#[test]
fn expands_to_dropdown_with_open_region() {
let mut assets = vec![serde_json::json!({
"name": "opt_wm",
"type": "OptionSelect",
"args": {
"setting": "window_mode", "label": "Window Mode",
"x": 100.0, "y": 200.0, "width": 300.0
}
})];
expand_option_selects(&mut assets).unwrap();
assert_eq!(
by_name(&assets, "opt_wm_label")["args"]["content"],
"Window Mode"
);
assert_eq!(
by_name(&assets, "opt_wm_value")["args"]["content"],
VALUE_PLACEHOLDER
);
assert_eq!(by_name(&assets, "opt_wm_chevron")["args"]["content"], "v");
assert!(!assets.iter().any(|v| asset_name(v) == "opt_wm_prev_glyph"));
assert!(!assets.iter().any(|v| asset_name(v) == "opt_wm_next_glyph"));
let open = by_name(&assets, "opt_wm_open");
assert_eq!(open["type"], "HitRegion");
assert_eq!(open["args"]["action"], "setting:window_mode:open");
assert_eq!(open["args"]["label"], "opt_wm_value");
assert!(!assets.iter().any(|v| asset_name(v) == "opt_wm_prev"));
assert!(!assets.iter().any(|v| asset_name(v) == "opt_wm_next"));
}
#[test]
fn dynamic_setting_expands_to_dropdown() {
let mut assets = vec![serde_json::json!({
"name": "opt_res",
"type": "OptionSelect",
"args": {
"setting": "resolution", "label": "Resolution",
"x": 100.0, "y": 200.0, "width": 300.0
}
})];
expand_option_selects(&mut assets).unwrap();
let open = by_name(&assets, "opt_res_open");
assert_eq!(open["args"]["action"], "setting:resolution:open");
assert_eq!(by_name(&assets, "opt_res_chevron")["args"]["content"], "v");
assert!(!assets.iter().any(|v| asset_name(v) == "opt_res_prev"));
}
#[test]
fn element_names_match_expansion() {
for (setting, label) in [("vsync", "Vsync"), ("window_mode", "Window Mode")] {
let mut assets = vec![serde_json::json!({
"name": "opt", "type": "OptionSelect",
"args": { "setting": setting, "label": label }
})];
expand_option_selects(&mut assets).unwrap();
let emitted: std::collections::HashSet<String> = assets
.iter()
.filter(|v| matches!(type_norm(v).as_str(), "textlabel" | "sprite"))
.map(asset_name)
.collect();
let listed: std::collections::HashSet<String> =
element_names("opt", setting).into_iter().collect();
assert_eq!(listed, emitted, "element_names drifted for '{setting}'");
}
}
}