udpscope: an oscilloscope view for a UDPStreamer source
GTK4 and libadwaita. It receives one UDP stream through the vendored udpsclient, buffers it in a large ring, and draws it with the vendored plotter: a trigger with a pre/post event window, a pause that freezes the window, cursors, measurements, math channels in a node editor, an event cache, named configurations, preferences, and an export of the visible window to .npy/.csv. Waveform files can be opened and analysed in place. The interface is two threads -- a producer that owns the socket and the GTK main thread that owns every widget and the GL context -- sharing one model under one mutex, with samples crossing through lock-free rings. deps/plotter is a submodule pinned to the commit this tree builds against; deps/udpsclient is vendored in place. docs/claude/ documents the rules that cost a defect to learn, and SPECS.md is the requirement document.
This commit is contained in:
1 parent
8f54841c2c
commit
0e256764d5
41 files changed
+2081
-154
No files matched your search
@@ -115,6 +115,7 @@ Config parse_args(int argc, char** argv) {
|
||||
c.open_waveform = value_of(argc, argv, i, "--open");
|
||||
} else if (a == "--relative-time") {
|
||||
c.relative_time = true;
|
||||
c.relative_time_specified = true;
|
||||
} else {
|
||||
fail("unknown argument: " + a);
|
||||
}
|
||||
|
||||
@@ -45,6 +45,11 @@ struct Config {
|
||||
/// sample's x. Absolute stream time is still recorded, for export only (`todo.md` item 2).
|
||||
bool relative_time = false;
|
||||
|
||||
/// Whether relative time was *stated* rather than left at its default -- by `--relative-time`, or
|
||||
/// by the preferences, which the source chooser can reach before this run is planned. A source's
|
||||
/// stored configuration is a statement from an *older* session, so it only fills in the gap.
|
||||
bool relative_time_specified = false;
|
||||
|
||||
/// `--capture-packets N`: the ring size of the raw-datagram capture, 1..100000.
|
||||
/// 0 means the flag was absent: the capture is off, and the popover button then
|
||||
/// uses the 1000-slot default when the user toggles it.
|
||||
|
||||
+8
-11
@@ -17,17 +17,12 @@
|
||||
namespace udpscope {
|
||||
namespace {
|
||||
|
||||
/// One directory under the cache holds *every* source's events, and each source gets a folder inside
|
||||
/// it: `<cache>/udpscope/events/<source>/`. The other way round -- a folder per source with an
|
||||
/// `events` inside -- spreads one feature over as many directories as there are sources, and turns
|
||||
/// "where are my events" into a question about which host you last used.
|
||||
constexpr const char* kEventsSubdir = "/events";
|
||||
|
||||
void mkdirs(const std::string& path) {
|
||||
std::string cur;
|
||||
for (std::size_t i = 0; i < path.size(); ++i) {
|
||||
cur.push_back(path[i]);
|
||||
if (path[i] != '/' && i + 1 != path.size()) continue;
|
||||
::mkdir(cur.c_str(), 0755);
|
||||
}
|
||||
}
|
||||
|
||||
/// `2026-10-09T11:32:00` in local time, plus the sequence number: two events inside one second are
|
||||
/// ordinary (a 5 Hz trigger latches five times a second), and a stem that collides would overwrite
|
||||
/// the event it collided with.
|
||||
@@ -236,7 +231,9 @@ bool event_meta_parse(const std::string& text, EventRecord* out) {
|
||||
return any;
|
||||
}
|
||||
|
||||
EventStore::EventStore(std::string source) {
|
||||
std::string default_events_dir() { return std::string(cache_dir()) + kEventsSubdir; }
|
||||
|
||||
EventStore::EventStore(std::string source, std::string base_dir) {
|
||||
if (source.empty()) return;
|
||||
std::string safe;
|
||||
for (char c : source) {
|
||||
@@ -244,7 +241,7 @@ EventStore::EventStore(std::string source) {
|
||||
c == '.' || c == '_' || c == '-';
|
||||
safe.push_back(ok ? c : '_');
|
||||
}
|
||||
dir_ = cache_dir() + "/" + safe + kEventsSubdir;
|
||||
dir_ = (base_dir.empty() ? default_events_dir() : base_dir) + "/" + safe;
|
||||
}
|
||||
|
||||
std::string EventStore::save(const ExportTable& table, const EventRecord& meta, std::size_t keep) {
|
||||
|
||||
+11
-2
@@ -29,7 +29,7 @@ struct EventRecord {
|
||||
std::uint64_t seq = 0; ///< the run's own event counter, so two events in one second differ
|
||||
};
|
||||
|
||||
/// The cache of captured events: `<cache_dir>/<source>/events/`, one `.npy` per event written in
|
||||
/// The cache of captured events: `<cache_dir>/events/<source>/`, one `.npy` per event written in
|
||||
/// the **export format** -- so `--open` and the chooser's Waveforms page read it back with the
|
||||
/// loader that already exists -- beside a `.txt` sidecar holding what the list shows without
|
||||
/// loading the samples, and whatever thumbnail the caller rendered.
|
||||
@@ -37,10 +37,19 @@ struct EventRecord {
|
||||
/// No GTK: the caller renders the thumbnail and hands over a path, exactly as it hands over the
|
||||
/// table. The store owns naming, the sidecar, pruning and listing, which is what makes it testable
|
||||
/// without a window.
|
||||
/// The directory every source's event folder is created under when no preference says otherwise:
|
||||
/// `<cache>/udpscope/events`. Exposed because the preferences dialog shows it -- "the standard
|
||||
/// location" is not an answer a user can act on, and a second copy of the path computed in the dialog
|
||||
/// is a second thing to get wrong.
|
||||
std::string default_events_dir();
|
||||
|
||||
class EventStore {
|
||||
public:
|
||||
/// `source` is the `<host>-<port>` pair, the same key the packet capture uses.
|
||||
explicit EventStore(std::string source = {});
|
||||
/// `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 = {});
|
||||
|
||||
/// 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.
|
||||
|
||||
@@ -6,7 +6,9 @@ pointer is over, and because the rows share one time axis, an x change moves all
|
||||
<b>How to reach it</b>
|
||||
|
||||
<b>Wheel</b> zooms both axes · <b>Shift+wheel</b> zooms x only · <b>Ctrl+wheel</b> zooms y only.
|
||||
<b>Drag</b> pans · <b>Ctrl+drag</b> is a box zoom on x · <b>Alt+drag</b> a box zoom on y.
|
||||
<b>Drag</b> pans · <b>Shift+drag</b> is a box zoom on both axes · <b>Ctrl+drag</b> on x only ·
|
||||
<b>Alt+drag</b> on values only.
|
||||
The area being chosen is framed while you drag it, so you can see what the release will apply.
|
||||
<b>+</b> and <b>-</b> zoom both axes; <b>Ctrl</b> with them restricts to x, <b>Alt</b> to y.
|
||||
<b>Arrow keys</b> pan, <b>f</b> fits, <b>Escape</b> cancels a gesture in progress.
|
||||
The full table is in the shortcut overview (<tt>Ctrl+/</tt>).
|
||||
|
||||
@@ -15,7 +15,7 @@ share. <b>Arm</b> starts watching; <tt>a</tt> arms and disarms from the keyboard
|
||||
<b>Worth knowing</b>
|
||||
|
||||
• <b>Every triggered acquisition is kept.</b> Latches are written to the event cache
|
||||
(<tt>~/.cache/udpscope/<source>/events/</tt>) as a <tt>.npy</tt> of the event, a
|
||||
(<tt>~/.cache/udpscope/events/<source>/</tt>) as a <tt>.npy</tt> of the event, a
|
||||
<tt>.txt</tt> of what it was, and a <tt>.bmp</tt> picture drawn from the samples. The
|
||||
<b>Events</b> pane on the left -- opened from the toolbar's event-history button, which appears
|
||||
while recording is enabled, and collapsed until you ask for it -- lists the last 32 newest first
|
||||
|
||||
+31
-55
@@ -16,12 +16,16 @@ namespace {
|
||||
/// Nulled by each window's own close handler, so a second open cannot present a dead widget.
|
||||
/// One pointer per window, file-scope because there is one application.
|
||||
///
|
||||
/// The help window is a `GtkWindow`. The other two are `AdwDialog`s, which are **not**
|
||||
/// `GtkWindow`s: they have no `::close-request`, so they clear on `::closed` and close with
|
||||
/// `adw_dialog_close()`.
|
||||
/// The help window and the shortcut overview are `GtkWindow`s. The About dialog is an
|
||||
/// `AdwDialog`, which is **not** a `GtkWindow`: it has no `::close-request`, so it clears on
|
||||
/// `::closed` and closes with `adw_dialog_close()`.
|
||||
GtkWidget* g_help = nullptr;
|
||||
AdwDialog* g_about = nullptr;
|
||||
AdwDialog* g_shortcuts = nullptr;
|
||||
/// The overview is the one window here built from a `.ui` file, so its builder is held too: the window
|
||||
/// belongs to the builder, and a *destroyed* `GtkWindow` cannot be presented again -- which is why the
|
||||
/// builder is replaced on the next opening rather than reused.
|
||||
GtkWidget* g_shortcuts = nullptr;
|
||||
GtkBuilder* g_shortcuts_ui = nullptr;
|
||||
|
||||
gboolean on_window_close(GtkWindow* window, gpointer slot) {
|
||||
(void)window;
|
||||
@@ -285,67 +289,39 @@ void show_help(GtkWindow* parent) {
|
||||
gtk_window_present(GTK_WINDOW(window));
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
/// Appends one table row to `dialog`, opening a new section whenever the row's `section`
|
||||
/// string differs from the last one. `*current` is the section being filled, `*title` the
|
||||
/// name it carries; both are owned by the caller so the two tables share one run.
|
||||
///
|
||||
/// `accel` is handed to `AdwShortcutsItem` as an accelerator when it is non-empty and
|
||||
/// `subtitle` is used when it is not, which is how a wheel or a drag gets a row: neither
|
||||
/// GTK's nor libadwaita's accelerator syntax can name one, and asking them to parse
|
||||
/// "Wheel" costs a parse warning and draws nothing.
|
||||
void append_shortcut(AdwShortcutsDialog* dialog, AdwShortcutsSection** current,
|
||||
const char** title, const char* section, const char* accel,
|
||||
const char* subtitle, const char* description) {
|
||||
if (*current == nullptr || std::string(*title) != section) {
|
||||
*current = adw_shortcuts_section_new(section);
|
||||
*title = section;
|
||||
adw_shortcuts_dialog_add(dialog, *current);
|
||||
}
|
||||
AdwShortcutsItem* item = adw_shortcuts_item_new(description, accel);
|
||||
if (subtitle != nullptr && *subtitle != '\0') {
|
||||
adw_shortcuts_item_set_subtitle(item, subtitle);
|
||||
}
|
||||
adw_shortcuts_section_add(*current, item);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void show_shortcuts(GtkWindow* parent) {
|
||||
if (g_shortcuts != nullptr) {
|
||||
adw_dialog_present(g_shortcuts, GTK_WIDGET(parent));
|
||||
gtk_window_present(GTK_WINDOW(g_shortcuts));
|
||||
return;
|
||||
}
|
||||
|
||||
AdwDialog* dialog = adw_shortcuts_dialog_new();
|
||||
g_shortcuts = dialog;
|
||||
adw_dialog_set_title(dialog, "Keyboard and mouse shortcuts");
|
||||
|
||||
AdwShortcutsSection* section = nullptr;
|
||||
const char* section_title = nullptr;
|
||||
int accel_count = 0;
|
||||
const Accel* accels = app_accels(&accel_count);
|
||||
for (int i = 0; i < accel_count; ++i) {
|
||||
append_shortcut(ADW_SHORTCUTS_DIALOG(dialog), §ion, §ion_title,
|
||||
accels[i].section, accels[i].accel, "", accels[i].description);
|
||||
}
|
||||
int gesture_count = 0;
|
||||
const Gesture* gestures = plot_gestures(&gesture_count);
|
||||
for (int i = 0; i < gesture_count; ++i) {
|
||||
append_shortcut(ADW_SHORTCUTS_DIALOG(dialog), §ion, §ion_title,
|
||||
gestures[i].section, gestures[i].accel, gestures[i].subtitle,
|
||||
gestures[i].description);
|
||||
}
|
||||
|
||||
g_signal_connect(dialog, "closed", G_CALLBACK(on_dialog_closed), &g_shortcuts);
|
||||
adw_dialog_present(dialog, GTK_WIDGET(parent));
|
||||
// A `GtkShortcutsWindow` from `src/ui/shortcuts.ui` -- gedit's arrangement, and GTK's own: one
|
||||
// section per page, with the sidebar and the search box drawn by the window itself.
|
||||
//
|
||||
// A UI file rather than widget construction because of the mouse rows. An accelerator cannot spell
|
||||
// "Ctrl and drag", and libadwaita's `AdwShortcutsItem` -- the modern replacement for this widget --
|
||||
// has no icon property at all, so its cell reads "No Shortcut". `GtkShortcutsShortcut`'s one generic
|
||||
// gesture type draws the `icon` the row names, which is what puts a mouse glyph where the keycaps
|
||||
// would be. (The widget is deprecated since 4.20, but this uses none of its functions: the file is
|
||||
// loaded as data and presented as a `GtkWindow`, so the build stays at its four warnings.)
|
||||
//
|
||||
// A builder per opening, not one reused: the window belongs to the builder and `close-request`
|
||||
// destroys the window, so the resource is parsed again rather than an already-dead window fetched.
|
||||
g_clear_object(&g_shortcuts_ui);
|
||||
g_shortcuts_ui = gtk_builder_new_from_resource("/org/udpscope/ui/shortcuts.ui");
|
||||
if (g_shortcuts_ui == nullptr) return;
|
||||
GObject* overlay = gtk_builder_get_object(g_shortcuts_ui, "help_overlay");
|
||||
if (overlay == nullptr) return;
|
||||
g_shortcuts = GTK_WIDGET(overlay);
|
||||
gtk_window_set_transient_for(GTK_WINDOW(g_shortcuts), parent);
|
||||
g_signal_connect(g_shortcuts, "close-request", G_CALLBACK(on_window_close), &g_shortcuts);
|
||||
gtk_window_present(GTK_WINDOW(g_shortcuts));
|
||||
}
|
||||
|
||||
void close_help_windows() {
|
||||
if (g_help != nullptr) gtk_window_close(GTK_WINDOW(g_help));
|
||||
if (g_about != nullptr) adw_dialog_close(g_about);
|
||||
if (g_shortcuts != nullptr) adw_dialog_close(g_shortcuts);
|
||||
if (g_shortcuts != nullptr) gtk_window_close(GTK_WINDOW(g_shortcuts));
|
||||
}
|
||||
|
||||
} // namespace udpscope
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- SPDX-License-Identifier: MIT -->
|
||||
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
|
||||
<!-- A boundary that can be dragged: a vertical double-headed arrow. It says *which* edge the handle
|
||||
moves, which a plain grip does not. Every coordinate is a whole pixel of the 16px cell, every
|
||||
subpath is a filled `path` wound the same way (a stroke would keep this colour on a dark theme),
|
||||
and the two heads meet the shaft exactly, at x 5..11 and x 7..9. -->
|
||||
<svg height="16px" width="16px" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill="#2e3436" d="M7 4 H9 V12 H7 Z
|
||||
M8 1 L11 5 H5 Z
|
||||
M8 15 L5 11 H11 Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 734 B |
@@ -22,6 +22,7 @@
|
||||
<file preprocess="xml-stripblanks">scalable/actions/udpscope-measure-symbolic.svg</file>
|
||||
<file preprocess="xml-stripblanks">scalable/actions/udpscope-math-symbolic.svg</file>
|
||||
<file preprocess="xml-stripblanks">scalable/actions/udpscope-events-symbolic.svg</file>
|
||||
<file preprocess="xml-stripblanks">scalable/actions/udpscope-resize-rows-symbolic.svg</file>
|
||||
<file preprocess="xml-stripblanks">scalable/apps/org.udpscope.app.svg</file>
|
||||
<file preprocess="xml-stripblanks">scalable/apps/org.udpscope.app-symbolic.svg</file>
|
||||
</gresource>
|
||||
|
||||
+11
-1
@@ -31,6 +31,12 @@ std::string sanitize(const std::string& host) {
|
||||
return out.empty() ? "_" : out;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
/// Creates `path` and every missing component, the last one included, like `mkdir -p`. Outside the
|
||||
/// anonymous namespace because the preferences file is written through it too: a bare `mkdir()` of
|
||||
/// `<config>/udpscope` fails when `<config>` does not exist yet, which is what a fresh install looks
|
||||
/// like -- and it fails *silently*, leaving the settings dialog reporting a write it did not make.
|
||||
void mkdirs(const std::string& path) {
|
||||
// **Every** component, the last one included. The condition used to be "the path has a trailing
|
||||
// slash", so the final directory was never created: `~/.config/udpscope` did not exist, the
|
||||
@@ -44,6 +50,8 @@ void mkdirs(const std::string& path) {
|
||||
}
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
std::string toml_escape(const std::string& s) {
|
||||
std::string out = "\"";
|
||||
for (char c : s) {
|
||||
@@ -504,6 +512,8 @@ bool load_layout(const std::string& path, LayoutState& out) {
|
||||
parse_string_array(value.substr(1, value.size() - 2), names);
|
||||
}
|
||||
st.rows.back().signals = names;
|
||||
} else if (key == "weight") {
|
||||
st.rows.back().weight = std::strtod(value.c_str(), nullptr);
|
||||
}
|
||||
} else if (section == Section::Math && !st.math.empty()) {
|
||||
MathChannel& m = st.math.back();
|
||||
@@ -620,7 +630,7 @@ bool save_layout(const std::string& path, const LayoutState& state) {
|
||||
o << "relative_time = " << (state.relative_time ? "true" : "false") << "\n";
|
||||
if (state.capture_packets > 0) o << "capture_packets = " << state.capture_packets << "\n";
|
||||
for (const auto& row : state.rows) {
|
||||
o << "\n[[plot]]\nsignals = [";
|
||||
o << "\n[[plot]]\nweight = " << row.weight << "\nsignals = [";
|
||||
for (std::size_t i = 0; i < row.signals.size(); ++i) {
|
||||
if (i != 0) o << ", ";
|
||||
o << toml_escape(row.signals[i]);
|
||||
|
||||
@@ -20,6 +20,9 @@ struct LayoutState {
|
||||
struct Row {
|
||||
/// One entry per slot; an empty string is an empty slot.
|
||||
std::vector<std::string> signals;
|
||||
/// The row's share of the figure's height, 1 for equal rows. Part of the layout because a sizing a
|
||||
/// user dragged is an arrangement, and an arrangement that is not saved is one that is lost.
|
||||
double weight = 1.0;
|
||||
};
|
||||
|
||||
double window_s = 0.0; ///< 0 keeps the default window
|
||||
@@ -126,6 +129,10 @@ std::string config_dir();
|
||||
/// lives (the packet captures; item 9's event cache). Created on demand by callers.
|
||||
std::string cache_dir();
|
||||
|
||||
/// Creates `path` and every missing component, like `mkdir -p`. Public because the preferences are
|
||||
/// written under the config directory too.
|
||||
void mkdirs(const std::string& path);
|
||||
|
||||
/// The *legacy* single-file path, `~/.config/udpscope/<host>_<port>.toml`, with the directory
|
||||
/// created. Still the import source for a file written before configurations existed; nothing
|
||||
/// writes here any more.
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
#include "acquisition.hpp"
|
||||
#include "config.hpp"
|
||||
#include "settings.hpp"
|
||||
#include "waveform.hpp"
|
||||
#include "model.hpp"
|
||||
#include "plot_host.hpp"
|
||||
@@ -16,6 +17,10 @@ int main(int argc, char** argv) {
|
||||
udpscope::Config cfg;
|
||||
try {
|
||||
cfg = udpscope::parse_args(argc, argv);
|
||||
// The preferences fill in what the command line did not decide. `--relative-time` is an explicit
|
||||
// instruction for this run, so a preference can only turn the mode on, never off -- which is also
|
||||
// why this is read here, before the buffers are planned: the mode changes how they are filled.
|
||||
if (udpscope::load_settings().relative_time) cfg.relative_time = true;
|
||||
} catch (const std::exception& e) {
|
||||
std::fprintf(stderr, "udpscope: %s\n", e.what());
|
||||
return 2;
|
||||
|
||||
@@ -526,6 +526,13 @@ double ScopeModel::channel_dt(std::size_t channel) const {
|
||||
BufferStore& ScopeModel::live() { return *store_; }
|
||||
|
||||
void ScopeModel::live_window_locked(double lo_s, double hi_s, u64& first, u64& last) {
|
||||
// There may be **no store at all**: `rebuild_locked()` resets it for a stream with no channels, which
|
||||
// is what the model is in until a CONFIG arrives. `live()` dereferences the pointer, so every path
|
||||
// that can be reached without a stream has to ask first -- pausing a scope that is not connected did
|
||||
// not, and walked into this function through `publish_pause_view()`, a null dereference.
|
||||
first = 0;
|
||||
last = 0;
|
||||
if (store_ == nullptr) return;
|
||||
BufferStore& src = live();
|
||||
plot::data::RingBuffer<f64>& tsrc = src.time();
|
||||
|
||||
@@ -578,6 +585,10 @@ void ScopeModel::live_window_locked(double lo_s, double hi_s, u64& first, u64& l
|
||||
}
|
||||
|
||||
void ScopeModel::publish_pause_view(double lo_s, double hi_s, double x_offset) {
|
||||
// Nothing has arrived yet, so there is nothing to freeze: pausing a scope that is not connected is a
|
||||
// legitimate gesture (the button works), and it must not take a window from a store that does not
|
||||
// exist.
|
||||
if (store_ == nullptr) return;
|
||||
// Timed from before the lock: the wait for it is part of what freezing a view costs the thread
|
||||
// doing it, and this one is the UI's.
|
||||
const std::uint64_t t0 = now_ns();
|
||||
|
||||
@@ -252,9 +252,23 @@ void RowSet::install(Row& r, int /*row_index*/, plot::Plot& p) {
|
||||
// `Figure::link_x()` refuses a member whose x axis still follows on its own.
|
||||
}
|
||||
|
||||
void RowSet::set_row_weights(std::vector<float> weights) {
|
||||
// The count is not checked here: `apply_row_weights()` sizes it to the rows that exist, so a layout
|
||||
// with rows added or removed keeps working instead of silently losing its sizing.
|
||||
row_weights_ = std::move(weights);
|
||||
if (figure_ != nullptr) apply_row_weights();
|
||||
}
|
||||
|
||||
void RowSet::apply_row_weights() {
|
||||
if (row_weights_.empty() || figure_ == nullptr) return;
|
||||
row_weights_.resize(static_cast<std::size_t>(rows_), 1.0f);
|
||||
(void)figure_->set_row_weights(row_weights_);
|
||||
}
|
||||
|
||||
void RowSet::rebuild_all() {
|
||||
figure_ = std::make_unique<plot::Figure>(static_cast<plot::u32>(rows_));
|
||||
figure_->set_row_gap(kRowGap);
|
||||
apply_row_weights();
|
||||
x_group_ = figure_->add_x_link();
|
||||
for (int i = 0; i < kMaxRows; ++i) {
|
||||
Row& r = row(i);
|
||||
|
||||
@@ -153,6 +153,15 @@ class RowSet {
|
||||
/// belongs with `assign()` -- set it before the assignments that follow it, or they keep the alpha
|
||||
/// the previous view left behind.
|
||||
void set_trace_alpha(float alpha) noexcept { trace_alpha_ = alpha; }
|
||||
|
||||
/// The rows' shares of the figure's height, normalised by the plotter; an empty vector means equal
|
||||
/// rows. Kept here, and re-applied after every rebuild: a `Figure` is built afresh whenever a row's
|
||||
/// trace set or the row count changes, so weights that lived only in the figure would be lost on the
|
||||
/// first drop or row change after a user had sized their layout.
|
||||
void set_row_weights(std::vector<float> weights);
|
||||
/// Sizes the stored weights to the rows that exist and hands them to the figure.
|
||||
void apply_row_weights();
|
||||
const std::vector<float>& row_weights() const noexcept { return row_weights_; }
|
||||
/// Shows `rows` rows. A changed count rebuilds the figure, since a `Figure`'s row
|
||||
/// count is fixed at construction.
|
||||
void set_row_count(int rows);
|
||||
@@ -376,6 +385,13 @@ class RowSet {
|
||||
/// Whether a window is pinned: a held event, a filling capture or a paused view. The condition
|
||||
/// `fit_x()` branches on, which is why the fit probe reports it.
|
||||
bool window_pinned() const noexcept { return pinned_; }
|
||||
/// True when the pin already *is* this window. A caller that re-states a pin every frame -- the file
|
||||
/// view does, from the ring's own extent -- uses this to update the pin's numbers with
|
||||
/// `set_data_pin()` instead of writing the group's range again with `pin_event_window()`, which would
|
||||
/// undo a gesture that `hold_pinned_window()` has not adopted yet.
|
||||
bool pinned_window_is(double pre_s, double post_s) const noexcept {
|
||||
return pinned_ && pinned_pre_ == pre_s && pinned_post_ == post_s;
|
||||
}
|
||||
|
||||
/// Adopts the instruments a gesture is moving, on `drag_row` (the row whose controller holds
|
||||
/// a cursor), and nothing otherwise.
|
||||
@@ -497,6 +513,7 @@ class RowSet {
|
||||
plot::u32 x_group_ = 0;
|
||||
int rows_ = 1;
|
||||
float trace_alpha_ = 1.0f;
|
||||
std::vector<float> row_weights_;
|
||||
double window_s_ = 2.0;
|
||||
double trigger_marker_x_ = 0.0;
|
||||
bool trigger_marker_on_ = false;
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
#include "settings.hpp"
|
||||
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <fstream>
|
||||
|
||||
#include <glib.h>
|
||||
|
||||
#include "layout_store.hpp" // `mkdirs()`, so a first run's config directory is created with parents
|
||||
|
||||
namespace udpscope {
|
||||
namespace {
|
||||
|
||||
/// One `key = value` per line, `#` for a comment: the same shape the layout files use, and parsed just
|
||||
/// as leniently -- an unknown key, an empty line or a file written by an older version is not an error.
|
||||
bool parse_bool(const std::string& v, bool fallback) {
|
||||
if (v == "true" || v == "1" || v == "yes") return true;
|
||||
if (v == "false" || v == "0" || v == "no") return false;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
std::string unquote(const std::string& v) {
|
||||
if (v.size() >= 2 && v.front() == '"' && v.back() == '"') return v.substr(1, v.size() - 2);
|
||||
return v;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
Settings default_settings() { return Settings{}; }
|
||||
|
||||
std::string settings_path() {
|
||||
const std::string dir = std::string(g_get_user_config_dir()) + "/udpscope";
|
||||
mkdirs(dir); // every component: `<config>` itself may not exist on a fresh install
|
||||
return dir + "/settings.toml";
|
||||
}
|
||||
|
||||
Settings load_settings() {
|
||||
Settings s; // the defaults, so the file only has to mention what a user changed
|
||||
std::ifstream in(settings_path());
|
||||
if (!in) return s;
|
||||
std::string line;
|
||||
while (std::getline(in, line)) {
|
||||
const std::size_t hash = line.find('#');
|
||||
if (hash != std::string::npos) line = line.substr(0, hash);
|
||||
const std::size_t eq = line.find('=');
|
||||
if (eq == std::string::npos) continue;
|
||||
std::string key = line.substr(0, eq);
|
||||
std::string value = line.substr(eq + 1);
|
||||
const auto trim = [](std::string& t) {
|
||||
const std::size_t a = t.find_first_not_of(" \t\r\n");
|
||||
const std::size_t b = t.find_last_not_of(" \t\r\n");
|
||||
t = a == std::string::npos ? std::string() : t.substr(a, b - a + 1);
|
||||
};
|
||||
trim(key);
|
||||
trim(value);
|
||||
value = unquote(value);
|
||||
if (value.empty()) continue;
|
||||
if (key == "event_cache_dir") {
|
||||
s.event_cache_dir = value;
|
||||
} else if (key == "event_cache_max") {
|
||||
s.event_cache_max = std::max(0, std::atoi(value.c_str()));
|
||||
} else if (key == "relative_time") {
|
||||
s.relative_time = parse_bool(value, s.relative_time);
|
||||
} else if (key == "theme") {
|
||||
if (value == "auto" || value == "light" || value == "dark") s.theme = value;
|
||||
}
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
bool save_settings(const Settings& settings) {
|
||||
std::ofstream out(settings_path(), std::ios::trunc);
|
||||
if (!out) return false;
|
||||
out << "# udpscope preferences -- written by the preferences dialog, read at start-up.\n";
|
||||
out << "# A key that is absent means its default, so this file only has to mention what changed.\n";
|
||||
out << "event_cache_dir = \"" << settings.event_cache_dir << "\"\n";
|
||||
out << "event_cache_max = " << settings.event_cache_max << "\n";
|
||||
out << "relative_time = " << (settings.relative_time ? "true" : "false") << "\n";
|
||||
out << "theme = \"" << settings.theme << "\"\n";
|
||||
out.flush();
|
||||
return out.good();
|
||||
}
|
||||
|
||||
} // namespace udpscope
|
||||
@@ -0,0 +1,55 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
//
|
||||
// The preferences: what a user chose about this program rather than about a source, kept between runs
|
||||
// in one file. Deliberately free of GTK and of the app's own state -- it is a handful of values, a
|
||||
// file to read them from and a file to write them to -- so the dialog that edits them, the code that
|
||||
// applies them and the tests that read them all go through the same struct.
|
||||
|
||||
#ifndef UDPSCOPE_SETTINGS_HPP
|
||||
#define UDPSCOPE_SETTINGS_HPP
|
||||
|
||||
#include <string>
|
||||
|
||||
namespace udpscope {
|
||||
|
||||
/// Everything the preferences dialog can change.
|
||||
///
|
||||
/// The defaults are the *stated* ones: an absent file and a fresh install behave identically, so a
|
||||
/// setting is only ever a user's decision and never an accident of what a file happened to contain.
|
||||
struct Settings {
|
||||
/// Where the event cache goes: the directory each source's folder is created in. Empty means the
|
||||
/// standard `<cache>/udpscope/events`, and a path is used as given -- one directory for every
|
||||
/// source, never one between them, because two sources writing one directory is two sources
|
||||
/// overwriting each other's events.
|
||||
std::string event_cache_dir;
|
||||
/// How many events one source's cache keeps. 0 disables trimming, which is a decision about the
|
||||
/// user's disk made deliberately rather than by default.
|
||||
int event_cache_max = 32;
|
||||
/// `--relative-time`'s setting, for a source whose wall-clock stamps jitter. A *startup* setting: the
|
||||
/// buffers are planned before any stream is known, so it takes effect on the next start (the
|
||||
/// preferences dialog says so, like the configurations dialog does).
|
||||
bool relative_time = false;
|
||||
/// "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 defaults, as a value rather than as a comment: what the dialog's *Defaults* button writes and
|
||||
/// what a missing file yields.
|
||||
Settings default_settings();
|
||||
|
||||
/// The file the settings live in: `<config>/udpscope/settings.toml`. Never empty.
|
||||
std::string settings_path();
|
||||
|
||||
/// Reads the settings, filling anything the file does not mention from the defaults. A file that
|
||||
/// cannot be read is not an error: it is a first run.
|
||||
Settings load_settings();
|
||||
|
||||
/// Writes them. Returns false when the file cannot be written, which the dialog reports rather than
|
||||
/// swallowing -- a preference that silently does not stick is worse than one that says it did not.
|
||||
bool save_settings(const Settings& settings);
|
||||
|
||||
} // namespace udpscope
|
||||
|
||||
#endif // UDPSCOPE_SETTINGS_HPP
|
||||
+6
-3
@@ -38,9 +38,11 @@ constexpr int kAccelCount = static_cast<int>(sizeof(kAccels) / sizeof(kAccels[0]
|
||||
/// wheel's: '+' is shift+'=' on common layouts, so Shift could not mean anything there.
|
||||
///
|
||||
/// A space-separated accelerator is the two-key alternative form the shortcut label draws
|
||||
/// as `+ / -`. The mouse rows carry no accelerator at all: neither GTK's accelerator
|
||||
/// syntax nor libadwaita's knows a wheel or a drag, and asking either to parse "Wheel"
|
||||
/// is a parse warning and an empty cell. Those rows are prose in the subtitle instead.
|
||||
/// as `+ / -`. The mouse rows carry no accelerator at all: neither GTK's accelerator syntax nor
|
||||
/// libadwaita's knows a wheel or a drag, and asking either to parse "Wheel" is a parse warning and an
|
||||
/// empty cell. The overview draws them from `src/ui/shortcuts.ui`, which gives each one GTK's generic
|
||||
/// gesture type and an icon where the keycaps would be. That file is the *presentation*; this table is
|
||||
/// what the application handles, which is why `[help]`'s `ui=` is checked against `tables=`.
|
||||
constexpr Gesture kGestures[] = {
|
||||
{"plus minus", "", "Zoom in or out, both axes", "Plot keyboard"},
|
||||
{"<Control>plus <Control>minus", "", "Zoom the time axis only", "Plot keyboard"},
|
||||
@@ -55,6 +57,7 @@ constexpr Gesture kGestures[] = {
|
||||
{"", "Drag", "Pan", "Plot mouse"},
|
||||
{"", "Ctrl and drag", "Box zoom on time", "Plot mouse"},
|
||||
{"", "Alt and drag", "Box zoom on values", "Plot mouse"},
|
||||
{"", "Shift and drag", "Box zoom on time and values", "Plot mouse"},
|
||||
};
|
||||
constexpr int kGestureCount = static_cast<int>(sizeof(kGestures) / sizeof(kGestures[0]));
|
||||
|
||||
|
||||
+71
-6
@@ -6,6 +6,8 @@
|
||||
#include "waveform.hpp"
|
||||
|
||||
#include "layout_store.hpp"
|
||||
#include "settings.hpp"
|
||||
#include "ui_prefs.hpp"
|
||||
|
||||
#include <adwaita.h>
|
||||
#include <gtk/gtk.h>
|
||||
@@ -32,7 +34,10 @@ void apply_source(Config& cfg, const KnownSource& src) {
|
||||
// only moment a buffer size can be applied at all -- and the reason they are stored per
|
||||
// configuration rather than only passed on the command line.
|
||||
if (src.buffer_mb > 0.0) cfg.buffer_mb = src.buffer_mb;
|
||||
cfg.relative_time = src.relative_time;
|
||||
// The list's own copy of the clock, but only where nothing newer has said: with the preferences
|
||||
// open from this dialog a user can state relative time a minute before clicking the row, and that
|
||||
// statement must survive the click that follows it.
|
||||
if (!cfg.relative_time_specified) cfg.relative_time = src.relative_time;
|
||||
cfg.capture_packets = src.capture_packets;
|
||||
}
|
||||
|
||||
@@ -100,6 +105,10 @@ struct Chooser {
|
||||
/// list with it, so the row a report would name is gone by the time the probe reads it.
|
||||
std::string saved_config;
|
||||
std::string saved_note;
|
||||
/// The preferences, for the button below. Same reasoning as `form` above: a member of the chooser
|
||||
/// rather than a heap struct, so nothing can outlive the stack frame that owns it.
|
||||
ui_impl::PrefsForm prefs;
|
||||
|
||||
};
|
||||
|
||||
void chooser_finish(Chooser& c, bool chosen) {
|
||||
@@ -114,6 +123,13 @@ void chooser_finish(Chooser& c, bool chosen) {
|
||||
c.form = nullptr; // before the destroy, so the `close-request` handler cannot re-enter
|
||||
gtk_window_destroy(GTK_WINDOW(w));
|
||||
}
|
||||
// The preferences window is transient for the chooser, so it has to go first: a window destroyed
|
||||
// with a transient still up is the lifetime this project has already got wrong once.
|
||||
if (c.prefs.window != nullptr) {
|
||||
GtkWidget* w = c.prefs.window;
|
||||
c.prefs.window = nullptr;
|
||||
gtk_window_destroy(GTK_WINDOW(w));
|
||||
}
|
||||
if (c.window != nullptr) {
|
||||
GtkWidget* w = c.window;
|
||||
c.window = nullptr; // before the destroy: `close-request` must not re-enter this
|
||||
@@ -577,6 +593,23 @@ gboolean on_chooser_key(GtkEventControllerKey*, guint keyval, guint, GdkModifier
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
/// The chooser's way into the preferences -- and the reason the form has no owner: this dialog runs
|
||||
/// before `App` exists. Relative time has to reach the run this chooser is about to start, because
|
||||
/// `main()` plans the buffers only after this returns; the rest is read from the file when that run
|
||||
/// starts, so it is honoured without anything here doing it.
|
||||
void open_chooser_preferences(Chooser& c) {
|
||||
ui_impl::prefs_open(c.prefs, c.window, load_settings(), [&c](const Settings& s, bool written) {
|
||||
// The newest statement of intent there is for this run: it beats the list's own copy and the
|
||||
// stored configuration, and it works in both directions -- turning it on *and* off.
|
||||
c.cfg->relative_time = s.relative_time;
|
||||
c.cfg->relative_time_specified = true;
|
||||
if (!written) {
|
||||
std::fprintf(stderr, "[sources] prefs: could not write %s\n", settings_path().c_str());
|
||||
std::fflush(stderr);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// `UDPSCOPE_PROBE_PICK_MS`: activate the first row, the way a click does.
|
||||
/// `UDPSCOPE_PROBE_NEWHOST=host:port` drives the form instead of a row. The crash surface of a
|
||||
/// dialog built, filled and destroyed is as much the point as the values, the same way
|
||||
@@ -588,6 +621,24 @@ gboolean pick_probe(gpointer user) {
|
||||
// -probe call would otherwise be a GLib critical on every run that let the probe fire.
|
||||
c.pick_source = 0;
|
||||
|
||||
// `UDPSCOPE_PROBE_CHOOSER_PREFS=1`: the preferences opened from *here* rather than from the app's
|
||||
// menu -- the entry point that exists because this dialog runs before `App`. It is driven through
|
||||
// the same fill-and-apply the app's probe uses, and reports what the run about to start will get,
|
||||
// which is the half of the contract the app's probe cannot see. The window is deliberately left
|
||||
// open: the pick below destroys the chooser with it still up, which is the lifetime at risk.
|
||||
if (g_getenv("UDPSCOPE_PROBE_CHOOSER_PREFS") != nullptr) {
|
||||
open_chooser_preferences(c);
|
||||
std::fprintf(stderr, "[sources] prefs: opened=%d apply=%d\n", c.prefs.window != nullptr ? 1 : 0,
|
||||
c.prefs.apply != nullptr ? 1 : 0);
|
||||
ui_impl::prefs_probe_fill_and_apply(c.prefs);
|
||||
const Settings back = load_settings();
|
||||
std::fprintf(stderr,
|
||||
"[sources] prefs: dir=%s keep=%d rel=%d theme=%s cfg_relative=%d\n",
|
||||
back.event_cache_dir.c_str(), back.event_cache_max, back.relative_time ? 1 : 0,
|
||||
back.theme.c_str(), c.cfg->relative_time ? 1 : 0);
|
||||
std::fflush(stderr);
|
||||
}
|
||||
|
||||
// `UDPSCOPE_PROBE_WAVEFORMS=1` is the waveforms page without a display: the recent list, and the
|
||||
// page selected the way a click on its tab does. `UDPSCOPE_PROBE_WAVE=<path>` is the file dialog's
|
||||
// answer without the dialog -- the same field, set the same way.
|
||||
@@ -701,13 +752,14 @@ void apply_stored_settings(Config& cfg) {
|
||||
if (want.empty()) return;
|
||||
LayoutState st;
|
||||
if (!load_layout(configuration_path(cfg.server, cfg.port, want), st)) return;
|
||||
// Only where the command line was silent: a flag the user typed wins, because it is the more
|
||||
// recent statement of intent -- and the file is not a place to hide a surprise from the ones who
|
||||
// did type it.
|
||||
// Only where nothing newer was said: a flag the user typed, or the preferences they just applied,
|
||||
// wins -- because it is the more recent statement of intent, and the file is not a place to hide a
|
||||
// surprise from whoever just made it.
|
||||
if (!cfg.buffer_mb_specified && st.buffer_mb > 0.0) cfg.buffer_mb = st.buffer_mb;
|
||||
if (st.relative_time) cfg.relative_time = true;
|
||||
const bool relative = !cfg.relative_time_specified && st.relative_time;
|
||||
if (relative) cfg.relative_time = true;
|
||||
if (cfg.capture_packets == 0 && st.capture_packets > 0) cfg.capture_packets = st.capture_packets;
|
||||
const bool applied = (!cfg.buffer_mb_specified && st.buffer_mb > 0.0) || st.relative_time ||
|
||||
const bool applied = (!cfg.buffer_mb_specified && st.buffer_mb > 0.0) || relative ||
|
||||
st.capture_packets > 0;
|
||||
if (applied) {
|
||||
std::fprintf(stderr,
|
||||
@@ -788,6 +840,18 @@ bool choose_source(Config& cfg) {
|
||||
gtk_widget_set_margin_top(actions, 12);
|
||||
gtk_widget_set_margin_bottom(actions, 12);
|
||||
gtk_widget_set_halign(actions, GTK_ALIGN_END);
|
||||
// The preferences are reachable from here as well as from the main window's menu: this dialog is
|
||||
// the only window that exists before anything is planned, so it is where a user still has the
|
||||
// chance to say how this run should behave.
|
||||
GtkWidget* prefs = gtk_button_new_with_label("Preferences\xe2\x80\xa6");
|
||||
gtk_widget_set_tooltip_text(prefs,
|
||||
"the event cache, relative time and the interface's theme -- the same "
|
||||
"dialog as the main window's menu, and the only place relative time can "
|
||||
"still change this run");
|
||||
g_signal_connect(prefs, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
|
||||
open_chooser_preferences(*static_cast<Chooser*>(user));
|
||||
}),
|
||||
&c);
|
||||
GtkWidget* new_host = gtk_button_new_with_label("New host\xe2\x80\xa6");
|
||||
g_signal_connect(new_host, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
|
||||
open_form(*static_cast<Chooser*>(user));
|
||||
@@ -798,6 +862,7 @@ bool choose_source(Config& cfg) {
|
||||
chooser_finish(*static_cast<Chooser*>(user), false);
|
||||
}),
|
||||
&c);
|
||||
gtk_box_append(GTK_BOX(actions), prefs);
|
||||
gtk_box_append(GTK_BOX(actions), new_host);
|
||||
gtk_box_append(GTK_BOX(actions), cancel);
|
||||
gtk_box_append(GTK_BOX(box), actions);
|
||||
|
||||
+199
-15
@@ -613,7 +613,21 @@ void sync_layout_editor(App& app) {
|
||||
for (GtkWidget* b : app.layout_buttons) {
|
||||
const int idx = row_key(b);
|
||||
const bool insert = g_object_get_data(G_OBJECT(b), kLayoutKindKey) != nullptr;
|
||||
if (insert) {
|
||||
const bool resize = g_object_get_data(G_OBJECT(b), kLayoutResizeKey) != nullptr;
|
||||
if (resize) {
|
||||
// *On* the boundary, beside the `+` -- the same edge, one handle's width to its right. Below it sat
|
||||
// inside the lower row, which read as a handle for that row: a boundary handle that looks like it
|
||||
// belongs to the plot under it is a handle whose drag changes the wrong plot, whatever it does.
|
||||
const double edge = idx < rows ? static_cast<double>(fl.rows[static_cast<std::size_t>(idx)].y) / scale
|
||||
: (static_cast<double>(fl.rows.back().y) +
|
||||
static_cast<double>(fl.rows.back().h)) / scale;
|
||||
const double top = std::clamp(edge - half, 0.0, height - kLayoutButtonPx);
|
||||
gtk_widget_set_margin_top(b, static_cast<int>(top + 0.5));
|
||||
const double width = static_cast<double>(gtk_widget_get_width(app.area_widget));
|
||||
const double left = width * 0.5 + kLayoutButtonPx * 0.5 + 6.0;
|
||||
gtk_widget_set_margin_start(
|
||||
b, static_cast<int>(std::clamp(left, 0.0, std::max(0.0, width - kLayoutButtonPx)) + 0.5));
|
||||
} else if (insert) {
|
||||
// Centred on the boundary: the top edge of row `idx`, or the bottom edge of the
|
||||
// last row for the one past the end.
|
||||
const double edge = idx < rows ? static_cast<double>(fl.rows[static_cast<std::size_t>(idx)].y) / scale
|
||||
@@ -637,6 +651,52 @@ void sync_layout_editor(App& app) {
|
||||
}
|
||||
}
|
||||
|
||||
/// A row may not be dragged below this, in logical pixels: a row of no height is a row a user cannot
|
||||
/// grab again, and the plotter keeps a floor of its own -- this is the one the *drag* respects.
|
||||
constexpr double kMinRowPx = 48.0;
|
||||
|
||||
void on_layout_drag_begin(GtkGestureDrag* gesture, double, double, gpointer user) {
|
||||
App& app = *static_cast<App*>(user);
|
||||
app.layout_drag_row =
|
||||
static_cast<int>(GPOINTER_TO_SIZE(g_object_get_data(G_OBJECT(gesture), "udpscope-layout-drag")));
|
||||
// A drag is *relative*: the weights it started from are kept, because reading them back while dragging
|
||||
// would compound -- each update would build on the last frame's instead of on the grab.
|
||||
app.layout_drag_start = app.rows->row_weights();
|
||||
app.layout_drag_start.resize(static_cast<std::size_t>(app.rows->row_count()), 1.0f);
|
||||
}
|
||||
|
||||
void on_layout_drag_update(GtkGestureDrag*, double, double offset_y, gpointer user) {
|
||||
App& app = *static_cast<App*>(user);
|
||||
const int at = app.layout_drag_row; // the boundary between rows `at - 1` and `at`
|
||||
const int rows = app.rows->row_count();
|
||||
if (at <= 0 || at >= rows) return; // the figure's own edges are not boundaries
|
||||
if (app.layout_drag_start.size() != static_cast<std::size_t>(rows)) return;
|
||||
const plot::FigureLayout& fl = app.rows->figure().layout();
|
||||
if (static_cast<int>(fl.rows.size()) != rows) return;
|
||||
const double scale =
|
||||
app.area != nullptr && app.area->device_scale() > 0.0f ? app.area->device_scale() : 1.0;
|
||||
const double up = static_cast<double>(fl.rows[static_cast<std::size_t>(at - 1)].h) / scale;
|
||||
const double lo = static_cast<double>(fl.rows[static_cast<std::size_t>(at)].h) / scale;
|
||||
const double pair = up + lo;
|
||||
if (!(pair > kMinRowPx * 2.0)) return;
|
||||
// Where the boundary is asked to go, floored on both sides: the *pixels* say where, and the pair's
|
||||
// weights are the shares that put it there.
|
||||
const double want_up = std::clamp(up + offset_y / scale, kMinRowPx, pair - kMinRowPx);
|
||||
std::vector<float> weights = app.layout_drag_start;
|
||||
const float sum = weights[static_cast<std::size_t>(at - 1)] + weights[static_cast<std::size_t>(at)];
|
||||
weights[static_cast<std::size_t>(at - 1)] = sum * static_cast<float>(want_up / pair);
|
||||
weights[static_cast<std::size_t>(at)] = sum - weights[static_cast<std::size_t>(at - 1)];
|
||||
app.rows->set_row_weights(std::move(weights));
|
||||
}
|
||||
|
||||
void on_layout_drag_end(GtkGestureDrag*, double, double, gpointer user) {
|
||||
App& app = *static_cast<App*>(user);
|
||||
app.layout_drag_row = -1;
|
||||
app.layout_drag_start.clear();
|
||||
// A dragged sizing is an arrangement, so it is saved like one.
|
||||
schedule_layout_save(app);
|
||||
}
|
||||
|
||||
/// Builds one `×` per row and one `+` per boundary, or takes them all off again when the
|
||||
/// editor is switched off.
|
||||
void rebuild_layout_editor(App& app) {
|
||||
@@ -676,6 +736,39 @@ void rebuild_layout_editor(App& app) {
|
||||
gtk_overlay_add_overlay(GTK_OVERLAY(app.area_overlay), b);
|
||||
app.layout_buttons.push_back(b);
|
||||
}
|
||||
// ... and one arrow per boundary whose *only* job is resizing the two rows it sits between. A widget
|
||||
// of its own rather than a second gesture on the `+`, so each handle does one thing -- and a handle
|
||||
// that cannot be clicked cannot be clicked by accident. The figure's own edges are not boundaries
|
||||
// (there is no pair of rows to share between them), so the first and last are inert.
|
||||
for (int b_i = 0; b_i <= rows; ++b_i) {
|
||||
GtkWidget* b = gtk_button_new_from_icon_name("udpscope-resize-rows-symbolic");
|
||||
gtk_widget_set_tooltip_text(b, b_i == 0 ? "drag to resize the first row"
|
||||
: b_i == rows ? "drag to resize the last row"
|
||||
: "drag to resize the two rows here");
|
||||
gtk_widget_add_css_class(b, "circular");
|
||||
gtk_widget_add_css_class(b, "osd");
|
||||
gtk_widget_set_size_request(b, kLayoutButtonPx, kLayoutButtonPx);
|
||||
gtk_widget_set_halign(b, GTK_ALIGN_CENTER);
|
||||
gtk_widget_set_valign(b, GTK_ALIGN_START);
|
||||
// Hidden, not disabled, where it has no pair to share between: the figure's own edges. A greyed
|
||||
// handle is a question ("why can I not drag this?"), and the answer is that there is nothing there.
|
||||
gtk_widget_set_visible(b, b_i > 0 && b_i < rows);
|
||||
gtk_widget_set_halign(b, GTK_ALIGN_START);
|
||||
// The *row key* is what `sync_layout_editor()` places by -- without it every arrow was placed at
|
||||
// index 0, the figure's top edge, whatever boundary it belonged to.
|
||||
set_row_key(b, b_i);
|
||||
g_object_set_data(G_OBJECT(b), kLayoutResizeKey, GSIZE_TO_POINTER(b_i));
|
||||
GtkGesture* drag = gtk_gesture_drag_new();
|
||||
gtk_gesture_single_set_button(GTK_GESTURE_SINGLE(drag), 1);
|
||||
gtk_event_controller_set_propagation_phase(GTK_EVENT_CONTROLLER(drag), GTK_PHASE_CAPTURE);
|
||||
g_object_set_data(G_OBJECT(drag), "udpscope-layout-drag", GSIZE_TO_POINTER(b_i));
|
||||
g_signal_connect(drag, "drag-begin", G_CALLBACK(on_layout_drag_begin), &app);
|
||||
g_signal_connect(drag, "drag-update", G_CALLBACK(on_layout_drag_update), &app);
|
||||
g_signal_connect(drag, "drag-end", G_CALLBACK(on_layout_drag_end), &app);
|
||||
gtk_widget_add_controller(b, GTK_EVENT_CONTROLLER(drag));
|
||||
gtk_overlay_add_overlay(GTK_OVERLAY(app.area_overlay), b);
|
||||
app.layout_buttons.push_back(b);
|
||||
}
|
||||
|
||||
// The editor's way out, in the area's top-right corner: the toolbar's toggle is in the
|
||||
// overflow menu, which is a long way from the plot you are editing. Aligned by margins
|
||||
@@ -3149,7 +3242,13 @@ gboolean tick(gpointer user) {
|
||||
// bound to nothing and the snapshot carries its own `x_offset`, so the epoch is 0 and
|
||||
// `ScopeModel::stream_at_x0()` is what crosses between the clocks.
|
||||
double want_epoch = 0.0;
|
||||
if (display == DisplayMode::Capturing) {
|
||||
if (!app->waveform_name.empty()) {
|
||||
// A file *is* an event: an export's stamps are already trigger-relative, so the trigger instant
|
||||
// is x = 0 and the epoch must not move it. The live rule below ("the newest sample sits at 0")
|
||||
// would put the trigger at the right edge and push the whole pre-trigger half off the screen --
|
||||
// which is what "I only see 0 to 0.2 of a -0.8 to 0.2 recording" was.
|
||||
want_epoch = 0.0;
|
||||
} else if (display == DisplayMode::Capturing) {
|
||||
want_epoch = -cap_edge;
|
||||
} else if (model.display_snapshot() == nullptr) {
|
||||
// `live_edge()` reports in the epoch currently in force, so subtracting it is minus the
|
||||
@@ -3166,13 +3265,53 @@ gboolean tick(gpointer user) {
|
||||
// Outside the lock: it reads and writes the two plots' own windows, not the model.
|
||||
sync_compare_axes(*app);
|
||||
sync_events_divider(*app);
|
||||
// The comparison's plot needs the same re-point the primary gets from `relayout_areas()`: a
|
||||
// `RowSet` rebuilds its *figure* when a row's trace set changes, and the area has to be handed the
|
||||
// new one. Nothing did that for the second plot, so it kept drawing the figure built when its slots
|
||||
// were still empty -- every trace in the palette's first colour, which is blue. Idempotent: the
|
||||
// area is handed the figure it already has on every other frame.
|
||||
// A file on screen is an event, and gets what a held capture gets: the axis pinned to the event's own
|
||||
// `[-pre, +post]` and the trigger instant marked at x = 0. That *is* the constraint -- the time base is
|
||||
// the event's, so no gesture can take the view outside it -- and the window control reads the event's
|
||||
// length. The extent comes from the ring itself, so nothing has to remember it: with the epoch at 0
|
||||
// the stamps are the display x.
|
||||
if (!app->waveform_name.empty() && !app->capturing && app->rows != nullptr) {
|
||||
double lo = 0.0;
|
||||
double hi = 0.0;
|
||||
{
|
||||
const auto& t = app->model->live().time();
|
||||
const std::uint64_t n = t.size();
|
||||
if (n > 0) {
|
||||
lo = t[0];
|
||||
hi = t[n - 1];
|
||||
}
|
||||
}
|
||||
if (hi > lo) {
|
||||
// State the pin **once**, then only its numbers. `pin_event_window()` writes the group's range as
|
||||
// well as the pin, and doing that every frame undid the user's box zoom before the clamp below
|
||||
// could adopt it -- the same defect the wheel had in single trigger mode, where re-stating the pin
|
||||
// made it a no-op. The held-event branch above has this shape for this reason: `set_data_pin()`
|
||||
// for the numbers, `pin_event_window()` when the view first becomes an event. A re-open into the
|
||||
// same row set with a different extent *is* a new event and re-states it.
|
||||
const auto pin_view = [lo, hi](RowSet& rows) {
|
||||
if (rows.pinned_window_is(-lo, hi)) {
|
||||
rows.set_data_pin(-lo, hi);
|
||||
} else {
|
||||
rows.pin_event_window(-lo, hi);
|
||||
}
|
||||
rows.set_trigger_marker(0.0, true);
|
||||
};
|
||||
pin_view(*app->rows);
|
||||
if (app->rows_b != nullptr) pin_view(*app->rows_b);
|
||||
sync_window_display(*app, hi - lo);
|
||||
}
|
||||
}
|
||||
// The comparison's plot needs the same re-point the primary gets from `relayout_areas()`: a `RowSet`
|
||||
// rebuilds its *figure* when a row's trace set changes, and the area has to be handed the new one.
|
||||
// Only when it *did* change, though -- `set_figure()` creates **a controller per row**, so handing it
|
||||
// the same figure every frame threw away the second plot's interaction state sixty times a second:
|
||||
// its box-zoom and its crosshair never took, while the primary's (re-pointed only on a dirty row) did.
|
||||
if (app->comparing && app->area_b != nullptr && app->rows_b != nullptr) {
|
||||
app->area_b->set_figure(&app->rows_b->figure());
|
||||
plot::Figure* want = &app->rows_b->figure();
|
||||
if (app->area_b_figure != want) {
|
||||
app->area_b->set_figure(want);
|
||||
app->area_b_figure = want;
|
||||
}
|
||||
}
|
||||
|
||||
// The gesture probe's verdicts, one frame after the ask and after this frame's clamp: a
|
||||
@@ -3258,10 +3397,18 @@ gboolean tick(gpointer user) {
|
||||
double hi = 0.0;
|
||||
if (app->rows->event_data_window(lo, hi)) app->rows->set_data_pin(lo, hi);
|
||||
app->rows->hold_pinned_window();
|
||||
} else if (display == DisplayMode::Paused || app->capturing) {
|
||||
// Pinned: the event (or the frozen pause), not a navigation surface. `clamp_to_data()` is
|
||||
// the live-mode half of the same rule -- a live view must stay on the data -- and the two
|
||||
// are complements, which is why this is one branch rather than two conditions.
|
||||
} else if (display == DisplayMode::Paused || app->capturing || app->rows->window_pinned()) {
|
||||
// Pinned: the event, the frozen pause *or an opened file*, not a navigation surface.
|
||||
// `clamp_to_data()` is the live-mode half of the same rule -- a live view must stay on the data
|
||||
// -- and the two are complements, which is why this is one branch rather than two conditions.
|
||||
//
|
||||
// A file belongs here and was the case that was missing. It pins its axis every frame (see the
|
||||
// `waveform_name` block above), but a pinned axis does *not* mean this branch: only
|
||||
// `hold_pinned_window()` adopts a gesture from the row's own axis, because a member's
|
||||
// `set_x_range` is deliberately not adopted into a group that is not following. Falling through
|
||||
// to `clamp_to_data()` therefore left a box zoom in a file view drawing its frame and changing
|
||||
// nothing -- the gesture's range went onto the member, the group kept the pin, and the picture
|
||||
// that was drawn came from the group.
|
||||
app->rows->hold_pinned_window();
|
||||
} else {
|
||||
app->rows->clamp_to_data();
|
||||
@@ -3405,7 +3552,11 @@ gboolean tick(gpointer user) {
|
||||
// being captured or held. x is seconds from the event in both: the display epoch is
|
||||
// minus the edge while the live ring is on a pinned axis, and the snapshot's own stamps
|
||||
// are edge-relative, so the edge sits at exactly x = 0 wherever the user has zoomed.
|
||||
if (!app->capturing && !(trigger_on && model.trigger_time_valid().load(std::memory_order_relaxed))) {
|
||||
// ... and it is the *only* thing that hides it. A file's own event is on screen for as long as the
|
||||
// file is, so the file branch above must survive this one: it used to run first and be undone here,
|
||||
// which is why an opened event had no trigger line.
|
||||
if (app->waveform_name.empty() && !app->capturing &&
|
||||
!(trigger_on && model.trigger_time_valid().load(std::memory_order_relaxed))) {
|
||||
app->rows->set_trigger_marker(0.0, false);
|
||||
}
|
||||
|
||||
@@ -3588,6 +3739,15 @@ gboolean tick(gpointer user) {
|
||||
const double dt_s = std::min(10.0, double(now_c - app->last_channels_meas) / 1.0e6);
|
||||
next_channels = now_c + G_TIME_SPAN_SECOND;
|
||||
app->last_channels_meas = now_c;
|
||||
// The cache's own question, on the same cadence as the channel dump: a directory read per second,
|
||||
// and the button's availability re-decided only when the answer changed.
|
||||
if (app->event_store != nullptr) {
|
||||
const bool cached = !app->event_store->list().empty();
|
||||
if (cached != app->events_cached) {
|
||||
app->events_cached = cached;
|
||||
sync_events_availability(*app);
|
||||
}
|
||||
}
|
||||
// Once a second, alongside the rate measurement: is the stream arriving at all? Nothing is
|
||||
// owed in file mode -- there is no socket and no source -- so a file is never starved.
|
||||
if (app->waveform_name.empty()) note_starvation(*app);
|
||||
@@ -4074,6 +4234,13 @@ GtkWidget* build_toolbar(App& app) {
|
||||
g_signal_connect(help_item, "clicked", G_CALLBACK(on_help_clicked), &app);
|
||||
gtk_box_append(GTK_BOX(menu_box), help_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));
|
||||
}),
|
||||
&app);
|
||||
gtk_box_append(GTK_BOX(menu_box), prefs_item);
|
||||
gtk_box_append(GTK_BOX(menu_box), menu_separator());
|
||||
GtkWidget* about_item = menu_button("About udpscope", nullptr);
|
||||
gtk_widget_set_tooltip_text(about_item, "version, build timestamp and settings paths");
|
||||
g_signal_connect(about_item, "clicked", G_CALLBACK(on_about_clicked), &app);
|
||||
@@ -4169,13 +4336,18 @@ GtkWidget* build_toolbar(App& app) {
|
||||
if (open) refresh_events_pane(*a);
|
||||
}),
|
||||
&app);
|
||||
gtk_header_bar_pack_start(GTK_HEADER_BAR(bar), app.events_toggle);
|
||||
|
||||
|
||||
app.pause_button = gtk_toggle_button_new();
|
||||
gtk_button_set_icon_name(GTK_BUTTON(app.pause_button), "media-playback-pause-symbolic");
|
||||
gtk_widget_set_tooltip_text(app.pause_button, "pause acquisition");
|
||||
g_signal_connect(app.pause_button, "toggled", G_CALLBACK(on_pause_toggled), &app);
|
||||
gtk_header_bar_pack_start(GTK_HEADER_BAR(bar), app.pause_button);
|
||||
// A separator, because the events button is not another mode on the plot: what stands to its left is
|
||||
// what the *view* is doing (the window, the trigger, the pause) and what follows is the cache of what
|
||||
// those produced.
|
||||
gtk_header_bar_pack_start(GTK_HEADER_BAR(bar), gtk_separator_new(GTK_ORIENTATION_VERTICAL));
|
||||
gtk_header_bar_pack_start(GTK_HEADER_BAR(bar), app.events_toggle);
|
||||
|
||||
// Packed here, after every widget above has been created: the *pack* order is what decides the
|
||||
// layout, not the creation order, and packing before the widget exists passes NULL to the header
|
||||
@@ -4941,6 +5113,11 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
|
||||
// Installs libadwaita's stylesheet, which the shortcut overview's `AdwShortcutsDialog`
|
||||
// needs to be drawn at all. Idempotent, and `choose_source()` may already have run it.
|
||||
adw_init();
|
||||
// The preferences are read before anything is built: the theme decides what the window looks like,
|
||||
// and the rest is in force from the first frame. A *local* until `App` exists below, which is why
|
||||
// this one read happens here and the assignment after it.
|
||||
const Settings startup_settings = load_settings();
|
||||
apply_theme(startup_settings.theme);
|
||||
// The app's own icons, before anything asks for one. They are a GResource compiled into the
|
||||
// binary (see CMakeLists), and this is what makes the theme look there: an unregistered path
|
||||
// is not an error, it is a missing-image placeholder.
|
||||
@@ -4952,6 +5129,7 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
|
||||
gtk_icon_theme_add_resource_path(gtk_icon_theme_get_for_display(gdk_display_get_default()),
|
||||
"/org/udpscope/icons");
|
||||
App app;
|
||||
app.settings = startup_settings;
|
||||
app.model = &model;
|
||||
app.acq = &acq;
|
||||
app.rows = &rows;
|
||||
@@ -4969,7 +5147,7 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
|
||||
// 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));
|
||||
std::to_string(app.source_port), app.settings.event_cache_dir);
|
||||
}
|
||||
|
||||
refresh_events_pane(app);
|
||||
@@ -5074,6 +5252,12 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
|
||||
if (const char* env = g_getenv("UDPSCOPE_PROBE_DEFAULTS_MS")) {
|
||||
g_timeout_add(static_cast<guint>(std::atoi(env)), defaults_probe, &app);
|
||||
}
|
||||
if (const char* env = g_getenv("UDPSCOPE_PROBE_BOXZOOM_MS")) {
|
||||
g_timeout_add(static_cast<guint>(std::atoi(env)), boxzoom_probe, &app);
|
||||
}
|
||||
if (const char* env = g_getenv("UDPSCOPE_PROBE_PREFS_MS")) {
|
||||
g_timeout_add(static_cast<guint>(std::atoi(env)), prefs_probe, &app);
|
||||
}
|
||||
if (const char* env = g_getenv("UDPSCOPE_PROBE_CAPTURE_MS")) {
|
||||
capture_probe_next(app, 0, static_cast<guint>(std::atoi(env)));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- SPDX-License-Identifier: MIT -->
|
||||
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
|
||||
<!--
|
||||
The shortcut overview, as a UI file instead of a couple of hundred lines of widget construction, and
|
||||
in gedit's arrangement: one GtkShortcutsWindow holding a section per page (the window draws the
|
||||
sidebar and the search box over them), each section holding a group, each group holding rows.
|
||||
|
||||
The mouse rows are the reason this file exists. An accelerator cannot spell "Ctrl and drag", and
|
||||
libadwaita's AdwShortcutsItem, the modern replacement for this widget, has no icon property at all,
|
||||
so its cell reads "No Shortcut". GtkShortcutsShortcut has exactly one gesture type whose *icon* is
|
||||
drawn (shortcut-type "gesture" plus the icon property, per its own documentation), and that is what
|
||||
puts the mouse glyph where the keycaps would otherwise be.
|
||||
|
||||
Keep this file in step with the tables in shortcuts.cpp: those are what the application handles,
|
||||
this is what it shows. The `[help]` probe's `ui=` against `tables=` is the guard against the two
|
||||
drifting apart.
|
||||
-->
|
||||
<interface>
|
||||
<object class="GtkShortcutsWindow" id="help_overlay">
|
||||
<property name="modal">1</property>
|
||||
<child>
|
||||
<object class="GtkShortcutsSection">
|
||||
<property name="visible">1</property>
|
||||
<property name="section-name">Acquisition</property>
|
||||
<property name="title" translatable="yes">Acquisition</property>
|
||||
<property name="max-height">12</property>
|
||||
<child>
|
||||
<object class="GtkShortcutsGroup">
|
||||
<property name="visible">1</property>
|
||||
<child>
|
||||
<object class="GtkShortcutsShortcut">
|
||||
<property name="visible">1</property>
|
||||
<property name="accelerator">space</property>
|
||||
<property name="title" translatable="yes">Pause or resume the display</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkShortcutsShortcut">
|
||||
<property name="visible">1</property>
|
||||
<property name="accelerator">a</property>
|
||||
<property name="title" translatable="yes">Arm or disarm the trigger</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkShortcutsShortcut">
|
||||
<property name="visible">1</property>
|
||||
<property name="accelerator">t</property>
|
||||
<property name="title" translatable="yes">Show or hide the trigger panel</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkShortcutsSection">
|
||||
<property name="visible">1</property>
|
||||
<property name="section-name">Panes and editors</property>
|
||||
<property name="title" translatable="yes">Panes and editors</property>
|
||||
<property name="max-height">12</property>
|
||||
<child>
|
||||
<object class="GtkShortcutsGroup">
|
||||
<property name="visible">1</property>
|
||||
<child>
|
||||
<object class="GtkShortcutsShortcut">
|
||||
<property name="visible">1</property>
|
||||
<property name="accelerator">c</property>
|
||||
<property name="title" translatable="yes">Show or hide the cursors pane</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkShortcutsShortcut">
|
||||
<property name="visible">1</property>
|
||||
<property name="accelerator">m</property>
|
||||
<property name="title" translatable="yes">Show or hide the measurements pane</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkShortcutsShortcut">
|
||||
<property name="visible">1</property>
|
||||
<property name="accelerator">g</property>
|
||||
<property name="title" translatable="yes">Show or hide the math pane</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkShortcutsShortcut">
|
||||
<property name="visible">1</property>
|
||||
<property name="accelerator">l</property>
|
||||
<property name="title" translatable="yes">Show or hide the layout editor</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkShortcutsShortcut">
|
||||
<property name="visible">1</property>
|
||||
<property name="accelerator">s</property>
|
||||
<property name="title" translatable="yes">Show or hide the side panel</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkShortcutsSection">
|
||||
<property name="visible">1</property>
|
||||
<property name="section-name">Files and windows</property>
|
||||
<property name="title" translatable="yes">Files and windows</property>
|
||||
<property name="max-height">12</property>
|
||||
<child>
|
||||
<object class="GtkShortcutsGroup">
|
||||
<property name="visible">1</property>
|
||||
<child>
|
||||
<object class="GtkShortcutsShortcut">
|
||||
<property name="visible">1</property>
|
||||
<property name="accelerator"><Control>e</property>
|
||||
<property name="title" translatable="yes">Export the visible range</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkShortcutsShortcut">
|
||||
<property name="visible">1</property>
|
||||
<property name="accelerator">F1</property>
|
||||
<property name="title" translatable="yes">Open the help window</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkShortcutsShortcut">
|
||||
<property name="visible">1</property>
|
||||
<property name="accelerator"><Control>slash</property>
|
||||
<property name="title" translatable="yes">Open this shortcut overview</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkShortcutsShortcut">
|
||||
<property name="visible">1</property>
|
||||
<property name="accelerator"><Control>q</property>
|
||||
<property name="title" translatable="yes">Quit udpscope</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkShortcutsSection">
|
||||
<property name="visible">1</property>
|
||||
<property name="section-name">Plot keyboard</property>
|
||||
<property name="title" translatable="yes">Plot keyboard</property>
|
||||
<property name="max-height">12</property>
|
||||
<child>
|
||||
<object class="GtkShortcutsGroup">
|
||||
<property name="visible">1</property>
|
||||
<child>
|
||||
<object class="GtkShortcutsShortcut">
|
||||
<property name="visible">1</property>
|
||||
<property name="accelerator">plus minus</property>
|
||||
<property name="title" translatable="yes">Zoom in or out, both axes</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkShortcutsShortcut">
|
||||
<property name="visible">1</property>
|
||||
<property name="accelerator"><Control>plus <Control>minus</property>
|
||||
<property name="title" translatable="yes">Zoom the time axis only</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkShortcutsShortcut">
|
||||
<property name="visible">1</property>
|
||||
<property name="accelerator"><Alt>plus <Alt>minus</property>
|
||||
<property name="title" translatable="yes">Zoom the value axis only</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkShortcutsShortcut">
|
||||
<property name="visible">1</property>
|
||||
<property name="accelerator">Left Right</property>
|
||||
<property name="title" translatable="yes">Pan along time</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkShortcutsShortcut">
|
||||
<property name="visible">1</property>
|
||||
<property name="accelerator">Up Down</property>
|
||||
<property name="title" translatable="yes">Pan along values</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkShortcutsShortcut">
|
||||
<property name="visible">1</property>
|
||||
<property name="accelerator">f</property>
|
||||
<property name="title" translatable="yes">Fit the view to the data</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkShortcutsShortcut">
|
||||
<property name="visible">1</property>
|
||||
<property name="accelerator">Escape</property>
|
||||
<property name="title" translatable="yes">Cancel the gesture in progress</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkShortcutsSection">
|
||||
<property name="visible">1</property>
|
||||
<property name="section-name">Plot mouse</property>
|
||||
<property name="title" translatable="yes">Plot mouse</property>
|
||||
<property name="max-height">12</property>
|
||||
<child>
|
||||
<object class="GtkShortcutsGroup">
|
||||
<property name="visible">1</property>
|
||||
<child>
|
||||
<object class="GtkShortcutsShortcut">
|
||||
<property name="visible">1</property>
|
||||
<property name="shortcut-type">gesture</property>
|
||||
<!-- A GIcon property takes an object, not a name: GtkBuilder reads a bare string as a
|
||||
reference to another object and fails the whole file with "Object with ID … not
|
||||
found" (a Gtk-ERROR, which aborts the process by default). -->
|
||||
<property name="icon">
|
||||
<object class="GThemedIcon">
|
||||
<property name="names">input-mouse-symbolic</property>
|
||||
</object>
|
||||
</property>
|
||||
<property name="title" translatable="yes">Zoom about the pointer, both axes</property>
|
||||
<property name="subtitle" translatable="yes">Wheel</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkShortcutsShortcut">
|
||||
<property name="visible">1</property>
|
||||
<property name="shortcut-type">gesture</property>
|
||||
<!-- A GIcon property takes an object, not a name: GtkBuilder reads a bare string as a
|
||||
reference to another object and fails the whole file with "Object with ID … not
|
||||
found" (a Gtk-ERROR, which aborts the process by default). -->
|
||||
<property name="icon">
|
||||
<object class="GThemedIcon">
|
||||
<property name="names">input-mouse-symbolic</property>
|
||||
</object>
|
||||
</property>
|
||||
<property name="title" translatable="yes">Zoom the time axis only</property>
|
||||
<property name="subtitle" translatable="yes">Shift and wheel</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkShortcutsShortcut">
|
||||
<property name="visible">1</property>
|
||||
<property name="shortcut-type">gesture</property>
|
||||
<!-- A GIcon property takes an object, not a name: GtkBuilder reads a bare string as a
|
||||
reference to another object and fails the whole file with "Object with ID … not
|
||||
found" (a Gtk-ERROR, which aborts the process by default). -->
|
||||
<property name="icon">
|
||||
<object class="GThemedIcon">
|
||||
<property name="names">input-mouse-symbolic</property>
|
||||
</object>
|
||||
</property>
|
||||
<property name="title" translatable="yes">Zoom the value axis only</property>
|
||||
<property name="subtitle" translatable="yes">Ctrl and wheel</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkShortcutsShortcut">
|
||||
<property name="visible">1</property>
|
||||
<property name="shortcut-type">gesture</property>
|
||||
<!-- A GIcon property takes an object, not a name: GtkBuilder reads a bare string as a
|
||||
reference to another object and fails the whole file with "Object with ID … not
|
||||
found" (a Gtk-ERROR, which aborts the process by default). -->
|
||||
<property name="icon">
|
||||
<object class="GThemedIcon">
|
||||
<property name="names">input-mouse-symbolic</property>
|
||||
</object>
|
||||
</property>
|
||||
<property name="title" translatable="yes">Pan</property>
|
||||
<property name="subtitle" translatable="yes">Drag</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkShortcutsShortcut">
|
||||
<property name="visible">1</property>
|
||||
<property name="shortcut-type">gesture</property>
|
||||
<!-- A GIcon property takes an object, not a name: GtkBuilder reads a bare string as a
|
||||
reference to another object and fails the whole file with "Object with ID … not
|
||||
found" (a Gtk-ERROR, which aborts the process by default). -->
|
||||
<property name="icon">
|
||||
<object class="GThemedIcon">
|
||||
<property name="names">input-mouse-symbolic</property>
|
||||
</object>
|
||||
</property>
|
||||
<property name="title" translatable="yes">Box zoom on time</property>
|
||||
<property name="subtitle" translatable="yes">Ctrl and drag</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkShortcutsShortcut">
|
||||
<property name="visible">1</property>
|
||||
<property name="shortcut-type">gesture</property>
|
||||
<!-- A GIcon property takes an object, not a name: GtkBuilder reads a bare string as a
|
||||
reference to another object and fails the whole file with "Object with ID … not
|
||||
found" (a Gtk-ERROR, which aborts the process by default). -->
|
||||
<property name="icon">
|
||||
<object class="GThemedIcon">
|
||||
<property name="names">input-mouse-symbolic</property>
|
||||
</object>
|
||||
</property>
|
||||
<property name="title" translatable="yes">Box zoom on values</property>
|
||||
<property name="subtitle" translatable="yes">Alt and drag</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkShortcutsShortcut">
|
||||
<property name="visible">1</property>
|
||||
<property name="shortcut-type">gesture</property>
|
||||
<!-- A GIcon property takes an object, not a name: GtkBuilder reads a bare string as a
|
||||
reference to another object and fails the whole file with "Object with ID … not
|
||||
found" (a Gtk-ERROR, which aborts the process by default). -->
|
||||
<property name="icon">
|
||||
<object class="GThemedIcon">
|
||||
<property name="names">input-mouse-symbolic</property>
|
||||
</object>
|
||||
</property>
|
||||
<property name="title" translatable="yes">Box zoom on time and values</property>
|
||||
<property name="subtitle" translatable="yes">Shift and drag</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</interface>
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- SPDX-License-Identifier: MIT -->
|
||||
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
|
||||
<!--
|
||||
The application's UI files, compiled into the binary beside the icons and the help pages, so there
|
||||
is still exactly one file to run. `preprocess="xml-stripblanks"` is what catches a malformed file:
|
||||
gtk_builder_new_from_resource() on a broken .ui returns nothing usable and the window simply does
|
||||
not open, which is a silent failure at runtime and a loud one here.
|
||||
-->
|
||||
<gresources>
|
||||
<gresource prefix="/org/udpscope/ui">
|
||||
<file preprocess="xml-stripblanks">shortcuts.ui</file>
|
||||
</gresource>
|
||||
</gresources>
|
||||
+39
-2
@@ -27,6 +27,8 @@
|
||||
#include "model.hpp"
|
||||
#include "plot_host.hpp"
|
||||
#include "search.hpp"
|
||||
#include "settings.hpp"
|
||||
#include "ui_prefs.hpp"
|
||||
#include "shortcuts.hpp"
|
||||
#include "status_text.hpp"
|
||||
#include "waveform.hpp"
|
||||
@@ -77,6 +79,10 @@ 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 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;
|
||||
std::vector<float> layout_drag_start;
|
||||
/// The editor's way out: one `Done` button in the plot area's top-right corner while
|
||||
/// the editor is up. Not part of `layout_buttons`, which is one button per row and per
|
||||
/// boundary -- the count the layout probe asserts.
|
||||
@@ -362,6 +368,9 @@ struct App {
|
||||
/// `event_recording` is the trigger panel's switch and defaults on -- the cache is only useful if
|
||||
/// it was already on when the event you wanted happened.
|
||||
std::unique_ptr<EventStore> event_store;
|
||||
/// Whether the cache holds anything, refreshed once a second: what keeps the history button on the bar
|
||||
/// for a source whose events are already written, even with recording switched off.
|
||||
bool events_cached = false;
|
||||
bool event_recording = false;
|
||||
/// The events pane: its list, the revealer that holds it, the recording switch (in the trigger
|
||||
/// panel, where the trigger's options live), and `events_thumb` -- the picture of the newest
|
||||
@@ -385,6 +394,9 @@ struct App {
|
||||
plot::Plot placeholder_plot_b;
|
||||
plot::gtk4::PlotArea* area_b = nullptr;
|
||||
GtkWidget* area_b_widget = nullptr;
|
||||
/// The figure the second area was last pointed at. `set_figure()` rebuilds its per-row input
|
||||
/// controllers, so it is only called when the `RowSet` really has a new figure.
|
||||
plot::Figure* area_b_figure = nullptr;
|
||||
std::unique_ptr<RowSet> rows_b;
|
||||
GtkWidget* plot_split = nullptr;
|
||||
bool comparing = false;
|
||||
@@ -407,6 +419,15 @@ struct App {
|
||||
/// live source. Nothing arrives in this mode, so everything that would report on a stream --
|
||||
/// the popover's Stream row, the starvation notice -- reports on the file instead.
|
||||
std::string waveform_name;
|
||||
/// The preferences, as read at start-up and written by the dialog. Held here rather than read where
|
||||
/// they are used: the dialog edits a copy, saves it and hands it back, so the file and the running
|
||||
/// program cannot disagree about what a preference is.
|
||||
Settings settings;
|
||||
/// The preferences window while it is up, and its controls: one at a time, like the configurations
|
||||
/// dialog -- a second window editing the same settings would let the two disagree.
|
||||
/// The preferences window. A `PrefsForm` rather than fields here because the source chooser opens
|
||||
/// the same dialog and runs before `App` exists -- see `ui_prefs.hpp`.
|
||||
PrefsForm prefs;
|
||||
/// The configurations dialog, while it is up: one at a time, and this is what makes a second
|
||||
/// `Configurations…` a present rather than a second window.
|
||||
GtkWidget* config_dialog = nullptr;
|
||||
@@ -979,6 +1000,10 @@ constexpr int kRowKeyBias = 1;
|
||||
constexpr int kLayoutButtonPx = 24;
|
||||
constexpr int kLayoutButtonPad = 4;
|
||||
constexpr const char* kLayoutKindKey = "udpscope-layout-insert";
|
||||
|
||||
/// The ↕ handles' own key: what they are is *not* what the `+` is, and `sync_layout_editor()`
|
||||
/// places them differently -- one boundary, two controls.
|
||||
constexpr const char* kLayoutResizeKey = "udpscope-layout-resize";
|
||||
constexpr gint64 kMeasureRefreshUs = 100 * 1000;
|
||||
constexpr gint64 kMeasureHeavyUs = 5 * 1000;
|
||||
constexpr const char* kCursorModeNames[] = {"vertical", "horizontal", "both", "mix"};
|
||||
@@ -992,8 +1017,8 @@ constexpr double kMathZoomStep = 1.1;
|
||||
constexpr double kWirePick = 6.0;
|
||||
constexpr int kZoomGestureSteps = 8;
|
||||
constexpr int kZoomWindowSteps = 8;
|
||||
constexpr int kEventThumbW = 126;
|
||||
constexpr int kEventThumbH = 42;
|
||||
constexpr int kEventThumbW = 138;
|
||||
constexpr int kEventThumbH = 46;
|
||||
inline int g_stats_phase = 0;
|
||||
inline std::vector<std::uint64_t> g_stats_n_before;
|
||||
inline double g_trigrenum_edge = 0.0;
|
||||
@@ -1090,6 +1115,18 @@ void update_search_panel(App& app);
|
||||
void sync_events_availability(App& app);
|
||||
/// Restates the events pane's divider position once a frame; see the definition.
|
||||
void sync_events_divider(App& app);
|
||||
/// The preferences dialog: one window, opened from the overflow menu. `apply_theme()` is separate
|
||||
/// because the theme is the one preference that takes effect without a restart.
|
||||
void open_preferences(App& app);
|
||||
gboolean prefs_probe(gpointer user);
|
||||
gboolean boxzoom_probe(gpointer user);
|
||||
/// A labelled row whose control keeps its natural width -- what a `GtkSwitch` needs and what
|
||||
/// `labeled_box()`'s expanding child denies it.
|
||||
GtkWidget* labeled_switch_row(const char* label, GtkWidget* control);
|
||||
/// The layout editor's boundary drag: the press starts it, movement sizes the two rows it separates.
|
||||
void on_layout_drag_begin(GtkGestureDrag* gesture, double x, double y, gpointer user);
|
||||
void on_layout_drag_update(GtkGestureDrag* gesture, double offset_x, double offset_y, gpointer user);
|
||||
void on_layout_drag_end(GtkGestureDrag* gesture, double offset_x, double offset_y, gpointer user);
|
||||
void on_trigger_toggled(GtkToggleButton* button, gpointer user);
|
||||
void on_go_live(GtkButton*, gpointer user);
|
||||
plot::u32 measure_color(std::size_t index);
|
||||
|
||||
+80
-31
@@ -7,6 +7,7 @@
|
||||
|
||||
#include <gtk/gtk.h>
|
||||
|
||||
#include <limits>
|
||||
#include <string>
|
||||
|
||||
namespace udpscope {
|
||||
@@ -47,7 +48,10 @@ void sync_events_availability(App& app) {
|
||||
// Available while the cache can still grow (recording on) *or* while it is being read (an event on
|
||||
// screen). Not trigger mode: a stored event being examined is a file, and it has no trigger -- if the
|
||||
// pane's life depended on the trigger, opening one would close the list it was opened from.
|
||||
const bool available = app.event_recording || app.replaying;
|
||||
// ... or whenever the cache *has* something: an event on disk outlives the switch that wrote it, and
|
||||
// the button that shows it must not go with the switch. `events_cached` is refreshed once a second
|
||||
// from the tick -- the store's list is a directory read, not a per-frame question.
|
||||
const bool available = app.event_recording || app.replaying || app.events_cached;
|
||||
gtk_widget_set_visible(app.events_toggle, available);
|
||||
if (!available) {
|
||||
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(app.events_toggle), FALSE);
|
||||
@@ -116,7 +120,12 @@ bool record_event(App& app) {
|
||||
meta.samples = table.rows();
|
||||
meta.seq = app.model->event_seq();
|
||||
|
||||
const std::string file = app.event_store->save(table, meta);
|
||||
// 0 means "keep everything", which `save()` cannot be asked for with a count: it is the largest
|
||||
// number a size_t holds, and trimming then never finds anything beyond it.
|
||||
const std::size_t keep = app.settings.event_cache_max <= 0
|
||||
? std::numeric_limits<std::size_t>::max()
|
||||
: static_cast<std::size_t>(app.settings.event_cache_max);
|
||||
const std::string file = app.event_store->save(table, meta, keep);
|
||||
if (file.empty()) return false;
|
||||
// The event is on screen; say it was kept, and where. A notice rather than a dialog: a latch every
|
||||
// 200 ms must not be a wall of windows.
|
||||
@@ -171,7 +180,8 @@ bool replay_event(App& app, const EventRecord& record) {
|
||||
rebuild_signal_panels(app);
|
||||
// Fit on *open*, and only then: the whole event framed the moment it is opened, the user's own zooms
|
||||
// left alone afterwards -- restated here and never per frame.
|
||||
if (report.span_s > 0.0) apply_time_window(app, report.span_s);
|
||||
// The axis is the event's own and the tick pins it there (`pin_event_window`): nothing to frame or
|
||||
// home here beyond the window control's own reading, which the tick keeps in step.
|
||||
// The pane is available through a replay, and this is what keeps it open.
|
||||
sync_events_availability(app);
|
||||
set_notice(app, "replaying " + record.stem + " (" +
|
||||
@@ -376,42 +386,72 @@ bool build_event_group(App& app, const std::vector<EventRecord>& records, EventG
|
||||
app.rows_b->set_row_count(rows);
|
||||
if (app.area_b != nullptr) app.area_b->set_figure(&app.rows_b->figure());
|
||||
}
|
||||
// The user's layout is read **first**, and then every slot is freed on both plots, before the group
|
||||
// assigns its own. The order matters: the layout is what says which slots the group fills, so
|
||||
// clearing first would clear the very thing being read (it did -- every slot came back empty and the
|
||||
// comparison drew nothing). And the clear itself is what stops a row carrying the previous view's
|
||||
// traces -- an accumulation's layers, a live session's signals -- into the new one, where a trace
|
||||
// left behind is an extra legend entry in a colour belonging to nothing on screen.
|
||||
struct SlotPlan {
|
||||
std::string name;
|
||||
std::string label;
|
||||
int colour = 0;
|
||||
};
|
||||
// ... and only the *base* layout is read back: a slot is part of it when its name carries no layer
|
||||
// suffix or layer 0's own (`(A)`). A group's *extra* layers -- `(B)`, `#2` -- are that group's own
|
||||
// artefacts, and reading them back as the user's arrangement is how an accumulation's layers survived
|
||||
// into the next comparison: extra legend entries, in colours belonging to nothing.
|
||||
const auto base_slot = [](const std::string& name) {
|
||||
return name == layer_base_name(name) ||
|
||||
(name.size() > 4 && name.compare(name.size() - 4, 4, " (A)") == 0);
|
||||
};
|
||||
std::vector<std::array<SlotPlan, RowSet::kSlotsPerRow>> layout(static_cast<std::size_t>(rows));
|
||||
for (int r = 0; r < rows; ++r) {
|
||||
for (int s = 0; s < RowSet::kSlotsPerRow; ++s) {
|
||||
const std::size_t at = static_cast<std::size_t>(s);
|
||||
const std::string& name = app.rows->row(r).signal_name[at];
|
||||
if (!base_slot(name)) continue;
|
||||
layout[static_cast<std::size_t>(r)][at] =
|
||||
SlotPlan{name, app.rows->row(r).name[at], app.rows->row(r).color_index[at]};
|
||||
}
|
||||
}
|
||||
const auto clear = [](RowSet* rows) {
|
||||
if (rows == nullptr) return;
|
||||
for (int r = 0; r < rows->row_count(); ++r) {
|
||||
for (int s = 0; s < RowSet::kSlotsPerRow; ++s) rows->assign(r, s, "", "", 0);
|
||||
}
|
||||
};
|
||||
clear(app.rows);
|
||||
clear(app.rows_b.get());
|
||||
app.rows->set_trace_alpha(kind == EventGroupKind::Accumulate ? 0.35f : 1.0f);
|
||||
if (app.rows_b != nullptr) app.rows_b->set_trace_alpha(1.0f);
|
||||
for (int r = 0; r < rows; ++r) {
|
||||
RowSet::Row& row = app.rows->row(r);
|
||||
const std::array<std::string, RowSet::kSlotsPerRow> names = row.signal_name;
|
||||
const std::array<std::string, RowSet::kSlotsPerRow> labels = row.name;
|
||||
const std::array<int, RowSet::kSlotsPerRow> colours = row.color_index;
|
||||
const std::array<SlotPlan, RowSet::kSlotsPerRow>& plan = layout[static_cast<std::size_t>(r)];
|
||||
for (int s = 0; s < RowSet::kSlotsPerRow; ++s) {
|
||||
const std::size_t at = static_cast<std::size_t>(s);
|
||||
if (names[at].empty()) continue;
|
||||
const std::string base = base_name(names[at]);
|
||||
app.rows->assign(r, s, base + suffix(0), labels[at], colours[at]);
|
||||
if (plan[at].name.empty()) continue;
|
||||
const std::string base = base_name(plan[at].name);
|
||||
app.rows->assign(r, s, base + suffix(0), plan[at].label, plan[at].colour);
|
||||
if (pair) {
|
||||
// The *same* colour index: the two plots show the same signal of two different events, and a
|
||||
// colour of its own for the second would say they were different signals.
|
||||
app.rows_b->assign(r, s, base + suffix(1), labels[at] + suffix(1), colours[at]);
|
||||
app.rows_b->assign(r, s, base + suffix(1), plan[at].label + suffix(1), plan[at].colour);
|
||||
continue;
|
||||
}
|
||||
// The extra layers go into free slots of **this** row, and nowhere else. A row's signals are what
|
||||
// its colours and its legend speak for, so a layer placed in another row put a signal there in the
|
||||
// colour of the signal it came from -- a multi-row layout whose other rows came out "wrong". A row
|
||||
// with no room accumulates as far as it can and stops.
|
||||
for (std::size_t layer = 1; layer < layers; ++layer) {
|
||||
int found_row = -1;
|
||||
int found_slot = -1;
|
||||
for (int pass = 0; pass < 2 && found_row < 0; ++pass) {
|
||||
const int from = pass == 0 ? r : 0;
|
||||
const int to = pass == 0 ? r + 1 : rows;
|
||||
for (int rr = from; rr < to && found_row < 0; ++rr) {
|
||||
for (int ss = 0; ss < RowSet::kSlotsPerRow; ++ss) {
|
||||
if (!app.rows->row(rr).signal_name[static_cast<std::size_t>(ss)].empty()) continue;
|
||||
found_row = rr;
|
||||
found_slot = ss;
|
||||
break;
|
||||
}
|
||||
}
|
||||
for (int ss = 0; ss < RowSet::kSlotsPerRow; ++ss) {
|
||||
if (!app.rows->row(r).signal_name[static_cast<std::size_t>(ss)].empty()) continue;
|
||||
found_slot = ss;
|
||||
break;
|
||||
}
|
||||
if (found_row < 0) break; // the layout is full: no room for another layer
|
||||
app.rows->assign(found_row, found_slot, base + suffix(layer), labels[at] + suffix(layer),
|
||||
colours[at]);
|
||||
if (found_slot < 0) break; // this row is full: its signal accumulates as far as it can
|
||||
app.rows->assign(r, found_slot, base + suffix(layer), plan[at].label + suffix(layer),
|
||||
plan[at].colour);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -435,7 +475,7 @@ bool build_event_group(App& app, const std::vector<EventRecord>& records, EventG
|
||||
rebuild_layout_editor(app);
|
||||
rebuild_signal_panels(app);
|
||||
// Fit on open, once: the group's own span, framed.
|
||||
apply_time_window(app, span);
|
||||
// As above: the tick pins the axis to the group's own span and marks the trigger.
|
||||
sync_events_availability(app);
|
||||
return true;
|
||||
}
|
||||
@@ -680,16 +720,25 @@ void refresh_events_pane(App& app) {
|
||||
gtk_box_append(GTK_BOX(box), name);
|
||||
|
||||
char sub[160];
|
||||
std::snprintf(sub, sizeof(sub), "%zu signals · %zu samples · %.4g S/s", r.signals, r.samples,
|
||||
r.rate_hz);
|
||||
std::snprintf(sub, sizeof(sub), "%zu signals · %zu samples", r.signals, r.samples);
|
||||
GtkWidget* detail = gtk_label_new(sub);
|
||||
gtk_widget_add_css_class(detail, "dim-label");
|
||||
gtk_widget_set_halign(detail, GTK_ALIGN_START);
|
||||
gtk_label_set_max_width_chars(GTK_LABEL(detail), 22);
|
||||
// Both labels ellipsize, so the text gives way to the picture rather than the picture to the
|
||||
// text: a label's minimum width is its longest word otherwise, and the pane is 250 pixels.
|
||||
gtk_label_set_ellipsize(GTK_LABEL(detail), PANGO_ELLIPSIZE_END);
|
||||
gtk_box_append(GTK_BOX(box), detail);
|
||||
|
||||
// A third line: the rate it was recorded at, where its edge sits and which latch it was -- what
|
||||
// tells two events of the same length apart once the picture is too small to read.
|
||||
char third[160];
|
||||
std::snprintf(third, sizeof(third), "%.4g S/s · edge %.4g s · #%llu", r.rate_hz, r.edge,
|
||||
static_cast<unsigned long long>(r.seq));
|
||||
GtkWidget* extra = gtk_label_new(third);
|
||||
gtk_widget_add_css_class(extra, "dim-label");
|
||||
gtk_widget_set_halign(extra, GTK_ALIGN_START);
|
||||
gtk_label_set_max_width_chars(GTK_LABEL(extra), 22);
|
||||
gtk_label_set_ellipsize(GTK_LABEL(extra), PANGO_ELLIPSIZE_END);
|
||||
gtk_box_append(GTK_BOX(box), extra);
|
||||
gtk_box_append(GTK_BOX(line), box);
|
||||
|
||||
gtk_list_box_row_set_child(GTK_LIST_BOX_ROW(row), line);
|
||||
|
||||
@@ -24,6 +24,8 @@ LayoutState collect_layout(App& app) {
|
||||
for (int s = 0; s < RowSet::kSlotsPerRow; ++s) {
|
||||
row.signals.push_back(r.signal_name[static_cast<std::size_t>(s)]);
|
||||
}
|
||||
const std::vector<float>& weights = app.rows->row_weights();
|
||||
if (static_cast<std::size_t>(i) < weights.size()) row.weight = weights[static_cast<std::size_t>(i)];
|
||||
st.rows.push_back(std::move(row));
|
||||
}
|
||||
for (std::size_t si = 0; si < model.signals().size(); ++si) {
|
||||
@@ -313,6 +315,15 @@ void apply_layout(App& app, const LayoutState& st) {
|
||||
if (rows > RowSet::kMaxRows) rows = RowSet::kMaxRows;
|
||||
if (rows > 0) app.rows->set_row_count(rows);
|
||||
|
||||
// The rows' shares of the height come from the same file as their signals: a sizing a user dragged is
|
||||
// an arrangement, and the layout is where an arrangement lives. Absent from an older file, the weights
|
||||
// are all 1 -- equal rows -- which is what such a file was saved with.
|
||||
{
|
||||
std::vector<float> weights;
|
||||
weights.reserve(st.rows.size());
|
||||
for (const LayoutState::Row& wr : st.rows) weights.push_back(static_cast<float>(wr.weight));
|
||||
app.rows->set_row_weights(std::move(weights));
|
||||
}
|
||||
for (int i = 0; i < rows; ++i) {
|
||||
int slot = 0;
|
||||
for (const std::string& name : st.rows[static_cast<std::size_t>(i)].signals) {
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
//
|
||||
// The preferences form: the values `settings.hpp` holds, in one place, with the settings file behind
|
||||
// them. It is a *view* of that struct -- it reads one, edits it and hands it back -- so nothing here
|
||||
// decides what a preference means; `PrefsForm::on_applied` is where that happens, and it happens
|
||||
// differently in the app and in the source chooser.
|
||||
//
|
||||
// Two rows can only take effect on the next start in the app's case (the cache directory and relative
|
||||
// time: the buffers are planned and the cache is opened before any stream is known), and the dialog
|
||||
// says so rather than pretending otherwise. From the chooser, both are honoured by the run the
|
||||
// chooser is about to start, because that run has not been planned yet.
|
||||
|
||||
#include "ui_prefs.hpp"
|
||||
|
||||
#include "event_store.hpp" // `default_events_dir()`, so the field shows the path the cache uses
|
||||
#include "ui_app.hpp" // the app's own half of the contract, at the bottom of this file
|
||||
|
||||
#include <adwaita.h>
|
||||
|
||||
#include <utility>
|
||||
|
||||
namespace udpscope {
|
||||
namespace ui_impl {
|
||||
namespace {
|
||||
|
||||
/// Turns the theme name into the colour scheme libadwaita understands. "auto" is `DEFAULT`, which is
|
||||
/// the system's own answer -- the one a user who has not chosen expects.
|
||||
AdwColorScheme theme_scheme(const std::string& name) {
|
||||
if (name == "light") return ADW_COLOR_SCHEME_FORCE_LIGHT;
|
||||
if (name == "dark") return ADW_COLOR_SCHEME_FORCE_DARK;
|
||||
return ADW_COLOR_SCHEME_DEFAULT;
|
||||
}
|
||||
|
||||
/// Reads the form into a `Settings`. It starts from the file rather than from an empty struct so that
|
||||
/// a field this dialog does not show is carried through untouched instead of being reset by every
|
||||
/// apply.
|
||||
Settings read_form(const PrefsForm& f) {
|
||||
Settings s = load_settings();
|
||||
s.event_cache_dir = gtk_editable_get_text(GTK_EDITABLE(f.dir_entry));
|
||||
// The field shows the *effective* directory, so the default is visible in full rather than described.
|
||||
// Typing it back unchanged is not a choice to pin it: the file keeps `unset`, and the default stays
|
||||
// the default of whatever cache directory the next run happens to have.
|
||||
if (s.event_cache_dir == default_events_dir()) s.event_cache_dir.clear();
|
||||
s.event_cache_max = gtk_spin_button_get_value_as_int(GTK_SPIN_BUTTON(f.keep_spin));
|
||||
s.relative_time = gtk_switch_get_active(GTK_SWITCH(f.relative_switch));
|
||||
const guint selected = gtk_drop_down_get_selected(GTK_DROP_DOWN(f.theme_drop));
|
||||
s.theme = selected == 1 ? "light" : selected == 2 ? "dark" : "auto";
|
||||
return s;
|
||||
}
|
||||
|
||||
/// Cancelling must change *nothing*: the field keeps what it had and the status line stays quiet. The
|
||||
/// null check is not defensive padding -- `prefs_close()` clears every widget pointer, and a folder
|
||||
/// chooser still open when its window closes is exactly how a callback lands on a dead field.
|
||||
void on_browse_done(GObject* source, GAsyncResult* result, gpointer user) {
|
||||
PrefsForm& f = *static_cast<PrefsForm*>(user);
|
||||
GFile* folder = gtk_file_dialog_select_folder_finish(GTK_FILE_DIALOG(source), result, nullptr);
|
||||
if (folder == nullptr) return;
|
||||
char* path = g_file_get_path(folder);
|
||||
g_object_unref(folder);
|
||||
if (path == nullptr) return;
|
||||
if (f.dir_entry != nullptr) gtk_editable_set_text(GTK_EDITABLE(f.dir_entry), path);
|
||||
g_free(path);
|
||||
}
|
||||
|
||||
/// `GtkFileDialog`, the same one the source chooser's *Open…* uses, rather than the older
|
||||
/// `GtkFileChooserDialog`: on a desktop with a portal it hands over to the system's own picker instead
|
||||
/// of drawing a GTK-shaped impression of one.
|
||||
void on_browse(GtkButton*, gpointer user) {
|
||||
PrefsForm& f = *static_cast<PrefsForm*>(user);
|
||||
GtkFileDialog* dialog = gtk_file_dialog_new();
|
||||
gtk_file_dialog_set_title(dialog, "Event cache directory");
|
||||
const char* current =
|
||||
f.dir_entry != nullptr ? gtk_editable_get_text(GTK_EDITABLE(f.dir_entry)) : "";
|
||||
if (current != nullptr && current[0] != '\0') {
|
||||
GFile* folder = g_file_new_for_path(current);
|
||||
gtk_file_dialog_set_initial_folder(dialog, folder);
|
||||
g_object_unref(folder);
|
||||
}
|
||||
gtk_file_dialog_select_folder(dialog, f.window != nullptr ? GTK_WINDOW(f.window) : nullptr, nullptr,
|
||||
on_browse_done, &f);
|
||||
g_object_unref(dialog); // the call holds its own reference
|
||||
}
|
||||
|
||||
void on_defaults(GtkButton*, gpointer user) {
|
||||
PrefsForm& f = *static_cast<PrefsForm*>(user);
|
||||
const Settings d = default_settings();
|
||||
gtk_editable_set_text(GTK_EDITABLE(f.dir_entry),
|
||||
(d.event_cache_dir.empty() ? default_events_dir() : d.event_cache_dir).c_str());
|
||||
gtk_spin_button_set_value(GTK_SPIN_BUTTON(f.keep_spin), d.event_cache_max);
|
||||
gtk_switch_set_active(GTK_SWITCH(f.relative_switch), d.relative_time);
|
||||
gtk_drop_down_set_selected(GTK_DROP_DOWN(f.theme_drop), 0);
|
||||
}
|
||||
|
||||
void on_apply(GtkButton*, gpointer user) {
|
||||
PrefsForm& f = *static_cast<PrefsForm*>(user);
|
||||
const Settings s = read_form(f);
|
||||
const bool written = save_settings(s);
|
||||
// The theme goes on even when the write failed: the user asked to see it, and the file is the
|
||||
// memory of the choice rather than the choice itself. The status line is what says it will not
|
||||
// survive a restart.
|
||||
apply_theme(s.theme);
|
||||
if (f.status != nullptr) {
|
||||
gtk_label_set_text(GTK_LABEL(f.status),
|
||||
written ? "" : "could not write the preferences file -- this session only");
|
||||
gtk_widget_set_visible(f.status, !written);
|
||||
}
|
||||
if (f.on_applied) f.on_applied(s, written);
|
||||
}
|
||||
|
||||
/// The one close path, whether it came from the button, the window manager or `prefs_close()`.
|
||||
gboolean on_close_request(GtkWindow*, gpointer user) {
|
||||
PrefsForm& f = *static_cast<PrefsForm*>(user);
|
||||
f.window = nullptr;
|
||||
f.dir_entry = nullptr;
|
||||
f.browse = nullptr;
|
||||
f.keep_spin = nullptr;
|
||||
f.relative_switch = nullptr;
|
||||
f.theme_drop = nullptr;
|
||||
f.apply = nullptr;
|
||||
f.status = nullptr;
|
||||
return FALSE; // the default handler destroys the window
|
||||
}
|
||||
|
||||
void on_close_clicked(GtkButton*, gpointer user) {
|
||||
prefs_close(*static_cast<PrefsForm*>(user));
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void prefs_open(PrefsForm& form, GtkWidget* parent, const Settings& current,
|
||||
std::function<void(const Settings&, bool written)> on_applied) {
|
||||
if (form.window != nullptr) {
|
||||
gtk_window_present(GTK_WINDOW(form.window));
|
||||
return;
|
||||
}
|
||||
form.on_applied = std::move(on_applied);
|
||||
|
||||
GtkWidget* window = dialog_window(parent, "Preferences");
|
||||
form.window = window;
|
||||
g_signal_connect(window, "close-request", G_CALLBACK(on_close_request), &form);
|
||||
|
||||
GtkWidget* column = gtk_box_new(GTK_ORIENTATION_VERTICAL, 12);
|
||||
gtk_widget_set_margin_top(column, 12);
|
||||
gtk_widget_set_margin_bottom(column, 12);
|
||||
gtk_widget_set_margin_start(column, 12);
|
||||
gtk_widget_set_margin_end(column, 12);
|
||||
|
||||
// --- the event cache ------------------------------------------------------
|
||||
GtkWidget* cache_heading = gtk_label_new(nullptr);
|
||||
gtk_label_set_markup(GTK_LABEL(cache_heading), "<b>Event cache</b>");
|
||||
gtk_widget_set_halign(cache_heading, GTK_ALIGN_START);
|
||||
gtk_box_append(GTK_BOX(column), cache_heading);
|
||||
|
||||
form.dir_entry = gtk_entry_new();
|
||||
gtk_editable_set_text(GTK_EDITABLE(form.dir_entry),
|
||||
(current.event_cache_dir.empty() ? default_events_dir()
|
||||
: current.event_cache_dir)
|
||||
.c_str());
|
||||
gtk_widget_set_tooltip_text(form.dir_entry,
|
||||
"where events are cached: one directory in which every source gets a "
|
||||
"folder of its own, never one directory between two sources. Left at "
|
||||
"the default shown here, it follows the cache directory");
|
||||
// A standard icon rather than the word: an icon-only button needs its meaning kept somewhere, so the
|
||||
// name goes to assistive technology and the explanation to the tooltip. `folder-open-symbolic` is
|
||||
// Adwaita's, not one of the six this program bundles -- it is the theme's own word for "choose a
|
||||
// directory", and a second drawing of it here would only be a worse one.
|
||||
form.browse = gtk_button_new_from_icon_name("folder-open-symbolic");
|
||||
gtk_widget_set_tooltip_text(form.browse,
|
||||
"choose the directory in a file chooser; what it returns is put in "
|
||||
"the field beside it, which stays the value that is stored");
|
||||
// Terminated by -1, not by a null: this call is varargs, and `nullptr` reads as property id 0 --
|
||||
// which is `autocomplete`, not "no more properties", so GTK then reads the next word as its value.
|
||||
gtk_accessible_update_property(GTK_ACCESSIBLE(form.browse), GTK_ACCESSIBLE_PROPERTY_LABEL,
|
||||
"Choose the event cache directory", -1);
|
||||
g_signal_connect(form.browse, "clicked", G_CALLBACK(on_browse), &form);
|
||||
GtkWidget* dir_row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
|
||||
gtk_box_append(GTK_BOX(dir_row), form.dir_entry);
|
||||
gtk_box_append(GTK_BOX(dir_row), form.browse);
|
||||
gtk_box_append(GTK_BOX(column), labeled_box("directory", dir_row));
|
||||
|
||||
form.keep_spin = spin(0, 100000, 1, 0, current.event_cache_max);
|
||||
gtk_widget_set_tooltip_text(form.keep_spin,
|
||||
"how many events one source keeps; the oldest are deleted as new ones "
|
||||
"arrive. 0 keeps every event, which is a decision about the disk");
|
||||
gtk_box_append(GTK_BOX(column), labeled_box("keep events", form.keep_spin));
|
||||
|
||||
// --- the plot -------------------------------------------------------------
|
||||
GtkWidget* plot_heading = gtk_label_new(nullptr);
|
||||
gtk_label_set_markup(GTK_LABEL(plot_heading), "<b>Plot</b>");
|
||||
gtk_widget_set_halign(plot_heading, GTK_ALIGN_START);
|
||||
gtk_box_append(GTK_BOX(column), plot_heading);
|
||||
|
||||
form.relative_switch = gtk_switch_new();
|
||||
gtk_switch_set_active(GTK_SWITCH(form.relative_switch), current.relative_time);
|
||||
gtk_widget_set_tooltip_text(form.relative_switch,
|
||||
"ignore the stream's own clock and plot from the sample rate and the "
|
||||
"sample index, for a source whose time stamps jitter");
|
||||
gtk_box_append(GTK_BOX(column), labeled_switch_row("relative time", form.relative_switch));
|
||||
|
||||
const char* themes[] = {"auto", "light", "dark", nullptr};
|
||||
form.theme_drop = gtk_drop_down_new_from_strings(themes);
|
||||
gtk_drop_down_set_selected(GTK_DROP_DOWN(form.theme_drop),
|
||||
current.theme == "light" ? 1 : current.theme == "dark" ? 2 : 0);
|
||||
gtk_widget_set_tooltip_text(form.theme_drop,
|
||||
"what the interface looks like; auto follows the system");
|
||||
gtk_box_append(GTK_BOX(column), labeled_box("theme", form.theme_drop));
|
||||
|
||||
// --- the way out ----------------------------------------------------------
|
||||
form.status = gtk_label_new(nullptr);
|
||||
gtk_label_set_wrap(GTK_LABEL(form.status), TRUE);
|
||||
gtk_label_set_xalign(GTK_LABEL(form.status), 0.0F);
|
||||
gtk_widget_add_css_class(form.status, "error");
|
||||
gtk_widget_set_visible(form.status, FALSE);
|
||||
gtk_box_append(GTK_BOX(column), form.status);
|
||||
|
||||
GtkWidget* buttons = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
|
||||
gtk_widget_set_halign(buttons, GTK_ALIGN_END);
|
||||
GtkWidget* defaults = gtk_button_new_with_label("Defaults");
|
||||
g_signal_connect(defaults, "clicked", G_CALLBACK(on_defaults), &form);
|
||||
gtk_box_append(GTK_BOX(buttons), defaults);
|
||||
form.apply = gtk_button_new_with_label("Apply");
|
||||
gtk_widget_add_css_class(form.apply, "suggested-action");
|
||||
g_signal_connect(form.apply, "clicked", G_CALLBACK(on_apply), &form);
|
||||
gtk_box_append(GTK_BOX(buttons), form.apply);
|
||||
GtkWidget* close = gtk_button_new_with_label("Close");
|
||||
g_signal_connect(close, "clicked", G_CALLBACK(on_close_clicked), &form);
|
||||
gtk_box_append(GTK_BOX(buttons), close);
|
||||
gtk_box_append(GTK_BOX(column), buttons);
|
||||
|
||||
gtk_window_set_child(GTK_WINDOW(window), column);
|
||||
gtk_window_present(GTK_WINDOW(window));
|
||||
}
|
||||
|
||||
void prefs_close(PrefsForm& form) {
|
||||
if (form.window == nullptr) return;
|
||||
GtkWidget* window = form.window;
|
||||
form.window = nullptr; // before the close, so `close-request` cannot re-enter a stale window
|
||||
gtk_window_close(GTK_WINDOW(window));
|
||||
}
|
||||
|
||||
void apply_theme(const std::string& name) {
|
||||
AdwStyleManager* manager = adw_style_manager_get_default();
|
||||
if (manager != nullptr) adw_style_manager_set_color_scheme(manager, theme_scheme(name));
|
||||
}
|
||||
|
||||
void prefs_probe_fill_and_apply(PrefsForm& form) {
|
||||
if (form.window == nullptr) return;
|
||||
gtk_editable_set_text(GTK_EDITABLE(form.dir_entry), "/tmp/udpscope-prefs-events");
|
||||
gtk_spin_button_set_value(GTK_SPIN_BUTTON(form.keep_spin), 7);
|
||||
gtk_switch_set_active(GTK_SWITCH(form.relative_switch), TRUE);
|
||||
gtk_drop_down_set_selected(GTK_DROP_DOWN(form.theme_drop), 2);
|
||||
if (form.apply != nullptr) g_signal_emit_by_name(form.apply, "clicked");
|
||||
}
|
||||
|
||||
/// The app's entry point: the preferences button in the overflow menu. Kept here rather than in
|
||||
/// `ui.cpp` with the rest of the chrome, because it is three lines of *this* form's contract -- what
|
||||
/// the app must do when a preference changes -- and reading it next to the form is the only way to
|
||||
/// see that the two agree.
|
||||
void open_preferences(App& app) {
|
||||
prefs_open(app.prefs, app.window, app.settings, [&app](const Settings& s, bool written) {
|
||||
app.settings = s;
|
||||
set_notice(app, written ? "preferences saved" : "could not write the preferences file", 4);
|
||||
});
|
||||
}
|
||||
|
||||
} // namespace ui_impl
|
||||
} // namespace udpscope
|
||||
@@ -0,0 +1,70 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
//
|
||||
// The preferences form, without an owner.
|
||||
//
|
||||
// It used to be six widget fields on `App` and a function taking `App&`, which made it unreachable
|
||||
// from the one other place that needs it: the source chooser, which runs *before* `App` exists --
|
||||
// `main()` chooses a source, and only then builds the model and the UI. So the widgets and what they
|
||||
// contain are a struct the caller owns, and what a change *means* is a callback, because it means
|
||||
// different things on the two sides: the app follows its own switch and puts up a notice, the chooser
|
||||
// hands relative time to the run it is about to start.
|
||||
|
||||
#ifndef UDPSCOPE_UI_PREFS_HPP
|
||||
#define UDPSCOPE_UI_PREFS_HPP
|
||||
|
||||
#include <functional>
|
||||
#include <string>
|
||||
|
||||
#include <gtk/gtk.h>
|
||||
|
||||
#include "settings.hpp"
|
||||
|
||||
namespace udpscope {
|
||||
namespace ui_impl {
|
||||
|
||||
/// One preferences window's widgets, owned by whoever opened it.
|
||||
///
|
||||
/// Every pointer is valid only while `window` is non-null: the close path clears the lot, so a handler
|
||||
/// that fires after the window went away reads null rather than freed memory.
|
||||
struct PrefsForm {
|
||||
GtkWidget* window = nullptr;
|
||||
GtkWidget* dir_entry = nullptr;
|
||||
/// The folder chooser beside it. A text field alone asks a user to know a path and type it correctly;
|
||||
/// the chooser is how they find one, and it writes what it found into the same field -- so the text
|
||||
/// stays the single value, and there is nothing to keep in step.
|
||||
GtkWidget* browse = nullptr;
|
||||
GtkWidget* keep_spin = nullptr;
|
||||
GtkWidget* relative_switch = nullptr;
|
||||
GtkWidget* theme_drop = nullptr;
|
||||
GtkWidget* apply = nullptr;
|
||||
/// Shown only when a write fails. A preference that silently does not stick is worse than one that
|
||||
/// says so, and the two owners have nowhere in common to say it: the app has a status bar, the
|
||||
/// chooser does not.
|
||||
GtkWidget* status = nullptr;
|
||||
/// Called on *Apply*, with what the form held and whether it reached the file -- which is already
|
||||
/// written by the time this runs. Empty is allowed.
|
||||
std::function<void(const Settings&, bool written)> on_applied;
|
||||
};
|
||||
|
||||
/// Opens the preferences for `form`, transient for `parent` (which may be null), starting from
|
||||
/// `current`. Called while the window is up it raises that window instead of building a second one:
|
||||
/// two forms editing one file is how a user's change disappears.
|
||||
void prefs_open(PrefsForm& form, GtkWidget* parent, const Settings& current,
|
||||
std::function<void(const Settings&, bool written)> on_applied);
|
||||
|
||||
/// Closes it. Safe to call when it is not open, and safe to call from inside the apply callback.
|
||||
void prefs_close(PrefsForm& form);
|
||||
|
||||
/// Puts the interface into the named theme at once. Separate from the form because it is the one
|
||||
/// preference with nothing to plan: it applies to windows that are already on screen.
|
||||
void apply_theme(const std::string& name);
|
||||
|
||||
/// Fills every field the way a user would and presses *Apply*: the self-check's two entry points both
|
||||
/// end up here, so the app's probe and the chooser's probe cannot drift apart in what they exercise.
|
||||
void prefs_probe_fill_and_apply(PrefsForm& form);
|
||||
|
||||
} // namespace ui_impl
|
||||
} // namespace udpscope
|
||||
|
||||
#endif // UDPSCOPE_UI_PREFS_HPP
|
||||
+393
-11
@@ -1505,6 +1505,22 @@ void layout_probe_report(App& a, const char* what) {
|
||||
}
|
||||
if (!any) traces += "-";
|
||||
}
|
||||
// Where the resize arrows are and whether they are on screen: a boundary handle placed at the wrong
|
||||
// index is invisible in a button count, and the one thing this control must get right is *which*
|
||||
// border it sits on.
|
||||
std::string resize_tops;
|
||||
for (GtkWidget* w : a.layout_buttons) {
|
||||
if (g_object_get_data(G_OBJECT(w), "udpscope-layout-resize") == nullptr) continue;
|
||||
if (gtk_widget_get_visible(w)) {
|
||||
const int top = gtk_widget_get_margin_top(w);
|
||||
resize_tops += (resize_tops.empty() ? "" : ",") + std::to_string(row_key(w)) + ":" +
|
||||
std::to_string(top);
|
||||
} else {
|
||||
resize_tops += std::string(resize_tops.empty() ? "" : ",") + "(" + std::to_string(row_key(w)) +
|
||||
":-)";
|
||||
}
|
||||
}
|
||||
if (resize_tops.empty()) resize_tops = "-";
|
||||
// Where the Done button ended up, in the overlay's own coordinates: the assertion is
|
||||
// that it hugs the area's top-right corner, so a layout change that pushed it off the
|
||||
// corner shows here as numbers rather than as a screenshot someone has to look at.
|
||||
@@ -1520,13 +1536,175 @@ void layout_probe_report(App& a, const char* what) {
|
||||
}
|
||||
}
|
||||
std::fprintf(stderr, "[layout-probe] %-12s rows=%d buttons=%zu done=%d done_box=[%s] "
|
||||
"rows_y=[%s] close=[%s] add=[%s] traces=[%s]\n",
|
||||
"rows_y=[%s] close=[%s] add=[%s] resize=[%s] traces=[%s]\n",
|
||||
what, rows, a.layout_buttons.size(), a.layout_done_button != nullptr ? 1 : 0,
|
||||
done_box.c_str(), geom.c_str(), close_tops.c_str(), add_tops.c_str(),
|
||||
resize_tops.c_str(),
|
||||
traces.c_str());
|
||||
std::fflush(stderr);
|
||||
}
|
||||
|
||||
|
||||
/// The preferences dialog, driven the way a user drives it: opened, filled in, *Apply* clicked, then
|
||||
/// the file read back from disk. Reading it back rather than checking `App::settings` is the point --
|
||||
/// a dialog that edits its copy and never writes is the failure this has to catch.
|
||||
|
||||
/// `UDPSCOPE_PROBE_BOXZOOM_MS`: what a Ctrl box zoom puts on the plot, and what it does to the axis.
|
||||
///
|
||||
/// The gesture is injected straight into the row's controller -- the way a headless probe has to, since
|
||||
/// the area's gestures arrive from GTK -- and both halves are read back a few ticks later. The *axis* is
|
||||
/// the half a rect cannot report: it is the *settled* one that matters, after the pin or the data clamp
|
||||
/// has had its say, and it is the half that silently does nothing in a view whose axis is pinned.
|
||||
///
|
||||
/// Three named steps rather than nested lambdas: `g_timeout_add` takes a plain function pointer, so a
|
||||
/// lambda can carry no state, and the reading needs what the axis was before the drag.
|
||||
struct BoxZoomProbe {
|
||||
App* app = nullptr;
|
||||
plot::Range before{0.0, 0.0};
|
||||
};
|
||||
|
||||
plot::Range boxzoom_axis(App& app) {
|
||||
const RowSet::Row& r = app.rows->row(0);
|
||||
return r.plot != nullptr ? r.plot->x_range(r.axis_x) : plot::Range{0.0, 0.0};
|
||||
}
|
||||
|
||||
gboolean boxzoom_probe_done(gpointer user) {
|
||||
BoxZoomProbe& probe = *static_cast<BoxZoomProbe*>(user);
|
||||
App& app = *probe.app;
|
||||
const plot::Rect frame = app.rows->figure().plot(0u).selection_rect();
|
||||
const plot::Range now = boxzoom_axis(app);
|
||||
std::fprintf(stderr,
|
||||
"[boxzoom-probe] closed: frame=%.0fx%.0f gone=%d axis_after=[%.6f..%.6f] zoomed=%d\n",
|
||||
static_cast<double>(frame.w), static_cast<double>(frame.h),
|
||||
(frame.w == 0.0f && frame.h == 0.0f) ? 1 : 0, static_cast<double>(now.min),
|
||||
static_cast<double>(now.max),
|
||||
(now.span() < probe.before.span() * 0.999) ? 1 : 0);
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
|
||||
gboolean boxzoom_probe_release(gpointer user) {
|
||||
App& app = *static_cast<BoxZoomProbe*>(user)->app;
|
||||
plot::input::Controller* c = app.area->row_controller(0);
|
||||
if (c != nullptr) {
|
||||
const plot::u32 ctrl = static_cast<plot::u32>(plot::input::Mod::Ctrl);
|
||||
c->on_pointer({300.0f, 160.0f, 0.0f, 0.0f, plot::input::Button::Left, ctrl, false, false, true});
|
||||
}
|
||||
g_timeout_add_full(G_PRIORITY_DEFAULT, 80, boxzoom_probe_done, user,
|
||||
+[](gpointer p) { delete static_cast<BoxZoomProbe*>(p); });
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
|
||||
gboolean boxzoom_probe_open(gpointer user) {
|
||||
BoxZoomProbe& probe = *static_cast<BoxZoomProbe*>(user);
|
||||
App& app = *probe.app;
|
||||
plot::Plot& plot = app.rows->figure().plot(0u);
|
||||
const plot::Rect frame = plot.selection_rect();
|
||||
const plot::Rect axes = plot.layout().axes;
|
||||
// The frame is the band the *release* will apply: the width the pointer swept, and the height of the
|
||||
// axes rather than the ten pixels the pointer also moved -- Ctrl zooms x only.
|
||||
const bool band = std::fabs(frame.w - 200.0f) < 1.0f && std::fabs(frame.h - axes.h) < 1.0f;
|
||||
std::fprintf(stderr,
|
||||
"[boxzoom-probe] open: swept=200x10 frame=%.0f,%.0f %.0fx%.0f axes_h=%.0f band=%d "
|
||||
"axis_before=[%.6f..%.6f]\n",
|
||||
static_cast<double>(frame.x), static_cast<double>(frame.y),
|
||||
static_cast<double>(frame.w), static_cast<double>(frame.h),
|
||||
static_cast<double>(axes.h), band ? 1 : 0, static_cast<double>(probe.before.min),
|
||||
static_cast<double>(probe.before.max));
|
||||
return boxzoom_probe_release(user);
|
||||
}
|
||||
|
||||
gboolean boxzoom_probe(gpointer user) {
|
||||
App& a = *static_cast<App*>(user);
|
||||
if (a.area == nullptr || a.rows == nullptr) return G_SOURCE_REMOVE;
|
||||
plot::input::Controller* c = a.area->row_controller(0);
|
||||
if (c == nullptr) return G_SOURCE_REMOVE;
|
||||
auto* probe = new BoxZoomProbe{&a, boxzoom_axis(a)};
|
||||
const plot::u32 ctrl = static_cast<plot::u32>(plot::input::Mod::Ctrl);
|
||||
c->on_pointer({100.0f, 150.0f, 0.0f, 0.0f, plot::input::Button::Left, ctrl, true, true, false});
|
||||
c->on_pointer({300.0f, 160.0f, 200.0f, 10.0f, plot::input::Button::Left, ctrl, true, false, false});
|
||||
// No destroy here: the *last* step owns the struct, because the middle step schedules it. A destroy
|
||||
// on every step would free it when the first one returns `G_SOURCE_REMOVE` -- and the step it just
|
||||
// armed would then read freed memory, which is a segfault in a self-check rather than a failure.
|
||||
g_timeout_add(80, boxzoom_probe_open, probe);
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
|
||||
gboolean prefs_probe(gpointer user) {
|
||||
App& a = *static_cast<App*>(user);
|
||||
open_preferences(a);
|
||||
// `dir` is the directory the dialog is offering and `store` is the one the cache is really using:
|
||||
// `store` must be `dir` plus the source's folder name, which is what says the field shows the path in
|
||||
// force rather than a plausible-looking string.
|
||||
const char* shown =
|
||||
a.prefs.dir_entry != nullptr ? gtk_editable_get_text(GTK_EDITABLE(a.prefs.dir_entry)) : "-";
|
||||
std::fprintf(stderr, "[prefs-probe] opened=%d apply=%d dir=%s store=%s\n",
|
||||
a.prefs.window != nullptr ? 1 : 0, a.prefs.apply != nullptr ? 1 : 0,
|
||||
a.prefs.dir_entry != nullptr ? shown : "-",
|
||||
a.event_store != nullptr ? a.event_store->dir().c_str() : "-");
|
||||
g_timeout_add(400, +[](gpointer u) -> gboolean {
|
||||
App& app = *static_cast<App*>(u);
|
||||
prefs_probe_fill_and_apply(app.prefs);
|
||||
const Settings back = load_settings();
|
||||
std::fprintf(stderr, "[prefs-probe] dir=%s keep=%d rel=%d theme=%s live=%d\n",
|
||||
back.event_cache_dir.c_str(), back.event_cache_max, back.relative_time ? 1 : 0,
|
||||
back.theme.c_str(),
|
||||
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 --
|
||||
// a path written into the file is a user's choice, and this one was not a choice.
|
||||
gtk_editable_set_text(GTK_EDITABLE(app.prefs.dir_entry), default_events_dir().c_str());
|
||||
g_signal_emit_by_name(app.prefs.apply, "clicked");
|
||||
std::fprintf(stderr, "[prefs-probe] default restored: file='%s'\n",
|
||||
load_settings().event_cache_dir.c_str());
|
||||
// The folder chooser, opened from its button and then *cancelled from outside*: a callback that
|
||||
// lands after its dialog's window is gone is what an async picker invites, and cancelling must
|
||||
// change nothing at all. The preferences window is closed a beat later, so the cancel callback
|
||||
// arrives while the form is still intact -- and the null checks in it are what makes the other
|
||||
// order safe too.
|
||||
if (app.prefs.browse != nullptr) {
|
||||
g_signal_emit_by_name(app.prefs.browse, "clicked");
|
||||
g_timeout_add(600, +[](gpointer u) -> gboolean {
|
||||
App& app = *static_cast<App*>(u);
|
||||
GListModel* tops = gtk_window_get_toplevels();
|
||||
const guint n = g_list_model_get_n_items(tops);
|
||||
guint extra = 0;
|
||||
for (guint i = 0; i < n; ++i) {
|
||||
GtkWidget* w = GTK_WIDGET(g_list_model_get_item(tops, i));
|
||||
if (w != app.window && w != app.prefs.window) {
|
||||
++extra;
|
||||
gtk_window_close(GTK_WINDOW(w));
|
||||
}
|
||||
g_object_unref(w);
|
||||
}
|
||||
// The button is an icon and nothing else, so "the theme has this icon" is the difference
|
||||
// between a control and a blank square: a name the icon theme does not know draws a
|
||||
// missing-image placeholder and says nothing anywhere else.
|
||||
std::fprintf(stderr, "[prefs-probe] browse: toplevels=%u extra=%u icon_ok=%d field='%s'\n", n,
|
||||
extra,
|
||||
app.prefs.browse != nullptr &&
|
||||
gtk_icon_theme_has_icon(
|
||||
gtk_icon_theme_get_for_display(
|
||||
gtk_widget_get_display(app.prefs.browse)),
|
||||
"folder-open-symbolic")
|
||||
? 1
|
||||
: 0,
|
||||
app.prefs.dir_entry != nullptr
|
||||
? gtk_editable_get_text(GTK_EDITABLE(app.prefs.dir_entry))
|
||||
: "-");
|
||||
g_timeout_add(600, +[](gpointer u2) -> gboolean {
|
||||
prefs_close(static_cast<App*>(u2)->prefs);
|
||||
return G_SOURCE_REMOVE;
|
||||
}, &app);
|
||||
return G_SOURCE_REMOVE;
|
||||
}, &app);
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
prefs_close(app.prefs);
|
||||
return G_SOURCE_REMOVE;
|
||||
}, &a);
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
|
||||
void layout_probe_next(App& a, int step, guint delay_ms) {
|
||||
a.dbg_layout_step = step;
|
||||
g_timeout_add(delay_ms, layout_probe_step, &a);
|
||||
@@ -2670,6 +2848,12 @@ gboolean events_probe_step(gpointer user) {
|
||||
// history button (which is only there once the first two have happened -- that is the item's rule,
|
||||
// and this is where it is asserted).
|
||||
gtk_switch_set_active(GTK_SWITCH(a->events_record_switch), TRUE);
|
||||
// An 80% pre-trigger share, so the event that latches is *not* symmetric: the window a click
|
||||
// opens must be the event's own span, and with a symmetric event the live window would look
|
||||
// identical and say nothing.
|
||||
if (a->trigger_pre_spin != nullptr) {
|
||||
gtk_spin_button_set_value(GTK_SPIN_BUTTON(a->trigger_pre_spin), 80.0);
|
||||
}
|
||||
report_events_availability(*a, "recording on, no trigger");
|
||||
if (a->trigger_toggle != nullptr) {
|
||||
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a->trigger_toggle), TRUE);
|
||||
@@ -2749,7 +2933,7 @@ gboolean events_probe_step(gpointer user) {
|
||||
gtk_picture_get_paintable(GTK_PICTURE(a->events_thumb)) != nullptr;
|
||||
std::fprintf(stderr,
|
||||
"[events] activated row0: replaying=%d pane=%d pane_w=%d pane_box=%d tick=%d "
|
||||
"window=%.3f "
|
||||
"window=%.3f axis=[%.3f,%.3f] marker=%.3f "
|
||||
"thumbnail=%d thumb_size=%dx%d thumb_visible=%d\n",
|
||||
a->replaying ? 1 : 0,
|
||||
a->events_revealer != nullptr && gtk_widget_get_visible(a->events_revealer) ? 1 : 0,
|
||||
@@ -2760,7 +2944,19 @@ gboolean events_probe_step(gpointer user) {
|
||||
a->events_revealer != nullptr && gtk_revealer_get_child(GTK_REVEALER(a->events_revealer)) != nullptr
|
||||
? gtk_widget_get_width(gtk_revealer_get_child(GTK_REVEALER(a->events_revealer)))
|
||||
: -1,
|
||||
signal_tick_visible(*a), a->rows->time_window(), picture ? 1 : 0,
|
||||
signal_tick_visible(*a), a->rows->time_window(),
|
||||
a->rows->row(0).plot != nullptr
|
||||
? a->rows->row(0).plot->x_range(a->rows->row(0).axis_x).min
|
||||
: 0.0,
|
||||
a->rows->row(0).plot != nullptr
|
||||
? a->rows->row(0).plot->x_range(a->rows->row(0).axis_x).max
|
||||
: 0.0,
|
||||
// The trigger line's own cursor: 0 is the trigger instant and a large negative is the
|
||||
// park. A file that opens without its trigger line reads the parked value here.
|
||||
a->rows->row(0).plot != nullptr
|
||||
? a->rows->row(0).plot->cursor(a->rows->row(0).trigger_marker).x
|
||||
: 0.0,
|
||||
picture ? 1 : 0,
|
||||
a->events_thumb != nullptr ? gtk_widget_get_width(a->events_thumb) : -1,
|
||||
a->events_thumb != nullptr ? gtk_widget_get_height(a->events_thumb) : -1,
|
||||
a->events_thumb != nullptr && gtk_widget_get_mapped(a->events_thumb) ? 1 : 0);
|
||||
@@ -2875,6 +3071,17 @@ gboolean events_group_probe_step(gpointer user) {
|
||||
const plot::Range bx = a->rows_b->row(0).plot->x_range(a->rows_b->row(0).axis_x);
|
||||
std::fprintf(stderr, " list=%d x_a=[%.3f,%.3f] x_b=[%.3f,%.3f] same_x=%d", entries, ax.min,
|
||||
ax.max, bx.min, bx.max, (ax.min == bx.min && ax.max == bx.max) ? 1 : 0);
|
||||
// Which *channel* each plot's row 0 draws: the comparison is only a comparison if the second
|
||||
// plot's slots resolved to the other event's channels (and not to the first event's).
|
||||
std::fprintf(stderr, " channels_a=[");
|
||||
for (int s = 0; s < RowSet::kSlotsPerRow; ++s) {
|
||||
std::fprintf(stderr, "%d%s", a->rows->channel_of(0, s), s + 1 < RowSet::kSlotsPerRow ? "," : "");
|
||||
}
|
||||
std::fprintf(stderr, "] channels_b=[");
|
||||
for (int s = 0; s < RowSet::kSlotsPerRow; ++s) {
|
||||
std::fprintf(stderr, "%d%s", a->rows_b->channel_of(0, s), s + 1 < RowSet::kSlotsPerRow ? "," : "");
|
||||
}
|
||||
std::fprintf(stderr, "]");
|
||||
// A y range nobody would arrive at by accident, set on the first plot only.
|
||||
a->rows->row(0).plot->set_y_range(a->rows->row(0).axis_y, plot::Range{-3.5, 3.5});
|
||||
}
|
||||
@@ -2897,10 +3104,76 @@ gboolean events_group_probe_step(gpointer user) {
|
||||
}
|
||||
std::fprintf(stderr, "[events] compare sync: y_a=%.3f y_b=%.3f mirrored=%d\n", y_a, y_b,
|
||||
(y_a == y_b && y_b == -3.5) ? 1 : 0);
|
||||
events_group_probe_next(*a, 57, 400);
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
|
||||
if (a->dbg_events_step == 57) {
|
||||
// The drop, in a *file* view: the signal is not on any plot yet, so `on_drop_channel()` takes the
|
||||
// path a drag takes -- and what it must not do is rebuild the model, which in this mode has no
|
||||
// producer to refill it.
|
||||
const std::size_t before = a->model->channels().size();
|
||||
std::uint64_t samples_before = 0;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(a->model->mutex());
|
||||
if (before > 0) samples_before = a->model->live().values(0).produced();
|
||||
}
|
||||
GValue value = G_VALUE_INIT;
|
||||
g_value_init(&value, G_TYPE_STRING);
|
||||
g_value_set_string(&value, "1"); // the second signal: on no row in the default layout
|
||||
const gboolean ok = on_drop_channel(nullptr, &value, 40.0, 20.0, a);
|
||||
g_value_unset(&value);
|
||||
std::uint64_t samples_after = 0;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(a->model->mutex());
|
||||
if (before > 0) samples_after = a->model->live().values(0).produced();
|
||||
}
|
||||
std::fprintf(stderr,
|
||||
"[events] drop in a file view: ok=%d channels=%zu slot0=['%s'] slot1=['%s'] "
|
||||
"samples=%llu->%llu\n",
|
||||
ok ? 1 : 0, a->model->channels().size(),
|
||||
a->rows->row(0).signal_name[0].c_str(), a->rows->row(0).signal_name[1].c_str(),
|
||||
static_cast<unsigned long long>(samples_before),
|
||||
static_cast<unsigned long long>(samples_after));
|
||||
// One step later, after the figure has been rebuilt and the area re-pointed: what the row now
|
||||
// *draws* -- the slot's channel and the traces the plot reports as visible.
|
||||
events_group_probe_next(*a, 58, 600);
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
|
||||
if (a->dbg_events_step == 58) {
|
||||
std::fprintf(stderr, "[events] drop result: slot1_channel=%d visible_traces=%d\n",
|
||||
a->rows->channel_of(0, 1), group_visible_traces(*a, 0));
|
||||
events_group_probe_next(*a, 5, 400);
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
|
||||
if (a->dbg_events_step == 59) {
|
||||
// A comparison *after* an accumulation: the accumulation left layers in slots a comparison does not
|
||||
// use, and those used to stay -- extra legend entries, in colours belonging to nothing.
|
||||
if (a->events_compare != nullptr) g_signal_emit_by_name(a->events_compare, "clicked");
|
||||
events_group_probe_next(*a, 60, 700);
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
|
||||
if (a->dbg_events_step == 60) {
|
||||
const RowSet::Row& r0 = a->rows->row(0);
|
||||
std::fprintf(stderr, "[events] compare after accumulate: kind=%d traces0=%d legend0=%zu slots=[",
|
||||
static_cast<int>(a->group_kind), group_visible_traces(*a, 0),
|
||||
r0.plot != nullptr ? r0.plot->layout().legend.rows.size() : 0UL);
|
||||
for (int s = 0; s < RowSet::kSlotsPerRow; ++s) {
|
||||
std::fprintf(stderr, "%s'%s'", s ? "," : "",
|
||||
a->rows->row(0).signal_name[static_cast<std::size_t>(s)].c_str());
|
||||
}
|
||||
std::fprintf(stderr, "]\n");
|
||||
std::fflush(stderr);
|
||||
go_live_again(*a);
|
||||
std::fprintf(stderr, "[events] group left: replaying=%d kind=%d waveform=%d\n", a->replaying ? 1 : 0,
|
||||
static_cast<int>(a->group_kind), a->waveform_name.empty() ? 0 : 1);
|
||||
std::fflush(stderr);
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
|
||||
if (a->dbg_events_step == 5) {
|
||||
if (a->events_accumulate != nullptr) g_signal_emit_by_name(a->events_accumulate, "clicked");
|
||||
events_group_probe_next(*a, 55, 700);
|
||||
@@ -2918,12 +3191,7 @@ gboolean events_group_probe_step(gpointer user) {
|
||||
static_cast<unsigned long long>(a->model->live().values(0).produced()),
|
||||
selected_events(*a).size(), group_visible_traces(*a, 0), a->rows->time_window());
|
||||
}
|
||||
// A group is a file view, so the way out of it is the way out of a replay.
|
||||
go_live_again(*a);
|
||||
std::fprintf(stderr, "[events] group left: replaying=%d kind=%d waveform=%d\n",
|
||||
a->replaying ? 1 : 0, static_cast<int>(a->group_kind),
|
||||
a->waveform_name.empty() ? 0 : 1);
|
||||
std::fflush(stderr);
|
||||
events_group_probe_next(*a, 59, 400);
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
return G_SOURCE_REMOVE;
|
||||
@@ -3080,6 +3348,114 @@ gboolean keys_probe(gpointer user) {
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
|
||||
|
||||
/// What the shortcut overview's UI file *declares*, read out of the resource rather than off the
|
||||
/// built widgets: `GtkShortcutsWindow` reaches each row from more than one place in its own tree, so a
|
||||
/// walk counts every row twice (52 for 26), and what a reader counts on the screen is the declaration.
|
||||
///
|
||||
/// The two numbers that matter are `with_accel`, the rows drawn as keycaps, and `with_icon`, the ones
|
||||
/// drawn as an icon. `bare` is a row with *neither*, which is what a cell reading "No Shortcut" is --
|
||||
/// the reason this overview is a GTK window built from a UI file rather than libadwaita's dialog, whose
|
||||
/// items have no icon property at all. It must be zero.
|
||||
struct UiShortcuts {
|
||||
int rows = -1;
|
||||
int with_accel = 0;
|
||||
int with_icon = 0;
|
||||
int bare = 0;
|
||||
// Per-row state, and the depth that tells a row's own `</object>` from the one closing the
|
||||
// `GThemedIcon` nested inside its icon property.
|
||||
int depth = 0;
|
||||
int row_depth = -1;
|
||||
bool row_accel = false;
|
||||
bool row_icon = false;
|
||||
bool in_property = false;
|
||||
std::string property;
|
||||
};
|
||||
|
||||
const char* attribute(const gchar** names, const gchar** values, const char* want) {
|
||||
for (int i = 0; names[i] != nullptr; ++i) {
|
||||
if (g_strcmp0(names[i], want) == 0) return values[i];
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
void ui_row_start(GMarkupParseContext*, const gchar* name, const gchar** names, const gchar** values,
|
||||
gpointer user, GError**) {
|
||||
UiShortcuts& out = *static_cast<UiShortcuts*>(user);
|
||||
if (g_strcmp0(name, "object") == 0) {
|
||||
++out.depth;
|
||||
if (g_strcmp0(attribute(names, values, "class"), "GtkShortcutsShortcut") == 0) {
|
||||
out.row_depth = out.depth;
|
||||
out.row_accel = false;
|
||||
out.row_icon = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (out.row_depth < 0 || g_strcmp0(name, "property") != 0) return;
|
||||
const char* prop = attribute(names, values, "name");
|
||||
out.property = prop != nullptr ? prop : "";
|
||||
// Only an accelerator is carried as text; an icon's value is a nested `GThemedIcon` object, so its
|
||||
// *declaration* is the whole of what there is to see and it is marked here rather than in the text
|
||||
// callback that would never fire for it.
|
||||
out.in_property = out.property == "accelerator";
|
||||
if (out.property == "icon") out.row_icon = true;
|
||||
}
|
||||
|
||||
/// The text callbacks carry the property's *value*; an empty `accelerator` is not a keycap, so only
|
||||
/// non-empty text counts.
|
||||
void ui_row_text(GMarkupParseContext*, const gchar* text, gsize len, gpointer user, GError**) {
|
||||
(void)text; // the length is what the callback is asked for; the bytes are not
|
||||
UiShortcuts& out = *static_cast<UiShortcuts*>(user);
|
||||
if (!out.in_property || len == 0) return;
|
||||
if (out.property == "accelerator") out.row_accel = true;
|
||||
}
|
||||
|
||||
void ui_row_end(GMarkupParseContext*, const gchar* name, gpointer user, GError**) {
|
||||
UiShortcuts& out = *static_cast<UiShortcuts*>(user);
|
||||
if (g_strcmp0(name, "property") == 0) {
|
||||
out.in_property = false;
|
||||
return;
|
||||
}
|
||||
if (g_strcmp0(name, "object") != 0) return;
|
||||
if (out.depth == out.row_depth) {
|
||||
out.row_depth = -1;
|
||||
++out.rows;
|
||||
if (out.row_accel) {
|
||||
++out.with_accel;
|
||||
} else if (out.row_icon) {
|
||||
++out.with_icon;
|
||||
} else {
|
||||
++out.bare;
|
||||
}
|
||||
}
|
||||
--out.depth;
|
||||
}
|
||||
|
||||
UiShortcuts ui_shortcut_rows(const char* path) {
|
||||
UiShortcuts out;
|
||||
GError* error = nullptr;
|
||||
GBytes* bytes = g_resources_lookup_data(path, G_RESOURCE_LOOKUP_FLAGS_NONE, &error);
|
||||
if (bytes == nullptr) {
|
||||
if (error != nullptr) g_error_free(error);
|
||||
return out;
|
||||
}
|
||||
gsize len = 0;
|
||||
const char* data = static_cast<const char*>(g_bytes_get_data(bytes, &len));
|
||||
GMarkupParser parser{&ui_row_start, &ui_row_end, &ui_row_text, nullptr, nullptr};
|
||||
if (GMarkupParseContext* ctx = g_markup_parse_context_new(&parser, G_MARKUP_DEFAULT_FLAGS, &out,
|
||||
nullptr);
|
||||
ctx != nullptr) {
|
||||
out.rows = 0;
|
||||
if (!g_markup_parse_context_parse(ctx, data, static_cast<gssize>(len), &error) && error != nullptr) {
|
||||
g_error_free(error);
|
||||
out.rows = 0; // a file this probe cannot parse is not a file with no rows
|
||||
}
|
||||
g_markup_parse_context_free(ctx);
|
||||
}
|
||||
g_bytes_unref(bytes);
|
||||
return out;
|
||||
}
|
||||
|
||||
gboolean help_probe(gpointer user) {
|
||||
App* a = static_cast<App*>(user);
|
||||
const HelpReport report = help_self_check();
|
||||
@@ -3098,11 +3474,17 @@ gboolean help_probe(gpointer user) {
|
||||
int gesture_count = 0;
|
||||
(void)app_accels(&accel_count);
|
||||
(void)plot_gestures(&gesture_count);
|
||||
// The tables are what the application handles, the UI file is what it shows. They are compared here
|
||||
// because they are two lists of the same thing: a row in one and not the other is a gesture that
|
||||
// works and is not documented, or is documented and does nothing.
|
||||
const UiShortcuts ui = ui_shortcut_rows("/org/udpscope/ui/shortcuts.ui");
|
||||
std::fprintf(stderr,
|
||||
"[help] pages=%d parsed=%d shortest='%s' %zu B; shortcuts: app=%d plot=%d; "
|
||||
"[help] pages=%d parsed=%d shortest='%s' %zu B; shortcuts: app=%d plot=%d tables=%d "
|
||||
"ui=%d ui_accel=%d ui_icon=%d ui_bare=%d; "
|
||||
"about version=%s commit=%s built=%s settings=%s icon=%s; closed ok\n",
|
||||
report.pages, report.parsed, report.shortest_title.c_str(),
|
||||
report.shortest_bytes, accel_count, gesture_count, build_info::kVersion,
|
||||
report.shortest_bytes, accel_count, gesture_count, accel_count + gesture_count,
|
||||
ui.rows, ui.with_accel, ui.with_icon, ui.bare, build_info::kVersion,
|
||||
build_info::kCommit, build_info::kTimestamp, config_dir().c_str(),
|
||||
about_icon.c_str());
|
||||
std::fflush(stderr);
|
||||
|
||||
@@ -118,6 +118,21 @@ GtkWidget* menu_separator() {
|
||||
return gtk_separator_new(GTK_ORIENTATION_HORIZONTAL);
|
||||
}
|
||||
|
||||
/// A labelled row whose control keeps its *natural* width. A `GtkSwitch`'s natural width is its size,
|
||||
/// so `labeled_box()`'s expanding child stretches it into a wide pill that reads as a slider -- this is
|
||||
/// the row a switch wants, with the label taking the slack instead.
|
||||
GtkWidget* labeled_switch_row(const char* label, GtkWidget* control) {
|
||||
GtkWidget* row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
|
||||
GtkWidget* text = gtk_label_new(label);
|
||||
gtk_widget_set_halign(text, GTK_ALIGN_START);
|
||||
gtk_widget_set_hexpand(text, TRUE);
|
||||
gtk_box_append(GTK_BOX(row), text);
|
||||
gtk_widget_set_halign(control, GTK_ALIGN_END);
|
||||
gtk_widget_set_valign(control, GTK_ALIGN_CENTER);
|
||||
gtk_box_append(GTK_BOX(row), control);
|
||||
return row;
|
||||
}
|
||||
|
||||
GtkWidget* labeled_box(const char* label, GtkWidget* child) {
|
||||
GtkWidget* box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
|
||||
GtkWidget* l = gtk_label_new(label);
|
||||
|
||||
Reference in new issue
Block a user