Files
udpscope/src/source_dialog.cpp
T
Martino FerrariandClaude Opus 4.6 034c82f7d7 Budget the sample buffer in samples per signal, under a hard memory limit
--buffer-samples (k/M/G) replaces --buffer-mb: the request is rounded down to a
power of two for every signal and the shared time ring. A separate memory limit
(--memory-limit-mb, a Preferences row, default 8 GiB) halves the plan until the
real ring bytes fit and says so on stderr and in the status line. The per-source
key buffer_mb becomes buffer_samples; an old buffer_mb is ignored.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-11-03 02:47:04 +01:00

1530 lines
79 KiB
C++

// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#include "source_dialog.hpp"
#include "waveform.hpp"
#include "help_window.hpp"
#include "layout_store.hpp"
#include "model.hpp" // `buffer_ticks()` / `planned_bytes()`: what a request becomes
#include "settings.hpp"
#include "status_text.hpp" // `format_samples()` and friends
#include "source/registry.hpp"
// The app's shared chrome -- `ui_impl::menu_button()`/`menu_toggle()` and `install_css()` -- so the
// chooser's help menu is the *same* menu the window's Help view is, not a second one that merely
// looks like it. Heavy header, but a row shape copied here would be a row shape that drifts.
#include "ui_app.hpp"
#include "ui_prefs.hpp"
#include <adwaita.h>
#include <gtk/gtk.h>
#include <unistd.h>
#include <algorithm>
#include <cmath>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <string>
#include <vector>
namespace udpscope {
namespace {
/// A chooser row's source becomes the connection to open. The interface comes along with the
/// group: it is the local address the join happens on, so storing one without the other would
/// reconnect to the right group on the wrong path.
void apply_source(Config& cfg, const KnownSource& src) {
cfg.source_kind = src.kind;
if (src.kind == "udps") {
cfg.server = src.host;
cfg.port = src.port;
cfg.multicast = src.multicast;
cfg.interface_addr = src.interface_addr;
cfg.source_opts = SourceConfig();
} else {
cfg.source_opts = src.opts;
}
// The configuration's own startup settings. They are read *before* the model exists, which is the
// 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_samples > 0) cfg.buffer_samples = src.buffer_samples;
// 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;
}
/// One line per known source on stderr, in the style of the probes in `ui.cpp`.
void report_sources(const std::vector<KnownSource>& known) {
std::fprintf(stderr, "[sources] %zu known:", known.size());
for (const KnownSource& s : known) {
std::fprintf(stderr, " %s:%u/%s plots=%d signals=%d samples=%llu relative=%d capture=%u",
s.host.c_str(), s.port, s.config.c_str(), s.rows, s.signals,
static_cast<unsigned long long>(s.buffer_samples),
s.relative_time ? 1 : 0, s.capture_packets);
if (!s.comment.empty()) std::fprintf(stderr, " note='%s'", s.comment.c_str());
if (!s.multicast.empty()) std::fprintf(stderr, " multicast=%s", s.multicast.c_str());
if (!s.interface_addr.empty()) {
std::fprintf(stderr, " interface=%s", s.interface_addr.c_str());
}
std::fprintf(stderr, " |");
}
std::fprintf(stderr, "\n");
std::fflush(stderr);
}
/// The chooser's own state for the life of its nested loop. A plain local in `choose_source()`:
/// the loop runs inside that call, so nothing here outlives the stack frame and there is nothing
/// to free on a handler path.
struct Chooser {
Config* cfg = nullptr;
GMainLoop* loop = nullptr;
bool chosen = false;
GtkWidget* window = nullptr;
GtkWidget* list = nullptr;
std::vector<KnownSource> known;
/// The window's frame is a `.ui` file (`src/ui/source-chooser.ui`): the window, its header bar, the two
/// pages and the buttons that are about the chooser rather than about a source. The builder is held
/// with the window for the same reason the form's is -- the window belongs to the builder's object list.
GtkBuilder* ui = nullptr;
/// The Waveforms page: the exports opened before, newest first, and the stack that holds the two pages.
/// A waveform is the *other* answer to "which source", so it gets a page of its own rather than a button
/// that hides a file dialog behind one more click. A `GtkNotebook` was the first shape of this and a tab
/// strip is the wrong one: an `AdwViewStack` under an `AdwViewSwitcherBar` is what current libadwaita
/// asks for, and the switcher's stack is wired in code because a property cannot name a widget declared
/// elsewhere in the same tree.
GtkWidget* page_stack = nullptr;
GtkWidget* switcher = nullptr;
/// The buttons the header bar holds, kept because two of them are read at runtime: `new_host` takes the
/// focus when the list is empty (there is nothing else to do), and all of them are read back by
/// `report_chooser_ui()` so a `.ui` that lost one is named rather than silently missing its button.
GtkWidget* prefs_button = nullptr;
GtkWidget* new_host = nullptr;
GtkWidget* waves_open = nullptr;
GtkWidget* cancel_button = nullptr;
GtkWidget* wave_list = nullptr;
std::vector<std::string> waves;
/// The connection form, created on demand. Members of the chooser rather than a heap struct:
/// the chooser outlives the form by construction, so there is nothing to free on a close path
/// -- and a dialog whose context is freed by a handler is how this project earned a use-after
/// -free in the math editor.
AdwDialog* form = nullptr;
/// The form is a `.ui` file (`src/ui/source-form.ui`), so its builder is held with it, and its rows
/// are typed: a switch is read with `adw_switch_row_get_active()` and not off a widget.
GtkBuilder* form_ui = nullptr;
AdwWindowTitle* form_title = nullptr;
/// The source-type dropdown and the group the chosen kind's rows live in. The rows themselves,
/// and the closures that read and write them, are the kind's (`ConfigForm`); the chooser only
/// swaps them when the dropdown moves.
AdwComboRow* form_kind_row = nullptr;
AdwPreferencesGroup* form_source_group = nullptr;
std::vector<SourceKind*> form_kinds; ///< dropdown order
ConfigForm form_cfg;
std::vector<GtkWidget*> form_cfg_rows; ///< the rows currently in `form_source_group`
GtkWidget* form_connect = nullptr;
/// The configuration half of the form: what a source's *session* is called and the settings that
/// can only be applied before the model exists. A new host needs all of them, and so does an
/// existing one -- which is why the same form is used for both.
AdwEntryRow* form_name = nullptr;
AdwEntryRow* form_note = nullptr;
AdwSpinRow* form_buffer = nullptr;
AdwSwitchRow* form_relative = nullptr;
/// The configuration the form is editing: empty for a new host. Copied rather than referenced,
/// because `chooser_populate()` rebuilds `known` under the form.
std::string edit_host;
std::uint16_t edit_port = 0;
std::string edit_config;
/// `UDPSCOPE_PROBE_PICK_MS`'s timeout, kept so a pick made before it fires can cancel it. The
/// probe clears it when it runs: `g_source_remove()` on an id whose source already returned
/// `G_SOURCE_REMOVE` is a GLib critical, not a no-op.
guint pick_source = 0;
/// What the *form* last wrote, for `report_pick()`: a save finishes the chooser and destroys the
/// 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;
/// The help menu: the `.ui`'s own `GtkMenuButton` and the three rows built onto it. The rows are
/// code for the same reason every other row here is -- each carries a handler, and the toggle
/// carries the value the settings file holds -- while the button that opens them is the file's, so
/// a `.ui` that lost it is caught by the null check below rather than by an empty popover.
GtkWidget* help_button = nullptr;
GtkWidget* help_docs = nullptr;
GtkWidget* help_tour = nullptr;
GtkWidget* help_toggle = nullptr;
GtkWidget* help_popover = nullptr;
/// The contextual help's "(i)" affordances, one per surface *this* window explains. They are not
/// the window's table -- `ui_context_help.cpp` names `App` fields, and there is no `App` here yet
/// -- so this window holds its own two rows and joins them with `context_help_button()`, which
/// draws the *one* "(i)" button both windows use.
GtkWidget* sources_head = nullptr;
GtkWidget* waves_actions = nullptr;
/// The guided tour of *this* window. It walks the chooser's own widgets and, when the walk
/// finishes, hands it on to the application through `Config::tour_pending`. The machinery is the
/// shared `ui_impl::TourView`; only the stops are this window's, because they name its widgets.
ui_impl::TourView tour;
/// The timer that auto-starts this tour on a first run, kept so a chooser that closes first can
/// cancel it (`&c` is a stack frame, and a timer that outlives it is the defect this project has
/// already paid for once).
guint tour_source = 0;
/// `UDPSCOPE_PROBE_CHOOSER_TOUR_MS`'s own timer, cancelled the same way.
guint tour_probe_source = 0;
};
void chooser_finish(Chooser& c, bool chosen) {
c.chosen = chosen;
// The two timers point into this stack frame (`&c`), so they go before the frame does. Both are
// cleared first: `g_source_remove()` on an id whose source already returned is a GLib critical.
if (c.tour_source != 0) {
g_source_remove(c.tour_source);
c.tour_source = 0;
}
if (c.tour_probe_source != 0) {
g_source_remove(c.tour_probe_source);
c.tour_probe_source = 0;
}
// The tour's own widgets go with the window; ending the walk first means the spotlight and the
// card are hidden rather than finalized by the destroy while still visible.
ui_impl::tour_end(c.tour);
// Destroy the form first: any path that reaches here while the form is up -- a row click, the
// Escape key -- would otherwise leave a window whose four handlers are bound to a dead stack
// frame. `gtk_window_destroy()` rather than `gtk_window_close()`: the pointer is already
// cleared, so the `close-request` handler is not needed here, and `close()` on an unrealized
// window returns having done nothing.
if (c.form != nullptr) {
AdwDialog* d = c.form;
c.form = nullptr; // before the close, so the `closed` handler cannot re-enter
adw_dialog_force_close(d);
}
// The preferences go first: they are presented *on* this window, and a dialog still up when its
// window is destroyed is the lifetime this project has got wrong once already. `prefs_close()` is
// `adw_dialog_close()`, and the form's own `closed` handler clears it.
ui_impl::prefs_close(c.prefs);
if (c.window != nullptr) {
GtkWidget* w = c.window;
c.window = nullptr; // before the destroy: `close-request` must not re-enter this
gtk_window_destroy(GTK_WINDOW(w));
}
// The window belonged to the builder's object list, so it is freed with it -- after the destroy, never
// before: a builder freed under a live window takes the window's children with it.
g_clear_object(&c.ui);
if (c.loop != nullptr) g_main_loop_quit(c.loop);
}
void chooser_populate(Chooser& c);
void chooser_populate_waves(Chooser& c);
void report_pick(const Chooser& c);
/// Defined below; the row buttons reach it, and the row builder sits above it.
void open_form(Chooser& c, int edit_index);
/// The chooser's own help surfaces and its own tour, defined below: the help menu's rows and the
/// first-run timer call them, and both need the window's widgets resolved first.
void chooser_help_sync(Chooser& c);
void chooser_tour_begin(Chooser& c);
void on_row_activated(GtkListBox*, GtkListBoxRow* row, gpointer user) {
Chooser& c = *static_cast<Chooser*>(user);
const int idx = GPOINTER_TO_INT(g_object_get_data(G_OBJECT(row), "source-index"));
if (idx < 0 || static_cast<std::size_t>(idx) >= c.known.size()) return;
const KnownSource& src = c.known[static_cast<std::size_t>(idx)];
// Picking a configuration *is* choosing it as the active one: the app reads that back when it
// loads the session, and the next save goes to the same file. Without this, opening `bench`
// from the chooser would leave the source pointing at whatever was active before.
set_active_configuration(src.host, src.port, src.config);
apply_source(*c.cfg, src);
chooser_finish(c, true);
}
/// One row per waveform opened before, newest first. The name first (that is what a person reads),
/// the directory under it, and a missing file said out loud rather than left to fail on the click.
void chooser_populate_waves(Chooser& c) {
while (GtkWidget* child = gtk_widget_get_first_child(c.wave_list)) {
gtk_list_box_remove(GTK_LIST_BOX(c.wave_list), child);
}
c.waves = recent_waveforms();
if (c.waves.empty()) {
GtkWidget* empty = gtk_label_new("No waveform opened yet -- use Open\xe2\x80\xa6");
gtk_widget_add_css_class(empty, "dim-label");
gtk_widget_set_margin_top(empty, 24);
gtk_widget_set_margin_bottom(empty, 24);
gtk_list_box_append(GTK_LIST_BOX(c.wave_list), empty);
gtk_list_box_set_selection_mode(GTK_LIST_BOX(c.wave_list), GTK_SELECTION_NONE);
return;
}
gtk_list_box_set_selection_mode(GTK_LIST_BOX(c.wave_list), GTK_SELECTION_SINGLE);
for (std::size_t i = 0; i < c.waves.size(); ++i) {
const std::string& path = c.waves[i];
GtkWidget* row = gtk_list_box_row_new();
g_object_set_data(G_OBJECT(row), "wave-index", GINT_TO_POINTER(static_cast<int>(i)));
GtkWidget* line = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 12);
gtk_widget_set_margin_start(line, 12);
gtk_widget_set_margin_end(line, 6);
gtk_widget_set_margin_top(line, 6);
gtk_widget_set_margin_bottom(line, 6);
GtkWidget* text = gtk_box_new(GTK_ORIENTATION_VERTICAL, 2);
gtk_widget_set_hexpand(text, TRUE);
const std::size_t slash = path.rfind('/');
const std::string base = slash == std::string::npos ? path : path.substr(slash + 1);
GtkWidget* title = gtk_label_new(base.c_str());
gtk_widget_set_halign(title, GTK_ALIGN_START);
gtk_box_append(GTK_BOX(text), title);
const bool present = ::access(path.c_str(), R_OK) == 0;
std::string sub = present ? path : path + " (missing)";
GtkWidget* under = gtk_label_new(sub.c_str());
gtk_widget_add_css_class(under, "dim-label");
gtk_widget_set_halign(under, GTK_ALIGN_START);
gtk_label_set_ellipsize(GTK_LABEL(under), PANGO_ELLIPSIZE_MIDDLE);
gtk_box_append(GTK_BOX(text), under);
gtk_box_append(GTK_BOX(line), text);
gtk_list_box_row_set_child(GTK_LIST_BOX_ROW(row), line);
gtk_list_box_append(GTK_LIST_BOX(c.wave_list), row);
}
}
void on_wave_activated(GtkListBox*, GtkListBoxRow* row, gpointer user) {
Chooser& c = *static_cast<Chooser*>(user);
if (row == nullptr) return;
const int idx = GPOINTER_TO_INT(g_object_get_data(G_OBJECT(row), "wave-index"));
if (idx < 0 || static_cast<std::size_t>(idx) >= c.waves.size()) return;
const std::string& path = c.waves[static_cast<std::size_t>(idx)];
if (::access(path.c_str(), R_OK) != 0) {
std::fprintf(stderr, "[sources] waveform is gone: %s\n", path.c_str());
std::fflush(stderr);
return;
}
// The file *is* the source: the chooser's answer is a path, not a socket.
c.cfg->open_waveform = path;
report_pick(c);
chooser_finish(c, true);
}
void on_wave_open_done(GObject* source, GAsyncResult* result, gpointer user) {
Chooser& c = *static_cast<Chooser*>(user);
GFile* file = gtk_file_dialog_open_finish(GTK_FILE_DIALOG(source), result, nullptr);
if (file == nullptr) return; // cancelled
char* path = g_file_get_path(file);
g_object_unref(file);
if (path != nullptr) {
c.cfg->open_waveform = path;
g_free(path);
report_pick(c);
chooser_finish(c, true);
}
}
void on_wave_open_clicked(GtkButton*, gpointer user) {
Chooser& c = *static_cast<Chooser*>(user);
GtkFileDialog* dialog = gtk_file_dialog_new();
gtk_file_dialog_set_title(dialog, "Open an exported waveform");
GtkFileFilter* filter = gtk_file_filter_new();
gtk_file_filter_set_name(filter, "NumPy export (*.npy)");
gtk_file_filter_add_pattern(filter, "*.npy");
GListStore* filters = g_list_store_new(GTK_TYPE_FILE_FILTER);
g_list_store_append(filters, filter);
gtk_file_dialog_set_filters(dialog, G_LIST_MODEL(filters));
gtk_file_dialog_open(dialog, GTK_WINDOW(c.window), nullptr, on_wave_open_done, &c);
g_object_unref(filters);
g_object_unref(filter);
g_object_unref(dialog);
}
void on_edit_clicked(GtkButton* button, gpointer user) {
Chooser& c = *static_cast<Chooser*>(user);
const int idx = GPOINTER_TO_INT(g_object_get_data(G_OBJECT(button), "source-index"));
if (idx < 0 || static_cast<std::size_t>(idx) >= c.known.size()) return;
open_form(c, idx);
}
void on_forget_clicked(GtkButton* button, gpointer user) {
Chooser& c = *static_cast<Chooser*>(user);
const int idx = GPOINTER_TO_INT(g_object_get_data(G_OBJECT(button), "source-index"));
if (idx < 0 || static_cast<std::size_t>(idx) >= c.known.size()) return;
forget_source(c.known[static_cast<std::size_t>(idx)]);
// Repopulate rather than remove one row: the rows carry their index into `known`, and dropping
// an entry renumbers every later one. Re-reading is also the honest answer to "did it go".
chooser_populate(c);
}
/// Fills the list box from `known_sources()`. Called again after a forget.
void chooser_populate(Chooser& c) {
while (GtkWidget* child = gtk_widget_get_first_child(c.list)) {
gtk_list_box_remove(GTK_LIST_BOX(c.list), child);
}
c.known = known_sources();
if (c.known.empty()) {
GtkWidget* empty = gtk_label_new("No saved sources yet.");
gtk_widget_add_css_class(empty, "dim-label");
gtk_widget_set_margin_top(empty, 24);
gtk_widget_set_margin_bottom(empty, 24);
// GTK4 auto-wraps any non-row widget appended to a `GtkListBox` in a row, so this is
// activatable -- but the wrapped row carries no `"source-index"` data, `g_object_get_data()`
// returns null, the index reads 0, and `on_row_activated`'s bounds check refuses it.
gtk_list_box_append(GTK_LIST_BOX(c.list), empty);
gtk_list_box_set_selection_mode(GTK_LIST_BOX(c.list), GTK_SELECTION_NONE);
return;
}
gtk_list_box_set_selection_mode(GTK_LIST_BOX(c.list), GTK_SELECTION_SINGLE);
for (std::size_t i = 0; i < c.known.size(); ++i) {
const KnownSource& s = c.known[i];
GtkWidget* row = gtk_list_box_row_new();
g_object_set_data(G_OBJECT(row), "source-index", GINT_TO_POINTER(static_cast<int>(i)));
GtkWidget* line = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 12);
gtk_widget_set_margin_start(line, 12);
gtk_widget_set_margin_end(line, 6);
gtk_widget_set_margin_top(line, 6);
gtk_widget_set_margin_bottom(line, 6);
GtkWidget* text = gtk_box_new(GTK_ORIENTATION_VERTICAL, 2);
gtk_widget_set_hexpand(text, TRUE);
// The *server*, even for a multicast session: that is the host the file is named after and
// the one CONFIG is fetched from. Titling the row by the group would name it after something
// that is not its identity.
// A non-UDPS session whose kind is not registered keeps its pseudo-host as the title.
SourceKind* sk = s.kind == "udps" ? nullptr : find_kind(s.kind);
const std::string where = sk != nullptr ? sk->display_name() + " " + sk->identity(s.opts)
: s.host + ":" + std::to_string(s.port);
const std::string title = where + " \u00b7 " + s.config;
GtkWidget* title_label = gtk_label_new(title.c_str());
gtk_widget_set_halign(title_label, GTK_ALIGN_START);
gtk_box_append(GTK_BOX(text), title_label);
// The note first when there is one: it is what tells two configurations of one source apart,
// and a list that shows only counts makes the user open them to find out.
if (!s.comment.empty()) {
GtkWidget* note = gtk_label_new(s.comment.c_str());
gtk_widget_set_halign(note, GTK_ALIGN_START);
gtk_label_set_ellipsize(GTK_LABEL(note), PANGO_ELLIPSIZE_END);
gtk_box_append(GTK_BOX(text), note);
}
std::string sub = std::to_string(s.rows) + (s.rows == 1 ? " plot, " : " plots, ") +
std::to_string(s.signals) + (s.signals == 1 ? " signal" : " signals");
if (s.buffer_samples > 0) sub += ", buffer " + format_samples(s.buffer_samples) + " samples";
if (s.relative_time) sub += ", relative time";
if (s.capture_packets > 0) {
sub += ", capture " + std::to_string(s.capture_packets) + " packets";
}
if (!s.multicast.empty()) sub += ", multicast " + s.multicast;
GtkWidget* sub_label = gtk_label_new(sub.c_str());
gtk_widget_add_css_class(sub_label, "dim-label");
gtk_widget_set_halign(sub_label, GTK_ALIGN_START);
gtk_box_append(GTK_BOX(text), sub_label);
gtk_box_append(GTK_BOX(line), text);
GtkWidget* edit = gtk_button_new_from_icon_name("document-edit-symbolic");
gtk_widget_set_tooltip_text(edit,
"Edit this configuration: its name and note, the buffer size and "
"the clock, or the address it points at");
gtk_widget_add_css_class(edit, "flat");
gtk_widget_set_valign(edit, GTK_ALIGN_CENTER);
g_object_set_data(G_OBJECT(edit), "source-index", GINT_TO_POINTER(static_cast<int>(i)));
g_signal_connect(edit, "clicked", G_CALLBACK(on_edit_clicked), &c);
gtk_box_append(GTK_BOX(line), edit);
GtkWidget* forget = gtk_button_new_from_icon_name("window-close-symbolic");
gtk_widget_set_tooltip_text(forget, "Forget this configuration (deletes its saved session)");
gtk_widget_add_css_class(forget, "flat");
gtk_widget_set_valign(forget, GTK_ALIGN_CENTER);
g_object_set_data(G_OBJECT(forget), "source-index", GINT_TO_POINTER(static_cast<int>(i)));
g_signal_connect(forget, "clicked", G_CALLBACK(on_forget_clicked), &c);
gtk_box_append(GTK_BOX(line), forget);
gtk_list_box_row_set_child(GTK_LIST_BOX_ROW(row), line);
gtk_list_box_append(GTK_LIST_BOX(c.list), row);
}
}
/// The form's sample budget as a count: the spin is in millions, and a count is what is stored.
std::uint64_t form_buffer_samples(const Chooser& c) {
const double m = adw_spin_row_get_value(c.form_buffer);
return static_cast<std::uint64_t>(std::max(128.0, std::round(m * 1.0e6)));
}
/// Says under the "Samples per signal" row what the number will become: the rings are powers of two,
/// so the request is a ceiling, and the memory limit can lower it further. The limit is judged for
/// *one* signal here -- the channel count is not known until a stream declares it -- so a clip shown
/// is certain, and the status line says what a real stream got. The cost line gives the two figures
/// the user can multiply by their own signal count.
void update_buffer_note(Chooser& c) {
if (c.form_buffer == nullptr) return;
const std::uint64_t requested = form_buffer_samples(c);
const bool relative = adw_switch_row_get_active(c.form_relative) != FALSE;
const std::uint64_t limit = c.cfg->memory_limit_mb * 1024u * 1024u;
const std::uint64_t wanted = buffer_ticks(requested);
std::uint64_t used = wanted;
while (used > 128 && planned_bytes(used, 1, relative) > limit) used /= 2;
const std::string note =
describe_buffer(requested, used, used < wanted, limit) + ". The rings take " +
format_bytes(used * sizeof(double) * (relative ? 2 : 1)) + " for the clock and " +
format_bytes(used * sizeof(float)) + " per signal.";
adw_action_row_set_subtitle(ADW_ACTION_ROW(c.form_buffer), note.c_str());
}
void form_close(Chooser& c) {
// The form's own `closed` handler clears the pointer, so this is the only place that has to know how
// an `AdwDialog` closes.
if (c.form != nullptr) adw_dialog_close(c.form);
}
/// The kind selected in the dropdown, or null.
SourceKind* form_selected_kind(const Chooser& c) {
if (c.form_kind_row == nullptr) return nullptr;
const guint i = adw_combo_row_get_selected(c.form_kind_row);
return i < c.form_kinds.size() ? c.form_kinds[i] : nullptr;
}
/// Swaps the Connection group's rows for `kind`'s form and fills them from `initial`. The
/// connect button follows the form's own `valid()`: the one validation here is the kind's.
void form_rebuild(Chooser& c, SourceKind& kind, const SourceConfig& initial) {
for (GtkWidget* r : c.form_cfg_rows) adw_preferences_group_remove(c.form_source_group, r);
c.form_cfg_rows.clear();
c.form_cfg = kind.make_config_form();
for (GtkWidget* r : c.form_cfg.rows) {
adw_preferences_group_add(c.form_source_group, r);
c.form_cfg_rows.push_back(r);
}
adw_preferences_group_set_description(
c.form_source_group,
c.form_cfg.description.empty() ? nullptr : c.form_cfg.description.c_str());
c.form_cfg.write(initial);
c.form_cfg.on_change([&c] {
if (c.form_connect != nullptr && c.form_cfg.valid) {
gtk_widget_set_sensitive(c.form_connect, c.form_cfg.valid() ? TRUE : FALSE);
}
});
if (c.form_connect != nullptr) {
gtk_widget_set_sensitive(c.form_connect, c.form_cfg.valid() ? TRUE : FALSE);
}
if (c.form_cfg.focus != nullptr) gtk_widget_grab_focus(c.form_cfg.focus);
}
void on_form_kind_changed(GObject*, GParamSpec*, gpointer user) {
Chooser& c = *static_cast<Chooser*>(user);
if (SourceKind* k = form_selected_kind(c)) form_rebuild(c, *k, SourceConfig());
}
void on_form_connect(GtkButton*, gpointer user) {
Chooser& c = *static_cast<Chooser*>(user);
SourceKind* kind = form_selected_kind(c);
if (kind == nullptr || !c.form_cfg.valid()) return;
const SourceConfig scfg = c.form_cfg.read();
const bool udps = kind->id() == "udps";
const std::string host = session_host(kind->id(), scfg);
const std::uint16_t port = session_port(kind->id(), scfg);
const std::string group = udps ? scfg.get("multicast") : std::string();
const std::string iface = udps ? scfg.get("interface") : std::string();
std::string name = gtk_editable_get_text(GTK_EDITABLE(c.form_name));
const std::string note = gtk_editable_get_text(GTK_EDITABLE(c.form_note));
const std::uint64_t buffer_samples = form_buffer_samples(c);
const bool relative = adw_switch_row_get_active(c.form_relative) != FALSE;
if (name.empty()) name = "default";
c.cfg->source_kind = kind->id();
if (udps) {
c.cfg->server = scfg.get("server");
c.cfg->port = port;
c.cfg->multicast = group;
c.cfg->interface_addr = iface;
c.cfg->source_opts = SourceConfig();
} else {
c.cfg->source_opts = scfg;
}
// The startup settings. Applied here because this runs *before* `ScopeModel` exists, which is the
// only moment a buffer size can be applied at all -- and stored, so the next run gets them back.
c.cfg->buffer_samples = buffer_samples;
c.cfg->relative_time = relative;
LayoutState st;
if (!c.edit_config.empty()) {
// An edit keeps everything the configuration already holds -- plots, calibration, math,
// measurements -- and changes only what the form shows.
load_layout(configuration_path(c.edit_host, c.edit_port, c.edit_config), st);
}
st.comment = note;
st.buffer_samples = buffer_samples;
st.relative_time = relative;
st.multicast = group;
st.interface_addr = iface;
st.source_kind = udps ? std::string() : kind->id();
st.source_opts = udps ? SourceConfig() : scfg;
const bool editing = !c.edit_config.empty();
const bool moved =
editing && (c.edit_host != host || c.edit_port != port || c.edit_config != name);
if (save_layout(configuration_path(host, port, name), st)) {
set_active_configuration(host, port, name);
// A renamed configuration, or one re-addressed, is a *move*: the old file has no reason to exist
// and leaving it would list the same session twice.
if (moved) forget_configuration(c.edit_host, c.edit_port, c.edit_config);
c.saved_config = name;
c.saved_note = note;
std::fprintf(stderr,
"[sources] configuration %s: %s:%u/%s note='%s' samples=%llu relative=%d\n",
editing ? "saved" : "created", host.c_str(), port, name.c_str(), note.c_str(),
static_cast<unsigned long long>(buffer_samples), relative ? 1 : 0);
std::fflush(stderr);
} else {
std::fprintf(stderr, "[sources] configuration write failed: %s:%u/%s\n", host.c_str(), port,
name.c_str());
std::fflush(stderr);
}
form_close(c);
chooser_finish(c, true);
}
/// Opens the connection form: on a new host, or on an existing configuration (`edit_index`). One
/// form for both, because the fields are the same and a configuration *is* a host plus its
/// settings -- the only difference is where the values start and what the button is called.
void open_form(Chooser& c, int edit_index = -1) {
if (c.form != nullptr) {
// `adw_dialog_present()`, never `gtk_window_present()`: an `AdwDialog` is a `GtkWidget` and
// **not** a `GtkWindow`, so the old cast was a type assertion GTK logs and then does nothing
// with -- the form stayed behind whatever had been raised over it. The chooser presents it the
// same way below, and it is the only call that knows how to show a dialog on a window.
adw_dialog_present(c.form, GTK_WIDGET(c.window));
return;
}
const KnownSource* src =
edit_index >= 0 && static_cast<std::size_t>(edit_index) < c.known.size()
? &c.known[static_cast<std::size_t>(edit_index)]
: nullptr;
// An edit of a session whose kind is not registered is refused: the form would rebuild as the first
// kind, and Save would rewrite the file as that kind and delete the original.
if (src != nullptr && !src->kind.empty()) {
bool known_kind = false;
for (SourceKind* k : kinds()) known_kind = known_kind || k->id() == src->kind;
if (!known_kind) {
std::fprintf(stderr, "[sources] cannot edit %s: source kind '%s' is not registered\n",
src->config.c_str(), src->kind.c_str());
std::fflush(stderr);
return;
}
}
c.edit_host = src != nullptr ? src->host : std::string();
c.edit_port = src != nullptr ? src->port : 0;
c.edit_config = src != nullptr ? src->config : std::string();
// A builder per opening, as with every other `.ui` here: the dialog belongs to its object list and
// `closed` destroys it, so the resource is parsed again rather than a dead dialog fetched.
g_clear_object(&c.form_ui);
c.form_ui = gtk_builder_new_from_resource("/org/udpscope/ui/source-form.ui");
if (c.form_ui == nullptr) return;
GObject* dialog = gtk_builder_get_object(c.form_ui, "source_form");
c.form_kind_row = ADW_COMBO_ROW(gtk_builder_get_object(c.form_ui, "kind_row"));
c.form_source_group =
ADW_PREFERENCES_GROUP(gtk_builder_get_object(c.form_ui, "source_group"));
c.form_name = ADW_ENTRY_ROW(gtk_builder_get_object(c.form_ui, "name_row"));
c.form_note = ADW_ENTRY_ROW(gtk_builder_get_object(c.form_ui, "note_row"));
c.form_buffer = ADW_SPIN_ROW(gtk_builder_get_object(c.form_ui, "buffer_row"));
c.form_relative = ADW_SWITCH_ROW(gtk_builder_get_object(c.form_ui, "form_relative_row"));
c.form_title = ADW_WINDOW_TITLE(gtk_builder_get_object(c.form_ui, "form_title"));
c.form_connect = GTK_WIDGET(gtk_builder_get_object(c.form_ui, "form_apply"));
// A missing id is the one mistake neither the build nor `GtkBuilder` sees, and every read below would
// be a null row.
if (dialog == nullptr || c.form_kind_row == nullptr || c.form_source_group == nullptr ||
c.form_name == nullptr ||
c.form_note == nullptr || c.form_buffer == nullptr || c.form_relative == nullptr ||
c.form_connect == nullptr) {
g_clear_object(&c.form_ui);
return;
}
c.form = ADW_DIALOG(dialog);
// Which mode the form is in, said in the header bar: the title names it and the action's label says
// what pressing it does.
const bool editing = src != nullptr;
if (c.form_title != nullptr) {
adw_window_title_set_title(c.form_title, editing ? "Edit configuration" : "New host");
}
gtk_button_set_label(GTK_BUTTON(c.form_connect), editing ? "Save" : "Connect");
// The values: the configuration being edited, or what the command line asked for. The hints live in
// the rows' subtitles, so an empty field still says what empty means.
gtk_editable_set_text(GTK_EDITABLE(c.form_name), src != nullptr ? src->config.c_str() : "default");
gtk_editable_set_text(GTK_EDITABLE(c.form_note), src != nullptr ? src->comment.c_str() : "");
adw_spin_row_set_value(c.form_buffer, double(src != nullptr && src->buffer_samples > 0
? src->buffer_samples
: c.cfg->buffer_samples) /
1.0e6);
update_buffer_note(c);
g_signal_connect(c.form_buffer, "notify::value", G_CALLBACK(+[](GObject*, GParamSpec*, gpointer user) {
update_buffer_note(*static_cast<Chooser*>(user));
}),
&c);
adw_switch_row_set_active(c.form_relative,
(src != nullptr ? src->relative_time : c.cfg->relative_time) ? TRUE
: FALSE);
// The relative clock doubles the time ring, so the note under the sample budget follows it.
g_signal_connect(c.form_relative, "notify::active", G_CALLBACK(+[](GObject*, GParamSpec*, gpointer user) {
update_buffer_note(*static_cast<Chooser*>(user));
}),
&c);
update_buffer_note(c);
// The dropdown lists the registered kinds. A new host starts on the kind the command line named
// (UDPS by default); an edit starts on the configuration's own kind and cannot change it --
// disabled, not hidden, like every other control that does not apply.
c.form_kinds = kinds();
GtkStringList* names = gtk_string_list_new(nullptr);
guint selected = 0;
const std::string want_kind = src != nullptr ? src->kind : c.cfg->source_kind;
for (guint i = 0; i < c.form_kinds.size(); ++i) {
gtk_string_list_append(names, c.form_kinds[i]->display_name().c_str());
if (c.form_kinds[i]->id() == want_kind) selected = i;
}
adw_combo_row_set_model(c.form_kind_row, G_LIST_MODEL(names));
adw_combo_row_set_selected(c.form_kind_row, selected);
gtk_widget_set_sensitive(GTK_WIDGET(c.form_kind_row), src == nullptr);
g_object_unref(names);
SourceConfig initial;
if (src != nullptr && src->kind == "udps") {
initial.set("server", src->host);
initial.set("port", std::to_string(src->port));
if (!src->multicast.empty()) initial.set("multicast", src->multicast);
if (!src->interface_addr.empty()) initial.set("interface", src->interface_addr);
} else if (src != nullptr) {
initial = src->opts;
} else if (c.cfg->source_kind != "udps") {
initial = c.cfg->source_opts;
}
if (!c.form_kinds.empty()) form_rebuild(c, *c.form_kinds[selected], initial);
// Connected after the first rebuild: setting `selected` above must not rebuild a second time.
g_signal_connect(c.form_kind_row, "notify::selected", G_CALLBACK(on_form_kind_changed), &c);
if (GObject* cancel = gtk_builder_get_object(c.form_ui, "form_cancel"); cancel != nullptr) {
g_signal_connect(cancel, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
form_close(*static_cast<Chooser*>(user));
}),
&c);
}
g_signal_connect(c.form_connect, "clicked", G_CALLBACK(on_form_connect), &c);
// `closed` clears the pointer. An `AdwDialog` emits that and not `close-request`, and it fires only
// when the dialog really went away -- which is what the comment here used to be about.
g_signal_connect(c.form, "closed", G_CALLBACK(+[](AdwDialog*, gpointer user) {
Chooser& ch = *static_cast<Chooser*>(user);
ch.form = nullptr;
// The rows are gone with the dialog; dropping the closures that captured them
// keeps a stale `read()` from ever being callable.
ch.form_cfg = ConfigForm();
ch.form_cfg_rows.clear();
ch.form_kind_row = nullptr;
ch.form_source_group = nullptr;
ch.form_connect = nullptr;
ch.form_kinds.clear();
}),
&c);
adw_dialog_present(c.form, GTK_WIDGET(c.window));
if (c.form_cfg.focus != nullptr) gtk_widget_grab_focus(c.form_cfg.focus);
}
/// What the pick probes report: the connection the chooser came back with. One helper rather than
/// the same four lines in each branch, since both probes assert against this exact line.
void report_pick(const Chooser& c) {
if (!c.cfg->open_waveform.empty()) {
std::fprintf(stderr, "[sources] picked waveform %s\n", c.cfg->open_waveform.c_str());
std::fflush(stderr);
return;
}
// The settings the configuration carried, not just the address: a pick that dropped the buffer
// size would look identical here otherwise, and the buffer is the one of them a user notices
// only after a long capture has been sized wrong.
std::fprintf(stderr,
"[sources] picked %s:%u multicast=%s interface=%s samples=%llu relative=%d "
"capture=%u config=%s note='%s'\n",
c.cfg->server.c_str(), c.cfg->port, c.cfg->multicast.c_str(),
c.cfg->interface_addr.c_str(),
static_cast<unsigned long long>(c.cfg->buffer_samples), c.cfg->relative_time ? 1 : 0,
c.cfg->capture_packets,
(c.saved_config.empty() ? (c.known.empty() ? "-" : c.known.front().config)
: c.saved_config)
.c_str(),
(c.saved_config.empty() ? (c.known.empty() ? "-" : c.known.front().comment)
: c.saved_note)
.c_str());
if (c.cfg->source_kind != "udps") {
std::string opts;
for (const auto& kv : c.cfg->source_opts.items()) {
opts += (opts.empty() ? "" : ",") + kv.first + "=" + kv.second;
}
std::fprintf(stderr, "[sources] picked kind=%s opts=%s session=%s:%u\n",
c.cfg->source_kind.c_str(), opts.c_str(), c.cfg->session_host().c_str(),
c.cfg->session_port());
}
std::fflush(stderr);
}
gboolean on_chooser_close(GtkWindow*, gpointer user) {
Chooser& c = *static_cast<Chooser*>(user);
c.window = nullptr; // GTK destroys it for us on the default handler
chooser_finish(c, false);
return FALSE;
}
gboolean on_chooser_key(GtkEventControllerKey*, guint keyval, guint, GdkModifierType,
gpointer user) {
if (keyval != GDK_KEY_Escape) return FALSE;
chooser_finish(*static_cast<Chooser*>(user), false);
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;
// The memory limit is read by the model when it is built, which is after this returns, so the
// chooser's own change is the newest word on it for the run about to start.
c.cfg->memory_limit_mb = static_cast<std::uint64_t>(s.memory_limit_mb);
c.cfg->memory_limit_specified = true;
if (!written) {
std::fprintf(stderr, "[sources] prefs: could not write %s\n", settings_path().c_str());
std::fflush(stderr);
}
});
}
/// The chooser's help menu: the same three affordances the window's Help view carries, because a
/// user who has not started yet is exactly the one who does not know how to start. The rows are
/// `ui_impl::menu_button()`/`menu_toggle()` -- the app's own menu-row shape, so the two menus read
/// as one program -- and the popover carries the same `.udpscope-menu` class, which is what trims
/// the rows; `install_css()` therefore runs before this, in `choose_source()`.
///
/// No row shows an accelerator. The window's Help rows show "F1"/"Ctrl+T" because the window handles
/// those keys; this dialog binds none of them, and a shortcut printed beside an action that does not
/// answer to it is the "documented and does nothing" failure the shortcut tables are checked for.
void build_chooser_help(Chooser& c) {
c.help_popover = gtk_popover_new();
gtk_widget_add_css_class(c.help_popover, "udpscope-menu");
GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
gtk_widget_set_margin_top(box, 6);
gtk_widget_set_margin_bottom(box, 6);
gtk_widget_set_margin_start(box, 6);
gtk_widget_set_margin_end(box, 6);
c.help_docs = ui_impl::menu_button("Documentation", nullptr);
gtk_widget_set_tooltip_text(c.help_docs,
"the udpscope manual, opened in the desktop help viewer");
g_signal_connect(c.help_docs, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
Chooser& ch = *static_cast<Chooser*>(user);
gtk_popover_popdown(GTK_POPOVER(ch.help_popover));
// No window of the app exists yet: the manual is a separate process reached
// through the `help:` URI, so this needs no more of the app than the chooser
// itself. The parent may be null, and this dialog is the right attribution.
show_help(GTK_WINDOW(ch.window));
}),
&c);
gtk_box_append(GTK_BOX(box), c.help_docs);
c.help_tour = ui_impl::menu_button("Guided tour", nullptr);
gtk_widget_set_tooltip_text(c.help_tour,
"a walk over this dialog's own widgets, then over the plot, the side "
"panel, the trigger, the signals and the status line once a source "
"has been opened");
g_signal_connect(c.help_tour, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
Chooser& ch = *static_cast<Chooser*>(user);
gtk_popover_popdown(GTK_POPOVER(ch.help_popover));
// The tour *starts here*, on this window: the chooser is what a first run meets
// first and what it has no way of knowing about. When this walk finishes it sets
// `Config::tour_pending`, so the application continues it once a source has been
// picked -- see `chooser_tour_begin()` and `chooser_tour_done()`.
chooser_tour_begin(ch);
std::fprintf(stderr, "[sources] chooser help: tour started in the chooser\n");
std::fflush(stderr);
}),
&c);
gtk_box_append(GTK_BOX(box), c.help_tour);
// The check *is* the setting, exactly as in the window's menu: the row is stated before the handler
// is connected, so a row that opens checked does not write the file while the dialog is still being
// built. The chooser re-syncs its own "(i)" layer here, the way the window re-syncs the window's.
c.help_toggle = ui_impl::menu_toggle("Enable contextual help", nullptr,
load_settings().contextual_help);
gtk_widget_set_tooltip_text(c.help_toggle,
"a small \u201c(i)\u201d beside each surface of this dialog and of the "
"main window, saying what that surface is for");
g_signal_connect(c.help_toggle, "toggled", G_CALLBACK(+[](GtkToggleButton* button, gpointer user) {
Chooser& ch = *static_cast<Chooser*>(user);
Settings s = load_settings();
s.contextual_help = gtk_toggle_button_get_active(button) != FALSE;
// Written in full, the way the window's toggle writes it: the keys this row does
// not own are carried through from the file rather than dropped.
if (!save_settings(s)) {
std::fprintf(stderr, "[sources] chooser help: could not write %s\n",
settings_path().c_str());
std::fflush(stderr);
}
chooser_help_sync(ch);
}),
&c);
gtk_box_append(GTK_BOX(box), c.help_toggle);
gtk_popover_set_child(GTK_POPOVER(c.help_popover), box);
gtk_menu_button_set_popover(GTK_MENU_BUTTON(c.help_button), c.help_popover);
}
/// The chooser's contextual help: the same "(i)" the window draws, on the two surfaces *this*
/// window has a row for -- the list of saved sources and the waveforms page's action row. It is
/// called once after the window is built and again by the Help menu's toggle, which is the one place
/// the setting can change while the dialog is up.
///
/// The form's rows are deliberately not a host: an `AdwEntryRow` is not a box and its editing widget
/// is internal, so a hint could not join it as a sibling -- it would have to be appended to the whole
/// `AdwPreferencesGroup` as a row of its own, which is not a hint *beside* the field but a row under
/// it. The connection fields are therefore explained by the group's own `description`, which is
/// where libadwaita puts a hint that must survive being typed over.
void chooser_help_sync(Chooser& c) {
const bool on = load_settings().contextual_help;
ui_impl::context_help_button(
c.sources_head, "chooser_sources", "The sources you have opened",
"One row per saved configuration. Clicking one opens it: it becomes the active configuration "
"and the stream starts. The pencil edits a row's name, note, buffer size and clock; the cross "
"forgets it and deletes its saved session.",
"", on);
ui_impl::context_help_button(
c.waves_actions, "chooser_waveform", "Opening a recording",
"A `.npy` an earlier run exported is opened for viewing and analysis instead of a live source: "
"there is no acquisition behind it, so the trigger and the rate counters are not there -- only "
"the samples, which can be measured, searched and re-exported.",
"", on);
}
/// One stop of the *chooser's* tour. Its targets are this window's widgets, which exist before `App`
/// does -- which is the whole reason the stops cannot live in the window's table.
struct ChooserStop {
GtkWidget* Chooser::* target;
/// The page of the chooser's own stack the stop stands on. The tabs stop is the one about the two
/// kinds of input, so it shows the *waveforms* page while it is up; every other stop stands on the
/// sources page, and naming it is what brings the page back when the walk steps off the tabs stop.
const char* page;
const char* title;
const char* body;
};
constexpr ChooserStop kChooserStops[] = {
{&Chooser::list, "sources", "Choosing a source",
"Every source you have saved, newest first. Clicking one opens it -- that is the choice this "
"dialog exists to make -- and the arrows on a row edit or forget its configuration."},
{&Chooser::switcher, "waveforms", "Two kinds of input",
"A *source* is a stream to receive; a *waveform* is a recording an earlier run exported. They "
"are the two answers to \u201cwhat should be on the screen\u201d, and these are the tabs "
"between them."},
{&Chooser::new_host, "sources", "A source that is not listed",
"Opens the form for an address that has no configuration yet: host, port, the settings a source "
"can only be given before it is opened, and the name its session will be saved under."},
{&Chooser::help_button, "sources", "The manual, and this tour",
"Documentation opens the manual in the desktop help viewer. This tour walks this window and "
"then the application's own window. Contextual help is the switch below it: on, every surface "
"carries a small \u201c(i)\u201d saying what it is for."},
{&Chooser::prefs_button, "sources", "Preferences",
"The event cache, relative time and the interface theme. This is the last moment relative time "
"can still change this run -- the buffers are planned as soon as a source is picked -- which is "
"why the button is here and not only in the window's menu."},
};
constexpr int kChooserStopCount = static_cast<int>(sizeof(kChooserStops) / sizeof(kChooserStops[0]));
/// The walk is over: the chooser hands the tour on to the application. This is the second half of the
/// two-window tour -- `tour_pending` is the value `main()` owns and hands to both `choose_source()`
/// and `run_ui()`, and the window starts its own tour from it once a source has been picked.
void chooser_tour_done(void* user) {
Chooser& c = *static_cast<Chooser*>(user);
c.cfg->tour_pending = true;
std::fprintf(stderr,
"[sources] chooser tour: finished %d stops -- the window continues it "
"(tour_pending=1)\n",
kChooserStopCount);
std::fflush(stderr);
}
/// "The tour has run" is the same `settings.tour_seen` the window's auto-start reads, written when
/// the walk *starts*: a user who skips it has still seen it.
void chooser_tour_mark_seen() {
Settings s = load_settings();
if (s.tour_seen) return;
s.tour_seen = true;
if (!save_settings(s)) {
std::fprintf(stderr, "[sources] tour: could not write %s: it will be offered again\n",
settings_path().c_str());
}
}
void chooser_tour_begin(Chooser& c) {
if (c.tour.card == nullptr) return;
chooser_tour_mark_seen();
// The chooser's own stack, so a stop can name the page it stands on (the window has no stack:
// `start_tour()` hooks its slide-over pages in through `on_page`).
c.tour.page_stack = c.page_stack;
// The whole table, in walk order: `tour_begin()` drops the stops whose surface is not on screen.
std::vector<ui_impl::TourStop> stops;
stops.reserve(kChooserStopCount);
for (int i = 0; i < kChooserStopCount; ++i) {
GtkWidget* target = c.*(kChooserStops[i].target);
stops.push_back({target, kChooserStops[i].page, kChooserStops[i].title, kChooserStops[i].body});
}
ui_impl::tour_begin(c.tour, std::move(stops), chooser_tour_done, &c);
}
/// The tour is drawn over the *whole* window, header bar included: two of its stops name the header's
/// own buttons, and an overlay placed inside the content would give them negative coordinates and
/// clamp its hole to the top edge. `AdwWindow`'s content takes the overlay and the toolbar view
/// becomes its child -- the builder still owns the tree, so there is no reference bookkeeping.
void build_chooser_tour(Chooser& c) {
GtkWidget* content = adw_window_get_content(ADW_WINDOW(c.window));
if (content == nullptr) return;
// The overlay has to *become* the window's content before the toolbar view can be adopted by it:
// `gtk_overlay_set_child()` refuses a child that still has a parent, so the two calls are in this
// order -- handing the window the overlay first is what unparents the toolbar view. The view is
// held across the swap rather than left to whatever the builder's object list keeps: it has no id
// for the builder to name it by, and the window's content was its only owner.
GtkWidget* overlay = gtk_overlay_new();
g_object_ref(content);
adw_window_set_content(ADW_WINDOW(c.window), overlay);
gtk_overlay_set_child(GTK_OVERLAY(overlay), content);
g_object_unref(content);
ui_impl::tour_build(c.tour, overlay);
}
/// A first run is the run whose settings file does not say the tour has been offered -- the same key
/// the window's own auto-start reads. Delayed 1.5 s for the reason the window's is: the window has to
/// be up and laid out before anything points at it.
gboolean chooser_tour_autostart(gpointer user) {
Chooser& c = *static_cast<Chooser*>(user);
c.tour_source = 0;
std::fprintf(stderr, "[sources] tour: first run -- auto_started=1 in the chooser\n");
std::fflush(stderr);
chooser_tour_begin(c);
return G_SOURCE_REMOVE;
}
/// Whether the probe has done its one Back step. One chooser runs per process, so a file-static is
/// enough and simpler than a field on `Chooser` that exists only for the probe.
bool g_chooser_tour_backed = false;
/// `UDPSCOPE_PROBE_CHOOSER_TOUR_MS`: the chooser's tour, read and walked a stop per tick -- the
/// cadence the window's own tour probe walks at, and for its reason: a page a stop selects is laid
/// out on the frame *after* the switch, so a reading taken in the switch's own tick would be of the
/// page the walk has just left. It starts the walk if one is not already running, so it does not
/// depend on the throwaway config being a first run; it reports the page the stack shows at every
/// stop; it steps **Back** off the stop that stands on the waveforms page and reads the page again
/// -- the other direction; then it finishes the walk. `page=waveforms` on that stop and `page=sources`
/// after the Back step is the pair the surface switch is read by.
gboolean chooser_tour_probe(gpointer user) {
Chooser& c = *static_cast<Chooser*>(user);
c.tour_probe_source = 0;
const auto page = [&c]() {
const char* name = adw_view_stack_get_visible_child_name(ADW_VIEW_STACK(c.page_stack));
return name != nullptr ? name : "?";
};
if (!c.tour.running) chooser_tour_begin(c);
if (!c.tour.running) {
std::fprintf(stderr, "[sources] chooser tour: nothing to walk (page=%s)\n", page());
std::fflush(stderr);
return G_SOURCE_REMOVE;
}
const std::size_t idx = static_cast<std::size_t>(c.tour.index);
const int n = static_cast<int>(c.tour.plan.size());
const ui_impl::TourStop& stop = c.tour.plan[idx];
std::fprintf(stderr, "[sources] chooser tour: step=%d/%d page=%s stop_page=%s target_mapped=%d\n",
c.tour.index + 1, n, page(), stop.page != nullptr ? stop.page : "-",
stop.target != nullptr && gtk_widget_get_mapped(stop.target) ? 1 : 0);
if (stop.page != nullptr && std::strcmp(stop.page, "waveforms") == 0 && !g_chooser_tour_backed) {
// Off it, Back: the previous stop stands on the sources page, and the page has to come back with
// it -- the other direction a surface switch has to work in.
g_chooser_tour_backed = true;
ui_impl::tour_advance(c.tour, -1);
std::fprintf(stderr, "[sources] chooser tour: back off the waveforms stop: step=%d/%d page=%s\n",
c.tour.index + 1, n, page());
} else {
ui_impl::tour_advance(c.tour, 1);
}
if (c.tour.running) {
c.tour_probe_source = g_timeout_add(250, chooser_tour_probe, &c);
} else {
std::fprintf(stderr, "[sources] chooser tour: after finish visible=0 page=%s tour_pending=%d\n",
page(), c.cfg->tour_pending ? 1 : 0);
}
std::fflush(stderr);
return G_SOURCE_REMOVE;
}
/// `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
/// `UDPSCOPE_PROBE_FIELDS_MS` is in `ui.cpp`.
/// The chooser's frame, read back from the `.ui` it was built from: the two pages by name and title, the
/// switcher wired to the stack, the four buttons the code resolved, and the help menu's three rows.
/// `[help] ui=` does the same against its tables -- the frame is what the file holds, so the frame is
/// what is read, while the rows a user sees are read by the probes that drive them.
void report_chooser_ui(const Chooser& c) {
std::fprintf(stderr, "[sources] chooser ui:");
for (GtkWidget* w = gtk_widget_get_first_child(c.page_stack); w != nullptr;
w = gtk_widget_get_next_sibling(w)) {
// A page is not the child: `AdwViewStackPage` is a `GObject` that *wraps* one, so the stack is what
// turns a child back into its page.
AdwViewStackPage* page = adw_view_stack_get_page(ADW_VIEW_STACK(c.page_stack), w);
if (page == nullptr) continue;
std::fprintf(stderr, " page='%s' title='%s' icon=%s", adw_view_stack_page_get_name(page),
adw_view_stack_page_get_title(page),
adw_view_stack_page_get_icon_name(page) != nullptr ? "yes" : "NO");
}
std::string hint_ids;
const int hints = ui_impl::context_help_count_in(c.window, &hint_ids);
int hint_w = 0;
int hint_h = 0;
ui_impl::context_help_size_in(c.window, &hint_w, &hint_h);
std::fprintf(stderr,
" visible='%s' switcher_wired=%d buttons=[prefs=%d new_host=%d open=%d cancel=%d] "
"help=[button=%d docs=%d tour=%d toggle=%d on=%d] hints=[%d {%s}] hint=%dx%d\n",
adw_view_stack_get_visible_child_name(ADW_VIEW_STACK(c.page_stack)),
adw_view_switcher_bar_get_stack(ADW_VIEW_SWITCHER_BAR(c.switcher)) ==
ADW_VIEW_STACK(c.page_stack)
? 1
: 0,
c.prefs_button != nullptr ? 1 : 0, c.new_host != nullptr ? 1 : 0,
c.waves_open != nullptr ? 1 : 0, c.cancel_button != nullptr ? 1 : 0,
c.help_button != nullptr ? 1 : 0, c.help_docs != nullptr ? 1 : 0,
c.help_tour != nullptr ? 1 : 0, c.help_toggle != nullptr ? 1 : 0,
c.help_toggle != nullptr &&
gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(c.help_toggle))
? 1
: 0,
hints, hint_ids.c_str(), hint_w, hint_h);
std::fflush(stderr);
}
gboolean pick_probe(gpointer user) {
Chooser& c = *static_cast<Chooser*>(user);
// Every path below returns `G_SOURCE_REMOVE`, so the source is gone the moment this returns and
// its id is stale. Clear it here rather than on each path: `choose_source()`'s cancel-the-pending
// -probe call would otherwise be a GLib critical on every run that let the probe fire.
c.pick_source = 0;
// What the `.ui` contributed, before anything is driven: the pages, the switcher and the buttons.
report_chooser_ui(c);
// `UDPSCOPE_PROBE_CHOOSER_TOUR=1` and `UDPSCOPE_PROBE_CHOOSER_HELP=1` drive the help menu's two
// stateful rows the way a click does, and then fall through to the pick below -- the point of the
// tour entry is that it crosses *into the run this chooser starts*, so a probe that stopped here
// would assert nothing. The tour is read back out of the `Config` the run will be handed, which is
// the carrier; the toggle is read back out of the settings file it writes, since the app is built
// from that file and not from the dialog.
// `UDPSCOPE_PROBE_CHOOSER_TOUR=1` drives the help menu's Guided tour row the way a click does --
// which now starts a tour *in this dialog*, not merely a request for the window's -- then walks it
// to its last stop and past it, so the handoff is exercised, and then falls through to the pick
// below. The point of the tour entry is that it crosses into the run this chooser starts, so a
// probe that stopped here would assert nothing; the carrier is `Config::tour_pending`.
if (g_getenv("UDPSCOPE_PROBE_CHOOSER_TOUR") != nullptr && c.help_tour != nullptr) {
g_signal_emit_by_name(c.help_tour, "clicked");
std::fprintf(stderr, "[sources] chooser tour: visible=%d steps=%d step=%d\n",
c.tour.running ? 1 : 0, static_cast<int>(c.tour.plan.size()), c.tour.index);
while (c.tour.running) ui_impl::tour_advance(c.tour, 1);
std::fprintf(stderr, "[sources] chooser tour: after finish visible=%d tour_pending=%d\n",
c.tour.running ? 1 : 0, c.cfg->tour_pending ? 1 : 0);
std::fflush(stderr);
}
if (g_getenv("UDPSCOPE_PROBE_CHOOSER_HELP") != nullptr && c.help_toggle != nullptr) {
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(c.help_toggle), TRUE);
const Settings back = load_settings();
std::fprintf(stderr, "[sources] chooser help: toggle=%d setting=%d file=%s\n",
gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(c.help_toggle)) ? 1 : 0,
back.contextual_help ? 1 : 0, settings_path().c_str());
std::fflush(stderr);
}
// `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 rows=%d\n", c.prefs.dialog != nullptr ? 1 : 0,
c.prefs.dir_row != nullptr ? 1 : 0);
ui_impl::prefs_probe_change(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.
if (g_getenv("UDPSCOPE_PROBE_WAVEFORMS") != nullptr) {
adw_view_stack_set_visible_child_name(ADW_VIEW_STACK(c.page_stack), "waveforms");
std::fprintf(stderr, "[sources] waveforms: %zu recent", c.waves.size());
for (const std::string& p : c.waves) {
std::fprintf(stderr, " %s(%s)", p.c_str(), ::access(p.c_str(), R_OK) == 0 ? "ok" : "missing");
}
std::fprintf(stderr, " page=%s\n",
adw_view_stack_get_visible_child_name(ADW_VIEW_STACK(c.page_stack)));
std::fflush(stderr);
}
if (const char* path = g_getenv("UDPSCOPE_PROBE_WAVE")) {
c.cfg->open_waveform = path;
report_pick(c);
chooser_finish(c, true);
return G_SOURCE_REMOVE;
}
// `UDPSCOPE_PROBE_NEWHOST=host:port` drives the form instead of a row: the address, and the four
// things a host could not be given before -- a name, a note, a buffer size and a clock.
if (const char* spec = g_getenv("UDPSCOPE_PROBE_NEWHOST")) {
open_form(c);
const std::string s = spec;
const std::size_t colon = s.rfind(':');
SourceConfig sc;
if (const char* kspec = g_getenv("UDPSCOPE_PROBE_NEWHOST_KIND")) {
// "kind" or "kind:k=v,k=v": pick the kind in the dropdown (which rebuilds the form), then
// write the options into it.
std::string ks = kspec;
std::string opts;
if (const std::size_t sep = ks.find(':'); sep != std::string::npos) {
opts = ks.substr(sep + 1);
ks = ks.substr(0, sep);
}
for (guint i = 0; i < c.form_kinds.size(); ++i) {
if (c.form_kinds[i]->id() == ks) adw_combo_row_set_selected(c.form_kind_row, i);
}
for (std::size_t pos = 0; pos < opts.size();) {
const std::size_t end = std::min(opts.find(',', pos), opts.size());
sc.parse_assignment(opts.substr(pos, end - pos));
pos = end + 1;
}
} else {
sc.set("server", colon == std::string::npos ? s : s.substr(0, colon));
if (colon != std::string::npos) sc.set("port", s.substr(colon + 1));
}
c.form_cfg.write(sc);
gtk_editable_set_text(GTK_EDITABLE(c.form_name), "probe-host");
gtk_editable_set_text(GTK_EDITABLE(c.form_note), "made by the new-host probe");
adw_spin_row_set_value(c.form_buffer, 0.5);
adw_switch_row_set_active(c.form_relative, TRUE);
// 9/9 for UDPS: the form's row titles are mnemonics (`Source _type`, `_Server`, `_Port`, ...). The
// total follows the kind's row count: five rows are the chooser's, the rest the kind's own.
int underlines = row_underlines(G_OBJECT(c.form_kind_row)) +
row_underlines(G_OBJECT(c.form_name)) + row_underlines(G_OBJECT(c.form_note)) +
row_underlines(G_OBJECT(c.form_buffer)) +
row_underlines(G_OBJECT(c.form_relative));
for (GtkWidget* r : c.form_cfg.rows) underlines += row_underlines(G_OBJECT(r));
std::fprintf(stderr, "[sources] form mnemonics=%d/%zu\n", underlines,
5 + c.form_cfg.rows.size());
std::fprintf(stderr,
"[sources] form: name='%s' note='%s' samples_m=%.6g relative=%d "
"connect_sensitive=%d\n",
gtk_editable_get_text(GTK_EDITABLE(c.form_name)),
gtk_editable_get_text(GTK_EDITABLE(c.form_note)),
adw_spin_row_get_value(c.form_buffer),
adw_switch_row_get_active(c.form_relative) ? 1 : 0,
gtk_widget_get_sensitive(c.form_connect) ? 1 : 0);
g_signal_emit_by_name(c.form_connect, "clicked");
report_pick(c);
return G_SOURCE_REMOVE;
}
// `UDPSCOPE_PROBE_EDITHOST=host:port` opens the existing configuration's form, changes its
// settings and saves: the path a user reaches by clicking the row's pencil.
if (const char* spec = g_getenv("UDPSCOPE_PROBE_EDITHOST")) {
const std::string want = spec;
int idx = -1;
for (std::size_t i = 0; i < c.known.size(); ++i) {
if (c.known[i].host + ":" + std::to_string(c.known[i].port) == want) {
idx = static_cast<int>(i);
break;
}
}
if (idx < 0) {
std::fprintf(stderr, "[sources] edit: no configuration for '%s'\n", want.c_str());
std::fflush(stderr);
chooser_finish(c, false);
return G_SOURCE_REMOVE;
}
open_form(c, idx);
// The reading is the *pre-fill*: an edit form that opens empty is the defect this asserts
// against, and the button's label says which mode it is in.
std::fprintf(stderr,
"[sources] edit form: name='%s' note='%s' samples_m=%.6g relative=%d button='%s'\n",
gtk_editable_get_text(GTK_EDITABLE(c.form_name)),
gtk_editable_get_text(GTK_EDITABLE(c.form_note)),
adw_spin_row_get_value(c.form_buffer),
adw_switch_row_get_active(c.form_relative) ? 1 : 0,
gtk_button_get_label(GTK_BUTTON(c.form_connect)));
gtk_editable_set_text(GTK_EDITABLE(c.form_note), "edited by the probe");
adw_spin_row_set_value(c.form_buffer, 0.25);
adw_switch_row_set_active(c.form_relative, FALSE);
// `UDPSCOPE_PROBE_EDITNAME=new-name` also renames it, which is a *move*: the old file must not
// survive, or the chooser lists the same session twice.
if (const char* rename_to = g_getenv("UDPSCOPE_PROBE_EDITNAME")) {
gtk_editable_set_text(GTK_EDITABLE(c.form_name), rename_to);
}
g_signal_emit_by_name(c.form_connect, "clicked");
report_pick(c);
return G_SOURCE_REMOVE;
}
// Check the source list rather than the row count: `gtk_list_box_append` auto-wraps any
// non-row widget in a `GtkListBoxRow`, so `gtk_list_box_get_row_at_index` returns non-null
// even when the only list child is the empty-state placeholder label.
if (c.known.empty()) {
std::fprintf(stderr, "[sources] picked nothing: the list is empty\n");
std::fflush(stderr);
chooser_finish(c, false);
return G_SOURCE_REMOVE;
}
GtkListBoxRow* row = gtk_list_box_get_row_at_index(GTK_LIST_BOX(c.list), 0);
// `row-activated`, not `gtk_widget_activate()`: this project has already learned that
// activating a widget takes a path a real click does not (a `GtkButton` holds for its 250 ms
// ACTIVATE_TIMEOUT and then emits into a widget a rebuild has destroyed).
g_signal_emit_by_name(c.list, "row-activated", row);
report_pick(c);
return G_SOURCE_REMOVE;
}
} // namespace
void apply_stored_settings(Config& cfg) {
const std::string want = active_configuration(cfg.session_host(), cfg.session_port());
if (want.empty()) return;
LayoutState st;
if (!load_layout(configuration_path(cfg.session_host(), cfg.session_port(), want), st)) return;
// 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_samples_specified && st.buffer_samples > 0) cfg.buffer_samples = st.buffer_samples;
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_samples_specified && st.buffer_samples > 0) || relative ||
st.capture_packets > 0;
if (applied) {
std::fprintf(stderr,
"[sources] settings from configuration '%s': samples=%llu relative=%d capture=%u\n",
want.c_str(), static_cast<unsigned long long>(cfg.buffer_samples),
cfg.relative_time ? 1 : 0, cfg.capture_packets);
std::fflush(stderr);
}
}
namespace {
/// The loading window and its builder. File scope because its life spans two calls made from two
/// translation units -- `main()` raises it, `run_ui()` takes it down once the real window is up --
/// and there is exactly one of it: the gap it covers happens once per run.
GtkWidget* g_loading_window = nullptr;
GtkBuilder* g_loading_ui = nullptr;
/// Runs the default main context for `ms`, the chooser's nested-loop idiom again. Pumping pending
/// events with `g_main_context_iteration()` is not enough: a window needs a frame-clock cycle to be
/// mapped and drawn, and the frame clock only ticks while a loop runs.
void pump_main_context(unsigned ms) {
GMainLoop* loop = g_main_loop_new(nullptr, FALSE);
g_timeout_add(
ms,
+[](gpointer user) {
g_main_loop_quit(static_cast<GMainLoop*>(user));
return G_SOURCE_REMOVE;
},
loop);
g_main_loop_run(loop);
g_main_loop_unref(loop);
}
} // namespace
void loading_show(const Config& cfg) {
if (g_loading_window != nullptr) return;
g_loading_ui = gtk_builder_new_from_resource("/org/udpscope/ui/loading.ui");
g_loading_window = GTK_WIDGET(gtk_builder_get_object(g_loading_ui, "loading_window"));
GtkWidget* title = GTK_WIDGET(gtk_builder_get_object(g_loading_ui, "loading_title"));
GtkWidget* detail = GTK_WIDGET(gtk_builder_get_object(g_loading_ui, "loading_detail"));
if (g_loading_window == nullptr || title == nullptr || detail == nullptr) {
std::fprintf(stderr, "[sources] loading ui: loading.ui is missing an id this code reads\n");
std::fflush(stderr);
g_loading_window = nullptr;
g_clear_object(&g_loading_ui);
return;
}
// The wording names the *wait*, not the destination: "connecting" with nothing else said is what
// this window was accused of being, and the buffer size is the number that explains the ten
// seconds.
std::string head;
std::string sub;
if (cfg.open_waveform.empty()) {
SourceKind* kind = cfg.source_kind != "udps" ? find_kind(cfg.source_kind) : nullptr;
if (kind != nullptr) {
head = "Connecting to " + kind->display_name() + " " + kind->identity(cfg.source_config());
} else {
head = "Connecting to " + cfg.server + ":" + std::to_string(cfg.port);
if (!cfg.multicast.empty()) head += " (" + cfg.multicast + ")";
}
sub = "planning the buffer: " + format_samples(buffer_ticks(cfg.buffer_samples)) +
" samples per signal, " + format_bytes(cfg.memory_limit_mb * 1024u * 1024u) +
" memory limit";
} else {
const std::size_t slash = cfg.open_waveform.rfind('/');
head = "Opening " + (slash == std::string::npos ? cfg.open_waveform
: cfg.open_waveform.substr(slash + 1));
sub = "reading the recording";
}
gtk_label_set_text(GTK_LABEL(title), head.c_str());
gtk_label_set_text(GTK_LABEL(detail), sub.c_str());
gtk_window_present(GTK_WINDOW(g_loading_window));
// Painted *here*, before the caller blocks. Everything this window covers -- the buffer
// allocation, the first CONFIG, a recording read -- runs on a thread that never returns to a main
// loop, so a window presented and not yet drawn would be an empty rectangle for the whole wait,
// which is the defect this is meant to fix rather than restate.
pump_main_context(180);
std::fprintf(stderr, "[sources] loading: title='%s' detail='%s'\n", head.c_str(), sub.c_str());
std::fflush(stderr);
}
void loading_hide() {
if (g_loading_window == nullptr) return;
GtkWidget* w = g_loading_window;
g_loading_window = nullptr; // before the destroy, as the chooser's own close does
gtk_window_destroy(GTK_WINDOW(w));
// The window belonged to the builder's object list, so the builder goes after the destroy.
g_clear_object(&g_loading_ui);
std::fprintf(stderr, "[sources] loading: closed\n");
std::fflush(stderr);
}
bool choose_source(Config& cfg) {
const std::vector<KnownSource> known = known_sources();
// The discovery half, checkable with no display at all: this returns before `gtk_init()`, so
// it asserts what the list found without needing an X server or a GL context. False means the
// caller exits 0, which is what "list and exit" wants.
if (g_getenv("UDPSCOPE_PROBE_SOURCES") != nullptr) {
report_sources(known);
return false;
}
// `adw_init()` rather than `gtk_init()`: it does everything the latter does and also
// installs libadwaita's stylesheet, which the shortcut overview's `AdwShortcutsDialog`
// is drawn with. Both are idempotent, so `run_ui()` calling it again costs nothing.
adw_init();
Chooser c;
c.cfg = &cfg;
// The frame is a `.ui` file; the rows are not, because a source row and a waveform row are data and are
// rebuilt whenever the known hosts or the recent files change. `make test-ui` checks the file's types
// against the GIR; this checks that the code and the file still agree on the names, which nothing else
// can -- a missing id would otherwise open a window with a hole in it, or crash on the first read.
c.ui = gtk_builder_new_from_resource("/org/udpscope/ui/source-chooser.ui");
c.window = GTK_WIDGET(gtk_builder_get_object(c.ui, "chooser_window"));
c.page_stack = GTK_WIDGET(gtk_builder_get_object(c.ui, "page_stack"));
c.switcher = GTK_WIDGET(gtk_builder_get_object(c.ui, "page_switcher"));
c.list = GTK_WIDGET(gtk_builder_get_object(c.ui, "sources_list"));
c.wave_list = GTK_WIDGET(gtk_builder_get_object(c.ui, "waves_list"));
c.prefs_button = GTK_WIDGET(gtk_builder_get_object(c.ui, "prefs_button"));
c.new_host = GTK_WIDGET(gtk_builder_get_object(c.ui, "new_host_button"));
c.waves_open = GTK_WIDGET(gtk_builder_get_object(c.ui, "waves_open"));
c.cancel_button = GTK_WIDGET(gtk_builder_get_object(c.ui, "cancel_button"));
c.help_button = GTK_WIDGET(gtk_builder_get_object(c.ui, "help_menu_button"));
c.sources_head = GTK_WIDGET(gtk_builder_get_object(c.ui, "sources_head"));
c.waves_actions = GTK_WIDGET(gtk_builder_get_object(c.ui, "waves_actions"));
if (c.window == nullptr || c.page_stack == nullptr || c.switcher == nullptr || c.list == nullptr ||
c.wave_list == nullptr || c.prefs_button == nullptr || c.new_host == nullptr ||
c.waves_open == nullptr || c.cancel_button == nullptr || c.help_button == nullptr ||
c.sources_head == nullptr || c.waves_actions == nullptr) {
std::fprintf(stderr, "[sources] chooser ui: source-chooser.ui is missing an id this code reads\n");
std::fflush(stderr);
return false;
}
adw_view_switcher_bar_set_stack(ADW_VIEW_SWITCHER_BAR(c.switcher), ADW_VIEW_STACK(c.page_stack));
// The help menu's rows are the window's own `menu_button()`/`menu_toggle()`, so its popover wants
// the same stylesheet the overflow menu is drawn with (`.udpscope-menu`, which trims a row to a
// menu row rather than Adwaita's 34 px button). The app installs it in `on_activate()`, which is
// *after* this dialog, so it has to be installed here first; `install_css()` is idempotent, so
// the later call is the no-op it reads as.
ui_impl::install_css();
build_chooser_help(c);
// The chooser's own "(i)" layer and its own tour, both over the widgets the block above resolved.
// The tour's overlay wraps the whole window, so it is built before the window is presented.
chooser_help_sync(c);
build_chooser_tour(c);
g_signal_connect(c.list, "row-activated", G_CALLBACK(on_row_activated), &c);
g_signal_connect(c.wave_list, "row-activated", G_CALLBACK(on_wave_activated), &c);
g_signal_connect(c.waves_open, "clicked", G_CALLBACK(on_wave_open_clicked), &c);
g_signal_connect(c.new_host, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
open_form(*static_cast<Chooser*>(user));
}),
&c);
g_signal_connect(c.cancel_button, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
chooser_finish(*static_cast<Chooser*>(user), false);
}),
&c);
// 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.
g_signal_connect(c.prefs_button, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
open_chooser_preferences(*static_cast<Chooser*>(user));
}),
&c);
chooser_populate(c);
// The other page's rows too: fills the sources, and this is the pair of it.
chooser_populate_waves(c);
if (c.known.empty()) gtk_widget_grab_focus(c.new_host);
g_signal_connect(c.window, "close-request", G_CALLBACK(on_chooser_close), &c);
GtkEventController* keys = gtk_event_controller_key_new();
g_signal_connect(keys, "key-pressed", G_CALLBACK(on_chooser_key), &c);
gtk_widget_add_controller(c.window, keys);
gtk_window_present(GTK_WINDOW(c.window));
// Keep the timeout id so it can be cancelled if the chooser finishes before it fires. A row
// clicked before UDPSCOPE_PROBE_PICK_MS elapses would otherwise leave an armed source on the
// default main context pointing at the now-gone stack frame.
if (const char* env = g_getenv("UDPSCOPE_PROBE_PICK_MS")) {
c.pick_source = g_timeout_add(static_cast<guint>(std::atoi(env)), pick_probe, &c);
}
// A first run gets the tour here, on this window, before a source is picked: the chooser is the
// surface a first run meets first, and the window's own auto-start is unreachable until this call
// has returned with a source. 1.5 s is the window's own delay and for its reason -- the window has
// to be up and laid out before anything points at it -- and the walk's own finish sets
// `tour_pending`, which is how it continues into the application's window.
if (!load_settings().tour_seen) {
c.tour_source = g_timeout_add(1500, chooser_tour_autostart, &c);
}
// `UDPSCOPE_PROBE_CHOOSER_TOUR_MS`: schedule the tour probe *after* the auto-start above, so the
// reading is of a tour the run itself began rather than one the probe started.
if (const char* env = g_getenv("UDPSCOPE_PROBE_CHOOSER_TOUR_MS")) {
c.tour_probe_source = g_timeout_add(static_cast<guint>(std::atoi(env)), chooser_tour_probe, &c);
}
c.loop = g_main_loop_new(nullptr, FALSE);
g_main_loop_run(c.loop);
g_main_loop_unref(c.loop);
c.loop = nullptr;
if (c.pick_source != 0) g_source_remove(c.pick_source);
return c.chosen;
}
} // namespace udpscope