Files
udpscope/src/ui_events.cpp
T
2026-11-03 06:56:24 +01:00

1056 lines
54 KiB
C++

// 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 <limits>
#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.
// ... 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;
dock_events_set_sensitive(app, available);
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();
// 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.
set_notice(app, "event cached: " + std::to_string(table.columns.size()) + " signals, " +
std::to_string(table.rows()) + " samples",
3);
// The dock follows every latch; the pane's rows (with pictures) only while the pane is on screen:
// rebuilding them 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);
} else {
dock_rebuild_events(app);
}
return true;
}
/// Remembers the live view's settings before a file view is opened over them.
///
/// A no-op unless the view being left *is* the live one: replaying a second event from inside the
/// first, or a group opened from a replay, must not overwrite the snapshot with a file's settings --
/// those are the very values *Go live* exists to undo.
void snapshot_live_state(App& app) {
if (app.replaying || app.model == nullptr || app.rows == nullptr) return;
App::LiveState& s = app.live_state;
s.valid = true;
s.window_s = app.rows->time_window();
s.trigger_on = app.trigger_toggle != nullptr &&
gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(app.trigger_toggle)) != FALSE;
s.level_touched = app.trigger_level_touched;
std::lock_guard<std::mutex> lock(app.model->mutex());
s.trigger = app.model->trigger();
s.trigger_channel = s.trigger.channel >= 0 &&
s.trigger.channel < static_cast<int>(app.model->channels().size())
? app.model->channels()[static_cast<std::size_t>(s.trigger.channel)].name
: std::string();
}
/// Puts those settings back on the way out of a file view.
///
/// Deferred by `go_live_again()` to the first tick that sees the stream's *own* channel list, which
/// is what makes this the last writer over the two things that would otherwise overwrite it: the
/// deferred layout re-apply (which ends in `apply_time_window()` with the *saved* window, not the
/// live one) and `rebuild_trigger_signals()`, which, finding a `channel` of `-1` after a rebuild
/// renumbered or renamed the channels, silently settles on the first signal and re-defaults the level
/// with it.
///
/// Inside, the order is the whole of it:
///
/// - The configuration goes in under the lock, with the channel resolved from the stored *name*.
/// `enabled` and `armed` are deliberately *not* written: they belong to the toolbar toggle, which
/// owns an atomic mirror of `enabled` that a plain field write would leave stale.
/// - The two controls a file view actually moved are restated from it -- the signal dropdown and the
/// level spin. The rest of the panel was never touched, and writing to a control fires its handler,
/// so the ones that have nothing to put back are left alone.
/// - `apply_time_window()`, the only path allowed to restate the time scale; it also drops the event
/// pin the file view left on the axis and re-arms the live follow.
/// - The toggle last, because its handler reads the configuration it is turning on.
void restore_live_state(App& app) {
App::LiveState& s = app.live_state;
if (!s.valid) return;
s.valid = false; // consumed: the next way in takes its own snapshot
int channel = -1;
{
std::lock_guard<std::mutex> lock(app.model->mutex());
const std::vector<ChannelInfo>& channels = app.model->channels();
for (std::size_t i = 0; i < channels.size(); ++i) {
if (channels[i].name == s.trigger_channel) {
channel = static_cast<int>(i);
break;
}
}
TriggerConfig& tc = app.model->trigger();
const bool enabled = tc.enabled;
const bool armed = tc.armed;
tc = s.trigger;
tc.enabled = enabled;
tc.armed = armed;
tc.channel = channel;
}
app.trigger_level_touched = s.level_touched;
app.updating_panels = true;
if (channel >= 0 && app.trigger_signal_drop != nullptr) {
gtk_drop_down_set_selected(GTK_DROP_DOWN(app.trigger_signal_drop),
static_cast<guint>(channel));
}
if (app.trigger_level_spin != nullptr) {
gtk_spin_button_set_value(GTK_SPIN_BUTTON(app.trigger_level_spin), s.trigger.condition.level);
}
app.updating_panels = false;
apply_time_window(app, s.window_s);
if (app.trigger_toggle != nullptr &&
(gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(app.trigger_toggle)) != FALSE) !=
s.trigger_on) {
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(app.trigger_toggle), s.trigger_on ? TRUE : FALSE);
}
}
/// 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;
// Before the stop, while the live view is still the view: everything below overwrites it.
snapshot_live_state(app);
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.
// 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 + " (" +
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);
// What the live view had, put back from the tick rather than here: the stream's own channel list is
// what the stored trigger channel has to be resolved against, and it does not exist until the first
// CONFIG of the restarted client lands on the producer thread. The generation read here is what the
// tick compares against to know that it has (see `restore_live_state()` for what else that ordering
// buys).
app.live_restore_generation = app.model->generation();
app.live_restore_pending = app.live_state.valid;
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);
// Nothing is on screen from the cache any more, so no row may claim to be. In place, for the
// reason `on_event_row_activated()` gives -- and this is why the mark went stale here before:
// rebuilding the list was too expensive to do for it.
mark_events_on_screen(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. The live view's settings are taken ahead of it, for `go_live_again()`:
// after the checks above, because a group that is refused leaves the live view alone.
snapshot_live_state(app);
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));
// One call, so the group's eight channels are planned once: a loop of single enables
// re-planned -- and reallocated -- the whole buffer per channel (see
// `ScopeModel::set_signals_enabled`), which is most of what made opening a comparison slow.
{
std::vector<std::size_t> all(cols * layers);
for (std::size_t i = 0; i < all.size(); ++i) all[i] = i;
app.model->set_signals_enabled(all, 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());
}
// 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) {
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 (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), 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_slot = -1;
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_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);
}
}
}
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.
// As above: the tick pins the axis to the group's own span and marks the trigger.
sync_events_availability(app);
mark_events_on_screen(app); // the dock marks the group's members
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;
auto* cache = static_cast<EventThumbCache*>(
g_object_get_data(G_OBJECT(app.events_list), "udpscope-event-thumbs"));
if (cache != nullptr) return cache;
// Created on first use and owned by the list. Nothing used to create it, and a missing cache is
// not a slow path here but a *blank* one: `event_preview_texture()` answers from the cache or not
// at all, so every row came up without a picture -- which is what the previews were reported for.
// Owned by the list because nothing else has the pane's lifetime: textures of a list that no
// longer exists are so much held memory.
cache = new EventThumbCache();
g_object_set_data_full(G_OBJECT(app.events_list), "udpscope-event-thumbs", cache,
[](gpointer p) {
auto* dead = static_cast<EventThumbCache*>(p);
for (auto& entry : *dead) {
if (entry.second != nullptr) g_object_unref(entry.second);
}
delete dead;
});
return cache;
}
/// 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 = nullptr;
if (stored != nullptr) {
// The stored picture's *own* proportions, fitted to the row's width: the preview is now as tall
// as its signal count makes it, and squeezing an eight-strip picture into the height a one-strip
// picture gets is what made a row unreadable. Capped, because the row still has to be a row.
const int sw = gdk_texture_get_width(stored);
const int sh = gdk_texture_get_height(stored);
const int height =
sw > 0 ? std::clamp(kEventThumbW * sh / sw, kEventThumbH, kEventThumbMaxH) : kEventThumbH;
texture = scale_texture(stored, kEventThumbW, height);
g_object_unref(stored);
}
cache->emplace(path, texture);
return texture;
}
/// Hovering a row's thumbnail shows the stored preview at full size. The row's picture is 138 pixels
/// wide and the stored one is 480: the small one says *which* event this is, and this says what is in
/// it, which is the whole reason a preview is worth keeping.
///
/// Through GTK's own tooltip rather than a popover of ours, for lifetime: a tooltip is shown, placed,
/// dismissed and destroyed by GTK, and it takes no pointer grab -- a popover per row would be a
/// parented widget to unparent on every rebuild of the pane, and a grab to lose (see the GTK
/// invariants on both). The texture is decoded in the handler and dropped with the tooltip's picture,
/// so a hover costs nothing once it is over and the row cache keeps holding only the small one.
gboolean on_preview_query_tooltip(GtkWidget*, int, int, gboolean, GtkTooltip* tooltip, gpointer user) {
const char* path = static_cast<const char*>(user);
if (path == nullptr || *path == '\0' || tooltip == nullptr) return FALSE;
GdkTexture* full = gdk_texture_new_from_filename(path, nullptr);
if (full == nullptr) return FALSE;
GtkWidget* picture = gtk_picture_new_for_paintable(GDK_PAINTABLE(full));
g_object_unref(full);
// `can_shrink` FALSE: the point is the full size, and a tooltip would otherwise be given the
// smallest size its content allows.
gtk_picture_set_can_shrink(GTK_PICTURE(picture), FALSE);
gtk_tooltip_set_custom(tooltip, picture);
return TRUE;
}
void attach_preview_zoom(GtkWidget* frame, const std::string& path) {
if (frame == nullptr || path.empty()) return;
gtk_widget_set_has_tooltip(frame, TRUE);
// The path outlives the signal: owned by the closure and freed with the widget, because the record
// it came from is a loop variable.
g_signal_connect_data(frame, "query-tooltip", G_CALLBACK(on_preview_query_tooltip),
g_strdup(path.c_str()), [](gpointer p, GClosure*) { g_free(p); },
GConnectFlags(0));
}
/// Whether the event is the one on screen: the one question the title's "(on screen)" and the dock's
/// filled mark both ask.
bool event_on_screen(const App& app, const EventRecord& r) {
return std::string(app.waveform_name) == r.file;
}
/// Whether the event is one of the members of the comparison or accumulation on screen.
bool event_in_group(const App& app, const EventRecord& r) {
if (app.group_kind == EventGroupKind::None) return false;
return std::any_of(app.group.begin(), app.group.end(),
[&](const EventRecord& g) { return g.file == r.file; });
}
/// A row's first line: when the event was latched, how long it is, and whether it is the one on
/// screen. One function because two callers write it -- the row builder and the in-place
/// re-mark -- and a mark that reads differently depending on which of them last ran would be
/// worse than no mark.
std::string event_row_title(App& app, const EventRecord& r) {
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,
event_on_screen(app, r) ? " (on screen)" : "");
return title;
}
void mark_events_on_screen(App& app) {
dock_mark_events(app);
if (app.events_list == nullptr) return;
for (GtkWidget* row = gtk_widget_get_first_child(app.events_list); row != nullptr;
row = gtk_widget_get_next_sibling(row)) {
const auto* record =
static_cast<const EventRecord*>(g_object_get_data(G_OBJECT(row), "udpscope-event"));
GtkWidget* name = GTK_WIDGET(g_object_get_data(G_OBJECT(row), "udpscope-event-title"));
// The "no events yet" placeholder is a row with neither: skipped, not asserted about.
if (record == nullptr || name == nullptr) continue;
gtk_label_set_text(GTK_LABEL(name), event_row_title(app, *record).c_str());
}
}
/// 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.
static void rebuild_events_pane_list(App& app, std::vector<EventRecord>& records) {
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;
// `records` is the caller's list, not const: a record whose preview had to be redrawn learns its
// thumbnail's path here.
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 (EventRecord& r : records) {
GtkWidget* row = gtk_list_box_row_new();
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 && app.event_store != nullptr) {
// Drawn again from the samples, which are still there: a row with no picture is the state the
// user reported as "sometimes missing", and the `.npy` is the event -- the `.bmp` is a picture
// of it. Once per row and never in a loop: a second failure is a real one, and the cache keeps
// the null so the next refresh does not read the file again.
const std::string redrawn = app.event_store->redraw_preview(r);
if (!redrawn.empty()) {
if (EventThumbCache* cache = event_thumb_cache(app)) cache->erase(redrawn);
r.thumbnail = redrawn;
texture = event_preview_texture(app, redrawn);
}
}
if (texture != nullptr) {
gtk_picture_set_paintable(GTK_PICTURE(shot), GDK_PAINTABLE(texture));
// The frame takes the *texture's* height, so a tall preview gets a tall row rather than being
// squashed into the floor. Its width is fixed: the rest of the row belongs to the text.
gtk_widget_set_size_request(frame, kEventThumbW, gdk_texture_get_height(texture));
attach_preview_zoom(frame, r.thumbnail);
} 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);
GtkWidget* name = gtk_label_new(event_row_title(app, r).c_str());
// Kept on the row: the title is the one part of a row that changes without the cache
// changing -- the "(on screen)" mark follows a replay -- and `mark_events_on_screen()`
// rewrites it through this instead of rebuilding the list.
g_object_set_data(G_OBJECT(row), "udpscope-event-title", name);
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", 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);
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);
// Attached *after* the thumbnail was resolved: a preview redrawn a moment ago is part of this
// record now, and the copy the row carries is what every later reader of the row sees.
g_object_set_data_full(G_OBJECT(row), "udpscope-event", new EventRecord(r),
[](gpointer p) { delete static_cast<EventRecord*>(p); });
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);
}
}
/// Both views of the cache, rebuilt from the same list: the pane's rows (with their pictures) and the
/// dock's compact ones. The dock follows even when the pane's list does not exist.
void refresh_events_pane(App& app) {
// One read of the cache directory for both views (none without a store, which the pane's rebuild
// and the dock's both treat as nothing to list).
std::vector<EventRecord> records;
if (app.event_store != nullptr) records = app.event_store->list();
rebuild_events_pane_list(app, records);
if (app.event_store != nullptr) dock_rebuild_events(app, records);
}
/// 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);
// The mark the replay just moved, rewritten on the rows that are already there. Rebuilding
// the list -- which is what this did -- discarded the scroll position and the selection to
// restate one word, so a click on an event scrolled the list back to the top and dropped the
// pair a comparison was being assembled from.
mark_events_on_screen(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) {
// The pane's frame -- the revealer that slides it in, the header with the way back to the live stream, the
// two actions that read the selection, and the list inside its scroller -- is a UI file. The rows are not:
// there is one per latched event, and the whole list is rebuilt whenever the cache changes. The two
// buttons' sensitivity is `update_group_buttons()`'s, which is why the file only starts them insensitive.
GtkBuilder* ui = ui_load(app, "/org/udpscope/ui/events-pane.ui");
app.events_revealer = GTK_WIDGET(gtk_builder_get_object(ui, "events_pane"));
app.events_compare = GTK_WIDGET(gtk_builder_get_object(ui, "events_compare"));
app.events_accumulate = GTK_WIDGET(gtk_builder_get_object(ui, "events_accumulate"));
GtkWidget* live = GTK_WIDGET(gtk_builder_get_object(ui, "events_live"));
app.events_list = GTK_WIDGET(gtk_builder_get_object(ui, "events_list"));
// The heading row, where the contextual help's "?" joins (see `ui_context_help.cpp`).
app.events_head = GTK_WIDGET(gtk_builder_get_object(ui, "events_head"));
if (app.events_revealer == nullptr || app.events_compare == nullptr ||
app.events_accumulate == nullptr || live == nullptr || app.events_list == nullptr) {
// A missing id is a mistake in the `.ui`: the pane comes up empty rather than taking the window with it,
// and `[udpscope] events pane:` reads the difference.
return app.events_revealer != nullptr ? app.events_revealer : gtk_revealer_new();
}
// What the pane *does*, which a file cannot hold: the two group actions read the selection, the list
// replays an event on a double click, and the header's button goes back to the live stream.
g_signal_connect(app.events_compare, "clicked", G_CALLBACK(on_group_clicked), &app);
g_signal_connect(app.events_accumulate, "clicked", G_CALLBACK(on_group_clicked), &app);
g_signal_connect(live, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
go_live_again(*static_cast<App*>(user));
}),
&app);
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);
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