From 2218aed8397cf0fb706ff564f7511040b0e80e3d Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Fri, 2 Oct 2026 11:30:54 +0200 Subject: [PATCH] Updated local storage --- CLAUDE.md | 11 ++ CMakeLists.txt | 1 + README.md | 37 +++++++ src/layout_store.cpp | 248 +++++++++++++++++++++++++++++++++++++++++++ src/layout_store.hpp | 46 ++++++++ src/main.cpp | 2 +- src/model.cpp | 7 ++ src/model.hpp | 8 ++ src/ui.cpp | 190 ++++++++++++++++++++++++++++++++- src/ui.hpp | 3 +- 10 files changed, 548 insertions(+), 5 deletions(-) create mode 100644 src/layout_store.cpp create mode 100644 src/layout_store.hpp diff --git a/CLAUDE.md b/CLAUDE.md index 75ed71e..334f70f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -218,6 +218,17 @@ Two lines of the self-check output are assertions in disguise: floor are the two refusals, and because the probe emits `::clicked` straight at the widget it reaches the handler even when the button is insensitive, so `add above 0` there must leave `rows=` and `traces=` untouched rather than growing a ninth row. +- `header: window_button_w=W label_w=L bar_w=B` (stderr, once a second) — **`W` must be + a small constant and must not track `B`**: the window control is a header-bar child, + and an expanding one is measured into the title's centre box, so it stretched from the + label to the title (516 px at a 1270 px bar before the fix, 121 px after). A `W` that + grows with the window width is that bug back. +- **The session layout round trip** (`~/.config/udpscope/_.toml`) — run once + with a drop and a window change, then again with no interaction: the second run's dump + must read `layout loaded from ...` and `layout: rows=R window_s=W row0=[...]` with the + first run's values, and the signals it names must come back *acquired* (a `row 0 slot 0 + -> channel ... samples=N` line). Delete the file before a run whose layout you did not + mean to inherit (`127.0.0.1_9540.toml` and friends accumulate otherwise). - `badge frames: lit=L dim=D idle=I` — the activity badge's phase per frame. Over a run that acquired anything **both `lit` and `dim` must be non-zero**: that is what "it blinks" means, and a badge stuck lit is indistinguishable from a working one in a diff --git a/CMakeLists.txt b/CMakeLists.txt index 8eef129..9a01d79 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -40,6 +40,7 @@ add_executable(udpscope src/acquisition.cpp src/plot_host.cpp src/export.cpp + src/layout_store.cpp src/ui.cpp ) diff --git a/README.md b/README.md index dcbb88d..8e1fe63 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,8 @@ src/acquisition.* the UDPStreamer client, the producer thread, frame decoding src/trigger.* the trigger configuration and the producer-side state machine src/plot_host.* the one plot::Figure, its rows' traces and cursors, the x link group src/export.* the visible range as a table, and the .npy / .csv writers +src/layout_store.* the per-source session layout file (TOML), written and read back +src/event_series.hpp an immutable captured event as a plot::data::Series src/ui.* the GTK4 window: toolbar, trigger panel, signal panel, plot box ``` @@ -56,6 +58,41 @@ frame and the GUI thread takes once per frame around binding the traces. Samples cross the boundary through `plot::data::RingBuffer` (single producer, single consumer), never through the lock. +## The session layout file + +What you arranged is remembered per data source, in +`~/.config/udpscope/_.toml` (`$XDG_CONFIG_HOME` is honoured; the +host's unsafe characters are replaced with `_`). The file holds the display +window, the row layout and which signal each of a row's slots plots, and the +per-signal gain and offset: + +```toml +window_s = 2 +plots = 1 + +[[plot]] +signals = ["Ch1", "", "", ""] + +[[calibration]] +name = "Ch1" +gain = 2 +offset = 0.5 +``` + +It is written (400 ms after the last change, and flushed on close) whenever the +window, the layout, a drop or a calibration changes, and applied once, when the +first CONFIG names the signals -- signal *names* are the identity, because a +CONFIG renumbers and an enable/disable rebuilds the channel list. Signals the +file plots are enabled on load, since a restored plot nobody acquires would come +back blank. It is plain TOML and safe to edit; a malformed file loads whatever +parsed rather than refusing to start. Writes go to a sibling temp file and are +renamed over the target, so a crash mid-write leaves the old layout. + +The same reason the window control is a fixed-width label in the header bar +(see the DPI/relayout notes in `ui.cpp`) is why the bar's `window` control does +not expand: a header-bar child that expands is measured into the title's centre +box, so the control stretched to the title and every window resize moved it. + ## How the spec maps onto the code **One buffer of `N+1` slices, plus frozen event snapshots.** `ScopeModel` diff --git a/src/layout_store.cpp b/src/layout_store.cpp new file mode 100644 index 0000000..98747e8 --- /dev/null +++ b/src/layout_store.cpp @@ -0,0 +1,248 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari + +#include "layout_store.hpp" + +#include +#include +#include +#include +#include +#include + +namespace udpscope { + +namespace { + +/// Anything outside a safe filename set becomes '_': the host is a dotted quad +/// today, but the CLI takes any string and a '/' or ':' in it must not escape +/// the directory. +std::string sanitize(const std::string& host) { + std::string out; + out.reserve(host.size()); + for (char c : host) { + const bool ok = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || + (c >= '0' && c <= '9') || c == '.' || c == '_' || c == '-'; + out.push_back(ok ? c : '_'); + } + return out.empty() ? "_" : out; +} + +std::string config_dir() { + const char* xdg = std::getenv("XDG_CONFIG_HOME"); + if (xdg != nullptr && xdg[0] != '\0') return std::string(xdg) + "/udpscope"; + const char* home = std::getenv("HOME"); + if (home == nullptr || home[0] == '\0') return "/tmp/udpscope-config"; + return std::string(home) + "/.config/udpscope"; +} + +void mkdirs(const std::string& path) { + std::string cur; + for (const char c : path) { + cur.push_back(c); + if (c == '/' || cur.size() == path.size()) { + if (cur == "/" || cur.back() != '/') continue; + ::mkdir(cur.c_str(), 0755); + } + } +} + +std::string toml_escape(const std::string& s) { + std::string out = "\""; + for (char c : s) { + if (c == '"' || c == '\\') out.push_back('\\'); + out.push_back(c); + } + return out + "\""; +} + +bool unescape(std::string& s) { + std::string out; + for (std::size_t i = 0; i < s.size(); ++i) { + if (s[i] != '\\') { + out.push_back(s[i]); + continue; + } + if (++i >= s.size()) return false; + if (s[i] == '"' || s[i] == '\\') { + out.push_back(s[i]); + } else { + return false; + } + } + s = out; + return true; +} + +std::string trim(const std::string& s) { + const auto a = s.find_first_not_of(" \t\r"); + if (a == std::string::npos) return {}; + const auto b = s.find_last_not_of(" \t\r"); + return s.substr(a, b - a + 1); +} + +/// `"a", "b"` (with the brackets already stripped) -> ["a", "b"]. +bool parse_string_array(const std::string& in, std::vector& out) { + out.clear(); + std::size_t i = 0; + while (i < in.size()) { + while (i < in.size() && (in[i] == ' ' || in[i] == ',' || in[i] == '\t')) ++i; + if (i >= in.size()) break; + if (in[i] != '"') return false; + ++i; + std::string tok; + while (i < in.size() && in[i] != '"') { + if (in[i] == '\\' && i + 1 < in.size()) { + tok.push_back(in[i]); + ++i; + } + tok.push_back(in[i]); + ++i; + } + if (i >= in.size()) return false; + ++i; // closing quote + if (!unescape(tok)) return false; + out.push_back(tok); + } + return true; +} + +} // namespace + +std::string layout_path(const std::string& host, std::uint16_t port) { + const std::string dir = config_dir(); + mkdirs(dir); + return dir + "/" + sanitize(host) + "_" + std::to_string(port) + ".toml"; +} + +bool load_layout(const std::string& path, LayoutState& out) { + std::ifstream in(path); + if (!in) return false; + + LayoutState st; + st.window_s = 0.0; + st.rows.clear(); + st.calibration.clear(); + + std::string line; + // What the last `[[...]]` header opened, so keys land in the right record. + enum class Section { Top, Plot, Calibration } section = Section::Top; + int plot_count = 0; + bool plot_section_seen = false; + while (std::getline(in, line)) { + const std::string t = trim(line); + if (t.empty() || t[0] == '#') continue; + if (t[0] == '[') { + if (t == "[[plot]]") { + section = Section::Plot; + plot_section_seen = true; + st.rows.emplace_back(); + } else if (t == "[[calibration]]") { + section = Section::Calibration; + st.calibration.emplace_back(); + } else { + section = Section::Top; // an unknown table: keep its keys out of ours + } + continue; + } + const auto eq = t.find('='); + if (eq == std::string::npos) continue; + const std::string key = trim(t.substr(0, eq)); + const std::string value = trim(t.substr(eq + 1)); + auto strip_quote = [](const std::string& v) { + if (v.size() >= 2 && v.front() == '"' && v.back() == '"') { + std::string s = v.substr(1, v.size() - 2); + std::string out; + for (std::size_t i = 0; i < s.size(); ++i) { + if (s[i] == '\\' && i + 1 < s.size()) ++i; + out.push_back(s[i]); + } + return out; + } + return v; + }; + + if (section == Section::Top) { + if (key == "window_s") { + try { + st.window_s = std::stod(value); + } catch (...) { + } + } else if (key == "plots") { + // Only a fallback for a file with no [[plot]] sections: the writer emits + // both, and honouring both appended the sections onto the pre-created + // rows (a 2-plot file loaded as 4). + try { + plot_count = std::max(0, std::stoi(value)); + } catch (...) { + } + } + } else if (section == Section::Plot && !st.rows.empty()) { + if (key == "signals") { + std::vector names; + if (value.size() >= 2 && value.front() == '[' && value.back() == ']') { + parse_string_array(value.substr(1, value.size() - 2), names); + } + st.rows.back().signals = names; + } + } else if (section == Section::Calibration && !st.calibration.empty()) { + if (key == "name") { + st.calibration.back().name = strip_quote(value); + } else if (key == "gain") { + try { + st.calibration.back().gain = std::stod(value); + } catch (...) { + } + } else if (key == "offset") { + try { + st.calibration.back().offset = std::stod(value); + } catch (...) { + } + } + } + } + if (!plot_section_seen && plot_count > 0) { + st.rows.assign(static_cast(plot_count), {}); + } + out = std::move(st); + return true; +} + +bool save_layout(const std::string& path, const LayoutState& state) { + std::ostringstream o; + o << "# udpscope session layout (written by the app; safe to edit)\n"; + if (state.window_s > 0.0) o << "window_s = " << state.window_s << "\n"; + o << "plots = " << state.rows.size() << "\n"; + for (const auto& row : state.rows) { + o << "\n[[plot]]\nsignals = ["; + for (std::size_t i = 0; i < row.signals.size(); ++i) { + if (i != 0) o << ", "; + o << toml_escape(row.signals[i]); + } + o << "]\n"; + } + for (const auto& cal : state.calibration) { + o << "\n[[calibration]]\nname = " << toml_escape(cal.name) << "\ngain = " << cal.gain + << "\noffset = " << cal.offset << "\n"; + } + + // A sibling temp file + rename: a crash mid-write leaves the old layout, not a + // truncated one that would fail to parse next start. + const std::string tmp = path + ".tmp"; + { + std::ofstream f(tmp, std::ios::trunc); + if (!f) return false; + f << o.str(); + if (!f.good()) { + ::remove(tmp.c_str()); + return false; + } + } + if (std::rename(tmp.c_str(), path.c_str()) != 0) { + ::remove(tmp.c_str()); + return false; + } + return true; +} + +} // namespace udpscope diff --git a/src/layout_store.hpp b/src/layout_store.hpp new file mode 100644 index 0000000..96a1f7d --- /dev/null +++ b/src/layout_store.hpp @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari +#pragma once + +#include +#include +#include + +namespace udpscope { + +/// The user-arranged part of a session, persisted per data source in +/// `~/.config/udpscope/_.toml`: the display window, the plot layout +/// (rows and which signal each of a row's slots holds) and the per-signal +/// calibration. Signal names are the stable identity (channel indexes are +/// renumbered by every enable/disable, see the model's own invariant). +struct LayoutState { + struct Row { + /// One entry per slot; an empty string is an empty slot. + std::vector signals; + }; + + double window_s = 0.0; ///< 0 keeps the default window + std::vector rows; + + struct Calibration { + std::string name; + double gain = 1.0; + double offset = 0.0; + }; + std::vector calibration; +}; + +/// `~/.config/udpscope/_.toml`, with the directory created. +/// Characters unsafe in a filename are replaced in the host part. +std::string layout_path(const std::string& host, std::uint16_t port); + +/// Parses the file's TOML subset. Returns false when the file is missing or +/// malformed; `out` is left usable either way (a malformed file loads as much as +/// parsed, which beats refusing a hand-edited file over one stray character). +bool load_layout(const std::string& path, LayoutState& out); + +/// Writes `state` as TOML. Atomic in effect: writes a sibling temp file and +/// renames it over the target. +bool save_layout(const std::string& path, const LayoutState& state); + +} // namespace udpscope diff --git a/src/main.cpp b/src/main.cpp index 73d10f3..7f2c5d0 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -30,7 +30,7 @@ int main(int argc, char** argv) { return 1; } - const int result = udpscope::run_ui(argc, argv, model, acquisition, rows); + const int result = udpscope::run_ui(argc, argv, cfg, model, acquisition, rows); acquisition.stop(); return result; } diff --git a/src/model.cpp b/src/model.cpp index fb8f176..d9345d6 100644 --- a/src/model.cpp +++ b/src/model.cpp @@ -184,6 +184,12 @@ void ScopeModel::rebuild_channels() { ch.unit = sig.unit; ch.elements = sig.elements(); ch.dt = sig.sampling_rate > 0.0 ? 1.0 / sig.sampling_rate : 0.0; + // A calibration the user already set for this name (before it was enabled, or + // before a CONFIG renumbered the channels) still applies. + if (auto it = calibration_.find(sig.name); it != calibration_.end()) { + ch.gain = it->second.first; + ch.offset = it->second.second; + } channels_.push_back(std::move(ch)); } } @@ -277,6 +283,7 @@ void ScopeModel::set_signal_enabled(std::size_t signal, bool on) { void ScopeModel::set_calibration(std::size_t signal, double gain, double offset) { std::lock_guard lock(mutex_); + if (signal < signals_.size()) calibration_[signals_[signal].name] = {gain, offset}; bool any = false; for (ChannelInfo& c : channels_) { if (c.signal != signal) continue; diff --git a/src/model.hpp b/src/model.hpp index 003eaf6..cff4a65 100644 --- a/src/model.hpp +++ b/src/model.hpp @@ -11,6 +11,7 @@ #include #include +#include #include #include #include @@ -183,6 +184,11 @@ class ScopeModel { static bool is_time_signal(const std::vector& signals, std::size_t signal); void set_signal_enabled(std::size_t signal, bool on); + /// Stores the calibration for a signal's *name* and applies it to that signal's + /// live channels. Keyed by name, not index: a CONFIG renumbers and an + /// enable/disable rebuilds the channel list, and `rebuild_channels()` re-applies + /// from here -- so a calibration set before a signal was enabled, or surviving a + /// CONFIG, is not silently dropped. void set_calibration(std::size_t signal, double gain, double offset); std::size_t channel_count() const noexcept { return channels_.size(); } @@ -352,6 +358,8 @@ class ScopeModel { std::atomic trigger_pending_{false}; std::atomic capture_edge_{0.0}; std::atomic> event_; ///< the event on screen + /// Per-signal-name calibration (see `set_calibration`). + std::unordered_map> calibration_; std::atomic event_seq_{0}; std::atomic display_epoch_{0.0}; std::atomic trigger_armed_{false}; diff --git a/src/ui.cpp b/src/ui.cpp index 33ce24e..5fdc37a 100644 --- a/src/ui.cpp +++ b/src/ui.cpp @@ -17,6 +17,7 @@ #include "acquisition.hpp" #include "config.hpp" #include "export.hpp" +#include "layout_store.hpp" #include "model.hpp" #include "plot_host.hpp" @@ -121,6 +122,13 @@ struct App { GtkWidget* cursors_toggle = nullptr; GtkWidget* cursors_reset = nullptr; + /// The persisted-layout file this data source maps to (--server/--port), a + /// debounced save handle, and whether the file has been applied once the + /// CONFIG arrived. + std::string source_host; + std::uint16_t source_port = 0; + guint layout_save_source = 0; + bool layout_loaded = false; unsigned signal_generation = ~0u; bool updating_panels = false; ///< guards signal handlers during a rebuild bool relayout_pending = false; @@ -292,6 +300,121 @@ GtkWidget* labeled_box(const char* label, GtkWidget* child) { return box; } +/// Same, without expanding the child. Header-bar children expand through the +/// title's centre box (a start-packed expanding widget is measured into it), so +/// the window control there stretched all the way to the title; panels want the +/// fill, the bar does not. +// --- persisted layout ------------------------------------------------------- + +void apply_time_window(App& app, double seconds); +int signal_color_index(const std::vector& signals, std::size_t signal); +void rebuild_signal_panels(App& app); + +/// 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 collect_layout(App& app) { + LayoutState st; + st.window_s = app.rows->time_window(); + ScopeModel& model = *app.model; + for (int i = 0; i < app.rows->row_count(); ++i) { + LayoutState::Row row; + const RowSet::Row& r = app.rows->row(i); + for (int s = 0; s < RowSet::kSlotsPerRow; ++s) { + row.signals.push_back(r.signal_name[static_cast(s)]); + } + st.rows.push_back(std::move(row)); + } + for (std::size_t si = 0; si < model.signals().size(); ++si) { + for (std::size_t c = 0; c < model.channels().size(); ++c) { + const ChannelInfo& ch = model.channels()[c]; + if (ch.signal != si) continue; + st.calibration.push_back({model.signals()[si].name, ch.gain, ch.offset}); + break; + } + } + return st; +} + +/// Writes the layout file (a debounced schedule lands here once). Errors go to +/// stderr; success is silent -- a save per click must not narrate. +void save_layout_now(App& app) { + if (app.source_port == 0) return; + if (!save_layout(layout_path(app.source_host, app.source_port), collect_layout(app))) { + std::fprintf(stderr, "[udpscope] failed to write the layout file\n"); + std::fflush(stderr); + } +} + +/// One-shot debounce: a burst of changes (a drop, a row insert, a calibration +/// commit in the same second) writes once, 400 ms after the last one. +gboolean layout_save_tick(gpointer user) { + App* app = static_cast(user); + app->layout_save_source = 0; + save_layout_now(*app); + return G_SOURCE_REMOVE; +} + +void schedule_layout_save(App& app) { + if (app.source_port == 0) return; + if (app.layout_save_source != 0) return; + app.layout_save_source = g_timeout_add(400, layout_save_tick, &app); +} + +/// Applies a loaded layout: rows, slot assignments, calibration, window. Names +/// that no longer exist in the stream are skipped rather than freeing their slot +/// (a stream that renamed a signal must not blank the user's layout). +void apply_layout(App& app, const LayoutState& st) { + ScopeModel& model = *app.model; + const auto& signals = model.signals(); + + int rows = static_cast(st.rows.size()); + if (rows > RowSet::kMaxRows) rows = RowSet::kMaxRows; + if (rows > 0) app.rows->set_row_count(rows); + + for (int i = 0; i < rows; ++i) { + int slot = 0; + for (const std::string& name : st.rows[static_cast(i)].signals) { + if (slot >= RowSet::kSlotsPerRow) break; + if (name.empty()) continue; + for (std::size_t si = 0; si < signals.size(); ++si) { + if (signals[si].name != name) continue; + // Enabling is what starts acquisition (a loaded plot nobody acquires would + // come back blank), and it is what a drop does for the signal it places. + app.rows->assign(i, slot, name, name, signal_color_index(signals, si)); + model.set_signal_enabled(si, true); + ++slot; + break; + } + } + } + + for (const auto& cal : st.calibration) { + for (std::size_t si = 0; si < signals.size(); ++si) { + if (signals[si].name != cal.name) continue; + model.set_calibration(si, cal.gain, cal.offset); + break; + } + } + + if (st.window_s > 0.0) apply_time_window(app, st.window_s); + + // set_row_count() rebuilt the figure (its row count is fixed at construction): + // re-point the area, as every other row-count change must, and refresh the + // signal panel's chips so they name the traces the file brought back. + if (app.area != nullptr) app.area->set_figure(&app.rows->figure()); + app.relayout_pending = true; + rebuild_signal_panels(app); +} + +GtkWidget* labeled_box_fixed(const char* label, GtkWidget* child) { + GtkWidget* box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6); + GtkWidget* l = gtk_label_new(label); + gtk_widget_set_halign(l, GTK_ALIGN_START); + gtk_box_append(GTK_BOX(box), l); + gtk_box_append(GTK_BOX(box), child); + return box; +} + GtkWidget* spin(double min, double max, double step, int digits) { GtkWidget* s = gtk_spin_button_new_with_range(min, max, step); gtk_spin_button_set_digits(GTK_SPIN_BUTTON(s), digits); @@ -646,6 +769,7 @@ void on_layout_row_remove(GtkButton* button, gpointer user) { // The signal list's chips name the trace a signal is drawn as, and a removed row took // its traces with it. rebuild_signal_panels(*app); + schedule_layout_save(*app); } void on_layout_row_insert(GtkButton* button, gpointer user) { @@ -659,6 +783,7 @@ void on_layout_row_insert(GtkButton* button, gpointer user) { if (app->area != nullptr) app->area->set_figure(&app->rows->figure()); rebuild_layout_editor(*app); rebuild_signal_panels(*app); + schedule_layout_save(*app); } /// Restates where the buttons sit, from the rects the last frame was laid out with. @@ -875,6 +1000,7 @@ void on_calibrate_apply(GtkButton*, gpointer user) { CalibContext* ctx = static_cast(user); ctx->app->model->set_calibration(ctx->signal, gtk_spin_button_get_value(GTK_SPIN_BUTTON(ctx->gain)), gtk_spin_button_get_value(GTK_SPIN_BUTTON(ctx->offset))); + schedule_layout_save(*ctx->app); gtk_window_destroy(GTK_WINDOW(ctx->window)); } @@ -1151,6 +1277,7 @@ void apply_time_window(App& app, double seconds) { app.rows->set_time_window(clamped); app.model->set_event_span(clamped); sync_window_display(app, clamped); + schedule_layout_save(app); } /// Enter in the entry. A number that will not parse restores the current window instead of @@ -1575,6 +1702,7 @@ gboolean on_drop_channel(GtkDropTarget*, const GValue* value, double x, double y const std::vector& signals = app->model->signals(); const SignalInfo& sig = signals[signal]; app->rows->assign(row, slot, sig.name, name, signal_color_index(signals, signal)); + schedule_layout_save(*app); app->relayout_pending = true; rebuild_signal_panels(*app); // the row's colour chip now has a trace to name cancel_drag_on(*app, row); @@ -1656,8 +1784,37 @@ gboolean tick(gpointer user) { app->relayout_pending = false; relayout_areas(*app); } + // Header geometry, once a second: the window control must be a fixed, small + // widget beside the title whatever the bar's width is (it used to expand through + // the title's centre box, i.e. window_button_w tracked the window width). + if (app->window_button != nullptr) { + static gint64 next_header_log = 0; + const gint64 now = g_get_monotonic_time(); + if (now >= next_header_log) { + next_header_log = now + G_TIME_SPAN_SECOND; + std::fprintf(stderr, "[udpscope] header: window_button_w=%d label_w=%d bar_w=%d\n", + gtk_widget_get_width(app->window_button), + gtk_widget_get_width(app->window_label), + gtk_widget_get_width(app->window)); + std::fflush(stderr); + } + } if (model.generation() != app->signal_generation) rebuild_signal_panels(*app); + // The first CONFIG names the signals, which is what the persisted layout is + // written against: apply it once, here, and never again (a later CONFIG only + // renames or renumbers; the user's layout survives those untouched). + if (!app->layout_loaded && !model.signals().empty()) { + app->layout_loaded = true; + LayoutState st; + if (load_layout(layout_path(app->source_host, app->source_port), st)) { + apply_layout(*app, st); + std::fprintf(stderr, "[udpscope] layout loaded from %s\n", + layout_path(app->source_host, app->source_port).c_str()); + std::fflush(stderr); + } + } + // What the display shows, decided once for the whole frame: the producer can open or // close a capture between any two reads of the model, and the bind, the epoch, the pin // and the status line all have to describe the same picture. @@ -2255,7 +2412,7 @@ GtkWidget* build_toolbar(App& app) { GtkWidget* bar = gtk_header_bar_new(); gtk_header_bar_pack_start(GTK_HEADER_BAR(bar), - labeled_box("window", build_window_control(app))); + labeled_box_fixed("window", build_window_control(app))); refresh_window_limits(app); // --- the overflow menu ---------------------------------------------------- @@ -2348,7 +2505,15 @@ void on_activate(GtkApplication* application, gpointer user) { // A window closed by hand (a WM close button) must stop the frame tick: it holds // pointers to widgets that are finalized with the window. g_signal_connect(app->window, "destroy", G_CALLBACK(+[](GtkWidget*, gpointer user) { - static_cast(user)->window = nullptr; + App* a = static_cast(user); + // A pending debounced save will never fire now: flush it, so + // quitting right after a change keeps the change. + if (a->layout_save_source != 0) { + g_source_remove(a->layout_save_source); + a->layout_save_source = 0; + save_layout_now(*a); + } + a->window = nullptr; }), app); gtk_window_set_title(GTK_WINDOW(app->window), "udpscope"); @@ -3078,12 +3243,16 @@ gboolean exit_probe_step1(gpointer user) { } // namespace -int run_ui(int argc, char** argv, ScopeModel& model, Acquisition& acq, RowSet& rows) { +int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisition& acq, + RowSet& rows) { (void)argc; App app; app.model = &model; app.acq = &acq; app.rows = &rows; + // Which data source this session talks to: the persisted-layout file's name. + app.source_host = cfg.server; + app.source_port = cfg.port; GtkApplication* application = gtk_application_new("org.udpscope.app", G_APPLICATION_DEFAULT_FLAGS); @@ -3240,6 +3409,21 @@ int run_ui(int argc, char** argv, ScopeModel& model, Acquisition& acq, RowSet& r tr.series->x_bounds().max); } } + std::printf("[udpscope] header: window_button_w=%d window_label_w=%d (bar_w=%d)\n", + app.window_button != nullptr ? gtk_widget_get_width(app.window_button) : -1, + app.window_label != nullptr ? gtk_widget_get_width(app.window_label) : -1, + app.window != nullptr ? gtk_widget_get_width(app.window) : -1); + std::printf("[udpscope] layout: rows=%d window_s=%.6g", rows.row_count(), + rows.time_window()); + for (int r = 0; r < rows.row_count() && r < RowSet::kMaxRows; ++r) { + std::printf(" row%d=[", r); + for (int s = 0; s < RowSet::kSlotsPerRow; ++s) { + if (s != 0) std::printf(","); + std::printf("'%s'", rows.row(r).signal_name[static_cast(s)].c_str()); + } + std::printf("]"); + } + std::printf("\n"); for (int r = 0; r < rows.row_count() && r < RowSet::kMaxRows; ++r) { for (int s = 0; s < RowSet::kSlotsPerRow; ++s) { const int ch = rows.channel_of(r, s); diff --git a/src/ui.hpp b/src/ui.hpp index 049c7dc..f5d2e9c 100644 --- a/src/ui.hpp +++ b/src/ui.hpp @@ -12,6 +12,7 @@ class RowSet; /// Runs the GTK application. Never returns while the window is open; returns the /// `g_application_run()` result once it closes. -int run_ui(int argc, char** argv, ScopeModel& model, Acquisition& acq, RowSet& rows); +int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisition& acq, + RowSet& rows); } // namespace udpscope