diff --git a/CHANGELOG.md b/CHANGELOG.md index 97d7d09b..6abfebfe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,56 @@ All notable changes to `plotjuggler_sdk` are recorded here. Versioning policy is in [`CLAUDE.md`](./CLAUDE.md) → "Release Versioning". +## [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) + +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` (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). `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 + +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/VERSION b/VERSION index d21d277b..1b58cc10 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.25.0 +0.27.0 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/CMakeLists.txt b/pj_base/CMakeLists.txt index 6603178f..96fcc8ab 100644 --- a/pj_base/CMakeLists.txt +++ b/pj_base/CMakeLists.txt @@ -151,6 +151,8 @@ 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/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/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/include/pj_base/plugin_data_api.h b/pj_base/include/pj_base/plugin_data_api.h index 93e302f0..9e92b883 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 */ @@ -1046,6 +1055,203 @@ 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, 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 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 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; + +typedef struct { + void* ctx; + 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 a6cb86e6..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 @@ -1653,6 +1680,279 @@ 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_{}; +}; + +// --------------------------------------------------------------------------- +// 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 68bb78d2..f98b0ef8 100644 --- a/pj_base/include/pj_base/sdk/service_traits.hpp +++ b/pj_base/include/pj_base/sdk/service_traits.hpp @@ -187,6 +187,45 @@ 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"); +}; + +/// "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/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/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_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()); } // ----------------------------------------------------------------------------- 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 0aa335e5..6dab8656 100644 --- a/pj_plugins/docs/ARCHITECTURE.md +++ b/pj_plugins/docs/ARCHITECTURE.md @@ -256,6 +256,23 @@ 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.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. 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