diff --git a/src/ui_app.hpp b/src/ui_app.hpp index 51e4533..540dd22 100644 --- a/src/ui_app.hpp +++ b/src/ui_app.hpp @@ -1797,7 +1797,12 @@ void dock_measure_value(App& app, std::size_t i, const std::string& text); /// 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); +/// The dock shows at most this many events (the newest), then one dim "... N more, see Manage" row. +inline constexpr std::size_t kDockEventsMax = 24; +/// The first lists the store itself; the second takes a list the caller already holds (so one refresh +/// reads the cache directory once, not twice). Both keep the list's vertical scroll across the rebuild. void dock_rebuild_events(App& app); +void dock_rebuild_events(App& app, const std::vector& records); 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 diff --git a/src/ui_dock.cpp b/src/ui_dock.cpp index 60da952..23827e6 100644 --- a/src/ui_dock.cpp +++ b/src/ui_dock.cpp @@ -18,6 +18,7 @@ #include +#include #include #include #include @@ -238,7 +239,7 @@ 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; +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`). @@ -255,6 +256,11 @@ void on_manage_events(GtkButton*, gpointer user) { /// 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. +/// +/// 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; @@ -266,15 +272,6 @@ void on_dock_event_activated(GtkListBox*, GtkListBoxRow* row, gpointer user) { 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) { @@ -323,9 +320,47 @@ GtkWidget* build_events_dock(App& app) { 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); - 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); @@ -340,7 +375,11 @@ void dock_rebuild_events(App& app) { gtk_list_box_append(list, row); return; } - for (const EventRecord& r : records) { + // 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); @@ -355,10 +394,13 @@ void dock_rebuild_events(App& app) { 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); + // The latch's sequence number, as the pane'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); @@ -367,6 +409,22 @@ void dock_rebuild_events(App& app) { 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) { diff --git a/src/ui_events.cpp b/src/ui_events.cpp index 6d441e3..596ee17 100644 --- a/src/ui_events.cpp +++ b/src/ui_events.cpp @@ -782,10 +782,8 @@ void attach_preview_zoom(GtkWidget* frame, const std::string& path) { GConnectFlags(0)); } -/// A row's first line: when the event was latched, how long it is, and whether it is the one on -/// 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. +/// Whether the event is the one on screen: the one question the title's "(on screen)" and the dock's +/// filled mark both ask. bool event_on_screen(const App& app, const EventRecord& r) { return std::string(app.waveform_name) == r.file; } @@ -797,6 +795,10 @@ bool event_in_group(const App& app, const EventRecord& r) { [&](const EventRecord& g) { return g.file == r.file; }); } +/// A row's first line: when the event was latched, how long it is, and whether it is the one on +/// 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. std::string event_row_title(App& app, const EventRecord& r) { const std::time_t t = static_cast(r.wall_time); std::tm tm{}; @@ -828,7 +830,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. -static void rebuild_events_pane_list(App& app) { +static void rebuild_events_pane_list(App& app, std::vector& records) { 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); @@ -838,8 +840,8 @@ static void rebuild_events_pane_list(App& app) { // the one that finds out. app.events_thumb = nullptr; if (app.event_store == nullptr) return; - // Not const: a record whose preview had to be redrawn learns its thumbnail's path here. - std::vector records = app.event_store->list(); + // `records` is the caller's list, not const: a record whose preview had to be redrawn learns its + // thumbnail's path here. if (records.empty()) { GtkWidget* empty = gtk_label_new("no events yet -- arm the trigger"); gtk_widget_add_css_class(empty, "dim-label"); @@ -975,8 +977,12 @@ static void rebuild_events_pane_list(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); + // One read of the cache directory for both views (none without a store, which the pane's rebuild + // and the dock's both treat as nothing to list). + std::vector records; + if (app.event_store != nullptr) records = app.event_store->list(); + rebuild_events_pane_list(app, records); + if (app.event_store != nullptr) dock_rebuild_events(app, records); } /// Clicking a row replays it: the same loader `--open` uses, on the event's file. The acquisition is diff --git a/src/ui_probe.cpp b/src/ui_probe.cpp index f731011..36e8d10 100644 --- a/src/ui_probe.cpp +++ b/src/ui_probe.cpp @@ -5579,14 +5579,19 @@ void report_live_restored(App& a, const char* what) { /// 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) { +/// `marked` is the index of the first one (-1 for none). `max_ok` is the cap: no more than +/// `kDockEventsMax` event rows and one "more" row. `expected_marked` is read after a replay of a record +/// the caller names by file (`expected_file`): 1 when exactly one dock row is filled and its record is that +/// one (record identity, not a count), 0 otherwise, -1 when the caller names none. +static void report_dock_events(App& a, const char* what, const std::string& expected_file = std::string()) { std::vector dock_titles; + std::string filled_file; + int dock_children = 0; 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) { + const auto titles = [&members, &filled_file](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; @@ -5603,22 +5608,41 @@ static void report_dock_events(App& a, const char* what) { 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; + if (first != nullptr && *first < 0) { + *first = i; + filled_file = static_cast(g_object_get_data(G_OBJECT(row), "udpscope-event"))->file; + } } } }; + for (GtkWidget* row = a.dock_events_list != nullptr ? gtk_widget_get_first_child(a.dock_events_list) : nullptr; + row != nullptr; row = gtk_widget_get_next_sibling(row)) { + ++dock_children; + } titles(a.dock_events_list, &dock_titles, &on_screen, &marked); titles(a.events_list, &pane_titles, nullptr, nullptr); + // The dock shows the newest `kDockEventsMax` only: its titles are the pane's first ones, in order. + if (pane_titles.size() > kDockEventsMax) pane_titles.resize(kDockEventsMax); 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", + "on_screen=%d marked=%d members=%d max_ok=%d expected_marked=%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); + marked, members, dock_children <= static_cast(kDockEventsMax) + 1 ? 1 : 0, + expected_file.empty() ? -1 : (on_screen == 1 && filled_file == expected_file ? 1 : 0), what); std::fflush(stderr); } +/// The file of the record the Nth row of a list carries ("" for a row without one). +static std::string listed_file(GtkWidget* list, int index) { + if (list == nullptr) return std::string(); + GtkListBoxRow* row = gtk_list_box_get_row_at_index(GTK_LIST_BOX(list), index); + if (row == nullptr) return std::string(); + const auto* r = static_cast(g_object_get_data(G_OBJECT(row), "udpscope-event")); + return r != nullptr ? r->file : std::string(); +} + 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); @@ -5805,7 +5829,9 @@ 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"); + // The pane replayed its own row 0; the dock's list is the same list, so the dock's filled marker has to + // be on the row whose record is that one. + report_dock_events(*a, "after pane replay", listed_file(a->events_list, 0)); // 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. { @@ -5813,6 +5839,9 @@ gboolean events_probe_step(gpointer user) { 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 clicked_file = + real ? static_cast(g_object_get_data(G_OBJECT(row), "udpscope-event"))->file + : std::string(); 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 @@ -5821,7 +5850,7 @@ gboolean events_probe_step(gpointer user) { 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"); + report_dock_events(*a, "after dock click", clicked_file); } events_probe_next(*a, 2, 1200); return G_SOURCE_REMOVE; @@ -6054,10 +6083,11 @@ gboolean events_group_probe_step(gpointer user) { 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; + const std::string clicked_file = real ? listed_file(a->dock_events_list, 1) : std::string(); 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"); + report_dock_events(*a, "second dock click", clicked_file); } return G_SOURCE_REMOVE; }