# Table of Contents - [Introduction - Building a Node-Based AI Flow Editor with eframe & egui](#introduction-building-a-node-based-ai-flow-editor-with-eframe-egui) - [Introduction - Building a Node-Based AI Flow Editor with eframe & egui](#introduction-building-a-node-based-ai-flow-editor-with-eframe-egui) - [Chapter 1: What Is eframe? - Building a Node-Based AI Flow Editor with eframe & egui](#chapter-1-what-is-eframe-building-a-node-based-ai-flow-editor-with-eframe-egui) - [Chapter 2: Project Setup & Structure - Building a Node-Based AI Flow Editor with eframe & egui](#chapter-2-project-setup-structure-building-a-node-based-ai-flow-editor-with-eframe-egui) - [Chapter 3: Your First Window - Building a Node-Based AI Flow Editor with eframe & egui](#chapter-3-your-first-window-building-a-node-based-ai-flow-editor-with-eframe-egui) - [Chapter 5: Structuring Your Application - Building a Node-Based AI Flow Editor with eframe & egui](#chapter-5-structuring-your-application-building-a-node-based-ai-flow-editor-with-eframe-egui) - [Chapter 7: Input, Menus & Dialogs - Building a Node-Based AI Flow Editor with eframe & egui](#chapter-7-input-menus-dialogs-building-a-node-based-ai-flow-editor-with-eframe-egui) - [Chapter 4: Layout, Widgets & State - Building a Node-Based AI Flow Editor with eframe & egui](#chapter-4-layout-widgets-state-building-a-node-based-ai-flow-editor-with-eframe-egui) - [Chapter 6: Theming, Fonts & Styling - Building a Node-Based AI Flow Editor with eframe & egui](#chapter-6-theming-fonts-styling-building-a-node-based-ai-flow-editor-with-eframe-egui) - [Chapter 8: Introducing egui-snarl - Building a Node-Based AI Flow Editor with eframe & egui](#chapter-8-introducing-egui-snarl-building-a-node-based-ai-flow-editor-with-eframe-egui) - [Chapter 10: Connections & Wires - Building a Node-Based AI Flow Editor with eframe & egui](#chapter-10-connections-wires-building-a-node-based-ai-flow-editor-with-eframe-egui) - [Chapter 12: Styling the Graph - Building a Node-Based AI Flow Editor with eframe & egui](#chapter-12-styling-the-graph-building-a-node-based-ai-flow-editor-with-eframe-egui) - [Chapter 11: Interactions & UX - Building a Node-Based AI Flow Editor with eframe & egui](#chapter-11-interactions-ux-building-a-node-based-ai-flow-editor-with-eframe-egui) - [Chapter 13: Defining Agent Nodes - Building a Node-Based AI Flow Editor with eframe & egui](#chapter-13-defining-agent-nodes-building-a-node-based-ai-flow-editor-with-eframe-egui) - [Chapter 15: Live Execution & Streaming - Building a Node-Based AI Flow Editor with eframe & egui](#chapter-15-live-execution-streaming-building-a-node-based-ai-flow-editor-with-eframe-egui) - [Chapter 14: Graph Evaluation - Building a Node-Based AI Flow Editor with eframe & egui](#chapter-14-graph-evaluation-building-a-node-based-ai-flow-editor-with-eframe-egui) - [Chapter 16: State Persistence - Building a Node-Based AI Flow Editor with eframe & egui](#chapter-16-state-persistence-building-a-node-based-ai-flow-editor-with-eframe-egui) - [Chapter 17: Production Architecture - Building a Node-Based AI Flow Editor with eframe & egui](#chapter-17-production-architecture-building-a-node-based-ai-flow-editor-with-eframe-egui) - [Chapter 9: Building Nodes & Pins - Building a Node-Based AI Flow Editor with eframe & egui](#chapter-9-building-nodes-pins-building-a-node-based-ai-flow-editor-with-eframe-egui) - [Chapter 18: Conclusion & Resources - Building a Node-Based AI Flow Editor with eframe & egui](#chapter-18-conclusion-resources-building-a-node-based-ai-flow-editor-with-eframe-egui) - [Building a Node-Based AI Flow Editor with eframe & egui](#building-a-node-based-ai-flow-editor-with-eframe-egui) --- # Introduction - Building a Node-Based AI Flow Editor with eframe & egui Keyboard shortcuts ------------------ Press ← or → to navigate between chapters Press S or / to search in the book Press ? to show this help Press Esc to hide this help * Auto * Light * Rust * Coal * Navy * Ayu Building a Node-Based AI Flow Editor with eframe & egui ======================================================= [](https://danpokhrel.github.io/tutorial/print.html "Print this book") Build a clean, modern **node graph editor** in Rust from scratch using `eframe` and `egui` — and progress all the way to a working **node-based agentic AI flow builder**, the kind you see in [Langflow](https://www.langflow.org/) and [Flowise](https://flowiseai.com/) . By the end of this tutorial you will have a production-grade desktop (and web-capable) application that lets users visually compose AI agent pipelines — connecting LLM nodes, prompt templates, tools, and memory components with drag-and-drop wires, then executing the resulting flow live with streaming output. By the end of this tutorial, you will have a fully functional node graph editor — the kind you see in shader editors, compositing tools, and visual scripting systems — extended into a visual builder for agentic AI workflows. It will feature: Draggable nodes with input/output pins, title bars, and embedded widgets for configuring prompts, models, and parameters. Drag-to-create wires between pins, with type validation and one-click deletion. A custom dark theme with styled pins, colored wires, a grid background, and per-node-type color coding. A graph evaluation engine that performs topological sorting and executes the flow — with streaming output rendered live into the UI. Save and restore the entire graph layout and configuration to and from disk, plus eframe’s built-in state persistence. The same codebase compiles to native desktop _and_ WebAssembly, so your flow builder can run in a browser. This tutorial assumes you have read through **Chapters 1–10** of [The Rust Book](https://doc.rust-lang.org/stable/book/) — covering ownership, borrowing, structs, enums, error handling, modules, and traits. You should be comfortable with: * Creating a project with `cargo new` * Writing `impl` blocks for your own types * Using `Result` and the `?` operator * Understanding the difference between `String` (owned) and `&str` (borrowed) Throughout this tutorial, we link to specific chapters of [The Rust Programming Language](https://doc.rust-lang.org/stable/book/) . These references ground our architectural decisions in the canonical Rust patterns the book teaches. A few callouts reach ahead to later chapters — `move` closures (Ch. 13.1), the `Drop` trait / RAII (Ch. 15.3), and the newtype pattern (Ch. 19.3) — each is briefly introduced where it first appears, so you don’t need to have read those chapters in advance. [`egui`](https://github.com/emilk/egui) is a pure-Rust **immediate-mode GUI library**. In immediate mode, the UI is re-built every frame from scratch — there is no retained widget tree. This makes it exceptionally fast to iterate on and ideal for developer tools, editors, and real-time applications. It has no windowing, no event loop, and no rendering backend of its own — it only _describes_ UI and handles layout and input. [`eframe`](https://github.com/emilk/egui/tree/master/crates/eframe) is the **official framework** for writing apps with egui. The name stands for both “egui frame” and “egui framework”. It wraps egui with everything needed to actually run: a window and event loop (`egui-winit`), a renderer (`egui-wgpu` by default), clipboard, persistence, web/WASM integration, and accessibility (AccessKit). Most apps only need to depend on `eframe` — it re-exports `egui`, `emath`, `epaint`, and the rendering crates. The rule of thumb: if you want a standalone desktop or web GUI app in Rust, start with `eframe`. If you’re embedding egui into a game engine (Bevy, macroquad) or a custom rendering surface, use `egui` + a specific integration crate instead. [`egui-snarl`](https://github.com/zakarumych/egui-snarl) is a customizable **node-graph widget** for egui — think visual scripting, shader graphs, material editors. (“Snarl” = what a complex graph looks like.) It is the most popular egui node-graph library. It provides a generic `Snarl` container that stores positioned nodes and wires, and a `SnarlViewer` trait you implement to drive the UI for each node type. [`serde`](https://serde.rs/) provides serialization and deserialization. We use it to save and load the entire graph — nodes, connections, and configuration — to JSON files. eframe’s built-in persistence also uses serde under the hood. egui is a layered ecosystem. Understanding the layers helps you reason about what each crate is responsible for: `┌─────────────────────────────────────────────┐ │ Your App (impl App) │ ├─────────────────────────────────────────────┤ │ eframe │ ← framework: event loop, web/native glue, persistence │ ┌───────────────┐ ┌────────────────────┐ │ │ │ egui-winit │ │ egui-wgpu/glow │ │ ← platform integration + rendering │ └───────┬───────┘ └─────────┬──────────┘ │ │ ▼ ▼ │ │ ┌────────────────────────────────────────┐ │ │ │ egui │ │ ← immediate-mode UI library (widgets, layout, ctx) │ │ ┌──────────┐ ┌────────┐ ┌────────┐ │ │ │ │ │ emath │ │ epaint │ │ ecolor │ │ │ ← math, tessellation/paint, colors │ │ └──────────┘ └────────┘ └────────┘ │ │ │ └────────────────────────────────────────┘ │ └─────────────────────────────────────────────┘` * **egui** is the _library_ — pure UI, layout, input; no windowing or rendering. * **eframe** is the _framework_ — it wraps egui with winit, a renderer, clipboard, persistence, WASM, and AccessKit. eframe re-exports `egui`, `emath`, `epaint`, and `egui_wgpu`/`wgpu` (and `egui_glow`/`glow` if the glow feature is on), so most apps only need to depend on `eframe`. This tutorial is pinned to the following stable releases (July 2026): | Crate | Version | | --- | --- | | `eframe` / `egui` | **0.35.0** (released 2026-06-25) | | `egui-snarl` | **0.11.0** | | `egui_extras` | **0.35.0** | | MSRV | Rust **1.92.0** | | Edition | **2024** | > **Important:** egui ships breaking changes in most minor version bumps. If you are using a different version, some API signatures may differ. The [migration guide](https://danpokhrel.github.io/tutorial/ch17-production.html) > chapter covers the key changes across versions. This tutorial is organized into five parts, each building on the previous: **Part 1 — Foundations** (Chapters 1–4) introduces the egui ecosystem, sets up a project, opens your first window, and covers the core building blocks: panels, layouts, widgets, and stateful UI. **Part 2 — Application Architecture** (Chapters 5–7) elevates the project from a prototype to a structured application: the `App` trait’s `logic`/`ui` split, theming and fonts, and input handling with menus and dialogs. **Part 3 — The Node Graph Editor** (Chapters 8–12) brings in `egui-snarl` and builds a full node graph editor: nodes, pins, wires, context menus, selection, and custom styling. **Part 4 — Building an Agentic AI Flow** (Chapters 13–16) turns the node editor into an agentic AI flow builder: defining agent node types (LLM, prompt, tool, memory), a graph evaluation engine, live streaming execution, and persistence. **Part 5 — Production** (Chapters 17–18) covers error types, testing, feature flags, logging, and web/WASM deployment — everything you need to ship a production-grade application. > **Scope of the reference implementation.** The companion `tutorial-implementation` crate realizes the **core node-graph editor** built across Parts 1–3 (Chapters 1–12): an `eframe` app with a custom dark theme, a four-node `DemoNode` graph (`Number` / `Text` / `Concat` / `Sink`), a `SnarlViewer` with bodies, footers, and add/remove context menus, plus the pure-data `graph/` module and its headless unit tests. It deliberately stops there: the type-validated `connect` (Ch. 10), the `SnarlStyle`/`header_frame` styling (Ch. 12), and all of Part 4 — agent nodes, graph evaluation, live streaming, and JSON persistence — are **forward-looking material the reference implementation does not (yet) ship**. Those chapters teach the patterns you would add next; they are accurate egui/egui-snarl pedagogy, but their code is not mirrored in the implementation. Each such chapter now opens with a short callout noting this. Code blocks are the heart of this tutorial. We use `rust` fenced blocks for all Rust code: `fn main() { println!("Hello, egui!"); }` > **Note:** The `no_run` attribute tells mdBook’s syntax highlighter to render the code without trying to compile it during tests. Throughout, we use blockquote callouts for tips, warnings, and notes — just like this one. Let’s begin with [Chapter 1: What Is eframe?](https://danpokhrel.github.io/tutorial/ch01-what-is-eframe.html) . --- # Introduction - Building a Node-Based AI Flow Editor with eframe & egui Keyboard shortcuts ------------------ Press ← or → to navigate between chapters Press S or / to search in the book Press ? to show this help Press Esc to hide this help * Auto * Light * Rust * Coal * Navy * Ayu Building a Node-Based AI Flow Editor with eframe & egui ======================================================= [](https://danpokhrel.github.io/tutorial/print.html "Print this book") Build a clean, modern **node graph editor** in Rust from scratch using `eframe` and `egui` — and progress all the way to a working **node-based agentic AI flow builder**, the kind you see in [Langflow](https://www.langflow.org/) and [Flowise](https://flowiseai.com/) . By the end of this tutorial you will have a production-grade desktop (and web-capable) application that lets users visually compose AI agent pipelines — connecting LLM nodes, prompt templates, tools, and memory components with drag-and-drop wires, then executing the resulting flow live with streaming output. By the end of this tutorial, you will have a fully functional node graph editor — the kind you see in shader editors, compositing tools, and visual scripting systems — extended into a visual builder for agentic AI workflows. It will feature: Draggable nodes with input/output pins, title bars, and embedded widgets for configuring prompts, models, and parameters. Drag-to-create wires between pins, with type validation and one-click deletion. A custom dark theme with styled pins, colored wires, a grid background, and per-node-type color coding. A graph evaluation engine that performs topological sorting and executes the flow — with streaming output rendered live into the UI. Save and restore the entire graph layout and configuration to and from disk, plus eframe’s built-in state persistence. The same codebase compiles to native desktop _and_ WebAssembly, so your flow builder can run in a browser. This tutorial assumes you have read through **Chapters 1–10** of [The Rust Book](https://doc.rust-lang.org/stable/book/) — covering ownership, borrowing, structs, enums, error handling, modules, and traits. You should be comfortable with: * Creating a project with `cargo new` * Writing `impl` blocks for your own types * Using `Result` and the `?` operator * Understanding the difference between `String` (owned) and `&str` (borrowed) Throughout this tutorial, we link to specific chapters of [The Rust Programming Language](https://doc.rust-lang.org/stable/book/) . These references ground our architectural decisions in the canonical Rust patterns the book teaches. A few callouts reach ahead to later chapters — `move` closures (Ch. 13.1), the `Drop` trait / RAII (Ch. 15.3), and the newtype pattern (Ch. 19.3) — each is briefly introduced where it first appears, so you don’t need to have read those chapters in advance. [`egui`](https://github.com/emilk/egui) is a pure-Rust **immediate-mode GUI library**. In immediate mode, the UI is re-built every frame from scratch — there is no retained widget tree. This makes it exceptionally fast to iterate on and ideal for developer tools, editors, and real-time applications. It has no windowing, no event loop, and no rendering backend of its own — it only _describes_ UI and handles layout and input. [`eframe`](https://github.com/emilk/egui/tree/master/crates/eframe) is the **official framework** for writing apps with egui. The name stands for both “egui frame” and “egui framework”. It wraps egui with everything needed to actually run: a window and event loop (`egui-winit`), a renderer (`egui-wgpu` by default), clipboard, persistence, web/WASM integration, and accessibility (AccessKit). Most apps only need to depend on `eframe` — it re-exports `egui`, `emath`, `epaint`, and the rendering crates. The rule of thumb: if you want a standalone desktop or web GUI app in Rust, start with `eframe`. If you’re embedding egui into a game engine (Bevy, macroquad) or a custom rendering surface, use `egui` + a specific integration crate instead. [`egui-snarl`](https://github.com/zakarumych/egui-snarl) is a customizable **node-graph widget** for egui — think visual scripting, shader graphs, material editors. (“Snarl” = what a complex graph looks like.) It is the most popular egui node-graph library. It provides a generic `Snarl` container that stores positioned nodes and wires, and a `SnarlViewer` trait you implement to drive the UI for each node type. [`serde`](https://serde.rs/) provides serialization and deserialization. We use it to save and load the entire graph — nodes, connections, and configuration — to JSON files. eframe’s built-in persistence also uses serde under the hood. egui is a layered ecosystem. Understanding the layers helps you reason about what each crate is responsible for: `┌─────────────────────────────────────────────┐ │ Your App (impl App) │ ├─────────────────────────────────────────────┤ │ eframe │ ← framework: event loop, web/native glue, persistence │ ┌───────────────┐ ┌────────────────────┐ │ │ │ egui-winit │ │ egui-wgpu/glow │ │ ← platform integration + rendering │ └───────┬───────┘ └─────────┬──────────┘ │ │ ▼ ▼ │ │ ┌────────────────────────────────────────┐ │ │ │ egui │ │ ← immediate-mode UI library (widgets, layout, ctx) │ │ ┌──────────┐ ┌────────┐ ┌────────┐ │ │ │ │ │ emath │ │ epaint │ │ ecolor │ │ │ ← math, tessellation/paint, colors │ │ └──────────┘ └────────┘ └────────┘ │ │ │ └────────────────────────────────────────┘ │ └─────────────────────────────────────────────┘` * **egui** is the _library_ — pure UI, layout, input; no windowing or rendering. * **eframe** is the _framework_ — it wraps egui with winit, a renderer, clipboard, persistence, WASM, and AccessKit. eframe re-exports `egui`, `emath`, `epaint`, and `egui_wgpu`/`wgpu` (and `egui_glow`/`glow` if the glow feature is on), so most apps only need to depend on `eframe`. This tutorial is pinned to the following stable releases (July 2026): | Crate | Version | | --- | --- | | `eframe` / `egui` | **0.35.0** (released 2026-06-25) | | `egui-snarl` | **0.11.0** | | `egui_extras` | **0.35.0** | | MSRV | Rust **1.92.0** | | Edition | **2024** | > **Important:** egui ships breaking changes in most minor version bumps. If you are using a different version, some API signatures may differ. The [migration guide](https://danpokhrel.github.io/tutorial/ch17-production.html) > chapter covers the key changes across versions. This tutorial is organized into five parts, each building on the previous: **Part 1 — Foundations** (Chapters 1–4) introduces the egui ecosystem, sets up a project, opens your first window, and covers the core building blocks: panels, layouts, widgets, and stateful UI. **Part 2 — Application Architecture** (Chapters 5–7) elevates the project from a prototype to a structured application: the `App` trait’s `logic`/`ui` split, theming and fonts, and input handling with menus and dialogs. **Part 3 — The Node Graph Editor** (Chapters 8–12) brings in `egui-snarl` and builds a full node graph editor: nodes, pins, wires, context menus, selection, and custom styling. **Part 4 — Building an Agentic AI Flow** (Chapters 13–16) turns the node editor into an agentic AI flow builder: defining agent node types (LLM, prompt, tool, memory), a graph evaluation engine, live streaming execution, and persistence. **Part 5 — Production** (Chapters 17–18) covers error types, testing, feature flags, logging, and web/WASM deployment — everything you need to ship a production-grade application. > **Scope of the reference implementation.** The companion `tutorial-implementation` crate realizes the **core node-graph editor** built across Parts 1–3 (Chapters 1–12): an `eframe` app with a custom dark theme, a four-node `DemoNode` graph (`Number` / `Text` / `Concat` / `Sink`), a `SnarlViewer` with bodies, footers, and add/remove context menus, plus the pure-data `graph/` module and its headless unit tests. It deliberately stops there: the type-validated `connect` (Ch. 10), the `SnarlStyle`/`header_frame` styling (Ch. 12), and all of Part 4 — agent nodes, graph evaluation, live streaming, and JSON persistence — are **forward-looking material the reference implementation does not (yet) ship**. Those chapters teach the patterns you would add next; they are accurate egui/egui-snarl pedagogy, but their code is not mirrored in the implementation. Each such chapter now opens with a short callout noting this. Code blocks are the heart of this tutorial. We use `rust` fenced blocks for all Rust code: `fn main() { println!("Hello, egui!"); }` > **Note:** The `no_run` attribute tells mdBook’s syntax highlighter to render the code without trying to compile it during tests. Throughout, we use blockquote callouts for tips, warnings, and notes — just like this one. Let’s begin with [Chapter 1: What Is eframe?](https://danpokhrel.github.io/tutorial/ch01-what-is-eframe.html) . --- # Chapter 1: What Is eframe? - Building a Node-Based AI Flow Editor with eframe & egui Keyboard shortcuts ------------------ Press ← or → to navigate between chapters Press S or / to search in the book Press ? to show this help Press Esc to hide this help * Auto * Light * Rust * Coal * Navy * Ayu Building a Node-Based AI Flow Editor with eframe & egui ======================================================= [](https://danpokhrel.github.io/tutorial/print.html "Print this book") This chapter introduces the two crates at the heart of this book—egui and eframe—and explains how they relate to each other and to the rest of the Rust GUI ecosystem. Before we write a single line of UI code, it is worth understanding the architecture you are about to build on, because the mental model of “immediate mode” will shape every decision you make in later chapters. If you have worked with retained-mode toolkits like Qt, GTK, or the web’s DOM, some of these ideas will feel backwards at first—that is normal, and we will take it slowly. **egui** is an immediate-mode GUI library written in pure Rust. “Immediate mode” means that, instead of constructing a tree of widget objects that live in memory and get mutated over time, you call functions every frame that _describe_ what the user interface should look like right now. egui takes that description, figures out what changed, and paints the result. There is no widget tree, no signal/slot wiring, and no hidden stateful object per button. egui itself knows nothing about windows, keyboards, mice, or rendering surfaces. It is a pure logic crate: you feed it input events, it hands you back a list of triangles to draw. That separation is deliberate—it lets egui run anywhere, from a desktop window to a web canvas to a game engine overlay. > **A note on the name.** “egui” is pronounced “e-goo-ee.” The “e” originally stood for _emerald_; these days most people just say “egui.” **eframe** is the framework crate that wraps egui and makes it usable as a complete application. Where egui says “draw these triangles,” eframe says “I will create the window, pump platform events, load fonts, talk to the clipboard, persist your state to disk, and hand egui a wgpu or glow surface to paint on.” In other words, eframe is the “batteries-included” layer so that you can focus on your app rather than windowing boilerplate. Concretely, eframe gives you: * A **window** and event loop via `winit` (on desktop) or the browser canvas (on the web). * A **renderer** backed by `wgpu` (the default) or `glow` (OpenGL). * **Clipboard** integration for copy/paste. * **Persistence**: save and restore your `App` state across runs using `epi::App::save`. * **WASM** support so the same code runs in a browser. * **AccessKit** integration for accessibility (screen readers, etc.). * Sensible **defaults**: fonts, visuals, dark/light theme, DPI handling. If you have used `wgpu` or `winit` directly, you know how much ceremony is involved just to get a blank window on screen. eframe exists so you do not have to write that ceremony. Your application sits on top of a layered stack. Each layer has one job and delegates to the one below it: `┌──────────────────────────────────┐ │ Your App │ <- your logic & state │ (implements eframe::App) │ └──────────────┬───────────────────┘ │ ┌──────────────▼───────────────────┐ │ eframe │ <- window, loop, persistence, │ (app framework) │ clipboard, AccessKit, WASM └──────────────┬───────────────────┘ │ ┌──────────────▼───────────────────┐ │ egui-winit egui-wgpu / glow │ <- glue: events -> egui input, │ │ egui output -> rendering └──────┬─────────────┬─────────────┘ │ │ ┌──────▼─────┐ ┌────▼──────────────┐ │ egui │ │ wgpu / glow │ <- GUI logic / GPU painting └──────┬─────┘ └───────────────────┘ │ ┌───────────┼───────────┐ │ │ │ ┌──▼──┐ ┌─────▼─────┐ ┌──▼─────┐ │emath│ │ epaint │ │ecolor │ <- math / tessellation / colors └─────┘ └──────────┘ └────────┘` * **Your App** implements the `eframe::App` trait. This is where your state lives and where you build your UI each frame. * **eframe** owns the event loop and the window, creates the renderer, and calls into your app at the right moments. * **egui-winit / egui-wgpu** are the “glue” crates: egui-winit translates `winit` events into egui’s input format, and egui-wgpu paints egui’s tessellated mesh onto a wgpu surface. * **egui** is the pure-logic GUI crate: layouts, widgets, input state, clipping, etc. * **emath / epaint / ecolor** are the small leaf crates egui depends on—math primitives, triangle tessellation, and color types respectively. You will almost never touch the crates below egui directly. Knowing the stack exists, though, helps you read error messages and understand which crate owns which behavior. The single most important concept in this book is the difference between immediate-mode and retained-mode UI. Let’s make it concrete. In a retained-mode toolkit (GTK, Qt, the DOM), you create a button object, add it to a layout object, connect a click handler, and the button lives in memory until you destroy it: ``#![allow(unused)] fn main() { // (Pseudocode—not egui) let mut button = Button::new("Click me"); button.on_click(|| { /* ... */ }); layout.add(&button); // `button` persists; the toolkit owns it. }`` In egui, there is no button object that outlives the frame. Each frame, you call a function that says “draw a button here, and tell me if it was clicked this frame”: `#![allow(unused)] fn main() { // egui if ui.button("Click me").clicked() { // ran only on the frame the click happened } }` The implications are profound: * **No widget state to sync.** There is no `Button` object whose `enabled` property you must keep in sync with your model. You just read your model and call the right function. For more on why avoiding shared mutable state is a theme of Rust in general, see [Chapter 4 of the Rust Book](https://doc.rust-lang.org/stable/book/ch04-00-understanding-ownership.html) . * **No callback wiring.** Event handling is a return value (`clicked()`), not a registered closure, so ownership stays simple. * **State lives in your structs.** egui holds no business state. A text field’s contents live in _your_ `String` field, not in a widget object. The UI is a pure projection of your state. * **Everything is rebuilt every frame.** This sounds expensive, but egui is fast: a typical frame rebuilds the whole UI in well under a millisecond. The trade-off is simplicity for the developer at the cost of some CPU per frame—but only on frames that actually run. > **Tip: the mental shift.** If you find yourself wanting to “get a reference to a widget and change it later,” stop. In egui you change your data; the widget reflects that next frame automatically. eframe is the right default, but egui is designed to be embeddable, so there are other hosts. Use this decision tree: 1. **Are you embedding UI inside a game engine (Bevy, Fyrox)?** Use `bevy_egui` (or the engine’s egui integration). You already have a window and a render loop; eframe would fight it. 2. **Do you need a custom window manager, multiple separate viewports, or a non-winit platform?** Use raw `egui` + `egui-winit` + `egui-wgpu` directly. You are taking on eframe’s job yourself, but you get full control. 3. **Are you writing a normal desktop or web app?** Use **eframe**. It is what this book covers, and it will handle 95% of real projects. In short: eframe is “egui as an application,” while the lower crates are “egui as a library.” egui shines for **developer-facing and power-user-facing tools**: * **Developer tools**: debug viewers, profilers, log inspectors. * **Dashboards**: telemetry, monitoring, data exploration. * **Editors**: level editors, configuration tools, asset browsers. * **Node graphs**: visual programming, shader graphs, material editors (this is exactly the domain of `egui-snarl`, which we use later in the book). egui is weaker in a few areas—know them before committing: * **Pixel-perfect mobile design.** egui is not layout-engine-precise the way a native mobile toolkit is. Touch hit-targeting and responsive breakpoints require manual work. * **Rich text editing.** There is no rich-text contenteditable widget; the `TextEdit` is single-style plain text plus some formatting. * **Deep native OS integration.** egui draws its own widgets, so they do not look native. If matching the OS chrome exactly is a hard requirement, consider a native toolkit instead. For the node-graph editor we build across this book, egui is an excellent fit. Every frame, eframe runs the same pipeline. Understanding this loop is the key to understanding why egui code looks the way it does: `platform events (input, resize, etc.) │ ▼ ┌───────────────────────────┐ │ App::logic (0.34+) │ <- state updates, async, animations │ ctx, frame │ NO UI calls here └────────────┬──────────────┘ │ ▼ ┌───────────────────────────┐ │ App::ui (required) │ <- build widgets from current state │ ui, frame │ keep this cheap & pure-ish └────────────┬──────────────┘ │ ▼ ┌───────────────────────────┐ │ egui tessellates │ <- primitives -> triangle mesh └────────────┬──────────────┘ │ ▼ ┌───────────────────────────┐ │ wgpu / glow paints │ <- GPU draws the mesh └───────────────────────────┘` Two things stand out. First, the **logic/ui split** (introduced in egui 0.34, carried into 0.35): `App::logic` runs _before_ `ui` and is meant for state mutation—reading input, advancing animations, polling async results—while `App::ui` only reads that state and builds the widget list. The old `App::update` method that did both is **removed in 0.35**; if you see old tutorials calling `update`, they are out of date. Second, the repaint model. egui does **not** render at a fixed 60 FPS by default. When nothing is happening, the application goes idle and uses essentially zero CPU. It only repaints when something requests a repaint: * User input (a click, a keystroke, a mouse move over the window) requests a repaint automatically. * Animations and timers inside egui request repaints as needed. * **Your background work** must request a repaint explicitly, or the UI will appear frozen until the next input event. If you spawn a background thread that produces data the UI should reflect, you must call `ctx.request_repaint()` when new data is ready. We will see exactly how to do that (clone the `Context`, which is cheaply cloneable, and call the method from any thread) in [Chapter 3](https://danpokhrel.github.io/tutorial/ch03-first-window.html) . > **Warning: the silent freeze.** The single most common “my UI won’t update” bug in egui is forgetting to request a repaint after a background update. If your app works when you wiggle the mouse but otherwise stalls, look here first. To round out this chapter, here are the five mistakes new egui developers hit over and over. We will revisit each in detail later; for now, memorize the list: 1. **Forgetting image loaders.** egui cannot load images out of the box in 0.35; you must register an image loader (typically via `egui_extras`’s `image`/`svg` features) in `App::new`. Without it, `Image` widgets show nothing or panic. 2. **Holding the `Context` lock across UI code.** Context access is closure-based (`ctx.input(|i| …)`) precisely to avoid this. If you grab a lock and then build widgets, you will deadlock or borrow-checker yourself into a corner. Always scope context reads tightly. 3. **Not requesting repaints.** Covered above. Background-driven updates need `ctx.request_repaint()`. 4. **Missing the `wgpu` feature.** Rendering is off by default — you must enable the `wgpu` feature on `eframe` (or `glow`, for OpenGL) to get a renderer. The good news in 0.35 is that eframe’s `wgpu` feature already turns on `egui-wgpu` with its _default_ native backends (DX12 on Windows, Metal on macOS, Vulkan/GLES on Linux), so you do **not** need a separate `wgpu` dependency or target-specific backend features. Forgetting the `wgpu` feature entirely, though, gives you a blank window or a runtime panic. We set this up in [Chapter 2](https://danpokhrel.github.io/tutorial/ch02-project-setup.html) . 5. **Unstable `Id`s in loops.** egui tracks widget state (scroll position, focus, etc.) by `Id`. If you generate `Id`s with a loop index that changes when items are added/removed, state will jump around. Use stable identifiers (`Id::new(&item.key)` or `id.with(child_id)`). We cover this in [Chapter 4](https://danpokhrel.github.io/tutorial/ch04-layout-widgets.html) . You now have the mental model: egui is pure immediate-mode logic, eframe is the application shell, state lives in your structs, and the whole thing repaints on demand. In [Chapter 2: Project Setup & Structure](https://danpokhrel.github.io/tutorial/ch02-project-setup.html) we will turn this into a real project—a `Cargo.toml`, the right feature flags, the wgpu backend gotcha, and a folder layout fit for a production app. --- # Chapter 2: Project Setup & Structure - Building a Node-Based AI Flow Editor with eframe & egui Keyboard shortcuts ------------------ Press ← or → to navigate between chapters Press S or / to search in the book Press ? to show this help Press Esc to hide this help * Auto * Light * Rust * Coal * Navy * Ayu Building a Node-Based AI Flow Editor with eframe & egui ======================================================= [](https://danpokhrel.github.io/tutorial/print.html "Print this book") In this chapter we go from an empty directory to a compiling eframe project with a sensible, production-shaped structure. The goal is not yet a polished UI—that comes in [Chapter 3](https://danpokhrel.github.io/tutorial/ch03-first-window.html) —but a solid foundation: the right dependencies, the right feature flags, the wgpu backend gotcha handled, and a folder layout that will not collapse under its own weight as the app grows. We will also lean on ideas from the Rust Book’s chapter on packages and modules, so if you have not read it, now is a good time: [Chapter 7, “Packages, Crates, and Modules”](https://doc.rust-lang.org/stable/book/ch07-00-managing-growing-projects-with-packages-crates-and-modules.html) . Start with a fresh binary crate. We will call our example app `flow-builder` because, across the book, it grows into a node-graph editor built on `egui-snarl`: `$ cargo new flow-builder --bin $ cd flow-builder` This gives you the standard skeleton: a `Cargo.toml` and a `src/main.rs` that prints “Hello, world!”. We will replace both. > **Note: always use `--bin`.** eframe apps are executables (and can additionally target `wasm32-unknown-unknown` for the web), not libraries. If you skip `--bin`, cargo makes a library crate, which is the wrong starting point. Replace `Cargo.toml` with the following. Each dependency and feature is deliberate; we explain the non-obvious choices immediately after. ``[package] name = "flow-builder" version = "0.1.0" edition = "2024" rust-version = "1.92.0" [dependencies] # eframe is the app framework. We pin 0.35 and pick only the features we need. eframe = { version = "0.35", default-features = false, features = [ "default_fonts", # ships with a sensible default font set "wgpu", # default renderer in 0.35 "persistence", # save/restore App state across runs "x11", # Linux/X11 backend (winit) "wayland", # Linux/Wayland backend (winit) ] } # egui_extras: image decoders and svg support for `install_image_loaders`. egui_extras = { version = "0.35", features = ["image", "svg"] } # Node-graph widget for the editor we build later in the book. egui-snarl = { version = "0.11", features = ["serde"] } # Serialization for persistence and for snarl snapshots. serde = { version = "1", features = ["derive"] } [profile.release] opt-level = 2 # egui needs reasonable optimization to feel responsive lto = "thin" # cross-crate inlining without full-LTO compile times codegen-units = 1 # better optimization at the cost of slower release builds strip = "symbols" # smaller binaries; debug info lives in the build, not the artifact [profile.dev.package."*"] opt-level = 2 # build *dependencies* (egui, wgpu, ...) optimized even in dev`` A few points worth calling out: * **`default-features = false` on eframe.** eframe’s defaults pull in everything, including the `glow` (OpenGL) renderer and platform backends you may not want. Being explicit avoids surprising link dependencies—especially on Linux, where an accidental X11/Wayland mismatch causes build pain. * **Edition 2024, MSRV 1.92.0.** eframe 0.35 targets edition 2024 idioms (let-chains, `if let` chains, etc.). Setting `rust-version` makes the toolchain requirement explicit. * **`egui-snarl` with `serde`.** We will serialize node graphs to disk, so the `serde` feature must be on. * **`egui_extras` with only `image` and `svg`.** These two features are all `install_image_loaders` needs to display `Image`/`Svg` widgets. The older `http` and `file` features were dropped from this project: nothing in the app fetches images over the network or opens files through `egui_extras` (file dialogs, when we want them, come from the `rfd` crate in [Chapter 7](https://danpokhrel.github.io/tutorial/ch07-input-menus.html) — and even that is optional, added only when you reach that chapter). * **Profile settings.** egui’s debug builds are sluggish because so much work happens every frame. The two `[profile]` blocks above make `cargo run` (without `--release`) usable by optimizing dependencies, while keeping _your_ code compiled fast. The `[profile.dev.package."*"]` trick applies `opt-level = 2` only to _dependencies_ (egui, wgpu, …), not to your own crate — the `"*"` wildcard targets every package _except_ the one being built. Your code stays at `opt-level = 0` (fast to compile), while the heavy libraries run fast enough for interactive use. The `[profile.release]` block adds thin LTO, a single codegen unit, and symbol stripping for a smaller, better-optimized shipping binary. See the [Cargo profile reference](https://doc.rust-lang.org/cargo/reference/profiles.html) for details. * **`x11` and `wayland`** are Linux-only winit backends. On macOS and Windows these features are silently ignored (they compile to no-ops), so listing them is harmless — a single `Cargo.toml` works across all three desktop platforms. A common older-tutorial pitfall is adding a _direct_ `wgpu` dependency to `Cargo.toml` with target-specific backend features (`dx12`, `metal`, `webgl`). **You do not need to do this with eframe 0.35.** The `wgpu` feature on `eframe` enables `egui-wgpu` with its default features, which already turn on wgpu 29’s native backends (DX12 on Windows, Metal on macOS, Vulkan/GLES on Linux). The window simply presents. > **Warning: do not re-add a direct `wgpu` dependency.** This project previously pinned `wgpu = "23"` in a `[target.'cfg(not(target_arch = "wasm32"))'.dependencies]` block. Because `eframe` 0.35 pulls in a newer wgpu transitively, pinning an older major version here compiled an _entire second, unused wgpu tree_ alongside the one eframe already uses — a slow, confusing build for no benefit. Leave wgpu out of `[dependencies]` unless `egui-wgpu`’s default feature set is ever insufficient for your needs. If you _did_ want to control the exact backends (rare), the place to do it is through `egui-wgpu`’s features, not a second `wgpu` crate dependency. For the web target, the WebGL/WebGPU paths are configured through eframe’s wasm glue, not through native backend features. On Linux, eframe (via winit) links against system libraries. You will need the development headers installed. On Debian/Ubuntu: `$ sudo apt install -y \ libxcb1-dev libxcb-render0-dev libxcb-shape0-dev libxcb-xfixes0-dev \ libxkbcommon-dev libxkbcommon-x11-dev \ libwayland-dev libwayland-protocols` On Fedora the packages are named `xcb-util-devel`, `libxkbcommon-devel`, `wayland-devel`, etc. On Arch they are in the `base-devel` group plus `libxcb`, `xkbcommon`, `wayland`. On Windows and macOS, the standard toolchains (MSVC and Xcode respectively) are sufficient—no extra system packages. A real app does not live in a single `main.rs`. Following the Rust Book’s guidance on [separating modules by concern](https://doc.rust-lang.org/stable/book/ch07-03-paths-for-referring-to-an-item-in-the-module-tree.html) , we split the codebase into three layers, each with a clear boundary: `flow-builder/ ├── Cargo.toml └── src/ ├── main.rs # entrypoint: build NativeOptions, run_native (stays thin) ├── theme.rs # EditorTheme: Visuals, colors, style setup ├── graph/ │ ├── mod.rs # re-exports │ ├── model.rs # NodeId, Pin, Node, Link — pure data │ └── state.rs # GraphState — ID allocation, mutations └── ui/ ├── mod.rs # re-exports (pub use app::App;) ├── app.rs # the App struct + eframe::App impl (state owner) └── viewer.rs # DemoNode enum + DemoViewer (SnarlViewer impl)` The principle is **separation of concerns**, and the boundary is egui itself: * **`graph/` is a _pure data model_.** It knows about nodes, pins, and links. It does **not** import `egui`. This means your graph logic is testable without rendering anything—exactly as the Rust Book advocates keeping I/O out of core logic ([Ch. 11 on testing](https://doc.rust-lang.org/stable/book/ch11-00-testing.html) ). (In [Chapter 8](https://danpokhrel.github.io/tutorial/ch08-egui-snarl.html) `egui-snarl`’s `Snarl` takes over as the _runtime_ graph container, so `graph/` becomes a pedagogical module kept only for its headless unit test — see the note in [Chapter 5](https://danpokhrel.github.io/tutorial/ch05-architecture.html) .) * **`ui/` is the _rendering layer_.** It imports egui (and, later, `egui_snarl`) and turns graph data into pixels. It may read from and call methods on the graph, but it does not own the persistent state. * **`ui/app.rs` is the _state owner_.** It holds the `Snarl`, the viewer, and the ephemeral UI flags, and wires everything together in `App::ui`. It is the only place that lives across frames. This mirrors how a well-architected Rust program separates the model from the view. The payoff comes in later chapters: when we add undo/redo in the graph, or when we render the same graph to an export image, the `graph/` module is reusable unchanged. If you have read the Rust Book’s [Chapter 7](https://doc.rust-lang.org/stable/book/ch07-00-managing-growing-projects-with-packages-crates-and-modules.html) , the file layout above should look familiar: each file is a module, `mod.rs` re-exports the children, and the crate root (`main.rs`) declares the top-level modules. `src/main.rs` will start like this: `// src/main.rs #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] mod graph; mod theme; mod ui; use crate::ui::App; use eframe::egui; fn main() -> eframe::Result { // (NativeOptions + run_native go here — see Chapter 3) Ok(()) }` The `windows_subsystem` attribute hides the console window on Windows in release builds. In debug builds we keep the console so `println!` output is visible—useful while developing. This is the standard Rust idiom for GUI apps; see the Rust Book’s note on it in the context of [Ch. 1’s “Hello, World”](https://doc.rust-lang.org/stable/book/ch01-02-hello-world.html) and the broader discussion of attributes. > **Tip: keep `main` thin.** `main` should declare the modules, configure eframe, and call `run_native`; all real work lives in `ui/app.rs`, `theme.rs`, and `graph/`. This keeps the entrypoint readable and makes the app testable. (The reference implementation’s `main.rs` is exactly this thin — under 30 lines.) We will not build any widgets yet, but let’s prove the setup compiles and shows a window. A truly minimal `main.rs`: `#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] use eframe::egui; fn main() -> eframe::Result { let options = eframe::NativeOptions { viewport: egui::ViewportBuilder::default() .with_inner_size([800.0, 600.0]) .with_min_inner_size([400.0, 300.0]), ..Default::default() }; eframe::run_native( "flow-builder", options, Box::new(|_cc| Ok(Box::new(MyApp::default()))), ) } #[derive(Default)] struct MyApp; impl eframe::App for MyApp { fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { ui.heading("It works!"); } }` > **Why `ViewportBuilder::default()` and not `::new()`?** In egui 0.35, `ViewportBuilder` has **no `new()` constructor** — it derives `Default` and exposes only `with_*` builder methods. Use `ViewportBuilder::default()` and chain `.with_inner_size(...)`, `.with_title(...)`, etc. If you see `ViewportBuilder::new()` in an older tutorial, it will not compile against 0.35. > **Why `ui` and not `update`?** As of egui 0.34, the old `App::update(ctx, frame)` was split into `logic(ctx, frame)` and `ui(ui, frame)`. In 0.35, `update` is **removed**. You implement `ui` (required) and override `logic` (optional). We explore both in [Chapter 3](https://danpokhrel.github.io/tutorial/ch03-first-window.html) > . Build and run: `$ cargo run --release` Use `--release`. egui in debug builds is noticeably sluggish because the per-frame work is unoptimized; with the `[profile.dev.package."*"]` tweak from earlier, plain `cargo run` is _tolerable_ for iteration, but for an honest feel of the toolkit, `--release` is the way. If the window is blank or the program panics about “no suitable adapter,” double-check that your `eframe` dependency has the `wgpu` feature on — that is what brings in the native GPU backends (see the section above). You should not need any separate `wgpu` dependency. You now have a compiling, correctly-configured project that opens an 800×600 window and draws a heading. In [Chapter 3: Your First Window](https://danpokhrel.github.io/tutorial/ch03-first-window.html) we will fill in the `App` trait properly: the `logic`/`ui` split, `CreationContext` setup, the root `CentralPanel`, basic widgets, and a complete hello-world app with a text field, a slider, and a button. --- # Chapter 3: Your First Window - Building a Node-Based AI Flow Editor with eframe & egui Keyboard shortcuts ------------------ Press ← or → to navigate between chapters Press S or / to search in the book Press ? to show this help Press Esc to hide this help * Auto * Light * Rust * Coal * Navy * Ayu Building a Node-Based AI Flow Editor with eframe & egui ======================================================= [](https://danpokhrel.github.io/tutorial/print.html "Print this book") In this chapter we take the empty window from [Chapter 2](https://danpokhrel.github.io/tutorial/ch02-project-setup.html) and turn it into a real application: one that holds state, responds to input, and persists across frames. Along the way we will get hands-on with the `App` trait, the `logic`/`ui` split introduced in egui 0.34, the `CreationContext` one-time setup hook, and the root `CentralPanel`. By the end you will have a complete hello-world app—a name field, an age slider, and a counter button—and the mental tools to extend it. We will also reference a few Rust concepts along the way, especially closures (Rust Book [Ch. 13](https://doc.rust-lang.org/stable/book/ch13-01-closures.html) ) and traits (Rust Book [Ch. 10](https://doc.rust-lang.org/stable/book/ch10-02-traits.html) ). Everything you write in eframe hangs off the `eframe::App` trait. In eframe 0.35, the trait has one **required** method and several **provided** ones you may override: | Method | Kind | Purpose | | --- | --- | --- | | `fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame)` | **required** | Build the UI for this frame. | | `fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame)` | provided | Mutate state, poll async, run animations. Override when needed. | | `fn save(&mut self, storage: &mut dyn Storage)` | provided | Persist state. Requires the `persistence` feature. | | `fn on_exit(&mut self, _gl: Option<&mut glow::Context>)` | provided | Cleanup on shutdown. | | `fn clear_color(&self, _visuals: &Visuals) -> [f32; 4]` | provided | Background clear color. | | `fn raw_input_hook(...)` | provided | Inspect raw input before egui processes it. | > **Warning: `update` is gone.** If you are following an older tutorial, you will see `fn update(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame)`. That method was **removed in 0.35** (it was split into `logic` + `ui` back in 0.34). Copying old code verbatim will not compile. Split the work as described below. egui 0.34 introduced, and 0.35 keeps, a clean separation of the per-frame work into two methods: * **`logic`** runs **before** `ui`. It receives a `&egui::Context` and the `Frame`. This is where you: * Advance animations and timers. * Drain async channel results (network, file IO) into your state. * Read _aggregated_ input via `ctx.input(|i| …)`. * Decide repaint scheduling. * **It must not** build any widgets. You have a `Context`, not a `Ui`. * **`ui`** runs **after** `logic`. It receives a `&mut egui::Ui` and the `Frame`. This is where you: * Read your (already-updated) state. * Emit widgets: `ui.button`, `ui.label`, etc. * **Keep it cheap and mostly side-effect-free.** The UI is a projection of state; if a click mutates something, prefer to record the intent and apply it in the next frame’s `logic`, or apply it directly but keep it trivial. Why split them? Two reasons. First, it makes the data flow one-directional: `state -> ui`, never `ui -> state` in surprising ways. Second, it lets egui reason about whether a repaint is needed—if `logic` reports no change and no input arrived, the frame can be skipped entirely. (See the Rust Book’s discussion of [ownership and data flow](https://doc.rust-lang.org/stable/book/ch04-00-understanding-ownership.html) for the general philosophy of keeping data movement explicit.) You launch an eframe app by handing a configuration struct and a closure to `eframe::run_native`. The closure is called once, with a `CreationContext`, and must return your `App` boxed up. Closures here follow exactly the patterns described in Rust Book [Ch. 13](https://doc.rust-lang.org/stable/book/ch13-01-closures.html) : `use eframe::egui; fn main() -> eframe::Result { let options = eframe::NativeOptions { viewport: egui::ViewportBuilder::default() .with_inner_size([800.0, 600.0]) .with_min_inner_size([400.0, 300.0]) .with_title("flow-builder"), ..Default::default() }; eframe::run_native( "flow-builder", options, Box::new(|cc| Ok(Box::new(HelloApp::new(cc)))), ) }` `Box::new(|cc| …)` is an `AppCreator`—a boxed closure capturing nothing (it can’t; eframe calls it from a context where only `cc` is available). It returns `Result, Box>`. The error type must be `Box` specifically because eframe is generic over the error so it can be propagated across the thread boundary where the closure is invoked — a plain `Box` would not be `Send` and the closure would not compile. This is why our `HelloApp::new` returns a `Result` with that exact error type: setup steps like loading fonts can fail, and the `AppCreator` closure needs to forward those errors back to `run_native`. See the Rust Book on [the `Result` type and `?`](https://doc.rust-lang.org/stable/book/ch09-02-recoverable-errors-with-result.html) . The `CreationContext` (`cc`) is the one-time setup hook. It is where you: * Install **image loaders** (e.g. `egui_extras::image` / `svg`). Without this, `Image` widgets do nothing—see the “beginner mistakes” in [Chapter 1](https://danpokhrel.github.io/tutorial/ch01-what-is-eframe.html) . * Configure **fonts** if you need a non-default font or extra glyphs (e.g. for icons or non-Latin scripts). * Tune **`Visuals`** (dark/light, spacing, colors) via `cc.egui_ctx.set_visuals(...)`. * Set up a **custom repainting** strategy. A typical `new`: `#![allow(unused)] fn main() { use eframe::{egui, CreationContext}; pub struct HelloApp { name: String, age: u32, clicks: u32, } impl HelloApp { pub fn new(cc: &CreationContext<'_>) -> Result> { // Image loaders so Image/Svg widgets work later. egui_extras::install_image_loaders(&cc.egui_ctx); // Dark theme by default. cc.egui_ctx.set_visuals(egui::Visuals::dark()); Ok(Self { name: "World".to_owned(), age: 25, clicks: 0, }) } } }` Because `HelloApp::new` returns a `Result`, the `AppCreator` closure in `main` can propagate setup failures cleanly—a more idiomatic choice than `unwrap()`\-ing in `new`. See the Rust Book on [the `Result` type and `?`](https://doc.rust-lang.org/stable/book/ch09-02-recoverable-errors-with-result.html) . > **Tip: `CreationContext` is borrowed, not owned.** The `&CreationContext<'_>` you receive in `new` borrows from the closure invocation — it lives only for the duration of the `AppCreator` call. You **cannot** store it on your `App` struct. If you need something from `cc` (the `egui_ctx`, the `storage`, the integration info), extract what you need into owned data or clone the `Context` (which is `Arc`\-backed and cheap to clone) and store _that_. The root `egui::Ui` handed to `App::ui` has **no margin and no background**. If you draw straight into it, your content sits jammed against the window edge and floats on the clear color. You almost always wrap everything in a panel. `CentralPanel::default()` is the root container that fills the remaining space and gives you the default theme’s background and padding: `#![allow(unused)] fn main() { impl eframe::App for HelloApp { fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { // Panels take &mut Ui since 0.34 — NOT &Context. egui::CentralPanel::default().show(ui, |ui| { ui.heading("Hello, eframe!"); }); } } }` > **API note (0.34+).** Panel `show()` methods take a `&mut Ui`, not a `&Context`. In tutorials older than 0.34 you will see `CentralPanel::default().show(ctx, …)`. That no longer compiles. If you are inside `App::ui`, you already have the `&mut Ui`; just pass it directly — `CentralPanel::default().show(ui, |ui| { … })`. When you genuinely have only a `Context`—for example, in `run_simple_native` or in `App::logic`—you use `.show(ctx, …)` on that path instead, because those entry points give you a `&Context` and construct the root `Ui` internally. egui’s `Ui` is a bag of builder methods. Each returns a `Response` you can inspect. Here are the ones you will use constantly: `#![allow(unused)] fn main() { ui.heading("A heading"); ui.label("A plain label."); // Single-line text bound to a &mut String: ui.text_edit_singleline(&mut self.name); // A slider over a &mut numeric: ui.add(egui::Slider::new(&mut self.age, 0..=120).text("age")); // A checkbox: let mut flag = true; ui.checkbox(&mut flag, "enable"); // A button — returns Response, .clicked() is true for one frame: if ui.button("Increment").clicked() { self.clicks += 1; } // Styled text: ui.colored_label(egui::Color32::RED, "red text"); }` Two patterns to internalize: 1. **Mutate through references.** `text_edit_singleline` takes `&mut String`, `Slider::new` takes `&mut u32`. egui writes straight into your fields. There is no “get the widget’s value” step—your struct _is_ the value. 2. **`clicked()` is true for one frame.** It is not a persistent state. You read it in the frame the click occurred and act immediately. For more on how methods on `self`/`&self`/`&mut self` map to call ergonomics, the Rust Book’s [Ch. 5 on method syntax](https://doc.rust-lang.org/stable/book/ch05-03-method-syntax.html) is the relevant background. This is the heart of immediate mode, restated for our concrete example. `self.name`, `self.age`, and `self.clicks` live on `HelloApp`, which lives across frames (eframe keeps your `App` around between calls to `ui`). Each frame, the widgets simply read and write those fields. Nothing needs to be “synced” anywhere—the next frame’s widgets will naturally reflect whatever value the fields now hold. Contrast this with retained mode, where you would have a `TextBox` widget holding its own `String` that you must push to and pull from. In egui that widget does not exist; only your data does. Putting it all together, here is a full, compilable `main.rs`\-style hello world: a name field, an age slider, and an increment button, all inside a `CentralPanel`: `#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] use eframe::{egui, CreationContext}; fn main() -> eframe::Result { let options = eframe::NativeOptions { viewport: egui::ViewportBuilder::default() .with_inner_size([480.0, 320.0]) .with_min_inner_size([320.0, 200.0]) .with_title("flow-builder"), ..Default::default() }; eframe::run_native( "flow-builder", options, Box::new(|cc| Ok(Box::new(HelloApp::new(cc)?))), ) } pub struct HelloApp { name: String, age: u32, clicks: u32, } impl HelloApp { pub fn new(cc: &CreationContext<'_>) -> Result> { egui_extras::install_image_loaders(&cc.egui_ctx); Ok(Self { name: "World".to_owned(), age: 25, clicks: 0, }) } } impl eframe::App for HelloApp { fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { egui::CentralPanel::default().show(ui, |ui| { ui.heading("Hello, eframe!"); ui.separator(); ui.horizontal(|ui| { ui.label("Your name:"); ui.text_edit_singleline(&mut self.name); }); ui.add(egui::Slider::new(&mut self.age, 0..=120).text("age")); ui.horizontal(|ui| { if ui.button("Increment").clicked() { self.clicks += 1; } ui.label(format!("clicked {} times", self.clicks)); }); ui.separator(); ui.label(format!("Hello, {} (age {})!", self.name, self.age)); }); } }` Run it with `cargo run --release`. Type a name, drag the slider, click the button—the values flow straight into your struct and the bottom line updates next frame. > **Passing `ui` directly.** When `App::ui` hands you a `&mut Ui`, you pass it straight to `CentralPanel::default().show(ui, …)`. There is no need for a bridge method or a context lookup — the `Ui` is already the right type for `show` in 0.35. We will compose multiple panels in [Chapter 4](https://danpokhrel.github.io/tutorial/ch04-layout-widgets.html) > . For throwaway prototypes or examples, you do not even need an `App` struct. eframe offers a closure-based entry point: `fn main() -> eframe::Result { let mut name = String::from("World"); let mut clicks = 0u32; eframe::run_simple_native("prototype", Default::default(), |ctx, _frame| { egui::CentralPanel::default().show(ctx, |ui| { ui.text_edit_singleline(&mut name); if ui.button("click").clicked() { clicks += 1; } ui.label(format!("{} clicks", clicks)); }); }) }` This is great for examples and one-off tools. Note that the closure receives a `Context` (not a `Ui`), so here `CentralPanel::show` does take `ctx`—this is the non-`App` path and is unaffected by the 0.34 panel-signature change. For anything beyond a quick demo, graduate to the `App` struct so you can use `logic`/`ui`, persistence, and clean state ownership. egui repaints on demand (see [Chapter 1](https://danpokhrel.github.io/tutorial/ch01-what-is-eframe.html) ). If a background thread produces data your UI should show, you must tell egui to repaint, or nothing will update until the user wiggles the mouse. `egui::Context` is cheaply cloneable (it is `Arc`\-backed internally), so the pattern is: clone it, move the clone into the thread, and call `request_repaint()` when there is new data: `#![allow(unused)] fn main() { use eframe::egui; use std::thread; use std::time::Duration; fn spawn_background_work(ctx: egui::Context) { thread::spawn(move || { for i in 0.. { // ... do work, produce a new value ... thread::sleep(Duration::from_millis(500)); // Tell egui: "please repaint, new data is available." ctx.request_repaint(); // (You would also push the data into a channel your // App::logic drains; request_repaint just ensures the // next frame runs so logic/ui see the new value.) let _ = i; } }); } }` > **Warning: do not hold the context lock across UI building.** Context access for _reading_ state (input, output) is closure-scoped: `ctx.input(|i| { /* read */ })`. This scoped form exists precisely so you never hold a lock while building widgets, which would risk deadlocks and borrow conflicts. Always scope context reads tightly, as shown. The `Arc`\-backed `Context` and the `move` closure are exactly the patterns the Rust Book covers in [Ch. 16 on threads](https://doc.rust-lang.org/stable/book/ch16-00-concurrency.html) and [Ch. 13 on closures](https://doc.rust-lang.org/stable/book/ch13-01-closures.html) . You can now build an `App`, set it up via `CreationContext`, lay out widgets in a `CentralPanel`, and drive repaints from background work. [Chapter 4: Layout, Widgets & State](https://danpokhrel.github.io/tutorial/ch04-layout-widgets.html) moves from a single panel to a real multi-panel layout: side panels, top bars, scroll areas, grids, richer text, and—critically—how to build your own custom `egui::Widget` and keep widget `Id`s stable in loops. --- # Chapter 5: Structuring Your Application - Building a Node-Based AI Flow Editor with eframe & egui Keyboard shortcuts ------------------ Press ← or → to navigate between chapters Press S or / to search in the book Press ? to show this help Press Esc to hide this help * Auto * Light * Rust * Coal * Navy * Ayu Building a Node-Based AI Flow Editor with eframe & egui ======================================================= [](https://danpokhrel.github.io/tutorial/print.html "Print this book") Up to now we have kept every piece of state, every widget, and every bit of logic inside a single `App` struct and a single `ui()` method. That works for a handful of widgets, but it does not scale. In this chapter we’ll learn how to split a growing egui application into a clean data model, a rendering layer, and a state-owning app shell — the same kind of separation you saw in the Rust Book when modules were introduced in [Chapter 7](https://doc.rust-lang.org/stable/book/ch07-00-managing-growing-projects-with-packages-crates-and-modules.html) . By the end you’ll have an architecture that keeps your pure data model free of any GUI dependency, so it can be unit-tested on a headless CI machine without a GPU or a window. A node-graph editor is a useful example because it mixes three concerns that each want their own home: * **Persistent data** — the nodes, pins, and links that make up the graph. This data outlives any single frame and is often serialized to disk. * **Background work** — loading a graph from a file, rendering a preview image, computing a simulation step. These run off the UI thread and deliver results back asynchronously. * **Ephemeral UI state** — which panel is open, which node is hovered, whether a drag is in progress. This is meaningless without a window and should not live next to the graph data. If all three live as fields on one giant struct and all logic lives in `ui()`, you end up with a 600-line method that borrows `&mut self` ten different ways in a single frame and fights the borrow checker at every turn. The fix is not cleverer borrowing — it is better structure. In eframe the `App` trait is the entry point the runtime calls every frame. The trait requires: `#![allow(unused)] fn main() { use eframe::egui; impl eframe::App for MyApp { fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) { // Read state and build widgets here. } } }` You may also override `logic()` for work that should happen before any widgets are built — polling async tasks, advancing animations, ticking timers: `#![allow(unused)] fn main() { impl eframe::App for MyApp { fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) { // Poll background tasks, advance animations, update timers. self.poll_background_tasks(ctx); self.advance_animations(ctx); } fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) { // Build the UI from current state. self.draw_graph(ui); self.draw_panels(ui); } } }` The key rule of thumb: **`logic()` mutates state; `ui()` reads state and builds widgets.** If you find yourself mutating persistent data inside `ui()`, ask whether that mutation belongs in `logic()` instead. Interaction _intents_ (a button was clicked) are collected in `ui()` and applied in `logic()` on the next frame — more on this below. The `App` struct owns everything: ``#![allow(unused)] fn main() { use std::sync::{Arc, Mutex}; pub struct MyApp { /// The graph — persistent, serializable, no egui dependency. pub graph: GraphState, /// Ephemeral UI flags — never serialized. pub ui_state: UiState, /// Background task results waiting to be folded into `graph`. pub pending: Arc>>, /// User configuration. pub config: Config, } }`` Following the guidance in Rust Book [Chapter 7](https://doc.rust-lang.org/stable/book/ch07-02-modules-and-use-to-control-scope-and-privacy.html) , we organize the crate into modules by concern. A layout that works well for a medium-sized egui app looks like this: `src/ ├── main.rs // entry point, eframe launch (stays thin) ├── theme.rs // EditorTheme: visual style, colors ├── graph/ │ ├── mod.rs // re-exports │ ├── model.rs // NodeId, PinId, Pin, Node, Link — pure data │ └── state.rs // GraphState — ID allocation, mutations └── ui/ ├── mod.rs // re-exports (pub use app::App;) ├── app.rs // the App struct + eframe::App impl (state owner) └── viewer.rs // the SnarlViewer impl + node enum (Ch. 8+)` The important boundary is between `graph/` and `ui/`: * `graph/` depends on `serde` and nothing else GUI-related. It compiles and tests on a machine with no display. * `ui/` depends on `egui` (and, from [Chapter 8](https://danpokhrel.github.io/tutorial/ch08-egui-snarl.html) , `egui_snarl`). It turns graph data into pixels. This is the same “separate the library from the binary” idea described in Rust Book [Chapter 7](https://doc.rust-lang.org/stable/book/ch07-01-packages-and-crates-for-making-libraries-and-executables.html) and it pays off the moment you want to write property tests against your graph model. Let’s define the core types. These are plain Rust structs with `serde` derives — no `egui` anywhere. ``#![allow(unused)] fn main() { // src/graph/model.rs use serde::{Deserialize, Serialize}; /// A newtype around `u64` identifying a node. /// /// This is the "newtype idiom" from Rust Book /// [Ch. 19.3](https://doc.rust-lang.org/stable/book/ch19-03-advanced-traits.html#using-the-newtype-pattern-to-implement-external-traits). /// Wrapping the raw integer gives us a distinct type the compiler can /// check, so you can never accidentally pass a `LinkId` where a `NodeId` /// is expected. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] pub struct NodeId(pub u64); #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] pub struct LinkId(pub u64); /// A newtype around `u64` identifying a pin on a node, distinct from /// `NodeId`/`LinkId` so endpoints can't be mixed up at compile time. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] pub struct PinId(pub u64); /// An input or output socket on a node. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Pin { pub id: PinId, pub name: String, pub kind: PinKind, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum PinKind { Input, Output, } /// A node in the graph — pure data. Position is stored here because it /// is part of the saved document, but it is the *only* UI-ish field. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Node { pub id: NodeId, pub title: String, pub pins: Vec, pub position: [f64; 2], } /// A connection between an output pin and an input pin. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Link { pub id: LinkId, pub from_node: NodeId, pub from_pin: PinId, pub to_node: NodeId, pub to_pin: PinId, } }`` > **Tip:** Keep `position` in the model only if you consider layout part of the document. If layout is purely cosmetic, move it into a side-table in `UiState` keyed by `NodeId`. That keeps the document format stable when users rearrange nodes. `GraphState` owns the nodes, links, and the counters that mint new IDs. IDs are **monotonically incrementing and never recycled**. Recycling IDs is a classic source of bugs: a stale link that referenced a deleted node suddenly points at a brand-new, unrelated node. By never recycling, a dangling `NodeId` is harmless — it simply refers to nothing. `#![allow(unused)] fn main() { // src/graph/state.rs use super::model::{Link, LinkId, Node, NodeId, PinId}; use std::collections::HashMap; #[derive(Debug, Default)] pub struct GraphState { pub nodes: HashMap, pub links: HashMap, next_node_id: u64, next_link_id: u64, } impl GraphState { pub fn add_node(&mut self, title: impl Into, position: [f64; 2]) -> NodeId { let id = NodeId(self.next_node_id); self.next_node_id += 1; self.nodes.insert( id, Node { id, title: title.into(), pins: Vec::new(), position, }, ); id } pub fn add_link( &mut self, from_node: NodeId, from_pin: PinId, to_node: NodeId, to_pin: PinId, ) -> LinkId { debug_assert!( self.nodes.contains_key(&from_node) && self.nodes.contains_key(&to_node), "add_link: endpoint node does not exist" ); let id = LinkId(self.next_link_id); self.next_link_id += 1; self.links.insert( id, Link { id, from_node, from_pin, to_node, to_pin, }, ); id } pub fn remove_node(&mut self, id: NodeId) { self.nodes.remove(&id); // Remove links that referenced this node. IDs are not recycled, // so any surviving references are simply gone. self.links .retain(|_, l| l.from_node != id && l.to_node != id); } } }` Because `GraphState` has no `egui` dependency, you can write a normal `#[test]` against it: `#![allow(unused)] fn main() { #[cfg(test)] mod tests { use super::*; #[test] fn removing_a_node_drops_its_links() { let mut g = GraphState::default(); let a = g.add_node("A", [0.0, 0.0]); let b = g.add_node("B", [100.0, 0.0]); let _link = g.add_link(a, PinId(0), b, PinId(0)); assert_eq!(g.links.len(), 1); g.remove_node(a); assert!(g.links.is_empty()); } } }` That test runs anywhere Rust runs — no GPU, no window, no display server. egui is _immediate mode_: every frame you rebuild the entire UI from current state. The `logic()` / `ui()` split maps cleanly onto two phases: 1. **`logic()` runs first.** Advance animations, poll background tasks, apply queued intents. This is where `&mut self` mutations of _persistent_ data belong. 2. **`ui()` runs second.** Read state, build widgets, collect new intents (button clicks, drag deltas) for next frame’s `logic()`. ``#![allow(unused)] fn main() { // src/app.rs use eframe::egui; use std::sync::{Arc, Mutex}; pub struct MyApp { pub graph: GraphState, pub ui_state: UiState, pub pending: Arc>>, } /// An intent collected from the UI, to be applied in `logic()`. pub enum Intent { DeleteNode(NodeId), AddLink(NodeId, u64, NodeId, u64), } impl eframe::App for MyApp { fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { // 1. Fold in background results. let mut pending = self.pending.lock().unwrap(); for result in pending.drain(..) { self.apply_background_result(result); } drop(pending); // 2. Apply intents collected last frame. for intent in self.ui_state.intents.drain(..) { match intent { Intent::DeleteNode(id) => self.graph.remove_node(id), Intent::AddLink(a, ap, b, bp) => { self.graph.add_link(a, ap, b, bp); } } } } fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { // Read-only access to graph; collect intents for next frame. self.draw_graph(ui); self.draw_panels(ui); } } }`` The `intents` buffer decouples “the user clicked delete” from “delete the node.” That decoupling is what lets `ui()` stay mostly immutable and keeps the borrow checker happy. > **Where do intents get pushed?** Inside `draw_graph` or `draw_panels` — the methods called from `ui()`. For example, a delete button’s click handler would do `self.ui_state.intents.push(Intent::DeleteNode(node.id))` rather than calling `self.graph.remove_node(...)` directly. The actual mutation happens in the next frame’s `logic()`, which drains the buffer. The `apply_background_result` method called in `logic()` is a placeholder for whatever your app does with loaded data — e.g., parsing bytes into a graph, updating a status message, etc. Define it as a regular `&mut self` method on `MyApp`. Long-running work — reading a large file, decoding an image, running a simulation — must not happen on the UI thread or the frame rate collapses. The pattern is: 1. **Spawn** the work on another thread. 2. The worker stores its result in a shared `Arc>`. 3. The worker calls `ctx.request_repaint()` so egui wakes up to consume the result. 4. **Poll** the shared buffer in `logic()` and fold results into your state. Because egui repaints on demand, a background thread finishing its work does not by itself cause a frame. The worker must explicitly request one. ``#![allow(unused)] fn main() { use eframe::egui; use std::sync::{Arc, Mutex}; use std::thread; pub enum BackgroundResult { FileLoaded { name: String, bytes: Vec }, } impl MyApp { /// Spawn a background file read. The result lands in `self.pending` /// and a repaint is requested when it arrives. pub fn load_file_async( &self, path: std::path::PathBuf, ctx: egui::Context, pending: Arc>>, ) { let name = path .file_name() .map(|n| n.to_string_lossy().to_string()) .unwrap_or_default(); thread::spawn(move || { // Simulate slow I/O. let bytes = std::fs::read(&path).unwrap_or_default(); let result = BackgroundResult::FileLoaded { name, bytes }; { let mut buf = pending.lock().unwrap(); buf.push(result); } // Wake the UI so logic() can drain the buffer this frame. ctx.request_repaint(); }); } fn apply_background_result(&mut self, result: BackgroundResult) { match result { BackgroundResult::FileLoaded { name, bytes: _ } => { // Parse bytes into the graph, update state, etc. self.ui_state.status = format!("Loaded {name}"); } } } } }`` > **Note:** `ctx: egui::Context` is cheaply cloneable and safe to send to another thread — it is wrapped in an `Arc` internally. Cloning it and moving the clone into a thread is the intended way to request repaints from background work. This mirrors the “shared state with `Arc>`” pattern from Rust Book [Chapter 16](https://doc.rust-lang.org/stable/book/ch16-03-shared-state.html) > . For web targets you would use `wasm_bindgen`\-friendly async instead of `std::thread`, but the `Arc>>` + `request_repaint()` shape is identical. The single most useful borrowing technique in egui is **collect, then mutate**. You take an immutable (or carefully scoped) borrow to gather what the user did, end that borrow, and _then_ take `&mut self` to apply the changes. Consider a list of nodes where each can be deleted by a button: ``#![allow(unused)] fn main() { impl MyApp { fn draw_node_list(&mut self, ui: &mut egui::Ui) { // Collect the IDs of nodes whose delete button was clicked. let to_delete: Vec = self .graph .nodes .values() .filter_map(|node| { let mut should_delete = false; ui.horizontal(|ui| { ui.label(&node.title); if ui.button("Delete").clicked() { should_delete = true; } }); should_delete.then_some(node.id) }) .collect(); // Now the immutable borrow of `self.graph.nodes` has ended // (it died with `to_delete`'s creation). We can mutate freely. for id in to_delete { self.graph.remove_node(id); } } } }`` The borrow checker is satisfied because `to_delete` owns _copies_ of the `NodeId` values (the newtype wraps a `Copy` type). By the time we call `remove_node`, no borrow of `self.graph` is outstanding. If you instead tried to mutate inside the iterator — `if clicked { self.graph.remove_node(node.id) }` — you would get the classic “cannot borrow `self.graph` as mutable because it is also borrowed as immutable” error described in Rust Book [Chapter 4](https://doc.rust-lang.org/stable/book/ch04-02-references-and-borrowing.html) . The collect-then-mutate pattern is the standard remedy. Keep a separate struct for ephemeral, non-serialized UI flags: `#![allow(unused)] fn main() { // src/ui/mod.rs use crate::graph::model::NodeId; #[derive(Debug, Default)] pub struct UiState { /// Intents collected this frame, drained in next frame's logic(). pub intents: Vec, /// Which node, if any, is currently being dragged. pub dragged_node: Option, /// The currently hovered node, for highlighting. pub hovered_node: Option, /// Whether the settings panel is open. pub settings_open: bool, /// A transient status message. pub status: String, } }` `UiState` is never written to disk; it resets every time the app starts. `GraphState` and `Config`, by contrast, are serialized with `serde`. Keeping them apart means a `#[derive(Serialize)]` on `MyApp` is trivially safe — you simply skip the `ui_state` field with `#[serde(skip)]`: `#![allow(unused)] fn main() { #[derive(serde::Serialize, serde::Deserialize)] pub struct MyApp { pub graph: GraphState, pub config: Config, #[serde(skip)] pub ui_state: UiState, #[serde(skip)] pub pending: std::sync::Arc>>, } }` Holding `&mut self` inside `ui()` lets you reborrow individual fields, which is the key to ergonomic egui code. The two patterns that resolve almost every conflict: 1. **Scoped closures.** `ui.horizontal(|ui| { ... })` takes a closure that borrows `ui` for its duration; when it returns, the borrow ends and you can touch `self` again. This is the closure-capturing behavior from Rust Book [Chapter 13](https://doc.rust-lang.org/stable/book/ch13-01-closures.html) . 2. **Collect-then-mutate.** Gather copies of what you need, let the borrow end, then mutate — as shown above. When a method needs to both read and write `self`, split it: take an immutable borrow in one method that returns data, and a mutable borrow in another that applies it. `#![allow(unused)] fn main() { impl MyApp { /// Immutable: figure out what the user wants to drag. fn collect_drag(&self, ui: &mut egui::Ui) -> Option<(NodeId, [f64; 2])> { self.graph.nodes.values().find_map(|node| { let resp = ui.add(egui::Label::new(&node.title)); if resp.dragged() { Some((node.id, resp.drag_delta().into())) } else { None } }) } /// Mutable: apply the drag. fn apply_drag(&mut self, id: NodeId, delta: [f64; 2]) { if let Some(node) = self.graph.nodes.get_mut(&id) { node.position[0] += delta[0]; node.position[1] += delta[1]; } } } }` Called back-to-back, these never conflict: `#![allow(unused)] fn main() { if let Some((id, delta)) = self.collect_drag(ui) { self.apply_drag(id, delta); } }` > **Important: what happens to `GraphState` when we adopt `egui-snarl`?** This chapter builds a pure-data `GraphState` and praises its headless testability — and that praise is justified. But when we introduce `egui-snarl` in [Chapter 8](https://danpokhrel.github.io/tutorial/ch08-egui-snarl.html) > , the `Snarl` container **replaces** `GraphState` as the runtime graph data structure. `Snarl` is its own graph container: it owns nodes, positions, and wires, and it provides its own `insert_node` / `connect` / `remove_node` methods. So what is `GraphState` for? It is a **pedagogical stepping stone** — it teaches you the data-model-without-GUI pattern before we introduce a library that already embodies it. Once you adopt `Snarl`, you can either (a) delete the `graph/` module entirely and let `Snarl` be your graph, or (b) keep the `graph/` module purely for its unit test as a regression check. We take option (b) in the reference implementation: the `graph/` module compiles with `#![allow(dead_code)]` so the test still runs, but the runtime path uses `Snarl`. The lesson — _keep your data model testable without a GPU_ — carries forward; `Snarl` and `AgentNode` are themselves plain data types that serialize and test cleanly. * * * With our application now cleanly split into a testable data model, a rendering layer, and a state-owning shell, the next concern is making it look good. In [Chapter 6](https://danpokhrel.github.io/tutorial/ch06-theming.html) we’ll cover theming — visuals, style, fonts, and custom color palettes — so your application can have a cohesive look instead of egui’s defaults. --- # Chapter 7: Input, Menus & Dialogs - Building a Node-Based AI Flow Editor with eframe & egui Keyboard shortcuts ------------------ Press ← or → to navigate between chapters Press S or / to search in the book Press ? to show this help Press Esc to hide this help * Auto * Light * Rust * Coal * Navy * Ayu Building a Node-Based AI Flow Editor with eframe & egui ======================================================= [](https://danpokhrel.github.io/tutorial/print.html "Print this book") An application is more than a display of data — it responds to the keyboard, the mouse, menus, and dialogs. In this chapter we’ll cover egui’s input system, build a menu bar with dropdowns, add right-click context menus, open floating windows, implement a modal dialog from scratch (egui has no built-in one), and wire up native file dialogs with the `rfd` crate. By the end we’ll assemble all of it into a single example app. This chapter assumes you have read [Chapter 5](https://danpokhrel.github.io/tutorial/ch05-architecture.html) on structuring your app and [Chapter 6](https://danpokhrel.github.io/tutorial/ch06-theming.html) on theming. All input state lives on the `egui::Context`. Because the context’s input is guarded by an internal lock, you access it through **closures** — you must never hold the lock across widget building. The closure form `ctx.input(|i| ...)` takes a shared borrow, runs your closure, and releases the lock before returning: `#![allow(unused)] fn main() { use eframe::egui; impl eframe::App for MyApp { fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { // Read-only input access via a closure. let save_pressed = ctx.input(|i| { i.key_pressed(egui::Key::S) && (i.modifiers.ctrl || i.modifiers.command) }); if save_pressed { self.save(); } } fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { // ... } } }` > **Warning:** Never store the `egui::InputState` returned by the closure outside it, and never call `ui.add(...)` from inside the closure. The lock must be released before you build widgets, or you will deadlock or panic. The closure form makes this impossible to get wrong — the borrow ends when the closure returns. For mutations — consuming an event so no one else sees it — use `ctx.input_mut`: `#![allow(unused)] fn main() { ctx.input_mut(|i| { if i.key_pressed(egui::Key::Escape) { i.events.clear(); // consume remaining events this frame } }); }` > **When to use `input_mut`:** You rarely need it. The main use case is `consume_shortcut`, which marks a shortcut as handled so it does not fire twice. For simply reading input state, always use `ctx.input(|i| ...)` (the shared borrow). Mutating input state outside of shortcut consumption is almost never necessary. Every widget you add to a `Ui` returns a `Response`, which tells you how the user interacted with _that specific widget_. This is the primary way to react to clicks, hovers, and drags: `#![allow(unused)] fn main() { let response = ui.button("Click me"); if response.clicked() { println!("clicked"); } if response.hovered() { println!("hovered"); } // Tooltips and context menus are also methods on Response. response.on_hover_text("This button does a thing"); }` The most useful `Response` methods are: | Method | Meaning | | --- | --- | | `clicked()` | The widget was clicked this frame. | | `hovered()` | The pointer is over the widget. | | `dragged()` | The widget is being dragged (button held + moved). | | `drag_delta()` | How far it moved since last frame. | | `on_hover_text(s)` | Show a tooltip. | | \`context\_menu( | ui | Because `Response` is returned by value, you can collect interaction results and act on them after the immutable borrow ends — the collect-then-mutate pattern from [Chapter 5](https://danpokhrel.github.io/tutorial/ch05-architecture.html) . For structured shortcuts, use `KeyboardShortcut` and `consume_shortcut`. This pairs a `Modifiers` value with a `Key` and lets egui handle the bookkeeping of whether the shortcut was pressed _and_ not yet consumed: `#![allow(unused)] fn main() { use eframe::egui; impl eframe::App for MyApp { fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { let new_shortcut = egui::KeyboardShortcut::new( egui::Modifiers::CTRL, egui::Key::N, ); let open_shortcut = egui::KeyboardShortcut::new( egui::Modifiers::CTRL, egui::Key::O, ); ctx.input_mut(|i| { if i.consume_shortcut(&new_shortcut) { self.new_document(); } if i.consume_shortcut(&open_shortcut) { self.open_document(); } }); } fn ui(&mut self, _ui: &mut egui::Ui, _frame: &mut eframe::Frame) {} } }` `consume_shortcut` returns `true` only if the shortcut was pressed this frame and has not already been consumed, which prevents double-handling when multiple widgets listen for the same key. The pattern of branching on a value and running code per arm is the same `match`\-style flow control introduced in Rust Book [Chapter 6](https://doc.rust-lang.org/stable/book/ch06-02-match.html) . Mouse state is part of the same input closure: `#![allow(unused)] fn main() { ctx.input(|i| { let pos = i.pointer.primary_pressed(); // Option on press let _hover = i.pointer.hover_pos(); // Option current pos let _mods = i.modifiers; // Modifiers (ctrl, shift, ...) let _clicked = i.pointer.primary_clicked(); }); }` `pointer.primary_pressed()` fires on the frame the button goes down; `primary_clicked()` fires on a completed click (down then up without much movement). Use `hover_pos()` for things like cursor-following tooltips. A top menu bar is a `Panel::top` containing a `MenuBar` closure. Inside, `ui.menu_button` creates a dropdown, and `ui.close()` closes it programmatically (useful after an action that should dismiss the menu): `#![allow(unused)] fn main() { use eframe::egui; impl eframe::App for MyApp { fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { egui::Panel::top("menu_bar").show(ui, |ui| { egui::MenuBar::new().ui(ui, |ui| { ui.menu_button("File", |ui| { if ui.button("New").clicked() { self.new_document(); ui.close(); } if ui.button("Open...").clicked() { self.open_document(); ui.close(); } if ui.button("Save").clicked() { self.save(); ui.close(); } ui.separator(); if ui.button("Quit").clicked() { ui.ctx().send_viewport_cmd( egui::ViewportCommand::Close, ); } }); ui.menu_button("Edit", |ui| { if ui.button("Undo").clicked() { self.undo(); ui.close(); } }); }); }); } } }` Recall from [Chapter 5](https://danpokhrel.github.io/tutorial/ch05-architecture.html) that `Panel::show` takes a `&mut Ui` (since 0.34). The menu items are ordinary buttons; their `clicked()` results drive your app state. Right-click menus attach to any `Response`: `#![allow(unused)] fn main() { let response = ui.label("Right-click me"); response.context_menu(|ui| { if ui.button("Rename").clicked() { self.ui_state.renaming = true; ui.close(); } if ui.button("Delete").clicked() { self.delete_selected(); ui.close(); } ui.separator(); if ui.button("Duplicate").clicked() { self.duplicate_selected(); ui.close(); } }); }` The menu appears automatically when the user right-clicks the widget and disappears when an item is chosen or the user clicks elsewhere. No extra state is required on your part. `egui::Window` is a floating, draggable, collapsible, resizable panel — the kind of window you’d use for a settings dialog or a tool palette. Its `.show` method takes a `&mut Ui` and a closure that builds its contents: `#![allow(unused)] fn main() { use eframe::egui; impl eframe::App for MyApp { fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { // A toggle button controls the window's visibility. ui.checkbox(&mut self.ui_state.settings_open, "Settings"); if self.ui_state.settings_open { egui::Window::new("Settings") .open(&mut self.ui_state.settings_open) .default_pos([100.0, 100.0]) .resizable(true) .collapsible(true) .show(ui, |ui| { ui.label("Application settings"); ui.separator(); ui.checkbox(&mut self.config.autosave, "Autosave"); ui.add( egui::Slider::new(&mut self.config.zoom, 0.5..=3.0) .text("Zoom"), ); }); } } } }` The `.open(&mut bool)` argument binds the window’s open state to a flag. When the user clicks the window’s close button (the X), egui sets the flag to `false`, which is how you detect that the user dismissed it. egui 0.35 introduced a built-in **`egui::Modal`** widget — `Modal::new(id).show(ctx, |ui| ...)` returns a `ModalResponse` with a `backdrop_response` you can check for click-to-dismiss. This is the recommended approach: it handles the dimming, click-capture, and centering for you, so you do not have to hand-roll an `Area` + `Window` stack. A quit-confirmation modal is a one-screen read once you have it. Gate it behind a `quit_modal_open: bool` on your `App`, render it only while the flag is set, and dismiss it on Cancel, Escape, backdrop click, or a confirmed Quit: `#![allow(unused)] fn main() { use eframe::egui; impl eframe::App for MyApp { fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { // ... your normal panels ... // --- Quit confirmation modal (egui 0.35's built-in Modal) --- if self.quit_modal_open { let resp = egui::Modal::new(egui::Id::new("quit_modal")).show(ui.ctx(), |ui| { ui.set_min_width(280.0); ui.heading("Quit"); ui.label("Quit the application?"); ui.add_space(8.0); ui.horizontal(|ui| { // Cancel on the button or the Escape key. let cancel = ui.button("Cancel").clicked() || ui.input(|i| i.key_pressed(egui::Key::Escape)); if cancel { self.quit_modal_open = false; } // Confirm on the button or Enter. let quit = ui.button("Quit").clicked() || ui.input(|i| i.key_pressed(egui::Key::Enter)); if quit { ui.ctx().send_viewport_cmd(egui::ViewportCommand::Close); } }); }); // Clicking the dimmed backdrop also dismisses the modal. if resp.backdrop_response.clicked() { self.quit_modal_open = false; } } } } }` > **Tip: intercept the window’s close button too.** The OS window-manager close (X) button bypasses your modal by default. To route it through the same confirmation, check `ctx.input(|i| i.viewport().close_requested())` in `logic()` and, when the modal is _not_ already open, send `ViewportCommand::CancelClose` and set `quit_modal_open = true`. If the modal is already open, let the request through — the user is insisting. The reference implementation’s `App::logic` does exactly this; see [“The Assembled App”](https://danpokhrel.github.io/tutorial/ch11-interactions.html#the-assembled-app) > in [Chapter 11](https://danpokhrel.github.io/tutorial/ch11-interactions.html) > . Below, for completeness and for cases where you need fine-grained control over the dimming/layering that `Modal` does not expose, is the manual `Area + Window` pattern. In practice, prefer `egui::Modal`. `#![allow(unused)] fn main() { use eframe::egui; pub struct ConfirmModal { pub open: bool, pub message: String, pub on_confirm: bool, // set true when user confirms } impl ConfirmModal { /// Render the modal. Returns Some(true) if confirmed, /// Some(false) if cancelled, None otherwise. pub fn show(&mut self, ui: &mut egui::Ui) -> Option { if !self.open { return None; } let mut result = None; // Dim the rest of the screen and capture clicks. let screen = ui.ctx().screen_rect(); egui::Area::new(egui::Id::new("modal_dimmer")) .order(egui::Order::Foreground) .fixed_pos(screen.min) .show(ui, |ui| { // A transparent-but-clickable layer over the whole screen. let _ = ui.allocate_rect(screen, egui::Sense::click()); let painter = ui.painter(); painter.rect_filled( screen, egui::CornerRadius::ZERO, egui::Color32::from_black_alpha(120), ); }); // The dialog itself, centered. let dialog_size = egui::vec2(320.0, 140.0); let pos = screen.center() - dialog_size / 2.0; egui::Window::new("Confirm") .order(egui::Order::Foreground) .fixed_pos(pos) .resizable(false) .collapsible(false) .movable(false) .show(ui, |ui| { ui.set_min_width(dialog_size.x); ui.label(&self.message); ui.add_space(10.0); ui.horizontal(|ui| { if ui.button("Cancel").clicked() { result = Some(false); } if ui.button("OK").clicked() { result = Some(true); } }); }); if let Some(confirmed) = result { self.open = false; Some(confirmed) } else { None } } } }` The dimming `Area` is drawn first with `Order::Foreground` and allocates the full screen rect with `Sense::click()`, which “eats” clicks so they don’t fall through to widgets underneath. The dialog `Window` is also `Foreground` and drawn after, so it sits on top of the dimmer. Because `movable(false)` and `resizable(false)` are set, it behaves like a true modal. > **Tip:** If you need several modals, give each dimmer a unique `Id` so they don’t clash, and track a `current_modal: Option` enum so only one is open at a time. Enums for state machines are the same idea as Rust Book [Chapter 6](https://doc.rust-lang.org/stable/book/ch06-01-defining-an-enum.html) > . egui does not ship a file picker. The standard choice is the [`rfd`](https://crates.io/crates/rfd) crate (Rusty File Dialog), which opens native OS dialogs and works on the web with an async fallback. Add it to your `Cargo.toml`: `[dependencies] eframe = "0.35" rfd = "0.15"` On desktop, `rfd` is synchronous and returns the result immediately: `#![allow(unused)] fn main() { fn open_file_dialog_sync() -> Option { rfd::FileDialog::new() .add_filter("Graph", &["json"]) .set_title("Open graph") .pick_file() } }` For background-friendliness (and web support), use the async API and spawn it the way we did in [Chapter 5](https://danpokhrel.github.io/tutorial/ch05-architecture.html) — store the result in an `Arc>` and call `ctx.request_repaint()`: ``#![allow(unused)] fn main() { use std::sync::{Arc, Mutex}; use eframe::egui; impl MyApp { pub fn open_file_async( &self, ctx: egui::Context, pending: Arc>>, ) { // `rfd::AsyncFileDialog` works on both native and web. let task = rfd::AsyncFileDialog::new() .add_filter("Graph", &["json"]) .set_title("Open graph") .pick_file(); // On native this spawns a thread; on web it integrates with the // browser's async runtime. Either way, the future resolves later. std::thread::spawn(move || { // Block on the future. On web you'd use a wasm-friendly // executor instead; see rfd's web docs. let file = pollster::block_on(task); if let Some(file) = file { let path = file.path().to_path_buf(); let bytes = std::fs::read(&path).unwrap_or_default(); { let mut buf = pending.lock().unwrap(); buf.push(BackgroundResult::FileLoaded { name: path .file_name() .map(|n| n.to_string_lossy().to_string()) .unwrap_or_default(), bytes, }); } ctx.request_repaint(); } }); } } }`` > **Note:** On the web, `rfd`’s async dialog must be driven by the browser’s event loop and cannot be blocked on with `pollster`. The `Arc>` + `request_repaint()` handoff is the same; only the executor differs. Consult the `rfd` crate’s web documentation for the exact integration with your async runtime. > **Note:** `pollster` is not in our `Cargo.toml`. Add `pollster = "0.3"` if you want the async path. However, on native desktop, `rfd::FileDialog::pick_file()` is already synchronous and blocking — you do not need `pollster`, threads, or async at all. The async API is only needed for web (WASM) targets, where `std::thread` is unavailable. Saving is symmetric — use `save_file()` / `AsyncFileDialog::save_file()`. Clipboard access is on the context: `#![allow(unused)] fn main() { ctx.copy_text("Hello, clipboard"); let pasted = ctx.input(|i| i.events.iter().find_map(|e| { if let egui::Event::Paste(s) = e { Some(s.clone()) } else { None } })); }` `ctx.copy_text` places text on the OS clipboard. Paste arrives as an `Event::Paste` in the input stream; Ctrl/Cmd-V is handled by egui automatically when a text widget has focus, so you usually only need to read paste events manually for custom widgets. You can drive the OS window from inside egui using `ViewportCommand`, sent via `ctx.send_viewport_cmd`: `#![allow(unused)] fn main() { use eframe::egui; impl eframe::App for MyApp { fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { let ctx = ui.ctx(); ui.horizontal(|ui| { if ui.button("Fullscreen").clicked() { ctx.send_viewport_cmd( egui::ViewportCommand::Fullscreen(true), ); } if ui.button("Minimize").clicked() { ctx.send_viewport_cmd(egui::ViewportCommand::Minimize(true)); } if ui.button("SetTitle").clicked() { ctx.send_viewport_cmd( egui::ViewportCommand::Title("New Title".into()), ); } if ui.button("Quit").clicked() { ctx.send_viewport_cmd(egui::ViewportCommand::Close); } }); } } }` Other useful commands include `InnerSize`, `OuterSize`, `Position`, `Maximized`, `Visible`, and `StartDragging` (lets the user drag an undecorated window). These correspond to the underlying windowing system’s capabilities. Let’s assemble everything: a menu bar (File → New / Open / Save / Quit), a settings floating window, and a confirmation modal that appears before quitting. This draws on the architecture from [Chapter 5](https://danpokhrel.github.io/tutorial/ch05-architecture.html) and the theming from [Chapter 6](https://danpokhrel.github.io/tutorial/ch06-theming.html) . `// src/main.rs use eframe::egui; use std::sync::{Arc, Mutex}; fn main() -> eframe::Result<()> { let options = eframe::NativeOptions { viewport: egui::ViewportBuilder::default() .with_inner_size([900.0, 600.0]) .with_title("Graph Editor"), ..Default::default() }; eframe::run_native( "Graph Editor", options, Box::new(|ctx| Ok(Box::new(App::new(ctx)))), ) } pub enum BackgroundResult { FileLoaded { name: String, bytes: Vec }, } pub struct App { settings_open: bool, autosave: bool, status: String, quit_modal: ConfirmModal, pending: Arc>>, } impl App { fn new(ctx: &egui::Context) -> Self { crate::theme::EditorTheme::dark().apply(ctx); egui_extras::install_image_loaders(ctx); Self { settings_open: false, autosave: true, status: "Ready".into(), quit_modal: ConfirmModal { open: false, message: "Quit without saving?".into(), on_confirm: false, }, pending: Arc::new(Mutex::new(Vec::new())), } } fn new_document(&mut self) { self.status = "New document".into(); } fn open_document(&mut self, ctx: &egui::Context) { self.status = "Opening...".into(); let pending = self.pending.clone(); let ctx = ctx.clone(); std::thread::spawn(move || { let file = rfd::FileDialog::new() .add_filter("Graph", &["json"]) .pick_file(); if let Some(path) = file { let bytes = std::fs::read(&path).unwrap_or_default(); let name = path .file_name() .map(|n| n.to_string_lossy().to_string()) .unwrap_or_default(); { let mut buf = pending.lock().unwrap(); buf.push(BackgroundResult::FileLoaded { name, bytes }); } ctx.request_repaint(); } }); } fn save(&mut self) { self.status = "Saved".into(); } fn request_quit(&mut self) { self.quit_modal.open = true; } } impl eframe::App for App { fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { // Keyboard shortcuts. ctx.input_mut(|i| { let new_sc = egui::KeyboardShortcut::new(egui::Modifiers::CTRL, egui::Key::N); let open_sc = egui::KeyboardShortcut::new(egui::Modifiers::CTRL, egui::Key::O); let save_sc = egui::KeyboardShortcut::new(egui::Modifiers::CTRL, egui::Key::S); let quit_sc = egui::KeyboardShortcut::new( egui::Modifiers::CTRL, egui::Key::Q, ); if i.consume_shortcut(&new_sc) { self.new_document(); } if i.consume_shortcut(&open_sc) { self.open_document(ctx); } if i.consume_shortcut(&save_sc) { self.save(); } if i.consume_shortcut(&quit_sc) { self.request_quit(); } }); // Drain background results. let mut pending = self.pending.lock().unwrap(); for result in pending.drain(..) { match result { BackgroundResult::FileLoaded { name, bytes: _ } => { self.status = format!("Loaded {name}"); } } } drop(pending); } fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { let ctx = ui.ctx().clone(); // --- Menu bar --- egui::Panel::top("menu_bar").show(ui, |ui| { egui::MenuBar::new().ui(ui, |ui| { ui.menu_button("File", |ui| { if ui.button("New").clicked() { self.new_document(); ui.close(); } if ui.button("Open...").clicked() { self.open_document(&ctx); ui.close(); } if ui.button("Save").clicked() { self.save(); ui.close(); } ui.separator(); if ui.button("Quit").clicked() { self.request_quit(); ui.close(); } }); ui.menu_button("View", |ui| { ui.checkbox(&mut self.settings_open, "Settings"); }); }); }); // --- Main content --- egui::CentralPanel::default().show(ui, |ui| { ui.heading("Graph Editor"); ui.label(&self.status); ui.checkbox(&mut self.settings_open, "Show settings"); // Context menu on a label, for variety. let resp = ui.label("Right-click here for options"); resp.context_menu(|ui| { if ui.button("New document").clicked() { self.new_document(); ui.close(); } if ui.button("Toggle settings").clicked() { self.settings_open = !self.settings_open; ui.close(); } }); }); // --- Settings floating window --- if self.settings_open { egui::Window::new("Settings") .open(&mut self.settings_open) .default_pos([120.0, 120.0]) .resizable(true) .show(ui, |ui| { ui.checkbox(&mut self.autosave, "Autosave"); ui.label("Theme:"); ui.horizontal(|ui| { if ui.button("Dark").clicked() { ui.ctx().set_theme(egui::ThemePreference::Dark); } if ui.button("Light").clicked() { ui.ctx().set_theme(egui::ThemePreference::Light); } }); }); } // --- Quit confirmation modal --- if let Some(confirmed) = self.quit_modal.show(ui) { if confirmed { ctx.send_viewport_cmd(egui::ViewportCommand::Close); } } } } /// A simple confirmation modal built from Area + Window. pub struct ConfirmModal { pub open: bool, pub message: String, pub on_confirm: bool, } impl ConfirmModal { pub fn show(&mut self, ui: &mut egui::Ui) -> Option { if !self.open { return None; } let mut result = None; let screen = ui.ctx().screen_rect(); // Dimmer: full-screen, eats clicks. egui::Area::new(egui::Id::new("quit_modal_dimmer")) .order(egui::Order::Foreground) .fixed_pos(screen.min) .show(ui, |ui| { let _ = ui.allocate_rect(screen, egui::Sense::click()); ui.painter().rect_filled( screen, egui::CornerRadius::ZERO, egui::Color32::from_black_alpha(120), ); }); // Dialog window, centered and immovable. let size = egui::vec2(300.0, 120.0); let pos = screen.center() - size / 2.0; egui::Window::new("Confirm") .order(egui::Order::Foreground) .fixed_pos(pos) .resizable(false) .collapsible(false) .movable(false) .show(ui, |ui| { ui.set_min_width(size.x); ui.label(&self.message); ui.add_space(8.0); ui.horizontal(|ui| { if ui.button("Cancel").clicked() { result = Some(false); } if ui.button("Quit").clicked() { result = Some(true); } }); }); if let Some(c) = result { self.open = false; self.on_confirm = c; Some(c) } else { None } } }` > **Tip:** Notice that `logic()` handles keyboard shortcuts and drains background results, while `ui()` only builds widgets and collects the _intent_ to quit (setting `quit_modal.open = true`). The actual `ViewportCommand::Close` is sent only after the user confirms in the modal. This separation keeps your control flow readable and is the same collect-then-mutate discipline from [Chapter 5](https://danpokhrel.github.io/tutorial/ch05-architecture.html) > . The error-handling pattern in the file-open thread — `unwrap_or_default()` on `std::fs::read` — is a deliberately forgiving choice. For production code you’d propagate the error through your `BackgroundResult` enum using `Result`, as described in Rust Book [Chapter 9](https://doc.rust-lang.org/stable/book/ch09-02-recoverable-errors-with-result.html) , and display it in the UI. * * * We now have an application that responds to the keyboard and mouse, offers menus and context menus, pops up floating settings windows, confirms destructive actions with a modal, and opens native file dialogs. In the next chapter we’ll turn our attention to the canvas — drawing custom geometry, handling pan-and-zoom, and rendering the node graph itself. --- # Chapter 4: Layout, Widgets & State - Building a Node-Based AI Flow Editor with eframe & egui Keyboard shortcuts ------------------ Press ← or → to navigate between chapters Press S or / to search in the book Press ? to show this help Press Esc to hide this help * Auto * Light * Rust * Coal * Navy * Ayu Building a Node-Based AI Flow Editor with eframe & egui ======================================================= [](https://danpokhrel.github.io/tutorial/print.html "Print this book") So far we have worked inside a single `CentralPanel`. Real applications are more structured: a sidebar of controls, a top toolbar, a central canvas, perhaps a status bar at the bottom. This chapter teaches egui’s layout system—how a `Ui` grows a cursor, how panels compose, how to size and center things, and how to lay out large or tabular data with `ScrollArea` and `Grid`. We then widen the widget vocabulary, introduce `RichText` for inline styling, and finish by building a **custom widget** that implements the `egui::Widget` trait—the same pattern the Rust Book uses to teach [implementing traits for your own types](https://doc.rust-lang.org/stable/book/ch10-02-traits.html) . We also address the most subtle pitfall in immediate-mode UIs: keeping widget `Id`s stable in loops. Every `egui::Ui` has a `Layout` and a **cursor**—a point that advances as you add widgets. The layout decides which direction the cursor moves and how widgets are placed: * `Layout::top_down(Align::Center)` — cursor moves down; widgets centered horizontally. * `Layout::bottom_up(...)` — cursor moves upward. * `Layout::left_to_right(Align::TOP)` — cursor moves right; widgets top-aligned. * `Layout::right_to_left(...)` — cursor moves left. The default for a fresh `Ui` is usually `top_down` (or whatever the parent panel dictates). You rarely set the layout on the root `Ui`; instead, you create inner `Ui`s with a chosen layout: `#![allow(unused)] fn main() { ui.horizontal(|ui| { /* left_to_right */ }); ui.vertical(|ui| { /* top_down */ }); // Explicit control: ui.horizontal_top(|ui| { /* left_to_right, top aligned */ }); }` `ui.horizontal(|ui| { … })` is shorthand for “create a child `Ui` with `left_to_right`, add children, then collapse.” The same closure pattern repeats throughout egui; see Rust Book [Ch. 13 on closures](https://doc.rust-lang.org/stable/book/ch13-01-closures.html) if the borrowed closure in `|ui|` looks unfamiliar. Panels are the structural backbone of an egui app. There are three families: * **`CentralPanel`** — fills all remaining space; exactly one per frame, and it must be last. * **`Panel`** — as of egui 0.35, the former `SidePanel` and `TopBottomPanel` were unified into a single `egui::Panel` type. You pick a side via the constructor: `Panel::left(id)` / `Panel::right(id)` give a resizable left or right column, and `Panel::top(id)` / `Panel::bottom(id)` give a resizable top or bottom bar. (Older code may still reference `SidePanel`/`TopBottomPanel`; on 0.35+ those names no longer exist—use `Panel` instead.) **Order matters.** Panels claim space from the available rectangle, and each later panel sees only what is left. You must add side panels and top/bottom panels **before** the central panel: `#![allow(unused)] fn main() { egui::Panel::left("left_panel") .resizable(true) .default_size(220.0) .min_size(150.0).max_size(400.0) .show(ui, |ui| { ui.heading("Controls"); // ... sidebar widgets ... }); egui::Panel::top("top_panel") .show(ui, |ui| { ui.horizontal(|ui| { ui.menu_button("File", |ui| { /* ... */ }); ui.menu_button("Edit", |ui| { /* ... */ }); }); }); egui::CentralPanel::default() .show(ui, |ui| { ui.heading("Canvas"); // ... the main content ... }); }` If you reversed this—`CentralPanel` first—it would consume the whole window, and the side/top panels would render over (or be squeezed into) nothing. > **API note (0.34+).** Since egui 0.34, panel `.show()` takes `&mut Ui` (the `Ui` you are currently building inside), **not** a `&Context`. Older code with `.show(ctx, …)` will not compile on 0.35. Inside `App::ui`, pass the `&mut Ui` you were handed (or a child `Ui` derived from it). When you genuinely have only a `Context`—e.g. in `run_simple_native`—you use `.show(ctx, …)` on that path instead. A `Ui` exposes the space it has, so you can lay things out precisely: `#![allow(unused)] fn main() { // How much room is left in this Ui right now? let avail = ui.available_size(); // Vec2 let width = ui.available_width(); // f32 let used = ui.min_rect(); // Rect of everything drawn so far // A widget that is exactly w×h, sized explicitly: ui.add_sized([width, 24.0], egui::Button::new("Full-width button")); // Reserve vertical space and get a child Ui inside it: let (rect, _response) = ui.allocate_exact_size( egui::vec2(200.0, 100.0), egui::Sense::hover(), ); let mut child = ui.child_ui_with_id_source( rect, *ui.layout(), "child_block", ); child.label("inside the allocated rect"); }` `add_sized` is the everyday tool for “make this widget exactly this big.” egui’s layout is deliberately simple; centering is a small recipe: `#![allow(unused)] fn main() { // Vertically center a row of widgets: ui.vertical_centered(|ui| { ui.horizontal(|ui| { if ui.button("Save").clicked() { /* ... */ } if ui.button("Cancel").clicked() { /* ... */ } }); }); // Or allocate a region with a chosen layout: ui.allocate_ui_with_layout( egui::vec2(300.0, 60.0), egui::Layout::top_down(egui::Align::Center), |ui| { ui.label("centered text"); }, ); }` For lists longer than the window, wrap content in a `ScrollArea`: `#![allow(unused)] fn main() { egui::ScrollArea::vertical() .auto_shrink([false, true]) .show(ui, |ui| { for i in 0..50 { ui.label(format!("row {}", i)); } }); }` Variants: `ScrollArea::both()`, `ScrollArea::horizontal()`. For **thousands** of rows, do not build them all—use a virtualized list. egui does not ship one, but the pattern is: clip to the visible rows given the scroll offset, and only emit those. The egui examples repository includes virtual-list patterns; the key API is `ScrollArea::show_rows`, which takes the total count and a closure that is only invoked for visible indices. For tabular, aligned content use `Grid`. Column widths auto-size to the widest cell, and `end_row()` advances to the next row: `#![allow(unused)] fn main() { egui::Grid::new("settings_grid") .num_columns(2) .spacing([16.0, 8.0]) .show(ui, |ui| { ui.label("Name"); ui.text_edit_singleline(&mut self.name); ui.end_row(); ui.label("Age"); ui.add(egui::Slider::new(&mut self.age, 0..=120)); ui.end_row(); ui.label("Sound"); ui.checkbox(&mut self.sound_on, "enabled"); ui.end_row(); }); }` Grids are great for settings dialogs and property editors—exactly what our sidebar needs. A quick tour of widgets you will reach for often: `#![allow(unused)] fn main() { let mut flag = true; let mut value = 5; let mut choice = 0u32; ui.checkbox(&mut flag, "enable thing"); // A numeric drag field (scrub the value by dragging): ui.add(egui::DragValue::new(&mut value).range(0..=100)); // Dropdown: egui::ComboBox::from_label("mode") .selected_index(choice as usize) .show_ui(ui, |ui| { ui.selectable_value(&mut choice, 0, "Linear"); ui.selectable_value(&mut choice, 1, "Smooth"); ui.selectable_value(&mut choice, 2, "Stepped"); }); // Collapsible section: ui.collapsing("Advanced", |ui| { ui.label("hidden by default"); }); // Visual separators: ui.separator(); // Progress and spinners: ui.add(egui::ProgressBar::new(0.4).text("40%")); ui.add(egui::Spinner::new()); }` `RichText` lets you style a span of text—size, color, font family—and `ui.label` accepts it directly: `#![allow(unused)] fn main() { use egui::RichText; ui.label(RichText::new("big and red").color(egui::Color32::RED).size(28.0)); ui.label(RichText::new("monospace").family(egui::FontFamily::Monospace)); // Spans in a single line: let line = RichText::new("Price: ").strong() + RichText::new("$42").color(egui::Color32::LIGHT_GREEN); ui.label(line); }` For anything beyond inline text, `egui::Label` has `wrap_mode`, `truncate`, and similar options. When none of the built-ins fit, you implement `egui::Widget`. The trait is small: `#![allow(unused)] fn main() { pub trait Widget { fn ui(self, ui: &mut Ui) -> Response; } }` A custom widget reserves space, handles interaction, paints, and returns a `Response`. Here is a complete **Knob** widget—a circular dial you drag vertically to change a value. It shows both idioms in one place: a struct implementing `Widget`, which internally uses lower-level `Ui` calls. `#![allow(unused)] fn main() { use eframe::egui; use egui::{Response, Sense, Ui, Vec2, Widget}; pub struct Knob<'a> { value: &'a mut f32, radius: f32, range: std::ops::RangeInclusive, } impl<'a> Knob<'a> { pub fn new(value: &'a mut f32, range: std::ops::RangeInclusive) -> Self { Self { value, radius: 20.0, range } } } impl<'a> Widget for Knob<'a> { fn ui(self, ui: &mut Ui) -> Response { let size = Vec2::splat(self.radius * 2.0); let (rect, response) = ui.allocate_exact_size(size, Sense::drag()); // Handle dragging: vertical drag changes the value. if response.dragged() { let dy = -response.drag_delta().y; let span = self.range.end() - self.range.start(); *self.value = (*self.value + dy * span * 0.01) .clamp(*self.range.start(), *self.range.end()); } // Paint the knob: a circle plus an indicator line. let center = rect.center(); let painter = ui.painter_at(rect); let color = if response.hovered() || response.dragged() { egui::Color32::from_rgb(120, 180, 255) } else { egui::Color32::from_rgb(90, 90, 90) }; painter.circle_stroke(center, self.radius * 0.9, egui::Stroke::new(2.0, color)); // Angle: map [start..end] to [-135°..+135°]. let t = (*self.value - self.range.start()) / (self.range.end() - self.range.start()); let angle = (-135.0_f32.to_radians()) + t * (270.0_f32.to_radians()); let tip = center + egui::vec2( (self.radius * 0.8) * angle.cos(), (self.radius * 0.8) * angle.sin(), ); painter.line_segment( [center, tip], egui::Stroke::new(2.0, egui::Color32::WHITE), ); // Always return the Response so callers can chain checks. response } } }` Use it like any built-in widget: `#![allow(unused)] fn main() { let mut gain = 0.5_f32; ui.add(Knob::new(&mut gain, 0.0..=1.0)); }` > **Note on `drag_delta`.** `response.drag_delta()` returns the _per-frame_ drag distance in screen pixels — it resets to zero each frame, it is **not** cumulative. The multiplier `0.01` in the Knob scales this per-pixel movement so that dragging 100 pixels changes the value by roughly one full range. If you want a different “feel,” adjust the multiplier: a smaller value makes the knob less sensitive. This is the same shape as Rust’s own trait implementations: a type declares it implements a trait, the trait has a required method, and callers treat it uniformly. See Rust Book [Ch. 10 on traits](https://doc.rust-lang.org/stable/book/ch10-02-traits.html) . The **two idioms** in the wild are: 1. **A struct implementing `Widget`** (above) — best for reusable, stateful-feeling widgets. 2. **A function returning `Response`** (`fn knob(ui, value, range) -> Response`) — best for one-off, parameterized widgets you do not need to name. Either is fine; prefer the struct when you expect to reuse it or attach configuration via builder methods (`Knob::new(...).radius(30.0).color(...)`). Every widget returns a `Response`. It is your handle for “what happened to this widget this frame.” Always return it from your own widgets—callers will want it: `#![allow(unused)] fn main() { let r = ui.button("delete"); if r.clicked() { /* one frame */ } if r.hovered() { /* pointer is over it */ } if r.dragged() { /* being dragged */ } r.on_hover_text("permanently remove the item"); // tooltip // Right-click context menu, attached to the same widget: r.context_menu(|ui| { if ui.button("Duplicate").clicked() { /* ... */ } if ui.button("Delete").clicked() { /* ... */ } }); }` `Response` is cheap—it is essentially an `Id` plus some flags. Chaining like `r.on_hover_text(...)` returns a new `Response` you can keep chaining on, which is the idiomatic way to enrich a widget. egui tracks per-widget state (scroll offsets, focus, animation timers, drag state) by `egui::Id`. The `Id` must be **stable** across frames for the same logical widget. The default `Id` for a `ui.button("Save")` is derived from the button text _and its position in the call sequence_—fine for static UIs, but **wrong in loops** where items can be added, removed, or reordered. Consider a list of toggles: `#![allow(unused)] fn main() { // BUG: index-based implicit Ids shift when items are removed, // so each toggle's checked-state jumps to its neighbor. for (i, item) in items.iter().enumerate() { ui.checkbox(&mut item.enabled, &item.name); // implicit Id from i let _ = i; } }` The fix is to derive the `Id` from a **stable key** of the item: `#![allow(unused)] fn main() { for item in items.iter_mut() { // Stable Id keyed by the item's own identifier, not its position. ui.push_id(format!("toggle_{}", item.id), |ui| { ui.checkbox(&mut item.enabled, &item.name); }); } }` > **Tip: `push_id` accepts `impl Into`.** `&str`, `String`, `Id`, and integer types all work. Using `name.as_str()` (a `&str`) is cheaper than `format!("toggle_{}", item.id)` because it avoids a heap allocation per row per frame. Reserve `format!` for when you need to combine multiple values into a key; prefer a bare `&str` or `Id` when a single stable key is available. Other stable-Id tools: `#![allow(unused)] fn main() { use egui::Id; let id = Id::new("my_knob"); let child_id = id.with("indicator"); // derive a child Id deterministically ui.push_id(id, |ui| { /* widgets here get id-prefixed Ids */ }); }` > **Warning: the silent shuffle.** Unstable `Id`s do not crash—they silently mis-associate state. The symptom is “my checkbox state jumped to the next row when I deleted an item.” Always key `Id`s on stable data, not loop indices. We finish with a small, working multi-panel app: a left sidebar of controls and a central display area. It exercises panels, grids, a custom widget, and stable `Id`s: `#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] use eframe::egui; use egui::{Response, Sense, Ui, Vec2, Widget}; // --- A tiny custom widget (Knob, abridged from above) --- struct Knob<'a> { value: &'a mut f32, } impl<'a> Widget for Knob<'a> { fn ui(self, ui: &mut Ui) -> Response { let radius = 20.0; let size = Vec2::splat(radius * 2.0); let (rect, response) = ui.allocate_exact_size(size, Sense::drag()); if response.dragged() { *self.value = (*self.value - response.drag_delta().y * 0.01).clamp(0.0, 1.0); } let painter = ui.painter_at(rect); let center = rect.center(); painter.circle_stroke(center, radius * 0.9, egui::Stroke::new(2.0, egui::Color32::LIGHT_BLUE)); let angle = -135.0_f32.to_radians() + *self.value * 270.0_f32.to_radians(); let tip = center + egui::vec2( (radius * 0.8) * angle.cos(), (radius * 0.8) * angle.sin(), ); painter.line_segment([center, tip], egui::Stroke::new(2.0, egui::Color32::WHITE)); response } } pub struct LayoutApp { name: String, gain: f32, rows: Vec<(String, bool)>, } impl Default for LayoutApp { fn default() -> Self { Self { name: "node 1".to_owned(), gain: 0.5, rows: vec![ ("alpha".to_owned(), true), ("beta".to_owned(), false), ("gamma".to_owned(), true), ], } } } impl eframe::App for LayoutApp { fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { // 1) Top bar FIRST. egui::Panel::top("top_bar") .show(ui, |ui| { egui::MenuBar::new().ui(ui, |ui| { ui.menu_button("File", |ui| { if ui.button("New").clicked() { self.rows.clear(); } }); ui.menu_button("Edit", |ui| { if ui.button("Add row").clicked() { self.rows.push((format!("row {}", self.rows.len()), false)); } }); }); }); // 2) Left sidebar NEXT. egui::Panel::left("sidebar") .resizable(true) .default_size(240.0) .min_size(160.0).max_size(420.0) .show(ui, |ui| { ui.heading("Controls"); ui.separator(); egui::Grid::new("settings") .num_columns(2) .spacing([12.0, 8.0]) .show(ui, |ui| { ui.label("Name"); ui.text_edit_singleline(&mut self.name); ui.end_row(); ui.label("Gain"); ui.add(Knob { value: &mut self.gain }); ui.end_row(); }); ui.separator(); ui.heading("Rows"); // Stable Ids keyed on each row's *name*, not its index. for (name, enabled) in self.rows.iter_mut() { ui.push_id(name.as_str(), |ui| { ui.checkbox(enabled, name.as_str()); }); } }); // 3) Central panel LAST. egui::CentralPanel::default() .show(ui, |ui| { ui.heading("Canvas"); ui.label(format!("editing: {} (gain {:.2})", self.name, self.gain)); ui.separator(); ui.label("rows:"); egui::ScrollArea::vertical().show(ui, |ui| { egui::Grid::new("rows_grid").num_columns(2).show(ui, |ui| { for (name, enabled) in &self.rows { ui.label(name); ui.label(if *enabled { "on" } else { "off" }); ui.end_row(); } }); }); }); } } fn main() -> eframe::Result { let options = eframe::NativeOptions { viewport: egui::ViewportBuilder::default() .with_inner_size([900.0, 600.0]) .with_title("flow-builder"), ..Default::default() }; eframe::run_native( "flow-builder", options, Box::new(|_cc| Ok(Box::::default())), ) }` Run it. The top bar offers File/Edit menus; the resizable left sidebar holds a settings grid and a knob; the central area scrolls a tabular view of rows. Drag the knob to change the gain; toggle rows in the sidebar and watch the central table update next frame—no syncing code, because the state lives on `LayoutApp` and both panels simply read it. You can now compose multi-panel layouts, size and center widgets, scroll and grid content, style text with `RichText`, and write your own `egui::Widget` returning a proper `Response`. You also understand the one subtlety that bites everyone once: stable `Id`s in loops. This completes the foundation of the book. From here, the natural next step is to bring in **`egui-snarl`** for the node-graph editor that `flow-builder` is named for: defining node types with typed input/output pins, wiring pins together, serializing graphs with `serde`, and rendering the snarl inside the central panel we just built. The data model lives in `src/graph/` (pure, no egui), the rendering lives in `src/panels/canvas.rs`, and `app.rs` owns it all—exactly the structure we set up in [Chapter 2](https://danpokhrel.github.io/tutorial/ch02-project-setup.html) . --- # Chapter 6: Theming, Fonts & Styling - Building a Node-Based AI Flow Editor with eframe & egui Keyboard shortcuts ------------------ Press ← or → to navigate between chapters Press S or / to search in the book Press ? to show this help Press Esc to hide this help * Auto * Light * Rust * Coal * Navy * Ayu Building a Node-Based AI Flow Editor with eframe & egui ======================================================= [](https://danpokhrel.github.io/tutorial/print.html "Print this book") egui ships with sensible defaults, but most real applications eventually want a distinct look — a cohesive color palette, a custom font (including non-Latin scripts), larger headings, or rounded buttons. egui gives you three independent knobs for this, and in this chapter we’ll learn how each one works and how to bundle them into a reusable `theme` module. This chapter builds on the module-structure ideas from [Chapter 5](https://danpokhrel.github.io/tutorial/ch05-architecture.html) ; if you skipped ahead, the `theme.rs` file we create here slots directly into the layout from that chapter. egui separates appearance into three structs, each controlling a different aspect: * **`Visuals`** — _colors_. Background, text, widget backgrounds, borders, selection highlights, and whether the whole theme is dark or light. * **`Style`** — _geometry_. Margins, spacing, corner rounding, text styles (which map `TextStyle` to font + size), and animation timings. * **`FontDefinitions`** — _fonts_. The actual font data and how `FontFamily` values map to lists of fonts. All three live on the `egui::Context` and can be changed at any time. They are also cheap to clone, so you can snapshot and restore them. The quickest way to change your app’s appearance is to swap the entire `Visuals`: `#![allow(unused)] fn main() { use eframe::egui; impl eframe::App for MyApp { fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { ui.horizontal(|ui| { if ui.button("Dark").clicked() { ui.ctx().set_visuals(egui::Visuals::dark()); } if ui.button("Light").clicked() { ui.ctx().set_visuals(egui::Visuals::light()); } }); } } }` `set_visuals` updates the context immediately and applies to every widget drawn afterward in the same frame and beyond. For the familiar sun/moon toggle, older egui versions shipped a built-in helper (`egui::widgets::global_dark_light_mode_buttons`). That helper was **removed in egui 0.35** in favor of the new theme system (`ThemePreference`). The equivalent today is a couple of buttons that call `set_theme`: `#![allow(unused)] fn main() { // egui 0.35 replaced global_dark_light_mode_buttons with the theme API: ui.horizontal(|ui| { if ui.button("Dark").clicked() { ui.ctx().set_theme(egui::ThemePreference::Dark); } if ui.button("Light").clicked() { ui.ctx().set_theme(egui::ThemePreference::Light); } }); }` This renders two small buttons that flip between the dark and light themes. It is the easiest way to give users control. To follow the OS preference, query it once at startup. In egui 0.35 the `eframe::dark_light::detect()` helper was removed; the replacement is `ctx.system_theme()`, which returns an `Option` reflecting the OS preference (or `None` on platforms where detection is unavailable): `#![allow(unused)] fn main() { // egui 0.35: eframe::dark_light::detect() was removed. // Use ctx.system_theme() which returns Option. impl MyApp { pub fn new(ctx: &egui::Context) -> Self { // Follow the OS theme if available, default to dark. let theme = ctx.system_theme().unwrap_or(egui::Theme::Dark); ctx.set_theme(theme); Self { /* ... */ } } } }` > **Tip:** `ctx.system_theme()` probes the OS for the current theme and returns `Option`. On platforms where detection fails it returns `None`, so falling back to dark — the safe default for developer tools — is a good idea. Sometimes you want a change to apply only to a subtree — a red error panel, a compact toolbar — without affecting the rest of the app. Use `ui.scope`: `#![allow(unused)] fn main() { ui.scope(|ui| { // Make everything in this scope use smaller spacing and a red tint. ui.style_mut().spacing.item_spacing = egui::vec2(2.0, 2.0); ui.visuals_mut().override_text_color = Some(egui::Color32::RED); ui.label("This is a compact, red warning block."); ui.button("Me too"); }); // Outside the scope, the original style and visuals are restored. ui.button("Back to normal spacing and color"); }` `ui.scope` snapshots `Style` and `Visuals` on entry and restores them on exit, so any mutation made through `ui.style_mut()` or `ui.visuals_mut()` is local to the scope. This is the immediate-mode equivalent of a CSS scope and costs nothing persistent. For one-off styling without a scope, `RichText` and `Button` carry their own style overrides. This is the idiomatic way to color a single label or fill a single button: `#![allow(unused)] fn main() { use eframe::egui; impl eframe::App for MyApp { fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { // RichText lets you set color, size, and font family inline. ui.label( egui::RichText::new("Danger!") .color(egui::Color32::RED) .text_style(egui::TextStyle::Heading) .strong(), ); // Button styling: fill, stroke, and text color. if ui .add( egui::Button::new( egui::RichText::new("Delete") .color(egui::Color32::WHITE), ) .fill(egui::Color32::from_rgb(180, 40, 40)) .stroke(egui::Stroke::NONE), ) .clicked() { // ... } } } }` Because `RichText` and `Button` are builders, you can chain as many overrides as you need. They do not touch the context, so they are safe to use anywhere. egui’s font system has three pieces: * **`FontData`** — raw font bytes, wrapped from a `&'static [u8]` or owned `Vec`. * **`FontFamily`** — a named family: `Proportional` (the default for body text) or `Monospace` (code). You can define your own as well. * **`FontDefinitions`** — the map that says “for `Proportional`, try font A, then B, then C.” Each family is a _fallback chain_: egui tries the first font for a glyph, and if it lacks that glyph, falls through to the next. This is how you mix a Latin font with a CJK font. `#![allow(unused)] fn main() { use eframe::egui; fn install_fonts(ctx: &egui::Context) { let mut fonts = egui::FontDefinitions::default(); // Register a custom proportional font from bytes compiled in. fonts.font_data.insert( "my_sans".to_owned(), egui::FontData::from_static(include_bytes!( "../assets/MySans-Regular.ttf" )), ); // Put it first in the Proportional fallback chain. fonts .families .entry(egui::FontFamily::Proportional) .or_default() .insert(0, "my_sans".to_owned()); ctx.set_fonts(fonts); } }` `include_bytes!` embeds the file into the binary at compile time (Rust Book [Chapter 19.5](https://doc.rust-lang.org/stable/book/ch19-06-macros.html) discusses macros more broadly; `include_bytes!` itself is documented among the standard macros). Using `from_static` lets egui borrow the bytes with a `'static` lifetime, avoiding a copy. To support Chinese, Japanese, or Korean text, embed a CJK font and _prepend_ it to the `Proportional` chain. Prepending (rather than appending) ensures CJK glyphs render in the CJK font rather than falling through to the Latin font’s missing-glyph box: `#![allow(unused)] fn main() { fn install_cjk_fonts(ctx: &egui::Context) { let mut fonts = egui::FontDefinitions::default(); fonts.font_data.insert( "noto_cjk".to_owned(), egui::FontData::from_static(include_bytes!( "../assets/NotoSansCJK-Regular.ttc" )), ); // Prepend so CJK glyphs are found here first. fonts .families .entry(egui::FontFamily::Proportional) .or_default() .insert(0, "noto_cjk".to_owned()); // Also make it available as its own family for explicit use. fonts .families .entry(egui::FontFamily::Name("CJK".into())) .or_default() .push("noto_cjk".to_owned()); ctx.set_fonts(fonts); } }` > **Warning:** CJK font files are large (often 10–20 MB). If binary size matters, consider subsetting the font to the glyphs you need before embedding, or loading it from disk at runtime via `FontData::from_owned(std::fs::read(path)?)`. egui maps a small set of `TextStyle` values to `(FontFamily, f32 size)` pairs inside `Style::text_styles`. Since egui 0.34 the default body size is **13.0 pt**. The standard styles are: * `TextStyle::Small` * `TextStyle::Body` — the default for `ui.label`. * `TextStyle::Monospace` * `TextStyle::Button` * `TextStyle::Heading` You can override any of them, or add your own: `#![allow(unused)] fn main() { fn tune_text_styles(ctx: &egui::Context) { use egui::{FontFamily, TextStyle}; let mut style = (*ctx.global_style()).clone(); style.text_styles = [ (TextStyle::Heading, FontFamily::Proportional, 22.0), (TextStyle::Body, FontFamily::Proportional, 15.0), (TextStyle::Monospace, FontFamily::Monospace, 13.0), (TextStyle::Button, FontFamily::Proportional, 15.0), (TextStyle::Small, FontFamily::Proportional, 11.0), ] .into_iter() .collect(); ctx.set_global_style(style); } }` To use a specific style on a label, use `RichText::text_style`: `#![allow(unused)] fn main() { ui.label(egui::RichText::new("Title").text_style(egui::TextStyle::Heading)); }` There are two ways to display images in egui. The `egui_extras` crate provides image loaders that fetch from a URI (a file path, `http://` URL, or `bytes://` embedding). Install them once at startup and then use `ui.image`: `#![allow(unused)] fn main() { use eframe::egui; impl MyApp { pub fn new(ctx: &egui::Context) -> Self { // Install the loaders. Do this exactly once. egui_extras::install_image_loaders(ctx); Self { /* ... */ } } } impl eframe::App for MyApp { fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { // The loader fetches and caches the image asynchronously. ui.image(egui::include_image!("../assets/logo.png")); } } }` `egui::include_image!` embeds the image and returns a URI `ui.image` understands. For runtime paths, pass the string URI directly. > **Warning — the #1 cause of blank images:** forgetting to call `egui_extras::install_image_loaders(ctx)`. Without it, `ui.image` has no loader registered and silently renders nothing (or a placeholder) on the first frames. If your images never appear, check this first. For full control — procedural textures, dynamically generated images, pixel buffers — create a `TextureHandle` from a `ColorImage`: `#![allow(unused)] fn main() { use eframe::egui; pub struct MyApp { texture: Option, } impl MyApp { pub fn new(ctx: &egui::Context) -> Self { // Build a 64x64 gradient image. let size = [64, 64]; let pixels: Vec = (0..64 * 64) .map(|i| { let t = (i % 64) as f32 / 64.0; egui::Color32::from_rgb( (t * 255.0) as u8, 100, (255.0 - t * 255.0) as u8, ) }) .collect(); let image = egui::ColorImage { size, pixels, }; let texture = ctx.load_texture("my-gradient", image, egui::TextureOptions::LINEAR); Self { texture: Some(texture), } } } impl eframe::App for MyApp { fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { if let Some(tex) = &self.texture { ui.image(egui::load::TextureHandle::id(tex), [128.0, 128.0]); } } } }` Store the `TextureHandle` on your `App` struct so it is not recreated every frame — recreating it would re-upload the data to the GPU each frame and waste bandwidth. Now let’s bundle everything into a `theme.rs` module, as suggested in [Chapter 5](https://danpokhrel.github.io/tutorial/ch05-architecture.html) . The goal is one call at startup that applies a cohesive look: ``#![allow(unused)] fn main() { // src/theme.rs use eframe::egui; /// A cohesive color palette for the node-graph editor. pub struct EditorTheme { pub bg: egui::Color32, pub panel_bg: egui::Color32, pub text: egui::Color32, pub accent: egui::Color32, pub accent_hover: egui::Color32, pub node_bg: egui::Color32, pub node_border: egui::Color32, } impl EditorTheme { /// A custom dark theme with a blue accent. pub fn dark() -> Self { Self { bg: egui::Color32::from_rgb(24, 24, 28), panel_bg: egui::Color32::from_rgb(32, 32, 38), text: egui::Color32::from_rgb(220, 222, 228), accent: egui::Color32::from_rgb(86, 140, 245), accent_hover: egui::Color32::from_rgb(120, 165, 255), node_bg: egui::Color32::from_rgb(44, 46, 54), node_border: egui::Color32::from_rgb(70, 74, 86), } } /// Apply the theme to a context: visuals, style, and fonts together. pub fn apply(&self, ctx: &egui::Context) { // Pin the app to dark mode. Without this, egui follows the system // theme and `set_visuals` below would only restyle whichever theme // happened to be active (or not apply at all on a light-mode OS). ctx.set_theme(egui::Theme::Dark); let mut visuals = egui::Visuals::dark(); // Backgrounds. (egui 0.35 renamed panel_bg → panel_fill, // window_bg → window_fill.) visuals.panel_fill = self.panel_bg; visuals.window_fill = self.panel_bg; visuals.extreme_bg_color = self.bg; // Widgets. egui 0.35 distinguishes bg_fill (always painted) from // weak_bg_fill (only for widgets with meaningful backgrounds). // For noninteractive, use bg_fill; for the rest, weak_bg_fill. visuals.widgets.noninteractive.bg_fill = self.bg; visuals.widgets.noninteractive.fg_stroke = egui::Stroke::new(1.0, self.text); visuals.widgets.inactive.weak_bg_fill = self.node_bg; visuals.widgets.inactive.fg_stroke = egui::Stroke::new(1.0, self.text); visuals.widgets.hovered.weak_bg_fill = self.accent_hover; visuals.widgets.hovered.fg_stroke = egui::Stroke::new(1.0, egui::Color32::WHITE); visuals.widgets.active.weak_bg_fill = self.accent; visuals.widgets.active.fg_stroke = egui::Stroke::new(1.0, egui::Color32::WHITE); // Selection and hyperlinks use the accent. visuals.selection.bg_fill = self.accent; visuals.hyperlink_color = self.accent; ctx.set_visuals(visuals); // Geometry: comfortable spacing and slightly rounded widgets. // egui 0.35: ctx.style()/set_style() → global_style()/set_global_style(). let mut style = (*ctx.global_style()).clone(); style.spacing.item_spacing = egui::vec2(8.0, 6.0); style.spacing.button_padding = egui::vec2(10.0, 4.0); style.spacing.window_margin = egui::Margin::same(10); // egui 0.35's Widgets struct has no iter_mut(); set each variant. let corner = egui::CornerRadius::same(6); style.visuals.widgets.noninteractive.corner_radius = corner; style.visuals.widgets.inactive.corner_radius = corner; style.visuals.widgets.hovered.corner_radius = corner; style.visuals.widgets.active.corner_radius = corner; style.visuals.widgets.open.corner_radius = corner; ctx.set_global_style(style); } } }`` > **Note:** `Visuals::dark()` is our starting point; we then override the fields we care about. Starting from a built-in preset and overriding is more robust than building a `Visuals` from scratch, because new egui versions may add fields that default sensibly when you start from a preset. > **egui 0.35 widget backgrounds:** The `Widgets` struct distinguishes `bg_fill` (always painted) from `weak_bg_fill` (only painted when the widget has a meaningful background, like a button). For most interactive widgets you want `weak_bg_fill`; for `noninteractive` backgrounds you want `bg_fill`. Using the wrong one can cause subtle visual glitches (a button that always shows a background even when you wanted it transparent). Applying it is a one-liner at startup: `#![allow(unused)] fn main() { // src/app.rs use crate::theme::EditorTheme; impl MyApp { pub fn new(ctx: &egui::Context) -> Self { EditorTheme::dark().apply(ctx); Self { /* ... */ } } } }` Because the theme lives in its own module with no `egui::Ui` rendering logic, you can later add a `EditorTheme::light()` constructor, serialize user preferences into it, or swap themes at runtime by calling `apply` again — the context picks up the change immediately. * * * Your application now has a clean architecture and a custom look. Next we need to let the user _do_ things: type into boxes, open menus, pick files, and respond to confirmation dialogs. In [Chapter 7](https://danpokhrel.github.io/tutorial/ch07-input-menus.html) we’ll cover input handling, menu bars, floating windows, modal dialogs, and native file pickers. --- # Chapter 8: Introducing egui-snarl - Building a Node-Based AI Flow Editor with eframe & egui Keyboard shortcuts ------------------ Press ← or → to navigate between chapters Press S or / to search in the book Press ? to show this help Press Esc to hide this help * Auto * Light * Rust * Coal * Navy * Ayu Building a Node-Based AI Flow Editor with eframe & egui ======================================================= [](https://danpokhrel.github.io/tutorial/print.html "Print this book") So far we have built conventional egui applications — windows, panels, menus, dialogs. Now we turn to a different kind of widget: the _node graph_. A node graph is a canvas where the user places boxes (nodes), connects them with wires, and edits data visually. This is the foundation of visual scripting, shader graphs, material editors, and — as we will see in Part 4 — agentic AI flows. In this chapter we introduce the `egui-snarl` crate, learn its core concepts, and build a minimal working graph editor. This chapter assumes you have read [Chapter 5](https://danpokhrel.github.io/tutorial/ch05-architecture.html) on structuring your app and [Chapter 7](https://danpokhrel.github.io/tutorial/ch07-input-menus.html) on input and menus. A node graph is a two-dimensional canvas on which the user manipulates _nodes_ and _wires_. Each node is a small panel that holds some data — a number, a string, a function — and exposes _pins_: input endpoints on the left and output endpoints on the right. The user draws _wires_ from an output pin to an input pin to feed data from one node into another. The result is a directed graph that the program can evaluate. You have seen node graphs if you have ever used a shader editor (like Blender’s shader nodes), a visual scripting system (like Unreal’s Blueprints), or a compositing tool (like Nuke). They are powerful because they let the user express _structure_ — what connects to what — visually, while keeping _values_ editable in place. The `egui-snarl` crate, written by Alex Krepa, gives egui a reusable node-graph widget. It handles the hard parts — dragging nodes, panning and zooming the canvas, drawing wires, hit-testing pins — and leaves the _content_ of each node up to you. > **Note:** `egui-snarl` 0.11.0 officially targets egui 0.34, but it works cleanly with egui 0.35.0, the version this book uses. If you encounter a breaking change in a future egui release, check the crate’s repository for an updated release. `egui-snarl` is built around four concepts. Understanding how they relate is the key to everything that follows. * **`Snarl`** — the container that owns all nodes, their positions, and their wires. It is generic over a _node data type_ `T`, which is usually an enum representing your different kinds of node. `Snarl` is plain data: it knows nothing about rendering. This separation mirrors the data/UI split we established in [Chapter 5](https://danpokhrel.github.io/tutorial/ch05-architecture.html) . * **Node** — a single node is a piece of `T` data plus a screen-space position (`Pos2`). You never manipulate a “node struct” directly; instead you talk to the `Snarl` through a `NodeId`, and you read the node’s data by indexing: `snarl[node_id]`. * **Pins** — the input and output endpoints of a node. A pin is identified by an `InPinId { node: NodeId, input: usize }` or `OutPinId { node: NodeId, output: usize }`. The `input`/`output` index is the _ordinal_ of the pin on its node — pin 0, pin 1, and so on. How many pins a node has is decided by your viewer (more on this below). * **Wires** — a connection from an `OutPinId` to an `InPinId`. A single input pin can receive multiple wires (its `remotes` is a `Vec`), and a single output pin can fan out to many inputs. The container is generic and the rendering is pluggable, so `egui-snarl` never needs to know what your nodes _mean_. It only needs to know how many pins each node has and how to draw them — and that is the job of the `SnarlViewer` trait. `SnarlViewer` is a trait you implement on your own struct to drive the UI for each node. The trait is generic over the same `T` as the `Snarl`, so a single viewer knows how to render every node type in your graph. This is the central design idea of `egui-snarl`: **the graph data (`Snarl`) and the rendering logic (`SnarlViewer`) are separate objects.** This is the same separation of concerns the Rust Book calls out when introducing traits in [Chapter 10](https://doc.rust-lang.org/stable/book/ch10-02-traits.html) : a trait defines shared behavior that can be implemented for many types, decoupling _what_ a type can do from _how_ a specific type does it. Here, `SnarlViewer` is the abstraction, and your concrete struct is one implementation of it. The trait has five required methods: ``#![allow(unused)] fn main() { use egui_snarl::{Snarl, InPin, OutPin, ui::{SnarlPin, SnarlViewer}}; impl SnarlViewer for MyViewer { /// The title shown in the node's header. fn title(&mut self, node: &T) -> String; /// How many input pins this node has. fn inputs(&mut self, node: &T) -> usize; /// How many output pins this node has. fn outputs(&mut self, node: &T) -> usize; /// Render one input pin. Returns a `PinInfo` describing how the pin is drawn. fn show_input( &mut self, pin: &InPin, ui: &mut egui::Ui, snarl: &mut Snarl, ) -> impl SnarlPin + 'static; /// Render one output pin. Returns a `PinInfo` describing how the pin is drawn. fn show_output( &mut self, pin: &OutPin, ui: &mut egui::Ui, snarl: &mut Snarl, ) -> impl SnarlPin + 'static; } }`` The trait also provides a large set of _provided_ methods with sensible defaults — `connect`, `disconnect`, `show_header`, `has_body`/`show_body`, `has_footer`/`show_footer`, context-menu hooks, and more — which we will explore in later chapters. > **Warning:** `SnarlViewer` is **not** `dyn`\-compatible. The `show_input` and `show_output` methods return `impl SnarlPin + 'static`, which uses `impl Trait` in return position. A trait object (`Box>`) cannot be constructed. You must implement the trait on a _concrete struct_, not behind a trait object. This is a Rust limitation discussed alongside trait objects in [Chapter 18](https://doc.rust-lang.org/stable/book/ch18-04-advanced-types.html) > of the Rust Book. You might wonder why `egui-snarl` does not simply give you a `Node` struct with fields you fill in. The reason is flexibility. By moving rendering into a trait, `egui-snarl` lets you: * Store _pure data_ in `Snarl` — no widgets, no `egui::Ui`, no colors. Your graph can be serialized, sent across a thread boundary, or unit-tested without a GPU. This is the data/UI split from [Chapter 5](https://danpokhrel.github.io/tutorial/ch05-architecture.html) in action. * Swap the _look_ of the graph without touching the data. A “compact” viewer and a “verbose” viewer can render the same `Snarl`. * Keep the borrow checker happy. Because the viewer owns no persistent state — it just receives `&mut Snarl` per call — you never end up holding overlapping borrows of the graph during rendering. Add the crate to your `Cargo.toml`. We enable the `serde` feature from the start because we will want to serialize graphs in [Chapter 16](https://danpokhrel.github.io/tutorial/ch16-persistence.html) : `[dependencies] eframe = "0.35.0" egui-snarl = { version = "0.11.0", features = ["serde"] } serde = { version = "1.0", features = ["derive"] } [package] edition = "2024" rust-version = "1.92.0"` The `serde` feature implements `Serialize`/`Deserialize` for `Snarl` and its supporting types (as long as your `T` is also `Serialize`/`Deserialize`). If you skip the feature, the graph still works — you just cannot persist it to disk. > **Note:** `egui-snarl` 0.11.0 declares `egui = "0.34"` as a dependency, but because egui’s public API is stable across the 0.34 → 0.35 boundary for the surface area `egui-snarl` uses, the two coexist without conflict. Cargo resolves them to compatible versions as long as your `eframe` 0.35.0 pulls in `egui` 0.35.0. The node data type `T` is almost always an enum — one variant per kind of node. This is a direct application of Rust’s enums as described in the Rust Book’s [Chapter 6](https://doc.rust-lang.org/stable/book/ch06-01-defining-an-enum.html) : an enum lets you say “this value is one of these several things, each of which may carry different data.” Let us define a small set of demo nodes: a `Number` node that holds an `f64`, a `Text` node that holds a `String`, and a `Sink` node that displays whatever is connected to it: `#![allow(unused)] fn main() { use serde::{Deserialize, Serialize}; /// Every node in our graph is one of these variants. #[derive(Debug, Serialize, Deserialize, Clone)] pub enum DemoNode { /// A constant number the user can edit. Number(f64), /// A constant string the user can edit. Text(String), /// A display node: shows the value wired into it. Sink, } }` Each variant carries the _editable state_ of that node kind. A `Number` node remembers its value; a `Sink` node has no state of its own because it only displays what it receives. Notice we derive `Serialize` and `Deserialize` — that is the whole reason we enabled the `serde` feature. We will lean on ownership and borrowing (Rust Book [Chapter 4](https://doc.rust-lang.org/stable/book/ch04-00-understanding-ownership.html) ) heavily: nodes own their `String`s, and the viewer borrows them during rendering rather than copying. Now we build the `App` struct. It owns two things: the `Snarl` (our persistent graph data) and a `DemoViewer` (the rendering logic, which holds no persistent state — it is a zero-sized marker in this minimal example, but in real apps it may hold transient state like a pending drag). We follow the architecture from [Chapter 5](https://danpokhrel.github.io/tutorial/ch05-architecture.html) : the `App` owns everything, and `ui()` reads state and builds widgets. `#![allow(unused)] fn main() { use eframe::egui; use egui_snarl::{Snarl, ui::SnarlViewer}; /// The viewer. Holds no persistent state in this minimal example. #[derive(Default)] pub struct DemoViewer; pub struct SnarlApp { snarl: Snarl, viewer: DemoViewer, } impl Default for SnarlApp { fn default() -> Self { let mut snarl = Snarl::new(); // Seed the graph with a couple of nodes so it is not empty. let n = snarl.insert_node(egui::pos2(-120.0, -40.0), DemoNode::Number(42.0)); let s = snarl.insert_node(egui::pos2(120.0, -40.0), DemoNode::Sink); // Connect the number node's output 0 to the sink's input 0. snarl.connect( egui_snarl::OutPinId { node: n, output: 0 }, egui_snarl::InPinId { node: s, input: 0 }, ); Self { snarl, viewer: DemoViewer::default(), } } } }` `Snarl::new()` creates an empty container. `snarl.insert_node(pos, node)` returns a `NodeId` — an opaque, stable identifier you use to refer to the node later. `snarl.connect(from, to)` wires an output pin to an input pin and returns `true` if the connection was created. > **Tip:** `NodeId` is a `Copy` type (a wrapper around a `usize` index), so you can pass it around freely without borrowing. The same is true of `InPinId` and `OutPinId`. Keep in mind, though, that a `NodeId` only remains valid as long as the node exists — if you `remove_node`, the id is recycled. Rendering the graph is a single chain of builder calls inside `CentralPanel`. The `SnarlWidget` is the widget `egui-snarl` provides; it takes a stable `egui::Id` (required so the widget can keep track of drags and selection state across frames), an optional `SnarlStyle`, and finally borrows your `Snarl` and your viewer: `#![allow(unused)] fn main() { use eframe::egui; use egui_snarl::ui::SnarlWidget; impl eframe::App for SnarlApp { fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { eframe::egui::CentralPanel::default().show_inside(ui, |ui| { SnarlWidget::new() .id_salt(egui::Id::new("demo-snarl")) .show(&mut self.snarl, &mut self.viewer, ui); }); } } }` That is the entire render path. The widget handles panning (drag the background), zooming (Ctrl + scroll), dragging nodes (drag a node header), and drawing wires (drag from a pin). Your only job is to answer the trait’s questions: _what is this node’s title? how many pins does it have? how should each pin be drawn?_ The stable `Id` is essential. egui requires that stateful widgets have an `Id` so their interaction state survives across frames; we discussed the role of `Id` in [Chapter 4](https://danpokhrel.github.io/tutorial/ch04-layout-widgets.html) . If you forget it, the widget will panic at runtime. Now we fill in the five required methods. This is where the node content lives. These three are straightforward. They take `&T` (the node data) and return static information about the node: `#![allow(unused)] fn main() { use egui_snarl::ui::SnarlViewer; impl SnarlViewer for DemoViewer { fn title(&mut self, node: &DemoNode) -> String { match node { DemoNode::Number(_) => "Number".to_string(), DemoNode::Text(_) => "Text".to_string(), DemoNode::Sink => "Sink".to_string(), } } fn inputs(&mut self, node: &DemoNode) -> usize { match node { DemoNode::Number(_) => 0, // a source has no inputs DemoNode::Text(_) => 0, DemoNode::Sink => 1, // the sink takes one input } } fn outputs(&mut self, node: &DemoNode) -> usize { match node { DemoNode::Number(_) => 1, // a number provides one output DemoNode::Text(_) => 1, DemoNode::Sink => 0, // the sink provides nothing } } // show_input and show_output follow below. (/* … */) } }` Notice how `match` on the enum — covered in the Rust Book’s [Chapter 6.2](https://doc.rust-lang.org/stable/book/ch06-02-match.html) — drives every decision: the title, the pin count, and (below) the rendering. The number of pins is a _function of the variant_, not a stored field. This is a strength of the enum approach: you cannot accidentally give a `Sink` an output pin, because the `match` arm for `Sink` returns `0` and there is no field to get out of sync. These two methods render a single pin. They receive a pin handle (`&InPin` or `&OutPin`), a `&mut egui::Ui` scoped to that pin’s row, and a `&mut Snarl` so you can read or mutate node data inline. They return a `PinInfo`, which describes how the pin itself (the little dot on the node border) is drawn. Let us start with the output side, which is simpler — when nothing is connected, we show an editable widget for the node’s value: `#![allow(unused)] fn main() { use egui_snarl::{ui::{PinInfo, SnarlPin}, InPin, OutPin}; impl SnarlViewer for DemoViewer { (/* title, inputs, outputs as above */) fn show_output( &mut self, pin: &OutPin, ui: &mut egui::Ui, snarl: &mut Snarl, ) -> impl SnarlPin + 'static { // Index into the node data for this pin's node. let node = &mut snarl[pin.id.node]; match node { DemoNode::Number(value) => { // Editable widget when nothing is wired. ui.add(egui::DragValue::new(value).speed(0.1)); PinInfo::circle().with_fill(egui::Color32::from_rgb(220, 80, 80)) } DemoNode::Text(s) => { ui.text_edit_singleline(s); PinInfo::circle().with_fill(egui::Color32::from_rgb(80, 200, 80)) } DemoNode::Sink => { // Sink has no outputs; this method is never called for it. unreachable!("Sink has no output pins") } } } fn show_input( &mut self, pin: &InPin, ui: &mut egui::Ui, snarl: &mut Snarl, ) -> impl SnarlPin + 'static { // Only the Sink has an input pin (index 0). let node = &mut snarl[pin.id.node]; if let DemoNode::Sink = node { // If something is wired in, show the source value; otherwise show "—". if let Some(remote) = pin.remotes.first() { let src = &snarl[remote.node]; match src { DemoNode::Number(v) => { ui.label(format!("{v}")); } DemoNode::Text(s) => { ui.label(s); } DemoNode::Sink => {} } PinInfo::circle().with_fill(egui::Color32::from_rgb(220, 80, 80)) } else { ui.label("—"); PinInfo::circle().with_fill(egui::Color32::from_rgb(120, 120, 120)) } } else { unreachable!("only Sink has input pins") } } } }` Let us unpack the two ideas this code introduces, because they recur throughout the rest of the book. **The borrow pattern.** `show_input` and `show_output` receive `&mut Snarl`. Inside, we index with `snarl[pin.id.node]` — this uses the `IndexMut` impl that `Snarl` provides — to get a `&mut DemoNode` for the node the pin belongs to. We then edit the node _inline_: dragging the `DragValue` mutates `DemoNode::Number`’s `f64` directly; typing in the text box mutates `DemoNode::Text`’s `String`. There is no copy, no commit step, no separate “edit buffer.” This is ownership and borrowing (Rust Book [Chapter 4](https://doc.rust-lang.org/stable/book/ch04-00-understanding-ownership.html) ) working in our favor: the borrow is short-lived (only for the duration of this method call), so the graph remains free for the next pin’s render. **Reading connected values via `remotes`.** The `InPin` has a `remotes: Vec` — the output pins that are wired into this input. To display the value a `Sink` receives, we look at `pin.remotes.first()`, take the source `OutPinId`, and index into `snarl[remote.node]` to read the source node’s data. `OutPin` has the mirror field: `remotes: Vec`, the inputs this output feeds. We will explore this fully in [Chapter 9](https://danpokhrel.github.io/tutorial/ch09-nodes-pins.html) . `PinInfo::circle().with_fill(color)` returns a `PinInfo` describing a round pin filled with the given color. There are also `.square()` and `.triangle()` shapes, and a `.with_wire_style(...)` modifier we will meet in [Chapter 12](https://danpokhrel.github.io/tutorial/ch12-styling-graph.html) . Color-coding pins by data type is a cheap and effective bit of visual design: red for numbers, green for text, and so on. Putting it all together, here is the complete, runnable-looking file. Save it as `src/main.rs` and run `cargo run`: `use eframe::egui; use egui_snarl::{ ui::{PinInfo, SnarlPin, SnarlViewer, SnarlWidget}, InPin, InPinId, OutPin, OutPinId, Snarl, }; use serde::{Deserialize, Serialize}; #[derive(Debug, Serialize, Deserialize, Clone)] pub enum DemoNode { Number(f64), Text(String), Sink, } #[derive(Default)] pub struct DemoViewer; impl SnarlViewer for DemoViewer { fn title(&mut self, node: &DemoNode) -> String { match node { DemoNode::Number(_) => "Number".to_string(), DemoNode::Text(_) => "Text".to_string(), DemoNode::Sink => "Sink".to_string(), } } fn inputs(&mut self, node: &DemoNode) -> usize { match node { DemoNode::Number(_) | DemoNode::Text(_) => 0, DemoNode::Sink => 1, } } fn outputs(&mut self, node: &DemoNode) -> usize { match node { DemoNode::Number(_) | DemoNode::Text(_) => 1, DemoNode::Sink => 0, } } fn show_input( &mut self, pin: &InPin, ui: &mut egui::Ui, snarl: &mut Snarl, ) -> impl SnarlPin + 'static { if let Some(remote) = pin.remotes.first() { match &snarl[remote.node] { DemoNode::Number(v) => ui.label(format!("{v}")), DemoNode::Text(s) => ui.label(s), DemoNode::Sink => ui.label(""), }; PinInfo::circle().with_fill(egui::Color32::from_rgb(220, 80, 80)) } else { ui.label("—"); PinInfo::circle().with_fill(egui::Color32::from_rgb(120, 120, 120)) } } fn show_output( &mut self, pin: &OutPin, ui: &mut egui::Ui, snarl: &mut Snarl, ) -> impl SnarlPin + 'static { let node = &mut snarl[pin.id.node]; match node { DemoNode::Number(value) => { ui.add(egui::DragValue::new(value).speed(0.1)); PinInfo::circle().with_fill(egui::Color32::from_rgb(220, 80, 80)) } DemoNode::Text(s) => { ui.text_edit_singleline(s); PinInfo::circle().with_fill(egui::Color32::from_rgb(80, 200, 80)) } DemoNode::Sink => unreachable!("Sink has no output pins"), } } } pub struct SnarlApp { snarl: Snarl, viewer: DemoViewer, } impl Default for SnarlApp { fn default() -> Self { let mut snarl = Snarl::new(); let n = snarl.insert_node(egui::pos2(-120.0, -40.0), DemoNode::Number(42.0)); let s = snarl.insert_node(egui::pos2(120.0, -40.0), DemoNode::Sink); snarl.connect( OutPinId { node: n, output: 0 }, InPinId { node: s, input: 0 }, ); Self { snarl, viewer: DemoViewer::default(), } } } impl eframe::App for SnarlApp { fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { egui::CentralPanel::default().show_inside(ui, |ui| { SnarlWidget::new() .id_salt(egui::Id::new("demo-snarl")) .show(&mut self.snarl, &mut self.viewer, ui); }); } } fn main() -> eframe::Result { let options = eframe::NativeOptions::default(); eframe::run_native( "egui-snarl demo", options, Box::new(|_cc| Ok(Box::::default())), ) }` Build and run the project. You should see a canvas with two nodes — a red `Number` node showing `42.0` on the left and a `Sink` node on the right — already wired together. The wire is drawn automatically because we called `snarl.connect` in `default()`. Try the following interactions: * **Drag a node** by its header to move it around the canvas. * **Pan the canvas** by dragging the background. * **Zoom** with Ctrl + scroll wheel. * **Edit the number** by dragging the `DragValue`. The `Sink` updates immediately because `show_input` reads the source node live, every frame. * **Break the wire** by right-clicking a pin and choosing to drop it (we will customize these menus in [Chapter 11](https://danpokhrel.github.io/tutorial/ch11-interactions.html) ). Notice that editing the `Number` node instantly changes what the `Sink` displays. There is no evaluation step, no “apply” button — the viewer simply reads the connected value every frame. In a real graph we will want an explicit _evaluation_ pass (Part 4), but for live editing of constants, this read-on-render approach is exactly right. > **Tip:** If your graph renders but interactions do not work (you cannot drag nodes or draw wires), the most common cause is a missing or duplicated `Id`. Each `SnarlWidget` on screen must have a unique `egui::Id`. Two widgets sharing an id will corrupt each other’s interaction state. One last structural point, because it will shape how you organize your code. Because `show_input` and `show_output` return `impl SnarlPin + 'static`, the `SnarlViewer` trait cannot be made into a trait object: `#![allow(unused)] fn main() { // This does NOT compile: let viewer: Box> = /* ... */; }` The compiler error points at the `impl Trait` return types and explains that the trait is not object-safe. This is a fundamental limitation of `impl Trait` in return position, as the Rust Book discusses when covering advanced types in [Chapter 18](https://doc.rust-lang.org/stable/book/ch18-04-advanced-types.html) . The practical consequence: implement `SnarlViewer` on a _concrete struct_ — `DemoViewer` here — and store that struct by value on your `App`. You cannot hide the viewer behind `Box`, and you cannot put different viewer types in a `Vec`. If you need multiple “skins” for the same graph, you have two options: a single viewer that branches internally, or generics over the viewer type. In this chapter we met `egui-snarl` and built our first working node graph. We learned that `Snarl` is a data container, that the `SnarlViewer` trait is the rendering layer, and that the two are deliberately separate so the graph stays pure data. We defined a node enum, implemented the five required trait methods, and rendered a live graph inside `CentralPanel`. The key patterns — indexing `snarl[node_id]` to read or edit node data, and reading `pin.remotes` to display connected values — will recur in every following chapter. We kept our nodes simple: an editable `Number`, an editable `Text`, and a read-only `Sink`. In [Chapter 9](https://danpokhrel.github.io/tutorial/ch09-nodes-pins.html) we will dig into the pin rendering pattern in depth — how to draw connected and unconnected pins differently, how to build node bodies and headers, and how to give each node type its own look. --- # Chapter 10: Connections & Wires - Building a Node-Based AI Flow Editor with eframe & egui Keyboard shortcuts ------------------ Press ← or → to navigate between chapters Press S or / to search in the book Press ? to show this help Press Esc to hide this help * Auto * Light * Rust * Coal * Navy * Ayu Building a Node-Based AI Flow Editor with eframe & egui ======================================================= [](https://danpokhrel.github.io/tutorial/print.html "Print this book") In [Chapter 9](https://danpokhrel.github.io/tutorial/ch09-nodes-pins.html) we built rich, editable nodes — but every wire in our graph so far was created in code with `snarl.connect`. In a real editor, the user draws wires by dragging from pin to pin, and we must _validate_ those connections: a number output should not feed a text input. In this chapter we override the `SnarlViewer` connection methods to add type checking, learn how disconnection and pin-drop work, and style wires per pin. This chapter assumes you have read [Chapter 8](https://danpokhrel.github.io/tutorial/ch08-egui-snarl.html) and [Chapter 9](https://danpokhrel.github.io/tutorial/ch09-nodes-pins.html) ; we will also lean on the enum pattern matching from the Rust Book’s [Chapter 6.2](https://doc.rust-lang.org/stable/book/ch06-02-match.html) . A wire is a connection from one `OutPinId` to one `InPinId`. The user creates a wire interactively by _dragging from a pin and releasing on another pin_. `egui-snarl` handles the drag detection and the visual rendering of the in-progress wire; at the moment of release it calls into your viewer to ask whether the connection should be allowed. > **Reference-impl note:** the demonstration editor this book builds (`DemoNode`: Number / Text / Concat / Sink) does **not** override `connect` — it keeps snarl’s default, permissive connect so that any output can wire into any input. That keeps the demo small and lets a reader experiment without type rejections getting in the way. This chapter is an optional enhancement: read it when you want to add type checking to your own graph, and skip it (or come back later) if you just want the demo editor. The reference impl’s `DemoViewer` has none of the `connect` / `disconnect` / `drop_*` overrides shown here. Three methods on `SnarlViewer` govern connections: | Method | When it is called | Default behavior | | --- | --- | --- | | `connect(&mut self, from: &OutPin, to: &InPin, snarl: &mut Snarl)` | User drops a wire from output `from` onto input `to`. | Calls `snarl.connect(from.id, to.id)`. | | `disconnect(&mut self, from: &OutPin, to: &InPin, snarl: &mut Snarl)` | A wire is removed (e.g., user drags a connected wire away). | Calls `snarl.disconnect(from.id, to.id)`. | | `drop_outputs` / `drop_inputs` | Right-click on a pin → “drop all wires”. | Clears all wires on that pin. | Crucially, these methods receive _pin handles_ (`&OutPin`, `&InPin`), not just ids. The handles carry `.remotes`, so your validation logic can inspect what is already connected. The `&mut Snarl` is passed so you can perform the actual mutation. > **Note:** The default `connect` is _permissive_ — it accepts any wire between any two pins. That is fine for a demo, but a production graph almost always wants type checking. Override `connect` to add it. The pattern is: inspect the source node and the destination node by type, decide whether the connection is valid, and only then call `snarl.connect`. Return `bool` — `egui-snarl` uses it (and whether `snarl.connect` returned `true`) to decide whether the visual wire should snap into place. Let us define a notion of “data type” for each pin, derived from the node variant. We extend our `DemoNode` enum from [Chapter 9](https://danpokhrel.github.io/tutorial/ch09-nodes-pins.html) with a helper that maps each output pin to a data type: `#![allow(unused)] fn main() { use serde::{Deserialize, Serialize}; /// The kind of data flowing over a wire. #[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Eq)] pub enum DataType { Number, Text, } #[derive(Serialize, Deserialize, Clone)] pub enum DemoNode { Number(f64), Text(String), Concat, Sink, } impl DemoNode { /// What data type a given *output* pin produces. pub fn output_type(&self, output: usize) -> Option { match self { DemoNode::Number(_) => (output == 0).then_some(DataType::Number), DemoNode::Text(_) => (output == 0).then_some(DataType::Text), DemoNode::Concat => (output == 0).then_some(DataType::Text), DemoNode::Sink => None, } } /// What data type a given *input* pin expects. pub fn input_type(&self, input: usize) -> Option { match self { DemoNode::Number(_) | DemoNode::Text(_) => None, // Both Concat inputs expect Text. DemoNode::Concat => (input < 2).then_some(DataType::Text), // The single Sink input accepts anything. DemoNode::Sink => (input == 0).then_some(DataType::Number).or( (input == 0).then_some(DataType::Text), ), } } } }` The `Sink` case shows a subtlety: a “sink any type” pin has no single expected type. We model that by returning `None` for “untyped” and checking compatibility specially. A cleaner design uses an `Option` where `None` means “accepts anything”; we do that here for inputs: ``#![allow(unused)] fn main() { impl DemoNode { /// `None` means "accepts any type". pub fn expected_input(&self, input: usize) -> Option { match self { DemoNode::Concat => Some(DataType::Text), DemoNode::Sink => None, // accepts anything _ => None, } } } }`` Now the `connect` override. The rule: a wire is allowed if the destination expects either nothing (any) or exactly the source’s type: `#![allow(unused)] fn main() { use egui_snarl::{ui::SnarlViewer, InPin, OutPin, Snarl}; impl SnarlViewer for DemoViewer { fn connect( &mut self, from: &OutPin, to: &InPin, snarl: &mut Snarl, ) -> bool { // Read source and destination node types BEFORE mutating. let src = &snarl[from.id.node]; let dst = &snarl[to.id.node]; let Some(src_type) = src.output_type(from.id.output) else { return false; // source has no such output }; match dst.expected_input(to.id.input) { None => { // Destination accepts any type. snarl.connect(from.id, to.id) } Some(expected) if expected == src_type => { snarl.connect(from.id, to.id) } Some(_) => { // Type mismatch: reject, do not create the wire. false } } } } }` Two design points. First, **read before mutate.** We take shared borrows of `snarl[from.id.node]` and `snarl[to.id.node]` to read the types, _then_ call `snarl.connect(...)`, which takes `&mut`. The shared borrows must end before the mutable call — the compiler enforces this because the shared borrows go out of scope at the end of the `let` bindings’ last use. This is the same stagger-the-borrows discipline from [Chapter 9](https://danpokhrel.github.io/tutorial/ch09-nodes-pins.html) , and it is the Rust Book’s [Chapter 4.2](https://doc.rust-lang.org/stable/book/ch04-02-references-and-borrowing.html) made concrete. Second, **return the result of `snarl.connect`.** The underlying `Snarl::connect(from, to) -> bool` returns `true` when a new connection is created and `false` when one already existed (a duplicate). Returning it directly lets the widget know whether a wire actually appeared. > **Tip:** If you want to _replace_ an existing wire on a single-input pin rather than reject a duplicate, call `snarl.drop_inputs(to.id)` before `snarl.connect(...)` in your override. This implements “one wire per input” semantics — common in shader graphs where an input takes exactly one value. `disconnect` is the inverse of `connect`. The default implementation calls `snarl.disconnect(from.id, to.id)` and that is usually all you want. You override it when removing a wire should trigger a _side effect_ — for example, clearing a cached evaluation, or marking a node “dirty” so it re-evaluates next frame: ``#![allow(unused)] fn main() { impl SnarlViewer for DemoViewer { fn disconnect( &mut self, from: &OutPin, to: &InPin, snarl: &mut Snarl, ) { // Perform the actual disconnection. snarl.disconnect(from.id, to.id); // Side effect: any node whose input changed may need re-evaluation. // In Part 4 we will mark `to.id.node` dirty here. self.mark_dirty(to.id.node); } } impl DemoViewer { fn mark_dirty(&mut self, _node: egui_snarl::NodeId) { // e.g. push onto a Vec of dirty nodes. } } }`` `disconnect` does not return a `bool` — it is a notification, not a gate. You cannot _prevent_ a disconnection (the user can always drag a wire away), but you can react to it. Right-clicking a pin offers (by default) a menu item to drop all wires on that pin. `drop_outputs(pin: &OutPin, snarl)` clears every wire leaving an output; `drop_inputs(pin: &InPin, snarl)` clears every wire arriving at an input. The defaults call `snarl.drop_outputs(pin.id)` and `snarl.drop_inputs(pin.id)` respectively. Like `disconnect`, you override these to add side effects when a whole pin is cleared. A common reason: if an output had several downstream consumers and you drop them all, you want to mark each consumer dirty: `#![allow(unused)] fn main() { impl SnarlViewer for DemoViewer { fn drop_outputs(&mut self, pin: &egui_snarl::ui::OutPin, snarl: &mut Snarl) { // Collect the affected downstream node ids first. let affected: Vec<_> = pin.remotes.iter().map(|r| r.node).collect(); snarl.drop_outputs(pin.id); for node in affected { self.mark_dirty(node); } } fn drop_inputs(&mut self, pin: &egui_snarl::ui::InPin, snarl: &mut Snarl) { snarl.drop_inputs(pin.id); self.mark_dirty(pin.id.node); } } }` Notice we again follow the collect-then-mutate pattern from [Chapter 5](https://danpokhrel.github.io/tutorial/ch05-architecture.html) : we copy the `remotes` ids into an owned `Vec` _before_ calling `snarl.drop_outputs`, because that call mutates the `Snarl` and would invalidate a live borrow of `pin.remotes`. By default, an input pin accepts multiple wires. `InPin.remotes` is a `Vec`, and the default `connect` simply appends. Whether that is desirable depends on your graph’s semantics: * A **merge/sum** node wants multiple wires on one input. * A **function argument** node wants exactly one wire per input. For the “exactly one” case, drop existing wires in your `connect` override before connecting the new one (the tip above). For the “many allowed” case, leave the default. `egui-snarl` itself has no notion of “single vs multi” — it is entirely your `connect`’s decision. Wires are drawn by `egui-snarl`, but you influence their appearance through `PinInfo`. Recall from [Chapter 9](https://danpokhrel.github.io/tutorial/ch09-nodes-pins.html) that `show_input`/`show_output` return a `PinInfo`. The `.with_wire_style(...)` modifier on `PinInfo` sets how wires _attached to that pin_ are drawn: `#![allow(unused)] fn main() { use egui_snarl::ui::{PinInfo, WireStyle}; // Smooth bezier (the default if you omit with_wire_style). let smooth = PinInfo::circle().with_fill(egui::Color32::from_rgb(220, 80, 80)); // Right-angled wires with rounded corners. let angular = PinInfo::circle() .with_fill(egui::Color32::from_rgb(80, 200, 80)) .with_wire_style(WireStyle::AxisAligned { corner_radius: 8.0 }); }` `WireStyle::AxisAligned { corner_radius }` produces wires that run horizontally from the pin, turn at a right angle, and run to the other pin — the look popularized by Unreal’s material editor. The default (no `.with_wire_style`) is a smooth bezier. Mixing styles on the same graph is possible but usually looks inconsistent; pick one convention and apply it to every pin. We will set a global default via `SnarlStyle` in [Chapter 12](https://danpokhrel.github.io/tutorial/ch12-styling-graph.html) . Wire _color_ follows the pin’s fill color. This is why color-coding pins by data type is so effective: the wires themselves become color-coded, and a red wire flowing into a green pin is an immediate, glaring type mismatch. For advanced cases — animated wires, wires with a direction arrow, wires that display a flowing dot — `egui-snarl` lets you take over rendering of an individual wire segment entirely. Override `has_wire_widget` to return `true` for wires you want to customize, and implement `show_wire_widget` to draw into the `ui` positioned along the wire: `#![allow(unused)] fn main() { use egui_snarl::{InPin, OutPin, ui::WireId}; impl SnarlViewer for DemoViewer { fn has_wire_widget(&mut self, _from: &OutPin, _to: &InPin) -> bool { // Only add widgets to number wires, for example. true } fn show_wire_widget( &mut self, _id: WireId, ui: &mut egui::Ui, _snarl: &mut Snarl, ) { // Draw a small label or a moving dot here. ui.label("·"); } } }` This is rarely needed; we mention it for completeness. The default wire rendering is attractive and performant. We will not use custom wire widgets in this book’s main example, but they are handy for tools that visualize data _flow_ during evaluation in [Chapter 15](https://danpokhrel.github.io/tutorial/ch15-live-execution.html) . Putting the pieces together, here is a `connect` that validates string→string, number→number, and rejects incompatible types, using the `expected_input`/`output_type` helpers from earlier in the chapter: `#![allow(unused)] fn main() { use egui_snarl::{ui::SnarlViewer, InPin, OutPin, Snarl}; impl SnarlViewer for DemoViewer { fn connect( &mut self, from: &OutPin, to: &InPin, snarl: &mut Snarl, ) -> bool { // Borrow the two nodes immutably to read their types. let (src_type, dst_expected) = { let src = &snarl[from.id.node]; let dst = &snarl[to.id.node]; (src.output_type(from.id.output), dst.expected_input(to.id.input)) }; // The shared borrows end here; we may now mutate. let Some(src_type) = src_type else { return false }; let compatible = match dst_expected { None => true, // destination accepts any type Some(expected) => expected == src_type, }; if !compatible { return false; // reject; no wire is created } // For single-value inputs, replace any existing wire. if matches!(dst_expected, Some(_)) { snarl.drop_inputs(to.id); } snarl.connect(from.id, to.id) } fn disconnect( &mut self, from: &OutPin, to: &InPin, snarl: &mut Snarl, ) { snarl.disconnect(from.id, to.id); self.mark_dirty(to.id.node); } } }` With this in place, dragging from a `Number` output onto a `Concat` input (which expects `Text`) will simply fail to create a wire — the dragged wire snaps back. Dragging from a `Text` output onto the `Sink` (which accepts anything) succeeds. The user gets immediate, silent feedback about type compatibility, exactly as in a shader graph. > **Note:** There is no built-in “red flash” or error toast on a rejected connection. If you want feedback, you can set a transient message field on your viewer inside the `!compatible` branch and render it as a toast next frame — the state-collection pattern from [Chapter 5](https://danpokhrel.github.io/tutorial/ch05-architecture.html) > . When a node is removed (we will wire up `remove_node` via a context menu in [Chapter 11](https://danpokhrel.github.io/tutorial/ch11-interactions.html) ), `egui-snarl` automatically drops every wire that touched the node’s pins. You do not need to disconnect manually. However, _downstream nodes that were reading from the removed node_ may now display stale data or an empty `remotes` vec — your `show_input` must handle `pin.remotes.is_empty()` gracefully, as we did in [Chapter 9](https://danpokhrel.github.io/tutorial/ch09-nodes-pins.html) by showing “—”. If you cache evaluations (as we will in Part 4), hook `disconnect` and the removal path to mark consumers dirty. The `Snarl::remove_node(node_id) -> T` method removes a node and returns its data; wires are cleaned up internally: `#![allow(unused)] fn main() { let removed: DemoNode = snarl.remove_node(node_id); // Wires that touched this node are gone; downstream remotes are updated. }` Because removal returns the node data by value, you can inspect it (e.g., to log it or to offer an “undo”) before dropping it. This is ownership in action — the `Snarl` _owned_ the `DemoNode`, and on removal it moves that ownership out to you (Rust Book [Chapter 4.1](https://doc.rust-lang.org/stable/book/ch04-01-what-is-ownership.html) ). In this chapter we took control of the graph’s connections. We overrode `connect` to validate wires by data type — accepting string→string and number→number, rejecting mismatches — and learned that `disconnect` and `drop_outputs`/`drop_inputs` are notification hooks for side effects like marking nodes dirty. We saw that multi-connection is the default and that “exactly one wire per input” is a one-line override, and we styled wires per pin through `PinInfo::with_wire_style`. The collect-then-mutate borrow discipline kept the type-checking code sound. Our graph now enforces a sensible type system, but the only way to add nodes is still in code. In [Chapter 11](https://danpokhrel.github.io/tutorial/ch11-interactions.html) we will add context menus — right-click the background to spawn nodes, right-click a node to delete it, and drag a wire to empty space to create a connected node automatically. --- # Chapter 12: Styling the Graph - Building a Node-Based AI Flow Editor with eframe & egui Keyboard shortcuts ------------------ Press ← or → to navigate between chapters Press S or / to search in the book Press ? to show this help Press Esc to hide this help * Auto * Light * Rust * Coal * Navy * Ayu Building a Node-Based AI Flow Editor with eframe & egui ======================================================= [](https://danpokhrel.github.io/tutorial/print.html "Print this book") Our editor is functional and interactive, but it still uses the default visual style — generic frames, default wire curves, no color-coded pins at a global level. In this chapter we build a polished dark theme: a custom `SnarlStyle` with a dark node frame and grid background, color-coded pins wired into a `theme` module, per-node-type colored headers, and per-pin wire styles. This chapter builds on the `PinInfo` material from [Chapter 9](https://danpokhrel.github.io/tutorial/ch09-nodes-pins.html) , the connection logic from [Chapter 10](https://danpokhrel.github.io/tutorial/ch10-connections.html) , and the theming-module ideas from [Chapter 6](https://danpokhrel.github.io/tutorial/ch06-theming.html) . > **Reference-impl note:** the demonstration editor does **not** use a custom `SnarlStyle` — it renders the snarl with `SnarlWidget::new().id_salt(...).show(...)` and no `.style(...)`, so it inherits snarl’s default node frame, grid background, and bezier wires. This chapter (like [Chapter 10](https://danpokhrel.github.io/tutorial/ch10-connections.html) > ) is an optional enhancement: read it when you want a polished, branded graph look, and skip it if the defaults suffice. The reference impl’s `DemoViewer` also does not override `header_frame` (see [Chapter 9](https://danpokhrel.github.io/tutorial/ch09-nodes-pins.html) > ), so there are no per-type colored headers either. Where `egui`’s `Visuals`/`Style`/`FontDefinitions` (from [Chapter 6](https://danpokhrel.github.io/tutorial/ch06-theming.html) ) style the whole application, `SnarlStyle` styles _the graph specifically_. It controls the graph background, the default node frame, the pin layout, the pin size, and more. You construct it with `SnarlStyle::new()` and override fields with struct-update syntax — the same `{ ..Default::default() }` idiom the Rust Book introduces when discussing structs in [Chapter 5.1](https://doc.rust-lang.org/stable/book/ch05-01-defining-structs.html) : ``#![allow(unused)] fn main() { use egui_snarl::SnarlStyle; let style = SnarlStyle { // All other fields keep their `new()` defaults: ..SnarlStyle::new() }; }`` The most useful fields are: | Field | Controls | | --- | --- | | `node_layout` | How pins are arranged (`Basic` = inputs left, outputs right). | | `pin_placement` | Where pins sit relative to the node border (`Edge`, etc.). | | `pin_size` | The radius/size of pin shapes. | | `node_frame` | The default `egui::Frame` around each node. | | `bg_frame` | The `egui::Frame` for the graph background (a grid by default). | You pass the style to the widget via `.style(style)`: `#![allow(unused)] fn main() { use eframe::egui; use egui_snarl::{SnarlStyle, ui::SnarlWidget}; impl eframe::App for SnarlApp { fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { egui::CentralPanel::default().show_inside(ui, |ui| { SnarlWidget::new() .id_salt(egui::Id::new("demo-snarl")) .style(self.style.clone()) .show(&mut self.snarl, &mut self.viewer, ui); }); } } }` Because `SnarlStyle` is cheap to clone, store it on the `App` and apply it every frame. This mirrors how we stored the app-wide `theme` module in [Chapter 6](https://danpokhrel.github.io/tutorial/ch06-theming.html) : a single source of truth for appearance. Let us build a dark style. The `node_frame` sets each node’s background, margin, corner radius, and border; the `bg_frame` sets the canvas background. egui’s `Frame` is a builder we used in [Chapter 6](https://danpokhrel.github.io/tutorial/ch06-theming.html) : `#![allow(unused)] fn main() { use eframe::egui; use egui_snarl::SnarlStyle; pub fn dark_snarl_style() -> SnarlStyle { let node_frame = egui::Frame::default() .fill(egui::Color32::from_rgb(40, 42, 48)) .corner_radius(6.0) .stroke(egui::Stroke::new(1.0, egui::Color32::from_rgb(70, 74, 82))) .inner_margin(egui::Margin::same(4.0)); let bg_frame = egui::Frame::default() .fill(egui::Color32::from_rgb(24, 26, 30)) .stroke(egui::Stroke::NONE); SnarlStyle { node_frame: Some(node_frame), bg_frame: Some(bg_frame), ..SnarlStyle::new() } } }` The header frame we customize separately, per node type, by overriding `SnarlViewer::header_frame` (we did this in [Chapter 9](https://danpokhrel.github.io/tutorial/ch09-nodes-pins.html) ). The `SnarlStyle.node_frame` is the _body_ frame; `header_frame` on the viewer overrides the header. `NodeLayout` decides how pins are arranged within a node. The default is `Basic` — input pins stacked on the left, output pins stacked on the right, with the body in between. This is what we have been using implicitly all along. You can construct the default explicitly: `#![allow(unused)] fn main() { use egui_snarl::{NodeLayout, SnarlStyle}; let style = SnarlStyle { node_layout: NodeLayout::coil(), ..SnarlStyle::new() }; }` `NodeLayout::coil()` produces the standard Basic layout. (The name reflects the internal rendering approach; the visible result is the familiar left-inputs / right-outputs arrangement.) For most graphs this is exactly right, and we will not change it. The point of knowing the field exists is that _alternative layouts_ are possible if your graph needs them. `PinPlacement` decides where the pin dot sits relative to the node frame. The `Edge` variant draws pins right on the node’s border — the look used by most shader-graph editors. You set it on the style: `#![allow(unused)] fn main() { use egui_snarl::{SnarlStyle, ui::PinPlacement}; let style = SnarlStyle { pin_placement: PinPlacement::Edge, ..SnarlStyle::new() }; }` > **Tip:** `PinPlacement::Edge` is the most polished look for a node graph: the pin dots sit half-in, half-out of the node border, so wires visually “plug in.” Try it before settling for the default, which places pins just inside the frame. In [Chapter 9](https://danpokhrel.github.io/tutorial/ch09-nodes-pins.html) we color-coded pins by hand. Now we formalize the convention in a `theme` module, so every pin returns a consistent color for its data type. This is the same encapsulation idea as the `theme.rs` module from [Chapter 6](https://danpokhrel.github.io/tutorial/ch06-theming.html) : keep your color constants in one place so a redesign touches one file. `#![allow(unused)] fn main() { // src/snarl_theme.rs use eframe::egui; use egui_snarl::ui::{PinInfo, WireStyle}; /// The data types in our graph and their colors. pub fn pin_color(ty: DataType) -> egui::Color32 { match ty { DataType::Number => egui::Color32::from_rgb(220, 80, 80), DataType::Text => egui::Color32::from_rgb(80, 200, 80), } } /// A round pin filled with the type's color, with axis-aligned wires. pub fn pin_for(ty: DataType) -> PinInfo { PinInfo::circle() .with_fill(pin_color(ty)) .with_wire_style(WireStyle::AxisAligned { corner_radius: 6.0 }) } /// A dim pin for an unconnected input. pub fn dim_pin() -> PinInfo { PinInfo::circle() .with_fill(egui::Color32::from_rgb(120, 120, 120)) } /// The header color for a node type. pub fn header_color(node: &DemoNode) -> egui::Color32 { match node { DemoNode::Number(_) => egui::Color32::from_rgb(110, 60, 60), DemoNode::Text(_) => egui::Color32::from_rgb(50, 90, 50), DemoNode::Concat => egui::Color32::from_rgb(60, 60, 110), DemoNode::Sink => egui::Color32::from_rgb(50, 50, 70), } } }` Now the viewer delegates to these helpers, and the colors are defined in exactly one place: `#![allow(unused)] fn main() { impl SnarlViewer for DemoViewer { fn show_output( &mut self, pin: &OutPin, ui: &mut egui::Ui, snarl: &mut Snarl, ) -> impl SnarlPin + 'static { let node = &mut snarl[pin.id.node]; let ty = node.output_type(pin.id.output).unwrap_or(DataType::Number); match node { DemoNode::Number(v) => { ui.add(egui::DragValue::new(v).speed(0.1)); } DemoNode::Text(s) => { ui.text_edit_singleline(s); } DemoNode::Concat => { ui.label("out"); } DemoNode::Sink => unreachable!(), } crate::snarl_theme::pin_for(ty) } fn header_frame( &mut self, _frame: egui::Frame, node: NodeId, _inputs: &[InPin], _outputs: &[OutPin], snarl: &Snarl, ) -> egui::Frame { let node = &snarl[node]; let color = crate::snarl_theme::header_color(node); egui::Frame::default() .fill(color) .corner_radius(4.0) .stroke(egui::Stroke::new(1.0, egui::Color32::BLACK)) } } }` Because every pin’s color comes from `pin_color`, the _wires_ — which take their color from the pin fill — are automatically color-coded too. A red wire flowing into a green pin is an instant, glaring type mismatch, and your eye finds it without reading a single label. We have already shown `header_frame` in [Chapter 9](https://danpokhrel.github.io/tutorial/ch09-nodes-pins.html) and formalized the colors above. The pattern is a `match` on `&T` returning a per-variant `egui::Frame`. The compiler ensures we cover every variant (Rust Book [Chapter 6.2](https://doc.rust-lang.org/stable/book/ch06-02-match.html) ); adding a new node type later produces a compile error until we choose its header color — a free consistency check. For a richer look, vary not just the color but the rounding and stroke per type. A “dangerous” node (one with side effects, like an agent that calls out to a network) might get a thicker border: `#![allow(unused)] fn main() { fn header_frame( &mut self, _frame: egui::Frame, node: NodeId, _inputs: &[InPin], _outputs: &[OutPin], snarl: &Snarl, ) -> egui::Frame { let node = &snarl[node]; let color = crate::snarl_theme::header_color(node); egui::Frame::default() .fill(color) .corner_radius(4.0) .stroke(egui::Stroke::new( if matches!(node, DemoNode::Sink) { 2.0 } else { 1.0 }, egui::Color32::BLACK, )) } }` By default, `SnarlStyle`’s background draws a grid — the classic “graph paper” look that helps the user gauge position and scale. You get this for free with `SnarlStyle::new()`. The grid color and spacing derive from the `bg_frame`’s fill and the widget’s internal logic. If you want a custom background — for example, a dotted pattern, a watermark, or a “spawn zone” tint — you override `SnarlViewer::draw_background`. The method receives a `&mut Ui` covering the whole canvas and the current `TSTransform` (the pan/zoom transform) so you can draw in _graph_ coordinates: `#![allow(unused)] fn main() { use egui_snarl::{ui::{TSTransform, SnarlViewer}, Snarl}; impl SnarlViewer for DemoViewer { fn draw_background( &mut self, transform: TSTransform, ui: &mut egui::Ui, _snarl: &mut Snarl, ) { // Let the default grid draw first, then overlay a tint in a corner. // (The default grid is drawn by SnarlStyle's bg_frame; here we add to it.) let painter = ui.painter().clone(); let rect = ui.max_rect(); // Example: a subtle radial vignette. painter.rect_filled( rect, 0.0, egui::Color32::from_black_alpha(0), ); let _ = transform; // use to convert graph<->screen coords if needed } } }` The default grid is usually what you want; we mention `draw_background` and `current_transform` for completeness — they are the escape hatch for tools that need custom canvas decorations, like drawing a “playhead” line during evaluation in [Chapter 15](https://danpokhrel.github.io/tutorial/ch15-live-execution.html) . Store the `SnarlStyle` on your `App` and apply it in `ui()`. Construct it once in `default()` so you never rebuild it per frame: `#![allow(unused)] fn main() { use eframe::egui; use egui_snarl::SnarlStyle; pub struct SnarlApp { snarl: Snarl, viewer: DemoViewer, style: SnarlStyle, } impl Default for SnarlApp { fn default() -> Self { Self { snarl: Snarl::new(), viewer: DemoViewer::default(), style: dark_snarl_style(), } } } impl eframe::App for SnarlApp { fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { egui::CentralPanel::default().show_inside(ui, |ui| { egui_snarl::SnarlWidget::new() .id_salt(egui::Id::new("demo-snarl")) .style(self.style.clone()) .show(&mut self.snarl, &mut self.viewer, ui); }); } } }` If you want a live dark/light toggle for the _graph_ (separate from egui’s application-wide `Visuals`), store a `bool` and rebuild the style in `logic()` when it changes. This is the same “derive a style from a small set of toggles” pattern we used for app theming in [Chapter 6](https://danpokhrel.github.io/tutorial/ch06-theming.html) . Following [Chapter 6](https://danpokhrel.github.io/tutorial/ch06-theming.html) , encapsulate the graph’s appearance in its own module so a redesign is a one-file change: `#![allow(unused)] fn main() { // src/snarl_theme.rs use eframe::egui; use egui_snarl::{ui::{PinInfo, PinPlacement, WireStyle}, NodeLayout, SnarlStyle}; #[derive(Clone, Copy, PartialEq, Eq)] pub enum DataType { Number, Text } pub fn pin_color(ty: DataType) -> egui::Color32 { /* ... */ } pub fn pin_for(ty: DataType) -> PinInfo { /* ... */ } pub fn dim_pin() -> PinInfo { /* ... */ } pub fn header_color(node: &DemoNode) -> egui::Color32 { /* ... */ } pub fn dark_snarl_style() -> SnarlStyle { let node_frame = egui::Frame::default() .fill(egui::Color32::from_rgb(40, 42, 48)) .corner_radius(6.0) .stroke(egui::Stroke::new(1.0, egui::Color32::from_rgb(70, 74, 82))) .inner_margin(egui::Margin::same(4.0)); let bg_frame = egui::Frame::default() .fill(egui::Color32::from_rgb(24, 26, 30)); SnarlStyle { node_layout: NodeLayout::coil(), pin_placement: PinPlacement::Edge, node_frame: Some(node_frame), bg_frame: Some(bg_frame), ..SnarlStyle::new() } } }` The viewer imports from this module rather than hardcoding colors. This is the Rust Book’s [Chapter 7](https://doc.rust-lang.org/stable/book/ch07-02-defining-modules-to-control-scope-and-privacy.html) advice on modules made concrete: a `snarl_theme` module exposes a small public API (`pin_for`, `header_color`, `dark_snarl_style`) and hides the rest. Here is the assembled `ui()` for a fully styled graph — dark node frames, `Edge` pin placement, axis-aligned wires, color-coded pins, per-type headers, and the grid background: `#![allow(unused)] fn main() { use eframe::egui; use egui_snarl::ui::SnarlWidget; pub struct SnarlApp { snarl: egui_snarl::Snarl, viewer: DemoViewer, style: egui_snarl::SnarlStyle, } impl eframe::App for SnarlApp { fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { egui::Panel::top("toolbar").show_inside(ui, |ui| { ui.horizontal(|ui| { ui.label("Node Graph Editor"); if ui.button("Reset Style").clicked() { self.style = crate::snarl_theme::dark_snarl_style(); } }); }); egui::CentralPanel::default().show_inside(ui, |ui| { SnarlWidget::new() .id_salt(egui::Id::new("demo-snarl")) .style(self.style.clone()) .show(&mut self.snarl, &mut self.viewer, ui); }); } } }` With the viewer from [Chapter 11](https://danpokhrel.github.io/tutorial/ch11-interactions.html) returning `pin_for(ty)` from `show_input`/`show_output` and `header_color(node)` from `header_frame`, the result is a graph that looks like a professional shader-graph editor: dark nodes with colored headers, color-coded pins and wires with right-angled corners, and a subtle grid behind it all. The `egui-snarl` demo itself uses the `egui_probe` crate to live-edit its `SnarlStyle` at runtime — a `Probe`\-driven inspector that exposes every style field as an editable widget. This is a _debugging_ tool, not a production feature, but it is invaluable when you are dialing in a look: you tweak `pin_size`, `corner_radius`, and colors in a running app and see the result instantly. > **Note:** `egui_probe` is not enabled by default. To use it, add `egui-snarl = { version = "0.11", features = ["serde", "egui-probe"] }` to your `Cargo.toml` and add `egui_probe` as a dependency. Without the feature, `ui.probe(...)` will not resolve. To try it, add `egui_probe` to your dev-dependencies and, behind a debug flag, render a probe panel: `#![allow(unused)] fn main() { // dev-dependency only; do not ship to end users. use egui_probe::Probe; impl eframe::App for SnarlApp { fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { egui::Panel::left("debug").show_inside(ui, |ui| { ui.heading("Style Probe"); // Probe generates editable widgets for every public field. ui.probe(&mut self.style); }); egui::CentralPanel::default().show_inside(ui, |ui| { egui_snarl::SnarlWidget::new() .id_salt(egui::Id::new("demo-snarl")) .style(self.style.clone()) .show(&mut self.snarl, &mut self.viewer, ui); }); } } }` `egui_probe`’s `Probe` trait uses reflection-style code generation to produce a UI for any struct that implements `Probe`. Once you have found values you like, hard-code them into your `dark_snarl_style()` function and remove the probe. This is exactly how the `egui-snarl` authors developed the defaults you inherited. > **Note:** `egui_probe` is a debugging aid, not a stable styling API. Its API may change between versions. Do not depend on it for production UI; use it only to discover the style values you then bake into your `theme` module. In this chapter we built a polished graph style. We learned that `SnarlStyle` controls the graph’s global appearance — node frame, background frame, pin layout, pin placement — and that `SnarlViewer::header_frame` overrides the header per node type. We factored all colors into a `snarl_theme` module, color-coded pins by data type so the wires themselves become a type-mismatch alarm, and switched wires to axis-aligned segments for a shader-graph look. We mentioned `egui_probe` as an optional live-tweaking tool for discovering style values. With Chapters 8 through 12, we now have a complete, styled, interactive node-graph editor. In Part 4, we will give this graph _meaning_: [Chapter 13](https://danpokhrel.github.io/tutorial/ch13-agent-nodes.html) defines agent nodes that call out to language models, [Chapter 14](https://danpokhrel.github.io/tutorial/ch14-graph-evaluation.html) turns the graph into an evaluation engine, and [Chapter 15](https://danpokhrel.github.io/tutorial/ch15-live-execution.html) streams results back into the node bodies as they run. --- # Chapter 11: Interactions & UX - Building a Node-Based AI Flow Editor with eframe & egui Keyboard shortcuts ------------------ Press ← or → to navigate between chapters Press S or / to search in the book Press ? to show this help Press Esc to hide this help * Auto * Light * Rust * Coal * Navy * Ayu Building a Node-Based AI Flow Editor with eframe & egui ======================================================= [](https://danpokhrel.github.io/tutorial/print.html "Print this book") Our graph validates connections and renders rich nodes, but the user still has no way to add or remove nodes without editing source code. In this chapter we wire up the full interaction surface of `egui-snarl`: context menus for the background (to spawn nodes), context menus for nodes (to delete them), dropped-wire menus (to create a pre-connected node from a dragged wire), hover popups, node selection, and keyboard shortcuts. This chapter brings together the input handling from [Chapter 7](https://danpokhrel.github.io/tutorial/ch07-input-menus.html) and the pin/connection model from [Chapter 9](https://danpokhrel.github.io/tutorial/ch09-nodes-pins.html) and [Chapter 10](https://danpokhrel.github.io/tutorial/ch10-connections.html) . `egui-snarl` exposes five pairs of `has_*`/`show_*` hooks for context menus and popups. Each `has_*` method returns `bool` (do we want this menu at all?); each `show_*` method builds the menu’s contents into a `&mut egui::Ui`: | Hook pair | Trigger | Typical use | | --- | --- | --- | | `has_graph_menu` / `show_graph_menu` | Right-click on empty canvas. | “Add Node” palette. | | `has_node_menu` / `show_node_menu` | Right-click on a node. | Delete, duplicate, rename. | | `has_dropped_wire_menu` / `show_dropped_wire_menu` | A wire is dragged to empty space and released. | Create a node pre-wired to the dropped pin. | | `has_on_hover_popup` / `show_on_hover_popup` | Pointer lingers over a node. | Tooltip / description. | All the `show_*` methods receive a `NodeId` (or, for the graph menu, a `Pos2`) and a `&mut Snarl`, so they can both read and mutate the graph directly. This is the same inline-mutation pattern from [Chapter 9](https://danpokhrel.github.io/tutorial/ch09-nodes-pins.html) : no event queue, no deferred command — you mutate the `Snarl` right there. `show_graph_menu` receives `pos: Pos2` — the screen-space point where the user right-clicked — plus the usual `ui` and `snarl`. The natural implementation builds an “Add Node” palette: one button per node type, and `insert_node(pos, node)` on click. Crucially, the new node appears _exactly where the user clicked_, because we pass `pos` straight through: `#![allow(unused)] fn main() { use egui_snarl::{Snarl, ui::SnarlViewer}; impl SnarlViewer for DemoViewer { fn has_graph_menu(&mut self, _pos: egui::Pos2, _snarl: &mut Snarl) -> bool { true // always offer the menu } fn show_graph_menu( &mut self, pos: egui::Pos2, ui: &mut egui::Ui, snarl: &mut Snarl, ) { ui.label("Add node"); if ui.button("Number").clicked() { snarl.insert_node(pos, DemoNode::Number(0.0)); ui.close(); } if ui.button("Text").clicked() { snarl.insert_node(pos, DemoNode::Text(String::new())); ui.close(); } if ui.button("Concat").clicked() { snarl.insert_node(pos, DemoNode::Concat); ui.close(); } if ui.button("Sink").clicked() { snarl.insert_node(pos, DemoNode::Sink); ui.close(); } } } }` `ui.close()` closes the popup after a click so the menu does not linger. `Snarl::insert_node(pos, node) -> NodeId` returns the new node’s id; you usually ignore it here, but if you want to _pre-wire_ the new node (as we do in the dropped-wire menu below), capture the id. > **Tip:** Right-click spawns the node where you clicked. If you would rather offset slightly so the new node does not sit under the cursor (and thus immediately get a hover popup), add a small delta: `snarl.insert_node(pos + egui::vec2(10.0, 10.0), node)`. For a larger graph with many node types, a flat list of buttons gets long. You can group them with `egui::CollapsingHeader` or split by category. A simple, readable approach is a small table of buttons: `#![allow(unused)] fn main() { fn show_graph_menu(&mut self, pos: egui::Pos2, ui: &mut egui::Ui, snarl: &mut Snarl) { ui.heading("Add Node"); ui.separator(); egui::Grid::new("add-node-grid").num_columns(2).show(ui, |ui| { if ui.button("Number").clicked() { snarl.insert_node(pos, DemoNode::Number(0.0)); ui.close(); } if ui.button("Text").clicked() { snarl.insert_node(pos, DemoNode::Text(String::new())); ui.close(); } if ui.button("Concat").clicked() { snarl.insert_node(pos, DemoNode::Concat); ui.close(); } if ui.button("Sink").clicked() { snarl.insert_node(pos, DemoNode::Sink); ui.close(); } }); } }` The `Grid` from [Chapter 4](https://danpokhrel.github.io/tutorial/ch04-layout-widgets.html) keeps the palette tidy. As your node count grows, factor the list into a `Vec<(name, constructor)>` and iterate — a closure that builds the node. The Rust Book’s [Chapter 13](https://doc.rust-lang.org/stable/book/ch13-01-closures.html) covers closures, which are the natural fit for “a thing that makes a `DemoNode`.” `show_node_menu` receives the node id of the right-clicked node. A minimal menu offers “Remove”: `#![allow(unused)] fn main() { fn has_node_menu(&mut self, _node: &DemoNode) -> bool { true } fn show_node_menu( &mut self, node_id: egui_snarl::NodeId, ui: &mut egui::Ui, snarl: &mut Snarl, ) { if ui.button("Remove node").clicked() { // remove_node returns the node data; wires are cleaned up automatically. let _removed = snarl.remove_node(node_id); ui.close(); } } }` `Snarl::remove_node(node_id) -> T` removes the node and returns its data by ownership (Rust Book [Chapter 4.1](https://doc.rust-lang.org/stable/book/ch04-01-what-is-ownership.html) ). Every wire that touched the node’s pins is dropped automatically — downstream inputs now have one fewer entry in `remotes`. As we noted in [Chapter 10](https://danpokhrel.github.io/tutorial/ch10-connections.html) , your `show_input` must handle the now-empty `remotes` gracefully (showing “—” or a fallback editor). > **Warning:** `NodeId`s are _recycled_. After `remove_node`, the same numeric id may be handed out to a new node later. Never store a `NodeId` long-term expecting it to keep referring to the same node; resolve ids to data fresh each frame. This is one of the nicest UX affordances in `egui-snarl`. When the user drags a wire from a pin and releases it on _empty canvas_ (not on another pin), `egui-snarl` offers a menu. You can use it to create a node and pre-wire it to the dragged pin in one gesture — the familiar “insert reroute node” of professional editors. The hook receives an `AnyPins` describing the source pins of the dropped wire. `AnyPins` is an enum with two variants: `#![allow(unused)] fn main() { pub enum AnyPins { Out(&[OutPinId]), // the user dragged from one or more output pins In(&[InPinId]), // the user dragged from one or more input pins } }` You match on it to decide which node types are compatible and how to wire the new node. For example, dragging from a `Number` output should offer to spawn a `Sink` (which accepts numbers) pre-connected: `#![allow(unused)] fn main() { use egui_snarl::{ui::{AnyPins, SnarlViewer}, InPinId, NodeId, OutPinId, Snarl}; impl SnarlViewer for DemoViewer { fn has_dropped_wire_menu(&mut self, _wires: &AnyPins, _snarl: &Snarl) -> bool { true } fn show_dropped_wire_menu( &mut self, pos: egui::Pos2, wires: &AnyPins, ui: &mut egui::Ui, snarl: &mut Snarl, ) { match wires { AnyPins::Out(pins) => { // We dragged from output pin(s). Offer nodes that take an input. if let Some(src) = pins.first() { // Read the source type BEFORE mutating, to decide which // buttons to show. Shared borrow ends at the end of the // expression; we copy the result out. let src_type = snarl[src.node].output_type(src.output); ui.label("Insert consumer"); if ui.button("Sink").clicked() { let new = snarl.insert_node(pos, DemoNode::Sink); snarl.connect(*src, InPinId { node: new, input: 0 }); ui.close(); } // Only offer a Text-consuming node if the source is Text. if src_type == Some(DataType::Text) && ui.button("Concat (as input A)").clicked() { let new = snarl.insert_node(pos, DemoNode::Concat); snarl.connect(*src, InPinId { node: new, input: 0 }); ui.close(); } } } AnyPins::In(pins) => { // We dragged from an input pin. Offer source nodes to feed it. if let Some(dst) = pins.first() { ui.label("Insert source"); if ui.button("Number").clicked() { let new = snarl.insert_node(pos, DemoNode::Number(0.0)); snarl.connect(OutPinId { node: new, output: 0 }, *dst); ui.close(); } if ui.button("Text").clicked() { let new = snarl.insert_node(pos, DemoNode::Text(String::new())); snarl.connect(OutPinId { node: new, output: 0 }, *dst); ui.close(); } } } } } } }` This is _context-aware_ node creation: the menu filters itself to compatible types based on the dropped wire. The `match` on `AnyPins` — an enum, as in the Rust Book’s [Chapter 6.1](https://doc.rust-lang.org/stable/book/ch06-01-defining-an-enum.html) — cleanly separates the two directions. Note we read the source type _before_ mutating, then use it to gate which buttons appear; once a button is clicked we mutate (insert + connect) and close the menu. > **Tip:** Because `connect` goes through your _own_ overridden `connect` (from [Chapter 10](https://danpokhrel.github.io/tutorial/ch10-connections.html) > ), the pre-wired connection is automatically type-validated. If you offer a button for an incompatible type by mistake, the `connect` will simply return `false` and no wire appears — a free safety net. `egui-snarl` tracks which nodes are selected (click a node to select it; Shift-click or drag a box to multi-select). The selected ids are queryable via `egui_snarl::ui::get_selected_nodes(id, ctx)`, which returns a `Vec`: `#![allow(unused)] fn main() { use egui_snarl::ui; use eframe::egui; impl eframe::App for SnarlApp { fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { egui::Panel::right("selection-panel").show_inside(ui, |ui| { ui.heading("Selection"); let selected = ui::get_selected_nodes(egui::Id::new("demo-snarl"), ui.ctx()); if selected.is_empty() { ui.label("(nothing selected)"); } else { for id in &selected { let node = &self.snarl[*id]; ui.label(format!("Node {}: {}", id.0, node.title_str())); } } }); egui::CentralPanel::default().show_inside(ui, |ui| { egui_snarl::SnarlWidget::new() .id_salt(egui::Id::new("demo-snarl")) .show(&mut self.snarl, &mut self.viewer, ui); }); } } impl DemoNode { fn title_str(&self) -> &'static str { match self { DemoNode::Number(_) => "Number", DemoNode::Text(_) => "Text", DemoNode::Concat => "Concat", DemoNode::Sink => "Sink", } } } }` The `Id` passed to `get_selected_nodes` must match the one you gave `SnarlWidget::new().id(...)`. The returned `Vec` is a snapshot for this frame — you can read from it freely. Displaying the current selection in a side panel is a small touch that makes a big difference to usability, and it sets up the “properties” panel pattern from [Chapter 5](https://danpokhrel.github.io/tutorial/ch05-architecture.html) . > **Note:** `get_selected_nodes` borrows the `Snarl`’s selection state from the `Context`, so it is cheap — no traversal of the graph. Call it every frame; do not cache it across frames. `has_on_hover_popup` / `show_on_hover_popup` fire when the pointer lingers over a node. They are perfect for showing a description of what a node does, without cluttering the node itself: `#![allow(unused)] fn main() { impl SnarlViewer for DemoViewer { fn has_on_hover_popup(&mut self, _node: &DemoNode) -> bool { true } fn show_on_hover_popup( &mut self, node_id: egui_snarl::NodeId, ui: &mut egui::Ui, snarl: &mut Snarl, ) { let node = &snarl[node_id]; let desc = match node { DemoNode::Number(_) => "A constant number. Drag the value to edit it.", DemoNode::Text(_) => "A constant string. Type to edit it.", DemoNode::Concat => "Joins two text inputs into one output.", DemoNode::Sink => "Displays its connected input value.", }; ui.label(desc); } } }` Keep popups short — they are not modal, they just appear and disappear with the hover. This is the same hover-tooltip idea as `Response::on_hover_text` from [Chapter 7](https://danpokhrel.github.io/tutorial/ch07-input-menus.html) , but here it is a full `Ui` you can build into, so you can show multi-line rich text or even a small live preview. `egui-snarl` does not bind keyboard shortcuts by default — you do, via the `egui::Context` input we covered in [Chapter 7](https://danpokhrel.github.io/tutorial/ch07-input-menus.html) . The most useful one is Delete to remove the currently selected nodes. The cleanest place for this is at the top of your `ui` method, before you build any widgets. The `egui::Ui` gives you its `Context` via `ui.ctx()`, so you can poll input there and mutate the `Snarl` immediately: `#![allow(unused)] fn main() { impl eframe::App for SnarlApp { fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { // --- Keyboard shortcuts: run before rendering the widget. --- let ctx = ui.ctx().clone(); let delete_pressed = ctx.input(|i| i.key_pressed(egui::Key::Delete)); if delete_pressed { // Snapshot the selection first: remove_node recycles ids, and we // never want to iterate live selection state while mutating. let selected: Vec<_> = egui_snarl::ui::get_selected_nodes(egui::Id::new("demo-snarl"), &ctx); for id in selected { self.snarl.remove_node(id); } } // --- Side panel showing the current selection. --- egui::Panel::right("sel").show_inside(ui, |ui| { ui.heading("Selection"); let selected = egui_snarl::ui::get_selected_nodes( egui::Id::new("demo-snarl"), ui.ctx(), ); if selected.is_empty() { ui.label("(none)"); } for id in &selected { ui.label(format!("Node {}", id.0)); } }); // --- The graph itself. --- egui::CentralPanel::default().show_inside(ui, |ui| { egui_snarl::SnarlWidget::new() .id_salt(egui::Id::new("demo-snarl")) .show(&mut self.snarl, &mut self.viewer, ui); }); } } }` Notice the architecture from [Chapter 5](https://danpokhrel.github.io/tutorial/ch05-architecture.html) at work: the keyboard handling runs at the top of the frame and mutates state (removes nodes), then the widget-building code below it reads the now-updated state. We clone the `Context` (`ui.ctx().clone()`) so the input closure and the subsequent `get_selected_nodes` call can both borrow it without fighting the `&mut self` borrow of `ui` — cloning an `egui::Context` is cheap, it is just an `Arc` handle. We collect the selected ids into a `Vec` _before_ the removal loop because removing nodes can recycle ids — operating on a snapshot is safe. > **Warning:** Do not iterate `selected` while calling `remove_node` if you obtained `selected` as a borrow of live selection state. Always capture into an owned `Vec` first. The borrow rules of [Chapter 4.2](https://doc.rust-lang.org/stable/book/ch04-02-references-and-borrowing.html) > protect you here, but a snapshot makes the intent obvious. Bringing it together, here is a viewer that offers the graph menu palette, the node remove menu, the dropped-wire menu, hover popups, and the `ui`\-method delete shortcut — all in one place. We omit the methods we already showed in full in [Chapter 9](https://danpokhrel.github.io/tutorial/ch09-nodes-pins.html) and [Chapter 10](https://danpokhrel.github.io/tutorial/ch10-connections.html) for brevity: `#![allow(unused)] fn main() { use eframe::egui; use egui_snarl::{ ui::{AnyPins, get_selected_nodes, PinInfo, SnarlViewer}, InPin, InPinId, NodeId, OutPin, OutPinId, Snarl, }; #[derive(Default)] pub struct DemoViewer; impl SnarlViewer for DemoViewer { (/* title, inputs, outputs, show_input, show_output, connect, disconnect as in chapters 8–10 */) fn has_graph_menu(&mut self, _: egui::Pos2, _: &Snarl) -> bool { true } fn show_graph_menu(&mut self, pos: egui::Pos2, ui: &mut egui::Ui, snarl: &mut Snarl) { ui.heading("Add Node"); ui.separator(); for (label, node) in [ ("Number", DemoNode::Number(0.0)), ("Text", DemoNode::Text(String::new())), ("Concat", DemoNode::Concat), ("Sink", DemoNode::Sink), ] { if ui.button(label).clicked() { snarl.insert_node(pos, node); ui.close(); } } } fn has_node_menu(&mut self, _: &DemoNode) -> bool { true } fn show_node_menu(&mut self, id: NodeId, ui: &mut egui::Ui, snarl: &mut Snarl) { ui.label(format!("Node {}", id.0)); ui.separator(); if ui.button("Remove").clicked() { snarl.remove_node(id); ui.close(); } } fn has_dropped_wire_menu(&mut self, _: &AnyPins, _: &Snarl) -> bool { true } fn show_dropped_wire_menu( &mut self, pos: egui::Pos2, wires: &AnyPins, ui: &mut egui::Ui, snarl: &mut Snarl, ) { match wires { AnyPins::Out(pins) => { if let Some(src) = pins.first() { if ui.button("New Sink (connected)").clicked() { let n = snarl.insert_node(pos, DemoNode::Sink); snarl.connect(*src, InPinId { node: n, input: 0 }); ui.close(); } } } AnyPins::In(pins) => { if let Some(dst) = pins.first() { if ui.button("New Number (connected)").clicked() { let n = snarl.insert_node(pos, DemoNode::Number(0.0)); snarl.connect(OutPinId { node: n, output: 0 }, *dst); ui.close(); } } } } } fn has_on_hover_popup(&mut self, _: &DemoNode) -> bool { true } fn show_on_hover_popup(&mut self, id: NodeId, ui: &mut egui::Ui, snarl: &mut Snarl) { let node = &snarl[id]; let desc = match node { DemoNode::Number(_) => "A constant number.", DemoNode::Text(_) => "A constant string.", DemoNode::Concat => "Joins two text inputs.", DemoNode::Sink => "Displays its input.", }; ui.label(desc); } } pub struct SnarlApp { snarl: Snarl, viewer: DemoViewer, } impl eframe::App for SnarlApp { fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { // Delete-key shortcut: remove selected nodes. let ctx = ui.ctx().clone(); if ctx.input(|i| i.key_pressed(egui::Key::Delete)) { let selected = get_selected_nodes(egui::Id::new("demo-snarl"), &ctx); for id in selected { self.snarl.remove_node(id); } } egui::Panel::right("sel").show_inside(ui, |ui| { ui.heading("Selection"); let selected = get_selected_nodes(egui::Id::new("demo-snarl"), ui.ctx()); if selected.is_empty() { ui.label("(none)"); } for id in &selected { ui.label(format!("Node {}", id.0)); } }); egui::CentralPanel::default().show_inside(ui, |ui| { egui_snarl::SnarlWidget::new() .id_salt(egui::Id::new("demo-snarl")) .show(&mut self.snarl, &mut self.viewer, ui); }); } } }` With this in place, the editor is fully interactive: right-click the background to add nodes, right-click a node to remove it, drag a wire to empty space to spawn a pre-connected node, hover a node for a description, and press Delete to remove the current selection. Everything in this part — the panels of [Chapter 4](https://danpokhrel.github.io/tutorial/ch04-layout-widgets.html) , the menus and modal of [Chapter 7](https://danpokhrel.github.io/tutorial/ch07-input-menus.html) , and the snarl viewer of [Chapters 8–11](https://danpokhrel.github.io/tutorial/ch09-nodes-pins.html) — comes together in one `App`. The reference implementation’s `ui/app.rs` is exactly that assembly, so it is worth seeing whole. It owns the `Snarl` and the `DemoViewer`, plus three ephemeral flags (`settings_open`, `quit_modal_open`, `status`); it renders a top menu bar, a left sidebar of live graph stats, a bottom status bar, and the central snarl canvas, then layers a settings window and a quit-confirmation modal on top. The `App::new` wires the theme from [Chapter 6](https://danpokhrel.github.io/tutorial/ch06-theming.html) and the image loaders, and seeds the graph from a small, headlessly-testable helper: ``#![allow(unused)] fn main() { // src/ui/app.rs use crate::theme::EditorTheme; use crate::ui::viewer::{DemoNode, DemoViewer}; use eframe::egui; use egui_snarl::{InPinId, OutPinId, Snarl, ui::SnarlWidget}; pub struct App { snarl: Snarl, viewer: DemoViewer, settings_open: bool, quit_modal_open: bool, status: String, } /// The platform quit shortcut: Cmd+Q on macOS, Ctrl+Q elsewhere. fn quit_shortcut() -> egui::KeyboardShortcut { egui::KeyboardShortcut::new(egui::Modifiers::COMMAND, egui::Key::Q) } /// Build the four-node demo graph the app starts with. /// Extracted from `App::new` so it can be unit-tested headless — /// `Snarl` is pure data and needs neither a GPU nor an egui `Context`. fn seed_demo_graph() -> Snarl { let mut snarl = Snarl::new(); let n = snarl.insert_node(egui::pos2(-280.0, -40.0), DemoNode::Number(42.0)); let t = snarl.insert_node(egui::pos2(-280.0, 60.0), DemoNode::Text("hello".into())); let c = snarl.insert_node(egui::pos2(-40.0, 0.0), DemoNode::Concat); let s = snarl.insert_node(egui::pos2(220.0, 0.0), DemoNode::Sink); // NB: keep the `connect` calls OUTSIDE `debug_assert!` — its argument is // not evaluated in release builds, which would silently drop the wires. let wired = snarl.connect( OutPinId { node: n, output: 0 }, InPinId { node: c, input: 0 }, ) && snarl.connect( OutPinId { node: t, output: 0 }, InPinId { node: c, input: 1 }, ) && snarl.connect( OutPinId { node: c, output: 0 }, InPinId { node: s, input: 0 }, ); debug_assert!(wired, "seed graph connections must not be duplicates"); snarl } impl App { pub fn new( cc: &eframe::CreationContext<'_>, ) -> Result> { EditorTheme::dark().apply(&cc.egui_ctx); egui_extras::install_image_loaders(&cc.egui_ctx); Ok(Self { snarl: seed_demo_graph(), viewer: DemoViewer, settings_open: false, quit_modal_open: false, status: "Ready".into(), }) } } }`` `logic()` handles the quit shortcut and — importantly — intercepts the window’s native close button so it routes through the same confirmation modal instead of quitting immediately: `#![allow(unused)] fn main() { impl eframe::App for App { fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { ctx.input_mut(|i| { if i.consume_shortcut(&quit_shortcut()) { self.quit_modal_open = true; } }); // The window's native close button must not bypass the confirmation // modal: cancel the close and route it through the same modal. If the // modal is already open, the request is allowed through (the user is // insisting). if ctx.input(|i| i.viewport().close_requested()) && !self.quit_modal_open { ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); self.quit_modal_open = true; } } // ui() shown below… } }` `ui()` lays the panels out in the order [Chapter 4](https://danpokhrel.github.io/tutorial/ch04-layout-widgets.html) prescribes — top, left, bottom, then central last — and uses egui 0.35’s built-in `Modal` for the quit confirmation (no hand-rolled dimmer needed, unlike the [Chapter 7](https://danpokhrel.github.io/tutorial/ch07-input-menus.html) example): `#![allow(unused)] fn main() { fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { // 1) Top menu bar FIRST. egui::Panel::top("menu_bar").show(ui, |ui| { egui::MenuBar::new().ui(ui, |ui| { ui.menu_button("File", |ui| { if ui.button("Reset graph").clicked() { self.snarl = Snarl::new(); self.status = "Graph cleared".into(); ui.close(); } ui.separator(); let quit_btn = egui::Button::new("Quit") .shortcut_text(ui.ctx().format_shortcut(&quit_shortcut())); if ui.add(quit_btn).clicked() { self.quit_modal_open = true; ui.close(); } }); ui.menu_button("View", |ui| { ui.checkbox(&mut self.settings_open, "Settings"); }); }); }); // 2) Left sidebar NEXT. egui::Panel::left("sidebar") .resizable(true) .default_size(220.0) .min_size(160.0) .max_size(400.0) .show(ui, |ui| { ui.heading("Graph"); ui.separator(); ui.label(format!("Nodes: {}", self.snarl.nodes().count())); ui.label(format!("Wires: {}", self.snarl.wires().count())); ui.separator(); ui.label("Drag node headers to move."); ui.label("Drag background to pan."); ui.label("Ctrl+scroll to zoom."); ui.label("Drag from a pin to wire."); }); // 3) Bottom status bar: the last event message plus live graph stats. egui::Panel::bottom("status_bar") .default_size(24.0) .resizable(false) .show(ui, |ui| { ui.horizontal(|ui| { ui.label(format!( "{} · {} nodes · {} wires", self.status, self.snarl.nodes().count(), self.snarl.wires().count() )); }); }); // 4) Central panel LAST — the node graph canvas. egui::CentralPanel::default().show(ui, |ui| { SnarlWidget::new() .id_salt(egui::Id::new("demo-snarl")) .show(&mut self.snarl, &mut self.viewer, ui); }); // --- Settings floating window --- if self.settings_open { egui::Window::new("Settings") .open(&mut self.settings_open) .default_pos([140.0, 140.0]) .resizable(true) .show(ui, |ui| { ui.label("Theme: dark (custom)"); ui.label(format!("Status: {}", self.status)); }); } // --- Quit confirmation modal (using egui 0.35's built-in Modal) --- if self.quit_modal_open { let resp = egui::Modal::new(egui::Id::new("quit_modal")).show(ui.ctx(), |ui| { ui.set_min_width(280.0); ui.heading("Quit"); ui.label("Quit the application?"); ui.add_space(8.0); ui.horizontal(|ui| { let cancel = ui.button("Cancel").clicked() || ui.input(|i| i.key_pressed(egui::Key::Escape)); if cancel { self.quit_modal_open = false; } let quit = ui.button("Quit").clicked() || ui.input(|i| i.key_pressed(egui::Key::Enter)); if quit { ui.ctx().send_viewport_cmd(egui::ViewportCommand::Close); } }); }); if resp.backdrop_response.clicked() { self.quit_modal_open = false; } } } } }` A couple of details worth calling out. The `App::new` signature takes `&eframe::CreationContext<'_>` and returns `Result>` — the same `Result`\-returning `AppCreator` pattern from [Chapter 3](https://danpokhrel.github.io/tutorial/ch03-first-window.html) , so setup failures propagate cleanly. The seed graph is built in a standalone `seed_demo_graph()` function (not inline in `new`) precisely so it can be unit-tested without a `Context`: `#![allow(unused)] fn main() { #[cfg(test)] mod tests { use super::*; #[test] fn seed_graph_has_four_nodes_and_three_wires() { let snarl = seed_demo_graph(); assert_eq!(snarl.nodes().count(), 4); assert_eq!(snarl.wires().count(), 3); } } }` > **Scope note.** This assembled `App` plus the `DemoViewer` from [Chapter 9](https://danpokhrel.github.io/tutorial/ch09-nodes-pins.html) > and [Chapter 11](https://danpokhrel.github.io/tutorial/ch11-interactions.html) > is the entirety of the reference implementation’s interactive editor — it deliberately omits the type-validated `connect` ([Chapter 10](https://danpokhrel.github.io/tutorial/ch10-connections.html) > ), the dropped-wire/hover/selection features shown earlier in this chapter, and the custom `SnarlStyle`/`header_frame` of [Chapter 12](https://danpokhrel.github.io/tutorial/ch12-styling-graph.html) > . Those are taught as optional enhancements; the shipped demo stays lean. The agent-flow evaluation, streaming, and persistence of Part 4 are _not_ in the reference implementation at all — see the introduction’s note on scope. In this chapter we wired up the full interaction surface of `egui-snarl`. We built an “Add Node” palette in `show_graph_menu`, a remove action in `show_node_menu`, a context-aware dropped-wire menu that pre-connects new nodes, hover popups, and a side panel showing the current selection. We put the Delete keyboard shortcut at the top of the `ui` method, using the input API from [Chapter 7](https://danpokhrel.github.io/tutorial/ch07-input-menus.html) and the collect-then-mutate pattern from [Chapter 5](https://danpokhrel.github.io/tutorial/ch05-architecture.html) . The graph is now fully usable. It is, however, still using the default visual style. In [Chapter 12](https://danpokhrel.github.io/tutorial/ch12-styling-graph.html) we will build a custom dark theme with color-coded pins, per-node-type colored headers, and a grid background — turning this functional editor into a polished one. --- # Chapter 13: Defining Agent Nodes - Building a Node-Based AI Flow Editor with eframe & egui Keyboard shortcuts ------------------ Press ← or → to navigate between chapters Press S or / to search in the book Press ? to show this help Press Esc to hide this help * Auto * Light * Rust * Coal * Navy * Ayu Building a Node-Based AI Flow Editor with eframe & egui ======================================================= [](https://danpokhrel.github.io/tutorial/print.html "Print this book") So far our node graph editor can hold nodes, draw pins, and connect wires — but the nodes themselves are empty placeholders. In this chapter we give them purpose. We’ll design a node type system for an _agentic AI flow builder_, the kind of visual tool made popular by projects like Langflow and Flowise, where you compose large language models (LLMs), prompt templates, tools, and memory into runnable pipelines by dragging boxes and drawing connections. By the end of the chapter you’ll have an `AgentNode` enum with a variant for each agent component, a `SnarlViewer` implementation that renders each variant’s pins and configuration UI, and a color-coded pin system. This chapter assumes you’ve worked through [Chapter 8](https://danpokhrel.github.io/tutorial/ch08-egui-snarl.html) through [Chapter 12](https://danpokhrel.github.io/tutorial/ch12-styling-graph.html) , where we built and styled the generic snarl-based graph shell. > **Scope note: not in the reference implementation.** The companion `tutorial-implementation` crate **does not implement `AgentNode`** — it stops at the `DemoNode` editor (Number / Text / Concat / Sink) from Part 3. This chapter and the rest of Part 4 (evaluation, live streaming, persistence) are forward-looking material: they teach the patterns you would add to turn the demo editor into a real agentic flow builder, and the code is accurate egui-snarl / Rust, but it is not mirrored in the implementation. If you are following along against the reference impl, treat Part 4 as a design exercise layered on top of your `DemoNode` editor rather than code you will find in the crate. Tools like Langflow and Flowise let you build LLM applications without writing glue code. You drop a _chat input_ node, wire it into a _prompt template_, feed that into an _LLM node_, optionally branch through _tools_ and _memory_, and terminate at an _output_ node. The graph is both a visual artifact (you can read the pipeline at a glance) and an executable program (the host evaluates it when the user clicks _Run_). Our goal for the rest of Part 4 is to build exactly that, in Rust, with eframe and egui-snarl. We won’t call real LLM APIs in the tutorial — we’ll _simulate_ them so the code runs anywhere without API keys — but the architecture will be honest: every node knows its inputs, its configuration, and how to produce an output, and the evaluation engine we build in [Chapter 14](https://danpokhrel.github.io/tutorial/ch14-graph-evaluation.html) will treat them uniformly. A minimal agentic flow might look like this: `[StringNode: "You are a helpful assistant"] ──┐ ├─→ [LLMNode] ─→ [OutputNode] [ChatInput: "What is 2+2?"] ───────┘` The `LLMNode` takes two string inputs — a _system prompt_ and a _user message_ — and produces a response string. More elaborate flows add a `PromptTemplate` to interpolate variables, a `ToolNode` to simulate a web search or calculator, and a `MemoryNode` to carry conversation history between turns. Every wire in these graphs carries a string, which keeps the type system simple while we learn the patterns; we’ll discuss adding real type checking at the end of this chapter. In egui-snarl, a `Snarl` holds one `T` per node, and a `SnarlViewer` decides how each `T` is rendered. The natural Rust design for “many kinds of node that share a common interface” is an **enum** — one variant per node kind. Each variant carries its own configuration fields: a model name for `LLMNode`, a template string for `PromptTemplate`, a temperature slider value, and so on. Why an enum and not a trait object (`Box`)? Two reasons. First, egui-snarl requires the node type to be `Clone + Send + Sync`, and enums of plain data trivially satisfy that. Second, enums give us exhaustive pattern matching, which we’ll lean on heavily when rendering pins and when evaluating the graph in [Chapter 14](https://danpokhrel.github.io/tutorial/ch14-graph-evaluation.html) . This is the same “modeling state with enums” idea the Rust Book emphasizes in [Chapter 6](https://doc.rust-lang.org/stable/book/ch06-01-defining-an-enum.html) and [Chapter 18](https://doc.rust-lang.org/stable/book/ch18-03-pattern-matching.html) — when the set of variants is closed and known, an enum is clearer than a trait hierarchy. We’ll define six node types, enough to express non-trivial agent flows: * **`ChatInput`** — a starting node with an editable user message. One output: a string. * **`StringNode`** — a constant string source, for system prompts and other fixed text. No inputs; one output. * **`PromptTemplate`** — formats a template string, substituting connected inputs in order. One input: string; one output: string. * **`LLMNode`** — represents an LLM call. Inputs: system prompt (string), user message (string); output: response (string). Configured with a model name and temperature. * **`ToolNode`** — simulates a tool call (web search, calculator). Input: query string; output: result string. Configured with a tool kind. * **`MemoryNode`** — stores conversation history. Input: a string; output: a string that includes accumulated context. * **`OutputNode`** — displays the final result. One input: string; no outputs. Every wire carries a string, so `StringNode`, `ChatInput`, and `PromptTemplate`’s output can all feed `LLMNode`’s inputs interchangeably. Let’s write the enum. We derive `Clone`, `Debug`, `serde::Serialize`, and `serde::Deserialize` — the serde derives are essential for the persistence work in [Chapter 16](https://danpokhrel.github.io/tutorial/ch16-persistence.html) , where `Snarl` will serialize straight to JSON. We also give each variant the configuration fields it needs: ``#![allow(unused)] fn main() { use serde::{Deserialize, Serialize}; /// A single node in an agentic AI flow graph. /// /// Every variant is pure data: no `egui` types here, so the enum /// can be unit-tested on a headless CI machine (see Chapter 11 and /// Chapter 17) and serialized to disk (Chapter 16). #[derive(Clone, Debug, Serialize, Deserialize)] pub enum AgentNode { /// User-typed message that starts a flow. ChatInput { message: String, }, /// A constant string, useful for system prompts. StringNode { value: String, }, /// Formats a template, substituting `{0}`, `{1}`, ... with inputs. PromptTemplate { template: String, }, /// An LLM call. Combines a system prompt and user message. LLMNode { model: String, temperature: f32, system_prompt: String, }, /// A simulated tool call (web search, calculator, etc.). ToolNode { tool: ToolKind, }, /// Accumulates conversation history and returns it as context. MemoryNode { history: Vec, }, /// Terminal node that displays the final result. OutputNode, } /// Which simulated tool a `ToolNode` represents. #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] pub enum ToolKind { WebSearch, Calculator, Weather, } impl Default for AgentNode { fn default() -> Self { // A reasonable starting node when one is created without arguments. AgentNode::StringNode { value: String::new(), } } } }`` > **Tip:** Notice we put `history: Vec` _inside_ the `MemoryNode` variant. Node-internal state like this serializes alongside the node and survives reloads. If a node needs state that is large, ephemeral, or non-serializable (a live HTTP connection, a thread handle), keep that in a side `HashMap` on the `App` instead — see the discussion of background-task state in [Chapter 5](https://danpokhrel.github.io/tutorial/ch05-architecture.html) > . We can also add convenience constructors so the UI (and our tests) can spawn nodes succinctly: `#![allow(unused)] fn main() { impl AgentNode { pub fn chat_input() -> Self { AgentNode::ChatInput { message: String::new(), } } pub fn llm() -> Self { AgentNode::LLMNode { model: "gpt-4o-mini".to_string(), temperature: 0.7, system_prompt: "You are a helpful assistant.".to_string(), } } pub fn output() -> Self { AgentNode::OutputNode } } }` The `SnarlViewer` trait has three jobs that matter to us here: decide a node’s **title**, list its **input pins**, and list its **output pins**. (We’ll handle interaction callbacks like `connect` and the in-node configuration UI in a moment.) Each method receives the `Snarl` and the node’s `NodeId`, so it can inspect the node’s data via `snarl[id]`. The number and order of pins a variant exposes is fixed and must match what the evaluator in [Chapter 14](https://danpokhrel.github.io/tutorial/ch14-graph-evaluation.html) expects, so we’ll be careful to keep them consistent. `#![allow(unused)] fn main() { use eframe::egui; use egui_snarl::{ui::{PinInfo, SnarlPin, SnarlViewer}, InPin, OutPin, Snarl}; pub struct AgentViewer; impl SnarlViewer for AgentViewer { fn title(&mut self, node: &AgentNode) -> String { match node { AgentNode::ChatInput { .. } => "Chat Input".into(), AgentNode::StringNode { .. } => "String".into(), AgentNode::PromptTemplate { .. } => "Prompt Template".into(), AgentNode::LLMNode { model, .. } => format!("LLM ({model})"), AgentNode::ToolNode { tool } => format!("Tool: {:?}", tool), AgentNode::MemoryNode { .. } => "Memory".into(), AgentNode::OutputNode => "Output".into(), } } fn inputs(&mut self, node: &AgentNode) -> usize { match node { AgentNode::ChatInput { .. } | AgentNode::StringNode { .. } | AgentNode::OutputNode => 0, AgentNode::PromptTemplate { .. } | AgentNode::ToolNode { .. } | AgentNode::MemoryNode { .. } => 1, // system prompt + user message AgentNode::LLMNode { .. } => 2, } } fn outputs(&mut self, node: &AgentNode) -> usize { match node { AgentNode::OutputNode => 0, _ => 1, } } } }` > **Important: the real `SnarlViewer` signatures.** The `title`, `inputs`, and `outputs` methods take only `&mut self` and `&T` (the node data) — they do **not** receive the `Snarl` or a `NodeId`. The `show_input`/`show_output` methods receive a `pin: &InPin` or `&OutPin` (which bundles the node id and the `remotes` list), a `&mut Ui`, and a `&mut Snarl`. The `connect` method takes `(&OutPin, &InPin, &mut Snarl)` and returns `()` (not `bool`). These signatures match the egui-snarl 0.11 trait exactly. If you see tutorials showing different signatures (with `&Snarl`, `NodeId`, and `usize` pin indices), they are outdated. `SnarlViewer::show_input` and `show_output` return a `PinInfo` describing how a pin is drawn. We use `PinInfo::circle().with_fill(color)` to render a small dot, and we color-code by the _kind_ of value the pin carries. Since all our pins currently carry strings, we use one color for string pins — green — and reserve other colors for future pin types so the system is visually extensible: ``#![allow(unused)] fn main() { /// Pin color for a string-carrying pin. const STRING_PIN: egui::Color32 = egui::Color32::from_rgb(80, 200, 120); /// Pin color for a control-flow pin (reserved for future use). const CONTROL_PIN: egui::Color32 = egui::Color32::from_rgb(220, 160, 60); impl SnarlViewer for AgentViewer { fn show_input( &mut self, _pin: &InPin, _ui: &mut egui::Ui, _snarl: &mut Snarl, ) -> impl SnarlPin + 'static { // All current inputs are strings. Later, a match on `_pin.id` // can return CONTROL_PIN for non-string pins. PinInfo::circle().with_fill(STRING_PIN) } fn show_output( &mut self, _pin: &OutPin, _ui: &mut egui::Ui, _snarl: &mut Snarl, ) -> impl SnarlPin + 'static { PinInfo::circle().with_fill(STRING_PIN) } } }`` > **Note:** We keep a `CONTROL_PIN` constant even though nothing uses it yet. When you later add typed pins (an image output, a structured-data output), you’ll branch on the node variant and pin index here, returning a distinct color per type — exactly the way the bundled egui-snarl demo distinguishes its pin kinds. Defining the colors up front keeps the visual language stable as the type system grows. A node is more than its pins — it needs editable configuration: the user must be able to type a prompt, pick a model, and drag a temperature slider without leaving the node. egui-snarl gives us two hooks for drawing arbitrary UI inside a node body: `show_input` and `show_output`, called when rendering each pin’s row. We already used them above to return `PinInfo`; the same methods can also draw widgets into the `&mut egui::Ui` they receive before returning the pin info. The trick is to match on the node variant and the pin index, draw the appropriate editor, and mutate the node. Because the method signature hands us `&AgentNode` (a shared reference), we need to collect the _intent_ to edit and apply it afterward — the same collect-then-mutate discipline from [Chapter 5](https://danpokhrel.github.io/tutorial/ch05-architecture.html) . In practice, egui-snarl’s `show_input`/`show_output` are called on a borrowed node, so we’ll read the current value, let the user edit a local copy, and stash the edited value in a pending-edits map owned by the viewer. For brevity in this chapter, we show the inline mutation pattern you’d use when the viewer holds `&mut` to the node through other hooks; the full collect-then-mutate version is left as an exercise and revisited in [Chapter 14](https://danpokhrel.github.io/tutorial/ch14-graph-evaluation.html) . Here is a partial `SnarlViewer` implementation covering `ChatInput`, `LLMNode`, and `OutputNode`, with configuration UI for the first two: ``#![allow(unused)] fn main() { use eframe::egui; use egui_snarl::{ui::{PinInfo, SnarlPin, SnarlViewer}, InPin, NodeId, OutPin, Snarl}; pub struct AgentViewer { /// Buffered text edits captured while rendering node bodies, applied /// after `show_*` returns so we never hold `&mut` to the node mid-render. pub pending_edits: std::collections::HashMap, } impl SnarlViewer for AgentViewer { fn title(&mut self, node: &AgentNode) -> String { match node { AgentNode::ChatInput { .. } => "Chat Input".into(), AgentNode::LLMNode { model, .. } => format!("LLM ({model})"), AgentNode::OutputNode => "Output".into(), _ => "Node".into(), } } fn inputs(&mut self, node: &AgentNode) -> usize { match node { AgentNode::ChatInput { .. } => 0, AgentNode::LLMNode { .. } => 2, // system prompt, user message AgentNode::OutputNode => 1, _ => 0, } } fn outputs(&mut self, node: &AgentNode) -> usize { match node { AgentNode::OutputNode => 0, _ => 1, } } fn show_input( &mut self, pin: &InPin, ui: &mut egui::Ui, snarl: &mut Snarl, ) -> impl SnarlPin + 'static { let id = pin.id.node; let input = pin.id.input; let node = snarl[id].clone(); match &node { AgentNode::LLMNode { system_prompt, .. } if input == 0 => { // System prompt editor on the first input row. let mut buf = system_prompt.clone(); ui.text_edit_multiline(&mut buf); if buf != *system_prompt { let mut edited = node.clone(); if let AgentNode::LLMNode { system_prompt, .. } = &mut edited { *system_prompt = buf; } self.pending_edits.insert(id, edited); } } AgentNode::OutputNode => { // Read-only: the output node just shows its received value. ui.label("(final result)"); } _ => {} } PinInfo::circle().with_fill(egui::Color32::from_rgb(80, 200, 120)) } fn show_output( &mut self, pin: &OutPin, ui: &mut egui::Ui, snarl: &mut Snarl, ) -> impl SnarlPin + 'static { let id = pin.id.node; let node = snarl[id].clone(); match &node { AgentNode::ChatInput { message } => { let mut buf = message.clone(); ui.text_edit_singleline(&mut buf); ui.add_space(2.0); if buf != *message { let mut edited = node.clone(); if let AgentNode::ChatInput { message } = &mut edited { *message = buf; } self.pending_edits.insert(id, edited); } } AgentNode::LLMNode { model, temperature, .. } => { // Model selector + temperature slider live in the *output* // row so the node body reads top-to-bottom. let mut temp = *temperature; let mut model_buf = model.clone(); ui.horizontal(|ui| { ui.label("Model"); egui::ComboBox::from_id_salt("llm_model") .selected_text(model_buf.as_str()) .show_ui(ui, |ui| { for m in ["gpt-4o-mini", "gpt-4o", "claude-3.5-sonnet"] { ui.selectable_value(&mut model_buf, m.to_string(), m); } }); }); ui.add( egui::Slider::new(&mut temp, 0.0..=2.0) .text("temp") .fixed_decimals(2), ); if temp != *temperature || model_buf != *model { let mut edited = node.clone(); if let AgentNode::LLMNode { model, temperature, .. } = &mut edited { *model = model_buf; *temperature = temp; } self.pending_edits.insert(id, edited); } } _ => {} } PinInfo::circle().with_fill(egui::Color32::from_rgb(80, 200, 120)) } } }`` > **Warning:** The model-selection snippet above uses `selectable_value` with a throwaway `String::from(m)` as the target, which won’t actually persist the edit — it’s illustrative only. In real code, buffer the selection in a local `&mut String` bound (mirroring the `ChatInput` pattern) and route the change through `pending_edits`. The point of the example is the _layout_: a `ComboBox` and a `Slider` rendered inside a node body via the `show_output` hook. After the snarl renders, the host must drain `pending_edits` and write each updated node back into the `Snarl`. The pattern, which we’ll reuse throughout Part 4, looks like: `#![allow(unused)] fn main() { // In your App::ui or a helper, after showing the snarl: let pending = std::mem::take(&mut viewer.pending_edits); for (id, node) in pending { snarl[id] = node; } }` This is the same collect-then-mutate discipline that keeps the borrow checker happy when a single frame touches many widgets — see [Chapter 5](https://danpokhrel.github.io/tutorial/ch05-architecture.html) for the full rationale. When the user draws a wire between an output pin and an input pin, egui-snarl calls `SnarlViewer::connect`, which by default just records the connection in the snarl’s wire list. We can override it to _validate_ the connection — refuse, for instance, to wire an output into another output. For now, every pin carries a string, so any output can connect to any input. That makes the graph maximally flexible, which is nice while learning. We can still add a guard for the obvious structural rule — never connect two inputs or two outputs together — and reserve the hook for richer type checking later: `#![allow(unused)] fn main() { use egui_snarl::{ui::SnarlViewer, InPinId, OutPinId, InPin, OutPin, Snarl}; impl SnarlViewer for AgentViewer { fn connect( &mut self, from: &OutPin, to: &InPin, snarl: &mut Snarl, ) { // egui-snarl only calls this for valid output→input pairs, so the // structural rule is enforced by the framework. We accept all // connections because every pin is a string. // To veto a connection, simply do NOT call snarl.connect here. snarl.connect(from.id, to.id); } } }` > **Note:** To veto a connection, simply do not call `snarl.connect(...)` inside your override — the wire will not be created. When you later introduce a type system (string vs. image vs. structured data pins), this is where you’d inspect the source and target node variants and skip the `snarl.connect` call for mismatched types — for example, refusing to wire an `LLMNode` output into a `ToolNode` field that expects a structured query. For now we accept everything. A clean way to add type checking without a big refactor is to give each pin a `PinType` (an enum: `String`, `Image`, `Json`, …), expose it via a helper on `AgentNode`, and check matching in `connect`. Because our evaluation engine (next chapter) already gathers inputs by pin index, adding a type field doesn’t disturb the evaluator as long as two pins of the same type stay compatible. We’ll leave this as an exercise; the architecture we’re building won’t fight you when you add it. We now have an `AgentNode` enum, a viewer with titles and pins, and configuration editors for three variants. To display it, we hand the snarl and viewer to a `SnarlWidget`, exactly as in [Chapter 9](https://danpokhrel.github.io/tutorial/ch09-nodes-pins.html) : `#![allow(unused)] fn main() { use eframe::egui; use egui_snarl::{ui::SnarlWidget, Snarl}; pub struct App { pub snarl: Snarl, pub viewer: AgentViewer, } impl App { pub fn show_graph(&mut self, ui: &mut egui::Ui) { SnarlWidget::new() .id_salt(egui::Id::new("agent_graph")) .show(&mut self.snarl, &mut self.viewer, ui); // Apply any text edits captured during rendering. let pending = std::mem::take(&mut self.viewer.pending_edits); for (id, node) in pending { self.snarl[id] = node; } } } }` We can seed a starter graph to make sure everything wires up: `#![allow(unused)] fn main() { impl App { pub fn new() -> Self { let mut snarl = Snarl::::new(); let sys = snarl.insert_node([0.0, 0.0], AgentNode::StringNode { value: "You are a helpful assistant.".into(), }); let chat = snarl.insert_node([0.0, 200.0], AgentNode::ChatInput { message: "What is 2+2?".into(), }); let llm = snarl.insert_node([400.0, 100.0], AgentNode::LLMNode { model: "gpt-4o-mini".into(), temperature: 0.7, system_prompt: String::new(), }); let out = snarl.insert_node([800.0, 100.0], AgentNode::OutputNode); // sys -> llm input 0 (system prompt) // chat -> llm input 1 (user message) // llm -> out input 0 snarl.connect( egui_snarl::OutPinId { node: sys, output: 0 }, egui_snarl::InPinId { node: llm, input: 0 }, ); snarl.connect( egui_snarl::OutPinId { node: chat, output: 0 }, egui_snarl::InPinId { node: llm, input: 1 }, ); snarl.connect( egui_snarl::OutPinId { node: llm, output: 0 }, egui_snarl::InPinId { node: out, input: 0 }, ); Self { snarl, viewer: AgentViewer { pending_edits: Default::default() }, } } } }` You should now see four nodes, color-coded green pins, editable prompt text in the `ChatInput` and `LLMNode` bodies, a model combo box and temperature slider, and three wires forming a complete pipeline. The graph looks like an agent flow — but nothing happens when you stare at it, because we haven’t taught it to _run_. That’s the job of the evaluation engine. * * * We now have a typed, visually-rich set of agent nodes living inside our snarl graph. Each node declares its pins, renders its own configuration UI, and color-codes its connections. What’s missing is the part that makes a flow builder _useful_: executing the graph. In [Chapter 14](https://danpokhrel.github.io/tutorial/ch14-graph-evaluation.html) we’ll build a graph evaluator — a pure-Rust module that topologically sorts the nodes, feeds inputs forward, and produces a result for every node — turning our visual diagram into a runnable program. --- # Chapter 15: Live Execution & Streaming - Building a Node-Based AI Flow Editor with eframe & egui Keyboard shortcuts ------------------ Press ← or → to navigate between chapters Press S or / to search in the book Press ? to show this help Press Esc to hide this help * Auto * Light * Rust * Coal * Navy * Ayu Building a Node-Based AI Flow Editor with eframe & egui ======================================================= [](https://danpokhrel.github.io/tutorial/print.html "Print this book") In [Chapter 14](https://danpokhrel.github.io/tutorial/ch14-graph-evaluation.html) we built a graph evaluation engine that computes the output of every node in topological order. But it ran all at once — the user clicks “Evaluate” and gets the final result. Real agentic AI flow builders like Langflow and Flowise make execution feel _alive_: you watch each node light up in turn, you see LLM output stream token by token, and you can stop mid-run. In this chapter we bring that real-time experience to our flow editor. > **Scope note: not in the reference implementation.** Live streaming is Part 4 territory and is not present in the impl (which has no evaluator to stream). The `request_repaint`\-driven stepping pattern, however, is the real, generally-useful egui technique it claims to be. The key insight is that **egui’s repaint model is on-demand**. When the window is idle, no CPU is wasted. When we want to show progress, we need to drive repaints ourselves with `ctx.request_repaint()`. This gives us a natural mechanism for stepping through evaluation: 1. The user clicks “Run Flow”. 2. We enter a `Running` state and evaluate **one node** per `logic()` call. 3. After each node, we call `ctx.request_repaint()` so the UI updates to show progress. 4. When all nodes are done, we transition to `Done`. This keeps `ui()` cheap — it only reads the current evaluation state and renders it. The actual work happens in `logic()`, which is exactly the separation the 0.34 `logic`/`ui` split was designed for. > **Note:** `logic()` runs once before each `ui()` call, and additionally when the UI is hidden but a repaint was requested. This means our stepping loop continues even if the user minimizes the window — a nice property for long-running flows. Let’s define an enum to track where we are in the evaluation lifecycle. Rust enums (covered in [Rust Book Chapter 6](https://doc.rust-lang.org/stable/book/ch06-00-enums.html) ) are perfect for modeling a finite state machine: ``#![allow(unused)] fn main() { use egui_snarl::NodeId; /// Tracks the state of a live graph evaluation. pub enum EvalState { /// No evaluation is running. Idle, /// Evaluation is in progress. `remaining` is the list of nodes /// still to evaluate, in topological order. `step` is how many /// we have completed so far (for the progress bar). Running { order: Vec, remaining: Vec, step: usize, results: std::collections::HashMap, }, /// Evaluation completed successfully. Done { results: std::collections::HashMap, }, /// Evaluation failed (e.g., a cycle was detected). Error(String), } }`` Each variant carries the data it needs. `Running` holds the topological order, the remaining nodes, the current step count, and a map of evaluated results. This is the same collect-then-mutate philosophy from [Chapter 5](https://danpokhrel.github.io/tutorial/ch05-architecture.html) — the state is owned by `App`, mutated in `logic()`, and read in `ui()`. When the user clicks “Run Flow”, we compute the topological order and enter the `Running` state: `#![allow(unused)] fn main() { use crate::eval::topo_sort; impl MyApp { fn start_eval(&mut self) { match topo_sort(&self.snarl) { Ok(order) => { self.eval = EvalState::Running { order: order.clone(), remaining: order, step: 0, results: Default::default(), }; } Err(e) => { self.eval = EvalState::Error(format!("Cannot evaluate: {e}")); } } } } }` If the graph has a cycle, `topo_sort` returns an error and we display it immediately. This follows [Rust Book Chapter 9](https://doc.rust-lang.org/stable/book/ch09-02-recoverable-errors-with-result.html) — recoverable errors are returned as `Result`, not panics. The heart of the streaming evaluation lives in `logic()`. Each call evaluates **one** node from the `remaining` list, stores its result, and requests a repaint: `#![allow(unused)] fn main() { impl eframe::App for MyApp { fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { // Take ownership of the current eval state so we can replace it. let eval = std::mem::replace(&mut self.eval, EvalState::Idle); match eval { EvalState::Running { mut order, mut remaining, mut step, mut results, } => { if let Some(&node_id) = remaining.first() { // Evaluate this one node. let result = crate::eval::evaluate_node( &self.snarl, node_id, &results, ); results.insert(node_id, result); remaining.remove(0); step += 1; // Update the log panel. self.log_lines.push(format!( "[{}/{}] Evaluated node {:?}", step, order.len(), node_id )); if remaining.is_empty() { // All done! self.eval = EvalState::Done { results }; } else { // Keep going — request a repaint to continue. self.eval = EvalState::Running { order, remaining, step, results, }; ctx.request_repaint(); } } } // Idle, Done, or Error — nothing to do in logic(). other => self.eval = other, } } fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { // ... render panels, graph, and console ... } } }` The critical line is `ctx.request_repaint()`. Without it, egui would not call `logic()` again — the evaluation would freeze after the first node. By requesting a repaint, we tell egui “something changed, run another frame,” which triggers another `logic()` call, which evaluates the next node, and so on until `remaining` is empty. > **Warning:** Never do heavy computation in `ui()`. It can be called many times per second and will make the app feel sluggish. All evaluation work belongs in `logic()` (or a background thread). See [Chapter 5](https://danpokhrel.github.io/tutorial/ch05-architecture.html) > for the full discussion. > > **What counts as “heavy”?** As a rule of thumb: anything that takes more than a few milliseconds per call. Reading from a `HashMap`, formatting a string, or building a handful of widgets is fine. Parsing JSON, sorting large vectors, or calling network APIs is not. Our graph evaluator (stepping one node per `logic()` call) is deliberately lightweight — each step is a single `match` on a node variant plus a string operation. If your evaluator does real I/O (LLM API calls), move it to a background thread as shown later in this chapter. In the UI, we read the evaluation state to show a progress bar and highlight the currently-running node: `#![allow(unused)] fn main() { fn render_status_bar(ui: &mut egui::Ui, eval: &EvalState) { match eval { EvalState::Idle => { ui.label("Idle — click Run to evaluate the flow."); } EvalState::Running { order, step, .. } => { let progress = *step as f32 / order.len().max(1) as f32; ui.add(egui::ProgressBar::new(progress) .desired_width(200.0) .text(format!("Evaluating {}/{}", step, order.len()))); } EvalState::Done { .. } => { ui.colored_label(egui::Color32::from_green(0x3f), "✓ Evaluation complete."); } EvalState::Error(msg) => { ui.colored_label(egui::Color32::from_red(0xb0), format!("Error: {msg}")); } } } }` We can also highlight the currently-evaluating node in the graph by checking whether a node ID matches the first element of `remaining`: `#![allow(unused)] fn main() { // In the SnarlViewer's header_frame override: fn header_frame( &mut self, frame: egui::Frame, node: NodeId, _inputs: &[InPin], _outputs: &[OutPin], _snarl: &Snarl, ) -> egui::Frame { // If this is the currently-evaluating node, highlight it. if let EvalState::Running { remaining, .. } = &self.eval_state { if remaining.first() == Some(&&node) { return frame .fill(egui::Color32::from_rgb(60, 80, 120)) .stroke(egui::Stroke::new(2.0, egui::Color32::from_rgb(100, 150, 255))); } } frame } }` This gives the user visual feedback — the currently-evaluating node lights up with a blue border, then returns to normal as the evaluation moves on. We need UI controls to start and cancel evaluation. Let’s add them to a toolbar: `#![allow(unused)] fn main() { fn render_toolbar(ui: &mut egui::Ui, app: &mut MyApp) { ui.horizontal(|ui| { let is_running = matches!(app.eval, EvalState::Running { .. }); ui.add_enabled_ui(!is_running, |ui| { if ui.button("▶ Run Flow").clicked() { app.start_eval(); } }); ui.add_enabled_ui(is_running, |ui| { if ui.button("⏹ Stop").clicked() { app.eval = EvalState::Idle; app.log_lines.push("Evaluation stopped by user.".into()); } }); if ui.button("Clear Results").clicked() { app.eval = EvalState::Idle; app.log_lines.clear(); } }); } }` The `add_enabled_ui` method (from [Chapter 4](https://danpokhrel.github.io/tutorial/ch04-layout-widgets.html) ) disables the Run button while a run is in progress and disables Stop when idle. This prevents the user from starting two evaluations simultaneously. Real flow builders show a log of what happened during execution. Let’s add a bottom panel that displays the log lines: `#![allow(unused)] fn main() { // In the App::ui method, add the bottom panel BEFORE the central panel: egui::Panel::bottom("console") .default_height(160.0) .resizable(true) .show(ui, |ui| { ui.heading("Execution Log"); ui.separator(); egui::ScrollArea::vertical().show(ui, |ui| { for line in &app.log_lines { ui.label(line); } }); }); }` Remember from [Chapter 4](https://danpokhrel.github.io/tutorial/ch04-layout-widgets.html) : panels must be added in the right order. `Panel::bottom` should come before `CentralPanel` so the central panel shrinks to fill the remaining space. For LLM nodes, real agentic AI tools stream the response token by token. We can simulate this by splitting the LLM’s output into chunks and revealing them progressively across multiple `logic()` calls: `#![allow(unused)] fn main() { EvalState::Running { mut order, mut remaining, mut step, mut results, } => { if let Some(&node_id) = remaining.first() { // Get the node data. let node = &self.snarl[node_id]; // For LLM nodes, simulate streaming: if we already have a partial // result, append the next "token"; otherwise start generating. let current = results.get(&node_id).cloned().unwrap_or_default(); if let AgentNode::LLMNode { .. } = node { let full_response = crate::eval::simulate_llm_response(&self.snarl, node_id, &results); let tokens: Vec<&str> = full_response.split_whitespace().collect(); let current_tokens = current.split_whitespace().count(); if current_tokens < tokens.len() { // Reveal one more token. let next = tokens[..current_tokens + 1].join(" "); results.insert(node_id, next); // Don't advance — come back next frame for the next token. // Request repaint to continue streaming. self.eval = EvalState::Running { order, remaining, step, results }; ctx.request_repaint(); return; } // All tokens revealed — move to the next node. } // For non-LLM nodes, evaluate immediately. let result = crate::eval::evaluate_node(&self.snarl, node_id, &results); results.insert(node_id, result); remaining.remove(0); step += 1; // ... update log, check if done ... } } }` This pattern — keeping a node in `remaining` until its streaming is complete, then removing it — gives us realistic-looking output without any real API calls. In a production app, you would replace the simulated streaming with a real HTTP request to an LLM API using `ehttp` or `reqwest`, as discussed in [Chapter 18](https://danpokhrel.github.io/tutorial/ch18-conclusion.html) . For genuinely long-running work (real API calls, heavy computation), you should move evaluation to a background thread. The pattern from [Chapter 5](https://danpokhrel.github.io/tutorial/ch05-architecture.html) applies: `#![allow(unused)] fn main() { use std::sync::{Arc, Mutex}; use std::sync::mpsc::{channel, Receiver}; struct MyApp { // Results arrive from the background thread through this channel. eval_rx: Option>, // Shared state for the background thread to read graph data. graph_snapshot: Arc>>, } impl MyApp { fn start_background_eval(&mut self, ctx: &egui::Context) { let (tx, rx) = channel(); self.eval_rx = Some(rx); // Snapshot the graph for the background thread (Snarl is not Send // if it contains non-Send data, so we extract what we need). let snapshot = Arc::new(Mutex::new( self.snarl.nodes_ids_data().collect::>() )); self.graph_snapshot = snapshot.clone(); let ctx = ctx.clone(); // Context is Arc-backed, cheaply clonable. std::thread::spawn(move || { let graph = snapshot.lock().unwrap(); let order = crate::eval::topo_sort_from_snapshot(&graph).unwrap(); drop(graph); // Release the lock before evaluating. let mut results = std::collections::HashMap::new(); for &node_id in &order { let result = crate::eval::evaluate_snapshot(&snapshot, node_id, &results); results.insert(node_id, result.clone()); // Send the result to the UI thread. let _ = tx.send((node_id, result)); // Wake the UI to show the new result. ctx.request_repaint(); } }); } } }` Then in `logic()`, we drain the channel: `#![allow(unused)] fn main() { fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { if let Some(rx) = &self.eval_rx { while let Ok((node_id, result)) = rx.try_recv() { self.eval_results.insert(node_id, result); self.log_lines.push(format!("Evaluated node {node_id:?}")); } } } }` The `while let Ok(...)` loop drains all available results each frame. If the channel is empty, it returns immediately — no blocking. The background thread calls `ctx.request_repaint()` each time it sends a result, so the UI wakes up to process it. > **Tip:** On web (WASM), `std::thread` is not available. Use `wasm_bindgen_futures::spawn_local` instead, and `ctx.request_repaint()` when the async task completes. The channel-based pattern works the same — just swap the thread for a future. See [Chapter 17](https://danpokhrel.github.io/tutorial/ch17-production.html) > for the full web setup. This is the most important rule of streaming egui apps: **`ui()` must be fast**. It can be called many times per second, and if it blocks, the entire app freezes. All the patterns in this chapter follow this principle: * Evaluation happens in `logic()` (one step per call) or a background thread. * `ui()` only _reads_ the current state and renders it. * The `request_repaint()` calls ensure `logic()` keeps being called until the evaluation is done. * The console panel uses a `ScrollArea` so it doesn’t re-lay-out all log lines if there are thousands (though for truly massive logs, consider a virtualized list from [Chapter 4](https://danpokhrel.github.io/tutorial/ch04-layout-widgets.html) ). > **Tip:** For the streaming-LLM-tokens loop, `ctx.request_repaint_after(Duration::from_millis(50))` is more appropriate than `ctx.request_repaint()`. The small delay makes each token visible to the user rather than rendering them all in a rapid burst of frames. The `request_repaint_after` method schedules a single repaint after the given duration — perfect for pacing visible output. Here’s the complete `App::ui` method with all the panels wired up: `#![allow(unused)] fn main() { fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { // 1. Top panel: toolbar with Run/Stop/Clear buttons. egui::Panel::top("toolbar").show(ui, |ui| { crate::ui::render_toolbar(ui, self); }); // 2. Bottom panel: execution log (resizable). egui::Panel::bottom("console") .default_height(140.0) .resizable(true) .show(ui, |ui| { ui.heading("Execution Log"); ui.separator(); egui::ScrollArea::vertical().show(ui, |ui| { for line in &self.log_lines { ui.label(line); } }); }); // 3. Left panel: status and results. egui::Panel::left("status") .default_width(240.0) .resizable(true) .show(ui, |ui| { crate::ui::render_status_bar(ui, &self.eval); ui.separator(); ui.heading("Results"); egui::ScrollArea::vertical().show(ui, |ui| { let results = match &self.eval { EvalState::Done { results } | EvalState::Running { results, .. } => results, _ => &self.eval_results, }; for (&id, value) in results { ui.label(format!("{id:?}: {value}")); } }); }); // 4. Central panel: the node graph. egui::CentralPanel::default().show(ui, |ui| { SnarlWidget::new() .id_salt(egui::Id::new("flow-snarl")) .style(self.snarl_style) .show(&mut self.snarl, &mut self.viewer, ui); }); } }` The panel ordering follows the rules from [Chapter 4](https://danpokhrel.github.io/tutorial/ch04-layout-widgets.html) : top and bottom panels first, then side panel, then central panel last so it fills the remaining space. * * * We now have a live, streaming evaluation engine that shows progress in real time. The final piece of production functionality is persistence — saving and loading the graph so the user’s work survives across sessions. In [Chapter 16](https://danpokhrel.github.io/tutorial/ch16-persistence.html) we’ll cover eframe’s built-in persistence, manual save/load with serde, and native file dialogs. --- # Chapter 14: Graph Evaluation - Building a Node-Based AI Flow Editor with eframe & egui Keyboard shortcuts ------------------ Press ← or → to navigate between chapters Press S or / to search in the book Press ? to show this help Press Esc to hide this help * Auto * Light * Rust * Coal * Navy * Ayu Building a Node-Based AI Flow Editor with eframe & egui ======================================================= [](https://danpokhrel.github.io/tutorial/print.html "Print this book") A flow builder with beautiful nodes and wires is, until something runs, just a drawing. In this chapter we make it _compute_. We’ll build a graph evaluator that takes a `Snarl` and produces an output value for every node, executing them in dependency order so that every node’s inputs are ready by the time it runs. The evaluator will live in a pure-Rust module with no `egui` dependency, so it can be unit-tested on a headless CI machine exactly as we planned in [Chapter 5](https://danpokhrel.github.io/tutorial/ch05-architecture.html) and practiced in [Chapter 11](https://danpokhrel.github.io/tutorial/ch11-interactions.html) . This chapter builds directly on the `AgentNode` enum from [Chapter 13](https://danpokhrel.github.io/tutorial/ch13-agent-nodes.html) . > **Scope note: not in the reference implementation.** The impl has no `eval` module and no evaluator — like the rest of Part 4, this chapter is forward-looking design. The function names here (`topo_sort`, `evaluate_node`, `simulate_llm_response`) are the public API [Chapter 15](https://danpokhrel.github.io/tutorial/ch15-live-execution.html) > and [Chapter 17](https://danpokhrel.github.io/tutorial/ch17-production.html) > build on, but you will not find them in the implementation. Given a directed graph of nodes where each node consumes some inputs and produces some outputs, _evaluate_ means: produce a value for every node such that every node’s inputs are the outputs of the nodes feeding into it. Concretely, for our agent flow: * `StringNode` produces its constant string. * `ChatInput` produces the message the user typed. * `PromptTemplate` formats its template using connected inputs. * `LLMNode` takes its two input strings, calls (or simulates) an LLM, and emits the response. * `ToolNode` simulates a tool call on its input query. * `MemoryNode` accumulates input into its history and emits the joined context. * `OutputNode` consumes its input and stores it as the final result. The catch is _ordering_. We cannot evaluate `LLMNode` before its system-prompt and user-message inputs are ready. So evaluation has two phases: figure out a valid order, then walk that order computing outputs. A _topological sort_ of a directed acyclic graph (DAG) is a linear ordering of its nodes such that for every edge `u → v`, `u` comes before `v`. If the graph has a cycle, no topological order exists, and that is exactly the error condition we must detect — a cyclic flow cannot be evaluated because a node’s input would depend on its own output. Two classic algorithms produce a topological order: * **Kahn’s algorithm**: repeatedly remove nodes with no remaining incoming edges (in-degree zero), appending them to the output and decrementing their successors’ in-degrees. If the output has fewer nodes than the graph, a cycle exists. * **DFS-based**: run a post-order DFS, pushing nodes onto a stack; popping the stack yields a topological order. Detect back-edges to find cycles. We’ll use Kahn’s algorithm because it gives us the cycle check for free and is easy to read. egui-snarl exposes connectivity through `snarl.wires()`, which yields `(OutPinId, InPinId)` pairs. Each such pair means “output of `OutPinId.node` feeds input of `InPinId.node`” — i.e. there is a dependency edge from the source node to the destination node. We can turn the wire iterator into an adjacency list and an in-degree map: ``#![allow(unused)] fn main() { use egui_snarl::{NodeId, Snarl}; use std::collections::{HashMap, HashSet}; /// Build an adjacency list and in-degree map from a snarl's wires. /// /// `outgoing[a]` is the set of nodes that depend on `a`'s outputs. /// `indeg[b]` is how many of `b`'s inputs are fed by some upstream node. fn build_adjacency( snarl: &Snarl, ) -> (HashMap>, HashMap) { let mut outgoing: HashMap> = HashMap::new(); let mut indeg: HashMap = HashMap::new(); // Ensure every node appears, even if it has no edges. for id in snarl.node_ids() { outgoing.entry(id).or_default(); indeg.entry(id).or_insert(0); } for (out_pin, in_pin) in snarl.wires() { let from = out_pin.node; let to = in_pin.node; if from == to { // A self-wire: treat as a cycle. continue; } if outgoing.entry(from).or_default().insert(to) { *indeg.entry(to).or_insert(0) += 1; } } (outgoing, indeg) } }`` > **Note:** egui-snarl’s `Snarl` exposes `node_ids()` to iterate node IDs. If your version does not, you can derive the ID list from `wires()` plus any bookkeeping you keep when inserting nodes; the adjacency-list code is otherwise unchanged. The `insert` check on the `HashSet` deduplicates parallel edges — multiple wires from the same source node into the same destination node count once for in-degree purposes, because one upstream node being ready unblocks the dependency. With the adjacency list in hand, Kahn’s algorithm is a handful of lines: `#![allow(unused)] fn main() { use std::collections::VecDeque; /// Returns a topological ordering of the snarl's nodes, or an error /// describing a cycle if one exists. fn topo_sort( snarl: &Snarl, ) -> Result, EvalError> { let (outgoing, mut indeg) = build_adjacency(snarl); let mut queue: VecDeque = indeg .iter() .filter(|(_, &d)| d == 0) .map(|(&n, _)| n) .collect(); let mut order = Vec::with_capacity(indeg.len()); while let Some(n) = queue.pop_front() { order.push(n); if let Some(succs) = outgoing.get(&n) { for &s in succs { let d = indeg.entry(s).or_insert(0); *d -= 1; if *d == 0 { queue.push_back(s); } } } } if order.len() == indeg.len() { Ok(order) } else { Err(EvalError::Cycle { involved: indeg.keys().copied().collect(), }) } } }` If `order.len()` is less than the number of nodes, the unscheduled nodes form one or more cycles, and we report a `Cycle` error. We return the involved node set so the UI can highlight them — more on that in [Chapter 15](https://danpokhrel.github.io/tutorial/ch15-live-execution.html) . Once we have an order, we walk it, gathering each node’s inputs from the _results_ of nodes already evaluated. The natural container is a map from `NodeId` to the node’s _primary output string_. Since all our pins carry strings, a node produces exactly one output value (or, for `OutputNode`, none). We use a `HashMap`: `#![allow(unused)] fn main() { use std::collections::HashMap; /// The result of evaluating a graph: one output string per node that /// produced one. Terminal nodes (OutputNode) record their received /// value under a well-known key instead. #[derive(Clone, Debug, Default)] pub struct EvalResults { pub values: HashMap, /// Logs accumulated during evaluation, for the console panel. pub logs: Vec, } }` > **Tip:** We keep `logs` on the results struct so the UI can display a trace without holding a separate side-channel. In [Chapter 15](https://danpokhrel.github.io/tutorial/ch15-live-execution.html) > we’ll stream these logs to a console panel as evaluation progresses. To evaluate a node we need the strings feeding each of its input pins. Each input pin has a `remotes: Vec` listing the output pins connected to it (see the API reference in [Chapter 8](https://danpokhrel.github.io/tutorial/ch08-egui-snarl.html) ). We resolve each remote to its source node’s already-computed output: ``#![allow(unused)] fn main() { use egui_snarl::{InPinId, NodeId, OutPinId, Snarl}; /// Resolve the inputs for `node`, one `String` per input pin, in order. /// Missing connections yield `None`; the caller decides how to handle that. fn gather_inputs( snarl: &Snarl, node: NodeId, results: &EvalResults, ) -> Vec> { let input_count = input_pin_count(&snarl[node]); let mut inputs = Vec::with_capacity(input_count); for i in 0..input_count { let pin = snarl.in_pin(InPinId { node, input: i }); // `pin.remotes` is the list of outputs wired into this input. let value = pin.remotes.first().and_then(|out: &OutPinId| { results.values.get(&out.node).cloned() }); inputs.push(value); } inputs } /// How many input pins a node has — must match the `SnarlViewer::inputs` /// impl from Chapter 13. fn input_pin_count(node: &AgentNode) -> usize { match node { AgentNode::ChatInput { .. } | AgentNode::StringNode { .. } => 0, AgentNode::PromptTemplate { .. } | AgentNode::ToolNode { .. } | AgentNode::MemoryNode { .. } => 1, AgentNode::LLMNode { .. } => 2, AgentNode::OutputNode => 1, } } }`` For multiple outputs wired into one input we take only the first — a simple policy. A stricter implementation could error or concatenate; we note this as a place to enforce stricter semantics later. With inputs gathered, evaluating a node is a `match` on the variant — exactly the exhaustive pattern matching the Rust Book champions in [Chapter 6](https://doc.rust-lang.org/stable/book/ch06-00-enums.html) . Each arm reads its inputs (substituting defaults when missing) and produces an output string. ``#![allow(unused)] fn main() { /// Evaluate a single node, given its resolved inputs. Returns its /// output string, or `None` for terminal nodes. fn eval_node( node: &AgentNode, inputs: Vec>, results: &mut EvalResults, ) -> Option { match node { AgentNode::StringNode { value } => Some(value.clone()), AgentNode::ChatInput { message } => Some(message.clone()), AgentNode::PromptTemplate { template } => { // Substitute {0}, {1}, ... with connected inputs in order. let mut out = template.clone(); for (i, inp) in inputs.iter().enumerate() { let val = inp.clone().unwrap_or_default(); out = out.replace(&format!("{{{i}}}"), &val); } results.logs.push(format!("PromptTemplate → {out}")); Some(out) } AgentNode::LLMNode { model, temperature, system_prompt } => { let system = inputs.get(0).cloned().flatten().unwrap_or_else(|| system_prompt.clone()); let user = inputs.get(1).cloned().flatten().unwrap_or_default(); let response = simulate_llm(model, *temperature, &system, &user); results.logs.push(format!("LLMNode {model} → {response}")); Some(response) } AgentNode::ToolNode { tool } => { let query = inputs.get(0).cloned().flatten().unwrap_or_default(); let result = simulate_tool(tool, &query); results.logs.push(format!("Tool {:?}({query}) → {result}", tool)); Some(result) } AgentNode::MemoryNode { history } => { // In a pure evaluator we can't mutate the node here, so we // append to a working copy carried by the caller instead. // For the tutorial, emit the existing context as the output. Some(history.join("\n")) } AgentNode::OutputNode => { // Terminal: record the value but produce no output pin. None } } } }`` For the tutorial we never call a real API — we simulate responses so the code runs anywhere. The simulation is intentionally trivial, which keeps the evaluator deterministic and easy to snapshot-test (see [Chapter 17](https://danpokhrel.github.io/tutorial/ch17-production.html) ). In [Chapter 18](https://danpokhrel.github.io/tutorial/ch18-conclusion.html) we point at how to swap in real API calls. ``#![allow(unused)] fn main() { fn simulate_llm(model: &str, temperature: f32, system: &str, user: &str) -> String { // A deterministic, obviously-fake response. Real code would call // an API here; see Chapter 18 for pointers to ehttp/reqwest. let _ = (model, temperature, system); format!("[simulated {model}] You said: {user}") } fn simulate_tool(tool: &ToolKind, query: &str) -> String { match tool { ToolKind::WebSearch => format!("Top result for '{query}': https://example.com/results?q={query}"), ToolKind::Calculator => match query.trim().parse::() { Ok(n) => format!("{n}"), Err(_) => format!("Not a number: {query}"), }, ToolKind::Weather => format!("Weather for {query}: 21°C, sunny (simulated)"), } } /// Resolve an `LLMNode`'s two inputs from `results` and produce its full /// (simulated) response in one shot. This is the convenience wrapper the /// streaming UI in [Chapter 15](./ch15-live-execution.md) calls to get the /// complete string before revealing it token-by-token. fn simulate_llm_response( snarl: &Snarl, node_id: NodeId, results: &EvalResults, ) -> String { let AgentNode::LLMNode { model, temperature, system_prompt } = &snarl[node_id] else { return String::new(); }; let system = snarl .in_pin(InPinId { node: node_id, input: 0 }) .remotes .first() .and_then(|out| results.values.get(&out.node)) .cloned() .unwrap_or_else(|| system_prompt.clone()); let user = snarl .in_pin(InPinId { node: node_id, input: 1 }) .remotes .first() .and_then(|out| results.values.get(&out.node)) .cloned() .unwrap_or_default(); simulate_llm(model, *temperature, &system, &user) } }`` > **Note:** We deliberately mark `temperature` as used (via `_`) in the simulated LLM. In a real implementation, temperature would seed sampling randomness; for deterministic tests you’d want it fixed or stubbed. Keeping the simulation pure is what makes the evaluator unit-testable, which is the whole point of the architecture from [Chapter 5](https://danpokhrel.github.io/tutorial/ch05-architecture.html) > . `eval_node` above takes a _node_ and its already-gathered _inputs_ — that is the shape the `GraphEvaluator` loop uses internally. But the UI in [Chapter 15](https://danpokhrel.github.io/tutorial/ch15-live-execution.html) and the tests in [Chapter 17](https://danpokhrel.github.io/tutorial/ch17-production.html) want a simpler, single-call entry point: “given the snarl, a node id, and the results computed so far, what is this node’s output string?” We expose that as a thin public wrapper that gathers the inputs and delegates to `eval_node`: ``#![allow(unused)] fn main() { /// Evaluate one node given the snarl and the results computed so far. /// Returns the node's output string (empty for terminal `OutputNode`). /// This is the entry point the streaming UI and the unit tests call. pub fn evaluate_node( snarl: &Snarl, node_id: NodeId, results: &EvalResults, ) -> String { let node = &snarl[node_id]; if matches!(node, AgentNode::OutputNode) { // Terminal: its received value is already in `results`. return results.values.get(&node_id).cloned().unwrap_or_default(); } let inputs = gather_inputs(snarl, node_id, results); // `eval_node` borrows `results` mutably only to push log lines; for the // read-only wrapper we hand it a throwaway clone so the shared borrow // passed in by the caller stays intact. let mut tmp = EvalResults { values: results.values.clone(), logs: Vec::new() }; eval_node(node, inputs, &mut tmp).unwrap_or_default() } }`` > **Tip:** The wrapper clones `results.values` so it can hand `eval_node` a `&mut EvalResults` (which it uses only to append log lines) without mutating the caller’s snapshot. For the streaming UI this is exactly right: it reads a frozen snapshot per step. Let’s assemble these pieces behind a single `GraphEvaluator` type with an `evaluate()` method that takes `&Snarl` and returns results (or an error). It also carries an optional working `MemoryNode` history buffer so the `MemoryNode` can accumulate across evaluations in the same session: ``#![allow(unused)] fn main() { use egui_snarl::{NodeId, Snarl}; /// A pure-Rust evaluator for agent flow graphs. Holds no `egui` types. pub struct GraphEvaluator { /// Working memory for `MemoryNode`s, keyed by node id, so history /// persists across `evaluate` calls in the same session. memory: HashMap>, } impl GraphEvaluator { pub fn new() -> Self { Self { memory: HashMap::new() } } pub fn evaluate(&mut self, snarl: &Snarl) -> Result { let order = topo_sort(snarl)?; let mut results = EvalResults::default(); for id in order { let node = &snarl[id]; // Memory nodes need session-persistent history, so handle them // specially: append the incoming value to the working buffer. if let AgentNode::MemoryNode { history } = node { let buf = self.memory.entry(id).or_default(); // `history` (the node's serialized field) seeds the buffer // on first evaluation; afterwards the buffer is authoritative. if buf.is_empty() { buf.extend(history.iter().cloned()); } } let inputs = gather_inputs(snarl, id, &results); let output = if let AgentNode::MemoryNode { .. } = node { let buf = self.memory.entry(id).or_default(); if let Some(v) = inputs.into_iter().next().and_then(|o| o) { buf.push(v); } Some(buf.join("\n")) } else if let AgentNode::OutputNode = node { let final_value = inputs.into_iter().next().and_then(|o| o) .unwrap_or_else(|| "".into()); results.logs.push(format!("Output: {final_value}")); results.values.insert(id, final_value.clone()); None } else { eval_node(node, inputs, &mut results) }; if let Some(out) = output { results.values.insert(id, out); } } Ok(results) } } }`` The `OutputNode` is special-cased so its received value lands in `results.values` under its own `NodeId` — that’s how the UI can later show “final answer” without a separate field. We define a small error enum, following the recoverable-errors guidance from Rust Book [Chapter 9](https://doc.rust-lang.org/stable/book/ch09-00-error-handling.html) . We’ll expand this enum in [Chapter 17](https://danpokhrel.github.io/tutorial/ch17-production.html) when we add production error types: ``#![allow(unused)] fn main() { use std::fmt; #[derive(Clone, Debug)] pub enum EvalError { /// The graph contains a cycle; `involved` lists the suspect nodes. Cycle { involved: Vec }, /// A node was missing a required input. MissingInput { node: NodeId, input: usize }, /// A pin's value had an unexpected type (reserved for later typed pins). TypeMismatch { node: NodeId, pin: usize, expected: &'static str }, } impl fmt::Display for EvalError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { EvalError::Cycle { involved } => write!( f, "graph has a cycle involving {} node(s)", involved.len() ), EvalError::MissingInput { node, input } => write!( f, "node {:?} is missing input pin {}", node, input ), EvalError::TypeMismatch { node, pin, expected } => write!( f, "node {:?} pin {} expected {}", node, pin, expected ), } } } impl std::error::Error for EvalError {} }`` The two policies worth stating explicitly: * **Missing inputs** use sensible defaults rather than aborting: a `PromptTemplate` with an unconnected `{0}` substitutes the empty string; an `LLMNode` with no system prompt falls back to its configured `system_prompt` field. This keeps the graph runnable while the user is mid-edit, which is much friendlier than refusing to run. We still _log_ the missing input so it shows in the console. * **Cycles** are fatal — there’s no useful default. We return `Cycle` and let the UI surface it. Evaluation is pure, so triggering it from the UI is a one-liner — we collect a “run” intent from a button in `ui()` and act on it in `logic()` (or immediately, since it’s fast), storing results in app state: `#![allow(unused)] fn main() { use eframe::egui; pub struct App { pub snarl: egui_snarl::Snarl, pub evaluator: GraphEvaluator, pub results: Option>, /// Set true by the Run button; drained in logic(). pub run_requested: bool, } impl eframe::App for App { fn logic(&mut self, _ctx: &egui::Context, _frame: &mut eframe::Frame) { if self.run_requested { self.run_requested = false; self.results = Some(self.evaluator.evaluate(&self.snarl)); } } fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { ui.horizontal(|ui| { if ui.button("▶ Run Flow").clicked() { self.run_requested = true; } }); // ...show the graph and any results... } } }` > **Tip:** Because evaluation is fast for a tutorial-sized graph, doing it synchronously in `logic()` is fine. For graphs that talk to real APIs, you’d move evaluation off the UI thread — that’s the whole subject of [Chapter 15](https://danpokhrel.github.io/tutorial/ch15-live-execution.html) > . Once `self.results` holds an `Ok(EvalResults)`, we can show computed values back in the node bodies, mirroring how the bundled egui-snarl demo displays computed output. We extend the viewer’s `show_output` to peek at the results and render the value: `#![allow(unused)] fn main() { use std::collections::HashMap; use egui_snarl::{ui::{PinInfo, SnarlPin, SnarlViewer}, NodeId, OutPin, Snarl}; pub struct AgentViewer { pub pending_edits: HashMap, /// Snapshot of the latest evaluation results, for inline display. pub results: Option, } impl SnarlViewer for AgentViewer { fn show_output( &mut self, pin: &OutPin, ui: &mut egui::Ui, _snarl: &mut Snarl, ) -> impl SnarlPin + 'static { let id = pin.id.node; // ...existing configuration UI elided (see Chapter 13)... // If we have a computed value for this node, show it. if let Some(results) = &self.results { if let Some(val) = results.values.get(&id) { ui.label( egui::RichText::new(val) .color(egui::Color32::from_rgb(180, 220, 180)) .small(), ); } } PinInfo::circle().with_fill(egui::Color32::from_rgb(80, 200, 120)) } } }` And the host copies the results snapshot onto the viewer each frame before showing the snarl: `#![allow(unused)] fn main() { impl App { fn show_graph(&mut self, ui: &mut egui::Ui) { self.viewer.results = self.results.as_ref().ok().cloned(); egui_snarl::ui::SnarlWidget::new() .id_salt(egui::Id::new("agent_graph")) .show(&mut self.snarl, &mut self.viewer, ui); // apply pending edits, as in Chapter 13 let pending = std::mem::take(&mut self.viewer.pending_edits); for (id, node) in pending { self.snarl[id] = node; } } } }` The console logs from `EvalResults::logs` can be rendered in a `Panel::bottom`: `#![allow(unused)] fn main() { egui::Panel::bottom("console").show(ui, |ui| { ui.heading("Evaluation log"); egui::ScrollArea::vertical().show(ui, |ui| { if let Some(Ok(results)) = &self.results { for line in &results.logs { ui.label(line); } } }); }); }` It is worth re-stating why the evaluator module imports nothing from `egui`. Because it depends only on `egui_snarl::{Snarl, NodeId, ...}` (which themselves are plain data types) and `AgentNode` (a pure-data enum), the entire evaluator can be compiled and tested without a window. A headless CI job can construct a `Snarl` in code, run `evaluate`, and assert on the resulting strings — exactly the testability we argued for in [Chapter 5](https://danpokhrel.github.io/tutorial/ch05-architecture.html) and exercised in [Chapter 11](https://danpokhrel.github.io/tutorial/ch11-interactions.html) . We’ll write those exact tests in [Chapter 17](https://danpokhrel.github.io/tutorial/ch17-production.html) . > **Module organization.** The functions in this chapter — `topo_sort`, `build_adjacency`, `gather_inputs`, `eval_node` (internal), `evaluate_node` (public wrapper), `simulate_llm`, `simulate_tool`, `simulate_llm_response`, `GraphEvaluator`, `EvalResults`, `EvalError` — should all live in a single `src/eval.rs` file, declared from `main.rs` with `mod eval;` (following the structure from [Chapter 2](https://danpokhrel.github.io/tutorial/ch02-project-setup.html) > and [Chapter 5](https://danpokhrel.github.io/tutorial/ch05-architecture.html) > ). [Chapter 15](https://danpokhrel.github.io/tutorial/ch15-live-execution.html) > and [Chapter 17](https://danpokhrel.github.io/tutorial/ch17-production.html) > call `crate::eval::topo_sort`, `crate::eval::evaluate_node`, and `crate::eval::simulate_llm_response`, so keep those exact public names. The key point: all evaluation logic lives in one module with no `egui` dependency, keeping it testable on a headless CI machine. > **Re-evaluation and `MemoryNode`.** The `GraphEvaluator` carries a `memory: HashMap>` working buffer that persists across `evaluate()` calls. On the first evaluation, the buffer is seeded from the node’s serialized `history` field. On subsequent evaluations, the buffer is authoritative — if the user edits the `history` field in the UI between evaluations, the working buffer will not pick up the change. To keep them in sync, either (a) clear the evaluator’s `memory` buffer whenever the user edits a `MemoryNode` (detect the edit in `pending_edits`), or (b) re-seed the buffer from the node field at the start of each `evaluate()` call. Option (b) is simpler but loses accumulated context across re-evaluations; option (a) is more correct but requires tracking which nodes were edited. * * * We can now evaluate a static graph in one shot, see results on the pins, and read a trace in the console. But the evaluation is instantaneous — the user clicks _Run_ and everything is done before the next frame. That’s correct, but it doesn’t _feel_ like an agent working. In [Chapter 15](https://danpokhrel.github.io/tutorial/ch15-live-execution.html) we’ll turn evaluation into a live, step-by-step process: nodes light up as they run, LLM output streams token-by-token, and a progress bar tracks the walk through the graph. --- # Chapter 16: State Persistence - Building a Node-Based AI Flow Editor with eframe & egui Keyboard shortcuts ------------------ Press ← or → to navigate between chapters Press S or / to search in the book Press ? to show this help Press Esc to hide this help * Auto * Light * Rust * Coal * Navy * Ayu Building a Node-Based AI Flow Editor with eframe & egui ======================================================= [](https://danpokhrel.github.io/tutorial/print.html "Print this book") In [Chapter 15](https://danpokhrel.github.io/tutorial/ch15-live-execution.html) we built a live, streaming evaluation engine. Now we need the other half of a production editor: the ability to save the user’s work and restore it later. In this chapter we’ll cover two layers of persistence — eframe’s built-in app-state persistence and our own manual save/load to JSON files with native file dialogs. > **Scope note: not in the reference implementation.** The impl enables eframe’s `persistence` feature (so egui’s own app/UI state is auto-saved) but ships no manual JSON save/load, no `ProjectFile`, and no `rfd` dependency — that is all Part 4 forward-looking material. (`rfd`, `serde_json`, and `chrono` are flagged in-text as dependencies to add when you reach this chapter; they are not in the impl’s `Cargo.toml`.) Our flow editor has two distinct kinds of state that need persisting: 1. **Graph data** — the nodes, connections, and configuration that define the user’s flow. This is the _valuable_ state — it represents the user’s work and must survive across sessions. 2. **App/UI state** — window position, panel sizes, scroll positions, egui’s `Memory`. This is _convenience_ state — it’s nice to restore but not critical. eframe handles (2) automatically when the `persistence` feature is enabled. We handle (1) ourselves with serde and file dialogs. eframe’s persistence is opt-in via the `persistence` feature (enabled by default in our `Cargo.toml` from [Chapter 2](https://danpokhrel.github.io/tutorial/ch02-project-setup.html) ). When enabled, eframe periodically calls `App::save()` and restores state in `App::new()`: `#![allow(unused)] fn main() { use eframe; #[derive(serde::Serialize, serde::Deserialize)] struct MyApp { snarl: egui_snarl::Snarl, snarl_style: egui_snarl::SnarlStyle, log_lines: Vec, // Note: EvalState is not serialized — it's ephemeral. } impl MyApp { fn new(cc: &eframe::CreationContext<'_>) -> Self { // Restore from storage if available. if let Some(storage = cc.storage { return eframe::get_value(storage, eframe::APP_KEY) .unwrap_or_default(); } Self::default() } } impl eframe::App for MyApp { fn save(&mut self, storage: &mut dyn eframe::Storage) { // Serialize the entire App struct to eframe's RON-based storage. eframe::set_value(storage, eframe::APP_KEY, self); } } }` eframe’s `Storage` is a string-to-string map serialized as RON (Rust Object Notation). On native, it’s stored as a file under `eframe::storage_dir()` (the OS data directory, keyed by the app id passed to `run_native`). On web, it’s stored in the browser’s Local Storage. > **Note:** `eframe::APP_KEY` is the standard key for app-level state. You can also use custom keys for separate pieces of state (e.g., `"graph"` and `"settings"`). The `get_value` and `set_value` helpers are serde convenience wrappers around `Storage::get_string` / `Storage::set_string`. The `save` method is called on shutdown and periodically (every `auto_save_interval()`, which defaults to 30 seconds). You can override `auto_save_interval` to change the frequency: `#![allow(unused)] fn main() { fn auto_save_interval(&self) -> std::time::Duration { std::time::Duration::from_secs(60) // save every minute } }` > **Warning:** For `App::save` to work, your `App` struct (and everything inside it) must derive `serde::Serialize` and `serde::Deserialize`. The `Snarl` type derives these when the `serde` feature is enabled on `egui-snarl` and `T: Serialize + Deserialize`. This is why our `Cargo.toml` from [Chapter 2](https://danpokhrel.github.io/tutorial/ch02-project-setup.html) > enables the `serde` feature on `egui-snarl`. > **Note:** `serde_json` is not in our `Cargo.toml` from [Chapter 2](https://danpokhrel.github.io/tutorial/ch02-project-setup.html) > . Add `serde_json = "1"` to your `[dependencies]` before using the save/load code in this chapter. eframe’s built-in persistence is great for auto-save, but users also want explicit “Save As” and “Open” file operations. For this, we serialize the `Snarl` to a JSON file using [`serde_json`](https://crates.io/crates/serde_json) : `#![allow(unused)] fn main() { use serde::{Serialize, Deserialize}; /// A project file containing the graph and metadata. #[derive(Serialize, Deserialize)] pub struct ProjectFile { /// The serialized node graph (nodes, positions, wires). pub snarl: egui_snarl::Snarl, /// The version of this file format. pub version: String, /// Optional metadata. pub created: String, } impl MyApp { /// Save the graph to a JSON file. pub fn save_graph(&self, path: &std::path::Path) -> Result<(), String> { let project = ProjectFile { snarl: self.snarl.clone(), version: env!("CARGO_PKG_VERSION").to_string(), created: chrono::Local::now().to_rfc3339(), }; let json = serde_json::to_string_pretty(&project) .map_err(|e| format!("Serialization error: {e}"))?; std::fs::write(path, json) .map_err(|e| format!("File write error: {e}"))?; Ok(()) } /// Load a graph from a JSON file. pub fn load_graph(&mut self, path: &std::path::Path) -> Result<(), String> { let json = std::fs::read_to_string(path) .map_err(|e| format!("File read error: {e}"))?; let project: ProjectFile = serde_json::from_str(&json) .map_err(|e| format!("Deserialization error: {e}"))?; self.snarl = project.snarl; self.eval = EvalState::Idle; self.log_lines.clear(); self.log_lines.push(format!("Loaded graph from {}", path.display())); Ok(()) } } }` > **Note:** `chrono` is not in our `Cargo.toml` from [Chapter 2](https://danpokhrel.github.io/tutorial/ch02-project-setup.html) > . Add `chrono = "0.4"` to your `[dependencies]` before using it. Alternatively, if you do not need human-readable timestamps, use `std::time::SystemTime::now().duration_since(SystemTime::UNIX_EPOCH)` to get a Unix epoch duration that requires no extra dependency. We use `Result<(), String>` for human-readable errors and `map_err` to convert `io::Error` and `serde_json::Error` into our string error type. This follows [Rust Book Chapter 9.2](https://doc.rust-lang.org/stable/book/ch09-02-recoverable-errors-with-result.html) — recoverable errors returned as `Result`, not panics. The `ProjectFile` struct wraps the `Snarl` with version metadata. This is important for forward compatibility: if you change the node format in a future version, you can check the `version` field and migrate old files. The [`rfd`](https://crates.io/crates/rfd) (Rusty File Dialog) crate provides native OS file dialogs on desktop and async fallbacks on web: `#![allow(unused)] fn main() { use rfd::FileDialog; impl MyApp { /// Show a save dialog and save the graph. pub fn save_graph_dialog(&mut self) { if let Some(path) = FileDialog::new() .add_filter("JSON Flow", &["json"]) .set_file_name("my-flow.json") .save_file() { if let Err(e) = self.save_graph(&path) { self.log_lines.push(format!("Save error: {e}")); } else { self.log_lines.push(format!("Saved to {}", path.display())); } } } /// Show an open dialog and load a graph. pub fn open_graph_dialog(&mut self) { if let Some(path) = FileDialog::new() .add_filter("JSON Flow", &["json"]) .pick_file() { if let Err(e) = self.load_graph(&path) { self.log_lines.push(format!("Load error: {e}")); } } } } }` On native, `rfd::FileDialog::save_file()` and `pick_file()` are blocking calls that show the OS dialog. On web, `rfd` has async variants (`save_file_async()`, `pick_file_async()`) that use `wasm-bindgen-futures` under the hood. > **Tip:** For an in-window, egui-styled file browser (no OS dialog), the [`egui_file`](https://crates.io/crates/egui_file) > crate is an alternative. It renders a file picker as an egui window, which can be more consistent with your app’s look and feel. File dialogs can be async (especially on web), and they shouldn’t block `ui()`. The idiomatic pattern is to use a “pending action” flag — store the action on `App` state, and execute it when appropriate: `#![allow(unused)] fn main() { /// Actions that need to be processed (file operations, etc.) pub enum PendingAction { SaveGraph(std::path::PathBuf), LoadGraph(std::path::PathBuf), NewGraph, SaveGraphAs, // trigger the file dialog LoadGraphFrom, // trigger the file dialog } }` Wire the menu items (from [Chapter 7](https://danpokhrel.github.io/tutorial/ch07-input-menus.html) ) to set pending actions: `#![allow(unused)] fn main() { fn render_menu_bar(ui: &mut egui::Ui, app: &mut MyApp) { egui::MenuBar::new().ui(ui, |ui| { ui.menu_button("File", |ui| { if ui.button("New").clicked() { app.pending = Some(PendingAction::NewGraph); ui.close(); } if ui.button("Open...").clicked() { app.pending = Some(PendingAction::LoadGraphFrom); ui.close(); } ui.separator(); if ui.button("Save").clicked() { // If we have a current file, save to it; otherwise trigger dialog. if let Some(path) = &app.current_file { app.pending = Some(PendingAction::SaveGraph(path.clone())); } else { app.pending = Some(PendingAction::SaveGraphAs); } ui.close(); } if ui.button("Save As...").clicked() { app.pending = Some(PendingAction::SaveGraphAs); ui.close(); } }); }); } }` Then process pending actions in `logic()` (so `ui()` stays cheap): `#![allow(unused)] fn main() { fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { match self.pending.take() { Some(PendingAction::NewGraph) => { self.snarl = Snarl::new(); self.current_file = None; self.eval = EvalState::Idle; self.log_lines.clear(); } Some(PendingAction::SaveGraph(path)) => { if let Err(e) = self.save_graph(&path) { self.log_lines.push(format!("Save error: {e}")); } } Some(PendingAction::LoadGraph(path)) => { if let Err(e) = self.load_graph(&path) { self.log_lines.push(format!("Load error: {e}")); } } Some(PendingAction::SaveGraphAs) => { if let Some(path) = rfd::FileDialog::new() .add_filter("JSON Flow", &["json"]) .set_file_name("my-flow.json") .save_file() { self.current_file = Some(path.clone()); self.pending = Some(PendingAction::SaveGraph(path)); ctx.request_repaint(); } } Some(PendingAction::LoadGraphFrom) => { if let Some(path) = rfd::FileDialog::new() .add_filter("JSON Flow", &["json"]) .pick_file() { self.current_file = Some(path.clone()); self.pending = Some(PendingAction::LoadGraph(path)); ctx.request_repaint(); } } None => {} } } }` This pattern — set a flag in `ui()`, process it in `logic()` — is the same deferred-execution approach used in the reference tutorial’s context-menu node creation. It keeps the rendering path clean and lets you centralize mutation logic. eframe’s `auto_save_interval` handles periodic saves to the built-in `Storage`. But you may also want auto-save to the user’s last-saved file. A simple approach: track the last modification time and save if it’s been more than N seconds since the last save: `#![allow(unused)] fn main() { struct MyApp { last_modified: Option, last_autosave: Option, } impl eframe::App for MyApp { fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { // If the graph was modified and 30s have passed since last autosave... if let (Some(modified), Some(last_save)) = (self.last_modified, self.last_autosave) { if modified > last_save && modified.elapsed().as_secs() > 30 { if let Some(path) = &self.current_file { let _ = self.save_graph(path); self.last_autosave = Some(std::time::Instant::now()); self.log_lines.push("(auto-saved)".into()); } } } // Request a periodic repaint to check auto-save timers. ctx.request_repaint_after(std::time::Duration::from_secs(5)); } } }` `ctx.request_repaint_after(duration)` schedules a repaint after the given duration, which triggers `logic()` — perfect for periodic tasks like auto-save. A production editor should support Ctrl+S for save and Ctrl+O for open. We use the `KeyboardShortcut` API from [Chapter 7](https://danpokhrel.github.io/tutorial/ch07-input-menus.html) : `#![allow(unused)] fn main() { fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { let save_shortcut = egui::KeyboardShortcut::new(egui::Modifiers::CTRL, egui::Key::S); let open_shortcut = egui::KeyboardShortcut::new(egui::Modifiers::CTRL, egui::Key::O); ctx.input_mut(|i| { if i.consume_shortcut(&save_shortcut) { if let Some(path) = &self.current_file { self.pending = Some(PendingAction::SaveGraph(path.clone())); } else { self.pending = Some(PendingAction::SaveGraphAs); } } if i.consume_shortcut(&open_shortcut) { self.pending = Some(PendingAction::LoadGraphFrom); } }); // ... process pending actions ... } }` `consume_shortcut` (rather than `key_pressed`) prevents the same shortcut from firing twice when multiple widgets check it. See [Chapter 7](https://danpokhrel.github.io/tutorial/ch07-input-menus.html) for the full discussion. It’s important to be deliberate about what you serialize. The `Snarl` (with the `serde` feature) serializes: * The node data (your `AgentNode` enum, which derives `Serialize`/`Deserialize`) * Node positions (stored in the `Snarl`) * Wires/connections (stored in the `Snarl`) It does **not** serialize: * `EvalState` — ephemeral, resets each session * `log_lines` — ephemeral * `SnarlStyle` — can be recreated from code (though you can serialize it too if you want user-customizable styling) * `current_file` path — this is UI state, not graph data This separation follows the principle from [Chapter 5](https://danpokhrel.github.io/tutorial/ch05-architecture.html) : persistent data (graph, configuration) is separate from ephemeral UI state (evaluation progress, log lines, which panel is open). Keeping them in separate structs makes the serialization boundary explicit. * * * With persistence in place, our flow editor is a functional, usable application — users can build, run, save, and restore their AI flows. In [Chapter 17](https://danpokhrel.github.io/tutorial/ch17-production.html) we’ll take the final step to production: custom error types, testing, feature flags, logging, and web/WASM deployment. --- # Chapter 17: Production Architecture - Building a Node-Based AI Flow Editor with eframe & egui Keyboard shortcuts ------------------ Press ← or → to navigate between chapters Press S or / to search in the book Press ? to show this help Press Esc to hide this help * Auto * Light * Rust * Coal * Navy * Ayu Building a Node-Based AI Flow Editor with eframe & egui ======================================================= [](https://danpokhrel.github.io/tutorial/print.html "Print this book") Over the previous chapters we built a functional AI flow editor. Now let’s elevate it to production quality with proper error handling, testing, feature flags, logging, and web deployment. This chapter follows the principles from [Rust Book Chapter 9](https://doc.rust-lang.org/stable/book/ch09-00-error-handling.html) (error handling) and [Chapter 11](https://doc.rust-lang.org/stable/book/ch11-00-testing.html) (testing). > **Scope note: partially in the reference implementation.** The impl _does_ practice the testing half of this chapter: `graph/state.rs`, `ui/app.rs` (`seed_demo_graph`), and `ui/viewer.rs` (`read_concat_inputs`) each ship a `#[cfg(test)] mod tests` block — pure-data, headless, no GPU — exactly the discipline below. But the production _plumbing_ — the custom `AppError` enum, `tracing`/`tracing-subscriber`, Cargo `[features]`, the `WebRunner`/Trunk web build, and the workspace split — is **not** in the impl. Those remain forward-looking (and `tracing`, like `rfd`/`serde_json`/`chrono`, is flagged in-text as a dependency to add when you reach it). Instead of using `String` for errors (as we did in [Chapter 16](https://danpokhrel.github.io/tutorial/ch16-persistence.html) ), a production app should define a proper domain error enum. This lets callers match on specific error variants and enables the `?` operator to convert automatically: ``#![allow(unused)] fn main() { use std::fmt; #[derive(Debug)] pub enum AppError { Io(std::io::Error), Serde(serde_json::Error), /// A graph evaluation error (e.g., a cycle was detected). Graph(String), /// An evaluation runtime error. Eval(String), /// Initialization failure. Init(String), } impl fmt::Display for AppError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { AppError::Io(e) => write!(f, "I/O error: {e}"), AppError::Serde(e) => write!(f, "Serialization error: {e}"), AppError::Graph(msg) => write!(f, "Graph error: {msg}"), AppError::Eval(msg) => write!(f, "Evaluation error: {msg}"), AppError::Init(msg) => write!(f, "Initialization error: {msg}"), } } } impl std::error::Error for AppError { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { match self { AppError::Io(e) => Some(e), AppError::Serde(e) => Some(e), _ => None, } } } // From impls enable the `?` operator for automatic conversion. impl From for AppError { fn from(e: std::io::Error) -> Self { AppError::Io(e) } } impl From for AppError { fn from(e: serde_json::Error) -> Self { AppError::Serde(e) } } }`` This follows [Rust Book Chapter 9.2](https://doc.rust-lang.org/stable/book/ch09-02-recoverable-errors-with-result.html) exactly: define a domain error enum, implement `Display` and `Error`, and add `From` impls. The `source()` method enables error chaining for debugging — you can walk the entire cause chain. Now our save/load functions can use `?`: `#![allow(unused)] fn main() { impl MyApp { pub fn save_graph(&self, path: &std::path::Path) -> Result<(), AppError> { let json = serde_json::to_string_pretty(&self.snarl)?; // serde_json::Error → AppError std::fs::write(path, json)?; // io::Error → AppError Ok(()) } pub fn load_graph(&mut self, path: &std::path::Path) -> Result<(), AppError> { let json = std::fs::read_to_string(path)?; let project: ProjectFile = serde_json::from_str(&json)?; self.snarl = project.snarl; Ok(()) } } }` Because we separated the graph data model from the UI (the principle from [Chapter 5](https://danpokhrel.github.io/tutorial/ch05-architecture.html) ), we can test `GraphState`, `topo_sort`, and `evaluate_node` without a GPU or window. This is pure Rust unit testing, as described in [Rust Book Chapter 11.1](https://doc.rust-lang.org/stable/book/ch11-01-writing-tests.html) : `#![allow(unused)] fn main() { #[cfg(test)] mod tests { use super::*; use egui_snarl::{Snarl, NodeId}; fn make_test_flow() -> Snarl { let mut snarl = Snarl::new(); let _input = snarl.insert_node( egui::pos2(0.0, 0.0), AgentNode::ChatInput { message: "Hello".into() }, ); let _llm = snarl.insert_node( egui::pos2(200.0, 0.0), AgentNode::LLMNode { model: "gpt-4".into(), temperature: 0.7, system_prompt: "You are helpful.".into(), }, ); let _output = snarl.insert_node( egui::pos2(400.0, 0.0), AgentNode::OutputNode, ); snarl } #[test] fn test_topo_sort_no_cycle() { let snarl = make_test_flow(); let order = topo_sort(&snarl).expect("should sort"); // The first node must be ChatInput (it has no inputs). assert!(!order.is_empty()); } #[test] fn test_topo_sort_detects_cycle() { let mut snarl = make_test_flow(); // Create a cycle: connect Output → ChatInput. // (In a real test, you'd set up nodes that can form a cycle.) // assert!(topo_sort(&snarl).is_err()); } }` > **Note:** The cycle-detection test above is commented out because `make_test_flow` produces a DAG with no cycle. To test cycle detection, create a graph where a node’s output feeds back into its own input — e.g., connect `OutputNode`’s input to `ChatInput`’s output, creating a circular dependency. Uncomment and adjust the assertion once you have such a graph. `#![allow(unused)] fn main() { #[test] fn test_evaluate_chat_input() { let snarl = make_test_flow(); let results = HashMap::new(); let output = evaluate_node(&snarl, NodeId(0), &results); assert!(!output.is_empty(), "ChatInput should produce a string"); } #[test] fn test_connect_validates_types() { // Our agent nodes all use string pins, so connections are accepted. // In a more advanced system, you'd test type rejection. } #[test] fn test_serialize_roundtrip() { let snarl = make_test_flow(); let json = serde_json::to_string(&snarl).unwrap(); let restored: Snarl = serde_json::from_str(&json).unwrap(); assert_eq!(snarl.nodes().count(), restored.nodes().count()); } } }` Unit tests live in the same file as the code they test, inside a `#[cfg(test)] mod tests` block. Integration tests live in `tests/`. See [Rust Book Chapter 11.3](https://doc.rust-lang.org/stable/book/ch11-03-test-organization.html) for the full organization guidance. egui is immediate-mode and deterministic given identical input, which makes snapshot testing tractable. eframe has a built-in `__screenshot` feature for visual regression tests: 1. Enable the `__screenshot` feature in your `Cargo.toml`. 2. Set the `EFRAME_SCREENSHOT_TO` environment variable to an output path. 3. Run the app — it renders one frame, writes the screenshot, and quits. `EFRAME_SCREENSHOT_TO=screenshot.png cargo run --features __screenshot` You can compare the output against a known-good baseline in CI to catch visual regressions. > **Note:** egui itself uses this mechanism for its own screenshot tests. It’s most useful for catching accidental visual changes (a widget moved, a color shifted) rather than functional bugs. For headless testing, you can create a `Context` manually without eframe, feed it synthetic input, and run your UI: `#![allow(unused)] fn main() { #[test] fn test_ui_renders_without_panic() { let ctx = egui::Context::default(); let mut app = MyApp::default(); // Simulate a single frame. ctx.run_ui(egui::Id::new("test"), |_ctx| {}, |ui| { app.ui(ui, &mut eframe::Frame::default()); }); // Assert on the resulting state. assert!(!app.log_lines.is_empty() || app.log_lines.is_empty()); // didn't panic } }` This follows the pattern from the research reference: build a `Context` manually, feed synthetic `RawInput` events, and assert on state or paint output. The `logic`/`ui` split helps here — you can unit-test state transitions in `logic` without any rendering. For production, use Cargo feature flags to conditionally compile debugging tools or optional dependencies: `[features] default = ["serde-support", "persistence"] serde-support = ["dep:serde", "dep:serde_json"] debug-tools = [] # Enables the egui demo window for debugging persistence = ["eframe/persistence"]` `[dependencies] eframe = { version = "0.35", default-features = false, features = [ "default_fonts", "wgpu", "x11", "wayland" ] } egui-snarl = { version = "0.11", features = ["serde"] } serde = { version = "1", features = ["derive"], optional = true } serde_json = { version = "1", optional = true } tracing = "0.1" tracing-subscriber = "0.3"` Then use `#[cfg(feature = "...")]` to conditionally compile: `#![allow(unused)] fn main() { // Only show the egui demo window in debug builds. #[cfg(feature = "debug-tools")] { egui::Window::new("egui Demo") .open(&mut self.show_demo) .show(ui, |ui| { ui.label("Debug tools enabled."); // egui::demo::DemoWindow::default().ui(ui); }); } }` Feature flags are documented in the [Cargo Book](https://doc.rust-lang.org/cargo/reference/features.html) . The `cfg` attribute is covered in the [Rust Reference](https://doc.rust-lang.org/reference/conditional-compilation.html) . > **Note:** `tracing` and `tracing-subscriber` are not in our `Cargo.toml` from [Chapter 2](https://danpokhrel.github.io/tutorial/ch02-project-setup.html) > . Add them when you reach this chapter: `tracing = "0.1"` and `tracing-subscriber = "0.3"`. For production debugging, structured logging is essential. The [`tracing`](https://crates.io/crates/tracing) crate is the modern standard: `use tracing::{info, error, debug}; fn main() -> Result<(), AppError> { // Initialize the tracing subscriber. tracing_subscriber::fmt() .with_env_filter( env!("CARGO_PKG_NAME").to_owned() + "=debug" ) .init(); info!("Starting Flow Builder v{}", env!("CARGO_PKG_VERSION")); let options = eframe::NativeOptions { viewport: egui::ViewportBuilder::default() .with_inner_size([1280.0, 800.0]) .with_title("AI Flow Builder"), ..Default::default() }; eframe::run_native( "ai-flow-builder", options, Box::new(|cc| Ok(Box::new(MyApp::new(cc)?))), ).map_err(|e| { error!("eframe error: {e}"); AppError::Init(e.to_string()) })?; info!("Shutting down."); Ok(()) }` Set `RUST_LOG=ai_flow_builder=debug,eframe=info` to control log levels. The `info!`, `debug!`, and `error!` macros are zero-cost when the log level is disabled. eframe compiles to `wasm32-unknown-unknown` and renders to an HTML ``. The [`eframe_template`](https://github.com/emilk/eframe_template) repository gives you the full web setup. Here’s the essence: `#![allow(unused)] fn main() { use wasm_bindgen::prelude::*; #[derive(Clone)] #[wasm_bindgen] pub struct WebHandle { runner: eframe::WebRunner, } #[wasm_bindgen] impl WebHandle { #[wasm_bindgen(constructor)] pub fn new() -> Self { eframe::WebLogger::init(log::LevelFilter::Debug).ok(); Self { runner: eframe::WebRunner::new() } } #[wasm_bindgen] pub async fn start(&self, canvas: web_sys::HtmlCanvasElement) -> Result<(), JsValue> { self.runner .start( canvas, eframe::WebOptions::default(), Box::new(|cc| Ok(Box::new(MyApp::new(cc)?))), ) .await } #[wasm_bindgen] pub fn destroy(&self) { self.runner.destroy(); } #[wasm_bindgen] pub fn has_panicked(&self) -> bool { self.runner.has_panicked() } } }` ` AI Flow Builder ` The CSS is critical: without `overflow: hidden` and the canvas filling the page, the layout will scroll and the canvas won’t fill the window. `# Install Trunk. cargo install --locked trunk # Dev server with live reload. trunk serve # → http://localhost:8080 # Production build (outputs to dist/). trunk build --release` > **Note:** On web, `std::thread::spawn` is not available. For async/background work, use `wasm_bindgen_futures::spawn_local` instead. The `ctx.request_repaint()` pattern from [Chapter 15](https://danpokhrel.github.io/tutorial/ch15-live-execution.html) > works the same — just swap the thread for a future. egui 0.35 introduced an **inspection protocol** — a TCP server that exposes the live widget tree and can inject input. Enable eframe’s `inspection` feature, then launch with: `EGUI_INSPECTION=1 cargo run --release # Listens on port 5719 by default. # Override: EGUI_INSPECTION_ADDR=127.0.0.1:5719` The companion [`egui_mcp`](https://crates.io/crates/egui_mcp) crate is an MCP (Model Context Protocol) server on top, letting an AI agent _see_ and _operate_ your UI — read the widget tree, inject clicks and keystrokes, and capture screenshots. This is the basis for automated UI testing and AI-driven end-to-end tests in CI. As the project grows, consider splitting into a Cargo [workspace](https://doc.rust-lang.org/stable/book/ch14-03-cargo-workspaces.html) — separating the core library, the GUI binary, and any plugins: `ai-flow-builder/ ├── Cargo.toml # workspace root ├── crates/ │ ├── graph/ # pure graph model + evaluation (no egui) │ ├── app/ # the eframe application │ └── render/ # custom rendering widgets ├── src/ │ └── main.rs # binary entry point └── index.html # for Trunk (web)` `# Cargo.toml (root) [workspace] members = ["crates/graph", "crates/app", "crates/render"] resolver = "2" # REQUIRED — eframe pulls crates with mutually exclusive features [workspace.dependencies] egui = "0.35" eframe = "0.35" serde = { version = "1", features = ["derive"] }` Without `resolver = "2"` (or `edition = "2024"` which implies it), feature unification will break eframe’s build. 1. **Always use `--release`** for decent egui performance. Debug builds are visibly sluggish. Set `opt-level = 2` in `[profile.release]` and `[profile.dev.package."*"]`. 2. **Keep `ui()` cheap.** It runs every repaint. Heavy work goes in `logic()` or a background thread (see [Chapter 15](https://danpokhrel.github.io/tutorial/ch15-live-execution.html) ). 3. **Use virtualized lists** for large data sets. A plain `ScrollArea` lays out _all_ rows every frame — use `egui_virtual_list` for thousands of items. 4. **Clone `Context` for background threads**, not `&Context`. `Context` is `Arc`\-backed and cheaply clonable. 5. **Avoid `Id` clashes** in loops. Use `ui.push_id(i)` or explicit `Id::new(...)` (see [Chapter 4](https://danpokhrel.github.io/tutorial/ch04-layout-widgets.html) ). 6. **Profile with Puffin.** Enable the `profile-with-puffin` feature on eframe and show a puffin window in your app for flamegraph-style profiling. Here’s how a production `main.rs` ties everything together: `#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] mod app; mod error; mod eval; mod graph; mod theme; mod ui; use app::MyApp; use error::AppError; use tracing::info; fn main() -> Result<(), AppError> { tracing_subscriber::fmt() .with_env_filter(env!("CARGO_PKG_NAME").to_owned() + "=debug") .init(); info!("AI Flow Builder v{} starting...", env!("CARGO_PKG_VERSION")); let options = eframe::NativeOptions { viewport: egui::ViewportBuilder::default() .with_inner_size([1280.0, 800.0]) .with_min_inner_size([600.0, 400.0]) .with_title("AI Flow Builder"), ..Default::default() }; eframe::run_native( "ai-flow-builder", options, Box::new(|cc| Ok(Box::new(MyApp::new(cc)?))), ).map_err(|e| AppError::Init(e.to_string()))?; info!("Shutting down."); Ok(()) }` Clean, minimal, and production-ready. The `windows_subsystem = "windows"` attribute hides the console window on Windows release builds. Error handling uses our custom `AppError` type. Logging is initialized before anything else. * * * In [Chapter 18](https://danpokhrel.github.io/tutorial/ch18-conclusion.html) we’ll review everything we’ve built, highlight the key patterns, and point you toward resources for taking your flow editor further. --- # Chapter 9: Building Nodes & Pins - Building a Node-Based AI Flow Editor with eframe & egui Keyboard shortcuts ------------------ Press ← or → to navigate between chapters Press S or / to search in the book Press ? to show this help Press Esc to hide this help * Auto * Light * Rust * Coal * Navy * Ayu Building a Node-Based AI Flow Editor with eframe & egui ======================================================= [](https://danpokhrel.github.io/tutorial/print.html "Print this book") In [Chapter 8](https://danpokhrel.github.io/tutorial/ch08-egui-snarl.html) we built a working node graph with three node types, but we barely scratched the surface of what a node can display. Our `Sink` showed a connected value as a label, and our `Number` node showed a `DragValue` — but real graphs need richer pins, node bodies, colored headers, and the ability to edit values inline. In this chapter we unpack the `show_input`/`show_output` rendering pattern in full, learn the `PinInfo` toolkit, add node bodies and footers, and customize per-node-type headers. By the end you will be able to render nodes that show editable widgets when unconnected and live values when connected — the visual contract of every good node graph. Every pin on every node is rendered by a call to either `show_input` or `show_output` on your viewer. The signatures are fixed by the `SnarlViewer` trait: `#![allow(unused)] fn main() { use egui_snarl::{Snarl, InPin, OutPin, ui::SnarlPin}; fn show_input( &mut self, pin: &InPin, ui: &mut egui::Ui, snarl: &mut Snarl, ) -> impl SnarlPin + 'static; fn show_output( &mut self, pin: &OutPin, ui: &mut egui::Ui, snarl: &mut Snarl, ) -> impl SnarlPin + 'static; }` Three things come into each call: * **`pin`** — a handle to the pin being rendered. `InPin` has `.id: InPinId` and `.remotes: Vec` (the output pins wired into this input). `OutPin` has `.id: OutPinId` and `.remotes: Vec` (the inputs this output feeds). The `remotes` vector is how you know whether a pin is connected and to what. * **`ui`** — a `&mut egui::Ui` scoped to this pin’s row. You build widgets into it just like any other `Ui`: `ui.label(...)`, `ui.add(DragValue::new(...))`, and so on. Whatever you build here becomes the pin’s _label_, drawn next to the pin dot. * **`snarl`** — a `&mut Snarl`. This is what makes inline editing possible: you index `snarl[pin.id.node]` to get a `&mut` to the node’s data and mutate it directly while rendering. The return value, `PinInfo`, tells `egui-snarl` how to draw the _pin itself_ — the little dot, square, or triangle on the node’s border — and how wires attached to that pin should look. The body widgets you build into `ui` are separate from the pin shape. > **Tip:** The separation between “pin label” (your `ui` widgets) and “pin shape” (the `PinInfo` you return) is the key to the whole rendering model. The label is _content_ — it can be a `DragValue`, a `TextEdit`, a `ComboBox`, anything. The shape is _connection affordance_ — its color and form tell the user what kind of data fits here. `PinInfo` is a small builder-style struct. You start from a shape constructor and chain `.with_fill(color)` and `.with_wire_style(style)`: `#![allow(unused)] fn main() { use egui_snarl::ui::{PinInfo, WireStyle}; // A round red pin with default (bezier) wires. let red = PinInfo::circle().with_fill(egui::Color32::from_rgb(220, 80, 80)); // A square green pin with axis-aligned (right-angled) wires. let green = PinInfo::square() .with_fill(egui::Color32::from_rgb(80, 200, 80)) .with_wire_style(WireStyle::AxisAligned { corner_radius: 6.0 }); // A triangle blue pin. let blue = PinInfo::triangle().with_fill(egui::Color32::from_rgb(80, 120, 220)); }` The shape constructors are: | Constructor | Shape | | --- | --- | | `PinInfo::circle()` | A filled circle (the most common choice). | | `PinInfo::square()` | A filled square. | | `PinInfo::triangle()` | A filled triangle. | `.with_fill(color)` sets the fill color. `.with_wire_style(WireStyle::AxisAligned { corner_radius })` switches the wires for this pin from the default smooth bezier curve to right-angled segments with rounded corners of the given radius — useful for mimicking the look of professional shader-graph editors. We will go deeper on wire styling in [Chapter 12](https://danpokhrel.github.io/tutorial/ch12-styling-graph.html) . A useful convention, which we adopt throughout this book, is to color-code pins by data type: red for numbers, green for strings, blue for boolean, and so on. The user learns the color scheme quickly and can scan a graph for type mismatches at a glance. The single most important field on a pin handle is `remotes`. For an `InPin`, `remotes` is a `Vec` — the output pins that are wired into this input, in the order they were connected. For an `OutPin`, `remotes` is a `Vec` — the inputs this output feeds. To display the value a connected input receives, you look up the source node: ``#![allow(unused)] fn main() { use egui_snarl::{ui::{PinInfo, SnarlPin}, InPin, Snarl}; impl SnarlViewer for DemoViewer { (/* other methods */) fn show_input( &mut self, pin: &InPin, ui: &mut egui::Ui, snarl: &mut Snarl, ) -> impl SnarlPin + 'static { if let Some(remote) = pin.remotes.first() { // `remote` is an `OutPinId`; `remote.node` is the source node id. match &snarl[remote.node] { DemoNode::Number(v) => ui.label(format!("{v}")), DemoNode::Text(s) => ui.label(s), DemoNode::Sink => ui.label(""), }; PinInfo::circle().with_fill(egui::Color32::from_rgb(220, 80, 80)) } else { // Unconnected: show a placeholder and a dim pin. ui.label("—"); PinInfo::circle().with_fill(egui::Color32::from_rgb(120, 120, 120)) } } } }`` Two things are worth pausing on. First, `pin.remotes` is borrowed from `pin`, which is itself a borrow of the `Snarl`. Because `snarl[remote.node]` needs to borrow `snarl`, you can hit a borrow conflict if you try to hold the borrow from `remotes` while indexing `snarl`. The fix is to extract what you need _first_ — here we take `remote` (a `Copy` `OutPinId`) and then drop the borrow of `remotes` before indexing. This is the collect-then-mutate pattern from [Chapter 5](https://danpokhrel.github.io/tutorial/ch05-architecture.html) , and it is the Rust Book’s guidance on lifetimes ([Chapter 10.3](https://doc.rust-lang.org/stable/book/ch10-03-lifetime-syntax.html) ) applied in practice: keep borrows short. Second, `pin.remotes` can have more than one entry. An input pin accepts multiple wires by default — `remotes` is a `Vec`, not an `Option`. When there are several, you may want to show them all, or show only the first, or show a count. The `Sink` above shows only the first wired value, but a “merge” node might sum them: `#![allow(unused)] fn main() { let sum: f64 = pin .remotes .iter() .map(|remote| match &snarl[remote.node] { DemoNode::Number(v) => *v, _ => 0.0, }) .sum(); ui.label(format!("Σ = {sum}")); }` Here the borrow of `snarl` inside the `.map` closure is shared (`&Snarl`), which is fine because we only _read_. If you needed to _mutate_ based on `remotes`, you would first collect the ids into an owned `Vec`, end the borrow of `pin`, then mutate — exactly the pattern we will use in [Chapter 10](https://danpokhrel.github.io/tutorial/ch10-connections.html) . The visual contract of a good node graph is: **show an editable widget when a pin is unconnected, and show the live value when it is connected.** The user edits constants directly on source nodes; once a value flows in over a wire, the pin becomes read-only. For output pins, this is usually automatic — the node _owns_ the value, so you always show the editable widget. For input pins, you branch on `remotes.is_empty()`: `#![allow(unused)] fn main() { fn show_input(&mut self, pin: &InPin, ui: &mut egui::Ui, snarl: &mut Snarl) -> impl SnarlPin + 'static { if pin.remotes.is_empty() { // No wire: let the user type a value directly into this pin. // We mutate the node's own stored fallback value. let node = &mut snarl[pin.id.node]; match node { DemoNode::Number(v) => { ui.add(egui::DragValue::new(v).speed(0.1)); } DemoNode::Text(s) => { ui.text_edit_singleline(s); } DemoNode::Sink => { ui.label("(nothing connected)"); } } PinInfo::circle().with_fill(egui::Color32::from_rgb(120, 120, 120)) } else { // Wired: show the source value, read-only. if let Some(remote) = pin.remotes.first() { let src = &snarl[remote.node]; match src { DemoNode::Number(v) => ui.label(format!("{v}")), DemoNode::Text(s) => ui.label(s), DemoNode::Sink => ui.label(""), }; } PinInfo::circle().with_fill(egui::Color32::from_rgb(220, 80, 80)) } } }` Notice the borrow choreography in the unconnected branch: `pin.id.node` is a `Copy` `NodeId`, so we read it from the (shared) `pin` borrow, then call `&mut snarl[...]` — this works because we have stopped touching `pin.remotes`. The Rust Book’s [Chapter 4](https://doc.rust-lang.org/stable/book/ch04-02-references-and-borrowing.html) chapter on references is your friend here: a shared borrow can coexist with nothing mutable, but a mutable borrow stands alone. Output pins are conceptually simpler because the node _owns_ the value. You almost always show an editable widget — the user changes the value, the node stores it, and downstream nodes see the new value over the wire next frame: `#![allow(unused)] fn main() { fn show_output(&mut self, pin: &OutPin, ui: &mut egui::Ui, snarl: &mut Snarl) -> impl SnarlPin + 'static { let node = &mut snarl[pin.id.node]; match node { DemoNode::Number(value) => { ui.add(egui::DragValue::new(value).speed(0.1)); PinInfo::circle().with_fill(egui::Color32::from_rgb(220, 80, 80)) } DemoNode::Text(s) => { ui.text_edit_singleline(s); PinInfo::circle().with_fill(egui::Color32::from_rgb(80, 200, 80)) } DemoNode::Sink => unreachable!("Sink has no output pins"), } } }` You _can_ also read `pin.remotes` on an output pin to show where the value goes — for example, a tooltip “→ 2 inputs” — but for editable source values the node’s own data is the source of truth. So far, the only content on a node has been its pins. But many nodes want a _body_: content drawn between the input pins (left) and output pins (right). `egui-snarl` provides two provided methods for this: `#![allow(unused)] fn main() { impl SnarlViewer for DemoViewer { /// Return true if this node should have a body region. fn has_body(&mut self, node: &DemoNode) -> bool { matches!(node, DemoNode::Sink) } /// Draw the body. Receives the node id so you can read/mutate its data. fn show_body( &mut self, node_id: egui_snarl::NodeId, _inputs: &[InPin], _outputs: &[OutPin], ui: &mut egui::Ui, snarl: &mut Snarl, ) { if let DemoNode::Sink = &snarl[node_id] { ui.label("A sink displays its connected value."); } } } }` `has_body` defaults to `false`; override it to `true` and implement `show_body` to add the region. A common use is a live preview — for example, a node that renders a small color swatch computed from its inputs. Every node has a **header** by default — the bar at the top showing the node’s title. The provided method `show_header` draws it; you almost never override it. What you _do_ override is `header_frame`, which controls the `egui::Frame` (background, margin, rounding) of the header. We will use this in the next section to give each node type a colored bar. A node can also have a **footer** — content drawn below the pins. Like the body, it is opt-in: `#![allow(unused)] fn main() { impl SnarlViewer for DemoViewer { fn has_footer(&mut self, node: &DemoNode) -> bool { // Show a small status line on every node. true } fn show_footer( &mut self, node_id: egui_snarl::NodeId, _inputs: &[InPin], _outputs: &[OutPin], ui: &mut egui::Ui, snarl: &mut Snarl, ) { let node = &snarl[node_id]; let kind = match node { DemoNode::Number(_) => "number", DemoNode::Text(_) => "text", DemoNode::Sink => "sink", }; ui.label(format!("[{kind}]")); } } }` Footers are a good place for metadata that does not belong in a pin: a node id, a “muted” badge, an error count. Keep them short — a footer widens the whole node. > **Warning: provided method signatures vary.** Every provided method on `SnarlViewer` (`show_body`, `show_footer`, `header_frame`, `show_header`, `show_graph_menu`, etc.) has a _different_ argument list. `show_body` and `show_footer` take `inputs: &[InPin]` and `outputs: &[OutPin]`; `header_frame` takes a default `Frame`, a `NodeId`, the pin slices, and `&Snarl`; the menu methods take fewer arguments. Always consult the `SnarlViewer` trait definition in the egui-snarl source for the exact signature of the method you are overriding. The five _required_ methods (`title`, `inputs`, `outputs`, `show_input`, `show_output`) have consistent signatures, but the provided methods do not. `header_frame` returns an `egui::Frame` used for the header of a given node. By matching on the node data, you give each node type a distinct color bar — the visual cue that makes a graph readable at a glance: `#![allow(unused)] fn main() { impl SnarlViewer for DemoViewer { fn header_frame( &mut self, _frame: egui::Frame, node: NodeId, _inputs: &[InPin], _outputs: &[OutPin], snarl: &Snarl, ) -> egui::Frame { let node = &snarl[node]; let color = match node { DemoNode::Number(_) => egui::Color32::from_rgb(80, 50, 50), DemoNode::Text(_) => egui::Color32::from_rgb(40, 70, 40), DemoNode::Sink => egui::Color32::from_rgb(40, 40, 60), }; egui::Frame::default() .fill(color) .corner_radius(4.0) .stroke(egui::Stroke::new(1.0, egui::Color32::BLACK)) } } }` The signature gives you the node data (`&T`) and the active `SnarlStyle` (so you can respect global settings like corner radius). This is the pattern-matching-on-enums idea from the Rust Book’s [Chapter 6.2](https://doc.rust-lang.org/stable/book/ch06-02-match.html) : one `match` expression drives a per-variant decision, and the compiler guarantees we handled every variant. If we later add `DemoNode::Boolean`, the `match` will fail to compile until we pick a color for it — a free correctness check. > **Note: this is an optional enhancement.** The reference implementation’s `DemoViewer` does **not** override `header_frame` — it keeps snarl’s default header, so the “Complete Rich Node Example” below omits it. We show `header_frame` here because it is a common and useful customization; add it to your own viewer only if you want per-type color bars. Let us assemble a fuller viewer that uses everything so far: color-coded pins, connected-vs-unconnected branching, a body on the `Sink`, and colored headers. We extend the node enum with a `Concat` node (two text inputs, one text output) to show multi-pin rendering: ``#![allow(unused)] fn main() { use eframe::egui; use egui_snarl::{ ui::{PinInfo, SnarlPin, SnarlViewer}, InPin, InPinId, NodeId, OutPin, Snarl, }; #[derive(serde::Serialize, serde::Deserialize, Clone)] pub enum DemoNode { Number(f64), Text(String), /// Concatenates two text inputs into one output. Concat, Sink, } #[derive(Default)] pub struct DemoViewer; impl SnarlViewer for DemoViewer { fn title(&mut self, node: &DemoNode) -> String { match node { DemoNode::Number(_) => "Number".into(), DemoNode::Text(_) => "Text".into(), DemoNode::Concat => "Concat".into(), DemoNode::Sink => "Sink".into(), } } fn inputs(&mut self, node: &DemoNode) -> usize { match node { DemoNode::Number(_) | DemoNode::Text(_) => 0, DemoNode::Concat => 2, // two text inputs DemoNode::Sink => 1, } } fn outputs(&mut self, node: &DemoNode) -> usize { match node { DemoNode::Number(_) | DemoNode::Text(_) | DemoNode::Concat => 1, DemoNode::Sink => 0, } } fn has_body(&mut self, node: &DemoNode) -> bool { matches!(node, DemoNode::Concat | DemoNode::Sink) } fn show_body( &mut self, node_id: NodeId, _inputs: &[InPin], _outputs: &[OutPin], ui: &mut egui::Ui, snarl: &mut Snarl, ) { let node = &snarl[node_id]; match node { DemoNode::Concat => { // Show a live preview of the concatenation. let (a, b) = read_concat_inputs(node_id, snarl); ui.label(format!("\"{a}\" + \"{b}\"")); } DemoNode::Sink => { ui.label("displays its input"); } _ => {} } } fn show_input( &mut self, pin: &InPin, ui: &mut egui::Ui, snarl: &mut Snarl, ) -> impl SnarlPin + 'static { // `InPin` owns its `remotes` list, so borrowing it alongside `&snarl` // is fine — no clone needed. if let Some(remote) = pin.remotes.first() { match &snarl[remote.node] { DemoNode::Number(v) => ui.label(format!("{v}")), DemoNode::Text(s) => ui.label(s), DemoNode::Concat => ui.label("(concat)"), DemoNode::Sink => ui.label(""), }; PinInfo::circle().with_fill(egui::Color32::from_rgb(220, 80, 80)) } else { ui.label("—"); PinInfo::circle().with_fill(egui::Color32::from_rgb(120, 120, 120)) } } fn show_output( &mut self, pin: &OutPin, ui: &mut egui::Ui, snarl: &mut Snarl, ) -> impl SnarlPin + 'static { let node = &mut snarl[pin.id.node]; match node { DemoNode::Number(value) => { ui.add(egui::DragValue::new(value).speed(0.1)); PinInfo::circle().with_fill(egui::Color32::from_rgb(220, 80, 80)) } DemoNode::Text(s) => { ui.text_edit_singleline(s); PinInfo::circle().with_fill(egui::Color32::from_rgb(80, 200, 80)) } DemoNode::Concat => { ui.label("out"); PinInfo::circle().with_fill(egui::Color32::from_rgb(80, 200, 80)) } // `outputs()` returns 0 for Sink, so the widget never calls this. // Stay defensive anyway: a GUI should grey out, not crash, if a // future edit makes the pin counts inconsistent. DemoNode::Sink => { ui.label("—"); PinInfo::circle().with_fill(egui::Color32::from_rgb(120, 120, 120)) } } } } /// Helper: read the two inputs of a Concat node as displayable strings. /// /// Borrows from the snarl where possible (`Text`) so the per-frame call in /// `show_body` does not allocate; a wired `Number` is formatted on demand. /// Unwired or non-displayable inputs read as empty. fn read_concat_inputs<'a>( node_id: NodeId, snarl: &'a Snarl, ) -> (std::borrow::Cow<'a, str>, std::borrow::Cow<'a, str>) { let read = |input: usize| -> std::borrow::Cow<'a, str> { let in_pin = snarl.in_pin(InPinId { node: node_id, input }); match in_pin.remotes.first() { Some(remote) => match &snarl[remote.node] { DemoNode::Text(s) => std::borrow::Cow::Borrowed(s.as_str()), DemoNode::Number(v) => std::borrow::Cow::Owned(v.to_string()), _ => std::borrow::Cow::Borrowed(""), }, None => std::borrow::Cow::Borrowed(""), } }; (read(0), read(1)) } }`` A few things in this example deserve comment. `snarl.in_pin(InPinId { node, input })` returns an `InPin` handle — the same type `show_input` receives, but obtained on demand. This is how a _body_ (which only gets a `NodeId`) reads connected values: it asks the `Snarl` for the pin handle, inspects `.remotes`, and indexes the source node. There is a symmetric `snarl.out_pin(OutPinId { ... }) -> OutPin`. We discussed the borrow discipline this requires in the “Reading Connected Values” section above: we `clone` the `remotes` ids first so the shared borrow of `snarl.in_pin(...)` ends before we index into `snarl`. The `read_concat_inputs` helper is an example of pulling reusable logic out of the viewer — exactly the kind of factoring the Rust Book recommends in [Chapter 7](https://doc.rust-lang.org/stable/book/ch07-01-packages-and-crates.html) when projects grow. As your viewer grows, you will want helpers like this in their own module; we set up that module structure in [Chapter 5](https://danpokhrel.github.io/tutorial/ch05-architecture.html) . Because `show_input` and `show_output` receive `&mut Snarl`, you can mutate node data _while rendering_. This is how inline editing works: there is no “edit buffer” — the `DragValue` directly mutates the `f64` inside `DemoNode::Number`, and the `TextEdit` directly mutates the `String` inside `DemoNode::Text`. Next frame, the new value is simply there. This is powerful but demands care with borrow ordering. The recurring gotcha is `pin.remotes` and `snarl[node]` both borrowing the `Snarl`. In our `show_input` we sidestep it cheaply: `InPin` _owns_ its `remotes` list (it is not a borrow of the `Snarl`’s internal storage), so reading `pin.remotes.first()` to get a `Copy` `OutPinId` and then indexing `&snarl[remote.node]` is sound — the borrow of `remotes` is tied to `pin`, not to a `&Snarl` borrow that conflicts with the indexing. The only thing you must _not_ do is hold a `&mut Snarl` borrow (from `snarl[node_id] = ...`) while still reading `pin.remotes`. The rule that always works: **read `Copy` ids out of the pin first (or rely on `InPin` owning its `remotes`), then index the `Snarl`.** When you genuinely need to _mutate_ based on `remotes` — for instance in `connect` ([Chapter 10](https://danpokhrel.github.io/tutorial/ch10-connections.html) ) or `drop_outputs` — copy the ids into an owned `Vec` first (`let remotes: Vec<_> = pin.remotes.iter().copied().collect();`), end the pin borrow, then mutate. The tiny allocation makes the borrow trivially sound. This discipline is the Rust Book’s [Chapter 4](https://doc.rust-lang.org/stable/book/ch04-02-references-and-borrowing.html) advice in miniature: a mutable borrow must be the _only_ borrow of that data. When two parts of your code both need the `Snarl`, stagger the borrows so they never overlap. In this chapter we learned the full pin rendering model: `show_input` and `show_output` receive a pin handle, a `Ui`, and a `&mut Snarl`, and they return a `PinInfo` describing the pin’s shape and wire style. We learned to read connected values through `pin.remotes`, to render editable widgets when unconnected and live values when connected, to add bodies and footers, and to color headers per node type. The borrow choreography — extract `Copy` ids, end the pin borrow, then index the `Snarl` — is the one pattern you must internalize. Our graph now looks good, but we have not controlled _which_ wires the user is allowed to draw. In [Chapter 10](https://danpokhrel.github.io/tutorial/ch10-connections.html) we will override `connect` and `disconnect` to validate connections by data type, and learn the wire-styling and dropped-wire tools. --- # Chapter 18: Conclusion & Resources - Building a Node-Based AI Flow Editor with eframe & egui Keyboard shortcuts ------------------ Press ← or → to navigate between chapters Press S or / to search in the book Press ? to show this help Press Esc to hide this help * Auto * Light * Rust * Coal * Navy * Ayu Building a Node-Based AI Flow Editor with eframe & egui ======================================================= [](https://danpokhrel.github.io/tutorial/print.html "Print this book") Congratulations! You’ve traveled from an empty `cargo new` to a production-grade node graph editor and agentic AI flow builder. Let’s review what we accomplished and where to go from here. Starting from a single-window “hello world” in [Chapter 3](https://danpokhrel.github.io/tutorial/ch03-first-window.html) , we built a complete, modern application with: Separate graph model (`graph/`), evaluation engine (`eval/`), UI rendering (`ui/`), and app wiring (`app.rs`), following [Rust Book Chapter 7](https://doc.rust-lang.org/stable/book/ch07-00-managing-growing-projects-with-packages-crates-and-modules.html) and [Chapter 17.3](https://doc.rust-lang.org/stable/book/ch17-03-oo.html) . The graph model and evaluator have zero egui dependencies — they’re testable without a GPU or window. Using [`egui-snarl`](https://github.com/zakarumych/egui-snarl) , we built an interactive canvas with draggable nodes, type-validated wire connections, context menus for adding and removing nodes, per-node-type color coding, and a custom dark theme with a grid background. We defined agent node types (`ChatInput`, `PromptTemplate`, `LLMNode`, `ToolNode`, `MemoryNode`, `OutputNode`) inspired by [Langflow](https://www.langflow.org/) and [Flowise](https://flowiseai.com/) , implemented a topological-sort evaluation engine, and added live streaming execution with per-node progress highlighting. eframe’s built-in persistence for app/UI state (window position, panel sizes) and manual serde-JSON save/load for the graph data, with native file dialogs via `rfd`. Custom error types, unit tests for the graph model and evaluator, feature flags, structured logging with `tracing`, and a web/WASM deployment path via Trunk. Here’s the final module structure: `ai-flow-builder/ ├── Cargo.toml ├── src/ │ ├── main.rs # entry point: tracing init, eframe::run_native │ ├── app.rs # MyApp struct, impl eframe::App │ ├── error.rs # AppError enum with Display + From impls │ ├── graph/ │ │ ├── mod.rs # re-exports │ │ └── nodes.rs # AgentNode enum, PinInfo colors │ ├── eval.rs # topo_sort, evaluate_node, simulate_llm │ ├── ui/ │ │ ├── mod.rs # render() top-level function │ │ ├── toolbar.rs # Run/Stop/Clear buttons │ │ ├── viewer.rs # SnarlViewer impl │ │ └── console.rs # execution log panel │ └── theme.rs # dark Visuals, SnarlStyle, color constants` The key architectural decisions: 1. **Separation of concerns** — graph data, evaluation logic, and rendering are in separate modules (Ch. 7). The graph and eval modules have no `egui` dependency. 2. **The `SnarlViewer` trait** — drives rendering for each node type without the graph container knowing about UI (Ch. 8–9). 3. **Collect-then-mutate** — interaction data is collected with an immutable borrow, then mutations happen after the borrow ends (Ch. 5, 9). 4. **`logic`/`ui` split** — state updates and evaluation happen in `logic()`; rendering only reads state in `ui()` (Ch. 5, 15). 5. **`ctx.request_repaint()` for streaming** — drives frame-by-frame evaluation without blocking the UI (Ch. 15). 6. **Serde for persistence** — the `Snarl` serializes cleanly to JSON (Ch. 16). These patterns transfer beyond egui to any Rust GUI or systems project: * **Immediate mode** — UI is rebuilt every frame from your state. No widget tree to sync. State lives in your `App` struct, mutated in `ui()`/`logic()`. * **The `Response` object** — `ui.add(widget)` returns a `Response` with `clicked()`, `hovered()`, `on_hover_text()`. Always return it from custom widgets so callers can chain. * **Stable `Id`s** — stateful widgets need stable `egui::Id`s. Use `ui.push_id(i)` in loops to avoid clashes. * **Clone `Context` for threads** — `egui::Context` is `Arc`\-backed. Clone it, pass to a background thread, call `ctx.request_repaint()` when done. * **Newtype IDs** — `NodeId(pub i32)` gives type safety at zero runtime cost (Rust Book Ch. 19.3). * **Error enums with `From` impls** — domain errors as enums, with `From` for automatic `?` conversion (Rust Book Ch. 9.2). * **Deferred execution** — set a “pending action” flag in `ui()`, process it in `logic()`. Keeps the rendering path clean. Here are directions to take your flow editor further: Replace the simulated LLM responses with real API calls. The [`ehttp`](https://crates.io/crates/ehttp) crate works on both native and web (uses `fetch` on WASM), making it ideal for cross-platform HTTP: `#![allow(unused)] fn main() { let request = ehttp::Request::post( "https://api.openai.com/v1/chat/completions", body_bytes, ); let ctx = ctx.clone(); ehttp::fetch(request, move |result: ehttp::Result| { // Parse the response, store the result, call ctx.request_repaint(). }); }` Design a trait-based plugin system where users can define their own node types: `#![allow(unused)] fn main() { pub trait NodeDefinition: Send + Sync { fn name(&self) -> &str; fn inputs(&self) -> Vec; fn outputs(&self) -> Vec; fn evaluate(&self, inputs: &[String]) -> String; } }` Implement undo/redo with the command pattern — every mutation (add node, connect, delete) creates a `Command` that can be undone. Store a stack of commands on `App`. Ctrl+Z / Ctrl+Shift+Z trigger undo/redo. Use [`egui_tiles`](https://crates.io/crates/egui_tiles) or [`egui_dock`](https://crates.io/crates/egui_dock) to support multiple open graphs in a tabbed or docked interface, similar to a real IDE. Export the visual flow as executable Rust or Python code, or as a REST API endpoint that runs the flow on demand. This turns your visual builder into a deployable service. * **Subgraphs** — nodes that contain their own graphs (recursive composition). * **Reroute nodes** — invisible nodes that just route wires, for cleaner layouts. * **Minimap** — a zoomed-out overview of the entire graph for navigation (egui-snarl doesn’t have a built-in minimap, but you can draw one with a custom `PaintCallback`). * **Searchable node palette** — a filterable list of available node types for the “Add Node” menu. #### [eframe](https://crates.io/crates/eframe) The official framework for writing apps with egui. [docs.rs](https://docs.rs/eframe) · [repo](https://github.com/emilk/egui/tree/master/crates/eframe) #### [egui](https://crates.io/crates/egui) The core immediate-mode UI library. [docs.rs](https://docs.rs/egui) · [demo app](https://www.egui.rs/) #### [egui-snarl](https://crates.io/crates/egui-snarl) Node-graph widget for egui. [docs.rs](https://docs.rs/egui-snarl) · [repo + demo](https://github.com/zakarumych/egui-snarl) #### [egui\_extras](https://crates.io/crates/egui_extras) Image loaders, table/strip builders, date picker. [docs.rs](https://docs.rs/egui_extras) #### [rfd](https://crates.io/crates/rfd) Native OS file/message dialogs (web + native). [docs.rs](https://docs.rs/rfd) #### [serde](https://crates.io/crates/serde) Serialization framework. [docs.rs](https://docs.rs/serde) · [guide](https://serde.rs/) #### [egui\_tiles](https://crates.io/crates/egui_tiles) Tiling/dock layout engine. [docs.rs](https://docs.rs/egui_tiles) #### [ehttp](https://crates.io/crates/ehttp) egui-adjacent HTTP client (native + web). [docs.rs](https://docs.rs/ehttp) * [The Rust Programming Language](https://doc.rust-lang.org/stable/book/) — the canonical Rust tutorial. This tutorial cross-references it throughout. * [egui docs](https://docs.rs/egui) — API documentation for every widget, layout, and context method. * [eframe docs](https://docs.rs/eframe) — the `App` trait, `NativeOptions`, `WebRunner`, and persistence API. * [egui-snarl demo](https://github.com/zakarumych/egui-snarl/blob/main/examples/demo.rs) — the reference implementation of a complete `SnarlViewer`. Read it alongside this tutorial. * [eframe\_template](https://github.com/emilk/eframe_template) — the official web+native starter. Clone it for your web deployment setup. * [egui wiki/FAQ](https://github.com/emilk/egui/wiki) — community knowledge base. Real-world node-based AI flow builders to study: * [Langflow](https://www.langflow.org/) — Python-based visual framework for composing LLMs, retrieval, and agents. Open source, supports multi-agent orchestration and streaming. * [Flowise](https://flowiseai.com/) — Node.js platform with three builder interfaces (Assistant, Chatflow, Agentflow). Enterprise features like RBAC and evaluations. * [n8n](https://n8n.io/) — General-purpose workflow automation with AI agent nodes. Combines business logic orchestration with LLM capabilities. * [LangGraph](https://github.com/langchain-ai/langgraph) — Explicit graph model for agent orchestration with checkpoints, pausing, and human-in-the-loop. Each of these demonstrates different architectural choices for the same problem: how to let users visually compose AI workflows. Our Rust/egui implementation proves that a performant, type-safe, cross-platform alternative is achievable. * * * You’ve built a production-grade node graph editor and agentic AI flow builder in Rust with a clean architecture, modern styling, full interactions, streaming execution, persistence, and a web deployment path. The patterns you’ve learned — immediate mode, the `SnarlViewer` trait, collect-then-mutate, async with `request_repaint`, serde persistence, and the `logic`/`ui` split — are the same patterns that make any egui application maintainable and performant. Go build something amazing. 🦀 --- # Building a Node-Based AI Flow Editor with eframe & egui Keyboard shortcuts ------------------ Press ← or → to navigate between chapters Press S or / to search in the book Press ? to show this help Press Esc to hide this help * Auto * Light * Rust * Coal * Navy * Ayu Building a Node-Based AI Flow Editor with eframe & egui ======================================================= [](https://danpokhrel.github.io/tutorial/print.html "Print this book") Build a clean, modern **node graph editor** in Rust from scratch using `eframe` and `egui` — and progress all the way to a working **node-based agentic AI flow builder**, the kind you see in [Langflow](https://www.langflow.org/) and [Flowise](https://flowiseai.com/) . By the end of this tutorial you will have a production-grade desktop (and web-capable) application that lets users visually compose AI agent pipelines — connecting LLM nodes, prompt templates, tools, and memory components with drag-and-drop wires, then executing the resulting flow live with streaming output. By the end of this tutorial, you will have a fully functional node graph editor — the kind you see in shader editors, compositing tools, and visual scripting systems — extended into a visual builder for agentic AI workflows. It will feature: Draggable nodes with input/output pins, title bars, and embedded widgets for configuring prompts, models, and parameters. Drag-to-create wires between pins, with type validation and one-click deletion. A custom dark theme with styled pins, colored wires, a grid background, and per-node-type color coding. A graph evaluation engine that performs topological sorting and executes the flow — with streaming output rendered live into the UI. Save and restore the entire graph layout and configuration to and from disk, plus eframe’s built-in state persistence. The same codebase compiles to native desktop _and_ WebAssembly, so your flow builder can run in a browser. This tutorial assumes you have read through **Chapters 1–10** of [The Rust Book](https://doc.rust-lang.org/stable/book/) — covering ownership, borrowing, structs, enums, error handling, modules, and traits. You should be comfortable with: * Creating a project with `cargo new` * Writing `impl` blocks for your own types * Using `Result` and the `?` operator * Understanding the difference between `String` (owned) and `&str` (borrowed) Throughout this tutorial, we link to specific chapters of [The Rust Programming Language](https://doc.rust-lang.org/stable/book/) . These references ground our architectural decisions in the canonical Rust patterns the book teaches. A few callouts reach ahead to later chapters — `move` closures (Ch. 13.1), the `Drop` trait / RAII (Ch. 15.3), and the newtype pattern (Ch. 19.3) — each is briefly introduced where it first appears, so you don’t need to have read those chapters in advance. [`egui`](https://github.com/emilk/egui) is a pure-Rust **immediate-mode GUI library**. In immediate mode, the UI is re-built every frame from scratch — there is no retained widget tree. This makes it exceptionally fast to iterate on and ideal for developer tools, editors, and real-time applications. It has no windowing, no event loop, and no rendering backend of its own — it only _describes_ UI and handles layout and input. [`eframe`](https://github.com/emilk/egui/tree/master/crates/eframe) is the **official framework** for writing apps with egui. The name stands for both “egui frame” and “egui framework”. It wraps egui with everything needed to actually run: a window and event loop (`egui-winit`), a renderer (`egui-wgpu` by default), clipboard, persistence, web/WASM integration, and accessibility (AccessKit). Most apps only need to depend on `eframe` — it re-exports `egui`, `emath`, `epaint`, and the rendering crates. The rule of thumb: if you want a standalone desktop or web GUI app in Rust, start with `eframe`. If you’re embedding egui into a game engine (Bevy, macroquad) or a custom rendering surface, use `egui` + a specific integration crate instead. [`egui-snarl`](https://github.com/zakarumych/egui-snarl) is a customizable **node-graph widget** for egui — think visual scripting, shader graphs, material editors. (“Snarl” = what a complex graph looks like.) It is the most popular egui node-graph library. It provides a generic `Snarl` container that stores positioned nodes and wires, and a `SnarlViewer` trait you implement to drive the UI for each node type. [`serde`](https://serde.rs/) provides serialization and deserialization. We use it to save and load the entire graph — nodes, connections, and configuration — to JSON files. eframe’s built-in persistence also uses serde under the hood. egui is a layered ecosystem. Understanding the layers helps you reason about what each crate is responsible for: `┌─────────────────────────────────────────────┐ │ Your App (impl App) │ ├─────────────────────────────────────────────┤ │ eframe │ ← framework: event loop, web/native glue, persistence │ ┌───────────────┐ ┌────────────────────┐ │ │ │ egui-winit │ │ egui-wgpu/glow │ │ ← platform integration + rendering │ └───────┬───────┘ └─────────┬──────────┘ │ │ ▼ ▼ │ │ ┌────────────────────────────────────────┐ │ │ │ egui │ │ ← immediate-mode UI library (widgets, layout, ctx) │ │ ┌──────────┐ ┌────────┐ ┌────────┐ │ │ │ │ │ emath │ │ epaint │ │ ecolor │ │ │ ← math, tessellation/paint, colors │ │ └──────────┘ └────────┘ └────────┘ │ │ │ └────────────────────────────────────────┘ │ └─────────────────────────────────────────────┘` * **egui** is the _library_ — pure UI, layout, input; no windowing or rendering. * **eframe** is the _framework_ — it wraps egui with winit, a renderer, clipboard, persistence, WASM, and AccessKit. eframe re-exports `egui`, `emath`, `epaint`, and `egui_wgpu`/`wgpu` (and `egui_glow`/`glow` if the glow feature is on), so most apps only need to depend on `eframe`. This tutorial is pinned to the following stable releases (July 2026): | Crate | Version | | --- | --- | | `eframe` / `egui` | **0.35.0** (released 2026-06-25) | | `egui-snarl` | **0.11.0** | | `egui_extras` | **0.35.0** | | MSRV | Rust **1.92.0** | | Edition | **2024** | > **Important:** egui ships breaking changes in most minor version bumps. If you are using a different version, some API signatures may differ. The [migration guide](https://danpokhrel.github.io/tutorial/print.html#chapter-17-production-architecture) > chapter covers the key changes across versions. This tutorial is organized into five parts, each building on the previous: **Part 1 — Foundations** (Chapters 1–4) introduces the egui ecosystem, sets up a project, opens your first window, and covers the core building blocks: panels, layouts, widgets, and stateful UI. **Part 2 — Application Architecture** (Chapters 5–7) elevates the project from a prototype to a structured application: the `App` trait’s `logic`/`ui` split, theming and fonts, and input handling with menus and dialogs. **Part 3 — The Node Graph Editor** (Chapters 8–12) brings in `egui-snarl` and builds a full node graph editor: nodes, pins, wires, context menus, selection, and custom styling. **Part 4 — Building an Agentic AI Flow** (Chapters 13–16) turns the node editor into an agentic AI flow builder: defining agent node types (LLM, prompt, tool, memory), a graph evaluation engine, live streaming execution, and persistence. **Part 5 — Production** (Chapters 17–18) covers error types, testing, feature flags, logging, and web/WASM deployment — everything you need to ship a production-grade application. > **Scope of the reference implementation.** The companion `tutorial-implementation` crate realizes the **core node-graph editor** built across Parts 1–3 (Chapters 1–12): an `eframe` app with a custom dark theme, a four-node `DemoNode` graph (`Number` / `Text` / `Concat` / `Sink`), a `SnarlViewer` with bodies, footers, and add/remove context menus, plus the pure-data `graph/` module and its headless unit tests. It deliberately stops there: the type-validated `connect` (Ch. 10), the `SnarlStyle`/`header_frame` styling (Ch. 12), and all of Part 4 — agent nodes, graph evaluation, live streaming, and JSON persistence — are **forward-looking material the reference implementation does not (yet) ship**. Those chapters teach the patterns you would add next; they are accurate egui/egui-snarl pedagogy, but their code is not mirrored in the implementation. Each such chapter now opens with a short callout noting this. Code blocks are the heart of this tutorial. We use `rust` fenced blocks for all Rust code: `fn main() { println!("Hello, egui!"); }` > **Note:** The `no_run` attribute tells mdBook’s syntax highlighter to render the code without trying to compile it during tests. Throughout, we use blockquote callouts for tips, warnings, and notes — just like this one. Let’s begin with [Chapter 1: What Is eframe?](https://danpokhrel.github.io/tutorial/print.html#chapter-1-what-is-eframe) . This chapter introduces the two crates at the heart of this book—egui and eframe—and explains how they relate to each other and to the rest of the Rust GUI ecosystem. Before we write a single line of UI code, it is worth understanding the architecture you are about to build on, because the mental model of “immediate mode” will shape every decision you make in later chapters. If you have worked with retained-mode toolkits like Qt, GTK, or the web’s DOM, some of these ideas will feel backwards at first—that is normal, and we will take it slowly. **egui** is an immediate-mode GUI library written in pure Rust. “Immediate mode” means that, instead of constructing a tree of widget objects that live in memory and get mutated over time, you call functions every frame that _describe_ what the user interface should look like right now. egui takes that description, figures out what changed, and paints the result. There is no widget tree, no signal/slot wiring, and no hidden stateful object per button. egui itself knows nothing about windows, keyboards, mice, or rendering surfaces. It is a pure logic crate: you feed it input events, it hands you back a list of triangles to draw. That separation is deliberate—it lets egui run anywhere, from a desktop window to a web canvas to a game engine overlay. > **A note on the name.** “egui” is pronounced “e-goo-ee.” The “e” originally stood for _emerald_; these days most people just say “egui.” **eframe** is the framework crate that wraps egui and makes it usable as a complete application. Where egui says “draw these triangles,” eframe says “I will create the window, pump platform events, load fonts, talk to the clipboard, persist your state to disk, and hand egui a wgpu or glow surface to paint on.” In other words, eframe is the “batteries-included” layer so that you can focus on your app rather than windowing boilerplate. Concretely, eframe gives you: * A **window** and event loop via `winit` (on desktop) or the browser canvas (on the web). * A **renderer** backed by `wgpu` (the default) or `glow` (OpenGL). * **Clipboard** integration for copy/paste. * **Persistence**: save and restore your `App` state across runs using `epi::App::save`. * **WASM** support so the same code runs in a browser. * **AccessKit** integration for accessibility (screen readers, etc.). * Sensible **defaults**: fonts, visuals, dark/light theme, DPI handling. If you have used `wgpu` or `winit` directly, you know how much ceremony is involved just to get a blank window on screen. eframe exists so you do not have to write that ceremony. Your application sits on top of a layered stack. Each layer has one job and delegates to the one below it: `┌──────────────────────────────────┐ │ Your App │ <- your logic & state │ (implements eframe::App) │ └──────────────┬───────────────────┘ │ ┌──────────────▼───────────────────┐ │ eframe │ <- window, loop, persistence, │ (app framework) │ clipboard, AccessKit, WASM └──────────────┬───────────────────┘ │ ┌──────────────▼───────────────────┐ │ egui-winit egui-wgpu / glow │ <- glue: events -> egui input, │ │ egui output -> rendering └──────┬─────────────┬─────────────┘ │ │ ┌──────▼─────┐ ┌────▼──────────────┐ │ egui │ │ wgpu / glow │ <- GUI logic / GPU painting └──────┬─────┘ └───────────────────┘ │ ┌───────────┼───────────┐ │ │ │ ┌──▼──┐ ┌─────▼─────┐ ┌──▼─────┐ │emath│ │ epaint │ │ecolor │ <- math / tessellation / colors └─────┘ └──────────┘ └────────┘` * **Your App** implements the `eframe::App` trait. This is where your state lives and where you build your UI each frame. * **eframe** owns the event loop and the window, creates the renderer, and calls into your app at the right moments. * **egui-winit / egui-wgpu** are the “glue” crates: egui-winit translates `winit` events into egui’s input format, and egui-wgpu paints egui’s tessellated mesh onto a wgpu surface. * **egui** is the pure-logic GUI crate: layouts, widgets, input state, clipping, etc. * **emath / epaint / ecolor** are the small leaf crates egui depends on—math primitives, triangle tessellation, and color types respectively. You will almost never touch the crates below egui directly. Knowing the stack exists, though, helps you read error messages and understand which crate owns which behavior. The single most important concept in this book is the difference between immediate-mode and retained-mode UI. Let’s make it concrete. In a retained-mode toolkit (GTK, Qt, the DOM), you create a button object, add it to a layout object, connect a click handler, and the button lives in memory until you destroy it: ``#![allow(unused)] fn main() { // (Pseudocode—not egui) let mut button = Button::new("Click me"); button.on_click(|| { /* ... */ }); layout.add(&button); // `button` persists; the toolkit owns it. }`` In egui, there is no button object that outlives the frame. Each frame, you call a function that says “draw a button here, and tell me if it was clicked this frame”: `#![allow(unused)] fn main() { // egui if ui.button("Click me").clicked() { // ran only on the frame the click happened } }` The implications are profound: * **No widget state to sync.** There is no `Button` object whose `enabled` property you must keep in sync with your model. You just read your model and call the right function. For more on why avoiding shared mutable state is a theme of Rust in general, see [Chapter 4 of the Rust Book](https://doc.rust-lang.org/stable/book/ch04-00-understanding-ownership.html) . * **No callback wiring.** Event handling is a return value (`clicked()`), not a registered closure, so ownership stays simple. * **State lives in your structs.** egui holds no business state. A text field’s contents live in _your_ `String` field, not in a widget object. The UI is a pure projection of your state. * **Everything is rebuilt every frame.** This sounds expensive, but egui is fast: a typical frame rebuilds the whole UI in well under a millisecond. The trade-off is simplicity for the developer at the cost of some CPU per frame—but only on frames that actually run. > **Tip: the mental shift.** If you find yourself wanting to “get a reference to a widget and change it later,” stop. In egui you change your data; the widget reflects that next frame automatically. eframe is the right default, but egui is designed to be embeddable, so there are other hosts. Use this decision tree: 1. **Are you embedding UI inside a game engine (Bevy, Fyrox)?** Use `bevy_egui` (or the engine’s egui integration). You already have a window and a render loop; eframe would fight it. 2. **Do you need a custom window manager, multiple separate viewports, or a non-winit platform?** Use raw `egui` + `egui-winit` + `egui-wgpu` directly. You are taking on eframe’s job yourself, but you get full control. 3. **Are you writing a normal desktop or web app?** Use **eframe**. It is what this book covers, and it will handle 95% of real projects. In short: eframe is “egui as an application,” while the lower crates are “egui as a library.” egui shines for **developer-facing and power-user-facing tools**: * **Developer tools**: debug viewers, profilers, log inspectors. * **Dashboards**: telemetry, monitoring, data exploration. * **Editors**: level editors, configuration tools, asset browsers. * **Node graphs**: visual programming, shader graphs, material editors (this is exactly the domain of `egui-snarl`, which we use later in the book). egui is weaker in a few areas—know them before committing: * **Pixel-perfect mobile design.** egui is not layout-engine-precise the way a native mobile toolkit is. Touch hit-targeting and responsive breakpoints require manual work. * **Rich text editing.** There is no rich-text contenteditable widget; the `TextEdit` is single-style plain text plus some formatting. * **Deep native OS integration.** egui draws its own widgets, so they do not look native. If matching the OS chrome exactly is a hard requirement, consider a native toolkit instead. For the node-graph editor we build across this book, egui is an excellent fit. Every frame, eframe runs the same pipeline. Understanding this loop is the key to understanding why egui code looks the way it does: `platform events (input, resize, etc.) │ ▼ ┌───────────────────────────┐ │ App::logic (0.34+) │ <- state updates, async, animations │ ctx, frame │ NO UI calls here └────────────┬──────────────┘ │ ▼ ┌───────────────────────────┐ │ App::ui (required) │ <- build widgets from current state │ ui, frame │ keep this cheap & pure-ish └────────────┬──────────────┘ │ ▼ ┌───────────────────────────┐ │ egui tessellates │ <- primitives -> triangle mesh └────────────┬──────────────┘ │ ▼ ┌───────────────────────────┐ │ wgpu / glow paints │ <- GPU draws the mesh └───────────────────────────┘` Two things stand out. First, the **logic/ui split** (introduced in egui 0.34, carried into 0.35): `App::logic` runs _before_ `ui` and is meant for state mutation—reading input, advancing animations, polling async results—while `App::ui` only reads that state and builds the widget list. The old `App::update` method that did both is **removed in 0.35**; if you see old tutorials calling `update`, they are out of date. Second, the repaint model. egui does **not** render at a fixed 60 FPS by default. When nothing is happening, the application goes idle and uses essentially zero CPU. It only repaints when something requests a repaint: * User input (a click, a keystroke, a mouse move over the window) requests a repaint automatically. * Animations and timers inside egui request repaints as needed. * **Your background work** must request a repaint explicitly, or the UI will appear frozen until the next input event. If you spawn a background thread that produces data the UI should reflect, you must call `ctx.request_repaint()` when new data is ready. We will see exactly how to do that (clone the `Context`, which is cheaply cloneable, and call the method from any thread) in [Chapter 3](https://danpokhrel.github.io/tutorial/print.html#chapter-3-your-first-window) . > **Warning: the silent freeze.** The single most common “my UI won’t update” bug in egui is forgetting to request a repaint after a background update. If your app works when you wiggle the mouse but otherwise stalls, look here first. To round out this chapter, here are the five mistakes new egui developers hit over and over. We will revisit each in detail later; for now, memorize the list: 1. **Forgetting image loaders.** egui cannot load images out of the box in 0.35; you must register an image loader (typically via `egui_extras`’s `image`/`svg` features) in `App::new`. Without it, `Image` widgets show nothing or panic. 2. **Holding the `Context` lock across UI code.** Context access is closure-based (`ctx.input(|i| …)`) precisely to avoid this. If you grab a lock and then build widgets, you will deadlock or borrow-checker yourself into a corner. Always scope context reads tightly. 3. **Not requesting repaints.** Covered above. Background-driven updates need `ctx.request_repaint()`. 4. **Missing the `wgpu` feature.** Rendering is off by default — you must enable the `wgpu` feature on `eframe` (or `glow`, for OpenGL) to get a renderer. The good news in 0.35 is that eframe’s `wgpu` feature already turns on `egui-wgpu` with its _default_ native backends (DX12 on Windows, Metal on macOS, Vulkan/GLES on Linux), so you do **not** need a separate `wgpu` dependency or target-specific backend features. Forgetting the `wgpu` feature entirely, though, gives you a blank window or a runtime panic. We set this up in [Chapter 2](https://danpokhrel.github.io/tutorial/print.html#chapter-2-project-setup--structure) . 5. **Unstable `Id`s in loops.** egui tracks widget state (scroll position, focus, etc.) by `Id`. If you generate `Id`s with a loop index that changes when items are added/removed, state will jump around. Use stable identifiers (`Id::new(&item.key)` or `id.with(child_id)`). We cover this in [Chapter 4](https://danpokhrel.github.io/tutorial/print.html#chapter-4-layout-widgets--state) . You now have the mental model: egui is pure immediate-mode logic, eframe is the application shell, state lives in your structs, and the whole thing repaints on demand. In [Chapter 2: Project Setup & Structure](https://danpokhrel.github.io/tutorial/print.html#chapter-2-project-setup--structure) we will turn this into a real project—a `Cargo.toml`, the right feature flags, the wgpu backend gotcha, and a folder layout fit for a production app. In this chapter we go from an empty directory to a compiling eframe project with a sensible, production-shaped structure. The goal is not yet a polished UI—that comes in [Chapter 3](https://danpokhrel.github.io/tutorial/print.html#chapter-3-your-first-window) —but a solid foundation: the right dependencies, the right feature flags, the wgpu backend gotcha handled, and a folder layout that will not collapse under its own weight as the app grows. We will also lean on ideas from the Rust Book’s chapter on packages and modules, so if you have not read it, now is a good time: [Chapter 7, “Packages, Crates, and Modules”](https://doc.rust-lang.org/stable/book/ch07-00-managing-growing-projects-with-packages-crates-and-modules.html) . Start with a fresh binary crate. We will call our example app `flow-builder` because, across the book, it grows into a node-graph editor built on `egui-snarl`: `$ cargo new flow-builder --bin $ cd flow-builder` This gives you the standard skeleton: a `Cargo.toml` and a `src/main.rs` that prints “Hello, world!”. We will replace both. > **Note: always use `--bin`.** eframe apps are executables (and can additionally target `wasm32-unknown-unknown` for the web), not libraries. If you skip `--bin`, cargo makes a library crate, which is the wrong starting point. Replace `Cargo.toml` with the following. Each dependency and feature is deliberate; we explain the non-obvious choices immediately after. ``[package] name = "flow-builder" version = "0.1.0" edition = "2024" rust-version = "1.92.0" [dependencies] # eframe is the app framework. We pin 0.35 and pick only the features we need. eframe = { version = "0.35", default-features = false, features = [ "default_fonts", # ships with a sensible default font set "wgpu", # default renderer in 0.35 "persistence", # save/restore App state across runs "x11", # Linux/X11 backend (winit) "wayland", # Linux/Wayland backend (winit) ] } # egui_extras: image decoders and svg support for `install_image_loaders`. egui_extras = { version = "0.35", features = ["image", "svg"] } # Node-graph widget for the editor we build later in the book. egui-snarl = { version = "0.11", features = ["serde"] } # Serialization for persistence and for snarl snapshots. serde = { version = "1", features = ["derive"] } [profile.release] opt-level = 2 # egui needs reasonable optimization to feel responsive lto = "thin" # cross-crate inlining without full-LTO compile times codegen-units = 1 # better optimization at the cost of slower release builds strip = "symbols" # smaller binaries; debug info lives in the build, not the artifact [profile.dev.package."*"] opt-level = 2 # build *dependencies* (egui, wgpu, ...) optimized even in dev`` A few points worth calling out: * **`default-features = false` on eframe.** eframe’s defaults pull in everything, including the `glow` (OpenGL) renderer and platform backends you may not want. Being explicit avoids surprising link dependencies—especially on Linux, where an accidental X11/Wayland mismatch causes build pain. * **Edition 2024, MSRV 1.92.0.** eframe 0.35 targets edition 2024 idioms (let-chains, `if let` chains, etc.). Setting `rust-version` makes the toolchain requirement explicit. * **`egui-snarl` with `serde`.** We will serialize node graphs to disk, so the `serde` feature must be on. * **`egui_extras` with only `image` and `svg`.** These two features are all `install_image_loaders` needs to display `Image`/`Svg` widgets. The older `http` and `file` features were dropped from this project: nothing in the app fetches images over the network or opens files through `egui_extras` (file dialogs, when we want them, come from the `rfd` crate in [Chapter 7](https://danpokhrel.github.io/tutorial/print.html#chapter-7-input-menus--dialogs) — and even that is optional, added only when you reach that chapter). * **Profile settings.** egui’s debug builds are sluggish because so much work happens every frame. The two `[profile]` blocks above make `cargo run` (without `--release`) usable by optimizing dependencies, while keeping _your_ code compiled fast. The `[profile.dev.package."*"]` trick applies `opt-level = 2` only to _dependencies_ (egui, wgpu, …), not to your own crate — the `"*"` wildcard targets every package _except_ the one being built. Your code stays at `opt-level = 0` (fast to compile), while the heavy libraries run fast enough for interactive use. The `[profile.release]` block adds thin LTO, a single codegen unit, and symbol stripping for a smaller, better-optimized shipping binary. See the [Cargo profile reference](https://doc.rust-lang.org/cargo/reference/profiles.html) for details. * **`x11` and `wayland`** are Linux-only winit backends. On macOS and Windows these features are silently ignored (they compile to no-ops), so listing them is harmless — a single `Cargo.toml` works across all three desktop platforms. A common older-tutorial pitfall is adding a _direct_ `wgpu` dependency to `Cargo.toml` with target-specific backend features (`dx12`, `metal`, `webgl`). **You do not need to do this with eframe 0.35.** The `wgpu` feature on `eframe` enables `egui-wgpu` with its default features, which already turn on wgpu 29’s native backends (DX12 on Windows, Metal on macOS, Vulkan/GLES on Linux). The window simply presents. > **Warning: do not re-add a direct `wgpu` dependency.** This project previously pinned `wgpu = "23"` in a `[target.'cfg(not(target_arch = "wasm32"))'.dependencies]` block. Because `eframe` 0.35 pulls in a newer wgpu transitively, pinning an older major version here compiled an _entire second, unused wgpu tree_ alongside the one eframe already uses — a slow, confusing build for no benefit. Leave wgpu out of `[dependencies]` unless `egui-wgpu`’s default feature set is ever insufficient for your needs. If you _did_ want to control the exact backends (rare), the place to do it is through `egui-wgpu`’s features, not a second `wgpu` crate dependency. For the web target, the WebGL/WebGPU paths are configured through eframe’s wasm glue, not through native backend features. On Linux, eframe (via winit) links against system libraries. You will need the development headers installed. On Debian/Ubuntu: `$ sudo apt install -y \ libxcb1-dev libxcb-render0-dev libxcb-shape0-dev libxcb-xfixes0-dev \ libxkbcommon-dev libxkbcommon-x11-dev \ libwayland-dev libwayland-protocols` On Fedora the packages are named `xcb-util-devel`, `libxkbcommon-devel`, `wayland-devel`, etc. On Arch they are in the `base-devel` group plus `libxcb`, `xkbcommon`, `wayland`. On Windows and macOS, the standard toolchains (MSVC and Xcode respectively) are sufficient—no extra system packages. A real app does not live in a single `main.rs`. Following the Rust Book’s guidance on [separating modules by concern](https://doc.rust-lang.org/stable/book/ch07-03-paths-for-referring-to-an-item-in-the-module-tree.html) , we split the codebase into three layers, each with a clear boundary: `flow-builder/ ├── Cargo.toml └── src/ ├── main.rs # entrypoint: build NativeOptions, run_native (stays thin) ├── theme.rs # EditorTheme: Visuals, colors, style setup ├── graph/ │ ├── mod.rs # re-exports │ ├── model.rs # NodeId, Pin, Node, Link — pure data │ └── state.rs # GraphState — ID allocation, mutations └── ui/ ├── mod.rs # re-exports (pub use app::App;) ├── app.rs # the App struct + eframe::App impl (state owner) └── viewer.rs # DemoNode enum + DemoViewer (SnarlViewer impl)` The principle is **separation of concerns**, and the boundary is egui itself: * **`graph/` is a _pure data model_.** It knows about nodes, pins, and links. It does **not** import `egui`. This means your graph logic is testable without rendering anything—exactly as the Rust Book advocates keeping I/O out of core logic ([Ch. 11 on testing](https://doc.rust-lang.org/stable/book/ch11-00-testing.html) ). (In [Chapter 8](https://danpokhrel.github.io/tutorial/print.html#chapter-8-introducing-egui-snarl) `egui-snarl`’s `Snarl` takes over as the _runtime_ graph container, so `graph/` becomes a pedagogical module kept only for its headless unit test — see the note in [Chapter 5](https://danpokhrel.github.io/tutorial/print.html#chapter-5-structuring-your-application) .) * **`ui/` is the _rendering layer_.** It imports egui (and, later, `egui_snarl`) and turns graph data into pixels. It may read from and call methods on the graph, but it does not own the persistent state. * **`ui/app.rs` is the _state owner_.** It holds the `Snarl`, the viewer, and the ephemeral UI flags, and wires everything together in `App::ui`. It is the only place that lives across frames. This mirrors how a well-architected Rust program separates the model from the view. The payoff comes in later chapters: when we add undo/redo in the graph, or when we render the same graph to an export image, the `graph/` module is reusable unchanged. If you have read the Rust Book’s [Chapter 7](https://doc.rust-lang.org/stable/book/ch07-00-managing-growing-projects-with-packages-crates-and-modules.html) , the file layout above should look familiar: each file is a module, `mod.rs` re-exports the children, and the crate root (`main.rs`) declares the top-level modules. `src/main.rs` will start like this: `// src/main.rs #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] mod graph; mod theme; mod ui; use crate::ui::App; use eframe::egui; fn main() -> eframe::Result { // (NativeOptions + run_native go here — see Chapter 3) Ok(()) }` The `windows_subsystem` attribute hides the console window on Windows in release builds. In debug builds we keep the console so `println!` output is visible—useful while developing. This is the standard Rust idiom for GUI apps; see the Rust Book’s note on it in the context of [Ch. 1’s “Hello, World”](https://doc.rust-lang.org/stable/book/ch01-02-hello-world.html) and the broader discussion of attributes. > **Tip: keep `main` thin.** `main` should declare the modules, configure eframe, and call `run_native`; all real work lives in `ui/app.rs`, `theme.rs`, and `graph/`. This keeps the entrypoint readable and makes the app testable. (The reference implementation’s `main.rs` is exactly this thin — under 30 lines.) We will not build any widgets yet, but let’s prove the setup compiles and shows a window. A truly minimal `main.rs`: `#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] use eframe::egui; fn main() -> eframe::Result { let options = eframe::NativeOptions { viewport: egui::ViewportBuilder::default() .with_inner_size([800.0, 600.0]) .with_min_inner_size([400.0, 300.0]), ..Default::default() }; eframe::run_native( "flow-builder", options, Box::new(|_cc| Ok(Box::new(MyApp::default()))), ) } #[derive(Default)] struct MyApp; impl eframe::App for MyApp { fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { ui.heading("It works!"); } }` > **Why `ViewportBuilder::default()` and not `::new()`?** In egui 0.35, `ViewportBuilder` has **no `new()` constructor** — it derives `Default` and exposes only `with_*` builder methods. Use `ViewportBuilder::default()` and chain `.with_inner_size(...)`, `.with_title(...)`, etc. If you see `ViewportBuilder::new()` in an older tutorial, it will not compile against 0.35. > **Why `ui` and not `update`?** As of egui 0.34, the old `App::update(ctx, frame)` was split into `logic(ctx, frame)` and `ui(ui, frame)`. In 0.35, `update` is **removed**. You implement `ui` (required) and override `logic` (optional). We explore both in [Chapter 3](https://danpokhrel.github.io/tutorial/print.html#chapter-3-your-first-window) > . Build and run: `$ cargo run --release` Use `--release`. egui in debug builds is noticeably sluggish because the per-frame work is unoptimized; with the `[profile.dev.package."*"]` tweak from earlier, plain `cargo run` is _tolerable_ for iteration, but for an honest feel of the toolkit, `--release` is the way. If the window is blank or the program panics about “no suitable adapter,” double-check that your `eframe` dependency has the `wgpu` feature on — that is what brings in the native GPU backends (see the section above). You should not need any separate `wgpu` dependency. You now have a compiling, correctly-configured project that opens an 800×600 window and draws a heading. In [Chapter 3: Your First Window](https://danpokhrel.github.io/tutorial/print.html#chapter-3-your-first-window) we will fill in the `App` trait properly: the `logic`/`ui` split, `CreationContext` setup, the root `CentralPanel`, basic widgets, and a complete hello-world app with a text field, a slider, and a button. In this chapter we take the empty window from [Chapter 2](https://danpokhrel.github.io/tutorial/print.html#chapter-2-project-setup--structure) and turn it into a real application: one that holds state, responds to input, and persists across frames. Along the way we will get hands-on with the `App` trait, the `logic`/`ui` split introduced in egui 0.34, the `CreationContext` one-time setup hook, and the root `CentralPanel`. By the end you will have a complete hello-world app—a name field, an age slider, and a counter button—and the mental tools to extend it. We will also reference a few Rust concepts along the way, especially closures (Rust Book [Ch. 13](https://doc.rust-lang.org/stable/book/ch13-01-closures.html) ) and traits (Rust Book [Ch. 10](https://doc.rust-lang.org/stable/book/ch10-02-traits.html) ). Everything you write in eframe hangs off the `eframe::App` trait. In eframe 0.35, the trait has one **required** method and several **provided** ones you may override: | Method | Kind | Purpose | | --- | --- | --- | | `fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame)` | **required** | Build the UI for this frame. | | `fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame)` | provided | Mutate state, poll async, run animations. Override when needed. | | `fn save(&mut self, storage: &mut dyn Storage)` | provided | Persist state. Requires the `persistence` feature. | | `fn on_exit(&mut self, _gl: Option<&mut glow::Context>)` | provided | Cleanup on shutdown. | | `fn clear_color(&self, _visuals: &Visuals) -> [f32; 4]` | provided | Background clear color. | | `fn raw_input_hook(...)` | provided | Inspect raw input before egui processes it. | > **Warning: `update` is gone.** If you are following an older tutorial, you will see `fn update(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame)`. That method was **removed in 0.35** (it was split into `logic` + `ui` back in 0.34). Copying old code verbatim will not compile. Split the work as described below. egui 0.34 introduced, and 0.35 keeps, a clean separation of the per-frame work into two methods: * **`logic`** runs **before** `ui`. It receives a `&egui::Context` and the `Frame`. This is where you: * Advance animations and timers. * Drain async channel results (network, file IO) into your state. * Read _aggregated_ input via `ctx.input(|i| …)`. * Decide repaint scheduling. * **It must not** build any widgets. You have a `Context`, not a `Ui`. * **`ui`** runs **after** `logic`. It receives a `&mut egui::Ui` and the `Frame`. This is where you: * Read your (already-updated) state. * Emit widgets: `ui.button`, `ui.label`, etc. * **Keep it cheap and mostly side-effect-free.** The UI is a projection of state; if a click mutates something, prefer to record the intent and apply it in the next frame’s `logic`, or apply it directly but keep it trivial. Why split them? Two reasons. First, it makes the data flow one-directional: `state -> ui`, never `ui -> state` in surprising ways. Second, it lets egui reason about whether a repaint is needed—if `logic` reports no change and no input arrived, the frame can be skipped entirely. (See the Rust Book’s discussion of [ownership and data flow](https://doc.rust-lang.org/stable/book/ch04-00-understanding-ownership.html) for the general philosophy of keeping data movement explicit.) You launch an eframe app by handing a configuration struct and a closure to `eframe::run_native`. The closure is called once, with a `CreationContext`, and must return your `App` boxed up. Closures here follow exactly the patterns described in Rust Book [Ch. 13](https://doc.rust-lang.org/stable/book/ch13-01-closures.html) : `use eframe::egui; fn main() -> eframe::Result { let options = eframe::NativeOptions { viewport: egui::ViewportBuilder::default() .with_inner_size([800.0, 600.0]) .with_min_inner_size([400.0, 300.0]) .with_title("flow-builder"), ..Default::default() }; eframe::run_native( "flow-builder", options, Box::new(|cc| Ok(Box::new(HelloApp::new(cc)))), ) }` `Box::new(|cc| …)` is an `AppCreator`—a boxed closure capturing nothing (it can’t; eframe calls it from a context where only `cc` is available). It returns `Result, Box>`. The error type must be `Box` specifically because eframe is generic over the error so it can be propagated across the thread boundary where the closure is invoked — a plain `Box` would not be `Send` and the closure would not compile. This is why our `HelloApp::new` returns a `Result` with that exact error type: setup steps like loading fonts can fail, and the `AppCreator` closure needs to forward those errors back to `run_native`. See the Rust Book on [the `Result` type and `?`](https://doc.rust-lang.org/stable/book/ch09-02-recoverable-errors-with-result.html) . The `CreationContext` (`cc`) is the one-time setup hook. It is where you: * Install **image loaders** (e.g. `egui_extras::image` / `svg`). Without this, `Image` widgets do nothing—see the “beginner mistakes” in [Chapter 1](https://danpokhrel.github.io/tutorial/print.html#chapter-1-what-is-eframe) . * Configure **fonts** if you need a non-default font or extra glyphs (e.g. for icons or non-Latin scripts). * Tune **`Visuals`** (dark/light, spacing, colors) via `cc.egui_ctx.set_visuals(...)`. * Set up a **custom repainting** strategy. A typical `new`: `#![allow(unused)] fn main() { use eframe::{egui, CreationContext}; pub struct HelloApp { name: String, age: u32, clicks: u32, } impl HelloApp { pub fn new(cc: &CreationContext<'_>) -> Result> { // Image loaders so Image/Svg widgets work later. egui_extras::install_image_loaders(&cc.egui_ctx); // Dark theme by default. cc.egui_ctx.set_visuals(egui::Visuals::dark()); Ok(Self { name: "World".to_owned(), age: 25, clicks: 0, }) } } }` Because `HelloApp::new` returns a `Result`, the `AppCreator` closure in `main` can propagate setup failures cleanly—a more idiomatic choice than `unwrap()`\-ing in `new`. See the Rust Book on [the `Result` type and `?`](https://doc.rust-lang.org/stable/book/ch09-02-recoverable-errors-with-result.html) . > **Tip: `CreationContext` is borrowed, not owned.** The `&CreationContext<'_>` you receive in `new` borrows from the closure invocation — it lives only for the duration of the `AppCreator` call. You **cannot** store it on your `App` struct. If you need something from `cc` (the `egui_ctx`, the `storage`, the integration info), extract what you need into owned data or clone the `Context` (which is `Arc`\-backed and cheap to clone) and store _that_. The root `egui::Ui` handed to `App::ui` has **no margin and no background**. If you draw straight into it, your content sits jammed against the window edge and floats on the clear color. You almost always wrap everything in a panel. `CentralPanel::default()` is the root container that fills the remaining space and gives you the default theme’s background and padding: `#![allow(unused)] fn main() { impl eframe::App for HelloApp { fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { // Panels take &mut Ui since 0.34 — NOT &Context. egui::CentralPanel::default().show(ui, |ui| { ui.heading("Hello, eframe!"); }); } } }` > **API note (0.34+).** Panel `show()` methods take a `&mut Ui`, not a `&Context`. In tutorials older than 0.34 you will see `CentralPanel::default().show(ctx, …)`. That no longer compiles. If you are inside `App::ui`, you already have the `&mut Ui`; just pass it directly — `CentralPanel::default().show(ui, |ui| { … })`. When you genuinely have only a `Context`—for example, in `run_simple_native` or in `App::logic`—you use `.show(ctx, …)` on that path instead, because those entry points give you a `&Context` and construct the root `Ui` internally. egui’s `Ui` is a bag of builder methods. Each returns a `Response` you can inspect. Here are the ones you will use constantly: `#![allow(unused)] fn main() { ui.heading("A heading"); ui.label("A plain label."); // Single-line text bound to a &mut String: ui.text_edit_singleline(&mut self.name); // A slider over a &mut numeric: ui.add(egui::Slider::new(&mut self.age, 0..=120).text("age")); // A checkbox: let mut flag = true; ui.checkbox(&mut flag, "enable"); // A button — returns Response, .clicked() is true for one frame: if ui.button("Increment").clicked() { self.clicks += 1; } // Styled text: ui.colored_label(egui::Color32::RED, "red text"); }` Two patterns to internalize: 1. **Mutate through references.** `text_edit_singleline` takes `&mut String`, `Slider::new` takes `&mut u32`. egui writes straight into your fields. There is no “get the widget’s value” step—your struct _is_ the value. 2. **`clicked()` is true for one frame.** It is not a persistent state. You read it in the frame the click occurred and act immediately. For more on how methods on `self`/`&self`/`&mut self` map to call ergonomics, the Rust Book’s [Ch. 5 on method syntax](https://doc.rust-lang.org/stable/book/ch05-03-method-syntax.html) is the relevant background. This is the heart of immediate mode, restated for our concrete example. `self.name`, `self.age`, and `self.clicks` live on `HelloApp`, which lives across frames (eframe keeps your `App` around between calls to `ui`). Each frame, the widgets simply read and write those fields. Nothing needs to be “synced” anywhere—the next frame’s widgets will naturally reflect whatever value the fields now hold. Contrast this with retained mode, where you would have a `TextBox` widget holding its own `String` that you must push to and pull from. In egui that widget does not exist; only your data does. Putting it all together, here is a full, compilable `main.rs`\-style hello world: a name field, an age slider, and an increment button, all inside a `CentralPanel`: `#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] use eframe::{egui, CreationContext}; fn main() -> eframe::Result { let options = eframe::NativeOptions { viewport: egui::ViewportBuilder::default() .with_inner_size([480.0, 320.0]) .with_min_inner_size([320.0, 200.0]) .with_title("flow-builder"), ..Default::default() }; eframe::run_native( "flow-builder", options, Box::new(|cc| Ok(Box::new(HelloApp::new(cc)?))), ) } pub struct HelloApp { name: String, age: u32, clicks: u32, } impl HelloApp { pub fn new(cc: &CreationContext<'_>) -> Result> { egui_extras::install_image_loaders(&cc.egui_ctx); Ok(Self { name: "World".to_owned(), age: 25, clicks: 0, }) } } impl eframe::App for HelloApp { fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { egui::CentralPanel::default().show(ui, |ui| { ui.heading("Hello, eframe!"); ui.separator(); ui.horizontal(|ui| { ui.label("Your name:"); ui.text_edit_singleline(&mut self.name); }); ui.add(egui::Slider::new(&mut self.age, 0..=120).text("age")); ui.horizontal(|ui| { if ui.button("Increment").clicked() { self.clicks += 1; } ui.label(format!("clicked {} times", self.clicks)); }); ui.separator(); ui.label(format!("Hello, {} (age {})!", self.name, self.age)); }); } }` Run it with `cargo run --release`. Type a name, drag the slider, click the button—the values flow straight into your struct and the bottom line updates next frame. > **Passing `ui` directly.** When `App::ui` hands you a `&mut Ui`, you pass it straight to `CentralPanel::default().show(ui, …)`. There is no need for a bridge method or a context lookup — the `Ui` is already the right type for `show` in 0.35. We will compose multiple panels in [Chapter 4](https://danpokhrel.github.io/tutorial/print.html#chapter-4-layout-widgets--state) > . For throwaway prototypes or examples, you do not even need an `App` struct. eframe offers a closure-based entry point: `fn main() -> eframe::Result { let mut name = String::from("World"); let mut clicks = 0u32; eframe::run_simple_native("prototype", Default::default(), |ctx, _frame| { egui::CentralPanel::default().show(ctx, |ui| { ui.text_edit_singleline(&mut name); if ui.button("click").clicked() { clicks += 1; } ui.label(format!("{} clicks", clicks)); }); }) }` This is great for examples and one-off tools. Note that the closure receives a `Context` (not a `Ui`), so here `CentralPanel::show` does take `ctx`—this is the non-`App` path and is unaffected by the 0.34 panel-signature change. For anything beyond a quick demo, graduate to the `App` struct so you can use `logic`/`ui`, persistence, and clean state ownership. egui repaints on demand (see [Chapter 1](https://danpokhrel.github.io/tutorial/print.html#chapter-1-what-is-eframe) ). If a background thread produces data your UI should show, you must tell egui to repaint, or nothing will update until the user wiggles the mouse. `egui::Context` is cheaply cloneable (it is `Arc`\-backed internally), so the pattern is: clone it, move the clone into the thread, and call `request_repaint()` when there is new data: `#![allow(unused)] fn main() { use eframe::egui; use std::thread; use std::time::Duration; fn spawn_background_work(ctx: egui::Context) { thread::spawn(move || { for i in 0.. { // ... do work, produce a new value ... thread::sleep(Duration::from_millis(500)); // Tell egui: "please repaint, new data is available." ctx.request_repaint(); // (You would also push the data into a channel your // App::logic drains; request_repaint just ensures the // next frame runs so logic/ui see the new value.) let _ = i; } }); } }` > **Warning: do not hold the context lock across UI building.** Context access for _reading_ state (input, output) is closure-scoped: `ctx.input(|i| { /* read */ })`. This scoped form exists precisely so you never hold a lock while building widgets, which would risk deadlocks and borrow conflicts. Always scope context reads tightly, as shown. The `Arc`\-backed `Context` and the `move` closure are exactly the patterns the Rust Book covers in [Ch. 16 on threads](https://doc.rust-lang.org/stable/book/ch16-00-concurrency.html) and [Ch. 13 on closures](https://doc.rust-lang.org/stable/book/ch13-01-closures.html) . You can now build an `App`, set it up via `CreationContext`, lay out widgets in a `CentralPanel`, and drive repaints from background work. [Chapter 4: Layout, Widgets & State](https://danpokhrel.github.io/tutorial/print.html#chapter-4-layout-widgets--state) moves from a single panel to a real multi-panel layout: side panels, top bars, scroll areas, grids, richer text, and—critically—how to build your own custom `egui::Widget` and keep widget `Id`s stable in loops. So far we have worked inside a single `CentralPanel`. Real applications are more structured: a sidebar of controls, a top toolbar, a central canvas, perhaps a status bar at the bottom. This chapter teaches egui’s layout system—how a `Ui` grows a cursor, how panels compose, how to size and center things, and how to lay out large or tabular data with `ScrollArea` and `Grid`. We then widen the widget vocabulary, introduce `RichText` for inline styling, and finish by building a **custom widget** that implements the `egui::Widget` trait—the same pattern the Rust Book uses to teach [implementing traits for your own types](https://doc.rust-lang.org/stable/book/ch10-02-traits.html) . We also address the most subtle pitfall in immediate-mode UIs: keeping widget `Id`s stable in loops. Every `egui::Ui` has a `Layout` and a **cursor**—a point that advances as you add widgets. The layout decides which direction the cursor moves and how widgets are placed: * `Layout::top_down(Align::Center)` — cursor moves down; widgets centered horizontally. * `Layout::bottom_up(...)` — cursor moves upward. * `Layout::left_to_right(Align::TOP)` — cursor moves right; widgets top-aligned. * `Layout::right_to_left(...)` — cursor moves left. The default for a fresh `Ui` is usually `top_down` (or whatever the parent panel dictates). You rarely set the layout on the root `Ui`; instead, you create inner `Ui`s with a chosen layout: `#![allow(unused)] fn main() { ui.horizontal(|ui| { /* left_to_right */ }); ui.vertical(|ui| { /* top_down */ }); // Explicit control: ui.horizontal_top(|ui| { /* left_to_right, top aligned */ }); }` `ui.horizontal(|ui| { … })` is shorthand for “create a child `Ui` with `left_to_right`, add children, then collapse.” The same closure pattern repeats throughout egui; see Rust Book [Ch. 13 on closures](https://doc.rust-lang.org/stable/book/ch13-01-closures.html) if the borrowed closure in `|ui|` looks unfamiliar. Panels are the structural backbone of an egui app. There are three families: * **`CentralPanel`** — fills all remaining space; exactly one per frame, and it must be last. * **`Panel`** — as of egui 0.35, the former `SidePanel` and `TopBottomPanel` were unified into a single `egui::Panel` type. You pick a side via the constructor: `Panel::left(id)` / `Panel::right(id)` give a resizable left or right column, and `Panel::top(id)` / `Panel::bottom(id)` give a resizable top or bottom bar. (Older code may still reference `SidePanel`/`TopBottomPanel`; on 0.35+ those names no longer exist—use `Panel` instead.) **Order matters.** Panels claim space from the available rectangle, and each later panel sees only what is left. You must add side panels and top/bottom panels **before** the central panel: `#![allow(unused)] fn main() { egui::Panel::left("left_panel") .resizable(true) .default_size(220.0) .min_size(150.0).max_size(400.0) .show(ui, |ui| { ui.heading("Controls"); // ... sidebar widgets ... }); egui::Panel::top("top_panel") .show(ui, |ui| { ui.horizontal(|ui| { ui.menu_button("File", |ui| { /* ... */ }); ui.menu_button("Edit", |ui| { /* ... */ }); }); }); egui::CentralPanel::default() .show(ui, |ui| { ui.heading("Canvas"); // ... the main content ... }); }` If you reversed this—`CentralPanel` first—it would consume the whole window, and the side/top panels would render over (or be squeezed into) nothing. > **API note (0.34+).** Since egui 0.34, panel `.show()` takes `&mut Ui` (the `Ui` you are currently building inside), **not** a `&Context`. Older code with `.show(ctx, …)` will not compile on 0.35. Inside `App::ui`, pass the `&mut Ui` you were handed (or a child `Ui` derived from it). When you genuinely have only a `Context`—e.g. in `run_simple_native`—you use `.show(ctx, …)` on that path instead. A `Ui` exposes the space it has, so you can lay things out precisely: `#![allow(unused)] fn main() { // How much room is left in this Ui right now? let avail = ui.available_size(); // Vec2 let width = ui.available_width(); // f32 let used = ui.min_rect(); // Rect of everything drawn so far // A widget that is exactly w×h, sized explicitly: ui.add_sized([width, 24.0], egui::Button::new("Full-width button")); // Reserve vertical space and get a child Ui inside it: let (rect, _response) = ui.allocate_exact_size( egui::vec2(200.0, 100.0), egui::Sense::hover(), ); let mut child = ui.child_ui_with_id_source( rect, *ui.layout(), "child_block", ); child.label("inside the allocated rect"); }` `add_sized` is the everyday tool for “make this widget exactly this big.” egui’s layout is deliberately simple; centering is a small recipe: `#![allow(unused)] fn main() { // Vertically center a row of widgets: ui.vertical_centered(|ui| { ui.horizontal(|ui| { if ui.button("Save").clicked() { /* ... */ } if ui.button("Cancel").clicked() { /* ... */ } }); }); // Or allocate a region with a chosen layout: ui.allocate_ui_with_layout( egui::vec2(300.0, 60.0), egui::Layout::top_down(egui::Align::Center), |ui| { ui.label("centered text"); }, ); }` For lists longer than the window, wrap content in a `ScrollArea`: `#![allow(unused)] fn main() { egui::ScrollArea::vertical() .auto_shrink([false, true]) .show(ui, |ui| { for i in 0..50 { ui.label(format!("row {}", i)); } }); }` Variants: `ScrollArea::both()`, `ScrollArea::horizontal()`. For **thousands** of rows, do not build them all—use a virtualized list. egui does not ship one, but the pattern is: clip to the visible rows given the scroll offset, and only emit those. The egui examples repository includes virtual-list patterns; the key API is `ScrollArea::show_rows`, which takes the total count and a closure that is only invoked for visible indices. For tabular, aligned content use `Grid`. Column widths auto-size to the widest cell, and `end_row()` advances to the next row: `#![allow(unused)] fn main() { egui::Grid::new("settings_grid") .num_columns(2) .spacing([16.0, 8.0]) .show(ui, |ui| { ui.label("Name"); ui.text_edit_singleline(&mut self.name); ui.end_row(); ui.label("Age"); ui.add(egui::Slider::new(&mut self.age, 0..=120)); ui.end_row(); ui.label("Sound"); ui.checkbox(&mut self.sound_on, "enabled"); ui.end_row(); }); }` Grids are great for settings dialogs and property editors—exactly what our sidebar needs. A quick tour of widgets you will reach for often: `#![allow(unused)] fn main() { let mut flag = true; let mut value = 5; let mut choice = 0u32; ui.checkbox(&mut flag, "enable thing"); // A numeric drag field (scrub the value by dragging): ui.add(egui::DragValue::new(&mut value).range(0..=100)); // Dropdown: egui::ComboBox::from_label("mode") .selected_index(choice as usize) .show_ui(ui, |ui| { ui.selectable_value(&mut choice, 0, "Linear"); ui.selectable_value(&mut choice, 1, "Smooth"); ui.selectable_value(&mut choice, 2, "Stepped"); }); // Collapsible section: ui.collapsing("Advanced", |ui| { ui.label("hidden by default"); }); // Visual separators: ui.separator(); // Progress and spinners: ui.add(egui::ProgressBar::new(0.4).text("40%")); ui.add(egui::Spinner::new()); }` `RichText` lets you style a span of text—size, color, font family—and `ui.label` accepts it directly: `#![allow(unused)] fn main() { use egui::RichText; ui.label(RichText::new("big and red").color(egui::Color32::RED).size(28.0)); ui.label(RichText::new("monospace").family(egui::FontFamily::Monospace)); // Spans in a single line: let line = RichText::new("Price: ").strong() + RichText::new("$42").color(egui::Color32::LIGHT_GREEN); ui.label(line); }` For anything beyond inline text, `egui::Label` has `wrap_mode`, `truncate`, and similar options. When none of the built-ins fit, you implement `egui::Widget`. The trait is small: `#![allow(unused)] fn main() { pub trait Widget { fn ui(self, ui: &mut Ui) -> Response; } }` A custom widget reserves space, handles interaction, paints, and returns a `Response`. Here is a complete **Knob** widget—a circular dial you drag vertically to change a value. It shows both idioms in one place: a struct implementing `Widget`, which internally uses lower-level `Ui` calls. `#![allow(unused)] fn main() { use eframe::egui; use egui::{Response, Sense, Ui, Vec2, Widget}; pub struct Knob<'a> { value: &'a mut f32, radius: f32, range: std::ops::RangeInclusive, } impl<'a> Knob<'a> { pub fn new(value: &'a mut f32, range: std::ops::RangeInclusive) -> Self { Self { value, radius: 20.0, range } } } impl<'a> Widget for Knob<'a> { fn ui(self, ui: &mut Ui) -> Response { let size = Vec2::splat(self.radius * 2.0); let (rect, response) = ui.allocate_exact_size(size, Sense::drag()); // Handle dragging: vertical drag changes the value. if response.dragged() { let dy = -response.drag_delta().y; let span = self.range.end() - self.range.start(); *self.value = (*self.value + dy * span * 0.01) .clamp(*self.range.start(), *self.range.end()); } // Paint the knob: a circle plus an indicator line. let center = rect.center(); let painter = ui.painter_at(rect); let color = if response.hovered() || response.dragged() { egui::Color32::from_rgb(120, 180, 255) } else { egui::Color32::from_rgb(90, 90, 90) }; painter.circle_stroke(center, self.radius * 0.9, egui::Stroke::new(2.0, color)); // Angle: map [start..end] to [-135°..+135°]. let t = (*self.value - self.range.start()) / (self.range.end() - self.range.start()); let angle = (-135.0_f32.to_radians()) + t * (270.0_f32.to_radians()); let tip = center + egui::vec2( (self.radius * 0.8) * angle.cos(), (self.radius * 0.8) * angle.sin(), ); painter.line_segment( [center, tip], egui::Stroke::new(2.0, egui::Color32::WHITE), ); // Always return the Response so callers can chain checks. response } } }` Use it like any built-in widget: `#![allow(unused)] fn main() { let mut gain = 0.5_f32; ui.add(Knob::new(&mut gain, 0.0..=1.0)); }` > **Note on `drag_delta`.** `response.drag_delta()` returns the _per-frame_ drag distance in screen pixels — it resets to zero each frame, it is **not** cumulative. The multiplier `0.01` in the Knob scales this per-pixel movement so that dragging 100 pixels changes the value by roughly one full range. If you want a different “feel,” adjust the multiplier: a smaller value makes the knob less sensitive. This is the same shape as Rust’s own trait implementations: a type declares it implements a trait, the trait has a required method, and callers treat it uniformly. See Rust Book [Ch. 10 on traits](https://doc.rust-lang.org/stable/book/ch10-02-traits.html) . The **two idioms** in the wild are: 1. **A struct implementing `Widget`** (above) — best for reusable, stateful-feeling widgets. 2. **A function returning `Response`** (`fn knob(ui, value, range) -> Response`) — best for one-off, parameterized widgets you do not need to name. Either is fine; prefer the struct when you expect to reuse it or attach configuration via builder methods (`Knob::new(...).radius(30.0).color(...)`). Every widget returns a `Response`. It is your handle for “what happened to this widget this frame.” Always return it from your own widgets—callers will want it: `#![allow(unused)] fn main() { let r = ui.button("delete"); if r.clicked() { /* one frame */ } if r.hovered() { /* pointer is over it */ } if r.dragged() { /* being dragged */ } r.on_hover_text("permanently remove the item"); // tooltip // Right-click context menu, attached to the same widget: r.context_menu(|ui| { if ui.button("Duplicate").clicked() { /* ... */ } if ui.button("Delete").clicked() { /* ... */ } }); }` `Response` is cheap—it is essentially an `Id` plus some flags. Chaining like `r.on_hover_text(...)` returns a new `Response` you can keep chaining on, which is the idiomatic way to enrich a widget. egui tracks per-widget state (scroll offsets, focus, animation timers, drag state) by `egui::Id`. The `Id` must be **stable** across frames for the same logical widget. The default `Id` for a `ui.button("Save")` is derived from the button text _and its position in the call sequence_—fine for static UIs, but **wrong in loops** where items can be added, removed, or reordered. Consider a list of toggles: `#![allow(unused)] fn main() { // BUG: index-based implicit Ids shift when items are removed, // so each toggle's checked-state jumps to its neighbor. for (i, item) in items.iter().enumerate() { ui.checkbox(&mut item.enabled, &item.name); // implicit Id from i let _ = i; } }` The fix is to derive the `Id` from a **stable key** of the item: `#![allow(unused)] fn main() { for item in items.iter_mut() { // Stable Id keyed by the item's own identifier, not its position. ui.push_id(format!("toggle_{}", item.id), |ui| { ui.checkbox(&mut item.enabled, &item.name); }); } }` > **Tip: `push_id` accepts `impl Into`.** `&str`, `String`, `Id`, and integer types all work. Using `name.as_str()` (a `&str`) is cheaper than `format!("toggle_{}", item.id)` because it avoids a heap allocation per row per frame. Reserve `format!` for when you need to combine multiple values into a key; prefer a bare `&str` or `Id` when a single stable key is available. Other stable-Id tools: `#![allow(unused)] fn main() { use egui::Id; let id = Id::new("my_knob"); let child_id = id.with("indicator"); // derive a child Id deterministically ui.push_id(id, |ui| { /* widgets here get id-prefixed Ids */ }); }` > **Warning: the silent shuffle.** Unstable `Id`s do not crash—they silently mis-associate state. The symptom is “my checkbox state jumped to the next row when I deleted an item.” Always key `Id`s on stable data, not loop indices. We finish with a small, working multi-panel app: a left sidebar of controls and a central display area. It exercises panels, grids, a custom widget, and stable `Id`s: `#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] use eframe::egui; use egui::{Response, Sense, Ui, Vec2, Widget}; // --- A tiny custom widget (Knob, abridged from above) --- struct Knob<'a> { value: &'a mut f32, } impl<'a> Widget for Knob<'a> { fn ui(self, ui: &mut Ui) -> Response { let radius = 20.0; let size = Vec2::splat(radius * 2.0); let (rect, response) = ui.allocate_exact_size(size, Sense::drag()); if response.dragged() { *self.value = (*self.value - response.drag_delta().y * 0.01).clamp(0.0, 1.0); } let painter = ui.painter_at(rect); let center = rect.center(); painter.circle_stroke(center, radius * 0.9, egui::Stroke::new(2.0, egui::Color32::LIGHT_BLUE)); let angle = -135.0_f32.to_radians() + *self.value * 270.0_f32.to_radians(); let tip = center + egui::vec2( (radius * 0.8) * angle.cos(), (radius * 0.8) * angle.sin(), ); painter.line_segment([center, tip], egui::Stroke::new(2.0, egui::Color32::WHITE)); response } } pub struct LayoutApp { name: String, gain: f32, rows: Vec<(String, bool)>, } impl Default for LayoutApp { fn default() -> Self { Self { name: "node 1".to_owned(), gain: 0.5, rows: vec![ ("alpha".to_owned(), true), ("beta".to_owned(), false), ("gamma".to_owned(), true), ], } } } impl eframe::App for LayoutApp { fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { // 1) Top bar FIRST. egui::Panel::top("top_bar") .show(ui, |ui| { egui::MenuBar::new().ui(ui, |ui| { ui.menu_button("File", |ui| { if ui.button("New").clicked() { self.rows.clear(); } }); ui.menu_button("Edit", |ui| { if ui.button("Add row").clicked() { self.rows.push((format!("row {}", self.rows.len()), false)); } }); }); }); // 2) Left sidebar NEXT. egui::Panel::left("sidebar") .resizable(true) .default_size(240.0) .min_size(160.0).max_size(420.0) .show(ui, |ui| { ui.heading("Controls"); ui.separator(); egui::Grid::new("settings") .num_columns(2) .spacing([12.0, 8.0]) .show(ui, |ui| { ui.label("Name"); ui.text_edit_singleline(&mut self.name); ui.end_row(); ui.label("Gain"); ui.add(Knob { value: &mut self.gain }); ui.end_row(); }); ui.separator(); ui.heading("Rows"); // Stable Ids keyed on each row's *name*, not its index. for (name, enabled) in self.rows.iter_mut() { ui.push_id(name.as_str(), |ui| { ui.checkbox(enabled, name.as_str()); }); } }); // 3) Central panel LAST. egui::CentralPanel::default() .show(ui, |ui| { ui.heading("Canvas"); ui.label(format!("editing: {} (gain {:.2})", self.name, self.gain)); ui.separator(); ui.label("rows:"); egui::ScrollArea::vertical().show(ui, |ui| { egui::Grid::new("rows_grid").num_columns(2).show(ui, |ui| { for (name, enabled) in &self.rows { ui.label(name); ui.label(if *enabled { "on" } else { "off" }); ui.end_row(); } }); }); }); } } fn main() -> eframe::Result { let options = eframe::NativeOptions { viewport: egui::ViewportBuilder::default() .with_inner_size([900.0, 600.0]) .with_title("flow-builder"), ..Default::default() }; eframe::run_native( "flow-builder", options, Box::new(|_cc| Ok(Box::::default())), ) }` Run it. The top bar offers File/Edit menus; the resizable left sidebar holds a settings grid and a knob; the central area scrolls a tabular view of rows. Drag the knob to change the gain; toggle rows in the sidebar and watch the central table update next frame—no syncing code, because the state lives on `LayoutApp` and both panels simply read it. You can now compose multi-panel layouts, size and center widgets, scroll and grid content, style text with `RichText`, and write your own `egui::Widget` returning a proper `Response`. You also understand the one subtlety that bites everyone once: stable `Id`s in loops. This completes the foundation of the book. From here, the natural next step is to bring in **`egui-snarl`** for the node-graph editor that `flow-builder` is named for: defining node types with typed input/output pins, wiring pins together, serializing graphs with `serde`, and rendering the snarl inside the central panel we just built. The data model lives in `src/graph/` (pure, no egui), the rendering lives in `src/panels/canvas.rs`, and `app.rs` owns it all—exactly the structure we set up in [Chapter 2](https://danpokhrel.github.io/tutorial/print.html#chapter-2-project-setup--structure) . Up to now we have kept every piece of state, every widget, and every bit of logic inside a single `App` struct and a single `ui()` method. That works for a handful of widgets, but it does not scale. In this chapter we’ll learn how to split a growing egui application into a clean data model, a rendering layer, and a state-owning app shell — the same kind of separation you saw in the Rust Book when modules were introduced in [Chapter 7](https://doc.rust-lang.org/stable/book/ch07-00-managing-growing-projects-with-packages-crates-and-modules.html) . By the end you’ll have an architecture that keeps your pure data model free of any GUI dependency, so it can be unit-tested on a headless CI machine without a GPU or a window. A node-graph editor is a useful example because it mixes three concerns that each want their own home: * **Persistent data** — the nodes, pins, and links that make up the graph. This data outlives any single frame and is often serialized to disk. * **Background work** — loading a graph from a file, rendering a preview image, computing a simulation step. These run off the UI thread and deliver results back asynchronously. * **Ephemeral UI state** — which panel is open, which node is hovered, whether a drag is in progress. This is meaningless without a window and should not live next to the graph data. If all three live as fields on one giant struct and all logic lives in `ui()`, you end up with a 600-line method that borrows `&mut self` ten different ways in a single frame and fights the borrow checker at every turn. The fix is not cleverer borrowing — it is better structure. In eframe the `App` trait is the entry point the runtime calls every frame. The trait requires: `#![allow(unused)] fn main() { use eframe::egui; impl eframe::App for MyApp { fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) { // Read state and build widgets here. } } }` You may also override `logic()` for work that should happen before any widgets are built — polling async tasks, advancing animations, ticking timers: `#![allow(unused)] fn main() { impl eframe::App for MyApp { fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) { // Poll background tasks, advance animations, update timers. self.poll_background_tasks(ctx); self.advance_animations(ctx); } fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) { // Build the UI from current state. self.draw_graph(ui); self.draw_panels(ui); } } }` The key rule of thumb: **`logic()` mutates state; `ui()` reads state and builds widgets.** If you find yourself mutating persistent data inside `ui()`, ask whether that mutation belongs in `logic()` instead. Interaction _intents_ (a button was clicked) are collected in `ui()` and applied in `logic()` on the next frame — more on this below. The `App` struct owns everything: ``#![allow(unused)] fn main() { use std::sync::{Arc, Mutex}; pub struct MyApp { /// The graph — persistent, serializable, no egui dependency. pub graph: GraphState, /// Ephemeral UI flags — never serialized. pub ui_state: UiState, /// Background task results waiting to be folded into `graph`. pub pending: Arc>>, /// User configuration. pub config: Config, } }`` Following the guidance in Rust Book [Chapter 7](https://doc.rust-lang.org/stable/book/ch07-02-modules-and-use-to-control-scope-and-privacy.html) , we organize the crate into modules by concern. A layout that works well for a medium-sized egui app looks like this: `src/ ├── main.rs // entry point, eframe launch (stays thin) ├── theme.rs // EditorTheme: visual style, colors ├── graph/ │ ├── mod.rs // re-exports │ ├── model.rs // NodeId, PinId, Pin, Node, Link — pure data │ └── state.rs // GraphState — ID allocation, mutations └── ui/ ├── mod.rs // re-exports (pub use app::App;) ├── app.rs // the App struct + eframe::App impl (state owner) └── viewer.rs // the SnarlViewer impl + node enum (Ch. 8+)` The important boundary is between `graph/` and `ui/`: * `graph/` depends on `serde` and nothing else GUI-related. It compiles and tests on a machine with no display. * `ui/` depends on `egui` (and, from [Chapter 8](https://danpokhrel.github.io/tutorial/print.html#chapter-8-introducing-egui-snarl) , `egui_snarl`). It turns graph data into pixels. This is the same “separate the library from the binary” idea described in Rust Book [Chapter 7](https://doc.rust-lang.org/stable/book/ch07-01-packages-and-crates-for-making-libraries-and-executables.html) and it pays off the moment you want to write property tests against your graph model. Let’s define the core types. These are plain Rust structs with `serde` derives — no `egui` anywhere. ``#![allow(unused)] fn main() { // src/graph/model.rs use serde::{Deserialize, Serialize}; /// A newtype around `u64` identifying a node. /// /// This is the "newtype idiom" from Rust Book /// [Ch. 19.3](https://doc.rust-lang.org/stable/book/ch19-03-advanced-traits.html#using-the-newtype-pattern-to-implement-external-traits). /// Wrapping the raw integer gives us a distinct type the compiler can /// check, so you can never accidentally pass a `LinkId` where a `NodeId` /// is expected. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] pub struct NodeId(pub u64); #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] pub struct LinkId(pub u64); /// A newtype around `u64` identifying a pin on a node, distinct from /// `NodeId`/`LinkId` so endpoints can't be mixed up at compile time. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] pub struct PinId(pub u64); /// An input or output socket on a node. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Pin { pub id: PinId, pub name: String, pub kind: PinKind, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum PinKind { Input, Output, } /// A node in the graph — pure data. Position is stored here because it /// is part of the saved document, but it is the *only* UI-ish field. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Node { pub id: NodeId, pub title: String, pub pins: Vec, pub position: [f64; 2], } /// A connection between an output pin and an input pin. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Link { pub id: LinkId, pub from_node: NodeId, pub from_pin: PinId, pub to_node: NodeId, pub to_pin: PinId, } }`` > **Tip:** Keep `position` in the model only if you consider layout part of the document. If layout is purely cosmetic, move it into a side-table in `UiState` keyed by `NodeId`. That keeps the document format stable when users rearrange nodes. `GraphState` owns the nodes, links, and the counters that mint new IDs. IDs are **monotonically incrementing and never recycled**. Recycling IDs is a classic source of bugs: a stale link that referenced a deleted node suddenly points at a brand-new, unrelated node. By never recycling, a dangling `NodeId` is harmless — it simply refers to nothing. `#![allow(unused)] fn main() { // src/graph/state.rs use super::model::{Link, LinkId, Node, NodeId, PinId}; use std::collections::HashMap; #[derive(Debug, Default)] pub struct GraphState { pub nodes: HashMap, pub links: HashMap, next_node_id: u64, next_link_id: u64, } impl GraphState { pub fn add_node(&mut self, title: impl Into, position: [f64; 2]) -> NodeId { let id = NodeId(self.next_node_id); self.next_node_id += 1; self.nodes.insert( id, Node { id, title: title.into(), pins: Vec::new(), position, }, ); id } pub fn add_link( &mut self, from_node: NodeId, from_pin: PinId, to_node: NodeId, to_pin: PinId, ) -> LinkId { debug_assert!( self.nodes.contains_key(&from_node) && self.nodes.contains_key(&to_node), "add_link: endpoint node does not exist" ); let id = LinkId(self.next_link_id); self.next_link_id += 1; self.links.insert( id, Link { id, from_node, from_pin, to_node, to_pin, }, ); id } pub fn remove_node(&mut self, id: NodeId) { self.nodes.remove(&id); // Remove links that referenced this node. IDs are not recycled, // so any surviving references are simply gone. self.links .retain(|_, l| l.from_node != id && l.to_node != id); } } }` Because `GraphState` has no `egui` dependency, you can write a normal `#[test]` against it: `#![allow(unused)] fn main() { #[cfg(test)] mod tests { use super::*; #[test] fn removing_a_node_drops_its_links() { let mut g = GraphState::default(); let a = g.add_node("A", [0.0, 0.0]); let b = g.add_node("B", [100.0, 0.0]); let _link = g.add_link(a, PinId(0), b, PinId(0)); assert_eq!(g.links.len(), 1); g.remove_node(a); assert!(g.links.is_empty()); } } }` That test runs anywhere Rust runs — no GPU, no window, no display server. egui is _immediate mode_: every frame you rebuild the entire UI from current state. The `logic()` / `ui()` split maps cleanly onto two phases: 1. **`logic()` runs first.** Advance animations, poll background tasks, apply queued intents. This is where `&mut self` mutations of _persistent_ data belong. 2. **`ui()` runs second.** Read state, build widgets, collect new intents (button clicks, drag deltas) for next frame’s `logic()`. ``#![allow(unused)] fn main() { // src/app.rs use eframe::egui; use std::sync::{Arc, Mutex}; pub struct MyApp { pub graph: GraphState, pub ui_state: UiState, pub pending: Arc>>, } /// An intent collected from the UI, to be applied in `logic()`. pub enum Intent { DeleteNode(NodeId), AddLink(NodeId, u64, NodeId, u64), } impl eframe::App for MyApp { fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { // 1. Fold in background results. let mut pending = self.pending.lock().unwrap(); for result in pending.drain(..) { self.apply_background_result(result); } drop(pending); // 2. Apply intents collected last frame. for intent in self.ui_state.intents.drain(..) { match intent { Intent::DeleteNode(id) => self.graph.remove_node(id), Intent::AddLink(a, ap, b, bp) => { self.graph.add_link(a, ap, b, bp); } } } } fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { // Read-only access to graph; collect intents for next frame. self.draw_graph(ui); self.draw_panels(ui); } } }`` The `intents` buffer decouples “the user clicked delete” from “delete the node.” That decoupling is what lets `ui()` stay mostly immutable and keeps the borrow checker happy. > **Where do intents get pushed?** Inside `draw_graph` or `draw_panels` — the methods called from `ui()`. For example, a delete button’s click handler would do `self.ui_state.intents.push(Intent::DeleteNode(node.id))` rather than calling `self.graph.remove_node(...)` directly. The actual mutation happens in the next frame’s `logic()`, which drains the buffer. The `apply_background_result` method called in `logic()` is a placeholder for whatever your app does with loaded data — e.g., parsing bytes into a graph, updating a status message, etc. Define it as a regular `&mut self` method on `MyApp`. Long-running work — reading a large file, decoding an image, running a simulation — must not happen on the UI thread or the frame rate collapses. The pattern is: 1. **Spawn** the work on another thread. 2. The worker stores its result in a shared `Arc>`. 3. The worker calls `ctx.request_repaint()` so egui wakes up to consume the result. 4. **Poll** the shared buffer in `logic()` and fold results into your state. Because egui repaints on demand, a background thread finishing its work does not by itself cause a frame. The worker must explicitly request one. ``#![allow(unused)] fn main() { use eframe::egui; use std::sync::{Arc, Mutex}; use std::thread; pub enum BackgroundResult { FileLoaded { name: String, bytes: Vec }, } impl MyApp { /// Spawn a background file read. The result lands in `self.pending` /// and a repaint is requested when it arrives. pub fn load_file_async( &self, path: std::path::PathBuf, ctx: egui::Context, pending: Arc>>, ) { let name = path .file_name() .map(|n| n.to_string_lossy().to_string()) .unwrap_or_default(); thread::spawn(move || { // Simulate slow I/O. let bytes = std::fs::read(&path).unwrap_or_default(); let result = BackgroundResult::FileLoaded { name, bytes }; { let mut buf = pending.lock().unwrap(); buf.push(result); } // Wake the UI so logic() can drain the buffer this frame. ctx.request_repaint(); }); } fn apply_background_result(&mut self, result: BackgroundResult) { match result { BackgroundResult::FileLoaded { name, bytes: _ } => { // Parse bytes into the graph, update state, etc. self.ui_state.status = format!("Loaded {name}"); } } } } }`` > **Note:** `ctx: egui::Context` is cheaply cloneable and safe to send to another thread — it is wrapped in an `Arc` internally. Cloning it and moving the clone into a thread is the intended way to request repaints from background work. This mirrors the “shared state with `Arc>`” pattern from Rust Book [Chapter 16](https://doc.rust-lang.org/stable/book/ch16-03-shared-state.html) > . For web targets you would use `wasm_bindgen`\-friendly async instead of `std::thread`, but the `Arc>>` + `request_repaint()` shape is identical. The single most useful borrowing technique in egui is **collect, then mutate**. You take an immutable (or carefully scoped) borrow to gather what the user did, end that borrow, and _then_ take `&mut self` to apply the changes. Consider a list of nodes where each can be deleted by a button: ``#![allow(unused)] fn main() { impl MyApp { fn draw_node_list(&mut self, ui: &mut egui::Ui) { // Collect the IDs of nodes whose delete button was clicked. let to_delete: Vec = self .graph .nodes .values() .filter_map(|node| { let mut should_delete = false; ui.horizontal(|ui| { ui.label(&node.title); if ui.button("Delete").clicked() { should_delete = true; } }); should_delete.then_some(node.id) }) .collect(); // Now the immutable borrow of `self.graph.nodes` has ended // (it died with `to_delete`'s creation). We can mutate freely. for id in to_delete { self.graph.remove_node(id); } } } }`` The borrow checker is satisfied because `to_delete` owns _copies_ of the `NodeId` values (the newtype wraps a `Copy` type). By the time we call `remove_node`, no borrow of `self.graph` is outstanding. If you instead tried to mutate inside the iterator — `if clicked { self.graph.remove_node(node.id) }` — you would get the classic “cannot borrow `self.graph` as mutable because it is also borrowed as immutable” error described in Rust Book [Chapter 4](https://doc.rust-lang.org/stable/book/ch04-02-references-and-borrowing.html) . The collect-then-mutate pattern is the standard remedy. Keep a separate struct for ephemeral, non-serialized UI flags: `#![allow(unused)] fn main() { // src/ui/mod.rs use crate::graph::model::NodeId; #[derive(Debug, Default)] pub struct UiState { /// Intents collected this frame, drained in next frame's logic(). pub intents: Vec, /// Which node, if any, is currently being dragged. pub dragged_node: Option, /// The currently hovered node, for highlighting. pub hovered_node: Option, /// Whether the settings panel is open. pub settings_open: bool, /// A transient status message. pub status: String, } }` `UiState` is never written to disk; it resets every time the app starts. `GraphState` and `Config`, by contrast, are serialized with `serde`. Keeping them apart means a `#[derive(Serialize)]` on `MyApp` is trivially safe — you simply skip the `ui_state` field with `#[serde(skip)]`: `#![allow(unused)] fn main() { #[derive(serde::Serialize, serde::Deserialize)] pub struct MyApp { pub graph: GraphState, pub config: Config, #[serde(skip)] pub ui_state: UiState, #[serde(skip)] pub pending: std::sync::Arc>>, } }` Holding `&mut self` inside `ui()` lets you reborrow individual fields, which is the key to ergonomic egui code. The two patterns that resolve almost every conflict: 1. **Scoped closures.** `ui.horizontal(|ui| { ... })` takes a closure that borrows `ui` for its duration; when it returns, the borrow ends and you can touch `self` again. This is the closure-capturing behavior from Rust Book [Chapter 13](https://doc.rust-lang.org/stable/book/ch13-01-closures.html) . 2. **Collect-then-mutate.** Gather copies of what you need, let the borrow end, then mutate — as shown above. When a method needs to both read and write `self`, split it: take an immutable borrow in one method that returns data, and a mutable borrow in another that applies it. `#![allow(unused)] fn main() { impl MyApp { /// Immutable: figure out what the user wants to drag. fn collect_drag(&self, ui: &mut egui::Ui) -> Option<(NodeId, [f64; 2])> { self.graph.nodes.values().find_map(|node| { let resp = ui.add(egui::Label::new(&node.title)); if resp.dragged() { Some((node.id, resp.drag_delta().into())) } else { None } }) } /// Mutable: apply the drag. fn apply_drag(&mut self, id: NodeId, delta: [f64; 2]) { if let Some(node) = self.graph.nodes.get_mut(&id) { node.position[0] += delta[0]; node.position[1] += delta[1]; } } } }` Called back-to-back, these never conflict: `#![allow(unused)] fn main() { if let Some((id, delta)) = self.collect_drag(ui) { self.apply_drag(id, delta); } }` > **Important: what happens to `GraphState` when we adopt `egui-snarl`?** This chapter builds a pure-data `GraphState` and praises its headless testability — and that praise is justified. But when we introduce `egui-snarl` in [Chapter 8](https://danpokhrel.github.io/tutorial/print.html#chapter-8-introducing-egui-snarl) > , the `Snarl` container **replaces** `GraphState` as the runtime graph data structure. `Snarl` is its own graph container: it owns nodes, positions, and wires, and it provides its own `insert_node` / `connect` / `remove_node` methods. So what is `GraphState` for? It is a **pedagogical stepping stone** — it teaches you the data-model-without-GUI pattern before we introduce a library that already embodies it. Once you adopt `Snarl`, you can either (a) delete the `graph/` module entirely and let `Snarl` be your graph, or (b) keep the `graph/` module purely for its unit test as a regression check. We take option (b) in the reference implementation: the `graph/` module compiles with `#![allow(dead_code)]` so the test still runs, but the runtime path uses `Snarl`. The lesson — _keep your data model testable without a GPU_ — carries forward; `Snarl` and `AgentNode` are themselves plain data types that serialize and test cleanly. * * * With our application now cleanly split into a testable data model, a rendering layer, and a state-owning shell, the next concern is making it look good. In [Chapter 6](https://danpokhrel.github.io/tutorial/print.html#chapter-6-theming-fonts--styling) we’ll cover theming — visuals, style, fonts, and custom color palettes — so your application can have a cohesive look instead of egui’s defaults. egui ships with sensible defaults, but most real applications eventually want a distinct look — a cohesive color palette, a custom font (including non-Latin scripts), larger headings, or rounded buttons. egui gives you three independent knobs for this, and in this chapter we’ll learn how each one works and how to bundle them into a reusable `theme` module. This chapter builds on the module-structure ideas from [Chapter 5](https://danpokhrel.github.io/tutorial/print.html#chapter-5-structuring-your-application) ; if you skipped ahead, the `theme.rs` file we create here slots directly into the layout from that chapter. egui separates appearance into three structs, each controlling a different aspect: * **`Visuals`** — _colors_. Background, text, widget backgrounds, borders, selection highlights, and whether the whole theme is dark or light. * **`Style`** — _geometry_. Margins, spacing, corner rounding, text styles (which map `TextStyle` to font + size), and animation timings. * **`FontDefinitions`** — _fonts_. The actual font data and how `FontFamily` values map to lists of fonts. All three live on the `egui::Context` and can be changed at any time. They are also cheap to clone, so you can snapshot and restore them. The quickest way to change your app’s appearance is to swap the entire `Visuals`: `#![allow(unused)] fn main() { use eframe::egui; impl eframe::App for MyApp { fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { ui.horizontal(|ui| { if ui.button("Dark").clicked() { ui.ctx().set_visuals(egui::Visuals::dark()); } if ui.button("Light").clicked() { ui.ctx().set_visuals(egui::Visuals::light()); } }); } } }` `set_visuals` updates the context immediately and applies to every widget drawn afterward in the same frame and beyond. For the familiar sun/moon toggle, older egui versions shipped a built-in helper (`egui::widgets::global_dark_light_mode_buttons`). That helper was **removed in egui 0.35** in favor of the new theme system (`ThemePreference`). The equivalent today is a couple of buttons that call `set_theme`: `#![allow(unused)] fn main() { // egui 0.35 replaced global_dark_light_mode_buttons with the theme API: ui.horizontal(|ui| { if ui.button("Dark").clicked() { ui.ctx().set_theme(egui::ThemePreference::Dark); } if ui.button("Light").clicked() { ui.ctx().set_theme(egui::ThemePreference::Light); } }); }` This renders two small buttons that flip between the dark and light themes. It is the easiest way to give users control. To follow the OS preference, query it once at startup. In egui 0.35 the `eframe::dark_light::detect()` helper was removed; the replacement is `ctx.system_theme()`, which returns an `Option` reflecting the OS preference (or `None` on platforms where detection is unavailable): `#![allow(unused)] fn main() { // egui 0.35: eframe::dark_light::detect() was removed. // Use ctx.system_theme() which returns Option. impl MyApp { pub fn new(ctx: &egui::Context) -> Self { // Follow the OS theme if available, default to dark. let theme = ctx.system_theme().unwrap_or(egui::Theme::Dark); ctx.set_theme(theme); Self { /* ... */ } } } }` > **Tip:** `ctx.system_theme()` probes the OS for the current theme and returns `Option`. On platforms where detection fails it returns `None`, so falling back to dark — the safe default for developer tools — is a good idea. Sometimes you want a change to apply only to a subtree — a red error panel, a compact toolbar — without affecting the rest of the app. Use `ui.scope`: `#![allow(unused)] fn main() { ui.scope(|ui| { // Make everything in this scope use smaller spacing and a red tint. ui.style_mut().spacing.item_spacing = egui::vec2(2.0, 2.0); ui.visuals_mut().override_text_color = Some(egui::Color32::RED); ui.label("This is a compact, red warning block."); ui.button("Me too"); }); // Outside the scope, the original style and visuals are restored. ui.button("Back to normal spacing and color"); }` `ui.scope` snapshots `Style` and `Visuals` on entry and restores them on exit, so any mutation made through `ui.style_mut()` or `ui.visuals_mut()` is local to the scope. This is the immediate-mode equivalent of a CSS scope and costs nothing persistent. For one-off styling without a scope, `RichText` and `Button` carry their own style overrides. This is the idiomatic way to color a single label or fill a single button: `#![allow(unused)] fn main() { use eframe::egui; impl eframe::App for MyApp { fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { // RichText lets you set color, size, and font family inline. ui.label( egui::RichText::new("Danger!") .color(egui::Color32::RED) .text_style(egui::TextStyle::Heading) .strong(), ); // Button styling: fill, stroke, and text color. if ui .add( egui::Button::new( egui::RichText::new("Delete") .color(egui::Color32::WHITE), ) .fill(egui::Color32::from_rgb(180, 40, 40)) .stroke(egui::Stroke::NONE), ) .clicked() { // ... } } } }` Because `RichText` and `Button` are builders, you can chain as many overrides as you need. They do not touch the context, so they are safe to use anywhere. egui’s font system has three pieces: * **`FontData`** — raw font bytes, wrapped from a `&'static [u8]` or owned `Vec`. * **`FontFamily`** — a named family: `Proportional` (the default for body text) or `Monospace` (code). You can define your own as well. * **`FontDefinitions`** — the map that says “for `Proportional`, try font A, then B, then C.” Each family is a _fallback chain_: egui tries the first font for a glyph, and if it lacks that glyph, falls through to the next. This is how you mix a Latin font with a CJK font. `#![allow(unused)] fn main() { use eframe::egui; fn install_fonts(ctx: &egui::Context) { let mut fonts = egui::FontDefinitions::default(); // Register a custom proportional font from bytes compiled in. fonts.font_data.insert( "my_sans".to_owned(), egui::FontData::from_static(include_bytes!( "../assets/MySans-Regular.ttf" )), ); // Put it first in the Proportional fallback chain. fonts .families .entry(egui::FontFamily::Proportional) .or_default() .insert(0, "my_sans".to_owned()); ctx.set_fonts(fonts); } }` `include_bytes!` embeds the file into the binary at compile time (Rust Book [Chapter 19.5](https://doc.rust-lang.org/stable/book/ch19-06-macros.html) discusses macros more broadly; `include_bytes!` itself is documented among the standard macros). Using `from_static` lets egui borrow the bytes with a `'static` lifetime, avoiding a copy. To support Chinese, Japanese, or Korean text, embed a CJK font and _prepend_ it to the `Proportional` chain. Prepending (rather than appending) ensures CJK glyphs render in the CJK font rather than falling through to the Latin font’s missing-glyph box: `#![allow(unused)] fn main() { fn install_cjk_fonts(ctx: &egui::Context) { let mut fonts = egui::FontDefinitions::default(); fonts.font_data.insert( "noto_cjk".to_owned(), egui::FontData::from_static(include_bytes!( "../assets/NotoSansCJK-Regular.ttc" )), ); // Prepend so CJK glyphs are found here first. fonts .families .entry(egui::FontFamily::Proportional) .or_default() .insert(0, "noto_cjk".to_owned()); // Also make it available as its own family for explicit use. fonts .families .entry(egui::FontFamily::Name("CJK".into())) .or_default() .push("noto_cjk".to_owned()); ctx.set_fonts(fonts); } }` > **Warning:** CJK font files are large (often 10–20 MB). If binary size matters, consider subsetting the font to the glyphs you need before embedding, or loading it from disk at runtime via `FontData::from_owned(std::fs::read(path)?)`. egui maps a small set of `TextStyle` values to `(FontFamily, f32 size)` pairs inside `Style::text_styles`. Since egui 0.34 the default body size is **13.0 pt**. The standard styles are: * `TextStyle::Small` * `TextStyle::Body` — the default for `ui.label`. * `TextStyle::Monospace` * `TextStyle::Button` * `TextStyle::Heading` You can override any of them, or add your own: `#![allow(unused)] fn main() { fn tune_text_styles(ctx: &egui::Context) { use egui::{FontFamily, TextStyle}; let mut style = (*ctx.global_style()).clone(); style.text_styles = [ (TextStyle::Heading, FontFamily::Proportional, 22.0), (TextStyle::Body, FontFamily::Proportional, 15.0), (TextStyle::Monospace, FontFamily::Monospace, 13.0), (TextStyle::Button, FontFamily::Proportional, 15.0), (TextStyle::Small, FontFamily::Proportional, 11.0), ] .into_iter() .collect(); ctx.set_global_style(style); } }` To use a specific style on a label, use `RichText::text_style`: `#![allow(unused)] fn main() { ui.label(egui::RichText::new("Title").text_style(egui::TextStyle::Heading)); }` There are two ways to display images in egui. The `egui_extras` crate provides image loaders that fetch from a URI (a file path, `http://` URL, or `bytes://` embedding). Install them once at startup and then use `ui.image`: `#![allow(unused)] fn main() { use eframe::egui; impl MyApp { pub fn new(ctx: &egui::Context) -> Self { // Install the loaders. Do this exactly once. egui_extras::install_image_loaders(ctx); Self { /* ... */ } } } impl eframe::App for MyApp { fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { // The loader fetches and caches the image asynchronously. ui.image(egui::include_image!("../assets/logo.png")); } } }` `egui::include_image!` embeds the image and returns a URI `ui.image` understands. For runtime paths, pass the string URI directly. > **Warning — the #1 cause of blank images:** forgetting to call `egui_extras::install_image_loaders(ctx)`. Without it, `ui.image` has no loader registered and silently renders nothing (or a placeholder) on the first frames. If your images never appear, check this first. For full control — procedural textures, dynamically generated images, pixel buffers — create a `TextureHandle` from a `ColorImage`: `#![allow(unused)] fn main() { use eframe::egui; pub struct MyApp { texture: Option, } impl MyApp { pub fn new(ctx: &egui::Context) -> Self { // Build a 64x64 gradient image. let size = [64, 64]; let pixels: Vec = (0..64 * 64) .map(|i| { let t = (i % 64) as f32 / 64.0; egui::Color32::from_rgb( (t * 255.0) as u8, 100, (255.0 - t * 255.0) as u8, ) }) .collect(); let image = egui::ColorImage { size, pixels, }; let texture = ctx.load_texture("my-gradient", image, egui::TextureOptions::LINEAR); Self { texture: Some(texture), } } } impl eframe::App for MyApp { fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { if let Some(tex) = &self.texture { ui.image(egui::load::TextureHandle::id(tex), [128.0, 128.0]); } } } }` Store the `TextureHandle` on your `App` struct so it is not recreated every frame — recreating it would re-upload the data to the GPU each frame and waste bandwidth. Now let’s bundle everything into a `theme.rs` module, as suggested in [Chapter 5](https://danpokhrel.github.io/tutorial/print.html#chapter-5-structuring-your-application) . The goal is one call at startup that applies a cohesive look: ``#![allow(unused)] fn main() { // src/theme.rs use eframe::egui; /// A cohesive color palette for the node-graph editor. pub struct EditorTheme { pub bg: egui::Color32, pub panel_bg: egui::Color32, pub text: egui::Color32, pub accent: egui::Color32, pub accent_hover: egui::Color32, pub node_bg: egui::Color32, pub node_border: egui::Color32, } impl EditorTheme { /// A custom dark theme with a blue accent. pub fn dark() -> Self { Self { bg: egui::Color32::from_rgb(24, 24, 28), panel_bg: egui::Color32::from_rgb(32, 32, 38), text: egui::Color32::from_rgb(220, 222, 228), accent: egui::Color32::from_rgb(86, 140, 245), accent_hover: egui::Color32::from_rgb(120, 165, 255), node_bg: egui::Color32::from_rgb(44, 46, 54), node_border: egui::Color32::from_rgb(70, 74, 86), } } /// Apply the theme to a context: visuals, style, and fonts together. pub fn apply(&self, ctx: &egui::Context) { // Pin the app to dark mode. Without this, egui follows the system // theme and `set_visuals` below would only restyle whichever theme // happened to be active (or not apply at all on a light-mode OS). ctx.set_theme(egui::Theme::Dark); let mut visuals = egui::Visuals::dark(); // Backgrounds. (egui 0.35 renamed panel_bg → panel_fill, // window_bg → window_fill.) visuals.panel_fill = self.panel_bg; visuals.window_fill = self.panel_bg; visuals.extreme_bg_color = self.bg; // Widgets. egui 0.35 distinguishes bg_fill (always painted) from // weak_bg_fill (only for widgets with meaningful backgrounds). // For noninteractive, use bg_fill; for the rest, weak_bg_fill. visuals.widgets.noninteractive.bg_fill = self.bg; visuals.widgets.noninteractive.fg_stroke = egui::Stroke::new(1.0, self.text); visuals.widgets.inactive.weak_bg_fill = self.node_bg; visuals.widgets.inactive.fg_stroke = egui::Stroke::new(1.0, self.text); visuals.widgets.hovered.weak_bg_fill = self.accent_hover; visuals.widgets.hovered.fg_stroke = egui::Stroke::new(1.0, egui::Color32::WHITE); visuals.widgets.active.weak_bg_fill = self.accent; visuals.widgets.active.fg_stroke = egui::Stroke::new(1.0, egui::Color32::WHITE); // Selection and hyperlinks use the accent. visuals.selection.bg_fill = self.accent; visuals.hyperlink_color = self.accent; ctx.set_visuals(visuals); // Geometry: comfortable spacing and slightly rounded widgets. // egui 0.35: ctx.style()/set_style() → global_style()/set_global_style(). let mut style = (*ctx.global_style()).clone(); style.spacing.item_spacing = egui::vec2(8.0, 6.0); style.spacing.button_padding = egui::vec2(10.0, 4.0); style.spacing.window_margin = egui::Margin::same(10); // egui 0.35's Widgets struct has no iter_mut(); set each variant. let corner = egui::CornerRadius::same(6); style.visuals.widgets.noninteractive.corner_radius = corner; style.visuals.widgets.inactive.corner_radius = corner; style.visuals.widgets.hovered.corner_radius = corner; style.visuals.widgets.active.corner_radius = corner; style.visuals.widgets.open.corner_radius = corner; ctx.set_global_style(style); } } }`` > **Note:** `Visuals::dark()` is our starting point; we then override the fields we care about. Starting from a built-in preset and overriding is more robust than building a `Visuals` from scratch, because new egui versions may add fields that default sensibly when you start from a preset. > **egui 0.35 widget backgrounds:** The `Widgets` struct distinguishes `bg_fill` (always painted) from `weak_bg_fill` (only painted when the widget has a meaningful background, like a button). For most interactive widgets you want `weak_bg_fill`; for `noninteractive` backgrounds you want `bg_fill`. Using the wrong one can cause subtle visual glitches (a button that always shows a background even when you wanted it transparent). Applying it is a one-liner at startup: `#![allow(unused)] fn main() { // src/app.rs use crate::theme::EditorTheme; impl MyApp { pub fn new(ctx: &egui::Context) -> Self { EditorTheme::dark().apply(ctx); Self { /* ... */ } } } }` Because the theme lives in its own module with no `egui::Ui` rendering logic, you can later add a `EditorTheme::light()` constructor, serialize user preferences into it, or swap themes at runtime by calling `apply` again — the context picks up the change immediately. * * * Your application now has a clean architecture and a custom look. Next we need to let the user _do_ things: type into boxes, open menus, pick files, and respond to confirmation dialogs. In [Chapter 7](https://danpokhrel.github.io/tutorial/print.html#chapter-7-input-menus--dialogs) we’ll cover input handling, menu bars, floating windows, modal dialogs, and native file pickers. An application is more than a display of data — it responds to the keyboard, the mouse, menus, and dialogs. In this chapter we’ll cover egui’s input system, build a menu bar with dropdowns, add right-click context menus, open floating windows, implement a modal dialog from scratch (egui has no built-in one), and wire up native file dialogs with the `rfd` crate. By the end we’ll assemble all of it into a single example app. This chapter assumes you have read [Chapter 5](https://danpokhrel.github.io/tutorial/print.html#chapter-5-structuring-your-application) on structuring your app and [Chapter 6](https://danpokhrel.github.io/tutorial/print.html#chapter-6-theming-fonts--styling) on theming. All input state lives on the `egui::Context`. Because the context’s input is guarded by an internal lock, you access it through **closures** — you must never hold the lock across widget building. The closure form `ctx.input(|i| ...)` takes a shared borrow, runs your closure, and releases the lock before returning: `#![allow(unused)] fn main() { use eframe::egui; impl eframe::App for MyApp { fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { // Read-only input access via a closure. let save_pressed = ctx.input(|i| { i.key_pressed(egui::Key::S) && (i.modifiers.ctrl || i.modifiers.command) }); if save_pressed { self.save(); } } fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { // ... } } }` > **Warning:** Never store the `egui::InputState` returned by the closure outside it, and never call `ui.add(...)` from inside the closure. The lock must be released before you build widgets, or you will deadlock or panic. The closure form makes this impossible to get wrong — the borrow ends when the closure returns. For mutations — consuming an event so no one else sees it — use `ctx.input_mut`: `#![allow(unused)] fn main() { ctx.input_mut(|i| { if i.key_pressed(egui::Key::Escape) { i.events.clear(); // consume remaining events this frame } }); }` > **When to use `input_mut`:** You rarely need it. The main use case is `consume_shortcut`, which marks a shortcut as handled so it does not fire twice. For simply reading input state, always use `ctx.input(|i| ...)` (the shared borrow). Mutating input state outside of shortcut consumption is almost never necessary. Every widget you add to a `Ui` returns a `Response`, which tells you how the user interacted with _that specific widget_. This is the primary way to react to clicks, hovers, and drags: `#![allow(unused)] fn main() { let response = ui.button("Click me"); if response.clicked() { println!("clicked"); } if response.hovered() { println!("hovered"); } // Tooltips and context menus are also methods on Response. response.on_hover_text("This button does a thing"); }` The most useful `Response` methods are: | Method | Meaning | | --- | --- | | `clicked()` | The widget was clicked this frame. | | `hovered()` | The pointer is over the widget. | | `dragged()` | The widget is being dragged (button held + moved). | | `drag_delta()` | How far it moved since last frame. | | `on_hover_text(s)` | Show a tooltip. | | \`context\_menu( | ui | Because `Response` is returned by value, you can collect interaction results and act on them after the immutable borrow ends — the collect-then-mutate pattern from [Chapter 5](https://danpokhrel.github.io/tutorial/print.html#chapter-5-structuring-your-application) . For structured shortcuts, use `KeyboardShortcut` and `consume_shortcut`. This pairs a `Modifiers` value with a `Key` and lets egui handle the bookkeeping of whether the shortcut was pressed _and_ not yet consumed: `#![allow(unused)] fn main() { use eframe::egui; impl eframe::App for MyApp { fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { let new_shortcut = egui::KeyboardShortcut::new( egui::Modifiers::CTRL, egui::Key::N, ); let open_shortcut = egui::KeyboardShortcut::new( egui::Modifiers::CTRL, egui::Key::O, ); ctx.input_mut(|i| { if i.consume_shortcut(&new_shortcut) { self.new_document(); } if i.consume_shortcut(&open_shortcut) { self.open_document(); } }); } fn ui(&mut self, _ui: &mut egui::Ui, _frame: &mut eframe::Frame) {} } }` `consume_shortcut` returns `true` only if the shortcut was pressed this frame and has not already been consumed, which prevents double-handling when multiple widgets listen for the same key. The pattern of branching on a value and running code per arm is the same `match`\-style flow control introduced in Rust Book [Chapter 6](https://doc.rust-lang.org/stable/book/ch06-02-match.html) . Mouse state is part of the same input closure: `#![allow(unused)] fn main() { ctx.input(|i| { let pos = i.pointer.primary_pressed(); // Option on press let _hover = i.pointer.hover_pos(); // Option current pos let _mods = i.modifiers; // Modifiers (ctrl, shift, ...) let _clicked = i.pointer.primary_clicked(); }); }` `pointer.primary_pressed()` fires on the frame the button goes down; `primary_clicked()` fires on a completed click (down then up without much movement). Use `hover_pos()` for things like cursor-following tooltips. A top menu bar is a `Panel::top` containing a `MenuBar` closure. Inside, `ui.menu_button` creates a dropdown, and `ui.close()` closes it programmatically (useful after an action that should dismiss the menu): `#![allow(unused)] fn main() { use eframe::egui; impl eframe::App for MyApp { fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { egui::Panel::top("menu_bar").show(ui, |ui| { egui::MenuBar::new().ui(ui, |ui| { ui.menu_button("File", |ui| { if ui.button("New").clicked() { self.new_document(); ui.close(); } if ui.button("Open...").clicked() { self.open_document(); ui.close(); } if ui.button("Save").clicked() { self.save(); ui.close(); } ui.separator(); if ui.button("Quit").clicked() { ui.ctx().send_viewport_cmd( egui::ViewportCommand::Close, ); } }); ui.menu_button("Edit", |ui| { if ui.button("Undo").clicked() { self.undo(); ui.close(); } }); }); }); } } }` Recall from [Chapter 5](https://danpokhrel.github.io/tutorial/print.html#chapter-5-structuring-your-application) that `Panel::show` takes a `&mut Ui` (since 0.34). The menu items are ordinary buttons; their `clicked()` results drive your app state. Right-click menus attach to any `Response`: `#![allow(unused)] fn main() { let response = ui.label("Right-click me"); response.context_menu(|ui| { if ui.button("Rename").clicked() { self.ui_state.renaming = true; ui.close(); } if ui.button("Delete").clicked() { self.delete_selected(); ui.close(); } ui.separator(); if ui.button("Duplicate").clicked() { self.duplicate_selected(); ui.close(); } }); }` The menu appears automatically when the user right-clicks the widget and disappears when an item is chosen or the user clicks elsewhere. No extra state is required on your part. `egui::Window` is a floating, draggable, collapsible, resizable panel — the kind of window you’d use for a settings dialog or a tool palette. Its `.show` method takes a `&mut Ui` and a closure that builds its contents: `#![allow(unused)] fn main() { use eframe::egui; impl eframe::App for MyApp { fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { // A toggle button controls the window's visibility. ui.checkbox(&mut self.ui_state.settings_open, "Settings"); if self.ui_state.settings_open { egui::Window::new("Settings") .open(&mut self.ui_state.settings_open) .default_pos([100.0, 100.0]) .resizable(true) .collapsible(true) .show(ui, |ui| { ui.label("Application settings"); ui.separator(); ui.checkbox(&mut self.config.autosave, "Autosave"); ui.add( egui::Slider::new(&mut self.config.zoom, 0.5..=3.0) .text("Zoom"), ); }); } } } }` The `.open(&mut bool)` argument binds the window’s open state to a flag. When the user clicks the window’s close button (the X), egui sets the flag to `false`, which is how you detect that the user dismissed it. egui 0.35 introduced a built-in **`egui::Modal`** widget — `Modal::new(id).show(ctx, |ui| ...)` returns a `ModalResponse` with a `backdrop_response` you can check for click-to-dismiss. This is the recommended approach: it handles the dimming, click-capture, and centering for you, so you do not have to hand-roll an `Area` + `Window` stack. A quit-confirmation modal is a one-screen read once you have it. Gate it behind a `quit_modal_open: bool` on your `App`, render it only while the flag is set, and dismiss it on Cancel, Escape, backdrop click, or a confirmed Quit: `#![allow(unused)] fn main() { use eframe::egui; impl eframe::App for MyApp { fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { // ... your normal panels ... // --- Quit confirmation modal (egui 0.35's built-in Modal) --- if self.quit_modal_open { let resp = egui::Modal::new(egui::Id::new("quit_modal")).show(ui.ctx(), |ui| { ui.set_min_width(280.0); ui.heading("Quit"); ui.label("Quit the application?"); ui.add_space(8.0); ui.horizontal(|ui| { // Cancel on the button or the Escape key. let cancel = ui.button("Cancel").clicked() || ui.input(|i| i.key_pressed(egui::Key::Escape)); if cancel { self.quit_modal_open = false; } // Confirm on the button or Enter. let quit = ui.button("Quit").clicked() || ui.input(|i| i.key_pressed(egui::Key::Enter)); if quit { ui.ctx().send_viewport_cmd(egui::ViewportCommand::Close); } }); }); // Clicking the dimmed backdrop also dismisses the modal. if resp.backdrop_response.clicked() { self.quit_modal_open = false; } } } } }` > **Tip: intercept the window’s close button too.** The OS window-manager close (X) button bypasses your modal by default. To route it through the same confirmation, check `ctx.input(|i| i.viewport().close_requested())` in `logic()` and, when the modal is _not_ already open, send `ViewportCommand::CancelClose` and set `quit_modal_open = true`. If the modal is already open, let the request through — the user is insisting. The reference implementation’s `App::logic` does exactly this; see [“The Assembled App”](https://danpokhrel.github.io/tutorial/print.html#the-assembled-app) > in [Chapter 11](https://danpokhrel.github.io/tutorial/print.html#chapter-11-interactions--ux) > . Below, for completeness and for cases where you need fine-grained control over the dimming/layering that `Modal` does not expose, is the manual `Area + Window` pattern. In practice, prefer `egui::Modal`. `#![allow(unused)] fn main() { use eframe::egui; pub struct ConfirmModal { pub open: bool, pub message: String, pub on_confirm: bool, // set true when user confirms } impl ConfirmModal { /// Render the modal. Returns Some(true) if confirmed, /// Some(false) if cancelled, None otherwise. pub fn show(&mut self, ui: &mut egui::Ui) -> Option { if !self.open { return None; } let mut result = None; // Dim the rest of the screen and capture clicks. let screen = ui.ctx().screen_rect(); egui::Area::new(egui::Id::new("modal_dimmer")) .order(egui::Order::Foreground) .fixed_pos(screen.min) .show(ui, |ui| { // A transparent-but-clickable layer over the whole screen. let _ = ui.allocate_rect(screen, egui::Sense::click()); let painter = ui.painter(); painter.rect_filled( screen, egui::CornerRadius::ZERO, egui::Color32::from_black_alpha(120), ); }); // The dialog itself, centered. let dialog_size = egui::vec2(320.0, 140.0); let pos = screen.center() - dialog_size / 2.0; egui::Window::new("Confirm") .order(egui::Order::Foreground) .fixed_pos(pos) .resizable(false) .collapsible(false) .movable(false) .show(ui, |ui| { ui.set_min_width(dialog_size.x); ui.label(&self.message); ui.add_space(10.0); ui.horizontal(|ui| { if ui.button("Cancel").clicked() { result = Some(false); } if ui.button("OK").clicked() { result = Some(true); } }); }); if let Some(confirmed) = result { self.open = false; Some(confirmed) } else { None } } } }` The dimming `Area` is drawn first with `Order::Foreground` and allocates the full screen rect with `Sense::click()`, which “eats” clicks so they don’t fall through to widgets underneath. The dialog `Window` is also `Foreground` and drawn after, so it sits on top of the dimmer. Because `movable(false)` and `resizable(false)` are set, it behaves like a true modal. > **Tip:** If you need several modals, give each dimmer a unique `Id` so they don’t clash, and track a `current_modal: Option` enum so only one is open at a time. Enums for state machines are the same idea as Rust Book [Chapter 6](https://doc.rust-lang.org/stable/book/ch06-01-defining-an-enum.html) > . egui does not ship a file picker. The standard choice is the [`rfd`](https://crates.io/crates/rfd) crate (Rusty File Dialog), which opens native OS dialogs and works on the web with an async fallback. Add it to your `Cargo.toml`: `[dependencies] eframe = "0.35" rfd = "0.15"` On desktop, `rfd` is synchronous and returns the result immediately: `#![allow(unused)] fn main() { fn open_file_dialog_sync() -> Option { rfd::FileDialog::new() .add_filter("Graph", &["json"]) .set_title("Open graph") .pick_file() } }` For background-friendliness (and web support), use the async API and spawn it the way we did in [Chapter 5](https://danpokhrel.github.io/tutorial/print.html#chapter-5-structuring-your-application) — store the result in an `Arc>` and call `ctx.request_repaint()`: ``#![allow(unused)] fn main() { use std::sync::{Arc, Mutex}; use eframe::egui; impl MyApp { pub fn open_file_async( &self, ctx: egui::Context, pending: Arc>>, ) { // `rfd::AsyncFileDialog` works on both native and web. let task = rfd::AsyncFileDialog::new() .add_filter("Graph", &["json"]) .set_title("Open graph") .pick_file(); // On native this spawns a thread; on web it integrates with the // browser's async runtime. Either way, the future resolves later. std::thread::spawn(move || { // Block on the future. On web you'd use a wasm-friendly // executor instead; see rfd's web docs. let file = pollster::block_on(task); if let Some(file) = file { let path = file.path().to_path_buf(); let bytes = std::fs::read(&path).unwrap_or_default(); { let mut buf = pending.lock().unwrap(); buf.push(BackgroundResult::FileLoaded { name: path .file_name() .map(|n| n.to_string_lossy().to_string()) .unwrap_or_default(), bytes, }); } ctx.request_repaint(); } }); } } }`` > **Note:** On the web, `rfd`’s async dialog must be driven by the browser’s event loop and cannot be blocked on with `pollster`. The `Arc>` + `request_repaint()` handoff is the same; only the executor differs. Consult the `rfd` crate’s web documentation for the exact integration with your async runtime. > **Note:** `pollster` is not in our `Cargo.toml`. Add `pollster = "0.3"` if you want the async path. However, on native desktop, `rfd::FileDialog::pick_file()` is already synchronous and blocking — you do not need `pollster`, threads, or async at all. The async API is only needed for web (WASM) targets, where `std::thread` is unavailable. Saving is symmetric — use `save_file()` / `AsyncFileDialog::save_file()`. Clipboard access is on the context: `#![allow(unused)] fn main() { ctx.copy_text("Hello, clipboard"); let pasted = ctx.input(|i| i.events.iter().find_map(|e| { if let egui::Event::Paste(s) = e { Some(s.clone()) } else { None } })); }` `ctx.copy_text` places text on the OS clipboard. Paste arrives as an `Event::Paste` in the input stream; Ctrl/Cmd-V is handled by egui automatically when a text widget has focus, so you usually only need to read paste events manually for custom widgets. You can drive the OS window from inside egui using `ViewportCommand`, sent via `ctx.send_viewport_cmd`: `#![allow(unused)] fn main() { use eframe::egui; impl eframe::App for MyApp { fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { let ctx = ui.ctx(); ui.horizontal(|ui| { if ui.button("Fullscreen").clicked() { ctx.send_viewport_cmd( egui::ViewportCommand::Fullscreen(true), ); } if ui.button("Minimize").clicked() { ctx.send_viewport_cmd(egui::ViewportCommand::Minimize(true)); } if ui.button("SetTitle").clicked() { ctx.send_viewport_cmd( egui::ViewportCommand::Title("New Title".into()), ); } if ui.button("Quit").clicked() { ctx.send_viewport_cmd(egui::ViewportCommand::Close); } }); } } }` Other useful commands include `InnerSize`, `OuterSize`, `Position`, `Maximized`, `Visible`, and `StartDragging` (lets the user drag an undecorated window). These correspond to the underlying windowing system’s capabilities. Let’s assemble everything: a menu bar (File → New / Open / Save / Quit), a settings floating window, and a confirmation modal that appears before quitting. This draws on the architecture from [Chapter 5](https://danpokhrel.github.io/tutorial/print.html#chapter-5-structuring-your-application) and the theming from [Chapter 6](https://danpokhrel.github.io/tutorial/print.html#chapter-6-theming-fonts--styling) . `// src/main.rs use eframe::egui; use std::sync::{Arc, Mutex}; fn main() -> eframe::Result<()> { let options = eframe::NativeOptions { viewport: egui::ViewportBuilder::default() .with_inner_size([900.0, 600.0]) .with_title("Graph Editor"), ..Default::default() }; eframe::run_native( "Graph Editor", options, Box::new(|ctx| Ok(Box::new(App::new(ctx)))), ) } pub enum BackgroundResult { FileLoaded { name: String, bytes: Vec }, } pub struct App { settings_open: bool, autosave: bool, status: String, quit_modal: ConfirmModal, pending: Arc>>, } impl App { fn new(ctx: &egui::Context) -> Self { crate::theme::EditorTheme::dark().apply(ctx); egui_extras::install_image_loaders(ctx); Self { settings_open: false, autosave: true, status: "Ready".into(), quit_modal: ConfirmModal { open: false, message: "Quit without saving?".into(), on_confirm: false, }, pending: Arc::new(Mutex::new(Vec::new())), } } fn new_document(&mut self) { self.status = "New document".into(); } fn open_document(&mut self, ctx: &egui::Context) { self.status = "Opening...".into(); let pending = self.pending.clone(); let ctx = ctx.clone(); std::thread::spawn(move || { let file = rfd::FileDialog::new() .add_filter("Graph", &["json"]) .pick_file(); if let Some(path) = file { let bytes = std::fs::read(&path).unwrap_or_default(); let name = path .file_name() .map(|n| n.to_string_lossy().to_string()) .unwrap_or_default(); { let mut buf = pending.lock().unwrap(); buf.push(BackgroundResult::FileLoaded { name, bytes }); } ctx.request_repaint(); } }); } fn save(&mut self) { self.status = "Saved".into(); } fn request_quit(&mut self) { self.quit_modal.open = true; } } impl eframe::App for App { fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { // Keyboard shortcuts. ctx.input_mut(|i| { let new_sc = egui::KeyboardShortcut::new(egui::Modifiers::CTRL, egui::Key::N); let open_sc = egui::KeyboardShortcut::new(egui::Modifiers::CTRL, egui::Key::O); let save_sc = egui::KeyboardShortcut::new(egui::Modifiers::CTRL, egui::Key::S); let quit_sc = egui::KeyboardShortcut::new( egui::Modifiers::CTRL, egui::Key::Q, ); if i.consume_shortcut(&new_sc) { self.new_document(); } if i.consume_shortcut(&open_sc) { self.open_document(ctx); } if i.consume_shortcut(&save_sc) { self.save(); } if i.consume_shortcut(&quit_sc) { self.request_quit(); } }); // Drain background results. let mut pending = self.pending.lock().unwrap(); for result in pending.drain(..) { match result { BackgroundResult::FileLoaded { name, bytes: _ } => { self.status = format!("Loaded {name}"); } } } drop(pending); } fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { let ctx = ui.ctx().clone(); // --- Menu bar --- egui::Panel::top("menu_bar").show(ui, |ui| { egui::MenuBar::new().ui(ui, |ui| { ui.menu_button("File", |ui| { if ui.button("New").clicked() { self.new_document(); ui.close(); } if ui.button("Open...").clicked() { self.open_document(&ctx); ui.close(); } if ui.button("Save").clicked() { self.save(); ui.close(); } ui.separator(); if ui.button("Quit").clicked() { self.request_quit(); ui.close(); } }); ui.menu_button("View", |ui| { ui.checkbox(&mut self.settings_open, "Settings"); }); }); }); // --- Main content --- egui::CentralPanel::default().show(ui, |ui| { ui.heading("Graph Editor"); ui.label(&self.status); ui.checkbox(&mut self.settings_open, "Show settings"); // Context menu on a label, for variety. let resp = ui.label("Right-click here for options"); resp.context_menu(|ui| { if ui.button("New document").clicked() { self.new_document(); ui.close(); } if ui.button("Toggle settings").clicked() { self.settings_open = !self.settings_open; ui.close(); } }); }); // --- Settings floating window --- if self.settings_open { egui::Window::new("Settings") .open(&mut self.settings_open) .default_pos([120.0, 120.0]) .resizable(true) .show(ui, |ui| { ui.checkbox(&mut self.autosave, "Autosave"); ui.label("Theme:"); ui.horizontal(|ui| { if ui.button("Dark").clicked() { ui.ctx().set_theme(egui::ThemePreference::Dark); } if ui.button("Light").clicked() { ui.ctx().set_theme(egui::ThemePreference::Light); } }); }); } // --- Quit confirmation modal --- if let Some(confirmed) = self.quit_modal.show(ui) { if confirmed { ctx.send_viewport_cmd(egui::ViewportCommand::Close); } } } } /// A simple confirmation modal built from Area + Window. pub struct ConfirmModal { pub open: bool, pub message: String, pub on_confirm: bool, } impl ConfirmModal { pub fn show(&mut self, ui: &mut egui::Ui) -> Option { if !self.open { return None; } let mut result = None; let screen = ui.ctx().screen_rect(); // Dimmer: full-screen, eats clicks. egui::Area::new(egui::Id::new("quit_modal_dimmer")) .order(egui::Order::Foreground) .fixed_pos(screen.min) .show(ui, |ui| { let _ = ui.allocate_rect(screen, egui::Sense::click()); ui.painter().rect_filled( screen, egui::CornerRadius::ZERO, egui::Color32::from_black_alpha(120), ); }); // Dialog window, centered and immovable. let size = egui::vec2(300.0, 120.0); let pos = screen.center() - size / 2.0; egui::Window::new("Confirm") .order(egui::Order::Foreground) .fixed_pos(pos) .resizable(false) .collapsible(false) .movable(false) .show(ui, |ui| { ui.set_min_width(size.x); ui.label(&self.message); ui.add_space(8.0); ui.horizontal(|ui| { if ui.button("Cancel").clicked() { result = Some(false); } if ui.button("Quit").clicked() { result = Some(true); } }); }); if let Some(c) = result { self.open = false; self.on_confirm = c; Some(c) } else { None } } }` > **Tip:** Notice that `logic()` handles keyboard shortcuts and drains background results, while `ui()` only builds widgets and collects the _intent_ to quit (setting `quit_modal.open = true`). The actual `ViewportCommand::Close` is sent only after the user confirms in the modal. This separation keeps your control flow readable and is the same collect-then-mutate discipline from [Chapter 5](https://danpokhrel.github.io/tutorial/print.html#chapter-5-structuring-your-application) > . The error-handling pattern in the file-open thread — `unwrap_or_default()` on `std::fs::read` — is a deliberately forgiving choice. For production code you’d propagate the error through your `BackgroundResult` enum using `Result`, as described in Rust Book [Chapter 9](https://doc.rust-lang.org/stable/book/ch09-02-recoverable-errors-with-result.html) , and display it in the UI. * * * We now have an application that responds to the keyboard and mouse, offers menus and context menus, pops up floating settings windows, confirms destructive actions with a modal, and opens native file dialogs. In the next chapter we’ll turn our attention to the canvas — drawing custom geometry, handling pan-and-zoom, and rendering the node graph itself. So far we have built conventional egui applications — windows, panels, menus, dialogs. Now we turn to a different kind of widget: the _node graph_. A node graph is a canvas where the user places boxes (nodes), connects them with wires, and edits data visually. This is the foundation of visual scripting, shader graphs, material editors, and — as we will see in Part 4 — agentic AI flows. In this chapter we introduce the `egui-snarl` crate, learn its core concepts, and build a minimal working graph editor. This chapter assumes you have read [Chapter 5](https://danpokhrel.github.io/tutorial/print.html#chapter-5-structuring-your-application) on structuring your app and [Chapter 7](https://danpokhrel.github.io/tutorial/print.html#chapter-7-input-menus--dialogs) on input and menus. A node graph is a two-dimensional canvas on which the user manipulates _nodes_ and _wires_. Each node is a small panel that holds some data — a number, a string, a function — and exposes _pins_: input endpoints on the left and output endpoints on the right. The user draws _wires_ from an output pin to an input pin to feed data from one node into another. The result is a directed graph that the program can evaluate. You have seen node graphs if you have ever used a shader editor (like Blender’s shader nodes), a visual scripting system (like Unreal’s Blueprints), or a compositing tool (like Nuke). They are powerful because they let the user express _structure_ — what connects to what — visually, while keeping _values_ editable in place. The `egui-snarl` crate, written by Alex Krepa, gives egui a reusable node-graph widget. It handles the hard parts — dragging nodes, panning and zooming the canvas, drawing wires, hit-testing pins — and leaves the _content_ of each node up to you. > **Note:** `egui-snarl` 0.11.0 officially targets egui 0.34, but it works cleanly with egui 0.35.0, the version this book uses. If you encounter a breaking change in a future egui release, check the crate’s repository for an updated release. `egui-snarl` is built around four concepts. Understanding how they relate is the key to everything that follows. * **`Snarl`** — the container that owns all nodes, their positions, and their wires. It is generic over a _node data type_ `T`, which is usually an enum representing your different kinds of node. `Snarl` is plain data: it knows nothing about rendering. This separation mirrors the data/UI split we established in [Chapter 5](https://danpokhrel.github.io/tutorial/print.html#chapter-5-structuring-your-application) . * **Node** — a single node is a piece of `T` data plus a screen-space position (`Pos2`). You never manipulate a “node struct” directly; instead you talk to the `Snarl` through a `NodeId`, and you read the node’s data by indexing: `snarl[node_id]`. * **Pins** — the input and output endpoints of a node. A pin is identified by an `InPinId { node: NodeId, input: usize }` or `OutPinId { node: NodeId, output: usize }`. The `input`/`output` index is the _ordinal_ of the pin on its node — pin 0, pin 1, and so on. How many pins a node has is decided by your viewer (more on this below). * **Wires** — a connection from an `OutPinId` to an `InPinId`. A single input pin can receive multiple wires (its `remotes` is a `Vec`), and a single output pin can fan out to many inputs. The container is generic and the rendering is pluggable, so `egui-snarl` never needs to know what your nodes _mean_. It only needs to know how many pins each node has and how to draw them — and that is the job of the `SnarlViewer` trait. `SnarlViewer` is a trait you implement on your own struct to drive the UI for each node. The trait is generic over the same `T` as the `Snarl`, so a single viewer knows how to render every node type in your graph. This is the central design idea of `egui-snarl`: **the graph data (`Snarl`) and the rendering logic (`SnarlViewer`) are separate objects.** This is the same separation of concerns the Rust Book calls out when introducing traits in [Chapter 10](https://doc.rust-lang.org/stable/book/ch10-02-traits.html) : a trait defines shared behavior that can be implemented for many types, decoupling _what_ a type can do from _how_ a specific type does it. Here, `SnarlViewer` is the abstraction, and your concrete struct is one implementation of it. The trait has five required methods: ``#![allow(unused)] fn main() { use egui_snarl::{Snarl, InPin, OutPin, ui::{SnarlPin, SnarlViewer}}; impl SnarlViewer for MyViewer { /// The title shown in the node's header. fn title(&mut self, node: &T) -> String; /// How many input pins this node has. fn inputs(&mut self, node: &T) -> usize; /// How many output pins this node has. fn outputs(&mut self, node: &T) -> usize; /// Render one input pin. Returns a `PinInfo` describing how the pin is drawn. fn show_input( &mut self, pin: &InPin, ui: &mut egui::Ui, snarl: &mut Snarl, ) -> impl SnarlPin + 'static; /// Render one output pin. Returns a `PinInfo` describing how the pin is drawn. fn show_output( &mut self, pin: &OutPin, ui: &mut egui::Ui, snarl: &mut Snarl, ) -> impl SnarlPin + 'static; } }`` The trait also provides a large set of _provided_ methods with sensible defaults — `connect`, `disconnect`, `show_header`, `has_body`/`show_body`, `has_footer`/`show_footer`, context-menu hooks, and more — which we will explore in later chapters. > **Warning:** `SnarlViewer` is **not** `dyn`\-compatible. The `show_input` and `show_output` methods return `impl SnarlPin + 'static`, which uses `impl Trait` in return position. A trait object (`Box>`) cannot be constructed. You must implement the trait on a _concrete struct_, not behind a trait object. This is a Rust limitation discussed alongside trait objects in [Chapter 18](https://doc.rust-lang.org/stable/book/ch18-04-advanced-types.html) > of the Rust Book. You might wonder why `egui-snarl` does not simply give you a `Node` struct with fields you fill in. The reason is flexibility. By moving rendering into a trait, `egui-snarl` lets you: * Store _pure data_ in `Snarl` — no widgets, no `egui::Ui`, no colors. Your graph can be serialized, sent across a thread boundary, or unit-tested without a GPU. This is the data/UI split from [Chapter 5](https://danpokhrel.github.io/tutorial/print.html#chapter-5-structuring-your-application) in action. * Swap the _look_ of the graph without touching the data. A “compact” viewer and a “verbose” viewer can render the same `Snarl`. * Keep the borrow checker happy. Because the viewer owns no persistent state — it just receives `&mut Snarl` per call — you never end up holding overlapping borrows of the graph during rendering. Add the crate to your `Cargo.toml`. We enable the `serde` feature from the start because we will want to serialize graphs in [Chapter 16](https://danpokhrel.github.io/tutorial/print.html#chapter-16-state-persistence) : `[dependencies] eframe = "0.35.0" egui-snarl = { version = "0.11.0", features = ["serde"] } serde = { version = "1.0", features = ["derive"] } [package] edition = "2024" rust-version = "1.92.0"` The `serde` feature implements `Serialize`/`Deserialize` for `Snarl` and its supporting types (as long as your `T` is also `Serialize`/`Deserialize`). If you skip the feature, the graph still works — you just cannot persist it to disk. > **Note:** `egui-snarl` 0.11.0 declares `egui = "0.34"` as a dependency, but because egui’s public API is stable across the 0.34 → 0.35 boundary for the surface area `egui-snarl` uses, the two coexist without conflict. Cargo resolves them to compatible versions as long as your `eframe` 0.35.0 pulls in `egui` 0.35.0. The node data type `T` is almost always an enum — one variant per kind of node. This is a direct application of Rust’s enums as described in the Rust Book’s [Chapter 6](https://doc.rust-lang.org/stable/book/ch06-01-defining-an-enum.html) : an enum lets you say “this value is one of these several things, each of which may carry different data.” Let us define a small set of demo nodes: a `Number` node that holds an `f64`, a `Text` node that holds a `String`, and a `Sink` node that displays whatever is connected to it: `#![allow(unused)] fn main() { use serde::{Deserialize, Serialize}; /// Every node in our graph is one of these variants. #[derive(Debug, Serialize, Deserialize, Clone)] pub enum DemoNode { /// A constant number the user can edit. Number(f64), /// A constant string the user can edit. Text(String), /// A display node: shows the value wired into it. Sink, } }` Each variant carries the _editable state_ of that node kind. A `Number` node remembers its value; a `Sink` node has no state of its own because it only displays what it receives. Notice we derive `Serialize` and `Deserialize` — that is the whole reason we enabled the `serde` feature. We will lean on ownership and borrowing (Rust Book [Chapter 4](https://doc.rust-lang.org/stable/book/ch04-00-understanding-ownership.html) ) heavily: nodes own their `String`s, and the viewer borrows them during rendering rather than copying. Now we build the `App` struct. It owns two things: the `Snarl` (our persistent graph data) and a `DemoViewer` (the rendering logic, which holds no persistent state — it is a zero-sized marker in this minimal example, but in real apps it may hold transient state like a pending drag). We follow the architecture from [Chapter 5](https://danpokhrel.github.io/tutorial/print.html#chapter-5-structuring-your-application) : the `App` owns everything, and `ui()` reads state and builds widgets. `#![allow(unused)] fn main() { use eframe::egui; use egui_snarl::{Snarl, ui::SnarlViewer}; /// The viewer. Holds no persistent state in this minimal example. #[derive(Default)] pub struct DemoViewer; pub struct SnarlApp { snarl: Snarl, viewer: DemoViewer, } impl Default for SnarlApp { fn default() -> Self { let mut snarl = Snarl::new(); // Seed the graph with a couple of nodes so it is not empty. let n = snarl.insert_node(egui::pos2(-120.0, -40.0), DemoNode::Number(42.0)); let s = snarl.insert_node(egui::pos2(120.0, -40.0), DemoNode::Sink); // Connect the number node's output 0 to the sink's input 0. snarl.connect( egui_snarl::OutPinId { node: n, output: 0 }, egui_snarl::InPinId { node: s, input: 0 }, ); Self { snarl, viewer: DemoViewer::default(), } } } }` `Snarl::new()` creates an empty container. `snarl.insert_node(pos, node)` returns a `NodeId` — an opaque, stable identifier you use to refer to the node later. `snarl.connect(from, to)` wires an output pin to an input pin and returns `true` if the connection was created. > **Tip:** `NodeId` is a `Copy` type (a wrapper around a `usize` index), so you can pass it around freely without borrowing. The same is true of `InPinId` and `OutPinId`. Keep in mind, though, that a `NodeId` only remains valid as long as the node exists — if you `remove_node`, the id is recycled. Rendering the graph is a single chain of builder calls inside `CentralPanel`. The `SnarlWidget` is the widget `egui-snarl` provides; it takes a stable `egui::Id` (required so the widget can keep track of drags and selection state across frames), an optional `SnarlStyle`, and finally borrows your `Snarl` and your viewer: `#![allow(unused)] fn main() { use eframe::egui; use egui_snarl::ui::SnarlWidget; impl eframe::App for SnarlApp { fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { eframe::egui::CentralPanel::default().show_inside(ui, |ui| { SnarlWidget::new() .id_salt(egui::Id::new("demo-snarl")) .show(&mut self.snarl, &mut self.viewer, ui); }); } } }` That is the entire render path. The widget handles panning (drag the background), zooming (Ctrl + scroll), dragging nodes (drag a node header), and drawing wires (drag from a pin). Your only job is to answer the trait’s questions: _what is this node’s title? how many pins does it have? how should each pin be drawn?_ The stable `Id` is essential. egui requires that stateful widgets have an `Id` so their interaction state survives across frames; we discussed the role of `Id` in [Chapter 4](https://danpokhrel.github.io/tutorial/print.html#chapter-4-layout-widgets--state) . If you forget it, the widget will panic at runtime. Now we fill in the five required methods. This is where the node content lives. These three are straightforward. They take `&T` (the node data) and return static information about the node: `#![allow(unused)] fn main() { use egui_snarl::ui::SnarlViewer; impl SnarlViewer for DemoViewer { fn title(&mut self, node: &DemoNode) -> String { match node { DemoNode::Number(_) => "Number".to_string(), DemoNode::Text(_) => "Text".to_string(), DemoNode::Sink => "Sink".to_string(), } } fn inputs(&mut self, node: &DemoNode) -> usize { match node { DemoNode::Number(_) => 0, // a source has no inputs DemoNode::Text(_) => 0, DemoNode::Sink => 1, // the sink takes one input } } fn outputs(&mut self, node: &DemoNode) -> usize { match node { DemoNode::Number(_) => 1, // a number provides one output DemoNode::Text(_) => 1, DemoNode::Sink => 0, // the sink provides nothing } } // show_input and show_output follow below. (/* … */) } }` Notice how `match` on the enum — covered in the Rust Book’s [Chapter 6.2](https://doc.rust-lang.org/stable/book/ch06-02-match.html) — drives every decision: the title, the pin count, and (below) the rendering. The number of pins is a _function of the variant_, not a stored field. This is a strength of the enum approach: you cannot accidentally give a `Sink` an output pin, because the `match` arm for `Sink` returns `0` and there is no field to get out of sync. These two methods render a single pin. They receive a pin handle (`&InPin` or `&OutPin`), a `&mut egui::Ui` scoped to that pin’s row, and a `&mut Snarl` so you can read or mutate node data inline. They return a `PinInfo`, which describes how the pin itself (the little dot on the node border) is drawn. Let us start with the output side, which is simpler — when nothing is connected, we show an editable widget for the node’s value: `#![allow(unused)] fn main() { use egui_snarl::{ui::{PinInfo, SnarlPin}, InPin, OutPin}; impl SnarlViewer for DemoViewer { (/* title, inputs, outputs as above */) fn show_output( &mut self, pin: &OutPin, ui: &mut egui::Ui, snarl: &mut Snarl, ) -> impl SnarlPin + 'static { // Index into the node data for this pin's node. let node = &mut snarl[pin.id.node]; match node { DemoNode::Number(value) => { // Editable widget when nothing is wired. ui.add(egui::DragValue::new(value).speed(0.1)); PinInfo::circle().with_fill(egui::Color32::from_rgb(220, 80, 80)) } DemoNode::Text(s) => { ui.text_edit_singleline(s); PinInfo::circle().with_fill(egui::Color32::from_rgb(80, 200, 80)) } DemoNode::Sink => { // Sink has no outputs; this method is never called for it. unreachable!("Sink has no output pins") } } } fn show_input( &mut self, pin: &InPin, ui: &mut egui::Ui, snarl: &mut Snarl, ) -> impl SnarlPin + 'static { // Only the Sink has an input pin (index 0). let node = &mut snarl[pin.id.node]; if let DemoNode::Sink = node { // If something is wired in, show the source value; otherwise show "—". if let Some(remote) = pin.remotes.first() { let src = &snarl[remote.node]; match src { DemoNode::Number(v) => { ui.label(format!("{v}")); } DemoNode::Text(s) => { ui.label(s); } DemoNode::Sink => {} } PinInfo::circle().with_fill(egui::Color32::from_rgb(220, 80, 80)) } else { ui.label("—"); PinInfo::circle().with_fill(egui::Color32::from_rgb(120, 120, 120)) } } else { unreachable!("only Sink has input pins") } } } }` Let us unpack the two ideas this code introduces, because they recur throughout the rest of the book. **The borrow pattern.** `show_input` and `show_output` receive `&mut Snarl`. Inside, we index with `snarl[pin.id.node]` — this uses the `IndexMut` impl that `Snarl` provides — to get a `&mut DemoNode` for the node the pin belongs to. We then edit the node _inline_: dragging the `DragValue` mutates `DemoNode::Number`’s `f64` directly; typing in the text box mutates `DemoNode::Text`’s `String`. There is no copy, no commit step, no separate “edit buffer.” This is ownership and borrowing (Rust Book [Chapter 4](https://doc.rust-lang.org/stable/book/ch04-00-understanding-ownership.html) ) working in our favor: the borrow is short-lived (only for the duration of this method call), so the graph remains free for the next pin’s render. **Reading connected values via `remotes`.** The `InPin` has a `remotes: Vec` — the output pins that are wired into this input. To display the value a `Sink` receives, we look at `pin.remotes.first()`, take the source `OutPinId`, and index into `snarl[remote.node]` to read the source node’s data. `OutPin` has the mirror field: `remotes: Vec`, the inputs this output feeds. We will explore this fully in [Chapter 9](https://danpokhrel.github.io/tutorial/print.html#chapter-9-building-nodes--pins) . `PinInfo::circle().with_fill(color)` returns a `PinInfo` describing a round pin filled with the given color. There are also `.square()` and `.triangle()` shapes, and a `.with_wire_style(...)` modifier we will meet in [Chapter 12](https://danpokhrel.github.io/tutorial/print.html#chapter-12-styling-the-graph) . Color-coding pins by data type is a cheap and effective bit of visual design: red for numbers, green for text, and so on. Putting it all together, here is the complete, runnable-looking file. Save it as `src/main.rs` and run `cargo run`: `use eframe::egui; use egui_snarl::{ ui::{PinInfo, SnarlPin, SnarlViewer, SnarlWidget}, InPin, InPinId, OutPin, OutPinId, Snarl, }; use serde::{Deserialize, Serialize}; #[derive(Debug, Serialize, Deserialize, Clone)] pub enum DemoNode { Number(f64), Text(String), Sink, } #[derive(Default)] pub struct DemoViewer; impl SnarlViewer for DemoViewer { fn title(&mut self, node: &DemoNode) -> String { match node { DemoNode::Number(_) => "Number".to_string(), DemoNode::Text(_) => "Text".to_string(), DemoNode::Sink => "Sink".to_string(), } } fn inputs(&mut self, node: &DemoNode) -> usize { match node { DemoNode::Number(_) | DemoNode::Text(_) => 0, DemoNode::Sink => 1, } } fn outputs(&mut self, node: &DemoNode) -> usize { match node { DemoNode::Number(_) | DemoNode::Text(_) => 1, DemoNode::Sink => 0, } } fn show_input( &mut self, pin: &InPin, ui: &mut egui::Ui, snarl: &mut Snarl, ) -> impl SnarlPin + 'static { if let Some(remote) = pin.remotes.first() { match &snarl[remote.node] { DemoNode::Number(v) => ui.label(format!("{v}")), DemoNode::Text(s) => ui.label(s), DemoNode::Sink => ui.label(""), }; PinInfo::circle().with_fill(egui::Color32::from_rgb(220, 80, 80)) } else { ui.label("—"); PinInfo::circle().with_fill(egui::Color32::from_rgb(120, 120, 120)) } } fn show_output( &mut self, pin: &OutPin, ui: &mut egui::Ui, snarl: &mut Snarl, ) -> impl SnarlPin + 'static { let node = &mut snarl[pin.id.node]; match node { DemoNode::Number(value) => { ui.add(egui::DragValue::new(value).speed(0.1)); PinInfo::circle().with_fill(egui::Color32::from_rgb(220, 80, 80)) } DemoNode::Text(s) => { ui.text_edit_singleline(s); PinInfo::circle().with_fill(egui::Color32::from_rgb(80, 200, 80)) } DemoNode::Sink => unreachable!("Sink has no output pins"), } } } pub struct SnarlApp { snarl: Snarl, viewer: DemoViewer, } impl Default for SnarlApp { fn default() -> Self { let mut snarl = Snarl::new(); let n = snarl.insert_node(egui::pos2(-120.0, -40.0), DemoNode::Number(42.0)); let s = snarl.insert_node(egui::pos2(120.0, -40.0), DemoNode::Sink); snarl.connect( OutPinId { node: n, output: 0 }, InPinId { node: s, input: 0 }, ); Self { snarl, viewer: DemoViewer::default(), } } } impl eframe::App for SnarlApp { fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { egui::CentralPanel::default().show_inside(ui, |ui| { SnarlWidget::new() .id_salt(egui::Id::new("demo-snarl")) .show(&mut self.snarl, &mut self.viewer, ui); }); } } fn main() -> eframe::Result { let options = eframe::NativeOptions::default(); eframe::run_native( "egui-snarl demo", options, Box::new(|_cc| Ok(Box::::default())), ) }` Build and run the project. You should see a canvas with two nodes — a red `Number` node showing `42.0` on the left and a `Sink` node on the right — already wired together. The wire is drawn automatically because we called `snarl.connect` in `default()`. Try the following interactions: * **Drag a node** by its header to move it around the canvas. * **Pan the canvas** by dragging the background. * **Zoom** with Ctrl + scroll wheel. * **Edit the number** by dragging the `DragValue`. The `Sink` updates immediately because `show_input` reads the source node live, every frame. * **Break the wire** by right-clicking a pin and choosing to drop it (we will customize these menus in [Chapter 11](https://danpokhrel.github.io/tutorial/print.html#chapter-11-interactions--ux) ). Notice that editing the `Number` node instantly changes what the `Sink` displays. There is no evaluation step, no “apply” button — the viewer simply reads the connected value every frame. In a real graph we will want an explicit _evaluation_ pass (Part 4), but for live editing of constants, this read-on-render approach is exactly right. > **Tip:** If your graph renders but interactions do not work (you cannot drag nodes or draw wires), the most common cause is a missing or duplicated `Id`. Each `SnarlWidget` on screen must have a unique `egui::Id`. Two widgets sharing an id will corrupt each other’s interaction state. One last structural point, because it will shape how you organize your code. Because `show_input` and `show_output` return `impl SnarlPin + 'static`, the `SnarlViewer` trait cannot be made into a trait object: `#![allow(unused)] fn main() { // This does NOT compile: let viewer: Box> = /* ... */; }` The compiler error points at the `impl Trait` return types and explains that the trait is not object-safe. This is a fundamental limitation of `impl Trait` in return position, as the Rust Book discusses when covering advanced types in [Chapter 18](https://doc.rust-lang.org/stable/book/ch18-04-advanced-types.html) . The practical consequence: implement `SnarlViewer` on a _concrete struct_ — `DemoViewer` here — and store that struct by value on your `App`. You cannot hide the viewer behind `Box`, and you cannot put different viewer types in a `Vec`. If you need multiple “skins” for the same graph, you have two options: a single viewer that branches internally, or generics over the viewer type. In this chapter we met `egui-snarl` and built our first working node graph. We learned that `Snarl` is a data container, that the `SnarlViewer` trait is the rendering layer, and that the two are deliberately separate so the graph stays pure data. We defined a node enum, implemented the five required trait methods, and rendered a live graph inside `CentralPanel`. The key patterns — indexing `snarl[node_id]` to read or edit node data, and reading `pin.remotes` to display connected values — will recur in every following chapter. We kept our nodes simple: an editable `Number`, an editable `Text`, and a read-only `Sink`. In [Chapter 9](https://danpokhrel.github.io/tutorial/print.html#chapter-9-building-nodes--pins) we will dig into the pin rendering pattern in depth — how to draw connected and unconnected pins differently, how to build node bodies and headers, and how to give each node type its own look. In [Chapter 8](https://danpokhrel.github.io/tutorial/print.html#chapter-8-introducing-egui-snarl) we built a working node graph with three node types, but we barely scratched the surface of what a node can display. Our `Sink` showed a connected value as a label, and our `Number` node showed a `DragValue` — but real graphs need richer pins, node bodies, colored headers, and the ability to edit values inline. In this chapter we unpack the `show_input`/`show_output` rendering pattern in full, learn the `PinInfo` toolkit, add node bodies and footers, and customize per-node-type headers. By the end you will be able to render nodes that show editable widgets when unconnected and live values when connected — the visual contract of every good node graph. Every pin on every node is rendered by a call to either `show_input` or `show_output` on your viewer. The signatures are fixed by the `SnarlViewer` trait: `#![allow(unused)] fn main() { use egui_snarl::{Snarl, InPin, OutPin, ui::SnarlPin}; fn show_input( &mut self, pin: &InPin, ui: &mut egui::Ui, snarl: &mut Snarl, ) -> impl SnarlPin + 'static; fn show_output( &mut self, pin: &OutPin, ui: &mut egui::Ui, snarl: &mut Snarl, ) -> impl SnarlPin + 'static; }` Three things come into each call: * **`pin`** — a handle to the pin being rendered. `InPin` has `.id: InPinId` and `.remotes: Vec` (the output pins wired into this input). `OutPin` has `.id: OutPinId` and `.remotes: Vec` (the inputs this output feeds). The `remotes` vector is how you know whether a pin is connected and to what. * **`ui`** — a `&mut egui::Ui` scoped to this pin’s row. You build widgets into it just like any other `Ui`: `ui.label(...)`, `ui.add(DragValue::new(...))`, and so on. Whatever you build here becomes the pin’s _label_, drawn next to the pin dot. * **`snarl`** — a `&mut Snarl`. This is what makes inline editing possible: you index `snarl[pin.id.node]` to get a `&mut` to the node’s data and mutate it directly while rendering. The return value, `PinInfo`, tells `egui-snarl` how to draw the _pin itself_ — the little dot, square, or triangle on the node’s border — and how wires attached to that pin should look. The body widgets you build into `ui` are separate from the pin shape. > **Tip:** The separation between “pin label” (your `ui` widgets) and “pin shape” (the `PinInfo` you return) is the key to the whole rendering model. The label is _content_ — it can be a `DragValue`, a `TextEdit`, a `ComboBox`, anything. The shape is _connection affordance_ — its color and form tell the user what kind of data fits here. `PinInfo` is a small builder-style struct. You start from a shape constructor and chain `.with_fill(color)` and `.with_wire_style(style)`: `#![allow(unused)] fn main() { use egui_snarl::ui::{PinInfo, WireStyle}; // A round red pin with default (bezier) wires. let red = PinInfo::circle().with_fill(egui::Color32::from_rgb(220, 80, 80)); // A square green pin with axis-aligned (right-angled) wires. let green = PinInfo::square() .with_fill(egui::Color32::from_rgb(80, 200, 80)) .with_wire_style(WireStyle::AxisAligned { corner_radius: 6.0 }); // A triangle blue pin. let blue = PinInfo::triangle().with_fill(egui::Color32::from_rgb(80, 120, 220)); }` The shape constructors are: | Constructor | Shape | | --- | --- | | `PinInfo::circle()` | A filled circle (the most common choice). | | `PinInfo::square()` | A filled square. | | `PinInfo::triangle()` | A filled triangle. | `.with_fill(color)` sets the fill color. `.with_wire_style(WireStyle::AxisAligned { corner_radius })` switches the wires for this pin from the default smooth bezier curve to right-angled segments with rounded corners of the given radius — useful for mimicking the look of professional shader-graph editors. We will go deeper on wire styling in [Chapter 12](https://danpokhrel.github.io/tutorial/print.html#chapter-12-styling-the-graph) . A useful convention, which we adopt throughout this book, is to color-code pins by data type: red for numbers, green for strings, blue for boolean, and so on. The user learns the color scheme quickly and can scan a graph for type mismatches at a glance. The single most important field on a pin handle is `remotes`. For an `InPin`, `remotes` is a `Vec` — the output pins that are wired into this input, in the order they were connected. For an `OutPin`, `remotes` is a `Vec` — the inputs this output feeds. To display the value a connected input receives, you look up the source node: ``#![allow(unused)] fn main() { use egui_snarl::{ui::{PinInfo, SnarlPin}, InPin, Snarl}; impl SnarlViewer for DemoViewer { (/* other methods */) fn show_input( &mut self, pin: &InPin, ui: &mut egui::Ui, snarl: &mut Snarl, ) -> impl SnarlPin + 'static { if let Some(remote) = pin.remotes.first() { // `remote` is an `OutPinId`; `remote.node` is the source node id. match &snarl[remote.node] { DemoNode::Number(v) => ui.label(format!("{v}")), DemoNode::Text(s) => ui.label(s), DemoNode::Sink => ui.label(""), }; PinInfo::circle().with_fill(egui::Color32::from_rgb(220, 80, 80)) } else { // Unconnected: show a placeholder and a dim pin. ui.label("—"); PinInfo::circle().with_fill(egui::Color32::from_rgb(120, 120, 120)) } } } }`` Two things are worth pausing on. First, `pin.remotes` is borrowed from `pin`, which is itself a borrow of the `Snarl`. Because `snarl[remote.node]` needs to borrow `snarl`, you can hit a borrow conflict if you try to hold the borrow from `remotes` while indexing `snarl`. The fix is to extract what you need _first_ — here we take `remote` (a `Copy` `OutPinId`) and then drop the borrow of `remotes` before indexing. This is the collect-then-mutate pattern from [Chapter 5](https://danpokhrel.github.io/tutorial/print.html#chapter-5-structuring-your-application) , and it is the Rust Book’s guidance on lifetimes ([Chapter 10.3](https://doc.rust-lang.org/stable/book/ch10-03-lifetime-syntax.html) ) applied in practice: keep borrows short. Second, `pin.remotes` can have more than one entry. An input pin accepts multiple wires by default — `remotes` is a `Vec`, not an `Option`. When there are several, you may want to show them all, or show only the first, or show a count. The `Sink` above shows only the first wired value, but a “merge” node might sum them: `#![allow(unused)] fn main() { let sum: f64 = pin .remotes .iter() .map(|remote| match &snarl[remote.node] { DemoNode::Number(v) => *v, _ => 0.0, }) .sum(); ui.label(format!("Σ = {sum}")); }` Here the borrow of `snarl` inside the `.map` closure is shared (`&Snarl`), which is fine because we only _read_. If you needed to _mutate_ based on `remotes`, you would first collect the ids into an owned `Vec`, end the borrow of `pin`, then mutate — exactly the pattern we will use in [Chapter 10](https://danpokhrel.github.io/tutorial/print.html#chapter-10-connections--wires) . The visual contract of a good node graph is: **show an editable widget when a pin is unconnected, and show the live value when it is connected.** The user edits constants directly on source nodes; once a value flows in over a wire, the pin becomes read-only. For output pins, this is usually automatic — the node _owns_ the value, so you always show the editable widget. For input pins, you branch on `remotes.is_empty()`: `#![allow(unused)] fn main() { fn show_input(&mut self, pin: &InPin, ui: &mut egui::Ui, snarl: &mut Snarl) -> impl SnarlPin + 'static { if pin.remotes.is_empty() { // No wire: let the user type a value directly into this pin. // We mutate the node's own stored fallback value. let node = &mut snarl[pin.id.node]; match node { DemoNode::Number(v) => { ui.add(egui::DragValue::new(v).speed(0.1)); } DemoNode::Text(s) => { ui.text_edit_singleline(s); } DemoNode::Sink => { ui.label("(nothing connected)"); } } PinInfo::circle().with_fill(egui::Color32::from_rgb(120, 120, 120)) } else { // Wired: show the source value, read-only. if let Some(remote) = pin.remotes.first() { let src = &snarl[remote.node]; match src { DemoNode::Number(v) => ui.label(format!("{v}")), DemoNode::Text(s) => ui.label(s), DemoNode::Sink => ui.label(""), }; } PinInfo::circle().with_fill(egui::Color32::from_rgb(220, 80, 80)) } } }` Notice the borrow choreography in the unconnected branch: `pin.id.node` is a `Copy` `NodeId`, so we read it from the (shared) `pin` borrow, then call `&mut snarl[...]` — this works because we have stopped touching `pin.remotes`. The Rust Book’s [Chapter 4](https://doc.rust-lang.org/stable/book/ch04-02-references-and-borrowing.html) chapter on references is your friend here: a shared borrow can coexist with nothing mutable, but a mutable borrow stands alone. Output pins are conceptually simpler because the node _owns_ the value. You almost always show an editable widget — the user changes the value, the node stores it, and downstream nodes see the new value over the wire next frame: `#![allow(unused)] fn main() { fn show_output(&mut self, pin: &OutPin, ui: &mut egui::Ui, snarl: &mut Snarl) -> impl SnarlPin + 'static { let node = &mut snarl[pin.id.node]; match node { DemoNode::Number(value) => { ui.add(egui::DragValue::new(value).speed(0.1)); PinInfo::circle().with_fill(egui::Color32::from_rgb(220, 80, 80)) } DemoNode::Text(s) => { ui.text_edit_singleline(s); PinInfo::circle().with_fill(egui::Color32::from_rgb(80, 200, 80)) } DemoNode::Sink => unreachable!("Sink has no output pins"), } } }` You _can_ also read `pin.remotes` on an output pin to show where the value goes — for example, a tooltip “→ 2 inputs” — but for editable source values the node’s own data is the source of truth. So far, the only content on a node has been its pins. But many nodes want a _body_: content drawn between the input pins (left) and output pins (right). `egui-snarl` provides two provided methods for this: `#![allow(unused)] fn main() { impl SnarlViewer for DemoViewer { /// Return true if this node should have a body region. fn has_body(&mut self, node: &DemoNode) -> bool { matches!(node, DemoNode::Sink) } /// Draw the body. Receives the node id so you can read/mutate its data. fn show_body( &mut self, node_id: egui_snarl::NodeId, _inputs: &[InPin], _outputs: &[OutPin], ui: &mut egui::Ui, snarl: &mut Snarl, ) { if let DemoNode::Sink = &snarl[node_id] { ui.label("A sink displays its connected value."); } } } }` `has_body` defaults to `false`; override it to `true` and implement `show_body` to add the region. A common use is a live preview — for example, a node that renders a small color swatch computed from its inputs. Every node has a **header** by default — the bar at the top showing the node’s title. The provided method `show_header` draws it; you almost never override it. What you _do_ override is `header_frame`, which controls the `egui::Frame` (background, margin, rounding) of the header. We will use this in the next section to give each node type a colored bar. A node can also have a **footer** — content drawn below the pins. Like the body, it is opt-in: `#![allow(unused)] fn main() { impl SnarlViewer for DemoViewer { fn has_footer(&mut self, node: &DemoNode) -> bool { // Show a small status line on every node. true } fn show_footer( &mut self, node_id: egui_snarl::NodeId, _inputs: &[InPin], _outputs: &[OutPin], ui: &mut egui::Ui, snarl: &mut Snarl, ) { let node = &snarl[node_id]; let kind = match node { DemoNode::Number(_) => "number", DemoNode::Text(_) => "text", DemoNode::Sink => "sink", }; ui.label(format!("[{kind}]")); } } }` Footers are a good place for metadata that does not belong in a pin: a node id, a “muted” badge, an error count. Keep them short — a footer widens the whole node. > **Warning: provided method signatures vary.** Every provided method on `SnarlViewer` (`show_body`, `show_footer`, `header_frame`, `show_header`, `show_graph_menu`, etc.) has a _different_ argument list. `show_body` and `show_footer` take `inputs: &[InPin]` and `outputs: &[OutPin]`; `header_frame` takes a default `Frame`, a `NodeId`, the pin slices, and `&Snarl`; the menu methods take fewer arguments. Always consult the `SnarlViewer` trait definition in the egui-snarl source for the exact signature of the method you are overriding. The five _required_ methods (`title`, `inputs`, `outputs`, `show_input`, `show_output`) have consistent signatures, but the provided methods do not. `header_frame` returns an `egui::Frame` used for the header of a given node. By matching on the node data, you give each node type a distinct color bar — the visual cue that makes a graph readable at a glance: `#![allow(unused)] fn main() { impl SnarlViewer for DemoViewer { fn header_frame( &mut self, _frame: egui::Frame, node: NodeId, _inputs: &[InPin], _outputs: &[OutPin], snarl: &Snarl, ) -> egui::Frame { let node = &snarl[node]; let color = match node { DemoNode::Number(_) => egui::Color32::from_rgb(80, 50, 50), DemoNode::Text(_) => egui::Color32::from_rgb(40, 70, 40), DemoNode::Sink => egui::Color32::from_rgb(40, 40, 60), }; egui::Frame::default() .fill(color) .corner_radius(4.0) .stroke(egui::Stroke::new(1.0, egui::Color32::BLACK)) } } }` The signature gives you the node data (`&T`) and the active `SnarlStyle` (so you can respect global settings like corner radius). This is the pattern-matching-on-enums idea from the Rust Book’s [Chapter 6.2](https://doc.rust-lang.org/stable/book/ch06-02-match.html) : one `match` expression drives a per-variant decision, and the compiler guarantees we handled every variant. If we later add `DemoNode::Boolean`, the `match` will fail to compile until we pick a color for it — a free correctness check. > **Note: this is an optional enhancement.** The reference implementation’s `DemoViewer` does **not** override `header_frame` — it keeps snarl’s default header, so the “Complete Rich Node Example” below omits it. We show `header_frame` here because it is a common and useful customization; add it to your own viewer only if you want per-type color bars. Let us assemble a fuller viewer that uses everything so far: color-coded pins, connected-vs-unconnected branching, a body on the `Sink`, and colored headers. We extend the node enum with a `Concat` node (two text inputs, one text output) to show multi-pin rendering: ``#![allow(unused)] fn main() { use eframe::egui; use egui_snarl::{ ui::{PinInfo, SnarlPin, SnarlViewer}, InPin, InPinId, NodeId, OutPin, Snarl, }; #[derive(serde::Serialize, serde::Deserialize, Clone)] pub enum DemoNode { Number(f64), Text(String), /// Concatenates two text inputs into one output. Concat, Sink, } #[derive(Default)] pub struct DemoViewer; impl SnarlViewer for DemoViewer { fn title(&mut self, node: &DemoNode) -> String { match node { DemoNode::Number(_) => "Number".into(), DemoNode::Text(_) => "Text".into(), DemoNode::Concat => "Concat".into(), DemoNode::Sink => "Sink".into(), } } fn inputs(&mut self, node: &DemoNode) -> usize { match node { DemoNode::Number(_) | DemoNode::Text(_) => 0, DemoNode::Concat => 2, // two text inputs DemoNode::Sink => 1, } } fn outputs(&mut self, node: &DemoNode) -> usize { match node { DemoNode::Number(_) | DemoNode::Text(_) | DemoNode::Concat => 1, DemoNode::Sink => 0, } } fn has_body(&mut self, node: &DemoNode) -> bool { matches!(node, DemoNode::Concat | DemoNode::Sink) } fn show_body( &mut self, node_id: NodeId, _inputs: &[InPin], _outputs: &[OutPin], ui: &mut egui::Ui, snarl: &mut Snarl, ) { let node = &snarl[node_id]; match node { DemoNode::Concat => { // Show a live preview of the concatenation. let (a, b) = read_concat_inputs(node_id, snarl); ui.label(format!("\"{a}\" + \"{b}\"")); } DemoNode::Sink => { ui.label("displays its input"); } _ => {} } } fn show_input( &mut self, pin: &InPin, ui: &mut egui::Ui, snarl: &mut Snarl, ) -> impl SnarlPin + 'static { // `InPin` owns its `remotes` list, so borrowing it alongside `&snarl` // is fine — no clone needed. if let Some(remote) = pin.remotes.first() { match &snarl[remote.node] { DemoNode::Number(v) => ui.label(format!("{v}")), DemoNode::Text(s) => ui.label(s), DemoNode::Concat => ui.label("(concat)"), DemoNode::Sink => ui.label(""), }; PinInfo::circle().with_fill(egui::Color32::from_rgb(220, 80, 80)) } else { ui.label("—"); PinInfo::circle().with_fill(egui::Color32::from_rgb(120, 120, 120)) } } fn show_output( &mut self, pin: &OutPin, ui: &mut egui::Ui, snarl: &mut Snarl, ) -> impl SnarlPin + 'static { let node = &mut snarl[pin.id.node]; match node { DemoNode::Number(value) => { ui.add(egui::DragValue::new(value).speed(0.1)); PinInfo::circle().with_fill(egui::Color32::from_rgb(220, 80, 80)) } DemoNode::Text(s) => { ui.text_edit_singleline(s); PinInfo::circle().with_fill(egui::Color32::from_rgb(80, 200, 80)) } DemoNode::Concat => { ui.label("out"); PinInfo::circle().with_fill(egui::Color32::from_rgb(80, 200, 80)) } // `outputs()` returns 0 for Sink, so the widget never calls this. // Stay defensive anyway: a GUI should grey out, not crash, if a // future edit makes the pin counts inconsistent. DemoNode::Sink => { ui.label("—"); PinInfo::circle().with_fill(egui::Color32::from_rgb(120, 120, 120)) } } } } /// Helper: read the two inputs of a Concat node as displayable strings. /// /// Borrows from the snarl where possible (`Text`) so the per-frame call in /// `show_body` does not allocate; a wired `Number` is formatted on demand. /// Unwired or non-displayable inputs read as empty. fn read_concat_inputs<'a>( node_id: NodeId, snarl: &'a Snarl, ) -> (std::borrow::Cow<'a, str>, std::borrow::Cow<'a, str>) { let read = |input: usize| -> std::borrow::Cow<'a, str> { let in_pin = snarl.in_pin(InPinId { node: node_id, input }); match in_pin.remotes.first() { Some(remote) => match &snarl[remote.node] { DemoNode::Text(s) => std::borrow::Cow::Borrowed(s.as_str()), DemoNode::Number(v) => std::borrow::Cow::Owned(v.to_string()), _ => std::borrow::Cow::Borrowed(""), }, None => std::borrow::Cow::Borrowed(""), } }; (read(0), read(1)) } }`` A few things in this example deserve comment. `snarl.in_pin(InPinId { node, input })` returns an `InPin` handle — the same type `show_input` receives, but obtained on demand. This is how a _body_ (which only gets a `NodeId`) reads connected values: it asks the `Snarl` for the pin handle, inspects `.remotes`, and indexes the source node. There is a symmetric `snarl.out_pin(OutPinId { ... }) -> OutPin`. We discussed the borrow discipline this requires in the “Reading Connected Values” section above: we `clone` the `remotes` ids first so the shared borrow of `snarl.in_pin(...)` ends before we index into `snarl`. The `read_concat_inputs` helper is an example of pulling reusable logic out of the viewer — exactly the kind of factoring the Rust Book recommends in [Chapter 7](https://doc.rust-lang.org/stable/book/ch07-01-packages-and-crates.html) when projects grow. As your viewer grows, you will want helpers like this in their own module; we set up that module structure in [Chapter 5](https://danpokhrel.github.io/tutorial/print.html#chapter-5-structuring-your-application) . Because `show_input` and `show_output` receive `&mut Snarl`, you can mutate node data _while rendering_. This is how inline editing works: there is no “edit buffer” — the `DragValue` directly mutates the `f64` inside `DemoNode::Number`, and the `TextEdit` directly mutates the `String` inside `DemoNode::Text`. Next frame, the new value is simply there. This is powerful but demands care with borrow ordering. The recurring gotcha is `pin.remotes` and `snarl[node]` both borrowing the `Snarl`. In our `show_input` we sidestep it cheaply: `InPin` _owns_ its `remotes` list (it is not a borrow of the `Snarl`’s internal storage), so reading `pin.remotes.first()` to get a `Copy` `OutPinId` and then indexing `&snarl[remote.node]` is sound — the borrow of `remotes` is tied to `pin`, not to a `&Snarl` borrow that conflicts with the indexing. The only thing you must _not_ do is hold a `&mut Snarl` borrow (from `snarl[node_id] = ...`) while still reading `pin.remotes`. The rule that always works: **read `Copy` ids out of the pin first (or rely on `InPin` owning its `remotes`), then index the `Snarl`.** When you genuinely need to _mutate_ based on `remotes` — for instance in `connect` ([Chapter 10](https://danpokhrel.github.io/tutorial/print.html#chapter-10-connections--wires) ) or `drop_outputs` — copy the ids into an owned `Vec` first (`let remotes: Vec<_> = pin.remotes.iter().copied().collect();`), end the pin borrow, then mutate. The tiny allocation makes the borrow trivially sound. This discipline is the Rust Book’s [Chapter 4](https://doc.rust-lang.org/stable/book/ch04-02-references-and-borrowing.html) advice in miniature: a mutable borrow must be the _only_ borrow of that data. When two parts of your code both need the `Snarl`, stagger the borrows so they never overlap. In this chapter we learned the full pin rendering model: `show_input` and `show_output` receive a pin handle, a `Ui`, and a `&mut Snarl`, and they return a `PinInfo` describing the pin’s shape and wire style. We learned to read connected values through `pin.remotes`, to render editable widgets when unconnected and live values when connected, to add bodies and footers, and to color headers per node type. The borrow choreography — extract `Copy` ids, end the pin borrow, then index the `Snarl` — is the one pattern you must internalize. Our graph now looks good, but we have not controlled _which_ wires the user is allowed to draw. In [Chapter 10](https://danpokhrel.github.io/tutorial/print.html#chapter-10-connections--wires) we will override `connect` and `disconnect` to validate connections by data type, and learn the wire-styling and dropped-wire tools. In [Chapter 9](https://danpokhrel.github.io/tutorial/print.html#chapter-9-building-nodes--pins) we built rich, editable nodes — but every wire in our graph so far was created in code with `snarl.connect`. In a real editor, the user draws wires by dragging from pin to pin, and we must _validate_ those connections: a number output should not feed a text input. In this chapter we override the `SnarlViewer` connection methods to add type checking, learn how disconnection and pin-drop work, and style wires per pin. This chapter assumes you have read [Chapter 8](https://danpokhrel.github.io/tutorial/print.html#chapter-8-introducing-egui-snarl) and [Chapter 9](https://danpokhrel.github.io/tutorial/print.html#chapter-9-building-nodes--pins) ; we will also lean on the enum pattern matching from the Rust Book’s [Chapter 6.2](https://doc.rust-lang.org/stable/book/ch06-02-match.html) . A wire is a connection from one `OutPinId` to one `InPinId`. The user creates a wire interactively by _dragging from a pin and releasing on another pin_. `egui-snarl` handles the drag detection and the visual rendering of the in-progress wire; at the moment of release it calls into your viewer to ask whether the connection should be allowed. > **Reference-impl note:** the demonstration editor this book builds (`DemoNode`: Number / Text / Concat / Sink) does **not** override `connect` — it keeps snarl’s default, permissive connect so that any output can wire into any input. That keeps the demo small and lets a reader experiment without type rejections getting in the way. This chapter is an optional enhancement: read it when you want to add type checking to your own graph, and skip it (or come back later) if you just want the demo editor. The reference impl’s `DemoViewer` has none of the `connect` / `disconnect` / `drop_*` overrides shown here. Three methods on `SnarlViewer` govern connections: | Method | When it is called | Default behavior | | --- | --- | --- | | `connect(&mut self, from: &OutPin, to: &InPin, snarl: &mut Snarl)` | User drops a wire from output `from` onto input `to`. | Calls `snarl.connect(from.id, to.id)`. | | `disconnect(&mut self, from: &OutPin, to: &InPin, snarl: &mut Snarl)` | A wire is removed (e.g., user drags a connected wire away). | Calls `snarl.disconnect(from.id, to.id)`. | | `drop_outputs` / `drop_inputs` | Right-click on a pin → “drop all wires”. | Clears all wires on that pin. | Crucially, these methods receive _pin handles_ (`&OutPin`, `&InPin`), not just ids. The handles carry `.remotes`, so your validation logic can inspect what is already connected. The `&mut Snarl` is passed so you can perform the actual mutation. > **Note:** The default `connect` is _permissive_ — it accepts any wire between any two pins. That is fine for a demo, but a production graph almost always wants type checking. Override `connect` to add it. The pattern is: inspect the source node and the destination node by type, decide whether the connection is valid, and only then call `snarl.connect`. Return `bool` — `egui-snarl` uses it (and whether `snarl.connect` returned `true`) to decide whether the visual wire should snap into place. Let us define a notion of “data type” for each pin, derived from the node variant. We extend our `DemoNode` enum from [Chapter 9](https://danpokhrel.github.io/tutorial/print.html#chapter-9-building-nodes--pins) with a helper that maps each output pin to a data type: `#![allow(unused)] fn main() { use serde::{Deserialize, Serialize}; /// The kind of data flowing over a wire. #[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Eq)] pub enum DataType { Number, Text, } #[derive(Serialize, Deserialize, Clone)] pub enum DemoNode { Number(f64), Text(String), Concat, Sink, } impl DemoNode { /// What data type a given *output* pin produces. pub fn output_type(&self, output: usize) -> Option { match self { DemoNode::Number(_) => (output == 0).then_some(DataType::Number), DemoNode::Text(_) => (output == 0).then_some(DataType::Text), DemoNode::Concat => (output == 0).then_some(DataType::Text), DemoNode::Sink => None, } } /// What data type a given *input* pin expects. pub fn input_type(&self, input: usize) -> Option { match self { DemoNode::Number(_) | DemoNode::Text(_) => None, // Both Concat inputs expect Text. DemoNode::Concat => (input < 2).then_some(DataType::Text), // The single Sink input accepts anything. DemoNode::Sink => (input == 0).then_some(DataType::Number).or( (input == 0).then_some(DataType::Text), ), } } } }` The `Sink` case shows a subtlety: a “sink any type” pin has no single expected type. We model that by returning `None` for “untyped” and checking compatibility specially. A cleaner design uses an `Option` where `None` means “accepts anything”; we do that here for inputs: ``#![allow(unused)] fn main() { impl DemoNode { /// `None` means "accepts any type". pub fn expected_input(&self, input: usize) -> Option { match self { DemoNode::Concat => Some(DataType::Text), DemoNode::Sink => None, // accepts anything _ => None, } } } }`` Now the `connect` override. The rule: a wire is allowed if the destination expects either nothing (any) or exactly the source’s type: `#![allow(unused)] fn main() { use egui_snarl::{ui::SnarlViewer, InPin, OutPin, Snarl}; impl SnarlViewer for DemoViewer { fn connect( &mut self, from: &OutPin, to: &InPin, snarl: &mut Snarl, ) -> bool { // Read source and destination node types BEFORE mutating. let src = &snarl[from.id.node]; let dst = &snarl[to.id.node]; let Some(src_type) = src.output_type(from.id.output) else { return false; // source has no such output }; match dst.expected_input(to.id.input) { None => { // Destination accepts any type. snarl.connect(from.id, to.id) } Some(expected) if expected == src_type => { snarl.connect(from.id, to.id) } Some(_) => { // Type mismatch: reject, do not create the wire. false } } } } }` Two design points. First, **read before mutate.** We take shared borrows of `snarl[from.id.node]` and `snarl[to.id.node]` to read the types, _then_ call `snarl.connect(...)`, which takes `&mut`. The shared borrows must end before the mutable call — the compiler enforces this because the shared borrows go out of scope at the end of the `let` bindings’ last use. This is the same stagger-the-borrows discipline from [Chapter 9](https://danpokhrel.github.io/tutorial/print.html#chapter-9-building-nodes--pins) , and it is the Rust Book’s [Chapter 4.2](https://doc.rust-lang.org/stable/book/ch04-02-references-and-borrowing.html) made concrete. Second, **return the result of `snarl.connect`.** The underlying `Snarl::connect(from, to) -> bool` returns `true` when a new connection is created and `false` when one already existed (a duplicate). Returning it directly lets the widget know whether a wire actually appeared. > **Tip:** If you want to _replace_ an existing wire on a single-input pin rather than reject a duplicate, call `snarl.drop_inputs(to.id)` before `snarl.connect(...)` in your override. This implements “one wire per input” semantics — common in shader graphs where an input takes exactly one value. `disconnect` is the inverse of `connect`. The default implementation calls `snarl.disconnect(from.id, to.id)` and that is usually all you want. You override it when removing a wire should trigger a _side effect_ — for example, clearing a cached evaluation, or marking a node “dirty” so it re-evaluates next frame: ``#![allow(unused)] fn main() { impl SnarlViewer for DemoViewer { fn disconnect( &mut self, from: &OutPin, to: &InPin, snarl: &mut Snarl, ) { // Perform the actual disconnection. snarl.disconnect(from.id, to.id); // Side effect: any node whose input changed may need re-evaluation. // In Part 4 we will mark `to.id.node` dirty here. self.mark_dirty(to.id.node); } } impl DemoViewer { fn mark_dirty(&mut self, _node: egui_snarl::NodeId) { // e.g. push onto a Vec of dirty nodes. } } }`` `disconnect` does not return a `bool` — it is a notification, not a gate. You cannot _prevent_ a disconnection (the user can always drag a wire away), but you can react to it. Right-clicking a pin offers (by default) a menu item to drop all wires on that pin. `drop_outputs(pin: &OutPin, snarl)` clears every wire leaving an output; `drop_inputs(pin: &InPin, snarl)` clears every wire arriving at an input. The defaults call `snarl.drop_outputs(pin.id)` and `snarl.drop_inputs(pin.id)` respectively. Like `disconnect`, you override these to add side effects when a whole pin is cleared. A common reason: if an output had several downstream consumers and you drop them all, you want to mark each consumer dirty: `#![allow(unused)] fn main() { impl SnarlViewer for DemoViewer { fn drop_outputs(&mut self, pin: &egui_snarl::ui::OutPin, snarl: &mut Snarl) { // Collect the affected downstream node ids first. let affected: Vec<_> = pin.remotes.iter().map(|r| r.node).collect(); snarl.drop_outputs(pin.id); for node in affected { self.mark_dirty(node); } } fn drop_inputs(&mut self, pin: &egui_snarl::ui::InPin, snarl: &mut Snarl) { snarl.drop_inputs(pin.id); self.mark_dirty(pin.id.node); } } }` Notice we again follow the collect-then-mutate pattern from [Chapter 5](https://danpokhrel.github.io/tutorial/print.html#chapter-5-structuring-your-application) : we copy the `remotes` ids into an owned `Vec` _before_ calling `snarl.drop_outputs`, because that call mutates the `Snarl` and would invalidate a live borrow of `pin.remotes`. By default, an input pin accepts multiple wires. `InPin.remotes` is a `Vec`, and the default `connect` simply appends. Whether that is desirable depends on your graph’s semantics: * A **merge/sum** node wants multiple wires on one input. * A **function argument** node wants exactly one wire per input. For the “exactly one” case, drop existing wires in your `connect` override before connecting the new one (the tip above). For the “many allowed” case, leave the default. `egui-snarl` itself has no notion of “single vs multi” — it is entirely your `connect`’s decision. Wires are drawn by `egui-snarl`, but you influence their appearance through `PinInfo`. Recall from [Chapter 9](https://danpokhrel.github.io/tutorial/print.html#chapter-9-building-nodes--pins) that `show_input`/`show_output` return a `PinInfo`. The `.with_wire_style(...)` modifier on `PinInfo` sets how wires _attached to that pin_ are drawn: `#![allow(unused)] fn main() { use egui_snarl::ui::{PinInfo, WireStyle}; // Smooth bezier (the default if you omit with_wire_style). let smooth = PinInfo::circle().with_fill(egui::Color32::from_rgb(220, 80, 80)); // Right-angled wires with rounded corners. let angular = PinInfo::circle() .with_fill(egui::Color32::from_rgb(80, 200, 80)) .with_wire_style(WireStyle::AxisAligned { corner_radius: 8.0 }); }` `WireStyle::AxisAligned { corner_radius }` produces wires that run horizontally from the pin, turn at a right angle, and run to the other pin — the look popularized by Unreal’s material editor. The default (no `.with_wire_style`) is a smooth bezier. Mixing styles on the same graph is possible but usually looks inconsistent; pick one convention and apply it to every pin. We will set a global default via `SnarlStyle` in [Chapter 12](https://danpokhrel.github.io/tutorial/print.html#chapter-12-styling-the-graph) . Wire _color_ follows the pin’s fill color. This is why color-coding pins by data type is so effective: the wires themselves become color-coded, and a red wire flowing into a green pin is an immediate, glaring type mismatch. For advanced cases — animated wires, wires with a direction arrow, wires that display a flowing dot — `egui-snarl` lets you take over rendering of an individual wire segment entirely. Override `has_wire_widget` to return `true` for wires you want to customize, and implement `show_wire_widget` to draw into the `ui` positioned along the wire: `#![allow(unused)] fn main() { use egui_snarl::{InPin, OutPin, ui::WireId}; impl SnarlViewer for DemoViewer { fn has_wire_widget(&mut self, _from: &OutPin, _to: &InPin) -> bool { // Only add widgets to number wires, for example. true } fn show_wire_widget( &mut self, _id: WireId, ui: &mut egui::Ui, _snarl: &mut Snarl, ) { // Draw a small label or a moving dot here. ui.label("·"); } } }` This is rarely needed; we mention it for completeness. The default wire rendering is attractive and performant. We will not use custom wire widgets in this book’s main example, but they are handy for tools that visualize data _flow_ during evaluation in [Chapter 15](https://danpokhrel.github.io/tutorial/print.html#chapter-15-live-execution--streaming) . Putting the pieces together, here is a `connect` that validates string→string, number→number, and rejects incompatible types, using the `expected_input`/`output_type` helpers from earlier in the chapter: `#![allow(unused)] fn main() { use egui_snarl::{ui::SnarlViewer, InPin, OutPin, Snarl}; impl SnarlViewer for DemoViewer { fn connect( &mut self, from: &OutPin, to: &InPin, snarl: &mut Snarl, ) -> bool { // Borrow the two nodes immutably to read their types. let (src_type, dst_expected) = { let src = &snarl[from.id.node]; let dst = &snarl[to.id.node]; (src.output_type(from.id.output), dst.expected_input(to.id.input)) }; // The shared borrows end here; we may now mutate. let Some(src_type) = src_type else { return false }; let compatible = match dst_expected { None => true, // destination accepts any type Some(expected) => expected == src_type, }; if !compatible { return false; // reject; no wire is created } // For single-value inputs, replace any existing wire. if matches!(dst_expected, Some(_)) { snarl.drop_inputs(to.id); } snarl.connect(from.id, to.id) } fn disconnect( &mut self, from: &OutPin, to: &InPin, snarl: &mut Snarl, ) { snarl.disconnect(from.id, to.id); self.mark_dirty(to.id.node); } } }` With this in place, dragging from a `Number` output onto a `Concat` input (which expects `Text`) will simply fail to create a wire — the dragged wire snaps back. Dragging from a `Text` output onto the `Sink` (which accepts anything) succeeds. The user gets immediate, silent feedback about type compatibility, exactly as in a shader graph. > **Note:** There is no built-in “red flash” or error toast on a rejected connection. If you want feedback, you can set a transient message field on your viewer inside the `!compatible` branch and render it as a toast next frame — the state-collection pattern from [Chapter 5](https://danpokhrel.github.io/tutorial/print.html#chapter-5-structuring-your-application) > . When a node is removed (we will wire up `remove_node` via a context menu in [Chapter 11](https://danpokhrel.github.io/tutorial/print.html#chapter-11-interactions--ux) ), `egui-snarl` automatically drops every wire that touched the node’s pins. You do not need to disconnect manually. However, _downstream nodes that were reading from the removed node_ may now display stale data or an empty `remotes` vec — your `show_input` must handle `pin.remotes.is_empty()` gracefully, as we did in [Chapter 9](https://danpokhrel.github.io/tutorial/print.html#chapter-9-building-nodes--pins) by showing “—”. If you cache evaluations (as we will in Part 4), hook `disconnect` and the removal path to mark consumers dirty. The `Snarl::remove_node(node_id) -> T` method removes a node and returns its data; wires are cleaned up internally: `#![allow(unused)] fn main() { let removed: DemoNode = snarl.remove_node(node_id); // Wires that touched this node are gone; downstream remotes are updated. }` Because removal returns the node data by value, you can inspect it (e.g., to log it or to offer an “undo”) before dropping it. This is ownership in action — the `Snarl` _owned_ the `DemoNode`, and on removal it moves that ownership out to you (Rust Book [Chapter 4.1](https://doc.rust-lang.org/stable/book/ch04-01-what-is-ownership.html) ). In this chapter we took control of the graph’s connections. We overrode `connect` to validate wires by data type — accepting string→string and number→number, rejecting mismatches — and learned that `disconnect` and `drop_outputs`/`drop_inputs` are notification hooks for side effects like marking nodes dirty. We saw that multi-connection is the default and that “exactly one wire per input” is a one-line override, and we styled wires per pin through `PinInfo::with_wire_style`. The collect-then-mutate borrow discipline kept the type-checking code sound. Our graph now enforces a sensible type system, but the only way to add nodes is still in code. In [Chapter 11](https://danpokhrel.github.io/tutorial/print.html#chapter-11-interactions--ux) we will add context menus — right-click the background to spawn nodes, right-click a node to delete it, and drag a wire to empty space to create a connected node automatically. Our graph validates connections and renders rich nodes, but the user still has no way to add or remove nodes without editing source code. In this chapter we wire up the full interaction surface of `egui-snarl`: context menus for the background (to spawn nodes), context menus for nodes (to delete them), dropped-wire menus (to create a pre-connected node from a dragged wire), hover popups, node selection, and keyboard shortcuts. This chapter brings together the input handling from [Chapter 7](https://danpokhrel.github.io/tutorial/print.html#chapter-7-input-menus--dialogs) and the pin/connection model from [Chapter 9](https://danpokhrel.github.io/tutorial/print.html#chapter-9-building-nodes--pins) and [Chapter 10](https://danpokhrel.github.io/tutorial/print.html#chapter-10-connections--wires) . `egui-snarl` exposes five pairs of `has_*`/`show_*` hooks for context menus and popups. Each `has_*` method returns `bool` (do we want this menu at all?); each `show_*` method builds the menu’s contents into a `&mut egui::Ui`: | Hook pair | Trigger | Typical use | | --- | --- | --- | | `has_graph_menu` / `show_graph_menu` | Right-click on empty canvas. | “Add Node” palette. | | `has_node_menu` / `show_node_menu` | Right-click on a node. | Delete, duplicate, rename. | | `has_dropped_wire_menu` / `show_dropped_wire_menu` | A wire is dragged to empty space and released. | Create a node pre-wired to the dropped pin. | | `has_on_hover_popup` / `show_on_hover_popup` | Pointer lingers over a node. | Tooltip / description. | All the `show_*` methods receive a `NodeId` (or, for the graph menu, a `Pos2`) and a `&mut Snarl`, so they can both read and mutate the graph directly. This is the same inline-mutation pattern from [Chapter 9](https://danpokhrel.github.io/tutorial/print.html#chapter-9-building-nodes--pins) : no event queue, no deferred command — you mutate the `Snarl` right there. `show_graph_menu` receives `pos: Pos2` — the screen-space point where the user right-clicked — plus the usual `ui` and `snarl`. The natural implementation builds an “Add Node” palette: one button per node type, and `insert_node(pos, node)` on click. Crucially, the new node appears _exactly where the user clicked_, because we pass `pos` straight through: `#![allow(unused)] fn main() { use egui_snarl::{Snarl, ui::SnarlViewer}; impl SnarlViewer for DemoViewer { fn has_graph_menu(&mut self, _pos: egui::Pos2, _snarl: &mut Snarl) -> bool { true // always offer the menu } fn show_graph_menu( &mut self, pos: egui::Pos2, ui: &mut egui::Ui, snarl: &mut Snarl, ) { ui.label("Add node"); if ui.button("Number").clicked() { snarl.insert_node(pos, DemoNode::Number(0.0)); ui.close(); } if ui.button("Text").clicked() { snarl.insert_node(pos, DemoNode::Text(String::new())); ui.close(); } if ui.button("Concat").clicked() { snarl.insert_node(pos, DemoNode::Concat); ui.close(); } if ui.button("Sink").clicked() { snarl.insert_node(pos, DemoNode::Sink); ui.close(); } } } }` `ui.close()` closes the popup after a click so the menu does not linger. `Snarl::insert_node(pos, node) -> NodeId` returns the new node’s id; you usually ignore it here, but if you want to _pre-wire_ the new node (as we do in the dropped-wire menu below), capture the id. > **Tip:** Right-click spawns the node where you clicked. If you would rather offset slightly so the new node does not sit under the cursor (and thus immediately get a hover popup), add a small delta: `snarl.insert_node(pos + egui::vec2(10.0, 10.0), node)`. For a larger graph with many node types, a flat list of buttons gets long. You can group them with `egui::CollapsingHeader` or split by category. A simple, readable approach is a small table of buttons: `#![allow(unused)] fn main() { fn show_graph_menu(&mut self, pos: egui::Pos2, ui: &mut egui::Ui, snarl: &mut Snarl) { ui.heading("Add Node"); ui.separator(); egui::Grid::new("add-node-grid").num_columns(2).show(ui, |ui| { if ui.button("Number").clicked() { snarl.insert_node(pos, DemoNode::Number(0.0)); ui.close(); } if ui.button("Text").clicked() { snarl.insert_node(pos, DemoNode::Text(String::new())); ui.close(); } if ui.button("Concat").clicked() { snarl.insert_node(pos, DemoNode::Concat); ui.close(); } if ui.button("Sink").clicked() { snarl.insert_node(pos, DemoNode::Sink); ui.close(); } }); } }` The `Grid` from [Chapter 4](https://danpokhrel.github.io/tutorial/print.html#chapter-4-layout-widgets--state) keeps the palette tidy. As your node count grows, factor the list into a `Vec<(name, constructor)>` and iterate — a closure that builds the node. The Rust Book’s [Chapter 13](https://doc.rust-lang.org/stable/book/ch13-01-closures.html) covers closures, which are the natural fit for “a thing that makes a `DemoNode`.” `show_node_menu` receives the node id of the right-clicked node. A minimal menu offers “Remove”: `#![allow(unused)] fn main() { fn has_node_menu(&mut self, _node: &DemoNode) -> bool { true } fn show_node_menu( &mut self, node_id: egui_snarl::NodeId, ui: &mut egui::Ui, snarl: &mut Snarl, ) { if ui.button("Remove node").clicked() { // remove_node returns the node data; wires are cleaned up automatically. let _removed = snarl.remove_node(node_id); ui.close(); } } }` `Snarl::remove_node(node_id) -> T` removes the node and returns its data by ownership (Rust Book [Chapter 4.1](https://doc.rust-lang.org/stable/book/ch04-01-what-is-ownership.html) ). Every wire that touched the node’s pins is dropped automatically — downstream inputs now have one fewer entry in `remotes`. As we noted in [Chapter 10](https://danpokhrel.github.io/tutorial/print.html#chapter-10-connections--wires) , your `show_input` must handle the now-empty `remotes` gracefully (showing “—” or a fallback editor). > **Warning:** `NodeId`s are _recycled_. After `remove_node`, the same numeric id may be handed out to a new node later. Never store a `NodeId` long-term expecting it to keep referring to the same node; resolve ids to data fresh each frame. This is one of the nicest UX affordances in `egui-snarl`. When the user drags a wire from a pin and releases it on _empty canvas_ (not on another pin), `egui-snarl` offers a menu. You can use it to create a node and pre-wire it to the dragged pin in one gesture — the familiar “insert reroute node” of professional editors. The hook receives an `AnyPins` describing the source pins of the dropped wire. `AnyPins` is an enum with two variants: `#![allow(unused)] fn main() { pub enum AnyPins { Out(&[OutPinId]), // the user dragged from one or more output pins In(&[InPinId]), // the user dragged from one or more input pins } }` You match on it to decide which node types are compatible and how to wire the new node. For example, dragging from a `Number` output should offer to spawn a `Sink` (which accepts numbers) pre-connected: `#![allow(unused)] fn main() { use egui_snarl::{ui::{AnyPins, SnarlViewer}, InPinId, NodeId, OutPinId, Snarl}; impl SnarlViewer for DemoViewer { fn has_dropped_wire_menu(&mut self, _wires: &AnyPins, _snarl: &Snarl) -> bool { true } fn show_dropped_wire_menu( &mut self, pos: egui::Pos2, wires: &AnyPins, ui: &mut egui::Ui, snarl: &mut Snarl, ) { match wires { AnyPins::Out(pins) => { // We dragged from output pin(s). Offer nodes that take an input. if let Some(src) = pins.first() { // Read the source type BEFORE mutating, to decide which // buttons to show. Shared borrow ends at the end of the // expression; we copy the result out. let src_type = snarl[src.node].output_type(src.output); ui.label("Insert consumer"); if ui.button("Sink").clicked() { let new = snarl.insert_node(pos, DemoNode::Sink); snarl.connect(*src, InPinId { node: new, input: 0 }); ui.close(); } // Only offer a Text-consuming node if the source is Text. if src_type == Some(DataType::Text) && ui.button("Concat (as input A)").clicked() { let new = snarl.insert_node(pos, DemoNode::Concat); snarl.connect(*src, InPinId { node: new, input: 0 }); ui.close(); } } } AnyPins::In(pins) => { // We dragged from an input pin. Offer source nodes to feed it. if let Some(dst) = pins.first() { ui.label("Insert source"); if ui.button("Number").clicked() { let new = snarl.insert_node(pos, DemoNode::Number(0.0)); snarl.connect(OutPinId { node: new, output: 0 }, *dst); ui.close(); } if ui.button("Text").clicked() { let new = snarl.insert_node(pos, DemoNode::Text(String::new())); snarl.connect(OutPinId { node: new, output: 0 }, *dst); ui.close(); } } } } } } }` This is _context-aware_ node creation: the menu filters itself to compatible types based on the dropped wire. The `match` on `AnyPins` — an enum, as in the Rust Book’s [Chapter 6.1](https://doc.rust-lang.org/stable/book/ch06-01-defining-an-enum.html) — cleanly separates the two directions. Note we read the source type _before_ mutating, then use it to gate which buttons appear; once a button is clicked we mutate (insert + connect) and close the menu. > **Tip:** Because `connect` goes through your _own_ overridden `connect` (from [Chapter 10](https://danpokhrel.github.io/tutorial/print.html#chapter-10-connections--wires) > ), the pre-wired connection is automatically type-validated. If you offer a button for an incompatible type by mistake, the `connect` will simply return `false` and no wire appears — a free safety net. `egui-snarl` tracks which nodes are selected (click a node to select it; Shift-click or drag a box to multi-select). The selected ids are queryable via `egui_snarl::ui::get_selected_nodes(id, ctx)`, which returns a `Vec`: `#![allow(unused)] fn main() { use egui_snarl::ui; use eframe::egui; impl eframe::App for SnarlApp { fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { egui::Panel::right("selection-panel").show_inside(ui, |ui| { ui.heading("Selection"); let selected = ui::get_selected_nodes(egui::Id::new("demo-snarl"), ui.ctx()); if selected.is_empty() { ui.label("(nothing selected)"); } else { for id in &selected { let node = &self.snarl[*id]; ui.label(format!("Node {}: {}", id.0, node.title_str())); } } }); egui::CentralPanel::default().show_inside(ui, |ui| { egui_snarl::SnarlWidget::new() .id_salt(egui::Id::new("demo-snarl")) .show(&mut self.snarl, &mut self.viewer, ui); }); } } impl DemoNode { fn title_str(&self) -> &'static str { match self { DemoNode::Number(_) => "Number", DemoNode::Text(_) => "Text", DemoNode::Concat => "Concat", DemoNode::Sink => "Sink", } } } }` The `Id` passed to `get_selected_nodes` must match the one you gave `SnarlWidget::new().id(...)`. The returned `Vec` is a snapshot for this frame — you can read from it freely. Displaying the current selection in a side panel is a small touch that makes a big difference to usability, and it sets up the “properties” panel pattern from [Chapter 5](https://danpokhrel.github.io/tutorial/print.html#chapter-5-structuring-your-application) . > **Note:** `get_selected_nodes` borrows the `Snarl`’s selection state from the `Context`, so it is cheap — no traversal of the graph. Call it every frame; do not cache it across frames. `has_on_hover_popup` / `show_on_hover_popup` fire when the pointer lingers over a node. They are perfect for showing a description of what a node does, without cluttering the node itself: `#![allow(unused)] fn main() { impl SnarlViewer for DemoViewer { fn has_on_hover_popup(&mut self, _node: &DemoNode) -> bool { true } fn show_on_hover_popup( &mut self, node_id: egui_snarl::NodeId, ui: &mut egui::Ui, snarl: &mut Snarl, ) { let node = &snarl[node_id]; let desc = match node { DemoNode::Number(_) => "A constant number. Drag the value to edit it.", DemoNode::Text(_) => "A constant string. Type to edit it.", DemoNode::Concat => "Joins two text inputs into one output.", DemoNode::Sink => "Displays its connected input value.", }; ui.label(desc); } } }` Keep popups short — they are not modal, they just appear and disappear with the hover. This is the same hover-tooltip idea as `Response::on_hover_text` from [Chapter 7](https://danpokhrel.github.io/tutorial/print.html#chapter-7-input-menus--dialogs) , but here it is a full `Ui` you can build into, so you can show multi-line rich text or even a small live preview. `egui-snarl` does not bind keyboard shortcuts by default — you do, via the `egui::Context` input we covered in [Chapter 7](https://danpokhrel.github.io/tutorial/print.html#chapter-7-input-menus--dialogs) . The most useful one is Delete to remove the currently selected nodes. The cleanest place for this is at the top of your `ui` method, before you build any widgets. The `egui::Ui` gives you its `Context` via `ui.ctx()`, so you can poll input there and mutate the `Snarl` immediately: `#![allow(unused)] fn main() { impl eframe::App for SnarlApp { fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { // --- Keyboard shortcuts: run before rendering the widget. --- let ctx = ui.ctx().clone(); let delete_pressed = ctx.input(|i| i.key_pressed(egui::Key::Delete)); if delete_pressed { // Snapshot the selection first: remove_node recycles ids, and we // never want to iterate live selection state while mutating. let selected: Vec<_> = egui_snarl::ui::get_selected_nodes(egui::Id::new("demo-snarl"), &ctx); for id in selected { self.snarl.remove_node(id); } } // --- Side panel showing the current selection. --- egui::Panel::right("sel").show_inside(ui, |ui| { ui.heading("Selection"); let selected = egui_snarl::ui::get_selected_nodes( egui::Id::new("demo-snarl"), ui.ctx(), ); if selected.is_empty() { ui.label("(none)"); } for id in &selected { ui.label(format!("Node {}", id.0)); } }); // --- The graph itself. --- egui::CentralPanel::default().show_inside(ui, |ui| { egui_snarl::SnarlWidget::new() .id_salt(egui::Id::new("demo-snarl")) .show(&mut self.snarl, &mut self.viewer, ui); }); } } }` Notice the architecture from [Chapter 5](https://danpokhrel.github.io/tutorial/print.html#chapter-5-structuring-your-application) at work: the keyboard handling runs at the top of the frame and mutates state (removes nodes), then the widget-building code below it reads the now-updated state. We clone the `Context` (`ui.ctx().clone()`) so the input closure and the subsequent `get_selected_nodes` call can both borrow it without fighting the `&mut self` borrow of `ui` — cloning an `egui::Context` is cheap, it is just an `Arc` handle. We collect the selected ids into a `Vec` _before_ the removal loop because removing nodes can recycle ids — operating on a snapshot is safe. > **Warning:** Do not iterate `selected` while calling `remove_node` if you obtained `selected` as a borrow of live selection state. Always capture into an owned `Vec` first. The borrow rules of [Chapter 4.2](https://doc.rust-lang.org/stable/book/ch04-02-references-and-borrowing.html) > protect you here, but a snapshot makes the intent obvious. Bringing it together, here is a viewer that offers the graph menu palette, the node remove menu, the dropped-wire menu, hover popups, and the `ui`\-method delete shortcut — all in one place. We omit the methods we already showed in full in [Chapter 9](https://danpokhrel.github.io/tutorial/print.html#chapter-9-building-nodes--pins) and [Chapter 10](https://danpokhrel.github.io/tutorial/print.html#chapter-10-connections--wires) for brevity: `#![allow(unused)] fn main() { use eframe::egui; use egui_snarl::{ ui::{AnyPins, get_selected_nodes, PinInfo, SnarlViewer}, InPin, InPinId, NodeId, OutPin, OutPinId, Snarl, }; #[derive(Default)] pub struct DemoViewer; impl SnarlViewer for DemoViewer { (/* title, inputs, outputs, show_input, show_output, connect, disconnect as in chapters 8–10 */) fn has_graph_menu(&mut self, _: egui::Pos2, _: &Snarl) -> bool { true } fn show_graph_menu(&mut self, pos: egui::Pos2, ui: &mut egui::Ui, snarl: &mut Snarl) { ui.heading("Add Node"); ui.separator(); for (label, node) in [ ("Number", DemoNode::Number(0.0)), ("Text", DemoNode::Text(String::new())), ("Concat", DemoNode::Concat), ("Sink", DemoNode::Sink), ] { if ui.button(label).clicked() { snarl.insert_node(pos, node); ui.close(); } } } fn has_node_menu(&mut self, _: &DemoNode) -> bool { true } fn show_node_menu(&mut self, id: NodeId, ui: &mut egui::Ui, snarl: &mut Snarl) { ui.label(format!("Node {}", id.0)); ui.separator(); if ui.button("Remove").clicked() { snarl.remove_node(id); ui.close(); } } fn has_dropped_wire_menu(&mut self, _: &AnyPins, _: &Snarl) -> bool { true } fn show_dropped_wire_menu( &mut self, pos: egui::Pos2, wires: &AnyPins, ui: &mut egui::Ui, snarl: &mut Snarl, ) { match wires { AnyPins::Out(pins) => { if let Some(src) = pins.first() { if ui.button("New Sink (connected)").clicked() { let n = snarl.insert_node(pos, DemoNode::Sink); snarl.connect(*src, InPinId { node: n, input: 0 }); ui.close(); } } } AnyPins::In(pins) => { if let Some(dst) = pins.first() { if ui.button("New Number (connected)").clicked() { let n = snarl.insert_node(pos, DemoNode::Number(0.0)); snarl.connect(OutPinId { node: n, output: 0 }, *dst); ui.close(); } } } } } fn has_on_hover_popup(&mut self, _: &DemoNode) -> bool { true } fn show_on_hover_popup(&mut self, id: NodeId, ui: &mut egui::Ui, snarl: &mut Snarl) { let node = &snarl[id]; let desc = match node { DemoNode::Number(_) => "A constant number.", DemoNode::Text(_) => "A constant string.", DemoNode::Concat => "Joins two text inputs.", DemoNode::Sink => "Displays its input.", }; ui.label(desc); } } pub struct SnarlApp { snarl: Snarl, viewer: DemoViewer, } impl eframe::App for SnarlApp { fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { // Delete-key shortcut: remove selected nodes. let ctx = ui.ctx().clone(); if ctx.input(|i| i.key_pressed(egui::Key::Delete)) { let selected = get_selected_nodes(egui::Id::new("demo-snarl"), &ctx); for id in selected { self.snarl.remove_node(id); } } egui::Panel::right("sel").show_inside(ui, |ui| { ui.heading("Selection"); let selected = get_selected_nodes(egui::Id::new("demo-snarl"), ui.ctx()); if selected.is_empty() { ui.label("(none)"); } for id in &selected { ui.label(format!("Node {}", id.0)); } }); egui::CentralPanel::default().show_inside(ui, |ui| { egui_snarl::SnarlWidget::new() .id_salt(egui::Id::new("demo-snarl")) .show(&mut self.snarl, &mut self.viewer, ui); }); } } }` With this in place, the editor is fully interactive: right-click the background to add nodes, right-click a node to remove it, drag a wire to empty space to spawn a pre-connected node, hover a node for a description, and press Delete to remove the current selection. Everything in this part — the panels of [Chapter 4](https://danpokhrel.github.io/tutorial/print.html#chapter-4-layout-widgets--state) , the menus and modal of [Chapter 7](https://danpokhrel.github.io/tutorial/print.html#chapter-7-input-menus--dialogs) , and the snarl viewer of [Chapters 8–11](https://danpokhrel.github.io/tutorial/print.html#chapter-9-building-nodes--pins) — comes together in one `App`. The reference implementation’s `ui/app.rs` is exactly that assembly, so it is worth seeing whole. It owns the `Snarl` and the `DemoViewer`, plus three ephemeral flags (`settings_open`, `quit_modal_open`, `status`); it renders a top menu bar, a left sidebar of live graph stats, a bottom status bar, and the central snarl canvas, then layers a settings window and a quit-confirmation modal on top. The `App::new` wires the theme from [Chapter 6](https://danpokhrel.github.io/tutorial/print.html#chapter-6-theming-fonts--styling) and the image loaders, and seeds the graph from a small, headlessly-testable helper: ``#![allow(unused)] fn main() { // src/ui/app.rs use crate::theme::EditorTheme; use crate::ui::viewer::{DemoNode, DemoViewer}; use eframe::egui; use egui_snarl::{InPinId, OutPinId, Snarl, ui::SnarlWidget}; pub struct App { snarl: Snarl, viewer: DemoViewer, settings_open: bool, quit_modal_open: bool, status: String, } /// The platform quit shortcut: Cmd+Q on macOS, Ctrl+Q elsewhere. fn quit_shortcut() -> egui::KeyboardShortcut { egui::KeyboardShortcut::new(egui::Modifiers::COMMAND, egui::Key::Q) } /// Build the four-node demo graph the app starts with. /// Extracted from `App::new` so it can be unit-tested headless — /// `Snarl` is pure data and needs neither a GPU nor an egui `Context`. fn seed_demo_graph() -> Snarl { let mut snarl = Snarl::new(); let n = snarl.insert_node(egui::pos2(-280.0, -40.0), DemoNode::Number(42.0)); let t = snarl.insert_node(egui::pos2(-280.0, 60.0), DemoNode::Text("hello".into())); let c = snarl.insert_node(egui::pos2(-40.0, 0.0), DemoNode::Concat); let s = snarl.insert_node(egui::pos2(220.0, 0.0), DemoNode::Sink); // NB: keep the `connect` calls OUTSIDE `debug_assert!` — its argument is // not evaluated in release builds, which would silently drop the wires. let wired = snarl.connect( OutPinId { node: n, output: 0 }, InPinId { node: c, input: 0 }, ) && snarl.connect( OutPinId { node: t, output: 0 }, InPinId { node: c, input: 1 }, ) && snarl.connect( OutPinId { node: c, output: 0 }, InPinId { node: s, input: 0 }, ); debug_assert!(wired, "seed graph connections must not be duplicates"); snarl } impl App { pub fn new( cc: &eframe::CreationContext<'_>, ) -> Result> { EditorTheme::dark().apply(&cc.egui_ctx); egui_extras::install_image_loaders(&cc.egui_ctx); Ok(Self { snarl: seed_demo_graph(), viewer: DemoViewer, settings_open: false, quit_modal_open: false, status: "Ready".into(), }) } } }`` `logic()` handles the quit shortcut and — importantly — intercepts the window’s native close button so it routes through the same confirmation modal instead of quitting immediately: `#![allow(unused)] fn main() { impl eframe::App for App { fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { ctx.input_mut(|i| { if i.consume_shortcut(&quit_shortcut()) { self.quit_modal_open = true; } }); // The window's native close button must not bypass the confirmation // modal: cancel the close and route it through the same modal. If the // modal is already open, the request is allowed through (the user is // insisting). if ctx.input(|i| i.viewport().close_requested()) && !self.quit_modal_open { ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); self.quit_modal_open = true; } } // ui() shown below… } }` `ui()` lays the panels out in the order [Chapter 4](https://danpokhrel.github.io/tutorial/print.html#chapter-4-layout-widgets--state) prescribes — top, left, bottom, then central last — and uses egui 0.35’s built-in `Modal` for the quit confirmation (no hand-rolled dimmer needed, unlike the [Chapter 7](https://danpokhrel.github.io/tutorial/print.html#chapter-7-input-menus--dialogs) example): `#![allow(unused)] fn main() { fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { // 1) Top menu bar FIRST. egui::Panel::top("menu_bar").show(ui, |ui| { egui::MenuBar::new().ui(ui, |ui| { ui.menu_button("File", |ui| { if ui.button("Reset graph").clicked() { self.snarl = Snarl::new(); self.status = "Graph cleared".into(); ui.close(); } ui.separator(); let quit_btn = egui::Button::new("Quit") .shortcut_text(ui.ctx().format_shortcut(&quit_shortcut())); if ui.add(quit_btn).clicked() { self.quit_modal_open = true; ui.close(); } }); ui.menu_button("View", |ui| { ui.checkbox(&mut self.settings_open, "Settings"); }); }); }); // 2) Left sidebar NEXT. egui::Panel::left("sidebar") .resizable(true) .default_size(220.0) .min_size(160.0) .max_size(400.0) .show(ui, |ui| { ui.heading("Graph"); ui.separator(); ui.label(format!("Nodes: {}", self.snarl.nodes().count())); ui.label(format!("Wires: {}", self.snarl.wires().count())); ui.separator(); ui.label("Drag node headers to move."); ui.label("Drag background to pan."); ui.label("Ctrl+scroll to zoom."); ui.label("Drag from a pin to wire."); }); // 3) Bottom status bar: the last event message plus live graph stats. egui::Panel::bottom("status_bar") .default_size(24.0) .resizable(false) .show(ui, |ui| { ui.horizontal(|ui| { ui.label(format!( "{} · {} nodes · {} wires", self.status, self.snarl.nodes().count(), self.snarl.wires().count() )); }); }); // 4) Central panel LAST — the node graph canvas. egui::CentralPanel::default().show(ui, |ui| { SnarlWidget::new() .id_salt(egui::Id::new("demo-snarl")) .show(&mut self.snarl, &mut self.viewer, ui); }); // --- Settings floating window --- if self.settings_open { egui::Window::new("Settings") .open(&mut self.settings_open) .default_pos([140.0, 140.0]) .resizable(true) .show(ui, |ui| { ui.label("Theme: dark (custom)"); ui.label(format!("Status: {}", self.status)); }); } // --- Quit confirmation modal (using egui 0.35's built-in Modal) --- if self.quit_modal_open { let resp = egui::Modal::new(egui::Id::new("quit_modal")).show(ui.ctx(), |ui| { ui.set_min_width(280.0); ui.heading("Quit"); ui.label("Quit the application?"); ui.add_space(8.0); ui.horizontal(|ui| { let cancel = ui.button("Cancel").clicked() || ui.input(|i| i.key_pressed(egui::Key::Escape)); if cancel { self.quit_modal_open = false; } let quit = ui.button("Quit").clicked() || ui.input(|i| i.key_pressed(egui::Key::Enter)); if quit { ui.ctx().send_viewport_cmd(egui::ViewportCommand::Close); } }); }); if resp.backdrop_response.clicked() { self.quit_modal_open = false; } } } } }` A couple of details worth calling out. The `App::new` signature takes `&eframe::CreationContext<'_>` and returns `Result>` — the same `Result`\-returning `AppCreator` pattern from [Chapter 3](https://danpokhrel.github.io/tutorial/print.html#chapter-3-your-first-window) , so setup failures propagate cleanly. The seed graph is built in a standalone `seed_demo_graph()` function (not inline in `new`) precisely so it can be unit-tested without a `Context`: `#![allow(unused)] fn main() { #[cfg(test)] mod tests { use super::*; #[test] fn seed_graph_has_four_nodes_and_three_wires() { let snarl = seed_demo_graph(); assert_eq!(snarl.nodes().count(), 4); assert_eq!(snarl.wires().count(), 3); } } }` > **Scope note.** This assembled `App` plus the `DemoViewer` from [Chapter 9](https://danpokhrel.github.io/tutorial/print.html#chapter-9-building-nodes--pins) > and [Chapter 11](https://danpokhrel.github.io/tutorial/print.html#chapter-11-interactions--ux) > is the entirety of the reference implementation’s interactive editor — it deliberately omits the type-validated `connect` ([Chapter 10](https://danpokhrel.github.io/tutorial/print.html#chapter-10-connections--wires) > ), the dropped-wire/hover/selection features shown earlier in this chapter, and the custom `SnarlStyle`/`header_frame` of [Chapter 12](https://danpokhrel.github.io/tutorial/print.html#chapter-12-styling-the-graph) > . Those are taught as optional enhancements; the shipped demo stays lean. The agent-flow evaluation, streaming, and persistence of Part 4 are _not_ in the reference implementation at all — see the introduction’s note on scope. In this chapter we wired up the full interaction surface of `egui-snarl`. We built an “Add Node” palette in `show_graph_menu`, a remove action in `show_node_menu`, a context-aware dropped-wire menu that pre-connects new nodes, hover popups, and a side panel showing the current selection. We put the Delete keyboard shortcut at the top of the `ui` method, using the input API from [Chapter 7](https://danpokhrel.github.io/tutorial/print.html#chapter-7-input-menus--dialogs) and the collect-then-mutate pattern from [Chapter 5](https://danpokhrel.github.io/tutorial/print.html#chapter-5-structuring-your-application) . The graph is now fully usable. It is, however, still using the default visual style. In [Chapter 12](https://danpokhrel.github.io/tutorial/print.html#chapter-12-styling-the-graph) we will build a custom dark theme with color-coded pins, per-node-type colored headers, and a grid background — turning this functional editor into a polished one. Our editor is functional and interactive, but it still uses the default visual style — generic frames, default wire curves, no color-coded pins at a global level. In this chapter we build a polished dark theme: a custom `SnarlStyle` with a dark node frame and grid background, color-coded pins wired into a `theme` module, per-node-type colored headers, and per-pin wire styles. This chapter builds on the `PinInfo` material from [Chapter 9](https://danpokhrel.github.io/tutorial/print.html#chapter-9-building-nodes--pins) , the connection logic from [Chapter 10](https://danpokhrel.github.io/tutorial/print.html#chapter-10-connections--wires) , and the theming-module ideas from [Chapter 6](https://danpokhrel.github.io/tutorial/print.html#chapter-6-theming-fonts--styling) . > **Reference-impl note:** the demonstration editor does **not** use a custom `SnarlStyle` — it renders the snarl with `SnarlWidget::new().id_salt(...).show(...)` and no `.style(...)`, so it inherits snarl’s default node frame, grid background, and bezier wires. This chapter (like [Chapter 10](https://danpokhrel.github.io/tutorial/print.html#chapter-10-connections--wires) > ) is an optional enhancement: read it when you want a polished, branded graph look, and skip it if the defaults suffice. The reference impl’s `DemoViewer` also does not override `header_frame` (see [Chapter 9](https://danpokhrel.github.io/tutorial/print.html#chapter-9-building-nodes--pins) > ), so there are no per-type colored headers either. Where `egui`’s `Visuals`/`Style`/`FontDefinitions` (from [Chapter 6](https://danpokhrel.github.io/tutorial/print.html#chapter-6-theming-fonts--styling) ) style the whole application, `SnarlStyle` styles _the graph specifically_. It controls the graph background, the default node frame, the pin layout, the pin size, and more. You construct it with `SnarlStyle::new()` and override fields with struct-update syntax — the same `{ ..Default::default() }` idiom the Rust Book introduces when discussing structs in [Chapter 5.1](https://doc.rust-lang.org/stable/book/ch05-01-defining-structs.html) : ``#![allow(unused)] fn main() { use egui_snarl::SnarlStyle; let style = SnarlStyle { // All other fields keep their `new()` defaults: ..SnarlStyle::new() }; }`` The most useful fields are: | Field | Controls | | --- | --- | | `node_layout` | How pins are arranged (`Basic` = inputs left, outputs right). | | `pin_placement` | Where pins sit relative to the node border (`Edge`, etc.). | | `pin_size` | The radius/size of pin shapes. | | `node_frame` | The default `egui::Frame` around each node. | | `bg_frame` | The `egui::Frame` for the graph background (a grid by default). | You pass the style to the widget via `.style(style)`: `#![allow(unused)] fn main() { use eframe::egui; use egui_snarl::{SnarlStyle, ui::SnarlWidget}; impl eframe::App for SnarlApp { fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { egui::CentralPanel::default().show_inside(ui, |ui| { SnarlWidget::new() .id_salt(egui::Id::new("demo-snarl")) .style(self.style.clone()) .show(&mut self.snarl, &mut self.viewer, ui); }); } } }` Because `SnarlStyle` is cheap to clone, store it on the `App` and apply it every frame. This mirrors how we stored the app-wide `theme` module in [Chapter 6](https://danpokhrel.github.io/tutorial/print.html#chapter-6-theming-fonts--styling) : a single source of truth for appearance. Let us build a dark style. The `node_frame` sets each node’s background, margin, corner radius, and border; the `bg_frame` sets the canvas background. egui’s `Frame` is a builder we used in [Chapter 6](https://danpokhrel.github.io/tutorial/print.html#chapter-6-theming-fonts--styling) : `#![allow(unused)] fn main() { use eframe::egui; use egui_snarl::SnarlStyle; pub fn dark_snarl_style() -> SnarlStyle { let node_frame = egui::Frame::default() .fill(egui::Color32::from_rgb(40, 42, 48)) .corner_radius(6.0) .stroke(egui::Stroke::new(1.0, egui::Color32::from_rgb(70, 74, 82))) .inner_margin(egui::Margin::same(4.0)); let bg_frame = egui::Frame::default() .fill(egui::Color32::from_rgb(24, 26, 30)) .stroke(egui::Stroke::NONE); SnarlStyle { node_frame: Some(node_frame), bg_frame: Some(bg_frame), ..SnarlStyle::new() } } }` The header frame we customize separately, per node type, by overriding `SnarlViewer::header_frame` (we did this in [Chapter 9](https://danpokhrel.github.io/tutorial/print.html#chapter-9-building-nodes--pins) ). The `SnarlStyle.node_frame` is the _body_ frame; `header_frame` on the viewer overrides the header. `NodeLayout` decides how pins are arranged within a node. The default is `Basic` — input pins stacked on the left, output pins stacked on the right, with the body in between. This is what we have been using implicitly all along. You can construct the default explicitly: `#![allow(unused)] fn main() { use egui_snarl::{NodeLayout, SnarlStyle}; let style = SnarlStyle { node_layout: NodeLayout::coil(), ..SnarlStyle::new() }; }` `NodeLayout::coil()` produces the standard Basic layout. (The name reflects the internal rendering approach; the visible result is the familiar left-inputs / right-outputs arrangement.) For most graphs this is exactly right, and we will not change it. The point of knowing the field exists is that _alternative layouts_ are possible if your graph needs them. `PinPlacement` decides where the pin dot sits relative to the node frame. The `Edge` variant draws pins right on the node’s border — the look used by most shader-graph editors. You set it on the style: `#![allow(unused)] fn main() { use egui_snarl::{SnarlStyle, ui::PinPlacement}; let style = SnarlStyle { pin_placement: PinPlacement::Edge, ..SnarlStyle::new() }; }` > **Tip:** `PinPlacement::Edge` is the most polished look for a node graph: the pin dots sit half-in, half-out of the node border, so wires visually “plug in.” Try it before settling for the default, which places pins just inside the frame. In [Chapter 9](https://danpokhrel.github.io/tutorial/print.html#chapter-9-building-nodes--pins) we color-coded pins by hand. Now we formalize the convention in a `theme` module, so every pin returns a consistent color for its data type. This is the same encapsulation idea as the `theme.rs` module from [Chapter 6](https://danpokhrel.github.io/tutorial/print.html#chapter-6-theming-fonts--styling) : keep your color constants in one place so a redesign touches one file. `#![allow(unused)] fn main() { // src/snarl_theme.rs use eframe::egui; use egui_snarl::ui::{PinInfo, WireStyle}; /// The data types in our graph and their colors. pub fn pin_color(ty: DataType) -> egui::Color32 { match ty { DataType::Number => egui::Color32::from_rgb(220, 80, 80), DataType::Text => egui::Color32::from_rgb(80, 200, 80), } } /// A round pin filled with the type's color, with axis-aligned wires. pub fn pin_for(ty: DataType) -> PinInfo { PinInfo::circle() .with_fill(pin_color(ty)) .with_wire_style(WireStyle::AxisAligned { corner_radius: 6.0 }) } /// A dim pin for an unconnected input. pub fn dim_pin() -> PinInfo { PinInfo::circle() .with_fill(egui::Color32::from_rgb(120, 120, 120)) } /// The header color for a node type. pub fn header_color(node: &DemoNode) -> egui::Color32 { match node { DemoNode::Number(_) => egui::Color32::from_rgb(110, 60, 60), DemoNode::Text(_) => egui::Color32::from_rgb(50, 90, 50), DemoNode::Concat => egui::Color32::from_rgb(60, 60, 110), DemoNode::Sink => egui::Color32::from_rgb(50, 50, 70), } } }` Now the viewer delegates to these helpers, and the colors are defined in exactly one place: `#![allow(unused)] fn main() { impl SnarlViewer for DemoViewer { fn show_output( &mut self, pin: &OutPin, ui: &mut egui::Ui, snarl: &mut Snarl, ) -> impl SnarlPin + 'static { let node = &mut snarl[pin.id.node]; let ty = node.output_type(pin.id.output).unwrap_or(DataType::Number); match node { DemoNode::Number(v) => { ui.add(egui::DragValue::new(v).speed(0.1)); } DemoNode::Text(s) => { ui.text_edit_singleline(s); } DemoNode::Concat => { ui.label("out"); } DemoNode::Sink => unreachable!(), } crate::snarl_theme::pin_for(ty) } fn header_frame( &mut self, _frame: egui::Frame, node: NodeId, _inputs: &[InPin], _outputs: &[OutPin], snarl: &Snarl, ) -> egui::Frame { let node = &snarl[node]; let color = crate::snarl_theme::header_color(node); egui::Frame::default() .fill(color) .corner_radius(4.0) .stroke(egui::Stroke::new(1.0, egui::Color32::BLACK)) } } }` Because every pin’s color comes from `pin_color`, the _wires_ — which take their color from the pin fill — are automatically color-coded too. A red wire flowing into a green pin is an instant, glaring type mismatch, and your eye finds it without reading a single label. We have already shown `header_frame` in [Chapter 9](https://danpokhrel.github.io/tutorial/print.html#chapter-9-building-nodes--pins) and formalized the colors above. The pattern is a `match` on `&T` returning a per-variant `egui::Frame`. The compiler ensures we cover every variant (Rust Book [Chapter 6.2](https://doc.rust-lang.org/stable/book/ch06-02-match.html) ); adding a new node type later produces a compile error until we choose its header color — a free consistency check. For a richer look, vary not just the color but the rounding and stroke per type. A “dangerous” node (one with side effects, like an agent that calls out to a network) might get a thicker border: `#![allow(unused)] fn main() { fn header_frame( &mut self, _frame: egui::Frame, node: NodeId, _inputs: &[InPin], _outputs: &[OutPin], snarl: &Snarl, ) -> egui::Frame { let node = &snarl[node]; let color = crate::snarl_theme::header_color(node); egui::Frame::default() .fill(color) .corner_radius(4.0) .stroke(egui::Stroke::new( if matches!(node, DemoNode::Sink) { 2.0 } else { 1.0 }, egui::Color32::BLACK, )) } }` By default, `SnarlStyle`’s background draws a grid — the classic “graph paper” look that helps the user gauge position and scale. You get this for free with `SnarlStyle::new()`. The grid color and spacing derive from the `bg_frame`’s fill and the widget’s internal logic. If you want a custom background — for example, a dotted pattern, a watermark, or a “spawn zone” tint — you override `SnarlViewer::draw_background`. The method receives a `&mut Ui` covering the whole canvas and the current `TSTransform` (the pan/zoom transform) so you can draw in _graph_ coordinates: `#![allow(unused)] fn main() { use egui_snarl::{ui::{TSTransform, SnarlViewer}, Snarl}; impl SnarlViewer for DemoViewer { fn draw_background( &mut self, transform: TSTransform, ui: &mut egui::Ui, _snarl: &mut Snarl, ) { // Let the default grid draw first, then overlay a tint in a corner. // (The default grid is drawn by SnarlStyle's bg_frame; here we add to it.) let painter = ui.painter().clone(); let rect = ui.max_rect(); // Example: a subtle radial vignette. painter.rect_filled( rect, 0.0, egui::Color32::from_black_alpha(0), ); let _ = transform; // use to convert graph<->screen coords if needed } } }` The default grid is usually what you want; we mention `draw_background` and `current_transform` for completeness — they are the escape hatch for tools that need custom canvas decorations, like drawing a “playhead” line during evaluation in [Chapter 15](https://danpokhrel.github.io/tutorial/print.html#chapter-15-live-execution--streaming) . Store the `SnarlStyle` on your `App` and apply it in `ui()`. Construct it once in `default()` so you never rebuild it per frame: `#![allow(unused)] fn main() { use eframe::egui; use egui_snarl::SnarlStyle; pub struct SnarlApp { snarl: Snarl, viewer: DemoViewer, style: SnarlStyle, } impl Default for SnarlApp { fn default() -> Self { Self { snarl: Snarl::new(), viewer: DemoViewer::default(), style: dark_snarl_style(), } } } impl eframe::App for SnarlApp { fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { egui::CentralPanel::default().show_inside(ui, |ui| { egui_snarl::SnarlWidget::new() .id_salt(egui::Id::new("demo-snarl")) .style(self.style.clone()) .show(&mut self.snarl, &mut self.viewer, ui); }); } } }` If you want a live dark/light toggle for the _graph_ (separate from egui’s application-wide `Visuals`), store a `bool` and rebuild the style in `logic()` when it changes. This is the same “derive a style from a small set of toggles” pattern we used for app theming in [Chapter 6](https://danpokhrel.github.io/tutorial/print.html#chapter-6-theming-fonts--styling) . Following [Chapter 6](https://danpokhrel.github.io/tutorial/print.html#chapter-6-theming-fonts--styling) , encapsulate the graph’s appearance in its own module so a redesign is a one-file change: `#![allow(unused)] fn main() { // src/snarl_theme.rs use eframe::egui; use egui_snarl::{ui::{PinInfo, PinPlacement, WireStyle}, NodeLayout, SnarlStyle}; #[derive(Clone, Copy, PartialEq, Eq)] pub enum DataType { Number, Text } pub fn pin_color(ty: DataType) -> egui::Color32 { /* ... */ } pub fn pin_for(ty: DataType) -> PinInfo { /* ... */ } pub fn dim_pin() -> PinInfo { /* ... */ } pub fn header_color(node: &DemoNode) -> egui::Color32 { /* ... */ } pub fn dark_snarl_style() -> SnarlStyle { let node_frame = egui::Frame::default() .fill(egui::Color32::from_rgb(40, 42, 48)) .corner_radius(6.0) .stroke(egui::Stroke::new(1.0, egui::Color32::from_rgb(70, 74, 82))) .inner_margin(egui::Margin::same(4.0)); let bg_frame = egui::Frame::default() .fill(egui::Color32::from_rgb(24, 26, 30)); SnarlStyle { node_layout: NodeLayout::coil(), pin_placement: PinPlacement::Edge, node_frame: Some(node_frame), bg_frame: Some(bg_frame), ..SnarlStyle::new() } } }` The viewer imports from this module rather than hardcoding colors. This is the Rust Book’s [Chapter 7](https://doc.rust-lang.org/stable/book/ch07-02-defining-modules-to-control-scope-and-privacy.html) advice on modules made concrete: a `snarl_theme` module exposes a small public API (`pin_for`, `header_color`, `dark_snarl_style`) and hides the rest. Here is the assembled `ui()` for a fully styled graph — dark node frames, `Edge` pin placement, axis-aligned wires, color-coded pins, per-type headers, and the grid background: `#![allow(unused)] fn main() { use eframe::egui; use egui_snarl::ui::SnarlWidget; pub struct SnarlApp { snarl: egui_snarl::Snarl, viewer: DemoViewer, style: egui_snarl::SnarlStyle, } impl eframe::App for SnarlApp { fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { egui::Panel::top("toolbar").show_inside(ui, |ui| { ui.horizontal(|ui| { ui.label("Node Graph Editor"); if ui.button("Reset Style").clicked() { self.style = crate::snarl_theme::dark_snarl_style(); } }); }); egui::CentralPanel::default().show_inside(ui, |ui| { SnarlWidget::new() .id_salt(egui::Id::new("demo-snarl")) .style(self.style.clone()) .show(&mut self.snarl, &mut self.viewer, ui); }); } } }` With the viewer from [Chapter 11](https://danpokhrel.github.io/tutorial/print.html#chapter-11-interactions--ux) returning `pin_for(ty)` from `show_input`/`show_output` and `header_color(node)` from `header_frame`, the result is a graph that looks like a professional shader-graph editor: dark nodes with colored headers, color-coded pins and wires with right-angled corners, and a subtle grid behind it all. The `egui-snarl` demo itself uses the `egui_probe` crate to live-edit its `SnarlStyle` at runtime — a `Probe`\-driven inspector that exposes every style field as an editable widget. This is a _debugging_ tool, not a production feature, but it is invaluable when you are dialing in a look: you tweak `pin_size`, `corner_radius`, and colors in a running app and see the result instantly. > **Note:** `egui_probe` is not enabled by default. To use it, add `egui-snarl = { version = "0.11", features = ["serde", "egui-probe"] }` to your `Cargo.toml` and add `egui_probe` as a dependency. Without the feature, `ui.probe(...)` will not resolve. To try it, add `egui_probe` to your dev-dependencies and, behind a debug flag, render a probe panel: `#![allow(unused)] fn main() { // dev-dependency only; do not ship to end users. use egui_probe::Probe; impl eframe::App for SnarlApp { fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { egui::Panel::left("debug").show_inside(ui, |ui| { ui.heading("Style Probe"); // Probe generates editable widgets for every public field. ui.probe(&mut self.style); }); egui::CentralPanel::default().show_inside(ui, |ui| { egui_snarl::SnarlWidget::new() .id_salt(egui::Id::new("demo-snarl")) .style(self.style.clone()) .show(&mut self.snarl, &mut self.viewer, ui); }); } } }` `egui_probe`’s `Probe` trait uses reflection-style code generation to produce a UI for any struct that implements `Probe`. Once you have found values you like, hard-code them into your `dark_snarl_style()` function and remove the probe. This is exactly how the `egui-snarl` authors developed the defaults you inherited. > **Note:** `egui_probe` is a debugging aid, not a stable styling API. Its API may change between versions. Do not depend on it for production UI; use it only to discover the style values you then bake into your `theme` module. In this chapter we built a polished graph style. We learned that `SnarlStyle` controls the graph’s global appearance — node frame, background frame, pin layout, pin placement — and that `SnarlViewer::header_frame` overrides the header per node type. We factored all colors into a `snarl_theme` module, color-coded pins by data type so the wires themselves become a type-mismatch alarm, and switched wires to axis-aligned segments for a shader-graph look. We mentioned `egui_probe` as an optional live-tweaking tool for discovering style values. With Chapters 8 through 12, we now have a complete, styled, interactive node-graph editor. In Part 4, we will give this graph _meaning_: [Chapter 13](https://danpokhrel.github.io/tutorial/print.html#chapter-13-defining-agent-nodes) defines agent nodes that call out to language models, [Chapter 14](https://danpokhrel.github.io/tutorial/print.html#chapter-14-graph-evaluation) turns the graph into an evaluation engine, and [Chapter 15](https://danpokhrel.github.io/tutorial/print.html#chapter-15-live-execution--streaming) streams results back into the node bodies as they run. So far our node graph editor can hold nodes, draw pins, and connect wires — but the nodes themselves are empty placeholders. In this chapter we give them purpose. We’ll design a node type system for an _agentic AI flow builder_, the kind of visual tool made popular by projects like Langflow and Flowise, where you compose large language models (LLMs), prompt templates, tools, and memory into runnable pipelines by dragging boxes and drawing connections. By the end of the chapter you’ll have an `AgentNode` enum with a variant for each agent component, a `SnarlViewer` implementation that renders each variant’s pins and configuration UI, and a color-coded pin system. This chapter assumes you’ve worked through [Chapter 8](https://danpokhrel.github.io/tutorial/print.html#chapter-8-introducing-egui-snarl) through [Chapter 12](https://danpokhrel.github.io/tutorial/print.html#chapter-12-styling-the-graph) , where we built and styled the generic snarl-based graph shell. > **Scope note: not in the reference implementation.** The companion `tutorial-implementation` crate **does not implement `AgentNode`** — it stops at the `DemoNode` editor (Number / Text / Concat / Sink) from Part 3. This chapter and the rest of Part 4 (evaluation, live streaming, persistence) are forward-looking material: they teach the patterns you would add to turn the demo editor into a real agentic flow builder, and the code is accurate egui-snarl / Rust, but it is not mirrored in the implementation. If you are following along against the reference impl, treat Part 4 as a design exercise layered on top of your `DemoNode` editor rather than code you will find in the crate. Tools like Langflow and Flowise let you build LLM applications without writing glue code. You drop a _chat input_ node, wire it into a _prompt template_, feed that into an _LLM node_, optionally branch through _tools_ and _memory_, and terminate at an _output_ node. The graph is both a visual artifact (you can read the pipeline at a glance) and an executable program (the host evaluates it when the user clicks _Run_). Our goal for the rest of Part 4 is to build exactly that, in Rust, with eframe and egui-snarl. We won’t call real LLM APIs in the tutorial — we’ll _simulate_ them so the code runs anywhere without API keys — but the architecture will be honest: every node knows its inputs, its configuration, and how to produce an output, and the evaluation engine we build in [Chapter 14](https://danpokhrel.github.io/tutorial/print.html#chapter-14-graph-evaluation) will treat them uniformly. A minimal agentic flow might look like this: `[StringNode: "You are a helpful assistant"] ──┐ ├─→ [LLMNode] ─→ [OutputNode] [ChatInput: "What is 2+2?"] ───────┘` The `LLMNode` takes two string inputs — a _system prompt_ and a _user message_ — and produces a response string. More elaborate flows add a `PromptTemplate` to interpolate variables, a `ToolNode` to simulate a web search or calculator, and a `MemoryNode` to carry conversation history between turns. Every wire in these graphs carries a string, which keeps the type system simple while we learn the patterns; we’ll discuss adding real type checking at the end of this chapter. In egui-snarl, a `Snarl` holds one `T` per node, and a `SnarlViewer` decides how each `T` is rendered. The natural Rust design for “many kinds of node that share a common interface” is an **enum** — one variant per node kind. Each variant carries its own configuration fields: a model name for `LLMNode`, a template string for `PromptTemplate`, a temperature slider value, and so on. Why an enum and not a trait object (`Box`)? Two reasons. First, egui-snarl requires the node type to be `Clone + Send + Sync`, and enums of plain data trivially satisfy that. Second, enums give us exhaustive pattern matching, which we’ll lean on heavily when rendering pins and when evaluating the graph in [Chapter 14](https://danpokhrel.github.io/tutorial/print.html#chapter-14-graph-evaluation) . This is the same “modeling state with enums” idea the Rust Book emphasizes in [Chapter 6](https://doc.rust-lang.org/stable/book/ch06-01-defining-an-enum.html) and [Chapter 18](https://doc.rust-lang.org/stable/book/ch18-03-pattern-matching.html) — when the set of variants is closed and known, an enum is clearer than a trait hierarchy. We’ll define six node types, enough to express non-trivial agent flows: * **`ChatInput`** — a starting node with an editable user message. One output: a string. * **`StringNode`** — a constant string source, for system prompts and other fixed text. No inputs; one output. * **`PromptTemplate`** — formats a template string, substituting connected inputs in order. One input: string; one output: string. * **`LLMNode`** — represents an LLM call. Inputs: system prompt (string), user message (string); output: response (string). Configured with a model name and temperature. * **`ToolNode`** — simulates a tool call (web search, calculator). Input: query string; output: result string. Configured with a tool kind. * **`MemoryNode`** — stores conversation history. Input: a string; output: a string that includes accumulated context. * **`OutputNode`** — displays the final result. One input: string; no outputs. Every wire carries a string, so `StringNode`, `ChatInput`, and `PromptTemplate`’s output can all feed `LLMNode`’s inputs interchangeably. Let’s write the enum. We derive `Clone`, `Debug`, `serde::Serialize`, and `serde::Deserialize` — the serde derives are essential for the persistence work in [Chapter 16](https://danpokhrel.github.io/tutorial/print.html#chapter-16-state-persistence) , where `Snarl` will serialize straight to JSON. We also give each variant the configuration fields it needs: ``#![allow(unused)] fn main() { use serde::{Deserialize, Serialize}; /// A single node in an agentic AI flow graph. /// /// Every variant is pure data: no `egui` types here, so the enum /// can be unit-tested on a headless CI machine (see Chapter 11 and /// Chapter 17) and serialized to disk (Chapter 16). #[derive(Clone, Debug, Serialize, Deserialize)] pub enum AgentNode { /// User-typed message that starts a flow. ChatInput { message: String, }, /// A constant string, useful for system prompts. StringNode { value: String, }, /// Formats a template, substituting `{0}`, `{1}`, ... with inputs. PromptTemplate { template: String, }, /// An LLM call. Combines a system prompt and user message. LLMNode { model: String, temperature: f32, system_prompt: String, }, /// A simulated tool call (web search, calculator, etc.). ToolNode { tool: ToolKind, }, /// Accumulates conversation history and returns it as context. MemoryNode { history: Vec, }, /// Terminal node that displays the final result. OutputNode, } /// Which simulated tool a `ToolNode` represents. #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] pub enum ToolKind { WebSearch, Calculator, Weather, } impl Default for AgentNode { fn default() -> Self { // A reasonable starting node when one is created without arguments. AgentNode::StringNode { value: String::new(), } } } }`` > **Tip:** Notice we put `history: Vec` _inside_ the `MemoryNode` variant. Node-internal state like this serializes alongside the node and survives reloads. If a node needs state that is large, ephemeral, or non-serializable (a live HTTP connection, a thread handle), keep that in a side `HashMap` on the `App` instead — see the discussion of background-task state in [Chapter 5](https://danpokhrel.github.io/tutorial/print.html#chapter-5-structuring-your-application) > . We can also add convenience constructors so the UI (and our tests) can spawn nodes succinctly: `#![allow(unused)] fn main() { impl AgentNode { pub fn chat_input() -> Self { AgentNode::ChatInput { message: String::new(), } } pub fn llm() -> Self { AgentNode::LLMNode { model: "gpt-4o-mini".to_string(), temperature: 0.7, system_prompt: "You are a helpful assistant.".to_string(), } } pub fn output() -> Self { AgentNode::OutputNode } } }` The `SnarlViewer` trait has three jobs that matter to us here: decide a node’s **title**, list its **input pins**, and list its **output pins**. (We’ll handle interaction callbacks like `connect` and the in-node configuration UI in a moment.) Each method receives the `Snarl` and the node’s `NodeId`, so it can inspect the node’s data via `snarl[id]`. The number and order of pins a variant exposes is fixed and must match what the evaluator in [Chapter 14](https://danpokhrel.github.io/tutorial/print.html#chapter-14-graph-evaluation) expects, so we’ll be careful to keep them consistent. `#![allow(unused)] fn main() { use eframe::egui; use egui_snarl::{ui::{PinInfo, SnarlPin, SnarlViewer}, InPin, OutPin, Snarl}; pub struct AgentViewer; impl SnarlViewer for AgentViewer { fn title(&mut self, node: &AgentNode) -> String { match node { AgentNode::ChatInput { .. } => "Chat Input".into(), AgentNode::StringNode { .. } => "String".into(), AgentNode::PromptTemplate { .. } => "Prompt Template".into(), AgentNode::LLMNode { model, .. } => format!("LLM ({model})"), AgentNode::ToolNode { tool } => format!("Tool: {:?}", tool), AgentNode::MemoryNode { .. } => "Memory".into(), AgentNode::OutputNode => "Output".into(), } } fn inputs(&mut self, node: &AgentNode) -> usize { match node { AgentNode::ChatInput { .. } | AgentNode::StringNode { .. } | AgentNode::OutputNode => 0, AgentNode::PromptTemplate { .. } | AgentNode::ToolNode { .. } | AgentNode::MemoryNode { .. } => 1, // system prompt + user message AgentNode::LLMNode { .. } => 2, } } fn outputs(&mut self, node: &AgentNode) -> usize { match node { AgentNode::OutputNode => 0, _ => 1, } } } }` > **Important: the real `SnarlViewer` signatures.** The `title`, `inputs`, and `outputs` methods take only `&mut self` and `&T` (the node data) — they do **not** receive the `Snarl` or a `NodeId`. The `show_input`/`show_output` methods receive a `pin: &InPin` or `&OutPin` (which bundles the node id and the `remotes` list), a `&mut Ui`, and a `&mut Snarl`. The `connect` method takes `(&OutPin, &InPin, &mut Snarl)` and returns `()` (not `bool`). These signatures match the egui-snarl 0.11 trait exactly. If you see tutorials showing different signatures (with `&Snarl`, `NodeId`, and `usize` pin indices), they are outdated. `SnarlViewer::show_input` and `show_output` return a `PinInfo` describing how a pin is drawn. We use `PinInfo::circle().with_fill(color)` to render a small dot, and we color-code by the _kind_ of value the pin carries. Since all our pins currently carry strings, we use one color for string pins — green — and reserve other colors for future pin types so the system is visually extensible: ``#![allow(unused)] fn main() { /// Pin color for a string-carrying pin. const STRING_PIN: egui::Color32 = egui::Color32::from_rgb(80, 200, 120); /// Pin color for a control-flow pin (reserved for future use). const CONTROL_PIN: egui::Color32 = egui::Color32::from_rgb(220, 160, 60); impl SnarlViewer for AgentViewer { fn show_input( &mut self, _pin: &InPin, _ui: &mut egui::Ui, _snarl: &mut Snarl, ) -> impl SnarlPin + 'static { // All current inputs are strings. Later, a match on `_pin.id` // can return CONTROL_PIN for non-string pins. PinInfo::circle().with_fill(STRING_PIN) } fn show_output( &mut self, _pin: &OutPin, _ui: &mut egui::Ui, _snarl: &mut Snarl, ) -> impl SnarlPin + 'static { PinInfo::circle().with_fill(STRING_PIN) } } }`` > **Note:** We keep a `CONTROL_PIN` constant even though nothing uses it yet. When you later add typed pins (an image output, a structured-data output), you’ll branch on the node variant and pin index here, returning a distinct color per type — exactly the way the bundled egui-snarl demo distinguishes its pin kinds. Defining the colors up front keeps the visual language stable as the type system grows. A node is more than its pins — it needs editable configuration: the user must be able to type a prompt, pick a model, and drag a temperature slider without leaving the node. egui-snarl gives us two hooks for drawing arbitrary UI inside a node body: `show_input` and `show_output`, called when rendering each pin’s row. We already used them above to return `PinInfo`; the same methods can also draw widgets into the `&mut egui::Ui` they receive before returning the pin info. The trick is to match on the node variant and the pin index, draw the appropriate editor, and mutate the node. Because the method signature hands us `&AgentNode` (a shared reference), we need to collect the _intent_ to edit and apply it afterward — the same collect-then-mutate discipline from [Chapter 5](https://danpokhrel.github.io/tutorial/print.html#chapter-5-structuring-your-application) . In practice, egui-snarl’s `show_input`/`show_output` are called on a borrowed node, so we’ll read the current value, let the user edit a local copy, and stash the edited value in a pending-edits map owned by the viewer. For brevity in this chapter, we show the inline mutation pattern you’d use when the viewer holds `&mut` to the node through other hooks; the full collect-then-mutate version is left as an exercise and revisited in [Chapter 14](https://danpokhrel.github.io/tutorial/print.html#chapter-14-graph-evaluation) . Here is a partial `SnarlViewer` implementation covering `ChatInput`, `LLMNode`, and `OutputNode`, with configuration UI for the first two: ``#![allow(unused)] fn main() { use eframe::egui; use egui_snarl::{ui::{PinInfo, SnarlPin, SnarlViewer}, InPin, NodeId, OutPin, Snarl}; pub struct AgentViewer { /// Buffered text edits captured while rendering node bodies, applied /// after `show_*` returns so we never hold `&mut` to the node mid-render. pub pending_edits: std::collections::HashMap, } impl SnarlViewer for AgentViewer { fn title(&mut self, node: &AgentNode) -> String { match node { AgentNode::ChatInput { .. } => "Chat Input".into(), AgentNode::LLMNode { model, .. } => format!("LLM ({model})"), AgentNode::OutputNode => "Output".into(), _ => "Node".into(), } } fn inputs(&mut self, node: &AgentNode) -> usize { match node { AgentNode::ChatInput { .. } => 0, AgentNode::LLMNode { .. } => 2, // system prompt, user message AgentNode::OutputNode => 1, _ => 0, } } fn outputs(&mut self, node: &AgentNode) -> usize { match node { AgentNode::OutputNode => 0, _ => 1, } } fn show_input( &mut self, pin: &InPin, ui: &mut egui::Ui, snarl: &mut Snarl, ) -> impl SnarlPin + 'static { let id = pin.id.node; let input = pin.id.input; let node = snarl[id].clone(); match &node { AgentNode::LLMNode { system_prompt, .. } if input == 0 => { // System prompt editor on the first input row. let mut buf = system_prompt.clone(); ui.text_edit_multiline(&mut buf); if buf != *system_prompt { let mut edited = node.clone(); if let AgentNode::LLMNode { system_prompt, .. } = &mut edited { *system_prompt = buf; } self.pending_edits.insert(id, edited); } } AgentNode::OutputNode => { // Read-only: the output node just shows its received value. ui.label("(final result)"); } _ => {} } PinInfo::circle().with_fill(egui::Color32::from_rgb(80, 200, 120)) } fn show_output( &mut self, pin: &OutPin, ui: &mut egui::Ui, snarl: &mut Snarl, ) -> impl SnarlPin + 'static { let id = pin.id.node; let node = snarl[id].clone(); match &node { AgentNode::ChatInput { message } => { let mut buf = message.clone(); ui.text_edit_singleline(&mut buf); ui.add_space(2.0); if buf != *message { let mut edited = node.clone(); if let AgentNode::ChatInput { message } = &mut edited { *message = buf; } self.pending_edits.insert(id, edited); } } AgentNode::LLMNode { model, temperature, .. } => { // Model selector + temperature slider live in the *output* // row so the node body reads top-to-bottom. let mut temp = *temperature; let mut model_buf = model.clone(); ui.horizontal(|ui| { ui.label("Model"); egui::ComboBox::from_id_salt("llm_model") .selected_text(model_buf.as_str()) .show_ui(ui, |ui| { for m in ["gpt-4o-mini", "gpt-4o", "claude-3.5-sonnet"] { ui.selectable_value(&mut model_buf, m.to_string(), m); } }); }); ui.add( egui::Slider::new(&mut temp, 0.0..=2.0) .text("temp") .fixed_decimals(2), ); if temp != *temperature || model_buf != *model { let mut edited = node.clone(); if let AgentNode::LLMNode { model, temperature, .. } = &mut edited { *model = model_buf; *temperature = temp; } self.pending_edits.insert(id, edited); } } _ => {} } PinInfo::circle().with_fill(egui::Color32::from_rgb(80, 200, 120)) } } }`` > **Warning:** The model-selection snippet above uses `selectable_value` with a throwaway `String::from(m)` as the target, which won’t actually persist the edit — it’s illustrative only. In real code, buffer the selection in a local `&mut String` bound (mirroring the `ChatInput` pattern) and route the change through `pending_edits`. The point of the example is the _layout_: a `ComboBox` and a `Slider` rendered inside a node body via the `show_output` hook. After the snarl renders, the host must drain `pending_edits` and write each updated node back into the `Snarl`. The pattern, which we’ll reuse throughout Part 4, looks like: `#![allow(unused)] fn main() { // In your App::ui or a helper, after showing the snarl: let pending = std::mem::take(&mut viewer.pending_edits); for (id, node) in pending { snarl[id] = node; } }` This is the same collect-then-mutate discipline that keeps the borrow checker happy when a single frame touches many widgets — see [Chapter 5](https://danpokhrel.github.io/tutorial/print.html#chapter-5-structuring-your-application) for the full rationale. When the user draws a wire between an output pin and an input pin, egui-snarl calls `SnarlViewer::connect`, which by default just records the connection in the snarl’s wire list. We can override it to _validate_ the connection — refuse, for instance, to wire an output into another output. For now, every pin carries a string, so any output can connect to any input. That makes the graph maximally flexible, which is nice while learning. We can still add a guard for the obvious structural rule — never connect two inputs or two outputs together — and reserve the hook for richer type checking later: `#![allow(unused)] fn main() { use egui_snarl::{ui::SnarlViewer, InPinId, OutPinId, InPin, OutPin, Snarl}; impl SnarlViewer for AgentViewer { fn connect( &mut self, from: &OutPin, to: &InPin, snarl: &mut Snarl, ) { // egui-snarl only calls this for valid output→input pairs, so the // structural rule is enforced by the framework. We accept all // connections because every pin is a string. // To veto a connection, simply do NOT call snarl.connect here. snarl.connect(from.id, to.id); } } }` > **Note:** To veto a connection, simply do not call `snarl.connect(...)` inside your override — the wire will not be created. When you later introduce a type system (string vs. image vs. structured data pins), this is where you’d inspect the source and target node variants and skip the `snarl.connect` call for mismatched types — for example, refusing to wire an `LLMNode` output into a `ToolNode` field that expects a structured query. For now we accept everything. A clean way to add type checking without a big refactor is to give each pin a `PinType` (an enum: `String`, `Image`, `Json`, …), expose it via a helper on `AgentNode`, and check matching in `connect`. Because our evaluation engine (next chapter) already gathers inputs by pin index, adding a type field doesn’t disturb the evaluator as long as two pins of the same type stay compatible. We’ll leave this as an exercise; the architecture we’re building won’t fight you when you add it. We now have an `AgentNode` enum, a viewer with titles and pins, and configuration editors for three variants. To display it, we hand the snarl and viewer to a `SnarlWidget`, exactly as in [Chapter 9](https://danpokhrel.github.io/tutorial/print.html#chapter-9-building-nodes--pins) : `#![allow(unused)] fn main() { use eframe::egui; use egui_snarl::{ui::SnarlWidget, Snarl}; pub struct App { pub snarl: Snarl, pub viewer: AgentViewer, } impl App { pub fn show_graph(&mut self, ui: &mut egui::Ui) { SnarlWidget::new() .id_salt(egui::Id::new("agent_graph")) .show(&mut self.snarl, &mut self.viewer, ui); // Apply any text edits captured during rendering. let pending = std::mem::take(&mut self.viewer.pending_edits); for (id, node) in pending { self.snarl[id] = node; } } } }` We can seed a starter graph to make sure everything wires up: `#![allow(unused)] fn main() { impl App { pub fn new() -> Self { let mut snarl = Snarl::::new(); let sys = snarl.insert_node([0.0, 0.0], AgentNode::StringNode { value: "You are a helpful assistant.".into(), }); let chat = snarl.insert_node([0.0, 200.0], AgentNode::ChatInput { message: "What is 2+2?".into(), }); let llm = snarl.insert_node([400.0, 100.0], AgentNode::LLMNode { model: "gpt-4o-mini".into(), temperature: 0.7, system_prompt: String::new(), }); let out = snarl.insert_node([800.0, 100.0], AgentNode::OutputNode); // sys -> llm input 0 (system prompt) // chat -> llm input 1 (user message) // llm -> out input 0 snarl.connect( egui_snarl::OutPinId { node: sys, output: 0 }, egui_snarl::InPinId { node: llm, input: 0 }, ); snarl.connect( egui_snarl::OutPinId { node: chat, output: 0 }, egui_snarl::InPinId { node: llm, input: 1 }, ); snarl.connect( egui_snarl::OutPinId { node: llm, output: 0 }, egui_snarl::InPinId { node: out, input: 0 }, ); Self { snarl, viewer: AgentViewer { pending_edits: Default::default() }, } } } }` You should now see four nodes, color-coded green pins, editable prompt text in the `ChatInput` and `LLMNode` bodies, a model combo box and temperature slider, and three wires forming a complete pipeline. The graph looks like an agent flow — but nothing happens when you stare at it, because we haven’t taught it to _run_. That’s the job of the evaluation engine. * * * We now have a typed, visually-rich set of agent nodes living inside our snarl graph. Each node declares its pins, renders its own configuration UI, and color-codes its connections. What’s missing is the part that makes a flow builder _useful_: executing the graph. In [Chapter 14](https://danpokhrel.github.io/tutorial/print.html#chapter-14-graph-evaluation) we’ll build a graph evaluator — a pure-Rust module that topologically sorts the nodes, feeds inputs forward, and produces a result for every node — turning our visual diagram into a runnable program. A flow builder with beautiful nodes and wires is, until something runs, just a drawing. In this chapter we make it _compute_. We’ll build a graph evaluator that takes a `Snarl` and produces an output value for every node, executing them in dependency order so that every node’s inputs are ready by the time it runs. The evaluator will live in a pure-Rust module with no `egui` dependency, so it can be unit-tested on a headless CI machine exactly as we planned in [Chapter 5](https://danpokhrel.github.io/tutorial/print.html#chapter-5-structuring-your-application) and practiced in [Chapter 11](https://danpokhrel.github.io/tutorial/print.html#chapter-11-interactions--ux) . This chapter builds directly on the `AgentNode` enum from [Chapter 13](https://danpokhrel.github.io/tutorial/print.html#chapter-13-defining-agent-nodes) . > **Scope note: not in the reference implementation.** The impl has no `eval` module and no evaluator — like the rest of Part 4, this chapter is forward-looking design. The function names here (`topo_sort`, `evaluate_node`, `simulate_llm_response`) are the public API [Chapter 15](https://danpokhrel.github.io/tutorial/print.html#chapter-15-live-execution--streaming) > and [Chapter 17](https://danpokhrel.github.io/tutorial/print.html#chapter-17-production-architecture) > build on, but you will not find them in the implementation. Given a directed graph of nodes where each node consumes some inputs and produces some outputs, _evaluate_ means: produce a value for every node such that every node’s inputs are the outputs of the nodes feeding into it. Concretely, for our agent flow: * `StringNode` produces its constant string. * `ChatInput` produces the message the user typed. * `PromptTemplate` formats its template using connected inputs. * `LLMNode` takes its two input strings, calls (or simulates) an LLM, and emits the response. * `ToolNode` simulates a tool call on its input query. * `MemoryNode` accumulates input into its history and emits the joined context. * `OutputNode` consumes its input and stores it as the final result. The catch is _ordering_. We cannot evaluate `LLMNode` before its system-prompt and user-message inputs are ready. So evaluation has two phases: figure out a valid order, then walk that order computing outputs. A _topological sort_ of a directed acyclic graph (DAG) is a linear ordering of its nodes such that for every edge `u → v`, `u` comes before `v`. If the graph has a cycle, no topological order exists, and that is exactly the error condition we must detect — a cyclic flow cannot be evaluated because a node’s input would depend on its own output. Two classic algorithms produce a topological order: * **Kahn’s algorithm**: repeatedly remove nodes with no remaining incoming edges (in-degree zero), appending them to the output and decrementing their successors’ in-degrees. If the output has fewer nodes than the graph, a cycle exists. * **DFS-based**: run a post-order DFS, pushing nodes onto a stack; popping the stack yields a topological order. Detect back-edges to find cycles. We’ll use Kahn’s algorithm because it gives us the cycle check for free and is easy to read. egui-snarl exposes connectivity through `snarl.wires()`, which yields `(OutPinId, InPinId)` pairs. Each such pair means “output of `OutPinId.node` feeds input of `InPinId.node`” — i.e. there is a dependency edge from the source node to the destination node. We can turn the wire iterator into an adjacency list and an in-degree map: ``#![allow(unused)] fn main() { use egui_snarl::{NodeId, Snarl}; use std::collections::{HashMap, HashSet}; /// Build an adjacency list and in-degree map from a snarl's wires. /// /// `outgoing[a]` is the set of nodes that depend on `a`'s outputs. /// `indeg[b]` is how many of `b`'s inputs are fed by some upstream node. fn build_adjacency( snarl: &Snarl, ) -> (HashMap>, HashMap) { let mut outgoing: HashMap> = HashMap::new(); let mut indeg: HashMap = HashMap::new(); // Ensure every node appears, even if it has no edges. for id in snarl.node_ids() { outgoing.entry(id).or_default(); indeg.entry(id).or_insert(0); } for (out_pin, in_pin) in snarl.wires() { let from = out_pin.node; let to = in_pin.node; if from == to { // A self-wire: treat as a cycle. continue; } if outgoing.entry(from).or_default().insert(to) { *indeg.entry(to).or_insert(0) += 1; } } (outgoing, indeg) } }`` > **Note:** egui-snarl’s `Snarl` exposes `node_ids()` to iterate node IDs. If your version does not, you can derive the ID list from `wires()` plus any bookkeeping you keep when inserting nodes; the adjacency-list code is otherwise unchanged. The `insert` check on the `HashSet` deduplicates parallel edges — multiple wires from the same source node into the same destination node count once for in-degree purposes, because one upstream node being ready unblocks the dependency. With the adjacency list in hand, Kahn’s algorithm is a handful of lines: `#![allow(unused)] fn main() { use std::collections::VecDeque; /// Returns a topological ordering of the snarl's nodes, or an error /// describing a cycle if one exists. fn topo_sort( snarl: &Snarl, ) -> Result, EvalError> { let (outgoing, mut indeg) = build_adjacency(snarl); let mut queue: VecDeque = indeg .iter() .filter(|(_, &d)| d == 0) .map(|(&n, _)| n) .collect(); let mut order = Vec::with_capacity(indeg.len()); while let Some(n) = queue.pop_front() { order.push(n); if let Some(succs) = outgoing.get(&n) { for &s in succs { let d = indeg.entry(s).or_insert(0); *d -= 1; if *d == 0 { queue.push_back(s); } } } } if order.len() == indeg.len() { Ok(order) } else { Err(EvalError::Cycle { involved: indeg.keys().copied().collect(), }) } } }` If `order.len()` is less than the number of nodes, the unscheduled nodes form one or more cycles, and we report a `Cycle` error. We return the involved node set so the UI can highlight them — more on that in [Chapter 15](https://danpokhrel.github.io/tutorial/print.html#chapter-15-live-execution--streaming) . Once we have an order, we walk it, gathering each node’s inputs from the _results_ of nodes already evaluated. The natural container is a map from `NodeId` to the node’s _primary output string_. Since all our pins carry strings, a node produces exactly one output value (or, for `OutputNode`, none). We use a `HashMap`: `#![allow(unused)] fn main() { use std::collections::HashMap; /// The result of evaluating a graph: one output string per node that /// produced one. Terminal nodes (OutputNode) record their received /// value under a well-known key instead. #[derive(Clone, Debug, Default)] pub struct EvalResults { pub values: HashMap, /// Logs accumulated during evaluation, for the console panel. pub logs: Vec, } }` > **Tip:** We keep `logs` on the results struct so the UI can display a trace without holding a separate side-channel. In [Chapter 15](https://danpokhrel.github.io/tutorial/print.html#chapter-15-live-execution--streaming) > we’ll stream these logs to a console panel as evaluation progresses. To evaluate a node we need the strings feeding each of its input pins. Each input pin has a `remotes: Vec` listing the output pins connected to it (see the API reference in [Chapter 8](https://danpokhrel.github.io/tutorial/print.html#chapter-8-introducing-egui-snarl) ). We resolve each remote to its source node’s already-computed output: ``#![allow(unused)] fn main() { use egui_snarl::{InPinId, NodeId, OutPinId, Snarl}; /// Resolve the inputs for `node`, one `String` per input pin, in order. /// Missing connections yield `None`; the caller decides how to handle that. fn gather_inputs( snarl: &Snarl, node: NodeId, results: &EvalResults, ) -> Vec> { let input_count = input_pin_count(&snarl[node]); let mut inputs = Vec::with_capacity(input_count); for i in 0..input_count { let pin = snarl.in_pin(InPinId { node, input: i }); // `pin.remotes` is the list of outputs wired into this input. let value = pin.remotes.first().and_then(|out: &OutPinId| { results.values.get(&out.node).cloned() }); inputs.push(value); } inputs } /// How many input pins a node has — must match the `SnarlViewer::inputs` /// impl from Chapter 13. fn input_pin_count(node: &AgentNode) -> usize { match node { AgentNode::ChatInput { .. } | AgentNode::StringNode { .. } => 0, AgentNode::PromptTemplate { .. } | AgentNode::ToolNode { .. } | AgentNode::MemoryNode { .. } => 1, AgentNode::LLMNode { .. } => 2, AgentNode::OutputNode => 1, } } }`` For multiple outputs wired into one input we take only the first — a simple policy. A stricter implementation could error or concatenate; we note this as a place to enforce stricter semantics later. With inputs gathered, evaluating a node is a `match` on the variant — exactly the exhaustive pattern matching the Rust Book champions in [Chapter 6](https://doc.rust-lang.org/stable/book/ch06-00-enums.html) . Each arm reads its inputs (substituting defaults when missing) and produces an output string. ``#![allow(unused)] fn main() { /// Evaluate a single node, given its resolved inputs. Returns its /// output string, or `None` for terminal nodes. fn eval_node( node: &AgentNode, inputs: Vec>, results: &mut EvalResults, ) -> Option { match node { AgentNode::StringNode { value } => Some(value.clone()), AgentNode::ChatInput { message } => Some(message.clone()), AgentNode::PromptTemplate { template } => { // Substitute {0}, {1}, ... with connected inputs in order. let mut out = template.clone(); for (i, inp) in inputs.iter().enumerate() { let val = inp.clone().unwrap_or_default(); out = out.replace(&format!("{{{i}}}"), &val); } results.logs.push(format!("PromptTemplate → {out}")); Some(out) } AgentNode::LLMNode { model, temperature, system_prompt } => { let system = inputs.get(0).cloned().flatten().unwrap_or_else(|| system_prompt.clone()); let user = inputs.get(1).cloned().flatten().unwrap_or_default(); let response = simulate_llm(model, *temperature, &system, &user); results.logs.push(format!("LLMNode {model} → {response}")); Some(response) } AgentNode::ToolNode { tool } => { let query = inputs.get(0).cloned().flatten().unwrap_or_default(); let result = simulate_tool(tool, &query); results.logs.push(format!("Tool {:?}({query}) → {result}", tool)); Some(result) } AgentNode::MemoryNode { history } => { // In a pure evaluator we can't mutate the node here, so we // append to a working copy carried by the caller instead. // For the tutorial, emit the existing context as the output. Some(history.join("\n")) } AgentNode::OutputNode => { // Terminal: record the value but produce no output pin. None } } } }`` For the tutorial we never call a real API — we simulate responses so the code runs anywhere. The simulation is intentionally trivial, which keeps the evaluator deterministic and easy to snapshot-test (see [Chapter 17](https://danpokhrel.github.io/tutorial/print.html#chapter-17-production-architecture) ). In [Chapter 18](https://danpokhrel.github.io/tutorial/print.html#chapter-18-conclusion--resources) we point at how to swap in real API calls. ``#![allow(unused)] fn main() { fn simulate_llm(model: &str, temperature: f32, system: &str, user: &str) -> String { // A deterministic, obviously-fake response. Real code would call // an API here; see Chapter 18 for pointers to ehttp/reqwest. let _ = (model, temperature, system); format!("[simulated {model}] You said: {user}") } fn simulate_tool(tool: &ToolKind, query: &str) -> String { match tool { ToolKind::WebSearch => format!("Top result for '{query}': https://example.com/results?q={query}"), ToolKind::Calculator => match query.trim().parse::() { Ok(n) => format!("{n}"), Err(_) => format!("Not a number: {query}"), }, ToolKind::Weather => format!("Weather for {query}: 21°C, sunny (simulated)"), } } /// Resolve an `LLMNode`'s two inputs from `results` and produce its full /// (simulated) response in one shot. This is the convenience wrapper the /// streaming UI in [Chapter 15](./ch15-live-execution.md) calls to get the /// complete string before revealing it token-by-token. fn simulate_llm_response( snarl: &Snarl, node_id: NodeId, results: &EvalResults, ) -> String { let AgentNode::LLMNode { model, temperature, system_prompt } = &snarl[node_id] else { return String::new(); }; let system = snarl .in_pin(InPinId { node: node_id, input: 0 }) .remotes .first() .and_then(|out| results.values.get(&out.node)) .cloned() .unwrap_or_else(|| system_prompt.clone()); let user = snarl .in_pin(InPinId { node: node_id, input: 1 }) .remotes .first() .and_then(|out| results.values.get(&out.node)) .cloned() .unwrap_or_default(); simulate_llm(model, *temperature, &system, &user) } }`` > **Note:** We deliberately mark `temperature` as used (via `_`) in the simulated LLM. In a real implementation, temperature would seed sampling randomness; for deterministic tests you’d want it fixed or stubbed. Keeping the simulation pure is what makes the evaluator unit-testable, which is the whole point of the architecture from [Chapter 5](https://danpokhrel.github.io/tutorial/print.html#chapter-5-structuring-your-application) > . `eval_node` above takes a _node_ and its already-gathered _inputs_ — that is the shape the `GraphEvaluator` loop uses internally. But the UI in [Chapter 15](https://danpokhrel.github.io/tutorial/print.html#chapter-15-live-execution--streaming) and the tests in [Chapter 17](https://danpokhrel.github.io/tutorial/print.html#chapter-17-production-architecture) want a simpler, single-call entry point: “given the snarl, a node id, and the results computed so far, what is this node’s output string?” We expose that as a thin public wrapper that gathers the inputs and delegates to `eval_node`: ``#![allow(unused)] fn main() { /// Evaluate one node given the snarl and the results computed so far. /// Returns the node's output string (empty for terminal `OutputNode`). /// This is the entry point the streaming UI and the unit tests call. pub fn evaluate_node( snarl: &Snarl, node_id: NodeId, results: &EvalResults, ) -> String { let node = &snarl[node_id]; if matches!(node, AgentNode::OutputNode) { // Terminal: its received value is already in `results`. return results.values.get(&node_id).cloned().unwrap_or_default(); } let inputs = gather_inputs(snarl, node_id, results); // `eval_node` borrows `results` mutably only to push log lines; for the // read-only wrapper we hand it a throwaway clone so the shared borrow // passed in by the caller stays intact. let mut tmp = EvalResults { values: results.values.clone(), logs: Vec::new() }; eval_node(node, inputs, &mut tmp).unwrap_or_default() } }`` > **Tip:** The wrapper clones `results.values` so it can hand `eval_node` a `&mut EvalResults` (which it uses only to append log lines) without mutating the caller’s snapshot. For the streaming UI this is exactly right: it reads a frozen snapshot per step. Let’s assemble these pieces behind a single `GraphEvaluator` type with an `evaluate()` method that takes `&Snarl` and returns results (or an error). It also carries an optional working `MemoryNode` history buffer so the `MemoryNode` can accumulate across evaluations in the same session: ``#![allow(unused)] fn main() { use egui_snarl::{NodeId, Snarl}; /// A pure-Rust evaluator for agent flow graphs. Holds no `egui` types. pub struct GraphEvaluator { /// Working memory for `MemoryNode`s, keyed by node id, so history /// persists across `evaluate` calls in the same session. memory: HashMap>, } impl GraphEvaluator { pub fn new() -> Self { Self { memory: HashMap::new() } } pub fn evaluate(&mut self, snarl: &Snarl) -> Result { let order = topo_sort(snarl)?; let mut results = EvalResults::default(); for id in order { let node = &snarl[id]; // Memory nodes need session-persistent history, so handle them // specially: append the incoming value to the working buffer. if let AgentNode::MemoryNode { history } = node { let buf = self.memory.entry(id).or_default(); // `history` (the node's serialized field) seeds the buffer // on first evaluation; afterwards the buffer is authoritative. if buf.is_empty() { buf.extend(history.iter().cloned()); } } let inputs = gather_inputs(snarl, id, &results); let output = if let AgentNode::MemoryNode { .. } = node { let buf = self.memory.entry(id).or_default(); if let Some(v) = inputs.into_iter().next().and_then(|o| o) { buf.push(v); } Some(buf.join("\n")) } else if let AgentNode::OutputNode = node { let final_value = inputs.into_iter().next().and_then(|o| o) .unwrap_or_else(|| "".into()); results.logs.push(format!("Output: {final_value}")); results.values.insert(id, final_value.clone()); None } else { eval_node(node, inputs, &mut results) }; if let Some(out) = output { results.values.insert(id, out); } } Ok(results) } } }`` The `OutputNode` is special-cased so its received value lands in `results.values` under its own `NodeId` — that’s how the UI can later show “final answer” without a separate field. We define a small error enum, following the recoverable-errors guidance from Rust Book [Chapter 9](https://doc.rust-lang.org/stable/book/ch09-00-error-handling.html) . We’ll expand this enum in [Chapter 17](https://danpokhrel.github.io/tutorial/print.html#chapter-17-production-architecture) when we add production error types: ``#![allow(unused)] fn main() { use std::fmt; #[derive(Clone, Debug)] pub enum EvalError { /// The graph contains a cycle; `involved` lists the suspect nodes. Cycle { involved: Vec }, /// A node was missing a required input. MissingInput { node: NodeId, input: usize }, /// A pin's value had an unexpected type (reserved for later typed pins). TypeMismatch { node: NodeId, pin: usize, expected: &'static str }, } impl fmt::Display for EvalError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { EvalError::Cycle { involved } => write!( f, "graph has a cycle involving {} node(s)", involved.len() ), EvalError::MissingInput { node, input } => write!( f, "node {:?} is missing input pin {}", node, input ), EvalError::TypeMismatch { node, pin, expected } => write!( f, "node {:?} pin {} expected {}", node, pin, expected ), } } } impl std::error::Error for EvalError {} }`` The two policies worth stating explicitly: * **Missing inputs** use sensible defaults rather than aborting: a `PromptTemplate` with an unconnected `{0}` substitutes the empty string; an `LLMNode` with no system prompt falls back to its configured `system_prompt` field. This keeps the graph runnable while the user is mid-edit, which is much friendlier than refusing to run. We still _log_ the missing input so it shows in the console. * **Cycles** are fatal — there’s no useful default. We return `Cycle` and let the UI surface it. Evaluation is pure, so triggering it from the UI is a one-liner — we collect a “run” intent from a button in `ui()` and act on it in `logic()` (or immediately, since it’s fast), storing results in app state: `#![allow(unused)] fn main() { use eframe::egui; pub struct App { pub snarl: egui_snarl::Snarl, pub evaluator: GraphEvaluator, pub results: Option>, /// Set true by the Run button; drained in logic(). pub run_requested: bool, } impl eframe::App for App { fn logic(&mut self, _ctx: &egui::Context, _frame: &mut eframe::Frame) { if self.run_requested { self.run_requested = false; self.results = Some(self.evaluator.evaluate(&self.snarl)); } } fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { ui.horizontal(|ui| { if ui.button("▶ Run Flow").clicked() { self.run_requested = true; } }); // ...show the graph and any results... } } }` > **Tip:** Because evaluation is fast for a tutorial-sized graph, doing it synchronously in `logic()` is fine. For graphs that talk to real APIs, you’d move evaluation off the UI thread — that’s the whole subject of [Chapter 15](https://danpokhrel.github.io/tutorial/print.html#chapter-15-live-execution--streaming) > . Once `self.results` holds an `Ok(EvalResults)`, we can show computed values back in the node bodies, mirroring how the bundled egui-snarl demo displays computed output. We extend the viewer’s `show_output` to peek at the results and render the value: `#![allow(unused)] fn main() { use std::collections::HashMap; use egui_snarl::{ui::{PinInfo, SnarlPin, SnarlViewer}, NodeId, OutPin, Snarl}; pub struct AgentViewer { pub pending_edits: HashMap, /// Snapshot of the latest evaluation results, for inline display. pub results: Option, } impl SnarlViewer for AgentViewer { fn show_output( &mut self, pin: &OutPin, ui: &mut egui::Ui, _snarl: &mut Snarl, ) -> impl SnarlPin + 'static { let id = pin.id.node; // ...existing configuration UI elided (see Chapter 13)... // If we have a computed value for this node, show it. if let Some(results) = &self.results { if let Some(val) = results.values.get(&id) { ui.label( egui::RichText::new(val) .color(egui::Color32::from_rgb(180, 220, 180)) .small(), ); } } PinInfo::circle().with_fill(egui::Color32::from_rgb(80, 200, 120)) } } }` And the host copies the results snapshot onto the viewer each frame before showing the snarl: `#![allow(unused)] fn main() { impl App { fn show_graph(&mut self, ui: &mut egui::Ui) { self.viewer.results = self.results.as_ref().ok().cloned(); egui_snarl::ui::SnarlWidget::new() .id_salt(egui::Id::new("agent_graph")) .show(&mut self.snarl, &mut self.viewer, ui); // apply pending edits, as in Chapter 13 let pending = std::mem::take(&mut self.viewer.pending_edits); for (id, node) in pending { self.snarl[id] = node; } } } }` The console logs from `EvalResults::logs` can be rendered in a `Panel::bottom`: `#![allow(unused)] fn main() { egui::Panel::bottom("console").show(ui, |ui| { ui.heading("Evaluation log"); egui::ScrollArea::vertical().show(ui, |ui| { if let Some(Ok(results)) = &self.results { for line in &results.logs { ui.label(line); } } }); }); }` It is worth re-stating why the evaluator module imports nothing from `egui`. Because it depends only on `egui_snarl::{Snarl, NodeId, ...}` (which themselves are plain data types) and `AgentNode` (a pure-data enum), the entire evaluator can be compiled and tested without a window. A headless CI job can construct a `Snarl` in code, run `evaluate`, and assert on the resulting strings — exactly the testability we argued for in [Chapter 5](https://danpokhrel.github.io/tutorial/print.html#chapter-5-structuring-your-application) and exercised in [Chapter 11](https://danpokhrel.github.io/tutorial/print.html#chapter-11-interactions--ux) . We’ll write those exact tests in [Chapter 17](https://danpokhrel.github.io/tutorial/print.html#chapter-17-production-architecture) . > **Module organization.** The functions in this chapter — `topo_sort`, `build_adjacency`, `gather_inputs`, `eval_node` (internal), `evaluate_node` (public wrapper), `simulate_llm`, `simulate_tool`, `simulate_llm_response`, `GraphEvaluator`, `EvalResults`, `EvalError` — should all live in a single `src/eval.rs` file, declared from `main.rs` with `mod eval;` (following the structure from [Chapter 2](https://danpokhrel.github.io/tutorial/print.html#chapter-2-project-setup--structure) > and [Chapter 5](https://danpokhrel.github.io/tutorial/print.html#chapter-5-structuring-your-application) > ). [Chapter 15](https://danpokhrel.github.io/tutorial/print.html#chapter-15-live-execution--streaming) > and [Chapter 17](https://danpokhrel.github.io/tutorial/print.html#chapter-17-production-architecture) > call `crate::eval::topo_sort`, `crate::eval::evaluate_node`, and `crate::eval::simulate_llm_response`, so keep those exact public names. The key point: all evaluation logic lives in one module with no `egui` dependency, keeping it testable on a headless CI machine. > **Re-evaluation and `MemoryNode`.** The `GraphEvaluator` carries a `memory: HashMap>` working buffer that persists across `evaluate()` calls. On the first evaluation, the buffer is seeded from the node’s serialized `history` field. On subsequent evaluations, the buffer is authoritative — if the user edits the `history` field in the UI between evaluations, the working buffer will not pick up the change. To keep them in sync, either (a) clear the evaluator’s `memory` buffer whenever the user edits a `MemoryNode` (detect the edit in `pending_edits`), or (b) re-seed the buffer from the node field at the start of each `evaluate()` call. Option (b) is simpler but loses accumulated context across re-evaluations; option (a) is more correct but requires tracking which nodes were edited. * * * We can now evaluate a static graph in one shot, see results on the pins, and read a trace in the console. But the evaluation is instantaneous — the user clicks _Run_ and everything is done before the next frame. That’s correct, but it doesn’t _feel_ like an agent working. In [Chapter 15](https://danpokhrel.github.io/tutorial/print.html#chapter-15-live-execution--streaming) we’ll turn evaluation into a live, step-by-step process: nodes light up as they run, LLM output streams token-by-token, and a progress bar tracks the walk through the graph. In [Chapter 14](https://danpokhrel.github.io/tutorial/print.html#chapter-14-graph-evaluation) we built a graph evaluation engine that computes the output of every node in topological order. But it ran all at once — the user clicks “Evaluate” and gets the final result. Real agentic AI flow builders like Langflow and Flowise make execution feel _alive_: you watch each node light up in turn, you see LLM output stream token by token, and you can stop mid-run. In this chapter we bring that real-time experience to our flow editor. > **Scope note: not in the reference implementation.** Live streaming is Part 4 territory and is not present in the impl (which has no evaluator to stream). The `request_repaint`\-driven stepping pattern, however, is the real, generally-useful egui technique it claims to be. The key insight is that **egui’s repaint model is on-demand**. When the window is idle, no CPU is wasted. When we want to show progress, we need to drive repaints ourselves with `ctx.request_repaint()`. This gives us a natural mechanism for stepping through evaluation: 1. The user clicks “Run Flow”. 2. We enter a `Running` state and evaluate **one node** per `logic()` call. 3. After each node, we call `ctx.request_repaint()` so the UI updates to show progress. 4. When all nodes are done, we transition to `Done`. This keeps `ui()` cheap — it only reads the current evaluation state and renders it. The actual work happens in `logic()`, which is exactly the separation the 0.34 `logic`/`ui` split was designed for. > **Note:** `logic()` runs once before each `ui()` call, and additionally when the UI is hidden but a repaint was requested. This means our stepping loop continues even if the user minimizes the window — a nice property for long-running flows. Let’s define an enum to track where we are in the evaluation lifecycle. Rust enums (covered in [Rust Book Chapter 6](https://doc.rust-lang.org/stable/book/ch06-00-enums.html) ) are perfect for modeling a finite state machine: ``#![allow(unused)] fn main() { use egui_snarl::NodeId; /// Tracks the state of a live graph evaluation. pub enum EvalState { /// No evaluation is running. Idle, /// Evaluation is in progress. `remaining` is the list of nodes /// still to evaluate, in topological order. `step` is how many /// we have completed so far (for the progress bar). Running { order: Vec, remaining: Vec, step: usize, results: std::collections::HashMap, }, /// Evaluation completed successfully. Done { results: std::collections::HashMap, }, /// Evaluation failed (e.g., a cycle was detected). Error(String), } }`` Each variant carries the data it needs. `Running` holds the topological order, the remaining nodes, the current step count, and a map of evaluated results. This is the same collect-then-mutate philosophy from [Chapter 5](https://danpokhrel.github.io/tutorial/print.html#chapter-5-structuring-your-application) — the state is owned by `App`, mutated in `logic()`, and read in `ui()`. When the user clicks “Run Flow”, we compute the topological order and enter the `Running` state: `#![allow(unused)] fn main() { use crate::eval::topo_sort; impl MyApp { fn start_eval(&mut self) { match topo_sort(&self.snarl) { Ok(order) => { self.eval = EvalState::Running { order: order.clone(), remaining: order, step: 0, results: Default::default(), }; } Err(e) => { self.eval = EvalState::Error(format!("Cannot evaluate: {e}")); } } } } }` If the graph has a cycle, `topo_sort` returns an error and we display it immediately. This follows [Rust Book Chapter 9](https://doc.rust-lang.org/stable/book/ch09-02-recoverable-errors-with-result.html) — recoverable errors are returned as `Result`, not panics. The heart of the streaming evaluation lives in `logic()`. Each call evaluates **one** node from the `remaining` list, stores its result, and requests a repaint: `#![allow(unused)] fn main() { impl eframe::App for MyApp { fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { // Take ownership of the current eval state so we can replace it. let eval = std::mem::replace(&mut self.eval, EvalState::Idle); match eval { EvalState::Running { mut order, mut remaining, mut step, mut results, } => { if let Some(&node_id) = remaining.first() { // Evaluate this one node. let result = crate::eval::evaluate_node( &self.snarl, node_id, &results, ); results.insert(node_id, result); remaining.remove(0); step += 1; // Update the log panel. self.log_lines.push(format!( "[{}/{}] Evaluated node {:?}", step, order.len(), node_id )); if remaining.is_empty() { // All done! self.eval = EvalState::Done { results }; } else { // Keep going — request a repaint to continue. self.eval = EvalState::Running { order, remaining, step, results, }; ctx.request_repaint(); } } } // Idle, Done, or Error — nothing to do in logic(). other => self.eval = other, } } fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { // ... render panels, graph, and console ... } } }` The critical line is `ctx.request_repaint()`. Without it, egui would not call `logic()` again — the evaluation would freeze after the first node. By requesting a repaint, we tell egui “something changed, run another frame,” which triggers another `logic()` call, which evaluates the next node, and so on until `remaining` is empty. > **Warning:** Never do heavy computation in `ui()`. It can be called many times per second and will make the app feel sluggish. All evaluation work belongs in `logic()` (or a background thread). See [Chapter 5](https://danpokhrel.github.io/tutorial/print.html#chapter-5-structuring-your-application) > for the full discussion. > > **What counts as “heavy”?** As a rule of thumb: anything that takes more than a few milliseconds per call. Reading from a `HashMap`, formatting a string, or building a handful of widgets is fine. Parsing JSON, sorting large vectors, or calling network APIs is not. Our graph evaluator (stepping one node per `logic()` call) is deliberately lightweight — each step is a single `match` on a node variant plus a string operation. If your evaluator does real I/O (LLM API calls), move it to a background thread as shown later in this chapter. In the UI, we read the evaluation state to show a progress bar and highlight the currently-running node: `#![allow(unused)] fn main() { fn render_status_bar(ui: &mut egui::Ui, eval: &EvalState) { match eval { EvalState::Idle => { ui.label("Idle — click Run to evaluate the flow."); } EvalState::Running { order, step, .. } => { let progress = *step as f32 / order.len().max(1) as f32; ui.add(egui::ProgressBar::new(progress) .desired_width(200.0) .text(format!("Evaluating {}/{}", step, order.len()))); } EvalState::Done { .. } => { ui.colored_label(egui::Color32::from_green(0x3f), "✓ Evaluation complete."); } EvalState::Error(msg) => { ui.colored_label(egui::Color32::from_red(0xb0), format!("Error: {msg}")); } } } }` We can also highlight the currently-evaluating node in the graph by checking whether a node ID matches the first element of `remaining`: `#![allow(unused)] fn main() { // In the SnarlViewer's header_frame override: fn header_frame( &mut self, frame: egui::Frame, node: NodeId, _inputs: &[InPin], _outputs: &[OutPin], _snarl: &Snarl, ) -> egui::Frame { // If this is the currently-evaluating node, highlight it. if let EvalState::Running { remaining, .. } = &self.eval_state { if remaining.first() == Some(&&node) { return frame .fill(egui::Color32::from_rgb(60, 80, 120)) .stroke(egui::Stroke::new(2.0, egui::Color32::from_rgb(100, 150, 255))); } } frame } }` This gives the user visual feedback — the currently-evaluating node lights up with a blue border, then returns to normal as the evaluation moves on. We need UI controls to start and cancel evaluation. Let’s add them to a toolbar: `#![allow(unused)] fn main() { fn render_toolbar(ui: &mut egui::Ui, app: &mut MyApp) { ui.horizontal(|ui| { let is_running = matches!(app.eval, EvalState::Running { .. }); ui.add_enabled_ui(!is_running, |ui| { if ui.button("▶ Run Flow").clicked() { app.start_eval(); } }); ui.add_enabled_ui(is_running, |ui| { if ui.button("⏹ Stop").clicked() { app.eval = EvalState::Idle; app.log_lines.push("Evaluation stopped by user.".into()); } }); if ui.button("Clear Results").clicked() { app.eval = EvalState::Idle; app.log_lines.clear(); } }); } }` The `add_enabled_ui` method (from [Chapter 4](https://danpokhrel.github.io/tutorial/print.html#chapter-4-layout-widgets--state) ) disables the Run button while a run is in progress and disables Stop when idle. This prevents the user from starting two evaluations simultaneously. Real flow builders show a log of what happened during execution. Let’s add a bottom panel that displays the log lines: `#![allow(unused)] fn main() { // In the App::ui method, add the bottom panel BEFORE the central panel: egui::Panel::bottom("console") .default_height(160.0) .resizable(true) .show(ui, |ui| { ui.heading("Execution Log"); ui.separator(); egui::ScrollArea::vertical().show(ui, |ui| { for line in &app.log_lines { ui.label(line); } }); }); }` Remember from [Chapter 4](https://danpokhrel.github.io/tutorial/print.html#chapter-4-layout-widgets--state) : panels must be added in the right order. `Panel::bottom` should come before `CentralPanel` so the central panel shrinks to fill the remaining space. For LLM nodes, real agentic AI tools stream the response token by token. We can simulate this by splitting the LLM’s output into chunks and revealing them progressively across multiple `logic()` calls: `#![allow(unused)] fn main() { EvalState::Running { mut order, mut remaining, mut step, mut results, } => { if let Some(&node_id) = remaining.first() { // Get the node data. let node = &self.snarl[node_id]; // For LLM nodes, simulate streaming: if we already have a partial // result, append the next "token"; otherwise start generating. let current = results.get(&node_id).cloned().unwrap_or_default(); if let AgentNode::LLMNode { .. } = node { let full_response = crate::eval::simulate_llm_response(&self.snarl, node_id, &results); let tokens: Vec<&str> = full_response.split_whitespace().collect(); let current_tokens = current.split_whitespace().count(); if current_tokens < tokens.len() { // Reveal one more token. let next = tokens[..current_tokens + 1].join(" "); results.insert(node_id, next); // Don't advance — come back next frame for the next token. // Request repaint to continue streaming. self.eval = EvalState::Running { order, remaining, step, results }; ctx.request_repaint(); return; } // All tokens revealed — move to the next node. } // For non-LLM nodes, evaluate immediately. let result = crate::eval::evaluate_node(&self.snarl, node_id, &results); results.insert(node_id, result); remaining.remove(0); step += 1; // ... update log, check if done ... } } }` This pattern — keeping a node in `remaining` until its streaming is complete, then removing it — gives us realistic-looking output without any real API calls. In a production app, you would replace the simulated streaming with a real HTTP request to an LLM API using `ehttp` or `reqwest`, as discussed in [Chapter 18](https://danpokhrel.github.io/tutorial/print.html#chapter-18-conclusion--resources) . For genuinely long-running work (real API calls, heavy computation), you should move evaluation to a background thread. The pattern from [Chapter 5](https://danpokhrel.github.io/tutorial/print.html#chapter-5-structuring-your-application) applies: `#![allow(unused)] fn main() { use std::sync::{Arc, Mutex}; use std::sync::mpsc::{channel, Receiver}; struct MyApp { // Results arrive from the background thread through this channel. eval_rx: Option>, // Shared state for the background thread to read graph data. graph_snapshot: Arc>>, } impl MyApp { fn start_background_eval(&mut self, ctx: &egui::Context) { let (tx, rx) = channel(); self.eval_rx = Some(rx); // Snapshot the graph for the background thread (Snarl is not Send // if it contains non-Send data, so we extract what we need). let snapshot = Arc::new(Mutex::new( self.snarl.nodes_ids_data().collect::>() )); self.graph_snapshot = snapshot.clone(); let ctx = ctx.clone(); // Context is Arc-backed, cheaply clonable. std::thread::spawn(move || { let graph = snapshot.lock().unwrap(); let order = crate::eval::topo_sort_from_snapshot(&graph).unwrap(); drop(graph); // Release the lock before evaluating. let mut results = std::collections::HashMap::new(); for &node_id in &order { let result = crate::eval::evaluate_snapshot(&snapshot, node_id, &results); results.insert(node_id, result.clone()); // Send the result to the UI thread. let _ = tx.send((node_id, result)); // Wake the UI to show the new result. ctx.request_repaint(); } }); } } }` Then in `logic()`, we drain the channel: `#![allow(unused)] fn main() { fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { if let Some(rx) = &self.eval_rx { while let Ok((node_id, result)) = rx.try_recv() { self.eval_results.insert(node_id, result); self.log_lines.push(format!("Evaluated node {node_id:?}")); } } } }` The `while let Ok(...)` loop drains all available results each frame. If the channel is empty, it returns immediately — no blocking. The background thread calls `ctx.request_repaint()` each time it sends a result, so the UI wakes up to process it. > **Tip:** On web (WASM), `std::thread` is not available. Use `wasm_bindgen_futures::spawn_local` instead, and `ctx.request_repaint()` when the async task completes. The channel-based pattern works the same — just swap the thread for a future. See [Chapter 17](https://danpokhrel.github.io/tutorial/print.html#chapter-17-production-architecture) > for the full web setup. This is the most important rule of streaming egui apps: **`ui()` must be fast**. It can be called many times per second, and if it blocks, the entire app freezes. All the patterns in this chapter follow this principle: * Evaluation happens in `logic()` (one step per call) or a background thread. * `ui()` only _reads_ the current state and renders it. * The `request_repaint()` calls ensure `logic()` keeps being called until the evaluation is done. * The console panel uses a `ScrollArea` so it doesn’t re-lay-out all log lines if there are thousands (though for truly massive logs, consider a virtualized list from [Chapter 4](https://danpokhrel.github.io/tutorial/print.html#chapter-4-layout-widgets--state) ). > **Tip:** For the streaming-LLM-tokens loop, `ctx.request_repaint_after(Duration::from_millis(50))` is more appropriate than `ctx.request_repaint()`. The small delay makes each token visible to the user rather than rendering them all in a rapid burst of frames. The `request_repaint_after` method schedules a single repaint after the given duration — perfect for pacing visible output. Here’s the complete `App::ui` method with all the panels wired up: `#![allow(unused)] fn main() { fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { // 1. Top panel: toolbar with Run/Stop/Clear buttons. egui::Panel::top("toolbar").show(ui, |ui| { crate::ui::render_toolbar(ui, self); }); // 2. Bottom panel: execution log (resizable). egui::Panel::bottom("console") .default_height(140.0) .resizable(true) .show(ui, |ui| { ui.heading("Execution Log"); ui.separator(); egui::ScrollArea::vertical().show(ui, |ui| { for line in &self.log_lines { ui.label(line); } }); }); // 3. Left panel: status and results. egui::Panel::left("status") .default_width(240.0) .resizable(true) .show(ui, |ui| { crate::ui::render_status_bar(ui, &self.eval); ui.separator(); ui.heading("Results"); egui::ScrollArea::vertical().show(ui, |ui| { let results = match &self.eval { EvalState::Done { results } | EvalState::Running { results, .. } => results, _ => &self.eval_results, }; for (&id, value) in results { ui.label(format!("{id:?}: {value}")); } }); }); // 4. Central panel: the node graph. egui::CentralPanel::default().show(ui, |ui| { SnarlWidget::new() .id_salt(egui::Id::new("flow-snarl")) .style(self.snarl_style) .show(&mut self.snarl, &mut self.viewer, ui); }); } }` The panel ordering follows the rules from [Chapter 4](https://danpokhrel.github.io/tutorial/print.html#chapter-4-layout-widgets--state) : top and bottom panels first, then side panel, then central panel last so it fills the remaining space. * * * We now have a live, streaming evaluation engine that shows progress in real time. The final piece of production functionality is persistence — saving and loading the graph so the user’s work survives across sessions. In [Chapter 16](https://danpokhrel.github.io/tutorial/print.html#chapter-16-state-persistence) we’ll cover eframe’s built-in persistence, manual save/load with serde, and native file dialogs. In [Chapter 15](https://danpokhrel.github.io/tutorial/print.html#chapter-15-live-execution--streaming) we built a live, streaming evaluation engine. Now we need the other half of a production editor: the ability to save the user’s work and restore it later. In this chapter we’ll cover two layers of persistence — eframe’s built-in app-state persistence and our own manual save/load to JSON files with native file dialogs. > **Scope note: not in the reference implementation.** The impl enables eframe’s `persistence` feature (so egui’s own app/UI state is auto-saved) but ships no manual JSON save/load, no `ProjectFile`, and no `rfd` dependency — that is all Part 4 forward-looking material. (`rfd`, `serde_json`, and `chrono` are flagged in-text as dependencies to add when you reach this chapter; they are not in the impl’s `Cargo.toml`.) Our flow editor has two distinct kinds of state that need persisting: 1. **Graph data** — the nodes, connections, and configuration that define the user’s flow. This is the _valuable_ state — it represents the user’s work and must survive across sessions. 2. **App/UI state** — window position, panel sizes, scroll positions, egui’s `Memory`. This is _convenience_ state — it’s nice to restore but not critical. eframe handles (2) automatically when the `persistence` feature is enabled. We handle (1) ourselves with serde and file dialogs. eframe’s persistence is opt-in via the `persistence` feature (enabled by default in our `Cargo.toml` from [Chapter 2](https://danpokhrel.github.io/tutorial/print.html#chapter-2-project-setup--structure) ). When enabled, eframe periodically calls `App::save()` and restores state in `App::new()`: `#![allow(unused)] fn main() { use eframe; #[derive(serde::Serialize, serde::Deserialize)] struct MyApp { snarl: egui_snarl::Snarl, snarl_style: egui_snarl::SnarlStyle, log_lines: Vec, // Note: EvalState is not serialized — it's ephemeral. } impl MyApp { fn new(cc: &eframe::CreationContext<'_>) -> Self { // Restore from storage if available. if let Some(storage = cc.storage { return eframe::get_value(storage, eframe::APP_KEY) .unwrap_or_default(); } Self::default() } } impl eframe::App for MyApp { fn save(&mut self, storage: &mut dyn eframe::Storage) { // Serialize the entire App struct to eframe's RON-based storage. eframe::set_value(storage, eframe::APP_KEY, self); } } }` eframe’s `Storage` is a string-to-string map serialized as RON (Rust Object Notation). On native, it’s stored as a file under `eframe::storage_dir()` (the OS data directory, keyed by the app id passed to `run_native`). On web, it’s stored in the browser’s Local Storage. > **Note:** `eframe::APP_KEY` is the standard key for app-level state. You can also use custom keys for separate pieces of state (e.g., `"graph"` and `"settings"`). The `get_value` and `set_value` helpers are serde convenience wrappers around `Storage::get_string` / `Storage::set_string`. The `save` method is called on shutdown and periodically (every `auto_save_interval()`, which defaults to 30 seconds). You can override `auto_save_interval` to change the frequency: `#![allow(unused)] fn main() { fn auto_save_interval(&self) -> std::time::Duration { std::time::Duration::from_secs(60) // save every minute } }` > **Warning:** For `App::save` to work, your `App` struct (and everything inside it) must derive `serde::Serialize` and `serde::Deserialize`. The `Snarl` type derives these when the `serde` feature is enabled on `egui-snarl` and `T: Serialize + Deserialize`. This is why our `Cargo.toml` from [Chapter 2](https://danpokhrel.github.io/tutorial/print.html#chapter-2-project-setup--structure) > enables the `serde` feature on `egui-snarl`. > **Note:** `serde_json` is not in our `Cargo.toml` from [Chapter 2](https://danpokhrel.github.io/tutorial/print.html#chapter-2-project-setup--structure) > . Add `serde_json = "1"` to your `[dependencies]` before using the save/load code in this chapter. eframe’s built-in persistence is great for auto-save, but users also want explicit “Save As” and “Open” file operations. For this, we serialize the `Snarl` to a JSON file using [`serde_json`](https://crates.io/crates/serde_json) : `#![allow(unused)] fn main() { use serde::{Serialize, Deserialize}; /// A project file containing the graph and metadata. #[derive(Serialize, Deserialize)] pub struct ProjectFile { /// The serialized node graph (nodes, positions, wires). pub snarl: egui_snarl::Snarl, /// The version of this file format. pub version: String, /// Optional metadata. pub created: String, } impl MyApp { /// Save the graph to a JSON file. pub fn save_graph(&self, path: &std::path::Path) -> Result<(), String> { let project = ProjectFile { snarl: self.snarl.clone(), version: env!("CARGO_PKG_VERSION").to_string(), created: chrono::Local::now().to_rfc3339(), }; let json = serde_json::to_string_pretty(&project) .map_err(|e| format!("Serialization error: {e}"))?; std::fs::write(path, json) .map_err(|e| format!("File write error: {e}"))?; Ok(()) } /// Load a graph from a JSON file. pub fn load_graph(&mut self, path: &std::path::Path) -> Result<(), String> { let json = std::fs::read_to_string(path) .map_err(|e| format!("File read error: {e}"))?; let project: ProjectFile = serde_json::from_str(&json) .map_err(|e| format!("Deserialization error: {e}"))?; self.snarl = project.snarl; self.eval = EvalState::Idle; self.log_lines.clear(); self.log_lines.push(format!("Loaded graph from {}", path.display())); Ok(()) } } }` > **Note:** `chrono` is not in our `Cargo.toml` from [Chapter 2](https://danpokhrel.github.io/tutorial/print.html#chapter-2-project-setup--structure) > . Add `chrono = "0.4"` to your `[dependencies]` before using it. Alternatively, if you do not need human-readable timestamps, use `std::time::SystemTime::now().duration_since(SystemTime::UNIX_EPOCH)` to get a Unix epoch duration that requires no extra dependency. We use `Result<(), String>` for human-readable errors and `map_err` to convert `io::Error` and `serde_json::Error` into our string error type. This follows [Rust Book Chapter 9.2](https://doc.rust-lang.org/stable/book/ch09-02-recoverable-errors-with-result.html) — recoverable errors returned as `Result`, not panics. The `ProjectFile` struct wraps the `Snarl` with version metadata. This is important for forward compatibility: if you change the node format in a future version, you can check the `version` field and migrate old files. The [`rfd`](https://crates.io/crates/rfd) (Rusty File Dialog) crate provides native OS file dialogs on desktop and async fallbacks on web: `#![allow(unused)] fn main() { use rfd::FileDialog; impl MyApp { /// Show a save dialog and save the graph. pub fn save_graph_dialog(&mut self) { if let Some(path) = FileDialog::new() .add_filter("JSON Flow", &["json"]) .set_file_name("my-flow.json") .save_file() { if let Err(e) = self.save_graph(&path) { self.log_lines.push(format!("Save error: {e}")); } else { self.log_lines.push(format!("Saved to {}", path.display())); } } } /// Show an open dialog and load a graph. pub fn open_graph_dialog(&mut self) { if let Some(path) = FileDialog::new() .add_filter("JSON Flow", &["json"]) .pick_file() { if let Err(e) = self.load_graph(&path) { self.log_lines.push(format!("Load error: {e}")); } } } } }` On native, `rfd::FileDialog::save_file()` and `pick_file()` are blocking calls that show the OS dialog. On web, `rfd` has async variants (`save_file_async()`, `pick_file_async()`) that use `wasm-bindgen-futures` under the hood. > **Tip:** For an in-window, egui-styled file browser (no OS dialog), the [`egui_file`](https://crates.io/crates/egui_file) > crate is an alternative. It renders a file picker as an egui window, which can be more consistent with your app’s look and feel. File dialogs can be async (especially on web), and they shouldn’t block `ui()`. The idiomatic pattern is to use a “pending action” flag — store the action on `App` state, and execute it when appropriate: `#![allow(unused)] fn main() { /// Actions that need to be processed (file operations, etc.) pub enum PendingAction { SaveGraph(std::path::PathBuf), LoadGraph(std::path::PathBuf), NewGraph, SaveGraphAs, // trigger the file dialog LoadGraphFrom, // trigger the file dialog } }` Wire the menu items (from [Chapter 7](https://danpokhrel.github.io/tutorial/print.html#chapter-7-input-menus--dialogs) ) to set pending actions: `#![allow(unused)] fn main() { fn render_menu_bar(ui: &mut egui::Ui, app: &mut MyApp) { egui::MenuBar::new().ui(ui, |ui| { ui.menu_button("File", |ui| { if ui.button("New").clicked() { app.pending = Some(PendingAction::NewGraph); ui.close(); } if ui.button("Open...").clicked() { app.pending = Some(PendingAction::LoadGraphFrom); ui.close(); } ui.separator(); if ui.button("Save").clicked() { // If we have a current file, save to it; otherwise trigger dialog. if let Some(path) = &app.current_file { app.pending = Some(PendingAction::SaveGraph(path.clone())); } else { app.pending = Some(PendingAction::SaveGraphAs); } ui.close(); } if ui.button("Save As...").clicked() { app.pending = Some(PendingAction::SaveGraphAs); ui.close(); } }); }); } }` Then process pending actions in `logic()` (so `ui()` stays cheap): `#![allow(unused)] fn main() { fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { match self.pending.take() { Some(PendingAction::NewGraph) => { self.snarl = Snarl::new(); self.current_file = None; self.eval = EvalState::Idle; self.log_lines.clear(); } Some(PendingAction::SaveGraph(path)) => { if let Err(e) = self.save_graph(&path) { self.log_lines.push(format!("Save error: {e}")); } } Some(PendingAction::LoadGraph(path)) => { if let Err(e) = self.load_graph(&path) { self.log_lines.push(format!("Load error: {e}")); } } Some(PendingAction::SaveGraphAs) => { if let Some(path) = rfd::FileDialog::new() .add_filter("JSON Flow", &["json"]) .set_file_name("my-flow.json") .save_file() { self.current_file = Some(path.clone()); self.pending = Some(PendingAction::SaveGraph(path)); ctx.request_repaint(); } } Some(PendingAction::LoadGraphFrom) => { if let Some(path) = rfd::FileDialog::new() .add_filter("JSON Flow", &["json"]) .pick_file() { self.current_file = Some(path.clone()); self.pending = Some(PendingAction::LoadGraph(path)); ctx.request_repaint(); } } None => {} } } }` This pattern — set a flag in `ui()`, process it in `logic()` — is the same deferred-execution approach used in the reference tutorial’s context-menu node creation. It keeps the rendering path clean and lets you centralize mutation logic. eframe’s `auto_save_interval` handles periodic saves to the built-in `Storage`. But you may also want auto-save to the user’s last-saved file. A simple approach: track the last modification time and save if it’s been more than N seconds since the last save: `#![allow(unused)] fn main() { struct MyApp { last_modified: Option, last_autosave: Option, } impl eframe::App for MyApp { fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { // If the graph was modified and 30s have passed since last autosave... if let (Some(modified), Some(last_save)) = (self.last_modified, self.last_autosave) { if modified > last_save && modified.elapsed().as_secs() > 30 { if let Some(path) = &self.current_file { let _ = self.save_graph(path); self.last_autosave = Some(std::time::Instant::now()); self.log_lines.push("(auto-saved)".into()); } } } // Request a periodic repaint to check auto-save timers. ctx.request_repaint_after(std::time::Duration::from_secs(5)); } } }` `ctx.request_repaint_after(duration)` schedules a repaint after the given duration, which triggers `logic()` — perfect for periodic tasks like auto-save. A production editor should support Ctrl+S for save and Ctrl+O for open. We use the `KeyboardShortcut` API from [Chapter 7](https://danpokhrel.github.io/tutorial/print.html#chapter-7-input-menus--dialogs) : `#![allow(unused)] fn main() { fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { let save_shortcut = egui::KeyboardShortcut::new(egui::Modifiers::CTRL, egui::Key::S); let open_shortcut = egui::KeyboardShortcut::new(egui::Modifiers::CTRL, egui::Key::O); ctx.input_mut(|i| { if i.consume_shortcut(&save_shortcut) { if let Some(path) = &self.current_file { self.pending = Some(PendingAction::SaveGraph(path.clone())); } else { self.pending = Some(PendingAction::SaveGraphAs); } } if i.consume_shortcut(&open_shortcut) { self.pending = Some(PendingAction::LoadGraphFrom); } }); // ... process pending actions ... } }` `consume_shortcut` (rather than `key_pressed`) prevents the same shortcut from firing twice when multiple widgets check it. See [Chapter 7](https://danpokhrel.github.io/tutorial/print.html#chapter-7-input-menus--dialogs) for the full discussion. It’s important to be deliberate about what you serialize. The `Snarl` (with the `serde` feature) serializes: * The node data (your `AgentNode` enum, which derives `Serialize`/`Deserialize`) * Node positions (stored in the `Snarl`) * Wires/connections (stored in the `Snarl`) It does **not** serialize: * `EvalState` — ephemeral, resets each session * `log_lines` — ephemeral * `SnarlStyle` — can be recreated from code (though you can serialize it too if you want user-customizable styling) * `current_file` path — this is UI state, not graph data This separation follows the principle from [Chapter 5](https://danpokhrel.github.io/tutorial/print.html#chapter-5-structuring-your-application) : persistent data (graph, configuration) is separate from ephemeral UI state (evaluation progress, log lines, which panel is open). Keeping them in separate structs makes the serialization boundary explicit. * * * With persistence in place, our flow editor is a functional, usable application — users can build, run, save, and restore their AI flows. In [Chapter 17](https://danpokhrel.github.io/tutorial/print.html#chapter-17-production-architecture) we’ll take the final step to production: custom error types, testing, feature flags, logging, and web/WASM deployment. Over the previous chapters we built a functional AI flow editor. Now let’s elevate it to production quality with proper error handling, testing, feature flags, logging, and web deployment. This chapter follows the principles from [Rust Book Chapter 9](https://doc.rust-lang.org/stable/book/ch09-00-error-handling.html) (error handling) and [Chapter 11](https://doc.rust-lang.org/stable/book/ch11-00-testing.html) (testing). > **Scope note: partially in the reference implementation.** The impl _does_ practice the testing half of this chapter: `graph/state.rs`, `ui/app.rs` (`seed_demo_graph`), and `ui/viewer.rs` (`read_concat_inputs`) each ship a `#[cfg(test)] mod tests` block — pure-data, headless, no GPU — exactly the discipline below. But the production _plumbing_ — the custom `AppError` enum, `tracing`/`tracing-subscriber`, Cargo `[features]`, the `WebRunner`/Trunk web build, and the workspace split — is **not** in the impl. Those remain forward-looking (and `tracing`, like `rfd`/`serde_json`/`chrono`, is flagged in-text as a dependency to add when you reach it). Instead of using `String` for errors (as we did in [Chapter 16](https://danpokhrel.github.io/tutorial/print.html#chapter-16-state-persistence) ), a production app should define a proper domain error enum. This lets callers match on specific error variants and enables the `?` operator to convert automatically: ``#![allow(unused)] fn main() { use std::fmt; #[derive(Debug)] pub enum AppError { Io(std::io::Error), Serde(serde_json::Error), /// A graph evaluation error (e.g., a cycle was detected). Graph(String), /// An evaluation runtime error. Eval(String), /// Initialization failure. Init(String), } impl fmt::Display for AppError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { AppError::Io(e) => write!(f, "I/O error: {e}"), AppError::Serde(e) => write!(f, "Serialization error: {e}"), AppError::Graph(msg) => write!(f, "Graph error: {msg}"), AppError::Eval(msg) => write!(f, "Evaluation error: {msg}"), AppError::Init(msg) => write!(f, "Initialization error: {msg}"), } } } impl std::error::Error for AppError { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { match self { AppError::Io(e) => Some(e), AppError::Serde(e) => Some(e), _ => None, } } } // From impls enable the `?` operator for automatic conversion. impl From for AppError { fn from(e: std::io::Error) -> Self { AppError::Io(e) } } impl From for AppError { fn from(e: serde_json::Error) -> Self { AppError::Serde(e) } } }`` This follows [Rust Book Chapter 9.2](https://doc.rust-lang.org/stable/book/ch09-02-recoverable-errors-with-result.html) exactly: define a domain error enum, implement `Display` and `Error`, and add `From` impls. The `source()` method enables error chaining for debugging — you can walk the entire cause chain. Now our save/load functions can use `?`: `#![allow(unused)] fn main() { impl MyApp { pub fn save_graph(&self, path: &std::path::Path) -> Result<(), AppError> { let json = serde_json::to_string_pretty(&self.snarl)?; // serde_json::Error → AppError std::fs::write(path, json)?; // io::Error → AppError Ok(()) } pub fn load_graph(&mut self, path: &std::path::Path) -> Result<(), AppError> { let json = std::fs::read_to_string(path)?; let project: ProjectFile = serde_json::from_str(&json)?; self.snarl = project.snarl; Ok(()) } } }` Because we separated the graph data model from the UI (the principle from [Chapter 5](https://danpokhrel.github.io/tutorial/print.html#chapter-5-structuring-your-application) ), we can test `GraphState`, `topo_sort`, and `evaluate_node` without a GPU or window. This is pure Rust unit testing, as described in [Rust Book Chapter 11.1](https://doc.rust-lang.org/stable/book/ch11-01-writing-tests.html) : `#![allow(unused)] fn main() { #[cfg(test)] mod tests { use super::*; use egui_snarl::{Snarl, NodeId}; fn make_test_flow() -> Snarl { let mut snarl = Snarl::new(); let _input = snarl.insert_node( egui::pos2(0.0, 0.0), AgentNode::ChatInput { message: "Hello".into() }, ); let _llm = snarl.insert_node( egui::pos2(200.0, 0.0), AgentNode::LLMNode { model: "gpt-4".into(), temperature: 0.7, system_prompt: "You are helpful.".into(), }, ); let _output = snarl.insert_node( egui::pos2(400.0, 0.0), AgentNode::OutputNode, ); snarl } #[test] fn test_topo_sort_no_cycle() { let snarl = make_test_flow(); let order = topo_sort(&snarl).expect("should sort"); // The first node must be ChatInput (it has no inputs). assert!(!order.is_empty()); } #[test] fn test_topo_sort_detects_cycle() { let mut snarl = make_test_flow(); // Create a cycle: connect Output → ChatInput. // (In a real test, you'd set up nodes that can form a cycle.) // assert!(topo_sort(&snarl).is_err()); } }` > **Note:** The cycle-detection test above is commented out because `make_test_flow` produces a DAG with no cycle. To test cycle detection, create a graph where a node’s output feeds back into its own input — e.g., connect `OutputNode`’s input to `ChatInput`’s output, creating a circular dependency. Uncomment and adjust the assertion once you have such a graph. `#![allow(unused)] fn main() { #[test] fn test_evaluate_chat_input() { let snarl = make_test_flow(); let results = HashMap::new(); let output = evaluate_node(&snarl, NodeId(0), &results); assert!(!output.is_empty(), "ChatInput should produce a string"); } #[test] fn test_connect_validates_types() { // Our agent nodes all use string pins, so connections are accepted. // In a more advanced system, you'd test type rejection. } #[test] fn test_serialize_roundtrip() { let snarl = make_test_flow(); let json = serde_json::to_string(&snarl).unwrap(); let restored: Snarl = serde_json::from_str(&json).unwrap(); assert_eq!(snarl.nodes().count(), restored.nodes().count()); } } }` Unit tests live in the same file as the code they test, inside a `#[cfg(test)] mod tests` block. Integration tests live in `tests/`. See [Rust Book Chapter 11.3](https://doc.rust-lang.org/stable/book/ch11-03-test-organization.html) for the full organization guidance. egui is immediate-mode and deterministic given identical input, which makes snapshot testing tractable. eframe has a built-in `__screenshot` feature for visual regression tests: 1. Enable the `__screenshot` feature in your `Cargo.toml`. 2. Set the `EFRAME_SCREENSHOT_TO` environment variable to an output path. 3. Run the app — it renders one frame, writes the screenshot, and quits. `EFRAME_SCREENSHOT_TO=screenshot.png cargo run --features __screenshot` You can compare the output against a known-good baseline in CI to catch visual regressions. > **Note:** egui itself uses this mechanism for its own screenshot tests. It’s most useful for catching accidental visual changes (a widget moved, a color shifted) rather than functional bugs. For headless testing, you can create a `Context` manually without eframe, feed it synthetic input, and run your UI: `#![allow(unused)] fn main() { #[test] fn test_ui_renders_without_panic() { let ctx = egui::Context::default(); let mut app = MyApp::default(); // Simulate a single frame. ctx.run_ui(egui::Id::new("test"), |_ctx| {}, |ui| { app.ui(ui, &mut eframe::Frame::default()); }); // Assert on the resulting state. assert!(!app.log_lines.is_empty() || app.log_lines.is_empty()); // didn't panic } }` This follows the pattern from the research reference: build a `Context` manually, feed synthetic `RawInput` events, and assert on state or paint output. The `logic`/`ui` split helps here — you can unit-test state transitions in `logic` without any rendering. For production, use Cargo feature flags to conditionally compile debugging tools or optional dependencies: `[features] default = ["serde-support", "persistence"] serde-support = ["dep:serde", "dep:serde_json"] debug-tools = [] # Enables the egui demo window for debugging persistence = ["eframe/persistence"]` `[dependencies] eframe = { version = "0.35", default-features = false, features = [ "default_fonts", "wgpu", "x11", "wayland" ] } egui-snarl = { version = "0.11", features = ["serde"] } serde = { version = "1", features = ["derive"], optional = true } serde_json = { version = "1", optional = true } tracing = "0.1" tracing-subscriber = "0.3"` Then use `#[cfg(feature = "...")]` to conditionally compile: `#![allow(unused)] fn main() { // Only show the egui demo window in debug builds. #[cfg(feature = "debug-tools")] { egui::Window::new("egui Demo") .open(&mut self.show_demo) .show(ui, |ui| { ui.label("Debug tools enabled."); // egui::demo::DemoWindow::default().ui(ui); }); } }` Feature flags are documented in the [Cargo Book](https://doc.rust-lang.org/cargo/reference/features.html) . The `cfg` attribute is covered in the [Rust Reference](https://doc.rust-lang.org/reference/conditional-compilation.html) . > **Note:** `tracing` and `tracing-subscriber` are not in our `Cargo.toml` from [Chapter 2](https://danpokhrel.github.io/tutorial/print.html#chapter-2-project-setup--structure) > . Add them when you reach this chapter: `tracing = "0.1"` and `tracing-subscriber = "0.3"`. For production debugging, structured logging is essential. The [`tracing`](https://crates.io/crates/tracing) crate is the modern standard: `use tracing::{info, error, debug}; fn main() -> Result<(), AppError> { // Initialize the tracing subscriber. tracing_subscriber::fmt() .with_env_filter( env!("CARGO_PKG_NAME").to_owned() + "=debug" ) .init(); info!("Starting Flow Builder v{}", env!("CARGO_PKG_VERSION")); let options = eframe::NativeOptions { viewport: egui::ViewportBuilder::default() .with_inner_size([1280.0, 800.0]) .with_title("AI Flow Builder"), ..Default::default() }; eframe::run_native( "ai-flow-builder", options, Box::new(|cc| Ok(Box::new(MyApp::new(cc)?))), ).map_err(|e| { error!("eframe error: {e}"); AppError::Init(e.to_string()) })?; info!("Shutting down."); Ok(()) }` Set `RUST_LOG=ai_flow_builder=debug,eframe=info` to control log levels. The `info!`, `debug!`, and `error!` macros are zero-cost when the log level is disabled. eframe compiles to `wasm32-unknown-unknown` and renders to an HTML ``. The [`eframe_template`](https://github.com/emilk/eframe_template) repository gives you the full web setup. Here’s the essence: `#![allow(unused)] fn main() { use wasm_bindgen::prelude::*; #[derive(Clone)] #[wasm_bindgen] pub struct WebHandle { runner: eframe::WebRunner, } #[wasm_bindgen] impl WebHandle { #[wasm_bindgen(constructor)] pub fn new() -> Self { eframe::WebLogger::init(log::LevelFilter::Debug).ok(); Self { runner: eframe::WebRunner::new() } } #[wasm_bindgen] pub async fn start(&self, canvas: web_sys::HtmlCanvasElement) -> Result<(), JsValue> { self.runner .start( canvas, eframe::WebOptions::default(), Box::new(|cc| Ok(Box::new(MyApp::new(cc)?))), ) .await } #[wasm_bindgen] pub fn destroy(&self) { self.runner.destroy(); } #[wasm_bindgen] pub fn has_panicked(&self) -> bool { self.runner.has_panicked() } } }` ` AI Flow Builder ` The CSS is critical: without `overflow: hidden` and the canvas filling the page, the layout will scroll and the canvas won’t fill the window. `# Install Trunk. cargo install --locked trunk # Dev server with live reload. trunk serve # → http://localhost:8080 # Production build (outputs to dist/). trunk build --release` > **Note:** On web, `std::thread::spawn` is not available. For async/background work, use `wasm_bindgen_futures::spawn_local` instead. The `ctx.request_repaint()` pattern from [Chapter 15](https://danpokhrel.github.io/tutorial/print.html#chapter-15-live-execution--streaming) > works the same — just swap the thread for a future. egui 0.35 introduced an **inspection protocol** — a TCP server that exposes the live widget tree and can inject input. Enable eframe’s `inspection` feature, then launch with: `EGUI_INSPECTION=1 cargo run --release # Listens on port 5719 by default. # Override: EGUI_INSPECTION_ADDR=127.0.0.1:5719` The companion [`egui_mcp`](https://crates.io/crates/egui_mcp) crate is an MCP (Model Context Protocol) server on top, letting an AI agent _see_ and _operate_ your UI — read the widget tree, inject clicks and keystrokes, and capture screenshots. This is the basis for automated UI testing and AI-driven end-to-end tests in CI. As the project grows, consider splitting into a Cargo [workspace](https://doc.rust-lang.org/stable/book/ch14-03-cargo-workspaces.html) — separating the core library, the GUI binary, and any plugins: `ai-flow-builder/ ├── Cargo.toml # workspace root ├── crates/ │ ├── graph/ # pure graph model + evaluation (no egui) │ ├── app/ # the eframe application │ └── render/ # custom rendering widgets ├── src/ │ └── main.rs # binary entry point └── index.html # for Trunk (web)` `# Cargo.toml (root) [workspace] members = ["crates/graph", "crates/app", "crates/render"] resolver = "2" # REQUIRED — eframe pulls crates with mutually exclusive features [workspace.dependencies] egui = "0.35" eframe = "0.35" serde = { version = "1", features = ["derive"] }` Without `resolver = "2"` (or `edition = "2024"` which implies it), feature unification will break eframe’s build. 1. **Always use `--release`** for decent egui performance. Debug builds are visibly sluggish. Set `opt-level = 2` in `[profile.release]` and `[profile.dev.package."*"]`. 2. **Keep `ui()` cheap.** It runs every repaint. Heavy work goes in `logic()` or a background thread (see [Chapter 15](https://danpokhrel.github.io/tutorial/print.html#chapter-15-live-execution--streaming) ). 3. **Use virtualized lists** for large data sets. A plain `ScrollArea` lays out _all_ rows every frame — use `egui_virtual_list` for thousands of items. 4. **Clone `Context` for background threads**, not `&Context`. `Context` is `Arc`\-backed and cheaply clonable. 5. **Avoid `Id` clashes** in loops. Use `ui.push_id(i)` or explicit `Id::new(...)` (see [Chapter 4](https://danpokhrel.github.io/tutorial/print.html#chapter-4-layout-widgets--state) ). 6. **Profile with Puffin.** Enable the `profile-with-puffin` feature on eframe and show a puffin window in your app for flamegraph-style profiling. Here’s how a production `main.rs` ties everything together: `#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] mod app; mod error; mod eval; mod graph; mod theme; mod ui; use app::MyApp; use error::AppError; use tracing::info; fn main() -> Result<(), AppError> { tracing_subscriber::fmt() .with_env_filter(env!("CARGO_PKG_NAME").to_owned() + "=debug") .init(); info!("AI Flow Builder v{} starting...", env!("CARGO_PKG_VERSION")); let options = eframe::NativeOptions { viewport: egui::ViewportBuilder::default() .with_inner_size([1280.0, 800.0]) .with_min_inner_size([600.0, 400.0]) .with_title("AI Flow Builder"), ..Default::default() }; eframe::run_native( "ai-flow-builder", options, Box::new(|cc| Ok(Box::new(MyApp::new(cc)?))), ).map_err(|e| AppError::Init(e.to_string()))?; info!("Shutting down."); Ok(()) }` Clean, minimal, and production-ready. The `windows_subsystem = "windows"` attribute hides the console window on Windows release builds. Error handling uses our custom `AppError` type. Logging is initialized before anything else. * * * In [Chapter 18](https://danpokhrel.github.io/tutorial/print.html#chapter-18-conclusion--resources) we’ll review everything we’ve built, highlight the key patterns, and point you toward resources for taking your flow editor further. Congratulations! You’ve traveled from an empty `cargo new` to a production-grade node graph editor and agentic AI flow builder. Let’s review what we accomplished and where to go from here. Starting from a single-window “hello world” in [Chapter 3](https://danpokhrel.github.io/tutorial/print.html#chapter-3-your-first-window) , we built a complete, modern application with: Separate graph model (`graph/`), evaluation engine (`eval/`), UI rendering (`ui/`), and app wiring (`app.rs`), following [Rust Book Chapter 7](https://doc.rust-lang.org/stable/book/ch07-00-managing-growing-projects-with-packages-crates-and-modules.html) and [Chapter 17.3](https://doc.rust-lang.org/stable/book/ch17-03-oo.html) . The graph model and evaluator have zero egui dependencies — they’re testable without a GPU or window. Using [`egui-snarl`](https://github.com/zakarumych/egui-snarl) , we built an interactive canvas with draggable nodes, type-validated wire connections, context menus for adding and removing nodes, per-node-type color coding, and a custom dark theme with a grid background. We defined agent node types (`ChatInput`, `PromptTemplate`, `LLMNode`, `ToolNode`, `MemoryNode`, `OutputNode`) inspired by [Langflow](https://www.langflow.org/) and [Flowise](https://flowiseai.com/) , implemented a topological-sort evaluation engine, and added live streaming execution with per-node progress highlighting. eframe’s built-in persistence for app/UI state (window position, panel sizes) and manual serde-JSON save/load for the graph data, with native file dialogs via `rfd`. Custom error types, unit tests for the graph model and evaluator, feature flags, structured logging with `tracing`, and a web/WASM deployment path via Trunk. Here’s the final module structure: `ai-flow-builder/ ├── Cargo.toml ├── src/ │ ├── main.rs # entry point: tracing init, eframe::run_native │ ├── app.rs # MyApp struct, impl eframe::App │ ├── error.rs # AppError enum with Display + From impls │ ├── graph/ │ │ ├── mod.rs # re-exports │ │ └── nodes.rs # AgentNode enum, PinInfo colors │ ├── eval.rs # topo_sort, evaluate_node, simulate_llm │ ├── ui/ │ │ ├── mod.rs # render() top-level function │ │ ├── toolbar.rs # Run/Stop/Clear buttons │ │ ├── viewer.rs # SnarlViewer impl │ │ └── console.rs # execution log panel │ └── theme.rs # dark Visuals, SnarlStyle, color constants` The key architectural decisions: 1. **Separation of concerns** — graph data, evaluation logic, and rendering are in separate modules (Ch. 7). The graph and eval modules have no `egui` dependency. 2. **The `SnarlViewer` trait** — drives rendering for each node type without the graph container knowing about UI (Ch. 8–9). 3. **Collect-then-mutate** — interaction data is collected with an immutable borrow, then mutations happen after the borrow ends (Ch. 5, 9). 4. **`logic`/`ui` split** — state updates and evaluation happen in `logic()`; rendering only reads state in `ui()` (Ch. 5, 15). 5. **`ctx.request_repaint()` for streaming** — drives frame-by-frame evaluation without blocking the UI (Ch. 15). 6. **Serde for persistence** — the `Snarl` serializes cleanly to JSON (Ch. 16). These patterns transfer beyond egui to any Rust GUI or systems project: * **Immediate mode** — UI is rebuilt every frame from your state. No widget tree to sync. State lives in your `App` struct, mutated in `ui()`/`logic()`. * **The `Response` object** — `ui.add(widget)` returns a `Response` with `clicked()`, `hovered()`, `on_hover_text()`. Always return it from custom widgets so callers can chain. * **Stable `Id`s** — stateful widgets need stable `egui::Id`s. Use `ui.push_id(i)` in loops to avoid clashes. * **Clone `Context` for threads** — `egui::Context` is `Arc`\-backed. Clone it, pass to a background thread, call `ctx.request_repaint()` when done. * **Newtype IDs** — `NodeId(pub i32)` gives type safety at zero runtime cost (Rust Book Ch. 19.3). * **Error enums with `From` impls** — domain errors as enums, with `From` for automatic `?` conversion (Rust Book Ch. 9.2). * **Deferred execution** — set a “pending action” flag in `ui()`, process it in `logic()`. Keeps the rendering path clean. Here are directions to take your flow editor further: Replace the simulated LLM responses with real API calls. The [`ehttp`](https://crates.io/crates/ehttp) crate works on both native and web (uses `fetch` on WASM), making it ideal for cross-platform HTTP: `#![allow(unused)] fn main() { let request = ehttp::Request::post( "https://api.openai.com/v1/chat/completions", body_bytes, ); let ctx = ctx.clone(); ehttp::fetch(request, move |result: ehttp::Result| { // Parse the response, store the result, call ctx.request_repaint(). }); }` Design a trait-based plugin system where users can define their own node types: `#![allow(unused)] fn main() { pub trait NodeDefinition: Send + Sync { fn name(&self) -> &str; fn inputs(&self) -> Vec; fn outputs(&self) -> Vec; fn evaluate(&self, inputs: &[String]) -> String; } }` Implement undo/redo with the command pattern — every mutation (add node, connect, delete) creates a `Command` that can be undone. Store a stack of commands on `App`. Ctrl+Z / Ctrl+Shift+Z trigger undo/redo. Use [`egui_tiles`](https://crates.io/crates/egui_tiles) or [`egui_dock`](https://crates.io/crates/egui_dock) to support multiple open graphs in a tabbed or docked interface, similar to a real IDE. Export the visual flow as executable Rust or Python code, or as a REST API endpoint that runs the flow on demand. This turns your visual builder into a deployable service. * **Subgraphs** — nodes that contain their own graphs (recursive composition). * **Reroute nodes** — invisible nodes that just route wires, for cleaner layouts. * **Minimap** — a zoomed-out overview of the entire graph for navigation (egui-snarl doesn’t have a built-in minimap, but you can draw one with a custom `PaintCallback`). * **Searchable node palette** — a filterable list of available node types for the “Add Node” menu. #### [eframe](https://crates.io/crates/eframe) The official framework for writing apps with egui. [docs.rs](https://docs.rs/eframe) · [repo](https://github.com/emilk/egui/tree/master/crates/eframe) #### [egui](https://crates.io/crates/egui) The core immediate-mode UI library. [docs.rs](https://docs.rs/egui) · [demo app](https://www.egui.rs/) #### [egui-snarl](https://crates.io/crates/egui-snarl) Node-graph widget for egui. [docs.rs](https://docs.rs/egui-snarl) · [repo + demo](https://github.com/zakarumych/egui-snarl) #### [egui\_extras](https://crates.io/crates/egui_extras) Image loaders, table/strip builders, date picker. [docs.rs](https://docs.rs/egui_extras) #### [rfd](https://crates.io/crates/rfd) Native OS file/message dialogs (web + native). [docs.rs](https://docs.rs/rfd) #### [serde](https://crates.io/crates/serde) Serialization framework. [docs.rs](https://docs.rs/serde) · [guide](https://serde.rs/) #### [egui\_tiles](https://crates.io/crates/egui_tiles) Tiling/dock layout engine. [docs.rs](https://docs.rs/egui_tiles) #### [ehttp](https://crates.io/crates/ehttp) egui-adjacent HTTP client (native + web). [docs.rs](https://docs.rs/ehttp) * [The Rust Programming Language](https://doc.rust-lang.org/stable/book/) — the canonical Rust tutorial. This tutorial cross-references it throughout. * [egui docs](https://docs.rs/egui) — API documentation for every widget, layout, and context method. * [eframe docs](https://docs.rs/eframe) — the `App` trait, `NativeOptions`, `WebRunner`, and persistence API. * [egui-snarl demo](https://github.com/zakarumych/egui-snarl/blob/main/examples/demo.rs) — the reference implementation of a complete `SnarlViewer`. Read it alongside this tutorial. * [eframe\_template](https://github.com/emilk/eframe_template) — the official web+native starter. Clone it for your web deployment setup. * [egui wiki/FAQ](https://github.com/emilk/egui/wiki) — community knowledge base. Real-world node-based AI flow builders to study: * [Langflow](https://www.langflow.org/) — Python-based visual framework for composing LLMs, retrieval, and agents. Open source, supports multi-agent orchestration and streaming. * [Flowise](https://flowiseai.com/) — Node.js platform with three builder interfaces (Assistant, Chatflow, Agentflow). Enterprise features like RBAC and evaluations. * [n8n](https://n8n.io/) — General-purpose workflow automation with AI agent nodes. Combines business logic orchestration with LLM capabilities. * [LangGraph](https://github.com/langchain-ai/langgraph) — Explicit graph model for agent orchestration with checkpoints, pausing, and human-in-the-loop. Each of these demonstrates different architectural choices for the same problem: how to let users visually compose AI workflows. Our Rust/egui implementation proves that a performant, type-safe, cross-platform alternative is achievable. * * * You’ve built a production-grade node graph editor and agentic AI flow builder in Rust with a clean architecture, modern styling, full interactions, streaming execution, persistence, and a web deployment path. The patterns you’ve learned — immediate mode, the `SnarlViewer` trait, collect-then-mutate, async with `request_repaint`, serde persistence, and the `logic`/`ui` split — are the same patterns that make any egui application maintainable and performant. Go build something amazing. 🦀 ---