From 6cb23124b5f89e5cbd4fca729459c50688b7e5a8 Mon Sep 17 00:00:00 2001 From: defiantnerd <97224712+defiantnerd@users.noreply.github.com> Date: Mon, 24 Aug 2026 12:37:00 +0200 Subject: [PATCH] compound: optional step on a numeric binding A binding maps an attribute to a property linearly - scale * x + offset - which is right for a continuous control and wrong for a segmented one. A meter drawn as discrete LED segments, a knob that should land on detents, a stepped bar: all of them need the *display* quantised while the attribute stays continuous, and that was not expressible. The alternative was quantising at the source, which changes the value everything else reads. `step` on CompoundBinding snaps the attribute value to the nearest multiple before the linear map, so one step is the same fraction of the range whatever the property it drives is measured in. 0 (the default) is smooth, so every existing binding is untouched. There is no bind-with-step entry point in the vtable and this needs none: the step travels through the existing float setter as ".step" (e.g. "width.step"), the same route stroke_dasharray takes to travel as a string. It is also kept in a per-layer bind_steps map, so setting it before the bind - or re-binding the prop afterwards - does not lose it, and setting it on an unbound prop cannot conjure a phantom binding that would read attribute "" as 0. The loader parses "step" inside a bind object and the serializer emits it when non-zero, so a component document round-trips. Tests cover the quantisation itself (rounding at the halfway point, reaching the full range, step 0 leaving the value alone), both setter orders plus the re-bind, and the document round-trip. --- hosts/shared/component_loader.h | 9 ++++- hosts/shared/compound.h | 26 ++++++++++++++ include/neui/d/compound.h | 9 +++++ tests/test_component_loader.cpp | 61 +++++++++++++++++++++++++++++++++ tests/test_compound.cpp | 54 +++++++++++++++++++++++++++++ 5 files changed, 158 insertions(+), 1 deletion(-) 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;