From a29f38eaa94aff408f650789cb3409a8ee20f697 Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Fri, 6 Nov 2026 12:02:51 +0100 Subject: [PATCH] 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. --- README.md | 8 +- docs/claude/invariants-session-and-export.md | 11 ++ docs/claude/probe-readings.md | 20 +++- src/layout_store.cpp | 56 +++++++++- src/layout_store.hpp | 13 +++ src/ui.cpp | 110 +++++++++++++++++++ src/ui_app.hpp | 20 ++++ src/ui_layout.cpp | 50 +++++++++ src/ui_probe.cpp | 50 ++++++++- 9 files changed, 326 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 53b4b51..a931661 100644 --- a/README.md +++ b/README.md @@ -145,7 +145,13 @@ 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 scope window also **carries its own layout editor**: `L` (or the grid button in its corner, +"plot 2"`), one button per plot of it. **The session remembers every extra window**, but a launch never opens one: a window is a view +you closed on purpose, and a start that tiled them onto the desktop would take that decision for +you. The overflow menu's **Reopen closed windows (N)** brings them back, arrangement and all; a +window that was *attached* is not remembered (its figure is on the main window again), and a +detached figure is preserved by its window's entry, so quitting with one open no longer loses 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 diff --git a/docs/claude/invariants-session-and-export.md b/docs/claude/invariants-session-and-export.md index e3323bf..e8fd986 100644 --- a/docs/claude/invariants-session-and-export.md +++ b/docs/claude/invariants-session-and-export.md @@ -204,3 +204,14 @@ one, which is the only ordering in which a switch cannot lose what it switches f configuration's folder): an upgrade keeps the events it had. The key is a *sanitized* path component, the same rule the configuration files use, so a name typed with a slash cannot leave the directory. + +- **The session remembers extra windows; a launch never opens them.** Each scope or detached + window is a `[[window]]` block (`name`, `detached`, and its own `[[window.plot]]` rows -- the + same keys the main `[[plot]]` rows carry), written from the open windows' present arrangement + plus the ones already closed (`collect_layout()`). Loading puts **all** of them in + `App::closed_windows`, never into `app.scopes`: the overflow menu's *Reopen closed windows (N)* + is the only path that opens them (`open_scope_window_from_state()`, the one constructor every + window uses, then its rows). Closing a window records it (by name, replaced, so close/reopen + cycles do not multiply entries); **attaching** records nothing -- its figure is on the main + window again -- and a detached figure survives a quit through its window's entry instead of + being lost from the main layout. diff --git a/docs/claude/probe-readings.md b/docs/claude/probe-readings.md index 61b138f..d5b0901 100644 --- a/docs/claude/probe-readings.md +++ b/docs/claude/probe-readings.md @@ -1462,12 +1462,13 @@ places), arranged as two single-cell rows with one signal each. The two reads af `UDPSCOPE_PROBE_SCOPE_MS`. The **second window**: `open_scope_window()` twice — once copying the main window's figure 0, once empty — and read back a few frames later (the main tick is the -scope's bind/acquire/repaint pump). **Do not run it with `UDPSCOPE_ENABLE_ALL_MS`**: the -enable-all probe's second leg, two seconds in, clears the front plot through the signal page -- -exactly the slot this probe copies -- so `copied=''` under that combination is that race and not -a defect in the copy. Measured output: +scope's bind/acquire/repaint pump). It can run beside `UDPSCOPE_ENABLE_ALL_MS`, but that probe's +second leg clears the front plot through the signal page, so the main's slot 0 is empty by then +and `copied` names the **first non-empty** copied slot instead (`'square'` in that order, `'sine'` +alone). `copied=''` with every slot empty is a copy that moved nothing. Measured output: ``` +[scope-probe] loaded closed=2 scopes=0 [scope-probe] count=2 copied='sine' bound=1 empty_ok=1 windows=1 [scope-probe] drop=1 slot1='square' page_groups=2 empty_drop=1 empty_slot0='square' [scope-probe] detach=1 scopes=3 frows=1 attach=1 scopes_back=2 frows_back=2 slot='ramp' @@ -1479,11 +1480,20 @@ a defect in the copy. Measured output: - `count=2` is both windows having been created and retained; `windows=1` each being a visible top-level window. -- `copied='sine'` is the copy's first slot holding the main window's figure-0 signal, and +- `copied='sine'` is the copy's first non-empty slot holding the main window's figure-0 signal + (`'square'` when the enable-all probe ran first and cleared slot 0 first), and `bound=1` the tick having resolved it to a live channel — the scope is bound to the same model, not a snapshot taken at creation. - `empty_ok=1` is the `-1` call producing an empty row, which is the other entry point the overflow menu uses. +- `loaded closed=N scopes=0` is the **session remembering windows without opening any**: the + entries a previous run saved are in `App::closed_windows`, and no scope window exists at start. + `scopes` above zero is a launch that opened windows on its own. +- `reopen scopes=… remembered=… left=0` is **close → remember → the menu entry**: destroying a + window puts its arrangement into `closed_windows` (by name -- `name=1` checks it), clicking the + *Reopen closed windows* item ("clicked", the item's own handler) opens every remembered one, and + the list is empty after (`left=0`). A `remembered` that did not grow is a destroy handler that + forgot to record; `left` non-zero is an entry the menu did not take. - The second line is the **window as a drop target and a *Plot on* group**: `drop=1` is `on_scope_drop_channel()` — the handler the window's own target wires — putting `square` in the copied window's second slot (`slot1=`), the rebuild and the area's re-point then left to diff --git a/src/layout_store.cpp b/src/layout_store.cpp index 0fc7974..4e014e5 100644 --- a/src/layout_store.cpp +++ b/src/layout_store.cpp @@ -448,7 +448,8 @@ bool load_layout(const std::string& path, LayoutState& out) { std::string line; // What the last `[[...]]` header opened, so keys land in the right record. - enum class Section { Top, Plot, Calibration, Unit, Color, Math, Measure } section = Section::Top; + enum class Section { Top, Plot, Window, WindowPlot, Calibration, Unit, Color, Math, Measure } section = + Section::Top; int plot_count = 0; bool plot_section_seen = false; while (std::getline(in, line)) { @@ -459,6 +460,14 @@ bool load_layout(const std::string& path, LayoutState& out) { section = Section::Plot; plot_section_seen = true; st.rows.emplace_back(); + } else if (t == "[[window]]") { + section = Section::Window; + st.windows.emplace_back(); + } else if (t == "[[window.plot]]") { + // A window's own plot rows: the same shape as the main layout's `[[plot]]`, one level in. + if (st.windows.empty()) st.windows.emplace_back(); + section = Section::WindowPlot; + st.windows.back().rows.emplace_back(); } else if (t == "[[calibration]]") { section = Section::Calibration; st.calibration.emplace_back(); @@ -608,6 +617,35 @@ bool load_layout(const std::string& path, LayoutState& out) { } catch (...) { } } + } else if (section == Section::Window && !st.windows.empty()) { + if (key == "name") { + st.windows.back().name = strip_quote(value); + } else if (key == "detached") { + st.windows.back().detached = value == "true"; + } + } else if ((section == Section::WindowPlot && !st.windows.empty() && + !st.windows.back().rows.empty())) { + // The window's rows take exactly the main layout's keys; `rows` is reached through the + // window record so the parser stays one shape. + LayoutState::Row& row = st.windows.back().rows.back(); + if (key == "signals") { + std::vector names; + if (value.size() >= 2 && value.front() == '[' && value.back() == ']') { + parse_string_array(value.substr(1, value.size() - 2), names); + } + row.signals = names; + } else if (key == "weight") { + row.weight = std::strtod(value.c_str(), nullptr); + } else if (key == "row") { + try { + const int r = std::stoi(value); + if (r >= 0) row.row = r; + } catch (...) { + } + } else if (key == "width") { + const double w = std::strtod(value.c_str(), nullptr); + if (std::isfinite(w) && w > 0.0) row.width = w; + } } else if (section == Section::Plot && !st.rows.empty()) { if (key == "signals") { std::vector names; @@ -802,8 +840,7 @@ bool save_layout(const std::string& path, const LayoutState& state) { if (state.buffer_samples > 0) o << "buffer_samples = " << state.buffer_samples << "\n"; o << "relative_time = " << (state.relative_time ? "true" : "false") << "\n"; if (state.capture_packets > 0) o << "capture_packets = " << state.capture_packets << "\n"; - for (const auto& row : state.rows) { - o << "\n[[plot]]\n"; + const auto write_row = [&](const LayoutState::Row& row) { // A row of -1 is a state built without the split (nothing in the app does that now); leaving the // key out keeps it readable as the one-cell-per-row file it is. if (row.row >= 0) o << "row = " << row.row << "\nwidth = " << row.width << "\n"; @@ -813,6 +850,19 @@ bool save_layout(const std::string& path, const LayoutState& state) { o << toml_escape(row.signals[i]); } o << "]\n"; + }; + for (const auto& row : state.rows) { + o << "\n[[plot]]\n"; + write_row(row); + } + // The extra windows: written so a closed one can be reopened, never opened by a launch. + for (const auto& window : state.windows) { + o << "\n[[window]]\nname = " << toml_escape(window.name) + << "\ndetached = " << (window.detached ? "true" : "false") << "\n"; + for (const auto& row : window.rows) { + o << "\n[[window.plot]]\n"; + write_row(row); + } } for (const auto& cal : state.calibration) { o << "\n[[calibration]]\nname = " << toml_escape(cal.name) << "\ngain = " << cal.gain diff --git a/src/layout_store.hpp b/src/layout_store.hpp index 76422f2..9bbde2a 100644 --- a/src/layout_store.hpp +++ b/src/layout_store.hpp @@ -36,6 +36,19 @@ struct LayoutState { double window_s = 0.0; ///< 0 keeps the default window std::vector rows; + /// An **extra window**: a scope or detached window, remembered with the session but never opened + /// by a launch. A window is a view a user closed, and bringing one back is a decision -- the + /// *Reopen closed windows* entry asks for it, so a start never conjures windows onto a desktop + /// that does not want them (a tiling WM would tile them straight away). `name` is the window's own + /// short name (`plot 2`, `scope 3`), `detached` whether its figure came from a detach, and `rows` + /// the same plot rows the main layout carries: signals, row weights and cell widths. + struct Window { + std::string name; + bool detached = false; + std::vector rows; + }; + std::vector windows; + struct Calibration { std::string name; double gain = 1.0; diff --git a/src/ui.cpp b/src/ui.cpp index 0138d32..995a4c1 100644 --- a/src/ui.cpp +++ b/src/ui.cpp @@ -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 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(RowSet::kMaxRows)) cells.resize(RowSet::kMaxRows); + } + scope->rows->set_cells_per_row(cells); + std::vector row_weights; + row_weights.reserve(cells.size()); + std::size_t at = 0; + for (const int n : cells) { + row_weights.push_back(static_cast(state.rows[at].weight)); + at += static_cast(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(cells[fr]); + if (n >= 2) { + std::vector widths; + for (std::size_t c = 0; c < n; ++c) widths.push_back(static_cast(state.rows[at + c].width)); + scope->rows->set_cell_weights(static_cast(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 signals; + { + std::lock_guard 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(app.rows->signal_colors().at(name)) + : 0; + }; + for (int i = 0; i < scope->rows->row_count() && + static_cast(i) < state.rows.size(); + ++i) { + const LayoutState::Row& row = state.rows[static_cast(i)]; + for (int sl = 0; sl < RowSet::kSlotsPerRow && static_cast(sl) < row.signals.size(); ++sl) { + const std::string& name = row.signals[static_cast(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(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(user); + std::vector 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(user)); diff --git a/src/ui_app.hpp b/src/ui_app.hpp index faebc0d..ad26bd7 100644 --- a/src/ui_app.hpp +++ b/src/ui_app.hpp @@ -225,6 +225,10 @@ 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; + /// False while attaching: the window is closing *because* its figure went back to the main + /// window, so there is nothing to remember (remembering it would offer to reopen a window + /// whose figure is on screen again). + bool remember_on_close = true; /// 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. @@ -252,6 +256,12 @@ struct App { std::unique_ptr rows; }; std::vector> scopes; + /// The **closed** extra windows, as the session remembers them: a window's arrangement is kept + /// when it is closed (and across a restart), and the *Reopen closed windows* entry opens them -- + /// a launch never does, because a window is a view the user closed on purpose. + std::vector closed_windows; + /// The menu entry that reopens them, so its label can carry the count. + GtkWidget* reopen_scopes_item = nullptr; /// Every empty scope window is numbered from this, so two of them never share a name. int scope_seq = 0; @@ -2090,6 +2100,16 @@ bool assign_signal_to_scope(App& app, App::ScopeWindow& scope, std::size_t signa /// the application through `run_app_action()`. gboolean on_scope_key(GtkEventControllerKey* controller, guint keyval, guint keycode, GdkModifierType state, gpointer user); +/// The session record of a window: its name, whether it was detached, and its plot rows. +LayoutState::Window scope_window_state(const App::ScopeWindow& scope); +/// Remembers a window as closed (`closed_windows`, replaced by name) -- what its destroy handler +/// does, and what keeps a closed window reopenable. +void remember_closed_window(App& app, const App::ScopeWindow& scope); +/// Opens a window from a remembered state: an empty scope window, then its rows, weights and +/// signals. Returns the opened window (nullptr when it could not be made). +App::ScopeWindow* open_scope_window_from_state(App& app, const LayoutState::Window& state); +/// Refreshes the *Reopen closed windows* entry: its count and whether it is sensitive. +void sync_reopen_scopes_item(App& app); /// 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 diff --git a/src/ui_layout.cpp b/src/ui_layout.cpp index 071e295..12a7300 100644 --- a/src/ui_layout.cpp +++ b/src/ui_layout.cpp @@ -14,6 +14,46 @@ namespace ui_impl { /// Reads the session layout back out of the model and rows: the display window, /// each row's slot signal names, and each signal's calibration. +LayoutState::Window scope_window_state(const App::ScopeWindow& scope) { + LayoutState::Window w; + w.name = scope.name; + w.detached = scope.detached; + if (scope.rows == nullptr) return w; + for (int i = 0; i < scope.rows->row_count(); ++i) { + LayoutState::Row row; + const RowSet::Row& r = scope.rows->row(i); + for (int s = 0; s < RowSet::kSlotsPerRow; ++s) { + const std::size_t si = static_cast(s); + row.signals.push_back(r.layer[si] > 0 ? std::string() : r.signal_name[si]); + } + const int fr = scope.rows->figure_row_of(i); + const std::vector& weights = scope.rows->row_weights(); + row.row = fr; + if (fr >= 0 && static_cast(fr) < weights.size()) { + row.weight = weights[static_cast(fr)]; + } + const std::vector& cw = scope.rows->cell_weights(fr); + const int cell = scope.rows->cell_of(i); + if (cell >= 0 && static_cast(cell) < cw.size()) row.width = cw[static_cast(cell)]; + w.rows.push_back(std::move(row)); + } + return w; +} + +void remember_closed_window(App& app, const App::ScopeWindow& scope) { + LayoutState::Window w = scope_window_state(scope); + for (LayoutState::Window& known : app.closed_windows) { + if (known.name != w.name) continue; + known = std::move(w); + sync_reopen_scopes_item(app); + schedule_layout_save(app); + return; + } + app.closed_windows.push_back(std::move(w)); + sync_reopen_scopes_item(app); + schedule_layout_save(app); +} + LayoutState collect_layout(App& app) { LayoutState st; st.window_s = app.rows->time_window(); @@ -64,6 +104,12 @@ LayoutState collect_layout(App& app) { if (model.signals()[si].math || !on[si]) continue; st.enabled.push_back(model.signals()[si].name); } + // The extra windows: the **open** ones by their present arrangement, then the closed ones as + // they were remembered. A launch never opens them; the *Reopen closed windows* entry does. + for (const std::unique_ptr& scope : app.scopes) { + st.windows.push_back(scope_window_state(*scope)); + } + for (const LayoutState::Window& w : app.closed_windows) st.windows.push_back(w); // Only the signals the user actually re-coloured: the rest take the positional palette, and // writing those out would freeze a colour that is meant to follow the signal list. for (const auto& [name, rgb] : app.rows->signal_colors()) { @@ -437,6 +483,10 @@ void apply_layout(App& app, const LayoutState& st) { break; } } + // The extra windows are **remembered, not opened**: a session's windows are views the user + // closed, and a launch that tiled them onto the desktop would take that decision for them. + app.closed_windows = st.windows; + sync_reopen_scopes_item(app); app.measures = st.measures; app.measure_stats_on = st.measure_stats; app.updating_panels = true; diff --git a/src/ui_probe.cpp b/src/ui_probe.cpp index 79c04de..e2eef68 100644 --- a/src/ui_probe.cpp +++ b/src/ui_probe.cpp @@ -1769,6 +1769,11 @@ gboolean scope_probe_step(gpointer user) { if (a->window == nullptr) return G_SOURCE_REMOVE; switch (a->dbg_scope_step) { case 0: { + // What the session remembered, and what a launch opened: the windows are never opened at + // start, so `scopes` is one (the main window) and `closed` is the count read from the file. + std::fprintf(stderr, "[scope-probe] loaded closed=%zu scopes=%zu\n", + a->closed_windows.size(), a->scopes.size()); + std::fflush(stderr); const std::vector& signals = a->model->signals(); for (std::size_t i = 0; i < signals.size(); ++i) { if (signals[i].name == "sine") a->model->set_signal_enabled(i, true); @@ -1805,9 +1810,22 @@ gboolean scope_probe_step(gpointer user) { } const App::ScopeWindow& copy = *a->scopes[0]; const App::ScopeWindow& empty = *a->scopes[1]; - const std::string copied = copy.rows->row(0).signal_name[0]; - const bool copied_bound = copy.rows->row(0).channel[0] >= 0; - const bool empty_ok = empty.rows->row(0).signal_name[0].empty(); + // The first non-empty slot of the copied row: the copy is slot-for-slot faithful, and slot 0 + // is empty whenever an earlier probe (the enable-all one disables a signal at its end) left + // the main's slot 0 empty -- so this reads *a* copied trace without assuming which slot. + std::string copied; + bool copied_bound = false; + for (int sl = 0; sl < RowSet::kSlotsPerRow; ++sl) { + const std::size_t si = static_cast(sl); + if (copy.rows->row(0).signal_name[si].empty()) continue; + copied = copy.rows->row(0).signal_name[si]; + copied_bound = copy.rows->row(0).channel[si] >= 0; + break; + } + bool empty_ok = true; + for (int sl = 0; sl < RowSet::kSlotsPerRow; ++sl) { + if (!empty.rows->row(0).signal_name[static_cast(sl)].empty()) empty_ok = false; + } const bool windows = copy.window != nullptr && empty.window != nullptr && gtk_widget_get_visible(GTK_WIDGET(copy.window)) && gtk_widget_get_visible(GTK_WIDGET(empty.window)); @@ -1973,6 +1991,32 @@ gboolean scope_probe_step(gpointer user) { 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); + // **Closing remembers, the menu reopens.** A closed window's arrangement stays in the + // session (`closed_windows`), a launch never opens it, and the *Reopen closed windows* entry + // brings it back -- so this leg closes the empty window, reads what was remembered, and + // clicks the entry. + const std::string closed_name = a->scopes[1]->name; + const std::size_t scopes_before = a->scopes.size(); + gtk_window_destroy(GTK_WINDOW(a->scopes[1]->window)); + const std::size_t remembered = a->closed_windows.size(); + const bool remembered_name = remembered > 0 && a->closed_windows.back().name == closed_name && + !a->closed_windows.back().rows.empty(); + const std::size_t scopes_after_close = a->scopes.size(); + if (a->reopen_scopes_item != nullptr) { + g_signal_emit_by_name(a->reopen_scopes_item, "clicked"); + } + const std::size_t scopes_after = a->scopes.size(); + const int back_rows = scopes_after > scopes_after_close ? a->scopes.back()->rows->row_count() : 0; + const std::string back_slot = + scopes_after > scopes_after_close && a->scopes.back()->rows->row_count() > 0 + ? a->scopes.back()->rows->row(0).signal_name[0] + : std::string(); + std::fprintf(stderr, + "[scope-probe] reopen scopes=%zu->%zu(closed)->%zu remembered=%zu name=%d " + "rows=%d slot0='%s' left=%zu\n", + scopes_before, scopes_after_close, scopes_after, remembered, + remembered_name ? 1 : 0, back_rows, back_slot.c_str(), + a->closed_windows.size()); std::fflush(stderr); return G_SOURCE_REMOVE; }