Please check the build logs for more information.
See Builds for ideas on how to fix a failed build, or Metadata for how to configure docs.rs builds.
If you believe this is docs.rs' fault, open an issue.
designs / gpui
The desktop (GPUI) variant of the Fluid Attacks design system. The web variant
in designs/src is the source of truth; this workspace projects its tokens and
components onto gpui for Rust desktop apps
(develops/mrq today, signals when it migrates).
Layout
Follows the federated Rust standard (develops/utils/rust):
crates/domain—fluidattacks-designs-domain: the design tokens and per-component styling decisions, pure andno_std, usable from any toolkit on any platform. Grows one component at a time; the web files each module mirrors are named in its docs.crates/shell—fluidattacks-designs: the GPUI widgets over those tokens. macOS-gated with their toolkit for now, mirroring mrq (gpui's Linux build needs a native windowing stack the Linux CI runners don't carry).
Catalog
cargo run --example gallery opens the native component catalog (macOS). A
browser gallery in the spirit of gpui-component's
story-web (gpui compiled to wasm) is the intended long-term storybook
equivalent; it stays out of scope until gpui's wasm backend ships in a
crates.io release, because the federated cargo-deny policy pins all
dependencies to crates.io (allow-git = []).
Boxes and scrolling
Container is the web's generic box. Its style surface there is TModifiable,
which is CSS by another name, and gpui's Styled is the same vocabulary, so the
port delegates to it rather than re-listing seventy properties: every gpui style
method works on a Container. Reach for it instead of a bare div() so an app
gets the design system's behaviour rather than reimplementing it.
That behaviour is the scroll prop. On the web overflow: auto is the whole
story, since the browser tracks the offset and paints the bar. gpui does neither,
so the port also takes the handle to track and composes Scrollbar:
new
.scroll
.flex
.flex_col
.child
It is also a block box, as a div is on the web, so children stack at their
content size and a scrolling one overflows. gpui defaults to flex instead, and
that default is a trap: flexbox drops the automatic minimum size of any child
hiding its own overflow, so a scrolling flex column squeezes those children and
never scrolls. Stacking is both the faithful default and the safe one; .flex()
opts into flex layout and its rules, exactly as on the web. Scrollbar stays
public for the rare bar that is not a Container's own.
Table
The web's table wraps TanStack Table, which supplies sorting,
filtering, column state and selection. No such library exists for GPUI, so the
port wrote that model itself: it lives in fluidattacks-designs-domain::table,
pure and tested, and TableState is the shell that holds it.
That split is the thing to know before reading the rest. TableState is an
entity you own — you build it once, keep it, and call methods on it; Table is
the element, rebuilt every frame, and carries only what the frame needs.
let table = cx.new;
new
.height
.search
.control
.pagination
.empty
height is the framed part — header, rows and footer — so the rows get what the
other two leave. The search-and-controls row sits above it and adds its own.
Rows virtualize against that remainder, which is why the number is the caller's
rather than something the content decides.
As on the web, a cell wraps its text within its column and keeps its line breaks, and a row stands as tall as its tallest cell, never below 40px. Every row is measured whenever the rows, the filters or a column's width change, so the table expects a page of rows, not a whole source.
Rows
Rows are Vec<Vec<SharedString>>, cells in column order, and set_rows
replaces the lot. Cells are strings even when they hold numbers — ColumnKind
is what makes 10 sort after 9 instead of before it:
state.set_rows;
set_rows drops the colors with the rows it replaces, because a color is keyed
by row index and those indices now mean something else. Repaint after serving:
state.set_rows;
for in severities.iter.enumerate
set_cell_color tints one cell instead, and clear_colors drops both.
Reading back what is on screen — after the sort, the filters and the search have
had their say — is visible_cells, Signals' get_visible_rows, with
visible_rows for just the count.
Sorting, search and filters — and where they run
Three ways to narrow, and the distinction that matters is what data they see.
All three act on the rows in hand, which under pagination is one page, not
the set behind it. Clicking a header cycles asc → desc → none, and the search
box — off unless search(true) mounts it — matches every visible cell.
An app that needs them over the whole set sends them to its source instead —
that is what on_search and on_sort are for. Both fire after the table has
recorded the change, so you read the new standing off the state and re-fetch:
// `query` reads the whole standing back off the table, so both handlers ask
// the source the same way
state.on_search;
// ordering a page is not ordering the catalog, so the source does it; like the
// web, a new standing returns to the first page
state.on_sort;
Install these once, when you build the state. Both are deferred, so a handler may call back into the table freely.
Column filters are predicates, Signals' filter / negative_match:
state.set_filter;
state.set_filter; // everything but
state.clear_filter;
sort_by sets the standing without a click, and set_search_placeholder
relabels the box.
Selection
Single by default. multi_select consumes the state, so it goes on the way in:
let table = cx.new;
which opens the rest:
| gesture | |
|---|---|
| click | select that row alone |
| shift-click | extend the run from the anchor, which stays put across a series |
| cmd-click | toggle one row, leaving the others (gpui's secondary modifier) |
| press and drag | lay a run down the rows |
| cmd/ctrl-C | copy the selection as TSV |
The selection is held by row key, like the web's rowSelection. set_keyed_rows
takes the app's own keys, the web's getRowId, and they must be unique. Sorting,
filtering and new keyed rows all keep the keys held, including ones not among
the rows in hand, so a pick survives a reload and a page change. set_rows keys
each row by its position and clears the selection, since after a reload a
position names another row.
state.set_keyed_rows;
state.set_selected;
state.on_selection;
on_selection hears every change once, whatever made it, and is deferred like
on_search. selected() returns the selection as indices into the rows last
supplied, copy_selection is the same clipboard write the keybinding does. The
keybinding arrives with Theme::init — an app that installs the theme needs no
keymap of its own.
Indices are into the rows you supplied, not into what is on screen; sorting and
filtering move rows around them. position_of converts one to a screen
position, which is what scroll_to_row and focus_row take. Both bring the
row just into sight rather than centering it, since rows differ in height:
if let Some = state.position_of
selection_column(true) draws a checkbox before the first column, the web's
display column. A row's box toggles that row alone and never reaches the row
click; the header's selects every row shown, or releases them once all are, and
leaves the rows the filters hide alone. With multi_select(false) the header
cell is empty and a row's box replaces the selection. Rows with a box paint no
selected wash or marker, as the web's do not.
Columns
Readers resize by dragging a header's right edge and reorder by dragging the header itself; both are gestures, with no API to call. Visibility is an app decision, so it has one:
let shown = state.is_column_visible;
state.set_column_visible;
Total column width can exceed the frame — that is what makes the horizontal bar appear, and the header scrolls with the rows. The bar answers the pointer dragging it, not the wheel: a container that scrolls only one axis otherwise claims the wheel's other axis and the page behind it stops moving.
Pagination
Server-side, the web's manualPagination. The table never slices anything: a
control moves the page it holds, hands it back, and fetching those rows is
yours.
// the handler outlives the frame that built it, so it owns a clone
let table = self.table.clone;
new.pagination
Without the counts the next arrow has nothing to go on and stays spent. A
source that knows its total says PageCounts::of(total); one that only knows
whether more exist says PageCounts::open(has_next), and the label reads 100+
the way the web's getCurrentRange does.
The footer's size chooser reports through the same handler, returning to the first page and asking for it. It offers steps up to the first one that covers what the source holds, plus whatever size is in use — so a table of 30 rows showing 25 offers 10, 20, 25 and 50, and never 100. A source that will not name its total is assumed to reach the largest step.
Two consequences worth knowing before wiring it up:
- The label counts the source; the rows count the filter. Searching a page
down to nothing leaves the empty state under a label still reading
1-25 of 320. Both are true — the source does hold 320 — but they answer different questions. set_pageresets the scroll, because the rows underneath it changed. Calling it is the signal, including for a re-fetch of the page already shown.
What the web has and this does not
CSV export, and the filter dropdowns beside the search — those are the app's to
assemble out of Select and hand to control, which is the same shape the web
takes. Editable cells exist in neither.
Text fields
TextInput is one line, TextArea is a block, and everything that is not the
text itself is the same on both — the label with its asterisk and tooltip, the
border that walks the state machine, the length counter, the error line, the
gray help line. That shared frame is the web's OutlineContainer, and the two
widgets differ in what sits inside it, which is exactly how the web differs.
Both use the split Select uses: the state is an entity the app owns, the
widget renders it.
let comment = cx.new;
new
.label
.required
.help_text
rows
rows is the height of the box, in lines, and text past it scrolls. That is
what a <textarea> is — a window onto the text, not a box that grows with it.
The height: auto in the web's stylesheet sits on the container, which is
only how the container comes to wrap the height the control already had; miss
that and you build a field that pushes the page down as it is typed into.
The caret is kept inside that window as it moves, since nothing else will
bring it back, and the vertical Scrollbar shows when there is more to read.
The count is honored as written, which the web does not do. Its
min-height: 6rem outranks any rows under five, so a rows={1} and a
rows={4} come out the same height there — the rows={3} on the comment
editor and the rows={1} on the chat box are both dead props. Here three rows
is three rows. An unset rows falls back to inputs::DEFAULT_ROWS, five,
which is the nearest whole count to what that floor actually shows.
What the extra dimension brings
Enter writes a newline instead of reaching the form, so a textarea that
should submit on a chord wants the app to bind one — the web's comment editor
uses ctrl+enter. Up and down walk the caret between visual rows, holding the
column they started from. Home and End answer to the visual row rather
than to the whole value, so a wrapped line behaves like the separate line it
looks like. Pasting keeps its newlines, where the single-line field has to
flatten them.
Wrapping is the box's, not the caller's: the text shapes at whatever width the layout hands down, and re-shapes when that width changes. The field never shrinks to make room for its neighbours — a form field is not a page's slack, and one squeezed below its own text would paint straight over what follows.
What the web has and this does not
resize, which the web turns off anyway, and maskValue. That second one is
worth being precise about: on the web's textarea maskValue only adds the
sr-block class, which keeps the value out of session replay and has no
effect on screen. It is not TextInput's bullet mask, so there is nothing to
port and no reveal toggle to show.
Selects
The web combobox is react-aria plus react-stately plus a virtualizer, so
almost nothing there was ours to copy: the menu's stylesheet was, and the
behaviour those three libraries supply had to be written. SelectState is the
entity the app owns — the options, the choice, and where the keyboard sits —
and Select renders it, the same split TextInput uses:
let picker = cx.new;
picker.update;
new.on_change
Filling the menu is not choosing from it, and only the second reports.
Neither set_options nor set_value can reach on_change, by construction:
the handler is held by the frame that renders the rows, not by the state. This
is the one behaviour to know before wiring a picker to a loader — the Signals
group picker used to select the first item as its list arrived and load a group
nobody had asked for.
The rest follows from gpui rather than from the web. The menu matches the width
the field measured last frame, because nothing else can tell a floating box how
wide the control it hangs off turned out to be. It dismisses on a mouse-down
outside rather than on blur, or choosing a row would blur the field and take
the row away before the click reached it. Grouped menus draw their rows
straight and flat ones virtualize, since uniform_list needs every row the
same height and a header is taller than an option. empty_message is what the
menu says when the query rules everything out.
The keymap — down, up, enter, escape — is bound by Theme::init, so an app
that installs the theme needs nothing of its own.
What the web has and this does not: multi-selection and its "All" row, the
clear button, per-option tags and links, and the label, help and error rows a
form field wears. Those last ones are not missing so much as elsewhere: they
belong to the outline container that TextInput also wraps, not to a select.
Lists
Table is for rows of cells and Select is for a menu that floats; List is
the plain column a screen stacks when each row carries one value. The split is
Select's: ListState is the entity the app owns, List renders it.
let topics = cx.new;
topics.update;
new.on_change
Filling the list is not choosing from it, exactly as with Select: neither
set_items nor set_value can reach on_change. Both also drop a value no row
carries, so a list repopulated from a slow query never stands on a row that is
gone.
The arrows move the choice rather than a separate highlight. A list is not a
floating menu — there is nothing to dismiss and nothing to commit — so the row
the walk lands on is the choice, and it reports as a click on that row would.
enter re-reports the row already standing, which is what answers a reader who
tabbed in. The keymap is bound by Theme::init.
SelectionMode::None is a list that answers no choice: rows stack, nothing
highlights, and on_change never fires — set_value is refused too, so the
mode cannot be talked into painting a row as chosen. That is the shape a
progress column needs: Signals' reattack assign window stacks findings only to
show how each one went, and a row that lit up under the pointer would invite a
click it will not answer.
The far glyph is one slot, as on the web: a row that names a right_icon shows
it, and one that does not shows a tick only while it is the choice. Its handler
is separate from the row's and stops the press, so a glyph can remove a row
without also selecting it. empty is the EmptyState the card shows when it
holds no rows.
max_height caps the card and scrolls its rows past that. It is the one
deviation from the web, and it is gpui's: there the page scrolls and the card
just grows, which is what an uncapped card here still does.
What this does not have: the href row — the tokens for a link's color are in
tokens::list, but no row renders one yet — and a header, which the web
documents but never declares or renders. Multi-selection, drag to reorder and
nested lists are absent from the web component too.
Checkboxes
Controlled, like the web: Checkbox paints the checked it is handed and
reports the value a click would produce. Nothing in the widget remembers
anything, so a checkbox cannot drift out of step with the filter or setting it
stands for.
new
.label
.on_change
Two differences from the web worth knowing. It is pointer-driven and takes no
focus: the web leans on a hidden native <input> for the keyboard, and there
is no native input here to stand on. And its 16px square is the one metric in
the design system not copied from the component it ports — the web hides that
input and paints the box on the check glyph itself, declaring an 8px svg with
4px of padding and a 1px border, which Tailwind's preflight puts in
border-box, where the padding and border do not fit inside the 8px they are
declared within. The sibling radio-button has no glyph to paint on and writes
width: 16px outright, so this takes that square and the two controls line up.
Worth confirming against the published component the next time the gallery runs
on a Mac.
Dialogs
Modal and ConfirmDialog render while the app draws them, the way the web
returns null when its modal is closed, and report through one handler:
new
.title
.confirm_text
.cancel_text
.on_outcome
.child
Two things the web does not have to say. Host the dialog as the last child of the view that owns the window, beside the scroll box rather than in it: the scrim is absolute over its parent, so a parent that scrolls or clips takes the dialog with it, and gpui paints siblings in order, so an earlier one is painted over. A browser portal appends to the end of the body for the same two reasons.
Last child rather than deferred. Deferring would raise the dialog over any
sibling, but gpui panics on a deferred inside another, and both Select and
Tooltip defer their floating parts — a deferred dialog would abort the
moment a form in its body opened a menu.
The body scrolls itself once it outgrows the box's 80% cap, so the caller hands
over content and nothing else. gpui paints no bar and tracks no offset, so the
dialog composes a Container with Scroll::Y and carries the handle across
frames in its element state.
Cancel and dismiss stay apart. The web folds both into one close(), and a
confirm dialog resolves its promise false for either; Signals treats someone
who said no differently from someone who walked away, so Outcome carries all
three and a caller that does not care can match the two together.
Alerts
A message that carries a severity. Alert is the web's banner, inline in the
column that raised it; AlertDialog is the same message as a blocking dialog,
which is the shape Signals shows it in:
new
.variant
.title
.closable
new
.variant
.on_acknowledge
Four variants — error, info, success and warning — each with its glyph. The
title is optional and takes the emphasis when it is there: the web drops the
message to regular weight under a bold title, and leaves it bold when it stands
alone. The dialog is a Modal with one button, so everything the Dialogs
section says about hosting one applies here too.
The ink follows the fill, not the mode. The web names shades directly — a
50 fill, a 500 border, 700 ink — and dark cannot flip those shade by shade:
700 ink on a 700 fill is no ink at all. So the fill re-anchors to the scale's
deep end and color::readable_on picks the ink for whatever fill it landed on:
the web's own 700 over the pale one, near-white over the deep one. Both ends
clear 4.5:1, and a test holds them there. The border is the vivid mid, which
color::dark leaves alone, so the frame is one color in either mode — a
hairline against its own fill, as it is on the web, and 5:1 and better against
the page.
The timeout is armed once, on the frame the banner first appears. gpui
renders a widget many times over and a timer per frame would be a stampede, so
a time or a handler changed after that frame is not re-read: draw it under a
new id to re-arm, which is what remounting does to the web's effect.
auto_hide decides whether the timeout also hides the banner; on_timeout
reports either way, like the web's onTimeOut.
A banner owns one piece of state, whether it is still up, so a dismissal or a
timeout needs no flag in the app. An app that wants the banner gone on its own
terms stops drawing it, which is why the web's show prop has no counterpart.
What the web has and this does not: the notification and message-banner
components built beside it, and toasts stacked in a corner. Each is additive on
what is here.
Markdown
Markdown draws a Markdown text. It is gpui-component's TextView
(gpui_ce_components, the gpui-component port built on the same gpui-ce as
this crate), dressed in the design system:
new
- Type is ours: Roboto body on the
Textscale, headings onHeading's sizes (#lg,##md,###sm, deeper xs), a 16px paragraph gap. - Code blocks take the web code snippet's box: the gray-100 fill, a 4px
radius, monospace 14/20, and our ghost copy button. No syntax colouring, as
on the platform today;
TextView's tree-sitter highlighter stays off. - Links open nothing — deciding what a click may open is the app's — and the text can be selected and copied.
TextView reads gpui-component's own theme global, so Theme::init also calls
gpui_component::init and repaints that theme from our roles and faces on
every mode switch. An app that calls Theme::init needs nothing else.
What TextView brings that the web design system has no component for — lists,
quotes, tables, images — is drawn in its own style, over our colors and faces.
Pictures
CloudImage resolves an illustration by Cloudinary id, out of the kit the
Icons section describes. ImageView is the other half: a picture the app
already holds, which is what evidence is — bytes the platform API returned, or
a file the pentester just chose.
new
.bytes
.alt
.width
.on_click
It fetches nothing, writes no file and logs nothing. The caller hands over bytes it already has and gpui decodes them in memory. Evidence can show a customer's systems, so the widget touching neither disk nor network is a property worth stating rather than a coincidence of the implementation. gpui does keep the decoded frames in its process-wide asset cache, keyed by the bytes themselves; an app's own cache of encoded evidence, keyed by identifier, sits above that. Signals has one, and a second cache inside a widget rebuilt every frame would only fight it.
Sizing is the web's FilePreview: width and height are the box, the
picture keeps its aspect ratio inside it, and an axis left unset is computed
from the other — which is what height: auto means there.
opacity on the web is not the element's opacity. It is the alpha of a black
wash the preview paints over the picture, defaulting to none, so it is called
scrim here: a port that read the name at face value would fade the evidence
into the page instead of dimming it.
Three things stand in for a picture
No source at all is the caller still fetching, and shows the Loading spinner.
Bytes that will not decode show a notice box, because a blank square in a list
of evidence reads as evidence that is not there. That box is an Alert's
error palette at the size of the slot the picture would have filled — the same
object the banner is, so it needs no severity vocabulary of its own.
A video is the third. tokens::image_view::file_type carries the web's
extension table, so a caller can tell a .webm from a .png before choosing a
constructor, but gpui has no video element and Signals does not play evidence
inline either — it hands the file to the desktop. So video() says so on
screen rather than painting a square that reads as missing evidence.
The viewer
A 120px thumbnail is for finding the right picture, not for reading one, so a
press opens ImageViewer:
let picture = new.bytes;
new
.on_close
The view only reports the press. Everything the Dialogs section says about hosting a modal applies to the viewer, and a thumbnail sits in exactly the place a dialog may not be raised from: inside the scroll box, with siblings painted after it. So the app keeps the flag and draws the viewer where its other dialogs live.
How big the picture gets is the viewer's decision, not the caller's: it takes
image_view::VIEWER_WIDTH_FRACTION of the window, which is a little less than
the Lg dialog around it, because the dialog spends the difference on its
header and the body's insets. A test holds the picture inside that box across
the window sizes a desktop gets. Hand the viewer a source, then — a width or a
height set on the view it is given is replaced.
The web has no viewer to mirror. Its own image modal is a 185px banner across the top of a dialog, decoration rather than a way to read evidence, so both the component and its sizes are desktop decisions over the modal's tokens.
What the web has and this does not
The <video controls> player and the link around it, for the reason above.
alt has no accessibility sink in gpui — there is no tree to write it
into — so it becomes a Tooltip on the picture, which is the one place a
desktop toolkit can put a description today.
Out of scope until something asks for them: zoom, pan, annotation, and stepping from one picture to the next. Signals uses none of the four.
Theme and modes
Theme::init(mode, cx) installs one of two tables, theme::LIGHT or
theme::DARK, as a gpui global. Widgets read it at render time, so switching is
a re-init and a repaint — call it again with the other mode and nothing below
has to be told:
let next = cx.theme.mode.flipped;
init?;
window.refresh;
It registers the faces on the first call only, so a switch cannot fail for that
reason; the Result is there because the first call can.
The dark table is the light one re-anchored by color::dark, which pairs each
shade with the one that holds the same contrast against a dark surface rather
than flipping its index — the ramp is not perceptually symmetric, so an index
flip turns quiet fills loud. Text lands one step brighter than parity, which is
where dark UIs have to sit to read as crisply as a light one.
Two kinds of color reach a widget, and only one of them is resolved on the way
to rgb:
- Roles (
cx.theme().colors.text) and anything a function taking a&theme::Colorshands back (button::colors,tabs::active_border,tag::colors) are already in the active mode. Paint them as they come. - Component tokens that name a palette shade — a table's header fill, a
menu's hover row — are written for the light surface. Pass them through
cx.theme().shade(..)first.
Resolving twice is a bug: the second pass walks the mirror again and lands on
the wrong end of the ramp. Reach for a role whenever one says what you mean, and
for shade only where the role vocabulary has no step that fine.
A color an app hands a widget splits the same way, and the line is what the color lands on:
- A fill on a surface the design system owns —
TableState::set_row_color,set_cell_color— is re-anchored like any other fill. Name the shade the light surface wants and the widget resolves it, so one call is right in both modes. Where the fill then has to carry text,color::readable_onpicks the ink from the fill rather than from the surface, because an app may hand over a shade the mirror leaves alone. - A content color the app paints on a ground it chose —
Link::color,Icon::color,loading::Color::Custom— is left exactly as handed over. The design system does not know what is behind it and must not guess.
cargo run --example gallery carries the switch in its header, so every widget
can be reviewed in both appearances.
Fonts
The design system sets its type in Roboto and Space Mono, vendored under
designs/assets/fonts beside the glyphs. Theme::init reads them from the same
Resources directory everything else resolves from, so an app delivers no fonts
of its own and carries no font knowledge at all.
It returns a Result, and the failure is worth handling rather than dropping:
every widget names Roboto by family, so with nothing registered the text
system walks its fallback stack down to whatever the platform offers. That
renders — which is exactly why it survives review — but in a face nobody chose.
Both licences require their notice to travel with a copy shipped inside a
binary, so Roboto-LICENSE.txt and SpaceMono-OFL.txt sit beside the faces and
reach the bundle the same way.
Icons and illustrations
Glyphs are FontAwesome Pro and illustrations are fetched rather than
versioned, so both are vendored under designs/assets instead of pulled at
build time. A build therefore needs no token, no network and no cache, and
nothing can be missing that the kit carries.
Everything resolves from one place at runtime: the Resources directory beside
the running executable. A bundle reads Contents/Resources; a cargo run reads
target/Resources, which the dev shell materializes. An app configures no
directory, exports no variable and bakes in no table — it registers Assets and
its packaging step copies designs-gpui-assets into its bundle:
cp -R ${designsAssets.designs-gpui-assets}/. "$app/Contents/Resources/"
That derivation unpacks the glyph archive and lays the faces and illustrations
beside it, so nix does the work once and every consumer copies one directory.
A miss at runtime is an Err, not a blank square, because a miss means the
packaging step did not run rather than that an asset is optional.
Faces come the same way: Theme::init reads them from Resources, so an app
delivers no fonts of its own. Their licences travel beside them, which
Apache-2.0 and the OFL both require of a copy shipped inside a binary.
Checks
Same jobs as every Rust component, from the designs flake:
nix run .#designs-gpui-lint— fmt, clippy, deny, machetenix run .#designs-gpui-conformance— federated config drift checknix run .#designs-gpui-test— nextest behind the coverage ratchetnix develop .#gpui— the Rust dev shell