The component library
How the rendering layer is organised, and the one rule that keeps it that way.
Two layers
Everything that decides an appearance lives in crates/micold-client/src/ui/, split in two:
ui/cdk/ — behavior primitives. Things that do something without deciding how it looks: the
overlay that positions and stacks floating surfaces, the Progress track that advances an
animation and knows when to stop. A primitive here must not hard-code a colour, a corner radius or
a duration.
ui/material/ — styled components. Button, TextField, Modal, NavigationDrawer,
TreeView, and the rest. These decide appearance, and they are the only place that may. The style
layer (ui/material/style.rs) is internal to this directory — nothing outside it can reach the
functions that turn a design token into a widget style.
Everything else under ui/ — sidebar.rs, toolbar.rs, shell.rs, the dialogs — is a feature
module. Feature modules compose components. They do not style widgets.
The rule
A feature module composes components. It never reaches for the rendering stack’s widgets to style them itself.
Concretely, a feature module may use layout primitives (row!, column!, Space, stack!) and
components from material::. It may not call .style(...) on an iced widget, construct a
container::Style, or import ui::material::style.
The reason is not tidiness. When a call site can style a widget directly, the design system becomes advisory: two places that should look the same drift, and changing the look means finding every place that decided it. Making the styling layer unreachable turns “use the component” from a convention into the only thing that compiles.
Why this is enforced by tests rather than review
Three gates run in CI. Each exists because the corresponding mistake is easy to make and invisible in a diff:
| Gate | File | What it catches |
|---|---|---|
| Boundary | tests/material_boundary.rs | a feature module importing the style layer or styling a raw widget |
| Builder API | tests/material_builder_api.rs | a component that is not built the same way as every other one |
| Opacity ratchet | tests/component_api_opacity.rs | a public component signature exposing how it works |
The ratchet is worth understanding before you add a component. It scans every public signature in
the library for a short list of forbidden things — progress values, style closures, animator
handles, rendering-stack types — and holds a REMAINING list of known exceptions. It fails both
when a new leak appears and when the list names something already fixed. It is currently empty,
which is the state it is meant to stay in.
Components own how they look; the application owns what is true
The dividing line:
If the state would still matter with animation disabled, it is the application’s. If it exists only to make a transition look right, it is the component’s.
So sidebar_hidden is application state — it is written to disk and restored next run. How far the
drawer has slid is not; the drawer owns that, in the widget tree, where the renderer already keeps
per-instance state.
This has a consequence worth stating plainly: there is no central animator, no enumeration of
animated elements, and no animation clock. A component that animates holds a cdk::motion::Progress
in its widget-tree state and asks the runtime for the next frame only while it is moving. Two
instances cannot interfere, because neither can see the other. A removed component drops its state
with it — so “nothing animates after an element disappears” is structural rather than policed.
The corollary for callers: a component takes a destination, never a position.
// The caller says where it should be:
Modal::new(dialog, roles).shown(state.overlay != Overlay::None)
// Not how far along it currently is:
fade(dialog, progress) // ← what this used to look like
Adding a component
- Put it in
ui/material/, one file per component, and register it inmod.rs. - Give it a builder:
Thing::new(required, args, roles)plus chainable.option(x)steps, and aFrom<Thing> for Elementat the end. The builder gate checks this. - Take design tokens (
Roles,spacing::*,type_scale::*), never literal colours or sizes. - If it animates, own a
Progressand take a destination. Do not add a parameter that carries progress in — the ratchet will fail, and it is right to. - If it needs to tell the application something, emit a message describing a decision (“the edge was dragged to x”), not a mechanism (“a drag started”).
- Add it to the component showcase — an
Entryinsrc/showcase/catalogue.rsplus a render function insrc/showcase/sections/. This is not optional and not a courtesy:tests/showcase_completeness.rsfails the build naming your component until you do, because a catalogue that silently omits things is worse than no catalogue at all.
Point 5 is the one most often got wrong. The resize handle used to emit SidebarDragStarted and
SidebarDragEnded so the application could mount a pointer-capture layer on its behalf. Owning the
drag itself let all of that be deleted; what remained was the single message that carried a
decision.
Pickers: one foundation, two controls
A picker is a field with a list of choices anchored beneath it. There are two — Typeahead for
searching a long list, Select for choosing from a fixed one — and everything they have in common is
one place rather than two similar places.
Shared, in material::picker / cdk::picker | Each control’s own | |
|---|---|---|
| Look | the row, the panel it sits on, the grow-and-fade transition | the field: a search box, or a trigger with a chevron |
| Behaviour | anchoring, flipping, dismissal, the keyboard rule | where the answer to a key goes |
| Timings | short_3 in, short_2 out, §6.3’s menu rows | — |
Neither control states a duration or a curve. If you are writing a third picker, you do not either:
call picker::animated_menu and pass picker::EXIT to the base, and it matches the other two by
construction. src/ui/material/picker_parity.rs builds both existing controls and compares their
lists rectangle for rectangle, so “they look the same” is measured rather than intended.
Searching a long list: Typeahead
Any picker over more entries than fit on screen consumes material::Typeahead rather than building
a second one. It is the branch picker’s control, but nothing about it is branch-shaped — a check
(tests/typeahead_is_generic.rs) fails the build if either half of it names a branch, a worktree or
git.
material::Typeahead::new(&self.query, rows, Message::QueryChanged, roles)
.placeholder("Search branches…")
.open(self.list_open)
.highlighted(self.highlight)
.selected(selected_index)
.empty_message("No branches match that search.")
.on_focus(Message::Focused)
.on_move(Message::HighlightMoved)
.on_dismiss(Message::Dismissed)
.on_pick(|i| Message::Chosen(i))
.into()
What it does not do is as important as what it does:
- It does not match. Rows arrive already matched, already ranked and already carrying the byte
ranges to emphasise. The matcher is
micold_core::typeahead— render-free, so the tiers, the ranking rules and the keyboard rule are all unit-tested rather than clicked at. - It holds no state. Whether the list is open, where the keyboard is, and what is selected are all the caller’s, passed in and echoed back as messages. That is what lets an open list with no rows exist at all — the state that shows the no-match message.
- It knows nothing about your domain. Its row is
{ label, spans, enabled }and, optionally, a second line. Whatever explains an unavailable row must already be insidelabel, and the second line is plain text the caller composed; the component never learns what it is made of.
A row may carry that second line through Row::details(text, spans). A row with details is two
wrapping lines: the label at the body role, the details under it at the caption role in the
secondary colour. Both wrap, a word wider than the row breaks inside the word, and nothing is
truncated; the row is as tall as its text needs, never shorter than a menu item, and the picked-row
marker sits beside the label’s first line. A row without details is unchanged: fixed height, one
truncated line.
So a new picker’s work is a mapping — your candidate type to a Row, and a row index back to your
candidate — plus the four messages. The branch picker’s version of that mapping is about twenty
lines in ui/worktree_form.rs, and it is the only place branch vocabulary and component vocabulary
meet.
The behaviour half, cdk::picker, is the only module in the library allowed to write its own
Widget::overlay(), and tests/one_overlay_implementation.rs holds it to saying why: the list
anchors to the field’s own on-screen bounds so it works inside a content-sized dialog, and it draws
rows that emphasise individual characters, which the rendering stack’s own menu cannot. Both pickers
go through it, so adding a third adds no second mechanism.
Choosing from a fixed list: Select
material::Select::new(&ConventionalType::ALL, self.chosen, Message::TypeChosen, roles)
.label("Type")
.placeholder("Select…")
.into()
Shorter than the type-ahead’s, and the difference is the interesting part: there is no .open(),
no .highlighted(), and no dismiss or move message. A select holds its own openness and its own
keyboard position, because nothing outside it is coupled to either — its options are fixed by the
call site, so there is no query for the list to be a function of. The type-ahead is the other way
round for exactly that reason: its rows are a function of caller-held state, so its openness is the
caller’s too.
That asymmetry is deliberate and is what closed accepted fidelity gap #3. §7.7 wants the active
indicator thickened while the list is open, and the previous select — built on the rendering
stack’s pick_list — reported its open state to its own style closure and to nobody else, so
Select::active had to be supplied by a caller and none was. Openness being the widget’s own is what
lets the indicator answer from the control’s own knowledge, and active left the builder with it.
So: if the list’s contents depend on something the screen holds, the screen holds the openness too. If they do not, the widget holds it. Do not make a new picker symmetric with the other one for symmetry’s sake — that is the mistake this arrangement exists to avoid.
Keeping the highlighted row in view
When rows differ in height, a fixed scroll step cannot keep the highlight visible. menu_element
wraps the highlighted row, and only it, in a container carrying the widget Id PICKER_HIGHLIGHT.
ui::picker_scroll::picker_highlight_into_view is a Task that runs a two-pass widget operation,
like focus::scroll_focused_into_view: pass one finds that row and the innermost scrollable it is
inside, pass two scrolls only that scrollable, by focus::delta_into_view with a margin of 0 (the
focus operation uses its 16dp MARGIN). A row already wholly visible does not move the list, and a
row taller than the list is aligned to its top.
Only the issue picker chains it today, after FormMsg::IssueHighlightMoved
(shell::issues::on_issue_highlight_moved). The branch picker and Select have rows of one base
row height and are not wired. A caller with rows of differing height chains the task after its own
highlight-moved message.
Switching between options: ToggleChip
material::ToggleChip::new("GitHub issue", Message::SourceChosen(Source::Issue), roles)
.active(source == Source::Issue)
.disabled(!github_available)
.into()
.disabled(true) is a builder step like the rest, not a second chip type. A disabled chip emits no
press and does not ripple, because a ripple would report a press that never happens. It takes
Material’s disabled treatment from tokens::state: its content at DISABLED_CONTENT (38%) and, when
active, its container at DISABLED_CONTAINER (12%). Say why it is disabled next to it. The
add-worktree form puts the reason in the caption under its source switch, because a greyed chip on
its own does not tell the user what to fix. The gallery poses it as disabled.
Where the boundary genuinely bends
Two things sit outside the library on purpose, and both are documented at their call sites:
- The terminal grid. It draws cells from a VT model, not components, and is a feature module for
that reason.
Widget-attached dropdowns.No longer an exception. It was one for a real reason — a dropdown inside a content-sized dialog has nothing window-level to anchor against, andSelectis built on iced’spick_list, whose overlay is positioned by the rendering stack from the trigger’s on-screen bounds.pick_listwas the only thing that could position from the trigger’s own bounds.cdk::pickerdoes that now and is ours to style, so the select is a component like any other and the sanction that named it was deleted rather than left standing. The gate that holds the list to one entry is what forced that:tests/one_overlay_implementation.rsfails while a sanction that no longer applies is still listed.
So the list is one exception, not two. If you find yourself wanting a second, the gates will tell you
— and the honest move is to extend a component rather than add an entry to a REMAINING list.