965 lines
48 KiB
C++
965 lines
48 KiB
C++
// SPDX-License-Identifier: MIT
|
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
|
|
|
#include "source_dialog.hpp"
|
|
|
|
#include "waveform.hpp"
|
|
|
|
#include "layout_store.hpp"
|
|
#include "settings.hpp"
|
|
#include "ui_prefs.hpp"
|
|
|
|
#include <adwaita.h>
|
|
#include <gtk/gtk.h>
|
|
|
|
#include <unistd.h>
|
|
|
|
#include <cstdio>
|
|
#include <cstdlib>
|
|
#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.server = src.host;
|
|
cfg.port = src.port;
|
|
cfg.multicast = src.multicast;
|
|
cfg.interface_addr = src.interface_addr;
|
|
// 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_mb > 0.0) cfg.buffer_mb = src.buffer_mb;
|
|
// 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 buffer=%.6g relative=%d capture=%u",
|
|
s.host.c_str(), s.port, s.config.c_str(), s.rows, s.signals, s.buffer_mb,
|
|
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;
|
|
AdwEntryRow* form_server = nullptr;
|
|
AdwSpinRow* form_port = nullptr;
|
|
AdwEntryRow* form_group = nullptr;
|
|
AdwEntryRow* form_iface = nullptr;
|
|
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;
|
|
|
|
};
|
|
|
|
void chooser_finish(Chooser& c, bool chosen) {
|
|
c.chosen = chosen;
|
|
// 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);
|
|
|
|
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.
|
|
const std::string title = s.host + ":" + std::to_string(s.port) + " \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_mb > 0.0) {
|
|
sub += ", buffer " + std::to_string(static_cast<int>(s.buffer_mb)) + " MB";
|
|
}
|
|
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);
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
void on_form_server_changed(GtkEditable* entry, gpointer user) {
|
|
Chooser& c = *static_cast<Chooser*>(user);
|
|
if (c.form_connect == nullptr) return;
|
|
// The only validation here. A *malformed* address is the acquisition layer's to report -- it
|
|
// already does, and the starvation notice covers a source that takes the socket and says
|
|
// nothing -- so checking it twice would be two expressions of one rule.
|
|
const char* text = gtk_editable_get_text(entry);
|
|
gtk_widget_set_sensitive(c.form_connect, text != nullptr && text[0] != '\0');
|
|
}
|
|
|
|
void on_form_connect(GtkButton*, gpointer user) {
|
|
Chooser& c = *static_cast<Chooser*>(user);
|
|
const char* server = gtk_editable_get_text(GTK_EDITABLE(c.form_server));
|
|
if (server == nullptr || server[0] == '\0') return;
|
|
const std::string host = server;
|
|
const std::uint16_t port = static_cast<std::uint16_t>(adw_spin_row_get_value(c.form_port));
|
|
const std::string group = gtk_editable_get_text(GTK_EDITABLE(c.form_group));
|
|
const std::string iface = gtk_editable_get_text(GTK_EDITABLE(c.form_iface));
|
|
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 double buffer_mb = adw_spin_row_get_value(c.form_buffer);
|
|
const bool relative = adw_switch_row_get_active(c.form_relative) != FALSE;
|
|
if (name.empty()) name = "default";
|
|
|
|
c.cfg->server = host;
|
|
c.cfg->port = port;
|
|
c.cfg->multicast = group;
|
|
c.cfg->interface_addr = iface;
|
|
// 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_mb = buffer_mb;
|
|
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_mb = buffer_mb;
|
|
st.relative_time = relative;
|
|
st.multicast = group;
|
|
st.interface_addr = iface;
|
|
|
|
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' buffer=%.6g relative=%d\n",
|
|
editing ? "saved" : "created", host.c_str(), port, name.c_str(), note.c_str(),
|
|
buffer_mb, 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) {
|
|
gtk_window_present(GTK_WINDOW(c.form));
|
|
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;
|
|
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_server = ADW_ENTRY_ROW(gtk_builder_get_object(c.form_ui, "server_row"));
|
|
c.form_port = ADW_SPIN_ROW(gtk_builder_get_object(c.form_ui, "port_row"));
|
|
c.form_group = ADW_ENTRY_ROW(gtk_builder_get_object(c.form_ui, "group_row"));
|
|
c.form_iface = ADW_ENTRY_ROW(gtk_builder_get_object(c.form_ui, "iface_row"));
|
|
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_server == nullptr || c.form_port == nullptr ||
|
|
c.form_group == nullptr || c.form_iface == 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_server), src != nullptr ? src->host.c_str() : "127.0.0.1");
|
|
adw_spin_row_set_value(c.form_port, src != nullptr ? src->port : 8000);
|
|
gtk_editable_set_text(GTK_EDITABLE(c.form_group), src != nullptr ? src->multicast.c_str() : "");
|
|
gtk_editable_set_text(GTK_EDITABLE(c.form_iface),
|
|
src != nullptr ? src->interface_addr.c_str() : "");
|
|
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, src != nullptr && src->buffer_mb > 0.0
|
|
? src->buffer_mb
|
|
: std::max(1.0, c.cfg->buffer_mb));
|
|
adw_switch_row_set_active(c.form_relative,
|
|
(src != nullptr ? src->relative_time : c.cfg->relative_time) ? TRUE
|
|
: FALSE);
|
|
|
|
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);
|
|
g_signal_connect(c.form_server, "changed", G_CALLBACK(on_form_server_changed), &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) {
|
|
static_cast<Chooser*>(user)->form = nullptr;
|
|
}),
|
|
&c);
|
|
|
|
adw_dialog_present(c.form, GTK_WIDGET(c.window));
|
|
gtk_widget_grab_focus(GTK_WIDGET(c.form_server));
|
|
}
|
|
|
|
/// 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 buffer=%.6g 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(), c.cfg->buffer_mb, 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());
|
|
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;
|
|
if (!written) {
|
|
std::fprintf(stderr, "[sources] prefs: could not write %s\n", settings_path().c_str());
|
|
std::fflush(stderr);
|
|
}
|
|
});
|
|
}
|
|
|
|
/// `UDPSCOPE_PROBE_PICK_MS`: activate the first row, the way a click does.
|
|
/// `UDPSCOPE_PROBE_NEWHOST=host:port` drives the form instead of a row. The crash surface of a
|
|
/// dialog built, filled and destroyed is as much the point as the values, the same way
|
|
/// `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, and the four buttons the code resolved. `[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::fprintf(stderr,
|
|
" visible='%s' switcher_wired=%d buttons=[prefs=%d new_host=%d open=%d cancel=%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);
|
|
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_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(':');
|
|
gtk_editable_set_text(GTK_EDITABLE(c.form_server),
|
|
colon == std::string::npos ? s.c_str() : s.substr(0, colon).c_str());
|
|
if (colon != std::string::npos) {
|
|
adw_spin_row_set_value(c.form_port, std::atof(s.c_str() + colon + 1));
|
|
}
|
|
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, 32);
|
|
adw_switch_row_set_active(c.form_relative, TRUE);
|
|
// 8/8: the form's row titles are mnemonics (`_Server`, `_Port`, `Multicast _group`, ...). The two
|
|
// buttons already asked for it; the rows did not, and drew their underscores.
|
|
std::fprintf(stderr, "[sources] form mnemonics=%d/8\n",
|
|
(row_underlines(G_OBJECT(c.form_server)) + row_underlines(G_OBJECT(c.form_port)) +
|
|
row_underlines(G_OBJECT(c.form_group)) + row_underlines(G_OBJECT(c.form_iface)) +
|
|
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))));
|
|
std::fprintf(stderr,
|
|
"[sources] form: name='%s' note='%s' buffer=%.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' buffer=%.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, 16);
|
|
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.server, cfg.port);
|
|
if (want.empty()) return;
|
|
LayoutState st;
|
|
if (!load_layout(configuration_path(cfg.server, cfg.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_mb_specified && st.buffer_mb > 0.0) cfg.buffer_mb = st.buffer_mb;
|
|
const bool relative = !cfg.relative_time_specified && st.relative_time;
|
|
if (relative) cfg.relative_time = true;
|
|
if (cfg.capture_packets == 0 && st.capture_packets > 0) cfg.capture_packets = st.capture_packets;
|
|
const bool applied = (!cfg.buffer_mb_specified && st.buffer_mb > 0.0) || relative ||
|
|
st.capture_packets > 0;
|
|
if (applied) {
|
|
std::fprintf(stderr,
|
|
"[sources] settings from configuration '%s': buffer=%.6g relative=%d capture=%u\n",
|
|
want.c_str(), cfg.buffer_mb, 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()) {
|
|
head = "Connecting to " + cfg.server + ":" + std::to_string(cfg.port);
|
|
if (!cfg.multicast.empty()) head += " (" + cfg.multicast + ")";
|
|
char buf[128];
|
|
std::snprintf(buf, sizeof(buf), "planning the %.0f MB sample buffer", cfg.buffer_mb);
|
|
sub = buf;
|
|
} 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"));
|
|
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) {
|
|
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));
|
|
|
|
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);
|
|
}
|
|
|
|
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
|