diff --git a/docs/claude/probe-readings.md b/docs/claude/probe-readings.md index e350608..a8f9093 100644 --- a/docs/claude/probe-readings.md +++ b/docs/claude/probe-readings.md @@ -945,14 +945,16 @@ configuration that carries settings, is the defect this exists for — a saved h ## `[sources] waveforms` and the waveform load ``` -[sources] waveforms: 1 recent /tmp/rt.npy(ok) page=1 +[sources] chooser ui: page='sources' title='Sources' icon=yes page='waveforms' title='Waveforms' icon=yes visible='sources' switcher_wired=1 buttons=[prefs=1 new_host=1 open=1 cancel=1] +[sources] waveforms: 1 recent /tmp/rt.npy(ok) page=waveforms [sources] picked waveform /tmp/rt.npy [udpscope] waveform loaded: /tmp/rt.npy signals=4 samples=20001 span=2.000000s rate=10000 time=[-2.000000..0.000000] ``` | field | value it must read | the defect a wrong reading names | | --- | --- | --- | -| `waveforms:` | one entry per remembered export, with `(ok)` or `(missing)`, and `page=1` | the Waveforms page not built, or a stale path offered as if it were openable | +| `chooser ui:` | both pages with a `title` and an `icon=yes`, `switcher_wired=1`, and all four buttons `1` | the chooser's `.ui` losing an id: the code names the missing one and refuses the window, and this line is the frame that *did* build -- a page with `icon=NO` is one the switcher cannot draw | +| `waveforms:` | one entry per remembered export, with `(ok)` or `(missing)`, and `page=waveforms` once the page is shown | the Waveforms page not built, or a stale path offered as if it were openable | | `picked waveform` | the selected path | a pick that carries no file: `--open` is what the mode reads | | `signals=`, `samples=` | the export's `cols=` and `rows=` | a reader mis-slicing its records, or the columns bound to the wrong channels | | `span=`, `rate=`, `time=[…]` | the exported window and rate, `[-window, 0]` | the plan not covering the whole file (the tail arrives as the whole picture) or the display epoch not pinned to the file's last stamp | diff --git a/docs/claude/verification.md b/docs/claude/verification.md index f6639a2..3d3b9d2 100644 --- a/docs/claude/verification.md +++ b/docs/claude/verification.md @@ -343,7 +343,7 @@ as distinct from the fault injection below, which exercises ingest: reads as one that cannot plot anything. A file-mode run must also write **no session** for the default `127.0.0.1:8000` (`App::source_port` is 0), or it overwrites that source's saved layout. - **The chooser's Waveforms page**: `UDPSCOPE_PROBE_WAVEFORMS=1` prints - `[sources] waveforms: N recent (ok|missing) … page=1`, and `UDPSCOPE_PROBE_WAVE=` sets + `[sources] waveforms: N recent (ok|missing) … page=waveforms`, and `UDPSCOPE_PROBE_WAVE=` sets the field the file dialog's callback sets and prints `[sources] picked waveform ` — then the load line above, with no socket involved. - **A `--server` launch reads the stored configuration.** With a throwaway `XDG_CONFIG_HOME` diff --git a/src/source_dialog.cpp b/src/source_dialog.cpp index 68d0085..501ca43 100644 --- a/src/source_dialog.cpp +++ b/src/source_dialog.cpp @@ -69,10 +69,25 @@ struct Chooser { GtkWidget* window = nullptr; GtkWidget* list = nullptr; std::vector known; - /// The Waveforms page: the exports opened before, newest first, and the notebook 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. - GtkWidget* notebook = nullptr; + /// 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 waves; /// The connection form, created on demand. Members of the chooser rather than a heap struct: @@ -136,6 +151,9 @@ void chooser_finish(Chooser& c, bool chosen) { 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); } @@ -582,6 +600,34 @@ void open_chooser_preferences(Chooser& c) { /// `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(user); // Every path below returns `G_SOURCE_REMOVE`, so the source is gone the moment this returns and @@ -589,6 +635,9 @@ gboolean pick_probe(gpointer user) { // -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, @@ -611,13 +660,13 @@ gboolean pick_probe(gpointer user) { // page selected the way a click on its tab does. `UDPSCOPE_PROBE_WAVE=` is the file dialog's // answer without the dialog -- the same field, set the same way. if (g_getenv("UDPSCOPE_PROBE_WAVEFORMS") != nullptr) { - if (c.notebook != nullptr) gtk_notebook_set_current_page(GTK_NOTEBOOK(c.notebook), 1); + 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=%d\n", - static_cast(gtk_notebook_get_current_page(GTK_NOTEBOOK(c.notebook)))); + 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")) { @@ -762,88 +811,53 @@ bool choose_source(Config& cfg) { 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); + // 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)); - // Two pages under one window: the sources it can connect to, and the waveforms it can open. A - // waveform is not a source with a file in place of an address -- there is no socket and nothing - // arrives -- so it is a *page* rather than a field, which is also where its history belongs. - c.notebook = gtk_notebook_new(); - gtk_widget_set_vexpand(c.notebook, TRUE); - gtk_box_append(GTK_BOX(box), c.notebook); - - 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_notebook_append_page(GTK_NOTEBOOK(c.notebook), scroller, gtk_label_new("Sources")); - - GtkWidget* wave_page = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); - GtkWidget* wave_scroller = gtk_scrolled_window_new(); - gtk_widget_set_vexpand(wave_scroller, TRUE); - gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(wave_scroller), GTK_POLICY_NEVER, - GTK_POLICY_AUTOMATIC); - c.wave_list = gtk_list_box_new(); - gtk_list_box_set_activate_on_single_click(GTK_LIST_BOX(c.wave_list), TRUE); g_signal_connect(c.wave_list, "row-activated", G_CALLBACK(on_wave_activated), &c); - gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(wave_scroller), c.wave_list); - gtk_box_append(GTK_BOX(wave_page), wave_scroller); - GtkWidget* wave_actions = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6); - gtk_widget_set_halign(wave_actions, GTK_ALIGN_END); - gtk_widget_set_margin_top(wave_actions, 6); - gtk_widget_set_margin_bottom(wave_actions, 6); - gtk_widget_set_margin_end(wave_actions, 12); - GtkWidget* wave_open = gtk_button_new_with_label("Open\xe2\x80\xa6"); - gtk_widget_set_tooltip_text(wave_open, "choose an exported .npy to view and analyse"); - g_signal_connect(wave_open, "clicked", G_CALLBACK(on_wave_open_clicked), &c); - gtk_box_append(GTK_BOX(wave_actions), wave_open); - gtk_box_append(GTK_BOX(wave_page), wave_actions); - gtk_notebook_append_page(GTK_NOTEBOOK(c.notebook), wave_page, gtk_label_new("Waveforms")); - chooser_populate_waves(c); - - 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); - // 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. - GtkWidget* prefs = gtk_button_new_with_label("Preferences\xe2\x80\xa6"); - gtk_widget_set_tooltip_text(prefs, - "the event cache, relative time and the interface's theme -- the same " - "dialog as the main window's menu, and the only place relative time can " - "still change this run"); - g_signal_connect(prefs, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) { - open_chooser_preferences(*static_cast(user)); - }), - &c); - GtkWidget* new_host = gtk_button_new_with_label("New host\xe2\x80\xa6"); - g_signal_connect(new_host, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) { + 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(user)); }), &c); - GtkWidget* cancel = gtk_button_new_with_label("Cancel"); - g_signal_connect(cancel, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) { + g_signal_connect(c.cancel_button, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) { chooser_finish(*static_cast(user), false); }), &c); - gtk_box_append(GTK_BOX(actions), prefs); - gtk_box_append(GTK_BOX(actions), new_host); - gtk_box_append(GTK_BOX(actions), cancel); - gtk_box_append(GTK_BOX(box), actions); + // 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(user)); + }), + &c); chooser_populate(c); - if (c.known.empty()) gtk_widget_grab_focus(new_host); + // 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(); diff --git a/src/ui/source-chooser.ui b/src/ui/source-chooser.ui new file mode 100644 index 0000000..c39dd61 --- /dev/null +++ b/src/ui/source-chooser.ui @@ -0,0 +1,119 @@ + + + + + + + udpscope — choose a source + 420 + 360 + + + + + + + _Preferences… + 1 + the event cache, relative time and the interface's theme — the same dialog as the main window's menu, and the only place relative time can still change this run + + + + + _Cancel + 1 + + + + + _New host… + 1 + + + + + + + + + sources + Sources + network-transmit-receive-symbolic + + + never + + + 1 + + + + + + + + + waveforms + Waveforms + document-open-recent-symbolic + + + vertical + + + 1 + never + + + 1 + + + + + + + horizontal + 6 + end + 6 + 6 + 12 + + + _Open… + 1 + choose an exported .npy to view and analyse + + + + + + + + + + + + + 1 + + + + + + diff --git a/src/ui/udpscope-ui.gresource.xml b/src/ui/udpscope-ui.gresource.xml index 8d59d0f..1365bd6 100644 --- a/src/ui/udpscope-ui.gresource.xml +++ b/src/ui/udpscope-ui.gresource.xml @@ -13,6 +13,7 @@ preferences.ui help.ui source-form.ui + source-chooser.ui main-window.ui cursors-panel.ui