Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,15 +41,23 @@ 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)

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.
Expand Down
4 changes: 2 additions & 2 deletions docs/dialog-sdk-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,8 +80,8 @@ For the full tutorial, see [dialog-plugin-guide.md](../pj_plugins/docs/dialog-pl
| `setSelectedRows(name, vector<int>)` | Set selected row indices |
| `setDisabledRows(name, vector<int>)` | 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<TableCellUpdate>)` | 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<TableCellUpdate>)` | 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<int>)` | Delta: remove plugin-space row indexes |

> A table must not combine `sortingEnabled=true` in its `.ui` with
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -150,17 +150,7 @@ class WidgetDataView {
std::vector<std::optional<NumericValue>> 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<uint64_t>()));
} else if (v.is_number_integer()) {
decoded.emplace_back(NumericValue(v.get<int64_t>()));
} else if (v.is_number_float()) {
decoded.emplace_back(NumericValue(v.get<double>()));
} else {
decoded.emplace_back(std::nullopt);
}
decoded.emplace_back(numericFromJson(v));
}
result.emplace(*col, std::move(decoded));
}
Expand Down Expand Up @@ -203,10 +193,17 @@ class WidgetDataView {
struct TableDeltaView {
std::uint64_t seq = 0;
std::vector<std::vector<std::string>> 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<int, std::vector<std::optional<NumericValue>>> 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<NumericValue> value;
};
std::vector<CellUpdate> update_cells;
std::vector<int> remove_rows;
Expand Down Expand Up @@ -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<int>(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<std::optional<NumericValue>> 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<int>() < 0 ||
update[1].get<int>() < 0) {
return std::nullopt;
}
delta.update_cells.push_back({update[0].get<int>(), update[1].get<int>(), update[2].get<std::string>()});
TableDeltaView::CellUpdate cell{
update[0].get<int>(), update[1].get<int>(), update[2].get<std::string>(), 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()) {
Expand Down Expand Up @@ -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<NumericValue> numericFromJson(const nlohmann::json& v) {
if (v.is_number_unsigned()) {
return NumericValue(v.get<uint64_t>());
}
if (v.is_number_integer()) {
return NumericValue(v.get<int64_t>());
}
if (v.is_number_float()) {
return NumericValue(v.get<double>());
}
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()) {
Expand Down
126 changes: 83 additions & 43 deletions pj_plugins/dialog_protocol/include/pj_plugins/sdk/widget_data.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<std::vector<TableItem>>& 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 {
Expand Down Expand Up @@ -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<std::vector<std::string>>& 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<std::vector<TableItem>>& 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<TableCellUpdate>& 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;
Expand Down Expand Up @@ -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<std::vector<TableItem>>& 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
39 changes: 37 additions & 2 deletions pj_plugins/dialog_protocol/tests/widget_data_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
#include <string>
#include <vector>

using PJ::TableItem;
using PJ::WidgetData;
using json = nlohmann::json;

Expand Down Expand Up @@ -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<std::vector<std::string>>{{"a", "b"}, {"c", "d"}});
wd.updateTableCells("tbl", 7, {{0, 1, "x"}});
wd.removeTableRows("tbl", 7, {3, 5});
auto j = parse(wd);
Expand All @@ -460,7 +461,7 @@ TEST(WidgetDataTest, TableDeltaOpsSerializeUnderOneKey) {

TEST(WidgetDataTest, TableDeltaDifferingSeqStartsFreshDelta) {
WidgetData wd;
wd.appendTableRows("tbl", 1, {{"old"}});
wd.appendTableRows("tbl", 1, std::vector<std::vector<std::string>>{{"old"}});
wd.updateTableCells("tbl", 2, {{0, 0, "new"}});
auto j = parse(wd);
const auto& delta = j["tbl"]["table_delta"];
Expand All @@ -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<std::vector<TableItem>>{
{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);
}
Loading
Loading