// SPDX-License-Identifier: MIT // SPDX-FileCopyrightText: 2026 Martino Ferrari // The compact readouts at the top of the dock: a read-only copy of the cursors table and of the // measurement values, in a fixed width, with an Edit button for each that opens the full panel. // // The strings are not formatted here. `update_cursors_pane()` and `update_measures()` format each value // once and hand the same variable to the page's label and to `dock_cursor_cell()` / `dock_measure_value()`, // so the two views cannot disagree. Every label is ellipsized at a fixed `width-chars`, so the dock's // layout does not depend on what a value reads; the full text is the label's tooltip. // // Below them, the Events block: one compact row per cached event (marker, title, time; no thumbnails), // a Manage button for the full list, and one click on a row to replay it. Its rows are built from the // same `event_store->list()` and the same `event_row_title()` as the events page, so the two cannot // disagree. #include "ui_app.hpp" #include #include #include #include #include #include namespace udpscope { namespace ui_impl { namespace { /// The width, in characters, of a value cell and of a `who` / type cell. Fixed, so the dock is as wide /// with `--` as with `-123.456 mV`; their sum (5 + 3 x 9 characters) is what keeps the readouts inside the /// dock's 280 px. constexpr int kValueChars = 9; constexpr int kNameChars = 5; GtkWidget* fixed_label(const char* text, int chars, GtkAlign halign) { GtkWidget* l = gtk_label_new(text); gtk_widget_set_halign(l, halign); gtk_label_set_ellipsize(GTK_LABEL(l), PANGO_ELLIPSIZE_END); gtk_label_set_width_chars(GTK_LABEL(l), chars); gtk_label_set_max_width_chars(GTK_LABEL(l), chars); gtk_widget_add_css_class(l, "caption"); return l; } /// Sets a value label's text and, with it, the tooltip that carries what the ellipsis cut. The text is /// compared first so the per-frame write of an unchanged value costs nothing. void set_value_text(GtkWidget* label, const std::string& text) { if (label == nullptr) return; if (std::strcmp(gtk_label_get_text(GTK_LABEL(label)), text.c_str()) == 0) return; gtk_label_set_text(GTK_LABEL(label), text.c_str()); gtk_widget_set_tooltip_text(label, text.c_str()); } void on_edit_cursors(GtkButton*, gpointer user) { slideover_open(*static_cast(user), "cursors"); } void on_edit_measures(GtkButton*, gpointer user) { slideover_open(*static_cast(user), "measure"); } /// A click on a measurement's line opens that measurement's dialog, as the page row's `edit` button /// does. The line carries its index (plus one, so that zero is "none") in its data. void on_measure_line_pressed(GtkGestureClick* gesture, int n_press, double, double, gpointer user) { if (n_press != 1) return; GtkWidget* line = gtk_event_controller_get_widget(GTK_EVENT_CONTROLLER(gesture)); const int idx = GPOINTER_TO_INT(g_object_get_data(G_OBJECT(line), "udpscope-measure-index")) - 1; if (idx < 0) return; open_measure_dialog(*static_cast(user), idx); } /// A section's heading row: its title at the left, its Edit button at the right. GtkWidget* section_head(const char* title, GtkWidget** edit_out, GCallback on_edit, App* app, const char* tip) { GtkWidget* row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6); GtkWidget* t = gtk_label_new(title); gtk_widget_set_halign(t, GTK_ALIGN_START); gtk_widget_set_hexpand(t, true); gtk_widget_add_css_class(t, "caption-heading"); gtk_box_append(GTK_BOX(row), t); GtkWidget* edit = gtk_button_new_with_label("Edit"); gtk_widget_add_css_class(edit, "flat"); gtk_widget_set_tooltip_text(edit, tip); g_signal_connect(edit, "clicked", on_edit, app); gtk_box_append(GTK_BOX(row), edit); *edit_out = edit; return row; } /// The same header `cursors_header()` writes into the page's table, small. void dock_cursors_header(GtkGrid* grid) { const char* texts[4] = {"", "A", "B", "delta"}; for (int c = 0; c < 4; ++c) { GtkWidget* l = fixed_label(texts[c], c == 0 ? kNameChars : kValueChars, c == 0 ? GTK_ALIGN_START : GTK_ALIGN_END); gtk_widget_add_css_class(l, "dim-label"); gtk_grid_attach(grid, l, c, 0, 1, 1); } } } // namespace GtkWidget* build_readouts_dock(App& app) { GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 4); gtk_widget_set_margin_top(box, 6); gtk_widget_set_margin_bottom(box, 6); gtk_widget_set_margin_start(box, 6); gtk_widget_set_margin_end(box, 6); GtkWidget* heading = gtk_label_new("Readouts"); gtk_widget_set_halign(heading, GTK_ALIGN_START); gtk_widget_add_css_class(heading, "heading"); gtk_box_append(GTK_BOX(box), heading); gtk_box_append(GTK_BOX(box), section_head("Cursors", &app.dock_cursors_edit, G_CALLBACK(on_edit_cursors), &app, "the cursors' full panel")); app.dock_cursor_grid = gtk_grid_new(); gtk_grid_set_row_spacing(GTK_GRID(app.dock_cursor_grid), 2); gtk_grid_set_column_spacing(GTK_GRID(app.dock_cursor_grid), 4); gtk_box_append(GTK_BOX(box), app.dock_cursor_grid); gtk_box_append(GTK_BOX(box), section_head("Measurements", &app.dock_measures_edit, G_CALLBACK(on_edit_measures), &app, "the measurements' full panel")); app.dock_measures_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 2); gtk_box_append(GTK_BOX(box), app.dock_measures_box); app.dock_readouts = box; // The tables are built from the pages' current lines, which exist already when the pages were built // first; otherwise the pages' own rebuild fills them. dock_rebuild_cursors(app); dock_rebuild_measures(app); return box; } void dock_rebuild_cursors(App& app) { if (app.dock_cursor_grid == nullptr) return; GtkGrid* grid = GTK_GRID(app.dock_cursor_grid); GtkWidget* child = gtk_widget_get_first_child(app.dock_cursor_grid); while (child != nullptr) { GtkWidget* next = gtk_widget_get_next_sibling(child); gtk_grid_remove(grid, child); child = next; } app.dock_cursor_lines.clear(); dock_cursors_header(grid); int at = 1; for (const App::CursorLine& src : app.cursors_lines) { App::DockCursorLine line; line.label = fixed_label("", kNameChars, GTK_ALIGN_START); if (src.label != nullptr) { // The page's `who` cell, verbatim: a chip and a name as markup, or plain text. const char* text = gtk_label_get_label(GTK_LABEL(src.label)); if (gtk_label_get_use_markup(GTK_LABEL(src.label))) { gtk_label_set_markup(GTK_LABEL(line.label), text); } else { gtk_label_set_text(GTK_LABEL(line.label), text); } const char* tip = gtk_widget_get_tooltip_text(src.label); gtk_widget_set_tooltip_text(line.label, tip != nullptr ? tip : gtk_label_get_text(GTK_LABEL(src.label))); } gtk_grid_attach(grid, line.label, 0, at, 1, 1); GtkWidget** cells[3] = {&line.a, &line.b, &line.d}; for (int c = 0; c < 3; ++c) { GtkWidget* v = fixed_label("", kValueChars, GTK_ALIGN_END); gtk_widget_add_css_class(v, "numeric"); gtk_label_set_selectable(GTK_LABEL(v), true); gtk_grid_attach(grid, v, c + 1, at, 1, 1); *cells[c] = v; } app.dock_cursor_lines.push_back(line); ++at; } } void dock_rebuild_measures(App& app) { if (app.dock_measures_box == nullptr) return; GtkWidget* child = gtk_widget_get_first_child(app.dock_measures_box); while (child != nullptr) { GtkWidget* next = gtk_widget_get_next_sibling(child); gtk_box_remove(GTK_BOX(app.dock_measures_box), child); child = next; } app.dock_measure_labels.clear(); if (app.measures.empty()) { GtkWidget* none = gtk_label_new("no measurements"); gtk_widget_set_halign(none, GTK_ALIGN_START); gtk_widget_add_css_class(none, "caption"); gtk_widget_add_css_class(none, "dim-label"); gtk_box_append(GTK_BOX(app.dock_measures_box), none); return; } for (std::size_t i = 0; i < app.measures.size(); ++i) { const Measure& m = app.measures[i]; GtkWidget* line = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6); g_object_set_data(G_OBJECT(line), "udpscope-measure-index", GINT_TO_POINTER(static_cast(i) + 1)); GtkWidget* chip = gtk_label_new(""); gtk_label_set_markup(GTK_LABEL(chip), measure_chip_markup(app, m.signal).c_str()); gtk_widget_set_tooltip_text(chip, m.signal.c_str()); gtk_box_append(GTK_BOX(line), chip); GtkWidget* kind = fixed_label(measure_type_name(m.type), 12, GTK_ALIGN_START); gtk_widget_set_hexpand(kind, true); gtk_widget_set_tooltip_text(kind, (m.signal + "\n" + measure_type_name(m.type)).c_str()); gtk_box_append(GTK_BOX(line), kind); GtkWidget* value = fixed_label("--", kValueChars, GTK_ALIGN_END); gtk_widget_add_css_class(value, "numeric"); gtk_label_set_selectable(GTK_LABEL(value), true); gtk_box_append(GTK_BOX(line), value); // On the line and not on the value label, which is selectable and so keeps its own clicks. GtkGesture* click = gtk_gesture_click_new(); g_signal_connect(click, "pressed", G_CALLBACK(on_measure_line_pressed), &app); gtk_widget_add_controller(line, GTK_EVENT_CONTROLLER(click)); gtk_box_append(GTK_BOX(app.dock_measures_box), line); app.dock_measure_labels.push_back(value); } } void dock_cursor_cell(App& app, std::size_t line, const std::string& a, const std::string& b, const std::string& d) { if (line >= app.dock_cursor_lines.size()) return; // mid-rebuild const App::DockCursorLine& l = app.dock_cursor_lines[line]; set_value_text(l.a, a); set_value_text(l.b, b); set_value_text(l.d, d); } void dock_measure_value(App& app, std::size_t i, const std::string& text) { if (i >= app.dock_measure_labels.size()) return; // mid-rebuild set_value_text(app.dock_measure_labels[i], text); } namespace { /// The widths of an event row's three cells, in characters. Fixed, so the Events block is as wide with /// one event as with thirty (the marker is one cell wide even when it is empty). constexpr int kEventTitleChars = 18; constexpr int kEventSeqChars = 8; /// The one-character mark of a row: filled for the event on screen, hollow for a member of the group on /// screen, blank for the rest. Both questions are the ones `event_row_title()` asks (`ui_events.cpp`). const char* dock_event_marker(const App& app, const EventRecord& r) { if (event_on_screen(app, r)) return "\xE2\x97\x8F"; // U+25CF black circle if (event_in_group(app, r)) return "\xE2\x97\x8B"; // U+25CB white circle return " "; } /// Opens the `events` page through `open_events_page()` (which refreshes the list only if it is not /// already open). The button is not a child of either list, so the rebuild cannot free the widget it runs from. void on_manage_events(GtkButton*, gpointer user) { open_events_page(*static_cast(user)); } /// One click on a row replays it, as the page list's activation does: `replay_event()` and then the in-place /// re-mark. The record is copied first, because nothing it triggers may be allowed to free the row it /// was read from. /// /// Safety depends on `replay_event()` never rebuilding the dock's list (it must go through /// `dock_mark_events()`, which rewrites labels in place): a rebuild here would free this very row, and /// `row`, `record` and the list box's own emission of `row-activated` would all be dangling. The copy /// guards the record only, not the row. If a replay ever has to refresh the cache's view, defer it. void on_dock_event_activated(GtkListBox*, GtkListBoxRow* row, gpointer user) { App& app = *static_cast(user); if (row == nullptr) return; const auto* record = static_cast(g_object_get_data(G_OBJECT(row), "udpscope-event")); if (record == nullptr) return; const EventRecord copy = *record; replay_event(app, copy); mark_events_on_screen(app); } } // namespace GtkWidget* build_events_dock(App& app) { GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 4); gtk_widget_set_margin_top(box, 6); gtk_widget_set_margin_bottom(box, 6); gtk_widget_set_margin_start(box, 6); gtk_widget_set_margin_end(box, 6); GtkWidget* head = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6); GtkWidget* title = gtk_label_new("Events"); gtk_widget_set_halign(title, GTK_ALIGN_START); gtk_widget_set_hexpand(title, true); gtk_widget_add_css_class(title, "heading"); gtk_box_append(GTK_BOX(head), title); GtkWidget* manage = gtk_button_new_with_label("Manage"); gtk_widget_add_css_class(manage, "flat"); gtk_widget_set_tooltip_text(manage, "the events' full list and actions"); g_signal_connect(manage, "clicked", G_CALLBACK(on_manage_events), &app); gtk_box_append(GTK_BOX(head), manage); gtk_box_append(GTK_BOX(box), head); GtkWidget* list = gtk_list_box_new(); gtk_list_box_set_selection_mode(GTK_LIST_BOX(list), GTK_SELECTION_NONE); gtk_list_box_set_activate_on_single_click(GTK_LIST_BOX(list), true); gtk_widget_add_css_class(list, "boxed-list"); g_signal_connect(list, "row-activated", G_CALLBACK(on_dock_event_activated), &app); // A fixed height (about four rows), so the block is as tall with no events as with thirty and the // readouts above it do not move when one is latched. 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_min_content_height(GTK_SCROLLED_WINDOW(scroller), 112); gtk_scrolled_window_set_max_content_height(GTK_SCROLLED_WINDOW(scroller), 112); gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(scroller), list); gtk_box_append(GTK_BOX(box), scroller); app.dock_events_box = box; app.dock_events_list = list; app.dock_events_manage = manage; // Insensitive until the first availability sync says otherwise: on screen and greyed, never hidden. gtk_widget_set_sensitive(box, FALSE); dock_rebuild_events(app); return box; } void dock_rebuild_events(App& app) { if (app.dock_events_list == nullptr || app.event_store == nullptr) return; dock_rebuild_events(app, app.event_store->list()); } namespace { /// Puts the list's vertical scroll back after a refill. The adjustment's bounds are only updated at the /// next allocation, so the value set at once is clamped against the old ones; an idle (which runs after the /// frame that laid the new rows out) sets it again against the new bounds, where the clamp is exact. void restore_dock_scroll(GtkWidget* list, double value) { GtkWidget* scroller = gtk_widget_get_ancestor(list, GTK_TYPE_SCROLLED_WINDOW); if (scroller == nullptr) return; GtkAdjustment* adj = gtk_scrolled_window_get_vadjustment(GTK_SCROLLED_WINDOW(scroller)); gtk_adjustment_set_value(adj, value); struct Pending { GtkAdjustment* adj; double value; }; auto* p = new Pending{GTK_ADJUSTMENT(g_object_ref(adj)), value}; g_idle_add_full( G_PRIORITY_DEFAULT_IDLE, [](gpointer d) -> gboolean { auto* q = static_cast(d); gtk_adjustment_set_value(q->adj, q->value); g_object_unref(q->adj); delete q; return G_SOURCE_REMOVE; }, p, nullptr); } } // namespace void dock_rebuild_events(App& app, const std::vector& records) { if (app.dock_events_list == nullptr) return; GtkListBox* list = GTK_LIST_BOX(app.dock_events_list); GtkWidget* scroller = gtk_widget_get_ancestor(app.dock_events_list, GTK_TYPE_SCROLLED_WINDOW); const double scroll = scroller != nullptr ? gtk_adjustment_get_value(gtk_scrolled_window_get_vadjustment(GTK_SCROLLED_WINDOW(scroller))) : 0.0; gtk_list_box_remove_all(list); if (records.empty()) { GtkWidget* none = gtk_label_new("no events yet"); gtk_widget_set_halign(none, GTK_ALIGN_START); gtk_widget_add_css_class(none, "caption"); gtk_widget_add_css_class(none, "dim-label"); gtk_widget_set_margin_start(none, 6); gtk_widget_set_margin_top(none, 4); gtk_widget_set_margin_bottom(none, 4); GtkWidget* row = gtk_list_box_row_new(); gtk_list_box_row_set_child(GTK_LIST_BOX_ROW(row), none); gtk_list_box_row_set_activatable(GTK_LIST_BOX_ROW(row), false); gtk_list_box_append(list, row); return; } // The newest `kDockEventsMax` only: the list is newest first, and a block that builds four widgets per // cached event is a cost that grows with a cache the user may have set to unbounded. const std::size_t shown = std::min(records.size(), kDockEventsMax); for (std::size_t i = 0; i < shown; ++i) { const EventRecord& r = records[i]; GtkWidget* row = gtk_list_box_row_new(); GtkWidget* line = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6); gtk_widget_set_margin_start(line, 6); gtk_widget_set_margin_end(line, 6); gtk_widget_set_margin_top(line, 3); gtk_widget_set_margin_bottom(line, 3); GtkWidget* marker = fixed_label(dock_event_marker(app, r), 1, GTK_ALIGN_CENTER); gtk_box_append(GTK_BOX(line), marker); const std::string text = event_row_title(app, r); GtkWidget* name = fixed_label(text.c_str(), kEventTitleChars, GTK_ALIGN_START); gtk_widget_set_hexpand(name, true); gtk_widget_set_tooltip_text(name, text.c_str()); gtk_box_append(GTK_BOX(line), name); // The latch's sequence number, as the page row's third line ends with it: the title already carries the // clock, so this is the part of the row that is not in it. const std::string seq = "#" + std::to_string(r.seq); GtkWidget* num = fixed_label(seq.c_str(), kEventSeqChars, GTK_ALIGN_END); gtk_widget_set_tooltip_text(num, ("latch " + seq).c_str()); gtk_widget_add_css_class(num, "dim-label"); gtk_box_append(GTK_BOX(line), num); g_object_set_data(G_OBJECT(row), "udpscope-event-marker", marker); g_object_set_data(G_OBJECT(row), "udpscope-event-title", name); g_object_set_data_full(G_OBJECT(row), "udpscope-event", new EventRecord(r), [](gpointer p) { delete static_cast(p); }); gtk_list_box_row_set_child(GTK_LIST_BOX_ROW(row), line); gtk_list_box_append(list, row); } if (records.size() > shown) { // A last row with no record (like the placeholder: `dock_mark_events()` skips it, and it does nothing // when activated). Fixed text and the same ellipsis as the rest, so it cannot widen the dock. const std::string more = "\xE2\x80\xA6 " + std::to_string(records.size() - shown) + " more, see Manage"; GtkWidget* label = fixed_label(more.c_str(), kEventTitleChars + kEventSeqChars, GTK_ALIGN_START); gtk_widget_add_css_class(label, "dim-label"); gtk_widget_set_tooltip_text(label, more.c_str()); gtk_widget_set_margin_start(label, 6); gtk_widget_set_margin_top(label, 4); gtk_widget_set_margin_bottom(label, 4); GtkWidget* row = gtk_list_box_row_new(); gtk_list_box_row_set_child(GTK_LIST_BOX_ROW(row), label); gtk_list_box_row_set_activatable(GTK_LIST_BOX_ROW(row), false); gtk_list_box_append(list, row); } restore_dock_scroll(app.dock_events_list, scroll); } void dock_mark_events(App& app) { if (app.dock_events_list == nullptr) return; for (GtkWidget* row = gtk_widget_get_first_child(app.dock_events_list); row != nullptr; row = gtk_widget_get_next_sibling(row)) { const auto* record = static_cast(g_object_get_data(G_OBJECT(row), "udpscope-event")); GtkWidget* marker = GTK_WIDGET(g_object_get_data(G_OBJECT(row), "udpscope-event-marker")); GtkWidget* name = GTK_WIDGET(g_object_get_data(G_OBJECT(row), "udpscope-event-title")); // The "no events yet" placeholder carries none of the three: skipped. if (record == nullptr || marker == nullptr || name == nullptr) continue; gtk_label_set_text(GTK_LABEL(marker), dock_event_marker(app, *record)); set_value_text(name, event_row_title(app, *record)); } } void dock_events_set_sensitive(App& app, bool available) { if (app.dock_events_box == nullptr) return; gtk_widget_set_sensitive(app.dock_events_box, available); } } // namespace ui_impl } // namespace udpscope