Layout Sanity Checker — Roadmap

Motivation

Dockable widget panels in WhiskerToolbox can be resized by the user to arbitrary dimensions. When a panel is too narrow or too short, child widgets (spinboxes, combo boxes, labels) can become “swished” — still visible but too small to interact with or read. Qt’s layout system does not hard-prevent this; it simply compresses widgets below their preferred sizes.

This problem has been observed concretely in ColormapControls when switching to Manual scaling mode inside a narrow HeatmapWidget dock panel. The QDoubleSpinBox widgets for vmin/vmax become unreadable because the QFormLayout squeezes them horizontally.

Rather than fixing each occurrence ad-hoc, we want a reusable diagnostic library that:

  1. Detects layout constraint violations at runtime (during development).
  2. Provides a testable API so violations can be asserted against in Catch2.
  3. Enables fuzz-style testing where widgets are resized to many dimensions and mode combinations are exercised, catching regressions automatically.

Architecture

Core: LayoutSanityChecker

A single header-only (or header + cpp) utility living under a new src/WhiskerToolbox/LayoutTesting/ library. No Qt GUI dependency beyond QtWidgets.

src/WhiskerToolbox/LayoutTesting/
├── CMakeLists.txt
├── LayoutSanityChecker.hpp
├── LayoutSanityChecker.cpp
└── LayoutViolation.hpp

Key types:

struct LayoutViolation {
    enum class Severity { Critical, Warning, Info };

    Severity    severity;
    QString     widgetPath;   // "MainWindow/DockWidget/ColormapControls/vmax_spin"
    QString     className;    // "QDoubleSpinBox"
    QString     description;  // Human-readable explanation
    QSize       actual;       // Actual widget size
    QSize       expected;     // minimumSizeHint or computed minimum
};

Two usage modes:

Mode How Purpose
Global monitor Install as event filter on qApp; logs violations via qWarning Development-time awareness
Static query LayoutSanityChecker::getViolations(QWidget * root) Catch2 test assertions

Widget Path Resolution

Many widgets lack object names. The checker builds a diagnostic path by walking the parent chain and using either the object name or the meta-object class name:

MainWindow / QDockWidget / HeatmapWidget / ColormapControls / QDoubleSpinBox

This makes violations immediately actionable — the developer can locate the exact widget in the tree.

Checks

Phase 1 — Core Checks

These catch the most common and severe problems:

# Check Severity Description
1 Zero dimension Critical width() == 0 || height() == 0 on a visible widget
2 Below minimumSizeHint Warning Widget smaller than minimumSizeHint() in either axis
3 Extreme aspect ratio Warning Visible widget with aspect ratio > 20:1 or < 1:20 (squished to a line)

Phase 2 — Content-Aware Checks

These require inspecting widget-specific properties:

# Check Severity Description
4 Label text truncation Warning QLabel::fontMetrics().horizontalAdvance(text()) > width()
5 Spinbox content overflow Warning Spinbox too narrow to display its maximum value text plus buttons
6 ComboBox content overflow Warning ComboBox too narrow to display the longest item text

Phase 3 — Spatial Checks

# Check Severity Description
7 Clipped children Warning Child widget geometry extends beyond parent rect()
8 Overlapping siblings Info Two visible siblings with significantly overlapping geometries

Global Monitor Design

The global monitor attaches as an event filter to qApp and watches for QEvent::LayoutRequest and QEvent::Resize on top-level windows. When triggered, it debounces via a restarting single-shot timer (50 ms) to let cascading layout passes settle before running the tree walk.

// Guarded by #ifndef NDEBUG in main.cpp
LayoutSanityChecker layoutChecker;

This is a debug-only facility. It is compiled out in release builds.

Test Integration

Unit Tests for the Checker Itself

Verify that the checker correctly detects known-bad layouts:

TEST_CASE("LayoutSanityChecker detects zero-dimension widget", "[ui][layout]") {
    QWidget parent;
    auto * child = new QWidget(&parent);
    child->setFixedSize(0, 50);
    child->show();
    parent.show();
    QCoreApplication::processEvents();

    auto violations = LayoutSanityChecker::getViolations(&parent);
    REQUIRE_FALSE(violations.empty());
    REQUIRE(violations[0].severity == LayoutViolation::Severity::Critical);
}

Resize Sweep Tests for Existing Widgets

Systematically resize a widget across a range and assert no violations appear:

TEST_CASE("ColormapControls layout resilience", "[ui][layout]") {
    ColormapControls controls;
    controls.show();
    QCoreApplication::processEvents();

    for (int w = 10; w <= 800; w += 10) {
        controls.resize(w, 300);
        QCoreApplication::processEvents();
        auto violations = LayoutSanityChecker::getViolations(&controls);
        if (!violations.empty()) {
            INFO("Width: " << w);
            for (auto const & v : violations) {
                INFO(v.description.toStdString());
            }
            CHECK(violations.empty());
        }
    }
}

Mode-Toggle Tests

For widgets with mode-dependent visibility (like ColormapControls showing/hiding spinboxes), exercise each mode at constrained sizes:

SECTION("Manual mode at constrained widths") {
    for (int w : {50, 100, 150, 200, 300}) {
        controls.resize(w, 400);
        controls.setColorRangeMode(ColorRangeConfig::Mode::Manual);
        QCoreApplication::processEvents();
        auto v = LayoutSanityChecker::getViolations(&controls);
        CHECK(v.empty());
    }
}

