use super::expand::{asset_name, type_norm};
use super::ui_spec::{font_sizes, label_value, sprite};
use concinnity_asset::cook::Slider;
use concinnity_world::spec::{asset, spec_to_value};
const CONTROL_FRAC: f32 = 0.42;
const MAX_CONTROL_WIDTH: f32 = 360.0;
const AVG_ADVANCE_RATIO: f32 = 0.5;
const VALUE_CHARS: f32 = 7.0;
const VALUE_PLACEHOLDER: &str = "--";
pub(crate) fn expand_sliders(assets: &mut Vec<serde_json::Value>) -> Result<(), String> {
if !assets.iter().any(|v| type_norm(v) == "slider") {
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) != "slider" {
result.push(value);
continue;
}
let name = asset_name(&value);
if name.is_empty() {
return Err("Slider: missing `name`".to_string());
}
let args = value
.get("args")
.cloned()
.unwrap_or_else(|| serde_json::json!({}));
let slider: Slider = serde_json::from_value(args)
.map_err(|e| format!("Slider '{}': invalid args: {}", name, e))?;
let default_px = slider.font_px;
let font_px = if slider.font.is_empty() {
default_px
} else {
*font_px_by_name.get(&slider.font).unwrap_or(&default_px)
};
result.extend(expand_one(&name, &slider, font_px));
}
*assets = result;
Ok(())
}
pub(crate) fn element_names(base: &str) -> Vec<String> {
vec![
format!("{base}_label"),
format!("{base}_value"),
format!("{base}_track"),
format!("{base}_handle"),
]
}
fn expand_one(name: &str, s: &Slider, font_px: f32) -> Vec<serde_json::Value> {
let glyph_w = font_px * AVG_ADVANCE_RATIO * s.text_scale;
let line_h = font_px * s.text_scale;
let text_y = s.y + (s.height - line_h) / 2.0;
let ctrl_x = (s.x + s.width * CONTROL_FRAC).max(s.x + s.width - MAX_CONTROL_WIDTH);
let right = s.x + s.width;
let value_w = glyph_w * VALUE_CHARS;
let value_x = right - value_w;
let gap = glyph_w;
let track_x = ctrl_x;
let track_w = (value_x - gap - track_x).max(20.0);
let track_h = (s.height * 0.12).max(4.0);
let track_y = s.y + (s.height - track_h) / 2.0;
let handle_w = (s.height * 0.45).max(10.0);
let handle_h = (s.height * 0.7).max(16.0);
let handle_y = s.y + (s.height - handle_h) / 2.0;
let value_name = format!("{}_value", name);
let handle_name = format!("{}_handle", name);
vec![
label_value(
&format!("{}_label", name),
&s.label,
&s.font,
s.x,
text_y,
s.text_color,
s.text_scale,
),
sprite(
&format!("{}_track", name),
track_x,
track_y,
track_w,
track_h,
s.track_color,
),
sprite(
&handle_name,
track_x,
handle_y,
handle_w,
handle_h,
s.handle_color,
),
label_value(
&value_name,
VALUE_PLACEHOLDER,
&s.font,
value_x,
text_y,
s.value_color,
s.text_scale,
),
spec_to_value(
&asset::hit_region(
format!("{}_drag", name),
[track_x, s.y, track_w, s.height],
format!("setting:{}:drag", s.setting),
)
.set("label", value_name)
.set("drag_handle", handle_name),
),
]
}
#[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_sliders() {
let mut assets = vec![serde_json::json!({"name":"x","type":"Window","args":{}})];
expand_sliders(&mut assets).unwrap();
assert_eq!(assets.len(), 1);
}
#[test]
fn expands_to_label_value_track_handle_and_drag_region() {
let mut assets = vec![serde_json::json!({
"name": "sld_exposure",
"type": "Slider",
"args": {
"setting": "exposure", "label": "Exposure",
"x": 100.0, "y": 200.0, "width": 400.0, "height": 48.0
}
})];
expand_sliders(&mut assets).unwrap();
assert!(!assets.iter().any(|v| type_norm(v) == "slider"));
let lbl = by_name(&assets, "sld_exposure_label");
assert_eq!(lbl["type"], "TextLabel");
assert_eq!(lbl["args"]["content"], "Exposure");
assert_eq!(lbl["args"]["centered"], false);
assert_eq!(lbl["args"]["x"], 100.0);
let val = by_name(&assets, "sld_exposure_value");
assert_eq!(val["type"], "TextLabel");
assert_eq!(val["args"]["content"], VALUE_PLACEHOLDER);
assert_eq!(by_name(&assets, "sld_exposure_track")["type"], "Sprite");
assert_eq!(by_name(&assets, "sld_exposure_handle")["type"], "Sprite");
let drag = by_name(&assets, "sld_exposure_drag");
assert_eq!(drag["type"], "HitRegion");
assert_eq!(drag["args"]["action"], "setting:exposure:drag");
assert_eq!(drag["args"]["label"], "sld_exposure_value");
assert_eq!(drag["args"]["drag_handle"], "sld_exposure_handle");
assert_eq!(drag["args"]["y"], 200.0);
assert_eq!(drag["args"]["height"], 48.0);
}
#[test]
fn missing_name_is_an_error() {
let mut assets = vec![serde_json::json!({"type":"Slider","args":{}})];
assert!(expand_sliders(&mut assets).is_err());
}
#[test]
fn invalid_args_name_the_slider() {
let mut assets = vec![serde_json::json!({
"name": "sld", "type": "Slider", "args": {"width": "wide"}
})];
let err = expand_sliders(&mut assets).unwrap_err();
assert!(err.contains("Slider 'sld'"), "{err}");
assert!(err.contains("invalid args"), "{err}");
}
#[test]
fn slider_without_args_uses_type_defaults() {
let mut assets = vec![serde_json::json!({"name":"sld","type":"Slider"})];
expand_sliders(&mut assets).unwrap();
let defaults = Slider::default();
assert_eq!(by_name(&assets, "sld_label")["args"]["x"], defaults.x);
assert_eq!(
by_name(&assets, "sld_label")["args"]["content"],
defaults.label
);
}
#[test]
fn element_names_match_expansion() {
let mut assets = vec![serde_json::json!({
"name": "sld", "type": "Slider",
"args": { "setting": "exposure", "label": "Exposure" }
})];
expand_sliders(&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("sld").into_iter().collect();
assert_eq!(listed, emitted, "element_names drifted from the expansion");
}
}