From 91ca29d18180968e98de107287b6913130a0511c Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Fri, 6 Nov 2026 12:21:38 +0100 Subject: [PATCH] scope windows: the same instruments as the main window A scope or detached window drew its traces and nothing else: no cursors, no gate band, no trigger marker, no measurement marks. They are not per-window state to be recreated -- the cursors are one pair of times, the gate one band, the trigger one instant -- so the main window's row set stays the **owner** and every window is written from it. `sync_scope_instruments()` runs in the main tick after the main's own `sync_cursors()`/ `sync_gate()`: a gesture inside a scope window is read back from *that* window's controllers first (those two calls only adopt while their own plot holds the grab, which is exactly "the drag happened here"), adopted by the owner, and then every window -- the dragged one included -- is written from the owner with the same cursor pair, mode and visibility, gate bounds and visibility, and trigger marker. `RowSet::set_cursors()` is the new pair setter; `trigger_marker_x()`/`_visible()` are the getters the mirror reads. The measurement marks travel by signal name (`RowSet::MeasureMark::signal`, filled by the measures pass), so each window finds the row that plots the signal and draws its ruler or mark there -- or none, when it does not plot it. The search marks are times on the window and are placed on every window by the same per-frame path, and the crosshair-axis-labels preference is restated to open windows when it changes. Probe: `[scope-probe] instruments windows=N cursors=N gate=N trigger=N marks=N (main trigger on=..)`, after adding a real gated `Top` measurement on `sine` -- the gate is restated visible each frame by `update_measures()` and marks come only from `show_on_plot` measurements, so the leg uses the app's own path. Docs: README, the cursors invariant, and the probe readings. --- README.md | 5 ++ .../claude/invariants-readouts-and-cursors.md | 11 +++ docs/claude/probe-readings.md | 9 +++ src/plot_host.cpp | 10 +++ src/plot_host.hpp | 14 ++++ src/ui.cpp | 79 +++++++++++++++++++ src/ui_app.hpp | 7 ++ src/ui_measure.cpp | 2 + src/ui_prefs.cpp | 3 + src/ui_probe.cpp | 71 +++++++++++++++++ 10 files changed, 211 insertions(+) diff --git a/README.md b/README.md index a931661..e831ccc 100644 --- a/README.md +++ b/README.md @@ -151,6 +151,11 @@ you. The overflow menu's **Reopen closed windows (N)** brings them back, arrange window that was *attached* is not remembered (its figure is on the main window again), and a detached figure is preserved by its window's entry, so quitting with one open no longer loses it. +A scope window draws the **same instruments as the main window** — the two cursors (draggable +there, and shared: moving one in any window moves it everywhere), the gate band, the trigger +marker and the measurements' marks on the traces each window actually plots. They have one owner +(the main window's plot set) and are written into every window each frame, so no window can drift. + A scope window also **carries its own layout editor**: `L` (or the grid button in its corner, revealed like the others) shows the same `+`/`×`/Done controls the main window's editor has, and they change *that* window's rows — so a second window can hold several figures as easily as the diff --git a/docs/claude/invariants-readouts-and-cursors.md b/docs/claude/invariants-readouts-and-cursors.md index f41548d..742082e 100644 --- a/docs/claude/invariants-readouts-and-cursors.md +++ b/docs/claude/invariants-readouts-and-cursors.md @@ -130,3 +130,14 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear stages the badge texts, which leave with the rest of the over batch over everything. An assertion on badge pixels inside a label's rows is the regression test: with the fill under the text, the label's glyph pixels are still lit inside the badge's rect. + +- **The instruments have one owner: the main window's row set.** The two cursors, the gate band, + the trigger marker and the measurement marks are *shared*, not copied per window. The main tick's + `sync_scope_instruments()` writes the owner's state into every scope window each frame (the + setters early-out, so a steady frame is a handful of comparisons), and a gesture inside a scope + window is read back from **that** window's controllers first -- `sync_cursors()`/`sync_gate()` + only adopt while their own plot holds the grab, which is exactly "the drag happened here" -- and + adopted by the owner before anything is written out. The measurement marks travel by **signal + name** (`RowSet::MeasureMark::signal`), so a window finds the row that plots it; a window that + does not plot the signal draws none. The search marks are times on the window and are placed on + every window by the same per-frame path. diff --git a/docs/claude/probe-readings.md b/docs/claude/probe-readings.md index d5b0901..9d1c697 100644 --- a/docs/claude/probe-readings.md +++ b/docs/claude/probe-readings.md @@ -1494,6 +1494,15 @@ alone). `copied=''` with every slot empty is a copy that moved nothing. Measured *Reopen closed windows* item ("clicked", the item's own handler) opens every remembered one, and the list is empty after (`left=0`). A `remembered` that did not grow is a destroy handler that forgot to record; `left` non-zero is an entry the menu did not take. +- `instruments windows=N cursors=N gate=N trigger=N marks=N` is the **one owner, every window**: + given a cursor pair, a gate band and the trigger marker on the main window, every scope window + reports the same (each count is how many windows matched). `marks` is the per-window measurement + mark count agreeing with what that window plots -- a window that plots the measured signal has + its ruler, one that does not has none. All four counts equal to `windows` is the reading; + `trigger` is compared against the **main** (the marker is up only while the trigger is), and the + parenthesis reports the main's state so a zero is distinguishable from an unequal one. The leg + adds a real gated `Top` measurement on `sine`, because the gate is restated visible every frame + by `update_measures()` and `marks` only come from `show_on_plot` measurements. - The second line is the **window as a drop target and a *Plot on* group**: `drop=1` is `on_scope_drop_channel()` — the handler the window's own target wires — putting `square` in the copied window's second slot (`slot1=`), the rebuild and the area's re-point then left to diff --git a/src/plot_host.cpp b/src/plot_host.cpp index 8071ae8..f2e430d 100644 --- a/src/plot_host.cpp +++ b/src/plot_host.cpp @@ -945,6 +945,16 @@ void RowSet::place_cursors(Row& r, plot::CursorPlacement placement) { } +void RowSet::set_cursors(double a, double b) { + if (a == cursor_a_x_ && b == cursor_b_x_) return; + cursor_a_x_ = a; + cursor_b_x_ = b; + // `Free`: the pair is the owner's already-committed position (a gesture in another window, or + // this window's own last sync); snapping it here would fight the hand still moving it. + for (int i = 0; i < rows_; ++i) place_cursors(row(i), plot::CursorPlacement::Free); + place_crossings(); +} + void RowSet::set_cursor_mode(CursorMode mode) { if (mode == cursor_mode_) return; cursor_mode_ = mode; diff --git a/src/plot_host.hpp b/src/plot_host.hpp index deb69c1..203f1a3 100644 --- a/src/plot_host.hpp +++ b/src/plot_host.hpp @@ -355,6 +355,16 @@ class RowSet { void place_crossings(); CursorMode cursor_mode() const noexcept { return cursor_mode_; } + /// Writes both vertical cursors at once: the two-time pair is one instrument with one owner + /// (the main window's row set), and a scope window is written from it each frame -- reading it + /// back from that window's own plots is what lets a drag *there* move the same pair. + void set_cursors(double a, double b); + + /// The trigger marker's position and whether it is up: what a scope window is written from, the + /// same way it is written from the cursor pair. + double trigger_marker_x() const noexcept { return trigger_marker_x_; } + bool trigger_marker_visible() const noexcept { return trigger_marker_on_; } + /// The two vertical cursors' positions, in the **display** x the axis reads -- not stream /// seconds, which they coincided with only while `display_epoch()` was always 0. A live view /// is newest-relative now, so a cursor left at -1.5 s keeps meaning "1.5 s ago" and the data @@ -399,6 +409,10 @@ class RowSet { /// its label stays empty. struct MeasureMark { int row = 0; + /// The signal the mark belongs to, so a *scope window* can find the row that plots it (its + /// row numbering is its own): a mark that never carried the name could only be placed in a + /// copy by index, which a window's own layout editor invalidates. + std::string signal; bool level = false; double value = 0.0; plot::u32 color = 0; diff --git a/src/ui.cpp b/src/ui.cpp index 995a4c1..d6fdfe2 100644 --- a/src/ui.cpp +++ b/src/ui.cpp @@ -1508,6 +1508,76 @@ void set_scope_layout_editing(App& app, App::ScopeWindow& scope, bool on) { if (scope.widget != nullptr) gtk_gl_area_queue_render(GTK_GL_AREA(scope.widget)); } +void sync_scope_instruments(App& app) { + if (app.scopes.empty()) return; + // A gesture in a **scope** window moves the same pair of instruments: read it back from that + // window's own controllers first, so the main window adopts it before anything is written out. + // (`sync_cursors()`/`sync_gate()` only adopt while their own plot is the one being dragged, + // which is exactly the question asked here.) + for (const std::unique_ptr& scope : app.scopes) { + if (scope->rows == nullptr || scope->area == nullptr) continue; + int cursor_row = -1; + int gate_row = -1; + for (int i = 0; i < scope->rows->row_count(); ++i) { + plot::input::Controller* c = scope->area->row_controller(static_cast(i)); + if (c == nullptr) continue; + if (cursor_row < 0 && c->grabbed_cursor() != plot::kInvalid) cursor_row = i; + if (gate_row < 0 && c->grabbed_region() != plot::kInvalid) gate_row = i; + } + if (cursor_row < 0 && gate_row < 0) continue; + scope->rows->sync_cursors(cursor_row); + scope->rows->sync_gate(gate_row); + if (cursor_row >= 0) { + app.rows->set_cursors(scope->rows->cursor_a_x(), scope->rows->cursor_b_x()); + } + if (gate_row >= 0) app.rows->set_gate(scope->rows->gate_x0(), scope->rows->gate_x1()); + } + // The main window's row set is the owner: every scope is written from it each frame. The setters + // early-out when nothing changed, so this is a handful of comparisons on a steady frame. + const bool cursors = app.rows->cursors_visible(); + const bool gate = app.rows->gate_visible(); + const bool trigger = app.rows->trigger_marker_visible(); + for (const std::unique_ptr& scope : app.scopes) { + if (scope->rows == nullptr) continue; + scope->rows->set_cursors_visible(cursors); + scope->rows->set_cursor_mode(app.rows->cursor_mode()); + // The pair is shared even while hidden: a window whose cursors are shown later must show them + // where the others already stand (`set_cursors()` places them only where the mode and the + // visibility let them be drawn). + scope->rows->set_cursors(app.rows->cursor_a_x(), app.rows->cursor_b_x()); + scope->rows->set_gate_visible(gate); + scope->rows->set_gate(app.rows->gate_x0(), app.rows->gate_x1()); + scope->rows->set_trigger_marker(app.rows->trigger_marker_x(), trigger); + } +} + +void mirror_measure_marks(App& app) { + if (app.scopes.empty()) return; + for (const std::unique_ptr& scope : app.scopes) { + if (scope->rows == nullptr) continue; + std::vector marks; + for (const RowSet::MeasureMark& mk : app.measure_marks_display) { + // The window's rows are its own: find the row that plots the mark's signal (a window that + // does not show it draws nothing for it, which is what "the mark is on the trace it + // measures" means). + int row = -1; + for (int r = 0; r < scope->rows->row_count() && row < 0; ++r) { + for (int sl = 0; sl < RowSet::kSlotsPerRow; ++sl) { + if (scope->rows->row(r).signal_name[static_cast(sl)] == mk.signal) { + row = r; + break; + } + } + } + if (row < 0) continue; + RowSet::MeasureMark t = mk; + t.row = row; + marks.push_back(std::move(t)); + } + scope->rows->set_measure_marks(marks); + } +} + void sync_reopen_scopes_item(App& app) { if (app.reopen_scopes_item == nullptr) return; const std::size_t n = app.closed_windows.size(); @@ -2999,6 +3069,11 @@ void place_search_marks(App& app) { gtk_label_set_text(GTK_LABEL(app.search_marks_label), text); } app.rows->set_search_marks(xs, current); + // A hit is a time on the window, not a property of a row: every scope window draws the same + // marks (and the same current one) on its own plots. + for (const std::unique_ptr& scope : app.scopes) { + if (scope->rows != nullptr) scope->rows->set_search_marks(xs, current); + } } @@ -4818,6 +4893,10 @@ gboolean tick(gpointer user) { } app->rows->sync_cursors(cursor_drag_row); app->rows->sync_gate(gate_drag_row); + // The scope windows' instruments, from the main window's one owner: the cursors, the gate band + // and the trigger marker are the same instruments on every window, and a drag on one of them + // *there* comes back through here to the one owner. + sync_scope_instruments(*app); // The drag's end is a new pair of gate bounds, and the bounds are session state: save there, // the way a dragged row sizing is saved when its own gesture ends. During the drag every // frame would only re-arm the same 400 ms debounce, but the *end* is the one moment the diff --git a/src/ui_app.hpp b/src/ui_app.hpp index ad26bd7..f7522c4 100644 --- a/src/ui_app.hpp +++ b/src/ui_app.hpp @@ -2108,6 +2108,13 @@ void remember_closed_window(App& app, const App::ScopeWindow& scope); /// Opens a window from a remembered state: an empty scope window, then its rows, weights and /// signals. Returns the opened window (nullptr when it could not be made). App::ScopeWindow* open_scope_window_from_state(App& app, const LayoutState::Window& state); +/// Writes the main window's instruments -- the two cursors, the gate band, the trigger marker -- +/// into every scope window, and adopts one dragged *there* back into the main window first. Called +/// once per frame from the main tick, after the main's own `sync_cursors()`/`sync_gate()`. +void sync_scope_instruments(App& app); +/// The main window's measurement marks, translated to each scope window's own rows (by signal +/// name) and placed there: a window that plots a measured signal draws its mark. +void mirror_measure_marks(App& app); /// Refreshes the *Reopen closed windows* entry: its count and whether it is sensitive. void sync_reopen_scopes_item(App& app); /// Turns a scope window's own layout editor on or off, building or taking down its buttons. diff --git a/src/ui_measure.cpp b/src/ui_measure.cpp index 89cd7ac..c92d368 100644 --- a/src/ui_measure.cpp +++ b/src/ui_measure.cpp @@ -296,6 +296,7 @@ void place_measure_marks(App& app) { } } app.rows->set_measure_marks(app.measure_marks_display); + mirror_measure_marks(app); } /// Recomputes every measurement over the window it describes and writes the results into the @@ -734,6 +735,7 @@ void update_measures(App& app, bool force, bool accumulate) { // `MeasureMarkSpec` -- the badge text is built where the display conversion happens). RowSet::MeasureMark mark; mark.row = row; + mark.signal = m.signal; mark.level = spec.level; mark.value = spec.level ? spec.value : spec.value + s0; mark.color = color; diff --git a/src/ui_prefs.cpp b/src/ui_prefs.cpp index 8998c28..ded78fc 100644 --- a/src/ui_prefs.cpp +++ b/src/ui_prefs.cpp @@ -287,6 +287,9 @@ void open_preferences(App& app) { // The crosshair's axis badges, at once like the theme: it is a drawing flag on the // plots, and a change is the next frame's drawing. app.rows->set_crosshair_axis_labels(s.crosshair_axis_labels); + for (const std::unique_ptr& scope : app.scopes) { + if (scope->rows != nullptr) scope->rows->set_crosshair_axis_labels(s.crosshair_axis_labels); + } // Only a failure is announced. A change arrives once per keystroke in the directory row, and a // status line that flashed on each one would be the app shouting about its own bookkeeping. if (!written) set_notice(app, "could not write the preferences file", 4); diff --git a/src/ui_probe.cpp b/src/ui_probe.cpp index e2eef68..89af8ae 100644 --- a/src/ui_probe.cpp +++ b/src/ui_probe.cpp @@ -2018,6 +2018,77 @@ gboolean scope_probe_step(gpointer user) { remembered_name ? 1 : 0, back_rows, back_slot.c_str(), a->closed_windows.size()); std::fflush(stderr); + // **The instruments are on every window**: the cursors, the gate band and the trigger + // marker are written from the main window's one owner (the next tick's + // `sync_scope_instruments()`), so the states below are asked for on the main and read on + // the scope. + a->rows->set_cursors_visible(true); + a->rows->set_cursor_mode(RowSet::CursorMode::Both); + a->rows->set_cursors(-1.0, 0.0); + a->rows->set_gate(-1.2, -0.8); + a->rows->set_gate_visible(true); + a->rows->set_trigger_marker(0.0, true); + // A real measurement on 'sine' with `show_on_plot`: the marks path is the one a user's + // measurement takes (`update_measures()` -> `place_measure_marks()` -> the windows), so a + // window that plots 'sine' must draw its ruler and one that does not must draw none. + { + Measure m; + m.signal = "sine"; + m.type = MeasureType::Top; + m.options.show_on_plot = true; + // Gated: `update_measures()` restates the gate's visibility every frame (it is up while + // *any* measurement is gated), so the band is genuinely on and its bounds are the ones + // set above rather than a value the mirror would be right to hide. + m.options.gate = true; + a->measures.push_back(std::move(m)); + rebuild_measures_pane(*a); + } + a->dbg_scope_step = 5; + g_timeout_add(700, scope_probe_step, a); + return G_SOURCE_REMOVE; + } + case 5: { + // A frame has passed: the tick wrote the main's instruments into every window. + int cursor_ok = 0; + int gate_ok = 0; + int trigger_ok = 0; + int marks_ok = 0; + for (const std::unique_ptr& scope : a->scopes) { + if (scope->rows == nullptr) continue; + const bool cursors = scope->rows->cursors_visible() && + scope->rows->cursor_mode() == RowSet::CursorMode::Both && + std::fabs(scope->rows->cursor_a_x() + 1.0) < 1e-9 && + std::fabs(scope->rows->cursor_b_x()) < 1e-9; + const bool gate = scope->rows->gate_visible() && + std::fabs(scope->rows->gate_x0() + 1.2) < 1e-9 && + std::fabs(scope->rows->gate_x1() + 0.8) < 1e-9; + const bool trigger = + scope->rows->trigger_marker_visible() == a->rows->trigger_marker_visible() && + std::fabs(scope->rows->trigger_marker_x() - a->rows->trigger_marker_x()) < 1e-9; + // The mark: the window that plots 'sine' has it, one that does not has none. Which is + // which is read off the same names. + bool plots_sine = false; + for (int r = 0; r < scope->rows->row_count(); ++r) { + for (int sl = 0; sl < RowSet::kSlotsPerRow; ++sl) { + if (scope->rows->row(r).signal_name[static_cast(sl)] == "sine") { + plots_sine = true; + } + } + } + const int placed = scope->rows->measure_marks_placed(); + const bool mark = plots_sine ? placed == 1 : placed == 0; + cursor_ok += cursors ? 1 : 0; + gate_ok += gate ? 1 : 0; + trigger_ok += trigger ? 1 : 0; + marks_ok += mark ? 1 : 0; + } + const int n = static_cast(a->scopes.size()); + std::fprintf(stderr, + "[scope-probe] instruments windows=%d cursors=%d gate=%d trigger=%d " + "marks=%d (main trigger on=%d x=%.3f)\n", + n, cursor_ok, gate_ok, trigger_ok, marks_ok, + a->rows->trigger_marker_visible() ? 1 : 0, a->rows->trigger_marker_x()); + std::fflush(stderr); return G_SOURCE_REMOVE; } default: