ui: a badge bar with a Trigger badge, and the trigger form in the slide-over

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Martino FerrariandClaude Opus 4.6 committed 2026-11-02 20:12:16 +01:00
1 parent a40863fa45
commit 5fb479bd69
9 files changed
+215 -7

No files matched your search

+1
View File
@@ -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
+10
View File
@@ -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.
+14 -4
View File
@@ -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<guint>(std::atoi(env)), slideover_probe, &app);
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_BADGES_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), badges_probe, &app);
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_SEARCH_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), search_probe, &app);
}
+26
View File
@@ -150,6 +150,32 @@
</child>
</object>
</child>
<child>
<!-- Setup is reached by the object it configures: a badge per signal on the left
(step 2), the system badges (Trigger, Source) on the right. A fixed height, so
the plot above it never moves when badges come and go. -->
<object class="GtkBox" id="badge_bar">
<property name="orientation">horizontal</property>
<property name="spacing">6</property>
<property name="height-request">36</property>
<property name="margin-start">6</property>
<property name="margin-end">6</property>
<child>
<object class="GtkBox" id="badge_signals">
<property name="orientation">horizontal</property>
<property name="spacing">6</property>
<property name="hexpand">1</property>
</object>
</child>
<child>
<object class="GtkBox" id="badge_system">
<property name="orientation">horizontal</property>
<property name="spacing">6</property>
<property name="halign">end</property>
</object>
</child>
</object>
</child>
<child>
<object class="GtkBox" id="status_row">
<property name="orientation">horizontal</property>
+27
View File
@@ -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
+120
View File
@@ -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 <cstdio>
#include <cstring>
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<Badge*>(user);
// The App is reached through the badge's button: the badges live inside App, so the pointer is stable.
App* app = static_cast<App*>(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<App*>(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
+10 -1
View File
@@ -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<App*>(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
+2 -2
View File
@@ -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) {
+5
View File
@@ -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