Hedronite · Backend Dev · Rust · Tue 2026-10-27

A frame is a function call testing an egui panel headless

Drive egui frames from cargo test with scripted input, read shapes instead of pixels, and see where immediate mode keeps state and where it does not.

Lesson Class: Dev (Rust · egui immediate-mode GUI)
Tested with: egui 0.36.2 · rustc 1.99.0 · debug build, no window, no GPU
Lab: crate/ · cargo test (6)
Source: egui 0.36.2 docs and source · epaint 0.36.2 TexturesDelta · egui README
Shapes, Not Pixels
One frame returns drawing commands a test can read.
One Source of Truth
Your struct holds the state; the next frame shows it.
Acknowledge the Atlas
Clear the texture delta or the debug build panics.
Describe the whole screen again; keep the truth in one place.

The problem

A Rust backend team runs a job queue service. Workers push jobs in and other workers pull them out. When something goes wrong, an engineer wants a small native operator panel with three parts. A line shows the queue depth. A "Drain one" button removes a single job. A collapsible details section shows how many jobs have been drained so far.

The panel is tiny, but proving it works is awkward. A native GUI normally needs a window, and a window needs a display server and usually a graphics card. CI runners have neither.

The team has three options, and each is poor:

None of these checks what matters: given this state and this click, the panel shows the right text and changes the right number. The team wants a plain cargo test that feeds the panel scripted input and checks both what it shows and what it changed, on any machine. egui, a Rust GUI library, makes that possible.

The idea in plain words

egui is an immediate mode GUI library. Immediate mode means you describe the whole interface again, from scratch, every time the screen is updated, by calling ordinary functions such as ui.label(...) and ui.button(...). One such pass, taking input and producing drawing instructions, is called a frame. The library does not keep a tree of button objects alive between frames. Retained mode is the opposite family: the toolkit keeps a persistent tree of widget objects (the browser DOM is one) and you mutate that tree when state changes. Because an immediate mode frame is a function call that takes data and returns data, a test can call it directly, pass it fake input, and inspect the result. The window, the GPU and the event loop are the job of a separate integration layer, and a test simply leaves that layer out.

Core concepts

The experiment is a small crate with one dependency. Its Cargo.toml is:

[package]
name = "egui-headless-lab"
version = "0.1.0"
edition = "2021"

[dependencies]
egui = "0.36"

The version matters. Everything here was compiled against egui 0.36.2, and the egui API changes between releases. The egui crate alone is enough, with no eframe (the crate that opens a window and runs the event loop). The library file holds the panel and a few helpers for building input:

//! A tiny ops panel drawn with egui, driven headless (no window, no GPU).
use egui::{CentralPanel, Event, Ui, PointerButton, Pos2, RawInput, Rect, Vec2};

/// App state lives here, in plain Rust. egui keeps no widget tree for it.
#[derive(Default)]
pub struct Panel {
    pub queue_depth: u32,
    pub drained: u32,
    pub last_button_rect: Option<Rect>,
}

impl Panel {
    /// One frame: describe the whole UI from current state.
    pub fn ui(&mut self, root: &mut Ui) {
        CentralPanel::default_margins().show(root, |ui| {
            ui.heading("jobs");
            ui.label(format!("queue depth: {}", self.queue_depth));
            let resp = ui.add_enabled(self.queue_depth > 0, egui::Button::new("Drain one"));
            self.last_button_rect = Some(resp.rect);
            if resp.clicked() {
                self.queue_depth -= 1;
                self.drained += 1;
            }
            ui.collapsing("details", |ui| {
                ui.label(format!("drained so far: {}", self.drained));
            });
        });
    }
}

pub fn screen() -> RawInput {
    RawInput {
        screen_rect: Some(Rect::from_min_size(Pos2::ZERO, Vec2::new(400.0, 300.0))),
        ..Default::default()
    }
}

pub fn with_events(events: Vec<Event>) -> RawInput {
    RawInput { events, ..screen() }
}

