Gated measurements #2

Merged
martino merged 12 commits from gated-measurements into main 2026-11-06 12:30:43 +01:00
9 changed files with 231 additions and 21 deletions
Showing only changes of commit 51b4fad386 - Show all commits

No files matched your search

+4
View File
@@ -138,6 +138,10 @@ and `PlotArea`; the overflow menu's **New scope window** opens one with an empty
window is another *view* of the same acquisition: the main window's tick binds and acquires its
rowset under the same model mutex, mirrors the main window's time window (or its pin) onto it
every frame, and pumps its render — so the two windows never disagree about the data, the display
mode or the span. **A signal badge dragged onto a scope window's plot lands there** — the same
drop the main plot takes, into that window's own row set, on the cell the pointer is over — and
the signal page's *Plot on* buttons list **every window**: one group per window (named, `In
"plot 2"`), one button per plot of it.
mode or the span.
**The window.** The plot is the window. Under it sits a fixed-height **badge bar** (36 px). Its left
+8 -1
View File
@@ -357,7 +357,14 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
(same display mode, same epoch), and its span follows the main window's window or pin every frame. It is
therefore a second *view* -- the settle rule is that a scope must never acquire, plan or epoch anything of
its own. Closing the main window destroys its scopes first: a leftover toplevel keeps the GApplication's
loop alive around a window that is gone (the exit run's hang).
loop alive around a window that is gone (the exit run's hang). What a user *arranges* in a scope is the
scope's own arrangement, and it takes the same two doors the main window's rows take: a badge **dropped on
a scope window's plot** lands in that window's row set on the cell under the pointer (its own drop target,
its own `assign_signal_to_scope()`), and the signal page's *Plot on* section carries **one named group per
window** -- rebuilt out of `App::scopes` on every fill, and again when a window closes (the destroy handler
refreshes the pages before the deferred delete can dangle a button's scope pointer). The change itself is
never applied synchronously: a drop arrives from inside the very widget it rearranges, so the rebuild and
the area's re-point are the window's next frame in the tick.
- **A figure row's full-screen is one click from two places, and never from one figure.** The
toggle sits on the row's top-right corner, **out of hiding only while the pointer is near that
+13 -1
View File
@@ -1453,10 +1453,14 @@ places), arranged as two single-cell rows with one signal each. The two reads af
`UDPSCOPE_PROBE_SCOPE_MS`. The **second window**: `open_scope_window()` twice — once copying
the main window's figure 0, once empty — and read back a few frames later (the main tick is the
scope's bind/acquire/repaint pump). Measured output:
scope's bind/acquire/repaint pump). **Do not run it with `UDPSCOPE_ENABLE_ALL_MS`**: the
enable-all probe's second leg, two seconds in, clears the front plot through the signal page --
exactly the slot this probe copies -- so `copied=''` under that combination is that race and not
a defect in the copy. Measured output:
```
[scope-probe] count=2 copied='sine' bound=1 empty_ok=1 windows=1
[scope-probe] drop=1 slot1='square' page_groups=2
```
- `count=2` is both windows having been created and retained; `windows=1` each being a visible
@@ -1466,6 +1470,14 @@ scope's bind/acquire/repaint pump). Measured output:
model, not a snapshot taken at creation.
- `empty_ok=1` is the `-1` call producing an empty row, which is the other entry point the
overflow menu uses.
- 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
the window's next frame; `page_groups=2` is the signal page of an unplotted signal carrying
**one *Plot on* group per window** (`udpscope-scope-plot-on`), so a signal can be plotted
into a scope window without dragging it there. `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.
## `[search-probe]`
+5 -2
View File
@@ -81,8 +81,11 @@
<item><p>A plot's right-click menu has <gui>Open this plot in a new window</gui>: a second
window showing the same stream through a copy of that figure (its cells, their widths and
every trace), with its own axes following the main window's time window. The overflow menu's
<gui>New scope window</gui> opens the same kind of window with an empty figure. Closing one
closes that view only; the main window keeps acquiring.</p></item>
<gui>New scope window</gui> opens the same kind of window with an empty figure. A signal
badge dragged onto a window's plot lands in that window (on the plot under the pointer), and
the signal page's <gui>Plot on</gui> section lists every window — one named group per
window, one button per plot of it. Closing one closes that view only; the main window keeps
acquiring.</p></item>
</list>
</section>
+20
View File
@@ -611,6 +611,16 @@ void open_scope_window(App& app, int figure_row) {
gtk_widget_set_hexpand(scope->widget, true);
gtk_widget_set_vexpand(scope->widget, true);
gtk_widget_set_size_request(scope->widget, 320, 160);
// The window's short name, what the signal page's *Plot on* groups carry: "plot N" for a copy
// (of the main window's figure N), "scope N" -- numbered app-wide -- for an empty one.
scope->name = figure_row >= 0 ? "plot " + std::to_string(figure_row + 1)
: "scope " + std::to_string(++app.scope_seq);
// A drop target like the main area's: a signal badge dragged onto this window's plot lands in
// that window's own row set, on the cell the pointer is over.
scope->drop = gtk_drop_target_new(G_TYPE_STRING, GDK_ACTION_COPY);
g_object_set_data(G_OBJECT(scope->drop), "udpscope-scope", scope.get());
g_signal_connect(scope->drop, "drop", G_CALLBACK(on_scope_drop_channel), &app);
gtk_widget_add_controller(scope->widget, GTK_EVENT_CONTROLLER(scope->drop));
scope->window = GTK_WINDOW(gtk_window_new());
const std::string title = figure_row >= 0
? "udpscope \u2014 plot " + std::to_string(figure_row + 1)
@@ -640,6 +650,10 @@ void open_scope_window(App& app, int figure_row) {
victim, nullptr);
break;
}
// The signal page's *Plot on* groups carry a button per window: with one gone,
// the pages are rebuilt before anything can read a dangling scope pointer
// (the struct itself is deleted on the idle above).
signal_pages_sync(*a);
}),
&app);
gtk_window_present(scope->window);
@@ -4235,6 +4249,12 @@ gboolean tick(gpointer user) {
} 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.
if (scope->rows->rebuild_if_dirty() && scope->area != nullptr) {
scope->area->set_figure(&scope->rows->figure());
}
if (scope->widget != nullptr) gtk_gl_area_queue_render(GTK_GL_AREA(scope->widget));
}
}
+15
View File
@@ -215,6 +215,12 @@ struct App {
GtkWindow* window = nullptr;
plot::gtk4::PlotArea* area = nullptr;
GtkWidget* widget = nullptr;
/// The window's short name, the one the signal page's *Plot on* groups carry ("plot 2" for a
/// copy, "scope 3" for an empty one): a button must say which window it plots into.
std::string name;
/// The drop target wired onto `widget`: the handler reads the *window* off it (each window's
/// target names its own scope), and the probe calls the handler with it directly.
GtkDropTarget* drop = nullptr;
/// The area's initial plot, owned here: `PlotArea::create()` wants a `Plot&` that
/// outlives the widget, and sharing the main window's placeholder would hand two areas
/// one plot the moment the figure mode is ever left.
@@ -222,6 +228,8 @@ struct App {
std::unique_ptr<RowSet> rows;
};
std::vector<std::unique_ptr<ScopeWindow>> scopes;
/// Every empty scope window is numbered from this, so two of them never share a name.
int scope_seq = 0;
/// On while the layout editor is up. Nothing else about the plot changes: the editor is
/// a set of overlay buttons, not a mode the figure knows about.
@@ -2038,6 +2046,13 @@ void row_menu_fill(RowMenuState& st);
GtkWidget* open_row_menu(App& app, int row, double x, double y, GtkWidget* parent, bool autohide = true);
void on_right_click(GtkGestureClick* gesture, int, double x, double y, gpointer user);
gboolean on_drop_channel(GtkDropTarget*, const GValue* value, double x, double y, gpointer user);
/// The same drop, onto a **scope window's** plot: the target carries the window, the row is the
/// window's own cell under the pointer, and the assignment lands in that window's row set.
gboolean on_scope_drop_channel(GtkDropTarget*, const GValue* value, double x, double y, gpointer user);
/// Plots `signal` on `row` of a scope window's row set (the signal page's *Plot on* buttons and the
/// drop target both end here). False, with a notice, when the signal cannot be acquired or the plot
/// holds four signals already.
bool assign_signal_to_scope(App& app, App::ScopeWindow& scope, std::size_t signal, int row);
/// Reads each pane's own switch back off the header bar's button, and the other way round: the two are
/// one control in two places, and `panel_switch_sync` keeps the write from looking like a click.
void sync_panel_switches(App& app);
+38
View File
@@ -1736,6 +1736,19 @@ gboolean maximize_probe_step(gpointer user) {
/// bound to the same stream a few frames later, while the empty one stays empty. The window
/// creation itself (a real `GtkWindow`, an own `PlotArea`, its own `RowSet`) is exercised by
/// the same call the two menu entries make.
/// How many descendants carry `name` -- the scope window's *Plot on* groups are counted, one per
/// window, where a single `find_named` would stop at the first.
int count_named(GtkWidget* root, const char* name) {
int n = 0;
if (root == nullptr) return 0;
if (strcmp(gtk_widget_get_name(root), name) == 0) ++n;
for (GtkWidget* c = gtk_widget_get_first_child(root); c != nullptr;
c = gtk_widget_get_next_sibling(c)) {
n += count_named(c, name);
}
return n;
}
gboolean scope_probe_step(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
@@ -1786,6 +1799,31 @@ gboolean scope_probe_step(gpointer user) {
std::fprintf(stderr,
"[scope-probe] count=2 copied='%s' bound=%d empty_ok=%d windows=%d\n",
copied.c_str(), copied_bound ? 1 : 0, empty_ok ? 1 : 0, windows ? 1 : 0);
// **A drop onto a window**: the handler the drop target wires, called directly with a
// cell-0 coordinate. The copied window holds 'sine' in slot 0, so the dropped signal must
// land in its second slot (the rebuild and the area's re-point are the window's next frame).
std::size_t square = std::size_t(-1);
for (std::size_t i = 0; i < a->model->signals().size(); ++i) {
if (a->model->signals()[i].name == "square") square = i;
}
GValue payload = G_VALUE_INIT;
g_value_init(&payload, G_TYPE_STRING);
g_value_set_string(&payload, std::to_string(square).c_str());
const gboolean dropped = square != std::size_t(-1) && copy.drop != nullptr
? on_scope_drop_channel(copy.drop, &payload, 40.0, 20.0, a)
: FALSE;
g_value_unset(&payload);
const std::string slot1 = copy.rows->row(0).signal_name[1];
// **The signal page lists the windows**: one *Plot on* group per window, in the page of a
// signal that is plotted nowhere (so every button is live).
signal_page_ensure(*a, "ramp");
signal_pages_sync(*a);
int groups = 0;
const auto page_it = a->signal_pages.find("ramp");
if (page_it != a->signal_pages.end()) groups = count_named(page_it->second, "udpscope-scope-plot-on");
std::fprintf(stderr,
"[scope-probe] drop=%d slot1='%s' page_groups=%d\n",
dropped ? 1 : 0, slot1.c_str(), groups);
std::fflush(stderr);
return G_SOURCE_REMOVE;
}
+42
View File
@@ -288,6 +288,48 @@ void fill(App& app, const std::string& name, GtkWidget* page) {
&app);
}
gtk_box_append(GTK_BOX(page), rows_box);
// The scope windows: each is a view with its own arrangement, and a signal is plotted there
// exactly as it is here -- one group per window (named, so two windows never blur into one),
// one button per plot of that window's figure. The buttons read *that* window's rows for
// "already on this plot", and a click lands in the window's own row set.
for (const std::unique_ptr<App::ScopeWindow>& scope : app.scopes) {
if (scope->rows == nullptr || scope->rows->row_count() <= 0) continue;
GtkWidget* scope_head = gtk_label_new(("In \"" + scope->name + "\"").c_str());
gtk_widget_add_css_class(scope_head, "heading");
gtk_widget_set_halign(scope_head, GTK_ALIGN_START);
gtk_box_append(GTK_BOX(page), scope_head);
GtkWidget* scope_box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 4);
gtk_widget_set_name(scope_box, "udpscope-scope-plot-on");
for (int r = 0; r < scope->rows->row_count(); ++r) {
GtkWidget* b = text_button(scope->rows->plot_label(r).c_str(), scope_box);
tag(b, index);
g_object_set_data(G_OBJECT(b), "udpscope-row", GINT_TO_POINTER(r));
g_object_set_data(G_OBJECT(b), "udpscope-scope", scope.get());
bool on_this_row = false;
for (int slot = 0; slot < RowSet::kSlotsPerRow; ++slot) {
if (scope->rows->row(r).signal_name[static_cast<std::size_t>(slot)] == sig.name) {
on_this_row = true;
}
}
gtk_widget_set_sensitive(b, !on_this_row);
gtk_widget_set_tooltip_text(b, on_this_row ? "already on this plot"
: "draw this signal in this window's plot");
g_signal_connect(b, "clicked", G_CALLBACK(+[](GtkButton* b, gpointer user) {
App* a = static_cast<App*>(user);
auto* scope = static_cast<App::ScopeWindow*>(
g_object_get_data(G_OBJECT(b), "udpscope-scope"));
if (scope == nullptr) return;
outlive_the_rebuild(GTK_WIDGET(b));
assign_signal_to_scope(*a, *scope, tagged(b),
GPOINTER_TO_INT(g_object_get_data(G_OBJECT(b),
"udpscope-row")));
}),
&app);
}
gtk_box_append(GTK_BOX(page), scope_box);
}
GtkWidget* off = text_button("Remove from plots", page);
gtk_widget_set_halign(off, GTK_ALIGN_START);
gtk_widget_set_sensitive(off, plotted);
+86 -17
View File
@@ -505,24 +505,26 @@ void rebuild_signal_list(App& app, const std::vector<SignalInfo>& signals) {
app.updating_panels = false;
}
bool assign_signal_to_row(App& app, std::size_t signal, int row) {
if (signal >= app.model->signals().size()) return false;
const auto notice = [&](const std::string& what) {
set_notice(app, what, 4);
std::fprintf(stderr, "[udpscope] drop: %s\n", what.c_str());
std::fflush(stderr);
};
/// The drop's one-line report, wherever the drop landed: the notice bar and the log, because a
/// refusal a reader cannot act on is only half an answer.
void drop_notice(App& app, const std::string& what) {
set_notice(app, what, 4);
std::fprintf(stderr, "[udpscope] drop: %s\n", what.c_str());
std::fflush(stderr);
}
/// Resolves a dropped signal to an acquired channel, enabling the signal when it has to be: the
/// channel index and its display name, or -1 when the signal cannot be acquired (a multi-element
/// signal has no channel to bind). Shared by the main window's drop and the scope windows'.
int resolve_drop_channel(App& app, std::size_t signal, std::string* name) {
// The dropped signal's channels are the ones the model built for it; take the
// first, or the first element if the signal is a vector.
const std::vector<ChannelInfo>& channels = app.model->channels();
int channel = -1;
std::string name;
for (std::size_t i = 0; i < channels.size(); ++i) {
if (channels[i].signal == signal) {
channel = static_cast<int>(i);
name = channels[i].name;
*name = channels[i].name;
break;
}
}
@@ -533,14 +535,22 @@ bool assign_signal_to_row(App& app, std::size_t signal, int row) {
for (std::size_t i = 0; i < again.size(); ++i) {
if (again[i].signal == signal) {
channel = static_cast<int>(i);
name = again[i].name;
*name = again[i].name;
break;
}
}
}
return channel;
}
bool assign_signal_to_row(App& app, std::size_t signal, int row) {
if (signal >= app.model->signals().size()) return false;
std::string name;
const int channel = resolve_drop_channel(app, signal, &name);
if (channel < 0) {
notice("cannot acquire '" + app.model->signals()[signal].name +
"' (multi-element signals are not acquired)");
drop_notice(app, "cannot acquire '" + app.model->signals()[signal].name +
"' (multi-element signals are not acquired)");
return false;
}
@@ -554,9 +564,9 @@ bool assign_signal_to_row(App& app, std::size_t signal, int row) {
if (slot < 0) {
// Say what to do about it. A refusal a reader cannot act on is only half an answer: the two ways
// out are another plot or another row.
notice("plot " + app.rows->plot_label(row) +
" holds four signals already: drop it on another plot, or add a row with the layout "
"editor (L)");
drop_notice(app, "plot " + app.rows->plot_label(row) +
" holds four signals already: drop it on another plot, or add a row with "
"the layout editor (L)");
return false;
}
// The slot remembers the *signal* (and element), never the channel index: the
@@ -567,10 +577,69 @@ bool assign_signal_to_row(App& app, std::size_t signal, int row) {
schedule_layout_save(app);
app.relayout_pending = true;
rebuild_signal_panels(app); // the row's colour chip now has a trace to name
notice("plotting '" + name + "' on plot " + app.rows->plot_label(row));
drop_notice(app, "plotting '" + name + "' on plot " + app.rows->plot_label(row));
return true;
}
bool assign_signal_to_scope(App& app, App::ScopeWindow& scope, std::size_t signal, int row) {
if (scope.rows == nullptr || signal >= app.model->signals().size()) return false;
if (row < 0 || row >= scope.rows->row_count()) return false;
std::string name;
const int channel = resolve_drop_channel(app, signal, &name);
if (channel < 0) {
drop_notice(app, "cannot acquire '" + app.model->signals()[signal].name +
"' (multi-element signals are not acquired)");
return false;
}
int slot = -1;
for (int s = 0; s < RowSet::kSlotsPerRow; ++s) {
if (!scope.rows->slot_bound(row, s)) {
slot = s;
break;
}
}
if (slot < 0) {
drop_notice(app, scope.name + " plot " + scope.rows->plot_label(row) + " holds four signals "
"already: drop it on another plot of that window");
return false;
}
// The slot remembers the *signal* (and element), never the channel index -- the same rule the
// main window's rows follow, for the same renumbering reason.
const std::vector<SignalInfo>& signals = app.model->signals();
const SignalInfo& sig = signals[signal];
scope.rows->assign(row, slot, sig.name, name, signal_color_index(signals, signal));
// The rebuild and the area's re-point are the window's own frame tick's (a drop arrives from
// inside this widget, and a synchronous rebuild would reset the drag controller under it). The
// signal page is refreshed now: its *Plot on* buttons read the scope's rows for "already on".
signal_pages_sync(app);
drop_notice(app, "plotting '" + name + "' on " + scope.name + " plot " +
scope.rows->plot_label(row));
return true;
}
gboolean on_scope_drop_channel(GtkDropTarget* target, const GValue* value, double x, double y,
gpointer user) {
App* app = static_cast<App*>(user);
auto* scope =
static_cast<App::ScopeWindow*>(g_object_get_data(G_OBJECT(target), "udpscope-scope"));
if (scope == nullptr || scope->area == nullptr || scope->rows == nullptr) return FALSE;
if (!G_VALUE_HOLDS_STRING(value)) return FALSE;
const char* text = g_value_get_string(value);
if (text == nullptr || *text == '\0') return FALSE;
char* end = nullptr;
const unsigned long long parsed = std::strtoull(text, &end, 10);
if (end == nullptr || *end != '\0') return FALSE;
const std::size_t signal = static_cast<std::size_t>(parsed);
// Where the drop landed decides the row *of this window*: one area covers its figure's cells.
const int row = std::clamp(static_cast<int>(scope->area->row_at(x, y)), 0,
scope->rows->row_count() - 1);
if (!assign_signal_to_scope(*app, *scope, signal, row)) return FALSE;
if (plot::input::Controller* c = scope->area->row_controller(static_cast<plot::u32>(row))) {
c->on_key(plot::input::KeyEvent{plot::input::kKeyEscape, 0, true});
}
return TRUE;
}
gboolean on_drop_channel(GtkDropTarget*, const GValue* value, double x, double y,
gpointer user) {
App* app = static_cast<App*>(user);