From 57c5522d885288a25cd4350fe6e9cc504c8897c6 Mon Sep 17 00:00:00 2001 From: seclorum Date: Sun, 8 Nov 2026 17:43:12 +0100 Subject: [PATCH 1/7] lua: menubars fix; ref hooks fix, follow python method; set_menus on menubar fix; ctx:free() no-ops fix; widget tests --- README.md | 6 + bindings/lua/README.md | 33 +++++ bindings/lua/src/bui_context.c | 166 ++++++++++++---------- bindings/lua/src/bui_internal.h | 3 + bindings/lua/src/bui_lua.c | 3 + bindings/lua/src/bui_node.c | 107 ++++++++++---- bindings/lua/tests/test_context.lua | 3 +- bindings/lua/tests/test_widgets.lua | 109 +++++++++++++- website/src/content/docs/lua/overview.mdx | 13 ++ 9 files changed, 336 insertions(+), 107 deletions(-) diff --git a/README.md b/README.md index a8e40c4..de2fdbd 100644 --- a/README.md +++ b/README.md @@ -1439,6 +1439,12 @@ bui.run() `custom`, `canvas`, `list`, `tree`, `gridview`, `ramp`, `curve`, `normal`, `histogram`, `radio`, `meter`, `filebrowser`. +**Menus**: item tables (`text`, `icon` by name or id, `shortcut`, `checked` / +`enabled` as booleans or numbers, `fn`, `sub`) feed `node:set_menus` — on a +`menubar` that builds one `menubutton` child per menu, like Python's +`MenuBar` — and `ctx:open_menu` / `ctx:open_pie`; `fn` callbacks are refcounted +by the kit and released with the items. + **Examples** (26 scripts, `bindings/lua/examples/`): - **Getting started:** `hello.lua`, `counter.lua`, `forms.lua`, `gallery.lua` diff --git a/bindings/lua/README.md b/bindings/lua/README.md index 8e8288d..e5b33c4 100644 --- a/bindings/lua/README.md +++ b/bindings/lua/README.md @@ -81,6 +81,39 @@ ctx:key("a", "a", 0) `canvas`, `list`, `tree`, `gridview`, `ramp`, `curve`, `normal`, `histogram`, `radio`, `meter`, `filebrowser` +## Menus + +Menu items are plain tables, matching the Python binding's `MenuItem`: + +```lua +mb = bui.Node("menubutton") +mb:set("text", "File") +mb:set_menus({ + {text = "New", icon = "FILE_NEW", fn = new_file}, -- icon: name or id + {text = "Bold", checked = true, fn = toggle_bold}, -- checked/enabled: bool or number + {text = "Save", shortcut = "Ctrl S", enabled = false}, + {text = "-"}, -- separator + {text = "Recent", sub = { -- submenu + {text = "doc1.blend", fn = open_recent}, + }}, +}) + +-- A menubar builds one menubutton child per top-level item (like Python's MenuBar) +bar = bui.Node("menubar") +bar:set_menus({ + {text = "File", sub = {{text = "New", fn = new_file}, {text = "Quit", fn = quit}}}, + {text = "Edit", sub = {{text = "Undo", fn = undo}}}, +}) + +-- Popup menus from code +ctx:open_menu(owner, {{text = "Cut", fn = cut}}, "Edit", 120, 90) +ctx:open_pie({{text = "W", fn = w}, {text = "E", fn = e}}, "Direction") +``` + +`fn` callbacks live in the Lua registry for exactly as long as the kit holds a +copy of the item (menu button, open popup, search filter) — they are released +automatically when the items are replaced or the node/context is freed. + ## Examples ```sh diff --git a/bindings/lua/src/bui_context.c b/bindings/lua/src/bui_context.c index b716f2c..d30b706 100644 --- a/bindings/lua/src/bui_context.c +++ b/bindings/lua/src/bui_context.c @@ -10,6 +10,12 @@ int luabui_host_requested = 0; * This is set during luabui_init_host_callbacks. */ static lua_State *g_host_L = NULL; +/* The module's Lua state, set at module load. Menu refs can be created + * (set_menus) before any context exists, so the ref hooks run against this + * state from the start, mirroring the Python binding installing its hooks + * at module init. */ +static lua_State *g_ref_L = NULL; + /* Forward: host callbacks */ static void host_request(void *data); static void host_set_cursor(void *data, int cursor); @@ -17,6 +23,13 @@ static char *host_clipboard_get(void *data); static void host_clipboard_set(void *data, const char *text); static void host_schedule(void *data, int ms, int timer); static void host_emit(void *data, BKNode *node, int kind, void *ref, double a, double b); +static void ref_retain(void *ref); +static void ref_release(void *ref); + +void luabui_set_ref_state(lua_State *L) { + g_ref_L = L; + BK_set_ref_hooks(ref_retain, ref_release); +} /* ------------------------------------------------------------------ */ /* BKHost implementation */ @@ -106,21 +119,67 @@ static void host_emit(void *data, BKNode *node, int kind, void *ref, double a, d /* ------------------------------------------------------------------ */ /* Ref hooks (retain/release for BKMenuSpec refs embedded in menu - * copies). Lua registry refs are permanent so retain is a no-op; - * release calls luaL_unref to let the GC reclaim the closure. */ + * copies). Same model as the Python binding's Py_INCREF/DECREF hooks: + * every copy the engine makes (bk_menuspec_copy / menu_filter / pick + * across close) retains, every free (bk_menuspec_free / pie_free) + * releases. A registry ref is owned by the builder's luaL_ref and by + * exactly the live engine copies, so the last release unrefs it. */ /* ------------------------------------------------------------------ */ -/* Retain/release hooks are intentionally NOT set up. The BUI engine calls - * bk_retain / bk_release when deep-copying menu specs (bk_menuspec_copy / - * bk_menuspec_free) and the menubutton stores a persistent copy in its own - * MenuButtonData. If we set up ref hooks here, the same Lua registry ref - * would be freed twice — once when the menu popup copy is released and - * again when the menubutton's persistent copy is released. - * - * Instead, Lua refs are freed manually: the menubutton's refs are tracked - * in _menu_refs and freed on the next set_menus call (see l_node_set_menus); - * one-shot open_menu / open_pie refs are small and reclaimed on context - * close. */ +/* Registry table ref-id -> live-copy count. Refs the engine never + * retained (host-owned BK_bind keys) are absent and stay untouched. */ +#define LUABUI_MENU_REFCNT "bui._menu_refcnt" + +static void ref_retain(void *ref) { + lua_State *L = g_ref_L ? g_ref_L : g_host_L; + int r = (int)(intptr_t)ref; + if (!L || !r) return; + int top = lua_gettop(L); + lua_getfield(L, LUA_REGISTRYINDEX, LUABUI_MENU_REFCNT); + if (lua_isnil(L, -1)) { + lua_pop(L, 1); + lua_newtable(L); + lua_pushvalue(L, -1); + lua_setfield(L, LUA_REGISTRYINDEX, LUABUI_MENU_REFCNT); + } + lua_pushinteger(L, r); + lua_rawget(L, -2); + lua_Integer count = lua_isnil(L, -1) ? 0 : (lua_Integer)lua_tointeger(L, -1); + lua_pop(L, 1); + lua_pushinteger(L, r); + lua_pushinteger(L, count + 1); + lua_rawset(L, -3); + lua_settop(L, top); +} + +static void ref_release(void *ref) { + lua_State *L = g_ref_L ? g_ref_L : g_host_L; + int r = (int)(intptr_t)ref; + if (!L || !r) return; + int top = lua_gettop(L); + lua_getfield(L, LUA_REGISTRYINDEX, LUABUI_MENU_REFCNT); + if (lua_istable(L, -1)) { + lua_pushinteger(L, r); + lua_rawget(L, -2); + if (!lua_isnil(L, -1)) { + lua_Integer count = (lua_Integer)lua_tointeger(L, -1) - 1; + lua_pop(L, 1); + if (count <= 0) { + luaL_unref(L, LUA_REGISTRYINDEX, r); + lua_pushinteger(L, r); + lua_pushnil(L); + lua_rawset(L, -3); + } else { + lua_pushinteger(L, r); + lua_pushinteger(L, count); + lua_rawset(L, -3); + } + } else { + lua_pop(L, 1); + } + } + lua_settop(L, top); +} /* ------------------------------------------------------------------ */ /* Initialization */ @@ -301,39 +360,16 @@ static int l_ctx_timer(lua_State *L) { return 0; } -/* Registry key for the bind-refs table: combo string -> {ref, prev_ref} */ -#define LUABUI_BIND_REFS "bui._bind_refs" - +/* Hotkey ref: BK_bind retains it and BK_context_free releases it, so the + * registry ref's lifetime is owned by the engine (as in the Python + * binding, which hands the callable straight to BK_bind). */ static int l_ctx_bind(lua_State *L) { luabui_Context *cud = CHECK_CTX(L, 1); if (!cud->ctx) return 0; const char *combo = luaL_checkstring(L, 2); luaL_checktype(L, 3, LUA_TFUNCTION); - - /* Unref any previous bind for this combo */ - lua_getfield(L, LUA_REGISTRYINDEX, LUABUI_BIND_REFS); - if (lua_isnil(L, -1)) { - lua_pop(L, 1); - lua_newtable(L); - lua_pushvalue(L, -1); - lua_setfield(L, LUA_REGISTRYINDEX, LUABUI_BIND_REFS); - } - lua_pushstring(L, combo); - lua_rawget(L, -2); - if (!lua_isnil(L, -1)) { - int old_ref = (int)lua_tointeger(L, -1); - luaL_unref(L, LUA_REGISTRYINDEX, old_ref); - } - lua_pop(L, 1); /* pop old ref value or nil */ - - /* Store new ref */ - lua_pushvalue(L, 3); /* copy function for luaL_ref */ + lua_pushvalue(L, 3); int ref = luaL_ref(L, LUA_REGISTRYINDEX); - lua_pushstring(L, combo); - lua_pushinteger(L, ref); - lua_rawset(L, -3); /* bind_refs[combo] = ref */ - lua_pop(L, 1); /* bind_refs table */ - BK_bind(cud->ctx, combo, (void *)(intptr_t)ref); return 0; } @@ -559,50 +595,31 @@ static int l_ctx_open_pie(lua_State *L) { return 0; } -/* Nullify all node userdata pointers in the node map. Called before freeing - * a context so that node __gc (which runs later) sees ud->node == NULL and - * does not try to access freed C memory. */ -/* Only nullify nodes that belong to `ctx`. Nodes from other contexts - * must stay valid — a filebrowser or popup context can be freed without - * disrupting the main UI context. */ -static void nullify_ctx_nodes(lua_State *L, BKContext *ctx) { - lua_getfield(L, LUA_REGISTRYINDEX, LUABUI_NODE_MAP); - lua_pushnil(L); - while (lua_next(L, -2)) { - if (lua_isuserdata(L, -1) && luaL_testudata(L, -1, LUABUI_NODE_MT)) { - luabui_Node *ud = (luabui_Node *)lua_touserdata(L, -1); - if (ud->node && bk_ctx(ud->node) == ctx) { - ud->node = NULL; - } - } +/* Invalidate the Lua wrappers of the nodes BK_context_free is about to + * free: the open popups (menus, pies, pickers) and their internal parts. + * Every other node — the root and its whole tree — outlives the context + * (Lua / the host owns them) and must stay usable after ctx:free(). The + * popover panel's body is borrowed from its owner, so the panel's kids + * are not ours to free and their wrappers stay valid. */ +static void nullify_popup_subtree(lua_State *L, BKNode *n, int depth) { + if (luabui_lookup_node(L, n)) { + luabui_Node *ud = (luabui_Node *)lua_touserdata(L, -1); + if (ud && luaL_testudata(L, -1, LUABUI_NODE_MT)) ud->node = NULL; lua_pop(L, 1); } - lua_pop(L, 1); + if (depth >= 32 || n->cls == &bk_cls_popover_panel) return; + for (int i = 0; i < n->nkids; i++) nullify_popup_subtree(L, n->kids[i], depth + 1); } -/* Clear all callback refs from the context */ -static void unref_bind_refs(lua_State *L) { - lua_getfield(L, LUA_REGISTRYINDEX, LUABUI_BIND_REFS); - if (lua_istable(L, -1)) { - lua_pushnil(L); - while (lua_next(L, -2)) { - /* key (combo), value (ref) */ - int ref = (int)lua_tointeger(L, -1); - luaL_unref(L, LUA_REGISTRYINDEX, ref); - lua_pop(L, 1); /* pop value, keep key for next */ - } - lua_pushnil(L); - lua_setfield(L, LUA_REGISTRYINDEX, LUABUI_BIND_REFS); - } - lua_pop(L, 1); +static void nullify_ctx_nodes(lua_State *L, BKContext *ctx) { + for (int i = 0; i < ctx->npopups; i++) nullify_popup_subtree(L, ctx->popups[i], 0); } static int l_ctx_free(lua_State *L) { luabui_Context *cud = CHECK_CTX(L, 1); if (cud->ctx) { - unref_bind_refs(L); nullify_ctx_nodes(L, cud->ctx); - BK_context_free(cud->ctx); + BK_context_free(cud->ctx); /* releases bind + menu refs via the hooks */ cud->ctx = NULL; } return 0; @@ -611,7 +628,6 @@ static int l_ctx_free(lua_State *L) { static int l_ctx_gc(lua_State *L) { luabui_Context *cud = (luabui_Context *)luaL_checkudata(L, 1, LUABUI_CONTEXT_MT); if (cud->ctx) { - unref_bind_refs(L); nullify_ctx_nodes(L, cud->ctx); BK_context_free(cud->ctx); cud->ctx = NULL; diff --git a/bindings/lua/src/bui_internal.h b/bindings/lua/src/bui_internal.h index 85829ef..aa3988a 100644 --- a/bindings/lua/src/bui_internal.h +++ b/bindings/lua/src/bui_internal.h @@ -115,6 +115,9 @@ extern int luabui_host_requested; void luabui_init_host_callbacks(lua_State *L, BKContext *ctx); +/* Install the engine ref hooks against this state (called from luaopen). */ +void luabui_set_ref_state(lua_State *L); + /* ------------------------------------------------------------------ */ /* Known numeric property names (for node:get() dispatch) */ /* ------------------------------------------------------------------ */ diff --git a/bindings/lua/src/bui_lua.c b/bindings/lua/src/bui_lua.c index aea2399..1297dc2 100644 --- a/bindings/lua/src/bui_lua.c +++ b/bindings/lua/src/bui_lua.c @@ -435,6 +435,9 @@ LUAMOD_API int luaopen_bui(lua_State *L) { return lua_error(L); } + /* Menu/hotkey ref hooks (retain/release), as in the Python binding */ + luabui_set_ref_state(L); + /* Weak-keyed node map in registry */ lua_newtable(L); lua_newtable(L); diff --git a/bindings/lua/src/bui_node.c b/bindings/lua/src/bui_node.c index 73ad36e..c698b4a 100644 --- a/bindings/lua/src/bui_node.c +++ b/bindings/lua/src/bui_node.c @@ -748,6 +748,23 @@ static int l_node_set_grid_colors(lua_State *L) { return 0; } +/* Truthy field with a numeric default (Python's PyObject_IsTrue): nil + * means absent (dflt), booleans are 0/1, numbers pass through, any + * other value is truthy. */ +static int opt_flag(lua_State *L, int idx, int dflt) { + if (lua_isnoneornil(L, idx)) return dflt; + if (lua_isboolean(L, idx)) return lua_toboolean(L, idx); + if (lua_isnumber(L, idx)) return (int)lua_tointeger(L, idx); + return 1; +} + +/* Icon field: a number (icon id) or a string (icon name, BK_icon_id). */ +static int opt_icon(lua_State *L, int idx) { + if (lua_isstring(L, idx)) return BK_icon_id(lua_tostring(L, idx)); + if (lua_isnumber(L, idx)) return (int)lua_tointeger(L, idx); + return 0; +} + /* Recursive menu-spec builder. * * `idx` — absolute stack index of a Lua array-of-tables (items table). @@ -757,6 +774,9 @@ static int l_node_set_grid_colors(lua_State *L) { * until the menu is installed (e.g. BK_menubutton_set_items has completed * its deep copy), then pop everything at once. * + * `fn` values become registry refs; the kit's ref hooks (ref_retain / + * ref_release in bui_context.c) balance them, as in the Python binding. + * * Returns the number of items, writes `*out` to the BKMenuSpec pointer. */ int luabui_build_menuspec(lua_State *L, int idx, BKMenuSpec **out) { @@ -771,10 +791,10 @@ int luabui_build_menuspec(lua_State *L, int idx, BKMenuSpec **out) { if (!lua_istable(L, -1)) { lua_pop(L, 1); continue; } lua_getfield(L, -1, "text"); specs[i].text = lua_tostring(L, -1); lua_pop(L, 1); - lua_getfield(L, -1, "icon"); specs[i].icon = (int)luaL_optinteger(L, -1, 0); lua_pop(L, 1); + lua_getfield(L, -1, "icon"); specs[i].icon = opt_icon(L, -1); lua_pop(L, 1); lua_getfield(L, -1, "shortcut");specs[i].shortcut = lua_tostring(L, -1); lua_pop(L, 1); - lua_getfield(L, -1, "checked"); specs[i].checked = (int)luaL_optinteger(L, -1, -1); lua_pop(L, 1); - lua_getfield(L, -1, "enabled"); specs[i].enabled = (int)luaL_optinteger(L, -1, 1); lua_pop(L, 1); + lua_getfield(L, -1, "checked"); specs[i].checked = opt_flag(L, -1, -1); lua_pop(L, 1); + lua_getfield(L, -1, "enabled"); specs[i].enabled = opt_flag(L, -1, 1); lua_pop(L, 1); lua_getfield(L, -1, "fn"); if (lua_isfunction(L, -1)) { specs[i].ref = (void *)(intptr_t)luaL_ref(L, LUA_REGISTRYINDEX); @@ -799,43 +819,70 @@ int luabui_build_menuspec(lua_State *L, int idx, BKMenuSpec **out) { return n; } -static int l_node_set_menus(lua_State *L) { - luabui_Node *ud = CHECK_NODE(L, 1); - if (!ud->node) return luaL_error(L, "attempt to use freed node"); - - /* Free old refs stored in the uservalue table */ +/* Drop the menubutton children a previous set_menus built into this + * menubar (tracked as lightuserdata in the uservalue's _bar_btns). */ +static void clear_bar_buttons(lua_State *L, luabui_Node *ud) { lua_getuservalue(L, 1); - lua_getfield(L, -1, "_menu_refs"); + if (!lua_istable(L, -1)) { lua_pop(L, 1); return; } + lua_getfield(L, -1, "_bar_btns"); if (lua_istable(L, -1)) { - for (int j = 0;; j++) { - lua_rawgeti(L, -1, j + 1); - if (lua_isnil(L, -1)) { lua_pop(L, 1); break; } - int old_ref = (int)lua_tointeger(L, -1); - luaL_unref(L, LUA_REGISTRYINDEX, old_ref); + for (int j = (int)lua_rawlen(L, -1); j >= 1; j--) { + lua_rawgeti(L, -1, j); + BKNode *btn = (BKNode *)lua_touserdata(L, -1); lua_pop(L, 1); + if (!btn) continue; + if (luabui_lookup_node(L, btn)) { /* invalidate any live wrapper */ + luabui_Node *w = (luabui_Node *)lua_touserdata(L, -1); + if (w && luaL_testudata(L, -1, LUABUI_NODE_MT)) w->node = NULL; + lua_pop(L, 1); + } + luabui_unmap_node(L, btn); + BK_node_remove(ud->node, btn); + BK_node_free(btn); } } - lua_pop(L, 1); /* pop refs array or nil */ - /* Create new refs array */ - lua_newtable(L); - lua_setfield(L, -2, "_menu_refs"); - lua_pop(L, 1); /* pop uservalue */ + lua_pop(L, 2); /* _bar_btns, uservalue */ + lua_getuservalue(L, 1); + if (lua_istable(L, -1)) { + lua_pushnil(L); + lua_setfield(L, -2, "_bar_btns"); + } + lua_pop(L, 1); +} + +/* set_menus(items): menu items on a menubutton / dropdown / enum, or, on a + * menubar, one menubutton child per top-level item — the same composition + * as Python's MenuBar (the kit menubar lays out menubutton children). */ +static int l_node_set_menus(lua_State *L) { + luabui_Node *ud = CHECK_NODE(L, 1); + if (!ud->node) return luaL_error(L, "attempt to use freed node"); BKMenuSpec *specs; - int n = luabui_build_menuspec(L, 2, &specs); - if (specs) { - /* Collect refs from the tree into _menu_refs for cleanup on the next call. - * We walk the built tree rather than re-parsing the Lua table. */ + int n = luabui_build_menuspec(L, 2, &specs); /* specs (+subs) live on the stack */ + + if (strcmp(BK_node_type(ud->node), "menubar") == 0) { + clear_bar_buttons(L, ud); lua_getuservalue(L, 1); - lua_getfield(L, -1, "_menu_refs"); + if (!lua_istable(L, -1)) { + lua_pop(L, 1); + lua_newtable(L); + lua_setuservalue(L, 1); + lua_getuservalue(L, 1); + } + lua_newtable(L); /* fresh _bar_btns */ for (int i = 0; i < n; i++) { - if (specs[i].ref) { - lua_pushinteger(L, (lua_Integer)(intptr_t)specs[i].ref); - lua_rawseti(L, -2, lua_rawlen(L, -2) + 1); - } + BKNode *btn = BK_node_new("menubutton"); + if (!btn) break; + if (specs[i].text) BK_set_str(btn, "text", specs[i].text); + if (specs[i].icon) BK_set_num(btn, "icon", specs[i].icon); + BK_menubutton_set_items(btn, specs[i].sub, specs[i].nsub); + BK_node_add(ud->node, btn, -1); + lua_pushlightuserdata(L, btn); + lua_rawseti(L, -2, i + 1); } - lua_pop(L, 2); /* pop _menu_refs, uservalue */ - + lua_setfield(L, -2, "_bar_btns"); + lua_pop(L, 1); /* uservalue */ + } else { BK_menubutton_set_items(ud->node, specs, n); } return 0; diff --git a/bindings/lua/tests/test_context.lua b/bindings/lua/tests/test_context.lua index b49c287..da7917a 100644 --- a/bindings/lua/tests/test_context.lua +++ b/bindings/lua/tests/test_context.lua @@ -199,7 +199,8 @@ check(clip == "test clipboard" or clip ~= nil, ctx:bind("ctrl+z", function() end) ctx:bind("ctrl+shift+z", function() end) --- Second bind with same combo replaces first (unrefs old) +-- BK_bind appends (as in the Python binding); refs are retained by the kit +-- and released on context free via the binding's ref hooks. ctx:bind("ctrl+z", function() print("new") end) -- ── Timer ──────────────────────────────────────────────────────────── diff --git a/bindings/lua/tests/test_widgets.lua b/bindings/lua/tests/test_widgets.lua index a72b06f..688a0d8 100644 --- a/bindings/lua/tests/test_widgets.lua +++ b/bindings/lua/tests/test_widgets.lua @@ -283,10 +283,117 @@ local mb = bui.Node("menubutton") mb:set_menus({ {text="Open", icon=0, fn=function() end}, {text="Save", icon=1, enabled=1}, - {text="", icon=0}, -- separator + {text="-"}, -- separator {text="Quit", icon=0}, }) +-- Menu item types the Python binding accepts (blanks and booleans, icon +-- names) must not error in Lua either. +local mb2 = bui.Node("menubutton") +mb2:set_menus({ + {text = "Bold", checked = true, icon = "CHECKBOX", enabled = false}, + {text = "Plain", checked = false, enabled = true}, + {text = "Num", checked = 1, enabled = 0}, +}) + +-- Pick through a real press-drag-release (kit_menus.c: press opens, +-- release on a row fires the item's callback). +local hits = {} +local mroot = bui.Node("frame") +local pmb = bui.Node("menubutton") +pmb:set("text", "File") +pmb:set_menus({ + {text = "New", fn = function() hits.new = (hits.new or 0) + 1 end}, + {text = "Off", enabled = false, fn = function() hits.off = (hits.off or 0) + 1 end}, + {text = "-"}, + {text = "Recent", icon = "FILE_FOLDER", sub = { + {text = "a.lua", fn = function() hits.deep = (hits.deep or 0) + 1 end}, + }}, +}) +mroot:add(pmb) +pmb:geom(bui.GEOM_PACK, {side = 0, fill = 1}) +local mctx = bui.Context(mroot) +mctx:render(300, 200, 1) +local mx, my, mw, mh = pmb:rect() +check(mw > 0, "menubutton not laid out") + +local function pick_row(rows, i) + local r = rows[i] + mctx:motion(r.x + 10, r.y + r.h // 2, 0) + mctx:release(r.x + 10, r.y + r.h // 2, 0) +end + +mctx:press(mx + mw // 2, my + mh // 2, 0) +local rows = mctx:menu_rows(-1) +check(rows and #rows == 4, "menu_rows count, got " .. tostring(rows and #rows)) +pick_row(rows, 1) +check(hits.new == 1, "menu item callback did not fire") +check(mctx:popup_count() == 0, "popups not closed after pick") + +-- A disabled item never fires its callback. +mctx:press(mx + mw // 2, my + mh // 2, 0) +rows = mctx:menu_rows(-1) +pick_row(rows, 2) +check(not hits.off, "disabled menu item fired its callback") +mctx:close_popups() + +-- Submenus open from the parent row and their items fire. +mctx:press(mx + mw // 2, my + mh // 2, 0) +rows = mctx:menu_rows(-1) +pick_row(rows, 4) +check(mctx:popup_count() == 2, "submenu did not open") +local srows = mctx:menu_rows(-1) +check(srows and srows[1].text == "a.lua", "submenu rows wrong") +mctx:press(srows[1].x + 10, srows[1].y + srows[1].h // 2, 0) +mctx:release(srows[1].x + 10, srows[1].y + srows[1].h // 2, 0) +check(hits.deep == 1, "submenu callback did not fire") +check(mctx:popup_count() == 0, "popups not closed after submenu pick") + +-- Replacing the items frees the old callbacks and the new ones fire. +pmb:set_menus({{text = "Again", fn = function() hits.again = (hits.again or 0) + 1 end}}) +mctx:press(mx + mw // 2, my + mh // 2, 0) +rows = mctx:menu_rows(-1) +check(rows and #rows == 1, "set_menus replacement row count") +pick_row(rows, 1) +check(hits.again == 1, "replaced menu callback did not fire") + +-- open_menu / open_pie callbacks. +mctx:open_menu(nil, {{text = "Quick", fn = function() hits.quick = (hits.quick or 0) + 1 end}}, + "Quick", 40, 40) +rows = mctx:menu_rows(-1) +check(rows and rows[1].text == "Quick", "open_menu rows") +mctx:press(rows[1].x + 10, rows[1].y + rows[1].h // 2, 0) +mctx:motion(rows[1].x + 10, rows[1].y + rows[1].h // 2, 0) +mctx:release(rows[1].x + 10, rows[1].y + rows[1].h // 2, 0) +check(hits.quick == 1, "open_menu callback did not fire") + +-- MenuBar composition (same as Python's MenuBar): set_menus on a menubar +-- builds one menubutton child per top-level menu. +local bar = bui.Node("menubar") +local broot = bui.Node("frame") +bar:set_menus({ + {text = "File", sub = {{text = "New", fn = function() hits.bar_new = (hits.bar_new or 0) + 1 end}}}, + {text = "Edit", sub = {{text = "Undo"}}}, +}) +broot:add(bar) +bar:geom(bui.GEOM_PACK, {side = 0, fill = 1}) +local bctx = bui.Context(broot) +bctx:render(400, 100, 1) +check(bar:child_count() == 2, "menubar child count, got " .. bar:child_count()) +local file_btn = bar:child(0) +check(file_btn ~= nil and file_btn:type() == "menubutton", "menubar child type") +check(file_btn:get("text") == "File", "menubar child text") +local bx, by, bw, bh = file_btn:rect() +check(bw > 0, "menubar button not laid out") +bctx:press(bx + bw // 2, by + bh // 2, 0) +local brows = bctx:menu_rows(-1) +check(brows and #brows == 1 and brows[1].text == "New", "menubar menu rows") +bctx:motion(brows[1].x + 10, brows[1].y + brows[1].h // 2, 0) +bctx:release(brows[1].x + 10, brows[1].y + brows[1].h // 2, 0) +check(hits.bar_new == 1, "menubar item callback did not fire") +bar:set_menus({{text = "View", sub = {{text = "Zoom"}}}}) +check(bar:child_count() == 1, "menubar rebuild child count") + -- ── Progress / Meter ──────────────────────────────────────────────── local prog = bui.Node("progress") diff --git a/website/src/content/docs/lua/overview.mdx b/website/src/content/docs/lua/overview.mdx index f8b23f0..e723779 100644 --- a/website/src/content/docs/lua/overview.mdx +++ b/website/src/content/docs/lua/overview.mdx @@ -42,6 +42,19 @@ bui.run() `frame`, `column`, `row`, `grid`, `box`, `panel`, `tabs`, `sidetabs`, `scroll`, `label`, `separator`, `progress`, `button`, `toggle`, `checkbox`, `decorator`, `text`, `search`, `hotkey`, `number`, `slider`, `dropdown`, `enum`, `swatch`, `popover`, `menubutton`, `menubar`, `image`, `video`, `textarea`, `custom`, `canvas`, `list`, `tree`, `gridview`, `ramp`, `curve`, `normal`, `histogram`, `radio`, `meter` and `filebrowser`. +## Menus + +Menu items are plain Lua tables, mirroring Python's `MenuItem`: `text`, +`icon` (icon name or id), `shortcut`, `checked` / `enabled` (booleans or +numbers), `fn` (the callback) and `sub` for nested menus. `set_menus` fills a +`menubutton`; on a `menubar` it builds one `menubutton` child per top-level +item, exactly like Python's `MenuBar`. `ctx:open_menu(owner, items, title, x, +y)` and `ctx:open_pie(items, title)` open popups from code. `fn` callbacks are +registry refs retained by the kit for as long as any copy of their item lives +(menu button, open popup, search filter) and released automatically when the +items are replaced or the node/context is freed. See `examples/menus.lua` and +`examples/menubar.lua`. + ## Examples Nine scripts in [`bindings/lua/examples`](https://github.com/ppmpreetham/BUI/tree/main/bindings/lua/examples): `hello.lua`, `counter.lua`, `todos.lua`, `gallery.lua`, `forms.lua`, `dashboard.lua`, `anim.lua`, `lua_table_editor.lua` and `editor_screenshot.lua`. From 7824549e96dfadaf94d2fa8e035504a02ab48bbc Mon Sep 17 00:00:00 2001 From: seclorum Date: Sun, 8 Nov 2026 17:58:39 +0100 Subject: [PATCH 2/7] lua: README.md - first app instructions --- README.md | 188 +++++++++++++++++++++++++++++++++++++----------------- 1 file changed, 128 insertions(+), 60 deletions(-) diff --git a/README.md b/README.md index de2fdbd..523e339 100644 --- a/README.md +++ b/README.md @@ -71,51 +71,52 @@ more to say. 12. [First app: JavaScript and React](#12-first-app-javascript-and-react) 13. [First app: Dear ImGui](#13-first-app-dear-imgui) 14. [First app: C](#14-first-app-c) +15. [First app: Lua](#15-first-app-lua) **[Part III: Concepts](#part-iii-concepts)** -15. [How it works](#15-how-it-works) -16. [Blender's widgets and interactions](#16-blenders-widgets-and-interactions) -17. [Three looks: HTML, styled and Blender components](#17-three-looks-html-styled-and-blender-components) -18. [Rendering on the CPU and the GPU](#18-rendering-on-the-cpu-and-the-gpu) -19. [Damage rendering](#19-damage-rendering) -20. [Window hosts](#20-window-hosts) -21. [Text and fonts](#21-text-and-fonts) -22. [Themes: dark and light](#22-themes-dark-and-light) +16. [How it works](#16-how-it-works) +17. [Blender's widgets and interactions](#17-blenders-widgets-and-interactions) +18. [Three looks: HTML, styled and Blender components](#18-three-looks-html-styled-and-blender-components) +19. [Rendering on the CPU and the GPU](#19-rendering-on-the-cpu-and-the-gpu) +20. [Damage rendering](#20-damage-rendering) +21. [Window hosts](#21-window-hosts) +22. [Text and fonts](#22-text-and-fonts) +23. [Themes: dark and light](#23-themes-dark-and-light) **[Part IV: Guides by language](#part-iv-guides-by-language)** -23. [Python](#23-python) -24. [Rust](#24-rust) -25. [JavaScript and React](#25-javascript-and-react) -26. [Dear ImGui](#26-dear-imgui) -27. [C and embedding](#27-c-and-embedding) -28. [Lua](#28-lua) -29. [Where the docs live](#29-where-the-docs-live) +24. [Python](#24-python) +25. [Rust](#25-rust) +26. [JavaScript and React](#26-javascript-and-react) +27. [Dear ImGui](#27-dear-imgui) +28. [C and embedding](#28-c-and-embedding) +29. [Lua](#29-lua) +30. [Where the docs live](#30-where-the-docs-live) **[Part V: Performance](#part-v-performance)** -30. [Before you read the numbers](#30-before-you-read-the-numbers) -31. [Start time and battery](#31-start-time-and-battery) -32. [Frame times](#32-frame-times) -33. [JavaScript and React: startup and animation](#33-javascript-and-react-startup-and-animation) -34. [Run the benchmarks yourself](#34-run-the-benchmarks-yourself) +31. [Before you read the numbers](#31-before-you-read-the-numbers) +32. [Start time and battery](#32-start-time-and-battery) +33. [Frame times](#33-frame-times) +34. [JavaScript and React: startup and animation](#34-javascript-and-react-startup-and-animation) +35. [Run the benchmarks yourself](#35-run-the-benchmarks-yourself) **[Part VI: Reference](#part-vi-reference)** -35. [Platforms](#35-platforms) -36. [Environment variables](#36-environment-variables) -37. [Building from source and CMake options](#37-building-from-source-and-cmake-options) -38. [Coverage](#38-coverage) -39. [Repository layout](#39-repository-layout) -40. [Examples](#40-examples) +36. [Platforms](#36-platforms) +37. [Environment variables](#37-environment-variables) +38. [Building from source and CMake options](#38-building-from-source-and-cmake-options) +39. [Coverage](#39-coverage) +40. [Repository layout](#40-repository-layout) +41. [Examples](#41-examples) **[Part VII: The project](#part-vii-the-project)** -41. [Status and roadmap](#41-status-and-roadmap) -42. [FAQ](#42-faq) -43. [Contributing and publishing](#43-contributing-and-publishing) -44. [License and credits](#44-license-and-credits) +42. [Status and roadmap](#42-status-and-roadmap) +43. [FAQ](#43-faq) +44. [Contributing and publishing](#44-contributing-and-publishing) +45. [License and credits](#45-license-and-credits) --- @@ -935,11 +936,78 @@ screenshots work. Guide: [docs/c-api.md](docs/c-api.md). +
+

