# 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`](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: 1. sets `view_preset` and the persisted preference; 2. sets `show_cal` from the preset; 3. sets the rail's source view (`replay` vs `live`); 4. sets the run-button role (`playback` vs `acquire`); 5. applies panel visibility; 6. applies the preset's splitter sizes; 7. switches the chart/replay source and clears replay-only state when leaving replay; 8. 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. 1. **The palette prohibition is not mechanically enforced.** `ui-palette.md` states that hex literals are not allowed in widget code, but `ui_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 in `ui-palette.md`. 2. **The containment check is position-blind.** `test_ui_smoke.test_children_stay_inside_their_parent` compares `child.width()/height()` against the parent's, and never looks at `x`/`y`. Since `QWidget.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. 3. **Rendered 3D pixels are tested but conditionally.** `test_scene3d_gl_smoke.py` asserts on pixels through `grabFramebuffer`, 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`](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`](../src/orisys_studio/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`](../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`](../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.