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