scope: full-screen figures, second windows, gated measurements, per-configuration events
A batch of view and instrument work, with the probes and docs for each. - **Full screen per figure row**: a toggle on each row's top-right corner, revealed by pointer proximity (`kMaximizeRevealPx` around its placed rect, per row, read from an overlay-level motion controller so a revealed button under the pointer cannot flicker); the plot menu offers the same as *Full screen this figure* / *Restore the layout*. The arrangement is saved and restored through `RowState`, so nothing plotted moves. A layout of one figure carries no toggle. - **Second window**: `open_scope_window()` copies a figure (cells, width shares, signals in their colours) or opens an empty one, with its own `RowSet` and `PlotArea`; the main window's tick binds, acquires and repaints it, mirroring the time window and pin. Entries: the plot menu's *Open this plot in a new window* and the overflow menu's *New scope window*. - **Layout editor from the plot**: the plot menu gains *Edit the layout...* / *Done editing the layout*, flipping the app's one toggle. - **Measurements gate**: `MeasureOptions::gate` restricts a measurement to the held/pause snapshot (live falls back to the live ring), persisted with the session and shown in the measure dialog. - **Crosshair axis badges**: a preference (`crosshair_axis_labels`, on by default) and the plotter's badges where the lines meet the axes, in the axes' own formats; the plot menu and the probe read them. - **Event cache per configuration**: `EventStore` is keyed `<source>/<configuration>/`, `retarget_event_store()` follows every configuration switch, and a pre-configuration cache (loose files) is adopted into the configuration's folder on first start. - **GTK4 lifecycle**: the main window's handlers move from the non-existent `"destroy"` signal to `close-request`; scope windows are closed with it and the application quits explicitly (the exit run's hang). The exit probe closes with `gtk_window_close()`. - Probes: `[maximize-probe]` (single-figure rule and the corner reveal), `[scope-probe]`, `[split-probe]` cell crosshair leg, `[prefs-probe]` store keying, `[configs]` store key, `[rowmenu]` layout-editor entry; docs, help pages, README and AGENT.md updated. Also fixes a probe-only use-after-free: `toggle_maximize_figure_row` replaces the figure object, so the area must be re-pointed (the button's path does; the probe's raw call did not).
This commit is contained in:
1 parent
bfe13630f9
commit
bdf0ff7ddc
39 files changed
+2267
-136
No files matched your search
+460
-20
@@ -547,6 +547,25 @@ GtkWidget* make_area(App& app) {
|
||||
gtk_overlay_set_child(GTK_OVERLAY(app.area_overlay), widget);
|
||||
gtk_widget_set_hexpand(app.area_overlay, true);
|
||||
gtk_widget_set_vexpand(app.area_overlay, true);
|
||||
// The corner toggles are revealed by proximity, so the pointer's position is kept here,
|
||||
// on the **overlay** and not on the GL area: a revealed toggle sits *under* the pointer,
|
||||
// and the event still travels the ancestor -- a leave on the GL widget would fire the
|
||||
// moment the button appeared and hide it again (the flicker the overlay avoids). `leave`
|
||||
// here means the pointer left the plot altogether, which is when the toggles go away.
|
||||
GtkEventController* motion = gtk_event_controller_motion_new();
|
||||
g_signal_connect(motion, "motion", G_CALLBACK(+[](GtkEventControllerMotion*, double x,
|
||||
double y, gpointer user) {
|
||||
App& a = *static_cast<App*>(user);
|
||||
a.area_pointer_x = x;
|
||||
a.area_pointer_y = y;
|
||||
a.area_pointer_valid = true;
|
||||
}),
|
||||
&app);
|
||||
g_signal_connect(motion, "leave", G_CALLBACK(+[](GtkEventControllerMotion*, gpointer user) {
|
||||
static_cast<App*>(user)->area_pointer_valid = false;
|
||||
}),
|
||||
&app);
|
||||
gtk_widget_add_controller(app.area_overlay, GTK_EVENT_CONTROLLER(motion));
|
||||
return app.area_overlay;
|
||||
}
|
||||
|
||||
@@ -559,6 +578,85 @@ void relayout_areas(App& app) {
|
||||
if (app.area != nullptr) app.area->set_figure(&app.rows->figure());
|
||||
}
|
||||
|
||||
/// A second top-level window: the same stream, another figure, its own `RowSet`. The main
|
||||
/// window's tick is its bind/acquire/repaint pump (see `tick()`), so the two never disagree
|
||||
/// about the data or the display mode. `figure_row` names the main window's figure to copy --
|
||||
/// its cells, their width shares and every slot's signal in its colour -- because "open this
|
||||
/// one big" is the reason a user reaches for a second window; -1 opens an empty scope.
|
||||
void open_scope_window(App& app, int figure_row) {
|
||||
if (figure_row >= app.rows->figure_row_count()) figure_row = -1;
|
||||
auto scope = std::make_unique<App::ScopeWindow>();
|
||||
scope->rows = std::make_unique<RowSet>();
|
||||
if (figure_row >= 0) {
|
||||
const int cells = app.rows->cells_in_row(figure_row);
|
||||
scope->rows->set_cells_per_row(std::vector<int>(1, cells));
|
||||
if (cells > 1) scope->rows->set_cell_weights(0, app.rows->cell_weights(figure_row));
|
||||
const int first = app.rows->first_plot_of_row(figure_row);
|
||||
for (int k = 0; k < cells; ++k) {
|
||||
const RowSet::Row& src = app.rows->row(first + k);
|
||||
for (int s = 0; s < RowSet::kSlotsPerRow; ++s) {
|
||||
const std::size_t si = static_cast<std::size_t>(s);
|
||||
if (src.signal_name[si].empty()) continue;
|
||||
scope->rows->assign(k, s, src.signal_name[si], src.name[si], src.color_index[si]);
|
||||
}
|
||||
}
|
||||
}
|
||||
scope->rows->set_time_window(app.rows->time_window());
|
||||
// The preference travels: the scope draws the same chrome the main window does, unless
|
||||
// the setting is off.
|
||||
scope->rows->set_crosshair_axis_labels(app.settings.crosshair_axis_labels);
|
||||
scope->area = plot::gtk4::PlotArea::create(scope->placeholder);
|
||||
scope->area->set_figure(&scope->rows->figure());
|
||||
scope->widget = scope->area->widget();
|
||||
gtk_widget_set_hexpand(scope->widget, true);
|
||||
gtk_widget_set_vexpand(scope->widget, true);
|
||||
gtk_widget_set_size_request(scope->widget, 320, 160);
|
||||
scope->window = GTK_WINDOW(gtk_window_new());
|
||||
const std::string title = figure_row >= 0
|
||||
? "udpscope \u2014 plot " + std::to_string(figure_row + 1)
|
||||
: std::string("udpscope \u2014 scope");
|
||||
gtk_window_set_title(scope->window, title.c_str());
|
||||
gtk_window_set_default_size(scope->window, 900, 500);
|
||||
gtk_window_set_child(scope->window, scope->widget);
|
||||
if (GtkApplication* application = gtk_window_get_application(GTK_WINDOW(app.window))) {
|
||||
gtk_window_set_application(scope->window, application);
|
||||
}
|
||||
// The vector owns the struct, so the window has to remove itself. The erase is deferred to
|
||||
// an idle: the destroy emission runs before the GL child is finalized, and the area
|
||||
// wrapper (`PlotArea::create()`'s, owned by the widget) holds references into this struct's
|
||||
// placeholder plot and row set -- freeing them under the finalization would be a
|
||||
// use-after-free. One main-loop turn later the widget is gone and the erase is safe.
|
||||
g_signal_connect(scope->window, "destroy", G_CALLBACK(+[](GtkWindow* window, gpointer user) {
|
||||
App* a = static_cast<App*>(user);
|
||||
for (auto it = a->scopes.begin(); it != a->scopes.end(); ++it) {
|
||||
if ((*it)->window != window) continue;
|
||||
App::ScopeWindow* victim = it->release();
|
||||
a->scopes.erase(it);
|
||||
g_idle_add_full(G_PRIORITY_DEFAULT_IDLE,
|
||||
+[](gpointer p) -> gboolean {
|
||||
delete static_cast<App::ScopeWindow*>(p);
|
||||
return G_SOURCE_REMOVE;
|
||||
},
|
||||
victim, nullptr);
|
||||
break;
|
||||
}
|
||||
}),
|
||||
&app);
|
||||
gtk_window_present(scope->window);
|
||||
app.scopes.push_back(std::move(scope));
|
||||
}
|
||||
|
||||
/// The plot menu's "open this plot in a new window": the figure row the clicked plot belongs
|
||||
/// to, copied into a scope window; the popover dismisses like every other row's action.
|
||||
void on_open_scope_row(GtkButton* button, gpointer user) {
|
||||
App* app = static_cast<App*>(user);
|
||||
const int row = row_key(GTK_WIDGET(button));
|
||||
const int fr = app->rows->figure_row_of(row);
|
||||
open_scope_window(*app, fr);
|
||||
GtkPopover* popover = GTK_POPOVER(g_object_get_data(G_OBJECT(button), "udpscope-popover"));
|
||||
if (popover != nullptr) gtk_popover_popdown(popover);
|
||||
}
|
||||
|
||||
// --- panel rebuilds ---------------------------------------------------------
|
||||
|
||||
/// Defined below, forward-declared because placing or freeing a trace slot has to
|
||||
@@ -891,17 +989,146 @@ void on_layout_cell_insert(GtkButton* button, gpointer user) {
|
||||
///
|
||||
/// What each button does with the look is the caller's: they differ in alignment, sensitivity, the data they
|
||||
/// carry and the gesture they take.
|
||||
GtkWidget* layout_overlay_button(App& app, const char* icon, const char* tooltip) {
|
||||
/// The look every overlay button shares -- icon, the two CSS classes that make a small round
|
||||
/// on-screen-display control, and the size -- in one place, because the editor's buttons and
|
||||
/// the full-screen toggles must not drift apart.
|
||||
GtkWidget* overlay_button(App& app, const char* icon, const char* tooltip,
|
||||
std::vector<GtkWidget*>& list) {
|
||||
GtkWidget* b = gtk_button_new_from_icon_name(icon);
|
||||
gtk_widget_set_tooltip_text(b, tooltip);
|
||||
gtk_widget_add_css_class(b, "circular");
|
||||
gtk_widget_add_css_class(b, "osd");
|
||||
gtk_widget_set_size_request(b, kLayoutButtonPx, kLayoutButtonPx);
|
||||
gtk_overlay_add_overlay(GTK_OVERLAY(app.area_overlay), b);
|
||||
app.layout_buttons.push_back(b);
|
||||
list.push_back(b);
|
||||
return b;
|
||||
}
|
||||
|
||||
/// The editor's own set: on the overlay only while the editor is up, cleared with it.
|
||||
GtkWidget* layout_overlay_button(App& app, const char* icon, const char* tooltip) {
|
||||
return overlay_button(app, icon, tooltip, app.layout_buttons);
|
||||
}
|
||||
|
||||
/// Forward declarations: the toggles' handlers are defined after the builder that wires them.
|
||||
void on_maximize_clicked(GtkButton* b, gpointer user);
|
||||
void on_fullscreen_row(GtkButton* b, gpointer user);
|
||||
void rebuild_maximize_buttons(App& app);
|
||||
|
||||
/// The one action behind both the row's toggle and the plot menu's entry: the figure row goes
|
||||
/// full screen, or -- while one is -- the saved arrangement comes back. The figure is handed
|
||||
/// over at once (the same path a cell insert takes), the toggles are rebuilt because their
|
||||
/// number and icon follow the state, and the signal panels are rebuilt because a maximize
|
||||
/// renumbers which row shows what.
|
||||
void toggle_fullscreen(App& app, int fr) {
|
||||
app.rows->toggle_maximize_figure_row(fr);
|
||||
if (app.area != nullptr) app.area->set_figure(&app.rows->figure());
|
||||
rebuild_maximize_buttons(app);
|
||||
rebuild_signal_panels(app);
|
||||
if (app.layout_editing) rebuild_layout_editor(app);
|
||||
}
|
||||
|
||||
/// One full-screen toggle per figure row: the row to enlarge is the row you are looking at,
|
||||
/// so the control sits on the row itself rather than behind the layout editor. While a row
|
||||
/// is maximized the single remaining button is the way back. A layout of one figure has
|
||||
/// nothing to maximize *to*, so it carries no toggle -- except while that one figure is
|
||||
/// maximized, where the button is the way out.
|
||||
void rebuild_maximize_buttons(App& app) {
|
||||
if (app.area_overlay == nullptr) return;
|
||||
for (GtkWidget* b : app.maximize_buttons) {
|
||||
gtk_overlay_remove_overlay(GTK_OVERLAY(app.area_overlay), b);
|
||||
}
|
||||
app.maximize_buttons.clear();
|
||||
app.maximize_buttons_rows = app.rows->figure_row_count();
|
||||
const bool maximized = app.rows->figures_maximized();
|
||||
if (!maximized && app.maximize_buttons_rows <= 1) return;
|
||||
for (int fr = 0; fr < app.maximize_buttons_rows; ++fr) {
|
||||
GtkWidget* b = overlay_button(app,
|
||||
maximized ? "view-restore-symbolic" : "view-fullscreen-symbolic",
|
||||
maximized ? "back to the whole layout"
|
||||
: "show this figure full screen",
|
||||
app.maximize_buttons);
|
||||
g_object_set_data(G_OBJECT(b), kMaximizeKey, GINT_TO_POINTER(fr + 1));
|
||||
g_signal_connect(b, "clicked", G_CALLBACK(on_maximize_clicked), &app);
|
||||
}
|
||||
}
|
||||
|
||||
/// Restates where the toggles sit, and rebuilds them when the figure row count changed (a
|
||||
/// resize, a flush, a maximize). Placed from the same `FigureLayout` rects the editor's
|
||||
/// buttons use -- the axes' right edge, so the button sits over the right gutter and never
|
||||
/// over a trace -- and from the rows' top edges, so each one names the row it would enlarge.
|
||||
void sync_maximize_buttons(App& app) {
|
||||
if (app.area_overlay == nullptr || app.area_widget == nullptr || app.area == nullptr) return;
|
||||
const int frows = app.rows->figure_row_count();
|
||||
if (frows != app.maximize_buttons_rows) rebuild_maximize_buttons(app);
|
||||
if (app.maximize_buttons.empty()) return;
|
||||
const plot::FigureLayout& fl = app.rows->figure().layout();
|
||||
const double scale = app.area->device_scale() > 0.0f
|
||||
? static_cast<double>(app.area->device_scale())
|
||||
: 1.0;
|
||||
const double width = static_cast<double>(gtk_widget_get_width(app.area_widget));
|
||||
const double height = static_cast<double>(gtk_widget_get_height(app.area_widget));
|
||||
for (GtkWidget* b : app.maximize_buttons) {
|
||||
const int fr = GPOINTER_TO_INT(g_object_get_data(G_OBJECT(b), kMaximizeKey)) - 1;
|
||||
if (fr < 0 || fr >= frows || fr >= static_cast<int>(fl.rows.size())) {
|
||||
gtk_widget_set_visible(b, FALSE);
|
||||
continue;
|
||||
}
|
||||
const int first = app.rows->first_plot_of_row(fr);
|
||||
const int n = app.rows->cells_in_row(fr);
|
||||
const plot::Plot* last = n > 0 ? app.rows->row(first + n - 1).plot : nullptr;
|
||||
const double right =
|
||||
last != nullptr
|
||||
? static_cast<double>(last->layout().axes.right()) / scale
|
||||
: static_cast<double>(fl.rows[static_cast<std::size_t>(fr)].right()) / scale;
|
||||
const double top =
|
||||
static_cast<double>(fl.rows[static_cast<std::size_t>(fr)].y) / scale + kLayoutButtonPad;
|
||||
gtk_widget_set_halign(b, GTK_ALIGN_START);
|
||||
gtk_widget_set_valign(b, GTK_ALIGN_START);
|
||||
const double by = static_cast<int>(std::clamp(top, 0.0, std::max(0.0, height - kLayoutButtonPx)) + 0.5);
|
||||
gtk_widget_set_margin_top(b, static_cast<int>(by));
|
||||
const double bx = static_cast<int>(std::clamp(right - kLayoutButtonPx - kLayoutButtonPad, 0.0,
|
||||
std::max(0.0, width - kLayoutButtonPx)) + 0.5);
|
||||
gtk_widget_set_margin_start(b, static_cast<int>(bx));
|
||||
// Out of hiding only while the pointer is near this row's corner: the button's own rect
|
||||
// inflated by `kMaximizeRevealPx`. The rect is the placed one (`bx`/`by`), so the zone and
|
||||
// the button cannot disagree -- a zone measured from the axes' edge while the button was
|
||||
// clamped to the widget would leave a corner that never reveals.
|
||||
const bool revealed =
|
||||
app.area_pointer_valid && app.area_pointer_x >= bx - kMaximizeRevealPx &&
|
||||
app.area_pointer_x <= bx + kLayoutButtonPx + kMaximizeRevealPx &&
|
||||
app.area_pointer_y >= by - kMaximizeRevealPx &&
|
||||
app.area_pointer_y <= by + kLayoutButtonPx + kMaximizeRevealPx;
|
||||
gtk_widget_set_visible(b, revealed);
|
||||
}
|
||||
}
|
||||
|
||||
/// The row's toggle: the `kMaximizeKey` data names the figure row it sits on.
|
||||
void on_maximize_clicked(GtkButton* b, gpointer user) {
|
||||
App& app = *static_cast<App*>(user);
|
||||
toggle_fullscreen(app, GPOINTER_TO_INT(g_object_get_data(G_OBJECT(b), kMaximizeKey)) - 1);
|
||||
}
|
||||
|
||||
/// The plot menu's entry: the figure row the clicked plot belongs to, through the same action,
|
||||
/// with the popover dismissed like every other row's.
|
||||
void on_fullscreen_row(GtkButton* b, gpointer user) {
|
||||
App& app = *static_cast<App*>(user);
|
||||
toggle_fullscreen(app, app.rows->figure_row_of(row_key(GTK_WIDGET(b))));
|
||||
GtkPopover* popover = GTK_POPOVER(g_object_get_data(G_OBJECT(b), "udpscope-popover"));
|
||||
if (popover != nullptr) gtk_popover_popdown(popover);
|
||||
}
|
||||
|
||||
/// The plot menu's way into the layout editor: the same toggle the overflow menu's `Layout editor`
|
||||
/// entry flips, so the menu cannot drift from the button's own state -- and the place a user who is
|
||||
/// looking at the plot will find splitting.
|
||||
void on_row_menu_layout_editor(GtkButton* b, gpointer user) {
|
||||
App& app = *static_cast<App*>(user);
|
||||
if (app.layout_toggle != nullptr) {
|
||||
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(app.layout_toggle), !app.layout_editing);
|
||||
}
|
||||
GtkPopover* popover = GTK_POPOVER(g_object_get_data(G_OBJECT(b), "udpscope-popover"));
|
||||
if (popover != nullptr) gtk_popover_popdown(popover);
|
||||
}
|
||||
|
||||
void rebuild_layout_editor(App& app) {
|
||||
clear_layout_editor(app);
|
||||
if (!app.layout_editing || app.area_overlay == nullptr) return;
|
||||
@@ -1684,7 +1911,23 @@ GtkWidget* measures_row(App& app, std::size_t index, const Measure& m) {
|
||||
gtk_label_set_markup(GTK_LABEL(sig_chip), measure_chip_markup(app, m.signal).c_str());
|
||||
gtk_widget_set_tooltip_text(sig_chip, m.signal.c_str());
|
||||
gtk_box_append(GTK_BOX(box), sig_chip);
|
||||
GtkWidget* kind = gtk_label_new(measure_type_name(m.type));
|
||||
// The row's who: the channel's number and name, then the type. The number is the active
|
||||
// channel's (see `measure_channel_number`) -- the same one the signal's badge carries -- so
|
||||
// a row, a badge and the signal page all answer "which channel is this" with one word.
|
||||
// Markup because the name is the stream's text; escaped for the same reason. A signal that
|
||||
// is not acquired keeps the old type-only row (there is no number to show and the row is
|
||||
// already grey).
|
||||
const int number = measure_channel_number(app, m.signal);
|
||||
std::string kind_text;
|
||||
{
|
||||
char* esc = g_markup_escape_text(m.signal.c_str(), -1);
|
||||
kind_text = number > 0 ? "<b>" + std::to_string(number) + ". " + esc + "</b> · " +
|
||||
measure_type_name(m.type)
|
||||
: std::string(measure_type_name(m.type));
|
||||
g_free(esc);
|
||||
}
|
||||
GtkWidget* kind = gtk_label_new(nullptr);
|
||||
gtk_label_set_markup(GTK_LABEL(kind), kind_text.c_str());
|
||||
gtk_widget_set_halign(kind, GTK_ALIGN_START);
|
||||
gtk_widget_set_hexpand(kind, true);
|
||||
gtk_label_set_ellipsize(GTK_LABEL(kind), PANGO_ELLIPSIZE_END);
|
||||
@@ -1982,9 +2225,22 @@ void open_measure_dialog(App& app, int edit_index) {
|
||||
}
|
||||
// The `const char*` array is built from the *finished* strings: taking `c_str()` as the
|
||||
// vector grew left every pointer pointing into memory the next push reallocated.
|
||||
//
|
||||
// Each entry is shown with its **channel number** (`measure_channel_number`), the same one
|
||||
// the pane's rows and the signal badges carry -- the dropdown and the rows name a channel
|
||||
// alike, which is what makes "1. sine" in one findable in the other. `d->signals` keeps the
|
||||
// raw names: the number is decoration, and Save resolves through the raw name. An entry
|
||||
// that is not an active channel (a persistence layer, `sine (A)`, whose base signal's
|
||||
// channel is someone else's) shows unnumbered rather than borrowing one.
|
||||
std::vector<std::string> sig_display;
|
||||
sig_display.reserve(sig_names.size());
|
||||
std::vector<const char*> sig_cstrs;
|
||||
sig_cstrs.reserve(sig_names.size() + 1);
|
||||
for (const std::string& n : sig_names) sig_cstrs.push_back(n.c_str());
|
||||
for (const std::string& n : sig_names) {
|
||||
const int number = measure_channel_number(app, n);
|
||||
sig_display.push_back(number > 0 ? std::to_string(number) + ". " + n : n);
|
||||
}
|
||||
for (const std::string& n : sig_display) sig_cstrs.push_back(n.c_str());
|
||||
sig_cstrs.push_back(nullptr);
|
||||
if (editing != nullptr) {
|
||||
const auto it = std::find(sig_names.begin(), sig_names.end(), editing->signal);
|
||||
@@ -2051,10 +2307,23 @@ void open_measure_dialog(App& app, int edit_index) {
|
||||
"at the time it happened, the pulse's edges as markers");
|
||||
gtk_box_append(GTK_BOX(box), show);
|
||||
|
||||
// The per-measurement gate: one check per measurement, one band shared by all of them. The
|
||||
// band is shown while any measurement is gated (`rebuild_measures_pane()` derives it), and
|
||||
// it is the thing this check switches on the plots -- the measurement itself only names
|
||||
// which side of the statistics question reads inside it. Shown for every type: the gate is
|
||||
// about *which samples* answer, and every type reads samples.
|
||||
GtkWidget* gate = gtk_check_button_new_with_label("Gate enabled");
|
||||
gtk_widget_set_tooltip_text(gate,
|
||||
"restrict this measurement to the shared gate band on the plots "
|
||||
"(violet). The band is shown while any measurement is gated, and "
|
||||
"stays where it is put: live it names an age (\"300 ms ago to "
|
||||
"100 ms ago\"), on a held event a fixed slice of the frame");
|
||||
gtk_box_append(GTK_BOX(box), gate);
|
||||
|
||||
auto* d = new MeasureDialog{&app, edit_index, signal_drop, type_drop,
|
||||
threshold, band, above, show,
|
||||
window, threshold_row, threshold_label, band_row,
|
||||
band_label, side_row, side_label,
|
||||
gate, window, threshold_row, threshold_label,
|
||||
band_row, band_label, side_row, side_label,
|
||||
noise, noise_row, hf, hf_row,
|
||||
sig_names};
|
||||
app.measure_dialog = window;
|
||||
@@ -2083,6 +2352,7 @@ void open_measure_dialog(App& app, int edit_index) {
|
||||
gtk_spin_button_set_value(GTK_SPIN_BUTTON(hf), editing->options.hf_fc);
|
||||
gtk_switch_set_active(GTK_SWITCH(above), editing->options.above);
|
||||
gtk_check_button_set_active(GTK_CHECK_BUTTON(show), editing->options.show_on_plot);
|
||||
gtk_check_button_set_active(GTK_CHECK_BUTTON(gate), editing->options.gate);
|
||||
} else {
|
||||
gtk_spin_button_set_value(GTK_SPIN_BUTTON(threshold), 50.0);
|
||||
gtk_spin_button_set_value(GTK_SPIN_BUTTON(band), MeasureOptions{}.threshold_pct2);
|
||||
@@ -2663,6 +2933,7 @@ GtkWidget* build_measures_panel(App& app) {
|
||||
update_measures(*a, /*force=*/true, /*accumulate=*/true);
|
||||
}),
|
||||
&app);
|
||||
|
||||
// The heading row -- the .ui's anonymous first child, carrying the title and the `+`. It is where
|
||||
// the contextual help's "?" joins (see `ui_context_help.cpp`).
|
||||
app.measures_head = gtk_widget_get_first_child(box);
|
||||
@@ -3069,6 +3340,21 @@ void row_menu_fill(RowMenuState& st) {
|
||||
// is what "fit" means here. `F` is the plot's own key for the second of them.
|
||||
add_row("Fit X axis", nullptr, G_CALLBACK(on_fit_x_clicked), &app);
|
||||
add_row("Fit all", "F", G_CALLBACK(on_fit_clicked), &app);
|
||||
// One click from a row to that row alone filling the plot: the button on the figure's
|
||||
// corner does the same, and the menu is where a user who cannot see it looks. A layout of
|
||||
// one figure has nothing to maximize *to*, so the entry appears only when it would act.
|
||||
if (app.rows->figure_row_count() > 1 || app.rows->figures_maximized()) {
|
||||
add_row(app.rows->figures_maximized() ? "Restore the layout" : "Full screen this figure",
|
||||
nullptr, G_CALLBACK(on_fullscreen_row), &app);
|
||||
}
|
||||
// One click from looking at a figure to having it in a window of its own -- the second
|
||||
// window copies this plot's figure, so the picture on screen is the picture it opens with.
|
||||
add_row("Open this plot in a new window", nullptr, G_CALLBACK(on_open_scope_row), &app);
|
||||
// Arranging is a plot gesture too: the editor's own buttons live on the plot, but the way in
|
||||
// belongs where the arrangement is being judged. It flips the app's one toggle, so its label
|
||||
// and the overflow menu's state cannot disagree.
|
||||
add_row(app.layout_editing ? "Done editing the layout" : "Edit the layout...", "L",
|
||||
G_CALLBACK(on_row_menu_layout_editor), &app);
|
||||
|
||||
gtk_box_append(GTK_BOX(st.box), menu_separator());
|
||||
// A row, not a `GtkMenuButton`: the removals are the *next view of this menu*, and the chevron
|
||||
@@ -3768,6 +4054,11 @@ gboolean tick(gpointer user) {
|
||||
reset_measure_stats(*app);
|
||||
app->search.clear();
|
||||
rebuild_signal_panels(*app);
|
||||
// The measures pane too, for the row's channel number: the number is the channel's
|
||||
// position in the new list, and a rebuild is what re-derives it. The stats were reset
|
||||
// above for exactly this reason (the old readings do not belong to the new list), so the
|
||||
// rebuild's own reset is the same act, not a second one.
|
||||
rebuild_measures_pane(*app);
|
||||
}
|
||||
// A gesture moves the *plot's* cursor; adopting it here is what makes the two
|
||||
// vertical markers one time across every row, and what the pane then reads.
|
||||
@@ -3776,16 +4067,25 @@ gboolean tick(gpointer user) {
|
||||
// (`cursor(id).x` is the free pointer position while a drag runs and the snapped value
|
||||
// otherwise, so reading outside a gesture reads our own echo).
|
||||
int cursor_drag_row = -1;
|
||||
// The same question for the measurement gate's region: a drag on it is the only time
|
||||
// `region(id)`'s bounds are the hand's and not our own last write.
|
||||
int gate_drag_row = -1;
|
||||
if (app->area != nullptr) {
|
||||
for (int i = 0; i < app->rows->row_count(); ++i) {
|
||||
plot::input::Controller* c = app->area->row_controller(static_cast<plot::u32>(i));
|
||||
if (c != nullptr && c->grabbed_cursor() != plot::kInvalid) {
|
||||
cursor_drag_row = i;
|
||||
break;
|
||||
}
|
||||
if (c == nullptr) continue;
|
||||
if (cursor_drag_row < 0 && c->grabbed_cursor() != plot::kInvalid) cursor_drag_row = i;
|
||||
if (gate_drag_row < 0 && c->grabbed_region() != plot::kInvalid) gate_drag_row = i;
|
||||
}
|
||||
}
|
||||
app->rows->sync_cursors(cursor_drag_row);
|
||||
app->rows->sync_gate(gate_drag_row);
|
||||
// The drag's end is a new pair of gate bounds, and the bounds are session state: save there,
|
||||
// the way a dragged row sizing is saved when its own gesture ends. During the drag every
|
||||
// frame would only re-arm the same 400 ms debounce, but the *end* is the one moment the
|
||||
// state is neither mid-gesture nor already saved.
|
||||
if (app->gate_drag_last >= 0 && gate_drag_row < 0) schedule_layout_save(*app);
|
||||
app->gate_drag_last = gate_drag_row;
|
||||
update_cursors_pane(*app);
|
||||
update_measures(*app, /*force=*/false, /*accumulate=*/true);
|
||||
// Every frame, whether or not a scan is running: the marks are in stream seconds and the axis
|
||||
@@ -3875,6 +4175,13 @@ gboolean tick(gpointer user) {
|
||||
app->rows_b->bind(model, display);
|
||||
app->rows_b->acquire(model);
|
||||
}
|
||||
// A scope window is a second view of the same data: its own figure binds and acquires
|
||||
// under the same lock, and the window/pin mirroring after it keeps the two windows on
|
||||
// one span.
|
||||
for (const std::unique_ptr<App::ScopeWindow>& scope : app->scopes) {
|
||||
scope->rows->bind(model, display);
|
||||
scope->rows->acquire(model);
|
||||
}
|
||||
// What x = 0 means, written *after* the bind and the acquire on purpose: the live answer
|
||||
// is minus the newest sample the traces can now report, and asking before they were bound
|
||||
// would put the newest data a frame's production to the right of the axis -- a third of
|
||||
@@ -3914,6 +4221,23 @@ gboolean tick(gpointer user) {
|
||||
// acquisition thread mutates, so it belongs under this lock and not after it.
|
||||
app->rows->set_invalid_spans(model, display);
|
||||
}
|
||||
// The scope windows, outside the lock: mirroring a window, restating a pin and asking for a
|
||||
// render are RowSet and GTK work, not model reads. The span follows the main window every
|
||||
// frame -- a scope is "the same picture, another window" -- and its own media (a held event
|
||||
// binds the same snapshot under the same epoch) comes from the bind above.
|
||||
if (!app->scopes.empty()) {
|
||||
const plot::Range pin = app->rows->pinned_range();
|
||||
const bool pinned = app->rows->window_pinned();
|
||||
const double window_s = app->rows->time_window();
|
||||
for (const std::unique_ptr<App::ScopeWindow>& scope : app->scopes) {
|
||||
if (pinned) {
|
||||
scope->rows->pin_event_window(pin.min, pin.max);
|
||||
} else if (std::fabs(scope->rows->time_window() - window_s) > 1.0e-12) {
|
||||
scope->rows->set_time_window(window_s);
|
||||
}
|
||||
if (scope->widget != nullptr) gtk_gl_area_queue_render(GTK_GL_AREA(scope->widget));
|
||||
}
|
||||
}
|
||||
// A depth the room or the retention budget could not show in full says so, once per *plan* rather
|
||||
// than once per frame: a user who asked for eight layers and sees two has no other way to tell
|
||||
// "the display is deliberately shallower" from "the feature is broken". Both bounds are named,
|
||||
@@ -4299,6 +4623,9 @@ gboolean tick(gpointer user) {
|
||||
// The editor's buttons are placed from the rects this frame was laid out with, so they
|
||||
// follow a resize and the gutter settling without being told.
|
||||
if (app->layout_editing) sync_layout_editor(*app);
|
||||
// The full-screen toggles are on the overlay always, so they follow the figure every frame
|
||||
// (and rebuild themselves when the figure row count changed).
|
||||
sync_maximize_buttons(*app);
|
||||
|
||||
// The one thing that has to be decided *per frame* about the dock: whether there is room for it
|
||||
// beside the plot. Collapsed, the split view puts it *over* the plot rather than giving it a share of
|
||||
@@ -4648,6 +4975,31 @@ gboolean tick(gpointer user) {
|
||||
gtk_widget_set_tooltip_text(app->status_badge, kWhy[reason]);
|
||||
}
|
||||
|
||||
// The trigger's own circle, one row up in the same alphabet as the badge: solid green while
|
||||
// the trigger has fired (an event held, or a capture filling), flashing green while armed
|
||||
// and waiting for one, red while armed-able but disarmed, and *hidden* while the trigger is
|
||||
// off -- a light for a mode the user is not in is noise, and the mode line next to it is
|
||||
// not showing trigger words either. The flash is the badge's own half-second square, and
|
||||
// the markup is rewritten on a change of the state key only, for the same relayout reason
|
||||
// the badge's is.
|
||||
const int circle = !trigger_on ? -1
|
||||
: (app->event_held || app->capturing)
|
||||
? 0
|
||||
: armed ? (lit ? 1 : 2) : 3;
|
||||
app->dbg_trigger_circle = circle;
|
||||
if (app->trigger_status != nullptr && circle != app->trigger_circle_state) {
|
||||
app->trigger_circle_state = circle;
|
||||
gtk_widget_set_visible(app->trigger_status, circle >= 0);
|
||||
if (circle >= 0) {
|
||||
gtk_label_set_markup(GTK_LABEL(app->trigger_status), trigger_circle_markup(circle));
|
||||
gtk_widget_set_tooltip_text(
|
||||
app->trigger_status,
|
||||
circle == 0 ? "trigger: triggered (an event is on screen)"
|
||||
: circle == 3 ? "trigger: disarmed (Arm to wait for an event)"
|
||||
: "trigger: armed, waiting for an event");
|
||||
}
|
||||
}
|
||||
|
||||
int bound = 0;
|
||||
std::size_t samples = 0;
|
||||
for (int s = 0; s < RowSet::kSlotsPerRow; ++s) {
|
||||
@@ -5169,6 +5521,15 @@ GtkWidget* build_toolbar(App& app) {
|
||||
app.menu_help_row = help_row;
|
||||
gtk_box_append(GTK_BOX(menu_box), help_row);
|
||||
|
||||
GtkWidget* scope_item = menu_button("New scope window", nullptr);
|
||||
gtk_widget_set_tooltip_text(scope_item,
|
||||
"a second window onto the same stream, with an empty figure; "
|
||||
"a plot's own menu opens that figure in one");
|
||||
g_signal_connect(scope_item, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
|
||||
open_scope_window(*static_cast<App*>(user), -1);
|
||||
}),
|
||||
&app);
|
||||
gtk_box_append(GTK_BOX(menu_box), scope_item);
|
||||
GtkWidget* prefs_item = menu_button("Preferences\u2026", nullptr);
|
||||
g_signal_connect(prefs_item, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
|
||||
open_preferences(*static_cast<App*>(user));
|
||||
@@ -5283,6 +5644,7 @@ void on_activate(GtkApplication* application, gpointer user) {
|
||||
app->plot_split = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "plot_split"));
|
||||
app->plot_overlay = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "plot_overlay"));
|
||||
app->status_badge = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "status_badge"));
|
||||
app->trigger_status = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "trigger_status"));
|
||||
app->status_label = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "status_label"));
|
||||
app->status_info_button = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "status_info"));
|
||||
// The status line's row, which is where the status line's own "?" joins and one of the surfaces the
|
||||
@@ -5309,18 +5671,43 @@ void on_activate(GtkApplication* application, gpointer user) {
|
||||
}
|
||||
// The window's actions live on the application, so it has to know which one it belongs to.
|
||||
gtk_window_set_application(GTK_WINDOW(app->window), application);
|
||||
// A window closed by hand (a WM close button) must stop the frame tick: it holds
|
||||
// pointers to widgets that are finalized with the window.
|
||||
g_signal_connect(app->window, "destroy", G_CALLBACK(+[](GtkWidget*, gpointer user) {
|
||||
// The destroy handler below needs the application, and a captureless callback cannot close
|
||||
// over it: kept on the window, which outlives the handler.
|
||||
g_object_set_data(G_OBJECT(app->window), "udpscope-application", application);
|
||||
// The main window's lifecycle hook, and it is `close-request`: **GTK4 has no
|
||||
// `GtkWidget::destroy` signal** (the GTK3 connect this used to be never fired), and the only
|
||||
// way a window is asked to end is the close request -- the WM's button, `gtk_window_close()`
|
||||
// from a menu action, and the exit probe's own close. `gtk_window_destroy()` does not emit
|
||||
// it, which is why everything that ends this window programmatically goes through
|
||||
// `gtk_window_close()` instead.
|
||||
//
|
||||
// What it does, in order: the **scope windows** go with it (they are views of this window's
|
||||
// acquisition; left behind they keep the GApplication's loop alive around a window that is
|
||||
// gone -- the exit run's hang), the debounced layout save is flushed (a pending save would
|
||||
// never fire now), the tick is stopped (`a->window = nullptr`; the widgets are about to be
|
||||
// finalized), and the application is asked to quit -- the main window is its anchor, so the
|
||||
// run must end whatever else was open. `FALSE` lets the default handler destroy the window.
|
||||
g_signal_connect(app->window, "close-request", G_CALLBACK(+[](GtkWindow* window, gpointer user) {
|
||||
App* a = static_cast<App*>(user);
|
||||
// A pending debounced save will never fire now: flush it, so
|
||||
// quitting right after a change keeps the change.
|
||||
// Collected first: each scope's own destroy handler erases it from
|
||||
// `scopes` as it fires.
|
||||
std::vector<GtkWindow*> scope_windows;
|
||||
scope_windows.reserve(a->scopes.size());
|
||||
for (const std::unique_ptr<App::ScopeWindow>& scope : a->scopes) {
|
||||
if (scope->window != nullptr) scope_windows.push_back(scope->window);
|
||||
}
|
||||
for (GtkWindow* w : scope_windows) gtk_window_close(w);
|
||||
if (a->layout_save_source != 0) {
|
||||
g_source_remove(a->layout_save_source);
|
||||
a->layout_save_source = 0;
|
||||
save_layout_now(*a);
|
||||
}
|
||||
a->window = nullptr;
|
||||
if (auto* application = GTK_APPLICATION(
|
||||
g_object_get_data(G_OBJECT(window), "udpscope-application"))) {
|
||||
g_application_quit(G_APPLICATION(application));
|
||||
}
|
||||
return FALSE;
|
||||
}),
|
||||
app);
|
||||
// The header bar is the window's own titlebar, and it is *not* in the `.ui`: every button on it is
|
||||
@@ -5362,6 +5749,12 @@ void on_activate(GtkApplication* application, gpointer user) {
|
||||
// tooltip says which phase the badge is *not* reporting.
|
||||
gtk_label_set_markup(GTK_LABEL(app->status_badge), badge_markup(BadgePhase::Idle));
|
||||
gtk_widget_set_tooltip_text(app->status_badge, "waiting for the stream");
|
||||
// The trigger circle starts hidden: the trigger starts off, and a light for a mode the user
|
||||
// is not in is noise (see the tick's write).
|
||||
if (app->trigger_status != nullptr) {
|
||||
gtk_label_set_markup(GTK_LABEL(app->trigger_status), trigger_circle_markup(3));
|
||||
gtk_widget_set_visible(app->trigger_status, FALSE);
|
||||
}
|
||||
|
||||
// The line is rewritten every frame and its text changes width as the counters
|
||||
// grow. A label's *minimum* width is its text, so an unellipsized one pushes the
|
||||
@@ -6122,6 +6515,9 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
|
||||
app.model = &model;
|
||||
app.acq = &acq;
|
||||
app.rows = &rows;
|
||||
// The crosshair's axis badges, before the window exists: the setting is per plot, and the
|
||||
// flag on the set is what every rebuild restates (see `RowSet::install()`).
|
||||
rows.set_crosshair_axis_labels(startup_settings.crosshair_axis_labels);
|
||||
// Which data source this session talks to: the persisted-layout file's name.
|
||||
// A waveform has **no source**: the address the command line defaults to is not one, and treating
|
||||
// it as one made a file-mode run load -- and then *save* -- the session of `127.0.0.1:8000`, which
|
||||
@@ -6141,11 +6537,20 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
|
||||
app.source_title = app.source_host + ":" + std::to_string(app.source_port);
|
||||
}
|
||||
if (SourceKind* k = find_kind(cfg.source_kind)) app.source_has_datagrams = k->has_datagrams();
|
||||
// The event cache is keyed on the source, like the packet capture. A waveform has no source, so a
|
||||
// file-mode run has no cache either -- there are no latches in a file.
|
||||
// The event cache is keyed on the source **and its active configuration**, like the packet
|
||||
// capture and the session files: an event's trigger, channel set and units belong to the
|
||||
// configuration it was latched under, so two configurations of one source must not list each
|
||||
// other's events. The active configuration is readable here -- the configuration files are not
|
||||
// stream data -- so the store starts on the right one instead of being re-keyed after the first
|
||||
// CONFIG arrives. A waveform has no source, so a file-mode run has no cache either -- there are
|
||||
// no latches in a file.
|
||||
if (!app.source_host.empty() && app.source_port != 0) {
|
||||
app.event_store = std::make_unique<EventStore>(app.source_host + "-" +
|
||||
std::to_string(app.source_port), app.settings.event_cache_dir);
|
||||
if (app.config_name.empty()) {
|
||||
app.config_name = active_configuration(app.source_host, app.source_port);
|
||||
}
|
||||
app.event_store = std::make_unique<EventStore>(
|
||||
app.source_host + "-" + std::to_string(app.source_port), app.settings.event_cache_dir,
|
||||
app.config_name);
|
||||
}
|
||||
|
||||
refresh_events_pane(app);
|
||||
@@ -6244,6 +6649,18 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
|
||||
if (const char* env = g_getenv("UDPSCOPE_PROBE_MEASURE_MS")) {
|
||||
g_timeout_add(static_cast<guint>(std::atoi(env)), measure_probe, &app);
|
||||
}
|
||||
if (const char* env = g_getenv("UDPSCOPE_PROBE_GATE_MS")) {
|
||||
g_timeout_add(static_cast<guint>(std::atoi(env)), gate_probe_step, &app);
|
||||
}
|
||||
if (const char* env = g_getenv("UDPSCOPE_PROBE_CROSSHAIR_MS")) {
|
||||
g_timeout_add(static_cast<guint>(std::atoi(env)), crosshair_probe, &app);
|
||||
}
|
||||
if (const char* env = g_getenv("UDPSCOPE_PROBE_MAXIMIZE_MS")) {
|
||||
g_timeout_add(static_cast<guint>(std::atoi(env)), maximize_probe_step, &app);
|
||||
}
|
||||
if (const char* env = g_getenv("UDPSCOPE_PROBE_SCOPE_MS")) {
|
||||
g_timeout_add(static_cast<guint>(std::atoi(env)), scope_probe_step, &app);
|
||||
}
|
||||
if (const char* env = g_getenv("UDPSCOPE_PROBE_LEVELS_MS")) {
|
||||
g_timeout_add(static_cast<guint>(std::atoi(env)), levels_probe, &app);
|
||||
}
|
||||
@@ -6732,7 +7149,16 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
|
||||
std::printf("[udpscope] measurements: %zu", app.measures.size());
|
||||
for (std::size_t i = 0; i < app.measures.size() && i < app.measure_results.size(); ++i) {
|
||||
const MeasureResult& r = app.measure_results[i];
|
||||
std::printf(" %s=%s", measure_type_name(app.measures[i].type),
|
||||
// Numbered like the pane's row: which active channel this measurement is about, and
|
||||
// unnumbered when its signal is not acquired -- a probe that switched a signal off
|
||||
// before the dump (enable_all does exactly that) must not read as a numbering fault.
|
||||
// The number is read from the model here (the dump cannot ask the row's label), so a
|
||||
// mismatch with what the pane showed while the signal was up is a finding about
|
||||
// `measure_channel_number`.
|
||||
const int number = measure_channel_number(app, app.measures[i].signal);
|
||||
const std::string prefix = number > 0 ? std::to_string(number) + "." : "";
|
||||
std::printf(" %s%s %s=%s", prefix.c_str(),
|
||||
app.measures[i].signal.c_str(), measure_type_name(app.measures[i].type),
|
||||
r.valid ? std::to_string(r.primary).c_str() : "--");
|
||||
if (r.has_secondary) std::printf("(%s)", std::to_string(r.secondary).c_str());
|
||||
}
|
||||
@@ -6762,6 +7188,20 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
|
||||
: 0.0);
|
||||
}
|
||||
}
|
||||
// The measurement gate's own line: whether it is on and where its bounds sit. The state is
|
||||
// the RowSet's (no GTK here -- the dump runs after the window is gone), and it is what a
|
||||
// session round trip is read against: a restore that left the gate off, or put the band
|
||||
// somewhere else than the saved session had it, reads here.
|
||||
std::printf("[udpscope] gate: on=%d bounds=[%.6f..%.6f]\n",
|
||||
app.rows->gate_visible() ? 1 : 0, app.rows->gate_x0(), app.rows->gate_x1());
|
||||
// The trigger circle's state as of the last tick: off / green / flash / dim / red. The
|
||||
// dump runs after the window is gone, so this is the tick's cached answer, not a read.
|
||||
{
|
||||
static const char* const kCircle[] = {"green", "flash", "dim", "red"};
|
||||
const int c = app.dbg_trigger_circle;
|
||||
std::printf("[udpscope] trigger circle: %s\n",
|
||||
c < 0 ? "off" : (c < 4 ? kCircle[c] : "?"));
|
||||
}
|
||||
std::printf("[udpscope] badge frames: lit=%d dim=%d idle=%d connecting=%d\n",
|
||||
app.dbg_badge_frames[0], app.dbg_badge_frames[1], app.dbg_badge_frames[2],
|
||||
app.dbg_badge_frames[3]);
|
||||
|
||||
Reference in new issue
Block a user