pub fn press(pos: Pos2, pressed: bool) -> Event {
    Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Default::default() }
}

/// Run one frame and acknowledge the texture delta, as a real integration
/// would after uploading the font atlas to the GPU. Returns the output.
pub fn frame(ctx: &egui::Context, input: RawInput, p: &mut Panel) -> egui::FullOutput {
    let mut out = ctx.run_ui(input, |ui| p.ui(ui));
    out.textures_delta.clear();
    out
}

The Panel struct holds all application state: the queue depth, the drained count, and the rectangle where the button was last drawn. The ui method is the entire interface. It reads the struct, emits widgets, and writes back to the struct when the button reports a click. A Ui is a region of the screen that you add widgets to, and the closure passed to each panel receives one as &mut Ui. The helper screen() builds a RawInput for a 400 by 300 point screen, with_events adds scripted events, press builds a primary-button press or release, and frame runs one frame.

The tests below are all in one file, tests/frames.rs, and are shown one per concept.

(a) A frame is a function call that returns shapes, not pixels

A Context is egui's central object. It owns everything that must survive between frames: fonts, input history, and a small memory of per-widget state. A RawInput describes what the outside world did since the previous frame: the screen size and a list of events such as "pointer moved" or "button pressed". Context::run_ui(RawInput, |ui| ...) runs one frame and returns a FullOutput. That value holds a list of Shape values, which are drawing commands such as "this text at this position" or "a rectangle here". It also holds a texture delta and per-viewport output, covered below.

The egui documentation puts it this way, in the section "Understanding immediate mode":

There is no button being created and stored somewhere. The only output of this call is some colored shapes, and a [Response].

A Response is the value every widget call returns. It reports what happened to that widget during this frame, such as clicked or hovered, and it carries the widget's rectangle on screen.

This is why headless testing works. Turning shapes into pixels is the job of a graphics backend, and the frame stops before that step. A test can read the shapes directly. Two more terms appear in the first test. Tessellation converts shapes into triangle meshes (lists of vertices) that a GPU can draw, and Context::tessellate does this on the CPU. A texture atlas is one large image holding the pictures of every font glyph; a backend uploads it to the GPU once and then refers to glyphs by position inside it.

use egui::{Context, Event};
use egui_headless_lab::*;

#[test]
fn t1_one_frame_yields_shapes_not_pixels() {
    let ctx = Context::default();
    let mut p = Panel { queue_depth: 3, ..Default::default() };
    let mut out = ctx.run_ui(screen(), |ui| p.ui(ui));
    println!("t1 textures_delta.set={} (font atlas the backend must upload)", out.textures_delta.set.len());
    out.textures_delta.clear();
    let n_shapes = out.shapes.len();
    let prims = ctx.tessellate(out.shapes, out.pixels_per_point);
    let verts: usize = prims.iter().map(|cp| match &cp.primitive {
        egui::epaint::Primitive::Mesh(m) => m.vertices.len(),
        _ => 0,
    }).sum();
    println!("t1 shapes={} clipped_primitives={} vertices={}",
        n_shapes, prims.len(), verts);
    assert!(n_shapes > 0);
}

The test creates a fresh Context, runs one frame with a queue depth of 3, and prints the number of texture deltas. It then clears the delta (the reason is the pitfall below) and counts the shapes. It passes the shapes to tessellate together with pixels_per_point, the scale between egui's logical points and physical pixels. Finally it sums the vertices across all meshes. The assert! only checks that at least one shape exists. The numbers are printed so you can see the scale of the output.

test t1_one_frame_yields_shapes_not_pixels ... t1 textures_delta.set=1 (font atlas the backend must upload)
t1 shapes=7 clipped_primitives=1 vertices=178
ok

Seven shapes became one clipped primitive (a mesh plus the clip rectangle it draws inside) with 178 vertices. Four of the seven shapes are text, as the fourth test shows. The test does not inspect the other three. The set=1 shows that the very first frame carries the font atlas, which a real backend must upload to the GPU.

