Repository: zng-ui/zng (language: rust)

Issue:
Refactor examples
Right now all examples share the same dependencies, this causes examples that only use view_prebuilt to also need to built the full view.
And now that `zng::env::init!` automatically starts a view-process this is even worse.

Refactor examples into crates. Refactor cargo-do to automatically find examples.

Remove util? Logging helper is already implemented in zng, maybe implement profiling too?


Discussion / hints:

Fix patch:
diff --git a/.gitignore b/.gitignore
index 0cf98eb10..df4e355f7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,18 +6,17 @@ profile*.json.gz
 **/*.expanded.rs
 dump.*
 dump*.*
-screenshot.*
-screencast.*
+/screenshot.*
+/screencast.*
 wip
 cargo-timing*
 large-image.*
-examples/examples
+examples/test
 TODO/_local.md
 crates/zng-view-prebuilt/lib/zng_view.*
 crates/zng-view-prebuilt/lib/libzng_view.*
 **/*.mm_profdata
 *.mono_items.md
-examples/.do-doc-readme-*
 do.bat
 do.ps1
 do
\ No newline at end of file
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2faea213c..ee859375f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,7 @@
     - Only `zng::env::init!()` needs to be called to setup the view-process.
 * **Breaking** `zng_view_api::Controller::start` now requires the exe path and supports optional env variables to set.
     - This only affects custom view-process implementers.
+* **Breaking** New default `zng::env::res`, was `./assets` now is `./res`.
 
 
 # 0.6.2
