diff --git a/docs/claude/invariants-plot-and-layout.md b/docs/claude/invariants-plot-and-layout.md index 39f4b22..fa45d42 100644 --- a/docs/claude/invariants-plot-and-layout.md +++ b/docs/claude/invariants-plot-and-layout.md @@ -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 diff --git a/docs/claude/probe-readings.md b/docs/claude/probe-readings.md index f0e92ec..22f8ae0 100644 --- a/docs/claude/probe-readings.md +++ b/docs/claude/probe-readings.md @@ -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]` ``` diff --git a/docs/superpowers/plans/2026-10-11-ui-files-and-gtk4-practice.md b/docs/superpowers/plans/2026-10-11-ui-files-and-gtk4-practice.md index 6ae95a5..3cf828e 100644 --- a/docs/superpowers/plans/2026-10-11-ui-files-and-gtk4-practice.md +++ b/docs/superpowers/plans/2026-10-11-ui-files-and-gtk4-practice.md @@ -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. diff --git a/src/ui.cpp b/src/ui.cpp index a8c0f11..ae3c73b 100644 --- a/src/ui.cpp +++ b/src/ui.cpp @@ -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(std::atoi(env)), boxzoom_probe, &app); } + if (const char* env = g_getenv("UDPSCOPE_PROBE_SIDEBAR_MS")) { + g_timeout_add(static_cast(std::atoi(env)), sidebar_probe, &app); + } if (const char* env = g_getenv("UDPSCOPE_PROBE_PREFS_MS")) { g_timeout_add(static_cast(std::atoi(env)), prefs_probe, &app); } diff --git a/src/ui/main-window.ui b/src/ui/main-window.ui index 31dfd64..fef2cc8 100644 --- a/src/ui/main-window.ui +++ b/src/ui/main-window.ui @@ -2,46 +2,67 @@ udpscope org.udpscope.app + 1280 800 - - horizontal - 0 - 0 - + + end + + + px + 320 + 320 + + + + never + automatic + 300 + + + vertical + 300 + + + + + horizontal + would push the divider around as the numbers change. --> starting… end @@ -100,22 +121,7 @@ - - - - - never - automatic - 320 - - - vertical - 300 - - - - + diff --git a/src/ui_app.hpp b/src/ui_app.hpp index a95bc92..b39850d 100644 --- a/src/ui_app.hpp +++ b/src/ui_app.hpp @@ -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. diff --git a/src/ui_probe.cpp b/src/ui_probe.cpp index 64c1cfe..3c615ba 100644 --- a/src/ui_probe.cpp +++ b/src/ui_probe.cpp @@ -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(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(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(user); open_preferences(a);