improving doc
This commit is contained in:
1 parent
02c53bb7a0
commit
1fc2a3afe2
11 files changed
+678
-76
No files matched your search
+64
-18
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user