15. First app: Lua

+ +The `bui-lua` host embeds Lua 5.4 and exposes the whole kit as a native +module — every widget, every event. This is a shortened +[`bindings/lua/examples/counter.lua`](bindings/lua/examples/counter.lua): a +label and buttons in a native window. + +```lua +local bui = require("bui") + +local root = bui.Node("frame") +root:set("padding", 12) + +local label = bui.Node("label") +label:set("text", "Count: 0") +root:add(label) +label:geom(bui.GEOM_PACK, {side = 0, fill = 1}) + +local count = 0 + +local inc = bui.Node("button") +inc:set("text", "Increment") +inc:set("icon", bui.icon_id("ADD")) +inc:on(bui.EV_CLICK, function() + count = count + 1 + label:set("text", "Count: " .. count) +end) +root:add(inc) +inc:geom(bui.GEOM_PACK, {fill = 1, pady0 = 4}) + +local ctx = bui.Context(root) +local win = bui.Window(ctx, "Counter", 240, 200) +if win then + win:show() + bui.run() +else + local px, _, w, h = ctx:render(240, 200, 1) + print("No window system. Headless render: " .. w .. "x" .. h .. " " .. #px .. " bytes") +end +``` + +```sh +cmake -S . -B build -DBUI_BUILD_LUA_BINDINGS=ON +cmake --build build --target bui-lua +./build/bindings/lua/bui-lua bindings/lua/examples/counter.lua +``` + +The window below is a bigger program than the one above, an interactive Lua +table editor with a tree view, search, undo/redo and export: + +

