Contributing to frus
Thanks for looking. frus is early — the core is solid but the surface is wide open, so a single well-scoped PR can genuinely shape a subsystem. This document tells you how to build it, what a good change looks like here, and how to get it merged.
Language. The repository is English — code, comments, documentation and commit messages. Issues and discussions are welcome in English or French. The one deliberate exception is
README.fr.md, the French translation of the README.
Table of contents
- Ways to help
- Setting up
- Running things
- The workflow
- What we expect from a change
- Testing
- Code style
- Design principles
- Commits and pull requests
- Platform notes and known gotchas
- Getting help
Ways to help
You don't need to know wgpu to be useful here.
| If you know… | Good places to start |
|---|---|
| Rust, no graphics | Widgets, layout behaviour, frus-core types, tests, examples, API ergonomics |
| Another UI toolkit | Port a widget or interaction pattern we're missing. Tell us where our API is worse than the one you know — that's a valid issue |
| GPU / graphics | frus-gpu: batching, the glyph atlas, path tessellation, shader work, the compositor |
| Mobile | Android shell hardening; iOS shell is unclaimed and self-contained |
| Web | wasm target: clipboard, accessibility, live-reload are all unwired |
| Accessibility | AccessKit coverage across widgets; semantics tree correctness |
| i18n | RTL edge cases, Fluent bundles, locale negotiation |
| Docs / writing | English documentation, docs/ translation, tutorials, rustdoc on public APIs |
Browse good first issues and help wanted, or read ROADMAP.md for the larger open areas.
Nothing is claimed until someone says so. Comment on the issue before you start, so two people don't build the same thing.
Setting up
Required:
- Rust 1.88 or newer to build — the minimum supported version, pinned as
rust-versionin the rootCargo.tomland checked in CI. Work on current stable (rustup update stable) all the same: the lints CI enforces are stable's, and a newer compiler finds warnings an older one does not. - A GPU with Vulkan, Metal, or DX12 drivers — frus renders through
wgpu, there is no software fallback for the demos rustfmtandclippycomponents:rustup component add rustfmt clippy
Per-platform extras:
| Target | Needs |
|---|---|
| Linux desktop | libxkbcommon-dev, libwayland-dev (or X11 headers), plus Vulkan drivers (mesa-vulkan-drivers) |
| Android | Android SDK + NDK, ANDROID_HOME / ANDROID_NDK_ROOT, cargo install cargo-apk, rustup target add aarch64-linux-android |
| Web | rustup target add wasm32-unknown-unknown, cargo install wasm-bindgen-cli, a WebGPU browser (Chrome/Edge 113+) |
git clone https://github.com/frus-rs/frus
cd frus
cargo test --workspace
If that's green, you're set.
Running things
cargo run -p frus-hello # minimal counter — read this first
cargo run -p frus-demo # broad app: todos, kanban, tables, pickers
cargo run -p frus-transforms # animation / transform showcase
cargo run -p frus-fetch-example # async HTTP + RemoteData states
Android:
cargo apk run -p frus-demo --lib
Web: see crates/frus-hello/web/README.md.
Logs go to the console with nothing to set up: the terminal on desktop, logcat on Android
(adb logcat -s frus_demo, the application's crate name), the browser's console on the web.
Everything from info up is shown, the application's own crate from debug up in a debug
build, and the graphics and windowing crates from warn up. On desktop, RUST_LOG decides over
that default:
RUST_LOG=frus_shell=debug,frus_gpu=info cargo run -p frus-demo
How smooth the frames are, and where a slow one went, is measured by the shell itself when
asked: FRUS_FRAME_STATS=1 on desktop, adb shell setprop debug.frus.frames 1 before starting
the application on Android. A line goes to the log every two seconds of drawing (see
crates/frus-shell/src/frame_stats.rs).
How long the application took to show its first frame is logged at every launch, with
nothing to turn on: one line, from the process's start to the first frame presented, broken
into the window, the GPU (adapter, device, surface, pipelines, warm-up), the fonts, the first
build, layout and presentation (crates/frus-shell/src/startup.rs). For a number rather than
one launch's luck, scripts/first-frame.sh launches a built program several times — each
launch ends at its first frame (FRUS_EXIT_AFTER_FIRST_FRAME=1) — and prints the fewest, the
median and the most milliseconds of each stage:
cargo build --release -p frus-hello
bash scripts/first-frame.sh -n 10 target/release/frus-hello
The workflow
frus is built in milestones. Each one is a single coherent step: a feature, its tests, and a design note in docs/milestone-N.md (index). There are 277 of them, and together they are the project's real memory — they record not just what was built but which alternatives were rejected and why.
You do not need to write a milestone note for a bug fix, a test, or a small ergonomic improvement. You do for anything that adds public API, changes a subsystem's shape, or makes a trade-off a future reader would question.
A milestone note follows the shape the existing ones use:
# Milestone N — <title>
## Goal
Why this step, what problem it solves, why it comes now.
## API (or: The type / Architecture)
The public surface, with code.
## Technical decisions
Alternatives considered, their trade-offs, and why this one won.
## Implementation
What changed, crate by crate.
## Verification
How it was tested. What's green.
## What's left
What this deliberately leaves for later.
Pick the next free number (ls docs/milestone-*.md | sort -V | tail -1). If your PR is a milestone, say so in the description and the maintainers will confirm the number.
What we expect from a change
Four things, in rough order of importance:
- Tests. No module lands without them. If it's logic, it's a unit test that runs without a GPU. If it's rendering, it's a golden.
- Cross-platform. A change is not done if it only works on your OS. If it can't be, gate it and say so.
- Overridable styling. Every visual property a widget uses must be settable by the app. Themed defaults are fine; hardcoded-only values are not — this is a hard rule, see Design principles.
- Documentation. Public items get rustdoc. Non-obvious decisions get a comment explaining the why, not the what.
Changes that will be sent back: new unsafe without justification, a widget with baked-in colors or sizes, a public API added without a doc comment, platform-specific code outside frus-shell, and anything that lands untested.
Testing
cargo test --workspace # everything
cargo test -p frus-widgets # one crate
cargo test -p frus-widgets scroll # one area
cargo test --workspace --features net,json # feature-gated code
Two kinds of tests.
Logic tests need no GPU and no window — this is the payoff of the pure-update architecture, and most of the suite is here. Prefer them:
#[test]
fn counting_is_pure() {
let mut app = Counter::default();
app.update(Msg::Increment);
app.update(Msg::Decrement);
assert_eq!(app.count, 0);
}
Golden tests render offscreen through frus-test and compare against a reference PNG in crates/frus-test/tests/goldens/:
snapshot.assert_golden(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/goldens/card.png"));
- A missing golden is created on first run — inspect it by eye, then commit it. Never commit a golden you haven't looked at.
- A failing golden writes
<name>.actual.pngnext to the reference so you can diff them. Those files are gitignored. - Goldens depend on the rasterizer; text antialiasing varies across drivers.
assert_golden_withtakes a channel tolerance and a pixel budget for that reason. If a golden fails only on your machine, say so in the PR rather than loosening it silently.
Run the goldens yourself when you change what pixels come out. cargo test --workspace includes them, but the headless CI job does not — only the GPU job does, on a runner whose rasteriser is now pinned to the exact mesa version this repository blesses under, so a golden that is wrong fails CI rather than merging quietly. That was not always so: this job used to be advisory for want of a pinned rasteriser, and the gap once let a deliberate change to text metrics leave 47 goldens red for five milestones with nobody looking (milestone 294). Waiting for CI to tell you is still slower than looking yourself, so before pushing, if your change touches text measurement, layout, painting or the renderer:
cargo test -p frus-test --test goldens --test widgets
Both files are pixel tests: goldens.rs holds the screens — tables, charts, forms,
pickers — and widgets.rs holds one image per group of widgets, which is what says
whether a change to painting moved a checkbox, an icon or a drawer (milestone 296).
If they change, look at the .actual.png files before deciding. A shift of a pixel or two on every glyph is a text-metric change and probably intended; a shape that has moved, vanished or changed colour is not. Accept them with FRUS_UPDATE_GOLDENS=1 only once you have looked, and say in the commit message what moved and why.
Benchmarks
crates/frus-bench measures what a frame costs on the CPU: build_ui, text
measurement and wrapping, batch planning. Shared fixtures live in its src/lib.rs, so
add screens there rather than inventing one per bench.
cargo bench -p frus-bench
cargo bench -p frus-bench --bench scene
Absolute numbers are your machine's; the ratios are the framework's. If you claim a
change made something faster, say which bench and by how much — that is what the crate
is for (milestone 299). CI runs cargo bench -p frus-bench -- --test, one iteration
each, so they cannot quietly stop compiling.
Code style
cargo fmt --allbefore committing. The tree is rustfmt-clean and CI enforces it (milestone 298).cargo clippy --workspace --all-targetsmust be silent — CI runs it with-D warnings(milestone 298). Where a lint is wrong for the code, write the#[allow]at the site with the reason beside it; don't widen it to a module or a crate.unsaferequires a justification comment. The framework core is safe Rust; the only legitimate uses are FFI at the platform edge.- Comments explain why. The code already says what.
- Public items get a
///doc comment. Widgets should show a usage snippet. - Keep the crate boundaries honest: if
frus-widgetsneeds to know about the platform, the design is wrong.
Design principles
These are the standing rules the codebase is built on. A PR that violates one needs a very good argument.
1. Everything in Rust. No framework logic in another language, ever. The only non-Rust code allowed is a paper-thin native adapter (Kotlin/Swift/Win32/JS) that exposes a platform API and contains no logic of its own.
2. Learn from mature toolkits, don't copy them. Spring physics, the widget/element split, gesture arenas, lifecycle contracts — these are solved problems, and the solutions are worth studying. Port the shape of what works. But where a prior design has a weakness, fix it rather than inheriting it. Every departure should be defensible.
3. Customizable to the last detail. Every widget's styling and every slot must be overridable by the app. Themed defaults, yes. Hardcoded-only, never. If you find yourself writing a literal color or dimension inside a widget's paint path, it belongs in the theme or in a builder method.
4. Pure update. State transitions are a pure function of state and message. Side effects go through Command; external events come in through Subscription. This is what makes the framework testable without a GPU, and it is not negotiable.
5. Dependencies point downward. core → layout/text/gpu → widgets → shell → app. Nothing below shell may know what platform it's running on.
6. cargo-native. No bespoke CLI, no code generation step, no project format. If a feature needs a tool, it should be an existing one.
7. Small, sharp dependencies. We lean on best-in-class Rust crates (taffy, cosmic-text, wgpu, lyon, fluent) rather than reinventing them — and write everything else ourselves. Adding a dependency needs a reason.
8. Nothing lands untested. See above.
Commits and pull requests
Commit messages follow Conventional Commits, and milestone commits name their milestone:
feat: Milestone 277 — <what the milestone delivers>
fix: Scroll no longer clips its last child when height is Auto
test: golden coverage for DataTable sort indicators
docs: translate milestone-101 to English
refactor: extract glyph atlas eviction into its own module
perf: batch path fills sharing a paint
Types: feat, fix, perf, refactor, test, docs, chore.
Pull requests:
- Fork, then branch from
master:git checkout -b feat/my-thing. - Make the change. Add tests. Update or add docs.
- Verify locally:
cargo test --workspacecargo clippy --workspace --all-targetscargo fmt --all -- --check # on your files
- Open the PR against
masterand fill in the template. Say what you changed, why, how you tested it, and which platforms you actually ran it on. - Screenshots or a short clip for anything visual. Before/after if you changed existing rendering.
Keep PRs focused. One subsystem, one concern. A large PR that mixes a refactor with a feature will be asked to split.
Reviews aim to be substantive rather than fast — expect questions about trade-offs, not nitpicks about formatting.
Platform notes and known gotchas
Things that have cost previous contributors real time:
- Scroll sizing. An unsized
SingleChildScrollViewis as big as its content, up to the room on offer, and takes all the room across its axis. It can only scroll when something bounds it: a page's body does, and so doesflexin a column. Beside other children in a column, give itflexso that it scrolls within what they leave. - RTL text. If a text buffer is wider than the text itself, cosmic-text right-aligns into the empty space and the glyphs land off-screen. Size the buffer to the text.
- Interactive widgets and hit-testing.
Ui::widget_rectresolves rects through per-kind registries. A new interactive widget that isn't wired into a registry will pass its unit tests and still silently no-op on live drag or hover paths. - Android fonts. Fonts are bundled, not taken from the system — an Android-only text bug is often a font-loading problem, not a shaping one.
- Windows + Smart App Control. With SAC in Enforce mode, freshly built test executables are intermittently blocked (
os error 4551). Compilation succeeds, running fails. Retry, run under WSL2 (seescripts/wsl-run.sh), or turn SAC off. - WSL2 display. Force the X11 backend (
WINIT_UNIX_BACKEND=x11); WSLg's Wayland connection is unstable under root.
Found a new one? Add it here in your PR.
How decisions get made
frus is small and maintainer-led today. That means:
- Maintainers merge. Every PR needs an approving review from a maintainer. Currently that is @KalybosPro.
- Architecture changes go through discussion first. For anything marked 🔴 in ROADMAP.md, or anything that changes a crate boundary or a public trait, open an issue or discussion before writing code. You'll get a faster answer and avoid rewriting.
- Disagreements are settled by argument, not seniority. The design principles are the shared ground. If you think one of them is wrong for a particular case, say so — several existing milestones exist because someone pushed back.
- "Framework X does it this way" starts a discussion, it doesn't end one. We port what works and fix what doesn't.
- Sustained, high-quality contribution in an area earns review rights in it. There's no formal process yet; if the project grows to need one, it will be written down here.
Issue labels you'll see: good first issue, help wanted, needs triage, needs design, bug, enhancement, plus area labels (area: widgets, area: gpu, area: shell, area: web, area: android, area: docs).
Getting help
- Questions and ideas → Discussions
- Bugs and feature requests → Issues
- Security → do not open a public issue, see SECURITY.md
Before asking, docs/milestone-*.md very often already answers "why is it like this?" — grep them.
By contributing you agree that your work is dual-licensed under MIT and Apache-2.0, and to abide by the Code of Conduct.