From 62ceb6927807aca188b2a38324390ddbb2520eb5 Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Tue, 3 Nov 2026 17:16:08 +0100 Subject: [PATCH] session: plots carry row and width; files without row load one cell per row --- src/layout_store.cpp | 15 +++++- src/layout_store.hpp | 7 +++ src/ui_app.hpp | 3 ++ src/ui_layout.cpp | 66 ++++++++++++++++++++++-- src/ui_probe.cpp | 118 ++++++++++++++++++++++++++++++++++++++++++- 5 files changed, 203 insertions(+), 6 deletions(-) diff --git a/src/layout_store.cpp b/src/layout_store.cpp index 480936f..7f19a0a 100644 --- a/src/layout_store.cpp +++ b/src/layout_store.cpp @@ -611,6 +611,15 @@ bool load_layout(const std::string& path, LayoutState& out) { st.rows.back().signals = names; } else if (key == "weight") { st.rows.back().weight = std::strtod(value.c_str(), nullptr); + } else if (key == "row") { + try { + const int r = std::stoi(value); + if (r >= 0) st.rows.back().row = r; + } catch (...) { + } + } else if (key == "width") { + const double w = std::strtod(value.c_str(), nullptr); + if (w > 0.0) st.rows.back().width = w; } } else if (section == Section::Math && !st.math.empty()) { MathChannel& m = st.math.back(); @@ -784,7 +793,11 @@ bool save_layout(const std::string& path, const LayoutState& state) { o << "relative_time = " << (state.relative_time ? "true" : "false") << "\n"; if (state.capture_packets > 0) o << "capture_packets = " << state.capture_packets << "\n"; for (const auto& row : state.rows) { - o << "\n[[plot]]\nweight = " << row.weight << "\nsignals = ["; + o << "\n[[plot]]\n"; + // A row of -1 is a state built without the split (nothing in the app does that now); leaving the + // key out keeps it readable as the one-cell-per-row file it is. + if (row.row >= 0) o << "row = " << row.row << "\nwidth = " << row.width << "\n"; + o << "weight = " << row.weight << "\nsignals = ["; for (std::size_t i = 0; i < row.signals.size(); ++i) { if (i != 0) o << ", "; o << toml_escape(row.signals[i]); diff --git a/src/layout_store.hpp b/src/layout_store.hpp index ad6320e..0521429 100644 --- a/src/layout_store.hpp +++ b/src/layout_store.hpp @@ -24,6 +24,13 @@ struct LayoutState { /// The row's share of the figure's height, 1 for equal rows. Part of the layout because a sizing a /// user dragged is an arrangement, and an arrangement that is not saved is one that is lost. double weight = 1.0; + /// The figure row this plot sits in; plots of one row are the cells of a horizontal split, in + /// order. -1 means the file had no `row` key (a session from before the split), which loads as + /// one cell per row. + int row = -1; + /// This cell's share of its row's width, 1 for equal cells. `weight` is the *row's* and is + /// written on every cell of the row; `width` is the cell's own. + double width = 1.0; }; double window_s = 0.0; ///< 0 keeps the default window diff --git a/src/ui_app.hpp b/src/ui_app.hpp index 319c085..9592f17 100644 --- a/src/ui_app.hpp +++ b/src/ui_app.hpp @@ -223,6 +223,9 @@ struct App { int dbg_layout_step = 0; /// Which step of the split probe runs next (`UDPSCOPE_PROBE_SPLIT_MS`). int dbg_split_step = 0; + /// The split probe's round trip: the arrangement saved, and the legacy copy of its file. + std::string dbg_split_expect; + std::string dbg_split_legacy_path; /// Which step of the cursors probe runs next (`UDPSCOPE_PROBE_CURSORS_MS`). int dbg_cursors_step = 0; /// Which step of the fit probe runs next (`UDPSCOPE_PROBE_FIT_MS`). diff --git a/src/ui_layout.cpp b/src/ui_layout.cpp index 3b8b665..df73682 100644 --- a/src/ui_layout.cpp +++ b/src/ui_layout.cpp @@ -30,8 +30,16 @@ LayoutState collect_layout(App& app) { row.signals.push_back(r.layer[si] > 0 ? std::string() : r.signal_name[si]); } + // The figure row's own weight and this cell's share of the row's width. + const int fr = app.rows->figure_row_of(i); const std::vector& weights = app.rows->row_weights(); - if (static_cast(i) < weights.size()) row.weight = weights[static_cast(i)]; + row.row = fr; + if (fr >= 0 && static_cast(fr) < weights.size()) { + row.weight = weights[static_cast(fr)]; + } + const std::vector& cw = app.rows->cell_weights(fr); + const int cell = app.rows->cell_of(i); + if (cell >= 0 && static_cast(cell) < cw.size()) row.width = cw[static_cast(cell)]; st.rows.push_back(std::move(row)); } for (std::size_t si = 0; si < model.signals().size(); ++si) { @@ -444,17 +452,67 @@ void apply_layout(App& app, const LayoutState& st) { int rows = static_cast(st.rows.size()); if (rows > RowSet::kMaxRows) rows = RowSet::kMaxRows; - if (rows > 0) app.rows->set_row_count(rows); + + // The arrangement. A file that names a figure row for every plot (`row = ...`) carries a horizontal + // split; one that names none -- every session written before the split -- is one cell per row, which + // is what it was. A `row` sequence that cannot be a layout (hand-edited, truncated, from a build + // with other limits) is not a reason to refuse the file: it loads as the old shape, with the signals + // where they were. + std::vector cells_per_row; + { + bool all_rows = !st.rows.empty() && st.rows.size() <= static_cast(RowSet::kMaxRows); + int prev = -1; + for (const LayoutState::Row& r : st.rows) { + if (!all_rows) break; + if (r.row < 0 || r.row < prev || r.row > prev + 1 || (prev < 0 && r.row != 0)) { + all_rows = false; + } else if (r.row != prev) { + cells_per_row.push_back(1); + } else if (++cells_per_row.back() > RowSet::kMaxCellsPerRow) { + all_rows = false; + } + prev = r.row; + } + if (!all_rows) cells_per_row.clear(); + } + const bool split_file = !cells_per_row.empty(); + if (split_file) { + app.rows->set_cells_per_row(cells_per_row); + } else if (rows > 0) { + app.rows->set_row_count(rows); + } // The rows' shares of the height come from the same file as their signals: a sizing a user dragged is // an arrangement, and the layout is where an arrangement lives. Absent from an older file, the weights - // are all 1 -- equal rows -- which is what such a file was saved with. + // are all 1 -- equal rows -- which is what such a file was saved with. The weight is the *figure row's* + // and is written on every cell of it; the row's first cell is the one read. { std::vector weights; weights.reserve(st.rows.size()); - for (const LayoutState::Row& wr : st.rows) weights.push_back(static_cast(wr.weight)); + if (split_file) { + std::size_t at = 0; + for (const int n : cells_per_row) { + weights.push_back(static_cast(st.rows[at].weight)); + at += static_cast(n); + } + } else { + for (const LayoutState::Row& wr : st.rows) weights.push_back(static_cast(wr.weight)); + } app.rows->set_row_weights(std::move(weights)); } + // The cells' shares of a split row's width, after the arrangement (which resets them). + if (split_file) { + std::size_t at = 0; + for (std::size_t fr = 0; fr < cells_per_row.size(); ++fr) { + const std::size_t n = static_cast(cells_per_row[fr]); + if (n >= 2) { + std::vector widths; + for (std::size_t c = 0; c < n; ++c) widths.push_back(static_cast(st.rows[at + c].width)); + app.rows->set_cell_weights(static_cast(fr), std::move(widths)); + } + at += n; + } + } for (int i = 0; i < rows; ++i) { int slot = 0; for (const std::string& name : st.rows[static_cast(i)].signals) { diff --git a/src/ui_probe.cpp b/src/ui_probe.cpp index 9f9a616..4b040a9 100644 --- a/src/ui_probe.cpp +++ b/src/ui_probe.cpp @@ -18,6 +18,7 @@ #include #include #include +#include #include namespace udpscope { @@ -3900,6 +3901,34 @@ void split_probe_report(App& a, const char* tag) { } namespace { +/// The arrangement and what is in it, as one comparable string: per figure row its weight, per cell +/// its width share and its traces. Weights are read back as stored, so a load is compared with a save +/// to the precision the file keeps (`%.4f`). +std::string split_probe_shape(const RowSet& rs) { + std::string out; + char buf[48]; + for (int fr = 0; fr < rs.figure_row_count(); ++fr) { + const std::vector& rw = rs.row_weights(); + const std::vector& cw = rs.cell_weights(fr); + std::snprintf(buf, sizeof(buf), "%sr%d(h=%.4f)", fr == 0 ? "" : " ", fr, + static_cast(fr) < rw.size() ? static_cast(rw[static_cast(fr)]) : 1.0); + out += buf; + const int first = rs.first_plot_of_row(fr); + for (int k = 0; k < rs.cells_in_row(fr); ++k) { + std::snprintf(buf, sizeof(buf), "[w=%.4f:", static_cast(k) < cw.size() ? static_cast(cw[static_cast(k)]) : 1.0); + out += buf; + bool any = false; + for (const std::string& n : rs.row(first + k).signal_name) { + if (n.empty()) continue; + out += (any ? "," : "") + n; + any = true; + } + out += any ? "]" : "-]"; + } + } + return out; +} + /// After any structural change: the new figure is the area's, and the frame tick re-lays it out. void split_probe_restructured(App& a) { if (a.area != nullptr) a.area->set_figure(&a.rows->figure()); @@ -4002,8 +4031,95 @@ gboolean split_probe_step(gpointer user) { split_probe_restructured(*a); split_probe_next(*a); break; - default: + case 6: { split_probe_report(*a, "remove"); + // The session round trip, through the real entry points. Plot 0 alone on row 0, plots 1 and 2 side + // by side on row 1, each cell holding a different trace, row 0 at twice row 1's height and the + // split row at 3:1. + rs.set_cells_per_row({1, 2}); + split_probe_clear_all(*a); + for (int p = 0; p < 3; ++p) split_probe_assign(*a, p, static_cast(p)); + rs.set_row_weights({2.0f, 1.0f}); + rs.set_cell_weights(1, {3.0f, 1.0f}); + a->dbg_split_expect = split_probe_shape(rs); + const LayoutState saved = collect_layout(*a); + const std::string path = std::string(g_get_tmp_dir()) + "/udpscope-split-probe.toml"; + const bool wrote = save_layout(path, saved); + // Back to the plain arrangement, so a load that did nothing cannot pass. + rs.set_cells_per_row({1}); + split_probe_clear_all(*a); + LayoutState loaded; + const bool read = load_layout(path, loaded); + // The same file with its `row`/`width` lines stripped: a session from before the split. + std::string legacy_path = path + ".legacy"; + { + std::ifstream in(path); + std::ofstream out(legacy_path); + std::string line; + while (std::getline(in, line)) { + if (line.rfind("row = ", 0) == 0 || line.rfind("width = ", 0) == 0) continue; + out << line << "\n"; + } + } + a->dbg_split_legacy_path = legacy_path; + std::remove(path.c_str()); + if (read) apply_layout(*a, loaded); + std::fprintf(stderr, "[split-probe] roundtrip-io wrote=%d read=%d plots_in_file=%zu\n", + wrote ? 1 : 0, read ? 1 : 0, loaded.rows.size()); + split_probe_restructured(*a); + split_probe_next(*a); + break; + } + case 7: { + split_probe_report(*a, "roundtrip"); + const std::string now = split_probe_shape(rs); + std::fprintf(stderr, "[split-probe] roundtrip rt=%d expect=%s got=%s\n", + now == a->dbg_split_expect ? 1 : 0, a->dbg_split_expect.c_str(), now.c_str()); + rs.set_cells_per_row({1}); + split_probe_clear_all(*a); + LayoutState loaded; + const bool read = load_layout(a->dbg_split_legacy_path, loaded); + std::remove(a->dbg_split_legacy_path.c_str()); + int rows_unset = 1; + for (const LayoutState::Row& r : loaded.rows) { + if (r.row != -1) rows_unset = 0; + } + if (read) apply_layout(*a, loaded); + std::fprintf(stderr, "[split-probe] legacy-io read=%d plots_in_file=%zu row_unset=%d\n", + read ? 1 : 0, loaded.rows.size(), rows_unset); + split_probe_restructured(*a); + split_probe_next(*a); + break; + } + case 8: { + split_probe_report(*a, "legacy"); + // A `row` sequence that cannot be a layout loads as one cell per row rather than refusing the + // file: five cells in one row (over the cap), a gap, a row that goes back, and one that does not + // start at 0. + const LayoutState base = collect_layout(*a); + const std::vector> bad = {{0, 0, 0, 0, 0}, {0, 2, 2}, {0, 1, 0}, {1, 1, 2}}; + std::string out; + int ok = 1; + for (const std::vector& seq : bad) { + LayoutState st = base; + st.rows.clear(); + for (const int r : seq) { + LayoutState::Row row = base.rows[0]; + row.row = r; + st.rows.push_back(row); + } + apply_layout(*a, st); + const bool one_each = rs.figure_row_count() == static_cast(seq.size()) && + rs.row_count() == static_cast(seq.size()); + if (!one_each) ok = 0; + out += (out.empty() ? "" : " ") + std::to_string(rs.figure_row_count()) + "/" + + std::to_string(rs.row_count()); + } + std::fprintf(stderr, "[split-probe] invalid-rows ok=%d frows/plots=[%s]\n", ok, out.c_str()); + split_probe_restructured(*a); + break; + } + default: break; } return G_SOURCE_REMOVE;