scope windows: mirror a pin as a range, and restore the tick rebuild

**The blank scope window in trigger mode.** A held event pins the main window to
`[-pre, +post]`, and the tick mirrors that pin onto every scope window. It passed
`pinned_range().min/max` to `pin_event_window()`, which takes **spans** (`pre, post`): `min` is
signed, so `pre` came out negative and the group range became `[+pre, +post]` — an inverted x
window. The plotter then had nothing to draw: no trace, no x-axis labels, an empty plot that
looked like a broken window. `RowSet::pin_display_range(x0, x1)` states the pin as the range a
mirroring view actually has, and the tick uses it; live mode was never affected, which is why the
defect only showed under the trigger.

Also restores the scope-window `rebuild_if_dirty()` in the tick, which a reproduction experiment
had left commented out (`REPRO-TEMP`) and a later commit carried: a window opened empty and filled
by a drop kept the figure it was built with.

Probe: `[scope-probe] pin_mirror=1 main=[..] scope=[..] x=[..]` pins the main window, reads the
scope's pin and plot x range a frame later, and reads `0` for an inverted or mismatched pin.
Docs: probe readings and the trigger/pinning invariant (range vs spans).
This commit is contained in:
Martino Ferrari committed 2026-11-06 10:39:10 +01:00
1 parent 2279e2140a
commit ecdbf4f0e1
6 files changed
+75 -9

No files matched your search

