working on todo.md features

This commit is contained in:
Martino Ferrari committed 2026-10-10 22:52:12 +02:00
1 parent 210d124814
commit 8f54841c2c
110 files changed
+15932 -6576

No files matched your search

+98 -2
View File
@@ -5,6 +5,13 @@
#include <udps_client.h>
#include "layout_store.hpp"
#include <glib/gstdio.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <algorithm>
#include <cmath>
#include <cstring>
@@ -61,6 +68,13 @@ void Acquisition::start() {
}
udps_client_set_callbacks(client_, &Acquisition::config_trampoline, &Acquisition::data_trampoline,
&Acquisition::event_trampoline, this);
udps_client_set_datagram_cb(client_, &Acquisition::datagram_trampoline, this);
// `--capture-packets`: armed before the first datagram, so the first CONNECT and
// its CONFIG -- the hardest strange behaviours to reproduce -- are in the ring.
// The open itself still happens on the producer thread, at the first datagram.
if (cfg_.capture_packets > 0) {
set_capture_want(true, cfg_.capture_packets);
}
started_ = std::chrono::steady_clock::now();
running_.store(true, std::memory_order_release);
@@ -100,6 +114,60 @@ void Acquisition::run() {
}
}
void Acquisition::set_capture_want(bool want, std::uint32_t slots) {
if (want) {
if (slots > 0) capture_slots_ = slots;
if (capture_path_.empty()) {
// Keyed on the source: one path per source, the previous capture kept one deep.
std::string host = cfg_.server;
for (char& ch : host) {
if (ch == ':' || ch == '/' || ch == ' ') ch = '_';
}
std::string dir = cache_dir() + "/" + host + "-" + std::to_string(cfg_.port);
g_mkdir_with_parents(dir.c_str(), 0700);
capture_path_ = dir + "/capture.bin";
std::uint32_t addr = 0;
struct ::in_addr a;
if (::inet_pton(AF_INET, cfg_.server.c_str(), &a) == 1) {
addr = a.s_addr; // already network order, which is what the header wants
}
capture_.set_peer(addr, cfg_.port);
}
}
capture_want_.store(want, std::memory_order_release);
}
void Acquisition::on_datagram(const std::uint8_t* bytes, std::size_t len, double recv_time) {
const bool want = capture_want_.load(std::memory_order_acquire);
if (want && !capture_.enabled()) {
// A user-initiated act that happens once per toggle, on this thread: a rename, a
// create, a 62.6 MiB reserve and an mmap. It can cost the packets in flight, and
// the measured cost is `capture_.open_ms()`.
if (!capture_.open(capture_path_, capture_slots_)) {
char err[128];
capture_.error(err, sizeof(err));
std::fprintf(stderr, "[udpscope] packet capture failed to open: %s\n", err);
// Stay off rather than retrying every datagram against a full disk; the GTK
// thread reads the flag back and returns the button to off.
capture_want_.store(false, std::memory_order_release);
return;
}
std::fprintf(stderr, "[udpscope] packet capture on: %s (%u slots, %.1f ms to open)\n",
capture_.path().c_str(), capture_.slot_count(), capture_.open_ms());
} else if (!want && capture_.enabled()) {
capture_.close();
std::fprintf(stderr, "[udpscope] packet capture off\n");
}
if (capture_.enabled()) {
capture_.store(bytes, len, recv_time);
}
}
void Acquisition::datagram_trampoline(const std::uint8_t* bytes, std::size_t len,
double recv_time, void* user) {
static_cast<Acquisition*>(user)->on_datagram(bytes, len, recv_time);
}
void Acquisition::on_config(const udps_signal_t* signals, std::uint32_t count,
std::uint8_t publish_mode) {
std::vector<SignalInfo> info;
@@ -129,6 +197,11 @@ void Acquisition::on_config(const udps_signal_t* signals, std::uint32_t count,
warned_outage_ = false;
warned_bridge_ = false;
warned_time_source_ = false;
// The clock choice is per connection too: a reconnect re-derives it from the frames
// the new session actually delivers, rather than trusting a decision made about a
// session that dropped.
time_source_ = 0;
reset_session_state();
model_.stats().have_config.store(true, std::memory_order_relaxed);
model_.set_signals(std::move(info));
}
@@ -139,9 +212,31 @@ void Acquisition::on_event(int event, const char* detail) {
model_.stats().connected.store(true, std::memory_order_relaxed);
} else if (event == UDPS_EVENT_DISCONNECTED) {
model_.stats().connected.store(false, std::memory_order_relaxed);
// The session dropped mid-flight: everything the producer knows about *that*
// session's numbering and clocks is now a lie about the next one. Clearing it here
// is what makes the next frame re-anchor instead of reading as late, as a glitch or
// as an outage -- and nothing here touches the buffer, whose history survives the
// drop (see `ScopeModel::set_signals`'s unchanged-CONFIG path).
reset_session_state();
}
}
void Acquisition::reset_session_state() noexcept {
have_counter_ = false;
last_counter_ = 0;
have_stream_anchor_ = false;
last_stream_anchor_ = 0.0;
stamp_failures_ = 0;
// The arrival clock's *anchor* stays -- stamps are small seconds the ring's timeline
// is already scaled against, and re-anchoring mid-stream would move x -- but the
// smoothed interval and the previous arrival are per-connection measurements, and
// carrying them across a multi-second outage makes the first new frame read as a
// clock fault rather than as the resumption it is.
have_arrival_interval_ = false;
arrival_interval_ = 0.0;
arrival_prev_ = 0.0;
}
std::uint32_t Acquisition::build_stamps(const udps_frame_t* frame, int time_signal) {
// The frame's span in base ticks: a scalar signal carries `num_samples` values
// (one per batched cycle), a block signal (rows*cols > 1) carries its whole
@@ -537,8 +632,9 @@ void Acquisition::on_data(const udps_frame_t* frame) {
// only an absolute slot index while `tick_ / period == produced()`, so the tick counter has
// to restart with the buffer -- otherwise the amend lands outside the ring, repairs nothing,
// and the hole is still marked repaired, which is the band saying the opposite of the truth.
// The generation is the signal rather than the stride/channel-count comparison below: a
// reconnect with an identical CONFIG rebuilds the store leaving both unchanged.
// The generation is the signal rather than the stride/channel-count comparison below: only
// a CONFIG that *changes* the stream re-plans the store -- a reconnect's identical CONFIG
// keeps it, history and all (see `ScopeModel::set_signals`).
// `slot_base_` is deliberately *not* reset -- it is relative mode's clock origin, samples
// since the stream began, and relative time must not jump backwards when the user retypes
// the window.
+31
View File
@@ -3,6 +3,7 @@
#pragma once
#include "capture.hpp"
#include "config.hpp"
#include "model.hpp"
#include "trigger.hpp"
@@ -38,6 +39,19 @@ class Acquisition {
void set_paused(bool paused) noexcept { paused_.store(paused, std::memory_order_relaxed); }
bool paused() const noexcept { return paused_.load(std::memory_order_relaxed); }
/// Turns the packet capture on or off -- the whole GTK side of the feature. The
/// path is built here, once, *before* the flag is published, so the producer reads
/// a complete string whenever it reads a true flag. `slots` is the ring's size;
/// zero means the 1000-slot default. The producer does the open or the close at the
/// top of the next datagram, so a toggle costs no lock and no second thread.
void set_capture_want(bool want, std::uint32_t slots = 0);
bool capture_want() const noexcept {
return capture_want_.load(std::memory_order_acquire);
}
/// The capture, for the popover's row and the probe. Counters and errors are read
/// cross-thread through PacketCapture's own published fields.
const PacketCapture& capture() const noexcept { return capture_; }
private:
void run();
void on_config(const udps_signal_t* signals, std::uint32_t count, std::uint8_t publish_mode);
@@ -48,6 +62,17 @@ class Acquisition {
std::uint8_t publish_mode, void* user);
static void data_trampoline(const udps_frame_t* frame, void* user);
static void event_trampoline(udps_event_t event, const char* detail, void* user);
static void datagram_trampoline(const std::uint8_t* bytes, std::size_t len,
double recv_time, void* user);
/// Drops the producer state that is per *connection*: the counter mark, the time
/// anchors and the clock choice. A reconnect re-sends the CONFIG and the stream may
/// have restarted its numbering; carrying the old session's marks across makes the
/// first new frames read as late, as glitches or as an outage, which is recovery
/// failing exactly when it matters. Called on the producer thread, from on_event().
void reset_session_state() noexcept;
/// The producer side of the toggle: open on a rising edge, close on a falling one,
/// then store if enabled. Every file operation happens here, on this thread.
void on_datagram(const std::uint8_t* bytes, std::size_t len, double recv_time);
/// Fills `stamps_` with this frame's base-tick timestamps, in stream seconds,
/// and returns how many were written (`frame->num_samples` unless the frame is
@@ -73,6 +98,12 @@ class Acquisition {
std::thread thread_;
std::atomic<bool> running_{false};
std::atomic<bool> paused_{false};
PacketCapture capture_;
std::atomic<bool> capture_want_{false};
std::uint32_t capture_slots_ = 1000;
/// Written once, by the GTK thread, before `capture_want_` is ever published true;
/// the producer reads it only after the flag, so no lock is needed.
std::string capture_path_;
TriggerEngine engine_;
std::uint64_t tick_ = 0; ///< base ticks consumed since the epoch
+238
View File
@@ -0,0 +1,238 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#include "capture.hpp"
#include <fcntl.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <unistd.h>
#include <cstdio>
#include <cstring>
#include <ctime>
#include <chrono>
#include <vector>
namespace udpscope {
namespace {
/// The file header is one 64-byte block: fixed fields first, zeroed padding after.
constexpr std::uint64_t kHeaderBytes = 64;
/// `seq` lives at the *end* of a slot, at a fixed offset, because the reader must
/// find it without trusting any other field.
constexpr std::uint64_t kSeqBytes = 8;
void put_u32(std::uint8_t* p, std::uint32_t v) { std::memcpy(p, &v, 4); }
void put_u16(std::uint8_t* p, std::uint16_t v) { std::memcpy(p, &v, 2); }
void put_f64(std::uint8_t* p, double v) { std::memcpy(p, &v, 8); }
std::uint32_t get_u32(const std::uint8_t* p) {
std::uint32_t v;
std::memcpy(&v, p, 4);
return v;
}
double get_f64(const std::uint8_t* p) {
double v;
std::memcpy(&v, p, 8);
return v;
}
double wall_seconds() {
struct timespec ts;
clock_gettime(CLOCK_REALTIME, &ts);
return double(ts.tv_sec) + double(ts.tv_nsec) * 1e-9;
}
/// `seq` is published with a release store *after* the bytes are down, so a reader
/// that sees a non-zero `seq` sees the whole slot -- the reason no `fsync` is needed
/// for the file to be readable after a crash. Plain __atomic builtins, not
/// `std::atomic_ref`: the target is mmap'd memory the class does not own as an object.
void publish_seq(std::uint8_t* slot, std::uint64_t seq) {
__atomic_store_n(reinterpret_cast<std::uint64_t*>(slot + kSlotBytes - kSeqBytes), seq,
__ATOMIC_RELEASE);
}
std::uint64_t read_seq(const std::uint8_t* slot) {
return __atomic_load_n(reinterpret_cast<const std::uint64_t*>(slot + kSlotBytes - kSeqBytes),
__ATOMIC_ACQUIRE);
}
} // namespace
void PacketCapture::set_error(const char* what) noexcept {
std::snprintf(error_, sizeof(error_), "%s", what);
error_set_.store(true, std::memory_order_release);
}
void PacketCapture::error(char* out, std::size_t cap) const noexcept {
if (cap == 0) return;
out[0] = '\0';
if (!error_set_.load(std::memory_order_acquire)) return;
std::snprintf(out, cap, "%s", error_);
}
bool PacketCapture::open(const std::string& path, std::uint32_t slot_count) {
close();
path_ = path;
slot_count_ = slot_count;
seq_ = 0;
cursor_ = 0;
stored_.store(0, std::memory_order_relaxed);
truncated_.store(0, std::memory_order_relaxed);
error_set_.store(false, std::memory_order_relaxed);
const std::chrono::steady_clock::time_point t0 = std::chrono::steady_clock::now();
// Keep the previous capture one deep: re-enabling must not destroy the capture the
// user enabled *for*. A failure here is reported, and the old previous kept.
std::string prev = path;
const std::size_t slash = prev.rfind('/');
const std::size_t dot = prev.rfind('.');
if (dot != std::string::npos && (slash == std::string::npos || dot > slash)) {
prev.insert(dot, "-prev");
} else {
prev += "-prev";
}
::rename(path.c_str(), prev.c_str());
fd_ = ::open(path.c_str(), O_RDWR | O_CREAT | O_EXCL | O_CLOEXEC, 0600);
if (fd_ < 0) {
set_error(strerror(errno));
return false;
}
// Reserved, not sparse: a store into a page the filesystem cannot back raises
// SIGBUS and kills udpscope outright, and an out-of-space condition must be an
// error at the moment the user clicked, not a crash ten minutes later.
const std::uint64_t total = kHeaderBytes + std::uint64_t(slot_count) * kSlotBytes;
if (::posix_fallocate(fd_, 0, off_t(total)) != 0) {
set_error(strerror(errno));
::close(fd_);
fd_ = -1;
::unlink(path.c_str());
return false;
}
map_ = static_cast<std::uint8_t*>(
::mmap(nullptr, total, PROT_READ | PROT_WRITE, MAP_SHARED, fd_, 0));
if (map_ == MAP_FAILED) {
map_ = nullptr;
set_error(strerror(errno));
::close(fd_);
fd_ = -1;
::unlink(path.c_str());
return false;
}
map_bytes_ = total;
std::uint8_t* h = map_;
std::memset(h, 0, kHeaderBytes);
std::memcpy(h, "UDPSCAP\0", 8);
put_u32(h + 8, 1); // version
put_u32(h + 12, slot_count);
put_u32(h + 16, kSlotBytes);
put_u32(h + 20, 0); // cursor: a hint, `seq` is the order
put_f64(h + 24, wall_seconds()); // the epoch recv_time is relative to
put_u32(h + 32, peer_addr_);
put_u16(h + 36, peer_port_);
put_u16(h + 38, 0); // local_port: the client does not expose its own socket
const double ms =
std::chrono::duration<double, std::milli>(std::chrono::steady_clock::now() - t0).count();
open_ms_.store(ms, std::memory_order_relaxed);
enabled_.store(true, std::memory_order_release);
return true;
}
void PacketCapture::close() noexcept {
if (map_ != nullptr) {
::munmap(map_, map_bytes_);
map_ = nullptr;
}
if (fd_ >= 0) {
::close(fd_);
fd_ = -1;
}
map_bytes_ = 0;
enabled_.store(false, std::memory_order_relaxed);
}
void PacketCapture::store(const std::uint8_t* bytes, std::size_t len, double recv_time) noexcept {
if (!enabled_.load(std::memory_order_relaxed) || map_ == nullptr) return;
std::uint8_t* slot = map_ + kHeaderBytes + std::uint64_t(cursor_) * kSlotBytes;
std::uint32_t orig = std::uint32_t(len);
std::size_t fit = len;
if (fit > kMaxDatagramBytes) {
fit = kMaxDatagramBytes;
truncated_.fetch_add(1, std::memory_order_relaxed);
}
put_f64(slot, recv_time);
put_u32(slot + 8, std::uint32_t(fit));
put_u32(slot + 12, orig);
std::memcpy(slot + kRecordHeaderBytes, bytes, fit);
++seq_;
publish_seq(slot, seq_); // last: a non-zero `seq` is what makes a slot complete
cursor_ = (cursor_ + 1) % slot_count_;
put_u32(map_ + 20, cursor_); // a hint for a human in xxd; `seq` is the order
stored_.fetch_add(1, std::memory_order_relaxed);
}
bool read_capture(const std::string& path, CaptureReadStats* out) {
if (out == nullptr) return false;
*out = CaptureReadStats{};
FILE* f = std::fopen(path.c_str(), "rb");
if (f == nullptr) return false;
std::fseek(f, 0, SEEK_END);
const long size = std::ftell(f);
std::fseek(f, 0, SEEK_SET);
out->file_bytes = std::uint64_t(size < 0 ? 0 : size);
std::uint8_t h[kHeaderBytes];
if (std::fread(h, 1, sizeof(h), f) != sizeof(h)) {
std::fclose(f);
return false;
}
out->header_ok = std::memcmp(h, "UDPSCAP\0", 8) == 0 && get_u32(h + 8) == 1;
out->slot_count = get_u32(h + 12);
out->slot_bytes = get_u32(h + 16);
out->cursor = get_u32(h + 20);
out->t0_realtime = get_f64(h + 24);
if (!out->header_ok || out->slot_count == 0 || out->slot_bytes < kRecordHeaderBytes + 8) {
std::fclose(f);
return false;
}
const std::uint32_t payload = out->slot_bytes - kRecordHeaderBytes - kSeqBytes;
std::vector<std::uint8_t> slot(out->slot_bytes);
for (std::uint32_t i = 0; i < out->slot_count; ++i) {
if (std::fseek(f, long(kHeaderBytes + std::uint64_t(i) * out->slot_bytes), SEEK_SET) != 0) {
break;
}
if (std::fread(slot.data(), 1, slot.size(), f) != slot.size()) break;
const std::uint64_t seq = read_seq(slot.data());
const std::uint32_t len = get_u32(slot.data() + 8);
const std::uint32_t orig = get_u32(slot.data() + 12);
if (seq == 0 && len == 0 && orig == 0) {
++out->empty;
continue;
}
if (seq == 0) {
++out->torn; // bytes down, ordinal never published: a capture that died mid-copy
continue;
}
++out->stored;
out->payload += len;
if (orig > payload) ++out->truncated;
if (out->first_seq == 0 || seq < out->first_seq) out->first_seq = seq;
if (seq > out->last_seq) out->last_seq = seq;
}
std::fclose(f);
return true;
}
} // namespace udpscope
+124
View File
@@ -0,0 +1,124 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once
#include <atomic>
#include <cstdint>
#include <string>
namespace udpscope {
/// The largest datagram the client's socket can deliver, and therefore the payload
/// capacity of one capture slot. Kept equal to the client's own receive buffer: an
/// over-long datagram is impossible by construction, and `store()` counts the
/// impossibility rather than trusting it.
inline constexpr std::uint32_t kMaxDatagramBytes = 65535 + 17;
/// One slot's stride: the fixed record header, the largest datagram, and the `seq`
/// word that is written last. 8-byte aligned.
inline constexpr std::uint32_t kRecordHeaderBytes = 16; ///< t_arrival f64, len u32, orig_len u32
inline constexpr std::uint32_t kSlotBytes =
kRecordHeaderBytes + kMaxDatagramBytes + 8; ///< the trailing 8 are `seq`
/// Owns the ring file a packet capture stores raw datagrams into, and stores a
/// datagram into it. It includes no GTK, no `ScopeModel`, and no client types: its
/// whole world is bytes, a clock reading and one file.
///
/// Everything here runs on the producer thread except `open()`'s caller choice and
/// the GTK thread's reads of the counters and the error, which are publishable
/// without a lock: see the field table in the design note
/// (`docs/superpowers/specs/2026-10-09-udp-packet-capture-design.md`).
class PacketCapture {
public:
PacketCapture() = default;
~PacketCapture() { close(); }
PacketCapture(const PacketCapture&) = delete;
PacketCapture& operator=(const PacketCapture&) = delete;
/// Renames an existing capture to `<path>` minus extension plus `-prev.bin`,
/// creates and reserves the new file, maps it, writes the header. Returns false
/// and leaves the object disabled on any failure, with `error()` set.
bool open(const std::string& path, std::uint32_t slot_count);
/// Unmaps and closes. Idempotent; keeps the counters, which read "since open()".
void close() noexcept;
bool enabled() const noexcept { return enabled_.load(std::memory_order_relaxed); }
/// One datagram. Called only from the producer thread. Never allocates, never
/// blocks, never fails: an over-long datagram is truncated to the slot and counted.
void store(const std::uint8_t* bytes, std::size_t len, double recv_time) noexcept;
std::uint64_t stored() const noexcept { return stored_.load(std::memory_order_relaxed); }
std::uint64_t truncated() const noexcept {
return truncated_.load(std::memory_order_relaxed);
}
std::uint32_t slot_count() const noexcept { return slot_count_; }
const std::string& path() const noexcept { return path_; }
/// The source's IPv4, in network order, and its port: written into the header at
/// open() so the pcap converter can synthesise a UDP header. Call before open(),
/// from the thread that owns the decision -- the same write-once discipline as the
/// path. `local_port` stays zero: the client does not expose its own socket.
void set_peer(std::uint32_t addr_net_order, std::uint16_t port) noexcept {
peer_addr_ = addr_net_order;
peer_port_ = port;
}
/// Wall milliseconds `open()` took, measured rather than asserted: the one cost a
/// toggle has, and a reading above ~50 ms is a finding about the filesystem.
double open_ms() const noexcept { return open_ms_.load(std::memory_order_relaxed); }
/// Copies the last open() failure out, or writes an empty string. Deliberately not
/// a `const std::string&`: the producer writes the fixed buffer and *then* publishes
/// the flag with a release store, and the GTK thread copies only when the flag reads
/// true -- a `std::string` shared like that is a data race with a reallocating
/// buffer, the kind that reads correctly for months.
void error(char* out, std::size_t cap) const noexcept;
private:
void set_error(const char* what) noexcept;
std::string path_;
std::uint32_t slot_count_ = 0;
int fd_ = -1;
std::uint8_t* map_ = nullptr; ///< whole file, header first
std::uint64_t map_bytes_ = 0;
std::uint64_t seq_ = 0; ///< 1-based ordinal since open(); producer-only
std::uint32_t cursor_ = 0; ///< next slot index to write; producer-only
std::uint32_t peer_addr_ = 0; ///< the source's IPv4, network order
std::uint16_t peer_port_ = 0;
std::atomic<bool> enabled_{false};
std::atomic<std::uint64_t> stored_{0};
std::atomic<std::uint64_t> truncated_{0};
std::atomic<bool> error_set_{false};
std::atomic<double> open_ms_{0.0};
mutable char error_[128] = {0};
};
/// What a capture file says when read back off disk. `stored` counts slots with a
/// non-zero `seq`; `torn` counts slots with bytes but no `seq` (a capture that died
/// mid-copy); `empty` counts all-zero slots, which a freshly reserved file is full of.
struct CaptureReadStats {
bool header_ok = false;
std::uint32_t slot_count = 0;
std::uint32_t slot_bytes = 0;
std::uint32_t cursor = 0;
double t0_realtime = 0.0;
std::uint64_t stored = 0;
std::uint64_t torn = 0;
std::uint64_t empty = 0;
std::uint64_t truncated = 0; ///< slots whose `orig_len` exceeds the payload capacity
std::uint64_t payload = 0; ///< sum of stored slots' `len`: the bytes the wire delivered
std::uint64_t first_seq = 0;
std::uint64_t last_seq = 0;
std::uint64_t file_bytes = 0;
};
/// Reads a capture file back and validates it. Pure POSIX plus `<cstdio>`; used by the
/// headless probe, and `tools/capture_to_pcap.py` re-implements the walk in Python.
bool read_capture(const std::string& path, CaptureReadStats* out);
} // namespace udpscope
+12
View File
@@ -54,9 +54,14 @@ void print_usage(std::FILE* out, const char* argv0) {
" --recv-buffer BYTES SO_RCVBUF for the receiver socket (default 8388608)\n"
" --plots N stacked plots to start with, 1..8 (default 1); rows are\n"
" added and removed from the layout editor\n"
" --capture-packets N store the last N raw datagrams on disk for forensics, in a\n"
" ring under the cache directory (1000 slots is ~62.6 MiB);\n"
" off unless given, and toggleable from the status bar\n"
" --relative-time take the plot's time from the packet counter and the\n"
" declared rate, not the stream's time signal; absolute\n"
" time is then kept for export only\n"
" --open FILE open an exported waveform (.npy) instead of a live source:\n"
" signals from its columns, no socket, no acquisition\n"
" -h, --help this text\n",
argv0);
}
@@ -82,6 +87,7 @@ Config parse_args(int argc, char** argv) {
c.interface_addr = value_of(argc, argv, i, "--interface");
} else if (a == "--buffer-mb" || a.rfind("--buffer-mb=", 0) == 0) {
c.buffer_mb = parse_double("--buffer-mb", value_of(argc, argv, i, "--buffer-mb"));
c.buffer_mb_specified = true;
if (c.buffer_mb < 1.0) fail("--buffer-mb must be at least 1");
} else if (a == "--max-window" || a.rfind("--max-window=", 0) == 0) {
c.max_window_s = parse_double("--max-window", value_of(argc, argv, i, "--max-window"));
@@ -101,6 +107,12 @@ Config parse_args(int argc, char** argv) {
fail("--plots must be 1.." + std::to_string(RowSet::kMaxRows) + ": " + v);
}
c.plots = static_cast<int>(n);
} else if (a == "--capture-packets" || a.rfind("--capture-packets=", 0) == 0) {
const long n = std::strtol(value_of(argc, argv, i, "--capture-packets").c_str(), nullptr, 10);
if (n < 1 || n > 100000) fail("--capture-packets must be 1..100000");
c.capture_packets = static_cast<std::uint32_t>(n);
} else if (a == "--open" || a.rfind("--open=", 0) == 0) {
c.open_waveform = value_of(argc, argv, i, "--open");
} else if (a == "--relative-time") {
c.relative_time = true;
} else {
+14
View File
@@ -24,6 +24,10 @@ struct Config {
/// Size of *each* of the two sample buffers, in megabytes. The application
/// always allocates two identical buffers (spec item 1).
double buffer_mb = 200.0;
/// Whether `--buffer-mb` was given. A stored configuration's buffer size fills in only when the
/// flag was *absent*: an explicit flag beats a saved setting, and "not asked" is not the same
/// question as "asked for 200".
bool buffer_mb_specified = false;
/// Largest time window the toolbar offers, in seconds. The buffer partition is
/// sized to hold at least this much time (spec: decimation).
@@ -40,6 +44,16 @@ struct Config {
/// from the stream's own time signal, so arrival and producer-clock jitter cannot move a
/// sample's x. Absolute stream time is still recorded, for export only (`todo.md` item 2).
bool relative_time = false;
/// `--capture-packets N`: the ring size of the raw-datagram capture, 1..100000.
/// 0 means the flag was absent: the capture is off, and the popover button then
/// uses the 1000-slot default when the user toggles it.
std::uint32_t capture_packets = 0;
/// `--open FILE`: a `.npy` an earlier run exported, opened for viewing and analysis instead of a
/// live source. File mode and source mode are exclusive -- there is no acquisition behind a file,
/// and the chooser's Waveforms page sets this rather than the connection fields.
std::string open_waveform;
};
/// Parses argv. On `--help` prints usage to stdout and exits(0); on a bad
+329
View File
@@ -0,0 +1,329 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#include "event_store.hpp"
#include <dirent.h>
#include <sys/stat.h>
#include <unistd.h>
#include <algorithm>
#include <cstdio>
#include <cstring>
#include <ctime>
#include "layout_store.hpp"
namespace udpscope {
namespace {
constexpr const char* kEventsSubdir = "/events";
void mkdirs(const std::string& path) {
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);
}
}
/// `2026-10-09T11:32:00` in local time, plus the sequence number: two events inside one second are
/// ordinary (a 5 Hz trigger latches five times a second), and a stem that collides would overwrite
/// the event it collided with.
std::string event_stem(double wall_time, std::uint64_t seq) {
const std::time_t t = static_cast<std::time_t>(wall_time);
std::tm tm{};
localtime_r(&t, &tm);
char buf[64];
std::strftime(buf, sizeof(buf), "%Y%m%d-%H%M%S", &tm);
char stem[96];
std::snprintf(stem, sizeof(stem), "%s-%04llu", buf,
static_cast<unsigned long long>(seq % 10000ull));
return stem;
}
/// `a.npy` -> `a`; anything else -> "".
std::string stem_of(const std::string& file, const char* ext) {
const std::size_t n = std::strlen(ext);
if (file.size() <= n || file.compare(file.size() - n, n, ext) != 0) return {};
return file.substr(0, file.size() - n);
}
/// The event's picture, drawn from its samples: one horizontal strip per signal, min/max per pixel
/// column, the trigger edge as a brighter line. A 24-bit BMP -- the header is nine words and the rest
/// is the pixels, so it needs no encoder, and a person can open it with anything.
///
/// The palette is fixed rather than the plots': this is navigation, and what has to be told apart at
/// a glance is one *event* from another, not one signal from another (for which the names are on the
/// row above).
bool write_preview(const ExportTable& table, const std::string& path, int width = 480, int height = 160) {
if (table.empty() || table.columns.empty()) return false;
const std::size_t rows = table.rows();
const std::size_t strips = table.columns.size();
const int strip_h = std::max(6, height / static_cast<int>(strips));
const int total_h = strip_h * static_cast<int>(strips);
static const std::uint32_t kPalette[] = {0x4FA3FFu, 0xFF7A4Fu, 0x6EE07Au, 0xE06ED0u,
0xFFD24Fu, 0x6FD8E0u, 0xB9A0FFu, 0xE0E0E0u};
// One byte per pixel per channel, bottom-up (BMP's own order), padded to a 4-byte row.
const int row_bytes = ((width * 3) + 3) & ~3;
std::vector<unsigned char> pixels(std::size_t(row_bytes) * total_h, 0);
const auto put = [&](int x, int y, std::uint32_t rgb, double alpha) {
if (x < 0 || y < 0 || x >= width || y >= total_h) return;
unsigned char* p = &pixels[std::size_t(total_h - 1 - y) * row_bytes + std::size_t(x) * 3];
const double a = std::clamp(alpha, 0.0, 1.0);
p[0] = static_cast<unsigned char>(p[0] * (1.0 - a) + ((rgb >> 16) & 0xFF) * a);
p[1] = static_cast<unsigned char>(p[1] * (1.0 - a) + ((rgb >> 8) & 0xFF) * a);
p[2] = static_cast<unsigned char>(p[2] * (1.0 - a) + (rgb & 0xFF) * a);
};
// The background: a dark field, so the traces read the way they do on screen.
for (int y = 0; y < total_h; ++y) {
for (int x = 0; x < width; ++x) put(x, y, 0x101418u, 1.0);
}
for (std::size_t c = 0; c < strips; ++c) {
const std::vector<f32>& values = table.columns[c].values;
if (values.empty()) continue;
double lo = values[0];
double hi = values[0];
for (f32 v : values) {
if (!std::isfinite(v)) continue;
lo = std::min(lo, double(v));
hi = std::max(hi, double(v));
}
if (!(hi > lo)) {
hi = lo + 1.0;
}
const int base = static_cast<int>(c) * strip_h;
const std::uint32_t colour = kPalette[c % (sizeof(kPalette) / sizeof(kPalette[0]))];
for (int x = 0; x < width; ++x) {
const std::size_t from = rows * std::size_t(x) / std::size_t(width);
const std::size_t to = std::max(from + 1, rows * std::size_t(x + 1) / std::size_t(width));
double vmin = 1e300;
double vmax = -1e300;
for (std::size_t i = from; i < to && i < rows; ++i) {
const double v = values[i];
if (!std::isfinite(v)) continue;
vmin = std::min(vmin, v);
vmax = std::max(vmax, v);
}
if (vmin > vmax) continue;
const auto to_y = [&](double v) {
const double frac = (v - lo) / (hi - lo);
return base + strip_h - 1 - static_cast<int>(frac * (strip_h - 1));
};
const int y0 = std::clamp(to_y(vmax), base, base + strip_h - 1);
const int y1 = std::clamp(to_y(vmin), base, base + strip_h - 1);
for (int y = y0; y <= y1; ++y) put(x, y, colour, 1.0);
}
// The strip's own baseline, so an empty stretch reads as a flat trace and not as a gap.
for (int x = 0; x < width; ++x) {
const int y = base + strip_h - 1;
put(x, y, 0x303840u, 1.0);
}
}
// The trigger edge: the snapshot's x = 0, which is at the same fraction of every event's window.
if (!table.time.empty()) {
const double t0 = table.time.front();
const double t1 = table.time.back();
if (t1 > t0) {
const int x = static_cast<int>((0.0 - t0) / (t1 - t0) * (width - 1));
for (int y = 0; y < total_h; ++y) put(x, y, 0xFFE9A0u, 0.85);
}
}
std::FILE* f = std::fopen(path.c_str(), "wb");
if (f == nullptr) return false;
const std::uint32_t image_bytes = std::uint32_t(row_bytes) * std::uint32_t(total_h);
const std::uint32_t file_bytes = 54u + image_bytes;
unsigned char header[54] = {};
header[0] = 'B';
header[1] = 'M';
const auto put32 = [&](int at, std::uint32_t v) {
header[at] = static_cast<unsigned char>(v & 0xFF);
header[at + 1] = static_cast<unsigned char>((v >> 8) & 0xFF);
header[at + 2] = static_cast<unsigned char>((v >> 16) & 0xFF);
header[at + 3] = static_cast<unsigned char>((v >> 24) & 0xFF);
};
put32(2, file_bytes);
put32(10, 54u);
put32(14, 40u);
put32(18, std::uint32_t(width));
put32(22, std::uint32_t(total_h));
header[26] = 1; // planes
header[28] = 24; // bits per pixel
put32(34, image_bytes);
put32(38, 2835u); // 72 dpi, in pixels per metre
put32(42, 2835u);
std::fwrite(header, 1, sizeof(header), f);
std::fwrite(pixels.data(), 1, pixels.size(), f);
std::fclose(f);
return true;
}
} // namespace
std::string event_meta_text(const EventRecord& r) {
char buf[512];
std::string out;
std::snprintf(buf, sizeof(buf), "wall_time = %.6f\n", r.wall_time);
out += buf;
std::snprintf(buf, sizeof(buf), "duration_s = %.6f\n", r.duration_s);
out += buf;
std::snprintf(buf, sizeof(buf), "pre_s = %.6f\n", r.pre_s);
out += buf;
std::snprintf(buf, sizeof(buf), "post_s = %.6f\n", r.post_s);
out += buf;
std::snprintf(buf, sizeof(buf), "edge = %.6f\n", r.edge);
out += buf;
std::snprintf(buf, sizeof(buf), "rate_hz = %.6g\n", r.rate_hz);
out += buf;
std::snprintf(buf, sizeof(buf), "signals = %zu\n", r.signals);
out += buf;
std::snprintf(buf, sizeof(buf), "samples = %zu\n", r.samples);
out += buf;
std::snprintf(buf, sizeof(buf), "seq = %llu\n", static_cast<unsigned long long>(r.seq));
out += buf;
return out;
}
bool event_meta_parse(const std::string& text, EventRecord* out) {
if (out == nullptr) return false;
bool any = false;
std::size_t i = 0;
while (i < text.size()) {
const std::size_t end = text.find('\n', i);
const std::string line = text.substr(i, end == std::string::npos ? std::string::npos : end - i);
i = end == std::string::npos ? text.size() : end + 1;
const std::size_t eq = line.find('=');
if (eq == std::string::npos) continue;
std::string key = line.substr(0, eq);
std::string value = line.substr(eq + 1);
while (!key.empty() && (key.back() == ' ' || key.back() == '\t')) key.pop_back();
while (!value.empty() && (value.front() == ' ' || value.front() == '\t')) value.erase(0, 1);
const double number = std::atof(value.c_str());
if (key == "wall_time") {
out->wall_time = number;
any = true;
} else if (key == "duration_s") {
out->duration_s = number;
any = true;
} else if (key == "pre_s") {
out->pre_s = number;
any = true;
} else if (key == "post_s") {
out->post_s = number;
any = true;
} else if (key == "edge") {
out->edge = number;
any = true;
} else if (key == "rate_hz") {
out->rate_hz = number;
any = true;
} else if (key == "signals") {
out->signals = static_cast<std::size_t>(std::atoll(value.c_str()));
any = true;
} else if (key == "samples") {
out->samples = static_cast<std::size_t>(std::atoll(value.c_str()));
any = true;
} else if (key == "seq") {
out->seq = static_cast<std::uint64_t>(std::strtoull(value.c_str(), nullptr, 10));
any = true;
}
}
return any;
}
EventStore::EventStore(std::string source) {
if (source.empty()) return;
std::string safe;
for (char c : source) {
const bool ok = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') ||
c == '.' || c == '_' || c == '-';
safe.push_back(ok ? c : '_');
}
dir_ = cache_dir() + "/" + safe + kEventsSubdir;
}
std::string EventStore::save(const ExportTable& table, const EventRecord& meta, std::size_t keep) {
if (dir_.empty() || table.empty()) return {};
mkdirs(dir_);
const std::string stem = event_stem(meta.wall_time > 0.0 ? meta.wall_time : double(std::time(nullptr)),
meta.seq);
const std::string samples = dir_ + "/" + stem + ".npy";
std::string error;
if (!write_npy(samples, table, error)) {
std::fprintf(stderr, "[udpscope] event cache: %s\n", error.c_str());
std::fflush(stderr);
return {};
}
EventRecord record = meta;
record.stem = stem;
std::string text = event_meta_text(record);
if (std::FILE* f = std::fopen((dir_ + "/" + stem + ".txt").c_str(), "wb"); f != nullptr) {
std::fwrite(text.data(), 1, text.size(), f);
std::fclose(f);
}
write_preview(table, dir_ + "/" + stem + ".bmp");
prune(keep);
return samples;
}
std::vector<EventRecord> EventStore::list() const {
std::vector<EventRecord> out;
if (dir_.empty()) return out;
DIR* d = ::opendir(dir_.c_str());
if (d == nullptr) return out;
while (const dirent* e = ::readdir(d)) {
const std::string stem = stem_of(e->d_name, ".txt");
if (stem.empty()) continue;
std::FILE* f = std::fopen((dir_ + "/" + e->d_name).c_str(), "rb");
if (f == nullptr) continue;
std::string text;
char buf[512];
std::size_t n = 0;
while ((n = std::fread(buf, 1, sizeof(buf), f)) > 0) text.append(buf, n);
std::fclose(f);
EventRecord r;
if (!event_meta_parse(text, &r)) continue;
r.stem = stem;
r.file = dir_ + "/" + stem + ".npy";
if (::access(r.file.c_str(), R_OK) != 0) continue; // a sidecar without its samples
struct stat sb {};
if (::stat((dir_ + "/" + stem + ".bmp").c_str(), &sb) == 0) {
r.thumbnail = dir_ + "/" + stem + ".bmp";
}
out.push_back(std::move(r));
}
::closedir(d);
// Newest first by the *record's* time, not the file's: a copy or a touch changes the mtime, and the
// list is about when the event happened.
std::stable_sort(out.begin(), out.end(), [](const EventRecord& a, const EventRecord& b) {
if (a.wall_time != b.wall_time) return a.wall_time > b.wall_time;
return a.seq > b.seq;
});
return out;
}
bool EventStore::forget(const std::string& stem) {
if (dir_.empty() || stem.empty()) return false;
const std::string base = dir_ + "/" + stem;
const bool gone = ::unlink((base + ".npy").c_str()) == 0;
::unlink((base + ".txt").c_str());
::unlink((base + ".bmp").c_str());
return gone;
}
void EventStore::prune(std::size_t keep) const {
const std::vector<EventRecord> all = list();
for (std::size_t i = keep; i < all.size(); ++i) {
const std::string base = dir_ + "/" + all[i].stem;
::unlink((base + ".npy").c_str());
::unlink((base + ".txt").c_str());
::unlink((base + ".bmp").c_str());
}
}
} // namespace udpscope
+82
View File
@@ -0,0 +1,82 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once
#include "export.hpp"
#include <cstddef>
#include <cstdint>
#include <string>
#include <vector>
namespace udpscope {
/// What a cached event is, without loading its samples: the sidecar's fields, and what the pane
/// shows in one row.
struct EventRecord {
std::string stem; ///< the file's name without the extension
std::string file; ///< `<stem>.npy`, the event's table in the export format
std::string thumbnail; ///< `<stem>.bmp`, the preview drawn from the samples; else empty
double wall_time = 0.0; ///< CLOCK_REALTIME at the latch, for the row's label
double duration_s = 0.0; ///< pre + post
double pre_s = 0.0;
double post_s = 0.0;
double edge = 0.0; ///< the stream time of the event's x = 0
double rate_hz = 0.0; ///< the stored sample rate at the latch
std::size_t signals = 0; ///< columns in the file
std::size_t samples = 0; ///< rows
std::uint64_t seq = 0; ///< the run's own event counter, so two events in one second differ
};
/// The cache of captured events: `<cache_dir>/<source>/events/`, one `.npy` per event written in
/// the **export format** -- so `--open` and the chooser's Waveforms page read it back with the
/// loader that already exists -- beside a `.txt` sidecar holding what the list shows without
/// loading the samples, and whatever thumbnail the caller rendered.
///
/// No GTK: the caller renders the thumbnail and hands over a path, exactly as it hands over the
/// table. The store owns naming, the sidecar, pruning and listing, which is what makes it testable
/// without a window.
class EventStore {
public:
/// `source` is the `<host>-<port>` pair, the same key the packet capture uses.
explicit EventStore(std::string source = {});
/// How many events to keep, newest first. The oldest beyond it are deleted on every save; item 9's
/// preferences are where this becomes the user's number.
static constexpr std::size_t kDefaultKeep = 32;
const std::string& dir() const noexcept { return dir_; }
bool empty_dir() const noexcept { return dir_.empty(); }
/// Writes `table` as one event, with `meta` in the sidecar and a preview drawn beside it. Returns
/// the samples' path, or an empty string on failure -- a cache that cannot be written must not stop
/// the scope.
///
/// The preview is drawn from the *data* -- min/max per pixel column, one strip per signal -- and not
/// captured from the window: the plot is a `GtkGLArea`, and a GL area's ink is not in the widget's
/// paint tree, so a snapshot of it is empty (`gtk_snapshot_free_to_node()` returns NULL). Drawing it
/// here also keeps this class GTK-free, which is what makes it testable without a window.
std::string save(const ExportTable& table, const EventRecord& meta,
std::size_t keep = kDefaultKeep);
/// The cached events, newest first: the sidecars, never the sample files, so a listing is cheap
/// however large the captures are.
std::vector<EventRecord> list() const;
/// Deletes one event's samples, sidecar and thumbnail.
bool forget(const std::string& stem);
/// Deletes the oldest events beyond `keep`.
void prune(std::size_t keep) const;
private:
std::string dir_;
};
/// The sidecar's text, and back. One `key = value` per line, the project's own TOML subset, so a
/// human can read the cache with `cat` -- which is the point of a forensic directory.
std::string event_meta_text(const EventRecord& record);
bool event_meta_parse(const std::string& text, EventRecord* out);
} // namespace udpscope
+15
View File
@@ -182,6 +182,21 @@ ExportTable collect_export(ScopeModel& model, RowSet& rows) {
return table;
}
ExportTable event_table(ScopeModel& model, const EventSnapshot& snapshot) {
ExportTable table;
table.time = snapshot.stamps;
table.abs_seconds = snapshot.abs_stamps.empty() ? snapshot.stamps : snapshot.abs_stamps;
std::lock_guard<std::mutex> lock(model.mutex());
const std::vector<ChannelInfo>& channels = model.channels();
for (std::size_t c = 0; c < snapshot.values.size() && c < channels.size(); ++c) {
ExportTable::Column column;
column.name = channels[c].name;
column.values = snapshot.values[c];
table.columns.push_back(std::move(column));
}
return table;
}
bool write_npy(const std::string& path, const ExportTable& table, std::string& error) {
if (table.empty()) {
error = "nothing to export: no signal is plotted in the visible range";
+6
View File
@@ -54,6 +54,12 @@ struct ExportTable {
/// model access. In trigger mode nothing is locked: the `EventSnapshot` is immutable.
ExportTable collect_export(ScopeModel& model, RowSet& rows);
/// The held event as a table, built from the **snapshot** rather than from the rows: a cache of the
/// acquisition, every channel the event holds, independent of which traces a row happens to show
/// when the latch lands. The channel names are the model's, in channel order -- the same order the
/// columns come out in, so `--open` restores the same signals.
ExportTable event_table(ScopeModel& model, const EventSnapshot& snapshot);
/// Writes a NumPy `.npy` file holding one structured array: `time` and `abs_seconds` as
/// `<f8`, every signal as `<f4` -- the width the ring actually stores, so the file is
/// about half the size of an all-`f8` one and invents no precision.
+13 -2
View File
@@ -6,8 +6,19 @@ interface. Pass it on the command line, or let udpscope ask.
<b>How to reach it</b>
Run with <tt>--server</tt> and the app connects straight away. Run <i>without</i>
<tt>--server</tt> and a chooser appears first: the sessions udpscope has stored, newest
first, plus a <b>New host…</b> form for one it has not seen. The chooser runs before any
<tt>--server</tt> and a chooser appears first: one row per stored <i>configuration</i>
(<tt>host:port · name</tt>, its note, and the settings it carries), newest first, plus a
<b>New host…</b> form that takes the address <i>and</i> the configuration it belongs to — its name
and note, the <b>buffer size</b>, and whether time is relative or absolute. A row's pencil button
opens the same form on that configuration, pre-filled, and changes to its name or address move it
(the old file is deleted).
The second page, <b>Waveforms</b>, opens an <i>exported</i> run instead of a live source: the files
opened before (newest first; one that has since been deleted is marked <i>missing</i>) and an
<b>Open…</b> button for a new one. The same file can be opened straight from the command line with
<tt>--open FILE</tt>. A waveform is a source that never arrives — no socket, no acquisition, no
starvation notice — and every instrument works on it: cursors, measurements, math channels and
another export. Only <tt>.npy</tt> files are read. form for one it has not seen. The chooser runs before any
buffer is allocated and before any socket is opened, so declining it costs nothing.
<b>Worth knowing</b>
+3
View File
@@ -13,6 +13,9 @@ the overflow menu opens the editor where the unit and the calibration live.
• Signals start disabled, and enabling one is what starts acquiring it. A trigger on a signal
nobody acquires can never fire, so arming a trigger enables its signal for you.
• <b>Calibrate</b> in a signal's row menu opens the gain and offset it is <i>currently</i> drawn
through, so a trace already scaled by a gain of 2 shows 2 — pressing <i>Apply</i> confirms it
rather than resetting it. A signal nobody calibrated shows the identity, 1 and 0.
• Calibration is applied <i>at ingest</i>, so changing it re-plans the buffer and drops the
history. So does enabling or disabling a signal, and so does a new CONFIG. An empty plot
for a second after one of those is the ring refilling, not a fault.
+4
View File
@@ -16,6 +16,10 @@ corner leaves it.
<b>Worth knowing</b>
• A plot's own <b>right-click menu</b> (the same compact rows as the app's menu) holds the
y-axis scale, the two fits, and <b>Remove signal</b>, which swaps the menu to that plot's traces
plus <i>All traces on this plot</i> — with a <b>Back</b> row to return. That is how a trace comes
off a row without going through the layout editor.
• A drop <i>adds</i> a trace to the row's next free slot; it does not replace one. With four
traces already on a row the drop is refused.
• Inserting a row moves the rows below it <i>with their contents</i>. Inserting above row 1
+21
View File
@@ -14,6 +14,27 @@ share. <b>Arm</b> starts watching; <tt>a</tt> arms and disarms from the keyboard
<b>Worth knowing</b>
• <b>Every triggered acquisition is kept.</b> Latches are written to the event cache
(<tt>~/.cache/udpscope/&lt;source&gt;/events/</tt>) as a <tt>.npy</tt> of the event, a
<tt>.txt</tt> of what it was, and a <tt>.bmp</tt> picture drawn from the samples. The
<b>Events</b> pane on the left -- opened from the toolbar's event-history button, which appears
while recording is enabled, and collapsed until you ask for it -- lists the last 32 newest first
(time, duration, signals, rate), each row with its own picture beside the numbers. Clicking a row
<i>replays</i> it: the stored event is loaded exactly like an exported file, <b>without changing the
layout</b> -- the rows, the colours, the window, the cursors and the measurements stay where they
are and read the stored data instead. A replay leaves trigger mode, because a file has no edge to
wait for, and the pane stays open. Drag the pane's edge to resize it. <b>Live</b> goes back to the
stream; the status bar names the file until you do.
Select two or more events in the list and the two buttons under the header act on them:
<b>Compare</b> puts two events in two plots side by side, keeping the layout you have -- one plot per
event, the axes synchronised, so zooming a row in one zooms the same row in the other -- and
<b>Accumulate</b> overlays up to four of them in the same traces, translucent, so where they agree
the trace is brighter. Your own traces stay where they are in both. They need events with the same
signals and the same length. A single click selects, a <i>double</i> click replays.
<b>Record events</b>, in the trigger panel with the trigger's other options, is what decides whether
anything is kept. It is <b>off by default</b> -- nothing is written to disk until you ask -- and
with it off the history button goes with it.
• Arming <i>enables</i> the signal if it was off, because a trigger on a signal nobody acquires
can never fire.
• The pre-trigger share is a share of the <i>display window</i>, not of the ring. A 2 s window
+41 -15
View File
@@ -1,34 +1,60 @@
<b>Sessions and settings</b>
udpscope stores one session file per source, so reopening a source gives you the scope you
closed. There is no global preferences file: the session <i>is</i> the settings.
udpscope stores named sessions — <i>configurations</i> — per source, so reopening a source gives
you the scope you closed, and one source can hold several arrangements for different experiments.
There is no global preferences file: the session <i>is</i> the settings.
<b>How to reach it</b>
Nothing to open — the file is written as you work. Its path is in the About dialog, and the
directory is <tt>$XDG_CONFIG_HOME/udpscope</tt>, or <tt>~/.config/udpscope</tt> when that is
unset. Each file is named <tt>&lt;host&gt;_&lt;port&gt;.toml</tt>.
unset. Each source has a directory <tt>&lt;host&gt;_&lt;port&gt;/</tt> holding one
<tt>&lt;name&gt;.toml</tt> per configuration and an <tt>active</tt> file naming the one it is on.
<b>Configurations</b>
<b>Configurations…</b> in the menu lists the running source's configurations: click a row to open
it, <tt>×</tt> to delete it, or type a name and a note and press <i>Save this session</i> to keep
the current arrangement under a new one. Opening a configuration saves the current session first,
so switching never loses what it switches from.
The startup chooser (start with no <tt>--server</tt>) lists one row per configuration, with its
note and its settings, so two arrangements of one source are told apart before they open.
A configuration also carries the settings that can only be decided when the buffers are
allocated: the <b>buffer size</b> (<tt>--buffer-mb</tt>), relative time, and the packet capture's
ring size. Opening a configuration from the chooser applies all three; switching inside a running
app applies the rest and says in the status line that these take effect on the next start.
The chooser's form sets the first two for a new host, and its pencil button edits them on an
existing configuration — along with the name and the note. Renaming a configuration, or editing
the address it points at, moves it: the old file is removed rather than left behind.
<b>Worth knowing</b>
• What a session persists: the time window, the rows and each row's slot signals, the
calibration, which signals are enabled, the math channels with their graphs, and the
measurements pane.
• What a configuration persists: the time window, the rows and each row's slot signals, the
calibration, the trace colours, which signals are enabled, the math channels with their graphs,
the measurements pane — and the startup settings: buffer size, relative time and the packet
capture's ring size.
• Signal identity is the <i>name</i> throughout, because a new CONFIG renumbers the channels.
• A file written by a run against a source whose streamer was down would name no signals at all,
so such a run does <b>not</b> write: a configured session is never overwritten by a run that
configured nothing.
configured nothing. (The chooser hides such a file for the same reason.)
• The multicast group and the interface live <i>inside</i> the file, because its name cannot
carry them. A file from an older build has neither key and loads as unicast on the default
route.
• The startup chooser lists only files that name at least one signal, which is what keeps it the
sources you configured rather than every port you have ever tested against.
• Deleting a file is a supported way to start a source over; the chooser's <tt>×</tt> does
exactly that.
• The chooser lists only configurations that name at least one signal, which is what keeps it the
sessions you configured rather than every port you have ever tested against.
• Deleting a configuration is a supported way to start over; the chooser's <tt>×</tt> and the
dialog's do exactly that.
• A session from before configurations existed is imported once as the configuration
<tt>default</tt>, and the old file is kept beside the directory as
<tt>&lt;host&gt;_&lt;port&gt;.toml.imported</tt>.
<b>Example</b>
Connect to <tt>127.0.0.1:8000</tt>, drop three signals on row 1 and set a 2 s window.
<tt>~/.config/udpscope/127.0.0.1_8000.toml</tt> now holds <tt>rows=1 window_s=2</tt> with
<tt>signals = ["sine", "ramp", "noise", ""]</tt>, and the next run reports
<tt>layout loaded from …/127.0.0.1_8000.toml</tt> with those signals already acquired.
Connect to <tt>127.0.0.1:8000</tt>, drop three signals on row 1, set a 2 s window, and save the
session as <tt>bench</tt> with a note. <tt>~/.config/udpscope/127.0.0.1_8000/bench.toml</tt> now
holds <tt>window_s=2 comment="…"</tt> with <tt>signals = ["sine", "ramp", "noise", ""]</tt>, and
the next run reports <tt>layout loaded from …/127.0.0.1_8000/bench.toml</tt> with those signals
already acquired.
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- SPDX-License-Identifier: MIT -->
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
<!-- The event history: a list of what was latched. A frame with three rows in it, the
last one short, so it reads as a list that ends rather than one that continues. Every
coordinate is a whole pixel of the 16px cell, and every subpath is a clockwise filled
`path`: a stroke, a line or a polyline keeps the colour written here and stays dark on
a dark theme, and the frame's hole needs `evenodd` because the rows sit inside it.
(No double hyphen anywhere in a comment here: xmllint, which preprocess= runs it
through, rejects one and fails the whole build.) -->
<svg height="16px" width="16px" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<path fill="#2e3436" fill-rule="evenodd" d="M0 1 H16 V15 H0 Z
M2 3 H14 V13 H2 Z"/>
<path fill="#2e3436" d="M4 4 H12 V6 H4 Z
M4 7 H12 V9 H4 Z
M4 10 H9 V12 H4 Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+1
View File
@@ -21,6 +21,7 @@
<file preprocess="xml-stripblanks">scalable/actions/udpscope-cursors-symbolic.svg</file>
<file preprocess="xml-stripblanks">scalable/actions/udpscope-measure-symbolic.svg</file>
<file preprocess="xml-stripblanks">scalable/actions/udpscope-math-symbolic.svg</file>
<file preprocess="xml-stripblanks">scalable/actions/udpscope-events-symbolic.svg</file>
<file preprocess="xml-stripblanks">scalable/apps/org.udpscope.app.svg</file>
<file preprocess="xml-stripblanks">scalable/apps/org.udpscope.app-symbolic.svg</file>
</gresource>
+226 -41
View File
@@ -114,56 +114,223 @@ std::string config_dir() {
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::vector<KnownSource> known_sources() {
std::vector<KnownSource> out;
const std::string dir = config_dir();
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 name = e->d_name;
if (name.size() <= 5 || name.compare(name.size() - 5, 5, ".toml") != 0) continue;
const std::string stem = name.substr(0, name.size() - 5);
// The *last* '_': `sanitize()` replaces anything unsafe in a host with one, so the host part
// may hold them too and a first-match split would return a host cut in half.
const std::size_t cut = stem.rfind('_');
if (cut == std::string::npos || cut + 1 == stem.size()) continue;
char* end = nullptr;
const long port = std::strtol(stem.c_str() + cut + 1, &end, 10);
if (end == nullptr || *end != '\0' || port <= 0 || port > 65535) continue;
const std::string host = stem.substr(0, cut);
// The name must be one `layout_path()` could have written, or the `×` -- which rebuilds it
// through `layout_path()` -- would unlink a different session. `sanitize()` is idempotent on
// any host that already came back out of a filename, so this is also true for hosts whose names
// contain `_` from the sanitizer.
if (sanitize(host) + "_" + std::to_string(port) != stem) continue;
LayoutState st;
const std::string path = dir + "/" + name;
if (!load_layout(path, st)) continue;
KnownSource s;
s.host = host;
s.port = static_cast<std::uint16_t>(port);
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;
}
}
if (s.signals == 0) continue; // see the header: a session nobody configured
const std::string dir = root + "/" + e->d_name;
struct stat sb {};
s.mtime = (::stat(path.c_str(), &sb) == 0) ? static_cast<std::int64_t>(sb.st_mtime) : 0;
out.push_back(std::move(s));
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
@@ -174,10 +341,10 @@ std::vector<KnownSource> known_sources() {
}
bool forget_source(const KnownSource& src) {
// Through `layout_path()` rather than by rebuilding the name here: one expression of the
// 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 std::remove(layout_path(src.host, src.port).c_str()) == 0;
return forget_configuration(src.host, src.port, src.config);
}
std::string math_node_text(const MathNode& node) {
@@ -315,6 +482,20 @@ bool load_layout(const std::string& path, LayoutState& out) {
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") {
@@ -434,6 +615,10 @@ bool save_layout(const std::string& path, const LayoutState& state) {
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]]\nsignals = [";
for (std::size_t i = 0; i < row.signals.size(); ++i) {
+56 -4
View File
@@ -65,6 +65,20 @@ struct LayoutState {
/// The measurements pane, by signal *name* and with the type and options a row was built with.
std::vector<Measure> measures;
/// Free text the user attached to this configuration: what it is for, what to look at first.
/// A configuration is a *named* session, and the name alone rarely says what makes it different
/// from its neighbour.
std::string comment;
/// The startup settings the configuration carries, applied when the chooser opens it -- which is
/// why they live here and not only on the command line: two sources on one machine want different
/// buffers and different clocks, and retyping the flags to look at the second one is how a
/// setting gets forgotten. A zero `buffer_mb` and an empty `capture_packets` mean "not stored":
/// the command line's value stands, so an old file cannot silently shrink a buffer.
double buffer_mb = 0.0;
bool relative_time = false;
std::uint32_t capture_packets = 0;
};
/// A data source udpscope has a stored session for: one row of the startup chooser.
@@ -76,13 +90,23 @@ struct KnownSource {
int rows = 0; ///< plots the session had
int signals = 0; ///< named slots across those plots
std::int64_t mtime = 0; ///< orders the list; never shown
/// Which *configuration* of that source this row is. A source may have several, each a named
/// session of its own, so the pair (host, port, config) is what identifies a row.
std::string config;
std::string comment; ///< the configuration's note, shown beside its name
/// The startup settings this configuration carries, so the chooser can apply them.
double buffer_mb = 0.0;
bool relative_time = false;
std::uint32_t capture_packets = 0;
};
/// Every session file that names at least one signal, newest first.
/// Every configuration that names at least one signal *or* carries a setting, newest first.
///
/// The filter is what makes the list usable: a development machine accumulates one session file
/// per probe run on a throwaway port -- 140 of them when this was written -- and every one holds
/// `signals = ["", "", "", ""]`. A listed source is one somebody actually configured.
/// `signals = ["", "", "", ""]`. A listed source is one somebody actually configured — and a
/// buffer size, a note or a clock counts as configuring it, because that is how the chooser's form
/// sets a host up before it has ever streamed.
std::vector<KnownSource> known_sources();
/// Deletes one source's session file; the chooser's `x`. False means the file was already gone or
@@ -98,11 +122,39 @@ bool math_node_from_text(const std::string& text, MathNode& out);
/// The directory session files live in: $XDG_CONFIG_HOME/udpscope, else
/// ~/.config/udpscope, else /tmp/udpscope-config. Not created by this call.
std::string config_dir();
/// `$XDG_CACHE_HOME/udpscope`, or `~/.cache/udpscope`: where bulky, disposable state
/// lives (the packet captures; item 9's event cache). Created on demand by callers.
std::string cache_dir();
/// `~/.config/udpscope/<host>_<port>.toml`, with the directory created.
/// Characters unsafe in a filename are replaced in the host part.
/// The *legacy* single-file path, `~/.config/udpscope/<host>_<port>.toml`, with the directory
/// created. Still the import source for a file written before configurations existed; nothing
/// writes here any more.
std::string layout_path(const std::string& host, std::uint16_t port);
/// The per-source directory a source's configurations live in:
/// `~/.config/udpscope/<host>_<port>/`. Not created by this call.
std::string config_dir_for(const std::string& host, std::uint16_t port);
/// One configuration's file, `<config_dir_for(host, port)>/<name>.toml`, with the directory
/// created. The name is sanitized the way the host is, so a name the user typed with a slash in it
/// cannot escape the directory.
std::string configuration_path(const std::string& host, std::uint16_t port,
const std::string& name);
/// The source's configurations, newest first. A legacy single-file session appears here as the
/// configuration named `default`, and importing it is the first thing this does.
std::vector<std::string> configuration_names(const std::string& host, std::uint16_t port);
/// The configuration the source was last left in: `<dir>/active`, or the newest that exists when
/// nothing wrote one. Never empty for a source that has any configuration at all.
std::string active_configuration(const std::string& host, std::uint16_t port);
/// Remembers which configuration this source is on. Called by every save.
bool set_active_configuration(const std::string& host, std::uint16_t port, const std::string& name);
/// Deletes one configuration's file, and `active` if it named it.
bool forget_configuration(const std::string& host, std::uint16_t port, const std::string& name);
/// 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).
+41 -6
View File
@@ -3,6 +3,7 @@
#include "acquisition.hpp"
#include "config.hpp"
#include "waveform.hpp"
#include "model.hpp"
#include "plot_host.hpp"
#include "source_dialog.hpp"
@@ -22,17 +23,51 @@ int main(int argc, char** argv) {
// No `--server` means the user has not said which source to open, so ask. Before the model:
// declining must cost no buffer and no socket, and nothing below has to cope with "no source".
if (!cfg.server_specified && !udpscope::choose_source(cfg)) return 0;
// `--open` is the other answer to that question, and the chooser knows it: its Waveforms page
// fills `open_waveform` in instead of the connection fields.
if (!cfg.server_specified && cfg.open_waveform.empty() && !udpscope::choose_source(cfg)) return 0;
// A source named on the command line skips the chooser, but its *stored* configuration still
// applies: the buffer size, the clock and the capture ring are decided here, before the model --
// which is the only moment they can be -- and a saved host whose buffer was ignored because the
// address came from the command line is an edit that looks like it never took effect.
if (cfg.server_specified) udpscope::apply_stored_settings(cfg);
udpscope::ScopeModel model(cfg);
udpscope::RowSet rows;
rows.set_row_count(cfg.plots);
udpscope::Acquisition acquisition(model, cfg);
try {
acquisition.start();
} catch (const std::exception& e) {
std::fprintf(stderr, "udpscope: %s\n", e.what());
return 1;
if (cfg.open_waveform.empty()) {
try {
acquisition.start();
} catch (const std::exception& e) {
std::fprintf(stderr, "udpscope: %s\n", e.what());
return 1;
}
} else {
// No socket and no thread: the file is the source, and there is nothing to arrive afterwards.
// The acquisition object is passed to the UI all the same -- stopped, which is what every
// "paused"/"capturing" question answers as false.
udpscope::WaveformLoadReport report;
std::string error;
if (!udpscope::load_waveform_into_model(model, cfg.open_waveform, &report, &error)) {
std::fprintf(stderr, "udpscope: %s\n", error.c_str());
return 1;
}
// One row per column, so every trace opens where a user can see and rearrange it -- and sized
// *here*, before the UI exists, exactly as `--plots` does: resizing the rows from inside the
// activation leaves the panels built for the old count.
rows.set_row_count(std::min<int>(std::max<int>(cfg.plots, static_cast<int>(report.signals)),
udpscope::RowSet::kMaxRows));
udpscope::remember_waveform(cfg.open_waveform);
// The whole file framed from the start: the window is the recording's own span, and nothing
// afterwards restates it -- a zoom is the user's.
if (report.span_s > 0.0) rows.set_time_window(report.span_s);
std::fprintf(stderr,
"[udpscope] waveform loaded: %s signals=%zu samples=%zu span=%.6fs rate=%.6g "
"time=[%.6f..%.6f]\n",
cfg.open_waveform.c_str(), report.signals, report.samples, report.span_s,
report.rate_hz, report.first_time, report.last_time);
std::fflush(stderr);
}
const int result = udpscope::run_ui(argc, argv, cfg, model, acquisition, rows);
+7
View File
@@ -61,6 +61,13 @@ void biquad(MathOp op, double fc, double q, double dt, double& b0, double& b1, d
/// that actually need carrying between samples.
double biquad_step(double x, double b0, double b1, double b2, double a1, double a2, double& z1,
double& z2) {
// A reserved hole is NaN in the source, and a NaN integrated into the state words is
// permanent: every later sample would come out NaN, so a filtered trace died at the first
// gap and never came back -- which is what a dropped packet did to every low pass in
// relative mode, where a counter gap reserves NaN slots by construction. A gap in the input
// is a gap in the output (the hole stays visible), and the filter's history is the last
// *real* samples it saw, so the state is left alone and the trace resumes after the hole.
if (!std::isfinite(x)) return kNaN;
const double y = b0 * x + z1;
z1 = b1 * x - a1 * y + z2;
z2 = b2 * x - a2 * y;
+60
View File
@@ -170,6 +170,35 @@ ScopeModel::ScopeModel(const Config& cfg) : cfg_(cfg) {
trigger_.holdoff_s = cfg.trigger_holdoff_s;
}
namespace {
/// True when the incoming CONFIG names exactly the stream the model already has: same
/// signals, same shapes, rates, ranges and time modes. A reconnect re-sends the CONFIG,
/// and EMI makes that happen at the worst possible moment; the buffer, its history, the
/// holes, the trigger's held event and every measurement are all still valid and must
/// survive the reconnect untouched. Only a *different* stream is worth a re-plan, and a
/// re-plan is what drops history (see `rebuild_locked`).
bool same_stream_signals(const std::vector<SignalInfo>& incoming,
const std::vector<SignalInfo>& current) {
// `current` is the stream's signals with the math channels' synthetic entries appended,
// so compare the incoming list against current's prefix, skipping nothing on the left.
std::size_t n_stream = 0;
while (n_stream < current.size() && !current[n_stream].math) ++n_stream;
if (incoming.size() != n_stream) return false;
for (std::size_t i = 0; i < incoming.size(); ++i) {
const SignalInfo& a = incoming[i];
const SignalInfo& b = current[i];
if (a.name != b.name || a.unit != b.unit || a.type_code != b.type_code ||
a.quant_type != b.quant_type || a.rows != b.rows || a.cols != b.cols ||
a.range_min != b.range_min || a.range_max != b.range_max ||
a.time_mode != b.time_mode || a.sampling_rate != b.sampling_rate ||
a.time_signal_idx != b.time_signal_idx) {
return false;
}
}
return true;
}
} // namespace
void ScopeModel::set_signals(std::vector<SignalInfo> signals) {
std::lock_guard<std::mutex> lock(mutex_);
// Keep the enable state across CONFIG updates for signals that kept their name. The math
@@ -184,6 +213,14 @@ void ScopeModel::set_signals(std::vector<SignalInfo> signals) {
signals.erase(std::remove_if(signals.begin(), signals.end(),
[](const SignalInfo& s) { return s.math; }),
signals.end());
if (!signals_.empty() && same_stream_signals(signals, signals_)) {
// A reconnect's identical CONFIG: the honest recovery is *nothing* -- the ring keeps
// its history, the trigger keeps its state and its held event, and the outage shows
// as the gap in the data that it was.
std::fprintf(stderr, "[udpscope] CONFIG unchanged: keeping the buffer and its history\n");
std::fflush(stderr);
return;
}
signals_ = std::move(signals);
enabled_.assign(signals_.size(), false);
for (std::size_t i = 0; i < signals_.size(); ++i) {
@@ -257,6 +294,22 @@ void ScopeModel::prepare_math_locked() {
}
}
void ScopeModel::push_loaded_slot(const std::vector<f32>& values, f64 t, f64 abs_t) {
std::lock_guard<std::mutex> lock(mutex_);
if (!store_) return;
BufferStore& store = *store_;
const std::size_t channels = channels_.size();
std::vector<f32> slot(channels, 0.0f);
for (std::size_t i = 0; i < channels; ++i) {
const f32 v = i < values.size() ? values[i] : 0.0f;
store.values(i).push(v);
slot[i] = v;
}
store.time().push(t);
if (auto* abs = store.abs_time()) abs->push(abs_t);
push_math_sample(slot);
}
void ScopeModel::push_math_sample(const std::vector<f32>& sample) {
// The common case is no derived channels at all, and this runs once per *stored slot* -- so the
// early-out is what keeps a stream with no math from paying for the feature.
@@ -441,6 +494,13 @@ void ScopeModel::set_signal_enabled(std::size_t signal, bool on) {
generation_.fetch_add(1, std::memory_order_release);
}
std::pair<double, double> ScopeModel::calibration(std::size_t signal) {
std::lock_guard<std::mutex> lock(mutex_);
if (signal >= signals_.size()) return {1.0, 0.0};
const auto it = calibration_.find(signals_[signal].name);
return it != calibration_.end() ? it->second : std::pair<double, double>{1.0, 0.0};
}
void ScopeModel::set_calibration(std::size_t signal, double gain, double offset) {
std::lock_guard<std::mutex> lock(mutex_);
if (signal < signals_.size()) calibration_[signals_[signal].name] = {gain, offset};
+13
View File
@@ -280,6 +280,13 @@ class ScopeModel {
/// the streamed channels. `sample[c]` is channel `c`'s value for this slot; the math channels are
/// evaluated from those, in order, so a math channel may use another one.
void push_math_sample(const std::vector<f32>& sample);
/// Pushes one stored slot of a **file** load -- the values, the shared clock and the absolute
/// clock -- and then the derived channels, in the producer's own order and under the model's own
/// lock. The loader cannot simply hold the lock and push into `live()` itself: every setter it
/// needs (`set_signals`, `set_signal_enabled`, ...) takes that same non-recursive lock, so the
/// outer one deadlocks the first call.
void push_loaded_slot(const std::vector<f32>& values, f64 t, f64 abs_t);
/// What the derived channels cost the *producer*: every stored slot that went through
/// `push_math_sample()`, and the nanoseconds taken by the `math_timed()` of them that were
/// measured. The producer owns the budget between two DATA packets, so this cost is invisible in
@@ -343,6 +350,12 @@ class ScopeModel {
/// CONFIG, is not silently dropped.
void set_calibration(std::size_t signal, double gain, double offset);
/// The calibration that *will* be applied to that signal, as `{gain, offset}` -- the identity
/// `{1, 0}` for a signal nobody calibrated. The read counterpart of `set_calibration()`, and read
/// from the model's own record rather than from the channels: a signal that is not acquired has
/// no channel to read it from, and the record is what a dialog must show.
std::pair<double, double> calibration(std::size_t signal);
std::size_t channel_count() const noexcept { return channels_.size(); }
const Partition& partition() const noexcept { return part_; }
+21 -1
View File
@@ -70,6 +70,23 @@ constexpr plot::f32 kRowGap = 6.0f;
} // namespace
std::string layer_base_name(const std::string& name) {
const std::size_t a = name.rfind(" (");
if (a != std::string::npos && a + 3 < name.size() && name[a + 3] == ')' &&
name[a + 2] >= 'A' && name[a + 2] <= 'B') {
return name.substr(0, a);
}
const std::size_t h = name.rfind(" #");
if (h != std::string::npos && h + 2 < name.size()) {
bool digits = true;
for (std::size_t i = h + 2; i < name.size(); ++i) {
if (name[i] < '0' || name[i] > '9') digits = false;
}
if (digits) return name.substr(0, h);
}
return name;
}
plot::Color RowSet::palette_color(int index) {
const std::size_t i = static_cast<std::size_t>(index < 0 ? 0 : index) % kPaletteSize;
return plot::Color::hex(kPalette[i]);
@@ -78,7 +95,10 @@ plot::Color RowSet::palette_color(int index) {
plot::Color RowSet::unplotted_color() { return plot::Color::hex(kUnplottedColor); }
plot::Color RowSet::signal_color(const std::string& signal, int palette_index) const {
const auto it = signal_colors_.find(signal);
// The exact name first, then the signal's: a colour the user picked for `sine` is the colour of
// every layer of it, so a comparison's `sine (B)` is not a signal of its own colour.
auto it = signal_colors_.find(signal);
if (it == signal_colors_.end()) it = signal_colors_.find(layer_base_name(signal));
if (it != signal_colors_.end()) return plot::Color::hex(it->second);
return palette_color(palette_index);
}
+20 -1
View File
@@ -41,6 +41,12 @@ namespace udpscope {
/// count is fixed at construction, so there is one path for "a row's traces changed"
/// and "the row count changed", and it costs no GL state -- the area keeps its
/// context and is simply handed the new figure.
/// The signal a channel name belongs to, with a group's layer suffix taken back off: `sine (A)`,
/// `sine (B)` and `sine #2` are all the signal `sine`. A comparison's model holds one channel per
/// (signal, layer), so this is what lets the signal list show each signal once, draw every layer of it
/// in the signal's own colour, and stop a second group from re-suffixing a name a first one left.
std::string layer_base_name(const std::string& name);
class RowSet {
public:
/// The ceiling on rows, not the count: rows are added and removed one at a time
@@ -49,7 +55,12 @@ class RowSet {
/// allocated somewhere, and because the figure keeps a floor height per row -- past
/// about this many a row is chrome and no plot.
static constexpr int kMaxRows = 8;
static constexpr int kSlotsPerRow = 4;
/// Traces one row can hold. Eight rather than the four a hand-arranged scope usually wants, because
/// the *default* layout is four signals in one row and an accumulation needs a slot per extra event:
/// with four, "accumulate these two events" over the layout the app starts with had nowhere to put
/// the second copy and silently did nothing. The free slots cost nothing -- a `Plot` creates its
/// traces up front either way -- and the user's own traces stay where they are.
static constexpr int kSlotsPerRow = 8;
/// Readout marks per row per orientation: enough for a handful of level and time
/// measurements to be shown at once, and a fixed number because a `Plot` fixes its cursor
/// set at construction.
@@ -135,6 +146,13 @@ class RowSet {
};
int row_count() const noexcept { return rows_; }
/// The alpha every trace is drawn with. The *group* views use it: accumulating events wants their
/// traces translucent so that agreement between them reads as brightness, which is the whole point
/// of a persistence display. It is mixed into the palette colour where the trace is created, so it
/// belongs with `assign()` -- set it before the assignments that follow it, or they keep the alpha
/// the previous view left behind.
void set_trace_alpha(float alpha) noexcept { trace_alpha_ = alpha; }
/// Shows `rows` rows. A changed count rebuilds the figure, since a `Figure`'s row
/// count is fixed at construction.
void set_row_count(int rows);
@@ -478,6 +496,7 @@ class RowSet {
/// The one x link group every visible row belongs to. Groups are numbered from 1.
plot::u32 x_group_ = 0;
int rows_ = 1;
float trace_alpha_ = 1.0f;
double window_s_ = 2.0;
double trigger_marker_x_ = 0.0;
bool trigger_marker_on_ = false;
+424 -18
View File
@@ -3,11 +3,15 @@
#include "source_dialog.hpp"
#include "waveform.hpp"
#include "layout_store.hpp"
#include <adwaita.h>
#include <gtk/gtk.h>
#include <unistd.h>
#include <cstdio>
#include <cstdlib>
#include <string>
@@ -24,13 +28,22 @@ void apply_source(Config& cfg, const KnownSource& src) {
cfg.port = src.port;
cfg.multicast = src.multicast;
cfg.interface_addr = src.interface_addr;
// The configuration's own startup settings. They are read *before* the model exists, which is the
// only moment a buffer size can be applied at all -- and the reason they are stored per
// configuration rather than only passed on the command line.
if (src.buffer_mb > 0.0) cfg.buffer_mb = src.buffer_mb;
cfg.relative_time = src.relative_time;
cfg.capture_packets = src.capture_packets;
}
/// One line per known source on stderr, in the style of the probes in `ui.cpp`.
void report_sources(const std::vector<KnownSource>& known) {
std::fprintf(stderr, "[sources] %zu known:", known.size());
for (const KnownSource& s : known) {
std::fprintf(stderr, " %s:%u plots=%d signals=%d", s.host.c_str(), s.port, s.rows, s.signals);
std::fprintf(stderr, " %s:%u/%s plots=%d signals=%d buffer=%.6g relative=%d capture=%u",
s.host.c_str(), s.port, s.config.c_str(), s.rows, s.signals, s.buffer_mb,
s.relative_time ? 1 : 0, s.capture_packets);
if (!s.comment.empty()) std::fprintf(stderr, " note='%s'", s.comment.c_str());
if (!s.multicast.empty()) std::fprintf(stderr, " multicast=%s", s.multicast.c_str());
if (!s.interface_addr.empty()) {
std::fprintf(stderr, " interface=%s", s.interface_addr.c_str());
@@ -51,6 +64,12 @@ struct Chooser {
GtkWidget* window = nullptr;
GtkWidget* list = nullptr;
std::vector<KnownSource> known;
/// The Waveforms page: the exports opened before, newest first, and the notebook that holds the
/// two pages. A waveform is the *other* answer to "which source", so it gets a page of its own
/// rather than a button that hides a file dialog behind one more click.
GtkWidget* notebook = nullptr;
GtkWidget* wave_list = nullptr;
std::vector<std::string> waves;
/// The connection form, created on demand. Members of the chooser rather than a heap struct:
/// the chooser outlives the form by construction, so there is nothing to free on a close path
/// -- and a dialog whose context is freed by a handler is how this project earned a use-after
@@ -61,10 +80,26 @@ struct Chooser {
GtkWidget* form_group = nullptr;
GtkWidget* form_iface = nullptr;
GtkWidget* form_connect = nullptr;
/// The configuration half of the form: what a source's *session* is called and the settings that
/// can only be applied before the model exists. A new host needs all of them, and so does an
/// existing one -- which is why the same form is used for both.
GtkWidget* form_name = nullptr;
GtkWidget* form_note = nullptr;
GtkWidget* form_buffer = nullptr;
GtkWidget* form_relative = nullptr;
/// The configuration the form is editing: empty for a new host. Copied rather than referenced,
/// because `chooser_populate()` rebuilds `known` under the form.
std::string edit_host;
std::uint16_t edit_port = 0;
std::string edit_config;
/// `UDPSCOPE_PROBE_PICK_MS`'s timeout, kept so a pick made before it fires can cancel it. The
/// probe clears it when it runs: `g_source_remove()` on an id whose source already returned
/// `G_SOURCE_REMOVE` is a GLib critical, not a no-op.
guint pick_source = 0;
/// What the *form* last wrote, for `report_pick()`: a save finishes the chooser and destroys the
/// list with it, so the row a report would name is gone by the time the probe reads it.
std::string saved_config;
std::string saved_note;
};
void chooser_finish(Chooser& c, bool chosen) {
@@ -88,15 +123,125 @@ void chooser_finish(Chooser& c, bool chosen) {
}
void chooser_populate(Chooser& c);
void chooser_populate_waves(Chooser& c);
void report_pick(const Chooser& c);
/// Defined below; the row buttons reach it, and the row builder sits above it.
void open_form(Chooser& c, int edit_index);
void on_row_activated(GtkListBox*, GtkListBoxRow* row, gpointer user) {
Chooser& c = *static_cast<Chooser*>(user);
const int idx = GPOINTER_TO_INT(g_object_get_data(G_OBJECT(row), "source-index"));
if (idx < 0 || static_cast<std::size_t>(idx) >= c.known.size()) return;
apply_source(*c.cfg, c.known[static_cast<std::size_t>(idx)]);
const KnownSource& src = c.known[static_cast<std::size_t>(idx)];
// Picking a configuration *is* choosing it as the active one: the app reads that back when it
// loads the session, and the next save goes to the same file. Without this, opening `bench`
// from the chooser would leave the source pointing at whatever was active before.
set_active_configuration(src.host, src.port, src.config);
apply_source(*c.cfg, src);
chooser_finish(c, true);
}
/// One row per waveform opened before, newest first. The name first (that is what a person reads),
/// the directory under it, and a missing file said out loud rather than left to fail on the click.
void chooser_populate_waves(Chooser& c) {
while (GtkWidget* child = gtk_widget_get_first_child(c.wave_list)) {
gtk_list_box_remove(GTK_LIST_BOX(c.wave_list), child);
}
c.waves = recent_waveforms();
if (c.waves.empty()) {
GtkWidget* empty = gtk_label_new("No waveform opened yet -- use Open\xe2\x80\xa6");
gtk_widget_add_css_class(empty, "dim-label");
gtk_widget_set_margin_top(empty, 24);
gtk_widget_set_margin_bottom(empty, 24);
gtk_list_box_append(GTK_LIST_BOX(c.wave_list), empty);
gtk_list_box_set_selection_mode(GTK_LIST_BOX(c.wave_list), GTK_SELECTION_NONE);
return;
}
gtk_list_box_set_selection_mode(GTK_LIST_BOX(c.wave_list), GTK_SELECTION_SINGLE);
for (std::size_t i = 0; i < c.waves.size(); ++i) {
const std::string& path = c.waves[i];
GtkWidget* row = gtk_list_box_row_new();
g_object_set_data(G_OBJECT(row), "wave-index", GINT_TO_POINTER(static_cast<int>(i)));
GtkWidget* line = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 12);
gtk_widget_set_margin_start(line, 12);
gtk_widget_set_margin_end(line, 6);
gtk_widget_set_margin_top(line, 6);
gtk_widget_set_margin_bottom(line, 6);
GtkWidget* text = gtk_box_new(GTK_ORIENTATION_VERTICAL, 2);
gtk_widget_set_hexpand(text, TRUE);
const std::size_t slash = path.rfind('/');
const std::string base = slash == std::string::npos ? path : path.substr(slash + 1);
GtkWidget* title = gtk_label_new(base.c_str());
gtk_widget_set_halign(title, GTK_ALIGN_START);
gtk_box_append(GTK_BOX(text), title);
const bool present = ::access(path.c_str(), R_OK) == 0;
std::string sub = present ? path : path + " (missing)";
GtkWidget* under = gtk_label_new(sub.c_str());
gtk_widget_add_css_class(under, "dim-label");
gtk_widget_set_halign(under, GTK_ALIGN_START);
gtk_label_set_ellipsize(GTK_LABEL(under), PANGO_ELLIPSIZE_MIDDLE);
gtk_box_append(GTK_BOX(text), under);
gtk_box_append(GTK_BOX(line), text);
gtk_list_box_row_set_child(GTK_LIST_BOX_ROW(row), line);
gtk_list_box_append(GTK_LIST_BOX(c.wave_list), row);
}
}
void on_wave_activated(GtkListBox*, GtkListBoxRow* row, gpointer user) {
Chooser& c = *static_cast<Chooser*>(user);
if (row == nullptr) return;
const int idx = GPOINTER_TO_INT(g_object_get_data(G_OBJECT(row), "wave-index"));
if (idx < 0 || static_cast<std::size_t>(idx) >= c.waves.size()) return;
const std::string& path = c.waves[static_cast<std::size_t>(idx)];
if (::access(path.c_str(), R_OK) != 0) {
std::fprintf(stderr, "[sources] waveform is gone: %s\n", path.c_str());
std::fflush(stderr);
return;
}
// The file *is* the source: the chooser's answer is a path, not a socket.
c.cfg->open_waveform = path;
report_pick(c);
chooser_finish(c, true);
}
void on_wave_open_done(GObject* source, GAsyncResult* result, gpointer user) {
Chooser& c = *static_cast<Chooser*>(user);
GFile* file = gtk_file_dialog_open_finish(GTK_FILE_DIALOG(source), result, nullptr);
if (file == nullptr) return; // cancelled
char* path = g_file_get_path(file);
g_object_unref(file);
if (path != nullptr) {
c.cfg->open_waveform = path;
g_free(path);
report_pick(c);
chooser_finish(c, true);
}
}
void on_wave_open_clicked(GtkButton*, gpointer user) {
Chooser& c = *static_cast<Chooser*>(user);
GtkFileDialog* dialog = gtk_file_dialog_new();
gtk_file_dialog_set_title(dialog, "Open an exported waveform");
GtkFileFilter* filter = gtk_file_filter_new();
gtk_file_filter_set_name(filter, "NumPy export (*.npy)");
gtk_file_filter_add_pattern(filter, "*.npy");
GListStore* filters = g_list_store_new(GTK_TYPE_FILE_FILTER);
g_list_store_append(filters, filter);
gtk_file_dialog_set_filters(dialog, G_LIST_MODEL(filters));
gtk_file_dialog_open(dialog, GTK_WINDOW(c.window), nullptr, on_wave_open_done, &c);
g_object_unref(filters);
g_object_unref(filter);
g_object_unref(dialog);
}
void on_edit_clicked(GtkButton* button, gpointer user) {
Chooser& c = *static_cast<Chooser*>(user);
const int idx = GPOINTER_TO_INT(g_object_get_data(G_OBJECT(button), "source-index"));
if (idx < 0 || static_cast<std::size_t>(idx) >= c.known.size()) return;
open_form(c, idx);
}
void on_forget_clicked(GtkButton* button, gpointer user) {
Chooser& c = *static_cast<Chooser*>(user);
const int idx = GPOINTER_TO_INT(g_object_get_data(G_OBJECT(button), "source-index"));
@@ -145,13 +290,29 @@ void chooser_populate(Chooser& c) {
// The *server*, even for a multicast session: that is the host the file is named after and
// the one CONFIG is fetched from. Titling the row by the group would name it after something
// that is not its identity.
const std::string title = s.host + ":" + std::to_string(s.port);
const std::string title = s.host + ":" + std::to_string(s.port) + " \u00b7 " + s.config;
GtkWidget* title_label = gtk_label_new(title.c_str());
gtk_widget_set_halign(title_label, GTK_ALIGN_START);
gtk_box_append(GTK_BOX(text), title_label);
// The note first when there is one: it is what tells two configurations of one source apart,
// and a list that shows only counts makes the user open them to find out.
if (!s.comment.empty()) {
GtkWidget* note = gtk_label_new(s.comment.c_str());
gtk_widget_set_halign(note, GTK_ALIGN_START);
gtk_label_set_ellipsize(GTK_LABEL(note), PANGO_ELLIPSIZE_END);
gtk_box_append(GTK_BOX(text), note);
}
std::string sub = std::to_string(s.rows) + (s.rows == 1 ? " plot, " : " plots, ") +
std::to_string(s.signals) + (s.signals == 1 ? " signal" : " signals");
if (s.buffer_mb > 0.0) {
sub += ", buffer " + std::to_string(static_cast<int>(s.buffer_mb)) + " MB";
}
if (s.relative_time) sub += ", relative time";
if (s.capture_packets > 0) {
sub += ", capture " + std::to_string(s.capture_packets) + " packets";
}
if (!s.multicast.empty()) sub += ", multicast " + s.multicast;
GtkWidget* sub_label = gtk_label_new(sub.c_str());
gtk_widget_add_css_class(sub_label, "dim-label");
@@ -159,8 +320,18 @@ void chooser_populate(Chooser& c) {
gtk_box_append(GTK_BOX(text), sub_label);
gtk_box_append(GTK_BOX(line), text);
GtkWidget* edit = gtk_button_new_from_icon_name("document-edit-symbolic");
gtk_widget_set_tooltip_text(edit,
"Edit this configuration: its name and note, the buffer size and "
"the clock, or the address it points at");
gtk_widget_add_css_class(edit, "flat");
gtk_widget_set_valign(edit, GTK_ALIGN_CENTER);
g_object_set_data(G_OBJECT(edit), "source-index", GINT_TO_POINTER(static_cast<int>(i)));
g_signal_connect(edit, "clicked", G_CALLBACK(on_edit_clicked), &c);
gtk_box_append(GTK_BOX(line), edit);
GtkWidget* forget = gtk_button_new_from_icon_name("window-close-symbolic");
gtk_widget_set_tooltip_text(forget, "Forget this source (deletes its saved session)");
gtk_widget_set_tooltip_text(forget, "Forget this configuration (deletes its saved session)");
gtk_widget_add_css_class(forget, "flat");
gtk_widget_set_valign(forget, GTK_ALIGN_CENTER);
g_object_set_data(G_OBJECT(forget), "source-index", GINT_TO_POINTER(static_cast<int>(i)));
@@ -193,11 +364,58 @@ void on_form_connect(GtkButton*, gpointer user) {
Chooser& c = *static_cast<Chooser*>(user);
const char* server = gtk_editable_get_text(GTK_EDITABLE(c.form_server));
if (server == nullptr || server[0] == '\0') return;
c.cfg->server = server;
c.cfg->port = static_cast<std::uint16_t>(
const std::string host = server;
const std::uint16_t port = static_cast<std::uint16_t>(
gtk_spin_button_get_value_as_int(GTK_SPIN_BUTTON(c.form_port)));
c.cfg->multicast = gtk_editable_get_text(GTK_EDITABLE(c.form_group));
c.cfg->interface_addr = gtk_editable_get_text(GTK_EDITABLE(c.form_iface));
const std::string group = gtk_editable_get_text(GTK_EDITABLE(c.form_group));
const std::string iface = gtk_editable_get_text(GTK_EDITABLE(c.form_iface));
std::string name = gtk_editable_get_text(GTK_EDITABLE(c.form_name));
const std::string note = gtk_editable_get_text(GTK_EDITABLE(c.form_note));
const double buffer_mb = gtk_spin_button_get_value(GTK_SPIN_BUTTON(c.form_buffer));
const bool relative = gtk_switch_get_active(GTK_SWITCH(c.form_relative));
if (name.empty()) name = "default";
c.cfg->server = host;
c.cfg->port = port;
c.cfg->multicast = group;
c.cfg->interface_addr = iface;
// The startup settings. Applied here because this runs *before* `ScopeModel` exists, which is the
// only moment a buffer size can be applied at all -- and stored, so the next run gets them back.
c.cfg->buffer_mb = buffer_mb;
c.cfg->relative_time = relative;
LayoutState st;
if (!c.edit_config.empty()) {
// An edit keeps everything the configuration already holds -- plots, calibration, math,
// measurements -- and changes only what the form shows.
load_layout(configuration_path(c.edit_host, c.edit_port, c.edit_config), st);
}
st.comment = note;
st.buffer_mb = buffer_mb;
st.relative_time = relative;
st.multicast = group;
st.interface_addr = iface;
const bool editing = !c.edit_config.empty();
const bool moved =
editing && (c.edit_host != host || c.edit_port != port || c.edit_config != name);
if (save_layout(configuration_path(host, port, name), st)) {
set_active_configuration(host, port, name);
// A renamed configuration, or one re-addressed, is a *move*: the old file has no reason to exist
// and leaving it would list the same session twice.
if (moved) forget_configuration(c.edit_host, c.edit_port, c.edit_config);
c.saved_config = name;
c.saved_note = note;
std::fprintf(stderr,
"[sources] configuration %s: %s:%u/%s note='%s' buffer=%.6g relative=%d\n",
editing ? "saved" : "created", host.c_str(), port, name.c_str(), note.c_str(),
buffer_mb, relative ? 1 : 0);
std::fflush(stderr);
} else {
std::fprintf(stderr, "[sources] configuration write failed: %s:%u/%s\n", host.c_str(), port,
name.c_str());
std::fflush(stderr);
}
form_close(c);
chooser_finish(c, true);
}
@@ -212,13 +430,24 @@ GtkWidget* form_field(GtkWidget* grid, int row, const char* label, GtkWidget* wi
return widget;
}
void open_form(Chooser& c) {
/// Opens the connection form: on a new host, or on an existing configuration (`edit_index`). One
/// form for both, because the fields are the same and a configuration *is* a host plus its
/// settings -- the only difference is where the values start and what the button is called.
void open_form(Chooser& c, int edit_index = -1) {
if (c.form != nullptr) {
gtk_window_present(GTK_WINDOW(c.form));
return;
}
const KnownSource* src =
edit_index >= 0 && static_cast<std::size_t>(edit_index) < c.known.size()
? &c.known[static_cast<std::size_t>(edit_index)]
: nullptr;
c.edit_host = src != nullptr ? src->host : std::string();
c.edit_port = src != nullptr ? src->port : 0;
c.edit_config = src != nullptr ? src->config : std::string();
c.form = gtk_window_new();
gtk_window_set_title(GTK_WINDOW(c.form), "New host");
gtk_window_set_title(GTK_WINDOW(c.form), src != nullptr ? "Edit configuration" : "New host");
gtk_window_set_transient_for(GTK_WINDOW(c.form), GTK_WINDOW(c.window));
gtk_window_set_modal(GTK_WINDOW(c.form), TRUE);
@@ -235,15 +464,52 @@ void open_form(Chooser& c) {
gtk_box_append(GTK_BOX(box), grid);
c.form_server = form_field(grid, 0, "Server", gtk_entry_new());
gtk_editable_set_text(GTK_EDITABLE(c.form_server), "127.0.0.1");
gtk_editable_set_text(GTK_EDITABLE(c.form_server),
src != nullptr ? src->host.c_str() : "127.0.0.1");
// A spin button holds no decimals by default, which is right for a port and wrong for every
// other numeric field in this project -- see the math dialog's fields.
c.form_port = form_field(grid, 1, "Port", gtk_spin_button_new_with_range(1, 65535, 1));
gtk_spin_button_set_value(GTK_SPIN_BUTTON(c.form_port), 8000);
gtk_spin_button_set_value(GTK_SPIN_BUTTON(c.form_port), src != nullptr ? src->port : 8000);
c.form_group = form_field(grid, 2, "Multicast group", gtk_entry_new());
gtk_entry_set_placeholder_text(GTK_ENTRY(c.form_group), "empty = unicast");
gtk_editable_set_text(GTK_EDITABLE(c.form_group),
src != nullptr ? src->multicast.c_str() : "");
c.form_iface = form_field(grid, 3, "Interface", gtk_entry_new());
gtk_entry_set_placeholder_text(GTK_ENTRY(c.form_iface), "empty = default route");
gtk_editable_set_text(GTK_EDITABLE(c.form_iface),
src != nullptr ? src->interface_addr.c_str() : "");
// The session half: what this source's configuration is called, and the settings that have to be
// decided before the first socket is opened.
GtkWidget* heading = gtk_label_new(nullptr);
gtk_label_set_markup(GTK_LABEL(heading), "<b>Configuration</b>");
gtk_widget_set_halign(heading, GTK_ALIGN_START);
gtk_widget_set_margin_top(heading, 8);
gtk_grid_attach(GTK_GRID(grid), heading, 0, 4, 2, 1);
c.form_name = form_field(grid, 5, "Name", gtk_entry_new());
gtk_entry_set_placeholder_text(GTK_ENTRY(c.form_name), "default");
gtk_editable_set_text(GTK_EDITABLE(c.form_name),
src != nullptr ? src->config.c_str() : "default");
c.form_note = form_field(grid, 6, "Note", gtk_entry_new());
gtk_entry_set_placeholder_text(GTK_ENTRY(c.form_note), "what this configuration is for");
gtk_editable_set_text(GTK_EDITABLE(c.form_note),
src != nullptr ? src->comment.c_str() : "");
c.form_buffer = form_field(grid, 7, "Buffer MB", gtk_spin_button_new_with_range(1, 65536, 1));
gtk_widget_set_tooltip_text(c.form_buffer,
"sample buffer per source, applied when this source is opened");
gtk_spin_button_set_value(GTK_SPIN_BUTTON(c.form_buffer),
src != nullptr && src->buffer_mb > 0.0
? src->buffer_mb
: std::max(1.0, c.cfg->buffer_mb));
GtkWidget* relative = gtk_switch_new();
gtk_widget_set_halign(relative, GTK_ALIGN_START);
gtk_widget_set_tooltip_text(relative,
"plot time from the packet counter and the declared rate, instead "
"of the stream's own time signal (--relative-time)");
gtk_switch_set_active(GTK_SWITCH(relative),
src != nullptr ? src->relative_time : c.cfg->relative_time);
c.form_relative = form_field(grid, 8, "Relative time", relative);
GtkWidget* actions = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
gtk_widget_set_halign(actions, GTK_ALIGN_END);
@@ -252,7 +518,7 @@ void open_form(Chooser& c) {
form_close(*static_cast<Chooser*>(user));
}),
&c);
c.form_connect = gtk_button_new_with_label("Connect");
c.form_connect = gtk_button_new_with_label(src != nullptr ? "Save" : "Connect");
g_signal_connect(c.form_connect, "clicked", G_CALLBACK(on_form_connect), &c);
gtk_box_append(GTK_BOX(actions), cancel);
gtk_box_append(GTK_BOX(actions), c.form_connect);
@@ -274,8 +540,26 @@ void open_form(Chooser& c) {
/// What the pick probes report: the connection the chooser came back with. One helper rather than
/// the same four lines in each branch, since both probes assert against this exact line.
void report_pick(const Chooser& c) {
std::fprintf(stderr, "[sources] picked %s:%u multicast=%s interface=%s\n", c.cfg->server.c_str(),
c.cfg->port, c.cfg->multicast.c_str(), c.cfg->interface_addr.c_str());
if (!c.cfg->open_waveform.empty()) {
std::fprintf(stderr, "[sources] picked waveform %s\n", c.cfg->open_waveform.c_str());
std::fflush(stderr);
return;
}
// The settings the configuration carried, not just the address: a pick that dropped the buffer
// size would look identical here otherwise, and the buffer is the one of them a user notices
// only after a long capture has been sized wrong.
std::fprintf(stderr,
"[sources] picked %s:%u multicast=%s interface=%s buffer=%.6g relative=%d "
"capture=%u config=%s note='%s'\n",
c.cfg->server.c_str(), c.cfg->port, c.cfg->multicast.c_str(),
c.cfg->interface_addr.c_str(), c.cfg->buffer_mb, c.cfg->relative_time ? 1 : 0,
c.cfg->capture_packets,
(c.saved_config.empty() ? (c.known.empty() ? "-" : c.known.front().config)
: c.saved_config)
.c_str(),
(c.saved_config.empty() ? (c.known.empty() ? "-" : c.known.front().comment)
: c.saved_note)
.c_str());
std::fflush(stderr);
}
@@ -304,7 +588,28 @@ gboolean pick_probe(gpointer user) {
// -probe call would otherwise be a GLib critical on every run that let the probe fire.
c.pick_source = 0;
// `UDPSCOPE_PROBE_NEWHOST=host:port` drives the form instead of a row.
// `UDPSCOPE_PROBE_WAVEFORMS=1` is the waveforms page without a display: the recent list, and the
// page selected the way a click on its tab does. `UDPSCOPE_PROBE_WAVE=<path>` is the file dialog's
// answer without the dialog -- the same field, set the same way.
if (g_getenv("UDPSCOPE_PROBE_WAVEFORMS") != nullptr) {
if (c.notebook != nullptr) gtk_notebook_set_current_page(GTK_NOTEBOOK(c.notebook), 1);
std::fprintf(stderr, "[sources] waveforms: %zu recent", c.waves.size());
for (const std::string& p : c.waves) {
std::fprintf(stderr, " %s(%s)", p.c_str(), ::access(p.c_str(), R_OK) == 0 ? "ok" : "missing");
}
std::fprintf(stderr, " page=%d\n",
static_cast<int>(gtk_notebook_get_current_page(GTK_NOTEBOOK(c.notebook))));
std::fflush(stderr);
}
if (const char* path = g_getenv("UDPSCOPE_PROBE_WAVE")) {
c.cfg->open_waveform = path;
report_pick(c);
chooser_finish(c, true);
return G_SOURCE_REMOVE;
}
// `UDPSCOPE_PROBE_NEWHOST=host:port` drives the form instead of a row: the address, and the four
// things a host could not be given before -- a name, a note, a buffer size and a clock.
if (const char* spec = g_getenv("UDPSCOPE_PROBE_NEWHOST")) {
open_form(c);
const std::string s = spec;
@@ -314,13 +619,63 @@ gboolean pick_probe(gpointer user) {
if (colon != std::string::npos) {
gtk_spin_button_set_value(GTK_SPIN_BUTTON(c.form_port), std::atof(s.c_str() + colon + 1));
}
std::fprintf(stderr, "[sources] form open, connect_sensitive=%d\n",
gtk_editable_set_text(GTK_EDITABLE(c.form_name), "probe-host");
gtk_editable_set_text(GTK_EDITABLE(c.form_note), "made by the new-host probe");
gtk_spin_button_set_value(GTK_SPIN_BUTTON(c.form_buffer), 32);
gtk_switch_set_active(GTK_SWITCH(c.form_relative), TRUE);
std::fprintf(stderr,
"[sources] form: name='%s' note='%s' buffer=%.6g relative=%d "
"connect_sensitive=%d\n",
gtk_editable_get_text(GTK_EDITABLE(c.form_name)),
gtk_editable_get_text(GTK_EDITABLE(c.form_note)),
gtk_spin_button_get_value(GTK_SPIN_BUTTON(c.form_buffer)),
gtk_switch_get_active(GTK_SWITCH(c.form_relative)) ? 1 : 0,
gtk_widget_get_sensitive(c.form_connect) ? 1 : 0);
g_signal_emit_by_name(c.form_connect, "clicked");
report_pick(c);
return G_SOURCE_REMOVE;
}
// `UDPSCOPE_PROBE_EDITHOST=host:port` opens the existing configuration's form, changes its
// settings and saves: the path a user reaches by clicking the row's pencil.
if (const char* spec = g_getenv("UDPSCOPE_PROBE_EDITHOST")) {
const std::string want = spec;
int idx = -1;
for (std::size_t i = 0; i < c.known.size(); ++i) {
if (c.known[i].host + ":" + std::to_string(c.known[i].port) == want) {
idx = static_cast<int>(i);
break;
}
}
if (idx < 0) {
std::fprintf(stderr, "[sources] edit: no configuration for '%s'\n", want.c_str());
std::fflush(stderr);
chooser_finish(c, false);
return G_SOURCE_REMOVE;
}
open_form(c, idx);
// The reading is the *pre-fill*: an edit form that opens empty is the defect this asserts
// against, and the button's label says which mode it is in.
std::fprintf(stderr,
"[sources] edit form: name='%s' note='%s' buffer=%.6g relative=%d button='%s'\n",
gtk_editable_get_text(GTK_EDITABLE(c.form_name)),
gtk_editable_get_text(GTK_EDITABLE(c.form_note)),
gtk_spin_button_get_value(GTK_SPIN_BUTTON(c.form_buffer)),
gtk_switch_get_active(GTK_SWITCH(c.form_relative)) ? 1 : 0,
gtk_button_get_label(GTK_BUTTON(c.form_connect)));
gtk_editable_set_text(GTK_EDITABLE(c.form_note), "edited by the probe");
gtk_spin_button_set_value(GTK_SPIN_BUTTON(c.form_buffer), 16);
gtk_switch_set_active(GTK_SWITCH(c.form_relative), FALSE);
// `UDPSCOPE_PROBE_EDITNAME=new-name` also renames it, which is a *move*: the old file must not
// survive, or the chooser lists the same session twice.
if (const char* rename_to = g_getenv("UDPSCOPE_PROBE_EDITNAME")) {
gtk_editable_set_text(GTK_EDITABLE(c.form_name), rename_to);
}
g_signal_emit_by_name(c.form_connect, "clicked");
report_pick(c);
return G_SOURCE_REMOVE;
}
// Check the source list rather than the row count: `gtk_list_box_append` auto-wraps any
// non-row widget in a `GtkListBoxRow`, so `gtk_list_box_get_row_at_index` returns non-null
// even when the only list child is the empty-state placeholder label.
@@ -341,6 +696,27 @@ gboolean pick_probe(gpointer user) {
} // namespace
void apply_stored_settings(Config& cfg) {
const std::string want = active_configuration(cfg.server, cfg.port);
if (want.empty()) return;
LayoutState st;
if (!load_layout(configuration_path(cfg.server, cfg.port, want), st)) return;
// Only where the command line was silent: a flag the user typed wins, because it is the more
// recent statement of intent -- and the file is not a place to hide a surprise from the ones who
// did type it.
if (!cfg.buffer_mb_specified && st.buffer_mb > 0.0) cfg.buffer_mb = st.buffer_mb;
if (st.relative_time) cfg.relative_time = true;
if (cfg.capture_packets == 0 && st.capture_packets > 0) cfg.capture_packets = st.capture_packets;
const bool applied = (!cfg.buffer_mb_specified && st.buffer_mb > 0.0) || st.relative_time ||
st.capture_packets > 0;
if (applied) {
std::fprintf(stderr,
"[sources] settings from configuration '%s': buffer=%.6g relative=%d capture=%u\n",
want.c_str(), cfg.buffer_mb, cfg.relative_time ? 1 : 0, cfg.capture_packets);
std::fflush(stderr);
}
}
bool choose_source(Config& cfg) {
const std::vector<KnownSource> known = known_sources();
@@ -366,6 +742,13 @@ bool choose_source(Config& cfg) {
GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
gtk_window_set_child(GTK_WINDOW(c.window), box);
// Two pages under one window: the sources it can connect to, and the waveforms it can open. A
// waveform is not a source with a file in place of an address -- there is no socket and nothing
// arrives -- so it is a *page* rather than a field, which is also where its history belongs.
c.notebook = gtk_notebook_new();
gtk_widget_set_vexpand(c.notebook, TRUE);
gtk_box_append(GTK_BOX(box), c.notebook);
GtkWidget* scroller = gtk_scrolled_window_new();
gtk_widget_set_vexpand(scroller, TRUE);
gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(scroller), GTK_POLICY_NEVER,
@@ -374,7 +757,30 @@ bool choose_source(Config& cfg) {
gtk_list_box_set_activate_on_single_click(GTK_LIST_BOX(c.list), TRUE);
g_signal_connect(c.list, "row-activated", G_CALLBACK(on_row_activated), &c);
gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(scroller), c.list);
gtk_box_append(GTK_BOX(box), scroller);
gtk_notebook_append_page(GTK_NOTEBOOK(c.notebook), scroller, gtk_label_new("Sources"));
GtkWidget* wave_page = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
GtkWidget* wave_scroller = gtk_scrolled_window_new();
gtk_widget_set_vexpand(wave_scroller, TRUE);
gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(wave_scroller), GTK_POLICY_NEVER,
GTK_POLICY_AUTOMATIC);
c.wave_list = gtk_list_box_new();
gtk_list_box_set_activate_on_single_click(GTK_LIST_BOX(c.wave_list), TRUE);
g_signal_connect(c.wave_list, "row-activated", G_CALLBACK(on_wave_activated), &c);
gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(wave_scroller), c.wave_list);
gtk_box_append(GTK_BOX(wave_page), wave_scroller);
GtkWidget* wave_actions = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
gtk_widget_set_halign(wave_actions, GTK_ALIGN_END);
gtk_widget_set_margin_top(wave_actions, 6);
gtk_widget_set_margin_bottom(wave_actions, 6);
gtk_widget_set_margin_end(wave_actions, 12);
GtkWidget* wave_open = gtk_button_new_with_label("Open\xe2\x80\xa6");
gtk_widget_set_tooltip_text(wave_open, "choose an exported .npy to view and analyse");
g_signal_connect(wave_open, "clicked", G_CALLBACK(on_wave_open_clicked), &c);
gtk_box_append(GTK_BOX(wave_actions), wave_open);
gtk_box_append(GTK_BOX(wave_page), wave_actions);
gtk_notebook_append_page(GTK_NOTEBOOK(c.notebook), wave_page, gtk_label_new("Waveforms"));
chooser_populate_waves(c);
GtkWidget* actions = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
gtk_widget_set_margin_start(actions, 12);
+10
View File
@@ -16,4 +16,14 @@ namespace udpscope {
/// same process, which works because `gtk_init()` is idempotent.
bool choose_source(Config& cfg);
/// Fills in the settings a *stored* configuration carries for `cfg`'s source -- the buffer size,
/// relative time and the packet capture's ring size -- leaving alone anything the command line
/// actually asked for.
///
/// The chooser applies these when it opens a source; a source named with `--server` never reaches
/// the chooser, so without this a saved host's buffer size and clock were silently ignored and the
/// edit that set them read as one that never took effect. The layout is *not* applied here: it
/// names signals, so it is loaded by the app once a CONFIG has described them.
void apply_stored_settings(Config& cfg);
} // namespace udpscope
+767 -6440
View File
File diff suppressed because it is too large. Load diff
+1302
View File
File diff suppressed because it is too large. Load diff
+148
View File
@@ -0,0 +1,148 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
//
// The cursors pane: what the two vertical markers and the per-row rulers read, in the four cursor\n// modes, rebuilt only when the rows or the mode change.
#include "ui_app.hpp"
#include <gtk/gtk.h>
#include <string>
namespace udpscope {
namespace ui_impl {
/// `%.6g s` is exact enough for a cursor readout and short enough for the panel.
void update_cursors_pane(App& app) {
if (app.cursors_body == nullptr || !app.rows->cursors_visible()) return;
if (app.cursors_pane_mode != static_cast<int>(app.rows->cursor_mode()) ||
app.cursors_pane_rows != app.rows->row_count() ||
app.cursors_pane_generation != app.model->generation() ||
app.cursors_pane_bound != bound_slot_mask(app)) {
rebuild_cursors_pane(app);
}
const double t0 = app.rows->cursor_a_x();
const double t1 = app.rows->cursor_b_x();
const auto put = [](GtkWidget* w, const std::string& text) {
if (w != nullptr) gtk_label_set_text(GTK_LABEL(w), text.c_str());
};
const RowSet::CursorMode mode = app.rows->cursor_mode();
const bool rulers = mode == RowSet::CursorMode::Horizontal || mode == RowSet::CursorMode::Both;
const bool crossings = mode == RowSet::CursorMode::Mix;
for (const App::CursorLine& line : app.cursors_lines) {
if (line.a == nullptr) continue;
// One measurement per cell, each with the unit its own axis declares: the pane is a
// readout beside a plot, where the values are the point.
std::string va;
std::string vb;
std::string vd;
if (line.slot == -2) {
va = format_measure(t0, "s");
vb = format_measure(t1, "s");
vd = format_measure(t1 - t0, "s");
} else if (line.slot == -1 && rulers) {
const std::string unit = row_unit(app, line.row);
const double y1 = app.rows->ruler_y(line.row);
const double y2 = app.rows->ruler_b_y(line.row);
va = format_measure(y1, unit);
vb = format_measure(y2, unit);
vd = format_measure(y2 - y1, unit);
} else if (crossings && line.slot >= 0) {
// Where each cursor crosses this trace: the value the trace has at that time,
// read the way a crosshair reads it.
// The same call the crossing markers use, on the same trace: the pane and the
// marks on the plot cannot disagree about what the value is.
const RowSet::Row& row = app.rows->row(line.row);
double y1 = 0.0;
double y2 = 0.0;
const bool a = row.plot != nullptr &&
row.plot->crosshair_value(static_cast<plot::u32>(line.slot), t0, y1);
const bool b = row.plot != nullptr &&
row.plot->crosshair_value(static_cast<plot::u32>(line.slot), t1, y2);
if (a && b) {
const std::string unit = slot_unit(app, line.row, line.slot);
va = format_measure(y1, unit);
vb = format_measure(y2, unit);
vd = format_measure(y2 - y1, unit);
} else {
va = vb = vd = a || b ? "partial" : "--";
}
} else {
va = vb = vd = "--";
}
put(line.a, va);
put(line.b, vb);
put(line.d, vd);
}
}
GtkWidget* build_cursors_panel(App& app) {
GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 4);
gtk_widget_set_margin_top(box, 6);
gtk_widget_set_margin_bottom(box, 6);
gtk_widget_set_margin_start(box, 6);
gtk_widget_set_margin_end(box, 6);
GtkWidget* head = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8);
GtkWidget* title = gtk_label_new("Cursors");
gtk_widget_set_halign(title, GTK_ALIGN_START);
gtk_widget_set_hexpand(title, true);
gtk_box_append(GTK_BOX(head), title);
const char* names[5] = {kCursorModeNames[0], kCursorModeNames[1], kCursorModeNames[2],
kCursorModeNames[3], nullptr};
app.cursors_mode_drop = gtk_drop_down_new_from_strings(names);
gtk_widget_set_tooltip_text(app.cursors_mode_drop,
"vertical: two times, shared by every row\n"
"horizontal: two rulers per row\n"
"both: the two together\n"
"mix: the two times, read off each trace where they cross");
g_signal_connect(app.cursors_mode_drop, "notify::selected",
G_CALLBACK(on_cursor_mode_changed), &app);
gtk_box_append(GTK_BOX(head), app.cursors_mode_drop);
// Reset lives here, beside the mode it resets, rather than in the overflow menu: it acts on this
// pane's instruments and is only visible while they are on, both of which are this pane's facts.
app.cursors_reset = gtk_button_new_from_icon_name("edit-clear-symbolic");
gtk_widget_add_css_class(app.cursors_reset, "flat");
gtk_widget_set_tooltip_text(app.cursors_reset, "re-place the cursors at the window's edges");
g_signal_connect(app.cursors_reset, "clicked", G_CALLBACK(on_cursors_reset), &app);
gtk_box_append(GTK_BOX(head), app.cursors_reset);
gtk_box_append(GTK_BOX(box), head);
app.cursors_body = gtk_grid_new();
gtk_grid_set_row_spacing(GTK_GRID(app.cursors_body), 2);
gtk_grid_set_column_spacing(GTK_GRID(app.cursors_body), 4);
gtk_box_append(GTK_BOX(box), app.cursors_body);
return box;
}
void on_cursors_toggled(GtkToggleButton* button, gpointer user) {
App* app = static_cast<App*>(user);
const bool on = gtk_toggle_button_get_active(button);
app->rows->set_cursors_visible(on);
// The reset button exists for the cursors: with them hidden there is nothing to
// reset, and a button that does nothing reads as broken.
gtk_widget_set_visible(app->cursors_reset, on);
// The pane is the cursors' readout: with them off there is nothing to read.
if (app->cursors_revealer != nullptr) {
gtk_revealer_set_reveal_child(GTK_REVEALER(app->cursors_revealer), on);
}
if (on) {
app->rows->reset_cursors();
rebuild_cursors_pane(*app);
update_cursors_pane(*app);
}
}
void on_cursors_reset(GtkButton*, gpointer user) {
App* app = static_cast<App*>(user);
// A reset the user cannot see reads as a dead button, so it also switches the
// cursors on.
if (!gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(app->cursors_toggle))) {
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(app->cursors_toggle), true);
}
app->rows->reset_cursors();
}
} // namespace ui_impl
} // namespace udpscope
+814
View File
@@ -0,0 +1,814 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
//
// The event cache, seen from the front end: the pane that lists it, the picture each row carries, the\n// replay, and the two group layouts (side-by-side comparison and accumulation) with the axis mirror\n// that keeps a comparison several plots in step.
#include "ui_app.hpp"
#include <gtk/gtk.h>
#include <string>
namespace udpscope {
namespace ui_impl {
/// The comparison's plot: the same figure machinery, its own `RowSet`, and deliberately *none* of the
/// primary area's gestures -- the drop target, the right-click menu and the frame counter all act on
/// the primary's rows, and a menu on the second plot that rearranged the first plot's rows would be a
/// menu lying about its subject. Hidden until a comparison is up: a hidden child of a paned takes no
/// room and leaves no handle.
GtkWidget* make_compare_area(App& app) {
app.rows_b = std::make_unique<RowSet>();
plot::gtk4::PlotArea* area = plot::gtk4::PlotArea::create(app.placeholder_plot_b);
app.area_b = area;
area->set_figure(&app.rows_b->figure());
GtkWidget* widget = area->widget();
app.area_b_widget = widget;
gtk_widget_set_hexpand(widget, true);
gtk_widget_set_vexpand(widget, true);
gtk_widget_set_size_request(widget, 320, 120);
gtk_widget_set_visible(widget, FALSE);
return widget;
}
/// Whether the event history is reachable at all, and the one place that decides it. It has exactly
/// two preconditions: trigger mode (there are no events without a trigger) and recording (a disabled
/// cache is empty, and an empty column beside the plot is what "off" must not look like). Both the
/// toolbar button and the pane follow from it, so they cannot disagree -- and turning recording off
/// while the pane is open takes the button and the pane with it rather than leaving a stale list.
void sync_events_availability(App& app) {
if (app.events_toggle == nullptr) return;
// What the divider was last dragged to. Read here, on every change of state, because the close that
// follows is about to set the position to 0 and lose it.
if (app.events_divider != nullptr) {
const int at = gtk_paned_get_position(GTK_PANED(app.events_divider));
if (at > 1) app.events_pane_width = at;
}
// Available while the cache can still grow (recording on) *or* while it is being read (an event on
// screen). Not trigger mode: a stored event being examined is a file, and it has no trigger -- if the
// pane's life depended on the trigger, opening one would close the list it was opened from.
const bool available = app.event_recording || app.replaying;
gtk_widget_set_visible(app.events_toggle, available);
if (!available) {
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(app.events_toggle), FALSE);
gtk_revealer_set_reveal_child(GTK_REVEALER(app.events_revealer), FALSE);
}
const bool open =
available && gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(app.events_toggle));
// Visibility, not only `reveal_child`: an unrevealed `GtkRevealer` *measures* 0x0 and its box still
// hands it the leftover width, so the collapsed pane reserved an empty column beside the plot.
gtk_widget_set_visible(app.events_revealer, open);
// The position is what gives the pane its room (or none): a paned hands its start child at least its
// minimum unless the position says otherwise.
if (app.events_divider != nullptr) {
gtk_paned_set_position(GTK_PANED(app.events_divider), open ? app.events_pane_width : 0);
}
}
/// Restates the divider's position, once a frame.
///
/// `gtk_paned_set_position()` is clamped against the allocation the paned has *now*, and a click on
/// the history button flips the toggle in the same instant the pane becomes visible -- so the position
/// is clamped against a child that still measures zero, the pane opens with no width, and only the
/// next call (a replay's, seconds later) gives it one. Restating it per frame is idempotent, and a
/// position the user dragged is *learned* rather than fought: the width is read back whenever the pane
/// is open and the position is not the one we would set.
void sync_events_divider(App& app) {
if (app.events_divider == nullptr) return;
const bool open =
app.events_revealer != nullptr && gtk_widget_get_visible(app.events_revealer);
const int at = gtk_paned_get_position(GTK_PANED(app.events_divider));
if (!open) {
if (at != 0) gtk_paned_set_position(GTK_PANED(app.events_divider), 0);
return;
}
if (at > 1 && at != app.events_pane_width) {
app.events_pane_width = at; // the user dragged it: that is the width now
return;
}
if (at != app.events_pane_width) {
gtk_paned_set_position(GTK_PANED(app.events_divider), app.events_pane_width);
}
}
/// Writes the held event into the cache: its table, a sidecar of what the list shows, and a
/// thumbnail of the plot area. One path for the tick's latch and for the probe, so what the probe
/// asserts is what a latch does.
bool record_event(App& app) {
if (!app.event_recording || app.event_store == nullptr || app.event_store->empty_dir()) {
return false;
}
// By value: a `shared_ptr` to the snapshot, so a latch that lands while this writes cannot pull the
// data out from under it.
const std::shared_ptr<const EventSnapshot> snap = app.model->event();
if (snap == nullptr) return false;
const ExportTable table = event_table(*app.model, *snap);
if (table.empty()) return false;
EventRecord meta;
meta.wall_time = static_cast<double>(g_get_real_time()) / 1.0e6;
meta.pre_s = snap->pre_s;
meta.post_s = snap->post_s;
meta.duration_s = snap->pre_s + snap->post_s;
meta.edge = snap->x_offset;
meta.rate_hz = app.model->partition().stored_rate();
meta.signals = table.columns.size();
meta.samples = table.rows();
meta.seq = app.model->event_seq();
const std::string file = app.event_store->save(table, meta);
if (file.empty()) return false;
// The event is on screen; say it was kept, and where. A notice rather than a dialog: a latch every
// 200 ms must not be a wall of windows.
set_notice(app, "event cached: " + std::to_string(table.columns.size()) + " signals, " +
std::to_string(table.rows()) + " samples",
3);
// Only while the pane is on screen: rebuilding every row per latch for a column nobody opened is
// work the tick owes nothing, and the toggle's own handler refreshes when it is asked for.
if (app.events_revealer != nullptr &&
gtk_revealer_get_child_revealed(GTK_REVEALER(app.events_revealer))) {
refresh_events_pane(app);
}
return true;
}
/// Replays a cached event: the same loader `--open` uses, on the event's file. The acquisition is
/// stopped first, because a live producer overwrites the loaded samples within a frame -- so the
/// scope becomes a file viewer until *Go live* starts it again.
bool replay_event(App& app, const EventRecord& record) {
if (app.acq == nullptr || app.model == nullptr) return false;
app.acq->stop();
WaveformLoadReport report;
std::string error;
if (!load_waveform_into_model(*app.model, record.file, &report, &error)) {
set_notice(app, "cannot replay: " + error, 5);
return false;
}
app.replaying = true;
app.waveform_name = record.file;
// One event is not a group: replaying replaces whatever layout was up, alpha and all.
app.group_kind = EventGroupKind::None;
app.group.clear();
app.comparing = false;
if (app.area_b_widget != nullptr) gtk_widget_set_visible(app.area_b_widget, FALSE);
app.sync_last_a.clear();
app.sync_last_b.clear();
app.rows->set_trace_alpha(1.0f);
app.layout_loaded = true; // the live session's layout must not fight the event's own binding
// The trigger belongs to the *stream*: a file has no edge to wait for. Leaving it on kept the frozen
// capture on screen instead of the file -- `display_mode()` stays `Held` while the model holds a
// snapshot, and a held display draws *the snapshot*, so the click looked like it did nothing at all
// while the event sat in the model unread.
if (app.trigger_toggle != nullptr &&
gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(app.trigger_toggle))) {
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(app.trigger_toggle), FALSE);
}
// The *data* changes; the layout does not. What a row plots is a signal *name*, and an event's export
// carries the same names the stream does, so the rows, their slots, the colours and the row count all
// stay where the user put them and simply read the stored event. Rebinding the rows here (what a
// freshly opened *file* needs) would rearrange the user's work under a click on a list.
app.rows->fit_all();
rebuild_signal_panels(app);
// Fit on *open*, and only then: the whole event framed the moment it is opened, the user's own zooms
// left alone afterwards -- restated here and never per frame.
if (report.span_s > 0.0) apply_time_window(app, report.span_s);
// The pane is available through a replay, and this is what keeps it open.
sync_events_availability(app);
set_notice(app, "replaying " + record.stem + " (" +
std::to_string(record.signals) + " signals, " +
std::to_string(record.samples) + " samples)", 5);
return true;
}
/// Back to the live stream after a replay: a fresh client and thread, and the replayed samples age
/// out of the ring as the stream refills it.
bool go_live_again(App& app) {
if (!app.replaying || app.acq == nullptr) return false;
app.replaying = false;
app.waveform_name.clear();
app.layout_loaded = false; // the session's layout applies to the source again
app.event_held = false;
app.model->set_display_epoch(0.0);
try {
app.acq->start();
} catch (const std::exception& e) {
set_notice(app, std::string("cannot go live: ") + e.what(), 6);
return false;
}
// Leaving a group is leaving a file view: the second plot goes, the traces become opaque, and the
// acquisition's return brings the signal list's acquire ticks back with it.
app.group_kind = EventGroupKind::None;
app.group.clear();
app.comparing = false;
if (app.area_b_widget != nullptr) gtk_widget_set_visible(app.area_b_widget, FALSE);
app.sync_last_a.clear();
app.sync_last_b.clear();
app.rows->set_trace_alpha(1.0f);
rebuild_signal_panels(app);
set_notice(app, "live", 3);
return true;
}
bool read_event_samples(const std::string& path, EventSamples* out, std::string* error) {
WaveformReader reader;
if (!reader.open(path, error)) return false;
if (reader.names().empty() || reader.rows() == 0) {
if (error != nullptr) *error = "'" + path + "' holds no samples";
return false;
}
out->names = reader.names();
out->columns.assign(out->names.size(), {});
for (std::vector<f32>& column : out->columns) column.reserve(reader.rows());
std::vector<f32> values(out->names.size(), 0.0f);
double t = 0.0;
double abs_t = 0.0;
while (reader.next(&t, &abs_t, values.data())) {
out->times.push_back(t);
for (std::size_t i = 0; i < values.size(); ++i) out->columns[i].push_back(values[i]);
}
reader.close();
if (out->times.empty()) {
if (error != nullptr) *error = "'" + path + "' could not be read to the end";
return false;
}
return true;
}
/// The events the list has selected, in list order (newest first).
std::vector<EventRecord> selected_events(App& app) {
std::vector<EventRecord> out;
if (app.events_list == nullptr) return out;
GList* rows = gtk_list_box_get_selected_rows(GTK_LIST_BOX(app.events_list));
for (GList* it = rows; it != nullptr; it = it->next) {
auto* record =
static_cast<EventRecord*>(g_object_get_data(G_OBJECT(it->data), "udpscope-event"));
if (record != nullptr) out.push_back(*record);
}
g_list_free(rows);
return out;
}
/// What the selection can do, as the buttons read it: exactly two events to compare, between two and
/// `kSlotsPerRow` to accumulate -- a row holds eight traces, so a ninth event has nowhere to go.
void update_group_buttons(App& app) {
// The window is gone: a teardown path clears it *before* the widgets go, and the list box outlives
// the buttons inside it -- so a `selected-rows-changed` while they are being finalized arrives here
// with freed pointers. This is the app's own "the widgets are going" flag.
if (app.window == nullptr) return;
const std::size_t selected = selected_events(app).size();
if (app.events_compare != nullptr) {
gtk_widget_set_sensitive(app.events_compare, selected == 2);
}
if (app.events_accumulate != nullptr) {
gtk_widget_set_sensitive(app.events_accumulate,
selected >= 2 && selected <= RowSet::kSlotsPerRow);
}
}
/// Puts `records` in one scope as a group.
///
/// The model is built *once*, from all of them: one channel per (signal, layer) pair, pushed in a
/// single pass, because the buffers below cannot hold two independent loads. A *comparison* is then two
/// plots over that model -- which plot draws which layer is only a matter of which channel *names* its
/// rows bind -- and an *accumulation* is one plot with the extra layers in the free slots of the row
/// that already holds their signal. The user's rows, slots and colours are never re-derived.
///
/// Both layouts stand on the events having the same signals and the same length; the check is here,
/// once, rather than in either layout.
bool build_event_group(App& app, const std::vector<EventRecord>& records, EventGroupKind kind,
std::string* error) {
const auto fail = [&](const std::string& why) {
if (error != nullptr) *error = why;
return false;
};
if (app.acq == nullptr || app.model == nullptr || app.rows == nullptr) return fail("no scope yet");
if (records.size() < 2) return fail("select at least two events");
const bool pair = kind == EventGroupKind::Compare;
if (pair && records.size() != 2) return fail("two events, no more");
if (!pair && records.size() > RowSet::kSlotsPerRow) {
return fail("at most " + std::to_string(RowSet::kSlotsPerRow) +
" events: the rows have no room for another layer");
}
if (pair && app.rows_b == nullptr) return fail("the second plot is not available");
// The acquisition first: a live producer keeps pushing into the model it was started with -- and it
// pushes *four* stream signals into the group's eight channels, so the group's own samples are the
// ones that go missing.
app.acq->stop();
std::vector<EventSamples> events(records.size());
for (std::size_t i = 0; i < records.size(); ++i) {
if (!read_event_samples(records[i].file, &events[i], error)) return false;
}
const std::size_t cols = events[0].names.size();
const std::size_t count = events[0].times.size();
for (std::size_t i = 1; i < events.size(); ++i) {
if (events[i].names != events[0].names) return fail("the events carry different signals");
if (events[i].times.size() != count) return fail("the events are not the same length");
}
const double span = events[0].times.back() - events[0].times.front();
if (!(span > 0.0)) return fail("the events have no duration");
if (pair) {
// The same sample *count* is not enough for the halves to line up: the same recording rate is what
// makes them the same duration. The tolerance is a few sample intervals, because two events this
// app recorded itself differ by the fraction of a sample the acquisition's window filled to.
const double dt = span / double(count > 1 ? count - 1 : 1);
const double other = events[1].times.back() - events[1].times.front();
if (std::abs(other - span) > std::max(4.0 * dt, 1.0e-3)) {
return fail("the events are not the same duration");
}
}
const std::size_t layers = pair ? 2u : events.size();
const auto suffix = [&](std::size_t layer) -> std::string {
if (pair) return layer == 0 ? " (A)" : " (B)";
return layer == 0 ? std::string() : " #" + std::to_string(layer + 1);
};
// A slot's name carries the previous group's suffix; the shared rule takes it back off, so a second
// group over the same slots maps to the same channels instead of to names nothing is called.
const auto base_name = [](const std::string& slot) { return layer_base_name(slot); };
const double rate = count > 1 ? double(count - 1) / span : 0.0;
std::vector<SignalInfo> signals;
signals.reserve(cols * layers);
for (std::size_t c = 0; c < cols; ++c) {
for (std::size_t k = 0; k < layers; ++k) {
SignalInfo sig;
sig.name = events[0].names[c] + suffix(k);
sig.sampling_rate = rate > 0.0 ? rate : 1.0;
signals.push_back(std::move(sig));
}
}
// The stamps: every event's own, merged and sorted. They are the same grid when the events came from
// the same acquisition setup -- each event's `time` column is edge-relative -- and where they differ
// an event simply has no sample at the other's instants and contributes a gap.
std::vector<f64> stamps;
stamps.reserve(count * events.size());
for (const EventSamples& e : events) stamps.insert(stamps.end(), e.times.begin(), e.times.end());
std::sort(stamps.begin(), stamps.end());
stamps.erase(std::unique(stamps.begin(), stamps.end()), stamps.end());
app.model->set_target_span(span);
app.model->set_signals(std::move(signals));
for (std::size_t i = 0; i < cols * layers; ++i) app.model->set_signal_enabled(i, true);
const f32 gap = std::numeric_limits<f32>::quiet_NaN();
std::vector<f32> values(cols * layers, 0.0f);
std::vector<std::size_t> cursor(events.size(), 0);
for (f64 t : stamps) {
for (std::size_t k = 0; k < layers; ++k) {
const EventSamples& e = events[k];
while (cursor[k] < e.times.size() && e.times[cursor[k]] < t) ++cursor[k];
const bool here = cursor[k] < e.times.size() && e.times[cursor[k]] == t;
for (std::size_t c = 0; c < cols; ++c) {
values[c * layers + k] = here ? e.columns[c][cursor[k]] : gap;
}
}
app.model->push_loaded_slot(values, t, t);
}
app.model->set_epoch_if_unset(events[0].times.front());
app.model->set_display_epoch(events[0].times.back());
// --- the layout: the user's, mirrored, never reinvented --------------------
const int rows = app.rows->row_count();
if (pair) {
app.rows_b->set_row_count(rows);
if (app.area_b != nullptr) app.area_b->set_figure(&app.rows_b->figure());
}
app.rows->set_trace_alpha(kind == EventGroupKind::Accumulate ? 0.35f : 1.0f);
if (app.rows_b != nullptr) app.rows_b->set_trace_alpha(1.0f);
for (int r = 0; r < rows; ++r) {
RowSet::Row& row = app.rows->row(r);
const std::array<std::string, RowSet::kSlotsPerRow> names = row.signal_name;
const std::array<std::string, RowSet::kSlotsPerRow> labels = row.name;
const std::array<int, RowSet::kSlotsPerRow> colours = row.color_index;
for (int s = 0; s < RowSet::kSlotsPerRow; ++s) {
const std::size_t at = static_cast<std::size_t>(s);
if (names[at].empty()) continue;
const std::string base = base_name(names[at]);
app.rows->assign(r, s, base + suffix(0), labels[at], colours[at]);
if (pair) {
// The *same* colour index: the two plots show the same signal of two different events, and a
// colour of its own for the second would say they were different signals.
app.rows_b->assign(r, s, base + suffix(1), labels[at] + suffix(1), colours[at]);
continue;
}
for (std::size_t layer = 1; layer < layers; ++layer) {
int found_row = -1;
int found_slot = -1;
for (int pass = 0; pass < 2 && found_row < 0; ++pass) {
const int from = pass == 0 ? r : 0;
const int to = pass == 0 ? r + 1 : rows;
for (int rr = from; rr < to && found_row < 0; ++rr) {
for (int ss = 0; ss < RowSet::kSlotsPerRow; ++ss) {
if (!app.rows->row(rr).signal_name[static_cast<std::size_t>(ss)].empty()) continue;
found_row = rr;
found_slot = ss;
break;
}
}
}
if (found_row < 0) break; // the layout is full: no room for another layer
app.rows->assign(found_row, found_slot, base + suffix(layer), labels[at] + suffix(layer),
colours[at]);
}
}
}
app.rows->fit_all();
if (pair) app.rows_b->fit_all();
app.replaying = true; // a file view: no stream behind it, and no starvation notice
app.waveform_name = pair ? "compare " + records[0].stem + " vs " + records[1].stem
: "accumulate " + records[0].stem + " +" +
std::to_string(records.size() - 1);
app.group_kind = kind;
app.group = records;
app.comparing = pair;
if (app.area_b_widget != nullptr) gtk_widget_set_visible(app.area_b_widget, pair);
app.sync_last_a.clear();
app.sync_last_b.clear();
if (app.trigger_toggle != nullptr &&
gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(app.trigger_toggle))) {
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(app.trigger_toggle), FALSE);
}
rebuild_layout_editor(app);
rebuild_signal_panels(app);
// Fit on open, once: the group's own span, framed.
apply_time_window(app, span);
sync_events_availability(app);
return true;
}
/// One frame's mirror between the two comparison plots.
///
/// The plotter owns its window, and a gesture reaches it through the plot's own setter with nothing
/// telling us it happened. So each frame reads the ranges and compares them with the previous frame's:
/// whichever side moved is the side the user touched, and the other is set to match it. Copying
/// unconditionally would work only until the first gesture, which the copy would undo; copying nothing
/// would let the two plots drift apart, which is the second plot's whole point.
///
/// What is compared is the *span* of an axis with a live edge, not its edges: under the live follow the
/// window is recomputed every frame as a distance behind the newest sample, so both plots' edges move
/// every frame and would read as "both sides moved" -- and then a zoom in one plot would be overwritten
/// by the other's ever-moving window.
void sync_compare_axes(App& app) {
if (!app.comparing || app.rows_b == nullptr) return;
const int rows = std::min(app.rows->row_count(), app.rows_b->row_count());
if (static_cast<int>(app.sync_last_a.size()) != rows) {
app.sync_last_a.assign(static_cast<std::size_t>(rows), {});
app.sync_last_b.assign(static_cast<std::size_t>(rows), {});
}
for (int k = 0; k < rows; ++k) {
RowSet::Row& ra = app.rows->row(k);
RowSet::Row& rb = app.rows_b->row(k);
if (ra.plot == nullptr || rb.plot == nullptr) continue;
const plot::Range ax = ra.plot->x_range(ra.axis_x);
const plot::Range ay = ra.plot->y_range(ra.axis_y);
const plot::Range bx = rb.plot->x_range(rb.axis_x);
const plot::Range by = rb.plot->y_range(rb.axis_y);
// A live *edge*, not merely an armed follow: an armed axis with nothing streaming keeps a stored
// window, whose edges a pan moves and whose span never changes.
f64 newest = 0.0;
const bool live = ra.plot->x_live_edge(ra.axis_x, newest);
const auto signature = [live](const plot::Range& x, const plot::Range& y) {
return std::array<double, 4>{live ? x.max - x.min : x.min, live ? 0.0 : x.max, y.min, y.max};
};
const std::size_t at = static_cast<std::size_t>(k);
const std::array<double, 4> a_now = signature(ax, ay);
const std::array<double, 4> b_now = signature(bx, by);
const bool a_moved = a_now != app.sync_last_a[at];
const bool b_moved = b_now != app.sync_last_b[at];
if (b_moved && !a_moved) {
ra.plot->set_x_range(ra.axis_x, bx);
ra.plot->set_y_range(ra.axis_y, by);
app.sync_last_a[at] = b_now;
app.sync_last_b[at] = b_now;
} else if (a_moved) {
// The first plot is the reference when both moved -- the live edge drifting on both, or the first
// frame after the build (the snapshots start empty and both read as moved).
rb.plot->set_x_range(rb.axis_x, ax);
rb.plot->set_y_range(rb.axis_y, ay);
app.sync_last_a[at] = a_now;
app.sync_last_b[at] = a_now;
} else {
app.sync_last_a[at] = a_now;
app.sync_last_b[at] = b_now;
}
}
}
/// The two group buttons' one handler: which layout it starts is which button it is.
void on_group_clicked(GtkButton* button, gpointer user) {
App& app = *static_cast<App*>(user);
const bool compare = button == GTK_BUTTON(app.events_compare);
const std::vector<EventRecord> records = selected_events(app);
std::string error;
if (!build_event_group(app, records, compare ? EventGroupKind::Compare : EventGroupKind::Accumulate,
&error)) {
set_notice(app, std::string(compare ? "cannot compare: " : "cannot accumulate: ") + error, 5);
return;
}
set_notice(app, compare ? "comparing " + records[0].stem + " with " + records[1].stem
: "accumulating " + std::to_string(records.size()) + " events",
5);
}
EventThumbCache* event_thumb_cache(App& app) {
if (app.events_list == nullptr) return nullptr;
return static_cast<EventThumbCache*>(
g_object_get_data(G_OBJECT(app.events_list), "udpscope-event-thumbs"));
}
/// The stored preview scaled to a row's size. Scaled here, once, for two reasons: a `GtkPicture`'s
/// natural size *is* its content, so a 480-pixel BMP in a row claims the row's whole width and squeezes
/// the text out of it -- a size request on the picture is a floor and not a cap -- and the cache then
/// holds a few KB per event instead of 300.
GdkTexture* scale_texture(GdkTexture* source, int width, int height) {
const int sw = gdk_texture_get_width(source);
const int sh = gdk_texture_get_height(source);
if (sw <= 0 || sh <= 0 || width <= 0 || height <= 0) return nullptr;
const std::size_t src_stride = std::size_t(sw) * 4;
std::vector<unsigned char> pixels(src_stride * std::size_t(sh));
gdk_texture_download(source, pixels.data(), src_stride);
cairo_surface_t* src = cairo_image_surface_create_for_data(pixels.data(), CAIRO_FORMAT_ARGB32, sw,
sh, int(src_stride));
cairo_surface_t* dst = cairo_image_surface_create(CAIRO_FORMAT_ARGB32, width, height);
if (cairo_surface_status(src) != CAIRO_STATUS_SUCCESS ||
cairo_surface_status(dst) != CAIRO_STATUS_SUCCESS) {
cairo_surface_destroy(src);
cairo_surface_destroy(dst);
return nullptr;
}
cairo_t* cr = cairo_create(dst);
cairo_scale(cr, double(width) / double(sw), double(height) / double(sh));
cairo_set_source_surface(cr, src, 0.0, 0.0);
cairo_pattern_set_filter(cairo_get_source(cr), CAIRO_FILTER_GOOD);
cairo_paint(cr);
cairo_destroy(cr);
cairo_surface_flush(dst);
const int dst_stride = cairo_image_surface_get_stride(dst);
GBytes* bytes = g_bytes_new(cairo_image_surface_get_data(dst),
std::size_t(dst_stride) * std::size_t(height));
GdkTexture* out = gdk_memory_texture_new(width, height, GDK_MEMORY_DEFAULT, bytes, dst_stride);
g_bytes_unref(bytes);
cairo_surface_destroy(src);
cairo_surface_destroy(dst);
return out;
}
/// The decoded preview for `path`, from the cache or decoded now and kept. NULL when the file cannot
/// be read -- cached as well: the store writes an event's picture once and never rewrites one.
///
/// Decoded here rather than with `gtk_picture_set_filename()`: that goes through the image loader's
/// sandboxed, asynchronous decoder, which on this machine fails silently -- the picture keeps a
/// paint-able and an allocation while nothing is on screen (see the GTK invariants).
GdkTexture* event_preview_texture(App& app, const std::string& path) {
EventThumbCache* cache = event_thumb_cache(app);
if (cache == nullptr || path.empty()) return nullptr;
const auto found = cache->find(path);
if (found != cache->end()) return found->second;
GdkTexture* stored = gdk_texture_new_from_filename(path.c_str(), nullptr);
GdkTexture* texture =
stored != nullptr ? scale_texture(stored, kEventThumbW, kEventThumbH) : nullptr;
if (stored != nullptr) g_object_unref(stored);
cache->emplace(path, texture);
return texture;
}
/// The events pane's rows, newest first: each row is the picture of *that* event beside the
/// sidecar's numbers, because what tells one event from another is its shape, and one picture for
/// the whole pane describes whichever event happens to be newest rather than the one being read. The
/// numbers are one line each: a list that has to load the samples to describe an event is a list that
/// cannot show thirty of them.
void refresh_events_pane(App& app) {
if (app.events_list == nullptr) return;
while (GtkWidget* child = gtk_widget_get_first_child(app.events_list)) {
gtk_list_box_remove(GTK_LIST_BOX(app.events_list), child);
}
// The row it pointed at went with the rest: a rebuild that leaves this dangling is a pointer into
// freed widget memory, and the next reader of it (the probe, or a repaint holding the picture) is
// the one that finds out.
app.events_thumb = nullptr;
if (app.event_store == nullptr) return;
const std::vector<EventRecord> records = app.event_store->list();
if (records.empty()) {
GtkWidget* empty = gtk_label_new("no events yet -- arm the trigger");
gtk_widget_add_css_class(empty, "dim-label");
gtk_label_set_xalign(GTK_LABEL(empty), 0.0f);
gtk_widget_set_margin_start(empty, 10);
gtk_widget_set_margin_top(empty, 12);
gtk_list_box_append(GTK_LIST_BOX(app.events_list), empty);
return;
}
// Only the events still on the list keep a decoded picture: the store trims to the newest few, and
// a latch a second would otherwise grow this session's cache by a dead 300 KB texture each time,
// for as long as the run lasted.
if (EventThumbCache* cache = event_thumb_cache(app)) {
for (auto it = cache->begin(); it != cache->end();) {
const bool listed = std::any_of(records.begin(), records.end(),
[&](const EventRecord& r) { return r.thumbnail == it->first; });
if (listed) {
++it;
continue;
}
if (it->second != nullptr) g_object_unref(it->second);
it = cache->erase(it);
}
}
std::size_t unreadable = 0;
for (const EventRecord& r : records) {
GtkWidget* row = gtk_list_box_row_new();
g_object_set_data_full(G_OBJECT(row), "udpscope-event",
new EventRecord(r), [](gpointer p) { delete static_cast<EventRecord*>(p); });
GtkWidget* line = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8);
gtk_widget_set_margin_start(line, 6);
gtk_widget_set_margin_end(line, 6);
gtk_widget_set_margin_top(line, 4);
gtk_widget_set_margin_bottom(line, 4);
// The picture of this event, beside its numbers. It sits in a fixed-size frame: a `GtkPicture`
// takes its *natural* size from the paintable -- the whole 480-pixel thumbnail -- so a size
// request on the picture itself is a floor and not a cap, and the row's width would go to the
// image rather than to the text. `can_shrink` is what lets it fit the frame it is given.
GtkWidget* shot = gtk_picture_new();
gtk_picture_set_can_shrink(GTK_PICTURE(shot), TRUE);
gtk_picture_set_content_fit(GTK_PICTURE(shot), GTK_CONTENT_FIT_CONTAIN);
gtk_widget_set_hexpand(shot, TRUE);
gtk_widget_set_vexpand(shot, TRUE);
GtkWidget* frame = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 0);
// Exactly the texture's size: equal to the picture's natural size, so the row's box has nothing
// to grow and the text keeps the rest of the width.
gtk_widget_set_size_request(frame, kEventThumbW, kEventThumbH);
// `halign` START, not the default FILL: a filling widget is given whatever the row has left over,
// which made the picture as wide as the pane and cropped by the height it was pinned to.
gtk_widget_set_halign(frame, GTK_ALIGN_START);
gtk_widget_set_valign(frame, GTK_ALIGN_CENTER);
gtk_widget_add_css_class(frame, "udpscope-event-thumb");
gtk_box_append(GTK_BOX(frame), shot);
GdkTexture* texture = event_preview_texture(app, r.thumbnail);
if (texture != nullptr) {
gtk_picture_set_paintable(GTK_PICTURE(shot), GDK_PAINTABLE(texture));
} else {
++unreadable;
}
gtk_box_append(GTK_BOX(line), frame);
// The newest event is the first row: what the probe measures, and nothing the pane treats
// specially.
if (app.events_thumb == nullptr) app.events_thumb = shot;
GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 2);
gtk_widget_set_valign(box, GTK_ALIGN_CENTER);
gtk_widget_set_hexpand(box, TRUE);
const std::time_t t = static_cast<std::time_t>(r.wall_time);
std::tm tm{};
localtime_r(&t, &tm);
char clock[32];
std::strftime(clock, sizeof(clock), "%H:%M:%S", &tm);
char title[160];
std::snprintf(title, sizeof(title), "%s %.3f s%s", clock, r.duration_s,
std::string(app.waveform_name) == r.file ? " (on screen)" : "");
GtkWidget* name = gtk_label_new(title);
gtk_widget_set_halign(name, GTK_ALIGN_START);
gtk_label_set_ellipsize(GTK_LABEL(name), PANGO_ELLIPSIZE_END);
// A cap, not only an ellipsis: `max-width-chars` bounds a label's *natural* width, and the row's
// natural width is the pane's minimum -- which is the floor the divider cannot go below.
gtk_label_set_max_width_chars(GTK_LABEL(name), 22);
gtk_widget_add_css_class(name, "udpscope-event-title");
gtk_box_append(GTK_BOX(box), name);
char sub[160];
std::snprintf(sub, sizeof(sub), "%zu signals · %zu samples · %.4g S/s", r.signals, r.samples,
r.rate_hz);
GtkWidget* detail = gtk_label_new(sub);
gtk_widget_add_css_class(detail, "dim-label");
gtk_widget_set_halign(detail, GTK_ALIGN_START);
gtk_label_set_max_width_chars(GTK_LABEL(detail), 22);
// Both labels ellipsize, so the text gives way to the picture rather than the picture to the
// text: a label's minimum width is its longest word otherwise, and the pane is 250 pixels.
gtk_label_set_ellipsize(GTK_LABEL(detail), PANGO_ELLIPSIZE_END);
gtk_box_append(GTK_BOX(box), detail);
gtk_box_append(GTK_BOX(line), box);
gtk_list_box_row_set_child(GTK_LIST_BOX_ROW(row), line);
gtk_list_box_append(GTK_LIST_BOX(app.events_list), row);
}
if (unreadable > 0) {
std::fprintf(stderr, "[udpscope] %zu event previews could not be read\n", unreadable);
std::fflush(stderr);
}
}
/// Clicking a row replays it: the same loader `--open` uses, on the event's file. The acquisition is
/// stopped by `replay_event()` -- a live producer would overwrite the samples within a frame -- so the
/// pane's own *Live* button is the way back.
void on_event_row_activated(GtkListBox*, GtkListBoxRow* row, gpointer user) {
App& app = *static_cast<App*>(user);
if (row == nullptr) return;
auto* record = static_cast<EventRecord*>(g_object_get_data(G_OBJECT(row), "udpscope-event"));
if (record == nullptr) return;
replay_event(app, *record);
// Rebuilt, not for the picture -- the row already has it -- but for the "on screen" mark the
// replay just changed.
refresh_events_pane(app);
}
/// The pane itself: a collapsible column on the left, a header and the list of events -- each row
/// carrying its own picture. Built once with the rest of the window, and opened on demand from the
/// toolbar's history button, which is itself only there while trigger mode and recording are both
/// on (`sync_events_availability()`).
GtkWidget* build_events_pane(App& app) {
GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 6);
// A floor, not a width: the divider sets the width and this is what the pane needs to stay usable at
// its narrowest.
gtk_widget_set_size_request(box, 170, -1);
gtk_widget_set_margin_start(box, 6);
gtk_widget_set_margin_end(box, 6);
gtk_widget_set_margin_top(box, 6);
GtkWidget* head = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
GtkWidget* title = gtk_label_new(nullptr);
gtk_label_set_markup(GTK_LABEL(title), "<b>Events</b>");
gtk_widget_set_hexpand(title, TRUE);
gtk_widget_set_halign(title, GTK_ALIGN_START);
gtk_box_append(GTK_BOX(head), title);
// The two group actions, under the header and above the list they read: what they act on is the
// selection, so they belong where the selection is.
GtkWidget* actions = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
app.events_compare = gtk_button_new_with_label("Compare");
gtk_widget_set_tooltip_text(app.events_compare,
"the two selected events in two plots side by side, with the axes "
"synchronised");
gtk_widget_set_hexpand(app.events_compare, TRUE);
gtk_widget_set_sensitive(app.events_compare, FALSE);
g_signal_connect(app.events_compare, "clicked", G_CALLBACK(on_group_clicked), &app);
gtk_box_append(GTK_BOX(actions), app.events_compare);
app.events_accumulate = gtk_button_new_with_label("Accumulate");
gtk_widget_set_tooltip_text(app.events_accumulate,
"up to four selected events overlaid in the same traces, translucent, "
"so where they agree the trace is brighter");
gtk_widget_set_hexpand(app.events_accumulate, TRUE);
gtk_widget_set_sensitive(app.events_accumulate, FALSE);
g_signal_connect(app.events_accumulate, "clicked", G_CALLBACK(on_group_clicked), &app);
gtk_box_append(GTK_BOX(actions), app.events_accumulate);
gtk_box_append(GTK_BOX(box), actions);
GtkWidget* live = gtk_button_new_with_label("Live");
gtk_widget_add_css_class(live, "flat");
gtk_widget_set_tooltip_text(live, "go back to the live stream after replaying a stored event");
g_signal_connect(live, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
go_live_again(*static_cast<App*>(user));
}),
&app);
gtk_box_append(GTK_BOX(head), live);
gtk_box_append(GTK_BOX(box), head);
app.events_list = gtk_list_box_new();
// The decoded pictures live as long as the list does.
g_object_set_data_full(G_OBJECT(app.events_list), "udpscope-event-thumbs", new EventThumbCache,
[](gpointer p) {
auto* cache = static_cast<EventThumbCache*>(p);
for (const auto& entry : *cache) {
if (entry.second != nullptr) g_object_unref(entry.second);
}
delete cache;
});
// Multiple, and activation on *double* click: the list's other job is choosing which events to
// compare or accumulate, and with single-click activation a plain click -- the gesture that builds a
// selection -- would replay the event instead.
gtk_list_box_set_activate_on_single_click(GTK_LIST_BOX(app.events_list), FALSE);
gtk_list_box_set_selection_mode(GTK_LIST_BOX(app.events_list), GTK_SELECTION_MULTIPLE);
g_signal_connect(app.events_list, "row-activated", G_CALLBACK(on_event_row_activated), &app);
g_signal_connect(app.events_list, "selected-rows-changed", G_CALLBACK(+[](GtkListBox*,
gpointer user) {
update_group_buttons(*static_cast<App*>(user));
}),
&app);
GtkWidget* scroll = gtk_scrolled_window_new();
gtk_widget_set_vexpand(scroll, TRUE);
gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(scroll), GTK_POLICY_NEVER,
GTK_POLICY_AUTOMATIC);
gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(scroll), app.events_list);
gtk_box_append(GTK_BOX(box), scroll);
app.events_revealer = gtk_revealer_new();
gtk_revealer_set_transition_type(GTK_REVEALER(app.events_revealer),
GTK_REVEALER_TRANSITION_TYPE_SLIDE_RIGHT);
gtk_revealer_set_child(GTK_REVEALER(app.events_revealer), box);
return app.events_revealer;
}
/// The recording switch, which lives here because it is a trigger option and not a pane option: it
/// decides whether latches are written at all, and the pane that lists them must not be the only
/// place to reach the control that stops them. Flipping it re-decides the pane's availability.
void on_events_record_toggled(GObject* o, GParamSpec*, gpointer user) {
App& app = *static_cast<App*>(user);
app.event_recording = gtk_switch_get_active(GTK_SWITCH(o));
sync_events_availability(app);
}
} // namespace ui_impl
} // namespace udpscope
+435
View File
@@ -0,0 +1,435 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
//
// The layout editor and the persisted session: the overlay buttons that add and remove rows, the\n// debounced save, and the configurations dialog with the layouts it loads and stores.
#include "ui_app.hpp"
#include <gtk/gtk.h>
#include <string>
namespace udpscope {
namespace ui_impl {
/// Reads the session layout back out of the model and rows: the display window,
/// each row's slot signal names, and each signal's calibration.
LayoutState 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;
}
}
// The *enable* state, not just what is plotted: a signal can be enabled only as a math channel's
// source, and a session that came back with it off would come back with a NaN trace. "Enabled" is
// read off the channels the model built -- it has no per-signal accessor and does not need one,
// since an enabled signal is exactly one that produced a channel.
std::vector<bool> on(model.signals().size(), false);
for (const ChannelInfo& ch : model.channels()) {
if (ch.math) continue;
if (ch.signal < on.size()) on[ch.signal] = true;
}
for (std::size_t si = 0; si < model.signals().size(); ++si) {
if (model.signals()[si].math || !on[si]) continue;
st.enabled.push_back(model.signals()[si].name);
}
// Only the signals the user actually re-coloured: the rest take the positional palette, and
// writing those out would freeze a colour that is meant to follow the signal list.
for (const auto& [name, rgb] : app.rows->signal_colors()) {
st.colors.push_back({name, rgb});
}
// The app's own lists are the pane's source of truth for both of these.
st.math = app.math;
st.measures = app.measures;
st.measure_stats = app.measure_stats_on;
// The connection is the session's too: the file's name carries only host and port.
st.multicast = app.source_multicast;
st.interface_addr = app.source_interface;
// The configuration's own identity and the startup settings it carries. The buffer size is a
// *startup* parameter -- the rings are allocated before any stream is known -- so a configuration
// is the only place it can be remembered per source, and the chooser applies it on open.
st.comment = app.config_comment;
st.buffer_mb = app.model->config().buffer_mb;
st.relative_time = app.model->config().relative_time;
st.capture_packets = app.model->config().capture_packets;
return st;
}
void on_config_row_activated(GtkListBox*, GtkListBoxRow* row, gpointer user) {
ConfigDialog* d = static_cast<ConfigDialog*>(user);
if (row == nullptr) return;
const char* name = static_cast<const char*>(g_object_get_data(G_OBJECT(row), "config-name"));
if (name == nullptr) return;
App& app = *d->app;
// The session on screen is saved first: switching configurations must not be the one gesture
// that loses the arrangement it is switching *from*.
save_layout_now(app);
LayoutState st;
const std::string path = configuration_path(app.source_host, app.source_port, name);
if (!load_layout(path, st)) {
set_notice(app, std::string("could not read the configuration \"") + name + "\"", 4);
return;
}
app.config_name = name;
app.config_comment = st.comment;
set_active_configuration(app.source_host, app.source_port, name);
apply_layout(app, st);
// A buffer size, a clock and a capture ring are decided before the first socket: saying so beats
// a configuration that appears to half-apply.
const Config& cfg = app.model->config();
std::string later;
if (st.buffer_mb > 0.0 && st.buffer_mb != cfg.buffer_mb) {
later += "buffer " + std::to_string(static_cast<int>(st.buffer_mb)) + " MB";
}
if (st.relative_time != cfg.relative_time) {
later += later.empty() ? "" : ", ";
later += st.relative_time ? "relative time" : "stream time";
}
if (st.capture_packets != cfg.capture_packets) {
later += later.empty() ? "" : ", ";
later += "packet capture";
}
set_notice(app, "configuration \"" + std::string(name) + "\" loaded" +
(later.empty() ? "" : " (" + later + " takes effect on the next start)"),
4);
config_dialog_refresh(*d);
}
void on_config_forget_clicked(GtkButton* button, gpointer user) {
ConfigDialog* d = static_cast<ConfigDialog*>(user);
const char* name = static_cast<const char*>(g_object_get_data(G_OBJECT(button), "config-name"));
if (name == nullptr) return;
App& app = *d->app;
if (app.config_name == name) {
// The active one is being deleted: the next save picks whatever is left, which is what
// `active_configuration()` answers once the name is gone.
app.config_name.clear();
app.config_comment.clear();
}
forget_configuration(app.source_host, app.source_port, name);
config_dialog_refresh(*d);
}
void on_config_save_as(GtkButton*, gpointer user) {
ConfigDialog* d = static_cast<ConfigDialog*>(user);
const std::string name = gtk_editable_get_text(GTK_EDITABLE(d->name));
const std::string comment = gtk_editable_get_text(GTK_EDITABLE(d->comment));
if (name.empty()) {
set_notice(*d->app, "a configuration needs a name", 4);
return;
}
if (!save_configuration_as(*d->app, name, comment)) {
set_notice(*d->app, "could not write the configuration", 4);
return;
}
gtk_editable_set_text(GTK_EDITABLE(d->name), "");
gtk_editable_set_text(GTK_EDITABLE(d->comment), "");
config_dialog_refresh(*d);
}
void config_dialog_refresh(ConfigDialog& d) {
while (GtkWidget* child = gtk_widget_get_first_child(d.list)) {
gtk_list_box_remove(GTK_LIST_BOX(d.list), child);
}
const std::vector<std::string> names =
configuration_names(d.app->source_host, d.app->source_port);
if (names.empty()) {
GtkWidget* empty = gtk_label_new("No configurations yet -- save this session below.");
gtk_widget_add_css_class(empty, "dim-label");
gtk_widget_set_margin_top(empty, 12);
gtk_widget_set_margin_bottom(empty, 12);
gtk_list_box_append(GTK_LIST_BOX(d.list), empty);
return;
}
for (const std::string& name : names) {
const std::string path = configuration_path(d.app->source_host, d.app->source_port, name);
LayoutState st;
if (!load_layout(path, st)) continue;
GtkWidget* row = gtk_list_box_row_new();
g_object_set_data_full(G_OBJECT(row), "config-name", g_strdup(name.c_str()), g_free);
GtkWidget* line = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 10);
gtk_widget_set_margin_start(line, 10);
gtk_widget_set_margin_end(line, 4);
gtk_widget_set_margin_top(line, 5);
gtk_widget_set_margin_bottom(line, 5);
GtkWidget* text = gtk_box_new(GTK_ORIENTATION_VERTICAL, 2);
gtk_widget_set_hexpand(text, TRUE);
std::string title = name;
if (name == d.app->config_name) title += " (active)";
GtkWidget* title_label = gtk_label_new(nullptr);
gtk_label_set_markup(GTK_LABEL(title_label), ("<b>" + title + "</b>").c_str());
gtk_widget_set_halign(title_label, GTK_ALIGN_START);
gtk_box_append(GTK_BOX(text), title_label);
if (!st.comment.empty()) {
GtkWidget* note = gtk_label_new(st.comment.c_str());
gtk_widget_add_css_class(note, "dim-label");
gtk_widget_set_halign(note, GTK_ALIGN_START);
gtk_label_set_ellipsize(GTK_LABEL(note), PANGO_ELLIPSIZE_END);
gtk_box_append(GTK_BOX(text), note);
}
int signals = 0;
for (const LayoutState::Row& r : st.rows) {
for (const std::string& sig : r.signals) {
if (!sig.empty()) ++signals;
}
}
std::string sub = std::to_string(st.rows.size()) + " plots, " + std::to_string(signals) +
" signals";
if (st.buffer_mb > 0.0) {
sub += ", buffer " + std::to_string(static_cast<int>(st.buffer_mb)) + " MB";
}
if (st.relative_time) sub += ", relative time";
GtkWidget* counts = gtk_label_new(sub.c_str());
gtk_widget_add_css_class(counts, "dim-label");
gtk_widget_set_halign(counts, GTK_ALIGN_START);
gtk_box_append(GTK_BOX(text), counts);
gtk_box_append(GTK_BOX(line), text);
GtkWidget* forget = gtk_button_new_from_icon_name("window-close-symbolic");
gtk_widget_set_tooltip_text(forget, "Delete this configuration");
gtk_widget_add_css_class(forget, "flat");
gtk_widget_set_valign(forget, GTK_ALIGN_CENTER);
g_object_set_data_full(G_OBJECT(forget), "config-name", g_strdup(name.c_str()), g_free);
g_signal_connect(forget, "clicked", G_CALLBACK(on_config_forget_clicked), &d);
gtk_box_append(GTK_BOX(line), forget);
gtk_list_box_row_set_child(GTK_LIST_BOX_ROW(row), line);
gtk_list_box_append(GTK_LIST_BOX(d.list), row);
}
}
/// 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;
// Nothing is known about this source yet -- no CONFIG ever arrived -- so every slot name is
// empty and this would overwrite a configured session with `signals = ["", ...]`. The startup
// chooser filters such a file out of its list, so a source whose streamer was down during one
// run would silently vanish from the only place it can be picked. Keeping the old file loses
// nothing: this run configured nothing.
if (app.model->signals().empty()) return;
if (app.config_name.empty()) app.config_name = active_configuration(app.source_host,
app.source_port);
const std::string path =
configuration_path(app.source_host, app.source_port, app.config_name);
if (!save_layout(path, collect_layout(app))) {
std::fprintf(stderr, "[udpscope] failed to write the layout file\n");
std::fflush(stderr);
return;
}
// Which configuration the source is on is part of remembering it: the next run opens this one.
set_active_configuration(app.source_host, app.source_port, app.config_name);
}
/// Writes the current session under a *new* name, with a note, and makes it the source's active
/// configuration. The one entry point for both the dialog's button and the probe: a probe that
/// rolled its own would test a different path than the user's.
bool save_configuration_as(App& app, const std::string& name, const std::string& comment) {
if (name.empty() || app.source_port == 0 || app.model->signals().empty()) return false;
app.config_name = name;
app.config_comment = comment;
const std::string path = configuration_path(app.source_host, app.source_port, name);
if (!save_layout(path, collect_layout(app))) {
std::fprintf(stderr, "[udpscope] failed to write the configuration file\n");
std::fflush(stderr);
return false;
}
set_active_configuration(app.source_host, app.source_port, name);
return true;
}
/// 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();
// The session's own state first: enabling is what starts acquisition, and a math channel's
// *name* is only a signal once its graph is installed -- a row that holds one would otherwise be
// skipped as a name the stream no longer has.
for (const std::string& name : st.enabled) {
for (std::size_t si = 0; si < signals.size(); ++si) {
if (signals[si].name != name || signals[si].math) continue;
model.set_signal_enabled(si, true);
break;
}
}
app.math = st.math;
apply_math_channels(app);
app.measures = st.measures;
app.measure_stats_on = st.measure_stats;
app.updating_panels = true;
if (app.measures_stats_check != nullptr) {
gtk_check_button_set_active(GTK_CHECK_BUTTON(app.measures_stats_check),
app.measure_stats_on);
}
// The toggled handler is guarded by updating_panels, so the Reset button's sensitivity
// and the stats-label visibility are set directly from the loaded state here; the labels'
// visibility is also set per-row inside rebuild_measures_pane via measures_row().
if (app.measures_stats_reset != nullptr)
gtk_widget_set_sensitive(app.measures_stats_reset, app.measure_stats_on);
app.updating_panels = false;
rebuild_measures_pane(app);
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;
// A signal the model will never build a channel for must not take a slot. The
// stream's time signal is skipped by `build_channels_locked()` however often it is
// enabled, so such a slot holds a name for ever and data never -- and because
// `collect_layout()` writes the names straight back, one that got in stayed in and
// was re-saved every session. On the plot it drew nothing; in the cursors pane's
// `mix` mode it drew a line that could only read `--`.
if (ScopeModel::is_time_signal(signals, si)) break;
// 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;
}
}
// Skipped for a name the stream no longer carries, like the calibration above: an override the
// signal list cannot show is one `collect_layout()` would write back every session for ever.
for (const auto& color : st.colors) {
for (std::size_t si = 0; si < signals.size(); ++si) {
if (signals[si].name != color.name) continue;
app.rows->set_signal_color(color.name, color.rgb);
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);
}
void on_layout_row_remove(GtkButton* button, gpointer user) {
App* app = static_cast<App*>(user);
const int row = row_key(GTK_WIDGET(button));
if (!app->rows->remove_row(row)) {
app->notice = "a figure needs at least one row";
app->notice_until_us = g_get_monotonic_time() + 3 * G_TIME_SPAN_SECOND;
return;
}
// `remove_row()` has already built the new figure (a `Figure`'s row count is fixed at
// construction), so the area has to be re-pointed here rather than through
// `relayout_areas()`, which only acts on a *dirty* row.
if (app->area != nullptr) app->area->set_figure(&app->rows->figure());
rebuild_layout_editor(*app);
// 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) {
App* app = static_cast<App*>(user);
const int at = row_key(GTK_WIDGET(button));
if (!app->rows->insert_row(at)) {
app->notice = "at most " + std::to_string(RowSet::kMaxRows) + " rows";
app->notice_until_us = g_get_monotonic_time() + 3 * G_TIME_SPAN_SECOND;
return;
}
if (app->area != nullptr) app->area->set_figure(&app->rows->figure());
rebuild_layout_editor(*app);
rebuild_signal_panels(*app);
schedule_layout_save(*app);
}
/// The `Done` button: the toolbar toggle is the one piece of state that says whether the
/// editor is up, so this turns *that* off and the existing `toggled` handler does the rest
/// (including taking this button down).
void on_layout_editor_done(GtkButton*, gpointer user) {
App* app = static_cast<App*>(user);
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(app->layout_toggle), false);
}
void on_layout_editor_toggled(GtkToggleButton* button, gpointer user) {
App* app = static_cast<App*>(user);
app->layout_editing = gtk_toggle_button_get_active(button);
rebuild_layout_editor(*app);
}
/// The editor button of one kind at one index, or null. The probe clicks the real
/// widgets rather than calling the handlers, so what it drives is what a user reaches.
GtkWidget* layout_button(App& a, bool insert, int index) {
for (GtkWidget* b : a.layout_buttons) {
const bool is_insert = g_object_get_data(G_OBJECT(b), kLayoutKindKey) != nullptr;
if (is_insert == insert && row_key(b) == index) return b;
}
return nullptr;
}
/// Clicks one editor button. `::clicked` directly and not `gtk_widget_activate()`: a
/// `GtkButton` activated that way holds the press for `ACTIVATE_TIMEOUT` (250 ms) and only
/// then emits `::clicked`, so every step of the probe read the *previous* step's result --
/// and the button whose timeout was pending is destroyed by the rebuild the step before it
/// caused, which dropped the click entirely.
void layout_click(GtkWidget* b) {
if (b != nullptr) g_signal_emit_by_name(b, "clicked");
}
} // namespace ui_impl
} // namespace udpscope
+1063
View File
File diff suppressed because it is too large. Load diff
+598
View File
@@ -0,0 +1,598 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
//
// The measurements pane and its dialog: the list of measurements, the window statistics they compute\n// on a throttle, and the marks they place on the plot.
#include "ui_app.hpp"
#include <gtk/gtk.h>
#include <string>
namespace udpscope {
namespace ui_impl {
/// A measurement's own colour, so the pane's badge and the marks it puts on the plot are
/// recognisably the same measurement. Picked from the trace palette's neighbours rather than
/// it: a measurement's mark must not look like one of the signals.
plot::u32 measure_color(std::size_t index) {
static const plot::u32 kPalette[] = {0x9BE8A0u, 0xE8A0C8u, 0xE8D79Bu, 0x9BD7E8u,
0xC8A0E8u, 0xA0E8D7u, 0xE8B9A0u, 0xA0A8E8u};
return kPalette[index % (sizeof(kPalette) / sizeof(kPalette[0]))];
}
/// The channel a measurement's signal resolved to, or -1 while the stream does not carry it.
int measure_channel(App& app, const std::string& signal) {
std::lock_guard<std::mutex> lock(app.model->mutex());
const std::vector<SignalInfo>& signals = app.model->signals();
const std::vector<ChannelInfo>& channels = app.model->channels();
for (std::size_t s = 0; s < signals.size(); ++s) {
if (signals[s].name != signal) continue;
for (std::size_t c = 0; c < channels.size(); ++c) {
if (channels[c].signal == s) return static_cast<int>(c);
}
return -1; // the signal exists but is not acquired
}
return -1;
}
/// Which row plots a signal (the first slot that does), or 0: a measurement's marks belong on
/// the plot showing the signal, and a signal nothing plots still has to go somewhere.
int measure_row(App& app, const std::string& signal) {
for (int r = 0; r < app.rows->row_count(); ++r) {
const RowSet::Row& row = app.rows->row(r);
for (int s = 0; s < RowSet::kSlotsPerRow; ++s) {
if (row.signal_name[static_cast<std::size_t>(s)] == signal) return r;
}
}
return 0;
}
/// The unit of a signal's channel, for the pane's readout.
std::string measure_unit(App& app, const std::string& signal) {
std::lock_guard<std::mutex> lock(app.model->mutex());
for (const ChannelInfo& c : app.model->channels()) {
if (c.name == signal) return c.unit;
}
return "";
}
void rebuild_measures_pane(App& app) {
if (app.measures_list == nullptr) return;
GtkWidget* child = gtk_widget_get_first_child(app.measures_list);
while (child != nullptr) {
GtkWidget* next = gtk_widget_get_next_sibling(child);
gtk_box_remove(GTK_BOX(app.measures_list), child);
child = next;
}
app.measure_labels.clear();
app.measure_stats_labels.clear();
app.measure_text.clear();
// Editing a measurement changes what the figures *mean*, so the history behind them is no
// longer one series. This is the implicit reset for an add, an edit and a delete alike.
app.measure_stats.assign(app.measures.size(), MeasureStatsAccum{});
if (app.measures.empty()) {
GtkWidget* empty = gtk_label_new("no measurements -- [+] to add one");
gtk_widget_add_css_class(empty, "dim-label");
gtk_widget_set_halign(empty, GTK_ALIGN_START);
gtk_box_append(GTK_BOX(app.measures_list), empty);
}
for (std::size_t i = 0; i < app.measures.size(); ++i) {
gtk_box_append(GTK_BOX(app.measures_list), measures_row(app, i, app.measures[i]));
app.measure_text.emplace_back();
}
update_measures(app, /*force=*/true, /*accumulate=*/true);
}
/// Shows the fields a type actually uses: the threshold and the side only where the engine
/// reads them, and the plot option only for a type that can be drawn. A field that does
/// nothing is worse than an absent one -- it invites the user to set it.
void measure_dialog_sync(MeasureDialog* d) {
const int sel = gtk_drop_down_get_selected(GTK_DROP_DOWN(d->type_drop));
const MeasureType type = measure_type_at(sel);
const bool threshold = measure_uses_threshold(type);
const bool side = measure_uses_side(type);
gtk_widget_set_visible(d->threshold_row, threshold);
gtk_widget_set_visible(d->side_row, side);
gtk_widget_set_visible(d->show, measure_can_show_on_plot(type));
// The words follow the type: one row, several meanings.
const std::string signal = d->signals.empty()
? std::string("the signal")
: d->signals[std::min<std::size_t>(
gtk_drop_down_get_selected(GTK_DROP_DOWN(d->signal_drop)),
d->signals.size() - 1)];
switch (type) {
case MeasureType::Average:
gtk_label_set_text(GTK_LABEL(d->threshold_label), "");
gtk_label_set_text(GTK_LABEL(d->side_label), "");
gtk_check_button_set_label(GTK_CHECK_BUTTON(d->show), "show on plot (ruler at the mean)");
break;
case MeasureType::Min:
case MeasureType::Max:
gtk_check_button_set_label(GTK_CHECK_BUTTON(d->show),
"show on plot (ruler at the value, marker where it is)");
break;
case MeasureType::PeakToPeak:
case MeasureType::Amplitude:
gtk_label_set_text(GTK_LABEL(d->threshold_label), "");
gtk_label_set_text(GTK_LABEL(d->side_label), "");
gtk_check_button_set_label(GTK_CHECK_BUTTON(d->show),
"show on plot (rulers at both extremes)");
break;
case MeasureType::Top:
case MeasureType::Bottom:
gtk_label_set_text(GTK_LABEL(d->threshold_label), "split % of the range");
gtk_check_button_set_label(GTK_CHECK_BUTTON(d->show), "show on plot (ruler at the level)");
break;
case MeasureType::PulseDuration:
gtk_label_set_text(GTK_LABEL(d->threshold_label), "split % of the range");
gtk_label_set_text(GTK_LABEL(d->side_label), "pulse above the level");
gtk_check_button_set_label(GTK_CHECK_BUTTON(d->show),
"show on plot (markers at the pulse's edges)");
break;
case MeasureType::Period:
case MeasureType::Frequency:
// No split to choose: the engine counts rising crossings of the mid level (see
// `compute_measure`), and the row that would offer one is not shown at all.
gtk_label_set_text(GTK_LABEL(d->threshold_label), "");
gtk_label_set_text(GTK_LABEL(d->side_label), "");
gtk_check_button_set_label(GTK_CHECK_BUTTON(d->show),
"show on plot (markers at two edges of a cycle)");
break;
}
gtk_widget_set_tooltip_text(
d->show,
("draw what the measurement found on the row plotting " + signal + " -- it follows the "
"window and cannot be dragged").c_str());
}
void on_measure_type_changed(GObject* obj, GParamSpec*, gpointer user) {
(void)obj;
measure_dialog_sync(static_cast<MeasureDialog*>(user));
}
void on_measure_dialog_apply(GtkButton*, gpointer user) {
MeasureDialog* d = static_cast<MeasureDialog*>(user);
App& app = *d->app;
const guint sig = gtk_drop_down_get_selected(GTK_DROP_DOWN(d->signal_drop));
if (sig >= d->signals.size()) {
gtk_window_destroy(GTK_WINDOW(d->window));
return;
}
Measure m;
m.signal = d->signals[sig];
m.type = measure_type_at(gtk_drop_down_get_selected(GTK_DROP_DOWN(d->type_drop)));
m.options.threshold_pct = gtk_spin_button_get_value(GTK_SPIN_BUTTON(d->threshold));
m.options.above = gtk_switch_get_active(GTK_SWITCH(d->above));
m.options.show_on_plot = gtk_check_button_get_active(GTK_CHECK_BUTTON(d->show));
if (d->edit >= 0 && static_cast<std::size_t>(d->edit) < app.measures.size()) {
app.measures[static_cast<std::size_t>(d->edit)] = std::move(m);
} else {
app.measures.push_back(std::move(m));
}
rebuild_measures_pane(app);
gtk_window_destroy(GTK_WINDOW(d->window));
}
void on_measure_dialog_delete(GtkButton*, gpointer user) {
MeasureDialog* d = static_cast<MeasureDialog*>(user);
App& app = *d->app;
if (d->edit >= 0 && static_cast<std::size_t>(d->edit) < app.measures.size()) {
app.measures.erase(app.measures.begin() + d->edit);
rebuild_measures_pane(app);
}
gtk_window_destroy(GTK_WINDOW(d->window));
}
/// Puts `App::measure_marks` on the plots, converting each **time** mark from the stream seconds
/// it is stored in to the x the axis is reading this frame. Called every frame -- that is what
/// makes the marks read-only (a gesture moves one, and this puts it back) and, since a live axis
/// is newest-relative, also what rolls each mark leftwards with the sample it measured.
void place_measure_marks(App& app) {
const double s0 = app.model->stream_at_x0();
app.measure_marks_display = app.measure_marks;
for (RowSet::MeasureMark& mk : app.measure_marks_display) {
if (!mk.level) mk.value -= s0;
}
app.rows->set_measure_marks(app.measure_marks_display);
}
/// Recomputes every measurement over the visible window and writes the results into the pane,
/// collecting the marks the ones asking to be shown need.
///
/// A measurement is a statistic of the window, so it is recomputed when the *window* moves --
/// not on a timer. A timer decouples the two: with the window scrolling under a 5 Hz cadence
/// the stored marks describe a range the plot has already left, which is how a min/max marker
/// ended up outside the viewport, and how a dragged marker survived until the next tick. The
/// threshold is a small fraction of the span, so the marks stay within a few pixels of where
/// the current window would put them, and the cost is bounded by the window's own motion
/// rather than by the frame rate.
void update_measures(App& app, bool force, bool accumulate) {
if (app.measure_labels.size() != app.measures.size()) return; // mid-rebuild
const RowSet::Row& r0 = app.rows->row(0);
if (r0.plot == nullptr) return;
// The window on the **stream's** clock, which is the only space in which "it moved" is a
// question with an answer: a live axis is newest-relative, so `view` itself is a fixed
// `[-window, 0]` that never changes while the data under it does.
const double s0 = app.model->stream_at_x0();
const plot::Range view = r0.plot->x_range(r0.axis_x);
const plot::Range swin{view.min + s0, view.max + s0};
const bool held = app.model->display_mode() == DisplayMode::Held;
const bool moved = app.measure_window_valid && !held &&
std::fabs(swin.min - app.measure_window.min) +
std::fabs(swin.max - app.measure_window.max) >
0.02 * std::max(1.0e-12, swin.span());
// A measurement is a statistic of the visible window, and *a live view moves that window every
// frame* -- so "recompute when the window moves" is "recompute every frame", at O(visible
// samples) per measurement. That is what stalled the frame tick (0.4 ms -> 13.7 ms, a third of
// the run's frames lost) once the window held a few million samples. The pane is a *readout*,
// so it is refreshed on the terms the status line's own fields already use: at most every
// `kMeasureRefreshUs`, plus immediately whenever the window stops moving or the mode changes,
// which is when the number has to be exact. Between those a tenth of a second of lag is
// invisible on a statistic that is itself a window average, and the marks below are re-placed
// from the last result every frame either way.
const gint64 now_us = g_get_monotonic_time();
const gint64 interval =
app.measure_last_pass_us > kMeasureHeavyUs ? 4 * kMeasureRefreshUs : kMeasureRefreshUs;
const bool due = !app.measure_stamp_valid || now_us - app.measure_stamp_us >= interval;
// Two reasons to skip, and they are different questions. `measure_window` is the window the
// *last recompute described*, so no change at all means the pane already says the right thing:
// that is the settled case, and it must recompute once when it settles and then never again --
// getting this backwards is what had a *pinned* window recomputing every single frame (457
// passes in one run) instead of once. The other is a window still scrolling: at most one pass
// per `kMeasureRefreshUs`, because the number is a window average and a tenth of a second of
// lag on it is not readable.
const bool changed = !app.measure_window_valid || held != app.measure_was_held || moved;
if (!force && !changed) {
place_measure_marks(app);
return;
}
if (!force && moved && held == app.measure_was_held && !due) {
// Nothing to recompute -- but the marks are still re-placed below, which is what makes
// them read-only: a gesture moves one on the plot, and this is what puts it back.
place_measure_marks(app);
return;
}
app.measure_window = swin;
app.measure_window_valid = true;
app.measure_was_held = held;
app.measure_stamp_us = now_us;
app.measure_stamp_valid = true;
const gint64 pass_start_us = g_get_monotonic_time();
app.measure_samples.clear();
app.measure_results.assign(app.measures.size(), MeasureResult{});
app.measure_last_valid.resize(app.measures.size());
app.measure_text.assign(app.measures.size(), std::string("--"));
app.measure_marks.clear();
std::vector<RowSet::MeasureMark> marks;
struct PassTimer {
App& app;
gint64 start;
~PassTimer() {
const gint64 took = g_get_monotonic_time() - start;
app.measure_cost_us += took;
app.measure_last_pass_us = took;
++app.measure_passes;
}
} pass_timer{app, pass_start_us};
// Who needs what, before any data is touched: the channel each measurement resolved to, and
// *which* level accumulator answers it. Several measurements on one signal share both walks, and
// several sharing a threshold share one accumulator -- so a pane full of measurements on one
// trace costs the same two walks as a single one. The common case is exactly one level: every
// crossing type reads the mid level and `Top`/`Bottom` default to it.
std::vector<int> chan(app.measures.size(), -1);
std::vector<int> want_level(app.measures.size(), -1);
for (std::size_t i = 0; i < app.measures.size(); ++i) {
const Measure& m = app.measures[i];
const int channel = measure_channel(app, m.signal);
chan[i] = channel;
if (channel < 0) continue; // unresolvable this pass: a rebuild has emptied the channel list
App::MeasureWindow& w = app.measure_samples[channel];
double pct = 0.0;
if (!measure_level_pct(m.type, m.options, pct)) continue;
std::size_t k = 0;
while (k < w.pct.size() && w.pct[k] != pct) ++k;
if (k == w.pct.size()) w.pct.push_back(pct);
want_level[i] = static_cast<int>(k);
}
// The data work: two walks per channel, whatever the pane holds. The first fixes the window's
// extremes (and so every level, which is a share of them); the second is skipped entirely when
// no measurement on this channel reads a level.
//
// Both go through the **LOD pyramid** rather than over the samples. The producer already folds
// every sample into it as it arrives, which is what "evaluate only on the new samples" means
// here: a 64-sample bucket carries its own extremes and its exact sum, so pass one reads 1/64
// of the window, and pass two opens only the buckets that *straddle* its level -- a handful per
// crossing. The raw path is still here, and still exact, for the window's partial edge buckets,
// for the samples produced since this frame's `acquire_all()`, and for those straddling
// buckets; a window shorter than a bucket, or a signal noisy enough to straddle everywhere,
// degrades back to the O(window) cost and nothing else changes.
constexpr std::size_t kChunk = 8192;
std::vector<MeasureBucket>& buckets = app.measure_bucket_scratch;
for (auto& entry : app.measure_samples) {
App::MeasureWindow& w = entry.second;
const gint64 fetch_start_us = g_get_monotonic_time();
MeasureWindowReader reader(app, static_cast<std::size_t>(entry.first));
const std::size_t n = reader.size();
const std::function<double(std::size_t)> time_of = [&reader](std::size_t i) {
return reader.time_of(i);
};
// The window's whole buckets, fetched once and walked by both passes.
u64 kb = 0;
u64 ke = 0;
buckets.clear();
if (reader.bucket_range(kb, ke)) {
buckets.reserve(static_cast<std::size_t>(ke - kb));
for (u64 k = kb; k < ke; ++k) buckets.push_back(reader.bucket(k));
}
const std::size_t bulk_from = buckets.empty() ? n : buckets.front().offset;
const std::size_t bulk_to = buckets.empty() ? n : buckets.back().offset + buckets.back().span;
std::size_t raw_read = 0;
// In window order, always: the first sample to reach an extreme is the one the mark names, so
// a later chunk that merely ties it must not move it.
const auto raw = [&](std::size_t from, std::size_t to, const auto& fn) {
for (std::size_t at = from; at < to; at += kChunk) {
const std::size_t take = std::min(kChunk, to - at);
fn(MeasureChunk{reader.values(at, take), take, at});
raw_read += take;
}
};
const auto scan = [&](const MeasureChunk& c) { measure_scan(c, w.stats); };
raw(0, bulk_from, scan);
if (!buckets.empty()) measure_scan_buckets(buckets.data(), buckets.size(), w.stats);
raw(bulk_to, n, scan);
// A bucket named the 64 samples that hold an extreme, not the sample. One scan of that bucket
// pins it -- two buckets at most, whatever the window's size.
if (w.stats.valid) {
const auto pin = [&](std::size_t& index, std::size_t& search, bool want_hi) {
if (search == 0) return;
MeasureStats one;
measure_scan(MeasureChunk{reader.values(index, search), search, index}, one);
if (one.valid) index = want_hi ? one.hi_i : one.lo_i;
raw_read += search;
search = 0;
};
pin(w.stats.lo_i, w.stats.lo_search, false);
pin(w.stats.hi_i, w.stats.hi_search, true);
// The two stamps the whole scan needed, read now rather than at every chunk that moved an
// extreme: a stamp is a ring read, and an extreme moves many times over a long window.
w.stats.at_lo = reader.time_of(w.stats.lo_i);
w.stats.at_hi = reader.time_of(w.stats.hi_i);
}
w.ok = w.stats.valid;
if (w.ok && !w.pct.empty()) {
w.level.assign(w.pct.size(), MeasureLevel{});
for (std::size_t k = 0; k < w.pct.size(); ++k) {
// The level *and* its hysteresis band, in the engine: how wide the band is is a property of
// how an edge is counted, not of the pane, and two expressions for it is how the walk and
// the bucket fast path end up disagreeing about where a crossing was.
measure_set_level(w.level[k], w.stats, w.pct[k]);
}
const auto accumulate = [&](const MeasureChunk& c) {
measure_accumulate(c, time_of, w.level.data(), w.level.size());
};
raw(0, bulk_from, accumulate);
for (const MeasureBucket& b : buckets) {
const f32* values = nullptr;
for (MeasureLevel& level : w.level) {
if (measure_accumulate_bucket(b, time_of, level)) continue;
// Fetched once for however many levels this bucket straddles, and only then: the usual
// case is one level, and the usual bucket answers without being opened at all.
if (values == nullptr) {
values = reader.values(b.offset, b.span);
raw_read += b.span;
}
measure_accumulate(MeasureChunk{values, b.span, b.offset}, time_of, &level, 1);
}
}
raw(bulk_to, n, accumulate);
}
app.measure_fetch_us += g_get_monotonic_time() - fetch_start_us;
app.measure_covered += static_cast<std::uint64_t>(n);
app.measure_bucketed += static_cast<std::uint64_t>(buckets.size());
app.measure_raw += raw_read;
}
for (std::size_t i = 0; i < app.measures.size(); ++i) {
const Measure& m = app.measures[i];
GtkWidget* label = app.measure_labels[i];
const auto it = chan[i] < 0 ? app.measure_samples.end() : app.measure_samples.find(chan[i]);
if (it == app.measure_samples.end() || !it->second.ok) {
// Its signal is momentarily unresolvable, or the window holds nothing -- so there is nothing
// to measure *this* pass. The marks still fall back (below); `continue`ing out of the marks
// was the other half of the rulers blinking off.
app.measure_text[i] = "--";
if (label != nullptr) gtk_label_set_text(GTK_LABEL(label), "--");
continue;
}
const App::MeasureWindow& w = it->second;
const MeasureLevel* level =
want_level[i] >= 0 && static_cast<std::size_t>(want_level[i]) < w.level.size()
? &w.level[static_cast<std::size_t>(want_level[i])]
: nullptr;
const MeasureResult r = compute_measure(w.stats, level, m.type, m.options);
app.measure_results[i] = r;
if (r.valid) app.measure_last_valid[i] = r;
// Time-valued types carry seconds and a frequency carries hertz whatever the signal's
// own unit is; everything else carries that unit. Hoisted out of the valid branch below
// because the statistics line needs the same unit.
const bool is_time =
m.type == MeasureType::PulseDuration || m.type == MeasureType::Period;
const std::string unit = is_time ? "s"
: m.type == MeasureType::Frequency ? "Hz"
: measure_unit(app, m.signal);
if (label != nullptr) {
std::string text = "--";
if (r.valid) {
text = format_measure(r.primary, unit);
if (r.has_secondary) {
// Pulse duration also reports the duty, which has no unit.
text += " " + format_measure(100.0 * r.secondary, "%");
}
}
app.measure_text[i] = text;
gtk_label_set_text(GTK_LABEL(label), text.c_str());
}
// One observation per recompute, and only when the pass produced one and the caller
// asked for it. An invalid pass contributes nothing -- the marks' `measure_last_valid`
// fallback is a *drawing* convenience. `accumulate=false` is the toggle handler, which
// forces a repaint of the newly-visible labels without counting the window again.
if (accumulate && r.valid && i < app.measure_stats.size()) app.measure_stats[i].add(r.primary);
if (i < app.measure_stats_labels.size() && app.measure_stats_labels[i] != nullptr) {
GtkWidget* sl = app.measure_stats_labels[i];
gtk_widget_set_visible(sl, app.measure_stats_on);
if (app.measure_stats_on && i < app.measure_stats.size()) {
const MeasureStatsAccum& s = app.measure_stats[i];
std::string text = "no passes yet";
if (s.n > 0) {
// The same formatter the reading above uses, so the two lines are comparable at a
// glance; sd and the extremes carry the measurement's unit, n does not.
text = "n " + std::to_string(s.n) + " mean " + format_measure(s.mean, unit) +
" sd " + format_measure(s.sd(), unit) + " " +
format_measure(s.lo, unit) + " .. " + format_measure(s.hi, unit);
}
gtk_label_set_text(GTK_LABEL(sl), text.c_str());
}
}
}
// The marks last, in their own pass over the measurements, so that no `continue` above can skip
// them. That is exactly how the rulers came to blink off -- first at a mode change, then at a
// rebuild where the signal is momentarily unresolvable -- while the plot put them back a few
// frames later, which reads as the lines flickering.
//
// A pass with no answer falls back to the last valid one, and the split is by *kind*: a
// **level** is a property of the signal (a mean, a top, an amplitude) and outlives the window it
// was measured in, so its ruler stays; a **time** mark is a position *inside* the window that
// result describes, so it may not outlive it and is dropped.
for (std::size_t i = 0; i < app.measures.size() && i < app.measure_results.size(); ++i) {
const Measure& m = app.measures[i];
if (!m.options.show_on_plot) continue;
const MeasureResult& r = app.measure_results[i];
const MeasureResult* mk = &r;
if (!r.valid && i < app.measure_last_valid.size() && app.measure_last_valid[i].valid) {
mk = &app.measure_last_valid[i];
}
const int row = measure_row(app, m.signal);
const plot::u32 color = measure_color(i);
for (const MeasureMarkSpec& spec : mk->marks) {
if (!spec.level && !r.valid) continue;
// A time mark is stored on the **stream's** clock -- the walks report display x, so `s0`
// puts it back -- and `place_measure_marks()` converts it again on every frame it draws.
// That is what keeps the mark on its own sample as a live view rolls underneath it; a level
// mark is a y and is stored as it comes.
marks.push_back({row, spec.level, spec.level ? spec.value : spec.value + s0, color});
}
}
app.measure_marks = std::move(marks);
place_measure_marks(app);
}
/// Repeating trace of the pane's numbers beside the *current* window and the marks actually
/// placed: the only way a stale result is visible as a number rather than as a stutter.
gboolean measure_trace(gpointer user) {
App* a = static_cast<App*>(user);
if (a->rows->row_count() <= 0) return G_SOURCE_REMOVE;
RowSet::Row& r0 = a->rows->row(0);
if (r0.plot == nullptr) return G_SOURCE_REMOVE;
const plot::Range view = r0.plot->x_range(r0.axis_x);
std::string line = "[measure-trace] view=[" + std::to_string(view.min) + ".." +
std::to_string(view.max) + "]";
for (std::size_t i = 0; i < a->measures.size() && i < a->measure_results.size(); ++i) {
const MeasureResult& r = a->measure_results[i];
line += " " + std::string(measure_type_name(a->measures[i].type)) + "=" +
(r.valid ? std::to_string(r.primary) : std::string("--"));
}
// The marks as *placed*, not as stored: a time mark is kept on the stream's clock and converted
// for the axis each frame (see `place_measure_marks()`), and it is the converted value that is
// comparable with the `view=` printed beside it.
line += " marks=" + std::to_string(a->measure_marks_display.size());
for (const RowSet::MeasureMark& m : a->measure_marks_display) {
line += " " + std::string(m.level ? "level" : "time") + ":" + std::to_string(m.value);
}
std::fprintf(stderr, "%s\n", line.c_str());
std::fflush(stderr);
return G_SOURCE_CONTINUE;
}
/// Adds one measurement of every type on the longest-cycling scalar signal the stream
/// describes, then reports each result -- through the pane's own list and the tick's own
/// recomputation, so what it prints is what a user would read.
/// Clicks a labelled button inside the measurement dialog, which is a window of its own.
void measure_dialog_click(App& a, const char* label) {
if (a.measure_dialog == nullptr) {
std::fprintf(stderr, "[measures-probe] no dialog to click '%s' in\n", label);
// (the zoom-in is judged on the next frame, after the layout pushes it)
}
GtkWidget* b = find_labelled_button(a.measure_dialog, label);
if (b == nullptr) {
std::fprintf(stderr, "[measures-probe] no '%s' button in the dialog\n", label);
// (the zoom-in is judged on the next frame, after the layout pushes it)
}
g_signal_emit_by_name(b, "clicked");
}
/// What the dialog's signal dropdown holds: the probe's assertion that the list is the
/// *acquired* signals (plus the edited measurement's own, when the stream no longer acquires
/// it), and that every entry is a name rather than the mangled pointer it used to be.
void measure_dialog_report(App& a, const char* what) {
std::string items;
int n = 0;
if (a.measure_dialog != nullptr) {
// The dialog keeps its fields in the context it stores on its own window, which is how a
// probe reaches a dropdown inside a window that is not the main one.
auto* d = static_cast<MeasureDialog*>(
g_object_get_data(G_OBJECT(a.measure_dialog), "udpscope-measure-ctx"));
if (d != nullptr && d->signal_drop != nullptr) {
GListModel* model = gtk_drop_down_get_model(GTK_DROP_DOWN(d->signal_drop));
const guint count = model != nullptr ? g_list_model_get_n_items(model) : 0;
for (guint i = 0; i < count; ++i) {
GObject* obj = G_OBJECT(g_list_model_get_item(model, i));
const char* text = GTK_IS_STRING_OBJECT(obj)
? gtk_string_object_get_string(GTK_STRING_OBJECT(obj))
: "?";
if (i != 0) items += ", ";
items += text;
g_object_unref(obj);
}
n = static_cast<int>(count);
}
}
int drop_h = -1;
char options[192] = "";
if (a.measure_dialog != nullptr) {
auto* dd = static_cast<MeasureDialog*>(
g_object_get_data(G_OBJECT(a.measure_dialog), "udpscope-measure-ctx"));
if (dd != nullptr && dd->signal_drop != nullptr) {
drop_h = gtk_widget_get_height(dd->signal_drop);
}
if (dd != nullptr) {
// What the dialog is offering for the type on screen: the option rows' visibility and
// the words on them, which is the per-type behaviour a user sees.
std::snprintf(options, sizeof(options), "type=%d thr=%d side=%d show=%d '%s'",
static_cast<int>(gtk_drop_down_get_selected(GTK_DROP_DOWN(dd->type_drop))),
dd->threshold_row != nullptr && gtk_widget_get_visible(dd->threshold_row) ? 1 : 0,
dd->side_row != nullptr && gtk_widget_get_visible(dd->side_row) ? 1 : 0,
dd->show != nullptr && gtk_widget_get_visible(dd->show) ? 1 : 0,
dd->show != nullptr && gtk_check_button_get_child(GTK_CHECK_BUTTON(dd->show)) !=
nullptr
? gtk_label_get_text(GTK_LABEL(gtk_check_button_get_child(
GTK_CHECK_BUTTON(dd->show))))
: "");
}
}
std::fprintf(stderr,
"[measures-probe] %-14s measures=%zu dialog=%d signals=%d drop_h=%d [%s] %s\n",
what, a.measures.size(), a.measure_dialog != nullptr ? 1 : 0, n, drop_h,
items.c_str(), options);
std::fflush(stderr);
}
} // namespace ui_impl
} // namespace udpscope
+3113
View File
File diff suppressed because it is too large. Load diff
+431
View File
@@ -0,0 +1,431 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
//
// The signal list: one row per signal with its colour chip and acquire tick, the panel around it,\n// and the drop that puts a signal on a row.
#include "ui_app.hpp"
#include <gtk/gtk.h>
#include <string>
namespace udpscope {
namespace ui_impl {
void on_signal_switch(GObject* obj, GParamSpec*, gpointer user) {
App* app = static_cast<App*>(user);
if (app->updating_panels) return;
const std::size_t index = static_cast<std::size_t>(
GPOINTER_TO_SIZE(g_object_get_data(obj, "udpscope-signal")));
app->model->set_signal_enabled(index, gtk_check_button_get_active(GTK_CHECK_BUTTON(obj)));
// The enable state is part of the session (a signal can be on only as a math channel's source).
schedule_layout_save(*app);
}
/// The palette entry a signal owns: its position among the *plottable* signals, the
/// time base skipped. It is a property of the signal and not of the row/slot a drop
/// lands in, so the same signal is drawn in the same colour on every plot it appears on
/// -- which is what lets one chip in this list name every one of those traces.
///
/// Positional, like the channel list, so a new CONFIG re-colours; that is fine because a
/// CONFIG drops the assignments and the history with them. A name hash would survive a
/// CONFIG but collide inside one, and two traces of the same colour on one row is the
/// failure this exists to prevent.
int signal_color_index(const std::vector<SignalInfo>& signals, std::size_t signal) {
// Counted over *signals*, not channels: a group's model holds each signal once per event, and
// counting the copies would spread one signal's colour across the palette and give its second layer a
// colour of its own.
int index = 0;
std::vector<std::string> seen;
for (std::size_t i = 0; i < signals.size() && i < signal; ++i) {
if (ScopeModel::is_time_signal(signals, i)) continue;
const std::string base = layer_base_name(signals[i].name);
if (std::find(seen.begin(), seen.end(), base) != seen.end()) continue;
seen.push_back(base);
++index;
}
return index;
}
/// Whether any row's slot holds `name` -- i.e. whether the signal has a trace on screen, which is
/// what decides between its own colour and the grey of an unplotted one, and what decides whether
/// there is a colour for the user to change.
bool signal_plotted(App& app, const std::string& name) {
for (int r = 0; r < app.rows->row_count(); ++r) {
const RowSet::Row& row = app.rows->row(r);
for (int s = 0; s < RowSet::kSlotsPerRow; ++s) {
if (row.signal_name[static_cast<std::size_t>(s)] == name) return true;
}
}
return false;
}
/// The chip for a row of the signal list, as Pango markup: the signal's own colour when
/// it is on a plot, grey when it is not (whether that is because it is not acquired or
/// because it is acquired and not placed). Always a filled disc, never a blank: a blank
/// cell is narrower than a disc, so the names beside it used to shift sideways as
/// signals were placed, and "no trace" read as "no chip here" rather than as a state.
std::string signal_chip_markup(App& app, const std::vector<SignalInfo>& signals,
std::size_t signal) {
const std::string& name = signals[signal].name;
if (!signal_plotted(app, name)) return chip_markup(RowSet::unplotted_color());
// Through `RowSet::signal_color()`, which is also what `install()` builds the trace with: the
// chip's whole job is to say which trace this row is about, so it may not resolve a colour by
// its own route. A user's override is exactly the state in which two routes would disagree.
return chip_markup(app.rows->signal_color(name, signal_color_index(signals, signal)));
}
/// Applies a chosen colour, or leaves everything alone if the chooser was dismissed.
void on_signal_color_chosen(GObject* source, GAsyncResult* res, gpointer user) {
std::unique_ptr<ColorPick> pick(static_cast<ColorPick*>(user));
GError* error = nullptr;
GdkRGBA* picked = gtk_color_dialog_choose_rgba_finish(GTK_COLOR_DIALOG(source), res, &error);
if (picked == nullptr) {
// A dismissed chooser arrives here as an error (`GTK_DIALOG_ERROR_DISMISSED`, or a cancelled
// `GError` if the window went away), and a cancel must leave the colour as it was. There is
// nothing to report either way: the user closed a chooser.
if (error != nullptr) g_error_free(error);
return;
}
const auto octet = [](double v) {
return static_cast<plot::u32>(std::clamp(v, 0.0, 1.0) * 255.0 + 0.5);
};
const plot::u32 rgb = (octet(picked->red) << 16) | (octet(picked->green) << 8) |
octet(picked->blue);
gdk_rgba_free(picked);
App& app = *pick->app;
app.rows->set_signal_color(pick->signal, rgb);
// The rebuild is deferred to the frame tick, like a drop's: a trace's colour is fixed at
// `add_trace()`, so a new colour is a new figure, and `relayout_areas()` is the one place that
// builds one and re-points the area at it.
app.relayout_pending = true;
rebuild_signal_panels(app);
schedule_layout_save(app);
}
/// One line per signal: colour chip, checkbox, name, unit, and a menu for the things
/// you touch once (information, calibration). A `GtkSwitch` and two always-visible
/// buttons made every row about twice this tall, which is what turned a four-signal
/// stream into a panel you have to scroll.
void rebuild_signal_list(App& app, const std::vector<SignalInfo>& signals) {
app.updating_panels = true;
GtkWidget* list = app.signal_list;
GtkWidget* child = gtk_widget_get_first_child(list);
while (child != nullptr) {
GtkWidget* next = gtk_widget_get_next_sibling(child);
gtk_list_box_remove(GTK_LIST_BOX(list), child);
child = next;
}
// One line per *signal*, not per channel. A group's model holds each signal once per event; a list
// that showed both would name one "sine (A)" and the other "sine (B)" as if they were different
// things, and give them different colours. What the row toggles, colours and drags is the signal.
std::vector<std::size_t> rows;
for (std::size_t i = 0; i < signals.size(); ++i) {
if (ScopeModel::is_time_signal(signals, i)) continue; // the time base is not plottable
const std::string base = layer_base_name(signals[i].name);
bool seen = false;
for (std::size_t j = 0; j < i && !seen; ++j) {
seen = !ScopeModel::is_time_signal(signals, j) &&
layer_base_name(signals[j].name) == base;
}
if (!seen) rows.push_back(i);
}
for (std::size_t i : rows) {
const SignalInfo& sig = signals[i];
GtkWidget* row = gtk_list_box_row_new();
gtk_widget_add_css_class(row, "udpscope-compact");
GtkWidget* box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
gtk_widget_set_margin_start(box, 6);
gtk_widget_set_margin_end(box, 2);
const bool plotted = signal_plotted(app, sig.name);
GtkWidget* chip = gtk_label_new(nullptr);
gtk_label_set_markup(GTK_LABEL(chip), signal_chip_markup(app, signals, i).c_str());
gtk_widget_set_tooltip_text(
chip, !app.model->signal_enabled(i) ? "not acquired"
: plotted ? "the colour this signal is plotted in -- click to change it"
: "on no plot, so there is no trace to colour");
if (plotted) {
// A gesture on the label rather than a `GtkButton` around it: a button brings its own
// padding, and padding on this row is what made the list twice as tall once already (see
// this function's note). The chip also has to stay exactly one disc wide, or the names
// beside it shift as signals are placed.
gtk_widget_set_cursor_from_name(chip, "pointer");
GtkGesture* pick = gtk_gesture_click_new();
g_object_set_data(G_OBJECT(pick), "udpscope-signal", GSIZE_TO_POINTER(i));
g_signal_connect(pick, "pressed",
G_CALLBACK(+[](GtkGestureClick* g, int, double, double, gpointer user) {
App* a = static_cast<App*>(user);
// Claimed, so the row's `GtkDragSource` cannot take the same sequence
// and turn a click on the chip into the start of a drag.
gtk_gesture_set_state(GTK_GESTURE(g), GTK_EVENT_SEQUENCE_CLAIMED);
open_signal_color(*a, static_cast<std::size_t>(GPOINTER_TO_SIZE(
g_object_get_data(G_OBJECT(g),
"udpscope-signal"))));
}),
&app);
gtk_widget_add_controller(chip, GTK_EVENT_CONTROLLER(pick));
}
gtk_box_append(GTK_BOX(box), chip);
GtkWidget* enable = gtk_check_button_new();
gtk_check_button_set_active(GTK_CHECK_BUTTON(enable), app.model->signal_enabled(i));
gtk_widget_set_tooltip_text(enable, "acquire this signal");
// A file view has nothing to acquire: an event's or a file's samples are already stored, and
// "acquire this signal" is a stream's question. Hidden rather than disabled -- a greyed tick invites
// the user to look for the reason, and the reason is that this mode has no acquisition at all.
gtk_widget_set_visible(enable, app.waveform_name.empty());
g_object_set_data(G_OBJECT(enable), "udpscope-signal", GSIZE_TO_POINTER(i));
g_signal_connect(enable, "notify::active", G_CALLBACK(on_signal_switch), &app);
gtk_box_append(GTK_BOX(box), enable);
GtkWidget* name = gtk_label_new(sig.name.c_str());
gtk_widget_set_hexpand(name, true);
gtk_widget_set_halign(name, GTK_ALIGN_START);
gtk_label_set_ellipsize(GTK_LABEL(name), PANGO_ELLIPSIZE_END);
gtk_box_append(GTK_BOX(box), name);
if (!sig.unit.empty()) {
GtkWidget* unit = gtk_label_new(sig.unit.c_str());
gtk_widget_add_css_class(unit, "dim-label");
gtk_box_append(GTK_BOX(box), unit);
}
// The overflow: information and calibration are set once per session, so they
// cost a click here instead of a button on every row forever.
GtkWidget* menu_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
gtk_widget_set_margin_top(menu_box, 4);
gtk_widget_set_margin_bottom(menu_box, 4);
gtk_widget_set_margin_start(menu_box, 4);
gtk_widget_set_margin_end(menu_box, 4);
GtkWidget* info = menu_button("Information", nullptr);
g_object_set_data(G_OBJECT(info), "udpscope-signal", GSIZE_TO_POINTER(i));
g_signal_connect(info, "clicked", G_CALLBACK(on_info_clicked), &app);
gtk_box_append(GTK_BOX(menu_box), info);
// The way back from a chosen colour. The chooser itself cannot carry a "default" button, and a
// setting with no undo is a trap; it lives here rather than on the chip because a second
// gesture on a one-glyph target is not something a user would find.
GtkWidget* recolor = menu_button("Default colour", nullptr);
gtk_widget_set_sensitive(recolor, app.rows->signal_colors().count(sig.name) != 0);
gtk_widget_set_tooltip_text(recolor, "back to the palette colour for this signal's position");
g_object_set_data(G_OBJECT(recolor), "udpscope-signal", GSIZE_TO_POINTER(i));
g_signal_connect(recolor, "clicked",
G_CALLBACK(+[](GtkButton* b, gpointer user) {
App* a = static_cast<App*>(user);
const std::size_t idx = static_cast<std::size_t>(
GPOINTER_TO_SIZE(g_object_get_data(G_OBJECT(b), "udpscope-signal")));
std::string name;
{
std::lock_guard<std::mutex> lock(a->model->mutex());
const std::vector<SignalInfo>& sigs = a->model->signals();
if (idx >= sigs.size()) return;
name = sigs[idx].name;
}
a->rows->clear_signal_color(name);
a->relayout_pending = true;
rebuild_signal_panels(*a);
schedule_layout_save(*a);
}),
&app);
gtk_box_append(GTK_BOX(menu_box), recolor);
GtkWidget* cal = menu_button("Calibrate...", nullptr);
gtk_widget_set_tooltip_text(cal, "gain and offset, applied at ingest");
g_object_set_data(G_OBJECT(cal), "udpscope-signal", GSIZE_TO_POINTER(i));
g_signal_connect(cal, "clicked",
G_CALLBACK(+[](GtkButton* b, gpointer user) {
App* a = static_cast<App*>(user);
const std::size_t idx = static_cast<std::size_t>(
GPOINTER_TO_SIZE(g_object_get_data(G_OBJECT(b), "udpscope-signal")));
open_calibration(*a, GTK_WINDOW(a->window), idx);
}),
&app);
gtk_box_append(GTK_BOX(menu_box), cal);
GtkWidget* popover = gtk_popover_new();
gtk_widget_add_css_class(popover, "udpscope-menu");
gtk_popover_set_child(GTK_POPOVER(popover), menu_box);
GtkWidget* more = gtk_menu_button_new();
gtk_menu_button_set_icon_name(GTK_MENU_BUTTON(more), "view-more-symbolic");
gtk_menu_button_set_popover(GTK_MENU_BUTTON(more), popover);
gtk_widget_add_css_class(more, "flat");
gtk_widget_set_tooltip_text(more, "information and calibration");
gtk_box_append(GTK_BOX(box), more);
gtk_list_box_row_set_child(GTK_LIST_BOX_ROW(row), box);
DragContext* ctx = new DragContext{&app, i};
GtkDragSource* source = gtk_drag_source_new();
gtk_drag_source_set_actions(source, GDK_ACTION_COPY);
g_signal_connect_data(source, "prepare", G_CALLBACK(on_drag_prepare), ctx,
[](gpointer data, GClosure*) { delete static_cast<DragContext*>(data); },
G_CONNECT_DEFAULT);
gtk_widget_add_controller(row, GTK_EVENT_CONTROLLER(source));
gtk_list_box_append(GTK_LIST_BOX(list), row);
}
app.updating_panels = false;
}
void on_signals_toggled(GtkToggleButton* button, gpointer user) {
App* app = static_cast<App*>(user);
gtk_revealer_set_reveal_child(GTK_REVEALER(app->signal_revealer),
gtk_toggle_button_get_active(button));
}
gboolean on_drop_channel(GtkDropTarget*, const GValue* value, double x, double y,
gpointer user) {
App* app = static_cast<App*>(user);
if (!G_VALUE_HOLDS_STRING(value)) return FALSE;
// Where the drop landed decides the row: one area covers them all.
const int row = row_at(*app, x, y);
const char* text = g_value_get_string(value);
const std::size_t signal = static_cast<std::size_t>(std::atoll(text ? text : "0"));
if (signal >= app->model->signals().size()) return FALSE;
const auto notice = [&](const std::string& what) {
app->notice = what;
app->notice_until_us = g_get_monotonic_time() + 4 * G_TIME_SPAN_SECOND;
std::fprintf(stderr, "[udpscope] drop: %s\n", what.c_str());
std::fflush(stderr);
};
// The dropped signal's channels are the ones the model built for it; take the
// first, or the first element if the signal is a vector.
const std::vector<ChannelInfo>& channels = app->model->channels();
int channel = -1;
std::string name;
for (std::size_t i = 0; i < channels.size(); ++i) {
if (channels[i].signal == signal) {
channel = static_cast<int>(i);
name = channels[i].name;
break;
}
}
if (channel < 0) {
// The signal is not enabled: enable it, then take the channel that appears.
app->model->set_signal_enabled(signal, true);
const std::vector<ChannelInfo>& again = app->model->channels();
for (std::size_t i = 0; i < again.size(); ++i) {
if (again[i].signal == signal) {
channel = static_cast<int>(i);
name = again[i].name;
break;
}
}
}
if (channel < 0) {
notice("cannot acquire '" + app->model->signals()[signal].name +
"' (multi-element signals are not acquired)");
return FALSE;
}
int slot = -1;
for (int s = 0; s < RowSet::kSlotsPerRow; ++s) {
if (!app->rows->slot_bound(row, s)) {
slot = s;
break;
}
}
if (slot < 0) {
notice("plot " + std::to_string(row + 1) + " already holds four signals");
return FALSE;
}
// The slot remembers the *signal* (and element), never the channel index: the
// channel list is renumbered whenever a signal is enabled or disabled.
const std::vector<SignalInfo>& 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);
notice("plotting '" + name + "' on plot " + std::to_string(row + 1));
return TRUE;
}
void rebuild_signal_panels(App& app) {
// Snapshot the model's lists under the lock: the acquisition thread replaces
// them on every CONFIG, and the panel build holds these references across many
// GTK calls (a use-after-free of the name strings otherwise).
std::vector<SignalInfo> signals;
bool have_signals = false;
{
std::lock_guard<std::mutex> lock(app.model->mutex());
signals = app.model->signals();
have_signals = !signals.empty();
}
rebuild_signal_list(app, signals);
rebuild_trigger_signals(app);
refresh_window_limits(app);
// Nothing to configure before a CONFIG arrives: hide the whole column and its
// toolbar buttons rather than showing two empty panels. The first CONFIG
// reveals the signal panel (the trigger panel stays behind its own button).
update_side_visibility(app);
if (have_signals && !app.signals_panel_initialised) {
app.signals_panel_initialised = true;
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(app.signal_toggle), TRUE);
}
if (!have_signals) {
app.signals_panel_initialised = false;
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(app.signal_toggle), FALSE);
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(app.trigger_toggle), FALSE);
}
app.signal_generation = app.model->generation();
}
GtkWidget* build_signal_panel(App& app) {
GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 6);
gtk_widget_set_margin_top(box, 8);
gtk_widget_set_margin_bottom(box, 8);
gtk_widget_set_margin_start(box, 8);
gtk_widget_set_margin_end(box, 8);
GtkWidget* heading = gtk_label_new("Signals");
gtk_widget_set_halign(heading, GTK_ALIGN_START);
gtk_box_append(GTK_BOX(box), heading);
GtkWidget* hint = gtk_label_new("drag a signal onto a plot");
gtk_widget_set_halign(hint, GTK_ALIGN_START);
gtk_widget_add_css_class(hint, "dim-label");
gtk_box_append(GTK_BOX(box), hint);
app.signal_list = gtk_list_box_new();
gtk_list_box_set_selection_mode(GTK_LIST_BOX(app.signal_list), GTK_SELECTION_NONE);
// Width only. A height floor here made the list occupy 120 px whatever it held,
// which hid the compact rows' height behind it and wasted the space on an empty
// stream; the scroller above it already caps how tall it can grow.
gtk_widget_set_size_request(app.signal_list, 260, -1);
gtk_box_append(GTK_BOX(box), app.signal_list);
return box;
}
/// The pane's availability as one reading: the history button on the bar, the pane revealed, and the
/// switch that decides half of it. Both the button and the pane follow trigger mode *and* recording,
/// so every step that changes either prints all three.
/// Whether the signal list's first row still carries its acquire tick. A file view has nothing to
/// acquire -- the samples are stored -- so the tick goes; in live mode it is the control that decides
/// what is acquired at all.
int signal_tick_visible(App& app) {
GtkWidget* row =
app.signal_list != nullptr ? gtk_widget_get_first_child(app.signal_list) : nullptr;
if (row == nullptr) return -1;
for (GtkWidget* box = gtk_widget_get_first_child(row); box != nullptr;
box = gtk_widget_get_next_sibling(box)) {
for (GtkWidget* w = gtk_widget_get_first_child(box); w != nullptr;
w = gtk_widget_get_next_sibling(w)) {
if (GTK_IS_CHECK_BUTTON(w)) return gtk_widget_get_visible(w) ? 1 : 0;
}
}
return -1;
}
} // namespace ui_impl
} // namespace udpscope
+187
View File
@@ -0,0 +1,187 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
//
// The status bar and the detail popover: the one-line glance with its activity badge, the popover\n// behind the ⓘ, the short-lived notices, and the starvation detector that says a stream stopped.
#include "ui_app.hpp"
#include <gtk/gtk.h>
#include <string>
namespace udpscope {
namespace ui_impl {
void on_popover_closed(GtkPopover* popover, gpointer) {
// Unparent, one idle later. `gtk_widget_set_parent()` took the reference the popover was created
// with (GTK4 widgets come with a floating reference, sunk by the first parent), so unparenting
// drops it to zero and destroys the popover -- a following `g_object_unref()` would be a
// use-after-free, which is why there is none.
//
// The *deferral* is not tidiness either: this runs inside the `closed` emission, which GTK raises
// from `gtk_popover_popdown()`, and destroying a widget in the middle of its own signal leaves the
// popdown path running on freed memory -- a `Gtk-CRITICAL` from `gtk_popover_get_autohide`, on
// every menu entry that dismisses its popover, which is every entry there is. The idle holds the
// reference (`g_object_ref` in, `g_object_unref` out) so the popover outlives its own emission.
g_idle_add_full(
G_PRIORITY_DEFAULT_IDLE,
+[](gpointer p) -> gboolean {
gtk_widget_unparent(GTK_WIDGET(p));
return G_SOURCE_REMOVE;
},
g_object_ref(popover), g_object_unref);
}
/// What the activity badge is saying. `Lit`/`Dim` are the two halves of the blink, so
/// the badge is only *blinking* while the pipeline is actually running; a steady badge is
/// a stopped one, which is the state worth noticing on a glance. `Connecting` is the
/// half-way state -- the socket answered but the stream has sent no CONFIG, so there is no
/// signal list and nothing to acquire -- and it is steady yellow rather than green,
/// because a blinking green badge over a source that has sent nothing claims a working
/// pipeline.
/// The badge is one glyph in one of four colours. It must stay the *same glyph*: the
/// status row sits above the plot, and a badge that changed width as it blinked would
/// move the label beside it and re-lay out the plot twice a second.
const char* badge_markup(BadgePhase phase) {
switch (phase) {
case BadgePhase::Lit:
return "<span foreground=\"#45E045\">\u25CF</span>";
case BadgePhase::Dim:
return "<span foreground=\"#1C5A1C\">\u25CF</span>";
case BadgePhase::Connecting:
return "<span foreground=\"#E0B020\">\u25CF</span>";
case BadgePhase::Idle:
break;
}
return "<span foreground=\"#5A5A5A\">\u25CF</span>";
}
/// A short-lived line in front of the status text: the outcome of an action that the
/// picture does not show by itself (a drop that changed nothing, an export that wrote a
/// file). `tick()` clears it when it expires.
void set_notice(App& app, std::string what, int seconds) {
app.notice = std::move(what);
app.notice_until_us = g_get_monotonic_time() + seconds * G_TIME_SPAN_SECOND;
}
/// The widest window that is worth offering. `--max-window` only: the buffer no longer
/// sets the ceiling, because a window too long to store at the wire rate is decimated on
/// ingest to fit rather than refused (see `plan_partition()`). It used to be capped to
/// `buffered_span()` as well, which is what made a 600 s view of a 1 MS/s stream
/// unreachable.
double window_limit(const App& app) { return app.model->config().max_window_s; }
/// Re-clamps the window against the current cap. Called once at start-up and again when a
/// CONFIG changes how much history one buffer holds -- never on a mere enable or calibration
/// change, so those cannot silently re-anchor the view.
void refresh_window_limits(App& app) {
const double limit = window_limit(app);
if (std::fabs(limit - app.window_limit_s) < 1.0e-9) return;
app.window_limit_s = limit;
apply_time_window(app, app.rows->time_window());
}
/// Sets the popover's seven value labels. Never creates a widget: `popover_rebuilds` exists to
/// assert that, because the popover is visible when this runs and a rebuild would relayout it.
void update_status_popover(App& app, const udpscope::StatusSnapshot& s) {
if (app.popover_label[4] == nullptr) return;
char buf[256];
if (!app.waveform_name.empty()) {
// Not "waiting, no config": a file is not a source that has not answered yet.
std::snprintf(buf, sizeof(buf), "file: %s", app.waveform_name.c_str());
} else {
std::snprintf(buf, sizeof(buf), "%s, %s", s.connected ? "connected" : "waiting",
s.have_config ? "configured" : "no config");
}
gtk_label_set_text(GTK_LABEL(app.popover_label[4]), buf);
std::snprintf(buf, sizeof(buf), "packets %llu · frames %llu · gaps %llu",
static_cast<unsigned long long>(s.packets),
static_cast<unsigned long long>(s.frames),
static_cast<unsigned long long>(s.gaps));
gtk_label_set_text(GTK_LABEL(app.popover_label[5]), buf);
std::snprintf(buf, sizeof(buf), "%zu signals · %zu channels", s.signals, s.channels);
gtk_label_set_text(GTK_LABEL(app.popover_label[6]), buf);
std::snprintf(buf, sizeof(buf), "%.1f MB · %.0f s span · time x%llu", s.buffer_mb, s.span_s,
static_cast<unsigned long long>(s.time_stride));
gtk_label_set_text(GTK_LABEL(app.popover_label[7]), buf);
const std::string stored = udpscope::format_rate(s.stored_rate_hz);
const std::string wire = udpscope::format_rate(s.declared_rate_hz);
std::snprintf(buf, sizeof(buf), "stored %s (wire %s)", stored.c_str(), wire.c_str());
gtk_label_set_text(GTK_LABEL(app.popover_label[8]), buf);
std::snprintf(buf, sizeof(buf), "%d traces · %zu samples", s.plot1_traces, s.plot1_samples);
gtk_label_set_text(GTK_LABEL(app.popover_label[9]), buf);
std::snprintf(buf, sizeof(buf), "%d", s.reconnects);
gtk_label_set_text(GTK_LABEL(app.popover_label[10]), buf);
}
/// Recognises the "the source is serving someone else" state and says so.
///
/// A UDPStreamer over unicast keeps one session: a second client's CONNECT displaces the
/// first, the first sees `silence_timeout_s` of no data and reconnects, and the two take
/// turns. Nothing in the client can give both of them the stream -- the wire has one
/// destination -- so the useful thing is to name the situation: a reconnect with no frames
/// delivered in the same second is that fight, or a server that keeps dropping us, and
/// neither is visible in an empty plot.
///
/// Called once a second from the frame tick.
void note_starvation(App& app) {
StreamStats& st = app.model->stats();
const std::uint64_t frames = st.frames.load(std::memory_order_relaxed);
const std::uint64_t reconnects = st.reconnects.load(std::memory_order_relaxed);
const bool connected = st.connected.load(std::memory_order_relaxed);
// "Connected, but nothing is arriving" is a *rate* fact, not a reconnect one: a source that
// answers CONNECT and then goes quiet -- the unicast case where another client took the session --
// never reconnects while it is silent, so requiring a reconnect made the notice invisible in
// exactly the state it describes. What it takes is `frames` not moving across three consecutive
// one-second samples: one sample would call a source slower than the sample interval starved, and
// three still clears the moment anything arrives.
const bool idle = app.starve_have_prev && frames == app.starve_frames;
app.starve_still = (connected && !app.acq->paused() && idle) ? app.starve_still + 1 : 0;
app.starve_frames = frames;
app.starve_have_prev = true;
if (app.starve_still < 3) {
app.starve_reported = false;
// **A `return`, not just a branch.** The notice and its line used to sit outside this test, so
// they were re-armed every frame and the status bar carried "no data is arriving" while the
// stream was arriving perfectly well.
return;
}
// Two diagnoses, split on `have_config`, because they are not the same situation. A source
// that sent a CONFIG and then went quiet is the unicast fight: the signal list is here, so
// something took the stream away. One that has sent no CONFIG at all never had the stream to
// lose -- there is no signal list, so nothing can be enabled and no DATA frame is owed -- and
// naming that "connected, but no data is arriving" blamed a second reader that does not exist.
// It is still the same stall test: a CONFIG that arrives promptly leaves no notice at all.
const bool have_config = st.have_config.load(std::memory_order_relaxed);
app.notice =
have_config
? "connected, but no data is arriving: another reader may hold the source (a unicast "
"source serves one client at a time) -- use --multicast to watch from several UIs"
: "trying to connect: the source answered, but has sent no configuration, so there is "
"no signal list yet -- check that the streamer is running and serving this port";
app.notice_until_us = g_get_monotonic_time() + 3 * G_TIME_SPAN_SECOND;
if (!app.starve_reported) {
app.starve_reported = true;
std::fprintf(stderr,
have_config
? "[udpscope] starved: connected (config=1), reconnects=%llu, no new frames "
"in the last second -- a unicast source serves one client at a time\n"
: "[udpscope] connecting: connected (config=0), reconnects=%llu, no "
"configuration from the stream -- there is no signal list to acquire\n",
static_cast<unsigned long long>(reconnects));
std::fflush(stderr);
}
}
} // namespace ui_impl
} // namespace udpscope
+432
View File
@@ -0,0 +1,432 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
//
// The trigger panel and everything the trigger change reaches: the condition rows it shows and hides\n// per shape, the arming, and the switch between the stream and a stored event.
#include "ui_app.hpp"
#include <gtk/gtk.h>
#include <string>
namespace udpscope {
namespace ui_impl {
void on_arm_clicked(GtkButton* button, gpointer user) {
App* app = static_cast<App*>(user);
(void)button;
bool armed = false;
int signal = -1;
{
// The producer reads (and, on a single-shot latch, writes) these fields under
// the same lock; an unguarded read-modify-write here can lose its update.
std::lock_guard<std::mutex> lock(app->model->mutex());
TriggerConfig& tc = app->model->trigger();
tc.armed = !tc.armed;
armed = tc.armed;
signal = tc.channel;
}
if (armed) {
app->event_held = false;
app->capturing = false;
app->model->trigger_time_valid().store(false, std::memory_order_relaxed);
}
// Arming is a statement that this signal should be watched, so its acquisition
// is switched on with it -- otherwise the trigger could never see a sample.
// Outside the lock: set_signal_enabled() takes it.
if (armed && signal >= 0) app->model->set_signal_enabled(static_cast<std::size_t>(signal), true);
gtk_button_set_label(GTK_BUTTON(app->arm_button), armed ? "Disarm" : "Arm");
}
/// The trigger type dropdown's order, as a table rather than a cast: the dropdown lists
/// only the conditions that are implemented, so its index is not `TriggerType`'s value.
TriggerType trigger_type_at(guint i) {
switch (i) {
case 1:
return TriggerType::PulseWidth;
case 2:
return TriggerType::Window;
case 3:
return TriggerType::Dropout;
default:
return TriggerType::Edge;
}
}
void publish_trigger_fields(App* app) {
if (app->updating_panels) return;
// Read the widgets first (they may be touched again by a re-entrant handler if a
// setter ran while the lock was held), then publish the values under the lock the
// producer reads them with.
const double level = gtk_spin_button_get_value(GTK_SPIN_BUTTON(app->trigger_level_spin));
const double pre = gtk_spin_button_get_value(GTK_SPIN_BUTTON(app->trigger_pre_spin));
const double holdoff = gtk_spin_button_get_value(GTK_SPIN_BUTTON(app->trigger_holdoff_spin));
const guint edge = gtk_drop_down_get_selected(GTK_DROP_DOWN(app->trigger_edge_drop));
const guint mode = gtk_drop_down_get_selected(GTK_DROP_DOWN(app->trigger_mode_drop));
const guint type = gtk_drop_down_get_selected(GTK_DROP_DOWN(app->trigger_type_drop));
const guint cmp = gtk_drop_down_get_selected(GTK_DROP_DOWN(app->trigger_cmp_drop));
const double width = gtk_spin_button_get_value(GTK_SPIN_BUTTON(app->trigger_width_spin));
const double width2 = gtk_spin_button_get_value(GTK_SPIN_BUTTON(app->trigger_width2_spin));
const double level2 = gtk_spin_button_get_value(GTK_SPIN_BUTTON(app->trigger_level2_spin));
const double timeout = gtk_spin_button_get_value(GTK_SPIN_BUTTON(app->trigger_timeout_spin));
{
std::lock_guard<std::mutex> lock(app->model->mutex());
TriggerConfig& tc = app->model->trigger();
tc.condition.level = level;
tc.pretrigger_pct = pre;
tc.holdoff_s = holdoff;
tc.condition.edge = edge == 0 ? TriggerEdge::Rising
: edge == 1 ? TriggerEdge::Falling
: TriggerEdge::Both;
tc.mode = mode == 0 ? TriggerMode::Normal : TriggerMode::Single;
tc.condition.type = trigger_type_at(type);
tc.condition.cmp = width_cmp_at(cmp);
tc.condition.width_s = width;
tc.condition.width2_s = width2;
tc.condition.level2 = level2;
tc.condition.timeout_s = timeout;
}
}
/// Shows only the rows the selected condition reads, so the panel never offers a figure the
/// condition ignores. The widgets keep their values while hidden: `EventCondition` carries
/// all eight fields whatever its type, and switching away and back must not lose a setting.
void sync_trigger_rows(App& app) {
if (app.trigger_type_drop == nullptr) return;
const TriggerType type =
trigger_type_at(gtk_drop_down_get_selected(GTK_DROP_DOWN(app.trigger_type_drop)));
const WidthCmp cmp =
width_cmp_at(gtk_drop_down_get_selected(GTK_DROP_DOWN(app.trigger_cmp_drop)));
const bool pulse = type == TriggerType::PulseWidth;
const bool window = type == TriggerType::Window;
gtk_widget_set_visible(app.trigger_cmp_row, pulse);
gtk_widget_set_visible(app.trigger_width_row, pulse);
gtk_widget_set_visible(app.trigger_width2_row,
pulse && (cmp == WidthCmp::Within || cmp == WidthCmp::Outside));
gtk_widget_set_visible(app.trigger_level2_row, window);
gtk_widget_set_visible(app.trigger_timeout_row, type == TriggerType::Dropout);
// A band of zero width reduces the condition to a float-equality test on a real-valued
// signal, which is unusable: it fires on the exact value and nothing else, which in practice
// is never. The panel is where that is refused: the second level is pushed at least one
// increment clear of the first, in whichever direction it already lay.
if (window) {
GtkSpinButton* l2 = GTK_SPIN_BUTTON(app.trigger_level2_spin);
const double level = gtk_spin_button_get_value(GTK_SPIN_BUTTON(app.trigger_level_spin));
double step = 0.0;
gtk_spin_button_get_increments(l2, &step, nullptr);
const double other = gtk_spin_button_get_value(l2);
if (std::fabs(other - level) < step) {
gtk_spin_button_set_value(l2, other < level ? level - step : level + step);
}
}
}
void on_trigger_field_changed(GtkWidget*, gpointer user) {
publish_trigger_fields(static_cast<App*>(user));
}
/// The same publish, for the two dropdowns. It needs its own entry point because
/// `notify::<prop>` passes a `GParamSpec*` ahead of the user data while
/// `GtkSpinButton::value-changed` does not: a two-parameter callback on a `notify::`
/// signal reads the pspec as its `App*` and crashes on the first field it touches.
void on_trigger_drop_changed(GObject*, GParamSpec*, gpointer user) {
publish_trigger_fields(static_cast<App*>(user));
}
/// The two dropdowns that change *which rows exist*, as opposed to a value within them.
/// Same `notify::selected` signature trap as `on_trigger_drop_changed`.
void on_trigger_shape_changed(GObject*, GParamSpec*, gpointer user) {
App* app = static_cast<App*>(user);
publish_trigger_fields(app);
sync_trigger_rows(*app);
}
void on_trigger_signal_changed(GObject* obj, GParamSpec*, gpointer user) {
App* app = static_cast<App*>(user);
if (app->updating_panels) return;
(void)obj;
const guint sel = gtk_drop_down_get_selected(GTK_DROP_DOWN(app->trigger_signal_drop));
if (sel == GTK_INVALID_LIST_POSITION) return;
{
std::lock_guard<std::mutex> lock(app->model->mutex());
if (sel >= app->model->channels().size()) return;
app->model->trigger().channel = static_cast<int>(sel);
}
app->trigger_level_touched = false; // a new signal gets a fresh default level
}
void on_trigger_toggled(GtkToggleButton* button, gpointer user) {
App* app = static_cast<App*>(user);
const bool on = gtk_toggle_button_get_active(button);
int signal = -1;
app->model->set_trigger_enabled(on); // takes the lock and mirrors the atomic
{
std::lock_guard<std::mutex> lock(app->model->mutex());
TriggerConfig& tc = app->model->trigger();
tc.armed = on; // trigger mode means "wait for an event", so it starts armed
signal = tc.channel;
}
app->event_held = false;
app->capturing = false;
if (!on) {
app->model->trigger_time_valid().store(false, std::memory_order_relaxed);
// The epoch is *not* written here: the frame tick owns it and states the live answer -- minus
// the newest acquired sample -- after it has bound and acquired, which is the only moment the
// right number is knowable. This used to set it to 0 ("seconds since the stream started"),
// which is no longer a display coordinate anything uses. Nothing is left stale in the
// meantime: the group follows the live edge again from here, and `Figure::layout()` re-derives
// the range from it on every frame.
app->rows->set_time_window(app->rows->time_window()); // and follow the live edge
}
if (on && signal >= 0) {
app->model->set_signal_enabled(static_cast<std::size_t>(signal), true);
}
gtk_revealer_set_reveal_child(GTK_REVEALER(app->trigger_revealer), on);
// The event-history button is part of trigger mode and the pane it opens is *on demand*; leaving
// trigger mode takes the button away and collapses the pane, so going back to streaming is not a
// column of stale events beside the plot. Recording is the other half of that answer, so this is
// `sync_events_availability()`'s call and not the trigger's alone.
sync_events_availability(*app);
gtk_button_set_label(GTK_BUTTON(app->arm_button), on ? "Disarm" : "Arm");
}
/// The way out of trigger mode, from inside the panel that froze the view: it is
/// the toolbar's trigger switch, so there is exactly one piece of state.
void on_go_live(GtkButton*, gpointer user) {
App* app = static_cast<App*>(user);
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(app->trigger_toggle), FALSE);
}
GtkWidget* build_trigger_panel(App& app) {
GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 6);
gtk_widget_set_margin_top(box, 8);
gtk_widget_set_margin_bottom(box, 8);
gtk_widget_set_margin_start(box, 8);
gtk_widget_set_margin_end(box, 8);
GtkWidget* heading = gtk_label_new("Trigger");
gtk_widget_set_halign(heading, GTK_ALIGN_START);
gtk_box_append(GTK_BOX(box), heading);
app.trigger_signal_drop = gtk_drop_down_new(G_LIST_MODEL(gtk_string_list_new(nullptr)), nullptr);
g_signal_connect(app.trigger_signal_drop, "notify::selected",
G_CALLBACK(on_trigger_signal_changed), &app);
gtk_box_append(GTK_BOX(box), labeled_box("signal", app.trigger_signal_drop));
const char* types[] = {"edge", "pulse width", "window", "dropout", nullptr};
app.trigger_type_drop = gtk_drop_down_new_from_strings(types);
gtk_widget_set_tooltip_text(app.trigger_type_drop,
"what counts as an event; the fields below change to suit it");
gtk_box_append(GTK_BOX(box), labeled_box("condition", app.trigger_type_drop));
const char* edges[] = {"rising", "falling", "both", nullptr};
app.trigger_edge_drop = gtk_drop_down_new_from_strings(edges);
gtk_box_append(GTK_BOX(box), labeled_box("edge", app.trigger_edge_drop));
const char* cmps[] = {"<", ">", "within", "outside", nullptr};
app.trigger_cmp_drop = gtk_drop_down_new_from_strings(cmps);
gtk_widget_set_tooltip_text(app.trigger_cmp_drop,
"how the measured pulse width is compared: '<' is the glitch "
"trigger -- a pulse shorter than it should be");
app.trigger_cmp_row = labeled_box("width is", app.trigger_cmp_drop);
gtk_box_append(GTK_BOX(box), app.trigger_cmp_row);
app.trigger_width_spin = spin(0.0, 3600.0, 0.001, 6, 0.001);
app.trigger_width_row = labeled_box("width s", app.trigger_width_spin);
gtk_box_append(GTK_BOX(box), app.trigger_width_row);
app.trigger_width2_spin = spin(0.0, 3600.0, 0.001, 6, 0.002);
app.trigger_width2_row = labeled_box("and s", app.trigger_width2_spin);
gtk_box_append(GTK_BOX(box), app.trigger_width2_row);
// Zero, not the range's minimum: the level this row reads is unset until a channel is chosen,
// and the panel's sync writes the declared range's middle then -- but a dialog that opens at
// -1e12 is a number nobody typed.
app.trigger_level2_spin = spin(-1.0e12, 1.0e12, 0.01, 4, 0.0);
gtk_widget_set_tooltip_text(app.trigger_level2_spin,
"the band's other edge; it is kept at least one increment away "
"from the level, because a band of zero width is not a band: the "
"condition becomes an exact float-equality test against the level, "
"which an ordinary signal never satisfies and a signal sitting at "
"the level satisfies on every transition");
app.trigger_level2_row = labeled_box("to level", app.trigger_level2_spin);
gtk_box_append(GTK_BOX(box), app.trigger_level2_row);
app.trigger_timeout_spin = spin(0.0, 3600.0, 0.001, 6, 0.001);
app.trigger_timeout_row = labeled_box("timeout s", app.trigger_timeout_spin);
gtk_box_append(GTK_BOX(box), app.trigger_timeout_row);
app.trigger_level_spin = spin(-1.0e12, 1.0e12, 0.01, 4, 0.0);
g_signal_connect(app.trigger_level_spin, "value-changed", G_CALLBACK(+[](GtkWidget*, gpointer u) {
static_cast<App*>(u)->trigger_level_touched = true;
}),
&app);
gtk_box_append(GTK_BOX(box), labeled_box("level", app.trigger_level_spin));
app.trigger_pre_spin = spin(0.0, 99.0, 1.0, 0, 50.0);
gtk_box_append(GTK_BOX(box), labeled_box("pre-trigger %", app.trigger_pre_spin));
app.trigger_holdoff_spin = spin(0.0, 3600.0, 0.001, 4,
app.model->config().trigger_holdoff_s);
gtk_box_append(GTK_BOX(box), labeled_box("hold-off s", app.trigger_holdoff_spin));
const char* modes[] = {"normal", "single", nullptr};
app.trigger_mode_drop = gtk_drop_down_new_from_strings(modes);
gtk_box_append(GTK_BOX(box), labeled_box("mode", app.trigger_mode_drop));
app.events_record_switch = gtk_switch_new();
gtk_widget_set_valign(app.events_record_switch, GTK_ALIGN_CENTER);
gtk_switch_set_active(GTK_SWITCH(app.events_record_switch), app.event_recording);
gtk_widget_set_tooltip_text(app.events_record_switch,
"keep every triggered acquisition in the event cache, with a "
"picture of each; the history button goes with it");
g_signal_connect(app.events_record_switch, "notify::active",
G_CALLBACK(on_events_record_toggled), &app);
// Not `labeled_box()`: that expands its child, which is what a spin button wants and what a switch
// must not have -- a switch's natural width *is* its size, so an expanded one stretches into a wide
// pill that reads as a slider. The label takes the slack instead.
{
GtkWidget* row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
GtkWidget* text = gtk_label_new("record events");
gtk_widget_set_halign(text, GTK_ALIGN_START);
gtk_widget_set_hexpand(text, TRUE);
gtk_box_append(GTK_BOX(row), text);
gtk_widget_set_halign(app.events_record_switch, GTK_ALIGN_END);
gtk_widget_set_valign(app.events_record_switch, GTK_ALIGN_CENTER);
gtk_box_append(GTK_BOX(row), app.events_record_switch);
gtk_box_append(GTK_BOX(box), row);
}
app.arm_button = gtk_button_new_with_label("Arm");
g_signal_connect(app.arm_button, "clicked", G_CALLBACK(on_arm_clicked), &app);
gtk_box_append(GTK_BOX(box), app.arm_button);
GtkWidget* live = gtk_button_new_with_label("Go live (trigger off)");
gtk_widget_set_tooltip_text(live, "disable trigger mode and stream continuously");
g_signal_connect(live, "clicked", G_CALLBACK(on_go_live), &app);
gtk_box_append(GTK_BOX(box), live);
for (GtkWidget* w : {app.trigger_level_spin, app.trigger_pre_spin, app.trigger_holdoff_spin,
app.trigger_width_spin, app.trigger_width2_spin,
app.trigger_level2_spin, app.trigger_timeout_spin}) {
g_signal_connect(w, "value-changed", G_CALLBACK(on_trigger_field_changed), &app);
}
g_signal_connect(app.trigger_edge_drop, "notify::selected",
G_CALLBACK(on_trigger_drop_changed), &app);
g_signal_connect(app.trigger_mode_drop, "notify::selected",
G_CALLBACK(on_trigger_drop_changed), &app);
// Searching *is* the trigger's condition applied to history, so it belongs in this panel
// rather than in one of its own: a second place to spell the same condition would be a
// second place to get it wrong. Collapsed by default -- it is not part of running a trigger.
GtkWidget* search_exp = gtk_expander_new("Search the buffer");
gtk_widget_set_tooltip_text(search_exp,
"find every place in the retained buffer where the condition "
"above holds, and step through them");
GtkWidget* sbox = gtk_box_new(GTK_ORIENTATION_VERTICAL, 4);
gtk_widget_set_margin_top(sbox, 4);
GtkWidget* srun = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
app.search_run_button = gtk_button_new_with_label("Search");
gtk_widget_set_hexpand(app.search_run_button, true);
g_signal_connect(app.search_run_button, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
App* a = static_cast<App*>(user);
// Published, not read back off the widgets: the producer's copy is the
// one the live trigger uses, so the search asks exactly its question.
publish_trigger_fields(a);
EventCondition c;
int channel = -1;
{
std::lock_guard<std::mutex> lock(a->model->mutex());
c = a->model->trigger().condition;
channel = a->model->trigger().channel;
}
a->search.start(c, channel);
update_search_panel(*a);
}),
&app);
gtk_box_append(GTK_BOX(srun), app.search_run_button);
app.search_cancel_button = gtk_button_new_with_label("Stop");
gtk_widget_set_sensitive(app.search_cancel_button, false);
g_signal_connect(app.search_cancel_button, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
App* a = static_cast<App*>(user);
a->search.cancel();
update_search_panel(*a);
}),
&app);
gtk_box_append(GTK_BOX(srun), app.search_cancel_button);
gtk_box_append(GTK_BOX(sbox), srun);
app.search_status_label = gtk_label_new("not searched");
gtk_widget_set_halign(app.search_status_label, GTK_ALIGN_START);
gtk_label_set_wrap(GTK_LABEL(app.search_status_label), true);
gtk_widget_add_css_class(app.search_status_label, "dim-label");
gtk_box_append(GTK_BOX(sbox), app.search_status_label);
GtkWidget* snav = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
app.search_prev_button = gtk_button_new_from_icon_name("go-previous-symbolic");
gtk_widget_set_tooltip_text(app.search_prev_button, "the previous hit");
gtk_widget_set_sensitive(app.search_prev_button, false);
g_signal_connect(app.search_prev_button, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
App* a = static_cast<App*>(user);
if (a->search.prev()) search_jump(*a, a->search.hits()[static_cast<std::size_t>(
a->search.cursor())]);
update_search_panel(*a);
}),
&app);
gtk_box_append(GTK_BOX(snav), app.search_prev_button);
app.search_pos_label = gtk_label_new("--");
gtk_widget_set_hexpand(app.search_pos_label, true);
gtk_box_append(GTK_BOX(snav), app.search_pos_label);
app.search_next_button = gtk_button_new_from_icon_name("go-next-symbolic");
gtk_widget_set_tooltip_text(app.search_next_button, "the next hit");
gtk_widget_set_sensitive(app.search_next_button, false);
g_signal_connect(app.search_next_button, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
App* a = static_cast<App*>(user);
// The first press with no cursor goes to the first hit, not the second.
if (a->search.cursor() < 0) {
a->search.select(0);
} else if (!a->search.next()) {
update_search_panel(*a);
return;
}
search_jump(*a, a->search.hits()[static_cast<std::size_t>(
a->search.cursor())]);
update_search_panel(*a);
}),
&app);
gtk_box_append(GTK_BOX(snav), app.search_next_button);
gtk_box_append(GTK_BOX(sbox), snav);
app.search_marks_label = gtk_label_new(" ");
gtk_widget_set_halign(app.search_marks_label, GTK_ALIGN_START);
gtk_widget_add_css_class(app.search_marks_label, "dim-label");
// Ellipsize so the label's minimum width is bounded: without this, the longest text
// (~58 chars) would widen the side pane just as `search_status_label` would without its own
// wrap — the same fix the statistics label carries at line 2780.
gtk_label_set_ellipsize(GTK_LABEL(app.search_marks_label), PANGO_ELLIPSIZE_END);
gtk_box_append(GTK_BOX(sbox), app.search_marks_label);
gtk_expander_set_child(GTK_EXPANDER(search_exp), sbox);
gtk_box_append(GTK_BOX(box), search_exp);
g_signal_connect(app.trigger_type_drop, "notify::selected",
G_CALLBACK(on_trigger_shape_changed), &app);
g_signal_connect(app.trigger_cmp_drop, "notify::selected",
G_CALLBACK(on_trigger_shape_changed), &app);
// The clamp lives in `sync_trigger_rows()`, so the two level spins have to run it rather
// than the plain field publish.
for (GtkWidget* w : {app.trigger_level_spin, app.trigger_level2_spin}) {
g_signal_connect(w, "value-changed", G_CALLBACK(+[](GtkWidget*, gpointer u) {
sync_trigger_rows(*static_cast<App*>(u));
}),
&app);
}
sync_trigger_rows(app);
return box;
}
} // namespace ui_impl
} // namespace udpscope
+166
View File
@@ -0,0 +1,166 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
//
// The small reusable pieces of GTK chrome: a menu row and the three rows built from it, a labelled\n// panel row, the one stylesheet rule the app needs, and the modal dialog frame every dialog here is\n// built in. They know nothing about this app beyond the widget types they return.
#include "ui_app.hpp"
#include <gtk/gtk.h>
#include <string>
namespace udpscope {
namespace ui_impl {
/// Installs the one style rule the app needs: a signal row that is as short as its
/// contents.
///
/// Adwaita gives a button a 34 px minimum height and a list row its own padding, and
/// those -- not the widgets in it -- are what set a signal row's height, so no amount
/// of trimming margins in C makes the list shorter. A handful of signals then filled
/// the whole side panel. Scoped to `.udpscope-compact` so it cannot reach any other
/// button in the window.
void install_css() {
static const char kCss[] =
"row.udpscope-compact { min-height: 0; padding: 1px 0; }\n"
// A popover menu row: Adwaita gives a button a 34 px minimum height and 8 px of padding,
// which is what made the overflow read as a stack of dialogs rather than a menu. The
// shortcut label is a plain .dim-label at the row's right, so all that is left to trim
// is the row itself.
"popover.udpscope-menu button { min-height: 0; padding: 3px 8px; }\n"
"popover.udpscope-menu button > box { min-height: 0; }\n"
"popover.udpscope-menu button label { min-height: 0; }\n"
"popover.udpscope-menu image { min-width: 12px; min-height: 0; }\n"
"popover.udpscope-menu separator { margin: 4px 0; }\n"
// The events pane: the row's time is what a person scans for, so it is the one thing in the
// column that is not dim; the thumbnail gets an edge, or a dark plot on a dark pane has none.
".udpscope-event-title { font-weight: bold; }\n"
".udpscope-event-thumb { border: 1px solid alpha(currentColor, 0.15); border-radius: 4px; }\n"
"row.udpscope-compact button { min-height: 20px; min-width: 20px; padding: 0 3px; }\n"
"row.udpscope-compact checkbutton { min-height: 20px; }\n";
GtkCssProvider* p = gtk_css_provider_new();
gtk_css_provider_load_from_string(p, kCss);
gtk_style_context_add_provider_for_display(gdk_display_get_default(),
GTK_STYLE_PROVIDER(p),
GTK_STYLE_PROVIDER_PRIORITY_APPLICATION);
g_object_unref(p);
}
/// Keeps a menu row's check in step with its toggle. The check keeps its allocation when it is
/// off (opacity, not visibility), so a checked and an unchecked row's labels line up.
void on_menu_row_toggled(GtkToggleButton* button, gpointer) {
GtkWidget* check = GTK_WIDGET(g_object_get_data(G_OBJECT(button), "udpscope-check"));
if (check != nullptr) {
gtk_widget_set_opacity(check, gtk_toggle_button_get_active(button) ? 1.0 : 0.0);
}
}
/// One row of a popover menu, laid out the way a GNOME menu lays one out: the label at the
/// left, the shortcut at the right when the action has one, and -- for a row that toggles
/// something -- a check in its own column. Returned as a box for the caller to hand to
/// `gtk_button_set_child()`; `.udpscope-menu` in install_css() is what trims the height.
GtkWidget* menu_row(const char* label, const char* accel, bool with_check,
GtkWidget** out_check) {
GtkWidget* box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 10);
if (with_check) {
GtkWidget* check = gtk_image_new_from_icon_name("object-select-symbolic");
gtk_widget_set_opacity(check, 0.0);
gtk_box_append(GTK_BOX(box), check);
if (out_check != nullptr) *out_check = check;
}
GtkWidget* text = gtk_label_new(label);
gtk_label_set_xalign(GTK_LABEL(text), 0.0f);
gtk_widget_set_hexpand(text, true);
gtk_box_append(GTK_BOX(box), text);
if (accel != nullptr) {
GtkWidget* key = gtk_label_new(accel);
gtk_label_set_xalign(GTK_LABEL(key), 1.0f);
gtk_widget_add_css_class(key, "dim-label");
gtk_box_append(GTK_BOX(box), key);
}
return box;
}
/// A plain menu row. `accel` is what the app binds the action to (see shortcuts.cpp) -- it is
/// shown, never bound here: the key handling stays in the one controller that refuses a key
/// while a text field has focus.
GtkWidget* menu_button(const char* label, const char* accel) {
GtkWidget* button = gtk_button_new();
// The check column is reserved even though this row cannot be checked: a menu whose labels
// start at two different x -- one for the rows that can be checked and one for the rest --
// is what "the text is not left-aligned" looks like.
gtk_button_set_child(GTK_BUTTON(button), menu_row(label, accel, true, nullptr));
gtk_widget_add_css_class(button, "flat");
gtk_widget_set_hexpand(button, true);
gtk_widget_set_halign(button, GTK_ALIGN_FILL);
return button;
}
/// A menu row that toggles something, with the check menu_row() reserves the column for. The
/// initial state is set *before* the handler is connected: a programmatic `set_active()` emits
/// `toggled`, the handler acts on widgets the caller has not built yet (the signal list's
/// revealer is created later in run_ui()), and a row that is created on must not reach it.
GtkWidget* menu_toggle(const char* label, const char* accel, bool active) {
GtkWidget* button = gtk_toggle_button_new();
GtkWidget* check = nullptr;
gtk_button_set_child(GTK_BUTTON(button), menu_row(label, accel, true, &check));
gtk_widget_add_css_class(button, "flat");
gtk_widget_set_hexpand(button, true);
gtk_widget_set_halign(button, GTK_ALIGN_FILL);
g_object_set_data(G_OBJECT(button), "udpscope-check", check);
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(button), active);
on_menu_row_toggled(GTK_TOGGLE_BUTTON(button), nullptr);
g_signal_connect(button, "toggled", G_CALLBACK(on_menu_row_toggled), nullptr);
return button;
}
GtkWidget* menu_separator() {
return gtk_separator_new(GTK_ORIENTATION_HORIZONTAL);
}
GtkWidget* labeled_box(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_widget_set_hexpand(child, true);
gtk_box_append(GTK_BOX(box), child);
return box;
}
/// A modal, transient window with a vertical content box; the caller appends its
/// controls and then a button row. GTK 4.10 deprecated `GtkDialog` and
/// `GtkMessageDialog`, so the dialogs here are built from a plain window.
GtkWidget* dialog_window(GtkWidget* parent, const char* title) {
GtkWidget* w = gtk_window_new();
gtk_window_set_title(GTK_WINDOW(w), title);
if (parent != nullptr) gtk_window_set_transient_for(GTK_WINDOW(w), GTK_WINDOW(parent));
gtk_window_set_modal(GTK_WINDOW(w), TRUE);
gtk_window_set_default_size(GTK_WINDOW(w), 340, -1);
GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 10);
gtk_widget_set_margin_top(box, 12);
gtk_widget_set_margin_bottom(box, 12);
gtk_widget_set_margin_start(box, 12);
gtk_widget_set_margin_end(box, 12);
gtk_window_set_child(GTK_WINDOW(w), box);
return w;
}
/// Appends `button` to a right-aligned row at the end of the dialog's box.
void dialog_add_button(GtkWidget* window, GtkWidget* button) {
GtkWidget* box = gtk_window_get_child(GTK_WINDOW(window));
GtkWidget* last = gtk_widget_get_last_child(box);
GtkWidget* row = nullptr;
if (last != nullptr && g_object_get_data(G_OBJECT(last), "udpscope-dialog-buttons") != nullptr) {
row = last;
} else {
row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
gtk_widget_set_halign(row, GTK_ALIGN_END);
g_object_set_data(G_OBJECT(row), "udpscope-dialog-buttons", GINT_TO_POINTER(1));
gtk_box_append(GTK_BOX(box), row);
}
gtk_box_append(GTK_BOX(row), button);
}
} // namespace ui_impl
} // namespace udpscope
+353
View File
@@ -0,0 +1,353 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#include "waveform.hpp"
#include <algorithm>
#include <cerrno>
#include <cmath>
#include <cstdlib>
#include <cstring>
#include <sys/stat.h>
#include "layout_store.hpp"
namespace udpscope {
namespace {
/// Reads `'<f4'`, `'<f8'` … out of one dtype entry and returns the bytes per element, 0 for a type
/// this loader does not know. The writer emits little-endian only, and the check that the host is
/// little-endian is the writer's own (`static_assert` there).
std::size_t dtype_bytes(const std::string& type) {
if (type == "<f8" || type == "<i8" || type == "<u8") return 8;
if (type == "<f4" || type == "<i4" || type == "<u4") return 4;
if (type == "<i2" || type == "<u2") return 2;
if (type == "|i1" || type == "|u1" || type == "|b1") return 1;
return 0;
}
/// The field list of a structured dtype: `[('time', '<f8'), ('ch1', '<f4')]` -> {name, type} pairs.
/// Deliberately a scanner rather than a parser: a `.npy` header is a Python dict literal, and the
/// three keys this reader needs are the only thing it may trust.
bool parse_fields(const std::string& descr, std::vector<std::pair<std::string, std::string>>* out) {
std::size_t i = 0;
while (true) {
const std::size_t open = descr.find("('", i);
if (open == std::string::npos) break;
std::size_t cursor = open + 2;
std::string name;
while (cursor < descr.size() && descr[cursor] != '\'') name.push_back(descr[cursor++]);
if (cursor >= descr.size()) return false;
++cursor; // past the name's closing quote, which a comma should follow
const std::size_t type_open = descr.find('\'', cursor);
if (type_open == std::string::npos) return false;
std::string type;
cursor = type_open + 1;
while (cursor < descr.size() && descr[cursor] != '\'') type.push_back(descr[cursor++]);
if (cursor >= descr.size()) return false;
out->emplace_back(name, type);
i = cursor + 1;
}
return !out->empty();
}
} // namespace
WaveformReader::~WaveformReader() { close(); }
void WaveformReader::close() noexcept {
if (f_ != nullptr) {
std::fclose(f_);
f_ = nullptr;
}
}
bool WaveformReader::open(const std::string& path, std::string* error) {
close();
f_ = std::fopen(path.c_str(), "rb");
if (f_ == nullptr) {
if (error != nullptr) *error = "cannot open '" + path + "': " + std::strerror(errno);
return false;
}
char magic[6] = {};
char version[2] = {};
if (std::fread(magic, 1, 6, f_) != 6 || std::fread(version, 1, 2, f_) != 2 ||
std::memcmp(magic, "\x93NUMPY", 6) != 0) {
if (error != nullptr) *error = "'" + path + "' is not a NumPy file";
close();
return false;
}
std::uint8_t len_bytes[4] = {};
std::size_t header_len = 0;
if (version[0] == 1) {
if (std::fread(len_bytes, 1, 2, f_) != 2) {
if (error != nullptr) *error = "'" + path + "' has a truncated header";
close();
return false;
}
header_len = static_cast<std::size_t>(len_bytes[0]) | (std::size_t(len_bytes[1]) << 8);
} else {
// v2.0 (and v3.0, which is UTF-8 and the same layout): a 4-byte little-endian length.
if (std::fread(len_bytes, 1, 4, f_) != 4) {
if (error != nullptr) *error = "'" + path + "' has a truncated header";
close();
return false;
}
header_len = std::size_t(len_bytes[0]) | (std::size_t(len_bytes[1]) << 8) |
(std::size_t(len_bytes[2]) << 16) | (std::size_t(len_bytes[3]) << 24);
}
std::string header(header_len, '\0');
if (header_len == 0 || std::fread(header.data(), 1, header_len, f_) != header_len) {
if (error != nullptr) *error = "'" + path + "' has a truncated header";
close();
return false;
}
// `descr` is either the field list (a structured array, which is what this project writes) or a
// single type string (a plain array, which carries no names and so cannot say what its columns
// are).
std::vector<std::pair<std::string, std::string>> fields;
const std::size_t descr_at = header.find("'descr':");
if (descr_at == std::string::npos) {
if (error != nullptr) *error = "'" + path + "' has no dtype";
close();
return false;
}
const std::size_t descr_end = header.find('\'', descr_at + 9);
const std::size_t bracket = header.find('(', descr_at);
if (bracket != std::string::npos && (descr_end == std::string::npos || bracket < descr_end + 1)) {
if (!parse_fields(header.substr(descr_at), &fields)) {
if (error != nullptr) *error = "'" + path + "': could not read the column names";
close();
return false;
}
} else {
if (error != nullptr) {
*error = "'" + path + "' is a plain array, not a udpscope export: its columns have no names";
}
close();
return false;
}
// The two time columns by name, the rest as signals -- and every one of them must be a type this
// loader can widen.
bool have_time = false;
bool have_abs = false;
record_bytes_ = 0;
rows_read_ = 0;
names_.clear();
field_bytes_.clear();
for (const auto& [name, type] : fields) {
const std::size_t bytes = dtype_bytes(type);
if (bytes == 0) {
if (error != nullptr) *error = "'" + path + "' has a column of an unsupported type '" + type + "'";
close();
return false;
}
record_bytes_ += bytes;
field_bytes_.push_back(bytes);
if (name == "time") {
have_time = true;
} else if (name == "abs_seconds") {
have_abs = true;
} else {
names_.push_back(name);
}
}
if (!have_time || !have_abs) {
if (error != nullptr) {
*error = "'" + path + "' is not a udpscope export: no 'time' and 'abs_seconds' columns";
}
close();
return false;
}
const std::size_t shape_at = header.find("'shape':");
if (shape_at == std::string::npos) {
if (error != nullptr) *error = "'" + path + "' has no shape";
close();
return false;
}
const std::size_t open_paren = header.find('(', shape_at);
if (open_paren == std::string::npos) {
if (error != nullptr) *error = "'" + path + "' has an unreadable shape";
close();
return false;
}
rows_ = static_cast<std::size_t>(std::strtoull(header.c_str() + open_paren + 1, nullptr, 10));
// The rate comes from the first two stamps, and reading them needs the file positioned: peek, then
// rewind. An export's time column is the ring's own stamps, which are uniform -- one dt describes
// all of it.
const long data_at = std::ftell(f_);
if (rows_ >= 2) {
std::vector<char> first(record_bytes_);
if (std::fread(first.data(), 1, record_bytes_, f_) == record_bytes_) {
double t0 = 0.0;
std::memcpy(&t0, first.data(), 8);
if (std::fread(first.data(), 1, record_bytes_, f_) == record_bytes_) {
double t1 = 0.0;
std::memcpy(&t1, first.data(), 8);
sample_dt_ = std::abs(t1 - t0);
}
}
std::fseek(f_, data_at, SEEK_SET);
}
return true;
}
bool WaveformReader::next(double* time, double* abs_seconds, float* values) {
if (f_ == nullptr || record_bytes_ == 0) return false;
if (rows_read_ >= rows_) return false;
std::vector<char> record(record_bytes_);
if (std::fread(record.data(), 1, record_bytes_, f_) != record_bytes_) return false;
const char* p = record.data();
std::memcpy(time, p, 8);
p += 8;
std::memcpy(abs_seconds, p, 8);
p += 8;
// The signals are the fields after the two time columns, each at its own width: `write_npy()`
// emits `<f4`, and a file an external tool widened to `<f8` is read as one rather than mis-sliced.
for (std::size_t i = 0; i < names_.size(); ++i) {
const std::size_t bytes = field_bytes_[2 + i];
switch (bytes) {
case 8: {
double v = 0.0;
std::memcpy(&v, p, 8);
values[i] = static_cast<float>(v);
break;
}
case 4: {
float v = 0.0f;
std::memcpy(&v, p, 4);
values[i] = v;
break;
}
default: {
// A narrower integer column: widen through the integer types rather than reinterpreting.
long long v = 0;
switch (bytes) {
case 2: {
short s = 0;
std::memcpy(&s, p, 2);
v = s;
break;
}
default:
v = static_cast<signed char>(*p);
break;
}
values[i] = static_cast<float>(v);
break;
}
}
p += bytes;
}
++rows_read_;
return true;
}
bool load_waveform_into_model(ScopeModel& model, const std::string& path,
WaveformLoadReport* report, std::string* error) {
WaveformReader reader;
if (!reader.open(path, error)) return false;
if (reader.names().empty() || reader.rows() == 0) {
if (error != nullptr) *error = "'" + path + "' holds no samples";
return false;
}
const double dt = reader.sample_dt();
const double rate = dt > 0.0 ? 1.0 / dt : 0.0;
// No lock held across this: every model call below takes the model's own, and they are the ones
// that must be atomic with each other -- holding it here deadlocked on the first of them.
// The span first: the plan that the enables below build has to cover the whole file, or the oldest
// samples are written past the ring's end and the file arrives as its own tail.
std::vector<SignalInfo> signals;
signals.reserve(reader.names().size());
for (const std::string& name : reader.names()) {
SignalInfo s;
s.name = name;
s.sampling_rate = rate > 0.0 ? rate : 1.0;
signals.push_back(std::move(s));
}
model.set_target_span(reader.rows() * (dt > 0.0 ? dt : 0.001));
model.set_signals(std::move(signals));
for (std::size_t i = 0; i < reader.names().size(); ++i) model.set_signal_enabled(i, true);
const std::size_t channels = model.channels().size();
if (channels == 0) {
if (error != nullptr) *error = "'" + path + "': the model built no channels for its columns";
return false;
}
std::vector<float> values(reader.names().size(), 0.0f);
std::size_t pushed = 0;
double stamp = 0.0;
double abs_stamp = 0.0;
double first = 0.0;
double last = 0.0;
while (reader.next(&stamp, &abs_stamp, values.data())) {
if (pushed == 0) first = stamp;
last = stamp;
model.push_loaded_slot(values, stamp, abs_stamp);
++pushed;
}
reader.close();
model.set_epoch_if_unset(first);
// The newest stamp is "now": the axis then reads `[-window, 0]`, which is the window the export was
// taken from.
model.set_display_epoch(pushed > 0 ? last : 0.0);
if (report != nullptr) {
report->signals = channels;
report->samples = pushed;
report->span_s = pushed > 0 ? std::abs(last - first) : 0.0;
report->rate_hz = rate;
report->first_time = first;
report->last_time = last;
}
if (pushed == 0) {
if (error != nullptr) *error = "'" + path + "' could not be read to the end";
return false;
}
return true;
}
namespace {
std::string waveforms_path() {
const std::string dir = config_dir();
::mkdir(dir.c_str(), 0755); // EEXIST is the common case and not an error
return dir + "/waveforms.txt";
}
} // namespace
std::vector<std::string> recent_waveforms() {
std::vector<std::string> out;
std::FILE* f = std::fopen(waveforms_path().c_str(), "rb");
if (f == nullptr) return out;
char line[4096];
while (std::fgets(line, sizeof(line), f) != nullptr) {
std::string path(line);
while (!path.empty() && (path.back() == '\n' || path.back() == '\r')) path.pop_back();
if (!path.empty()) out.push_back(path);
}
std::fclose(f);
return out;
}
void remember_waveform(const std::string& path) {
std::vector<std::string> list = recent_waveforms();
list.erase(std::remove(list.begin(), list.end(), path), list.end());
list.insert(list.begin(), path);
// A list long enough to be a history, short enough to be a menu: twenty entries is more than a
// session's worth of exports and far less than a directory's.
if (list.size() > 20) list.resize(20);
std::FILE* f = std::fopen(waveforms_path().c_str(), "wb");
if (f == nullptr) return;
for (const std::string& p : list) std::fprintf(f, "%s\n", p.c_str());
std::fclose(f);
}
} // namespace udpscope
+84
View File
@@ -0,0 +1,84 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once
#include "model.hpp"
#include <cstdio>
#include <string>
#include <vector>
namespace udpscope {
/// A `.npy` written by `write_npy()`: one packed structured array whose *dtype names the columns*
/// (`time`, `abs_seconds`, then one field per signal). That is what makes an export readable again
/// without a sidecar: the signal names are in the file.
///
/// Read one record at a time, not into a table: an export of a wide window on a fast stream is
/// hundreds of megabytes, and the model's rings are the copy that matters -- a second full copy in
/// a vector would double the peak for nothing.
class WaveformReader {
public:
WaveformReader() = default;
~WaveformReader();
WaveformReader(const WaveformReader&) = delete;
WaveformReader& operator=(const WaveformReader&) = delete;
/// Parses the header and rewinds to the first record. On failure `error` says what the file is
/// (not a NumPy file, not a udpscope export, truncated) and nothing else is usable.
bool open(const std::string& path, std::string* error);
void close() noexcept;
std::size_t rows() const noexcept { return rows_; }
/// The *signal* columns, in file order: `time` and `abs_seconds` are the two time columns and are
/// read through `next()`, not listed here.
const std::vector<std::string>& names() const noexcept { return names_; }
/// Seconds between the first two records: an export's `time` column is the ring's own stamps, which
/// are uniform at the stored rate. 0 when there are fewer than two rows.
double sample_dt() const noexcept { return sample_dt_; }
/// Reads the next record into `time`, `abs_seconds` and `values` (`names().size()` of them).
/// False at the end of the file or on a short read.
bool next(double* time, double* abs_seconds, float* values);
private:
std::FILE* f_ = nullptr;
std::size_t rows_ = 0;
std::size_t rows_read_ = 0;
std::size_t record_bytes_ = 0;
double sample_dt_ = 0.0;
std::vector<std::string> names_;
/// Bytes per *field*, in file order: the two time columns then the signals. Kept so a column an
/// external tool widened to `<f8` is read as one rather than mis-sliced by an assumed width.
std::vector<std::size_t> field_bytes_;
};
/// What a load put into the model, for the log and the status line.
struct WaveformLoadReport {
std::size_t signals = 0;
std::size_t samples = 0;
double span_s = 0.0;
double rate_hz = 0.0;
double first_time = 0.0;
double last_time = 0.0;
};
/// Loads a `.npy` export into `model` as if it had been streamed: one signal and channel per
/// column, every sample pushed into the live ring, and the display epoch pinned to the file's last
/// stamp so the axis reads `[-window, 0]` exactly as the export did.
///
/// Takes `model.mutex()` for the whole load. There is no acquisition behind this -- nothing arrives
/// afterwards -- so the caller must not start one, and the UI must not expect frames.
bool load_waveform_into_model(ScopeModel& model, const std::string& path,
WaveformLoadReport* report, std::string* error);
/// The waveform files opened before, newest first: one path per line in
/// `<config_dir>/waveforms.txt`. Written by `remember_waveform()`, which is called on every
/// successful open -- including the one the command line asked for, so the list fills up from
/// either entry point.
std::vector<std::string> recent_waveforms();
void remember_waveform(const std::string& path);
} // namespace udpscope