diff --git a/CMakeLists.txt b/CMakeLists.txt index 81261a9..e031848 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -202,7 +202,7 @@ endif() if(PJ_INSTALL_SDK) include(CMakePackageConfigHelpers) - set(PJ_PACKAGE_VERSION "0.5.0") + set(PJ_PACKAGE_VERSION "0.5.1") set(PJ_PACKAGE_CMAKE_DIR ${CMAKE_INSTALL_LIBDIR}/cmake/plotjuggler_core) install(EXPORT plotjuggler_coreTargets diff --git a/conanfile.py b/conanfile.py index b442f98..6891337 100644 --- a/conanfile.py +++ b/conanfile.py @@ -7,7 +7,7 @@ plugin_sdk — umbrella for plugin authors (base + dialog SDK + parser SDK) plugin_host — umbrella for host loaders (data_source/parser/toolbox/dialog) -A consuming Conan recipe declares e.g. `plotjuggler_core/0.5.0` and then: +A consuming Conan recipe declares e.g. `plotjuggler_core/0.5.1` and then: find_package(plotjuggler_core REQUIRED COMPONENTS plugin_sdk) target_link_libraries(my_plugin PRIVATE plotjuggler_core::plugin_sdk) @@ -27,7 +27,7 @@ class PlotjugglerCoreConan(ConanFile): name = "plotjuggler_core" - version = "0.5.0" + version = "0.5.1" # Apache-2.0 covers pj_base + pj_plugins (the plugin-facing SDK); # MPL-2.0 covers pj_datastore (the storage engine). See LICENSE. license = "Apache-2.0 AND MPL-2.0" diff --git a/docs/dialog-sdk-reference.md b/docs/dialog-sdk-reference.md index 7ff7fa2..5f475e9 100644 --- a/docs/dialog-sdk-reference.md +++ b/docs/dialog-sdk-reference.md @@ -90,6 +90,8 @@ For the full tutorial, see [dialog-plugin-guide.md](../pj_plugins/docs/dialog-pl | `setPlainText(name, text)` | Set plain text content | | `setCodeContent(name, code)` | Set editable code content | | `setCodeLanguage(name, lang)` | Set syntax highlighting language such as `"lua"` or `"python"` | +| `setCodeCursor(name, cursor)` | Move the caret to byte offset `cursor` (e.g. after inserting a completion) | +| `setCodeCaretTracking(name, enabled=true)` | Opt into caret tracking: report the caret on cursor moves too, not just edits | ### QTabWidget @@ -139,6 +141,7 @@ Override these in your `DialogPluginTyped` subclass. Return `true` when state ch | `onSelectionChanged(name, items)` | QListWidget, QTableWidget | Vector of selected item texts | | `onItemDoubleClicked(name, index)` | QListWidget, QTableWidget | Row index of double-clicked item | | `onCodeChanged(name, code)` | QPlainTextEdit code editor | Edited code | +| `onCodeChangedWithCursor(name, code, cursor)` | QPlainTextEdit code editor | Edited code + caret offset (`cursor < 0` when no opt-in / not reported); defaults to `onCodeChanged` | | `onItemsDropped(name, items)` | Any widget with `setDropTarget` | Dropped item labels | | `onChartViewChanged(name, x_min, x_max, y_min, y_max)` | QFrame chart container | Visible chart range | | `onTabChanged(name, index)` | QTabWidget | New tab index | diff --git a/pj_plugins/dialog_protocol/include/pj_plugins/host/widget_data_view.hpp b/pj_plugins/dialog_protocol/include/pj_plugins/host/widget_data_view.hpp index ae71864..e55bfa4 100644 --- a/pj_plugins/dialog_protocol/include/pj_plugins/host/widget_data_view.hpp +++ b/pj_plugins/dialog_protocol/include/pj_plugins/host/widget_data_view.hpp @@ -209,6 +209,16 @@ class WidgetDataView { [[nodiscard]] std::optional codeLanguage(std::string_view name) const { return getString(name, "code_language"); } + /// Requested caret offset (bytes) for a code editor; the host moves the caret + /// here after applying new code content. Absent ⇒ leave the caret as-is. + [[nodiscard]] std::optional codeCursor(std::string_view name) const { + return getInt(name, "code_cursor"); + } + /// Whether this code editor opted into caret tracking (setCodeCaretTracking). + /// Absent ⇒ not requested; the host wires cursor-move events only when true. + [[nodiscard]] std::optional codeCaretTracking(std::string_view name) const { + return getBool(name, "code_caret_tracking"); + } // --- QLabel --- [[nodiscard]] std::optional label(std::string_view name) const { diff --git a/pj_plugins/dialog_protocol/include/pj_plugins/host/widget_event_builder.hpp b/pj_plugins/dialog_protocol/include/pj_plugins/host/widget_event_builder.hpp index 0a38e7f..683741b 100644 --- a/pj_plugins/dialog_protocol/include/pj_plugins/host/widget_event_builder.hpp +++ b/pj_plugins/dialog_protocol/include/pj_plugins/host/widget_event_builder.hpp @@ -106,10 +106,15 @@ struct WidgetEventBuilder { return j.dump(); } - /// Code editor: code changed - [[nodiscard]] static std::string codeChanged(std::string_view code) { + /// Code editor: code changed. `cursor` is the caret offset (bytes) in the new + /// text, or negative when unknown; it is serialized only when >= 0, so callers + /// that omit it stay wire-compatible with readers that ignore the field. + [[nodiscard]] static std::string codeChanged(std::string_view code, int cursor = -1) { nlohmann::json j; j["code_changed"] = code; + if (cursor >= 0) { + j["code_cursor"] = cursor; + } return j.dump(); } diff --git a/pj_plugins/dialog_protocol/include/pj_plugins/sdk/dialog_plugin_typed.hpp b/pj_plugins/dialog_protocol/include/pj_plugins/sdk/dialog_plugin_typed.hpp index 16a693a..640f2f0 100644 --- a/pj_plugins/dialog_protocol/include/pj_plugins/sdk/dialog_plugin_typed.hpp +++ b/pj_plugins/dialog_protocol/include/pj_plugins/sdk/dialog_plugin_typed.hpp @@ -72,6 +72,21 @@ class DialogPluginTyped : public DialogPluginBase { return false; } + /// Cursor-aware code change: `cursor` is the caret offset (bytes) in `code`, + /// or negative when the host didn't report one. The dispatch always calls + /// this; it defaults to onCodeChanged(name, code), so existing plugins keep + /// working. Override this (instead of onCodeChanged) to drive caret-aware + /// completion. A distinct name (rather than an overload) avoids the + /// overloaded-virtual hiding hazard. + /// + /// The caret is only reported (and cursor-only moves only fire this at all) + /// for editors that opted in via WidgetData::setCodeCaretTracking. Without + /// opt-in this fires on text changes only, with cursor < 0 — so an editor + /// that merely validates code is not re-run on every cursor move. + virtual bool onCodeChangedWithCursor(std::string_view widget_name, std::string_view code, int /*cursor*/) { + return onCodeChanged(widget_name, code); + } + virtual bool onItemsDropped(std::string_view /*widget_name*/, const std::vector& /*items*/) { return false; } @@ -113,7 +128,7 @@ class DialogPluginTyped : public DialogPluginBase { return onItemsDropped(widget_name, *v); } if (auto v = event.codeChanged()) { - return onCodeChanged(widget_name, *v); + return onCodeChangedWithCursor(widget_name, *v, event.codeCursor().value_or(-1)); } if (auto v = event.text()) { return onTextChanged(widget_name, *v); diff --git a/pj_plugins/dialog_protocol/include/pj_plugins/sdk/widget_data.hpp b/pj_plugins/dialog_protocol/include/pj_plugins/sdk/widget_data.hpp index 3e4bd92..10ed5a9 100644 --- a/pj_plugins/dialog_protocol/include/pj_plugins/sdk/widget_data.hpp +++ b/pj_plugins/dialog_protocol/include/pj_plugins/sdk/widget_data.hpp @@ -213,6 +213,24 @@ class WidgetData { return *this; } + /// Move the caret of a code editor to `cursor` (byte offset). Used after the + /// plugin programmatically rewrites the code (e.g. inserting a completion) so + /// the caret lands where the user expects rather than jumping to the start. + WidgetData& setCodeCursor(std::string_view name, int cursor) { + entry(name)["code_cursor"] = cursor; + return *this; + } + + /// Opt this code editor into caret tracking. When enabled, the host reports + /// the caret offset on cursor moves as well as text edits (via + /// onCodeChangedWithCursor), so the plugin can drive caret-aware completion. + /// Editors that don't opt in only fire on text changes — the default — so an + /// editor that merely validates code isn't re-run on every cursor move. + WidgetData& setCodeCaretTracking(std::string_view name, bool enabled = true) { + entry(name)["code_caret_tracking"] = enabled; + return *this; + } + // --- QLabel --- WidgetData& setLabel(std::string_view name, std::string_view text) { entry(name)["label"] = text; diff --git a/pj_plugins/dialog_protocol/include/pj_plugins/sdk/widget_event.hpp b/pj_plugins/dialog_protocol/include/pj_plugins/sdk/widget_event.hpp index 266546a..d0db1cc 100644 --- a/pj_plugins/dialog_protocol/include/pj_plugins/sdk/widget_event.hpp +++ b/pj_plugins/dialog_protocol/include/pj_plugins/sdk/widget_event.hpp @@ -106,6 +106,16 @@ class WidgetEvent { return getString("code_changed"); } + /// Caret offset (bytes) accompanying a codeChanged event, when the host + /// reported one. Absent for hosts/events that don't carry the cursor. + std::optional codeCursor() const { + auto it = data_.find("code_cursor"); + if (it == data_.end() || !it->is_number_integer()) { + return std::nullopt; + } + return it->get(); + } + /// Drag-and-drop: items dropped on a widget (curves, files, or any draggable payload). std::optional> itemsDropped() const { auto it = data_.find("items_dropped"); diff --git a/pj_plugins/dialog_protocol/tests/dialog_plugin_typed_test.cpp b/pj_plugins/dialog_protocol/tests/dialog_plugin_typed_test.cpp index 90b6ae1..d62d876 100644 --- a/pj_plugins/dialog_protocol/tests/dialog_plugin_typed_test.cpp +++ b/pj_plugins/dialog_protocol/tests/dialog_plugin_typed_test.cpp @@ -95,6 +95,14 @@ class RecordingPlugin : public PJ::DialogPluginTyped { return true; } + bool onCodeChangedWithCursor(std::string_view widget_name, std::string_view code, int cursor) override { + last_handler = "code_changed"; + last_widget = std::string(widget_name); + last_text = std::string(code); + last_int = cursor; + return true; + } + // Recorded state std::string last_handler; std::string last_widget; @@ -241,3 +249,16 @@ TEST_F(TypedDispatchTest, CurrentIndexTakesPriorityOverValue) { EXPECT_TRUE(dispatch(plugin, "w", R"({"current_index": 1, "value": 5})")); EXPECT_EQ(plugin.last_handler, "index_changed"); } + +TEST_F(TypedDispatchTest, CodeChangedCarriesCursorToTypedHandler) { + EXPECT_TRUE(dispatch(plugin, "editor", R"({"code_changed": "robot ==", "code_cursor": 8})")); + EXPECT_EQ(plugin.last_handler, "code_changed"); + EXPECT_EQ(plugin.last_text, "robot =="); + EXPECT_EQ(plugin.last_int, 8); +} + +TEST_F(TypedDispatchTest, CodeChangedWithoutCursorPassesNegativeOne) { + EXPECT_TRUE(dispatch(plugin, "editor", R"({"code_changed": "x"})")); + EXPECT_EQ(plugin.last_handler, "code_changed"); + EXPECT_EQ(plugin.last_int, -1); +} diff --git a/pj_plugins/dialog_protocol/tests/widget_data_test.cpp b/pj_plugins/dialog_protocol/tests/widget_data_test.cpp index 73ea2f8..d313abb 100644 --- a/pj_plugins/dialog_protocol/tests/widget_data_test.cpp +++ b/pj_plugins/dialog_protocol/tests/widget_data_test.cpp @@ -249,3 +249,25 @@ TEST(WidgetDataTest, Chaining) { EXPECT_EQ(j["port"]["value"], 80); EXPECT_EQ(j["tls"]["checked"], true); } + +TEST(WidgetDataTest, SetCodeCursor) { + WidgetData wd; + wd.setCodeContent("editor", "robot ==").setCodeCursor("editor", 8); + auto j = parse(wd); + EXPECT_EQ(j["editor"]["code_content"], "robot =="); + EXPECT_EQ(j["editor"]["code_cursor"], 8); +} + +TEST(WidgetDataTest, SetCodeCaretTracking) { + WidgetData wd; + wd.setCodeCaretTracking("editor"); + auto j = parse(wd); + EXPECT_EQ(j["editor"]["code_caret_tracking"], true); +} + +TEST(WidgetDataTest, SetCodeCaretTrackingExplicitFalse) { + WidgetData wd; + wd.setCodeCaretTracking("editor", false); + auto j = parse(wd); + EXPECT_EQ(j["editor"]["code_caret_tracking"], false); +} diff --git a/pj_plugins/dialog_protocol/tests/widget_data_view_test.cpp b/pj_plugins/dialog_protocol/tests/widget_data_view_test.cpp index acb0894..54d20ae 100644 --- a/pj_plugins/dialog_protocol/tests/widget_data_view_test.cpp +++ b/pj_plugins/dialog_protocol/tests/widget_data_view_test.cpp @@ -251,3 +251,23 @@ TEST(WidgetDataViewTest, RawAccess) { EXPECT_TRUE(raw.is_object()); EXPECT_EQ(raw["w"]["custom_field"], 99); } + +TEST(WidgetDataViewTest, CodeCursor) { + PJ::WidgetDataView v(R"({"editor": {"code_cursor": 12}})"); + EXPECT_EQ(v.codeCursor("editor"), 12); +} + +TEST(WidgetDataViewTest, CodeCursorAbsent) { + PJ::WidgetDataView v(R"({"editor": {"code_content": "x"}})"); + EXPECT_FALSE(v.codeCursor("editor").has_value()); +} + +TEST(WidgetDataViewTest, CodeCaretTracking) { + PJ::WidgetDataView v(R"({"editor": {"code_caret_tracking": true}})"); + EXPECT_EQ(v.codeCaretTracking("editor"), true); +} + +TEST(WidgetDataViewTest, CodeCaretTrackingAbsent) { + PJ::WidgetDataView v(R"({"editor": {"code_content": "x"}})"); + EXPECT_FALSE(v.codeCaretTracking("editor").has_value()); +} diff --git a/pj_plugins/dialog_protocol/tests/widget_event_builder_test.cpp b/pj_plugins/dialog_protocol/tests/widget_event_builder_test.cpp index 958dc23..c108ef0 100644 --- a/pj_plugins/dialog_protocol/tests/widget_event_builder_test.cpp +++ b/pj_plugins/dialog_protocol/tests/widget_event_builder_test.cpp @@ -155,3 +155,20 @@ TEST(WidgetEventBuilderTest, TextChangedDoesNotTriggerOtherFields) { EXPECT_FALSE(ev.checked().has_value()); EXPECT_FALSE(ev.clicked()); } + +TEST(WidgetEventBuilderTest, CodeChangedWithCursor) { + std::string json = PJ::WidgetEventBuilder::codeChanged("robot ==", 8); + PJ::WidgetEvent ev(json); + ASSERT_TRUE(ev.codeChanged().has_value()); + EXPECT_EQ(*ev.codeChanged(), "robot =="); + ASSERT_TRUE(ev.codeCursor().has_value()); + EXPECT_EQ(*ev.codeCursor(), 8); +} + +TEST(WidgetEventBuilderTest, CodeChangedWithoutCursorOmitsField) { + std::string json = PJ::WidgetEventBuilder::codeChanged("x"); + PJ::WidgetEvent ev(json); + ASSERT_TRUE(ev.codeChanged().has_value()); + EXPECT_EQ(*ev.codeChanged(), "x"); + EXPECT_FALSE(ev.codeCursor().has_value()); +} diff --git a/pj_plugins/docs/dialog-plugin-guide.md b/pj_plugins/docs/dialog-plugin-guide.md index 54af12c..7c09f8d 100644 --- a/pj_plugins/docs/dialog-plugin-guide.md +++ b/pj_plugins/docs/dialog-plugin-guide.md @@ -358,7 +358,7 @@ work like polling a server for available topics. | QLabel | `setLabel` | (none — display only) | | QListWidget | `setListItems`, `setSelectedItems` | `onSelectionChanged(name, items)`, `onItemDoubleClicked(name, index)` | | QTableWidget | `setTableHeaders`, `setTableRows`, `setSelectedRows`, `setVisibleRows`, `setRowColor`, `setCellTooltip` | `onSelectionChanged(name, items)`, `onHeaderClicked(name, section)` | -| QPlainTextEdit | `setPlainText`, `setCodeContent`, `setCodeLanguage` | `onCodeChanged(name, code)` for code editors | +| QPlainTextEdit | `setPlainText`, `setCodeContent`, `setCodeLanguage`, `setCodeCursor`, `setCodeCaretTracking` | `onCodeChanged(name, code)`, or `onCodeChangedWithCursor(name, code, cursor)` when the editor opts into caret tracking | | QFrame (chart container) | `setChartSeries`, `clearChart`, `setChartZoomEnabled` | `onChartViewChanged(name, x_min, x_max, y_min, y_max)` | | QDateTimeEdit | `setDateTime`, `setDateTimeRange` | (none — input only) | | RangeSlider (two-handle) | `setRangeSliderBounds`, `setRangeSliderValues`, `setRangeSliderTimeSpan` | `onRangeChanged(name, lower, upper)` |