Files
udpscope/src/plot_host.hpp
T

509 lines
29 KiB
C++

// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once
#include "model.hpp"
#include <plot/plot/figure.hpp>
#include <plot/plot/plot.hpp>
#include <array>
#include <map>
#include <memory>
#include <string>
namespace udpscope {
/// The stacked plots (spec item 4).
///
/// The rows are the rows of **one `plot::Figure`**, rendered by one
/// `plot::gtk4::PlotArea` in figure mode. The figure owns everything the rows have
/// to agree on, which is why it is one figure and not N plots:
///
/// - the x window, through a single x link group every row joins. A gesture on any
/// row changes that row's range, the group adopts it and pushes it to the others
/// (`Figure::sync_x_links()`, run from `layout()`), so there is no "which row
/// acted" to work out and nothing to propagate by hand;
/// - the live follow, which is the *group's* (`set_x_link_follow`). A member's own
/// axis must be `XFollow::Off` -- `Figure::link_x()` refuses a `Live` member --
/// because a following axis would reinterpret the pushed range as its own span and
/// offset behind its own edge, which is per-row drift by construction;
/// - the left gutter, so a vertical feature lands in the same pixel column in every
/// row (`Figure::layout()` lays each row out twice and imposes the maximum);
/// - the crosshair, placed on every row at one x by `PlotArea`'s figure-mode sync.
///
/// A row's `Plot` cannot drop a trace, so every row carries a fixed set of trace
/// slots created up front; assigning a signal to a slot is a rebind of that slot's
/// `SlotSeries`, and only a *name* change -- which `Plot` cannot express -- forces a
/// rebuild (see `row_dirty()`). A rebuild is of the whole figure: `Figure`'s row
/// count is fixed at construction, so there is one path for "a row's traces changed"
/// and "the row count changed", and it costs no GL state -- the area keeps its
/// context and is simply handed the new figure.
class RowSet {
public:
/// The ceiling on rows, not the count: rows are added and removed one at a time
/// (`insert_row()`, `remove_row()`) rather than picked off a list of fixed layouts.
/// A cap still exists because `storage_` is the rows' persistent state and has to be
/// allocated somewhere, and because the figure keeps a floor height per row -- past
/// 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;
/// Search hits drawn at once, per row. A scan can return thousands; a plot cannot grow its
/// cursor set, and sixteen lines over one window is already the most a reader can count. The
/// panel states how many of the hits it is showing.
static constexpr int kSearchMarks = 16;
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
/// `rebuild_all()`, which builds a new figure.
plot::Plot* plot = nullptr;
std::array<SlotSeries, kSlotsPerRow> slots;
/// Model channel per trace slot. Must start at -1: `std::array<Row, N>{}`
/// zero-initialises a `Row`, and a zero here reads as "slot 0 already holds
/// channel 0", which makes every drop look like the row is full.
/// What the slot plots, as a *stable identity*: a channel index is positional,
/// and enabling/disabling a signal or a new CONFIG renumbers the channel list,
/// so an index would silently start plotting a different signal. Empty name =
/// free slot.
std::array<std::string, kSlotsPerRow> signal_name;
/// The channel index `signal_name`/`element` resolved to, filled by `bind()`.
std::array<int, kSlotsPerRow> channel = {-1, -1, -1, -1};
/// The trace's legend name (the channel's display name), which is what the
/// plot was built with.
std::array<std::string, kSlotsPerRow> name;
/// The palette entry the slot's trace is drawn in. A *signal's* index, not the
/// slot's: a signal dropped on two rows has to be the same colour in both, or the
/// signal list's colour chip cannot name a trace. Supplied by the caller, because
/// only it knows the signal list this indexes into. A trace's colour is fixed at
/// `add_trace()`, which is why this is stored rather than resolved per frame --
/// changing it needs a rebuild, and `assign()` already forces one.
std::array<int, kSlotsPerRow> color_index = {0, 1, 2, 3};
plot::u32 axis_x = 0;
plot::u32 axis_y = 0;
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;
/// 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<plot::u32, kMeasureMarks> measure_h{};
std::array<plot::u32, kMeasureMarks> measure_v{};
/// The search hits' marks: the same arrangement as the measurement pool above, vertical only
/// (a hit is an instant, never a level).
std::array<plot::u32, kSearchMarks> search_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_b_y = 0.0;
};
int row_count() const noexcept { return rows_; }
/// Shows `rows` rows. A changed count rebuilds the figure, since a `Figure`'s row
/// count is fixed at construction.
void set_row_count(int rows);
/// Inserts an empty row at `at` (0 = above the first row, `row_count()` = below the
/// last), pushing the rows at and below it down. Returns false at `kMaxRows`.
///
/// The rows below move as *rows*: their trace assignments, colours and cursors follow
/// them, so inserting above row 1 does not renumber what row 1 was plotting. The
/// figure's `Plot`s are not what moves -- they are rebuilt and re-dealt -- which is
/// the same mechanism a row-count change already used.
bool insert_row(int at);
/// Removes row `at` and pulls the rows below it up. Returns false when only one row
/// is left: a figure with no rows has nothing to draw and no way back.
bool remove_row(int at);
Row& row(int i) { return storage_[static_cast<std::size_t>(i)]; }
const Row& row(int i) const { return storage_[static_cast<std::size_t>(i)]; }
/// The figure the rows live in. The host must be handed this again after every
/// `rebuild_all()`.
plot::Figure& figure() noexcept { return *figure_; }
/// Builds a new figure: one `Plot` per visible row with its axes, cursors and fixed
/// trace slots, all joined by one x link group.
void rebuild_all();
/// Rebuilds if any row's traces changed, and reports whether it did -- in which case
/// the caller re-points its host at `figure()`.
bool rebuild_if_dirty();
bool row_dirty(int i) const { return row_dirty_[static_cast<std::size_t>(i)]; }
/// Binds a row's trace slot to a signal element (`signal_name` empty frees it).
/// `color_index` is the signal's palette entry (see `Row::color_index`).
void assign(int row, int slot, std::string signal_name, std::string display_name,
int color_index);
/// Whether a slot plots anything.
bool slot_bound(int row, int slot) const;
/// The channel index a row/slot holds, or -1.
int channel_of(int row, int slot) const;
/// Rebinds every slot to what `mode` says it should show this frame. Called every
/// frame, because the channel list is renumbered by any structural change.
///
/// `mode` is passed in rather than read from the model, so that one frame's bind, axis
/// and status line all describe the same display: the producer can open or close a
/// capture between any two reads of `ScopeModel::display_mode()`.
void bind(ScopeModel& model, DisplayMode mode);
/// Publishes the live buffer's new samples.
void acquire(ScopeModel& model) { model.acquire_all(); }
/// Frames the newest `seconds` of the stream: the link group follows the live edge,
/// with its window ending on that edge (which is what resets any pan the user had).
void set_time_window(double seconds);
double time_window() const noexcept { return window_s_; }
/// The newest x any row's live traces hold, which is what the link group follows -- and, since
/// it is reported in the display epoch currently in force, also what the frame tick subtracts
/// from that epoch to park the newest sample at x = 0. It must be read **after** `bind()` and
/// `acquire()`: it asks the bound series, so before them it answers for the previous frame's
/// window, and a frame's production at 1 MS/s over a 50 ms view is a third of the screen.
/// False when no row holds a streaming trace.
bool live_edge(double& newest) const;
void set_y_auto(int row, bool on);
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' positions, in the **display** x the axis reads -- not stream
/// seconds, which they coincided with only while `display_epoch()` was always 0. A live view
/// is newest-relative now, so a cursor left at -1.5 s keeps meaning "1.5 s ago" and the data
/// flows under it, which is what a bench scope's roll mode does and is the behaviour that was
/// asked for. They are read back out of the plots and written to them unchanged, so there is
/// nothing to convert; anything that needs the stream's own clock goes through
/// `ScopeModel::stream_at_x0()`. 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_; }
/// Whether the x labels are currently written relative to the window's start, and what that
/// start was. The mode is decided by the span (see `update_time_labels()`), which is why it
/// is state rather than a per-frame expression: the switch has hysteresis, so a span
/// hovering on the boundary cannot make the labels flicker between two forms.
bool time_labels_relative() const noexcept { return relative_x_; }
/// The value the x labels are relative to (meaningless when `time_labels_relative()` is
/// false). The host prints it beside the axis so the reader can add it to a tick.
double time_labels_origin() const noexcept { return x_origin_; }
/// Chooses the x label form for the window the rows are showing and applies it: relative
/// with the window's own start as the origin when the span is sub-second, absolute
/// otherwise. Called once per frame from the tick, and returns true when the form changed.
bool update_time_labels();
/// 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_; }
/// Places the search hits' marks and parks the rest of the pool. `xs` are **display** x values
/// (the caller has already taken `stream_at_x0()` off a stream time), the same convention
/// `set_measure_marks()` takes a time mark in, and the marks are unselectable for the same
/// reason: a hit is where the scope found the condition, not something the pointer may move.
///
/// `current` indexes the one the navigation is standing on, which is drawn in its own colour, or
/// is negative for none. The caller orders `xs`; this places them in the order it is given.
void set_search_marks(const std::vector<double>& xs, int current);
/// How many search marks the last call placed, which is `min(xs.size(), kSearchMarks)`.
int search_marks_placed() const noexcept { return search_marks_placed_; }
/// Whether every instrument this app *computed* is out of the pointer's reach: the trigger
/// marker and the measurement marks. They are readouts -- where the scope put the edge, what a
/// measurement measured -- and a user must not be able to drag them; the report in the dump is
/// how that is checked, since a drag is not something a probe can perform.
bool readouts_locked() const noexcept;
/// How many times `clamp_to_data()` re-framed the view. A clamp that fires every frame is a
/// *loop*, not a clamp -- and `span > window_s_` can stay true after clamping to exactly
/// `window_s_`, because the group's span is the range's span through f64 and lands a ULP over.
/// Reported in the dump so a flickering view has a number rather than a description.
int clamp_fires() const noexcept { return clamp_fires_; }
/// The window the rows are actually on: the *group's* range, which is what the gestures and the
/// clamps write. A member's own range only catches up at the next layout, so a probe that reads
/// a member to judge a clamp sees the previous frame's window.
plot::Range view_range() const noexcept {
return figure_ ? figure_->x_link_range(x_group_) : plot::Range{0.0, 0.0};
}
/// Re-states the pinned window, so a gesture cannot move it.
///
/// In trigger mode the group follows nothing: `clamp_to_data()` is live-only by design, and the
/// library's own pan bound lives in the *following* path (a `Live` group's offset is clamped
/// against what its members retain), so with the follow off nothing pulled a drag back -- a pan
/// or a zoom-out walked the frozen capture off its axis and left an empty plot over data the
/// scope never had. The view is kept inside the event instead: the span is capped at the event's
/// and the position clamped into it, so a **zoom in is allowed** (less of the same event is
/// still the event) while leaving it is not. Doing it by re-stating the pin -- this function's
/// first version -- made the wheel a no-op, i.e. an acquired event could not be examined at all.
/// Does nothing when no window is pinned.
void hold_pinned_window();
/// Pins the axes where they are for the pause's frozen view: the snapshot is not a streaming
/// series, so live follow has nothing to derive a window from, and the frame the pause froze
/// has to stay the frame on screen. `unpin_pause_window()` restores following and re-frames on
/// the newest data, which is what a resume should show.
/// `lo`/`hi` are the *snapshot's* own stamps -- the data the frozen view actually holds, which
/// may be narrower than the window that was requested when the pause began.
void pin_pause_window(double lo, double hi);
/// The window the bound **event data** spans: the union of the event series the rows hold. What
/// a `Held` view may be navigated *within* -- the snapshot's stamps, never the recorded
/// `pre_s`/`post_s` split, which is the *request* and can exceed what was captured. False when
/// no row holds an event.
bool event_data_window(double& lo, double& hi) const;
/// Sets the pin without touching what is on screen: the bound a gesture is clamped into. The
/// `Held` tick re-states it every frame (the snapshot does not move, so it is idempotent) while
/// a user's zoom -- which is a narrower window *inside* the pin -- survives.
void set_data_pin(double lo, double hi);
void unpin_pause_window();
/// Undoes an x zoom: puts the view back on the whole time window, leaving every y axis alone.
/// The x counterpart of `fit_all()`.
///
/// **It does not change the time window** -- `window_s_` is read, never written. A fit is a
/// plot interaction, and the window is also the trigger's event span, the buffer partition and
/// the control's readout, so a fit that *framed the data* instead would redefine all of that
/// from a gesture (and re-plan the rings, dropping the history). What a user asks for here is
/// the zoom undone: a 2 s window zoomed into reads 2 s again.
///
/// Which 2 s depends on the state, and both cases restore the *whole* of what the view is
/// allowed to show: live, the newest window at the live edge; pinned (a held event or a paused
/// view), the pin's full range, with the pin kept -- `set_time_window()` would clear it and
/// re-frame on a live edge that a frozen view has nothing bound to.
void fit_x();
/// Whether a window is pinned: a held event, a filling capture or a paused view. The condition
/// `fit_x()` branches on, which is why the fit probe reports it.
bool window_pinned() const noexcept { return pinned_; }
/// Adopts the instruments a gesture is moving, on `drag_row` (the row whose controller holds
/// a cursor), and nothing otherwise.
///
/// Reading the plots is only meaningful *while a drag runs*: `cursor(id).x` reports the free
/// pointer position then, and the snapped one otherwise -- which is what this very code wrote
/// a frame earlier (or the snap of it). Adopting outside a drag therefore adopts our own echo
/// back and a row's stale copy of the shared time, which is what made a dragged cursor spring
/// to the position it had before the gesture. The vertical pair is one time for the whole
/// figure, so a drag on any row moves every row's; a ruler belongs to its own row alone.
/// Called once a frame from the tick, with `drag_row` < 0 when no cursor is held.
void sync_cursors(int drag_row);
/// 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
/// parks it when there is no event on screen.
void set_trigger_marker(double x, bool visible);
/// Keeps the shared x window inside the data the rings actually hold: zooming out
/// stops at the whole retained span. A time window is a view of the acquisition, so
/// there is nothing to see outside it -- zooming into empty time only loses the data
/// off the edge of the plot. Only the *span*: a following group clamps its own pan
/// against what its members retain (`Figure::place_live_window()`), while a wheel
/// notch multiplies the span and nothing in the library bounds that.
void clamp_to_data();
/// Pins the shared x window to the captured event: `[-pre, +post]` with the edge at
/// 0, so nothing outside the pre+post window is visible.
void pin_event_window(double pre_s, double post_s);
/// Fits every row's y axis to the data it holds, and puts x back on the whole time window --
/// `fit_x()`'s x half exactly, so the two cannot drift apart. Like it, **this does not change
/// the time window**: the y axes are fitted to their data, x is merely un-zoomed.
void fit_all();
/// Reports the left gutter the rows currently need, and reserves at least the width
/// of the widest *plausible* y label.
///
/// The gutter is measured from the y tick labels, and `TickFormat::Si` makes their
/// width change as the auto-fitted range moves (`1.5` becomes `15`, `150`, `1.5k`),
/// so a gutter that follows them drags the whole axes -- and with it the x axis --
/// left and right by a few pixels every frame. Since `set_min_y_gutter()` is a
/// floor and the library still takes the larger of the two, a floor smaller than
/// the labels cannot stop that; the reservation therefore covers the widest label
/// `format_si()` writes for an axis with no unit -- a sign, three significant figures
/// and a one-glyph prefix -- plus the label padding, measured once with the plot's own
/// font. Both reservations only ever grow, so the gutter is constant in practice.
///
/// The same metrics fix the **right** gutter, which is a want and not a measurement:
/// see `apply_gutters()`.
void note_y_gutter(double need_px);
/// Applies the stable left gutter as the figure's floor, and the right gutter beside
/// it. Call once per frame, before the frame is drawn.
///
/// Both have to be the *figure's* and not each row's: `Figure::layout()` clears every
/// row's `set_min_y_gutter()` to measure what its own labels need, so a floor written
/// onto the rows survives exactly until the next layout, and the rows have to end on
/// one column as well as start on one.
void apply_gutters();
/// The palette entry `index`, wrapped. Keyed by the *signal*, so that the colour a
/// trace is drawn in is the colour the signal list's chip shows, on every row the
/// signal appears in.
static plot::Color palette_color(int index);
/// The colour of a signal that is on no plot, which the signal list shows instead of
/// leaving its chip blank -- a blank chip is a narrower cell than a filled one, so the
/// names beside it shifted sideways as signals were placed.
static plot::Color unplotted_color();
/// The colour a signal is drawn in: the one the user chose for it if they chose one, the
/// positional palette entry otherwise.
///
/// **The one resolver**, used by `install()` for the trace and by the panels for their chips.
/// A chip exists to say which trace on screen a panel row is about, so a chip that resolved
/// a colour by a different route than the trace is the single failure the chip cannot
/// survive -- and an override is exactly the state in which two routes would disagree.
plot::Color signal_color(const std::string& signal, int palette_index) const;
/// Gives `signal` the colour `rgb` (0xRRGGBB), by *name*: a palette entry is positional, so an
/// override held by index would move to another signal on the next CONFIG. Marks every row that
/// plots it dirty, because a trace's colour is fixed at `add_trace()` -- a new colour is a new
/// figure, which is the same path `assign()` already takes.
void set_signal_color(const std::string& signal, plot::u32 rgb);
/// Back to the palette entry. A no-op, and no rebuild, when there was no override.
void clear_signal_color(const std::string& signal);
/// Every override, for the session file. Ordered by name, so a saved session's colour
/// tables do not reshuffle between writes.
const std::map<std::string, plot::u32>& signal_colors() const noexcept { return signal_colors_; }
private:
/// Marks every row with `signal` in a slot for rebuild. Not `assign()`: nothing about what
/// the slot *holds* changed, only the colour its trace has to be built with.
void mark_signal_dirty(const std::string& signal);
/// Moves one row's *persistent* state -- what it plots, in which colours, where its
/// cursors are -- onto another row. Deliberately not a `Row` assignment: a `Row` also
/// carries the figure handles (`plot`, the axis and cursor ids) and the `SlotSeries`
/// objects the traces point at, and those belong to the row's *position*, not to its
/// contents. `rebuild_all()` re-deals them straight after.
static void carry_row(Row& dst, const Row& src);
/// Back to an empty row: nothing plotted, the default palette entries, no cursors.
static void clear_row(Row& r);
void install(Row& r, int row_index, plot::Plot& p);
/// Writes a row's instruments from the stored values. `placement` is passed through to the
/// plot: a *programmatic* placement (reset, a mode change) wants the default `Snapped`, so
/// the marker lands on a sample; echoing a value a *drag* just produced wants `Free`, because
/// snapping it again is what made a dragged cursor spring back -- the library snaps a cursor
/// at the end of the gesture (`Controller::end_drag()`), and a value read out of the plot
/// mid-drag has already been through that act.
void place_cursors(Row& r, plot::CursorPlacement placement = plot::CursorPlacement::Snapped);
std::unique_ptr<plot::Figure> figure_;
/// The one x link group every visible row belongs to. Groups are numbered from 1.
plot::u32 x_group_ = 0;
int rows_ = 1;
double window_s_ = 2.0;
double trigger_marker_x_ = 0.0;
bool trigger_marker_on_ = false;
/// The event window currently pinned, so a rebuild can restate it instead of
/// re-framing on the live edge.
double pinned_pre_ = 0.0;
double pinned_post_ = 0.0;
bool pinned_ = false;
int clamp_fires_ = 0;
/// The window the pinned clamp last stated. A row's own axis that differs from this is a
/// gesture to be honoured; one that matches it is just the clamp's own answer arriving back.
plot::Range pinned_stated_{0.0, 0.0};
double y_gutter_px_ = 0.0;
/// The empty band kept to the right of the data, device pixels. Nothing is drawn in it,
/// so it is reserved once from the font's metrics and never measured or grown.
double right_gutter_px_ = 0.0;
bool y_gutter_reserved_ = false;
std::array<Row, static_cast<std::size_t>(kMaxRows)> storage_{};
/// The colours the user chose, by signal name (see `set_signal_color()`). Sparse on purpose:
/// an absent entry means "the palette decides", which is what keeps a new CONFIG's signals
/// coloured without anything having to seed them.
std::map<std::string, plot::u32> signal_colors_;
CursorMode cursor_mode_ = CursorMode::Vertical;
bool cursors_on_ = false;
bool relative_x_ = false;
double x_origin_ = 0.0;
/// What was last *written* to each row's own vertical cursors. A row is only evidence of a
/// gesture when its value differs from its own last write: the pair's value is shared, so a
/// row that has not been re-written yet still holds the previous shared value, and adopting
/// *that* would undo the row the user actually dragged -- which is what made a drag spring
/// back, one row's stale echo at a time.
int measure_marks_placed_ = 0;
int search_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_{};
};
} // namespace udpscope