Common mistake: dropping the texture delta. The first run of all six tests panicked with this message:

Dropped TexturesDelta with 1 unapplied deltas. Deltas need to be handled. If you want to drop this intentionally call `clear` before dropping.

The message comes from epaint 0.36.2, src/textures.rs. There, impl Drop for TexturesDelta contains a debug_assert!(self.is_empty(), ...). A real backend must upload the delta's contents to the GPU, so egui complains when you drop one unhandled. A test has no GPU, so it must acknowledge the delta explicitly, which is why the frame helper calls out.textures_delta.clear(). The egui docs' own example calls full_output.drop_without_applying_deltas() for the same purpose. Because the check is a debug_assert!, a release build compiles it out and would not panic. That last point comes from reading the source; the experiment ran only in debug.

A second trap affects readers who follow older tutorials. Earlier egui versions took CentralPanel::default().show(ctx, ...). In 0.36.2 the panel needs a &mut Ui, and CentralPanel::default_margins().show(ui, ...) is the form that compiles. The old form fails with error[E0308] and the text "expected mutable reference &mut Ui found reference &egui::Context".

(b) Your state lives in your struct

The documentation states the rule directly: "egui does not store the slider value for you - it only displays the current value ... This means it is responsibility of the egui user to store the state (value) so that it persists between frames."

The consequence is that the screen is a pure function of your struct. If something outside the UI changes the struct, such as a worker thread pushing jobs, the next frame shows the change. No signal is emitted, no invalidation call is made, and no widget has to be found and updated. The tradeoff is that you must run a frame to see the change.

#[test]
fn t4_state_change_outside_ui_shows_next_frame() {
    let ctx = Context::default();
    let mut p = Panel { queue_depth: 1, ..Default::default() };
    let a = frame(&ctx, screen(), &mut p).shapes.len();
    p.queue_depth = 500; // a worker pushed jobs; no signal, no invalidation call
    let out = frame(&ctx, screen(), &mut p);
    let texts: Vec<String> = out.shapes.iter().filter_map(|s| match &s.shape {
        egui::Shape::Text(t) => Some(t.galley.job.text.clone()),
        _ => None,
    }).collect();
    println!("t4 shapes before={} after={} texts={:?}", a, out.shapes.len(), texts);
    assert!(texts.iter().any(|t| t.contains("500")));
}

The test runs one frame at depth 1, then assigns 500 directly to the struct field, which stands in for a background worker. It runs a second frame and collects the text of every Shape::Text in the output. A galley is egui's laid-out text, and its job.text holds the original string. The assertion checks that "500" appears. Nothing tells egui that the value changed, and the test never touches a label object, because there is none.

test t4_state_change_outside_ui_shows_next_frame ... t4 shapes before=7 after=7 texts=["jobs", "queue depth: 500", "Drain one", "details"]
ok

The shape count stays at 7 while the text changes to "queue depth: 500". The structure of the frame is identical and only the data differs.

Common mistake: keeping a second copy of state. Engineers used to retained toolkits sometimes cache a formatted string or a "last shown" value next to the real state. In immediate mode that copy goes stale, because the frame already recomputes everything from the struct. Keep one source of truth and format it inside the frame.

(c) Interaction arrives as events across frames

A click is not a single event. Windowing systems report a pointer press and a later pointer release, and egui reports a widget as clicked when it sees both in sequence on the same widget. The release arrives in a later frame than the press. A test that wants a click must therefore script at least two frames. Each frame carries its events in RawInput.events.

Scripting events through RawInput means the test exercises the same path as a real window. The widget code that decides what a click means is the production code, unchanged. A disabled widget is part of that path too: add_enabled(false, ...) draws the button greyed out and ignores input.

