diff --git a/README.md b/README.md index 1133c26..65a520b 100644 --- a/README.md +++ b/README.md @@ -145,7 +145,9 @@ every frame, and pumps its render — so the two windows never disagree about th 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. +"plot 2"`), one button per plot of it. A right-click on a scope window's plot opens **its own +menu** — y axis (auto or a fixed range), fit, remove a trace — acting on that window's rows; a +detached window's menu carries **Attach back to the main window** beside them. 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/probe-readings.md b/docs/claude/probe-readings.md index 158a332..43f3b76 100644 --- a/docs/claude/probe-readings.md +++ b/docs/claude/probe-readings.md @@ -1473,6 +1473,7 @@ a defect in the copy. Measured output: [scope-probe] detach=1 scopes=3 frows=1 attach=1 scopes_back=2 frows_back=2 slot='ramp' [scope-probe] gl copy=ok(67f) empty=ok(39f) empty_bound=1 [scope-probe] gl detached ok=1 frames=42 rows=1 slot0='sine' +[scope-probe] rclick=1 ``` - `count=2` is both windows having been created and retained; `windows=1` each being a visible @@ -1492,6 +1493,9 @@ a defect in the copy. Measured output: signal into it" — landing in its row set. `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. +- `rclick=1` is the scope window's **own right-click menu** being wired (one click gesture with + button 3 on its GL area, beside the drop target): the menu's actions act on that window's row + set. - The fourth line is the windows' **GL account** (`gl copy=`/`gl empty=`): `ok(Nf)` is a context that staged N frames, `err:` carries the adapter's failure message. A scope whose context failed draws *nothing* — no axes, no traces, exactly the "blank plot widget" report — so diff --git a/src/ui.cpp b/src/ui.cpp index 7b8d818..c0308a5 100644 --- a/src/ui.cpp +++ b/src/ui.cpp @@ -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(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(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(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(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(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(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(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(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(user); + GtkWidget* widget = gtk_event_controller_get_widget(GTK_EVENT_CONTROLLER(gesture)); + App::ScopeWindow* scope = nullptr; + for (const std::unique_ptr& 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(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(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), "Plot %s", 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(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(x), static_cast(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 diff --git a/src/ui_app.hpp b/src/ui_app.hpp index d2a3ac1..7d67ad0 100644 --- a/src/ui_app.hpp +++ b/src/ui_app.hpp @@ -1652,6 +1652,9 @@ struct ExportContext { }; struct FixedRangeContext { App* app; + /// The row set the range applies to: `nullptr` is the main window's. A scope window's own + /// dialog writes its window's rows. + RowSet* rows = nullptr; int row; GtkWidget* lo; GtkWidget* hi; @@ -2070,6 +2073,10 @@ gboolean on_scope_drop_channel(GtkDropTarget*, const GValue* value, double x, do /// 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); +/// The scope window's own right-click menu: the same row menu's actions, aimed at **that +/// window's** row set. +void on_scope_right_click(GtkGestureClick* gesture, int n_press, double x, double y, + gpointer user); /// 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 7885314..066f23b 100644 --- a/src/ui_probe.cpp +++ b/src/ui_probe.cpp @@ -1915,6 +1915,24 @@ gboolean scope_probe_step(gpointer user) { det.rows != nullptr && det.rows->row_count() > 0 ? det.rows->row(0).signal_name[0].c_str() : ""); + // The scope window carries its own right-click menu (its gesture is on the GL area, wired + // beside the drop target): one click gesture whose button is 3, per window. + int right_clicks = 0; + if (det.widget != nullptr) { + GListModel* controllers = gtk_widget_observe_controllers(det.widget); + const guint n = g_list_model_get_n_items(G_LIST_MODEL(controllers)); + for (guint i = 0; i < n; ++i) { + GObject* c = G_OBJECT(g_list_model_get_item(controllers, i)); + if (GTK_IS_GESTURE_SINGLE(c) && + gtk_gesture_single_get_button(GTK_GESTURE_SINGLE(c)) == 3) { + ++right_clicks; + } + g_object_unref(c); + } + g_object_unref(controllers); + } + std::fprintf(stderr, "[scope-probe] rclick=%d\n", right_clicks); + std::fflush(stderr); } std::fflush(stderr); return G_SOURCE_REMOVE;