From baf1c73b21749cf94c01dd0eedb6891565705c65 Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Tue, 3 Nov 2026 06:48:35 +0100 Subject: [PATCH] ui: compact events block in the dock Co-Authored-By: Claude Sonnet 4.6 --- src/ui.cpp | 4 +- src/ui_app.hpp | 19 ++++++ src/ui_dock.cpp | 163 ++++++++++++++++++++++++++++++++++++++++++++++ src/ui_events.cpp | 32 +++++++-- src/ui_probe.cpp | 74 +++++++++++++++++++++ 5 files changed, 287 insertions(+), 5 deletions(-) diff --git a/src/ui.cpp b/src/ui.cpp index 77f96c8..7dcb73d 100644 --- a/src/ui.cpp +++ b/src/ui.cpp @@ -5172,8 +5172,10 @@ void on_activate(GtkApplication* application, gpointer user) { gtk_widget_set_valign(app->measures_panel, GTK_ALIGN_START); slideover_add_page(*app, "cursors", "Cursors", app->cursors_panel); slideover_add_page(*app, "measure", "Measurements", app->measures_panel); - // The dock is the compact readouts alone (step 4 adds the events to it). + // The dock: the compact readouts, and under them the compact events list. gtk_box_append(GTK_BOX(app->dock_page), build_readouts_dock(*app)); + gtk_box_append(GTK_BOX(app->dock_page), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL)); + gtk_box_append(GTK_BOX(app->dock_page), build_events_dock(*app)); // The cursors start off, so their readout starts insensitive -- stated here by the one place that // knows the default, and the *body* and not the panel, because the panel's heading carries the diff --git a/src/ui_app.hpp b/src/ui_app.hpp index 8f24657..51e4533 100644 --- a/src/ui_app.hpp +++ b/src/ui_app.hpp @@ -809,6 +809,11 @@ struct App { /// to. 240 by default: enough for the preview and the numbers beside it, and not a plot's worth. GtkWidget* events_divider = nullptr; int events_pane_width = 240; + /// The Events block of the dock: the box, its list (compact rows, one per `EventRecord`, the record + /// attached to each row as `udpscope-event`) and the Manage button. + GtkWidget* dock_events_box = nullptr; + GtkWidget* dock_events_list = nullptr; + GtkWidget* dock_events_manage = nullptr; /// The comparison's second plot: its own placeholder, area and `RowSet`, and the paned that holds /// the two side by side. `rows_b` exists only once the second area is built, and the area is hidden /// (so the paned gives it no room and no handle) unless a comparison is up. @@ -1786,6 +1791,20 @@ void dock_rebuild_measures(App& app); void dock_cursor_cell(App& app, std::size_t line, const std::string& a, const std::string& b, const std::string& d); void dock_measure_value(App& app, std::size_t i, const std::string& text); +/// The Events block under the readouts (`src/ui_dock.cpp`): compact rows (marker, title, time; no +/// thumbnails) from `event_store->list()`, newest first, and a Manage button for the `events` page. +/// `dock_rebuild_events()` follows every change of what is *cached*, `dock_mark_events()` every change +/// of what is *on screen* (in place, no rebuild), `dock_events_set_sensitive()` is the one writer of the +/// block's sensitivity. All three are no-ops before the block is built. +GtkWidget* build_events_dock(App& app); +void dock_rebuild_events(App& app); +void dock_mark_events(App& app); +void dock_events_set_sensitive(App& app, bool available); +/// A row's first line, and the two questions its mark is made of (`ui_events.cpp`): the one function +/// for the pane and the dock, so the two cannot disagree about which event is on screen. +std::string event_row_title(App& app, const EventRecord& r); +bool event_on_screen(const App& app, const EventRecord& r); +bool event_in_group(const App& app, const EventRecord& r); /// The signal chip of a measurement's row, in the colour every other view names that signal with. std::string measure_chip_markup(App& app, const std::string& signal); std::string row_unit(App& app, int row); diff --git a/src/ui_dock.cpp b/src/ui_dock.cpp index c0a1693..60da952 100644 --- a/src/ui_dock.cpp +++ b/src/ui_dock.cpp @@ -8,13 +8,20 @@ // 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 pane, so the two cannot +// disagree. #include "ui_app.hpp" #include #include +#include #include +#include namespace udpscope { namespace ui_impl { @@ -226,5 +233,161 @@ void dock_measure_value(App& app, std::size_t i, const std::string& text) { 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 kEventTimeChars = 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 " "; +} + +void on_manage_events(GtkButton*, gpointer user) { + slideover_open(*static_cast(user), "events"); +} + +/// One click on a row replays it, as the pane'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. +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); +} + +std::string event_clock(const EventRecord& r) { + const std::time_t t = static_cast(r.wall_time); + std::tm tm{}; + localtime_r(&t, &tm); + char clock[32]; + std::strftime(clock, sizeof(clock), "%H:%M:%S", &tm); + return clock; +} + +} // 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; + GtkListBox* list = GTK_LIST_BOX(app.dock_events_list); + gtk_list_box_remove_all(list); + const std::vector records = app.event_store->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; + } + for (const EventRecord& r : records) { + 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); + const std::string clock = event_clock(r); + GtkWidget* when = fixed_label(clock.c_str(), kEventTimeChars, GTK_ALIGN_END); + gtk_widget_add_css_class(when, "dim-label"); + gtk_box_append(GTK_BOX(line), when); + + 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); + } +} + +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 diff --git a/src/ui_events.cpp b/src/ui_events.cpp index 8c087a9..6d441e3 100644 --- a/src/ui_events.cpp +++ b/src/ui_events.cpp @@ -52,6 +52,7 @@ void sync_events_availability(App& app) { // the button that shows it must not go with the switch. `events_cached` is refreshed once a second // from the tick -- the store's list is a directory read, not a per-frame question. const bool available = app.event_recording || app.replaying || app.events_cached; + dock_events_set_sensitive(app, available); gtk_widget_set_visible(app.events_toggle, available); if (!available) { gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(app.events_toggle), FALSE); @@ -132,11 +133,14 @@ bool record_event(App& app) { set_notice(app, "event cached: " + std::to_string(table.columns.size()) + " signals, " + std::to_string(table.rows()) + " samples", 3); - // Only while the pane is on screen: rebuilding every row per latch for a column nobody opened is - // work the tick owes nothing, and the toggle's own handler refreshes when it is asked for. + // The dock follows every latch; the pane's rows (with pictures) only while the pane is on screen: + // rebuilding them per latch for a column nobody opened is work the tick owes nothing, and the toggle's + // own handler refreshes when it is asked for. if (app.events_revealer != nullptr && gtk_revealer_get_child_revealed(GTK_REVEALER(app.events_revealer))) { refresh_events_pane(app); + } else { + dock_rebuild_events(app); } return true; } @@ -580,6 +584,7 @@ bool build_event_group(App& app, const std::vector& records, EventG // Fit on open, once: the group's own span, framed. // As above: the tick pins the axis to the group's own span and marks the trigger. sync_events_availability(app); + mark_events_on_screen(app); // the dock marks the group's members return true; } @@ -781,6 +786,17 @@ void attach_preview_zoom(GtkWidget* frame, const std::string& path) { /// screen. One function because two callers write it -- the row builder and the in-place /// re-mark -- and a mark that reads differently depending on which of them last ran would be /// worse than no mark. +bool event_on_screen(const App& app, const EventRecord& r) { + return std::string(app.waveform_name) == r.file; +} + +/// Whether the event is one of the members of the comparison or accumulation on screen. +bool event_in_group(const App& app, const EventRecord& r) { + if (app.group_kind == EventGroupKind::None) return false; + return std::any_of(app.group.begin(), app.group.end(), + [&](const EventRecord& g) { return g.file == r.file; }); +} + std::string event_row_title(App& app, const EventRecord& r) { const std::time_t t = static_cast(r.wall_time); std::tm tm{}; @@ -789,11 +805,12 @@ std::string event_row_title(App& app, const EventRecord& r) { std::strftime(clock, sizeof(clock), "%H:%M:%S", &tm); char title[160]; std::snprintf(title, sizeof(title), "%s %.3f s%s", clock, r.duration_s, - std::string(app.waveform_name) == r.file ? " (on screen)" : ""); + event_on_screen(app, r) ? " (on screen)" : ""); return title; } void mark_events_on_screen(App& app) { + dock_mark_events(app); if (app.events_list == nullptr) return; for (GtkWidget* row = gtk_widget_get_first_child(app.events_list); row != nullptr; row = gtk_widget_get_next_sibling(row)) { @@ -811,7 +828,7 @@ void mark_events_on_screen(App& app) { /// the whole pane describes whichever event happens to be newest rather than the one being read. The /// numbers are one line each: a list that has to load the samples to describe an event is a list that /// cannot show thirty of them. -void refresh_events_pane(App& app) { +static void rebuild_events_pane_list(App& app) { if (app.events_list == nullptr) return; while (GtkWidget* child = gtk_widget_get_first_child(app.events_list)) { gtk_list_box_remove(GTK_LIST_BOX(app.events_list), child); @@ -955,6 +972,13 @@ void refresh_events_pane(App& app) { } } +/// Both views of the cache, rebuilt from the same list: the pane's rows (with their pictures) and the +/// dock's compact ones. The dock follows even when the pane's list does not exist. +void refresh_events_pane(App& app) { + rebuild_events_pane_list(app); + dock_rebuild_events(app); +} + /// Clicking a row replays it: the same loader `--open` uses, on the event's file. The acquisition is /// stopped by `replay_event()` -- a live producer would overwrite the samples within a frame -- so the /// pane's own *Live* button is the way back. diff --git a/src/ui_probe.cpp b/src/ui_probe.cpp index ab465d6..f731011 100644 --- a/src/ui_probe.cpp +++ b/src/ui_probe.cpp @@ -5576,6 +5576,49 @@ void report_live_restored(App& a, const char* what) { std::fflush(stderr); } +/// The dock's Events block against the pane's list. `same_titles` compares each dock row's title label +/// with the pane row's, in order (both are rebuilt from the same list by `refresh_events_pane()`, and both +/// are marked by `mark_events_on_screen()`); `on_screen` counts the dock rows with the filled marker, and +/// `marked` is the index of the first one (-1 for none). +static void report_dock_events(App& a, const char* what) { + std::vector dock_titles; + std::vector pane_titles; + int on_screen = 0; + int marked = -1; + int members = 0; + const auto titles = [&members](GtkWidget* list, std::vector* out, int* filled, int* first) { + if (list == nullptr) return; + int i = 0; + for (GtkWidget* row = gtk_widget_get_first_child(list); row != nullptr; + row = gtk_widget_get_next_sibling(row), ++i) { + if (g_object_get_data(G_OBJECT(row), "udpscope-event") == nullptr) continue; + GtkWidget* name = GTK_WIDGET(g_object_get_data(G_OBJECT(row), "udpscope-event-title")); + if (name == nullptr) continue; + out->push_back(gtk_label_get_text(GTK_LABEL(name))); + GtkWidget* marker = GTK_WIDGET(g_object_get_data(G_OBJECT(row), "udpscope-event-marker")); + if (filled != nullptr && marker != nullptr && + std::strcmp(gtk_label_get_text(GTK_LABEL(marker)), "\xE2\x97\x8B") == 0) { + ++members; + } + if (filled != nullptr && marker != nullptr && + std::strcmp(gtk_label_get_text(GTK_LABEL(marker)), "\xE2\x97\x8F") == 0) { + ++*filled; + if (first != nullptr && *first < 0) *first = i; + } + } + }; + titles(a.dock_events_list, &dock_titles, &on_screen, &marked); + titles(a.events_list, &pane_titles, nullptr, nullptr); + std::fprintf(stderr, + "[events] dock events: rows=%zu pane_rows=%zu same_titles=%d sensitive=%d dock_w=%d " + "on_screen=%d marked=%d members=%d at=%s\n", + dock_titles.size(), pane_titles.size(), dock_titles == pane_titles ? 1 : 0, + a.dock_events_box != nullptr && gtk_widget_is_sensitive(a.dock_events_box) ? 1 : 0, + a.dock_events_box != nullptr ? gtk_widget_get_width(a.dock_events_box) : -1, on_screen, + marked, members, what); + std::fflush(stderr); +} + void events_probe_next(App& a, int step, guint delay_ms) { a.dbg_events_step = step; g_timeout_add(delay_ms, events_probe_step, &a); @@ -5762,6 +5805,24 @@ gboolean events_probe_step(gpointer user) { a->events_thumb != nullptr && gtk_widget_get_mapped(a->events_thumb) ? 1 : 0, rows_kept ? 1 : 0, selected_now, a->dbg_events_selected); std::fflush(stderr); + report_dock_events(*a, "after pane replay"); + // One click on a dock row, the gesture: a *second* event when there is one, so the marker has to + // move. Through `row-activated`, which is what a single click emits. + { + GtkListBox* dock = GTK_LIST_BOX(a->dock_events_list); + GtkListBoxRow* row = gtk_list_box_get_row_at_index(dock, 1); + if (row == nullptr) row = gtk_list_box_get_row_at_index(dock, 0); + const bool real = row != nullptr && g_object_get_data(G_OBJECT(row), "udpscope-event") != nullptr; + const std::string before = a->waveform_name; + if (real) g_signal_emit_by_name(dock, "row-activated", row); + // Already replaying (the pane's click above), so `replaying` alone cannot tell the click from the + // state it found: `waveform_changed` says the click opened the row's own event (1 when it was a + // second event, 0 when there was only the one to click again). + std::fprintf(stderr, "[events] dock replay: clicked=%d replaying=%d waveform_changed=%d\n", + real ? 1 : 0, a->replaying ? 1 : 0, before != a->waveform_name ? 1 : 0); + std::fflush(stderr); + report_dock_events(*a, "after dock click"); + } events_probe_next(*a, 2, 1200); return G_SOURCE_REMOVE; } @@ -5834,6 +5895,7 @@ gboolean events_group_probe_step(gpointer user) { // The live settings this leg will be read against, taken *here* rather than inherited from the // replay leg: a group is a second, independent way into a file view, and its way back has to put // back what was on screen when the button was pressed. + report_dock_events(*a, "two events latched"); capture_live_expectation(*a); if (a->events_compare != nullptr) g_signal_emit_by_name(a->events_compare, "clicked"); // Read one step later: a rebuilt row's traces are bound by the next *frame*, so a reading taken in @@ -5853,6 +5915,7 @@ gboolean events_group_probe_step(gpointer user) { group_visible_traces(*a, 0), group_visible_traces(*a, 1), a->rows->time_window(), a->event_recording ? 1 : 0, a->events_revealer != nullptr && gtk_widget_get_visible(a->events_revealer) ? 1 : 0); + report_dock_events(*a, "comparison up"); events_group_probe_next(*a, 46, 400); return G_SOURCE_REMOVE; } @@ -5985,6 +6048,17 @@ gboolean events_group_probe_step(gpointer user) { if (a->dbg_events_step == 61) { report_live_restored(*a, "after a group"); + // A single click on the *second* dock row now that the app is live again: a different event than the + // last (there are several by now), so the filled marker has to move to that row. + { + GtkListBox* dock = GTK_LIST_BOX(a->dock_events_list); + GtkListBoxRow* row = gtk_list_box_get_row_at_index(dock, 1); + const bool real = row != nullptr && g_object_get_data(G_OBJECT(row), "udpscope-event") != nullptr; + if (real) g_signal_emit_by_name(dock, "row-activated", row); + std::fprintf(stderr, "[events] dock replay: clicked=%d replaying=%d (second click, live before)\n", + real ? 1 : 0, a->replaying ? 1 : 0); + report_dock_events(*a, "second dock click"); + } return G_SOURCE_REMOVE; }