+ + + + The interactive Lua table editor: tree view with three columns, toolbar with demo selector, search, add/delete/export/undo/redo buttons, collapsible editor panel + +
A bigger Lua program: the interactive table editor (bindings/lua/examples/lua_table_editor.lua) +

+ +Without a window system the same script renders headless (`bui-lua --headless +app.lua`), which is also how the Lua tests and documentation screenshots +work; all 26 examples run this way. Guide: +[bindings/lua/README.md](bindings/lua/README.md). + +
+ --- # Part III: Concepts -## 15. How it works +## 16. How it works ``` your code: tkinter Qt components Dear ImGui (C++/C/Python/Rust) Rust: rsx! / HTML / CSS / Tailwind / hooks JS / React @@ -981,7 +1049,7 @@ What's under the hood: More: [ARCHITECTURE.md](ARCHITECTURE.md) and [How it works](https://bui.preetham.top/docs/concepts/how-it-works/). -## 16. Blender's widgets and interactions +## 17. Blender's widgets and interactions These rules live in the C kit, so they work the same everywhere Blender widgets are drawn: the Python components, tkinter and Qt syntax, the Rust and @@ -1023,7 +1091,7 @@ Coming soon: typing values with units ("2 m"), Blender's search-as-you-type popups, and widget drag and drop. More: [Blender's widgets](https://bui.preetham.top/docs/concepts/blender-widgets/). -## 17. Three looks: HTML, styled and Blender components +## 18. Three looks: HTML, styled and Blender components In Rust and JavaScript you pick the look per element: @@ -1086,7 +1154,7 @@ import { render, NumberSlider, Button } from "@blender-ui/react" Python's [Blender components](docs/components.md) (`Panel`, `PieMenu`, the node editor ...) are a separate, larger set. -## 18. Rendering on the CPU and the GPU +## 19. Rendering on the CPU and the GPU BUI has two renderers, and they draw **the same pixels**. @@ -1134,7 +1202,7 @@ RAM plus GPU memory). Servers and tests: the CPU. macOS: the CPU, for now More: [Rendering](https://bui.preetham.top/docs/concepts/rendering/). -## 19. Damage rendering +## 20. Damage rendering Leave a BUI window open and walk away: it uses no CPU at all. Hover a button, and BUI redraws that button, not the window. @@ -1156,7 +1224,7 @@ about **0.05 ms** for a hover or number-scrub frame. Animations run only while something moves, paced to the display. More: [Damage rendering](https://bui.preetham.top/docs/concepts/damage-rendering/). -## 20. Window hosts +## 21. Window hosts A **window host** puts a kit context in a top-level window and feeds it input. @@ -1173,7 +1241,7 @@ A **window host** puts a kit context in a top-level window and feeds it input. Blender's default, 20 px widgets). More: [Window hosts](https://bui.preetham.top/docs/concepts/window-hosts/). -## 21. Text and fonts +## 22. Text and fonts Your app's text looks the same on Windows, Linux and macOS, and it looks like Blender's: @@ -1200,7 +1268,7 @@ as boxes), IME on Windows only (X11 and macOS are coming), right-to-left text is reordered but not shaped, no OpenType features. Font fallback is coming soon. More: [Text and fonts](https://bui.preetham.top/docs/concepts/text-and-fonts/). -## 22. Themes: dark and light +## 23. Themes: dark and light BUI looks like Blender because it uses Blender's theme. It ships two: Blender's **Dark** theme (the default) and its **Light** preset. Both are @@ -1235,7 +1303,7 @@ Coming soon: loading any Blender theme file, beyond Dark and Light. More: # Part IV: Guides by language
-

23. Python

+

24. Python

