Files
DarkRoom/docs/dev/view-composition.md
dtourolle 84fade99ec Put the developer docs under docs/dev and index the folder for users first
docs/ had 26 developer documents flat beside the manual, and the two
audiences are very differently sized: most readers want the manual and
the gesture reference, a few want the register, the designs and the
measurements. The manual and gestures.md stay at the top; everything for
someone changing the code moves to docs/dev/, and the two documents that
name their own successors — the v0.1 milestone and the UI-refinement plan
— go to docs/dev/archive/ rather than being deleted, since both are still
cited. docs/README.md is the index, users first.

Every reference follows: code comments, Cargo manifests, the workflows,
the pre-commit hook, the bench and traceability tools (which locate the
repo root by docs/dev/requirements.md now), packaging, the Docker READMEs,
CLAUDE.md, CONTRIBUTING.md and the README. The matrix links one level
deeper and is regenerated. Links out of the moved documents into the tree
gain a level; a link checker over every Markdown file finds none broken.
2026-09-20 21:16:03 +02:00

297 lines
13 KiB
Markdown

# View composition: a controller for the display layer
TRACES: FR-UI-1 | FR-UI-6 | FR-UI-8 | FR-DEV-3a | NFR-P9
**Status:** Draft · 2026-08-09
**Companion to:** [architecture.md](architecture.md) §4.3a, [ui-refinement.md](archive/ui-refinement.md)
## Why
`dr-ui` has four views — launch, library, develop, collections — and no view
layer. `run()` in `ui/dr-ui/src/lib.rs` is 500 lines that construct every
controller, wire every cross-view callback, own the develop session, and hold
the only complete picture of what is on screen. It has become the controller
by accretion rather than by design, and it shows in three specific ways.
**The back-patched knot.** `run()` declares
```rust
let open_from_library: Rc<RefCell<Option<Rc<dyn Fn(String)>>>> = ...
```
wired empty at line 328 and filled at line 604, because the library grid needs
to open an image in develop and the develop closure needs the GPU context that
is built after the library is wired. The comment in the source is candid about
it: *"This cell is the knot between them: wired empty here, filled once `show`
exists."* A nullable function slot resolved at runtime is what a dependency
cycle looks like when the language will not let you write one directly.
**Two copies of load-and-display.** `show` (lib.rs:524) and the remote-fetch
timer body (lib.rs:657) run the same sequence after their inputs diverge: set
`load_error` empty, set camera / exposure / dimensions from metadata, branch on
whether sensor data was recovered, then either `set_adjust_enabled(true)` +
`sync_rows` + `redraw`, or clear the session, empty the rows, disable adjust
and show the fallback image — and on failure, clear the session and report the
error. One takes a path and one takes bytes; everything downstream is written
twice.
The shared preamble has *already* been factored out into `reset_view_state`,
which both branches call, so the direction is established — the post-load half
is simply the part that has not been done yet. The two halves have not yet
drifted in the fields they set; the argument for consolidating is to keep it
that way, since every future display field must currently be added in two
places.
**Diffuse ownership of window state.** Distinct `window.set_*` calls by module:
| Module | Distinct window properties written |
|---|---|
| `library_ui` | 24 |
| `lib.rs` (`run`) | 23 |
| `launch_ui` | 16 |
| `collections_ui` | 9 |
No one owns "what is on screen". The clearest symptom is view switching
itself: `show-launch` and `show-library` are two booleans encoding one piece
of state, written from seven call sites across three modules
(`lib.rs:353`, `launch_ui.rs:171`, `library_ui.rs:246/254/1486/1645/1655`).
Nothing prevents both being true, and `app.slint` compensates with
`if !root.show-launch && root.show-library` chains at lines 349, 386 and 491.
`develop.rs` is the exception that proves the point: it writes no window
properties at all, taking capabilities in and returning rows and images out.
It is the one module already shaped the way this document argues for.
None of this is broken. It works, and several of the surrounding patterns are
load-bearing and correct — the mpsc-plus-timer worker shape exists because
Slint's event loop must never block (NFR-P9), and `sync_rows` mutates rows in
place because replacing the model breaks slider dragging. This document
changes ownership, not threading and not Slint model handling.
## Non-goals
- No change to the threading model. Workers stay on threads; results still
return through channels drained by Slint timers (NFR-P9).
- No change to the pipeline, catalog, sync layer, or render path.
- No change to how operations reach the adjust panel. `develop.rs` already
composes from capabilities and names no operation (FR-DEV-3a); that stays.
- **No dynamic view instantiation.** See the constraint below.
## The constraint that shapes all of this
Slint has no runtime component instantiation. Views are selected by statically
compiled conditionals over properties — `if root.show-launch: LaunchScreen`
and friends — so every view must exist in the `.slint` source at build time.
Descriptors therefore drive **composition and chrome**: which views exist,
their labels, their order, whether each is currently available, and which one
is active. They cannot conjure a view body. This is a smaller claim than
"self-describing modules" might suggest, and stating it here is deliberate:
a design that assumed otherwise would hit the wall at codegen.
`build.rs` already generates `theme.slint` from `style.yaml`, so generating a
tab bar from the descriptor set is *possible* later. It is not proposed now —
generated `.slint` is markedly harder to debug than generated tokens, and the
win does not yet justify it.
---
## Stage 1 — `ViewController`
Independently landable, and worth landing whether or not stages 2 and 3
follow.
A `ViewController` owns the window handle and the state that currently floats
in `run()`'s closure captures: `session`, `entries`, `index`, `viewport`,
`rows`, and `redraw`. Sub-controllers are held by it rather than wired to each
other through `run()`.
```rust
pub struct ViewController {
window: slint::Weak<AppWindow>,
gpu: Option<GpuContext>,
session: RefCell<Option<DevelopSession>>,
entries: RefCell<Vec<PathBuf>>,
index: Cell<usize>,
viewport: Cell<(u32, u32)>,
rows: Rc<slint::VecModel<ParamRow>>,
library: Rc<LibraryController>,
collections: Rc<CollectionsController>,
launch: Rc<LaunchController>,
}
```
Three changes follow, in order:
**1a. One `present()`.** Both load routes converge on a single method:
```rust
impl ViewController {
fn present(&self, loaded: Result<Loaded, String>, name: &str) { ... }
}
```
The local path calls `self.present(load(gpu, path), &name)`; the remote timer
calls `self.present(load_bytes(gpu, &bytes), &name)`. The duplicated post-load
sequence exists once, so a new display field can only be added in one place.
**1b. `Rc<ViewController>` replaces the nullable callback cell.** The grid's
click handler captures the controller and calls
`controller.open_remote(path)`. Both sides now depend on the controller rather
than on each other, so the cycle disappears and the `Option` slot with it.
**1c. Collapse the four adjust callbacks.** `on_param_changed` (lib.rs:702),
`on_param_reset` (716), `on_reset_all` (730) and `on_curve_reset` (747) are
four blocks differing only in which `DevelopSession` method they call; each
then runs the identical `sync_rows` + `redraw` pair. A single
`self.mutate_session(|s| ...)` helper that performs the mutation and then
re-syncs makes the shared tail impossible to forget.
Note that not every handler wants that tail — the view/pan handlers below them
deliberately `redraw` without `sync_rows`, because panning changes no
parameter. The helper must therefore be the *opinionated* path for parameter
mutation, not a mandatory funnel for everything that touches the session.
**Verification.** These are refactors with no behavioural change: existing
tests in `lib.rs` (`describe_camera`, `describe_exposure`, `collect`) must pass
untouched, and manual checks cover launch → library → develop, next/previous,
slider drag, reset, and canvas resize.
---
## Stage 2 — The `View` trait
The same discipline `descriptor.rs` already applies to operations, applied one
level up. That precedent matters: the core publishes `OpDescriptor` /
`ParamKind` / `Presentation`, and `develop.rs` builds controls from it without
naming a single operation. Views are the same shape of problem.
```rust
pub struct ViewDescriptor {
pub id: ViewId,
pub label: LocalizedKey,
pub kind: ViewKind,
}
/// Closed, not a string — a controller must be able to match exhaustively
/// and know it has covered everything. Same reasoning as `WidgetKind`.
pub enum ViewKind {
/// Occupies the window alone; no chrome, not tabbable. Launch.
Modal,
/// Participates in the tab set. Library, develop.
Primary,
/// Renders beside a primary view. Collections.
Adjunct,
}
pub enum Availability {
Available,
/// Greyed out, with a reason the UI can show. Not hidden — a missing
/// tab is indistinguishable from a bug.
Unavailable(LocalizedKey),
}
pub trait View {
fn descriptor(&self) -> ViewDescriptor;
fn availability(&self, ctx: &AppContext) -> Availability;
fn activate(&self, ctx: &AppContext) {}
fn deactivate(&self, ctx: &AppContext) {}
}
```
Two properties are carried up from `descriptor.rs` deliberately, because they
are why that design works:
- **`ViewKind` is a closed enum.** Its `WidgetKind` counterpart says so
explicitly: *"a UI must be able to match exhaustively and know it has
covered everything the core can ask for."*
- **Descriptors are hints with a working fallback.** A curve degrades to
sliders. A view whose `kind` a shell does not implement still renders
standalone; nothing about tabbing is required for a view to function.
Labels are `LocalizedKey`, reusing the type `dr-pipeline` already exports —
`dr-ui` depends on `dr-pipeline`, so this adds no coupling, and it keeps view
labels on the same footing as operation labels. `labels::resolve` already takes
a `&str` and derives a readable fallback for uncatalogued keys, so a new view
appears with a sensible label before anyone writes a translation, exactly as a
new operation does today.
`availability` is the part that earns the trait rather than merely tidying.
Develop-without-a-GPU and collections-without-a-catalog are handled
inconsistently today — `run()` logs a warning and sets `backend` to "NO GPU",
`library_ui` guards each catalog access separately — and the differences are
not intentional. One method, asked before a view is offered, makes those cases
uniform and gives the shell something honest to display.
`activate`/`deactivate` exist so switching away can stop timers and release
GPU resources instead of leaking them, which nothing does today.
**Scope check.** Four views, all known, all in-tree, no third-party authors.
This abstraction has to justify itself against an `if` chain, and on tab
composition alone it would be close. The `availability` consolidation is what
tips it, because that is a correctness fix rather than a tidiness one.
**Verification.** Stage 2 retrofits the trait to the four existing views and
changes no Slint. The descriptors must reproduce current behaviour exactly
before stage 3 consumes them.
---
## Stage 3 — Data-driven view switching
With descriptors in place, the boolean pair collapses:
```
- in property <bool> show-launch;
- in property <bool> show-library;
+ in property <string> active-view;
+ in-out property <[TabEntry]> view-tabs;
```
`app.slint`'s conditionals key off `active-view == "library"` rather than a
two-boolean conjunction, and the illegal both-true state stops being
representable. `view-tabs` is a model the controller publishes from the
descriptor set, carrying label, id, and availability — so the tab bar is built
from data even though the view bodies are static.
Only `ViewController` writes `active-view`. The seven scattered `set_show_*`
call sites become `controller.activate(ViewId::Library)`, which is also the
hook where `deactivate` on the outgoing view runs.
**Verification.** Behavioural parity on every transition currently reachable:
launch → library on sign-in, library → develop on cell click, develop →
library on back, and the startup paths in `launch::Startup` (all three arms).
---
## Sequencing
| Stage | Depends on | Independently valuable |
|---|---|---|
| 1 — `ViewController` + `present()` | — | Yes: removes the cycle and the duplication |
| 2 — `View` trait | 1 | Yes: uniform availability handling |
| 3 — `active-view` | 2 | Yes: illegal states unrepresentable |
Stage 1 first regardless. The descriptor layer needs a controller to live in,
and the `present()` duplication is a live bug source independent of how views
are composed.
## Requirements
Stages 1 and 2 are traced by existing IDs — FR-UI-6 (shared components),
FR-DEV-3a (self-describing), NFR-P9 (no UI-thread blocking). Stage 3's claim,
that view composition is data-driven and view identity single-valued, has no
requirement covering it. Proposed for `requirements.md` §3.5, after FR-UI-7:
> **FR-UI-8 — Self-describing views.** Each view publishes a descriptor —
> identity, label key, kind, and current availability — and a single
> controller composes the interface from the descriptor set. View identity is
> single-valued: exactly one primary view is active at a time. A view that
> cannot currently function reports why, and the shell presents it as
> unavailable rather than omitting it. Adding a view requires no change to the
> shell beyond registering it and declaring its body.
Not added to the register by this document; adding it is a separate edit,
since `requirements.md` is the register of record and renumbering there ripples
into `traceability.md`.