Back to Raylib Rs

Public: How do other engines / immediate-mode GUI libraries balance string ergonomics vs. per-frame C-string allocation?

docs/research/2026-05-26-A-raygui-string-ergonomics/public.md

6.0.06.2 KB
Original Source

Public: How do other engines / immediate-mode GUI libraries balance string ergonomics vs. per-frame C-string allocation?

Context: raygui (C) takes const char* (null-terminated). The safe binding must turn a Rust string into a null-terminated pointer every frame, for every control. The 6.0-rc safe.rs currently does CString::new(text).unwrap() per call (fresh heap alloc + panic on interior NUL). CLAUDE.md's stated convention is &CStr + the rstr! macro (zero-alloc but macro-bound). Which is the right balance?

Findings

Dear ImGui (C++) — the reference immediate-mode design. Takes const char* (null-terminated). The full UI is rebuilt every frame, so string params are hot. C++ callers typically pass literals or printf-style formatting into an internal/stack buffer (ImGui::Text("%d", x)), avoiding heap churn. There's a long-standing proposal to accept length-delimited ImStr/string_view so callers needn't null-terminate (ocornut/imgui #494), but null-terminated const char* remains the default. Lesson: the C layer requires null termination; the binding owns the conversion cost.

imgui-rs (Rust binding — the closest analog to raylib-rs + raygui). This is the most directly relevant precedent, and it iterated away from exactly the rstr!-style approach raylib-rs documents:

  • v0.8 removed ImStr/ImString; v0.9 removed the im_str! macro — the direct analog of raylib-rs's rstr! (CHANGELOG, v0.8.0 release).
  • All widget functions now take impl AsRef<str>. Migration guide: im_str!("button")"button"; &im_str!("age {}", 100)format!("age {}", 100).
  • Null-termination is handled by a single reusable scratch buffer held on the Ui context (UiBuffer { buffer: Vec<u8>, max_len }). The scratch_txt helper appends the string bytes + a \0 and returns a pointer into that buffer; the buffer is cleared only when it grows past max_len. So it is amortized zero-allocation in steady state while keeping a fully ergonomic &str/String/format! API (string.rs source). Variants like scratch_txt_two place multiple live strings at different offsets in the one buffer for multi-string calls.
  • Their own design issue captured the tension explicitly: "I'm not yet sure if avoiding dynamic allocation is an unnecessary micro-optimization or an important design goal" (issue #7). They resolved it with the scratch buffer — keeping ergonomics and avoiding per-call heap allocation.

egui (pure-Rust immediate mode). Takes impl Into<String> / impl Into<WidgetText> and allocates freely (owns Strings). Philosophy: a frame is ~1–2 ms and that's fine; if you are allocation-bound, swap in a custom allocator (mimalloc/talc) for ~20% — they don't contort the API to avoid allocation (egui README, docs.rs/egui). Lesson: for typical UIs, per-frame string allocation is not actually a measured bottleneck.

Unreal Engine (C++, retained Slate UI). Three semantic string types (Epic docs): FString (mutable, dynamic, heap), FName (immutable, interned/hashed — one stored copy per unique string, for identifiers), and FText (localized, user-facing UI text). UI uses FText. Because Slate is retained-mode, per-frame allocation isn't the axis; the transferable lessons are (a) interning repeated identifiers (FName) and (b) a dedicated user-facing text type. The interning idea maps loosely to caching the C-string for repeated literal labels.

Synthesis for raygui

The closest precedent (imgui-rs) deliberately abandoned the rstr!-equivalent macro in favor of impl AsRef<str> backed by a reusable per-frame scratch buffer, achieving both ergonomics and amortized zero per-frame heap allocation. egui shows that even naive per-frame allocation is usually not a real bottleneck. Unreal contributes the interning idea for repeated identifiers.

That yields three viable conventions for the safe raygui layer, in rough order of ergonomics-vs-allocation balance:

  1. impl AsRef<str> + reusable scratch buffer (imgui-rs model). Ergonomic ("x", format!(...), String), amortized zero-alloc, no NUL panic. Needs buffer infra (scratch_txt / _opt / _two) and a place to hold the buffer (thread-local fits raylib's single-threaded !Send draw model, or on the handle since gui methods take &mut self). Optional/nullable text → Option<impl AsRef<str>>.
  2. Option<&CStr> + rstr! (current canonical raylib-rs / showcase). Zero-alloc for literals, but macro-bound and awkward for dynamic strings.
  3. &str + per-call CString::new().unwrap() (current 6.0-rc). Simplest/ergonomic but per-frame heap alloc + NUL panic; contradicts CLAUDE.md.

Sources