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

+64 -4
View File
@@ -239,15 +239,75 @@ bool event_meta_parse(const std::string& text, EventRecord* out) {
std::string default_events_dir() { return std::string(cache_dir()) + kEventsSubdir; }
EventStore::EventStore(std::string source, std::string base_dir) {
if (source.empty()) return;
namespace {
/// A path component from a user-visible name: alphanumerics, `.`, `_` and `-` kept, everything else
/// replaced. The same rule the layout store applies to a configuration name, and for the same
/// reason -- a name typed with a slash in it must not be able to leave the directory.
std::string sanitize_component(const std::string& in) {
std::string safe;
for (char c : source) {
safe.reserve(in.size());
for (char c : in) {
const bool ok = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') ||
c == '.' || c == '_' || c == '-';
safe.push_back(ok ? c : '_');
}
dir_ = (base_dir.empty() ? default_events_dir() : base_dir) + "/" + safe;
return safe;
}
/// The directory a source's configuration writes into: `<root>/<source>/<configuration>/`, or the
/// source's own directory when it has no configuration name (a store built without one).
std::string event_dir(const std::string& root, const std::string& source,
const std::string& configuration) {
std::string dir = root + "/" + sanitize_component(source);
if (!configuration.empty()) dir += "/" + sanitize_component(configuration);
return dir;
}
} // namespace
EventStore::EventStore(std::string source, std::string base_dir, std::string configuration)
: root_(base_dir.empty() ? default_events_dir() : std::move(base_dir)),
source_(std::move(source)),
configuration_(std::move(configuration)) {
if (source_.empty()) {
root_.clear();
configuration_.clear();
return;
}
dir_ = event_dir(root_, source_, configuration_);
// **An upgrade keeps its events.** Before configurations came in, a source's events sat directly
// in `<root>/<source>/`; the first store opened under a configuration adopts every loose event
// file it finds there, so the cache a user already has is the cache the new folder lists.
if (!configuration_.empty()) {
const std::string from = root_ + "/" + sanitize_component(source_);
DIR* d = ::opendir(from.c_str());
if (d != nullptr) {
bool moved = false;
while (dirent* e = ::readdir(d)) {
const std::string name = e->d_name;
if (name == "." || name == "..") continue;
const bool event_file = name.size() > 4 &&
(name.compare(name.size() - 4, 4, ".npy") == 0 ||
name.compare(name.size() - 4, 4, ".txt") == 0 ||
name.compare(name.size() - 4, 4, ".bmp") == 0);
if (!event_file) continue;
if (!moved) {
mkdirs(dir_);
moved = true;
}
std::rename((from + "/" + name).c_str(), (dir_ + "/" + name).c_str());
}
::closedir(d);
}
}
}
bool EventStore::set_configuration(const std::string& configuration) {
if (source_.empty() || configuration == configuration_) return false;
configuration_ = configuration;
dir_ = event_dir(root_, source_, configuration_);
return true;
}
std::string EventStore::save(const ExportTable& table, const EventRecord& meta, std::size_t keep) {
+25 -2
View File
@@ -45,11 +45,28 @@ std::string default_events_dir();
class EventStore {
public:
/// `source` is the `<host>-<port>` pair, the same key the packet capture uses.
/// `source` is the `<host>-<port>` pair, the same key the packet capture uses. `configuration`
/// is the source's **named configuration** -- the same name the session files and the packet
/// capture use, `default` for a source that never saved one: an event's trigger, its channel set
/// and its units belong to the configuration it was latched under, so two configurations of one
/// source must not list each other's events. The directory is
/// `<base>/<source>/<configuration>/`; an empty configuration is the legacy layout
/// (`<base>/<source>/`), and the first construction under a configuration **adopts** the loose
/// event files it finds there, so an upgrade keeps the events it had.
///
/// `base_dir` is the directory every source's own folder is created under; empty means
/// `<cache>/udpscope/events`. It comes from the preferences (item 9), because a user with a small
/// home partition and a second disk should not have to choose between events and free space.
explicit EventStore(std::string source = {}, std::string base_dir = {});
explicit EventStore(std::string source = {}, std::string base_dir = {},
std::string configuration = {});
/// Points the store at another configuration of the same source, for the configuration switch
/// the session has: only the directory changes, and `true` means it did -- the caller then
/// refreshes whatever lists the cache. A store with no source (`empty_dir()`) never changes.
bool set_configuration(const std::string& configuration);
/// The configuration the store currently writes to (the constructor's, or `set_configuration`'s).
const std::string& configuration() const noexcept { return configuration_; }
/// How many events to keep, newest first. The oldest beyond it are deleted on every save; item 9's
/// preferences are where this becomes the user's number.
@@ -89,6 +106,12 @@ class EventStore {
void prune(std::size_t keep) const;
private:
/// The base directory every source folder lives under (an absolute path, or empty for a store
/// with no source), and the source's own sanitized key: kept so a configuration switch can
/// re-derive `dir_` without asking the caller for either again.
std::string root_;
std::string source_;
std::string configuration_;
std::string dir_;
};
+11 -1
View File
@@ -573,6 +573,11 @@ bool load_layout(const std::string& path, LayoutState& out) {
}
} else if (key == "measure_stats") {
st.measure_stats = value == "true";
} else if (key == "gate_x0" || key == "gate_x1") {
try {
(key == "gate_x0" ? st.gate_x0 : st.gate_x1) = std::stod(value);
} catch (...) {
}
} else if (key == "dock") {
st.dock_shown = value == "true";
} else if (key.rfind("trigger_", 0) == 0) {
@@ -685,6 +690,8 @@ bool load_layout(const std::string& path, LayoutState& out) {
m.options.above = value == "true";
} else if (key == "show") {
m.options.show_on_plot = value == "true";
} else if (key == "gate") {
m.options.gate = value == "true";
}
} else if (section == Section::Calibration && !st.calibration.empty()) {
if (key == "name") {
@@ -762,6 +769,8 @@ bool save_layout(const std::string& path, const LayoutState& state) {
o << "]\n";
}
o << "measure_stats = " << (state.measure_stats ? "true" : "false") << "\n";
o << "gate_x0 = " << state.gate_x0 << "\n";
o << "gate_x1 = " << state.gate_x1 << "\n";
o << "dock = " << (state.dock_shown ? "true" : "false") << "\n";
// The trigger, settings and state both. Written even when it is off: that is the point of it.
o << "trigger_enabled = " << (state.trigger.enabled ? "true" : "false") << "\n";
@@ -841,7 +850,8 @@ bool save_layout(const std::string& path, const LayoutState& state) {
<< "\nthreshold2 = " << m.options.threshold_pct2
<< "\nabove = " << (m.options.above ? "true" : "false") << "\nshow = "
<< (m.options.show_on_plot ? "true" : "false") << "\nnoise = " << m.options.noise_pct
<< "\nhf = " << m.options.hf_fc << "\n";
<< "\nhf = " << m.options.hf_fc
<< "\ngate = " << (m.options.gate ? "true" : "false") << "\n";
}
// A sibling temp file + rename: a crash mid-write leaves the old layout, not a
+8
View File
@@ -87,6 +87,14 @@ struct LayoutState {
/// stream and mean nothing restored against another one.
bool measure_stats = false;
/// The measurement gate's **bounds** -- where the one shared band sits, in the display x the
/// axes read, the cursors' coordinate space, so they restore as the same relative offsets
/// ("half a second ago to a tenth ago") whatever the stream is doing. Whether the gate is on
/// is *not* here: it is a per-measurement option (`MeasureOptions::gate`, in `measures`
/// below), and the band is shown exactly while some measurement is gated.
double gate_x0 = 0.0;
double gate_x1 = 0.0;
/// The dock's shown state (the user's last word on the toolbar's Dock button). The one thing the dock
/// persists -- its 320 px width is the `.ui`'s. An older file's `side_tab` key is not read: the parser
/// ignores keys it does not know.
+7
View File
@@ -93,6 +93,13 @@ struct MeasureOptions {
/// -- a filtered window cannot use the LOD pyramid, because a bucket aggregates unfiltered samples --
/// so a non-zero corner costs a full raw walk.
double hf_fc = 0.0;
/// Every type: measure only inside the **gate** -- the one shaded band shared by every gated
/// measurement, clipped to the window this measurement would otherwise describe. The band is
/// carried by its own pair of draggable bounds on the plots (see `RowSet::gate_x0()`), and it
/// is shown while *any* measurement is gated: it is one area, and its bounds are one pair of
/// hands. Off by default; a measurement with it off reads the whole window and shares the
/// walks it always shared.
bool gate = false;
};
/// One entry in the measurements pane, and everything needed to recompute it: the signal by
+149 -17
View File
@@ -40,6 +40,12 @@ constexpr plot::u32 kSearchColor = 0x7FB2FFu;
/// around. Sixteen identical lines with no way to tell which of them the readout is quoting is a
/// picture that cannot be read, so the current hit is brighter than its neighbours.
constexpr plot::u32 kSearchCurrentColor = 0xE8D37Fu;
/// The measurement gate's colour: violet, a hue no instrument on screen uses -- the cursors'
/// yellow and cyan, the measurements' green, the trigger's red and a hit's blue all name
/// something else, and the gate's shaded band is the one area that must not read as any of
/// them. The fill is the same hue at the low alpha a region reads as "area of interest" through.
constexpr plot::u32 kGateColor = 0xB080FFu;
constexpr float kGateFillAlpha = 0.15f;
/// The smallest difference a `TickFormat::Time` label can *write* at the magnitude `t` -- what
/// the last digit of a label at that magnitude is worth. `format_time` writes three significant
@@ -210,24 +216,64 @@ void RowSet::set_row_count(int rows) {
rebuild_all();
}
void RowSet::carry_row(Row& dst, const Row& src) {
dst.signal_name = src.signal_name;
dst.name = src.name;
dst.channel = src.channel;
dst.color_index = src.color_index;
// A layer travels with the slot that draws it: it *is* part of what that slot holds. The tick's
// `sync_layers()` re-plans them all next frame in any case, which is what heals a row that has
// moved under a plan made for the old one.
dst.layer = src.layer;
dst.slot_alpha = src.slot_alpha;
void RowSet::toggle_maximize_figure_row(int fr) {
if (maximized_) {
// Back exactly as it was: every row's state first, then the arrangement it was indexed
// by, then the rebuild that deals the saved rows back into fresh plots.
for (int i = 0; i < saved_rows_ && i < kMaxRows; ++i) {
apply_row_state(storage_[static_cast<std::size_t>(i)],
saved_row_states_[static_cast<std::size_t>(i)]);
}
cells_per_row_ = saved_cells_;
cell_weights_ = saved_cell_weights_;
row_weights_ = saved_row_weights_;
rows_ = saved_rows_;
maximized_ = false;
saved_cells_.clear();
saved_cell_weights_.clear();
saved_row_weights_.clear();
saved_rows_ = 0;
rebuild_all();
return;
}
if (fr < 0 || fr >= figure_row_count()) return;
const int n = cells_in_row(fr);
const int first = first_plot_of_row(fr);
if (!(n > 0)) return;
// The arrangement and every row's persistent state, so the restore is the inverse and
// nothing the user plotted is lost to a view change.
for (int i = 0; i < kMaxRows; ++i) {
saved_row_states_[static_cast<std::size_t>(i)] =
row_state_of(storage_[static_cast<std::size_t>(i)]);
}
saved_cells_ = cells_per_row_;
saved_cell_weights_ = cell_weights_;
saved_row_weights_ = row_weights_;
saved_rows_ = rows_;
// The row's plots move to the front, in order: `rebuild_all()` re-deals `storage_[i]` to
// figure plot `i`, so a maximized figure row *is* those rows and nothing else. The states
// are copied from the saved snapshot, so a source row further down is not overwritten
// before it is read.
for (int k = 0; k < n; ++k) {
apply_row_state(storage_[static_cast<std::size_t>(k)],
saved_row_states_[static_cast<std::size_t>(first + k)]);
}
cells_per_row_.assign(1, n);
cell_weights_.assign(1, fr < static_cast<int>(saved_cell_weights_.size())
? saved_cell_weights_[static_cast<std::size_t>(fr)]
: std::vector<float>{});
// One figure row: equal weights of one. `apply_row_weights()` sizes the vector to the rows
// that exist, so this is the stated "one row takes the whole figure".
row_weights_.assign(1, 1.0f);
rows_ = std::min(n, static_cast<int>(kMaxRows));
maximized_ = true;
rebuild_all();
}
dst.ruler_y = src.ruler_y;
dst.ruler_b_y = src.ruler_b_y;
// The slot's series stays where it is, but what it is bound to belonged to the row
// this state came *from*. `bind()` re-resolves every slot next frame; clearing it here
// is what stops the frame in between from drawing the moved row's data under the new
// row's legend.
for (SlotSeries& s : dst.slots) s.bind(nullptr);
void RowSet::carry_row(Row& dst, const Row& src) {
// One spelling of "a row's persistent state": the save/restore the maximize toggle uses
// is the same copy, and a field added there is a field carried here.
apply_row_state(dst, row_state_of(src));
}
void RowSet::clear_row(Row& r) {
@@ -435,6 +481,21 @@ void RowSet::install(Row& r, int /*row_index*/, plot::Plot& p) {
// does not stop a drag -- the controller holds the marker for the gesture's life and follows the
// pointer between frames -- so excluding it from the hit test is the only place that can say no.
r.plot->set_cursor_selectable(r.trigger_marker, false);
// The measurement gate: one region per plot, parked until the gate is switched on (see
// `place_gate()`). Its bounds are the gate's own pair of instruments -- grabbable, the
// way a cursor is -- and its shaded fill is the area the user sees: the measurements
// describe what is inside it. The label names it, because a shaded band with no caption
// next to a plot full of readout marks is a band the user has to guess at.
plot::Region gate;
gate.x0 = kParked;
gate.x1 = kParked + 1.0;
gate.fill = plot::Color::hex(kGateColor, kGateFillAlpha);
gate.edge = plot::Color::hex(kGateColor);
gate.label = "gate";
r.gate_region = r.plot->add_region(gate);
// The crosshair's axis badges carry the set's flag onto the new plot: a rebuild keeps the
// setting, the way it keeps the cursor mode.
p.set_crosshair_axis_labels(crosshair_axis_labels_);
// No `set_x_follow(Live)`: the link group owns the window *and* the follow state, and
// `Figure::link_x()` refuses a member whose x axis still follows on its own.
}
@@ -499,6 +560,10 @@ void RowSet::rebuild_all() {
r.ruler_b_y = first.ruler_b_y;
}
place_cursors(r);
// The gate too: a rebuild builds new plots, whose regions are parked until this
// restates the shared bounds on them -- a row added while the gate was on would
// otherwise be the one row without the band.
place_gate(r);
if (trigger_marker_on_) {
r.plot->set_cursor_x(r.trigger_marker, trigger_marker_x_, plot::CursorPlacement::Free);
}
@@ -992,6 +1057,7 @@ void RowSet::set_measure_marks(const std::vector<MeasureMark>& marks) {
const std::size_t mi = static_cast<std::size_t>(v++);
r.plot->set_cursor_x(r.measure_v[mi], m.value, plot::CursorPlacement::Free);
r.plot->set_cursor_color(r.measure_v[mi], plot::Color::hex(m.color));
r.plot->set_cursor_label(r.measure_v[mi], m.label);
}
}
// What the measurements left over is parked rather than kept: a stale readout at the old
@@ -1001,6 +1067,7 @@ void RowSet::set_measure_marks(const std::vector<MeasureMark>& marks) {
}
for (; v < kMeasureMarks; ++v) {
r.plot->set_cursor_x(r.measure_v[static_cast<std::size_t>(v)], kParked);
r.plot->set_cursor_label(r.measure_v[static_cast<std::size_t>(v)], "");
}
}
}
@@ -1315,6 +1382,63 @@ void RowSet::sync_cursors(int drag_row) {
place_crossings();
}
void RowSet::place_gate(Row& r) {
if (!r.plot) return;
// Off is parked, the cursors' own convention for taking an instrument off screen: bounds
// the window cannot reach contribute no clipped rect, so nothing draws and -- because the
// controller's hit test reads the same clipped geometry -- nothing is grabbable either.
const double a = gate_on_ ? gate_x0_ : kParked;
const double b = gate_on_ ? gate_x1_ : kParked + 1.0;
r.plot->set_region(r.gate_region, a, b);
}
void RowSet::set_gate_visible(bool on) {
if (on && !gate_seeded_) reset_gate();
gate_on_ = on;
for (int i = 0; i < kMaxRows; ++i) place_gate(row(i));
}
void RowSet::set_gate(double x0, double x1) {
if (!std::isfinite(x0) || !std::isfinite(x1)) return;
gate_x0_ = std::min(x0, x1);
gate_x1_ = std::max(x0, x1);
gate_seeded_ = true;
for (int i = 0; i < kMaxRows; ++i) place_gate(row(i));
}
void RowSet::reset_gate() {
if (figure_ == nullptr) return;
const plot::Range x = figure_->x_link_range(x_group_);
if (!(x.span() > 0.0) || !std::isfinite(x.min) || !std::isfinite(x.max)) return;
// The middle half of whatever is on screen -- the same quarter/three-quarter answer
// `reset_cursors()` gives the vertical pair, so both instruments start in the same place
// a user looking for them knows to look.
gate_x0_ = x.min + 0.25 * x.span();
gate_x1_ = x.min + 0.75 * x.span();
gate_seeded_ = true;
for (int i = 0; i < kMaxRows; ++i) place_gate(row(i));
}
void RowSet::sync_gate(int drag_row) {
if (!gate_on_ || drag_row < 0 || drag_row >= rows_) return;
// With the gate off the plots hold the parked pair, whose bounds read back finite --
// adopting them would be the same echo `sync_cursors()` guards against.
Row& r = row(drag_row);
if (!r.plot) return;
const plot::Region& reg = r.plot->region(r.gate_region);
if (!std::isfinite(reg.x0) || !std::isfinite(reg.x1)) return;
if (reg.x0 == gate_x0_ && reg.x1 == gate_x1_) return;
gate_x0_ = reg.x0;
gate_x1_ = reg.x1;
gate_seeded_ = true;
// Every row's region is written from the shared bounds; the dragged row already holds
// them, and `set_region()` orders them, so a bound dragged across the other lands here
// as the swap the controller already made in its own plot.
for (int i = 0; i < rows_; ++i) {
if (i != drag_row) place_gate(row(i));
}
}
void RowSet::clamp_to_data() {
if (!figure_) return;
// The rows share one window, so they share one bound: the union of what they hold.
@@ -1461,6 +1585,14 @@ void RowSet::set_cursors_visible(bool on) {
for (int i = 0; i < kMaxRows; ++i) place_cursors(row(i));
}
void RowSet::set_crosshair_axis_labels(bool on) {
crosshair_axis_labels_ = on;
for (int i = 0; i < kMaxRows; ++i) {
Row& r = row(i);
if (r.plot != nullptr) r.plot->set_crosshair_axis_labels(on);
}
}
void RowSet::reset_cursors() {
for (int i = 0; i < kMaxRows; ++i) {
Row& r = row(i);
+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_{};
};
+3
View File
@@ -70,6 +70,8 @@ Settings load_settings() {
if (mb >= 16) s.memory_limit_mb = static_cast<int>(std::min<long>(mb, 1000000000L));
} else if (key == "theme") {
if (value == "auto" || value == "light" || value == "dark") s.theme = value;
} else if (key == "crosshair_axis_labels") {
s.crosshair_axis_labels = parse_bool(value, s.crosshair_axis_labels);
} else if (key == "contextual_help") {
s.contextual_help = parse_bool(value, s.contextual_help);
} else if (key == "tour_seen") {
@@ -94,6 +96,7 @@ bool save_settings(const Settings& settings) {
// second. `read_rows()` in the preferences starts from `load_settings()`, so a dialog save carries
// them through untouched.
out << "contextual_help = " << (settings.contextual_help ? "true" : "false") << "\n";
out << "crosshair_axis_labels = " << (settings.crosshair_axis_labels ? "true" : "false") << "\n";
out << "tour_seen = " << (settings.tour_seen ? "true" : "false") << "\n";
out.flush();
return out.good();
+5
View File
@@ -39,6 +39,11 @@ struct Settings {
/// "auto", "light" or "dark": what the interface should look like. Kept as the three names libadwaita
/// uses, so the file says what the setting is rather than what an enum happens to be numbered.
std::string theme = "auto";
/// The two small badges the crosshair writes in the label strips -- the y value where its
/// horizontal line meets the y axis, the x time where its vertical line meets the x axis.
/// **On by default**: it is the axis reading an oscilloscope puts where the eye already is,
/// and off is the decision that needs a reason. Applies as it changes, like the theme.
bool crosshair_axis_labels = true;
/// The contextual-help layer: a small "?" beside each surface it can explain. **Off by default** --
/// a first run shows the interface as a user expects it and the layer is asked for, from the Help
/// menu or from the chooser's own help menu, by whoever wants it; on is then a decision that
+460 -20
View File
@@ -547,6 +547,25 @@ GtkWidget* make_area(App& app) {
gtk_overlay_set_child(GTK_OVERLAY(app.area_overlay), widget);
gtk_widget_set_hexpand(app.area_overlay, true);
gtk_widget_set_vexpand(app.area_overlay, true);
// The corner toggles are revealed by proximity, so the pointer's position is kept here,
// on the **overlay** and not on the GL area: a revealed toggle sits *under* the pointer,
// and the event still travels the ancestor -- a leave on the GL widget would fire the
// moment the button appeared and hide it again (the flicker the overlay avoids). `leave`
// here means the pointer left the plot altogether, which is when the toggles go away.
GtkEventController* motion = gtk_event_controller_motion_new();
g_signal_connect(motion, "motion", G_CALLBACK(+[](GtkEventControllerMotion*, double x,
double y, gpointer user) {
App& a = *static_cast<App*>(user);
a.area_pointer_x = x;
a.area_pointer_y = y;
a.area_pointer_valid = true;
}),
&app);
g_signal_connect(motion, "leave", G_CALLBACK(+[](GtkEventControllerMotion*, gpointer user) {
static_cast<App*>(user)->area_pointer_valid = false;
}),
&app);
gtk_widget_add_controller(app.area_overlay, GTK_EVENT_CONTROLLER(motion));
return app.area_overlay;
}
@@ -559,6 +578,85 @@ void relayout_areas(App& app) {
if (app.area != nullptr) app.area->set_figure(&app.rows->figure());
}
/// A second top-level window: the same stream, another figure, its own `RowSet`. The main
/// window's tick is its bind/acquire/repaint pump (see `tick()`), so the two never disagree
/// about the data or the display mode. `figure_row` names the main window's figure to copy --
/// its cells, their width shares and every slot's signal in its colour -- because "open this
/// one big" is the reason a user reaches for a second window; -1 opens an empty scope.
void open_scope_window(App& app, int figure_row) {
if (figure_row >= app.rows->figure_row_count()) figure_row = -1;
auto scope = std::make_unique<App::ScopeWindow>();
scope->rows = std::make_unique<RowSet>();
if (figure_row >= 0) {
const int cells = app.rows->cells_in_row(figure_row);
scope->rows->set_cells_per_row(std::vector<int>(1, cells));
if (cells > 1) scope->rows->set_cell_weights(0, app.rows->cell_weights(figure_row));
const int first = app.rows->first_plot_of_row(figure_row);
for (int k = 0; k < cells; ++k) {
const RowSet::Row& src = app.rows->row(first + k);
for (int s = 0; s < RowSet::kSlotsPerRow; ++s) {
const std::size_t si = static_cast<std::size_t>(s);
if (src.signal_name[si].empty()) continue;
scope->rows->assign(k, s, src.signal_name[si], src.name[si], src.color_index[si]);
}
}
}
scope->rows->set_time_window(app.rows->time_window());
// The preference travels: the scope draws the same chrome the main window does, unless
// the setting is off.
scope->rows->set_crosshair_axis_labels(app.settings.crosshair_axis_labels);
scope->area = plot::gtk4::PlotArea::create(scope->placeholder);
scope->area->set_figure(&scope->rows->figure());
scope->widget = scope->area->widget();
gtk_widget_set_hexpand(scope->widget, true);
gtk_widget_set_vexpand(scope->widget, true);
gtk_widget_set_size_request(scope->widget, 320, 160);
scope->window = GTK_WINDOW(gtk_window_new());
const std::string title = figure_row >= 0
? "udpscope \u2014 plot " + std::to_string(figure_row + 1)
: std::string("udpscope \u2014 scope");
gtk_window_set_title(scope->window, title.c_str());
gtk_window_set_default_size(scope->window, 900, 500);
gtk_window_set_child(scope->window, scope->widget);
if (GtkApplication* application = gtk_window_get_application(GTK_WINDOW(app.window))) {
gtk_window_set_application(scope->window, application);
}
// The vector owns the struct, so the window has to remove itself. The erase is deferred to
// an idle: the destroy emission runs before the GL child is finalized, and the area
// wrapper (`PlotArea::create()`'s, owned by the widget) holds references into this struct's
// placeholder plot and row set -- freeing them under the finalization would be a
// use-after-free. One main-loop turn later the widget is gone and the erase is safe.
g_signal_connect(scope->window, "destroy", G_CALLBACK(+[](GtkWindow* window, gpointer user) {
App* a = static_cast<App*>(user);
for (auto it = a->scopes.begin(); it != a->scopes.end(); ++it) {
if ((*it)->window != window) continue;
App::ScopeWindow* victim = it->release();
a->scopes.erase(it);
g_idle_add_full(G_PRIORITY_DEFAULT_IDLE,
+[](gpointer p) -> gboolean {
delete static_cast<App::ScopeWindow*>(p);
return G_SOURCE_REMOVE;
},
victim, nullptr);
break;
}
}),
&app);
gtk_window_present(scope->window);
app.scopes.push_back(std::move(scope));
}
/// The plot menu's "open this plot in a new window": the figure row the clicked plot belongs
/// to, copied into a scope window; the popover dismisses like every other row's action.
void on_open_scope_row(GtkButton* button, gpointer user) {
App* app = static_cast<App*>(user);
const int row = row_key(GTK_WIDGET(button));
const int fr = app->rows->figure_row_of(row);
open_scope_window(*app, fr);
GtkPopover* popover = GTK_POPOVER(g_object_get_data(G_OBJECT(button), "udpscope-popover"));
if (popover != nullptr) gtk_popover_popdown(popover);
}
// --- panel rebuilds ---------------------------------------------------------
/// Defined below, forward-declared because placing or freeing a trace slot has to
@@ -891,17 +989,146 @@ void on_layout_cell_insert(GtkButton* button, gpointer user) {
///
/// What each button does with the look is the caller's: they differ in alignment, sensitivity, the data they
/// carry and the gesture they take.
GtkWidget* layout_overlay_button(App& app, const char* icon, const char* tooltip) {
/// The look every overlay button shares -- icon, the two CSS classes that make a small round
/// on-screen-display control, and the size -- in one place, because the editor's buttons and
/// the full-screen toggles must not drift apart.
GtkWidget* overlay_button(App& app, const char* icon, const char* tooltip,
std::vector<GtkWidget*>& list) {
GtkWidget* b = gtk_button_new_from_icon_name(icon);
gtk_widget_set_tooltip_text(b, tooltip);
gtk_widget_add_css_class(b, "circular");
gtk_widget_add_css_class(b, "osd");
gtk_widget_set_size_request(b, kLayoutButtonPx, kLayoutButtonPx);
gtk_overlay_add_overlay(GTK_OVERLAY(app.area_overlay), b);
app.layout_buttons.push_back(b);
list.push_back(b);
return b;
}
/// The editor's own set: on the overlay only while the editor is up, cleared with it.
GtkWidget* layout_overlay_button(App& app, const char* icon, const char* tooltip) {
return overlay_button(app, icon, tooltip, app.layout_buttons);
}
/// Forward declarations: the toggles' handlers are defined after the builder that wires them.
void on_maximize_clicked(GtkButton* b, gpointer user);
void on_fullscreen_row(GtkButton* b, gpointer user);
void rebuild_maximize_buttons(App& app);
/// The one action behind both the row's toggle and the plot menu's entry: the figure row goes
/// full screen, or -- while one is -- the saved arrangement comes back. The figure is handed
/// over at once (the same path a cell insert takes), the toggles are rebuilt because their
/// number and icon follow the state, and the signal panels are rebuilt because a maximize
/// renumbers which row shows what.
void toggle_fullscreen(App& app, int fr) {
app.rows->toggle_maximize_figure_row(fr);
if (app.area != nullptr) app.area->set_figure(&app.rows->figure());
rebuild_maximize_buttons(app);
rebuild_signal_panels(app);
if (app.layout_editing) rebuild_layout_editor(app);
}
/// One full-screen toggle per figure row: the row to enlarge is the row you are looking at,
/// so the control sits on the row itself rather than behind the layout editor. While a row
/// is maximized the single remaining button is the way back. A layout of one figure has
/// nothing to maximize *to*, so it carries no toggle -- except while that one figure is
/// maximized, where the button is the way out.
void rebuild_maximize_buttons(App& app) {
if (app.area_overlay == nullptr) return;
for (GtkWidget* b : app.maximize_buttons) {
gtk_overlay_remove_overlay(GTK_OVERLAY(app.area_overlay), b);
}
app.maximize_buttons.clear();
app.maximize_buttons_rows = app.rows->figure_row_count();
const bool maximized = app.rows->figures_maximized();
if (!maximized && app.maximize_buttons_rows <= 1) return;
for (int fr = 0; fr < app.maximize_buttons_rows; ++fr) {
GtkWidget* b = overlay_button(app,
maximized ? "view-restore-symbolic" : "view-fullscreen-symbolic",
maximized ? "back to the whole layout"
: "show this figure full screen",
app.maximize_buttons);
g_object_set_data(G_OBJECT(b), kMaximizeKey, GINT_TO_POINTER(fr + 1));
g_signal_connect(b, "clicked", G_CALLBACK(on_maximize_clicked), &app);
}
}
/// Restates where the toggles sit, and rebuilds them when the figure row count changed (a
/// resize, a flush, a maximize). Placed from the same `FigureLayout` rects the editor's
/// buttons use -- the axes' right edge, so the button sits over the right gutter and never
/// over a trace -- and from the rows' top edges, so each one names the row it would enlarge.
void sync_maximize_buttons(App& app) {
if (app.area_overlay == nullptr || app.area_widget == nullptr || app.area == nullptr) return;
const int frows = app.rows->figure_row_count();
if (frows != app.maximize_buttons_rows) rebuild_maximize_buttons(app);
if (app.maximize_buttons.empty()) return;
const plot::FigureLayout& fl = app.rows->figure().layout();
const double scale = app.area->device_scale() > 0.0f
? static_cast<double>(app.area->device_scale())
: 1.0;
const double width = static_cast<double>(gtk_widget_get_width(app.area_widget));
const double height = static_cast<double>(gtk_widget_get_height(app.area_widget));
for (GtkWidget* b : app.maximize_buttons) {
const int fr = GPOINTER_TO_INT(g_object_get_data(G_OBJECT(b), kMaximizeKey)) - 1;
if (fr < 0 || fr >= frows || fr >= static_cast<int>(fl.rows.size())) {
gtk_widget_set_visible(b, FALSE);
continue;
}
const int first = app.rows->first_plot_of_row(fr);
const int n = app.rows->cells_in_row(fr);
const plot::Plot* last = n > 0 ? app.rows->row(first + n - 1).plot : nullptr;
const double right =
last != nullptr
? static_cast<double>(last->layout().axes.right()) / scale
: static_cast<double>(fl.rows[static_cast<std::size_t>(fr)].right()) / scale;
const double top =
static_cast<double>(fl.rows[static_cast<std::size_t>(fr)].y) / scale + kLayoutButtonPad;
gtk_widget_set_halign(b, GTK_ALIGN_START);
gtk_widget_set_valign(b, GTK_ALIGN_START);
const double by = static_cast<int>(std::clamp(top, 0.0, std::max(0.0, height - kLayoutButtonPx)) + 0.5);
gtk_widget_set_margin_top(b, static_cast<int>(by));
const double bx = static_cast<int>(std::clamp(right - kLayoutButtonPx - kLayoutButtonPad, 0.0,
std::max(0.0, width - kLayoutButtonPx)) + 0.5);
gtk_widget_set_margin_start(b, static_cast<int>(bx));
// Out of hiding only while the pointer is near this row's corner: the button's own rect
// inflated by `kMaximizeRevealPx`. The rect is the placed one (`bx`/`by`), so the zone and
// the button cannot disagree -- a zone measured from the axes' edge while the button was
// clamped to the widget would leave a corner that never reveals.
const bool revealed =
app.area_pointer_valid && app.area_pointer_x >= bx - kMaximizeRevealPx &&
app.area_pointer_x <= bx + kLayoutButtonPx + kMaximizeRevealPx &&
app.area_pointer_y >= by - kMaximizeRevealPx &&
app.area_pointer_y <= by + kLayoutButtonPx + kMaximizeRevealPx;
gtk_widget_set_visible(b, revealed);
}
}
/// The row's toggle: the `kMaximizeKey` data names the figure row it sits on.
void on_maximize_clicked(GtkButton* b, gpointer user) {
App& app = *static_cast<App*>(user);
toggle_fullscreen(app, GPOINTER_TO_INT(g_object_get_data(G_OBJECT(b), kMaximizeKey)) - 1);
}
/// The plot menu's entry: the figure row the clicked plot belongs to, through the same action,
/// with the popover dismissed like every other row's.
void on_fullscreen_row(GtkButton* b, gpointer user) {
App& app = *static_cast<App*>(user);
toggle_fullscreen(app, app.rows->figure_row_of(row_key(GTK_WIDGET(b))));
GtkPopover* popover = GTK_POPOVER(g_object_get_data(G_OBJECT(b), "udpscope-popover"));
if (popover != nullptr) gtk_popover_popdown(popover);
}
/// The plot menu's way into the layout editor: the same toggle the overflow menu's `Layout editor`
/// entry flips, so the menu cannot drift from the button's own state -- and the place a user who is
/// looking at the plot will find splitting.
void on_row_menu_layout_editor(GtkButton* b, gpointer user) {
App& app = *static_cast<App*>(user);
if (app.layout_toggle != nullptr) {
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(app.layout_toggle), !app.layout_editing);
}
GtkPopover* popover = GTK_POPOVER(g_object_get_data(G_OBJECT(b), "udpscope-popover"));
if (popover != nullptr) gtk_popover_popdown(popover);
}
void rebuild_layout_editor(App& app) {
clear_layout_editor(app);
if (!app.layout_editing || app.area_overlay == nullptr) return;
@@ -1684,7 +1911,23 @@ GtkWidget* measures_row(App& app, std::size_t index, const Measure& m) {
gtk_label_set_markup(GTK_LABEL(sig_chip), measure_chip_markup(app, m.signal).c_str());
gtk_widget_set_tooltip_text(sig_chip, m.signal.c_str());
gtk_box_append(GTK_BOX(box), sig_chip);
GtkWidget* kind = gtk_label_new(measure_type_name(m.type));
// The row's who: the channel's number and name, then the type. The number is the active
// channel's (see `measure_channel_number`) -- the same one the signal's badge carries -- so
// a row, a badge and the signal page all answer "which channel is this" with one word.
// Markup because the name is the stream's text; escaped for the same reason. A signal that
// is not acquired keeps the old type-only row (there is no number to show and the row is
// already grey).
const int number = measure_channel_number(app, m.signal);
std::string kind_text;
{
char* esc = g_markup_escape_text(m.signal.c_str(), -1);
kind_text = number > 0 ? "<b>" + std::to_string(number) + ". " + esc + "</b> · " +
measure_type_name(m.type)
: std::string(measure_type_name(m.type));
g_free(esc);
}
GtkWidget* kind = gtk_label_new(nullptr);
gtk_label_set_markup(GTK_LABEL(kind), kind_text.c_str());
gtk_widget_set_halign(kind, GTK_ALIGN_START);
gtk_widget_set_hexpand(kind, true);
gtk_label_set_ellipsize(GTK_LABEL(kind), PANGO_ELLIPSIZE_END);
@@ -1982,9 +2225,22 @@ void open_measure_dialog(App& app, int edit_index) {
}
// The `const char*` array is built from the *finished* strings: taking `c_str()` as the
// vector grew left every pointer pointing into memory the next push reallocated.
//
// Each entry is shown with its **channel number** (`measure_channel_number`), the same one
// the pane's rows and the signal badges carry -- the dropdown and the rows name a channel
// alike, which is what makes "1. sine" in one findable in the other. `d->signals` keeps the
// raw names: the number is decoration, and Save resolves through the raw name. An entry
// that is not an active channel (a persistence layer, `sine (A)`, whose base signal's
// channel is someone else's) shows unnumbered rather than borrowing one.
std::vector<std::string> sig_display;
sig_display.reserve(sig_names.size());
std::vector<const char*> sig_cstrs;
sig_cstrs.reserve(sig_names.size() + 1);
for (const std::string& n : sig_names) sig_cstrs.push_back(n.c_str());
for (const std::string& n : sig_names) {
const int number = measure_channel_number(app, n);
sig_display.push_back(number > 0 ? std::to_string(number) + ". " + n : n);
}
for (const std::string& n : sig_display) sig_cstrs.push_back(n.c_str());
sig_cstrs.push_back(nullptr);
if (editing != nullptr) {
const auto it = std::find(sig_names.begin(), sig_names.end(), editing->signal);
@@ -2051,10 +2307,23 @@ void open_measure_dialog(App& app, int edit_index) {
"at the time it happened, the pulse's edges as markers");
gtk_box_append(GTK_BOX(box), show);
// The per-measurement gate: one check per measurement, one band shared by all of them. The
// band is shown while any measurement is gated (`rebuild_measures_pane()` derives it), and
// it is the thing this check switches on the plots -- the measurement itself only names
// which side of the statistics question reads inside it. Shown for every type: the gate is
// about *which samples* answer, and every type reads samples.
GtkWidget* gate = gtk_check_button_new_with_label("Gate enabled");
gtk_widget_set_tooltip_text(gate,
"restrict this measurement to the shared gate band on the plots "
"(violet). The band is shown while any measurement is gated, and "
"stays where it is put: live it names an age (\"300 ms ago to "
"100 ms ago\"), on a held event a fixed slice of the frame");
gtk_box_append(GTK_BOX(box), gate);
auto* d = new MeasureDialog{&app, edit_index, signal_drop, type_drop,
threshold, band, above, show,
window, threshold_row, threshold_label, band_row,
band_label, side_row, side_label,
gate, window, threshold_row, threshold_label,
band_row, band_label, side_row, side_label,
noise, noise_row, hf, hf_row,
sig_names};
app.measure_dialog = window;
@@ -2083,6 +2352,7 @@ void open_measure_dialog(App& app, int edit_index) {
gtk_spin_button_set_value(GTK_SPIN_BUTTON(hf), editing->options.hf_fc);
gtk_switch_set_active(GTK_SWITCH(above), editing->options.above);
gtk_check_button_set_active(GTK_CHECK_BUTTON(show), editing->options.show_on_plot);
gtk_check_button_set_active(GTK_CHECK_BUTTON(gate), editing->options.gate);
} else {
gtk_spin_button_set_value(GTK_SPIN_BUTTON(threshold), 50.0);
gtk_spin_button_set_value(GTK_SPIN_BUTTON(band), MeasureOptions{}.threshold_pct2);
@@ -2663,6 +2933,7 @@ GtkWidget* build_measures_panel(App& app) {
update_measures(*a, /*force=*/true, /*accumulate=*/true);
}),
&app);
// The heading row -- the .ui's anonymous first child, carrying the title and the `+`. It is where
// the contextual help's "?" joins (see `ui_context_help.cpp`).
app.measures_head = gtk_widget_get_first_child(box);
@@ -3069,6 +3340,21 @@ void row_menu_fill(RowMenuState& st) {
// is what "fit" means here. `F` is the plot's own key for the second of them.
add_row("Fit X axis", nullptr, G_CALLBACK(on_fit_x_clicked), &app);
add_row("Fit all", "F", G_CALLBACK(on_fit_clicked), &app);
// One click from a row to that row alone filling the plot: the button on the figure's
// corner does the same, and the menu is where a user who cannot see it looks. A layout of
// one figure has nothing to maximize *to*, so the entry appears only when it would act.
if (app.rows->figure_row_count() > 1 || app.rows->figures_maximized()) {
add_row(app.rows->figures_maximized() ? "Restore the layout" : "Full screen this figure",
nullptr, G_CALLBACK(on_fullscreen_row), &app);
}
// One click from looking at a figure to having it in a window of its own -- the second
// window copies this plot's figure, so the picture on screen is the picture it opens with.
add_row("Open this plot in a new window", nullptr, G_CALLBACK(on_open_scope_row), &app);
// Arranging is a plot gesture too: the editor's own buttons live on the plot, but the way in
// belongs where the arrangement is being judged. It flips the app's one toggle, so its label
// and the overflow menu's state cannot disagree.
add_row(app.layout_editing ? "Done editing the layout" : "Edit the layout...", "L",
G_CALLBACK(on_row_menu_layout_editor), &app);
gtk_box_append(GTK_BOX(st.box), menu_separator());
// A row, not a `GtkMenuButton`: the removals are the *next view of this menu*, and the chevron
@@ -3768,6 +4054,11 @@ gboolean tick(gpointer user) {
reset_measure_stats(*app);
app->search.clear();
rebuild_signal_panels(*app);
// The measures pane too, for the row's channel number: the number is the channel's
// position in the new list, and a rebuild is what re-derives it. The stats were reset
// above for exactly this reason (the old readings do not belong to the new list), so the
// rebuild's own reset is the same act, not a second one.
rebuild_measures_pane(*app);
}
// A gesture moves the *plot's* cursor; adopting it here is what makes the two
// vertical markers one time across every row, and what the pane then reads.
@@ -3776,16 +4067,25 @@ gboolean tick(gpointer user) {
// (`cursor(id).x` is the free pointer position while a drag runs and the snapped value
// otherwise, so reading outside a gesture reads our own echo).
int cursor_drag_row = -1;
// The same question for the measurement gate's region: a drag on it is the only time
// `region(id)`'s bounds are the hand's and not our own last write.
int gate_drag_row = -1;
if (app->area != nullptr) {
for (int i = 0; i < app->rows->row_count(); ++i) {
plot::input::Controller* c = app->area->row_controller(static_cast<plot::u32>(i));
if (c != nullptr && c->grabbed_cursor() != plot::kInvalid) {
cursor_drag_row = i;
break;
}
if (c == nullptr) continue;
if (cursor_drag_row < 0 && c->grabbed_cursor() != plot::kInvalid) cursor_drag_row = i;
if (gate_drag_row < 0 && c->grabbed_region() != plot::kInvalid) gate_drag_row = i;
}
}
app->rows->sync_cursors(cursor_drag_row);
app->rows->sync_gate(gate_drag_row);
// The drag's end is a new pair of gate bounds, and the bounds are session state: save there,
// the way a dragged row sizing is saved when its own gesture ends. During the drag every
// frame would only re-arm the same 400 ms debounce, but the *end* is the one moment the
// state is neither mid-gesture nor already saved.
if (app->gate_drag_last >= 0 && gate_drag_row < 0) schedule_layout_save(*app);
app->gate_drag_last = gate_drag_row;
update_cursors_pane(*app);
update_measures(*app, /*force=*/false, /*accumulate=*/true);
// Every frame, whether or not a scan is running: the marks are in stream seconds and the axis
@@ -3875,6 +4175,13 @@ gboolean tick(gpointer user) {
app->rows_b->bind(model, display);
app->rows_b->acquire(model);
}
// A scope window is a second view of the same data: its own figure binds and acquires
// under the same lock, and the window/pin mirroring after it keeps the two windows on
// one span.
for (const std::unique_ptr<App::ScopeWindow>& scope : app->scopes) {
scope->rows->bind(model, display);
scope->rows->acquire(model);
}
// What x = 0 means, written *after* the bind and the acquire on purpose: the live answer
// is minus the newest sample the traces can now report, and asking before they were bound
// would put the newest data a frame's production to the right of the axis -- a third of
@@ -3914,6 +4221,23 @@ gboolean tick(gpointer user) {
// acquisition thread mutates, so it belongs under this lock and not after it.
app->rows->set_invalid_spans(model, display);
}
// The scope windows, outside the lock: mirroring a window, restating a pin and asking for a
// render are RowSet and GTK work, not model reads. The span follows the main window every
// frame -- a scope is "the same picture, another window" -- and its own media (a held event
// binds the same snapshot under the same epoch) comes from the bind above.
if (!app->scopes.empty()) {
const plot::Range pin = app->rows->pinned_range();
const bool pinned = app->rows->window_pinned();
const double window_s = app->rows->time_window();
for (const std::unique_ptr<App::ScopeWindow>& scope : app->scopes) {
if (pinned) {
scope->rows->pin_event_window(pin.min, pin.max);
} else if (std::fabs(scope->rows->time_window() - window_s) > 1.0e-12) {
scope->rows->set_time_window(window_s);
}
if (scope->widget != nullptr) gtk_gl_area_queue_render(GTK_GL_AREA(scope->widget));
}
}
// A depth the room or the retention budget could not show in full says so, once per *plan* rather
// than once per frame: a user who asked for eight layers and sees two has no other way to tell
// "the display is deliberately shallower" from "the feature is broken". Both bounds are named,
@@ -4299,6 +4623,9 @@ gboolean tick(gpointer user) {
// The editor's buttons are placed from the rects this frame was laid out with, so they
// follow a resize and the gutter settling without being told.
if (app->layout_editing) sync_layout_editor(*app);
// The full-screen toggles are on the overlay always, so they follow the figure every frame
// (and rebuild themselves when the figure row count changed).
sync_maximize_buttons(*app);
// The one thing that has to be decided *per frame* about the dock: whether there is room for it
// beside the plot. Collapsed, the split view puts it *over* the plot rather than giving it a share of
@@ -4648,6 +4975,31 @@ gboolean tick(gpointer user) {
gtk_widget_set_tooltip_text(app->status_badge, kWhy[reason]);
}
// The trigger's own circle, one row up in the same alphabet as the badge: solid green while
// the trigger has fired (an event held, or a capture filling), flashing green while armed
// and waiting for one, red while armed-able but disarmed, and *hidden* while the trigger is
// off -- a light for a mode the user is not in is noise, and the mode line next to it is
// not showing trigger words either. The flash is the badge's own half-second square, and
// the markup is rewritten on a change of the state key only, for the same relayout reason
// the badge's is.
const int circle = !trigger_on ? -1
: (app->event_held || app->capturing)
? 0
: armed ? (lit ? 1 : 2) : 3;
app->dbg_trigger_circle = circle;
if (app->trigger_status != nullptr && circle != app->trigger_circle_state) {
app->trigger_circle_state = circle;
gtk_widget_set_visible(app->trigger_status, circle >= 0);
if (circle >= 0) {
gtk_label_set_markup(GTK_LABEL(app->trigger_status), trigger_circle_markup(circle));
gtk_widget_set_tooltip_text(
app->trigger_status,
circle == 0 ? "trigger: triggered (an event is on screen)"
: circle == 3 ? "trigger: disarmed (Arm to wait for an event)"
: "trigger: armed, waiting for an event");
}
}
int bound = 0;
std::size_t samples = 0;
for (int s = 0; s < RowSet::kSlotsPerRow; ++s) {
@@ -5169,6 +5521,15 @@ GtkWidget* build_toolbar(App& app) {
app.menu_help_row = help_row;
gtk_box_append(GTK_BOX(menu_box), help_row);
GtkWidget* scope_item = menu_button("New scope window", nullptr);
gtk_widget_set_tooltip_text(scope_item,
"a second window onto the same stream, with an empty figure; "
"a plot's own menu opens that figure in one");
g_signal_connect(scope_item, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
open_scope_window(*static_cast<App*>(user), -1);
}),
&app);
gtk_box_append(GTK_BOX(menu_box), scope_item);
GtkWidget* prefs_item = menu_button("Preferences\u2026", nullptr);
g_signal_connect(prefs_item, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
open_preferences(*static_cast<App*>(user));
@@ -5283,6 +5644,7 @@ void on_activate(GtkApplication* application, gpointer user) {
app->plot_split = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "plot_split"));
app->plot_overlay = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "plot_overlay"));
app->status_badge = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "status_badge"));
app->trigger_status = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "trigger_status"));
app->status_label = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "status_label"));
app->status_info_button = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "status_info"));
// The status line's row, which is where the status line's own "?" joins and one of the surfaces the
@@ -5309,18 +5671,43 @@ void on_activate(GtkApplication* application, gpointer user) {
}
// The window's actions live on the application, so it has to know which one it belongs to.
gtk_window_set_application(GTK_WINDOW(app->window), application);
// A window closed by hand (a WM close button) must stop the frame tick: it holds
// pointers to widgets that are finalized with the window.
g_signal_connect(app->window, "destroy", G_CALLBACK(+[](GtkWidget*, gpointer user) {
// The destroy handler below needs the application, and a captureless callback cannot close
// over it: kept on the window, which outlives the handler.
g_object_set_data(G_OBJECT(app->window), "udpscope-application", application);
// The main window's lifecycle hook, and it is `close-request`: **GTK4 has no
// `GtkWidget::destroy` signal** (the GTK3 connect this used to be never fired), and the only
// way a window is asked to end is the close request -- the WM's button, `gtk_window_close()`
// from a menu action, and the exit probe's own close. `gtk_window_destroy()` does not emit
// it, which is why everything that ends this window programmatically goes through
// `gtk_window_close()` instead.
//
// What it does, in order: the **scope windows** go with it (they are views of this window's
// acquisition; left behind they keep the GApplication's loop alive around a window that is
// gone -- the exit run's hang), the debounced layout save is flushed (a pending save would
// never fire now), the tick is stopped (`a->window = nullptr`; the widgets are about to be
// finalized), and the application is asked to quit -- the main window is its anchor, so the
// run must end whatever else was open. `FALSE` lets the default handler destroy the window.
g_signal_connect(app->window, "close-request", G_CALLBACK(+[](GtkWindow* window, gpointer user) {
App* a = static_cast<App*>(user);
// A pending debounced save will never fire now: flush it, so
// quitting right after a change keeps the change.
// Collected first: each scope's own destroy handler erases it from
// `scopes` as it fires.
std::vector<GtkWindow*> scope_windows;
scope_windows.reserve(a->scopes.size());
for (const std::unique_ptr<App::ScopeWindow>& scope : a->scopes) {
if (scope->window != nullptr) scope_windows.push_back(scope->window);
}
for (GtkWindow* w : scope_windows) gtk_window_close(w);
if (a->layout_save_source != 0) {
g_source_remove(a->layout_save_source);
a->layout_save_source = 0;
save_layout_now(*a);
}
a->window = nullptr;
if (auto* application = GTK_APPLICATION(
g_object_get_data(G_OBJECT(window), "udpscope-application"))) {
g_application_quit(G_APPLICATION(application));
}
return FALSE;
}),
app);
// The header bar is the window's own titlebar, and it is *not* in the `.ui`: every button on it is
@@ -5362,6 +5749,12 @@ void on_activate(GtkApplication* application, gpointer user) {
// tooltip says which phase the badge is *not* reporting.
gtk_label_set_markup(GTK_LABEL(app->status_badge), badge_markup(BadgePhase::Idle));
gtk_widget_set_tooltip_text(app->status_badge, "waiting for the stream");
// The trigger circle starts hidden: the trigger starts off, and a light for a mode the user
// is not in is noise (see the tick's write).
if (app->trigger_status != nullptr) {
gtk_label_set_markup(GTK_LABEL(app->trigger_status), trigger_circle_markup(3));
gtk_widget_set_visible(app->trigger_status, FALSE);
}
// The line is rewritten every frame and its text changes width as the counters
// grow. A label's *minimum* width is its text, so an unellipsized one pushes the
@@ -6122,6 +6515,9 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
app.model = &model;
app.acq = &acq;
app.rows = &rows;
// The crosshair's axis badges, before the window exists: the setting is per plot, and the
// flag on the set is what every rebuild restates (see `RowSet::install()`).
rows.set_crosshair_axis_labels(startup_settings.crosshair_axis_labels);
// Which data source this session talks to: the persisted-layout file's name.
// A waveform has **no source**: the address the command line defaults to is not one, and treating
// it as one made a file-mode run load -- and then *save* -- the session of `127.0.0.1:8000`, which
@@ -6141,11 +6537,20 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
app.source_title = app.source_host + ":" + std::to_string(app.source_port);
}
if (SourceKind* k = find_kind(cfg.source_kind)) app.source_has_datagrams = k->has_datagrams();
// The event cache is keyed on the source, like the packet capture. A waveform has no source, so a
// file-mode run has no cache either -- there are no latches in a file.
// The event cache is keyed on the source **and its active configuration**, like the packet
// capture and the session files: an event's trigger, channel set and units belong to the
// configuration it was latched under, so two configurations of one source must not list each
// other's events. The active configuration is readable here -- the configuration files are not
// stream data -- so the store starts on the right one instead of being re-keyed after the first
// CONFIG arrives. A waveform has no source, so a file-mode run has no cache either -- there are
// no latches in a file.
if (!app.source_host.empty() && app.source_port != 0) {
app.event_store = std::make_unique<EventStore>(app.source_host + "-" +
std::to_string(app.source_port), app.settings.event_cache_dir);
if (app.config_name.empty()) {
app.config_name = active_configuration(app.source_host, app.source_port);
}
app.event_store = std::make_unique<EventStore>(
app.source_host + "-" + std::to_string(app.source_port), app.settings.event_cache_dir,
app.config_name);
}
refresh_events_pane(app);
@@ -6244,6 +6649,18 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
if (const char* env = g_getenv("UDPSCOPE_PROBE_MEASURE_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), measure_probe, &app);
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_GATE_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), gate_probe_step, &app);
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_CROSSHAIR_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), crosshair_probe, &app);
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_MAXIMIZE_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), maximize_probe_step, &app);
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_SCOPE_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), scope_probe_step, &app);
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_LEVELS_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), levels_probe, &app);
}
@@ -6732,7 +7149,16 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
std::printf("[udpscope] measurements: %zu", app.measures.size());
for (std::size_t i = 0; i < app.measures.size() && i < app.measure_results.size(); ++i) {
const MeasureResult& r = app.measure_results[i];
std::printf(" %s=%s", measure_type_name(app.measures[i].type),
// Numbered like the pane's row: which active channel this measurement is about, and
// unnumbered when its signal is not acquired -- a probe that switched a signal off
// before the dump (enable_all does exactly that) must not read as a numbering fault.
// The number is read from the model here (the dump cannot ask the row's label), so a
// mismatch with what the pane showed while the signal was up is a finding about
// `measure_channel_number`.
const int number = measure_channel_number(app, app.measures[i].signal);
const std::string prefix = number > 0 ? std::to_string(number) + "." : "";
std::printf(" %s%s %s=%s", prefix.c_str(),
app.measures[i].signal.c_str(), measure_type_name(app.measures[i].type),
r.valid ? std::to_string(r.primary).c_str() : "--");
if (r.has_secondary) std::printf("(%s)", std::to_string(r.secondary).c_str());
}
@@ -6762,6 +7188,20 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
: 0.0);
}
}
// The measurement gate's own line: whether it is on and where its bounds sit. The state is
// the RowSet's (no GTK here -- the dump runs after the window is gone), and it is what a
// session round trip is read against: a restore that left the gate off, or put the band
// somewhere else than the saved session had it, reads here.
std::printf("[udpscope] gate: on=%d bounds=[%.6f..%.6f]\n",
app.rows->gate_visible() ? 1 : 0, app.rows->gate_x0(), app.rows->gate_x1());
// The trigger circle's state as of the last tick: off / green / flash / dim / red. The
// dump runs after the window is gone, so this is the tick's cached answer, not a read.
{
static const char* const kCircle[] = {"green", "flash", "dim", "red"};
const int c = app.dbg_trigger_circle;
std::printf("[udpscope] trigger circle: %s\n",
c < 0 ? "off" : (c < 4 ? kCircle[c] : "?"));
}
std::printf("[udpscope] badge frames: lit=%d dim=%d idle=%d connecting=%d\n",
app.dbg_badge_frames[0], app.dbg_badge_frames[1], app.dbg_badge_frames[2],
app.dbg_badge_frames[3]);
+7
View File
@@ -153,6 +153,13 @@
<property name="margin-top">2</property>
<property name="margin-bottom">2</property>
<property name="margin-start">6</property>
<child>
<!-- The trigger's own circle: green while an event is on screen (or a capture
is filling), flashing green while armed and waiting, red while disarmed,
and hidden while the trigger is off: a light for a mode the user is not
in is noise. Markup is written by the tick on a change of state only. -->
<object class="GtkLabel" id="trigger_status"/>
</child>
<child>
<object class="GtkLabel" id="status_badge"/>
</child>
+4 -3
View File
@@ -9,9 +9,10 @@
readout row — so the rows are built and rebuilt in code, and this file holds the frame they land in
(`measures_list`) plus the three widgets whose state outlives them.
One statistics switch for the panel rather than one per reading: statistics are either what a user is
looking at or they are not, and a per-row switch is a column of chrome paid for on every row to answer a
question asked once.
One statistics switch for the panel rather than one per reading: statistics are either what a user
is looking at or they are not, and a per-row switch is a column of chrome paid for on every row to
answer a question asked once. The gate is the opposite case — a per-measurement choice — so it
lives in the measurement dialog, not here.
-->
<interface>
<object class="GtkBox" id="measures_panel">
+7
View File
@@ -68,6 +68,13 @@
<property name="subtitle" translatable="yes">Take the plot's time from the sample rate and the packet counter rather than from the stream's own clock, so arrival and clock jitter cannot move a sample; absolute time is still kept for export. Read at start-up, because the buffers are planned before any source is known.</property>
</object>
</child>
<child>
<object class="AdwSwitchRow" id="crosshair_axis_labels_row">
<property name="title" translatable="yes">Crosshair _axis labels</property>
<property name="use-underline">True</property>
<property name="subtitle" translatable="yes">Show the value and the time where the crosshair's lines meet the axes, each in its axis' own format, in the strips the tick labels are written in. Applies at once.</property>
</object>
</child>
<child>
<object class="AdwComboRow" id="theme_row">
<property name="title" translatable="yes">_Theme</property>
+109 -3
View File
@@ -190,6 +190,14 @@ struct App {
/// figure, so there is one widget, one context and one `::render` whatever the row
/// count is.
GtkWidget* area_widget = nullptr;
/// The pointer's last position over the plot area, in the area's coordinates (the overlay's,
/// for the toggles' corner), and whether one is over it at all: the full-screen toggles are
/// revealed by proximity, so the frame tick reads this. The overlay's own motion controller
/// keeps it current, which is why a pointer *on* a revealed toggle still counts as over the
/// area -- the toggle is an overlay child, and the event still travels the ancestor.
double area_pointer_x = 0.0;
double area_pointer_y = 0.0;
bool area_pointer_valid = false;
plot::gtk4::PlotArea* area = nullptr;
/// The GL area wrapped in a `GtkOverlay`, which is what the plot box actually holds.
/// The layout editor's buttons are overlay children: each is a small widget placed by
@@ -197,6 +205,24 @@ struct App {
/// is -- a single transparent child covering the area would swallow every pan, drop and
/// hover the editor is not interested in.
GtkWidget* area_overlay = nullptr;
/// One extra top-level scope window: its own figure and `RowSet`, bound to the same
/// `ScopeModel` by the main window's tick -- which is also its repaint pump -- so a scope
/// is a second *view* of one acquisition, never a second acquisition. The main window's
/// x window (or its pin) is mirrored onto it every frame, so the two look at the same
/// span; its own controllers can still pan and zoom, and the mirror reclaims the span on
/// the next frame.
struct ScopeWindow {
GtkWindow* window = nullptr;
plot::gtk4::PlotArea* area = nullptr;
GtkWidget* widget = nullptr;
/// The area's initial plot, owned here: `PlotArea::create()` wants a `Plot&` that
/// outlives the widget, and sharing the main window's placeholder would hand two areas
/// one plot the moment the figure mode is ever left.
plot::Plot placeholder;
std::unique_ptr<RowSet> rows;
};
std::vector<std::unique_ptr<ScopeWindow>> scopes;
/// On while the layout editor is up. Nothing else about the plot changes: the editor is
/// a set of overlay buttons, not a mode the figure knows about.
bool layout_editing = false;
@@ -204,6 +230,11 @@ struct App {
/// The editor's buttons, in the order they were added, so they can be taken off the
/// overlay again. Rebuilt when the row count changes, repositioned when the rows move.
std::vector<GtkWidget*> layout_buttons;
/// The full-screen toggles: one per figure row, on the overlay **always** (not only while
/// the editor is up), placed in each row's top-right corner. Rebuilt when the figure row
/// count changes (`maximize_buttons_rows` is the count they were built for).
std::vector<GtkWidget*> maximize_buttons;
int maximize_buttons_rows = -1;
/// The boundary a resize drag is on, and the weights it started from: a drag is *relative* to where it
/// began, so the beginning has to be kept -- reading the weights while dragging would compound.
int layout_drag_row = -1;
@@ -261,6 +292,15 @@ struct App {
/// What the badge was last set to (`BadgePhase`), so the markup is rewritten on a
/// change of phase rather than on every one of the 60 frames a second.
int badge_phase = -1;
/// The trigger status circle beside it (`main-window.ui` `trigger_status`): green while an
/// event is held or a capture is filling, flashing green while armed and waiting, red while
/// disarmed, hidden while the trigger is off. Same width-locked logic as the badge: one
/// "●" glyph in every state, markup rewritten on a change of the state key only, and the
/// flash runs off the same half-second square the badge blinks on.
GtkWidget* trigger_status = nullptr;
int trigger_circle_state = -1;
/// The circle's state as of the last tick, for the exit dump (which cannot read widgets).
int dbg_trigger_circle = -1;
/// The display-window control: a header-bar button whose label is the current window
/// and whose popover holds the number, its unit and the two decade steppers. The label
/// is width-locked (see `kWindowLabelChars`) because the header bar sits above the plot
@@ -681,6 +721,14 @@ struct App {
plot::Range measure_window{};
bool measure_window_valid = false;
bool measure_was_held = false;
/// The **gate band** the last pass saw, in the same stream seconds, clipped to the window the
/// pane was about. The gate is per measurement, but the band is one: when any measurement is
/// gated, a band that moves -- a drag, a seed, a mode change -- changes what *those*
/// measurements describe without the viewport or the pin moving, so the "it moved" question
/// is asked of this as well. Booked even when nothing is gated (then it is the pane's own
/// window, and the comparison below repeats one the viewport has already answered).
plot::Range measure_gate_window{};
bool measure_gate_window_valid = false;
/// The window **on screen**, in the same stream seconds, as of the last *tick* -- which is the
/// only thing `measure_window` is comparable with. Captured here rather than read back in the
/// dump for the reason every other dump field is (see verification.md): the dump runs from the
@@ -704,12 +752,15 @@ struct App {
/// `MeasureChunk`).
/// One channel's window, at one HF corner, with every level accumulator the pane asked for.
///
/// Keyed by `(channel, fc)` and not by channel alone: the corner is a per-measurement setting, so two
/// measurements on one signal at different corners are reading two different signals and cannot share a
/// walk.
/// Keyed by `(channel, fc, gated)` and not by channel alone: the corner and the gate are
/// per-measurement settings, so two measurements on one signal at different corners -- or one
/// gated and one not -- are reading two different windows and cannot share a walk. A gated
/// window is walked over the gate band clipped to the pane's window; an ungated one over the
/// pane's window whole.
struct MeasureWindow {
int channel = -1;
double fc = 0.0; ///< the HF corner this window was walked at; 0 is unfiltered
bool gated = false; ///< this window is the gate band, not the pane's window whole
MeasureStats stats;
std::vector<double> pct; ///< the level shares asked for, deduplicated
std::vector<double> noise; ///< each entry's band, as a percentage; parallel to `pct`
@@ -798,6 +849,29 @@ struct App {
/// probes add measurements, so they must not be scheduled in one run (see verification.md), and a
/// shared counter would make that a silent mis-step rather than a documented prohibition.
int dbg_bandavg_step = 0;
/// The gate probe's (`UDPSCOPE_PROBE_GATE_MS`) step counter and bookkeeping: the bounds it
/// set and the `s0` it read when it set them, so the second leg can say whether the band
/// stayed still while the stream's clock moved under it.
int dbg_gate_step = 0;
double dbg_gate_x0 = 0.0;
double dbg_gate_x1 = 0.0;
double dbg_gate_s0 = 0.0;
/// The gate probe's settle polls (`dbg_gate_step == 1` re-enters while the ring is younger
/// than the window, which is what its whole-periods arithmetic needs), bounded so a stalled
/// stream still measures and prints.
int dbg_gate_wait = 0;
/// The row a gate-region drag was on last frame, or -1. The fall to -1 is the drag's end,
/// and the tick saves the session there: the bounds are session state, and a drag that is
/// not saved is a session that lies about where the gate is.
int gate_drag_last = -1;
/// Which step of the crosshair probe runs next (`UDPSCOPE_PROBE_CROSSHAIR_MS`).
int dbg_crosshair_step = 0;
/// Which step of the maximize probe runs next (`UDPSCOPE_PROBE_MAXIMIZE_MS`).
int dbg_maximize_step = 0;
std::size_t dbg_maximize_single = 0;
int dbg_maximize_hidden = -1;
/// Which step of the scope-window probe runs next (`UDPSCOPE_PROBE_SCOPE_MS`).
int dbg_scope_step = 0;
int cursors_pane_mode = -1;
int cursors_pane_rows = -1;
unsigned cursors_pane_generation = ~0u;
@@ -1440,6 +1514,10 @@ struct MeasureDialog {
GtkWidget* band = nullptr;
GtkWidget* above = nullptr;
GtkWidget* show = nullptr;
/// The per-measurement gate: measure only inside the one shared band. Type-independent --
/// the gate is about *which samples* answer, and every type reads samples -- so unlike the
/// rows above it is shown for every type and never retitled.
GtkWidget* gate = nullptr;
GtkWidget* window = nullptr;
/// The option rows' boxes and labels, so the dialog can *retitle* them per type: the same
/// switch means "the pulse is above the level" for a pulse and nothing at all for a period,
@@ -1618,6 +1696,12 @@ constexpr int kWindowLabelChars = 9;
constexpr int kRowKeyBias = 1;
constexpr int kLayoutButtonPx = 24;
constexpr int kLayoutButtonPad = 4;
/// How far from its corner a full-screen toggle comes out of hiding. The toggles are on the
/// screen only while the pointer is near them; this is the slack around the button's own rect
/// (a square corner zone a few button-widths wide), large enough that a pointer arrives without
/// having to find the exact pixel and small enough that crossing a plot's top edge leaves it
/// hidden.
constexpr int kMaximizeRevealPx = 48;
constexpr const char* kLayoutKindKey = "udpscope-layout-insert";
/// The ↕ handles' own key: what they are is *not* what the `+` is, and `sync_layout_editor()`
@@ -1627,6 +1711,9 @@ constexpr const char* kLayoutResizeKey = "udpscope-layout-resize";
constexpr const char* kLayoutCellKey = "udpscope-layout-cell";
/// Column resize handles carry the plot index of the right-hand cell.
constexpr const char* kLayoutColKey = "udpscope-layout-col";
/// The full-screen toggle's key: the figure row it acts on, +1 (0 is not a valid row). Its
/// own key because the button is on the overlay *always*, not only while the editor is up.
constexpr const char* kMaximizeKey = "udpscope-maximize-row";
/// The minimum width a cell may be dragged to, in logical pixels.
constexpr double kMinCellPx = 96.0;
/// The plotter's own per-cell floor, `kMinCellWidthLogical` in `deps/plotter/src/plot/figure.cpp`
@@ -1705,6 +1792,9 @@ int window_unit_for(double s);
std::string format_window_value(double s, int unit);
std::string format_window(double s);
const char* badge_markup(BadgePhase phase);
/// The trigger status circle's markup, by state (see `trigger_circle_markup` in
/// `ui_status.cpp`): 0 solid green, 1/2 the flash's halves, 3 red.
const char* trigger_circle_markup(int state);
std::string format_measure(double v, const std::string& unit);
void set_notice(App& app, std::string what, int seconds = 4);
void set_row_key(GtkWidget* widget, int row);
@@ -1862,6 +1952,9 @@ bool event_on_screen(const App& app, const EventRecord& r);
bool event_in_group(const App& app, const EventRecord& r);
/// The signal chip of a measurement's row, in the colour every other view names that signal with.
std::string measure_chip_markup(App& app, const std::string& signal);
/// The 1-based number of an active channel (its position among the acquired channels), or 0
/// when the signal is not acquired. The number the badge bar and the measurements pane show.
int measure_channel_number(App& app, const std::string& signal);
std::string row_unit(App& app, int row);
std::string slot_unit(App& app, int row, int slot);
std::uint32_t bound_slot_mask(App& app);
@@ -2002,6 +2095,11 @@ GdkTexture* scale_texture(GdkTexture* source, int width, int height);
EventThumbCache* event_thumb_cache(App& app);
GdkTexture* event_preview_texture(App& app, const std::string& path);
void refresh_events_pane(App& app);
/// Points the event cache at the configuration the session is on -- the active one when
/// `App::config_name` is empty -- and refreshes the two views of it when the directory changed.
/// Every configuration switch calls it next to setting `App::config_name`, so the list on screen
/// is always the configuration's own.
void retarget_event_store(App& app);
/// Opens the `events` page. **Every route that opens that page must use this** and never `slideover_open()`
/// directly: the page's list is rebuilt only while the page is open (see `record_event()`), and the
/// slide-over has no on-open hook, so the list is refreshed here, once, before the page shows. The refresh
@@ -2041,6 +2139,14 @@ gboolean mathwrap_probe_wait(gpointer user);
gboolean mathwrap_probe_report(gpointer user);
gboolean measure_probe(gpointer user);
gboolean levels_probe(gpointer user);
gboolean gate_probe_step(gpointer user);
gboolean crosshair_probe(gpointer user);
gboolean maximize_probe_step(gpointer user);
gboolean scope_probe_step(gpointer user);
/// A second top-level window showing the same stream through its own figure: `figure_row` >= 0
/// copies that figure of the main window (cells, widths, every slot's signal), -1 opens an
/// empty one-row scope. The main window's tick binds, acquires and repaints it.
void open_scope_window(App& app, int figure_row);
gboolean measurepin_probe(gpointer user);
gboolean measurepin_held_wait(gpointer user);
gboolean measurepin_pause_wait(gpointer user);
+10 -2
View File
@@ -122,7 +122,9 @@ gboolean on_badge_drop(GtkDropTarget*, const GValue* value, double, double, gpoi
return TRUE;
}
/// A signal's badge: its colour dot (grey when it has no trace) and its name. Words and a dot, no icon.
/// A signal's badge: its colour dot (grey when it has no trace) and its name, prefixed with
/// the channel's number -- the same number the measurements pane shows, so a badge and a
/// measurement row answer "which channel" with one word. Words and a dot, no icon.
void make_signal_badge(App& app, Badge& b, const std::vector<SignalInfo>& signals, std::size_t index) {
b.signal = index;
b.signal_name = signals[index].name;
@@ -133,7 +135,13 @@ void make_signal_badge(App& app, Badge& b, const std::vector<SignalInfo>& signal
GtkWidget* row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
GtkWidget* dot = gtk_label_new(nullptr);
gtk_label_set_markup(GTK_LABEL(dot), signal_chip_markup(app, signals, index).c_str());
b.name = gtk_label_new(b.signal_name.c_str());
// The channel's number, the same one the measurements pane carries: an active channel is
// "the k-th acquired channel", and the badge and the rows name it alike. A signal the
// stream does not carry yet (a badge can outlive its channel for a tick) shows unnumbered.
const int number = measure_channel_number(app, b.signal_name);
const std::string who = number > 0 ? std::to_string(number) + ". " + b.signal_name
: b.signal_name;
b.name = gtk_label_new(who.c_str());
gtk_label_set_ellipsize(GTK_LABEL(b.name), PANGO_ELLIPSIZE_END);
gtk_label_set_max_width_chars(GTK_LABEL(b.name), 14);
gtk_box_append(GTK_BOX(row), dot);
+26 -8
View File
@@ -10,8 +10,9 @@
// layout does not depend on what a value reads; the full text is the label's tooltip.
//
// The same strings are shown a third time in the readouts strip under the plot (`readouts_strip`, one
// fixed-height line: a "Cursors" button, one chip per cursor line with its delta, a "Measures" button, one
// chip per measurement with its value), which is on screen whenever the dock is not beside the plot (hidden
// fixed-height line: a "Cursors" button, one chip per cursor line with its delta, one chip per
// measurement with its value and a `[+]` to add one), which is on screen whenever the dock is not
// beside the plot (hidden
// with the Dock button, or the window collapsed and the dock an overlay). The setters below
// write the strip's labels in the same call as the dock's, from the same argument, so there is still one
// string and no second formatter.
@@ -148,7 +149,12 @@ void dock_cursors_header(GtkGrid* grid) {
}
}
/// Fills the strip `main-window.ui` declares: the two buttons and the two boxes the rebuilds fill.
/// The strip's `[+]`: a new measurement, through the same dialog the pane's own `+` opens.
void on_add_measure(GtkButton*, gpointer user) {
open_measure_dialog(*static_cast<App*>(user), -1);
}
/// Fills the strip `main-window.ui` declares: the two groups and the two boxes the rebuilds fill.
void build_readouts_strip(App& app) {
if (app.strip_box == nullptr) return;
clear_box(app.strip_box);
@@ -163,10 +169,13 @@ void build_readouts_strip(App& app) {
gtk_widget_set_margin_top(app.strip_sep, 5);
gtk_widget_set_margin_bottom(app.strip_sep, 5);
app.strip_measure_group = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8);
gtk_box_append(GTK_BOX(app.strip_measure_group),
strip_button("Measures", G_CALLBACK(on_edit_measures), &app,
"the measurements' full panel"));
// The chips, then the `[+]` after the last one: the compact strip is where the readings are
// watched, so that is where adding one belongs -- and it replaces the old "Measures" button,
// which only opened the panel any chip already opens its own measurement from (the dock and
// the page keep their Edit).
gtk_box_append(GTK_BOX(app.strip_measure_group), app.strip_measure_box);
gtk_box_append(GTK_BOX(app.strip_measure_group),
strip_button("+", G_CALLBACK(on_add_measure), &app, "add a measurement"));
gtk_box_append(GTK_BOX(app.strip_box), app.strip_cursor_group);
gtk_box_append(GTK_BOX(app.strip_box), app.strip_sep);
gtk_box_append(GTK_BOX(app.strip_box), app.strip_measure_group);
@@ -274,6 +283,12 @@ void dock_rebuild_measures(App& app) {
}
for (std::size_t i = 0; i < app.measures.size(); ++i) {
const Measure& m = app.measures[i];
// The row's channel number, the same one the pane's row carries: the dock line says which
// channel it is about with one word, and the chip beside it says which colour.
const int number = measure_channel_number(app, m.signal);
const std::string who =
number > 0 ? std::to_string(number) + ". " + measure_type_name(m.type)
: std::string(measure_type_name(m.type));
GtkWidget* line = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
g_object_set_data(G_OBJECT(line), "udpscope-measure-index",
GINT_TO_POINTER(static_cast<int>(i) + 1));
@@ -281,7 +296,7 @@ void dock_rebuild_measures(App& app) {
gtk_label_set_markup(GTK_LABEL(chip), measure_chip_markup(app, m.signal).c_str());
gtk_widget_set_tooltip_text(chip, m.signal.c_str());
gtk_box_append(GTK_BOX(line), chip);
GtkWidget* kind = fixed_label(measure_type_name(m.type), 12, GTK_ALIGN_START);
GtkWidget* kind = fixed_label(who.c_str(), 12, GTK_ALIGN_START);
gtk_widget_set_hexpand(kind, true);
gtk_widget_set_tooltip_text(kind, (m.signal + "\n" + measure_type_name(m.type)).c_str());
gtk_box_append(GTK_BOX(line), kind);
@@ -306,7 +321,10 @@ void dock_rebuild_measures(App& app) {
gtk_label_set_markup(GTK_LABEL(schip), measure_chip_markup(app, m.signal).c_str());
gtk_widget_set_tooltip_text(schip, m.signal.c_str());
gtk_box_append(GTK_BOX(sline), schip);
GtkWidget* skind = fixed_label(measure_type_name(m.type), kStripTypeChars, GTK_ALIGN_START);
const std::string swho =
number > 0 ? std::to_string(number) + ". " + measure_type_name(m.type)
: std::string(measure_type_name(m.type));
GtkWidget* skind = fixed_label(swho.c_str(), kStripTypeChars, GTK_ALIGN_START);
gtk_widget_set_tooltip_text(skind, (m.signal + "\n" + measure_type_name(m.type)).c_str());
gtk_box_append(GTK_BOX(sline), skind);
GtkWidget* svalue = fixed_label("--", kValueChars, GTK_ALIGN_END);
+8
View File
@@ -929,6 +929,14 @@ static void rebuild_events_pane_list(App& app, std::vector<EventRecord>& records
/// Both views of the cache, rebuilt from the same list: the events page's rows (with their pictures) and the
/// dock block's compact ones. The dock follows even when the page's list does not exist. To *open* the page
/// use `open_events_page()`, which calls this only when the page is not already open.
void retarget_event_store(App& app) {
if (app.event_store == nullptr) return;
const std::string name = app.config_name.empty()
? active_configuration(app.source_host, app.source_port)
: app.config_name;
if (app.event_store->set_configuration(name)) refresh_events_pane(app);
}
void refresh_events_pane(App& app) {
// One read of the cache directory for both views (none without a store, which the page's rebuild
// and the dock's both treat as nothing to list).
+16
View File
@@ -73,6 +73,8 @@ LayoutState collect_layout(App& app) {
st.math = app.math;
st.measures = app.measures;
st.measure_stats = app.measure_stats_on;
st.gate_x0 = app.rows->gate_x0();
st.gate_x1 = app.rows->gate_x1();
st.dock_shown = app.sidebar_wanted;
// The trigger, its settings *and* whether it was on. The channel goes by name, like every identity
// in this file: every rebuild renumbers the channels.
@@ -138,6 +140,7 @@ void on_config_row_activated(GtkListBox*, GtkListBoxRow* row, gpointer user) {
app.config_name = name;
app.config_comment = st.comment;
set_active_configuration(app.source_host, app.source_port, name);
retarget_event_store(app);
apply_layout(app, st);
// A buffer size, a clock and a capture ring are decided before the first socket: saying so beats
@@ -173,6 +176,8 @@ void on_config_forget_clicked(GtkButton* button, gpointer user) {
app.config_comment.clear();
}
forget_configuration(app.source_host, app.source_port, name);
// The cleared name means "whatever is active now", which `retarget_event_store()` resolves.
retarget_event_store(app);
config_dialog_refresh(*d);
}
@@ -312,6 +317,7 @@ bool save_configuration_as(App& app, const std::string& name, const std::string&
return false;
}
set_active_configuration(app.source_host, app.source_port, name);
retarget_event_store(app);
return true;
}
@@ -442,6 +448,16 @@ void apply_layout(App& app, const LayoutState& st) {
gtk_check_button_set_active(GTK_CHECK_BUTTON(app.measures_stats_check),
app.measure_stats_on);
}
// The gate's bounds, before the pane rebuild below: whether the gate is on rides on the
// restored measurements (`MeasureOptions::gate`), and `rebuild_measures_pane()` derives the
// band's visibility from them -- a session with a gated measurement gets the band back
// exactly where this file says it sat. A file that never carried bounds leaves the RowSet
// unseeded on purpose: `set_gate()` with the file's degenerate defaults would mark the
// bounds as placed, and the first gated measurement would then show a zero-width band at
// x = 0 instead of seeding the middle half of the window.
if (std::isfinite(st.gate_x0) && std::isfinite(st.gate_x1) && st.gate_x1 > st.gate_x0) {
app.rows->set_gate(st.gate_x0, st.gate_x1);
}
// The toggled handler is guarded by updating_panels, so the Reset button's sensitivity
// and the stats-label visibility are set directly from the loaded state here; the labels'
// visibility is also set per-row inside rebuild_measures_pane via measures_row().
+78 -9
View File
@@ -51,6 +51,21 @@ std::string measure_row_summary(const Measure& m) {
}
}
/// The 1-based number of an **active** channel, as the badge bar and the measurements show it:
/// its position in the model's channel list, which is the acquired channels in stream order
/// (streamed signals first, a math channel after them). 0 when the signal is not acquired --
/// a row whose signal stopped is already grey, and inventing a number for it would be a second
/// lie. Renumbering on every enable and CONFIG is inherent: the number identifies "the k-th
/// active channel", which is exactly what a CONFIG renumbers.
int measure_channel_number(App& app, const std::string& signal) {
std::lock_guard<std::mutex> lock(app.model->mutex());
const std::vector<ChannelInfo>& channels = app.model->channels();
for (std::size_t c = 0; c < channels.size(); ++c) {
if (channels[c].name == signal) return static_cast<int>(c) + 1;
}
return 0;
}
/// The channel a measurement's signal resolved to, or -1 while the stream does not carry it.
int measure_channel(App& app, const std::string& signal) {
std::lock_guard<std::mutex> lock(app.model->mutex());
@@ -111,6 +126,15 @@ void rebuild_measures_pane(App& app) {
gtk_box_append(GTK_BOX(app.measures_list), measures_row(app, i, app.measures[i]));
app.measure_text.emplace_back();
}
// The gate band is one area shared by every gated measurement, and it is on exactly while
// one exists: shown as soon as a measurement is gated, gone when the last one stops. Every
// change of the measure set -- an add, an edit, a delete, a session restore -- passes
// through here, which is what makes this the one derivation. `set_gate_visible()` seeds the
// bounds over the middle half of the window on the first enable, so a gate asked for with
// no band ever placed still lands over the data.
bool any_gated = false;
for (const Measure& m : app.measures) any_gated = any_gated || m.options.gate;
app.rows->set_gate_visible(any_gated);
dock_rebuild_measures(app);
sync_readouts_strip(app); // the strip's Measures group (and the strip itself) follows at once, not a tick later
update_measures(app, /*force=*/true, /*accumulate=*/true);
@@ -232,6 +256,7 @@ void on_measure_dialog_apply(GtkButton*, gpointer user) {
m.options.threshold_pct2 = gtk_spin_button_get_value(GTK_SPIN_BUTTON(d->band));
m.options.above = gtk_switch_get_active(GTK_SWITCH(d->above));
m.options.show_on_plot = gtk_check_button_get_active(GTK_CHECK_BUTTON(d->show));
m.options.gate = gtk_check_button_get_active(GTK_CHECK_BUTTON(d->gate));
m.options.noise_pct = gtk_spin_button_get_value(GTK_SPIN_BUTTON(d->noise));
m.options.hf_fc = gtk_spin_button_get_value(GTK_SPIN_BUTTON(d->hf));
if (d->edit >= 0 && static_cast<std::size_t>(d->edit) < app.measures.size()) {
@@ -261,7 +286,14 @@ void place_measure_marks(App& app) {
const double s0 = app.model->stream_at_x0();
app.measure_marks_display = app.measure_marks;
for (RowSet::MeasureMark& mk : app.measure_marks_display) {
if (!mk.level) mk.value -= s0;
if (!mk.level) {
mk.value -= s0;
// The badge text the plot draws beside the mark: its own time, in the pane's
// formatter -- the reading a cursor's ruler line carries, on the axis the mark is an
// answer about. Display x (an age, on a live view), because that is the coordinate the
// axis labels and the cursors pane both read.
mk.label = format_measure(mk.value, "s");
}
}
app.rows->set_measure_marks(app.measure_marks_display);
}
@@ -305,8 +337,21 @@ void update_measures(App& app, bool force, bool accumulate) {
const plot::Range pin = app.rows->pinned_range();
const bool pinned_view = app.rows->window_pinned() && pin.span() > 0.0 &&
std::isfinite(pin.min) && std::isfinite(pin.max);
// The gate band, clipped to that window, for the measurements that are gated. The gate is
// per measurement (`MeasureOptions::gate`), but the band is one and shared: when no
// measurement is gated the clip below is a no-op (`gated == window`) and nothing is walked
// twice. A band entirely outside the window clips to a zero span, and a zero-span window
// reads no sample, so a gated measurement over nothing falls to the same "--" an empty
// window gives.
const plot::Range window = pinned_view ? pin : view;
plot::Range gated = window;
if (app.rows->gate_visible()) {
const double lo = std::max(app.rows->gate_x0(), window.min);
const double hi = std::min(app.rows->gate_x1(), window.max);
gated = hi > lo ? plot::Range{lo, hi} : plot::Range{lo, lo};
}
const plot::Range swin{window.min + s0, window.max + s0};
const plot::Range gswin{gated.min + s0, gated.max + s0};
// Both windows of the tick, on the stream's clock: what is **on screen**, and (while pinned)
// what the pin *is*. The dump reads them instead of calling `stream_at_x0()` itself, which by
// the time it runs answers for a mode no frame was drawn in.
@@ -328,10 +373,19 @@ void update_measures(App& app, bool force, bool accumulate) {
// change below all move `swin` -- and a settled one satisfies `swin == measure_window` exactly
// and so reads `moved == false` on its own, which is what keeps it from recomputing every
// frame (457 passes in one run is the shape that mistake took).
const bool moved = app.measure_window_valid &&
std::fabs(swin.min - app.measure_window.min) +
std::fabs(swin.max - app.measure_window.max) >
0.02 * std::max(1.0e-12, swin.span());
const bool moved = (app.measure_window_valid &&
std::fabs(swin.min - app.measure_window.min) +
std::fabs(swin.max - app.measure_window.max) >
0.02 * std::max(1.0e-12, swin.span())) ||
// The gate is the second thing that can move a measurement's window
// without anything on screen moving: a band dragged on a still frame
// changes what the gated measurements describe, and a pane whose every
// measurement is ungated answers this with its own echo (the band clip
// is the window, which the test above already compares).
(app.measure_gate_window_valid &&
std::fabs(gswin.min - app.measure_gate_window.min) +
std::fabs(gswin.max - app.measure_gate_window.max) >
0.02 * std::max(1.0e-12, gswin.span()));
// A measurement is a statistic of the visible window, and *a live view moves that window every
// frame* -- so "recompute when the window moves" is "recompute every frame", at O(visible
// samples) per measurement. That is what stalled the frame tick (0.4 ms -> 13.7 ms, a third of
@@ -365,6 +419,8 @@ void update_measures(App& app, bool force, bool accumulate) {
}
app.measure_window = swin;
app.measure_window_valid = true;
app.measure_gate_window = gswin;
app.measure_gate_window_valid = true;
app.measure_was_held = held;
app.measure_stamp_us = now_us;
app.measure_stamp_valid = true;
@@ -414,13 +470,15 @@ void update_measures(App& app, bool force, bool accumulate) {
const double fc = m.options.hf_fc > 0.0 ? m.options.hf_fc : 0.0;
std::size_t wi = 0;
while (wi < app.measure_samples.size() &&
(app.measure_samples[wi].channel != channel || app.measure_samples[wi].fc != fc)) {
(app.measure_samples[wi].channel != channel || app.measure_samples[wi].fc != fc ||
app.measure_samples[wi].gated != m.options.gate)) {
++wi;
}
if (wi == app.measure_samples.size()) {
App::MeasureWindow fresh;
fresh.channel = channel;
fresh.fc = fc;
fresh.gated = m.options.gate;
measure_filter_set(fresh.scan_filter, fc, slot_dt);
measure_filter_set(fresh.acc_filter, fc, slot_dt);
app.measure_samples.push_back(fresh);
@@ -477,7 +535,11 @@ void update_measures(App& app, bool force, bool accumulate) {
std::vector<MeasureBucket>& buckets = app.measure_bucket_scratch;
for (App::MeasureWindow& w : app.measure_samples) {
const gint64 fetch_start_us = g_get_monotonic_time();
MeasureWindowReader reader(app, static_cast<std::size_t>(w.channel), window);
// The window this walk is about: the gate band for a gated walk, the pane's window whole
// otherwise. Everything below -- reader, buckets, chunk edges -- reads it through `wwindow`
// and never the pane's own.
const plot::Range wwindow = w.gated ? gated : window;
MeasureWindowReader reader(app, static_cast<std::size_t>(w.channel), wwindow);
const std::size_t n = reader.size();
const std::function<double(std::size_t)> time_of = [&reader](std::size_t i) {
return reader.time_of(i);
@@ -667,8 +729,15 @@ void update_measures(App& app, bool force, bool accumulate) {
// A time mark is stored on the **stream's** clock -- the walks report display x, so `s0`
// puts it back -- and `place_measure_marks()` converts it again on every frame it draws.
// That is what keeps the mark on its own sample as a live view rolls underneath it; a level
// mark is a y and is stored as it comes.
marks.push_back({row, spec.level, spec.level ? spec.value : spec.value + s0, color});
// mark is a y and is stored as it comes. Field by field: a partial brace-initialiser
// re-adds the missing-initialiser warning the baseline forbids (label has no room in a
// `MeasureMarkSpec` -- the badge text is built where the display conversion happens).
RowSet::MeasureMark mark;
mark.row = row;
mark.level = spec.level;
mark.value = spec.level ? spec.value : spec.value + s0;
mark.color = color;
marks.push_back(mark);
}
}
app.measure_marks = std::move(marks);
+11 -1
View File
@@ -39,6 +39,7 @@ Settings read_rows(const PrefsForm& f) {
s.relative_time = adw_switch_row_get_active(f.relative_row) != FALSE;
s.memory_limit_mb = static_cast<int>(adw_spin_row_get_value(f.memory_row));
s.theme = theme_name(adw_combo_row_get_selected(f.theme_row));
s.crosshair_axis_labels = adw_switch_row_get_active(f.crosshair_row) != FALSE;
// The row shows the *effective* directory, so the default is written out in full rather than
// described. Leaving it at that default is not a choice to pin it: the file keeps "unset", and the
// setting goes on following the cache directory it is named after.
@@ -67,6 +68,7 @@ void fill_rows(PrefsForm& f, const Settings& s) {
adw_switch_row_set_active(f.relative_row, s.relative_time ? TRUE : FALSE);
adw_spin_row_set_value(f.memory_row, s.memory_limit_mb);
adw_combo_row_set_selected(f.theme_row, s.theme == "light" ? 1 : (s.theme == "dark" ? 2 : 0));
adw_switch_row_set_active(f.crosshair_row, s.crosshair_axis_labels ? TRUE : FALSE);
f.filling = false;
}
@@ -146,6 +148,7 @@ void on_closed(AdwDialog*, gpointer user) {
f.relative_row = nullptr;
f.memory_row = nullptr;
f.theme_row = nullptr;
f.crosshair_row = nullptr;
f.browse = nullptr;
f.parent = nullptr;
}
@@ -160,13 +163,15 @@ void resolve_rows(PrefsForm& f) {
f.relative_row = ADW_SWITCH_ROW(gtk_builder_get_object(f.builder, "relative_row"));
f.theme_row = ADW_COMBO_ROW(gtk_builder_get_object(f.builder, "theme_row"));
f.memory_row = ADW_SPIN_ROW(gtk_builder_get_object(f.builder, "memory_row"));
f.crosshair_row = ADW_SWITCH_ROW(gtk_builder_get_object(f.builder, "crosshair_axis_labels_row"));
if (f.dir_row == nullptr || f.keep_row == nullptr || f.relative_row == nullptr ||
f.theme_row == nullptr || f.memory_row == nullptr) {
f.theme_row == nullptr || f.memory_row == nullptr || f.crosshair_row == nullptr) {
f.dir_row = nullptr;
f.keep_row = nullptr;
f.relative_row = nullptr;
f.theme_row = nullptr;
f.memory_row = nullptr;
f.crosshair_row = nullptr;
g_clear_object(&f.builder);
}
}
@@ -222,6 +227,7 @@ void prefs_open(PrefsForm& form, GtkWidget* parent, const Settings& current,
g_signal_connect(form.relative_row, "notify::active", G_CALLBACK(on_row_notify), &form);
g_signal_connect(form.memory_row, "notify::value", G_CALLBACK(on_row_notify), &form);
g_signal_connect(form.theme_row, "notify::selected", G_CALLBACK(on_row_notify), &form);
g_signal_connect(form.crosshair_row, "notify::active", G_CALLBACK(on_row_notify), &form);
if (GObject* reset = gtk_builder_get_object(form.builder, "reset_row"); reset != nullptr) {
g_signal_connect(reset, "activated", G_CALLBACK(on_reset), &form);
}
@@ -248,6 +254,7 @@ void prefs_probe_change(PrefsForm& form) {
adw_switch_row_set_active(form.relative_row, TRUE);
adw_spin_row_set_value(form.memory_row, 4096.0);
adw_combo_row_set_selected(form.theme_row, 2);
adw_switch_row_set_active(form.crosshair_row, FALSE);
}
/// A changed memory limit reaches the running model. Only when it differs from the model's own (every
@@ -277,6 +284,9 @@ void open_preferences(App& app) {
prefs_open(app.prefs, app.window, app.settings, [&app](const Settings& s, bool written) {
app.settings = s;
apply_memory_limit(app, s.memory_limit_mb);
// The crosshair's axis badges, at once like the theme: it is a drawing flag on the
// plots, and a change is the next frame's drawing.
app.rows->set_crosshair_axis_labels(s.crosshair_axis_labels);
// Only a failure is announced. A change arrives once per keystroke in the directory row, and a
// status line that flashed on each one would be the app shouting about its own bookkeeping.
if (!written) set_notice(app, "could not write the preferences file", 4);
+2
View File
@@ -64,6 +64,8 @@ struct PrefsForm {
AdwSwitchRow* relative_row = nullptr;
AdwSpinRow* memory_row = nullptr;
AdwComboRow* theme_row = nullptr;
/// The crosshair's axis-badge switch (`Settings::crosshair_axis_labels`).
AdwSwitchRow* crosshair_row = nullptr;
/// The folder chooser beside the directory row. A text field alone asks a user to know a path and
/// type it correctly; the chooser is how they find one, and what it returns goes into that row -- so
/// the text stays the single value and there is nothing to keep in step.
+559 -14
View File
@@ -219,7 +219,10 @@ gboolean exit_probe_step3b(gpointer user) {
// `dbg_window_text` is kept by `sync_window_display()`, the only place that knows the span the
// window control is showing: there is no label to read it back from any more.
slideover_close(*a); // opened by step 3 for the signal list's allocations read above
if (window != nullptr) gtk_window_destroy(GTK_WINDOW(window));
// `gtk_window_close()`, not `gtk_window_destroy()`: only a close emits `close-request`,
// which is where the window's lifecycle (the scope windows, the save flush, the application
// quit) lives -- GTK4 has no `GtkWidget::destroy` to hook.
if (window != nullptr) gtk_window_close(GTK_WINDOW(window));
return G_SOURCE_REMOVE;
}
@@ -1350,13 +1353,448 @@ gboolean measure_probe(gpointer user) {
}
rebuild_measures_pane(*a);
update_measures(*a, /*force=*/true, /*accumulate=*/true);
std::fprintf(stderr, "[measure-probe] %zu measurements on '%s'\n", a->measures.size(),
name.c_str());
std::fprintf(stderr, "[measure-probe] %zu measurements on '%s' (channel %d)\n",
a->measures.size(), name.c_str(), measure_channel_number(*a, name));
std::fflush(stderr);
g_timeout_add(250, measure_trace, a);
return G_SOURCE_REMOVE;
}
/// `UDPSCOPE_PROBE_GATE_MS`: the **per-measurement gate**. It adds two averages on one
/// channel -- index 0 ungated, index 1 with `MeasureOptions::gate` -- and puts the channel on
/// a plot (the way `enable_all_probe` does it: a channel with no trace leaves the display
/// epoch still, which is the state the `still=` leg needs to see move). The pane rebuild then
/// derives the band's visibility from the measure set, and the probe re-states its bounds to a
/// known relative interval, 300 ms ago to 100 ms ago, on the mock's 1 Hz sine: a whole-seconds
/// window is whole *periods* there, so the ungated average is exactly 0 however the phase
/// sits, while the gated slice's mean is wherever in its period the stream happens to be.
/// Three lines. `on=` carries the assertions: `band=1` (one gated measurement showed the
/// band), `placed=1` (the plot's region holds exactly the bounds the `RowSet` does),
/// `plain=1` (the ungated reading is zero -- it read the whole window), `split=1` (the gated
/// reading differs -- it read the band) and `gwin=1` (the pass booked `measure_gate_window` =
/// the band moved onto the stream's clock, the phase-free statement of *which* slice the
/// gated walk read). `still=`, a second later, is the relative behaviour: the band exactly
/// where it was put while `s0` moved under it. The exit dump's `gate:` line is the session
/// leg: the probe saves the bounds the way a drag end does, and a second run on the same
/// config restores the band -- and the two measurements' gate flags -- from it. **Not in one
/// run with the other measurement probes**: they add measurements of their own, and these
/// lines quote indexes 0 and 1.
gboolean gate_probe_step(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
switch (a->dbg_gate_step) {
case 0: {
// The signal is enabled *here*, and the measurement goes on a *channel*: the first
// signal of a stream is its timestamp, which is not acquired -- the same two rules
// `measure_probe` states, for the same reasons.
{
const std::vector<SignalInfo>& signals = a->model->signals();
for (std::size_t i = 0; i < signals.size(); ++i) {
if (signals[i].name == "sine") a->model->set_signal_enabled(i, true);
}
}
std::string name;
plot::u32 color_index = 0;
{
std::lock_guard<std::mutex> lock(a->model->mutex());
if (a->model->channels().empty()) return G_SOURCE_REMOVE;
const ChannelInfo& ch = a->model->channels().front();
name = ch.name;
color_index = static_cast<plot::u32>(
signal_color_index(a->model->signals(), ch.signal));
}
a->rows->assign(0, 0, name, name, static_cast<int>(color_index));
a->relayout_pending = true;
MeasureOptions gated_options;
gated_options.gate = true;
a->measures.push_back({name, MeasureType::Average, MeasureOptions{}});
a->measures.push_back({name, MeasureType::Average, gated_options});
// The rebuild derives the band from the measure set -- this, and not a widget's
// emission, is the one path a gate flag reaches the plots through -- and forces a
// recompute. The wait after it is not idle: the epoch the reading below needs is
// written by the first frame that binds the trace, and this callback runs between
// ticks.
rebuild_measures_pane(*a);
a->dbg_gate_step = 1;
g_timeout_add(800, gate_probe_step, a);
break;
}
case 1: {
const RowSet::Row& r0 = a->rows->row(0);
if (r0.plot == nullptr) return G_SOURCE_REMOVE;
// The ring must be older than the window before anything is measured: the ungated
// reading below is exact zero only because a whole-seconds window of the 1 Hz sine is
// whole *periods*, and a window clipped to what a young ring holds is a fraction of a
// cycle. `stream_at_x0()` is the newest sample's stream time, so waiting for 2 s of it
// is waiting for the window to be full. Bounded, so a stalled stream still prints.
if (a->model->stream_at_x0() < 2.0 && a->dbg_gate_wait < 40) {
++a->dbg_gate_wait;
return G_SOURCE_CONTINUE;
}
// A fixed relative interval -- 300 ms ago to 100 ms ago -- rather than a share of the
// view: the reading below must land on samples that *exist*, and at this point in a
// short run the view's left half can predate the ring's first sample. The newest half
// second cannot. A 200 ms slice of a sine has two zero-mean phases per second, and the
// band lands on one with a few percent probability per run -- a reading about the
// phase, not about the gate -- so a first attempt that reads a zero mean with a valid
// window moves the band a quarter period older and measures again, once.
bool band = false;
bool placed_ok = false;
bool plain_zero = false;
bool split = false;
bool gwin = false;
double lo = 0.0;
double hi = 0.0;
double s0 = 0.0;
plot::Range xr{};
for (int attempt = 0; attempt < 2 && !split; ++attempt) {
const double shift = 0.25 * attempt; // a quarter period, still inside the newest data
lo = -0.30 - shift;
hi = -0.10 - shift;
a->rows->set_gate(lo, hi);
// Saved the way a drag end saves (`ui.cpp`'s tick): the bounds are session state, and
// the exit dump's `gate:` line is read against the file a second run restores from.
schedule_layout_save(*a);
s0 = a->model->stream_at_x0();
xr = r0.plot->x_range(r0.axis_x);
update_measures(*a, /*force=*/true, /*accumulate=*/true);
band = a->rows->gate_visible();
const plot::Region& placed = r0.plot->region(r0.gate_region);
placed_ok = placed.x0 == a->rows->gate_x0() && placed.x1 == a->rows->gate_x1();
const bool have = a->measure_results.size() >= 2 && a->measure_results[0].valid &&
a->measure_results[1].valid;
const double plain = have ? a->measure_results[0].primary : 0.0;
const double gated = have ? a->measure_results[1].primary : 0.0;
// The ungated window is a whole number of seconds of a 1 Hz sine: whole periods, mean
// zero (to the double, and the tolerance is far coarser than the arithmetic).
plain_zero = have && std::fabs(plain) < 0.05;
split = have && std::fabs(gated - plain) > 0.01;
// The gated walk's window, booked by the pass itself (`App::measure_gate_window`):
// the band moved onto the stream's clock, which is the phase-free statement of "the
// gated measurement read *this* slice" -- the slice mean depends on where in its
// period the stream happens to be, the window does not. The tolerance is the same
// 5 % of the band as the production-interval slack: the probe's `s0` and the pass's
// can straddle one.
const double gtol = 0.05 * (hi - lo);
gwin = a->measure_gate_window_valid &&
std::fabs(a->measure_gate_window.min - (lo + s0)) < gtol &&
std::fabs(a->measure_gate_window.max - (hi + s0)) < gtol;
}
a->dbg_gate_x0 = lo;
a->dbg_gate_x1 = hi;
a->dbg_gate_s0 = s0;
std::fprintf(stderr,
"[gate-probe] on: gate=[%.6f..%.6f] view=[%.6f..%.6f] s0=%.6f "
"for=[%.6f..%.6f] band=%d placed=%d plain=%d split=%d gwin=%d "
"ungated=%s gated=%s\n",
lo, hi, xr.min, xr.max, s0, a->measure_window.min, a->measure_window.max,
band ? 1 : 0, placed_ok ? 1 : 0, plain_zero ? 1 : 0, split ? 1 : 0,
gwin ? 1 : 0,
a->measure_text.size() > 0 ? a->measure_text[0].c_str() : "--",
a->measure_text.size() > 1 ? a->measure_text[1].c_str() : "--");
std::fflush(stderr);
a->dbg_gate_step = 2;
g_timeout_add(1000, gate_probe_step, a);
break;
}
case 2: {
const double s0 = a->model->stream_at_x0();
const bool still =
a->rows->gate_x0() == a->dbg_gate_x0 && a->rows->gate_x1() == a->dbg_gate_x1;
const bool s0_moved = std::fabs(s0 - a->dbg_gate_s0) > 1.0e-9;
std::fprintf(stderr,
"[gate-probe] still: gate=[%.6f..%.6f] s0=[%.6f->%.6f] still=%d s0_moved=%d\n",
a->rows->gate_x0(), a->rows->gate_x1(), a->dbg_gate_s0, s0, still ? 1 : 0,
s0_moved ? 1 : 0);
std::fflush(stderr);
return G_SOURCE_REMOVE;
}
default:
return G_SOURCE_REMOVE;
}
return G_SOURCE_REMOVE;
}
/// `UDPSCOPE_PROBE_CROSSHAIR_MS`: drives a real pointer **hover** across row 0 through the
/// GTK adapter's own controller -- the only path a user's crosshair arrives by -- and asserts
/// that the plot then placed the crosshair and, with it, the two axis badges: the y value in
/// the y label strip, the x time in the x label strip, each inside its strip and outside the
/// axes. The pixels themselves are the plotter suite's (`crosshair_ink_stays_inside_the_axes`);
/// what this probe owns is the adapter -- the viewport conversion from its pointer pixels to
/// the plot's data -- and the flag the preference answers to, which the last leg switches off
/// and reads back from the plot.
gboolean crosshair_probe(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
switch (a->dbg_crosshair_step) {
case 0: {
// A trace under the pointer, by name on a plot: the same setup `gate_probe_step` makes
// and for the same two reasons (a channel, not `signals().front()`; and on a plot,
// because the hover needs axes with data to be worth hovering over).
{
const std::vector<SignalInfo>& signals = a->model->signals();
for (std::size_t i = 0; i < signals.size(); ++i) {
if (signals[i].name == "sine") a->model->set_signal_enabled(i, true);
}
}
std::string name;
plot::u32 color_index = 0;
{
std::lock_guard<std::mutex> lock(a->model->mutex());
if (a->model->channels().empty()) return G_SOURCE_REMOVE;
const ChannelInfo& ch = a->model->channels().front();
name = ch.name;
color_index = static_cast<plot::u32>(
signal_color_index(a->model->signals(), ch.signal));
}
a->rows->assign(0, 0, name, name, static_cast<int>(color_index));
a->relayout_pending = true;
a->dbg_crosshair_step = 1;
g_timeout_add(800, crosshair_probe, a);
break;
}
case 1: {
const RowSet::Row& r0 = a->rows->row(0);
plot::input::Controller* c =
a->area != nullptr ? a->area->row_controller(0) : nullptr;
if (r0.plot == nullptr || c == nullptr) return G_SOURCE_REMOVE;
const plot::Rect axes = r0.plot->layout().axes;
if (!(axes.w > 0.0f) || !(axes.h > 0.0f)) return G_SOURCE_REMOVE;
// A hover move into the middle of the axes: viewport pixels, the space the controller
// converts into the plot's data. No button, no transition -- a plain move.
c->on_pointer({axes.x + 0.5f * axes.w, axes.y + 0.4f * axes.h, 0.0f, 0.0f,
plot::input::Button::Left, 0u, false, false, false});
a->dbg_crosshair_step = 2;
g_timeout_add(300, crosshair_probe, a);
break;
}
case 2: {
const RowSet::Row& r0 = a->rows->row(0);
if (r0.plot == nullptr) return G_SOURCE_REMOVE;
const plot::PlotLayout& lay = r0.plot->layout();
const plot::CrosshairLayout& ch = lay.crosshair;
const bool y_ok = ch.y_axis_badge.w > 0.0f &&
ch.y_axis_badge.x >= lay.y_label_area.x - 0.5f &&
ch.y_axis_badge.right() <= lay.axes.x + 0.5f;
// The y badge is centred on the line it names: its middle row is the pointer's screen y
// (the probe injected one). A top-aligned badge -- the defect this reads for -- is a
// whole half-badge off, so one pixel of slack is generous.
const f32 pointer_y = lay.axes.y + 0.4f * lay.axes.h;
const bool y_centre = ch.y_axis_badge.w > 0.0f &&
std::abs(ch.y_axis_badge.y + 0.5f * ch.y_axis_badge.h - pointer_y) <= 1.0f;
const bool x_ok = ch.x_axis_badge.w > 0.0f &&
ch.x_axis_badge.x >= lay.x_label_area.x - 0.5f &&
ch.x_axis_badge.x + ch.x_axis_badge.w <= lay.x_label_area.right() + 0.5f &&
ch.x_axis_badge.y >= lay.axes.bottom() - 0.5f;
std::fprintf(stderr,
"[crosshair-probe] active=%d y=[%.0fx%.0f@%.0f,%.0f] x=[%.0fx%.0f@%.0f,%.0f] "
"y_ok=%d x_ok=%d y_centre=%d\n",
ch.active ? 1 : 0, ch.y_axis_badge.w, ch.y_axis_badge.h, ch.y_axis_badge.x,
ch.y_axis_badge.y, ch.x_axis_badge.w, ch.x_axis_badge.h, ch.x_axis_badge.x,
ch.x_axis_badge.y, y_ok ? 1 : 0, x_ok ? 1 : 0, y_centre ? 1 : 0);
std::fflush(stderr);
// Off again through the same call the preference makes -- the flag is the setting.
a->rows->set_crosshair_axis_labels(false);
a->dbg_crosshair_step = 3;
g_timeout_add(300, crosshair_probe, a);
break;
}
case 3: {
const RowSet::Row& r0 = a->rows->row(0);
if (r0.plot == nullptr) return G_SOURCE_REMOVE;
std::fprintf(stderr, "[crosshair-probe] off: plot_flag=%d\n",
r0.plot->crosshair_axis_labels() ? 1 : 0);
std::fflush(stderr);
a->rows->set_crosshair_axis_labels(true);
return G_SOURCE_REMOVE;
}
default:
return G_SOURCE_REMOVE;
}
return G_SOURCE_REMOVE;
}
/// `UDPSCOPE_PROBE_MAXIMIZE_MS`: the one-click full-screen toggle, exercised through the
/// `RowSet` call the button's handler makes (a probe cannot click an overlay button the frame
/// places). Asserts that a maximize shows the row's own plot and nothing else, that the toggle
/// back restores every row with its signal, and that the overlay carries one toggle per figure
/// row while the editor is down.
gboolean maximize_probe_step(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
switch (a->dbg_maximize_step) {
case 0: {
// Two single-cell rows with one signal each: the smallest arrangement a maximize can be
// told apart in.
const std::vector<SignalInfo>& signals = a->model->signals();
for (std::size_t i = 0; i < signals.size(); ++i) {
if (signals[i].name == "sine" || signals[i].name == "square")
a->model->set_signal_enabled(i, true);
}
std::string sine;
std::string square;
int sine_color = 0;
int square_color = 0;
{
std::lock_guard<std::mutex> lock(a->model->mutex());
for (const ChannelInfo& c : a->model->channels()) {
if (c.name == "sine") {
sine = c.name;
sine_color = signal_color_index(a->model->signals(), c.signal);
} else if (c.name == "square") {
square = c.name;
square_color = signal_color_index(a->model->signals(), c.signal);
}
}
}
if (sine.empty() || square.empty()) return G_SOURCE_REMOVE;
// A layout of one figure carries no full-screen toggle (there is nothing to enlarge
// *to*); the button appears together with the second figure row, below.
a->dbg_maximize_single = a->maximize_buttons.size();
a->rows->set_cells_per_row({1, 1});
a->rows->assign(0, 0, sine, sine, sine_color);
a->rows->assign(1, 0, square, square, square_color);
a->relayout_pending = true;
a->dbg_maximize_step = 1;
g_timeout_add(800, maximize_probe_step, a);
break;
}
case 1: {
// A frame has passed: the toggles are placed, one per figure row.
const int rows_before = a->rows->row_count();
const int frows_before = a->rows->figure_row_count();
const std::string row0_before = a->rows->row(0).signal_name[0];
const std::string row1_before = a->rows->row(1).signal_name[0];
const std::size_t buttons = a->maximize_buttons.size();
// The toggle rebuilds the figure *object* (`RowSet::figure()` is a `unique_ptr` that
// `rebuild_all()` replaces), so the area must be re-pointed the way the button's
// `toggle_fullscreen()` does -- otherwise the render keeps a freed figure (a probe-only
// hazard: the app's every path re-points). This is the whole action minus the buttons
// and panels, which the probe reads separately.
a->rows->toggle_maximize_figure_row(0);
if (a->area != nullptr) a->area->set_figure(&a->rows->figure());
const int rows_max = a->rows->row_count();
const int frows_max = a->rows->figure_row_count();
const bool flag_max = a->rows->figures_maximized();
const std::string row0_max = a->rows->row(0).signal_name[0];
a->rows->toggle_maximize_figure_row(0);
if (a->area != nullptr) a->area->set_figure(&a->rows->figure());
const int rows_back = a->rows->row_count();
const std::string row0_back = a->rows->row(0).signal_name[0];
const std::string row1_back = a->rows->row(1).signal_name[0];
const bool flag_back = a->rows->figures_maximized();
std::fprintf(stderr,
"[maximize-probe] single_buttons=%zu before: rows=%d frows=%d row0=%s row1=%s "
"buttons=%zu; max: rows=%d frows=%d flag=%d row0=%s; "
"back: rows=%d row0=%s row1=%s flag=%d\n",
a->dbg_maximize_single, rows_before, frows_before, row0_before.c_str(),
row1_before.c_str(), buttons, rows_max, frows_max, flag_max ? 1 : 0,
row0_max.c_str(), rows_back, row0_back.c_str(), row1_back.c_str(),
flag_back ? 1 : 0);
std::fflush(stderr);
a->relayout_pending = true;
// The corner reveal, next: no pointer has entered the area, so the toggles must be
// out of sight; then a pointer in the first row's top-right corner must bring that
// row's toggle out and only that one.
a->dbg_maximize_step = 2;
g_timeout_add(250, maximize_probe_step, a);
break;
}
case 2: {
a->dbg_maximize_hidden =
a->maximize_buttons.empty() || !gtk_widget_get_visible(a->maximize_buttons[0]) ? 1 : 0;
a->area_pointer_x = static_cast<double>(gtk_widget_get_width(a->area_widget)) - 8.0;
a->area_pointer_y = 8.0;
a->area_pointer_valid = true;
a->dbg_maximize_step = 3;
g_timeout_add(250, maximize_probe_step, a);
break;
}
case 3: {
const int shown = !a->maximize_buttons.empty() &&
gtk_widget_get_visible(a->maximize_buttons[0])
? 1
: 0;
const int other = a->maximize_buttons.size() > 1 &&
gtk_widget_get_visible(a->maximize_buttons[1])
? 1
: 0;
std::fprintf(stderr, "[maximize-probe] corner: hidden_when_off=%d shown_when_near=%d "
"other_row=%d\n",
a->dbg_maximize_hidden, shown, other);
std::fflush(stderr);
return G_SOURCE_REMOVE;
}
default:
return G_SOURCE_REMOVE;
}
return G_SOURCE_REMOVE;
}
/// `UDPSCOPE_PROBE_SCOPE_MS`: opens a **scope window** twice -- once copying the main window's
/// figure 0, once empty -- and asserts what the copy is: the same signals on the same cells,
/// bound to the same stream a few frames later, while the empty one stays empty. The window
/// creation itself (a real `GtkWindow`, an own `PlotArea`, its own `RowSet`) is exercised by
/// the same call the two menu entries make.
gboolean scope_probe_step(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
switch (a->dbg_scope_step) {
case 0: {
const std::vector<SignalInfo>& signals = a->model->signals();
for (std::size_t i = 0; i < signals.size(); ++i) {
if (signals[i].name == "sine") a->model->set_signal_enabled(i, true);
}
std::string name;
int color = 0;
{
std::lock_guard<std::mutex> lock(a->model->mutex());
if (!a->model->channels().empty()) {
const ChannelInfo& ch = a->model->channels().front();
name = ch.name;
color = signal_color_index(a->model->signals(), ch.signal);
}
}
if (name.empty()) return G_SOURCE_REMOVE;
a->rows->assign(0, 0, name, name, color);
a->relayout_pending = true;
a->dbg_scope_step = 1;
g_timeout_add(800, scope_probe_step, a);
break;
}
case 1: {
open_scope_window(*a, 0);
open_scope_window(*a, -1);
a->dbg_scope_step = 2;
g_timeout_add(600, scope_probe_step, a);
break;
}
case 2: {
if (a->scopes.size() != 2) {
std::fprintf(stderr, "[scope-probe] count=%zu (expected 2)\n", a->scopes.size());
std::fflush(stderr);
return G_SOURCE_REMOVE;
}
const App::ScopeWindow& copy = *a->scopes[0];
const App::ScopeWindow& empty = *a->scopes[1];
const std::string copied = copy.rows->row(0).signal_name[0];
const bool copied_bound = copy.rows->row(0).channel[0] >= 0;
const bool empty_ok = empty.rows->row(0).signal_name[0].empty();
const bool windows = copy.window != nullptr && empty.window != nullptr &&
gtk_widget_get_visible(GTK_WIDGET(copy.window)) &&
gtk_widget_get_visible(GTK_WIDGET(empty.window));
std::fprintf(stderr,
"[scope-probe] count=2 copied='%s' bound=%d empty_ok=%d windows=%d\n",
copied.c_str(), copied_bound ? 1 : 0, empty_ok ? 1 : 0, windows ? 1 : 0);
std::fflush(stderr);
return G_SOURCE_REMOVE;
}
default:
return G_SOURCE_REMOVE;
}
return G_SOURCE_REMOVE;
}
/// `UDPSCOPE_PROBE_LEVELS_MS`: puts a pane of **N** measurements on screen -- one `above` per
/// share in `UDPSCOPE_PROBE_LEVELS` (comma separated, default `50`) -- spread in turn over the
/// first `UDPSCOPE_PROBE_LEVELS_CHANNELS` channels (default 1), and says how many *windows* and
@@ -2985,6 +3423,24 @@ gboolean arm_probe(gpointer user) {
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a->trigger_toggle), TRUE);
std::fprintf(stderr, "[arm-probe] trigger armed\n");
std::fflush(stderr);
// The circle's states, sampled after the arm: waiting reads flash (the lit and dim halves,
// whichever half-second the sample lands in), a latched event reads green, a disarm red.
// The tick caches the state (`dbg_trigger_circle`), so this reads it without touching GTK.
for (int i = 1; i <= 4; ++i) {
g_timeout_add(static_cast<guint>(1000 * i),
+[](gpointer p) -> gboolean {
App* a = static_cast<App*>(p);
if (a->window == nullptr) return G_SOURCE_REMOVE;
static const char* const kState[] = {"green", "flash-lit", "flash-dim",
"red", "off"};
const int c = a->dbg_trigger_circle;
std::fprintf(stderr, "[arm-probe] circle=%s\n",
c >= -1 && c < 4 ? kState[c + 1] : "?");
std::fflush(stderr);
return G_SOURCE_REMOVE;
},
a);
}
return G_SOURCE_REMOVE;
}
@@ -3716,17 +4172,44 @@ gboolean prefs_probe(gpointer user) {
(row_underlines(G_OBJECT(a.prefs.dir_row)) + row_underlines(G_OBJECT(a.prefs.keep_row)) +
row_underlines(G_OBJECT(a.prefs.relative_row)) +
row_underlines(G_OBJECT(a.prefs.theme_row))));
std::fprintf(stderr, "[prefs-probe] opened=%d rows=%d dir=%s store=%s\n",
// The cache is keyed on the source **and its active configuration**, not on the source alone: the
// store's directory must be the dialog's directory, the source's folder and the configuration's
// folder, and pointing it at another configuration -- the helper every switch calls -- must move
// it there and back. Nothing is created: `set_configuration()` re-derives the path.
const std::string source_dir =
std::string(shown) + "/" + a.source_host + "-" + std::to_string(a.source_port);
const std::string active_cfg = active_configuration(a.source_host, a.source_port);
int cfg_keyed = 0;
int cfg_retarget = 0;
if (a.event_store != nullptr) {
cfg_keyed = a.event_store->configuration() == active_cfg &&
a.event_store->dir() == source_dir + "/" + active_cfg
? 1
: 0;
const std::string restore_name = a.config_name;
a.config_name = "probe-other";
retarget_event_store(a);
cfg_retarget = a.event_store->configuration() == "probe-other" &&
a.event_store->dir() == source_dir + "/probe-other"
? 1
: 0;
a.config_name = restore_name;
retarget_event_store(a);
cfg_retarget = cfg_retarget && a.event_store->configuration() == active_cfg &&
a.event_store->dir() == source_dir + "/" + active_cfg;
}
std::fprintf(stderr, "[prefs-probe] opened=%d rows=%d dir=%s store=%s cfg=%d cfg_switch=%d\n",
a.prefs.dialog != nullptr ? 1 : 0, a.prefs.dir_row != nullptr ? 1 : 0,
a.prefs.dir_row != nullptr ? shown : "-",
a.event_store != nullptr ? a.event_store->dir().c_str() : "-");
a.event_store != nullptr ? a.event_store->dir().c_str() : "-", cfg_keyed,
cfg_retarget);
g_timeout_add(400, +[](gpointer u) -> gboolean {
App& app = *static_cast<App*>(u);
prefs_probe_change(app.prefs);
const Settings back = load_settings();
std::fprintf(stderr, "[prefs-probe] dir=%s keep=%d rel=%d theme=%s live=%d\n",
std::fprintf(stderr, "[prefs-probe] dir=%s keep=%d rel=%d theme=%s xhair=%d live=%d\n",
back.event_cache_dir.c_str(), back.event_cache_max, back.relative_time ? 1 : 0,
back.theme.c_str(),
back.theme.c_str(), back.crosshair_axis_labels ? 1 : 0,
app.settings.theme == "dark" ? 1 : 0);
// And typing the default back must not *pin* it: the file keeps "unset", so the default still
// follows whatever cache directory the next run has. An empty value here is the whole point --
@@ -4705,6 +5188,50 @@ gboolean split_probe_step(gpointer user) {
g_x_axes.row0.c_str(), g_x_axes.bottom.c_str(), g_x_axes.gap_ok.c_str(),
g_x_axes.gap.c_str(), g_x_axes.col_gap, g_x_axes.note.c_str(), gesture);
rs.set_time_window(g_x_axes.window_before); // unpins and re-arms the live follow
split_probe_next(*a); // the split row's shared-y crosshair, below
break;
}
// --- Steps 28..30: the horizontal line spans a split row (its cells share one y) ---
case 28: {
// Two cells sharing one y on figure row 0 and one cell on row 1; the next steps hover
// cell 1 and read what the row's *other* cell got.
rs.set_cells_per_row({2, 1});
split_probe_clear_all(*a);
split_probe_assign(*a, 0, 0);
split_probe_assign(*a, 1, 1);
split_probe_restructured(*a);
split_probe_next(*a);
break;
}
case 29: {
if (rs.row(1).plot == nullptr || rs.row(0).plot == nullptr) {
split_probe_next(*a);
break;
}
// The adapter's own call, minus its pointer plumbing (which the crosshair probe covers):
// one shared x to every plot, the hovered plot's y into the figure -- a split row's cells
// share that y, so the line must land in both.
const plot::Transform tx = rs.row(1).plot->x_transform(0u);
const plot::Transform ty = rs.row(1).plot->y_transform(0u);
const plot::Rect axes = rs.row(1).plot->layout().axes;
const f64 x = tx.to_data_x(static_cast<f64>(axes.x + 0.5f * axes.w));
const f64 y = ty.to_data_y(static_cast<f64>(axes.y + 0.4f * axes.h));
rs.figure().set_crosshair(x, 1u, y, true);
split_probe_next(*a);
break;
}
case 30: {
const plot::CrosshairLayout& left = rs.row(0).plot->layout().crosshair;
const plot::CrosshairLayout& right = rs.row(1).plot->layout().crosshair;
const plot::CrosshairLayout& other = rs.row(2).plot->layout().crosshair;
const f32 dy = std::fabs((left.horizontal.y + 0.5f * left.horizontal.h) -
(right.horizontal.y + 0.5f * right.horizontal.h));
std::fprintf(stderr,
"[split-probe] cell-xhair left=%d right=%d centre_dy=%.1f sibling=%d "
"(the horizontal line spans a split row's shared y, and no other row)\n",
left.horizontal.w > 0.0f ? 1 : 0, right.horizontal.w > 0.0f ? 1 : 0,
static_cast<double>(dy), other.horizontal.h > 0.0f ? 1 : 0);
rs.figure().clear_crosshair();
break;
}
default:
@@ -5301,17 +5828,23 @@ static void strip_report(App& a, const char* tag, const char* what) {
const bool g_mea = a.strip_measure_group != nullptr && gtk_widget_get_visible(a.strip_measure_group);
const bool g_sep = a.strip_sep != nullptr && gtk_widget_get_visible(a.strip_sep);
const bool groups_ok = g_cur == cur_on && g_mea == mea_on && g_sep == (cur_on && mea_on);
// The `[+]` closes the Measures group: one adds a measurement from the strip, after the
// last chip (the old "Measures" panel button is gone). Structural, because a probe that
// clicked it would open a dialog on every report.
const bool plus_ok = a.strip_measure_group != nullptr &&
gtk_widget_get_last_child(a.strip_measure_group) != a.strip_measure_box &&
GTK_IS_BUTTON(gtk_widget_get_last_child(a.strip_measure_group));
std::fprintf(stderr,
"[%s-probe] %-14s strip: visible=%d expected=%d visible_ok=%d dock_shown=%d cursors=%zu "
"measures=%zu same_text=%d h=%d sens_same=%d collapsed=%d window=%d groups=%d%d%d "
"groups_ok=%d\n",
"groups_ok=%d add=%d\n",
tag, what, visible ? 1 : 0, expected ? 1 : 0, visible == expected ? 1 : 0,
dock_shown ? 1 : 0, a.strip_cursor_deltas.size(),
a.strip_measure_labels.size(), same, a.readouts_strip != nullptr
? gtk_widget_get_height(a.readouts_strip) : -1,
sens_same ? 1 : 0, collapsed ? 1 : 0,
a.window != nullptr ? gtk_widget_get_width(a.window) : -1,
g_cur ? 1 : 0, g_mea ? 1 : 0, g_sep ? 1 : 0, groups_ok ? 1 : 0);
g_cur ? 1 : 0, g_mea ? 1 : 0, g_sep ? 1 : 0, groups_ok ? 1 : 0, plus_ok ? 1 : 0);
}
/// Puts the dock away through the wish the tick re-asserts and reads the strip with it hidden
@@ -6427,8 +6960,14 @@ gboolean configs_probe(gpointer user) {
}
return out.empty() ? std::string("-") : out;
};
std::fprintf(stderr, "[configs] before=%s active=%s\n", joined().c_str(),
active_configuration(a->source_host, a->source_port).c_str());
// The event cache is keyed on the *configuration*, so each line carries the store's own key: it
// must follow the active configuration through save-as and back, or a user's events for one
// configuration would be listed under another.
const auto store_key = [&]() {
return a->event_store != nullptr ? a->event_store->configuration() : std::string("-");
};
std::fprintf(stderr, "[configs] before=%s active=%s store=%s\n", joined().c_str(),
active_configuration(a->source_host, a->source_port).c_str(), store_key().c_str());
// A non-default band and corner before the save, so the round trip below proves the two keys are
// *written*: left at their defaults, a key that never made it to the file would read back the same.
@@ -6438,8 +6977,9 @@ gboolean configs_probe(gpointer user) {
a->measures.front().options.hf_fc = 250.0;
}
const bool saved = save_configuration_as(*a, "probe", "written by the configs probe");
std::fprintf(stderr, "[configs] save-as=%d names=%s active=%s\n", saved ? 1 : 0,
joined().c_str(), active_configuration(a->source_host, a->source_port).c_str());
std::fprintf(stderr, "[configs] save-as=%d names=%s active=%s store=%s\n", saved ? 1 : 0,
joined().c_str(), active_configuration(a->source_host, a->source_port).c_str(),
store_key().c_str());
// The round trip: what a reopened configuration must carry, note and startup settings included.
LayoutState st;
@@ -6462,7 +7002,12 @@ gboolean configs_probe(gpointer user) {
noise_back, hf_back);
forget_configuration(a->source_host, a->source_port, "probe");
std::fprintf(stderr, "[configs] after forget names=%s\n", joined().c_str());
// The forgotten one was the active one: the dialog clears the name and lets the cache follow
// whatever is active now, and the probe does the same so the reading is that path's.
if (a->config_name == "probe") a->config_name.clear();
retarget_event_store(*a);
std::fprintf(stderr, "[configs] after forget names=%s active=%s store=%s\n", joined().c_str(),
active_configuration(a->source_host, a->source_port).c_str(), store_key().c_str());
std::fflush(stderr);
return G_SOURCE_REMOVE;
}
+20
View File
@@ -56,6 +56,26 @@ const char* badge_markup(BadgePhase phase) {
return "<span foreground=\"#5A5A5A\">\u25CF</span>";
}
/// What the trigger status circle is saying, by state: 0 solid green (an event is on screen,
/// or a capture is filling), 1 and 2 the bright and dim halves of the waiting flash, 3 red
/// (disarmed). Same glyph and the same colour family as the activity badge -- the two sit
/// beside each other on the status row, and two alphabets there would be a puzzle -- and one
/// glyph in every state, for the same width-locked reason.
const char* trigger_circle_markup(int state) {
switch (state) {
case 0:
case 1:
return "<span foreground=\"#45E045\">\u25CF</span>";
case 2:
return "<span foreground=\"#1C5A1C\">\u25CF</span>";
case 3:
return "<span foreground=\"#E04545\">\u25CF</span>";
default:
break;
}
return "<span foreground=\"#5A5A5A\">\u25CF</span>";
}
/// A short-lived line in front of the status text: the outcome of an action that the
/// picture does not show by itself (a drop that changed nothing, an export that wrote a
/// file). `tick()` clears it when it expires.
+9 -5
View File
@@ -441,8 +441,11 @@ struct TourStep {
constexpr TourStep kTourSteps[] = {
{"plot", nullptr, "The plot",
"Every signal you have switched on is drawn here, all plots sharing one time axis. The wheel "
"zooms, a drag pans, and a right-click on a plot opens that plot's own menu.",
"Every signal you have switched on is drawn here, all plots sharing one time axis -- and the "
"plots of a split row share one y as well. The wheel zooms, a drag pans, and a right-click on "
"a plot opens its own menu: fit, full screen (the button also comes out in a figure's corner "
"as the pointer nears it), a second window showing that figure, and the layout editor, which "
"splits a row into up to four plots.",
&App::area_overlay},
{"badges", nullptr, "The badges",
"Setup is reached by the thing it configures. A badge per signal opens that signal's colour, "
@@ -457,9 +460,10 @@ constexpr TourStep kTourSteps[] = {
{"side", nullptr, "The dock",
"The dock beside the plot is a read-only copy of the cursors and of every measurement, in a "
"fixed width, with the recorded events below them: one row per latched event, newest first, and "
"one click on a row replays it. Edit, beside each heading, opens that instrument's full panel "
"over the plot, Manage opens the full list of events; a click on a measurement opens its "
"dialog. The dock button in the bar above shows and hides the dock.",
"one click on a row replays it. Each source and each of its configurations keeps its own cache. "
"Edit, beside each heading, opens that instrument's full panel over the plot, Manage opens the "
"full list of events; a click on a measurement opens its dialog. The dock button in the bar "
"above shows and hides the dock.",
&App::side_box},
{"signals", "signals", "Adding a signal",
"What can be added: the signals the source declares that are not acquired yet, each with the "