Files
udpscope/src/layout_store.hpp
T
Martino Ferrari ac054a30c9 fix: bound the density grid, and make [notrace] a reading instead of a flood
The plotter caps a density grid at kDensityCellCap now (deps/plotter), so a 2 s
window with persistence on no longer scales the image with the window: the
probe's four one-signal rows read 1 044 714 cells / 4 178 856 bytes where they
read 2 226 150 / 8 904 600, and a moved frame uploads 1 044 714 bytes where it
uploaded 2 226 150. The probe's own legs are unchanged (on peak=1.0000,
step-acquisitions peak=1.5000, step-time ratio=0.367879).

The paint probe no longer counts a frame the density is drawing as *traceless*.
`Renderer::draw_traces()` returns while the ink is the image, so every frame of
a persistence display stages no trace: the dump read `traceless paints: 653
(637 after the first bind)` for a run whose real figure is 4 (0), and the
`[notrace]` line printed for each of those frames. It is now behind
UDPSCOPE_PROBE_NOTRACE -- a per-paint line is a log flood and not a reading --
while the counters it details stay in every build, and the line stays reachable
for the state it was written for (a bound row that draws nothing).

Docs corrected where they stated the old rule: the README, the trigger
invariants, the density probe's readings, the verification list (the new
variable) and the manual page behind the panel's tooltip.
2026-10-16 21:09:11 +02:00

229 lines
12 KiB
C++

// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once
#include <cstdint>
#include <string>
#include <vector>
#include "math_channel.hpp"
#include "measure.hpp"
namespace udpscope {
/// The user-arranged part of a session, persisted per data source in
/// `~/.config/udpscope/<host>_<port>.toml`: the display window, the plot layout
/// (rows and which signal each of a row's slots holds) and the per-signal
/// calibration. Signal names are the stable identity (channel indexes are
/// renumbered by every enable/disable, see the model's own invariant).
struct LayoutState {
struct Row {
/// One entry per slot; an empty string is an empty slot.
std::vector<std::string> signals;
/// The row's share of the figure's height, 1 for equal rows. Part of the layout because a sizing a
/// user dragged is an arrangement, and an arrangement that is not saved is one that is lost.
double weight = 1.0;
};
double window_s = 0.0; ///< 0 keeps the default window
std::vector<Row> rows;
struct Calibration {
std::string name;
double gain = 1.0;
double offset = 0.0;
};
std::vector<Calibration> calibration;
/// The trace colours the user chose, `0xRRGGBB`. Only the *overrides*: a signal with no entry
/// takes the positional palette entry, which is what keeps a stream's new signals coloured
/// without a session having to name them. Saved by name like every identity here.
struct Color {
std::string name;
std::uint32_t rgb = 0;
};
std::vector<Color> colors;
/// Signals the session had enabled. Enabling is what starts acquisition, so this is what makes a
/// reopened scope look like the one that was closed -- and it is saved by *name*, like every other
/// identity here, because a CONFIG renumbers the channels.
std::vector<std::string> enabled;
/// How this source was reached, so the startup chooser can reconnect to it exactly. The file is
/// named `<host>_<port>.toml` and that name can say neither of these. Empty `multicast` is
/// unicast; empty `interface_addr` is the default route.
std::string multicast;
std::string interface_addr;
/// The derived channels, graphs and all: the editor's work is part of the session, not of the
/// process. A graph is stored as one string per node so the file stays in this store's TOML
/// subset (tables, string arrays, scalars) rather than growing a nested-table parser for it; see
/// `math_node_text()` for the field order.
std::vector<MathChannel> math;
/// Whether the measurements pane shows its statistics line. The only piece of the
/// statistics that is persisted: the accumulated figures themselves describe a session's
/// stream and mean nothing restored against another one.
bool measure_stats = false;
/// Which sidebar tab was showing: "measure" or "signals". The one thing the panel persists --
/// no revealer states remain, and the 320 px width is the `.ui`'s.
std::string side_tab = "signals";
/// The measurements pane, by signal *name* and with the type and options a row was built with.
std::vector<Measure> measures;
/// The trigger: its settings *and* whether trigger mode was on. Both, and the settings even while
/// it is off, because a trigger set up once and switched off is still the trigger you come back to
/// -- and the pane's locked view is built from exactly this.
///
/// The signal and the two enumerations are stored as *text*: the signal by name, because every
/// rebuild renumbers the channels (the model's own rule), and the shapes by the words the panel's
/// dropdowns show, because an index would tie this file to a declaration order -- which the math
/// channel's op index already taught this store once.
struct Trigger {
bool enabled = false;
std::string channel; ///< the acquired channel's name; empty is "none chosen"
std::string type = "edge"; ///< edge | pulse width | window | dropout
std::string edge = "rising"; ///< rising | falling | both
std::string cmp = "greater"; ///< pulse width: `<` | `>` | within | outside
std::string mode = "normal"; ///< normal | single
double level = 0.0;
double level2 = 0.0; ///< window: the band's other edge
double width_s = 1.0e-3;
double width2_s = 2.0e-3;
double timeout_s = 1.0e-3;
double pretrigger_pct = 50.0;
double holdoff_s = 0.010;
double noise_band = 0.0;
double hf_fc = 0.0;
/// How many of the most recent acquisitions the trigger mode keeps on screen (1 = the event
/// alone). Part of the trigger's own setup, like the window it captures and the shape it waits
/// for: it is what the display does with the acquisitions, and there is no second switch.
///
/// This is the **multiple event** display -- N retained acquisitions, drawn as N traces. The
/// phosphor density beside it is a different display with a different cost (one bounded image a
/// trace against N window copies), which is why it has its own four fields below and why this
/// one keeps the key it has always had: an older file's `trigger_persistence` is still the
/// multiple-event depth.
int persistence = 1;
/// The **phosphor density** (`TriggerConfig::density` and the three beside it): whether the
/// trigger-mode display accumulates every acquisition into one image, what it fades by, and the
/// constant or factor that fade uses. Persisted because it is a display the user set up and
/// comes back to -- the trigger settings around it already are.
bool density = false;
/// "acquisitions" | "time": the same two words the panel's dropdown shows, for the reason the
/// shapes above are stored as text -- an index would tie the file to a declaration order.
std::string density_decay = "acquisitions";
double density_tau_s = 1.0;
double density_factor = 0.5;
/// Whether the file said anything about the level. A session that did keeps it; one that did not
/// leaves the panel's own "follow the channel's range" default alone.
bool level_given = false;
};
Trigger trigger;
/// Free text the user attached to this configuration: what it is for, what to look at first.
/// A configuration is a *named* session, and the name alone rarely says what makes it different
/// from its neighbour.
std::string comment;
/// The startup settings the configuration carries, applied when the chooser opens it -- which is
/// why they live here and not only on the command line: two sources on one machine want different
/// buffers and different clocks, and retyping the flags to look at the second one is how a
/// setting gets forgotten. A zero `buffer_mb` and an empty `capture_packets` mean "not stored":
/// the command line's value stands, so an old file cannot silently shrink a buffer.
double buffer_mb = 0.0;
bool relative_time = false;
std::uint32_t capture_packets = 0;
};
/// A data source udpscope has a stored session for: one row of the startup chooser.
struct KnownSource {
std::string host; ///< the CONFIG server, even for a multicast session
std::uint16_t port = 0;
std::string multicast; ///< empty = unicast
std::string interface_addr; ///< empty = the default route
int rows = 0; ///< plots the session had
int signals = 0; ///< named slots across those plots
std::int64_t mtime = 0; ///< orders the list; never shown
/// Which *configuration* of that source this row is. A source may have several, each a named
/// session of its own, so the pair (host, port, config) is what identifies a row.
std::string config;
std::string comment; ///< the configuration's note, shown beside its name
/// The startup settings this configuration carries, so the chooser can apply them.
double buffer_mb = 0.0;
bool relative_time = false;
std::uint32_t capture_packets = 0;
};
/// Every configuration that names at least one signal *or* carries a setting, newest first.
///
/// The filter is what makes the list usable: a development machine accumulates one session file
/// per probe run on a throwaway port -- 140 of them when this was written -- and every one holds
/// `signals = ["", "", "", ""]`. A listed source is one somebody actually configured — and a
/// buffer size, a note or a clock counts as configuring it, because that is how the chooser's form
/// sets a host up before it has ever streamed.
std::vector<KnownSource> known_sources();
/// Deletes one source's session file; the chooser's `x`. False means the file was already gone or
/// could not be removed, which costs the caller nothing -- there is no state to roll back.
bool forget_source(const KnownSource& src);
/// One `MathNode` as `kind|source|in_a|in_b|gain|offset|fc|q|lo|hi|x|y`, and back. The kind is the
/// op's *index* into `math_op_at()`, which is why the enum's order is a stored format and not just
/// an implementation detail: reordering it renames every saved graph's operations.
std::string math_node_text(const MathNode& node);
bool math_node_from_text(const std::string& text, MathNode& out);
/// The directory session files live in: $XDG_CONFIG_HOME/udpscope, else
/// ~/.config/udpscope, else /tmp/udpscope-config. Not created by this call.
std::string config_dir();
/// `$XDG_CACHE_HOME/udpscope`, or `~/.cache/udpscope`: where bulky, disposable state
/// lives (the packet captures; item 9's event cache). Created on demand by callers.
std::string cache_dir();
/// Creates `path` and every missing component, like `mkdir -p`. Public because the preferences are
/// written under the config directory too.
void mkdirs(const std::string& path);
/// The *legacy* single-file path, `~/.config/udpscope/<host>_<port>.toml`, with the directory
/// created. Still the import source for a file written before configurations existed; nothing
/// writes here any more.
std::string layout_path(const std::string& host, std::uint16_t port);
/// The per-source directory a source's configurations live in:
/// `~/.config/udpscope/<host>_<port>/`. Not created by this call.
std::string config_dir_for(const std::string& host, std::uint16_t port);
/// One configuration's file, `<config_dir_for(host, port)>/<name>.toml`, with the directory
/// created. The name is sanitized the way the host is, so a name the user typed with a slash in it
/// cannot escape the directory.
std::string configuration_path(const std::string& host, std::uint16_t port,
const std::string& name);
/// The source's configurations, newest first. A legacy single-file session appears here as the
/// configuration named `default`, and importing it is the first thing this does.
std::vector<std::string> configuration_names(const std::string& host, std::uint16_t port);
/// The configuration the source was last left in: `<dir>/active`, or the newest that exists when
/// nothing wrote one. Never empty for a source that has any configuration at all.
std::string active_configuration(const std::string& host, std::uint16_t port);
/// Remembers which configuration this source is on. Called by every save.
bool set_active_configuration(const std::string& host, std::uint16_t port, const std::string& name);
/// Deletes one configuration's file, and `active` if it named it.
bool forget_configuration(const std::string& host, std::uint16_t port, const std::string& name);
/// Parses the file's TOML subset. Returns false when the file is missing or
/// malformed; `out` is left usable either way (a malformed file loads as much as
/// parsed, which beats refusing a hand-edited file over one stray character).
bool load_layout(const std::string& path, LayoutState& out);
/// Writes `state` as TOML. Atomic in effect: writes a sibling temp file and
/// renames it over the target.
bool save_layout(const std::string& path, const LayoutState& state);
} // namespace udpscope