#[test]
fn t2_click_needs_press_and_release_across_frames() {
    let ctx = Context::default();
    let mut p = Panel { queue_depth: 2, ..Default::default() };
    let _ = frame(&ctx, screen(), &mut p);
    let c = p.last_button_rect.unwrap().center();
    println!("t2 button center = {:?}", c);
    let _ = frame(&ctx, with_events(vec![Event::PointerMoved(c), press(c, true)]), &mut p);
    println!("t2 after press: depth={} drained={}", p.queue_depth, p.drained);
    let _ = frame(&ctx, with_events(vec![press(c, false)]), &mut p);
    println!("t2 after release: depth={} drained={}", p.queue_depth, p.drained);
    assert_eq!(p.queue_depth, 1);
}

The first frame has no input. It exists so the layout runs and last_button_rect records where the button was drawn, which the panel stored from the widget's Response. The test then aims at the centre of that rectangle. The second frame moves the pointer there and presses. The third frame releases. After each of the last two frames the test prints the counters, and the final assertion expects exactly one job drained.

test t2_click_needs_press_and_release_across_frames ... t2 button center = [40.1 59.0]
t2 after press: depth=2 drained=0
t2 after release: depth=1 drained=1
ok

Nothing changes after the press frame. The click registers on the release frame, so depth drops from 2 to 1 and drained rises to 1. The disabled case uses the same script against an empty queue:

#[test]
fn t3_disabled_button_ignores_click() {
    let ctx = Context::default();
    let mut p = Panel { queue_depth: 0, ..Default::default() };
    let _ = frame(&ctx, screen(), &mut p);
    let c = p.last_button_rect.unwrap().center();
    let _ = frame(&ctx, with_events(vec![Event::PointerMoved(c), press(c, true)]), &mut p);
    let _ = frame(&ctx, with_events(vec![press(c, false)]), &mut p);
    println!("t3 depth={} drained={}", p.queue_depth, p.drained);
    assert_eq!(p.drained, 0);
}

The setup is the same, except that the depth starts at zero, so add_enabled receives false. The test sends the identical press and release at the identical position. Its assertion is that drained stays at zero, so a click on a disabled button changes nothing. This is the behaviour you want from the "Drain one" button on an empty queue, and the test confirms it without a human or a screenshot. Its printed line is t3 depth=0 drained=0.

Common mistake: asserting after the press frame, or guessing coordinates. Checking the state right after the press sees no change and looks like a broken button. Hard-coding a pixel position breaks as soon as a font or margin changes. Read the rectangle from the Response instead, as the panel does. The experiment did not try sending press and release in the same frame, so it makes no claim about that case.

(d) A little memory keyed by Id, and repaint on demand

egui is not entirely stateless. An Id is a hash-based identifier egui assigns to each widget. In egui it is derived from the widget's label or an explicit salt value, combined with its parent's Id. That derivation is general knowledge about egui and was not tested here. The Context keeps a small memory keyed by these Ids for things the app should not have to track, such as whether a collapsing header is open. In this panel, the "details" section's open state is not a field in Panel.

#[test]
fn t5_collapsing_open_state_is_egui_memory_keyed_by_id() {
    let ctx = Context::default();
    let mut p = Panel { queue_depth: 1, drained: 7, ..Default::default() };
    let count = |out: egui::FullOutput| out.shapes.iter().filter(|s| matches!(&s.shape,
        egui::Shape::Text(t) if t.galley.job.text.contains("drained so far"))).count();
    let closed = count(frame(&ctx, screen(), &mut p));
    // find header: it sits below the button; click roughly there
    let b = p.last_button_rect.unwrap();
    let h = egui::pos2(b.left() + 20.0, b.bottom() + 12.0);
    let _ = frame(&ctx, with_events(vec![Event::PointerMoved(h), press(h, true)]), &mut p);
    let _ = frame(&ctx, with_events(vec![press(h, false)]), &mut p);
    let mut opened = 0;
    for _ in 0..30 { opened = count(frame(&ctx, screen(), &mut p)); } // let the open animation finish
    println!("t5 'drained so far' label count: closed={} after_click={}", closed, opened);
    let fresh = Context::default();
    let fresh_count = count(frame(&fresh, screen(), &mut p));
    println!("t5 same Panel, fresh Context: {}", fresh_count);
}

The test counts how many text shapes contain "drained so far". That text exists only when the section is open. It counts once while closed, clicks a point just below the button where the header should be, and runs thirty idle frames so the opening animation finishes. It then counts again. Finally it runs a frame with the same Panel but a brand-new Context and counts once more. The click position is a coordinate guess, so the test asserts nothing and only prints the counts.

test t5_collapsing_open_state_is_egui_memory_keyed_by_id ... t5 'drained so far' label count: closed=0 after_click=1
t5 same Panel, fresh Context: 0
ok

The section opens in the same Context, which gives 0 and then 1. A fresh Context with an identical Panel shows it closed again. The open state therefore lives in egui's memory, not in the app struct.

**Common mistake: creating a new Context for every frame in a test.** The memory is discarded, so open sections close, animations restart, and input history vanishes. Create one Context per test and reuse it.

The second half of this concept is repaint. A repaint is a request for the integration layer to run another frame. egui does not draw continuously. The viewport output of each frame carries repaint_delay, which the documentation describes as: "Schedule a repaint of this viewport after this delay." and "If the duration is zero, schedule a repaint immediately."

#[test]
fn t6_repaint_is_on_demand() {
    let ctx = Context::default();
    let mut p = Panel::default();
    for n in 1..=4 {
        let out = frame(&ctx, screen(), &mut p);
        let d = out.viewport_output.values().next().unwrap().repaint_delay;
        println!("t6 idle frame {} repaint_delay={:?}", n, d);
    }
    ctx.request_repaint();
    let out = frame(&ctx, screen(), &mut p);
    println!("t6 after request_repaint (before frame) repaint_delay={:?}", out.viewport_output.values().next().unwrap().repaint_delay);
    let mut out = ctx.run_ui(screen(), |ui| { ui.ctx().request_repaint_after(std::time::Duration::from_secs(2)); p.ui(ui) });
    out.textures_delta.clear();
    println!("t6 request_repaint_after(2s) inside frame repaint_delay={:?}", out.viewport_output.values().next().unwrap().repaint_delay);
}

The test runs four frames with no input and prints the delay each time. It then calls request_repaint on the context before a frame, and finally asks for a repaint after two seconds from inside a frame. The output is below.

test t6_repaint_is_on_demand ... t6 idle frame 1 repaint_delay=0ns
t6 idle frame 2 repaint_delay=0ns
t6 idle frame 3 repaint_delay=18446744073709551615.999999999s
t6 idle frame 4 repaint_delay=18446744073709551615.999999999s
t6 after request_repaint (before frame) repaint_delay=0ns
t6 request_repaint_after(2s) inside frame repaint_delay=1.983333332s
ok

The first two idle frames ask for an immediate repaint. A reasonable inference, not verified here, is that the first frames are still settling layout. From the third frame the delay is Duration::MAX, printed as that long number of seconds. It means "no repaint needed until input arrives", not an error. Calling request_repaint produces a zero delay again. The two-second request comes back as 1.983333332 s. That number matches two seconds minus 1/60 s, which suggests egui subtracts a predicted frame time; treat that reading as an inference.

Immediate mode versus retained mode

Retained-mode UIs keep a persistent object tree. Two common examples are the browser DOM, updated by a signal framework such as Leptos or Dioxus, and a widget-tree toolkit with button and label objects. The table compares them with egui. Entries marked "measured" come from the experiment above. Entries marked "docs" are claims from the egui documentation. Entries marked "general" are general knowledge about the other families that this experiment did not test.

egui (immediate mode)DOM with a signal frameworkWidget-tree toolkit
Where state livesYour struct, plus a small per-Id memory in Context (measured, t4 and t5)Signals or component state; the DOM holds the rendered result (general)Often inside widget objects, such as a text field's contents (general)
How a change reaches the screenThe next frame re-describes the UI from the struct (measured, t4)Setting a signal updates the affected nodes (general)A setter or binding marks the widget dirty and schedules a redraw (general)
How you testCall run_ui with scripted RawInput, read shapes and your struct (measured)Usually a browser or DOM emulator, querying nodes (general)A toolkit test driver, which may need a display or an offscreen platform (general)
Cost modelLayout re-runs on every painted frame; idle frames are skipped by the repaint scheduler (docs; t6 shows the scheduler reporting no repaint, cost not measured)Work scales with what changed (general)Work scales with dirty widgets (general)

The main difference is where the truth sits. In a retained UI there are two copies of the interface state, yours and the toolkit's, and keeping them in sync is the programmer's job. The egui README, in its "Why immediate mode" section, states the author's case directly:

You don't have to worry about app state and GUI state being out-of-sync (i.e. the GUI showing something outdated), because the GUI isn't storing any state - it is showing the latest state immediately.

The same section names the cost. Because an immediate mode GUI "does a full layout each frame", a very large UI inside a long scroll area can be slow, and the author claims that "for most cases you can expect egui to take up 1-2 ms per frame". It also says egui "only repaints when there is interaction (e.g. mouse movement) or an animation". Both are the author's claims about the current README, not measurements from this experiment. t6 confirms only the second part in a narrow way: an idle Context reports that it needs no repaint. The README also lists layout as the main weakness: a window cannot know its size before laying out its contents, so egui reuses the previous frame's size for windows and grids, which can cause a one-frame jitter the first time something appears.

Putting it together

Every test is a short script of frames. A frame is one call that takes the struct, the Context and a RawInput, and returns a FullOutput:

Panel (your struct) ──┐
RawInput (events)   ──┼─> Context::run_ui ──> FullOutput { shapes, textures_delta, viewport_output }
Context (memory)    ──┘                        │
                                               ├─ test: inspect shapes, clear textures_delta, check Panel
                                               └─ real app: tessellate, upload atlas, draw pixels

In production, a window integration takes the lower branch. The tests take the upper one. The six tests map onto it as follows:

The frame helper makes all of them possible, because it runs the frame and acknowledges the texture delta in one place.

What this experiment did not cover. It used the egui crate version 0.36.2 alone, on rustc 1.99.0, with cargo test in a debug build. There was no eframe, no window, no GPU backend, and no screenshot or pixel comparison. Tessellation ran on the CPU, but nothing was rendered. The egui_kittest crate, egui's own test kit, was not used; it is the natural next step if you want queries by widget label and image snapshots. No performance was measured, so any statement about speed in this lesson comes from the egui docs or general knowledge. The t5 check is print-only, because its click position is a guess and the test asserts nothing about it.

Check yourself

  1. What does Context::run_ui return, and why does that make headless testing possible? *A FullOutput holding shapes, a texture delta and viewport output. It contains drawing commands, not pixels, so no window or GPU is needed to read it.*
  1. A worker thread sets queue_depth = 500 while no frame is running. What must you do for the screen to update? Nothing beyond running the next frame. The panel is drawn from your struct each frame, so no signal or invalidation exists to call.
  1. Why does t2 need three frames, and what happens if you assert right after the press? The first frame lays out the button so its rectangle is known. The click completes only on the release frame, so an assertion after the press still sees the old counters.
  1. Your tests panic with "Dropped TexturesDelta with 1 unapplied deltas". What is the cause and the fix? *The font atlas upload in FullOutput.textures_delta was dropped unhandled, which trips a debug_assert! in epaint. Call out.textures_delta.clear() after each frame.*
  1. A test opens the collapsing section, then builds a new Context for the next frame, and the section is closed. Why? *The open state is stored in the Context's memory, keyed by Id, not in your struct, so a new Context starts with it closed.*

Sources