scope windows: a right-click menu of their own

A scope window is a view with its own arrangement, but its plot had no menu: every right-click
went nowhere, because the main window's row menu acts on the main window's rows.

`on_scope_right_click()` wires each scope window's GL area (beside its drop target) to a menu
that acts on **that window's** row set: y axis auto scale, y axis fixed range (the main menu's
dialog, aimed at the window's rows through `FixedRangeContext::rows`), fit, and one
*Remove '<signal>'* per trace on the plot. A detached window's menu also carries **Attach back
to the main window**. What it deliberately does not carry is an x fit: the window follows the
main window's time span, so an x fit here would be undone by the next frame's mirror.

`remove_trace()` and `FixedRangeContext` are generalized to take the row set they act on (the
main window passes its own). Probe: `[scope-probe] rclick=1` asserts the click gesture is wired
per window; the menu build is exercised by the real right-clicks the smoke makes.
This commit is contained in:
Martino Ferrari committed 2026-11-06 08:55:29 +01:00
1 parent b90a99ec39
commit 2924f7627d
5 files changed
+205 -10

No files matched your search

+173 -9
View File
@@ -621,6 +621,12 @@ void open_scope_window(App& app, int figure_row, bool detached) {
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));
// The window's own right-click menu: its y scales, its traces, its removals -- the same
// actions the main window's plot menu carries, aimed at **this window's** row set.
GtkGesture* right = gtk_gesture_click_new();
gtk_gesture_single_set_button(GTK_GESTURE_SINGLE(right), 3);
g_signal_connect(right, "pressed", G_CALLBACK(on_scope_right_click), &app);
gtk_widget_add_controller(scope->widget, GTK_EVENT_CONTROLLER(right));
scope->detached = detached;
scope->window = GTK_WINDOW(gtk_window_new());
std::string title = figure_row >= 0
@@ -3338,8 +3344,9 @@ void on_y_auto_clicked(GtkButton* button, gpointer user) {
void on_fixed_range_apply(GtkButton*, gpointer user) {
FixedRangeContext* ctx = static_cast<FixedRangeContext*>(user);
ctx->app->rows->set_y_range(ctx->row, gtk_spin_button_get_value(GTK_SPIN_BUTTON(ctx->lo)),
gtk_spin_button_get_value(GTK_SPIN_BUTTON(ctx->hi)));
RowSet& rows = ctx->rows != nullptr ? *ctx->rows : *ctx->app->rows;
rows.set_y_range(ctx->row, gtk_spin_button_get_value(GTK_SPIN_BUTTON(ctx->lo)),
gtk_spin_button_get_value(GTK_SPIN_BUTTON(ctx->hi)));
gtk_window_destroy(GTK_WINDOW(ctx->window));
}
@@ -3354,7 +3361,7 @@ void on_y_fixed_clicked(GtkButton* button, gpointer user) {
gtk_box_append(GTK_BOX(box), labeled_box("min", lo));
gtk_box_append(GTK_BOX(box), labeled_box("max", hi));
auto* ctx = new FixedRangeContext{app, row, lo, hi, window};
auto* ctx = new FixedRangeContext{app, nullptr, row, lo, hi, window};
GtkWidget* cancel = gtk_button_new_with_label("Cancel");
g_signal_connect_swapped(cancel, "clicked", G_CALLBACK(gtk_window_destroy), window);
GtkWidget* apply = gtk_button_new_with_label("Apply");
@@ -3374,9 +3381,9 @@ void on_y_fixed_clicked(GtkButton* button, gpointer user) {
/// Frees one trace slot of a row. All it takes is the free: the row is rebuilt with a series that is
/// nothing, so it draws nothing and leaves the legend.
void remove_trace(App& app, int row, int slot) {
if (row < 0 || row >= app.rows->row_count()) return;
app.rows->assign(row, slot, std::string(), std::string(), 0);
void remove_trace(RowSet& rows, int row, int slot) {
if (row < 0 || row >= rows.row_count()) return;
rows.assign(row, slot, std::string(), std::string(), 0);
}
/// Disables every math channel that (transitively) reads `name`, and answers with their names.
@@ -3429,7 +3436,7 @@ int remove_signal_from_plots(App& app, const std::string& name) {
// The slot remembers the *signal* (and its element), never the channel index -- the channel list is
// renumbered on every enable change, which is what this is reacting to.
if (app.rows->row(row).signal_name[static_cast<std::size_t>(slot)] != name) continue;
remove_trace(app, row, slot);
remove_trace(*app.rows, row, slot);
++removed;
}
}
@@ -3448,14 +3455,14 @@ void on_remove_clicked(GtkButton* button, gpointer user) {
int removed = 0;
for (int s = 0; s < RowSet::kSlotsPerRow; ++s) {
if (r.channel[static_cast<std::size_t>(s)] < 0) continue;
remove_trace(app, target->row, s);
remove_trace(*target->app->rows, target->row, s);
++removed;
}
what = "removed " + std::to_string(removed) + (removed == 1 ? " trace" : " traces") +
" from plot " + app.rows->plot_label(target->row);
} else {
const std::string name = r.name[static_cast<std::size_t>(target->slot)];
remove_trace(app, target->row, target->slot);
remove_trace(*target->app->rows, target->row, target->slot);
what = "removed '" + name + "' from plot " + app.rows->plot_label(target->row);
}
@@ -3656,6 +3663,163 @@ void on_right_click(GtkGestureClick* gesture, int, double x, double y, gpointer
open_row_menu(*app, row, x, y, widget);
}
/// Scope-menu helpers: the rows carry their window, their plot and their slot, the popover to
/// dismiss, and the handlers are free functions -- a menu row's handler is a plain callback, not a
/// lambda, whose commas would split the `G_CALLBACK` macro into arguments.
App::ScopeWindow* scope_menu_scope(GtkButton* b) {
return static_cast<App::ScopeWindow*>(g_object_get_data(G_OBJECT(b), "udpscope-scope"));
}
int scope_menu_row(GtkButton* b) {
return GPOINTER_TO_INT(g_object_get_data(G_OBJECT(b), "udpscope-row"));
}
void scope_menu_dismiss(GtkButton* b) {
GtkPopover* p = GTK_POPOVER(g_object_get_data(G_OBJECT(b), "udpscope-popover"));
if (p != nullptr) gtk_popover_popdown(p);
}
void on_scope_y_auto_clicked(GtkButton* b, gpointer) {
App::ScopeWindow* scope = scope_menu_scope(b);
if (scope == nullptr || scope->rows == nullptr) return;
scope->rows->set_y_auto(scope_menu_row(b), true);
scope_menu_dismiss(b);
}
/// The main menu's fixed-range dialog, aimed at **this window's** rows: the dialog's Apply
/// writes the row set the context names, and the context carries the scope's.
void on_scope_y_fixed_clicked(GtkButton* b, gpointer user) {
App* a = static_cast<App*>(user);
App::ScopeWindow* scope = scope_menu_scope(b);
const int row = scope_menu_row(b);
if (scope == nullptr || scope->rows == nullptr || scope->rows->row(row).plot == nullptr) return;
GtkWidget* window = dialog_window(a->window, "Fixed Y range");
GtkWidget* box = gtk_window_get_child(GTK_WINDOW(window));
const plot::Range cur = scope->rows->row(row).plot->y_range(0);
GtkWidget* lo = spin(-1.0e12, 1.0e12, 0.1, 6, cur.min);
GtkWidget* hi = spin(-1.0e12, 1.0e12, 0.1, 6, cur.max);
gtk_box_append(GTK_BOX(box), labeled_box("min", lo));
gtk_box_append(GTK_BOX(box), labeled_box("max", hi));
auto* ctx = new FixedRangeContext{a, scope->rows.get(), row, lo, hi, window};
GtkWidget* cancel = gtk_button_new_with_label("Cancel");
g_signal_connect_swapped(cancel, "clicked", G_CALLBACK(gtk_window_destroy), window);
GtkWidget* apply = gtk_button_new_with_label("Apply");
g_object_set_data_full(G_OBJECT(apply), "udpscope-ctx", ctx,
[](gpointer p) { delete static_cast<FixedRangeContext*>(p); });
g_signal_connect(apply, "clicked", G_CALLBACK(on_fixed_range_apply), ctx);
dialog_add_button(window, cancel);
dialog_add_button(window, apply);
gtk_window_present(GTK_WINDOW(window));
scope_menu_dismiss(b);
}
/// The y fit of a scope window: the window's x follows the main window's span, so the fit is the
/// y axes alone -- `fit_all()`'s x reset would be undone by the next frame's mirror.
void on_scope_fit_clicked(GtkButton* b, gpointer) {
App::ScopeWindow* scope = scope_menu_scope(b);
if (scope == nullptr || scope->rows == nullptr) return;
scope->rows->set_y_auto(scope_menu_row(b), true);
scope_menu_dismiss(b);
}
void on_scope_remove_clicked(GtkButton* b, gpointer) {
App::ScopeWindow* scope = scope_menu_scope(b);
if (scope == nullptr || scope->rows == nullptr) return;
remove_trace(*scope->rows, scope_menu_row(b),
GPOINTER_TO_INT(g_object_get_data(G_OBJECT(b), "udpscope-slot")));
scope_menu_dismiss(b);
}
void on_scope_attach_menu_clicked(GtkButton* b, gpointer user) {
App& a = *static_cast<App*>(user);
App::ScopeWindow* scope = scope_menu_scope(b);
if (scope == nullptr) return;
outlive_the_rebuild(GTK_WIDGET(b));
attach_scope_window(a, *scope);
}
/// A scope window's own right-click menu. A scope window is a *view with its own arrangement*:
/// its y scales, its traces and its removals are its own, so the menu acts on **that window's**
/// row set -- the same rows the drop target and the signal page's *Plot on* groups write. What it
/// deliberately does not carry is an x fit: the window follows the main window's time span, so an
/// x fit here would be undone by the next frame.
void on_scope_right_click(GtkGestureClick* gesture, int, double x, double y, gpointer user) {
App* app = static_cast<App*>(user);
GtkWidget* widget = gtk_event_controller_get_widget(GTK_EVENT_CONTROLLER(gesture));
App::ScopeWindow* scope = nullptr;
for (const std::unique_ptr<App::ScopeWindow>& s : app->scopes) {
if (s->widget == widget) scope = s.get();
}
if (scope == nullptr || scope->rows == nullptr || scope->rows->row_count() <= 0) return;
const int row = std::clamp(static_cast<int>(scope->area->row_at(x, y)), 0,
scope->rows->row_count() - 1);
gtk_gesture_set_state(GTK_GESTURE(gesture), GTK_EVENT_SEQUENCE_CLAIMED);
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});
}
GtkWidget* popover = gtk_popover_new();
gtk_widget_add_css_class(popover, "udpscope-menu");
GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
gtk_widget_set_margin_top(box, 6);
gtk_widget_set_margin_bottom(box, 6);
gtk_widget_set_margin_start(box, 6);
gtk_widget_set_margin_end(box, 6);
const auto row_btn = [&](const char* label) {
GtkWidget* b = gtk_button_new_with_label(label);
gtk_widget_add_css_class(b, "flat");
gtk_widget_set_halign(b, GTK_ALIGN_FILL);
g_object_set_data(G_OBJECT(b), "udpscope-popover", popover);
g_object_set_data(G_OBJECT(b), "udpscope-scope", scope);
g_object_set_data(G_OBJECT(b), "udpscope-row", GINT_TO_POINTER(row));
gtk_box_append(GTK_BOX(box), b);
return b;
};
char markup[64];
std::snprintf(markup, sizeof(markup), "<b>Plot %s</b>", scope->rows->plot_label(row).c_str());
GtkWidget* heading = gtk_label_new(nullptr);
gtk_label_set_markup(GTK_LABEL(heading), markup);
gtk_widget_set_halign(heading, GTK_ALIGN_START);
gtk_box_append(GTK_BOX(box), heading);
GtkWidget* y_auto = row_btn("Y axis: auto scale");
g_signal_connect(y_auto, "clicked", G_CALLBACK(on_scope_y_auto_clicked), user);
GtkWidget* y_fixed = row_btn("Y axis: fixed range...");
g_signal_connect(y_fixed, "clicked", G_CALLBACK(on_scope_y_fixed_clicked), user);
GtkWidget* fit = row_btn("Fit Y axis");
g_signal_connect(fit, "clicked", G_CALLBACK(on_scope_fit_clicked), user);
// One removal per trace on the plot, flat rather than as a second view: a scope window holds
// at most its own figure's traces, and the menu is small.
bool traces = false;
for (int s = 0; s < RowSet::kSlotsPerRow; ++s) {
const std::size_t si = static_cast<std::size_t>(s);
const std::string name = scope->rows->row(row).signal_name[si];
if (name.empty()) continue;
traces = true;
GtkWidget* rm = row_btn(("Remove '" + name + "'").c_str());
g_object_set_data(G_OBJECT(rm), "udpscope-slot", GINT_TO_POINTER(s));
g_signal_connect(rm, "clicked", G_CALLBACK(on_scope_remove_clicked), user);
}
if (!traces) {
GtkWidget* none = gtk_label_new("no traces on this plot");
gtk_widget_add_css_class(none, "dim-label");
gtk_widget_set_halign(none, GTK_ALIGN_START);
gtk_box_append(GTK_BOX(box), none);
}
if (scope->detached) {
GtkWidget* back = row_btn("Attach back to the main window");
g_signal_connect(back, "clicked", G_CALLBACK(on_scope_attach_menu_clicked), user);
}
gtk_popover_set_child(GTK_POPOVER(popover), box);
GdkRectangle rect{static_cast<int>(x), static_cast<int>(y), 1, 1};
gtk_popover_set_pointing_to(GTK_POPOVER(popover), &rect);
gtk_widget_set_parent(popover, widget);
gtk_popover_popup(GTK_POPOVER(popover));
g_signal_connect(popover, "closed", G_CALLBACK(on_popover_closed), nullptr);
}
/// The availability of everything that does nothing until the stream has signals: the dock and the two
/// slide-over pages that were once in it (Cursors, Measurements). "Disable, do not hide": a page may be
/// opened by its key before the first CONFIG, and shows its controls insensitive until then. The signal