Files
udpscope/src/plot_host.hpp
T
Martino Ferrari bdf0ff7ddc scope: full-screen figures, second windows, gated measurements, per-configuration events
A batch of view and instrument work, with the probes and docs for each.

- **Full screen per figure row**: a toggle on each row's top-right corner, revealed by
  pointer proximity (`kMaximizeRevealPx` around its placed rect, per row, read from an
  overlay-level motion controller so a revealed button under the pointer cannot flicker);
  the plot menu offers the same as *Full screen this figure* / *Restore the layout*. The
  arrangement is saved and restored through `RowState`, so nothing plotted moves. A layout
  of one figure carries no toggle.
- **Second window**: `open_scope_window()` copies a figure (cells, width shares, signals in
  their colours) or opens an empty one, with its own `RowSet` and `PlotArea`; the main
  window's tick binds, acquires and repaints it, mirroring the time window and pin. Entries:
  the plot menu's *Open this plot in a new window* and the overflow menu's *New scope window*.
- **Layout editor from the plot**: the plot menu gains *Edit the layout...* / *Done editing
  the layout*, flipping the app's one toggle.
- **Measurements gate**: `MeasureOptions::gate` restricts a measurement to the held/pause
  snapshot (live falls back to the live ring), persisted with the session and shown in the
  measure dialog.
- **Crosshair axis badges**: a preference (`crosshair_axis_labels`, on by default) and the
  plotter's badges where the lines meet the axes, in the axes' own formats; the plot menu
  and the probe read them.
- **Event cache per configuration**: `EventStore` is keyed `<source>/<configuration>/`,
  `retarget_event_store()` follows every configuration switch, and a pre-configuration cache
  (loose files) is adopted into the configuration's folder on first start.
