udpscope: an oscilloscope view for a UDPStreamer source

GTK4 and libadwaita. It receives one UDP stream through the vendored
udpsclient, buffers it in a large ring, and draws it with the vendored
plotter: a trigger with a pre/post event window, a pause that freezes the
window, cursors, measurements, math channels in a node editor, an event cache,
named configurations, preferences, and an export of the visible window to
.npy/.csv. Waveform files can be opened and analysed in place.

The interface is two threads -- a producer that owns the socket and the GTK
main thread that owns every widget and the GL context -- sharing one model
under one mutex, with samples crossing through lock-free rings.

deps/plotter is a submodule pinned to the commit this tree builds against;
deps/udpsclient is vendored in place. docs/claude/ documents the rules that
cost a defect to learn, and SPECS.md is the requirement document.
This commit is contained in:
Martino Ferrari committed 2026-10-11 15:21:40 +02:00
1 parent 8f54841c2c
commit 0e256764d5
41 files changed
+2081 -154

No files matched your search

+11 -1
View File
@@ -31,6 +31,12 @@ std::string sanitize(const std::string& host) {
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
@@ -44,6 +50,8 @@ void mkdirs(const std::string& path) {
}
}
namespace {
std::string toml_escape(const std::string& s) {
std::string out = "\"";
for (char c : s) {
@@ -504,6 +512,8 @@ bool load_layout(const std::string& path, LayoutState& out) {
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();
@@ -620,7 +630,7 @@ bool save_layout(const std::string& path, const LayoutState& state) {
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]]\nsignals = [";
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]);