improving doc

This commit is contained in:
Martino Ferrari committed 2026-10-05 00:28:10 +02:00
1 parent 02c53bb7a0
commit 1fc2a3afe2
11 files changed
+678 -76

No files matched your search

+64 -18
View File
@@ -1,26 +1,72 @@
<b>What udpscope is</b>
udpscope plots a live UDPStreamer source. One window holds up to 8 rows of plots, each
with up to 4 traces; every row shares one time axis, so the whole figure scrolls together.
Acquisition runs on its own thread and writes a ring buffer sized from --buffer-mb; the
drawing thread reads whatever that ring holds when a frame starts.
udpscope plots a live UDPStreamer source. One window holds up to 8 rows of plots, each with up
to 4 traces, and every row shares one time axis, so the whole figure scrolls together.
Acquisition runs on its own thread and writes a ring buffer sized from <tt>--buffer-mb</tt>;
the drawing thread reads whatever that ring holds when a frame starts, which is why a trace
never makes the window stutter.
The stream is the clock. Every time you read on screen is a timestamp the source sent, never
your machine's, so a stalled source freezes the view instead of scrolling it -- and a source
whose clock steps still gets the window you asked for.
<b>How to reach it</b>
Start the app with a source (<tt>udpscope --server 127.0.0.1 --port 8000</tt>), or start it
with no <tt>--server</tt> and pick one from the chooser. The signals arrive disabled; turn
one on in the side panel (<tt>s</tt>) and drag it onto a row.
Start the app with a source (<tt>udpscope --server 127.0.0.1 --port 8000</tt>), or start it with
no <tt>--server</tt> and pick one from the chooser, which lists the sessions you have opened
before. A unicast source serves one client at a time: if a second viewer connects, they take
turns and the status bar says so. To watch one source from several instances, use
<tt>--multicast</tt> with the group and <tt>--interface</tt> with the local address.
The signals arrive <i>disabled</i>. Nothing is acquired until you turn one on in the signal
list, and enabling is what starts acquisition -- so a freshly opened scope with an idle side
panel is working correctly, not failing to connect.
<b>The window</b>
• <b>The header bar</b> -- left to right: the time window (<tt>2 s</tt>), the trigger and the
pause, then the pane switches -- signal list, cursors, measurements, math, side panel -- and
the menu, which holds Fit to data, the layout editor, Export, Keyboard shortcuts, Help and
About.
• <b>The side panel</b> -- the trigger, cursors, measurements and math panes at the top, then
the signal list: one row per signal with its colour, an enable switch, its name and its unit.
Signals are dragged from here onto a plot row.
• <b>The plot</b> -- up to 8 rows, each with its own y axis and up to 4 traces, all on one
shared time axis. Right-click a row for its own menu (fit, add a signal, remove a trace).
• <b>The status bar</b>, directly above the plot's bottom edge -- the state word, the stored
sample rate and the window, and the ⓘ beside it for packets, gaps and the buffer's own
figures. The second screenshot above is what a held capture looks like: <tt>event held</tt>,
the axis pinned to the event, and the trigger marker at the edge.
<b>The first five minutes</b>
1. <b>Connect.</b> With no <tt>--server</tt>, pick a stored session or type a host and port.
2. <b>Enable a signal</b> in the signal list. The plot stays empty for the first fraction of a
second while the first window fills -- the buffer holds only what has arrived.
3. <b>Drag it onto a row.</b> Drop a second signal on another row, or the same signal again for
a second view of it. The colour is per signal, so it is the same one wherever it appears.
4. <b>Set the window</b> with the control in the header bar: type a number, pick a unit, or step
a decade. The trigger's event window is a share of this span, so a window change moves it.
5. <b>Pause</b> (<tt>space</tt>) to look at a still picture; the producer keeps filling the
buffer behind it, and resuming shows what arrived meanwhile rather than a hole.
6. <b>Arm the trigger</b> (<tt>a</tt>) and wait for a latch: the view freezes on one event, and
you can zoom in on it. Disarming mid-capture keeps what the capture got.
7. <b>Measure it</b> -- the measurements pane computes a statistic of the visible window, and
<b>show on plot</b> marks it as a ruler or a pair of markers.
8. <b>Save the data</b> with Export visible range, which writes the plotted signals over the
window at the stored rate to <tt>.npy</tt> or <tt>.csv</tt>.
<b>Worth knowing</b>
• Nothing is acquired until a signal is enabled. Enabling is what starts acquisition, so a
freshly opened scope with an idle side panel is working correctly.
• The stream carries its own clock. Every time on screen is a stream timestamp, never the
local wall clock, which is why a stalled source freezes the view instead of scrolling it.
• The window remembers itself. Rows, traces, the time window, calibration, math channels
and measurements are written to a session file per host and port, and reloaded next time.
<b>Example</b>
A 3-signal source at 10 kS/s with a 200 MB buffer holds 1677 s of history. Enable all three,
drop them on row 1, and the status bar reads <tt>● live · 10 kS/s · 2 s window</tt>.
• A signal nobody enables costs nothing -- no buffer, no socket work, no measurements.
• The window remembers itself. Rows, traces, the time window, calibration, which signals were
enabled, the math channels with their graphs, and the measurements are written per host and
port, and reloaded the next time you open that source.
• A very wide window on a fast stream is drawn from averaged groups of samples, so the rate in
the status bar falls: that is the anti-alias filter, not a fault -- striding would fold a fast
signal back as a slow one, which looks entirely plausible on screen.
• Every instrument the app computed -- the trigger marker, the measurement marks -- is locked to
the pointer's reach. The cursors are yours to drag; those are not.
+16
View File
@@ -33,3 +33,19 @@ orange dot marks the output node.
same source passes 50 Hz at 0.97 and 2 kHz at 0.002; a 1 kHz band pass with Q=2 passes 200 Hz at
0.05, 1 kHz at 0.95 and 4 kHz at 0.05. Each derived channel must hold exactly as many samples as
the shared clock has time slots.
<b>Why an operation sometimes does nothing</b>. A graph's <i>output</i> is the node whose value
becomes the channel, and it is drawn as the orange dot. A new graph is built source, then operation,
and the last node added becomes the output -- but a graph whose output is left on the source is
valid and simply passes its input through, which is the usual reason a gain or a filter appears to
have no effect. <i>Set as output</i> moves the dot.
<b>Save refuses an incomplete graph</b>, and says why: an input that is not connected, no output
chosen, or a <i>channel</i> node naming a signal the scope is not acquiring. Only the channels being
acquired are offered, because a source that is not acquired reads as no data for ever -- and a graph
that reads NaN is harder to diagnose than one that would not save.
<b>The filters are real filters.</b> A math channel's samples are produced as the stream arrives,
once per stored slot, so a low pass carries the history a filter needs. A cutoff typed in hertz is
against the <i>stored</i> rate, which is the wire rate until a wide window makes the buffer decimate
-- the status line's rate is that number, and the filters are rebuilt whenever it changes.
+7
View File
@@ -23,5 +23,12 @@
<file>12-export.txt</file>
<file>13-sessions.txt</file>
<file>14-status.txt</file>
<!-- The screenshots live with the user documentation (docs/); the resource aliases
them under `img/` so a help page can name one by resource path. The path is relative
to this manifest's directory. -->
<file alias="img/main-window.png">../../docs/img/main-window.png</file>
<file alias="img/trigger-capture.png">../../docs/img/trigger-capture.png</file>
<file alias="img/math-editor.png">../../docs/img/math-editor.png</file>
</gresource>
</gresources>
+52 -4
View File
@@ -40,22 +40,38 @@ struct HelpPage {
const char* section; ///< the group heading in the sidebar
const char* title; ///< the row's label and the page's heading
const char* resource; ///< a path under /org/udpscope/help
/// The screenshots shown above the text, as resource paths and a null terminator. A page
/// with none is the common case and the reason this is a list rather than one field: the
/// overview carries two, the window and a held capture.
const char* const* images = nullptr;
};
/// Adding a page is one .txt file, one <file> line in the manifest and one row here.
/// The order here is the order the sidebar shows; `section` groups consecutive rows.
/// How tall a page's screenshots are drawn. They are scaled to fit this *height* and centre
/// themselves in it, so a 1500x950 capture is readable without a horizontal scrollbar.
constexpr int kHelpShotHeight = 260;
/// The screenshots each page carries, in the order it shows them.
constexpr const char* kOverviewShots[] = {"/org/udpscope/help/img/main-window.png",
"/org/udpscope/help/img/trigger-capture.png",
nullptr};
constexpr const char* kTriggerShots[] = {"/org/udpscope/help/img/trigger-capture.png",
nullptr};
constexpr const char* kMathShots[] = {"/org/udpscope/help/img/math-editor.png", nullptr};
constexpr HelpPage kPages[] = {
{"Getting started", "Overview", "/org/udpscope/help/01-overview.txt"},
{"Getting started", "Overview", "/org/udpscope/help/01-overview.txt", kOverviewShots},
{"Getting started", "Choosing a source", "/org/udpscope/help/02-source.txt"},
{"Getting started", "Signals and calibration", "/org/udpscope/help/03-signals.txt"},
{"Getting started", "Rows, layout and drag & drop", "/org/udpscope/help/04-layout.txt"},
{"Viewing", "Time window", "/org/udpscope/help/05-window.txt"},
{"Viewing", "Navigating the plot", "/org/udpscope/help/06-navigate.txt"},
{"Viewing", "Pause and play", "/org/udpscope/help/07-pause.txt"},
{"Viewing", "Trigger", "/org/udpscope/help/08-trigger.txt"},
{"Viewing", "Trigger", "/org/udpscope/help/08-trigger.txt", kTriggerShots},
{"Analysis", "Cursors", "/org/udpscope/help/09-cursors.txt"},
{"Analysis", "Measurements", "/org/udpscope/help/10-measurements.txt"},
{"Analysis", "Math channels", "/org/udpscope/help/11-math.txt"},
{"Analysis", "Math channels", "/org/udpscope/help/11-math.txt", kMathShots},
{"Output and state", "Saving and export", "/org/udpscope/help/12-export.txt"},
{"Output and state", "Sessions and settings", "/org/udpscope/help/13-sessions.txt"},
{"Output and state", "Status bar and diagnostics", "/org/udpscope/help/14-status.txt"},
@@ -94,6 +110,28 @@ void on_help_row_selected(GtkListBox*, GtkListBoxRow* row, gpointer body_label)
bool ok = false;
const std::string body = load_page(kPages[idx], &ok);
gtk_label_set_markup(GTK_LABEL(body_label), body.c_str());
// The screenshots above it, if this page has any. The column is the label's previous sibling
// inside the page's box, which is what lets this handler stay a matter of the label alone --
// and refilling it is a page *switch*, not a frame, so rebuilding it here is cheap and is
// also the only way to be sure the pictures are this page's and not the last one's.
GtkWidget* label = GTK_WIDGET(body_label);
GtkWidget* parent = gtk_widget_get_parent(label);
GtkWidget* shots = parent != nullptr ? gtk_widget_get_first_child(parent) : nullptr;
if (shots != nullptr && GTK_IS_BOX(shots)) {
while (GtkWidget* old = gtk_widget_get_first_child(shots)) {
gtk_box_remove(GTK_BOX(shots), old);
}
for (const char* const* img = kPages[idx].images; img != nullptr && *img != nullptr; ++img) {
GtkWidget* shot = gtk_picture_new_for_resource(*img);
gtk_picture_set_can_shrink(GTK_PICTURE(shot), TRUE);
// A height, and `CONTAIN` (the default) to fit the image inside it: a picture that may
// shrink asks for no height at all in a box, so without this it is allocated nothing and
// the page shows its text with a gap where the screenshots should be.
gtk_widget_set_size_request(shot, -1, kHelpShotHeight);
gtk_box_append(GTK_BOX(shots), shot);
}
gtk_widget_set_visible(shots, gtk_widget_get_first_child(shots) != nullptr);
}
}
} // namespace
@@ -180,7 +218,17 @@ void show_help(GtkWindow* parent) {
GtkWidget* body_scroll = gtk_scrolled_window_new();
gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(body_scroll), GTK_POLICY_NEVER,
GTK_POLICY_AUTOMATIC);
gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(body_scroll), body);
// The page's own column: its screenshots above its text. Built once and *refilled* by the page
// switch, which is why no picture is made here -- one `GtkPicture` per image of the page.
GtkWidget* shot_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 8);
gtk_widget_set_margin_start(shot_box, 18);
gtk_widget_set_margin_end(shot_box, 18);
gtk_widget_set_margin_top(shot_box, 14);
gtk_widget_set_visible(shot_box, false);
GtkWidget* page_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
gtk_box_append(GTK_BOX(page_box), shot_box);
gtk_box_append(GTK_BOX(page_box), body);
gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(body_scroll), page_box);
gtk_widget_set_hexpand(body_scroll, TRUE);
GtkWidget* list = gtk_list_box_new();
+299 -50
View File
@@ -334,8 +334,9 @@ struct App {
/// second ago. A source that serves one client at a time shows up as a reconnect plus
/// no frames for the second between two samples (see `note_starvation`).
std::uint64_t starve_frames = 0;
std::uint64_t starve_reconnects = 0;
bool starve_have_prev = false;
/// Consecutive one-second samples with `frames` unchanged while connected and running.
int starve_still = 0;
/// Set while the starvation notice is up, so the stderr line prints on the edges.
bool starve_reported = false;
/// Frames drawn with every bound trace slot empty although its channel has
@@ -496,6 +497,8 @@ struct App {
/// The last glance line, for the exit dump's `status bar:` line — the dump runs after the
/// window is destroyed, so it cannot ask the label.
std::string dbg_glance_last;
/// Which step of the menu probe runs next (`UDPSCOPE_PROBE_MENUS_MS`).
int dbg_menus_step = 0;
GtkWidget* status_info_button = nullptr; ///< the ⓘ
GtkWidget* status_popover = nullptr;
@@ -518,6 +521,15 @@ struct App {
void install_css() {
static const char kCss[] =
"row.udpscope-compact { min-height: 0; padding: 1px 0; }\n"
// A popover menu row: Adwaita gives a button a 34 px minimum height and 8 px of padding,
// which is what made the overflow read as a stack of dialogs rather than a menu. The
// shortcut label is a plain .dim-label at the row's right, so all that is left to trim
// is the row itself.
"popover.udpscope-menu button { min-height: 0; padding: 3px 8px; }\n"
"popover.udpscope-menu button > box { min-height: 0; }\n"
"popover.udpscope-menu button label { min-height: 0; }\n"
"popover.udpscope-menu image { min-width: 12px; min-height: 0; }\n"
"popover.udpscope-menu separator { margin: 4px 0; }\n"
"row.udpscope-compact button { min-height: 20px; min-width: 20px; padding: 0 3px; }\n"
"row.udpscope-compact checkbutton { min-height: 20px; }\n";
GtkCssProvider* p = gtk_css_provider_new();
@@ -528,6 +540,78 @@ void install_css() {
g_object_unref(p);
}
/// Keeps a menu row's check in step with its toggle. The check keeps its allocation when it is
/// off (opacity, not visibility), so a checked and an unchecked row's labels line up.
void on_menu_row_toggled(GtkToggleButton* button, gpointer) {
GtkWidget* check = GTK_WIDGET(g_object_get_data(G_OBJECT(button), "udpscope-check"));
if (check != nullptr) {
gtk_widget_set_opacity(check, gtk_toggle_button_get_active(button) ? 1.0 : 0.0);
}
}
/// One row of a popover menu, laid out the way a GNOME menu lays one out: the label at the
/// left, the shortcut at the right when the action has one, and -- for a row that toggles
/// something -- a check in its own column. Returned as a box for the caller to hand to
/// `gtk_button_set_child()`; `.udpscope-menu` in install_css() is what trims the height.
GtkWidget* menu_row(const char* label, const char* accel, bool with_check,
GtkWidget** out_check) {
GtkWidget* box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 10);
if (with_check) {
GtkWidget* check = gtk_image_new_from_icon_name("object-select-symbolic");
gtk_widget_set_opacity(check, 0.0);
gtk_box_append(GTK_BOX(box), check);
if (out_check != nullptr) *out_check = check;
}
GtkWidget* text = gtk_label_new(label);
gtk_label_set_xalign(GTK_LABEL(text), 0.0f);
gtk_widget_set_hexpand(text, true);
gtk_box_append(GTK_BOX(box), text);
if (accel != nullptr) {
GtkWidget* key = gtk_label_new(accel);
gtk_label_set_xalign(GTK_LABEL(key), 1.0f);
gtk_widget_add_css_class(key, "dim-label");
gtk_box_append(GTK_BOX(box), key);
}
return box;
}
/// A plain menu row. `accel` is what the app binds the action to (see shortcuts.cpp) -- it is
/// shown, never bound here: the key handling stays in the one controller that refuses a key
/// while a text field has focus.
GtkWidget* menu_button(const char* label, const char* accel) {
GtkWidget* button = gtk_button_new();
// The check column is reserved even though this row cannot be checked: a menu whose labels
// start at two different x -- one for the rows that can be checked and one for the rest --
// is what "the text is not left-aligned" looks like.
gtk_button_set_child(GTK_BUTTON(button), menu_row(label, accel, true, nullptr));
gtk_widget_add_css_class(button, "flat");
gtk_widget_set_hexpand(button, true);
gtk_widget_set_halign(button, GTK_ALIGN_FILL);
return button;
}
/// A menu row that toggles something, with the check menu_row() reserves the column for. The
/// initial state is set *before* the handler is connected: a programmatic `set_active()` emits
/// `toggled`, the handler acts on widgets the caller has not built yet (the signal list's
/// revealer is created later in run_ui()), and a row that is created on must not reach it.
GtkWidget* menu_toggle(const char* label, const char* accel, bool active) {
GtkWidget* button = gtk_toggle_button_new();
GtkWidget* check = nullptr;
gtk_button_set_child(GTK_BUTTON(button), menu_row(label, accel, true, &check));
gtk_widget_add_css_class(button, "flat");
gtk_widget_set_hexpand(button, true);
gtk_widget_set_halign(button, GTK_ALIGN_FILL);
g_object_set_data(G_OBJECT(button), "udpscope-check", check);
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(button), active);
on_menu_row_toggled(GTK_TOGGLE_BUTTON(button), nullptr);
g_signal_connect(button, "toggled", G_CALLBACK(on_menu_row_toggled), nullptr);
return button;
}
GtkWidget* menu_separator() {
return gtk_separator_new(GTK_ORIENTATION_HORIZONTAL);
}
GtkWidget* labeled_box(const char* label, GtkWidget* child) {
GtkWidget* box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
GtkWidget* l = gtk_label_new(label);
@@ -1661,18 +1745,18 @@ void rebuild_signal_list(App& app, const std::vector<SignalInfo>& signals) {
// The overflow: information and calibration are set once per session, so they
// cost a click here instead of a button on every row forever.
GtkWidget* menu_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 2);
GtkWidget* menu_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
gtk_widget_set_margin_top(menu_box, 4);
gtk_widget_set_margin_bottom(menu_box, 4);
gtk_widget_set_margin_start(menu_box, 4);
gtk_widget_set_margin_end(menu_box, 4);
GtkWidget* info = gtk_button_new_with_label("Information");
GtkWidget* info = menu_button("Information", nullptr);
g_object_set_data(G_OBJECT(info), "udpscope-signal", GSIZE_TO_POINTER(i));
g_signal_connect(info, "clicked", G_CALLBACK(on_info_clicked), &app);
gtk_box_append(GTK_BOX(menu_box), info);
GtkWidget* cal = gtk_button_new_with_label("Calibrate...");
GtkWidget* cal = menu_button("Calibrate...", nullptr);
gtk_widget_set_tooltip_text(cal, "gain and offset, applied at ingest");
g_object_set_data(G_OBJECT(cal), "udpscope-signal", GSIZE_TO_POINTER(i));
g_signal_connect(cal, "clicked",
@@ -1686,6 +1770,7 @@ void rebuild_signal_list(App& app, const std::vector<SignalInfo>& signals) {
gtk_box_append(GTK_BOX(menu_box), cal);
GtkWidget* popover = gtk_popover_new();
gtk_widget_add_css_class(popover, "udpscope-menu");
gtk_popover_set_child(GTK_POPOVER(popover), menu_box);
GtkWidget* more = gtk_menu_button_new();
gtk_menu_button_set_icon_name(GTK_MENU_BUTTON(more), "view-more-symbolic");
@@ -5483,17 +5568,23 @@ void note_starvation(App& app) {
// No `have_config` here: the losing client of a single-client source need never see a
// CONFIG at all (the server answered the first one), and "connected, kicked, nothing
// arriving" is the state worth naming either way.
bool starved = false;
if (app.starve_have_prev && connected && !app.acq->paused()) {
starved = reconnects > app.starve_reconnects && frames == app.starve_frames;
}
// "Connected, but nothing is arriving" is a *rate* fact, not a reconnect one: a source that
// answers CONNECT and then goes quiet -- the unicast case where another client took the session --
// never reconnects while it is silent, so requiring a reconnect made the notice invisible in
// exactly the state it describes. What it takes is `frames` not moving across three consecutive
// one-second samples: one sample would call a source slower than the sample interval starved, and
// three still clears the moment anything arrives.
const bool idle = app.starve_have_prev && frames == app.starve_frames;
app.starve_still = (connected && !app.acq->paused() && idle) ? app.starve_still + 1 : 0;
app.starve_frames = frames;
app.starve_reconnects = reconnects;
app.starve_have_prev = true;
if (!starved) {
if (app.starve_still < 3) {
app.starve_reported = false;
// (the zoom-in is judged on the next frame, after the layout pushes it)
// **A `return`, not just a branch.** The notice and its line used to sit outside this test, so
// they were re-armed every frame and the status bar carried "no data is arriving" while the
// stream was arriving perfectly well.
return;
}
app.notice =
"connected, but no data is arriving: another reader may hold the source (a unicast "
@@ -5662,27 +5753,68 @@ GtkWidget* build_toolbar(App& app) {
// --- the overflow menu ----------------------------------------------------
// Plain buttons in a popover rather than a GMenu: these act on the `RowSet`
// directly and two of them are toggles whose state the rest of the UI reads, so
// a GAction model would only add a second copy of that state.
GtkWidget* menu_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 2);
// Rows rather than plain buttons: the label at the left, the shortcut at the right and a
// check on the rows that toggle something, which is the shape every GNOME menu has and the
// reason this popover no longer reads as a stack of dialogs (see menu_row(), and the
// `.udpscope-menu` rules in install_css()). It stays hand-built instead of a `GMenu` model
// because these act on the `RowSet` directly and two of them are toggles whose state the
// rest of the UI reads, so an action model would only add a second copy of that state.
//
// The order is a GNOME menu's: what you do to the *view*, then the data, then the help
// trio. Nothing here needs a separator of its own -- the groups are the separators.
GtkWidget* menu_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
gtk_widget_set_margin_top(menu_box, 6);
gtk_widget_set_margin_bottom(menu_box, 6);
gtk_widget_set_margin_start(menu_box, 6);
gtk_widget_set_margin_end(menu_box, 6);
GtkWidget* fit = gtk_button_new_with_label("Fit to data");
GtkWidget* fit = menu_button("Fit to data", "F");
gtk_widget_set_tooltip_text(fit, "auto-scale every axis and frame the buffered data");
g_signal_connect(fit, "clicked", G_CALLBACK(on_fit_clicked), &app);
gtk_box_append(GTK_BOX(menu_box), fit);
app.layout_toggle = gtk_toggle_button_new_with_label("Layout editor");
app.layout_toggle = menu_toggle("Layout editor", "L", false);
gtk_widget_set_tooltip_text(app.layout_toggle,
"add and remove plot rows in place: \u00d7 on a row removes "
"it, + between rows inserts one there");
g_signal_connect(app.layout_toggle, "toggled", G_CALLBACK(on_layout_editor_toggled), &app);
gtk_box_append(GTK_BOX(menu_box), app.layout_toggle);
app.signal_toggle = menu_toggle("Signal list", nullptr, true);
gtk_widget_set_tooltip_text(app.signal_toggle, "show or hide the signal list");
g_signal_connect(app.signal_toggle, "toggled", G_CALLBACK(on_signals_toggled), &app);
gtk_box_append(GTK_BOX(menu_box), app.signal_toggle);
gtk_box_append(GTK_BOX(menu_box), menu_separator());
GtkWidget* export_btn = menu_button("Export visible range...", "Ctrl+E");
gtk_widget_set_tooltip_text(export_btn,
"write every plotted signal over the visible x range, at the "
"stored sample rate, to a .npy or .csv file");
g_signal_connect(export_btn, "clicked", G_CALLBACK(on_export_clicked), &app);
gtk_box_append(GTK_BOX(menu_box), export_btn);
gtk_box_append(GTK_BOX(menu_box), menu_separator());
GtkWidget* shortcuts_item = menu_button("Keyboard shortcuts", "Ctrl+/");
gtk_widget_set_tooltip_text(shortcuts_item,
"every key and mouse gesture, including the plot's own");
g_signal_connect(shortcuts_item, "clicked", G_CALLBACK(on_shortcuts_clicked), &app);
gtk_box_append(GTK_BOX(menu_box), shortcuts_item);
GtkWidget* help_item = menu_button("Help", "F1");
gtk_widget_set_tooltip_text(help_item, "the paged guide to every feature");
g_signal_connect(help_item, "clicked", G_CALLBACK(on_help_clicked), &app);
gtk_box_append(GTK_BOX(menu_box), help_item);
GtkWidget* about_item = menu_button("About udpscope", nullptr);
gtk_widget_set_tooltip_text(about_item, "version, build timestamp and settings paths");
g_signal_connect(about_item, "clicked", G_CALLBACK(on_about_clicked), &app);
gtk_box_append(GTK_BOX(menu_box), about_item);
// --- the pane toggles, which live on the bar rather than in the menu ------
// Created here because they share the popover's styling helpers; packed onto the header bar
// below, where the pack order (not this order) decides where they sit.
app.measures_toggle = gtk_toggle_button_new();
gtk_button_set_icon_name(GTK_BUTTON(app.measures_toggle), "udpscope-measure-symbolic");
gtk_widget_set_tooltip_text(app.measures_toggle,
@@ -5698,7 +5830,6 @@ GtkWidget* build_toolbar(App& app) {
}
}),
&app);
// (packed after math and before cursors below: see the order note at the menu)
app.math_toggle = gtk_toggle_button_new();
gtk_button_set_icon_name(GTK_BUTTON(app.math_toggle), "udpscope-math-symbolic");
@@ -5724,39 +5855,8 @@ GtkWidget* build_toolbar(App& app) {
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(app.cursors_toggle), false);
g_signal_connect(app.cursors_toggle, "toggled", G_CALLBACK(on_cursors_toggled), &app);
GtkWidget* export_btn = gtk_button_new_with_label("Export visible range...");
gtk_widget_set_tooltip_text(export_btn,
"write every plotted signal over the visible x range, at the "
"stored sample rate, to a .npy or .csv file");
g_signal_connect(export_btn, "clicked", G_CALLBACK(on_export_clicked), &app);
gtk_box_append(GTK_BOX(menu_box), export_btn);
gtk_box_append(GTK_BOX(menu_box), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL));
app.signal_toggle = gtk_toggle_button_new_with_label("Signal list");
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(app.signal_toggle), true);
g_signal_connect(app.signal_toggle, "toggled", G_CALLBACK(on_signals_toggled), &app);
gtk_box_append(GTK_BOX(menu_box), app.signal_toggle);
gtk_box_append(GTK_BOX(menu_box), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL));
GtkWidget* help_item = gtk_button_new_with_label("Help (F1)");
gtk_widget_set_tooltip_text(help_item, "the paged guide to every feature");
g_signal_connect(help_item, "clicked", G_CALLBACK(on_help_clicked), &app);
gtk_box_append(GTK_BOX(menu_box), help_item);
GtkWidget* shortcuts_item = gtk_button_new_with_label("Keyboard shortcuts (Ctrl+/)");
gtk_widget_set_tooltip_text(shortcuts_item,
"every key and mouse gesture, including the plot's own");
g_signal_connect(shortcuts_item, "clicked", G_CALLBACK(on_shortcuts_clicked), &app);
gtk_box_append(GTK_BOX(menu_box), shortcuts_item);
GtkWidget* about_item = gtk_button_new_with_label("About udpscope");
gtk_widget_set_tooltip_text(about_item, "version, build timestamp and settings paths");
g_signal_connect(about_item, "clicked", G_CALLBACK(on_about_clicked), &app);
gtk_box_append(GTK_BOX(menu_box), about_item);
GtkWidget* popover = gtk_popover_new();
gtk_widget_add_css_class(popover, "udpscope-menu");
gtk_popover_set_child(GTK_POPOVER(popover), menu_box);
app.menu_button = gtk_menu_button_new();
gtk_menu_button_set_icon_name(GTK_MENU_BUTTON(app.menu_button), "open-menu-symbolic");
@@ -5903,8 +6003,17 @@ void on_activate(GtkApplication* application, gpointer user) {
app->status_info_button = gtk_menu_button_new();
gtk_menu_button_set_icon_name(GTK_MENU_BUTTON(app->status_info_button),
"help-about-symbolic");
// Above the button, always. The status row is the last thing in the window, so "below"
// is off the window's bottom edge and, on a window that reaches the bottom of the screen,
// off the screen: the bubble's top edge and its arrow stayed at the button while the body
// was clipped away -- what reads as an arrow disconnected from its body. GTK still slides
// it sideways to fit, which is why only the vertical placement has to be stated.
gtk_menu_button_set_popover(GTK_MENU_BUTTON(app->status_info_button),
app->status_popover);
// After `set_popover()`: a `GtkMenuButton` states a position of its own (below, which is
// where a menu button in a header bar wants it), and it is the *last* word that counts.
gtk_popover_set_position(GTK_POPOVER(app->status_popover), GTK_POS_TOP);
gtk_popover_set_has_arrow(GTK_POPOVER(app->status_popover), TRUE);
gtk_widget_set_tooltip_text(app->status_info_button, "Stream and buffer detail");
gtk_widget_set_has_tooltip(app->status_info_button, TRUE);
gtk_box_append(GTK_BOX(status_row), app->status_info_button);
@@ -7487,6 +7596,143 @@ gboolean zoomkeys_probe(gpointer user) {
return G_SOURCE_REMOVE;
}
/// How tall a menu row may be. Adwaita's own minimum for a button is 34 px, and a popover of
/// seven such rows is what made the overflow read as a stack of dialogs; the `.udpscope-menu`
/// rules trim it to a menu row and this is what says they are still applied.
constexpr int kMenuRowMaxPx = 30;
gboolean menus_probe_step(gpointer user);
void menus_probe_next(App& a, int step, guint delay_ms) {
a.dbg_menus_step = step;
g_timeout_add(delay_ms, menus_probe_step, &a);
}
/// The label and the shortcut of one row, in the order menu_row() appends them. The optional
/// check image comes first and is not a label, so this counts labels rather than positions.
void menu_row_labels(GtkWidget* box, GtkWidget** label, GtkWidget** accel) {
*label = nullptr;
*accel = nullptr;
for (GtkWidget* child = box != nullptr ? gtk_widget_get_first_child(box) : nullptr;
child != nullptr; child = gtk_widget_get_next_sibling(child)) {
if (!GTK_IS_LABEL(child)) continue;
if (*label == nullptr) {
*label = child;
} else {
*accel = child;
}
}
}
/// The two popovers, measured, with the side panel hidden -- the state the report is about.
/// What it asserts, row by row, is what a screenshot can only show an eye: the label at the
/// left of its row, the shortcut at the right of it, a row under kMenuRowMaxPx, the rows in
/// the intended order, and an info bubble placed above its button with room for its height.
gboolean menus_probe_step(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
if (a->dbg_menus_step == 0) {
// Hiding the panel is the case that was reported: the info button then sits at the
// window's right edge, and a bubble placed below it has nowhere to go.
run_app_action(*a, AppAction::SidePanel);
// Autohide off *for the probe only*: a popover dismisses itself when it loses the grab, and
// under a bare `xvfb-run` there is no window manager to hand it one, so the rows would be
// measured before they were ever mapped. The app's own popovers keep autohide on.
GtkPopover* popover = gtk_menu_button_get_popover(GTK_MENU_BUTTON(a->menu_button));
if (popover != nullptr) gtk_popover_set_autohide(popover, FALSE);
gtk_menu_button_popup(GTK_MENU_BUTTON(a->menu_button));
menus_probe_next(*a, 1, 300);
return G_SOURCE_REMOVE;
}
if (a->dbg_menus_step == 1) {
GtkPopover* popover = gtk_menu_button_get_popover(GTK_MENU_BUTTON(a->menu_button));
GtkWidget* box = popover != nullptr ? gtk_popover_get_child(popover) : nullptr;
static const char* const kExpected[] = {"Fit to data", "Layout editor", "Signal list",
"Export visible range...", "Keyboard shortcuts",
"Help", "About udpscope"};
const int expected = static_cast<int>(sizeof(kExpected) / sizeof(kExpected[0]));
int rows = 0;
int bad = 0;
int first_label_x = 0;
for (GtkWidget* row = box != nullptr ? gtk_widget_get_first_child(box) : nullptr;
row != nullptr; row = gtk_widget_get_next_sibling(row)) {
if (!GTK_IS_BUTTON(row)) continue;
GtkWidget* label = nullptr;
GtkWidget* accel = nullptr;
menu_row_labels(gtk_widget_get_first_child(row), &label, &accel);
const int row_w = gtk_widget_get_width(row);
const int row_h = gtk_widget_get_height(row);
const graphene_point_t origin = GRAPHENE_POINT_INIT(0.0f, 0.0f);
graphene_point_t at = GRAPHENE_POINT_INIT(0.0f, 0.0f);
const int label_x = label != nullptr && gtk_widget_compute_point(label, row, &origin, &at)
? static_cast<int>(at.x) : -1;
const int accel_right =
accel != nullptr && gtk_widget_compute_point(accel, row, &origin, &at)
? static_cast<int>(at.x) + gtk_widget_get_width(accel) : -1;
const char* text = label != nullptr ? gtk_label_get_text(GTK_LABEL(label)) : "";
const char* key = accel != nullptr ? gtk_label_get_text(GTK_LABEL(accel)) : "";
const bool named = rows < expected && std::strcmp(text, kExpected[rows]) == 0;
// Every label starts at the same x -- that is what "left-aligned" means for a menu whose
// rows differ in whether they carry a check.
if (rows == 0) first_label_x = label_x;
const bool left = label_x >= 0 && label_x <= 40 && label_x == first_label_x;
const bool right = accel == nullptr || (accel_right > 0 && row_w - accel_right <= 12);
const bool compact = row_h > 0 && row_h <= kMenuRowMaxPx;
const bool ok = named && left && right && compact;
if (!ok) ++bad;
std::fprintf(stderr,
"[menu] %d '%s' accel='%s' h=%d label_x=%d accel_right=%d/%d order=%d "
"ok=%d\n", rows, text, key, row_h, label_x, accel_right, row_w,
named ? 1 : 0, ok ? 1 : 0);
++rows;
}
std::fprintf(stderr,
"[menu] rows=%d expected=%d order=%d rows_bad=%d popover_visible=%d "
"mapped=%d button_mapped=%d\n", rows, expected, rows == expected ? 1 : 0, bad,
popover != nullptr ? gtk_widget_get_visible(GTK_WIDGET(popover)) : -1,
popover != nullptr ? gtk_widget_get_mapped(GTK_WIDGET(popover)) : -1,
a->menu_button != nullptr ? gtk_widget_get_mapped(a->menu_button) : -1);
if (popover != nullptr) gtk_popover_set_autohide(popover, TRUE);
gtk_menu_button_popdown(GTK_MENU_BUTTON(a->menu_button));
if (a->status_info_button != nullptr) {
GtkPopover* info = gtk_menu_button_get_popover(GTK_MENU_BUTTON(a->status_info_button));
if (info != nullptr) gtk_popover_set_autohide(info, FALSE);
gtk_menu_button_popup(GTK_MENU_BUTTON(a->status_info_button));
}
menus_probe_next(*a, 2, 300);
return G_SOURCE_REMOVE;
}
// The info bubble: its own size against the room above the button it points at. GTK slides a
// popover sideways to fit, so only the vertical placement has to hold -- and it is the one
// that was wrong: below the button is off the window's bottom edge.
GtkWidget* bubble = a->status_popover;
const int bubble_w = bubble != nullptr ? gtk_widget_get_width(bubble) : -1;
const int bubble_h = bubble != nullptr ? gtk_widget_get_height(bubble) : -1;
graphene_rect_t bounds = GRAPHENE_RECT_INIT(0, 0, 0, 0);
const bool have_button = a->status_info_button != nullptr &&
gtk_widget_compute_bounds(a->status_info_button, a->window, &bounds);
const int button_top = have_button ? static_cast<int>(bounds.origin.y) : -1;
const int button_right =
have_button ? static_cast<int>(bounds.origin.x + bounds.size.width) : -1;
const int pos = bubble != nullptr
? static_cast<int>(gtk_popover_get_position(GTK_POPOVER(bubble))) : -1;
const int fits = (pos == GTK_POS_TOP && bubble_h > 0 && bubble_w > 0 &&
button_top >= bubble_h + 14) ? 1 : 0;
std::fprintf(stderr, "[menu] status pos=%s bubble=%dx%d button_top=%d button_right=%d "
"fits=%d\n", pos == GTK_POS_TOP ? "top" : "other", bubble_w, bubble_h,
button_top, button_right, fits);
if (a->status_info_button != nullptr) {
GtkPopover* info = gtk_menu_button_get_popover(GTK_MENU_BUTTON(a->status_info_button));
if (info != nullptr) gtk_popover_set_autohide(info, TRUE);
gtk_menu_button_popdown(GTK_MENU_BUTTON(a->status_info_button));
}
std::fflush(stderr);
return G_SOURCE_REMOVE;
}
/// Fires a representative accel from each group and reports the state it changed, then
/// checks the GtkEditable refusal. It goes through run_app_action() for the actions and
/// through the real handler for the refusal, because the refusal lives in the handler.
@@ -7687,6 +7933,9 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
if (const char* env = g_getenv("UDPSCOPE_PROBE_HELP_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), help_probe, &app);
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_MENUS_MS")) {
menus_probe_next(app, 0, static_cast<guint>(std::atoi(env)));
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_KEYS_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), keys_probe, &app);
}