added auto measuraments

This commit is contained in:
Martino Ferrari committed 2026-10-03 10:34:15 +02:00
1 parent 44fc4cdf36
commit 0eceaf537b
7 files changed
+2182 -55

No files matched your search

+101 -2
View File
@@ -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 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 aliased to a full-amplitude 125 Hz one, which looks entirely plausible on screen. This is
the only check that distinguishes averaging from striding. the only check that distinguishes averaging from striding.
- `[layout-probe] <action> rows=N buttons=B rows_y=[..] close=[..] add=[..] traces=[..]` - `[cursors-probe] <action> 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<std::string>` 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] <action> 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] <action> 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 (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 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 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 — `[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 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 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 -> 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). 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 - **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 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 `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`, 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 never a row: the area keeps a reference to the plot it was created with, and a rebuild
destroys every row. 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 - **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 `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 figure handles, so inserting above row 1 moves what row 1 plotted down to row 2 rather
+1
View File
@@ -41,6 +41,7 @@ add_executable(udpscope
src/plot_host.cpp src/plot_host.cpp
src/export.cpp src/export.cpp
src/layout_store.cpp src/layout_store.cpp
src/measure.cpp
src/ui.cpp src/ui.cpp
) )
+155 -9
View File
@@ -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, Samples cross the boundary through `plot::data::RingBuffer` (single producer,
single consumer), never through the lock. single consumer), never through the lock.
## Measurements
**Measurements** (overflow menu) reveals a pane with a `[+]` and one row per measurement:
`[badge] <type> <value> [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 ## The session layout file
What you arranged is remembered per data source, in 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 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 (`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 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 '<signal>' from plot N** entry per with auto scale, a fixed range, **Fit X axis**, **Fit all**, and one
occupied trace slot (a plot with nothing on it says so instead). **Remove '<signal>' 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 **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, 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 The two panels collapse vertically (`SLIDE_DOWN`), so hiding one gives its space
back instead of leaving an invisible gap in the column. 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` **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, 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 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 `time_stride` alone rebuilds nothing. Per-channel `stride` is still always 1 (see the
limitations below); only the shared clock is decimated. 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 **Fit to data** (overflow menu, the `f` key, or **Fit all** in a plot's right-click menu)
axis and re-frames the x window on the data the ring holds, propagated to every auto-scales every row's y axis and re-frames the x window on the data the ring holds,
row. The status line acknowledges it (`fitted to the buffered data (1.25 s)`). 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 **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. `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` 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. (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 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 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 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 hundred samples every fifth of a second made every frame span microseconds while
the frames were 100 ms apart). 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 <group> [--interface <local ip>]` 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 ## Limitations
- **Multi-element (vector/matrix) signals are acquired with a zero-order hold.** - **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 - **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 buffers (and drops the history). Applying it at read time would need the LOD
pyramid to be gain-aware. pyramid to be gain-aware.
- **Cursors are per row** and not synchronised with each other; show/hide parks - **The horizontal rulers are per row**, since a y value means nothing without the
them outside the axis range rather than removing them, because `Plot` has no y axis it is on; the vertical pair is shared, so the same two times are on every
`remove_cursor`. The trigger marker is the one exception: it has its own cursor row. Show/hide *parks* every instrument outside the axis range rather than removing
and is independent of the cursor toggle. 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 - **Verified headless only.** Acquisition, partitioning, the trigger state
machine and its integration with the buffers, and rendering under Xvfb machine and its integration with the buffers, and rendering under Xvfb
(llvmpipe) were exercised; the interactive paths (drag and drop, the dialogs, (llvmpipe) were exercised; the interactive paths (drag and drop, the dialogs,
the right-click menu) were not driven by an automated test. 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.
+1 -1
+227 -24
View File
@@ -26,6 +26,10 @@ constexpr plot::u32 kUnplottedColor = 0x6E6E6Eu;
constexpr plot::u32 kCursorAColor = 0xFFD24Fu; constexpr plot::u32 kCursorAColor = 0xFFD24Fu;
constexpr plot::u32 kCursorBColor = 0x7FE0FFu; constexpr plot::u32 kCursorBColor = 0x7FE0FFu;
constexpr plot::u32 kRulerColor = 0xB0B0B0u; 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 plot::u32 kTriggerColor = 0xFF5B5Bu;
constexpr double kParked = -1.0e12; ///< hides an instrument without removing it 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.name = src.name;
dst.channel = src.channel; dst.channel = src.channel;
dst.color_index = src.color_index; 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_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 // 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 // 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 // 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.name = {};
r.channel = {-1, -1, -1, -1}; r.channel = {-1, -1, -1, -1};
r.color_index = {0, 1, 2, 3}; 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; r.ruler_y = 0.0;
for (SlotSeries& s : r.slots) s.bind(nullptr); 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_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.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 = 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<std::size_t>(s)] = r.plot->add_annotation(a);
r.crossing_b[static_cast<std::size_t>(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<std::size_t>(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)); 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 // 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. // 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) { void RowSet::place_cursors(Row& r) {
if (!r.plot) return; if (!r.plot) return;
const bool show = r.cursors_visible; const bool on = cursors_on_;
const double a = show ? r.cursor_a_x : kParked; // Which instruments the mode uses; the rest are parked (a `Plot` cannot drop a
const double b = show ? r.cursor_b_x : kParked; // cursor, so hiding is the only way to take one off screen).
const double h = show ? r.ruler_y : kParked; const bool vertical = on && cursor_mode_ != CursorMode::Horizontal;
r.plot->set_cursor_x(r.cursor_a, a); const bool horizontal = on && (cursor_mode_ == CursorMode::Horizontal ||
r.plot->set_cursor_x(r.cursor_b, b); cursor_mode_ == CursorMode::Both);
r.plot->set_cursor_y(r.ruler, h); 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<std::size_t>(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<plot::u32>(s), cursor_a_x_, ya);
const bool has_b = mix && r.plot->crosshair_value(static_cast<plot::u32>(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<std::size_t>(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<std::size_t>(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<MeasureMark>& marks) {
measure_marks_placed_ = static_cast<int>(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<std::size_t>(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<std::size_t>(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<std::size_t>(h)], kParked);
}
for (; v < kMeasureMarks; ++v) {
r.plot->set_cursor_x(r.measure_v[static_cast<std::size_t>(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() { 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) { void RowSet::set_cursors_visible(bool on) {
for (int i = 0; i < kMaxRows; ++i) { cursors_on_ = on;
Row& r = row(i); for (int i = 0; i < kMaxRows; ++i) place_cursors(row(i));
r.cursors_visible = on;
place_cursors(r);
}
} }
void RowSet::reset_cursors() { void RowSet::reset_cursors() {
@@ -455,13 +653,18 @@ void RowSet::reset_cursors() {
if (!r.plot) continue; if (!r.plot) continue;
const plot::Range xr = r.plot->x_range(r.axis_x); const plot::Range xr = r.plot->x_range(r.axis_x);
const double span = xr.span() > 0.0 ? xr.span() : window_s_; const double span = xr.span() > 0.0 ? xr.span() : window_s_;
r.cursor_a_x = xr.min + 0.25 * span; cursor_a_x_ = xr.min + 0.25 * span;
r.cursor_b_x = xr.min + 0.75 * span; cursor_b_x_ = xr.min + 0.75 * span;
r.ruler_y = r.plot->y_range(r.axis_y).min + const double y_lo = 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() const double y_span = r.plot->y_range(r.axis_y).span();
: 1.0); r.ruler_y = y_lo + 0.35 * (y_span > 0.0 ? y_span : 1.0);
place_cursors(r); 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 } // namespace udpscope
+102 -5
View File
@@ -48,9 +48,27 @@ class RowSet {
/// about this many a row is chrome and no plot. /// about this many a row is chrome and no plot.
static constexpr int kMaxRows = 8; static constexpr int kMaxRows = 8;
static constexpr int kSlotsPerRow = 4; 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(); 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 { struct Row {
/// The figure's row, borrowed. Null for a row beyond `row_count()`, which is how /// 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 /// 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_a = 0;
plot::u32 cursor_b = 0; plot::u32 cursor_b = 0;
plot::u32 ruler = 0; plot::u32 ruler = 0;
plot::u32 ruler_b = 0;
/// The vertical line marking the trigger instant, drawn whenever an event is /// 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: /// on screen (and parked outside the axis otherwise). Not the user's cursor:
/// it keeps its position whatever the cursor toggle says. /// it keeps its position whatever the cursor toggle says.
plot::u32 trigger_marker = 0; 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. /// The measurements' readout marks: a pool created with the plot (a `Plot` cannot add or
bool cursors_visible = false; /// drop a cursor after the fact) and parked unless a measurement is using the slot.
double cursor_a_x = 0.0; std::array<plot::u32, kMeasureMarks> measure_h{};
double cursor_b_x = 0.0; std::array<plot::u32, kMeasureMarks> 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<plot::u32, kSlotsPerRow> crossing_a{};
std::array<plot::u32, kSlotsPerRow> 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_y = 0.0;
double ruler_b_y = 0.0;
}; };
int row_count() const noexcept { return rows_; } 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_y_range(int row, double lo, double hi);
void set_cursors_visible(bool on); 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<MeasureMark>& 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(); void reset_cursors();
/// Places the trigger marker at stream time `x` (seconds since the epoch), or /// 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; double y_gutter_px_ = 0.0;
bool y_gutter_reserved_ = false; bool y_gutter_reserved_ = false;
std::array<Row, static_cast<std::size_t>(kMaxRows)> storage_{}; std::array<Row, static_cast<std::size_t>(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<bool, static_cast<std::size_t>(kMaxRows)> row_dirty_{}; std::array<bool, static_cast<std::size_t>(kMaxRows)> row_dirty_{};
}; };
+1595 -14
View File
File diff suppressed because it is too large. Load diff