ui: the signal badge is the drag handle (to a plot row, or to reorder); the list rows stop dragging

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Martino FerrariandClaude Opus 4.6 committed 2026-11-03 01:30:55 +01:00
1 parent f27925da59
commit ca10dfb323
9 files changed
+230 -109

No files matched your search

+2 -21
View File
@@ -4634,25 +4634,6 @@ void on_pane_edit_clicked(GtkButton* button, gpointer user) {
apply_pane_lock(*app);
}
/// Hides the sidebar for the length of a drag, when it is *overlaid*, and restores it after.
///
/// With the split view collapsed the sidebar covers the plot -- that is what collapsed means -- so a drag
/// that starts in the signal list has nowhere to land: the pointer is over the sidebar and never over a
/// drop target, and the drag is impossible rather than merely awkward. Side by side nothing is done: the
/// plot is already visible, and hiding the list the drag started in would be startling.
void row_drag_sidebar(App& app, bool dragging) {
if (app.side_view == nullptr) return;
AdwOverlaySplitView* split = ADW_OVERLAY_SPLIT_VIEW(app.side_view);
if (dragging) {
if (!adw_overlay_split_view_get_collapsed(split)) return;
app.sidebar_hidden_for_drag = adw_overlay_split_view_get_show_sidebar(split) != FALSE;
if (app.sidebar_hidden_for_drag) adw_overlay_split_view_set_show_sidebar(split, FALSE);
} else if (app.sidebar_hidden_for_drag) {
app.sidebar_hidden_for_drag = false;
adw_overlay_split_view_set_show_sidebar(split, TRUE);
}
}
void run_app_action(App& app, AppAction action) {
const auto toggle = [](GtkWidget* button) {
if (button == nullptr) return;
@@ -6223,8 +6204,8 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
app.window_limit_s);
{
GtkIconTheme* theme = gtk_icon_theme_get_for_display(gdk_display_get_default());
std::printf("[udpscope] pane drag: sidebar hidden for the drag=%d restored after it=%d\n",
app.dbg_drag_hid_sidebar, app.dbg_drag_restored_sidebar);
std::printf("[udpscope] pane drag: slide-over closed for the drag=%d\n",
app.dbg_drag_closed_slideover);
std::printf("[udpscope] pane edits: locked=%s(%s) config=%s(%s)\n",
app.dbg_pane_edit_icon.c_str(),
gtk_icon_theme_has_icon(theme, app.dbg_pane_edit_icon.c_str()) ? "ok" : "MISSING",
+4 -11
View File
@@ -531,9 +531,6 @@ struct App {
/// True while `sync_panel_switches()` is writing the switches: setting a `GtkSwitch` emits its own
/// `notify::active`, and without this the program's own write would come back as a click.
bool panel_switch_sync = false;
/// True while a drag has the overlaid sidebar hidden: what it was showing before, so `drag-end` puts it
/// back rather than guessing.
bool sidebar_hidden_for_drag = false;
/// The channel a session's trigger names, until `rebuild_trigger_signals()` can resolve it: the
/// file stores a *name* and the trigger needs an index into the acquired channels, which do not
/// exist until the stream has been described. Empty means "nothing pending".
@@ -1166,11 +1163,11 @@ struct App {
/// of them are streamed signals. The `MATH` divider and the math channels account for the rest, and
/// `rows == signals + 1 + math` is the invariant the dump checks -- a list that lost its `+`.
int dbg_signal_rows = 0;
/// How many rows of the list carry a `GtkDragSource`: `rows - 1`, the divider not being draggable.
/// How many rows of the list carry a `GtkDragSource`: 0 -- the signal badges are the drag handles now.
int dbg_signal_drag_sources = -1;
/// The drag-with-an-overlaid-sidebar pair: the sidebar hid for the drag, and came back after it.
int dbg_drag_hid_sidebar = -1;
int dbg_drag_restored_sidebar = -1;
/// A drag begun through the first signal badge's source closed the open slide-over (1), or did not (0);
/// -1 when there is no badge to drag.
int dbg_drag_closed_slideover = -1;
std::size_t dbg_signal_plain = 0;
int dbg_side_column_after_hide = 1;
std::string dbg_mode_triggered;
@@ -1852,10 +1849,6 @@ const char* trigger_mode_text(TriggerMode m);
TriggerMode trigger_mode_from_text(const std::string& s);
/// The panel's controls from the model, and the session's trigger into both.
void write_trigger_widgets(App& app);
/// Hides the sidebar for the length of a drag when it is *overlaid*, and puts it back after. With the
/// split view collapsed the sidebar covers the plot, so a drag that starts in the signal list has nowhere
/// to land: the pointer is over the sidebar, not over a drop target.
void row_drag_sidebar(App& app, bool dragging);
/// The compact line the trigger pane shows while it is locked, and the lock itself: the panes'
/// bodies, their [edit] buttons and the signal list's rows all follow from `trigger_locked` and
/// `signals_locked`.
+161 -1
View File
@@ -10,6 +10,7 @@
#include <algorithm>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <memory>
#include <mutex>
@@ -78,6 +79,40 @@ void make_badge(App& app, Badge& b, GtkWidget* parent, const char* name, const s
gtk_box_append(GTK_BOX(parent), b.button);
}
/// The reorder: widgets are moved, never rebuilt, so neither the dragged badge nor this target (whose "drop"
/// emission is running) is destroyed under the drag. The payload is the signal index as a decimal string (the
/// plot area's drop target reads the same one); anything else a foreign drag might carry is refused.
gboolean on_badge_drop(GtkDropTarget*, const GValue* value, double, double, gpointer user) {
Badge* target = static_cast<Badge*>(user);
App* app = static_cast<App*>(g_object_get_data(G_OBJECT(target->button), "udpscope-app"));
if (app == nullptr || value == nullptr || !G_VALUE_HOLDS_STRING(value)) return FALSE;
const char* text = g_value_get_string(value);
if (text == nullptr || *text == '\0') return FALSE;
char* end = nullptr;
const unsigned long long parsed = std::strtoull(text, &end, 10);
if (end == nullptr || *end != '\0') return FALSE;
const std::size_t signal = static_cast<std::size_t>(parsed);
Badge* moved = nullptr;
for (auto& b : app->badges.signal_badges) {
if (b->signal == signal) moved = b.get();
}
if (moved == nullptr) return FALSE;
if (moved == target) return TRUE;
GtkWidget* prev = gtk_widget_get_prev_sibling(target->button);
if (prev != moved->button) { // already directly in front: nothing to move
gtk_box_reorder_child_after(GTK_BOX(app->badges.signals_row), moved->button, prev);
}
// The order, as the widgets now stand.
app->badge_order.clear();
for (GtkWidget* c = gtk_widget_get_first_child(app->badges.signals_row); c != nullptr;
c = gtk_widget_get_next_sibling(c)) {
for (auto& b : app->badges.signal_badges) {
if (b->button == c) app->badge_order.push_back(b->signal_name);
}
}
return TRUE;
}
/// A signal's badge: its colour dot (grey when it has no trace) and its name. Words and a dot, no icon.
void make_signal_badge(App& app, Badge& b, const std::vector<SignalInfo>& signals, std::size_t index) {
b.signal = index;
@@ -98,6 +133,27 @@ void make_signal_badge(App& app, Badge& b, const std::vector<SignalInfo>& signal
gtk_widget_set_tooltip_text(b.button, b.signal_name.c_str());
g_object_set_data(G_OBJECT(b.button), "udpscope-app", &app);
g_signal_connect(b.button, "clicked", G_CALLBACK(on_badge_clicked), &b);
// A drag handle: the same payload the list's rows used to carry, so the plot area's drop target takes it
// unchanged. The slide-over steps aside for the drag, since it covers the plot's right edge. (A click
// without movement never reaches the drag threshold, so it still opens the page; past the threshold the
// source claims the sequence and the button's click is cancelled.)
DragContext* ctx = new DragContext{&app, index};
GtkDragSource* source = gtk_drag_source_new();
gtk_drag_source_set_actions(source, GDK_ACTION_COPY);
g_signal_connect_data(source, "prepare", G_CALLBACK(on_drag_prepare), ctx,
[](gpointer data, GClosure*) { delete static_cast<DragContext*>(data); },
G_CONNECT_DEFAULT);
g_signal_connect(source, "drag-begin", G_CALLBACK(+[](GtkDragSource*, GdkDrag*, gpointer user) {
slideover_close(*static_cast<App*>(user));
}),
&app);
gtk_widget_add_controller(b.button, GTK_EVENT_CONTROLLER(source));
// Dropping another signal's badge on this one moves it in front of this one.
GtkDropTarget* drop = gtk_drop_target_new(G_TYPE_STRING, GDK_ACTION_COPY);
g_signal_connect(drop, "drop", G_CALLBACK(on_badge_drop), &b);
gtk_widget_add_controller(b.button, GTK_EVENT_CONTROLLER(drop));
}
} // namespace
@@ -197,7 +253,9 @@ void sync_badge_bar(App& app) {
}
if (bb.overflow.button != nullptr) {
gtk_widget_set_sensitive(bb.overflow.button, !app.model->signals().empty());
set_open(bb.overflow.button, slideover_page(app) == bb.overflow.page);
// The overflow stands for every signal's badge too: a signal page opened from a list row marks it.
set_open(bb.overflow.button,
slideover_page(app) == bb.overflow.page || slideover_page(app).rfind("signal:", 0) == 0);
}
for (const std::unique_ptr<Badge>& sb : bb.signal_badges) {
set_open(sb->button, slideover_page(app) == sb->page);
@@ -360,6 +418,108 @@ gboolean signal_badges_probe(gpointer user) {
"bar_h=%d bar_h_fixed=%d bar_min_w=%d opens=%d plus_opens=%d\n",
enabled, badges, overflowed ? 1 : 0, overflow_text.c_str(), plus_visible, dot_plotted, before.first,
bar_h_fixed, bar_min_w, opens, plus_opens);
// The drag handles. Controllers are enumerated through the widget's own model.
const auto count_controllers = [](GtkWidget* w, bool (*is)(GObject*)) {
int n = 0;
GListModel* controllers = gtk_widget_observe_controllers(w);
const guint items = controllers != nullptr ? g_list_model_get_n_items(controllers) : 0;
for (guint i = 0; i < items; ++i) {
GObject* c = G_OBJECT(g_list_model_get_item(controllers, i));
if (is(c)) ++n;
g_object_unref(c);
}
return n;
};
const auto is_source = [](GObject* c) { return GTK_IS_DRAG_SOURCE(c) != FALSE; };
const auto is_target = [](GObject* c) { return GTK_IS_DROP_TARGET(c) != FALSE; };
int drag_sources = 0;
int drop_targets = 0;
for (const std::unique_ptr<Badge>& sb : bb.signal_badges) {
drag_sources += count_controllers(sb->button, is_source);
drop_targets += count_controllers(sb->button, is_target);
}
// The payload: what a drag from the first badge carries, read back from the provider `prepare` returns.
int prepare_ok = -1;
if (!bb.signal_badges.empty()) {
prepare_ok = 0;
GListModel* controllers = gtk_widget_observe_controllers(bb.signal_badges.front()->button);
const guint items = controllers != nullptr ? g_list_model_get_n_items(controllers) : 0;
for (guint i = 0; i < items; ++i) {
GObject* c = G_OBJECT(g_list_model_get_item(controllers, i));
if (GTK_IS_DRAG_SOURCE(c)) {
GdkContentProvider* provider = nullptr;
g_signal_emit_by_name(c, "prepare", 0.0, 0.0, &provider);
if (provider != nullptr) {
GValue v = G_VALUE_INIT;
g_value_init(&v, G_TYPE_STRING);
if (gdk_content_provider_get_value(provider, &v, nullptr) && g_value_get_string(&v) != nullptr &&
std::to_string(bb.signal_badges.front()->signal) == g_value_get_string(&v)) {
prepare_ok = 1;
}
g_value_unset(&v);
g_object_unref(provider);
}
}
g_object_unref(c);
}
}
// The reorder, by the drop handler the targets run: the last badge dropped on the first goes in front of it,
// and the remembered order follows the widgets. Then the order is put back as it was.
const auto widget_names = [&] {
std::string names;
for (GtkWidget* c = gtk_widget_get_first_child(bb.signals_row); c != nullptr; c = gtk_widget_get_next_sibling(c)) {
for (const std::unique_ptr<Badge>& sb : bb.signal_badges) {
if (sb->button == c) names += (names.empty() ? "" : ",") + sb->signal_name;
}
}
return names;
};
std::string reorder_before = "-";
std::string reorder_after = "-";
int reorder_ok = -1;
if (bb.signal_badges.size() >= 2) {
const std::vector<std::string> saved_order = a->badge_order;
reorder_before = widget_names();
Badge& first = *bb.signal_badges.front();
Badge& last = *bb.signal_badges.back();
GValue v = G_VALUE_INIT;
g_value_init(&v, G_TYPE_STRING);
g_value_set_string(&v, std::to_string(last.signal).c_str());
const gboolean handled = on_badge_drop(nullptr, &v, 0.0, 0.0, &first);
g_value_unset(&v);
reorder_after = widget_names();
std::string order;
for (const std::string& n : a->badge_order) order += (order.empty() ? "" : ",") + n;
GtkWidget* head = gtk_widget_get_first_child(bb.signals_row);
reorder_ok = handled && head == last.button && order == reorder_after && reorder_after != reorder_before ? 1 : 0;
// Back to the original place: the moved badge goes after the one that was last, and the order is restored.
gtk_box_reorder_child_after(GTK_BOX(bb.signals_row), last.button, bb.signal_badges[bb.signal_badges.size() - 2]->button);
a->badge_order = saved_order;
if (widget_names() != reorder_before) reorder_ok = 0;
}
// The drop path itself: a badge's payload lands through `assign_signal_to_row`, as the plot area's drop does.
// (The assignment rebuilds the panels, and the badges with them, so the first one is named beforehand.)
int payload_drop = -1;
int list_drag = -1;
if (!bb.signal_badges.empty()) {
const std::size_t signal = bb.signal_badges.front()->signal;
const std::string name = bb.signal_badges.front()->signal_name;
remove_signal_from_plots(*a, name);
const bool assigned = assign_signal_to_row(*a, signal, 0);
payload_drop = assigned && signal_plotted(*a, name) ? 1 : 0;
}
if (a->signal_list != nullptr) {
GtkWidget* row = gtk_widget_get_first_child(a->signal_list);
list_drag = row != nullptr ? count_controllers(row, is_source) : -1;
}
std::fprintf(stderr,
"[sigbadges] drag_sources=%d drop_targets=%d prepare_ok=%d reorder_before=%s reorder_after=%s "
"reorder_ok=%d payload_drop=%d list_drag=%d\n",
drag_sources, drop_targets, prepare_ok, reorder_before.c_str(), reorder_after.c_str(), reorder_ok,
payload_drop, list_drag);
return G_SOURCE_REMOVE;
}
+18 -30
View File
@@ -101,10 +101,9 @@ gboolean exit_probe_step3b(gpointer user) {
// the page is open and the reveal has run.
a->dbg_signal_w = gtk_widget_get_width(a->signal_panel);
a->dbg_signal_h = gtk_widget_get_height(a->signal_panel);
// How many rows of the signal list carry a drag source: dragging a signal onto a plot is how a plot is
// filled, so it has to survive both pane modes -- the locked list drops the tick, the menu and the `+`,
// and this is the reading that says it did not drop the drag with them. One row is the `MATH` divider,
// which is not draggable: the count must be `rows - 1`.
// How many rows of the signal list carry a drag source: none. Dragging a signal onto a plot is the signal
// badge's job (and survives the pane lock, since the badges do not follow it); the list's rows only open
// the signal's page. The reading says the list did not grow a second, competing handle.
if (a->signal_list != nullptr) {
int sources = 0;
for (GtkWidget* r = gtk_widget_get_first_child(a->signal_list); r != nullptr;
@@ -119,34 +118,23 @@ gboolean exit_probe_step3b(gpointer user) {
}
a->dbg_signal_drag_sources = sources;
}
// A drag out of an *overlaid* sidebar: the sidebar has to step aside or the drop has nowhere to land.
// The state is forced the way the sidebar probe forces it -- collapsed is what a window with no room
// beside the plot produces -- and the drag is begun through the *source*, so the handler is the one a
// real drag reaches.
if (a->side_view != nullptr && a->signal_list != nullptr) {
AdwOverlaySplitView* split = ADW_OVERLAY_SPLIT_VIEW(a->side_view);
GtkWidget* row = gtk_widget_get_first_child(a->signal_list);
adw_overlay_split_view_set_collapsed(split, TRUE);
adw_overlay_split_view_set_show_sidebar(split, TRUE);
if (row != nullptr) {
GListModel* controllers = gtk_widget_observe_controllers(row);
const guint n = controllers != nullptr ? g_list_model_get_n_items(controllers) : 0;
for (guint i = 0; i < n; ++i) {
GObject* c = G_OBJECT(g_list_model_get_item(controllers, i));
if (GTK_IS_DRAG_SOURCE(c)) {
g_signal_emit_by_name(c, "drag-begin", nullptr);
a->dbg_drag_hid_sidebar =
adw_overlay_split_view_get_show_sidebar(split) == FALSE ? 1 : 0;
row_drag_sidebar(*a, false);
a->dbg_drag_restored_sidebar =
adw_overlay_split_view_get_show_sidebar(split) != FALSE ? 1 : 0;
}
g_object_unref(c);
// A drag out of a badge with a slide-over open: the panel covers the plot's right edge, so it has to step
// aside or part of the plot is not a place to drop. The drag is begun through the *source*, so the handler
// is the one a real drag reaches; the page that was open is put back so the readings after it stand.
if (!a->badges.signal_badges.empty() && slideover_has_page(*a, "signals")) {
const std::string was = slideover_page(*a);
slideover_open(*a, "signals");
GListModel* controllers = gtk_widget_observe_controllers(a->badges.signal_badges.front()->button);
const guint n = controllers != nullptr ? g_list_model_get_n_items(controllers) : 0;
for (guint i = 0; i < n; ++i) {
GObject* c = G_OBJECT(g_list_model_get_item(controllers, i));
if (GTK_IS_DRAG_SOURCE(c)) {
g_signal_emit_by_name(c, "drag-begin", nullptr);
a->dbg_drag_closed_slideover = slideover_page(*a).empty() ? 1 : 0;
}
g_object_unref(c);
}
// Left as the window's own width decides, so the readings taken before this are the ones that stand.
adw_overlay_split_view_set_collapsed(split, FALSE);
adw_overlay_split_view_set_show_sidebar(split, TRUE);
if (!was.empty()) slideover_open(*a, was);
}
// The panes' own switches, and *both* directions of the sync: the header bar's trigger button is
// flipped here and the pane's switch has to follow, then the cursors' switch is driven from the
+4 -22
View File
@@ -302,7 +302,7 @@ void rebuild_signal_list(App& app, const std::vector<SignalInfo>& signals) {
// One line per *signal*, not per channel. A group's model holds each signal once per event; a list
// that showed both would name one "sine (A)" and the other "sine (B)" as if they were different
// things, and give them different colours. What the row toggles, colours and drags is the signal.
// things, and give them different colours. What the row toggles and colours is the signal.
std::vector<std::size_t> rows;
// The `MATH` divider goes in before the first math row, and once only: `append_math_signals_locked()`
// has already put every math channel in `signals()`, so the boundary is the one thing the deleted
@@ -350,8 +350,8 @@ void rebuild_signal_list(App& app, const std::vector<SignalInfo>& signals) {
g_signal_connect(pick, "pressed",
G_CALLBACK(+[](GtkGestureClick* g, int, double, double, gpointer user) {
App* a = static_cast<App*>(user);
// Claimed, so the row's `GtkDragSource` cannot take the same sequence
// and turn a click on the chip into the start of a drag.
// Claimed, so the same click does not also activate the row (which would open
// the signal's page).
gtk_gesture_set_state(GTK_GESTURE(g), GTK_EVENT_SEQUENCE_CLAIMED);
open_signal_color(*a, static_cast<std::size_t>(GPOINTER_TO_SIZE(
g_object_get_data(G_OBJECT(g),
@@ -466,25 +466,7 @@ void rebuild_signal_list(App& app, const std::vector<SignalInfo>& signals) {
gtk_list_box_row_set_child(GTK_LIST_BOX_ROW(row), box);
// Activating the row opens the signal's page; the row resolves its identity the way its widgets do.
g_object_set_data(G_OBJECT(row), "udpscope-signal", GSIZE_TO_POINTER(i));
DragContext* ctx = new DragContext{&app, i};
GtkDragSource* source = gtk_drag_source_new();
gtk_drag_source_set_actions(source, GDK_ACTION_COPY);
g_signal_connect_data(source, "prepare", G_CALLBACK(on_drag_prepare), ctx,
[](gpointer data, GClosure*) { delete static_cast<DragContext*>(data); },
G_CONNECT_DEFAULT);
// A drag out of an *overlaid* sidebar would have nowhere to land (see `row_drag_sidebar()`), so the
// sidebar steps out of the way for as long as the drag lasts.
g_signal_connect(source, "drag-begin", G_CALLBACK(+[](GtkDragSource*, GdkDrag*, gpointer user) {
row_drag_sidebar(*static_cast<App*>(user), true);
}),
&app);
g_signal_connect(source, "drag-end",
G_CALLBACK(+[](GtkDragSource*, GdkDrag*, gboolean, gpointer user) {
row_drag_sidebar(*static_cast<App*>(user), false);
}),
&app);
gtk_widget_add_controller(row, GTK_EVENT_CONTROLLER(source));
// Dragging a signal onto a plot is the signal badge's job (`make_signal_badge`): the row opens the page.
gtk_list_box_append(GTK_LIST_BOX(list), row);
}