@@ -629,3 +629,11 @@ samples. Three rules keep that honest:
its ink is not in the widget's paint tree, so `gtk_snapshot_free_to_node()` on a widget paintable of its ink is not in the widget's paint tree, so `gtk_snapshot_free_to_node()` on a widget paintable of
it returns NULL and the "screenshot" would be an empty file. Drawing it in the store also keeps that it returns NULL and the "screenshot" would be an empty file. Drawing it in the store also keeps that
class GTK-free, which is what makes the cache testable without a window. class GTK-free, which is what makes the cache testable without a window.
- **A pin is mirrored as a *range*, never as a pair of numbers that might be spans.** `Range`
(`pinned_range()`: `{-pre, +post}`) and the pin's *spans* (`pin_event_window(pre, post)`) are two
different things that both look like a pair, and mixing them inverts the window: a second view
that handed `pinned_range().min` to `pin_event_window()` pinned to `[+pre, +post]`, and the plot
drew nothing — no trace, no axis labels — because the x range was degenerate. Views that mirror a
pin use `pin_display_range(x0, x1)`, the range's own form. `[scope-probe] pin_mirror=1` reads the
two windows' pins and the scope's x range, and an inverted pin reads `0`.
+7
View File
@@ -1474,6 +1474,7 @@ a defect in the copy. Measured output:
[scope-probe] gl copy=ok(67f) empty=ok(39f) empty_bound=1 [scope-probe] gl copy=ok(67f) empty=ok(39f) empty_bound=1
[scope-probe] gl detached ok=1 frames=42 rows=1 slot0='sine' [scope-probe] gl detached ok=1 frames=42 rows=1 slot0='sine'
[scope-probe] rclick=1 [scope-probe] rclick=1
[scope-probe] pin_mirror=1 main=[-1.000..1.000] scope=[-1.000..1.000] x=[-1.000..1.000]
``` ```
- `count=2` is both windows having been created and retained; `windows=1` each being a visible - `count=2` is both windows having been created and retained; `windows=1` each being a visible
@@ -1493,6 +1494,12 @@ a defect in the copy. Measured output:
signal into it" — landing in its row set. `drop=0` names a refused drop (a full plot or an signal into it" — landing in its row set. `drop=0` names a refused drop (a full plot or an
unacquirable signal); `page_groups` less than the window count is a page rebuilt before the unacquirable signal); `page_groups` less than the window count is a page rebuilt before the
windows were read or a group that lost its window. windows were read or a group that lost its window.
- `pin_mirror=1` is a scope window **mirroring a pin as a range**: the probe pins the main window
and reads the scope's pin and its plot's x range a frame later. Both must agree and the x range
must be ordered (`x=[min..max]`, `min < max`). A `0` with an inverted scope pin (`[+pre, +post]`)
is the defect this exists for: `pin_event_window()` takes **spans**, and passing it a range's
signed `min` turned the window inside out, so a scope in Held or Paused mode drew no trace and no
x labels at all.
- `rclick=1` is the scope window's **own right-click menu** being wired (one click gesture with - `rclick=1` is the scope window's **own right-click menu** being wired (one click gesture with
button 3 on its GL area, beside the drop target): the menu's actions act on that window's row button 3 on its GL area, beside the drop target): the menu's actions act on that window's row
set. set.
+13
View File
@@ -1509,6 +1509,19 @@ void RowSet::pin_event_window(double pre_s, double post_s) {
figure_->set_x_link_range(x_group_, {-pre_s, post_s}); figure_->set_x_link_range(x_group_, {-pre_s, post_s});
} }
void RowSet::pin_display_range(double x0, double x1) {
if (!(x1 > x0)) return;
window_s_ = std::max(1.0e-3, x1 - x0);
pinned_ = true;
pinned_pre_ = -x0;
pinned_post_ = x1;
if (!figure_) return;
// The same two statements `pin_event_window()` makes, from the range instead of the spans: the
// group stops following the live edge and is placed on the event's own window.
figure_->set_x_link_follow(x_group_, plot::XFollow::Off);
figure_->set_x_link_range(x_group_, {x0, x1});
}
void RowSet::fit_all() { void RowSet::fit_all() {
for (int i = 0; i < rows_; ++i) { for (int i = 0; i < rows_; ++i) {
Row& r = row(i); Row& r = row(i);
+7
View File
@@ -570,6 +570,13 @@ class RowSet {
/// 0, so nothing outside the pre+post window is visible. /// 0, so nothing outside the pre+post window is visible.
void pin_event_window(double pre_s, double post_s); void pin_event_window(double pre_s, double post_s);
/// The same pin, stated as the **range** a second view mirrors: the caller has the display
/// range (`x0 < x1`) and not the spans -- a scope window takes `pinned_range()` from the main
/// window and hands it on. `pin_event_window()` takes *spans*, and passing it a range's signed
/// `min` turned the pin inside out (a scope in Held mode pinned to `[+pre, +post]`: no trace
/// and no x labels). A degenerate or inverted range pins nothing.
void pin_display_range(double x0, double x1);
/// Fits every row's y axis to the data it holds, and puts x back on the whole time window -- /// Fits every row's y axis to the data it holds, and puts x back on the whole time window --
/// `fit_x()`'s x half exactly, so the two cannot drift apart. Like it, **this does not change /// `fit_x()`'s x half exactly, so the two cannot drift apart. Like it, **this does not change
/// the time window**: the y axes are fitted to their data, x is merely un-zoomed. /// the time window**: the y axes are fitted to their data, x is merely un-zoomed.
+12 -6
View File
@@ -4633,17 +4633,23 @@ gboolean tick(gpointer user) {
const double window_s = app->rows->time_window(); const double window_s = app->rows->time_window();
for (const std::unique_ptr<App::ScopeWindow>& scope : app->scopes) { for (const std::unique_ptr<App::ScopeWindow>& scope : app->scopes) {
if (pinned) { if (pinned) {
scope->rows->pin_event_window(pin.min, pin.max); // The pin as a *range*: `pin_event_window()` takes spans (pre, post), and handing it
// `pin.min` -- a signed time -- inverted the window, so a scope in Held or Paused mode
// drew no trace and no x labels at all.
scope->rows->pin_display_range(pin.min, pin.max);
} else if (std::fabs(scope->rows->time_window() - window_s) > 1.0e-12) { } else if (std::fabs(scope->rows->time_window() - window_s) > 1.0e-12) {
scope->rows->set_time_window(window_s); scope->rows->set_time_window(window_s);
} }
// An arrangement change (a drop, a *Plot on* button) rebuilt the row set: point the area at // An arrangement change (a drop, a *Plot on* button) rebuilt the row set: point the area at
// the new figure here, in the tick -- the change arrives from inside this very widget (a drop // the new figure here, in the tick -- the change arrives from inside this very widget (a drop
// signal), and a synchronous rebuild would reset the drag controller under it. // signal), and a synchronous rebuild would reset the drag controller under it. The traces
// REPRO-TEMP: rebuild disabled // themselves are `SlotSeries` rebinds, so a slot that gains or loses a source draws either
// if (scope->rows->rebuild_if_dirty() && scope->area != nullptr) { // way; what *does* need this is the figure's own state: a row's legend, its cell structure,
// scope->area->set_figure(&scope->rows->figure()); // and the trace objects `install()` creates only for the slots that hold a signal at rebuild
// } // time -- a window opened empty and filled by a drop has none until this runs.
if (scope->rows->rebuild_if_dirty() && scope->area != nullptr) {
scope->area->set_figure(&scope->rows->figure());
}
if (scope->detached) sync_scope_attach(*app, *scope); if (scope->detached) sync_scope_attach(*app, *scope);
// **A window that cannot paint says so.** A blank plot is indistinguishable from a // **A window that cannot paint says so.** A blank plot is indistinguishable from a
// context that failed or an area that never got a frame, and the report ("nothing is // context that failed or an area that never got a frame, and the report ("nothing is
+28 -3
View File
@@ -1915,11 +1915,13 @@ gboolean scope_probe_step(gpointer user) {
det.rows != nullptr && det.rows->row_count() > 0 det.rows != nullptr && det.rows->row_count() > 0
? det.rows->row(0).signal_name[0].c_str() ? det.rows->row(0).signal_name[0].c_str()
: ""); : "");
}
// The scope window carries its own right-click menu (its gesture is on the GL area, wired // The scope window carries its own right-click menu (its gesture is on the GL area, wired
// beside the drop target): one click gesture whose button is 3, per window. // beside the drop target): one click gesture whose button is 3, per window.
int right_clicks = 0; int right_clicks = 0;
if (det.widget != nullptr) { GtkWidget* det_widget = a->scopes.empty() ? nullptr : a->scopes.back()->widget;
GListModel* controllers = gtk_widget_observe_controllers(det.widget); if (det_widget != nullptr) {
GListModel* controllers = gtk_widget_observe_controllers(det_widget);
const guint n = g_list_model_get_n_items(G_LIST_MODEL(controllers)); const guint n = g_list_model_get_n_items(G_LIST_MODEL(controllers));
for (guint i = 0; i < n; ++i) { for (guint i = 0; i < n; ++i) {
GObject* c = G_OBJECT(g_list_model_get_item(controllers, i)); GObject* c = G_OBJECT(g_list_model_get_item(controllers, i));
@@ -1933,8 +1935,31 @@ gboolean scope_probe_step(gpointer user) {
} }
std::fprintf(stderr, "[scope-probe] rclick=%d\n", right_clicks); std::fprintf(stderr, "[scope-probe] rclick=%d\n", right_clicks);
std::fflush(stderr); std::fflush(stderr);
} // **The pin mirror**: a held event pins the main window, and every scope must be pinned to
// the *same range*. `pin_event_window()` takes spans (pre, post) and passing it a range's
// signed min turned the pin inside out (a scope in Held drew no trace and no x labels).
a->rows->pin_event_window(1.6, 0.4);
a->dbg_scope_step = 4;
g_timeout_add(350, scope_probe_step, a);
return G_SOURCE_REMOVE;
}
case 4: {
const plot::Range main_pin = a->rows->pinned_range();
const plot::Range scope_pin = a->scopes[0]->rows->pinned_range();
const plot::Range scope_x = a->scopes[0]->rows->row(0).plot != nullptr
? a->scopes[0]->rows->row(0).plot->x_range(0)
: plot::Range{};
const bool pinned = a->scopes[0]->rows->window_pinned() == a->rows->window_pinned();
const bool same = std::fabs(scope_pin.min - main_pin.min) < 1e-9 &&
std::fabs(scope_pin.max - main_pin.max) < 1e-9 &&
scope_x.max > scope_x.min;
std::fprintf(stderr,
"[scope-probe] pin_mirror=%d main=[%.3f..%.3f] scope=[%.3f..%.3f] "
"x=[%.3f..%.3f]\n",
pinned && same ? 1 : 0, main_pin.min, main_pin.max, scope_pin.min,
scope_pin.max, scope_x.min, scope_x.max);
std::fflush(stderr); std::fflush(stderr);
a->rows->set_time_window(2.0); // unpin: leave the window as the run found it
return G_SOURCE_REMOVE; return G_SOURCE_REMOVE;
} }
default: default: