From 4ebcb83e7a188c075ce0dba2c4c96b4cc0d60568 Mon Sep 17 00:00:00 2001 From: Davide Faconti Date: Sat, 18 Jul 2026 14:57:01 +0200 Subject: [PATCH] =?UTF-8?q?feat(dialog):=20sort-key-aware=20table=20deltas?= =?UTF-8?q?=20=E2=80=94=20typed=20append=20+=20whole-cell=20updates?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Without this, the sort-keys feature and the batch deltas undermined each other: a typed table that streams via deltas silently degraded, because appended rows could carry no keys (text rank in a numeric column) and cell updates could not refresh the ordering truth behind the text. - appendTableRows gains a TableItem overload emitting a sparse append_values column map aligned to the appended rows (the delta-side mirror of column_values, via a shared encodeTypedRows helper); the string overload erases append_values for the same stale-key protection as setTableRows. - TableCellUpdate becomes {row, col, TableItem}: an update replaces the WHOLE cell — display text and optional key together, so they cannot desync; a keyless item clears the key. Wire: [row, col, text] or [row, col, text, value]; a null key means keyless (NaN/Inf serialize as null), matching the column_values convention. - TableDeltaView decodes both strictly (malformed key or misaligned append_values column rejects the whole delta) through a shared numericFromJson helper, now also backing tableColumnValues. - Existing braced-literal appendTableRows call sites explicitized per the documented overload-ambiguity caveat, which now names appendTableRows too. - Removed the last stale 0.17.0 reference (test comment). Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 12 +- docs/dialog-sdk-reference.md | 4 +- .../pj_plugins/host/widget_data_view.hpp | 75 +++++++++-- .../include/pj_plugins/sdk/widget_data.hpp | 126 ++++++++++++------ .../tests/widget_data_test.cpp | 39 +++++- .../tests/widget_data_view_test.cpp | 43 +++++- pj_plugins/docs/dialog-plugin-guide.md | 29 +++- 7 files changed, 259 insertions(+), 69 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b30b9e9..db78b2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,14 @@ display text. Backward-compatible JSON additions — no C ABI change, - The plain-string `setTableRows` overload now erases any `column_values` a previous typed delivery left on the same table, so alternating overloads can never pair fresh rows with stale sort keys. +- The batch table deltas (below) are sort-key aware: `appendTableRows` gains a + `TableItem` overload emitting a sparse `append_values` column map aligned to + the appended rows, and `TableCellUpdate` becomes `{row, col, TableItem}` — + an update replaces the whole cell (text + optional key; a keyless item + clears the key), serialized as `[row, col, text]` or + `[row, col, text, value]`. `TableDeltaView` decodes both (strict: a + malformed key or a misaligned `append_values` column rejects the whole + delta), so a typed table stays typed while it streams. ### Feature: batch table deltas for large QTableWidgets (MINOR) @@ -48,8 +56,8 @@ Mutate a table without resending the whole `rows` array (backward-compatible JSON addition; no C ABI change, `PJ_DIALOG_PROTOCOL_VERSION` unchanged): - `WidgetData::appendTableRows` / `updateTableCells` / `removeTableRows` write - a per-widget `table_delta` object (`seq`, `append`, `update_cells`, - `remove_rows`). `seq` is plugin-owned; hosts apply a delta only when its seq + a per-widget `table_delta` object (`seq`, `append`, `append_values`, + `update_cells`, `remove_rows`). `seq` is plugin-owned; hosts apply a delta only when its seq differs from the last one applied to that widget, in the order update_cells → remove_rows → append, with all indexes addressing the pre-delta table. New `TableCellUpdate` struct. diff --git a/docs/dialog-sdk-reference.md b/docs/dialog-sdk-reference.md index 7ad1fa8..f831b6d 100644 --- a/docs/dialog-sdk-reference.md +++ b/docs/dialog-sdk-reference.md @@ -80,8 +80,8 @@ For the full tutorial, see [dialog-plugin-guide.md](../pj_plugins/docs/dialog-pl | `setSelectedRows(name, vector)` | Set selected row indices | | `setDisabledRows(name, vector)` | Grey out rows (non-selectable) | | `setTableRadioColumn(name, column, checked_row)` | Render `column` as an exclusive radio group; `checked_row` is selected (-1 = none). Fires `onTableRadioSelected`. | -| `appendTableRows(name, seq, rows)` | Delta: append rows without resending `rows` (see guide → "Table deltas") | -| `updateTableCells(name, seq, vector)` | Delta: rewrite individual cells (`{row, col, text}`, plugin row space) | +| `appendTableRows(name, seq, rows)` | Delta: append rows without resending `rows` — string or `TableItem` rows (typed rows keep their sort keys via a sparse `append_values` map). See guide → "Table deltas". | +| `updateTableCells(name, seq, vector)` | Delta: rewrite individual cells (`{row, col, TableItem}`, plugin row space). Replaces the whole cell — a keyless item clears the sort key. | | `removeTableRows(name, seq, vector)` | Delta: remove plugin-space row indexes | > A table must not combine `sortingEnabled=true` in its `.ui` with diff --git a/pj_plugins/dialog_protocol/include/pj_plugins/host/widget_data_view.hpp b/pj_plugins/dialog_protocol/include/pj_plugins/host/widget_data_view.hpp index 872d3e6..0389cd3 100644 --- a/pj_plugins/dialog_protocol/include/pj_plugins/host/widget_data_view.hpp +++ b/pj_plugins/dialog_protocol/include/pj_plugins/host/widget_data_view.hpp @@ -150,17 +150,7 @@ class WidgetDataView { std::vector> decoded; decoded.reserve(values.size()); for (const auto& v : values) { - // The wire collapses every integer width to a JSON integer, so decode to the - // widest lossless alternative rather than guessing the plugin's original type. - if (v.is_number_unsigned()) { - decoded.emplace_back(NumericValue(v.get())); - } else if (v.is_number_integer()) { - decoded.emplace_back(NumericValue(v.get())); - } else if (v.is_number_float()) { - decoded.emplace_back(NumericValue(v.get())); - } else { - decoded.emplace_back(std::nullopt); - } + decoded.emplace_back(numericFromJson(v)); } result.emplace(*col, std::move(decoded)); } @@ -203,10 +193,17 @@ class WidgetDataView { struct TableDeltaView { std::uint64_t seq = 0; std::vector> append; + /// Sort keys for the appended rows: column index -> one entry per appended + /// row, in `append` order (same sparse shape as tableColumnValues). A + /// column absent here, or a nullopt entry, appends a keyless cell. + std::map>> append_values; struct CellUpdate { int row = 0; int col = 0; std::string text; + /// The cell's new sort key; nullopt CLEARS any previous key (an update + /// replaces the whole cell, so display and ordering cannot desync). + std::optional value; }; std::vector update_cells; std::vector remove_rows; @@ -261,17 +258,53 @@ class WidgetDataView { delta.append.push_back(std::move(cells)); } } + if (auto values_it = it->find("append_values"); values_it != it->end()) { + if (!values_it->is_object()) { + return std::nullopt; + } + for (auto kv = values_it->begin(); kv != values_it->end(); ++kv) { + const auto col = parseNumber(kv.key()); + const nlohmann::json& values = kv.value(); + if (!col.has_value() || *col < 0 || !values.is_array() || values.size() != delta.append.size()) { + return std::nullopt; + } + std::vector> decoded; + decoded.reserve(values.size()); + for (const auto& v : values) { + if (v.is_null()) { + decoded.emplace_back(std::nullopt); + continue; + } + auto num = numericFromJson(v); + if (!num.has_value()) { + return std::nullopt; + } + decoded.emplace_back(*num); + } + delta.append_values.emplace(*col, std::move(decoded)); + } + } if (auto cells_it = it->find("update_cells"); cells_it != it->end()) { if (!cells_it->is_array()) { return std::nullopt; } for (const auto& update : *cells_it) { - if (!update.is_array() || update.size() != 3 || !update[0].is_number_integer() || + if (!update.is_array() || update.size() < 3 || update.size() > 4 || !update[0].is_number_integer() || !update[1].is_number_integer() || !update[2].is_string() || update[0].get() < 0 || update[1].get() < 0) { return std::nullopt; } - delta.update_cells.push_back({update[0].get(), update[1].get(), update[2].get()}); + TableDeltaView::CellUpdate cell{ + update[0].get(), update[1].get(), update[2].get(), std::nullopt}; + // A null key means keyless (NaN/Inf serialize as null), matching the + // column_values convention; any other non-number is malformed. + if (update.size() == 4 && !update[3].is_null()) { + cell.value = numericFromJson(update[3]); + if (!cell.value.has_value()) { + return std::nullopt; + } + } + delta.update_cells.push_back(std::move(cell)); } } if (auto remove_it = it->find("remove_rows"); remove_it != it->end()) { @@ -853,6 +886,22 @@ class WidgetDataView { private: nlohmann::json data_; + /// Decode a JSON number into the widest lossless NumericValue alternative + /// (the wire collapses every integer width, so no guessing the original + /// type). Non-numbers yield nullopt. + static std::optional numericFromJson(const nlohmann::json& v) { + if (v.is_number_unsigned()) { + return NumericValue(v.get()); + } + if (v.is_number_integer()) { + return NumericValue(v.get()); + } + if (v.is_number_float()) { + return NumericValue(v.get()); + } + return std::nullopt; + } + const nlohmann::json* widget(std::string_view name) const { auto it = data_.find(std::string(name)); if (it == data_.end() || !it->is_object()) { diff --git a/pj_plugins/dialog_protocol/include/pj_plugins/sdk/widget_data.hpp b/pj_plugins/dialog_protocol/include/pj_plugins/sdk/widget_data.hpp index 297b870..a18177c 100644 --- a/pj_plugins/dialog_protocol/include/pj_plugins/sdk/widget_data.hpp +++ b/pj_plugins/dialog_protocol/include/pj_plugins/sdk/widget_data.hpp @@ -137,10 +137,13 @@ struct ChartMarker { /// One cell write of a table delta (see WidgetData::updateTableCells). /// `row`/`col` are in the plugin's own row space — the same space /// setTableRows / setSelectedRows use, regardless of any user sorting. +/// The update replaces the WHOLE cell: `item` carries both the display text +/// and the optional sort key, so a keyless item clears any previous key +/// rather than preserving it — display and ordering truth cannot desync. struct TableCellUpdate { int row = 0; int col = 0; - std::string text; + TableItem item; }; /// One mark on a MarkerTimeline. `region` true → a resizable [start,end] span; @@ -307,45 +310,10 @@ class WidgetData { /// Columns where no cell has a value are omitted entirely and sort by text. WidgetData& setTableRows(std::string_view name, const std::vector>& rows) { auto& e = entry(name); - - nlohmann::json text_rows = nlohmann::json::array(); - std::size_t max_cols = 0; - for (const auto& row : rows) { - nlohmann::json cells = nlohmann::json::array(); - for (const auto& item : row) { - cells.push_back(item.text); - } - text_rows.push_back(std::move(cells)); - if (row.size() > max_cols) { - max_cols = row.size(); - } - } + nlohmann::json text_rows; + nlohmann::json columns; + encodeTypedRows(rows, text_rows, columns); e["rows"] = std::move(text_rows); - - nlohmann::json columns = nlohmann::json::object(); - for (std::size_t c = 0; c < max_cols; ++c) { - bool any_value = false; - for (const auto& row : rows) { - if (c < row.size() && row[c].value.has_value()) { - any_value = true; - break; - } - } - if (!any_value) { - continue; - } - nlohmann::json values = nlohmann::json::array(); - for (const auto& row : rows) { - if (c < row.size() && row[c].value.has_value()) { - // Push the native alternative: nlohmann stores int64/uint64 natively, so a - // large count or a nanosecond timestamp survives the round-trip exactly. - std::visit([&values](auto v) { values.push_back(v); }, *row[c].value); - } else { - values.push_back(nlohmann::json::value_t::null); - } - } - columns[std::to_string(c)] = std::move(values); - } if (columns.empty()) { e.erase("column_values"); } else { @@ -431,18 +399,47 @@ class WidgetData { // same delta appends. A `rows` field in the same refresh wins: the host // applies the full replace and the delta counts as consumed. - /// Append rows to the end of the table. + /// Append plain-text rows to the end of the table (cells sort by text). WidgetData& appendTableRows( std::string_view name, std::uint64_t seq, const std::vector>& rows) { - tableDeltaEntry(name, seq)["append"] = rows; + auto& delta = tableDeltaEntry(name, seq); + delta["append"] = rows; + // Same stale-key protection as the plain setTableRows overload: a typed + // append written earlier under this seq must not leave keys behind. + delta.erase("append_values"); return *this; } - /// Rewrite individual cells in place. + /// Typed append: rows that carry sort keys (see TableItem). Emits display + /// text as `append` plus the keys as a sparse `append_values` column map + /// aligned to the appended rows — the delta-side mirror of `column_values`, + /// so appended cells keep sorting numerically in typed columns. + WidgetData& appendTableRows( + std::string_view name, std::uint64_t seq, const std::vector>& rows) { + auto& delta = tableDeltaEntry(name, seq); + nlohmann::json text_rows; + nlohmann::json columns; + encodeTypedRows(rows, text_rows, columns); + delta["append"] = std::move(text_rows); + if (columns.empty()) { + delta.erase("append_values"); + } else { + delta["append_values"] = std::move(columns); + } + return *this; + } + + /// Rewrite individual cells in place. Each update replaces the WHOLE cell — + /// display text plus optional sort key (see TableCellUpdate); a keyless item + /// clears the cell's key. Wire: [row, col, text] or [row, col, text, value]. WidgetData& updateTableCells(std::string_view name, std::uint64_t seq, const std::vector& cells) { auto arr = nlohmann::json::array(); for (const auto& cell : cells) { - arr.push_back({cell.row, cell.col, cell.text}); + nlohmann::json update = {cell.row, cell.col, cell.item.text}; + if (cell.item.value.has_value()) { + std::visit([&update](auto v) { update.push_back(v); }, *cell.item.value); + } + arr.push_back(std::move(update)); } tableDeltaEntry(name, seq)["update_cells"] = std::move(arr); return *this; @@ -880,6 +877,49 @@ class WidgetData { } return delta; } + + /// Encode TableItem rows into display-text rows plus the sparse per-column + /// key map shared by `column_values` and `append_values`: only columns where + /// some cell has a value, keyless cells as null, omitted entirely otherwise. + static void encodeTypedRows( + const std::vector>& rows, nlohmann::json& out_text_rows, nlohmann::json& out_columns) { + out_text_rows = nlohmann::json::array(); + std::size_t max_cols = 0; + for (const auto& row : rows) { + nlohmann::json cells = nlohmann::json::array(); + for (const auto& item : row) { + cells.push_back(item.text); + } + out_text_rows.push_back(std::move(cells)); + if (row.size() > max_cols) { + max_cols = row.size(); + } + } + out_columns = nlohmann::json::object(); + for (std::size_t c = 0; c < max_cols; ++c) { + bool any_value = false; + for (const auto& row : rows) { + if (c < row.size() && row[c].value.has_value()) { + any_value = true; + break; + } + } + if (!any_value) { + continue; + } + nlohmann::json values = nlohmann::json::array(); + for (const auto& row : rows) { + if (c < row.size() && row[c].value.has_value()) { + // Push the native alternative: nlohmann stores int64/uint64 natively, so a + // large count or a nanosecond timestamp survives the round-trip exactly. + std::visit([&values](auto v) { values.push_back(v); }, *row[c].value); + } else { + values.push_back(nlohmann::json::value_t::null); + } + } + out_columns[std::to_string(c)] = std::move(values); + } + } }; } // namespace PJ diff --git a/pj_plugins/dialog_protocol/tests/widget_data_test.cpp b/pj_plugins/dialog_protocol/tests/widget_data_test.cpp index f78f8e5..03954dd 100644 --- a/pj_plugins/dialog_protocol/tests/widget_data_test.cpp +++ b/pj_plugins/dialog_protocol/tests/widget_data_test.cpp @@ -10,6 +10,7 @@ #include #include +using PJ::TableItem; using PJ::WidgetData; using json = nlohmann::json; @@ -443,7 +444,7 @@ TEST(WidgetDataTest, SetCodeCaretTrackingExplicitFalse) { TEST(WidgetDataTest, TableDeltaOpsSerializeUnderOneKey) { WidgetData wd; - wd.appendTableRows("tbl", 7, {{"a", "b"}, {"c", "d"}}); + wd.appendTableRows("tbl", 7, std::vector>{{"a", "b"}, {"c", "d"}}); wd.updateTableCells("tbl", 7, {{0, 1, "x"}}); wd.removeTableRows("tbl", 7, {3, 5}); auto j = parse(wd); @@ -460,7 +461,7 @@ TEST(WidgetDataTest, TableDeltaOpsSerializeUnderOneKey) { TEST(WidgetDataTest, TableDeltaDifferingSeqStartsFreshDelta) { WidgetData wd; - wd.appendTableRows("tbl", 1, {{"old"}}); + wd.appendTableRows("tbl", 1, std::vector>{{"old"}}); wd.updateTableCells("tbl", 2, {{0, 0, "new"}}); auto j = parse(wd); const auto& delta = j["tbl"]["table_delta"]; @@ -469,3 +470,37 @@ TEST(WidgetDataTest, TableDeltaDifferingSeqStartsFreshDelta) { EXPECT_FALSE(delta.contains("append")); ASSERT_EQ(delta["update_cells"].size(), 1U); } + +TEST(WidgetDataTest, TypedAppendEmitsSparseAppendValues) { + WidgetData wd; + wd.appendTableRows( + "tbl", 3, + std::vector>{ + {TableItem("plain"), TableItem(int64_t{42}, "42 ms")}, {TableItem("other"), TableItem("keyless")}}); + auto j = parse(wd); + const auto& delta = j["tbl"]["table_delta"]; + EXPECT_EQ(delta["seq"], 3); + ASSERT_EQ(delta["append"].size(), 2U); + EXPECT_EQ(delta["append"][0][1], "42 ms"); + // Sparse, mirroring column_values: only column 1 carries any key; the + // keyless cell is null there; column 0 is absent entirely. + ASSERT_TRUE(delta.contains("append_values")); + EXPECT_FALSE(delta["append_values"].contains("0")); + ASSERT_EQ(delta["append_values"]["1"].size(), 2U); + EXPECT_EQ(delta["append_values"]["1"][0], 42); + EXPECT_TRUE(delta["append_values"]["1"][1].is_null()); +} + +TEST(WidgetDataTest, TypedUpdateCellCarriesValue) { + WidgetData wd; + wd.updateTableCells("tbl", 4, {{0, 1, {int64_t{7}, "7 s"}}, {2, 0, "text-only"}}); + auto j = parse(wd); + const auto& cells = j["tbl"]["table_delta"]["update_cells"]; + ASSERT_EQ(cells.size(), 2U); + ASSERT_EQ(cells[0].size(), 4U); + EXPECT_EQ(cells[0][2], "7 s"); + EXPECT_EQ(cells[0][3], 7); + // A keyless cell stays 3-element: update is a full cell replacement, so no + // fourth element means the cell's sort key is cleared, not preserved. + EXPECT_EQ(cells[1].size(), 3U); +} diff --git a/pj_plugins/dialog_protocol/tests/widget_data_view_test.cpp b/pj_plugins/dialog_protocol/tests/widget_data_view_test.cpp index 7fa41b2..6087e51 100644 --- a/pj_plugins/dialog_protocol/tests/widget_data_view_test.cpp +++ b/pj_plugins/dialog_protocol/tests/widget_data_view_test.cpp @@ -394,7 +394,7 @@ TEST(WidgetDataViewTest, CodeCaretTrackingAbsent) { } // WidgetData -> toJson -> WidgetDataView round trips for the dialog-protocol -// additions in 0.17.0. A key-name mismatch between the setter and the view +// additions (deletable lists, placeholders). A key-name mismatch between the setter and the view // accessor would silently suppress the feature at the plugin boundary. TEST(WidgetDataViewTest, ListDeletableRoundTrip) { PJ::WidgetData wd; @@ -427,7 +427,7 @@ TEST(WidgetDataViewTest, ChartPlaceholderRoundTrip) { TEST(WidgetDataViewTest, TableDeltaRoundTrip) { PJ::WidgetData wd; - wd.appendTableRows("tbl", 9, {{"r1c1", "r1c2"}}); + wd.appendTableRows("tbl", 9, std::vector>{{"r1c1", "r1c2"}}); wd.updateTableCells("tbl", 9, {{2, 0, "upd"}}); wd.removeTableRows("tbl", 9, {1}); PJ::WidgetDataView view(wd.toJson()); @@ -471,3 +471,42 @@ TEST(WidgetDataViewTest, TableDeltaRemoveRowsNormalizedDescendingUnique) { ASSERT_TRUE(delta.has_value()); EXPECT_EQ(delta->remove_rows, (std::vector{7, 5, 2})); } + +TEST(WidgetDataViewTest, TableDeltaTypedRoundTrip) { + PJ::WidgetData wd; + wd.appendTableRows( + "tbl", 6, std::vector>{{PJ::TableItem(uint64_t{1} << 60, "big"), PJ::TableItem("x")}}); + wd.updateTableCells("tbl", 6, {{1, 0, {2.5, "2.5"}}}); + PJ::WidgetDataView view(wd.toJson()); + auto delta = view.tableDelta("tbl"); + ASSERT_TRUE(delta.has_value()); + ASSERT_EQ(delta->append.size(), 1U); + EXPECT_EQ(delta->append[0][0], "big"); + ASSERT_TRUE(delta->append_values.contains(0)); + ASSERT_TRUE(delta->append_values.at(0)[0].has_value()); + EXPECT_EQ(std::get(*delta->append_values.at(0)[0]), uint64_t{1} << 60); + EXPECT_FALSE(delta->append_values.contains(1)); + ASSERT_EQ(delta->update_cells.size(), 1U); + ASSERT_TRUE(delta->update_cells[0].value.has_value()); + EXPECT_EQ(std::get(*delta->update_cells[0].value), 2.5); +} + +TEST(WidgetDataViewTest, TableDeltaMisalignedAppendValuesRejectsWholeDelta) { + PJ::WidgetDataView view(R"({"tbl": {"table_delta": {"seq": 2, "append": [["a"]], "append_values": {"0": [1, 2]}}}})"); + EXPECT_FALSE(view.tableDelta("tbl").has_value()); +} + +TEST(WidgetDataViewTest, TableDeltaBadUpdateValueTypeRejectsWholeDelta) { + PJ::WidgetDataView view(R"({"tbl": {"table_delta": {"seq": 2, "update_cells": [[0, 0, "x", "not-num"]]}}})"); + EXPECT_FALSE(view.tableDelta("tbl").has_value()); +} + +TEST(WidgetDataViewTest, TableDeltaNullUpdateValueMeansKeyless) { + // NaN/Inf keys serialize as JSON null (nlohmann's dump); per TableItem's + // documented semantics such a cell arrives keyless — not a fatal delta. + PJ::WidgetDataView view(R"({"tbl": {"table_delta": {"seq": 2, "update_cells": [[0, 0, "N/A", null]]}}})"); + auto delta = view.tableDelta("tbl"); + ASSERT_TRUE(delta.has_value()); + ASSERT_EQ(delta->update_cells.size(), 1U); + EXPECT_FALSE(delta->update_cells[0].value.has_value()); +} diff --git a/pj_plugins/docs/dialog-plugin-guide.md b/pj_plugins/docs/dialog-plugin-guide.md index e3672e3..c09aaf9 100644 --- a/pj_plugins/docs/dialog-plugin-guide.md +++ b/pj_plugins/docs/dialog-plugin-guide.md @@ -418,11 +418,12 @@ plain-string one) and the host sorts on the numbers. Rows are heterogeneous, as real tables are — strings and numbers side by side: > One overload-resolution edge: a braced literal such as -> `setTableRows("t", {{"a", "b"}})` is ambiguous between the string and -> `TableItem` overloads (a compile error, never a silent behavior change). Name -> the vector — `std::vector> rows = …` — or wrap the -> cells in `PJ::TableItem(...)` to pick a side. Already-compiled plugins are -> unaffected. +> `setTableRows("t", {{"a", "b"}})` — and likewise +> `appendTableRows("t", seq, {{"a", "b"}})` — is ambiguous between the string +> and `TableItem` overloads (a compile error, never a silent behavior change). +> Name the vector — `std::vector> rows = …` — or wrap +> the cells in `PJ::TableItem(...)` to pick a side. Already-compiled plugins +> are unaffected. ```cpp std::string widget_data() override { @@ -798,6 +799,24 @@ appends. A delta with any malformed op is rejected whole (never partially applied). Sending `rows` in the same refresh wins: the host applies the full replace and the delta counts as consumed. +Deltas are sort-key aware, so a typed table stays typed while it streams: + +- `appendTableRows` has a `TableItem` overload — appended rows emit their keys + as a sparse `append_values` column map (the delta-side mirror of + `column_values`), so new rows keep sorting numerically. Appending plain + strings to a typed table demotes those cells to the keyless text rank. +- `updateTableCells` takes `TableCellUpdate{row, col, TableItem}` — each update + replaces the **whole cell**, display text and key together. A keyless item + clears the cell's key; to change text while keeping a numeric key, resend + the key: `{row, col, {value, "new text"}}`. + +```cpp +++delta_seq_; +wd.appendTableRows("faultTable", delta_seq_, + std::vector>{{PJ::TableItem(now_ns, "10:23:07"), "sensor timeout"}}); +wd.updateTableCells("faultTable", delta_seq_, {{2, 1, {duration_ms, "1.2 s"}}}); +``` + > **Host support:** this SDK release ships the protocol (setters + the > `WidgetDataView::tableDelta` decoder); the PlotJuggler host applies deltas > from its companion release onward. Older hosts ignore the `table_delta` key