Skip to content

Commit fdf7601

Browse files
feat: align Script Editor UI with PJ3 — layout, texts and widgets
Restructure the reactive script editor dialog to match PlotJuggler 3: description label on top, "Script Editor" / "Function Library" tabs, left column with global code + function editor, right column with Name/Save + Active Scripts + Recent scripts; borders on text boxes and lists. - Active Scripts are in-memory (empty on restart); Recent scripts persist. - The Library tab is an editable Lua helper block with a validity semaphore and Apply / Restore-default actions. - Double-click on Active or Recent loads that snippet into the editor.
1 parent bb0ebd5 commit fdf7601

2 files changed

Lines changed: 429 additions & 296 deletions

File tree

toolbox_reactive_scripts_editor/reactive_script_editor.cpp

Lines changed: 190 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,50 @@ std::string persistLibraryToDisk(
7979
}
8080
}
8181

82+
std::filesystem::path luaEditorRecentPath() {
83+
return PJ::sdk::userDataDir() / "toolbox_reactive_scripts_editor" / "recent.json";
84+
}
85+
86+
void persistRecentToDisk(const std::filesystem::path& path, const std::vector<SnippetData>& recent) {
87+
try {
88+
std::filesystem::create_directories(path.parent_path());
89+
nlohmann::json j = nlohmann::json::array();
90+
for (const auto& s : recent) {
91+
j.push_back({{"code", s.code}, {"global_code", s.global_code}, {"function_name", s.function_name}});
92+
}
93+
std::ofstream out(path);
94+
if (out) {
95+
out << j.dump(2);
96+
}
97+
} catch (...) {}
98+
}
99+
100+
std::vector<SnippetData> loadRecentFromDisk(const std::filesystem::path& path) {
101+
std::vector<SnippetData> result;
102+
std::ifstream in(path);
103+
if (!in) {
104+
return result;
105+
}
106+
std::stringstream buf;
107+
buf << in.rdbuf();
108+
auto j = nlohmann::json::parse(buf.str(), nullptr, false);
109+
if (!j.is_array()) {
110+
return result;
111+
}
112+
for (auto& item : j) {
113+
if (!item.is_object()) {
114+
continue;
115+
}
116+
result.push_back(
117+
SnippetData{
118+
item.value("code", std::string{}),
119+
item.value("global_code", std::string{}),
120+
item.value("function_name", std::string{}),
121+
});
122+
}
123+
return result;
124+
}
125+
82126
std::map<std::string, SnippetData> loadLibraryFromDisk(const std::filesystem::path& path) {
83127
std::map<std::string, SnippetData> result;
84128
std::ifstream in(path);
@@ -245,8 +289,19 @@ class ReactiveScriptEditorDialog : public PJ::DialogPluginTyped {
245289
std::string widget_data() override {
246290
PJ::WidgetData wd;
247291

248-
// -- Timeseries list (left panel) --
249-
wd.setListItems("series_list", series_names_);
292+
// -- Active Scripts: in-memory only, empty on restart (matches PJ3)
293+
std::vector<std::string> active_names;
294+
for (const auto& s : active_scripts_) {
295+
active_names.push_back(s.function_name);
296+
}
297+
wd.setListItems("series_list", active_names);
298+
299+
// -- Recent scripts (independent history with full data copies, matches PJ3 listWidgetRecent)
300+
std::vector<std::string> recent_names;
301+
for (const auto& s : recent_snippets_) {
302+
recent_names.push_back(s.function_name);
303+
}
304+
wd.setListItems("recent_list", recent_names);
250305

251306
// -- Language selector --
252307
bool is_lua = (language_ == "lua");
@@ -265,7 +320,8 @@ class ReactiveScriptEditorDialog : public PJ::DialogPluginTyped {
265320
.setCodeLanguage("code_editor", language_)
266321
.setText("function_name", function_name_)
267322
.setEnabled("save_button", !function_name_.empty() && !code_.empty())
268-
.setEnabled("run_button", !function_name_.empty() && !code_.empty() && !has_syntax_error_);
323+
.setEnabled("run_button", !function_name_.empty() && !code_.empty() && !has_syntax_error_)
324+
.setEnabled("deleteButton", !active_selected_.empty());
269325

270326
// -- Dynamic labels based on language --
271327
if (is_lua) {
@@ -283,32 +339,15 @@ class ReactiveScriptEditorDialog : public PJ::DialogPluginTyped {
283339
wd.setPlainText("terminal_output", terminal_text_);
284340
}
285341

286-
// -- Library tab --
287-
std::vector<std::string> visible_names;
288-
for (const auto& [name, snippet] : saved_snippets_) {
289-
if (library_search_.empty() || name.find(library_search_) != std::string::npos) {
290-
visible_names.push_back(name);
291-
}
292-
}
293-
wd.setListItems("library_list", visible_names);
294-
wd.setEnabled("library_use", !library_selected_.empty());
295-
wd.setEnabled("library_delete", !library_selected_.empty());
296-
297-
// Library preview
298-
if (!library_selected_.empty()) {
299-
auto it = saved_snippets_.find(library_selected_);
300-
if (it != saved_snippets_.end()) {
301-
std::string preview;
302-
if (!it->second.global_code.empty()) {
303-
preview += it->second.global_code + "\n\n";
304-
}
305-
preview += "function " + it->second.function_name + "(tracker_time)\n";
306-
preview += it->second.code;
307-
preview += "\nend";
308-
wd.setPlainText("library_preview", preview);
309-
}
342+
// -- Library tab (matches PJ3 textLibrary) --
343+
wd.setCodeContent("library_editor", library_code_).setCodeLanguage("library_editor", "lua");
344+
wd.setEnabled("library_apply", !library_valid_);
345+
346+
// Semaphore: green = library OK, red = syntax error
347+
if (library_valid_) {
348+
wd.setText("labelSemaphore", "<html><body><span style='color:#00aa00; font-size:22px;'>⬤</span></body></html>");
310349
} else {
311-
wd.setPlainText("library_preview", "");
350+
wd.setText("labelSemaphore", "<html><body><span style='color:#cc0000; font-size:22px;'>⬤</span></body></html>");
312351
}
313352

314353
// Tab control (switch to Editor when loading snippet from Library)
@@ -335,6 +374,12 @@ class ReactiveScriptEditorDialog : public PJ::DialogPluginTyped {
335374
terminal_sticky_ = false;
336375
return true;
337376
}
377+
if (name == "library_editor") {
378+
library_code_ = std::string(code);
379+
// Validate library Lua syntax — pass as global code, empty function body
380+
library_valid_ = validateLuaSyntax(library_code_, "").empty();
381+
return true;
382+
}
338383
return false;
339384
}
340385

@@ -343,11 +388,6 @@ class ReactiveScriptEditorDialog : public PJ::DialogPluginTyped {
343388
function_name_ = std::string(text);
344389
return true;
345390
}
346-
if (name == "library_search") {
347-
library_search_ = std::string(text);
348-
library_selected_.clear();
349-
return true;
350-
}
351391
return false;
352392
}
353393

@@ -375,9 +415,16 @@ class ReactiveScriptEditorDialog : public PJ::DialogPluginTyped {
375415

376416
bool onClicked(std::string_view name) override {
377417
if (name == "save_button" && !function_name_.empty() && !code_.empty()) {
378-
// Save current code to the library (does NOT execute)
379-
saved_snippets_[function_name_] = SnippetData{code_, global_code_, function_name_};
380-
requestLibraryPersist();
418+
SnippetData snippet{code_, global_code_, function_name_};
419+
// Add to Active Scripts (in-memory, matches PJ3 listWidgetFunctions)
420+
active_scripts_.erase(
421+
std::remove_if(
422+
active_scripts_.begin(), active_scripts_.end(),
423+
[&](const SnippetData& s) { return s.function_name == function_name_; }),
424+
active_scripts_.end());
425+
active_scripts_.push_back(snippet);
426+
// Add to Recent scripts (persisted, matches PJ3 listWidgetRecent)
427+
addToRecent(snippet);
381428
return true;
382429
}
383430
if (name == "run_button" && !function_name_.empty() && !code_.empty()) {
@@ -395,29 +442,49 @@ class ReactiveScriptEditorDialog : public PJ::DialogPluginTyped {
395442
terminal_sticky_ = true;
396443
return true;
397444
}
398-
if (name == "library_use") {
399-
return loadSelectedSnippet();
445+
// Delete button in Active Scripts (in-memory only, matches PJ3 pushButtonDelete)
446+
if (name == "deleteButton" && !active_selected_.empty()) {
447+
active_scripts_.erase(
448+
std::remove_if(
449+
active_scripts_.begin(), active_scripts_.end(),
450+
[&](const SnippetData& s) { return s.function_name == active_selected_; }),
451+
active_scripts_.end());
452+
active_selected_.clear();
453+
return true;
400454
}
401-
if (name == "library_delete" && !library_selected_.empty()) {
402-
saved_snippets_.erase(library_selected_);
403-
library_selected_.clear();
404-
requestLibraryPersist();
455+
// Restore default library code (matches PJ3 pushButtonDefaultLibrary)
456+
if (name == "library_restore") {
457+
library_code_ = kDefaultLibraryCode;
458+
library_valid_ = true;
459+
return true;
460+
}
461+
// Apply changes — mark library as applied (matches PJ3 pushButtonApplyLibrary)
462+
if (name == "library_apply") {
463+
library_valid_ = true;
405464
return true;
406465
}
407466
return false;
408467
}
409468

410469
bool onSelectionChanged(std::string_view name, const std::vector<std::string>& items) override {
411-
if (name == "library_list") {
412-
library_selected_ = items.empty() ? "" : items.front();
470+
// Selecting in either Active Scripts or Recent scripts selects the same snippet
471+
if (name == "series_list" || name == "recent_list") {
472+
active_selected_ = items.empty() ? "" : items.front();
413473
return true;
414474
}
415475
return false;
416476
}
417477

418-
bool onItemDoubleClicked(std::string_view name, int /*index*/) override {
419-
if (name == "library_list") {
420-
return loadSelectedSnippet();
478+
bool onItemDoubleClicked(std::string_view name, int index) override {
479+
// Double-click on Active Scripts (in-memory) or Recent scripts (PJ3 restoreRecent)
480+
// loads that snippet into the editor.
481+
if (name == "series_list" && index >= 0 && index < static_cast<int>(active_scripts_.size())) {
482+
applySnippet(active_scripts_[static_cast<size_t>(index)]);
483+
return true;
484+
}
485+
if (name == "recent_list" && index >= 0 && index < static_cast<int>(recent_snippets_.size())) {
486+
applySnippet(recent_snippets_[static_cast<size_t>(index)]);
487+
return true;
421488
}
422489
return false;
423490
}
@@ -509,8 +576,6 @@ class ReactiveScriptEditorDialog : public PJ::DialogPluginTyped {
509576
terminal_visible_ = false;
510577
terminal_text_.clear();
511578
validation_pending_ = false;
512-
library_selected_.clear();
513-
library_search_.clear();
514579
switch_to_tab_ = -1;
515580
return true;
516581
}
@@ -549,6 +614,23 @@ class ReactiveScriptEditorDialog : public PJ::DialogPluginTyped {
549614
saved_snippets_ = std::move(snippets);
550615
}
551616

617+
void setRecentSnippets(std::vector<SnippetData> recent) {
618+
recent_snippets_ = std::move(recent);
619+
}
620+
621+
void addToRecent(const SnippetData& snippet) {
622+
recent_snippets_.erase(
623+
std::remove_if(
624+
recent_snippets_.begin(), recent_snippets_.end(),
625+
[&](const SnippetData& s) { return s.function_name == snippet.function_name; }),
626+
recent_snippets_.end());
627+
recent_snippets_.insert(recent_snippets_.begin(), snippet);
628+
if (recent_snippets_.size() > 10) {
629+
recent_snippets_.resize(10);
630+
}
631+
persistRecentToDisk(luaEditorRecentPath(), recent_snippets_);
632+
}
633+
552634
// Persistence callback: called by the dialog after a save/delete in the library.
553635
// The toolbox provides this to flush the library to disk and surface errors via the runtime host.
554636
using LibrarySaveCallback = std::function<void(const std::map<std::string, SnippetData>&)>;
@@ -567,34 +649,64 @@ class ReactiveScriptEditorDialog : public PJ::DialogPluginTyped {
567649
}
568650
}
569651

570-
bool loadSelectedSnippet() {
571-
if (library_selected_.empty()) {
572-
return false;
573-
}
574-
auto it = saved_snippets_.find(library_selected_);
575-
if (it == saved_snippets_.end()) {
576-
return false;
577-
}
578-
579-
code_ = it->second.code;
580-
global_code_ = it->second.global_code;
581-
function_name_ = it->second.function_name;
582-
switch_to_tab_ = 0; // Switch back to Editor tab
652+
// Load a snippet's code/global/name into the editor and switch to the Editor tab.
653+
void applySnippet(const SnippetData& s) {
654+
code_ = s.code;
655+
global_code_ = s.global_code;
656+
function_name_ = s.function_name;
657+
switch_to_tab_ = 0;
583658
validation_pending_ = true;
584659
validation_tick_counter_ = 0;
585-
return true;
586660
}
587661

588-
std::string code_ =
589-
"-- Write your Lua function body here.\n"
590-
"-- It receives tracker_time as parameter.\n"
591-
"-- Example:\n"
592-
"-- local series = TimeseriesView(\"my_field\")\n"
593-
"-- local val = series:atTime(tracker_time)\n";
662+
std::string code_;
594663
std::string global_code_;
595664
std::string function_name_;
596665
std::string language_ = "lua";
597666

667+
// Library code (matches PJ3 textLibrary) — editable block of Lua helper functions
668+
static constexpr const char* kDefaultLibraryCode =
669+
"-- Helper functions usable in your reactive scripts\n"
670+
"\n"
671+
"function CreateSeriesFromArray(new_series, prefix, suffix_X, suffix_Y, timestamp)\n"
672+
" new_series:clear()\n"
673+
" local index = 0\n"
674+
" while true do\n"
675+
" local x = index\n"
676+
" if suffix_X ~= nil then\n"
677+
" local series_x = TimeseriesView.find(string.format(\"%s.%d/%s\", prefix, index, suffix_X))\n"
678+
" if series_x == nil then break end\n"
679+
" x = series_x:atTime(timestamp)\n"
680+
" end\n"
681+
" local series_y = TimeseriesView.find(string.format(\"%s.%d/%s\", prefix, index, suffix_Y))\n"
682+
" if series_y == nil then break end\n"
683+
" local y = series_y:atTime(timestamp)\n"
684+
" new_series:push_back(x, y)\n"
685+
" index = index + 1\n"
686+
" end\n"
687+
"end\n"
688+
"\n"
689+
"function GetSeriesNamesByPrefix(prefix)\n"
690+
" local all_names = GetSeriesNames()\n"
691+
" local filtered = {}\n"
692+
" for i, name in ipairs(all_names) do\n"
693+
" if name:find(prefix, 1, #prefix) then\n"
694+
" table.insert(filtered, name)\n"
695+
" end\n"
696+
" end\n"
697+
" return filtered\n"
698+
"end\n"
699+
"\n"
700+
"function ApplyOffsetInPlace(series, delta_x, delta_y)\n"
701+
" for index=0, series:size()-1 do\n"
702+
" local x, y = series:at(index)\n"
703+
" series:set(index, x + delta_x, y + delta_y)\n"
704+
" end\n"
705+
"end\n";
706+
707+
std::string library_code_ = kDefaultLibraryCode;
708+
bool library_valid_ = true; // true when library_code_ compiles without errors
709+
598710
// Terminal / validation
599711
std::string terminal_text_;
600712
bool terminal_visible_ = false;
@@ -613,8 +725,11 @@ class ReactiveScriptEditorDialog : public PJ::DialogPluginTyped {
613725
// Library
614726
std::map<std::string, SnippetData> saved_snippets_;
615727
std::map<std::string, SnippetData> legacy_library_snippets_; // Set by loadConfig() for one-shot migration.
616-
std::string library_search_;
617-
std::string library_selected_;
728+
std::string active_selected_; // selected in series_list or recent_list
729+
// Active Scripts: in-memory only, empty on restart (matches PJ3 listWidgetFunctions)
730+
std::vector<SnippetData> active_scripts_;
731+
// Recent history: persisted, survives restart (matches PJ3 listWidgetRecent)
732+
std::vector<SnippetData> recent_snippets_;
618733
LibrarySaveCallback library_save_callback_;
619734
int switch_to_tab_ = -1; // -1 = no programmatic switch
620735
};
@@ -1058,7 +1173,11 @@ class ReactiveScriptEditorToolbox : public PJ::ToolboxPluginBase {
10581173
if (library_loaded_) {
10591174
return;
10601175
}
1176+
// Active Scripts: loaded from library.json (persists saves/deletes)
10611177
dialog_.setLibrary(loadLibraryFromDisk(luaEditorLibraryPath()));
1178+
// Recent scripts: independent history from recent.json — survives deletion from Active Scripts
1179+
// On startup Active Scripts may be empty (like PJ3) while Recent scripts keeps the history
1180+
dialog_.setRecentSnippets(loadRecentFromDisk(luaEditorRecentPath()));
10621181
library_loaded_ = true;
10631182
}
10641183

0 commit comments

Comments
 (0)