Files
ykcheungab eb70ac7638 add the Studio element manifest and its window-matching test
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.
2026-10-01 15:18:16 +08:00

163 lines
9.9 KiB
Markdown

# 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.