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:
- Detects layout constraint violations at runtime (during development).
- Provides a testable API so violations can be asserted against in Catch2.
- 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:
- Resize the widget.
- Process the event loop.
- Collect violations.
- 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:
- Enumerate all combo box items × all checkbox states.
- For each state, resize to a set of representative sizes.
- 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:
- Programmatically create a set of dock widgets.
- Randomly arrange them (side-by-side, tabbed, floating).
- Resize the main window to various dimensions.
- 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. QMenuBarandQStatusBarare excluded from the extreme aspect ratio check viaqobject_cast— they are inherently designed as wide, thin bars.- The offscreen Qt platform (
QT_QPA_PLATFORM=offscreen) does not enforcepropagateSizeHints(), 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())againstcontentsRect().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 viaQStyle::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_combosquishing described in the Phase 1 known limitation.
Property panel resize sweep tests added:
GlyphStyleControls— width sweep 50–500 pxLineStyleControls— width sweep 50–500 pxEstimationMethodControls— 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:
- Setting
setMinimumWidth()on the spinboxes. - Switching the
QFormLayoutrow wrap policy toWrapAllRowsso labels stack above fields when the container is narrow. - Setting an appropriate
minimumWidthonColormapControlsitself.
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.