Previous Frame Guide
September 9, 2026 · View on GitHub
Why interactive widgets in SLT use last-frame layout data, when Response.rect
is zero vs. meaningful, and how to write animations, measurements, and focus
logic that stay correct on frame 1.
The setup
SLT is immediate mode. Your closure runs once per frame. Every call you make
(ui.text(...), ui.button(...), ui.row(|ui| ...)) records a Command
into a per-frame buffer. Layout runs after the closure returns, walks the
recorded tree, and computes positions and sizes. Rendering follows, and then
events are collected for the next frame.
That ordering has one consequence you must internalize:
When you call
ui.button("Save")on frame N, layout has not run yet for frame N. TheResponsereturned inside the closure uses frame N-1's layout — positions, sizes, and hit-test rectangles — to answer.clicked,.hovered, and.rect.
This is what the README calls "synchronous feedback". You get interaction results on the same line where you describe the widget, even though the rect you actually see returned was measured one frame ago.
Frame N vs. frame N+1 timeline
Frame N Frame N+1
------------------------------------- -------------------------------------
1. Events arrive (mouse, keys)
2. Context::new() swaps prev_*
maps in from last frame's layout
3. Your closure runs
- records commands
- reads prev_hit_map to decide
.clicked / .hovered / .focused
- Response.rect = last frame's rect
4. build_tree() walks commands
5. flexbox::compute() assigns rects
6. collect_all() produces FrameData:
hit_areas, focus_rects,
scroll_rects, group_rects, ...
7. render() draws the buffer diff
8. FrameData saved into FrameState
as prev_* for the NEXT frame 1. Events arrive
2. Context::new() pulls the
FrameData saved at the end of
frame N; the hit map and focus
rects now describe the tree the
user actually saw
3. Your closure runs again; this
time Response.rect is valid
because layout has run at least
once
The key variables live in src/context/core.rs (prev_hit_map,
prev_focus_count, _prev_focus_rects, prev_scroll_rects, ...) and get
populated at the end of each frame from collect_all() in
src/layout/collect.rs. Context::new() in src/context/runtime.rs reads
those same fields at the start of the next frame.
When Response.rect is meaningful
Response.rect is a plain Rect, not Option<Rect>. On the first frame
there is no previous layout, so you see the default — Rect { x: 0, y: 0, width: 0, height: 0 }. From frame 2 onward the value is the rect that
widget occupied during the previous frame's layout pass.
Use ui.tick() (the public helper on Context) to branch. The tick
counter is a u64 that starts at 0 and increments by one per rendered
frame.
slt::run(|ui: &mut slt::Context| {
let r = ui.button("Save");
if ui.tick() > 0 && r.rect.width > 0 {
// Safe to use the visible r.rect for anim targets
// or tooltip anchors. The rect reflects last frame's layout.
anim_target = r.rect;
}
});
Response.rect is clipped to ancestor viewports: it can be zero even after
many frames when the widget is offscreen. Do not reconstruct document positions
by adding a scroll offset to this rectangle. In v0.25, use
ui.focused_layout_rect() or ui.measured_layout_rect(group_name) for a full
border box before scrolling/clipping. These are previous-frame root/overlay
logical coordinates, including border and padding, not local scroll coordinates.
They return None before measurement or after resize. form_field_response
also exposes the full field rectangle as optional layout_rect.
Focus-following scroll corrections render with the completed layout and update
the bound ScrollState on its next use. Application offset edits between those
frames override the pending state update. No UI closure is executed twice.
Equivalent guards that also work:
if r.rect.area() > 0 { /* rect has real dimensions */ }
if !r.rect.is_empty() { /* same idea */ }
Prefer the tick() > 0 test when the rect could legitimately be zero-sized
(an empty column, a hidden tab). Prefer the area check when you want to
skip the widget regardless of why the rect is empty.
Common pitfalls
Pitfall 1: first-frame flicker from premature measuring
// BROKEN: on frame 0 this reads zero, so you get a one-frame flash
let r = ui.container().col(|ui| { ui.text(long_label); });
let inset = r.rect.width / 2;
ui.text(format!("centered under width {inset}"));
Fix: branch on tick(), or smooth with a use_state cache that only
updates when width is non-zero.
let cached = ui.use_state(|| 0u32);
let r = ui.container().col(|ui| { ui.text(long_label); });
if r.rect.width > 0 {
*cached.get_mut(ui) = r.rect.width;
}
let inset = *cached.get(ui) / 2;
Pitfall 2: focus lost because the hit map was empty
A widget added on frame 1 has no entry in prev_hit_map, so a click that
lands on it during frame 1 is ignored — prev_focus_rects is still empty.
On the first frame, ui.register_focusable() matches only the requested slot
(0 by default), rather than granting focus to every new widget. Call
ui.set_focus_index(desired) before declaring the widgets for deterministic
startup selection instead of relying on mouse hit-testing. Responses already
returned before a later programmatic focus request keep their original flags;
the next frame reflects that request and reports its gain/loss edges.
Pitfall 3: animating toward a previous-frame rect
Tween, Spring, Keyframes, Sequence, and Stagger from
src/anim.rs all take the current tick via value(ui.tick()). When the
animation target is another widget's rect, thread the previous-frame rect
through use_state so the animation keeps a stable target across
re-layouts:
let target = ui.use_state(|| Rect::default());
let row = ui.row(|ui| { ui.text("follow me"); });
if row.rect.width > 0 {
*target.get_mut(ui) = row.rect;
}
let t = tween.value(ui.tick());
let dest = *target.get(ui);
let x = (dest.x as f64) * t;
// ... place a marker at x using ui.draw_with or absolute positioning
See examples/anim.rs for the canonical pattern: start the animation with
tween.reset(ui.tick()), read the current value with
tween.value(ui.tick()), and always use ui.tick() — never a local
frame counter that may drift under run_inline or frame()-based
backends.
Design rationale
Immediate-mode UIs face a chicken-and-egg problem: to tell you whether a widget was clicked, the framework has to know the widget's rect, but the rect only exists after you've finished describing the tree. Two options exist. A two-pass approach would run your closure once to measure and once to commit — double the work every frame. The one-pass approach SLT takes instead answers interaction questions using the previous frame's layout. Typical TUI apps run at 30–60 FPS with sub-cell sized widgets, so a one-frame lag on hit-testing is imperceptible to a human and buys you one closure execution per frame instead of two.
The tradeoff pays off again during scrolling and resizing. When the layout changes between frames, the hit map the user aimed at is the hit map from the frame they saw on screen, which is exactly the previous frame's map. Using the in-flight frame's rects would mean clicks could miss a button that had moved under the cursor between input capture and rendering. Borrowing egui's phrasing: "the rectangle they clicked is the one they saw, not the one we're about to draw."
See also
docs/AI_GUIDE.md— general rules for AI agents working with SLTdocs/PATTERNS.md— idiomatic immediate-mode patternsdocs/ANIMATION.md— full reference forTween,Spring,Keyframesexamples/anim.rs— all five animation primitives driven byui.tick()src/context/runtime.rs— wheretick,prev_hit_map, and focus state are wired up at the start of each framesrc/layout/collect.rs—collect_all()DFS that produces theFrameDatasnapshot saved for the next frame