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

Issue:
Implement 9-patch borders
Some property ideas:

* `border_image`
* `border_linear`
* `border_radial`
* `border_conic`


Discussion / hints:

Fix patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 47366203c..c85d116d9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,10 @@
 # Unreleased
 
+* Add `border_img` and other related properties and types.
+* **Breaking** Moved `zng::render::RepeatMode` to `zng::widget`.
+* **Breaking** Add `zng_app::render::Img::size` for the renderer image interface trait.
+* **Breaking** Changed default image filter to only allow images in `zng::env::res`, replacing the current exe dir filter.
+* **Breaking** Add missing inputs in 9-patch border rendering methods in `FrameBuilder` and `DisplayListBuilder`.
 
 # 0.12.10
 
diff --git a/crates/zng-app/src/render.rs b/crates/zng-app/src/render.rs
index a11138e8e..a986c273e 100644
--- a/crates/zng-app/src/render.rs
+++ b/crates/zng-app/src/render.rs
@@ -74,6 +74,9 @@ pub trait Img {
     fn alpha_type(&self) -> zng_view_api::AlphaType {
         zng_view_api::AlphaType::PremultipliedAlpha
     }
+
+    /// Image pixel size.
+    fn size(&self) -> PxSize;
 }
 
 macro_rules! expect_inner {
@@ -1275,6 +1278,7 @@ impl FrameBuilder {
         &mut self,
         bounds: PxRect,
         widths: PxSideOffsets,
+        slice: PxSideOffsets,
         fill: bool,
         repeat_horizontal: RepeatMode,
         repeat_vertical: RepeatMode,
@@ -1290,6 +1294,8 @@ impl FrameBuilder {
                 bounds,
                 NinePatchSource::Image { image_id, rendering },
                 widths,
+                image.size(),
+                slice,
                 fill,
                 repeat_horizontal,
                 repeat_vertical,
@@ -1303,6 +1309,7 @@ impl FrameBuilder {
         &mut self,
         bounds: PxRect,
         widths: PxSideOffsets,
+        slice: PxSideOffsets,
         fill: bool,
         repeat_horizontal: RepeatMode,
         repeat_vertical: RepeatMode,
@@ -1329,6 +1336,8 @@ impl FrameBuilder {
                     stops: stops.to_vec().into_boxed_slice(),
                 },
                 widths,
+                bounds.size,
+                slice,
                 fill,
                 repeat_horizontal,
                 repeat_vertical,
@@ -1342,6 +1351,7 @@ impl FrameBuilder {
         &mut self,
         bounds: PxRect,
         widths: PxSideOffsets,
+        slice: PxSideOffsets,
         fill: bool,
         repeat_horizontal: RepeatMode,
         repeat_vertical: RepeatMode,
@@ -1372,6 +1382,8 @@ impl FrameBuilder {
                     stops: stops.to_vec().into_boxed_slice(),
                 },
                 widths,
+                bounds.size,
+                slice,
                 fill,
                 repeat_horizontal,
                 repeat_vertical,
@@ -1385,6 +1397,7 @@ impl FrameBuilder {
         &mut self,
         bounds: PxRect,
         widths: PxSideOffsets,
+        slice: PxSideOffsets,
         fill: bool,
         repeat_horizontal: RepeatMode,
         repeat_vertical: RepeatMode,
@@ -1415,6 +1428,8 @@ impl FrameBuilder {
                     stops: stops.to_vec().into_boxed_slice(),
                 },
                 widths,
+                bounds.size,
+                slice,
                 fill,
                 repeat_horizontal,
                 repeat_vertical,
diff --git a/crates/zng-ext-image/Cargo.toml b/crates/zng-ext-image/Cargo.toml
index ed64d02c5..8e091cab8 100644
--- a/crates/zng-ext-image/Cargo.toml
+++ b/crates/zng-ext-image/Cargo.toml
@@ -27,6 +27,7 @@ zng-task = { path = "../zng-task", version = "0.4.11" }
 zng-layout = { path = "../zng-layout", version = "0.2.30" }
 zng-color = { path = "../zng-color", version = "0.3.16" }
 zng-state-map = { path = "../zng-state-map", version = "0.3.7" }
+zng-env = { path = "../zng-env", version = "0.3.7" }
 
 serde = "1.0"
 once_cell = "1.19"
diff --git a/crates/zng-ext-image/src/types.rs b/crates/zng-ext-image/src/types.rs
index ca82c4778..c4807a4ca 100644
--- a/crates/zng-ext-image/src/types.rs
+++ b/crates/zng-ext-image/src/types.rs
@@ -397,6 +397,10 @@ impl zng_app::render::Img for Img {
             ImageTextureId::INVALID
         }
     }
+
+    fn size(&self) -> PxSize {
+        self.size()
+    }
 }
 
 struct RenderImage {
@@ -1002,6 +1006,13 @@ impl PathFilter {
         // not `BlockAll` so this can still be composed using `or`.
         Self::custom(|_| false)
     }
+
+    /// Allow any file inside the [`zng::env::res`] directory or sub-directories.
+    ///
+    /// [`zng::env::res`]: zng_env::res
+    pub fn allow_res() -> Self {
+        Self::allow_dir(zng_env::res(""))
+    }
 }
 
 /// Represents a [`ImageSource::Download`] path request filter.
@@ -1104,12 +1115,12 @@ impl ImageLimits {
 impl Default for ImageLimits {
     /// 100 megabytes encoded and 4096 megabytes decoded (BMP max).
     ///
-    /// Allows only paths in the executable directory, blocks all downloads.
+    /// Allows only paths in `zng::env::res`, blocks all downloads.
     fn default() -> Self {
         Self {
             max_encoded_len: 100.megabytes(),
             max_decoded_len: 4096.megabytes(),
-            allow_path: PathFilter::allow_exe_dir(),
+            allow_path: PathFilter::allow_res(),
             #[cfg(feature = "http")]
             allow_uri: UriFilter::BlockAll,
         }
diff --git a/crates/zng-view-api/src/display_list.rs b/crates/zng-view-api/src/display_list.rs
index 15495621a..bf85e926f 100644
--- a/crates/zng-view-api/src/display_list.rs
+++ b/crates/zng-view-api/src/display_list.rs
@@ -258,11 +258,14 @@ impl DisplayListBuilder {
     }
 
     /// Push a nine-patch border.
+    #[expect(clippy::too_many_arguments)]
     pub fn push_nine_patch_border(
         &mut self,
         bounds: PxRect,
         source: NinePatchSource,
         widths: PxSideOffsets,
+        img_size: PxSize,
+        slice: PxSideOffsets,
         fill: bool,
         repeat_horizontal: RepeatMode,
         repeat_vertical: RepeatMode,
@@ -271,6 +274,8 @@ impl DisplayListBuilder {
             bounds,
             source,
             widths,
+            img_size,
+            slice,
             fill,
             repeat_horizontal,
             repeat_vertical,
@@ -830,6 +835,8 @@ pub enum DisplayItem {
         bounds: PxRect,
         source: NinePatchSource,
         widths: PxSideOffsets,
+        img_size: PxSize,
+        slice: PxSideOffsets,
         fill: bool,
         repeat_horizontal: RepeatMode,
         repeat_vertical: RepeatMode,
diff --git a/crates/zng-view-api/src/types.rs b/crates/zng-view-api/src/types.rs
index 25ab3b97e..289233b0a 100644
--- a/crates/zng-view-api/src/types.rs
+++ b/crates/zng-view-api/src/types.rs
@@ -853,9 +853,10 @@ pub struct ReferenceFrameId(pub u64, pub u64);
 ///
 /// Defines how the edges and middle region of a nine-patch border is filled.
 #[repr(u8)]
-#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, serde::Serialize, serde::Deserialize)]
+#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, serde::Serialize, serde::Deserialize, Default)]
 pub enum RepeatMode {
     /// The source image's edge regions are stretched to fill the gap between each border.
+    #[default]
     Stretch,
     /// The source image's edge regions are tiled (repeated) to fill the gap between each
     /// border. Tiles may be clipped to achieve the proper fit.
@@ -867,12 +868,22 @@ pub enum RepeatMode {
     /// border. Extra space will be distributed in between tiles to achieve the proper fit.
     Space,
 }
+/// Converts `true` to `Repeat` and `false` to the default `Stretch`.
+impl From<bool> for RepeatMode {
+    fn from(value: bool) -> Self {
+        match value {
+            true => RepeatMode::Repeat,
+            false => RepeatMode::Stretch,
+        }
+    }
+}
 
 /// Color mix blend mode.
 #[allow(missing_docs)]
 #[repr(u8)]
-#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
+#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, PartialEq, Serialize, Default)]
 pub enum MixBlendMode {
+    #[default]
     Normal = 0,
     Multiply = 1,
     Screen = 2,
diff --git a/crates/zng-view/src/display_list.rs b/crates/zng-view/src/display_list.rs
index 60a817e79..196249a73 100644
--- a/crates/zng-view/src/display_list.rs
+++ b/crates/zng-view/src/display_list.rs
@@ -640,9 +640,11 @@ fn display_item_to_webrender(
             source,
             bounds,
             widths,
+            img_size,
             fill,
-            repeat_horizontal,
-            repeat_vertical,
+            slice,
+            mut repeat_horizontal,
+            mut repeat_vertical,
         } => {
             let wr_bounds = bounds.to_wr();
             let clip = sc.clip_chain_id(wr_list);
@@ -700,6 +702,21 @@ fn display_item_to_webrender(
                 }
             };
 
+            // webrender does not implement RepeatMode::Space
+            let mut space_mode = false;
+            if matches!(repeat_horizontal, zng_view_api::RepeatMode::Space) {
+                repeat_horizontal = zng_view_api::RepeatMode::Repeat;
+                space_mode = true;
+            }
+            if matches!(repeat_vertical, zng_view_api::RepeatMode::Space) {
+                repeat_vertical = zng_view_api::RepeatMode::Repeat;
+                space_mode = true;
+            }
+
+            if space_mode {
+                tracing::warn!("`RepeatMode::Space` not implemented, will use `RepeatMode::Repeat`");
+            }
+
             wr_list.push_border(
                 &wr::CommonItemProperties {
                     clip_rect: wr_bounds,
@@ -711,9 +728,9 @@ fn display_item_to_webrender(
                 widths.to_wr(),
                 wr::BorderDetails::NinePatch(wr::NinePatchBorder {
                     source,
-                    width: bounds.width().0,
-                    height: bounds.height().0,
-                    slice: widths.to_wr_device(),
+                    width: img_size.width.0,
+                    height: img_size.height.0,
+                    slice: slice.to_wr_device(),
                     fill: *fill,
                     repeat_horizontal: repeat_horizontal.to_wr(),
                     repeat_vertical: repeat_vertical.to_wr(),
diff --git a/crates/zng-wgt-image/src/border.rs b/crates/zng-wgt-image/src/border.rs
new file mode 100644
index 000000000..62de6ee02
--- /dev/null
+++ b/crates/zng-wgt-image/src/border.rs
@@ -0,0 +1,222 @@
+//! 9-patch image border.
+
+use std::mem;
+
+use zng_app::render::RepeatMode;
+use zng_ext_image::{ImageCacheMode, ImageRenderArgs, ImageSource, Img, IMAGES};
+use zng_wgt::prelude::*;
+
+use crate::{IMAGE_CACHE_VAR, IMAGE_LIMITS_VAR, IMAGE_RENDERING_VAR};
+
+/// 9-patch image border.
+///
+/// The `source` image is sliced by `slices` and projected onto `widths` in the widget layout.
+///
+/// See also [`border_img_repeat`] and [`border_img_fill`].
+///
+/// [`border_img_repeat`]: fn@border_img_repeat
+/// [`border_img_fill`]: fn@border_img_fill
+#[property(BORDER)]
+pub fn border_img(
+    child: impl UiNode,
+    widths: impl IntoVar<SideOffsets>,
+    source: impl IntoVar<ImageSource>,
+    slices: impl IntoVar<SideOffsets>,
+) -> impl UiNode {
+    let widths = widths.into_var();
+    let source = source.into_var();
+    let slices = slices.into_var();
+
+    let mut img = var(Img::dummy(None)).read_only();
+    let mut _img_sub = VarHandle::dummy();
+    let mut slices_img_size = PxSize::zero();
+    let mut slices_px = PxSideOffsets::zero();
+
+    border_node(
+        child,
+        widths,
+        match_node_leaf(move |op| match op {
+            UiNodeOp::Init => {
+                WIDGET
+                    .sub_var(&source)
+                    .sub_var(&IMAGE_CACHE_VAR)
+                    .sub_var_render(&slices)
+                    .sub_var_render(&BORDER_IMG_REPEAT_VAR)
+                    .sub_var_render(&IMAGE_RENDERING_VAR)
+                    .sub_var_render(&BORDER_IMG_FILL_VAR);
+
+                let mode = if IMAGE_CACHE_VAR.get() {
+                    ImageCacheMode::Cache
+                } else {
+                    ImageCacheMode::Ignore
+                };
+                let limits = IMAGE_LIMITS_VAR.get();
+
+                let mut source = source.get();
+                if let ImageSource::Render(_, args) = &mut source {
+                    *args = Some(ImageRenderArgs { parent: Some(WINDOW.id()) });
+                }
+                img = IMAGES.image(source, mode, limits, None, None);
+                _img_sub = img.subscribe(UpdateOp::Update, WIDGET.id());
+
+                let img = img.get();
+                if img.is_loaded() {
+                    slices_img_size = img.size();
+                }
+            }
+            UiNodeOp::Deinit => {
+                img = var(Img::dummy(None)).read_only();
+                _img_sub = VarHandle::dummy();
+            }
+            UiNodeOp::Update { .. } => {
+                if source.is_new() {
+                    // source update:
+
+                    let mut source = source.get();
+
+                    if let ImageSource::Render(_, args) = &mut source {
+                        *args = Some(ImageRenderArgs { parent: Some(WINDOW.id()) });
+                    }
+
+                    let mode = if IMAGE_CACHE_VAR.get() {
+                        ImageCacheMode::Cache
+                    } else {
+                        ImageCacheMode::Ignore
+                    };
+                    let limits = IMAGE_LIMITS_VAR.get();
+
+                    img = IMAGES.image(source, mode, limits, None, None);
+                } else if let Some(enabled) = IMAGE_CACHE_VAR.get_new() {
+                    // cache-mode update:
+                    let is_cached = img.with(|img| IMAGES.is_cached(img));
+                    if enabled != is_cached {
+                        img = if is_cached {
+                            // must not cache, but is cached, detach from cache.
+
+                            let img = mem::replace(&mut img, var(Img::dummy(None)).read_only());
+                            IMAGES.detach(img)
+                        } else {
+                            // must cache, but image is not cached, get source again.
+
+                            let source = source.get();
+                            let limits = IMAGE_LIMITS_VAR.get();
+                            IMAGES.image(source, ImageCacheMode::Cache, limits, None, None)
+                        };
+                    }
+                }
+
+                if img.is_new() {
+                    let img = img.get();
+                    if img.is_loaded() {
+                        let s = img.size();
+                        if s != slices_img_size {
+                            slices_img_size = s;
+                            WIDGET.layout();
+                        }
+                    }
+                    WIDGET.render();
+                }
+            }
+            UiNodeOp::Measure { desired_size, .. } => {
+                *desired_size = LAYOUT.constraints().fill_size();
+            }
+            UiNodeOp::Layout { final_size, .. } => {
+                *final_size = LAYOUT.constraints().fill_size();
+
+                let metrics = LAYOUT
+                    .metrics()
+                    .with_constraints(PxConstraints2d::new_exact_size(slices_img_size))
+                    .with_scale_factor(1.fct());
+                let s = LAYOUT.with_context(metrics, || slices.layout());
+                if s != slices_px {
+                    slices_px = s;
+                    WIDGET.render();
+                }
+            }
+            UiNodeOp::Render { frame } => {
+                let img = img.get();
+                if img.is_loaded() {
+                    let (rect, offsets) = BORDER.border_layout();
+                    if !rect.size.is_empty() {
+                        let repeats = BORDER_IMG_REPEAT_VAR.get();
+                        frame.push_border_image(
+                            rect,
+                            offsets,
+                            slices_px,
+                            BORDER_IMG_FILL_VAR.get(),
+                            repeats.top_bottom,
+                            repeats.left_right,
+                            &img,
+                            IMAGE_RENDERING_VAR.get(),
+                        );
+                    }
+                }
+            }
+            _ => {}
+        }),
+    )
+}
+
+/// Defines how the 9-patch edge slices are used to fill the widths.
+///
+/// This property sets the [`BORDER_IMG_REPEAT_VAR`].
+#[property(CONTEXT, default(BORDER_IMG_REPEAT_VAR))]
+pub fn border_img_repeat(child: impl UiNode, repeats: impl IntoVar<BorderRepeats>) -> impl UiNode {
+    with_context_var(child, BORDER_IMG_REPEAT_VAR, repeats)
+}
+
+/// Defines if the middle slice of the 9-patch image is also rendered.
+///
+/// This property sets the [`BORDER_IMG_FILL_VAR`].
+#[property(CONTEXT, default(BORDER_IMG_FILL_VAR))]
+pub fn border_img_fill(child: impl UiNode, fill: impl IntoVar<bool>) -> impl UiNode {
+    with_context_var(child, BORDER_IMG_FILL_VAR, fill)
+}
+
+/// Defines how the 9-patch edge slices are used to fill the widths.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize, Default)]
+pub struct BorderRepeats {
+    /// Top and bottom edges.
+    ///
+    /// Also middle if *fill* is set.
+    pub top_bottom: RepeatMode,
+    /// Left and right edges.
+    pub left_right: RepeatMode,
+}
+impl BorderRepeats {
+    /// Top-bottom and left-right equal. From any [`RepeatMode`] type.
+    pub fn new<TB: Into<RepeatMode>, LR: Into<RepeatMode>>(top_bottom: TB, left_right: LR) -> Self {
+        BorderRepeats {
+            top_bottom: top_bottom.into(),
+            left_right: left_right.into(),
+        }
+    }
+
+    /// All sides equal. From any [`RepeatMode`] type.
+    pub fn new_all<T: Into<RepeatMode>>(all_sides: T) -> Self {
+        let all_sides = all_sides.into();
+        Self::new(all_sides, all_sides)
+    }
+}
+context_var! {
+    /// Defines how the 9-patch edge slices are used to fill the widths.
+    pub static BORDER_IMG_REPEAT_VAR: BorderRepeats = BorderRepeats::default();
+
+    /// If the middle slice is rendered in a 9-patch border.
+    pub static BORDER_IMG_FILL_VAR: bool = false;
+}
+
+impl_from_and_into_var! {
+    fn from(repeat: RepeatMode) -> BorderRepeats {
+        BorderRepeats::new_all(repeat)
+    }
+
+    /// `true` is `Repeat`, `false` is `Stretch`.
+    fn from(repeat_or_stretch: bool) -> BorderRepeats {
+        BorderRepeats::new_all(repeat_or_stretch)
+    }
+
+    fn from<TB: Into<RepeatMode>, LR: Into<RepeatMode>>((top_bottom, left_right): (TB, LR)) -> BorderRepeats {
+        BorderRepeats::new(top_bottom, left_right)
+    }
+}
diff --git a/crates/zng-wgt-image/src/lib.rs b/crates/zng-wgt-image/src/lib.rs
index 3a6dc56c9..b5070384e 100644
--- a/crates/zng-wgt-image/src/lib.rs
+++ b/crates/zng-wgt-image/src/lib.rs
@@ -15,6 +15,7 @@ use zng_wgt::prelude::*;
 mod image_properties;
 pub use image_properties::*;
 
+pub mod border;
 pub mod mask;
 
 use zng_wgt_access::{access_role, AccessRole};
diff --git a/crates/zng/src/render.rs b/crates/zng/src/render.rs
index 9b2a72aa7..bd49450b5 100644
--- a/crates/zng/src/render.rs
+++ b/crates/zng/src/render.rs
@@ -106,6 +106,6 @@
 
 pub use zng_app::render::{
     ClipBuilder, FontSynthesis, FrameBuilder, FrameUpdate, FrameValue, FrameValueKey, FrameValueUpdate, HitTestBuilder, HitTestClipBuilder,
-    ImageRendering, ReferenceFrameId, RepeatMode, ReuseRange, SpatialFrameId,
+    ImageRendering, ReferenceFrameId, ReuseRange, SpatialFrameId,
 };
 pub use zng_view_api::window::FrameId;
diff --git a/crates/zng/src/widget.rs b/crates/zng/src/widget.rs
index 4131c0e7d..2b2ec46ce 100644
--- a/crates/zng/src/widget.rs
+++ b/crates/zng/src/widget.rs
@@ -47,6 +47,8 @@ pub use zng_app::widget::border::{
 pub use zng_app::widget::info::Visibility;
 pub use zng_app::widget::node::ZIndex;
 
+pub use zng_app::render::RepeatMode;
+
 pub use zng_wgt::{
     auto_hide, border, border_align, border_over, clip_to_bounds, corner_radius, corner_radius_fit, enabled, hit_test_mode, inline,
     interactive, is_collapsed, is_disabled, is_enabled, is_hidden, is_hit_testable, is_inited, is_mobile, is_visible, modal,
@@ -59,6 +61,8 @@ pub use zng_wgt::{
     OnNodeOpArgs, WeakWidgetFn, Wgt, WidgetFn, EDITORS, IS_MOBILE_VAR,
 };
 
+pub use zng_wgt_image::border::{border_img, border_img_fill, border_img_repeat, BorderRepeats};
+
 pub use zng_wgt_fill::{
     background, background_color, background_conic, background_fn, background_gradient, background_radial, foreground, foreground_color,
     foreground_conic, foreground_fn, foreground_gradient, foreground_highlight, foreground_radial,
diff --git a/examples/border/Cargo.toml b/examples/border/Cargo.toml
index 329cea4ab..e951e2464 100644
--- a/examples/border/Cargo.toml
+++ b/examples/border/Cargo.toml
@@ -5,4 +5,4 @@ publish = false
 edition = "2021"
 
 [dependencies]
-zng = { path = "../../crates/zng", features = ["view_prebuilt"] }
+zng = { path = "../../crates/zng", features = ["view"] }
diff --git a/examples/border/res/border.png b/examples/border/res/border.png
new file mode 100644
index 000000000..e84870e6c
Binary files /dev/null and b/examples/border/res/border.png differ
diff --git a/examples/border/res/screenshot.png b/examples/border/res/screenshot.png
index 1df74aebe..cb6d9ca59 100644
Binary files a/examples/border/res/screenshot.png and b/examples/border/res/screenshot.png differ
diff --git a/examples/border/src/main.rs b/examples/border/src/main.rs
index 333ae49a2..df9347185 100644
--- a/examples/border/src/main.rs
+++ b/examples/border/src/main.rs
@@ -8,8 +8,14 @@ use zng::{
 mod widgets;
 
 fn main() {
+    zng::env::init_res(concat!(env!("CARGO_MANIFEST_DIR"), "/res"));
     zng::env::init!();
 
+    // zng::view_process::default::run_same_process(app_main);
+    app_main();
+}
+
+fn app_main() {
     APP.defaults().run_window(async {
         Window! {
             title = "Border Example";
@@ -43,6 +49,18 @@ fn main() {
                                 border_align = 100.pct();
                                 child = Text!("border_align = 100.pct();");
                             },
+                            Button! {
+                                child = Text!("border_img");
+                                on_click = hn!(|_| on_border_img());
+                                zng::color::base_color = web_colors::GREEN.darken(40.pct());
+                                zng::mouse::cursor = zng::mouse::CursorIcon::Pointer;
+
+                                widget::border_img = {
+                                    widths: 5,
+                                    source: zng::env::res("border.png"),
+                                    slices: (100.0 / 3.0).pct(),
+                                }
+                            }
                         ]
                     },
                     Stack! {
@@ -76,6 +94,55 @@ fn main() {
     })
 }
 
+fn on_border_img() {
+    WINDOWS.focus_or_open("border_img-win", async {
+        let fill = var(false);
+        let repeat = var(zng::widget::RepeatMode::default());
+        Window! {
+            title = "border_img";
+            child = Container! {
+                widget::border_img = {
+                    widths: 15,
+                    source: zng::env::res("border.png"),
+                    slices: (100.0 / 3.0).pct(),
+                };
+                widget::border_img_fill = fill.clone();
+                widget::border_img_repeat = repeat.map_into();
+
+                layout::margin = 20;
+                padding = 20;
+                child = Stack!(top_to_bottom, ui_vec![
+                    Toggle! {
+                        checked = fill;
+                        style_fn = toggle::CheckStyle!();
+                        child = Text!("border_img_fill = true;");
+                    },
+                    Text!(txt = "border_img_repeat = "; layout::margin = (25, 0, 0, 0)),
+                    Stack! {
+                        direction = StackDirection::top_to_bottom();
+                        spacing = 5;
+                        toggle::selector = toggle::Selector::single(repeat);
+                        layout::margin = (0, 0, 0, 50);
+                        children = {
+                            use zng::widget::RepeatMode::*;
+                            [
+                                Stretch,
+                                Repeat,
+                                Round,
+                                Space, // not implemented by zng-view yet
+                            ].into_iter().map(|m| Toggle! {
+                                value::<zng::widget::RepeatMode> = m;
+                                child = Text!("{m:?};");
+                                style_fn = toggle::RadioStyle!();
+                            }).collect::<UiVec>()
+                        }
+                    }
+                ])
+            }
+        }
+    });
+}
+
 fn clip_to_bounds_demo() -> impl UiNode {
     let clip = var(true);
     Container! {
diff --git a/examples/image/src/main.rs b/examples/image/src/main.rs
index e05c9a9e1..af653f1e7 100644
--- a/examples/image/src/main.rs
+++ b/examples/image/src/main.rs
@@ -27,7 +27,7 @@ fn main() {
 
     APP.defaults().run_window(async {
         // by default all "ImageSource::Download" requests are blocked and "ImageSource::Read"
-        // is limited to only the exe dir. The limits can be set globally in here and overridden 
+        // is limited to only the `zng::env::res`. The limits can be set globally in here and overridden 
         // for each image with the "img_limits" property.
         IMAGES.limits().modify(|l| {
             let l = l.to_mut();
diff --git a/examples/window/src/main.rs b/examples/window/src/main.rs
index 764e09c24..d73e7318f 100644
--- a/examples/window/src/main.rs
+++ b/examples/window/src/main.rs
@@ -27,11 +27,6 @@ fn app_main() {
 async fn main_window() -> window::WindowRoot {
     // WINDOWS.exit_on_last_close().set(false);
 
-    zng::image::IMAGES.limits().modify(|l| {
-        let l = l.to_mut();
-        l.allow_path = zng::image::PathFilter::allow_dir(zng::env::res(""));
-    });
-
     let window_vars = WINDOW.vars();
     let title = merge_var!(
         window_vars.actual_position(),


Test patch: