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 & 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 & 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 "function". 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 "function". 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..e17d677
--- /dev/null
+++ b/toolbox_transform_editor/transform_editor_plugin.cpp
@@ -0,0 +1,1841 @@
+// 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 "memory", i.e. they need the previous value. To do this you may use the "global variable" 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::string language = "luau"; // "luau" | "python"; legacy/builtin snippets are Luau
+};
+
+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},
+ {"language", s.language}});
+ }
+ 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{}), item.value("language", std::string{"luau"})});
+ }
+ 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);
+ }
+
+ // 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.
+ 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;
+
+ // 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
+ // 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);
+
+ 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.
+ 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", "Language"});
+ std::vector> lib_rows;
+ lib_rows.reserve(names.size());
+ for (const auto& n : names) {
+ 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_));
+ }
+
+ // 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_);
+ 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
+ // 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 (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 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
+ // 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();
+ 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();
+ cfg["extra_sources"] = orderedExtras();
+ 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()) {
+ 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_, language_};
+ 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;
+ 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";
+ }
+ 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)
+ 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_;
+ 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};
+ // 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, editor_params);
+ 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("transform", dialog_.language(), validate_script);
+ 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("transform", dialog_.batchLanguage(), script);
+ 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)