- **GTK4 lifecycle**: the main window's handlers move from the non-existent `"destroy"`
  signal to `close-request`; scope windows are closed with it and the application quits
  explicitly (the exit run's hang). The exit probe closes with `gtk_window_close()`.
- Probes: `[maximize-probe]` (single-figure rule and the corner reveal), `[scope-probe]`,
  `[split-probe]` cell crosshair leg, `[prefs-probe]` store keying, `[configs]` store key,
  `[rowmenu]` layout-editor entry; docs, help pages, README and AGENT.md updated.

Also fixes a probe-only use-after-free: `toggle_maximize_figure_row` replaces the figure
object, so the area must be re-pointed (the button's path does; the probe's raw call did not).
2026-11-05 17:58:59 +01:00

771 lines
45 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>
#include <vector>
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.
/// The signal a channel name belongs to, with a group's layer suffix taken back off: `sine (A)`,
/// `sine (B)` and `sine #2` are all the signal `sine`. A comparison's model holds one channel per
/// (signal, layer), so this is what lets the signal list show each signal once, draw every layer of it
/// in the signal's own colour, and stop a second group from re-suffixing a name a first one left.
std::string layer_base_name(const std::string& name);
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;
/// Traces one row can hold. Eight rather than the four a hand-arranged scope usually wants, because
/// the *default* layout is four signals in one row and an accumulation needs a slot per extra event:
/// with four, "accumulate these two events" over the layout the app starts with had nowhere to put
/// the second copy and silently did nothing. The free slots cost nothing -- a `Plot` creates its
/// traces up front either way -- and the user's own traces stay where they are.
static constexpr int kSlotsPerRow = 8;
/// 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};
/// The *persistence layer* this slot draws: 0 for a signal's own trace (whatever the frame's
/// display mode binds it to), `a` > 0 for the acquisition `a` latches back, drawn at
/// `slot_alpha`. A layer slot holds a suffixed name (`sine (A)`) and no channel at all -- it is
/// not a signal, and `channel[]` stays -1 for it (see `assign_layer()`).
std::array<int, kSlotsPerRow> layer = {0, 0, 0, 0};
/// How bright the slot's trace is built: 1 for a signal's own trace, `persist_alpha(layer)` for
/// a persistence layer, multiplied into `trace_alpha_`. It lives on the slot because a `Plot`
/// fixes a trace's colour -- alpha included -- at `add_trace()`, so a per-layer brightness is a
/// property of the trace's *creation*, which is what a rebuild is for.
std::array<float, kSlotsPerRow> slot_alpha = {1.0f, 1.0f, 1.0f, 1.0f};
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 measurement gate: one shaded interval per plot, whose bounds are the gate's own
/// pair of instruments -- not the cursors above, which stay what the cursors pane reads.
/// Every row carries its own `Region` because each plot shades its own axes rect, but
/// they all draw the one shared interval (`gate_x0_`/`gate_x1_` below), the way the
/// vertical cursors are one time on every row.
plot::u32 gate_region = 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;
};
/// The number of **plots** (cells over all figure rows), which is what `row(i)` indexes. With no
/// row split it is also the number of figure rows; `figure_row_count()` is the latter.
int row_count() const noexcept { return rows_; }
/// A figure row holds one to `kMaxCellsPerRow` plots side by side, sharing the row's height and
/// the figure's one x window. The flattened plot index (`row(i)`, `Row::plot`, the figure's
/// `plot(i)`) runs row-major: row 0's cells left to right, then row 1's.
static constexpr int kMaxCellsPerRow = 4;
int figure_row_count() const noexcept { return static_cast<int>(cells_per_row_.size()); }
/// How many cells figure row `fr` holds; 0 for a row that does not exist.
int cells_in_row(int fr) const;
/// The flattened index of figure row `fr`'s first cell; `row_count()` for `fr ==
/// figure_row_count()`.
int first_plot_of_row(int fr) const;
/// The figure row plot `plot` is in, or -1 for an index that is not a plot.
int figure_row_of(int plot) const;
/// Plot `plot`'s 0-based position inside its row, or -1.
int cell_of(int plot) const;
/// Figure row `fr`'s cell width shares (empty means equal; always empty for a single-cell row).
const std::vector<float>& cell_weights(int fr) const;
/// What the UI calls a plot: the figure row's number (`"2"`) for a single-cell row, the number
/// and a letter (`"2a"`..`"2d"`) for a split one.
std::string plot_label(int plot) const;
/// Adds an empty cell to figure row `fr` at position `at` (clamped to the row), pushing the
/// cells at and after it right. False at `kMaxCellsPerRow` cells or `kMaxRows` plots.
bool insert_cell(int fr, int at);
/// Removes plot `plot`. A single-cell row removes the row itself (`remove_row()`); false when
/// that would leave no plot.
bool remove_cell(int plot);
/// Figure row `fr`'s cell width shares, normalised by the plotter; empty means equal.
void set_cell_weights(int fr, std::vector<float> w);
/// Replaces the whole arrangement: `cells[r]` plots in figure row `r`, each clamped to
/// `1..kMaxCellsPerRow` and the total to `kMaxRows`. Weights are reset; plot contents stay in
/// their flattened slots.
void set_cells_per_row(std::vector<int> cells);
/// The alpha every trace is drawn with. The *group* views use it: accumulating events wants their
/// traces translucent so that agreement between them reads as brightness, which is the whole point
/// of a persistence display. It is mixed into the palette colour where the trace is created, so it
/// belongs with `assign()` -- set it before the assignments that follow it, or they keep the alpha
/// the previous view left behind.
void set_trace_alpha(float alpha) noexcept { trace_alpha_ = alpha; }
/// The rows' shares of the figure's height, normalised by the plotter; an empty vector means equal
/// rows. Kept here, and re-applied after every rebuild: a `Figure` is built afresh whenever a row's
/// trace set or the row count changes, so weights that lived only in the figure would be lost on the
/// first drop or row change after a user had sized their layout.
void set_row_weights(std::vector<float> weights);
/// Sizes the stored weights to the figure rows that exist and hands them to the figure. Weights
/// are per **figure row**, not per plot.
void apply_row_weights();
const std::vector<float>& row_weights() const noexcept { return row_weights_; }
/// Shows `rows` figure rows of one cell each (so `rows` plots, any split undone). A changed
/// arrangement rebuilds the figure, since a `Figure`'s shape is fixed at construction.
void set_row_count(int rows);
/// One figure row filling the plot area, and back -- the "full screen this row" toggle.
///
/// The arrangement, the weights and **every row's state** are saved on the way in and
/// restored on the way out, so a maximize is a *view*: nothing a user plotted moves, and
/// nothing survives a restore that was not there before it. Volatile like a zoom -- the
/// session keeps the arrangement, not the view. Calling it while maximized restores, so a
/// click on the eye-catching button is its own way back; a figure row outside the current
/// arrangement (the maximized one, by definition a single row) is ignored when not
/// maximized.
void toggle_maximize_figure_row(int fr);
bool figures_maximized() const noexcept { return maximized_; }
/// Inserts an empty single-cell figure row at `at` (0 = above the first row,
/// `figure_row_count()` = below the last), pushing the rows at and below it down with their
/// cells. Returns false at `kMaxRows` plots.
///
/// 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 figure row `at` (with all its cells) 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;
// --- persistence layers ---------------------------------------------------
/// The brightness a persistence layer is drawn at, `a` acquisitions back: the acquisition on
/// screen is the full-bright one and each older one is **half** the previous, so the schedule is
/// exponential in age -- 1, 0.5, 0.25, 0.125, ... and by the seventh (0.008, `kMaxPersistDepth`)
/// the layer is gone. Halving is per acquisition and not per *depth*, so changing N re-cuts how
/// many are kept without rescaling the ones already on screen.
static float persist_alpha(int age) noexcept;
/// Binds a row's slot to a persistence *layer*: the acquisition `age` latches back, at `alpha`
/// (see `Row::layer`). The name carries the layer's suffix (`sine (A)`, `sine #4`), which is what
/// keeps a layer out of the signal list and out of the measurements -- it is not a signal -- while
/// `signal_color()` still resolves it to the base signal's own colour.
void assign_layer(int row, int slot, std::string signal_name, std::string display_name,
int color_index, int age, float alpha);
/// Frees every slot bound to a layer. What turning persistence off -- or the trigger -- is.
void clear_layers();
/// Lays the persistence layers out over the rows' free slots, one per (signal, age): the signal's
/// own trace stays where it is and its layers take the free slots of the *same* row, layer by
/// layer (age 1 for every signal first, then age 2, as the accumulate path does), so a row that
/// runs out of room draws as many layers as it has room for and no more. Every slot the plan does
/// not fill is freed, which is what makes the display follow a layout change, a depth change and a
/// signal's removal without any caller having to reset anything.
///
/// Returns the number of layers placed; `want` is how many the depth asked for, so a caller can
/// say when a layout could not show them all.
int sync_layers(int depth, int* want);
/// The layer a slot is drawing, 0 for a signal's own trace.
int layer_of(int row, int slot) const;
/// The slot's own brightness (1 unless it is a layer).
float slot_alpha_of(int row, int slot) const;
/// How many slots are layers, over every row: the reading that says the layers exist.
int layer_slots() const;
/// The layers of one row, oldest first, as `(age, alpha)` pairs -- the ordering a reader has to
/// see to know the decay is the right way round.
std::vector<std::pair<int, float>> row_layers(int row) 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 `model.holes()` to every row as shading, in display coordinates.
///
/// Separate from `bind()` and called *after* the display epoch has been written, because the
/// epoch the band needs is this frame's: `ui.cpp` recomputes it from the newly bound traces'
/// live edge, and the previous frame's value is a whole production interval stale -- a third
/// of the screen at 1 MS/s over a 50 ms window, which would put the band beside the gap
/// rather than over it.
///
/// Shading only in `Live` and `Capturing`. A `Held` or `Paused` snapshot carries its own
/// stamps and its own x offset, and the live hole table does not address it; shading it would
/// accuse whichever samples happened to land under those coordinates.
void set_invalid_spans(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);
/// The crosshair's axis badges (the y and x values the crosshair's lines meet the axes at,
/// drawn in the label strips): one flag for the whole set, restated to every new plot by
/// `install()` -- a rebuilt row keeps the setting, the way it keeps the cursor mode.
void set_crosshair_axis_labels(bool on);
bool crosshair_axis_labels() const noexcept { return crosshair_axis_labels_; }
/// 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. A time mark's `label` is the badge
/// text the plot draws beside it (its own time, the pane's formatter), so a column of
/// marks reads as numbers and not as bare lines; a level ruler writes its own value, so
/// its label stays empty.
struct MeasureMark {
int row = 0;
bool level = false;
double value = 0.0;
plot::u32 color = 0;
std::string label;
};
/// 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_; }
/// The pin's own extent, in the x the rows' axes read -- what a pinned view is a view *of*.
/// The one expression of it, shared by the framing (`hold_pinned_window()`, `fit_x()`) and by
/// the measurement pane, which measures *this* while the view is pinned so that a gesture
/// changes no number (see `update_measures()`); meaningless when `window_pinned()` is false.
plot::Range pinned_range() const noexcept { return {-pinned_pre_, pinned_post_}; }
/// True when the pin already *is* this window. A caller that re-states a pin every frame -- the file
/// view does, from the ring's own extent -- uses this to update the pin's numbers with
/// `set_data_pin()` instead of writing the group's range again with `pin_event_window()`, which would
/// undo a gesture that `hold_pinned_window()` has not adopted yet.
bool pinned_window_is(double pre_s, double post_s) const noexcept {
return pinned_ && pinned_pre_ == pre_s && pinned_post_ == post_s;
}
/// 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);
// --- the measurement gate -------------------------------------------------
/// Shows (or hides) the gate on every row. Bounds that were never seeded are seeded
/// first (`reset_gate()`), so the first enable puts the band under the data instead of
/// parking it wherever zero is.
void set_gate_visible(bool on);
/// Whether the gate is on. One flag for the whole set, like the cursors' own: a per-row
/// copy is something a rebuild can clear behind the user's back.
bool gate_visible() const noexcept { return gate_on_; }
/// The gate's bounds, in the **display** x the axes read -- the cursors' own coordinate
/// space. A live view is newest-relative, so a gate left at `[-0.5, -0.1]` keeps meaning
/// "half a second ago to a tenth of a second ago" and the data flows under it; a held
/// event's stamps are edge-relative, so there the band names a fixed slice of the frozen
/// frame. Either way it stays where it was put, which is what a gate has to do.
double gate_x0() const noexcept { return gate_x0_; }
double gate_x1() const noexcept { return gate_x1_; }
/// Restates the gate's bounds (ordered, both finite) and redraws every row.
void set_gate(double x0, double x1);
/// Puts the gate over the middle half of the current x window -- where
/// `reset_cursors()` puts the vertical pair -- and remembers it as seeded.
void reset_gate();
/// Adopts the gate bounds a gesture is moving, on `drag_row` (the row whose controller
/// holds a region), and mirrors them to every other row. Same contract as
/// `sync_cursors()`: reading outside a drag reads our own echo. Called once a frame from
/// the tick, with `drag_row` < 0 when no region drag is open.
void sync_gate(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);
/// One row's persistent state, without the plot-bound `slots`: a `SlotSeries` owns a
/// `unique_ptr`, so a `Row` is not copy-assignable, and the state a save needs is exactly
/// what `carry_row()` copies. The maximize toggle saves it for every row on the way in and
/// re-applies it on the way out, so a view change moves no signal.
struct RowState {
std::array<std::string, kSlotsPerRow> signal_name;
std::array<std::string, kSlotsPerRow> name;
std::array<int, kSlotsPerRow> channel;
std::array<int, kSlotsPerRow> color_index;
std::array<int, kSlotsPerRow> layer;
std::array<float, kSlotsPerRow> slot_alpha;
double ruler_y = 0.0;
double ruler_b_y = 0.0;
};
static RowState row_state_of(const Row& r) {
RowState s;
s.signal_name = r.signal_name;
s.name = r.name;
s.channel = r.channel;
s.color_index = r.color_index;
s.layer = r.layer;
s.slot_alpha = r.slot_alpha;
s.ruler_y = r.ruler_y;
s.ruler_b_y = r.ruler_b_y;
return s;
}
static void apply_row_state(Row& r, const RowState& s) {
r.signal_name = s.signal_name;
r.name = s.name;
r.channel = s.channel;
r.color_index = s.color_index;
r.layer = s.layer;
r.slot_alpha = s.slot_alpha;
r.ruler_y = s.ruler_y;
r.ruler_b_y = s.ruler_b_y;
// What it was bound to belonged to the row this state came *from*; the tick's `bind()`
// re-resolves every slot next frame, and clearing stops the frame in between from
// drawing the moved row's data under the new row's legend (the `carry_row()` rule).
for (SlotSeries& slot : r.slots) slot.bind(nullptr);
}
/// 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);
/// Writes a row's gate region from the shared bounds -- parked outside the axis when the
/// gate is off, the cursors' own convention for taking an instrument off screen.
void place_gate(Row& r);
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;
/// The number of plots; `sum(cells_per_row_) == rows_` always.
int rows_ = 1;
/// Cells per figure row, and each row's cell width shares (empty = equal; kept empty for a
/// single-cell row). Both are state of the `RowSet`, restated to every new figure.
std::vector<int> cells_per_row_{1};
std::vector<std::vector<float>> cell_weights_ = std::vector<std::vector<float>>(1);
void apply_cell_weights();
float trace_alpha_ = 1.0f;
std::vector<float> row_weights_; ///< per figure row
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_;
/// Reused across frames by `set_invalid_spans()`: see there.
std::vector<plot::InvalidSpan> spans_;
CursorMode cursor_mode_ = CursorMode::Vertical;
bool cursors_on_ = false;
bool crosshair_axis_labels_ = true;
/// The maximize toggle's saved arrangement (see `toggle_maximize_figure_row()`): every
/// row's persistent state, the cells, the weights and the plot count, kept only while
/// `maximized_`. States, not `Row`s: a `Row` is not copyable (its slots own a
/// `unique_ptr`) and the figure handles belong to the position anyway.
bool maximized_ = false;
std::array<RowState, static_cast<std::size_t>(kMaxRows)> saved_row_states_{};
std::vector<int> saved_cells_;
std::vector<std::vector<float>> saved_cell_weights_;
std::vector<float> saved_row_weights_;
int saved_rows_ = 0;
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;
/// The gate's shared interval and its on/off flag (`gate_visible()`). `gate_seeded_`
/// distinguishes "never placed" from "placed at the window's own origin", which the
/// first enable needs: seeding must put the band over the data, not over x = 0.
bool gate_on_ = false;
bool gate_seeded_ = false;
double gate_x0_ = 0.0;
double gate_x1_ = 0.0;
std::array<bool, static_cast<std::size_t>(kMaxRows)> row_dirty_{};
};
} // namespace udpscope