// 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 #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_set_title(App& app, const std::string& id, const std::string& title) { SlideOver& s = app.slideover; if (s.stack == nullptr || title.empty()) return false; GtkWidget* child = gtk_stack_get_child_by_name(GTK_STACK(s.stack), id.c_str()); if (child == nullptr) return false; GtkStackPage* page = gtk_stack_get_page(GTK_STACK(s.stack), child); gtk_stack_page_set_title(page, title.c_str()); if (is_source_page(id)) { AdwToggle* t = adw_toggle_group_get_toggle_by_name(ADW_TOGGLE_GROUP(s.source_switch), id.c_str()); if (t != nullptr) adw_toggle_set_label(t, title.c_str()); } // The header shows the *open* page's title, so a retitle of the page on show is visible at once -- // a signal page's channel number can change under it when the stream renumbers its channels. if (s.page == id) gtk_label_set_text(GTK_LABEL(s.title), title.c_str()); 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_remove_page(App& app, const std::string& id) { SlideOver& s = app.slideover; if (!slideover_has_page(app, id) || s.page == id) return false; GtkWidget* child = gtk_stack_get_child_by_name(GTK_STACK(s.stack), id.c_str()); if (is_source_page(id)) { s.source_pages.erase(std::remove(s.source_pages.begin(), s.source_pages.end(), id), s.source_pages.end()); AdwToggle* t = adw_toggle_group_get_toggle_by_name(ADW_TOGGLE_GROUP(s.source_switch), id.c_str()); if (t != nullptr) { s.syncing = true; adw_toggle_group_remove(ADW_TOGGLE_GROUP(s.source_switch), t); s.syncing = false; } } gtk_stack_remove(GTK_STACK(s.stack), child); return true; } 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); 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); 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