ui: a slide-over panel over the plot's right edge, with its probe
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
73f74ca495
commit
a40863fa45
7 files changed
+334
-9
No files matched your search
@@ -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
|
||||
Reference in new issue
Block a user