`pip install blenderui` gives you four APIs in one package: @@ -1266,7 +1334,7 @@ On the website: [tkinter](https://bui.preetham.top/docs/python/tkinter/),
-

24. Rust

+

25. Rust

The `blenderui` crate is a web-like framework drawn by BUI: every HTML element, CSS, Tailwind, HTML-like markup (`rsx!` / `view!`) or gpui-style builders, @@ -1326,7 +1394,7 @@ transforms, OpenType features, multi-column layout. Guide:
-

25. JavaScript and React

+

26. JavaScript and React

- **`@blender-ui/react`**: a React 19.3 renderer. HTML host components, CSS and Tailwind, events, Suspense, transitions, actions, and the Blender components. @@ -1360,7 +1428,7 @@ JavaScript after BUI handled them (no native `preventDefault`), and
-

26. Dear ImGui

+

27. Dear ImGui

| Language | What you use | Built by | | -------- | ------------------------------------------ | --------------------------------------- | @@ -1386,7 +1454,7 @@ Blender style), [bindings/imgui/blender/README.md](bindings/imgui/blender/README
-

27. C and embedding

+

28. C and embedding

The kit's concepts: **nodes** made by type name (`frame`, `slider`, `button`, `panel`, `tree`, `canvas`, `filebrowser` ...), **properties** set by name, @@ -1404,7 +1472,7 @@ SVG recorder use it. Guide: [INTEGRATION.md](INTEGRATION.md).
-

28. Lua

+

29. Lua

A standalone executable and a loadable module that embeds Lua 5.4 and exposes the full BUI kit API — every widget, every event. Use the `bui-lua` binary or drop @@ -1461,7 +1529,7 @@ All 26 run headless (`bui-lua --headless app.lua`) for CI and documentation scre --- -## 29. Where the docs live +## 30. Where the docs live | Guide | What's inside | | -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | @@ -1486,7 +1554,7 @@ All 26 run headless (`bui-lua --headless app.lua`) for CI and documentation scre # Part V: Performance -## 30. Before you read the numbers +## 31. Before you read the numbers These numbers were measured on a Ryzen 7 5800U laptop (Windows 11, on AC power) **before the latest features landed**: Dear ImGui, the Linux GPU @@ -1494,7 +1562,7 @@ renderer, the light theme, and the Blender search and expression code. They show the shape of things, not a promise for your machine. Rerun them with the tools in [Run the benchmarks yourself](#34-run-the-benchmarks-yourself). -## 31. Start time and battery +## 32. Start time and battery The same small settings window (a label, text field, checkbox, slider and button) written each of the five ways, from [`bench/apps`](bench/apps), on @@ -1539,7 +1607,7 @@ uses more memory (the GPU device), and pays off on large, animated scenes (below). Node's event loop accounts for the JavaScript idle figure, Python's times include starting the interpreter, and memory doesn't count GPU memory. -## 32. Frame times +## 33. Frame times Python and C, on the full component demo (520×900): @@ -1593,7 +1661,7 @@ gpui-fast's heavy-scene frame rate varies between sessions on this laptop [bindings/rust/bench/README.md](bindings/rust/bench/README.md).
-## 33. JavaScript and React: startup and animation +## 34. JavaScript and React: startup and animation **Startup.** These launch a small React app (the counter above) that quits once its first frame is on screen. Each figure is the median of 12 launches after a @@ -1687,7 +1755,7 @@ What this means: Method, the memory breakdown and how to rerun: [docs/javascript.md](docs/javascript.md#performance). -## 34. Run the benchmarks yourself +## 35. Run the benchmarks yourself Every number above comes from a tool in this repo. Build in release mode, close other heavy apps, and plug the laptop in. Run one benchmark (and one build) at @@ -1760,7 +1828,7 @@ py bindings/python/bench/tk_canvas_bench.py 3000 one # move one # Part VI: Reference -## 35. Platforms +## 36. Platforms | | Windows | Linux | macOS | | ---------------------------- | -------------------------------- | -------------------------------------------------------- | ---------------------- | @@ -1800,7 +1868,7 @@ and on an NVIDIA GPU. | `@blender-ui/native`, `@blender-ui/react`, `@blender-ui/svelte` on npm | Windows x64 | Node.js 18+ (22.15+ for Svelte) or Bun. Elsewhere, build the addon from source. | | C and C++ (CMake) | Windows, Linux, macOS | CMake 3.20+, a C11 and C++20 compiler. | -## 36. Environment variables +## 37. Environment variables Most are read by the C library, so they work the same from every language. On/off variables look at the first character: `1`, `y`, `Y`, `t` or `T` is on. @@ -1833,7 +1901,7 @@ Every variable, with defaults and where it's read: [Environment variables](https://bui.preetham.top/docs/reference/environment-variables/) and [docs/getting-started.md](docs/getting-started.md#environment-variables). -## 37. Building from source and CMake options +## 38. Building from source and CMake options You need **CMake 3.20+**, a **C11 / C++20 compiler** (MSVC, GCC/MinGW or Clang) and **Python 3.11+** with headers. On Linux, also the X11 headers. @@ -1869,7 +1937,7 @@ Rebuild the docs screenshots with `python tools/gen_doc_images.py` (and the website's dark and light shots with `--site`). More: [CMake options](https://bui.preetham.top/docs/reference/cmake-options/). -## 38. Coverage +## 39. Coverage | API | Coverage | Status | | --------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | -------- | @@ -1888,7 +1956,7 @@ How each was counted: [Coverage](https://bui.preetham.top/docs/reference/coverag [SYNTAX_COVERAGE.md](SYNTAX_COVERAGE.md), [docs/rust.md](docs/rust.md#coverage), [docs/imgui.md](docs/imgui.md#coverage). -## 39. Repository layout +## 40. Repository layout | Path | Contents | | ------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | @@ -1906,7 +1974,7 @@ How each was counted: [Coverage](https://bui.preetham.top/docs/reference/coverag | `examples/`, `tools/` | Demos, the website's screenshot programs (`examples/site/`), the icon and theme generators, the docs screenshot generator | | `website/` | The documentation website, [bui.preetham.top](https://bui.preetham.top) | -## 40. Examples +## 41. Examples | File | Shows | | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | @@ -1935,7 +2003,7 @@ How each was counted: [Coverage](https://bui.preetham.top/docs/reference/coverag # Part VII: The project -## 41. Status and roadmap +## 42. Status and roadmap **Known limitations.** Read these before you pick BUI for something you will ship. The tables below have the details. @@ -2022,7 +2090,7 @@ ship. The tables below have the details. Our roadmap has no dates on it. Like a good render, it's done when the noise goes away. -## 42. FAQ +## 43. FAQ
Do I need Blender installed? @@ -2166,7 +2234,7 @@ ship. (This is a summary, not legal advice.) -## 43. Contributing and publishing +## 44. Contributing and publishing Contributions are welcome: bug reports, missing tkinter/Qt behavior, CSS features, platform hosts and docs. If you have a Mac, running the Cocoa host @@ -2179,7 +2247,7 @@ Releases go to PyPI (`blenderui`, wheels built by CI on a `v*` tag), crates.io (the eight Rust crates, in dependency order) and npm (`@blender-ui/native`, `@blender-ui/react`). The steps are in [docs/publishing.md](docs/publishing.md). -## 44. License and credits +## 45. License and credits **License.** BUI follows Blender's licensing. BUI as a whole is licensed under the **GNU General Public License, version 3 or later** (the text is in From 9bba3ec2d8f6f081a557081c7ff8e040925f2a5a Mon Sep 17 00:00:00 2001 From: seclorum Date: Sun, 8 Nov 2026 19:26:39 +0100 Subject: [PATCH 3/7] lua: lua_table_editor fix: copy/serialize, unify tree builders, fix apply_edit / editor split, fix siwtch_demo deepcopy, add_Row arrays, refresh_tree actions route, cosmetics, and headless run + ctest -R buil_lua --- bindings/lua/examples/lua_table_editor.lua | 625 +++++++++++---------- 1 file changed, 317 insertions(+), 308 deletions(-) diff --git a/bindings/lua/examples/lua_table_editor.lua b/bindings/lua/examples/lua_table_editor.lua index e22083c..bc71943 100644 --- a/bindings/lua/examples/lua_table_editor.lua +++ b/bindings/lua/examples/lua_table_editor.lua @@ -29,10 +29,22 @@ local function count_items(t) return n end -local function table_deepcopy(t) +local function count_all(t) + local n = 0 + for _, v in pairs(t) do + n = n + 1 + if type(v) == "table" then n = n + count_all(v) end + end + return n +end + +local function table_deepcopy(t, seen) if type(t) ~= "table" then return t end + seen = seen or {} + if seen[t] then return seen[t] end local c = {} - for k, v in pairs(t) do c[table_deepcopy(k)] = table_deepcopy(v) end + seen[t] = c + for k, v in pairs(t) do c[table_deepcopy(k, seen)] = table_deepcopy(v, seen) end return c end @@ -69,6 +81,19 @@ local function fmt_val(v) else return "(" .. t .. ")" end end +local TYPE_NAMES = {"string", "number", "boolean", "table", "array", "nil"} + +local function type_index(tn) + for i, t in ipairs(TYPE_NAMES) do + if t == tn then return i - 1 end + end + return nil +end + +local function is_scalar_type(tn) + return tn == "string" or tn == "number" or tn == "boolean" +end + -- ============================================================ -- DEMO TABLES -- ============================================================ @@ -173,70 +198,125 @@ local function push_undo() redo_stack = {} end +-- ============================================================ +-- UI STATE +-- ============================================================ + +local tree_node = nil +local editor_key = nil +local editor_type = nil +local editor_val_str = nil +local editor_val_num = nil +local editor_bool = nil +local val_label = nil +local apply_btn = nil +local status_node = nil +local current_demo = "" +local edit_id = nil +local type_dirty = false +local undo_btn = nil +local redo_btn = nil +local search_node = nil +local key_num_toggle = nil + -- ============================================================ -- TREE BUILDING -- ============================================================ -local function build_subtree(parent_item, t, parent_data) - for k, v in sorted_pairs(t) do - local key_str = tostring(k) - local vt = lua_type_name(v) - local item_icon = (vt == "table" or vt == "array") and bui.icon_id("OUTLINER_COLLECTION") or bui.icon_id("FILE") - local item = bui.TreeItem(key_str, item_icon) - item:set_values({key_str, vt, fmt_val(v)}) - local id = next_id() - item:set_ref(id) - item_meta[id] = { - type = vt, - key = k, - value = v, - parent = parent_data, - id = id, - item = item, - } +local function make_item(k, v, parent_data) + local key_str = tostring(k) + local vt = lua_type_name(v) + local item_icon = (vt == "table" or vt == "array") and bui.icon_id("OUTLINER_COLLECTION") or bui.icon_id("FILE") + local item = bui.TreeItem(key_str, item_icon) + item:set_values({key_str, vt, fmt_val(v)}) + local id = next_id() + item:set_ref(id) + item_meta[id] = { + type = vt, + key = k, + value = v, + parent = parent_data, + id = id, + item = item, + } + return item +end + +local function build_children(item, tbl) + for k, v in sorted_pairs(tbl) do + local child = make_item(k, v, tbl) if type(v) == "table" then - build_subtree(item, v, v) + build_children(child, v) end - if parent_item then - parent_item:add(item) + item:add(child) + end +end + +local function key_matches(k, q) + return tostring(k):lower():find(q, 1, true) ~= nil +end + +local function scalar_matches(v, q) + return fmt_val(v):lower():find(q, 1, true) ~= nil +end + +local function subtree_matches(t, q) + for k, v in pairs(t) do + if key_matches(k, q) then return true end + if type(v) == "table" then + if subtree_matches(v, q) then return true end + elseif scalar_matches(v, q) then + return true end end + return false end -local function rebuild_tree(tree, source, meta_clean) - if meta_clean then item_meta = {} end - id_counter = 1000 +-- Single builder for filtered and unfiltered trees. q == nil builds +-- everything; otherwise a node is kept when its own key or scalar value +-- matches (whole subtree kept) or when any descendant matches (only +-- matching branches kept). meta.parent always points at the live table. +local function collect_items(parent_item, tbl, parent_data, q) local items = {} - for k, v in sorted_pairs(source) do - local key_str = tostring(k) - local vt = lua_type_name(v) - local item_icon = (vt == "table" or vt == "array") and bui.icon_id("OUTLINER_COLLECTION") or bui.icon_id("FILE") - local item = bui.TreeItem(key_str, item_icon) - item:set_values({key_str, vt, fmt_val(v)}) - local id = next_id() - item:set_ref(id) - item_meta[id] = { - type = vt, - key = k, - value = v, - parent = source, - id = id, - item = item, - } + for k, v in sorted_pairs(tbl) do + local own = true + if q then + own = key_matches(k, q) or (type(v) ~= "table" and scalar_matches(v, q)) + if not own and not (type(v) == "table" and subtree_matches(v, q)) then + goto continue + end + end + local item = make_item(k, v, parent_data) if type(v) == "table" then - build_subtree(item, v, v) + if q == nil or own then + build_children(item, v) + else + collect_items(item, v, v, q) + end end - items[#items + 1] = item + if parent_item then parent_item:add(item) else items[#items + 1] = item end + ::continue:: end - tree:set_roots(items) - tree:relayout() + return items +end + +local function refresh_tree() + if not tree_node or not data then return 0 end + item_meta = {} + id_counter = 1000 + local query = search_node and search_node:get("text") or "" + local q = query ~= "" and query:lower() or nil + local items = collect_items(nil, data, data, q) + tree_node:set_roots(items) + tree_node:relayout() + return #items end -- ============================================================ -- SERIALIZATION -- ============================================================ -local function serialize(v, indent) +local function serialize(v, indent, seen) indent = indent or "" local t = type(v) if t == "string" then return string.format("%q", v) @@ -247,6 +327,10 @@ local function serialize(v, indent) elseif t == "nil" then return "nil" elseif t ~= "table" then return tostring(v) end + seen = seen or {} + if seen[v] then return "{...}" end + seen[v] = true + local n = count_items(v) if n == 0 then return "{}" end local is_arr = true @@ -257,15 +341,16 @@ local function serialize(v, indent) for k, val in sorted_pairs(v) do local line if is_arr then - line = serialize(val, sub) + line = serialize(val, sub, seen) elseif type(k) == "string" and k:match("^[%a_][%w_]*$") then - line = k .. " = " .. serialize(val, sub) + line = k .. " = " .. serialize(val, sub, seen) else local ks = type(k) == "string" and string.format("[%q]", k) or "[" .. tostring(k) .. "]" - line = ks .. " = " .. serialize(val, sub) + line = ks .. " = " .. serialize(val, sub, seen) end parts[#parts + 1] = line end + seen[v] = nil local body if #parts <= 3 and #table.concat(parts, ", ") < 60 then body = "{ " .. table.concat(parts, ", ") .. " }" @@ -275,26 +360,6 @@ local function serialize(v, indent) return body end --- ============================================================ --- UI STATE --- ============================================================ - -local tree_node = nil -local editor_key = nil -local editor_type = nil -local editor_val_str = nil -local editor_val_num = nil -local editor_bool = nil -local val_label = nil -local status_node = nil -local demo_selector = nil -local current_demo = "" -local edit_id = nil -local undo_btn = nil -local redo_btn = nil -local search_node = nil -local key_num_toggle = nil - -- ============================================================ -- EDITOR ACTIONS -- ============================================================ @@ -303,91 +368,87 @@ local function update_status(text) if status_node then status_node:set("text", text) end end -local function refresh_editor() - if not tree_node then return end - local sel = tree_node:selected() - if not sel then - edit_id = nil - if editor_key then editor_key:set("value", "") end - if editor_type then editor_type:set("value", 0) end - if editor_val_str then - editor_val_str:set("value", "") - editor_val_str:set("enabled", 0) - end - if editor_val_num then - editor_val_num:set("value", 0) - editor_val_num:set("enabled", 0) - end - if editor_bool then - editor_bool:set("value", 0) - editor_bool:set("enabled", 0) - end - if val_label then val_label:set("text", "Value:") end - return - end +local function refresh_undo_buttons() + if undo_btn then undo_btn:set("enabled", #undo_stack > 0 and 1 or 0) end + if redo_btn then redo_btn:set("enabled", #redo_stack > 0 and 1 or 0) end +end - local id = sel:ref() - local meta = item_meta[id] - if not meta then edit_id = nil; return end - edit_id = id +local function selected_type_name() + return TYPE_NAMES[(editor_type:get("value") or 0) + 1] or "string" +end - if editor_key then editor_key:set("value", tostring(meta.key)) end +local function apply_is_allowed() + if not edit_id then return false end + local meta = item_meta[edit_id] + if not meta then return false end + if not type_dirty then return is_scalar_type(meta.type) end + return selected_type_name() ~= meta.type +end - local type_names = {"string", "number", "boolean", "table", "array", "nil"} - local type_idx = 0 - for i, tn in ipairs(type_names) do - if tn == meta.type then type_idx = i - 1; break end +local function update_value_widgets(tn) + if editor_val_str then editor_val_str:set("enabled", tn == "string" and 1 or 0) end + if editor_val_num then editor_val_num:set("enabled", tn == "number" and 1 or 0) end + if editor_bool then editor_bool:set("enabled", tn == "boolean" and 1 or 0) end + if val_label then val_label:set("text", is_scalar_type(tn) and "Value:" or "(read-only)") end + if apply_btn then apply_btn:set("enabled", apply_is_allowed() and 1 or 0) end +end + +local function clear_editor() + edit_id = nil + type_dirty = false + if editor_key then editor_key:set("value", "") end + if editor_type then editor_type:set("value", 0) end + if editor_val_str then + editor_val_str:set("value", "") + editor_val_str:set("enabled", 0) end - if editor_type then editor_type:set("value", type_idx) end - - if meta.type == "boolean" then - if val_label then val_label:set("text", "Value:") end - if editor_val_str then editor_val_str:set("enabled", 0) end - if editor_val_num then editor_val_num:set("enabled", 0) end - if editor_bool then - editor_bool:set("value", meta.value and 1 or 0) - editor_bool:set("enabled", 1) - end - elseif meta.type == "string" then - if val_label then val_label:set("text", "Value:") end - if editor_val_str then - editor_val_str:set("value", meta.value or "") - editor_val_str:set("enabled", 1) - end - if editor_val_num then editor_val_num:set("enabled", 0) end - if editor_bool then editor_bool:set("enabled", 0) end - elseif meta.type == "number" then - if val_label then val_label:set("text", "Value:") end - if editor_val_str then editor_val_str:set("enabled", 0) end - if editor_val_num then - editor_val_num:set("value", meta.value or 0) - editor_val_num:set("enabled", 1) - end - if editor_bool then editor_bool:set("enabled", 0) end - else - if val_label then val_label:set("text", "(read-only)") end - if editor_val_str then - editor_val_str:set("value", "") - editor_val_str:set("enabled", 0) - end - if editor_val_num then editor_val_num:set("enabled", 0) end - if editor_bool then editor_bool:set("enabled", 0) end + if editor_val_num then + editor_val_num:set("value", 0) + editor_val_num:set("enabled", 0) + end + if editor_bool then + editor_bool:set("value", 0) + editor_bool:set("enabled", 0) end + if val_label then val_label:set("text", "Value:") end + if apply_btn then apply_btn:set("enabled", 0) end +end + +local function refresh_editor() + if not tree_node then return end + local sel = tree_node:selected() + if not sel then clear_editor(); return end + local meta = item_meta[sel:ref()] + if not meta then clear_editor(); return end + edit_id = meta.id + type_dirty = false + + if editor_key then editor_key:set("value", tostring(meta.key)) end + local ti = type_index(meta.type) + if editor_type then editor_type:set("value", ti or 0) end + if meta.type == "string" and editor_val_str then editor_val_str:set("value", meta.value or "") end + if meta.type == "number" and editor_val_num then editor_val_num:set("value", meta.value or 0) end + if meta.type == "boolean" and editor_bool then editor_bool:set("value", meta.value and 1 or 0) end + update_value_widgets(meta.type) end local function apply_edit() if not edit_id then return end + if not apply_is_allowed() then + local meta = item_meta[edit_id] + if meta then update_status("Read-only: pick a new type to replace this entry") end + return + end local meta = item_meta[edit_id] - if not meta then return end - local parent = meta.parent local old_key = meta.key + local new_key_str = editor_key:get("text") if not new_key_str or new_key_str == "" then return end - local type_idx = editor_type:get("value") - local type_names = {"string", "number", "boolean", "table", "array", "nil"} - local new_type = type_names[type_idx + 1] or "string" + local new_type = selected_type_name() + local type_changed = type_dirty and new_type ~= meta.type + local scalar_meta = is_scalar_type(meta.type) local new_val if new_type == "string" then @@ -398,7 +459,7 @@ local function apply_edit() new_val = editor_bool:get("value") > 0 elseif new_type == "table" or new_type == "array" then new_val = {} - elseif new_type == "nil" then + else new_val = nil end @@ -413,31 +474,32 @@ local function apply_edit() end end - push_undo() + local key_changed = (new_key ~= old_key) + if key_changed and parent[new_key] ~= nil then + update_status("Duplicate key: " .. tostring(new_key)) + return + end - local is_scalar = (new_type == "string" or new_type == "number" or new_type == "boolean") - local type_changed = meta.type ~= new_type - local key_changed = (old_key ~= new_key) - local is_simple = is_scalar and not type_changed + push_undo() - if is_simple and not key_changed then + if scalar_meta and not type_changed and not key_changed then + parent[old_key] = new_val meta.value = new_val local sel = tree_node:selected() if sel then sel:set_values({tostring(new_key), new_type, fmt_val(new_val)}) end else - if old_key ~= new_key then + if key_changed then parent[old_key] = nil end parent[new_key] = new_val - rebuild_tree(tree_node, data) + edit_id = nil + refresh_tree() end - edit_id = nil - update_status("Applied. Entries: " .. count_items(data)) - if redo_btn then redo_btn:set("enabled", #redo_stack > 0 and 1 or 0) end - if undo_btn then undo_btn:set("enabled", #undo_stack > 0 and 1 or 0) end + update_status("Applied. Entries: " .. count_all(data)) + refresh_undo_buttons() end local function delete_selected() @@ -446,55 +508,54 @@ local function delete_selected() if not meta then return end push_undo() meta.parent[meta.key] = nil - rebuild_tree(tree_node, data) - edit_id = nil - update_status("Deleted. Entries: " .. count_items(data)) - if redo_btn then redo_btn:set("enabled", #redo_stack > 0 and 1 or 0) end - if undo_btn then undo_btn:set("enabled", #undo_stack > 0 and 1 or 0) end + refresh_tree() + clear_editor() + update_status("Deleted. Entries: " .. count_all(data)) + refresh_undo_buttons() end local function add_row() - local target = data + local target, is_arr = data, false if edit_id then local meta = item_meta[edit_id] - if meta and (meta.type == "table" or meta.type == "array") then + if meta and meta.type == "array" then + target, is_arr = meta.value, true + elseif meta and meta.type == "table" then target = meta.value end end push_undo() - local base = "new" - local i = 1 - while target[base .. i] ~= nil do i = i + 1 end - target[base .. i] = "" - rebuild_tree(tree_node, data) - edit_id = nil - update_status("Added entry. Entries: " .. count_items(data)) - if redo_btn then redo_btn:set("enabled", #redo_stack > 0 and 1 or 0) end - if undo_btn then undo_btn:set("enabled", #undo_stack > 0 and 1 or 0) end + if is_arr then + target[#target + 1] = "" + else + local i = 1 + while target["new" .. i] ~= nil do i = i + 1 end + target["new" .. i] = "" + end + refresh_tree() + clear_editor() + update_status("Added entry. Entries: " .. count_all(data)) + refresh_undo_buttons() end local function undo_action() if #undo_stack == 0 then return end table.insert(redo_stack, table_deepcopy(data)) data = table.remove(undo_stack) - rebuild_tree(tree_node, data) - edit_id = nil - if editor_key then editor_key:set("value", "") end - if redo_btn then redo_btn:set("enabled", 1) end - if undo_btn then undo_btn:set("enabled", #undo_stack > 0 and 1 or 0) end - update_status("Undo. Entries: " .. count_items(data)) + refresh_tree() + clear_editor() + refresh_undo_buttons() + update_status("Undo. Entries: " .. count_all(data)) end local function redo_action() if #redo_stack == 0 then return end table.insert(undo_stack, table_deepcopy(data)) data = table.remove(redo_stack) - rebuild_tree(tree_node, data) - edit_id = nil - if editor_key then editor_key:set("value", "") end - if undo_btn then undo_btn:set("enabled", 1) end - if redo_btn then redo_btn:set("enabled", #redo_stack > 0 and 1 or 0) end - update_status("Redo. Entries: " .. count_items(data)) + refresh_tree() + clear_editor() + refresh_undo_buttons() + update_status("Redo. Entries: " .. count_all(data)) end local function export_table() @@ -539,71 +600,28 @@ local function export_to_file_via_browser() end end -local function search_filter() +local function on_search() if not tree_node or not search_node then return end - local query = search_node:get("value") or "" + local query = search_node:get("text") or "" + local n = refresh_tree() if query == "" then - rebuild_tree(tree_node, data) - return - end - local q = query:lower() - local function matches(key, val) - return tostring(key):lower():find(q, 1, true) or - fmt_val(val):lower():find(q, 1, true) - end - local function should_include(t, path) - for k, v in pairs(t) do - if matches(k, v) then return true end - if type(v) == "table" and should_include(v, path) then return true end - end - return false - end - id_counter = 1000 - item_meta = {} - local items = {} - for k, v in sorted_pairs(data) do - if matches(k, v) or (type(v) == "table" and should_include(v, {})) then - local key_str = tostring(k) - local vt = lua_type_name(v) - local item_icon = (vt == "table" or vt == "array") and bui.icon_id("OUTLINER_COLLECTION") or bui.icon_id("FILE") - local item = bui.TreeItem(key_str, item_icon) - item:set_values({key_str, vt, fmt_val(v)}) - local id = next_id() - item:set_ref(id) - item_meta[id] = { - type = vt, - key = k, - value = v, - parent = data, - id = id, - item = item, - } - if type(v) == "table" then - build_subtree(item, v, v) - end - items[#items + 1] = item - end + update_status("Loaded: " .. current_demo .. " | Entries: " .. count_all(data)) + else + update_status("Search: " .. n .. " top-level matches | Entries: " .. count_all(data)) end - tree_node:set_roots(items) - tree_node:relayout() - update_status("Search: " .. #items .. " matches | Entries: " .. count_items(data)) end local function switch_demo(name, t) current_demo = name - data = t - item_meta = {} + data = table_deepcopy(t) undo_stack = {} redo_stack = {} - rebuild_tree(tree_node, data) + refresh_tree() + clear_editor() if undo_btn then undo_btn:set("enabled", 0) end if redo_btn then redo_btn:set("enabled", 0) end if search_node then search_node:set("value", "") end - update_status("Loaded: " .. name .. " | Entries: " .. count_items(data)) - edit_id = nil - if editor_key then editor_key:set("value", "") end - if editor_val_str then editor_val_str:set("value", "") end - if editor_val_num then editor_val_num:set("value", 0) end + update_status("Loaded: " .. name .. " | Entries: " .. count_all(data)) end -- ============================================================ @@ -638,66 +656,64 @@ local function build_ui() local search = bui.Node("text") search:set("placeholder", "Search...") - search:on(bui.EV_CHANGE, function() - search_filter() - end) + search:on(bui.EV_CHANGE, on_search) tb:add(search) search:geom(bui.GEOM_PACK, {fill = 1, side = 1}) search_node = search -local sep1 = bui.Node("separator") -sep1:geom(bui.GEOM_PACK, {side = 1}) -tb:add(sep1) - -local add_btn = bui.Node("button") -add_btn:set("text", "+ Add") -add_btn:set("icon", bui.icon_id("ADD")) -add_btn:on(bui.EV_CLICK, add_row) -tb:add(add_btn) -add_btn:geom(bui.GEOM_PACK, {side = 1}) - -local del_btn = bui.Node("button") -del_btn:set("text", "Delete") -del_btn:set("icon", bui.icon_id("X")) -del_btn:on(bui.EV_CLICK, delete_selected) -tb:add(del_btn) -del_btn:geom(bui.GEOM_PACK, {side = 1}) - -local exp_btn = bui.Node("button") -exp_btn:set("text", "Export") -exp_btn:set("icon", bui.icon_id("EXPORT")) -exp_btn:on(bui.EV_CLICK, export_table) -tb:add(exp_btn) -exp_btn:geom(bui.GEOM_PACK, {side = 1}) - -local expf_btn = bui.Node("button") -expf_btn:set("text", "Save...") -expf_btn:set("icon", bui.icon_id("FILE_TICK")) -expf_btn:on(bui.EV_CLICK, export_to_file_via_browser) -tb:add(expf_btn) -expf_btn:geom(bui.GEOM_PACK, {side = 1}) - -local sep2 = bui.Node("separator") -sep2:geom(bui.GEOM_PACK, {side = 1}) -tb:add(sep2) - -local ub = bui.Node("button") -ub:set("text", "Undo") -ub:set("icon", bui.icon_id("LOOP_BACK")) -ub:set("enabled", 0) -ub:on(bui.EV_CLICK, undo_action) -tb:add(ub) -ub:geom(bui.GEOM_PACK, {side = 1}) -undo_btn = ub - -local rb = bui.Node("button") -rb:set("text", "Redo") -rb:set("icon", bui.icon_id("LOOP_FORWARDS")) -rb:set("enabled", 0) -rb:on(bui.EV_CLICK, redo_action) -tb:add(rb) -rb:geom(bui.GEOM_PACK, {side = 1}) -redo_btn = rb + local sep1 = bui.Node("separator") + sep1:geom(bui.GEOM_PACK, {side = 1}) + tb:add(sep1) + + local add_btn = bui.Node("button") + add_btn:set("text", "+ Add") + add_btn:set("icon", bui.icon_id("ADD")) + add_btn:on(bui.EV_CLICK, add_row) + tb:add(add_btn) + add_btn:geom(bui.GEOM_PACK, {side = 1}) + + local del_btn = bui.Node("button") + del_btn:set("text", "Delete") + del_btn:set("icon", bui.icon_id("X")) + del_btn:on(bui.EV_CLICK, delete_selected) + tb:add(del_btn) + del_btn:geom(bui.GEOM_PACK, {side = 1}) + + local exp_btn = bui.Node("button") + exp_btn:set("text", "Export") + exp_btn:set("icon", bui.icon_id("EXPORT")) + exp_btn:on(bui.EV_CLICK, export_table) + tb:add(exp_btn) + exp_btn:geom(bui.GEOM_PACK, {side = 1}) + + local expf_btn = bui.Node("button") + expf_btn:set("text", "Save...") + expf_btn:set("icon", bui.icon_id("FILE_TICK")) + expf_btn:on(bui.EV_CLICK, export_to_file_via_browser) + tb:add(expf_btn) + expf_btn:geom(bui.GEOM_PACK, {side = 1}) + + local sep2 = bui.Node("separator") + sep2:geom(bui.GEOM_PACK, {side = 1}) + tb:add(sep2) + + local ub = bui.Node("button") + ub:set("text", "Undo") + ub:set("icon", bui.icon_id("LOOP_BACK")) + ub:set("enabled", 0) + ub:on(bui.EV_CLICK, undo_action) + tb:add(ub) + ub:geom(bui.GEOM_PACK, {side = 1}) + undo_btn = ub + + local rb = bui.Node("button") + rb:set("text", "Redo") + rb:set("icon", bui.icon_id("LOOP_FORWARDS")) + rb:set("enabled", 0) + rb:on(bui.EV_CLICK, redo_action) + tb:add(rb) + rb:geom(bui.GEOM_PACK, {side = 1}) + redo_btn = rb root:add(tb) tb:geom(bui.GEOM_PACK, {side = 0, fill = 1}) @@ -711,8 +727,8 @@ redo_btn = rb local tree = bui.Node("tree") tree:set_strs("headers", {"Key", "Type", "Value"}) tree:set_strs("columns", {"0", "2", "1", "3"}) - tree:on(bui.EV_ACTIVATE, function() refresh_editor() end) - tree:on(bui.EV_CHANGE, function() refresh_editor() end) + tree:on(bui.EV_ACTIVATE, refresh_editor) + tree:on(bui.EV_CHANGE, refresh_editor) main_row:add(tree) tree:geom(bui.GEOM_PACK, {fill = 1}) tree_node = tree @@ -758,9 +774,10 @@ redo_btn = rb tl:set("text", "Type:") body:add(tl) editor_type = bui.Node("dropdown") - editor_type:set_strs("items", {"string", "number", "boolean", "table", "array", "nil"}) + editor_type:set_strs("items", TYPE_NAMES) editor_type:on(bui.EV_CHANGE, function() - refresh_editor() + type_dirty = true + update_value_widgets(selected_type_name()) end) body:add(editor_type) @@ -771,9 +788,7 @@ redo_btn = rb editor_val_str = bui.Node("text") editor_val_str:set("placeholder", "value") - editor_val_str:on(bui.EV_SUBMIT, function() - apply_edit() - end) + editor_val_str:on(bui.EV_SUBMIT, apply_edit) body:add(editor_val_str) editor_val_num = bui.Node("number") @@ -784,30 +799,25 @@ redo_btn = rb -- Boolean toggle local bl = bui.Node("label") - bl:set("text", "Toggle:") + bl:set("text", "Value:") body:add(bl) editor_bool = bui.Node("toggle") editor_bool:set("text", "Enabled") editor_bool:set("value", 0) + editor_bool:set("enabled", 0) body:add(editor_bool) -- Apply - local apply_btn = bui.Node("button") + apply_btn = bui.Node("button") apply_btn:set("text", "Apply") + apply_btn:set("enabled", 0) apply_btn:on(bui.EV_CLICK, apply_edit) body:add(apply_btn) apply_btn:geom(bui.GEOM_PACK, {fill = 1, pady0 = 4}) local clear_btn = bui.Node("button") clear_btn:set("text", "Clear Editor") - clear_btn:on(bui.EV_CLICK, function() - edit_id = nil - editor_key:set("value", "") - editor_type:set("value", 0) - editor_val_str:set("value", "") - editor_val_num:set("value", 0) - editor_bool:set("value", 0) - end) + clear_btn:on(bui.EV_CLICK, clear_editor) body:add(clear_btn) clear_btn:geom(bui.GEOM_PACK, {fill = 1, pady0 = 2}) @@ -823,7 +833,6 @@ redo_btn = rb local first_name = demo_names[1] switch_demo(first_name, DEMOS[first_name]) if demo_dd then demo_dd:set("value", 0) end - if search_node then search_node:set("value", "") end return root, tree end @@ -832,7 +841,7 @@ end -- MAIN -- ============================================================ -print("Lua Table Editor v0.2") +print("Lua Table Editor v0.3") print("3 demos: Person Record, Project Config, Game State") print("Select a demo from the dropdown, then edit entries.") print("Export to stdout | Save... to file | Ctrl+Z / Ctrl+Shift+Z for undo/redo\n") From 668e16809cb1edb3014cc5f699e0da11d8c8fc86 Mon Sep 17 00:00:00 2001 From: seclorum Date: Sun, 8 Nov 2026 20:15:31 +0100 Subject: [PATCH 4/7] lua: ph nutrients example, excercise window / canvas --- README.md | 12 +- bindings/lua/README.md | 6 + bindings/lua/examples/ph_nutrients.lua | 410 ++++++++++++++++++++++ website/src/content/docs/lua/overview.mdx | 2 +- 4 files changed, 423 insertions(+), 7 deletions(-) create mode 100644 bindings/lua/examples/ph_nutrients.lua diff --git a/README.md b/README.md index 523e339..c1ebe4d 100644 --- a/README.md +++ b/README.md @@ -998,7 +998,7 @@ table editor with a tree view, search, undo/redo and export: Without a window system the same script renders headless (`bui-lua --headless app.lua`), which is also how the Lua tests and documentation screenshots -work; all 26 examples run this way. Guide: +work; all 27 examples run this way. Guide: [bindings/lua/README.md](bindings/lua/README.md).
@@ -1513,13 +1513,13 @@ bui.run() `MenuBar` — and `ctx:open_menu` / `ctx:open_pie`; `fn` callbacks are refcounted by the kit and released with the items. -**Examples** (26 scripts, `bindings/lua/examples/`): +**Examples** (27 scripts, `bindings/lua/examples/`): - **Getting started:** `hello.lua`, `counter.lua`, `forms.lua`, `gallery.lua` - **Layout:** `split.lua`, `grid.lua`, `box.lua`, `dashboard.lua` - **Text and data:** `textarea.lua`, `enum.lua`, `listview.lua`, `data_table.lua` - **Menus and popups:** `menus.lua`, `menubar.lua`, `popover.lua` -- **Color and drawing:** `color_picker.lua`, `ramp.lua`, `curve.lua`, `histogram.lua`, `canvas.lua` +- **Color and drawing:** `color_picker.lua`, `ramp.lua`, `curve.lua`, `histogram.lua`, `canvas.lua`, `ph_nutrients.lua` - **Animation:** `anim.lua` - **Tools:** `todos.lua`, `filebrowser_demo.lua`, `image_viewer.lua`, `editor_screenshot.lua`, `lua_table_editor.lua` @@ -1997,7 +1997,7 @@ How each was counted: [Coverage](https://bui.preetham.top/docs/reference/coverag | [`bindings/node/examples/counter.js`](bindings/node/examples/counter.js) | JavaScript: the DOM-like API | | [`bindings/react/examples/counter.jsx`](bindings/react/examples/counter.jsx) | React in JSX (`bun examples/counter.jsx`) | | [`bindings/svelte/examples/Counter.svelte`](bindings/svelte/examples/Counter.svelte) | Svelte 5 (`node --import ./bindings/svelte/register.js bindings/svelte/examples/counter.js`) | -| Lua (26 in [`bindings/lua/examples/`](bindings/lua/examples/)) | 26 examples covering every BUI widget type: `hello.lua`, `counter.lua`, `forms.lua`, `gallery.lua`, `split.lua`, `grid.lua`, `box.lua`, `dashboard.lua`, `textarea.lua`, `enum.lua`, `listview.lua`, `data_table.lua`, `menus.lua`, `menubar.lua`, `popover.lua`, `color_picker.lua`, `ramp.lua`, `curve.lua`, `histogram.lua`, `canvas.lua`, `anim.lua`, `todos.lua`, `filebrowser_demo.lua`, `image_viewer.lua`, `editor_screenshot.lua`, `lua_table_editor.lua` | +| Lua (27 in [`bindings/lua/examples/`](bindings/lua/examples/)) | 27 examples covering every BUI widget type: `hello.lua`, `counter.lua`, `forms.lua`, `gallery.lua`, `split.lua`, `grid.lua`, `box.lua`, `dashboard.lua`, `textarea.lua`, `enum.lua`, `listview.lua`, `data_table.lua`, `menus.lua`, `menubar.lua`, `popover.lua`, `color_picker.lua`, `ramp.lua`, `curve.lua`, `histogram.lua`, `canvas.lua`, `anim.lua`, `todos.lua`, `filebrowser_demo.lua`, `image_viewer.lua`, `editor_screenshot.lua`, `lua_table_editor.lua`, `ph_nutrients.lua` | --- @@ -2085,7 +2085,7 @@ ship. The tables below have the details. | **Accessibility** | Screen-reader support through UI Automation (Windows), AT-SPI (Linux) and NSAccessibility (macOS) | | **Qt parity** | Measure against PySide6, run the same programs on Qt and BUI, fill in the missing classes, then `install()` so `import PySide6` programs run unchanged | | **More JavaScript frameworks** | Vue and Angular, as thin adapters over `@blender-ui/native` (the layer React and Svelte share) | -| **More languages** | Lua (26 examples, every widget), Ruby, Go (through cgo), C#, Zig, Swift, Kotlin and MyGo over the same C header; Godot, Unity and Bevy; Flutter | +| **More languages** | Lua (27 examples, every widget), Ruby, Go (through cgo), C#, Zig, Swift, Kotlin and MyGo over the same C header; Godot, Unity and Bevy; Flutter | Our roadmap has no dates on it. Like a good render, it's done when the noise goes away. @@ -2167,7 +2167,7 @@ headless there. Python can always fall back to the Tk window Can I use BUI from Vue, Angular, Ruby, Go or Lua? Lua is now supported: cmake -DBUI_BUILD_LUA_BINDINGS=ON builds bui-lua (a standalone host) and bui.so / bui.dll -(a loadable module) for Lua 5.4, LuaJIT or LuaRocks. There are 26 examples and +(a loadable module) for Lua 5.4, LuaJIT or LuaRocks. There are 27 examples and every BUI widget type works. Vue and Angular would be thin adapters over @blender-ui/native, the layer React and Svelte already share (how to add a framework). Ruby and Go diff --git a/bindings/lua/README.md b/bindings/lua/README.md index e5b33c4..21472d8 100644 --- a/bindings/lua/README.md +++ b/bindings/lua/README.md @@ -138,4 +138,10 @@ automatically when the items are replaced or the node/context is freed. # collapsible panel, undo/redo, # search/filter, export, metatable # display, Ctrl+Z / Ctrl+Shift+Z) +./bui-lua bindings/lua/examples/ph_nutrients.lua # soil pH / nutrient availability chart + # (canvas-drawn pH scale, nutrient + # availability bands, two overlapping + # curves, redraws on window resize via + # EV_CONFIGURE, editable via number + # fields and sliders) ``` diff --git a/bindings/lua/examples/ph_nutrients.lua b/bindings/lua/examples/ph_nutrients.lua new file mode 100644 index 0000000..82fbf09 --- /dev/null +++ b/bindings/lua/examples/ph_nutrients.lua @@ -0,0 +1,410 @@ +-- Soil pH and nutrient availability chart for bui-lua. +-- A Truog-style graph custom-drawn on a canvas node: pH scale (4.0-10.0), +-- horizontal availability bands per nutrient, and two overlapping +-- availability curves that peak in the slightly acidic to neutral range. +-- Every plotted value is editable with ordinary widgets below the chart. +-- +-- Usage: bui-lua bindings/lua/examples/ph_nutrients.lua +-- +-- Reference: the classic soil pH / nutrient availability chart +-- (https://jrcpl.us/contrib/2020/gardening-for-engineers/img/ph.jpg). +-- Band ranges are illustrative, not laboratory data. +-- +-- SPDX-License-Identifier: GPL-2.0-or-later + +local bui = require("bui") + +local WIN_W, WIN_H = 1260, 900 +local PH_MIN, PH_MAX = 4.0, 10.0 + +-- Plot geometry (canvas items use absolute coordinates; RIGHT/BOTTOM follow +-- the canvas size and are recomputed in draw() whenever EV_CONFIGURE fires). +local LEFT, TOP = 64, 48 +local canvas_w, canvas_h = WIN_W - 16, 636 +local RIGHT, BOTTOM = canvas_w - 24, canvas_h - 56 + +local PAPER = {0.965, 0.955, 0.90, 1.0} +local INK = {0.10, 0.10, 0.09, 1.0} +local GRID = {0.50, 0.48, 0.43, 0.35} +local FAINT = {0.50, 0.48, 0.43, 0.18} + +local DEFAULT_NUTRIENTS = { + {name = "Nitrogen", ph_min = 4.0, ph_max = 10.0, color = {0.30, 0.55, 0.25}}, + {name = "Phosphorus", ph_min = 5.5, ph_max = 8.0, color = {0.25, 0.50, 0.35}}, + {name = "Potassium", ph_min = 4.5, ph_max = 9.5, color = {0.40, 0.55, 0.20}}, + {name = "Sulfur", ph_min = 4.0, ph_max = 9.5, color = {0.45, 0.55, 0.30}}, + {name = "Calcium", ph_min = 5.5, ph_max = 9.5, color = {0.35, 0.50, 0.45}}, + {name = "Magnesium", ph_min = 5.5, ph_max = 8.5, color = {0.30, 0.45, 0.30}}, + {name = "Iron", ph_min = 4.0, ph_max = 6.5, color = {0.60, 0.35, 0.15}}, + {name = "Manganese", ph_min = 4.0, ph_max = 6.5, color = {0.55, 0.30, 0.20}}, + {name = "Boron", ph_min = 4.5, ph_max = 8.5, color = {0.50, 0.40, 0.20}}, + {name = "Copper", ph_min = 4.0, ph_max = 7.0, color = {0.60, 0.30, 0.15}}, + {name = "Zinc", ph_min = 4.0, ph_max = 7.0, color = {0.50, 0.35, 0.25}}, +} + +local DEFAULT_CURVES = { + macro = {label = "Macronutrients (N, P, K, S, Ca, Mg)", center = 6.7, spread = 1.60, + color = {0.13, 0.35, 0.20, 1.0}}, + micro = {label = "Micronutrients (Fe, Mn, B, Cu, Zn)", center = 5.7, spread = 1.80, + color = {0.55, 0.25, 0.08, 1.0}}, +} + +local nutrients = {} +for i, n in ipairs(DEFAULT_NUTRIENTS) do + nutrients[i] = {name = n.name, ph_min = n.ph_min, ph_max = n.ph_max, color = n.color} +end +local curves = { + macro = {label = DEFAULT_CURVES.macro.label, center = DEFAULT_CURVES.macro.center, + spread = DEFAULT_CURVES.macro.spread, color = DEFAULT_CURVES.macro.color}, + micro = {label = DEFAULT_CURVES.micro.label, center = DEFAULT_CURVES.micro.center, + spread = DEFAULT_CURVES.micro.spread, color = DEFAULT_CURVES.micro.color}, +} +local show_grid, show_curves = true, true + +-- ============================================================ +-- UI SHELL +-- ============================================================ + +local root = bui.Node("frame") +root:set("padding", 8) + +local title = bui.Node("label") +title:set("text", "Soil pH and Nutrient Availability") +root:add(title) +title:geom(bui.GEOM_PACK, {side = 0, fill = 1}) + +local cv = bui.Node("canvas") +root:add(cv) +cv:geom(bui.GEOM_PACK, {side = 0, expand = 1, fill = 3}) + +local sep = bui.Node("separator") +root:add(sep) +sep:geom(bui.GEOM_PACK, {side = 0, fill = 1}) + +-- Controls row: two nutrient columns + a curves/options column. +local ctrl_row = bui.Node("row") +root:add(ctrl_row) +ctrl_row:geom(bui.GEOM_PACK, {side = 0, fill = 1}) + +local function make_column(parent) + local col = bui.Node("column") + col:set("padding", 4) + parent:add(col) + col:geom(bui.GEOM_PACK, {side = 1}) + return col +end + +local status_node = bui.Node("label") +status_node:set("text", "Edit the fields below; the graph redraws live.") +root:add(status_node) +status_node:geom(bui.GEOM_PACK, {side = 0, fill = 1}) + +-- ============================================================ +-- CANVAS HELPERS +-- ============================================================ + +local drawn, next_id = {}, 1 + +local function item(spec) + spec.id = next_id + next_id = next_id + 1 + cv:canvas_set(spec) + drawn[#drawn + 1] = spec.id +end + +local function line(x1, y1, x2, y2, color, width) + item({type = bui.ITEM_LINE, coords = {x1, y1, x2, y2}, fill = color or INK, + width = width or 1}) +end + +-- RECT/ROUNDRECT/OVAL take Tk-style corner coords {x1, y1, x2, y2}. +local function rect(x, y, w, h, fill, outline, radius) + item({type = radius and bui.ITEM_ROUNDRECT or bui.ITEM_RECT, + coords = {x, y, x + w, y + h}, fill = fill, outline = outline or INK, + width = 1, radius = radius}) +end + +local function text(x, y, str, size, color) + item({type = bui.ITEM_TEXT, coords = {x, y}, text = str, fill = color or INK, + size = size or 12}) +end + +local function polyline(points, color, width) + for i = 1, #points - 1 do + local a, b = points[i], points[i + 1] + line(a[1], a[2], b[1], b[2], color, width) + end +end + +local function ph_x(ph) + return LEFT + ((ph - PH_MIN) / (PH_MAX - PH_MIN)) * (RIGHT - LEFT) +end + +local function alpha(c, a) + return {c[1], c[2], c[3], a} +end + +local function gaussian(x, center, sigma) + local d = (x - center) / sigma + return math.exp(-0.5 * d * d) +end + +local function status(msg) + status_node:set("text", msg) +end + +-- ============================================================ +-- DRAWING +-- ============================================================ + +local function draw() + for _, id in ipairs(drawn) do cv:canvas_delete(id) end + drawn, next_id = {}, 1 + + RIGHT = math.max(LEFT + 240, canvas_w - 24) + BOTTOM = math.max(TOP + 160, canvas_h - 56) + + rect(LEFT, TOP, RIGHT - LEFT, BOTTOM - TOP, PAPER, INK) + + if show_grid then + local i = 0 + while i <= (PH_MAX - PH_MIN) * 2 do + local ph = PH_MIN + i * 0.5 + local major = (ph * 2) % 2 == 0 + line(ph_x(ph), TOP, ph_x(ph), BOTTOM, major and GRID or FAINT, major and 1 or 0.6) + i = i + 1 + end + for r = 1, 9 do + local y = TOP + r * (BOTTOM - TOP) / 10 + line(LEFT, y, RIGHT, y, FAINT, 0.6) + end + end + + -- Nutrient bands (horizontal, each spanning its available pH range) + local row_h = (BOTTOM - TOP - 8) / #nutrients + for i, n in ipairs(nutrients) do + local cy = TOP + 4 + (i - 0.5) * row_h + local x0, x1 = ph_x(n.ph_min), ph_x(n.ph_max) + local bh = row_h - 12 + rect(x0, cy - bh / 2, math.max(x1 - x0, 4), bh, alpha(n.color, 0.40), + alpha(n.color, 0.9), bh / 2) + text((x0 + x1) / 2 - #n.name * 3.1, cy - 6, n.name, 11, INK) + end + + -- Two overlapping availability curves + if show_curves then + for _, c in ipairs({curves.macro, curves.micro}) do + local pts = {} + for s = 0, 120 do + local ph = PH_MIN + (s / 120) * (PH_MAX - PH_MIN) + local avail = gaussian(ph, c.center, c.spread) + pts[#pts + 1] = {ph_x(ph), BOTTOM - 8 - avail * (BOTTOM - TOP - 20)} + end + polyline(pts, c.color, 2.5) + end + local lx, ly = RIGHT - 262, TOP + 10 + rect(lx, ly, 252, 44, {1, 1, 1, 0.85}, GRID) + line(lx + 8, ly + 14, lx + 30, ly + 14, curves.macro.color, 2.5) + text(lx + 36, ly + 8, curves.macro.label, 10, INK) + line(lx + 8, ly + 32, lx + 30, ly + 32, curves.micro.color, 2.5) + text(lx + 36, ly + 26, curves.micro.label, 10, INK) + end + + -- Axis labels + text(ph_x(5.0) - 24, TOP - 20, "ACIDIC", 11, INK) + text(ph_x(7.0) - 30, TOP - 20, "NEUTRAL", 11, INK) + text(ph_x(9.0) - 34, TOP - 20, "ALKALINE", 11, INK) + local i = 0 + while i <= (PH_MAX - PH_MIN) * 2 do + local ph = PH_MIN + i * 0.5 + local major = (ph * 2) % 2 == 0 + local x = ph_x(ph) + line(x, BOTTOM, x, BOTTOM + (major and 6 or 3), INK, 1) + if major then + text(x - 11, BOTTOM + 12, string.format("%.1f", ph), 11, INK) + end + i = i + 1 + end + text((LEFT + RIGHT) / 2 - 8, BOTTOM + 32, "pH", 12, INK) + text(LEFT - 56, TOP + 2, "more", 10, INK) + text(LEFT - 56, TOP + 14, "available", 10, INK) + text(LEFT - 56, BOTTOM - 18, "less", 10, INK) + text(LEFT - 56, BOTTOM - 6, "available", 10, INK) +end + +-- Redraw the plot whenever the canvas geometry changes (window resize). +-- The kit only reports BK_EV_CONFIGURE to nodes carrying the Tk-style +-- "events" flag (bk_arrange checks NF_EVENTS), so turn it on first. +cv:set("events", 1) +cv:on(bui.EV_CONFIGURE, function(_, w, h) + w, h = math.floor(w), math.floor(h) + if w <= 0 or h <= 0 or (w == canvas_w and h == canvas_h) then + return + end + canvas_w, canvas_h = w, h + draw() +end) + +-- ============================================================ +-- EDIT WIDGETS +-- ============================================================ + +local min_fields, max_fields = {}, {} + +local function clamp_ph(v, lo, hi) + if v < PH_MIN then v = PH_MIN end + if v > PH_MAX then v = PH_MAX end + if v < lo then v = lo end + if v > hi then v = hi end + return v +end + +local function nutrient_row(parent, i) + local n = nutrients[i] + local row = bui.Node("row") + row:set("padding", 2) + parent:add(row) + + local name_lbl = bui.Node("label") + name_lbl:set("text", n.name) + row:add(name_lbl) + + local fmin = bui.Node("number") + fmin:set("min", PH_MIN) + fmin:set("max", PH_MAX) + fmin:set("step", 0.1) + fmin:set("precision", 1) + fmin:set("value", n.ph_min) + fmin:on(bui.EV_CHANGE, function() + local v = clamp_ph(fmin:get("value") or n.ph_min, PH_MIN, n.ph_max) + fmin:set("value", v) + n.ph_min = v + draw() + status(("%s: %.1f - %.1f"):format(n.name, n.ph_min, n.ph_max)) + end) + row:add(fmin) + min_fields[i] = fmin + + local dash = bui.Node("label") + dash:set("text", " - ") + row:add(dash) + + local fmax = bui.Node("number") + fmax:set("min", PH_MIN) + fmax:set("max", PH_MAX) + fmax:set("step", 0.1) + fmax:set("precision", 1) + fmax:set("value", n.ph_max) + fmax:on(bui.EV_CHANGE, function() + local v = clamp_ph(fmax:get("value") or n.ph_max, n.ph_min, PH_MAX) + fmax:set("value", v) + n.ph_max = v + draw() + status(("%s: %.1f - %.1f"):format(n.name, n.ph_min, n.ph_max)) + end) + row:add(fmax) + max_fields[i] = fmax +end + +local col_a = make_column(ctrl_row) +local head_a = bui.Node("label") +head_a:set("text", "Nutrient pH ranges") +col_a:add(head_a) +for i = 1, 6 do nutrient_row(col_a, i) end + +local col_b = make_column(ctrl_row) +col_b:add(bui.Node("separator")) +for i = 7, #nutrients do nutrient_row(col_b, i) end + +local col_c = make_column(ctrl_row) +local head_c = bui.Node("label") +head_c:set("text", "Availability curves") +col_c:add(head_c) + +local function curve_slider(c, field, lo, hi, label) + local row = bui.Node("row") + row:set("padding", 2) + col_c:add(row) + local lbl = bui.Node("label") + lbl:set("text", label) + row:add(lbl) + local sl = bui.Node("slider") + sl:set("min", lo) + sl:set("max", hi) + sl:set("value", c[field]) + sl:on(bui.EV_CHANGE, function() + c[field] = sl:get("value") or c[field] + draw() + status(("%s %s = %.2f"):format(c.label, field, c[field])) + end) + row:add(sl) + sl:geom(bui.GEOM_PACK, {fill = 1}) + return sl +end + +local sliders = { + curve_slider(curves.macro, "center", 5.0, 8.5, "macro center"), + curve_slider(curves.macro, "spread", 0.6, 3.0, "macro spread"), + curve_slider(curves.micro, "center", 4.0, 7.5, "micro center"), + curve_slider(curves.micro, "spread", 0.6, 3.0, "micro spread"), +} + +local tg_grid = bui.Node("toggle") +tg_grid:set("text", "Grid") +tg_grid:set("value", 1) +tg_grid:on(bui.EV_CHANGE, function() + show_grid = tg_grid:get("value") > 0 + draw() +end) +col_c:add(tg_grid) + +local tg_curves = bui.Node("toggle") +tg_curves:set("text", "Curves") +tg_curves:set("value", 1) +tg_curves:on(bui.EV_CHANGE, function() + show_curves = tg_curves:get("value") > 0 + draw() +end) +col_c:add(tg_curves) + +local reset_btn = bui.Node("button") +reset_btn:set("text", "Reset defaults") +reset_btn:set("icon", bui.icon_id("LOOP_BACK")) +reset_btn:on(bui.EV_CLICK, function() + for i, n in ipairs(nutrients) do + local d = DEFAULT_NUTRIENTS[i] + n.ph_min, n.ph_max = d.ph_min, d.ph_max + min_fields[i]:set("value", d.ph_min) + max_fields[i]:set("value", d.ph_max) + end + curves.macro.center, curves.macro.spread = DEFAULT_CURVES.macro.center, DEFAULT_CURVES.macro.spread + curves.micro.center, curves.micro.spread = DEFAULT_CURVES.micro.center, DEFAULT_CURVES.micro.spread + sliders[1]:set("value", curves.macro.center) + sliders[2]:set("value", curves.macro.spread) + sliders[3]:set("value", curves.micro.center) + sliders[4]:set("value", curves.micro.spread) + draw() + status("Reset to default chart.") +end) +col_c:add(reset_btn) +reset_btn:geom(bui.GEOM_PACK, {fill = 1, pady0 = 6}) + +-- ============================================================ +-- MAIN +-- ============================================================ + +print("Soil pH and Nutrient Availability") +print("Edit nutrient ranges (number fields) and curves (sliders);\nthe canvas graph redraws live.\n") + +draw() + +local ctx = bui.Context(root) +local win = bui.Window(ctx, "Soil pH and Nutrient Availability", WIN_W, WIN_H) + +if win then + win:show() + bui.run() +else + local px, _, w, h = ctx:render(WIN_W, WIN_H, 1) + print("No window system. Headless render: " .. w .. "x" .. h .. " " .. #px .. " bytes") +end diff --git a/website/src/content/docs/lua/overview.mdx b/website/src/content/docs/lua/overview.mdx index e8ede2c..e05510d 100644 --- a/website/src/content/docs/lua/overview.mdx +++ b/website/src/content/docs/lua/overview.mdx @@ -57,7 +57,7 @@ items are replaced or the node/context is freed. See `examples/menus.lua` and ## Examples -26 scripts in [`bindings/lua/examples`](https://github.com/ppmpreetham/BUI/tree/main/bindings/lua/examples): `hello.lua`, `counter.lua`, `todos.lua`, `gallery.lua`, `forms.lua`, `dashboard.lua`, `anim.lua`, `lua_table_editor.lua`, `editor_screenshot.lua`, `box.lua`, `canvas.lua`, `curve.lua`, `enum.lua`, `grid.lua`, `histogram.lua`, `listview.lua`, `menubar.lua`, `menus.lua`, `popover.lua`, `ramp.lua`, `split.lua`, `textarea.lua`, `color_picker.lua`, `filebrowser_demo.lua`, `image_viewer.lua` and `data_table.lua`. +27 scripts in [`bindings/lua/examples`](https://github.com/ppmpreetham/BUI/tree/main/bindings/lua/examples): `hello.lua`, `counter.lua`, `todos.lua`, `gallery.lua`, `forms.lua`, `dashboard.lua`, `anim.lua`, `lua_table_editor.lua`, `editor_screenshot.lua`, `box.lua`, `canvas.lua`, `curve.lua`, `enum.lua`, `grid.lua`, `histogram.lua`, `listview.lua`, `menubar.lua`, `menus.lua`, `popover.lua`, `ramp.lua`, `split.lua`, `textarea.lua`, `color_picker.lua`, `filebrowser_demo.lua`, `image_viewer.lua`, `data_table.lua` and `ph_nutrients.lua`. ## Next steps From 1c4ab049a0cdaf6721e27a10822fa5db77e07d95 Mon Sep 17 00:00:00 2001 From: seclorum Date: Mon, 9 Nov 2026 03:25:58 +0100 Subject: [PATCH 5/7] kit: tabs - add missing draw_tree (mirrors panel_draw_tree) so page children paint --- kit/intern/kit_widgets.c | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/kit/intern/kit_widgets.c b/kit/intern/kit_widgets.c index 923cf60..123b591 100644 --- a/kit/intern/kit_widgets.c +++ b/kit/intern/kit_widgets.c @@ -986,7 +986,28 @@ static void tabs_draw(BKNode *n) bk_roundbox(n->x, n->y + th, n->w, n->h - th, rad, CNR_ALL & ~CNR_TL, c); } +/* Like panels: paint the widget, then draw children clipped to the content + * region under the tab bar (tabs_arrange shows only the current page). */ +static void tabs_draw_tree(BKNode *n) +{ + if (!NODE_VISIBLE(n)) { + return; + } + BKRect r = bk_rect(n->x - 3, n->y - 3, n->w + 6, n->h + 6); + if (!bk_clip_visible(&r)) { + return; + } + tabs_draw(n); + const int th = tab_h(); + bk_clip_push(n->x, n->y + th, n->w, BK_MAX(0, n->h - th)); + for (int i = 0; i < n->nkids; i++) { + bk_draw_tree(n->kids[i]); + } + bk_clip_pop(); +} + const BKClass bk_cls_tabs = {.name = "tabs", + .draw_tree = tabs_draw_tree, .flags = CLS_INTERACTIVE, .measure = tabs_measure, .arrange = tabs_arrange, From 620a7287b587ad26fd62490e0e2a5405417eb814 Mon Sep 17 00:00:00 2001 From: seclorum Date: Mon, 9 Nov 2026 03:25:58 +0100 Subject: [PATCH 6/7] lua: node remove detaches only (C tree owns nodes, cf. buipy); free tears down + invalidates whole subtree --- bindings/lua/src/bui_node.c | 33 +++++++++++++++++++++++++++------ 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/bindings/lua/src/bui_node.c b/bindings/lua/src/bui_node.c index c698b4a..cd91cb2 100644 --- a/bindings/lua/src/bui_node.c +++ b/bindings/lua/src/bui_node.c @@ -67,9 +67,10 @@ static int l_node_remove(lua_State *L) { if (!parent->node || !child->node) return luaL_error(L, "attempt to use freed node"); BK_node_remove(parent->node, child->node); - /* Also invalidate the child Lua reference */ - luabui_unmap_node(L, child->node); - child->node = NULL; + /* Detach only: the C tree owns nodes, remove never frees (BK_node_remove + * just unlinks; cf. buipy_kit.c kitnode_remove). The child wrapper stays + * valid so detached subtrees (e.g. tab pages) can be re-added; reclaim + * with node:free(). */ return 0; } @@ -284,12 +285,32 @@ static int l_node_relayout(lua_State *L) { return 0; } +/* BK_node_free() recurses into ref-less (plain) children, so every live + * wrapper in the subtree must be invalidated first — otherwise methods on + * stale userdata would dereference freed BKNodes. */ +static void invalidate_node_subtree(lua_State *L, BKNode *n) { + const int nkids = BK_node_child_count(n); + for (int i = 0; i < nkids; i++) { + BKNode *c = BK_node_child(n, i); + if (!c || BK_node_ref(c)) + continue; /* host-owned: BK_node_free leaves it alive */ + invalidate_node_subtree(L, c); + } + if (luabui_lookup_node(L, n)) { + luabui_Node *cud = (luabui_Node *)lua_touserdata(L, -1); + if (cud) + cud->node = NULL; + lua_pop(L, 1); + } + luabui_unmap_node(L, n); +} + static int l_node_free(lua_State *L) { luabui_Node *ud = CHECK_NODE(L, 1); if (ud->node) { - luabui_unmap_node(L, ud->node); - BK_node_free(ud->node); - ud->node = NULL; + BKNode *n = ud->node; /* keep it: invalidation NULLs this wrapper too */ + invalidate_node_subtree(L, n); + BK_node_free(n); } return 0; } From 1f627dbd8b88c5f6914b63a6291a418b1f163b3a Mon Sep 17 00:00:00 2001 From: seclorum Date: Mon, 9 Nov 2026 03:25:58 +0100 Subject: [PATCH 7/7] lua: ph_nutrients - tabbed Chart/Table UI, edits the ph data as a Lua table (tree view, Apply, add/delete, undo/redo) --- bindings/lua/README.md | 14 +- bindings/lua/examples/ph_nutrients.lua | 518 +++++++++++++++++++++---- 2 files changed, 452 insertions(+), 80 deletions(-) diff --git a/bindings/lua/README.md b/bindings/lua/README.md index 21472d8..2a954c4 100644 --- a/bindings/lua/README.md +++ b/bindings/lua/README.md @@ -138,10 +138,12 @@ automatically when the items are replaced or the node/context is freed. # collapsible panel, undo/redo, # search/filter, export, metatable # display, Ctrl+Z / Ctrl+Shift+Z) -./bui-lua bindings/lua/examples/ph_nutrients.lua # soil pH / nutrient availability chart - # (canvas-drawn pH scale, nutrient - # availability bands, two overlapping - # curves, redraws on window resize via - # EV_CONFIGURE, editable via number - # fields and sliders) + ./bui-lua bindings/lua/examples/ph_nutrients.lua # soil pH / nutrient availability chart + # (canvas-drawn pH scale, nutrient + # availability bands, two overlapping + # curves, redraws on window resize via + # EV_CONFIGURE, editable via number + # fields and sliders; a Table tab edits + # the same data as a Lua table — tree + # view, Apply, add/delete, undo/redo) ``` diff --git a/bindings/lua/examples/ph_nutrients.lua b/bindings/lua/examples/ph_nutrients.lua index 82fbf09..7026a00 100644 --- a/bindings/lua/examples/ph_nutrients.lua +++ b/bindings/lua/examples/ph_nutrients.lua @@ -2,7 +2,10 @@ -- A Truog-style graph custom-drawn on a canvas node: pH scale (4.0-10.0), -- horizontal availability bands per nutrient, and two overlapping -- availability curves that peak in the slightly acidic to neutral range. --- Every plotted value is editable with ordinary widgets below the chart. +-- Two tabs share one data model: "Chart" edits through number fields and +-- sliders, "Table" edits the very same data as a Lua table in a tree view +-- (name, pH range, colours, curve parameters). Edits in either tab are +-- reflected in the graph live. -- -- Usage: bui-lua bindings/lua/examples/ph_nutrients.lua -- @@ -49,18 +52,50 @@ local DEFAULT_CURVES = { color = {0.55, 0.25, 0.08, 1.0}}, } -local nutrients = {} -for i, n in ipairs(DEFAULT_NUTRIENTS) do - nutrients[i] = {name = n.name, ph_min = n.ph_min, ph_max = n.ph_max, color = n.color} +-- ============================================================ +-- DATA MODEL (single source of truth for both tabs) +-- ============================================================ + +local function deepcopy(t) + if type(t) ~= "table" then return t end + local c = {} + for k, v in pairs(t) do c[deepcopy(k)] = deepcopy(v) end + return c end -local curves = { - macro = {label = DEFAULT_CURVES.macro.label, center = DEFAULT_CURVES.macro.center, - spread = DEFAULT_CURVES.macro.spread, color = DEFAULT_CURVES.macro.color}, - micro = {label = DEFAULT_CURVES.micro.label, center = DEFAULT_CURVES.micro.center, - spread = DEFAULT_CURVES.micro.spread, color = DEFAULT_CURVES.micro.color}, -} + +local nutrients = deepcopy(DEFAULT_NUTRIENTS) +local curves = deepcopy(DEFAULT_CURVES) local show_grid, show_curves = true, true +local undo_stack, redo_stack = {}, {} + +local function clamp(v, lo, hi) + if v < lo then return lo end + if v > hi then return hi end + return v +end + +-- Keeps the model displayable after arbitrary table-tab edits. +local function normalize_model() + for _, n in ipairs(nutrients) do + if type(n.ph_min) ~= "number" then n.ph_min = PH_MIN end + if type(n.ph_max) ~= "number" then n.ph_max = PH_MAX end + if type(n.name) ~= "string" then n.name = tostring(n.name) end + n.ph_min, n.ph_max = clamp(n.ph_min, PH_MIN, PH_MAX), clamp(n.ph_max, PH_MIN, PH_MAX) + if n.ph_min > n.ph_max then n.ph_min, n.ph_max = n.ph_max, n.ph_min end + n.color = type(n.color) == "table" and n.color or {0.5, 0.5, 0.5} + for ch = 1, 3 do n.color[ch] = clamp(tonumber(n.color[ch]) or 0.5, 0, 1) end + end + for _, key in ipairs({"macro", "micro"}) do + local c = curves[key] + if type(c.label) ~= "string" then c.label = key end + c.center = clamp(tonumber(c.center) or 6.5, 0, 14) + c.spread = clamp(tonumber(c.spread) or 1.5, 0.1, 5) + c.color = type(c.color) == "table" and c.color or {0.5, 0.5, 0.5, 1} + for ch = 1, 4 do c.color[ch] = clamp(tonumber(c.color[ch]) or 0.5, 0, 1) end + end +end + -- ============================================================ -- UI SHELL -- ============================================================ @@ -73,31 +108,45 @@ title:set("text", "Soil pH and Nutrient Availability") root:add(title) title:geom(bui.GEOM_PACK, {side = 0, fill = 1}) -local cv = bui.Node("canvas") -root:add(cv) -cv:geom(bui.GEOM_PACK, {side = 0, expand = 1, fill = 3}) +-- The kit's tabs widget owns its pages as children (tabs_arrange shows the +-- current child below the bar and hides the rest; tabs_select raises +-- BK_EV_CHANGE) — add pages to the tabs node itself, not a sibling frame. +local tabs = bui.Node("tabs") +tabs:set_strs("items", {"Chart", "Table"}) +root:add(tabs) +tabs:geom(bui.GEOM_PACK, {side = 0, expand = 1, fill = 3}) -local sep = bui.Node("separator") -root:add(sep) -sep:geom(bui.GEOM_PACK, {side = 0, fill = 1}) +local status_node = bui.Node("label") +status_node:set("text", "Chart tab: edit the fields below; the graph redraws live.") +root:add(status_node) +status_node:geom(bui.GEOM_PACK, {side = 0, fill = 1}) --- Controls row: two nutrient columns + a curves/options column. -local ctrl_row = bui.Node("row") -root:add(ctrl_row) -ctrl_row:geom(bui.GEOM_PACK, {side = 0, fill = 1}) +local function status(msg) + status_node:set("text", msg) +end -local function make_column(parent) - local col = bui.Node("column") - col:set("padding", 4) - parent:add(col) - col:geom(bui.GEOM_PACK, {side = 1}) - return col +-- Forward declarations for the shared edit pipeline. +local cv +local draw, rebuild_chart_rows, build_data_tree +local undo_btn, redo_btn + +-- After any structural change: redraw the plot, rebuild the chart rows and +-- the data tree from the model. +local function after_model_change() + normalize_model() + draw() + rebuild_chart_rows() + build_data_tree() end -local status_node = bui.Node("label") -status_node:set("text", "Edit the fields below; the graph redraws live.") -root:add(status_node) -status_node:geom(bui.GEOM_PACK, {side = 0, fill = 1}) +local tree_dirty = false + +-- Model-only edit from the chart tab: cheap path (tree rebuilt lazily on tab +-- switch, chart rows unaffected). +local function after_chart_edit() + draw() + tree_dirty = true +end -- ============================================================ -- CANVAS HELPERS @@ -149,15 +198,11 @@ local function gaussian(x, center, sigma) return math.exp(-0.5 * d * d) end -local function status(msg) - status_node:set("text", msg) -end - -- ============================================================ -- DRAWING -- ============================================================ -local function draw() +draw = function() for _, id in ipairs(drawn) do cv:canvas_delete(id) end drawn, next_id = {}, 1 @@ -181,7 +226,7 @@ local function draw() end -- Nutrient bands (horizontal, each spanning its available pH range) - local row_h = (BOTTOM - TOP - 8) / #nutrients + local row_h = (BOTTOM - TOP - 8) / math.max(#nutrients, 1) for i, n in ipairs(nutrients) do local cy = TOP + 4 + (i - 0.5) * row_h local x0, x1 = ph_x(n.ph_min), ph_x(n.ph_max) @@ -232,9 +277,21 @@ local function draw() text(LEFT - 56, BOTTOM - 6, "available", 10, INK) end --- Redraw the plot whenever the canvas geometry changes (window resize). --- The kit only reports BK_EV_CONFIGURE to nodes carrying the Tk-style --- "events" flag (bk_arrange checks NF_EVENTS), so turn it on first. +-- ============================================================ +-- CHART PAGE (canvas + control columns) +-- ============================================================ + +local chart_page = bui.Node("frame") +chart_page:set("padding", 4) + +cv = bui.Node("canvas") +chart_page:add(cv) +cv:geom(bui.GEOM_PACK, {side = 0, expand = 1, fill = 3}) + +-- Redraw the plot whenever the canvas geometry changes (window resize, or +-- being re-packed when the Chart tab comes back). The kit only reports +-- BK_EV_CONFIGURE to nodes carrying the Tk-style "events" flag (bk_arrange +-- checks NF_EVENTS), so turn it on first. cv:set("events", 1) cv:on(bui.EV_CONFIGURE, function(_, w, h) w, h = math.floor(w), math.floor(h) @@ -245,20 +302,24 @@ cv:on(bui.EV_CONFIGURE, function(_, w, h) draw() end) --- ============================================================ --- EDIT WIDGETS --- ============================================================ +local sep = bui.Node("separator") +chart_page:add(sep) +sep:geom(bui.GEOM_PACK, {side = 0, fill = 1}) -local min_fields, max_fields = {}, {} +local ctrl_row = bui.Node("row") +chart_page:add(ctrl_row) +ctrl_row:geom(bui.GEOM_PACK, {side = 0, fill = 1}) -local function clamp_ph(v, lo, hi) - if v < PH_MIN then v = PH_MIN end - if v > PH_MAX then v = PH_MAX end - if v < lo then v = lo end - if v > hi then v = hi end - return v +local function make_column(parent) + local col = bui.Node("column") + col:set("padding", 4) + parent:add(col) + col:geom(bui.GEOM_PACK, {side = 1}) + return col end +local min_fields, max_fields = {}, {} + local function nutrient_row(parent, i) local n = nutrients[i] local row = bui.Node("row") @@ -276,10 +337,10 @@ local function nutrient_row(parent, i) fmin:set("precision", 1) fmin:set("value", n.ph_min) fmin:on(bui.EV_CHANGE, function() - local v = clamp_ph(fmin:get("value") or n.ph_min, PH_MIN, n.ph_max) + local v = clamp(fmin:get("value") or n.ph_min, PH_MIN, n.ph_max) fmin:set("value", v) n.ph_min = v - draw() + after_chart_edit() status(("%s: %.1f - %.1f"):format(n.name, n.ph_min, n.ph_max)) end) row:add(fmin) @@ -296,10 +357,10 @@ local function nutrient_row(parent, i) fmax:set("precision", 1) fmax:set("value", n.ph_max) fmax:on(bui.EV_CHANGE, function() - local v = clamp_ph(fmax:get("value") or n.ph_max, n.ph_min, PH_MAX) + local v = clamp(fmax:get("value") or n.ph_max, n.ph_min, PH_MAX) fmax:set("value", v) n.ph_max = v - draw() + after_chart_edit() status(("%s: %.1f - %.1f"):format(n.name, n.ph_min, n.ph_max)) end) row:add(fmax) @@ -310,17 +371,28 @@ local col_a = make_column(ctrl_row) local head_a = bui.Node("label") head_a:set("text", "Nutrient pH ranges") col_a:add(head_a) -for i = 1, 6 do nutrient_row(col_a, i) end local col_b = make_column(ctrl_row) col_b:add(bui.Node("separator")) -for i = 7, #nutrients do nutrient_row(col_b, i) end + +-- Rebuild the two nutrient columns from the model (after add/delete/undo). +rebuild_chart_rows = function() + while col_a:child_count() > 1 do col_a:child(1):free() end + while col_b:child_count() > 1 do col_b:child(1):free() end + min_fields, max_fields = {}, {} + local half = math.ceil(#nutrients / 2) + for i = 1, half do nutrient_row(col_a, i) end + for i = half + 1, #nutrients do nutrient_row(col_b, i) end +end +rebuild_chart_rows() local col_c = make_column(ctrl_row) local head_c = bui.Node("label") head_c:set("text", "Availability curves") col_c:add(head_c) +local sliders + local function curve_slider(c, field, lo, hi, label) local row = bui.Node("row") row:set("padding", 2) @@ -333,8 +405,8 @@ local function curve_slider(c, field, lo, hi, label) sl:set("max", hi) sl:set("value", c[field]) sl:on(bui.EV_CHANGE, function() - c[field] = sl:get("value") or c[field] - draw() + c[field] = clamp(sl:get("value") or c[field], lo, hi) + after_chart_edit() status(("%s %s = %.2f"):format(c.label, field, c[field])) end) row:add(sl) @@ -342,7 +414,14 @@ local function curve_slider(c, field, lo, hi, label) return sl end -local sliders = { +local function sync_curve_sliders() + sliders[1]:set("value", curves.macro.center) + sliders[2]:set("value", curves.macro.spread) + sliders[3]:set("value", curves.micro.center) + sliders[4]:set("value", curves.micro.spread) +end + +sliders = { curve_slider(curves.macro, "center", 5.0, 8.5, "macro center"), curve_slider(curves.macro, "spread", 0.6, 3.0, "macro spread"), curve_slider(curves.micro, "center", 4.0, 7.5, "micro center"), @@ -367,36 +446,327 @@ tg_curves:on(bui.EV_CHANGE, function() end) col_c:add(tg_curves) +-- Shared by the chart Reset button and the table Reset button. +local function restore_defaults() + nutrients = deepcopy(DEFAULT_NUTRIENTS) + curves = deepcopy(DEFAULT_CURVES) + undo_stack, redo_stack = {}, {} + after_model_change() + sync_curve_sliders() + if undo_btn then undo_btn:set("enabled", 0) end + if redo_btn then redo_btn:set("enabled", 0) end + status("Reset to default chart.") +end + local reset_btn = bui.Node("button") reset_btn:set("text", "Reset defaults") reset_btn:set("icon", bui.icon_id("LOOP_BACK")) -reset_btn:on(bui.EV_CLICK, function() +reset_btn:on(bui.EV_CLICK, restore_defaults) +col_c:add(reset_btn) +reset_btn:geom(bui.GEOM_PACK, {fill = 1, pady0 = 6}) + +-- ============================================================ +-- TABLE PAGE (edit the same model as a Lua table) +-- ============================================================ + +local table_page = bui.Node("frame") +table_page:set("padding", 4) + +local trow = bui.Node("row") +table_page:add(trow) +trow:geom(bui.GEOM_PACK, {side = 0, expand = 1, fill = 3}) + +local tree_node = bui.Node("tree") +tree_node:set_strs("headers", {"Key", "Type", "Value"}) +tree_node:set_strs("columns", {"0", "2", "1", "3"}) +trow:add(tree_node) +tree_node:geom(bui.GEOM_PACK, {fill = 1, expand = 1}) + +local edit_col = make_column(trow) +edit_col:set("padding", 6) + +local ed_head = bui.Node("label") +ed_head:set("text", "Edit entry") +edit_col:add(ed_head) + +local ed_key_lbl = bui.Node("label") +ed_key_lbl:set("text", "Key: -") +edit_col:add(ed_key_lbl) +local ed_type_lbl = bui.Node("label") +ed_type_lbl:set("text", "Type: -") +edit_col:add(ed_type_lbl) +local ed_val_lbl = bui.Node("label") +ed_val_lbl:set("text", "Value:") +edit_col:add(ed_val_lbl) + +local ed_str = bui.Node("text") +ed_str:set("placeholder", "text value") +edit_col:add(ed_str) +local ed_num = bui.Node("number") +ed_num:set("min", -20) +ed_num:set("max", 20) +ed_num:set("step", 0.01) +ed_num:set("precision", 2) +edit_col:add(ed_num) + +local ed_apply = bui.Node("button") +ed_apply:set("text", "Apply") +ed_apply:set("enabled", 0) +edit_col:add(ed_apply) +ed_apply:geom(bui.GEOM_PACK, {fill = 1, pady0 = 4}) + +local add_btn = bui.Node("button") +add_btn:set("text", "+ Nutrient") +add_btn:set("icon", bui.icon_id("ADD")) +edit_col:add(add_btn) + +local del_btn = bui.Node("button") +del_btn:set("text", "Delete Nutrient") +del_btn:set("icon", bui.icon_id("X")) +del_btn:set("enabled", 0) +edit_col:add(del_btn) + +local sep_t = bui.Node("separator") +edit_col:add(sep_t) + +undo_btn = bui.Node("button") +undo_btn:set("text", "Undo") +undo_btn:set("icon", bui.icon_id("LOOP_BACK")) +undo_btn:set("enabled", 0) +edit_col:add(undo_btn) + +redo_btn = bui.Node("button") +redo_btn:set("text", "Redo") +redo_btn:set("icon", bui.icon_id("LOOP_FORWARDS")) +redo_btn:set("enabled", 0) +edit_col:add(redo_btn) + +local treset_btn = bui.Node("button") +treset_btn:set("text", "Reset defaults") +treset_btn:set("icon", bui.icon_id("LOOP_BACK")) +edit_col:add(treset_btn) +treset_btn:geom(bui.GEOM_PACK, {fill = 1, pady0 = 6}) +treset_btn:on(bui.EV_CLICK, restore_defaults) + +-- Tree building: every item carries the live container/key it edits. +local item_meta, tree_id_count, sel_id + +local function add_tree_item(parent_item, key_str, kind, container, key, disp, nutrient_i) + local icon = kind == "group" and bui.icon_id("OUTLINER_COLLECTION") or bui.icon_id("FILE") + local it = bui.TreeItem(key_str, icon) + it:set_values({key_str, kind == "group" and "table" or kind, disp}) + tree_id_count = tree_id_count + 1 + it:set_ref(tree_id_count) + item_meta[tree_id_count] = {kind = kind, container = container, key = key, + label = key_str, nutrient_i = nutrient_i} + if parent_item then parent_item:add(it) end + return it +end + +local function fmt_num(v) + return string.format("%g", v) +end + +build_data_tree = function() + if not tree_node then return end + item_meta, tree_id_count, sel_id = {}, 0, nil + local roots = {} + + local g = add_tree_item(nil, "nutrients", "group", nil, nil, #nutrients .. " items") + roots[#roots + 1] = g for i, n in ipairs(nutrients) do - local d = DEFAULT_NUTRIENTS[i] - n.ph_min, n.ph_max = d.ph_min, d.ph_max - min_fields[i]:set("value", d.ph_min) - max_fields[i]:set("value", d.ph_max) + local ni = add_tree_item(g, "[" .. i .. "]", "group", nil, nil, n.name, i) + add_tree_item(ni, "name", "string", n, "name", string.format("%q", n.name), i) + add_tree_item(ni, "ph_min", "number", n, "ph_min", fmt_num(n.ph_min), i) + add_tree_item(ni, "ph_max", "number", n, "ph_max", fmt_num(n.ph_max), i) + local cg = add_tree_item(ni, "color", "group", nil, nil, "3 items", i) + for ch = 1, 3 do + add_tree_item(cg, "[" .. ch .. "]", "number", n.color, ch, fmt_num(n.color[ch]), i) + end + end + + local cg2 = add_tree_item(nil, "curves", "group", nil, nil, "2 items") + roots[#roots + 1] = cg2 + for _, cname in ipairs({"macro", "micro"}) do + local c = curves[cname] + local ci = add_tree_item(cg2, cname, "group", nil, nil, "4 items") + add_tree_item(ci, "label", "string", c, "label", string.format("%q", c.label)) + add_tree_item(ci, "center", "number", c, "center", fmt_num(c.center)) + add_tree_item(ci, "spread", "number", c, "spread", fmt_num(c.spread)) + local col = add_tree_item(ci, "color", "group", nil, nil, "4 items") + for ch = 1, 4 do + add_tree_item(col, "[" .. ch .. "]", "number", c.color, ch, fmt_num(c.color[ch])) + end + end + + tree_node:set_roots(roots) + tree_node:relayout() + ed_key_lbl:set("text", "Key: -") + ed_type_lbl:set("text", "Type: -") + ed_str:set("enabled", 0) + ed_num:set("enabled", 0) + ed_apply:set("enabled", 0) + del_btn:set("enabled", 0) +end + +local function clear_editor() + sel_id = nil + ed_key_lbl:set("text", "Key: -") + ed_type_lbl:set("text", "Type: -") + ed_str:set("value", "") + ed_str:set("enabled", 0) + ed_num:set("value", 0) + ed_num:set("enabled", 0) + ed_apply:set("enabled", 0) + del_btn:set("enabled", 0) +end + +local function tree_select() + local sel = tree_node:selected() + if not sel then clear_editor(); return end + local m = item_meta[sel:ref()] + if not m or m.kind == "group" then + clear_editor() + if m then + ed_key_lbl:set("text", "Key: " .. m.label) + ed_type_lbl:set("text", "Type: table (select a leaf to edit)") + del_btn:set("enabled", m.nutrient_i and 1 or 0) + end + return + end + sel_id = sel:ref() + ed_key_lbl:set("text", "Key: " .. m.label) + ed_type_lbl:set("text", "Type: " .. m.kind) + local cur = m.container[m.key] + if m.kind == "string" then + ed_str:set("value", tostring(cur)) + ed_str:set("enabled", 1) + ed_num:set("enabled", 0) + else + ed_num:set("value", tonumber(cur) or 0) + ed_num:set("enabled", 1) + ed_str:set("enabled", 0) + end + ed_apply:set("enabled", 1) + del_btn:set("enabled", m.nutrient_i and 1 or 0) +end + +tree_node:on(bui.EV_ACTIVATE, tree_select) +tree_node:on(bui.EV_CHANGE, tree_select) + +local function refresh_undo_buttons() + undo_btn:set("enabled", #undo_stack > 0 and 1 or 0) + redo_btn:set("enabled", #redo_stack > 0 and 1 or 0) +end + +local function push_undo() + table.insert(undo_stack, {nutrients = deepcopy(nutrients), curves = deepcopy(curves)}) + redo_stack = {} + refresh_undo_buttons() +end + +local function apply_tree_edit() + if not sel_id then return end + local m = item_meta[sel_id] + if not m or m.kind == "group" or not m.container then return end + local v + if m.kind == "string" then + v = ed_str:get("text") + if not v then return end + else + v = tonumber(ed_num:get("value")) + if not v then return end + end + push_undo() + m.container[m.key] = v + after_model_change() + status(("Applied %s = %s"):format(m.label, tostring(v))) +end +ed_apply:on(bui.EV_CLICK, apply_tree_edit) +ed_str:on(bui.EV_SUBMIT, apply_tree_edit) + +add_btn:on(bui.EV_CLICK, function() + push_undo() + nutrients[#nutrients + 1] = { + name = "New Nutrient", ph_min = 5.0, ph_max = 8.0, color = {0.40, 0.50, 0.40}} + after_model_change() + status("Added nutrient (" .. #nutrients .. " total)") +end) + +del_btn:on(bui.EV_CLICK, function() + local m = sel_id and item_meta[sel_id] + local idx = m and m.nutrient_i + if not idx or not nutrients[idx] then + status("Select a nutrient row (or a field inside one) to delete") + return + end + push_undo() + local name = nutrients[idx].name + table.remove(nutrients, idx) + clear_editor() + after_model_change() + status("Deleted " .. name .. " (" .. #nutrients .. " left)") +end) + +local function undo_action() + if #undo_stack == 0 then return end + table.insert(redo_stack, {nutrients = deepcopy(nutrients), curves = deepcopy(curves)}) + local s = table.remove(undo_stack) + nutrients, curves = s.nutrients, s.curves + clear_editor() + after_model_change() + refresh_undo_buttons() + sync_curve_sliders() + status("Undo") +end + +local function redo_action() + if #redo_stack == 0 then return end + table.insert(undo_stack, {nutrients = deepcopy(nutrients), curves = deepcopy(curves)}) + local s = table.remove(redo_stack) + nutrients, curves = s.nutrients, s.curves + clear_editor() + after_model_change() + refresh_undo_buttons() + sync_curve_sliders() + status("Redo") +end + +undo_btn:on(bui.EV_CLICK, undo_action) +redo_btn:on(bui.EV_CLICK, redo_action) + +-- ============================================================ +-- TAB SWITCHING +-- ============================================================ + +-- Pages are children of the tabs node: tabs_arrange packs the current child +-- below the bar (hiding the rest) and tabs_select raises EV_CHANGE. Pages +-- still need pack geometry to count as managed (drawn) children. +for _, p in ipairs({chart_page, table_page}) do + tabs:add(p) + p:geom(bui.GEOM_PACK, {side = 0, expand = 1, fill = 3}) +end +tabs:set("value", 0) + +tabs:on(bui.EV_CHANGE, function(_, which) + if (which or tabs:get("value") or 0) + 1 == 1 then + draw() -- hidden canvases keep stale items; refresh on the way back + status("Chart tab: edit the fields below; the graph redraws live.") + else + if tree_dirty then build_data_tree(); tree_dirty = false end + status("Table tab: expand nutrients/curves, select a leaf, Apply.") end - curves.macro.center, curves.macro.spread = DEFAULT_CURVES.macro.center, DEFAULT_CURVES.macro.spread - curves.micro.center, curves.micro.spread = DEFAULT_CURVES.micro.center, DEFAULT_CURVES.micro.spread - sliders[1]:set("value", curves.macro.center) - sliders[2]:set("value", curves.macro.spread) - sliders[3]:set("value", curves.micro.center) - sliders[4]:set("value", curves.micro.spread) - draw() - status("Reset to default chart.") end) -col_c:add(reset_btn) -reset_btn:geom(bui.GEOM_PACK, {fill = 1, pady0 = 6}) -- ============================================================ -- MAIN -- ============================================================ print("Soil pH and Nutrient Availability") -print("Edit nutrient ranges (number fields) and curves (sliders);\nthe canvas graph redraws live.\n") +print("Chart tab: number fields + sliders | Table tab: edit the same data\nas a Lua table (tree view, Apply, add/delete, undo/redo).\n") draw() +build_data_tree() local ctx = bui.Context(root) local win = bui.Window(ctx, "Soil pH and Nutrient Availability", WIN_W, WIN_H)