Skip to main content

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​

You don't need to know wgpu to be useful here.

If you know…Good places to start
Rust, no graphicsWidgets, layout behaviour, frus-core types, tests, examples, API ergonomics
Another UI toolkitPort 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 / graphicsfrus-gpu: batching, the glyph atlas, path tessellation, shader work, the compositor
MobileAndroid shell hardening; iOS shell is unclaimed and self-contained
Webwasm target: clipboard, accessibility, live-reload are all unwired
AccessibilityAccessKit coverage across widgets; semantics tree correctness
i18nRTL edge cases, Fluent bundles, locale negotiation
Docs / writingEnglish 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-version in the root Cargo.toml and 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
  • rustfmt and clippy components: rustup component add rustfmt clippy

Per-platform extras:

TargetNeeds
Linux desktoplibxkbcommon-dev, libwayland-dev (or X11 headers), plus Vulkan drivers (mesa-vulkan-drivers)
AndroidAndroid SDK + NDK, ANDROID_HOME / ANDROID_NDK_ROOT, cargo install cargo-apk, rustup target add aarch64-linux-android
Webrustup 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:

  1. 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.
  2. Cross-platform. A change is not done if it only works on your OS. If it can't be, gate it and say so.
  3. 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.
  4. 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.png next to the reference so you can diff them. Those files are gitignored.
  • Goldens depend on the rasterizer; text antialiasing varies across drivers. assert_golden_with takes 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 --all before committing. The tree is rustfmt-clean and CI enforces it (milestone 298).
  • cargo clippy --workspace --all-targets must 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.
  • unsafe requires 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-widgets needs 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:

  1. Fork, then branch from master: git checkout -b feat/my-thing.
  2. Make the change. Add tests. Update or add docs.
  3. Verify locally:
    cargo test --workspace
    cargo clippy --workspace --all-targets
    cargo fmt --all -- --check # on your files
  4. Open the PR against master and fill in the template. Say what you changed, why, how you tested it, and which platforms you actually ran it on.
  5. 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 SingleChildScrollView is 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 does flex in a column. Beside other children in a column, give it flex so 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_rect resolves 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 (see scripts/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​

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.