improving doc

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

No files matched your search

+24 -1
View File
@@ -619,7 +619,26 @@ Two lines of the self-check output are assertions in disguise:
so the panes the tick rewrites are measured directly. The `[width-probe]` lines are the so the panes the tick rewrites are measured directly. The `[width-probe]` lines are the
per-change detail behind the `axes.w changes` count, and a step over 100 px dumps the widget per-change detail behind the `axes.w changes` count, and a step over 100 px dumps the widget
tree's wide subtrees — that is what the documented, intermittent ~320 px step wants read. tree's wide subtrees — that is what the documented, intermittent ~320 px step wants read.
- `[help] pages=14 parsed=14 shortest='Overview' 1379 B; shortcuts: app=12 plot=13; about version=… commit=… built=… settings=… icon=org.udpscope.app; closed ok` (stderr, `UDPSCOPE_PROBE_HELP_MS`) — **`parsed` must equal `pages`** and no page may be empty: a missing resource or markup Pango rejects renders a one-line stand-in instead of failing, so the window *looks* fine and only this count notices. `settings=` must be a real path (the same one the About dialog shows). **`icon=` must read `org.udpscope.app`** — it is read back through `adw_about_dialog_get_application_icon()` while the dialog is open, so the dialog is asserted to *carry* the icon rather than merely to open (a `set_application_icon()` lost in an edit leaves a dialog that opens, looks almost right, and shows a generic placeholder). `closed ok` with **no `Gtk-CRITICAL`** is as much a crash test of three windows built, presented and closed in one run as it is a state check, and a stronger one than it was: the three are now closed by **two different calls**, so a mismatched pair crashes or leaks here. One of the three is a `GtkWindow` (the help window, `gtk_window_close()`); the About dialog and the shortcut overview are `AdwDialog`s and close with `adw_dialog_close()`, cleared on `::closed`. `shortest=` is the page title — a page whose body was never compiled into the binary is absent rather than empty, and a title alone is the stand-in's shape. - `[help] pages=14 parsed=14 shortest='Pause and play' 1434 B; shortcuts: app=12 plot=13; about version=… commit=… built=… settings=… icon=org.udpscope.app; closed ok` (stderr, `UDPSCOPE_PROBE_HELP_MS`) — **`parsed` must equal `pages`** and no page may be empty: a missing resource or markup Pango rejects renders a one-line stand-in instead of failing, so the window *looks* fine and only this count notices. `settings=` must be a real path (the same one the About dialog shows). **`icon=` must read `org.udpscope.app`** — it is read back through `adw_about_dialog_get_application_icon()` while the dialog is open, so the dialog is asserted to *carry* the icon rather than merely to open (a `set_application_icon()` lost in an edit leaves a dialog that opens, looks almost right, and shows a generic placeholder). `closed ok` with **no `Gtk-CRITICAL`** is as much a crash test of three windows built, presented and closed in one run as it is a state check, and a stronger one than it was: the three are now closed by **two different calls**, so a mismatched pair crashes or leaks here. One of the three is a `GtkWindow` (the help window, `gtk_window_close()`); the About dialog and the shortcut overview are `AdwDialog`s and close with `adw_dialog_close()`, cleared on `::closed`. `shortest=` is the page title — a page whose body was never compiled into the binary is absent rather than empty, and a title alone is the stand-in's shape.
- `[menu] N 'label' accel='key' h=P label_x=X accel_right=R/W order=D ok=O`, one line per row,
then `[menu] rows=R expected=E order=D rows_bad=B popover_visible=V mapped=M button_mapped=B`
and `[menu] status pos=top bubble=WxH button_top=T button_right=R fits=F` (stderr,
`UDPSCOPE_PROBE_MENUS_MS`) — the two popovers the toolbar and the status bar open, measured with
the side panel hidden. **`rows_bad` must be 0 and `fits` must be 1.** What it asserts is what a
screenshot can only show an eye: every row's label starts at the *same* x (`label_x=26` for all
seven — a menu whose checkable rows indent their text and whose others do not is exactly what
"the text is not left-aligned" looks like), every shortcut ends at the row's own right edge
(`accel_right == W`), no row is over `kMenuRowMaxPx` (30) tall — Adwaita's button minimum is 34,
and seven of those is what made the popover read as a stack of dialogs until `.udpscope-menu`
trimmed it — and the rows arrive in the order its `kExpected` names. `[menu] status` is the ⓘ
bubble: **`pos` must read `top`**, which is set *after* `gtk_menu_button_set_popover()` because a
`GtkMenuButton` states a position of its own and the last word wins, and `button_top` must exceed
the bubble's height, because "below the button" is off the window's bottom edge — the bubble's
top edge stayed at the button with its body clipped away, which reads as an arrow disconnected
from its body. Run it **alone**: another probe that opens a window dismisses the popover, and
every row then reports `h=0`. It turns autohide off for its own measurement — a popover dismisses
itself when it loses the grab, and `xvfb-run` has no window manager to hand it one — and restores
it afterwards.
- `[keys] a: armed 0->1 | space: paused 0->1 | t: trigger panel: 0->1 | … | editable focused: 'm' refused=1` (stderr, `UDPSCOPE_PROBE_KEYS_MS`) — **every `X->Y` pair must differ** and **`refused` must be 1**. The refusal is the whole reason the controller is in the bubble phase: every bare-letter accel is also a character, so an accel that fires while a `GtkEditable` has focus makes the window entry unusable. A pair reading `0->0` is an action whose dispatch does not reach the handler a click reaches. Note the format is `<key>: <widget-description>: <before>-><after>` — the name passed to `before_after()` includes the widget's description, so `t: trigger panel: 0->1` is the correct form (not `t: trigger panel 0->1` without the colon before the values). - `[keys] a: armed 0->1 | space: paused 0->1 | t: trigger panel: 0->1 | … | editable focused: 'm' refused=1` (stderr, `UDPSCOPE_PROBE_KEYS_MS`) — **every `X->Y` pair must differ** and **`refused` must be 1**. The refusal is the whole reason the controller is in the bubble phase: every bare-letter accel is also a character, so an accel that fires while a `GtkEditable` has focus makes the window entry unusable. A pair reading `0->0` is an action whose dispatch does not reach the handler a click reaches. Note the format is `<key>: <widget-description>: <before>-><after>` — the name passed to `before_after()` includes the widget's description, so `t: trigger panel: 0->1` is the correct form (not `t: trigger panel 0->1` without the colon before the values).
- `[zoomkeys] ctrl+plus x=[..]->[..] y_held=1 | alt+plus y=[..]->[..] x_held=1` (stderr, `UDPSCOPE_PROBE_ZOOMKEYS_MS`) — **both `_held` must be 1 and both zooms must still happen.** Run it inside a pinned state, where a frame cannot re-frame the other axis for its own reasons: `UDPSCOPE_PROBE_PAUSE_MS=1500 UDPSCOPE_PROBE_ZOOMKEYS_MS=2200`. A `_held=0` is the modifier not reaching the plotter's controller; an unchanged range on the *zoomed* axis is the opposite failure, the key doing nothing. Ctrl restricts to x and Alt to y; Ctrl+Alt zooms neither (no special case needed — neither flag alone fires). - `[zoomkeys] ctrl+plus x=[..]->[..] y_held=1 | alt+plus y=[..]->[..] x_held=1` (stderr, `UDPSCOPE_PROBE_ZOOMKEYS_MS`) — **both `_held` must be 1 and both zooms must still happen.** Run it inside a pinned state, where a frame cannot re-frame the other axis for its own reasons: `UDPSCOPE_PROBE_PAUSE_MS=1500 UDPSCOPE_PROBE_ZOOMKEYS_MS=2200`. A `_held=0` is the modifier not reaching the plotter's controller; an unchanged range on the *zoomed* axis is the opposite failure, the key doing nothing. Ctrl restricts to x and Alt to y; Ctrl+Alt zooms neither (no special case needed — neither flag alone fires).
- `status bar: glance=[● live · 1.09 MS/s · 2 s window] max_glance_chars=53 detail_fields=11 popover_rebuilds=0` (exit dump) — **`max_glance_chars` must stay small** (under about 60) and **`popover_rebuilds` must be 0**. The status row is directly above the plot, so this is the "layout must not depend on content" rule read from the bar's own side: the state field is one word from a fixed set, the rate field is `%.3g` (so `10 kS/s`, never `10.0 kS/s`), and the only field trigger mode adds is the elapsed time, which steps units rather than growing digits. `detail_fields` is the popover's label count, a literal, so adding a label without thinking about layout shows up here. The glance rate format matters: `%.3g` drops the trailing zero that a `%.4g` or a fixed-precision format would keep — a rate that reads differently in the dump and in the help page is a format disagreement, and `14-status.txt`'s example must match it. - `status bar: glance=[● live · 1.09 MS/s · 2 s window] max_glance_chars=53 detail_fields=11 popover_rebuilds=0` (exit dump) — **`max_glance_chars` must stay small** (under about 60) and **`popover_rebuilds` must be 0**. The status row is directly above the plot, so this is the "layout must not depend on content" rule read from the bar's own side: the state field is one word from a fixed set, the rate field is `%.3g` (so `10 kS/s`, never `10.0 kS/s`), and the only field trigger mode adds is the elapsed time, which steps units rather than growing digits. `detail_fields` is the popover's label count, a literal, so adding a label without thinking about layout shows up here. The glance rate format matters: `%.3g` drops the trailing zero that a `%.4g` or a fixed-precision format would keep — a rate that reads differently in the dump and in the help page is a format disagreement, and `14-status.txt`'s example must match it.
@@ -1433,6 +1452,10 @@ far the largest file.
generated symbols cannot collide with the icons'), validated with `pango_parse_markup()` at generated symbols cannot collide with the icons'), validated with `pango_parse_markup()` at
load; a page that fails either lookup or parse renders a one-line stand-in and is excluded from load; a page that fails either lookup or parse renders a one-line stand-in and is excluded from
`parsed`. `UDPSCOPE_PROBE_HELP_MS`'s `parsed` must equal `pages`. `parsed`. `UDPSCOPE_PROBE_HELP_MS`'s `parsed` must equal `pages`.
A page may also carry screenshots (`HelpPage::images`, a null-terminated list of resources).
They are `GtkPicture`s **with a height request**: one created `can_shrink` asks for *no* height
at all inside a box, so without the request it is allocated nothing and the page renders its text
with a silent gap where its screenshots should be — which is what the first version did.
- **`find_accel()` masks the modifiers before comparing.** Lock, Num-Lock and the pointer buttons - **`find_accel()` masks the modifiers before comparing.** Lock, Num-Lock and the pointer buttons
all arrive in `GdkModifierType`, so an unmasked compare matches no table row at all once all arrive in `GdkModifierType`, so an unmasked compare matches no table row at all once
Caps Lock is on. Caps Lock is on.
+15 -3
View File
@@ -823,9 +823,21 @@ returning.
*while watching a signal*: the time window, pause/resume, the trigger toggle, the *while watching a signal*: the time window, pause/resume, the trigger toggle, the
side-panel toggle and an overflow menu. Those side-panel toggle and an overflow menu. Those
four buttons are icons with tooltips; nine text buttons was a wall of words to read four buttons are icons with tooltips; nine text buttons was a wall of words to read
past every time you wanted the one you needed. The overflow menu holds **Fit to past every time you wanted the one you needed. The overflow menu holds **Fit to data**, the
data**, the **Layout editor**, **Cursors**, **Reset cursors**, **Layout editor**, the **Signal list** toggle, **Export visible range...**, **Keyboard
**Export visible range…** and the **Signal list** toggle — the things you set up once. The side column and the two panel toggles stay hidden until a shortcuts**, **Help** and **About udpscope**, in that order — the shape a GNOME menu has, and
drawn as one: every row's label starts at the same left edge (and the rows that can be checked
keep that column whether or not they are checked), a shortcut sits at the row's right edge, a row
is ~18 px tall rather than Adwaita's 34, and the groups are separated by rules. The ⓘ on the
status bar opens its detail **above** the button: below is off the window's bottom edge, and a
bubble whose body is clipped away while its top edge stays at the button is what "the arrow is
detached from the body" looks like.
The **help window** (`F1`) carries the same screenshots as `docs/`: the overview page shows the
main window and a held capture above its text, and the trigger and math pages carry theirs. A
page's images are `GtkPicture`s **with a height request** — a shrinkable one asks for no height at
all inside a box, so without it the page renders its text with a silent gap where the screenshots
should be. The side column and the two panel toggles stay hidden until a
CONFIG arrives — there is nothing to configure before the stream describes itself — CONFIG arrives — there is nothing to configure before the stream describes itself —
and the first CONFIG reveals the signal panel (the trigger panel stays behind its and the first CONFIG reveals the signal panel (the trigger panel stays behind its
own toggle). own toggle).
+201
View File
@@ -0,0 +1,201 @@
# udpscope — user guide
A viewer for a UDPStreamer source: it connects, decodes the CONFIG, plots the signals you enable,
and lets you freeze, measure and derive from them. This is the *user* guide; `README.md` documents
what was built and why, and `SPECS.md` is the requirement document.
![the main window](img/main-window.png)
*The main window with a source streaming: the signal panel on the right, the plot in the middle, the
status line directly under it, and the toolbar above. The window control — `2 s` — is the first thing
on the left.*
## 1. Connecting
```sh
udpscope # asks which source to open
udpscope --server 10.0.0.5 --port 8000 # connect straight away
udpscope --server 239.1.2.3 --port 8000 --multicast 239.1.2.3 --interface 10.0.0.9
```
With no `--server` the app shows a chooser: the sessions it has stored, each naming its source, plus
a form for a new host. A source opened from the list reconnects to the multicast group and interface
it was stored with.
**A unicast source serves one client at a time.** A second viewer's `CONNECT` displaces the first, and
they take turns — the status line says so when it happens. Watching one source from several instances
needs `--multicast <group>` with `--interface <local address>`.
Every launch is its own process, so running two scopes side by side is fine.
## 2. The status line
Directly under the plot, it reads `● <state> · <rate> · <window>`, and the ⓘ beside it opens the
detail: packets, frames, gaps, per-channel sample counts, the buffer's span and its decimation.
- **The state word is one word** from a fixed set: `live`, `paused`, `armed`, `capturing`,
`event held`, `disarmed`. `no data` appears in the rate's place when nothing is arriving.
- **The rate is the *stored* rate**, which is the wire rate only while the whole requested window
fits in the buffer. A window wider than the budget allows is bought by averaging groups of samples,
so the rate falls and the status line says so — that is the anti-alias filter doing its job.
- **`connected, but no data is arriving`** appears after three seconds in which no frame arrived while
connected: the usual cause is the unicast point above. It clears the moment data returns.
- **`N reconnects`** is appended when the client has had to re-establish the session.
## 3. Signals
The signal panel lists every signal the CONFIG described, with a switch each. **Signals start
disabled — enabling one is what starts acquiring it**, and a signal nobody enables costs nothing.
Drag a signal from the panel onto a plot row to add a trace; drag it again to another row to plot the
same signal twice. The colour is per *signal*, so the same signal is the same colour wherever it
appears.
The panel also holds the per-signal calibration: gain and offset, applied at ingest, so the
measurements and the export carry the same numbers the plot shows.
## 4. The window and navigation
The header's window control (`2 s`) sets the time span on screen; the popover takes a number and a
unit, with a decade stepper either side. The trigger's event window is a share of *this* span, so a
window change moves the trigger with it.
- **Wheel** zooms about the pointer; **Shift+wheel** on x only, **Ctrl+wheel** y only.
- **Ctrl `+` / `-`** zooms x, **Alt `+` / `-`** y, about the plot centre.
- **Drag** pans, **right-drag** too (the right button also grabs a cursor at the pointer).
- **Ctrl+drag** frames x to a box, **Alt+drag** y.
- **Fit to data** (menu) auto-scales both axes and frames the whole buffered range.
The view is bounded by the **time window**, not by the buffer: zooming out stops at the span you set,
and panning stops where the data starts. When the buffer holds *less* than the window — right after a
signal is enabled — the window is left empty on its left, which is what a scope that has just started
should look like.
## 5. Trigger
![a capture held](img/trigger-capture.png)
*Single mode with a capture held: the axis is the event's own `[-pre, +post]`, the trigger marker
sits at the edge, and the status line reads `event held`.*
The trigger panel has the edge, the pre/post split (as a share of the event span), Normal or Single,
and Arm/Disarm. Selecting a signal for the trigger enables it.
- **While a capture fills** the plot keeps drawing live data on the event's clock, so the post side
fills in view; the axis does not move.
- **A held event is navigable within its own data** — zooming in works, panning stops at the capture's
edges — and it is never quietly replaced by live data.
- **Disarming mid-capture** publishes what the capture got and holds that, so the picture on screen
when you disarm is the picture you keep.
- The event window is counted in **stream seconds** end to end, so a source whose clock advances in
jumps still gets the window it asked for.
## 6. Pause
Pause freezes the *picture*, not the acquisition: the producer keeps filling the buffer behind it, and
the frozen view is a snapshot of the window that was on screen. Resuming shows the data that arrived
meanwhile — there is no hole. A paused view is held like a held event, so zooming in works and panning
does not walk it off its snapshot.
## 7. Cursors
The cursors toolbar toggle switches on two instruments per row:
- **vertical** — two times, shared by every row;
- **horizontal** — two rulers per row;
- **both** — the two together;
- **mix** — the two times, read off each *trace* where they cross it.
The pane beside it reports each trace's values at the markers, and **Reset** puts them back at the
window's edges. Values read `--` when a marker is past the newest sample and `partial` when only one
marker is over data.
## 8. Measurements
The measurements pane computes statistics of the **visible window** of a signal: average, top,
bottom, min, max, peak to peak, amplitude, pulse duration, period and frequency. `+` adds one, the
row's pencil edits it, and **show on plot** draws its result as a ruler (levels) or markers (times).
`top` and `bottom` take a *split* as a share of the window's own range — the percentage travels
between signals where a threshold in volts would not. `period` and `frequency` deliberately have no
split: they count rising crossings of the mid level, one edge per cycle, and a split there is a
setting you can only get wrong.
The values are refreshed on a cadence — at most every 100 ms while the window is scrolling, once when
it stops, and once per mode change — so a live view cannot cost you frames. The pane's cost is
reported in the exit dump with the app's self-check.
## 9. Math channels
![the math channel editor](img/math-editor.png)
*The compact node editor: each node is a box, its inputs the squares on the left edge and its output
the square on the right. The orange dot marks the node that carries the channel's value.*
A math channel is derived from the others and is then an ordinary channel: enable it and it is
acquired, plotted, measured and exported like a streamed signal, and another math channel can use it
as a source.
Operations: `channel` (a source), `gain + offset`, `abs`, `sqrt`, `clamp`, `low pass`, `high pass`,
`band pass` (biquads, corner in Hz against the stored rate), `sum`, `difference`, `product`, `ratio`.
- **Click** a node to select it; its parameters appear in the form beside the canvas.
- **Click an output square, then an input square**, to wire them. An input is only offered the nodes
above it — the graph is a list walked in order, so a cycle cannot be drawn.
- **Drag** a box to move it. **add node**, **delete node** and **set as output** are below the form.
- The **output is the last node** of a graph you build top to bottom, and the orange dot says which;
*set as output* changes it. A graph whose output is the source is valid and just passes the input
through — that is the usual reason an operation "does nothing".
- **Save** refuses an incomplete graph and says why: an unconnected input, no output, or a source that
is not a channel the scope is acquiring.
The samples are produced at ingest, once per stored slot, so the filters carry real history — a low
pass is the filter you asked for rather than a window recomputed every frame.
## 10. Sessions
Everything above is saved per source in `~/.config/udpscope/<host>_<port>.toml`: the window, the rows
and which signal each slot holds, the calibration, **which signals were enabled**, **the math channels
with their graphs** and **the measurements**. Reopening the same source brings the scope back as you
left it.
The file is plain TOML and safe to edit — a malformed line is skipped rather than refusing the whole
file. Deleting it starts from the defaults.
## 11. Export
**Export visible range…** writes the plotted signals over the visible x range at the *stored* rate to
`.npy` or `.csv`, picked by the file name's extension. The table is collected when you click, not when
the chooser closes, so a live view cannot scroll under the dialog. The `.npy` is a 2-D array: one row
per sample, `time`, `stream_time`, then one column per signal.
## 12. Keyboard
| key | action |
|---|---|
| `space` | pause / resume |
| `a` | arm / disarm the trigger |
| `t` | the trigger panel |
| `l` | the layout editor |
| `g` | go live |
| `f` | fit to data |
| `d` | add the first free signal to the focused row |
| `c` | cursors on/off |
| `m` | measurements pane |
| `e` | export |
| `?` | shortcuts |
`Ctrl+E` exports, `Ctrl+Q` quits, `Ctrl+plus`/`Ctrl+minus` zoom x and `Alt+plus`/`Alt+minus` zoom y.
A keystroke is ignored while a text field has focus, so typing in the window control never arms the
trigger.
## 13. Troubleshooting
| symptom | cause |
|---|---|
| `connected, but no data is arriving` | another client holds a unicast source — use `--multicast` |
| the plot is empty, the status reads `no data` | nothing is enabled: enable a signal in the panel |
| the trace is blank while the trigger is armed | `Waiting` — armed and nothing latched yet |
| the axis has a margin at the right | a capture is filling: the post side arrives as it is acquired |
| a math channel equals its input | its output node is the source — see §9 |
| a very wide window shows a low rate | the ingest's anti-alias filter, not a fault |
| a level reads `--` | the measurement is invalid in this window (e.g. a period with one edge) |
Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

