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.
This commit is contained in:
Martino Ferrari committed 2026-11-06 12:21:38 +01:00
1 parent a29f38eaa9
commit 91ca29d181
10 files changed
+211

No files matched your search

+5
View File
@@ -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
@@ -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.
+9
View File
@@ -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
+10
View File
@@ -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;
+14
View File
@@ -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;
+79
View File
@@ -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<App::ScopeWindow>& 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<plot::u32>(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<App::ScopeWindow>& 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<App::ScopeWindow>& scope : app.scopes) {
if (scope->rows == nullptr) continue;
std::vector<RowSet::MeasureMark> 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<std::size_t>(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<App::ScopeWindow>& 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
+7
View File
@@ -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.
+2
View File
@@ -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;
+3
View File
@@ -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<App::ScopeWindow>& 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);
+71
View File
@@ -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<App::ScopeWindow>& 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<std::size_t>(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<int>(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: