diff --git a/CMakeLists.txt b/CMakeLists.txt index f3a9785..a65da52 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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 diff --git a/docs/claude/verification.md b/docs/claude/verification.md index 92c7393..1b5fe08 100644 --- a/docs/claude/verification.md +++ b/docs/claude/verification.md @@ -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 ... 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. diff --git a/src/ui.cpp b/src/ui.cpp index c6c715d..a3dcaf5 100644 --- a/src/ui.cpp +++ b/src/ui.cpp @@ -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(std::atoi(env)), side_tab_probe, &app); } + 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_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 a6bf93f..444a0d5 100644 --- a/src/ui/main-window.ui +++ b/src/ui/main-window.ui @@ -3,7 +3,8 @@ + 1 1 - - horizontal - 1 + + vertical + 1 + 4 + 1 + 1 + + + horizontal + 1 + + diff --git a/src/ui_app.hpp b/src/ui_app.hpp index 8437d53..f63da4d 100644 --- a/src/ui_app.hpp +++ b/src/ui_app.hpp @@ -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 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 diff --git a/src/ui_slideover.cpp b/src/ui_slideover.cpp new file mode 100644 index 0000000..cb3fe2b --- /dev/null +++ b/src/ui_slideover.cpp @@ -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 + +#include +#include +#include + +namespace udpscope { +namespace ui_impl { + +namespace { + +void on_close_clicked(GtkButton*, gpointer user) { slideover_close(*static_cast(user)); } + +void on_source_switch(GObject* o, GParamSpec*, gpointer user) { + App* app = static_cast(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(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(x), static_cast(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(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(r.size.width + 0.5f) : -1; + const int rx = have ? static_cast(r.origin.x + 0.5f) : -1; + const int rh = have ? static_cast(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(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 diff --git a/src/ui_widgets.cpp b/src/ui_widgets.cpp index 1eeb979..1bf2aba 100644 --- a/src/ui_widgets.cpp +++ b/src/ui_widgets.cpp @@ -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