// 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 #include #include 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(); 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 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(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::max() : static_cast(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 lock(app.model->mutex()); s.trigger = app.model->trigger(); s.trigger_channel = s.trigger.channel >= 0 && s.trigger.channel < static_cast(app.model->channels().size()) ? app.model->channels()[static_cast(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 lock(app.model->mutex()); const std::vector& channels = app.model->channels(); for (std::size_t i = 0; i < channels.size(); ++i) { if (channels[i].name == s.trigger_channel) { channel = static_cast(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(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& column : out->columns) column.reserve(reader.rows()); std::vector 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 selected_events(App& app) { std::vector 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(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& 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 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 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 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 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::quiet_NaN(); std::vector values(cols * layers, 0.0f); std::vector 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> layout(static_cast(rows)); for (int r = 0; r < rows; ++r) { for (int s = 0; s < RowSet::kSlotsPerRow; ++s) { const std::size_t at = static_cast(s); const std::string& name = app.rows->row(r).signal_name[at]; if (!base_slot(name)) continue; layout[static_cast(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& plan = layout[static_cast(r)]; for (int s = 0; s < RowSet::kSlotsPerRow; ++s) { const std::size_t at = static_cast(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(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(app.sync_last_a.size()) != rows) { app.sync_last_a.assign(static_cast(rows), {}); app.sync_last_b.assign(static_cast(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{live ? x.max - x.min : x.min, live ? 0.0 : x.max, y.min, y.max}; }; const std::size_t at = static_cast(k); const std::array a_now = signature(ax, ay); const std::array 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(user); const bool compare = button == GTK_BUTTON(app.events_compare); const std::vector 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( 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(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 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(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(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(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& 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(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(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 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(user); if (row == nullptr) return; auto* record = static_cast(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(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(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(user); app.event_recording = gtk_switch_get_active(GTK_SWITCH(o)); sync_events_availability(app); } } // namespace ui_impl } // namespace udpscope