ui-layout.md named the arrangement's owners but nothing knew which elements exist, so a view switch could silently take a running session's tracker reset away: docs/ui-elements.json now states all 195 by region, pane and view, ui/elements.py loads it, and tools/ui_elements_dump.py drafts it. Verified: apps/studio 393 items (13 new) and ui_check 600/0, autocal 358, shared 390 with its 2 known wall-clock failures, lint and doc gates clean; a removed element, a renamed objectName and a new named widget each fail the suite.
9.9 KiB
Studio UI layout contract
Rules for the Studio window's layout, and for the tests that prove it. The colour equivalent is
ui-palette.md; this file is the layout half.
Scope: apps/studio. The same rules apply to apps/autocal where it has panes of its own.
1. Ownership
| Concern | Owner |
|---|---|
| Top-level panel arrangement, presets, splitter sizes, which panes are visible | view_layout.py (ViewLayoutController) |
| A panel's internal layout, its own minimums, how it degrades when narrow | that panel's module |
| Colour and appearance | theme tokens + build_qss (see ui-palette.md) |
| Shared spacing and minimum sizes | the token module named in §4 |
A panel MUST NOT set the size of another panel, and MUST NOT reach into the splitter. If a panel needs more room, it raises its own minimum and lets the controller allocate.
One owner per region. A region's geometry is decided in exactly one place. The app_window.py
extractions (ViewLayoutController, DisplayPrefsController) are how this is achieved; new layout
behaviour belongs in a controller, not in app_window.py.
2. View transitions
The three top-level views are arrangements of the same window. Switching between them is a single operation with a fixed set of effects.
Every path that changes the view MUST go through apply_preset(). It is the only place that
applies the complete set, and apply_preset deliberately performs all of:
- sets
view_presetand the persisted preference; - sets
show_calfrom the preset; - sets the rail's source view (
replayvslive); - sets the run-button role (
playbackvsacquire); - applies panel visibility;
- applies the preset's splitter sizes;
- switches the chart/replay source and clears replay-only state when leaving replay;
- updates the brand bar, view checks and replay actions.
MUST NOT write _view_preset, the view_preset preference, or the splitter sizes directly.
Doing so produces a state that looks correct in the nav and wrong everywhere else — stale chart
source, wrong run role, sizes from the previous view.
The transition that broke
ViewLayoutController.on_cal_panel_toggled(False) set _view_preset = "live" and persisted it
without calling apply_preset("live"). The sequence replay → calibration → calibration off
therefore ended with view_preset == "live" while the rail still reported the replay source and no
live splitter sizes had been requested. The live branch (on_cal_panel_toggled(True)) did call
apply_preset, which is what made the asymmetry easy to miss.
This transition MUST be covered by a regression test that asserts the whole effect set of §2, in the
controller and again through a real StudioMainWindow. Asserting only view_preset, show_cal and
the brand bar — as the existing toggle test does — is what let it through.
3. Per-view arrangement
Each view is described by what it is for, so priority disputes have a reference.
| View | Primary pane (gets width first) | Companion panes | Degradation as width shrinks, in order |
|---|---|---|---|
live |
measurement panes (2D/3D) | charts, settings rail | charts narrow to their minimum, then settings rail collapses, then charts scroll |
replay |
charts (history browsing is the point) | 2D pane, rail | 2D pane narrows, then hides where the layout allows |
calibration |
cal_panel — its six-column table and action row need width |
rest | the table scrolls horizontally rather than compressing columns below readability |
The 3D pane is a peer of the 2D pane, never a replacement for it. Where both are visible they share the measurement region; where only one is, it takes the region.
Squeeze is a designed state, not an accident. For each pane, name what happens at minimum width. "Everything gets smaller" is not a degradation order.
4. Numeric geometry
Not every numeric argument is a defect; blanket replacement of numbers with names is churn. The rule distinguishes by reason:
| Kind | Rule |
|---|---|
| Structural constants — 1 px separator, 1 px rule, a colour-bar's height, a native handle that must stay at zero size | Keep, with a named constant and a comment stating why the number is the value |
| Shared spacing — margins, gaps, padding shared by more than one panel | MUST come from the shared token module |
| Content-driven width — a field, a spin box, a label that holds text | MUST derive from sizeHint(), fontMetrics(), or a token for its role. A literal that must hold the longest locale string MUST be justified in a comment |
| A flexible panel's own minimum | MUST be a named constant with the reason recorded |
The token module (shared spacing and minimum sizes) does not exist yet; creating it is a follow-up. Until it does, new shared spacing MUST NOT be introduced as a literal in more than one place.
Text must fit. Any control whose text can change with locale MUST hold the longest supported string without clipping.
5. Enforcement gaps to close
These are stated because the contract is not currently enforced where it claims to be.
- The palette prohibition is not mechanically enforced.
ui-palette.mdstates that hex literals are not allowed in widget code, butui_check.audit_palette()reads the theme JSON and checks values and contrast only — it never scans source. Hex literals exist in widget modules today. Either add the source scan or correct the claim inui-palette.md. - The containment check is position-blind.
test_ui_smoke.test_children_stay_inside_their_parentcompareschild.width()/height()against the parent's, and never looks atx/y. SinceQWidget.geometry()is parent-relative, a child shifted outside its parent — or overlapping a sibling — passes as long as it is no larger. It MUST compare the full rectangle. - Rendered 3D pixels are tested but conditionally.
test_scene3d_gl_smoke.pyasserts on pixels throughgrabFramebuffer, and skips without a real GL context. That is the right design; the gap is that the skip is invisible in an ordinary run. Where the GL gate is required, a skip MUST be reported as a failure of the run, not counted as a pass.
6. Tests
The existing suites stay: state assertions are cheap and catch real regressions. What is added is assertions about the visible result.
| Tier | What it asserts | Where |
|---|---|---|
| Transition | the full effect set of §2, for every path that changes the view, including the breaking sequence | test_view_layout.py (controller) and a window-level test through StudioMainWindow |
| Geometry | full child rectangles inside parents, no sibling overlap among laid-out peers, required text fits | extend test_ui_smoke.py |
| Matrix | the above under combinations that historically broke: minimum window size, longest locale, both themes, sensor type, pane state | parametrised in test_ui_smoke.py |
| Rendered | a few pinned 2D and toolbar snapshots for human review | ui_snapshot.py output, reviewed not asserted |
| GL pixels | rendered 3D, in the real-context suite only | test_scene3d_gl_smoke.py |
On waiting: processEvents() dispatches pending paint events, so it is sufficient to let a
layout settle. It does not assert what was painted, and it does not guarantee that later
asynchronous work has finished. Tests that depend on completion MUST wait for the signal or
condition, not for a fixed number of processEvents() calls. pytest-qt makes that easier and is
not a prerequisite.
Golden images, if adopted: pin the window size, scale factor, font and theme, or they will flake and be disabled. A flaky render gate is worse than none.
7. The element manifest
ui-elements.json is the machine-readable half of this contract. The rules
above say who owns the arrangement; the manifest says which elements are supposed to exist, per
region, per pane and per view. Each entry carries a stable id, the Qt objectName (recorded only
when it is a stable identity — the rail's transport carries a stylesheet token instead, so it is
found by path), the class, a one-line purpose, and the views it appears in. Anything that exists
only in some views, or only while a condition holds, declares views or a named condition
rather than leaving it ambiguous.
ui/elements.py loads it and answers the questions a test
needs: missing(), wrong_class(), unlisted_named_widgets() and validate().
tests/test_ui_elements.py runs them against a real
StudioMainWindow in each view, and asserts the declared panes are the visible ones.
tools/ui_elements_dump.py drafts the file from a live window.
The manifest is authored, not generated. A generated file accepts whatever the code happens to contain, which is the drift it exists to catch; the dump is a drafting aid and its output must be curated by hand. An element is listed before a test relies on it — that is what makes the file a spec rather than a description of what someone found. The drift report (visible named widgets the manifest accounts for neither as an element identity nor as a declared stylesheet token) is compared with the record in each view, so new interface names fail until the manifest says what they are.
Whoever changes the set of elements — adding, renaming or removing a widget the manifest covers — updates the manifest in the same change, before the test is asked to rely on it.
This is the element tier of §6 and nothing more. It does not assert geometry or containment, it does not run the locale/theme/minimum-size matrix, and it does not compare rendered pixels. Those extend it rather than replace it: the geometry tier would add the rectangle a listed element is allowed to occupy, the matrix tier would vary the window states the same list is checked under, and the rendered tier would pin a reference image for the panes it names. None of that is attempted here.