+64 -18
View File
@@ -1,26 +1,72 @@
<b>What udpscope is</b> <b>What udpscope is</b>
udpscope plots a live UDPStreamer source. One window holds up to 8 rows of plots, each udpscope plots a live UDPStreamer source. One window holds up to 8 rows of plots, each with up
with up to 4 traces; every row shares one time axis, so the whole figure scrolls together. 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 --buffer-mb; the Acquisition runs on its own thread and writes a ring buffer sized from <tt>--buffer-mb</tt>;
drawing thread reads whatever that ring holds when a frame starts. 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> <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 Start the app with a source (<tt>udpscope --server 127.0.0.1 --port 8000</tt>), or start it with
with no <tt>--server</tt> and pick one from the chooser. The signals arrive disabled; turn no <tt>--server</tt> and pick one from the chooser, which lists the sessions you have opened
one on in the side panel (<tt>s</tt>) and drag it onto a row. 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> <b>Worth knowing</b>
• Nothing is acquired until a signal is enabled. Enabling is what starts acquisition, so a • A signal nobody enables costs nothing -- no buffer, no socket work, no measurements.
freshly opened scope with an idle side panel is working correctly. • The window remembers itself. Rows, traces, the time window, calibration, which signals were
• The stream carries its own clock. Every time on screen is a stream timestamp, never the enabled, the math channels with their graphs, and the measurements are written per host and
local wall clock, which is why a stalled source freezes the view instead of scrolling it. port, and reloaded the next time you open that source.
• The window remembers itself. Rows, traces, the time window, calibration, math channels • A very wide window on a fast stream is drawn from averaged groups of samples, so the rate in
and measurements are written to a session file per host and port, and reloaded next time. 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.
<b>Example</b> • 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.
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>.
+16
View File
@@ -33,3 +33,19 @@ orange dot marks the output node.
same source passes 50 Hz at 0.97 and 2 kHz at 0.002; a 1 kHz band pass with Q=2 passes 200 Hz at 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 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. the shared clock has time slots.
<b>Why an operation sometimes does nothing</b>. A graph's <i>output</i> is the node whose value
becomes the channel, and it is drawn as the orange dot. A new graph is built source, then operation,
and the last node added becomes the output -- but a graph whose output is left on the source is
valid and simply passes its input through, which is the usual reason a gain or a filter appears to
have no effect. <i>Set as output</i> moves the dot.
<b>Save refuses an incomplete graph</b>, and says why: an input that is not connected, no output
chosen, or a <i>channel</i> node naming a signal the scope is not acquiring. Only the channels being
acquired are offered, because a source that is not acquired reads as no data for ever -- and a graph
that reads NaN is harder to diagnose than one that would not save.
<b>The filters are real filters.</b> A math channel's samples are produced as the stream arrives,
once per stored slot, so a low pass carries the history a filter needs. A cutoff typed in hertz is
against the <i>stored</i> rate, which is the wire rate until a wide window makes the buffer decimate
-- the status line's rate is that number, and the filters are rebuilt whenever it changes.
+7
View File
@@ -23,5 +23,12 @@
<file>12-export.txt</file> <file>12-export.txt</file>
<file>13-sessions.txt</file> <file>13-sessions.txt</file>
<file>14-status.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> </gresource>
</gresources> </gresources>
+52 -4
View File
@@ -40,22 +40,38 @@ struct HelpPage {
const char* section; ///< the group heading in the sidebar const char* section; ///< the group heading in the sidebar
const char* title; ///< the row's label and the page's heading const char* title; ///< the row's label and the page's heading
const char* resource; ///< a path under /org/udpscope/help 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. /// 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. /// 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[] = { 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", "Choosing a source", "/org/udpscope/help/02-source.txt"},
{"Getting started", "Signals and calibration", "/org/udpscope/help/03-signals.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"}, {"Getting started", "Rows, layout and drag & drop", "/org/udpscope/help/04-layout.txt"},
{"Viewing", "Time window", "/org/udpscope/help/05-window.txt"}, {"Viewing", "Time window", "/org/udpscope/help/05-window.txt"},
{"Viewing", "Navigating the plot", "/org/udpscope/help/06-navigate.txt"}, {"Viewing", "Navigating the plot", "/org/udpscope/help/06-navigate.txt"},
{"Viewing", "Pause and play", "/org/udpscope/help/07-pause.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", "Cursors", "/org/udpscope/help/09-cursors.txt"},
{"Analysis", "Measurements", "/org/udpscope/help/10-measurements.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", "Saving and export", "/org/udpscope/help/12-export.txt"},
{"Output and state", "Sessions and settings", "/org/udpscope/help/13-sessions.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"}, {"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; bool ok = false;
const std::string body = load_page(kPages[idx], &ok); const std::string body = load_page(kPages[idx], &ok);
gtk_label_set_markup(GTK_LABEL(body_label), body.c_str()); 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 } // namespace
@@ -180,7 +218,17 @@ void show_help(GtkWindow* parent) {
GtkWidget* body_scroll = gtk_scrolled_window_new(); GtkWidget* body_scroll = gtk_scrolled_window_new();
gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(body_scroll), GTK_POLICY_NEVER, gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(body_scroll), GTK_POLICY_NEVER,
GTK_POLICY_AUTOMATIC); 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); gtk_widget_set_hexpand(body_scroll, TRUE);
GtkWidget* list = gtk_list_box_new(); GtkWidget* list = gtk_list_box_new();
+299 -50
View File
@@ -334,8 +334,9 @@ struct App {
/// second ago. A source that serves one client at a time shows up as a reconnect plus /// 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`). /// no frames for the second between two samples (see `note_starvation`).
std::uint64_t starve_frames = 0; std::uint64_t starve_frames = 0;
std::uint64_t starve_reconnects = 0;
bool starve_have_prev = false; 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. /// Set while the starvation notice is up, so the stderr line prints on the edges.
bool starve_reported = false; bool starve_reported = false;
/// Frames drawn with every bound trace slot empty although its channel has /// 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 /// 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. /// window is destroyed, so it cannot ask the label.
std::string dbg_glance_last; 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_info_button = nullptr; ///< the ⓘ
GtkWidget* status_popover = nullptr; GtkWidget* status_popover = nullptr;
@@ -518,6 +521,15 @@ struct App {
void install_css() { void install_css() {
static const char kCss[] = static const char kCss[] =
"row.udpscope-compact { min-height: 0; padding: 1px 0; }\n" "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 button { min-height: 20px; min-width: 20px; padding: 0 3px; }\n"
"row.udpscope-compact checkbutton { min-height: 20px; }\n"; "row.udpscope-compact checkbutton { min-height: 20px; }\n";
GtkCssProvider* p = gtk_css_provider_new(); GtkCssProvider* p = gtk_css_provider_new();
@@ -528,6 +540,78 @@ void install_css() {
g_object_unref(p); 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* labeled_box(const char* label, GtkWidget* child) {
GtkWidget* box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6); GtkWidget* box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
GtkWidget* l = gtk_label_new(label); 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 // The overflow: information and calibration are set once per session, so they
// cost a click here instead of a button on every row forever. // 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_top(menu_box, 4);
gtk_widget_set_margin_bottom(menu_box, 4); gtk_widget_set_margin_bottom(menu_box, 4);
gtk_widget_set_margin_start(menu_box, 4); gtk_widget_set_margin_start(menu_box, 4);
gtk_widget_set_margin_end(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_object_set_data(G_OBJECT(info), "udpscope-signal", GSIZE_TO_POINTER(i));
g_signal_connect(info, "clicked", G_CALLBACK(on_info_clicked), &app); g_signal_connect(info, "clicked", G_CALLBACK(on_info_clicked), &app);
gtk_box_append(GTK_BOX(menu_box), info); 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"); 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_object_set_data(G_OBJECT(cal), "udpscope-signal", GSIZE_TO_POINTER(i));
g_signal_connect(cal, "clicked", 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); gtk_box_append(GTK_BOX(menu_box), cal);
GtkWidget* popover = gtk_popover_new(); GtkWidget* popover = gtk_popover_new();
gtk_widget_add_css_class(popover, "udpscope-menu");
gtk_popover_set_child(GTK_POPOVER(popover), menu_box); gtk_popover_set_child(GTK_POPOVER(popover), menu_box);
GtkWidget* more = gtk_menu_button_new(); GtkWidget* more = gtk_menu_button_new();
gtk_menu_button_set_icon_name(GTK_MENU_BUTTON(more), "view-more-symbolic"); 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 // 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 // CONFIG at all (the server answered the first one), and "connected, kicked, nothing
// arriving" is the state worth naming either way. // arriving" is the state worth naming either way.
bool starved = false; // "Connected, but nothing is arriving" is a *rate* fact, not a reconnect one: a source that
if (app.starve_have_prev && connected && !app.acq->paused()) { // answers CONNECT and then goes quiet -- the unicast case where another client took the session --
starved = reconnects > app.starve_reconnects && frames == app.starve_frames; // 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_frames = frames;
app.starve_reconnects = reconnects;
app.starve_have_prev = true; app.starve_have_prev = true;
if (!starved) { if (app.starve_still < 3) {
app.starve_reported = false; 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 = app.notice =
"connected, but no data is arriving: another reader may hold the source (a unicast " "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 ---------------------------------------------------- // --- the overflow menu ----------------------------------------------------
// Plain buttons in a popover rather than a GMenu: these act on the `RowSet` // Rows rather than plain buttons: the label at the left, the shortcut at the right and a
// directly and two of them are toggles whose state the rest of the UI reads, so // check on the rows that toggle something, which is the shape every GNOME menu has and the
// a GAction model would only add a second copy of that state. // reason this popover no longer reads as a stack of dialogs (see menu_row(), and the
GtkWidget* menu_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 2); // `.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_top(menu_box, 6);
gtk_widget_set_margin_bottom(menu_box, 6); gtk_widget_set_margin_bottom(menu_box, 6);
gtk_widget_set_margin_start(menu_box, 6); gtk_widget_set_margin_start(menu_box, 6);
gtk_widget_set_margin_end(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"); 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); g_signal_connect(fit, "clicked", G_CALLBACK(on_fit_clicked), &app);
gtk_box_append(GTK_BOX(menu_box), fit); 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, gtk_widget_set_tooltip_text(app.layout_toggle,
"add and remove plot rows in place: \u00d7 on a row removes " "add and remove plot rows in place: \u00d7 on a row removes "
"it, + between rows inserts one there"); "it, + between rows inserts one there");
g_signal_connect(app.layout_toggle, "toggled", G_CALLBACK(on_layout_editor_toggled), &app); g_signal_connect(app.layout_toggle, "toggled", G_CALLBACK(on_layout_editor_toggled), &app);
gtk_box_append(GTK_BOX(menu_box), app.layout_toggle); 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(); app.measures_toggle = gtk_toggle_button_new();
gtk_button_set_icon_name(GTK_BUTTON(app.measures_toggle), "udpscope-measure-symbolic"); gtk_button_set_icon_name(GTK_BUTTON(app.measures_toggle), "udpscope-measure-symbolic");
gtk_widget_set_tooltip_text(app.measures_toggle, gtk_widget_set_tooltip_text(app.measures_toggle,
@@ -5698,7 +5830,6 @@ GtkWidget* build_toolbar(App& app) {
} }
}), }),
&app); &app);
// (packed after math and before cursors below: see the order note at the menu)
app.math_toggle = gtk_toggle_button_new(); app.math_toggle = gtk_toggle_button_new();
gtk_button_set_icon_name(GTK_BUTTON(app.math_toggle), "udpscope-math-symbolic"); 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); 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); 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(); GtkWidget* popover = gtk_popover_new();
gtk_widget_add_css_class(popover, "udpscope-menu");
gtk_popover_set_child(GTK_POPOVER(popover), menu_box); gtk_popover_set_child(GTK_POPOVER(popover), menu_box);
app.menu_button = gtk_menu_button_new(); app.menu_button = gtk_menu_button_new();
gtk_menu_button_set_icon_name(GTK_MENU_BUTTON(app.menu_button), "open-menu-symbolic"); 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(); app->status_info_button = gtk_menu_button_new();
gtk_menu_button_set_icon_name(GTK_MENU_BUTTON(app->status_info_button), gtk_menu_button_set_icon_name(GTK_MENU_BUTTON(app->status_info_button),
"help-about-symbolic"); "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), gtk_menu_button_set_popover(GTK_MENU_BUTTON(app->status_info_button),
app->status_popover); 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_tooltip_text(app->status_info_button, "Stream and buffer detail");
gtk_widget_set_has_tooltip(app->status_info_button, TRUE); gtk_widget_set_has_tooltip(app->status_info_button, TRUE);
gtk_box_append(GTK_BOX(status_row), app->status_info_button); gtk_box_append(GTK_BOX(status_row), app->status_info_button);
@@ -7487,6 +7596,143 @@ gboolean zoomkeys_probe(gpointer user) {
return G_SOURCE_REMOVE; 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 /// 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 /// 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. /// 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")) { if (const char* env = g_getenv("UDPSCOPE_PROBE_HELP_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), help_probe, &app); 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")) { if (const char* env = g_getenv("UDPSCOPE_PROBE_KEYS_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), keys_probe, &app); g_timeout_add(static_cast<guint>(std::atoi(env)), keys_probe, &app);
} }