// 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,\n// and the drop that puts a signal on a row. #include "ui_app.hpp" #include #include namespace udpscope { namespace ui_impl { void on_signal_switch(GObject* obj, GParamSpec*, gpointer user) { App* app = static_cast(user); if (app->updating_panels) return; const std::size_t index = static_cast( 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& 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(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 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& 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 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(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& 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 pick(static_cast(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(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(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, bool with_add) { 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); // Locked: the heading stays, because the list it divides is the one being read, but the `+` that // creates a channel does not. A `+` on a locked list is a button that opens the editor the lock // exists to keep shut. if (with_add) { 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. static 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(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(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 lock(app.model->mutex()); const std::vector& 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(user); const std::size_t index = static_cast( 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(user); const std::size_t index = static_cast( 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); } /// 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& 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, colours and drags is the signal. std::vector 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; for (std::size_t i = 0; i < signals.size(); ++i) { if (ScopeModel::is_time_signal(signals, i)) continue; // the time base is not plottable // Locked: a row per signal that is *on*, which is the list a user reads rather than the list they // configure. Turning one on is what the pane's [edit] button is for. if (app.signals_locked && !app.model->signal_enabled(i)) continue; 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) rows.push_back(i); } 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, !app.signals_locked)); } if (!sig.math) ++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 && !app.signals_locked) { // 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(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. gtk_gesture_set_state(GTK_GESTURE(g), GTK_EVENT_SEQUENCE_CLAIMED); open_signal_color(*a, static_cast(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); // Locked: the switch decides what is acquired, so it is a configuration control and it goes with // the rest of them. if (!app.signals_locked) 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) { App* a = static_cast(user); const std::size_t idx = static_cast( GPOINTER_TO_SIZE(g_object_get_data(G_OBJECT(b), "udpscope-signal"))); std::string name; { std::lock_guard lock(a->model->mutex()); const std::vector& sigs = a->model->signals(); if (idx >= sigs.size()) return; name = sigs[idx].name; } a->rows->clear_signal_color(name); a->relayout_pending = true; rebuild_signal_panels(*a); schedule_layout_save(*a); }), &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(user); const std::size_t idx = static_cast( 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"); // Locked: the menu holds calibration, deletion and a rename, which are exactly what this mode is // not for. The popover is left unattached, so nothing can reach it. if (!app.signals_locked) gtk_box_append(GTK_BOX(box), more); gtk_list_box_row_set_child(GTK_LIST_BOX_ROW(row), box); 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(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(user), true); }), &app); g_signal_connect(source, "drag-end", G_CALLBACK(+[](GtkDragSource*, GdkDrag*, gboolean, gpointer user) { row_drag_sidebar(*static_cast(user), false); }), &app); gtk_widget_add_controller(row, GTK_EVENT_CONTROLLER(source)); 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.signals_locked)); } app.dbg_signal_plain = plain; 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; } gboolean on_drop_channel(GtkDropTarget*, const GValue* value, double x, double y, gpointer user) { App* app = static_cast(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); const std::size_t signal = static_cast(std::atoll(text ? text : "0")); if (signal >= app->model->signals().size()) return FALSE; const auto notice = [&](const std::string& what) { set_notice(*app, what, 4); std::fprintf(stderr, "[udpscope] drop: %s\n", what.c_str()); std::fflush(stderr); }; // 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& channels = app->model->channels(); int channel = -1; std::string name; for (std::size_t i = 0; i < channels.size(); ++i) { if (channels[i].signal == signal) { channel = static_cast(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& again = app->model->channels(); for (std::size_t i = 0; i < again.size(); ++i) { if (again[i].signal == signal) { channel = static_cast(i); name = again[i].name; break; } } } if (channel < 0) { notice("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. notice("plot " + std::to_string(row + 1) + " 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& 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 cancel_drag_on(*app, row); notice("plotting '" + name + "' on plot " + std::to_string(row + 1)); 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 signals; bool have_signals = false; { std::lock_guard 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); } app.signal_generation = app.model->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_edit = GTK_WIDGET(gtk_builder_get_object(ui, "signals_edit")); if (app.signals_edit != nullptr) { g_signal_connect(app.signals_edit, "clicked", G_CALLBACK(on_pane_edit_clicked), &app); } 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); } return box; } /// The pane's availability as one reading: the history button on the bar, the pane revealed, and the /// switch that decides half of it. Both the button and the pane follow trigger mode *and* recording, /// so every step that changes either prints all three. /// 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