The hamburger menu's help rows were three flat entries and a window. They are a **`Help` submenu** now, which is where every GNOME menu keeps them, with the four things a first run cannot find: - Keyboard shortcuts Ctrl+/ (the existing overview, `show_shortcuts()`) - Documentation F1 (the existing manual, `show_help()`) - Guided tour Ctrl+T (new) - Enable contextual help (new, a stateful row: its check *is* the setting) The submenu *replaces* the menu's rows rather than floating beside them, the same shape and the same reason as the plot's right-click menu: a submenu torn out of the menu it belongs to is a second thing to dismiss, and the `GtkMenuButton` that owned one toggled itself out of sync the moment anything else closed its popover. It is the pages of a `GtkStack`, not a rebuild of the box and not a swap of the popover's child: the main view holds `app.layout_toggle`, which the tick's `sync_layout_editor()` and the `L` accel read, so rebuilding would leave that pointer on a destroyed widget, and a swapped child would leave the view that is not showing without an owner. **The contextual help is one table and a "?" per surface** (`src/ui_context_help.cpp`): the plot, the trigger, the cursors, the measurements, the math channels, the signals, the event history, the time window, the status line, and the math editor's identity row. Each row names the *heading box* its button joins -- a sibling of the title, never an overlay child on a corner, which is what would cover the switch or the [edit] button already there -- so adding a surface is a row and a widget pointer. Off means *gone*, not greyed: the buttons are removed, and they are found by a data key rather than a list of pointers because the math editor's heading is built afresh on every opening and a list would go stale one dialog later. `contextual_help` and `tour_seen` are keys of `settings.toml`, written through the same `Settings`/`load_settings()`/`save_settings()` the Preferences dialog uses, whose own rows start from `load_settings()` and so carry them through untouched. **The guided tour is five steps on a `GtkOverlay`** above the window's content: the plot, the side panel, the trigger, the signals and the status line. There is no popover in it and therefore no pointer grab to leak -- four dim rectangles around the hole are what make it modal -- and the probe asserts that as a reading (`controllers=` against the count taken before it opened) rather than as a claim. Everything is placed from `gtk_widget_compute_bounds()` re-read on every frame, because a widget's allocation is 0 on the frame it is first shown and a window resize moves it under a one-shot measurement; the hole is kept from the last frame that could measure the target, because a frame that lands between two layout passes would otherwise empty it and make the spotlight jump. `Skip`, `Finish` and `Escape` all hide every child and leave nothing behind. A first run gets the tour once, decided by `tour_seen` and not by "the settings file did not exist" (a user who changed the theme has a file and has still never seen it), and the flag is written when the tour *starts* rather than when it finishes: a user who skips it in the first second has still seen it. `UDPSCOPE_PROBE_HELPUI_MS` drives all of it -- the auto-start, the menu's seven rows and the submenu's five, the toggle's effect read as a count of affordances on either side of it, and the tour started, stepped forwards and back and dismissed -- and the exit dump gains `help layer:` and `help figures:`, the latter being the ten surfaces' window-coordinate rectangles that the manual's annotated screenshots crop their boxes from. Two of them are what the D-Bus-free headless run can still not decide, and both are limitations rather than defects: export carries no "?" (it is a menu action with no surface of its own, and a popover inside the menu's popover is the nested-popover trap the invariants already warn about), and the tour card cannot miss its target when the target is the whole content -- which is what the plot is at the window's 756 px minimum, where the split view overlays the side panel instead of putting it beside the plot. The probe prints both rectangles rather than asserting a placement, so that case reads as the geometry it is. Also fixed while it was found: `gtk_widget_measure()` includes the widget's own margins (measured: a 280 px box with `margin-start 453` reports 733), so a measure that is used to *place* the widget is a feedback loop -- that is what made the tour card walk between the corner and the target from frame to frame.
1026 lines
53 KiB
C++
1026 lines
53 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;
|
|
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);
|
|
// 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;
|
|
}
|
|
|
|
/// 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);
|
|
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));
|
|
}
|
|
|
|
/// 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,
|
|
std::string(app.waveform_name) == r.file ? " (on screen)" : "");
|
|
return title;
|
|
}
|
|
|
|
void mark_events_on_screen(App& 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.
|
|
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;
|
|
// Not const: a record whose preview had to be redrawn learns its thumbnail's path here.
|
|
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 (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);
|
|
}
|
|
}
|
|
|
|
/// 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
|