Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
655 lines
39 KiB
C++
655 lines
39 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 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);
|
|
/// 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);
|
|
/// 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_; }
|
|
|
|
/// 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);
|
|
|
|
/// 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;
|
|
/// 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 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
|