diff --git a/docs/claude/invariants-trigger-and-pinning.md b/docs/claude/invariants-trigger-and-pinning.md index 53dd376..cbca069 100644 --- a/docs/claude/invariants-trigger-and-pinning.md +++ b/docs/claude/invariants-trigger-and-pinning.md @@ -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 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. + +- **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`. diff --git a/docs/claude/probe-readings.md b/docs/claude/probe-readings.md index 43f3b76..7b22de3 100644 --- a/docs/claude/probe-readings.md +++ b/docs/claude/probe-readings.md @@ -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 detached ok=1 frames=42 rows=1 slot0='sine' [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 @@ -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 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. +- `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 button 3 on its GL area, beside the drop target): the menu's actions act on that window's row set. diff --git a/src/plot_host.cpp b/src/plot_host.cpp index c5f7756..8071ae8 100644 --- a/src/plot_host.cpp +++ b/src/plot_host.cpp @@ -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}); } +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() { for (int i = 0; i < rows_; ++i) { Row& r = row(i); diff --git a/src/plot_host.hpp b/src/plot_host.hpp index 3fb5d08..deb69c1 100644 --- a/src/plot_host.hpp +++ b/src/plot_host.hpp @@ -570,6 +570,13 @@ class RowSet { /// 0, so nothing outside the pre+post window is visible. 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 -- /// `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. diff --git a/src/ui.cpp b/src/ui.cpp index b2d7419..2e96169 100644 --- a/src/ui.cpp +++ b/src/ui.cpp @@ -4633,17 +4633,23 @@ gboolean tick(gpointer user) { const double window_s = app->rows->time_window(); for (const std::unique_ptr& scope : app->scopes) { 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) { scope->rows->set_time_window(window_s); } // 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 - // signal), and a synchronous rebuild would reset the drag controller under it. - // REPRO-TEMP: rebuild disabled - // if (scope->rows->rebuild_if_dirty() && scope->area != nullptr) { - // scope->area->set_figure(&scope->rows->figure()); - // } + // signal), and a synchronous rebuild would reset the drag controller under it. The traces + // themselves are `SlotSeries` rebinds, so a slot that gains or loses a source draws either + // way; what *does* need this is the figure's own state: a row's legend, its cell structure, + // 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); // **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 diff --git a/src/ui_probe.cpp b/src/ui_probe.cpp index 066f23b..a2710b9 100644 --- a/src/ui_probe.cpp +++ b/src/ui_probe.cpp @@ -1915,11 +1915,13 @@ gboolean scope_probe_step(gpointer user) { det.rows != nullptr && det.rows->row_count() > 0 ? 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 // beside the drop target): one click gesture whose button is 3, per window. int right_clicks = 0; - if (det.widget != nullptr) { - GListModel* controllers = gtk_widget_observe_controllers(det.widget); + GtkWidget* det_widget = a->scopes.empty() ? nullptr : a->scopes.back()->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)); for (guint i = 0; i < n; ++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::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); + a->rows->set_time_window(2.0); // unpin: leave the window as the run found it return G_SOURCE_REMOVE; } default: