udpscope: an oscilloscope view for a UDPStreamer source
GTK4 and libadwaita. It receives one UDP stream through the vendored udpsclient, buffers it in a large ring, and draws it with the vendored plotter: a trigger with a pre/post event window, a pause that freezes the window, cursors, measurements, math channels in a node editor, an event cache, named configurations, preferences, and an export of the visible window to .npy/.csv. Waveform files can be opened and analysed in place. The interface is two threads -- a producer that owns the socket and the GTK main thread that owns every widget and the GL context -- sharing one model under one mutex, with samples crossing through lock-free rings. deps/plotter is a submodule pinned to the commit this tree builds against; deps/udpsclient is vendored in place. docs/claude/ documents the rules that cost a defect to learn, and SPECS.md is the requirement document.
This commit is contained in:
1 parent
8f54841c2c
commit
0e256764d5
41 files changed
+2081
-154
No files matched your search
+80
-31
@@ -7,6 +7,7 @@
|
||||
|
||||
#include <gtk/gtk.h>
|
||||
|
||||
#include <limits>
|
||||
#include <string>
|
||||
|
||||
namespace udpscope {
|
||||
@@ -47,7 +48,10 @@ void sync_events_availability(App& app) {
|
||||
// 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;
|
||||
// ... or whenever the cache *has* something: an event on disk outlives the switch that wrote it, and
|
||||
// the button that shows it must not go with the switch. `events_cached` is refreshed once a second
|
||||
// from the tick -- the store's list is a directory read, not a per-frame question.
|
||||
const bool available = app.event_recording || app.replaying || app.events_cached;
|
||||
gtk_widget_set_visible(app.events_toggle, available);
|
||||
if (!available) {
|
||||
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(app.events_toggle), FALSE);
|
||||
@@ -116,7 +120,12 @@ bool record_event(App& app) {
|
||||
meta.samples = table.rows();
|
||||
meta.seq = app.model->event_seq();
|
||||
|
||||
const std::string file = app.event_store->save(table, meta);
|
||||
// 0 means "keep everything", which `save()` cannot be asked for with a count: it is the largest
|
||||
// number a size_t holds, and trimming then never finds anything beyond it.
|
||||
const std::size_t keep = app.settings.event_cache_max <= 0
|
||||
? std::numeric_limits<std::size_t>::max()
|
||||
: static_cast<std::size_t>(app.settings.event_cache_max);
|
||||
const std::string file = app.event_store->save(table, meta, keep);
|
||||
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.
|
||||
@@ -171,7 +180,8 @@ bool replay_event(App& app, const EventRecord& record) {
|
||||
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 axis is the event's own and the tick pins it there (`pin_event_window`): nothing to frame or
|
||||
// home here beyond the window control's own reading, which the tick keeps in step.
|
||||
// The pane is available through a replay, and this is what keeps it open.
|
||||
sync_events_availability(app);
|
||||
set_notice(app, "replaying " + record.stem + " (" +
|
||||
@@ -376,42 +386,72 @@ bool build_event_group(App& app, const std::vector<EventRecord>& records, EventG
|
||||
app.rows_b->set_row_count(rows);
|
||||
if (app.area_b != nullptr) app.area_b->set_figure(&app.rows_b->figure());
|
||||
}
|
||||
// The user's layout is read **first**, and then every slot is freed on both plots, before the group
|
||||
// assigns its own. The order matters: the layout is what says which slots the group fills, so
|
||||
// clearing first would clear the very thing being read (it did -- every slot came back empty and the
|
||||
// comparison drew nothing). And the clear itself is what stops a row carrying the previous view's
|
||||
// traces -- an accumulation's layers, a live session's signals -- into the new one, where a trace
|
||||
// left behind is an extra legend entry in a colour belonging to nothing on screen.
|
||||
struct SlotPlan {
|
||||
std::string name;
|
||||
std::string label;
|
||||
int colour = 0;
|
||||
};
|
||||
// ... and only the *base* layout is read back: a slot is part of it when its name carries no layer
|
||||
// suffix or layer 0's own (`(A)`). A group's *extra* layers -- `(B)`, `#2` -- are that group's own
|
||||
// artefacts, and reading them back as the user's arrangement is how an accumulation's layers survived
|
||||
// into the next comparison: extra legend entries, in colours belonging to nothing.
|
||||
const auto base_slot = [](const std::string& name) {
|
||||
return name == layer_base_name(name) ||
|
||||
(name.size() > 4 && name.compare(name.size() - 4, 4, " (A)") == 0);
|
||||
};
|
||||
std::vector<std::array<SlotPlan, RowSet::kSlotsPerRow>> layout(static_cast<std::size_t>(rows));
|
||||
for (int r = 0; r < rows; ++r) {
|
||||
for (int s = 0; s < RowSet::kSlotsPerRow; ++s) {
|
||||
const std::size_t at = static_cast<std::size_t>(s);
|
||||
const std::string& name = app.rows->row(r).signal_name[at];
|
||||
if (!base_slot(name)) continue;
|
||||
layout[static_cast<std::size_t>(r)][at] =
|
||||
SlotPlan{name, app.rows->row(r).name[at], app.rows->row(r).color_index[at]};
|
||||
}
|
||||
}
|
||||
const auto clear = [](RowSet* rows) {
|
||||
if (rows == nullptr) return;
|
||||
for (int r = 0; r < rows->row_count(); ++r) {
|
||||
for (int s = 0; s < RowSet::kSlotsPerRow; ++s) rows->assign(r, s, "", "", 0);
|
||||
}
|
||||
};
|
||||
clear(app.rows);
|
||||
clear(app.rows_b.get());
|
||||
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;
|
||||
const std::array<SlotPlan, RowSet::kSlotsPerRow>& plan = layout[static_cast<std::size_t>(r)];
|
||||
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 (plan[at].name.empty()) continue;
|
||||
const std::string base = base_name(plan[at].name);
|
||||
app.rows->assign(r, s, base + suffix(0), plan[at].label, plan[at].colour);
|
||||
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]);
|
||||
app.rows_b->assign(r, s, base + suffix(1), plan[at].label + suffix(1), plan[at].colour);
|
||||
continue;
|
||||
}
|
||||
// The extra layers go into free slots of **this** row, and nowhere else. A row's signals are what
|
||||
// its colours and its legend speak for, so a layer placed in another row put a signal there in the
|
||||
// colour of the signal it came from -- a multi-row layout whose other rows came out "wrong". A row
|
||||
// with no room accumulates as far as it can and stops.
|
||||
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;
|
||||
}
|
||||
}
|
||||
for (int ss = 0; ss < RowSet::kSlotsPerRow; ++ss) {
|
||||
if (!app.rows->row(r).signal_name[static_cast<std::size_t>(ss)].empty()) continue;
|
||||
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]);
|
||||
if (found_slot < 0) break; // this row is full: its signal accumulates as far as it can
|
||||
app.rows->assign(r, found_slot, base + suffix(layer), plan[at].label + suffix(layer),
|
||||
plan[at].colour);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -435,7 +475,7 @@ bool build_event_group(App& app, const std::vector<EventRecord>& records, EventG
|
||||
rebuild_layout_editor(app);
|
||||
rebuild_signal_panels(app);
|
||||
// Fit on open, once: the group's own span, framed.
|
||||
apply_time_window(app, span);
|
||||
// As above: the tick pins the axis to the group's own span and marks the trigger.
|
||||
sync_events_availability(app);
|
||||
return true;
|
||||
}
|
||||
@@ -680,16 +720,25 @@ void refresh_events_pane(App& app) {
|
||||
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);
|
||||
std::snprintf(sub, sizeof(sub), "%zu signals · %zu samples", r.signals, r.samples);
|
||||
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);
|
||||
|
||||
// A third line: the rate it was recorded at, where its edge sits and which latch it was -- what
|
||||
// tells two events of the same length apart once the picture is too small to read.
|
||||
char third[160];
|
||||
std::snprintf(third, sizeof(third), "%.4g S/s · edge %.4g s · #%llu", r.rate_hz, r.edge,
|
||||
static_cast<unsigned long long>(r.seq));
|
||||
GtkWidget* extra = gtk_label_new(third);
|
||||
gtk_widget_add_css_class(extra, "dim-label");
|
||||
gtk_widget_set_halign(extra, GTK_ALIGN_START);
|
||||
gtk_label_set_max_width_chars(GTK_LABEL(extra), 22);
|
||||
gtk_label_set_ellipsize(GTK_LABEL(extra), PANGO_ELLIPSIZE_END);
|
||||
gtk_box_append(GTK_BOX(box), extra);
|
||||
gtk_box_append(GTK_BOX(line), box);
|
||||
|
||||
gtk_list_box_row_set_child(GTK_LIST_BOX_ROW(row), line);
|
||||
|
||||
Reference in new issue
Block a user