From ee70ee0487ddd3e8ab7ab0a5f5b98b7af2551ce1 Mon Sep 17 00:00:00 2001 From: GNERSIS Date: Mon, 25 May 2026 20:36:22 +0400 Subject: [PATCH 1/5] feat(dialog-protocol): widget channels + Image codec for the Mosaico toolbox Adds the RangeSlider, SequencePicker date-range, MetadataQueryBar, QDateTimeEdit and QTableWidget filter/cell-style channels, the requestClose panel command, the canonical Image byte codec, and the toolbox object-write API the Mosaico plugin builds on. Co-Authored-By: Claude Opus 4.7 (1M context) --- pj_base/include/pj_base/plugin_data_api.h | 35 +++- .../include/pj_base/sdk/plugin_data_api.hpp | 51 +++++ .../include/pj_datastore/plugin_data_host.hpp | 6 +- pj_datastore/src/plugin_data_host.cpp | 86 +++++++- .../tests/arrow_stream_round_trip_test.cpp | 4 +- pj_datastore/tests/plugin_host_read_test.cpp | 4 +- pj_datastore/tests/plugin_host_write_test.cpp | 4 +- .../pj_plugins/host/widget_data_view.hpp | 193 ++++++++++++++++++ .../pj_plugins/host/widget_event_builder.hpp | 37 ++++ .../pj_plugins/sdk/dialog_plugin_base.hpp | 16 +- .../pj_plugins/sdk/dialog_plugin_typed.hpp | 38 ++++ .../include/pj_plugins/sdk/widget_data.hpp | 146 +++++++++++++ .../include/pj_plugins/sdk/widget_event.hpp | 54 +++++ pj_plugins/docs/dialog-plugin-guide.md | 10 +- .../pj_plugins/testing/toolbox_test_store.hpp | 5 + pj_plugins/tests/toolbox_plugin_test.cpp | 5 + 16 files changed, 680 insertions(+), 14 deletions(-) diff --git a/pj_base/include/pj_base/plugin_data_api.h b/pj_base/include/pj_base/plugin_data_api.h index 9b9fd97..5d82106 100644 --- a/pj_base/include/pj_base/plugin_data_api.h +++ b/pj_base/include/pj_base/plugin_data_api.h @@ -183,6 +183,15 @@ typedef struct { uint32_t id; } PJ_field_handle_t; +/* ABI-FROZEN: layout permanent; changes = v5 break. + * + * Declared up here (rather than next to the object-store host vtables below) + * because PJ_toolbox_host_vtable_t now references it: toolbox plugins can + * register/push canonical media topics directly through the toolbox host. */ +typedef struct { + uint32_t id; /* 0 == invalid handle */ +} PJ_object_topic_handle_t; + /* ========================================================================== * Protocol v4 core types * @@ -489,6 +498,26 @@ typedef struct PJ_toolbox_host_vtable_t { bool (*read_series_arrow)( void* ctx, PJ_field_handle_t field, struct ArrowSchema* out_schema, struct ArrowArray* out_array, PJ_error_t* out_error) PJ_NOEXCEPT; + + /* [main-thread] Register an object topic for media payloads (images, point + * clouds, annotations). The topic is namespaced under the data source + * previously created via create_data_source. `metadata_json` is opaque to + * the host — viewers and parsers read it to pick a renderer (e.g. + * {"object_type":"image"} for the MediaViewerWidget). Returns false (with + * out_error populated) if the source handle is unknown or a topic with + * this name already exists for the data source. */ + bool (*register_object_topic)( + void* ctx, PJ_data_source_handle_t source, PJ_string_view_t topic_name, PJ_string_view_t metadata_json, + PJ_object_topic_handle_t* out_handle, PJ_error_t* out_error) PJ_NOEXCEPT; + + /* [main-thread] Eager push of an object payload — host copies the bytes + * into ObjectStore. Appropriate for both small messages and the + * one-shot media writes that toolbox plugins typically perform; lazy + * push is not offered on the toolbox surface (plugin holds the bytes + * already by the time it calls). */ + bool (*push_owned_object)( + void* ctx, PJ_object_topic_handle_t topic, int64_t timestamp_ns, const uint8_t* data, size_t size, + PJ_error_t* out_error) PJ_NOEXCEPT; } PJ_toolbox_host_vtable_t; typedef struct { @@ -511,10 +540,8 @@ typedef struct { * `pj.parser_object_write.v1` in addition to `pj.parser_write.v1`. * ========================================================================== */ -/* ABI-FROZEN: layout permanent; changes = ABI break. */ -typedef struct { - uint32_t id; /* 0 == invalid handle */ -} PJ_object_topic_handle_t; +/* PJ_object_topic_handle_t is declared earlier (next to PJ_field_handle_t) + * because the toolbox host vtable references it as well. */ /* Lazy-fetch callback type. Invoked by the host on-demand when a consumer * reads an entry stored via push_lazy. On success the plugin populates diff --git a/pj_base/include/pj_base/sdk/plugin_data_api.hpp b/pj_base/include/pj_base/sdk/plugin_data_api.hpp index f11bd55..f500668 100644 --- a/pj_base/include/pj_base/sdk/plugin_data_api.hpp +++ b/pj_base/include/pj_base/sdk/plugin_data_api.hpp @@ -1178,12 +1178,63 @@ class ToolboxHostView { return MaterializedSeriesView(std::move(schema), std::move(array)); } + /// Register an object topic for media payloads (images, point clouds, + /// annotations) under a previously created data source. `metadata_json` + /// is opaque to the store and retained verbatim; viewers and parsers + /// read it to pick a renderer. + /// + /// Returns `unexpected` if the host predates this ABI slot — older hosts + /// can be detected via the ABI's `PJ_HAS_TAIL_SLOT` macro; in this + /// view's terms, the function pointer will be null. + [[nodiscard]] Expected registerObjectTopic( + DataSourceHandle source, std::string_view name, std::string_view metadata_json) const { + if (!valid()) { + return unexpected("toolbox host is not bound"); + } + if (!hasTailSlot(offsetof(PJ_toolbox_host_vtable_t, register_object_topic), host_.vtable->register_object_topic)) { + return unexpected("toolbox host does not support object topics (older host)"); + } + ObjectTopicHandle handle{}; + PJ_error_t err{}; + if (!host_.vtable->register_object_topic( + host_.ctx, source, toAbiString(name), toAbiString(metadata_json), &handle, &err)) { + return unexpected(errorToString(err)); + } + return handle; + } + + /// Eager push of an object payload — host copies the bytes into its own + /// storage. Returns `unexpected` on older hosts that don't expose the + /// slot (see registerObjectTopic). + [[nodiscard]] Status pushOwnedObject(ObjectTopicHandle topic, Timestamp ts, Span payload) const { + if (!valid()) { + return unexpected("toolbox host is not bound"); + } + if (!hasTailSlot(offsetof(PJ_toolbox_host_vtable_t, push_owned_object), host_.vtable->push_owned_object)) { + return unexpected("toolbox host does not support object payloads (older host)"); + } + PJ_error_t err{}; + if (!host_.vtable->push_owned_object(host_.ctx, topic, ts, payload.data(), payload.size(), &err)) { + return unexpected(errorToString(err)); + } + return okStatus(); + } + [[nodiscard]] const PJ_toolbox_host_t& raw() const noexcept { return host_; } private: PJ_toolbox_host_t host_{}; + + /// Tail-slot guard mirroring PJ_HAS_TAIL_SLOT from the C ABI: the host's + /// struct_size must be large enough to cover the field, AND the slot + /// must be non-null. Templated on the function-pointer type so the + /// sizeof check stays accurate without naming the typedef. + template + [[nodiscard]] bool hasTailSlot(std::size_t field_offset, Fn fn) const noexcept { + return host_.vtable != nullptr && host_.vtable->struct_size >= field_offset + sizeof(Fn) && fn != nullptr; + } }; // --------------------------------------------------------------------------- diff --git a/pj_datastore/include/pj_datastore/plugin_data_host.hpp b/pj_datastore/include/pj_datastore/plugin_data_host.hpp index 6a109e0..27365f7 100644 --- a/pj_datastore/include/pj_datastore/plugin_data_host.hpp +++ b/pj_datastore/include/pj_datastore/plugin_data_host.hpp @@ -117,7 +117,11 @@ class DatastoreParserObjectWriteHost { class DatastoreToolboxHost { public: - explicit DatastoreToolboxHost(DataEngine& engine); + /// Construct with both an engine (scalar/Arrow column writes) and an + /// object store (canonical media payloads — images, point clouds, + /// annotations). The two are independent storage backends; toolbox + /// plugins write into one or both via the same host fat pointer. + DatastoreToolboxHost(DataEngine& engine, ObjectStore& object_store); ~DatastoreToolboxHost(); DatastoreToolboxHost(const DatastoreToolboxHost&) = delete; diff --git a/pj_datastore/src/plugin_data_host.cpp b/pj_datastore/src/plugin_data_host.cpp index 0b0ae25..4afeb3e 100644 --- a/pj_datastore/src/plugin_data_host.cpp +++ b/pj_datastore/src/plugin_data_host.cpp @@ -921,8 +921,17 @@ struct DatastoreParserWriteHostState { }; struct DatastoreToolboxHostState { - explicit DatastoreToolboxHostState(DataEngine& engine) : core(engine) {} + DatastoreToolboxHostState(DataEngine& engine, ObjectStore& store) : core(engine), object_store(store) {} ToolboxCore core; + // Toolbox plugins share the session's object store; the host holds a + // reference so register_object_topic + push_owned_object can forward + // without going back through the engine. + ObjectStore& object_store; + std::string object_last_error; + + void setObjectError(std::string msg) { + object_last_error = std::move(msg); + } }; struct DatastoreSourceObjectWriteHostState { @@ -1199,6 +1208,75 @@ bool toolboxReadSeriesArrow( }); } +bool toolboxRegisterObjectTopic( + void* ctx, DataSourceHandle source, PJ_string_view_t topic_name, PJ_string_view_t metadata_json, + PJ_object_topic_handle_t* out_handle, PJ_error_t* out_error) noexcept { + auto* impl = static_cast(ctx); + if (out_handle == nullptr) { + propagateError(out_error, "out_handle must not be null"); + return false; + } + // Validate the source handle against the engine — same check used by + // scalar ensureTopic so the toolbox can't register a topic against a + // dataset that doesn't exist. + if (impl->core.engine_.getDataset(source.id) == nullptr) { + impl->setObjectError(fmt::format("data source {} not found", source.id)); + propagateError(out_error, impl->object_last_error.c_str()); + return false; + } + try { + ObjectTopicDescriptor desc{}; + desc.dataset_id = source.id; + desc.topic_name = std::string(toStringView(topic_name)); + desc.metadata_json = std::string(toStringView(metadata_json)); + auto result = impl->object_store.registerTopic(desc); + if (!result) { + impl->setObjectError(result.error()); + propagateError(out_error, impl->object_last_error.c_str()); + return false; + } + out_handle->id = result->id; + impl->object_last_error.clear(); + return true; + } catch (const std::exception& e) { + impl->setObjectError(e.what()); + propagateError(out_error, impl->object_last_error.c_str()); + return false; + } catch (...) { + impl->setObjectError("registerObjectTopic: unknown exception"); + propagateError(out_error, impl->object_last_error.c_str()); + return false; + } +} + +bool toolboxPushOwnedObject( + void* ctx, PJ_object_topic_handle_t topic, int64_t timestamp_ns, const uint8_t* data, std::size_t size, + PJ_error_t* out_error) noexcept { + auto* impl = static_cast(ctx); + try { + std::vector bytes; + if (data != nullptr && size > 0) { + bytes.assign(data, data + size); + } + auto result = impl->object_store.pushOwned(ObjectTopicId{topic.id}, timestamp_ns, std::move(bytes)); + if (!result) { + impl->setObjectError(result.error()); + propagateError(out_error, impl->object_last_error.c_str()); + return false; + } + impl->object_last_error.clear(); + return true; + } catch (const std::exception& e) { + impl->setObjectError(e.what()); + propagateError(out_error, impl->object_last_error.c_str()); + return false; + } catch (...) { + impl->setObjectError("pushOwnedObject: unknown exception"); + propagateError(out_error, impl->object_last_error.c_str()); + return false; + } +} + /// RAII holder for the plugin-owned `fetch_ctx` passed to push_lazy. Stores /// the destroy callback pointer and the ctx value; destroys both on drop. /// Wrapped in a shared_ptr so the lambda that ObjectStore stores remains @@ -1597,6 +1675,8 @@ const PJ_toolbox_host_vtable_t kToolboxVTable = { toolboxAppendArrowStream, toolboxAcquireCatalogSnapshot, toolboxReadSeriesArrow, + toolboxRegisterObjectTopic, + toolboxPushOwnedObject, }; const PJ_object_write_host_vtable_t kSourceObjectWriteVTable = { @@ -1647,8 +1727,8 @@ void DatastoreParserWriteHost::flushPending() { state_->core.flushPending(); } -DatastoreToolboxHost::DatastoreToolboxHost(DataEngine& engine) - : state_(std::make_unique(engine)) {} +DatastoreToolboxHost::DatastoreToolboxHost(DataEngine& engine, ObjectStore& object_store) + : state_(std::make_unique(engine, object_store)) {} DatastoreToolboxHost::~DatastoreToolboxHost() = default; DatastoreToolboxHost::DatastoreToolboxHost(DatastoreToolboxHost&&) noexcept = default; DatastoreToolboxHost& DatastoreToolboxHost::operator=(DatastoreToolboxHost&&) noexcept = default; diff --git a/pj_datastore/tests/arrow_stream_round_trip_test.cpp b/pj_datastore/tests/arrow_stream_round_trip_test.cpp index c8d1cb3..4b139f8 100644 --- a/pj_datastore/tests/arrow_stream_round_trip_test.cpp +++ b/pj_datastore/tests/arrow_stream_round_trip_test.cpp @@ -26,6 +26,7 @@ #include "pj_base/type_tree.hpp" #include "pj_base/types.hpp" #include "pj_datastore/engine.hpp" +#include "pj_datastore/object_store.hpp" #include "pj_datastore/plugin_data_host.hpp" namespace PJ { @@ -152,7 +153,8 @@ TEST(ArrowStreamRoundTripTest, WriteViaAppendArrowStreamReadViaReadSeriesArrow) write_host.flushPending(); // Catalog snapshot — look up the field handle for "value". - DatastoreToolboxHost tb_host(engine); + ObjectStore object_store; + DatastoreToolboxHost tb_host(engine, object_store); auto tb_vtable = tb_host.raw(); PJ_catalog_snapshot_t snapshot{}; diff --git a/pj_datastore/tests/plugin_host_read_test.cpp b/pj_datastore/tests/plugin_host_read_test.cpp index 4bc0cec..a6da23b 100644 --- a/pj_datastore/tests/plugin_host_read_test.cpp +++ b/pj_datastore/tests/plugin_host_read_test.cpp @@ -13,6 +13,7 @@ #include "pj_base/sdk/plugin_data_api.hpp" #include "pj_base/type_tree.hpp" #include "pj_datastore/engine.hpp" +#include "pj_datastore/object_store.hpp" #include "pj_datastore/plugin_data_host.hpp" #include "pj_datastore/writer.hpp" @@ -23,7 +24,8 @@ using namespace PJ::sdk; struct Fixture { DataEngine engine; - DatastoreToolboxHost toolbox_impl{engine}; + ObjectStore object_store; + DatastoreToolboxHost toolbox_impl{engine, object_store}; ToolboxHostView toolbox{toolbox_impl.raw()}; }; diff --git a/pj_datastore/tests/plugin_host_write_test.cpp b/pj_datastore/tests/plugin_host_write_test.cpp index 3873476..c7be2f9 100644 --- a/pj_datastore/tests/plugin_host_write_test.cpp +++ b/pj_datastore/tests/plugin_host_write_test.cpp @@ -14,6 +14,7 @@ #include "nanoarrow/nanoarrow_ipc.h" #include "pj_base/sdk/plugin_data_api.hpp" #include "pj_datastore/engine.hpp" +#include "pj_datastore/object_store.hpp" #include "pj_datastore/plugin_data_host.hpp" namespace PJ { @@ -50,7 +51,8 @@ std::vector serializeToIpc(ArrowSchema* schema, ArrowArray* array) { struct Fixture { DataEngine engine; - DatastoreToolboxHost toolbox_impl{engine}; + ObjectStore object_store; + DatastoreToolboxHost toolbox_impl{engine, object_store}; ToolboxHostView toolbox{toolbox_impl.raw()}; }; 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 39c34ed..445ecde 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 @@ -2,10 +2,14 @@ // Copyright 2026 Davide Faconti // SPDX-License-Identifier: MIT +#include +#include #include #include #include #include +#include +#include #include namespace PJ { @@ -253,6 +257,87 @@ class WidgetDataView { return getString(name, "title"); } + // --- RangeSlider --- + [[nodiscard]] std::optional rangeSliderMin(std::string_view name) const { + return getInt(name, "range_min"); + } + [[nodiscard]] std::optional rangeSliderMax(std::string_view name) const { + return getInt(name, "range_max"); + } + [[nodiscard]] std::optional rangeSliderLower(std::string_view name) const { + return getInt(name, "range_lower"); + } + [[nodiscard]] std::optional rangeSliderUpper(std::string_view name) const { + return getInt(name, "range_upper"); + } + /// Returns the [min_ns, max_ns] time window for duration labels, parsed from + /// the string-encoded nanosecond fields. nullopt if not set. + [[nodiscard]] std::optional> rangeSliderTimeSpan(std::string_view name) const { + auto mn = getString(name, "range_time_min_ns"); + auto mx = getString(name, "range_time_max_ns"); + if (!mn.has_value() || !mx.has_value()) { + return std::nullopt; + } + try { + return std::make_pair(static_cast(std::stoll(*mn)), static_cast(std::stoll(*mx))); + } catch (...) { + return std::nullopt; + } + } + + // --- SequencePicker --- + [[nodiscard]] std::optional datePickerEarliest(std::string_view name) const { + return getString(name, "picker_earliest"); + } + + // --- MetadataQueryBar --- + [[nodiscard]] std::optional> queryKeys(std::string_view name) const { + return getStringArray(name, "query_keys"); + } + [[nodiscard]] std::optional> queryOperators(std::string_view name) const { + return getStringArray(name, "query_ops"); + } + [[nodiscard]] std::optional> queryValues(std::string_view name) const { + return getStringArray(name, "query_values"); + } + [[nodiscard]] std::optional> queryCompletions(std::string_view name) const { + return getStringArray(name, "query_completions"); + } + /// Read the key→values schema for a self-contained MetadataQueryBar (the JSON + /// is a {key: [values]} object written by WidgetData::setQuerySchema). + [[nodiscard]] std::optional>> querySchema( + std::string_view name) const { + const nlohmann::json* w = widget(name); + if (!w) { + return std::nullopt; + } + auto it = w->find("query_schema"); + if (it == w->end() || !it->is_object()) { + return std::nullopt; + } + std::map> result; + for (const auto& [key, vals] : it->items()) { + if (!vals.is_array()) { + continue; + } + std::vector values; + values.reserve(vals.size()); + for (const auto& v : vals) { + if (v.is_string()) { + values.push_back(v.get()); + } + } + result.emplace(key, std::move(values)); + } + return result; + } + [[nodiscard]] std::optional queryFeedbackText(std::string_view name) const { + return getString(name, "query_feedback_text"); + } + [[nodiscard]] std::optional queryFeedbackOk(std::string_view name) const { + return getBool(name, "query_feedback_ok"); + } + // --- QDialogButtonBox --- [[nodiscard]] std::optional okEnabled(std::string_view name) const { return getBool(name, "ok_enabled"); @@ -285,6 +370,19 @@ class WidgetDataView { return result; } + // --- QDateTimeEdit --- + [[nodiscard]] std::optional dateTime(std::string_view name) const { + return getString(name, "datetime"); + } + [[nodiscard]] std::optional> dateTimeRange(std::string_view name) const { + auto mn = getString(name, "datetime_min"); + auto mx = getString(name, "datetime_max"); + if (!mn.has_value() && !mx.has_value()) { + return std::nullopt; + } + return std::make_pair(mn.value_or(std::string()), mx.value_or(std::string())); + } + // --- Generic (any widget) --- [[nodiscard]] std::optional enabled(std::string_view name) const { return getBool(name, "enabled"); @@ -312,6 +410,101 @@ class WidgetDataView { return ui_it->get(); } + /// Returns the close-reason string if WidgetData::requestClose was called, or + /// nullopt. Observed by PanelEngine; ignored by DialogEngine. + [[nodiscard]] std::optional requestClose() const { + auto it = data_.find("__request_close"); + if (it == data_.end() || !it->is_string()) { + return std::nullopt; + } + return it->get(); + } + + // --- QTableWidget filtering / styling (Mosaico parity) --- + + /// List of row indexes that should remain visible (rows not listed are + /// hidden). Returns nullopt when the field is missing OR JSON null + /// (clearVisibleRows): in both cases the host applies no visibility change + /// this update. To show every row, send the full index list explicitly. + /// NOTE: missing and null are intentionally indistinguishable here; a future + /// 3-state contract (unset / show-all / explicit-set) would need a sentinel. + [[nodiscard]] std::optional> visibleRows(std::string_view name) const { + const nlohmann::json* w = widget(name); + if (w == nullptr) { + return std::nullopt; + } + auto it = w->find("visible_rows"); + if (it == w->end() || it->is_null()) { + return std::nullopt; + } + if (!it->is_array()) { + return std::nullopt; + } + std::vector result; + result.reserve(it->size()); + for (const auto& item : *it) { + if (item.is_number_integer()) { + result.push_back(item.get()); + } + } + return result; + } + + /// row index → "#rrggbb" tint. Empty string clears the override. + [[nodiscard]] std::optional>> rowColors(std::string_view name) const { + const nlohmann::json* w = widget(name); + if (w == nullptr) { + return std::nullopt; + } + auto it = w->find("row_colors"); + if (it == w->end() || !it->is_object()) { + return std::nullopt; + } + std::vector> result; + for (auto kv = it->begin(); kv != it->end(); ++kv) { + try { + int row = std::stoi(kv.key()); + if (kv.value().is_string()) { + result.emplace_back(row, kv.value().get()); + } + } catch (...) { + // skip malformed keys + } + } + return result; + } + + /// (row, col, tooltip) tuples for cell-level tooltips. + [[nodiscard]] std::optional>> cellTooltips( + std::string_view name) const { + const nlohmann::json* w = widget(name); + if (w == nullptr) { + return std::nullopt; + } + auto it = w->find("cell_tooltips"); + if (it == w->end() || !it->is_object()) { + return std::nullopt; + } + std::vector> result; + for (auto kv = it->begin(); kv != it->end(); ++kv) { + const std::string& key = kv.key(); + auto comma = key.find(','); + if (comma == std::string::npos) { + continue; + } + try { + int row = std::stoi(key.substr(0, comma)); + int col = std::stoi(key.substr(comma + 1)); + if (kv.value().is_string()) { + result.emplace_back(row, col, kv.value().get()); + } + } catch (...) { + // skip malformed keys + } + } + return result; + } + // --- Enumeration --- [[nodiscard]] std::vector widgetNames() const { std::vector names; 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 e917370..6c3e2ff 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 @@ -97,6 +97,15 @@ struct WidgetEventBuilder { return j.dump(); } + /// QTableWidget: a horizontal-header section was clicked (column index). + /// Lets a plugin own column sorting — it re-sorts its row model and re-emits + /// rows, keeping index-based selection/visibility consistent. + [[nodiscard]] static std::string headerClicked(int section) { + nlohmann::json j; + j["header_section"] = section; + return j.dump(); + } + /// Code editor: code changed [[nodiscard]] static std::string codeChanged(std::string_view code) { nlohmann::json j; @@ -111,6 +120,34 @@ struct WidgetEventBuilder { return j.dump(); } + /// SequencePicker: date/time range filter changed. from/to are ISO-8601 + /// datetime strings (empty = unbounded on that side). + [[nodiscard]] static std::string dateRangeChanged(std::string_view from_iso, std::string_view to_iso, + bool every_day) { + nlohmann::json j; + j["date_from_iso"] = from_iso; + j["date_to_iso"] = to_iso; + j["every_day"] = every_day; + return j.dump(); + } + + /// MetadataQueryBar: a key/op/value selector combo was activated. + /// role is "key" | "op" | "value". + [[nodiscard]] static std::string querySelector(std::string_view role, std::string_view value) { + nlohmann::json j; + j["query_selector_role"] = role; + j["query_selector_value"] = value; + return j.dump(); + } + + /// RangeSlider: lower/upper handle position changed (slider units). + [[nodiscard]] static std::string rangeChanged(int lower, int upper) { + nlohmann::json j; + j["range_lower"] = lower; + j["range_upper"] = upper; + return j.dump(); + } + /// ChartPreviewWidget: visible range changed via zoom or pan. [[nodiscard]] static std::string chartViewChanged(double x_min, double x_max, double y_min, double y_max) { nlohmann::json j; diff --git a/pj_plugins/dialog_protocol/include/pj_plugins/sdk/dialog_plugin_base.hpp b/pj_plugins/dialog_protocol/include/pj_plugins/sdk/dialog_plugin_base.hpp index 8cfe571..24f256e 100644 --- a/pj_plugins/dialog_protocol/include/pj_plugins/sdk/dialog_plugin_base.hpp +++ b/pj_plugins/dialog_protocol/include/pj_plugins/sdk/dialog_plugin_base.hpp @@ -243,7 +243,16 @@ const PJ_dialog_vtable_t* dialogVtableFor() noexcept; /// }; template PJ_borrowed_dialog_t borrowDialog(DialogT& dialog) noexcept { - return PJ_borrowed_dialog_t{&dialog, dialogVtableFor()}; + // static_cast to DialogPluginBase* before erasing to void* so the + // C-ABI ctx points to the DialogPluginBase subobject regardless of + // multiple inheritance order. Without this, plugins whose dialog + // class derives from another base first (e.g. QObject for Q_OBJECT + // support) would hand the wrong base-pointer to the trampolines, + // and `static_cast(ctx)` inside them would read + // garbage members and dispatch through a corrupted vtable, producing + // a SIGSEGV deep inside the first `get_ui_content` / `widget_data` + // call. + return PJ_borrowed_dialog_t{static_cast(&dialog), dialogVtableFor()}; } } // namespace PJ @@ -277,7 +286,10 @@ PJ_borrowed_dialog_t borrowDialog(DialogT& dialog) noexcept { static const PJ_dialog_vtable_t* vt = PJ::DialogPluginBase::vtableWithCreate( \ []() noexcept -> void* { \ try { \ - return new ClassName(); \ + /* static_cast to DialogPluginBase* before erasing to void* so */ \ + /* trampolines that re-cast back find the right subobject in */ \ + /* multiple-inheritance plugins (e.g. ClassName : QObject, Dialog). */ \ + return static_cast(new ClassName()); \ } catch (...) { \ return nullptr; \ } \ 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 295e455..5b2052e 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 @@ -61,6 +61,13 @@ class DialogPluginTyped : public DialogPluginBase { return false; } + /// QTableWidget: a horizontal-header section (column) was clicked. Plugins + /// that drive their own column sorting override this, re-order their row + /// model, and re-emit — index-based selection/visibility stays consistent. + virtual bool onHeaderClicked(std::string_view /*widget_name*/, int /*section*/) { + return false; + } + virtual bool onCodeChanged(std::string_view /*widget_name*/, std::string_view /*code*/) { return false; } @@ -76,6 +83,25 @@ class DialogPluginTyped : public DialogPluginBase { return false; } + /// RangeSlider: a handle (or the whole span) moved. + virtual bool onRangeChanged(std::string_view /*widget_name*/, int /*lower*/, int /*upper*/) { + return false; + } + + /// SequencePicker: the date/time range filter changed. from_iso/to_iso are + /// ISO-8601 datetime strings (empty = unbounded on that side). + virtual bool onDateRangeChanged(std::string_view /*widget_name*/, std::string_view /*from_iso*/, + std::string_view /*to_iso*/, bool /*every_day*/) { + return false; + } + + /// MetadataQueryBar: a key/op/value selector combo was activated. + /// role is "key" | "op" | "value". + virtual bool onQuerySelector(std::string_view /*widget_name*/, std::string_view /*role*/, + std::string_view /*value*/) { + return false; + } + private: /// Parses event_json and dispatches to the appropriate typed virtual above. bool onWidgetEvent(std::string_view widget_name, std::string_view event_json) final { @@ -84,6 +110,15 @@ class DialogPluginTyped : public DialogPluginBase { if (auto v = event.chartViewChanged()) { return onChartViewChanged(widget_name, v->x_min, v->x_max, v->y_min, v->y_max); } + if (auto v = event.rangeChanged()) { + return onRangeChanged(widget_name, v->lower, v->upper); + } + if (auto v = event.dateRangeChanged()) { + return onDateRangeChanged(widget_name, v->from_iso, v->to_iso, v->every_day); + } + if (auto v = event.querySelector()) { + return onQuerySelector(widget_name, v->role, v->value); + } if (auto v = event.itemsDropped()) { return onItemsDropped(widget_name, *v); } @@ -117,6 +152,9 @@ class DialogPluginTyped : public DialogPluginBase { if (auto v = event.itemDoubleClickedIndex()) { return onItemDoubleClicked(widget_name, *v); } + if (auto v = event.headerSection()) { + return onHeaderClicked(widget_name, *v); + } // value: try int first, then double if (auto v = event.valueInt()) { return onValueChanged(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 cfe2c89..0147311 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 @@ -2,6 +2,8 @@ // Copyright 2026 Davide Faconti // SPDX-License-Identifier: MIT +#include +#include #include #include #include @@ -112,6 +114,49 @@ class WidgetData { return *this; } + /// Hide rows whose indexes are NOT in the visible set (live filtering). + /// Empty visible set hides every row. To show every row, pass the full + /// index list (this is what the host acts on). clearVisibleRows() merely + /// unsets the field — the host reads that as nullopt and makes NO visibility + /// change, so it does not by itself re-show previously hidden rows. + WidgetData& setVisibleRows(std::string_view name, const std::vector& visible) { + entry(name)["visible_rows"] = visible; + return *this; + } + + /// Unset any prior setVisibleRows (serializes JSON null). The host view + /// surfaces this as nullopt (indistinguishable from "never set") and applies + /// no visibility change; to actively re-show all rows, setVisibleRows() with + /// the full index list instead. + WidgetData& clearVisibleRows(std::string_view name) { + auto& e = entry(name); + e["visible_rows"] = nlohmann::json::value_t::null; + return *this; + } + + /// Tint a single row's background. `color_hex` follows "#rrggbb" or + /// the empty string to clear the override. + WidgetData& setRowColor(std::string_view name, int row, std::string_view color_hex) { + auto& e = entry(name); + auto& colors = e["row_colors"]; + if (!colors.is_object()) { + colors = nlohmann::json::object(); + } + colors[std::to_string(row)] = std::string(color_hex); + return *this; + } + + /// Set the tooltip on a single cell. + WidgetData& setCellTooltip(std::string_view name, int row, int col, std::string_view tooltip) { + auto& e = entry(name); + auto& tt = e["cell_tooltips"]; + if (!tt.is_object()) { + tt = nlohmann::json::object(); + } + tt[std::to_string(row) + "," + std::to_string(col)] = std::string(tooltip); + return *this; + } + // --- Chart (QFrame used as chart container) --- /// Set chart series data on a QFrame widget. The host will create or update @@ -212,6 +257,96 @@ class WidgetData { return *this; } + // --- QDateTimeEdit --- + /// Set the displayed date+time as an ISO-8601 string (e.g. "2026-05-21T13:45:00"). + /// Empty string clears any prior value (widget falls back to its default). + WidgetData& setDateTime(std::string_view name, std::string_view iso8601) { + entry(name)["datetime"] = std::string(iso8601); + return *this; + } + + /// Set the allowed [min, max] datetime range for a QDateTimeEdit. + WidgetData& setDateTimeRange(std::string_view name, std::string_view min_iso, std::string_view max_iso) { + auto& e = entry(name); + e["datetime_min"] = std::string(min_iso); + e["datetime_max"] = std::string(max_iso); + return *this; + } + + // --- RangeSlider (two-handle range slider) --- + + /// Set the integer [min, max] bounds of a RangeSlider. Setting bounds resets + /// the handle values, so call setRangeSliderValues afterwards (or in the same + /// widget_data tick) to restore them. + WidgetData& setRangeSliderBounds(std::string_view name, int min, int max) { + auto& e = entry(name); + e["range_min"] = min; + e["range_max"] = max; + return *this; + } + + /// Set the lower/upper handle positions of a RangeSlider (in slider units). + WidgetData& setRangeSliderValues(std::string_view name, int lower, int upper) { + auto& e = entry(name); + e["range_lower"] = lower; + e["range_upper"] = upper; + return *this; + } + + /// Enable duration floating labels on a RangeSlider, mapping the slider's + /// [min, max] span onto the absolute time window [min_ns, max_ns]. Handle + /// labels show the offset from min_ns; the center label shows the selected + /// duration. Nanoseconds are carried as strings to avoid double precision + /// loss on epoch-scale values. Pass min_ns == max_ns == 0 to disable. + WidgetData& setRangeSliderTimeSpan(std::string_view name, std::int64_t min_ns, std::int64_t max_ns) { + auto& e = entry(name); + e["range_time_min_ns"] = std::to_string(min_ns); + e["range_time_max_ns"] = std::to_string(max_ns); + return *this; + } + + // --- MetadataQueryBar (Lua filter + key/op/value selectors) --- + + WidgetData& setQueryKeys(std::string_view name, const std::vector& keys) { + entry(name)["query_keys"] = keys; + return *this; + } + WidgetData& setQueryOperators(std::string_view name, const std::vector& ops) { + entry(name)["query_ops"] = ops; + return *this; + } + WidgetData& setQueryValues(std::string_view name, const std::vector& values) { + entry(name)["query_values"] = values; + return *this; + } + WidgetData& setQueryCompletions(std::string_view name, const std::vector& items) { + entry(name)["query_completions"] = items; + return *this; + } + /// Feed the full key→values schema to a self-contained MetadataQueryBar, which + /// owns its own context-aware key/op/value combos and completion. nlohmann + /// serializes the map as a JSON object {key: [values]}. + WidgetData& setQuerySchema(std::string_view name, const std::map>& schema) { + entry(name)["query_schema"] = schema; + return *this; + } + /// Validation feedback under the editor. Empty text hides it; ok ⇒ green. + WidgetData& setQueryFeedback(std::string_view name, std::string_view text, bool ok) { + auto& e = entry(name); + e["query_feedback_text"] = std::string(text); + e["query_feedback_ok"] = ok; + return *this; + } + + // --- SequencePicker (date/time range picker) --- + + /// Set the "from" field placeholder of a SequencePicker to the dataset's + /// earliest date (ISO-8601 date, e.g. "2016-04-29"). Empty resets the hint. + WidgetData& setDatePickerEarliest(std::string_view name, std::string_view iso_date) { + entry(name)["picker_earliest"] = std::string(iso_date); + return *this; + } + // --- QDialogButtonBox --- /// Set OK button enabled state. The widget name defaults to "buttonBox" /// which is the required name for the DialogEngine to wire accept/reject. @@ -267,6 +402,17 @@ class WidgetData { return *this; } + /// Request that the host close the panel hosting this plugin (PanelEngine). + /// `reason` is a free-form plugin-defined string (e.g. "import_complete", + /// "user_back", "error") forwarded to the host's onCloseRequested callback. + /// DialogEngine ignores this command — it has its own accept/reject flow + /// via requestAccept() and the buttonBox. PanelEngine observes it on every + /// tick and tears down the panel after the callback fires. + WidgetData& requestClose(std::string_view reason) { + data_["__request_close"] = std::string(reason); + return *this; + } + /// Serialize to JSON string std::string toJson() const { return data_.dump(); 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 3bdbc45..031dcf0 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 @@ -96,6 +96,11 @@ class WidgetEvent { return getInt("item_double_clicked_index"); } + /// QTableWidget: horizontal-header section clicked (returns column index) + std::optional headerSection() const { + return getInt("header_section"); + } + /// Code editor: code changed std::optional codeChanged() const { return getString("code_changed"); @@ -117,6 +122,55 @@ class WidgetEvent { return result; } + /// SequencePicker: date/time range filter (ISO datetime strings, empty = + /// unbounded on that side). + struct DateRangeFilter { + std::string from_iso; + std::string to_iso; + bool every_day; + }; + + std::optional dateRangeChanged() const { + auto from = data_.find("date_from_iso"); + auto to = data_.find("date_to_iso"); + auto ed = data_.find("every_day"); + if (from == data_.end() || !from->is_string() || to == data_.end() || !to->is_string() || ed == data_.end() || + !ed->is_boolean()) { + return std::nullopt; + } + return DateRangeFilter{from->get(), to->get(), ed->get()}; + } + + /// MetadataQueryBar: a key/op/value selector combo was activated. + struct QuerySelector { + std::string role; // "key" | "op" | "value" + std::string value; + }; + + std::optional querySelector() const { + auto role = data_.find("query_selector_role"); + auto value = data_.find("query_selector_value"); + if (role == data_.end() || !role->is_string() || value == data_.end() || !value->is_string()) { + return std::nullopt; + } + return QuerySelector{role->get(), value->get()}; + } + + /// RangeSlider: lower/upper handle positions (slider units). + struct RangeValues { + int lower; + int upper; + }; + + std::optional rangeChanged() const { + auto lo = data_.find("range_lower"); + auto hi = data_.find("range_upper"); + if (lo == data_.end() || !lo->is_number_integer() || hi == data_.end() || !hi->is_number_integer()) { + return std::nullopt; + } + return RangeValues{lo->get(), hi->get()}; + } + /// ChartPreviewWidget: visible range changed via zoom or pan. struct ChartViewState { double x_min; diff --git a/pj_plugins/docs/dialog-plugin-guide.md b/pj_plugins/docs/dialog-plugin-guide.md index 81a1fa3..21341ff 100644 --- a/pj_plugins/docs/dialog-plugin-guide.md +++ b/pj_plugins/docs/dialog-plugin-guide.md @@ -252,10 +252,18 @@ bool onTextChanged(std::string_view name, std::string_view text) override { | `onFileSelected(name, path)` | QPushButton (file picker) | selected file path | | `onSelectionChanged(name, items)` | QListWidget | vector of selected item texts | | `onItemDoubleClicked(name, index)` | QListWidget | row index of double-clicked item | +| `onHeaderClicked(name, section)` | QTableWidget | clicked column index (for plugin-owned sorting) | | `onTabChanged(name, index)` | QTabWidget | new tab index | All handlers default to returning `false`. Override only the ones you need. +> **Column sorting.** The host does not enable `QTableWidget`'s built-in sort +> (it would reorder items and desync the index-based `setVisibleRows` / +> `setSelectedRows` model). Instead, header clicks arrive as +> `onHeaderClicked(name, section)`; a plugin that wants sortable columns +> re-orders its own row model (numeric for numeric columns) and re-emits the +> rows. The host shows a sort indicator on the clicked column. + ### 5. Export the plugin At file scope, after the class definition: @@ -349,7 +357,7 @@ work like polling a server for available topics. | QPushButton (folder picker) | `setFolderPicker` | `onFolderSelected(name, path)` | | QLabel | `setLabel` | (none — display only) | | QListWidget | `setListItems`, `setSelectedItems` | `onSelectionChanged(name, items)`, `onItemDoubleClicked(name, index)` | -| QTableWidget | `setTableHeaders`, `setTableRows`, `setSelectedRows` | `onSelectionChanged(name, items)` | +| QTableWidget | `setTableHeaders`, `setTableRows`, `setSelectedRows` | `onSelectionChanged(name, items)`, `onHeaderClicked(name, section)` | | QPlainTextEdit | `setPlainText`, `setCodeContent`, `setCodeLanguage` | `onCodeChanged(name, code)` for code editors | | QFrame (chart container) | `setChartSeries`, `clearChart`, `setChartZoomEnabled` | `onChartViewChanged(name, x_min, x_max, y_min, y_max)` | | QTabWidget | `setTabIndex` | `onTabChanged(name, index)` | diff --git a/pj_plugins/include/pj_plugins/testing/toolbox_test_store.hpp b/pj_plugins/include/pj_plugins/testing/toolbox_test_store.hpp index 4e35044..39fda82 100644 --- a/pj_plugins/include/pj_plugins/testing/toolbox_test_store.hpp +++ b/pj_plugins/include/pj_plugins/testing/toolbox_test_store.hpp @@ -142,6 +142,11 @@ class ToolboxTestStore { .append_arrow_stream = &ToolboxTestStore::trampolineAppendArrowStream, .acquire_catalog_snapshot = &ToolboxTestStore::trampolineAcquireCatalogSnapshot, .read_series_arrow = &ToolboxTestStore::trampolineReadSeriesArrow, + // Tail-only object-topic slots — the in-memory test store doesn't + // back an ObjectStore. SDK-side tail-slot checks turn the missing + // slots into clear "older host" errors. + .register_object_topic = nullptr, + .push_owned_object = nullptr, }; return PJ_toolbox_host_t{.ctx = this, .vtable = &vtable}; } diff --git a/pj_plugins/tests/toolbox_plugin_test.cpp b/pj_plugins/tests/toolbox_plugin_test.cpp index 02d391e..fe8272d 100644 --- a/pj_plugins/tests/toolbox_plugin_test.cpp +++ b/pj_plugins/tests/toolbox_plugin_test.cpp @@ -82,6 +82,11 @@ PJ_toolbox_host_t makeToolboxHost(ToolboxState* state) { .append_arrow_stream = tbAppendArrowStream, .acquire_catalog_snapshot = tbCatalog, .read_series_arrow = tbReadSeriesArrow, + // Tail slots — left null because this mock host doesn't exercise the + // object-topic surface. ToolboxHostView::registerObjectTopic / + // pushOwnedObject return `unexpected("older host")` for null slots. + .register_object_topic = nullptr, + .push_owned_object = nullptr, }; return PJ_toolbox_host_t{.ctx = state, .vtable = &vtable}; } From dea2be182b2a6917b0f3daa4a29c5ef8dc560460 Mon Sep 17 00:00:00 2001 From: Davide Faconti Date: Mon, 25 May 2026 20:08:42 +0200 Subject: [PATCH 2/5] fix: update tests after rebase onto v5 ABI main Adapt the rebased Mosaico-deps work to main's current state: - ABI sentinel: PJ_toolbox_host_vtable_t grew 72->88 bytes when this branch appended register_object_topic / push_owned_object; pin the two new tail-slot offsets and bump the size assertion. - arrow_stream_round_trip_test: a test case added on main still used the pre-rebase 1-arg DatastoreToolboxHost(engine); supply the ObjectStore the constructor now requires. Co-Authored-By: Claude Opus 4.7 (1M context) --- pj_base/tests/abi_layout_sentinels_test.cpp | 4 +++- pj_datastore/tests/arrow_stream_round_trip_test.cpp | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/pj_base/tests/abi_layout_sentinels_test.cpp b/pj_base/tests/abi_layout_sentinels_test.cpp index b6f3a0d..0730456 100644 --- a/pj_base/tests/abi_layout_sentinels_test.cpp +++ b/pj_base/tests/abi_layout_sentinels_test.cpp @@ -172,7 +172,9 @@ static_assert(offsetof(PJ_toolbox_host_vtable_t, struct_size) == 4, "toolbox hos static_assert(offsetof(PJ_toolbox_host_vtable_t, append_bound_record) == 40, "toolbox host baseline pinned"); static_assert(offsetof(PJ_toolbox_host_vtable_t, append_arrow_stream) == 48, "toolbox host bulk slot pinned"); static_assert(offsetof(PJ_toolbox_host_vtable_t, read_series_arrow) == 64, "toolbox host read slot pinned"); -static_assert(sizeof(PJ_toolbox_host_vtable_t) == 72, "Toolbox host size"); +static_assert(offsetof(PJ_toolbox_host_vtable_t, register_object_topic) == 72, "toolbox host object-topic slot pinned"); +static_assert(offsetof(PJ_toolbox_host_vtable_t, push_owned_object) == 80, "toolbox host object-push tail slot pinned"); +static_assert(sizeof(PJ_toolbox_host_vtable_t) == 88, "Toolbox host size (update deliberately on append)"); // --- ABI version symbol ------------------------------------------------------ static_assert(PJ_ABI_VERSION == 5, "v5 ABI version"); diff --git a/pj_datastore/tests/arrow_stream_round_trip_test.cpp b/pj_datastore/tests/arrow_stream_round_trip_test.cpp index 4b139f8..7719eae 100644 --- a/pj_datastore/tests/arrow_stream_round_trip_test.cpp +++ b/pj_datastore/tests/arrow_stream_round_trip_test.cpp @@ -241,7 +241,8 @@ TEST(ArrowStreamRoundTripTest, ParserWriteHostAppendArrowStreamWritesBoundTopic) parser_write_host.flushPending(); - DatastoreToolboxHost tb_host(engine); + ObjectStore object_store; + DatastoreToolboxHost tb_host(engine, object_store); auto tb_vtable = tb_host.raw(); PJ_catalog_snapshot_t snapshot{}; From 916c38c7ed26b239b15d72757c7b62f9d510568e Mon Sep 17 00:00:00 2001 From: Davide Faconti Date: Mon, 25 May 2026 20:22:01 +0200 Subject: [PATCH 3/5] docs: document toolbox object-write API and new dialog widgets Bring SDK docs in line with the Mosaico-deps surface added on this branch: - dialog-plugin-guide: add RangeSlider, SequencePicker, MetadataQueryBar, QDateTimeEdit and the QTableWidget filter/style setters to the widget table; document onHeaderClicked / onQuerySelector / requestClose. - toolbox-guide: document registerObjectTopic / pushOwnedObject with a serializeImage example and the older-host degradation caveat. - ARCHITECTURE: note the toolbox host's object-topic write slots are tail-appended under ABI v5 (struct_size-gated, no version bump) and now require an ObjectStore& at construction. Co-Authored-By: Claude Opus 4.7 (1M context) --- pj_plugins/docs/ARCHITECTURE.md | 10 ++++++ pj_plugins/docs/dialog-plugin-guide.md | 45 +++++++++++++++++++++++++- pj_plugins/docs/toolbox-guide.md | 42 ++++++++++++++++++++++++ 3 files changed, 96 insertions(+), 1 deletion(-) diff --git a/pj_plugins/docs/ARCHITECTURE.md b/pj_plugins/docs/ARCHITECTURE.md index 4248514..3e810ee 100644 --- a/pj_plugins/docs/ARCHITECTURE.md +++ b/pj_plugins/docs/ARCHITECTURE.md @@ -425,6 +425,16 @@ All three share a common internal `WriteCore` that handles: used by the toolbox host's C++ implementation. **Not part of the v4 plugin ABI** — at the boundary, `read_series_arrow` returns host-owned `ArrowSchema` + `ArrowArray` structs instead. +- Object-topic writes — `register_object_topic` + `push_owned_object` + route canonical media (images, point clouds, annotations) into the + session `ObjectStore` rather than the columnar engine. They forward to + the same `ObjectStore::registerTopic` / `pushOwned` the DataSource and + Parser object-write hosts use, so the toolbox host now requires an + `ObjectStore&` at construction alongside the `DataEngine&`. These are + **tail slots** appended to `PJ_toolbox_host_vtable_t` under ABI v5 (no + version bump): existing slot offsets are unchanged, and `struct_size` + gating lets pre-object-write plugins and hosts interoperate — the SDK + `ToolboxHostView` returns an "older host" error when the slot is absent. ### Arrow C Data Interface ownership rules diff --git a/pj_plugins/docs/dialog-plugin-guide.md b/pj_plugins/docs/dialog-plugin-guide.md index 21341ff..c7708a8 100644 --- a/pj_plugins/docs/dialog-plugin-guide.md +++ b/pj_plugins/docs/dialog-plugin-guide.md @@ -357,9 +357,13 @@ work like polling a server for available topics. | QPushButton (folder picker) | `setFolderPicker` | `onFolderSelected(name, path)` | | QLabel | `setLabel` | (none — display only) | | QListWidget | `setListItems`, `setSelectedItems` | `onSelectionChanged(name, items)`, `onItemDoubleClicked(name, index)` | -| QTableWidget | `setTableHeaders`, `setTableRows`, `setSelectedRows` | `onSelectionChanged(name, items)`, `onHeaderClicked(name, section)` | +| QTableWidget | `setTableHeaders`, `setTableRows`, `setSelectedRows`, `setVisibleRows`, `setRowColor`, `setCellTooltip` | `onSelectionChanged(name, items)`, `onHeaderClicked(name, section)` | | QPlainTextEdit | `setPlainText`, `setCodeContent`, `setCodeLanguage` | `onCodeChanged(name, code)` for code editors | | 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)` | +| SequencePicker (date range) | `setDatePickerEarliest` | `onDateRangeChanged(name, from_iso, to_iso, every_day)` | +| MetadataQueryBar | `setQueryKeys`, `setQueryOperators`, `setQueryValues`, `setQueryCompletions`, `setQuerySchema`, `setQueryFeedback` | `onQuerySelector(name, role, value)` | | QTabWidget | `setTabIndex` | `onTabChanged(name, index)` | | QDialogButtonBox | `setOkEnabled` | (none — host handles OK/Cancel) | @@ -367,9 +371,25 @@ All widgets also support `setEnabled(name, bool)`, `setVisible(name, bool)`, and `setDropTarget(name, bool)`. Drop targets receive `onItemsDropped(name, items)`. +`onHeaderClicked(name, section)` reports the clicked column index for plugins +that drive their own sorting. The `QTableWidget` styling setters layer over the +row data: `setVisibleRows` live-filters by index (an empty set hides every row; +to re-show all rows pass the full index list — clearing the field makes *no* +change), `setRowColor` tints a row (`"#rrggbb"`, or `""` to clear), and +`setCellTooltip` annotates a single cell. + +For `onQuerySelector`, `role` is one of `"key"`, `"op"`, or `"value"`. For +`SequencePicker`, the `from_iso` / `to_iso` strings are ISO-8601 datetimes and +are empty when that side of the range is unbounded. + > **Note:** `QTextEdit` and `QTableView` (model-based) are not supported by the > widget binding system. Use `QPlainTextEdit` for plain text or code editing, > and `QTableWidget` for tabular data such as topic lists and preview tables. +> +> **Custom widgets:** `RangeSlider`, `SequencePicker`, and `MetadataQueryBar` +> are PlotJuggler-provided widget classes, not stock Qt. Use them as *promoted* +> widgets in your `.ui` (promote a placeholder `QWidget` to the class name); the +> host binds them by object name exactly like the stock widgets above. ## Optional Features @@ -508,6 +528,29 @@ void onRejected() override { } ``` +### Panel close — `requestClose` + +`requestClose(reason)` asks the host to tear down the panel that hosts the +plugin. It is a **panel-only** command: the `PanelEngine` observes the +`__request_close` flag on every tick and closes the panel after invoking its +`onCloseRequested` callback with the reason string. The modal `DialogEngine` +**ignores** it — classic dialogs close through `requestAccept()` and the +buttonBox instead, so the same plugin code is safe under either host. + +`reason` is a free-form, plugin-defined string (for example `"import_complete"`, +`"user_back"`, or `"error"`) forwarded verbatim to the host. + +```cpp +std::string widget_data() override { + PJ::WidgetData wd; + // ... set widget states ... + if (import_finished_) { + wd.requestClose("import_complete"); + } + return wd.toJson(); +} +``` + ### Error reporting Fallible dialog callbacks return `bool` through the C ABI and receive a diff --git a/pj_plugins/docs/toolbox-guide.md b/pj_plugins/docs/toolbox-guide.md index 2127551..389199c 100644 --- a/pj_plugins/docs/toolbox-guide.md +++ b/pj_plugins/docs/toolbox-guide.md @@ -196,6 +196,8 @@ data store. | `appendArrowStream(topic, stream, ts_col)` | Hand an `ArrowArrayStream*` (Arrow C Data Interface) to the host for bulk ingest. Same ownership rule as the source write path: success transfers, failure retains. | | `catalogSnapshot()` | Acquire a read-only snapshot of all data sources, topics, and fields. | | `readSeriesArrow(field, schema*, array*)` | Read one field's full time series into host-owned `ArrowSchema` + `ArrowArray` out-params (two columns: `timestamp` int64 ns, then the typed field value). | +| `registerObjectTopic(source, name, metadata_json)` | Register a media/object topic under a data source. `metadata_json` is opaque to the store and retained verbatim; viewers and parsers read it to pick a renderer. Returns an `ObjectTopicHandle`. | +| `pushOwnedObject(topic, ts, payload)` | Eager-push serialized object bytes into the ObjectStore under an object topic; the host copies the bytes, so the plugin's buffer is free immediately after the call returns. | ### Runtime host — control plane @@ -248,6 +250,46 @@ auto status = toolboxHost().appendArrowStream( // release() dance required. ``` +### Writing object payloads (images, point clouds, annotations) + +`readSeriesArrow` / `appendArrowStream` cover *scalar* columns. To emit +**canonical media** — an image a toolbox renders, a point cloud, an annotation +overlay — use the object-write surface, which routes to the host `ObjectStore` +rather than the columnar engine: + +1. `registerObjectTopic(source, name, metadata_json)` declares a topic under a + data source you created. The `metadata_json` is opaque to the store; viewers + and parsers read it to choose a renderer (e.g. `{"object_type":"image"}` for + the MediaViewer). It returns an `ObjectTopicHandle`. +2. `pushOwnedObject(topic, ts, payload)` pushes serialized bytes (e.g. a + `PJ.Image` produced via `serializeImage()` from `pj_base/builtin/image_codec.hpp`). + The push is **eager**: the host copies the bytes immediately, so your buffer + may be reused or freed the moment the call returns. There is no lazy/fetch + variant on the toolbox surface — a toolbox already holds the bytes by the + time it writes them. + +```cpp +auto topic = toolboxHost().registerObjectTopic( + source, "mosaic/preview", R"({"object_type":"image"})"); +if (!topic) { + runtimeHost().reportMessage(PJ::ToolboxMessageLevel::kError, topic.error()); + return; +} + +std::vector bytes = PJ::serializeImage(my_image); +auto status = toolboxHost().pushOwnedObject( + *topic, timestamp_ns, PJ::Span(bytes.data(), bytes.size())); +if (!status) { + runtimeHost().reportMessage(PJ::ToolboxMessageLevel::kError, status.error()); +} +``` + +> **Older-host compatibility:** these two methods are tail slots appended to the +> toolbox host vtable. Against a host built before they existed, both return +> `unexpected("…older host")` instead of crashing — the SDK gates each call on +> the host's `struct_size`. Check the returned `Expected`/`Status` and degrade +> gracefully if you must support pre-object-write hosts. + ## Configuration Persistence Override `saveConfig()` / `loadConfig()` to support layout save/restore: From 253a7077865d0069b0cbfcb2a0418abd43884666 Mon Sep 17 00:00:00 2001 From: Davide Faconti Date: Mon, 25 May 2026 20:26:55 +0200 Subject: [PATCH 4/5] chore(release): bump version to 0.3.1 Backward-compatible release: adds the toolbox object-write API and dialog widget channels (tail-appended ABI slots, additive SDK), so a PATCH bump over 0.3.0 per the project's 0.MINOR.PATCH convention. Co-Authored-By: Claude Opus 4.7 (1M context) --- CMakeLists.txt | 2 +- conanfile.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 6a52809..2ee8058 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -183,7 +183,7 @@ endif() if(PJ_INSTALL_SDK) include(CMakePackageConfigHelpers) - set(PJ_PACKAGE_VERSION "0.3.0") + set(PJ_PACKAGE_VERSION "0.3.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 cd38a6f..678fac1 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.3.0` and then: +A consuming Conan recipe declares e.g. `plotjuggler_core/0.3.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.3.0" + version = "0.3.1" license = "MIT" url = "https://github.com/PlotJuggler/plotjuggler_core" description = "C++20 foundation libraries for PlotJuggler: storage engine, plugin SDK, plugin host loaders." From 08053853ea5315cea783ce80c8380952ca990096 Mon Sep 17 00:00:00 2001 From: Davide Faconti Date: Mon, 25 May 2026 20:27:02 +0200 Subject: [PATCH 5/5] docs(claude): require a Conan release decision in every PR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Document the release policy: each PR should propose a version bump — MINOR for API/ABI breaks, PATCH for backward-compatible changes — with conanfile.py and CMakeLists.txt kept in sync, and tagging/pushing gated on explicit approval. Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 846ad3a..0a9ad28 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -89,6 +89,24 @@ Before committing, always run: Code formatting and linting are enforced via pre-commit hooks (clang-format v17). +## Release Versioning + +In **every PR**, proactively raise whether it warrants a new Conan release, and +propose the version bump rather than waiting to be asked. Pre-1.0 versioning +convention (`0.MINOR.PATCH`): + +- **MINOR** bump (`0.X.0`) — any API or ABI **break**: removing/reordering ABI + vtable slots, changing existing struct layouts or function signatures, or any + source-incompatible SDK change. +- **PATCH** bump (`0.x.Y`) — **backward-compatible** changes: tail-appended ABI + slots (gated by `struct_size`), additive SDK helpers, bug fixes, docs. + +The version is declared in two places that **must stay in sync**: `version` in +`conanfile.py` and `PJ_PACKAGE_VERSION` in the root `CMakeLists.txt` (also update +the example tag in the `conanfile.py` docstring). Tagging and pushing the release +is a separate, explicitly-authorized step — never tag or push a release without +the user's go-ahead. + ## Instructions Glossary - **"Read all documentation"** means: find and read every `.md` file in the entire project tree (all subdirectories). Use `find . -name "*.md"` or equivalent. This includes docs in `docs/`, `pj_datastore/docs/`, `pj_plugins/docs/`, and any other location.