the source chooser's list
One row per stored session, titled by its server -- the host the file is named after and the one CONFIG comes from -- with the group as subtitle. A forget repopulates rather than removing one row, because the rows carry their index into the list and dropping one renumbers the rest.
This commit is contained in:
1 parent
0be3feecc5
commit
b18b8a5b04
1 file changed
+206
-3
+206
-3
@@ -8,6 +8,7 @@
|
|||||||
#include <gtk/gtk.h>
|
#include <gtk/gtk.h>
|
||||||
|
|
||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
|
#include <cstdlib>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
@@ -17,7 +18,7 @@ namespace {
|
|||||||
/// A chooser row's source becomes the connection to open. The interface comes along with the
|
/// 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
|
/// 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.
|
/// 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.server = src.host;
|
||||||
cfg.port = src.port;
|
cfg.port = src.port;
|
||||||
cfg.multicast = src.multicast;
|
cfg.multicast = src.multicast;
|
||||||
@@ -39,6 +40,155 @@ void report_sources(const std::vector<KnownSource>& known) {
|
|||||||
std::fflush(stderr);
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
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<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;
|
||||||
|
apply_source(*c.cfg, c.known[static_cast<std::size_t>(idx)]);
|
||||||
|
chooser_finish(c, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
// 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<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);
|
||||||
|
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<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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `UDPSCOPE_PROBE_PICK_MS`: activate the first row, the way a click does.
|
||||||
|
gboolean pick_probe(gpointer user) {
|
||||||
|
Chooser& c = *static_cast<Chooser*>(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
|
} // namespace
|
||||||
|
|
||||||
bool choose_source(Config& cfg) {
|
bool choose_source(Config& cfg) {
|
||||||
@@ -53,8 +203,61 @@ bool choose_source(Config& cfg) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
gtk_init();
|
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<Chooser*>(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<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;
|
||||||
|
return c.chosen;
|
||||||
}
|
}
|
||||||
|
|
||||||
} // namespace udpscope
|
} // namespace udpscope
|
||||||
Reference in new issue
Block a user