From 0c9333858cd37368a4200de37ef289ff06f37f97 Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Fri, 6 Nov 2026 10:54:50 +0100 Subject: [PATCH] scope windows: their own layout editor, and the same shortcuts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A detached window is a view with an arrangement of its own -- and there was no way to change it: the layout editor was the main window's, and a second window's rows could not be added or removed. Every scope window now carries its own editor: `L` (or the grid toggle in its corner, revealed by the same proximity rule as the attach button) shows the main editor's `+` / `×` / Done controls, and they act on **that window's** row set (`set_scope_layout_editing()`, every button tagged with its window), so a window holds as many figures as the user gives it and the two editors cannot move each other's rows. Nothing a scope window arranges is written to the main window's session: the windows themselves are not persisted. The keyboard: each scope window gets a `GtkEventControllerKey` running the application's one accelerator table -- `L` acts on the window the key arrived in, every other action reaches the application through `run_app_action()`. The plot's own keys ('f', the arrows, Escape) already worked there, through its plot controller. The window struct now always builds its overlay (the corner controls' host); the attach button stays detached-only, and both corner toggles step aside while the editor is up (Done is the way out). Probe: `[scope-probe] layout buttons=3 rows=1->2` -- the toggle builds 2*rows+1 buttons and a boundary `+` adds a row. `L` in a real scope window verified by OCR ("Done" appears, the row's `×` at its left). Docs: README, the Mallard layout page, the probe readings, and the layout invariant (a view's arrangement is the view's). --- README.md | 8 +- docs/claude/invariants-plot-and-layout.md | 9 + docs/claude/probe-readings.md | 4 + src/ui.cpp | 306 +++++++++++++++++++--- src/ui_app.hpp | 23 +- src/ui_probe.cpp | 14 + 6 files changed, 327 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index 6d8a953..53b4b51 100644 --- a/README.md +++ b/README.md @@ -145,9 +145,11 @@ 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. A right-click on a scope window's plot opens **its own -menu** — y axis (auto or a fixed range), *Fit X axis*, *Fit all*, remove a trace — acting on that -window's rows (the x fit resets the **shared** time span, so every view follows); a detached +"plot 2"`), one button per plot of it. 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 +main one. A right-click on a scope window's plot opens **its own menu** — y axis (auto or a fixed +range), *Fit X axis*, *Fit all*, remove a trace — acting on that window's rows (the x fit resets the **shared** time span, so every view follows); a detached window's menu carries **Attach back to the main window** beside them. A window that cannot paint says so on stderr and in the status bar after two seconds, naming its **GSK renderer**: a blank plot is indistinguishable from a context that failed, and the GTK **Vulkan** renderer is known to diff --git a/docs/claude/invariants-plot-and-layout.md b/docs/claude/invariants-plot-and-layout.md index dd8ac99..4b9f53f 100644 --- a/docs/claude/invariants-plot-and-layout.md +++ b/docs/claude/invariants-plot-and-layout.md @@ -380,3 +380,12 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear figure row carries **no** toggle (and no menu entry): there is nothing to enlarge *to*, and a visible no-op is worse than none. The button reappears while a row *is* maximized, because there it is the way back. + +- **A view's arrangement is the view's, editor included.** A scope window holds its own figure + rows and edits them with its own `+`/`×`/Done set (`set_scope_layout_editing()`, + `rebuild_scope_layout_editor()`, `sync_scope_layout_editor()`): the buttons are tagged with the + window and the handlers act on *that* window's row set, so the two editors cannot move each + other's figures. `L` acts on the window the key arrived in (a `GtkEventControllerKey` per + window; every other accelerator reaches the application through `run_app_action()`), and a + pinned layout change is **not** written to the main window's session — the windows themselves + are not persisted. diff --git a/docs/claude/probe-readings.md b/docs/claude/probe-readings.md index 7b22de3..61b138f 100644 --- a/docs/claude/probe-readings.md +++ b/docs/claude/probe-readings.md @@ -1500,6 +1500,10 @@ a defect in the copy. Measured output: is the defect this exists for: `pin_event_window()` takes **spans**, and passing it a range's signed `min` turned the window inside out, so a scope in Held or Paused mode drew no trace and no x labels at all. +- `layout buttons=3 rows=1->2` is the window's **own layout editor**: the toggle builds + `2 × rows + 1` buttons (a `×` per row, a `+` per boundary, Done is not counted) for one figure, + and emitting the first boundary `+` adds a row. A wrong count is a button set built for the main + window's rows; `rows` unchanged is a handler writing the wrong row set. - `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. diff --git a/src/ui.cpp b/src/ui.cpp index 2e96169..0138d32 100644 --- a/src/ui.cpp +++ b/src/ui.cpp @@ -645,30 +645,31 @@ void open_scope_window(App& app, int figure_row, bool detached) { if (detached) title += " (detached)"; gtk_window_set_title(scope->window, title.c_str()); gtk_window_set_default_size(scope->window, 900, 500); + // The overlay holds every corner control this window has: the attach button (detached windows + // only), the layout toggle, and the layout editor's own buttons. It is **always** created, not + // only for detached windows, because a scope is a view with its own arrangement and every one of + // them can be rearranged. The motion controller lives on the overlay, so a revealed button sits + // *under* the pointer and the event still travels the ancestor -- a leave on the GL widget would + // fire the moment the button appeared and hide it again. + scope->overlay = gtk_overlay_new(); + gtk_overlay_set_child(GTK_OVERLAY(scope->overlay), scope->widget); + gtk_window_set_child(scope->window, scope->overlay); + GtkEventController* motion = gtk_event_controller_motion_new(); + g_signal_connect(motion, "motion", G_CALLBACK(+[](GtkEventControllerMotion*, double x, + double y, gpointer user) { + App::ScopeWindow* scope = static_cast(user); + scope->pointer_x = x; + scope->pointer_y = y; + scope->pointer_valid = true; + }), + scope.get()); + g_signal_connect(motion, "leave", G_CALLBACK(+[](GtkEventControllerMotion*, gpointer user) { + static_cast(user)->pointer_valid = false; + }), + scope.get()); + gtk_widget_add_controller(scope->overlay, GTK_EVENT_CONTROLLER(motion)); if (detached) { - // The attach control, and it is the main window's own reveal mechanism in miniature: an - // overlay wrapping the GL area (so a revealed button sits *under* the pointer and the motion - // event still travels the ancestor -- a leave on the GL widget would fire the moment the - // button appeared and hide it again), and one button in the top-right corner, hidden until - // the pointer nears it. Only a detached window carries one: a copy or an empty window has - // nothing to put back. - scope->attach_overlay = gtk_overlay_new(); - gtk_overlay_set_child(GTK_OVERLAY(scope->attach_overlay), scope->widget); - gtk_window_set_child(scope->window, scope->attach_overlay); - GtkEventController* motion = gtk_event_controller_motion_new(); - g_signal_connect(motion, "motion", G_CALLBACK(+[](GtkEventControllerMotion*, double x, - double y, gpointer user) { - App::ScopeWindow* scope = static_cast(user); - scope->pointer_x = x; - scope->pointer_y = y; - scope->pointer_valid = true; - }), - scope.get()); - g_signal_connect(motion, "leave", G_CALLBACK(+[](GtkEventControllerMotion*, gpointer user) { - static_cast(user)->pointer_valid = false; - }), - scope.get()); - gtk_widget_add_controller(scope->attach_overlay, GTK_EVENT_CONTROLLER(motion)); + // Only a detached window has something to put back. GtkWidget* attach = gtk_button_new_from_icon_name("view-restore-symbolic"); gtk_widget_set_tooltip_text(attach, "attach this figure back to the main window"); gtk_widget_add_css_class(attach, "circular"); @@ -686,11 +687,34 @@ void open_scope_window(App& app, int figure_row, bool detached) { attach_scope_window(a, *scope); }), &app); - gtk_overlay_add_overlay(GTK_OVERLAY(scope->attach_overlay), attach); + gtk_overlay_add_overlay(GTK_OVERLAY(scope->overlay), attach); scope->attach_buttons.push_back(attach); - } else { - gtk_window_set_child(scope->window, scope->widget); } + // The layout toggle: the editor's door, the same one the main window's overflow menu carries, + // revealed by the same proximity rule. + scope->layout_toggle = gtk_button_new_from_icon_name("view-grid-symbolic"); + gtk_widget_set_tooltip_text(scope->layout_toggle, + "layout editor: add and remove figure rows in this window"); + gtk_widget_add_css_class(scope->layout_toggle, "circular"); + gtk_widget_add_css_class(scope->layout_toggle, "osd"); + gtk_widget_set_size_request(scope->layout_toggle, kLayoutButtonPx, kLayoutButtonPx); + gtk_widget_set_visible(scope->layout_toggle, FALSE); + g_object_set_data(G_OBJECT(scope->layout_toggle), "udpscope-scope", scope.get()); + g_signal_connect(scope->layout_toggle, "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; + set_scope_layout_editing(a, *scope, !scope->layout_editing); + }), + &app); + gtk_overlay_add_overlay(GTK_OVERLAY(scope->overlay), scope->layout_toggle); + // The window's keyboard: the same accelerators the main window's keys carry. `L` acts on *this* + // window's layout; every other action reaches the application (see `on_scope_key()`). + GtkEventController* keys = gtk_event_controller_key_new(); + g_signal_connect(keys, "key-pressed", G_CALLBACK(on_scope_key), &app); + gtk_widget_add_controller(GTK_WIDGET(scope->window), GTK_EVENT_CONTROLLER(keys)); if (GtkApplication* application = gtk_window_get_application(GTK_WINDOW(app.window))) { gtk_window_set_application(scope->window, application); } @@ -1255,9 +1279,7 @@ void on_detach_clicked(GtkButton* b, gpointer user) { /// numbers so they cannot disagree. void sync_scope_attach(App& app, App::ScopeWindow& scope) { (void)app; - if (scope.attach_overlay == nullptr || scope.attach_buttons.empty() || scope.widget == nullptr) { - return; - } + if (scope.overlay == nullptr || scope.widget == nullptr) return; const double scale = scope.area != nullptr && scope.area->device_scale() > 0.0f ? static_cast(scope.area->device_scale()) : 1.0; @@ -1280,6 +1302,227 @@ void sync_scope_attach(App& app, App::ScopeWindow& scope) { scope.pointer_y <= by + kLayoutButtonPx + kMaximizeRevealPx; gtk_widget_set_visible(b, revealed); } + if (scope.layout_toggle != nullptr) { + // The layout toggle sits at the corner, to the left of the attach button when there is one. + const double bx = std::clamp(static_cast(width - kLayoutButtonPx - kLayoutButtonPad - + (scope.attach_buttons.empty() + ? 0 + : kLayoutButtonPx + kLayoutButtonPad)), + 0.0, std::max(0.0, width - kLayoutButtonPx)); + const double by = std::clamp(static_cast(kLayoutButtonPad), 0.0, + std::max(0.0, height - kLayoutButtonPx)); + gtk_widget_set_halign(scope.layout_toggle, GTK_ALIGN_START); + gtk_widget_set_valign(scope.layout_toggle, GTK_ALIGN_START); + gtk_widget_set_margin_start(scope.layout_toggle, static_cast(bx)); + gtk_widget_set_margin_top(scope.layout_toggle, static_cast(by)); + const bool revealed = + !scope.layout_editing && scope.pointer_valid && + scope.pointer_x >= bx - kMaximizeRevealPx && + scope.pointer_x <= bx + kLayoutButtonPx + kMaximizeRevealPx && + scope.pointer_y >= by - kMaximizeRevealPx && + scope.pointer_y <= by + kLayoutButtonPx + kMaximizeRevealPx; + gtk_widget_set_visible(scope.layout_toggle, revealed); + } + // The attach button steps aside while the editor is up: Done is the way out of that mode. + for (GtkWidget* b : scope.attach_buttons) { + if (scope.layout_editing) gtk_widget_set_visible(b, FALSE); + } +} + +// --- the scope windows' own layout editor ----------------------------------------------------- +// +// A scope window is a view with its own arrangement, and its arrangement is the user's: it holds +// as many figure rows as it is given and edits them with the same controls the main window's +// editor carries (`+` on a boundary, `×` on a row, Done to leave). The buttons are the window's +// own -- the main editor's target is the main window -- so the two editors cannot move each +// other's rows. + +namespace { +/// Data keys of a scope editor's buttons: the figure row a `×` removes, the boundary a `+` splits. +constexpr const char* kScopeLayoutRowKey = "udpscope-scope-layout-row"; +constexpr const char* kScopeLayoutBoundaryKey = "udpscope-scope-layout-boundary"; + +App::ScopeWindow* scope_layout_scope(GtkWidget* w) { + return static_cast(g_object_get_data(G_OBJECT(w), "udpscope-scope")); +} +} // namespace + +void rebuild_scope_layout_editor(App& app, App::ScopeWindow& scope); +void set_scope_layout_editing(App& app, App::ScopeWindow& scope, bool on); + +void clear_scope_layout_editor(App::ScopeWindow& scope) { + if (scope.overlay != nullptr) { + for (GtkWidget* b : scope.layout_buttons) { + gtk_overlay_remove_overlay(GTK_OVERLAY(scope.overlay), b); + } + if (scope.layout_done != nullptr) { + gtk_overlay_remove_overlay(GTK_OVERLAY(scope.overlay), scope.layout_done); + } + } + scope.layout_buttons.clear(); + scope.layout_done = nullptr; + scope.layout_buttons_rows = -1; +} + +/// One of the scope editor's buttons: the main editor's look (`circular`, `osd`, the button size) +/// on this window's overlay. +GtkWidget* scope_layout_button(App::ScopeWindow& scope, const char* icon, const char* tip) { + GtkWidget* b = gtk_button_new_from_icon_name(icon); + gtk_widget_set_tooltip_text(b, tip); + gtk_widget_add_css_class(b, "circular"); + gtk_widget_add_css_class(b, "osd"); + gtk_widget_set_size_request(b, kLayoutButtonPx, kLayoutButtonPx); + g_object_set_data(G_OBJECT(b), "udpscope-scope", &scope); + gtk_overlay_add_overlay(GTK_OVERLAY(scope.overlay), b); + scope.layout_buttons.push_back(b); + return b; +} + +void on_scope_layout_row_remove(GtkButton* b, gpointer user) { + App& app = *static_cast(user); + App::ScopeWindow* scope = scope_layout_scope(GTK_WIDGET(b)); + if (scope == nullptr || scope->rows == nullptr) return; + const int fr = GPOINTER_TO_INT(g_object_get_data(G_OBJECT(b), kScopeLayoutRowKey)) - 1; + outlive_the_rebuild(GTK_WIDGET(b)); + if (!scope->rows->remove_row(fr)) { + set_notice(app, "a window keeps at least one figure", 4); + return; + } + if (scope->area != nullptr) scope->area->set_figure(&scope->rows->figure()); + rebuild_scope_layout_editor(app, *scope); + if (scope->widget != nullptr) gtk_gl_area_queue_render(GTK_GL_AREA(scope->widget)); +} + +void on_scope_layout_row_insert(GtkButton* b, gpointer user) { + App& app = *static_cast(user); + App::ScopeWindow* scope = scope_layout_scope(GTK_WIDGET(b)); + if (scope == nullptr || scope->rows == nullptr) return; + const int at = GPOINTER_TO_INT(g_object_get_data(G_OBJECT(b), kScopeLayoutBoundaryKey)); + outlive_the_rebuild(GTK_WIDGET(b)); + if (!scope->rows->insert_row(at)) { + set_notice(app, "this window holds " + std::to_string(RowSet::kMaxRows) + " figures already", 4); + return; + } + if (scope->area != nullptr) scope->area->set_figure(&scope->rows->figure()); + rebuild_scope_layout_editor(app, *scope); + if (scope->widget != nullptr) gtk_gl_area_queue_render(GTK_GL_AREA(scope->widget)); +} + +void on_scope_layout_done(GtkButton* b, gpointer user) { + App& app = *static_cast(user); + App::ScopeWindow* scope = scope_layout_scope(GTK_WIDGET(b)); + if (scope == nullptr) return; + set_scope_layout_editing(app, *scope, false); +} + +void rebuild_scope_layout_editor(App& app, App::ScopeWindow& scope) { + clear_scope_layout_editor(scope); + if (!scope.layout_editing || scope.rows == nullptr || scope.overlay == nullptr) return; + const int frows = scope.rows->figure_row_count(); + scope.layout_buttons_rows = frows; + for (int fr = 0; fr < frows; ++fr) { + GtkWidget* rm = scope_layout_button(scope, "window-close-symbolic", "remove this figure"); + g_object_set_data(G_OBJECT(rm), kScopeLayoutRowKey, GINT_TO_POINTER(fr + 1)); + g_signal_connect(rm, "clicked", G_CALLBACK(on_scope_layout_row_remove), &app); + } + for (int at = 0; at <= frows; ++at) { + GtkWidget* add = scope_layout_button(scope, "list-add-symbolic", "add a figure here"); + g_object_set_data(G_OBJECT(add), kScopeLayoutBoundaryKey, GINT_TO_POINTER(at)); + g_signal_connect(add, "clicked", G_CALLBACK(on_scope_layout_row_insert), &app); + } + scope.layout_done = gtk_button_new_with_label("Done"); + gtk_widget_set_tooltip_text(scope.layout_done, "leave the layout editor"); + gtk_widget_add_css_class(scope.layout_done, "osd"); + gtk_widget_set_size_request(scope.layout_done, -1, kLayoutButtonPx); + g_object_set_data(G_OBJECT(scope.layout_done), "udpscope-scope", &scope); + g_signal_connect(scope.layout_done, "clicked", G_CALLBACK(on_scope_layout_done), &app); + gtk_overlay_add_overlay(GTK_OVERLAY(scope.overlay), scope.layout_done); +} + +/// Restates where the editor's buttons sit, from the rects the last frame was laid out with: the +/// `×` inside its row's left edge, the `+` on its boundary, Done in the corner. +void sync_scope_layout_editor(App& app, App::ScopeWindow& scope) { + if (!scope.layout_editing || scope.rows == nullptr || scope.area == nullptr || + scope.widget == nullptr) { + return; + } + const int frows = scope.rows->figure_row_count(); + if (frows != scope.layout_buttons_rows) rebuild_scope_layout_editor(app, scope); + const plot::FigureLayout& fl = scope.rows->figure().layout(); + if (static_cast(fl.rows.size()) != frows) return; + const double scale = + scope.area->device_scale() > 0.0f ? static_cast(scope.area->device_scale()) : 1.0; + const double height = static_cast(gtk_widget_get_height(scope.widget)); + const double width = static_cast(gtk_widget_get_width(scope.widget)); + if (!(height > 0.0) || !(width > 0.0)) return; + const double half = kLayoutButtonPx * 0.5; + for (GtkWidget* b : scope.layout_buttons) { + const int fr = GPOINTER_TO_INT(g_object_get_data(G_OBJECT(b), kScopeLayoutRowKey)) - 1; + const int at = GPOINTER_TO_INT(g_object_get_data(G_OBJECT(b), kScopeLayoutBoundaryKey)); + gtk_widget_set_halign(b, GTK_ALIGN_START); + gtk_widget_set_valign(b, GTK_ALIGN_START); + if (fr >= 0) { + if (fr >= frows) continue; + const plot::Rect& row = fl.rows[static_cast(fr)]; + gtk_widget_set_margin_top( + b, static_cast(std::clamp(static_cast(row.y) / scale + kLayoutButtonPad, 0.0, + height - kLayoutButtonPx) + 0.5)); + // Inside the axes, not over the y labels: the row's first cell's plot knows where they end. + const plot::Plot* p = scope.rows->row(scope.rows->first_plot_of_row(fr)).plot; + const double left = + (p != nullptr ? static_cast(p->layout().axes.x) / scale + : static_cast(row.x) / scale) + kLayoutButtonPad; + gtk_widget_set_margin_start(b, static_cast(std::clamp(left, 0.0, width - kLayoutButtonPx) + 0.5)); + } else if (at >= 0) { + const double edge = at < frows + ? static_cast(fl.rows[static_cast(at)].y) / scale + : (static_cast(fl.rows.back().y) + + static_cast(fl.rows.back().h)) / scale; + gtk_widget_set_margin_top( + b, static_cast(std::clamp(edge - half, 0.0, height - kLayoutButtonPx) + 0.5)); + gtk_widget_set_margin_start(b, static_cast(kLayoutButtonPad)); + } + } + if (scope.layout_done != nullptr) { + gtk_widget_set_halign(scope.layout_done, GTK_ALIGN_END); + gtk_widget_set_valign(scope.layout_done, GTK_ALIGN_START); + gtk_widget_set_margin_end(scope.layout_done, kLayoutButtonPad); + gtk_widget_set_margin_top(scope.layout_done, kLayoutButtonPad); + } +} + +void set_scope_layout_editing(App& app, App::ScopeWindow& scope, bool on) { + if (scope.layout_editing == on) return; + scope.layout_editing = on; + if (on) { + rebuild_scope_layout_editor(app, scope); + } else { + clear_scope_layout_editor(scope); + } + sync_scope_attach(app, scope); + if (scope.widget != nullptr) gtk_gl_area_queue_render(GTK_GL_AREA(scope.widget)); +} + +gboolean on_scope_key(GtkEventControllerKey* controller, guint keyval, guint, GdkModifierType state, + gpointer user) { + App& app = *static_cast(user); + const Accel* accel = find_accel(keyval, static_cast(state)); + if (accel == nullptr) return FALSE; + if (accel->action == AppAction::LayoutEditor) { + // The window the key arrived in, not the main one: `L` is the layout editor of the view you + // are looking at. + GtkWidget* widget = gtk_event_controller_get_widget(GTK_EVENT_CONTROLLER(controller)); + GtkWidget* window = widget != nullptr ? gtk_widget_get_ancestor(widget, GTK_TYPE_WINDOW) : nullptr; + for (const std::unique_ptr& scope : app.scopes) { + if (GTK_WIDGET(scope->window) != window) continue; + set_scope_layout_editing(app, *scope, !scope->layout_editing); + return TRUE; + } + return FALSE; + } + // Everything else is the application's, exactly as the main window's keys take it. + run_app_action(app, accel->action); + return TRUE; } /// **Detach**: the figure row moves to a window of its own. Copied first (the new window opens @@ -4650,7 +4893,10 @@ gboolean tick(gpointer user) { if (scope->rows->rebuild_if_dirty() && scope->area != nullptr) { scope->area->set_figure(&scope->rows->figure()); } - if (scope->detached) sync_scope_attach(*app, *scope); + if (scope->detached || scope->layout_toggle != nullptr) sync_scope_attach(*app, *scope); + // The window's own editor: buttons restated from the rects this frame was laid out with. + // Its own arrangement is its own -- nothing here saves the main window's session. + if (scope->layout_editing) sync_scope_layout_editor(*app, *scope); // **A window that cannot paint says so.** A blank plot is indistinguishable from a // context that failed or an area that never got a frame, and the report ("nothing is // drawn, no axes") is exactly what this turns into a sentence: the window's own title diff --git a/src/ui_app.hpp b/src/ui_app.hpp index 560e9e6..faebc0d 100644 --- a/src/ui_app.hpp +++ b/src/ui_app.hpp @@ -225,10 +225,18 @@ struct App { /// carries the attach control and can be put back. A copied or empty window is a plain second /// view, and has no attach control. bool detached = false; - /// The attach control: an overlay wrapping the GL widget, its button in the top-right corner - /// (revealed by pointer proximity, the same rule the main window's toggles follow), and the - /// pointer state the overlay's own motion controller keeps. - GtkWidget* attach_overlay = nullptr; + /// The overlay wrapping the GL widget: the corner controls (attach, the layout toggle, the + /// layout editor's own buttons) all live on it. Its motion controller keeps the pointer state + /// they are revealed by. + GtkWidget* overlay = nullptr; + /// Layout editing in this window: its own editor and toggle. A scope is a view with its own + /// arrangement, so it holds as many figure rows as it is given and edits them with the same + /// `+` / `×` / Done controls the main window's editor has -- its own, never the main window's. + bool layout_editing = false; + GtkWidget* layout_toggle = nullptr; + std::vector layout_buttons; + GtkWidget* layout_done = nullptr; + int layout_buttons_rows = -1; std::vector attach_buttons; double pointer_x = 0.0; double pointer_y = 0.0; @@ -2077,6 +2085,13 @@ 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); +/// Runs one keyboard accelerator. Scope windows carry the same shortcuts as the main window: the +/// view-level ones (`L` -- the layout editor) act on the window the key arrived in; the rest reach +/// the application through `run_app_action()`. +gboolean on_scope_key(GtkEventControllerKey* controller, guint keyval, guint keycode, + GdkModifierType state, gpointer user); +/// Turns a scope window's own layout editor on or off, building or taking down its buttons. +void set_scope_layout_editing(App& app, App::ScopeWindow& scope, bool on); /// 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, diff --git a/src/ui_probe.cpp b/src/ui_probe.cpp index a2710b9..79c04de 100644 --- a/src/ui_probe.cpp +++ b/src/ui_probe.cpp @@ -1960,6 +1960,20 @@ gboolean scope_probe_step(gpointer user) { scope_pin.max, scope_x.min, scope_x.max); std::fflush(stderr); a->rows->set_time_window(2.0); // unpin: leave the window as the run found it + // **The scope window's own layout editor**: the toggle builds its buttons and a `+` adds a + // figure row -- the arrangement of a view is the view's. + set_scope_layout_editing(*a, *a->scopes[1], true); + const std::size_t layout_buttons = a->scopes[1]->layout_buttons.size(); + const int rows_before = a->scopes[1]->rows->row_count(); + if (layout_buttons > static_cast(rows_before)) { + g_signal_emit_by_name(a->scopes[1]->layout_buttons[static_cast(rows_before)], + "clicked"); + } + const int rows_after = a->scopes[1]->rows->row_count(); + set_scope_layout_editing(*a, *a->scopes[1], false); + std::fprintf(stderr, "[scope-probe] layout buttons=%zu rows=%d->%d\n", layout_buttons, + rows_before, rows_after); + std::fflush(stderr); return G_SOURCE_REMOVE; } default: