diff --git a/CHANGELOG.md b/CHANGELOG.md index 35f50b2..9e003ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ All notable changes to **blenderui** (BUI). The format follows moves or the text changes); Blender's own fields keep Blender's solid caret. The window wakes only for the next toggle, so an idle window still sleeps. ### Fixed +- **Lua bindings** set_menus on menubar fix; ctx:free() no-ops fix; widget tests - **Browser-look text fields** take the CSS `color` and `font-size` the field itself is given (one that differs from its parent's); without them they keep Chrome's black 13.33px, as before. - **Browser-look form controls** drew their text with Blender's UI drop shadow, which made it look heavy next to the page's text. They draw none now, as in a browser. - **macOS:** the npm `bui-runtime` (and the release build of the addon) failed to link with `Undefined symbols: ___isPlatformVersionAtLeast`: the diff --git a/README.md b/README.md index a8e40c4..c1ebe4d 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 27 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 @@ -1439,13 +1507,19 @@ bui.run() `custom`, `canvas`, `list`, `tree`, `gridview`, `ramp`, `curve`, `normal`, `histogram`, `radio`, `meter`, `filebrowser`. -**Examples** (26 scripts, `bindings/lua/examples/`): +**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** (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` @@ -1455,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 | | -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | @@ -1480,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 @@ -1488,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 @@ -1533,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): @@ -1587,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 @@ -1681,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 @@ -1754,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 | | ---------------------------- | -------------------------------- | -------------------------------------------------------- | ---------------------- | @@ -1794,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. @@ -1827,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. @@ -1863,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 | | --------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | -------- | @@ -1882,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 | | ------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | @@ -1900,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 | | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | @@ -1923,13 +1997,13 @@ 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` | --- # 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. @@ -2011,12 +2085,12 @@ 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. -## 42. FAQ +## 43. FAQ
Do I need Blender installed? @@ -2093,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 @@ -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