fix: the plot's two side margins are balanced, and the dump outlives its widgets

With the axis titles gone the left gutter was 200 px of an 870 px viewport and the right
side had no margin at all, so the newest tick's label was drawn half outside the plot and
the newest sample's ink landed on the frame.

Two causes on the left. The reservation covered `format_si()`'s `%.3g` fallback
(`-1.23e-45`), which no signal on an `Si` axis produces -- the widest real label for a
unitless axis is `-1.23m` -- and `note_y_gutter()` added 6 px of slack to a figure that is
already the maximum of the labels and last frame's floor, making the gutter a ratchet that
climbed to its 200 px cap in ~19 frames. It settles at 104 px now, with `axes.w changes: 1`
where it read 17.

The right side did not exist: a plot's `axes` rect reached the viewport's right edge by
construction. `deps/plotter` gains `Plot::set_right_gutter()` and `Figure::set_right_gutter()`
-- a want rather than a measurement, since nothing is drawn there, imposed figure-wide so
the rows end on one column as they already start on one. udpscope asks for half the widest
time label plus the label padding (59 px), x labels being centred on their tick.

Separately: the exit dump asked two destroyed widgets for their size, which is where the
"exactly two Gtk-CRITICALs" baseline came from. The pointer is freed memory by then, so a
run that had destroyed other windows first (UDPSCOPE_PROBE_HELP_MS opens three) segfaulted
in the middle of the dump instead. Both sizes are captured on the tick; a clean run now
carries no critical.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Martino FerrariandClaude Opus 4.6 committed 2026-10-05 22:34:10 +02:00
1 parent 811c6d80f0
commit 62df049f53
7 files changed
+153 -39

No files matched your search

