diff --git a/hosts/shared/component_loader.h b/hosts/shared/component_loader.h index 14e2b88..cf3527d 100644 --- a/hosts/shared/component_loader.h +++ b/hosts/shared/component_loader.h @@ -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(scale), static_cast(offset)); + // Optional quantisation of the bound value, carried as the + // ".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(step)); } } } @@ -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))); } diff --git a/hosts/shared/compound.h b/hosts/shared/compound.h index dede1be..fe720f6 100644 --- a/hosts/shared/compound.h +++ b/hosts/shared/compound.h @@ -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; }; @@ -250,6 +255,9 @@ namespace neui_detail // Bindings per property name. std::unordered_map 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 bind_steps; }; // The compound asset itself - a slot-reused vector of CompoundLayer. @@ -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; } @@ -653,6 +664,19 @@ namespace neui_detail inline bool apply_set_float(CompoundLayer& L, const std::string& prop, float v) { + // ".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; @@ -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); } diff --git a/include/neui/d/compound.h b/include/neui/d/compound.h index d8a0a9f..a15574e 100644 --- a/include/neui/d/compound.h +++ b/include/neui/d/compound.h @@ -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 + // ".step" float quantisation of the binding on (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. @@ -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 ".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, diff --git a/tests/test_component_loader.cpp b/tests/test_component_loader.cpp index 894fb2c..c603e5e 100644 --- a/tests/test_component_loader.cpp +++ b/tests/test_component_loader.cpp @@ -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 \".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({ @@ -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(static_cast(0xFF7CF29Cu))); + + BehaviorAsset ba; + std::string nm = "seg"; + std::vector> an; + std::vector> 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({ diff --git a/tests/test_compound.cpp b/tests/test_compound.cpp index 5d3c532..102fc72 100644 --- a/tests/test_compound.cpp +++ b/tests/test_compound.cpp @@ -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: \".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;