+
+
A bigger Lua program: the interactive table editor (bindings/lua/examples/lua_table_editor.lua)
+
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
@@ -2160,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
@@ -2173,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
diff --git a/bindings/lua/README.md b/bindings/lua/README.md
index 8e8288d..2a954c4 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
@@ -105,4 +138,12 @@ ctx:key("a", "a", 0)
# 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; a Table tab edits
+ # the same data as a Lua table — tree
+ # view, Apply, add/delete, undo/redo)
```
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")
diff --git a/bindings/lua/examples/ph_nutrients.lua b/bindings/lua/examples/ph_nutrients.lua
new file mode 100644
index 0000000..7026a00
--- /dev/null
+++ b/bindings/lua/examples/ph_nutrients.lua
@@ -0,0 +1,780 @@
+-- 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.
+-- 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
+--
+-- 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}},
+}
+
+-- ============================================================
+-- 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 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
+-- ============================================================
+
+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})
+
+-- 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 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})
+
+local function status(msg)
+ status_node:set("text", msg)
+end
+
+-- 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 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
+-- ============================================================
+
+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
+
+-- ============================================================
+-- DRAWING
+-- ============================================================
+
+draw = function()
+ 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) / 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)
+ 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
+
+-- ============================================================
+-- 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)
+ 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)
+
+local sep = bui.Node("separator")
+chart_page:add(sep)
+sep:geom(bui.GEOM_PACK, {side = 0, fill = 1})
+
+local ctrl_row = bui.Node("row")
+chart_page: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 min_fields, max_fields = {}, {}
+
+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(fmin:get("value") or n.ph_min, PH_MIN, n.ph_max)
+ fmin:set("value", v)
+ n.ph_min = v
+ after_chart_edit()
+ 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(fmax:get("value") or n.ph_max, n.ph_min, PH_MAX)
+ fmax:set("value", v)
+ n.ph_max = v
+ after_chart_edit()
+ 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)
+
+local col_b = make_column(ctrl_row)
+col_b:add(bui.Node("separator"))
+
+-- 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)
+ 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] = 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)
+ sl:geom(bui.GEOM_PACK, {fill = 1})
+ return sl
+end
+
+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"),
+ 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)
+
+-- 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, 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 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
+end)
+
+-- ============================================================
+-- MAIN
+-- ============================================================
+
+print("Soil pH and Nutrient Availability")
+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)
+
+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/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..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;
}
@@ -748,6 +769,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 +795,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 +812,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 +840,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/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,
diff --git a/website/src/content/docs/lua/overview.mdx b/website/src/content/docs/lua/overview.mdx
index 97ecafe..e05510d 100644
--- a/website/src/content/docs/lua/overview.mdx
+++ b/website/src/content/docs/lua/overview.mdx
@@ -42,9 +42,22 @@ 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
-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