diff --git a/src/source_dialog.cpp b/src/source_dialog.cpp index 2c3b529..e28a876 100644 --- a/src/source_dialog.cpp +++ b/src/source_dialog.cpp @@ -8,6 +8,7 @@ #include #include +#include #include #include @@ -17,7 +18,7 @@ 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. -[[maybe_unused]] void apply_source(Config& cfg, const KnownSource& src) { +void apply_source(Config& cfg, const KnownSource& src) { cfg.server = src.host; cfg.port = src.port; cfg.multicast = src.multicast; @@ -39,6 +40,155 @@ void report_sources(const std::vector& known) { 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; +}; + +void chooser_finish(Chooser& c, bool chosen) { + c.chosen = chosen; + 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); + // Not a `GtkListBoxRow`, so it cannot be activated into a source that does not exist. + 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); + } +} + +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. +gboolean pick_probe(gpointer user) { + Chooser& c = *static_cast(user); + // 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); + if (row == nullptr) { + std::fprintf(stderr, "[sources] picked nothing: the list is empty\n"); + std::fflush(stderr); + chooser_finish(c, false); + return G_SOURCE_REMOVE; + } + // `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); + 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); + return G_SOURCE_REMOVE; +} + } // namespace bool choose_source(Config& cfg) { @@ -53,8 +203,61 @@ bool choose_source(Config& cfg) { } gtk_init(); - (void)cfg; - return false; + + 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"); + 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)); + + if (const char* env = g_getenv("UDPSCOPE_PROBE_PICK_MS")) { + 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; + return c.chosen; } } // namespace udpscope