diff --git a/toolbox_reactive_scripts_editor/reactive_script_editor.cpp b/toolbox_reactive_scripts_editor/reactive_script_editor.cpp index 28aea7a..cbff4b0 100644 --- a/toolbox_reactive_scripts_editor/reactive_script_editor.cpp +++ b/toolbox_reactive_scripts_editor/reactive_script_editor.cpp @@ -46,62 +46,46 @@ struct SnippetData { std::string function_name; }; -std::filesystem::path luaEditorLibraryPath() { - return PJ::sdk::userDataDir() / "toolbox_reactive_scripts_editor" / "library.json"; +std::filesystem::path luaEditorRecentPath() { + return PJ::sdk::userDataDir() / "toolbox_reactive_scripts_editor" / "recent.json"; } -// --------------------------------------------------------------------------- -// Library disk I/O -// -// Returns empty string on success, or a human-readable error on failure. -// --------------------------------------------------------------------------- - -std::string persistLibraryToDisk( - const std::filesystem::path& path, const std::map& snippets) { +void persistRecentToDisk(const std::filesystem::path& path, const std::vector& recent) { try { std::filesystem::create_directories(path.parent_path()); - nlohmann::json j = nlohmann::json::object(); - for (const auto& [name, snippet] : snippets) { - j[name] = { - {"code", snippet.code}, - {"global_code", snippet.global_code}, - {"function_name", snippet.function_name}, - }; + nlohmann::json j = nlohmann::json::array(); + for (const auto& s : recent) { + j.push_back({{"code", s.code}, {"global_code", s.global_code}, {"function_name", s.function_name}}); } std::ofstream out(path); - if (!out) { - return "cannot open " + path.string() + " for writing"; + if (out) { + out << j.dump(2); } - out << j.dump(2); - return ""; - } catch (const std::exception& e) { - return std::string("library write failed: ") + e.what(); - } + } catch (...) {} } -std::map loadLibraryFromDisk(const std::filesystem::path& path) { - std::map result; +std::vector loadRecentFromDisk(const std::filesystem::path& path) { + std::vector result; std::ifstream in(path); if (!in) { - return result; // Missing file is fine: no snippets yet. + return result; } - std::stringstream buf; buf << in.rdbuf(); auto j = nlohmann::json::parse(buf.str(), nullptr, false); - if (j.is_discarded() || !j.is_object()) { + if (!j.is_array()) { return result; } - - for (auto& [key, val] : j.items()) { - if (!val.is_object()) { + for (auto& item : j) { + if (!item.is_object()) { continue; } - result[key] = SnippetData{ - val.value("code", std::string{}), - val.value("global_code", std::string{}), - val.value("function_name", key), - }; + result.push_back( + SnippetData{ + item.value("code", std::string{}), + item.value("global_code", std::string{}), + item.value("function_name", std::string{}), + }); } return result; } @@ -245,8 +229,19 @@ class ReactiveScriptEditorDialog : public PJ::DialogPluginTyped { std::string widget_data() override { PJ::WidgetData wd; - // -- Timeseries list (left panel) -- - wd.setListItems("series_list", series_names_); + // -- Active Scripts: in-memory only, empty on restart (matches PJ3) + std::vector active_names; + for (const auto& s : active_scripts_) { + active_names.push_back(s.function_name); + } + wd.setListItems("series_list", active_names); + + // -- Recent scripts (independent history with full data copies, matches PJ3 listWidgetRecent) + std::vector recent_names; + for (const auto& s : recent_snippets_) { + recent_names.push_back(s.function_name); + } + wd.setListItems("recent_list", recent_names); // -- Language selector -- bool is_lua = (language_ == "lua"); @@ -265,7 +260,8 @@ class ReactiveScriptEditorDialog : public PJ::DialogPluginTyped { .setCodeLanguage("code_editor", language_) .setText("function_name", function_name_) .setEnabled("save_button", !function_name_.empty() && !code_.empty()) - .setEnabled("run_button", !function_name_.empty() && !code_.empty() && !has_syntax_error_); + .setEnabled("run_button", !function_name_.empty() && !code_.empty() && !has_syntax_error_) + .setEnabled("deleteButton", !active_selected_.empty()); // -- Dynamic labels based on language -- if (is_lua) { @@ -283,32 +279,15 @@ class ReactiveScriptEditorDialog : public PJ::DialogPluginTyped { wd.setPlainText("terminal_output", terminal_text_); } - // -- Library tab -- - std::vector visible_names; - for (const auto& [name, snippet] : saved_snippets_) { - if (library_search_.empty() || name.find(library_search_) != std::string::npos) { - visible_names.push_back(name); - } - } - wd.setListItems("library_list", visible_names); - wd.setEnabled("library_use", !library_selected_.empty()); - wd.setEnabled("library_delete", !library_selected_.empty()); - - // Library preview - if (!library_selected_.empty()) { - auto it = saved_snippets_.find(library_selected_); - if (it != saved_snippets_.end()) { - std::string preview; - if (!it->second.global_code.empty()) { - preview += it->second.global_code + "\n\n"; - } - preview += "function " + it->second.function_name + "(tracker_time)\n"; - preview += it->second.code; - preview += "\nend"; - wd.setPlainText("library_preview", preview); - } + // -- Library tab (matches PJ3 textLibrary) -- + wd.setCodeContent("library_editor", library_code_).setCodeLanguage("library_editor", "lua"); + wd.setEnabled("library_apply", !library_valid_); + + // Semaphore: green = library OK, red = syntax error + if (library_valid_) { + wd.setText("labelSemaphore", ""); } else { - wd.setPlainText("library_preview", ""); + wd.setText("labelSemaphore", ""); } // Tab control (switch to Editor when loading snippet from Library) @@ -335,6 +314,12 @@ class ReactiveScriptEditorDialog : public PJ::DialogPluginTyped { terminal_sticky_ = false; return true; } + if (name == "library_editor") { + library_code_ = std::string(code); + // Validate library Lua syntax — pass as global code, empty function body + library_valid_ = validateLuaSyntax(library_code_, "").empty(); + return true; + } return false; } @@ -343,11 +328,6 @@ class ReactiveScriptEditorDialog : public PJ::DialogPluginTyped { function_name_ = std::string(text); return true; } - if (name == "library_search") { - library_search_ = std::string(text); - library_selected_.clear(); - return true; - } return false; } @@ -375,9 +355,16 @@ class ReactiveScriptEditorDialog : public PJ::DialogPluginTyped { bool onClicked(std::string_view name) override { if (name == "save_button" && !function_name_.empty() && !code_.empty()) { - // Save current code to the library (does NOT execute) - saved_snippets_[function_name_] = SnippetData{code_, global_code_, function_name_}; - requestLibraryPersist(); + SnippetData snippet{code_, global_code_, function_name_}; + // Add to Active Scripts (in-memory, matches PJ3 listWidgetFunctions) + active_scripts_.erase( + std::remove_if( + active_scripts_.begin(), active_scripts_.end(), + [&](const SnippetData& s) { return s.function_name == function_name_; }), + active_scripts_.end()); + active_scripts_.push_back(snippet); + // Add to Recent scripts (persisted, matches PJ3 listWidgetRecent) + addToRecent(snippet); return true; } if (name == "run_button" && !function_name_.empty() && !code_.empty()) { @@ -395,29 +382,49 @@ class ReactiveScriptEditorDialog : public PJ::DialogPluginTyped { terminal_sticky_ = true; return true; } - if (name == "library_use") { - return loadSelectedSnippet(); + // Delete button in Active Scripts (in-memory only, matches PJ3 pushButtonDelete) + if (name == "deleteButton" && !active_selected_.empty()) { + active_scripts_.erase( + std::remove_if( + active_scripts_.begin(), active_scripts_.end(), + [&](const SnippetData& s) { return s.function_name == active_selected_; }), + active_scripts_.end()); + active_selected_.clear(); + return true; + } + // Restore default library code (matches PJ3 pushButtonDefaultLibrary) + if (name == "library_restore") { + library_code_ = kDefaultLibraryCode; + library_valid_ = true; + return true; } - if (name == "library_delete" && !library_selected_.empty()) { - saved_snippets_.erase(library_selected_); - library_selected_.clear(); - requestLibraryPersist(); + // Apply changes — mark library as applied (matches PJ3 pushButtonApplyLibrary) + if (name == "library_apply") { + library_valid_ = true; return true; } return false; } bool onSelectionChanged(std::string_view name, const std::vector& items) override { - if (name == "library_list") { - library_selected_ = items.empty() ? "" : items.front(); + // Selecting in either Active Scripts or Recent scripts selects the same snippet + if (name == "series_list" || name == "recent_list") { + active_selected_ = items.empty() ? "" : items.front(); return true; } return false; } - bool onItemDoubleClicked(std::string_view name, int /*index*/) override { - if (name == "library_list") { - return loadSelectedSnippet(); + bool onItemDoubleClicked(std::string_view name, int index) override { + // Double-click on Active Scripts (in-memory) or Recent scripts (PJ3 restoreRecent) + // loads that snippet into the editor. + if (name == "series_list" && index >= 0 && index < static_cast(active_scripts_.size())) { + applySnippet(active_scripts_[static_cast(index)]); + return true; + } + if (name == "recent_list" && index >= 0 && index < static_cast(recent_snippets_.size())) { + applySnippet(recent_snippets_[static_cast(index)]); + return true; } return false; } @@ -492,35 +499,13 @@ class ReactiveScriptEditorDialog : public PJ::DialogPluginTyped { global_code_.clear(); } - // Migration path: legacy workspaces embedded the library inline. Surface - // those snippets via `legacyLibrarySnippets()` so the toolbox can merge - // them into the on-disk library exactly once. - legacy_library_snippets_.clear(); - if (j.contains("library") && j["library"].is_object()) { - for (auto& [key, val] : j["library"].items()) { - legacy_library_snippets_[key] = SnippetData{ - val.value("code", ""), - val.value("global_code", ""), - val.value("function_name", key), - }; - } - } - terminal_visible_ = false; terminal_text_.clear(); validation_pending_ = false; - library_selected_.clear(); - library_search_.clear(); switch_to_tab_ = -1; return true; } - // One-shot accessor for migration: returns inline-library snippets parsed - // from a legacy workspace config and clears them. Empty after the first call. - std::map takeLegacyLibrarySnippets() { - return std::exchange(legacy_library_snippets_, {}); - } - [[nodiscard]] const std::string& code() const { return code_; } @@ -544,57 +529,82 @@ class ReactiveScriptEditorDialog : public PJ::DialogPluginTyped { run_callback_ = std::move(cb); } - // Library injection: called by the toolbox after loading from disk. - void setLibrary(std::map snippets) { - saved_snippets_ = std::move(snippets); + void setRecentSnippets(std::vector recent) { + recent_snippets_ = std::move(recent); } - // Persistence callback: called by the dialog after a save/delete in the library. - // The toolbox provides this to flush the library to disk and surface errors via the runtime host. - using LibrarySaveCallback = std::function&)>; - void setLibrarySaveCallback(LibrarySaveCallback cb) { - library_save_callback_ = std::move(cb); - } - - [[nodiscard]] const std::map& library() const { - return saved_snippets_; - } - - private: - void requestLibraryPersist() { - if (library_save_callback_) { - library_save_callback_(saved_snippets_); + void addToRecent(const SnippetData& snippet) { + recent_snippets_.erase( + std::remove_if( + recent_snippets_.begin(), recent_snippets_.end(), + [&](const SnippetData& s) { return s.function_name == snippet.function_name; }), + recent_snippets_.end()); + recent_snippets_.insert(recent_snippets_.begin(), snippet); + if (recent_snippets_.size() > 10) { + recent_snippets_.resize(10); } + persistRecentToDisk(luaEditorRecentPath(), recent_snippets_); } - bool loadSelectedSnippet() { - if (library_selected_.empty()) { - return false; - } - auto it = saved_snippets_.find(library_selected_); - if (it == saved_snippets_.end()) { - return false; - } - - code_ = it->second.code; - global_code_ = it->second.global_code; - function_name_ = it->second.function_name; - switch_to_tab_ = 0; // Switch back to Editor tab + private: + // Load a snippet's code/global/name into the editor and switch to the Editor tab. + void applySnippet(const SnippetData& s) { + code_ = s.code; + global_code_ = s.global_code; + function_name_ = s.function_name; + switch_to_tab_ = 0; validation_pending_ = true; validation_tick_counter_ = 0; - return true; } - std::string code_ = - "-- Write your Lua function body here.\n" - "-- It receives tracker_time as parameter.\n" - "-- Example:\n" - "-- local series = TimeseriesView(\"my_field\")\n" - "-- local val = series:atTime(tracker_time)\n"; + std::string code_; std::string global_code_; std::string function_name_; std::string language_ = "lua"; + // Library code (matches PJ3 textLibrary) — editable block of Lua helper functions + static constexpr const char* kDefaultLibraryCode = + "-- Helper functions usable in your reactive scripts\n" + "\n" + "function CreateSeriesFromArray(new_series, prefix, suffix_X, suffix_Y, timestamp)\n" + " new_series:clear()\n" + " local index = 0\n" + " while true do\n" + " local x = index\n" + " if suffix_X ~= nil then\n" + " local series_x = TimeseriesView.find(string.format(\"%s.%d/%s\", prefix, index, suffix_X))\n" + " if series_x == nil then break end\n" + " x = series_x:atTime(timestamp)\n" + " end\n" + " local series_y = TimeseriesView.find(string.format(\"%s.%d/%s\", prefix, index, suffix_Y))\n" + " if series_y == nil then break end\n" + " local y = series_y:atTime(timestamp)\n" + " new_series:push_back(x, y)\n" + " index = index + 1\n" + " end\n" + "end\n" + "\n" + "function GetSeriesNamesByPrefix(prefix)\n" + " local all_names = GetSeriesNames()\n" + " local filtered = {}\n" + " for i, name in ipairs(all_names) do\n" + " if name:find(prefix, 1, #prefix) then\n" + " table.insert(filtered, name)\n" + " end\n" + " end\n" + " return filtered\n" + "end\n" + "\n" + "function ApplyOffsetInPlace(series, delta_x, delta_y)\n" + " for index=0, series:size()-1 do\n" + " local x, y = series:at(index)\n" + " series:set(index, x + delta_x, y + delta_y)\n" + " end\n" + "end\n"; + + std::string library_code_ = kDefaultLibraryCode; + bool library_valid_ = true; // true when library_code_ compiles without errors + // Terminal / validation std::string terminal_text_; bool terminal_visible_ = false; @@ -610,12 +620,11 @@ class ReactiveScriptEditorDialog : public PJ::DialogPluginTyped { // Timeseries (populated by toolbox before dialog opens) std::vector series_names_; - // Library - std::map saved_snippets_; - std::map legacy_library_snippets_; // Set by loadConfig() for one-shot migration. - std::string library_search_; - std::string library_selected_; - LibrarySaveCallback library_save_callback_; + std::string active_selected_; // selected in series_list or recent_list + // Active Scripts: in-memory only, empty on restart (matches PJ3 listWidgetFunctions) + std::vector active_scripts_; + // Recent history: persisted, survives restart (matches PJ3 listWidgetRecent) + std::vector recent_snippets_; int switch_to_tab_ = -1; // -1 = no programmatic switch }; @@ -775,11 +784,9 @@ class ReactiveScriptEditorToolbox : public PJ::ToolboxPluginBase { } dialog_.setSeriesNames(series_names_); } - ensureLibraryLoaded(); + ensureRecentLoaded(); dialog_.setRunCallback( [this](const std::string& c, const std::string& g, const std::string& n) { return executeScript(c, g, n); }); - dialog_.setLibrarySaveCallback( - [this](const std::map& snippets) { writeLibrary(snippets); }); return PJ::borrowDialog(dialog_); } @@ -792,24 +799,6 @@ class ReactiveScriptEditorToolbox : public PJ::ToolboxPluginBase { return PJ::unexpected("invalid config JSON"); } - // Migrate inline library from a legacy workspace into the on-disk store - // (one-shot; the dialog only surfaces these once after each loadConfig). - auto legacy = dialog_.takeLegacyLibrarySnippets(); - if (!legacy.empty()) { - ensureLibraryLoaded(); - auto merged = dialog_.library(); - for (auto& [k, v] : legacy) { - merged.try_emplace(k, std::move(v)); // Disk wins on conflict. - } - dialog_.setLibrary(merged); - writeLibrary(merged); - if (runtimeHostBound()) { - runtimeHost().reportMessage( - PJ::ToolboxMessageLevel::kInfo, - "Migrated " + std::to_string(legacy.size()) + " legacy library snippet(s) to disk"); - } - } - if (auto_run_pending_ && !dialog_.code().empty() && !dialog_.functionName().empty() && toolboxHostBound() && runtimeHostBound()) { auto_run_pending_ = false; @@ -1053,27 +1042,20 @@ class ReactiveScriptEditorToolbox : public PJ::ToolboxPluginBase { } #endif // PJ_REACTIVE_HAS_PYTHON - // Loads the on-disk library into the dialog the first time it's needed. - void ensureLibraryLoaded() { - if (library_loaded_) { + // Loads the persisted Recent-scripts history into the dialog the first time it's + // needed. Active Scripts are in-memory only (empty on startup, like PJ3). + void ensureRecentLoaded() { + if (recent_loaded_) { return; } - dialog_.setLibrary(loadLibraryFromDisk(luaEditorLibraryPath())); - library_loaded_ = true; - } - - // Persists the library to disk; surfaces failures via the runtime host. - void writeLibrary(const std::map& snippets) { - std::string err = persistLibraryToDisk(luaEditorLibraryPath(), snippets); - if (!err.empty() && runtimeHostBound()) { - runtimeHost().reportMessage(PJ::ToolboxMessageLevel::kWarning, "Lua editor library: " + err); - } + dialog_.setRecentSnippets(loadRecentFromDisk(luaEditorRecentPath())); + recent_loaded_ = true; } ReactiveScriptEditorDialog dialog_; std::unordered_map series_map_; std::vector series_names_; - bool library_loaded_ = false; + bool recent_loaded_ = false; bool auto_run_pending_ = true; }; diff --git a/toolbox_reactive_scripts_editor/reactive_script_editor_dialog.ui b/toolbox_reactive_scripts_editor/reactive_script_editor_dialog.ui index 4e14a6e..0391a28 100644 --- a/toolbox_reactive_scripts_editor/reactive_script_editor_dialog.ui +++ b/toolbox_reactive_scripts_editor/reactive_script_editor_dialog.ui @@ -3,194 +3,224 @@ ReactiveScriptEditorDialog - Reactive Script Editor + Script Editor - 800550 + 900600 4 + + + + + 12 + + + The <span style=" font-weight:600;">Script Editor </span>is an advanced Lua editor that allows the user to create new series (ScatterXY or Timeseries) which are updated when the timetracker slider is moved or new data is received. <a href="https://slides.com/davidefaconti/plotjuggler-reactive-scripts/fullscreen"><span style=" text-decoration: underline; color:#0000ff;">Tutorial link</span></a>. + + true + true + + + + + + QFrame::Plain + Qt::Horizontal + + + + 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; +} + + - + - Editor + Script Editor 4 - - - - - - 00 - - - - 012 - - - 1677721580 - - - Monospace10 - - - QPlainTextEdit { - background-color: #4a4a4a; - color: #f0f0f0; - border: 1px solid #666; - font-family: "Monospace"; -} -QScrollBar:vertical { - background: #2b2b2b; - width: 10px; -} -QScrollBar::handle:vertical { - background: #555; - border-radius: 4px; -} - - true - 500 - - - - + 8 - + - - - 2000 - - - 30016777215 - - - 0 - 0 - 0 - 0 + + 4 - - - Timeseries: - true - - - - - - - 03 - - - - - - - Global variables: - - - - - - - 01 - - - - Monospace11 - - - Add your global variables here - - - + + + Global code, execute once: + + + + + + Monospace12 + + + QPlainTextEdit { border: 1px solid #888; } + + + define here your global variables + + + - - + + + The following function is called every time the time tracker is moved or new data is received. + true + + + + + + true + function(tracker_time) + + + + + + + + 02 + + + + Monospace12 + + + QPlainTextEdit { border: 1px solid #888; } + + + body of the function. tracker_time is the value of the time slider + + + + + - + - - - 0 - 0 - 0 - 0 + + 4 - - - - - - function(tracker_time) - true - - - - - Qt::Horizontal - - 4020 - - - - - - Lua - true - - - - - Python - - - - - Save to Library - - - - + + + + + + Name: + + + + + name the new function + + + + + false + Save + + + + - - - - - - 01 - - - - Monospace11 - - - Write your function implementation here - - - + + + + 11 + + + Active Scripts: + + + + + false + 2828 + 2828 + + <html><head/><body><p>Remove selected script</p></body></html> + + + + + :/resources/svg/trash.svg:/resources/svg/trash.svg + + + + 2525 + + true + + + + - - - - end - true - - + + + + + QListWidget { border: 1px solid #888; } + + + - - + + + + + <html><head/><body><p>Recent scripts (double-click to load):</p></body></html> + + true + + + + + + + + QListWidget { border: 1px solid #888; } + + + + + @@ -199,50 +229,53 @@ QScrollBar::handle:vertical { - + - Library + Function Library + - - Search snippets... - - - - - - 0150 - - - - - - Preview: - true - - - - - - 0120 - - - Monospace10 - - true - + + 10 + + + Add here your helper functions, which can be used in your script. Useful to make your scripts less verbose. + true + + + + + 3232 + 3232 + Qt::AlignCenter + + + + + + + 5 - - Use + + + + 00 + + + Restore default - - Delete + + false + + <html><head/><body><p>If you have already created one or more reactive series, reload them with the new library.</p></body></html> + + Apply changes @@ -256,44 +289,25 @@ QScrollBar::handle:vertical { + + + + + Monospace12 + + + QPlainTextEdit { border: 1px solid #888; } + + + + - - - - - - Qt::Horizontal - - 4020 - - - - - - Name: - - - - - - 2000 - - name the new function - - - - - Run - - - - - +