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).
This commit is contained in:
Martino Ferrari committed 2026-11-05 17:58:59 +01:00
1 parent bfe13630f9
commit bdf0ff7ddc
39 files changed
+2267 -136

No files matched your search

+117 -1
View File
@@ -133,6 +133,13 @@ class RowSet {
/// 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{};
@@ -211,6 +218,18 @@ class RowSet {
/// 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.
@@ -322,6 +341,11 @@ class RowSet {
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);
@@ -369,12 +393,16 @@ class RowSet {
/// 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.
/// 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
@@ -494,6 +522,33 @@ class RowSet {
/// 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.
@@ -585,6 +640,46 @@ class RowSet {
/// 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);
@@ -596,6 +691,9 @@ class RowSet {
/// 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.
@@ -635,6 +733,17 @@ class RowSet {
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
@@ -648,6 +757,13 @@ class RowSet {
/// 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_{};
};