plot host: split rows share y, rulers per row

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Martino FerrariandClaude Opus 4.6 committed 2026-11-04 00:55:59 +01:00
1 parent 135310ceca
commit 5f0bc75c27
5 files changed
+163 -13

No files matched your search

+4 -2
View File
@@ -2857,7 +2857,8 @@ inside the panel does not close it), `click_outside_closes=1` (a click on the pl
[split-probe] weights 3:1 plots=2 frows=1 cells=[2] widths=[533,220] tile=1 x_equal=1 cursors_aligned=1 weights=[3,1] traces=[1a:sine|1b:square] [split-probe] weights 3:1 plots=2 frows=1 cells=[2] widths=[533,220] tile=1 x_equal=1 cursors_aligned=1 weights=[3,1] traces=[1a:sine|1b:square]
[split-probe] caps-calls insert_cell=[1,1,0] refused_cell=1 insert_row=[1,1,0] refused_row=1 plots=8 [split-probe] caps-calls insert_cell=[1,1,0] refused_cell=1 insert_row=[1,1,0] refused_row=1 plots=8
[split-probe] roundtrip rt=1 expect=r0(h=2.0000)[w=1.0000:sine] r1(h=1.0000)[w=3.0000:square][w=1.0000:ramp] got=r0(h=2.0000)[w=1.0000:sine] r1(h=1.0000)[w=3.0000:square][w=1.0000:ramp] [split-probe] roundtrip rt=1 expect=r0(h=2.0000)[w=1.0000:sine] r1(h=1.0000)[w=3.0000:square][w=1.0000:ramp] got=r0(h=2.0000)[w=1.0000:sine] r1(h=1.0000)[w=3.0000:square][w=1.0000:ramp]
[split-probe] ruler-labels split=[plot 1a,plot 1b,plot 2a,plot 2b] mixed=[row 0,plot 2a,plot 2b] [split-probe] ruler-labels split=[row 1,row 2] mixed=[row 0,row 2]
[split-probe] shared-y y_shared=1 y_gutter=1 y_fit=1 ruler_mirror=1 ranges=[A=-3.000000..7.000000 B=-3.000000..7.000000 | fit A=-1.04..1.04 B=-1.04..1.04 data=-1..1] axes_w=[642.4,642.4] auto=1
``` ```
`UDPSCOPE_PROBE_SPLIT_MS` (stderr; see [verification.md](verification.md)). Nineteen steps, 250 ms apart; `UDPSCOPE_PROBE_SPLIT_MS` (stderr; see [verification.md](verification.md)). Nineteen steps, 250 ms apart;
@@ -2903,7 +2904,8 @@ The other lines, in order:
| `plot-on-buttons` | `[1a:row 1, cell 1 of 2\|1b:row 1, cell 2 of 2\|2a:row 2, cell 1 of 2\|2b:row 2, cell 2 of 2]` | a signals-page button labelled with the flat index, or without the cell tooltip; a one-cell row keeps its plain label and the tooltip "draw this signal on this plot" | | `plot-on-buttons` | `[1a:row 1, cell 1 of 2\|1b:row 1, cell 2 of 2\|2a:row 2, cell 1 of 2\|2b:row 2, cell 2 of 2]` | a signals-page button labelled with the flat index, or without the cell tooltip; a one-cell row keeps its plain label and the tooltip "draw this signal on this plot" |
| `assign-1b` / `drop-2b` | `assigned=1 notice='plotting 'sine' on plot 1b'`; `dropped=1 notice='plotting 'square' on plot 2b'` | a notice that names the plot by number alone | | `assign-1b` / `drop-2b` | `assigned=1 notice='plotting 'sine' on plot 1b'`; `dropped=1 notice='plotting 'square' on plot 2b'` | a notice that names the plot by number alone |
| `after-assign` (state line) | `traces=[1a:-\|1b:sine\|2a:-\|2b:square]` | the signal went to a neighbouring cell | | `after-assign` (state line) | `traces=[1a:-\|1b:sine\|2a:-\|2b:square]` | the signal went to a neighbouring cell |
| `ruler-labels` | `split=[plot 1a,plot 1b,plot 2a,plot 2b]` and, with cells `{1,2}`, `mixed=[row 0,plot 2a,plot 2b]` | the cursors pane's per-plot ruler line (a label read through the pane's own widgets): a split row's plots are `plot <label>`, an unsplit row keeps `row <plot index>` (**0-based**, as it always was -- `row 0` is the first plot, not a defect) | | `ruler-labels` | `split=[row 1,row 2]` and, with cells `{1,2}`, `mixed=[row 0,row 2]` | the cursors pane's ruler lines (a label read through the pane's own widgets): **one line per figure row**, because the cells of a split row share their y axis and so their rulers. A split row is `row <figure row, from 1>` and sits on its first cell; an unsplit row keeps `row <plot index>` (**0-based**, as it always was -- `row 0` is the first plot, not a defect). One line per cell (`plot 1a,plot 1b,...`) is the pre-sharing reading |
| `shared-y` | `y_shared=1 y_gutter=1 y_fit=1 ruler_mirror=1`, `ranges=[A=-3..7 B=-3..7 | fit A=B]`, `axes_w` equal to within a pixel, `auto=1` | a row of two cells with a trace each. `y_shared`: a `set_y_range(-3, 7)` on cell A is B's after one frame. `y_gutter`: only cell A has a y gutter (B's `y_axis_width` and `y_label_area.w` are 0) and the two plots are equally wide. `y_fit`: `fit_all()` leaves both cells on one range that covers both traces (not the manual one). `ruler_mirror`: a ruler written on A and read back by `sync_cursors(0)` is B's too. A 0 in the first two is `set_share_row_y(true)` not reaching the figure; in `y_fit`, no frame between `fit_all()` and the read (the probe restructures to pump one) or a group that did not follow the flag |
The run ends with the usual `gtk warnings: 0` and no `Gtk-CRITICAL`. `x_equal=1` and `cursors_aligned=1` on every state line are The run ends with the usual `gtk warnings: 0` and no `Gtk-CRITICAL`. `x_equal=1` and `cursors_aligned=1` on every state line are
the only readings of the shared time window across cells. the only readings of the shared time window across cells.
+3 -3
View File
@@ -205,15 +205,15 @@ precedent, not an exception.
with, so a synchronous sequence would read every position one action stale. It clicks by with, so a synchronous sequence would read every position one action stale. It clicks by
emitting `::clicked`, never `gtk_widget_activate()`, which holds a `GtkButton` for its emitting `::clicked`, never `gtk_widget_activate()`, which holds a `GtkButton` for its
250 ms `ACTIVATE_TIMEOUT` and then emits into a button the rebuild has already destroyed. 250 ms `ACTIVATE_TIMEOUT` and then emits into a button the rebuild has already destroyed.
- `UDPSCOPE_PROBE_SPLIT_MS` — drive the horizontal plot split, 19 steps 250 ms apart (so a run needs - `UDPSCOPE_PROBE_SPLIT_MS` — drive the horizontal plot split, 23 steps 250 ms apart (so a run needs
`UDPSCOPE_PROBE_SPLIT_MS=1500 UDPSCOPE_EXIT_AFTER_MS=7500` at least; it enables and assigns the `UDPSCOPE_PROBE_SPLIT_MS=1500 UDPSCOPE_EXIT_AFTER_MS=9500` at least; it enables and assigns the
signals itself, so `UDPSCOPE_ENABLE_ALL_MS` is not needed). Each step restructures the cells, signals itself, so `UDPSCOPE_ENABLE_ALL_MS` is not needed). Each step restructures the cells,
re-points the area at the new figure and lets the *next* step read a frame that was actually laid out re-points the area at the new figure and lets the *next* step read a frame that was actually laid out
with them, which is why every report line describes the previous step's change. It splits and with them, which is why every report line describes the previous step's change. It splits and
weights a row, fills the caps, round-trips a session through `collect_layout()` / `save_layout()` / weights a row, fills the caps, round-trips a session through `collect_layout()` / `save_layout()` /
`load_layout()` / `apply_layout()` (and a copy with its `row`/`width` lines stripped), clicks the layout `load_layout()` / `apply_layout()` (and a copy with its `row`/`width` lines stripped), clicks the layout
editor's real cell `+` button, drives the column drag handler, and checks the signals page, the drop's editor's real cell `+` button, drives the column drag handler, and checks the signals page, the drop's
`row_at()` and the cursors pane's ruler labels. Run it once, serially, on its own mock port, from a `row_at()`, the cursors pane's ruler labels and the shared y axis of a split row (`shared-y`). Run it once, serially, on its own mock port, from a
fresh `XDG_CONFIG_HOME` (the probe deletes the temp files `udpscope-split-probe.toml` and `.legacy` it fresh `XDG_CONFIG_HOME` (the probe deletes the temp files `udpscope-split-probe.toml` and `.legacy` it
writes under `g_get_tmp_dir()`, but the editor's clicks schedule a real session save). Read it against writes under `g_get_tmp_dir()`, but the editor's clicks schedule a real session save). Read it against
the `[split-probe]` section of [probe-readings.md](probe-readings.md); the run must still end with the `[split-probe]` section of [probe-readings.md](probe-readings.md); the run must still end with
+30
View File
@@ -289,6 +289,13 @@ bool RowSet::insert_cell(int fr, int at) {
const int p = first_plot_of_row(fr) + k; const int p = first_plot_of_row(fr) + k;
for (int i = rows_; i > p; --i) carry_row(row(i), row(i - 1)); for (int i = rows_; i > p; --i) carry_row(row(i), row(i - 1));
clear_row(row(p)); clear_row(row(p));
// The row's rulers belong to the new cell as well (they are one pair for the whole row): taken from
// a neighbour that is not the new cell, which sits at `p` and so cannot be its own source.
{
const Row& src = row(k == 0 ? p + 1 : first_plot_of_row(fr));
row(p).ruler_y = src.ruler_y;
row(p).ruler_b_y = src.ruler_b_y;
}
++rows_; ++rows_;
++cells_per_row_[static_cast<std::size_t>(fr)]; ++cells_per_row_[static_cast<std::size_t>(fr)];
auto& w = cell_weights_[static_cast<std::size_t>(fr)]; auto& w = cell_weights_[static_cast<std::size_t>(fr)];
@@ -469,6 +476,10 @@ void RowSet::rebuild_all() {
figure_ = std::make_unique<plot::Figure>(std::span<const plot::u32>(cells)); figure_ = std::make_unique<plot::Figure>(std::span<const plot::u32>(cells));
figure_->set_row_gap(kRowGap); figure_->set_row_gap(kRowGap);
figure_->set_col_gap(kColGap); figure_->set_col_gap(kColGap);
// The cells of a split row are one plot with a vertical divider, not unrelated plots: they share
// one y range and auto state, and only the first draws the y axis. Before `install()`, which adds
// the axes the figure enrols; a row of one cell is never a group, so nothing changes for it.
figure_->set_share_row_y(true);
apply_row_weights(); apply_row_weights();
apply_cell_weights(); apply_cell_weights();
x_group_ = figure_->add_x_link(); x_group_ = figure_->add_x_link();
@@ -480,6 +491,13 @@ void RowSet::rebuild_all() {
continue; continue;
} }
install(r, i, figure_->plot(static_cast<plot::u32>(i))); install(r, i, figure_->plot(static_cast<plot::u32>(i)));
// A ruler is the row's (its y axis is shared), so every cell of a split row holds the first
// cell's values -- including a cell a restructure put next to one it had nothing to do with.
if (const int fr = figure_row_of(i); fr >= 0 && cell_of(i) > 0) {
const Row& first = row(first_plot_of_row(fr));
r.ruler_y = first.ruler_y;
r.ruler_b_y = first.ruler_b_y;
}
place_cursors(r); place_cursors(r);
if (trigger_marker_on_) { if (trigger_marker_on_) {
r.plot->set_cursor_x(r.trigger_marker, trigger_marker_x_, plot::CursorPlacement::Free); r.plot->set_cursor_x(r.trigger_marker, trigger_marker_x_, plot::CursorPlacement::Free);
@@ -1250,6 +1268,18 @@ void RowSet::sync_cursors(int drag_row) {
const double yb = r.plot->cursor(r.ruler_b).y; const double yb = r.plot->cursor(r.ruler_b).y;
if (y > kParked * 0.5) r.ruler_y = y; if (y > kParked * 0.5) r.ruler_y = y;
if (yb > kParked * 0.5) r.ruler_b_y = yb; if (yb > kParked * 0.5) r.ruler_b_y = yb;
// The row's cells share one y axis, so the dragged ruler is the row's: every other cell of the
// figure row takes the values and draws them (`Free`: the dragged cell's own value just came
// out of its plot and must not be snapped).
const int fr = figure_row_of(drag_row);
const int first = first_plot_of_row(fr);
for (int i = first; i < first + cells_in_row(fr); ++i) {
if (i == drag_row) continue;
Row& mate = row(i);
mate.ruler_y = r.ruler_y;
mate.ruler_b_y = r.ruler_b_y;
place_cursors(mate, plot::CursorPlacement::Free);
}
} }
bool push = false; bool push = false;
if (cursor_mode_ != CursorMode::Horizontal) { if (cursor_mode_ != CursorMode::Horizontal) {
+15 -5
View File
@@ -1752,7 +1752,8 @@ GtkWidget* cursors_header(App& app) {
/// The unit a row's y rulers are in: the one its bound channels agree on, or none when they /// The unit a row's y rulers are in: the one its bound channels agree on, or none when they
/// disagree -- a ruler sits on the row's y axis, and two signals of different units on one /// disagree -- a ruler sits on the row's y axis, and two signals of different units on one
/// axis make "the" unit a guess. /// axis make "the" unit a guess. The cells of a split row share that axis, so the channels of
/// every cell of `row`'s figure row count.
/// ///
/// The channel list is rebuilt by the producer, so it is read under the model mutex, like /// The channel list is rebuilt by the producer, so it is read under the model mutex, like
/// every other read of it from this thread. /// every other read of it from this thread.
@@ -1760,9 +1761,13 @@ std::string row_unit(App& app, int row) {
std::lock_guard<std::mutex> lock(app.model->mutex()); std::lock_guard<std::mutex> lock(app.model->mutex());
std::string unit; std::string unit;
bool first = true; bool first = true;
const int fr = app.rows->figure_row_of(row);
const int begin = fr >= 0 ? app.rows->first_plot_of_row(fr) : row;
const int end = fr >= 0 ? begin + app.rows->cells_in_row(fr) : row + 1;
for (int p = begin; p < end; ++p) {
const RowSet::Row& r = app.rows->row(p);
for (int s = 0; s < RowSet::kSlotsPerRow; ++s) { for (int s = 0; s < RowSet::kSlotsPerRow; ++s) {
const std::size_t si = static_cast<std::size_t>(s); const std::size_t si = static_cast<std::size_t>(s);
const RowSet::Row& r = app.rows->row(row);
if (r.signal_name[si].empty()) continue; if (r.signal_name[si].empty()) continue;
const int ch = r.channel[si]; const int ch = r.channel[si];
if (ch < 0 || static_cast<std::size_t>(ch) >= app.model->channels().size()) continue; if (ch < 0 || static_cast<std::size_t>(ch) >= app.model->channels().size()) continue;
@@ -1774,6 +1779,7 @@ std::string row_unit(App& app, int row) {
return ""; return "";
} }
} }
}
return unit; return unit;
} }
@@ -1840,10 +1846,14 @@ void rebuild_cursors_pane(App& app) {
for (int r = 0; r < app.rows->row_count(); ++r) { for (int r = 0; r < app.rows->row_count(); ++r) {
if (!rulers) break; if (!rulers) break;
char who[64]; char who[64];
// A row split into cells names its plots the way the rest of the UI does ("plot 2a"); an const int fr = app.rows->figure_row_of(r);
const bool split = app.rows->cells_in_row(fr) > 1;
// A ruler pair belongs to the figure row (its cells share one y axis), so a split row gets one
// line, on its first cell, named by the row's number -- the one `plot_label()` leads with. An
// unsplit row keeps the wording it always had. // unsplit row keeps the wording it always had.
if (app.rows->cells_in_row(app.rows->figure_row_of(r)) > 1) { if (split && app.rows->cell_of(r) != 0) continue;
std::snprintf(who, sizeof(who), "plot %s", app.rows->plot_label(r).c_str()); if (split) {
std::snprintf(who, sizeof(who), "row %d", fr + 1);
} else { } else {
std::snprintf(who, sizeof(who), "row %d", r); std::snprintf(who, sizeof(who), "row %d", r);
} }
+111 -3
View File
@@ -3974,6 +3974,18 @@ void split_probe_assign(App& a, int p, std::size_t c) {
a.rows->assign(p, 0, ch.name, ch.name, signal_color_index(a.model->signals(), ch.signal)); a.rows->assign(p, 0, ch.name, ch.name, signal_color_index(a.model->signals(), ch.signal));
} }
/// What the shared-y steps carry from the step that measures to the step that prints.
struct SharedYProbe {
int y_shared = 0;
int y_gutter = 0;
int ruler_mirror = 0;
std::string ranges;
std::string axes_w;
RowSet::CursorMode was_mode = RowSet::CursorMode::Mix;
bool was_on = false;
};
SharedYProbe g_shared_y;
void split_probe_clear_all(App& a) { void split_probe_clear_all(App& a) {
for (int r = 0; r < RowSet::kMaxRows; ++r) { for (int r = 0; r < RowSet::kMaxRows; ++r) {
for (int s = 0; s < RowSet::kSlotsPerRow; ++s) a.rows->assign(r, s, "", "", 0); for (int s = 0; s < RowSet::kSlotsPerRow; ++s) a.rows->assign(r, s, "", "", 0);
@@ -4393,9 +4405,10 @@ gboolean split_probe_step(gpointer user) {
} }
case 18: { case 18: {
split_probe_report(*a, "after-assign"); split_probe_report(*a, "after-assign");
// The cursors pane's ruler labels (`rebuild_cursors_pane()`, ruler modes): a plot of a split row is // The cursors pane's ruler labels (`rebuild_cursors_pane()`, ruler modes): one line per figure row,
// "plot <label>", a plot of an unsplit row keeps "row <index>". Both arrangements are read off the // since a split row's cells share their y axis and their rulers. A split row is "row <figure row,
// pane's own label widgets, the all-split one first and a mixed one second. // from 1>" (one line, not one per cell); an unsplit row keeps "row <index>". Both arrangements are
// read off the pane's own label widgets, the all-split one first and a mixed one second.
const RowSet::CursorMode was_mode = rs.cursor_mode(); const RowSet::CursorMode was_mode = rs.cursor_mode();
rs.set_cursor_mode(RowSet::CursorMode::Both); rs.set_cursor_mode(RowSet::CursorMode::Both);
const auto ruler_labels = [&]() { const auto ruler_labels = [&]() {
@@ -4415,6 +4428,101 @@ gboolean split_probe_step(gpointer user) {
mixed_labels.c_str()); mixed_labels.c_str());
rs.set_cursor_mode(was_mode); rs.set_cursor_mode(was_mode);
rebuild_cursors_pane(*a); rebuild_cursors_pane(*a);
split_probe_next(*a);
break;
}
// --- Steps 19..21: a split row shares one y axis ---
case 19: {
// One figure row of two cells with a different trace in each (sine, ramp), rulers on. The next
// step sets a range on cell A against a frame that was laid out with this arrangement.
g_shared_y.was_mode = rs.cursor_mode();
g_shared_y.was_on = rs.cursors_visible();
rs.set_cells_per_row({2});
split_probe_clear_all(*a);
if (a->model->channel_count() >= 3) {
split_probe_assign(*a, 0, 0);
split_probe_assign(*a, 1, 2);
}
rs.set_cursor_mode(RowSet::CursorMode::Both);
rs.set_cursors_visible(true);
rs.reset_cursors();
split_probe_restructured(*a);
split_probe_next(*a);
break;
}
case 20: {
// A range set on cell A; the frame between this step and the next is the row's next layout.
rs.set_y_range(0, -3.0, 7.0);
split_probe_restructured(*a);
split_probe_next(*a);
break;
}
case 21: {
const RowSet::Row& ra = rs.row(0);
const RowSet::Row& rb = rs.row(1);
if (ra.plot == nullptr || rb.plot == nullptr) {
std::fprintf(stderr, "[split-probe] shared-y no plots\n");
break;
}
// 1. The range set on A is B's.
const plot::Range a_rng = ra.plot->y_range(ra.axis_y);
const plot::Range b_rng = rb.plot->y_range(rb.axis_y);
const int y_shared = (std::fabs(b_rng.min + 3.0) < 1e-9 && std::fabs(b_rng.max - 7.0) < 1e-9 &&
std::fabs(a_rng.min + 3.0) < 1e-9 && std::fabs(a_rng.max - 7.0) < 1e-9)
? 1 : 0;
// 2. Only the first cell has a gutter, and the two plots are the same width.
const plot::PlotLayout& la = ra.plot->layout();
const plot::PlotLayout& lb = rb.plot->layout();
const int y_gutter = (la.y_axis_width > 0.0f && lb.y_axis_width == 0.0f && lb.y_label_area.w == 0.0f &&
std::fabs(la.axes.w - lb.axes.w) <= 1.0f)
? 1 : 0;
const std::string ranges_before =
"A=" + std::to_string(a_rng.min) + ".." + std::to_string(a_rng.max) +
" B=" + std::to_string(b_rng.min) + ".." + std::to_string(b_rng.max);
const std::string axes_w = std::to_string(la.axes.w) + "," + std::to_string(lb.axes.w);
// 4. A ruler written on A's plot and read back by sync_cursors() is B's too.
ra.plot->set_cursor_y(ra.ruler, 1.25, plot::CursorPlacement::Free);
ra.plot->set_cursor_y(ra.ruler_b, 4.5, plot::CursorPlacement::Free);
rs.sync_cursors(0);
const int ruler_mirror = (rs.ruler_y(0) == 1.25 && rs.ruler_y(1) == 1.25 && rs.ruler_b_y(0) == 4.5 &&
rs.ruler_b_y(1) == 4.5 && rb.plot->cursor(rb.ruler).y == 1.25 &&
rb.plot->cursor(rb.ruler_b).y == 4.5)
? 1 : 0;
g_shared_y.y_shared = y_shared;
g_shared_y.y_gutter = y_gutter;
g_shared_y.ruler_mirror = ruler_mirror;
g_shared_y.ranges = ranges_before;
g_shared_y.axes_w = axes_w;
// 3. Fit all: the next frame's layout fits the union of both cells' traces.
rs.fit_all();
split_probe_restructured(*a);
split_probe_next(*a);
break;
}
case 22: {
const RowSet::Row& ra = rs.row(0);
const RowSet::Row& rb = rs.row(1);
if (ra.plot == nullptr || rb.plot == nullptr) break;
const plot::Range a_rng = ra.plot->y_range(ra.axis_y);
const plot::Range b_rng = rb.plot->y_range(rb.axis_y);
plot::Range da{};
plot::Range db{};
const bool have_a = ra.plot->y_data_bounds(ra.axis_y, da);
const bool have_b = rb.plot->y_data_bounds(rb.axis_y, db);
const double lo = have_a && have_b ? std::min(da.min, db.min) : 0.0;
const double hi = have_a && have_b ? std::max(da.max, db.max) : 0.0;
const int y_fit = (have_a && have_b && a_rng.min == b_rng.min && a_rng.max == b_rng.max &&
a_rng.min <= lo && a_rng.max >= hi && a_rng.max - a_rng.min < 10.0)
? 1 : 0;
std::fprintf(stderr,
"[split-probe] shared-y y_shared=%d y_gutter=%d y_fit=%d ruler_mirror=%d "
"ranges=[%s | fit A=%.9g..%.9g B=%.9g..%.9g data=%.4g..%.4g] axes_w=[%s] auto=%d\n",
g_shared_y.y_shared, g_shared_y.y_gutter, y_fit, g_shared_y.ruler_mirror,
g_shared_y.ranges.c_str(), a_rng.min, a_rng.max, b_rng.min, b_rng.max, lo, hi,
g_shared_y.axes_w.c_str(), rs.figure().row_y_auto(0) ? 1 : 0);
rs.set_cursor_mode(g_shared_y.was_mode);
rs.set_cursors_visible(g_shared_y.was_on);
rebuild_cursors_pane(*a);
break; break;
} }
default: default: