improved perfs and event management

This commit is contained in:
Martino Ferrari committed 2026-10-13 23:00:42 +02:00
1 parent 667ed0c100
commit bb99be5889
17 files changed
+626 -59

No files matched your search

+39 -5
View File
@@ -13,6 +13,7 @@
#include <ctime>
#include "layout_store.hpp"
#include "waveform.hpp"
namespace udpscope {
namespace {
@@ -52,25 +53,30 @@ std::string stem_of(const std::string& file, const char* ext) {
/// The palette is fixed rather than the plots': this is navigation, and what has to be told apart at
/// a glance is one *event* from another, not one signal from another (for which the names are on the
/// row above).
bool write_preview(const ExportTable& table, const std::string& path, int width = 480, int height = 160) {
///
/// The height is **per strip**, not for the whole picture: with a fixed total, a six-signal event got
/// 26 pixels a signal and an eight-signal one got 20, which is a smear and not a waveform -- the
/// picture has to grow with the signal count, and it is the *viewer* that scales it down to a row.
bool write_preview(const ExportTable& table, const std::string& path, int width = 480,
int strip_h = 44) {
if (table.empty() || table.columns.empty()) return false;
const std::size_t rows = table.rows();
const std::size_t strips = table.columns.size();
const int strip_h = std::max(6, height / static_cast<int>(strips));
const int total_h = strip_h * static_cast<int>(strips);
static const std::uint32_t kPalette[] = {0x4FA3FFu, 0xFF7A4Fu, 0x6EE07Au, 0xE06ED0u,
0xFFD24Fu, 0x6FD8E0u, 0xB9A0FFu, 0xE0E0E0u};
// One byte per pixel per channel, bottom-up (BMP's own order), padded to a 4-byte row.
// One byte per pixel per channel, bottom-up and **blue first** -- both are BMP's own order, and
// writing RGB here is what made every external viewer show the traces in the wrong colour.
const int row_bytes = ((width * 3) + 3) & ~3;
std::vector<unsigned char> pixels(std::size_t(row_bytes) * total_h, 0);
const auto put = [&](int x, int y, std::uint32_t rgb, double alpha) {
if (x < 0 || y < 0 || x >= width || y >= total_h) return;
unsigned char* p = &pixels[std::size_t(total_h - 1 - y) * row_bytes + std::size_t(x) * 3];
const double a = std::clamp(alpha, 0.0, 1.0);
p[0] = static_cast<unsigned char>(p[0] * (1.0 - a) + ((rgb >> 16) & 0xFF) * a);
p[0] = static_cast<unsigned char>(p[0] * (1.0 - a) + (rgb & 0xFF) * a);
p[1] = static_cast<unsigned char>(p[1] * (1.0 - a) + ((rgb >> 8) & 0xFF) * a);
p[2] = static_cast<unsigned char>(p[2] * (1.0 - a) + (rgb & 0xFF) * a);
p[2] = static_cast<unsigned char>(p[2] * (1.0 - a) + ((rgb >> 16) & 0xFF) * a);
};
// The background: a dark field, so the traces read the way they do on screen.
for (int y = 0; y < total_h; ++y) {
@@ -268,6 +274,34 @@ std::string EventStore::save(const ExportTable& table, const EventRecord& meta,
return samples;
}
std::string EventStore::redraw_preview(const EventRecord& record) const {
if (dir_.empty() || record.stem.empty() || record.file.empty()) return {};
WaveformReader reader;
std::string error;
if (!reader.open(record.file, &error)) return {};
// The whole event in one table, which an event can afford where an export cannot: it is bounded by
// the trigger's own pre/post window, so this is seconds of samples and not a session's worth.
ExportTable table;
table.columns.resize(reader.names().size());
for (std::size_t c = 0; c < table.columns.size(); ++c) {
table.columns[c].name = reader.names()[c];
table.columns[c].values.reserve(reader.rows());
}
table.time.reserve(reader.rows());
std::vector<float> values(reader.names().size(), 0.0f);
double t = 0.0;
double abs_t = 0.0;
while (reader.next(&t, &abs_t, values.data())) {
table.time.push_back(t);
for (std::size_t c = 0; c < table.columns.size(); ++c) {
table.columns[c].values.push_back(values[c]);
}
}
const std::string path = dir_ + "/" + record.stem + ".bmp";
if (!write_preview(table, path)) return {};
return path;
}
std::vector<EventRecord> EventStore::list() const {
std::vector<EventRecord> out;
if (dir_.empty()) return out;
+9
View File
@@ -73,6 +73,15 @@ class EventStore {
/// however large the captures are.
std::vector<EventRecord> list() const;
/// Draws `record`'s preview again from its samples and returns the thumbnail's path, or an empty
/// string when the samples cannot be read.
///
/// This is the repair for a preview that is missing or will not decode -- a write that failed, a
/// truncated file, a cache written by an older build. The `.npy` is the event, the `.bmp` is only a
/// picture of it, so a lost picture is recoverable and must not be shown as a blank row. It reads
/// the samples, so unlike `list()` it is **not** cheap: call it for the one event that needs it.
std::string redraw_preview(const EventRecord& record) const;
/// Deletes one event's samples, sidecar and thumbnail.
bool forget(const std::string& stem);
+62 -7
View File
@@ -163,6 +163,16 @@ BufferStore::BufferStore(const Partition& part, f64 span_s)
}
}
void BufferStore::reset() noexcept {
time_->clear();
if (abs_time_) abs_time_->clear();
for (auto& v : values_) v->clear();
for (auto& c : channels_) {
c->invalidate();
c->set_epoch(0.0);
}
}
void BufferStore::acquire_all() {
for (auto& c : channels_) c->acquire();
}
@@ -536,9 +546,32 @@ void ScopeModel::rebuild_locked() {
return;
}
part_ = plan_partition(dts, cfg_.buffer_mb * 1024.0 * 1024.0,
target_span_.load(std::memory_order_relaxed), cfg_.relative_time);
store_ = std::make_unique<BufferStore>(part_, part_.span_s);
const Partition want = plan_partition(dts, cfg_.buffer_mb * 1024.0 * 1024.0,
target_span_.load(std::memory_order_relaxed),
cfg_.relative_time);
// A plan that describes the storage already allocated is cleared, not rebuilt. Allocating
// a store is what costs -- its rings are value-initialised, so it touches the whole
// `--buffer-mb` -- and most structural changes do not move the plan at all: a calibration
// keeps the channel set, and so does leaving an event for the live view. Startup asked for
// sixteen of these and eight of them planned the same buffer twice. Clearing has the same
// meaning (`BufferStore::reset()`): the history is gone either way, which is why
// `plan_generation_` bumps above regardless.
if (store_ && part_.same_storage_as(want)) {
part_ = want;
store_->reset();
} else {
part_ = want;
const std::uint64_t t0 = now_ns();
store_ = std::make_unique<BufferStore>(part_, part_.span_s);
// Named, with what it cost: this is the one operation in the application whose duration a
// user can see with no instrument, because it blocks the main thread for the time it takes
// to touch the whole buffer -- about 300 ms per GB. A startup or a mode change that reads
// more than one of these lines is a re-plan that should have been coalesced, and that is
// the defect the line exists to name (it was fifteen of them at startup).
std::fprintf(stderr, "[udpscope] buffer allocated: %zu channels, %.3g s span, %.0f ms\n",
channels_.size(), part_.span_s, double(now_ns() - t0) / 1.0e6);
std::fflush(stderr);
}
if (trigger_.channel >= static_cast<int>(channels_.size())) trigger_.channel = -1;
prepare_math_locked();
@@ -590,9 +623,8 @@ bool ScopeModel::signal_enabled(std::size_t signal) const {
return signal < enabled_.size() && enabled_[signal];
}
void ScopeModel::set_signal_enabled(std::size_t signal, bool on) {
std::lock_guard<std::mutex> lock(mutex_);
if (signal >= enabled_.size() || enabled_[signal] == on) return;
bool ScopeModel::enable_locked(std::size_t signal, bool on) {
if (signal >= enabled_.size() || enabled_[signal] == on) return false;
enabled_[signal] = on;
// A math channel's synthetic signal is enabled *here* -- it is the panel's switch -- while
// `append_math_signals_locked()` re-states `enabled_` from `math_` on the next edit. The two have
@@ -603,9 +635,14 @@ void ScopeModel::set_signal_enabled(std::size_t signal, bool on) {
if (m.name == signals_[signal].name) m.enabled = on;
}
}
return true;
}
void ScopeModel::rebuild_enabled_locked() {
// The trigger names a channel by its display name, which is stable across
// renumbering: when its own signal is disabled the channel vanishes (the trigger
// then waits, and comes back when the signal is re-enabled).
// then waits, and comes back when the signal is re-enabled). Read before
// `rebuild_channels()`, which is what renumbers.
const int old_trigger = trigger_.channel;
const std::string trigger_name =
(old_trigger >= 0 && old_trigger < static_cast<int>(channels_.size()))
@@ -622,6 +659,24 @@ void ScopeModel::set_signal_enabled(std::size_t signal, bool on) {
generation_.fetch_add(1, std::memory_order_release);
}
void ScopeModel::set_signal_enabled(std::size_t signal, bool on) {
std::lock_guard<std::mutex> lock(mutex_);
if (!enable_locked(signal, on)) return;
rebuild_enabled_locked();
}
void ScopeModel::set_signals_enabled(const std::vector<std::size_t>& signals, bool on) {
std::lock_guard<std::mutex> lock(mutex_);
bool any = false;
for (const std::size_t s : signals) {
if (enable_locked(s, on)) any = true;
}
// Nothing moved: no re-plan, and in particular no `generation_` bump -- a restored session
// re-enables what is already on, and a bump there would make the UI rebind for nothing.
if (!any) return;
rebuild_enabled_locked();
}
std::pair<double, double> ScopeModel::calibration(std::size_t signal) {
std::lock_guard<std::mutex> lock(mutex_);
if (signal >= signals_.size()) return {1.0, 0.0};
+58
View File
@@ -109,6 +109,23 @@ struct Partition {
/// Nyquist. The boxcar the ingest path averages with has its first null at the stored
/// rate itself, so everything that would fold back is attenuated.
double antialias_hz() const noexcept { return 0.5 * stored_rate(); }
/// Two plans that describe the **same storage**: the same rings, of the same lengths, at
/// the same decimation, over the same clock. This is the test `rebuild_locked()` uses to
/// decide whether it can clear the buffer it has instead of allocating another one.
///
/// `base_dt` is compared although it buys no byte, because a reused store keeps the
/// `Partition` *it* was built with and hands `slot_dt()` to every `ChannelBuffer` as the
/// nominal grid `dx()` reports -- and `dx()` is what picks the LOD level. Two channel sets
/// can want the same capacities off a different base tick, and reusing across that would
/// leave every channel describing a grid the samples are not on. It is an exact compare of
/// a value that is copied, never recomputed, from the fastest channel's `dt`.
/// `span_s` needs no compare of its own: it is the product of the three fields above.
bool same_storage_as(const Partition& o) const noexcept {
return time_capacity == o.time_capacity && time_stride == o.time_stride &&
abs_time == o.abs_time && base_dt == o.base_dt && stride == o.stride &&
value_capacity == o.value_capacity;
}
};
/// Plans a partition for `channel_dt` cadences within `buffer_bytes`, covering at least
@@ -156,6 +173,27 @@ class BufferStore {
}
const Partition& partition() const noexcept { return part_; }
/// Drops every sample and leaves a store indistinguishable from a freshly built one with
/// the same `Partition` -- the state `rebuild_locked()` wants when the plan did not change.
///
/// Why it exists: constructing a store *is* its cost. A ring is a
/// `vector<std::atomic<T>>`, which is value-initialised, so building one touches every
/// byte of the user's `--buffer-mb` -- about 300 ms per GB, nearly all of it first-touch
/// page faults. Clearing is a count store per ring. Most structural changes (a
/// calibration, a re-enable of what was just disabled, going back to live) re-plan to the
/// *same* partition, and paying a second full allocation for them is what made a large
/// buffer take ten seconds to start and an event take seconds to leave.
///
/// It must leave **nothing** of the old history behind, in either copy: the rings are
/// emptied and every `ChannelBuffer` is `invalidate()`d, which drops the published window
/// and the pyramid built over it. The epoch goes back to 0 for the same reason -- a fresh
/// store has none, and a reused one keeping the old event's epoch would re-base the next
/// axis against a trigger that no longer exists.
///
/// Caller's thread: the consumer, under `ScopeModel::mutex()` -- the same lock the
/// producer's pushes hold. `RingBuffer::clear()` is not safe against a concurrent push.
void reset() noexcept;
/// Consumer: publishes every channel's new samples.
void acquire_all();
@@ -356,6 +394,17 @@ class ScopeModel {
static bool is_time_signal(const std::vector<SignalInfo>& signals,
std::size_t signal);
void set_signal_enabled(std::size_t signal, bool on);
/// The same switch thrown for several signals as **one** structural change.
///
/// Enabling a signal changes the channel count, so it re-plans the partition and the new
/// plan really is a different buffer -- there is no reusing the old store for it (see
/// `BufferStore::reset()`). A caller that enables n signals one at a time therefore pays n
/// full allocations to reach a state one allocation describes, and every one of them drops
/// the history the one before it started collecting. That is what a restored session, a
/// loaded waveform and an event group all do, and at a 1 GB buffer it is seconds of a
/// blank window: the plots are bound to a store that is replaced again before it holds a
/// screenful. Indices the model has no signal for are ignored.
void set_signals_enabled(const std::vector<std::size_t>& signals, bool on);
/// Stores the calibration for a signal's *name* and applies it to that signal's
/// live channels. Keyed by name, not index: a CONFIG renumbers and an
/// enable/disable rebuilds the channel list, and `rebuild_channels()` re-applies
@@ -587,6 +636,15 @@ class ScopeModel {
private:
void rebuild_channels();
/// One signal's enable bookkeeping -- `enabled_` and the math channel's own flag -- with no
/// re-plan. Returns whether the state actually moved. The two halves of
/// `set_signal_enabled()` are split so that a batch can do n of these and one of the
/// rebuild below.
bool enable_locked(std::size_t signal, bool on);
/// The re-plan an enable needs: carry the trigger's channel across the renumbering,
/// rebuild the channels and the buffers, and publish the new generation. Caller holds
/// `mutex()` and has already moved `enabled_`.
void rebuild_enabled_locked();
/// Re-appends one synthetic signal per math channel, after the stream's own. Called under the
/// lock by both `set_signals()` (a CONFIG) and `set_math_channels()` (an edit).
void append_math_signals_locked();
+13 -1
View File
@@ -452,7 +452,19 @@ void RowSet::set_time_window(double seconds) {
// `{0, window}` here instead would state an offset of a whole epoch.
figure_->set_x_link_range(x_group_, {newest - window_s_, newest});
} else {
figure_->set_x_link_range(x_group_, {0.0, window_s_});
// No edge to read: there are no samples bound yet, which is the state every structural
// change leaves behind -- a re-plan drops the rings' history, and this function is called
// from `apply_time_window()`, which is what re-plans. So this is not a rare fallback; it
// is the axis a mode change is drawn with until the first sample lands.
//
// Which is why it states the window the same way the branch above does, with its maximum
// *at* the edge the live view puts at x = 0. `{0, window}` -- what it asked for before --
// is an offset of a whole window by the rule the comment above states, so the frames
// between the re-plan and the first sample drew the time base one screen to the left of
// where the following frames drew it. Two or three frames, every time an event or a
// waveform was opened: a trace that jumps a screen sideways and comes back is the
// "the time base flickers and it is unusable" report.
figure_->set_x_link_range(x_group_, {-window_s_, 0.0});
}
}
+14
View File
@@ -4827,6 +4827,20 @@ void on_activate(GtkApplication* application, gpointer user) {
gtk_widget_add_controller(app->window, GTK_EVENT_CONTROLLER(press));
gtk_window_present(GTK_WINDOW(app->window));
// What the window is waiting for, said *before* the first tick. The stream's first CONFIG plans
// the buffer on the producer thread, which holds the model's mutex for the whole of it -- at a few
// gigabytes that is around a second per gigabyte, and the frame tick cannot read the model while
// it runs, so the window does not change. Set here it is on screen already: the present paints a
// frame before any timeout fires, which is the difference between waiting and "the dialog
// disappeared and nothing happened".
if (app->waveform_name.empty()) {
const Config& cfg = app->model->config();
char buf[192];
std::snprintf(buf, sizeof(buf), "connecting to %s:%u -- planning the %.0f MB buffer",
cfg.server.c_str(), unsigned(cfg.port), cfg.buffer_mb);
set_notice(*app, buf, 6);
}
}
/// Headless self-check, step 2: read the panel geometry while the widgets still
+21
View File
@@ -743,6 +743,12 @@ struct App {
/// it keeps for a second latch (it is counting retries, not time).
int dbg_events_step = 0;
int dbg_events_wait = 0;
/// The event row the probe selected before it activated one, and how many rows were selected.
/// Both are read back *after* the activation: a row pointer that is no longer the list's row 0,
/// or a smaller count, says the activation rebuilt the list and so threw away the user's scroll
/// position and their comparison selection.
GtkListBoxRow* dbg_events_row0 = nullptr;
int dbg_events_selected = 0;
/// Which step of the plot-context-menu probe runs next (`UDPSCOPE_PROBE_ROWMENU_MS`), and the
/// popover it is measuring.
int dbg_rowmenu_step = 0;
@@ -1097,7 +1103,13 @@ constexpr double kWirePick = 6.0;
constexpr int kZoomGestureSteps = 8;
constexpr int kZoomWindowSteps = 8;
constexpr int kEventThumbW = 138;
/// The row thumbnail's height floor and ceiling. A floor and not a fixed size: the stored preview is
/// `44 px` per signal, so its proportions depend on how many signals the event has, and a row shows
/// it at *its* aspect -- a one-signal event is a short row and a six-signal one a tall one. The
/// ceiling is what keeps a wide event from making a row taller than the pane; the full picture is a
/// hover away.
constexpr int kEventThumbH = 46;
constexpr int kEventThumbMaxH = 104;
inline int g_stats_phase = 0;
inline std::vector<std::uint64_t> g_stats_n_before;
inline double g_trigrenum_edge = 0.0;
@@ -1333,6 +1345,15 @@ GdkTexture* scale_texture(GdkTexture* source, int width, int height);
EventThumbCache* event_thumb_cache(App& app);
GdkTexture* event_preview_texture(App& app, const std::string& path);
void refresh_events_pane(App& app);
/// Re-marks which row is the event currently on screen, **without rebuilding the list**.
///
/// The mark is the only thing about a row that a replay changes -- the picture and the numbers
/// are the file's -- and rebuilding the list to restate it threw away the scroll position and
/// the selection with it, so reaching the thirtieth event meant scrolling to it again after
/// every click, and a selection made for a comparison was lost by the act of looking at one of
/// its members. Call this for a change of what is on screen, and `refresh_events_pane()` only
/// for a change of what is *cached*.
void mark_events_on_screen(App& app);
void on_event_row_activated(GtkListBox*, GtkListBoxRow* row, gpointer user);
GtkWidget* build_events_pane(App& app);
gboolean tick(gpointer user);
+136 -21
View File
@@ -215,6 +215,10 @@ bool go_live_again(App& app) {
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;
}
@@ -361,7 +365,14 @@ bool build_event_group(App& app, const std::vector<EventRecord>& records, EventG
app.model->set_target_span(span);
app.model->set_signals(std::move(signals));
for (std::size_t i = 0; i < cols * layers; ++i) app.model->set_signal_enabled(i, true);
// 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);
@@ -556,8 +567,24 @@ void on_group_clicked(GtkButton* button, gpointer user) {
EventThumbCache* event_thumb_cache(App& app) {
if (app.events_list == nullptr) return nullptr;
return static_cast<EventThumbCache*>(
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
@@ -609,13 +636,84 @@ GdkTexture* event_preview_texture(App& app, const std::string& path) {
const auto found = cache->find(path);
if (found != cache->end()) return found->second;
GdkTexture* stored = gdk_texture_new_from_filename(path.c_str(), nullptr);
GdkTexture* texture =
stored != nullptr ? scale_texture(stored, kEventThumbW, kEventThumbH) : nullptr;
if (stored != nullptr) g_object_unref(stored);
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
@@ -631,7 +729,8 @@ void refresh_events_pane(App& app) {
// the one that finds out.
app.events_thumb = nullptr;
if (app.event_store == nullptr) return;
const std::vector<EventRecord> records = app.event_store->list();
// 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");
@@ -657,10 +756,8 @@ void refresh_events_pane(App& app) {
}
}
std::size_t unreadable = 0;
for (const EventRecord& r : records) {
for (EventRecord& r : records) {
GtkWidget* row = gtk_list_box_row_new();
g_object_set_data_full(G_OBJECT(row), "udpscope-event",
new EventRecord(r), [](gpointer p) { delete static_cast<EventRecord*>(p); });
GtkWidget* line = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8);
gtk_widget_set_margin_start(line, 6);
@@ -688,8 +785,24 @@ void refresh_events_pane(App& app) {
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;
}
@@ -702,15 +815,11 @@ void refresh_events_pane(App& app) {
gtk_widget_set_valign(box, GTK_ALIGN_CENTER);
gtk_widget_set_hexpand(box, TRUE);
const std::time_t t = static_cast<std::time_t>(r.wall_time);
std::tm tm{};
localtime_r(&t, &tm);
char clock[32];
std::strftime(clock, sizeof(clock), "%H:%M:%S", &tm);
char title[160];
std::snprintf(title, sizeof(title), "%s %.3f s%s", clock, r.duration_s,
std::string(app.waveform_name) == r.file ? " (on screen)" : "");
GtkWidget* name = gtk_label_new(title);
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
@@ -741,6 +850,10 @@ void refresh_events_pane(App& app) {
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);
}
@@ -759,9 +872,11 @@ void on_event_row_activated(GtkListBox*, GtkListBoxRow* row, gpointer user) {
auto* record = static_cast<EventRecord*>(g_object_get_data(G_OBJECT(row), "udpscope-event"));
if (record == nullptr) return;
replay_event(app, *record);
// Rebuilt, not for the picture -- the row already has it -- but for the "on screen" mark the
// replay just changed.
refresh_events_pane(app);
// The 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
+33 -7
View File
@@ -284,18 +284,38 @@ void apply_layout(App& app, const LayoutState& st) {
ScopeModel& model = *app.model;
const auto& signals = model.signals();
// The session's own state first: enabling is what starts acquisition, and a math channel's
// *name* is only a signal once its graph is installed -- a row that holds one would otherwise be
// skipped as a name the stream no longer has.
// The window before anything structural. Nothing is enabled yet, so there are no channels
// to plan for and this only *records* the span -- but it is the span every plan below is
// then made with, so the `apply_time_window()` at the end of this function finds the
// decimation already right and re-plans nothing. Stated last, as it used to be, it moved
// the stride and bought a second full allocation of the buffer after the data was already
// flowing into the first.
if (st.window_s > 0.0) model.set_target_span(st.window_s);
// The math graph before the enables, because a math channel's *name* is only a signal once
// its graph is installed -- a row that holds one would otherwise be skipped as a name the
// stream no longer has -- and because installing it while nothing is enabled is the one
// ordering in which it does not pay for a buffer of its own. `signals` is a reference to
// the model's own vector, so the synthetic entries this appends are visible below.
app.math = st.math;
apply_math_channels(app);
// What the session had on, collected and enabled **once**, at the end of this function.
// Each enable re-plans the partition, and a re-plan allocates -- and so touches -- the whole
// of `--buffer-mb`: seven signals one at a time is seven allocations before a single sample
// is kept, which is the ten seconds of blank window a large buffer used to start with. The
// math channels are not collected here: their enable state is the session's `math` and
// `set_math_channels()` has already restored it, so listing them again could turn on one
// the user saved off.
std::vector<std::size_t> wanted;
wanted.reserve(st.enabled.size());
for (const std::string& name : st.enabled) {
for (std::size_t si = 0; si < signals.size(); ++si) {
if (signals[si].name != name || signals[si].math) continue;
model.set_signal_enabled(si, true);
wanted.push_back(si);
break;
}
}
app.math = st.math;
apply_math_channels(app);
app.measures = st.measures;
app.measure_stats_on = st.measure_stats;
app.updating_panels = true;
@@ -341,12 +361,18 @@ void apply_layout(App& app, const LayoutState& st) {
// Enabling is what starts acquisition (a loaded plot nobody acquires would
// come back blank), and it is what a drop does for the signal it places.
app.rows->assign(i, slot, name, name, signal_color_index(signals, si));
model.set_signal_enabled(si, true);
wanted.push_back(si);
++slot;
break;
}
}
}
// The one structural change of a restored session: everything the file had on, and
// everything its rows placed, in a single re-plan. Enabling is what starts acquisition (a
// loaded plot nobody acquires would come back blank), and it is what a drop does for the
// signal it places -- so a row holding a signal the `enabled` list did not name is enabled
// here, by having been collected above.
model.set_signals_enabled(wanted, true);
for (const auto& cal : st.calibration) {
for (std::size_t si = 0; si < signals.size(); ++si) {
+70 -6
View File
@@ -1892,8 +1892,13 @@ gboolean layout_probe_step(gpointer user) {
gboolean enable_all_probe(gpointer user) {
App* a = static_cast<App*>(user);
for (std::size_t i = 0; i < a->model->signals().size(); ++i) {
a->model->set_signal_enabled(i, true);
// In one call, the way a restored session does it: enabling them one at a time re-plans
// the buffer per signal, so a probe that did that would not be measuring the path a real
// start takes (and `[gen]` would read a burst that the application no longer produces).
{
std::vector<std::size_t> all(a->model->signals().size());
for (std::size_t i = 0; i < all.size(); ++i) all[i] = i;
a->model->set_signals_enabled(all, true);
}
int row = 0;
int slot = 0;
@@ -1908,8 +1913,12 @@ gboolean enable_all_probe(gpointer user) {
}
}
a->relayout_pending = true;
std::fprintf(stderr, "[enable-all-probe] %zu channel(s) enabled and assigned to plots\n",
a->model->channel_count());
// The startup notice comes with the reading, because it is the window's only answer to "why is
// nothing happening yet": it is set as the window is presented, before the first CONFIG can take
// the model's mutex, and it names the buffer being planned. An empty `notice` here is the window
// that used to come up silent while a large buffer was allocated.
std::fprintf(stderr, "[enable-all-probe] %zu channel(s) enabled and assigned to plots, notice='%s'\n",
a->model->channel_count(), a->notice.c_str());
std::fflush(stderr);
// Then switch one off through its own switch in the signal list: a disabled signal must leave the plots,
// not sit there drawing nothing in a slot a live one could take. Late enough that a math probe armed behind
@@ -3158,6 +3167,40 @@ gboolean events_probe_step(gpointer user) {
}
if (a->dbg_events_step == 1) {
// The preview's repair, measured before anything else reads the pane: the newest event's picture
// is deleted and the pane refreshed, so the row that comes back proves the pane draws it again
// from the samples rather than showing a blank. The stored geometry is read back too, because the
// defect this fixes was a readable *file* that was too short to read -- `strip_h * signals` says
// the picture grew with the signal count, a fixed 160 says it did not.
const std::vector<EventRecord> before = a->event_store->list();
if (!before.empty()) {
const std::string bmp = a->event_store->dir() + "/" + before.front().stem + ".bmp";
::remove(bmp.c_str());
// The decoded picture has to go with the file: the cache answers by path and would hand back
// the texture of a file that is no longer there, so the refresh would never reach the repair.
if (EventThumbCache* cache = event_thumb_cache(*a)) {
for (auto& entry : *cache) {
if (entry.second != nullptr) g_object_unref(entry.second);
}
cache->clear();
}
refresh_events_pane(*a);
int w = -1;
int h = -1;
if (std::FILE* f = std::fopen(bmp.c_str(), "rb"); f != nullptr) {
unsigned char head[26] = {};
if (std::fread(head, 1, sizeof(head), f) == sizeof(head)) {
w = int(head[18]) | int(head[19]) << 8 | int(head[20]) << 16;
h = int(head[22]) | int(head[23]) << 8 | int(head[24]) << 16;
}
std::fclose(f);
}
const bool drawn = a->events_thumb != nullptr &&
gtk_picture_get_paintable(GTK_PICTURE(a->events_thumb)) != nullptr;
std::fprintf(stderr, "[events] preview redrawn: signals=%zu stored=%dx%d row_drawn=%d\n",
before.front().signals, w, h, drawn ? 1 : 0);
std::fflush(stderr);
}
const std::vector<EventRecord> records = a->event_store->list();
std::fprintf(stderr, "[events] dir=%s n=%zu", a->event_store->dir().c_str(), records.size());
if (!records.empty()) {
@@ -3185,6 +3228,18 @@ gboolean events_probe_step(gpointer user) {
// "framed" from "untouched".
apply_time_window(*a, 0.5);
GtkListBoxRow* row = gtk_list_box_get_row_at_index(GTK_LIST_BOX(a->events_list), 0);
// Selected *and* remembered, because what the activation must not do is rebuild the list:
// a selection the user made for a comparison, and the row object itself, are what a
// rebuild silently discarded. Two rows, so the reading after the click can say the
// selection survived rather than merely that something is selected.
if (row != nullptr) gtk_list_box_select_row(GTK_LIST_BOX(a->events_list), row);
if (GtkListBoxRow* second = gtk_list_box_get_row_at_index(GTK_LIST_BOX(a->events_list), 1)) {
gtk_list_box_select_row(GTK_LIST_BOX(a->events_list), second);
}
a->dbg_events_row0 = row;
GList* chosen = gtk_list_box_get_selected_rows(GTK_LIST_BOX(a->events_list));
a->dbg_events_selected = int(g_list_length(chosen));
g_list_free(chosen);
if (row != nullptr) g_signal_emit_by_name(a->events_list, "row-activated", row);
}
// One layout pass before the picture is measured: a widget created a moment ago has no
@@ -3198,10 +3253,18 @@ gboolean events_probe_step(gpointer user) {
// is set, so a non-null one says nothing about whether anything is on screen.
const bool picture = a->events_thumb != nullptr &&
gtk_picture_get_paintable(GTK_PICTURE(a->events_thumb)) != nullptr;
// The row *object*, not its index: a rebuilt list has a row 0 too, so only identity tells a
// list that was left alone from one that was torn down and refilled under the user's scroll.
const bool rows_kept =
a->dbg_events_row0 != nullptr &&
gtk_list_box_get_row_at_index(GTK_LIST_BOX(a->events_list), 0) == a->dbg_events_row0;
GList* still = gtk_list_box_get_selected_rows(GTK_LIST_BOX(a->events_list));
const int selected_now = int(g_list_length(still));
g_list_free(still);
std::fprintf(stderr,
"[events] activated row0: replaying=%d pane=%d pane_w=%d pane_box=%d tick=%d "
"window=%.3f axis=[%.3f,%.3f] marker=%.3f "
"thumbnail=%d thumb_size=%dx%d thumb_visible=%d\n",
"thumbnail=%d thumb_size=%dx%d thumb_visible=%d rows_kept=%d selected=%d/%d\n",
a->replaying ? 1 : 0,
a->events_revealer != nullptr && gtk_widget_get_visible(a->events_revealer) ? 1 : 0,
a->events_divider != nullptr ? gtk_paned_get_position(GTK_PANED(a->events_divider))
@@ -3226,7 +3289,8 @@ gboolean events_probe_step(gpointer user) {
picture ? 1 : 0,
a->events_thumb != nullptr ? gtk_widget_get_width(a->events_thumb) : -1,
a->events_thumb != nullptr ? gtk_widget_get_height(a->events_thumb) : -1,
a->events_thumb != nullptr && gtk_widget_get_mapped(a->events_thumb) ? 1 : 0);
a->events_thumb != nullptr && gtk_widget_get_mapped(a->events_thumb) ? 1 : 0,
rows_kept ? 1 : 0, selected_now, a->dbg_events_selected);
std::fflush(stderr);
events_probe_next(*a, 2, 1200);
return G_SOURCE_REMOVE;
+5 -1
View File
@@ -272,7 +272,11 @@ bool load_waveform_into_model(ScopeModel& model, const std::string& path,
}
model.set_target_span(reader.rows() * (dt > 0.0 ? dt : 0.001));
model.set_signals(std::move(signals));
for (std::size_t i = 0; i < reader.names().size(); ++i) model.set_signal_enabled(i, true);
// All of them in one call: one re-plan, hence one allocation of the buffer, where a loop
// of single enables planned it once per column (see `set_signals_enabled`).
std::vector<std::size_t> all(reader.names().size());
for (std::size_t i = 0; i < all.size(); ++i) all[i] = i;
model.set_signals_enabled(all, true);
const std::size_t channels = model.channels().size();
if (channels == 0) {