Files
udpscope/src/layout_store.cpp
T
Martino Ferrari 5c2c221777 ui: the node editor offers the de-noise node and persists its window
The form gains a `median samples` row, shown only for `DeNoise`; the corner row widens to
anything that reads `fc` (`math_op_uses_fc()`) while `q` stays band-pass-only, and the wiring
row keeps its place at the end so the form's order is unchanged for every existing op.

The session format gains a thirteenth field, `med_n`, after `y`. A twelve-field node -- a file
written before this node existed -- still loads, with `med_n` left at the struct's default,
which is what such a file meant; the reader's `return number(...) && ...` chain had to become a
guarded sequence so the thirteenth field could be read conditionally.

The math probe enables the channel it builds its graphs from, the same self-sufficiency rule
the noise and measurement probes already follow: `enable_all_probe` ends by switching one
signal off, and with `sine` gone the graphs landed on `square`, changing a documented line for
reasons unrelated to the math.

Readings: `[math-probe] 2 math channels on 'sine' and 'noise'`, `pane add: 'math3' sources
'sine'`, `graph edit: stored_slots 31000 -> 32000`, `dialog build: nodes=2, output after add=1`
and the rest of the suite unchanged; no `Gtk-CRITICAL`.
2026-10-13 15:50:58 +02:00

715 lines
26 KiB
C++

// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#include "layout_store.hpp"
#include <algorithm>
#include <cstdint>
#include <cstdio>
#include <cstdlib>
#include <dirent.h>
#include <fstream>
#include <sstream>
#include <sys/stat.h>
#include <sys/types.h>
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;
}
} // namespace
/// Creates `path` and every missing component, the last one included, like `mkdir -p`. Outside the
/// anonymous namespace because the preferences file is written through it too: a bare `mkdir()` of
/// `<config>/udpscope` fails when `<config>` does not exist yet, which is what a fresh install looks
/// like -- and it fails *silently*, leaving the settings dialog reporting a write it did not make.
void mkdirs(const std::string& path) {
// **Every** component, the last one included. The condition used to be "the path has a trailing
// slash", so the final directory was never created: `~/.config/udpscope` did not exist, the
// first `save_layout()` failed silently, and the session appeared to persist only on a machine
// where something else had already made the directory.
std::string cur;
for (std::size_t i = 0; i < path.size(); ++i) {
cur.push_back(path[i]);
if (path[i] != '/' && i + 1 != path.size()) continue;
::mkdir(cur.c_str(), 0755);
}
}
namespace {
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<std::string>& 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 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";
}
std::string cache_dir() {
const char* xdg = std::getenv("XDG_CACHE_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-cache";
return std::string(home) + "/.cache/udpscope";
}
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";
}
std::string config_dir_for(const std::string& host, std::uint16_t port) {
return config_dir() + "/" + sanitize(host) + "_" + std::to_string(port);
}
std::string configuration_path(const std::string& host, std::uint16_t port,
const std::string& name) {
const std::string dir = config_dir_for(host, port);
mkdirs(dir);
return dir + "/" + sanitize(name) + ".toml";
}
namespace {
bool file_exists(const std::string& path) {
struct stat sb {};
return ::stat(path.c_str(), &sb) == 0;
}
/// A session written before configurations existed is the configuration named `default`: copied
/// into the per-source directory, remembered as active, and the old file renamed out of the way
/// rather than deleted, so the import can be undone by hand. Runs before every read and write of
/// the per-source directory, so a machine that upgrades keeps its session.
void import_legacy(const std::string& host, std::uint16_t port) {
const std::string dir = config_dir_for(host, port);
if (file_exists(dir)) return;
const std::string legacy = layout_path(host, port);
if (!file_exists(legacy)) return;
mkdirs(dir);
FILE* in = std::fopen(legacy.c_str(), "rb");
if (in == nullptr) return;
const std::string target = dir + "/default.toml";
FILE* out = std::fopen(target.c_str(), "wb");
if (out == nullptr) {
std::fclose(in);
return;
}
char buf[4096];
std::size_t n = 0;
while ((n = std::fread(buf, 1, sizeof(buf), in)) > 0) std::fwrite(buf, 1, n, out);
std::fclose(in);
std::fclose(out);
FILE* active = std::fopen((dir + "/active").c_str(), "wb");
if (active != nullptr) {
std::fputs("default\n", active);
std::fclose(active);
}
::rename(legacy.c_str(), (legacy + ".imported").c_str());
}
/// `a.toml` -> `a`; anything else -> "".
std::string toml_stem(const std::string& file) {
if (file.size() <= 5 || file.compare(file.size() - 5, 5, ".toml") != 0) return {};
return file.substr(0, file.size() - 5);
}
/// `host_port` -> {host, port}, or false. The *last* '_' is the split, because `sanitize()`
/// replaces anything unsafe in a host with one.
bool split_stem(const std::string& stem, std::string* host, std::uint16_t* port) {
const std::size_t cut = stem.rfind('_');
if (cut == std::string::npos || cut + 1 == stem.size()) return false;
char* end = nullptr;
const long p = std::strtol(stem.c_str() + cut + 1, &end, 10);
if (end == nullptr || *end != '\0' || p <= 0 || p > 65535) return false;
*host = stem.substr(0, cut);
*port = static_cast<std::uint16_t>(p);
return true;
}
/// The configurations a source directory holds, newest first, by file name.
std::vector<std::string> names_in_dir(const std::string& dir) {
std::vector<std::pair<std::int64_t, std::string>> found;
DIR* d = ::opendir(dir.c_str());
if (d == nullptr) return {};
while (const dirent* e = ::readdir(d)) {
const std::string stem = toml_stem(e->d_name);
if (stem.empty()) continue;
struct stat sb {};
const std::string path = dir + "/" + e->d_name;
std::int64_t mtime = 0;
if (::stat(path.c_str(), &sb) == 0) mtime = static_cast<std::int64_t>(sb.st_mtime);
found.emplace_back(mtime, stem);
}
::closedir(d);
std::stable_sort(found.begin(), found.end(),
[](const auto& a, const auto& b) { return a.first > b.first; });
std::vector<std::string> out;
out.reserve(found.size());
for (auto& f : found) out.push_back(std::move(f.second));
return out;
}
} // namespace
std::vector<std::string> configuration_names(const std::string& host, std::uint16_t port) {
import_legacy(host, port);
return names_in_dir(config_dir_for(host, port));
}
std::string active_configuration(const std::string& host, std::uint16_t port) {
const std::vector<std::string> names = configuration_names(host, port);
if (names.empty()) return "default";
FILE* f = std::fopen((config_dir_for(host, port) + "/active").c_str(), "rb");
if (f != nullptr) {
char buf[512];
const std::size_t n = std::fread(buf, 1, sizeof(buf) - 1, f);
std::fclose(f);
buf[n] = '\0';
const std::string want = trim(std::string(buf));
if (std::find(names.begin(), names.end(), want) != names.end()) return want;
}
// Newest, which is the one just saved when nothing wrote an `active`.
return names.front();
}
bool set_active_configuration(const std::string& host, std::uint16_t port,
const std::string& name) {
const std::string dir = config_dir_for(host, port);
mkdirs(dir);
FILE* f = std::fopen((dir + "/active").c_str(), "wb");
if (f == nullptr) return false;
std::fputs(sanitize(name).c_str(), f);
std::fputs("\n", f);
std::fclose(f);
return true;
}
bool forget_configuration(const std::string& host, std::uint16_t port, const std::string& name) {
const std::string path = configuration_path(host, port, name);
const bool gone = std::remove(path.c_str()) == 0;
const std::string active = active_configuration(host, port);
if (gone && sanitize(name) == sanitize(active)) {
std::remove((config_dir_for(host, port) + "/active").c_str());
}
return gone;
}
std::vector<KnownSource> known_sources() {
const std::string root = config_dir();
// A legacy single-file session is imported first, so the listing below has one shape: the
// per-source directory and its configuration files.
if (DIR* d = ::opendir(root.c_str())) {
while (const dirent* e = ::readdir(d)) {
const std::string stem = toml_stem(e->d_name);
if (stem.empty()) continue;
std::string host;
std::uint16_t port = 0;
if (!split_stem(stem, &host, &port)) continue;
// The scheme `layout_path()` writes, and nothing else: a stray `.toml` here is not ours.
if (sanitize(host) + "_" + std::to_string(port) != stem) continue;
import_legacy(host, port);
}
::closedir(d);
}
std::vector<KnownSource> out;
DIR* d = ::opendir(root.c_str());
// No directory yet is an empty list, not an error: a first run has never saved anything.
if (d == nullptr) return out;
while (const dirent* e = ::readdir(d)) {
const std::string dir = root + "/" + e->d_name;
struct stat sb {};
if (::stat(dir.c_str(), &sb) != 0 || !S_ISDIR(sb.st_mode)) continue;
std::string host;
std::uint16_t port = 0;
if (!split_stem(e->d_name, &host, &port)) continue;
// The name must be one `config_dir_for()` could have written, or `forget_source()` would walk
// out of the directory the row came from.
if (sanitize(host) + "_" + std::to_string(port) != e->d_name) continue;
// One row per *configuration*: the same source may hold several named sessions, which is the
// point of the feature, and the chooser is where they are told apart.
for (const std::string& name : names_in_dir(dir)) {
const std::string path = dir + "/" + name + ".toml";
LayoutState st;
if (!load_layout(path, st)) continue;
KnownSource s;
s.host = host;
s.port = port;
s.config = name;
s.comment = st.comment;
s.buffer_mb = st.buffer_mb;
s.relative_time = st.relative_time;
s.capture_packets = st.capture_packets;
s.multicast = st.multicast;
s.interface_addr = st.interface_addr;
s.rows = static_cast<int>(st.rows.size());
for (const LayoutState::Row& row : st.rows) {
for (const std::string& sig : row.signals) {
if (!sig.empty()) ++s.signals;
}
}
// A configuration nobody configured is not worth a row: a development machine accumulates one
// session file per probe run and every one holds `signals = ["", "", "", ""]`. One that
// carries a *setting* is worth a row even before it has ever streamed -- that is how a host is
// set up from the chooser's form, and the settings are the whole point of editing one.
const bool configured = s.signals > 0 || s.buffer_mb > 0.0 || !st.comment.empty() ||
st.relative_time || st.capture_packets > 0;
if (!configured) continue;
struct stat fb {};
s.mtime = (::stat(path.c_str(), &fb) == 0) ? static_cast<std::int64_t>(fb.st_mtime) : 0;
out.push_back(std::move(s));
}
}
::closedir(d);
// Stable, so two files sharing an mtime keep the order the directory gave them rather than
// swapping between runs.
std::stable_sort(out.begin(), out.end(),
[](const KnownSource& a, const KnownSource& b) { return a.mtime > b.mtime; });
return out;
}
bool forget_source(const KnownSource& src) {
// Through `configuration_path()` rather than by rebuilding the name here: one expression of the
// scheme. `src.host` came back out of a filename, so it is already sanitized, and `sanitize()`
// keeps alphanumerics, '.', '-' and '_' -- it is idempotent, so the round trip is exact.
return forget_configuration(src.host, src.port, src.config);
}
std::string math_node_text(const MathNode& node) {
// The op is written as its index through `math_op_at()`, not as the raw enum value: that accessor
// is the contract the dialog, the engine and this file share, so the format is tied to it rather
// than to the declaration order of an enum nobody else has to know about.
int kind = 0;
for (int i = 0; i < math_op_count(); ++i) {
if (math_op_at(i) == node.op) kind = i;
}
std::ostringstream o;
o << kind << '|' << node.source << '|' << node.in_a << '|' << node.in_b << '|' << node.gain << '|'
<< node.offset << '|' << node.fc << '|' << node.q << '|' << node.lo << '|' << node.hi << '|'
<< node.x << '|' << node.y << '|' << node.med_n;
return o.str();
}
bool math_node_from_text(const std::string& text, MathNode& out) {
std::vector<std::string> f;
std::string cur;
for (const char c : text) {
if (c == '|') {
f.push_back(cur);
cur.clear();
} else {
cur.push_back(c);
}
}
f.push_back(cur);
// Twelve fields is a session file written before the de-noise node existed; its `med_n` stays at the
// struct's default, which is what that file meant.
if (f.size() != 12u && f.size() != 13u) return false;
const auto number = [&](std::size_t i, double& v) {
try {
v = std::stod(f[i]);
return true;
} catch (...) {
return false;
}
};
double kind = 0.0;
double in_a = -1.0;
double in_b = -1.0;
if (!number(0, kind) || !number(2, in_a) || !number(3, in_b)) return false;
const int k = static_cast<int>(kind);
if (k < 0 || k >= math_op_count()) return false;
out = MathNode{};
out.op = math_op_at(k);
out.source = f[1];
out.in_a = static_cast<int>(in_a);
out.in_b = static_cast<int>(in_b);
if (!(number(4, out.gain) && number(5, out.offset) && number(6, out.fc) && number(7, out.q) &&
number(8, out.lo) && number(9, out.hi) && number(10, out.x) && number(11, out.y))) {
return false;
}
if (f.size() == 13u) {
double med = 0.0;
if (!number(12, med)) return false;
out.med_n = static_cast<int>(med);
}
return true;
}
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();
st.colors.clear();
st.enabled.clear();
st.math.clear();
st.measures.clear();
std::string line;
// What the last `[[...]]` header opened, so keys land in the right record.
enum class Section { Top, Plot, Calibration, Color, Math, Measure } 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 if (t == "[[color]]") {
section = Section::Color;
st.colors.emplace_back();
} else if (t == "[[math]]") {
section = Section::Math;
st.math.emplace_back();
} else if (t == "[[measure]]") {
section = Section::Measure;
st.measures.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 == "enabled") {
if (value.size() >= 2 && value.front() == '[' && value.back() == ']') {
parse_string_array(value.substr(1, value.size() - 2), st.enabled);
}
} 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 (key == "measure_stats") {
st.measure_stats = value == "true";
} else if (key == "multicast") {
st.multicast = strip_quote(value);
} else if (key == "interface") {
st.interface_addr = strip_quote(value);
} else if (key == "comment") {
st.comment = strip_quote(value);
} else if (key == "buffer_mb") {
try {
st.buffer_mb = std::stod(value);
} catch (...) {
}
} else if (key == "relative_time") {
st.relative_time = value == "true";
} else if (key == "capture_packets") {
try {
st.capture_packets = static_cast<std::uint32_t>(std::max(0, std::stoi(value)));
} catch (...) {
}
}
} else if (section == Section::Plot && !st.rows.empty()) {
if (key == "signals") {
std::vector<std::string> 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 (key == "weight") {
st.rows.back().weight = std::strtod(value.c_str(), nullptr);
}
} else if (section == Section::Math && !st.math.empty()) {
MathChannel& m = st.math.back();
if (key == "name") {
m.name = strip_quote(value);
} else if (key == "unit") {
m.unit = strip_quote(value);
} else if (key == "enabled") {
m.enabled = value == "true";
} else if (key == "output") {
try {
m.output = std::stoi(value);
} catch (...) {
}
} else if (key == "nodes") {
std::vector<std::string> texts;
if (value.size() >= 2 && value.front() == '[' && value.back() == ']') {
parse_string_array(value.substr(1, value.size() - 2), texts);
}
m.nodes.clear();
for (const std::string& text : texts) {
MathNode node;
// A node this version cannot read is dropped rather than failing the whole file: a graph
// with a hole in it is invalid and the editor says so, which beats losing the session.
if (math_node_from_text(text, node)) m.nodes.push_back(node);
}
}
} else if (section == Section::Measure && !st.measures.empty()) {
Measure& m = st.measures.back();
if (key == "signal") {
m.signal = strip_quote(value);
} else if (key == "type") {
try {
const int index = std::stoi(value);
if (index >= 0 && index < measure_type_count()) m.type = measure_type_at(index);
} catch (...) {
}
} else if (key == "threshold") {
try {
m.options.threshold_pct = std::stod(value);
} catch (...) {
}
} else if (key == "noise") {
try {
m.options.noise_pct = std::stod(value);
} catch (...) {
}
} else if (key == "hf") {
try {
m.options.hf_fc = std::stod(value);
} catch (...) {
}
} else if (key == "above") {
m.options.above = value == "true";
} else if (key == "show") {
m.options.show_on_plot = value == "true";
}
} 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 (...) {
}
}
} else if (section == Section::Color && !st.colors.empty()) {
if (key == "name") {
st.colors.back().name = strip_quote(value);
} else if (key == "rgb") {
// `"#RRGGBB"`, so the file says what it means to a reader hand-editing it -- a bare decimal
// would be the one number in this file nobody could check by eye. Anything unparseable
// leaves the entry at 0, and `apply_layout()` drops an entry with no name anyway.
const std::string hex = strip_quote(value);
const std::size_t at = hex.find('#');
try {
st.colors.back().rgb =
static_cast<std::uint32_t>(std::stoul(
at == std::string::npos ? hex : hex.substr(at + 1), nullptr, 16)) &
0x00FFFFFFu;
} catch (...) {
}
}
}
}
if (!plot_section_seen && plot_count > 0) {
st.rows.assign(static_cast<std::size_t>(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";
// Top level, before any table. A bare key written after a `[[table]]` header belongs to that
// table in TOML, which is exactly how `enabled` was once parsed into the last `[[calibration]]`
// record and silently lost.
if (!state.multicast.empty()) o << "multicast = " << toml_escape(state.multicast) << "\n";
if (!state.interface_addr.empty()) {
o << "interface = " << toml_escape(state.interface_addr) << "\n";
}
// Top level, before any table. Emitted after the `[[calibration]]` tables it belonged to the last
// of them -- a bare key after a header is that table's key in TOML -- so the parser, correctly,
// never saw it and a session's enable state was silently lost.
if (!state.enabled.empty()) {
o << "enabled = [";
for (std::size_t i = 0; i < state.enabled.size(); ++i) {
if (i != 0) o << ", ";
o << toml_escape(state.enabled[i]);
}
o << "]\n";
}
o << "measure_stats = " << (state.measure_stats ? "true" : "false") << "\n";
if (!state.comment.empty()) o << "comment = " << toml_escape(state.comment) << "\n";
if (state.buffer_mb > 0.0) o << "buffer_mb = " << state.buffer_mb << "\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]]\nweight = " << row.weight << "\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";
}
for (const auto& color : state.colors) {
char rgb[16];
std::snprintf(rgb, sizeof(rgb), "\"#%06X\"", color.rgb & 0x00FFFFFFu);
o << "\n[[color]]\nname = " << toml_escape(color.name) << "\nrgb = " << rgb << "\n";
}
for (const MathChannel& m : state.math) {
o << "\n[[math]]\nname = " << toml_escape(m.name) << "\nunit = " << toml_escape(m.unit)
<< "\nenabled = " << (m.enabled ? "true" : "false") << "\noutput = " << m.output
<< "\nnodes = [";
for (std::size_t i = 0; i < m.nodes.size(); ++i) {
if (i != 0) o << ", ";
o << toml_escape(math_node_text(m.nodes[i]));
}
o << "]\n";
}
for (const Measure& m : state.measures) {
// The *accessor's* index, not the enum's value: `measure_type_at()` is the mapping the dialog
// and this file share, and its order is the dropdown's -- which is not the declaration order of
// the enum. Writing the raw value and reading it back through the accessor turned `top` into
// whatever sat at that position (the same mistake the math nodes' op index was written to avoid).
int kind = 0;
for (int i = 0; i < measure_type_count(); ++i) {
if (measure_type_at(i) == m.type) kind = i;
}
o << "\n[[measure]]\nsignal = " << toml_escape(m.signal) << "\ntype = " << kind
<< "\nthreshold = " << m.options.threshold_pct
<< "\nabove = " << (m.options.above ? "true" : "false") << "\nshow = "
<< (m.options.show_on_plot ? "true" : "false") << "\nnoise = " << m.options.noise_pct
<< "\nhf = " << m.options.hf_fc << "\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