Feature/source plugins #1

Merged
martino merged 130 commits from feature/source-plugins into main 2026-11-04 07:35:21 +01:00
7 changed files with 334 additions and 9 deletions
Showing only changes of commit a40863fa45 - Show all commits

No files matched your search

+1
View File
@@ -272,6 +272,7 @@ add_executable(udpscope
src/ui_math.cpp
src/ui_layout.cpp
src/ui_source.cpp
src/ui_slideover.cpp
src/ui_trigger.cpp
src/ui_signals.cpp
src/ui_context_help.cpp
+11
View File
@@ -418,6 +418,17 @@ precedent, not an exception.
defect where the accumulation is gated on the visible tab, which would leave a gap in min/max/avg for
exactly as long as the trigger was on screen. It enables *and plots* its own signal, so it needs no other
probe ahead of it. The reading's own entry is in `probe-readings.md`.
- `UDPSCOPE_PROBE_SLIDEOVER_MS=1500` with `UDPSCOPE_EXIT_AFTER_MS=5000` (and `UDPSCOPE_ENABLE_ALL_MS=500`) -- the
slide-over panel over the plot's right edge, opened on a throw-away `probe` page, read 0.5 s later. Two
`[slideover]` lines. Line 1 must read `panel=360x<overlay height> ... edge_flush=1 full_height=1
outside_hits_plot=1 inside_hits_panel=1 ... plot_unchanged=1`: a 360 px opaque panel flush to the overlay's
right edge and as tall as it, a pick outside it landing in the plot and one inside it in the panel, and the
plot's width unchanged by the panel being open (`plot_w before=` equal to `open=`).
`outside_hits_plot=0` means the panel has an allocation wider than 360 or a transparent parent that
swallows the plot's pan, drop and hover; `plot_unchanged=0` means the panel resized the plot. Line 2 must
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_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.
+8 -1
View File
@@ -5009,6 +5009,7 @@ void on_activate(GtkApplication* application, gpointer user) {
GtkWidget* events_divider = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "events_divider"));
app->plot_box = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "plot_box"));
app->plot_split = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "plot_split"));
app->plot_overlay = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "plot_overlay"));
app->status_badge = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "status_badge"));
app->status_label = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "status_label"));
app->status_info_button = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "status_info"));
@@ -5023,7 +5024,8 @@ void on_activate(GtkApplication* application, gpointer user) {
// 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_split == nullptr || app->status_badge == 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 ||
side == nullptr || side_stack == nullptr || side_switcher == nullptr ||
@@ -5063,6 +5065,8 @@ void on_activate(GtkApplication* application, gpointer user) {
app->tour.overlay = overlay;
build_tour(*app);
}
// The slide-over panel: an overlay child of the plot, above the GL area.
build_slideover(*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 --
@@ -5905,6 +5909,9 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
if (const char* env = g_getenv("UDPSCOPE_PROBE_SIDE_TAB_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), side_tab_probe, &app);
}
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_SEARCH_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), search_probe, &app);
}
+20 -8
View File
@@ -3,7 +3,8 @@
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
<!--
The main window's skeleton: the window, the split view that puts the sidebar beside the plot, the
events pane's divider, the plot column with the status line under it, and the sidebar's tab stack.
events pane's divider, the plot column (`plot_overlay` > `plot_box`, the slide-over panel being an overlay child added in code)
with the status line under it, and the sidebar's tab stack.
The sidebar is an `AdwOverlaySplitView`, which is what a utility sidebar is in GNOME (as against
`AdwNavigationSplitView`, which is for navigation lists). Three things follow from that, and they are
@@ -124,16 +125,27 @@
<property name="orientation">vertical</property>
<property name="hexpand">1</property>
<child>
<object class="GtkBox" id="plot_box">
<property name="orientation">vertical</property>
<property name="homogeneous">1</property>
<property name="spacing">4</property>
<!-- The plot, and the slide-over panel that opens over its right edge. The panel is
one opaque overlay child placed by `halign=end` and a 360 px width, never a
full-area transparent child, which would be picked wherever its allocation is
and swallow the plot's pan, drop and hover (docs/claude/invariants-plot-and-layout.md).
The overlay is the only parent change: `plot_box` keeps its id and its content. -->
<object class="GtkOverlay" id="plot_overlay">
<property name="hexpand">1</property>
<property name="vexpand">1</property>
<child>
<object class="GtkPaned" id="plot_split">
<property name="orientation">horizontal</property>
<property name="shrink-end-child">1</property>
<object class="GtkBox" id="plot_box">
<property name="orientation">vertical</property>
<property name="homogeneous">1</property>
<property name="spacing">4</property>
<property name="hexpand">1</property>
<property name="vexpand">1</property>
<child>
<object class="GtkPaned" id="plot_split">
<property name="orientation">horizontal</property>
<property name="shrink-end-child">1</property>
</object>
</child>
</object>
</child>
</object>
+29
View File
@@ -117,6 +117,19 @@ struct SourcePanelPage {
GtkWidget* content = nullptr;
};
/// The slide-over panel's widgets and state (src/ui_slideover.cpp). `page` is the open page's id, empty when closed.
struct SlideOver {
GtkWidget* overlay = nullptr; ///< the GtkOverlay around the plot (main-window.ui `plot_overlay`)
GtkWidget* revealer = nullptr; ///< the one overlay child; halign END, valign FILL, 360 px
GtkWidget* title = nullptr; ///< GtkLabel in the panel header
GtkWidget* stack = nullptr; ///< GtkStack: one scroller per page
GtkWidget* source_switch = nullptr; ///< AdwToggleGroup, shown on a source page when there are >= 2
std::vector<std::string> source_pages;
std::string page;
bool syncing = false; ///< true while we write the toggle group ourselves
};
constexpr int kSlideOverWidth = 360;
struct App {
ScopeModel* model = nullptr;
Acquisition* acq = nullptr;
@@ -124,6 +137,9 @@ struct App {
GtkWidget* window = nullptr;
GtkWidget* plot_box = nullptr;
/// The `GtkOverlay` around `plot_box` (`main-window.ui`), which the slide-over panel is a child of.
GtkWidget* plot_overlay = nullptr;
SlideOver slideover;
/// 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.
@@ -1872,6 +1888,19 @@ gboolean stats_probe_report(gpointer user);
gboolean stats_probe(gpointer user);
gboolean side_tab_probe_report(gpointer user);
gboolean side_tab_probe(gpointer user);
/// The slide-over panel (`src/ui_slideover.cpp`): one panel at a time over the plot's right edge, a page
/// per setup surface, each page in a scroller of its own.
void build_slideover(App& app); // once, after the plot_overlay exists
bool slideover_add_page(App& app, const std::string& id, const std::string& title, GtkWidget* content);
bool slideover_has_page(const App& app, const std::string& id);
bool slideover_open(App& app, const std::string& id); // false when the id is unknown
void slideover_close(App& app);
bool slideover_toggle(App& app, const std::string& id); // open if closed or on another page, else close
const std::string& slideover_page(const App& app); // "" when closed
bool slideover_escape(App& app); // closes if open; true when it did
/// 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
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
+260
View File
@@ -0,0 +1,260 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
// The slide-over panel: one panel at a time over the plot's right edge, a page per setup surface.
// See docs/superpowers/specs/2026-11-02-ui-badges-dock-design.md.
#include "ui_app.hpp"
#include <adwaita.h>
#include <cstdio>
#include <cstdlib>
#include <cstring>
namespace udpscope {
namespace ui_impl {
namespace {
void on_close_clicked(GtkButton*, gpointer user) { slideover_close(*static_cast<App*>(user)); }
void on_source_switch(GObject* o, GParamSpec*, gpointer user) {
App* app = static_cast<App*>(user);
if (app->slideover.syncing) return;
const char* name = adw_toggle_group_get_active_name(ADW_TOGGLE_GROUP(o));
if (name != nullptr && app->slideover.page != name) slideover_open(*app, name);
}
/// Observes every press on the plot overlay, in the capture phase, and never claims the sequence: the
/// plot's own pan/zoom/drop gestures see the press exactly as they did before.
void on_press_capture(GtkGestureClick*, int, double x, double y, gpointer user) {
slideover_click_away(*static_cast<App*>(user), x, y);
}
bool is_source_page(const std::string& id) { return id.rfind("source-", 0) == 0; }
} // namespace
void build_slideover(App& app) {
SlideOver& s = app.slideover;
s.overlay = app.plot_overlay;
if (s.overlay == nullptr) return;
GtkWidget* panel = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
gtk_widget_add_css_class(panel, "udpscope-slideover");
gtk_widget_set_size_request(panel, kSlideOverWidth, -1);
gtk_widget_set_hexpand(panel, FALSE);
GtkWidget* head = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
gtk_widget_set_margin_start(head, 12);
gtk_widget_set_margin_end(head, 6);
gtk_widget_set_margin_top(head, 6);
gtk_widget_set_margin_bottom(head, 6);
s.title = gtk_label_new("");
gtk_widget_add_css_class(s.title, "udpscope-slideover-title");
gtk_label_set_xalign(GTK_LABEL(s.title), 0.0f);
gtk_widget_set_hexpand(s.title, TRUE);
gtk_label_set_ellipsize(GTK_LABEL(s.title), PANGO_ELLIPSIZE_END);
GtkWidget* close = gtk_button_new_with_label("Close");
gtk_widget_add_css_class(close, "flat");
g_signal_connect(close, "clicked", G_CALLBACK(on_close_clicked), &app);
gtk_box_append(GTK_BOX(head), s.title);
gtk_box_append(GTK_BOX(head), close);
gtk_box_append(GTK_BOX(panel), head);
// A source with several panels (the E-1608: Analog out, Digital I/O) gets a segmented switch under
// the header; it is hidden unless the open page is a source page and there are two or more.
s.source_switch = adw_toggle_group_new();
gtk_widget_set_margin_start(s.source_switch, 12);
gtk_widget_set_margin_end(s.source_switch, 12);
gtk_widget_set_margin_bottom(s.source_switch, 6);
gtk_widget_set_visible(s.source_switch, FALSE);
g_signal_connect(s.source_switch, "notify::active-name", G_CALLBACK(on_source_switch), &app);
gtk_box_append(GTK_BOX(panel), s.source_switch);
gtk_box_append(GTK_BOX(panel), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL));
s.stack = gtk_stack_new();
gtk_widget_set_vexpand(s.stack, TRUE);
gtk_box_append(GTK_BOX(panel), s.stack);
s.revealer = gtk_revealer_new();
gtk_revealer_set_transition_type(GTK_REVEALER(s.revealer), GTK_REVEALER_TRANSITION_TYPE_SLIDE_LEFT);
gtk_revealer_set_transition_duration(GTK_REVEALER(s.revealer), 120);
gtk_revealer_set_child(GTK_REVEALER(s.revealer), panel);
gtk_widget_set_halign(s.revealer, GTK_ALIGN_END);
gtk_widget_set_valign(s.revealer, GTK_ALIGN_FILL);
// Closed, it must not be a pick target for the frames of its slide-out.
gtk_widget_set_can_target(s.revealer, FALSE);
gtk_overlay_add_overlay(GTK_OVERLAY(s.overlay), s.revealer);
gtk_overlay_set_clip_overlay(GTK_OVERLAY(s.overlay), s.revealer, TRUE);
GtkGesture* click = gtk_gesture_click_new();
gtk_event_controller_set_propagation_phase(GTK_EVENT_CONTROLLER(click), GTK_PHASE_CAPTURE);
g_signal_connect(click, "pressed", G_CALLBACK(on_press_capture), &app);
gtk_widget_add_controller(s.overlay, GTK_EVENT_CONTROLLER(click));
}
bool slideover_add_page(App& app, const std::string& id, const std::string& title, GtkWidget* content) {
SlideOver& s = app.slideover;
if (s.stack == nullptr || content == nullptr || id.empty() || slideover_has_page(app, id)) {
return false;
}
// A scroller per page: pages differ in height, and one shared scroller would leave a page scrolled
// to somewhere it never asked for. It does not propagate the content's height, so the panel's
// height is the plot's and never the page's.
GtkWidget* scroller = gtk_scrolled_window_new();
gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(scroller), GTK_POLICY_NEVER, GTK_POLICY_AUTOMATIC);
gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(scroller), content);
gtk_stack_add_titled(GTK_STACK(s.stack), scroller, id.c_str(), title.c_str());
if (is_source_page(id)) {
s.source_pages.push_back(id);
AdwToggle* t = adw_toggle_new();
adw_toggle_set_name(t, id.c_str());
adw_toggle_set_label(t, title.c_str());
adw_toggle_group_add(ADW_TOGGLE_GROUP(s.source_switch), t); // takes the reference
}
return true;
}
bool slideover_has_page(const App& app, const std::string& id) {
return app.slideover.stack != nullptr &&
gtk_stack_get_child_by_name(GTK_STACK(app.slideover.stack), id.c_str()) != nullptr;
}
bool slideover_open(App& app, const std::string& id) {
SlideOver& s = app.slideover;
if (!slideover_has_page(app, id)) return false;
GtkWidget* child = gtk_stack_get_child_by_name(GTK_STACK(s.stack), id.c_str());
gtk_stack_set_visible_child(GTK_STACK(s.stack), child);
s.page = id;
GtkStackPage* sp = gtk_stack_get_page(GTK_STACK(s.stack), child);
const char* title = gtk_stack_page_get_title(sp);
gtk_label_set_text(GTK_LABEL(s.title), title != nullptr ? title : "");
const bool several = is_source_page(id) && s.source_pages.size() >= 2;
gtk_widget_set_visible(s.source_switch, several);
if (several) {
s.syncing = true;
adw_toggle_group_set_active_name(ADW_TOGGLE_GROUP(s.source_switch), id.c_str());
s.syncing = false;
}
gtk_widget_set_can_target(s.revealer, TRUE);
gtk_revealer_set_reveal_child(GTK_REVEALER(s.revealer), TRUE);
// task 2: sync_badge_bar(app);
return true;
}
void slideover_close(App& app) {
SlideOver& s = app.slideover;
if (s.revealer == nullptr || s.page.empty()) return;
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);
}
bool slideover_toggle(App& app, const std::string& id) {
if (!slideover_has_page(app, id)) return false;
if (app.slideover.page == id) {
slideover_close(app);
} else {
slideover_open(app, id);
}
return true;
}
const std::string& slideover_page(const App& app) { return app.slideover.page; }
bool slideover_escape(App& app) {
if (app.slideover.page.empty()) return false;
slideover_close(app);
return true;
}
bool slideover_click_away(App& app, double x, double y) {
SlideOver& s = app.slideover;
if (s.page.empty() || s.overlay == nullptr || s.revealer == nullptr) return false;
graphene_point_t in = GRAPHENE_POINT_INIT(static_cast<float>(x), static_cast<float>(y));
graphene_point_t out;
if (gtk_widget_compute_point(s.overlay, s.revealer, &in, &out) &&
gtk_widget_contains(s.revealer, out.x, out.y)) {
return false;
}
slideover_close(app);
return true;
}
// --- the probe ---------------------------------------------------------------------------------
namespace {
struct ProbeState {
App* app;
int plot_w_before = 0;
};
bool under(GtkWidget* w, GtkWidget* ancestor) {
return w != nullptr && ancestor != nullptr && (w == ancestor || gtk_widget_is_ancestor(w, ancestor));
}
gboolean slideover_probe_read(gpointer user) {
ProbeState* ps = static_cast<ProbeState*>(user);
App& a = *ps->app;
SlideOver& s = a.slideover;
const int ow = gtk_widget_get_width(s.overlay);
const int oh = gtk_widget_get_height(s.overlay);
graphene_rect_t r;
const bool have = gtk_widget_compute_bounds(s.revealer, s.overlay, &r);
const int rw = have ? static_cast<int>(r.size.width + 0.5f) : -1;
const int rx = have ? static_cast<int>(r.origin.x + 0.5f) : -1;
const int rh = have ? static_cast<int>(r.size.height + 0.5f) : -1;
// Outside the panel, a quarter in: the plot must be what answers. Inside it, the panel.
GtkWidget* outside = gtk_widget_pick(s.overlay, ow / 4.0, oh / 2.0, GTK_PICK_DEFAULT);
GtkWidget* inside = gtk_widget_pick(s.overlay, ow - kSlideOverWidth / 2.0, oh / 2.0, GTK_PICK_DEFAULT);
const int plot_w_open = gtk_widget_get_width(a.plot_box);
std::fprintf(stderr,
"[slideover] open page=%s panel=%dx%d at x=%d overlay=%dx%d edge_flush=%d full_height=%d "
"outside_hits_plot=%d inside_hits_panel=%d plot_w before=%d open=%d plot_unchanged=%d\n",
s.page.c_str(), rw, rh, rx, ow, oh, rx + rw == ow ? 1 : 0, rh == oh ? 1 : 0,
under(outside, a.plot_box) ? 1 : 0, under(inside, s.revealer) ? 1 : 0, ps->plot_w_before,
plot_w_open, ps->plot_w_before == plot_w_open ? 1 : 0);
// For a screenshot only: leave the page open instead of exercising click-away and Esc.
if (std::getenv("UDPSCOPE_PROBE_SLIDEOVER_HOLD") != nullptr) {
delete ps;
return G_SOURCE_REMOVE;
}
// Click-away from outside the panel closes; a press inside does not.
const bool inside_closed = slideover_click_away(a, ow - kSlideOverWidth / 2.0, oh / 2.0);
const bool open_after_inside = !s.page.empty();
const bool outside_closed = slideover_click_away(a, ow / 4.0, oh / 2.0);
slideover_open(a, "probe");
const bool esc_closed = slideover_escape(a);
std::fprintf(stderr,
"[slideover] click_inside_closes=%d still_open=%d click_outside_closes=%d esc_closes=%d "
"closed=%d\n",
inside_closed ? 1 : 0, open_after_inside ? 1 : 0, outside_closed ? 1 : 0, esc_closed ? 1 : 0,
s.page.empty() ? 1 : 0);
delete ps;
return G_SOURCE_REMOVE;
}
} // namespace
gboolean slideover_probe(gpointer user) {
App* a = static_cast<App*>(user);
if (a->slideover.stack == nullptr) return G_SOURCE_REMOVE;
// A page of the probe's own: the real pages arrive in later tasks, and this one has an entry so the
// panel has something that takes focus and keys.
GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 6);
gtk_box_append(GTK_BOX(box), gtk_label_new("slide-over probe page"));
gtk_box_append(GTK_BOX(box), gtk_entry_new());
slideover_add_page(*a, "probe", "Probe", box);
ProbeState* ps = new ProbeState{a, gtk_widget_get_width(a->plot_box)};
slideover_open(*a, "probe");
// The revealer's slide is 120 ms; the reading is taken well after it has settled.
g_timeout_add(500, slideover_probe_read, ps);
return G_SOURCE_REMOVE;
}
} // namespace ui_impl
} // namespace udpscope
+5
View File
@@ -69,6 +69,11 @@ void install_css() {
".udpscope-tour-spot { border: 2px solid @accent_bg_color; border-radius: 8px; }\n"
".udpscope-tour-card { background-color: @window_bg_color; border: 1px solid @borders;"
" border-radius: 12px; padding: 12px; box-shadow: 0 3px 12px rgba(0, 0, 0, 0.45); }\n"
// The slide-over panel over the plot's right edge: opaque (it must hide the plot under it),
// with an edge and a shadow so it reads as above the plot rather than beside it.
".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 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