fix: the sidebar keeps its own width, so a small window can no longer hide it

The sidebar's width was a GtkPaned divider position: `window - 940` in pixels. On any window
narrower than 940 px the divider landed past the right edge and the sidebar came up 0 px wide,
reachable only by dragging the divider back.

Every attempt that kept the paned failed for that reason -- deriving the position from the paned's
own width, re-parenting the column, property-form children, code-set size requests. The mechanism was
the fault, so it is gone: the row is an AdwOverlaySplitView, which is what a utility sidebar is in
GNOME, with min-sidebar-width = max-sidebar-width = 320 in pixels. Its width is therefore its own at
any window size, and when there is no room beside the plot it *overlays* the plot (collapsed, set from
the window's width in tick()) instead of being squeezed to nothing.

Two things found on the way, both recorded in the invariants:

- sidebar-width-fraction is a 0..1 fraction of the window whatever sidebar-width-unit says. Setting a
  pixel count there is a GObject critical and it falls back to 0.25, which is why the sidebar first
  read 560. The width is pinned with min/max instead.
- the window opened at its content minimum (756 px, the plot column plus the sidebar) because the
  explicit gtk_window_set_default_size() was dropped when the window moved into the .ui. Restored.

[sidebar-probe] reads both states and is the guard: window=756 sidebar=320 under a 2x scale, which is
the case that used to read 0.
This commit is contained in:
Martino Ferrari committed 2026-10-12 12:17:56 +02:00
1 parent a8c25f0cb3
commit bb01278d88
7 files changed
+165 -54

No files matched your search

+16
View File
@@ -4,6 +4,22 @@ How `plot::Figure`/`plot::Plot` may and may not be driven, what a row's state be
Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to learn; read the ones touching the area you are editing before you edit it.
- **A sidebar's size must be its own, never a divider's position.** `GtkPaned` keeps a child's size in
`position`, which is a number of pixels **of the window**: the sidebar's width *was* `window - 940`, so on
any window narrower than 940 px — a scaled display, where the logical width is half what the screen looks
like, or a window a manager capped — the sidebar came up **0 px wide**, and the only way to see it was to
drag the divider back. Every patch that kept the paned failed for the same reason (the position derived
from the paned's own width, re-parenting the column, the property form of its children, size requests):
the *mechanism* was the fault. It is an **`AdwOverlaySplitView`** now — what a utility sidebar is in
GNOME, as against `AdwNavigationSplitView`, which is for navigation lists — with `min-sidebar-width` =
`max-sidebar-width` = 320 in **pixels**, so the width is its own at any window size, and `collapsed` (set
from the window's width in `tick()`) to *overlay* the plot when there is no room beside it rather than
being squeezed. `[sidebar-probe]` reads both states: `window=756 sidebar=320` under a 2x scale is exactly
the case that used to be zero.
- **`AdwOverlaySplitView:sidebar-width-fraction` is a 0..1 fraction of the window, whatever
`sidebar-width-unit` says.** Setting a pixel count there (320) is a `GObject` critical, and it silently
falls back to 0.25 — which reads as "the sidebar is mysteriously 560 px wide" on a wide window with no
error anywhere the build would catch. Pin the width with `min-sidebar-width`/`max-sidebar-width`.
- **A `GtkPaned`'s position is stated after both its sides are filled**, and that is a fact about the
*function's order* rather than about the window's shape, which is why it is the one part of the
window skeleton that cannot live in `main-window.ui`. `gtk_paned_set_position()` clamps against what
+17
View File
@@ -1043,6 +1043,23 @@ library measures and draws the rect in tests of its own.
defect this reading exists for, and it was invisible from the frame alone: the file view pinning its
axis from the ring each frame is what swallowed the gesture.
## `[sidebar-probe]`
```
[sidebar-probe] beside window=756 sidebar=320 collapsed=1
[sidebar-probe] overlaid window=756 sidebar=320 collapsed=1
```
`UDPSCOPE_PROBE_SIDEBAR_MS` reads the sidebar's width in both of the split view's states: beside the plot,
and **overlaid** on it (`collapsed`, which is the state a window with no room beside the plot produces).
- **`sidebar` must never be 0.** It was, for every window narrower than the 940 px the divider used to sit
at, and the pane could only be found by dragging the divider back — the report this reading exists for.
It must read its own width (320) in *both* states: `collapsed` changes where the sidebar is, never how
wide it is.
- `collapsed=1` on the `beside` line means the window is narrower than `kSidebarRoom` (900), which is the
expected reading on a scaled display, where the logical width is half what the screen looks like.
## `[prefs-probe]`
```
@@ -74,6 +74,10 @@ needs a probe that proves it *loads* and that its contents match whatever drives
| `src/ui/ui_layout.cpp` (~20 kB) | the layout editor's overlay buttons, which are positioned by code | only the buttons are template |
| `build_toolbar` (`src/ui.cpp`, ~212 lines) | the header bar's layout, with every button still wired in code | delicate: the `[udpscope] header:` probe reads its geometry |
**Done since:** the sidebar is an `AdwOverlaySplitView` (`src/ui/main-window.ui`) rather than a paned's end
child, so its width is its own 320 px and it overlays the plot instead of collapsing to zero — see
`[sidebar-probe]`.
Each of these is one change on the pattern `src/ui/ui_cursors.cpp` now follows: `ui_load()` the file, resolve
the ids, keep every value, signal and rebuild in code, and run the panel's own probe before committing.
+34 -8
View File
@@ -2753,7 +2753,12 @@ void update_side_visibility(App& app) {
const bool have_signals = !app.model->signals().empty();
const bool want_column =
have_signals && gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(app.sidebar_toggle));
gtk_widget_set_visible(app.side_scroll, want_column);
// 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. Hiding the scroller
// instead left an empty column where the panels should be.
if (app.side_view != nullptr) {
adw_overlay_split_view_set_show_sidebar(ADW_OVERLAY_SPLIT_VIEW(app.side_view), want_column);
}
gtk_widget_set_visible(app.signal_toggle, have_signals);
gtk_widget_set_visible(app.trigger_toggle, have_signals);
}
@@ -3616,6 +3621,18 @@ 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
// 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
// worth looking at.
if (app->side_view != nullptr && app->window != nullptr) {
const int window_w = gtk_widget_get_width(app->window);
if (window_w > 0) {
adw_overlay_split_view_set_collapsed(ADW_OVERLAY_SPLIT_VIEW(app->side_view),
window_w < kSidebarRoom);
}
}
if (app->area_widget != nullptr) gtk_gl_area_queue_render(GTK_GL_AREA(app->area_widget));
// What the side column *demands*, not what it got. A GTK box's minimum width is its
// children's, a `GtkLabel`'s minimum is its text, and a minimum larger than the screen is
@@ -4375,7 +4392,8 @@ void on_activate(GtkApplication* application, gpointer user) {
app->window_ui = gtk_builder_new_from_resource("/org/udpscope/ui/main-window.ui");
if (app->window_ui == nullptr) return;
app->window = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "main_window"));
GtkWidget* paned = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "paned"));
GtkWidget* side_view = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "side_view"));
app->side_view = side_view;
GtkWidget* events_divider = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "events_divider"));
app->plot_box = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "plot_box"));
app->plot_split = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "plot_split"));
@@ -4386,7 +4404,7 @@ void on_activate(GtkApplication* application, gpointer user) {
GtkWidget* side = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "side_column"));
// A missing id is the one mistake neither the build nor `GtkBuilder` sees, and every use below would
// be a null pointer.
if (app->window == nullptr || paned == nullptr || events_divider == nullptr ||
if (app->window == nullptr || side_view == nullptr || events_divider == nullptr ||
app->plot_box == nullptr || app->plot_split == nullptr || app->status_badge == nullptr ||
app->status_label == nullptr || app->status_info_button == nullptr ||
app->side_scroll == nullptr || side == nullptr) {
@@ -4413,6 +4431,12 @@ void on_activate(GtkApplication* application, gpointer user) {
// shows are the window's, and those are in the file.
gtk_window_set_titlebar(GTK_WINDOW(app->window), build_toolbar(*app));
// The size the window opens at, stated in code as well as in the `.ui`: `default-width` there is the
// size it *asks* for, and a window whose content has a larger minimum opens at that minimum instead --
// 756 px here, the plot column's own minimum plus the sidebar's 320. The old code set it explicitly;
// this is where it went when the window moved into the file, and it is why the window came up small.
gtk_window_set_default_size(GTK_WINDOW(app->window), 1280, 800);
// The badge and the text share one row so the badge sits at the line's head. It is a
// sibling rather than part of the label's own markup because the label's text is
// rewritten every frame and its markup is ellipsized -- a blinking span inside it would
@@ -4574,11 +4598,10 @@ void on_activate(GtkApplication* application, gpointer user) {
// Two plots, in a paned so a comparison's divider can move: the primary takes everything while the
// second is hidden, and the width is the user's once it is not.
// After the children exist -- the sidebar's panels are appended above -- or the position is clamped
// against an empty pane and the divider lands wherever the empty width happened to be. This is the
// one thing about a `GtkPaned` that cannot live in the `.ui`: it has to be stated after both sides
// have been filled, which is a fact about this function's order, not about the window's shape.
gtk_paned_set_position(GTK_PANED(paned), 940);
// Nothing to state about the sidebar's size. It is the split view's: 320 px from the `.ui`, floored
// at 240, and never a number derived from the window -- which is what the removed
// `gtk_paned_set_position(paned, 940)` was, and why a window narrower than 940 px gave the sidebar
// no width at all.
// The split is in the `.ui`; what goes in it is not, because an area is a GL surface with a figure
// handed to it.
@@ -5236,6 +5259,9 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
if (const char* env = g_getenv("UDPSCOPE_PROBE_BOXZOOM_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), boxzoom_probe, &app);
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_SIDEBAR_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), sidebar_probe, &app);
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_PREFS_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), prefs_probe, &app);
}
+52 -46
View File
@@ -2,46 +2,67 @@
<!-- SPDX-License-Identifier: MIT -->
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
<!--
The main window's skeleton: the window, the two panes that make its three columns, the plot column
with the status line under it, and the sidebar's scroller.
The main window's skeleton: the window, the split view that puts the sidebar beside the plot, the
events pane's divider, the plot column with the status line under it, and the sidebar's scroller.
The sidebar is an `AdwOverlaySplitView`, which is what a utility sidebar is in GNOME (as against
`AdwNavigationSplitView`, which is for navigation lists). Three things follow from that, and they are
the reasons it is here:
- its width is *its own*: `sidebar-width-fraction` in **pixels** (the unit says so), floored by
`min-sidebar-width`. A `GtkPaned` kept a child's size in a divider position, which is a number of
pixels *of the window* — so on a window narrower than that number the sidebar came up with no
width at all, which is the bug this widget replaces;
- it resizes by dragging its own edge, within `min-sidebar-width`..`max-sidebar-width`;
- when the window is too narrow for both columns it *overlays* the plot instead of squeezing it,
which is what `collapsed` is for; `tick()` sets it from the window's width.
What is *not* here is as deliberate as what is. Everything this file nests is fixed — the shape of
the window does not depend on the stream, the trigger or the layout — while the four subtrees that
do are built by code and put in place:
the window does not depend on the stream, the trigger or the layout — while the subtrees that do are
built by code and put in place: the events pane, into `events_divider`'s start side; the two plot
areas, into the split inside `plot_box`; the sidebar's panels, appended to `side_column`; and the
status popover, attached to `status_info`.
- the events pane, into the inner paned's start side;
- the two plot areas, into the split inside `plot_box`;
- the sidebar's panels, appended to `side_column`;
- the status popover, attached to `status_info`.
A `GtkApplicationWindow` and not an `AdwApplicationWindow`, deliberately: the header bar is the
window's own titlebar and Adw's window refuses one outright (`gtk_window_set_titlebar() is not
supported for AdwApplicationWindow`, which is a fatal `Adwaita-ERROR`, not a warning). An Adw window
would want its header as an `AdwToolbarView`'s top bar instead, and this one is a `GtkHeaderBar`
packed by hand — a change for its own sake, and one that would take the window controls away from it
(a GtkHeaderBar draws those only as a titlebar).
The header bar is *not* in this file: it is the window's own titlebar,
built by `build_toolbar()` because every button on it is wired to an action. The status line is not a
bottom bar either — it belongs to the plot column, not to the window, so it stays inside `left_column`
where the paned divider can move past it.
The `GtkPaned`s are here rather than in code because their *rules* are the skeleton: the events pane
is shrinkable and does not resize (so a closed pane costs the plot nothing), and the sidebar is
neither. `gtk_paned_set_position()` is not, because it has to run after the children exist.
The header bar is not here either: it is the window's own titlebar, built by `build_toolbar()`
because every button on it is wired to an action. The status line is not a bottom bar — it belongs to
the plot column, not to the window, so it stays inside `left_column` where the divider can move past
it.
-->
<interface>
<object class="GtkApplicationWindow" id="main_window">
<property name="title">udpscope</property>
<property name="icon-name">org.udpscope.app</property>
<!-- The size the window asks for, stated in code as well as here: a window whose content has a
larger minimum opens at that minimum instead (see `ui.cpp`). -->
<property name="default-width">1280</property>
<property name="default-height">800</property>
<child>
<object class="GtkPaned" id="paned">
<property name="orientation">horizontal</property>
<property name="resize-end-child">0</property>
<property name="shrink-end-child">0</property>
<child type="start">
<object class="AdwOverlaySplitView" id="side_view">
<property name="sidebar-position">end</property>
<!-- Its width in pixels, not as a fraction of the window: 320 on a first run, and never less
than 240 however small the window gets. -->
<!-- Both ends of the range, in pixels: `sidebar-width-fraction` is a 0..1 *fraction* of the
window (setting a pixel count there is a GObject critical and falls back to 0.25), so the
width is pinned by min and max instead. -->
<property name="sidebar-width-unit">px</property>
<property name="min-sidebar-width">320</property>
<property name="max-sidebar-width">320</property>
<property name="sidebar">
<!-- The column scrolls as a whole, so a long signal list or a revealed trigger panel can
never push either panel out of the window. -->
<object class="GtkScrolledWindow" id="side_scroll">
<property name="hscrollbar-policy">never</property>
<property name="vscrollbar-policy">automatic</property>
<property name="min-content-width">300</property>
<property name="child">
<object class="GtkBox" id="side_column">
<property name="orientation">vertical</property>
<property name="width-request">300</property>
</object>
</property>
</object>
</property>
<property name="content">
<object class="GtkPaned" id="events_divider">
<property name="orientation">horizontal</property>
<!-- Shrinkable, or the pane's own minimum is reserved as an empty column even while it is
@@ -80,7 +101,7 @@
<child>
<!-- The line is rewritten every frame and its counters grow, so it is
ellipsized: a label's minimum width is its text, and an unellipsized one
would push the paned divider around as the numbers change. -->
would push the divider around as the numbers change. -->
<object class="GtkLabel" id="status_label">
<property name="label">starting…</property>
<property name="ellipsize">end</property>
@@ -100,22 +121,7 @@
</object>
</child>
</object>
</child>
<child type="end">
<!-- The column scrolls as a whole, so a long signal list or a revealed trigger panel can
never push either panel out of the window. -->
<object class="GtkScrolledWindow" id="side_scroll">
<property name="hscrollbar-policy">never</property>
<property name="vscrollbar-policy">automatic</property>
<property name="width-request">320</property>
<child>
<object class="GtkBox" id="side_column">
<property name="orientation">vertical</property>
<property name="width-request">300</property>
</object>
</child>
</object>
</child>
</property>
</object>
</child>
</object>
+8
View File
@@ -405,6 +405,10 @@ struct App {
/// The main window's builder. Held because the window belongs to its object list: it is replaced on
/// the next build rather than reused, and everything found by id comes out of it.
/// The split view the sidebar lives in. Its width, its collapse and its overlay are that widget's
/// business -- `AdwOverlaySplitView` -- and not a paned position's.
GtkWidget* side_view = nullptr;
GtkBuilder* window_ui = nullptr;
bool comparing = false;
/// The last x/y range seen on each side of a comparison, one entry per row: a frame compares them
@@ -1000,6 +1004,9 @@ struct RowMenuProbeState {
};
constexpr int kTickMs = 16;
/// Below this window width the sidebar has no room beside the plot, and the split view overlays it
/// instead of squeezing it. See `tick()`.
constexpr int kSidebarRoom = 900;
constexpr int kWindowUnitCount = 3;
constexpr double kWindowMinS = 1.0e-6;
constexpr int kWindowLabelChars = 9;
@@ -1129,6 +1136,7 @@ void sync_events_divider(App& app);
/// because the theme is the one preference that takes effect without a restart.
void open_preferences(App& app);
gboolean prefs_probe(gpointer user);
gboolean sidebar_probe(gpointer user);
gboolean boxzoom_probe(gpointer user);
/// A labelled row whose control keeps its natural width -- what a `GtkSwitch` needs and what
/// `labeled_box()`'s expanding child denies it.
+34
View File
@@ -1633,6 +1633,40 @@ gboolean boxzoom_probe(gpointer user) {
return G_SOURCE_REMOVE;
}
/// `UDPSCOPE_PROBE_SIDEBAR_MS`: the sidebar's width, in both of the split view's states.
///
/// The small-window case is the one a divider position got wrong: with the sidebar's width derived from
/// the window, a window narrower than that number gave it nothing at all. The split view takes its size
/// from its own properties instead, and when there is no room beside the plot it *overlays* it
/// (`collapsed`) rather than shrinking to nothing. Both states are read here, so "the sidebar is never
/// zero" is a number rather than a claim.
gboolean sidebar_probe(gpointer user) {
App& a = *static_cast<App*>(user);
if (a.side_view == nullptr) return G_SOURCE_REMOVE;
const auto report = [](App& app, const char* what) {
const int side = app.side_box != nullptr ? gtk_widget_get_width(app.side_box) : -1;
const int win = app.window != nullptr ? gtk_widget_get_width(app.window) : -1;
std::fprintf(stderr, "[sidebar-probe] %-10s window=%d sidebar=%d collapsed=%d\n", what, win,
side, adw_overlay_split_view_get_collapsed(ADW_OVERLAY_SPLIT_VIEW(app.side_view)) ? 1
: 0);
};
report(a, "beside");
// The state a window with no room produces: the sidebar goes over the plot, and its width is still its
// own. If this ever reads 0, the sidebar is being given a share of a width that is not there.
adw_overlay_split_view_set_collapsed(ADW_OVERLAY_SPLIT_VIEW(a.side_view), TRUE);
g_timeout_add(400, +[](gpointer u) -> gboolean {
App& app = *static_cast<App*>(u);
const int side = app.side_box != nullptr ? gtk_widget_get_width(app.side_box) : -1;
std::fprintf(stderr, "[sidebar-probe] %-10s window=%d sidebar=%d collapsed=%d\n", "overlaid",
app.window != nullptr ? gtk_widget_get_width(app.window) : -1, side,
adw_overlay_split_view_get_collapsed(ADW_OVERLAY_SPLIT_VIEW(app.side_view)) ? 1 : 0);
adw_overlay_split_view_set_collapsed(ADW_OVERLAY_SPLIT_VIEW(app.side_view), FALSE);
return G_SOURCE_REMOVE;
}, &a);
return G_SOURCE_REMOVE;
}
gboolean prefs_probe(gpointer user) {
App& a = *static_cast<App*>(user);
open_preferences(a);