Updated local storage
This commit is contained in:
1 parent
30db44eaa0
commit
2218aed839
10 files changed
+548
-5
No files matched your search
@@ -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
|
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
|
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.
|
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/<host>_<port>.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
|
- `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
|
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
|
blinks" means, and a badge stuck lit is indistinguishable from a working one in a
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ add_executable(udpscope
|
|||||||
src/acquisition.cpp
|
src/acquisition.cpp
|
||||||
src/plot_host.cpp
|
src/plot_host.cpp
|
||||||
src/export.cpp
|
src/export.cpp
|
||||||
|
src/layout_store.cpp
|
||||||
src/ui.cpp
|
src/ui.cpp
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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/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/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/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
|
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,
|
Samples cross the boundary through `plot::data::RingBuffer` (single producer,
|
||||||
single consumer), never through the lock.
|
single consumer), never through the lock.
|
||||||
|
|
||||||
|
## The session layout file
|
||||||
|
|
||||||
|
What you arranged is remembered per data source, in
|
||||||
|
`~/.config/udpscope/<host>_<port>.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
|
## How the spec maps onto the code
|
||||||
|
|
||||||
**One buffer of `N+1` slices, plus frozen event snapshots.** `ScopeModel`
|
**One buffer of `N+1` slices, plus frozen event snapshots.** `ScopeModel`
|
||||||
|
|||||||
@@ -0,0 +1,248 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
|
#include "layout_store.hpp"
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
#include <cstdio>
|
||||||
|
#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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<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 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<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 (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<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";
|
||||||
|
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
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace udpscope {
|
||||||
|
|
||||||
|
/// The user-arranged part of a session, persisted per data source in
|
||||||
|
/// `~/.config/udpscope/<host>_<port>.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<std::string> signals;
|
||||||
|
};
|
||||||
|
|
||||||
|
double window_s = 0.0; ///< 0 keeps the default window
|
||||||
|
std::vector<Row> rows;
|
||||||
|
|
||||||
|
struct Calibration {
|
||||||
|
std::string name;
|
||||||
|
double gain = 1.0;
|
||||||
|
double offset = 0.0;
|
||||||
|
};
|
||||||
|
std::vector<Calibration> calibration;
|
||||||
|
};
|
||||||
|
|
||||||
|
/// `~/.config/udpscope/<host>_<port>.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
|
||||||
+1
-1
@@ -30,7 +30,7 @@ int main(int argc, char** argv) {
|
|||||||
return 1;
|
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();
|
acquisition.stop();
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
@@ -184,6 +184,12 @@ void ScopeModel::rebuild_channels() {
|
|||||||
ch.unit = sig.unit;
|
ch.unit = sig.unit;
|
||||||
ch.elements = sig.elements();
|
ch.elements = sig.elements();
|
||||||
ch.dt = sig.sampling_rate > 0.0 ? 1.0 / sig.sampling_rate : 0.0;
|
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));
|
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) {
|
void ScopeModel::set_calibration(std::size_t signal, double gain, double offset) {
|
||||||
std::lock_guard<std::mutex> lock(mutex_);
|
std::lock_guard<std::mutex> lock(mutex_);
|
||||||
|
if (signal < signals_.size()) calibration_[signals_[signal].name] = {gain, offset};
|
||||||
bool any = false;
|
bool any = false;
|
||||||
for (ChannelInfo& c : channels_) {
|
for (ChannelInfo& c : channels_) {
|
||||||
if (c.signal != signal) continue;
|
if (c.signal != signal) continue;
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
#include <plot/data/ring_buffer.hpp>
|
#include <plot/data/ring_buffer.hpp>
|
||||||
|
|
||||||
#include <atomic>
|
#include <atomic>
|
||||||
|
#include <unordered_map>
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <memory>
|
#include <memory>
|
||||||
#include <mutex>
|
#include <mutex>
|
||||||
@@ -183,6 +184,11 @@ class ScopeModel {
|
|||||||
static bool is_time_signal(const std::vector<SignalInfo>& signals,
|
static bool is_time_signal(const std::vector<SignalInfo>& signals,
|
||||||
std::size_t signal);
|
std::size_t signal);
|
||||||
void set_signal_enabled(std::size_t signal, bool on);
|
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);
|
void set_calibration(std::size_t signal, double gain, double offset);
|
||||||
|
|
||||||
std::size_t channel_count() const noexcept { return channels_.size(); }
|
std::size_t channel_count() const noexcept { return channels_.size(); }
|
||||||
@@ -352,6 +358,8 @@ class ScopeModel {
|
|||||||
std::atomic<bool> trigger_pending_{false};
|
std::atomic<bool> trigger_pending_{false};
|
||||||
std::atomic<double> capture_edge_{0.0};
|
std::atomic<double> capture_edge_{0.0};
|
||||||
std::atomic<std::shared_ptr<const EventSnapshot>> event_; ///< the event on screen
|
std::atomic<std::shared_ptr<const EventSnapshot>> event_; ///< the event on screen
|
||||||
|
/// Per-signal-name calibration (see `set_calibration`).
|
||||||
|
std::unordered_map<std::string, std::pair<double, double>> calibration_;
|
||||||
std::atomic<std::uint64_t> event_seq_{0};
|
std::atomic<std::uint64_t> event_seq_{0};
|
||||||
std::atomic<double> display_epoch_{0.0};
|
std::atomic<double> display_epoch_{0.0};
|
||||||
std::atomic<bool> trigger_armed_{false};
|
std::atomic<bool> trigger_armed_{false};
|
||||||
|
|||||||
+187
-3
@@ -17,6 +17,7 @@
|
|||||||
#include "acquisition.hpp"
|
#include "acquisition.hpp"
|
||||||
#include "config.hpp"
|
#include "config.hpp"
|
||||||
#include "export.hpp"
|
#include "export.hpp"
|
||||||
|
#include "layout_store.hpp"
|
||||||
#include "model.hpp"
|
#include "model.hpp"
|
||||||
#include "plot_host.hpp"
|
#include "plot_host.hpp"
|
||||||
|
|
||||||
@@ -121,6 +122,13 @@ struct App {
|
|||||||
GtkWidget* cursors_toggle = nullptr;
|
GtkWidget* cursors_toggle = nullptr;
|
||||||
GtkWidget* cursors_reset = 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;
|
unsigned signal_generation = ~0u;
|
||||||
bool updating_panels = false; ///< guards signal handlers during a rebuild
|
bool updating_panels = false; ///< guards signal handlers during a rebuild
|
||||||
bool relayout_pending = false;
|
bool relayout_pending = false;
|
||||||
@@ -292,6 +300,121 @@ GtkWidget* labeled_box(const char* label, GtkWidget* child) {
|
|||||||
return box;
|
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<SignalInfo>& 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<std::size_t>(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<App*>(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<int>(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<std::size_t>(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* spin(double min, double max, double step, int digits) {
|
||||||
GtkWidget* s = gtk_spin_button_new_with_range(min, max, step);
|
GtkWidget* s = gtk_spin_button_new_with_range(min, max, step);
|
||||||
gtk_spin_button_set_digits(GTK_SPIN_BUTTON(s), digits);
|
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
|
// The signal list's chips name the trace a signal is drawn as, and a removed row took
|
||||||
// its traces with it.
|
// its traces with it.
|
||||||
rebuild_signal_panels(*app);
|
rebuild_signal_panels(*app);
|
||||||
|
schedule_layout_save(*app);
|
||||||
}
|
}
|
||||||
|
|
||||||
void on_layout_row_insert(GtkButton* button, gpointer user) {
|
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());
|
if (app->area != nullptr) app->area->set_figure(&app->rows->figure());
|
||||||
rebuild_layout_editor(*app);
|
rebuild_layout_editor(*app);
|
||||||
rebuild_signal_panels(*app);
|
rebuild_signal_panels(*app);
|
||||||
|
schedule_layout_save(*app);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Restates where the buttons sit, from the rects the last frame was laid out with.
|
/// 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<CalibContext*>(user);
|
CalibContext* ctx = static_cast<CalibContext*>(user);
|
||||||
ctx->app->model->set_calibration(ctx->signal, gtk_spin_button_get_value(GTK_SPIN_BUTTON(ctx->gain)),
|
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)));
|
gtk_spin_button_get_value(GTK_SPIN_BUTTON(ctx->offset)));
|
||||||
|
schedule_layout_save(*ctx->app);
|
||||||
gtk_window_destroy(GTK_WINDOW(ctx->window));
|
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.rows->set_time_window(clamped);
|
||||||
app.model->set_event_span(clamped);
|
app.model->set_event_span(clamped);
|
||||||
sync_window_display(app, 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
|
/// 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<SignalInfo>& signals = app->model->signals();
|
const std::vector<SignalInfo>& signals = app->model->signals();
|
||||||
const SignalInfo& sig = signals[signal];
|
const SignalInfo& sig = signals[signal];
|
||||||
app->rows->assign(row, slot, sig.name, name, signal_color_index(signals, signal));
|
app->rows->assign(row, slot, sig.name, name, signal_color_index(signals, signal));
|
||||||
|
schedule_layout_save(*app);
|
||||||
app->relayout_pending = true;
|
app->relayout_pending = true;
|
||||||
rebuild_signal_panels(*app); // the row's colour chip now has a trace to name
|
rebuild_signal_panels(*app); // the row's colour chip now has a trace to name
|
||||||
cancel_drag_on(*app, row);
|
cancel_drag_on(*app, row);
|
||||||
@@ -1656,8 +1784,37 @@ gboolean tick(gpointer user) {
|
|||||||
app->relayout_pending = false;
|
app->relayout_pending = false;
|
||||||
relayout_areas(*app);
|
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);
|
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
|
// 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
|
// 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.
|
// 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();
|
GtkWidget* bar = gtk_header_bar_new();
|
||||||
|
|
||||||
gtk_header_bar_pack_start(GTK_HEADER_BAR(bar),
|
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);
|
refresh_window_limits(app);
|
||||||
|
|
||||||
// --- the overflow menu ----------------------------------------------------
|
// --- 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
|
// 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.
|
// pointers to widgets that are finalized with the window.
|
||||||
g_signal_connect(app->window, "destroy", G_CALLBACK(+[](GtkWidget*, gpointer user) {
|
g_signal_connect(app->window, "destroy", G_CALLBACK(+[](GtkWidget*, gpointer user) {
|
||||||
static_cast<App*>(user)->window = nullptr;
|
App* a = static_cast<App*>(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);
|
app);
|
||||||
gtk_window_set_title(GTK_WINDOW(app->window), "udpscope");
|
gtk_window_set_title(GTK_WINDOW(app->window), "udpscope");
|
||||||
@@ -3078,12 +3243,16 @@ gboolean exit_probe_step1(gpointer user) {
|
|||||||
|
|
||||||
} // namespace
|
} // 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;
|
(void)argc;
|
||||||
App app;
|
App app;
|
||||||
app.model = &model;
|
app.model = &model;
|
||||||
app.acq = &acq;
|
app.acq = &acq;
|
||||||
app.rows = &rows;
|
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 =
|
GtkApplication* application =
|
||||||
gtk_application_new("org.udpscope.app", G_APPLICATION_DEFAULT_FLAGS);
|
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);
|
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<std::size_t>(s)].c_str());
|
||||||
|
}
|
||||||
|
std::printf("]");
|
||||||
|
}
|
||||||
|
std::printf("\n");
|
||||||
for (int r = 0; r < rows.row_count() && r < RowSet::kMaxRows; ++r) {
|
for (int r = 0; r < rows.row_count() && r < RowSet::kMaxRows; ++r) {
|
||||||
for (int s = 0; s < RowSet::kSlotsPerRow; ++s) {
|
for (int s = 0; s < RowSet::kSlotsPerRow; ++s) {
|
||||||
const int ch = rows.channel_of(r, s);
|
const int ch = rows.channel_of(r, s);
|
||||||
|
|||||||
+2
-1
@@ -12,6 +12,7 @@ class RowSet;
|
|||||||
|
|
||||||
/// Runs the GTK application. Never returns while the window is open; returns the
|
/// Runs the GTK application. Never returns while the window is open; returns the
|
||||||
/// `g_application_run()` result once it closes.
|
/// `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
|
} // namespace udpscope
|
||||||
Reference in new issue
Block a user