From 51b4fad386398590a47ea78bb3444d2127f83962 Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Thu, 5 Nov 2026 19:37:42 +0100 Subject: [PATCH] scope windows take drops, and the signal page lists them The second windows were read-only by accident: nothing accepted a drop on them, and the signal page's *Plot on* buttons knew only the main window's rows. - **Drop target per scope window**: the same string payload a badge drags (the signal index), handled by `on_scope_drop_channel()` -- the target carries its own window, the row is that window's cell under the pointer, and `assign_signal_to_scope()` lands the signal in the window's own row set (enabling an unacquired signal first, exactly as the main drop does). The rebuild and the area's re-point are the window's next frame in the tick: a drop arrives from inside the very widget it rearranges, and a synchronous rebuild would reset the drag controller under it. - **Plot on, per window**: the signal page carries one named group per scope window ("In \"plot 2\""), one button per plot of that window, sensitive on that window's own rows. A window closing refreshes the pages in its destroy handler, before the deferred delete could dangle a button's scope pointer. - Probe: `[scope-probe]` drops through the wired handler (`drop=1 slot1='square'`) and counts the page's groups (`page_groups=2`); the section documents that it must not run with `UDPSCOPE_ENABLE_ALL_MS` -- the enable-all probe's second leg clears the front plot, which is the slot this probe copies. README, help and the layout invariant follow. --- README.md | 4 + docs/claude/invariants-plot-and-layout.md | 9 +- docs/claude/probe-readings.md | 14 ++- help/C/org.udpscope.app/layout.page | 7 +- src/ui.cpp | 20 +++++ src/ui_app.hpp | 15 ++++ src/ui_probe.cpp | 38 ++++++++ src/ui_signal_page.cpp | 42 +++++++++ src/ui_signals.cpp | 103 ++++++++++++++++++---- 9 files changed, 231 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index ba0bf65..346dd7e 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/claude/invariants-plot-and-layout.md b/docs/claude/invariants-plot-and-layout.md index ff1521b..dd8ac99 100644 --- a/docs/claude/invariants-plot-and-layout.md +++ b/docs/claude/invariants-plot-and-layout.md @@ -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 diff --git a/docs/claude/probe-readings.md b/docs/claude/probe-readings.md index e778b3c..b779a23 100644 --- a/docs/claude/probe-readings.md +++ b/docs/claude/probe-readings.md @@ -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]` diff --git a/help/C/org.udpscope.app/layout.page b/help/C/org.udpscope.app/layout.page index 388989b..6624736 100644 --- a/help/C/org.udpscope.app/layout.page +++ b/help/C/org.udpscope.app/layout.page @@ -81,8 +81,11 @@

A plot's right-click menu has Open this plot in a new window: 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 - New scope window opens the same kind of window with an empty figure. Closing one - closes that view only; the main window keeps acquiring.

+ New scope window 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 Plot on 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.

diff --git a/src/ui.cpp b/src/ui.cpp index 9997e1f..d2b172a 100644 --- a/src/ui.cpp +++ b/src/ui.cpp @@ -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)); } } diff --git a/src/ui_app.hpp b/src/ui_app.hpp index 73c6ec0..6221c0c 100644 --- a/src/ui_app.hpp +++ b/src/ui_app.hpp @@ -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 rows; }; std::vector> 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); diff --git a/src/ui_probe.cpp b/src/ui_probe.cpp index 3d83ba7..2af228b 100644 --- a/src/ui_probe.cpp +++ b/src/ui_probe.cpp @@ -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(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; } diff --git a/src/ui_signal_page.cpp b/src/ui_signal_page.cpp index f1a78e3..a8350fe 100644 --- a/src/ui_signal_page.cpp +++ b/src/ui_signal_page.cpp @@ -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& 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(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(user); + auto* scope = static_cast( + 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); diff --git a/src/ui_signals.cpp b/src/ui_signals.cpp index d87a8b9..10d82e8 100644 --- a/src/ui_signals.cpp +++ b/src/ui_signals.cpp @@ -505,24 +505,26 @@ void rebuild_signal_list(App& app, const std::vector& 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& 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(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(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& 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(user); + auto* scope = + static_cast(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(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(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(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(user);