diff --git a/README.md b/README.md
index 346dd7e..1133c26 100644
--- a/README.md
+++ b/README.md
@@ -129,7 +129,11 @@ rect plus a `kMaximizeRevealPx` of slack, per row) and is invisible otherwise, s
decorated by controls nobody is reaching for; one click makes that row fill
the plot area and the same button restores the whole arrangement; the plot's right-click menu
offers the same as **Full screen this figure** / **Restore the layout**, and a layout of a single
-figure carries no button (there is nothing to enlarge *to*). It is a *view* — the row set
+figure carries no button (there is nothing to enlarge *to*). Beside the toggle, and with the
+same show-on-approach, a **detach** button moves the figure into a window of its own (removed
+from the main window's rows, which save the change); the detached window carries an **attach**
+button in the same corner — its figure returns to the main window's rows and the window closes.
+It is a *view* — the row set
saves every row's state on the way in and re-applies it on the way out (`RowState` in
`plot_host.*`), so nothing plotted moves and nothing is saved. A plot's right-click menu has
**Open this plot in a new window**, which copies that figure — its cells, their width shares and
diff --git a/docs/claude/probe-readings.md b/docs/claude/probe-readings.md
index b779a23..cb12384 100644
--- a/docs/claude/probe-readings.md
+++ b/docs/claude/probe-readings.md
@@ -1422,19 +1422,28 @@ Measured output (viewport pixels; the absolute numbers are the headless window's
## `[maximize-probe]`
`UDPSCOPE_PROBE_MAXIMIZE_MS`. The one-click **full-screen row** toggle, exercised through the
+`RowSet` call the button's handler makes. **Do not run it with `UDPSCOPE_ENABLE_ALL_MS`**: the
+enable-all probe's second leg clears the front plot two seconds in, and this probe's `before`
+read then names an empty row (`row0=`) -- a probe race, not a defect. The full-screen recipe:
`RowSet` call the button's handler makes (a probe cannot click an overlay button the frame
places), arranged as two single-cell rows with one signal each. The two reads after it are the
**corner reveal**: no pointer over the area, then a pointer position in the first row's corner
(set directly -- the probe owns the state the motion controller fills). Measured output:
```
-[maximize-probe] single_buttons=0 before: rows=2 frows=2 row0=sine row1=square buttons=2; max: rows=1 frows=1 flag=1 row0=sine; back: rows=2 row0=sine row1=square flag=0
-[maximize-probe] corner: hidden_when_off=1 shown_when_near=1 other_row=0
+[maximize-probe] single_buttons=0 detach_single=0 before: rows=2 frows=2 row0=sine row1=square buttons=2 detach=2; max: rows=1 frows=1 flag=1 row0=sine; back: rows=2 row0=sine row1=square flag=0
+[maximize-probe] corner: hidden_when_off=1 shown_when_near=1 other_row=0 detach_near=1 detach_other=0
```
- `single_buttons=` is the toggle count *before* the second row is added, while the layout is
the default one figure: **0** — one figure has nothing to enlarge *to*, so it carries no
button (and `buttons=` is what the same count reads once the second row exists).
+ `detach_single=` is the same for the **detach** buttons: **0** — detaching the only figure
+ would leave the main window with nothing to draw — and `detach=2` once the second row exists
+ (and **0** again on the `max:` line, where a maximized layout has hidden its rows).
+- the corner leg's `detach_near=1 detach_other=0` read the detach buttons with the same pointer
+ the maximize toggle reads: both come out together at the corner, the other row's stay hidden.
+ The pointer sits on the detach button's own centre — its zone is the strictest of the pair.
- `before:` is the arrangement as set: two plots, two figure rows, the signals on their rows,
and `buttons=` one overlay toggle per figure row (the toggles are on screen whether or not
the layout editor is up).
diff --git a/help/C/org.udpscope.app/layout.page b/help/C/org.udpscope.app/layout.page
index 6624736..0caba3e 100644
--- a/help/C/org.udpscope.app/layout.page
+++ b/help/C/org.udpscope.app/layout.page
@@ -77,7 +77,10 @@
nothing is saved, nothing is dropped, and the rows' traces, colours and cursors come back
where they were. The plot's right-click menu offers the same as Full screen this
figure (and Restore the layout while one is enlarged). A layout of a single
- figure has nothing to enlarge *to*, so it carries no button.
+ figure has nothing to enlarge *to*, so it carries no button. Beside the toggle, shown the same way, a 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
diff --git a/src/ui.cpp b/src/ui.cpp
index d2b172a..dff420f 100644
--- a/src/ui.cpp
+++ b/src/ui.cpp
@@ -583,7 +583,7 @@ void relayout_areas(App& app) {
/// about the data or the display mode. `figure_row` names the main window's figure to copy --
/// its cells, their width shares and every slot's signal in its colour -- because "open this
/// one big" is the reason a user reaches for a second window; -1 opens an empty scope.
-void open_scope_window(App& app, int figure_row) {
+void open_scope_window(App& app, int figure_row, bool detached) {
if (figure_row >= app.rows->figure_row_count()) figure_row = -1;
auto scope = std::make_unique();
scope->rows = std::make_unique();
@@ -621,13 +621,60 @@ void open_scope_window(App& app, int figure_row) {
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->detached = detached;
scope->window = GTK_WINDOW(gtk_window_new());
- const std::string title = figure_row >= 0
- ? "udpscope \u2014 plot " + std::to_string(figure_row + 1)
- : std::string("udpscope \u2014 scope");
+ std::string title = figure_row >= 0
+ ? "udpscope \u2014 plot " + std::to_string(figure_row + 1)
+ : std::string("udpscope \u2014 scope");
+ if (detached) title += " (detached)";
gtk_window_set_title(scope->window, title.c_str());
gtk_window_set_default_size(scope->window, 900, 500);
- gtk_window_set_child(scope->window, scope->widget);
+ 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));
+ 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");
+ gtk_widget_add_css_class(attach, "osd");
+ gtk_widget_set_size_request(attach, kLayoutButtonPx, kLayoutButtonPx);
+ gtk_widget_set_visible(attach, FALSE);
+ g_object_set_data(G_OBJECT(attach), "udpscope-scope", scope.get());
+ g_signal_connect(attach, "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));
+ attach_scope_window(a, *scope);
+ }),
+ &app);
+ gtk_overlay_add_overlay(GTK_OVERLAY(scope->attach_overlay), attach);
+ scope->attach_buttons.push_back(attach);
+ } else {
+ gtk_window_set_child(scope->window, scope->widget);
+ }
if (GtkApplication* application = gtk_window_get_application(GTK_WINDOW(app.window))) {
gtk_window_set_application(scope->window, application);
}
@@ -1025,6 +1072,7 @@ GtkWidget* layout_overlay_button(App& app, const char* icon, const char* tooltip
/// Forward declarations: the toggles' handlers are defined after the builder that wires them.
void on_maximize_clicked(GtkButton* b, gpointer user);
+void on_detach_clicked(GtkButton* b, gpointer user);
void on_fullscreen_row(GtkButton* b, gpointer user);
void rebuild_maximize_buttons(App& app);
@@ -1052,6 +1100,10 @@ void rebuild_maximize_buttons(App& app) {
gtk_overlay_remove_overlay(GTK_OVERLAY(app.area_overlay), b);
}
app.maximize_buttons.clear();
+ for (GtkWidget* b : app.detach_buttons) {
+ gtk_overlay_remove_overlay(GTK_OVERLAY(app.area_overlay), b);
+ }
+ app.detach_buttons.clear();
app.maximize_buttons_rows = app.rows->figure_row_count();
const bool maximized = app.rows->figures_maximized();
if (!maximized && app.maximize_buttons_rows <= 1) return;
@@ -1063,6 +1115,17 @@ void rebuild_maximize_buttons(App& app) {
app.maximize_buttons);
g_object_set_data(G_OBJECT(b), kMaximizeKey, GINT_TO_POINTER(fr + 1));
g_signal_connect(b, "clicked", G_CALLBACK(on_maximize_clicked), &app);
+ // The detach button sits beside it, one rule behind it: a layout of one figure detaches
+ // nothing (the main window would be left with nothing to draw), and a maximized one has
+ // hidden its own rows anyway.
+ if (!maximized && app.maximize_buttons_rows >= 2) {
+ GtkWidget* d = overlay_button(app, "send-to-symbolic",
+ "detach this figure to a window of its own",
+ app.detach_buttons);
+ gtk_widget_set_name(d, "udpscope-detach-button");
+ g_object_set_data(G_OBJECT(d), kMaximizeKey, GINT_TO_POINTER(fr + 1));
+ g_signal_connect(d, "clicked", G_CALLBACK(on_detach_clicked), &app);
+ }
}
}
@@ -1114,6 +1177,38 @@ void sync_maximize_buttons(App& app) {
app.area_pointer_y <= by + kLayoutButtonPx + kMaximizeRevealPx;
gtk_widget_set_visible(b, revealed);
}
+ // The detach buttons, one per figure row to the **left** of its full-screen toggle: the same
+ // corner, the same reveal, the row's own rect -- a pointer arriving for one of the pair shows
+ // both, and leaving hides both.
+ for (GtkWidget* b : app.detach_buttons) {
+ const int fr = GPOINTER_TO_INT(g_object_get_data(G_OBJECT(b), kMaximizeKey)) - 1;
+ if (fr < 0 || fr >= frows || fr >= static_cast(fl.rows.size())) {
+ gtk_widget_set_visible(b, FALSE);
+ continue;
+ }
+ const int first = app.rows->first_plot_of_row(fr);
+ const int n = app.rows->cells_in_row(fr);
+ const plot::Plot* last = n > 0 ? app.rows->row(first + n - 1).plot : nullptr;
+ const double right =
+ last != nullptr
+ ? static_cast(last->layout().axes.right()) / scale
+ : static_cast(fl.rows[static_cast(fr)].right()) / scale;
+ const double top =
+ static_cast(fl.rows[static_cast(fr)].y) / scale + kLayoutButtonPad;
+ gtk_widget_set_halign(b, GTK_ALIGN_START);
+ gtk_widget_set_valign(b, GTK_ALIGN_START);
+ const double dx = std::clamp(right - 2 * kLayoutButtonPx - 2 * kLayoutButtonPad, 0.0,
+ std::max(0.0, width - kLayoutButtonPx));
+ gtk_widget_set_margin_start(b, static_cast(dx));
+ const double dy = std::clamp(top, 0.0, std::max(0.0, height - kLayoutButtonPx));
+ gtk_widget_set_margin_top(b, static_cast(dy));
+ const bool revealed =
+ app.area_pointer_valid && app.area_pointer_x >= dx - kMaximizeRevealPx &&
+ app.area_pointer_x <= dx + kLayoutButtonPx + kMaximizeRevealPx &&
+ app.area_pointer_y >= dy - kMaximizeRevealPx &&
+ app.area_pointer_y <= dy + kLayoutButtonPx + kMaximizeRevealPx;
+ gtk_widget_set_visible(b, revealed);
+ }
}
/// The row's toggle: the `kMaximizeKey` data names the figure row it sits on.
@@ -1131,6 +1226,100 @@ void on_fullscreen_row(GtkButton* b, gpointer user) {
if (popover != nullptr) gtk_popover_popdown(popover);
}
+/// The detach button, the full-screen toggle's neighbour: the figure row it names moves to a
+/// window of its own.
+void on_detach_clicked(GtkButton* b, gpointer user) {
+ App& app = *static_cast(user);
+ detach_figure(app, GPOINTER_TO_INT(g_object_get_data(G_OBJECT(b), kMaximizeKey)) - 1);
+}
+
+/// The attach control's reveal and placement, one call per frame from the tick: the button sits at
+/// the window's top-right corner and comes out of hiding while the pointer is near it -- the
+/// placed rect inflated by `kMaximizeRevealPx`, the zone and the button measured from the same
+/// 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;
+ }
+ const double scale = scope.area != nullptr && scope.area->device_scale() > 0.0f
+ ? static_cast(scope.area->device_scale())
+ : 1.0;
+ (void)scale;
+ const double width = static_cast(gtk_widget_get_width(scope.widget));
+ const double height = static_cast(gtk_widget_get_height(scope.widget));
+ for (GtkWidget* b : scope.attach_buttons) {
+ const double bx = std::clamp(static_cast(width - 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(b, GTK_ALIGN_START);
+ gtk_widget_set_valign(b, GTK_ALIGN_START);
+ gtk_widget_set_margin_start(b, static_cast(bx));
+ gtk_widget_set_margin_top(b, static_cast(by));
+ const bool revealed =
+ 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(b, revealed);
+ }
+}
+
+/// **Detach**: the figure row moves to a window of its own. Copied first (the new window opens
+/// carrying exactly what was on screen), then removed from the main window's rows -- the copy and
+/// the removal are one action in one call, so nothing renders in between. A detached window is the
+/// one kind with an attach control; the arrangement the user had follows it there.
+void detach_figure(App& app, int fr) {
+ if (app.rows->figures_maximized() || app.rows->figure_row_count() <= 1) return;
+ if (fr < 0 || fr >= app.rows->figure_row_count()) return;
+ const std::string label = app.rows->plot_label(app.rows->first_plot_of_row(fr));
+ open_scope_window(app, fr, /*detached=*/true);
+ if (!app.rows->remove_row(fr)) return;
+ if (app.area != nullptr) app.area->set_figure(&app.rows->figure());
+ rebuild_maximize_buttons(app);
+ rebuild_signal_panels(app);
+ if (app.layout_editing) rebuild_layout_editor(app);
+ schedule_layout_save(app);
+ set_notice(app, "plot " + label + " detached to its own window", 4);
+}
+
+/// **Attach**: a detached window's figure row is appended to the main window's -- the window's
+/// cells, their weights and every slot's signal in its colour -- and the window closes. Closing
+/// runs its own teardown (the pages' *Plot on* groups are refreshed before the window's struct is
+/// freed), so nothing here may touch `scope` once the destroy has been asked for.
+void attach_scope_window(App& app, App::ScopeWindow& scope) {
+ if (!scope.detached || scope.rows == nullptr || scope.rows->row_count() <= 0) return;
+ const int at = app.rows->figure_row_count();
+ if (!app.rows->insert_row(at)) {
+ set_notice(app, "no room for another figure row", 4);
+ return;
+ }
+ const int cells = scope.rows->cells_in_row(0);
+ for (int k = 1; k < cells; ++k) {
+ if (!app.rows->insert_cell(at, k)) break;
+ }
+ if (cells > 1) app.rows->set_cell_weights(at, scope.rows->cell_weights(0));
+ const int first = app.rows->first_plot_of_row(at);
+ for (int k = 0; k < cells; ++k) {
+ const RowSet::Row& src = scope.rows->row(k);
+ for (int s = 0; s < RowSet::kSlotsPerRow; ++s) {
+ const std::size_t si = static_cast(s);
+ if (src.signal_name[si].empty()) continue;
+ app.rows->assign(first + k, s, src.signal_name[si], src.name[si], src.color_index[si]);
+ }
+ }
+ GtkWindow* window = scope.window;
+ if (window != nullptr) gtk_window_destroy(window); // erases the scope, refreshes the pages
+ app.relayout_pending = true;
+ if (app.area != nullptr) app.area->set_figure(&app.rows->figure());
+ rebuild_maximize_buttons(app);
+ rebuild_signal_panels(app);
+ if (app.layout_editing) rebuild_layout_editor(app);
+ schedule_layout_save(app);
+ set_notice(app, "the detached figure is back on the main window", 4);
+}
+
/// The plot menu's way into the layout editor: the same toggle the overflow menu's `Layout editor`
/// entry flips, so the menu cannot drift from the button's own state -- and the place a user who is
/// looking at the plot will find splitting.
@@ -4255,6 +4444,7 @@ 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->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 6221c0c..d2a3ac1 100644
--- a/src/ui_app.hpp
+++ b/src/ui_app.hpp
@@ -221,6 +221,18 @@ struct App {
/// 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;
+ /// This window came from a **detach** -- the figure left the main window to live here -- so it
+ /// 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;
+ std::vector attach_buttons;
+ double pointer_x = 0.0;
+ double pointer_y = 0.0;
+ bool pointer_valid = false;
/// 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.
@@ -243,6 +255,10 @@ struct App {
/// count changes (`maximize_buttons_rows` is the count they were built for).
std::vector maximize_buttons;
int maximize_buttons_rows = -1;
+ /// The **detach** buttons: one per figure row beside its full-screen toggle, same reveal,
+ /// same rebuild cadence -- but only while the layout holds two or more figures (detaching the
+ /// only figure would leave the main window with nothing to draw) and never while maximized.
+ std::vector detach_buttons;
/// The boundary a resize drag is on, and the weights it started from: a drag is *relative* to where it
/// began, so the beginning has to be kept -- reading the weights while dragging would compound.
int layout_drag_row = -1;
@@ -878,6 +894,7 @@ struct App {
int dbg_maximize_step = 0;
std::size_t dbg_maximize_single = 0;
int dbg_maximize_hidden = -1;
+ std::size_t dbg_detach_single = 0;
/// Which step of the scope-window probe runs next (`UDPSCOPE_PROBE_SCOPE_MS`).
int dbg_scope_step = 0;
int cursors_pane_mode = -1;
@@ -2161,7 +2178,16 @@ gboolean scope_probe_step(gpointer user);
/// A second top-level window showing the same stream through its own figure: `figure_row` >= 0
/// copies that figure of the main window (cells, widths, every slot's signal), -1 opens an
/// empty one-row scope. The main window's tick binds, acquires and repaints it.
-void open_scope_window(App& app, int figure_row);
+void open_scope_window(App& app, int figure_row, bool detached = false);
+/// Detaches figure row `fr` into a window of its own: the figure is copied into a new scope
+/// window -- which is what makes the window *detached*, the one kind with an attach control --
+/// and removed from the main window's rows. Refused, changing nothing, for a one-figure layout
+/// or a maximized one: detaching then would leave the main window with nothing to draw.
+void detach_figure(App& app, int fr);
+/// Puts a detached window's figure back: a figure row is appended to the main window carrying
+/// the window's arrangement (cells, weights, every slot's signal in its colour) and the window
+/// closes. The window must be a detached one; anything else returns without changing.
+void attach_scope_window(App& app, App::ScopeWindow& scope);
gboolean measurepin_probe(gpointer user);
gboolean measurepin_held_wait(gpointer user);
gboolean measurepin_pause_wait(gpointer user);
diff --git a/src/ui_probe.cpp b/src/ui_probe.cpp
index 2af228b..e7817d3 100644
--- a/src/ui_probe.cpp
+++ b/src/ui_probe.cpp
@@ -1651,6 +1651,7 @@ gboolean maximize_probe_step(gpointer user) {
// A layout of one figure carries no full-screen toggle (there is nothing to enlarge
// *to*); the button appears together with the second figure row, below.
a->dbg_maximize_single = a->maximize_buttons.size();
+ a->dbg_detach_single = a->detach_buttons.size();
a->rows->set_cells_per_row({1, 1});
a->rows->assign(0, 0, sine, sine, sine_color);
a->rows->assign(1, 0, square, square, square_color);
@@ -1684,11 +1685,13 @@ gboolean maximize_probe_step(gpointer user) {
const std::string row1_back = a->rows->row(1).signal_name[0];
const bool flag_back = a->rows->figures_maximized();
std::fprintf(stderr,
- "[maximize-probe] single_buttons=%zu before: rows=%d frows=%d row0=%s row1=%s "
- "buttons=%zu; max: rows=%d frows=%d flag=%d row0=%s; "
+ "[maximize-probe] single_buttons=%zu detach_single=%zu before: rows=%d frows=%d "
+ "row0=%s row1=%s buttons=%zu detach=%zu; max: rows=%d frows=%d flag=%d row0=%s; "
"back: rows=%d row0=%s row1=%s flag=%d\n",
- a->dbg_maximize_single, rows_before, frows_before, row0_before.c_str(),
- row1_before.c_str(), buttons, rows_max, frows_max, flag_max ? 1 : 0,
+ a->dbg_maximize_single, a->dbg_detach_single, rows_before, frows_before,
+ row0_before.c_str(),
+ row1_before.c_str(), buttons, a->detach_buttons.size(), rows_max, frows_max,
+ flag_max ? 1 : 0,
row0_max.c_str(), rows_back, row0_back.c_str(), row1_back.c_str(),
flag_back ? 1 : 0);
std::fflush(stderr);
@@ -1703,25 +1706,37 @@ gboolean maximize_probe_step(gpointer user) {
case 2: {
a->dbg_maximize_hidden =
a->maximize_buttons.empty() || !gtk_widget_get_visible(a->maximize_buttons[0]) ? 1 : 0;
- a->area_pointer_x = static_cast(gtk_widget_get_width(a->area_widget)) - 8.0;
- a->area_pointer_y = 8.0;
+ // On the **detach** button's own centre -- its zone is the strictest of the corner pair, so a
+ // pointer there must reveal both it and the full-screen toggle beside it.
+ if (const plot::Plot* last = a->rows->row(0).plot) {
+ const double scale =
+ a->area != nullptr && a->area->device_scale() > 0.0f
+ ? static_cast(a->area->device_scale())
+ : 1.0;
+ const double right = static_cast(last->layout().axes.right()) / scale;
+ a->area_pointer_x = right - 2 * kLayoutButtonPx - 2 * kLayoutButtonPad +
+ 0.5 * kLayoutButtonPx;
+ a->area_pointer_y = kLayoutButtonPad + 0.5 * kLayoutButtonPx;
+ } else {
+ a->area_pointer_x = static_cast(gtk_widget_get_width(a->area_widget)) - 8.0;
+ a->area_pointer_y = 8.0;
+ }
a->area_pointer_valid = true;
a->dbg_maximize_step = 3;
g_timeout_add(250, maximize_probe_step, a);
break;
}
case 3: {
- const int shown = !a->maximize_buttons.empty() &&
- gtk_widget_get_visible(a->maximize_buttons[0])
- ? 1
- : 0;
- const int other = a->maximize_buttons.size() > 1 &&
- gtk_widget_get_visible(a->maximize_buttons[1])
- ? 1
- : 0;
+ const auto visible = [](const std::vector& buttons, std::size_t i) {
+ return i < buttons.size() && gtk_widget_get_visible(buttons[i]) ? 1 : 0;
+ };
+ const int shown = visible(a->maximize_buttons, 0);
+ const int other = visible(a->maximize_buttons, 1);
+ const int dshown = visible(a->detach_buttons, 0);
+ const int dother = visible(a->detach_buttons, 1);
std::fprintf(stderr, "[maximize-probe] corner: hidden_when_off=%d shown_when_near=%d "
- "other_row=%d\n",
- a->dbg_maximize_hidden, shown, other);
+ "other_row=%d detach_near=%d detach_other=%d\n",
+ a->dbg_maximize_hidden, shown, other, dshown, dother);
std::fflush(stderr);
return G_SOURCE_REMOVE;
}
@@ -1824,6 +1839,34 @@ gboolean scope_probe_step(gpointer user) {
std::fprintf(stderr,
"[scope-probe] drop=%d slot1='%s' page_groups=%d\n",
dropped ? 1 : 0, slot1.c_str(), groups);
+ // **Detach and attach**: a second figure row detaches -- the window is marked detached and
+ // carries the attach control -- and its figure comes back on the main window.
+ a->rows->set_cells_per_row({1, 1});
+ {
+ std::size_t ramp = std::size_t(-1);
+ int color = 0;
+ for (std::size_t i = 0; i < a->model->signals().size(); ++i) {
+ if (a->model->signals()[i].name == "ramp") {
+ ramp = i;
+ color = signal_color_index(a->model->signals(), i);
+ }
+ }
+ if (ramp != std::size_t(-1)) a->rows->assign(1, 0, "ramp", "ramp", color);
+ }
+ a->relayout_pending = true;
+ detach_figure(*a, 1);
+ const bool detached_ok = a->scopes.size() == 3 && a->scopes[2]->detached &&
+ a->rows->figure_row_count() == 1 &&
+ a->scopes[2]->rows->row(0).signal_name[0] == "ramp" &&
+ a->scopes[2]->attach_buttons.size() == 1;
+ attach_scope_window(*a, *a->scopes[2]);
+ const int frows_back = a->rows->figure_row_count();
+ const bool attach_ok = a->scopes.size() == 2 && frows_back == 2 &&
+ a->rows->row(a->rows->first_plot_of_row(1)).signal_name[0] == "ramp";
+ std::fprintf(stderr,
+ "[scope-probe] detach=%d scopes=3 frows=1 attach=%d scopes_back=2 "
+ "frows_back=%d slot='ramp'\n",
+ detached_ok ? 1 : 0, attach_ok ? 1 : 0, frows_back);
std::fflush(stderr);
return G_SOURCE_REMOVE;
}
diff --git a/src/ui_tour.cpp b/src/ui_tour.cpp
index 09d01e8..59a7f15 100644
--- a/src/ui_tour.cpp
+++ b/src/ui_tour.cpp
@@ -443,8 +443,8 @@ constexpr TourStep kTourSteps[] = {
{"plot", nullptr, "The plot",
"Every signal you have switched on is drawn here, all plots sharing one time axis -- and the "
"plots of a split row share one y as well. The wheel zooms, a drag pans, and a right-click on "
- "a plot opens its own menu: fit, full screen (the button also comes out in a figure's corner "
- "as the pointer nears it), a second window showing that figure, and the layout editor, which "
+ "a plot opens its own menu: fit, full screen and detach (the corner buttons come out as the "
+ "pointer nears them), a second window showing that figure, and the layout editor, which "
"splits a row into up to four plots.",
&App::area_overlay},
{"badges", nullptr, "The badges",