Files
udpscope/src/ui_signals.cpp
T
Martino Ferrari 51b4fad386 scope windows take drops, and the signal page lists them
The second windows were read-only by accident: nothing accepted a drop on them, and the
signal page's *Plot on* buttons knew only the main window's rows.

- **Drop target per scope window**: the same string payload a badge drags (the signal index),
  handled by `on_scope_drop_channel()` -- the target carries its own window, the row is that
  window's cell under the pointer, and `assign_signal_to_scope()` lands the signal in the
  window's own row set (enabling an unacquired signal first, exactly as the main drop does).
  The rebuild and the area's re-point are the window's next frame in the tick: a drop arrives
  from inside the very widget it rearranges, and a synchronous rebuild would reset the drag
  controller under it.
- **Plot on, per window**: the signal page carries one named group per scope window ("In
  \"plot 2\""), one button per plot of that window, sensitive on that window's own rows. A
  window closing refreshes the pages in its destroy handler, before the deferred delete could
  dangle a button's scope pointer.
- Probe: `[scope-probe]` drops through the wired handler (`drop=1 slot1='square'`) and counts
  the page's groups (`page_groups=2`); the section documents that it must not run with
  `UDPSCOPE_ENABLE_ALL_MS` -- the enable-all probe's second leg clears the front plot, which
  is the slot this probe copies. README, help and the layout invariant follow.
2026-11-05 19:37:42 +01:00

747 lines
37 KiB
C++

// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
//
// The signal list: one row per signal with its colour chip and acquire tick, the panel around it,
// and the drop that puts a signal on a row.
#include "ui_app.hpp"
#include <gtk/gtk.h>
#include <string>
namespace udpscope {
namespace ui_impl {
void on_signal_switch(GObject* obj, GParamSpec*, gpointer user) {
App* app = static_cast<App*>(user);
if (app->updating_panels) return;
const std::size_t index = static_cast<std::size_t>(
GPOINTER_TO_SIZE(g_object_get_data(obj, "udpscope-signal")));
const bool on = gtk_check_button_get_active(GTK_CHECK_BUTTON(obj)) != FALSE;
// The name first: the switch may be the last reference to a signal the model is about to drop.
std::string name;
{
const std::vector<SignalInfo>& signals = app->model->signals();
if (index < signals.size()) name = signals[index].name;
}
// Hold a reference on the emitting widget across the rebuild. `rebuild_signal_panels` destroys
// the list that owns `obj` (this check button's row), dropping the row's reference and leaving
// only GLib's own per-emission ref. `gtk_check_button_set_active` emits both `notify::active`
// and `toggled` in sequence: GLib's ref covers only one emission at a time, so after
// `notify::active` finishes GLib unrefs the instance and, without our extra hold, that is the
// last reference -- and the subsequent `toggled` emission is then into freed memory, which is
// the two `GLib-GObject-CRITICAL` diagnostics. Taking our own ref here and releasing it from
// an idle keeps the instance alive until after `set_active` has emitted everything and returned.
g_object_ref(obj);
app->model->set_signal_enabled(index, on);
// `App::math` is the copy the session file is written from, so a math row's tick has to reach it.
// Here and not after the `if (!on)` block below: `apply_math_channels()` down there pushes
// `App::math` into the model, and a stale `enabled = true` would switch the channel straight back on.
{
const int k = math_index_for_signal(*app, name);
if (k >= 0) app->math[static_cast<std::size_t>(k)].enabled = on;
}
if (!on) {
// A trace of a signal nobody acquires draws nothing, and holds the slot a live one could take.
int gone = remove_signal_from_plots(*app, name);
// ... and the derived channels that read it cannot be computed at all, so they follow it off, and their
// traces leave with them.
const std::vector<std::string> off = disable_dependent_math_channels(*app, name);
for (const std::string& channel : off) gone += remove_signal_from_plots(*app, channel);
if (!off.empty()) apply_math_channels(*app); // the single writer: the pane's switches follow it
if (gone > 0 || !off.empty()) {
app->relayout_pending = true;
rebuild_signal_panels(*app); // the chips of the signals that are still plotted
std::string what = "'" + name + "' is off: " + std::to_string(gone) +
(gone == 1 ? " trace left the plots" : " traces left the plots");
if (!off.empty()) {
// The channel names already read as what they are ("math x2"), so the sentence does not prefix them.
what += "; math off: " + off.front() +
(off.size() == 1 ? "" : " and " + std::to_string(off.size() - 1) + " more");
}
set_notice(*app, what, 5);
}
}
// The enable state is part of the session (a signal can be on only as a math channel's source).
schedule_layout_save(*app);
// Release the extra reference from an idle so that `set_active`'s full emission chain --
// `notify::active` and `toggled` -- has completed before the instance can be finalized.
g_idle_add_full(G_PRIORITY_DEFAULT_IDLE,
+[](gpointer) -> gboolean { return G_SOURCE_REMOVE; },
obj, g_object_unref);
}
/// The palette entry a signal owns: its position among the *plottable* signals, the
/// time base skipped. It is a property of the signal and not of the row/slot a drop
/// lands in, so the same signal is drawn in the same colour on every plot it appears on
/// -- which is what lets one chip in this list name every one of those traces.
///
/// Positional, like the channel list, so a new CONFIG re-colours; that is fine because a
/// CONFIG drops the assignments and the history with them. A name hash would survive a
/// CONFIG but collide inside one, and two traces of the same colour on one row is the
/// failure this exists to prevent.
int signal_color_index(const std::vector<SignalInfo>& signals, std::size_t signal) {
// Counted over *signals*, not channels: a group's model holds each signal once per event, and
// counting the copies would spread one signal's colour across the palette and give its second layer a
// colour of its own.
int index = 0;
std::vector<std::string> seen;
for (std::size_t i = 0; i < signals.size() && i < signal; ++i) {
if (ScopeModel::is_time_signal(signals, i)) continue;
const std::string base = layer_base_name(signals[i].name);
if (std::find(seen.begin(), seen.end(), base) != seen.end()) continue;
seen.push_back(base);
++index;
}
return index;
}
/// Whether any row's slot holds `name` -- i.e. whether the signal has a trace on screen, which is
/// what decides between its own colour and the grey of an unplotted one, and what decides whether
/// there is a colour for the user to change.
bool signal_plotted(App& app, const std::string& name) {
for (int r = 0; r < app.rows->row_count(); ++r) {
const RowSet::Row& row = app.rows->row(r);
for (int s = 0; s < RowSet::kSlotsPerRow; ++s) {
if (row.signal_name[static_cast<std::size_t>(s)] == name) return true;
}
}
return false;
}
/// The chip for a row of the signal list, as Pango markup: the signal's own colour when
/// it is on a plot, grey when it is not (whether that is because it is not acquired or
/// because it is acquired and not placed). Always a filled disc, never a blank: a blank
/// cell is narrower than a disc, so the names beside it used to shift sideways as
/// signals were placed, and "no trace" read as "no chip here" rather than as a state.
std::string signal_chip_markup(App& app, const std::vector<SignalInfo>& signals,
std::size_t signal) {
const std::string& name = signals[signal].name;
if (!signal_plotted(app, name)) return chip_markup(RowSet::unplotted_color());
// Through `RowSet::signal_color()`, which is also what `install()` builds the trace with: the
// chip's whole job is to say which trace this row is about, so it may not resolve a colour by
// its own route. A user's override is exactly the state in which two routes would disagree.
return chip_markup(app.rows->signal_color(name, signal_color_index(signals, signal)));
}
/// Applies a chosen colour, or leaves everything alone if the chooser was dismissed.
void on_signal_color_chosen(GObject* source, GAsyncResult* res, gpointer user) {
std::unique_ptr<ColorPick> pick(static_cast<ColorPick*>(user));
GError* error = nullptr;
GdkRGBA* picked = gtk_color_dialog_choose_rgba_finish(GTK_COLOR_DIALOG(source), res, &error);
if (picked == nullptr) {
// A dismissed chooser arrives here as an error (`GTK_DIALOG_ERROR_DISMISSED`, or a cancelled
// `GError` if the window went away), and a cancel must leave the colour as it was. There is
// nothing to report either way: the user closed a chooser.
if (error != nullptr) g_error_free(error);
return;
}
const auto octet = [](double v) {
return static_cast<plot::u32>(std::clamp(v, 0.0, 1.0) * 255.0 + 0.5);
};
const plot::u32 rgb = (octet(picked->red) << 16) | (octet(picked->green) << 8) |
octet(picked->blue);
gdk_rgba_free(picked);
App& app = *pick->app;
app.rows->set_signal_color(pick->signal, rgb);
// The rebuild is deferred to the frame tick, like a drop's: a trace's colour is fixed at
// `add_trace()`, so a new colour is a new figure, and `relayout_areas()` is the one place that
// builds one and re-points the area at it.
app.relayout_pending = true;
rebuild_signal_panels(app);
schedule_layout_save(app);
}
/// `App::math` is indexed by position and `signals()` by name, and the mapping between them is
/// the name: `append_math_signals_locked()` names each synthetic signal after its channel and
/// `enable_locked()` matches it back the same way.
int math_index_for_signal(App& app, const std::string& name) {
for (std::size_t i = 0; i < app.math.size(); ++i) {
if (app.math[i].name == name) return static_cast<int>(i);
}
return -1;
}
/// The `MATH` boundary in the one channel list, carrying the `+` that creates a channel. Not
/// selectable and not activatable: it is a heading, and a heading that highlights under the
/// pointer reads as a row you can plot.
GtkWidget* make_math_divider(App& app) {
GtkWidget* row = gtk_list_box_row_new();
gtk_list_box_row_set_selectable(GTK_LIST_BOX_ROW(row), FALSE);
gtk_list_box_row_set_activatable(GTK_LIST_BOX_ROW(row), FALSE);
GtkWidget* box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
gtk_widget_set_margin_top(box, 6);
gtk_widget_set_margin_start(box, 6);
gtk_widget_set_margin_end(box, 6);
GtkWidget* label = gtk_label_new("MATH");
gtk_widget_add_css_class(label, "dim-label");
gtk_widget_add_css_class(label, "caption-heading");
gtk_widget_set_halign(label, GTK_ALIGN_START);
gtk_widget_set_hexpand(label, TRUE);
gtk_box_append(GTK_BOX(box), label);
// The `+` that creates a channel: the "new math channel" entry of a page that proposes what can be added.
GtkWidget* add = gtk_button_new_from_icon_name("list-add-symbolic");
gtk_widget_add_css_class(add, "flat");
gtk_widget_set_tooltip_text(add, "add a math channel");
g_signal_connect(add, "clicked", G_CALLBACK(on_math_add_clicked), &app);
gtk_box_append(GTK_BOX(box), add);
gtk_list_box_row_set_child(GTK_LIST_BOX_ROW(row), box);
return row;
}
/// A handler that rebuilds the signal list destroys the widget it was emitted from -- the row it
/// lives in, and that row's menu popover with it. GLib holds a reference for the duration of *one*
/// emission, and a button's own code keeps running after the handler returns, so the last reference
/// has to outlive the emission: hold one here and drop it from an idle. This is the same repair
/// `on_signal_switch()` makes, for the same reason.
void outlive_the_rebuild(GtkWidget* widget) {
g_object_ref(widget);
g_idle_add_full(G_PRIORITY_DEFAULT_IDLE,
+[](gpointer) -> gboolean { return G_SOURCE_REMOVE; }, widget, g_object_unref);
}
/// Create a channel, then open the editor on it: a new channel is a single `Source` node with a
/// default name, which is of no use until it is given a graph.
void on_math_add_clicked(GtkButton* button, gpointer user) {
App* a = static_cast<App*>(user);
outlive_the_rebuild(GTK_WIDGET(button));
add_math_channel(*a);
if (a->math.empty()) return;
// `add_math_channel()` appended, so the new channel is the last one; `apply_math_channels()`
// inside it has already rebuilt this list, which is why the index is taken after the call.
open_math_dialog(*a, static_cast<int>(a->math.size()) - 1);
}
/// The row's menu items resolve their identity the one way the list already does: the widget
/// carries its *signal* index, and the name is read off the model under the lock.
static std::string signal_name_at(App& app, const std::size_t index) {
std::lock_guard<std::mutex> lock(app.model->mutex());
const std::vector<SignalInfo>& signals = app.model->signals();
return index < signals.size() ? signals[index].name : std::string();
}
/// Close the row's popover before anything is opened or destroyed from it: a dialog that takes
/// focus over an open popover leaves it hanging over the new window, and `Delete` removes the row
/// the popover belongs to, which cannot be left pointing at it.
static void dismiss_row_menu(GtkButton* button) {
GtkWidget* popover = gtk_widget_get_ancestor(GTK_WIDGET(button), GTK_TYPE_POPOVER);
if (popover != nullptr) gtk_widget_set_visible(popover, FALSE);
}
void on_math_edit_clicked(GtkButton* button, gpointer user) {
App* a = static_cast<App*>(user);
const std::size_t index = static_cast<std::size_t>(
GPOINTER_TO_SIZE(g_object_get_data(G_OBJECT(button), "udpscope-signal")));
const int k = math_index_for_signal(*a, signal_name_at(*a, index));
if (k < 0) return;
dismiss_row_menu(button);
open_math_dialog(*a, k);
}
void on_math_delete_clicked(GtkButton* button, gpointer user) {
App* a = static_cast<App*>(user);
const std::size_t index = static_cast<std::size_t>(
GPOINTER_TO_SIZE(g_object_get_data(G_OBJECT(button), "udpscope-signal")));
const int k = math_index_for_signal(*a, signal_name_at(*a, index));
if (k < 0) return;
dismiss_row_menu(button);
outlive_the_rebuild(GTK_WIDGET(button));
a->math.erase(a->math.begin() + k);
// Rebuilds the model's signal list, the plots and this list, and schedules the save.
apply_math_channels(*a);
}
bool signal_badges_overflowed(App& app, const std::vector<SignalInfo>& signals) {
std::size_t enabled = 0;
for (std::size_t i : listed_signals(signals)) {
if (app.model->signal_enabled(i)) ++enabled;
}
return enabled > kMaxSignalBadges;
}
std::vector<std::size_t> listed_signals(const std::vector<SignalInfo>& signals) {
std::vector<std::size_t> out;
for (std::size_t i = 0; i < signals.size(); ++i) {
if (ScopeModel::is_time_signal(signals, i)) continue; // the time base is not plottable
const std::string base = layer_base_name(signals[i].name);
bool seen = false;
for (std::size_t j = 0; j < i && !seen; ++j) {
seen = !ScopeModel::is_time_signal(signals, j) && layer_base_name(signals[j].name) == base;
}
if (!seen) out.push_back(i);
}
return out;
}
void reset_signal_color(App& app, std::size_t signal) {
std::string name;
{
std::lock_guard<std::mutex> lock(app.model->mutex());
const std::vector<SignalInfo>& sigs = app.model->signals();
if (signal >= sigs.size()) return;
name = sigs[signal].name;
}
app.rows->clear_signal_color(name);
app.relayout_pending = true;
rebuild_signal_panels(app);
schedule_layout_save(app);
}
/// One line per signal: colour chip, checkbox, name, unit, and a menu for the things
/// you touch once (information, calibration). A `GtkSwitch` and two always-visible
/// buttons made every row about twice this tall, which is what turned a four-signal
/// stream into a panel you have to scroll.
void rebuild_signal_list(App& app, const std::vector<SignalInfo>& signals) {
if (app.signal_list == nullptr) return;
app.updating_panels = true;
GtkWidget* list = app.signal_list;
GtkWidget* child = gtk_widget_get_first_child(list);
while (child != nullptr) {
GtkWidget* next = gtk_widget_get_next_sibling(child);
gtk_list_box_remove(GTK_LIST_BOX(list), child);
child = next;
}
// 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 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
// math pane contributed that the list does not already have.
bool math_divider_done = false;
// Streamed signals, counted as they are drawn: the dump reads `rows == signals + 1 + math`.
std::size_t plain = 0;
std::size_t math_rows = 0;
// The page proposes what can be *added*: an acquired signal already has a badge, so it is not listed --
// unless the badges have overflowed into one "N signals" badge, when the rows are the only way to reach
// each signal's page.
const bool overflowed = signal_badges_overflowed(app, signals);
for (std::size_t i : listed_signals(signals)) {
if (!overflowed && app.model->signal_enabled(i)) continue;
rows.push_back(i);
}
// Nothing addable: say so rather than leave the math entry alone under the hint. Not before the stream
// has declared any signal -- "every signal is acquired" would be untrue of no signals at all.
if (app.signals_empty != nullptr) {
gtk_widget_set_visible(app.signals_empty, rows.empty() && !listed_signals(signals).empty());
}
for (std::size_t i : rows) {
const SignalInfo& sig = signals[i];
// One list, with a boundary in it: a derived channel *is* a channel, and the divider carries the
// `+` that creates the first one.
if (sig.math && !math_divider_done) {
math_divider_done = true;
gtk_list_box_append(GTK_LIST_BOX(list), make_math_divider(app));
}
if (sig.math) {
++math_rows;
} else {
++plain;
}
GtkWidget* row = gtk_list_box_row_new();
gtk_widget_add_css_class(row, "udpscope-compact");
GtkWidget* box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
gtk_widget_set_margin_start(box, 6);
gtk_widget_set_margin_end(box, 2);
const bool plotted = signal_plotted(app, sig.name);
GtkWidget* chip = gtk_label_new(nullptr);
gtk_label_set_markup(GTK_LABEL(chip), signal_chip_markup(app, signals, i).c_str());
gtk_widget_set_tooltip_text(
chip, !app.model->signal_enabled(i) ? "not acquired"
: plotted ? "the colour this signal is plotted in -- click to change it"
: "on no plot, so there is no trace to colour");
if (plotted) {
// A gesture on the label rather than a `GtkButton` around it: a button brings its own
// padding, and padding on this row is what made the list twice as tall once already (see
// this function's note). The chip also has to stay exactly one disc wide, or the names
// beside it shift as signals are placed.
gtk_widget_set_cursor_from_name(chip, "pointer");
GtkGesture* pick = gtk_gesture_click_new();
g_object_set_data(G_OBJECT(pick), "udpscope-signal", GSIZE_TO_POINTER(i));
g_signal_connect(pick, "pressed",
G_CALLBACK(+[](GtkGestureClick* g, int, double, double, gpointer user) {
App* a = static_cast<App*>(user);
// 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),
"udpscope-signal"))));
}),
&app);
gtk_widget_add_controller(chip, GTK_EVENT_CONTROLLER(pick));
}
gtk_box_append(GTK_BOX(box), chip);
GtkWidget* enable = gtk_check_button_new();
gtk_check_button_set_active(GTK_CHECK_BUTTON(enable), app.model->signal_enabled(i));
// A derived channel is computed here, not streamed: its tick is its own switch, and it has one
// in every view -- a file view has no acquisition to start, which is the other tick's question.
gtk_widget_set_tooltip_text(enable, sig.math ? "compute this channel" : "acquire this signal");
// A file view has nothing to acquire: an event's or a file's samples are already stored, and
// "acquire this signal" is a stream's question. Hidden rather than disabled -- a greyed tick invites
// the user to look for the reason, and the reason is that this mode has no acquisition at all.
gtk_widget_set_visible(enable, sig.math || app.waveform_name.empty());
g_object_set_data(G_OBJECT(enable), "udpscope-signal", GSIZE_TO_POINTER(i));
g_signal_connect(enable, "notify::active", G_CALLBACK(on_signal_switch), &app);
gtk_box_append(GTK_BOX(box), enable);
GtkWidget* name = gtk_label_new(sig.name.c_str());
gtk_widget_set_hexpand(name, true);
gtk_widget_set_halign(name, GTK_ALIGN_START);
gtk_label_set_ellipsize(GTK_LABEL(name), PANGO_ELLIPSIZE_END);
gtk_box_append(GTK_BOX(box), name);
if (!sig.unit.empty()) {
GtkWidget* unit = gtk_label_new(sig.unit.c_str());
gtk_widget_add_css_class(unit, "dim-label");
gtk_box_append(GTK_BOX(box), unit);
}
// The overflow: information and calibration are set once per session, so they
// cost a click here instead of a button on every row forever.
GtkWidget* menu_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
gtk_widget_set_margin_top(menu_box, 4);
gtk_widget_set_margin_bottom(menu_box, 4);
gtk_widget_set_margin_start(menu_box, 4);
gtk_widget_set_margin_end(menu_box, 4);
// The way back from a chosen colour. The chooser itself cannot carry a "default" button, and a
// setting with no undo is a trap; it lives here rather than on the chip because a second
// gesture on a one-glyph target is not something a user would find.
GtkWidget* recolor = menu_button("Default colour", nullptr);
gtk_widget_set_sensitive(recolor, app.rows->signal_colors().count(sig.name) != 0);
gtk_widget_set_tooltip_text(recolor, "back to the palette colour for this signal's position");
g_object_set_data(G_OBJECT(recolor), "udpscope-signal", GSIZE_TO_POINTER(i));
g_signal_connect(recolor, "clicked",
G_CALLBACK(+[](GtkButton* b, gpointer user) {
// The rebuild destroys this button (it is in the list it rebuilds).
outlive_the_rebuild(GTK_WIDGET(b));
reset_signal_color(
*static_cast<App*>(user),
static_cast<std::size_t>(GPOINTER_TO_SIZE(
g_object_get_data(G_OBJECT(b), "udpscope-signal"))));
}),
&app);
if (sig.math) {
// A math row carries its own menu. Information and Calibrate... are a *streamed*
// signal's: calibration is applied at ingest, and a derived channel has no stream to
// describe. No rename either -- the node editor owns a math channel's name and its unit,
// and a second place to change them would be a second answer to the same question.
GtkWidget* edit = menu_button("Edit graph...", nullptr);
g_object_set_data(G_OBJECT(edit), "udpscope-signal", GSIZE_TO_POINTER(i));
g_signal_connect(edit, "clicked", G_CALLBACK(on_math_edit_clicked), &app);
gtk_box_append(GTK_BOX(menu_box), edit);
gtk_box_append(GTK_BOX(menu_box), recolor);
GtkWidget* del = menu_button("Delete", nullptr);
gtk_widget_set_tooltip_text(del, "delete this math channel");
g_object_set_data(G_OBJECT(del), "udpscope-signal", GSIZE_TO_POINTER(i));
g_signal_connect(del, "clicked", G_CALLBACK(on_math_delete_clicked), &app);
gtk_box_append(GTK_BOX(menu_box), del);
} else {
GtkWidget* info = menu_button("Information", nullptr);
g_object_set_data(G_OBJECT(info), "udpscope-signal", GSIZE_TO_POINTER(i));
g_signal_connect(info, "clicked", G_CALLBACK(on_info_clicked), &app);
gtk_box_append(GTK_BOX(menu_box), info);
gtk_box_append(GTK_BOX(menu_box), recolor);
GtkWidget* cal = menu_button("Calibrate...", nullptr);
gtk_widget_set_tooltip_text(cal, "gain and offset, applied at ingest");
g_object_set_data(G_OBJECT(cal), "udpscope-signal", GSIZE_TO_POINTER(i));
g_signal_connect(cal, "clicked",
G_CALLBACK(+[](GtkButton* b, gpointer user) {
App* a = static_cast<App*>(user);
const std::size_t idx = static_cast<std::size_t>(
GPOINTER_TO_SIZE(g_object_get_data(G_OBJECT(b), "udpscope-signal")));
open_calibration(*a, GTK_WINDOW(a->window), idx);
}),
&app);
gtk_box_append(GTK_BOX(menu_box), cal);
}
GtkWidget* popover = gtk_popover_new();
gtk_widget_add_css_class(popover, "udpscope-menu");
gtk_popover_set_child(GTK_POPOVER(popover), menu_box);
GtkWidget* more = gtk_menu_button_new();
gtk_menu_button_set_icon_name(GTK_MENU_BUTTON(more), "view-more-symbolic");
gtk_menu_button_set_popover(GTK_MENU_BUTTON(more), popover);
gtk_widget_add_css_class(more, "flat");
gtk_widget_set_tooltip_text(more, "information and calibration");
gtk_box_append(GTK_BOX(box), more);
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));
// 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);
}
// Appended when there are no math channels at all: the `+` is the only way to create the first
// one, so it cannot be conditional on one already existing.
if (!math_divider_done) {
gtk_list_box_append(GTK_LIST_BOX(list), make_math_divider(app));
}
app.dbg_signal_plain = plain;
app.dbg_signal_math_rows = math_rows;
// The proposal as the model states it, to compare with the rows built: the signals not acquired, or every
// listed signal while the badges are overflowed.
app.dbg_signal_addable = 0;
for (std::size_t i : listed_signals(signals)) {
if (overflowed || !app.model->signal_enabled(i)) ++app.dbg_signal_addable;
}
app.dbg_signal_empty_label =
app.signals_empty != nullptr && gtk_widget_get_visible(app.signals_empty) != FALSE ? 1 : 0;
app.dbg_signal_rows = 0;
for (GtkWidget* c = gtk_widget_get_first_child(list); c != nullptr;
c = gtk_widget_get_next_sibling(c)) {
++app.dbg_signal_rows;
}
app.updating_panels = false;
}
/// The drop's one-line report, wherever the drop landed: the notice bar and the log, because a
/// refusal a reader cannot act on is only half an answer.
void drop_notice(App& app, const std::string& what) {
set_notice(app, what, 4);
std::fprintf(stderr, "[udpscope] drop: %s\n", what.c_str());
std::fflush(stderr);
}
/// Resolves a dropped signal to an acquired channel, enabling the signal when it has to be: the
/// channel index and its display name, or -1 when the signal cannot be acquired (a multi-element
/// signal has no channel to bind). Shared by the main window's drop and the scope windows'.
int resolve_drop_channel(App& app, std::size_t signal, std::string* name) {
// The dropped signal's channels are the ones the model built for it; take the
// first, or the first element if the signal is a vector.
const std::vector<ChannelInfo>& channels = app.model->channels();
int channel = -1;
for (std::size_t i = 0; i < channels.size(); ++i) {
if (channels[i].signal == signal) {
channel = static_cast<int>(i);
*name = channels[i].name;
break;
}
}
if (channel < 0) {
// The signal is not enabled: enable it, then take the channel that appears.
app.model->set_signal_enabled(signal, true);
const std::vector<ChannelInfo>& again = app.model->channels();
for (std::size_t i = 0; i < again.size(); ++i) {
if (again[i].signal == signal) {
channel = static_cast<int>(i);
*name = again[i].name;
break;
}
}
}
return channel;
}
bool assign_signal_to_row(App& app, std::size_t signal, int row) {
if (signal >= app.model->signals().size()) return false;
std::string name;
const int channel = resolve_drop_channel(app, signal, &name);
if (channel < 0) {
drop_notice(app, "cannot acquire '" + app.model->signals()[signal].name +
"' (multi-element signals are not acquired)");
return false;
}
int slot = -1;
for (int s = 0; s < RowSet::kSlotsPerRow; ++s) {
if (!app.rows->slot_bound(row, s)) {
slot = s;
break;
}
}
if (slot < 0) {
// Say what to do about it. A refusal a reader cannot act on is only half an answer: the two ways
// out are another plot or another row.
drop_notice(app, "plot " + app.rows->plot_label(row) +
" holds four signals already: drop it on another plot, or add a row with "
"the layout editor (L)");
return false;
}
// The slot remembers the *signal* (and element), never the channel index: the
// channel list is renumbered whenever a signal is enabled or disabled.
const std::vector<SignalInfo>& signals = app.model->signals();
const SignalInfo& sig = signals[signal];
app.rows->assign(row, slot, sig.name, name, signal_color_index(signals, signal));
schedule_layout_save(app);
app.relayout_pending = true;
rebuild_signal_panels(app); // the row's colour chip now has a trace to name
drop_notice(app, "plotting '" + name + "' on plot " + app.rows->plot_label(row));
return true;
}
bool assign_signal_to_scope(App& app, App::ScopeWindow& scope, std::size_t signal, int row) {
if (scope.rows == nullptr || signal >= app.model->signals().size()) return false;
if (row < 0 || row >= scope.rows->row_count()) return false;
std::string name;
const int channel = resolve_drop_channel(app, signal, &name);
if (channel < 0) {
drop_notice(app, "cannot acquire '" + app.model->signals()[signal].name +
"' (multi-element signals are not acquired)");
return false;
}
int slot = -1;
for (int s = 0; s < RowSet::kSlotsPerRow; ++s) {
if (!scope.rows->slot_bound(row, s)) {
slot = s;
break;
}
}
if (slot < 0) {
drop_notice(app, scope.name + " plot " + scope.rows->plot_label(row) + " holds four signals "
"already: drop it on another plot of that window");
return false;
}
// The slot remembers the *signal* (and element), never the channel index -- the same rule the
// main window's rows follow, for the same renumbering reason.
const std::vector<SignalInfo>& signals = app.model->signals();
const SignalInfo& sig = signals[signal];
scope.rows->assign(row, slot, sig.name, name, signal_color_index(signals, signal));
// The rebuild and the area's re-point are the window's own frame tick's (a drop arrives from
// inside this widget, and a synchronous rebuild would reset the drag controller under it). The
// signal page is refreshed now: its *Plot on* buttons read the scope's rows for "already on".
signal_pages_sync(app);
drop_notice(app, "plotting '" + name + "' on " + scope.name + " plot " +
scope.rows->plot_label(row));
return true;
}
gboolean on_scope_drop_channel(GtkDropTarget* target, const GValue* value, double x, double y,
gpointer user) {
App* app = static_cast<App*>(user);
auto* scope =
static_cast<App::ScopeWindow*>(g_object_get_data(G_OBJECT(target), "udpscope-scope"));
if (scope == nullptr || scope->area == nullptr || scope->rows == nullptr) return FALSE;
if (!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);
// Where the drop landed decides the row *of this window*: one area covers its figure's cells.
const int row = std::clamp(static_cast<int>(scope->area->row_at(x, y)), 0,
scope->rows->row_count() - 1);
if (!assign_signal_to_scope(*app, *scope, signal, row)) return FALSE;
if (plot::input::Controller* c = scope->area->row_controller(static_cast<plot::u32>(row))) {
c->on_key(plot::input::KeyEvent{plot::input::kKeyEscape, 0, true});
}
return TRUE;
}
gboolean on_drop_channel(GtkDropTarget*, const GValue* value, double x, double y,
gpointer user) {
App* app = static_cast<App*>(user);
if (!G_VALUE_HOLDS_STRING(value)) return FALSE;
// Where the drop landed decides the row: one area covers them all.
const int row = row_at(*app, x, y);
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);
if (!assign_signal_to_row(*app, signal, row)) return FALSE;
cancel_drag_on(*app, row);
return TRUE;
}
void rebuild_signal_panels(App& app) {
// Snapshot the model's lists under the lock: the acquisition thread replaces
// them on every CONFIG, and the panel build holds these references across many
// GTK calls (a use-after-free of the name strings otherwise).
std::vector<SignalInfo> signals;
bool have_signals = false;
// The generation this build answers to is read *before* the snapshot: a CONFIG landing between the two
// would otherwise be stamped as seen without having been, and the tick would never rebuild for it.
const unsigned generation = app.model->generation();
{
std::lock_guard<std::mutex> lock(app.model->mutex());
signals = app.model->signals();
have_signals = !signals.empty();
}
rebuild_signal_list(app, signals);
rebuild_trigger_signals(app);
refresh_window_limits(app);
// Nothing to configure before a CONFIG arrives: hide the whole column.
update_side_visibility(app);
if (!have_signals) {
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(app.trigger_toggle), FALSE);
}
signal_pages_sync(app);
sync_signal_badges(app);
app.signal_generation = generation;
}
GtkWidget* build_signal_panel(App& app) {
// The panel's frame -- the heading, the hint under it, and the list -- is a UI file. The rows are not:
// there is one per signal, carrying that signal's switch, colour, calibration and drop targets, and the
// whole list is rebuilt whenever the stream's signals change. `make test-ui` checks the file's types
// against the GIR; the id below is what keeps the code and the file agreeing on the name.
GtkBuilder* ui = ui_load(app, "/org/udpscope/ui/signals-panel.ui");
GtkWidget* box = GTK_WIDGET(gtk_builder_get_object(ui, "signal_panel"));
app.signal_list = GTK_WIDGET(gtk_builder_get_object(ui, "signal_list"));
// The heading row, where the contextual help's "?" joins (see `ui_context_help.cpp`).
app.signals_head = GTK_WIDGET(gtk_builder_get_object(ui, "signals_head"));
// The hint line under it, which is where the "signals_config" hint joins: the heading already
// carries one, and the rule the layer follows is a hint beside the thing it explains.
app.signals_note = GTK_WIDGET(gtk_builder_get_object(ui, "signals_note"));
app.signals_empty = GTK_WIDGET(gtk_builder_get_object(ui, "signals_empty"));
if (box == nullptr || app.signal_list == nullptr) {
// A missing id is a mistake in the `.ui`: the panel comes up empty rather than taking the window with
// it, and `[udpscope] signals panel:` reads the difference.
return box != nullptr ? box : gtk_box_new(GTK_ORIENTATION_VERTICAL, 6);
}
// Activating a row (a click on the row's own space; its tick, chip and menu consume their own clicks)
// opens that signal's page.
gtk_list_box_set_activate_on_single_click(GTK_LIST_BOX(app.signal_list), TRUE);
g_signal_connect(app.signal_list, "row-activated",
G_CALLBACK(+[](GtkListBox*, GtkListBoxRow* row, gpointer user) {
App* a = static_cast<App*>(user);
const std::size_t index = static_cast<std::size_t>(
GPOINTER_TO_SIZE(g_object_get_data(G_OBJECT(row), "udpscope-signal")));
std::string name;
{
std::lock_guard<std::mutex> lock(a->model->mutex());
const std::vector<SignalInfo>& s = a->model->signals();
if (index >= s.size()) return;
name = s[index].name;
}
if (signal_page_ensure(*a, name)) slideover_open(*a, "signal:" + name);
}),
&app);
return box;
}
/// Whether the signal list's first row still carries its acquire tick. A file view has nothing to
/// acquire -- the samples are stored -- so the tick goes; in live mode it is the control that decides
/// what is acquired at all.
int signal_tick_visible(App& app) {
GtkWidget* row =
app.signal_list != nullptr ? gtk_widget_get_first_child(app.signal_list) : nullptr;
if (row == nullptr) return -1;
for (GtkWidget* box = gtk_widget_get_first_child(row); box != nullptr;
box = gtk_widget_get_next_sibling(box)) {
for (GtkWidget* w = gtk_widget_get_first_child(box); w != nullptr;
w = gtk_widget_get_next_sibling(w)) {
if (GTK_IS_CHECK_BUTTON(w)) return gtk_widget_get_visible(w) ? 1 : 0;
}
}
return -1;
}
} // namespace ui_impl
} // namespace udpscope