ui: the sidebar's tabs become one dock; Measure and Signals by keyboard section; session keeps the dock's state

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Martino FerrariandClaude Opus 4.6 committed 2026-11-02 20:37:55 +01:00
1 parent 31ff778e07
commit 4b65197f56
19 files changed
+421 -419

No files matched your search

+177 -62
View File
@@ -104,12 +104,6 @@ gboolean exit_probe_step3(gpointer user) {
a->dbg_signal_h = gtk_widget_get_height(a->signal_panel);
a->dbg_trigger_w = gtk_widget_get_width(a->trigger_panel);
a->dbg_trigger_h = gtk_widget_get_height(a->trigger_panel);
// The switcher's size as allocated: read here and not on the first tick, where the window has not
// been allocated yet and every widget still measures 0x0.
if (a->side_switcher != nullptr) {
a->dbg_switcher_w = gtk_widget_get_width(a->side_switcher);
a->dbg_switcher_h = gtk_widget_get_height(a->side_switcher);
}
// How many rows of the signal list carry a drag source: dragging a signal onto a plot is how a plot is
// filled, so it has to survive both pane modes -- the locked list drops the tick, the menu and the `+`,
// and this is the reading that says it did not drop the drag with them. One row is the `MATH` divider,
@@ -187,14 +181,9 @@ gboolean exit_probe_step3(gpointer user) {
}
if (a->area != nullptr) a->dbg_frames_painted = static_cast<int>(a->area->frames_painted());
a->dbg_reset_sensitive = a->cursors_reset != nullptr && gtk_widget_get_sensitive(a->cursors_reset) ? 1 : 0;
// A page, not a revealer: a stack's hidden page measures 0x0 the way an unrevealed revealer
// did, so the reading asks about *state* -- is this page the current one -- and never about size.
const char* page = a->side_stack != nullptr
? adw_view_stack_get_visible_child_name(ADW_VIEW_STACK(a->side_stack))
: nullptr;
const int on_signals = page != nullptr && std::strcmp(page, "signals") == 0 ? 1 : 0;
a->dbg_signal_page_current =
on_signals && gtk_widget_get_first_child(a->signal_list) != nullptr ? 1 : 0;
// The signal list has rows: a reading about *state* (the dock has no page to be current on), never
// about size.
a->dbg_signal_page_current = gtk_widget_get_first_child(a->signal_list) != nullptr ? 1 : 0;
// The trigger form is no longer on a sidebar tab, so the reading is the body's own sensitivity.
a->dbg_trigger_sensitive = gtk_widget_get_sensitive(a->trigger_body) ? 1 : 0;
// `gtk_widget_get_visible` on the sidebar's GtkBox is always 1: AdwOverlaySplitView hides its
@@ -203,8 +192,17 @@ gboolean exit_probe_step3(gpointer user) {
const int sidebar_shown = a->side_view != nullptr &&
adw_overlay_split_view_get_show_sidebar(ADW_OVERLAY_SPLIT_VIEW(a->side_view)) ? 1 : 0;
a->dbg_side_column_after_hide = sidebar_shown;
a->dbg_switcher_visible =
a->side_switcher != nullptr && gtk_widget_get_visible(a->side_switcher) ? 1 : 0;
// The dock, read here with the other sizes (the dump runs after the window is gone). `sensitive` is the
// availability: the column is on screen whether or not the stream has signals, and does nothing until
// it does.
a->dbg_dock_shown = sidebar_shown;
a->dbg_dock_sensitive = gtk_widget_get_sensitive(a->side_box) ? 1 : 0;
a->dbg_dock_w = a->dbg_side_w;
a->dbg_dock_scrollers = 0;
for (GtkWidget* c = gtk_widget_get_first_child(a->side_box); c != nullptr;
c = gtk_widget_get_next_sibling(c)) {
if (GTK_IS_SCROLLED_WINDOW(c)) ++a->dbg_dock_scrollers;
}
if (a->trigger_signal_drop != nullptr) {
GListModel* model = gtk_drop_down_get_model(GTK_DROP_DOWN(a->trigger_signal_drop));
a->dbg_trigger_items = model != nullptr ? static_cast<int>(g_list_model_get_n_items(model)) : 0;
@@ -1795,11 +1793,24 @@ gboolean stats_probe(gpointer user) {
return G_SOURCE_REMOVE;
}
/// Phase two of the tab-switch probe: the hidden page's accumulator must have advanced. A statistics
/// readout is *history*, so min/max/avg would gain a gap for exactly as long as the trigger was on
/// screen -- which is the one defect the design's "what runs on a hidden page" rule is there to
/// prevent, and the reason no frame-rate code may be gated on which tab is current.
gboolean side_tab_probe_report(gpointer user) {
/// Which section the dock's scroller is showing at its top: "measure" once it has been scrolled to the
/// cursors panel or past it, "signals" above that. Read from the adjustment, so it is the state the user
/// would see and not the last call made.
static const char* dock_section_in_view(App& a) {
if (a.dock_scroller == nullptr || a.cursors_panel == nullptr || a.dock_page == nullptr) return "none";
graphene_point_t zero = GRAPHENE_POINT_INIT(0.f, 0.f);
graphene_point_t at;
if (!gtk_widget_compute_point(a.cursors_panel, a.dock_page, &zero, &at)) return "none";
GtkAdjustment* adj = gtk_scrolled_window_get_vadjustment(GTK_SCROLLED_WINDOW(a.dock_scroller));
return gtk_adjustment_get_value(adj) > 0.5 * at.y ? "measure" : "signals";
}
/// Phase two of the dock-section probe: the accumulators must have advanced while the Measure section was
/// scrolled out of view. A statistics readout is *history*, so min/max/avg would gain a gap for exactly as
/// long as the section was off screen -- which is the one defect the design's "what runs on a hidden
/// page" rule is there to prevent, and the reason no frame-rate code may be gated on which section is in
/// view. (It was a tab once; the dock is one scroller, and the rule outlived the tabs.)
gboolean dock_section_probe_report(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
std::uint64_t advanced = 0;
@@ -1807,25 +1818,37 @@ gboolean side_tab_probe_report(gpointer user) {
bool first = true;
for (std::size_t i = 0; i < a->measure_stats.size(); ++i) {
const std::uint64_t n = a->measure_stats[i].n;
if (i < g_side_tab_n_before.size() && n > g_side_tab_n_before[i]) ++advanced;
if (i < g_dock_section_n_before.size() && n > g_dock_section_n_before[i]) ++advanced;
if (first || n < min_n) {
min_n = n;
first = false;
}
}
if (a->dock_page != nullptr) gtk_widget_set_size_request(a->dock_page, -1, -1);
std::fprintf(stderr,
"[side-tab-probe] page=%s rows=%zu accums=%zu advanced=%" G_GUINT64_FORMAT
"[side-tab-probe] section=%s rows=%zu accums=%zu advanced=%" G_GUINT64_FORMAT
" min_n=%" G_GUINT64_FORMAT "\n",
a->side_tab.c_str(), a->measures.size(), a->measure_stats.size(), advanced, min_n);
dock_section_in_view(*a), a->measures.size(), a->measure_stats.size(), advanced, min_n);
std::fflush(stderr);
return G_SOURCE_REMOVE;
}
/// Phase one: four measurements on the first channel, statistics on, then switch *away* from the
/// Measure tab and come back in two seconds to read what ran while it was not on screen.
gboolean side_tab_probe(gpointer user) {
/// Phase one: four measurements on the first channel, statistics on, then scroll the dock *away* from the
/// Measure section and come back in two seconds to read what ran while it was not in view. (The probe
/// keeps its old environment variable, `UDPSCOPE_PROBE_SIDE_TAB_MS`, and its `[side-tab-probe]` tag.)
gboolean dock_section_probe(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
// A dock whose content fits has nothing to scroll away from, and a tall window does that. The first
// pass makes the page taller than any window (a probe-only minimum height, undone in the report) and
// comes back once it has been laid out, so "scrolled out of view" is a state this run really reaches.
static bool padded = false;
if (!padded && a->dock_page != nullptr) {
padded = true;
gtk_widget_set_size_request(a->dock_page, -1, 3000);
g_timeout_add(400, dock_section_probe, a);
return G_SOURCE_REMOVE;
}
// One signal on, and on a plot, the way `enable_all_probe` does it and a restored session arrives.
// Both halves matter here: a signal starts disabled (enabling is what starts acquisition), and a
// channel with no trace leaves the live window *still* -- and a still window is exactly the state
@@ -1863,18 +1886,23 @@ gboolean side_tab_probe(gpointer user) {
if (a->measures_stats_check != nullptr) {
gtk_check_button_set_active(GTK_CHECK_BUTTON(a->measures_stats_check), TRUE);
}
select_side_tab(*a, "measure");
dock_show_section(*a, "measure");
// Where the dock went: `scrolled` is its offset and `reach` the most it can scroll. A reach of 0 would
// mean the content fits and "away from the section" is not a state this run can reach.
GtkAdjustment* dock_adj = gtk_scrolled_window_get_vadjustment(GTK_SCROLLED_WINDOW(a->dock_scroller));
const double scrolled = gtk_adjustment_get_value(dock_adj);
const double reach = gtk_adjustment_get_upper(dock_adj) - gtk_adjustment_get_page_size(dock_adj);
update_measures(*a, /*force=*/true, /*accumulate=*/true);
g_side_tab_n_before.clear();
for (const MeasureStatsAccum& s : a->measure_stats) g_side_tab_n_before.push_back(s.n);
// Away from the page the measurements are on: its widgets are now unrealized and measure 0x0, and
// the tick keeps calling `update_measures()` regardless.
select_side_tab(*a, "signals");
std::fprintf(stderr, "[side-tab-probe] page=%s accums=%zu baseline recorded\n",
a->side_tab.c_str(), g_side_tab_n_before.size());
g_dock_section_n_before.clear();
for (const MeasureStatsAccum& s : a->measure_stats) g_dock_section_n_before.push_back(s.n);
// Away from the section the measurements are in: they are scrolled out of the viewport (the widgets
// stay mapped, only clipped), and the tick keeps calling `update_measures()` regardless.
dock_show_section(*a, "signals");
std::fprintf(stderr, "[side-tab-probe] section=%s accums=%zu baseline recorded (measure at scroll %.0f of %.0f)\n",
dock_section_in_view(*a), g_dock_section_n_before.size(), scrolled, reach);
std::fflush(stderr);
// Long enough for several throttled recomputes (one per 100 ms while the window moves).
g_timeout_add(2000, side_tab_probe_report, a);
g_timeout_add(2000, dock_section_probe_report, a);
return G_SOURCE_REMOVE;
}
@@ -3573,6 +3601,63 @@ gboolean sidebar_probe(gpointer user) {
return G_SOURCE_REMOVE;
}
/// `UDPSCOPE_PROBE_DOCK_SESSION_MS`: the dock's shown state through the session file, both ways.
///
/// The Dock button's state is the one thing the dock persists (`dock = true|false`, replacing `side_tab`).
/// Leg 1 hides the dock through the button (a wish, so it must schedule a save), collects the session and
/// writes it, reads the file back and applies it; leg 2 does the same shown. Leg 3 loads a file written by
/// the previous version (`side_tab = "measure"`, no `dock`): it must load without a complaint and leave
/// `dock_shown` at its default, true. Read as `ok=1` on every line.
gboolean dock_session_probe(gpointer user) {
App& a = *static_cast<App*>(user);
if (a.window == nullptr || a.sidebar_toggle == nullptr) return G_SOURCE_REMOVE;
const std::string dir = g_get_tmp_dir();
const std::string path = dir + "/udpscope-dock-session-probe.toml";
const auto leg = [&](const char* name, bool shown) {
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a.sidebar_toggle), shown);
const bool wish = a.sidebar_wanted;
const bool save_pending = a.layout_save_source != 0;
LayoutState st = collect_layout(a);
const bool collected = st.dock_shown;
const bool saved = save_layout(path, st);
LayoutState back;
const bool loaded = load_layout(path, back);
// Apply the *opposite* first so the restore has something to undo, then the loaded state.
a.sidebar_wanted = !shown;
apply_layout(a, back);
const bool applied = a.sidebar_wanted == shown;
const bool split_follows =
(adw_overlay_split_view_get_show_sidebar(ADW_OVERLAY_SPLIT_VIEW(a.side_view)) != FALSE) == shown;
std::fprintf(stderr,
"[dock-session] %s: wish=%d save_scheduled=%d collected=%d saved=%d loaded=%d read=%d "
"applied=%d split_follows=%d ok=%d\n",
name, wish ? 1 : 0, save_pending ? 1 : 0, collected ? 1 : 0, saved ? 1 : 0,
loaded ? 1 : 0, back.dock_shown ? 1 : 0, applied ? 1 : 0, split_follows ? 1 : 0,
(wish == shown && save_pending && collected == shown && saved && loaded &&
back.dock_shown == shown && applied)
? 1 : 0);
};
leg("hidden", false);
leg("shown", true);
{
std::FILE* f = std::fopen(path.c_str(), "w");
if (f != nullptr) {
std::fputs("side_tab = \"measure\"\nmeasure_stats = false\n", f);
std::fclose(f);
}
LayoutState old_file;
old_file.dock_shown = false; // anything but the default, so "left at the default" is a reading
const bool loaded = load_layout(path, old_file);
LayoutState fresh;
std::fprintf(stderr, "[dock-session] old file (side_tab only): loaded=%d dock_shown=%d default=%d ok=%d\n",
loaded ? 1 : 0, old_file.dock_shown ? 1 : 0, fresh.dock_shown ? 1 : 0,
loaded && fresh.dock_shown ? 1 : 0);
}
std::remove(path.c_str());
std::fflush(stderr);
return G_SOURCE_REMOVE;
}
gboolean prefs_probe(gpointer user) {
App& a = *static_cast<App*>(user);
open_preferences(a);
@@ -4524,7 +4609,7 @@ gboolean measures_probe_step(gpointer user) {
m.options.show_on_plot = measure_can_show_on_plot(m.type);
a->measures.push_back(std::move(m));
}
select_side_tab(*a, "measure");
dock_show_section(*a, "measure");
rebuild_measures_pane(*a);
std::fprintf(stderr, "[measures-probe] added %zu measurements on '%s'\n", a->measures.size(),
signal.c_str());
@@ -4683,7 +4768,7 @@ gboolean bandavg_probe_step(gpointer user) {
m.options.show_on_plot = measure_can_show_on_plot(m.type);
a->measures.push_back(std::move(m));
}
select_side_tab(*a, "measure");
dock_show_section(*a, "measure");
rebuild_measures_pane(*a);
g_timeout_add(250, measure_trace, a);
bandavg_probe_next(*a, 1, 1500);
@@ -6247,6 +6332,16 @@ gboolean defaults_probe(gpointer user) {
gboolean keys_probe(gpointer user) {
App* a = static_cast<App*>(user);
// `m` scrolls the dock, and a dock whose content fits has nothing to scroll: a run on a tall window
// would read 0->0 and prove nothing. So the first pass only makes the page taller than any window
// (a probe-only minimum height, undone below) and comes back once it has been laid out.
static bool padded = false;
if (!padded && a->dock_page != nullptr) {
padded = true;
gtk_widget_set_size_request(a->dock_page, -1, 3000);
g_timeout_add(400, keys_probe, a);
return G_SOURCE_REMOVE;
}
std::string line = "[keys]";
const auto before_after = [&](const char* name, GtkWidget* button, AppAction action) {
@@ -6282,38 +6377,56 @@ gboolean keys_probe(gpointer user) {
before_after("t: trigger panel", a->trigger_toggle, AppAction::TriggerPanel);
before_after("c: cursors", a->cursors_toggle, AppAction::CursorsPane);
// `m` and `g` scroll the dock: the reading is the scroller's offset before and after, and `m` must land
// on the cursors panel's own y (clamped to what the page can scroll, which is why the reachable maximum
// is printed beside it), `g` back on 0.
const auto dock_adjustment = [a]() {
return gtk_scrolled_window_get_vadjustment(GTK_SCROLLED_WINDOW(a->dock_scroller));
};
const auto dock_value = [&]() { return gtk_adjustment_get_value(dock_adjustment()); };
{
const std::string before_tab = a->side_tab;
run_app_action(*a, AppAction::SideTabMeasure);
const std::string after_tab = a->side_tab;
char buf[96];
std::snprintf(buf, sizeof(buf), " m: tab %s->%s |", before_tab.c_str(), after_tab.c_str());
const double before = dock_value();
run_app_action(*a, AppAction::ShowMeasure);
const double after = dock_value();
graphene_point_t zero = GRAPHENE_POINT_INIT(0.f, 0.f);
graphene_point_t at = GRAPHENE_POINT_INIT(-1.f, -1.f);
if (!gtk_widget_compute_point(a->cursors_panel, a->dock_page, &zero, &at)) at.y = -1.f;
GtkAdjustment* adj = dock_adjustment();
const double reach = gtk_adjustment_get_upper(adj) - gtk_adjustment_get_page_size(adj);
char buf[160];
std::snprintf(buf, sizeof(buf), " m: dock %.0f->%.0f (cursors_y=%.0f reach=%.0f at_cursors=%d) |", before,
after, static_cast<double>(at.y), reach,
std::fabs(after - std::min(static_cast<double>(at.y), reach)) < 1.0 ? 1 : 0);
line += buf;
}
{
const std::string before_tab = a->side_tab;
run_app_action(*a, AppAction::SideTabSignals);
const std::string after_tab = a->side_tab;
const double before = dock_value();
run_app_action(*a, AppAction::ShowSignals);
const double after = dock_value();
char buf[96];
std::snprintf(buf, sizeof(buf), " g: tab %s->%s |", before_tab.c_str(), after_tab.c_str());
std::snprintf(buf, sizeof(buf), " g: dock %.0f->%.0f at_top=%d |", before, after,
std::fabs(after) < 1.0 ? 1 : 0);
line += buf;
}
before_after("l: layout", a->layout_toggle, AppAction::LayoutEditor);
before_after("s: side panel", a->sidebar_toggle, AppAction::SidePanel);
// An accel must be refused while text is being typed. Focus the window entry, send 'm'
// through the real handler, and require the side tab not to change.
// through the real handler, and require the dock not to scroll.
int refused = -1;
if (a->window_entry != nullptr) {
gtk_menu_button_popup(GTK_MENU_BUTTON(a->window_button));
gtk_widget_grab_focus(a->window_entry);
const std::string before_tab = a->side_tab;
// The dock has to be somewhere other than where `m` would take it, or "did not scroll" proves nothing.
gtk_adjustment_set_value(dock_adjustment(), 0.0);
const double before_dock = dock_value();
const gboolean handled = on_app_key_pressed(nullptr, GDK_KEY_m, 0,
static_cast<GdkModifierType>(0), a);
const std::string after_tab = a->side_tab;
refused = (handled == FALSE && before_tab == after_tab) ? 1 : 0;
const double after_dock = dock_value();
refused = (handled == FALSE && before_dock == after_dock) ? 1 : 0;
gtk_menu_button_popdown(GTK_MENU_BUTTON(a->window_button));
}
if (a->dock_page != nullptr) gtk_widget_set_size_request(a->dock_page, -1, -1);
char tail[64];
std::snprintf(tail, sizeof(tail), " editable focused: 'm' refused=%d\n", refused);
line += tail;
@@ -6570,10 +6683,10 @@ void tour_geometry_line(App& a, const char* probe, const char* what) {
p.origin.y < q.origin.y + q.size.height && q.origin.y < p.origin.y + p.size.height;
};
std::fprintf(stderr,
"[%s] %s=%d/%d visible=%d side_tab=%s spot=[%.0f,%.0f %.0fx%.0f] "
"[%s] %s=%d/%d visible=%d slideover=%s spot=[%.0f,%.0f %.0fx%.0f] "
"target=[%.0f,%.0f %.0fx%.0f] contains=%d card=[%.0f,%.0f %.0fx%.0f] clear=%d\n",
probe, what, tour_step_index(a) + 1, tour_step_count(a), tour_active(a) ? 1 : 0,
a.side_tab.c_str(), spot.origin.x, spot.origin.y, spot.size.width, spot.size.height,
(slideover_page(a).empty() ? "none" : slideover_page(a).c_str()), spot.origin.x, spot.origin.y, spot.size.width, spot.size.height,
target.origin.x, target.origin.y, target.size.width, target.size.height,
have_spot && have_target && contains(spot, target) ? 1 : 0, card.origin.x,
card.origin.y, card.size.width, card.size.height,
@@ -6818,10 +6931,10 @@ gboolean tour_surface_probe_step(gpointer user) {
case 0: {
// Where the run starts: a fresh config's sidebar is on Signals, so this is a *first* run's
// state. The auto-start's walk (if one began) would only confuse the reading; it goes first.
std::fprintf(stderr, "[toursurface] before: side_tab=%s signal_list_mapped=%d running=%d\n",
a->side_tab.c_str(), mapped(a->signal_list), tour_active(*a) ? 1 : 0);
std::fprintf(stderr, "[toursurface] before: slideover=%s signal_list_mapped=%d running=%d\n",
slideover_page(*a).empty() ? "none" : slideover_page(*a).c_str(), mapped(a->signal_list), tour_active(*a) ? 1 : 0);
tour_dismiss(*a);
select_side_tab(*a, "measure");
slideover_open(*a, "trigger");
a->dbg_tour_surface_step = 1;
tour_surface_probe_next(*a, 500);
return G_SOURCE_REMOVE;
@@ -6830,8 +6943,9 @@ gboolean tour_surface_probe_step(gpointer user) {
// A frame after the switch, so the Measure page has been laid out and the list really is off
// screen. The tour is started *here*, from this state.
std::fprintf(stderr,
"[toursurface] on measure: side_tab=%s signal_list_mapped=%d side_stack_mapped=%d\n",
a->side_tab.c_str(), mapped(a->signal_list), mapped(a->side_stack));
"[toursurface] on trigger: slideover=%s signal_list_mapped=%d side_box_mapped=%d\n",
slideover_page(*a).empty() ? "none" : slideover_page(*a).c_str(),
mapped(a->signal_list), mapped(a->side_box));
start_tour(*a);
std::fprintf(stderr, "[toursurface] tour: visible=%d steps=%d step=%d signals_stop=%d\n",
tour_active(*a) ? 1 : 0, tour_step_count(*a), tour_step_index(*a),
@@ -6848,8 +6962,9 @@ gboolean tour_surface_probe_step(gpointer user) {
}
GtkWidget* target = tour_target(*a);
const bool is_list = target != nullptr && target == a->signal_list;
std::fprintf(stderr, "[toursurface] step=%d/%d side_tab=%s target_mapped=%d%s\n",
tour_step_index(*a) + 1, tour_step_count(*a), a->side_tab.c_str(), mapped(target),
std::fprintf(stderr, "[toursurface] step=%d/%d slideover=%s target_mapped=%d%s\n",
tour_step_index(*a) + 1, tour_step_count(*a),
slideover_page(*a).empty() ? "none" : slideover_page(*a).c_str(), mapped(target),
is_list ? " target=signal_list" : "");
if (is_list) {
// The frame after the switch: the page has been laid out and `update_tour()` has placed the
@@ -6876,9 +6991,9 @@ gboolean tour_surface_probe_step(gpointer user) {
default: {
start_tour(*a);
std::fprintf(stderr,
"[toursurface] collapsed: side_stack_mapped=%d steps=%d signals_stop=%d "
"[toursurface] collapsed: side_box_mapped=%d steps=%d signals_stop=%d "
"side_stop=%d\n",
mapped(a->side_stack), tour_step_count(*a),
mapped(a->side_box), tour_step_count(*a),
tour_plan_holds(*a, a->signal_list) ? 1 : 0,
tour_plan_holds(*a, a->side_box) ? 1 : 0);
tour_dismiss(*a);