diff --git a/Cargo.toml b/Cargo.toml
index 099093cc7..eebf66ab3 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -4,8 +4,7 @@ resolver = "2"
 
 members = [
     "crates/*",
-    "examples",
-    "examples/util",
+    "examples/*",
     "tests",
     "tests/macro-tests",
     "tests/render-tests",
diff --git a/crates/zng-env/src/lib.rs b/crates/zng-env/src/lib.rs
index f7416942f..42d76b6e8 100644
--- a/crates/zng-env/src/lib.rs
+++ b/crates/zng-env/src/lib.rs
@@ -278,9 +278,8 @@ lazy_static! {
 /// Gets a path relative to the package resources.
 ///
 /// * The res dir can be set by [`init_res`] before any env dir is used.
-/// * In all platforms if a file `bin/current_exe_name.zng_res_dir` is found the file first line not starting with
-///  `"\s#"` and non empty is used as the res path.
-/// * In `cfg(debug_assertions)` builds returns `assets`.
+/// * In all platforms if a file `bin/current_exe_name.zng_res_dir` is found it defines the res path.
+/// * In `cfg(debug_assertions)` builds returns `res`.
 /// * In macOS returns `bin("../Resources")`, assumes the package is deployed using a desktop `.app` folder.
 /// * In iOS returns `bin("")`, assumes the package is deployed as a mobile `.app` folder.
 /// * In Android returns `bin("../res")`, assumes the package is deployed as a `.apk` file.
@@ -289,11 +288,11 @@ lazy_static! {
 ///
 /// # Built Resources
 ///
-/// In `cfg(any(debug_assertions, feature="built_res"))` builds if the `target/assets/{relative_path}` path exists it
-/// is returned instead. This is useful during development when the app depends on assets that are generated locally and not
+/// In `cfg(any(debug_assertions, feature="built_res"))` builds if the `target/res/{relative_path}` path exists it
+/// is returned instead. This is useful during development when the app depends on res that are generated locally and not
 /// included in version control.
 ///
-/// Note that the built resources must be packaged with the other assets at the same relative location, so that release builds can find then.
+/// Note that the built resources must be packaged with the other res at the same relative location, so that release builds can find then.
 pub fn res(relative_path: impl AsRef<Path>) -> PathBuf {
     res_impl(relative_path.as_ref())
 }
@@ -338,7 +337,7 @@ lazy_static! {
     static ref RES: PathBuf = find_res();
 
     #[cfg(any(debug_assertions, feature="built_res"))]
-    static ref BUILT_RES: PathBuf = PathBuf::from("target/assets");
+    static ref BUILT_RES: PathBuf = PathBuf::from("target/res");
 }
 fn find_res() -> PathBuf {
     if let Ok(mut p) = std::env::current_exe() {
@@ -348,7 +347,7 @@ fn find_res() -> PathBuf {
         }
     }
     if cfg!(debug_assertions) {
-        PathBuf::from("assets")
+        PathBuf::from("res")
     } else if cfg!(windows) {
         bin("../res")
     } else if cfg!(target_os = "macos") {
diff --git a/examples/Cargo.toml b/examples/Cargo.toml
deleted file mode 100644
index adf091245..000000000
--- a/examples/Cargo.toml
+++ /dev/null
@@ -1,128 +0,0 @@
-[package]
-name = "examples"
-version = "0.0.0"
-publish = false
-edition = "2021"
-
-[dev-dependencies]
-mexprp = { version = "0.3", default-features = false }
-tracing = "0.1"
-lipsum = "0.9.0"
-rand = "0.8.5"
-regex = "1.10"
-serde = { version = "1.0", features = ["derive"] }
-raw-window-handle = "0.6"
-dunce = "1.0"
-linkme = "0.3"
-
-zng = { path = "../crates/zng", features = [
-    "http",
-    "hyphenation_embed_all",
-    "inspector",
-    "deadlock_detection",
-    "material_icons",
-    "view",
-    "view_prebuilt",
-    "hot_reload",
-    # "multi_app",
-] }
-
-zng-app = { path = "../crates/zng-app" }                                 # for respawn and extend_view examples
-zng-view-api = { path = "../crates/zng-view-api" }                       # for the extend_view example
-zng-view = { path = "../crates/zng-view" }                               # for the extend_view example
-zng-wgt-webrender-debug = { path = "../crates/zng-wgt-webrender-debug" } # for the extend_view example
-
-examples-util = { path = "util" }
-examples-hot-reload = { path = "hot-reload-lib" }
-
-[[example]]
-name = "animation"
-path = "animation.rs"
-
-[[example]]
-name = "border"
-path = "border.rs"
-
-[[example]]
-name = "button"
-path = "button.rs"
-
-[[example]]
-name = "calculator"
-path = "calculator.rs"
-
-[[example]]
-name = "config"
-path = "config.rs"
-
-[[example]]
-name = "countdown"
-path = "countdown.rs"
-
-[[example]]
-name = "cursor"
-path = "cursor.rs"
-
-[[example]]
-name = "extend_view"
-path = "extend_view.rs"
-
-[[example]]
-name = "focus"
-path = "focus.rs"
-
-[[example]]
-name = "gradient"
-path = "gradient.rs"
-
-[[example]]
-name = "headless"
-path = "headless.rs"
-
-[[example]]
-name = "hot_reload"
-path = "hot_reload.rs"
-
-[[example]]
-name = "icon"
-path = "icon.rs"
-
-[[example]]
-name = "image"
-path = "image.rs"
-
-[[example]]
-name = "layer"
-path = "layer.rs"
-
-[[example]]
-name = "localize"
-path = "localize.rs"
-
-[[example]]
-name = "markdown"
-path = "markdown.rs"
-
-[[example]]
-name = "respawn"
-path = "respawn.rs"
-
-[[example]]
-name = "scroll"
-path = "scroll.rs"
-
-[[example]]
-name = "shortcut"
-path = "shortcut.rs"
-
-[[example]]
-name = "text"
-path = "text.rs"
-
-[[example]]
-name = "transform"
-path = "transform.rs"
-
-[[example]]
-name = "window"
-path = "window.rs"
diff --git a/examples/README.md b/examples/README.md
index a8facce9c..b026d5a9f 100644
--- a/examples/README.md
+++ b/examples/README.md
@@ -2,9 +2,9 @@
 <!--do doc --readme-examples-->
 ### `animation`
 
-<img alt='animation screenshot' src='./res/screenshots/animation.png' width='300'>
+<img alt='animation screenshot' src='./animation/res/screenshot.png' width='300'>
 
-Source: [animation.rs](./animation.rs)
+Source: [animation/src](./animation/src)
 
 ```console
 cargo do run animation
@@ -14,9 +14,9 @@ Demonstrates animation, easing functions.
 
 ### `border`
 
-<img alt='border screenshot' src='./res/screenshots/border.png' width='300'>
+<img alt='border screenshot' src='./border/res/screenshot.png' width='300'>
 
-Source: [border.rs](./border.rs)
+Source: [border/src](./border/src)
 
 ```console
 cargo do run border
@@ -26,9 +26,9 @@ Demonstrates borders, corner radius, multiple borders per widget and clip-to-bou
 
 ### `button`
 
-<img alt='button screenshot' src='./res/screenshots/button.png' width='300'>
+<img alt='button screenshot' src='./button/res/screenshot.png' width='300'>
 
-Source: [button.rs](./button.rs)
+Source: [button/src](./button/src)
 
 ```console
 cargo do run button
@@ -38,9 +38,9 @@ Demonstrates the button and toggle widgets.
 
 ### `calculator`
 
-<img alt='calculator screenshot' src='./res/screenshots/calculator.png' width='300'>
+<img alt='calculator screenshot' src='./calculator/res/screenshot.png' width='300'>
 
-Source: [calculator.rs](./calculator.rs)
+Source: [calculator/src](./calculator/src)
 
 ```console
 cargo do run calculator
@@ -50,9 +50,9 @@ Simple calculator, demonstrates Grid layout, data context.
 
 ### `config`
 
-<img alt='config screenshot' src='./res/screenshots/config.png' width='300'>
+<img alt='config screenshot' src='./config/res/screenshot.png' width='300'>
 
-Source: [config.rs](./config.rs)
+Source: [config/src](./config/src)
 
 ```console
 cargo do run config
@@ -62,7 +62,9 @@ Demonstrates the CONFIG service, live updating config between processes.
 
 ### `countdown`
 
-Source: [countdown.rs](./countdown.rs)
+<img alt='countdown screenshot' src='./countdown/res/screenshot.png' width='300'>
+
+Source: [countdown/src](./countdown/src)
 
 ```console
 cargo do run countdown
@@ -72,9 +74,9 @@ Demonstrates the TIMERS service, variable mapping.
 
 ### `cursor`
 
-<img alt='cursor screenshot' src='./res/screenshots/cursor.png' width='300'>
+<img alt='cursor screenshot' src='./cursor/res/screenshot.png' width='300'>
 
-Source: [cursor.rs](./cursor.rs)
+Source: [cursor/src](./cursor/src)
 
 ```console
 cargo do run cursor
@@ -82,21 +84,23 @@ cargo do run cursor
 
 Demonstrates each `CursorIcon`, tooltip anchored to cursor.
 
-### `extend_view`
+### `extend-view`
+
+<img alt='extend-view screenshot' src='./extend-view/res/screenshot.png' width='300'>
 
-Source: [extend_view.rs](./extend_view.rs)
+Source: [extend-view/src](./extend-view/src)
 
 ```console
-cargo do run extend_view
+cargo do run extend-view
 ```
 
 Demonstrates the `zng-view` extension API and render extensions API.
 
 ### `focus`
 
-<img alt='focus screenshot' src='./res/screenshots/focus.png' width='300'>
+<img alt='focus screenshot' src='./focus/res/screenshot.png' width='300'>
 
-Source: [focus.rs](./focus.rs)
+Source: [focus/src](./focus/src)
 
 ```console
 cargo do run focus
@@ -106,9 +110,9 @@ Demonstrates the focus service, logical and directional navigation.
 
 ### `gradient`
 
-<img alt='gradient screenshot' src='./res/screenshots/gradient.png' width='300'>
+<img alt='gradient screenshot' src='./gradient/res/screenshot.png' width='300'>
 
-Source: [gradient.rs](./gradient.rs)
+Source: [gradient/src](./gradient/src)
 
 ```console
 cargo do run gradient
@@ -118,9 +122,9 @@ Demonstrates gradient rendering.
 
 ### `headless`
 
-<img alt='headless screenshot' src='./res/screenshots/headless.png' width='300'>
+<img alt='headless screenshot' src='./headless/res/screenshot.png' width='300'>
 
-Source: [headless.rs](./headless.rs)
+Source: [headless/src](./headless/src)
 
 ```console
 cargo do run headless
@@ -128,23 +132,23 @@ cargo do run headless
 
 Demonstrates headless apps, image and video rendering.
 
-### `hot_reload`
+### `hot-reload`
 
-<img alt='hot_reload screenshot' src='./res/screenshots/hot_reload.png' width='300'>
+<img alt='hot-reload screenshot' src='./hot-reload/res/screenshot.png' width='300'>
 
-Source: [hot_reload.rs](./hot_reload.rs)
+Source: [hot-reload/src](./hot-reload/src)
 
 ```console
-cargo do run hot_reload
+cargo do run hot-reload
 ```
 
 Demonstrates the `"hot_reload"` feature.
 
 ### `icon`
 
-<img alt='icon screenshot' src='./res/screenshots/icon.png' width='300'>
+<img alt='icon screenshot' src='./icon/res/screenshot.png' width='300'>
 
-Source: [icon.rs](./icon.rs)
+Source: [icon/src](./icon/src)
 
 ```console
 cargo do run icon
@@ -154,9 +158,9 @@ Search and copy Material Icons constants.
 
 ### `image`
 
-<img alt='image screenshot' src='./res/screenshots/image.png' width='300'>
+<img alt='image screenshot' src='./image/res/screenshot.png' width='300'>
 
-Source: [image.rs](./image.rs)
+Source: [image/src](./image/src)
 
 ```console
 cargo do run image
@@ -166,9 +170,9 @@ Demonstrates image loading, displaying, animated sprites, rendering, pasting.
 
 ### `layer`
 
-<img alt='layer screenshot' src='./res/screenshots/layer.png' width='300'>
+<img alt='layer screenshot' src='./layer/res/screenshot.png' width='300'>
 
-Source: [layer.rs](./layer.rs)
+Source: [layer/src](./layer/src)
 
 ```console
 cargo do run layer
@@ -178,9 +182,9 @@ Demonstrates the LAYERS service.
 
 ### `localize`
 
-<img alt='localize screenshot' src='./res/screenshots/localize.png' width='300'>
+<img alt='localize screenshot' src='./localize/res/screenshot.png' width='300'>
 
-Source: [localize.rs](./localize.rs)
+Source: [localize/src](./localize/src)
 
 ```console
 cargo do run localize
@@ -190,9 +194,9 @@ Demonstrates localization.
 
 ### `markdown`
 
-<img alt='markdown screenshot' src='./res/screenshots/markdown.png' width='300'>
+<img alt='markdown screenshot' src='./markdown/res/screenshot.png' width='300'>
 
-Source: [markdown.rs](./markdown.rs)
+Source: [markdown/src](./markdown/src)
 
 ```console
 cargo do run markdown
@@ -202,9 +206,9 @@ Demonstrates the `Markdown!` widget.
 
 ### `respawn`
 
-<img alt='respawn screenshot' src='./res/screenshots/respawn.png' width='300'>
+<img alt='respawn screenshot' src='./respawn/res/screenshot.png' width='300'>
 
-Source: [respawn.rs](./respawn.rs)
+Source: [respawn/src](./respawn/src)
 
 ```console
 cargo do run respawn
@@ -214,9 +218,9 @@ Demonstrates app-process crash handler and view-process respawn.
 
 ### `scroll`
 
-<img alt='scroll screenshot' src='./res/screenshots/scroll.png' width='300'>
+<img alt='scroll screenshot' src='./scroll/res/screenshot.png' width='300'>
 
-Source: [scroll.rs](./scroll.rs)
+Source: [scroll/src](./scroll/src)
 
 ```console
 cargo do run scroll
@@ -226,9 +230,9 @@ Demonstrates the `Scroll!` widget and scroll commands.
 
 ### `shortcut`
 
-<img alt='shortcut screenshot' src='./res/screenshots/shortcut.png' width='300'>
+<img alt='shortcut screenshot' src='./shortcut/res/screenshot.png' width='300'>
 
-Source: [shortcut.rs](./shortcut.rs)
+Source: [shortcut/src](./shortcut/src)
 
 ```console
 cargo do run shortcut
@@ -238,9 +242,9 @@ Small utility that displays the pressed key gestures.
 
 ### `text`
 
-<img alt='text screenshot' src='./res/screenshots/text.png' width='300'>
+<img alt='text screenshot' src='./text/res/screenshot.png' width='300'>
 
-Source: [text.rs](./text.rs)
+Source: [text/src](./text/src)
 
 ```console
 cargo do run text
@@ -250,9 +254,9 @@ Demonstrates the `Text!` and `TextInput!` widgets. Text rendering, text editor.
 
 ### `transform`
 
-<img alt='transform screenshot' src='./res/screenshots/transform.png' width='300'>
+<img alt='transform screenshot' src='./transform/res/screenshot.png' width='300'>
 
-Source: [transform.rs](./transform.rs)
+Source: [transform/src](./transform/src)
 
 ```console
 cargo do run transform
@@ -262,9 +266,9 @@ Demonstrates 2D and 3D transforms, touch transforms.
 
 ### `window`
 
-<img alt='window screenshot' src='./res/screenshots/window.png' width='300'>
+<img alt='window screenshot' src='./window/res/screenshot.png' width='300'>
 
-Source: [window.rs](./window.rs)
+Source: [window/src](./window/src)
 
 ```console
 cargo do run window
@@ -276,20 +280,29 @@ Demonstrates the window widget, service, state and commands.
 
 ## Adding an Example
 
-Add the new example in `examples/<example-name>.rs`:
+Add the new example crate in `examples/<example-name>`:
 
-```rust
-//! Demonstrates foo, bar.
+`examples/<example-name>/Cargo.toml`
 
-#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
+```
+[package]
+name = "<example-name>"
+version = "0.0.0"
+publish = false
+edition = "2021"
 
-use zng::prelude::*;
+[dependencies]
+zng = { path = "../../crates/zng", features = ["view_prebuilt"] }
+```
 
-use zng::view_process::prebuilt as view_process;
+`examples/<example-name>/src/main.rs`: 
+
+```rust
+//! Demonstrates foo, bar.
+use zng::prelude::*;
 
 fn main() {
     examples_util::print_info();
-
     zng::env::init!();
     zng::app::crash_handler::init_debug();
     app_main();
@@ -305,27 +318,19 @@ fn app_main() {
 }
 ```
 
-Register it in `examples/Cargo.toml`:
-
-```toml
-[[example]]
-name = "<example-name>"
-path = "<example-name>.rs"
-```
-
 Run the example and test it.
 
 ```console
 cargo do run <example-name>
 ```
 
-Optionally, take a screenshot and save it to `examples/res/screenshots/<example-name>.png`. You can take a screenshot using
+Optionally, take a screenshot and save it to `examples/<example-name>/res/screenshot.png`. You can take a screenshot using
 the inspector window, press `Ctrl+Shift+I` then click the "Save Screenshot" menu.
 
 Run [`oxipng`](https://github.com/shssoichiro/oxipng) or another minifier on the screenshot before committing.
 
 ```console
-oxipng -o max --strip safe --alpha "examples/res/screenshots/<example-name>.png"
+oxipng -o max --strip safe --alpha "examples/<example-name>/res/screenshot.png"
 ```
 
 Update the auto generated README:
@@ -338,5 +343,4 @@ Done.
 
 ## Local Example
 
-You can create local examples for manual testing in `/examples/examples/<test>.rs`. These
-files are git-ignored and can be run using `cargo do run <test>` without needing to register.
+You can create a local "example" for manual testing in `/examples/test/`. This dir is gitignored.
\ No newline at end of file
diff --git a/examples/animation.rs b/examples/animation.rs
deleted file mode 100644
index 5e489a9a9..000000000
--- a/examples/animation.rs
+++ /dev/null
@@ -1,269 +0,0 @@
-//! Demonstrates animation, easing functions.
-
-#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
-
-use std::collections::HashMap;
-
-use zng::{
-    button,
-    color::{ColorScheme, Rgba},
-    image,
-    layout::{margin, offset, size},
-    prelude::*,
-    rule_line::RuleLine,
-    var::{
-        animation::{
-            self,
-            easing::{EasingFn, EasingTime},
-        },
-        ArcVar, VARS,
-    },
-    widget::{background_color, corner_radius, LineOrientation},
-    window::RenderMode,
-};
-
-fn main() {
-    examples_util::print_info();
-    zng::env::init!();
-    zng::app::crash_handler::init_debug();
-
-    // let rec = examples_util::record_profile("animation");
-
-    // zng::view_process::prebuilt::run_same_process(app_main);
-    app_main();
-
-    // rec.finish();
-}
-
-fn app_main() {
-    APP.defaults().run_window(async {
-        Window! {
-            title = "Animation Example";
-            padding = 10;
-            child_align = Align::CENTER;
-            child = example();
-        }
-    })
-}
-
-const FROM_COLOR: Rgba = web_colors::RED;
-const TO_COLOR: Rgba = web_colors::GREEN;
-const FPS: u32 = 60;
-
-fn example() -> impl UiNode {
-    // VARS.animation_time_scale().set(0.5.fct());
-    VARS.frame_duration().set((1.0 / FPS as f32).secs());
-
-    let x = var(0.dip());
-    let color = var(FROM_COLOR);
-
-    // x.trace_value(move |v| {
-    //     tracing::debug_span!("x", value = ?v, thread = "<x>").entered()
-    // })
-    // .perm();
-
-    let easing_mod = var(Txt::from("ease_out"));
-
-    Stack! {
-        direction = StackDirection::top_to_bottom();
-        spacing = 10;
-        children_align = Align::TOP;
-        children = ui_vec![
-            Container! {
-                id = "demo";
-                layout::width = 301;
-                widget::background = ruler();
-                margin = (0, 0, 40, 0);
-                child_align = Align::LEFT;
-                child = Wgt! {
-                    id = "ball";
-                    size = (40, 40);
-                    corner_radius = 20;
-                    background_color = color.clone();
-
-                    layout::x = x.map(|x| x.clone() - 20.dip());
-
-                    when *#gesture::is_hovered {
-                        background_color = web_colors::LIME;
-                    }
-                };
-            },
-            Stack! {
-                id = "mod-menu";
-                direction = StackDirection::left_to_right();
-                spacing = 2;
-                toggle::selector = toggle::Selector::single(easing_mod.clone());
-                children = {
-                    let mode = |m: Txt| Toggle! {
-                        child = Text!(m.clone());
-                        value::<Txt> = m;
-                    };
-                    ui_vec![
-                        mode(Txt::from("ease_in")),
-                        mode(Txt::from("ease_out")),
-                        mode(Txt::from("ease_in_out")),
-                        mode(Txt::from("ease_out_in")),
-                        mode(Txt::from("reverse")),
-                        mode(Txt::from("reverse_out")),
-                    ]
-                }
-            },
-            Grid! {
-                id = "easing-menu";
-                spacing = 2;
-                columns = ui_vec![grid::Column!(1.lft()); 7];
-                auto_grow_fn = wgt_fn!(|_| grid::Row!(1.lft()));
-                button::style_fn = Style! { layout::padding = 3 };
-                cells = ui_vec![
-                    ease_btn(&x, &color, "", EasingFn::Linear, &easing_mod),
-                    ease_btn(&x, &color, "", EasingFn::Quad, &easing_mod),
-                    ease_btn(&x, &color, "", EasingFn::Cubic, &easing_mod),
-                    ease_btn(&x, &color, "", EasingFn::Quart, &easing_mod),
-                    ease_btn(&x, &color, "", EasingFn::Quint, &easing_mod),
-                    ease_btn(&x, &color, "", EasingFn::Sine, &easing_mod),
-                    ease_btn(&x, &color, "", EasingFn::Expo, &easing_mod),
-                    ease_btn(&x, &color, "", EasingFn::Circ, &easing_mod),
-                    ease_btn(&x, &color, "", EasingFn::Back, &easing_mod),
-                    ease_btn(&x, &color, "", EasingFn::Elastic, &easing_mod),
-                    ease_btn(&x, &color, "", EasingFn::Bounce, &easing_mod),
-                    ease_btn(&x, &color, "step_ceil(6)", EasingFn::custom(|t| easing::step_ceil(6, t)), &easing_mod),
-                    ease_btn(&x, &color, "step_floor(6)", EasingFn::custom(|t| easing::step_floor(6, t)), &easing_mod),
-                    ease_btn(&x, &color, "", EasingFn::None, &easing_mod),
-                ]
-            },
-            Button! {
-                child = Text!("reset");
-                widget::foreground_highlight = {
-                    offsets: -2,
-                    widths: 1,
-                    sides: web_colors::DARK_RED,
-                };
-                gesture::click_shortcut = shortcut![Escape];
-                on_click = hn!(x, color, |_| {
-                    x.set(0);
-                    color.set(FROM_COLOR);
-                });
-            },
-        ]
-    }
-}
-
-fn ease_btn(l: &ArcVar<Length>, color: &ArcVar<Rgba>, name: &'static str, easing: EasingFn, easing_mod: &ArcVar<Txt>) -> impl UiNode {
-    let name = if name.is_empty() { formatx!("{easing:?}") } else { name.to_txt() };
-    let easing = easing_mod.map(clmv!(easing, |m| {
-        let f = match m.as_str() {
-            "ease_in" => easing.clone(),
-            "ease_out" => easing.clone().ease_out(),
-            "ease_in_out" => easing.clone().ease_in_out(),
-            "ease_out_in" => easing.clone().ease_out_in(),
-            "reverse" => easing.clone().reverse(),
-            "reverse_out" => easing.clone().reverse_out(),
-            _ => unreachable!(),
-        };
-        (m.clone(), f)
-    }));
-    let mut plot_cache = HashMap::new(); // to reuse rendered images
-    Button! {
-        grid::cell::at = grid::cell::AT_AUTO;
-        child = Stack! {
-            direction = StackDirection::top_to_bottom();
-            spacing = 2;
-            children_align = Align::TOP;
-            children = ui_vec![
-                Text!(name),
-                Image! {
-                    img_scale_ppi = true;
-                    img_loading_fn = wgt_fn!(|_| Wgt! {
-                        size = (64, 64);
-                        margin = 10;
-                    });
-                    source = easing.map(move |(name, f)| plot_cache.entry(name.clone()).or_insert_with(|| plot(f.clone())).clone());
-                },
-            ]
-        };
-        on_click = hn!(l, color, |_| {
-            let f = easing.get().1;
-            l.set_ease(0, 300, 1.secs(), f.ease_fn()).perm();
-            color.set_ease(FROM_COLOR, TO_COLOR, 1.secs(), f.ease_fn()).perm();
-        });
-    }
-}
-fn plot(easing: EasingFn) -> ImageSource {
-    let size = (64, 64);
-    ImageSource::render_node(
-        RenderMode::Software,
-        clmv!(size, |_| {
-            let mut children = ui_vec![];
-            let color_t = animation::Transition::new(FROM_COLOR, TO_COLOR);
-            let fps_f = FPS as f32;
-            for i in 0..=FPS {
-                let x_fct = (i as f32 / fps_f).fct();
-                let x = size.0 * x_fct;
-
-                let y_fct = easing(EasingTime::new(x_fct));
-                let y = size.1 * (1.fct() - y_fct);
-
-                children.push(
-                    Wgt! {
-                        offset = (x, y);
-                        size = (3, 3);
-                        corner_radius = 2;
-                        layout::translate = -1.5, -1.5;
-                        background_color = color_t.sample(y_fct);
-                    }
-                    .boxed(),
-                )
-            }
-
-            image::IMAGE_RENDER.retain().set(true);
-            let meta_color = WINDOW.vars().actual_color_scheme().map(|t| match t {
-                ColorScheme::Light => rgba(0, 0, 0, 0.4),
-                ColorScheme::Dark => rgba(255, 255, 255, 0.4),
-            });
-
-            #[allow(clippy::precedence)]
-            children.push(
-                Text! {
-                    txt = "v";
-                    font_size = 12;
-                    font_style = FontStyle::Italic;
-                    font_color = meta_color.clone();
-                    offset = (-3.dip() - 100.pct(), -3.dip());
-                }
-                .boxed(),
-            );
-            children.push(
-                Text! {
-                    txt = "t";
-                    font_size = 12;
-                    font_style = FontStyle::Italic;
-                    font_color = meta_color.clone();
-                    offset = (size.0.dip() - 100.pct() - 3.dip(), size.1 - 3);
-                }
-                .boxed(),
-            );
-            Stack! {
-                children_align = Align::TOP_LEFT;
-                children;
-                size;
-                widget::border = (0, 0, 1, 1), meta_color.map_into();
-                margin = 10;
-            }
-        }),
-    )
-}
-
-fn ruler() -> impl UiNode {
-    Stack! {
-        children_align = Align::LEFT;
-        children = (0..=300).step_by(10)
-            .map(|x| RuleLine! {
-                orientation = LineOrientation::Vertical;
-                color = text::FONT_COLOR_VAR.map(|c| c.with_alpha(40.pct()));
-                layout::x = x.dip();
-                layout::height = if x % 100 == 0 { 52 } else if x % 50 == 0 { 22 } else { 12 };
-            }
-            .boxed())
-            .collect::<Vec<_>>(),
-    }
-}
diff --git a/examples/animation/Cargo.toml b/examples/animation/Cargo.toml
new file mode 100644
index 000000000..023cad3b4
--- /dev/null
+++ b/examples/animation/Cargo.toml
@@ -0,0 +1,8 @@
+[package]
+name = "animation"
+version = "0.0.0"
+publish = false
+edition = "2021"
+
+[dependencies]
+zng = { path = "../../crates/zng", features = ["view_prebuilt"] }
diff --git a/examples/res/screenshots/animation.png b/examples/animation/res/screenshot.png
similarity index 100%
rename from examples/res/screenshots/animation.png
rename to examples/animation/res/screenshot.png
diff --git a/examples/animation/src/main.rs b/examples/animation/src/main.rs
new file mode 100644
index 000000000..360d3e0f6
--- /dev/null
+++ b/examples/animation/src/main.rs
@@ -0,0 +1,141 @@
+//! Demonstrates animation, easing functions.
+
+#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
+
+use zng::{
+    button,
+    color::Rgba,
+    layout::{margin, size},
+    prelude::*,
+    var::animation::easing::EasingFn,
+    var::VARS,
+    widget::{background_color, corner_radius},
+};
+
+mod widgets;
+use widgets::{ease_btn, ruler};
+
+fn main() {
+    zng::env::init!();
+    zng::app::crash_handler::init_debug();
+
+    // let rec = examples_util::record_profile("animation");
+
+    // zng::view_process::prebuilt::run_same_process(app_main);
+    app_main();
+
+    // rec.finish();
+}
+
+fn app_main() {
+    APP.defaults().run_window(async {
+        Window! {
+            title = "Animation Example";
+            padding = 10;
+            child_align = Align::CENTER;
+            child = example();
+        }
+    })
+}
+
+pub(crate) const FROM_COLOR: Rgba = web_colors::RED;
+pub(crate) const TO_COLOR: Rgba = web_colors::GREEN;
+pub(crate) const FPS: u32 = 60;
+
+fn example() -> impl UiNode {
+    // VARS.animation_time_scale().set(0.5.fct());
+    VARS.frame_duration().set((1.0 / FPS as f32).secs());
+
+    let x = var(0.dip());
+    let color = var(FROM_COLOR);
+
+    // x.trace_value(move |v| {
+    //     tracing::debug_span!("x", value = ?v, thread = "<x>").entered()
+    // })
+    // .perm();
+
+    let easing_mod = var(Txt::from("ease_out"));
+
+    Stack! {
+        direction = StackDirection::top_to_bottom();
+        spacing = 10;
+        children_align = Align::TOP;
+        children = ui_vec![
+            Container! {
+                id = "demo";
+                layout::width = 301;
+                widget::background = ruler();
+                margin = (0, 0, 40, 0);
+                child_align = Align::LEFT;
+                child = Wgt! {
+                    id = "ball";
+                    size = (40, 40);
+                    corner_radius = 20;
+                    background_color = color.clone();
+
+                    layout::x = x.map(|x| x.clone() - 20.dip());
+
+                    when *#gesture::is_hovered {
+                        background_color = web_colors::LIME;
+                    }
+                };
+            },
+            Stack! {
+                id = "mod-menu";
+                direction = StackDirection::left_to_right();
+                spacing = 2;
+                toggle::selector = toggle::Selector::single(easing_mod.clone());
+                children = {
+                    let mode = |m: Txt| Toggle! {
+                        child = Text!(m.clone());
+                        value::<Txt> = m;
+                    };
+                    ui_vec![
+                        mode(Txt::from("ease_in")),
+                        mode(Txt::from("ease_out")),
+                        mode(Txt::from("ease_in_out")),
+                        mode(Txt::from("ease_out_in")),
+                        mode(Txt::from("reverse")),
+                        mode(Txt::from("reverse_out")),
+                    ]
+                }
+            },
+            Grid! {
+                id = "easing-menu";
+                spacing = 2;
+                columns = ui_vec![grid::Column!(1.lft()); 7];
+                auto_grow_fn = wgt_fn!(|_| grid::Row!(1.lft()));
+                button::style_fn = Style! { layout::padding = 3 };
+                cells = ui_vec![
+                    ease_btn(&x, &color, "", EasingFn::Linear, &easing_mod),
+                    ease_btn(&x, &color, "", EasingFn::Quad, &easing_mod),
+                    ease_btn(&x, &color, "", EasingFn::Cubic, &easing_mod),
+                    ease_btn(&x, &color, "", EasingFn::Quart, &easing_mod),
+                    ease_btn(&x, &color, "", EasingFn::Quint, &easing_mod),
+                    ease_btn(&x, &color, "", EasingFn::Sine, &easing_mod),
+                    ease_btn(&x, &color, "", EasingFn::Expo, &easing_mod),
+                    ease_btn(&x, &color, "", EasingFn::Circ, &easing_mod),
+                    ease_btn(&x, &color, "", EasingFn::Back, &easing_mod),
+                    ease_btn(&x, &color, "", EasingFn::Elastic, &easing_mod),
+                    ease_btn(&x, &color, "", EasingFn::Bounce, &easing_mod),
+                    ease_btn(&x, &color, "step_ceil(6)", EasingFn::custom(|t| easing::step_ceil(6, t)), &easing_mod),
+                    ease_btn(&x, &color, "step_floor(6)", EasingFn::custom(|t| easing::step_floor(6, t)), &easing_mod),
+                    ease_btn(&x, &color, "", EasingFn::None, &easing_mod),
+                ]
+            },
+            Button! {
+                child = Text!("reset");
+                widget::foreground_highlight = {
+                    offsets: -2,
+                    widths: 1,
+                    sides: web_colors::DARK_RED,
+                };
+                gesture::click_shortcut = shortcut![Escape];
+                on_click = hn!(x, color, |_| {
+                    x.set(0);
+                    color.set(FROM_COLOR);
+                });
+            },
+        ]
+    }
+}
diff --git a/examples/animation/src/widgets.rs b/examples/animation/src/widgets.rs
new file mode 100644
index 000000000..d57d3daa7
--- /dev/null
+++ b/examples/animation/src/widgets.rs
@@ -0,0 +1,138 @@
+use crate::{FPS, FROM_COLOR, TO_COLOR};
+use easing::EasingTime;
+use std::collections::HashMap;
+use window::RenderMode;
+use zng::{
+    image::{self, ImageSource},
+    prelude::*,
+    prelude_wgt::*,
+    var::animation::{self, easing::EasingFn},
+};
+
+pub(crate) fn ease_btn(
+    l: &ArcVar<Length>,
+    color: &ArcVar<Rgba>,
+    name: &'static str,
+    easing: EasingFn,
+    easing_mod: &ArcVar<Txt>,
+) -> impl UiNode {
+    let name = if name.is_empty() { formatx!("{easing:?}") } else { name.to_txt() };
+    let easing = easing_mod.map(clmv!(easing, |m| {
+        let f = match m.as_str() {
+            "ease_in" => easing.clone(),
+            "ease_out" => easing.clone().ease_out(),
+            "ease_in_out" => easing.clone().ease_in_out(),
+            "ease_out_in" => easing.clone().ease_out_in(),
+            "reverse" => easing.clone().reverse(),
+            "reverse_out" => easing.clone().reverse_out(),
+            _ => unreachable!(),
+        };
+        (m.clone(), f)
+    }));
+    let mut plot_cache = HashMap::new(); // to reuse rendered images
+    Button! {
+        grid::cell::at = grid::cell::AT_AUTO;
+        child = Stack! {
+            direction = StackDirection::top_to_bottom();
+            spacing = 2;
+            children_align = Align::TOP;
+            children = ui_vec![
+                Text!(name),
+                Image! {
+                    img_scale_ppi = true;
+                    img_loading_fn = wgt_fn!(|_| Wgt! {
+                        layout::size = (64, 64);
+                        layout::margin = 10;
+                    });
+                    source = easing.map(move |(name, f)| plot_cache.entry(name.clone()).or_insert_with(|| plot(f.clone())).clone());
+                },
+            ]
+        };
+        on_click = hn!(l, color, |_| {
+            // ANIMATION
+            let f = easing.get().1;
+            l.set_ease(0, 300, 1.secs(), f.ease_fn()).perm();
+            color.set_ease(FROM_COLOR, TO_COLOR, 1.secs(), f.ease_fn()).perm();
+        });
+    }
+}
+
+fn plot(easing: EasingFn) -> ImageSource {
+    let size = (64, 64);
+    ImageSource::render_node(
+        RenderMode::Software,
+        clmv!(size, |_| {
+            let mut children = ui_vec![];
+            let color_t = animation::Transition::new(FROM_COLOR, TO_COLOR);
+            let fps_f = FPS as f32;
+            for i in 0..=FPS {
+                let x_fct = (i as f32 / fps_f).fct();
+                let x = size.0 * x_fct;
+
+                let y_fct = easing(EasingTime::new(x_fct));
+                let y = size.1 * (1.fct() - y_fct);
+
+                children.push(
+                    Wgt! {
+                        layout::offset = (x, y);
+                        layout::size = (3, 3);
+                        widget::corner_radius = 2;
+                        layout::translate = -1.5, -1.5;
+                        widget::background_color = color_t.sample(y_fct);
+                    }
+                    .boxed(),
+                )
+            }
+
+            image::IMAGE_RENDER.retain().set(true);
+            let meta_color = WINDOW.vars().actual_color_scheme().map(|t| match t {
+                ColorScheme::Light => rgba(0, 0, 0, 0.4),
+                ColorScheme::Dark => rgba(255, 255, 255, 0.4),
+            });
+
+            #[allow(clippy::precedence)]
+            children.push(
+                Text! {
+                    txt = "v";
+                    font_size = 12;
+                    font_style = FontStyle::Italic;
+                    font_color = meta_color.clone();
+                    layout::offset = (-3.dip() - 100.pct(), -3.dip());
+                }
+                .boxed(),
+            );
+            children.push(
+                Text! {
+                    txt = "t";
+                    font_size = 12;
+                    font_style = FontStyle::Italic;
+                    font_color = meta_color.clone();
+                    layout::offset = (size.0.dip() - 100.pct() - 3.dip(), size.1 - 3);
+                }
+                .boxed(),
+            );
+            Stack! {
+                children_align = Align::TOP_LEFT;
+                children;
+                layout::size;
+                widget::border = (0, 0, 1, 1), meta_color.map_into();
+                layout::margin = 10;
+            }
+        }),
+    )
+}
+
+pub(crate) fn ruler() -> impl UiNode {
+    Stack! {
+        children_align = Align::LEFT;
+        children = (0..=300).step_by(10)
+            .map(|x| zng::rule_line::RuleLine! {
+                orientation = LineOrientation::Vertical;
+                color = text::FONT_COLOR_VAR.map(|c| c.with_alpha(40.pct()));
+                layout::x = x.dip();
+                layout::height = if x % 100 == 0 { 52 } else if x % 50 == 0 { 22 } else { 12 };
+            }
+            .boxed())
+            .collect::<Vec<_>>(),
+    }
+}
diff --git a/examples/border/Cargo.toml b/examples/border/Cargo.toml
new file mode 100644
index 000000000..f201db2aa
--- /dev/null
+++ b/examples/border/Cargo.toml
@@ -0,0 +1,8 @@
+[package]
+name = "border"
+version = "0.0.0"
+publish = false
+edition = "2021"
+
+[dependencies]
+zng = { path = "../../crates/zng", features = ["view_prebuilt"] }
diff --git a/examples/res/screenshots/border.png b/examples/border/res/screenshot.png
similarity index 100%
rename from examples/res/screenshots/border.png
rename to examples/border/res/screenshot.png
diff --git a/examples/border.rs b/examples/border/src/main.rs
similarity index 71%
rename from examples/border.rs
rename to examples/border/src/main.rs
index 42a910548..a6126dbee 100644
--- a/examples/border.rs
+++ b/examples/border/src/main.rs
@@ -6,8 +6,9 @@ use zng::{
     widget::{background_color, border_align, corner_radius},
 };
 
+mod widgets;
+
 fn main() {
-    examples_util::print_info();
     zng::env::init!();
     zng::app::crash_handler::init_debug();
 
@@ -106,47 +107,3 @@ fn clip_to_bounds_demo() -> impl UiNode {
         };
     }
 }
-
-mod widgets {
-    use zng::{
-        container::Container,
-        prelude_wgt::*,
-        widget::{self, border},
-    };
-
-    #[widget($crate::widgets::MrBorders)]
-    pub struct MrBorders(Container);
-    impl MrBorders {
-        fn widget_intrinsic(&mut self) {
-            widget_set! {
-                self;
-                padding = 20;
-
-                child_align = Align::CENTER;
-
-                widget::background_color = web_colors::GREEN.darken(40.pct());
-
-                border0 = 4, colors::WHITE.with_alpha(20.pct());
-                border1 = 4, colors::BLACK.with_alpha(20.pct());
-                border2 = 4, colors::WHITE.with_alpha(20.pct());
-
-                widget::foreground_highlight = 3, 1, web_colors::ORANGE;
-
-                widget::corner_radius = 20;
-            }
-        }
-    }
-
-    #[property(BORDER, default(0, BorderStyle::Hidden), widget_impl(MrBorders))]
-    pub fn border0(child: impl UiNode, widths: impl IntoVar<SideOffsets>, sides: impl IntoVar<BorderSides>) -> impl UiNode {
-        border(child, widths, sides)
-    }
-    #[property(BORDER, default(0, BorderStyle::Hidden), widget_impl(MrBorders))]
-    pub fn border1(child: impl UiNode, widths: impl IntoVar<SideOffsets>, sides: impl IntoVar<BorderSides>) -> impl UiNode {
-        border(child, widths, sides)
-    }
-    #[property(BORDER, default(0, BorderStyle::Hidden), widget_impl(MrBorders))]
-    pub fn border2(child: impl UiNode, widths: impl IntoVar<SideOffsets>, sides: impl IntoVar<BorderSides>) -> impl UiNode {
-        border(child, widths, sides)
-    }
-}
diff --git a/examples/border/src/widgets.rs b/examples/border/src/widgets.rs
new file mode 100644
index 000000000..8ce28b7b3
--- /dev/null
+++ b/examples/border/src/widgets.rs
@@ -0,0 +1,41 @@
+use zng::{
+    container::Container,
+    prelude_wgt::*,
+    widget::{self, border},
+};
+
+#[widget($crate::widgets::MrBorders)]
+pub struct MrBorders(Container);
+impl MrBorders {
+    fn widget_intrinsic(&mut self) {
+        widget_set! {
+            self;
+            padding = 20;
+
+            child_align = Align::CENTER;
+
+            widget::background_color = web_colors::GREEN.darken(40.pct());
+
+            border0 = 4, colors::WHITE.with_alpha(20.pct());
+            border1 = 4, colors::BLACK.with_alpha(20.pct());
+            border2 = 4, colors::WHITE.with_alpha(20.pct());
+
+            widget::foreground_highlight = 3, 1, web_colors::ORANGE;
+
+            widget::corner_radius = 20;
+        }
+    }
+}
+
+#[property(BORDER, default(0, BorderStyle::Hidden), widget_impl(MrBorders))]
+pub fn border0(child: impl UiNode, widths: impl IntoVar<SideOffsets>, sides: impl IntoVar<BorderSides>) -> impl UiNode {
+    border(child, widths, sides)
+}
+#[property(BORDER, default(0, BorderStyle::Hidden), widget_impl(MrBorders))]
+pub fn border1(child: impl UiNode, widths: impl IntoVar<SideOffsets>, sides: impl IntoVar<BorderSides>) -> impl UiNode {
+    border(child, widths, sides)
+}
+#[property(BORDER, default(0, BorderStyle::Hidden), widget_impl(MrBorders))]
+pub fn border2(child: impl UiNode, widths: impl IntoVar<SideOffsets>, sides: impl IntoVar<BorderSides>) -> impl UiNode {
+    border(child, widths, sides)
+}
diff --git a/examples/button/Cargo.toml b/examples/button/Cargo.toml
new file mode 100644
index 000000000..9f2050085
--- /dev/null
+++ b/examples/button/Cargo.toml
@@ -0,0 +1,9 @@
+[package]
+name = "button"
+version = "0.0.0"
+publish = false
+edition = "2021"
+
+[dependencies]
+zng = { path = "../../crates/zng", features = ["view_prebuilt"] }
+tracing = "0.1"
\ No newline at end of file
diff --git a/examples/res/screenshots/button.png b/examples/button/res/screenshot.png
similarity index 100%
rename from examples/res/screenshots/button.png
rename to examples/button/res/screenshot.png
diff --git a/examples/button.rs b/examples/button/src/main.rs
similarity index 99%
rename from examples/button.rs
rename to examples/button/src/main.rs
index f1f5726b0..b4f3f6826 100644
--- a/examples/button.rs
+++ b/examples/button/src/main.rs
@@ -13,7 +13,6 @@ use zng::{
 };
 
 fn main() {
-    examples_util::print_info();
     zng::env::init!();
     zng::app::crash_handler::init_debug();
 
@@ -102,7 +101,7 @@ fn image_button() -> impl UiNode {
         on_click = hn!(|_| tracing::info!("Clicked image button"));
         child_start = {
             node: Image! {
-                source = include_bytes!("res/window/icon-bytes.png");
+                source = include_bytes!("../../window/res/icon-bytes.png");
                 layout::size = 16;
                 align = Align::CENTER;
             },
diff --git a/examples/calculator/Cargo.toml b/examples/calculator/Cargo.toml
new file mode 100644
index 000000000..1e2e3c7d9
--- /dev/null
+++ b/examples/calculator/Cargo.toml
@@ -0,0 +1,9 @@
+[package]
+name = "calculator"
+version = "0.0.0"
+publish = false
+edition = "2021"
+
+[dependencies]
+zng = { path = "../../crates/zng", features = ["view_prebuilt"] }
+mexprp = { version = "0.3", default-features = false }
\ No newline at end of file
diff --git a/examples/res/calculator/notosanssymbols2-regular-subset.ttf b/examples/calculator/res/notosanssymbols2-regular-subset.ttf
similarity index 100%
rename from examples/res/calculator/notosanssymbols2-regular-subset.ttf
rename to examples/calculator/res/notosanssymbols2-regular-subset.ttf
diff --git a/examples/res/screenshots/calculator.png b/examples/calculator/res/screenshot.png
similarity index 100%
rename from examples/res/screenshots/calculator.png
rename to examples/calculator/res/screenshot.png
diff --git a/examples/calculator.rs b/examples/calculator/src/main.rs
similarity index 98%
rename from examples/calculator.rs
rename to examples/calculator/src/main.rs
index 148902aeb..a2e30e23b 100644
--- a/examples/calculator.rs
+++ b/examples/calculator/src/main.rs
@@ -8,7 +8,6 @@ use zng::{
 };
 
 fn main() {
-    examples_util::print_info();
     zng::env::init!();
     zng::app::crash_handler::init_debug();
 
@@ -265,7 +264,7 @@ async fn set_fallback_font() {
     {
         // OS UI and fallback fonts do not support `⌫`, load custom font that does.
 
-        static FALLBACK: &[u8] = include_bytes!("res/calculator/notosanssymbols2-regular-subset.ttf");
+        static FALLBACK: &[u8] = include_bytes!("../res/notosanssymbols2-regular-subset.ttf");
         let fallback = CustomFont::from_bytes("fallback", FontDataRef::from_static(FALLBACK), 0);
 
         FONTS.register(fallback).wait_rsp().await.unwrap();
diff --git a/examples/config/Cargo.toml b/examples/config/Cargo.toml
new file mode 100644
index 000000000..1358ce526
--- /dev/null
+++ b/examples/config/Cargo.toml
@@ -0,0 +1,10 @@
+[package]
+name = "config"
+version = "0.0.0"
+publish = false
+edition = "2021"
+
+[dependencies]
+zng = { path = "../../crates/zng", features = ["view_prebuilt", "material_icons"] }
+tracing = "0.1"
+dunce = "1"
\ No newline at end of file
diff --git a/examples/res/config/defaults.json b/examples/config/res/defaults.json
similarity index 100%
rename from examples/res/config/defaults.json
rename to examples/config/res/defaults.json
diff --git a/examples/res/screenshots/config.png b/examples/config/res/screenshot.png
similarity index 100%
rename from examples/res/screenshots/config.png
rename to examples/config/res/screenshot.png
diff --git a/examples/config.rs b/examples/config/src/main.rs
similarity index 94%
rename from examples/config.rs
rename to examples/config/src/main.rs
index 144295178..f7fbef7f3 100644
--- a/examples/config.rs
+++ b/examples/config/src/main.rs
@@ -13,8 +13,8 @@ use zng::{
 use zng::config::*;
 
 fn main() {
-    examples_util::print_info();
     zng::env::init!();
+    zng::env::init_res(concat!(env!("CARGO_MANIFEST_DIR"), "/res"));
     zng::app::crash_handler::init_debug();
 
     // let rec = examples_util::record_profile("button");
@@ -27,9 +27,9 @@ fn main() {
 
 fn load_config() -> Box<dyn FallbackConfigReset> {
     // config file for the app, keys with prefix "main." are saved here.
-    let user_cfg = JsonConfig::sync("target/tmp/example.config.json");
+    let user_cfg = JsonConfig::sync(zng::env::config("example.config.json"));
     // entries not found in `user_cfg` bind to this file first before going to embedded fallback.
-    let default_cfg = ReadOnlyConfig::new(JsonConfig::sync("examples/res/config/defaults.json"));
+    let default_cfg = ReadOnlyConfig::new(JsonConfig::sync(zng::env::res("defaults.json")));
 
     // the app settings.
     let main_cfg = FallbackConfig::new(user_cfg, default_cfg);
@@ -38,7 +38,7 @@ fn load_config() -> Box<dyn FallbackConfigReset> {
     let main_ref = main_cfg.clone_boxed();
 
     // any other configs (Window::save_state for example)
-    let other_cfg = JsonConfig::sync("target/tmp/example.config.other.json");
+    let other_cfg = JsonConfig::sync(zng::env::config("example.config.other.json"));
 
     CONFIG.load(SwitchConfig::new().with_prefix("main.", main_cfg).with_prefix("", other_cfg));
 
diff --git a/examples/countdown/Cargo.toml b/examples/countdown/Cargo.toml
new file mode 100644
index 000000000..c46645183
--- /dev/null
+++ b/examples/countdown/Cargo.toml
@@ -0,0 +1,9 @@
+[package]
+name = "countdown"
+version = "0.0.0"
+publish = false
+edition = "2021"
+
+[dependencies]
+zng = { path = "../../crates/zng", features = ["view_prebuilt"] }
+tracing = "0.1"
\ No newline at end of file
diff --git a/examples/countdown/res/screenshot.png b/examples/countdown/res/screenshot.png
new file mode 100644
index 000000000..0421fcab5
Binary files /dev/null and b/examples/countdown/res/screenshot.png differ
diff --git a/examples/countdown.rs b/examples/countdown/src/main.rs
similarity index 98%
rename from examples/countdown.rs
rename to examples/countdown/src/main.rs
index 6609b3ec4..7f10ce2e7 100644
--- a/examples/countdown.rs
+++ b/examples/countdown/src/main.rs
@@ -4,7 +4,6 @@
 use zng::{image, prelude::*, widget::background_color};
 
 fn main() {
-    examples_util::print_info();
     zng::env::init!();
     zng::app::crash_handler::init_debug();
     app_main();
diff --git a/examples/cursor.rs b/examples/cursor.rs
deleted file mode 100644
index 6b5331e9d..000000000
--- a/examples/cursor.rs
+++ /dev/null
@@ -1,188 +0,0 @@
-//! Demonstrates each `CursorIcon`, tooltip anchored to cursor.
-
-#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
-
-use zng::{
-    color::{color_scheme_map, filter::invert_color},
-    image::ImageFit,
-    mouse::CursorIcon,
-    prelude::*,
-    prelude_wgt::NilUiNode,
-};
-
-fn main() {
-    examples_util::print_info();
-    zng::env::init!();
-    zng::app::crash_handler::init_debug();
-    app_main();
-}
-
-fn app_main() {
-    APP.defaults().run_window(async {
-        let mut demos = ui_vec![];
-        for icon in CURSORS {
-            demos.push(cursor_demo(Some(*icon)));
-        }
-        for (label, (img, x, y), fallback) in CURSOR_IMGS {
-            demos.push(cursor_img_demo(label, img, (*x, *y), *fallback));
-        }
-
-        Window! {
-            title = "Cursor Example";
-            resizable = false;
-            enabled_buttons = !window::WindowButton::MAXIMIZE;
-            auto_size = true;
-            padding = 20;
-            child = Stack!(top_to_bottom, ui_vec![
-                Grid! {
-                    columns = ui_vec![grid::Column!(1.lft()); 5];
-                    auto_grow_fn = wgt_fn!(|_| grid::Row!(1.lft()));
-                    cells = demos;
-                },
-                cursor_demo(None),
-            ])
-        }
-    })
-}
-
-fn cursor_demo(icon: Option<(CursorIcon, &'static [u8])>) -> impl UiNode {
-    widgets::DemoEntry! {
-        mouse::cursor = match icon {
-            Some(i) => i.0.into(),
-            None => mouse::CursorSource::Hidden
-        };
-
-        widget::background = match icon {
-            Some((_, img)) => Image!{
-                source = img;
-                img_fit = ImageFit::None;
-                invert_color = color_scheme_map(true, false);
-            }.boxed(),
-            None => NilUiNode.boxed(),
-        };
-
-        child = Text! {
-            txt = match icon {
-                Some((ico, _)) => formatx!("{ico:?}"),
-                None => Txt::from_static("<none>"),
-            };
-
-            font_style = match icon {
-                Some(_) => FontStyle::Normal,
-                None => FontStyle::Italic,
-            };
-        };
-    }
-}
-
-fn cursor_img_demo(label: &'static str, img: &'static [u8], hotspot: (i32, i32), fallback: CursorIcon) -> impl UiNode {
-    widgets::DemoEntry! {
-        mouse::cursor = mouse::CursorImg {
-            source: img.into(),
-            hotspot: hotspot.into(),
-            fallback,
-        };
-
-        widget::background = Image! {
-            source = img;
-            img_fit = ImageFit::None;
-        };
-
-        child = Text! {
-            txt = label;
-            font_style = FontStyle::Italic;
-        }
-    }
-}
-
-// (cursor, demo image)
-pub const CURSORS: &[(CursorIcon, &[u8])] = &[
-    (CursorIcon::Default, include_bytes!("res/cursor/default.png")),
-    (CursorIcon::Crosshair, include_bytes!("res/cursor/crosshair.png")),
-    (CursorIcon::Pointer, include_bytes!("res/cursor/pointer.png")),
-    (CursorIcon::Move, include_bytes!("res/cursor/move.png")),
-    (CursorIcon::Text, include_bytes!("res/cursor/text.png")),
-    (CursorIcon::Wait, include_bytes!("res/cursor/wait.png")),
-    (CursorIcon::Help, include_bytes!("res/cursor/help.png")),
-    (CursorIcon::Progress, include_bytes!("res/cursor/progress.png")),
-    (CursorIcon::NotAllowed, include_bytes!("res/cursor/not-allowed.png")),
-    (CursorIcon::ContextMenu, include_bytes!("res/cursor/context-menu.png")),
-    (CursorIcon::Cell, include_bytes!("res/cursor/cell.png")),
-    (CursorIcon::VerticalText, include_bytes!("res/cursor/vertical-text.png")),
-    (CursorIcon::Alias, include_bytes!("res/cursor/alias.png")),
-    (CursorIcon::Copy, include_bytes!("res/cursor/copy.png")),
-    (CursorIcon::NoDrop, include_bytes!("res/cursor/no-drop.png")),
-    (CursorIcon::Grab, include_bytes!("res/cursor/grab.png")),
-    (CursorIcon::Grabbing, include_bytes!("res/cursor/grabbing.png")),
-    (CursorIcon::AllScroll, include_bytes!("res/cursor/all-scroll.png")),
-    (CursorIcon::ZoomIn, include_bytes!("res/cursor/zoom-in.png")),
-    (CursorIcon::ZoomOut, include_bytes!("res/cursor/zoom-out.png")),
-    (CursorIcon::EResize, include_bytes!("res/cursor/e-resize.png")),
-    (CursorIcon::NResize, include_bytes!("res/cursor/n-resize.png")),
-    (CursorIcon::NeResize, include_bytes!("res/cursor/ne-resize.png")),
-    (CursorIcon::NwResize, include_bytes!("res/cursor/nw-resize.png")),
-    (CursorIcon::SResize, include_bytes!("res/cursor/s-resize.png")),
-    (CursorIcon::SeResize, include_bytes!("res/cursor/se-resize.png")),
-    (CursorIcon::SwResize, include_bytes!("res/cursor/sw-resize.png")),
-    (CursorIcon::WResize, include_bytes!("res/cursor/w-resize.png")),
-    (CursorIcon::EwResize, include_bytes!("res/cursor/3-resize.png")),
-    (CursorIcon::NsResize, include_bytes!("res/cursor/6-resize.png")),
-    (CursorIcon::NeswResize, include_bytes!("res/cursor/1-resize.png")),
-    (CursorIcon::NwseResize, include_bytes!("res/cursor/4-resize.png")),
-    (CursorIcon::ColResize, include_bytes!("res/cursor/col-resize.png")),
-    (CursorIcon::RowResize, include_bytes!("res/cursor/row-resize.png")),
-];
-
-// (label, cursor_img, fallback)
-#[allow(clippy::type_complexity)]
-pub const CURSOR_IMGS: &[(&str, (&[u8], i32, i32), CursorIcon)] =
-    &[("custom", (include_bytes!("res/image/RGBA8.png"), 4, 6), CursorIcon::Default)];
-
-mod widgets {
-    use zng::{prelude::*, prelude_wgt::*};
-
-    #[widget($crate::widgets::DemoEntry)]
-    pub struct DemoEntry(Container);
-
-    impl DemoEntry {
-        fn widget_intrinsic(&mut self) {
-            widget_set! {
-                self;
-                grid::cell::at = grid::cell::AT_AUTO;
-
-                layout::size = (150, 80);
-                layout::align = Align::CENTER;
-
-                tooltip = Tip!(Text!("tooltip position"));
-                tip::tooltip_anchor = {
-                    let mut mode = AnchorMode::tooltip();
-                    mode.transform = layer::AnchorTransform::Cursor {
-                        offset: layer::AnchorOffset::out_bottom_in_left(),
-                        include_touch: true,
-                        bounds: None,
-                    };
-                    mode
-                };
-                tip::tooltip_delay = 0.ms();
-
-                layout::margin = 1;
-                widget::background_color = color_scheme_map(colors::BLACK, colors::WHITE);
-
-                #[easing(150.ms())]
-                text::font_color = color_scheme_map(rgb(140, 140, 140), rgb(115, 115, 115));
-
-                when *#gesture::is_hovered {
-                    #[easing(0.ms())]
-                    text::font_color = color_scheme_map(colors::WHITE, colors::BLACK);
-                }
-
-                text::font_family = "monospace";
-                text::font_size = 16;
-                text::font_weight = FontWeight::BOLD;
-
-                child_align = Align::TOP_LEFT;
-                padding = (2, 5);
-            }
-        }
-    }
-}
diff --git a/examples/cursor/Cargo.toml b/examples/cursor/Cargo.toml
new file mode 100644
index 000000000..d050e8dbf
--- /dev/null
+++ b/examples/cursor/Cargo.toml
@@ -0,0 +1,8 @@
+[package]
+name = "cursor"
+version = "0.0.0"
+publish = false
+edition = "2021"
+
+[dependencies]
+zng = { path = "../../crates/zng", features = ["view_prebuilt"] }
diff --git a/examples/res/cursor/1-resize.png b/examples/cursor/res/1-resize.png
similarity index 100%
rename from examples/res/cursor/1-resize.png
rename to examples/cursor/res/1-resize.png
diff --git a/examples/res/cursor/3-resize.png b/examples/cursor/res/3-resize.png
similarity index 100%
rename from examples/res/cursor/3-resize.png
rename to examples/cursor/res/3-resize.png
diff --git a/examples/res/cursor/4-resize.png b/examples/cursor/res/4-resize.png
similarity index 100%
rename from examples/res/cursor/4-resize.png
rename to examples/cursor/res/4-resize.png
diff --git a/examples/res/cursor/6-resize.png b/examples/cursor/res/6-resize.png
similarity index 100%
rename from examples/res/cursor/6-resize.png
rename to examples/cursor/res/6-resize.png
diff --git a/examples/res/cursor/alias.png b/examples/cursor/res/alias.png
similarity index 100%
rename from examples/res/cursor/alias.png
rename to examples/cursor/res/alias.png
diff --git a/examples/res/cursor/all-scroll.png b/examples/cursor/res/all-scroll.png
similarity index 100%
rename from examples/res/cursor/all-scroll.png
rename to examples/cursor/res/all-scroll.png
diff --git a/examples/res/cursor/cell.png b/examples/cursor/res/cell.png
similarity index 100%
rename from examples/res/cursor/cell.png
rename to examples/cursor/res/cell.png
diff --git a/examples/res/cursor/col-resize.png b/examples/cursor/res/col-resize.png
similarity index 100%
rename from examples/res/cursor/col-resize.png
rename to examples/cursor/res/col-resize.png
diff --git a/examples/res/cursor/context-menu.png b/examples/cursor/res/context-menu.png
similarity index 100%
rename from examples/res/cursor/context-menu.png
rename to examples/cursor/res/context-menu.png
diff --git a/examples/res/cursor/copy.png b/examples/cursor/res/copy.png
similarity index 100%
rename from examples/res/cursor/copy.png
rename to examples/cursor/res/copy.png
diff --git a/examples/res/cursor/crosshair.png b/examples/cursor/res/crosshair.png
similarity index 100%
rename from examples/res/cursor/crosshair.png
rename to examples/cursor/res/crosshair.png
diff --git a/examples/res/cursor/default.png b/examples/cursor/res/default.png
similarity index 100%
rename from examples/res/cursor/default.png
rename to examples/cursor/res/default.png
diff --git a/examples/res/cursor/e-resize.png b/examples/cursor/res/e-resize.png
similarity index 100%
rename from examples/res/cursor/e-resize.png
rename to examples/cursor/res/e-resize.png
diff --git a/examples/res/cursor/grab.png b/examples/cursor/res/grab.png
similarity index 100%
rename from examples/res/cursor/grab.png
rename to examples/cursor/res/grab.png
diff --git a/examples/res/cursor/grabbing.png b/examples/cursor/res/grabbing.png
similarity index 100%
rename from examples/res/cursor/grabbing.png
rename to examples/cursor/res/grabbing.png
diff --git a/examples/res/cursor/help.png b/examples/cursor/res/help.png
similarity index 100%
rename from examples/res/cursor/help.png
rename to examples/cursor/res/help.png
diff --git a/examples/res/cursor/move.png b/examples/cursor/res/move.png
similarity index 100%
rename from examples/res/cursor/move.png
rename to examples/cursor/res/move.png
diff --git a/examples/res/cursor/n-resize.png b/examples/cursor/res/n-resize.png
similarity index 100%
rename from examples/res/cursor/n-resize.png
rename to examples/cursor/res/n-resize.png
diff --git a/examples/res/cursor/ne-resize.png b/examples/cursor/res/ne-resize.png
similarity index 100%
rename from examples/res/cursor/ne-resize.png
rename to examples/cursor/res/ne-resize.png
diff --git a/examples/res/cursor/no-drop.png b/examples/cursor/res/no-drop.png
similarity index 100%
rename from examples/res/cursor/no-drop.png
rename to examples/cursor/res/no-drop.png
diff --git a/examples/res/cursor/not-allowed.png b/examples/cursor/res/not-allowed.png
similarity index 100%
rename from examples/res/cursor/not-allowed.png
rename to examples/cursor/res/not-allowed.png
diff --git a/examples/res/cursor/nw-resize.png b/examples/cursor/res/nw-resize.png
similarity index 100%
rename from examples/res/cursor/nw-resize.png
rename to examples/cursor/res/nw-resize.png
diff --git a/examples/res/cursor/pointer.png b/examples/cursor/res/pointer.png
similarity index 100%
rename from examples/res/cursor/pointer.png
rename to examples/cursor/res/pointer.png
diff --git a/examples/res/cursor/progress.png b/examples/cursor/res/progress.png
similarity index 100%
rename from examples/res/cursor/progress.png
rename to examples/cursor/res/progress.png
diff --git a/examples/res/cursor/row-resize.png b/examples/cursor/res/row-resize.png
similarity index 100%
rename from examples/res/cursor/row-resize.png
rename to examples/cursor/res/row-resize.png
diff --git a/examples/res/cursor/s-resize.png b/examples/cursor/res/s-resize.png
similarity index 100%
rename from examples/res/cursor/s-resize.png
rename to examples/cursor/res/s-resize.png
diff --git a/examples/res/screenshots/cursor.png b/examples/cursor/res/screenshot.png
similarity index 100%
rename from examples/res/screenshots/cursor.png
rename to examples/cursor/res/screenshot.png
diff --git a/examples/res/cursor/se-resize.png b/examples/cursor/res/se-resize.png
similarity index 100%
rename from examples/res/cursor/se-resize.png
rename to examples/cursor/res/se-resize.png
diff --git a/examples/res/cursor/sw-resize.png b/examples/cursor/res/sw-resize.png
similarity index 100%
rename from examples/res/cursor/sw-resize.png
rename to examples/cursor/res/sw-resize.png
diff --git a/examples/res/cursor/text.png b/examples/cursor/res/text.png
similarity index 100%
rename from examples/res/cursor/text.png
rename to examples/cursor/res/text.png
diff --git a/examples/res/cursor/vertical-text.png b/examples/cursor/res/vertical-text.png
similarity index 100%
rename from examples/res/cursor/vertical-text.png
rename to examples/cursor/res/vertical-text.png
diff --git a/examples/res/cursor/w-resize.png b/examples/cursor/res/w-resize.png
similarity index 100%
rename from examples/res/cursor/w-resize.png
rename to examples/cursor/res/w-resize.png
diff --git a/examples/res/cursor/wait.png b/examples/cursor/res/wait.png
similarity index 100%
rename from examples/res/cursor/wait.png
rename to examples/cursor/res/wait.png
diff --git a/examples/res/cursor/zoom-in.png b/examples/cursor/res/zoom-in.png
similarity index 100%
rename from examples/res/cursor/zoom-in.png
rename to examples/cursor/res/zoom-in.png
diff --git a/examples/res/cursor/zoom-out.png b/examples/cursor/res/zoom-out.png
similarity index 100%
rename from examples/res/cursor/zoom-out.png
rename to examples/cursor/res/zoom-out.png
diff --git a/examples/cursor/src/main.rs b/examples/cursor/src/main.rs
new file mode 100644
index 000000000..a0eb393b4
--- /dev/null
+++ b/examples/cursor/src/main.rs
@@ -0,0 +1,140 @@
+//! Demonstrates each `CursorIcon`, tooltip anchored to cursor.
+
+#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
+
+use zng::{
+    color::{color_scheme_map, filter::invert_color},
+    image::ImageFit,
+    mouse::CursorIcon,
+    prelude::*,
+    prelude_wgt::NilUiNode,
+};
+
+mod widgets;
+
+fn main() {
+    zng::env::init!();
+    zng::app::crash_handler::init_debug();
+    app_main();
+}
+
+fn app_main() {
+    APP.defaults().run_window(async {
+        let mut demos = ui_vec![];
+        for icon in CURSORS {
+            demos.push(cursor_demo(Some(*icon)));
+        }
+        for (label, (img, x, y), fallback) in CURSOR_IMGS {
+            demos.push(cursor_img_demo(label, img, (*x, *y), *fallback));
+        }
+
+        Window! {
+            title = "Cursor Example";
+            resizable = false;
+            enabled_buttons = !window::WindowButton::MAXIMIZE;
+            auto_size = true;
+            padding = 20;
+            child = Stack!(top_to_bottom, ui_vec![
+                Grid! {
+                    columns = ui_vec![grid::Column!(1.lft()); 5];
+                    auto_grow_fn = wgt_fn!(|_| grid::Row!(1.lft()));
+                    cells = demos;
+                },
+                cursor_demo(None),
+            ])
+        }
+    })
+}
+
+fn cursor_demo(icon: Option<(CursorIcon, &'static [u8])>) -> impl UiNode {
+    widgets::DemoEntry! {
+        mouse::cursor = match icon {
+            Some(i) => i.0.into(),
+            None => mouse::CursorSource::Hidden
+        };
+
+        widget::background = match icon {
+            Some((_, img)) => Image!{
+                source = img;
+                img_fit = ImageFit::None;
+                invert_color = color_scheme_map(true, false);
+            }.boxed(),
+            None => NilUiNode.boxed(),
+        };
+
+        child = Text! {
+            txt = match icon {
+                Some((ico, _)) => formatx!("{ico:?}"),
+                None => Txt::from_static("<none>"),
+            };
+
+            font_style = match icon {
+                Some(_) => FontStyle::Normal,
+                None => FontStyle::Italic,
+            };
+        };
+    }
+}
+
+fn cursor_img_demo(label: &'static str, img: &'static [u8], hotspot: (i32, i32), fallback: CursorIcon) -> impl UiNode {
+    widgets::DemoEntry! {
+        mouse::cursor = mouse::CursorImg {
+            source: img.into(),
+            hotspot: hotspot.into(),
+            fallback,
+        };
+
+        widget::background = Image! {
+            source = img;
+            img_fit = ImageFit::None;
+        };
+
+        child = Text! {
+            txt = label;
+            font_style = FontStyle::Italic;
+        }
+    }
+}
+
+// (cursor, demo image)
+pub const CURSORS: &[(CursorIcon, &[u8])] = &[
+    (CursorIcon::Default, include_bytes!("../res/default.png")),
+    (CursorIcon::Crosshair, include_bytes!("../res/crosshair.png")),
+    (CursorIcon::Pointer, include_bytes!("../res/pointer.png")),
+    (CursorIcon::Move, include_bytes!("../res/move.png")),
+    (CursorIcon::Text, include_bytes!("../res/text.png")),
+    (CursorIcon::Wait, include_bytes!("../res/wait.png")),
+    (CursorIcon::Help, include_bytes!("../res/help.png")),
+    (CursorIcon::Progress, include_bytes!("../res/progress.png")),
+    (CursorIcon::NotAllowed, include_bytes!("../res/not-allowed.png")),
+    (CursorIcon::ContextMenu, include_bytes!("../res/context-menu.png")),
+    (CursorIcon::Cell, include_bytes!("../res/cell.png")),
+    (CursorIcon::VerticalText, include_bytes!("../res/vertical-text.png")),
+    (CursorIcon::Alias, include_bytes!("../res/alias.png")),
+    (CursorIcon::Copy, include_bytes!("../res/copy.png")),
+    (CursorIcon::NoDrop, include_bytes!("../res/no-drop.png")),
+    (CursorIcon::Grab, include_bytes!("../res/grab.png")),
+    (CursorIcon::Grabbing, include_bytes!("../res/grabbing.png")),
+    (CursorIcon::AllScroll, include_bytes!("../res/all-scroll.png")),
+    (CursorIcon::ZoomIn, include_bytes!("../res/zoom-in.png")),
+    (CursorIcon::ZoomOut, include_bytes!("../res/zoom-out.png")),
+    (CursorIcon::EResize, include_bytes!("../res/e-resize.png")),
+    (CursorIcon::NResize, include_bytes!("../res/n-resize.png")),
+    (CursorIcon::NeResize, include_bytes!("../res/ne-resize.png")),
+    (CursorIcon::NwResize, include_bytes!("../res/nw-resize.png")),
+    (CursorIcon::SResize, include_bytes!("../res/s-resize.png")),
+    (CursorIcon::SeResize, include_bytes!("../res/se-resize.png")),
+    (CursorIcon::SwResize, include_bytes!("../res/sw-resize.png")),
+    (CursorIcon::WResize, include_bytes!("../res/w-resize.png")),
+    (CursorIcon::EwResize, include_bytes!("../res/3-resize.png")),
+    (CursorIcon::NsResize, include_bytes!("../res/6-resize.png")),
+    (CursorIcon::NeswResize, include_bytes!("../res/1-resize.png")),
+    (CursorIcon::NwseResize, include_bytes!("../res/4-resize.png")),
+    (CursorIcon::ColResize, include_bytes!("../res/col-resize.png")),
+    (CursorIcon::RowResize, include_bytes!("../res/row-resize.png")),
+];
+
+// (label, cursor_img, fallback)
+#[allow(clippy::type_complexity)]
+pub const CURSOR_IMGS: &[(&str, (&[u8], i32, i32), CursorIcon)] =
+    &[("custom", (include_bytes!("../../image/res/RGBA8.png"), 4, 6), CursorIcon::Default)];
diff --git a/examples/cursor/src/widgets.rs b/examples/cursor/src/widgets.rs
new file mode 100644
index 000000000..82a971663
--- /dev/null
+++ b/examples/cursor/src/widgets.rs
@@ -0,0 +1,46 @@
+use zng::{prelude::*, prelude_wgt::*};
+
+#[widget($crate::widgets::DemoEntry)]
+pub struct DemoEntry(Container);
+
+impl DemoEntry {
+    fn widget_intrinsic(&mut self) {
+        widget_set! {
+            self;
+            grid::cell::at = grid::cell::AT_AUTO;
+
+            layout::size = (150, 80);
+            layout::align = Align::CENTER;
+
+            tooltip = Tip!(Text!("tooltip position"));
+            tip::tooltip_anchor = {
+                let mut mode = AnchorMode::tooltip();
+                mode.transform = layer::AnchorTransform::Cursor {
+                    offset: layer::AnchorOffset::out_bottom_in_left(),
+                    include_touch: true,
+                    bounds: None,
+                };
+                mode
+            };
+            tip::tooltip_delay = 0.ms();
+
+            layout::margin = 1;
+            widget::background_color = color_scheme_map(colors::BLACK, colors::WHITE);
+
+            #[easing(150.ms())]
+            text::font_color = color_scheme_map(rgb(140, 140, 140), rgb(115, 115, 115));
+
+            when *#gesture::is_hovered {
+                #[easing(0.ms())]
+                text::font_color = color_scheme_map(colors::WHITE, colors::BLACK);
+            }
+
+            text::font_family = "monospace";
+            text::font_size = 16;
+            text::font_weight = FontWeight::BOLD;
+
+            child_align = Align::TOP_LEFT;
+            padding = (2, 5);
+        }
+    }
+}
diff --git a/examples/extend-view/Cargo.toml b/examples/extend-view/Cargo.toml
new file mode 100644
index 000000000..cf46b3181
--- /dev/null
+++ b/examples/extend-view/Cargo.toml
@@ -0,0 +1,15 @@
+[package]
+name = "extend-view"
+version = "0.0.0"
+publish = false
+edition = "2021"
+
+[dependencies]
+zng = { path = "../../crates/zng", features = ["view"] }
+zng-view = { path = "../../crates/zng-view" }
+zng-view-api = { path = "../../crates/zng-view-api" }
+zng-app = { path = "../../crates/zng-app" }
+tracing = "0.1"
+serde = { version = "1.0", features = ["derive"] }
+raw-window-handle = "0.6"
+linkme = "0.3"
\ No newline at end of file
diff --git a/examples/extend-view/res/screenshot.png b/examples/extend-view/res/screenshot.png
new file mode 100644
index 000000000..0421fcab5
Binary files /dev/null and b/examples/extend-view/res/screenshot.png differ
diff --git a/examples/extend-view/src/get_window_handle.rs b/examples/extend-view/src/get_window_handle.rs
new file mode 100644
index 000000000..a176eff49
--- /dev/null
+++ b/examples/extend-view/src/get_window_handle.rs
@@ -0,0 +1,93 @@
+//! Demo view extension window.
+
+/// App-process stuff.
+pub mod app_side {
+    use zng::prelude::*;
+    use zng_app::view_process::VIEW_PROCESS;
+    use zng_view_api::api_extension::ApiExtensionId;
+
+    /// Get the raw-window-handle formatted to text.
+    ///
+    /// This sends a custom command to the view-process (implemented in `super::view_side`), the view-process
+    /// uses a WindowExtension to access the raw-window-handle and format it to text as a basic example.
+    pub fn get_window_handle(win_id: WindowId) -> Option<Txt> {
+        match WINDOWS.view_window_extension::<_, super::api::Response>(
+            win_id,
+            self::extension_id(),
+            &super::api::Request { alternate: false },
+        ) {
+            Ok(r) => Some(r.handle_txt),
+            Err(e) => {
+                tracing::error!("failed to get extension response, {e}");
+                None
+            }
+        }
+    }
+
+    pub fn extension_id() -> ApiExtensionId {
+        VIEW_PROCESS
+            .extension_id(super::api::extension_name())
+            .ok()
+            .flatten()
+            .unwrap_or(ApiExtensionId::INVALID)
+    }
+}
+
+/// View-process stuff, the actual extension.
+pub mod view_side {
+    use zng::text::formatx;
+    use zng_view::extensions::{ViewExtensions, WindowExtension};
+    use zng_view_api::api_extension::{ApiExtensionId, ApiExtensionPayload};
+
+    pub fn extend(exts: &mut ViewExtensions) {
+        exts.window(super::api::extension_name(), CustomExtension::new);
+    }
+
+    struct CustomExtension {
+        id: ApiExtensionId,
+    }
+    impl CustomExtension {
+        fn new(id: ApiExtensionId) -> Self {
+            Self { id }
+        }
+    }
+    impl WindowExtension for CustomExtension {
+        fn is_init_only(&self) -> bool {
+            false
+        }
+
+        fn command(&mut self, args: &mut zng_view::extensions::WindowCommandArgs) -> ApiExtensionPayload {
+            match args.request.deserialize::<super::api::Request>() {
+                Ok(r) => {
+                    let h = raw_window_handle::HasWindowHandle::window_handle(args.window).unwrap();
+                    ApiExtensionPayload::serialize(&super::api::Response {
+                        // note that you should only use the window handle in the view-process side.
+                        handle_txt: if r.alternate { formatx!("{h:#?}") } else { formatx!("{h:?}") },
+                    })
+                    .unwrap()
+                }
+                Err(e) => ApiExtensionPayload::invalid_request(self.id, format_args!("invalid command request, {e}")),
+            }
+        }
+    }
+}
+
+/// Shared types.
+pub mod api {
+    use zng::text::Txt;
+    use zng_view_api::api_extension::ApiExtensionName;
+
+    pub fn extension_name() -> ApiExtensionName {
+        ApiExtensionName::new("zng.examples.extend_renderer.get_window_handle").unwrap()
+    }
+
+    #[derive(serde::Serialize, serde::Deserialize)]
+    pub struct Request {
+        pub alternate: bool,
+    }
+
+    #[derive(serde::Serialize, serde::Deserialize)]
+    pub struct Response {
+        pub handle_txt: Txt,
+    }
+}
diff --git a/examples/extend-view/src/main.rs b/examples/extend-view/src/main.rs
new file mode 100644
index 000000000..426822059
--- /dev/null
+++ b/examples/extend-view/src/main.rs
@@ -0,0 +1,132 @@
+//! Demonstrates the `zng-view` extension API and render extensions API.
+
+use zng::{color::filter::hue_rotate, layout::size, prelude::*};
+use zng_view::extensions::ViewExtensions;
+// use zng_wgt_webrender_debug as wr;
+
+// Examples of how to extend the view-process with custom renderers.
+//
+// This is an advanced API, use it only if you really can't render the effect you want
+// using custom nodes/properties.
+
+fn main() {
+    zng::env::init!();
+    zng::app::crash_handler::init_debug();
+
+    // zng_view::run_same_process_extended(app_main, view_extensions);
+    app_main();
+}
+
+zng_view::view_process_extension!(view_extensions);
+
+/// Called in the view-process to init extensions.
+fn view_extensions() -> ViewExtensions {
+    let mut exts = ViewExtensions::new();
+    get_window_handle::view_side::extend(&mut exts);
+    using_display_items::view_side::extend(&mut exts);
+    using_blob::view_side::extend(&mut exts);
+    using_gl_overlay::view_side::extend(&mut exts);
+    using_gl_texture::view_side::extend(&mut exts);
+    exts
+}
+
+mod get_window_handle;
+mod using_blob;
+mod using_display_items;
+mod using_gl_overlay;
+mod using_gl_texture;
+
+fn app_main() {
+    APP.defaults().run_window(async {
+        Window! {
+            // wr::renderer_debug = {
+            //     wr::DebugFlags::TEXTURE_CACHE_DBG | wr::DebugFlags::TEXTURE_CACHE_DBG_CLEAR_EVICTED
+            // };
+
+            title = "Extend-View Example";
+            width = 900;
+
+            on_frame_image_ready = hn_once!(|_| {
+                let h = get_window_handle::app_side::get_window_handle(WINDOW.id()).unwrap();
+                tracing::info!("RAW-WINDOW-HANDLE: {h}");
+            });
+
+            child = Stack! {
+                children_align = Align::CENTER;
+                direction = StackDirection::left_to_right();
+                spacing = 20;
+
+                children = ui_vec![
+                    Stack! {
+                        direction = StackDirection::top_to_bottom();
+                        children_align = Align::CENTER;
+                        spacing = 5;
+                        children = ui_vec![
+                            Text!("Using Display Items"),
+                            Container! {
+                                size = 30.vmin_pct();
+                                child = using_display_items::app_side::custom_render_node();
+                            },
+                            Container! {
+                                size = 30.vmin_pct();
+                                hue_rotate = 180.deg();
+                                child = using_display_items::app_side::custom_render_node();
+                            },
+                        ]
+                    },
+                    Stack! {
+                        direction = StackDirection::top_to_bottom();
+                        children_align = Align::CENTER;
+                        spacing = 5;
+                        children = ui_vec![
+                            Text!("Using Blob Images"),
+                            Container! {
+                                size = 30.vmin_pct();
+                                child = using_blob::app_side::custom_render_node();
+                            },
+                            Container! {
+                                size = 30.vmin_pct();
+                                hue_rotate = 180.deg();
+                                child = using_blob::app_side::custom_render_node();
+                            },
+                        ]
+                    },
+                    Stack! {
+                        direction = StackDirection::top_to_bottom();
+                        children_align = Align::CENTER;
+                        spacing = 5;
+                        children = ui_vec![
+                            Text!("Using GL Overlay"),
+                            Container! {
+                                size = 30.vmin_pct();
+                                child = using_gl_overlay::app_side::custom_render_node();
+                            },
+                            Container! {
+                                size = 30.vmin_pct();
+                                hue_rotate = 180.deg(); // no effect
+                                child = using_gl_overlay::app_side::custom_render_node();
+                            },
+                        ]
+                    },
+                    Stack! {
+                        direction = StackDirection::top_to_bottom();
+                        children_align = Align::CENTER;
+                        spacing = 5;
+                        children = ui_vec![
+                            Text!("Using GL Texture"),
+                            Container! {
+                                size = 30.vmin_pct();
+                                child = using_gl_texture::app_side::custom_render_node();
+                            },
+                            Container! {
+                                size = 30.vmin_pct();
+                                hue_rotate = 180.deg();
+                                child = using_gl_texture::app_side::custom_render_node();
+                            },
+                        ]
+                    },
+                ]
+            }
+        }
+    })
+}
diff --git a/examples/extend-view/src/using_blob.rs b/examples/extend-view/src/using_blob.rs
new file mode 100644
index 000000000..e3857439e
--- /dev/null
+++ b/examples/extend-view/src/using_blob.rs
@@ -0,0 +1,435 @@
+//! Demo view extension custom renderer, integrated with Webrender using the blob API.
+
+/// App-process stuff, nodes.
+pub mod app_side {
+    use zng::prelude::UiNode;
+    use zng_app::view_process::VIEW_PROCESS;
+    use zng_view_api::api_extension::ApiExtensionId;
+
+    /// Node that sends external display item and updates.
+    pub fn custom_render_node() -> impl UiNode {
+        crate::using_display_items::app_side::custom_ext_node(extension_id)
+    }
+
+    pub fn extension_id() -> ApiExtensionId {
+        VIEW_PROCESS
+            .extension_id(super::api::extension_name())
+            .ok()
+            .flatten()
+            .unwrap_or(ApiExtensionId::INVALID)
+    }
+}
+
+/// View-process stuff, the actual extension.
+pub mod view_side {
+    use std::{collections::HashMap, sync::Arc};
+
+    use zng::layout::{PxPoint, PxSize};
+    use zng::prelude::task::parking_lot::Mutex;
+    use zng_view::extensions::PxToWr as _;
+    use zng_view::{
+        extensions::{AsyncBlobRasterizer, BlobExtension, RenderItemArgs, RenderUpdateArgs, RendererExtension, ViewExtensions},
+        webrender::{
+            api::{
+                units::{BlobDirtyRect, DeviceIntRect, DeviceIntSize, LayoutRect},
+                BlobImageError, BlobImageKey, BlobImageParams, BlobImageResult, ColorF, CommonItemProperties, ImageDescriptor,
+                ImageDescriptorFlags, ImageFormat, RasterizedBlobImage,
+            },
+            euclid,
+        },
+    };
+    use zng_view_api::api_extension::ApiExtensionId;
+
+    pub fn extend(exts: &mut ViewExtensions) {
+        exts.renderer(super::api::extension_name(), CustomExtension::new);
+    }
+
+    struct CustomExtension {
+        // id of this extension, for tracing.
+        _id: ApiExtensionId,
+        // renderer, shared between blob extensions.
+        renderer: Arc<Mutex<CustomRenderer>>,
+    }
+    impl CustomExtension {
+        fn new(id: ApiExtensionId) -> Self {
+            Self {
+                _id: id,
+                renderer: Arc::default(),
+            }
+        }
+    }
+    impl RendererExtension for CustomExtension {
+        fn is_init_only(&self) -> bool {
+            false // retain the extension after renderer creation.
+        }
+
+        fn configure(&mut self, args: &mut zng_view::extensions::RendererConfigArgs) {
+            // Blob entry point inside Webrender.
+            args.blobs.push(Box::new(CustomBlobExtension {
+                renderer: Arc::clone(&self.renderer),
+            }));
+
+            // Worker threads will be used during rasterization.
+            //
+            // This option is always already set by the window. Note that this thread pool
+            // is also used by Webrender's glyph rasterizer, so be careful not to clog it.
+            self.renderer.lock().workers.clone_from(&args.options.workers);
+        }
+
+        fn render_start(&mut self, _: &mut zng_view::extensions::RenderArgs) {
+            let mut renderer = self.renderer.lock();
+            for t in renderer.tasks.iter_mut() {
+                if matches!(t.state, CustomRenderTaskState::Used) {
+                    t.state = CustomRenderTaskState::Marked;
+                }
+            }
+        }
+
+        fn render_end(&mut self, args: &mut zng_view::extensions::RenderArgs) {
+            let mut renderer = self.renderer.lock();
+            for t in renderer.tasks.iter_mut() {
+                match &mut t.state {
+                    CustomRenderTaskState::Marked => t.state = CustomRenderTaskState::Free(0),
+                    CustomRenderTaskState::Free(n) if *n < MAX_FREE => {
+                        *n += 1;
+                        if *n == MAX_FREE {
+                            *n = MAX_FREE + 1;
+                            args.transaction.delete_blob_image(t.key)
+                        }
+                    }
+                    _ => {}
+                }
+            }
+        }
+
+        fn render_push(&mut self, args: &mut RenderItemArgs) {
+            match args.payload.deserialize::<super::api::RenderPayload>() {
+                Ok(p) => {
+                    let mut renderer = self.renderer.lock();
+                    let renderer = &mut *renderer;
+
+                    let param = if let Some(binding) = p.cursor_binding {
+                        // updatable item, gets own image
+                        CustomTaskParams::Bound(binding)
+                    } else {
+                        // not updatable item, shares images of same params
+                        CustomTaskParams::Params(p.size, p.cursor)
+                    };
+
+                    let mut key = None;
+                    if let Some(i) = renderer.task_params.get(&param) {
+                        let t = &mut renderer.tasks[*i];
+                        if matches!(t.state, CustomRenderTaskState::Marked | CustomRenderTaskState::Used) {
+                            // already rendering param
+                            key = Some(t.key);
+                            t.size = p.size;
+                            t.state = CustomRenderTaskState::Used;
+                        }
+                    }
+                    let blob_key = if let Some(k) = key {
+                        k
+                    } else {
+                        // start rendering param
+                        //
+                        // the renderer will receive an async rasterize request from Webrender
+                        // that is when we will actually render this.
+
+                        let key = if let Some(i) = renderer
+                            .tasks
+                            .iter()
+                            .position(|t| matches!(t.state, CustomRenderTaskState::Free(n) if n < MAX_FREE))
+                        {
+                            // reuse blob key
+
+                            let t = &mut renderer.tasks[i];
+                            renderer.task_params.remove(&t.param());
+
+                            if t.size != p.size {
+                                let size = DeviceIntSize::new(p.size.width.0, p.size.height.0);
+                                args.transaction.update_blob_image(
+                                    t.key,
+                                    ImageDescriptor {
+                                        format: ImageFormat::BGRA8,
+                                        size,
+                                        stride: None,
+                                        offset: 0,
+                                        flags: ImageDescriptorFlags::IS_OPAQUE,
+                                    },
+                                    // we only need the params (size, cursor),
+                                    // this can be used to store render commands.
+                                    Arc::new(vec![]),
+                                    DeviceIntRect::from_size(size),
+                                    &BlobDirtyRect::All,
+                                );
+                            }
+
+                            t.size = p.size;
+                            t.cursor = p.cursor;
+                            t.cursor_binding = p.cursor_binding;
+                            t.state = CustomRenderTaskState::Used;
+
+                            renderer.task_params.insert(t.param(), i);
+
+                            t.key
+                        } else {
+                            // new blob key
+
+                            let i = renderer.tasks.len();
+
+                            let key = args.api.generate_blob_image_key();
+                            let task = CustomRenderTask {
+                                key,
+                                size: p.size,
+                                cursor: p.cursor,
+                                cursor_binding: p.cursor_binding,
+                                state: CustomRenderTaskState::Used,
+                            };
+                            renderer.tasks.push(task);
+                            renderer.task_keys.insert(key, i);
+                            renderer.task_params.insert(param, i);
+
+                            let size = DeviceIntSize::new(p.size.width.0, p.size.height.0);
+                            args.transaction.add_blob_image(
+                                key,
+                                ImageDescriptor {
+                                    format: ImageFormat::BGRA8,
+                                    size,
+                                    stride: None,
+                                    offset: 0,
+                                    flags: ImageDescriptorFlags::IS_OPAQUE,
+                                },
+                                // we only need the params (size, cursor),
+                                // this can be used to store render commands.
+                                Arc::new(vec![]),
+                                DeviceIntRect::from_size(size),
+                                Some(128),
+                            );
+
+                            key
+                        };
+
+                        key
+                    };
+
+                    let rect = LayoutRect::from_size(p.size.to_wr());
+                    let _cursor = p.cursor.to_wr();
+
+                    let props = CommonItemProperties {
+                        clip_rect: rect,
+                        clip_chain_id: args.sc.clip_chain_id(args.list),
+                        spatial_id: args.sc.spatial_id(),
+                        flags: args.sc.primitive_flags(),
+                    };
+                    args.list.push_image(
+                        &props,
+                        rect,
+                        zng_view::webrender::api::ImageRendering::Auto,
+                        zng_view::webrender::api::AlphaType::Alpha,
+                        blob_key.as_image(),
+                        ColorF::WHITE,
+                    )
+                }
+                Err(e) => tracing::error!("invalid display item, {e}"),
+            }
+        }
+
+        fn render_update(&mut self, args: &mut RenderUpdateArgs) {
+            match args.payload.deserialize::<super::api::RenderUpdatePayload>() {
+                Ok(p) => {
+                    let mut renderer = self.renderer.lock();
+                    let renderer = &mut *renderer;
+
+                    if let Some(&i) = renderer.task_params.get(&CustomTaskParams::Bound(p.cursor_binding)) {
+                        // update the render task
+
+                        let t = &mut renderer.tasks[i];
+                        t.cursor = p.cursor;
+
+                        let size = DeviceIntSize::new(t.size.width.0, t.size.height.0);
+                        args.transaction.update_blob_image(
+                            t.key,
+                            ImageDescriptor {
+                                format: ImageFormat::BGRA8,
+                                size,
+                                stride: None,
+                                offset: 0,
+                                flags: ImageDescriptorFlags::IS_OPAQUE,
+                            },
+                            Arc::new(vec![]),
+                            DeviceIntRect::from_size(size),
+                            &BlobDirtyRect::All,
+                        );
+                    } else {
+                        // or rebuilds the display list
+                        args.new_frame = true;
+                    }
+                }
+                Err(e) => tracing::error!("invalid update request, {e}"),
+            }
+        }
+    }
+
+    struct CustomBlobExtension {
+        renderer: Arc<Mutex<CustomRenderer>>,
+    }
+    impl BlobExtension for CustomBlobExtension {
+        fn create_blob_rasterizer(&mut self) -> Box<dyn zng_view::extensions::AsyncBlobRasterizer> {
+            Box::new(CustomBlobRasterizer {
+                // rasterizer is a snapshot of the current state
+                snapshot: self.renderer.lock().clone(),
+            })
+        }
+
+        fn create_similar(&self) -> Box<dyn BlobExtension> {
+            Box::new(CustomBlobExtension {
+                renderer: Arc::clone(&self.renderer),
+            })
+        }
+
+        fn add(&mut self, _args: &zng_view::extensions::BlobAddArgs) {
+            // Webrender received the add request
+            //
+            // In this demo we already added from the display item.
+        }
+
+        fn update(&mut self, _args: &zng_view::extensions::BlobUpdateArgs) {
+            // Webrender received the update request
+        }
+
+        fn delete(&mut self, key: BlobImageKey) {
+            // Webrender requested cleanup.
+
+            let mut renderer = self.renderer.lock();
+            let renderer = &mut *renderer;
+            if let Some(i) = renderer.task_keys.remove(&key) {
+                let t = &mut renderer.tasks[i];
+                renderer.task_params.remove(&t.param());
+                t.state = CustomRenderTaskState::Free(MAX_FREE + 1);
+            }
+        }
+
+        fn enable_multithreading(&mut self, enable: bool) {
+            self.renderer.lock().single_threaded = !enable;
+        }
+    }
+
+    struct CustomBlobRasterizer {
+        snapshot: CustomRenderer,
+    }
+    impl CustomBlobRasterizer {
+        fn rasterize_tile(task: &CustomRenderTask, r: &BlobImageParams) -> BlobImageResult {
+            if r.descriptor.format != ImageFormat::BGRA8 {
+                // you must always respond, if no extension responds Webrender will panic.
+                return Err(BlobImageError::Other(format!("format {:?} is not supported", r.descriptor.format)));
+            }
+
+            // draw the requested tile
+            let size = r.descriptor.rect.size();
+            let offset = r.descriptor.rect.min.to_f32().to_vector();
+            let cursor = task.cursor.to_wr();
+            let max_dist = task.size.width.0 as f32;
+
+            let mut texels = Vec::with_capacity(size.area() as usize * 4);
+
+            for y in 0..size.height {
+                for x in 0..size.width {
+                    let t = euclid::Point2D::new(x, y).to_f32() + offset;
+                    let dist = t.distance_to(cursor).min(max_dist);
+
+                    let d = 1.0 - dist / max_dist;
+                    let d = (255.0 * d).round() as u8;
+
+                    let r = if (dist % 5.0).abs() < 1.0 { d.max(50) } else { 50 };
+
+                    texels.extend([d, d, r, 255]);
+                }
+            }
+
+            Ok(RasterizedBlobImage {
+                rasterized_rect: DeviceIntRect::from_size(DeviceIntSize::new(size.width, size.height)),
+                data: Arc::new(texels),
+            })
+        }
+    }
+    impl AsyncBlobRasterizer for CustomBlobRasterizer {
+        fn rasterize(&mut self, args: &mut zng_view::extensions::BlobRasterizerArgs) {
+            if !self.snapshot.single_threaded {
+                // rasterize all tiles in parallel, Webrender also uses Rayon
+                // but the `rasterize` call is made in the SceneBuilderThread
+                // so we mount the workers thread-pool here.
+
+                use zng::prelude::task::rayon::prelude::*;
+
+                let tiles = self.snapshot.workers.as_ref().unwrap().install(|| {
+                    args.requests.par_iter().filter_map(|r| {
+                        let i = *self.snapshot.task_keys.get(&r.request.key)?;
+                        // request is for us
+                        let task = &self.snapshot.tasks[i];
+                        Some((r.request, Self::rasterize_tile(task, r)))
+                    })
+                });
+
+                args.responses.par_extend(tiles);
+            } else {
+                // single-threaded mode is only for testing
+                let tiles = args.requests.iter().filter_map(|r| {
+                    let i = *self.snapshot.task_keys.get(&r.request.key)?;
+                    let task = &self.snapshot.tasks[i];
+                    Some((r.request, Self::rasterize_tile(task, r)))
+                });
+                args.responses.extend(tiles);
+            }
+        }
+    }
+
+    #[derive(Clone, Default)]
+    struct CustomRenderer {
+        tasks: Vec<CustomRenderTask>,
+        task_keys: HashMap<BlobImageKey, usize>,
+        task_params: HashMap<CustomTaskParams, usize>,
+        single_threaded: bool,
+        workers: Option<Arc<zng::prelude::task::rayon::ThreadPool>>,
+    }
+
+    #[derive(PartialEq, Eq, Hash, Clone, Copy)]
+    enum CustomTaskParams {
+        Bound(super::api::BindingId),
+        Params(PxSize, PxPoint),
+    }
+
+    #[derive(Clone)]
+    struct CustomRenderTask {
+        key: BlobImageKey,
+        size: PxSize,
+        cursor: PxPoint,
+        cursor_binding: Option<super::api::BindingId>,
+        state: CustomRenderTaskState,
+    }
+
+    impl CustomRenderTask {
+        fn param(&self) -> CustomTaskParams {
+            match self.cursor_binding {
+                Some(id) => CustomTaskParams::Bound(id),
+                None => CustomTaskParams::Params(self.size, self.cursor),
+            }
+        }
+    }
+    #[derive(Clone, Copy, Debug)]
+    enum CustomRenderTaskState {
+        Used,
+        Marked,
+        Free(u8),
+    }
+    /// Maximum display-list rebuilds that a unused BlobImageKey is retained.
+    const MAX_FREE: u8 = 5;
+}
+
+pub mod api {
+    use zng_view_api::api_extension::ApiExtensionName;
+
+    pub use crate::using_display_items::api::*;
+
+    pub fn extension_name() -> ApiExtensionName {
+        ApiExtensionName::new("zng.examples.extend_renderer.using_blob").unwrap()
+    }
+}
diff --git a/examples/extend-view/src/using_display_items.rs b/examples/extend-view/src/using_display_items.rs
new file mode 100644
index 000000000..3f5987547
--- /dev/null
+++ b/examples/extend-view/src/using_display_items.rs
@@ -0,0 +1,275 @@
+//! Demo view extension renderer, using only Webrender display items.
+
+/// App-process stuff, nodes.
+pub mod app_side {
+    use zng::{
+        mouse::{MOUSE_HOVERED_EVENT, MOUSE_MOVE_EVENT},
+        prelude_wgt::*,
+    };
+    use zng_app::view_process::{VIEW_PROCESS, VIEW_PROCESS_INITED_EVENT};
+    use zng_view_api::api_extension::ApiExtensionId;
+
+    /// Node that sends external display item and updates.
+    pub fn custom_render_node() -> impl UiNode {
+        custom_ext_node(extension_id)
+    }
+    // node that sends the cursor position, widget size and widget position in window to a view extension.
+    // abstracted here to be reused by the other demos.
+    pub(crate) fn custom_ext_node(extension_id: fn() -> ApiExtensionId) -> impl UiNode {
+        let mut ext_id = ApiExtensionId::INVALID;
+        let mut cursor = DipPoint::splat(Dip::MIN);
+        let mut cursor_px = PxPoint::splat(Px::MIN);
+        let mut render_size = PxSize::zero();
+
+        // identifies this item in the view (for updates)
+        let cursor_binding = super::api::BindingId::next_unique();
+
+        match_node_leaf(move |op| match op {
+            UiNodeOp::Init => {
+                WIDGET
+                    .sub_event(&VIEW_PROCESS_INITED_EVENT)
+                    .sub_event(&MOUSE_MOVE_EVENT)
+                    .sub_event(&MOUSE_HOVERED_EVENT);
+                ext_id = extension_id();
+            }
+            UiNodeOp::Event { update } => {
+                if let Some(args) = MOUSE_MOVE_EVENT.on(update) {
+                    if cursor != args.position {
+                        cursor = args.position;
+                        WIDGET.layout();
+                    }
+                } else if let Some(args) = MOUSE_HOVERED_EVENT.on(update) {
+                    if args.is_mouse_leave() {
+                        cursor = DipPoint::splat(Dip::MIN);
+                        cursor_px = PxPoint::splat(Px::MIN);
+                        WIDGET.render_update();
+                    }
+                } else if VIEW_PROCESS_INITED_EVENT.on(update).is_some() {
+                    ext_id = extension_id();
+                    WIDGET.render();
+                }
+            }
+            UiNodeOp::Measure { desired_size, .. } => {
+                *desired_size = LAYOUT.constraints().fill_size();
+            }
+            UiNodeOp::Layout { final_size, .. } => {
+                *final_size = LAYOUT.constraints().fill_size();
+
+                if render_size != *final_size {
+                    render_size = *final_size;
+                    WIDGET.render();
+                }
+
+                if cursor != DipPoint::splat(Dip::MIN) {
+                    let p = cursor.to_px(LAYOUT.scale_factor());
+                    if cursor_px != p {
+                        cursor_px = p;
+                        WIDGET.render_update();
+                    }
+                }
+            }
+            UiNodeOp::Render { frame } => {
+                // if extension is available
+                if ext_id != ApiExtensionId::INVALID {
+                    let mut cursor = PxPoint::splat(Px::MIN);
+                    if cursor_px != cursor {
+                        if let Some(c) = frame.transform().inverse().and_then(|t| t.transform_point(cursor_px)) {
+                            cursor = c;
+                        }
+                    }
+
+                    let window_pos = frame.transform().transform_point(PxPoint::zero()).unwrap_or_default();
+
+                    // push the entire custom item.
+                    frame.push_extension_item(
+                        ext_id,
+                        &super::api::RenderPayload {
+                            cursor_binding: Some(cursor_binding),
+                            cursor,
+                            size: render_size,
+                            window_pos,
+                        },
+                    );
+                }
+            }
+            UiNodeOp::RenderUpdate { update } => {
+                // if extension is available
+                if ext_id != ApiExtensionId::INVALID {
+                    let mut cursor = PxPoint::splat(Px::MIN);
+                    if cursor_px != cursor {
+                        if let Some(c) = update.transform().inverse().and_then(|t| t.transform_point(cursor_px)) {
+                            cursor = c;
+                        }
+                    }
+
+                    // push an update.
+                    update.update_extension(ext_id, &super::api::RenderUpdatePayload { cursor_binding, cursor });
+                }
+            }
+            _ => {}
+        })
+    }
+
+    pub fn extension_id() -> ApiExtensionId {
+        VIEW_PROCESS
+            .extension_id(super::api::extension_name())
+            .ok()
+            .flatten()
+            .unwrap_or(ApiExtensionId::INVALID)
+    }
+}
+
+/// View-process stuff, the actual extension.
+pub mod view_side {
+    use std::collections::HashMap;
+
+    use zng::prelude_wgt::PxPoint;
+    use zng_view::{
+        extensions::{PxToWr as _, RenderItemArgs, RenderUpdateArgs, RendererExtension, ViewExtensions},
+        webrender::{
+            api::{
+                units::{LayoutPoint, LayoutRect},
+                ColorF, CommonItemProperties,
+            },
+            euclid,
+        },
+    };
+    use zng_view_api::api_extension::ApiExtensionId;
+
+    pub fn extend(exts: &mut ViewExtensions) {
+        exts.renderer(super::api::extension_name(), CustomExtension::new);
+    }
+
+    struct CustomExtension {
+        // id of this extension, for tracing.
+        _id: ApiExtensionId,
+        // updated values
+        updated: HashMap<super::api::BindingId, PxPoint>,
+    }
+    impl CustomExtension {
+        fn new(id: ApiExtensionId) -> Self {
+            Self {
+                _id: id,
+                updated: HashMap::new(),
+            }
+        }
+    }
+    impl RendererExtension for CustomExtension {
+        fn is_init_only(&self) -> bool {
+            false // retain the extension after renderer creation.
+        }
+
+        fn render_push(&mut self, args: &mut RenderItemArgs) {
+            match args.payload.deserialize::<super::api::RenderPayload>() {
+                Ok(mut p) => {
+                    if let Some(binding) = p.cursor_binding {
+                        // updatable item
+                        match self.updated.entry(binding) {
+                            std::collections::hash_map::Entry::Occupied(e) => {
+                                if args.is_reuse {
+                                    // item is old, use updated value
+                                    p.cursor = *e.get();
+                                } else {
+                                    // item is new, previous updated value invalid
+                                    e.remove();
+                                }
+                            }
+                            std::collections::hash_map::Entry::Vacant(_) => {}
+                        }
+                    }
+
+                    // render
+                    let rect = LayoutRect::from_size(p.size.to_wr());
+                    let part_size = rect.size() / 10.0;
+
+                    let color = ColorF::new(0.5, 0.0, 1.0, 1.0);
+                    let cursor = p.cursor.to_wr();
+
+                    for y in 0..10 {
+                        for x in 0..10 {
+                            let part_pos = LayoutPoint::new(x as f32 * part_size.width, y as f32 * part_size.height);
+                            let part_rect = euclid::Rect::new(part_pos, part_size).to_box2d();
+
+                            let mut color = color;
+                            let mid = part_pos.to_vector() + part_size.to_vector() / 2.0;
+                            let dist = mid.to_point().distance_to(cursor).min(rect.width()) / rect.width();
+                            color.g = 1.0 - dist;
+
+                            let props = CommonItemProperties {
+                                clip_rect: part_rect,
+                                clip_chain_id: args.sc.clip_chain_id(args.list),
+                                spatial_id: args.sc.spatial_id(),
+                                flags: args.sc.primitive_flags(),
+                            };
+                            args.list.push_rect(&props, part_rect, color);
+                        }
+                    }
+                }
+                Err(e) => tracing::error!("invalid display item, {e}"),
+            }
+        }
+
+        fn render_update(&mut self, args: &mut RenderUpdateArgs) {
+            match args.payload.deserialize::<super::api::RenderUpdatePayload>() {
+                Ok(p) => {
+                    self.updated.insert(p.cursor_binding, p.cursor);
+                    // Request a full display list rebuild.
+                    //
+                    // This is optional because Webrender supports frame updates, using Webrender bindings,
+                    // but just supporting render-updates is probably worth-it, if the full display-item payloads are large
+                    // and update often.
+                    //
+                    // Note that even if you provide an optimal implementation and don't request a
+                    // new_frame you still must handle the case when a display-item payload is reused
+                    // after an update.
+                    args.new_frame = true;
+
+                    // For example we could have created a Webrender binding for each color square during
+                    // `display_item_push`, then recomputed the colors and updated all here.
+                    //
+                    // args.properties.colors.push(..)
+                    //
+                    // Note that if you are going to do this you need to generate the binding keys in
+                    // the app-process using the type `FrameValueKey<T>`, otherwise you will have key
+                    // collisions with the normal animating properties.
+                }
+                Err(e) => tracing::error!("invalid update request, {e}"),
+            }
+        }
+    }
+}
+
+/// Shared types.
+pub mod api {
+    use std::sync::atomic::{AtomicU32, Ordering};
+
+    use zng::layout::{PxPoint, PxSize};
+    use zng_view_api::api_extension::ApiExtensionName;
+
+    pub fn extension_name() -> ApiExtensionName {
+        ApiExtensionName::new("zng.examples.extend_renderer.using_display_items").unwrap()
+    }
+
+    #[derive(serde::Serialize, serde::Deserialize, Clone, Copy, PartialEq, Eq, Hash, Debug)]
+    pub struct BindingId(u32);
+    static ID_GEN: AtomicU32 = AtomicU32::new(0);
+    impl BindingId {
+        pub fn next_unique() -> Self {
+            Self(ID_GEN.fetch_add(1, Ordering::Relaxed))
+        }
+    }
+
+    #[derive(serde::Serialize, serde::Deserialize)]
+    pub struct RenderPayload {
+        pub cursor_binding: Option<BindingId>,
+        pub cursor: PxPoint,
+        pub size: PxSize,
+        pub window_pos: PxPoint,
+    }
+
+    #[derive(serde::Serialize, serde::Deserialize)]
+    pub struct RenderUpdatePayload {
+        pub cursor_binding: BindingId,
+        pub cursor: PxPoint,
+    }
+}
diff --git a/examples/extend-view/src/using_gl_overlay.rs b/examples/extend-view/src/using_gl_overlay.rs
new file mode 100644
index 000000000..1096efa43
--- /dev/null
+++ b/examples/extend-view/src/using_gl_overlay.rs
@@ -0,0 +1,202 @@
+//! Demo view extension custom renderer, integrated by drawing directly over the frame.
+
+/// App-process stuff, nodes.
+pub mod app_side {
+    use zng::prelude::UiNode;
+    use zng_app::view_process::VIEW_PROCESS;
+    use zng_view_api::api_extension::ApiExtensionId;
+
+    /// Node that sends external display item and updates.
+    pub fn custom_render_node() -> impl UiNode {
+        crate::using_display_items::app_side::custom_ext_node(extension_id)
+    }
+
+    pub fn extension_id() -> ApiExtensionId {
+        VIEW_PROCESS
+            .extension_id(super::api::extension_name())
+            .ok()
+            .flatten()
+            .unwrap_or(ApiExtensionId::INVALID)
+    }
+}
+
+/// View-process stuff, the actual extension.
+pub mod view_side {
+    use zng::layout::{Px, PxPoint, PxRect, PxSize};
+    use zng_view::{
+        extensions::{RenderItemArgs, RenderUpdateArgs, RendererExtension, ViewExtensions},
+        gleam::gl,
+    };
+    use zng_view_api::api_extension::ApiExtensionId;
+
+    use super::api::BindingId;
+
+    pub fn extend(exts: &mut ViewExtensions) {
+        exts.renderer(super::api::extension_name(), CustomExtension::new);
+    }
+
+    struct CustomExtension {
+        // id of this extension, for tracing.
+        _id: ApiExtensionId,
+        renderer: Option<CustomRenderer>,
+    }
+    impl CustomExtension {
+        fn new(id: ApiExtensionId) -> Self {
+            Self { _id: id, renderer: None }
+        }
+    }
+    impl RendererExtension for CustomExtension {
+        fn is_init_only(&self) -> bool {
+            false // retain the extension after renderer creation.
+        }
+
+        fn renderer_inited(&mut self, args: &mut zng_view::extensions::RendererInitedArgs) {
+            // shaders/programs can be loaded here.
+            self.renderer = Some(CustomRenderer::load(&**args.context.gl()));
+        }
+        fn renderer_deinited(&mut self, args: &mut zng_view::extensions::RendererDeinitedArgs) {
+            // ..and unloaded here.
+            if let Some(r) = self.renderer.take() {
+                r.unload(&**args.context.gl());
+            }
+        }
+
+        fn render_start(&mut self, _: &mut zng_view::extensions::RenderArgs) {
+            if let Some(r) = &mut self.renderer {
+                r.clear();
+            }
+        }
+
+        fn render_push(&mut self, args: &mut RenderItemArgs) {
+            match args.payload.deserialize::<super::api::RenderPayload>() {
+                Ok(p) => {
+                    // we use the display list for convenience only, each render/update
+                    // is paired with Webrender updates, this extension does not actually
+                    // use Webrender.
+                    if let Some(r) = &mut self.renderer {
+                        r.push_task(&p);
+                    }
+                }
+                Err(e) => tracing::error!("invalid display item, {e}"),
+            }
+        }
+
+        fn render_update(&mut self, args: &mut RenderUpdateArgs) {
+            match args.payload.deserialize::<super::api::RenderUpdatePayload>() {
+                Ok(p) => {
+                    if let Some(r) = &mut self.renderer {
+                        r.update_task(&p);
+                    }
+                }
+                Err(e) => tracing::error!("invalid update request, {e}"),
+            }
+        }
+
+        fn redraw(&mut self, args: &mut zng_view::extensions::RedrawArgs) {
+            if let Some(r) = &mut self.renderer {
+                r.redraw(args.size, &**args.context.gl());
+            }
+        }
+    }
+
+    struct CustomRenderer {
+        tasks: Vec<DrawTask>,
+        unloaded_ok: bool,
+    }
+    impl CustomRenderer {
+        pub fn load(_gl: &dyn gl::Gl) -> Self {
+            // let vao = gl.gen_vertex_arrays(1);
+
+            Self {
+                tasks: vec![],
+                unloaded_ok: false,
+            }
+        }
+        fn unload(mut self, _gl: &dyn gl::Gl) {
+            // gl.delete_vertex_arrays(&self.vao);
+
+            self.tasks.clear();
+            self.unloaded_ok = true;
+        }
+
+        pub fn clear(&mut self) {
+            self.tasks.clear();
+        }
+
+        pub fn push_task(&mut self, task: &super::api::RenderPayload) {
+            self.tasks.push(DrawTask {
+                area: PxRect::new(task.window_pos, task.size),
+                cursor: task.cursor,
+                cursor_binding: task.cursor_binding,
+            });
+        }
+
+        pub fn update_task(&mut self, p: &super::api::RenderUpdatePayload) {
+            if let Some(i) = self.tasks.iter_mut().find(|i| i.cursor_binding == Some(p.cursor_binding)) {
+                i.cursor = p.cursor;
+            }
+        }
+
+        pub fn redraw(&mut self, canvas_size: PxSize, gl: &dyn gl::Gl) {
+            // gl (0, 0) is the bottom-left corner not the top-left.
+            let gl_y = |max_y: Px| canvas_size.height - max_y;
+
+            for task in &self.tasks {
+                // gl.bind_vertex_array(vao);
+
+                gl.enable(gl::SCISSOR_TEST);
+                gl.scissor(
+                    task.area.origin.x.0,
+                    gl_y(task.area.max_y()).0,
+                    task.area.size.width.0,
+                    task.area.size.height.0,
+                );
+                gl.clear_color(0.0, 0.0, 0.0, 1.0);
+                gl.clear(gl::COLOR_BUFFER_BIT);
+            }
+            for task in &self.tasks {
+                if task.cursor.x >= Px(0)
+                    && task.cursor.y < task.area.width()
+                    && task.cursor.y >= Px(0)
+                    && task.cursor.y < task.area.height()
+                {
+                    let r = task.cursor.x.0 as f32 / task.area.width().0 as f32;
+                    let b = task.cursor.y.0 as f32 / task.area.height().0 as f32;
+
+                    let x = task.area.origin.x + task.cursor.x - Px(50);
+                    let y = task.area.origin.y + task.cursor.y - Px(50);
+                    let cursor = PxRect::new(PxPoint::new(x, y), PxSize::splat(Px(100)));
+
+                    gl.enable(gl::SCISSOR_TEST);
+                    gl.scissor(cursor.origin.x.0, gl_y(cursor.max_y()).0, cursor.size.width.0, cursor.size.height.0);
+                    gl.clear_color(r, 0.5, b, 1.0);
+                    gl.clear(gl::COLOR_BUFFER_BIT);
+                }
+            }
+        }
+    }
+    impl Drop for CustomRenderer {
+        fn drop(&mut self) {
+            if !self.unloaded_ok {
+                tracing::error!("CustomRenderer::unload was not used to drop the renderer");
+            }
+        }
+    }
+
+    #[derive(Debug)]
+    struct DrawTask {
+        area: PxRect,
+        cursor: PxPoint,
+        cursor_binding: Option<BindingId>,
+    }
+}
+
+pub mod api {
+    use zng_view_api::api_extension::ApiExtensionName;
+
+    pub use crate::using_display_items::api::*;
+
+    pub fn extension_name() -> ApiExtensionName {
+        ApiExtensionName::new("zng.examples.extend_renderer.using_gl_overlay").unwrap()
+    }
+}
diff --git a/examples/extend-view/src/using_gl_texture.rs b/examples/extend-view/src/using_gl_texture.rs
new file mode 100644
index 000000000..609ac51b1
--- /dev/null
+++ b/examples/extend-view/src/using_gl_texture.rs
@@ -0,0 +1,171 @@
+//! Demo view extension custom renderer, integrated by drawing to a texture uses as an image.
+
+/// App-process stuff, nodes.
+pub mod app_side {
+    use zng::prelude::UiNode;
+    use zng_app::view_process::VIEW_PROCESS;
+    use zng_view_api::api_extension::ApiExtensionId;
+
+    /// Node that sends external display item and updates.
+    pub fn custom_render_node() -> impl UiNode {
+        crate::using_display_items::app_side::custom_ext_node(extension_id)
+    }
+
+    pub fn extension_id() -> ApiExtensionId {
+        VIEW_PROCESS
+            .extension_id(super::api::extension_name())
+            .ok()
+            .flatten()
+            .unwrap_or(ApiExtensionId::INVALID)
+    }
+}
+
+/// View-process stuff, the actual extension.
+pub mod view_side {
+    use zng::layout::PxRect;
+    use zng_view::{
+        extensions::{PxToWr as _, RenderItemArgs, RendererExtension, ViewExtensions},
+        gleam::gl,
+        webrender::api::{
+            units::{DeviceIntSize, TexelRect},
+            AlphaType, ColorF, CommonItemProperties, ExternalImageData, ExternalImageId, ExternalImageType, ImageDescriptor,
+            ImageDescriptorFlags, ImageFormat, ImageKey, ImageRendering,
+        },
+    };
+    use zng_view_api::api_extension::ApiExtensionId;
+
+    pub fn extend(exts: &mut ViewExtensions) {
+        exts.renderer(super::api::extension_name(), CustomExtension::new);
+    }
+
+    struct TextureInfo {
+        // texture in OpenGL.
+        texture: gl::GLuint,
+        // texture in external image registry (for webrender).
+        external_id: ExternalImageId,
+        // texture in renderer (and display lists).
+        image_key: ImageKey,
+    }
+
+    struct CustomExtension {
+        // id of this extension, for tracing.
+        _id: ApiExtensionId,
+
+        texture: Option<TextureInfo>,
+    }
+    impl CustomExtension {
+        fn new(id: ApiExtensionId) -> Self {
+            Self { _id: id, texture: None }
+        }
+    }
+    impl RendererExtension for CustomExtension {
+        fn is_init_only(&self) -> bool {
+            false // retain the extension after renderer creation.
+        }
+
+        fn renderer_inited(&mut self, args: &mut zng_view::extensions::RendererInitedArgs) {
+            // gl available here and in `redraw`.
+            //
+            // dynamic textures can be generated by collecting request on `command` or on `render_push` and
+            // generating on the next `redraw` that will happen after `render_push` or on request after `command`.
+
+            let size = DeviceIntSize::splat(100);
+
+            // OpenGL
+            let texture = args.context.gl().gen_textures(1)[0];
+            args.context.gl().bind_texture(gl::TEXTURE_2D, texture);
+            let mut img = vec![0u8; size.width as usize * size.height as usize * 4];
+            let mut line = 0u8;
+            let mut col = 0u8;
+            for rgba in img.chunks_exact_mut(4) {
+                rgba[0] = 255;
+                rgba[1] = 10 + line * 3;
+                rgba[2] = 10 + line * 3;
+                rgba[3] = 255;
+
+                col = col.wrapping_add(1);
+                if col == 0 {
+                    line = line.wrapping_add(1);
+                }
+            }
+            args.context.gl().tex_image_2d(
+                gl::TEXTURE_2D,
+                0,
+                gl::RGBA as _,
+                size.width,
+                size.height,
+                0,
+                gl::RGBA,
+                gl::UNSIGNED_BYTE,
+                Some(&img),
+            );
+
+            // Webrender
+            let external_id = args
+                .external_images
+                .register_texture(TexelRect::new(0.0, 0.0, size.width as f32, size.height as f32), texture);
+
+            let image_key = args.api.generate_image_key();
+            let mut txn = zng_view::webrender::Transaction::new();
+            txn.add_image(
+                image_key,
+                ImageDescriptor {
+                    format: ImageFormat::RGBA8,
+                    size,
+                    stride: None,
+                    offset: 0,
+                    flags: ImageDescriptorFlags::IS_OPAQUE,
+                },
+                zng_view::webrender::api::ImageData::External(ExternalImageData {
+                    id: external_id,
+                    channel_index: 0,
+                    image_type: ExternalImageType::TextureHandle(zng_view::webrender::api::ImageBufferKind::Texture2D),
+                }),
+                None,
+            );
+            args.api.send_transaction(args.document_id, txn);
+
+            self.texture = Some(TextureInfo {
+                texture,
+                external_id,
+                image_key,
+            });
+        }
+
+        fn renderer_deinited(&mut self, args: &mut zng_view::extensions::RendererDeinitedArgs) {
+            if let Some(t) = self.texture.take() {
+                let _ = t.external_id; // already cleanup by renderer deinit.
+                args.context.gl().delete_textures(&[t.texture]);
+            }
+        }
+
+        fn render_push(&mut self, args: &mut RenderItemArgs) {
+            match args.payload.deserialize::<super::api::RenderPayload>() {
+                Ok(p) => {
+                    if let Some(t) = &self.texture {
+                        let rect = PxRect::from_size(p.size).to_wr();
+                        let props = CommonItemProperties {
+                            clip_rect: rect,
+                            clip_chain_id: args.sc.clip_chain_id(args.list),
+                            spatial_id: args.sc.spatial_id(),
+                            flags: args.sc.primitive_flags(),
+                        };
+                        args.list
+                            .push_image(&props, rect, ImageRendering::Auto, AlphaType::Alpha, t.image_key, ColorF::WHITE);
+                    }
+                }
+                Err(e) => tracing::error!("invalid display item, {e}"),
+            }
+        }
+    }
+}
+
+pub mod api {
+    use zng_view_api::api_extension::ApiExtensionName;
+
+    pub use crate::using_display_items::api::*;
+
+    pub fn extension_name() -> ApiExtensionName {
+        ApiExtensionName::new("zng.examples.extend_renderer.using_gl_texture").unwrap()
+    }
+}
diff --git a/examples/extend_view.rs b/examples/extend_view.rs
deleted file mode 100644
index 7bf4e005c..000000000
--- a/examples/extend_view.rs
+++ /dev/null
@@ -1,1313 +0,0 @@
-//! Demonstrates the `zng-view` extension API and render extensions API.
-
-use zng::{color::filter::hue_rotate, layout::size, prelude::*};
-use zng_view::extensions::ViewExtensions;
-// use zng_wgt_webrender_debug as wr;
-
-// Examples of how to extend the view-process with custom renderers.
-//
-// This is an advanced API, use it only if you really can't render the effect you want
-// using custom nodes/properties.
-
-fn main() {
-    examples_util::print_info();
-    zng::env::init!();
-    zng::app::crash_handler::init_debug();
-
-    // zng_view::run_same_process_extended(app_main, view_extensions);
-    app_main();
-}
-
-zng_view::view_process_extension!(view_extensions);
-
-fn app_main() {
-    APP.defaults().run_window(async {
-        Window! {
-            // wr::renderer_debug = {
-            //     wr::DebugFlags::TEXTURE_CACHE_DBG | wr::DebugFlags::TEXTURE_CACHE_DBG_CLEAR_EVICTED
-            // };
-
-            title = "Extend-View Example";
-            width = 900;
-
-            on_frame_image_ready = hn_once!(|_| {
-                let h = get_window_handle::app_side::get_window_handle(WINDOW.id()).unwrap();
-                tracing::info!("RAW-WINDOW-HANDLE: {h}");
-            });
-
-            child = Stack! {
-                children_align = Align::CENTER;
-                direction = StackDirection::left_to_right();
-                spacing = 20;
-
-                children = ui_vec![
-                    Stack! {
-                        direction = StackDirection::top_to_bottom();
-                        children_align = Align::CENTER;
-                        spacing = 5;
-                        children = ui_vec![
-                            Text!("Using Display Items"),
-                            Container! {
-                                size = 30.vmin_pct();
-                                child = using_display_items::app_side::custom_render_node();
-                            },
-                            Container! {
-                                size = 30.vmin_pct();
-                                hue_rotate = 180.deg();
-                                child = using_display_items::app_side::custom_render_node();
-                            },
-                        ]
-                    },
-                    Stack! {
-                        direction = StackDirection::top_to_bottom();
-                        children_align = Align::CENTER;
-                        spacing = 5;
-                        children = ui_vec![
-                            Text!("Using Blob Images"),
-                            Container! {
-                                size = 30.vmin_pct();
-                                child = using_blob::app_side::custom_render_node();
-                            },
-                            Container! {
-                                size = 30.vmin_pct();
-                                hue_rotate = 180.deg();
-                                child = using_blob::app_side::custom_render_node();
-                            },
-                        ]
-                    },
-                    Stack! {
-                        direction = StackDirection::top_to_bottom();
-                        children_align = Align::CENTER;
-                        spacing = 5;
-                        children = ui_vec![
-                            Text!("Using GL Overlay"),
-                            Container! {
-                                size = 30.vmin_pct();
-                                child = using_gl_overlay::app_side::custom_render_node();
-                            },
-                            Container! {
-                                size = 30.vmin_pct();
-                                hue_rotate = 180.deg(); // no effect
-                                child = using_gl_overlay::app_side::custom_render_node();
-                            },
-                        ]
-                    },
-                    Stack! {
-                        direction = StackDirection::top_to_bottom();
-                        children_align = Align::CENTER;
-                        spacing = 5;
-                        children = ui_vec![
-                            Text!("Using GL Texture"),
-                            Container! {
-                                size = 30.vmin_pct();
-                                child = using_gl_texture::app_side::custom_render_node();
-                            },
-                            Container! {
-                                size = 30.vmin_pct();
-                                hue_rotate = 180.deg();
-                                child = using_gl_texture::app_side::custom_render_node();
-                            },
-                        ]
-                    },
-                ]
-            }
-        }
-    })
-}
-
-/// Called in the view-process to init extensions.
-fn view_extensions() -> ViewExtensions {
-    let mut exts = ViewExtensions::new();
-    get_window_handle::view_side::extend(&mut exts);
-    using_display_items::view_side::extend(&mut exts);
-    using_blob::view_side::extend(&mut exts);
-    using_gl_overlay::view_side::extend(&mut exts);
-    using_gl_texture::view_side::extend(&mut exts);
-    exts
-}
-
-/// Demo view extension window.
-pub mod get_window_handle {
-    /// App-process stuff.
-    pub mod app_side {
-        use zng::prelude::*;
-        use zng_app::view_process::VIEW_PROCESS;
-        use zng_view_api::api_extension::ApiExtensionId;
-
-        /// Get the raw-window-handle formatted to text.
-        ///
-        /// This sends a custom command to the view-process (implemented in `super::view_side`), the view-process
-        /// uses a WindowExtension to access the raw-window-handle and format it to text as a basic example.
-        pub fn get_window_handle(win_id: WindowId) -> Option<Txt> {
-            match WINDOWS.view_window_extension::<_, super::api::Response>(
-                win_id,
-                self::extension_id(),
-                &super::api::Request { alternate: false },
-            ) {
-                Ok(r) => Some(r.handle_txt),
-                Err(e) => {
-                    tracing::error!("failed to get extension response, {e}");
-                    None
-                }
-            }
-        }
-
-        pub fn extension_id() -> ApiExtensionId {
-            VIEW_PROCESS
-                .extension_id(super::api::extension_name())
-                .ok()
-                .flatten()
-                .unwrap_or(ApiExtensionId::INVALID)
-        }
-    }
-
-    /// View-process stuff, the actual extension.
-    pub mod view_side {
-        use zng::text::formatx;
-        use zng_view::extensions::{ViewExtensions, WindowExtension};
-        use zng_view_api::api_extension::{ApiExtensionId, ApiExtensionPayload};
-
-        pub fn extend(exts: &mut ViewExtensions) {
-            exts.window(super::api::extension_name(), CustomExtension::new);
-        }
-
-        struct CustomExtension {
-            id: ApiExtensionId,
-        }
-        impl CustomExtension {
-            fn new(id: ApiExtensionId) -> Self {
-                Self { id }
-            }
-        }
-        impl WindowExtension for CustomExtension {
-            fn is_init_only(&self) -> bool {
-                false
-            }
-
-            fn command(&mut self, args: &mut zng_view::extensions::WindowCommandArgs) -> ApiExtensionPayload {
-                match args.request.deserialize::<super::api::Request>() {
-                    Ok(r) => {
-                        let h = raw_window_handle::HasWindowHandle::window_handle(args.window).unwrap();
-                        ApiExtensionPayload::serialize(&super::api::Response {
-                            // note that you should only use the window handle in the view-process side.
-                            handle_txt: if r.alternate { formatx!("{h:#?}") } else { formatx!("{h:?}") },
-                        })
-                        .unwrap()
-                    }
-                    Err(e) => ApiExtensionPayload::invalid_request(self.id, format_args!("invalid command request, {e}")),
-                }
-            }
-        }
-    }
-
-    /// Shared types.
-    pub mod api {
-        use zng::text::Txt;
-        use zng_view_api::api_extension::ApiExtensionName;
-
-        pub fn extension_name() -> ApiExtensionName {
-            ApiExtensionName::new("zng.examples.extend_renderer.get_window_handle").unwrap()
-        }
-
-        #[derive(serde::Serialize, serde::Deserialize)]
-        pub struct Request {
-            pub alternate: bool,
-        }
-
-        #[derive(serde::Serialize, serde::Deserialize)]
-        pub struct Response {
-            pub handle_txt: Txt,
-        }
-    }
-}
-
-/// Demo view extension renderer, using only Webrender display items.
-pub mod using_display_items {
-    /// App-process stuff, nodes.
-    pub mod app_side {
-        use zng::{
-            mouse::{MOUSE_HOVERED_EVENT, MOUSE_MOVE_EVENT},
-            prelude_wgt::*,
-        };
-        use zng_app::view_process::{VIEW_PROCESS, VIEW_PROCESS_INITED_EVENT};
-        use zng_view_api::api_extension::ApiExtensionId;
-
-        /// Node that sends external display item and updates.
-        pub fn custom_render_node() -> impl UiNode {
-            custom_ext_node(extension_id)
-        }
-        // node that sends the cursor position, widget size and widget position in window to a view extension.
-        // abstracted here to be reused by the other demos.
-        pub(crate) fn custom_ext_node(extension_id: fn() -> ApiExtensionId) -> impl UiNode {
-            let mut ext_id = ApiExtensionId::INVALID;
-            let mut cursor = DipPoint::splat(Dip::MIN);
-            let mut cursor_px = PxPoint::splat(Px::MIN);
-            let mut render_size = PxSize::zero();
-
-            // identifies this item in the view (for updates)
-            let cursor_binding = super::api::BindingId::next_unique();
-
-            match_node_leaf(move |op| match op {
-                UiNodeOp::Init => {
-                    WIDGET
-                        .sub_event(&VIEW_PROCESS_INITED_EVENT)
-                        .sub_event(&MOUSE_MOVE_EVENT)
-                        .sub_event(&MOUSE_HOVERED_EVENT);
-                    ext_id = extension_id();
-                }
-                UiNodeOp::Event { update } => {
-                    if let Some(args) = MOUSE_MOVE_EVENT.on(update) {
-                        if cursor != args.position {
-                            cursor = args.position;
-                            WIDGET.layout();
-                        }
-                    } else if let Some(args) = MOUSE_HOVERED_EVENT.on(update) {
-                        if args.is_mouse_leave() {
-                            cursor = DipPoint::splat(Dip::MIN);
-                            cursor_px = PxPoint::splat(Px::MIN);
-                            WIDGET.render_update();
-                        }
-                    } else if VIEW_PROCESS_INITED_EVENT.on(update).is_some() {
-                        ext_id = extension_id();
-                        WIDGET.render();
-                    }
-                }
-                UiNodeOp::Measure { desired_size, .. } => {
-                    *desired_size = LAYOUT.constraints().fill_size();
-                }
-                UiNodeOp::Layout { final_size, .. } => {
-                    *final_size = LAYOUT.constraints().fill_size();
-
-                    if render_size != *final_size {
-                        render_size = *final_size;
-                        WIDGET.render();
-                    }
-
-                    if cursor != DipPoint::splat(Dip::MIN) {
-                        let p = cursor.to_px(LAYOUT.scale_factor());
-                        if cursor_px != p {
-                            cursor_px = p;
-                            WIDGET.render_update();
-                        }
-                    }
-                }
-                UiNodeOp::Render { frame } => {
-                    // if extension is available
-                    if ext_id != ApiExtensionId::INVALID {
-                        let mut cursor = PxPoint::splat(Px::MIN);
-                        if cursor_px != cursor {
-                            if let Some(c) = frame.transform().inverse().and_then(|t| t.transform_point(cursor_px)) {
-                                cursor = c;
-                            }
-                        }
-
-                        let window_pos = frame.transform().transform_point(PxPoint::zero()).unwrap_or_default();
-
-                        // push the entire custom item.
-                        frame.push_extension_item(
-                            ext_id,
-                            &super::api::RenderPayload {
-                                cursor_binding: Some(cursor_binding),
-                                cursor,
-                                size: render_size,
-                                window_pos,
-                            },
-                        );
-                    }
-                }
-                UiNodeOp::RenderUpdate { update } => {
-                    // if extension is available
-                    if ext_id != ApiExtensionId::INVALID {
-                        let mut cursor = PxPoint::splat(Px::MIN);
-                        if cursor_px != cursor {
-                            if let Some(c) = update.transform().inverse().and_then(|t| t.transform_point(cursor_px)) {
-                                cursor = c;
-                            }
-                        }
-
-                        // push an update.
-                        update.update_extension(ext_id, &super::api::RenderUpdatePayload { cursor_binding, cursor });
-                    }
-                }
-                _ => {}
-            })
-        }
-
-        pub fn extension_id() -> ApiExtensionId {
-            VIEW_PROCESS
-                .extension_id(super::api::extension_name())
-                .ok()
-                .flatten()
-                .unwrap_or(ApiExtensionId::INVALID)
-        }
-    }
-
-    /// View-process stuff, the actual extension.
-    pub mod view_side {
-        use std::collections::HashMap;
-
-        use zng::prelude_wgt::PxPoint;
-        use zng_view::{
-            extensions::{PxToWr as _, RenderItemArgs, RenderUpdateArgs, RendererExtension, ViewExtensions},
-            webrender::{
-                api::{
-                    units::{LayoutPoint, LayoutRect},
-                    ColorF, CommonItemProperties,
-                },
-                euclid,
-            },
-        };
-        use zng_view_api::api_extension::ApiExtensionId;
-
-        pub fn extend(exts: &mut ViewExtensions) {
-            exts.renderer(super::api::extension_name(), CustomExtension::new);
-        }
-
-        struct CustomExtension {
-            // id of this extension, for tracing.
-            _id: ApiExtensionId,
-            // updated values
-            updated: HashMap<super::api::BindingId, PxPoint>,
-        }
-        impl CustomExtension {
-            fn new(id: ApiExtensionId) -> Self {
-                Self {
-                    _id: id,
-                    updated: HashMap::new(),
-                }
-            }
-        }
-        impl RendererExtension for CustomExtension {
-            fn is_init_only(&self) -> bool {
-                false // retain the extension after renderer creation.
-            }
-
-            fn render_push(&mut self, args: &mut RenderItemArgs) {
-                match args.payload.deserialize::<super::api::RenderPayload>() {
-                    Ok(mut p) => {
-                        if let Some(binding) = p.cursor_binding {
-                            // updatable item
-                            match self.updated.entry(binding) {
-                                std::collections::hash_map::Entry::Occupied(e) => {
-                                    if args.is_reuse {
-                                        // item is old, use updated value
-                                        p.cursor = *e.get();
-                                    } else {
-                                        // item is new, previous updated value invalid
-                                        e.remove();
-                                    }
-                                }
-                                std::collections::hash_map::Entry::Vacant(_) => {}
-                            }
-                        }
-
-                        // render
-                        let rect = LayoutRect::from_size(p.size.to_wr());
-                        let part_size = rect.size() / 10.0;
-
-                        let color = ColorF::new(0.5, 0.0, 1.0, 1.0);
-                        let cursor = p.cursor.to_wr();
-
-                        for y in 0..10 {
-                            for x in 0..10 {
-                                let part_pos = LayoutPoint::new(x as f32 * part_size.width, y as f32 * part_size.height);
-                                let part_rect = euclid::Rect::new(part_pos, part_size).to_box2d();
-
-                                let mut color = color;
-                                let mid = part_pos.to_vector() + part_size.to_vector() / 2.0;
-                                let dist = mid.to_point().distance_to(cursor).min(rect.width()) / rect.width();
-                                color.g = 1.0 - dist;
-
-                                let props = CommonItemProperties {
-                                    clip_rect: part_rect,
-                                    clip_chain_id: args.sc.clip_chain_id(args.list),
-                                    spatial_id: args.sc.spatial_id(),
-                                    flags: args.sc.primitive_flags(),
-                                };
-                                args.list.push_rect(&props, part_rect, color);
-                            }
-                        }
-                    }
-                    Err(e) => tracing::error!("invalid display item, {e}"),
-                }
-            }
-
-            fn render_update(&mut self, args: &mut RenderUpdateArgs) {
-                match args.payload.deserialize::<super::api::RenderUpdatePayload>() {
-                    Ok(p) => {
-                        self.updated.insert(p.cursor_binding, p.cursor);
-                        // Request a full display list rebuild.
-                        //
-                        // This is optional because Webrender supports frame updates, using Webrender bindings,
-                        // but just supporting render-updates is probably worth-it, if the full display-item payloads are large
-                        // and update often.
-                        //
-                        // Note that even if you provide an optimal implementation and don't request a
-                        // new_frame you still must handle the case when a display-item payload is reused
-                        // after an update.
-                        args.new_frame = true;
-
-                        // For example we could have created a Webrender binding for each color square during
-                        // `display_item_push`, then recomputed the colors and updated all here.
-                        //
-                        // args.properties.colors.push(..)
-                        //
-                        // Note that if you are going to do this you need to generate the binding keys in
-                        // the app-process using the type `FrameValueKey<T>`, otherwise you will have key
-                        // collisions with the normal animating properties.
-                    }
-                    Err(e) => tracing::error!("invalid update request, {e}"),
-                }
-            }
-        }
-    }
-
-    /// Shared types.
-    pub mod api {
-        use std::sync::atomic::{AtomicU32, Ordering};
-
-        use zng::layout::{PxPoint, PxSize};
-        use zng_view_api::api_extension::ApiExtensionName;
-
-        pub fn extension_name() -> ApiExtensionName {
-            ApiExtensionName::new("zng.examples.extend_renderer.using_display_items").unwrap()
-        }
-
-        #[derive(serde::Serialize, serde::Deserialize, Clone, Copy, PartialEq, Eq, Hash, Debug)]
-        pub struct BindingId(u32);
-        static ID_GEN: AtomicU32 = AtomicU32::new(0);
-        impl BindingId {
-            pub fn next_unique() -> Self {
-                Self(ID_GEN.fetch_add(1, Ordering::Relaxed))
-            }
-        }
-
-        #[derive(serde::Serialize, serde::Deserialize)]
-        pub struct RenderPayload {
-            pub cursor_binding: Option<BindingId>,
-            pub cursor: PxPoint,
-            pub size: PxSize,
-            pub window_pos: PxPoint,
-        }
-
-        #[derive(serde::Serialize, serde::Deserialize)]
-        pub struct RenderUpdatePayload {
-            pub cursor_binding: BindingId,
-            pub cursor: PxPoint,
-        }
-    }
-}
-
-/// Demo view extension custom renderer, integrated with Webrender using the blob API.
-pub mod using_blob {
-    /// App-process stuff, nodes.
-    pub mod app_side {
-        use zng::prelude::UiNode;
-        use zng_app::view_process::VIEW_PROCESS;
-        use zng_view_api::api_extension::ApiExtensionId;
-
-        /// Node that sends external display item and updates.
-        pub fn custom_render_node() -> impl UiNode {
-            crate::using_display_items::app_side::custom_ext_node(extension_id)
-        }
-
-        pub fn extension_id() -> ApiExtensionId {
-            VIEW_PROCESS
-                .extension_id(super::api::extension_name())
-                .ok()
-                .flatten()
-                .unwrap_or(ApiExtensionId::INVALID)
-        }
-    }
-
-    /// View-process stuff, the actual extension.
-    pub mod view_side {
-        use std::{collections::HashMap, sync::Arc};
-
-        use zng::layout::{PxPoint, PxSize};
-        use zng::prelude::task::parking_lot::Mutex;
-        use zng_view::extensions::PxToWr as _;
-        use zng_view::{
-            extensions::{AsyncBlobRasterizer, BlobExtension, RenderItemArgs, RenderUpdateArgs, RendererExtension, ViewExtensions},
-            webrender::{
-                api::{
-                    units::{BlobDirtyRect, DeviceIntRect, DeviceIntSize, LayoutRect},
-                    BlobImageError, BlobImageKey, BlobImageParams, BlobImageResult, ColorF, CommonItemProperties, ImageDescriptor,
-                    ImageDescriptorFlags, ImageFormat, RasterizedBlobImage,
-                },
-                euclid,
-            },
-        };
-        use zng_view_api::api_extension::ApiExtensionId;
-
-        pub fn extend(exts: &mut ViewExtensions) {
-            exts.renderer(super::api::extension_name(), CustomExtension::new);
-        }
-
-        struct CustomExtension {
-            // id of this extension, for tracing.
-            _id: ApiExtensionId,
-            // renderer, shared between blob extensions.
-            renderer: Arc<Mutex<CustomRenderer>>,
-        }
-        impl CustomExtension {
-            fn new(id: ApiExtensionId) -> Self {
-                Self {
-                    _id: id,
-                    renderer: Arc::default(),
-                }
-            }
-        }
-        impl RendererExtension for CustomExtension {
-            fn is_init_only(&self) -> bool {
-                false // retain the extension after renderer creation.
-            }
-
-            fn configure(&mut self, args: &mut zng_view::extensions::RendererConfigArgs) {
-                // Blob entry point inside Webrender.
-                args.blobs.push(Box::new(CustomBlobExtension {
-                    renderer: Arc::clone(&self.renderer),
-                }));
-
-                // Worker threads will be used during rasterization.
-                //
-                // This option is always already set by the window. Note that this thread pool
-                // is also used by Webrender's glyph rasterizer, so be careful not to clog it.
-                self.renderer.lock().workers.clone_from(&args.options.workers);
-            }
-
-            fn render_start(&mut self, _: &mut zng_view::extensions::RenderArgs) {
-                let mut renderer = self.renderer.lock();
-                for t in renderer.tasks.iter_mut() {
-                    if matches!(t.state, CustomRenderTaskState::Used) {
-                        t.state = CustomRenderTaskState::Marked;
-                    }
-                }
-            }
-
-            fn render_end(&mut self, args: &mut zng_view::extensions::RenderArgs) {
-                let mut renderer = self.renderer.lock();
-                for t in renderer.tasks.iter_mut() {
-                    match &mut t.state {
-                        CustomRenderTaskState::Marked => t.state = CustomRenderTaskState::Free(0),
-                        CustomRenderTaskState::Free(n) if *n < MAX_FREE => {
-                            *n += 1;
-                            if *n == MAX_FREE {
-                                *n = MAX_FREE + 1;
-                                args.transaction.delete_blob_image(t.key)
-                            }
-                        }
-                        _ => {}
-                    }
-                }
-            }
-
-            fn render_push(&mut self, args: &mut RenderItemArgs) {
-                match args.payload.deserialize::<super::api::RenderPayload>() {
-                    Ok(p) => {
-                        let mut renderer = self.renderer.lock();
-                        let renderer = &mut *renderer;
-
-                        let param = if let Some(binding) = p.cursor_binding {
-                            // updatable item, gets own image
-                            CustomTaskParams::Bound(binding)
-                        } else {
-                            // not updatable item, shares images of same params
-                            CustomTaskParams::Params(p.size, p.cursor)
-                        };
-
-                        let mut key = None;
-                        if let Some(i) = renderer.task_params.get(&param) {
-                            let t = &mut renderer.tasks[*i];
-                            if matches!(t.state, CustomRenderTaskState::Marked | CustomRenderTaskState::Used) {
-                                // already rendering param
-                                key = Some(t.key);
-                                t.size = p.size;
-                                t.state = CustomRenderTaskState::Used;
-                            }
-                        }
-                        let blob_key = if let Some(k) = key {
-                            k
-                        } else {
-                            // start rendering param
-                            //
-                            // the renderer will receive an async rasterize request from Webrender
-                            // that is when we will actually render this.
-
-                            let key = if let Some(i) = renderer
-                                .tasks
-                                .iter()
-                                .position(|t| matches!(t.state, CustomRenderTaskState::Free(n) if n < MAX_FREE))
-                            {
-                                // reuse blob key
-
-                                let t = &mut renderer.tasks[i];
-                                renderer.task_params.remove(&t.param());
-
-                                if t.size != p.size {
-                                    let size = DeviceIntSize::new(p.size.width.0, p.size.height.0);
-                                    args.transaction.update_blob_image(
-                                        t.key,
-                                        ImageDescriptor {
-                                            format: ImageFormat::BGRA8,
-                                            size,
-                                            stride: None,
-                                            offset: 0,
-                                            flags: ImageDescriptorFlags::IS_OPAQUE,
-                                        },
-                                        // we only need the params (size, cursor),
-                                        // this can be used to store render commands.
-                                        Arc::new(vec![]),
-                                        DeviceIntRect::from_size(size),
-                                        &BlobDirtyRect::All,
-                                    );
-                                }
-
-                                t.size = p.size;
-                                t.cursor = p.cursor;
-                                t.cursor_binding = p.cursor_binding;
-                                t.state = CustomRenderTaskState::Used;
-
-                                renderer.task_params.insert(t.param(), i);
-
-                                t.key
-                            } else {
-                                // new blob key
-
-                                let i = renderer.tasks.len();
-
-                                let key = args.api.generate_blob_image_key();
-                                let task = CustomRenderTask {
-                                    key,
-                                    size: p.size,
-                                    cursor: p.cursor,
-                                    cursor_binding: p.cursor_binding,
-                                    state: CustomRenderTaskState::Used,
-                                };
-                                renderer.tasks.push(task);
-                                renderer.task_keys.insert(key, i);
-                                renderer.task_params.insert(param, i);
-
-                                let size = DeviceIntSize::new(p.size.width.0, p.size.height.0);
-                                args.transaction.add_blob_image(
-                                    key,
-                                    ImageDescriptor {
-                                        format: ImageFormat::BGRA8,
-                                        size,
-                                        stride: None,
-                                        offset: 0,
-                                        flags: ImageDescriptorFlags::IS_OPAQUE,
-                                    },
-                                    // we only need the params (size, cursor),
-                                    // this can be used to store render commands.
-                                    Arc::new(vec![]),
-                                    DeviceIntRect::from_size(size),
-                                    Some(128),
-                                );
-
-                                key
-                            };
-
-                            key
-                        };
-
-                        let rect = LayoutRect::from_size(p.size.to_wr());
-                        let _cursor = p.cursor.to_wr();
-
-                        let props = CommonItemProperties {
-                            clip_rect: rect,
-                            clip_chain_id: args.sc.clip_chain_id(args.list),
-                            spatial_id: args.sc.spatial_id(),
-                            flags: args.sc.primitive_flags(),
-                        };
-                        args.list.push_image(
-                            &props,
-                            rect,
-                            zng_view::webrender::api::ImageRendering::Auto,
-                            zng_view::webrender::api::AlphaType::Alpha,
-                            blob_key.as_image(),
-                            ColorF::WHITE,
-                        )
-                    }
-                    Err(e) => tracing::error!("invalid display item, {e}"),
-                }
-            }
-
-            fn render_update(&mut self, args: &mut RenderUpdateArgs) {
-                match args.payload.deserialize::<super::api::RenderUpdatePayload>() {
-                    Ok(p) => {
-                        let mut renderer = self.renderer.lock();
-                        let renderer = &mut *renderer;
-
-                        if let Some(&i) = renderer.task_params.get(&CustomTaskParams::Bound(p.cursor_binding)) {
-                            // update the render task
-
-                            let t = &mut renderer.tasks[i];
-                            t.cursor = p.cursor;
-
-                            let size = DeviceIntSize::new(t.size.width.0, t.size.height.0);
-                            args.transaction.update_blob_image(
-                                t.key,
-                                ImageDescriptor {
-                                    format: ImageFormat::BGRA8,
-                                    size,
-                                    stride: None,
-                                    offset: 0,
-                                    flags: ImageDescriptorFlags::IS_OPAQUE,
-                                },
-                                Arc::new(vec![]),
-                                DeviceIntRect::from_size(size),
-                                &BlobDirtyRect::All,
-                            );
-                        } else {
-                            // or rebuilds the display list
-                            args.new_frame = true;
-                        }
-                    }
-                    Err(e) => tracing::error!("invalid update request, {e}"),
-                }
-            }
-        }
-
-        struct CustomBlobExtension {
-            renderer: Arc<Mutex<CustomRenderer>>,
-        }
-        impl BlobExtension for CustomBlobExtension {
-            fn create_blob_rasterizer(&mut self) -> Box<dyn zng_view::extensions::AsyncBlobRasterizer> {
-                Box::new(CustomBlobRasterizer {
-                    // rasterizer is a snapshot of the current state
-                    snapshot: self.renderer.lock().clone(),
-                })
-            }
-
-            fn create_similar(&self) -> Box<dyn BlobExtension> {
-                Box::new(CustomBlobExtension {
-                    renderer: Arc::clone(&self.renderer),
-                })
-            }
-
-            fn add(&mut self, _args: &zng_view::extensions::BlobAddArgs) {
-                // Webrender received the add request
-                //
-                // In this demo we already added from the display item.
-            }
-
-            fn update(&mut self, _args: &zng_view::extensions::BlobUpdateArgs) {
-                // Webrender received the update request
-            }
-
-            fn delete(&mut self, key: BlobImageKey) {
-                // Webrender requested cleanup.
-
-                let mut renderer = self.renderer.lock();
-                let renderer = &mut *renderer;
-                if let Some(i) = renderer.task_keys.remove(&key) {
-                    let t = &mut renderer.tasks[i];
-                    renderer.task_params.remove(&t.param());
-                    t.state = CustomRenderTaskState::Free(MAX_FREE + 1);
-                }
-            }
-
-            fn enable_multithreading(&mut self, enable: bool) {
-                self.renderer.lock().single_threaded = !enable;
-            }
-        }
-
-        struct CustomBlobRasterizer {
-            snapshot: CustomRenderer,
-        }
-        impl CustomBlobRasterizer {
-            fn rasterize_tile(task: &CustomRenderTask, r: &BlobImageParams) -> BlobImageResult {
-                if r.descriptor.format != ImageFormat::BGRA8 {
-                    // you must always respond, if no extension responds Webrender will panic.
-                    return Err(BlobImageError::Other(format!("format {:?} is not supported", r.descriptor.format)));
-                }
-
-                // draw the requested tile
-                let size = r.descriptor.rect.size();
-                let offset = r.descriptor.rect.min.to_f32().to_vector();
-                let cursor = task.cursor.to_wr();
-                let max_dist = task.size.width.0 as f32;
-
-                let mut texels = Vec::with_capacity(size.area() as usize * 4);
-
-                for y in 0..size.height {
-                    for x in 0..size.width {
-                        let t = euclid::Point2D::new(x, y).to_f32() + offset;
-                        let dist = t.distance_to(cursor).min(max_dist);
-
-                        let d = 1.0 - dist / max_dist;
-                        let d = (255.0 * d).round() as u8;
-
-                        let r = if (dist % 5.0).abs() < 1.0 { d.max(50) } else { 50 };
-
-                        texels.extend([d, d, r, 255]);
-                    }
-                }
-
-                Ok(RasterizedBlobImage {
-                    rasterized_rect: DeviceIntRect::from_size(DeviceIntSize::new(size.width, size.height)),
-                    data: Arc::new(texels),
-                })
-            }
-        }
-        impl AsyncBlobRasterizer for CustomBlobRasterizer {
-            fn rasterize(&mut self, args: &mut zng_view::extensions::BlobRasterizerArgs) {
-                if !self.snapshot.single_threaded {
-                    // rasterize all tiles in parallel, Webrender also uses Rayon
-                    // but the `rasterize` call is made in the SceneBuilderThread
-                    // so we mount the workers thread-pool here.
-
-                    use zng::prelude::task::rayon::prelude::*;
-
-                    let tiles = self.snapshot.workers.as_ref().unwrap().install(|| {
-                        args.requests.par_iter().filter_map(|r| {
-                            let i = *self.snapshot.task_keys.get(&r.request.key)?;
-                            // request is for us
-                            let task = &self.snapshot.tasks[i];
-                            Some((r.request, Self::rasterize_tile(task, r)))
-                        })
-                    });
-
-                    args.responses.par_extend(tiles);
-                } else {
-                    // single-threaded mode is only for testing
-                    let tiles = args.requests.iter().filter_map(|r| {
-                        let i = *self.snapshot.task_keys.get(&r.request.key)?;
-                        let task = &self.snapshot.tasks[i];
-                        Some((r.request, Self::rasterize_tile(task, r)))
-                    });
-                    args.responses.extend(tiles);
-                }
-            }
-        }
-
-        #[derive(Clone, Default)]
-        struct CustomRenderer {
-            tasks: Vec<CustomRenderTask>,
-            task_keys: HashMap<BlobImageKey, usize>,
-            task_params: HashMap<CustomTaskParams, usize>,
-            single_threaded: bool,
-            workers: Option<Arc<zng::prelude::task::rayon::ThreadPool>>,
-        }
-
-        #[derive(PartialEq, Eq, Hash, Clone, Copy)]
-        enum CustomTaskParams {
-            Bound(super::api::BindingId),
-            Params(PxSize, PxPoint),
-        }
-
-        #[derive(Clone)]
-        struct CustomRenderTask {
-            key: BlobImageKey,
-            size: PxSize,
-            cursor: PxPoint,
-            cursor_binding: Option<super::api::BindingId>,
-            state: CustomRenderTaskState,
-        }
-
-        impl CustomRenderTask {
-            fn param(&self) -> CustomTaskParams {
-                match self.cursor_binding {
-                    Some(id) => CustomTaskParams::Bound(id),
-                    None => CustomTaskParams::Params(self.size, self.cursor),
-                }
-            }
-        }
-        #[derive(Clone, Copy, Debug)]
-        enum CustomRenderTaskState {
-            Used,
-            Marked,
-            Free(u8),
-        }
-        /// Maximum display-list rebuilds that a unused BlobImageKey is retained.
-        const MAX_FREE: u8 = 5;
-    }
-
-    pub mod api {
-        use zng_view_api::api_extension::ApiExtensionName;
-
-        pub use crate::using_display_items::api::*;
-
-        pub fn extension_name() -> ApiExtensionName {
-            ApiExtensionName::new("zng.examples.extend_renderer.using_blob").unwrap()
-        }
-    }
-}
-
-/// Demo view extension custom renderer, integrated by drawing directly over the frame.
-pub mod using_gl_overlay {
-    /// App-process stuff, nodes.
-    pub mod app_side {
-        use zng::prelude::UiNode;
-        use zng_app::view_process::VIEW_PROCESS;
-        use zng_view_api::api_extension::ApiExtensionId;
-
-        /// Node that sends external display item and updates.
-        pub fn custom_render_node() -> impl UiNode {
-            crate::using_display_items::app_side::custom_ext_node(extension_id)
-        }
-
-        pub fn extension_id() -> ApiExtensionId {
-            VIEW_PROCESS
-                .extension_id(super::api::extension_name())
-                .ok()
-                .flatten()
-                .unwrap_or(ApiExtensionId::INVALID)
-        }
-    }
-
-    /// View-process stuff, the actual extension.
-    pub mod view_side {
-        use zng::layout::{Px, PxPoint, PxRect, PxSize};
-        use zng_view::{
-            extensions::{RenderItemArgs, RenderUpdateArgs, RendererExtension, ViewExtensions},
-            gleam::gl,
-        };
-        use zng_view_api::api_extension::ApiExtensionId;
-
-        use super::api::BindingId;
-
-        pub fn extend(exts: &mut ViewExtensions) {
-            exts.renderer(super::api::extension_name(), CustomExtension::new);
-        }
-
-        struct CustomExtension {
-            // id of this extension, for tracing.
-            _id: ApiExtensionId,
-            renderer: Option<CustomRenderer>,
-        }
-        impl CustomExtension {
-            fn new(id: ApiExtensionId) -> Self {
-                Self { _id: id, renderer: None }
-            }
-        }
-        impl RendererExtension for CustomExtension {
-            fn is_init_only(&self) -> bool {
-                false // retain the extension after renderer creation.
-            }
-
-            fn renderer_inited(&mut self, args: &mut zng_view::extensions::RendererInitedArgs) {
-                // shaders/programs can be loaded here.
-                self.renderer = Some(CustomRenderer::load(&**args.context.gl()));
-            }
-            fn renderer_deinited(&mut self, args: &mut zng_view::extensions::RendererDeinitedArgs) {
-                // ..and unloaded here.
-                if let Some(r) = self.renderer.take() {
-                    r.unload(&**args.context.gl());
-                }
-            }
-
-            fn render_start(&mut self, _: &mut zng_view::extensions::RenderArgs) {
-                if let Some(r) = &mut self.renderer {
-                    r.clear();
-                }
-            }
-
-            fn render_push(&mut self, args: &mut RenderItemArgs) {
-                match args.payload.deserialize::<super::api::RenderPayload>() {
-                    Ok(p) => {
-                        // we use the display list for convenience only, each render/update
-                        // is paired with Webrender updates, this extension does not actually
-                        // use Webrender.
-                        if let Some(r) = &mut self.renderer {
-                            r.push_task(&p);
-                        }
-                    }
-                    Err(e) => tracing::error!("invalid display item, {e}"),
-                }
-            }
-
-            fn render_update(&mut self, args: &mut RenderUpdateArgs) {
-                match args.payload.deserialize::<super::api::RenderUpdatePayload>() {
-                    Ok(p) => {
-                        if let Some(r) = &mut self.renderer {
-                            r.update_task(&p);
-                        }
-                    }
-                    Err(e) => tracing::error!("invalid update request, {e}"),
-                }
-            }
-
-            fn redraw(&mut self, args: &mut zng_view::extensions::RedrawArgs) {
-                if let Some(r) = &mut self.renderer {
-                    r.redraw(args.size, &**args.context.gl());
-                }
-            }
-        }
-
-        struct CustomRenderer {
-            tasks: Vec<DrawTask>,
-            unloaded_ok: bool,
-        }
-        impl CustomRenderer {
-            pub fn load(_gl: &dyn gl::Gl) -> Self {
-                // let vao = gl.gen_vertex_arrays(1);
-
-                Self {
-                    tasks: vec![],
-                    unloaded_ok: false,
-                }
-            }
-            fn unload(mut self, _gl: &dyn gl::Gl) {
-                // gl.delete_vertex_arrays(&self.vao);
-
-                self.tasks.clear();
-                self.unloaded_ok = true;
-            }
-
-            pub fn clear(&mut self) {
-                self.tasks.clear();
-            }
-
-            pub fn push_task(&mut self, task: &super::api::RenderPayload) {
-                self.tasks.push(DrawTask {
-                    area: PxRect::new(task.window_pos, task.size),
-                    cursor: task.cursor,
-                    cursor_binding: task.cursor_binding,
-                });
-            }
-
-            pub fn update_task(&mut self, p: &super::api::RenderUpdatePayload) {
-                if let Some(i) = self.tasks.iter_mut().find(|i| i.cursor_binding == Some(p.cursor_binding)) {
-                    i.cursor = p.cursor;
-                }
-            }
-
-            pub fn redraw(&mut self, canvas_size: PxSize, gl: &dyn gl::Gl) {
-                // gl (0, 0) is the bottom-left corner not the top-left.
-                let gl_y = |max_y: Px| canvas_size.height - max_y;
-
-                for task in &self.tasks {
-                    // gl.bind_vertex_array(vao);
-
-                    gl.enable(gl::SCISSOR_TEST);
-                    gl.scissor(
-                        task.area.origin.x.0,
-                        gl_y(task.area.max_y()).0,
-                        task.area.size.width.0,
-                        task.area.size.height.0,
-                    );
-                    gl.clear_color(0.0, 0.0, 0.0, 1.0);
-                    gl.clear(gl::COLOR_BUFFER_BIT);
-                }
-                for task in &self.tasks {
-                    if task.cursor.x >= Px(0)
-                        && task.cursor.y < task.area.width()
-                        && task.cursor.y >= Px(0)
-                        && task.cursor.y < task.area.height()
-                    {
-                        let r = task.cursor.x.0 as f32 / task.area.width().0 as f32;
-                        let b = task.cursor.y.0 as f32 / task.area.height().0 as f32;
-
-                        let x = task.area.origin.x + task.cursor.x - Px(50);
-                        let y = task.area.origin.y + task.cursor.y - Px(50);
-                        let cursor = PxRect::new(PxPoint::new(x, y), PxSize::splat(Px(100)));
-
-                        gl.enable(gl::SCISSOR_TEST);
-                        gl.scissor(cursor.origin.x.0, gl_y(cursor.max_y()).0, cursor.size.width.0, cursor.size.height.0);
-                        gl.clear_color(r, 0.5, b, 1.0);
-                        gl.clear(gl::COLOR_BUFFER_BIT);
-                    }
-                }
-            }
-        }
-        impl Drop for CustomRenderer {
-            fn drop(&mut self) {
-                if !self.unloaded_ok {
-                    tracing::error!("CustomRenderer::unload was not used to drop the renderer");
-                }
-            }
-        }
-
-        #[derive(Debug)]
-        struct DrawTask {
-            area: PxRect,
-            cursor: PxPoint,
-            cursor_binding: Option<BindingId>,
-        }
-    }
-
-    pub mod api {
-        use zng_view_api::api_extension::ApiExtensionName;
-
-        pub use crate::using_display_items::api::*;
-
-        pub fn extension_name() -> ApiExtensionName {
-            ApiExtensionName::new("zng.examples.extend_renderer.using_gl_overlay").unwrap()
-        }
-    }
-}
-
-/// Demo view extension custom renderer, integrated by drawing to a texture uses as an image.
-pub mod using_gl_texture {
-    /// App-process stuff, nodes.
-    pub mod app_side {
-        use zng::prelude::UiNode;
-        use zng_app::view_process::VIEW_PROCESS;
-        use zng_view_api::api_extension::ApiExtensionId;
-
-        /// Node that sends external display item and updates.
-        pub fn custom_render_node() -> impl UiNode {
-            crate::using_display_items::app_side::custom_ext_node(extension_id)
-        }
-
-        pub fn extension_id() -> ApiExtensionId {
-            VIEW_PROCESS
-                .extension_id(super::api::extension_name())
-                .ok()
-                .flatten()
-                .unwrap_or(ApiExtensionId::INVALID)
-        }
-    }
-
-    /// View-process stuff, the actual extension.
-    pub mod view_side {
-        use zng::layout::PxRect;
-        use zng_view::{
-            extensions::{PxToWr as _, RenderItemArgs, RendererExtension, ViewExtensions},
-            gleam::gl,
-            webrender::api::{
-                units::{DeviceIntSize, TexelRect},
-                AlphaType, ColorF, CommonItemProperties, ExternalImageData, ExternalImageId, ExternalImageType, ImageDescriptor,
-                ImageDescriptorFlags, ImageFormat, ImageKey, ImageRendering,
-            },
-        };
-        use zng_view_api::api_extension::ApiExtensionId;
-
-        pub fn extend(exts: &mut ViewExtensions) {
-            exts.renderer(super::api::extension_name(), CustomExtension::new);
-        }
-
-        struct TextureInfo {
-            // texture in OpenGL.
-            texture: gl::GLuint,
-            // texture in external image registry (for webrender).
-            external_id: ExternalImageId,
-            // texture in renderer (and display lists).
-            image_key: ImageKey,
-        }
-
-        struct CustomExtension {
-            // id of this extension, for tracing.
-            _id: ApiExtensionId,
-
-            texture: Option<TextureInfo>,
-        }
-        impl CustomExtension {
-            fn new(id: ApiExtensionId) -> Self {
-                Self { _id: id, texture: None }
-            }
-        }
-        impl RendererExtension for CustomExtension {
-            fn is_init_only(&self) -> bool {
-                false // retain the extension after renderer creation.
-            }
-
-            fn renderer_inited(&mut self, args: &mut zng_view::extensions::RendererInitedArgs) {
-                // gl available here and in `redraw`.
-                //
-                // dynamic textures can be generated by collecting request on `command` or on `render_push` and
-                // generating on the next `redraw` that will happen after `render_push` or on request after `command`.
-
-                let size = DeviceIntSize::splat(100);
-
-                // OpenGL
-                let texture = args.context.gl().gen_textures(1)[0];
-                args.context.gl().bind_texture(gl::TEXTURE_2D, texture);
-                let mut img = vec![0u8; size.width as usize * size.height as usize * 4];
-                let mut line = 0u8;
-                let mut col = 0u8;
-                for rgba in img.chunks_exact_mut(4) {
-                    rgba[0] = 255;
-                    rgba[1] = 10 + line * 3;
-                    rgba[2] = 10 + line * 3;
-                    rgba[3] = 255;
-
-                    col = col.wrapping_add(1);
-                    if col == 0 {
-                        line = line.wrapping_add(1);
-                    }
-                }
-                args.context.gl().tex_image_2d(
-                    gl::TEXTURE_2D,
-                    0,
-                    gl::RGBA as _,
-                    size.width,
-                    size.height,
-                    0,
-                    gl::RGBA,
-                    gl::UNSIGNED_BYTE,
-                    Some(&img),
-                );
-
-                // Webrender
-                let external_id = args
-                    .external_images
-                    .register_texture(TexelRect::new(0.0, 0.0, size.width as f32, size.height as f32), texture);
-
-                let image_key = args.api.generate_image_key();
-                let mut txn = zng_view::webrender::Transaction::new();
-                txn.add_image(
-                    image_key,
-                    ImageDescriptor {
-                        format: ImageFormat::RGBA8,
-                        size,
-                        stride: None,
-                        offset: 0,
-                        flags: ImageDescriptorFlags::IS_OPAQUE,
-                    },
-                    zng_view::webrender::api::ImageData::External(ExternalImageData {
-                        id: external_id,
-                        channel_index: 0,
-                        image_type: ExternalImageType::TextureHandle(zng_view::webrender::api::ImageBufferKind::Texture2D),
-                    }),
-                    None,
-                );
-                args.api.send_transaction(args.document_id, txn);
-
-                self.texture = Some(TextureInfo {
-                    texture,
-                    external_id,
-                    image_key,
-                });
-            }
-
-            fn renderer_deinited(&mut self, args: &mut zng_view::extensions::RendererDeinitedArgs) {
-                if let Some(t) = self.texture.take() {
-                    let _ = t.external_id; // already cleanup by renderer deinit.
-                    args.context.gl().delete_textures(&[t.texture]);
-                }
-            }
-
-            fn render_push(&mut self, args: &mut RenderItemArgs) {
-                match args.payload.deserialize::<super::api::RenderPayload>() {
-                    Ok(p) => {
-                        if let Some(t) = &self.texture {
-                            let rect = PxRect::from_size(p.size).to_wr();
-                            let props = CommonItemProperties {
-                                clip_rect: rect,
-                                clip_chain_id: args.sc.clip_chain_id(args.list),
-                                spatial_id: args.sc.spatial_id(),
-                                flags: args.sc.primitive_flags(),
-                            };
-                            args.list
-                                .push_image(&props, rect, ImageRendering::Auto, AlphaType::Alpha, t.image_key, ColorF::WHITE);
-                        }
-                    }
-                    Err(e) => tracing::error!("invalid display item, {e}"),
-                }
-            }
-        }
-    }
-
-    pub mod api {
-        use zng_view_api::api_extension::ApiExtensionName;
-
-        pub use crate::using_display_items::api::*;
-
-        pub fn extension_name() -> ApiExtensionName {
-            ApiExtensionName::new("zng.examples.extend_renderer.using_gl_texture").unwrap()
-        }
-    }
-}
diff --git a/examples/focus/Cargo.toml b/examples/focus/Cargo.toml
new file mode 100644
index 000000000..1e80547f3
--- /dev/null
+++ b/examples/focus/Cargo.toml
@@ -0,0 +1,9 @@
+[package]
+name = "focus"
+version = "0.0.0"
+publish = false
+edition = "2021"
+
+[dependencies]
+zng = { path = "../../crates/zng", features = ["view_prebuilt"] }
+tracing = "0.1"
\ No newline at end of file
diff --git a/examples/res/screenshots/focus.png b/examples/focus/res/screenshot.png
similarity index 100%
rename from examples/res/screenshots/focus.png
rename to examples/focus/res/screenshot.png
diff --git a/examples/focus.rs b/examples/focus/src/main.rs
similarity index 99%
rename from examples/focus.rs
rename to examples/focus/src/main.rs
index f27bb4fe7..e52949d84 100644
--- a/examples/focus.rs
+++ b/examples/focus/src/main.rs
@@ -21,7 +21,6 @@ use zng::{
 };
 
 fn main() {
-    examples_util::print_info();
     zng::env::init!();
     zng::app::crash_handler::init_debug();
 
diff --git a/examples/gradient/Cargo.toml b/examples/gradient/Cargo.toml
new file mode 100644
index 000000000..738a2676a
--- /dev/null
+++ b/examples/gradient/Cargo.toml
@@ -0,0 +1,8 @@
+[package]
+name = "gradient"
+version = "0.0.0"
+publish = false
+edition = "2021"
+
+[dependencies]
+zng = { path = "../../crates/zng", features = ["view_prebuilt"] }
diff --git a/examples/res/screenshots/gradient.png b/examples/gradient/res/screenshot.png
similarity index 100%
rename from examples/res/screenshots/gradient.png
rename to examples/gradient/res/screenshot.png
diff --git a/examples/gradient.rs b/examples/gradient/src/main.rs
similarity index 99%
rename from examples/gradient.rs
rename to examples/gradient/src/main.rs
index 6c39bf904..2bdeeafb0 100644
--- a/examples/gradient.rs
+++ b/examples/gradient/src/main.rs
@@ -14,7 +14,6 @@ use zng::{
 };
 
 fn main() {
-    examples_util::print_info();
     zng::env::init!();
     zng::app::crash_handler::init_debug();
 
diff --git a/examples/headless.rs b/examples/headless.rs
deleted file mode 100644
index 307106d47..000000000
--- a/examples/headless.rs
+++ /dev/null
@@ -1,360 +0,0 @@
-//! Demonstrates headless apps, image and video rendering.
-
-use std::{io::Write, path::PathBuf};
-
-use zng::{
-    color::{
-        self,
-        gradient::{linear_gradient, GradientStops},
-    },
-    image::Img,
-    prelude::*,
-    stack::stack_nodes,
-    window::{FrameCaptureMode, FrameImageReadyArgs, HeadlessAppWindowExt},
-};
-
-fn main() {
-    examples_util::print_info();
-    zng::env::init!();
-
-    // zng::view_process::prebuilt::run_same_process(headless_example);
-    headless_example();
-    // headless_example_video();
-    // images_render();
-}
-
-#[allow(unused)]
-fn headless_example() {
-    println!("-=Headless Example=-\n");
-    // This example uses a headless window to render an image.
-
-    // open headless with renderer flag, this causes the view-process to start.
-    let mut app = APP.defaults().run_headless(true);
-
-    app.run_window(async {
-        Window! {
-            // the window content is the image.
-            child = image();
-            auto_size = true;
-
-            // use the CPU only backend if available, by default the
-            // same GPU used for headed windows is used.
-            render_mode = window::RenderMode::Software;
-
-            // capture the first frame.
-            frame_capture_mode = FrameCaptureMode::Next;
-
-            // this event will fire every time a frame is rendered (just once in this case).
-            on_frame_image_ready = async_hn_once!(|args: FrameImageReadyArgs| {
-                // in this case a `frame_image` was already captured.
-                let img = args.frame_image.unwrap();
-
-                // we save it...
-                print!("saving ./screenshot.png ... ");
-                flush_stdout();
-
-                img.save("screenshot.png").await.unwrap();
-
-                println!("done");
-                APP.exit();
-            });
-        }
-    });
-}
-
-// A 800x600 "Hello World!" with a fancy background.
-fn image() -> impl UiNode {
-    Container! {
-        layout::size = (800, 600);
-
-        widget::background = stack_nodes({
-            fn gradient(angle: i32, mut color: color::Rgba) -> impl UiNode {
-                color.alpha = 0.3;
-                let stops = GradientStops::from_stripes(&[color, color.transparent()], 0.0);
-                linear_gradient(angle.deg(), stops)
-            }
-
-            ui_vec![
-                color::flood(colors::WHITE),
-                gradient(0, colors::RED),
-                gradient(20, colors::RED),
-                gradient(40, colors::RED),
-                gradient(120, colors::GREEN),
-                gradient(140, colors::GREEN),
-                gradient(160, colors::GREEN),
-                gradient(240, colors::BLUE),
-                gradient(260, colors::BLUE),
-                gradient(280, colors::BLUE),
-            ]
-        });
-
-        child = Text! {
-            layout::align = Align::CENTER;
-            txt = "Hello World!";
-            font_size = 72;
-            font_family = ["Consolas", "monospace"];
-            font_color = colors::WHITE;
-        };
-    }
-}
-
-fn flush_stdout() {
-    std::io::stdout().lock().flush().ok();
-}
-
-/// You can also use the `IMAGES` service to render to an image.
-#[allow(unused)]
-fn images_render() {
-    println!("-=IMAGES.render Example=-\n");
-
-    // open headless with renderer flag, this causes the view-process to start.
-    let mut app = APP.defaults().run_headless(true);
-
-    // request an image rendered from a node, the `IMAGES` service will render the node and update the image
-    // variable every time the node (re)renders.
-    let img = zng::image::IMAGES.render_node(window::RenderMode::Integrated, 1.fct(), None, logo);
-
-    app.run_task(async move {
-        while img.with(Img::is_loading) {
-            img.wait_update().await;
-        }
-        let img = img.get();
-
-        if img.is_loaded() {
-            // we save it...
-            print!("saving ./examples/res/image/zng-logo.png ... ");
-            flush_stdout();
-
-            img.save("examples/res/image/zng-logo-icon.png").await.unwrap();
-
-            println!("done");
-        } else if let Some(err) = img.error() {
-            eprintln!("[error]: {err}");
-        }
-    });
-
-    // Internally the `IMAGES` service uses a headless window for rendering too, but this method is more easy
-    // to use, with the trade-off that you have less control over the headless window.
-}
-
-fn video(finished: zng::var::ArcVar<bool>) -> impl UiNode {
-    let bkg_rotate = var(0.turn());
-    let txt_fade = var(0.fct());
-    let txt_size = var(32.dip());
-    let fade_out = var(0.fct());
-    Container! {
-        layout::size = (800, 600);
-
-        widget::on_init = async_hn!(txt_fade, txt_size, bkg_rotate, fade_out, finished, |_| {
-            task::deadline(300.ms()).await;
-            txt_fade.ease(1.fct(), 800.ms(), easing::linear).perm();
-            txt_size.ease(72, 800.ms(), easing::linear).perm();
-
-            task::deadline(100.ms()).await;
-            bkg_rotate.ease(5.turn(), 10.secs(), easing::circ).perm();
-
-            task::deadline(8.secs()).await;
-            txt_size.ease(120, 2.secs(), easing::linear).perm();
-            txt_fade.ease(0.fct(), 2.secs(), easing::linear).perm();
-
-            task::deadline(1.secs()).await;
-            fade_out.ease(1.fct(), 1.secs(), easing::linear).perm();
-
-            bkg_rotate.wait_animation().await;
-            finished.set(true);
-        });
-
-        widget::background = Stack! {
-            children = {
-                let gradient = clmv!(bkg_rotate, |angle: i32, mut color: color::Rgba| {
-                    color.alpha = 0.3;
-                    let stops = color::gradient::GradientStops::from_stripes(&[color, color.transparent()], 0.0);
-                    let angle = angle.deg();
-                    color::gradient::linear_gradient(
-                        bkg_rotate.map(move |r| (angle + layout::AngleDegree::from(*r)).into()),
-                        stops
-                    )
-                });
-
-                ui_vec![
-                    color::flood(colors::WHITE),
-                    gradient(0, colors::RED),
-                    gradient(20, colors::RED),
-                    gradient(40, colors::RED),
-                    gradient(120, colors::GREEN),
-                    gradient(140, colors::GREEN),
-                    gradient(160, colors::GREEN),
-                    gradient(240, colors::BLUE),
-                    gradient(260, colors::BLUE),
-                    gradient(280, colors::BLUE),
-                ]
-            };
-        };
-        widget::foreground_color = fade_out.map(|&o| colors::BLACK.with_alpha(o));
-
-        child = Text! {
-            layout::align = Align::CENTER;
-            txt = "Hello World!";
-            font_family = ["Consolas", "monospace"];
-            font_color = colors::WHITE;
-            font_size = txt_size;
-            color::filter::opacity = txt_fade;
-        };
-    }
-}
-
-#[allow(unused)]
-fn headless_example_video() {
-    println!("-=Headless Example (video)=-\n");
-    // This example uses a headless window to render frames for FFmpeg.
-
-    // open headless with renderer flag, this causes the view-process to start.
-    let mut app = APP.defaults().run_headless(true);
-    // saving frame can be slow, so we will manually control the app time to not miss any frame.
-    APP.start_manual_time();
-
-    const FPS: f32 = 60.0;
-    zng::var::VARS.frame_duration().set((1.0 / FPS).secs());
-
-    app.run_window(async {
-        // will save frames as "{temp}/{frame}.png"
-        let temp = PathBuf::from("target/tmp/headless_example_video");
-        let _ = std::fs::remove_dir_all(&temp);
-        std::fs::create_dir_all(&temp).unwrap();
-
-        let frame = var(0u32);
-        let recorded = var(false);
-        print_status("recording", &recorded);
-
-        Window! {
-            // the window content is the "video".
-            child = video(recorded.clone());
-            auto_size = true;
-
-            // use the CPU only backend if available, by default the
-            // same GPU used for headed windows is used.
-            render_mode = window::RenderMode::Software;
-
-            // capture all frames.
-            frame_capture_mode = FrameCaptureMode::All;
-
-            // this event will fire every time a frame is rendered.
-            on_frame_image_ready = async_hn!(temp, frame, |args: FrameImageReadyArgs| {
-                let img = args.frame_image.unwrap();
-
-                let frame_i = frame.get();
-                frame.set(frame_i + 1);
-
-                img.save(temp.join(format!("{frame_i:05}.png"))).await.unwrap();
-
-                // advance time at a perfect framerate.
-                APP.advance_manual_time((1.0 / FPS).secs());
-                // ensure a frame image is actually generated (for video).
-                //
-                // also, retained rendering only renders when needed, so without this
-                // line the app never even updates, and the initial delay timer waits forever.
-                WIDGET.render_update();
-            });
-
-            on_load = async_hn!(recorded, temp, |_| {
-                recorded.wait_value(|&f| f).await;
-
-                let encoded = var(false);
-                print_status("encoding", &encoded);
-
-                task::spawn_wait(clmv!(encoded, || {
-                    // https://www.ffmpeg.org/download.html
-                    let ffmpeg = std::process::Command::new("ffmpeg")
-                    .arg("-framerate")
-                    .arg(FPS.to_string())
-                    .arg("-y")
-                    .arg("-i")
-                    .arg(temp.join("%05d.png"))
-                    .arg("-c:v")
-                    .arg("libx264")
-                    .arg("-pix_fmt")
-                    .arg("yuv420p")
-                    .arg("screencast.mp4")
-                    .arg("-hide_banner")
-                    .arg("-loglevel")
-                    .arg("error")
-                    .status();
-                    let _ = std::fs::remove_dir_all(temp);
-                    encoded.set(true);
-
-                    assert!(ffmpeg.unwrap().success());
-                }));
-                encoded.wait_value(|&f| f).await;
-                println!("\rfinished.");
-
-                APP.exit();
-            });
-        }
-    });
-    while !matches!(app.update(true), zng::app::AppControlFlow::Exit) {}
-}
-
-fn print_status(task: &'static str, done: &zng::var::ArcVar<bool>) {
-    task::spawn(async_clmv!(done, {
-        let mut dots = 0;
-        while !done.get() {
-            dots += 1;
-            if dots > 3 {
-                dots = 0;
-            }
-            print!("\r                      ");
-            print!("\r{task}{}", String::from_utf8(vec![b'.'; dots]).unwrap());
-            flush_stdout();
-            task::deadline(500.ms()).await;
-        }
-    }));
-}
-
-fn logo() -> impl UiNode {
-    let logo = Container! {
-        layout::size = 200;
-        layout::perspective = 500;
-
-        child = Stack! {
-            layout::transform_style = layout::TransformStyle::Preserve3D;
-            text::font_size = 180;
-            text::font_family = "Arial";
-            text::font_weight = FontWeight::EXTRA_BOLD;
-            text::txt_align = Align::CENTER;
-            text::font_color = colors::WHITE;
-            layout::transform = layout::Transform::new_rotate_y((-45).deg()).rotate_x((-35).deg()).translate_z(-100);
-            children = ui_vec![
-                Text! {
-                    txt = "Z";
-                    layout::padding = (-40, 0, 0, 0);
-                    layout::transform = layout::Transform::new_translate_z(100);
-                    widget::background_color = colors::RED.darken(50.pct());
-                    widget::border = (0, 0, 12, 12), colors::WHITE;
-                },
-                Text! {
-                    txt = "Z";
-                    layout::padding = (-40, 0, 0, 0);
-                    layout::transform = layout::Transform::new_translate_z(100).rotate_x(90.deg());
-                    widget::background_color = colors::GREEN.darken(50.pct());
-                    widget::border = (12, 0, 0, 12), colors::WHITE;
-                },
-                Text! {
-                    txt = "g";
-                    layout::padding = (-75, 0, 0, 0);
-                    layout::transform = layout::Transform::new_translate_z(100).rotate_y(90.deg());
-                    widget::background_color = colors::BLUE.darken(50.pct());
-                    widget::border = (0, 12, 12, 0), colors::WHITE;
-                },
-            ];
-        }
-    };
-
-    Container! {
-        layout::size = 278;
-
-        child_align = Align::CENTER;
-        padding = (-27, 0, 0, 0);
-
-        child = logo;
-    }
-}
diff --git a/examples/headless/Cargo.toml b/examples/headless/Cargo.toml
new file mode 100644
index 000000000..95a9f04c6
--- /dev/null
+++ b/examples/headless/Cargo.toml
@@ -0,0 +1,8 @@
+[package]
+name = "headless"
+version = "0.0.0"
+publish = false
+edition = "2021"
+
+[dependencies]
+zng = { path = "../../crates/zng", features = ["view_prebuilt"] }
diff --git a/examples/res/screenshots/headless.png b/examples/headless/res/screenshot.png
similarity index 100%
rename from examples/res/screenshots/headless.png
rename to examples/headless/res/screenshot.png
diff --git a/examples/headless/src/headless.rs b/examples/headless/src/headless.rs
new file mode 100644
index 000000000..e312e65e3
--- /dev/null
+++ b/examples/headless/src/headless.rs
@@ -0,0 +1,83 @@
+use std::io::Write as _;
+use zng::{
+    color::{
+        self,
+        gradient::{linear_gradient, GradientStops},
+    },
+    prelude::*,
+    stack::stack_nodes,
+    window::{FrameCaptureMode, FrameImageReadyArgs, HeadlessAppWindowExt},
+};
+
+/// This example uses a headless window to render an image.
+pub fn run() {
+    println!("-=Headless Example=-\n");
+
+    // open headless with renderer flag, this causes the view-process to start.
+    let mut app = APP.defaults().run_headless(true);
+
+    app.run_window(async {
+        Window! {
+            // the window content is the image.
+            child = image();
+            auto_size = true;
+
+            // use the CPU only backend if available, by default the
+            // same GPU used for headed windows is used.
+            render_mode = window::RenderMode::Software;
+
+            // capture the first frame.
+            frame_capture_mode = FrameCaptureMode::Next;
+
+            // this event will fire every time a frame is rendered (just once in this case).
+            on_frame_image_ready = async_hn_once!(|args: FrameImageReadyArgs| {
+                // in this case a `frame_image` was already captured.
+                let img = args.frame_image.unwrap();
+
+                // we save it...
+                print!("saving ./screenshot.png ... ");
+                std::io::stdout().lock().flush().ok();
+
+                img.save("screenshot.png").await.unwrap();
+
+                println!("done");
+                APP.exit();
+            });
+        }
+    });
+}
+
+fn image() -> impl UiNode {
+    Container! {
+        layout::size = (800, 600);
+
+        widget::background = stack_nodes({
+            fn gradient(angle: i32, mut color: color::Rgba) -> impl UiNode {
+                color.alpha = 0.3;
+                let stops = GradientStops::from_stripes(&[color, color.transparent()], 0.0);
+                linear_gradient(angle.deg(), stops)
+            }
+
+            ui_vec![
+                color::flood(colors::WHITE),
+                gradient(0, colors::RED),
+                gradient(20, colors::RED),
+                gradient(40, colors::RED),
+                gradient(120, colors::GREEN),
+                gradient(140, colors::GREEN),
+                gradient(160, colors::GREEN),
+                gradient(240, colors::BLUE),
+                gradient(260, colors::BLUE),
+                gradient(280, colors::BLUE),
+            ]
+        });
+
+        child = Text! {
+            layout::align = Align::CENTER;
+            txt = "Hello World!";
+            font_size = 72;
+            font_family = ["Consolas", "monospace"];
+            font_color = colors::WHITE;
+        };
+    }
+}
diff --git a/examples/headless/src/image.rs b/examples/headless/src/image.rs
new file mode 100644
index 000000000..5b0502dc1
--- /dev/null
+++ b/examples/headless/src/image.rs
@@ -0,0 +1,85 @@
+use std::io::Write as _;
+use zng::{image::Img, prelude::*};
+
+/// This example uses the `IMAGES` service to render to an image.
+pub fn run() {
+    println!("-=IMAGES.render Example=-\n");
+
+    // open headless with renderer flag, this causes the view-process to start.
+    let mut app = APP.defaults().run_headless(true);
+
+    // request an image rendered from a node, the `IMAGES` service will render the node and update the image
+    // variable every time the node (re)renders.
+    let img = zng::image::IMAGES.render_node(window::RenderMode::Integrated, 1.fct(), None, logo);
+
+    app.run_task(async move {
+        while img.with(Img::is_loading) {
+            img.wait_update().await;
+        }
+        let img = img.get();
+
+        if img.is_loaded() {
+            // we save it...
+            print!("saving ./examples/image/res/zng-logo.png ... ");
+            std::io::stdout().lock().flush().ok();
+
+            img.save("examples/image/res/zng-logo-icon.png").await.unwrap();
+
+            println!("done");
+        } else if let Some(err) = img.error() {
+            eprintln!("[error]: {err}");
+        }
+    });
+
+    // Internally the `IMAGES` service uses a headless window for rendering too, but this method is more easy
+    // to use, with the trade-off that you have less control over the headless window.
+}
+
+fn logo() -> impl UiNode {
+    let logo = Container! {
+        layout::size = 200;
+        layout::perspective = 500;
+
+        child = Stack! {
+            layout::transform_style = layout::TransformStyle::Preserve3D;
+            text::font_size = 180;
+            text::font_family = "Arial";
+            text::font_weight = FontWeight::EXTRA_BOLD;
+            text::txt_align = Align::CENTER;
+            text::font_color = colors::WHITE;
+            layout::transform = layout::Transform::new_rotate_y((-45).deg()).rotate_x((-35).deg()).translate_z(-100);
+            children = ui_vec![
+                Text! {
+                    txt = "Z";
+                    layout::padding = (-40, 0, 0, 0);
+                    layout::transform = layout::Transform::new_translate_z(100);
+                    widget::background_color = colors::RED.darken(50.pct());
+                    widget::border = (0, 0, 12, 12), colors::WHITE;
+                },
+                Text! {
+                    txt = "Z";
+                    layout::padding = (-40, 0, 0, 0);
+                    layout::transform = layout::Transform::new_translate_z(100).rotate_x(90.deg());
+                    widget::background_color = colors::GREEN.darken(50.pct());
+                    widget::border = (12, 0, 0, 12), colors::WHITE;
+                },
+                Text! {
+                    txt = "g";
+                    layout::padding = (-75, 0, 0, 0);
+                    layout::transform = layout::Transform::new_translate_z(100).rotate_y(90.deg());
+                    widget::background_color = colors::BLUE.darken(50.pct());
+                    widget::border = (0, 12, 12, 0), colors::WHITE;
+                },
+            ];
+        }
+    };
+
+    Container! {
+        layout::size = 278;
+
+        child_align = Align::CENTER;
+        padding = (-27, 0, 0, 0);
+
+        child = logo;
+    }
+}
diff --git a/examples/headless/src/main.rs b/examples/headless/src/main.rs
new file mode 100644
index 000000000..7b4671f9a
--- /dev/null
+++ b/examples/headless/src/main.rs
@@ -0,0 +1,14 @@
+//! Demonstrates headless apps, image and video rendering.
+
+mod headless;
+mod image;
+mod video;
+
+fn main() {
+    zng::env::init!();
+    match std::env::args().nth(1).unwrap_or_default().as_str() {
+        "image" => image::run(),
+        "video" => video::run(),
+        _ => headless::run(),
+    }
+}
diff --git a/examples/headless/src/video.rs b/examples/headless/src/video.rs
new file mode 100644
index 000000000..8bc6c25aa
--- /dev/null
+++ b/examples/headless/src/video.rs
@@ -0,0 +1,175 @@
+use std::io::Write as _;
+use window::{FrameCaptureMode, FrameImageReadyArgs};
+use zng::prelude::*;
+
+/// This example uses a headless window to render frames for FFmpeg.
+pub fn run() {
+    println!("-=Headless Example (video)=-\n");
+
+    // open headless with renderer flag, this causes the view-process to start.
+    let mut app = APP.defaults().run_headless(true);
+    // saving frame can be slow, so we will manually control the app time to not miss any frame.
+    APP.start_manual_time();
+
+    const FPS: f32 = 60.0;
+    zng::var::VARS.frame_duration().set((1.0 / FPS).secs());
+
+    app.run_window(async {
+        // will save frames as "{temp}/{frame}.png"
+        let temp = zng::env::cache("headless_example_video");
+        let _ = std::fs::remove_dir_all(&temp);
+        std::fs::create_dir_all(&temp).unwrap();
+
+        let frame = var(0u32);
+        let recorded = var(false);
+        print_status("recording", &recorded);
+
+        Window! {
+            // the window content is the "video".
+            child = video(recorded.clone());
+            auto_size = true;
+
+            // use the CPU only backend if available, by default the
+            // same GPU used for headed windows is used.
+            render_mode = window::RenderMode::Software;
+
+            // capture all frames.
+            frame_capture_mode = FrameCaptureMode::All;
+
+            // this event will fire every time a frame is rendered.
+            on_frame_image_ready = async_hn!(temp, frame, |args: FrameImageReadyArgs| {
+                let img = args.frame_image.unwrap();
+
+                let frame_i = frame.get();
+                frame.set(frame_i + 1);
+
+                img.save(temp.join(format!("{frame_i:05}.png"))).await.unwrap();
+
+                // advance time at a perfect framerate.
+                APP.advance_manual_time((1.0 / FPS).secs());
+                // ensure a frame image is actually generated (for video).
+                //
+                // also, retained rendering only renders when needed, so without this
+                // line the app never even updates, and the initial delay timer waits forever.
+                WIDGET.render_update();
+            });
+
+            on_load = async_hn!(recorded, temp, |_| {
+                recorded.wait_value(|&f| f).await;
+
+                let encoded = var(false);
+                print_status("encoding", &encoded);
+
+                task::spawn_wait(clmv!(encoded, || {
+                    // https://www.ffmpeg.org/download.html
+                    let ffmpeg = std::process::Command::new("ffmpeg")
+                    .arg("-framerate")
+                    .arg(FPS.to_string())
+                    .arg("-y")
+                    .arg("-i")
+                    .arg(temp.join("%05d.png"))
+                    .arg("-c:v")
+                    .arg("libx264")
+                    .arg("-pix_fmt")
+                    .arg("yuv420p")
+                    .arg("screencast.mp4")
+                    .arg("-hide_banner")
+                    .arg("-loglevel")
+                    .arg("error")
+                    .status();
+                    let _ = std::fs::remove_dir_all(temp);
+                    encoded.set(true);
+
+                    assert!(ffmpeg.unwrap().success());
+                }));
+                encoded.wait_value(|&f| f).await;
+                println!("\rfinished.");
+
+                APP.exit();
+            });
+        }
+    });
+    while !matches!(app.update(true), zng::app::AppControlFlow::Exit) {}
+}
+
+fn video(finished: zng::var::ArcVar<bool>) -> impl UiNode {
+    let bkg_rotate = var(0.turn());
+    let txt_fade = var(0.fct());
+    let txt_size = var(32.dip());
+    let fade_out = var(0.fct());
+    Container! {
+        layout::size = (800, 600);
+
+        widget::on_init = async_hn!(txt_fade, txt_size, bkg_rotate, fade_out, finished, |_| {
+            task::deadline(300.ms()).await;
+            txt_fade.ease(1.fct(), 800.ms(), easing::linear).perm();
+            txt_size.ease(72, 800.ms(), easing::linear).perm();
+
+            task::deadline(100.ms()).await;
+            bkg_rotate.ease(5.turn(), 10.secs(), easing::circ).perm();
+
+            task::deadline(8.secs()).await;
+            txt_size.ease(120, 2.secs(), easing::linear).perm();
+            txt_fade.ease(0.fct(), 2.secs(), easing::linear).perm();
+
+            task::deadline(1.secs()).await;
+            fade_out.ease(1.fct(), 1.secs(), easing::linear).perm();
+
+            bkg_rotate.wait_animation().await;
+            finished.set(true);
+        });
+
+        widget::background = Stack! {
+            children = {
+                let gradient = clmv!(bkg_rotate, |angle: i32, mut color: color::Rgba| {
+                    color.alpha = 0.3;
+                    let stops = color::gradient::GradientStops::from_stripes(&[color, color.transparent()], 0.0);
+                    let angle = angle.deg();
+                    color::gradient::linear_gradient(
+                        bkg_rotate.map(move |r| (angle + layout::AngleDegree::from(*r)).into()),
+                        stops
+                    )
+                });
+
+                ui_vec![
+                    color::flood(colors::WHITE),
+                    gradient(0, colors::RED),
+                    gradient(20, colors::RED),
+                    gradient(40, colors::RED),
+                    gradient(120, colors::GREEN),
+                    gradient(140, colors::GREEN),
+                    gradient(160, colors::GREEN),
+                    gradient(240, colors::BLUE),
+                    gradient(260, colors::BLUE),
+                    gradient(280, colors::BLUE),
+                ]
+            };
+        };
+        widget::foreground_color = fade_out.map(|&o| colors::BLACK.with_alpha(o));
+
+        child = Text! {
+            layout::align = Align::CENTER;
+            txt = "Hello World!";
+            font_family = ["Consolas", "monospace"];
+            font_color = colors::WHITE;
+            font_size = txt_size;
+            color::filter::opacity = txt_fade;
+        };
+    }
+}
+
+fn print_status(task: &'static str, done: &zng::var::ArcVar<bool>) {
+    task::spawn(async_clmv!(done, {
+        let mut dots = 0;
+        while !done.get() {
+            dots += 1;
+            if dots > 3 {
+                dots = 0;
+            }
+            print!("\r                      ");
+            print!("\r{task}{}", String::from_utf8(vec![b'.'; dots]).unwrap());
+            std::io::stdout().lock().flush().ok();
+            task::deadline(500.ms()).await;
+        }
+    }));
+}
diff --git a/examples/hot-reload-lib/Cargo.toml b/examples/hot-reload-lib/Cargo.toml
index 0ef1921cf..cb881e5ee 100644
--- a/examples/hot-reload-lib/Cargo.toml
+++ b/examples/hot-reload-lib/Cargo.toml
@@ -1,5 +1,5 @@
 [package]
-name = "examples-hot-reload"
+name = "hot-reload-lib"
 version = "0.0.0"
 edition = "2021"
 publish = false
diff --git a/examples/hot-reload/Cargo.toml b/examples/hot-reload/Cargo.toml
new file mode 100644
index 000000000..a0a8509b0
--- /dev/null
+++ b/examples/hot-reload/Cargo.toml
@@ -0,0 +1,9 @@
+[package]
+name = "hot-reload"
+version = "0.0.0"
+publish = false
+edition = "2021"
+
+[dependencies]
+hot-reload-lib = { path = "../hot-reload-lib" }
+zng = { path = "../../crates/zng", features = ["view_prebuilt"] }
\ No newline at end of file
diff --git a/examples/res/screenshots/hot_reload.png b/examples/hot-reload/res/screenshot.png
similarity index 100%
rename from examples/res/screenshots/hot_reload.png
rename to examples/hot-reload/res/screenshot.png
diff --git a/examples/hot_reload.rs b/examples/hot-reload/src/main.rs
similarity index 90%
rename from examples/hot_reload.rs
rename to examples/hot-reload/src/main.rs
index 88ad0cdf9..5aca358e6 100644
--- a/examples/hot_reload.rs
+++ b/examples/hot-reload/src/main.rs
@@ -5,7 +5,6 @@
 use zng::prelude::*;
 
 fn main() {
-    examples_util::print_info();
     zng::env::init!();
     zng::app::crash_handler::init_debug();
     app_main();
@@ -22,10 +21,10 @@ fn app_main() {
         let state = var(true);
         let example = Container! {
             // hot reloading node, edit the code in `examples/hot-reload-lib` to see updates.
-            child = examples_hot_reload::hot_node();
+            child = hot_reload_lib::hot_node();
 
             // hot reloading property, the state does not change, but changes in the property code update.
-            examples_hot_reload::hot_prop = state.clone();
+            hot_reload_lib::hot_prop = state.clone();
             gesture::on_click = hn!(|_| { state.set(!state.get()); });
         };
 
diff --git a/examples/icon/Cargo.toml b/examples/icon/Cargo.toml
new file mode 100644
index 000000000..09e16a541
--- /dev/null
+++ b/examples/icon/Cargo.toml
@@ -0,0 +1,9 @@
+[package]
+name = "icon"
+version = "0.0.0"
+publish = false
+edition = "2021"
+
+[dependencies]
+zng = { path = "../../crates/zng", features = ["view_prebuilt"] }
+tracing = "0.1"
\ No newline at end of file
diff --git a/examples/res/screenshots/icon.png b/examples/icon/res/screenshot.png
similarity index 100%
rename from examples/res/screenshots/icon.png
rename to examples/icon/res/screenshot.png
diff --git a/examples/icon.rs b/examples/icon/src/main.rs
similarity index 99%
rename from examples/icon.rs
rename to examples/icon/src/main.rs
index 3316c38cf..f58904fc8 100644
--- a/examples/icon.rs
+++ b/examples/icon/src/main.rs
@@ -23,7 +23,6 @@ use zng::{
 };
 
 fn main() {
-    examples_util::print_info();
     zng::env::init!();
     zng::app::crash_handler::init_debug();
 
diff --git a/examples/image/Cargo.toml b/examples/image/Cargo.toml
new file mode 100644
index 000000000..3da6e95df
--- /dev/null
+++ b/examples/image/Cargo.toml
@@ -0,0 +1,10 @@
+[package]
+name = "image"
+version = "0.0.0"
+publish = false
+edition = "2021"
+
+[dependencies]
+zng = { path = "../../crates/zng", features = ["view_prebuilt", "http"] }
+zng-wgt-webrender-debug = { path = "../../crates/zng-wgt-webrender-debug" }
+tracing = "0.1"
\ No newline at end of file
diff --git a/examples/res/image/Luma16.png b/examples/image/res/Luma16.png
similarity index 100%
rename from examples/res/image/Luma16.png
rename to examples/image/res/Luma16.png
diff --git a/examples/res/image/Luma8.png b/examples/image/res/Luma8.png
similarity index 100%
rename from examples/res/image/Luma8.png
rename to examples/image/res/Luma8.png
diff --git a/examples/res/image/LumaA16.png b/examples/image/res/LumaA16.png
similarity index 100%
rename from examples/res/image/LumaA16.png
rename to examples/image/res/LumaA16.png
diff --git a/examples/res/image/LumaA8.png b/examples/image/res/LumaA8.png
similarity index 100%
rename from examples/res/image/LumaA8.png
rename to examples/image/res/LumaA8.png
diff --git a/examples/res/image/RGB16.png b/examples/image/res/RGB16.png
similarity index 100%
rename from examples/res/image/RGB16.png
rename to examples/image/res/RGB16.png
diff --git a/examples/res/image/RGB8.png b/examples/image/res/RGB8.png
similarity index 100%
rename from examples/res/image/RGB8.png
rename to examples/image/res/RGB8.png
diff --git a/examples/res/image/RGBA16.png b/examples/image/res/RGBA16.png
similarity index 100%
rename from examples/res/image/RGBA16.png
rename to examples/image/res/RGBA16.png
diff --git a/examples/res/image/RGBA8.png b/examples/image/res/RGBA8.png
similarity index 100%
rename from examples/res/image/RGBA8.png
rename to examples/image/res/RGBA8.png
diff --git a/examples/res/image/player_combat_sheet-10-96x84-CC0.png b/examples/image/res/player_combat_sheet-10-96x84-CC0.png
similarity index 100%
rename from examples/res/image/player_combat_sheet-10-96x84-CC0.png
rename to examples/image/res/player_combat_sheet-10-96x84-CC0.png
diff --git a/examples/res/screenshots/image.png b/examples/image/res/screenshot.png
similarity index 100%
rename from examples/res/screenshots/image.png
rename to examples/image/res/screenshot.png
diff --git a/examples/res/image/zdenek-machacek-unsplash.jpg b/examples/image/res/zdenek-machacek-unsplash.jpg
similarity index 100%
rename from examples/res/image/zdenek-machacek-unsplash.jpg
rename to examples/image/res/zdenek-machacek-unsplash.jpg
diff --git a/examples/res/image/zng-logo-icon.png b/examples/image/res/zng-logo-icon.png
similarity index 100%
rename from examples/res/image/zng-logo-icon.png
rename to examples/image/res/zng-logo-icon.png
diff --git a/examples/res/image/zng-logo.png b/examples/image/res/zng-logo.png
similarity index 100%
rename from examples/res/image/zng-logo.png
rename to examples/image/res/zng-logo.png
diff --git a/examples/image.rs b/examples/image/src/main.rs
similarity index 95%
rename from examples/image.rs
rename to examples/image/src/main.rs
index cc423f461..08642492f 100644
--- a/examples/image.rs
+++ b/examples/image/src/main.rs
@@ -24,8 +24,8 @@ use zng::{
 use zng_wgt_webrender_debug as wr;
 
 fn main() {
-    examples_util::print_info();
     zng::env::init!();
+    zng::env::init_res(concat!(env!("CARGO_MANIFEST_DIR"), "/res"));
     zng::app::crash_handler::init_debug();
 
     // let rec = examples_util::record_profile("image");
@@ -48,7 +48,7 @@ fn app_main() {
         // setup a file cache so we don't download the images every run.
         http::set_default_client_init(move || {
             http::Client::builder()
-                .cache(http::FileSystemCache::new(examples_util::temp_dir("image")).unwrap())
+                .cache(http::FileSystemCache::new(zng::env::cache("image")).unwrap())
                 .cache_mode(img_cache_mode)
                 .build()
         })
@@ -73,18 +73,18 @@ fn app_main() {
                                     fn img(source: &str) -> impl UiNode {
                                         Image! {
                                             grid::cell::at = grid::cell::AT_AUTO;
-                                            source;
+                                            source = zng::env::res(source);
                                         }
                                     }
                                     ui_vec![
-                                        img("examples/res/image/Luma8.png"),
-                                        img("examples/res/image/Luma16.png"),
-                                        img("examples/res/image/LumaA8.png"),
-                                        img("examples/res/image/LumaA16.png"),
-                                        img("examples/res/image/RGB8.png"),
-                                        img("examples/res/image/RGB16.png"),
-                                        img("examples/res/image/RGBA8.png"),
-                                        img("examples/res/image/RGBA16.png"),
+                                        img("Luma8.png"),
+                                        img("Luma16.png"),
+                                        img("LumaA8.png"),
+                                        img("LumaA16.png"),
+                                        img("RGB8.png"),
+                                        img("RGB16.png"),
+                                        img("RGBA8.png"),
+                                        img("RGBA16.png"),
                                     ]
                                 }
                           },
@@ -113,7 +113,7 @@ fn app_main() {
                             },
                             sub_title("Render Mask"),
                             Image! {
-                                source = "examples/res/image/zdenek-machacek-unsplash.jpg";
+                                source = zng::env::res("zdenek-machacek-unsplash.jpg");
                                 size = (200, 120);
                                 mask_image = ImageSource::render_node(RenderMode::Software, |_| Text! {
                                     txt = "Mask";
@@ -144,7 +144,7 @@ fn app_main() {
                                 "Mix-Blend",
                                 ui_vec![
                                     Image! {
-                                        source = "examples/res/image/zdenek-machacek-unsplash.jpg";
+                                        source = zng::env::res("zdenek-machacek-unsplash.jpg");
                                         size = (200, 100);
                                         widget::foreground = Text! {
                                             mix_blend = color::MixBlendMode::ColorDodge;
@@ -225,7 +225,7 @@ fn img_fit(fit: impl IntoVar<ImageFit>) -> impl UiNode {
         children = ui_vec![
             sub_title(fit.map_debug()),
             Image! {
-                source = "examples/res/image/zdenek-machacek-unsplash.jpg";
+                source = zng::env::res("zdenek-machacek-unsplash.jpg");
                 size = (200, 100);
                 img_fit = fit;
                 border = {
@@ -255,7 +255,7 @@ fn img_filter(filter: impl IntoVar<Filter>) -> impl UiNode {
                 }
             })),
             Image! {
-                source = "examples/res/image/zdenek-machacek-unsplash.jpg";
+                source = zng::env::res("zdenek-machacek-unsplash.jpg");
                 size = (200, 100);
                 filter;
             }
@@ -286,7 +286,7 @@ fn sprite() -> impl UiNode {
                 });
             },
             Image! {
-                source = "examples/res/image/player_combat_sheet-10-96x84-CC0.png";
+                source = zng::env::res("player_combat_sheet-10-96x84-CC0.png");
                 size = (96, 84);
                 border = {
                     widths: 1,
@@ -598,7 +598,7 @@ impl ImgWindow {
             state = WindowState::Maximized;
             size = (1140, 770);// restore size
 
-            icon = "examples/res/image/zng-logo.png";
+            icon = zng::env::res("zng-logo.png");
             widget::background = Checkerboard!();
 
             color_scheme = color::ColorScheme::Dark;
diff --git a/examples/layer/Cargo.toml b/examples/layer/Cargo.toml
new file mode 100644
index 000000000..1fa8dc087
--- /dev/null
+++ b/examples/layer/Cargo.toml
@@ -0,0 +1,8 @@
+[package]
+name = "layer"
+version = "0.0.0"
+publish = false
+edition = "2021"
+
+[dependencies]
+zng = { path = "../../crates/zng", features = ["view_prebuilt"] }
diff --git a/examples/res/screenshots/layer.png b/examples/layer/res/screenshot.png
similarity index 100%
rename from examples/res/screenshots/layer.png
rename to examples/layer/res/screenshot.png
diff --git a/examples/layer.rs b/examples/layer/src/main.rs
similarity index 99%
rename from examples/layer.rs
rename to examples/layer/src/main.rs
index a53d5e553..f08b2ca59 100644
--- a/examples/layer.rs
+++ b/examples/layer/src/main.rs
@@ -13,7 +13,6 @@ use zng::{
 };
 
 fn main() {
-    examples_util::print_info();
     zng::env::init!();
     zng::app::crash_handler::init_debug();
 
diff --git a/examples/localize/Cargo.toml b/examples/localize/Cargo.toml
new file mode 100644
index 000000000..9e7bfde22
--- /dev/null
+++ b/examples/localize/Cargo.toml
@@ -0,0 +1,9 @@
+[package]
+name = "localize"
+version = "0.0.0"
+publish = false
+edition = "2021"
+
+[dependencies]
+zng = { path = "../../crates/zng", features = ["view_prebuilt"] }
+tracing = "0.1"
\ No newline at end of file
diff --git a/examples/res/localize/ar.ftl b/examples/localize/res/ar.ftl
similarity index 100%
rename from examples/res/localize/ar.ftl
rename to examples/localize/res/ar.ftl
diff --git a/examples/res/localize/ar/msg.ftl b/examples/localize/res/ar/msg.ftl
similarity index 100%
rename from examples/res/localize/ar/msg.ftl
rename to examples/localize/res/ar/msg.ftl
diff --git a/examples/res/localize/en-US.ftl b/examples/localize/res/en-US.ftl
similarity index 100%
rename from examples/res/localize/en-US.ftl
rename to examples/localize/res/en-US.ftl
diff --git a/examples/res/localize/en-US/msg.ftl b/examples/localize/res/en-US/msg.ftl
similarity index 100%
rename from examples/res/localize/en-US/msg.ftl
rename to examples/localize/res/en-US/msg.ftl
diff --git a/examples/res/localize/pseudo-mirr.ftl b/examples/localize/res/pseudo-mirr.ftl
similarity index 100%
rename from examples/res/localize/pseudo-mirr.ftl
rename to examples/localize/res/pseudo-mirr.ftl
diff --git a/examples/res/localize/pseudo-mirr/msg.ftl b/examples/localize/res/pseudo-mirr/msg.ftl
similarity index 100%
rename from examples/res/localize/pseudo-mirr/msg.ftl
rename to examples/localize/res/pseudo-mirr/msg.ftl
diff --git a/examples/res/localize/pseudo-wide.ftl b/examples/localize/res/pseudo-wide.ftl
similarity index 100%
rename from examples/res/localize/pseudo-wide.ftl
rename to examples/localize/res/pseudo-wide.ftl
diff --git a/examples/res/localize/pseudo-wide/msg.ftl b/examples/localize/res/pseudo-wide/msg.ftl
similarity index 100%
rename from examples/res/localize/pseudo-wide/msg.ftl
rename to examples/localize/res/pseudo-wide/msg.ftl
diff --git a/examples/res/localize/pseudo.ftl b/examples/localize/res/pseudo.ftl
similarity index 100%
rename from examples/res/localize/pseudo.ftl
rename to examples/localize/res/pseudo.ftl
diff --git a/examples/res/localize/pseudo/msg.ftl b/examples/localize/res/pseudo/msg.ftl
similarity index 100%
rename from examples/res/localize/pseudo/msg.ftl
rename to examples/localize/res/pseudo/msg.ftl
diff --git a/examples/res/localize/pt-BR.ftl b/examples/localize/res/pt-BR.ftl
similarity index 100%
rename from examples/res/localize/pt-BR.ftl
rename to examples/localize/res/pt-BR.ftl
diff --git a/examples/res/localize/pt-BR/msg.ftl b/examples/localize/res/pt-BR/msg.ftl
similarity index 100%
rename from examples/res/localize/pt-BR/msg.ftl
rename to examples/localize/res/pt-BR/msg.ftl
diff --git a/examples/res/screenshots/localize.png b/examples/localize/res/screenshot.png
similarity index 100%
rename from examples/res/screenshots/localize.png
rename to examples/localize/res/screenshot.png
diff --git a/examples/res/localize/template.ftl b/examples/localize/res/template.ftl
similarity index 100%
rename from examples/res/localize/template.ftl
rename to examples/localize/res/template.ftl
diff --git a/examples/res/localize/template/msg.ftl b/examples/localize/res/template/msg.ftl
similarity index 100%
rename from examples/res/localize/template/msg.ftl
rename to examples/localize/res/template/msg.ftl
diff --git a/examples/localize.rs b/examples/localize/src/main.rs
similarity index 98%
rename from examples/localize.rs
rename to examples/localize/src/main.rs
index 94cf54533..24acb5da7 100644
--- a/examples/localize.rs
+++ b/examples/localize/src/main.rs
@@ -24,8 +24,8 @@ use zng::{
 // cargo run -p cargo-zng -- l10n "examples/localize*" "examples/res/localize"
 
 fn main() {
-    examples_util::print_info();
     zng::env::init!();
+    zng::env::init_res(concat!(env!("CARGO_MANIFEST_DIR"), "/res"));
     zng::app::crash_handler::init_debug();
 
     // let rec = examples_util::record_profile("localize");
@@ -39,7 +39,7 @@ fn main() {
 fn app_main() {
     APP.defaults().run_window(async {
         // load `available_langs`
-        L10N.load_dir("examples/res/localize");
+        L10N.load_dir(zng::env::res(""));
 
         // pre-load resources for the first available lang in sys-langs.
         let (lang, handle) = L10N.wait_first(L10N.app_lang().get()).await;
diff --git a/examples/markdown/Cargo.toml b/examples/markdown/Cargo.toml
new file mode 100644
index 000000000..174b634a8
--- /dev/null
+++ b/examples/markdown/Cargo.toml
@@ -0,0 +1,8 @@
+[package]
+name = "markdown"
+version = "0.0.0"
+publish = false
+edition = "2021"
+
+[dependencies]
+zng = { path = "../../crates/zng", features = ["view_prebuilt", "http"] }
diff --git a/examples/res/markdown/sample.md b/examples/markdown/res/sample.md
similarity index 84%
rename from examples/res/markdown/sample.md
rename to examples/markdown/res/sample.md
index be8e71a74..2e449f0e4 100644
--- a/examples/res/markdown/sample.md
+++ b/examples/markdown/res/sample.md
@@ -4,7 +4,7 @@ A paragraph, with **bold** text , **italic** text, and ***both***.
 
 Lorem ipsum dolor sit amet, ei nam copiosae invidunt accusamus. Vidit dicat cu pri, sit magna vocibus ut. Cum eu assum primis voluptatum. Est dolorum urbanitas elaboraret no, nisl definitiones cu sit.
 
-You can also link [web](https://httpbin.org), [file](../image/zdenek-machacek-unsplash.jpg), a [title](#table "Table"), or a footnote[^1]. And finally you can `inline` code.
+You can also link [web](https://httpbin.org), [file](../../image/res/zdenek-machacek-unsplash.jpg), a [title](#table "Table"), or a footnote[^1]. And finally you can `inline` code.
 
 ## Blocks
 
@@ -43,7 +43,7 @@ Nested lists:
 
 Image from local file:
 
-![two hummingbirds, flying, looking at each other](../image/zdenek-machacek-unsplash.jpg "Title text")
+![two hummingbirds, flying, looking at each other](../../image/res/zdenek-machacek-unsplash.jpg "Title text")
 
 Image from the web:
 
diff --git a/examples/res/screenshots/markdown.png b/examples/markdown/res/screenshot.png
similarity index 100%
rename from examples/res/screenshots/markdown.png
rename to examples/markdown/res/screenshot.png
diff --git a/examples/markdown.rs b/examples/markdown/src/main.rs
similarity index 81%
rename from examples/markdown.rs
rename to examples/markdown/src/main.rs
index e224bfd74..310b167d2 100644
--- a/examples/markdown.rs
+++ b/examples/markdown/src/main.rs
@@ -1,8 +1,5 @@
 //! Demonstrates the `Markdown!` widget.
 
-#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
-use std::path::PathBuf;
-
 use zng::{
     image::{self, ImageLimits, PathFilter, UriFilter},
     markdown::{self, Markdown},
@@ -11,8 +8,8 @@ use zng::{
 };
 
 fn main() {
-    examples_util::print_info();
     zng::env::init!();
+    zng::env::init_res(concat!(env!("CARGO_MANIFEST_DIR"), "/res"));
     zng::app::crash_handler::init_debug();
 
     // let rec = examples_util::record_profile("markdown");
@@ -31,19 +28,19 @@ fn app_main() {
                 mode = ScrollMode::VERTICAL;
                 padding = 10;
                 child = Markdown! {
-                    txt = std::fs::read_to_string("examples/res/markdown/sample.md").unwrap_or_else(|e| e.to_string());
+                    txt = std::fs::read_to_string(zng::env::res("sample.md")).unwrap_or_else(|e| e.to_string());
 
                     // allow limited image download and read.
                     image::img_limits = ImageLimits::default()
                         .with_allow_uri(UriFilter::allow_host("httpbin.org"))
-                        .with_allow_path(PathFilter::allow_dir("examples/res"));
+                        .with_allow_path(PathFilter::allow_dir("examples"));
 
                     /// fix path to local images.
                     image_resolver = markdown::ImageResolver::new(|img| {
                         let mut r: ImageSource = img.into();
                         if let ImageSource::Read(file) = &mut r {
                             if file.is_relative() {
-                                *file = PathBuf::from("examples/res/markdown").join(&file);
+                                *file = zng::env::res(&file);
                             }
                         }
                         r
diff --git a/examples/respawn/Cargo.toml b/examples/respawn/Cargo.toml
new file mode 100644
index 000000000..fca104d40
--- /dev/null
+++ b/examples/respawn/Cargo.toml
@@ -0,0 +1,12 @@
+[package]
+name = "respawn"
+version = "0.0.0"
+publish = false
+edition = "2021"
+
+[dependencies]
+zng = { path = "../../crates/zng", features = ["view"] }
+zng-view = { path = "../../crates/zng-view" }
+zng-app = { path = "../../crates/zng-app" }
+tracing = "0.1"
+linkme = "0.3"
\ No newline at end of file
diff --git a/examples/res/screenshots/respawn.png b/examples/respawn/res/screenshot.png
similarity index 100%
rename from examples/res/screenshots/respawn.png
rename to examples/respawn/res/screenshot.png
diff --git a/examples/respawn.rs b/examples/respawn/src/main.rs
similarity index 98%
rename from examples/respawn.rs
rename to examples/respawn/src/main.rs
index e3f5204c2..a2c82169a 100644
--- a/examples/respawn.rs
+++ b/examples/respawn/src/main.rs
@@ -12,7 +12,8 @@ use zng_app::view_process::VIEW_PROCESS;
 use zng_view::extensions::ViewExtensions;
 
 fn main() {
-    examples_util::print_info();
+    // log other processes too.
+    zng::app::print_tracing(tracing::Level::INFO);
     // init metadata, view-process, crash-dialog-process.
     zng::env::init!();
 
@@ -211,7 +212,7 @@ fn click_counter() -> impl UiNode {
 
 fn image() -> impl UiNode {
     Image! {
-        source = include_bytes!("res/window/icon-bytes.png"); size = (32, 32);
+        source = include_bytes!("../../window/res/icon-bytes.png"); size = (32, 32);
         tooltip = Tip!(Text!("Image reloads after respawn"));
     }
 }
diff --git a/examples/scroll/Cargo.toml b/examples/scroll/Cargo.toml
new file mode 100644
index 000000000..29a0027e7
--- /dev/null
+++ b/examples/scroll/Cargo.toml
@@ -0,0 +1,10 @@
+[package]
+name = "scroll"
+version = "0.0.0"
+publish = false
+edition = "2021"
+
+[dependencies]
+zng = { path = "../../crates/zng", features = ["view_prebuilt"] }
+rand = "0.8"
+lipsum = "0.9"
\ No newline at end of file
diff --git a/examples/res/screenshots/scroll.png b/examples/scroll/res/screenshot.png
similarity index 100%
rename from examples/res/screenshots/scroll.png
rename to examples/scroll/res/screenshot.png
diff --git a/examples/scroll.rs b/examples/scroll/src/main.rs
similarity index 99%
rename from examples/scroll.rs
rename to examples/scroll/src/main.rs
index 1f26d2177..b4774a46f 100644
--- a/examples/scroll.rs
+++ b/examples/scroll/src/main.rs
@@ -11,7 +11,6 @@ use zng::{
 use rand::SeedableRng;
 
 fn main() {
-    examples_util::print_info();
     zng::env::init!();
     zng::app::crash_handler::init_debug();
 
diff --git a/examples/shortcut/Cargo.toml b/examples/shortcut/Cargo.toml
new file mode 100644
index 000000000..c5357bafd
--- /dev/null
+++ b/examples/shortcut/Cargo.toml
@@ -0,0 +1,8 @@
+[package]
+name = "shortcut"
+version = "0.0.0"
+publish = false
+edition = "2021"
+
+[dependencies]
+zng = { path = "../../crates/zng", features = ["view_prebuilt"] }
diff --git a/examples/res/screenshots/shortcut.png b/examples/shortcut/res/screenshot.png
similarity index 100%
rename from examples/res/screenshots/shortcut.png
rename to examples/shortcut/res/screenshot.png
diff --git a/examples/shortcut.rs b/examples/shortcut/src/main.rs
similarity index 99%
rename from examples/shortcut.rs
rename to examples/shortcut/src/main.rs
index 48d0018c2..583798340 100644
--- a/examples/shortcut.rs
+++ b/examples/shortcut/src/main.rs
@@ -5,7 +5,6 @@
 use zng::{font::FontName, layout::align, prelude::*};
 
 fn main() {
-    examples_util::print_info();
     zng::env::init!();
     zng::app::crash_handler::init_debug();
 
diff --git a/examples/text.rs b/examples/text.rs
deleted file mode 100644
index 19d9f2389..000000000
--- a/examples/text.rs
+++ /dev/null
@@ -1,929 +0,0 @@
-//! Demonstrates the `Text!` and `TextInput!` widgets. Text rendering, text editor.
-
-#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
-
-use core::fmt;
-use std::sync::Arc;
-
-use zng::{
-    app::{NEW_CMD, OPEN_CMD, SAVE_AS_CMD, SAVE_CMD},
-    button,
-    clipboard::{COPY_CMD, CUT_CMD, PASTE_CMD},
-    color::{color_scheme_map, filter::opacity},
-    focus::{alt_focus_scope, focus_click_behavior, FocusClickBehavior},
-    font::{FontName, FontNames},
-    gesture::{click_shortcut, is_hovered},
-    icon::{self, Icon},
-    label::{self, Label},
-    layout::{align, margin, padding, Dip},
-    prelude::*,
-    rule_line,
-    scroll::ScrollMode,
-    text::{font_family, font_weight, UnderlinePosition, UnderlineSkip},
-    text_input,
-    undo::UNDO_CMD,
-    var::ArcVar,
-    widget::{background_color, corner_radius, enabled, visibility, LineStyle, Visibility},
-    window::{native_dialog, WindowRoot},
-};
-
-fn main() {
-    examples_util::print_info();
-    zng::env::init!();
-    zng::app::crash_handler::init_debug();
-
-    //let rec = examples_util::record_profile("text");
-
-    // zng::view_process::prebuilt::run_same_process(app_main);
-    app_main();
-
-    // rec.finish();
-}
-
-fn app_main() {
-    APP.defaults().run_window(async {
-        let fs = var(Length::Pt(11.0));
-
-        Window! {
-            title = fs.map(|s| formatx!("Text Example - font_size: {s}"));
-            child = Stack!(ui_vec![
-                Stack! {
-                    text::font_size = fs.easing(150.ms(), easing::linear);
-                    direction = StackDirection::left_to_right();
-                    align = Align::CENTER;
-                    spacing = 40;
-                    children = ui_vec![
-                        Stack! {
-                            direction = StackDirection::top_to_bottom();
-                            spacing = 20;
-                            children = ui_vec![
-                                basic(),
-                                defaults(),
-                            ];
-                        },
-                        Stack! {
-                            direction = StackDirection::top_to_bottom();
-                            spacing = 20;
-                            children = ui_vec![
-                                line_height(),
-                                line_spacing(),
-                                word_spacing(),
-                                letter_spacing(),
-                            ];
-                        },
-                        Stack! {
-                            direction = StackDirection::top_to_bottom();
-                            spacing = 20;
-                            children = ui_vec![
-                                decoration_lines(),
-                            ]
-                        }
-                    ];
-                },
-                Container! {
-                    align = Align::TOP;
-                    margin = 10;
-                    child = font_size_example(fs);
-                },
-                Stack! {
-                    direction = StackDirection::top_to_bottom();
-                    spacing = 5;
-                    margin = 20;
-                    align = Align::BOTTOM_RIGHT;
-                    children_align = Align::RIGHT;
-                    children = ui_vec![
-                        text_editor(),
-                        form_editor(),
-                    ];
-                },
-            ])
-        }
-    })
-}
-
-fn font_size_example(font_size: ArcVar<Length>) -> impl UiNode {
-    fn change_size(font_size: &ArcVar<Length>, change: f32) {
-        font_size.modify(move |s| {
-            *s.to_mut() += Length::Pt(change);
-        });
-    }
-    Stack! {
-        button::style_fn = Style! { padding = (0, 5) };
-        direction = StackDirection::left_to_right();
-        spacing = 5;
-        corner_radius = 4;
-        background_color = color_scheme_map(rgba(0, 0, 0, 40.pct()), rgba(1., 1., 1., 40.pct()));
-        padding = 4;
-        children = ui_vec![
-            Button! {
-                child = Text!("-");
-                font_family = FontName::monospace();
-                font_weight = FontWeight::BOLD;
-                click_shortcut = [shortcut!('-')];
-                on_click = hn!(font_size, |_| {
-                    change_size(&font_size, -1.0)
-                });
-            },
-            Text! {
-                txt = font_size.map(|s| formatx!("{s}"));
-            },
-            Button! {
-                child = Text!("+");
-                font_family = FontName::monospace();
-                font_weight = FontWeight::BOLD;
-                click_shortcut = [shortcut!('+')];
-                on_click = hn!(font_size, |_| {
-                    change_size(&font_size, 1.0)
-                });
-            },
-        ]
-    }
-}
-
-fn basic() -> impl UiNode {
-    section(
-        "basic",
-        ui_vec![
-            Text!("Basic Text"),
-            text::Strong!("Strong Text"),
-            text::Em!("Emphasis Text"),
-            Text! {
-                font_color = color_scheme_map(web_colors::LIGHT_GREEN, web_colors::DARK_GREEN);
-                txt = "Colored Text";
-
-                when *#is_hovered {
-                    font_color = color_scheme_map(web_colors::YELLOW, web_colors::BROWN);
-                }
-            },
-            Text!("Emoticons 🔎👨‍💻🧐"),
-        ],
-    )
-}
-
-fn line_height() -> impl UiNode {
-    section(
-        "line_height",
-        ui_vec![
-            Text! {
-                txt = "Default: 'Émp Giga Ç'";
-                background_color = web_colors::LIGHT_BLUE;
-                font_color = web_colors::BLACK;
-            },
-            Text! {
-                txt = "150%: 'Émp Giga Ç'";
-                background_color = web_colors::LIGHT_BLUE;
-                font_color = web_colors::BLACK;
-                line_height = 150.pct();
-            },
-        ],
-    )
-}
-
-fn line_spacing() -> impl UiNode {
-    section(
-        "line_spacing",
-        ui_vec![Container! {
-            child = Text! {
-                txt = "Hello line 1!\nHello line 2!\nHover to change `line_spacing`";
-                background_color = rgba(0.5, 0.5, 0.5, 0.3);
-                txt_wrap = false;
-
-                when *#is_hovered {
-                    #[easing(150.ms())]
-                    line_spacing = 30.pct();
-                }
-            };
-            child_align = Align::TOP;
-            layout::min_height = 1.7.em() * 3.fct();
-        }],
-    )
-}
-
-fn word_spacing() -> impl UiNode {
-    section(
-        "word_spacing",
-        ui_vec![Text! {
-            txt = "Word spacing\n\thover to change";
-            background_color = rgba(0.5, 0.5, 0.5, 0.3);
-
-            when *#is_hovered {
-                #[easing(150.ms())]
-                word_spacing = 100.pct();
-            }
-        }],
-    )
-}
-
-fn letter_spacing() -> impl UiNode {
-    section(
-        "letter_spacing",
-        ui_vec![Text! {
-            txt = "Letter spacing\n\thover to change";
-            background_color = rgba(0.5, 0.5, 0.5, 0.3);
-
-            when *#is_hovered {
-                #[easing(150.ms())]
-                letter_spacing = 30.pct();
-            }
-        }],
-    )
-}
-
-fn decoration_lines() -> impl UiNode {
-    section(
-        "Decorations",
-        ui_vec![
-            Text! {
-                txt = "Overline, 1, Dotted,\ndefault color";
-                overline = 1, LineStyle::Dotted;
-
-                background_color = rgba(0.5, 0.5, 0.5, 0.3);
-                margin = (0, 0, 4, 0);
-            },
-            Text! {
-                txt = "Strikethrough, 1, Solid,\ndefault color";
-                strikethrough = 1, LineStyle::Solid;
-
-                background_color = rgba(0.5, 0.5, 0.5, 0.3);
-                margin = (0, 0, 4, 0);
-            },
-            Text! {
-                txt = "Strikethrough, 4, Double,\ndifferent color";
-                strikethrough = 4, LineStyle::Double;
-                strikethrough_color = web_colors::RED;
-
-                background_color = rgba(0.5, 0.5, 0.5, 0.3);
-                margin = (0, 0, 4, 0);
-            },
-            Text! {
-                txt = "Underline, 1, Solid,\ndefault color";
-                underline = 1, LineStyle::Solid;
-
-                background_color = rgba(0.5, 0.5, 0.5, 0.3);
-                margin = (0, 0, 4, 0);
-            },
-            Text! {
-                txt = "Underline, 1, Solid,\ndefault color, skip spaces";
-                underline = 1, LineStyle::Solid;
-                underline_skip = UnderlineSkip::SPACES;
-
-                background_color = rgba(0.5, 0.5, 0.5, 0.3);
-                margin = (0, 0, 4, 0);
-            },
-            Text! {
-                txt = "Underline, 1, Solid,\ndefault color, descent";
-                underline = 1, LineStyle::Solid;
-                underline_position = UnderlinePosition::Descent;
-
-                background_color = rgba(0.5, 0.5, 0.5, 0.3);
-                margin = (0, 0, 4, 0);
-            },
-            Text! {
-                txt = "Underline, 3, wavy,\ndifferent color, no skip";
-                underline = 3, LineStyle::Wavy(1.0);
-                underline_color = web_colors::GREEN;
-                underline_skip = UnderlineSkip::NONE;
-
-                background_color = rgba(0.5, 0.5, 0.5, 0.3);
-            }
-        ],
-    )
-}
-
-fn defaults() -> impl UiNode {
-    fn demo(title: &str, font_family: impl Into<FontNames>) -> impl UiNode {
-        let font_family = font_family.into();
-
-        let font_name = zng::font::FONTS
-            .list(
-                &font_family,
-                FontStyle::Normal,
-                FontWeight::NORMAL,
-                FontStretch::NORMAL,
-                &lang!(und),
-            )
-            .map(|f| match f.done() {
-                Some(f) => f.best().family_name().to_txt(),
-                None => Txt::from_str(""),
-            });
-
-        Stack! {
-            direction = StackDirection::left_to_right();
-            children_align = Align::BASELINE_LEFT;
-            children = ui_vec![
-                Text!(if title.is_empty() {
-                    formatx!("{font_family}: ")
-                } else {
-                    formatx!("{title}: ")
-                }),
-                Text! {
-                    txt = font_name;
-                    font_family;
-                }
-            ];
-        }
-    }
-
-    section(
-        "defaults",
-        ui_vec![
-            // Generic
-            demo("", FontName::serif()),
-            demo("", FontName::sans_serif()),
-            demo("", FontName::monospace()),
-            demo("", FontName::cursive()),
-            demo("", FontName::fantasy()),
-            demo("Fallback", "not-a-font-get-fallback"),
-            demo("UI", FontNames::default())
-        ],
-    )
-}
-
-fn section(header: &'static str, items: impl UiNodeList) -> impl UiNode {
-    Stack! {
-        direction = StackDirection::top_to_bottom();
-        spacing = 5;
-        children = ui_vec![Text! {
-            txt = header;
-            font_weight = FontWeight::BOLD;
-            margin = (0, 4);
-        }].chain(items);
-    }
-}
-
-fn text_editor() -> impl UiNode {
-    let is_open = var(false);
-
-    Button! {
-        child = Text!(is_open.map(|&i| if i { "show text editor" } else { "open text editor" }.into()));
-        style_fn = button::LinkStyle!();
-        on_click = hn!(|_| {
-            let editor_id = WindowId::named("text-editor");
-            if is_open.get() {
-                if WINDOWS.focus(editor_id).is_err() {
-                    is_open.set(false);
-                }
-            } else {
-                WINDOWS.open_id(editor_id, async_clmv!(is_open, {
-                    text_editor_window(is_open)
-                }));
-            }
-        });
-    }
-}
-
-fn text_editor_window(is_open: ArcVar<bool>) -> WindowRoot {
-    let editor = TextEditor::init();
-    Window! {
-        title = editor.title();
-        on_open = hn!(is_open, |_| {
-            is_open.set(true);
-        });
-        on_close = hn!(is_open, |_| {
-            is_open.set(false);
-        });
-        enabled = editor.enabled();
-        on_close_requested = async_hn!(editor, |args: WindowCloseRequestedArgs| {
-            editor.on_close_requested(args).await;
-        });
-        min_width = 450;
-
-        child_top = text_editor_menu(editor.clone()), 0;
-
-        child = Scroll! {
-            mode = ScrollMode::VERTICAL;
-            child_align = Align::FILL;
-            scroll_to_focused_mode = None;
-
-            // line numbers
-            child_start = Text! {
-                padding = (7, 4);
-                txt_align = Align::TOP_RIGHT;
-                opacity = 80.pct();
-                layout::min_width = 24;
-                txt = editor.lines.map(|s| {
-                    use std::fmt::Write;
-                    let mut txt = String::new();
-                    match s {
-                        text::LinesWrapCount::NoWrap(len) => {
-                            for i in 1..=(*len).max(1) {
-                                let _ = writeln!(&mut txt, "{i}");
-                            }
-                        },
-                        text::LinesWrapCount::Wrap(counts) => {
-                            for (i, &c) in counts.iter().enumerate() {
-                                let _ = write!(&mut txt, "{}", i + 1);
-                                for _ in 0..c {
-                                    txt.push('\n');
-                                }
-                            }
-                        }
-                    }
-                    Txt::from_str(&txt)
-                });
-            }, 0;
-
-            // editor
-            child = TextInput! {
-                id = editor.input_wgt_id();
-                txt = editor.txt.clone();
-                accepts_tab = true;
-                accepts_enter = true;
-                get_caret_status = editor.caret_status.clone();
-                get_lines_wrap_count = editor.lines.clone();
-                widget::border = unset!;
-            };
-        };
-
-        child_bottom = Text! {
-            margin = (0, 4);
-            align = Align::RIGHT;
-            txt = editor.caret_status.map_to_txt();
-        }, 0;
-    }
-}
-
-fn text_editor_menu(editor: Arc<TextEditor>) -> impl UiNode {
-    let menu_width = var(Dip::MAX);
-    let gt_700 = menu_width.map(|&w| Visibility::from(w > Dip::new(700)));
-    let gt_600 = menu_width.map(|&w| Visibility::from(w > Dip::new(600)));
-    let gt_500 = menu_width.map(|&w| Visibility::from(w > Dip::new(500)));
-
-    let clipboard_btn = clmv!(gt_600, |cmd: zng::event::Command| {
-        let cmd = cmd.focus_scoped();
-        Button! {
-            child = widget::node::presenter((), cmd.flat_map(|c| c.icon()));
-            child_right = Text!(txt = cmd.flat_map(|c| c.name()); visibility = gt_600.clone()), 4;
-            tooltip = Tip!(Text!(cmd.flat_map(|c|c.name_with_shortcut())));
-            visibility = true;
-            cmd;
-        }
-    });
-
-    let undo_combo = clmv!(gt_700, |op: zng::undo::UndoOp| {
-        let cmd = op.cmd().undo_scoped();
-
-        Toggle! {
-            style_fn = toggle::ComboStyle!();
-
-            widget::enabled = cmd.flat_map(|c| c.is_enabled());
-
-            child = Button! {
-                child = widget::node::presenter((), cmd.flat_map(|c| c.icon()));
-                child_right = Text!(txt = cmd.flat_map(|c| c.name()); visibility = gt_700.clone()), 4;
-                tooltip = Tip!(Text!(cmd.flat_map(|c|c.name_with_shortcut())));
-                on_click = hn!(|a: &gesture::ClickArgs| {
-                    a.propagation().stop();
-                    cmd.get().notify();
-                });
-            };
-
-            checked_popup = wgt_fn!(|_| popup::Popup! {
-                child = zng::undo::history::UndoHistory!(op);
-            });
-        }
-    });
-
-    Stack! {
-        id = "menu";
-        align = Align::FILL_TOP;
-        alt_focus_scope = true;
-        focus_click_behavior = FocusClickBehavior::Exit;
-        spacing = 4;
-        direction = StackDirection::left_to_right();
-        padding = 4;
-        layout::actual_width = menu_width;
-        button::style_fn = Style! {
-            padding = (2, 4);
-            corner_radius = 2;
-            icon::ico_size = 16;
-        };
-        rule_line::vr::margin = 0;
-        children = ui_vec![
-            Button! {
-                child = Icon!(icon::material_sharp::INSERT_DRIVE_FILE);
-                child_right = Text!(txt = NEW_CMD.name(); visibility = gt_500.clone()), 4;
-                tooltip = Tip!(Text!(NEW_CMD.name_with_shortcut()));
-
-                click_shortcut = NEW_CMD.shortcut();
-                on_click = async_hn!(editor, |_| {
-                    editor.create().await;
-                });
-            },
-            Button! {
-                child = Icon!(icon::material_sharp::FOLDER_OPEN);
-                child_right = Text!(txt = OPEN_CMD.name(); visibility = gt_500.clone()), 4;
-                tooltip = Tip!(Text!(OPEN_CMD.name_with_shortcut()));
-
-                click_shortcut = OPEN_CMD.shortcut();
-                on_click = async_hn!(editor, |_| {
-                    editor.open().await;
-                });
-            },
-            Button! {
-                child = Icon!(icon::material_sharp::SAVE);
-                child_right = Text!(txt = SAVE_CMD.name(); visibility = gt_500.clone()), 4;
-                tooltip = Tip!(Text!(SAVE_CMD.name_with_shortcut()));
-
-                enabled = editor.unsaved();
-                click_shortcut = SAVE_CMD.shortcut();
-                on_click = async_hn!(editor, |_| {
-                    editor.save().await;
-                });
-            },
-            Button! {
-                child = Text!(SAVE_AS_CMD.name());
-                when #{gt_500}.is_collapsed() {
-                    child = Icon!(icon::material_sharp::SAVE_AS);
-                }
-
-                tooltip = Tip!(Text!(SAVE_AS_CMD.name_with_shortcut()));
-
-                click_shortcut = SAVE_AS_CMD.shortcut();
-                on_click = async_hn!(editor, |_| {
-                    editor.save_as().await;
-                });
-            },
-            rule_line::vr::Vr!(),
-            clipboard_btn(CUT_CMD),
-            clipboard_btn(COPY_CMD),
-            clipboard_btn(PASTE_CMD),
-            rule_line::vr::Vr!(),
-            undo_combo(zng::undo::UndoOp::Undo),
-            undo_combo(zng::undo::UndoOp::Redo),
-        ]
-    }
-}
-struct TextEditor {
-    input_wgt_id: WidgetId,
-    file: ArcVar<Option<std::path::PathBuf>>,
-    txt: ArcVar<Txt>,
-
-    txt_touched: ArcVar<bool>,
-
-    caret_status: ArcVar<text::CaretStatus>,
-    lines: ArcVar<text::LinesWrapCount>,
-    busy: ArcVar<u32>,
-}
-impl TextEditor {
-    pub fn init() -> Arc<Self> {
-        let txt = var(Txt::from_static(""));
-        let unsaved = var(false);
-        txt.bind_map(&unsaved, |_| true).perm();
-        Arc::new(Self {
-            input_wgt_id: WidgetId::new_unique(),
-            file: var(None),
-            txt,
-            txt_touched: unsaved,
-            caret_status: var(text::CaretStatus::none()),
-            lines: var(text::LinesWrapCount::NoWrap(0)),
-            busy: var(0),
-        })
-    }
-
-    pub fn input_wgt_id(&self) -> WidgetId {
-        self.input_wgt_id
-    }
-
-    pub fn title(&self) -> impl Var<Txt> {
-        merge_var!(self.unsaved(), self.file.clone(), |u, f| {
-            let mut t = "Text Example - Editor".to_owned();
-            if *u {
-                t.push('*');
-            }
-            if let Some(f) = f {
-                use std::fmt::Write;
-                let _ = write!(&mut t, " - {}", f.display());
-            }
-            Txt::from_str(&t)
-        })
-    }
-
-    pub fn unsaved(&self) -> impl Var<bool> {
-        let can_undo = UNDO_CMD.scoped(self.input_wgt_id).is_enabled();
-        merge_var!(self.txt_touched.clone(), can_undo, |&t, &u| t && u)
-    }
-
-    pub fn enabled(&self) -> impl Var<bool> {
-        self.busy.map(|&b| b == 0)
-    }
-
-    pub async fn create(&self) {
-        let _busy = self.enter_busy();
-
-        if self.handle_unsaved().await {
-            self.txt.set(Txt::from_static(""));
-            self.file.set(None);
-            self.txt_touched.set(false);
-        }
-    }
-
-    pub async fn open(&self) {
-        let _busy = self.enter_busy();
-
-        if !self.handle_unsaved().await {
-            return;
-        }
-
-        let mut dlg = native_dialog::FileDialog {
-            title: "Open Text".into(),
-            kind: native_dialog::FileDialogKind::OpenFile,
-            ..Default::default()
-        };
-        dlg.push_filter("Text Files", &["txt", "md"]).push_filter("All Files", &["*"]);
-        let r = WINDOWS.native_file_dialog(WINDOW.id(), dlg).wait_rsp().await;
-        match r {
-            native_dialog::FileDialogResponse::Selected(mut s) => {
-                let file = s.remove(0);
-                let r = task::wait(clmv!(file, || std::fs::read_to_string(file))).await;
-                match r {
-                    Ok(t) => {
-                        self.txt.set(Txt::from_str(&t));
-                        self.txt_touched.set(false);
-                        self.file.set(file);
-                    }
-                    Err(e) => {
-                        self.handle_error("reading file", e.to_txt()).await;
-                    }
-                }
-            }
-            native_dialog::FileDialogResponse::Cancel => {}
-            native_dialog::FileDialogResponse::Error(e) => {
-                self.handle_error("opening file", e).await;
-            }
-        }
-    }
-
-    pub async fn save(&self) -> bool {
-        if let Some(file) = self.file.get() {
-            let _busy = self.enter_busy();
-            let ok = self.write(file).await;
-            self.txt_touched.set(!ok);
-            ok
-        } else {
-            self.save_as().await
-        }
-    }
-
-    pub async fn save_as(&self) -> bool {
-        let _busy = self.enter_busy();
-
-        let mut dlg = native_dialog::FileDialog {
-            title: "Save Text".into(),
-            kind: native_dialog::FileDialogKind::SaveFile,
-            ..Default::default()
-        };
-        dlg.push_filter("Text", &["txt"])
-            .push_filter("Markdown", &["md"])
-            .push_filter("All Files", &["*"]);
-        let r = WINDOWS.native_file_dialog(WINDOW.id(), dlg).wait_rsp().await;
-        match r {
-            native_dialog::FileDialogResponse::Selected(mut s) => {
-                if let Some(file) = s.pop() {
-                    let ok = self.write(file.clone()).await;
-                    self.txt_touched.set(!ok);
-                    if ok {
-                        self.file.set(Some(file));
-                    }
-                    return ok;
-                }
-            }
-            native_dialog::FileDialogResponse::Cancel => {}
-            native_dialog::FileDialogResponse::Error(e) => {
-                self.handle_error("saving file", e.to_txt()).await;
-            }
-        }
-
-        false // cancel
-    }
-
-    pub async fn on_close_requested(&self, args: WindowCloseRequestedArgs) {
-        if self.unsaved().get() {
-            args.propagation().stop();
-            if self.handle_unsaved().await {
-                self.txt_touched.set(false);
-                WINDOW.close();
-            }
-        }
-    }
-
-    async fn write(&self, file: std::path::PathBuf) -> bool {
-        let txt = self.txt.clone();
-        let r = task::wait(move || txt.with(move |txt| std::fs::write(file, txt.as_bytes()))).await;
-        match r {
-            Ok(()) => true,
-            Err(e) => {
-                self.handle_error("writing file", e.to_txt()).await;
-                false
-            }
-        }
-    }
-
-    async fn handle_unsaved(&self) -> bool {
-        if !self.unsaved().get() {
-            return true;
-        }
-
-        let dlg = native_dialog::MsgDialog {
-            title: "Save File?".into(),
-            message: "Save file? All unsaved changes will be lost.".into(),
-            icon: native_dialog::MsgDialogIcon::Warn,
-            buttons: native_dialog::MsgDialogButtons::YesNo,
-        };
-        let r = WINDOWS.native_message_dialog(WINDOW.id(), dlg).wait_rsp().await;
-        match r {
-            native_dialog::MsgDialogResponse::Yes => self.save().await,
-            native_dialog::MsgDialogResponse::No => true,
-            _ => false,
-        }
-    }
-
-    async fn handle_error(&self, context: &'static str, e: Txt) {
-        tracing::error!("error {context}, {e}");
-
-        let dlg = native_dialog::MsgDialog {
-            title: "Error".into(),
-            message: formatx!("Error {context}.\n\n{e}"),
-            icon: native_dialog::MsgDialogIcon::Error,
-            buttons: native_dialog::MsgDialogButtons::Ok,
-        };
-        let _ = WINDOWS.native_message_dialog(WINDOW.id(), dlg).wait_rsp().await;
-    }
-
-    fn enter_busy(&self) -> impl Drop {
-        struct BusyTracker(ArcVar<u32>);
-        impl Drop for BusyTracker {
-            fn drop(&mut self) {
-                self.0.modify(|b| *b.to_mut() -= 1);
-            }
-        }
-        self.busy.modify(|b| *b.to_mut() += 1);
-        BusyTracker(self.busy.clone())
-    }
-}
-
-fn form_editor() -> impl UiNode {
-    let is_open = var(false);
-
-    Button! {
-        child = Text!(is_open.map(|&i| if i { "show form editor" } else { "open form editor" }.into()));
-        style_fn = button::LinkStyle!();
-        on_click = hn!(|_| {
-            let editor_id = WindowId::named("form-editor");
-            if is_open.get() {
-                if WINDOWS.focus(editor_id).is_err() {
-                    is_open.set(false);
-                }
-            } else {
-                WINDOWS.open_id(editor_id, async_clmv!(is_open, {
-                    form_editor_window(is_open)
-                }));
-            }
-        });
-    }
-}
-
-fn form_editor_window(is_open: ArcVar<bool>) -> WindowRoot {
-    Window! {
-        title = "Form";
-        on_open = hn!(is_open, |_| {
-            is_open.set(true);
-        });
-        on_close = hn!(is_open, |_| {
-            is_open.set(false);
-        });
-
-        size = (400, 500);
-
-        child = Grid! {
-            id = "form";
-
-            columns = ui_vec![grid::Column!(), grid::Column!(1.lft())];
-            spacing = (5, 10);
-            padding = 20;
-
-            label::style_fn = Style! { text::txt_align = Align::END; };
-            text_input::style_fn = style_fn!(|_| text_input::FieldStyle!());
-
-            cells = ui_vec![
-                Label! {
-                    txt = "Name";
-                    target = "field-name";
-                },
-                TextInput! {
-                    grid::cell::column = 1;
-                    id = "field-name";
-                    txt = var_from("my-crate");
-                    max_chars_count = 50;
-                },
-
-                Label! {
-                    grid::cell::row = 1;
-                    txt = "Authors";
-                    target = "field-authors";
-                },
-                TextInput! {
-                    grid::cell::row = 1;
-                    grid::cell::column = 1;
-                    id = "field-authors";
-                    txt = var_from("John Doe");
-                },
-
-                Label! {
-                    grid::cell::row = 2;
-                    txt = "Version";
-                    target = "field-version";
-                },
-                TextInput! {
-                    id = "field-version";
-                    grid::cell::row = 2;
-                    grid::cell::column = 1;
-                    txt_parse = var(Version::default());
-                    text_input::field_help = "help text";
-                    // txt_parse_on_stop = true;
-                },
-
-                Label! {
-                    grid::cell::row = 3;
-                    txt = "Password";
-                    target = "field-password";
-                },
-                TextInput! {
-                    grid::cell::row = 3;
-                    grid::cell::column = 1;
-                    id = "field-password";
-                    txt = var_from("pass");
-                    obscure_txt = true;
-                },
-            ];
-        };
-
-        child_bottom = {
-            node: Stack! {
-                direction = StackDirection::start_to_end();
-                padding = 10;
-                align = Align::END;
-                spacing = 5;
-                children = ui_vec![
-                    Button! {
-                        child = Text!("Cancel");
-                        on_click = hn!(|_| {
-                            WINDOW.close();
-                        });
-                    },
-                    Button! {
-                        font_weight = FontWeight::BOLD;
-                        child = Text!("Validate");
-                        on_click = hn!(|_| {
-                            zng::text::cmd::PARSE_CMD
-                                .notify_descendants(&WINDOW.info().get("form").unwrap());
-                        });
-                    }
-                ]
-            },
-            spacing: 10,
-        };
-    }
-}
-
-/// Basic version type for input validation demo.
-#[derive(Default, Debug, Clone, Copy, PartialEq, Eq)]
-struct Version {
-    major: u32,
-    minor: u32,
-    rev: u32,
-}
-impl fmt::Display for Version {
-    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
-        write!(f, "{}.{}.{}", self.major, self.minor, self.rev)
-    }
-}
-impl std::str::FromStr for Version {
-    type Err = Txt;
-
-    fn from_str(s: &str) -> Result<Self, Self::Err> {
-        let mut r = Self::default();
-
-        let mut split = s.split('.');
-        if let Some(major) = split.next() {
-            if !major.is_empty() {
-                r.major = u32::from_str(major).map_err(|e| e.to_txt())?;
-            }
-        }
-        if let Some(minor) = split.next() {
-            if !minor.is_empty() {
-                r.minor = u32::from_str(minor).map_err(|e| e.to_txt())?;
-            }
-        }
-        if let Some(rev) = split.next() {
-            if !rev.is_empty() {
-                r.rev = u32::from_str(rev).map_err(|e| e.to_txt())?;
-            }
-        }
-        if split.next().is_some() {
-            return Err("expected maximum of 3 version numbers".into());
-        }
-
-        Ok(r)
-    }
-}
diff --git a/examples/text/Cargo.toml b/examples/text/Cargo.toml
new file mode 100644
index 000000000..81a95172e
--- /dev/null
+++ b/examples/text/Cargo.toml
@@ -0,0 +1,9 @@
+[package]
+name = "text"
+version = "0.0.0"
+publish = false
+edition = "2021"
+
+[dependencies]
+zng = { path = "../../crates/zng", features = ["view_prebuilt", "material_icons"] }
+tracing = "0.1"
\ No newline at end of file
diff --git a/examples/res/screenshots/text.png b/examples/text/res/screenshot.png
similarity index 100%
rename from examples/res/screenshots/text.png
rename to examples/text/res/screenshot.png
diff --git a/examples/res/text/udhr_arb.txt b/examples/text/res/udhr_arb.txt
similarity index 100%
rename from examples/res/text/udhr_arb.txt
rename to examples/text/res/udhr_arb.txt
diff --git a/examples/res/text/unicode-emoji-15.0/ReadMe.txt b/examples/text/res/unicode-emoji-15.0/ReadMe.txt
similarity index 100%
rename from examples/res/text/unicode-emoji-15.0/ReadMe.txt
rename to examples/text/res/unicode-emoji-15.0/ReadMe.txt
diff --git a/examples/res/text/unicode-emoji-15.0/emoji-sequences.txt b/examples/text/res/unicode-emoji-15.0/emoji-sequences.txt
similarity index 100%
rename from examples/res/text/unicode-emoji-15.0/emoji-sequences.txt
rename to examples/text/res/unicode-emoji-15.0/emoji-sequences.txt
diff --git a/examples/res/text/unicode-emoji-15.0/emoji-zwj-sequences.txt b/examples/text/res/unicode-emoji-15.0/emoji-zwj-sequences.txt
similarity index 100%
rename from examples/res/text/unicode-emoji-15.0/emoji-zwj-sequences.txt
rename to examples/text/res/unicode-emoji-15.0/emoji-zwj-sequences.txt
diff --git a/examples/text/src/editor.rs b/examples/text/src/editor.rs
new file mode 100644
index 000000000..a6ae704ce
--- /dev/null
+++ b/examples/text/src/editor.rs
@@ -0,0 +1,430 @@
+use std::sync::Arc;
+
+use zng::{
+    app::{NEW_CMD, OPEN_CMD, SAVE_AS_CMD, SAVE_CMD},
+    button,
+    clipboard::{COPY_CMD, CUT_CMD, PASTE_CMD},
+    color::filter::opacity,
+    focus::{alt_focus_scope, focus_click_behavior, FocusClickBehavior},
+    gesture::click_shortcut,
+    icon::{self, Icon},
+    layout::{align, margin, padding, Dip},
+    prelude::*,
+    rule_line,
+    scroll::ScrollMode,
+    undo::UNDO_CMD,
+    var::ArcVar,
+    widget::{corner_radius, enabled, visibility, Visibility},
+    window::{native_dialog, WindowRoot},
+};
+
+pub fn text_editor() -> impl UiNode {
+    let is_open = var(false);
+
+    Button! {
+        child = Text!(is_open.map(|&i| if i { "show text editor" } else { "open text editor" }.into()));
+        style_fn = button::LinkStyle!();
+        on_click = hn!(|_| {
+            let editor_id = WindowId::named("text-editor");
+            if is_open.get() {
+                if WINDOWS.focus(editor_id).is_err() {
+                    is_open.set(false);
+                }
+            } else {
+                WINDOWS.open_id(editor_id, async_clmv!(is_open, {
+                    text_editor_window(is_open)
+                }));
+            }
+        });
+    }
+}
+
+fn text_editor_window(is_open: ArcVar<bool>) -> WindowRoot {
+    let editor = TextEditor::init();
+    Window! {
+        title = editor.title();
+        on_open = hn!(is_open, |_| {
+            is_open.set(true);
+        });
+        on_close = hn!(is_open, |_| {
+            is_open.set(false);
+        });
+        enabled = editor.enabled();
+        on_close_requested = async_hn!(editor, |args: WindowCloseRequestedArgs| {
+            editor.on_close_requested(args).await;
+        });
+        min_width = 450;
+
+        child_top = text_editor_menu(editor.clone()), 0;
+
+        child = Scroll! {
+            mode = ScrollMode::VERTICAL;
+            child_align = Align::FILL;
+            scroll_to_focused_mode = None;
+
+            // line numbers
+            child_start = Text! {
+                padding = (7, 4);
+                txt_align = Align::TOP_RIGHT;
+                opacity = 80.pct();
+                layout::min_width = 24;
+                txt = editor.lines.map(|s| {
+                    use std::fmt::Write;
+                    let mut txt = String::new();
+                    match s {
+                        text::LinesWrapCount::NoWrap(len) => {
+                            for i in 1..=(*len).max(1) {
+                                let _ = writeln!(&mut txt, "{i}");
+                            }
+                        },
+                        text::LinesWrapCount::Wrap(counts) => {
+                            for (i, &c) in counts.iter().enumerate() {
+                                let _ = write!(&mut txt, "{}", i + 1);
+                                for _ in 0..c {
+                                    txt.push('\n');
+                                }
+                            }
+                        }
+                    }
+                    Txt::from_str(&txt)
+                });
+            }, 0;
+
+            // editor
+            child = TextInput! {
+                id = editor.input_wgt_id();
+                txt = editor.txt.clone();
+                accepts_tab = true;
+                accepts_enter = true;
+                get_caret_status = editor.caret_status.clone();
+                get_lines_wrap_count = editor.lines.clone();
+                widget::border = unset!;
+            };
+        };
+
+        child_bottom = Text! {
+            margin = (0, 4);
+            align = Align::RIGHT;
+            txt = editor.caret_status.map_to_txt();
+        }, 0;
+    }
+}
+
+fn text_editor_menu(editor: Arc<TextEditor>) -> impl UiNode {
+    let menu_width = var(Dip::MAX);
+    let gt_700 = menu_width.map(|&w| Visibility::from(w > Dip::new(700)));
+    let gt_600 = menu_width.map(|&w| Visibility::from(w > Dip::new(600)));
+    let gt_500 = menu_width.map(|&w| Visibility::from(w > Dip::new(500)));
+
+    let clipboard_btn = clmv!(gt_600, |cmd: zng::event::Command| {
+        let cmd = cmd.focus_scoped();
+        Button! {
+            child = widget::node::presenter((), cmd.flat_map(|c| c.icon()));
+            child_right = Text!(txt = cmd.flat_map(|c| c.name()); visibility = gt_600.clone()), 4;
+            tooltip = Tip!(Text!(cmd.flat_map(|c|c.name_with_shortcut())));
+            visibility = true;
+            cmd;
+        }
+    });
+
+    let undo_combo = clmv!(gt_700, |op: zng::undo::UndoOp| {
+        let cmd = op.cmd().undo_scoped();
+
+        Toggle! {
+            style_fn = toggle::ComboStyle!();
+
+            widget::enabled = cmd.flat_map(|c| c.is_enabled());
+
+            child = Button! {
+                child = widget::node::presenter((), cmd.flat_map(|c| c.icon()));
+                child_right = Text!(txt = cmd.flat_map(|c| c.name()); visibility = gt_700.clone()), 4;
+                tooltip = Tip!(Text!(cmd.flat_map(|c|c.name_with_shortcut())));
+                on_click = hn!(|a: &gesture::ClickArgs| {
+                    a.propagation().stop();
+                    cmd.get().notify();
+                });
+            };
+
+            checked_popup = wgt_fn!(|_| popup::Popup! {
+                child = zng::undo::history::UndoHistory!(op);
+            });
+        }
+    });
+
+    Stack! {
+        id = "menu";
+        align = Align::FILL_TOP;
+        alt_focus_scope = true;
+        focus_click_behavior = FocusClickBehavior::Exit;
+        spacing = 4;
+        direction = StackDirection::left_to_right();
+        padding = 4;
+        layout::actual_width = menu_width;
+        button::style_fn = Style! {
+            padding = (2, 4);
+            corner_radius = 2;
+            icon::ico_size = 16;
+        };
+        rule_line::vr::margin = 0;
+        children = ui_vec![
+            Button! {
+                child = Icon!(icon::material_sharp::INSERT_DRIVE_FILE);
+                child_right = Text!(txt = NEW_CMD.name(); visibility = gt_500.clone()), 4;
+                tooltip = Tip!(Text!(NEW_CMD.name_with_shortcut()));
+
+                click_shortcut = NEW_CMD.shortcut();
+                on_click = async_hn!(editor, |_| {
+                    editor.create().await;
+                });
+            },
+            Button! {
+                child = Icon!(icon::material_sharp::FOLDER_OPEN);
+                child_right = Text!(txt = OPEN_CMD.name(); visibility = gt_500.clone()), 4;
+                tooltip = Tip!(Text!(OPEN_CMD.name_with_shortcut()));
+
+                click_shortcut = OPEN_CMD.shortcut();
+                on_click = async_hn!(editor, |_| {
+                    editor.open().await;
+                });
+            },
+            Button! {
+                child = Icon!(icon::material_sharp::SAVE);
+                child_right = Text!(txt = SAVE_CMD.name(); visibility = gt_500.clone()), 4;
+                tooltip = Tip!(Text!(SAVE_CMD.name_with_shortcut()));
+
+                enabled = editor.unsaved();
+                click_shortcut = SAVE_CMD.shortcut();
+                on_click = async_hn!(editor, |_| {
+                    editor.save().await;
+                });
+            },
+            Button! {
+                child = Text!(SAVE_AS_CMD.name());
+                when #{gt_500}.is_collapsed() {
+                    child = Icon!(icon::material_sharp::SAVE_AS);
+                }
+
+                tooltip = Tip!(Text!(SAVE_AS_CMD.name_with_shortcut()));
+
+                click_shortcut = SAVE_AS_CMD.shortcut();
+                on_click = async_hn!(editor, |_| {
+                    editor.save_as().await;
+                });
+            },
+            rule_line::vr::Vr!(),
+            clipboard_btn(CUT_CMD),
+            clipboard_btn(COPY_CMD),
+            clipboard_btn(PASTE_CMD),
+            rule_line::vr::Vr!(),
+            undo_combo(zng::undo::UndoOp::Undo),
+            undo_combo(zng::undo::UndoOp::Redo),
+        ]
+    }
+}
+struct TextEditor {
+    input_wgt_id: WidgetId,
+    file: ArcVar<Option<std::path::PathBuf>>,
+    txt: ArcVar<Txt>,
+
+    txt_touched: ArcVar<bool>,
+
+    caret_status: ArcVar<text::CaretStatus>,
+    lines: ArcVar<text::LinesWrapCount>,
+    busy: ArcVar<u32>,
+}
+impl TextEditor {
+    pub fn init() -> Arc<Self> {
+        let txt = var(Txt::from_static(""));
+        let unsaved = var(false);
+        txt.bind_map(&unsaved, |_| true).perm();
+        Arc::new(Self {
+            input_wgt_id: WidgetId::new_unique(),
+            file: var(None),
+            txt,
+            txt_touched: unsaved,
+            caret_status: var(text::CaretStatus::none()),
+            lines: var(text::LinesWrapCount::NoWrap(0)),
+            busy: var(0),
+        })
+    }
+
+    pub fn input_wgt_id(&self) -> WidgetId {
+        self.input_wgt_id
+    }
+
+    pub fn title(&self) -> impl Var<Txt> {
+        merge_var!(self.unsaved(), self.file.clone(), |u, f| {
+            let mut t = "Text Example - Editor".to_owned();
+            if *u {
+                t.push('*');
+            }
+            if let Some(f) = f {
+                use std::fmt::Write;
+                let _ = write!(&mut t, " - {}", f.display());
+            }
+            Txt::from_str(&t)
+        })
+    }
+
+    pub fn unsaved(&self) -> impl Var<bool> {
+        let can_undo = UNDO_CMD.scoped(self.input_wgt_id).is_enabled();
+        merge_var!(self.txt_touched.clone(), can_undo, |&t, &u| t && u)
+    }
+
+    pub fn enabled(&self) -> impl Var<bool> {
+        self.busy.map(|&b| b == 0)
+    }
+
+    pub async fn create(&self) {
+        let _busy = self.enter_busy();
+
+        if self.handle_unsaved().await {
+            self.txt.set(Txt::from_static(""));
+            self.file.set(None);
+            self.txt_touched.set(false);
+        }
+    }
+
+    pub async fn open(&self) {
+        let _busy = self.enter_busy();
+
+        if !self.handle_unsaved().await {
+            return;
+        }
+
+        let mut dlg = native_dialog::FileDialog {
+            title: "Open Text".into(),
+            kind: native_dialog::FileDialogKind::OpenFile,
+            ..Default::default()
+        };
+        dlg.push_filter("Text Files", &["txt", "md"]).push_filter("All Files", &["*"]);
+        let r = WINDOWS.native_file_dialog(WINDOW.id(), dlg).wait_rsp().await;
+        match r {
+            native_dialog::FileDialogResponse::Selected(mut s) => {
+                let file = s.remove(0);
+                let r = task::wait(clmv!(file, || std::fs::read_to_string(file))).await;
+                match r {
+                    Ok(t) => {
+                        self.txt.set(Txt::from_str(&t));
+                        self.txt_touched.set(false);
+                        self.file.set(file);
+                    }
+                    Err(e) => {
+                        self.handle_error("reading file", e.to_txt()).await;
+                    }
+                }
+            }
+            native_dialog::FileDialogResponse::Cancel => {}
+            native_dialog::FileDialogResponse::Error(e) => {
+                self.handle_error("opening file", e).await;
+            }
+        }
+    }
+
+    pub async fn save(&self) -> bool {
+        if let Some(file) = self.file.get() {
+            let _busy = self.enter_busy();
+            let ok = self.write(file).await;
+            self.txt_touched.set(!ok);
+            ok
+        } else {
+            self.save_as().await
+        }
+    }
+
+    pub async fn save_as(&self) -> bool {
+        let _busy = self.enter_busy();
+
+        let mut dlg = native_dialog::FileDialog {
+            title: "Save Text".into(),
+            kind: native_dialog::FileDialogKind::SaveFile,
+            ..Default::default()
+        };
+        dlg.push_filter("Text", &["txt"])
+            .push_filter("Markdown", &["md"])
+            .push_filter("All Files", &["*"]);
+        let r = WINDOWS.native_file_dialog(WINDOW.id(), dlg).wait_rsp().await;
+        match r {
+            native_dialog::FileDialogResponse::Selected(mut s) => {
+                if let Some(file) = s.pop() {
+                    let ok = self.write(file.clone()).await;
+                    self.txt_touched.set(!ok);
+                    if ok {
+                        self.file.set(Some(file));
+                    }
+                    return ok;
+                }
+            }
+            native_dialog::FileDialogResponse::Cancel => {}
+            native_dialog::FileDialogResponse::Error(e) => {
+                self.handle_error("saving file", e.to_txt()).await;
+            }
+        }
+
+        false // cancel
+    }
+
+    pub async fn on_close_requested(&self, args: WindowCloseRequestedArgs) {
+        if self.unsaved().get() {
+            args.propagation().stop();
+            if self.handle_unsaved().await {
+                self.txt_touched.set(false);
+                WINDOW.close();
+            }
+        }
+    }
+
+    async fn write(&self, file: std::path::PathBuf) -> bool {
+        let txt = self.txt.clone();
+        let r = task::wait(move || txt.with(move |txt| std::fs::write(file, txt.as_bytes()))).await;
+        match r {
+            Ok(()) => true,
+            Err(e) => {
+                self.handle_error("writing file", e.to_txt()).await;
+                false
+            }
+        }
+    }
+
+    async fn handle_unsaved(&self) -> bool {
+        if !self.unsaved().get() {
+            return true;
+        }
+
+        let dlg = native_dialog::MsgDialog {
+            title: "Save File?".into(),
+            message: "Save file? All unsaved changes will be lost.".into(),
+            icon: native_dialog::MsgDialogIcon::Warn,
+            buttons: native_dialog::MsgDialogButtons::YesNo,
+        };
+        let r = WINDOWS.native_message_dialog(WINDOW.id(), dlg).wait_rsp().await;
+        match r {
+            native_dialog::MsgDialogResponse::Yes => self.save().await,
+            native_dialog::MsgDialogResponse::No => true,
+            _ => false,
+        }
+    }
+
+    async fn handle_error(&self, context: &'static str, e: Txt) {
+        tracing::error!("error {context}, {e}");
+
+        let dlg = native_dialog::MsgDialog {
+            title: "Error".into(),
+            message: formatx!("Error {context}.\n\n{e}"),
+            icon: native_dialog::MsgDialogIcon::Error,
+            buttons: native_dialog::MsgDialogButtons::Ok,
+        };
+        let _ = WINDOWS.native_message_dialog(WINDOW.id(), dlg).wait_rsp().await;
+    }
+
+    fn enter_busy(&self) -> impl Drop {
+        struct BusyTracker(ArcVar<u32>);
+        impl Drop for BusyTracker {
+            fn drop(&mut self) {
+                self.0.modify(|b| *b.to_mut() -= 1);
+            }
+        }
+        self.busy.modify(|b| *b.to_mut() += 1);
+        BusyTracker(self.busy.clone())
+    }
+}
diff --git a/examples/text/src/form.rs b/examples/text/src/form.rs
new file mode 100644
index 000000000..6f37be793
--- /dev/null
+++ b/examples/text/src/form.rs
@@ -0,0 +1,178 @@
+use std::fmt;
+
+use zng::{
+    button,
+    label::{self, Label},
+    layout::{align, padding},
+    prelude::*,
+    text::font_weight,
+    text_input,
+    var::ArcVar,
+    window::WindowRoot,
+};
+
+pub fn form_editor() -> impl UiNode {
+    let is_open = var(false);
+
+    Button! {
+        child = Text!(is_open.map(|&i| if i { "show form editor" } else { "open form editor" }.into()));
+        style_fn = button::LinkStyle!();
+        on_click = hn!(|_| {
+            let editor_id = WindowId::named("form-editor");
+            if is_open.get() {
+                if WINDOWS.focus(editor_id).is_err() {
+                    is_open.set(false);
+                }
+            } else {
+                WINDOWS.open_id(editor_id, async_clmv!(is_open, {
+                    form_editor_window(is_open)
+                }));
+            }
+        });
+    }
+}
+
+fn form_editor_window(is_open: ArcVar<bool>) -> WindowRoot {
+    Window! {
+        title = "Form";
+        on_open = hn!(is_open, |_| {
+            is_open.set(true);
+        });
+        on_close = hn!(is_open, |_| {
+            is_open.set(false);
+        });
+
+        size = (400, 500);
+
+        child = Grid! {
+            id = "form";
+
+            columns = ui_vec![grid::Column!(), grid::Column!(1.lft())];
+            spacing = (5, 10);
+            padding = 20;
+
+            label::style_fn = Style! { text::txt_align = Align::END; };
+            text_input::style_fn = style_fn!(|_| text_input::FieldStyle!());
+
+            cells = ui_vec![
+                Label! {
+                    txt = "Name";
+                    target = "field-name";
+                },
+                TextInput! {
+                    grid::cell::column = 1;
+                    id = "field-name";
+                    txt = var_from("my-crate");
+                    max_chars_count = 50;
+                },
+
+                Label! {
+                    grid::cell::row = 1;
+                    txt = "Authors";
+                    target = "field-authors";
+                },
+                TextInput! {
+                    grid::cell::row = 1;
+                    grid::cell::column = 1;
+                    id = "field-authors";
+                    txt = var_from("John Doe");
+                },
+
+                Label! {
+                    grid::cell::row = 2;
+                    txt = "Version";
+                    target = "field-version";
+                },
+                TextInput! {
+                    id = "field-version";
+                    grid::cell::row = 2;
+                    grid::cell::column = 1;
+                    txt_parse = var(Version::default());
+                    text_input::field_help = "help text";
+                    // txt_parse_on_stop = true;
+                },
+
+                Label! {
+                    grid::cell::row = 3;
+                    txt = "Password";
+                    target = "field-password";
+                },
+                TextInput! {
+                    grid::cell::row = 3;
+                    grid::cell::column = 1;
+                    id = "field-password";
+                    txt = var_from("pass");
+                    obscure_txt = true;
+                },
+            ];
+        };
+
+        child_bottom = {
+            node: Stack! {
+                direction = StackDirection::start_to_end();
+                padding = 10;
+                align = Align::END;
+                spacing = 5;
+                children = ui_vec![
+                    Button! {
+                        child = Text!("Cancel");
+                        on_click = hn!(|_| {
+                            WINDOW.close();
+                        });
+                    },
+                    Button! {
+                        font_weight = FontWeight::BOLD;
+                        child = Text!("Validate");
+                        on_click = hn!(|_| {
+                            zng::text::cmd::PARSE_CMD
+                                .notify_descendants(&WINDOW.info().get("form").unwrap());
+                        });
+                    }
+                ]
+            },
+            spacing: 10,
+        };
+    }
+}
+
+/// Basic version type for input validation demo.
+#[derive(Default, Debug, Clone, Copy, PartialEq, Eq)]
+struct Version {
+    major: u32,
+    minor: u32,
+    rev: u32,
+}
+impl fmt::Display for Version {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        write!(f, "{}.{}.{}", self.major, self.minor, self.rev)
+    }
+}
+impl std::str::FromStr for Version {
+    type Err = Txt;
+
+    fn from_str(s: &str) -> Result<Self, Self::Err> {
+        let mut r = Self::default();
+
+        let mut split = s.split('.');
+        if let Some(major) = split.next() {
+            if !major.is_empty() {
+                r.major = u32::from_str(major).map_err(|e| e.to_txt())?;
+            }
+        }
+        if let Some(minor) = split.next() {
+            if !minor.is_empty() {
+                r.minor = u32::from_str(minor).map_err(|e| e.to_txt())?;
+            }
+        }
+        if let Some(rev) = split.next() {
+            if !rev.is_empty() {
+                r.rev = u32::from_str(rev).map_err(|e| e.to_txt())?;
+            }
+        }
+        if split.next().is_some() {
+            return Err("expected maximum of 3 version numbers".into());
+        }
+
+        Ok(r)
+    }
+}
diff --git a/examples/text/src/main.rs b/examples/text/src/main.rs
new file mode 100644
index 000000000..cc37052e6
--- /dev/null
+++ b/examples/text/src/main.rs
@@ -0,0 +1,339 @@
+//! Demonstrates the `Text!` and `TextInput!` widgets. Text rendering, text editor.
+
+use zng::{
+    button,
+    color::color_scheme_map,
+    font::{FontName, FontNames},
+    gesture::{click_shortcut, is_hovered},
+    layout::{align, margin, padding},
+    prelude::*,
+    text::{font_family, font_weight, UnderlinePosition, UnderlineSkip},
+    var::ArcVar,
+    widget::{background_color, corner_radius, LineStyle},
+};
+
+mod editor;
+mod form;
+
+fn main() {
+    zng::env::init!();
+    zng::app::crash_handler::init_debug();
+
+    //let rec = examples_util::record_profile("text");
+
+    // zng::view_process::prebuilt::run_same_process(app_main);
+    app_main();
+
+    // rec.finish();
+}
+
+fn app_main() {
+    APP.defaults().run_window(async {
+        let fs = var(Length::Pt(11.0));
+
+        Window! {
+            title = fs.map(|s| formatx!("Text Example - font_size: {s}"));
+            child = Stack!(ui_vec![
+                Stack! {
+                    text::font_size = fs.easing(150.ms(), easing::linear);
+                    direction = StackDirection::left_to_right();
+                    align = Align::CENTER;
+                    spacing = 40;
+                    children = ui_vec![
+                        Stack! {
+                            direction = StackDirection::top_to_bottom();
+                            spacing = 20;
+                            children = ui_vec![
+                                basic(),
+                                defaults(),
+                            ];
+                        },
+                        Stack! {
+                            direction = StackDirection::top_to_bottom();
+                            spacing = 20;
+                            children = ui_vec![
+                                line_height(),
+                                line_spacing(),
+                                word_spacing(),
+                                letter_spacing(),
+                            ];
+                        },
+                        Stack! {
+                            direction = StackDirection::top_to_bottom();
+                            spacing = 20;
+                            children = ui_vec![
+                                decoration_lines(),
+                            ]
+                        }
+                    ];
+                },
+                Container! {
+                    align = Align::TOP;
+                    margin = 10;
+                    child = font_size_example(fs);
+                },
+                Stack! {
+                    direction = StackDirection::top_to_bottom();
+                    spacing = 5;
+                    margin = 20;
+                    align = Align::BOTTOM_RIGHT;
+                    children_align = Align::RIGHT;
+                    children = ui_vec![
+                        editor::text_editor(),
+                        form::form_editor(),
+                    ];
+                },
+            ])
+        }
+    })
+}
+
+fn font_size_example(font_size: ArcVar<Length>) -> impl UiNode {
+    fn change_size(font_size: &ArcVar<Length>, change: f32) {
+        font_size.modify(move |s| {
+            *s.to_mut() += Length::Pt(change);
+        });
+    }
+    Stack! {
+        button::style_fn = Style! { padding = (0, 5) };
+        direction = StackDirection::left_to_right();
+        spacing = 5;
+        corner_radius = 4;
+        background_color = color_scheme_map(rgba(0, 0, 0, 40.pct()), rgba(1., 1., 1., 40.pct()));
+        padding = 4;
+        children = ui_vec![
+            Button! {
+                child = Text!("-");
+                font_family = FontName::monospace();
+                font_weight = FontWeight::BOLD;
+                click_shortcut = [shortcut!('-')];
+                on_click = hn!(font_size, |_| {
+                    change_size(&font_size, -1.0)
+                });
+            },
+            Text! {
+                txt = font_size.map(|s| formatx!("{s}"));
+            },
+            Button! {
+                child = Text!("+");
+                font_family = FontName::monospace();
+                font_weight = FontWeight::BOLD;
+                click_shortcut = [shortcut!('+')];
+                on_click = hn!(font_size, |_| {
+                    change_size(&font_size, 1.0)
+                });
+            },
+        ]
+    }
+}
+
+fn basic() -> impl UiNode {
+    section(
+        "basic",
+        ui_vec![
+            Text!("Basic Text"),
+            text::Strong!("Strong Text"),
+            text::Em!("Emphasis Text"),
+            Text! {
+                font_color = color_scheme_map(web_colors::LIGHT_GREEN, web_colors::DARK_GREEN);
+                txt = "Colored Text";
+
+                when *#is_hovered {
+                    font_color = color_scheme_map(web_colors::YELLOW, web_colors::BROWN);
+                }
+            },
+            Text!("Emoticons 🔎👨‍💻🧐"),
+        ],
+    )
+}
+
+fn line_height() -> impl UiNode {
+    section(
+        "line_height",
+        ui_vec![
+            Text! {
+                txt = "Default: 'Émp Giga Ç'";
+                background_color = web_colors::LIGHT_BLUE;
+                font_color = web_colors::BLACK;
+            },
+            Text! {
+                txt = "150%: 'Émp Giga Ç'";
+                background_color = web_colors::LIGHT_BLUE;
+                font_color = web_colors::BLACK;
+                line_height = 150.pct();
+            },
+        ],
+    )
+}
+
+fn line_spacing() -> impl UiNode {
+    section(
+        "line_spacing",
+        ui_vec![Container! {
+            child = Text! {
+                txt = "Hello line 1!\nHello line 2!\nHover to change `line_spacing`";
+                background_color = rgba(0.5, 0.5, 0.5, 0.3);
+                txt_wrap = false;
+
+                when *#is_hovered {
+                    #[easing(150.ms())]
+                    line_spacing = 30.pct();
+                }
+            };
+            child_align = Align::TOP;
+            layout::min_height = 1.7.em() * 3.fct();
+        }],
+    )
+}
+
+fn word_spacing() -> impl UiNode {
+    section(
+        "word_spacing",
+        ui_vec![Text! {
+            txt = "Word spacing\n\thover to change";
+            background_color = rgba(0.5, 0.5, 0.5, 0.3);
+
+            when *#is_hovered {
+                #[easing(150.ms())]
+                word_spacing = 100.pct();
+            }
+        }],
+    )
+}
+
+fn letter_spacing() -> impl UiNode {
+    section(
+        "letter_spacing",
+        ui_vec![Text! {
+            txt = "Letter spacing\n\thover to change";
+            background_color = rgba(0.5, 0.5, 0.5, 0.3);
+
+            when *#is_hovered {
+                #[easing(150.ms())]
+                letter_spacing = 30.pct();
+            }
+        }],
+    )
+}
+
+fn decoration_lines() -> impl UiNode {
+    section(
+        "Decorations",
+        ui_vec![
+            Text! {
+                txt = "Overline, 1, Dotted,\ndefault color";
+                overline = 1, LineStyle::Dotted;
+
+                background_color = rgba(0.5, 0.5, 0.5, 0.3);
+                margin = (0, 0, 4, 0);
+            },
+            Text! {
+                txt = "Strikethrough, 1, Solid,\ndefault color";
+                strikethrough = 1, LineStyle::Solid;
+
+                background_color = rgba(0.5, 0.5, 0.5, 0.3);
+                margin = (0, 0, 4, 0);
+            },
+            Text! {
+                txt = "Strikethrough, 4, Double,\ndifferent color";
+                strikethrough = 4, LineStyle::Double;
+                strikethrough_color = web_colors::RED;
+
+                background_color = rgba(0.5, 0.5, 0.5, 0.3);
+                margin = (0, 0, 4, 0);
+            },
+            Text! {
+                txt = "Underline, 1, Solid,\ndefault color";
+                underline = 1, LineStyle::Solid;
+
+                background_color = rgba(0.5, 0.5, 0.5, 0.3);
+                margin = (0, 0, 4, 0);
+            },
+            Text! {
+                txt = "Underline, 1, Solid,\ndefault color, skip spaces";
+                underline = 1, LineStyle::Solid;
+                underline_skip = UnderlineSkip::SPACES;
+
+                background_color = rgba(0.5, 0.5, 0.5, 0.3);
+                margin = (0, 0, 4, 0);
+            },
+            Text! {
+                txt = "Underline, 1, Solid,\ndefault color, descent";
+                underline = 1, LineStyle::Solid;
+                underline_position = UnderlinePosition::Descent;
+
+                background_color = rgba(0.5, 0.5, 0.5, 0.3);
+                margin = (0, 0, 4, 0);
+            },
+            Text! {
+                txt = "Underline, 3, wavy,\ndifferent color, no skip";
+                underline = 3, LineStyle::Wavy(1.0);
+                underline_color = web_colors::GREEN;
+                underline_skip = UnderlineSkip::NONE;
+
+                background_color = rgba(0.5, 0.5, 0.5, 0.3);
+            }
+        ],
+    )
+}
+
+fn defaults() -> impl UiNode {
+    fn demo(title: &str, font_family: impl Into<FontNames>) -> impl UiNode {
+        let font_family = font_family.into();
+
+        let font_name = zng::font::FONTS
+            .list(
+                &font_family,
+                FontStyle::Normal,
+                FontWeight::NORMAL,
+                FontStretch::NORMAL,
+                &lang!(und),
+            )
+            .map(|f| match f.done() {
+                Some(f) => f.best().family_name().to_txt(),
+                None => Txt::from_str(""),
+            });
+
+        Stack! {
+            direction = StackDirection::left_to_right();
+            children_align = Align::BASELINE_LEFT;
+            children = ui_vec![
+                Text!(if title.is_empty() {
+                    formatx!("{font_family}: ")
+                } else {
+                    formatx!("{title}: ")
+                }),
+                Text! {
+                    txt = font_name;
+                    font_family;
+                }
+            ];
+        }
+    }
+
+    section(
+        "defaults",
+        ui_vec![
+            // Generic
+            demo("", FontName::serif()),
+            demo("", FontName::sans_serif()),
+            demo("", FontName::monospace()),
+            demo("", FontName::cursive()),
+            demo("", FontName::fantasy()),
+            demo("Fallback", "not-a-font-get-fallback"),
+            demo("UI", FontNames::default())
+        ],
+    )
+}
+
+fn section(header: &'static str, items: impl UiNodeList) -> impl UiNode {
+    Stack! {
+        direction = StackDirection::top_to_bottom();
+        spacing = 5;
+        children = ui_vec![Text! {
+            txt = header;
+            font_weight = FontWeight::BOLD;
+            margin = (0, 4);
+        }].chain(items);
+    }
+}
diff --git a/examples/transform/Cargo.toml b/examples/transform/Cargo.toml
new file mode 100644
index 000000000..489ab7465
--- /dev/null
+++ b/examples/transform/Cargo.toml
@@ -0,0 +1,8 @@
+[package]
+name = "transform"
+version = "0.0.0"
+publish = false
+edition = "2021"
+
+[dependencies]
+zng = { path = "../../crates/zng", features = ["view_prebuilt"] }
diff --git a/examples/res/screenshots/transform.png b/examples/transform/res/screenshot.png
similarity index 100%
rename from examples/res/screenshots/transform.png
rename to examples/transform/res/screenshot.png
diff --git a/examples/transform.rs b/examples/transform/src/main.rs
similarity index 99%
rename from examples/transform.rs
rename to examples/transform/src/main.rs
index 4faa7341a..0e1c7f66c 100644
--- a/examples/transform.rs
+++ b/examples/transform/src/main.rs
@@ -17,7 +17,6 @@ use zng::{
 };
 
 fn main() {
-    examples_util::print_info();
     zng::env::init!();
     zng::app::crash_handler::init_debug();
 
diff --git a/examples/window/Cargo.toml b/examples/window/Cargo.toml
new file mode 100644
index 000000000..52184909a
--- /dev/null
+++ b/examples/window/Cargo.toml
@@ -0,0 +1,9 @@
+[package]
+name = "window"
+version = "0.0.0"
+publish = false
+edition = "2021"
+
+[dependencies]
+zng = { path = "../../crates/zng", features = ["view_prebuilt", "inspector"] }
+tracing = "0.1"
\ No newline at end of file
diff --git a/examples/res/window/icon-bytes.png b/examples/window/res/icon-bytes.png
similarity index 100%
rename from examples/res/window/icon-bytes.png
rename to examples/window/res/icon-bytes.png
diff --git a/examples/res/window/icon-file.png b/examples/window/res/icon-file.png
similarity index 100%
rename from examples/res/window/icon-file.png
rename to examples/window/res/icon-file.png
diff --git a/examples/res/screenshots/window.png b/examples/window/res/screenshot.png
similarity index 100%
rename from examples/res/screenshots/window.png
rename to examples/window/res/screenshot.png
diff --git a/examples/window.rs b/examples/window/src/main.rs
similarity index 99%
rename from examples/window.rs
rename to examples/window/src/main.rs
index 6fedb6c9d..407928169 100644
--- a/examples/window.rs
+++ b/examples/window/src/main.rs
@@ -25,13 +25,12 @@ use zng::{
 };
 
 fn main() {
-    examples_util::print_info();
     zng::env::init!();
     zng::app::crash_handler::init_debug();
 
     // let rec = examples_util::record_profile("window");
 
-    zng::view_process::default::run_same_process(app_main);
+    zng::view_process::prebuilt::run_same_process(app_main);
     // app_main();
 
     // rec.finish();
@@ -248,8 +247,8 @@ fn icon_example() -> impl UiNode {
         WINDOW.vars().icon(),
         ui_vec![
             icon_btn("Default", WindowIcon::Default),
-            icon_btn("Png File", "examples/res/window/icon-file.png".into()),
-            icon_btn("Png Bytes", include_bytes!("res/window/icon-bytes.png").into()),
+            icon_btn("Png File", "../res/icon-file.png".into()),
+            icon_btn("Png Bytes", include_bytes!("../res/icon-bytes.png").into()),
             icon_btn("Raw BGRA", {
                 let color = [0, 0, 255, 255 / 2];
 
diff --git a/tools/cargo-do/src/main.rs b/tools/cargo-do/src/main.rs
index f7f71a09b..2cc7c6321 100644
--- a/tools/cargo-do/src/main.rs
+++ b/tools/cargo-do/src/main.rs
@@ -1,7 +1,7 @@
 mod readme_gen;
 mod util;
 mod version_doc_sync;
-use std::format_args as f;
+use std::{fmt::Write as _, format_args as f};
 use util::*;
 
 fn main() {
@@ -523,21 +523,34 @@ fn run(mut args: Vec<&str>) {
         } else {
             &[""]
         };
-        let mut build_args = vec!["build", "--package", "examples", "--examples"];
-        build_args.extend(release);
-        cmd_env("cargo", &build_args, &[], &[trace]);
+
         for example in examples() {
-            let mut ex_args = vec!["run", "--package", "examples", "--example", &example];
-            ex_args.extend(release);
-            cmd_env("cargo", &ex_args, &[], &[trace]);
+            cmd_env(
+                "cargo",
+                &["run", "--manifest-path", &format!("examples/{example}/Cargo.toml")],
+                release,
+                &[trace],
+            );
         }
     } else {
         if take_flag(&mut args, &["--release-lto"]) {
             args.push("--profile");
             args.push("release-lto");
         }
-
-        cmd_env("cargo", &["run", "--package", "examples", "--example"], &args, &[trace]);
+        if !args.iter().any(|a| !a.starts_with('-')) {
+            let mut msg = "missing example name, options:\n".to_owned();
+            for example in examples() {
+                writeln!(&mut msg, "   {example}").unwrap();
+            }
+        } else {
+            let example = args.remove(0);
+            cmd_env(
+                "cargo",
+                &["run", "--manifest-path", &format!("examples/{example}/Cargo.toml")],
+                &args,
+                &[trace],
+            );
+        }
     }
 }
 
diff --git a/tools/cargo-do/src/readme_gen.rs b/tools/cargo-do/src/readme_gen.rs
index 9a898ba3b..63ca01268 100644
--- a/tools/cargo-do/src/readme_gen.rs
+++ b/tools/cargo-do/src/readme_gen.rs
@@ -181,38 +181,15 @@ const SECTION_END: &str = "<!--do doc --readme #SECTION-END-->";
 pub fn generate_examples(_args: Vec<&str>) {
     use std::fmt::*;
 
-    let cargo = std::fs::read_to_string("examples/Cargo.toml").unwrap();
-    let mut is_example = false;
-
-    let mut examples = vec![];
-
-    let mut name = String::new();
-    for line in cargo.lines() {
-        let line = line.trim();
-
-        if line == "[[example]]" {
-            is_example = true;
-        } else if line.starts_with('[') {
-            is_example = false;
-        } else if is_example {
-            if let Some(n) = line.strip_prefix("name = ") {
-                name = n.trim_matches(&['"', ' ']).to_owned();
-            } else if let Some(p) = line.strip_prefix("path = ") {
-                let p = p.trim_matches(&['"', ' ']);
-                if p != format!("{name}.rs") {
-                    crate::error(format_args!("expected example `{name}` to have the same file name, but was `{p}`"));
-                } else {
-                    examples.push(std::mem::take(&mut name));
-                }
-            }
-        }
-    }
-
     const TAG: &str = "<!--do doc --readme-examples-->";
     let mut section = format!("{TAG}\n");
 
-    for example in examples {
-        let file = format!("examples/{example}.rs");
+    for example in crate::util::examples() {
+        if example == "test" {
+            continue;
+        }
+
+        let file = format!("examples/{example}/src/main.rs");
         println(&file);
 
         let file = std::fs::read_to_string(file).unwrap();
@@ -228,12 +205,12 @@ pub fn generate_examples(_args: Vec<&str>) {
 
         writeln!(&mut section, "### `{example}`\n").unwrap();
 
-        let screenshot = format!("./res/screenshots/{example}.png");
+        let screenshot = format!("./{example}/res/screenshot.png");
         if PathBuf::from("examples").join(&screenshot).exists() {
             writeln!(&mut section, "<img alt='{example} screenshot' src='{screenshot}' width='300'>\n",).unwrap();
         }
 
-        writeln!(&mut section, "Source: [{example}.rs](./{example}.rs)\n").unwrap();
+        writeln!(&mut section, "Source: [{example}/src](./{example}/src)\n").unwrap();
         writeln!(&mut section, "```console\ncargo do run {example}\n```\n").unwrap();
 
         if docs.is_empty() {
diff --git a/tools/cargo-do/src/util.rs b/tools/cargo-do/src/util.rs
index 745c56b20..93b25e986 100644
--- a/tools/cargo-do/src/util.rs
+++ b/tools/cargo-do/src/util.rs
@@ -248,12 +248,12 @@ pub fn all_ext(dir: &str, ext: &str) -> Vec<String> {
     glob(&format!("{dir}/**/*.{ext}"))
 }
 
-// Get all `examples/*.rs` file names.
+// Get all `examples/*/src/main.rs` names.
 pub fn examples() -> Vec<String> {
-    match glob::glob("examples/*.rs") {
+    match glob::glob("examples/*/src/main.rs") {
         Ok(iter) => iter
             .filter_map(|r| match r {
-                Ok(p) => p.file_name().map(|n| n.to_string_lossy().trim_end_matches(".rs").to_owned()),
+                Ok(p) => Some(p.parent()?.parent()?.file_name()?.to_string_lossy().into_owned()),
                 Err(e) => {
                     error(e);
                     None


Test patch:
diff --git a/examples/res/text/unicode-emoji-15.0/emoji-test.txt b/examples/text/res/unicode-emoji-15.0/emoji-test.txt
similarity index 100%
rename from examples/res/text/unicode-emoji-15.0/emoji-test.txt
rename to examples/text/res/unicode-emoji-15.0/emoji-test.txt
diff --git a/tests/text.rs b/tests/text.rs
index 510a433fb..d1b1ad032 100644
--- a/tests/text.rs
+++ b/tests/text.rs
@@ -8,7 +8,7 @@ use zng::{
 
 #[test]
 fn emoji_segs() {
-    let tests = std::fs::read_to_string("../examples/res/text/unicode-emoji-15.0/emoji-test.txt").unwrap();
+    let tests = std::fs::read_to_string("../examples/text/res/unicode-emoji-15.0/emoji-test.txt").unwrap();
 
     let mut errors = String::new();
     let mut error_count = 0;