+4 -1
View File
@@ -1,7 +1,10 @@
<b>Rows, layout and drag &amp; drop</b>
The figure is a stack of rows, 1 to 8 of them, each holding up to 4 traces. Rows share one
time axis and one left gutter, so their y labels line up and their windows cannot drift apart.
time axis and one pair of side margins, so their y labels line up, their data areas start and
end on the same two columns, and their windows cannot drift apart. The left margin holds the y
labels; the right one is empty, and is there so the newest tick's label and the newest sample's
ink have somewhere to land instead of sitting on the frame.
<b>How to reach it</b>
+28 -12
View File
@@ -125,9 +125,9 @@ bool RowSet::remove_row(int at) {
void RowSet::install(Row& r, int /*row_index*/, plot::Plot& p) {
r.plot = &p;
r.axis_x = r.plot->add_x_axis(
{"time", {-1.0, 1.0}, true, plot::AxisPlace::Bottom, false, plot::TickFormat::Time});
{"", {-1.0, 1.0}, true, plot::AxisPlace::Bottom, false, plot::TickFormat::Time});
r.axis_y = r.plot->add_y_axis(
{"value", {-1.0, 1.0}, true, plot::AxisPlace::Left, false, plot::TickFormat::Si});
{"", {-1.0, 1.0}, true, plot::AxisPlace::Left, false, plot::TickFormat::Si});
for (int s = 0; s < kSlotsPerRow; ++s) {
const std::size_t i = static_cast<std::size_t>(s);
r.plot->add_trace(
@@ -199,7 +199,7 @@ void RowSet::rebuild_all() {
}
figure_->link_x(static_cast<plot::u32>(i), x_group_);
}
if (y_gutter_px_ > 0.0) apply_y_gutter();
apply_gutters();
// A new figure starts with no window at all, so the one the rows were showing has to
// be restated -- as the pin it was, if an event is on screen: assigning a trace or
// changing the row count while a capture is displayed must not throw the view back
@@ -844,10 +844,16 @@ void RowSet::fit_all() {
}
void RowSet::note_y_gutter(double need_px) {
double want = std::min(need_px + 6.0, 200.0);
// No slack added here, and that is load-bearing: `need_px` is the gutter the row was
// *laid out with*, which is already the maximum of its labels and the floor imposed last
// frame -- so a few pixels added to it are added again next frame. The `+ 6.0` this used
// to carry made the gutter a ratchet that climbed 6 px a frame until it hit the cap
// below, which is the whole reason the left margin was `200` of an 870 px viewport (and
// the `axes.w changes: 17` beside it). The label padding the reservation below adds is
// the slack, once.
double want = std::min(need_px, 200.0);
// Reserve room for the widest label the formatter can produce, once, with the
// font the labels are actually drawn in (the embedded font is monospaced, so a
// character count is an exact width).
// font the labels are actually drawn in.
if (!y_gutter_reserved_) {
for (int i = 0; i < rows_; ++i) {
Row& r = row(i);
@@ -855,10 +861,19 @@ void RowSet::note_y_gutter(double need_px) {
const plot::render::TextBackend* text = r.plot->text_backend();
if (text == nullptr) continue;
const plot::render::Style& style = r.plot->style();
const double advance = double(text->measure("0123456789")) / 10.0;
const double pad = double(style.device(style.grid_label_pad));
// "-1.23e-45" is the longest label `format_si()`'s fallback can print.
want = std::max(want, 9.0 * advance + 2.0 * pad + 2.0);
// A sign, three significant figures and a one-glyph SI prefix: the widest label
// `format_si()` writes for an axis with no unit, measured rather than counted in
// characters. Its `%.3g` fallback is wider (`-1.23e-45`) and was what this reserved,
// which cost a *third* of the gutter for a label no signal on a `Si` axis produces --
// it needs a value past 1e24, and `note_y_gutter()` is a monotone maximum over the
// measured need, so one that did appear widens the gutter for good anyway.
want = std::max(want, double(text->measure("-1.23m")) + 2.0 * pad + 2.0);
// The other side, from the same metrics. An x tick label is *centred* on its tick and
// the newest tick sits on the axes' right edge (a live axis is [-window, 0]), so half
// the widest time label is what the rightmost one needs to be drawn at all -- and that
// is also the band which keeps the newest sample's ink off the frame.
right_gutter_px_ = 0.5 * double(text->measure("-1.23ms")) + pad;
y_gutter_reserved_ = true;
break;
}
@@ -866,9 +881,10 @@ void RowSet::note_y_gutter(double need_px) {
if (want > y_gutter_px_) y_gutter_px_ = want;
}
void RowSet::apply_y_gutter() {
if (!figure_ || !(y_gutter_px_ > 0.0)) return;
figure_->set_min_y_gutter(static_cast<f32>(y_gutter_px_));
void RowSet::apply_gutters() {
if (!figure_) return;
if (y_gutter_px_ > 0.0) figure_->set_min_y_gutter(static_cast<f32>(y_gutter_px_));
figure_->set_right_gutter(static_cast<f32>(right_gutter_px_));
}
void RowSet::set_trigger_marker(double x, bool visible) {
+16 -9
View File
@@ -373,18 +373,22 @@ class RowSet {
/// left and right by a few pixels every frame. Since `set_min_y_gutter()` is a
/// floor and the library still takes the larger of the two, a floor smaller than
/// the labels cannot stop that; the reservation therefore covers the widest label
/// `format_si()` can produce (three significant digits, a sign and a prefix, or the
/// `%.3g` fallback) plus the label padding, measured once with the plot's own font.
/// Both reservations only ever grow, so the gutter is constant in practice.
/// `format_si()` writes for an axis with no unit -- a sign, three significant figures
/// and a one-glyph prefix -- plus the label padding, measured once with the plot's own
/// font. Both reservations only ever grow, so the gutter is constant in practice.
///
/// The same metrics fix the **right** gutter, which is a want and not a measurement:
/// see `apply_gutters()`.
void note_y_gutter(double need_px);
/// Applies the stable gutter as the figure's floor. Call once per frame, before the
/// frame is drawn.
/// Applies the stable left gutter as the figure's floor, and the right gutter beside
/// it. Call once per frame, before the frame is drawn.
///
/// It has to be the *figure's* floor and not each row's: `Figure::layout()` clears
/// every row's `set_min_y_gutter()` to measure what its own labels need, so a floor
/// written onto the rows survives exactly until the next layout.
void apply_y_gutter();
/// Both have to be the *figure's* and not each row's: `Figure::layout()` clears every
/// row's `set_min_y_gutter()` to measure what its own labels need, so a floor written
/// onto the rows survives exactly until the next layout, and the rows have to end on
/// one column as well as start on one.
void apply_gutters();
/// The palette entry `index`, wrapped. Keyed by the *signal*, so that the colour a
/// trace is drawn in is the colour the signal list's chip shows, on every row the
@@ -431,6 +435,9 @@ class RowSet {
/// gesture to be honoured; one that matches it is just the clamp's own answer arriving back.
plot::Range pinned_stated_{0.0, 0.0};
double y_gutter_px_ = 0.0;
/// The empty band kept to the right of the data, device pixels. Nothing is drawn in it,
/// so it is reserved once from the font's metrics and never measured or grown.
double right_gutter_px_ = 0.0;
bool y_gutter_reserved_ = false;
std::array<Row, static_cast<std::size_t>(kMaxRows)> storage_{};
CursorMode cursor_mode_ = CursorMode::Vertical;
+14 -3
View File
@@ -274,6 +274,13 @@ struct App {
int dbg_win_btn_w = -1;
int dbg_win_label_w = -1;
int dbg_bar_w = -1;
/// The measurements pane's allocation, for the same reason and more urgently: the dump used
/// to ask the widget itself, which is where the two documented `Gtk-CRITICAL`s came from --
/// and a critical is the lucky outcome. The pointer is freed memory by then, so a run that
/// built and destroyed other windows first (`UDPSCOPE_PROBE_HELP_MS`, three of them) has had
/// that memory reused and the read is a **SIGSEGV** in the middle of the dump.
int dbg_measures_pane_w = -1;
int dbg_measures_pane_h = -1;
bool measures_revealed = false;
/// The measurement dialog while one is open (null otherwise). A probe needs a handle on it:
/// it is a window of its own, so the main window's widget tree does not reach its buttons.
@@ -4878,6 +4885,11 @@ gboolean tick(gpointer user) {
std::fflush(stderr);
}
}
// Captured every frame so the dump has the last live value: see `dbg_measures_pane_w`.
if (app->measures_list != nullptr) {
app->dbg_measures_pane_w = gtk_widget_get_width(app->measures_list);
app->dbg_measures_pane_h = gtk_widget_get_height(app->measures_list);
}
if (model.generation() != app->signal_generation) rebuild_signal_panels(*app);
// A gesture moves the *plot's* cursor; adopting it here is what makes the two
// vertical markers one time across every row, and what the pane then reads.
@@ -5243,7 +5255,7 @@ gboolean tick(gpointer user) {
if (r.plot) gutter = std::max(gutter, double(r.plot->layout().y_axis_width));
}
app->rows->note_y_gutter(gutter);
app->rows->apply_y_gutter();
app->rows->apply_gutters();
// The editor's buttons are placed from the rects this frame was laid out with, so they
// follow a resize and the gutter settling without being told.
@@ -8566,8 +8578,7 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
"pane_w=%d pane_h=%d\n",
app.measures.size(), app.measures_revealed ? 1 : 0,
rows.measure_marks_placed(), app.measure_labels.size(),
app.measures_list != nullptr ? gtk_widget_get_width(app.measures_list) : -1,
app.measures_list != nullptr ? gtk_widget_get_height(app.measures_list) : -1);
app.dbg_measures_pane_w, app.dbg_measures_pane_h);
}
std::printf("[udpscope] header: window_button_w=%d window_label_w=%d (bar_w=%d)\n",
app.dbg_win_btn_w, app.dbg_win_label_w, app.dbg_bar_w);