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
@@ -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
|
||||
Reference in new issue
Block a user