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
+174 -24

No files matched your search

+30
View File
@@ -289,6 +289,13 @@ bool RowSet::insert_cell(int fr, int at) {
const int p = first_plot_of_row(fr) + k;
for (int i = rows_; i > p; --i) carry_row(row(i), row(i - 1));
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_;
++cells_per_row_[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_->set_row_gap(kRowGap);
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_cell_weights();
x_group_ = figure_->add_x_link();
@@ -480,6 +491,13 @@ void RowSet::rebuild_all() {
continue;
}
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);
if (trigger_marker_on_) {
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;
if (y > kParked * 0.5) r.ruler_y = y;
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;
if (cursor_mode_ != CursorMode::Horizontal) {
+26 -16
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
/// 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
/// every other read of it from this thread.
@@ -1760,18 +1761,23 @@ std::string row_unit(App& app, int row) {
std::lock_guard<std::mutex> lock(app.model->mutex());
std::string unit;
bool first = true;
for (int s = 0; s < RowSet::kSlotsPerRow; ++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;
const int ch = r.channel[si];
if (ch < 0 || static_cast<std::size_t>(ch) >= app.model->channels().size()) continue;
const std::string& u = app.model->channels()[static_cast<std::size_t>(ch)].unit;
if (first) {
unit = u;
first = false;
} else if (unit != u) {
return "";
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) {
const std::size_t si = static_cast<std::size_t>(s);
if (r.signal_name[si].empty()) continue;
const int ch = r.channel[si];
if (ch < 0 || static_cast<std::size_t>(ch) >= app.model->channels().size()) continue;
const std::string& u = app.model->channels()[static_cast<std::size_t>(ch)].unit;
if (first) {
unit = u;
first = false;
} else if (unit != u) {
return "";
}
}
}
return unit;
@@ -1840,10 +1846,14 @@ void rebuild_cursors_pane(App& app) {
for (int r = 0; r < app.rows->row_count(); ++r) {
if (!rulers) break;
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.
if (app.rows->cells_in_row(app.rows->figure_row_of(r)) > 1) {
std::snprintf(who, sizeof(who), "plot %s", app.rows->plot_label(r).c_str());
if (split && app.rows->cell_of(r) != 0) continue;
if (split) {
std::snprintf(who, sizeof(who), "row %d", fr + 1);
} else {
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));
}
/// 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) {
for (int r = 0; r < RowSet::kMaxRows; ++r) {
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: {
split_probe_report(*a, "after-assign");
// The cursors pane's ruler labels (`rebuild_cursors_pane()`, ruler modes): a plot of a split row is
// "plot <label>", a plot of 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.
// The cursors pane's ruler labels (`rebuild_cursors_pane()`, ruler modes): one line per figure row,
// since a split row's cells share their y axis and their rulers. A split row is "row <figure row,
// 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();
rs.set_cursor_mode(RowSet::CursorMode::Both);
const auto ruler_labels = [&]() {
@@ -4415,6 +4428,101 @@ gboolean split_probe_step(gpointer user) {
mixed_labels.c_str());
rs.set_cursor_mode(was_mode);
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;
}
default: