SLT API Design Rules
April 28, 2026 · View on GitHub
These are the consistency rules for adding or changing public API in SLT.
Read this before opening any PR that touches Context::*, widget signatures, or *Response types.
Sister docs:
- DESIGN_PRINCIPLES.md — high-level philosophy
- WIDGETS.md — current widget catalog
- PATTERNS.md — composition patterns
1. Why this document exists
In v0.20 we added gauge, line_gauge, breadcrumb, and scrollable_with_gutter in roughly the same week,
each through a different agent. The shapes drifted apart, even though the widgets are conceptually the same kind of thing:
// v0.20 reality — four widgets, four signature shapes
ui.gauge(0.6, "60%"); // f32, two positionals
ui.gauge_w(0.6, "60%", 48); // f32, three positionals + suffix variant
ui.gauge_colored(0.6, "60%", 48, Color::Green); // f32, four positionals + another variant
ui.line_gauge(0.6, LineGaugeOpts::default().label("…")); // f32, opts struct (different from above)
ui.breadcrumb(&["a", "b", "c"]); // slice, returns BreadcrumbResponse
ui.scrollable_with_gutter(&mut state, total, vp, gf, f); // five positionals, no opts struct
When the v0.20 showcase example was first generated by an AI coder, it guessed wrong on every single one of these widgets. The mistakes were not about logic — they were about which of four plausible shapes the API actually used. We then audited internal mistakes and external bug reports across v0.18–v0.20: roughly 70% of "API guessed wrong" errors traced to inconsistent design between sibling widgets, not to actual API complexity.
The lesson: consistent APIs reduce both human and AI cognitive load. A consistent surface lets readers guess correctly on first read; an inconsistent one forces a doc lookup at every call site. The five rules below codify the consistency we want going forward.
2. The Five Rules
Rule 1: Builder pattern for widgets with optional fields
// Method on Context returns a builder — required args only
impl Context {
pub fn gauge(&mut self, ratio: f64) -> Gauge<'_> { ... }
}
// Builder methods are chainable, take &mut self -> &mut Self
impl<'a> Gauge<'a> {
pub fn label(&mut self, s: impl Into<String>) -> &mut Self { ... }
pub fn width(&mut self, w: u32) -> &mut Self { ... }
pub fn color(&mut self, c: Color) -> &mut Self { ... }
}
// Renders on Drop. Use .show() when you need the Response back.
impl Drop for Gauge<'_> { ... }
impl Gauge<'_> { pub fn show(self) -> GaugeResponse { ... } }
Why: matches egui / RataTUI / dioxus mental model. One entry point per widget concept,
optional configuration via chaining. Avoids gauge_w / gauge_colored / gauge_with_label proliferation
where every new optional dimension grows the public surface combinatorially.
Example (good):
ui.gauge(0.5).label("CPU").width(48).color(Color::Green);
let r = ui.gauge(0.7).label("Mem").show(); // explicit Response
Counter-example (do NOT do this):
ui.gauge_w(0.5, "CPU", 48); // suffix-encoded variant
ui.gauge_colored(0.5, "CPU", Color::Green, 48); // diverging arg order
ui.gauge_label_color(0.5, "CPU", Color::Green); // n^2 explosion as opts grow
Rule 2: Floats are f64 everywhere
pub fn gauge(&mut self, ratio: f64) -> Gauge<'_>;
pub fn slider(&mut self, label: &str, value: &mut f64, range: RangeInclusive<f64>) -> Response;
pub fn progress(&mut self, ratio: f64) -> Progress<'_>;
pub fn chart(&mut self) -> ChartBuilder<'_>; // datasets are [(f64, f64)]
Why:
- Avoids precision-loss cliffs at API boundaries (caller has
f64, API takesf32, silent narrowing). - Matches Rust's default literal type —
0.5isf64, soui.gauge(0.5)just works without0.5_f32. - Consistent with
Duration::as_secs_f64,Instant::elapsed, and the rest ofstd.
Example (good):
let cpu: f64 = sys.cpu_usage();
ui.gauge(cpu / 100.0).label(format!("{cpu:.1}%"));
Counter-example (do NOT do this):
pub fn gauge(&mut self, ratio: f32, label: &str) -> GaugeResponse;
// ^^^ caller usually has f64, gets a silent `as f32` cast
ui.gauge(cpu as f32 / 100.0, &format!("{cpu:.1}%"));
Exception: explicit graphics math where f32 dominates (color blending in style/color.rs,
GPU-style normalized math) may use f32 internally. Public API still takes/returns f64 and converts at
the boundary — f32 does not appear in any pub fn on Context.
Rule 3: Options struct when public function takes 4+ args
// 1 arg — positional
pub fn gauge(&mut self, ratio: f64) -> Gauge<'_>;
// 2 args — positional
pub fn alert(&mut self, level: AlertLevel, body: &str) -> Response;
// 3 args — positional, OK
pub fn slider(&mut self, label: &str, value: &mut f64, range: RangeInclusive<f64>) -> Response;
// 4+ args — opts struct (or builder)
pub fn scrollable_with_gutter<G, F>(
&mut self,
state: &mut ScrollState,
opts: GutterOpts<G>,
body: F,
) -> GutterResponse;
Why: positional 5-tuples are unmemorable. Every reader (human or AI) has to look up which u32
is width vs height vs row count. Named fields on an opts struct are self-documenting and survive future
additions without breaking the signature.
Counter-example (the v0.20 mistake we are fixing):
// 5 positional args — caller has to remember argument order
pub fn scrollable_with_gutter<G, F>(
&mut self,
state: &mut ScrollState,
total: usize, // is this lines or pixels?
viewport: u32, // height? width?
gutter: G, // before or after body?
body: F,
) -> GutterResponse;
Fix:
pub struct GutterOpts<G> {
pub total_lines: usize,
pub viewport_height: u32,
pub gutter: G,
}
pub fn scrollable_with_gutter<G, F>(
&mut self,
state: &mut ScrollState,
opts: GutterOpts<G>,
body: F,
) -> GutterResponse;
The arity boundary is intentionally low: 3 positional args is the comfort threshold for most readers. If you hit 4, add an opts struct or convert the leaf args to a builder. Do not "just live with" 5 positionals.
Rule 4: Stateful widgets take &mut StateType, not &mut String / &mut bool
ui.text_input(&mut TextInputState); // not &mut String
ui.scrollable(&mut ScrollState, ...); // not &mut usize
ui.tabs(&mut TabsState, &[…]); // not &mut usize
ui.list(&mut ListState, &[…]);
ui.tree(&mut TreeState, …);
ui.calendar(&mut CalendarState);
Why: widgets need to remember more than the visible value. Cursor position, selection range,
scroll offset, drag origin, last-clicked index, in-progress IME composition — none of that is the caller's
business. A newtype wrapper hides it. If we take &mut String, we either lose the internal state
between frames or hide it in a side table keyed by interaction id (worse: spooky persistence).
Acceptable for trivial state: slider(value: &mut f64) is OK because the only thing to remember
is the value itself. The same applies to checkbox(value: &mut bool) and toggle(value: &mut bool).
The rule is: if the widget needs anything beyond the user-visible value, it needs a State newtype.
Counter-example:
// Caller has to manage cursor + selection manually = leaks internal state
pub fn text_input(&mut self, value: &mut String, cursor: &mut usize) -> Response;
Fix:
pub struct TextInputState { value: String, cursor: usize, selection: Option<Range<usize>>, ... }
pub fn text_input(&mut self, state: &mut TextInputState) -> Response;
Rule 5: Return types — Response for one-rect widgets, XxxResponse (Deref) for compound
// One rect, one set of interactions
pub fn button(&mut self, label: &str) -> Response;
pub fn checkbox(&mut self, label: &str, value: &mut bool) -> Response;
// Compound: extra data alongside the standard interaction set
#[must_use = "BreadcrumbResponse contains interaction state — check .clicked_segment, .hovered, or .rect"]
pub struct BreadcrumbResponse {
pub response: Response,
pub clicked_segment: Option<usize>,
}
impl Deref for BreadcrumbResponse {
type Target = Response;
fn deref(&self) -> &Response { &self.response }
}
Why: Response already covers clicked / hovered / changed / focused / rect. A compound widget
adds more (which segment clicked, which cell of a grid, current scroll highlight index). Returning a
struct that Derefs to Response lets callers write .hovered and .rect exactly like a simple widget,
while still exposing the extra fields. This is the same pattern React uses for components that return
both DOM ref and instance handle.
Example (good):
let r = ui.breadcrumb(&["Home", "Settings", "Profile"]);
if r.hovered { /* via Deref */ }
if let Some(idx) = r.clicked_segment { /* compound-specific */ }
Counter-example (do NOT do this):
// Returning raw Response loses the extra signal
pub fn breadcrumb(&mut self, segments: &[&str]) -> Response;
// caller now has to track which segment was clicked via mouse coordinates manually
// Returning a tuple loses naming and Deref ergonomics
pub fn breadcrumb(&mut self, segments: &[&str]) -> (Response, Option<usize>);
// .0 / .1 access, no `.hovered` shortcut, no `#[must_use]` enforcement
The #[must_use] attribute on every *Response is mandatory — it catches the common bug of dropping
a meaningful response without checking it.
3. v0.20 Retrospective
The rules above are not theoretical. They are direct corrections to mistakes shipped in v0.20.
| Mistake | Symptom | Rule | Fix |
|---|---|---|---|
gauge / gauge_w / gauge_colored family | Three positional variants for the same widget; AI guessed gauge_with_label instead | 1 | Single gauge(ratio).label().width().color() builder |
f32 for ratios in gauge, progress, line_gauge | Caller had f64 from cpu_usage(), inserted as f32 casts at every call site | 2 | All public floats are f64 |
scrollable_with_gutter 5-positional signature | Showcase example used wrong argument order on first attempt | 3 | GutterOpts struct |
HighlightRange::line vs ::span (originally ::single) | Two constructors with no obvious naming relationship | 1 + naming | Keep ::line (1-line) and ::span (n-line); document together |
Mixed breadcrumb / gauge return shapes | Some returned Response, some returned (Response, T), some returned new types | 5 | All compound widgets return XxxResponse: Deref<Response> |
These corrections land in the v0.20 API consistency commit (see git log --grep="api consistency" on
release/v0.20.0). v0.20 is the last release where the inconsistencies above will appear in the
public surface; v0.21+ enforces all five rules through the PR checklist below.
4. PR Reviewer Checklist
Copy-paste this checklist into any PR that adds or changes a public widget:
## API Design Checklist (docs/API_DESIGN.md)
- [ ] Floats are `f64` (no `f32` in public signature)
- [ ] Public function takes ≤ 3 positional args (otherwise opts struct or builder)
- [ ] Optional configuration uses builder pattern (chainable `&mut self -> &mut Self`)
- [ ] Stateful widget takes `&mut XxxState` newtype (not `&mut String` / `&mut Vec<…>`)
- [ ] Returns `Response` (or `XxxResponse: Deref<Target = Response>` for compound widgets)
- [ ] `*Response` struct has `#[must_use = "..."]` attribute
- [ ] Doctest shows idiomatic one-line happy path
- [ ] Naming matches existing widgets (`fn gauge` returns `Gauge<'_>`, not `GaugeBuilder`; opts struct is
`GaugeOpts`, not `GaugeConfig` or `GaugeParams`)
- [ ] No `_w` / `_colored` / `_with_label` suffix variants — fold into builder methods instead
If a check fails, fix the API before merging. The cost of an inconsistent shape compounds across every future caller, every future doc reader, and every future AI coder generating SLT code from examples.
5. References
- egui
Widgetpattern —add(impl Widget)returnsResponse, builders configure widgets beforeadd. Direct ancestor of SLT's builder shape. - ratatui
Widget/StatefulWidget— splits stateless from stateful widgets via two traits; SLT collapses both intoContext::*methods but inherits the state-newtype convention. - React component composition — compound components return both a primary handle and additional refs;
SLT's
XxxResponse: Deref<Response>is the closest type-level analog. - Anthropic API legibility guidance for AI coders — consistent surfaces are predicted correctly on first attempt; inconsistent surfaces require disambiguation passes against documentation. See the v0.20 retrospective above for concrete numbers.
- OpenAI / Cursor coding-agent benchmarks confirm the same trend: per-symbol guess accuracy is dominated by sibling-widget consistency, not by surface size.
6. When These Rules Conflict
If two rules conflict in a specific case, follow this priority:
- Rule 5 (Response shape) — never break the calling convention readers already know
- Rule 4 (state newtype) — encapsulation beats convenience
- Rule 2 (
f64) — precision-loss is silent and hard to debug - Rule 3 (opts struct at 4+ args) — readability beats keystroke savings
- Rule 1 (builder over suffix variants) — last because the cost is taste, not correctness
If an existing widget violates a rule for legacy reasons, deprecate-and-replace; do not add a new inconsistency to "match the existing one." The whole point of this document is that consistency is the load-bearing property — preserving a bad pattern multiplies its cost.