From e717b1192c6ab98cefa63b1e183f8a34e8b85e52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20I=C3=B1igo=20Blasco?= Date: Sun, 28 Jun 2026 03:22:41 +0200 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20Transform=20Editor=20toolbox=20plug?= =?UTF-8?q?in=20=E2=80=94=20PJ3=20Custom=20Series=20port?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports PlotJuggler 3's Custom Series / Function Editor as a data-only toolbox plugin. The UI mirrors PJ3: a Single Function tab (drag-drop source, additional sources with a primary-source radio in the first column, function body), a Batch Functions tab, and a function library — an interactive browser sub-panel and Import / Export of the library to a JSON file. Data-only: the plugin never runs a script itself. On Save it hands the host a self-describing Luau class (N inputs -> M outputs) via `pj.data_processors.v1`, run live as a DerivedEngine node; the live preview is produced host-side too (ephemeral transform + validate). There is no local Lua engine in the plugin. Requires plotjuggler_sdk 0.14.0 — the data-processor script API, the interactive sub-panel + table radio-column dialog additions (#136), and the save-file picker used by the library Export (#138). The extern/plotjuggler_core submodule is pinned to an SDK build carrying both. Contents: - transform_editor_plugin.cpp — dialog + toolbox: drag-drop, primary-source radio, function library browser, Import/Export, host-side preview, name-prompt + overwrite on Save, createTransform on Save. - transform_editor_dialog.ui — PJ3-style UI. - manifest.json / CMakeLists.txt / conanfile.py — registration; SDK 0.14.0. Pairs with the host side: PlotJuggler/pj4#252. --- CMakeLists.txt | 1 + SDK_VERSION | 2 +- extern/plotjuggler_core | 2 +- toolbox_transform_editor/CMakeLists.txt | 41 + toolbox_transform_editor/conanfile.py | 22 + toolbox_transform_editor/manifest.json | 22 + .../transform_editor_dialog.ui | 618 ++++++ .../transform_editor_plugin.cpp | 1720 +++++++++++++++++ 8 files changed, 2426 insertions(+), 2 deletions(-) create mode 100644 toolbox_transform_editor/CMakeLists.txt create mode 100644 toolbox_transform_editor/conanfile.py create mode 100644 toolbox_transform_editor/manifest.json create mode 100644 toolbox_transform_editor/transform_editor_dialog.ui create mode 100644 toolbox_transform_editor/transform_editor_plugin.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 24199c4..16bb9d2 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -49,6 +49,7 @@ if(TARGET plotjuggler_sdk::plugin_sdk) add_subdirectory(toolbox_quaternion) add_subdirectory(toolbox_reactive_scripts_editor) add_subdirectory(toolbox_mosaico) + add_subdirectory(toolbox_transform_editor) return() endif() diff --git a/SDK_VERSION b/SDK_VERSION index d9df1bb..a803cc2 100644 --- a/SDK_VERSION +++ b/SDK_VERSION @@ -1 +1 @@ -0.11.0 +0.14.0 diff --git a/extern/plotjuggler_core b/extern/plotjuggler_core index 7975095..feba626 160000 --- a/extern/plotjuggler_core +++ b/extern/plotjuggler_core @@ -1 +1 @@ -Subproject commit 7975095246e2412db16008bfdacf42feb3744450 +Subproject commit feba626e4c0462e50cd32c50ee2c532f05e68770 diff --git a/toolbox_transform_editor/CMakeLists.txt b/toolbox_transform_editor/CMakeLists.txt new file mode 100644 index 0000000..e4db9dd --- /dev/null +++ b/toolbox_transform_editor/CMakeLists.txt @@ -0,0 +1,41 @@ +find_package(nlohmann_json REQUIRED) + +include(${CMAKE_CURRENT_LIST_DIR}/../cmake/EmbedUi.cmake) +include(${CMAKE_CURRENT_LIST_DIR}/../cmake/EmbedManifest.cmake) + +add_library(toolbox_transform_editor_plugin SHARED transform_editor_plugin.cpp) + +target_compile_features(toolbox_transform_editor_plugin PRIVATE cxx_std_20) +target_compile_options(toolbox_transform_editor_plugin PRIVATE ${PJ_WARNING_FLAGS}) + +target_link_libraries(toolbox_transform_editor_plugin PRIVATE + plotjuggler_sdk::plugin_sdk + nlohmann_json::nlohmann_json +) + +# When built as part of the PJ4 app (not standalone), link pj_dialog_engine_qt +# to get access to ChartPreviewWidget for the live preview chart. +if(TARGET pj_dialog_engine_qt) + target_link_libraries(toolbox_transform_editor_plugin PRIVATE pj_dialog_engine_qt) + target_compile_definitions(toolbox_transform_editor_plugin PRIVATE PJ_HAS_CHART_PREVIEW=1) +endif() + +target_include_directories(toolbox_transform_editor_plugin PRIVATE + ${CMAKE_CURRENT_BINARY_DIR}/generated +) + +pj_embed_ui(toolbox_transform_editor_plugin + UI_FILE ${CMAKE_CURRENT_SOURCE_DIR}/transform_editor_dialog.ui + HEADER ${CMAKE_CURRENT_BINARY_DIR}/generated/transform_editor_dialog_ui.hpp + VAR_NAME kTransformEditorDialogUi +) + +pj_embed_manifest(toolbox_transform_editor_plugin + HEADER ${CMAKE_CURRENT_BINARY_DIR}/generated/transform_editor_manifest.hpp + VAR_NAME kTransformEditorManifest +) + +pj_emit_plugin_manifest(toolbox_transform_editor_plugin + FAMILY toolbox + MANIFEST_FILE ${CMAKE_CURRENT_SOURCE_DIR}/manifest.json +) diff --git a/toolbox_transform_editor/conanfile.py b/toolbox_transform_editor/conanfile.py new file mode 100644 index 0000000..a260ccb --- /dev/null +++ b/toolbox_transform_editor/conanfile.py @@ -0,0 +1,22 @@ +import os +from conan import ConanFile + + +_SDK_VERSION = ( + open(os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir, "SDK_VERSION")) + .read() + .strip() +) + + +class ToolboxTransformEditorConan(ConanFile): + name = "toolbox_transform_editor" + version = "0" + settings = "os", "compiler", "build_type", "arch" + generators = "CMakeDeps", "CMakeToolchain" + requires = ( + f"plotjuggler_sdk/{_SDK_VERSION}", + "gtest/1.17.0", + "nlohmann_json/3.12.0", + ) + default_options = {"*:shared": False} diff --git a/toolbox_transform_editor/manifest.json b/toolbox_transform_editor/manifest.json new file mode 100644 index 0000000..82b14ae --- /dev/null +++ b/toolbox_transform_editor/manifest.json @@ -0,0 +1,22 @@ +{ + "id": "toolbox-transform-editor", + "name": "Transform Editor", + "version": "1.0.0", + "description": "Create named derived time series using Lua functions. Equivalent to the Custom Series editor in PJ3.", + "author": "PlotJuggler Team", + "publisher": "PlotJuggler", + "website": "https://github.com/PlotJuggler/pj-official-plugins", + "repository": "https://github.com/PlotJuggler/pj-official-plugins", + "license": "MIT", + "icon_url": "", + "category": "toolbox", + "tags": [ + "lua", + "transform", + "derived", + "custom", + "toolbox", + "hidden" + ], + "min_plotjuggler_version": "4.0.0" +} diff --git a/toolbox_transform_editor/transform_editor_dialog.ui b/toolbox_transform_editor/transform_editor_dialog.ui new file mode 100644 index 0000000..13949b5 --- /dev/null +++ b/toolbox_transform_editor/transform_editor_dialog.ui @@ -0,0 +1,618 @@ + + + TransformEditorDialog + + + 480420 + + + Transform Editor + + + + + + + 0 + + +QTabWidget::pane { + border: 1px solid #888; + top: -1px; + border-radius: 2px; + border-top-left-radius: 0px; +} +QTabBar::tab { + padding: 4px 12px; + border: 1px solid #888; + border-bottom: none; + border-top-left-radius: 2px; + border-top-right-radius: 2px; + min-width: 10ex; +} +QTabBar::tab:selected { + background: palette(window); +} +QTabBar::tab:!selected { + background: transparent; + border-bottom: 1px solid #888; +} + + + + + + Single Function + + 0 + 0 + 0 + 0 + + + + + QFrame::NoFrame + true + + + 0 + 0 + 0 + 0 + + + Qt::Vertical + false + + + 080 + + 0 + 0 + 0 + 0 + + + QFrame::StyledPanel + QFrame::Sunken + + + + + + true + + QPlainTextEdit { background-color: #2b2b2b; color: #f0f0f0; border: 1px solid #555; } + + DejaVu Sans Mono10 + + + + + + + 10 + + + + + 2950 + + 8 + + + + + + + 025 + 75true + + <html><head/><body><p>Name of the new time series.</p></body></html> + + New series name: + + + + + 025 + name the new timeseries + + + + + 5016777215 + Help + QPushButton { border: 1px solid #888; padding: 2px 6px; } + + + + + + + + + 0 + 0 + + + 025 + + <html><head/><body><p><span style=" font-weight:700;">Timeseries</span>: drag &amp; drop the input timeseries here</p></body></html> + + false + + + + + 2424 + 2424 + + <html><head/><body><p>Remove the selected time series (or all, if none selected).</p></body></html> + + + + :/resources/svg/trash.svg:/resources/svg/trash.svg + + 2222 + true + + + + + + + + + 040 + + <html><head/><body><p>Drag &amp; drop timeseries here. Pick the radio to set which one is <span style=" font-weight:600;">value</span>.</p></body></html> + + + QTableWidget { border: 1px solid #888; } + + QAbstractItemView::SelectRows + QAbstractItemView::NoEditTriggers + false + false + + + + + + + + + + + + + 2950 + + 0 + 0 + 0 + 0 + + + + + 11 + + + + <html><head/><body><p><span style=" font-weight:700;">Global:</span> this code runs once before &quot;function&quot;. Initialize global, persistent variables here.</p></body></html> + + false + + + + + Qt::Horizontal + 4020 + + + + + + + + + + 0 + 0 + 0 + 0 + + + + <html><head/><body><p>Portion of code outside the function.</p><p>Useful to add global variables.</p></body></html> + + + QPlainTextEdit { border: 1px solid #888; } + + Add your global variables here + Monospace11 + + + + + + + 75true + function( time, value ) + + + + + Qt::Horizontal + 4020 + + + + + Lua + true + + + + + Python + + + + + 3030 + 3030 + + <html><head/><body><p>Import functions from a XML library.</p></body></html> + + + + :/resources/svg/import.svg:/resources/svg/import.svg + + 2626 + true + + + + + 3030 + 3030 + + <html><head/><body><p>Export functions from a XML library.</p></body></html> + + + + :/resources/svg/export.svg:/resources/svg/export.svg + + 2626 + true + + + + + 3030 + 3030 + + <html><head/><body><p>Save the current function in the library.</p></body></html> + + + + :/resources/svg/save.svg:/resources/svg/save.svg + + 2626 + true + + + + + 3030 + 3030 + + <html><head/><body><p>Browse and manage available functions.</p></body></html> + + + + :/resources/svg/apps_box.svg:/resources/svg/apps_box.svg + + 2626 + true + + + + + + + + <html><head/><body><p>Write your function implementation here. </p><p>It <span style=" font-weight:600;">must</span> return a new value.</p></body></html> + + + QPlainTextEdit { border: 1px solid #888; } + + Write your function here + Monospace11 + + + + + + + + + + + + + + + + + + + + + + + + Batch Functions + + + + 10 + + + + + 10 + + + Input time series. Select them using the filter box. + true + + + + + + + Filter: + + + + + + + + + + 8 + + + Contains + + + + + Wildcard + true + + + + + RegExp + + + + + Qt::Horizontal + 4020 + + + + + + + + QListWidget { border: 1px solid #888; } + + + + + + + 090 + 16777215140 + true + + QPlainTextEdit { background-color: #2b2b2b; color: #f0f0f0; border: 1px solid #555; } + + DejaVu Sans Mono10 + + + + + + + + + 2950 + + 10 + 0 + 0 + 0 + + + + + <html><head/><body><p>Apply the Lua function below to <span style=" font-weight:600;">all </span>the series on the left table.<br/>Specify the prefix/suffix to be added to the original name.</p></body></html> + + true + + + + + Qt::Horizontal + + + + + + + + + Prefix + + + + + Suffix + true + + + + + 025 + prefix / suffix to add to the names + + + + + + + + + 11 + + + + <html><head/><body><p><span style=" font-weight:700;">Global:</span> this code runs once before &quot;function&quot;. Initialize global, persistent variables here.</p></body></html> + + false + + + + + Qt::Horizontal + 4020 + + + + + + + + + + 0 + 0 + 0 + 0 + + + + <html><head/><body><p>Portion of code outside the function.</p><p>Useful to add global variables.</p></body></html> + + + QPlainTextEdit { border: 1px solid #888; } + + Add your global variables here + Monospace11 + + + + + + + 75true + function( time, value ) + + + + + Qt::Horizontal + 4020 + + + + + Lua + true + + + + + Python + + + + + + + + <html><head/><body><p>Write your function implementation here. </p><p>It <span style=" font-weight:600;">must</span> return a new value.</p></body></html> + + + QPlainTextEdit { border: 1px solid #888; } + + Write your function here + Monospace11 + + + + + + + + + + + + + + + + + + + + + + + + Qt::Horizontal + 4020 + + + + + false + Create New Time Series + true + + + + + Close + + + + + + + + diff --git a/toolbox_transform_editor/transform_editor_plugin.cpp b/toolbox_transform_editor/transform_editor_plugin.cpp new file mode 100644 index 0000000..86883cd --- /dev/null +++ b/toolbox_transform_editor/transform_editor_plugin.cpp @@ -0,0 +1,1720 @@ +// SPDX-License-Identifier: MPL-2.0 +// +// Transform Editor toolbox plugin for PlotJuggler 4. +// Ports the PJ3 "Custom Series" / Function Editor. Identical UI to PJ3. +// Motor: pj.data_processors.v1 — onSave hands the host a self-describing Luau class +// (N inputs -> M outputs) run live as a DerivedEngine node. Requires SDK >= 0.12.0. +// Preview uses createEphemeralTransform (SDK 0.12+) — no local Lua runtime. + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "transform_editor_dialog_ui.hpp" +#include "transform_editor_manifest.hpp" + +namespace { + +// Help dialog UI (cloned from the native TransformEditorHelp.ui). Shown via +// WidgetData::requestSubDialog when either Help button is clicked — same content +// for both the Single Function and Batch Functions tabs. +constexpr const char* kHelpDialogUi = R"PJHELP( + + TransformEditorHelp + + 00800600 + Help + + + + true + + <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0//EN" "http://www.w3.org/TR/REC-html40/strict.dtd"> +<html><head><meta name="qrichtext" content="1" /><style type="text/css"> +p, li { white-space: pre-wrap; } +</style></head><body style=" font-family:'Ubuntu'; font-size:11pt; font-weight:400; font-style:normal;"> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:14pt; font-weight:600;">Transform editor Help</span></p> +<p style="-qt-paragraph-type:empty; margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px; font-size:14pt; font-weight:600;"><br /></p> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">The transform editor uses the scripting language Lua. You may want to read <a href="http://tylerneylon.com/a/learn-lua/"><span style=" text-decoration: underline; color:#0000ff;">Learn Lua in 15 minutes</span></a>.</p> +<p style="-qt-paragraph-type:empty; margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><br /></p> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">In practice, you don't need to know that much to use it in PlotJuggler.</p> +<p style="-qt-paragraph-type:empty; margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><br /></p> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">You should implement the body of a <span style=" font-style:italic; color:#204a87;">function(time, value),</span> that is invoked for each point. The returned value is the point [time, your_value]</p> +<p style="-qt-paragraph-type:empty; margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><br /></p> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:12pt; font-weight:600;">Single input, stateless functions:</span></p> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Example:</p> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-style:italic; color:#204a87;"> return (value * 2) + 1</span></p> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">or you can use the <a href="https://www.tutorialspoint.com/lua/lua_math_library.htm"><span style=" text-decoration: underline; color:#0000ff;">math library from Lua</span></a>:</p> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-style:italic; color:#204a87;"> return math.log(value)</span></p> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">The new point will have the same time of the original point. If you want to modify the timestamp, just return two values like this:</p> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-style:italic; color:#204a87;"> return time +1, (value * 2) </span></p> +<p style="-qt-paragraph-type:empty; margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><br /></p> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:12pt; font-weight:600;">Return multiple points at once:</span></p> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Since version 3.1, you may also return multiple values at once, using a Lua table:</p> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-style:italic; color:#204a87;"> -- Calculate t1,v1, t2, v2, etc...</span></p> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-style:italic; color:#204a87;"> return { {t1, v1}, {t2, v2} }</span></p> +<p style="-qt-paragraph-type:empty; margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><br /></p> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:12pt; font-weight:600;">Stateful functions:</span></p> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Some transformations require some form of &quot;memory&quot;, i.e. they need the previous value. To do this you may use the &quot;global variable&quot; textbox.</p> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">For instance, to compute the finite difference between two consecutive value, you would add this to the <span style=" font-weight:600;">Global Variables</span> textbox:</p> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-style:italic; color:#204a87;"> prev = nil</span></p> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">And this to the <span style=" font-weight:600;">Function() </span>textbox</p> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-style:italic; color:#204a87;"> if ( prev == nil ) then</span></p> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-style:italic; color:#204a87;"> prev = value</span></p> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-style:italic; color:#204a87;"> end</span></p> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-style:italic; color:#204a87;"> diff = value - prev</span></p> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-style:italic; color:#204a87;"> prev = value</span></p> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-style:italic; color:#204a87;"> return diff </span></p> +<p style="-qt-paragraph-type:empty; margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><br /></p> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:12pt; font-weight:600;">Multi input, single output:</span></p> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Select an additional time series and drop it in the table on the left side. Now your function definition will look like <span style=" font-style:italic; color:#204a87;">function(time,value,v1)</span></p> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Average of two time series:</p> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-style:italic; color:#204a87;"> return (value + v1) / 2</span></p></body></html> + + + + + + Qt::Horizontal + QDialogButtonBox::Close + + + + +)PJHELP"; + +// Function Library dialog UI (cloned from PJ3's functions_library.ui / the +// buttonLibraryBox box). Shown via WidgetData::requestSubPanel as a LIVE, +// interactive sub-panel: the search box filters as you type, the table selection +// drives the preview, and double-click / Use loads the function into the editor. +constexpr const char* kFunctionLibraryUi = R"PJLIB( + + FunctionsLibrary + + 00440470 + 360320 + Function Library + + + + Search + + + + + 75true + Function Preview: + + + + + QTableWidget { border: 1px solid #888; } + QAbstractItemView::SelectRows + QAbstractItemView::ExtendedSelection + QAbstractItemView::NoEditTriggers + false + false + + + + + 0100 + true + QPlainTextEdit { background-color: #ffffff; color: #000000; border: 1px solid #888; } + DejaVu Sans Mono10 + + + + + + + true + Ctrl+Click to select multiple + + + + + Qt::Horizontal + 4020 + + + + Use + + + + + + + +)PJLIB"; + +// "Save current function" name prompt (PJ3 parity: QInputDialog asking for the +// function name, prefilled with the current name). Shown as a modal sub-dialog; +// the host harvests `saveFunctionName` and emits subDialogAccepted on OK. +constexpr const char* kSaveNameUi = R"PJSAVE( + + SaveFunctionName + + 00360120 + Name of the Function + + + Name: + + + + + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + +)PJSAVE"; + +// Overwrite confirmation (PJ3 parity: warn when a function with the same name +// already exists). OK = overwrite, Cancel = abort. +constexpr const char* kOverwriteUi = R"PJOVW( + + OverwriteFunction + + 00400130 + Warning + + + + true + A function with the same name exists already in the list of saved functions. + +Overwrite it? + + + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + +)PJOVW"; + +// --------------------------------------------------------------------------- +// Snippet +// --------------------------------------------------------------------------- + +struct Snippet { + std::string name; + std::string global_code; + std::string function_body; +}; + +std::filesystem::path snippetLibraryPath() { + return PJ::sdk::userDataDir() / "toolbox_transform_editor" / "snippets.json"; +} + +// Write the snippet library as a JSON array to `path`, creating parent dirs. +// Returns false if the directory can't be created or the file can't be written +// (callers that surface Export feedback rely on this; autosave ignores it). +// Shared by the fixed-location persistence and the user-chosen Export target. +bool saveSnippetsToPath(const std::vector& snippets, const std::filesystem::path& path) { + try { + std::filesystem::create_directories(path.parent_path()); + nlohmann::json j = nlohmann::json::array(); + for (const auto& s : snippets) { + j.push_back({{"name", s.name}, {"global_code", s.global_code}, {"function_body", s.function_body}}); + } + std::ofstream out(path); + if (!out) { + return false; + } + out << j.dump(2); + return out.good(); + } catch (...) { + return false; + } +} + +void saveSnippetsToDisk(const std::vector& snippets) { + saveSnippetsToPath(snippets, snippetLibraryPath()); // best-effort autosave; failures are non-fatal +} + +// Default snippets ported from PJ3's default.snippets.xml +std::vector defaultSnippets() { + return { + {"backward_difference_derivative", "prevX = 0\nprevY = 0\nis_first = true", + "if (is_first) then\n is_first = false\n prevX = time\n prevY = value\nend\n\ndx = time - prevX\ndy = value " + "- prevY\nprevX = time\nprevY = value\n\nreturn dy/dx"}, + {"central_difference_derivative", + "firstX = 0\nfirstY = 0\nis_first = true\nsecondX = 0\nsecondY = 0\nis_second = false", + "if (is_first) then\n is_first = false\n is_second = true\n firstX = time\n firstY = value\nend\n\nif " + "(is_second) then\n is_second = false\n secondX = time\n secondY = value\nend\n\ndx = time - firstX\ndy = " + "value - firstY\nfirstX = secondX\nfirstY = secondY\nsecondX = time\nsecondY = value\n\nreturn dy/dx"}, + {"average_two_curves", "", "return (value+v1)/2"}, + {"integral", "prevX = 0\nintegral = 0\nis_first = true", + "if (is_first) then\n is_first = false\n prevX = time\nend\n\ndx = time - prevX\nprevX = time\nintegral = " + "integral + value*dx\n\nreturn integral"}, + {"rad_to_deg", "", "return value*180/3.14159"}, + {"remove_offset", "is_first = true\nfirst_value = 0", + "if (is_first) then\n is_first = false\n first_value = value\nend\n\nreturn value - first_value"}, + {"quat_to_roll", "", + "w = value\nx = v1\ny = v2\nz = v3\n\ndcm21 = 2 * (w * x + y * z)\ndcm22 = w*w - x*x - y*y + z*z\n\nroll = " + "math.atan2(dcm21, dcm22)\n\nreturn roll"}, + {"quat_to_pitch", "", + "w = value\nx = v1\ny = v2\nz = v3\n\ndcm20 = 2 * (x * z - w * y)\n\npitch = math.asin(-dcm20)\n\nreturn pitch"}, + {"quat_to_yaw", "", + "w = value\nx = v1\ny = v2\nz = v3\n\ndcm10 = 2 * (x * y + w * z)\ndcm00 = w*w + x*x - y*y - z*z\n\nyaw = " + "math.atan2(dcm10, dcm00)\n\nreturn yaw"}, + }; +} + +// Read a snippet library (JSON array) from `path`. Returns the parsed snippets +// (possibly empty, for a "[]" library), or nullopt when the file cannot be +// opened or does not hold a JSON array. The nullopt vs empty distinction lets +// callers tell "no readable library" apart from "an explicitly empty one". +// Shared by the fixed-location load and the user-chosen Import source. +std::optional> loadSnippetsFromPath(const std::filesystem::path& path) { + std::ifstream in(path); + if (!in) { + return std::nullopt; + } + std::stringstream buf; + buf << in.rdbuf(); + auto j = nlohmann::json::parse(buf.str(), nullptr, false); + if (!j.is_array()) { + return std::nullopt; + } + std::vector result; + for (auto& item : j) { + if (!item.is_object()) { + continue; + } + result.push_back( + {item.value("name", std::string{}), item.value("global_code", std::string{}), + item.value("function_body", std::string{})}); + } + return result; +} + +// The persisted library, or the built-in defaults when none can be read yet — +// a missing, unreadable, or corrupt file all fall back to the defaults (so a +// transient read failure never silently presents an empty library). +std::vector loadSnippetsFromDisk() { + if (auto loaded = loadSnippetsFromPath(snippetLibraryPath())) { + return std::move(*loaded); + } + return defaultSnippets(); +} + +// The output-name field doubles as a comma-separated list: "roll,pitch,yaw" declares +// three output topics and the body must `return r, p, q` (M values, positional). +// Whitespace around each name is trimmed; empty entries drop. One name => one output +// (the common case). +inline std::vector splitOutputNames(const std::string& field) { + std::vector names; + std::size_t start = 0; + while (start <= field.size()) { + const std::size_t comma = field.find(',', start); + const std::size_t end = (comma == std::string::npos) ? field.size() : comma; + std::string name = field.substr(start, end - start); + const std::size_t b = name.find_first_not_of(" \t"); + const std::size_t e = name.find_last_not_of(" \t"); + if (b != std::string::npos) { + names.push_back(name.substr(b, e - b + 1)); + } + if (comma == std::string::npos) { + break; + } + start = comma + 1; + } + return names; +} + +// Build a complete self-describing Luau FILTER CLASS the host can compile and run +// live as a DerivedEngine node (via createTransform). The user's global code runs +// once per instance inside a factory closure, so its locals persist across calls +// (PJ3 global-variable semantics); the body becomes the per-sample function with +// `time`/`value` (and `v1..vN` for the additional sources) in scope. +// +// `num_extra` is the count of additional sources: the body function takes +// `(time, value, v1, …, v)`, matching the host's MIMO calculate contract +// (calculate(self, t, v, v1..vN-1) — see pj_scripting FILTER_CLASS.md). `:calculate` +// forwards its args with `...`, so any arity works; the named params just give the +// body the v1..vN identifiers. Output count is decided host-side by the `outputs` +// passed to createTransform — `:calculate` returns the body's results unchanged +// (MULTRET), so a body that `return`s M values feeds M output topics. +inline std::string buildTransformScript( + const std::string& id, const std::string& name, const std::string& global_code, const std::string& body, + std::size_t num_extra, const std::string& language = "luau") { + std::string params = "time, value"; + for (std::size_t k = 0; k < num_extra; ++k) { + params += ", v" + std::to_string(k + 1); + } + // The header declares the backend; the host's inferTransformBackend reads it. + // PJ4 only has a Luau backend today, so "python" is accepted by the UI but the + // host rejects it on create/validate (no Python backend yet). + std::string src = "-- pj-script: " + language + "\n"; + src += "local function _pj_make()\n"; + src += global_code + "\n"; + src += " return function(" + params + ")\n"; + src += body + "\n"; + src += " end\n"; + src += "end\n"; + src += "local T = { id = \"" + id + "\", name = \"" + name + "\", output = \"double\" }\n"; + src += "T.__index = T\n"; + src += "function T.create(_) return setmetatable({ fn = _pj_make() }, T) end\n"; + src += "function T:calculate(t, v, ...) return self.fn(t, v, ...) end\n"; + src += "return T\n"; + return src; +} + +// --------------------------------------------------------------------------- +// TransformEditorDialog +// --------------------------------------------------------------------------- + +class TransformEditorDialog : public PJ::DialogPluginTyped { + using PJ::DialogPluginTyped::onValueChanged; + + public: + std::string manifest() const override { + return kTransformEditorManifest; + } + std::string ui_content() const override { + return kTransformEditorDialogUi; + } + + PJ::WidgetData buildWidgetData() { + PJ::WidgetData wd; + + // Single function tab — one table of inputs (drop target). Col 0 is the radio + // marking which row provides `value`; col 1 is the series path the trash button + // acts on; col 2 is the bound variable. The host emits row selection from the + // first text column (col 1 here, as col 0 hosts the radio widget and carries no + // text). The non-primary rows are v1, v2, … in row order. + wd.setDropTarget("tableSources"); + wd.setTableHeaders("tableSources", {"", "Input timeseries", "Var"}); + std::vector> rows; + rows.reserve(sources_.size()); + for (int i = 0; i < static_cast(sources_.size()); ++i) { + rows.push_back({"", sources_[static_cast(i)], variableName(i)}); + } + wd.setTableRows("tableSources", rows); + wd.setTableRadioColumn("tableSources", 0, primaryIndex()); + // Enabled only while a row is selected, so it can never delete the whole list + // on a single click when nothing is highlighted (and it disables again the + // moment the selection is cleared). + wd.setEnabled("pushButtonDeleteCurves", !selected_paths_.empty()); + wd.setText("nameLineEdit", output_name_); + + // Function signature reflects the inputs: `value` (the radio-selected row) plus + // v1..vN for the remaining series, so the user sees the identifier to reference + // for each one in the body. + const std::size_t num_extra = orderedExtras().size(); + std::string signature = "function( time, value"; + for (std::size_t i = 0; i < num_extra; ++i) { + signature += ", v" + std::to_string(i + 1); + } + signature += " )"; + wd.setText("labelFunction", signature); + + wd.setCodeContent("globalVarsText", global_code_).setCodeLanguage("globalVarsText", "lua"); + wd.setCodeContent("functionText", function_body_).setCodeLanguage("functionText", "lua"); + + // Function Library sub-panel (cloned from PJ3's buttonLibraryBox dialog). + // One-shot open/close commands, then live population while it is open. + if (emit_open_library_) { + wd.requestSubPanel(kFunctionLibraryUi); + emit_open_library_ = false; + } + if (emit_close_library_) { + wd.closeSubPanel(); + emit_close_library_ = false; + } + // Save-current-function dialogs (PJ3 parity). Name prompt is prefilled with the + // current name; the overwrite warning only appears for an existing name. + if (emit_save_name_dialog_) { + wd.setText("saveFunctionName", pending_save_name_); + wd.requestSubDialog(kSaveNameUi); + emit_save_name_dialog_ = false; + } + if (emit_save_confirm_dialog_) { + wd.requestSubDialog(kOverwriteUi); + emit_save_confirm_dialog_ = false; + } + if (library_open_) { + const std::vector names = filteredSnippetNames(); + wd.setTableHeaders("tableFunctions", {"Function"}); + std::vector> lib_rows; + lib_rows.reserve(names.size()); + for (const auto& n : names) { + lib_rows.push_back({n}); + } + wd.setTableRows("tableFunctions", lib_rows); + wd.setPlainText("previewPlainText", combinedSnippetText(library_selected_)); + } + + // Import / Export library buttons: the host drives the native file choosers. + // Import opens an "open" dialog and Export a "save as"; both report back via + // onFileSelected, routed by widget name. Complements the library browser above. + wd.setFilePicker("buttonLoadFunctions", "", "Snippet library (*.json)", "Import snippet library"); + wd.setSaveFilePicker("buttonSaveFunctions", "", "Snippet library (*.json)", "Export snippet library", "json"); + + // Single-tab validation terminal — same as PJ3's onUpdatePreview: list every + // blocking problem; the terminal HIDES once the function is valid, and Create is + // enabled iff there are none. validation_error_ is the host's real compile/run + // error (refreshPreview ran the script as an ephemeral transform). PJ4 keeps its + // Modify-by-name behaviour, so an already-existing name is NOT an error here. + std::string single_term; + if (output_name_.empty()) { + single_term += "- Missing name of the new time series.\n"; + } else if (std::find(sources_.begin(), sources_.end(), output_name_) != sources_.end()) { + single_term += "- The name of the new timeseries is the same of one of its inputs.\n"; + } + if (sources_.empty()) { + single_term += "- Missing source time series.\n"; + } + if (function_body_.empty()) { + single_term += "- Missing function body.\n"; + } + if (!validation_error_.empty()) { + single_term += "- " + validation_error_ + "\n"; + } + // A one-shot Import/Export status line shows in the terminal for this render + // (above any validation problems) and then clears on the next build. + if (!io_status_.empty()) { + single_term = single_term.empty() ? io_status_ : io_status_ + "\n" + single_term; + io_status_.clear(); + } + // Red fill on the name field when it's missing (PJ3 parity). + wd.setFieldValid("nameLineEdit", !output_name_.empty(), output_name_.empty() ? "Name is required" : ""); + wd.setPlainText("terminalPlainText", single_term); + // PJ3 parity: the chart and the terminal share the same area and are mutually + // exclusive — the graph only appears once the function is valid; while there are + // problems the terminal takes its place. + wd.setVisible("terminalPlainText", !single_term.empty()); + wd.setVisible("framePlotPreview", single_term.empty()); + + // Create button enabled + // Batch tab content (set first so the validation terminal + Create gating below + // see the current state). + wd.setListItems("listBatchSources", batch_filtered_sources_); + wd.setCodeContent("globalVarsTextBatch", batch_global_code_).setCodeLanguage("globalVarsTextBatch", "lua"); + wd.setCodeContent("functionTextBatch", batch_function_body_).setCodeLanguage("functionTextBatch", "lua"); + + // Batch validation terminal — same messages and behaviour as PJ3's + // onUpdatePreviewBatch (function_editor.cpp): list every blocking problem; the + // terminal HIDES entirely once the batch is valid, and Create is enabled iff + // there are no problems. batch_validation_error_ is the host's real compile/run + // error (validateBatch). + std::string batch_term; + if (batch_suffix_.empty()) { + batch_term += "- Missing prefix/suffix.\n"; + } + if (batch_selected_.empty()) { + batch_term += "- No input series.\n"; + } + if (batch_function_body_.empty()) { + batch_term += "- Missing function body.\n"; + } + if (!batch_validation_error_.empty()) { + batch_term += "- " + batch_validation_error_ + "\n"; + } + wd.setPlainText("terminalBatchPlainText", batch_term); + wd.setVisible("terminalBatchPlainText", !batch_term.empty()); + + // Create-enable: Single needs source+name+body; Batch needs an empty terminal + // (no blocking problems) — mirrors PJ3 enabling Create only when valid, so an + // empty prefix/suffix now blocks creation. + const bool can_create = (current_tab_ == 0) ? single_term.empty() : batch_term.empty(); + wd.setEnabled("pushButtonCreate", can_create); + // Create vs Modify (PJ3 parity): in explicit edit mode, or when the Single-tab + // output name already exists as a series, the button reads "Modify Time Series". + const bool is_modify = current_tab_ == 0 && (edit_mode_ || outputNameExists(output_name_)); + wd.setButtonText("pushButtonCreate", is_modify ? "Modify Time Series" : "Create New Time Series"); + // Lock the name while editing so a rename can't fork a new series (PJ3 parity). + wd.setEnabled("nameLineEdit", !edit_mode_); + + // Preview chart — always set (even if empty) so ChartPreviewWidget is + // instantiated from the start, matching PJ3 behaviour where the empty + // plot area is visible as soon as the editor opens. + wd.setChartSeries("framePlotPreview", preview_series_); + wd.setChartAutoZoom("framePlotPreview", autozoom_); + + return wd; + } + + bool onTextChanged(std::string_view name, std::string_view text) override { + if (name == "nameLineEdit") { + output_name_ = std::string(text); + return true; + } + // Live search filter in the function library box. + if (name == "searchLineEdit") { + library_search_ = std::string(text); + return true; + } + // Name typed in the "Save current function" prompt (harvested on OK). + if (name == "saveFunctionName") { + pending_save_name_ = std::string(text); + return true; + } + if (name == "lineEditTab2Filter") { + batch_filter_ = std::string(text); + updateBatchFilter(); + return true; + } + if (name == "suffixLineEdit") { + batch_suffix_ = std::string(text); + return true; + } + return false; + } + + bool onCodeChanged(std::string_view name, std::string_view text) override { + if (name == "globalVarsText") { + global_code_ = std::string(text); + validateSyntax(); + preview_dirty_ = true; + return true; + } + if (name == "functionText") { + function_body_ = std::string(text); + validateSyntax(); + preview_dirty_ = true; + return true; + } + if (name == "globalVarsTextBatch") { + batch_global_code_ = std::string(text); + batch_dirty_ = true; + return true; + } + if (name == "functionTextBatch") { + batch_function_body_ = std::string(text); + batch_dirty_ = true; + return true; + } + return false; + } + + bool onClicked(std::string_view name) override { + if (name == "pushButtonCreate") { + save_requested_ = true; + return true; + } + if (name == "pushButtonCancel") { + close_requested_ = true; + return true; + } + // Function library box (PJ3's buttonLibraryBox): open the interactive sub-panel. + if (name == "buttonLibraryBox") { + library_open_ = true; + emit_open_library_ = true; + library_search_.clear(); + library_selected_.clear(); + return true; + } + // "Use" in the library box: load the selected function(s) and dismiss the box. + // There is no Close button, so Use also doubles as the way to close it. + if (name == "useButton") { + loadSnippetsIntoEditor(library_selected_); + library_open_ = false; + emit_close_library_ = true; + return true; + } + // Synthetic event the host sends when the user dismisses the sub-panel. + if (name == "subPanelClosed") { + library_open_ = false; + return true; + } + // Save current function (PJ3 parity): prompt for a name (prefilled with the + // current one), then warn before overwriting an existing entry. Opens the + // name-prompt modal; the actual save happens on subDialogAccepted. + if (name == "buttonSaveCurrent") { + pending_save_name_ = output_name_; + save_stage_ = SaveStage::NamePrompt; + emit_save_name_dialog_ = true; + return true; + } + // A modal sub-dialog was accepted (OK). Drives the save state machine. + if (name == "subDialogAccepted") { + if (save_stage_ == SaveStage::NamePrompt) { + if (pending_save_name_.empty()) { + save_stage_ = SaveStage::None; + } else if (snippetExists(pending_save_name_)) { + save_stage_ = SaveStage::OverwriteConfirm; + emit_save_confirm_dialog_ = true; // ask before overwriting + } else { + doSaveSnippet(pending_save_name_); + save_stage_ = SaveStage::None; + } + } else if (save_stage_ == SaveStage::OverwriteConfirm) { + doSaveSnippet(pending_save_name_); // user confirmed overwrite + save_stage_ = SaveStage::None; + } + return true; + } + if (name == "pushButtonDeleteCurves") { + // Remove the selected row(s) only. The button is disabled when nothing is + // selected (see buildWidgetData), so an empty selection here is a no-op + // rather than a "clear everything" — guards against deleting the whole list + // on a stray click. The selection arrives as series paths (column-0 text). + if (selected_paths_.empty()) { + return true; + } + { + // Remember the primary's path so the radio follows the same series after + // the surviving rows renumber. + const std::string primary_path = primarySource(); + sources_.erase( + std::remove_if( + sources_.begin(), sources_.end(), + [this](const std::string& s) { + return std::find(selected_paths_.begin(), selected_paths_.end(), s) != selected_paths_.end(); + }), + sources_.end()); + selected_paths_.clear(); + // Keep the primary on the same series if it survived, else fall back to row 0. + primary_index_ = 0; + for (int i = 0; i < static_cast(sources_.size()); ++i) { + if (sources_[static_cast(i)] == primary_path) { + primary_index_ = i; + break; + } + } + } + if (sources_.empty()) { + primary_index_ = -1; + } + preview_dirty_ = true; + return true; + } + if (name == "pushButtonHelp") { + help_requested_ = true; + return true; + } + return false; + } + + bool onTick() override { + if (close_requested_) { + close_requested_ = false; + pending_close_ = true; + if (on_teardown_preview_) { + on_teardown_preview_(); + } + } + if (save_requested_) { + save_requested_ = false; + if (on_save_) { + on_save_(); + } + } + // Always refresh the preview every tick so a live/streaming source keeps + // moving in the chart (mirrors the FFT toolbox). refreshPreview re-reads the + // latest samples and re-applies the Lua function each time. + preview_dirty_ = false; + if (on_refresh_preview_) { + on_refresh_preview_(); + } + // Re-validate the batch function only when its fields changed (not every tick). + if (batch_dirty_) { + batch_dirty_ = false; + if (on_validate_batch_) { + on_validate_batch_(); + } + } + return true; + } + + std::string widget_data() override { + PJ::WidgetData wd = buildWidgetData(); + if (help_requested_) { + help_requested_ = false; + wd.requestSubDialog(kHelpDialogUi); + } + if (pending_close_) { + pending_close_ = false; + wd.requestClose("user_closed"); + } + return wd.toJson(); + } + + bool onSelectionChanged(std::string_view name, const std::vector& items) override { + if (name == "tableFunctions") { + library_selected_ = items; + return true; + } + if (name == "listBatchSources") { + batch_selected_ = items; + return true; + } + if (name == "tableSources") { + selected_paths_ = items; + return true; + } + return false; + } + + // Radio in the sources table: make `row` the series that provides `value`. + bool onTableRadioSelected(std::string_view name, int row) override { + if (name == "tableSources" && row >= 0 && row < static_cast(sources_.size())) { + primary_index_ = row; + preview_dirty_ = true; + return true; + } + return false; + } + + bool onToggled(std::string_view name, bool checked) override { + if (name == "radioButtonPrefix") { + batch_use_prefix_ = checked; + return true; + } + if (name == "radioButtonSuffix") { + batch_use_prefix_ = !checked; + return true; + } + // Single-tab script language (Lua / Python). Only "luau" actually runs today; + // selecting Python re-validates so the host's "unsupported language" surfaces. + if (name == "luaButton" && checked) { + language_ = "luau"; + validateSyntax(); + return true; + } + if (name == "pythonButton" && checked) { + language_ = "python"; + validateSyntax(); + return true; + } + if (name == "luaBatchButton" && checked) { + batch_language_ = "luau"; + batch_dirty_ = true; + return true; + } + if (name == "pythonBatchButton" && checked) { + batch_language_ = "python"; + batch_dirty_ = true; + return true; + } + return false; + } + + bool onTabChanged(std::string_view name, int index) override { + if (name == "tabWidget") { + current_tab_ = index; + return true; + } + return false; + } + + bool onItemsDropped(std::string_view widget_name, const std::vector& items) override { + if (widget_name == "tableSources") { + const bool was_empty = sources_.empty(); + for (const auto& item : items) { + if (std::find(sources_.begin(), sources_.end(), item) == sources_.end()) { + sources_.push_back(item); + } + } + // The first series dropped becomes the primary (`value`) by default. + if (was_empty && !sources_.empty()) { + primary_index_ = 0; + } + preview_dirty_ = true; + return true; + } + return false; + } + + bool onItemDoubleClicked(std::string_view name, int index) override { + // Double-click in the library box: load the current selection (or the + // double-clicked row if nothing is selected yet) and dismiss the box. The + // index is into the FILTERED list shown in the table, not into snippets_. + if (name == "tableFunctions") { + std::vector to_load = library_selected_; + if (to_load.empty()) { + const auto names = filteredSnippetNames(); + if (index >= 0 && index < static_cast(names.size())) { + to_load = {names[static_cast(index)]}; + } + } + loadSnippetsIntoEditor(to_load); + library_open_ = false; + emit_close_library_ = true; + return true; + } + return false; + } + + // Merge `incoming` into the library by name: an incoming snippet replaces a + // same-named one, otherwise it is appended. Existing snippets whose names are + // absent from `incoming` are kept — so Import is additive, never destructive. + void mergeSnippets(const std::vector& incoming) { + for (const auto& s : incoming) { + auto it = std::find_if(snippets_.begin(), snippets_.end(), [&](const Snippet& e) { return e.name == s.name; }); + if (it != snippets_.end()) { + *it = s; + } else { + snippets_.push_back(s); + } + } + } + + // Both Import and Export report the chosen path here (the host opens an "open" + // dialog for one and a "save as" for the other, per the widget's action). Each + // sets io_status_, a one-shot line the next widget_data() shows then clears. + bool onFileSelected(std::string_view widget_name, std::string_view path) override { + if (widget_name == "buttonLoadFunctions") { + // Import: merge the chosen library into the current one (additive, so the + // user's other snippets survive) and persist so it outlives a restart. + if (auto loaded = loadSnippetsFromPath(std::filesystem::path(path))) { + if (loaded->empty()) { + io_status_ = "The selected file contains no functions."; + } else { + mergeSnippets(*loaded); + saveSnippetsToDisk(snippets_); + io_status_ = "Imported " + std::to_string(loaded->size()) + " function(s)."; + } + } else { + io_status_ = "Could not read a function library from the selected file."; + } + return true; + } + if (widget_name == "buttonSaveFunctions") { + // Export: write the current library to the chosen file. + const bool ok = saveSnippetsToPath(snippets_, std::filesystem::path(path)); + io_status_ = + ok ? "Exported " + std::to_string(snippets_.size()) + " function(s)." : "Could not write the selected file."; + return true; + } + return false; + } + + void onAccepted(std::string_view /*json*/) override {} + + std::string saveConfig() const override { + nlohmann::json cfg; + cfg["output_name"] = output_name_; + cfg["global_code"] = global_code_; + cfg["function_body"] = function_body_; + cfg["sources"] = sources_; + cfg["primary_index"] = primaryIndex(); + // Back-compat mirror: an older editor reads source_series + extra_sources, so + // expose the primary as the source and the rest as extras in order. + cfg["source_series"] = primarySource(); + cfg["extra_sources"] = orderedExtras(); + return cfg.dump(); + } + + bool loadConfig(std::string_view json) override { + auto cfg = nlohmann::json::parse(json, nullptr, false); + if (cfg.is_discarded()) { + return false; + } + output_name_ = cfg.value("output_name", std::string{}); + global_code_ = cfg.value("global_code", std::string{}); + function_body_ = cfg.value("function_body", std::string{}); + sources_.clear(); + primary_index_ = -1; + if (cfg.contains("sources") && cfg["sources"].is_array()) { + sources_ = cfg["sources"].get>(); + primary_index_ = sources_.empty() ? -1 : cfg.value("primary_index", 0); + } else { + // Legacy format: one source_series (the primary) plus an extra_sources list. + const std::string src = cfg.value("source_series", std::string{}); + if (!src.empty()) { + sources_.push_back(src); + } + if (cfg.contains("extra_sources") && cfg["extra_sources"].is_array()) { + for (auto& e : cfg["extra_sources"].get>()) { + sources_.push_back(e); + } + } + primary_index_ = sources_.empty() ? -1 : 0; + } + // Loading a populated config = editing an existing series → MODIFY mode: the + // button reads "Modify" and the name is locked so the user can't accidentally + // fork a new series (PJ3 parity: editExistingPlot disables nameLineEdit). + edit_mode_ = !output_name_.empty(); + return true; + } + + void setOnSave(std::function cb) { + on_save_ = std::move(cb); + } + void setOnRefreshPreview(std::function cb) { + on_refresh_preview_ = std::move(cb); + } + void setOnTeardownPreview(std::function cb) { + on_teardown_preview_ = std::move(cb); + } + void setOnValidateBatch(std::function cb) { + on_validate_batch_ = std::move(cb); + } + void setSnippets(std::vector s) { + snippets_ = std::move(s); + } + + bool snippetExists(const std::string& snippet_name) const { + return std::any_of(snippets_.begin(), snippets_.end(), [&](const Snippet& s) { return s.name == snippet_name; }); + } + + // Save the current editor contents under `snippet_name` (insert or overwrite), + // then persist the library to disk. The name prompt + overwrite warning are + // handled by the caller (the save state machine), mirroring PJ3. + void doSaveSnippet(const std::string& snippet_name) { + Snippet sn{snippet_name, global_code_, function_body_}; + auto it = + std::find_if(snippets_.begin(), snippets_.end(), [&](const Snippet& s) { return s.name == snippet_name; }); + if (it != snippets_.end()) { + *it = sn; + } else { + snippets_.push_back(sn); + } + saveSnippetsToDisk(snippets_); + } + + // Snippet names matching the library box's search filter (case-insensitive + // substring), in library order. Shared by the table population and the + // double-click handler so the displayed-row index maps to the right snippet. + std::vector filteredSnippetNames() const { + auto lower = [](std::string v) { + for (char& c : v) { + c = static_cast(std::tolower(static_cast(c))); + } + return v; + }; + const std::string q = lower(library_search_); + std::vector out; + for (const auto& s : snippets_) { + if (q.empty() || lower(s.name).find(q) != std::string::npos) { + out.push_back(s.name); + } + } + return out; + } + + // Render the combined code of the given library functions (globals first, then + // the function body) — used both for the box preview and for what Use loads. + // Multiple selections are concatenated in order, mirroring PJ3's combine. + std::string combinedSnippetText(const std::vector& names) const { + std::string globals; + std::string body; + for (const auto& n : names) { + auto it = std::find_if(snippets_.begin(), snippets_.end(), [&](const Snippet& s) { return s.name == n; }); + if (it == snippets_.end()) { + continue; + } + if (!it->global_code.empty()) { + if (!globals.empty()) { + globals += "\n"; + } + globals += it->global_code; + } + if (!body.empty()) { + body += "\n"; + } + body += it->function_body; + } + if (globals.empty()) { + return body; + } + return globals + "\n\nfunction(time,value)\n" + body + "\nend"; + } + + // Load the selected library function(s) into the Single-tab editor (combined + // globals + body), then re-validate. Mirrors PJ3's "Use" / double-click. + void loadSnippetsIntoEditor(const std::vector& names) { + if (names.empty()) { + return; + } + std::string globals; + std::string body; + for (const auto& n : names) { + auto it = std::find_if(snippets_.begin(), snippets_.end(), [&](const Snippet& s) { return s.name == n; }); + if (it == snippets_.end()) { + continue; + } + if (!it->global_code.empty()) { + if (!globals.empty()) { + globals += "\n"; + } + globals += it->global_code; + } + if (!body.empty()) { + body += "\n"; + } + body += it->function_body; + } + global_code_ = globals; + function_body_ = body; + output_name_ = names.front(); // seed with the first; the user can rename + validateSyntax(); + } + + void setPreviewSeries(std::vector series) { + preview_series_ = std::move(series); + } + /// Set by the toolbox after each ephemeral-preview attempt: empty = script + /// accepted by the host (semaphore green); non-empty = the host's error + /// message (semaphore red + shown in the status box). + void setValidationError(std::string error) { + validation_error_ = std::move(error); + } + /// Ask the host to close this panel (e.g. after a successful Create — matches PJ3, + /// where creating the series closes the editor). Honoured on the next widget_data(). + void requestClose() { + pending_close_ = true; + } + /// Batch-tab counterpart of setValidationError (drives labelSemaphoreBatch). + void setBatchValidationError(std::string error) { + batch_validation_error_ = std::move(error); + } + /// Current batch function body / globals (read by the toolbox to validate). + const std::string& batchFunctionBody() const { + return batch_function_body_; + } + const std::string& batchGlobalCode() const { + return batch_global_code_; + } + /// Batch-create inputs read by the toolbox's onSave. + int currentTab() const { + return current_tab_; + } + const std::vector& batchSelected() const { + return batch_selected_; + } + const std::string& batchSuffix() const { + return batch_suffix_; + } + bool batchUsePrefix() const { + return batch_use_prefix_; + } + /// True once since the last consumeBatchDirty(); set on any batch field change. + bool consumeBatchDirty() { + const bool was = batch_dirty_; + batch_dirty_ = false; + return was; + } + void setAvailableSeries(std::vector series) { + all_series_ = std::move(series); + updateBatchFilter(); + } + + // The series that provides `value` (the radio-selected row), "" when no inputs. + std::string sourceSeries() const { + return primarySource(); + } + const std::string& outputName() const { + return output_name_; + } + const std::string& globalCode() const { + return global_code_; + } + const std::string& functionBody() const { + return function_body_; + } + const std::string& language() const { + return language_; + } + const std::string& batchLanguage() const { + return batch_language_; + } + // The v1, v2, ... inputs (non-primary rows, in row order). + std::vector extraSources() const { + return orderedExtras(); + } + + private: + // True if a series with output name `name` already exists (so a Create would + // replace it → the button shows "Modify"). Matches against the available-series + // list, both as a full path and as the last "/"-segment. + bool outputNameExists(const std::string& name) const { + if (name.empty()) { + return false; + } + for (const auto& s : all_series_) { + if (s == name) { + return true; + } + // `name` is the output TOPIC; the series is listed as "topic/field". + if (s.size() > name.size() && s.compare(0, name.size(), name) == 0 && s[name.size()] == '/') { + return true; + } + // `name` matches the last path segment (field). + const auto slash = s.find_last_of('/'); + if (slash != std::string::npos && s.substr(slash + 1) == name) { + return true; + } + } + return false; + } + + void validateSyntax() { + // Lightweight check: non-empty function body = tentatively OK + syntax_ok_ = !function_body_.empty(); + } + + void updateBatchFilter() { + batch_filtered_sources_.clear(); + for (const auto& s : all_series_) { + if (batch_filter_.empty() || s.find(batch_filter_) != std::string::npos) { + batch_filtered_sources_.push_back(s); + } + } + } + + // Clamped primary row: the checked radio, or row 0 when the stored index is + // stale (e.g. the primary row was just deleted), or -1 when there are no inputs. + int primaryIndex() const { + if (sources_.empty()) { + return -1; + } + if (primary_index_ < 0 || primary_index_ >= static_cast(sources_.size())) { + return 0; + } + return primary_index_; + } + + // The series that provides `value` ("" when there are no inputs). + std::string primarySource() const { + const int p = primaryIndex(); + return p < 0 ? std::string{} : sources_[static_cast(p)]; + } + + // Non-primary series in row order — the v1, v2, ... inputs. + std::vector orderedExtras() const { + std::vector extras; + const int p = primaryIndex(); + for (int i = 0; i < static_cast(sources_.size()); ++i) { + if (i != p) { + extras.push_back(sources_[static_cast(i)]); + } + } + return extras; + } + + // Variable name shown for row `i`: "value" for the primary, else v1, v2, ... by + // the row's position among the non-primary rows (matches buildTransformScript). + std::string variableName(int i) const { + const int p = primaryIndex(); + if (i == p) { + return "value"; + } + int vnum = 0; + for (int j = 0; j <= i; ++j) { + if (j != p) { + ++vnum; + } + } + return "v" + std::to_string(vnum); + } + + // Single function state. All dragged inputs live in one table; the row whose + // radio is checked (`primary_index_`) provides `value`, the rest become v1, v2, + // ... in row order. `selected_paths_` are the rows the user highlighted for the + // trash button (the table reports its column-0 text, i.e. the series path). + std::string language_ = "luau"; // single-tab script language: "luau" | "python" (radios) + std::string batch_language_ = "luau"; // batch-tab script language (radios) + std::string output_name_; + std::string global_code_; + std::string function_body_ = "return value"; + std::vector sources_; + int primary_index_ = -1; + std::vector selected_paths_; + bool syntax_ok_ = false; + bool autozoom_ = true; // AutoZoom checkbox state (default on) + bool edit_mode_ = false; // opened to modify an existing series (locks the name, button = Modify) + std::string validation_error_; // host's rejection message for the current script (empty = OK) + std::string batch_validation_error_; // batch-tab counterpart (empty = OK) + bool batch_dirty_ = true; // batch script/inputs changed → re-validate (start dirty) + + // Batch state + std::string batch_global_code_; + std::string batch_function_body_ = "return value"; + std::string batch_filter_; + std::string batch_suffix_; + int current_tab_ = 0; // active tab (0 = Single, 1 = Batch) + std::vector batch_selected_; // full paths selected in listBatchSources + bool batch_use_prefix_ = false; // Prefix vs Suffix radio (default Suffix) + std::vector all_series_; + std::vector batch_filtered_sources_; + + // Library + std::vector snippets_; + // Function library box (interactive sub-panel) state. + bool library_open_ = false; // sub-panel currently shown + bool emit_open_library_ = false; // one-shot: emit requestSubPanel next build + bool emit_close_library_ = false; // one-shot: emit closeSubPanel next build + std::string library_search_; // current search-filter text + std::vector library_selected_; // names of the rows selected in the box + + // "Save current function" flow (PJ3 parity): name prompt -> overwrite warning. + enum class SaveStage { None, NamePrompt, OverwriteConfirm }; + SaveStage save_stage_ = SaveStage::None; + std::string pending_save_name_; // name being saved (from the prompt) + bool emit_save_name_dialog_ = false; // one-shot: open the name prompt + bool emit_save_confirm_dialog_ = false; // one-shot: open the overwrite warning + + std::string io_status_; // one-shot Import/Export status line; consumed by the next widget_data() + + bool save_requested_ = false; + bool close_requested_ = false; + bool pending_close_ = false; + bool preview_dirty_ = false; + bool help_requested_ = false; + std::function on_save_; + std::function on_refresh_preview_; + std::function on_teardown_preview_; + std::function on_validate_batch_; + std::vector preview_series_; +}; + +// --------------------------------------------------------------------------- +// TransformEditorToolbox +// --------------------------------------------------------------------------- + +class TransformEditorToolbox : public PJ::ToolboxPluginBase { + public: + uint64_t capabilities() const override { + return PJ::kToolboxCapabilityHasDialog; + } + + PJ_borrowed_dialog_t getDialog() override { + if (!callbacks_wired_) { + dialog_.setOnSave([this]() { onSave(); }); + dialog_.setOnRefreshPreview([this]() { refreshPreview(); }); + dialog_.setOnTeardownPreview([this]() { tearDownPreview(); }); + dialog_.setOnValidateBatch([this]() { validateBatch(); }); + callbacks_wired_ = true; + } + dialog_.setSnippets(loadSnippetsFromDisk()); + + // Populate available series for the batch tab + if (toolboxHostBound()) { + auto catalog = toolboxHost().catalogSnapshot(); + if (catalog) { + std::vector series; + for (const auto& topic : catalog->topics()) { + series.emplace_back(topic.name.data, topic.name.size); + } + dialog_.setAvailableSeries(std::move(series)); + } + } + + refreshPreview(); + return PJ::borrowDialog(dialog_); + } + + PJ::Status bind(PJ::sdk::ServiceRegistry services) override { + auto status = ToolboxPluginBase::bind(services); + if (!status) { + return status; + } + // Request the data processors host bridge for live (DerivedEngine) transforms. + if (auto dp = services.get()) { + dp_view_ = *dp; + } + return PJ::okStatus(); + } + + std::string saveConfig() const override { + return dialog_.saveConfig(); + } + + PJ::Status loadConfig(std::string_view json) override { + if (!dialog_.loadConfig(json)) { + return PJ::unexpected("invalid transform editor config"); + } + return PJ::okStatus(); + } + + private: + void onSave() { + if (dialog_.currentTab() != 0) { + onSaveBatch(); + return; + } + const auto& source = dialog_.sourceSeries(); + const auto& output_name = dialog_.outputName(); + const auto& global = dialog_.globalCode(); + const auto& body = dialog_.functionBody(); + + const auto report = [this](PJ::ToolboxMessageLevel level, const std::string& msg) { + if (runtimeHostBound()) { + runtimeHost().reportMessage(level, msg); + } + }; + + if (source.empty() || output_name.empty() || body.empty()) { + report( + PJ::ToolboxMessageLevel::kWarning, + "Transform Editor: a source series, an output name, and a function body are required."); + return; + } + if (!dp_view_.valid()) { + report( + PJ::ToolboxMessageLevel::kError, + "Transform Editor: the host did not expose pj.data_processors.v1 (cannot create the series)."); + return; + } + + // Inputs: the principal source first, then the additional sources as v1..vN. + // Pass the FULL field path ("dummy/noise/random"); the host resolves it to the + // owning topic AND the selected leaf column, so the transform reads the chosen + // field instead of always column 0 of a multi-field topic. + // The host runs N->1: a single input installs a SISO node, multiple inputs a + // MIMO node. The synthesized class arity matches the input count. + std::vector input_topics; + input_topics.push_back(source); + for (const std::string& extra : dialog_.extraSources()) { + input_topics.push_back(extra); + } + const std::size_t num_extra = dialog_.extraSources().size(); + + // The name field doubles as a comma-separated list of output topics; the body + // must `return` one value per declared name (positional). See splitOutputNames. + std::vector output_names = splitOutputNames(output_name); + if (output_names.empty()) { + report(PJ::ToolboxMessageLevel::kWarning, "Transform Editor: no valid output name."); + return; + } + + // Build a self-describing Luau filter class and hand it to the host. The host + // compiles + runs it as an eager DerivedEngine node that RECOMPUTES LIVE as new + // samples arrive — so a streaming source produces a streaming output that + // survives the plugin/panel closing. + const std::string id = output_names.front(); // host namespaces it under the plugin id + const std::string script = + buildTransformScript(id, output_names.front(), global, body, num_extra, dialog_.language()); + + std::vector inputs(input_topics.begin(), input_topics.end()); + std::vector outputs(output_names.begin(), output_names.end()); + // Persist the editor state in params_json so the Edit (pencil) button can + // reconstruct the editor for an in-place Modify. The script ignores params. + const std::string editor_params = dialog_.saveConfig(); + auto status = dp_view_.createTransform( + id, PJ::Span(inputs.data(), inputs.size()), + PJ::Span(outputs.data(), outputs.size()), script, editor_params); + if (!status) { + report(PJ::ToolboxMessageLevel::kError, "Transform Editor: " + std::string(status.error())); + return; + } + report( + PJ::ToolboxMessageLevel::kInfo, + "Transform Editor: created " + std::to_string(output_names.size()) + " series."); + // The host materializes the topic; refresh so it shows in the Custom Series panel. + if (runtimeHostBound()) { + runtimeHost().notifyDataChanged(); + } + dialog_.requestClose(); // PJ3 parity: creating the series closes the editor. + } + + // Batch create: apply the batch function to EVERY selected source, naming each + // output with the prefix/suffix (mirrors the native panel's batch path). One + // transform per source; each output is surfaced in Custom Series via on_data_changed. + void onSaveBatch() { + const auto report = [this](PJ::ToolboxMessageLevel level, const std::string& msg) { + if (runtimeHostBound()) { + runtimeHost().reportMessage(level, msg); + } + }; + const std::string& body = dialog_.batchFunctionBody(); + const std::string& global = dialog_.batchGlobalCode(); + const std::string& suffix = dialog_.batchSuffix(); + const bool use_prefix = dialog_.batchUsePrefix(); + const auto& selected = dialog_.batchSelected(); + if (body.empty() || selected.empty() || suffix.empty()) { + report( + PJ::ToolboxMessageLevel::kWarning, + "Transform Editor: select sources, set a prefix/suffix, and write a function body."); + return; + } + if (!dp_view_.valid()) { + report( + PJ::ToolboxMessageLevel::kError, + "Transform Editor: the host did not expose pj.data_processors.v1 (cannot create the series)."); + return; + } + int created = 0; + for (const std::string& source_display : selected) { + const auto slash = source_display.find_last_of('/'); + const std::string base = (slash == std::string::npos) ? source_display : source_display.substr(slash + 1); + const std::string name = use_prefix ? (suffix + base) : (base + suffix); + if (name.empty()) { + continue; + } + // Pass the FULL field path; the host resolves topic + selected leaf column. + const std::string script = buildTransformScript(name, name, global, body, 0, dialog_.batchLanguage()); + std::vector ins{source_display}; + std::vector outs{name}; + auto status = dp_view_.createTransform( + name, PJ::Span(ins.data(), ins.size()), + PJ::Span(outs.data(), outs.size()), script, "{}"); + if (!status) { + report( + PJ::ToolboxMessageLevel::kError, + "Transform Editor: error on '" + source_display + "': " + std::string(status.error())); + return; + } + ++created; + } + report(PJ::ToolboxMessageLevel::kInfo, "Transform Editor: created " + std::to_string(created) + " series."); + if (runtimeHostBound()) { + runtimeHost().notifyDataChanged(); + } + dialog_.requestClose(); // PJ3 parity: creating the series closes the editor. + } + + void tearDownPreview() { + if (!preview_key_.empty() && dp_view_.valid()) { + (void)dp_view_.remove(preview_key_); + preview_key_.clear(); + } + } + + // Read a datastore field by name (exact, or topic/field suffix/prefix match) and + // return its (absolute_ns, value) samples, downsampled to ~2000 points for the + // preview. The ephemeral transform output lives in the engine's topic list (and + // hence in catalogSnapshot) even though it is kept out of the UI catalog, so this + // resolves both the source AND the transformed result by name. + std::vector> readRawSamples(const std::string& field_name) { + std::vector> out; + auto catalog = toolboxHost().catalogSnapshot(); + if (!catalog) { + return out; + } + // Resolve `field_name` (a full "topic/field" path, OR a bare topic name for a + // single-field derived output) to a field handle. Field paths in the snapshot + // are RELATIVE to their topic, so reconstruct the full "topic/field" name the + // same way CatalogModel does, instead of fragile substring matching. + const auto fields = catalog->fields(); + const auto topics = catalog->topics(); + PJ::sdk::FieldHandle handle{}; + bool found = false; + for (const auto& t : topics) { + const std::string tname(t.name.data, t.name.size); + const bool topic_is_target = (tname == field_name); + const uint32_t end = t.first_field + t.field_count; + for (uint32_t fi = t.first_field; fi < end && fi < fields.size(); ++fi) { + const auto& f = fields[fi]; + const std::string fname(f.name.data, f.name.size); + const std::string full = tname.empty() ? fname : (tname + "/" + fname); + if (topic_is_target || full == field_name || fname == field_name) { + handle = PJ::sdk::FieldHandle{f.handle}; + found = true; + break; + } + } + if (found) { + break; + } + } + if (!found) { + return out; + } + auto read = toolboxHost().readSeries(handle); + if (!read || read->rowCount() == 0 || read->valuesAsFloat64() == nullptr) { + return out; + } + const auto ts = read->timestamps(); + const double* vals = read->valuesAsFloat64(); + const size_t n = read->rowCount(); + const size_t step = (n > 2000) ? (n / 2000) : 1; + out.reserve(n / step + 1); + for (size_t i = 0; i < n; i += step) { + out.emplace_back(ts[i], vals[i]); + } + return out; + } + + // Rebuild the Batch tab's available-series list from the LIVE catalog (full + // "topic/field" paths, skipping our own "__" ephemeral outputs). Called every + // tick so series from a file/stream loaded AFTER the editor opened show up; the + // panel engine diffs WidgetData, so an unchanged list is a no-op (selection kept). + void refreshAvailableSeries() { + auto catalog = toolboxHost().catalogSnapshot(); + if (!catalog) { + return; + } + const auto fields = catalog->fields(); + std::vector series; + for (const auto& t : catalog->topics()) { + const std::string tname(t.name.data, t.name.size); + if (tname.rfind("__", 0) == 0) { + continue; + } + const uint32_t end = t.first_field + t.field_count; + for (uint32_t fi = t.first_field; fi < end && fi < fields.size(); ++fi) { + const auto& f = fields[fi]; + const std::string fname(f.name.data, f.name.size); + series.push_back(tname.empty() ? fname : (tname + "/" + fname)); + } + } + dialog_.setAvailableSeries(std::move(series)); + } + + void refreshPreview() { + refreshAvailableSeries(); + // The live preview belongs to the Single Function tab only. On the Batch tab + // tear it down so its ephemeral node can never coexist with / leak into a + // batch Create (and to avoid needless per-tick churn). + if (dialog_.currentTab() != 0) { + tearDownPreview(); + dialog_.setPreviewSeries({}); + return; + } + if (!dp_view_.valid()) { + return; + } + const auto& source = dialog_.sourceSeries(); + const auto& global = dialog_.globalCode(); + const auto& body = dialog_.functionBody(); + + if (source.empty() || body.empty()) { + tearDownPreview(); + dialog_.setPreviewSeries({}); + dialog_.setValidationError(""); // incomplete input is not an error + return; + } + + // Pass the FULL field path as the input; the host resolves it to the owning + // topic AND the selected leaf column (so the preview reads the chosen field, + // not column 0 of a multi-field topic — matching the live Create path). + const std::size_t num_extra = dialog_.extraSources().size(); + + std::vector input_topics; + input_topics.push_back(source); + for (const std::string& extra : dialog_.extraSources()) { + input_topics.push_back(extra); + } + + const std::string preview_output = "__te_preview__"; + const std::string preview_id(kPreviewId); + const std::string script = + buildTransformScript(preview_id, preview_id, global, body, num_extra, dialog_.language()); + + // Evaluate the code FIRST via the SDK (cheap: compile + one test point, no node). + // Only materialise the live preview node when the script is valid — so a broken + // function does not create/tear down a transform on every keystroke. The + // validation script uses the host's expected "__validate__" class id (the + // preview/create script keeps preview_id for the upsert). + const std::string validate_script = + buildTransformScript("__validate__", "__validate__", global, body, num_extra, dialog_.language()); + auto validation = dp_view_.validateScript(validate_script, dialog_.language()); + dialog_.setValidationError(validation ? "" : std::string(validation.error())); + if (!validation) { + tearDownPreview(); + dialog_.setPreviewSeries({}); + return; + } + + std::vector inputs_sv(input_topics.begin(), input_topics.end()); + std::vector outputs_sv{preview_output}; + + tearDownPreview(); + auto status = dp_view_.createEphemeralTransform( + kPreviewId, PJ::Span(inputs_sv.data(), inputs_sv.size()), + PJ::Span(outputs_sv.data(), outputs_sv.size()), script, "{}"); + + if (!status) { + dialog_.setValidationError(std::string(status.error())); + dialog_.setPreviewSeries({}); + return; + } + preview_key_ = preview_id; + + // Read the source (ghost) and the freshly-materialised transform output back + // from the datastore, and hand the host explicit points to plot. Both share a + // common t0 (the earliest sample) so they align on the time axis. + auto ghost_raw = readRawSamples(source); + auto result_raw = readRawSamples(preview_output); + + // The script compiled, but if the real node produced NO output while the source + // has data, the function returns nil/non-numeric (e.g. `return valu`) — flag it. + // This catches the runtime case that validateScript (compile-only) cannot, using + // the real inputs (so a valid multi-input function is NOT falsely flagged). + if (!ghost_raw.empty() && result_raw.empty()) { + dialog_.setValidationError("function produced no output — it must return a number"); + } + + int64_t t0 = 0; + bool have_t0 = false; + if (!ghost_raw.empty()) { + t0 = ghost_raw.front().first; + have_t0 = true; + } + if (!result_raw.empty() && (!have_t0 || result_raw.front().first < t0)) { + t0 = result_raw.front().first; + have_t0 = true; + } + + const auto to_points = [t0](const std::vector>& raw) { + std::vector pts; + pts.reserve(raw.size()); + for (const auto& [ts, v] : raw) { + pts.push_back({static_cast(ts - t0) / 1e9, v}); + } + return pts; + }; + + std::vector series; + // Ghost (original): faded blue + dashed, distinct from the solid orange result + // — same styling as the native TransformEditorPanel. Color hex is #AARRGGBB. + series.push_back({source, to_points(ghost_raw), "#5A4488ff", /*dashed=*/true}); + if (!result_raw.empty()) { + const std::string label = dialog_.outputName().empty() ? "result" : dialog_.outputName(); + series.push_back({label, to_points(result_raw), "#ff8800", /*dashed=*/false}); + } + dialog_.setPreviewSeries(std::move(series)); + } + + // Validate the BATCH function via the SDK's validateScript — the host compiles + // it and runs a synthetic test point, with NO node/topic created (unlike the old + // throwaway-transform approach). Drives labelSemaphoreBatch; called only when the + // batch fields changed (consumeBatchDirty), not every tick. + void validateBatch() { + if (!dp_view_.valid()) { + return; + } + const std::string& body = dialog_.batchFunctionBody(); + if (body.empty()) { + dialog_.setBatchValidationError(""); + return; + } + const std::string script = buildTransformScript( + "__validate__", "__validate__", dialog_.batchGlobalCode(), body, 0, dialog_.batchLanguage()); + auto status = dp_view_.validateScript(script, dialog_.batchLanguage()); + dialog_.setBatchValidationError(status ? "" : std::string(status.error())); + } + + static constexpr std::string_view kPreviewId = "__te_preview__"; + + TransformEditorDialog dialog_; + bool callbacks_wired_ = false; + std::optional ds_handle_{std::nullopt}; + PJ::sdk::DataProcessorsHostView dp_view_; + std::string preview_key_; // non-empty when an ephemeral preview node is live +}; + +} // namespace + +PJ_TOOLBOX_PLUGIN(TransformEditorToolbox, kTransformEditorManifest) +PJ_DIALOG_PLUGIN(TransformEditorDialog) From af15f000917943579dc25b3ad671bfd88ac6f196 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20I=C3=B1igo=20Blasco?= Date: Mon, 29 Jun 2026 12:32:02 +0200 Subject: [PATCH 2/3] feat(toolbox_transform_editor): sync plugin to latest Bring the Transform Editor plugin up to date: per-snippet language tagging in the library, Python module emission for the Python backend, and the validateScript(kind, language, script) update for the unified data_processors SDK. --- .../transform_editor_plugin.cpp | 157 ++++++++++++++++-- 1 file changed, 139 insertions(+), 18 deletions(-) diff --git a/toolbox_transform_editor/transform_editor_plugin.cpp b/toolbox_transform_editor/transform_editor_plugin.cpp index 86883cd..a942ee1 100644 --- a/toolbox_transform_editor/transform_editor_plugin.cpp +++ b/toolbox_transform_editor/transform_editor_plugin.cpp @@ -225,6 +225,7 @@ struct Snippet { std::string name; std::string global_code; std::string function_body; + std::string language = "luau"; // "luau" | "python"; legacy/builtin snippets are Luau }; std::filesystem::path snippetLibraryPath() { @@ -240,7 +241,11 @@ bool saveSnippetsToPath(const std::vector& snippets, const std::filesys std::filesystem::create_directories(path.parent_path()); nlohmann::json j = nlohmann::json::array(); for (const auto& s : snippets) { - j.push_back({{"name", s.name}, {"global_code", s.global_code}, {"function_body", s.function_body}}); + j.push_back( + {{"name", s.name}, + {"global_code", s.global_code}, + {"function_body", s.function_body}, + {"language", s.language}}); } std::ofstream out(path); if (!out) { @@ -309,7 +314,7 @@ std::optional> loadSnippetsFromPath(const std::filesystem:: } result.push_back( {item.value("name", std::string{}), item.value("global_code", std::string{}), - item.value("function_body", std::string{})}); + item.value("function_body", std::string{}), item.value("language", std::string{"luau"})}); } return result; } @@ -368,9 +373,44 @@ inline std::string buildTransformScript( for (std::size_t k = 0; k < num_extra; ++k) { params += ", v" + std::to_string(k + 1); } + + // Python backend: emit a module with a top-level class `T` (see pj_scripting's + // python_engine.h). The global section runs once at module level (so `global` + // persistent state works, PJ3-style); the body becomes the function. Python is + // whitespace-sensitive, so every body line is indented one level. + if (language == "python") { + const auto indent_block = [](const std::string& code) { + std::string out; + std::size_t start = 0; + while (start <= code.size()) { + const std::size_t nl = code.find('\n', start); + const std::string line = code.substr(start, nl == std::string::npos ? std::string::npos : nl - start); + out += " " + line + "\n"; + if (nl == std::string::npos) { + break; + } + start = nl + 1; + } + return out; + }; + std::string src = "# pj-script: python\n"; + if (!global_code.empty()) { + src += global_code + "\n\n"; + } + src += "def _pj_fn(" + params + "):\n"; + src += indent_block(body.empty() ? "return value" : body); + src += "\n"; + src += "class T:\n"; + src += " id = \"" + id + "\"\n"; + src += " name = \"" + name + "\"\n"; + src += " output = \"double\"\n"; + src += " @staticmethod\n"; + src += " def create(params):\n return T()\n"; + src += " def calculate(self, time, value, *args):\n return _pj_fn(time, value, *args)\n"; + return src; + } + // The header declares the backend; the host's inferTransformBackend reads it. - // PJ4 only has a Luau backend today, so "python" is accepted by the UI but the - // host rejects it on create/validate (no Python backend yet). std::string src = "-- pj-script: " + language + "\n"; src += "local function _pj_make()\n"; src += global_code + "\n"; @@ -404,6 +444,17 @@ class TransformEditorDialog : public PJ::DialogPluginTyped { PJ::WidgetData buildWidgetData() { PJ::WidgetData wd; + // One-shot after loadConfig (Modify): force the editor onto the tab the series + // was created from, and (for batch) re-select its source. Only once, so the + // user can freely switch tabs / change the selection afterwards. + if (pending_tab_restore_) { + wd.setTabIndex("tabWidget", current_tab_); + if (current_tab_ == 1) { + wd.setSelectedItems("listBatchSources", batch_selected_); + } + pending_tab_restore_ = false; + } + // Single function tab — one table of inputs (drop target). Col 0 is the radio // marking which row provides `value`; col 1 is the series path the trash button // acts on; col 2 is the bound variable. The host emits row selection from the @@ -435,8 +486,13 @@ class TransformEditorDialog : public PJ::DialogPluginTyped { signature += " )"; wd.setText("labelFunction", signature); - wd.setCodeContent("globalVarsText", global_code_).setCodeLanguage("globalVarsText", "lua"); - wd.setCodeContent("functionText", function_body_).setCodeLanguage("functionText", "lua"); + const char* single_lang = (language_ == "python") ? "python" : "lua"; + wd.setCodeContent("globalVarsText", global_code_).setCodeLanguage("globalVarsText", single_lang); + wd.setCodeContent("functionText", function_body_).setCodeLanguage("functionText", single_lang); + // Reflect the active language on the radios (so loading a library snippet flips + // them, not just the user clicking). Pushed every tick; matches language_. + wd.setChecked("luaButton", language_ != "python"); + wd.setChecked("pythonButton", language_ == "python"); // Function Library sub-panel (cloned from PJ3's buttonLibraryBox dialog). // One-shot open/close commands, then live population while it is open. @@ -461,11 +517,13 @@ class TransformEditorDialog : public PJ::DialogPluginTyped { } if (library_open_) { const std::vector names = filteredSnippetNames(); - wd.setTableHeaders("tableFunctions", {"Function"}); + wd.setTableHeaders("tableFunctions", {"Function", "Language"}); std::vector> lib_rows; lib_rows.reserve(names.size()); for (const auto& n : names) { - lib_rows.push_back({n}); + auto it = std::find_if(snippets_.begin(), snippets_.end(), [&](const Snippet& s) { return s.name == n; }); + const std::string lang = (it != snippets_.end() && it->language == "python") ? "Python" : "Lua"; + lib_rows.push_back({n, lang}); } wd.setTableRows("tableFunctions", lib_rows); wd.setPlainText("previewPlainText", combinedSnippetText(library_selected_)); @@ -516,8 +574,11 @@ class TransformEditorDialog : public PJ::DialogPluginTyped { // Batch tab content (set first so the validation terminal + Create gating below // see the current state). wd.setListItems("listBatchSources", batch_filtered_sources_); - wd.setCodeContent("globalVarsTextBatch", batch_global_code_).setCodeLanguage("globalVarsTextBatch", "lua"); - wd.setCodeContent("functionTextBatch", batch_function_body_).setCodeLanguage("functionTextBatch", "lua"); + const char* batch_lang = (batch_language_ == "python") ? "python" : "lua"; + wd.setCodeContent("globalVarsTextBatch", batch_global_code_).setCodeLanguage("globalVarsTextBatch", batch_lang); + wd.setCodeContent("functionTextBatch", batch_function_body_).setCodeLanguage("functionTextBatch", batch_lang); + wd.setChecked("luaBatchButton", batch_language_ != "python"); + wd.setChecked("pythonBatchButton", batch_language_ == "python"); // Batch validation terminal — same messages and behaviour as PJ3's // onUpdatePreviewBatch (function_editor.cpp): list every blocking problem; the @@ -545,12 +606,19 @@ class TransformEditorDialog : public PJ::DialogPluginTyped { // empty prefix/suffix now blocks creation. const bool can_create = (current_tab_ == 0) ? single_term.empty() : batch_term.empty(); wd.setEnabled("pushButtonCreate", can_create); - // Create vs Modify (PJ3 parity): in explicit edit mode, or when the Single-tab - // output name already exists as a series, the button reads "Modify Time Series". - const bool is_modify = current_tab_ == 0 && (edit_mode_ || outputNameExists(output_name_)); + // Create vs Modify (PJ3 parity): in explicit edit mode (single OR batch), or + // when the Single-tab output name already exists, the button reads "Modify". + const bool is_modify = edit_mode_ || (current_tab_ == 0 && outputNameExists(output_name_)); wd.setButtonText("pushButtonCreate", is_modify ? "Modify Time Series" : "Create New Time Series"); - // Lock the name while editing so a rename can't fork a new series (PJ3 parity). + // Lock the identity while editing so a rename can't fork a new series (PJ3 + // parity). Single: the name field. Batch: the inputs that form the name — + // source selection + filter + prefix/suffix. wd.setEnabled("nameLineEdit", !edit_mode_); + wd.setEnabled("listBatchSources", !edit_mode_); + wd.setEnabled("lineEditTab2Filter", !edit_mode_); + wd.setEnabled("suffixLineEdit", !edit_mode_); + wd.setEnabled("radioButtonPrefix", !edit_mode_); + wd.setEnabled("radioButtonSuffix", !edit_mode_); // Preview chart — always set (even if empty) so ChartPreviewWidget is // instantiated from the start, matching PJ3 behaviour where the empty @@ -916,6 +984,8 @@ class TransformEditorDialog : public PJ::DialogPluginTyped { cfg["function_body"] = function_body_; cfg["sources"] = sources_; cfg["primary_index"] = primaryIndex(); + cfg["language"] = language_; // restore the Lua/Python radio on Modify + cfg["mode"] = "single"; // reopen on the Single tab when modified (PJ3 parity) // Back-compat mirror: an older editor reads source_series + extra_sources, so // expose the primary as the source and the rest as extras in order. cfg["source_series"] = primarySource(); @@ -923,14 +993,53 @@ class TransformEditorDialog : public PJ::DialogPluginTyped { return cfg.dump(); } + // Build the editor config for ONE batch-created series so that editing it later + // reopens the BATCH tab (PJ3 parity), repopulated with this series' source, + // prefix/suffix, global, body and language. + static std::string makeBatchConfig(const std::string& name, const std::string& global, const std::string& body, + const std::string& source, const std::string& language, const std::string& suffix, + bool use_prefix) { + nlohmann::json cfg; + cfg["mode"] = "batch"; + cfg["output_name"] = name; + cfg["global_code"] = global; + cfg["function_body"] = body; + cfg["language"] = language; + cfg["suffix"] = suffix; + cfg["use_prefix"] = use_prefix; + cfg["sources"] = std::vector{source}; + return cfg.dump(); + } + bool loadConfig(std::string_view json) override { auto cfg = nlohmann::json::parse(json, nullptr, false); if (cfg.is_discarded()) { return false; } + // A batch-created series reopens the BATCH tab (PJ3 parity), repopulated with + // its source, prefix/suffix, global, body and language. + if (cfg.value("mode", std::string{}) == "batch") { + batch_global_code_ = cfg.value("global_code", std::string{}); + batch_function_body_ = cfg.value("function_body", std::string{}); + batch_language_ = cfg.value("language", std::string{"luau"}); + batch_suffix_ = cfg.value("suffix", std::string{}); + batch_use_prefix_ = cfg.value("use_prefix", false); + batch_selected_.clear(); + if (cfg.contains("sources") && cfg["sources"].is_array()) { + batch_selected_ = cfg["sources"].get>(); + } + current_tab_ = 1; // open on the Batch tab + pending_tab_restore_ = true; // one-shot: push the tab + selection to the UI + batch_dirty_ = true; + edit_mode_ = true; + return true; + } output_name_ = cfg.value("output_name", std::string{}); global_code_ = cfg.value("global_code", std::string{}); function_body_ = cfg.value("function_body", std::string{}); + language_ = cfg.value("language", std::string{"luau"}); + current_tab_ = 0; // open on the Single tab + pending_tab_restore_ = true; // one-shot: push the tab to the UI sources_.clear(); primary_index_ = -1; if (cfg.contains("sources") && cfg["sources"].is_array()) { @@ -980,7 +1089,7 @@ class TransformEditorDialog : public PJ::DialogPluginTyped { // then persist the library to disk. The name prompt + overwrite warning are // handled by the caller (the save state machine), mirroring PJ3. void doSaveSnippet(const std::string& snippet_name) { - Snippet sn{snippet_name, global_code_, function_body_}; + Snippet sn{snippet_name, global_code_, function_body_, language_}; auto it = std::find_if(snippets_.begin(), snippets_.end(), [&](const Snippet& s) { return s.name == snippet_name; }); if (it != snippets_.end()) { @@ -1047,11 +1156,18 @@ class TransformEditorDialog : public PJ::DialogPluginTyped { } std::string globals; std::string body; + bool language_set = false; for (const auto& n : names) { auto it = std::find_if(snippets_.begin(), snippets_.end(), [&](const Snippet& s) { return s.name == n; }); if (it == snippets_.end()) { continue; } + // Adopt the first snippet's language so the editor interprets it correctly + // (and flips the Lua/Python radio). A multi-select combine uses the first. + if (!language_set) { + language_ = it->language.empty() ? "luau" : it->language; + language_set = true; + } if (!it->global_code.empty()) { if (!globals.empty()) { globals += "\n"; @@ -1252,6 +1368,7 @@ class TransformEditorDialog : public PJ::DialogPluginTyped { std::string batch_filter_; std::string batch_suffix_; int current_tab_ = 0; // active tab (0 = Single, 1 = Batch) + bool pending_tab_restore_ = false; // one-shot: push tab+selection after loadConfig std::vector batch_selected_; // full paths selected in listBatchSources bool batch_use_prefix_ = false; // Prefix vs Suffix radio (default Suffix) std::vector all_series_; @@ -1465,9 +1582,13 @@ class TransformEditorToolbox : public PJ::ToolboxPluginBase { const std::string script = buildTransformScript(name, name, global, body, 0, dialog_.batchLanguage()); std::vector ins{source_display}; std::vector outs{name}; + // Persist this series' editor state (single-tab shape) so the Edit (pencil) + // button can Modify it just like a single-created series — PJ3 parity. + const std::string editor_params = TransformEditorDialog::makeBatchConfig( + name, global, body, source_display, dialog_.batchLanguage(), suffix, use_prefix); auto status = dp_view_.createTransform( name, PJ::Span(ins.data(), ins.size()), - PJ::Span(outs.data(), outs.size()), script, "{}"); + PJ::Span(outs.data(), outs.size()), script, editor_params); if (!status) { report( PJ::ToolboxMessageLevel::kError, @@ -1618,7 +1739,7 @@ class TransformEditorToolbox : public PJ::ToolboxPluginBase { // preview/create script keeps preview_id for the upsert). const std::string validate_script = buildTransformScript("__validate__", "__validate__", global, body, num_extra, dialog_.language()); - auto validation = dp_view_.validateScript(validate_script, dialog_.language()); + auto validation = dp_view_.validateScript("transform", dialog_.language(), validate_script); dialog_.setValidationError(validation ? "" : std::string(validation.error())); if (!validation) { tearDownPreview(); @@ -1701,7 +1822,7 @@ class TransformEditorToolbox : public PJ::ToolboxPluginBase { } const std::string script = buildTransformScript( "__validate__", "__validate__", dialog_.batchGlobalCode(), body, 0, dialog_.batchLanguage()); - auto status = dp_view_.validateScript(script, dialog_.batchLanguage()); + auto status = dp_view_.validateScript("transform", dialog_.batchLanguage(), script); dialog_.setBatchValidationError(status ? "" : std::string(status.error())); } From d58f11d4edcedde40a0d780e30ddeeeaf7df92e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20I=C3=B1igo=20Blasco?= Date: Mon, 29 Jun 2026 12:50:29 +0200 Subject: [PATCH 3/3] style: clang-format the transform editor plugin Apply clang-format so pre-commit passes. --- toolbox_transform_editor/transform_editor_plugin.cpp | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/toolbox_transform_editor/transform_editor_plugin.cpp b/toolbox_transform_editor/transform_editor_plugin.cpp index a942ee1..e17d677 100644 --- a/toolbox_transform_editor/transform_editor_plugin.cpp +++ b/toolbox_transform_editor/transform_editor_plugin.cpp @@ -996,9 +996,9 @@ class TransformEditorDialog : public PJ::DialogPluginTyped { // Build the editor config for ONE batch-created series so that editing it later // reopens the BATCH tab (PJ3 parity), repopulated with this series' source, // prefix/suffix, global, body and language. - static std::string makeBatchConfig(const std::string& name, const std::string& global, const std::string& body, - const std::string& source, const std::string& language, const std::string& suffix, - bool use_prefix) { + static std::string makeBatchConfig( + const std::string& name, const std::string& global, const std::string& body, const std::string& source, + const std::string& language, const std::string& suffix, bool use_prefix) { nlohmann::json cfg; cfg["mode"] = "batch"; cfg["output_name"] = name; @@ -1028,7 +1028,7 @@ class TransformEditorDialog : public PJ::DialogPluginTyped { if (cfg.contains("sources") && cfg["sources"].is_array()) { batch_selected_ = cfg["sources"].get>(); } - current_tab_ = 1; // open on the Batch tab + current_tab_ = 1; // open on the Batch tab pending_tab_restore_ = true; // one-shot: push the tab + selection to the UI batch_dirty_ = true; edit_mode_ = true;