Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion hosts/shared/component_loader.h
Original file line number Diff line number Diff line change
Expand Up @@ -682,11 +682,17 @@ namespace neui_detail
if (bkv.first == "asset") {
apis.compound->bind_asset(session, cs, layer, "asset", attr->c_str());
} else {
double scale = 1.0, offset = 0.0;
double scale = 1.0, offset = 0.0, step = 0.0;
as_num(obj_get(*bo, "scale"), scale);
as_num(obj_get(*bo, "offset"), offset);
apis.compound->bind(session, cs, layer, bkv.first.c_str(), attr->c_str(),
static_cast<float>(scale), static_cast<float>(offset));
// Optional quantisation of the bound value, carried as the
// "<prop>.step" float prop (no bind-with-step in the vtable).
if (as_num(obj_get(*bo, "step"), step) && step > 0.0)
apis.compound->set_float(session, cs, layer,
(bkv.first + ".step").c_str(),
static_cast<float>(step));
}
}
}
Expand Down Expand Up @@ -1015,6 +1021,7 @@ namespace neui_detail
if (!b.is_asset) {
one.emplace_back("scale", njson_num(b.scale));
one.emplace_back("offset", njson_num(b.offset));
if (b.step > 0.0f) one.emplace_back("step", njson_num(b.step));
}
bo.emplace_back(k, njson_obj(std::move(one)));
}
Expand Down
26 changes: 26 additions & 0 deletions hosts/shared/compound.h
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@ namespace neui_detail
std::string attr_key;
float scale = 1.0f;
float offset = 0.0f;
// Quantisation of the attribute value, applied BEFORE scale/offset: the
// value is snapped to the nearest multiple of `step`. 0 (default) = smooth.
// A stepped bar / arc / rotation is a display decision, not a data one, so
// it belongs here rather than in whatever writes the attr.
float step = 0.0f;
bool is_asset = false;
};

Expand Down Expand Up @@ -250,6 +255,9 @@ namespace neui_detail

// Bindings per property name.
std::unordered_map<std::string, CompoundBinding> bindings;
// Quantisation per property name, kept apart from the binding so it can be
// set before one exists and survives a re-bind. Empty unless used.
std::unordered_map<std::string, float> bind_steps;
};

// The compound asset itself - a slot-reused vector of CompoundLayer.
Expand Down Expand Up @@ -534,6 +542,9 @@ namespace neui_detail
{
if (b.is_asset) return 0.0f;
float x = attr_as_float(bag, b.attr_key, 0.0f);
// Snap first, then map: `step` is in attribute units, so one step is the
// same fraction of the range whatever the property it drives is measured in.
if (b.step > 0.0f) x = std::round(x / b.step) * b.step;
return b.scale * x + b.offset;
}

Expand Down Expand Up @@ -653,6 +664,19 @@ namespace neui_detail

