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

+79 -109
View File
@@ -2864,35 +2864,38 @@ void on_right_click(GtkGestureClick* gesture, int, double x, double y, gpointer
open_row_menu(*app, row, x, y, widget);
}
void on_side_tab_changed(GObject* o, GParamSpec*, gpointer user) {
App* app = static_cast<App*>(user);
const char* name = adw_view_stack_get_visible_child_name(ADW_VIEW_STACK(o));
app->side_tab = name != nullptr ? name : "signals";
// Not while the layout is being applied: the restore would schedule a save of what it just read.
if (!app->updating_panels) schedule_layout_save(*app);
/// Scrolls the dock to a section. The dock is one scroller (`App::dock_scroller`) holding the signal list,
/// the cursors and the measurements top to bottom, so "measure" is the cursors panel's offset in the
/// page, and "signals" the top. Nothing is hidden or removed: both stay in the page. An unknown name is a
/// no-op, so a key bound to a section a later version drops cannot blank anything.
void dock_show_section(App& app, const char* section) {
if (app.dock_scroller == nullptr || app.dock_page == nullptr || section == nullptr) return;
GtkAdjustment* adj = gtk_scrolled_window_get_vadjustment(GTK_SCROLLED_WINDOW(app.dock_scroller));
if (std::strcmp(section, "signals") == 0) {
// The list is the first section: its own offset in the page is the page's margin, and "go to Signals"
// means the top of the dock.
gtk_adjustment_set_value(adj, 0.0);
return;
}
if (std::strcmp(section, "measure") != 0 || app.cursors_panel == nullptr) return;
graphene_point_t zero = GRAPHENE_POINT_INIT(0.f, 0.f);
graphene_point_t at;
if (!gtk_widget_compute_point(app.cursors_panel, app.dock_page, &zero, &at)) return;
gtk_adjustment_set_value(adj, at.y);
}
void select_side_tab(App& app, const char* name) {
if (app.side_stack == nullptr || name == nullptr) return;
// An unknown name leaves the stack where it is -- a session file from a later version must not
// be able to blank the sidebar.
if (adw_view_stack_get_child_by_name(ADW_VIEW_STACK(app.side_stack), name) == nullptr) return;
adw_view_stack_set_visible_child_name(ADW_VIEW_STACK(app.side_stack), name);
app.side_tab = name;
}
/// The side column is on when it has something to show *and* the user's sidebar
/// button is in; the two panel buttons appear as soon as a CONFIG describes any
/// signal at all.
/// The dock is on when the user's sidebar button is in -- from the start, with or without signals; the
/// two panel buttons appear as soon as a CONFIG describes any signal at all.
void update_side_visibility(App& app) {
if (app.side_stack == nullptr) return;
if (app.side_box == nullptr) return;
const bool have_signals = !app.model->signals().empty();
// `have_signals` decides whether the sidebar *can* be on; `sidebar_wanted` is the user's last word,
// which is why it is kept apart from the button's own state -- that state follows the panel now, and
// the panel is off while there is nothing to show. A swipe that hides the sidebar fires the button's
// handler (the binding is bidirectional), so a gesture is a choice too.
gtk_widget_set_sensitive(app.sidebar_toggle, have_signals);
const bool want_column = have_signals && app.sidebar_wanted;
// `sidebar_wanted` is the user's last word, which is why it is kept apart from the button's own state:
// a swipe that hides the sidebar fires the button's handler (the binding is bidirectional), so a gesture
// is a choice too. "Disable, do not hide": the dock is on screen from the start and does nothing until
// the stream has signals, so the signals decide its *sensitivity* and never whether it is there.
gtk_widget_set_sensitive(app.side_box, have_signals);
gtk_widget_set_sensitive(app.sidebar_toggle, TRUE);
const bool want_column = app.sidebar_wanted;
// The split view owns the sidebar's visibility: it animates it, and when the window is too narrow it
// shows it as an overlay rather than letting the column be squeezed to nothing.
if (app.side_view != nullptr) {
@@ -2908,7 +2911,12 @@ void on_sidebar_toggled(GtkToggleButton* button, gpointer user) {
App* app = static_cast<App*>(user);
// Only a state we did not write is a wish: `update_side_visibility` sets this button (and the panel)
// from `sidebar_wanted`, so an unguarded change here is the user's.
if (!app->sidebar_syncing) app->sidebar_wanted = gtk_toggle_button_get_active(button) != FALSE;
if (!app->sidebar_syncing) {
app->sidebar_wanted = gtk_toggle_button_get_active(button) != FALSE;
// The wish is persisted (`SessionState::dock_shown`), so a change of it is a change of the session --
// but not one made while a layout is being applied, which would save what it just read.
if (!app->updating_panels) schedule_layout_save(*app);
}
update_side_visibility(*app);
}
@@ -2994,8 +3002,8 @@ void note_panel_controls(App& app) {
// `HeaderBar::pack_end()` parents into an internal box, so the bar is a level up, not the parent.
char text[256];
std::snprintf(text, sizeof(text),
"cursors=%d in the toolbar, switcher=%d, reset=%d in the cursors pane",
in_bar(app.cursors_toggle), app.side_switcher != nullptr ? 1 : 0,
"cursors=%d in the toolbar, dock=%d, reset=%d in the cursors pane",
in_bar(app.cursors_toggle), app.dock_page != nullptr ? 1 : 0,
in_cursors_pane(app.cursors_reset));
// ... and the order the bar actually shows, which is what "the menu should be last" is about. A
// widget tree walked in child order *is* the layout order, so this is the reading order of the
@@ -3051,42 +3059,6 @@ void note_panel_controls(App& app) {
" " +
icon_report(theme, "org.udpscope.app-symbolic",
"/org/udpscope/icons/scalable/apps/org.udpscope.app-symbolic.svg");
// What the switcher is drawing: the sidebar's tabs are words (`wide` policy, a title per page, no
// icon). The pills are `AdwViewSwitcher`'s own buttons, built when the stack is mapped, so this
// reads on the first tick. `mapped`, not `visible`: visibility is per widget, so a label whose
// *parent* is hidden still reads visible; `mapped` requires every ancestor, which is what "on
// screen" means -- and it is why the pill count is read the same way: if nothing is mapped the
// pill count reads 0 and the label count means nothing, rather than passing vacuously.
int pills = 0;
int drawn_labels = 0;
int drawn_images = 0;
const std::function<void(GtkWidget*)> count_drawn = [&](GtkWidget* w) {
if (GTK_IS_TOGGLE_BUTTON(w) && gtk_widget_get_mapped(w)) ++pills;
// A *mapped* image is an icon (or its missing-icon placeholder) on a tab: tabs are words only.
if (GTK_IS_IMAGE(w) && gtk_widget_get_mapped(w)) ++drawn_images;
// A *mapped* label with text: an empty label would be counted by "mapped" alone.
if (GTK_IS_LABEL(w) && gtk_widget_get_mapped(w) && gtk_label_get_text(GTK_LABEL(w))[0] != '\0') {
++drawn_labels;
}
for (GtkWidget* c = gtk_widget_get_first_child(w); c != nullptr;
c = gtk_widget_get_next_sibling(c)) {
count_drawn(c);
}
};
if (app.side_switcher != nullptr) count_drawn(app.side_switcher);
app.dbg_switcher_pills = pills;
app.dbg_switcher_labels = drawn_labels;
app.dbg_switcher_images = drawn_images;
// Walked through `get_pages()`, not the widget tree: an `AdwViewStackPage` is a plain `GObject`.
app.dbg_switcher_pages =
app.side_stack != nullptr
? static_cast<int>(g_list_model_get_n_items(
G_LIST_MODEL(adw_view_stack_get_pages(ADW_VIEW_STACK(app.side_stack)))))
: 0;
if (app.side_switcher != nullptr) {
g_object_get(G_OBJECT(app.side_switcher), "policy", &app.dbg_switcher_policy, nullptr);
}
}
/// The event-zoom sweep's two halves: a gesture zoom, then the same walk through the window
@@ -4022,7 +3994,7 @@ gboolean tick(gpointer user) {
// follow a resize and the gutter settling without being told.
if (app->layout_editing) sync_layout_editor(*app);
// The one thing that has to be decided *per frame* about the sidebar: whether there is room for it
// The one thing that has to be decided *per frame* about the dock: whether there is room for it
// beside the plot. Collapsed, the split view puts it *over* the plot rather than giving it a share of
// a width that is not there -- so a small window shows fewer pixels of plot, never a panel squeezed
// to nothing. `kSidebarRoom` is the sidebar's 320 plus the width below which the plot stops being
@@ -4030,8 +4002,12 @@ gboolean tick(gpointer user) {
if (app->side_view != nullptr && app->window != nullptr) {
// The split view writes `show-sidebar` itself (at startup and when it collapses), so the wish is
// re-asserted here -- but only when they disagree, so a settled frame touches nothing. This is also
// why a hide *gesture* does not stick: the panel is where the button says it is.
const bool want = !app->model->signals().empty() && app->sidebar_wanted;
// why a hide *gesture* does not stick: the panel is where the button says it is. The wish alone decides
// whether the dock is there; having signals decides only whether it does anything ("disable, do not
// hide"): a dock that is on screen from the start does not jump in when the first CONFIG arrives.
const bool want = app->sidebar_wanted;
gtk_widget_set_sensitive(app->side_box, !app->model->signals().empty());
gtk_widget_set_sensitive(app->sidebar_toggle, TRUE);
if (adw_overlay_split_view_get_show_sidebar(ADW_OVERLAY_SPLIT_VIEW(app->side_view)) != want) {
adw_overlay_split_view_set_show_sidebar(ADW_OVERLAY_SPLIT_VIEW(app->side_view), want);
if (gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(app->sidebar_toggle)) != want) {
@@ -4702,8 +4678,8 @@ void run_app_action(App& app, AppAction action) {
case AppAction::CursorsPane:
toggle(app.cursors_toggle);
break;
case AppAction::SideTabMeasure: select_side_tab(app, "measure"); break;
case AppAction::SideTabSignals: select_side_tab(app, "signals"); break;
case AppAction::ShowMeasure: dock_show_section(app, "measure"); break;
case AppAction::ShowSignals: dock_show_section(app, "signals"); break;
case AppAction::LayoutEditor:
toggle(app.layout_toggle);
break;
@@ -4745,6 +4721,9 @@ gboolean on_app_key_pressed(GtkEventControllerKey*, guint keyval, guint, GdkModi
tour_dismiss(*app);
return TRUE;
}
// ... and then the slide-over: Escape closes it, ahead of the editable check for the same reason -- the
// key that dismisses a panel must work from inside one of its own spin buttons.
if (keyval == GDK_KEY_Escape && slideover_escape(*app)) return TRUE;
GtkWidget* focus = gtk_window_get_focus(GTK_WINDOW(app->window));
if (focus != nullptr && GTK_IS_EDITABLE(focus)) return FALSE;
@@ -4998,8 +4977,8 @@ void on_activate(GtkApplication* application, gpointer user) {
install_css();
// The window's skeleton is a `.ui` file: the window, its two panes, the plot column with the status
// line under it, and the sidebar's tab switcher and two-page stack. Everything that depends on the
// stream -- the events pane, the two plot areas, the sidebar's panels -- is built below and put
// line under it, and the dock's scroller. Everything that depends on the
// stream -- the events pane, the two plot areas, the dock's panels -- is built below and put
// into the slots by id.
g_clear_object(&app->window_ui);
app->window_ui = gtk_builder_new_from_resource("/org/udpscope/ui/main-window.ui");
@@ -5018,10 +4997,8 @@ void on_activate(GtkApplication* application, gpointer user) {
// help's annotated figures label.
app->status_row = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "status_row"));
GtkWidget* side = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "side_column"));
GtkWidget* side_stack = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "side_stack"));
GtkWidget* side_switcher = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "side_switcher"));
GtkWidget* measure_page = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "measure_page"));
GtkWidget* signals_page = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "signals_page"));
app->dock_scroller = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "dock_scroller"));
app->dock_page = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "dock_page"));
app->badge_bar = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "badge_bar"));
app->badge_signals = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "badge_signals"));
app->badge_system = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "badge_system"));
@@ -5033,8 +5010,7 @@ void on_activate(GtkApplication* application, gpointer user) {
app->status_label == nullptr || app->status_info_button == nullptr ||
app->status_row == nullptr || app->badge_bar == nullptr || app->badge_signals == nullptr ||
app->badge_system == nullptr ||
side == nullptr || side_stack == nullptr || side_switcher == nullptr ||
measure_page == nullptr || signals_page == nullptr) {
side == nullptr || app->dock_scroller == nullptr || app->dock_page == nullptr) {
return;
}
// The window's actions live on the application, so it has to know which one it belongs to.
@@ -5177,28 +5153,28 @@ void on_activate(GtkApplication* application, gpointer user) {
gtk_popover_set_has_arrow(GTK_POPOVER(app->status_popover), TRUE);
}
// The right-hand side is two tabs, not five stacked panels: one tab is on screen at a time, so
// no section competes with four others for height. Each page scrolls on its own -- the two are
// different heights, and one shared scroller would leave a switch scrolled where the new page
// never asked to be.
// `side` and `side_stack`/`side_switcher`/`measure_page`/`signals_page` are resolved above with
// the rest of the skeleton and checked by the null guard; assign the members here.
// The right-hand side is the dock: one scroller, the signal list first (it is what is dragged onto the
// plot), then the cursors and the measurements. Every section is always there -- "go to Measure" scrolls
// to it instead of switching a tab, so the measurements are as live as the list above them.
// `side` and `dock_page`/`dock_scroller` are resolved above with the rest of the skeleton and checked
// by the null guard; assign the member here.
app->side_box = side;
app->side_stack = side_stack;
app->side_switcher = side_switcher;
// Measure: what the cursors bracket, and the figures derived from it.
// Built in the order the panels have always been (cursors, measurements, trigger, signals) and placed in
// the dock's: the trigger form goes to the slide-over (reached by the Trigger badge).
app->cursors_panel = build_cursors_panel(*app);
gtk_box_append(GTK_BOX(measure_page), app->cursors_panel);
gtk_box_append(GTK_BOX(measure_page), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL));
gtk_box_append(GTK_BOX(measure_page), build_measures_panel(*app));
// The trigger form lives in the slide-over (reached by the Trigger badge); the signal list stays in the
// sidebar until the dock replaces it.
GtkWidget* measures_panel = build_measures_panel(*app);
app->trigger_panel = build_trigger_panel(*app);
// Top-aligned: the page's scroller gives the child the page's height, and a form that stretched to it
// would spread its rows (or draw its frame) over empty space under the last one.
gtk_widget_set_valign(app->trigger_panel, GTK_ALIGN_START);
slideover_add_page(*app, "trigger", "Trigger", app->trigger_panel);
app->signal_panel = build_signal_panel(*app);
gtk_box_append(GTK_BOX(signals_page), app->signal_panel);
gtk_box_append(GTK_BOX(app->dock_page), app->signal_panel);
gtk_box_append(GTK_BOX(app->dock_page), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL));
gtk_box_append(GTK_BOX(app->dock_page), app->cursors_panel);
gtk_box_append(GTK_BOX(app->dock_page), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL));
gtk_box_append(GTK_BOX(app->dock_page), measures_panel);
// 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
@@ -5210,15 +5186,8 @@ void on_activate(GtkApplication* application, gpointer user) {
sync_arm_buttons(*app);
gtk_widget_set_sensitive(app->cursors_reset, FALSE);
if (app->side_stack != nullptr) {
adw_view_stack_set_visible_child_name(ADW_VIEW_STACK(app->side_stack), "signals");
g_signal_connect(app->side_stack, "notify::visible-child-name",
G_CALLBACK(on_side_tab_changed), app);
}
// The source kind's own tabs (a generator, digital I/O), added before the first tick so the
// pages-versus-pills reading below counts them too. The tabs are words, so the switcher's icons
// are hidden first and kept hidden for the pages this call adds.
keep_tabs_icon_free(*app);
// The source kind's own panels (a generator, digital I/O), pages of the slide-over, added before the
// first tick.
build_source_panels(*app);
// The badge bar reads `trigger_toggle` (made by `build_toolbar()` above) and the source's pages, which
// `build_source_panels()` has just registered: the Source badge opens the first of them.
@@ -5915,7 +5884,7 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
g_timeout_add(static_cast<guint>(std::atoi(env)), stats_probe, &app);
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_SIDE_TAB_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), side_tab_probe, &app);
g_timeout_add(static_cast<guint>(std::atoi(env)), dock_section_probe, &app);
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_SLIDEOVER_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), slideover_probe, &app);
@@ -6028,6 +5997,9 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
if (const char* env = g_getenv("UDPSCOPE_PROBE_MENUS_MS")) {
menus_probe_next(app, 0, static_cast<guint>(std::atoi(env)));
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_DOCK_SESSION_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), dock_session_probe, &app);
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_KEYS_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), keys_probe, &app);
}
@@ -6237,12 +6209,12 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
inside(app.events_list, app.events_revealer));
}
std::printf("[udpscope] panels: side=%dx%d (visible=%d) signals=%dx%d row_h=%d "
"(page_current=%d) trigger=%dx%d (sensitive=%d) switcher=%d "
"(page_current=%d) trigger=%dx%d (sensitive=%d) "
"cursors=%d window=%.3fs window_cap=%.1fs\n",
app.dbg_side_w, app.dbg_side_h, app.dbg_side_column_after_hide, app.dbg_signal_w,
app.dbg_signal_h, app.dbg_signal_row_h, app.dbg_signal_page_current,
app.dbg_trigger_w, app.dbg_trigger_h,
app.dbg_trigger_sensitive, app.dbg_switcher_visible,
app.dbg_trigger_sensitive,
rows.cursors_visible() ? 1 : 0, rows.time_window(),
app.window_limit_s);
{
@@ -6267,10 +6239,8 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
app.dbg_panel_switch_trigger, app.dbg_toolbar_switch_trigger,
app.dbg_panel_switch_cursors, app.dbg_toolbar_switch_cursors,
app.dbg_panel_drove_toolbar);
std::printf("[udpscope] switcher: policy=%s pills=%d labels=%d images=%d pages=%d size=%dx%d\n",
app.dbg_switcher_policy == 0 ? "narrow" : (app.dbg_switcher_policy == 1 ? "wide" : "?"),
app.dbg_switcher_pills, app.dbg_switcher_labels, app.dbg_switcher_images,
app.dbg_switcher_pages, app.dbg_switcher_w, app.dbg_switcher_h);
std::printf("[udpscope] dock: shown=%d sensitive=%d w=%d scroller_pages=%d\n", app.dbg_dock_shown,
app.dbg_dock_sensitive, app.dbg_dock_w, app.dbg_dock_scrollers);
// The help affordance layer: what the contextual-help toggle left on the surfaces, and where the
// guided tour stood when the last capture ran. Both are captured on the tick (`capture_help_state`),
// so neither asks a destroyed widget anything.