From 0452e066e91fbd2850b7b43b9d782833e48cb3f7 Mon Sep 17 00:00:00 2001 From: alvvm Date: Tue, 7 Jul 2026 11:19:51 +0200 Subject: [PATCH 1/5] feat(sdk): pj.playback.v1 + pj.viewport.v1 host services MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two new optional host services so a plugin (first consumer: the Assistant Agent toolbox) can drive the app like a user — transport and zoom: - pj.playback.v1 (PJ_playback_host_vtable_t, sdk::PlaybackHostView): play/pause/seek/set_playback_rate/get_state (ABI-frozen PJ_playback_state_t) + to_display_time (absolute int64 ns -> display-axis seconds via the topic's dataset offset; current-frame semantics). - pj.viewport.v1 (PJ_viewport_host_vtable_t, sdk::ViewportHostView): zoom_to_time_range (every open time plot's X window, per-plot Y kept) and zoom_reset (fit all). All times are display-axis seconds — the numbers the plot X axes and the playback slider show. Additive only: no existing struct or slot touched (abidiff: additions only). Version provisional at 0.16.0 (0.15.0 taken by the in-flight per-topic subscription work). --- CHANGELOG.md | 21 ++ pj_base/CMakeLists.txt | 1 + pj_base/include/pj_base/plugin_data_api.h | 90 ++++++ .../include/pj_base/sdk/plugin_data_api.hpp | 158 +++++++++++ .../include/pj_base/sdk/service_traits.hpp | 26 ++ pj_base/tests/playback_viewport_api_test.cpp | 268 ++++++++++++++++++ pj_plugins/docs/ARCHITECTURE.md | 10 + 7 files changed, 574 insertions(+) create mode 100644 pj_base/tests/playback_viewport_api_test.cpp diff --git a/CHANGELOG.md b/CHANGELOG.md index 97d7d09b..d7efd891 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,27 @@ All notable changes to `plotjuggler_sdk` are recorded here. Versioning policy is in [`CLAUDE.md`](./CLAUDE.md) → "Release Versioning". +## [Unreleased] — on branch `feat/playback-viewport-services` + +### Added — host services `pj.playback.v1` and `pj.viewport.v1` (MINOR, additions only) + +Two new optional host services so a plugin (first consumer: the Assistant Agent +toolbox) can drive the app like a user — transport and zoom — without any new +executable surface crossing the ABI: + +- **`pj.playback.v1`** (`PJ_playback_host_vtable_t`, `sdk::PlaybackHostView`, + `sdk::PlaybackHostService`): `play` / `pause` / `seek` / `set_playback_rate` / + `get_state` (ABI-frozen `PJ_playback_state_t` snapshot) / `to_display_time` + (absolute int64 ns → display-axis seconds, per-topic dataset offset; + current-frame semantics). All times are display-axis seconds. +- **`pj.viewport.v1`** (`PJ_viewport_host_vtable_t`, `sdk::ViewportHostView`, + `sdk::ViewportHostService`): `zoom_to_time_range` (every open time plot's X + window; per-plot Y preserved; XY/empty plots untouched) and `zoom_reset` + (fit all). + +No existing struct or vtable slot was touched — every already-built plugin keeps +working with no recompile (`abidiff`: additions only). + ## [0.25.0] ### Feature: plugin-authoring CMake helpers ship with the SDK (MINOR) diff --git a/pj_base/CMakeLists.txt b/pj_base/CMakeLists.txt index 6603178f..141e550e 100644 --- a/pj_base/CMakeLists.txt +++ b/pj_base/CMakeLists.txt @@ -151,6 +151,7 @@ if(PJ_BUILD_TESTS) tests/parser_module_abi_test.cpp tests/parser_module_manifest_test.cpp tests/data_processors_api_test.cpp + tests/playback_viewport_api_test.cpp tests/settings_store_host_test.cpp tests/parser_runtime_host_test.cpp tests/data_source_protocol_test.cpp diff --git a/pj_base/include/pj_base/plugin_data_api.h b/pj_base/include/pj_base/plugin_data_api.h index 93e302f0..26761960 100644 --- a/pj_base/include/pj_base/plugin_data_api.h +++ b/pj_base/include/pj_base/plugin_data_api.h @@ -1046,6 +1046,96 @@ typedef struct { const PJ_settings_store_vtable_t* vtable; } PJ_settings_store_t; +/** + * Playback host service ("pj.playback.v1", protocol_version 1). + * + * Optional programmatic control of the host's playback cursor (the vertical + * time tracker the playback slider drives). All slots are [main-thread]. + * + * TIME UNIT: every time in this service is DISPLAY-AXIS SECONDS — the numbers + * the plot X axes and the playback slider show — NOT absolute nanoseconds. + * Display time is derived from absolute time via per-dataset, user-editable + * offsets, so any converted value is valid for the CURRENT frame only: + * re-query after the user edits a source offset or the time reference. + * + * ABI-APPENDABLE: new slots may be added at the tail; struct_size gates read. + */ + +/* ABI-FROZEN: layout permanent; changes = ABI break (add a get_state_v2 slot instead). */ +typedef struct { + bool is_playing; + double current_time_s; /* display-axis seconds */ + double range_min_s; /* playback range in display-axis seconds */ + double range_max_s; + double playback_rate; /* speed multiplier; 1.0 = real time */ +} PJ_playback_state_t; + +typedef struct PJ_playback_host_vtable_t { + uint32_t protocol_version; /* = 1 */ + uint32_t struct_size; /* = sizeof(PJ_playback_host_vtable_t) */ + + /* [main-thread] Start advancing the playback cursor. Idempotent. */ + bool (*play)(void* ctx, PJ_error_t* out_error) PJ_NOEXCEPT; + + /* [main-thread] Stop advancing the playback cursor. Idempotent. */ + bool (*pause)(void* ctx, PJ_error_t* out_error) PJ_NOEXCEPT; + + /* [main-thread] Move the cursor to `time_s` (display-axis seconds); the host + * clamps into [range_min_s, range_max_s]. Play/pause state is unchanged. + * Non-finite time is an error. */ + bool (*seek)(void* ctx, double time_s, PJ_error_t* out_error) PJ_NOEXCEPT; + + /* [main-thread] Set the playback-speed multiplier (> 0; the host may clamp). + * Non-finite or non-positive rate is an error. */ + bool (*set_playback_rate)(void* ctx, double rate, PJ_error_t* out_error) PJ_NOEXCEPT; + + /* [main-thread] Snapshot the current playback state. During live streaming + * the range (and a cursor glued to its tip) advances on every ingest tick. */ + bool (*get_state)(void* ctx, PJ_playback_state_t* out_state, PJ_error_t* out_error) PJ_NOEXCEPT; + + /* [main-thread] Convert an ABSOLUTE nanosecond timestamp (the unit the data + * read/write surfaces speak) into display-axis seconds, using the display + * offset of the dataset that owns `topic`. An empty topic uses the host's + * representative dataset. Current-frame semantics (see the service + * doc-comment). An unknown topic is an error. */ + bool (*to_display_time)( + void* ctx, PJ_string_view_t topic, int64_t absolute_ns, double* out_display_s, PJ_error_t* out_error) + PJ_NOEXCEPT; +} PJ_playback_host_vtable_t; + +typedef struct { + void* ctx; + const PJ_playback_host_vtable_t* vtable; +} PJ_playback_host_t; + +/** + * Viewport host service ("pj.viewport.v1", protocol_version 1). + * + * Optional zoom control over the host's open time-series plots. All slots are + * [main-thread]. Times are display-axis seconds (same convention as + * "pj.playback.v1"). ABI-APPENDABLE: new slots may be added at the tail; + * struct_size gates read. + */ +typedef struct PJ_viewport_host_vtable_t { + uint32_t protocol_version; /* = 1 */ + uint32_t struct_size; /* = sizeof(PJ_viewport_host_vtable_t) */ + + /* [main-thread] Set every open time-series plot's visible X window to + * [t0_s, t1_s] (display-axis seconds). Each plot keeps its own Y range; + * XY plots and empty plots are untouched. Requires finite t0_s < t1_s; + * no open time plot to act on is an error. */ + bool (*zoom_to_time_range)(void* ctx, double t0_s, double t1_s, PJ_error_t* out_error) PJ_NOEXCEPT; + + /* [main-thread] Reset every open plot to fit its data (the host's + * "zoom out all" action). */ + bool (*zoom_reset)(void* ctx, PJ_error_t* out_error) PJ_NOEXCEPT; +} PJ_viewport_host_vtable_t; + +typedef struct { + void* ctx; + const PJ_viewport_host_vtable_t* vtable; +} PJ_viewport_host_t; + #ifdef __cplusplus } #endif 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 a6cb86e6..ffa5a963 100644 --- a/pj_base/include/pj_base/sdk/plugin_data_api.hpp +++ b/pj_base/include/pj_base/sdk/plugin_data_api.hpp @@ -1653,6 +1653,164 @@ class DataProcessorsHostView { PJ_data_processors_host_t host_{}; }; +// --------------------------------------------------------------------------- +// PlaybackHostView — typed C++ view over PJ_playback_host_t +// --------------------------------------------------------------------------- + +/// Snapshot of the host's playback state. All times are display-axis seconds +/// (the numbers on the plot X axes and the playback slider). +struct PlaybackState { + bool is_playing = false; + double current_time_s = 0.0; + double range_min_s = 0.0; + double range_max_s = 0.0; + double playback_rate = 1.0; +}; + +/// C++ wrapper around PJ_playback_host_t — optional programmatic control of +/// the host's playback cursor (service "pj.playback.v1"). Empty-constructible; +/// `valid()` tells whether the host exposed the service. All calls are +/// main-thread. Times are display-axis seconds; conversions from absolute +/// nanoseconds (`toDisplayTime`) hold for the current frame only — display +/// offsets are per-dataset and user-editable (see the C ABI doc-comment). +class PlaybackHostView { + public: + PlaybackHostView() = default; + explicit PlaybackHostView(PJ_playback_host_t host) : host_(host) {} + + [[nodiscard]] bool valid() const noexcept { + return host_.vtable != nullptr && host_.ctx != nullptr; + } + + /// Start advancing the playback cursor. Idempotent. + [[nodiscard]] Status play() const { + if (!valid() || host_.vtable->play == nullptr) { + return unexpected("playback host is not bound"); + } + PJ_error_t err{}; + if (!host_.vtable->play(host_.ctx, &err)) { + return unexpected(errorToString(err)); + } + return okStatus(); + } + + /// Stop advancing the playback cursor. Idempotent. + [[nodiscard]] Status pause() const { + if (!valid() || host_.vtable->pause == nullptr) { + return unexpected("playback host is not bound"); + } + PJ_error_t err{}; + if (!host_.vtable->pause(host_.ctx, &err)) { + return unexpected(errorToString(err)); + } + return okStatus(); + } + + /// Move the cursor to `time_s` (display-axis seconds); the host clamps into + /// the playback range. Play/pause state is unchanged. + [[nodiscard]] Status seek(double time_s) const { + if (!valid() || host_.vtable->seek == nullptr) { + return unexpected("playback host is not bound"); + } + PJ_error_t err{}; + if (!host_.vtable->seek(host_.ctx, time_s, &err)) { + return unexpected(errorToString(err)); + } + return okStatus(); + } + + /// Set the playback-speed multiplier (> 0; the host may clamp). + [[nodiscard]] Status setPlaybackRate(double rate) const { + if (!valid() || host_.vtable->set_playback_rate == nullptr) { + return unexpected("playback host is not bound"); + } + PJ_error_t err{}; + if (!host_.vtable->set_playback_rate(host_.ctx, rate, &err)) { + return unexpected(errorToString(err)); + } + return okStatus(); + } + + /// Snapshot the current playback state. During live streaming the range + /// (and a cursor glued to its tip) advances on every ingest tick. + [[nodiscard]] Expected state() const { + if (!valid() || host_.vtable->get_state == nullptr) { + return unexpected("playback host is not bound"); + } + PJ_playback_state_t raw{}; + PJ_error_t err{}; + if (!host_.vtable->get_state(host_.ctx, &raw, &err)) { + return unexpected(errorToString(err)); + } + return PlaybackState{raw.is_playing, raw.current_time_s, raw.range_min_s, raw.range_max_s, raw.playback_rate}; + } + + /// Convert an ABSOLUTE nanosecond timestamp into display-axis seconds using + /// the display offset of the dataset owning `topic` (empty topic = the + /// host's representative dataset). Current-frame semantics. + [[nodiscard]] Expected toDisplayTime(std::string_view topic, int64_t absolute_ns) const { + if (!valid() || host_.vtable->to_display_time == nullptr) { + return unexpected("playback host is not bound"); + } + double display_s = 0.0; + PJ_error_t err{}; + if (!host_.vtable->to_display_time(host_.ctx, toAbiString(topic), absolute_ns, &display_s, &err)) { + return unexpected(errorToString(err)); + } + return display_s; + } + + private: + PJ_playback_host_t host_{}; +}; + +// --------------------------------------------------------------------------- +// ViewportHostView — typed C++ view over PJ_viewport_host_t +// --------------------------------------------------------------------------- + +/// C++ wrapper around PJ_viewport_host_t — optional zoom control over the +/// host's open time-series plots (service "pj.viewport.v1"). +/// Empty-constructible; `valid()` tells whether the host exposed the service. +/// All calls are main-thread; times are display-axis seconds. +class ViewportHostView { + public: + ViewportHostView() = default; + explicit ViewportHostView(PJ_viewport_host_t host) : host_(host) {} + + [[nodiscard]] bool valid() const noexcept { + return host_.vtable != nullptr && host_.ctx != nullptr; + } + + /// Set every open time-series plot's visible X window to [t0_s, t1_s]. + /// Each plot keeps its own Y range; XY and empty plots are untouched. + /// Requires finite t0_s < t1_s. + [[nodiscard]] Status zoomToTimeRange(double t0_s, double t1_s) const { + if (!valid() || host_.vtable->zoom_to_time_range == nullptr) { + return unexpected("viewport host is not bound"); + } + PJ_error_t err{}; + if (!host_.vtable->zoom_to_time_range(host_.ctx, t0_s, t1_s, &err)) { + return unexpected(errorToString(err)); + } + return okStatus(); + } + + /// Reset every open plot to fit its data (the host's "zoom out all"). + [[nodiscard]] Status zoomReset() const { + if (!valid() || host_.vtable->zoom_reset == nullptr) { + return unexpected("viewport host is not bound"); + } + PJ_error_t err{}; + if (!host_.vtable->zoom_reset(host_.ctx, &err)) { + return unexpected(errorToString(err)); + } + return okStatus(); + } + + private: + PJ_viewport_host_t host_{}; +}; + // --------------------------------------------------------------------------- // SettingsView — typed C++ view over PJ_settings_store_t // --------------------------------------------------------------------------- diff --git a/pj_base/include/pj_base/sdk/service_traits.hpp b/pj_base/include/pj_base/sdk/service_traits.hpp index 68bb78d2..bb3c3523 100644 --- a/pj_base/include/pj_base/sdk/service_traits.hpp +++ b/pj_base/include/pj_base/sdk/service_traits.hpp @@ -187,6 +187,32 @@ struct DataProcessorsHostService { static_assert(detail::isValidServiceName(kName), "kName must match the pj naming rule"); }; +/// Optional playback control for any plugin family: play/pause/seek/rate and +/// a state snapshot over the host's playback cursor, plus absolute-ns -> +/// display-seconds conversion. All times are display-axis seconds (the numbers +/// the plot X axes and the playback slider show); see the C ABI doc-comment on +/// PJ_playback_host_vtable_t for the current-frame caveat. +struct PlaybackHostService { + static constexpr const char* kName = "pj.playback.v1"; + static constexpr uint32_t kMinVersion = 1; + using Raw = PJ_playback_host_t; + using Vtable = PJ_playback_host_vtable_t; + using View = PlaybackHostView; + static_assert(detail::isValidServiceName(kName), "kName must match the pj naming rule"); +}; + +/// Optional viewport control: zoom every open time-series plot to a +/// display-seconds X window, or reset all plots to fit their data. Hosts +/// without plots (headless) simply do not register it. +struct ViewportHostService { + static constexpr const char* kName = "pj.viewport.v1"; + static constexpr uint32_t kMinVersion = 1; + using Raw = PJ_viewport_host_t; + using Vtable = PJ_viewport_host_vtable_t; + using View = ViewportHostView; + static_assert(detail::isValidServiceName(kName), "kName must match the pj naming rule"); +}; + /// Optional QSettings-like key/value persistence exposed to any plugin family. /// Host-backed (QSettings in the GUI app, JSON in a headless host); keys are /// namespaced per plugin by the host. diff --git a/pj_base/tests/playback_viewport_api_test.cpp b/pj_base/tests/playback_viewport_api_test.cpp new file mode 100644 index 00000000..9ba8d456 --- /dev/null +++ b/pj_base/tests/playback_viewport_api_test.cpp @@ -0,0 +1,268 @@ +// Copyright 2026 Davide Faconti +// SPDX-License-Identifier: Apache-2.0 + +#include + +#include +#include + +#include "pj_base/plugin_data_api.h" +#include "pj_base/sdk/plugin_data_api.hpp" + +namespace PJ { +namespace { + +// Fake host for pj.playback.v1: records the last call and serves a settable +// state snapshot. to_display_time applies a fixed offset so the args-to-result +// path is observable. +struct FakePlaybackHost { + bool play_called = false; + bool pause_called = false; + double last_seek_s = 0.0; + double last_rate = 0.0; + bool should_fail = false; + + PJ_playback_state_t state{}; + + std::string last_topic; + int64_t last_absolute_ns = 0; + int64_t display_offset_ns = 0; +}; + +bool pbFail(FakePlaybackHost* self, PJ_error_t* out_error) noexcept { + if (self->should_fail) { + sdk::fillError(out_error, 1, "playback", "playback boom"); + return true; + } + return false; +} + +bool pbPlay(void* ctx, PJ_error_t* out_error) noexcept { + auto* self = static_cast(ctx); + if (pbFail(self, out_error)) { + return false; + } + self->play_called = true; + return true; +} + +bool pbPause(void* ctx, PJ_error_t* out_error) noexcept { + auto* self = static_cast(ctx); + if (pbFail(self, out_error)) { + return false; + } + self->pause_called = true; + return true; +} + +bool pbSeek(void* ctx, double time_s, PJ_error_t* out_error) noexcept { + auto* self = static_cast(ctx); + if (pbFail(self, out_error)) { + return false; + } + self->last_seek_s = time_s; + return true; +} + +bool pbSetRate(void* ctx, double rate, PJ_error_t* out_error) noexcept { + auto* self = static_cast(ctx); + if (pbFail(self, out_error)) { + return false; + } + self->last_rate = rate; + return true; +} + +bool pbGetState(void* ctx, PJ_playback_state_t* out_state, PJ_error_t* out_error) noexcept { + auto* self = static_cast(ctx); + if (pbFail(self, out_error)) { + return false; + } + *out_state = self->state; + return true; +} + +bool pbToDisplayTime( + void* ctx, PJ_string_view_t topic, int64_t absolute_ns, double* out_display_s, PJ_error_t* out_error) noexcept { + auto* self = static_cast(ctx); + if (pbFail(self, out_error)) { + return false; + } + self->last_topic = std::string(sdk::toStringView(topic)); + self->last_absolute_ns = absolute_ns; + *out_display_s = static_cast(absolute_ns - self->display_offset_ns) * 1e-9; + return true; +} + +PJ_playback_host_vtable_t makePlaybackVtable() { + return PJ_playback_host_vtable_t{ + .protocol_version = 1, + .struct_size = sizeof(PJ_playback_host_vtable_t), + .play = pbPlay, + .pause = pbPause, + .seek = pbSeek, + .set_playback_rate = pbSetRate, + .get_state = pbGetState, + .to_display_time = pbToDisplayTime, + }; +} + +// Fake host for pj.viewport.v1: records the last zoom call. +struct FakeViewportHost { + double last_t0_s = 0.0; + double last_t1_s = 0.0; + bool reset_called = false; + bool should_fail = false; +}; + +bool vpZoom(void* ctx, double t0_s, double t1_s, PJ_error_t* out_error) noexcept { + auto* self = static_cast(ctx); + if (self->should_fail) { + sdk::fillError(out_error, 1, "viewport", "zoom boom"); + return false; + } + self->last_t0_s = t0_s; + self->last_t1_s = t1_s; + return true; +} + +bool vpReset(void* ctx, PJ_error_t* out_error) noexcept { + auto* self = static_cast(ctx); + if (self->should_fail) { + sdk::fillError(out_error, 1, "viewport", "reset boom"); + return false; + } + self->reset_called = true; + return true; +} + +PJ_viewport_host_vtable_t makeViewportVtable() { + return PJ_viewport_host_vtable_t{ + .protocol_version = 1, + .struct_size = sizeof(PJ_viewport_host_vtable_t), + .zoom_to_time_range = vpZoom, + .zoom_reset = vpReset, + }; +} + +// --- PlaybackHostView -------------------------------------------------------- + +TEST(PlaybackApiTest, PlayPauseForwardToHost) { + FakePlaybackHost host; + const auto vtable = makePlaybackVtable(); + sdk::PlaybackHostView view(PJ_playback_host_t{.ctx = &host, .vtable = &vtable}); + + ASSERT_TRUE(view.play()); + EXPECT_TRUE(host.play_called); + ASSERT_TRUE(view.pause()); + EXPECT_TRUE(host.pause_called); +} + +TEST(PlaybackApiTest, SeekAndRateForwardValues) { + FakePlaybackHost host; + const auto vtable = makePlaybackVtable(); + sdk::PlaybackHostView view(PJ_playback_host_t{.ctx = &host, .vtable = &vtable}); + + ASSERT_TRUE(view.seek(42.25)); + EXPECT_DOUBLE_EQ(host.last_seek_s, 42.25); + ASSERT_TRUE(view.setPlaybackRate(0.5)); + EXPECT_DOUBLE_EQ(host.last_rate, 0.5); +} + +TEST(PlaybackApiTest, StateMarshalsAllFields) { + FakePlaybackHost host; + host.state = PJ_playback_state_t{ + .is_playing = true, .current_time_s = 12.5, .range_min_s = 1.0, .range_max_s = 99.0, .playback_rate = 2.0}; + const auto vtable = makePlaybackVtable(); + sdk::PlaybackHostView view(PJ_playback_host_t{.ctx = &host, .vtable = &vtable}); + + auto state = view.state(); + ASSERT_TRUE(state) << state.error(); + EXPECT_TRUE(state->is_playing); + EXPECT_DOUBLE_EQ(state->current_time_s, 12.5); + EXPECT_DOUBLE_EQ(state->range_min_s, 1.0); + EXPECT_DOUBLE_EQ(state->range_max_s, 99.0); + EXPECT_DOUBLE_EQ(state->playback_rate, 2.0); +} + +TEST(PlaybackApiTest, ToDisplayTimeForwardsTopicAndConverts) { + FakePlaybackHost host; + host.display_offset_ns = 1'000'000'000; // 1 s + const auto vtable = makePlaybackVtable(); + sdk::PlaybackHostView view(PJ_playback_host_t{.ctx = &host, .vtable = &vtable}); + + auto display_s = view.toDisplayTime("imu/accel", 3'500'000'000); + ASSERT_TRUE(display_s) << display_s.error(); + EXPECT_EQ(host.last_topic, "imu/accel"); + EXPECT_EQ(host.last_absolute_ns, 3'500'000'000); + EXPECT_DOUBLE_EQ(*display_s, 2.5); +} + +TEST(PlaybackApiTest, HostFailureSurfacesError) { + FakePlaybackHost host; + host.should_fail = true; + const auto vtable = makePlaybackVtable(); + sdk::PlaybackHostView view(PJ_playback_host_t{.ctx = &host, .vtable = &vtable}); + + auto status = view.play(); + EXPECT_FALSE(status); + EXPECT_NE(status.error().find("playback boom"), std::string::npos); +} + +TEST(PlaybackApiTest, UnboundViewReportsNotBound) { + sdk::PlaybackHostView view; // default-constructed = not bound + EXPECT_FALSE(view.valid()); + + EXPECT_FALSE(view.play()); + EXPECT_FALSE(view.pause()); + EXPECT_FALSE(view.seek(0.0)); + EXPECT_FALSE(view.setPlaybackRate(1.0)); + EXPECT_FALSE(view.state()); + auto converted = view.toDisplayTime("t", 0); + EXPECT_FALSE(converted); + EXPECT_NE(converted.error().find("not bound"), std::string::npos); +} + +// --- ViewportHostView -------------------------------------------------------- + +TEST(ViewportApiTest, ZoomForwardsRange) { + FakeViewportHost host; + const auto vtable = makeViewportVtable(); + sdk::ViewportHostView view(PJ_viewport_host_t{.ctx = &host, .vtable = &vtable}); + + ASSERT_TRUE(view.zoomToTimeRange(2.0, 8.5)); + EXPECT_DOUBLE_EQ(host.last_t0_s, 2.0); + EXPECT_DOUBLE_EQ(host.last_t1_s, 8.5); +} + +TEST(ViewportApiTest, ResetForwards) { + FakeViewportHost host; + const auto vtable = makeViewportVtable(); + sdk::ViewportHostView view(PJ_viewport_host_t{.ctx = &host, .vtable = &vtable}); + + ASSERT_TRUE(view.zoomReset()); + EXPECT_TRUE(host.reset_called); +} + +TEST(ViewportApiTest, HostFailureSurfacesError) { + FakeViewportHost host; + host.should_fail = true; + const auto vtable = makeViewportVtable(); + sdk::ViewportHostView view(PJ_viewport_host_t{.ctx = &host, .vtable = &vtable}); + + auto status = view.zoomToTimeRange(0.0, 1.0); + EXPECT_FALSE(status); + EXPECT_NE(status.error().find("zoom boom"), std::string::npos); +} + +TEST(ViewportApiTest, UnboundViewReportsNotBound) { + sdk::ViewportHostView view; + EXPECT_FALSE(view.valid()); + + auto status = view.zoomReset(); + EXPECT_FALSE(status); + EXPECT_NE(status.error().find("not bound"), std::string::npos); +} + +} // namespace +} // namespace PJ diff --git a/pj_plugins/docs/ARCHITECTURE.md b/pj_plugins/docs/ARCHITECTURE.md index 0aa335e5..d8cc8e93 100644 --- a/pj_plugins/docs/ARCHITECTURE.md +++ b/pj_plugins/docs/ARCHITECTURE.md @@ -256,6 +256,16 @@ service registry, error out-params, and typed borrowed-dialog patterns): non-blocking diagnostics channel. Parsers report severity, a machine-stable code, representative text, and an occurrence count; the host aggregates by parser identity and bound schema/type without changing parse success. + `"pj.playback.v1"` (optional) gives a plugin programmatic control of the + host's playback cursor — play/pause/seek/rate plus a state snapshot and an + absolute-ns → display-seconds conversion — via a Qt-free + `sdk::PlaybackHostView`; every time in the service is display-axis seconds + (the numbers the plot X axes and the playback slider show), valid for the + current frame only because display offsets are per-dataset and user-editable. + `"pj.viewport.v1"` (optional) zooms every open time-series plot to a + display-seconds X window or resets all plots to fit + (`sdk::ViewportHostView`); hosts without plots (headless) simply do not + register it. - **Structured errors everywhere.** All fallible ABI calls take a `PJ_error_t* out_error` out-parameter. The old per-plugin `get_last_error` slot is gone. From aca3ed75c2cdb324c24eaeb436c34515aa11b4b3 Mon Sep 17 00:00:00 2001 From: alvvm Date: Tue, 7 Jul 2026 14:20:12 +0200 Subject: [PATCH 2/5] fix(codec): empty buffer decodes to an empty PlotMarkers set + freeze playback state layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An empty buffer IS the canonical proto encoding of an empty set — the "clear my markers" tombstone a producer publishes to a replace-only store — but deserializePlotMarkers rejected size==0 as an error, making the tombstone unrepresentable on the wire. Now it round-trips; null-with- size, truncated, and malformed payloads still error. (Logically part of the plot-markers feature line; riding this branch until PRs are sorted.) Also pin PJ_playback_state_t (ABI-FROZEN, bool + padding + 4 doubles) in the layout-sentinels test. --- CHANGELOG.md | 8 ++++++++ .../pj_base/builtin/plot_markers_codec.hpp | 5 ++++- pj_base/src/builtin/plot_markers_codec.cpp | 10 ++++++++-- pj_base/tests/abi_layout_sentinels_test.cpp | 11 +++++++++++ pj_base/tests/plot_markers_codec_test.cpp | 16 +++++++++++++--- 5 files changed, 44 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d7efd891..40bf001f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,14 @@ executable surface crossing the ABI: No existing struct or vtable slot was touched — every already-built plugin keeps working with no recompile (`abidiff`: additions only). +### Fixed — `deserializePlotMarkers` accepts the empty buffer as an empty set + +An empty buffer is the canonical proto encoding of an empty `PlotMarkers` set — +the "clear my markers" tombstone a producer publishes to a replace-only store — +but the decoder rejected `size == 0` as an error, making the tombstone +unrepresentable on the wire. It now decodes to an empty set; null-with-size, +truncated, and malformed payloads still error. + ## [0.25.0] ### Feature: plugin-authoring CMake helpers ship with the SDK (MINOR) diff --git a/pj_base/include/pj_base/builtin/plot_markers_codec.hpp b/pj_base/include/pj_base/builtin/plot_markers_codec.hpp index 22d586cb..b7b8ff0e 100644 --- a/pj_base/include/pj_base/builtin/plot_markers_codec.hpp +++ b/pj_base/include/pj_base/builtin/plot_markers_codec.hpp @@ -20,7 +20,10 @@ inline constexpr std::string_view kSchemaPlotMarkers = "PJ.PlotMarkers"; /// Decodes canonical PJ.PlotMarkers wire bytes into sdk::PlotMarkers. /// -/// Returns an error for null, empty, truncated, or malformed payloads. +/// An EMPTY buffer decodes to an empty set — the canonical "no markers" +/// tombstone a producer publishes to clear its output (the object store is +/// replace-only; markers are overwritten, never deleted). Returns an error for +/// null-with-size, truncated, or malformed payloads. [[nodiscard]] Expected deserializePlotMarkers(const uint8_t* data, size_t size); } // namespace PJ diff --git a/pj_base/src/builtin/plot_markers_codec.cpp b/pj_base/src/builtin/plot_markers_codec.cpp index df763d77..c29a7995 100644 --- a/pj_base/src/builtin/plot_markers_codec.cpp +++ b/pj_base/src/builtin/plot_markers_codec.cpp @@ -273,8 +273,14 @@ std::vector serializePlotMarkers(const sdk::PlotMarkers& markers) { } Expected deserializePlotMarkers(const uint8_t* data, size_t size) { - if (data == nullptr || size == 0) { - return unexpected(std::string("PlotMarkers wire: empty buffer")); + // An empty buffer IS the canonical encoding of an empty set (proto semantics: + // empty message = all defaults) — the "clear my markers" tombstone a producer + // publishes, since the store is replace-only and markers are never deleted. + if (size == 0) { + return sdk::PlotMarkers{}; + } + if (data == nullptr) { + return unexpected(std::string("PlotMarkers wire: null buffer")); } Reader reader(data, size); diff --git a/pj_base/tests/abi_layout_sentinels_test.cpp b/pj_base/tests/abi_layout_sentinels_test.cpp index 845f61f7..cc9b9a54 100644 --- a/pj_base/tests/abi_layout_sentinels_test.cpp +++ b/pj_base/tests/abi_layout_sentinels_test.cpp @@ -66,6 +66,17 @@ static_assert(offsetof(PJ_error_t, message) == 36, "PJ_error_t layout pinned"); static_assert(offsetof(PJ_error_t, extended) == 264, "PJ_error_t layout pinned"); static_assert(offsetof(PJ_error_t, extended_kind) == 272, "PJ_error_t layout pinned"); +// --- PJ_playback_state_t (ABI-FROZEN) ----------------------------------------- +// bool + 7 padding bytes + 4 doubles: the alignment-hole shape this test exists +// to pin. Growth goes through a get_state_v2 tail slot, never through this struct. +static_assert(sizeof(PJ_playback_state_t) == 40, "PJ_playback_state_t size pinned at introduction (0.16.0)"); +static_assert(alignof(PJ_playback_state_t) == 8, "PJ_playback_state_t alignment pinned"); +static_assert(offsetof(PJ_playback_state_t, is_playing) == 0, "PJ_playback_state_t layout pinned"); +static_assert(offsetof(PJ_playback_state_t, current_time_s) == 8, "PJ_playback_state_t layout pinned"); +static_assert(offsetof(PJ_playback_state_t, range_min_s) == 16, "PJ_playback_state_t layout pinned"); +static_assert(offsetof(PJ_playback_state_t, range_max_s) == 24, "PJ_playback_state_t layout pinned"); +static_assert(offsetof(PJ_playback_state_t, playback_rate) == 32, "PJ_playback_state_t layout pinned"); + // --- Service registry (fat pointer types) ------------------------------------ static_assert(sizeof(PJ_service_t) == 16, "PJ_service_t fat pointer pinned"); static_assert(sizeof(PJ_service_registry_t) == 16, "PJ_service_registry_t fat pointer pinned"); diff --git a/pj_base/tests/plot_markers_codec_test.cpp b/pj_base/tests/plot_markers_codec_test.cpp index dde898a2..92c4b5fb 100644 --- a/pj_base/tests/plot_markers_codec_test.cpp +++ b/pj_base/tests/plot_markers_codec_test.cpp @@ -46,10 +46,20 @@ TEST(PlotMarkersCodecTest, EmptySetProducesEmptyBytes) { EXPECT_TRUE(serializePlotMarkers(markers).empty()); } -TEST(PlotMarkersCodecTest, NullOrEmptyBufferIsError) { - EXPECT_FALSE(deserializePlotMarkers(nullptr, 0).has_value()); +// An empty buffer is the canonical encoding of an empty set (the replace-only +// store's "clear" tombstone), so it round-trips instead of erroring. +TEST(PlotMarkersCodecTest, EmptyBufferDecodesToEmptySet) { + const Expected null_empty = deserializePlotMarkers(nullptr, 0); + ASSERT_TRUE(null_empty.has_value()); + EXPECT_TRUE(null_empty->markers.empty()); const uint8_t byte = 0; - EXPECT_FALSE(deserializePlotMarkers(&byte, 0).has_value()); + const Expected ptr_empty = deserializePlotMarkers(&byte, 0); + ASSERT_TRUE(ptr_empty.has_value()); + EXPECT_TRUE(ptr_empty->markers.empty()); +} + +TEST(PlotMarkersCodecTest, NullBufferWithSizeIsError) { + EXPECT_FALSE(deserializePlotMarkers(nullptr, 4).has_value()); } // ----------------------------------------------------------------------------- From ccd1f2a703bae7990e41707884fa62a4dcb01b39 Mon Sep 17 00:00:00 2001 From: alvvm Date: Tue, 7 Jul 2026 14:20:12 +0200 Subject: [PATCH 3/5] abi,docs: named error-code convention + pj_enable_when dialog docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PJ_ERROR_CODE_REJECTED / PJ_ERROR_CODE_INTERNAL: the two error classes every PJ host service distinguishes, now with an authoritative home — aggregators (a kind router probing multiple backends) rely on REJECTED meaning "not mine, safe to try another backend" vs INTERNAL meaning "a real failure that must never be masked by a fallback". - Document the host-honored "pj_enable_when" dynamic property (combo- driven field enabling, incl. inside modal sub-dialogs) in the dialog plugin guide + SDK reference. --- docs/dialog-sdk-reference.md | 9 +++++++++ pj_base/include/pj_base/plugin_data_api.h | 9 +++++++++ pj_plugins/docs/dialog-plugin-guide.md | 19 +++++++++++++++++++ 3 files changed, 37 insertions(+) diff --git a/docs/dialog-sdk-reference.md b/docs/dialog-sdk-reference.md index e39f0a43..444fba6c 100644 --- a/docs/dialog-sdk-reference.md +++ b/docs/dialog-sdk-reference.md @@ -510,3 +510,12 @@ public: } }; ``` + +## Conditional field enabling (`pj_enable_when`) + +Declarative, host-driven enable/disable: give any widget the string dynamic +property `pj_enable_when` = `":[,...]"` and it +stays enabled only while that combo sits on one of the listed indices. Works +inside modal sub-dialogs (where the plugin cannot push widget data); rules +re-assert after every widget-data apply. Malformed/unresolvable rules are +ignored. See dialog-plugin-guide.md → "Optional: conditional field enabling". diff --git a/pj_base/include/pj_base/plugin_data_api.h b/pj_base/include/pj_base/plugin_data_api.h index 26761960..b811c177 100644 --- a/pj_base/include/pj_base/plugin_data_api.h +++ b/pj_base/include/pj_base/plugin_data_api.h @@ -249,6 +249,15 @@ typedef struct { * Every populator (see sdk::fillError) MUST clear both new slots when * writing to avoid stale pointers in reused error structs. */ +/* Shared host-service error-code convention. Codes are domain-specific in + * general, but every PJ host service distinguishes at least these two classes, + * and aggregators (e.g. a kind router probing multiple backends) rely on the + * distinction: REJECTED means "this request is not mine / not valid" (safe to + * try another backend), INTERNAL means a real failure on a request the backend + * owns (must be surfaced, never masked by a fallback). */ +#define PJ_ERROR_CODE_REJECTED 1 +#define PJ_ERROR_CODE_INTERNAL 2 + typedef struct { int32_t code; /* 0 = success; otherwise domain-specific */ char domain[PJ_ERROR_DOMAIN_MAX]; /* null-terminated; truncated if too long */ diff --git a/pj_plugins/docs/dialog-plugin-guide.md b/pj_plugins/docs/dialog-plugin-guide.md index 6ce255c7..d058c641 100644 --- a/pj_plugins/docs/dialog-plugin-guide.md +++ b/pj_plugins/docs/dialog-plugin-guide.md @@ -25,6 +25,25 @@ compile-time error. | The `QDialogButtonBox` MUST set the `standardButtons` property in the XML | Without it, the box instantiates with no buttons even when found by name. | | Every interactive widget MUST have a unique `objectName` | All `WidgetData` setters and event handlers address widgets by name. | +### Optional: conditional field enabling (`pj_enable_when`) + +Any widget may carry a string **dynamic property** `pj_enable_when` with the +value `":[,...]"`. The host keeps the widget +enabled only while the named `QComboBox` (looked up in the same widget tree) +sits on one of those indices, applying the initial state at load and tracking +index changes live — including inside modal sub-dialogs, whose nested event +loop blocks the plugin from pushing `setEnabled` updates itself. Rules +re-assert after every widget-data apply, so a rule always wins over a +plugin-pushed `enabled` on the same widget. Malformed values or unknown combo +names are ignored (the widget stays as authored). Typical use: a backend +selector combo greying out the fields of the non-selected backend. + +```xml + + backendCombo:0 + +``` + ## What is a Dialog Plugin? A dialog plugin is a shared library (`.so` / `.dylib` / `.dll`) that drives a From a4379219cad27504ccc65e2d31d3a5bd90a4ba45 Mon Sep 17 00:00:00 2001 From: alvvm Date: Tue, 1 Sep 2026 13:04:07 +0200 Subject: [PATCH 4/5] build(sdk): claim 0.27.0 for the playback + viewport services Tail-appended MINOR, additions only. Numbered assuming #183 (0.26.0) merges first; renumber at release if the two land in the other order. --- CHANGELOG.md | 5 ++++- VERSION | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 40bf001f..1ed1f12e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,10 @@ All notable changes to `plotjuggler_sdk` are recorded here. Versioning policy is in [`CLAUDE.md`](./CLAUDE.md) → "Release Versioning". -## [Unreleased] — on branch `feat/playback-viewport-services` +## [0.27.0] — Unreleased, on branch `feat/playback-viewport-services` + +> Numbered assuming #183 (`feat/dataset-qualified-names`, 0.26.0) merges first; +> renumber at release if the two land in the other order. ### Added — host services `pj.playback.v1` and `pj.viewport.v1` (MINOR, additions only) diff --git a/VERSION b/VERSION index d21d277b..1b58cc10 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.25.0 +0.27.0 From 1d2b94219fb016054ef77d2df949d828ea5130ac Mon Sep 17 00:00:00 2001 From: alvvm Date: Tue, 1 Sep 2026 15:04:51 +0200 Subject: [PATCH 5/5] =?UTF-8?q?feat(sdk):=20pj.plot=5Ftabs.v1=20=E2=80=94?= =?UTF-8?q?=20a=20plugin=20composes=20plotting=20tabs=20of=20its=20own?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A plugin can create a plotting tab, place and remove curves in it, read back what it holds, and close it. The point is a boundary: a tab a plugin made is its own to compose, and the user's tabs stay the user's. Scoping is the HOST's duty, enforced where ownership is actually known. A tab this plugin did not create is rejected exactly as an unknown id is, so the service never discloses, mutates, or even confirms the existence of anyone else's tab; ownership comes from the per-binding ctx and never crosses the wire, the same unspoofable-identity discipline pj.data_processors.v1 already uses for its per-plugin isolation. Three choices worth their reasoning: - Ids are plugin-chosen and host-namespaced, so no handle has to travel back as an out-string. The resemblance to pj.data_processors.v1 stops at namespacing, and the doc says so: a processor id survives a reload because the host replays it from a recipe, whereas a tab is a view and this service promises nothing of the kind. - Curves are addressed by their parts — topic, field, dataset source — not by a joined path. Field paths legitimately contain '/' and dataset names contain ':', so splitting a joined form would be guesswork the host should not have to do. An empty dataset source means the pair must be unique, and an ambiguous one is refused with the candidates rather than picked. - tab_config reads back what a tab actually holds, so a caller reports what was drawn instead of what it asked for — the same reason create_markers reads its own output back out of the store. pj.viewport.v1 keeps both signatures and narrows only its documented scope, to the tabs the calling plugin owns; it has never shipped, so nothing is broken. That does couple the two services in practice even though the registry treats them as independently optional, and the doc now says they are registered together or not at all. The boundary is the view: pj.playback.v1 stays global, because one time cursor is shared by every plot. Additions only — no existing struct or vtable slot was touched. 85 tests (10 new); the one red is the environmental plugin_catalog_test. --- CHANGELOG.md | 26 +- pj_base/CMakeLists.txt | 1 + pj_base/include/pj_base/plugin_data_api.h | 125 +++++- .../include/pj_base/sdk/plugin_data_api.hpp | 142 +++++++ .../include/pj_base/sdk/service_traits.hpp | 13 + pj_base/tests/plot_tabs_api_test.cpp | 375 ++++++++++++++++++ pj_plugins/docs/ARCHITECTURE.md | 15 +- 7 files changed, 680 insertions(+), 17 deletions(-) create mode 100644 pj_base/tests/plot_tabs_api_test.cpp diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ed1f12e..6abfebfe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,12 +20,30 @@ executable surface crossing the ABI: (absolute int64 ns → display-axis seconds, per-topic dataset offset; current-frame semantics). All times are display-axis seconds. - **`pj.viewport.v1`** (`PJ_viewport_host_vtable_t`, `sdk::ViewportHostView`, - `sdk::ViewportHostService`): `zoom_to_time_range` (every open time plot's X - window; per-plot Y preserved; XY/empty plots untouched) and `zoom_reset` - (fit all). + `sdk::ViewportHostService`): `zoom_to_time_range` (X window in display-axis + seconds; per-plot Y preserved; XY/empty plots untouched) and `zoom_reset` + (fit). Both are **scoped to the tabs the calling plugin owns** — see + `pj.plot_tabs.v1` below; a plugin owning none has nothing to zoom, which is an + error rather than a silent no-op. +- **`pj.plot_tabs.v1`** (`PJ_plot_tab_host_vtable_t`, `sdk::PlotTabHostView`, + `sdk::PlotTabHostService`): a plugin composes plotting tabs of its own — + `create_tab` / `close_tab` / `list_tab_ids` / `tab_config` / `add_curve` / + `remove_curve` / `clear_tab`. Ids are plugin-chosen and namespaced per plugin + (the `pj.data_processors.v1` discipline), and every slot is scoped to the + caller's own tabs, so the user's tabs are neither disclosed nor mutable + through it. Curves are addressed by their parts — topic, field, dataset + source — rather than a joined path, because field paths contain `/` and + dataset names contain `:`, and an empty dataset source requires the pair to be + unique. `tab_config` reads back what a tab actually holds, so a caller can + report what was drawn instead of what it asked for. + +The boundary these two draw is the VIEW. `pj.playback.v1` stays global by +nature: one time cursor is shared by every plot. No existing struct or vtable slot was touched — every already-built plugin keeps -working with no recompile (`abidiff`: additions only). +working with no recompile (`abidiff`: additions only). `pj.viewport.v1`'s two +slots keep their signatures; only their documented scope narrowed, and it has +never shipped. ### Fixed — `deserializePlotMarkers` accepts the empty buffer as an empty set diff --git a/pj_base/CMakeLists.txt b/pj_base/CMakeLists.txt index 141e550e..96fcc8ab 100644 --- a/pj_base/CMakeLists.txt +++ b/pj_base/CMakeLists.txt @@ -152,6 +152,7 @@ if(PJ_BUILD_TESTS) tests/parser_module_manifest_test.cpp tests/data_processors_api_test.cpp tests/playback_viewport_api_test.cpp + tests/plot_tabs_api_test.cpp tests/settings_store_host_test.cpp tests/parser_runtime_host_test.cpp tests/data_source_protocol_test.cpp diff --git a/pj_base/include/pj_base/plugin_data_api.h b/pj_base/include/pj_base/plugin_data_api.h index b811c177..9e92b883 100644 --- a/pj_base/include/pj_base/plugin_data_api.h +++ b/pj_base/include/pj_base/plugin_data_api.h @@ -1120,23 +1120,38 @@ typedef struct { /** * Viewport host service ("pj.viewport.v1", protocol_version 1). * - * Optional zoom control over the host's open time-series plots. All slots are - * [main-thread]. Times are display-axis seconds (same convention as - * "pj.playback.v1"). ABI-APPENDABLE: new slots may be added at the tail; + * Optional zoom control, SCOPED to the tabs the calling plugin owns (see + * "pj.plot_tabs.v1"). A host that grants a plugin its own tabs must confine + * these slots to them: the user's plots are not a plugin's to reframe. A + * plugin owning no tab therefore has nothing to zoom, which is an error, not a + * silent no-op. + * + * That makes this service dependent on the other in practice, even though the + * registry treats the two as independently optional: a host offering this one + * WITHOUT "pj.plot_tabs.v1" leaves every plugin with nothing it may zoom, so + * these two are registered together or not at all. + * + * Note the boundary is the VIEW, not the transport: "pj.playback.v1" stays + * global by nature, since one time cursor is shared by every plot. + * + * All slots are [main-thread]. Times are display-axis seconds (same convention + * as "pj.playback.v1"). ABI-APPENDABLE: new slots may be added at the tail; * struct_size gates read. */ typedef struct PJ_viewport_host_vtable_t { uint32_t protocol_version; /* = 1 */ uint32_t struct_size; /* = sizeof(PJ_viewport_host_vtable_t) */ - /* [main-thread] Set every open time-series plot's visible X window to - * [t0_s, t1_s] (display-axis seconds). Each plot keeps its own Y range; - * XY plots and empty plots are untouched. Requires finite t0_s < t1_s; - * no open time plot to act on is an error. */ + /* [main-thread] Set the visible X window of every time-series plot the + * calling plugin owns to [t0_s, t1_s] (display-axis seconds). Each plot keeps + * its own Y range; XY plots and empty plots are untouched. Requires finite + * t0_s < t1_s. Owning no such plot is an error, and the host is expected to + * say which kind — no tab at all, or a tab with nothing to zoom — since the + * caller's remedy differs. */ bool (*zoom_to_time_range)(void* ctx, double t0_s, double t1_s, PJ_error_t* out_error) PJ_NOEXCEPT; - /* [main-thread] Reset every open plot to fit its data (the host's - * "zoom out all" action). */ + /* [main-thread] Reset every plot the calling plugin owns to fit its data. + * Like zoom_to_time_range, owning nothing is an error. */ bool (*zoom_reset)(void* ctx, PJ_error_t* out_error) PJ_NOEXCEPT; } PJ_viewport_host_vtable_t; @@ -1145,6 +1160,98 @@ typedef struct { const PJ_viewport_host_vtable_t* vtable; } PJ_viewport_host_t; +/** + * Plot-tab host service ("pj.plot_tabs.v1", protocol_version 1). + * + * Lets a plugin compose plotting tabs OF ITS OWN: create one, place and remove + * curves in it, read back what it holds, close it. Every slot is scoped to the + * calling binding — a tab this plugin did not create is rejected exactly as an + * unknown id is, so the service never discloses, mutates or even confirms the + * existence of the user's tabs. That scoping is the host's job, not the + * plugin's: it is enforced where ownership is known, and ownership is derived + * from `ctx`, never passed in. + * + * Ids are chosen by the plugin and namespaced per plugin by the host (the + * "pj.data_processors.v1" discipline), so an id is unique within this binding + * and cannot collide with another plugin's. The resemblance stops there, and + * the difference matters: a data processor's id survives a session reload + * because the host replays it from a persisted recipe, whereas a tab is a VIEW + * and this service promises nothing of the sort. Treat an id as live only for + * as long as `list_tab_ids` still returns it, and re-read rather than assume + * after anything that could have rebuilt the workspace. + * + * A tab created here is the host's to present: it carries whatever permanent + * mark the host uses for model-authored views, and the plugin cannot suppress + * it. Whether such a tab is saved with the workspace is likewise the host's + * policy, not this service's contract. + * + * All slots are [main-thread]. ABI-APPENDABLE: new slots may be added at the + * tail; struct_size gates read. + */ +typedef struct PJ_plot_tab_host_vtable_t { + uint32_t protocol_version; /* = 1 */ + uint32_t struct_size; /* = sizeof(PJ_plot_tab_host_vtable_t) */ + + /* [main-thread] Create (or replace, upsert by id) a tab owned by this plugin, + * holding one empty plot. An empty `title` lets the host name it. All string + * arguments are borrowed for the duration of the call. */ + bool (*create_tab)(void* ctx, PJ_string_view_t id, PJ_string_view_t title, PJ_error_t* out_error) PJ_NOEXCEPT; + + /* [main-thread] Close one of this plugin's tabs. Unknown id is an error. + * Closing a tab discards the VIEW only: any derived series or markers drawn + * in it are data and outlive it. */ + bool (*close_tab)(void* ctx, PJ_string_view_t id, PJ_error_t* out_error) PJ_NOEXCEPT; + + /* [main-thread] Enumerate the ids of THIS plugin's live tabs. + * Count-then-fill: pass capacity 0 to read *out_count, then call again with a + * buffer of that size. On success the first min(capacity, *out_count) entries + * of out_ids are filled and point into host storage valid only until the next + * call on this vtable. Owning no tab is success with *out_count == 0. */ + bool (*list_tab_ids)( + void* ctx, PJ_string_view_t* out_ids, uint64_t capacity, uint64_t* out_count, + PJ_error_t* out_error) PJ_NOEXCEPT; + + /* [main-thread] Read back what a tab actually holds, as JSON + * {"title":"...","curves":[{"topic":"...","field":"...","dataset":"..."}]}. + * This is how a caller reports what was drawn instead of what it asked for: + * a curve the host could not resolve is simply absent. "dataset" is always + * the RESOLVED source name, even where the caller left it empty, so the + * report says which run a curve actually came from. The host emits valid + * JSON: any character needing escaping in a topic or field is escaped here. + * *out_config_json is borrowed, valid only until the next call on this + * vtable. Unknown id is an error. */ + bool (*tab_config)( + void* ctx, PJ_string_view_t id, PJ_string_view_t* out_config_json, PJ_error_t* out_error) PJ_NOEXCEPT; + + /* [main-thread] Draw one curve in a tab of this plugin's. The series is named + * by its parts, not a joined path, because field paths legitimately contain + * '/' and dataset names contain ':' — splitting a joined form is guesswork the + * host should not have to do. An empty `dataset_source` means the topic/field + * must be unique across loaded datasets; the host refuses an ambiguous one + * with the qualified candidates rather than picking. Adding a curve already + * present is not an error. */ + bool (*add_curve)( + void* ctx, PJ_string_view_t id, PJ_string_view_t topic, PJ_string_view_t field, + PJ_string_view_t dataset_source, PJ_error_t* out_error) PJ_NOEXCEPT; + + /* [main-thread] Take one curve back out. `dataset_source` resolves by the + * same rule as in add_curve — empty means "the topic/field must be unique" — + * so a curve can be removed with whatever form was used to add it, or with + * the resolved name tab_config reports. A curve that is not there is an + * error, so a mistaken path is visible rather than silently accepted. */ + bool (*remove_curve)( + void* ctx, PJ_string_view_t id, PJ_string_view_t topic, PJ_string_view_t field, + PJ_string_view_t dataset_source, PJ_error_t* out_error) PJ_NOEXCEPT; + + /* [main-thread] Remove every curve from a tab, keeping the tab itself. */ + bool (*clear_tab)(void* ctx, PJ_string_view_t id, PJ_error_t* out_error) PJ_NOEXCEPT; +} PJ_plot_tab_host_vtable_t; + +typedef struct { + void* ctx; + const PJ_plot_tab_host_vtable_t* vtable; +} PJ_plot_tab_host_t; + #ifdef __cplusplus } #endif 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 ffa5a963..41b80cf7 100644 --- a/pj_base/include/pj_base/sdk/plugin_data_api.hpp +++ b/pj_base/include/pj_base/sdk/plugin_data_api.hpp @@ -1022,6 +1022,33 @@ inline PrimitiveType formatToPrimitiveType(const char* fmt) noexcept { return PrimitiveType::kUnspecified; } } + +/// Two-call "count then fill" marshalling for any vtable slot that enumerates +/// borrowed strings: ask for the count, size a buffer, ask again, copy out. +/// The copies are made here on purpose — the views the host fills in point into +/// its own storage, which the next call on that vtable may invalidate. +/// +/// `slot` is the raw function pointer; the caller has already established that +/// the host is bound, since only it knows which view is speaking. +template +[[nodiscard]] inline Expected> listBorrowedStrings(void* ctx, ListSlot slot) { + PJ_error_t err{}; + uint64_t count = 0; + if (!slot(ctx, nullptr, 0, &count, &err)) { + return unexpected(errorToString(err)); + } + std::vector borrowed(count); + uint64_t filled = 0; + if (count != 0 && !slot(ctx, borrowed.data(), borrowed.size(), &filled, &err)) { + return unexpected(errorToString(err)); + } + std::vector out; + out.reserve(filled); + for (uint64_t i = 0; i < filled; ++i) { + out.emplace_back(toStringView(borrowed[i])); + } + return out; +} } // namespace detail /// Typed view over the two-column Arrow struct returned by @@ -1811,6 +1838,121 @@ class ViewportHostView { PJ_viewport_host_t host_{}; }; +// --------------------------------------------------------------------------- +// PlotTabHostView — typed C++ view over PJ_plot_tab_host_t +// --------------------------------------------------------------------------- + +/// C++ wrapper around PJ_plot_tab_host_t for plugins that compose plotting tabs of +/// their own (see the C ABI doc-comment on PJ_plot_tab_host_vtable_t). Every call is +/// scoped by the host to the tabs THIS plugin created; ids naming anything else are +/// rejected like unknown ids. Empty-constructible; `valid()` tells whether the host +/// exposed the service. Strings returned by `list()`/`configOf()` are owned copies, so +/// they stay valid past the next vtable call. +class PlotTabHostView { + public: + PlotTabHostView() = default; + explicit PlotTabHostView(PJ_plot_tab_host_t host) : host_(host) {} + + [[nodiscard]] bool valid() const noexcept { + return host_.vtable != nullptr && host_.ctx != nullptr; + } + + /// Create (or replace, upsert by id) a tab holding one empty plot. An empty + /// `title` lets the host name it. + [[nodiscard]] Status create(std::string_view id, std::string_view title = {}) const { + if (!valid() || host_.vtable->create_tab == nullptr) { + return unexpected("plot tab host is not bound"); + } + PJ_error_t err{}; + if (!host_.vtable->create_tab(host_.ctx, toAbiString(id), toAbiString(title), &err)) { + return unexpected(errorToString(err)); + } + return okStatus(); + } + + /// Close one of this plugin's tabs. Any derived series or markers drawn in it + /// are data and outlive the view. + [[nodiscard]] Status close(std::string_view id) const { + if (!valid() || host_.vtable->close_tab == nullptr) { + return unexpected("plot tab host is not bound"); + } + PJ_error_t err{}; + if (!host_.vtable->close_tab(host_.ctx, toAbiString(id), &err)) { + return unexpected(errorToString(err)); + } + return okStatus(); + } + + /// Enumerate the ids of this plugin's live tabs (owned copies). Owning none is + /// success with an empty vector. + [[nodiscard]] Expected> list() const { + if (!valid() || host_.vtable->list_tab_ids == nullptr) { + return unexpected("plot tab host is not bound"); + } + return detail::listBorrowedStrings(host_.ctx, host_.vtable->list_tab_ids); + } + + /// Read back what a tab actually holds, as JSON (owned copy) — the way to + /// report what was drawn rather than what was asked for. + [[nodiscard]] Expected configOf(std::string_view id) const { + if (!valid() || host_.vtable->tab_config == nullptr) { + return unexpected("plot tab host is not bound"); + } + PJ_error_t err{}; + PJ_string_view_t out{}; + if (!host_.vtable->tab_config(host_.ctx, toAbiString(id), &out, &err)) { + return unexpected(errorToString(err)); + } + return std::string(toStringView(out)); + } + + /// Draw one curve. An empty `dataset_source` requires the topic/field to be + /// unique across loaded datasets; an ambiguous one is refused by the host with + /// the qualified candidates rather than guessed. + [[nodiscard]] Status addCurve( + std::string_view id, std::string_view topic, std::string_view field, std::string_view dataset_source = {}) const { + if (!valid() || host_.vtable->add_curve == nullptr) { + return unexpected("plot tab host is not bound"); + } + PJ_error_t err{}; + if (!host_.vtable->add_curve( + host_.ctx, toAbiString(id), toAbiString(topic), toAbiString(field), toAbiString(dataset_source), &err)) { + return unexpected(errorToString(err)); + } + return okStatus(); + } + + /// Take one curve back out, resolved by the same rule as addCurve. A curve + /// that is not there is an error. + [[nodiscard]] Status removeCurve( + std::string_view id, std::string_view topic, std::string_view field, std::string_view dataset_source = {}) const { + if (!valid() || host_.vtable->remove_curve == nullptr) { + return unexpected("plot tab host is not bound"); + } + PJ_error_t err{}; + if (!host_.vtable->remove_curve( + host_.ctx, toAbiString(id), toAbiString(topic), toAbiString(field), toAbiString(dataset_source), &err)) { + return unexpected(errorToString(err)); + } + return okStatus(); + } + + /// Remove every curve from a tab, keeping the tab itself. + [[nodiscard]] Status clear(std::string_view id) const { + if (!valid() || host_.vtable->clear_tab == nullptr) { + return unexpected("plot tab host is not bound"); + } + PJ_error_t err{}; + if (!host_.vtable->clear_tab(host_.ctx, toAbiString(id), &err)) { + return unexpected(errorToString(err)); + } + return okStatus(); + } + + private: + PJ_plot_tab_host_t host_{}; +}; + // --------------------------------------------------------------------------- // SettingsView — typed C++ view over PJ_settings_store_t // --------------------------------------------------------------------------- diff --git a/pj_base/include/pj_base/sdk/service_traits.hpp b/pj_base/include/pj_base/sdk/service_traits.hpp index bb3c3523..f98b0ef8 100644 --- a/pj_base/include/pj_base/sdk/service_traits.hpp +++ b/pj_base/include/pj_base/sdk/service_traits.hpp @@ -213,6 +213,19 @@ struct ViewportHostService { static_assert(detail::isValidServiceName(kName), "kName must match the pj naming rule"); }; +/// "pj.plot_tabs.v1" — compose plotting tabs of the plugin's own: create, place +/// and remove curves, read back what they hold, close. Scoped by the host to +/// this plugin's tabs, which is also what bounds "pj.viewport.v1". Hosts with +/// no plot workspace (headless) simply do not register it. +struct PlotTabHostService { + static constexpr const char* kName = "pj.plot_tabs.v1"; + static constexpr uint32_t kMinVersion = 1; + using Raw = PJ_plot_tab_host_t; + using Vtable = PJ_plot_tab_host_vtable_t; + using View = PlotTabHostView; + static_assert(detail::isValidServiceName(kName), "kName must match the pj naming rule"); +}; + /// Optional QSettings-like key/value persistence exposed to any plugin family. /// Host-backed (QSettings in the GUI app, JSON in a headless host); keys are /// namespaced per plugin by the host. diff --git a/pj_base/tests/plot_tabs_api_test.cpp b/pj_base/tests/plot_tabs_api_test.cpp new file mode 100644 index 00000000..6ceab843 --- /dev/null +++ b/pj_base/tests/plot_tabs_api_test.cpp @@ -0,0 +1,375 @@ +// Copyright 2026 Davide Faconti +// SPDX-License-Identifier: Apache-2.0 + +#include + +#include +#include +#include +#include +#include + +#include "pj_base/plugin_data_api.h" +#include "pj_base/sdk/plugin_data_api.hpp" + +namespace PJ { +namespace { + +// Fake host for pj.plot_tabs.v1: a small model of tabs-with-curves, not a bare +// recorder, so the round-trip tests below can assert on what a read-back +// actually contains rather than merely that a call was forwarded. +struct FakePlotTabHost { + struct Curve { + std::string topic; + std::string field; + std::string dataset; + }; + struct Tab { + std::string id; + std::string title; + std::vector curves; + }; + + std::vector tabs; + bool should_fail = false; + std::string last_config_json; // storage backing the borrowed tab_config out-string + + Tab* find(std::string_view id) { + auto it = std::find_if(tabs.begin(), tabs.end(), [&](const Tab& t) { return t.id == id; }); + return it == tabs.end() ? nullptr : &(*it); + } +}; + +bool ptFail(FakePlotTabHost* self, PJ_error_t* out_error) noexcept { + if (self->should_fail) { + sdk::fillError(out_error, 1, "plot_tabs", "tab boom"); + return true; + } + return false; +} + +bool ptCreateTab(void* ctx, PJ_string_view_t id, PJ_string_view_t title, PJ_error_t* out_error) noexcept { + auto* self = static_cast(ctx); + if (ptFail(self, out_error)) { + return false; + } + const auto id_sv = sdk::toStringView(id); + auto* existing = self->find(id_sv); + if (existing != nullptr) { + existing->curves.clear(); + existing->title = std::string(sdk::toStringView(title)); + return true; + } + self->tabs.push_back( + FakePlotTabHost::Tab{.id = std::string(id_sv), .title = std::string(sdk::toStringView(title)), .curves = {}}); + return true; +} + +bool ptCloseTab(void* ctx, PJ_string_view_t id, PJ_error_t* out_error) noexcept { + auto* self = static_cast(ctx); + if (ptFail(self, out_error)) { + return false; + } + const auto id_sv = sdk::toStringView(id); + auto it = std::find_if(self->tabs.begin(), self->tabs.end(), [&](const auto& t) { return t.id == id_sv; }); + if (it == self->tabs.end()) { + sdk::fillError(out_error, 2, "plot_tabs", "unknown tab id"); + return false; + } + self->tabs.erase(it); + return true; +} + +bool ptListTabIds( + void* ctx, PJ_string_view_t* out_ids, uint64_t capacity, uint64_t* out_count, PJ_error_t* out_error) noexcept { + auto* self = static_cast(ctx); + if (ptFail(self, out_error)) { + return false; + } + const auto total = static_cast(self->tabs.size()); + if (capacity == 0) { + *out_count = total; + return true; + } + const uint64_t filled = std::min(capacity, total); + for (uint64_t i = 0; i < filled; ++i) { + out_ids[i] = sdk::toAbiString(self->tabs[i].id); + } + *out_count = filled; + return true; +} + +bool ptTabConfig(void* ctx, PJ_string_view_t id, PJ_string_view_t* out_config_json, PJ_error_t* out_error) noexcept { + auto* self = static_cast(ctx); + if (ptFail(self, out_error)) { + return false; + } + auto* tab = self->find(sdk::toStringView(id)); + if (tab == nullptr) { + sdk::fillError(out_error, 2, "plot_tabs", "unknown tab id"); + return false; + } + std::string json = "{\"title\":\"" + tab->title + "\",\"curves\":["; + for (size_t i = 0; i < tab->curves.size(); ++i) { + if (i != 0) { + json += ","; + } + const auto& curve = tab->curves[i]; + json += + "{\"topic\":\"" + curve.topic + "\",\"field\":\"" + curve.field + "\",\"dataset\":\"" + curve.dataset + "\"}"; + } + json += "]}"; + self->last_config_json = std::move(json); + *out_config_json = sdk::toAbiString(self->last_config_json); + return true; +} + +bool ptAddCurve( + void* ctx, PJ_string_view_t id, PJ_string_view_t topic, PJ_string_view_t field, PJ_string_view_t dataset_source, + PJ_error_t* out_error) noexcept { + auto* self = static_cast(ctx); + if (ptFail(self, out_error)) { + return false; + } + auto* tab = self->find(sdk::toStringView(id)); + if (tab == nullptr) { + sdk::fillError(out_error, 2, "plot_tabs", "unknown tab id"); + return false; + } + tab->curves.push_back( + FakePlotTabHost::Curve{ + .topic = std::string(sdk::toStringView(topic)), + .field = std::string(sdk::toStringView(field)), + .dataset = std::string(sdk::toStringView(dataset_source))}); + return true; +} + +bool ptRemoveCurve( + void* ctx, PJ_string_view_t id, PJ_string_view_t topic, PJ_string_view_t field, PJ_string_view_t dataset_source, + PJ_error_t* out_error) noexcept { + auto* self = static_cast(ctx); + if (ptFail(self, out_error)) { + return false; + } + auto* tab = self->find(sdk::toStringView(id)); + if (tab == nullptr) { + sdk::fillError(out_error, 2, "plot_tabs", "unknown tab id"); + return false; + } + const auto topic_sv = sdk::toStringView(topic); + const auto field_sv = sdk::toStringView(field); + const auto dataset_sv = sdk::toStringView(dataset_source); + auto it = std::find_if(tab->curves.begin(), tab->curves.end(), [&](const auto& c) { + return c.topic == topic_sv && c.field == field_sv && c.dataset == dataset_sv; + }); + if (it == tab->curves.end()) { + sdk::fillError(out_error, 3, "plot_tabs", "curve not present"); + return false; + } + tab->curves.erase(it); + return true; +} + +bool ptClearTab(void* ctx, PJ_string_view_t id, PJ_error_t* out_error) noexcept { + auto* self = static_cast(ctx); + if (ptFail(self, out_error)) { + return false; + } + auto* tab = self->find(sdk::toStringView(id)); + if (tab == nullptr) { + sdk::fillError(out_error, 2, "plot_tabs", "unknown tab id"); + return false; + } + tab->curves.clear(); + return true; +} + +PJ_plot_tab_host_vtable_t makePlotTabVtable() { + return PJ_plot_tab_host_vtable_t{ + .protocol_version = 1, + .struct_size = sizeof(PJ_plot_tab_host_vtable_t), + .create_tab = ptCreateTab, + .close_tab = ptCloseTab, + .list_tab_ids = ptListTabIds, + .tab_config = ptTabConfig, + .add_curve = ptAddCurve, + .remove_curve = ptRemoveCurve, + .clear_tab = ptClearTab, + }; +} + +// --- PlotTabHostView -------------------------------------------------------- + +TEST(PlotTabApiTest, CreateAndListRoundTrip) { + FakePlotTabHost host; + const auto vtable = makePlotTabVtable(); + sdk::PlotTabHostView view(PJ_plot_tab_host_t{.ctx = &host, .vtable = &vtable}); + + ASSERT_TRUE(view.create("tab-a", "First")); + ASSERT_TRUE(view.create("tab-b", "Second")); + + auto ids = view.list(); + ASSERT_TRUE(ids) << ids.error(); + ASSERT_EQ(ids->size(), 2u); + EXPECT_EQ((*ids)[0], "tab-a"); + EXPECT_EQ((*ids)[1], "tab-b"); +} + +TEST(PlotTabApiTest, ListOnAnEmptyHostSucceedsWithNoTabs) { + FakePlotTabHost host; + const auto vtable = makePlotTabVtable(); + sdk::PlotTabHostView view(PJ_plot_tab_host_t{.ctx = &host, .vtable = &vtable}); + + auto ids = view.list(); + ASSERT_TRUE(ids) << ids.error(); + EXPECT_TRUE(ids->empty()); +} + +TEST(PlotTabApiTest, ConfigReadsBackWhatWasActuallyAdded) { + FakePlotTabHost host; + const auto vtable = makePlotTabVtable(); + sdk::PlotTabHostView view(PJ_plot_tab_host_t{.ctx = &host, .vtable = &vtable}); + + ASSERT_TRUE(view.create("tab-a", "My Tab")); + ASSERT_TRUE(view.addCurve("tab-a", "imu/accel", "x", "bag1")); + ASSERT_TRUE(view.addCurve("tab-a", "imu/gyro", "y", "bag1")); + + auto config = view.configOf("tab-a"); + ASSERT_TRUE(config) << config.error(); + EXPECT_NE(config->find("My Tab"), std::string::npos); + EXPECT_NE(config->find("imu/accel"), std::string::npos); + EXPECT_NE(config->find("imu/gyro"), std::string::npos); + + const auto grown_size = config->size(); + ASSERT_TRUE(view.addCurve("tab-a", "imu/mag", "z", "bag1")); + auto config2 = view.configOf("tab-a"); + ASSERT_TRUE(config2) << config2.error(); + EXPECT_NE(config2->find("imu/mag"), std::string::npos); + EXPECT_GT(config2->size(), grown_size); +} + +TEST(PlotTabApiTest, RemoveCurveThatIsNotThereIsAnError) { + FakePlotTabHost host; + const auto vtable = makePlotTabVtable(); + sdk::PlotTabHostView view(PJ_plot_tab_host_t{.ctx = &host, .vtable = &vtable}); + + ASSERT_TRUE(view.create("tab-a")); + auto status = view.removeCurve("tab-a", "imu/accel", "x", "bag1"); + EXPECT_FALSE(status); +} + +TEST(PlotTabApiTest, ClearKeepsTheTab) { + FakePlotTabHost host; + const auto vtable = makePlotTabVtable(); + sdk::PlotTabHostView view(PJ_plot_tab_host_t{.ctx = &host, .vtable = &vtable}); + + ASSERT_TRUE(view.create("tab-a", "My Tab")); + ASSERT_TRUE(view.addCurve("tab-a", "imu/accel", "x", "bag1")); + ASSERT_TRUE(view.clear("tab-a")); + + auto ids = view.list(); + ASSERT_TRUE(ids) << ids.error(); + ASSERT_EQ(ids->size(), 1u); + EXPECT_EQ((*ids)[0], "tab-a"); + + auto config = view.configOf("tab-a"); + ASSERT_TRUE(config) << config.error(); + EXPECT_EQ(config->find("imu/accel"), std::string::npos); +} + +TEST(PlotTabApiTest, CloseRemovesTheTab) { + FakePlotTabHost host; + const auto vtable = makePlotTabVtable(); + sdk::PlotTabHostView view(PJ_plot_tab_host_t{.ctx = &host, .vtable = &vtable}); + + ASSERT_TRUE(view.create("tab-a")); + ASSERT_TRUE(view.close("tab-a")); + + auto ids = view.list(); + ASSERT_TRUE(ids) << ids.error(); + EXPECT_TRUE(ids->empty()); + + auto config = view.configOf("tab-a"); + EXPECT_FALSE(config); +} + +TEST(PlotTabApiTest, UnknownIdIsAnError) { + FakePlotTabHost host; + const auto vtable = makePlotTabVtable(); + sdk::PlotTabHostView view(PJ_plot_tab_host_t{.ctx = &host, .vtable = &vtable}); + + EXPECT_FALSE(view.addCurve("nope", "imu/accel", "x")); + EXPECT_FALSE(view.configOf("nope")); + EXPECT_FALSE(view.close("nope")); +} + +TEST(PlotTabApiTest, HostFailureSurfacesTheMessage) { + FakePlotTabHost host; + host.should_fail = true; + const auto vtable = makePlotTabVtable(); + sdk::PlotTabHostView view(PJ_plot_tab_host_t{.ctx = &host, .vtable = &vtable}); + + auto create_status = view.create("tab-a"); + EXPECT_FALSE(create_status); + EXPECT_NE(create_status.error().find("tab boom"), std::string::npos); + + auto add_status = view.addCurve("tab-a", "imu/accel", "x"); + EXPECT_FALSE(add_status); + EXPECT_NE(add_status.error().find("tab boom"), std::string::npos); + + auto list_status = view.list(); + EXPECT_FALSE(list_status); + EXPECT_NE(list_status.error().find("tab boom"), std::string::npos); +} + +TEST(PlotTabApiTest, UnboundViewReportsNotBound) { + sdk::PlotTabHostView view; // default-constructed = not bound + EXPECT_FALSE(view.valid()); + + auto create_status = view.create("tab-a"); + EXPECT_FALSE(create_status); + EXPECT_NE(create_status.error().find("not bound"), std::string::npos); + + auto close_status = view.close("tab-a"); + EXPECT_FALSE(close_status); + EXPECT_NE(close_status.error().find("not bound"), std::string::npos); + + auto list_status = view.list(); + EXPECT_FALSE(list_status); + EXPECT_NE(list_status.error().find("not bound"), std::string::npos); + + auto config_status = view.configOf("tab-a"); + EXPECT_FALSE(config_status); + EXPECT_NE(config_status.error().find("not bound"), std::string::npos); + + auto add_status = view.addCurve("tab-a", "imu/accel", "x"); + EXPECT_FALSE(add_status); + EXPECT_NE(add_status.error().find("not bound"), std::string::npos); + + auto remove_status = view.removeCurve("tab-a", "imu/accel", "x"); + EXPECT_FALSE(remove_status); + EXPECT_NE(remove_status.error().find("not bound"), std::string::npos); + + auto clear_status = view.clear("tab-a"); + EXPECT_FALSE(clear_status); + EXPECT_NE(clear_status.error().find("not bound"), std::string::npos); +} + +TEST(PlotTabApiTest, DatasetQualifierReachesTheHost) { + FakePlotTabHost host; + const auto vtable = makePlotTabVtable(); + sdk::PlotTabHostView view(PJ_plot_tab_host_t{.ctx = &host, .vtable = &vtable}); + + ASSERT_TRUE(view.create("tab-a")); + ASSERT_TRUE(view.addCurve("tab-a", "imu/accel", "x", "bag1")); + ASSERT_TRUE(view.addCurve("tab-a", "imu/gyro", "y")); + + auto* tab = host.find("tab-a"); + ASSERT_NE(tab, nullptr); + ASSERT_EQ(tab->curves.size(), 2u); + EXPECT_EQ(tab->curves[0].dataset, "bag1"); + EXPECT_TRUE(tab->curves[1].dataset.empty()); +} + +} // namespace +} // namespace PJ diff --git a/pj_plugins/docs/ARCHITECTURE.md b/pj_plugins/docs/ARCHITECTURE.md index d8cc8e93..6dab8656 100644 --- a/pj_plugins/docs/ARCHITECTURE.md +++ b/pj_plugins/docs/ARCHITECTURE.md @@ -262,10 +262,17 @@ service registry, error out-params, and typed borrowed-dialog patterns): `sdk::PlaybackHostView`; every time in the service is display-axis seconds (the numbers the plot X axes and the playback slider show), valid for the current frame only because display offsets are per-dataset and user-editable. - `"pj.viewport.v1"` (optional) zooms every open time-series plot to a - display-seconds X window or resets all plots to fit - (`sdk::ViewportHostView`); hosts without plots (headless) simply do not - register it. + `"pj.plot_tabs.v1"` (optional) lets a plugin compose plotting tabs **of its + own** — create one, place and remove curves by (topic, field, dataset), + read back what it actually holds, close it (`sdk::PlotTabHostView`). Ids are + plugin-chosen and namespaced per plugin, as in `"pj.data_processors.v1"`, and + every slot is scoped to the caller's own tabs: a tab the plugin did not create + is rejected exactly as an unknown id, so the user's tabs are never disclosed + or touched. `"pj.viewport.v1"` (optional) zooms to a display-seconds X window + or resets to fit (`sdk::ViewportHostView`), **bounded to those same owned + tabs** — the boundary is the view, not the transport, which is why + `"pj.playback.v1"` stays global. Hosts without plots (headless) simply do not + register either. - **Structured errors everywhere.** All fallible ABI calls take a `PJ_error_t* out_error` out-parameter. The old per-plugin `get_last_error` slot is gone.