A batch of view and instrument work, with the probes and docs for each. - **Full screen per figure row**: a toggle on each row's top-right corner, revealed by pointer proximity (`kMaximizeRevealPx` around its placed rect, per row, read from an overlay-level motion controller so a revealed button under the pointer cannot flicker); the plot menu offers the same as *Full screen this figure* / *Restore the layout*. The arrangement is saved and restored through `RowState`, so nothing plotted moves. A layout of one figure carries no toggle. - **Second window**: `open_scope_window()` copies a figure (cells, width shares, signals in their colours) or opens an empty one, with its own `RowSet` and `PlotArea`; the main window's tick binds, acquires and repaints it, mirroring the time window and pin. Entries: the plot menu's *Open this plot in a new window* and the overflow menu's *New scope window*. - **Layout editor from the plot**: the plot menu gains *Edit the layout...* / *Done editing the layout*, flipping the app's one toggle. - **Measurements gate**: `MeasureOptions::gate` restricts a measurement to the held/pause snapshot (live falls back to the live ring), persisted with the session and shown in the measure dialog. - **Crosshair axis badges**: a preference (`crosshair_axis_labels`, on by default) and the plotter's badges where the lines meet the axes, in the axes' own formats; the plot menu and the probe read them. - **Event cache per configuration**: `EventStore` is keyed `<source>/<configuration>/`, `retarget_event_store()` follows every configuration switch, and a pre-configuration cache (loose files) is adopted into the configuration's folder on first start. - **GTK4 lifecycle**: the main window's handlers move from the non-existent `"destroy"` signal to `close-request`; scope windows are closed with it and the application quits explicitly (the exit run's hang). The exit probe closes with `gtk_window_close()`. - Probes: `[maximize-probe]` (single-figure rule and the corner reveal), `[scope-probe]`, `[split-probe]` cell crosshair leg, `[prefs-probe]` store keying, `[configs]` store key, `[rowmenu]` layout-editor entry; docs, help pages, README and AGENT.md updated. Also fixes a probe-only use-after-free: `toggle_maximize_figure_row` replaces the figure object, so the area must be re-pointed (the button's path does; the probe's raw call did not).
298 lines
14 KiB
C++
298 lines
14 KiB
C++
// SPDX-License-Identifier: MIT
|
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
|
|
|
#include "ui_prefs.hpp"
|
|
|
|
#include "event_store.hpp" // `default_events_dir()`, so the row shows the path the cache uses
|
|
#include "status_text.hpp" // `format_bytes()`
|
|
#include "ui_app.hpp" // the application's half of the contract, at the bottom of this file
|
|
|
|
#include <adwaita.h>
|
|
|
|
#include <cstdio>
|
|
#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;
|
|
}
|
|
|
|
const char* theme_name(guint selected) {
|
|
if (selected == 1) return "light";
|
|
if (selected == 2) return "dark";
|
|
return "auto";
|
|
}
|
|
|
|
/// What the rows hold. It starts from the file so that a field this dialog does not show is carried
|
|
/// through untouched rather than being reset by every keystroke.
|
|
Settings read_rows(const PrefsForm& f) {
|
|
Settings s = load_settings();
|
|
s.event_cache_dir = gtk_editable_get_text(GTK_EDITABLE(f.dir_row));
|
|
s.event_cache_max = static_cast<int>(adw_spin_row_get_value(f.keep_row));
|
|
s.relative_time = adw_switch_row_get_active(f.relative_row) != FALSE;
|
|
s.memory_limit_mb = static_cast<int>(adw_spin_row_get_value(f.memory_row));
|
|
s.theme = theme_name(adw_combo_row_get_selected(f.theme_row));
|
|
s.crosshair_axis_labels = adw_switch_row_get_active(f.crosshair_row) != FALSE;
|
|
// The row shows the *effective* directory, so the default is written out in full rather than
|
|
// described. Leaving it at that default is not a choice to pin it: the file keeps "unset", and the
|
|
// setting goes on following the cache directory it is named after.
|
|
if (s.event_cache_dir == default_events_dir()) s.event_cache_dir.clear();
|
|
return s;
|
|
}
|
|
|
|
void write_rows(PrefsForm& f) {
|
|
const Settings s = read_rows(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.
|
|
apply_theme(s.theme);
|
|
// The caller decides what a failure looks like: the app has a status bar and the source chooser does
|
|
// not. A *success* is deliberately not reported -- it happens once per keystroke in the directory.
|
|
if (f.on_changed) f.on_changed(s, written);
|
|
}
|
|
|
|
/// Fills the rows from `s`, writing nothing back.
|
|
void fill_rows(PrefsForm& f, const Settings& s) {
|
|
f.filling = true;
|
|
gtk_editable_set_text(
|
|
GTK_EDITABLE(f.dir_row),
|
|
(s.event_cache_dir.empty() ? default_events_dir() : s.event_cache_dir).c_str());
|
|
adw_spin_row_set_value(f.keep_row, s.event_cache_max < 0 ? 0.0 : s.event_cache_max);
|
|
adw_switch_row_set_active(f.relative_row, s.relative_time ? TRUE : FALSE);
|
|
adw_spin_row_set_value(f.memory_row, s.memory_limit_mb);
|
|
adw_combo_row_set_selected(f.theme_row, s.theme == "light" ? 1 : (s.theme == "dark" ? 2 : 0));
|
|
adw_switch_row_set_active(f.crosshair_row, s.crosshair_axis_labels ? TRUE : FALSE);
|
|
f.filling = false;
|
|
}
|
|
|
|
/// `AdwEntryRow` is a `GtkEditable`, so its change signal is that interface's.
|
|
void on_entry_changed(GtkEditable*, gpointer user) {
|
|
PrefsForm& f = *static_cast<PrefsForm*>(user);
|
|
if (!f.filling) write_rows(f);
|
|
}
|
|
|
|
/// One handler for the three rows that carry their value in a property: `adw_spin_row_set_value()`,
|
|
/// `adw_switch_row_set_active()` and `adw_combo_row_set_selected()` all say it with `notify`.
|
|
void on_row_notify(GObject*, GParamSpec*, gpointer user) {
|
|
PrefsForm& f = *static_cast<PrefsForm*>(user);
|
|
if (!f.filling) write_rows(f);
|
|
}
|
|
|
|
void on_reset(GtkWidget*, gpointer user) {
|
|
PrefsForm& f = *static_cast<PrefsForm*>(user);
|
|
fill_rows(f, default_settings());
|
|
// `fill_rows()` writes nothing on purpose, so the reset says so itself.
|
|
write_rows(f);
|
|
}
|
|
|
|
/// Cancelling must change *nothing*: the row keeps what it had, and nothing is written. The null check
|
|
/// is not defensive padding -- closing clears every pointer, and a folder chooser still open when the
|
|
/// dialog closes is exactly how a callback lands on a dead row.
|
|
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;
|
|
// Through the row, so the change applies like any other -- one value, one path to the file.
|
|
if (f.dir_row != nullptr) gtk_editable_set_text(GTK_EDITABLE(f.dir_row), 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);
|
|
// One chooser at a time, and the previous one is cancelled rather than abandoned: two open pickers
|
|
// would each write the row when they answered.
|
|
g_cancellable_cancel(f.browse_cancel);
|
|
g_clear_object(&f.browse_cancel);
|
|
f.browse_cancel = g_cancellable_new();
|
|
GtkFileDialog* dialog = gtk_file_dialog_new();
|
|
gtk_file_dialog_set_title(dialog, "Event cache directory");
|
|
const char* current =
|
|
f.dir_row != nullptr ? gtk_editable_get_text(GTK_EDITABLE(f.dir_row)) : "";
|
|
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.parent, f.browse_cancel, on_browse_done, &f);
|
|
g_object_unref(dialog); // the call holds its own reference
|
|
}
|
|
|
|
/// Everything goes: a row read after the dialog closed would be a dangling pointer, and the probes read
|
|
/// the rows while it is open and the file after it is not.
|
|
///
|
|
/// The *builder* is left for the next `prefs_open()` to drop. Freeing it here would finalise the dialog
|
|
/// while GTK is still unwinding the close that is emitting this signal, which is the use-after-free
|
|
/// this project has paid for once already.
|
|
void on_closed(AdwDialog*, gpointer user) {
|
|
PrefsForm& f = *static_cast<PrefsForm*>(user);
|
|
// A picker still open answers after this, and its answer writes a row: cancel it here, while the
|
|
// rows are still there to be checked against null.
|
|
g_cancellable_cancel(f.browse_cancel);
|
|
g_clear_object(&f.browse_cancel);
|
|
f.dialog = nullptr;
|
|
f.dir_row = nullptr;
|
|
f.keep_row = nullptr;
|
|
f.relative_row = nullptr;
|
|
f.memory_row = nullptr;
|
|
f.theme_row = nullptr;
|
|
f.crosshair_row = nullptr;
|
|
f.browse = nullptr;
|
|
f.parent = nullptr;
|
|
}
|
|
|
|
/// The rows a dialog built from `prefs.ui` declares resolved, or cleared if the file is missing one.
|
|
///
|
|
/// The file is checked by the build (`xml-stripblanks`) and by `GtkBuilder` at load; a missing *id* is
|
|
/// the one mistake neither sees, and it would be a null row read on the first change.
|
|
void resolve_rows(PrefsForm& f) {
|
|
f.dir_row = ADW_ENTRY_ROW(gtk_builder_get_object(f.builder, "dir_row"));
|
|
f.keep_row = ADW_SPIN_ROW(gtk_builder_get_object(f.builder, "keep_row"));
|
|
f.relative_row = ADW_SWITCH_ROW(gtk_builder_get_object(f.builder, "relative_row"));
|
|
f.theme_row = ADW_COMBO_ROW(gtk_builder_get_object(f.builder, "theme_row"));
|
|
f.memory_row = ADW_SPIN_ROW(gtk_builder_get_object(f.builder, "memory_row"));
|
|
f.crosshair_row = ADW_SWITCH_ROW(gtk_builder_get_object(f.builder, "crosshair_axis_labels_row"));
|
|
if (f.dir_row == nullptr || f.keep_row == nullptr || f.relative_row == nullptr ||
|
|
f.theme_row == nullptr || f.memory_row == nullptr || f.crosshair_row == nullptr) {
|
|
f.dir_row = nullptr;
|
|
f.keep_row = nullptr;
|
|
f.relative_row = nullptr;
|
|
f.theme_row = nullptr;
|
|
f.memory_row = nullptr;
|
|
f.crosshair_row = nullptr;
|
|
g_clear_object(&f.builder);
|
|
}
|
|
}
|
|
|
|
} // namespace
|
|
|
|
void prefs_open(PrefsForm& form, GtkWidget* parent, const Settings& current,
|
|
std::function<void(const Settings&, bool written)> on_changed) {
|
|
if (form.dialog != nullptr) {
|
|
adw_dialog_present(form.dialog, parent);
|
|
return;
|
|
}
|
|
form.on_changed = std::move(on_changed);
|
|
form.parent = GTK_IS_WINDOW(parent) ? GTK_WINDOW(parent) : nullptr;
|
|
|
|
// A builder per opening: the dialog belongs to its object list and `closed` destroys the dialog, so
|
|
// the resource is parsed again rather than an already-dead dialog fetched. This is where the previous
|
|
// builder goes, which is safe because that dialog has been closed for as long as it takes a user to
|
|
// open the menu again.
|
|
g_clear_object(&form.builder);
|
|
form.builder = gtk_builder_new_from_resource("/org/udpscope/ui/preferences.ui");
|
|
if (form.builder == nullptr) return;
|
|
GObject* dialog = gtk_builder_get_object(form.builder, "preferences");
|
|
resolve_rows(form);
|
|
if (dialog == nullptr || form.dir_row == nullptr) return;
|
|
form.dialog = ADW_DIALOG(dialog);
|
|
|
|
// Guarded while the widgets are dressed: setting the theme list moves the combo's selection, and
|
|
// filling a row fires its own signal -- either would write a settings file the user has not touched.
|
|
form.filling = true;
|
|
|
|
// The theme list in code: a `GtkStringList` from a strv is one line, and saying the same thing in a
|
|
// `.ui` needs an `<items>` model with three entries.
|
|
const char* themes[] = {"auto", "light", "dark", nullptr};
|
|
adw_combo_row_set_model(form.theme_row, G_LIST_MODEL(gtk_string_list_new(themes)));
|
|
|
|
// The folder chooser, as a suffix of the directory row.
|
|
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 goes into "
|
|
"the row 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);
|
|
adw_entry_row_add_suffix(form.dir_row, form.browse);
|
|
|
|
// Every row applies as it changes. Each row says so with the property it carries; the entry says it
|
|
// with `GtkEditable::changed`, which is the interface it implements.
|
|
g_signal_connect(form.dir_row, "changed", G_CALLBACK(on_entry_changed), &form);
|
|
g_signal_connect(form.keep_row, "notify::value", G_CALLBACK(on_row_notify), &form);
|
|
g_signal_connect(form.relative_row, "notify::active", G_CALLBACK(on_row_notify), &form);
|
|
g_signal_connect(form.memory_row, "notify::value", G_CALLBACK(on_row_notify), &form);
|
|
g_signal_connect(form.theme_row, "notify::selected", G_CALLBACK(on_row_notify), &form);
|
|
g_signal_connect(form.crosshair_row, "notify::active", G_CALLBACK(on_row_notify), &form);
|
|
if (GObject* reset = gtk_builder_get_object(form.builder, "reset_row"); reset != nullptr) {
|
|
g_signal_connect(reset, "activated", G_CALLBACK(on_reset), &form);
|
|
}
|
|
g_signal_connect(form.dialog, "closed", G_CALLBACK(on_closed), &form);
|
|
|
|
fill_rows(form, current);
|
|
adw_dialog_present(form.dialog, parent);
|
|
}
|
|
|
|
void prefs_close(PrefsForm& form) {
|
|
// `closed` clears the pointers, so this is the only place that has to know how a dialog closes.
|
|
if (form.dialog != nullptr) adw_dialog_close(form.dialog);
|
|
}
|
|
|
|
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_change(PrefsForm& form) {
|
|
if (form.dialog == nullptr) return;
|
|
gtk_editable_set_text(GTK_EDITABLE(form.dir_row), "/tmp/udpscope-prefs-events");
|
|
adw_spin_row_set_value(form.keep_row, 7.0);
|
|
adw_switch_row_set_active(form.relative_row, TRUE);
|
|
adw_spin_row_set_value(form.memory_row, 4096.0);
|
|
adw_combo_row_set_selected(form.theme_row, 2);
|
|
adw_switch_row_set_active(form.crosshair_row, FALSE);
|
|
}
|
|
|
|
/// A changed memory limit reaches the running model. Only when it differs from the model's own (every
|
|
/// preference arrives through the same callback, and a theme change is not a re-plan), and without
|
|
/// re-planning while a file or a replayed event is what the buffer holds -- clearing those would
|
|
/// throw away what the user opened, so the limit is stored and the next plan honours it.
|
|
static void apply_memory_limit(App& app, int mb) {
|
|
if (app.model == nullptr || mb <= 0) return;
|
|
const std::uint64_t bytes = static_cast<std::uint64_t>(mb) * 1024u * 1024u;
|
|
if (bytes == app.model->memory_limit_bytes()) return;
|
|
const bool holds_a_view = !app.waveform_name.empty() || app.replaying;
|
|
const bool rebuilt = app.model->set_memory_limit_bytes(bytes, !holds_a_view);
|
|
std::fprintf(stderr, "[udpscope] memory limit now %s (rebuilt=%d%s)\n",
|
|
format_bytes(bytes).c_str(), rebuilt ? 1 : 0,
|
|
holds_a_view ? ", applies to the next plan" : "");
|
|
std::fflush(stderr);
|
|
if (rebuilt) {
|
|
set_notice(app, "memory limit " + format_bytes(bytes) + ": buffer re-planned, history cleared", 5);
|
|
}
|
|
}
|
|
|
|
/// The application's entry point: **Preferences…** in the overflow menu. Kept here rather than in
|
|
/// `ui.cpp` with the rest of the chrome, because it is the application's half of *this* form's
|
|
/// contract -- what a preference changing means to a running scope -- and reading it beside 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;
|
|
apply_memory_limit(app, s.memory_limit_mb);
|
|
// The crosshair's axis badges, at once like the theme: it is a drawing flag on the
|
|
// plots, and a change is the next frame's drawing.
|
|
app.rows->set_crosshair_axis_labels(s.crosshair_axis_labels);
|
|
// Only a failure is announced. A change arrives once per keystroke in the directory row, and a
|
|
// status line that flashed on each one would be the app shouting about its own bookkeeping.
|
|
if (!written) set_notice(app, "could not write the preferences file", 4);
|
|
});
|
|
}
|
|
|
|
} // namespace ui_impl
|
|
} // namespace udpscope
|