Initial commit

This commit is contained in:
Martino Ferrari committed 2026-10-01 15:42:48 +02:00
commit 8059b90de7
29 files changed
+7893

No files matched your search

+215
View File
@@ -0,0 +1,215 @@
// 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 <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:
static constexpr int kMaxRows = 4;
static constexpr int kSlotsPerRow = 4;
RowSet();
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;
/// The vertical line marking the trigger instant, drawn whenever an event is
/// on screen (and parked outside the axis otherwise). Not the user's cursor:
/// it keeps its position whatever the cursor toggle says.
plot::u32 trigger_marker = 0;
/// Off by default: the cursors are switched on from the toolbar, and a rebuild
/// (any drop, any row-count change) must not switch them back off.
bool cursors_visible = false;
double cursor_a_x = 0.0;
double cursor_b_x = 0.0;
double ruler_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);
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 it should show this frame: the model's live buffer,
/// or -- in trigger mode -- the captured event. Called every frame, because the
/// channel list is renumbered by any structural change.
void bind(ScopeModel& model);
/// 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_; }
void set_y_auto(int row, bool on);
void set_y_range(int row, double lo, double hi);
void set_cursors_visible(bool on);
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 re-frames the shared x window
/// on what row 0 retains (the same thing the plot's own `f` key does). The window
/// the toolbar reports becomes the fitted span, so the dropdown can say what is
/// actually on screen.
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()` can produce (three significant digits, a sign and a prefix, or the
/// `%.3g` fallback) plus the label padding, measured once with the plot's own font.
/// Both reservations only ever grow, so the gutter is constant in practice.
void note_y_gutter(double need_px);
/// Applies the stable gutter as the figure's floor. Call once per frame, before the
/// frame is drawn.
///
/// It has to be the *figure's* floor 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.
void apply_y_gutter();
/// 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();
private:
void install(Row& r, int row_index, plot::Plot& p);
void place_cursors(Row& r);
/// The newest x any row's live traces hold, which is what the group follows.
bool live_edge(double& newest) const;
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;
double y_gutter_px_ = 0.0;
bool y_gutter_reserved_ = false;
std::array<Row, static_cast<std::size_t>(kMaxRows)> storage_{};
std::array<bool, static_cast<std::size_t>(kMaxRows)> row_dirty_{};
};
} // namespace udpscope