Build a Reactive ECS GUI in Rust
Build a GPU-rendered Rust counter with ECS widgets, signals, and redraws.
Introduction
30 Second Summary
Desktop apps feel effortless when a click produces an immediate response. That smooth moment depends on the interface remembering its state while drawing only when something changes.
In this project, you will build a native Linux counter in Rust that carries winit input through custom signals into bevy_ecs widget state. Vello renders each requested scene through wgpu.
What You'll Build
A polished dark counter window where each click advances a glowing seven-segment digit while the terminal reports the exact number of widgets that needed repainting.
By the end of this project, you'll have:
- A native DPI-aware counter that keeps its controls aligned at your desktop scale.
- An inspectable ECS widget tree with separate entities for the panel, digit, and plus button.
- An event-driven signal path that advances the digit from 0 through 9 while reporting each targeted widget update.
- Secret Mission: Route the Space key through the same counter action as a pointer click.
Are there any prerequisites?
You should already feel comfortable building small Rust programs with Cargo. Your Linux machine also needs Vulkan-capable graphics drivers.
Before We Start
Defining the boundary first keeps this production-shaped vertical slice focused on six connected areas: window, rendering, widget state, interaction, signals, and invalidation. Typography, accessibility, IME, clipboard, advanced layout, and multi-window support remain follow-on work.
Pin and Verify the Graphics Stack
Graphics libraries move quickly. A mismatched release can stop this counter before its first window opens.
You'll update your existing Rust stable toolchain to meet the project's 1.95 requirement.
You'll also pin the Cargo dependency graph. Every later step then builds against the same APIs.
In this step, get ready to:
- Update the active stable Rust toolchain.
- Create the reactive-ecs-gui binary project with pinned dependencies.
- Verify that Cargo resolves one compatible wgpu version.
Meet the Rust version requirement
Bevy ECS 0.19.1 requires Rust 1.95. Updating the stable channel gives Cargo a compiler that satisfies this dependency floor.
- Press the Super key to open your Linux application launcher.
- Search for Terminal.
- Launch your terminal application.
- Refresh the active stable toolchain by running this command:
rustup update
What does this command do?
The rustup tool updates installed channels. This refreshes the stable compiler that Cargo uses for the project.
When the command finishes, your active stable toolchain meets the project's Rust 1.95 requirement.
Did the toolchain update fail?
- Confirm that your Linux machine has an active internet connection.
- Close any other process that is currently updating the Rust toolchain.
Still stuck? Help me troubleshoot a failed Rust stable toolchain update on Linux.
Establish the project manifest
A Cargo binary project provides the package structure that the counter runtime builds on. Starting from your Desktop also gives the project a predictable location.
- Create the project on your Desktop and enter its directory by running these commands:
cd ~/Desktop
cargo new reactive-ecs-gui && cd reactive-ecs-gui
What do these commands create?
- The first line moves the terminal into your Desktop directory.
- The second line creates a Rust 2024 binary package named reactive-ecs-gui. It also leaves the terminal inside the new project directory.
- Use your code editor's folder-opening control to open the reactive-ecs-gui folder from your Desktop.
- Confirm that the editor sidebar lists Cargo.toml.
- Confirm that the editor sidebar lists src/main.rs.
Can't find the project folder?
Check the terminal output for the location where Cargo created reactive-ecs-gui. The folder should be on your Desktop.
Need a hand? Help me locate the Cargo project I created from my Linux terminal.
The manifest fixes Bevy ECS 0.19.1, Pollster 0.4.0, Vello 0.10.0, wgpu 29.0.4, and winit 0.30.13 into one compatible set.
- Select Cargo.toml in your editor sidebar.
- Replace its contents with this pinned manifest:
[package]
name = "reactive-ecs-gui"
version = "0.1.0"
edition = "2024"
rust-version = "1.95"
publish = false
[dependencies]
bevy_ecs = { version = "=0.19.1", default-features = false, features = ["std"] }
pollster = "=0.4.0"
vello = "=0.10.0"
wgpu = "=29.0.4"
winit = "=0.30.13"
Why pin every dependency?
- The rust-version field records the minimum compiler version for the package.
- Each leading = tells Cargo to resolve that exact library version.
- The reduced Bevy ECS feature set keeps the project focused on its standalone ECS functionality.
- The explicit wgpu pin keeps Vello on one compatible GPU API release.
- Save Cargo.toml.
- Confirm that the file starts with [package].
- Confirm that the dependency section ends with winit = "=0.30.13".
Does the manifest look different?
- Remove any dependency line left over from the generated manifest.
- Check that every opening quote has a matching closing quote.
- Check that the [package] section appears before [dependencies].
Still seeing a mismatch? Help me compare my Cargo manifest with the pinned project manifest.
✔️ Awesome, I've got everything!
Your manifest now contains the complete pinned dependency set.
ⓧ I'd like to double check the full code
[package]
name = "reactive-ecs-gui"
version = "0.1.0"
edition = "2024"
rust-version = "1.95"
publish = false
[dependencies]
bevy_ecs = { version = "=0.19.1", default-features = false, features = ["std"] }
pollster = "=0.4.0"
vello = "=0.10.0"
wgpu = "=29.0.4"
winit = "=0.30.13"
What are you comparing?
The reference tab contains the exact Cargo.toml required at this point. Matching it removes dependency drift before the graphics code arrives.
Prove the dependency graph is consistent
A successful static check proves that Cargo accepts the manifest. The inverse dependency tree then reveals whether Cargo selected one wgpu release.
The first check can take noticeably longer while Cargo prepares the graphics dependency graph. Later checks reuse those build artifacts.
Before you run the check, do you expect the pinned graph to contain one wgpu version or several?
- Check the project and inspect the resolved wgpu dependency by running:
cargo check && cargo tree -i wgpu
What does this verification prove?
- The first command checks the package with its dependencies without producing the final executable.
- The second command shows which resolved packages depend on wgpu.
- The double ampersand runs the dependency inspection only after the project check succeeds.
You should see Cargo finish successfully. The dependency tree should contain one wgpu v29.0.4 entry.
- Keep the generated Cargo.lock file in the reactive-ecs-gui project.
- Confirm that Cargo.lock is visible in your editor sidebar.
- Leave the terminal inside the reactive-ecs-gui directory.
Did the dependency check fail?
- Compare Cargo.toml with the full-file reference above.
- Correct the first manifest error reported by Cargo before repeating the check.
- Confirm that every dependency version includes its leading =.
Need help reading the result? Help me diagnose my Cargo check or duplicate wgpu dependency output.
That's the dependency risk contained. Your pinned Rust graphics stack is ready for the first ECS widget tree in the next step.
Render the First ECS Widget Tree
The pinned Rust project is ready, but the dependency graph cannot prove that pixels can reach a native window. A visible widget tree gives you that proof.
This step connects winit, Vello, wgpu, and an ECS data model. You will render a static counter before adding signals or pointer interaction.
In this step, get ready to:
- Store the panel, digit, and button as retained widget entities.
- Connect the widget scene to a native GPU surface.
- Run the application and inspect the first rendered counter.
Build the retained ECS widget tree
A retained interface stores each widget between frames. bevy_ecs gives UiWorld a place to own that state while Vello turns queried components into vector shapes.
- Create src/ui.rs inside the reactive-ecs-gui folder from earlier.
- Add the graphics imports and logical bounds by pasting this first chunk:
use bevy_ecs::prelude::*;
use vello::{
Scene,
kurbo::{Affine, Rect},
peniko::{Color, Fill},
};
use winit::event_loop::EventLoopProxy;
use crate::UiEvent;
#[derive(Component, Clone, Copy)]
struct Bounds {
x: f64,
y: f64,
width: f64,
height: f64,
}
impl Bounds {
const fn new(x: f64, y: f64, width: f64, height: f64) -> Self {
Self { x, y, width, height }
}
fn rect(self) -> Rect {
Rect::new(self.x, self.y, self.x + self.width, self.y + self.height)
}
}
What Does This Code Do?
- The Vello imports provide the scene, rectangles, transforms, fill rules, and colors used during painting.
- The Bounds component stores widget geometry in logical pixels.
- The rect() helper converts that geometry into a shape Vello can fill.
- Add the retained widget components and the UiWorld::counter constructor below the existing code:
#[derive(Component)]
struct Panel;
#[derive(Component)]
struct Digit {
displayed: u8,
}
#[derive(Component)]
struct Button;
#[derive(Component)]
struct PaintState {
dirty: bool,
}
impl PaintState {
const fn dirty() -> Self {
Self { dirty: true }
}
}
pub struct UiWorld {
world: World,
}
impl UiWorld {
pub fn counter(proxy: EventLoopProxy<UiEvent>) -> Self {
let _ = proxy;
let mut world = World::new();
world.spawn((Panel, Bounds::new(60.0, 40.0, 360.0, 280.0), PaintState::dirty()));
world.spawn((Digit { displayed: 0 }, Bounds::new(155.0, 70.0, 170.0, 140.0), PaintState::dirty()));
world.spawn((Button, Bounds::new(190.0, 235.0, 100.0, 60.0), PaintState::dirty()));
Self { world }
}
}
How Is the Widget Tree Retained?
- The marker components identify the panel, digit, and button without combining their paint logic.
- Each entity carries its own Bounds and PaintState components.
- The digit stores 0 as retained display state.
- The proxy enters the constructor now so the next step can connect signals without changing the runtime boundary.
- Add paint_scene as another method inside the existing impl UiWorld block:
pub fn paint_scene(&mut self, scene: &mut Scene, scale_factor: f64) -> usize {
let dirty_count = {
let mut count = 0;
let mut query = self.world.query::<&mut PaintState>();
for mut paint in query.iter_mut(&mut self.world) {
if paint.dirty {
count += 1;
paint.dirty = false;
}
}
count
};
let mut logical_scene = Scene::new();
{
let mut query = self.world.query::<(&Panel, &Bounds)>();
for (_, bounds) in query.iter(&self.world) {
fill_rect(&mut logical_scene, bounds.rect(), Color::from_rgb8(42, 50, 70));
fill_rect(
&mut logical_scene,
Rect::new(bounds.x + 3.0, bounds.y + 3.0, bounds.x + bounds.width - 3.0, bounds.y + bounds.height - 3.0),
Color::from_rgb8(18, 23, 36),
);
}
}
{
let mut query = self.world.query::<(&Digit, &Bounds)>();
for (digit, bounds) in query.iter(&self.world) {
draw_digit(&mut logical_scene, *bounds, digit.displayed);
}
}
Why Use Separate Query Passes?
The panel pass runs before the digit pass. That deterministic order keeps the background behind the display without making entity insertion order part of the renderer contract.
The dirty count records how many retained widgets needed paint work. Each flag is cleared as the scene is encoded.
- Finish paint_scene with the button pass and scale transform:
{
let mut query = self.world.query::<(&Button, &Bounds)>();
for (_, bounds) in query.iter(&self.world) {
draw_button(&mut logical_scene, *bounds);
}
}
scene.reset();
scene.append(&logical_scene, Some(Affine::scale(scale_factor)));
self.world.clear_trackers();
dirty_count
}
}
What Finishes the Scene?
- The final ECS pass paints the button after the panel and digit.
- The scale transform converts the logical widget scene into physical output coordinates.
- The tracker cleanup gives the standalone ECS world a clear boundary after painting.
- Add the rectangle helper and static button painter below impl UiWorld:
fn fill_rect(scene: &mut Scene, rect: Rect, color: Color) {
scene.fill(Fill::NonZero, Affine::IDENTITY, color, None, &rect);
}
fn draw_button(scene: &mut Scene, bounds: Bounds) {
fill_rect(
scene,
Rect::new(
bounds.x + 4.0,
bounds.y + 6.0,
bounds.x + bounds.width + 4.0,
bounds.y + bounds.height + 6.0,
),
Color::from_rgb8(7, 10, 20),
);
fill_rect(scene, bounds.rect(), Color::from_rgb8(55, 118, 210));
let center_x = bounds.x + bounds.width / 2.0;
let center_y = bounds.y + bounds.height / 2.0;
let icon = Color::from_rgb8(244, 248, 255);
fill_rect(
scene,
Rect::new(center_x - 18.0, center_y - 4.0, center_x + 18.0, center_y + 4.0),
icon,
);
fill_rect(
scene,
Rect::new(center_x - 4.0, center_y - 18.0, center_x + 4.0, center_y + 18.0),
icon,
);
}
How Is the Button Drawn?
The first rectangle creates a dark offset shadow. The second rectangle paints the blue button face.
Two narrow white rectangles cross at the center to form the plus icon.
- Add the seven-segment digit painter at the bottom of src/ui.rs:
fn draw_digit(scene: &mut Scene, bounds: Bounds, value: u8) {
const DIGITS: [[bool; 7]; 10] = [
[true, true, true, true, true, true, false],
[false, true, true, false, false, false, false],
[true, true, false, true, true, false, true],
[true, true, true, true, false, false, true],
[false, true, true, false, false, true, true],
[true, false, true, true, false, true, true],
[true, false, true, true, true, true, true],
[true, true, true, false, false, false, false],
[true, true, true, true, true, true, true],
[true, true, true, true, false, true, true],
];
fill_rect(scene, bounds.rect(), Color::from_rgb8(8, 13, 24));
let x = bounds.x + 20.0;
let y = bounds.y + 12.0;
let width = bounds.width - 40.0;
let height = bounds.height - 24.0;
let thickness = 14.0;
let middle = y + height / 2.0;
let segments = [
Rect::new(x + thickness, y, x + width - thickness, y + thickness),
Rect::new(x + width - thickness, y + thickness, x + width, middle - thickness / 2.0),
Rect::new(x + width - thickness, middle + thickness / 2.0, x + width, y + height - thickness),
Rect::new(x + thickness, y + height - thickness, x + width - thickness, y + height),
Rect::new(x, middle + thickness / 2.0, x + thickness, y + height - thickness),
Rect::new(x, y + thickness, x + thickness, middle - thickness / 2.0),
Rect::new(x + thickness, middle - thickness / 2.0, x + width - thickness, middle + thickness / 2.0),
];
for (active, segment) in DIGITS[usize::from(value % 10)].into_iter().zip(segments) {
let color = if active { Color::from_rgb8(65, 225, 210) } else { Color::from_rgb8(20, 48, 54) };
fill_rect(scene, segment, color);
}
}
How Does the Digit Work?
The lookup table maps each decimal value to seven horizontal or vertical segments. The initial value activates the six segments that form 0.
Active segments use cyan. Inactive segments remain dim enough to reveal the display structure.
- Save src/ui.rs.
- Confirm the file contains the three spawn calls and the three deterministic query passes.
Missing a Widget Definition?
Check that both impl blocks close before the standalone drawing functions begin. A misplaced brace can make Rust treat a helper as an associated function.
Still stuck? Help me compare the structure of my src/ui.rs file with the retained ECS widget code.
✔️ I have the retained widget code
Your ECS world now has separate entities for the panel, digit, and button. The paint passes preserve their visual order.
ⓧ I'd like to double check the full code
use bevy_ecs::prelude::*;
use vello::{
Scene,
kurbo::{Affine, Rect},
peniko::{Color, Fill},
};
use winit::event_loop::EventLoopProxy;
use crate::UiEvent;
#[derive(Component, Clone, Copy)]
struct Bounds {
x: f64,
y: f64,
width: f64,
height: f64,
}
impl Bounds {
const fn new(x: f64, y: f64, width: f64, height: f64) -> Self {
Self { x, y, width, height }
}
fn rect(self) -> Rect {
Rect::new(self.x, self.y, self.x + self.width, self.y + self.height)
}
}
#[derive(Component)]
struct Panel;
#[derive(Component)]
struct Digit {
displayed: u8,
}
#[derive(Component)]
struct Button;
#[derive(Component)]
struct PaintState {
dirty: bool,
}
impl PaintState {
const fn dirty() -> Self {
Self { dirty: true }
}
}
pub struct UiWorld {
world: World,
}
impl UiWorld {
pub fn counter(proxy: EventLoopProxy<UiEvent>) -> Self {
let _ = proxy;
let mut world = World::new();
world.spawn((Panel, Bounds::new(60.0, 40.0, 360.0, 280.0), PaintState::dirty()));
world.spawn((Digit { displayed: 0 }, Bounds::new(155.0, 70.0, 170.0, 140.0), PaintState::dirty()));
world.spawn((Button, Bounds::new(190.0, 235.0, 100.0, 60.0), PaintState::dirty()));
Self { world }
}
pub fn paint_scene(&mut self, scene: &mut Scene, scale_factor: f64) -> usize {
let dirty_count = {
let mut count = 0;
let mut query = self.world.query::<&mut PaintState>();
for mut paint in query.iter_mut(&mut self.world) {
if paint.dirty {
count += 1;
paint.dirty = false;
}
}
count
};
let mut logical_scene = Scene::new();
{
let mut query = self.world.query::<(&Panel, &Bounds)>();
for (_, bounds) in query.iter(&self.world) {
fill_rect(&mut logical_scene, bounds.rect(), Color::from_rgb8(42, 50, 70));
fill_rect(
&mut logical_scene,
Rect::new(bounds.x + 3.0, bounds.y + 3.0, bounds.x + bounds.width - 3.0, bounds.y + bounds.height - 3.0),
Color::from_rgb8(18, 23, 36),
);
}
}
{
let mut query = self.world.query::<(&Digit, &Bounds)>();
for (digit, bounds) in query.iter(&self.world) {
draw_digit(&mut logical_scene, *bounds, digit.displayed);
}
}
{
let mut query = self.world.query::<(&Button, &Bounds)>();
for (_, bounds) in query.iter(&self.world) {
draw_button(&mut logical_scene, *bounds);
}
}
scene.reset();
scene.append(&logical_scene, Some(Affine::scale(scale_factor)));
self.world.clear_trackers();
dirty_count
}
}
fn fill_rect(scene: &mut Scene, rect: Rect, color: Color) {
scene.fill(Fill::NonZero, Affine::IDENTITY, color, None, &rect);
}
fn draw_button(scene: &mut Scene, bounds: Bounds) {
fill_rect(
scene,
Rect::new(
bounds.x + 4.0,
bounds.y + 6.0,
bounds.x + bounds.width + 4.0,
bounds.y + bounds.height + 6.0,
),
Color::from_rgb8(7, 10, 20),
);
fill_rect(scene, bounds.rect(), Color::from_rgb8(55, 118, 210));
let center_x = bounds.x + bounds.width / 2.0;
let center_y = bounds.y + bounds.height / 2.0;
let icon = Color::from_rgb8(244, 248, 255);
fill_rect(
scene,
Rect::new(center_x - 18.0, center_y - 4.0, center_x + 18.0, center_y + 4.0),
icon,
);
fill_rect(
scene,
Rect::new(center_x - 4.0, center_y - 18.0, center_x + 4.0, center_y + 18.0),
icon,
);
}
fn draw_digit(scene: &mut Scene, bounds: Bounds, value: u8) {
const DIGITS: [[bool; 7]; 10] = [
[true, true, true, true, true, true, false],
[false, true, true, false, false, false, false],
[true, true, false, true, true, false, true],
[true, true, true, true, false, false, true],
[false, true, true, false, false, true, true],
[true, false, true, true, false, true, true],
[true, false, true, true, true, true, true],
[true, true, true, false, false, false, false],
[true, true, true, true, true, true, true],
[true, true, true, true, false, true, true],
];
fill_rect(scene, bounds.rect(), Color::from_rgb8(8, 13, 24));
let x = bounds.x + 20.0;
let y = bounds.y + 12.0;
let width = bounds.width - 40.0;
let height = bounds.height - 24.0;
let thickness = 14.0;
let middle = y + height / 2.0;
let segments = [
Rect::new(x + thickness, y, x + width - thickness, y + thickness),
Rect::new(x + width - thickness, y + thickness, x + width, middle - thickness / 2.0),
Rect::new(x + width - thickness, middle + thickness / 2.0, x + width, y + height - thickness),
Rect::new(x + thickness, y + height - thickness, x + width - thickness, y + height),
Rect::new(x, middle + thickness / 2.0, x + thickness, y + height - thickness),
Rect::new(x, y + thickness, x + thickness, middle - thickness / 2.0),
Rect::new(x + thickness, middle - thickness / 2.0, x + width - thickness, middle + thickness / 2.0),
];
for (active, segment) in DIGITS[usize::from(value % 10)].into_iter().zip(segments) {
let color = if active { Color::from_rgb8(65, 225, 210) } else { Color::from_rgb8(20, 48, 54) };
fill_rect(scene, segment, color);
}
}
Connect Vello to the native window
The widget scene still needs a window surface and a presentation path. The runtime owns those graphics resources because their lifetime follows the application window.
Why Use Vello's Render Context?
Vello's RenderContext packages the device, surface, intermediate texture, and blitter used by its supported window path. This keeps the step focused on GUI architecture instead of rebuilding low-level GPU setup.
- Create src/runtime.rs inside the src folder.
- Add the runtime imports and owned graphics state by pasting this chunk:
use std::sync::Arc;
use vello::{
AaConfig, RenderParams, Renderer, RendererOptions, Scene,
peniko::Color,
util::{RenderContext, RenderSurface},
};
use wgpu::CurrentSurfaceTexture;
use winit::{
application::ApplicationHandler,
dpi::LogicalSize,
event::WindowEvent,
event_loop::ActiveEventLoop,
window::{Window, WindowId},
};
use crate::{UiEvent, ui::UiWorld};
struct RenderState {
surface: RenderSurface<'static>,
window: Arc<Window>,
}
pub struct GuiApp {
context: RenderContext,
renderer: Option<Renderer>,
state: Option<RenderState>,
scene: Scene,
ui: UiWorld,
}
Who Owns the Graphics State?
- The render state keeps the surface before its owning window handle.
- The application keeps the renderer optional because it is created after the event loop resumes.
- The scene is reused for each requested redraw.
- Add the GuiApp::new constructor below the state definitions:
impl GuiApp {
pub fn new(ui: UiWorld) -> Self {
Self {
context: RenderContext::new(),
renderer: None,
state: None,
scene: Scene::new(),
ui,
}
}
}
What Is Ready Before Resume?
The application can create its reusable context and scene immediately. Window-dependent objects stay empty until winit announces that creating a window is valid.
- Add the rendering method as a second impl GuiApp block:
impl GuiApp {
fn render(&mut self) {
let (Some(state), Some(renderer)) = (&mut self.state, &mut self.renderer) else {
return;
};
let dirty_count = self.ui.paint_scene(&mut self.scene, state.window.scale_factor());
if dirty_count > 0 {
println!("redraw triggered by {dirty_count} dirty widget(s)");
}
let surface = &mut state.surface;
let device_handle = &self.context.devices[surface.dev_id];
let params = RenderParams {
base_color: Color::from_rgb8(10, 14, 24),
width: surface.config.width,
height: surface.config.height,
antialiasing_method: AaConfig::Area,
};
if let Err(error) = renderer.render_to_texture(&device_handle.device, &device_handle.queue, &self.scene, &surface.target_view, ¶ms) {
eprintln!("Vello render failed: {error}");
return;
}
let CurrentSurfaceTexture::Success(surface_texture) = surface.surface.get_current_texture() else {
return;
};
let mut encoder = device_handle.device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("reactive-ecs-gui surface blit") });
let surface_view = surface_texture.texture.create_view(&wgpu::TextureViewDescriptor::default());
surface.blitter.copy(&device_handle.device, &mut encoder, &surface.target_view, &surface_view);
device_handle.queue.submit([encoder.finish()]);
state.window.pre_present_notify();
surface_texture.present();
}
}
How Does a Frame Reach the Screen?
- The ECS world encodes its retained widgets into the reusable Vello scene.
- Vello renders that scene into its intermediate target texture.
- The blitter copies the target into the acquired wgpu surface texture.
- The surface texture presents the completed frame to the window.
- Add the winit application lifecycle implementation below the rendering method:
impl ApplicationHandler<UiEvent> for GuiApp {
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
if self.state.is_some() { return; }
let attributes = Window::default_attributes()
.with_title("Reactive ECS Counter")
.with_inner_size(LogicalSize::new(480.0, 360.0))
.with_resizable(false);
let window = Arc::new(event_loop.create_window(attributes).expect("failed to create the application window"));
let size = window.inner_size();
let surface = pollster::block_on(self.context.create_surface(window.clone(), size.width, size.height, wgpu::PresentMode::AutoVsync))
.expect("failed to create a Vello render surface");
let renderer = Renderer::new(&self.context.devices[surface.dev_id].device, RendererOptions::default())
.expect("failed to create the Vello renderer");
self.renderer = Some(renderer);
self.state = Some(RenderState { surface, window: window.clone() });
window.request_redraw();
}
fn window_event(&mut self, event_loop: &ActiveEventLoop, window_id: WindowId, event: WindowEvent) {
let Some(state) = &mut self.state else { return; };
if state.window.id() != window_id { return; }
match event {
WindowEvent::CloseRequested => event_loop.exit(),
WindowEvent::RedrawRequested => self.render(),
_ => {}
}
}
}
How Does On-Demand Rendering Start?
The resumed callback creates the 480 by 360 logical-pixel window and its graphics resources. It requests one redraw after setup.
The event handler renders only for RedrawRequested. Closing the window exits the application loop.
- Save src/runtime.rs.
- Confirm the file defines RenderState, GuiApp, and the ApplicationHandler<UiEvent> implementation.
Seeing a Runtime Structure Error?
Check that the two impl GuiApp blocks are separate from the ApplicationHandler implementation. Confirm that each block has its own closing brace.
Still stuck? Help me find a missing brace or misplaced method in src/runtime.rs.
✔️ I have the window runtime
Your runtime now owns the window, Vello renderer, reusable scene, and wgpu presentation path.
ⓧ I'd like to double check the full code
use std::sync::Arc;
use vello::{
AaConfig, RenderParams, Renderer, RendererOptions, Scene,
peniko::Color,
util::{RenderContext, RenderSurface},
};
use wgpu::CurrentSurfaceTexture;
use winit::{
application::ApplicationHandler,
dpi::LogicalSize,
event::WindowEvent,
event_loop::ActiveEventLoop,
window::{Window, WindowId},
};
use crate::{UiEvent, ui::UiWorld};
struct RenderState {
surface: RenderSurface<'static>,
window: Arc<Window>,
}
pub struct GuiApp {
context: RenderContext,
renderer: Option<Renderer>,
state: Option<RenderState>,
scene: Scene,
ui: UiWorld,
}
impl GuiApp {
pub fn new(ui: UiWorld) -> Self {
Self {
context: RenderContext::new(),
renderer: None,
state: None,
scene: Scene::new(),
ui,
}
}
}
impl GuiApp {
fn render(&mut self) {
let (Some(state), Some(renderer)) = (&mut self.state, &mut self.renderer) else {
return;
};
let dirty_count = self.ui.paint_scene(&mut self.scene, state.window.scale_factor());
if dirty_count > 0 {
println!("redraw triggered by {dirty_count} dirty widget(s)");
}
let surface = &mut state.surface;
let device_handle = &self.context.devices[surface.dev_id];
let params = RenderParams {
base_color: Color::from_rgb8(10, 14, 24),
width: surface.config.width,
height: surface.config.height,
antialiasing_method: AaConfig::Area,
};
if let Err(error) = renderer.render_to_texture(&device_handle.device, &device_handle.queue, &self.scene, &surface.target_view, ¶ms) {
eprintln!("Vello render failed: {error}");
return;
}
let CurrentSurfaceTexture::Success(surface_texture) = surface.surface.get_current_texture() else {
return;
};
let mut encoder = device_handle.device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("reactive-ecs-gui surface blit") });
let surface_view = surface_texture.texture.create_view(&wgpu::TextureViewDescriptor::default());
surface.blitter.copy(&device_handle.device, &mut encoder, &surface.target_view, &surface_view);
device_handle.queue.submit([encoder.finish()]);
state.window.pre_present_notify();
surface_texture.present();
}
}
impl ApplicationHandler<UiEvent> for GuiApp {
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
if self.state.is_some() { return; }
let attributes = Window::default_attributes()
.with_title("Reactive ECS Counter")
.with_inner_size(LogicalSize::new(480.0, 360.0))
.with_resizable(false);
let window = Arc::new(event_loop.create_window(attributes).expect("failed to create the application window"));
let size = window.inner_size();
let surface = pollster::block_on(self.context.create_surface(window.clone(), size.width, size.height, wgpu::PresentMode::AutoVsync))
.expect("failed to create a Vello render surface");
let renderer = Renderer::new(&self.context.devices[surface.dev_id].device, RendererOptions::default())
.expect("failed to create the Vello renderer");
self.renderer = Some(renderer);
self.state = Some(RenderState { surface, window: window.clone() });
window.request_redraw();
}
fn window_event(&mut self, event_loop: &ActiveEventLoop, window_id: WindowId, event: WindowEvent) {
let Some(state) = &mut self.state else { return; };
if state.window.id() != window_id { return; }
match event {
WindowEvent::CloseRequested => event_loop.exit(),
WindowEvent::RedrawRequested => self.render(),
_ => {}
}
}
}
Start the application and inspect the first frame
The runtime needs a small event type before it can create a typed event loop. The signal module provides that identifier boundary now while reactive handles remain part of the next step.
- Create src/signal.rs inside the src folder.
- Add the placeholder signal identifier by pasting this code:
#[derive(Clone, Copy, Debug)]
pub struct SignalId(u64);
Why Add a Placeholder Module?
The event type can refer to SignalId before the generic signal implementation exists. This preserves the module boundary that the next step expands.
- Save src/signal.rs.
- Replace the generated contents of src/main.rs with this application entry point:
mod runtime;
mod signal;
mod ui;
use runtime::GuiApp;
use signal::SignalId;
use ui::UiWorld;
use winit::event_loop::{ControlFlow, EventLoop};
#[derive(Clone, Copy, Debug)]
pub enum UiEvent {
SignalChanged(SignalId),
}
fn main() {
let event_loop = EventLoop::<UiEvent>::with_user_event()
.build()
.expect("failed to create the winit event loop");
event_loop.set_control_flow(ControlFlow::Wait);
let ui = UiWorld::counter(event_loop.create_proxy());
let mut app = GuiApp::new(ui);
event_loop
.run_app(&mut app)
.expect("the winit event loop failed");
}
What Starts the Runtime?
- The module declarations compile the runtime, signal placeholder, and retained UI together.
- The typed event loop creates the proxy passed into UiWorld::counter.
- The ControlFlow::Wait policy lets the application sleep until an event arrives.
- The run_app call hands lifecycle control to GuiApp.
- Save src/main.rs.
- Check the complete application from the terminal panel you used earlier by running:
cargo check
What Should the Check Prove?
Cargo should finish successfully after compiling the new modules. Warnings about the unused signal identifier are expected because reactive updates arrive in the next step.
Does Cargo Report an Error?
- Check that all three files sit directly inside src.
- Compare the module names in src/main.rs with the three file names.
- Check the first reported line before addressing later errors.
Still stuck? Help me diagnose the first cargo check error in this Rust GUI project.
✔️ Cargo check succeeds
The retained widget model and native render lifecycle compile as one application.
ⓧ I'd like to double check the startup code
#[derive(Clone, Copy, Debug)]
pub struct SignalId(u64);
mod runtime;
mod signal;
mod ui;
use runtime::GuiApp;
use signal::SignalId;
use ui::UiWorld;
use winit::event_loop::{ControlFlow, EventLoop};
#[derive(Clone, Copy, Debug)]
pub enum UiEvent {
SignalChanged(SignalId),
}
fn main() {
let event_loop = EventLoop::<UiEvent>::with_user_event()
.build()
.expect("failed to create the winit event loop");
event_loop.set_control_flow(ControlFlow::Wait);
let ui = UiWorld::counter(event_loop.create_proxy());
let mut app = GuiApp::new(ui);
event_loop
.run_app(&mut app)
.expect("the winit event loop failed");
}
The first run compiles a substantial graphics dependency graph, so plan for a few minutes before the window opens.
Before you run it, which three visual elements do you expect the ECS query passes to paint?
- Start the native counter from the terminal by running:
cargo run
What Should I See?
You should see a non-resizable 480 by 360 logical-pixel window titled Reactive ECS Counter.
The window should contain a dark panel, a cyan seven-segment zero, and a blue plus button. The terminal should report that the initial redraw was triggered by three dirty widgets.
- Close the counter window after confirming the three rendered elements.
Does the Window Fail to Render?
- Check the terminal for the first graphics or window creation message.
- Confirm that the Linux session is using the Vulkan-capable GPU drivers from your starting environment.
- Confirm that cargo check still succeeds before running the binary again.
Still stuck? Help me diagnose why the Reactive ECS Counter window does not render on Linux.
That is the first visible slice complete. Your retained widget entities now reach a real GPU-backed window through Vello and wgpu.
Next, you will add a signal and make the button respond while deliberately leaving the retained digit stale.
Demonstrate a Stale Signal
Your static counter already proves that the retained ECS widgets can reach a Vello scene.
This step tests whether changing a signal automatically changes the retained digit. You will keep the signal value separate from the value that the digit paints.
In this step, get ready to:
- Build generic read and write signal handles.
- Connect pointer interaction to the retained button entity.
- Compare the signal notification with the displayed digit.
Build the signal handles
A signal stores a value behind shared handles. The read handle retrieves the latest value while the write handle changes it.
Why queue a typed event?
The write handle sends a typed event through the winit event-loop proxy. This keeps UI-world mutation on the event-loop thread.
Direct callbacks into the world would mix signal updates with ECS borrowing. The event queue preserves one clear mutation boundary.
- Switch back to the terminal running the counter from earlier.
- Stop the running application by pressing Ctrl+C.
- Return to src/signal.rs in your editor.
- Replace the placeholder module with the imports and signal identity code below:
use std::sync::{
Arc, RwLock,
atomic::{AtomicU64, Ordering},
};
use winit::event_loop::EventLoopProxy;
use crate::UiEvent;
static NEXT_SIGNAL_ID: AtomicU64 = AtomicU64::new(1);
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct SignalId(u64);
impl SignalId {
fn next() -> Self {
Self(NEXT_SIGNAL_ID.fetch_add(1, Ordering::Relaxed))
}
}
How signal identity works
- The atomic counter allocates one stable ID for each signal.
- The relaxed ordering is sufficient because the atomic value only provides unique IDs.
- The derived traits let the runtime copy each ID into a typed event.
- Add the cloneable read handle directly below the SignalId implementation:
#[derive(Clone)]
pub struct ReadSignal<T> {
id: SignalId,
value: Arc<RwLock<T>>,
}
impl<T: Clone> ReadSignal<T> {
pub fn get(&self) -> T {
self.value
.read()
.expect("signal read lock was poisoned")
.clone()
}
pub const fn id(&self) -> SignalId {
self.id
}
}
What does the read handle do?
- The shared pointer lets every cloned handle reach the same stored value.
- The read lock allows concurrent readers.
- The get() method returns an owned clone so no lock guard escapes into widget code.
- Save src/signal.rs.
- Check the first half of the module by running:
cargo check
What does this check prove?
Cargo checks that the new signal identity and read handle compile with the existing application. A successful finish proves the shared read path is valid Rust.
- Add the write handle directly below the read handle:
#[derive(Clone)]
pub struct WriteSignal<T> {
id: SignalId,
value: Arc<RwLock<T>>,
proxy: EventLoopProxy<UiEvent>,
}
impl<T> WriteSignal<T> {
pub fn update(&self, update: impl FnOnce(&mut T)) {
{
let mut value = self
.value
.write()
.expect("signal write lock was poisoned");
update(&mut value);
}
let _ = self.proxy.send_event(UiEvent::SignalChanged(self.id));
}
}
How updates reach the event loop
- The write lock gives the update closure exclusive access to the stored value.
- The lock guard ends before the event is sent.
- The proxy sends the changed signal ID without directly borrowing the ECS world.
- Add the constructor at the end of src/signal.rs so both handles share one value:
pub fn signal<T>(
initial: T,
proxy: EventLoopProxy<UiEvent>,
) -> (ReadSignal<T>, WriteSignal<T>) {
let id = SignalId::next();
let value = Arc::new(RwLock::new(initial));
(
ReadSignal {
id,
value: Arc::clone(&value),
},
WriteSignal { id, value, proxy },
)
}
Why return two handles?
The read handle belongs with state that consumes the value. The write handle belongs with the action that changes it.
Both handles share one lock-protected allocation. Their common ID connects future notifications to the correct subscriber.
- Save src/signal.rs.
- Verify the complete signal module by running:
cargo check
What should the check confirm?
Cargo should finish successfully. This confirms that the generic handles share a value and can send typed UI events.
Seeing a signal compilation error?
- Check that AtomicU64 and Ordering are imported from std::sync::atomic.
- Check that EventLoopProxy uses the existing UiEvent type.
- Confirm that SignalId remains public because src/main.rs imports it.
Still stuck? Help me debug the generic signal module.
✔️ Awesome, I've got everything!
Great. Save src/signal.rs before connecting the handles to your widgets.
ⓧ I'd like to double check the full code
use std::sync::{
Arc, RwLock,
atomic::{AtomicU64, Ordering},
};
use winit::event_loop::EventLoopProxy;
use crate::UiEvent;
static NEXT_SIGNAL_ID: AtomicU64 = AtomicU64::new(1);
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct SignalId(u64);
impl SignalId {
fn next() -> Self {
Self(NEXT_SIGNAL_ID.fetch_add(1, Ordering::Relaxed))
}
}
#[derive(Clone)]
pub struct ReadSignal<T> {
id: SignalId,
value: Arc<RwLock<T>>,
}
impl<T: Clone> ReadSignal<T> {
pub fn get(&self) -> T {
self.value
.read()
.expect("signal read lock was poisoned")
.clone()
}
pub const fn id(&self) -> SignalId {
self.id
}
}
#[derive(Clone)]
pub struct WriteSignal<T> {
id: SignalId,
value: Arc<RwLock<T>>,
proxy: EventLoopProxy<UiEvent>,
}
impl<T> WriteSignal<T> {
pub fn update(&self, update: impl FnOnce(&mut T)) {
{
let mut value = self
.value
.write()
.expect("signal write lock was poisoned");
update(&mut value);
}
let _ = self.proxy.send_event(UiEvent::SignalChanged(self.id));
}
}
pub fn signal<T>(
initial: T,
proxy: EventLoopProxy<UiEvent>,
) -> (ReadSignal<T>, WriteSignal<T>) {
let id = SignalId::next();
let value = Arc::new(RwLock::new(initial));
(
ReadSignal {
id,
value: Arc::clone(&value),
},
WriteSignal { id, value, proxy },
)
}
Connect pointer input to retained widgets
The button needs retained interaction state so its appearance can survive between events. Its semantic action holds the write handle that increments the counter.
- Return to src/ui.rs.
- Update the imports at the top of the file to include the event-loop proxy and signal handles:
use bevy_ecs::prelude::*;
use vello::{
Scene,
kurbo::{Affine, Rect},
peniko::{Color, Fill},
};
use winit::event_loop::EventLoopProxy;
use crate::{
UiEvent,
signal::{ReadSignal, SignalId, WriteSignal, signal},
};
Why does the UI module need these types?
The proxy enters through UiWorld::counter() when the widget tree is created. The signal handles then live inside the digit and button components.
- Replace the existing digit and button component definitions with this retained state:
#[derive(Component)]
struct Digit {
source: ReadSignal<u8>,
displayed: u8,
}
#[derive(Component)]
struct Button;
#[derive(Component, Default)]
struct Interaction {
hovered: bool,
pressed: bool,
}
#[derive(Component)]
struct OnClick {
action: WriteSignal<u8>,
}
#[derive(Component)]
struct SignalBinding(SignalId);
What lives in each component?
- The digit stores its signal reader in source.
- The digit paints the separate displayed field.
- The button stores pointer state in Interaction.
- The button stores its semantic counter action in OnClick.
- Update the start of UiWorld::counter() so it creates the counter signal:
pub fn counter(proxy: EventLoopProxy<UiEvent>) -> Self {
let (count, set_count) = signal(0_u8, proxy);
let mut world = World::new();
What does the constructor now own?
The widget tree creates one counter signal with an initial value of 0. The two handles can now be assigned to different entities.
- Replace the existing digit spawn with the signal-backed digit entity:
world.spawn((
Digit {
displayed: count.get(),
source: count.clone(),
},
SignalBinding(count.id()),
Bounds::new(155.0, 70.0, 170.0, 140.0),
PaintState::dirty(),
));
Why store two digit values?
The read handle can retrieve the changing source value. The retained displayed field remains the value used during painting.
The binding records which signal belongs to this entity. That relationship becomes visible during the experiment.
- Replace the existing button spawn with the interactive button entity:
world.spawn((
Button,
OnClick { action: set_count },
Interaction::default(),
Bounds::new(190.0, 235.0, 100.0, 60.0),
PaintState::dirty(),
));
How the button owns its action
The button keeps the write handle inside OnClick. Pointer code can trigger the action without knowing how the counter is stored.
- Save src/ui.rs.
- Check the new component model by running:
cargo check
What does this check prove?
A successful check confirms that the digit and button can own their respective signal handles. It also confirms that every spawned component tuple is valid.
Hit testing converts a pointer position into a simple question: does this point fall inside the button bounds? A state change marks the button paint state as dirty.
- Add pointer_moved() inside the existing impl UiWorld block:
pub fn pointer_moved(&mut self, x: f64, y: f64) -> bool {
let mut changed = false;
let mut query = self
.world
.query::<(&Bounds, &mut Interaction, &mut PaintState)>();
for (bounds, mut interaction, mut paint) in query.iter_mut(&mut self.world) {
let hovered = bounds.contains(x, y);
if interaction.hovered != hovered {
interaction.hovered = hovered;
paint.dirty = true;
changed = true;
}
}
changed
}
How hover hit testing works
- The query selects entities that have bounds plus interaction state.
- The bounds check determines the next hover value.
- The method returns true only when the painted interaction state changes.
- Add pointer_button() directly below pointer_moved() using this complete method:
pub fn pointer_button(&mut self, pressed: bool) -> bool {
let mut actions = Vec::new();
let mut changed = false;
let mut query = self.world.query::<(
&mut Interaction,
&OnClick,
&mut PaintState,
)>();
for (mut interaction, on_click, mut paint) in query.iter_mut(&mut self.world) {
if pressed {
let next_pressed = interaction.hovered;
if interaction.pressed != next_pressed {
interaction.pressed = next_pressed;
paint.dirty = true;
changed = true;
}
} else {
let activate = interaction.pressed && interaction.hovered;
if interaction.pressed {
interaction.pressed = false;
paint.dirty = true;
changed = true;
}
if activate {
actions.push(on_click.action.clone());
}
}
}
for action in actions {
action.update(|value| *value = (*value + 1) % 10);
}
changed
}
How a pointer release becomes an action
- A press begins only while the pointer is inside the button.
- A release activates the button only when the pointer is still inside.
- The cloned action runs after the ECS query releases its mutable borrows.
- The update wraps the counter after 9.
- Update the button query inside paint_scene() so painting receives the interaction state:
{
let mut query = self.world.query::<(&Button, &Bounds, &Interaction)>();
for (_, bounds, interaction) in query.iter(&self.world) {
draw_button(&mut logical_scene, *bounds, interaction);
}
}
Why painting reads interaction state
The renderer queries the retained hover and press values during each requested redraw. The widget appearance now comes from ECS data.
- Change the draw_button() signature so it accepts the interaction component:
fn draw_button(scene: &mut Scene, bounds: Bounds, interaction: &Interaction) {
What changes in the paint helper?
The helper now receives retained input state with the button bounds. It can choose a paint color without reading raw window events.
- Replace the fixed blue button fill with this state-based color selection:
let color = if interaction.pressed {
Color::from_rgb8(36, 99, 180)
} else if interaction.hovered {
Color::from_rgb8(72, 149, 239)
} else {
Color::from_rgb8(55, 118, 210)
};
fill_rect(scene, bounds.rect(), color);
How the button communicates state
The default color shows an idle button. Hover uses a brighter blue.
Press uses a darker blue. Each color makes the retained interaction state visible.
- Save src/ui.rs.
- Verify the interaction model by running:
cargo check
What should compile now?
Cargo should accept the hit-testing methods and the state-aware paint helper. The UI module can now change button state in response to logical pointer coordinates.
Seeing an ECS borrow error?
- Confirm that button actions are cloned into actions during the query.
- Confirm that the action updates run after the query loop ends.
- Check that Interaction derives Default.
Still stuck? Help me resolve the ECS interaction error.
Run the stale-state demonstration
The runtime now needs to route mouse events into UiWorld. It will also print each typed signal notification for this experiment.
- Return to src/runtime.rs.
- Update the winit imports so the runtime can identify left-button input:
use winit::{
application::ApplicationHandler,
dpi::LogicalSize,
event::{MouseButton, WindowEvent},
event_loop::ActiveEventLoop,
window::{Window, WindowId},
};
Why identify the mouse button?
The runtime receives several pointer event types. Matching MouseButton::Left keeps activation limited to the primary pointer button.
- Add these cursor event arms inside the existing match event block:
WindowEvent::CursorMoved { position, .. } => {
let scale = state.window.scale_factor();
if self.ui.pointer_moved(position.x / scale, position.y / scale) {
state.window.request_redraw();
}
}
WindowEvent::CursorLeft { .. } => {
if self.ui.pointer_moved(-1.0, -1.0) {
state.window.request_redraw();
}
}
How cursor coordinates reach the UI
- The window reports physical pointer coordinates.
- Dividing by the scale factor produces the logical coordinates used by widget bounds.
- Leaving the window sends an out-of-bounds point that clears hover state.
- Add the left-button event arm alongside the cursor event arms:
WindowEvent::MouseInput {
state: button_state,
button: MouseButton::Left,
..
} => {
if self.ui.pointer_button(button_state.is_pressed()) {
state.window.request_redraw();
}
}
Why request an interaction redraw?
Pressing or releasing can change the button paint state. The returned boolean prevents redraw requests when no retained interaction value changed.
- Add this temporary user_event() callback to the ApplicationHandler<UiEvent> implementation:
fn user_event(&mut self, _event_loop: &ActiveEventLoop, event: UiEvent) {
match event {
UiEvent::SignalChanged(signal_id) => {
println!("signal changed: {signal_id:?}");
}
}
}
What does this temporary callback do?
The callback proves that the write handle reached the event loop. It only prints the changed signal ID.
- Save src/runtime.rs.
- Save src/ui.rs.
Before you run the application, do you think changing the signal also changes the value painted by the retained digit?
- Launch the updated counter by running:
cargo run
What does the experiment reveal?
Move the pointer over the plus button. You will see the button turn brighter.
Press the left mouse button. You will see the button turn darker.
Release over the button. You will see a signal line in the terminal while the displayed digit remains 0.
The stale digit is the intended result. The signal value changed while Digit.displayed kept the retained value used by the paint path.
Why does the digit stay stale?
The signal notification carries an ID into the event loop. No code copies the current source value into Digit.displayed.
The signal event also schedules no signal-driven redraw. The retained view therefore has no synchronization boundary.
No hover color or signal line?
- Confirm that pointer coordinates are divided by state.window.scale_factor() before hit testing.
- Confirm that the button entity includes both Interaction::default() and OnClick.
- Release the left mouse button while the pointer remains inside the blue button.
Still stuck? Help me debug the stale-signal demonstration.
You have exposed the exact gap that reactive retained interfaces must solve. Next, you will bridge the signal event into targeted ECS invalidation.
Bridge Signals Into ECS Invalidation
The stale digit from the previous step revealed a missing bridge. A changed signal never reached the retained widget state.
This step adds targeted invalidation to the ECS world. A matching event copies the latest value into the digit before winit schedules one redraw.
In this step, get ready to:
- Synchronize widgets whose signal bindings match the changed signal.
- Request one redraw when a subscribed widget becomes dirty.
- Clear paint flags after each rendered scene.
Synchronize the subscribed digit
Invalidation turns a signal notification into a narrow ECS query. Matching bindings copy fresh data into retained state. Only those entities receive a dirty paint flag.
- Return to src/ui.rs in your editor.
- Place the following method inside impl UiWorld after pointer_button:
pub fn invalidate(&mut self, signal_id: SignalId) -> usize {
let mut invalidated = 0;
let mut query = self
.world
.query::<(&SignalBinding, &mut Digit, &mut PaintState)>();
for (binding, mut digit, mut paint) in query.iter_mut(&mut self.world) {
if binding.0 == signal_id {
digit.displayed = digit.source.get();
paint.dirty = true;
invalidated += 1;
}
}
invalidated
}
How Targeted Invalidation Works
- The query selects entities containing SignalBinding, Digit, and PaintState components.
- The ID comparison limits synchronization to widgets subscribed to the changed signal.
- The source.get() call copies the current signal value into Digit.displayed.
- The returned count tells the runtime whether any widget requires a redraw.
- Save src/ui.rs.
- Close the running counter window.
- Check the new ECS query with the compiler by running:
cargo check
What This Check Proves
Cargo checks the new method against the existing component types. A successful check confirms that the query can mutate the digit while reading its binding.
Cargo should finish successfully. The new invalidation path now compiles with the retained widget world.
Does the Invalidation Method Fail to Compile?
- Confirm that invalidate sits inside the existing impl UiWorld block.
- Check that the method parameter uses the existing SignalId import.
- Compare the query tuple with the existing SignalBinding, Digit, and PaintState component names.
Need another pair of eyes? Help me debug my UiWorld invalidate method.
Good progress. The ECS world can now find the exact digit subscribed to a changed signal.
Request a redraw for matching signals
The invalidation method changes retained state without presenting a new frame by itself. The event handler now becomes the boundary that schedules rendering when the invalidated count is greater than zero.
- Return to src/runtime.rs in your editor.
- Locate the user_event method that currently logs the signal event.
- Replace the entire user_event method with the following implementation:
fn user_event(&mut self, _event_loop: &ActiveEventLoop, event: UiEvent) {
match event {
UiEvent::SignalChanged(signal_id) => {
let invalidated = self.ui.invalidate(signal_id);
if invalidated > 0 {
println!("signal invalidated {invalidated} dirty widget(s)");
self.request_redraw();
}
}
}
}
How the Event Bridge Works
- The typed event carries the stable SignalId into the UI thread.
- The call to invalidate synchronizes matching retained widget state.
- The count check prevents unrelated signal events from scheduling a frame.
- The request_redraw() call asks winit to deliver one redraw event.
- Save src/runtime.rs.
Before you run the counter, do you expect every signal event to redraw the window or only events with a matching binding?
- Run the counter with the new event bridge by running:
cargo run
What This Run Tests
Cargo builds the updated runtime before opening the counter window. The running app can now carry a signal notification through ECS invalidation to a requested redraw.
- Move the pointer over the blue plus button.
- Click the plus button once.
You should see the digit advance from 0 to 1. The terminal should print signal invalidated 1 dirty widget(s).
- Close the counter window after confirming the digit changed.
Does the Digit Still Show Zero?
- Confirm that user_event passes the received signal_id into self.ui.invalidate(signal_id).
- Check that self.request_redraw() remains inside the positive invalidation-count condition.
- Confirm that invalidate assigns digit.source.get() to digit.displayed.
Still seeing stale state? Help me trace my signal event into ECS invalidation.
That stale-state bug is fixed. The displayed digit now follows the signal through a single subscribed entity.
Clear paint flags after each scene
A dirty flag records pending paint work for one frame. Clearing consumed flags keeps later redraw diagnostics tied to the event that actually changed a widget.
- Return to src/ui.rs in your editor.
- Change the return type of paint_scene to usize.
- Insert the following dirty-widget counter at the beginning of paint_scene:
let dirty_count = {
let mut count = 0;
let mut query = self.world.query::<&mut PaintState>();
for mut paint in query.iter_mut(&mut self.world) {
if paint.dirty {
count += 1;
paint.dirty = false;
}
}
count
};
Why Count Before Clearing?
The query captures how many widgets requested the current paint pass. It clears each consumed flag so the next frame starts with no leftover paint work.
- Make dirty_count the final expression in paint_scene after the existing scene encoding and tracker cleanup.
- Save src/ui.rs.
- Check the updated return value with the compiler by running:
cargo check
What the Compiler Checks
The check confirms that paint_scene returns the dirty-widget count on every completed path. It also verifies that the mutable paint query finishes before the scene queries begin.
Cargo should finish successfully. Each Vello scene pass now consumes the paint flags that triggered it.
- Return to src/runtime.rs in your editor.
- Assign the result of the existing paint_scene call inside render to dirty_count.
- Add the following diagnostic immediately after that call:
if dirty_count > 0 {
println!("redraw triggered by {dirty_count} dirty widget(s)");
}
What the Redraw Diagnostic Shows
The message reports the paint work consumed by this render pass. Signal updates should report one dirty widget because only the bound digit receives invalidation.
- Save src/runtime.rs.
Before the final run, how many dirty widgets do you expect one counter signal update to report?
- Run the completed invalidation path by running:
cargo run
What the Final Run Proves
The counter now connects retained state synchronization with on-demand rendering. Each changed widget leaves a visible diagnostic trail through the terminal.
- Move the pointer onto the plus button.
- Press and hold the left mouse button.
You should see the button switch to its pressed color.
- Release the left mouse button over the plus button.
You should see the digit advance by one. The terminal should print signal invalidated 1 dirty widget(s) followed by redraw triggered by 1 dirty widget(s).
- Click the plus button until the digit reaches 9.
- Click the plus button once more.
You should see the digit wrap from 9 to 0. Every signal-driven terminal line should continue to report one dirty widget.
- Move the pointer away from the button.
- Leave the counter untouched for five seconds.
The terminal should remain quiet until another event changes paint state. This confirms that the counter redraws on demand instead of using a continuous polling loop.
Do the Dirty Counts Keep Growing?
- Confirm that the dirty-widget query sets paint.dirty to false after incrementing the count.
- Check that dirty_count is local to each paint_scene call.
- Confirm that signal invalidation marks only the matching digit entity as dirty.
Need help isolating the extra paint work? Help me debug growing dirty-widget counts.
Your counter now synchronizes retained widget state through targeted invalidation. Next up, you will harden the window and render lifecycle for real desktop conditions.
Harden the Window and Render Lifecycle
Your winit signal bridge now updates the retained ECS digit through one targeted invalidation. Vello redraws the counter only after a relevant event.
Desktop DPI scaling changes the relationship between physical pointer pixels and logical widget bounds.
Minimizing a window can produce a zero-sized wgpu surface. Restoring it can require a surface refresh.
Suspension releases graphics state. This step handles each lifecycle boundary without starting a busy render loop.
In this step, get ready to:
- Convert physical pointer positions into logical widget coordinates.
- Recover the render surface across resize and acquisition states.
- Release graphics state during suspension and verify on-demand rendering.
Scale logical coordinates for DPI
Your widget bounds stay in logical pixels. Pointer events arrive in physical pixels, so the runtime must convert them before hit testing.
Why scale the scene too?
Pointer conversion keeps input aligned with logical bounds. Scaling the Vello scene maps those same bounds back onto the physical surface.
Both directions use one scale factor. The button you see remains the button you can click.
- Switch back to src/ui.rs in your editor.
- Locate the paint_scene method.
- Update its signature to accept the window scale factor using this reference line:
pub fn paint_scene(&mut self, scene: &mut Scene, scale_factor: f64) -> usize {
What does this change do?
The new scale_factor parameter gives scene encoding the conversion value supplied by the window.
- Find the final scene.append call in paint_scene.
- Replace that call with this scaled scene composition:
scene.append(&logical_scene, Some(Affine::scale(scale_factor)));
How does scene scaling work?
The ECS still stores the panel, digit, and button in logical coordinates. Affine::scale(scale_factor) transforms the completed scene once before Vello renders it.
- Switch back to src/runtime.rs.
- Locate the dirty_count assignment inside render.
- Pass the current window scale factor into paint_scene using this reference snippet:
let dirty_count = self
.ui
.paint_scene(&mut self.scene, state.window.scale_factor());
Where does the scale factor come from?
The window reports the current mapping between logical pixels and physical pixels. Reading it during rendering keeps the scene transform tied to the active window.
- Locate the WindowEvent::CursorMoved match arm.
- Replace that arm with this logical-coordinate conversion:
WindowEvent::CursorMoved { position, .. } => {
let scale = state.window.scale_factor();
if self.ui.pointer_moved(position.x / scale, position.y / scale) {
state.window.request_redraw();
}
}
What happens to pointer coordinates?
- The position values represent physical surface pixels.
- Dividing by scale converts each value into the logical coordinate space used by Bounds.
- A hover change requests one redraw through the existing event-driven path.
- Save src/ui.rs.
- Save src/runtime.rs.
- Run the counter from the reactive-ecs-gui folder using this command:
cargo run
What should you see?
You should see the dark counter panel in a 480 by 360 logical-pixel window. The plus button should change color when your pointer crosses its visible edges.
Is the pointer offset from the button?
- Confirm that both position.x and position.y are divided by scale before hit testing.
- Confirm that paint_scene receives state.window.scale_factor().
- Confirm that the final scene append uses Affine::scale(scale_factor).
Still misaligned? Help me debug DPI-aware hit testing in this winit and Vello counter
Use these tabs to compare the completed widget file before moving on.
✔️ Awesome, I've got everything!
Your logical widget tree now renders through the window scale factor. Keep both edited files saved.
ⓧ I'd like to double check the full code
use bevy_ecs::prelude::*;
use vello::{
Scene,
kurbo::{Affine, Rect},
peniko::{Color, Fill},
};
use winit::event_loop::EventLoopProxy;
use crate::{
UiEvent,
signal::{ReadSignal, SignalId, WriteSignal, signal},
};
#[derive(Component, Clone, Copy)]
struct Bounds {
x: f64,
y: f64,
width: f64,
height: f64,
}
impl Bounds {
const fn new(x: f64, y: f64, width: f64, height: f64) -> Self {
Self {
x,
y,
width,
height,
}
}
fn rect(self) -> Rect {
Rect::new(self.x, self.y, self.x + self.width, self.y + self.height)
}
fn contains(self, x: f64, y: f64) -> bool {
x >= self.x
&& x <= self.x + self.width
&& y >= self.y
&& y <= self.y + self.height
}
}
#[derive(Component)]
struct Panel;
#[derive(Component)]
struct Digit {
source: ReadSignal<u8>,
displayed: u8,
}
#[derive(Component)]
struct Button;
#[derive(Component, Default)]
struct Interaction {
hovered: bool,
pressed: bool,
}
#[derive(Component)]
struct OnClick {
action: WriteSignal<u8>,
}
#[derive(Component)]
struct SignalBinding(SignalId);
#[derive(Component)]
struct PaintState {
dirty: bool,
}
impl PaintState {
const fn dirty() -> Self {
Self { dirty: true }
}
}
pub struct UiWorld {
world: World,
}
impl UiWorld {
pub fn counter(proxy: EventLoopProxy<UiEvent>) -> Self {
let (count, set_count) = signal(0_u8, proxy);
let mut world = World::new();
world.spawn((
Panel,
Bounds::new(60.0, 40.0, 360.0, 280.0),
PaintState::dirty(),
));
world.spawn((
Digit {
displayed: count.get(),
source: count.clone(),
},
SignalBinding(count.id()),
Bounds::new(155.0, 70.0, 170.0, 140.0),
PaintState::dirty(),
));
world.spawn((
Button,
OnClick { action: set_count },
Interaction::default(),
Bounds::new(190.0, 235.0, 100.0, 60.0),
PaintState::dirty(),
));
Self { world }
}
pub fn pointer_moved(&mut self, x: f64, y: f64) -> bool {
let mut changed = false;
let mut query = self
.world
.query::<(&Bounds, &mut Interaction, &mut PaintState)>();
for (bounds, mut interaction, mut paint) in query.iter_mut(&mut self.world) {
let hovered = bounds.contains(x, y);
if interaction.hovered != hovered {
interaction.hovered = hovered;
paint.dirty = true;
changed = true;
}
}
changed
}
pub fn pointer_button(&mut self, pressed: bool) -> bool {
let mut actions = Vec::new();
let mut changed = false;
let mut query = self
.world
.query::<(&mut Interaction, &OnClick, &mut PaintState)>();
for (mut interaction, on_click, mut paint) in query.iter_mut(&mut self.world) {
if pressed {
let next_pressed = interaction.hovered;
if interaction.pressed != next_pressed {
interaction.pressed = next_pressed;
paint.dirty = true;
changed = true;
}
} else {
let activate = interaction.pressed && interaction.hovered;
if interaction.pressed {
interaction.pressed = false;
paint.dirty = true;
changed = true;
}
if activate {
actions.push(on_click.action.clone());
}
}
}
for action in actions {
action.update(|value| *value = (*value + 1) % 10);
}
changed
}
pub fn invalidate(&mut self, signal_id: SignalId) -> usize {
let mut invalidated = 0;
let mut query = self
.world
.query::<(&SignalBinding, &mut Digit, &mut PaintState)>();
for (binding, mut digit, mut paint) in query.iter_mut(&mut self.world) {
if binding.0 == signal_id {
digit.displayed = digit.source.get();
paint.dirty = true;
invalidated += 1;
}
}
invalidated
}
pub fn paint_scene(&mut self, scene: &mut Scene, scale_factor: f64) -> usize {
let dirty_count = {
let mut count = 0;
let mut query = self.world.query::<&mut PaintState>();
for mut paint in query.iter_mut(&mut self.world) {
if paint.dirty {
count += 1;
paint.dirty = false;
}
}
count
};
let mut logical_scene = Scene::new();
{
let mut query = self.world.query::<(&Panel, &Bounds)>();
for (_, bounds) in query.iter(&self.world) {
fill_rect(
&mut logical_scene,
bounds.rect(),
Color::from_rgb8(42, 50, 70),
);
fill_rect(
&mut logical_scene,
Rect::new(
bounds.x + 3.0,
bounds.y + 3.0,
bounds.x + bounds.width - 3.0,
bounds.y + bounds.height - 3.0,
),
Color::from_rgb8(18, 23, 36),
);
}
}
{
let mut query = self.world.query::<(&Digit, &Bounds)>();
for (digit, bounds) in query.iter(&self.world) {
draw_digit(&mut logical_scene, *bounds, digit.displayed);
}
}
{
let mut query = self.world.query::<(&Button, &Bounds, &Interaction)>();
for (_, bounds, interaction) in query.iter(&self.world) {
draw_button(&mut logical_scene, *bounds, interaction);
}
}
scene.reset();
scene.append(&logical_scene, Some(Affine::scale(scale_factor)));
self.world.clear_trackers();
dirty_count
}
}
fn fill_rect(scene: &mut Scene, rect: Rect, color: Color) {
scene.fill(Fill::NonZero, Affine::IDENTITY, color, None, &rect);
}
fn draw_button(scene: &mut Scene, bounds: Bounds, interaction: &Interaction) {
fill_rect(
scene,
Rect::new(
bounds.x + 4.0,
bounds.y + 6.0,
bounds.x + bounds.width + 4.0,
bounds.y + bounds.height + 6.0,
),
Color::from_rgb8(7, 10, 20),
);
let color = if interaction.pressed {
Color::from_rgb8(36, 99, 180)
} else if interaction.hovered {
Color::from_rgb8(72, 149, 239)
} else {
Color::from_rgb8(55, 118, 210)
};
fill_rect(scene, bounds.rect(), color);
let center_x = bounds.x + bounds.width / 2.0;
let center_y = bounds.y + bounds.height / 2.0;
let icon = Color::from_rgb8(244, 248, 255);
fill_rect(
scene,
Rect::new(center_x - 18.0, center_y - 4.0, center_x + 18.0, center_y + 4.0),
icon,
);
fill_rect(
scene,
Rect::new(center_x - 4.0, center_y - 18.0, center_x + 4.0, center_y + 18.0),
icon,
);
}
fn draw_digit(scene: &mut Scene, bounds: Bounds, value: u8) {
const DIGITS: [[bool; 7]; 10] = [
[true, true, true, true, true, true, false],
[false, true, true, false, false, false, false],
[true, true, false, true, true, false, true],
[true, true, true, true, false, false, true],
[false, true, true, false, false, true, true],
[true, false, true, true, false, true, true],
[true, false, true, true, true, true, true],
[true, true, true, false, false, false, false],
[true, true, true, true, true, true, true],
[true, true, true, true, false, true, true],
];
fill_rect(scene, bounds.rect(), Color::from_rgb8(8, 13, 24));
let x = bounds.x + 20.0;
let y = bounds.y + 12.0;
let width = bounds.width - 40.0;
let height = bounds.height - 24.0;
let thickness = 14.0;
let middle = y + height / 2.0;
let segments = [
Rect::new(x + thickness, y, x + width - thickness, y + thickness),
Rect::new(
x + width - thickness,
y + thickness,
x + width,
middle - thickness / 2.0,
),
Rect::new(
x + width - thickness,
middle + thickness / 2.0,
x + width,
y + height - thickness,
),
Rect::new(
x + thickness,
y + height - thickness,
x + width - thickness,
y + height,
),
Rect::new(
x,
middle + thickness / 2.0,
x + thickness,
y + height - thickness,
),
Rect::new(x, y + thickness, x + thickness, middle - thickness / 2.0),
Rect::new(
x + thickness,
middle - thickness / 2.0,
x + width - thickness,
middle + thickness / 2.0,
),
];
for (active, segment) in DIGITS[usize::from(value % 10)].into_iter().zip(segments) {
let color = if active {
Color::from_rgb8(65, 225, 210)
} else {
Color::from_rgb8(20, 48, 54)
};
fill_rect(scene, segment, color);
}
}
What should you compare?
Confirm that the existing widget bounds remain unchanged. The lifecycle update only adds scale_factor to paint_scene and applies it when appending the logical scene.
Recover the surface across resize states
A minimized window may report a zero-sized drawable area. Vello's surface resize path requires non-zero dimensions, so the runtime filters resize events before touching the surface.
- In src/runtime.rs, locate the existing WindowEvent::Resized match arm.
- Replace that arm with this guarded resize path:
WindowEvent::Resized(size) if size.width > 0 && size.height > 0 => {
self.context
.resize_surface(&mut state.surface, size.width, size.height);
state.window.request_redraw();
}
Why guard both dimensions?
The match guard ignores resize events whenever either dimension is zero. A valid resize updates Vello's surface targets before requesting one redraw.
Surface acquisition can also report that its stored configuration is no longer current. Each result needs a specific recovery path before the frame can be presented.
- Locate the surface.surface.get_current_texture() call inside render.
- Replace its result handling with this complete surface-state match:
let surface_texture = match surface.surface.get_current_texture() {
CurrentSurfaceTexture::Success(texture) => texture,
CurrentSurfaceTexture::Suboptimal(_) | CurrentSurfaceTexture::Outdated => {
self.context.configure_surface(surface);
state.window.request_redraw();
return;
}
CurrentSurfaceTexture::Occluded | CurrentSurfaceTexture::Timeout => return,
CurrentSurfaceTexture::Lost => {
eprintln!("wgpu surface was lost; restore the window to recreate it");
return;
}
CurrentSurfaceTexture::Validation => {
eprintln!("wgpu rejected the current surface texture");
return;
}
};
How does surface recovery work?
- A successful acquisition returns the texture used for the current frame.
- An outdated or suboptimal surface is reconfigured before one replacement redraw is requested.
- An occluded or timed-out frame is skipped because there is nothing useful to present.
- A lost or rejected surface produces a diagnostic before rendering stops for that frame.
- Save src/runtime.rs.
Before you run this check, make a quick prediction about what the counter should do after a minimize and restore cycle.
- Start the counter from the reactive-ecs-gui folder by running:
cargo run
What does this check prove?
This run exercises the same render path with the new resize guard and acquisition handling. The window stays event-driven throughout the check.
- Minimize the counter window.
- Restore the counter window.
- Move the pointer across the plus button.
- Click the plus button once.
You should see the panel redraw after restoration. The button should still respond to hover before the digit advances by one.
Does the restored window stay blank?
- Confirm that the non-zero resize arm calls resize_surface before requesting a redraw.
- Confirm that outdated and suboptimal acquisitions call configure_surface.
- Check the terminal for a surface diagnostic after restoring the window.
Need another pair of eyes? Help me debug Vello surface recovery after minimizing a winit window
Preserve ownership and verify on-demand rendering
The render surface depends on its window remaining alive. Field order makes that ownership relationship explicit during cleanup.
- Find the RenderState definition near the top of src/runtime.rs.
- Set its field order to match this reference snippet:
struct RenderState {
surface: RenderSurface<'static>,
window: Arc<Window>,
}
Why does field order matter?
The surface appears before the owning window in RenderState. Cleanup drops the surface state while the Arc<Window> still keeps the window alive.
A suspended application should release graphics state. The ECS world and signal handles remain inside GuiApp for the next resume cycle.
- Locate the suspended callback at the bottom of src/runtime.rs.
- Replace its body with this cleanup path:
fn suspended(&mut self, _event_loop: &ActiveEventLoop) {
self.renderer = None;
self.state = None;
}
What survives suspension?
The renderer and surface state are released. The retained UI world stays available, so resumed can rebuild only the graphics resources.
- Save src/runtime.rs.
Use these tabs to compare the complete runtime before the final check.
✔️ Awesome, I've got everything!
Your runtime now keeps coordinate conversion, rendering, recovery, invalidation, and suspension behind one lifecycle boundary.
ⓧ I'd like to double check the full code
use std::sync::Arc;
use vello::{
AaConfig, RenderParams, Renderer, RendererOptions, Scene,
peniko::Color,
util::{RenderContext, RenderSurface},
};
use wgpu::CurrentSurfaceTexture;
use winit::{
application::ApplicationHandler,
dpi::LogicalSize,
event::{MouseButton, WindowEvent},
event_loop::ActiveEventLoop,
window::{Window, WindowId},
};
use crate::{UiEvent, ui::UiWorld};
struct RenderState {
surface: RenderSurface<'static>,
window: Arc<Window>,
}
pub struct GuiApp {
context: RenderContext,
renderer: Option<Renderer>,
state: Option<RenderState>,
scene: Scene,
ui: UiWorld,
}
impl GuiApp {
pub fn new(ui: UiWorld) -> Self {
Self {
context: RenderContext::new(),
renderer: None,
state: None,
scene: Scene::new(),
ui,
}
}
fn request_redraw(&self) {
if let Some(state) = &self.state {
state.window.request_redraw();
}
}
fn render(&mut self) {
let (Some(state), Some(renderer)) = (&mut self.state, &mut self.renderer) else {
return;
};
let dirty_count = self
.ui
.paint_scene(&mut self.scene, state.window.scale_factor());
if dirty_count > 0 {
println!("redraw triggered by {dirty_count} dirty widget(s)");
}
let surface = &mut state.surface;
let device_handle = &self.context.devices[surface.dev_id];
let params = RenderParams {
base_color: Color::from_rgb8(10, 14, 24),
width: surface.config.width,
height: surface.config.height,
antialiasing_method: AaConfig::Area,
};
if let Err(error) = renderer.render_to_texture(
&device_handle.device,
&device_handle.queue,
&self.scene,
&surface.target_view,
¶ms,
) {
eprintln!("Vello render failed: {error}");
return;
}
let surface_texture = match surface.surface.get_current_texture() {
CurrentSurfaceTexture::Success(texture) => texture,
CurrentSurfaceTexture::Suboptimal(_) | CurrentSurfaceTexture::Outdated => {
self.context.configure_surface(surface);
state.window.request_redraw();
return;
}
CurrentSurfaceTexture::Occluded | CurrentSurfaceTexture::Timeout => return,
CurrentSurfaceTexture::Lost => {
eprintln!("wgpu surface was lost; restore the window to recreate it");
return;
}
CurrentSurfaceTexture::Validation => {
eprintln!("wgpu rejected the current surface texture");
return;
}
};
let mut encoder = device_handle
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("reactive-ecs-gui surface blit"),
});
let surface_view = surface_texture
.texture
.create_view(&wgpu::TextureViewDescriptor::default());
surface.blitter.copy(
&device_handle.device,
&mut encoder,
&surface.target_view,
&surface_view,
);
device_handle.queue.submit([encoder.finish()]);
state.window.pre_present_notify();
surface_texture.present();
}
}
impl ApplicationHandler<UiEvent> for GuiApp {
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
if self.state.is_some() {
return;
}
let attributes = Window::default_attributes()
.with_title("Reactive ECS Counter")
.with_inner_size(LogicalSize::new(480.0, 360.0))
.with_resizable(false);
let window = Arc::new(
event_loop
.create_window(attributes)
.expect("failed to create the application window"),
);
let size = window.inner_size();
let surface = pollster::block_on(self.context.create_surface(
window.clone(),
size.width,
size.height,
wgpu::PresentMode::AutoVsync,
))
.expect("failed to create a Vello render surface");
let renderer = Renderer::new(
&self.context.devices[surface.dev_id].device,
RendererOptions::default(),
)
.expect("failed to create the Vello renderer");
self.renderer = Some(renderer);
self.state = Some(RenderState {
surface,
window: window.clone(),
});
window.request_redraw();
}
fn window_event(
&mut self,
event_loop: &ActiveEventLoop,
window_id: WindowId,
event: WindowEvent,
) {
let Some(state) = &mut self.state else {
return;
};
if state.window.id() != window_id {
return;
}
match event {
WindowEvent::CloseRequested => event_loop.exit(),
WindowEvent::Resized(size) if size.width > 0 && size.height > 0 => {
self.context
.resize_surface(&mut state.surface, size.width, size.height);
state.window.request_redraw();
}
WindowEvent::CursorMoved { position, .. } => {
let scale = state.window.scale_factor();
if self.ui.pointer_moved(position.x / scale, position.y / scale) {
state.window.request_redraw();
}
}
WindowEvent::CursorLeft { .. } => {
if self.ui.pointer_moved(-1.0, -1.0) {
state.window.request_redraw();
}
}
WindowEvent::MouseInput {
state: button_state,
button: MouseButton::Left,
..
} => {
if self.ui.pointer_button(button_state.is_pressed()) {
state.window.request_redraw();
}
}
WindowEvent::RedrawRequested => self.render(),
_ => {}
}
}
fn user_event(&mut self, _event_loop: &ActiveEventLoop, event: UiEvent) {
match event {
UiEvent::SignalChanged(signal_id) => {
let invalidated = self.ui.invalidate(signal_id);
if invalidated > 0 {
println!("signal invalidated {invalidated} dirty widget(s)");
self.request_redraw();
}
}
}
}
fn suspended(&mut self, _event_loop: &ActiveEventLoop) {
self.renderer = None;
self.state = None;
}
}
What should you compare?
Confirm that RenderState stores surface before window. Check the DPI conversion, resize guard, surface-state match, invalidation callback, and suspension cleanup against your file.
Before the final run, predict whether the counter can restore its panel without producing redraw diagnostics while it sits idle.
- Run the finished counter from the reactive-ecs-gui folder using:
cargo run
What does the final run verify?
The command builds the completed lifecycle code before starting the native window. Every following interaction tests a requested event instead of a continuous frame loop.
- Move the pointer around the panel at your current desktop scale factor.
- Press the plus button.
- Release the plus button.
- Minimize the counter window.
- Restore the counter window.
- Leave the restored window idle for a moment.
You should see the button remain aligned with the pointer. Its hover and press colors should respond before the digit advances.
The restored panel should render correctly. The terminal should stay quiet while the window is idle because ControlFlow::Wait keeps rendering event-driven.
That completes the hard lifecycle boundary. Your counter now survives real window conditions while preserving targeted signal invalidation.
Secret mission
Add Keyboard Activation
Your counter already turns pointer input into a semantic button action. Extend that boundary so the Space key follows the same signal and ECS invalidation path without duplicating counter mutation logic.
Clean Up Your Resources
Clean Up Your Resources
The counter runs entirely on your Linux machine with no cloud resources or ongoing service costs. Choose whether to keep the project available, pause its process, or delete its files.
Resources you used:
- Local project folder reactive-ecs-gui containing Cargo.toml, Cargo.lock, and the src/ modules.
- Native counter process if its window is still open.
Keep everything running
No action needed. Choose this if you want to keep extending the counter or reuse its architecture in another local GUI project.
- Keep reactive-ecs-gui with its pinned dependency graph and source modules.
- Reuse the signal handles and ECS widgets as a foundation for future interface experiments.
- Close the counter window after each test when you want to release its local CPU and GPU usage.
Pause - I'll come back to this later
Shut down the running process to release local resources while preserving every project file for your next session.
- Close the counter window if it is still running.
- Leave reactive-ecs-gui on disk with its manifest and lockfile intact.
- Return to src/ui.rs or src/runtime.rs when you are ready to continue developing the runtime.
Delete - I don't want to use this again
This option permanently removes the local project so you can start fresh. The cleanup targets only reactive-ecs-gui.
Your existing Rust toolchain stays installed. Your Vulkan drivers also stay installed.
- Close the counter window if it is still running.
- Switch back to the terminal session that ran the counter from reactive-ecs-gui.
- Delete reactive-ecs-gui by running these commands:
cd ..
rm -rf reactive-ecs-gui
What Do These Commands Remove?
- The first line moves the terminal to the folder above your project.
- The second line permanently deletes reactive-ecs-gui and every file inside it.
- Confirm the project folder is gone by running this command:
ls
What Should I See?
This command lists the remaining files and folders in the current location. You should no longer see reactive-ecs-gui in the output.
Still See the Project Folder?
Make sure the terminal was inside reactive-ecs-gui before you ran the cleanup commands. Close the counter process before trying again.
Help me remove the local reactive-ecs-gui folder safely on Linux.
Nice Work!
Nice Work!
That counter is alive! You built a native Rust GUI whose retained ECS widgets redraw on demand through an event loop. This first project in the series gives you a production-shaped starting boundary for a larger GUI framework.
You've learned how to:
- Build a native window around the winit application lifecycle. Create graphics state on resume. Render Vello vector scenes through a real wgpu surface only when the app requests a frame. Recover that surface after resize or restore while preserving DPI scaling.
- Represent the panel, digit, and button as bevy_ecs entities with focused components. Use bounds plus hit testing to keep pointer feedback aligned at desktop scale factors.
- Create cloneable read and write signal handles that notify the event loop through typed events. Repair a deliberately stale retained view through targeted invalidation that updates only the subscribed digit. Confirm each redraw through the one-widget diagnostic.
- Secret Mission: Reuse the same semantic button action for the Space key plus pointer activation. Preserve one owner for the modulo-10 counter mutation.
Ready to quiz yourself?