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.
13 KiB
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 §4.3a, 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
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.rsalready 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().
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:
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.
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:
ViewKindis a closed enum. ItsWidgetKindcounterpart 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
kinda 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.