// SPDX-License-Identifier: MIT // SPDX-FileCopyrightText: 2026 Martino Ferrari #pragma once #include "model.hpp" #include #include #include #include #include #include #include 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 slots; /// Model channel per trace slot. Must start at -1: `std::array{}` /// 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 signal_name; /// The channel index `signal_name`/`element` resolved to, filled by `bind()`. std::array 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 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 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 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 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 measure_h{}; std::array 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 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 crossing_a{}; std::array 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(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& 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 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 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 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& 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(i)]; } const Row& row(int i) const { return storage_[static_cast(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(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> 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& 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& 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& 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 signal_name; std::array name; std::array channel; std::array color_index; std::array layer; std::array 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 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 cells_per_row_{1}; std::vector> cell_weights_ = std::vector>(1); void apply_cell_weights(); float trace_alpha_ = 1.0f; std::vector 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(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 signal_colors_; /// Reused across frames by `set_invalid_spans()`: see there. std::vector 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(kMaxRows)> saved_row_states_{}; std::vector saved_cells_; std::vector> saved_cell_weights_; std::vector 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(kMaxRows)> row_dirty_{}; }; } // namespace udpscope