feat: the manual is a Mallard set opened in Yelp, not a GResource in the binary

The help was 14 Pango-markup `.txt` bodies in a second GResource, drawn into a paged `GtkWindow`
built in `help_window.cpp`. That made F1 a viewer of ours with no search index, no cross-references
and no translation, and it kept the manual in the *binary* when every other desktop application's is
a document on disk. It is now a real Mallard set:

  help/C/org.udpscope.app/  index.page + 14 topic pages + figures/*.png

and `show_help()` hands `help:org.udpscope.app` to whatever the desktop registered for the `help:`
scheme -- Yelp on GNOME -- through a `GtkUriLauncher`, reporting a launch failure with a
`GtkAlertDialog` rather than a click that silently does nothing. The paged window's builder, the
page table and `help_self_check()`'s markup validation are gone with the resource.

The document identifier is the desktop file's own basename (its desktop-file id), derived in
CMakeLists from `data/org.udpscope.app.desktop.in`, so the same string is the `.desktop` name, the
GApplication id and the install directory at once. The set is installed by a new `install()` rule to
`<datadir>/help/C/org.udpscope.app/` -- where the freedesktop Help System Specification's `help:`
URI resolves `help:<id>` -- so the manual is read from a datadir by another process even though the
binary has no install step. `UDPSCOPE_HELP_NAME` and `UDPSCOPE_HELP_DIR` are compiled in from that
one place.

The 14 pages were migrated fact for fact, but restructured the Mallard way: a `<desc>` per page
(what a search result shows), `<steps>` for procedures, `<terms>` for option lists,
`<gui>`/`<key>`/`<keyseq>` for interface words and keys, `<note>` for the asides, and a topic graph
via `<link type="guide" xref="index#main"/>`. The figures are annotated screenshots produced by
`tools/make-help-shots.sh`, which drives the app headlessly against `tools/mock_udpstreamer.py` and
draws the boxes with ImageMagick; the side panel's box is measured from the app's own `side=NNNx`
dump reading, the header and status bands come from a documented table, and a crop whose standard
deviation is 0 fails the script.

`cmake --build build --target test-help` checks the set the way `test-ui` checks the `.ui` files:
`yelp-check`'s offline subcommands (links, media, orphans, ids) and a `yelp-build html` render.
`yelp-check validate` is deliberately absent -- it fetches the Mallard RelaxNG schema over the
network and fails offline for that reason, not for a fault in the pages.

The `[help]` probe reading changes with the resource it was written for. `parsed == pages` over
in-binary markup cannot exist now, so `help_self_check()` scans `UDPSCOPE_HELP_DIR` and the line
reads `topics=14 index=1 figures=3`; `topics=0 index=0` is the manual missing from the tree, which
is the finding the old count named.

Verified: clean build with the documented three warnings (the help bundle's `-Woverlength-strings`
is gone, so the baseline moves from four to three); test-ui 14/14; test-help clean (the four
subcommands and 15 pages rendered); a temp-prefix install lands 15 `.page` files and 3 figures at
`share/help/C/org.udpscope.app/`; the headless run reads `[help] topics=14 index=1 figures=3 dir=...`
with 0 Gtk-CRITICAL. Yelp's own window could not be exercised on this box -- it starts under Xvfb,
activates its portals and never maps, the same WebKit/portal limitation that stops a grab -- so its
rendering and its search index are unverified here; the content pipeline was (`yelp-build` renders
the whole set).

Docs: the manual's build/install/open story in `build-and-run.md` and README; the warning baseline
in `verification.md`/CLAUDE.md; the help bullets in `invariants-gtk-and-chrome.md`; the `[help]`
line in `probe-readings.md`.
This commit is contained in:
Martino Ferrari committed 2026-10-14 16:49:30 +02:00
1 parent 620473a5be
commit c1460a6977
47 files changed
+1910 -1056

No files matched your search

-79
View File
@@ -1,79 +0,0 @@
<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, 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 interval you read on screen is measured between timestamps 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.
The time axis is <i>relative to now</i>. While streaming it is a fixed <tt>[-window .. 0]</tt>
with the newest sample at 0, so x reads as an age: <tt>-1.5s</tt> is a sample a second and a half
old. In trigger mode 0 is the event's edge instead, and a paused view keeps the ages it was
frozen with. The source's own clock is still there when you need it -- an export writes it as a
second column.
<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, 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, the events, cursors and side-panel switches, and the menu, which holds Fit to data, the
layout editor, Export, Keyboard shortcuts, Help and About.
• <b>The side panel</b> -- two tabs. <b>Measure</b> holds the cursors readout and the
measurements derived from it; <b>Signals</b> holds the trigger and the channel list, where a
math channel is a row of that one list under a <tt>MATH</tt> heading whose <tt>+</tt> creates
one. One row per channel: its colour, an enable switch, its name and its unit. Channels 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>, <tt>.csv</tt> or <tt>.mf3</tt> (MDF 3.30).
<b>Worth knowing</b>
• 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.
-42
View File
@@ -1,42 +0,0 @@
<b>Choosing a source</b>
A source is a host and a port, optionally reached through a multicast group on a named
interface. Pass it on the command line, or let udpscope ask.
<b>How to reach it</b>
Run with <tt>--server</tt> and the app connects straight away. Run <i>without</i>
<tt>--server</tt> and a chooser appears first: one row per stored <i>configuration</i>
(<tt>host:port · name</tt>, its note, and the settings it carries), newest first, plus a
<b>New host…</b> form that takes the address <i>and</i> the configuration it belongs to — its name
and note, the <b>buffer size</b>, and whether time is relative or absolute. A row's pencil button
opens the same form on that configuration, pre-filled, and changes to its name or address move it
(the old file is deleted).
The second page, <b>Waveforms</b>, opens an <i>exported</i> run instead of a live source: the files
opened before (newest first; one that has since been deleted is marked <i>missing</i>) and an
<b>Open…</b> button for a new one. The same file can be opened straight from the command line with
<tt>--open FILE</tt>. A waveform is a source that never arrives — no socket, no acquisition, no
starvation notice — and every instrument works on it: cursors, measurements, math channels and
another export. Only <tt>.npy</tt> files are read. form for one it has not seen. The chooser runs before any
buffer is allocated and before any socket is opened, so declining it costs nothing.
<b>Worth knowing</b>
• The list is the sessions that <i>named a signal</i>. A session whose streamer was down
stores empty slot names and is filtered out, which is what keeps the list the sources you
actually configured rather than every port you have ever typed.
• A stored row reconnects the way it was reached. The multicast group and the interface are
written inside the session file, because the file's name (<tt>host_port.toml</tt>) cannot
carry them.
• <tt>--multicast</tt> still needs <tt>--server</tt>: the CONFIG message is fetched from the
server, not from the group.
• The <tt>×</tt> beside a row deletes that session file. Nothing else is removed.
<b>Example</b>
<tt>udpscope</tt> with two stored sessions lists
<tt>127.0.0.1:8000 plots=1 signals=3</tt> and
<tt>10.0.0.5:8001 plots=2 signals=4 multicast=239.1.2.3 interface=10.0.0.9</tt>.
Activating the first reports <tt>[sources] picked 127.0.0.1:8000</tt> and continues into
the scope with that session's layout already loaded.
-36
View File
@@ -1,36 +0,0 @@
<b>Signals and calibration</b>
The source's CONFIG message describes its signals: a name, a unit and a type. udpscope turns
each enabled signal into a <i>channel</i> — a ring of values beside the shared time ring —
and a channel is what a trace plots. Calibration is a scale and an offset applied at ingest.
<b>How to reach it</b>
The side panel (<tt>s</tt>) lists every signal with a switch. The <b>Signal list</b> item in
the overflow menu opens the editor where the unit and the calibration live.
<b>Worth knowing</b>
• Signals start disabled, and enabling one is what starts acquiring it. A trigger on a signal
nobody acquires can never fire, so arming a trigger enables its signal for you.
• <b>Calibrate</b> in a signal's row menu opens the gain and offset it is <i>currently</i> drawn
through, so a trace already scaled by a gain of 2 shows 2 — pressing <i>Apply</i> confirms it
rather than resetting it. A signal nobody calibrated shows the identity, 1 and 0.
• Calibration is applied <i>at ingest</i>, so changing it re-plans the buffer and drops the
history. So does enabling or disabling a signal, and so does a new CONFIG. An empty plot
for a second after one of those is the ring refilling, not a fault.
• The stream's first signal is usually its <i>timestamp</i>, and that one is never acquired.
It cannot be plotted, measured or used as a math source, and udpscope refuses to put it in
a row rather than binding a trace that would read nothing for ever.
• A signal's colour follows the <i>signal</i>, not the slot, so the same signal on two rows
is one colour and the side panel's chip identifies it unambiguously. A signal on no row
gets a grey chip rather than none, so the panel's rows keep their height.
• <b>Click a coloured chip to change that signal's colour.</b> A grey chip names no trace, so
it is not clickable. The colour is saved with the session; <b>Default colour</b> in the
signal's ⋯ menu gives the palette entry back.
<b>Example</b>
A source declaring <tt>time</tt>, <tt>sine</tt>, <tt>ramp</tt> and <tt>noise</tt> yields three
plottable signals. Enable all three and the exit dump reads <tt>3 signals, 3 channels</tt>;
<tt>time</tt> is not among them.
-39
View File
@@ -1,39 +0,0 @@
<b>Rows, layout and drag &amp; drop</b>
The figure is a stack of rows, 1 to 8 of them, each holding up to 4 traces. Rows share one
time axis and one pair of side margins, so their y labels line up, their data areas start and
end on the same two columns, and their windows cannot drift apart. The left margin holds the y
labels; the right one is empty, and is there so the newest tick's label and the newest sample's
ink have somewhere to land instead of sitting on the frame.
<b>How to reach it</b>
Drag a signal's chip out of the side panel (<tt>s</tt>) and drop it on a row to add a trace.
Press <tt>l</tt>, or pick <b>Layout editor</b> from the overflow menu, to add and remove rows:
a <tt>×</tt> appears at the top left of each row and a <tt>+</tt> on each boundary between
rows, including above the first and below the last. The editor's own button in the top-right
corner leaves it.
<b>Worth knowing</b>
• A plot's own <b>right-click menu</b> (the same compact rows as the app's menu) holds the
y-axis scale, the two fits, and <b>Remove signal</b>, which swaps the menu to that plot's traces
plus <i>All traces on this plot</i> — with a <b>Back</b> row to return. That is how a trace comes
off a row without going through the layout editor.
• A drop <i>adds</i> a trace to the row's next free slot; it does not replace one. With four
traces already on a row the drop is refused.
• Inserting a row moves the rows below it <i>with their contents</i>. Inserting above row 1
puts what row 1 plotted onto row 2 and leaves the new row empty — the rows are not
renumbered under you.
• A trace is remembered by the signal's <i>name</i>, not by its channel number, because a new
CONFIG or an enable renumbers the channels and an index would silently re-point the trace.
• Adding or removing a row rebuilds the figure, which is cheap, but it also re-deals every
trace. A frozen trigger event survives the rebuild: the pinned window is restated rather
than re-framed on the live edge.
<b>Example</b>
With three traces on row 1, pressing <tt>l</tt> shows 3 buttons (one <tt>×</tt>, two
<tt>+</tt>). Clicking the <tt>+</tt> above row 1 gives 2 rows and 5 buttons, with the three
traces now on row 2 — the layout probe reports exactly this as
<tt>rows=2 buttons=5 traces=[0:-|1:sine,ramp,noise]</tt>.
-33
View File
@@ -1,33 +0,0 @@
<b>Time window</b>
The window is how much time the plot shows. Every row shares one window: the figure has a
single x link group, so the rows cannot drift apart. The trigger's event span follows it, and
the pre-trigger share is a share of <i>this</i> window.
<b>How to reach it</b>
The header bar's window button opens a popover: type a number, pick a unit under it, or step a
decade with the arrows. The button's own label is a reading of the current window in a natural
unit, width-locked so a change of window cannot widen the header bar.
<b>Worth knowing</b>
• A window too long to store at the wire rate is <i>decimated, not refused</i>. The shared clock
takes a power-of-two stride, and each group of samples is <b>averaged</b> rather than strided,
so a tone above the stored Nyquist is attenuated instead of aliasing to a false low frequency.
• The stamp stored for a group is the group's <i>mean</i>, which is where the averaging puts the
value. Stamping at the end of a group would shift every trace half a stored sample late.
• Moving the window across a decimation boundary rebuilds the buffer and drops the history.
Walking it inside one decade is free.
• <b>This control is the only thing that changes the window.</b> A zoom, a pan or a fit moves the
view <i>inside</i> it and leaves it alone — which is the point of the previous bullet: a wheel
notch that redefined the window could re-plan the buffer and throw your samples away.
• The trigger sees the <i>stored</i> (filtered) signal, not the wire signal, because an edge
found on a sample that is not in the buffer could not be cut out of it.
<b>Example</b>
1 MS/s with 200 MB of buffer. A 2 s window stores every sample (<tt>time x1</tt>). Widen it to
600 s and the stride becomes <tt>x16</tt>: 625 S/s stored, anti-aliased at 312.5 Hz. A 2 kHz
sine seen through that window reads <tt>y=[-0.059, 0.079]</tt> — attenuated to about 6%, not
folded down to a full-amplitude 125 Hz one.
-59
View File
@@ -1,59 +0,0 @@
<b>Navigating the plot</b>
The plot pans and zooms with the mouse and with the keyboard. Every gesture acts on the row the
pointer is over, and because the rows share one time axis, an x change moves all of them.
<b>How to reach it</b>
<b>Wheel</b> zooms both axes · <b>Shift+wheel</b> zooms x only · <b>Ctrl+wheel</b> zooms y only.
<b>Drag</b> pans · <b>Shift+drag</b> is a box zoom on both axes · <b>Ctrl+drag</b> on x only ·
<b>Alt+drag</b> on values only.
The area being chosen is framed while you drag it, so you can see what the release will apply.
<b>+</b> and <b>-</b> zoom both axes; <b>Ctrl</b> with them restricts to x, <b>Alt</b> to y.
<b>Arrow keys</b> pan, <b>f</b> fits, <b>Escape</b> cancels a gesture in progress.
The full table is in the shortcut overview (<tt>Ctrl+/</tt>).
<b>Worth knowing</b>
• The keyboard zoom's modifiers are <i>not</i> the wheel's, and that is deliberate: on common
layouts <tt>+</tt> <i>is</i> shift+<tt>=</tt>, so a bare zoom-in already arrives with Shift
set and Shift could not mean anything. Ctrl/Alt mirror the box-zoom drags instead.
• A <i>pinned</i> view is navigable within its own data but cannot leave it. While an event is
held, a capture is filling, or the view is paused, a zoom <i>in</i> works normally — "not
outside the event" is not "not readable" — but a pan or a zoom out is clamped to the data.
• Nothing you do on the plot changes the <i>time window</i> — only the header control does. A
zoom or a pan moves the view inside the window; <b>f</b> (and Fit X axis) is the <i>undo</i> of a
zoom, so a 2 s window zoomed into reads 2 s again. The window is more than the axis: it is also
the trigger's event span and how the samples are stored, so a wheel notch that redefined it
would re-plan the buffer and drop your history.
• The axis does not scroll — the <i>data</i> does. A live axis is a fixed <tt>[-window .. 0]</tt>
with the newest sample at 0, so the labels stand still and the trace rolls under them. The
cursors stand still with the labels (they are positions on screen, so a cursor you placed stays
where you put it, over newer data each frame), while a measurement's marks travel with the
sample they measured and leave the view when it does.
• A live view is clamped to the window you asked for, not to what the ring happens to hold.
That is why a buffer that has only just started filling leaves its left-hand side empty
instead of squeezing the window down to the first few samples.
• Zooming hard into a held capture is exact. The renderer finds the visible samples by binary
search over the real timestamps, never over a nominal sample grid, so a capture whose clock
wandered does not drift away from its axis as you zoom.
• A trace holding far more samples than the plot has pixels is drawn <i>one pixel column at a
time</i>: each column is a single vertical line from the lowest to the highest value that falls
in it. Drawn as a polyline instead, such a window puts three or four near-vertical lines in
every column at slightly different sub-pixel positions, and how they overlap beats against the
pixel grid — which reads as bands of light and dark that <i>crawl</i> as you pan. One line per
column removes the beat, and it is also much faster: a 1 MS/s view costs a couple of thousand
line segments a frame instead of a couple of hundred thousand.
• Those columns are <i>graded by brightness</i>, the way a digital-phosphor scope reads: bright
where the signal spent its time, faint where it only passed through. A column's highest and
lowest value are the same whether the samples in it are spread evenly or piled at its two ends,
so the grading is what tells the two apart — the flat parts of a square wave come out solid and
the edges between them faint. It is read from a finer division of each column rather than from
the line that is drawn, and it costs about a millisecond a frame.
<b>Example</b>
Pause a live view, then press <tt>Ctrl++</tt>: x narrows and y does not move. Press
<tt>Alt++</tt>: y narrows and x does not. Eight doublings into a held 2 s capture, the trace
still reaches both margins — the zoom probe reports <tt>gap_px</tt> and <tt>end_px</tt> within
a pixel or two at every step.
-31
View File
@@ -1,31 +0,0 @@
<b>Pause and play</b>
Pause freezes the <i>display</i>, not the acquisition. The producer keeps filling the ring at
full rate; the view binds an immutable snapshot of the window that was on screen, and the axis
is pinned to it.
<b>How to reach it</b>
The pause button in the toolbar, or <tt>Space</tt>. Press it again to resume, which rebinds the
live ring and puts the window back on the newest data.
<b>Worth knowing</b>
• Nothing is lost while paused. Because the producer never stops, resuming shows current data
with no hole in the history behind it.
• The axis is pinned to the <i>snapshot's own stamps</i>, not to the window that was requested.
A window reaching back past the oldest retained sample yields a shorter snapshot, and the
pinned axis is that shorter span — so you see a narrower view rather than an empty margin.
• A paused view is pinned exactly as a held trigger event is: zoom in freely, but a pan or a
zoom out is clamped to the snapshot's data.
• Pausing is the easiest way to measure a layout or read a cursor precisely, because nothing
under the pointer moves between frames.
• The status bar says how long you have been paused — wall time, since the stream itself never
stopped. It is there so a frozen picture cannot be mistaken for a live one that went quiet.
<b>Example</b>
Pause with a 2 s window over a ring holding 1.4 s: the frozen axis reads
<tt>[-1.40 .. 0.00]</tt> rather than <tt>[-2.00 .. 0.00]</tt> with 0.6 s of blank. The pause
probe reports <tt>ring_produced</tt> still growing across the pause and
<tt>event_size</tt> non-zero exactly while paused.
-80
View File
@@ -1,80 +0,0 @@
<b>Trigger</b>
The trigger watches one signal for a condition and captures a window around it. The
<b>Shape</b> dropdown chooses what counts as an event; the arming, hold-off, pre/post window
and Normal/Single mode are the same for all four shapes.
The captured window is frozen on screen until you re-arm.
<b>How to reach it</b>
The trigger button in the toolbar (<tt>t</tt>) reveals the trigger panel: the signal, the level,
the shape, the edge (rising, falling, either), the mode (Normal or Single) and the pre-trigger
share. <b>Arm</b> starts watching; <tt>a</tt> arms and disarms from the keyboard. When the pane is
locked with its <tt>[edit]</tt> button — so that it states what the trigger is set to instead of showing
the controls — <b>Arm/Disarm</b> is in its heading, and the trigger can be armed or disarmed without
unfolding the panel. It is offered only while trigger mode is on, because a trigger that is off has no
engine to arm.
<b>Search the buffer</b> is at the foot of the same panel.
<b>Worth knowing</b>
• <b>Every triggered acquisition is kept.</b> Latches are written to the event cache
(<tt>~/.cache/udpscope/events/&lt;source&gt;/</tt>) as a <tt>.npy</tt> of the event, a
<tt>.txt</tt> of what it was, and a <tt>.bmp</tt> picture drawn from the samples. The
<b>Events</b> pane on the left -- opened from the toolbar's event-history button, which appears
while recording is enabled, and collapsed until you ask for it -- lists the last 32 newest first
(time, duration, signals, rate), each row with its own picture beside the numbers. Clicking a row
<i>replays</i> it: the stored event is loaded exactly like an exported file, <b>without changing the
layout</b> -- the rows, the colours, the window, the cursors and the measurements stay where they
are and read the stored data instead. A replay leaves trigger mode, because a file has no edge to
wait for, and the pane stays open. Drag the pane's edge to resize it. <b>Live</b> goes back to the
stream; the status bar names the file until you do.
Select two or more events in the list and the two buttons under the header act on them:
<b>Compare</b> puts two events in two plots side by side, keeping the layout you have -- one plot per
event, the axes synchronised, so zooming a row in one zooms the same row in the other -- and
<b>Accumulate</b> overlays up to four of them in the same traces, translucent, so where they agree
the trace is brighter. Your own traces stay where they are in both. They need events with the same
signals and the same length. A single click selects, a <i>double</i> click replays.
<b>Record events</b>, in the trigger panel with the trigger's other options, is what decides whether
anything is kept. It is <b>off by default</b> -- nothing is written to disk until you ask -- and
with it off the history button goes with it.
• Arming <i>enables</i> the signal if it was off, because a trigger on a signal nobody acquires
can never fire.
• The pre-trigger share is a share of the <i>display window</i>, not of the ring. A 2 s window
at 50% captures 1 s before the edge and 1 s after.
• There are four display states, and the status bar names each: <tt>armed</tt> (watching,
nothing drawn yet), <tt>capturing</tt> (the edge was accepted and the post window is filling
<i>in view</i>, with the marker at x = 0), <tt>event held</tt> (the frozen capture) and
<tt>disarmed</tt>. A held capture is never quietly replaced by live data.
• The first edge after arming is only accepted once the history reaches back a full pre-trigger
window, so a capture may legitimately be shorter than you asked for. The axis is pinned to
what the capture actually got.
• Disarming <i>during</i> a capture publishes what the capture got to, so the picture on screen
at that moment stays on screen. No event fired, so the event count does not move.
• The trigger reads the <b>stored</b> signal — decimated and averaged — not the wire signal.
A spike narrower than one decimation group is filtered away rather than triggering on data
the buffer does not contain.
• The four shapes: <b>edge</b> — a crossing of the level in the chosen direction, the classic
behaviour. <b>Pulse width</b> — a crossing and its return, qualified by how long the excursion
lasted: less than, greater than, within or outside a pair of widths; the event is timed at the
closing edge. <b>Window</b> — the signal entering or leaving a band between two levels; the
panel keeps the two levels at least one increment apart, because a zero-width band is not a
band: the condition becomes an exact float-equality test against the level, which an ordinary
signal never satisfies and a signal sitting at the level satisfies on every transition.
<b>Dropout</b> —
the absence of a crossing for longer than a timeout; one event per outage, re-arms when a
crossing returns.
• The shape's own rows are the only ones shown; the rest are hidden, so the panel never offers
a pulse width to a window trigger.
• <b>Search the buffer</b> runs the same condition over the whole retained ring and lists every
place it holds. The arrows step through hits; each step pauses on the hit, centred, exactly as
the Pause button does. Up to 16 hits in the visible window are marked on every row; when there
are more the panel says how many and suggests zooming in. The search uses the trigger's own
condition, set in one place, so a hit is by construction an instant the trigger would have fired.
<b>Example</b>
A 1 Hz sine, level 0, rising, 2 s window at 50%, Single. The capture spans
<tt>[-1.000 .. 1.000]</tt> with <tt>n=20001</tt> samples at 10 kS/s — the span times the real
rate, plus one — and the marker sits at x = 0 throughout.
-50
View File
@@ -1,50 +0,0 @@
<b>Cursors</b>
Cursors read values off the traces. There are two kinds: one <i>shared vertical pair</i> that
every row carries at the same time, and <i>horizontal rulers</i>, one pair per row, that belong
to that row alone.
<b>How to reach it</b>
The cursors button in the toolbar, or <tt>c</tt>. The pane below the plot shows the readings and
owns a <b>Reset</b> button. Drag a marker to move it. The mode dropdown offers four modes:
<tt>vertical</tt> (the shared time pair), <tt>horizontal</tt> (the per-row rulers),
<tt>both</tt>, and <tt>mix</tt>.
<b>Worth knowing</b>
• The vertical pair is <i>one</i> time for the whole figure, so moving it on any row moves it on
all of them. That is what makes <tt>dt</tt> meaningful across rows.
• A ruler belongs to its row and nothing shares it, so two rows' rulers are independent.
• <tt>mix</tt> is the reading mode: one line per <i>bound</i> trace, with the trace's own values
at each marker and their difference. A slot with a name but no channel behind it is not a
trace and gets no line.
• A value is the <i>nearest stored sample's</i>, never a number between two samples. Zoom in past
the sample spacing and the line between two samples is something the plot drew, not data —
so a reading off it would be the drawing. The hover crosshair reads the same way.
• Every reading carries a <i>metric prefix</i> so the digits sit in <tt>1..999</tt>:
<tt>10.52 mV</tt>, not <tt>0.01 V</tt>. Two decimals is all an instrument landing on an
<tt>f32</tt> sample can claim, so without the prefix a small value would read <tt>0.00</tt>
and the reading would be gone. A stream that declares no unit gets the prefix anyway, written
tight against the number (<tt>585.24m</tt>) — the space separates the number from its unit,
and a spaced <tt>m</tt> with nothing after it would read as metres.
• A marker past the newest sample reads <tt>--</tt>. With only one marker over data the row
reads <tt>partial</tt>: a difference between a number and nothing is not a number.
• In <tt>mix</tt> each marker also places a crossing annotation per trace, which is what lets
you see on the plot which sample a number came from. It is <i>on that sample</i>, so when the
marker falls between two it sits a little off its own line: the line is where you put the
marker, the dot is the datum being quoted. The hover crosshair's mark snaps the same way, and
is drawn larger than a trace's sample dots so it does not read as one more sample.
• The time row exists only in the modes that show the vertical pair. <tt>horizontal</tt> has no
time row, by design.
• A cursor <i>stays where you put it</i> while a live view rolls under it, which is what a bench
scope does: its <tt>T0</tt> is an age, so a marker left at <tt>-1.5s</tt> keeps reading the
sample a second and a half old as the stream advances. A measurement's marks do the opposite —
they travel with the sample they measured — because a cursor is a question you asked and a mark
is an answer about one particular sample.
<b>Example</b>
Three traces in <tt>mix</tt> mode with both markers over data: 3 lines and 6 crossing marks,
each line reading like <tt>sine 0.31 V -0.84 V -1.15 V</tt>. Move the second marker past the
newest sample and the third column reads <tt>--</tt>.
-60
View File
@@ -1,60 +0,0 @@
<b>Measurements</b>
A measurement is a statistic of the <i>visible window</i> of one signal, recomputed as that
window moves. The pane lists them with their values; each can also draw its result on the plot.
<b>How to reach it</b>
The measurements button in the toolbar, or <tt>m</tt>. The pane's <tt>+</tt> adds one; clicking
a row opens the dialog where the signal, the type, the threshold share and the on-plot marks are
set, with <b>Save</b> and <b>Delete</b>.
<b>Worth knowing</b>
• The types are average, top, bottom, min, max, peak to peak, amplitude, pulse duration, period
and frequency. <i>top</i> and <i>bottom</i> are the means of the upper and lower halves, which
is why a unit sine reads 0.637 (2/π) rather than 1.
• The on-plot marks come in two kinds and the mix is the reading: a <i>level</i> mark is a ruler
at a value, a <i>time</i> mark is a marker at a position. An average draws one level mark; a
max draws a level mark <i>and</i> a time mark at the first sample that reached it.
• Marks cannot be dragged. A measurement mark says what the measurement measured, so it is
created unselectable — as is the trigger marker.
• A pass is throttled to a cadence rather than run every frame, so a ruler over a live view is
<i>stepped</i>. The number and the ruler come from the same pass, so they always agree.
• An invalid pass keeps its <i>level</i> marks and drops its <i>time</i> marks. A level is a
property of the signal and outlives the window; a position inside the window does not. That is
what stops the rulers blinking off at a mode change.
• A crossing is counted with <i>hysteresis</i>, not on a bare level: the thresholds are a tenth of
the window's peak to peak either side of the share, and an edge is counted only once the signal
has passed the far one. Period, frequency and pulse duration all depend on it. The mid level is
exactly where a waveform moves fastest, so any interferer faster than the fundamental recrosses a
single level several times per real crossing — a 5 Hz sine with a 500 Hz tone at a tenth of the
amplitude reads 39 Hz without the band and 5 Hz with it. The band holds to an interferer a third
of the waveform's own size.
• The band is clamped to stay inside the data, which is what keeps a threshold set near an extreme
usable: a fixed band there would sit above every sample and the measurement would read nothing.
A share near an extreme still leaves the band little room, so it rejects less — which is one
reason period and frequency offer no share to set.
• A window with too few crossings reads <tt>invalid</tt> / <tt>--</tt> rather than a plausible
invented number. Frequency over a window holding one edge is the usual case.
• <tt>frequency</tt> is reported in Hz and <tt>period</tt>/<tt>pulse duration</tt> in seconds;
every other type carries the channel's own unit. A value is written with a <i>metric prefix</i>
so its digits sit in <tt>1..999</tt> — <tt>636.62 mV</tt>, not <tt>0.64 V</tt> — including when
the stream declares no unit at all, which is always the case for a math channel: there the
prefix goes tight against the number (<tt>636.62m</tt>), since two decimals of an unprefixed
small value is <tt>0.00</tt>.
• Cost is bounded by reading the summary pyramid the producer already maintains, not the raw
window. The values are still exact: a bucket's sum is a sum.
• <b>Statistics</b> adds a second line to every measurement showing <tt>n</tt>, the mean, the
standard deviation and the range. Each observation is one <i>recompute</i> of the pane, so
<tt>n</tt> counts windows measured, not samples — a mean frequency over many windows is what
that claims to be. <b>Reset</b> clears every accumulator and starts fresh. A change of display
window, a CONFIG or a measurement edit also resets; a pause, a resume, a zoom, a pan and the
toggle itself do not, so turning the line off to read the plot and back on again keeps the history.
<b>Example</b>
On a 1 Hz ±1 sine: <tt>average=0</tt>, <tt>top=0.636620</tt>, <tt>bottom=-0.636620</tt>,
<tt>max=1</tt>, <tt>min=-1</tt>, <tt>peak to peak=2</tt>, <tt>amplitude=1</tt>,
<tt>pulse duration=0.5 s</tt> (duty 0.5), <tt>period=1 s</tt>, <tt>frequency=1 Hz</tt>. Over a
2 M-sample window those two passes read about 0.1% of the samples.
-61
View File
@@ -1,61 +0,0 @@
<b>Math channels</b>
A math channel is a signal computed from other signals. You build it as a small graph — source
nodes, operations, one output — and udpscope then treats the result as an ordinary channel.
<b>How to reach it</b>
The math button in the toolbar, or <tt>g</tt>. The pane's <tt>+</tt> adds a channel, and opening
one shows the graph editor: nodes as boxes, click a box to select it and edit its parameters beside
the canvas. Inside the editor, <i>add node</i> is a menu button whose popover picks the op, so the
node arrives as the operation you asked for.
<b>Graph gestures:</b> <b>drag</b> from an output port to an input port to wire them; drag a
wired input port off its connection to detach it; click a wire and press <tt>Delete</tt> to remove
it; drag a box to move it. <b>Wheel</b> zooms about the pointer. <b>Drag the background</b> to
pan. <tt>f</tt> fits all nodes into view; <tt>Ctrl+0</tt> resets the view. <i>tidy</i>, the button
beside <i>fit</i> in the editor, lays the graph out in topological columns and frames it. The orange
dot marks the output node; click <i>set as output</i> to move it.
<b>Worth knowing</b>
• The graph is evaluated as an ordered list, so a node may only read ones <i>above</i> it. A
wire drawn right to left re-sorts the list silently so the graph stays valid. A cycle is
unrepresentable: the connection is refused rather than created, and the line beneath the form
says why — a loop, or a node asked to read itself.
• A derived channel <i>is</i> a channel. It appears in the signal panel, can be plotted,
measured, exported, and used as a source for another math channel.
• The math runs on the <b>stored</b> samples — decimated and averaged — so a cutoff you type in
Hz means the rate the filter will actually run at. Change the window across a decimation
boundary and the coefficients are rebuilt for the new rate.
• A source may only name an <i>acquired</i> channel. The stream's timestamp signal is not one:
a graph sourced from it validates and then reads NaN for ever, so the dropdown does not
offer it.
• The output defaults to the <i>last</i> node, and a node added to a linear graph becomes the
output. Choosing an earlier node explicitly is respected.
• Every numeric field starts at its own identity value — gain 1, offset 0 — not at the bottom
of its range.
<b>Example</b>
<tt>[sine] → [gain x2]</tt> gives a channel reading <tt>y=[-2, 2]</tt>. A 100 Hz low pass on the
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>Apply is insensitive while the graph is incomplete.</b> A line beneath the form 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.
-44
View File
@@ -1,44 +0,0 @@
<b>Saving and export</b>
Export writes the <i>visible range</i> of every plotted trace to a file: NumPy <tt>.npy</tt>,
<tt>.csv</tt>, or <b>MDF 3.30</b> (<tt>.mf3</tt> or <tt>.mdf</tt>), chosen by the extension you
type.
<b>How to reach it</b>
<b>Export visible range…</b> in the overflow menu, or <tt>Ctrl+E</tt>. A file chooser asks where;
the format follows the path's extension.
<b>Worth knowing</b>
• The table is collected at the moment you click, not when the chooser returns. A live view keeps
scrolling while a dialog is up, so a late collection would write a window you never saw.
• The rows are the <b>stored samples</b>, never the LOD buckets the plot draws from. A row count
far below the span times the stored rate means something cut the table from the pyramid.
• The stored rate is not always the wire rate. A window long enough to need decimation is stored
averaged at a lower rate, and the export carries <i>that</i> — read the status bar's ⓘ popover
for the figure before trusting a sample count.
• Two time columns. <tt>time</tt> is the display timeline — negative ages ending at 0 while
streaming, offsets from the trigger edge while an event is held — so it is the x you read a
value at on screen. <tt>abs_seconds</tt> is the source's own clock for the same sample, which
is what lets two exports taken in different modes line up. Both are strictly increasing and
evenly spaced; duplicate or backwards stamps would mean the file held two clocks.
• An export taken <i>mid-capture</i> writes the partly filled window that is on screen, which is
shorter than the event span. Export outside a capture if you want the whole event.
• <b>MDF</b> exists for handing a window to another tool, and its shape follows the contract that
reader uses: one channel group, every channel record-interleaved, the master channel as the x
axis and the unit in the channel metadata. So there the <b>master is <tt>abs_seconds</tt></b>,
named <tt>time</tt> — the clock that is the same in every display mode — and the display axis
goes out beside it as an ordinary channel, <tt>time_relative</tt>. Both are seconds; each signal
carries the source's own unit. That is also what <b>ScryLab</b> reads: channel name → signal
name, master → x axis, unit → the signal's unit.
<b>Example</b>
A 2 s window at 10 kS/s over 3 traces writes 20001 rows and 5 columns
(<tt>time</tt>, <tt>abs_seconds</tt>, three signals). The <tt>.npy</tt> is
<tt>64·⌈(10+len(header)+1)/64⌉ + rows·(16 + 4·cols)</tt> bytes. Read it back with
<tt>pd.DataFrame(np.load(path))</tt>. The <tt>.mf3</tt> is bigger — a block per channel plus a
comment — <tt>272 + comment + rows·record + 28 + channels·274 + 30</tt> bytes, with
<tt>record = 16 + 4·cols</tt> and <tt>channels = cols + 2</tt> for the master and the relative
axis. Read that one back with <tt>asammdf.MDF(path)</tt> or in ScryLab.
-60
View File
@@ -1,60 +0,0 @@
<b>Sessions and settings</b>
udpscope stores named sessions — <i>configurations</i> — per source, so reopening a source gives
you the scope you closed, and one source can hold several arrangements for different experiments.
There is no global preferences file: the session <i>is</i> the settings.
<b>How to reach it</b>
Nothing to open — the file is written as you work. Its path is in the About dialog, and the
directory is <tt>$XDG_CONFIG_HOME/udpscope</tt>, or <tt>~/.config/udpscope</tt> when that is
unset. Each source has a directory <tt>&lt;host&gt;_&lt;port&gt;/</tt> holding one
<tt>&lt;name&gt;.toml</tt> per configuration and an <tt>active</tt> file naming the one it is on.
<b>Configurations</b>
<b>Configurations…</b> in the menu lists the running source's configurations: click a row to open
it, <tt>×</tt> to delete it, or type a name and a note and press <i>Save this session</i> to keep
the current arrangement under a new one. Opening a configuration saves the current session first,
so switching never loses what it switches from.
The startup chooser (start with no <tt>--server</tt>) lists one row per configuration, with its
note and its settings, so two arrangements of one source are told apart before they open.
A configuration also carries the settings that can only be decided when the buffers are
allocated: the <b>buffer size</b> (<tt>--buffer-mb</tt>), relative time, and the packet capture's
ring size. Opening a configuration from the chooser applies all three; switching inside a running
app applies the rest and says in the status line that these take effect on the next start.
The chooser's form sets the first two for a new host, and its pencil button edits them on an
existing configuration — along with the name and the note. Renaming a configuration, or editing
the address it points at, moves it: the old file is removed rather than left behind.
<b>Worth knowing</b>
• What a configuration persists: the time window, the rows and each row's slot signals, the
calibration, the trace colours, which signals are enabled, the math channels with their graphs,
the measurements pane — and the startup settings: buffer size, relative time and the packet
capture's ring size.
• Signal identity is the <i>name</i> throughout, because a new CONFIG renumbers the channels.
• A file written by a run against a source whose streamer was down would name no signals at all,
so such a run does <b>not</b> write: a configured session is never overwritten by a run that
configured nothing. (The chooser hides such a file for the same reason.)
• The multicast group and the interface live <i>inside</i> the file, because its name cannot
carry them. A file from an older build has neither key and loads as unicast on the default
route.
• The chooser lists only configurations that name at least one signal, which is what keeps it the
sessions you configured rather than every port you have ever tested against.
• Deleting a configuration is a supported way to start over; the chooser's <tt>×</tt> and the
dialog's do exactly that.
• A session from before configurations existed is imported once as the configuration
<tt>default</tt>, and the old file is kept beside the directory as
<tt>&lt;host&gt;_&lt;port&gt;.toml.imported</tt>.
<b>Example</b>
Connect to <tt>127.0.0.1:8000</tt>, drop three signals on row 1, set a 2 s window, and save the
session as <tt>bench</tt> with a note. <tt>~/.config/udpscope/127.0.0.1_8000/bench.toml</tt> now
holds <tt>window_s=2 comment="…"</tt> with <tt>signals = ["sine", "ramp", "noise", ""]</tt>, and
the next run reports <tt>layout loaded from …/127.0.0.1_8000/bench.toml</tt> with those signals
already acquired.
-46
View File
@@ -1,46 +0,0 @@
<b>Status bar and diagnostics</b>
The status bar is two tiers. The bar itself is one glance line — the state, the rate and the
window — and everything else is behind the ⓘ button at its right-hand end.
<b>How to reach it</b>
The bar is always visible under the plot. Click <b>ⓘ</b> for the detail popover.
<b>Worth knowing</b>
• The badge is one glyph in four colours: green <i>alternating</i> lit and dim means data is
arriving, and a steady colour means it is not. A badge that never blinks is not a working badge.
Of the two steady colours, <i>grey</i> is no stream at all or a pipeline you stopped (the
tooltip says which), and <i>yellow</i> is a source that answered but has sent no configuration.
• The state field is one word from a fixed set: <tt>connecting</tt>, <tt>live</tt>,
<tt>paused</tt>, <tt>armed</tt>, <tt>capturing</tt>, <tt>event held</tt>, <tt>disarmed</tt>. Two
states add one field each: in trigger mode, how long ago the last event was; while paused, how
long you have been paused.
• <tt>connecting</tt> means there is no <i>signal list</i> yet — either no session or a session
with no configuration behind it. Every other word claims a signal list, so until one arrives
there is nothing for them to describe: nothing can be enabled and no data is owed.
• The paused figure is <i>wall</i> time, not stream time — the producer never stops while paused,
so it answers "how long have I been looking at this picture".
• Nothing on the bar may change width with the stream, because the bar sits directly above the
plot and a wider label would relayout it. That is why the rate collapses to one figure or a
range rather than listing per channel, and why an elapsed time steps to a coarser unit instead
of growing digits.
• <tt>gaps</tt> counts packets the receiver knows it missed. It is the number that matters: a
non-zero count means samples are absent from the buffer, not merely late.
• <tt>reconnects</tt> counts sessions re-established with the source. Over unicast a climbing
count usually means a second client is taking the source's single session.
• The starvation notice — <i>connected, but no data is arriving</i> — is that same situation seen
from the other side: the signal list is here, so something took the stream away. A source that
has sent <i>no</i> configuration gets a different notice, <i>trying to connect</i>, because
nothing was taken away — pointing the app at a port where nothing answers is that case, and the
usual cause is a streamer that is not running or is serving another port.
• <tt>stored</tt> versus <tt>wire</tt> in the popover is the decimation. They differ exactly
when the window is too long to store at full rate.
<b>Example</b>
The bar reads <tt>● live · 10 kS/s · 2 s window</tt>. Behind ⓘ:
<tt>Stream connected, configured / packets 717 · frames 716 · gaps 0 / 3 signals · 3 channels</tt>,
<tt>Buffer 200.0 MB · 1677 s span · time x1 / stored 10 kS/s (wire 10 kS/s)</tt>,
<tt>Plot 1 3 traces · 195000 samples</tt>, <tt>Reconnects 0</tt>.
-34
View File
@@ -1,34 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
SPDX-License-Identifier: MIT
The help pages, compiled into the binary for the same reason the icons are: udpscope is
run straight out of the build tree and has no install step, so a search path would have
to be located at runtime and the failure mode of not locating it is a help window full
of missing pages.
-->
<gresources>
<gresource prefix="/org/udpscope/help">
<file>01-overview.txt</file>
<file>02-source.txt</file>
<file>03-signals.txt</file>
<file>04-layout.txt</file>
<file>05-window.txt</file>
<file>06-navigate.txt</file>
<file>07-pause.txt</file>
<file>08-trigger.txt</file>
<file>09-cursors.txt</file>
<file>10-measurements.txt</file>
<file>11-math.txt</file>
<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>
+76 -203
View File
@@ -3,48 +3,34 @@
#include <adwaita.h>
#include <cstdio>
#include <string>
#include "build_info.hpp"
#include "layout_store.hpp"
#include "shortcuts.hpp"
// Both come from CMakeLists: the document identifier is derived from the desktop file's
// basename (its desktop-file id), and the directory is where the Mallard set lives in the
// source tree, which is what the self-check scans. Yelp resolves `help:<identifier>` to
// `<datadir>/help/<lang>/<identifier>/` on its own (freedesktop Help System Specification),
// so the identifier and the installed directory have to name the same set -- which is why
// neither is written out again here.
static_assert(sizeof(UDPSCOPE_HELP_NAME) > 1, "UDPSCOPE_HELP_NAME must come from the build");
static_assert(sizeof(UDPSCOPE_HELP_DIR) > 1, "UDPSCOPE_HELP_DIR must come from the build");
namespace udpscope {
namespace {
/// Nulled by each window's own close handler, so a second open cannot present a dead widget.
/// One pointer per window, file-scope because there is one application.
///
/// The help window and the shortcut overview are `GtkWindow`s. The About dialog is an
/// `AdwDialog`, which is **not** a `GtkWindow`: it has no `::close-request`, so it clears on
/// `::closed` and closes with `adw_dialog_close()`.
GtkWidget* g_help = nullptr;
/// The help window is a `.ui` file too, so its builder, the label one page's markup goes into and the
/// column its screenshots go into are all kept beside the window's own pointer. All four are cleared by
/// the window's own close handler: the page switch reads them, and it cannot run after the window is
/// gone.
GtkBuilder* g_help_ui = nullptr;
GtkWidget* g_help_body = nullptr;
GtkWidget* g_help_shots = nullptr;
/// Nulled by each dialog's own close handler, so a second open cannot present a dead widget.
/// One pointer per dialog, file-scope because there is one application. The help manual is no
/// longer one of these: it opens in a separate viewer process through the `help:` URI, and a
/// process with no window of ours has nothing to null.
AdwDialog* g_about = nullptr;
/// The overview is the one window here built from a `.ui` file, so its builder is held too: the window
/// belongs to the builder, and a *destroyed* `GtkWindow` cannot be presented again -- which is why the
/// The overview is built from a `.ui` file, so its builder is held too: the window belongs to
/// the builder, and a *destroyed* `GtkWindow` cannot be presented again -- which is why the
/// builder is replaced on the next opening rather than reused.
GtkWidget* g_shortcuts = nullptr;
GtkBuilder* g_shortcuts_ui = nullptr;
/// The help window's own close handler, rather than the shared one: this window also owns the label a
/// page is written into and the column its screenshots are built in, and a page switch that ran after
/// the window was gone would read both.
gboolean on_help_close(GtkWindow* window, gpointer) {
(void)window;
g_help = nullptr;
g_help_body = nullptr;
g_help_shots = nullptr;
return FALSE; // let the default handler destroy it
}
gboolean on_window_close(GtkWindow* window, gpointer slot) {
(void)window;
*static_cast<GtkWidget**>(slot) = nullptr;
@@ -58,100 +44,27 @@ void on_dialog_closed(AdwDialog* dialog, gpointer slot) {
*static_cast<AdwDialog**>(slot) = nullptr;
}
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", 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", 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", 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"},
};
constexpr int kPageCount = static_cast<int>(sizeof(kPages) / sizeof(kPages[0]));
/// The page's markup, or a one-line stand-in. `ok` reports whether the real body loaded
/// *and* parsed: a missing resource and markup Pango rejects both render the stand-in,
/// because a help window that fails to open is worse than one page that says so.
std::string load_page(const HelpPage& page, bool* ok) {
*ok = false;
/// The launch is asynchronous and can fail -- no help viewer installed, no handler for the
/// `help:` scheme, or a document no viewer can find -- and it runs in another process, so
/// nothing raises a window of ours. Report the failure here rather than leaving a click that
/// silently did nothing; the detail names the identifier a viewer looks for.
void on_help_launched(GObject* source, GAsyncResult* result, gpointer user) {
GtkWindow* parent = user != nullptr ? GTK_WINDOW(user) : nullptr;
GError* error = nullptr;
GBytes* bytes = g_resources_lookup_data(page.resource, G_RESOURCE_LOOKUP_FLAGS_NONE,
&error);
if (bytes == nullptr) {
if (error != nullptr) g_error_free(error);
return "<i>This page is missing from the build.</i>";
}
gsize size = 0;
const char* data = static_cast<const char*>(g_bytes_get_data(bytes, &size));
std::string body(data, size);
g_bytes_unref(bytes);
if (!pango_parse_markup(body.c_str(), -1, 0, nullptr, nullptr, nullptr, &error)) {
if (error != nullptr) g_error_free(error);
return "<i>This page could not be displayed: its markup is malformed.</i>";
}
*ok = true;
return body;
}
void on_help_row_selected(GtkListBox*, GtkListBoxRow* row, gpointer) {
if (row == nullptr || g_help_body == nullptr) return;
const int idx = GPOINTER_TO_INT(g_object_get_data(G_OBJECT(row), "help-page"));
if (idx < 0 || idx >= kPageCount) return;
bool ok = false;
const std::string body = load_page(kPages[idx], &ok);
gtk_label_set_markup(GTK_LABEL(g_help_body), body.c_str());
// The screenshots above it, if this page has any. The column comes from the `.ui` by id rather than
// being found as the label's previous sibling: 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* shots = g_help_shots;
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);
if (!gtk_uri_launcher_launch_finish(GTK_URI_LAUNCHER(source), result, &error)) {
GtkAlertDialog* alert = gtk_alert_dialog_new("Could not open the help manual");
const std::string detail =
std::string(error->message) +
"\n\nThe manual is installed under $XDG_DATA_DIRS/help/C/help:" UDPSCOPE_HELP_NAME
"; a desktop help viewer (for example `yelp`) that handles the `help:` URI is needed "
"to read it.";
gtk_alert_dialog_set_detail(alert, detail.c_str());
gtk_alert_dialog_show(alert, parent);
g_object_unref(alert);
g_error_free(error);
}
// `parent` was referenced for the duration of the async call.
if (parent != nullptr) g_object_unref(parent);
}
} // namespace
@@ -197,84 +110,46 @@ const char* about_icon_name() {
HelpReport help_self_check() {
HelpReport report;
report.pages = kPageCount;
for (const HelpPage& page : kPages) {
bool ok = false;
const std::string body = load_page(page, &ok);
if (ok && !body.empty()) ++report.parsed;
if (report.shortest_title.empty() || body.size() < report.shortest_bytes) {
report.shortest_title = page.title;
report.shortest_bytes = body.size();
// The manual is a set of files on disk now, so the reading is a file-system one: a missing
// directory leaves `topics=0 index=false`, which is a manual that is not where the build
// says it is -- and Yelp would show nothing for `help:<identifier>`.
GError* error = nullptr;
GDir* dir = g_dir_open(UDPSCOPE_HELP_DIR, 0, &error);
if (dir == nullptr) {
if (error != nullptr) g_error_free(error);
return report;
}
while (const char* name = g_dir_read_name(dir)) {
if (!g_str_has_suffix(name, ".page")) continue;
if (g_strcmp0(name, "index.page") == 0) report.index = true;
else ++report.topics;
}
g_dir_close(dir);
// The figures the pages reference, counted for the same reason: an image that went missing
// is a broken figure in the viewer rather than a failed build.
const std::string figures = std::string(UDPSCOPE_HELP_DIR) + "/figures";
if (GDir* figdir = g_dir_open(figures.c_str(), 0, &error)) {
while (const char* name = g_dir_read_name(figdir)) {
if (g_str_has_suffix(name, ".png") || g_str_has_suffix(name, ".jpg")) ++report.figures;
}
g_dir_close(figdir);
} else if (error != nullptr) {
g_error_free(error);
}
return report;
}
void show_help(GtkWindow* parent) {
if (g_help != nullptr) {
gtk_window_present(GTK_WINDOW(g_help));
return;
}
// A builder per opening, as with every other `.ui` here: the window belongs to its object list and
// `close-request` destroys the window, so the resource is parsed again rather than a dead window
// fetched. This is where the previous one goes, and it is safe because that window has been closed
// for as long as it takes someone to ask for help again.
g_clear_object(&g_help_ui);
g_help_ui = gtk_builder_new_from_resource("/org/udpscope/ui/help.ui");
if (g_help_ui == nullptr) return;
GObject* window = gtk_builder_get_object(g_help_ui, "help_window");
GObject* list = gtk_builder_get_object(g_help_ui, "page_list");
GObject* body = gtk_builder_get_object(g_help_ui, "body");
GObject* shots = gtk_builder_get_object(g_help_ui, "shot_box");
// A missing id is the one mistake the build and `GtkBuilder` both miss; every reader below would be a
// null widget on the first page switch.
if (window == nullptr || list == nullptr || body == nullptr || shots == nullptr) return;
g_help = GTK_WIDGET(window);
g_help_body = GTK_WIDGET(body);
g_help_shots = GTK_WIDGET(shots);
gtk_window_set_transient_for(GTK_WINDOW(g_help), parent);
// The rows are the pages, which is data and not template: a heading row per section, then one row per
// page carrying its own index. A template cannot know the fourteen pages, and a sectioned list is not
// what a GtkListView with one item factory can draw.
const char* current_section = nullptr;
for (int i = 0; i < kPageCount; ++i) {
if (current_section == nullptr || std::string(current_section) != kPages[i].section) {
current_section = kPages[i].section;
GtkWidget* heading = gtk_label_new(nullptr);
char markup[128];
std::snprintf(markup, sizeof(markup), "<b><small>%s</small></b>", current_section);
gtk_label_set_markup(GTK_LABEL(heading), markup);
gtk_label_set_xalign(GTK_LABEL(heading), 0.0f);
gtk_widget_set_margin_start(heading, 10);
gtk_widget_set_margin_top(heading, 10);
gtk_widget_set_margin_bottom(heading, 2);
GtkWidget* heading_row = gtk_list_box_row_new();
gtk_list_box_row_set_selectable(GTK_LIST_BOX_ROW(heading_row), FALSE);
gtk_list_box_row_set_activatable(GTK_LIST_BOX_ROW(heading_row), FALSE);
gtk_list_box_row_set_child(GTK_LIST_BOX_ROW(heading_row), heading);
gtk_list_box_append(GTK_LIST_BOX(list), heading_row);
}
GtkWidget* label = gtk_label_new(kPages[i].title);
gtk_label_set_xalign(GTK_LABEL(label), 0.0f);
gtk_widget_set_margin_start(label, 18);
gtk_widget_set_margin_end(label, 10);
gtk_widget_set_margin_top(label, 4);
gtk_widget_set_margin_bottom(label, 4);
GtkWidget* row = gtk_list_box_row_new();
gtk_list_box_row_set_child(GTK_LIST_BOX_ROW(row), label);
g_object_set_data(G_OBJECT(row), "help-page", GINT_TO_POINTER(i));
gtk_list_box_append(GTK_LIST_BOX(list), row);
}
g_signal_connect(list, "row-selected", G_CALLBACK(on_help_row_selected), nullptr);
// Select the first page so the window never opens blank.
GtkListBoxRow* first = gtk_list_box_get_row_at_index(GTK_LIST_BOX(list), 1);
if (first != nullptr) gtk_list_box_select_row(GTK_LIST_BOX(list), first);
g_signal_connect(g_help, "close-request", G_CALLBACK(on_help_close), &g_help);
gtk_window_present(GTK_WINDOW(g_help));
// The freedesktop `help:` URI, handed to whatever the desktop registered for it -- Yelp on
// GNOME. The launch is asynchronous, so the parent is referenced across it and released in
// the callback; `parent` may legitimately be nullptr (the chooser runs before any window
// exists).
GtkUriLauncher* launcher = gtk_uri_launcher_new("help:" UDPSCOPE_HELP_NAME);
gtk_uri_launcher_launch(launcher, parent, nullptr, on_help_launched,
parent != nullptr ? g_object_ref(parent) : nullptr);
g_object_unref(launcher);
}
void show_shortcuts(GtkWindow* parent) {
@@ -284,17 +159,16 @@ void show_shortcuts(GtkWindow* parent) {
}
// A `GtkShortcutsWindow` from `src/ui/shortcuts.ui` -- gedit's arrangement, and GTK's own: one
// section per page, with the sidebar and the search box drawn by the window itself.
//
// A UI file rather than widget construction because of the mouse rows. An accelerator cannot spell
// "Ctrl and drag", and libadwaita's `AdwShortcutsItem` -- the modern replacement for this widget --
// has no icon property at all, so its cell reads "No Shortcut". `GtkShortcutsShortcut`'s one generic
// gesture type draws the `icon` the row names, which is what puts a mouse glyph where the keycaps
// would be. (The widget is deprecated since 4.20, but this uses none of its functions: the file is
// loaded as data and presented as a `GtkWindow`, so the build stays at its four warnings.)
// section per page, with the sidebar and the search box drawn by the window itself. A UI file
// rather than widget construction because of the mouse rows: an accelerator cannot spell "Ctrl
// and drag", and libadwaita's `AdwShortcutsItem` -- the modern replacement -- has no icon
// property, so its cell reads "No Shortcut". `GtkShortcutsShortcut`'s one generic gesture type
// draws the `icon` the row names. (The widget is deprecated since 4.20, but this uses none of
// its functions: the file is loaded as data and presented as a `GtkWindow`.)
//
// A builder per opening, not one reused: the window belongs to the builder and `close-request`
// destroys the window, so the resource is parsed again rather than an already-dead window fetched.
// destroys the window, so the resource is parsed again rather than an already-dead window
// fetched.
g_clear_object(&g_shortcuts_ui);
g_shortcuts_ui = gtk_builder_new_from_resource("/org/udpscope/ui/shortcuts.ui");
if (g_shortcuts_ui == nullptr) return;
@@ -307,7 +181,6 @@ void show_shortcuts(GtkWindow* parent) {
}
void close_help_windows() {
if (g_help != nullptr) gtk_window_close(GTK_WINDOW(g_help));
if (g_about != nullptr) adw_dialog_close(g_about);
if (g_shortcuts != nullptr) gtk_window_close(GTK_WINDOW(g_shortcuts));
}
+20 -14
View File
@@ -18,20 +18,26 @@ void show_about(GtkWindow* parent, const std::string& session_path);
/// so the dialog is asserted to *show* the app icon rather than merely to open.
const char* about_icon_name();
/// The paged help window. Modal on nothing — it is a reference, so it stays usable while
/// the scope runs. Already open: presented.
/// Opens the installed Mallard manual in the desktop help viewer — Yelp, through the
/// `help:` URI the freedesktop Help System Specification defines — rather than drawing a
/// window of our own. `parent` is the window the launch is attributed to (and the one an
/// error is reported on); the viewer is a separate process, so nothing here can be closed
/// with `close_help_windows()`.
void show_help(GtkWindow* parent);
/// What UDPSCOPE_PROBE_HELP_MS reports. `parsed` counts pages whose markup
/// pango_parse_markup() accepted and whose body is non-empty; it must equal `pages`.
/// What UDPSCOPE_PROBE_HELP_MS reports about the manual *in the tree*. `topics` is the
/// number of `<id>.page` files other than `index.page`, and `index` says whether
/// `index.page` is present: the manual is now a set of files on disk (installed by the
/// `install()` rule, and read by Yelp), not a GResource in the binary, so the self-check
/// asserts the set is *there* and must be read as a file-system finding — `topics=0
/// index=0` means the manual is missing from `UDPSCOPE_HELP_DIR`.
struct HelpReport {
int pages = 0;
int parsed = 0;
std::string shortest_title;
std::size_t shortest_bytes = 0;
int topics = 0;
bool index = false;
int figures = 0;
};
/// Loads and validates every page without building a window. Safe to call headless.
/// Scans the manual's directory without opening a viewer. Safe to call headless.
HelpReport help_self_check();
/// The shortcut overview: the application accelerators and the plot's own interactions,
@@ -39,11 +45,11 @@ HelpReport help_self_check();
/// deprecated as of GTK 4.18 and this project builds warning-free.
void show_shortcuts(GtkWindow* parent);
/// Closes whichever of the three windows are open. One of them is a GtkWindow -- the help
/// window, closed with gtk_window_close(), which emits close-request so its handler runs. The
/// other two are AdwDialogs, which are not GtkWindows: they close with adw_dialog_close(),
/// which emits ::closed. Either way the file-scope pointer is nulled by the handler, which is
/// what prevents a second open presenting a dead widget. Used by the probe.
/// Closes whichever of the two dialogs are open. Both are `AdwDialog`s, which are not
/// `GtkWindow`s: they close with `adw_dialog_close()`, which emits `::closed`, and the
/// file-scope pointer is nulled by the handler that prevents a second open presenting a dead
/// widget. There is no help window left to close -- the manual opens in a separate viewer
/// process. Used by the probe.
void close_help_windows();
} // namespace udpscope
+5 -3
View File
@@ -5461,9 +5461,11 @@ void collect_spins(GtkWidget* w, std::vector<double>* out) {
/// 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.
/// Loads and validates every help page, opens all three windows, closes them, and reports.
/// `parsed` must equal `pages` and no page may be empty; `closed ok` with no GTK critical
/// is the crash check for three windows built and destroyed in one run.
/// Checks that the manual is present in the tree where the build says it is (`topics` and
/// `index`, both zero if it is missing), opens the About and shortcut dialogs, reads them and
/// closes them. The manual itself opens in a separate viewer process, so there is no help
/// window here to build; `closed ok` with no GTK critical is the crash check for the two
/// dialogs built and destroyed in one run.
int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisition& acq,
+3 -2
View File
@@ -2,8 +2,9 @@
<!-- SPDX-License-Identifier: MIT -->
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
<!--
The application's UI files, compiled into the binary beside the icons and the help pages, so there
is still exactly one file to run. `preprocess="xml-stripblanks"` is what catches a malformed file:
The application's UI files, compiled into the binary beside the icons, so there is still exactly
one file to run. (The manual is no longer a bundle here: it is a Mallard set installed for Yelp.)
`preprocess="xml-stripblanks"` is what catches a malformed file:
gtk_builder_new_from_resource() on a broken .ui returns nothing usable and the window simply does
not open, which is a silent failure at runtime and a loud one here.
-->
+7 -4
View File
@@ -4447,7 +4447,10 @@ gboolean help_probe(gpointer user) {
const std::string session =
layout_path(a->model->config().server, a->model->config().port);
show_help(GTK_WINDOW(a->window));
// The manual is not opened here: it lives in another process (Yelp, through the `help:` URI)
// and neither it nor this box can draw a window for it, so there is nothing to open, read
// back or close. `help_self_check()` above is the assertion -- the set is on disk where the
// build says it is. The two AdwDialogs below are still built, read and closed for real.
show_about(GTK_WINDOW(a->window), session);
show_shortcuts(GTK_WINDOW(a->window));
// Read while it is open: the icon is the point of the dialog carrying one, and
@@ -4464,11 +4467,11 @@ gboolean help_probe(gpointer user) {
// works and is not documented, or is documented and does nothing.
const UiShortcuts ui = ui_shortcut_rows("/org/udpscope/ui/shortcuts.ui");
std::fprintf(stderr,
"[help] pages=%d parsed=%d shortest='%s' %zu B; shortcuts: app=%d plot=%d tables=%d "
"[help] topics=%d index=%d figures=%d dir=%s; shortcuts: app=%d plot=%d tables=%d "
"ui=%d ui_accel=%d ui_icon=%d ui_bare=%d; "
"about version=%s commit=%s built=%s settings=%s icon=%s; closed ok\n",
report.pages, report.parsed, report.shortest_title.c_str(),
report.shortest_bytes, accel_count, gesture_count, accel_count + gesture_count,
report.topics, report.index ? 1 : 0, report.figures, UDPSCOPE_HELP_DIR,
accel_count, gesture_count, accel_count + gesture_count,
ui.rows, ui.with_accel, ui.with_icon, ui.bare, build_info::kVersion,
build_info::kCommit, build_info::kTimestamp, config_dir().c_str(),
about_icon.c_str());