diff --git a/CMakeLists.txt b/CMakeLists.txt index a65da52..db2b8f6 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -273,6 +273,7 @@ add_executable(udpscope src/ui_layout.cpp src/ui_source.cpp src/ui_slideover.cpp + src/ui_badges.cpp src/ui_trigger.cpp src/ui_signals.cpp src/ui_context_help.cpp diff --git a/docs/claude/verification.md b/docs/claude/verification.md index 1b5fe08..502315c 100644 --- a/docs/claude/verification.md +++ b/docs/claude/verification.md @@ -429,6 +429,16 @@ precedent, not an exception. read `click_inside_closes=0 still_open=1 click_outside_closes=1 esc_closes=1 closed=1`. For a screenshot add `UDPSCOPE_PROBE_SLIDEOVER_HOLD=1`, which leaves the page open after line 1 (run with `GSK_RENDERER=cairo` and `import -window root`): the panel must be drawn over the traces, opaque. +- `UDPSCOPE_PROBE_BADGES_MS=2000` with `UDPSCOPE_EXIT_AFTER_MS=5000` and `UDPSCOPE_ENABLE_ALL_MS=500` -- the badge + bar under the plot. One `[badges]` line, which must read `table_bad=0 fixed=1 open_marked=1 page_open=1 + closed_unmarked=1`: `table_bad` counts the rows of the 16-input `trigger_badge_state()` table the probe + gets wrong; `fixed=1` is the bar's height being the same (and at least 36 px) before and after the + `trigger` page was opened and closed -- `0` means the bar's height depends on content and the plot above it + moves; `open_marked`/`closed_unmarked` are the `udpscope-badge-open` class following the slide-over's page. + `trigger_state=` is the state word as read then (`off` with the trigger toggle off). **The trigger form is + a slide-over page**, so a probe that reads one of its widgets' *allocation* (a width, a height) must + `slideover_open(*a, "trigger")` and read after the 120 ms reveal, as `exit_probe_step1` and `force_probe` do; + a sensitivity, a visibility or a value needs no open page. - `UDPSCOPE_PROBE_SOURCE_KINDS=2000` with `UDPSCOPE_EXIT_AFTER_MS=5000`, run as `--source probe --source-opt rate=500` -- the source-plugin path end to end. The variable's presence also registers the test-only `probe` kind (a synthetic sine with one panel), so it is the only way a run sees that kind; its value is the delay in ms. diff --git a/src/ui.cpp b/src/ui.cpp index a3dcaf5..e28d78d 100644 --- a/src/ui.cpp +++ b/src/ui.cpp @@ -3380,6 +3380,7 @@ gboolean tick(gpointer user) { if (app->window == nullptr) return G_SOURCE_REMOVE; note_panel_controls(*app); sync_source_panels(*app); + sync_badge_bar(*app); // The tour, re-measured every frame: the target's allocation is 0 on the frame it is first shown // (see the allocation bullets in `docs/claude/invariants-trigger-and-pinning.md`) and the widget // moves under the spotlight on a window resize, so a one-shot measurement is wrong both ways. @@ -5021,13 +5022,17 @@ void on_activate(GtkApplication* application, gpointer user) { GtkWidget* side_switcher = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "side_switcher")); GtkWidget* measure_page = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "measure_page")); GtkWidget* signals_page = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "signals_page")); + app->badge_bar = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "badge_bar")); + app->badge_signals = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "badge_signals")); + app->badge_system = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "badge_system")); // A missing id is the one mistake neither the build nor `GtkBuilder` sees, and every use below would // be a null pointer. if (app->window == nullptr || side_view == nullptr || events_divider == nullptr || app->plot_box == nullptr || app->plot_overlay == nullptr || app->plot_split == nullptr || app->status_badge == nullptr || app->status_label == nullptr || app->status_info_button == nullptr || - app->status_row == nullptr || + app->status_row == nullptr || app->badge_bar == nullptr || app->badge_signals == nullptr || + app->badge_system == nullptr || side == nullptr || side_stack == nullptr || side_switcher == nullptr || measure_page == nullptr || signals_page == nullptr) { return; @@ -5067,6 +5072,8 @@ void on_activate(GtkApplication* application, gpointer user) { } // The slide-over panel: an overlay child of the plot, above the GL area. build_slideover(*app); + // The badge bar reads `trigger_toggle`, which `build_toolbar()` made above. + build_badge_bar(*app); // The size the window opens at, stated in code as well as in the `.ui`: `default-width` there is the // size it *asks* for, and a window whose content has a larger minimum opens at that minimum instead -- @@ -5188,10 +5195,10 @@ void on_activate(GtkApplication* application, gpointer user) { gtk_box_append(GTK_BOX(measure_page), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL)); gtk_box_append(GTK_BOX(measure_page), build_measures_panel(*app)); - // Signals: the trigger picks a channel from the list beside it. + // The trigger form lives in the slide-over (reached by the Trigger badge); the signal list stays in the + // sidebar until the dock replaces it. app->trigger_panel = build_trigger_panel(*app); - gtk_box_append(GTK_BOX(signals_page), app->trigger_panel); - gtk_box_append(GTK_BOX(signals_page), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL)); + slideover_add_page(*app, "trigger", "Trigger", app->trigger_panel); app->signal_panel = build_signal_panel(*app); gtk_box_append(GTK_BOX(signals_page), app->signal_panel); @@ -5912,6 +5919,9 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit if (const char* env = g_getenv("UDPSCOPE_PROBE_SLIDEOVER_MS")) { g_timeout_add(static_cast(std::atoi(env)), slideover_probe, &app); } + if (const char* env = g_getenv("UDPSCOPE_PROBE_BADGES_MS")) { + g_timeout_add(static_cast(std::atoi(env)), badges_probe, &app); + } if (const char* env = g_getenv("UDPSCOPE_PROBE_SEARCH_MS")) { g_timeout_add(static_cast(std::atoi(env)), search_probe, &app); } diff --git a/src/ui/main-window.ui b/src/ui/main-window.ui index 444a0d5..a2d16b1 100644 --- a/src/ui/main-window.ui +++ b/src/ui/main-window.ui @@ -150,6 +150,32 @@ + + + + horizontal + 6 + 36 + 6 + 6 + + + horizontal + 6 + 1 + + + + + horizontal + 6 + end + + + + horizontal diff --git a/src/ui_app.hpp b/src/ui_app.hpp index f63da4d..8a58a80 100644 --- a/src/ui_app.hpp +++ b/src/ui_app.hpp @@ -130,6 +130,22 @@ struct SlideOver { }; constexpr int kSlideOverWidth = 360; +/// One badge: a button with a name and a state line. `page` is the slide-over page it opens. +struct Badge { + GtkWidget* button = nullptr; + GtkWidget* name = nullptr; + GtkWidget* state = nullptr; + std::string page; +}; +/// The badge bar under the plot (src/ui_badges.cpp). +struct BadgeBar { + GtkWidget* bar = nullptr; ///< main-window.ui `badge_bar`, fixed height + GtkWidget* signals_slot = nullptr; ///< left; empty in step 1, step 2 fills it + GtkWidget* system_slot = nullptr; ///< right: Trigger, Source + Badge trigger; + Badge source; ///< task 3 +}; + struct App { ScopeModel* model = nullptr; Acquisition* acq = nullptr; @@ -140,6 +156,11 @@ struct App { /// The `GtkOverlay` around `plot_box` (`main-window.ui`), which the slide-over panel is a child of. GtkWidget* plot_overlay = nullptr; SlideOver slideover; + /// The badge bar under the plot (`main-window.ui` `badge_bar`, with its two slots). + BadgeBar badges; + GtkWidget* badge_bar = nullptr; + GtkWidget* badge_signals = nullptr; + GtkWidget* badge_system = nullptr; /// The one GL area, drawing the `RowSet`'s figure: every row is a row of that /// figure, so there is one widget, one context and one `::render` whatever the row /// count is. @@ -1901,6 +1922,12 @@ bool slideover_escape(App& app); // closes if open; true /// x, y in the overlay's coordinates; closes unless the point is inside the panel; true when it closed. bool slideover_click_away(App& app, double x, double y); gboolean slideover_probe(gpointer user); // UDPSCOPE_PROBE_SLIDEOVER_MS +/// The badge bar (`src/ui_badges.cpp`): setup is reached by the object it configures. +/// The Trigger badge's state word. Pure, so the probe can assert the whole table. +const char* trigger_badge_state(bool enabled, bool armed, bool pending, bool has_event); +void build_badge_bar(App& app); // after build_slideover and after the toolbar (it reads trigger_toggle) +void sync_badge_bar(App& app); // per tick and on every slide-over open/close; only writes what changed +gboolean badges_probe(gpointer user); // UDPSCOPE_PROBE_BADGES_MS gboolean source_kinds_probe_report(gpointer user); gboolean source_kinds_probe(gpointer user); /// MCC E-1608 end to end against `tools/mock_e1608.py`: the stream, the calibration, the panels and diff --git a/src/ui_badges.cpp b/src/ui_badges.cpp new file mode 100644 index 0000000..1ac2830 --- /dev/null +++ b/src/ui_badges.cpp @@ -0,0 +1,120 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari + +// The badge bar under the plot: setup is reached by the object it configures. + +#include "ui_app.hpp" + +#include +#include + +namespace udpscope { +namespace ui_impl { + +const char* trigger_badge_state(bool enabled, bool armed, bool pending, bool has_event) { + if (!enabled) return "off"; + if (has_event && !pending) return "triggered"; + if (pending) return "capturing"; + return armed ? "armed" : "disarmed"; +} + +namespace { + +void set_text_if_changed(GtkWidget* label, const char* text) { + if (std::strcmp(gtk_label_get_text(GTK_LABEL(label)), text) != 0) gtk_label_set_text(GTK_LABEL(label), text); +} + +void set_open(GtkWidget* button, bool open) { + if ((gtk_widget_has_css_class(button, "udpscope-badge-open") != FALSE) == open) return; + if (open) { + gtk_widget_add_css_class(button, "udpscope-badge-open"); + } else { + gtk_widget_remove_css_class(button, "udpscope-badge-open"); + } +} + +void on_badge_clicked(GtkButton*, gpointer user) { + Badge* b = static_cast(user); + // The App is reached through the badge's button: the badges live inside App, so the pointer is stable. + App* app = static_cast(g_object_get_data(G_OBJECT(b->button), "udpscope-app")); + if (app != nullptr) slideover_toggle(*app, b->page); +} + +/// A badge: a flat pill, name on the left, state dimmed beside it. Words only. +void make_badge(App& app, Badge& b, GtkWidget* parent, const char* name, const std::string& page) { + b.page = page; + b.button = gtk_button_new(); + gtk_widget_add_css_class(b.button, "flat"); + gtk_widget_add_css_class(b.button, "udpscope-badge"); + GtkWidget* row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8); + b.name = gtk_label_new(name); + b.state = gtk_label_new(""); + gtk_widget_add_css_class(b.state, "udpscope-badge-state"); + gtk_box_append(GTK_BOX(row), b.name); + gtk_box_append(GTK_BOX(row), b.state); + gtk_button_set_child(GTK_BUTTON(b.button), row); + g_object_set_data(G_OBJECT(b.button), "udpscope-app", &app); + g_signal_connect(b.button, "clicked", G_CALLBACK(on_badge_clicked), &b); + gtk_box_append(GTK_BOX(parent), b.button); +} + +} // namespace + +void build_badge_bar(App& app) { + BadgeBar& bb = app.badges; + bb.bar = app.badge_bar; + bb.signals_slot = app.badge_signals; + bb.system_slot = app.badge_system; + make_badge(app, bb.trigger, bb.system_slot, "Trigger", "trigger"); + sync_badge_bar(app); +} + +void sync_badge_bar(App& app) { + BadgeBar& bb = app.badges; + if (bb.trigger.button == nullptr) return; + const bool enabled = app.trigger_toggle != nullptr && + gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(app.trigger_toggle)) != FALSE; + const bool armed = app.model->trigger_armed().load(std::memory_order_relaxed); + const bool pending = app.model->trigger_pending(); + const bool has_event = app.model->event() != nullptr; + set_text_if_changed(bb.trigger.state, trigger_badge_state(enabled, armed, pending, has_event)); + set_open(bb.trigger.button, slideover_page(app) == bb.trigger.page); +} + +gboolean badges_probe(gpointer user) { + App* a = static_cast(user); + // The whole table of the pure function: 16 inputs, 5 words. + struct Row { bool e, a, p, v; const char* want; }; + static const Row kRows[] = { + {false, false, false, false, "off"}, {false, false, false, true, "off"}, + {false, false, true, false, "off"}, {false, false, true, true, "off"}, + {false, true, false, false, "off"}, {false, true, false, true, "off"}, + {false, true, true, false, "off"}, {false, true, true, true, "off"}, + {true, false, false, false, "disarmed"}, {true, false, false, true, "triggered"}, + {true, false, true, false, "capturing"}, {true, false, true, true, "capturing"}, + {true, true, false, false, "armed"}, {true, true, false, true, "triggered"}, + {true, true, true, false, "capturing"}, {true, true, true, true, "capturing"}, + }; + int bad = 0; + for (const Row& r : kRows) { + if (std::strcmp(trigger_badge_state(r.e, r.a, r.p, r.v), r.want) != 0) ++bad; + } + // The geometry: fixed height whatever the state, and the open mark follows the slide-over. + const int h_closed = gtk_widget_get_height(a->badges.bar); + slideover_toggle(*a, "trigger"); + const bool marked_open = gtk_widget_has_css_class(a->badges.trigger.button, "udpscope-badge-open") != FALSE; + const bool page_open = slideover_page(*a) == "trigger"; + slideover_toggle(*a, "trigger"); + const bool marked_closed = gtk_widget_has_css_class(a->badges.trigger.button, "udpscope-badge-open") == FALSE; + const int h_after = gtk_widget_get_height(a->badges.bar); + std::fprintf(stderr, + "[badges] table_bad=%d trigger_state=%s bar_h=%d bar_h_after=%d fixed=%d open_marked=%d " + "page_open=%d closed_unmarked=%d\n", + bad, gtk_label_get_text(GTK_LABEL(a->badges.trigger.state)), h_closed, h_after, + h_closed == h_after && h_closed >= 36 ? 1 : 0, marked_open ? 1 : 0, page_open ? 1 : 0, + marked_closed ? 1 : 0); + return G_SOURCE_REMOVE; +} + +} // namespace ui_impl +} // namespace udpscope diff --git a/src/ui_probe.cpp b/src/ui_probe.cpp index fe5dc8c..1d9df6e 100644 --- a/src/ui_probe.cpp +++ b/src/ui_probe.cpp @@ -195,7 +195,8 @@ gboolean exit_probe_step3(gpointer user) { const int on_signals = page != nullptr && std::strcmp(page, "signals") == 0 ? 1 : 0; a->dbg_signal_page_current = on_signals && gtk_widget_get_first_child(a->signal_list) != nullptr ? 1 : 0; - a->dbg_trigger_sensitive = on_signals && gtk_widget_get_sensitive(a->trigger_body) ? 1 : 0; + // The trigger form is no longer on a sidebar tab, so the reading is the body's own sensitivity. + a->dbg_trigger_sensitive = gtk_widget_get_sensitive(a->trigger_body) ? 1 : 0; // `gtk_widget_get_visible` on the sidebar's GtkBox is always 1: AdwOverlaySplitView hides its // sidebar child via `gtk_widget_set_child_visible()`, not `gtk_widget_set_visible()`. The useful // reading is whether the split view is currently showing the sidebar panel. @@ -212,6 +213,7 @@ gboolean exit_probe_step3(gpointer user) { } // `dbg_window_text` is kept by `sync_window_display()`, the only place that knows the span the // window control is showing: there is no label to read it back from any more. + slideover_close(*a); // opened by step 1 for the allocations read above if (window != nullptr) gtk_window_destroy(GTK_WINDOW(window)); return G_SOURCE_REMOVE; } @@ -4030,6 +4032,9 @@ gboolean force_probe(gpointer user) { // trigger, so a second run against the same source would otherwise start armed and never read the // off case at all. gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a->trigger_toggle), FALSE); + // The buttons' widths are allocations, and the trigger form is a slide-over page: open it, and step 2 + // reads 300 ms later, past the 120 ms reveal. + slideover_open(*a, "trigger"); g_timeout_add(300, force_probe_step2, a); return G_SOURCE_REMOVE; } @@ -4178,6 +4183,10 @@ gboolean export_probe(gpointer user) { gboolean exit_probe_step1(gpointer user) { App* a = static_cast(user); + // The trigger form is a slide-over page now, and a page that is not the open one has no allocation: + // open it here, and the chain below reads its widgets 2.1 s later, long after the 120 ms reveal. Step 3 + // closes it again. Sensitivity, visibility and values need no open page; widths and heights do. + slideover_open(*a, "trigger"); // Record the panel states before the toggles are exercised. a->dbg_trigger_sensitive_default = gtk_widget_get_sensitive(a->trigger_body) ? 1 : 0; // The two [edit] buttons, in each mode: the icon is the only thing that says which mode the pane is diff --git a/src/ui_slideover.cpp b/src/ui_slideover.cpp index cb3fe2b..40beeae 100644 --- a/src/ui_slideover.cpp +++ b/src/ui_slideover.cpp @@ -140,7 +140,7 @@ bool slideover_open(App& app, const std::string& id) { } gtk_widget_set_can_target(s.revealer, TRUE); gtk_revealer_set_reveal_child(GTK_REVEALER(s.revealer), TRUE); - // task 2: sync_badge_bar(app); + sync_badge_bar(app); return true; } @@ -150,7 +150,7 @@ void slideover_close(App& app) { s.page.clear(); gtk_widget_set_can_target(s.revealer, FALSE); gtk_revealer_set_reveal_child(GTK_REVEALER(s.revealer), FALSE); - // task 2: sync_badge_bar(app); + sync_badge_bar(app); } bool slideover_toggle(App& app, const std::string& id) { diff --git a/src/ui_widgets.cpp b/src/ui_widgets.cpp index 1bf2aba..cb5931a 100644 --- a/src/ui_widgets.cpp +++ b/src/ui_widgets.cpp @@ -74,6 +74,11 @@ void install_css() { ".udpscope-slideover { background-color: @window_bg_color; border-left: 1px solid @borders;" " box-shadow: -3px 0 10px rgba(0, 0, 0, 0.35); }\n" ".udpscope-slideover-title { font-weight: bold; }\n" + // The badge bar: a badge is a pill carrying a name and a state; the one whose page is open is + // marked, as a bench scope marks the open menu's button. + ".udpscope-badge { padding: 2px 12px; min-height: 0; border-radius: 14px; }\n" + ".udpscope-badge-open { background-color: alpha(@accent_bg_color, 0.28); }\n" + ".udpscope-badge-state { opacity: 0.7; }\n" // The contextual-help "(i)": a *mark*, not a button. Adwaita's menu button is a 34x34 control // (a 24 px floor plus 5 px of padding) with a 16 px glyph, which beside a heading reads as a // second toolbar button. Measured on libadwaita 1.10: the outer `menubutton` node *and* its