Fuzz Testing Strategy

Resize Fuzzing

Systematically sweep width × height dimensions. For each dimension pair:

  1. Resize the widget.
  2. Process the event loop.
  3. Collect violations.
  4. Log or assert.

A full sweep for a single widget (10–800 px, step 10, both axes) produces ~6400 test points. This can be run as a parameterized Catch2 GENERATE test.

State-Space Fuzzing

For widgets with configurable state (combo box selections, checkbox toggles, spinbox values), enumerate combinations:

  1. Enumerate all combo box items × all checkbox states.
  2. For each state, resize to a set of representative sizes.
  3. Collect and aggregate violations.

This is particularly useful for ColormapControls where Manual mode reveals additional widgets that Auto mode hides.

Dock Arrangement Fuzzing

Since WhiskerToolbox uses Qt6AdvancedDocking:

  1. Programmatically create a set of dock widgets.
  2. Randomly arrange them (side-by-side, tabbed, floating).
  3. Resize the main window to various dimensions.
  4. Walk all visible widgets for violations.

This catches cases where a specific dock arrangement creates an unexpectedly narrow panel.

Property Fuzzing

Randomize widget properties that affect sizing:

  • Font size / DPI scaling
  • Spinbox decimal count and range
  • Label text length
  • Locale (affects number formatting width)

Implementation Phases

Phase 1: Core Library + ColormapControls Fix

Implementation notes:

  • Internal Qt child widgets (objectName starting with qt_) are automatically skipped to avoid false positives from platform-specific internal sizing.
  • Third-party framework widgets (class names starting with ads::) are skipped entirely — the Advanced Docking System’s spacers, title bars, and area widgets are infrastructure we do not control and they legitimately have extreme aspect ratios or zero-dimension spacers.
  • Combo box popup internals (QComboBoxPrivateContainer, QComboBoxListView) are skipped — these are ephemeral popup widgets with sizes governed by the popup mechanism, not the application layout.
  • QMenuBar and QStatusBar are excluded from the extreme aspect ratio check via qobject_cast — they are inherently designed as wide, thin bars.
  • The offscreen Qt platform (QT_QPA_PLATFORM=offscreen) does not enforce propagateSizeHints(), so resize sweep tests may not detect all violations that occur on a real display. The checker is most useful when run under XCB or Wayland.

Known limitation — content-aware squishing:

Phase 1 checks detect widgets that are below minimumSizeHint() or have zero dimensions, but they do not detect content overflow — e.g. a QComboBox that is technically above its minimum size but too narrow to display its item text without clipping. This is the case for the color_range_mode_combo in ColormapControls when switching to Manual mode: the combo box is squeezed by the QFormLayout to make room for the vmin/vmax rows, but it remains at or above minimumSizeHint(). Detecting this requires Phase 2 Check 6 (ComboBox content overflow).

Phase 2: Content-Aware Checks + Event-Driven Diffing

Event-driven violation diffing: The global monitor stores the previous violation set and only emits newly appeared violations. This uses LayoutViolation::operator== which compares severity, widgetPath, and description. The _previous_violations member is updated after each scan. This makes the output event-driven: “widget X became squished after you did Y” instead of repeating the same violations every 50 ms.

Content-aware checks implementation:

  • Check 4 (Label truncation): Uses QLabel::fontMetrics().horizontalAdvance(text()) against contentsRect().width(). Rich text labels (Qt::RichText) are skipped since their rendered width cannot be computed via simple font metrics.
  • Check 5 (Spinbox content overflow): Uses QAbstractSpinBox::text() to get the current display string, then compares its rendered width against the edit-field sub-control rect obtained via QStyle::subControlRect().
  • Check 6 (ComboBox content overflow): Iterates all items to find the widest text, then compares against the edit-field sub-control rect. This catches the color_range_mode_combo squishing described in the Phase 1 known limitation.

Property panel resize sweep tests added:

  • GlyphStyleControls — width sweep 50–500 px
  • LineStyleControls — width sweep 50–500 px
  • EstimationMethodControls — binning mode width sweep + method transition tests at constrained widths (Binning, Gaussian, CausalExponential)
  • ColormapControls — Auto→Manual mode-toggle diffing test at widths 100–500 px

Phase 3: Spatial Checks + Dock Fuzzing

CMake Integration

add_library(LayoutTesting STATIC
    LayoutSanityChecker.hpp
    LayoutSanityChecker.cpp
    LayoutViolation.hpp
)
target_link_libraries(LayoutTesting PUBLIC Qt6::Widgets)

Test executables link LayoutTesting alongside their widget-under-test. The main application links it only in debug builds via a generator expression:

target_link_libraries(WhiskerToolbox PRIVATE
    $<$<CONFIG:Debug>:LayoutTesting>
)

Relationship to ColormapControls Bug

Running the Phase 1 resize sweep on ColormapControls in Manual mode is expected to identify vmin_spin and vmax_spin as violating minimumSizeHint at narrow widths. The fix likely involves one or more of:

  1. Setting setMinimumWidth() on the spinboxes.
  2. Switching the QFormLayout row wrap policy to WrapAllRows so labels stack above fields when the container is narrow.
  3. Setting an appropriate minimumWidth on ColormapControls itself.

The checker output will pinpoint exactly which widgets are undersized and at what threshold width the violations begin, providing data-driven guidance for the fix.