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

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.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().

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:

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