layout: detach a figure to its own window, and attach it back

Beside each figure row's full-screen toggle -- same corner, same show-on-approach reveal, same
one-figure/never-maximized rules -- a **detach** button moves the figure into a window of its
own: copied into a new scope window (titled "detached") and removed from the main window's rows,
which then save the change. The detached window is the one scope window with an **attach**
control: an overlay wrapping its GL area, a button in the top-right corner revealed by the same
pointer-proximity rule from the overlay's own motion controller, and one click appends the
figure back to the main window's rows (cells, weights, every slot's signal in its colour) and
closes the window.

`detach_figure()` / `attach_scope_window()`; the reveal and placement share the maximize
buttons' geometry and pointer state, per row. A detached window's rows participate everywhere a
scope window's do (drops, the signal page's *Plot on* groups), which the previous commit gave
them.

Probe: `[maximize-probe]` now counts the detach buttons (`detach_single=0` for one figure,
`detach=2` for two, absent while maximized) and reads their corner reveal
(`detach_near=1 detach_other=0`, the pointer parked on the detach button's own centre -- the
strictest zone of the pair). Real-pointer smoke: hover reveals the pair, clicking detach opens
the window, clicking attach closes it with the figure back. Docs: README, the Mallard layout
page, the probe readings, the layout invariant, and the tour's plot stop.
This commit is contained in:
Martino Ferrari committed 2026-11-05 23:35:26 +01:00
1 parent 51b4fad386
commit 5ed75eb470
7 files changed
+303 -28

No files matched your search

+195 -5
View File
@@ -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<App::ScopeWindow>();
scope->rows = std::make_unique<RowSet>();
@@ -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<App::ScopeWindow*>(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<App::ScopeWindow*>(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<App*>(user);
auto* scope =
static_cast<App::ScopeWindow*>(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<int>(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<double>(last->layout().axes.right()) / scale
: static_cast<double>(fl.rows[static_cast<std::size_t>(fr)].right()) / scale;
const double top =
static_cast<double>(fl.rows[static_cast<std::size_t>(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<int>(dx));
const double dy = std::clamp(top, 0.0, std::max(0.0, height - kLayoutButtonPx));
gtk_widget_set_margin_top(b, static_cast<int>(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<App*>(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<double>(scope.area->device_scale())
: 1.0;
(void)scale;
const double width = static_cast<double>(gtk_widget_get_width(scope.widget));
const double height = static_cast<double>(gtk_widget_get_height(scope.widget));
for (GtkWidget* b : scope.attach_buttons) {
const double bx = std::clamp(static_cast<double>(width - kLayoutButtonPx - kLayoutButtonPad),
0.0, std::max(0.0, width - kLayoutButtonPx));
const double by = std::clamp(static_cast<double>(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<int>(bx));
gtk_widget_set_margin_top(b, static_cast<int>(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<std::size_t>(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));
}
}