working on todo.md features
This commit is contained in:
1 parent
210d124814
commit
8f54841c2c
110 files changed
+15932
-6576
No files matched your search
+98
-2
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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";
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/<source>/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
@@ -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><host>_<port>.toml</tt>.
|
||||
unset. Each source has a directory <tt><host>_<port>/</tt> holding one
|
||||
<tt><name>.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><host>_<port>.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 |
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
+1302
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||
@@ -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
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in new issue
Block a user