session: remember extra windows, and reopen them from the menu

A scope or detached window was never written to the session, and a detached figure vanished from
the layout at the next start -- the figure had been removed from the main rows, and nothing kept
the window that held it.

Each window is now a `[[window]]` block in the session -- `name`, `detached`, and its own
`[[window.plot]]` rows with the same keys the main `[[plot]]` rows carry (signals, row weight,
cell width). `collect_layout()` writes the open windows' present arrangement plus the ones already
closed; loading puts **all** of them in `App::closed_windows` and never opens one: a window is a
view the user closed, and a launch that tiled windows onto the desktop would take that decision
for them (a tiling WM would tile them straight away). The overflow menu gains **Reopen closed
windows (N)**, which opens every remembered window through `open_scope_window_from_state()` -- the
one window constructor, then its rows, weights and signals dealt into its row set.

Closing a window records it, by name (a close/reopen cycle replaces its entry rather than
multiplying them), and schedules the save; **attaching** records nothing, because that window's
figure is on the main window again. A detached figure therefore survives a quit through its
window's entry.

Probe: `[scope-probe] loaded closed=N scopes=0` (remembered, none opened) and `reopen
scopes=..->.. remembered=.. left=0` (close, remember, the menu item's own click reopens).
Docs: README, the Mallard layout page, the probe readings and the session invariant.
This commit is contained in:
Martino Ferrari committed 2026-11-06 12:06:21 +01:00
1 parent 0c9333858c
commit a29f38eaa9
9 files changed
+326 -12

No files matched your search

+110
View File
@@ -728,6 +728,11 @@ void open_scope_window(App& app, int figure_row, bool detached) {
for (auto it = a->scopes.begin(); it != a->scopes.end(); ++it) {
if ((*it)->window != window) continue;
App::ScopeWindow* victim = it->release();
// A closed window is remembered -- its arrangement is the session's, and
// *Reopen closed windows* brings it back. Attaching is the exception: the
// figure is on the main window again, so a reopen entry would be a
// duplicate of what is already on screen.
if (victim->remember_on_close) remember_closed_window(*a, *victim);
a->scopes.erase(it);
g_idle_add_full(G_PRIORITY_DEFAULT_IDLE,
+[](gpointer p) -> gboolean {
@@ -1503,6 +1508,93 @@ void set_scope_layout_editing(App& app, App::ScopeWindow& scope, bool on) {
if (scope.widget != nullptr) gtk_gl_area_queue_render(GTK_GL_AREA(scope.widget));
}
void sync_reopen_scopes_item(App& app) {
if (app.reopen_scopes_item == nullptr) return;
const std::size_t n = app.closed_windows.size();
const std::string label =
n == 0 ? std::string("Reopen closed windows")
: "Reopen closed windows (" + std::to_string(n) + ")";
gtk_button_set_label(GTK_BUTTON(app.reopen_scopes_item), label.c_str());
gtk_widget_set_sensitive(app.reopen_scopes_item, n > 0 ? TRUE : FALSE);
}
App::ScopeWindow* open_scope_window_from_state(App& app, const LayoutState::Window& state) {
// The window itself comes from the one constructor every window uses -- an empty scope window,
// the same one the overflow menu makes -- and then its arrangement is dealt into its row set.
open_scope_window(app, -1, state.detached);
if (app.scopes.empty()) return nullptr;
App::ScopeWindow* scope = app.scopes.back().get();
if (scope->rows == nullptr || state.rows.empty()) {
scope->name = state.name;
if (scope->window != nullptr) gtk_window_set_title(scope->window, state.name.c_str());
return scope;
}
// The cells per figure row, from the `row` keys the same way `apply_layout()` reads the main
// layout: a file that names the figure row of every plot carries its splits, an older one is
// one cell per row.
std::vector<int> cells;
{
int prev = -1;
for (const LayoutState::Row& r : state.rows) {
const int fr = r.row < 0 ? prev + 1 : r.row;
if (fr != prev) cells.push_back(1);
else if (++cells.back() > RowSet::kMaxCellsPerRow) cells.back() = RowSet::kMaxCellsPerRow;
prev = fr;
}
if (cells.size() > static_cast<std::size_t>(RowSet::kMaxRows)) cells.resize(RowSet::kMaxRows);
}
scope->rows->set_cells_per_row(cells);
std::vector<float> row_weights;
row_weights.reserve(cells.size());
std::size_t at = 0;
for (const int n : cells) {
row_weights.push_back(static_cast<float>(state.rows[at].weight));
at += static_cast<std::size_t>(n);
}
scope->rows->set_row_weights(std::move(row_weights));
at = 0;
for (std::size_t fr = 0; fr < cells.size(); ++fr) {
const std::size_t n = static_cast<std::size_t>(cells[fr]);
if (n >= 2) {
std::vector<float> widths;
for (std::size_t c = 0; c < n; ++c) widths.push_back(static_cast<float>(state.rows[at + c].width));
scope->rows->set_cell_weights(static_cast<int>(fr), std::move(widths));
}
at += n;
}
// The slots, by name (the channel index is re-resolved every frame by `bind()`). The copy is
// taken under the model's lock: the producer replaces the list on every CONFIG.
std::vector<SignalInfo> signals;
{
std::lock_guard<std::mutex> lock(app.model->mutex());
signals = app.model->signals();
}
const auto color_of = [&](const std::string& name) {
for (std::size_t si = 0; si < signals.size(); ++si) {
if (signals[si].name == name) return signal_color_index(signals, si);
}
return app.rows->signal_colors().count(name) != 0
? static_cast<int>(app.rows->signal_colors().at(name))
: 0;
};
for (int i = 0; i < scope->rows->row_count() &&
static_cast<std::size_t>(i) < state.rows.size();
++i) {
const LayoutState::Row& row = state.rows[static_cast<std::size_t>(i)];
for (int sl = 0; sl < RowSet::kSlotsPerRow && static_cast<std::size_t>(sl) < row.signals.size(); ++sl) {
const std::string& name = row.signals[static_cast<std::size_t>(sl)];
if (name.empty()) continue;
scope->rows->assign(i, sl, name, name, color_of(name));
}
}
if (scope->area != nullptr) scope->area->set_figure(&scope->rows->figure());
scope->name = state.name;
if (scope->window != nullptr) {
gtk_window_set_title(scope->window, (state.name + (state.detached ? " (detached)" : "")).c_str());
}
return scope;
}
gboolean on_scope_key(GtkEventControllerKey* controller, guint keyval, guint, GdkModifierType state,
gpointer user) {
App& app = *static_cast<App*>(user);
@@ -1569,6 +1661,7 @@ void attach_scope_window(App& app, App::ScopeWindow& scope) {
}
}
GtkWindow* window = scope.window;
scope.remember_on_close = false; // its figure is going back to the main 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());
@@ -6227,6 +6320,23 @@ GtkWidget* build_toolbar(App& app) {
}),
&app);
gtk_box_append(GTK_BOX(menu_box), scope_item);
// The windows a session remembers but a launch does not open: the entry carries their count and
// opens them on request.
app.reopen_scopes_item = menu_button("Reopen closed windows", nullptr);
gtk_widget_set_tooltip_text(app.reopen_scopes_item,
"open the extra windows the session remembers (they are saved, "
"never opened at start)");
g_signal_connect(app.reopen_scopes_item, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
App& a = *static_cast<App*>(user);
std::vector<LayoutState::Window> windows = std::move(a.closed_windows);
a.closed_windows.clear();
for (const LayoutState::Window& w : windows) open_scope_window_from_state(a, w);
sync_reopen_scopes_item(a);
schedule_layout_save(a);
}),
&app);
gtk_box_append(GTK_BOX(menu_box), app.reopen_scopes_item);
sync_reopen_scopes_item(app);
GtkWidget* prefs_item = menu_button("Preferences\u2026", nullptr);
g_signal_connect(prefs_item, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
open_preferences(*static_cast<App*>(user));