From 0eceaf537b2c9509f4bbc44b0ab411b784e43167 Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Sat, 3 Oct 2026 10:34:15 +0200 Subject: [PATCH] added auto measuraments --- CLAUDE.md | 103 ++- CMakeLists.txt | 1 + README.md | 164 ++++- deps/plotter | 2 +- src/plot_host.cpp | 251 ++++++- src/plot_host.hpp | 107 ++- src/ui.cpp | 1609 ++++++++++++++++++++++++++++++++++++++++++++- 7 files changed, 2182 insertions(+), 55 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 37746dc..e101ef2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -201,10 +201,94 @@ Two lines of the self-check output are assertions in disguise: 2 kHz at about 6%. **A `y=[-1, 1]` there means the filter is gone** and the 2 kHz sine has aliased to a full-amplitude 125 Hz one, which looks entirely plausible on screen. This is the only check that distinguishes averaging from striding. -- `[layout-probe] rows=N buttons=B rows_y=[..] close=[..] add=[..] traces=[..]` +- `[cursors-probe] mode=.. on=D pane=D t0=.. t1=.. dt=.. T=[..] lines=N [..]` + (stderr, `UDPSCOPE_PROBE_CURSORS_MS`) — walks `vertical` → `horizontal` → `both` → + `mix`, then moves a marker *through the plot* (`Plot::set_cursor_x(..., Free)`, which is + what a drag does) and reports again. **The times must survive every mode change**: they + are the `RowSet`'s one pair, and a mode that parks the markers must not read them back + (a parked cursor reports a real number, so a read-back would adopt it as a gesture — the + bug that made `T0` read 0). **`lines`** must be 0 for `vertical`, one per row for + `horizontal`/`both` (and the time row exists only in the modes that show the vertical + pair — `T=[]` in `horizontal` is the assertion that it does not), and one per bound trace + for `mix`; in `mix` the values must be the + traces' own (`--` when a marker is past the newest sample, `partial` when only one + marker is over samples). After the move, `t0` must have changed and every row's marker + must have followed it (the report's `t0` is the shared value). **`marks`** is the `mix` + mode's crossing annotations: **0 in every other mode, and one per bound trace for each + marker that is over samples in `mix`** (so a move that brings the second marker into the + data doubles it). The `first mark at x=.. y=..` line that follows must agree with the + pane's *first* value for the same trace (the pane's lines are `chip Y1 Y2 dY`, with + the values formatted as measurements -- two decimals, an SI prefix, the channel's unit -- + so the comparison is against the first number): both are read through the same + `Plot::crosshair_value()`, and that line is the check that they stay the same number. + A **ruler** drag is the other direction and the other hazard: the pane's `A`/`B` cells + for a ruler row must change when the ruler moves (`after ruler drag` in the probe walks + the first ruler to 80% of its y range), because nothing shares a ruler and the read-back + in `RowSet::sync_cursors()` is the only thing that can notice. The dump's `cursors in plot + 0:` line is the other half: it reads the markers back out of + the plot (`cursor(id).x/.visible`), which is what the renderer draws from — the pane's + numbers and the plot's state agreeing is the difference between "the app placed them" and + "the app placed them and they are on screen". +- `[measures-probe]` + the dump's `measure N: 'sig' type thr=..% above=D show=D -> ok|invalid value text=[..] + [duty=..] [marks=..]` and `measures pane: n=N revealed=D marks_shown=M pane_rows=R pane_w=W + pane_h=H` + (stderr, `UDPSCOPE_PROBE_MEASURES_MS`) — adds one measurement of every type on the first + plottable signal and lets the tick compute them, so what it prints is what a user reads. + On the test stream's 1 Hz ±1 sine the values must be: average ≈ 0, `top` ≈ 0.64 and + `bottom` ≈ -0.64 (the mean of each half of a sine), max/min ≈ ±1, `pulse duration` ≈ 0.5 s + with `duty` ≈ 0.5, `period` ≈ 1 s and `frequency` ≈ 1 Hz. `marks=` lists each mark with its kind, and the **mix is the point**: + `level:V` alone for average/top/bottom, `level:V,time:T` for min/max (a ruler at the value + *and* a marker at the first sample that reached it — two level marks at both extremes was + the bug), `time:A,time:B` for pulse/period/frequency. `text=` is the pane's own string with + its unit, so `frequency` must read `Hz` (not the signal's unit), `period`/`pulse duration` + seconds, and the rest the channel's unit. `marks_shown` counts two per shown measurement + (a min/max showing both its marks counts two of its own). `measures window:` is the + timeliness assertion: **`drift` must stay at (or near) zero** -- the window the numbers + were computed over against the one on screen -- and every *time* mark must lie inside it + (`marks_in_window=K/K`). A measurement is recomputed when the window moves (2 % of its + span) or the held/live state changes, never on a wall-clock timer: a timer is what let a + marker sit outside the viewport and a dragged one survive a frame. **`marks_selectable` must be 0**: the + measurement marks are created unselectable (`Plot::set_cursor_selectable()`, which the + library gained for this), read back out of the plot here -- re-placing a marker every frame + does **not** stop a drag, because the controller holds it for the gesture's life and the + marker follows the pointer between frames. The library's own controller test + (`an_unselectable_cursor_is_neither_hovered_nor_grabbed`) is the other half. The probe's last + step still drags a mark *programmatically* and requires the next frame to put it back + (`restored=1`), which now checks the placement path rather than the read-only property. The dialog report's `type= thr= + side= show=` is the per-type behaviour: `period` and `frequency` must show **neither** the + split nor the side row (they compute from the mid level's rising crossings, and an option + there is one the user can only get wrong), `average`/`min`/`max` no split either, and only + `pulse duration` a side. A measurement whose signal is not acquired, or + a window with too few crossings, must read `invalid`/`--` rather than a made-up number + (`frequency` on a window holding one edge is the case: `duty` is reported only when a + period exists to divide by). The probe then **walks the dialog's lifecycle**: open the edit + dialog on measurement 0, click its `Save`, open it again, click `Delete` — `measures=` must + be unchanged after the save and one lower after the delete, with the process still alive. + That is the check for the two things a dialog of this shape gets wrong: two buttons owning + one context (a double free aborts on edit/delete, and only those paths have two buttons) and + a `const char*` array built from a `std::vector` as it grew (every pointer + dangling, the first entry the most visibly). The report's `signals=[..]` and `drop_h` are the second and third: + the list must be the **plotted traces** (real names, deduplicated, in signal-list order — + *not* every channel, and never a stream's timestamp signal, which is what an acquired-but- + unplotted list put first and auto-selected), and `drop_h` must be a combo box's height + (tens of pixels — a scrolled-window wrapper asked it to grow to 160). +- `[fit-probe] window=.. x=[..] span=.. y=[..]` (stderr, `UDPSCOPE_PROBE_FIT_MS`) — + the two fits, through the same `RowSet` calls the right-click menu wires. It first puts + the y scale and the window where the data is *not* (a fixed `[-100, 100]`, a 50 ms + window), so the assertions have something to be wrong about: **`fit x` must widen the + window to the data's span and leave `y` exactly as it was**, and **`fit all` must then + move `y` to the data's own range**. A `y` that changed on the `fit x` line is the two + paths aliased. +- `[layout-probe] rows=N buttons=B done=D done_box=[x,y,WxH] rows_y=[..] close=[..] add=[..] traces=[..]` (stderr, `UDPSCOPE_PROBE_LAYOUT_MS`) — **`buttons` must be `2N+1`** while the editor is up (one `×` per row, one `+` per boundary) and **0 on the `editor off` line**: the - buttons exist only while the editor does. **`traces=` is the real assertion**: an insert + buttons exist only while the editor does. `done` follows the same rule for the editor's + own exit button (1 up, 0 off), and its `done_box` on the `placed` line must sit in the + area's top-right corner: `x + W` within a few pixels of the area width and `y` within a + few of 0 (the on-switch line reports a box from before the first frame, so read the + `placed` one). The last probe step leaves the editor by **clicking that button** — the + toolbar toggle is still exercised by the first step — so `buttons=0 done=0` with the + rows and traces untouched is the assertion that the way out works. **`traces=` is the real assertion**: an insert or a remove moves the rows' *contents* with them, so `add above 0` must read `[0:-|1:sine,…]` — row 0's signals now on row 1, not renumbered onto the new empty row — and `remove 0` must pull them back to `0:`. A `traces=` where the signals stayed on row 0 @@ -229,6 +313,14 @@ Two lines of the self-check output are assertions in disguise: first run's values, and the signals it names must come back *acquired* (a `row 0 slot 0 -> channel ... samples=N` line). Delete the file before a run whose layout you did not mean to inherit (`127.0.0.1_9540.toml` and friends accumulate otherwise). +- `[udpscope] starved: connected (config=D), reconnects=N, no new frames in the last + second ...` (stderr, once per second while the state holds, plus a status-line notice) — + "connected but nothing is arriving after a reconnect", which over unicast is the second + instance taking the source's single session. Two ways to see it without a second client: + point the app at a UDP port where nothing answers (the client CONNECTs, times out, + reconnects; `config=0`), and check it does **not** fire against a live source. The status + line carries ` | N reconnects` only when N > 0, which is the same counter without the + prose. - **The export's `time`/`stream_time` columns must be strictly increasing and evenly spaced.** Check the written file, not just the probe: `np.diff(t)` with no `<= 0` and `rows / (t[-1] - t[0])` equal to the stored rate (the `ch` line's figure). Duplicate or @@ -363,6 +455,13 @@ far the largest file. again until the next latch. `PlotArea::create()` is handed `App::placeholder_plot`, never a row: the area keeps a reference to the plot it was created with, and a rebuild destroys every row. +- **The cursors' on/off is one flag on the `RowSet`, never a copy per row.** It is one + piece of state (the toolbar toggle) and the mode is one value; a per-row copy is something + `insert_row()`'s `clear_row()`, or any rebuild, can clear behind the user's back, which is + how a row added while the cursors were already on came up with its instruments parked + while every other row showed them. The same goes for the vertical pair's `x`: it is one + stream time for the figure, so it lives on the `RowSet` too, and `Row` keeps only what is + genuinely per row (the rulers' values, the traces, the colours). - **A row's contents are state of the `RowSet`, not of the figure.** `insert_row()` and `remove_row()` shift that state with `carry_row()` and let `rebuild_all()` re-deal the figure handles, so inserting above row 1 moves what row 1 plotted down to row 2 rather diff --git a/CMakeLists.txt b/CMakeLists.txt index 9a01d79..0ade4ba 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -41,6 +41,7 @@ add_executable(udpscope src/plot_host.cpp src/export.cpp src/layout_store.cpp + src/measure.cpp src/ui.cpp ) diff --git a/README.md b/README.md index b77ae2e..65b9ab7 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,68 @@ frame and the GUI thread takes once per frame around binding the traces. Samples cross the boundary through `plot::data::RingBuffer` (single producer, single consumer), never through the lock. +## Measurements + +**Measurements** (overflow menu) reveals a pane with a `[+]` and one row per measurement: +`[badge] [edit]`. `[+]` opens a dialog with the signal, the type, the options +that type actually uses, and "show on plot"; `edit` opens the same dialog on that +measurement, with a `Delete` beside `Save`. + +The signal list is the signals the **plots are showing**, in signal-list order: a +measurement reads the window a plot displays and its marks go on the row that plots the +signal, so the candidates are the traces on screen. That is also what keeps everything else +out of it — a signal nobody acquires, and a stream's own timestamp channel, which is acquired +but is not something to measure. A measurement whose signal is no longer plotted keeps it in +the list while it is being edited, so saving does not silently re-point it at whatever the +dropdown happened to be showing. The badge is the measurement's own colour, and +the marks it draws on the plot use it, so a readout and its ink are recognisably the same +measurement. + +| type | what it is | options | shown on the plot as | +|---|---|---|---| +| `average` | the mean of the visible samples | — | a ruler at the mean | +| `top` / `bottom` | the high / low level: the mean of the samples above / below the split | split % | a ruler at the level | +| `max` / `min` | the extremes | — | a ruler at the value **and** a marker where it happened | +| `pulse duration` | the mean width of the pulse-side runs | split %, which side is the pulse | markers at the pulse's two edges | +| `period` / `frequency` | the spacing of the threshold crossings / its reciprocal, in Hz | — | markers at two edges of a cycle | + +`period` and `frequency` deliberately take **no options**: the engine counts *rising* +crossings of the *mid* level, one edge per cycle, and a split or a side to choose from could +only make that count wrong — a split near either extreme counts noise as crossings, and +counting both edges halves the period. + +The split is a share of the **window's own** min..max, not volts: a measurement is about this +signal in this window, and a level in volts would have to be typed for every signal. A +crossing has to be *strict* — a sample exactly at the level is on neither side — so a signal +that merely touches the level does not count every sample as an event. + +Each measurement's row carries **two badges**: its own colour (the same one its marks use on +the plot) and the signal's chip, in the palette colour every other view names that signal +with. The dialog's fields and their wording follow the type — the split row is absent for +`average`, `min` and `max`, the side row only for `pulse duration`, and the "show on plot" +line says what would be drawn. + +The values are **window statistics over exactly the visible range, at the stored rate**: the +same cut the export makes, so a measurement describes the data on screen rather than the ring +behind it — and the marks it draws belong to that window, so they stay in the viewport. + +They are recomputed when the **window moves**, not on a timer: a timer decouples the two, and +with a scrolling window the stored marks then describe a range the plot has already left (the +min/max marker ends up outside the viewport, and a dragged marker survives until the next +tick). The trigger is 2 % of the span, so the numbers track the window within a few pixels +while the cost stays bounded by the window's motion rather than by the frame rate; one pass +copies the visible samples of each *channel* once, however many measurements read it. + +**Show on plot** marks the result on the row that plots the signal: a ruler at the level +(`top`/`bottom`), both rulers at the extremes (`min`/`max`), the pulse's or the cycle's two +edge times as vertical markers. The marks are **readouts, not instruments**: a fixed pool of +cursors per row, created **unselectable** (`Plot::set_cursor_selectable()`, added for this), so +the controller's hit test never offers the pointer one — it can neither hover nor drag a +measurement's ruler. They are also re-placed every frame, because a mark is only correct for +the window its measurement was computed over. Re-placing alone was not enough: a drag is held +for the gesture's life, so the marker followed the pointer *between* frames and the readout +looked draggable. + ## The session layout file What you arranged is remembered per data source, in @@ -409,8 +471,12 @@ figure so the legend name is right (there is one widget, so the row a drop lands on is `PlotArea::row_at()`). The status line reports the outcome of a drop (`plotting 'sine' on plot 1`, `plot 1 already holds four signals`), so an action that changes nothing still says why. Each plot's y axis has a right-click menu -with auto scale, a fixed range, and one **Remove '' from plot N** entry per -occupied trace slot (a plot with nothing on it says so instead). +with auto scale, a fixed range, **Fit X axis**, **Fit all**, and one +**Remove '' from plot N** entry per occupied trace slot (a plot with nothing on +it says so instead). The two fits are the same pair the overflow menu and the `f` key +offer, split by what they touch: **Fit X axis** frames the window on the data the ring +holds and leaves every y scale exactly as the user set it, **Fit all** does that and +auto-scales the y axes too. **The plot does not twitch.** Two independent causes were fixed, both of which showed up as the x axis moving a few pixels every frame — including while paused, @@ -518,6 +584,55 @@ switches them on as well, because a reset nobody can see reads as a dead button. The two panels collapse vertically (`SLIDE_DOWN`), so hiding one gives its space back instead of leaving an invisible gap in the column. +With the cursors on, a **readout panel** appears in the side column, and a dropdown +chooses what the instruments are: + +| mode | instruments | the pane reads | +|---|---|---| +| `vertical` | two x markers, one pair for the whole figure | `T0`, `T1`, `dT` | +| `horizontal` | two y rulers on **each** row | `Y1`, `Y2`, `dY` per row | +| `both` | both of the above | the times, then a line per row | +| `mix` | the x pair, plus where each marker **crosses each trace** | the times, then `Y1`, `Y2`, `dY` per plotted signal | + +The vertical pair is one stream time shared by every row, so the markers line up down +the column and dragging one on any row moves them all — the app reads the position back +out of the plot each frame, because a drag writes the *plot* and that is the only place +it is visible. A ruler is per row: each row has its own y axis, so two rows' rulers are +two different values, and the pane names the row. + +`mix` needs no rulers: it reports the value the trace actually has at each marker, read +through the library's `Plot::crosshair_value()` — the two bracketing samples, +interpolated — so it is the answer to "what is this signal at these two times" rather +than "drag a ruler until it crosses the trace". A readout is `--` when the trace has +nothing at that time and `partial` when only one of the two markers is over samples. + +The readouts are a **table**: a header naming the columns, then one row per instrument — +`| who | A | B | delta |`. The first row is the two times and their difference, and the +rows below it are the rulers (one per row, in `horizontal`/`both`) or the traces (in +`mix`). The time row is there only where the time cursors are: in `horizontal` the pair is +parked, and a row of times nothing on the plot can move would be a readout of nothing. Columns size to their widest cell, so a column of numbers is a column; the two +cursors are `A` and `B` and their difference is `delta`, whichever of the three the mode +puts in them. + +Every readout is a **measurement**, not a raw number: at most two decimals, scaled by an +SI prefix so the digits sit in `1..999`, and the unit the stream declared appended — +`10.52 mV`, `-9.8 V`, `dT -398.67 ms`, `1.06 pV`. Two decimals is the resolution the +instruments can claim (a marker lands on a sample, and the sample is an `f32`), and a +trailing zero is dropped, so `1.50 V` reads `1.5 V`. A row's rulers use its signals' unit +when they agree on one and print bare digits when they do not: a ruler is on the row's y +axis, and two units on one axis make "the" unit a guess. A trace's line carries the +signal's **colour chip** instead of its name — the chip identifies it exactly as the signal +panel does — and the values are bare: `● 616.49 mV -24.57 mV -641.06 mV`, because the +pane is a readout beside a plot and `Y1:` labels there are noise. + +`mix` also *draws* the crossings: a mark on each trace where each marker meets it, in the +marker's own colour, placed with the same call the pane reads (so the mark and the number +cannot disagree), and shown only where the trace actually has samples at that time. The +marks are annotations the plot holds from construction and that are moved, never added: +`Plot` appends annotations only, so a host that re-added them would grow the plot every +frame — which is why the library gained `Plot::set_annotation()` for this. Nothing marks +the ruler modes: a ruler *is* the mark. + **The time window is typed, not picked off a ladder.** The header bar's `window` button reads the current span and opens onto `÷10` · a number · a unit (µs, ms, s, min) · `×10`. A ladder of round numbers could only ever offer the spans someone had @@ -573,9 +688,11 @@ logged with both rates and the new anti-alias corner. A window change that leave `time_stride` alone rebuilds nothing. Per-channel `stride` is still always 1 (see the limitations below); only the shared clock is decimated. -**Fit to data** (overflow menu) does what the plot's own `f` key does: auto-scales every row's y -axis and re-frames the x window on the data the ring holds, propagated to every -row. The status line acknowledges it (`fitted to the buffered data (1.25 s)`). +**Fit to data** (overflow menu, the `f` key, or **Fit all** in a plot's right-click menu) +auto-scales every row's y axis and re-frames the x window on the data the ring holds, +propagated to every row. **Fit X axis** is the same re-framing without the y scaling, for +the common case where the window should be the data's but the levels were placed by hand. +The status line acknowledges either (`x fitted to the buffered data (1.25 s)`). **Shared x axis.** The rows are the rows of **one `plot::Figure`**, drawn by one `plot::gtk4::PlotArea` in figure mode, and they all join a single x link group. @@ -607,6 +724,11 @@ removes it, a `+` on each boundary inserts one there — so the row an action na row you are pointing at. `--plots` is now only the count to start with, and `RowSet::kMaxRows` (8) is a ceiling rather than a menu: past about that a row is chrome and no plot. +The editor also carries its own way out: a **`Done`** button in the plot area's top-right +corner while it is up, because the toggle that opened it lives in the overflow menu — a +long way from the plot being edited. It turns that toggle off, so there is still exactly +one piece of state, and the buttons (its own included) go with it. + An insert or a remove **moves the rows' contents with them**: what row 1 plotted, in the colours it plotted it in and with its cursors where they were, is on row 2 after inserting above row 1. Only that state moves; the figure's `Plot`s are rebuilt and re-dealt, which is @@ -711,6 +833,19 @@ now — they are listed so they are not mistaken for anything else: hundred samples every fifth of a second made every frame span microseconds while the frames were 100 ms apart). +**One source, one client, over unicast.** A UDPStreamer over unicast keeps a single +session: a second `udpscope`'s CONNECT displaces the first, the displaced client sees its +`silence_timeout_s` elapse, reconnects and displaces the other, and the two take turns +being served — which reads as "the second UI gets no data" (and both windows look alike +because they share the saved layout). Nothing on the client side can give both of them the +stream: the wire has one destination. Watching one source from several UIs is what +`--multicast [--interface ]` is for — every instance joins the group and +the kernel hands each of them a copy. When the app sees the fight (a reconnect with no +frames delivered in the same second — the client's own retry counter is also shown in the +status line as `N reconnects` once it is nonzero) it says so in the status line rather than +leaving an empty plot to be puzzled over, and prints a `starved:` line to stderr while the +state holds. + ## Limitations - **Multi-element (vector/matrix) signals are acquired with a zero-order hold.** @@ -727,11 +862,22 @@ now — they are listed so they are not mistaken for anything else: - **Calibration is applied at ingest**, so changing gain or offset rebuilds the buffers (and drops the history). Applying it at read time would need the LOD pyramid to be gain-aware. -- **Cursors are per row** and not synchronised with each other; show/hide parks - them outside the axis range rather than removing them, because `Plot` has no - `remove_cursor`. The trigger marker is the one exception: it has its own cursor - and is independent of the cursor toggle. +- **The horizontal rulers are per row**, since a y value means nothing without the + y axis it is on; the vertical pair is shared, so the same two times are on every + row. Show/hide *parks* every instrument outside the axis range rather than removing + it, because `Plot` has no `remove_cursor` — which is why switching mode re-places + rather than rebuilds, and why the frame tick reads the markers back: a parked + cursor's snapped position is a real number, so nothing may be inferred from it. The + trigger marker is separate: its own cursor, independent of the cursor toggle. - **Verified headless only.** Acquisition, partitioning, the trigger state machine and its integration with the buffers, and rendering under Xvfb (llvmpipe) were exercised; the interactive paths (drag and drop, the dialogs, the right-click menu) were not driven by an automated test. + +**A debug-build assertion in the library's suite.** `deps/plotter`'s debug build prints +`TextBatch::add_cell`'s `glyph_is_cell` assertion once while running its tests — from a +forked child of the text/atlas cases (`a_backend_without_an_atlas_uploads_nothing` is the +one that precedes it), not from a failing test: the suite still reports `355 cases, +0 failed`, and this is pre-existing rather than something the cursors work introduced. The +new ruler test asserts the *strokes* (`figure_rows_draw_their_own_horizontal_rulers`) with +the harness's no-atlas backend, so it neither needs nor trips the text path. diff --git a/deps/plotter b/deps/plotter index e337c6a..211c5b9 160000 --- a/deps/plotter +++ b/deps/plotter @@ -1 +1 @@ -Subproject commit e337c6a60c4ea072558c4056b10337f908ad16b0 +Subproject commit 211c5b97bc8430b58e9afe4b0ee414cce2ccc1b4 diff --git a/src/plot_host.cpp b/src/plot_host.cpp index 425d63c..9968f41 100644 --- a/src/plot_host.cpp +++ b/src/plot_host.cpp @@ -26,6 +26,10 @@ constexpr plot::u32 kUnplottedColor = 0x6E6E6Eu; constexpr plot::u32 kCursorAColor = 0xFFD24Fu; constexpr plot::u32 kCursorBColor = 0x7FE0FFu; constexpr plot::u32 kRulerColor = 0xB0B0B0u; +constexpr plot::u32 kRulerBColor = 0x8FA8C8u; +/// The measurements' own colour, distinct from both cursor rulers so a readout mark cannot be +/// mistaken for something the user placed. +constexpr plot::u32 kMeasureColor = 0x9BE8A0u; constexpr plot::u32 kTriggerColor = 0xFF5B5Bu; constexpr double kParked = -1.0e12; ///< hides an instrument without removing it @@ -59,10 +63,9 @@ void RowSet::carry_row(Row& dst, const Row& src) { dst.name = src.name; dst.channel = src.channel; dst.color_index = src.color_index; - dst.cursors_visible = src.cursors_visible; - dst.cursor_a_x = src.cursor_a_x; - dst.cursor_b_x = src.cursor_b_x; + dst.ruler_y = src.ruler_y; + dst.ruler_b_y = src.ruler_b_y; // The slot's series stays where it is, but what it is bound to belonged to the row // this state came *from*. `bind()` re-resolves every slot next frame; clearing it here // is what stops the frame in between from drawing the moved row's data under the new @@ -75,9 +78,6 @@ void RowSet::clear_row(Row& r) { r.name = {}; r.channel = {-1, -1, -1, -1}; r.color_index = {0, 1, 2, 3}; - r.cursors_visible = false; - r.cursor_a_x = 0.0; - r.cursor_b_x = 0.0; r.ruler_y = 0.0; for (SlotSeries& s : r.slots) s.bind(nullptr); } @@ -115,6 +115,30 @@ void RowSet::install(Row& r, int /*row_index*/, plot::Plot& p) { r.cursor_a = r.plot->add_cursor(0.0, plot::Color::hex(kCursorAColor)); r.cursor_b = r.plot->add_cursor(0.0, plot::Color::hex(kCursorBColor)); r.ruler = r.plot->add_h_ruler(0.0, plot::Color::hex(kRulerColor)); + r.ruler_b = r.plot->add_h_ruler(0.0, plot::Color::hex(kRulerBColor)); + // The `mix` crossings, one pair per trace slot, hidden until that mode is on. Created + // here because a `Plot` fixes its annotation set the same way it fixes its traces -- + // `add_annotation()` only appends -- so the markers are made up front and moved. + for (int s = 0; s < kSlotsPerRow; ++s) { + plot::Annotation a{0.0, 0.0, "", plot::Color::hex(kCursorAColor), r.axis_x, r.axis_y, false}; + plot::Annotation b{0.0, 0.0, "", plot::Color::hex(kCursorBColor), r.axis_x, r.axis_y, false}; + r.crossing_a[static_cast(s)] = r.plot->add_annotation(a); + r.crossing_b[static_cast(s)] = r.plot->add_annotation(b); + } + // The measurements' readout marks: a fixed pool, parked. Levels are rulers (they read a y) + // and times are markers (they read an x), so the pool has one of each per slot. + for (int m = 0; m < kMeasureMarks; ++m) { + const std::size_t mi = static_cast(m); + r.measure_h[mi] = r.plot->add_h_ruler(kParked, plot::Color::hex(kMeasureColor)); + r.measure_v[mi] = r.plot->add_cursor(kParked, plot::Color::hex(kMeasureColor)); + // Readouts, not instruments: the pointer must not be able to hover or grab one. Parking + // them outside the axis and re-placing every frame does not stop a *drag* -- the + // controller holds the marker for the gesture's life, so it follows the pointer between + // frames and the readout looks draggable. Excluding it from the hit test is the only + // place that can say no. + r.plot->set_cursor_selectable(r.measure_h[mi], false); + r.plot->set_cursor_selectable(r.measure_v[mi], false); + } r.trigger_marker = r.plot->add_cursor(kParked, plot::Color::hex(kTriggerColor)); // A marker, not an instrument: it must sit exactly on the edge's time, so its // snap is Free and the controller never rounds it onto a sample. @@ -303,13 +327,190 @@ void RowSet::set_y_range(int row_index, double lo, double hi) { void RowSet::place_cursors(Row& r) { if (!r.plot) return; - const bool show = r.cursors_visible; - const double a = show ? r.cursor_a_x : kParked; - const double b = show ? r.cursor_b_x : kParked; - const double h = show ? r.ruler_y : kParked; - r.plot->set_cursor_x(r.cursor_a, a); - r.plot->set_cursor_x(r.cursor_b, b); - r.plot->set_cursor_y(r.ruler, h); + const bool on = cursors_on_; + // Which instruments the mode uses; the rest are parked (a `Plot` cannot drop a + // cursor, so hiding is the only way to take one off screen). + const bool vertical = on && cursor_mode_ != CursorMode::Horizontal; + const bool horizontal = on && (cursor_mode_ == CursorMode::Horizontal || + cursor_mode_ == CursorMode::Both); + r.plot->set_cursor_x(r.cursor_a, vertical ? cursor_a_x_ : kParked); + r.plot->set_cursor_x(r.cursor_b, vertical ? cursor_b_x_ : kParked); + r.plot->set_cursor_y(r.ruler, horizontal ? r.ruler_y : kParked); + r.plot->set_cursor_y(r.ruler_b, horizontal ? r.ruler_b_y : kParked); +} + +void RowSet::set_cursor_mode(CursorMode mode) { + if (mode == cursor_mode_) return; + cursor_mode_ = mode; + for (int i = 0; i < kMaxRows; ++i) place_cursors(row(i)); + place_crossings(); +} + +void RowSet::place_crossings() { + const bool mix = cursor_mode_ == CursorMode::Mix && cursors_visible(); + for (int i = 0; i < kMaxRows; ++i) { + Row& r = row(i); + if (!r.plot) continue; + for (int s = 0; s < kSlotsPerRow; ++s) { + const std::size_t si = static_cast(s); + const plot::u32 ida = r.crossing_a[si]; + const plot::u32 idb = r.crossing_b[si]; + if (ida >= r.plot->annotation_count() || idb >= r.plot->annotation_count()) continue; + plot::Annotation a = r.plot->annotation(ida); + plot::Annotation b = r.plot->annotation(idb); + double ya = 0.0; + double yb = 0.0; + const bool has_a = mix && r.plot->crosshair_value(static_cast(s), cursor_a_x_, ya); + const bool has_b = mix && r.plot->crosshair_value(static_cast(s), cursor_b_x_, yb); + a.visible = has_a; + b.visible = has_b; + if (has_a) { + a.x = cursor_a_x_; + a.y = ya; + } + if (has_b) { + b.x = cursor_b_x_; + b.y = yb; + } + r.plot->set_annotation(ida, a); + r.plot->set_annotation(idb, b); + } + } +} + +double RowSet::ruler_y(int r) const { + return r >= 0 && r < rows_ ? row(r).ruler_y : 0.0; +} + +double RowSet::ruler_b_y(int r) const { + return r >= 0 && r < rows_ ? row(r).ruler_b_y : 0.0; +} + +int RowSet::crossings_visible() const { + int n = 0; + for (int i = 0; i < rows_; ++i) { + const Row& r = row(i); + if (!r.plot) continue; + for (int s = 0; s < kSlotsPerRow; ++s) { + const std::size_t si = static_cast(s); + if (r.crossing_a[si] < r.plot->annotation_count() && + r.plot->annotation(r.crossing_a[si]).visible) { + ++n; + } + if (r.crossing_b[si] < r.plot->annotation_count() && + r.plot->annotation(r.crossing_b[si]).visible) { + ++n; + } + } + } + return n; +} + +bool RowSet::crossing_at(int i, int slot, bool second, double& x, double& y) const { + if (i < 0 || i >= rows_ || slot < 0 || slot >= kSlotsPerRow) return false; + const Row& r = row(i); + if (!r.plot) return false; + const std::size_t si = static_cast(slot); + const plot::u32 id = second ? r.crossing_b[si] : r.crossing_a[si]; + if (id >= r.plot->annotation_count()) return false; + const plot::Annotation& a = r.plot->annotation(id); + if (!a.visible) return false; + x = a.x; + y = a.y; + return true; +} + +void RowSet::set_measure_marks(const std::vector& marks) { + measure_marks_placed_ = static_cast(marks.size()); + for (int i = 0; i < kMaxRows; ++i) { + Row& r = row(i); + if (!r.plot) continue; + int h = 0; + int v = 0; + for (const MeasureMark& m : marks) { + if (m.row != i) continue; + if (m.level) { + if (h >= kMeasureMarks) continue; + const std::size_t mi = static_cast(h++); + r.plot->set_cursor_y(r.measure_h[mi], m.value, plot::CursorPlacement::Free); + r.plot->set_cursor_color(r.measure_h[mi], plot::Color::hex(m.color)); + } else { + if (v >= kMeasureMarks) continue; + const std::size_t mi = static_cast(v++); + r.plot->set_cursor_x(r.measure_v[mi], m.value, plot::CursorPlacement::Free); + r.plot->set_cursor_color(r.measure_v[mi], plot::Color::hex(m.color)); + } + } + // What the measurements left over is parked rather than kept: a stale readout at the old + // window's position is worse than none. + for (; h < kMeasureMarks; ++h) { + r.plot->set_cursor_y(r.measure_h[static_cast(h)], kParked); + } + for (; v < kMeasureMarks; ++v) { + r.plot->set_cursor_x(r.measure_v[static_cast(v)], kParked); + } + } +} + +void RowSet::fit_x() { + Row& first = row(0); + if (!first.plot || !figure_) return; + first.plot->auto_fit_x(); + const plot::Range fitted = first.plot->x_range(first.axis_x); + const double span = fitted.span(); + if (!std::isfinite(span) || !(span > 0.0)) return; + // The same two paths `fit_all()` takes: a held event owns its window and states it + // through the link group, a live view re-arms the follow and frames the newest span. + if (pinned_) { + window_s_ = span; + figure_->set_x_link_range(x_group_, fitted); + } else { + set_time_window(span); + } +} + +void RowSet::sync_cursors() { + if (!cursors_visible()) return; + // Only a mode that *shows* the vertical pair reads them back. With them parked (the + // `horizontal` mode) whatever the plot reports is our own park coming back -- and a + // snapped cursor parked far outside the axis reads back as something finite, which + // would be adopted as a gesture and destroy the shared times. + // Anything within this much of what we last pushed is our own placement coming back + // (the plot stores f64, so an exact compare would be brittle across a rebuild). + const auto moved = [](double a, double b) { + return std::fabs(a - b) > 1.0e-9 * std::max(1.0, std::fabs(b)); + }; + bool push = false; + for (int i = 0; i < rows_; ++i) { + Row& r = row(i); + if (!r.plot) continue; + // A gesture writes the *plot*; this is the only place its result is visible. A + // ruler is its own row's value -- nothing to share, but it still has to be read + // back or the pane would keep showing wherever the app last put it. + const bool rulers = cursor_mode_ == CursorMode::Horizontal || cursor_mode_ == CursorMode::Both; + if (rulers) { + const double y = r.plot->cursor(r.ruler).y; + const double yb = r.plot->cursor(r.ruler_b).y; + if (y > kParked * 0.5) r.ruler_y = y; + if (yb > kParked * 0.5) r.ruler_b_y = yb; + } + if (cursor_mode_ == CursorMode::Horizontal) continue; // the pair is parked: not a value + const double a = r.plot->cursor(r.cursor_a).x; + const double b = r.plot->cursor(r.cursor_b).x; + if (a < kParked * 0.5 || b < kParked * 0.5) continue; // parked: not a value + if (moved(a, cursor_a_x_)) { + cursor_a_x_ = a; + push = true; + } + if (moved(b, cursor_b_x_)) { + cursor_b_x_ = b; + push = true; + } + } + if (push) { + for (int i = 0; i < rows_; ++i) place_cursors(row(i)); + } + place_crossings(); } void RowSet::clamp_to_data() { @@ -442,11 +643,8 @@ void RowSet::set_trigger_marker(double x, bool visible) { } void RowSet::set_cursors_visible(bool on) { - for (int i = 0; i < kMaxRows; ++i) { - Row& r = row(i); - r.cursors_visible = on; - place_cursors(r); - } + cursors_on_ = on; + for (int i = 0; i < kMaxRows; ++i) place_cursors(row(i)); } void RowSet::reset_cursors() { @@ -455,13 +653,18 @@ void RowSet::reset_cursors() { if (!r.plot) continue; const plot::Range xr = r.plot->x_range(r.axis_x); const double span = xr.span() > 0.0 ? xr.span() : window_s_; - r.cursor_a_x = xr.min + 0.25 * span; - r.cursor_b_x = xr.min + 0.75 * span; - r.ruler_y = r.plot->y_range(r.axis_y).min + - 0.5 * (r.plot->y_range(r.axis_y).span() > 0.0 ? r.plot->y_range(r.axis_y).span() - : 1.0); - place_cursors(r); + cursor_a_x_ = xr.min + 0.25 * span; + cursor_b_x_ = xr.min + 0.75 * span; + const double y_lo = r.plot->y_range(r.axis_y).min; + const double y_span = r.plot->y_range(r.axis_y).span(); + r.ruler_y = y_lo + 0.35 * (y_span > 0.0 ? y_span : 1.0); + r.ruler_b_y = y_lo + 0.65 * (y_span > 0.0 ? y_span : 1.0); } + // Every row's instruments are written from what was just set. Without this the plots + // keep their build-time cursors (x = 0) and `sync_cursors()` -- which reads the plots + // because a *gesture* writes them -- adopts that zero as a moved cursor. + for (int i = 0; i < kMaxRows; ++i) place_cursors(row(i)); + place_crossings(); } } // namespace udpscope diff --git a/src/plot_host.hpp b/src/plot_host.hpp index d62247f..1dbf45f 100644 --- a/src/plot_host.hpp +++ b/src/plot_host.hpp @@ -48,9 +48,27 @@ class RowSet { /// about this many a row is chrome and no plot. static constexpr int kMaxRows = 8; static constexpr int kSlotsPerRow = 4; + /// Readout marks per row per orientation: enough for a handful of level and time + /// measurements to be shown at once, and a fixed number because a `Plot` fixes its cursor + /// set at construction. + static constexpr int kMeasureMarks = 6; RowSet(); + /// Which cursor instruments are up, from the toolbar's toggle. The two vertical + /// markers are shared by every row (one stream time each, so they line up down the + /// column); a horizontal ruler is per row, because each row has its own y axis. + /// + /// `Mix` is the vertical pair with the *per-trace* readout instead of rulers: the + /// pane then reports where each cursor crosses each trace, which is the value a + /// ruler would have to be dragged to express. + enum class CursorMode { + Vertical, ///< two x markers, shared by all rows + Horizontal, ///< two y rulers per row + Both, ///< both instruments + Mix, ///< the x pair, read off each trace + }; + struct Row { /// The figure's row, borrowed. Null for a row beyond `row_count()`, which is how /// every loop here and in the UI stays inside the rows that exist. Invalidated by @@ -82,16 +100,28 @@ class RowSet { plot::u32 cursor_a = 0; plot::u32 cursor_b = 0; plot::u32 ruler = 0; + plot::u32 ruler_b = 0; /// The vertical line marking the trigger instant, drawn whenever an event is /// on screen (and parked outside the axis otherwise). Not the user's cursor: /// it keeps its position whatever the cursor toggle says. plot::u32 trigger_marker = 0; - /// Off by default: the cursors are switched on from the toolbar, and a rebuild - /// (any drop, any row-count change) must not switch them back off. - bool cursors_visible = false; - double cursor_a_x = 0.0; - double cursor_b_x = 0.0; + + /// The measurements' readout marks: a pool created with the plot (a `Plot` cannot add or + /// drop a cursor after the fact) and parked unless a measurement is using the slot. + std::array measure_h{}; + std::array measure_v{}; + + /// One marker per trace per cursor: where each vertical cursor crosses that trace's + /// series, drawn only in `mix` mode. Two per slot because the pair crosses a trace + /// at two places, and an annotation cannot be shared between them. + std::array crossing_a{}; + std::array crossing_b{}; + + /// The two rulers' values. The vertical pair is *not* here: one stream time is + /// shared by every row, so it lives on the `RowSet` where it cannot drift between + /// rows. double ruler_y = 0.0; + double ruler_b_y = 0.0; }; int row_count() const noexcept { return rows_; } @@ -152,6 +182,67 @@ class RowSet { void set_y_range(int row, double lo, double hi); void set_cursors_visible(bool on); + /// What the cursors are (see `CursorMode`). Switching parks the instruments the mode + /// does not use rather than removing them: a `Plot` cannot drop a cursor. + void set_cursor_mode(CursorMode mode); + /// Places (or hides) the `mix` mode's crossing markers: for every plotted trace, the + /// point where each cursor meets it. Called from `sync_cursors()` and when the mode + /// changes, so the markers are never a frame behind the markers that cast them. + void place_crossings(); + CursorMode cursor_mode() const noexcept { return cursor_mode_; } + + /// The two vertical cursors' stream times; shared by every row. + double cursor_a_x() const noexcept { return cursor_a_x_; } + double cursor_b_x() const noexcept { return cursor_b_x_; } + /// One row's ruler values, or 0 for a row that does not exist. + double ruler_y(int row) const; + double ruler_b_y(int row) const; + /// How many `mix` crossing markers are on screen, and the first one's position: the + /// probe's window onto "the marks follow the cursors". + int crossings_visible() const; + bool crossing_at(int row, int slot, bool second, double& x, double& y) const; + + /// Whether the cursors are on. One flag for the whole set, not a copy per row: it is one + /// piece of state (the toolbar's toggle), and a per-row copy is something a rebuild, an + /// insert or a carried row can clear behind the user's back -- which is how a row could + /// end up with its instruments parked while every other row showed them. + bool cursors_visible() const noexcept { return cursors_on_; } + + /// One measurement's readout mark on a row: a *level* (drawn as a horizontal ruler) or a + /// *time* (a vertical marker). Which one a measurement needs is the measurement's own + /// business; the row is the one that plots its signal. + struct MeasureMark { + int row = 0; + bool level = false; + double value = 0.0; + plot::u32 color = 0; + }; + + /// Places the measurements' marks and parks every pool slot they do not use. The marks are + /// created unselectable (`Plot::set_cursor_selectable()`), so the pointer can neither hover + /// nor drag them; this runs every frame because a mark is only correct for the window its + /// measurement was computed over. + void set_measure_marks(const std::vector& marks); + /// How many marks the last `set_measure_marks()` placed: the probe's window onto "the + /// measurement is drawn", as opposed to merely computed. + int measure_marks_placed() const noexcept { return measure_marks_placed_; } + + /// Frames the x window on the data row 0 holds, leaving every y axis alone. The x + /// counterpart of `fit_all()`, for the one thing the y axes cannot say: how wide the + /// window should be. + void fit_x(); + + /// Adopts the instruments a gesture moved. The vertical pair is one time for the whole + /// figure, so a drag on any row's marker moves every row's; a ruler belongs to its own + /// row and is only read back. Either way the plot is where a drag writes, so this is the + /// one place the app can see it -- and the pane reads what is stored here, which is why a + /// ruler that was not read back showed a stale value while its marker on the plot moved. + /// Called once a frame from the tick. + void sync_cursors(); + + /// Puts both vertical cursors at a quarter and three quarters of the x window, and + /// the two rulers at a third and two thirds of each row's y range. Also switches the + /// cursors on, so the button cannot look dead. void reset_cursors(); /// Places the trigger marker at stream time `x` (seconds since the epoch), or @@ -237,6 +328,12 @@ class RowSet { double y_gutter_px_ = 0.0; bool y_gutter_reserved_ = false; std::array(kMaxRows)> storage_{}; + CursorMode cursor_mode_ = CursorMode::Vertical; + bool cursors_on_ = false; + int measure_marks_placed_ = 0; + /// The one stream time each vertical cursor is at, whatever row's copy was last moved. + double cursor_a_x_ = 0.0; + double cursor_b_x_ = 0.0; std::array(kMaxRows)> row_dirty_{}; }; diff --git a/src/ui.cpp b/src/ui.cpp index 5fdc37a..0e94803 100644 --- a/src/ui.cpp +++ b/src/ui.cpp @@ -18,6 +18,7 @@ #include "config.hpp" #include "export.hpp" #include "layout_store.hpp" +#include "measure.hpp" #include "model.hpp" #include "plot_host.hpp" @@ -64,6 +65,10 @@ struct App { /// The editor's buttons, in the order they were added, so they can be taken off the /// overlay again. Rebuilt when the row count changes, repositioned when the rows move. std::vector layout_buttons; + /// The editor's way out: one `Done` button in the plot area's top-right corner while + /// the editor is up. Not part of `layout_buttons`, which is one button per row and per + /// boundary -- the count the layout probe asserts. + GtkWidget* layout_done_button = nullptr; /// The row count and figure geometry the buttons were placed for. Their positions are /// restated only when one of these moves: a `gtk_widget_set_margin_top()` per frame is a /// relayout per frame, and overlay children are measured out of the main child's @@ -73,6 +78,10 @@ struct App { /// a frame apart, because the buttons are placed from the rects the last frame was laid /// out with, so the sequence cannot be one function. int dbg_layout_step = 0; + /// Which step of the cursors probe runs next (`UDPSCOPE_PROBE_CURSORS_MS`). + int dbg_cursors_step = 0; + /// Which step of the fit probe runs next (`UDPSCOPE_PROBE_FIT_MS`). + int dbg_fit_step = 0; /// What `PlotArea::create()` is handed. The area is in figure mode from the start and /// never draws this, but it keeps a reference to the plot it was created with (its /// single-plot controller holds one) -- and every plot of the figure is destroyed by a @@ -121,6 +130,65 @@ struct App { GtkWidget* menu_button = nullptr; GtkWidget* cursors_toggle = nullptr; GtkWidget* cursors_reset = nullptr; + /// The cursors pane: revealed with the toggle, and listing what the instruments + /// read -- T0/T1/dT always, per row an Y1/Y2/dY where rulers are up, and in `mix` mode + /// the value each cursor crosses each trace at. + GtkWidget* cursors_revealer = nullptr; + GtkWidget* cursors_mode_drop = nullptr; + GtkWidget* cursors_body = nullptr; + /// One line per row (rulers) and one per bound trace (`mix`), in the order the body + /// was built with. The texts are rewritten each frame; the widgets are rebuilt only + /// when the rows or the mode change. + /// One row of the pane's table: a `who` cell and the three value cells, so the numbers + /// line up as columns the way a table does. + struct CursorLine { + GtkWidget* label = nullptr; ///< the `who` cell (a chip's markup, or text) + GtkWidget* a = nullptr; + GtkWidget* b = nullptr; + GtkWidget* d = nullptr; + int row = -1; + int slot = -1; + }; + std::vector cursors_lines; + /// The measurements the user set up, and what they last computed. The list is the state; + /// the results are recomputed on a throttle (the numbers are window statistics, and the + /// window scrolls every frame -- 60 recomputations a second of every sample is not a + /// price the frame tick should pay). + std::vector measures; + std::vector measure_results; + GtkWidget* measures_revealer = nullptr; + GtkWidget* measures_list = nullptr; + GtkWidget* measures_toggle = nullptr; + /// One value label per measure, in `measures` order; rebuilt with the list. + std::vector measure_labels; + /// The text each label was last set to. The dump runs from the destroy handler, where the + /// labels are gone, so it prints these instead of reading freed widgets. + std::vector measure_text; + /// The window the current results were computed over, and whether a capture was held then: + /// a recomputation is owed when either moves (see `update_measures`). + plot::Range measure_window{}; + bool measure_window_valid = false; + bool measure_was_held = false; + /// The visible samples, per channel, for the *pass*: several measurements on one signal + /// must not copy the ring several times. + std::unordered_map, std::vector>> measure_samples; + /// The marks the last computation produced: re-placed every frame, which is what makes the + /// measurement cursors read-only. + std::vector measure_marks; + /// What the dump prints for the header and the panels. Captured while the window is alive: + /// the dump runs from the destroy handler, where every widget call is a GTK-CRITICAL and + /// reads nothing. + int dbg_win_btn_w = -1; + int dbg_win_label_w = -1; + int dbg_bar_w = -1; + bool measures_revealed = false; + /// The measurement dialog while one is open (null otherwise). A probe needs a handle on it: + /// it is a window of its own, so the main window's widget tree does not reach its buttons. + GtkWidget* measure_dialog = nullptr; + int dbg_measure_step = 0; + int cursors_pane_mode = -1; + int cursors_pane_rows = -1; + unsigned cursors_pane_generation = ~0u; /// The persisted-layout file this data source maps to (--server/--port), a /// debounced save handle, and whether the file has been applied once the @@ -180,6 +248,14 @@ struct App { /// link group's one window, so this is an assertion: it must read 0. int dbg_x_desync = 0; gint64 last_channels_meas = 0; + /// Starvation detector: the delivered-frame count and the client's reconnect count a + /// 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; + /// 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 /// samples: the view was swapped and not yet published. int dbg_blank_frames = 0; @@ -307,6 +383,7 @@ GtkWidget* labeled_box(const char* label, GtkWidget* child) { // --- persisted layout ------------------------------------------------------- void apply_time_window(App& app, double seconds); +void note_starvation(App& app); int signal_color_index(const std::vector& signals, std::size_t signal); void rebuild_signal_panels(App& app); @@ -557,6 +634,57 @@ std::string format_elapsed(double seconds) { return buf; } +/// A measurement: at most two decimals, scaled by an SI prefix so the number reads in +/// `1..999` (0.01052 V is `10.52 mV`), and the unit appended when the stream declares one. +/// +/// Two decimals is the cursors' resolution claim: the markers land on a sample and the +/// sample is an `f32` of a value the CONFIG may scale arbitrarily, so more digits would be +/// noise dressed as precision. Trailing zeros go, so `1.50 V` reads `1.5 V` -- "at most +/// two decimals", not exactly two. +std::string format_measure(double v, const std::string& unit) { + if (!std::isfinite(v)) return "--"; + static const struct { + double scale; + const char* prefix; + } kPrefixes[] = { + {1.0e12, "T"}, {1.0e9, "G"}, {1.0e6, "M"}, {1.0e3, "k"}, + {1.0, ""}, {1.0e-3, "m"}, {1.0e-6, "\u00b5"}, {1.0e-9, "n"}, {1.0e-12, "p"}, + }; + const double mag = std::fabs(v); + const char* prefix = ""; + double scale = 1.0; + // No unit, no prefix: `10.52 m` is a prefix with nothing to prefix, which reads as + // metres. A bare number stays a bare number and simply keeps its decimals. + if (!unit.empty()) { + for (const auto& p : kPrefixes) { + if (mag >= p.scale) { + prefix = p.prefix; + scale = p.scale; + break; + } + } + if (mag > 0.0 && mag < 1.0e-12) { // below the ladder: keep it in plain digits + prefix = ""; + scale = 1.0; + } + } + char buf[64]; + std::snprintf(buf, sizeof(buf), "%.2f", v / scale); + std::string num = buf; + if (num.find('.') != std::string::npos) { // at most two decimals, not exactly two + while (num.size() > 1 && num.back() == '0') num.pop_back(); + if (num.back() == '.') num.pop_back(); + } + if (num == "-0") num = "0"; + std::string out = num; + if (prefix[0] != '\0') out += std::string(" ") + prefix; + if (!unit.empty()) { + if (prefix[0] == '\0') out += " "; + out += unit; + } + return out; +} + /// A sample rate in the unit it reads shortest in. Rates here span six decades (a 1 Hz /// housekeeping signal next to a 1 MS/s acquisition), so a fixed unit makes one of the /// two unreadable. @@ -736,6 +864,7 @@ constexpr int kLayoutButtonPad = 4; constexpr const char* kLayoutKindKey = "udpscope-layout-insert"; void rebuild_layout_editor(App& app); +void on_layout_editor_done(GtkButton*, gpointer); /// Takes the editor's buttons off the overlay. `gtk_overlay_remove_overlay()` drops the /// overlay's reference, which is the only one, so the widgets go with it. @@ -748,8 +877,12 @@ void clear_layout_editor(App& app) { for (GtkWidget* b : app.layout_buttons) { gtk_overlay_remove_overlay(GTK_OVERLAY(app.area_overlay), b); } + if (app.layout_done_button != nullptr) { + gtk_overlay_remove_overlay(GTK_OVERLAY(app.area_overlay), app.layout_done_button); + } } app.layout_buttons.clear(); + app.layout_done_button = nullptr; app.layout_buttons_rows = 0; } @@ -866,9 +999,33 @@ void rebuild_layout_editor(App& app) { gtk_overlay_add_overlay(GTK_OVERLAY(app.area_overlay), b); app.layout_buttons.push_back(b); } + + // The editor's way out, in the area's top-right corner: the toolbar's toggle is in the + // overflow menu, which is a long way from the plot you are editing. Aligned by margins + // like every other overlay child, so it never touches the figure's layout. + GtkWidget* done = gtk_button_new_with_label("Done"); + gtk_widget_set_tooltip_text(done, "leave the layout editor"); + gtk_widget_add_css_class(done, "osd"); + gtk_widget_set_size_request(done, -1, kLayoutButtonPx); + gtk_widget_set_halign(done, GTK_ALIGN_END); + gtk_widget_set_valign(done, GTK_ALIGN_START); + gtk_widget_set_margin_top(done, kLayoutButtonPad); + gtk_widget_set_margin_end(done, kLayoutButtonPad); + g_signal_connect(done, "clicked", G_CALLBACK(on_layout_editor_done), &app); + gtk_overlay_add_overlay(GTK_OVERLAY(app.area_overlay), done); + app.layout_done_button = done; + sync_layout_editor(app); } +/// The `Done` button: the toolbar toggle is the one piece of state that says whether the +/// editor is up, so this turns *that* off and the existing `toggled` handler does the rest +/// (including taking this button down). +void on_layout_editor_done(GtkButton*, gpointer user) { + App* app = static_cast(user); + gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(app->layout_toggle), false); +} + void on_layout_editor_toggled(GtkToggleButton* button, gpointer user) { App* app = static_cast(user); app->layout_editing = gtk_toggle_button_get_active(button); @@ -1373,6 +1530,829 @@ void on_go_live(GtkButton*, gpointer user) { gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(app->trigger_toggle), FALSE); } +// --- measurements ----------------------------------------------------------- + +/// A measurement's own colour, so the pane's badge and the marks it puts on the plot are +/// recognisably the same measurement. Picked from the trace palette's neighbours rather than +/// it: a measurement's mark must not look like one of the signals. +plot::u32 measure_color(std::size_t index) { + static const plot::u32 kPalette[] = {0x9BE8A0u, 0xE8A0C8u, 0xE8D79Bu, 0x9BD7E8u, + 0xC8A0E8u, 0xA0E8D7u, 0xE8B9A0u, 0xA0A8E8u}; + return kPalette[index % (sizeof(kPalette) / sizeof(kPalette[0]))]; +} + +/// The visible samples of one channel as parallel time/value arrays: the same cut the export +/// makes -- the held event's snapshot while one is on screen, else the live ring over the +/// shared window -- at the stored rate, so a measurement describes exactly the data the plot +/// is showing. Call from the GTK thread; the live branch takes the model mutex. +bool visible_samples(App& app, std::size_t channel, std::vector& time, + std::vector& value) { + time.clear(); + value.clear(); + const RowSet::Row& r0 = app.rows->row(0); + if (r0.plot == nullptr) return false; + const plot::Range view = r0.plot->x_range(r0.axis_x); + + if (app.model->display_mode() == DisplayMode::Held) { + std::shared_ptr snap = app.model->event(); + if (snap == nullptr || channel >= snap->values.size()) return false; + const std::vector& st = snap->stamps; + const std::vector& vals = snap->values[channel]; + if (vals.size() != st.size()) return false; + const auto lo = std::lower_bound(st.begin(), st.end(), static_cast(view.min)); + const auto hi = std::upper_bound(st.begin(), st.end(), static_cast(view.max)); + for (auto it = lo; it != hi; ++it) { + time.push_back(static_cast(*it)); + value.push_back(static_cast(vals[static_cast(it - st.begin())])); + } + return !time.empty(); + } + + std::lock_guard lock(app.model->mutex()); + if (channel >= app.model->channel_count()) return false; + BufferStore& store = app.model->live(); + const f64 epoch = app.model->display_epoch(); + u64 first = 0; + u64 last = 0; + app.model->live_window_locked(view.min - epoch, view.max - epoch, first, last); + const u64 n = last - first; + if (n == 0) return false; + std::vector stamps(static_cast(n)); + std::vector vals(static_cast(n)); + store.time().copy_from(first, n, stamps.data()); + store.values(channel).copy_from(first, n, vals.data()); + time.reserve(stamps.size()); + value.reserve(vals.size()); + for (std::size_t i = 0; i < stamps.size(); ++i) { + time.push_back(epoch + static_cast(stamps[i])); + value.push_back(static_cast(vals[i])); + } + return !time.empty(); +} + +/// The channel a measurement's signal resolved to, or -1 while the stream does not carry it. +int measure_channel(App& app, const std::string& signal) { + std::lock_guard lock(app.model->mutex()); + const std::vector& signals = app.model->signals(); + const std::vector& channels = app.model->channels(); + for (std::size_t s = 0; s < signals.size(); ++s) { + if (signals[s].name != signal) continue; + for (std::size_t c = 0; c < channels.size(); ++c) { + if (channels[c].signal == s) return static_cast(c); + } + return -1; // the signal exists but is not acquired + } + return -1; +} + +/// Which row plots a signal (the first slot that does), or 0: a measurement's marks belong on +/// the plot showing the signal, and a signal nothing plots still has to go somewhere. +int measure_row(App& app, const std::string& signal) { + for (int r = 0; r < app.rows->row_count(); ++r) { + const RowSet::Row& row = app.rows->row(r); + for (int s = 0; s < RowSet::kSlotsPerRow; ++s) { + if (row.signal_name[static_cast(s)] == signal) return r; + } + } + return 0; +} + +/// The unit of a signal's channel, for the pane's readout. +std::string measure_unit(App& app, const std::string& signal) { + std::lock_guard lock(app.model->mutex()); + for (const ChannelInfo& c : app.model->channels()) { + if (c.name == signal) return c.unit; + } + return ""; +} + +void rebuild_measures_pane(App& app); +void update_measures(App& app, bool force); +void open_measure_dialog(App& app, int edit_index); + +/// One pane row: the measurement's badge, its type, its value and an `edit` button. The value +/// label is the one widget `update_measures()` rewrites; everything else is rebuilt with the +/// list. +GtkWidget* measures_row(App& app, std::size_t index, const Measure& m) { + GtkWidget* box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6); + GtkWidget* chip = gtk_label_new(""); + gtk_label_set_markup(GTK_LABEL(chip), + chip_markup(plot::Color::hex(measure_color(index))).c_str()); + gtk_widget_set_tooltip_text(chip, "this measurement's colour, and the colour of its marks"); + gtk_box_append(GTK_BOX(box), chip); + // The signal's own chip beside it, in the palette colour every other view names that signal + // with, so the row says *what* is being measured as well as by which measurement. + GtkWidget* sig_chip = gtk_label_new(""); + { + std::lock_guard lock(app.model->mutex()); + const std::vector& signals = app.model->signals(); + int color_index = -1; + for (std::size_t i = 0; i < signals.size(); ++i) { + if (signals[i].name == m.signal) { + color_index = signal_color_index(signals, i); + break; + } + } + gtk_label_set_markup(GTK_LABEL(sig_chip), + chip_markup(color_index >= 0 ? RowSet::palette_color(color_index) + : RowSet::unplotted_color()) + .c_str()); + } + gtk_widget_set_tooltip_text(sig_chip, m.signal.c_str()); + gtk_box_append(GTK_BOX(box), sig_chip); + GtkWidget* kind = gtk_label_new(measure_type_name(m.type)); + gtk_widget_set_halign(kind, GTK_ALIGN_START); + gtk_widget_set_hexpand(kind, true); + gtk_label_set_ellipsize(GTK_LABEL(kind), PANGO_ELLIPSIZE_END); + char tip[160]; + std::snprintf(tip, sizeof(tip), "%s\n%s", m.signal.c_str(), + measure_uses_threshold(m.type) + ? (m.options.above ? "the above-threshold side" : "the below-threshold side") + : ""); + gtk_widget_set_tooltip_text(kind, tip); + gtk_box_append(GTK_BOX(box), kind); + GtkWidget* value = gtk_label_new("--"); + gtk_widget_set_halign(value, GTK_ALIGN_END); + gtk_label_set_selectable(GTK_LABEL(value), true); + gtk_box_append(GTK_BOX(box), value); + GtkWidget* edit = gtk_button_new_with_label("edit"); + gtk_widget_add_css_class(edit, "flat"); + g_object_set_data(G_OBJECT(edit), "udpscope-measure-index", + GINT_TO_POINTER(static_cast(index) + 1)); + g_signal_connect(edit, "clicked", G_CALLBACK(+[](GtkButton* b, gpointer user) { + App* a = static_cast(user); + const int idx = GPOINTER_TO_INT( + g_object_get_data(G_OBJECT(b), "udpscope-measure-index")) - + 1; + open_measure_dialog(*a, idx); + }), + &app); + gtk_box_append(GTK_BOX(box), edit); + app.measure_labels.push_back(value); + return box; +} + +void rebuild_measures_pane(App& app) { + if (app.measures_list == nullptr) return; + GtkWidget* child = gtk_widget_get_first_child(app.measures_list); + while (child != nullptr) { + GtkWidget* next = gtk_widget_get_next_sibling(child); + gtk_box_remove(GTK_BOX(app.measures_list), child); + child = next; + } + app.measure_labels.clear(); + app.measure_text.clear(); + if (app.measures.empty()) { + GtkWidget* empty = gtk_label_new("no measurements -- [+] to add one"); + gtk_widget_add_css_class(empty, "dim-label"); + gtk_widget_set_halign(empty, GTK_ALIGN_START); + gtk_box_append(GTK_BOX(app.measures_list), empty); + } + for (std::size_t i = 0; i < app.measures.size(); ++i) { + gtk_box_append(GTK_BOX(app.measures_list), measures_row(app, i, app.measures[i])); + app.measure_text.emplace_back(); + } + update_measures(app, true); +} + +// --- the cursors pane ------------------------------------------------------- + +/// The cursor modes, in the order the dropdown lists them; the index is `CursorMode`. +constexpr const char* kCursorModeNames[] = {"vertical", "horizontal", "both", "mix"}; + +void rebuild_cursors_pane(App& app); + +void on_cursor_mode_changed(GObject* obj, GParamSpec*, gpointer user) { + App* app = static_cast(user); + if (app->updating_panels) return; + const guint sel = gtk_drop_down_get_selected(GTK_DROP_DOWN(obj)); + if (sel > 3) return; + app->rows->set_cursor_mode(static_cast(sel)); + rebuild_cursors_pane(*app); +} + +/// Appends one row to the pane's table: a `who` cell and the three value cells, in one +/// `GtkGrid` so the four columns size to their widest cell and the numbers read as a table +/// (`| | A | B | delta |`) rather than as a sentence per line. +App::CursorLine cursors_line(App& app, const char* who, plot::Color chip, bool has_chip) { + GtkGrid* grid = GTK_GRID(app.cursors_body); + const int at = static_cast(app.cursors_lines.size()) + 1; // row 0 is the header + App::CursorLine line; + line.label = has_chip ? gtk_label_new("") : gtk_label_new(who); + gtk_widget_set_halign(line.label, GTK_ALIGN_START); + gtk_label_set_ellipsize(GTK_LABEL(line.label), PANGO_ELLIPSIZE_END); + if (has_chip) gtk_label_set_markup(GTK_LABEL(line.label), chip_markup(chip).c_str()); + gtk_grid_attach(grid, line.label, 0, at, 1, 1); + GtkWidget* cells[3] = {nullptr, nullptr, nullptr}; + for (int c = 0; c < 3; ++c) { + GtkWidget* v = gtk_label_new(""); + gtk_widget_set_halign(v, GTK_ALIGN_END); + gtk_label_set_selectable(GTK_LABEL(v), true); + gtk_widget_set_margin_start(v, 10); + gtk_grid_attach(grid, v, c + 1, at, 1, 1); + cells[c] = v; + } + line.a = cells[0]; + line.b = cells[1]; + line.d = cells[2]; + return line; +} + +/// The table's header: the columns have no name but the two cursors and their difference. +GtkWidget* cursors_header(App& app) { + GtkGrid* grid = GTK_GRID(app.cursors_body); + GtkWidget* cells[4] = {gtk_label_new(""), gtk_label_new("A"), gtk_label_new("B"), + gtk_label_new("delta")}; + for (int c = 0; c < 4; ++c) { + gtk_widget_set_halign(cells[c], c == 0 ? GTK_ALIGN_START : GTK_ALIGN_END); + gtk_widget_add_css_class(cells[c], "dim-label"); + if (c > 0) gtk_widget_set_margin_start(cells[c], 10); + gtk_grid_attach(grid, cells[c], c, 0, 1, 1); + } + return cells[1]; +} + +/// The unit a row's y rulers are in: the one its bound channels agree on, or none when they +/// disagree -- a ruler sits on the row's y axis, and two signals of different units on one +/// axis make "the" unit a guess. +/// +/// The channel list is rebuilt by the producer, so it is read under the model mutex, like +/// every other read of it from this thread. +std::string row_unit(App& app, int row) { + std::lock_guard lock(app.model->mutex()); + std::string unit; + bool first = true; + for (int s = 0; s < RowSet::kSlotsPerRow; ++s) { + const std::size_t si = static_cast(s); + const RowSet::Row& r = app.rows->row(row); + if (r.signal_name[si].empty()) continue; + const int ch = r.channel[si]; + if (ch < 0 || static_cast(ch) >= app.model->channels().size()) continue; + const std::string& u = app.model->channels()[static_cast(ch)].unit; + if (first) { + unit = u; + first = false; + } else if (unit != u) { + return ""; + } + } + return unit; +} + +/// The unit of one bound trace, or "" for a slot that plots nothing. +std::string slot_unit(App& app, int row, int slot) { + std::lock_guard lock(app.model->mutex()); + const RowSet::Row& r = app.rows->row(row); + const int ch = r.channel[static_cast(slot)]; + if (ch < 0 || static_cast(ch) >= app.model->channels().size()) return ""; + return app.model->channels()[static_cast(ch)].unit; +} + +/// The pane's body follows what the mode reads and what is plotted: a line per row for +/// the rulers, a line per bound trace for `mix`. Called when the mode changes, when the +/// rows change and when the stream is re-described -- the texts are then rewritten every +/// frame by `update_cursors_pane()`. +void rebuild_cursors_pane(App& app) { + if (app.cursors_body == nullptr) return; + GtkWidget* child = gtk_widget_get_first_child(app.cursors_body); + while (child != nullptr) { + GtkWidget* next = gtk_widget_get_next_sibling(child); + gtk_grid_remove(GTK_GRID(app.cursors_body), child); + child = next; + } + app.cursors_lines.clear(); + cursors_header(app); + app.cursors_pane_mode = static_cast(app.rows->cursor_mode()); + app.cursors_pane_rows = app.rows->row_count(); + app.cursors_pane_generation = app.model->generation(); + + const RowSet::CursorMode mode = app.rows->cursor_mode(); + const bool rulers = mode == RowSet::CursorMode::Horizontal || mode == RowSet::CursorMode::Both; + const bool crossings = mode == RowSet::CursorMode::Mix; + + // The times are a row of the same table: `| t | T0 | T1 | dT |`. `slot = -2` marks it, + // `-1` a ruler row, `>= 0` a trace. Only the modes that *have* the time cursors get it: + // in `horizontal` the pair is parked, and a row of times nothing on the plot can move + // would be a readout of nothing. + if (mode != RowSet::CursorMode::Horizontal) { + app.cursors_lines.push_back(cursors_line(app, "t", plot::Color::hex(0), false)); + app.cursors_lines.back().row = -1; + app.cursors_lines.back().slot = -2; + } + + if (rulers || crossings) { + for (int r = 0; r < app.rows->row_count(); ++r) { + if (!rulers) break; + char who[64]; + std::snprintf(who, sizeof(who), "row %d", r); + app.cursors_lines.push_back(cursors_line(app, who, plot::Color::hex(0), false)); + app.cursors_lines.back().row = r; + app.cursors_lines.back().slot = -1; + } + if (crossings) { + for (int r = 0; r < app.rows->row_count(); ++r) { + const RowSet::Row& row = app.rows->row(r); + for (int s = 0; s < RowSet::kSlotsPerRow; ++s) { + const std::size_t si = static_cast(s); + if (row.signal_name[si].empty()) continue; + app.cursors_lines.push_back( + cursors_line(app, "", RowSet::palette_color(row.color_index[si]), true)); + app.cursors_lines.back().row = r; + app.cursors_lines.back().slot = s; + } + } + } + } +} + +/// `%.6g s` is exact enough for a cursor readout and short enough for the panel. +void update_cursors_pane(App& app) { + if (app.cursors_body == nullptr || !app.rows->cursors_visible()) return; + if (app.cursors_pane_mode != static_cast(app.rows->cursor_mode()) || + app.cursors_pane_rows != app.rows->row_count() || + app.cursors_pane_generation != app.model->generation()) { + rebuild_cursors_pane(app); + } + const double t0 = app.rows->cursor_a_x(); + const double t1 = app.rows->cursor_b_x(); + const auto put = [](GtkWidget* w, const std::string& text) { + if (w != nullptr) gtk_label_set_text(GTK_LABEL(w), text.c_str()); + }; + + const RowSet::CursorMode mode = app.rows->cursor_mode(); + const bool rulers = mode == RowSet::CursorMode::Horizontal || mode == RowSet::CursorMode::Both; + const bool crossings = mode == RowSet::CursorMode::Mix; + for (const App::CursorLine& line : app.cursors_lines) { + if (line.a == nullptr) continue; + // One measurement per cell, each with the unit its own axis declares: the pane is a + // readout beside a plot, where the values are the point. + std::string va; + std::string vb; + std::string vd; + if (line.slot == -2) { + va = format_measure(t0, "s"); + vb = format_measure(t1, "s"); + vd = format_measure(t1 - t0, "s"); + } else if (line.slot == -1 && rulers) { + const std::string unit = row_unit(app, line.row); + const double y1 = app.rows->ruler_y(line.row); + const double y2 = app.rows->ruler_b_y(line.row); + va = format_measure(y1, unit); + vb = format_measure(y2, unit); + vd = format_measure(y2 - y1, unit); + } else if (crossings && line.slot >= 0) { + // Where each cursor crosses this trace: the value the trace has at that time, + // read the way a crosshair reads it. + // The same call the crossing markers use, on the same trace: the pane and the + // marks on the plot cannot disagree about what the value is. + const RowSet::Row& row = app.rows->row(line.row); + double y1 = 0.0; + double y2 = 0.0; + const bool a = row.plot != nullptr && + row.plot->crosshair_value(static_cast(line.slot), t0, y1); + const bool b = row.plot != nullptr && + row.plot->crosshair_value(static_cast(line.slot), t1, y2); + if (a && b) { + const std::string unit = slot_unit(app, line.row, line.slot); + va = format_measure(y1, unit); + vb = format_measure(y2, unit); + vd = format_measure(y2 - y1, unit); + } else { + va = vb = vd = a || b ? "partial" : "--"; + } + } else { + va = vb = vd = "--"; + } + put(line.a, va); + put(line.b, vb); + put(line.d, vd); + } +} + +/// `labeled_box()` with the label handed back, for a row whose text depends on what is being +/// edited (the dialog's option rows). +GtkWidget* labelled_row(const char* label, GtkWidget* child, GtkWidget** label_out) { + GtkWidget* box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6); + GtkWidget* l = gtk_label_new(label); + gtk_widget_set_halign(l, GTK_ALIGN_START); + gtk_box_append(GTK_BOX(box), l); + gtk_box_append(GTK_BOX(box), child); + *label_out = l; + return box; +} + +/// The add/edit dialog. `edit_index < 0` adds; otherwise it edits that measurement in place +/// (and offers Delete, which is the only way one is removed -- there is no undo here, so the +/// destructive button sits with the dialog that shows what it would destroy). +struct MeasureDialog { + App* app = nullptr; + int edit = -1; + GtkWidget* signal_drop = nullptr; + GtkWidget* type_drop = nullptr; + GtkWidget* threshold = nullptr; + GtkWidget* above = nullptr; + GtkWidget* show = nullptr; + GtkWidget* window = nullptr; + /// The option rows' boxes and labels, so the dialog can *retitle* them per type: the same + /// switch means "the pulse is above the level" for a pulse and nothing at all for a period, + /// and the difference has to be in words, not just in visibility. + GtkWidget* threshold_row = nullptr; + GtkWidget* threshold_label = nullptr; + GtkWidget* side_row = nullptr; + GtkWidget* side_label = nullptr; + /// The names the signal dropdown was built from, in order. `apply` resolves an index + /// through *this*, not through the model's signal list: the dropdown lists the plotted + /// traces, so index `i` here is the i-th trace -- indexing the CONFIG with it selected + /// whatever signal happened to sit at that position there (the stream's timestamp, for the + /// first entry, which is what picking the first trace used to store). + std::vector signals; +}; + +/// Shows the fields a type actually uses: the threshold and the side only where the engine +/// reads them, and the plot option only for a type that can be drawn. A field that does +/// nothing is worse than an absent one -- it invites the user to set it. +void measure_dialog_sync(MeasureDialog* d) { + const int sel = gtk_drop_down_get_selected(GTK_DROP_DOWN(d->type_drop)); + const MeasureType type = measure_type_at(sel); + const bool threshold = measure_uses_threshold(type); + const bool side = measure_uses_side(type); + gtk_widget_set_visible(d->threshold_row, threshold); + gtk_widget_set_visible(d->side_row, side); + gtk_widget_set_visible(d->show, measure_can_show_on_plot(type)); + + // The words follow the type: one row, several meanings. + const std::string signal = d->signals.empty() + ? std::string("the signal") + : d->signals[std::min( + gtk_drop_down_get_selected(GTK_DROP_DOWN(d->signal_drop)), + d->signals.size() - 1)]; + switch (type) { + case MeasureType::Average: + gtk_label_set_text(GTK_LABEL(d->threshold_label), ""); + gtk_label_set_text(GTK_LABEL(d->side_label), ""); + gtk_check_button_set_label(GTK_CHECK_BUTTON(d->show), "show on plot (ruler at the mean)"); + break; + case MeasureType::Min: + case MeasureType::Max: + gtk_check_button_set_label(GTK_CHECK_BUTTON(d->show), + "show on plot (ruler at the value, marker where it is)"); + break; + case MeasureType::Top: + case MeasureType::Bottom: + gtk_label_set_text(GTK_LABEL(d->threshold_label), "split % of the range"); + gtk_check_button_set_label(GTK_CHECK_BUTTON(d->show), "show on plot (ruler at the level)"); + break; + case MeasureType::PulseDuration: + gtk_label_set_text(GTK_LABEL(d->threshold_label), "split % of the range"); + gtk_label_set_text(GTK_LABEL(d->side_label), "pulse above the level"); + gtk_check_button_set_label(GTK_CHECK_BUTTON(d->show), + "show on plot (markers at the pulse's edges)"); + break; + case MeasureType::Period: + case MeasureType::Frequency: + // No split to choose: the engine counts rising crossings of the mid level (see + // `compute_measure`), and the row that would offer one is not shown at all. + gtk_label_set_text(GTK_LABEL(d->threshold_label), ""); + gtk_label_set_text(GTK_LABEL(d->side_label), ""); + gtk_check_button_set_label(GTK_CHECK_BUTTON(d->show), + "show on plot (markers at two edges of a cycle)"); + break; + } + gtk_widget_set_tooltip_text( + d->show, + ("draw what the measurement found on the row plotting " + signal + " -- it follows the " + "window and cannot be dragged").c_str()); +} + +void on_measure_type_changed(GObject* obj, GParamSpec*, gpointer user) { + (void)obj; + measure_dialog_sync(static_cast(user)); +} + +void on_measure_dialog_apply(GtkButton*, gpointer user) { + MeasureDialog* d = static_cast(user); + App& app = *d->app; + const guint sig = gtk_drop_down_get_selected(GTK_DROP_DOWN(d->signal_drop)); + if (sig >= d->signals.size()) { + gtk_window_destroy(GTK_WINDOW(d->window)); + return; + } + Measure m; + m.signal = d->signals[sig]; + m.type = measure_type_at(gtk_drop_down_get_selected(GTK_DROP_DOWN(d->type_drop))); + m.options.threshold_pct = gtk_spin_button_get_value(GTK_SPIN_BUTTON(d->threshold)); + m.options.above = gtk_switch_get_active(GTK_SWITCH(d->above)); + m.options.show_on_plot = gtk_check_button_get_active(GTK_CHECK_BUTTON(d->show)); + if (d->edit >= 0 && static_cast(d->edit) < app.measures.size()) { + app.measures[static_cast(d->edit)] = std::move(m); + } else { + app.measures.push_back(std::move(m)); + } + rebuild_measures_pane(app); + gtk_window_destroy(GTK_WINDOW(d->window)); +} + +void on_measure_dialog_delete(GtkButton*, gpointer user) { + MeasureDialog* d = static_cast(user); + App& app = *d->app; + if (d->edit >= 0 && static_cast(d->edit) < app.measures.size()) { + app.measures.erase(app.measures.begin() + d->edit); + rebuild_measures_pane(app); + } + gtk_window_destroy(GTK_WINDOW(d->window)); +} + +/// The dialog. Two dropdowns and the type's own options, laid out like the calibration one: +/// a small window with a `labeled_box` per field and the buttons in the header bar. +void open_measure_dialog(App& app, int edit_index) { + const Measure* editing = + edit_index >= 0 && static_cast(edit_index) < app.measures.size() + ? &app.measures[static_cast(edit_index)] + : nullptr; + + GtkWidget* window = dialog_window(GTK_WIDGET(app.window), + editing != nullptr ? "Edit measurement" : "Add measurement"); + GtkWidget* box = gtk_window_get_child(GTK_WINDOW(window)); + + // The list is the signals the *plots* are showing, in signal-list order: a measurement + // reads the window a plot is displaying, and its marks are drawn on the row that plots the + // signal, so the candidates are the traces on screen. That is also what keeps a channel + // nobody plots out of the list -- including a stream's timestamp signal, which is + // acquired but is not something to measure. The editing measurement's own signal is added + // back when it is not plotted (otherwise Save would silently re-point it at whatever the + // dropdown happened to show). + std::vector sig_names; + int selected_signal = 0; + for (int r = 0; r < app.rows->row_count(); ++r) { + const RowSet::Row& row = app.rows->row(r); + for (int sl = 0; sl < RowSet::kSlotsPerRow; ++sl) { + const std::size_t si = static_cast(sl); + const std::string& n = row.name[si]; + if (n.empty()) continue; + if (std::find(sig_names.begin(), sig_names.end(), n) != sig_names.end()) continue; + sig_names.push_back(n); + } + } + if (editing != nullptr) { + if (std::find(sig_names.begin(), sig_names.end(), editing->signal) == sig_names.end()) { + sig_names.push_back(editing->signal); + } + } + // The `const char*` array is built from the *finished* strings: taking `c_str()` as the + // vector grew left every pointer pointing into memory the next push reallocated. + std::vector sig_cstrs; + sig_cstrs.reserve(sig_names.size() + 1); + for (const std::string& n : sig_names) sig_cstrs.push_back(n.c_str()); + sig_cstrs.push_back(nullptr); + if (editing != nullptr) { + const auto it = std::find(sig_names.begin(), sig_names.end(), editing->signal); + if (it != sig_names.end()) { + selected_signal = static_cast(it - sig_names.begin()); + } + } + GtkWidget* signal_drop = gtk_drop_down_new_from_strings(sig_cstrs.data()); + gtk_drop_down_set_selected(GTK_DROP_DOWN(signal_drop), static_cast(selected_signal)); + // No scrolled window around it: a `GtkDropDown` scrolls its own popover list, and wrapping + // one asked the *combo box itself* to grow to 160 px. + gtk_box_append(GTK_BOX(box), labeled_box("signal", signal_drop)); + + std::vector type_names; + for (int i = 0; i < measure_type_count(); ++i) type_names.push_back(measure_type_name(measure_type_at(i))); + type_names.push_back(nullptr); + GtkWidget* type_drop = gtk_drop_down_new_from_strings(type_names.data()); + gtk_box_append(GTK_BOX(box), labeled_box("measure", type_drop)); + + GtkWidget* threshold = spin(0.0, 100.0, 1.0, 1); + gtk_widget_set_tooltip_text(threshold, + "where the signal is split, as a share of the visible range"); + GtkWidget* threshold_label = nullptr; + GtkWidget* threshold_row = labelled_row("threshold %", threshold, &threshold_label); + gtk_box_append(GTK_BOX(box), threshold_row); + + GtkWidget* above = gtk_switch_new(); + gtk_widget_set_halign(above, GTK_ALIGN_START); + gtk_widget_set_tooltip_text(above, "which half of the waveform is the pulse"); + GtkWidget* side_label = nullptr; + GtkWidget* side_row = labelled_row("pulse above level", above, &side_label); + gtk_box_append(GTK_BOX(box), side_row); + + GtkWidget* show = gtk_check_button_new_with_label("show on plot"); + gtk_widget_set_tooltip_text(show, + "draw the result on the plot: a ruler at the level, a marker " + "at the time it happened, the pulse's edges as markers"); + gtk_box_append(GTK_BOX(box), show); + + auto* d = new MeasureDialog{&app, edit_index, signal_drop, type_drop, + threshold, above, show, window, + threshold_row, threshold_label, side_row, side_label, + sig_names}; + app.measure_dialog = window; + g_signal_connect(window, "destroy", G_CALLBACK(+[](GtkWidget*, gpointer user) { + static_cast(user)->measure_dialog = nullptr; + }), + &app); + // One owner, on the window: the buttons only *refer* to the context. Letting two of them + // own it (Apply and Delete) freed it twice, which is the abort an edit or a delete used to + // produce -- an add has one button, which is why only those two paths crashed. + g_object_set_data_full(G_OBJECT(window), "udpscope-measure-ctx", d, + [](gpointer p) { delete static_cast(p); }); + if (editing != nullptr) { + // The index by looking `editing->type` up in the same function that fills the dropdown: + // a hand-written chain of ternaries is a second copy of the order, and the copy is what + // decides which type the user gets. + for (int i = 0; i < measure_type_count(); ++i) { + if (measure_type_at(i) == editing->type) { + gtk_drop_down_set_selected(GTK_DROP_DOWN(type_drop), static_cast(i)); + break; + } + } + gtk_spin_button_set_value(GTK_SPIN_BUTTON(threshold), editing->options.threshold_pct); + gtk_switch_set_active(GTK_SWITCH(above), editing->options.above); + gtk_check_button_set_active(GTK_CHECK_BUTTON(show), editing->options.show_on_plot); + } else { + gtk_spin_button_set_value(GTK_SPIN_BUTTON(threshold), 50.0); + gtk_switch_set_active(GTK_SWITCH(above), true); + } + g_signal_connect(type_drop, "notify::selected", G_CALLBACK(on_measure_type_changed), d); + measure_dialog_sync(d); + + GtkWidget* cancel = gtk_button_new_with_label("Cancel"); + g_signal_connect_swapped(cancel, "clicked", G_CALLBACK(gtk_window_destroy), window); + dialog_add_button(window, cancel); + if (editing != nullptr) { + GtkWidget* del = gtk_button_new_with_label("Delete"); + gtk_widget_add_css_class(del, "destructive-action"); + g_signal_connect(del, "clicked", G_CALLBACK(on_measure_dialog_delete), d); + dialog_add_button(window, del); + } + GtkWidget* apply = gtk_button_new_with_label(editing != nullptr ? "Save" : "Add"); + gtk_widget_add_css_class(apply, "suggested-action"); + g_signal_connect(apply, "clicked", G_CALLBACK(on_measure_dialog_apply), d); + dialog_add_button(window, apply); + gtk_window_present(GTK_WINDOW(window)); +} + +/// Recomputes every measurement over the visible window and writes the results into the pane, +/// collecting the marks the ones asking to be shown need. +/// +/// A measurement is a statistic of the window, so it is recomputed when the *window* moves -- +/// not on a timer. A timer decouples the two: with the window scrolling under a 5 Hz cadence +/// the stored marks describe a range the plot has already left, which is how a min/max marker +/// ended up outside the viewport, and how a dragged marker survived until the next tick. The +/// threshold is a small fraction of the span, so the marks stay within a few pixels of where +/// the current window would put them, and the cost is bounded by the window's own motion +/// rather than by the frame rate. +void update_measures(App& app, bool force) { + if (app.measure_labels.size() != app.measures.size()) return; // mid-rebuild + const RowSet::Row& r0 = app.rows->row(0); + if (r0.plot == nullptr) return; + const plot::Range view = r0.plot->x_range(r0.axis_x); + const bool held = app.model->display_mode() == DisplayMode::Held; + const bool moved = app.measure_window_valid && !held && + std::fabs(view.min - app.measure_window.min) + + std::fabs(view.max - app.measure_window.max) > + 0.02 * std::max(1.0e-12, view.span()); + if (!force && app.measure_window_valid && !moved && held == app.measure_was_held) { + // Nothing to recompute -- but the marks are still re-placed below, which is what makes + // them read-only: a gesture moves one on the plot, and this is what puts it back. + app.rows->set_measure_marks(app.measure_marks); + return; + } + app.measure_window = view; + app.measure_window_valid = true; + app.measure_was_held = held; + app.measure_samples.clear(); + + app.measure_results.assign(app.measures.size(), MeasureResult{}); + app.measure_text.assign(app.measures.size(), std::string("--")); + app.measure_marks.clear(); + std::vector marks; + for (std::size_t i = 0; i < app.measures.size(); ++i) { + const Measure& m = app.measures[i]; + GtkWidget* label = app.measure_labels[i]; + const int channel = measure_channel(app, m.signal); + std::vector t; + std::vector v; + if (channel < 0) { + app.measure_text[i] = "--"; + if (label != nullptr) gtk_label_set_text(GTK_LABEL(label), "--"); + continue; + } + { + // One copy of the visible window per channel per pass, shared by every measurement on + // that signal (the common case is several on one trace). + auto it = app.measure_samples.find(channel); + if (it == app.measure_samples.end()) { + std::pair, std::vector> fresh; + if (visible_samples(app, static_cast(channel), fresh.first, fresh.second)) { + it = app.measure_samples.emplace(channel, std::move(fresh)).first; + } + } else if (it->second.first.empty()) { + it = app.measure_samples.end(); + } + if (it == app.measure_samples.end()) { + app.measure_text[i] = "--"; + if (label != nullptr) gtk_label_set_text(GTK_LABEL(label), "--"); + continue; + } + t = it->second.first; + v = it->second.second; + } + const MeasureResult r = compute_measure(t, v, m.type, m.options); + app.measure_results[i] = r; + if (label != nullptr) { + std::string text = "--"; + if (r.valid) { + // Time-valued types carry seconds and a frequency carries hertz whatever the + // signal's own unit is; everything else carries that unit. + const bool is_time = m.type == MeasureType::PulseDuration || + m.type == MeasureType::Period; + const std::string unit = is_time ? "s" + : m.type == MeasureType::Frequency ? "Hz" + : measure_unit(app, m.signal); + text = format_measure(r.primary, unit); + if (r.has_secondary) { + // Pulse duration also reports the duty, which has no unit. + text += " " + format_measure(100.0 * r.secondary, "%"); + } + } + app.measure_text[i] = text; + gtk_label_set_text(GTK_LABEL(label), text.c_str()); + } + if (m.options.show_on_plot && r.valid) { + const int row = measure_row(app, m.signal); + const plot::u32 color = measure_color(i); + for (const MeasureMarkSpec& spec : r.marks) { + marks.push_back({row, spec.level, spec.value, color}); + } + } + } + app.measure_marks = std::move(marks); + app.rows->set_measure_marks(app.measure_marks); +} + +/// The measurements panel: a header with the `[+]`, and one row per measurement. +GtkWidget* build_measures_panel(App& app) { + GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 4); + gtk_widget_set_margin_top(box, 6); + gtk_widget_set_margin_bottom(box, 6); + gtk_widget_set_margin_start(box, 6); + gtk_widget_set_margin_end(box, 6); + + GtkWidget* head = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8); + GtkWidget* title = gtk_label_new("Measurements"); + gtk_widget_set_halign(title, GTK_ALIGN_START); + gtk_widget_set_hexpand(title, true); + gtk_box_append(GTK_BOX(head), title); + GtkWidget* add = gtk_button_new_from_icon_name("list-add-symbolic"); + gtk_widget_add_css_class(add, "flat"); + gtk_widget_set_tooltip_text(add, "add a measurement"); + g_signal_connect(add, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) { + open_measure_dialog(*static_cast(user), -1); + }), + &app); + gtk_box_append(GTK_BOX(head), add); + gtk_box_append(GTK_BOX(box), head); + + app.measures_list = gtk_box_new(GTK_ORIENTATION_VERTICAL, 2); + gtk_box_append(GTK_BOX(box), app.measures_list); + return box; +} + +/// The panel's frame: the title row (the mode) and the readouts. The time line is not +/// part of the body, because it is there in every mode. +GtkWidget* build_cursors_panel(App& app) { + GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 4); + gtk_widget_set_margin_top(box, 6); + gtk_widget_set_margin_bottom(box, 6); + gtk_widget_set_margin_start(box, 6); + gtk_widget_set_margin_end(box, 6); + + GtkWidget* head = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8); + GtkWidget* title = gtk_label_new("Cursors"); + gtk_widget_set_halign(title, GTK_ALIGN_START); + gtk_widget_set_hexpand(title, true); + gtk_box_append(GTK_BOX(head), title); + const char* names[5] = {kCursorModeNames[0], kCursorModeNames[1], kCursorModeNames[2], + kCursorModeNames[3], nullptr}; + app.cursors_mode_drop = gtk_drop_down_new_from_strings(names); + gtk_widget_set_tooltip_text(app.cursors_mode_drop, + "vertical: two times, shared by every row\n" + "horizontal: two rulers per row\n" + "both: the two together\n" + "mix: the two times, read off each trace where they cross"); + g_signal_connect(app.cursors_mode_drop, "notify::selected", + G_CALLBACK(on_cursor_mode_changed), &app); + gtk_box_append(GTK_BOX(head), app.cursors_mode_drop); + gtk_box_append(GTK_BOX(box), head); + + app.cursors_body = gtk_grid_new(); + gtk_grid_set_row_spacing(GTK_GRID(app.cursors_body), 2); + gtk_grid_set_column_spacing(GTK_GRID(app.cursors_body), 4); + gtk_box_append(GTK_BOX(box), app.cursors_body); + return box; +} + void on_cursors_toggled(GtkToggleButton* button, gpointer user) { App* app = static_cast(user); const bool on = gtk_toggle_button_get_active(button); @@ -1380,6 +2360,15 @@ void on_cursors_toggled(GtkToggleButton* button, gpointer user) { // The reset button exists for the cursors: with them hidden there is nothing to // reset, and a button that does nothing reads as broken. gtk_widget_set_visible(app->cursors_reset, on); + // The pane is the cursors' readout: with them off there is nothing to read. + if (app->cursors_revealer != nullptr) { + gtk_revealer_set_reveal_child(GTK_REVEALER(app->cursors_revealer), on); + } + if (on) { + app->rows->reset_cursors(); + rebuild_cursors_pane(*app); + update_cursors_pane(*app); + } } void on_signals_toggled(GtkToggleButton* button, gpointer user) { @@ -1399,6 +2388,17 @@ void on_fit_clicked(GtkButton*, gpointer user) { app->notice_until_us = g_get_monotonic_time() + 3 * G_TIME_SPAN_SECOND; } +/// Fit the x window only: the y axes keep the scales the user set. The counterpart of the +/// other fit, for the common case where the interesting range is the data's but the levels +/// were placed by hand. +void on_fit_x_clicked(GtkButton*, gpointer user) { + App* app = static_cast(user); + app->rows->fit_x(); + sync_window_display(*app, app->rows->time_window()); + app->notice = "x fitted to the buffered data (" + format_window(app->rows->time_window()) + ")"; + app->notice_until_us = g_get_monotonic_time() + 3 * G_TIME_SPAN_SECOND; +} + void on_cursors_reset(GtkButton*, gpointer user) { App* app = static_cast(user); // A reset the user cannot see reads as a dead button, so it also switches the @@ -1599,6 +2599,21 @@ void on_right_click(GtkGestureClick* gesture, int, double x, double y, gpointer g_signal_connect(fixed_btn, "clicked", G_CALLBACK(on_y_fixed_clicked), app); gtk_box_append(GTK_BOX(box), fixed_btn); + // Fitting is a per-plot gesture in the menu as it is on the screen: the two do the + // same thing whatever row they were opened on (the x window is shared and the y fit + // walks them all), which is what "fit" means here. + GtkWidget* fitx_btn = gtk_button_new_with_label("Fit X axis"); + g_object_set_data(G_OBJECT(fitx_btn), "udpscope-popover", popover); + g_signal_connect(fitx_btn, "clicked", G_CALLBACK(on_fit_x_clicked), app); + gtk_box_append(GTK_BOX(box), fitx_btn); + + GtkWidget* fitall_btn = gtk_button_new_with_label("Fit all"); + g_object_set_data(G_OBJECT(fitall_btn), "udpscope-popover", popover); + g_signal_connect(fitall_btn, "clicked", G_CALLBACK(on_fit_clicked), app); + gtk_box_append(GTK_BOX(box), fitall_btn); + + gtk_box_append(GTK_BOX(box), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL)); + // One entry per occupied trace slot of this row. const RowSet::Row& r = app->rows->row(row); bool any = false; @@ -1792,14 +2807,20 @@ gboolean tick(gpointer user) { const gint64 now = g_get_monotonic_time(); if (now >= next_header_log) { next_header_log = now + G_TIME_SPAN_SECOND; + app->dbg_win_btn_w = gtk_widget_get_width(app->window_button); + app->dbg_win_label_w = gtk_widget_get_width(app->window_label); + app->dbg_bar_w = gtk_widget_get_width(app->window); std::fprintf(stderr, "[udpscope] header: window_button_w=%d label_w=%d bar_w=%d\n", - gtk_widget_get_width(app->window_button), - gtk_widget_get_width(app->window_label), - gtk_widget_get_width(app->window)); + app->dbg_win_btn_w, app->dbg_win_label_w, app->dbg_bar_w); std::fflush(stderr); } } if (model.generation() != app->signal_generation) rebuild_signal_panels(*app); + // A gesture moves the *plot's* cursor; adopting it here is what makes the two + // vertical markers one time across every row, and what the pane then reads. + app->rows->sync_cursors(); + update_cursors_pane(*app); + update_measures(*app, false); // The first CONFIG names the signals, which is what the persisted layout is // written against: apply it once, here, and never again (a later CONFIG only @@ -2069,6 +3090,8 @@ gboolean tick(gpointer user) { const double dt_s = std::min(10.0, double(now_c - app->last_channels_meas) / 1.0e6); next_channels = now_c + G_TIME_SPAN_SECOND; app->last_channels_meas = now_c; + // Once a second, alongside the rate measurement: is the stream arriving at all? + note_starvation(*app); std::lock_guard lock(model.mutex()); app->stored_rate.assign(model.channel_count(), 0.0); app->declared_rate.assign(model.channel_count(), 0.0); @@ -2223,18 +3246,27 @@ gboolean tick(gpointer user) { : " | no event yet"; } + // Only when it has happened: a stream that is fine carries no counter, so the line + // stays as short as it was, and a client being kicked shows the number it was kicked. + char reconnects_note[48]; + reconnects_note[0] = '\0'; + if (st.reconnects.load(std::memory_order_relaxed) > 0) { + std::snprintf(reconnects_note, sizeof(reconnects_note), " | %llu reconnects", + static_cast(st.reconnects.load(std::memory_order_relaxed))); + } + char status[1024]; std::snprintf(status, sizeof(status), "[%s] %s %s | %s | packets %llu frames %llu gaps %llu | %zu signals, %zu " "channels | plot 1: %d trace(s), %zu samples | buffer %.1f MB, %.2f s span, " - "time x%llu%s", + "time x%llu%s%s", mode, st.connected.load() ? "connected" : "waiting", st.have_config.load() ? "configured" : "-", rate.c_str(), static_cast(st.packets.load()), static_cast(st.frames.load()), static_cast(st.gaps.load()), signal_count, channel_count, bound, samples, double(model.config().buffer_mb), span_s, - static_cast(time_stride), since.c_str()); + static_cast(time_stride), since.c_str(), reconnects_note); if (!app->notice.empty() && g_get_monotonic_time() < app->notice_until_us) { const std::string with_notice = app->notice + " | " + status; gtk_label_set_text(GTK_LABEL(app->status_label), with_notice.c_str()); @@ -2338,6 +3370,52 @@ GtkWidget* build_signal_panel(App& app) { return box; } +/// Recognises the "the source is serving someone else" state and says so. +/// +/// A UDPStreamer over unicast keeps one session: a second client's CONNECT displaces the +/// first, the first sees `silence_timeout_s` of no data and reconnects, and the two take +/// turns. Nothing in the client can give both of them the stream -- the wire has one +/// destination -- so the useful thing is to name the situation: a reconnect with no frames +/// delivered in the same second is that fight, or a server that keeps dropping us, and +/// neither is visible in an empty plot. +/// +/// Called once a second from the frame tick. +void note_starvation(App& app) { + StreamStats& st = app.model->stats(); + const std::uint64_t frames = st.frames.load(std::memory_order_relaxed); + const std::uint64_t reconnects = st.reconnects.load(std::memory_order_relaxed); + const bool connected = st.connected.load(std::memory_order_relaxed); + + // 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; + } + app.starve_frames = frames; + app.starve_reconnects = reconnects; + app.starve_have_prev = true; + + if (!starved) { + app.starve_reported = false; + return; + } + app.notice = + "connected, but no data is arriving: another reader may hold the source (a unicast " + "source serves one client at a time) -- use --multicast to watch from several UIs"; + app.notice_until_us = g_get_monotonic_time() + 3 * G_TIME_SPAN_SECOND; + if (!app.starve_reported) { + app.starve_reported = true; + std::fprintf(stderr, + "[udpscope] starved: connected (config=%d), reconnects=%llu, no new frames " + "in the last second -- a unicast source serves one client at a time\n", + st.have_config.load(std::memory_order_relaxed) ? 1 : 0, + static_cast(reconnects)); + std::fflush(stderr); + } +} + /// The display-window control: a button reading the current window, and a popover holding /// the number, its unit and a decade stepper either side. /// @@ -2437,6 +3515,22 @@ GtkWidget* build_toolbar(App& 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); + app.measures_toggle = gtk_toggle_button_new_with_label("Measurements"); + gtk_widget_set_tooltip_text(app.measures_toggle, + "compute a value over the visible window of a signal, and " + "optionally mark it on the plot"); + g_signal_connect(app.measures_toggle, "toggled", G_CALLBACK(+[](GtkToggleButton* b, + gpointer user) { + App* a = static_cast(user); + a->measures_revealed = gtk_toggle_button_get_active(b); + if (a->measures_revealer != nullptr) { + gtk_revealer_set_reveal_child(GTK_REVEALER(a->measures_revealer), + a->measures_revealed); + } + }), + &app); + gtk_box_append(GTK_BOX(menu_box), app.measures_toggle); + app.cursors_toggle = gtk_toggle_button_new_with_label("Cursors"); 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); @@ -2573,6 +3667,23 @@ void on_activate(GtkApplication* application, gpointer user) { gtk_box_append(GTK_BOX(side), app->trigger_revealer); gtk_box_append(GTK_BOX(side), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL)); + // The cursors readout sits with the other panels and is revealed by its toggle: the + // instruments are on the plot, but what they read has to be somewhere a value can be + // selected and copied from. + app->cursors_revealer = gtk_revealer_new(); + gtk_revealer_set_transition_type(GTK_REVEALER(app->cursors_revealer), + GTK_REVEALER_TRANSITION_TYPE_SLIDE_DOWN); + gtk_revealer_set_child(GTK_REVEALER(app->cursors_revealer), build_cursors_panel(*app)); + gtk_box_append(GTK_BOX(side), app->cursors_revealer); + gtk_box_append(GTK_BOX(side), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL)); + + app->measures_revealer = gtk_revealer_new(); + gtk_revealer_set_transition_type(GTK_REVEALER(app->measures_revealer), + GTK_REVEALER_TRANSITION_TYPE_SLIDE_DOWN); + gtk_revealer_set_child(GTK_REVEALER(app->measures_revealer), build_measures_panel(*app)); + gtk_box_append(GTK_BOX(side), app->measures_revealer); + gtk_box_append(GTK_BOX(side), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL)); + app->signal_panel = build_signal_panel(*app); app->signal_revealer = gtk_revealer_new(); gtk_revealer_set_transition_type(GTK_REVEALER(app->signal_revealer), @@ -2983,10 +4094,25 @@ void layout_probe_report(App& a, const char* what) { } if (!any) traces += "-"; } - std::fprintf(stderr, "[layout-probe] %-12s rows=%d buttons=%zu rows_y=[%s] close=[%s] " - "add=[%s] traces=[%s]\n", - what, rows, a.layout_buttons.size(), geom.c_str(), close_tops.c_str(), - add_tops.c_str(), traces.c_str()); + // Where the Done button ended up, in the overlay's own coordinates: the assertion is + // that it hugs the area's top-right corner, so a layout change that pushed it off the + // corner shows here as numbers rather than as a screenshot someone has to look at. + std::string done_box = "-"; + if (a.layout_done_button != nullptr && a.area_overlay != nullptr) { + graphene_rect_t r{}; + if (gtk_widget_compute_bounds(a.layout_done_button, a.area_overlay, &r)) { + char buf[64]; + std::snprintf(buf, sizeof(buf), "%.0f,%.0f,%.0fx%.0f", static_cast(r.origin.x), + static_cast(r.origin.y), static_cast(r.size.width), + static_cast(r.size.height)); + done_box = buf; + } + } + std::fprintf(stderr, "[layout-probe] %-12s rows=%d buttons=%zu done=%d done_box=[%s] " + "rows_y=[%s] close=[%s] add=[%s] traces=[%s]\n", + what, rows, a.layout_buttons.size(), a.layout_done_button != nullptr ? 1 : 0, + done_box.c_str(), geom.c_str(), close_tops.c_str(), add_tops.c_str(), + traces.c_str()); std::fflush(stderr); } @@ -3038,7 +4164,11 @@ gboolean layout_probe_step(gpointer user) { break; case 4: layout_probe_report(*a, "add at end"); - gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a->layout_toggle), false); + // Leave through the editor's own Done button, which is the way out a user has in + // front of them; the toolbar toggle is the same state and stays exercised by the + // `on` step above. + layout_probe_report(*a, "before done"); + layout_click(a->layout_done_button); layout_probe_next(*a, 5, 200); break; default: @@ -3243,6 +4373,343 @@ gboolean exit_probe_step1(gpointer user) { } // namespace +/// Reports the cursors: the instruments' values, whether the pane is up, and the pane's +/// own text (which is what a user reads, so it is what the probe asserts). +void cursors_probe_report(App& a, const char* what) { + static const char* const kModes[] = {"vertical", "horizontal", "both", "mix"}; + const int mode = static_cast(a.rows->cursor_mode()); + // The table as a user reads it: the time row first, then one row per line, each cell's + // own text -- which is the assertion (the app's formatting, not the probe's). + std::string t_text; + std::string lines; + int n = 0; + const auto cells = [](const App::CursorLine& line) { + return std::string(gtk_label_get_text(GTK_LABEL(line.a))) + " " + + gtk_label_get_text(GTK_LABEL(line.b)) + " " + + gtk_label_get_text(GTK_LABEL(line.d)); + }; + for (const App::CursorLine& line : a.cursors_lines) { + if (line.a == nullptr) continue; + if (line.slot == -2) { + t_text = cells(line); + continue; + } + if (n != 0) lines += " | "; + lines += cells(line); + ++n; + } + // The `mix` crossing markers: how many are on screen and where the first is. They are + // what the mode promises on the plot itself, so they are what the probe asserts. + double mx = 0.0; + double my = 0.0; + const bool have_mark = a.rows->crossing_at(0, 0, false, mx, my); + std::fprintf(stderr, + "[cursors-probe] %-14s mode=%-10s on=%d pane=%d t0=%.6f t1=%.6f dt=%.6f " + "T=[%s] lines=%d marks=%d [%s]\n", + what, kModes[mode], a.rows->cursors_visible() ? 1 : 0, + a.cursors_revealer != nullptr && + gtk_revealer_get_reveal_child(GTK_REVEALER(a.cursors_revealer)) + ? 1 + : 0, + a.rows->cursor_a_x(), a.rows->cursor_b_x(), + a.rows->cursor_b_x() - a.rows->cursor_a_x(), t_text.c_str(), n, + a.rows->crossings_visible(), lines.c_str()); + if (have_mark) { + std::fprintf(stderr, "[cursors-probe] %-14s first mark at x=%.6f y=%.6g\n", what, mx, my); + } + std::fflush(stderr); +} + +gboolean cursors_probe_step(gpointer user); + +/// The two fits the right-click menu offers, driven through the handlers the menu wires +/// (a null button: neither handler looks at it). `fit x` must narrow the window to the +/// data's own span and leave the y ranges alone; `fit all` must then move the y ranges too. +void fit_probe_report(App& a, const char* what) { + const RowSet::Row& r0 = a.rows->row(0); + const plot::Range x = r0.plot != nullptr ? r0.plot->x_range(r0.axis_x) : plot::Range{}; + const plot::Range y = r0.plot != nullptr ? r0.plot->y_range(r0.axis_y) : plot::Range{}; + std::fprintf(stderr, + "[fit-probe] %-10s window=%.6f x=[%.6f..%.6f] span=%.6f y=[%.6f..%.6f]\n", + what, a.rows->time_window(), x.min, x.max, x.span(), y.min, y.max); + std::fflush(stderr); +} + +gboolean fit_probe_step(gpointer user) { + App* a = static_cast(user); + if (a->window == nullptr) return G_SOURCE_REMOVE; + switch (a->dbg_fit_step) { + case 0: + // Move the y scale off the data's range first, so "fit x leaves y alone" is a + // statement with something to be wrong about. + a->rows->set_y_range(0, -100.0, 100.0); + a->rows->set_time_window(0.05); + fit_probe_report(*a, "start"); + g_timeout_add(400, fit_probe_step, a); + a->dbg_fit_step = 1; + break; + case 1: + a->rows->fit_x(); + sync_window_display(*a, a->rows->time_window()); + g_timeout_add(400, fit_probe_step, a); + a->dbg_fit_step = 2; + break; + case 2: + fit_probe_report(*a, "fit x"); + a->rows->fit_all(); + sync_window_display(*a, a->rows->time_window()); + g_timeout_add(400, fit_probe_step, a); + a->dbg_fit_step = 3; + break; + default: + fit_probe_report(*a, "fit all"); + break; + } + return G_SOURCE_REMOVE; +} + +void cursors_probe_next(App& a, int step, guint delay_ms) { + a.dbg_cursors_step = step; + g_timeout_add(delay_ms, cursors_probe_step, &a); +} + +gboolean cursors_probe_step(gpointer user) { + App* a = static_cast(user); + if (a->window == nullptr) return G_SOURCE_REMOVE; + switch (a->dbg_cursors_step) { + case 0: + // Through the toggle the toolbar offers, which is what also reveals the pane. + gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a->cursors_toggle), true); + cursors_probe_report(*a, "on"); + cursors_probe_next(*a, 1, 300); + break; + case 1: + a->rows->set_cursor_mode(RowSet::CursorMode::Horizontal); + cursors_probe_next(*a, 2, 300); + break; + case 2: + cursors_probe_report(*a, "horizontal"); + a->rows->set_cursor_mode(RowSet::CursorMode::Both); + cursors_probe_next(*a, 3, 300); + break; + case 3: + cursors_probe_report(*a, "both"); + a->rows->set_cursor_mode(RowSet::CursorMode::Mix); + cursors_probe_next(*a, 4, 300); + break; + case 4: + cursors_probe_report(*a, "mix"); + cursors_probe_next(*a, 5, 300); + break; + case 5: { + // A drag, as the adapter delivers one: the *plot's* cursor moves and the frame + // tick is what notices. If the sync works, every row's marker follows. + RowSet::Row& r0 = a->rows->row(0); + if (r0.plot != nullptr) { + const plot::Range xr = r0.plot->x_range(r0.axis_x); + r0.plot->set_cursor_x(r0.cursor_a, xr.min + 0.20 * xr.span(), plot::CursorPlacement::Free); + } + cursors_probe_next(*a, 6, 300); + break; + } + case 6: + cursors_probe_report(*a, "after drag"); + // The other half of issue 1: a *ruler* is dragged through its own plot, and the + // pane's Y cells have to follow it. Nothing shares a ruler, so this is the read-back + // path alone -- the one that used to leave the numbers showing where the app last + // put them. + a->rows->set_cursor_mode(RowSet::CursorMode::Both); + cursors_probe_next(*a, 7, 300); + break; + case 7: { + cursors_probe_report(*a, "both"); + RowSet::Row& r0 = a->rows->row(0); + if (r0.plot != nullptr) { + const plot::Range yr = r0.plot->y_range(r0.axis_y); + r0.plot->set_cursor_y(r0.ruler, yr.min + 0.80 * yr.span(), plot::CursorPlacement::Free); + } + cursors_probe_next(*a, 8, 300); + break; + } + default: + cursors_probe_report(*a, "after ruler drag"); + break; + } + return G_SOURCE_REMOVE; +} + +/// Adds one measurement of every type on the longest-cycling scalar signal the stream +/// describes, then reports each result -- through the pane's own list and the tick's own +/// recomputation, so what it prints is what a user would read. +/// Clicks a labelled button inside the measurement dialog, which is a window of its own. +void measure_dialog_click(App& a, const char* label) { + if (a.measure_dialog == nullptr) { + std::fprintf(stderr, "[measures-probe] no dialog to click '%s' in\n", label); + return; + } + GtkWidget* b = find_labelled_button(a.measure_dialog, label); + if (b == nullptr) { + std::fprintf(stderr, "[measures-probe] no '%s' button in the dialog\n", label); + return; + } + g_signal_emit_by_name(b, "clicked"); +} + +/// What the dialog's signal dropdown holds: the probe's assertion that the list is the +/// *acquired* signals (plus the edited measurement's own, when the stream no longer acquires +/// it), and that every entry is a name rather than the mangled pointer it used to be. +void measure_dialog_report(App& a, const char* what) { + std::string items; + int n = 0; + if (a.measure_dialog != nullptr) { + // The dialog keeps its fields in the context it stores on its own window, which is how a + // probe reaches a dropdown inside a window that is not the main one. + auto* d = static_cast( + g_object_get_data(G_OBJECT(a.measure_dialog), "udpscope-measure-ctx")); + if (d != nullptr && d->signal_drop != nullptr) { + GListModel* model = gtk_drop_down_get_model(GTK_DROP_DOWN(d->signal_drop)); + const guint count = model != nullptr ? g_list_model_get_n_items(model) : 0; + for (guint i = 0; i < count; ++i) { + GObject* obj = G_OBJECT(g_list_model_get_item(model, i)); + const char* text = GTK_IS_STRING_OBJECT(obj) + ? gtk_string_object_get_string(GTK_STRING_OBJECT(obj)) + : "?"; + if (i != 0) items += ", "; + items += text; + g_object_unref(obj); + } + n = static_cast(count); + } + } + int drop_h = -1; + char options[192] = ""; + if (a.measure_dialog != nullptr) { + auto* dd = static_cast( + g_object_get_data(G_OBJECT(a.measure_dialog), "udpscope-measure-ctx")); + if (dd != nullptr && dd->signal_drop != nullptr) { + drop_h = gtk_widget_get_height(dd->signal_drop); + } + if (dd != nullptr) { + // What the dialog is offering for the type on screen: the option rows' visibility and + // the words on them, which is the per-type behaviour a user sees. + std::snprintf(options, sizeof(options), "type=%d thr=%d side=%d show=%d '%s'", + static_cast(gtk_drop_down_get_selected(GTK_DROP_DOWN(dd->type_drop))), + dd->threshold_row != nullptr && gtk_widget_get_visible(dd->threshold_row) ? 1 : 0, + dd->side_row != nullptr && gtk_widget_get_visible(dd->side_row) ? 1 : 0, + dd->show != nullptr && gtk_widget_get_visible(dd->show) ? 1 : 0, + dd->show != nullptr && gtk_check_button_get_child(GTK_CHECK_BUTTON(dd->show)) != + nullptr + ? gtk_label_get_text(GTK_LABEL(gtk_check_button_get_child( + GTK_CHECK_BUTTON(dd->show)))) + : ""); + } + } + std::fprintf(stderr, + "[measures-probe] %-14s measures=%zu dialog=%d signals=%d drop_h=%d [%s] %s\n", + what, a.measures.size(), a.measure_dialog != nullptr ? 1 : 0, n, drop_h, + items.c_str(), options); + std::fflush(stderr); +} + +gboolean measures_probe_step(gpointer user); + +void measures_probe_next(App& a, int step, guint delay_ms) { + a.dbg_measure_step = step; + g_timeout_add(delay_ms, measures_probe_step, &a); +} + +gboolean measures_probe_step(gpointer user) { + App* a = static_cast(user); + if (a->window == nullptr) return G_SOURCE_REMOVE; + switch (a->dbg_measure_step) { + case 0: { + std::string signal; + { + std::lock_guard lock(a->model->mutex()); + if (!a->model->channels().empty()) signal = a->model->channels().front().name; + } + if (signal.empty()) { + std::fprintf(stderr, "[measures-probe] no acquired signal to measure\n"); + return G_SOURCE_REMOVE; + } + for (int i = 0; i < measure_type_count(); ++i) { + Measure m; + m.signal = signal; + m.type = measure_type_at(i); + m.options.show_on_plot = measure_can_show_on_plot(m.type); + a->measures.push_back(std::move(m)); + } + gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a->measures_toggle), true); + rebuild_measures_pane(*a); + std::fprintf(stderr, "[measures-probe] added %zu measurements on '%s'\n", a->measures.size(), + signal.c_str()); + measures_probe_next(*a, 1, 400); + break; + } + case 1: + open_measure_dialog(*a, 0); // the edit path: this and the delete are what aborted + measures_probe_next(*a, 2, 400); + break; + case 2: + // Reported a frame after opening, so the dropdown has an allocation: its height is + // what says the combo is a combo and not a 160 px box. + measure_dialog_report(*a, "edit opened"); + measures_probe_next(*a, 3, 200); + break; + case 3: + measure_dialog_click(*a, "Save"); + measures_probe_next(*a, 4, 400); + break; + case 4: + measure_dialog_report(*a, "after save"); + open_measure_dialog(*a, 0); + measures_probe_next(*a, 5, 200); + break; + case 5: + measure_dialog_click(*a, "Delete"); + measures_probe_next(*a, 6, 400); + break; + case 6: + // A gesture on a measurement's own cursor, placed through the plot exactly as the + // controller would: the next frame has to put it back, which is what "read-only" means + // here (the library has no flag the hit test would respect). + if (!a->measure_marks.empty()) { + RowSet::Row& r0 = a->rows->row(0); + if (r0.plot != nullptr) { + const RowSet::MeasureMark& mk = a->measure_marks.front(); + if (mk.level) { + r0.plot->set_cursor_y(r0.measure_h[0], mk.value + 3.0, plot::CursorPlacement::Free); + } else { + r0.plot->set_cursor_x(r0.measure_v[0], mk.value + 3.0, plot::CursorPlacement::Free); + } + } + } + measures_probe_next(*a, 7, 300); + break; + default: { + double restored = -1.0; + double want = 0.0; + if (!a->measure_marks.empty()) { + const RowSet::Row& r0 = a->rows->row(0); + const RowSet::MeasureMark& mk = a->measure_marks.front(); + want = mk.value; + if (r0.plot != nullptr) { + restored = mk.level ? r0.plot->cursor(r0.measure_h[0]).y + : r0.plot->cursor(r0.measure_v[0]).x; + } + } + std::fprintf(stderr, + "[measures-probe] %-14s marks=%zu first=%.6f plot_now=%.6f restored=%d\n", + "after drag", a->measure_marks.size(), want, restored, + std::fabs(restored - want) < 1.0e-9 ? 1 : 0); + std::fflush(stderr); + measure_dialog_report(*a, "final"); + break; + } + } + return G_SOURCE_REMOVE; +} + int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisition& acq, RowSet& rows) { (void)argc; @@ -3281,6 +4748,15 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit if (const char* env = g_getenv("UDPSCOPE_PROBE_WINDOW_MS")) { g_timeout_add(static_cast(std::atoi(env)), window_probe, &app); } + if (const char* env = g_getenv("UDPSCOPE_PROBE_MEASURES_MS")) { + g_timeout_add(static_cast(std::atoi(env)), measures_probe_step, &app); + } + if (const char* env = g_getenv("UDPSCOPE_PROBE_FIT_MS")) { + g_timeout_add(static_cast(std::atoi(env)), fit_probe_step, &app); + } + if (const char* env = g_getenv("UDPSCOPE_PROBE_CURSORS_MS")) { + g_timeout_add(static_cast(std::atoi(env)), cursors_probe_step, &app); + } if (const char* env = g_getenv("UDPSCOPE_PROBE_LAYOUT_MS")) { g_timeout_add(static_cast(std::atoi(env)), layout_probe_step, &app); } @@ -3371,7 +4847,7 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit app.dbg_signal_h, app.dbg_signal_row_h, app.dbg_signal_revealed, app.dbg_trigger_w, app.dbg_trigger_h, app.dbg_trigger_revealed, app.dbg_toggles_visible, - rows.row(0).cursors_visible ? 1 : 0, rows.time_window(), + rows.cursors_visible() ? 1 : 0, rows.time_window(), app.window_limit_s); std::printf("[udpscope] mode with trigger on: %s\n", app.dbg_mode_triggered.c_str()); std::printf("[udpscope] trigger marker x=%.3f, view x_max=%.3f; model trigger_time=%.3f " @@ -3409,10 +4885,115 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit tr.series->x_bounds().max); } } + { + static const char* const kModes[] = {"vertical", "horizontal", "both", "mix"}; + const int mode = static_cast(rows.cursor_mode()); + // What the *plot* holds, which is what the renderer draws from: a marker the app + // placed but the plot parked (or hid) would show up here and not on screen. + for (int i = 0; i < rows.row_count(); ++i) { + const RowSet::Row& r = rows.row(i); + if (r.plot == nullptr) continue; + const plot::Plot& p = *r.plot; + std::printf("[udpscope] cursors in plot %d: vis=%d a=(x=%.6f vis=%d) " + "b=(x=%.6f vis=%d) ruler=(y=%.6f vis=%d) ruler_b=(y=%.6f vis=%d) " + "stored_rulers=%.6f,%.6f\n", + i, rows.cursors_visible() ? 1 : 0, p.cursor(r.cursor_a).x, + p.cursor(r.cursor_a).visible, p.cursor(r.cursor_b).x, + p.cursor(r.cursor_b).visible, p.cursor(r.ruler).y, + p.cursor(r.ruler).visible, p.cursor(r.ruler_b).y, + p.cursor(r.ruler_b).visible, r.ruler_y, r.ruler_b_y); + // Where the plot would draw them: the row's own axes rect and the device + // coordinate the ruler's y maps to (`cursor_screen_coordinate`). + const plot::PlotLayout& lay = p.layout(); + std::printf("[udpscope] cursors in plot %d: axes=(%.0f,%.0f %.0fx%.0f) " + "ruler_screen=%.1f ruler_b_screen=%.1f y_range=[%.4g..%.4g]\n", + i, double(lay.axes.x), double(lay.axes.y), double(lay.axes.w), + double(lay.axes.h), double(p.cursor_screen_coordinate(r.ruler)), + double(p.cursor_screen_coordinate(r.ruler_b)), p.y_range(r.axis_y).min, + p.y_range(r.axis_y).max); + } + std::printf("[udpscope] cursors: on=%d mode=%s t0=%.6f t1=%.6f dt=%.6f", + rows.cursors_visible() ? 1 : 0, kModes[mode], rows.cursor_a_x(), + rows.cursor_b_x(), rows.cursor_b_x() - rows.cursor_a_x()); + for (int r = 0; r < rows.row_count(); ++r) { + std::printf(" y%d=[%.6g,%.6g]", r, rows.ruler_y(r), rows.ruler_b_y(r)); + } + std::printf("\n"); + } + for (std::size_t i = 0; i < app.measures.size(); ++i) { + const Measure& m = app.measures[i]; + const MeasureResult& r = i < app.measure_results.size() ? app.measure_results[i] + : MeasureResult{}; + std::printf("[udpscope] measure %zu: '%s' %s thr=%.1f%% above=%d show=%d -> %s " + "%.9g", + i, m.signal.c_str(), measure_type_name(m.type), m.options.threshold_pct, + m.options.above ? 1 : 0, m.options.show_on_plot ? 1 : 0, + r.valid ? "ok" : "invalid", r.valid ? r.primary : 0.0); + // What the row *reads*: the formatted string with its unit (so `frequency` showing Hz + // is asserted here, not inferred), and the row's chip count (two: the measurement's + // colour and the signal's). + if (i < app.measure_text.size()) std::printf(" text=[%s]", app.measure_text[i].c_str()); + if (r.valid && r.has_secondary) std::printf(" duty=%.6g", r.secondary); + if (r.valid && !r.marks.empty()) { + // One entry per mark with its kind: the mix is the point (`min` is a ruler *and* a + // marker), so a shape that could only say "level" or "time" would hide the case this + // asserts. + std::printf(" marks="); + for (std::size_t k = 0; k < r.marks.size(); ++k) { + if (k != 0) std::printf(","); + std::printf("%s:%.9g", r.marks[k].level ? "level" : "time", r.marks[k].value); + } + } + std::printf("\n"); + } + { + std::lock_guard lock(model.mutex()); + // `results_for` is the window the numbers describe, `win` the one on screen now: the + // drift between them is the assertion that a measurement is a statistic of the *current* + // window -- a timer-decoupled recomputation is what used to leave a marker outside it. + const RowSet::Row& r0 = rows.row(0); + const plot::Range win = r0.plot != nullptr ? r0.plot->x_range(r0.axis_x) : plot::Range{}; + const double span = app.measure_window.span(); + int in_window = 0; + int marks = 0; + for (const RowSet::MeasureMark& mk : app.measure_marks) { + if (mk.level) continue; + ++marks; + if (mk.value >= app.measure_window.min && mk.value <= app.measure_window.max) ++in_window; + } + // Whether the marks the app placed are *unselectable* -- the property the pointer's hit + // test respects. Read back out of the plot, so this is the app's own setting and not + // the intent behind it. + int selectable = 0; + for (int i = 0; i < rows.row_count(); ++i) { + const RowSet::Row& r = rows.row(i); + if (r.plot == nullptr) continue; + for (int m = 0; m < RowSet::kMeasureMarks; ++m) { + const std::size_t mi = static_cast(m); + if (r.plot->cursor(r.measure_h[mi]).selectable) ++selectable; + if (r.plot->cursor(r.measure_v[mi]).selectable) ++selectable; + } + } + std::printf("[udpscope] measures window: win=[%.6f..%.6f] results_for=[%.6f..%.6f] " + "drift=%.4f%% marks_in_window=%d/%d marks_selectable=%d\n", + win.min, win.max, app.measure_window.min, app.measure_window.max, + span > 0.0 ? 100.0 * (std::fabs(win.min - app.measure_window.min) + + std::fabs(win.max - app.measure_window.max)) / + span + : 0.0, + in_window, marks, selectable); + } + { + std::lock_guard lock(model.mutex()); + std::printf("[udpscope] measures pane: n=%zu revealed=%d marks_shown=%d pane_rows=%zu " + "pane_w=%d pane_h=%d\n", + app.measures.size(), app.measures_revealed ? 1 : 0, + rows.measure_marks_placed(), app.measure_labels.size(), + app.measures_list != nullptr ? gtk_widget_get_width(app.measures_list) : -1, + app.measures_list != nullptr ? gtk_widget_get_height(app.measures_list) : -1); + } std::printf("[udpscope] header: window_button_w=%d window_label_w=%d (bar_w=%d)\n", - app.window_button != nullptr ? gtk_widget_get_width(app.window_button) : -1, - app.window_label != nullptr ? gtk_widget_get_width(app.window_label) : -1, - app.window != nullptr ? gtk_widget_get_width(app.window) : -1); + app.dbg_win_btn_w, app.dbg_win_label_w, app.dbg_bar_w); std::printf("[udpscope] layout: rows=%d window_s=%.6g", rows.row_count(), rows.time_window()); for (int r = 0; r < rows.row_count() && r < RowSet::kMaxRows; ++r) {