// SPDX-License-Identifier: MIT // SPDX-FileCopyrightText: 2026 Martino Ferrari #include "source_dialog.hpp" #include "layout_store.hpp" #include #include #include #include #include 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; } /// One line per known source on stderr, in the style of the probes in `ui.cpp`. void report_sources(const std::vector& known) { std::fprintf(stderr, "[sources] %zu known:", known.size()); for (const KnownSource& s : known) { std::fprintf(stderr, " %s:%u plots=%d signals=%d", s.host.c_str(), s.port, s.rows, s.signals); 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 known; /// 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. GtkWidget* form = nullptr; GtkWidget* form_server = nullptr; GtkWidget* form_port = nullptr; GtkWidget* form_group = nullptr; GtkWidget* form_iface = nullptr; GtkWidget* form_connect = nullptr; /// `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; }; 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) { GtkWidget* w = c.form; c.form = nullptr; // before the destroy, so the `close-request` handler cannot re-enter gtk_window_destroy(GTK_WINDOW(w)); } if (c.window != nullptr) { GtkWidget* w = c.window; c.window = nullptr; // before the destroy: `close-request` must not re-enter this gtk_window_destroy(GTK_WINDOW(w)); } if (c.loop != nullptr) g_main_loop_quit(c.loop); } void chooser_populate(Chooser& c); void on_row_activated(GtkListBox*, GtkListBoxRow* row, gpointer user) { Chooser& c = *static_cast(user); const int idx = GPOINTER_TO_INT(g_object_get_data(G_OBJECT(row), "source-index")); if (idx < 0 || static_cast(idx) >= c.known.size()) return; apply_source(*c.cfg, c.known[static_cast(idx)]); chooser_finish(c, true); } void on_forget_clicked(GtkButton* button, gpointer user) { Chooser& c = *static_cast(user); const int idx = GPOINTER_TO_INT(g_object_get_data(G_OBJECT(button), "source-index")); if (idx < 0 || static_cast(idx) >= c.known.size()) return; forget_source(c.known[static_cast(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(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); 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); 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.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* forget = gtk_button_new_from_icon_name("window-close-symbolic"); gtk_widget_set_tooltip_text(forget, "Forget this source (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(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) { if (c.form == nullptr) return; GtkWidget* w = c.form; c.form = nullptr; // before the close, so the handler cannot re-enter gtk_window_close(GTK_WINDOW(w)); } void on_form_server_changed(GtkEditable* entry, gpointer user) { Chooser& c = *static_cast(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. gtk_widget_set_sensitive(c.form_connect, gtk_entry_buffer_get_length( gtk_entry_get_buffer(GTK_ENTRY(entry))) > 0); } void on_form_connect(GtkButton*, gpointer user) { Chooser& c = *static_cast(user); const char* server = gtk_editable_get_text(GTK_EDITABLE(c.form_server)); if (server == nullptr || server[0] == '\0') return; c.cfg->server = server; c.cfg->port = static_cast( gtk_spin_button_get_value_as_int(GTK_SPIN_BUTTON(c.form_port))); c.cfg->multicast = gtk_editable_get_text(GTK_EDITABLE(c.form_group)); c.cfg->interface_addr = gtk_editable_get_text(GTK_EDITABLE(c.form_iface)); form_close(c); chooser_finish(c, true); } /// A labelled row for the form's grid. GtkWidget* form_field(GtkWidget* grid, int row, const char* label, GtkWidget* widget) { GtkWidget* l = gtk_label_new(label); gtk_widget_set_halign(l, GTK_ALIGN_END); gtk_grid_attach(GTK_GRID(grid), l, 0, row, 1, 1); gtk_widget_set_hexpand(widget, TRUE); gtk_grid_attach(GTK_GRID(grid), widget, 1, row, 1, 1); return widget; } void open_form(Chooser& c) { if (c.form != nullptr) { gtk_window_present(GTK_WINDOW(c.form)); return; } c.form = gtk_window_new(); gtk_window_set_title(GTK_WINDOW(c.form), "New host"); gtk_window_set_transient_for(GTK_WINDOW(c.form), GTK_WINDOW(c.window)); gtk_window_set_modal(GTK_WINDOW(c.form), TRUE); GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 12); gtk_widget_set_margin_start(box, 12); gtk_widget_set_margin_end(box, 12); gtk_widget_set_margin_top(box, 12); gtk_widget_set_margin_bottom(box, 12); gtk_window_set_child(GTK_WINDOW(c.form), box); GtkWidget* grid = gtk_grid_new(); gtk_grid_set_row_spacing(GTK_GRID(grid), 6); gtk_grid_set_column_spacing(GTK_GRID(grid), 12); gtk_box_append(GTK_BOX(box), grid); c.form_server = form_field(grid, 0, "Server", gtk_entry_new()); gtk_editable_set_text(GTK_EDITABLE(c.form_server), "127.0.0.1"); // A spin button holds no decimals by default, which is right for a port and wrong for every // other numeric field in this project -- see the math dialog's fields. c.form_port = form_field(grid, 1, "Port", gtk_spin_button_new_with_range(1, 65535, 1)); gtk_spin_button_set_value(GTK_SPIN_BUTTON(c.form_port), 8000); c.form_group = form_field(grid, 2, "Multicast group", gtk_entry_new()); gtk_entry_set_placeholder_text(GTK_ENTRY(c.form_group), "empty = unicast"); c.form_iface = form_field(grid, 3, "Interface", gtk_entry_new()); gtk_entry_set_placeholder_text(GTK_ENTRY(c.form_iface), "empty = default route"); GtkWidget* actions = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6); gtk_widget_set_halign(actions, GTK_ALIGN_END); GtkWidget* cancel = gtk_button_new_with_label("Cancel"); g_signal_connect(cancel, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) { form_close(*static_cast(user)); }), &c); c.form_connect = gtk_button_new_with_label("Connect"); g_signal_connect(c.form_connect, "clicked", G_CALLBACK(on_form_connect), &c); gtk_box_append(GTK_BOX(actions), cancel); gtk_box_append(GTK_BOX(actions), c.form_connect); gtk_box_append(GTK_BOX(box), actions); g_signal_connect(c.form_server, "changed", G_CALLBACK(on_form_server_changed), &c); // `close-request` clears the pointer, and only a *close* emits it: `gtk_window_destroy()` does // not, which is how the math dialog once kept presenting a finalized window. g_signal_connect(c.form, "close-request", G_CALLBACK(+[](GtkWindow*, gpointer user) { static_cast(user)->form = nullptr; return FALSE; }), &c); gtk_window_present(GTK_WINDOW(c.form)); gtk_widget_grab_focus(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) { std::fprintf(stderr, "[sources] picked %s:%u multicast=%s interface=%s\n", c.cfg->server.c_str(), c.cfg->port, c.cfg->multicast.c_str(), c.cfg->interface_addr.c_str()); std::fflush(stderr); } gboolean on_chooser_close(GtkWindow*, gpointer user) { Chooser& c = *static_cast(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(user), false); return TRUE; } /// `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`. gboolean pick_probe(gpointer user) { Chooser& c = *static_cast(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; // `UDPSCOPE_PROBE_NEWHOST=host:port` drives the form instead of a row. 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) { gtk_spin_button_set_value(GTK_SPIN_BUTTON(c.form_port), std::atof(s.c_str() + colon + 1)); } std::fprintf(stderr, "[sources] form open, connect_sensitive=%d\n", 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; } // 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 bool choose_source(Config& cfg) { const std::vector 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; } gtk_init(); Chooser c; c.cfg = &cfg; c.window = gtk_window_new(); gtk_window_set_title(GTK_WINDOW(c.window), "udpscope \xe2\x80\x94 choose a source"); gtk_window_set_default_size(GTK_WINDOW(c.window), 420, 320); GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); gtk_window_set_child(GTK_WINDOW(c.window), box); GtkWidget* scroller = gtk_scrolled_window_new(); gtk_widget_set_vexpand(scroller, TRUE); gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(scroller), GTK_POLICY_NEVER, GTK_POLICY_AUTOMATIC); c.list = gtk_list_box_new(); gtk_list_box_set_activate_on_single_click(GTK_LIST_BOX(c.list), TRUE); g_signal_connect(c.list, "row-activated", G_CALLBACK(on_row_activated), &c); gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(scroller), c.list); gtk_box_append(GTK_BOX(box), scroller); GtkWidget* actions = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6); gtk_widget_set_margin_start(actions, 12); gtk_widget_set_margin_end(actions, 12); gtk_widget_set_margin_top(actions, 12); gtk_widget_set_margin_bottom(actions, 12); gtk_widget_set_halign(actions, GTK_ALIGN_END); GtkWidget* new_host = gtk_button_new_with_label("New host\xe2\x80\xa6"); g_signal_connect(new_host, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) { open_form(*static_cast(user)); }), &c); GtkWidget* cancel = gtk_button_new_with_label("Cancel"); g_signal_connect(cancel, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) { chooser_finish(*static_cast(user), false); }), &c); gtk_box_append(GTK_BOX(actions), new_host); gtk_box_append(GTK_BOX(actions), cancel); gtk_box_append(GTK_BOX(box), actions); chooser_populate(c); if (c.known.empty()) gtk_widget_grab_focus(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(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