inline bool apply_set_float(CompoundLayer& L, const std::string& prop, float v)
{
// "<prop>.step" is the quantisation of THAT prop's binding (see
// eval_binding_float). The compound API has no bind-with-step entry point
// and this needs no vtable change - the same route stroke_dasharray takes to
// travel as a string. Kept in `bind_steps` as well as on the binding, so it
// survives a later (re)bind and does not depend on call order.
if (prop.size() > 5 && prop.compare(prop.size() - 5, 5, ".step") == 0) {
std::string target = prop.substr(0, prop.size() - 5);
float step = (v > 0.0f) ? v : 0.0f;
L.bind_steps[target] = step;
auto it = L.bindings.find(target);
if (it != L.bindings.end()) it->second.step = step;
return true;
}
if (prop == "alpha") {
if (v < 0.0f) v = 0.0f;
if (v > 1.0f) v = 1.0f;
Expand Down Expand Up @@ -744,6 +768,8 @@ namespace neui_detail
b.scale = scale;
b.offset = offset;
b.is_asset = false;
auto s = L.bind_steps.find(prop); // set before the bind, or by a re-bind
if (s != L.bind_steps.end()) b.step = s->second;
L.bindings[prop] = std::move(b);
}

Expand Down
9 changes: 9 additions & 0 deletions include/neui/d/compound.h
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,12 @@ extern "C" {
// "width" int px (NEUI_COMPOUND_FILL = match widget width)
// "height" int px (NEUI_COMPOUND_FILL = match widget height)
// "alpha" float 0..1 opacity; 0 short-circuits the layer
// "<prop>.step" float quantisation of the binding on <prop> (e.g.
// "width.step"): the attribute value is snapped to
// the nearest multiple of this before scale/offset,
// so a bound bar / arc / rotation moves in steps.
// 0 (default) = smooth. Independent of call order -
// it may be set before or after the bind.
// "show_when" int bitmask of NEUI_LAYER_STATE_* flags; 0 (default)
// = visible always; non-zero = visible only when
// (current_state & show_when) != 0.
Expand All @@ -367,6 +373,9 @@ extern "C" {
// is computed and applied to the property. Int-typed properties
// (offset_x, width, color, align_*) are rounded to nearest.
//
// Set "<prop>.step" (see the prop list above) to quantise x before that
// map, which steps the property without quantising the attribute itself.
//
// Replaces any previous static value or binding on the same prop.
void (NEUI_ABI *bind)(neui_session_t session, neui_asset_t asset,
neui_compound_layer_t layer,
Expand Down
61 changes: 61 additions & 0 deletions tests/test_component_loader.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -532,6 +532,34 @@ TEST_CASE("component_loader: a group layer nests children via add_child_layer")
CHECK(g_layers[2].parent == 0);
}

TEST_CASE("component_loader: a stepped bind travels as the \"<prop>.step\" prop")
{
// A value-driven clip window (a group whose width is bound) that snaps in
// eight steps - the shape asvglib's `reveal:` class emits.
const char* json = R"json({
"size": [220, 150],
"layers": [
{ "kind": "group", "z": 0, "anchor": ["top_left","top_left"], "size": "fill",
"bind": { "width": { "attr": "neui.param.value", "scale": 140, "offset": 60,
"step": 0.125 } },
"layers": [ { "kind": "rect", "z": 0, "fill_color": "#FF7CF29C" } ] },
{ "kind": "group", "z": 1, "anchor": ["top_left","top_left"], "size": "fill",
"bind": { "width": { "attr": "neui.param.value", "scale": 188, "offset": 16 } },
"layers": [ { "kind": "rect", "z": 0, "fill_color": "#FFEDF2F7" } ] }
]
})json";
run_loader(json);

REQUIRE(g_layers.size() == 4);
REQUIRE(g_layers[0].binds.count("width") == 1);
CHECK(g_layers[0].binds.at("width").attr == "neui.param.value");
CHECK_APPROX(g_layers[0].binds.at("width").scale, 140.0);
CHECK_APPROX(g_layers[0].flts.at("width.step"), 0.125);
// A smooth bind must not carry the prop at all - the default has to stay off.
REQUIRE(g_layers[2].binds.count("width") == 1);
CHECK(g_layers[2].flts.count("width.step") == 0);
}

TEST_CASE("build_component: arc layer props + value binding")
{
const char* json = R"json({
Expand Down Expand Up @@ -960,6 +988,39 @@ TEST_CASE("serialize_component: nests a group's children under a layers array")
// load (target_y was previously absent from the string-prop table, so it was
// silently dropped at load while serialize still emitted it - a lossy
// round-trip for any biaxial component).
TEST_CASE("serialize_component: round-trips a stepped binding")
{
// A stepped bind has to survive emit -> load, or a saved component loses the
// quantisation it was authored with.
CompoundAsset ca;
uint32_t g = compound_add_layer(ca, NEUI_COMPOUND_LAYER_GROUP, 0);
compound_get_layer(ca, g)->width = NEUI_COMPOUND_FILL;
compound_get_layer(ca, g)->height = NEUI_COMPOUND_FILL;
apply_bind(*compound_get_layer(ca, g), "width", "neui.param.value", 140.0f, 60.0f);
apply_set_float(*compound_get_layer(ca, g), "width.step", 0.125f);
uint32_t c0 = compound_add_child_layer(ca, g, NEUI_COMPOUND_LAYER_RECT, 0);
apply_set_int(*compound_get_layer(ca, c0), "fill_color",
static_cast<int>(static_cast<uint32_t>(0xFF7CF29Cu)));

BehaviorAsset ba;
std::string nm = "seg";
std::vector<std::pair<std::string, std::string>> an;
std::vector<std::pair<uint32_t, std::string>> hn;
ComponentSerializeInput in;
in.name = &nm; in.width = 220.0f; in.height = 150.0f;
in.asset_names = &an; in.asset_handle_names = &hn;
in.compound = &ca; in.behavior = &ba;

std::string json = serialize_component(in, 0);
CHECK(json.find("\"step\"") != std::string::npos);

run_loader(json);
REQUIRE(g_layers.size() == 2);
REQUIRE(g_layers[0].binds.count("width") == 1);
CHECK_APPROX(g_layers[0].binds.at("width").offset, 60.0);
CHECK_APPROX(g_layers[0].flts.at("width.step"), 0.125);
}

TEST_CASE("component_loader: drag_biaxial target_y survives load + round-trip")
{
const char* kBiaxial = R"json({
Expand Down
54 changes: 54 additions & 0 deletions tests/test_compound.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,60 @@ TEST_CASE("eval_binding_float: scale*x + offset over the attr value")
CHECK_APPROX(eval_binding_float(bi, &ibag), 6.0);
}

TEST_CASE("eval_binding_float: step quantises the attr value before the map")
{
AttrBag bag;
CompoundBinding b;
b.attr_key = "v";
b.scale = 100.0f;
b.offset = 10.0f;
b.step = 0.25f;

bag.set_float("v", 0.0f);
CHECK_APPROX(eval_binding_float(b, &bag), 10.0); // 0 -> 0
bag.set_float("v", 0.12f);
CHECK_APPROX(eval_binding_float(b, &bag), 10.0); // rounds down to 0
bag.set_float("v", 0.13f);
CHECK_APPROX(eval_binding_float(b, &bag), 35.0); // rounds up to 0.25
bag.set_float("v", 0.6f);
CHECK_APPROX(eval_binding_float(b, &bag), 60.0); // 0.5
bag.set_float("v", 1.0f);
CHECK_APPROX(eval_binding_float(b, &bag), 110.0); // reaches the full range

// step 0 is the default and must leave the value alone.
b.step = 0.0f;
bag.set_float("v", 0.13f);
CHECK_APPROX(eval_binding_float(b, &bag), 23.0);
}

TEST_CASE("apply_set_float: \"<prop>.step\" quantises that prop's binding, either order")
{
AttrBag bag;
bag.set_float("v", 0.6f);

// Step after the bind.
CompoundLayer L;
apply_bind(L, "width", "v", 100.0f, 0.0f);
CHECK(apply_set_float(L, "width.step", 0.25f));
CHECK_APPROX(L.bindings.at("width").step, 0.25);
CHECK_EQ(effective_int(L, "width", 0, &bag), 50); // 0.5 * 100

// Step before the bind - and surviving a re-bind, since the reveal path in a
// component document may set them in either order.
CompoundLayer M;
CHECK(apply_set_float(M, "height.step", 0.25f));
CHECK(M.bindings.find("height") == M.bindings.end()); // no phantom binding
CHECK_EQ(effective_int(M, "height", 7, &bag), 7); // still the static value
apply_bind(M, "height", "v", 100.0f, 0.0f);
CHECK_EQ(effective_int(M, "height", 7, &bag), 50);
apply_bind(M, "height", "v", 200.0f, 0.0f);
CHECK_EQ(effective_int(M, "height", 7, &bag), 100); // step kept across the re-bind

// A negative or zero step clears it.
CHECK(apply_set_float(M, "height.step", 0.0f));
CHECK_EQ(effective_int(M, "height", 7, &bag), 120); // 0.6 * 200, unquantised
}

TEST_CASE("effective_int/float: static value unless a binding overrides")
{
AttrBag bag;
Expand Down