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

Issue:
Implement cursor images
New winit supports this, already prepared some view API code in anticipation, just need to make it work.


Discussion / hints:

Fix patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a92db34ae..0df55fced 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -71,6 +71,10 @@ fn version_0_5() -> impl UiNode {
 * Fix panic on window move in Wayland.
 * Fix minimize command removing maximized state from restore.
 * Fix issue when parent widget's cursor can override child's cursor when the parent cursor var updates.
+* **Breaking** Remove the `cursor_img` property and window var.
+* **Breaking** The `cursor` property now has input type `CursorSource`.
+    - Note that the `CursorIcon` type converts to `CursorSource`.
+* Implement custom cursor images in the default view.
 
 # 0.4.0
 
diff --git a/crates/zng-ext-window/src/control.rs b/crates/zng-ext-window/src/control.rs
index 4d2071649..82b298f5b 100644
--- a/crates/zng-ext-window/src/control.rs
+++ b/crates/zng-ext-window/src/control.rs
@@ -46,9 +46,9 @@ use zng_view_api::{
 
 use crate::{
     cmd::{WindowCommands, MINIMIZE_CMD, RESTORE_CMD},
-    AutoSize, CursorImg, FrameCaptureMode, FrameImageReadyArgs, HeadlessMonitor, MonitorInfo, StartPosition, WidgetInfoImeArea,
-    WindowChangedArgs, WindowIcon, WindowRoot, WindowVars, FRAME_IMAGE_READY_EVENT, MONITORS, MONITORS_CHANGED_EVENT, WINDOWS,
-    WINDOW_CHANGED_EVENT, WINDOW_FOCUS,
+    AutoSize, FrameCaptureMode, FrameImageReadyArgs, HeadlessMonitor, MonitorInfo, StartPosition, WidgetInfoImeArea, WindowChangedArgs,
+    WindowIcon, WindowRoot, WindowVars, FRAME_IMAGE_READY_EVENT, MONITORS, MONITORS_CHANGED_EVENT, WINDOWS, WINDOW_CHANGED_EVENT,
+    WINDOW_FOCUS,
 };
 
 struct ImageResources {
@@ -377,24 +377,41 @@ impl HeadedCtrl {
             });
         }
 
-        // cursor_image:
-        if let Some(cur) = self.vars.cursor_img().get_new() {
-            self.img_res.cursor_var = match cur {
-                None => None,
-                Some(CursorImg { source, .. }) => match source {
-                    ImageSource::Render(cur, _) => Some(IMAGES.cache(ImageSource::Render(
-                        cur.clone(),
-                        Some(ImageRenderArgs { parent: Some(WINDOW.id()) }),
-                    ))),
-                    source => Some(IMAGES.cache(source)),
-                },
-            };
+        // cursor (image):
+        if let Some(cursor) = self.vars.cursor().get_new() {
+            match cursor {
+                crate::CursorSource::Icon(ico) => {
+                    self.img_res.cursor_var = None;
+                    self.update_gen(move |view| {
+                        let _: Ignore = view.set_cursor(Some(ico));
+                        let _: Ignore = view.set_cursor_image(None, PxPoint::zero());
+                    });
+                }
+                crate::CursorSource::Img(img) => {
+                    self.img_res.cursor_var = Some(match img.source {
+                        ImageSource::Render(cur, _) => IMAGES.cache(ImageSource::Render(
+                            cur.clone(),
+                            Some(ImageRenderArgs { parent: Some(WINDOW.id()) }),
+                        )),
+                        source => IMAGES.cache(source),
+                    });
+
+                    self.update_gen(move |view| {
+                        let _: Ignore = view.set_cursor(Some(img.fallback));
+                        let _: Ignore = view.set_cursor_image(None, PxPoint::zero());
+                    });
+                }
+                crate::CursorSource::Hidden => {
+                    self.img_res.cursor_var = None;
+                    self.update_gen(move |view| {
+                        let _: Ignore = view.set_cursor(None);
+                        let _: Ignore = view.set_cursor_image(None, PxPoint::zero());
+                    });
+                }
+            }
 
             if let Some(cur) = &self.img_res.cursor_var {
-                let hotspot = self
-                    .vars
-                    .cursor_img()
-                    .with(|i| i.as_ref().map(|s| s.hotspot.clone()).unwrap_or_default());
+                let hotspot = self.vars.cursor().with(|i| i.hotspot().cloned().unwrap_or_default());
 
                 let cursor_img_to_actual = move |img: &Img| -> Option<(Img, PxPoint)> {
                     let hotspot = if img.is_loaded() {
@@ -410,7 +427,6 @@ impl HeadedCtrl {
 
                     Some((img.clone(), hotspot))
                 };
-
                 self.vars.0.actual_cursor_img.set_from_map(cur, cursor_img_to_actual.clone());
                 self.img_res.cursor_binding = cur.bind_map(&self.vars.0.actual_cursor_img, cursor_img_to_actual);
 
@@ -465,12 +481,6 @@ impl HeadedCtrl {
             });
         }
 
-        if let Some(cursor) = self.vars.cursor().get_new() {
-            self.update_gen(move |view| {
-                let _: Ignore = view.set_cursor(cursor);
-            });
-        }
-
         if let Some(visible) = self.vars.taskbar_visible().get_new() {
             self.update_gen(move |view| {
                 let _: Ignore = view.set_taskbar_visible(visible);
@@ -1113,13 +1123,12 @@ impl HeadedCtrl {
                 .as_ref()
                 .and_then(|ico| ico.get().view().map(|ico| ico.id()))
                 .flatten(),
-            cursor: self.vars.cursor().get(),
+            cursor: self.vars.cursor().with(|c| c.icon()),
             cursor_image: self
-                .img_res
-                .cursor_var
-                .as_ref()
-                .and_then(|cur| cur.get().view().map(|cur| cur.id()))
-                .flatten(),
+                .vars
+                .actual_cursor_img()
+                .get()
+                .and_then(|(i, h)| i.view().and_then(|i| i.id()).map(|i| (i, h))),
             transparent: self.transparent,
             capture_mode: matches!(self.vars.frame_capture_mode().get(), FrameCaptureMode::All),
             render_mode: self.render_mode.unwrap_or_else(|| WINDOWS.default_render_mode().get()),
@@ -1240,13 +1249,12 @@ impl HeadedCtrl {
                 .as_ref()
                 .and_then(|ico| ico.get().view().map(|ico| ico.id()))
                 .flatten(),
-            cursor: self.vars.cursor().get(),
+            cursor: self.vars.cursor().with(|c| c.icon()),
             cursor_image: self
-                .img_res
-                .cursor_var
-                .as_ref()
-                .and_then(|cur| cur.get().view().map(|cur| cur.id()))
-                .flatten(),
+                .vars
+                .actual_cursor_img()
+                .get()
+                .and_then(|(i, h)| i.view().and_then(|i| i.id()).map(|i| (i, h))),
             transparent: self.transparent,
             capture_mode: matches!(self.vars.frame_capture_mode().get(), FrameCaptureMode::All),
             render_mode: self.render_mode.unwrap_or_else(|| WINDOWS.default_render_mode().get()),
diff --git a/crates/zng-ext-window/src/types.rs b/crates/zng-ext-window/src/types.rs
index d47b995ec..202bbeed7 100644
--- a/crates/zng-ext-window/src/types.rs
+++ b/crates/zng-ext-window/src/types.rs
@@ -21,7 +21,7 @@ use zng_unique_id::IdSet;
 use zng_var::impl_from_and_into_var;
 use zng_view_api::{
     image::{ImageDataFormat, ImageMaskMode},
-    window::{EventCause, FrameId},
+    window::{CursorIcon, EventCause, FrameId},
     ViewProcessOffline,
 };
 
@@ -366,11 +366,67 @@ pub struct CursorImg {
     ///
     /// This value is ignored if the image source format already has hotspot information.
     pub hotspot: Point,
+
+    /// Icon to use if the image cannot be displayed.
+    pub fallback: CursorIcon,
 }
 impl_from_and_into_var! {
     fn from(img: CursorImg) -> Option<CursorImg>;
 }
 
+/// Window cursor source.
+#[derive(Debug, Clone, PartialEq)]
+pub enum CursorSource {
+    /// Platform dependent named cursor icon.
+    Icon(CursorIcon),
+    /// Custom cursor image, with fallback.
+    Img(CursorImg),
+    /// Don't show cursor.
+    Hidden,
+}
+impl CursorSource {
+    /// Get the icon, image fallback or `None` if is hidden.
+    pub fn icon(&self) -> Option<CursorIcon> {
+        match self {
+            CursorSource::Icon(ico) => Some(*ico),
+            CursorSource::Img(img) => Some(img.fallback),
+            CursorSource::Hidden => None,
+        }
+    }
+
+    /// Custom icon image source.
+    pub fn img(&self) -> Option<&ImageSource> {
+        match self {
+            CursorSource::Img(img) => Some(&img.source),
+            _ => None,
+        }
+    }
+
+    /// Custom icon image click point, when the image data does not contain a hotspot.
+    pub fn hotspot(&self) -> Option<&Point> {
+        match self {
+            CursorSource::Img(img) => Some(&img.hotspot),
+            _ => None,
+        }
+    }
+}
+impl_from_and_into_var! {
+    fn from(icon: CursorIcon) -> CursorSource {
+        CursorSource::Icon(icon)
+    }
+    fn from(img: CursorImg) -> CursorSource {
+        CursorSource::Img(img)
+    }
+    /// Converts `true` to `CursorIcon::Default` and `false` to `CursorSource::Hidden`.
+    fn from(default_icon_or_hidden: bool) -> CursorSource {
+        if default_icon_or_hidden {
+            CursorIcon::Default.into()
+        } else {
+            CursorSource::Hidden
+        }
+    }
+}
+
 /// Frame image capture mode in a window.
 ///
 /// You can set the capture mode using [`WindowVars::frame_capture_mode`].
diff --git a/crates/zng-ext-window/src/vars.rs b/crates/zng-ext-window/src/vars.rs
index 3411b6e13..98deec863 100644
--- a/crates/zng-ext-window/src/vars.rs
+++ b/crates/zng-ext-window/src/vars.rs
@@ -9,20 +9,19 @@ use zng_layout::unit::{Dip, DipPoint, DipRect, DipSize, DipToPx, Factor, FactorU
 use zng_state_map::StaticStateId;
 use zng_txt::{ToTxt, Txt};
 use zng_unique_id::IdSet;
-use zng_var::{merge_var, var, ArcVar, BoxedVar, ReadOnlyArcVar, Var};
+use zng_var::{merge_var, var, var_from, ArcVar, BoxedVar, ReadOnlyArcVar, Var};
 use zng_view_api::{
     config::ColorScheme,
     window::{CursorIcon, FocusIndicator, RenderMode, VideoMode, WindowButton, WindowState},
 };
 
-use crate::{AutoSize, CursorImg, FrameCaptureMode, MonitorQuery, WindowIcon};
+use crate::{AutoSize, CursorSource, FrameCaptureMode, MonitorQuery, WindowIcon};
 
 pub(super) struct WindowVarsData {
     chrome: ArcVar<bool>,
     icon: ArcVar<WindowIcon>,
     pub(super) actual_icon: ArcVar<Option<Img>>,
-    cursor: ArcVar<Option<CursorIcon>>,
-    cursor_img: ArcVar<Option<CursorImg>>,
+    cursor: ArcVar<CursorSource>,
     pub(super) actual_cursor_img: ArcVar<Option<(Img, PxPoint)>>,
     title: ArcVar<Txt>,
 
@@ -94,8 +93,7 @@ impl WindowVars {
             chrome: var(true),
             icon: var(WindowIcon::Default),
             actual_icon: var(None),
-            cursor: var(Some(CursorIcon::Default)),
-            cursor_img: var(None),
+            cursor: var_from(CursorIcon::Default),
             actual_cursor_img: var(None),
             title: var("".to_txt()),
 
@@ -202,32 +200,22 @@ impl WindowVars {
 
     /// Window cursor icon and visibility.
     ///
-    /// See [`CursorIcon`] for details.
+    /// See [`CursorSource`] for details.
     ///
-    /// The default is [`CursorIcon::Default`], if set to `None` no cursor icon is shown.
+    /// The default is [`CursorIcon::Default`].
     ///
     /// [`CursorIcon`]: zng_view_api::window::CursorIcon
     /// [`CursorIcon::Default`]: zng_view_api::window::CursorIcon::Default
-    pub fn cursor(&self) -> ArcVar<Option<CursorIcon>> {
+    pub fn cursor(&self) -> ArcVar<CursorSource> {
         self.0.cursor.clone()
     }
 
-    /// Window custom cursor.
-    ///
-    /// When this is set to a loaded image it is used as a cursor, falls back to [`cursor`].
-    ///
-    /// See [`CursorImg`] for details.
-    ///
-    /// [`cursor`]: Self::cursor
-    pub fn cursor_img(&self) -> ArcVar<Option<CursorImg>> {
-        self.0.cursor_img.clone()
-    }
-
     /// Window custom cursor image.
     ///
-    /// This is `None` if [`cursor_img`] is not set, otherwise it is an [`Img`] reference clone with computed hotspot [`PxPoint`].
+    /// This is `None` if [`cursor`] is not set to a custom image, otherwise it is an [`Img`]
+    /// reference clone with computed hotspot [`PxPoint`].
     ///
-    /// [`cursor_img`]: Self::cursor_img
+    /// [`cursor`]: Self::cursor
     /// [`Img`]: zng_ext_image::Img
     /// [`PxPoint`]: zng_layout::unit::PxPoint
     pub fn actual_cursor_img(&self) -> ReadOnlyArcVar<Option<(Img, PxPoint)>> {
diff --git a/crates/zng-view-api/src/window.rs b/crates/zng-view-api/src/window.rs
index 723f6746c..65d0f1e82 100644
--- a/crates/zng-view-api/src/window.rs
+++ b/crates/zng-view-api/src/window.rs
@@ -384,8 +384,8 @@ pub struct WindowRequest {
     pub icon: Option<ImageId>,
     /// Window cursor icon and visibility.
     pub cursor: Option<CursorIcon>,
-    /// Window custom cursor.
-    pub cursor_image: Option<ImageId>,
+    /// Window custom cursor with hotspot.
+    pub cursor_image: Option<(ImageId, PxPoint)>,
     /// If the window is see-through in pixels that are not fully opaque.
     pub transparent: bool,
 
@@ -533,7 +533,7 @@ impl WindowStateAll {
     }
 }
 
-/// Describes the appearance of the mouse cursor.
+/// Named system dependent cursor icon.
 #[non_exhaustive]
 #[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
 pub enum CursorIcon {
diff --git a/crates/zng-view/src/image_cache.rs b/crates/zng-view/src/image_cache.rs
index 7a5ed506b..15aeed7f7 100644
--- a/crates/zng-view/src/image_cache.rs
+++ b/crates/zng-view/src/image_cache.rs
@@ -1,7 +1,10 @@
 use std::{fmt, sync::Arc};
 
 use webrender::api::{ImageDescriptor, ImageDescriptorFlags, ImageFormat};
-use winit::window::Icon;
+use winit::{
+    event_loop::ActiveEventLoop,
+    window::{CustomCursor, Icon},
+};
 use zng_txt::{formatx, ToTxt, Txt};
 use zng_unit::{Px, PxPoint, PxSize};
 use zng_view_api::{
@@ -914,31 +917,62 @@ impl Image {
         let height = size.height.0 as u32;
         if width == 0 || height == 0 || self.0.is_mask() {
             None
-        } else if width > 255 || height > 255 {
-            // resize to max 255
-            let mut buf = pixels.as_ref().to_vec();
-            // BGRA to RGBA
-            buf.chunks_exact_mut(4).for_each(|c| c.swap(0, 2));
-            let img = image::ImageBuffer::from_raw(width, height, buf).unwrap();
-            let img = image::DynamicImage::ImageRgba8(img);
-            img.resize(255, 255, image::imageops::FilterType::Lanczos3);
-
-            use image::GenericImageView;
-            let (width, height) = img.dimensions();
-            let buf = img.into_rgba8().into_raw();
-            winit::window::Icon::from_rgba(buf, width, height).ok()
         } else {
-            let mut buf = pixels.as_ref().to_vec();
-            // BGRA to RGBA
-            buf.chunks_exact_mut(4).for_each(|c| c.swap(0, 2));
-            winit::window::Icon::from_rgba(buf, width, height).ok()
+            let r = if width > 255 || height > 255 {
+                // resize to max 255
+                let mut buf = pixels.as_ref().to_vec();
+                // BGRA to RGBA
+                buf.chunks_exact_mut(4).for_each(|c| c.swap(0, 2));
+                let img = image::ImageBuffer::from_raw(width, height, buf).unwrap();
+                let img = image::DynamicImage::ImageRgba8(img);
+                img.resize(255, 255, image::imageops::FilterType::Lanczos3);
+
+                use image::GenericImageView;
+                let (width, height) = img.dimensions();
+                let buf = img.into_rgba8().into_raw();
+                winit::window::Icon::from_rgba(buf, width, height)
+            } else {
+                let mut buf = pixels.as_ref().to_vec();
+                // BGRA to RGBA
+                buf.chunks_exact_mut(4).for_each(|c| c.swap(0, 2));
+                winit::window::Icon::from_rgba(buf, width, height)
+            };
+            match r {
+                Ok(i) => Some(i),
+                Err(e) => {
+                    tracing::error!("failed to convert image to custom icon, {e}");
+                    None
+                }
+            }
         }
     }
 
     /// Generate a cursor from the image.
-    pub fn cursor(&self, hotspot: PxPoint) -> Option<()> {
-        let _hotspot = hotspot;
-        None // TODO after https://github.com/rust-windowing/winit/issues/3306
+    pub fn cursor(&self, hotspot: PxPoint, event_loop: &ActiveEventLoop) -> Option<CustomCursor> {
+        let (size, pixels) = match &*self.0 {
+            ImageData::RawData { size, pixels, .. } => (size, pixels),
+            ImageData::NativeTexture { .. } => unreachable!(),
+        };
+
+        let width: u16 = size.width.0.try_into().ok()?;
+        let height: u16 = size.height.0.try_into().ok()?;
+        let hotspot_x: u16 = hotspot.x.0.try_into().ok()?;
+        let hotspot_y: u16 = hotspot.y.0.try_into().ok()?;
+
+        if width == 0 || height == 0 || hotspot_x > width || hotspot_y > height || self.0.is_mask() {
+            None
+        } else {
+            let mut buf = pixels.as_ref().to_vec();
+            // BGRA to RGBA
+            buf.chunks_exact_mut(4).for_each(|c| c.swap(0, 2));
+            match CustomCursor::from_rgba(buf, width, height, hotspot_x, hotspot_y) {
+                Ok(c) => Some(event_loop.create_custom_cursor(c)),
+                Err(e) => {
+                    tracing::error!("failed to convert image to custom cursor, {e}");
+                    None
+                }
+            }
+        }
     }
 
     pub fn encode(&self, format: image::ImageFormat, buffer: &mut Vec<u8>) -> image::ImageResult<()> {
diff --git a/crates/zng-view/src/lib.rs b/crates/zng-view/src/lib.rs
index f51673c89..b1b51d632 100644
--- a/crates/zng-view/src/lib.rs
+++ b/crates/zng-view/src/lib.rs
@@ -1541,6 +1541,9 @@ impl Api for App {
             let win = Window::open(
                 self.gen,
                 config.icon.and_then(|i| self.image_cache.get(i)).and_then(|i| i.icon()),
+                config
+                    .cursor_image
+                    .and_then(|(i, h)| self.image_cache.get(i).and_then(|i| i.cursor(h, &self.winit_loop))),
                 config,
                 &self.winit_loop,
                 &mut self.gl_manager,
@@ -1684,14 +1687,8 @@ impl Api for App {
     }
 
     fn set_cursor_image(&mut self, id: WindowId, icon: Option<CursorImage>) {
-        let icon = icon.and_then(|img| self.image_cache.get(img.img).and_then(|i| i.cursor(img.hotspot)));
-        self.with_window(
-            id,
-            |w| {
-                let _ = (w, icon); // TODO after https://github.com/rust-windowing/winit/issues/3306
-            },
-            || (),
-        );
+        let icon = icon.and_then(|img| self.image_cache.get(img.img).and_then(|i| i.cursor(img.hotspot, &self.winit_loop)));
+        self.with_window(id, |w| w.set_cursor_image(icon), || ());
     }
 
     fn set_ime_area(&mut self, id: WindowId, area: Option<DipRect>) {
diff --git a/crates/zng-view/src/window.rs b/crates/zng-view/src/window.rs
index 60a40f16b..f9dcb539d 100644
--- a/crates/zng-view/src/window.rs
+++ b/crates/zng-view/src/window.rs
@@ -19,7 +19,7 @@ use webrender::{
 use winit::{
     event_loop::ActiveEventLoop,
     monitor::{MonitorHandle, VideoModeHandle as GVideoMode},
-    window::{Fullscreen, Icon, Window as GWindow, WindowAttributes},
+    window::{CustomCursor, Fullscreen, Icon, Window as GWindow, WindowAttributes},
 };
 use zng_txt::{ToTxt, Txt};
 use zng_unit::{DipPoint, DipRect, DipSize, DipToPx, Factor, Px, PxPoint, PxRect, PxToDip, PxVector, Rgba};
@@ -119,6 +119,9 @@ pub(crate) struct Window {
     ime_area: Option<DipRect>,
     #[cfg(windows)]
     ime_open: bool,
+
+    cursor: Option<CursorIcon>,
+    cursor_img: Option<CustomCursor>,
 }
 impl fmt::Debug for Window {
     fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
@@ -133,7 +136,8 @@ impl Window {
     #[allow(clippy::too_many_arguments)]
     pub fn open(
         gen: ViewProcessGen,
-        icon: Option<Icon>,
+        cfg_icon: Option<Icon>,
+        cfg_cursor_image: Option<CustomCursor>,
         cfg: WindowRequest,
         winit_loop: &ActiveEventLoop,
         gl_manager: &mut GlContextManager,
@@ -150,7 +154,7 @@ impl Window {
             .with_title(cfg.title)
             .with_resizable(cfg.resizable)
             .with_transparent(cfg.transparent)
-            .with_window_icon(icon);
+            .with_window_icon(cfg_icon);
 
         let mut s = cfg.state;
         s.clamp_size();
@@ -447,6 +451,8 @@ impl Window {
             ime_area: cfg.ime_area,
             #[cfg(windows)]
             ime_open: false,
+            cursor: None,
+            cursor_img: None,
         };
 
         if !cfg.default_position && win.state.state == WindowState::Normal {
@@ -457,7 +463,10 @@ impl Window {
             win.set_always_on_top(true);
         }
 
-        win.set_cursor(cfg.cursor);
+        win.cursor = cfg.cursor;
+        win.cursor_img = cfg_cursor_image;
+        win.update_cursor();
+
         win.set_taskbar_visible(cfg.taskbar_visible);
 
         win.set_enabled_buttons(cfg.enabled_buttons);
@@ -840,13 +849,32 @@ impl Window {
         self.window.set_window_icon(icon);
     }
 
-    /// Set cursor icon and visibility.
+    /// Set named cursor.
     pub fn set_cursor(&mut self, icon: Option<CursorIcon>) {
-        if let Some(icon) = icon {
-            self.window.set_cursor(icon.to_winit());
-            self.window.set_cursor_visible(true);
-        } else {
-            self.window.set_cursor_visible(false);
+        self.cursor = icon;
+        self.update_cursor();
+    }
+
+    /// Set custom cursor.
+    pub fn set_cursor_image(&mut self, img: Option<CustomCursor>) {
+        self.cursor_img = img;
+        self.update_cursor();
+    }
+
+    fn update_cursor(&self) {
+        match (&self.cursor_img, self.cursor) {
+            (Some(i), _) => {
+                self.window.set_cursor(i.clone());
+                self.window.set_cursor_visible(true);
+            }
+            (None, Some(i)) => {
+                self.window.set_cursor(i.to_winit());
+                self.window.set_cursor_visible(true);
+            }
+            (None, None) => {
+                self.window.set_cursor_visible(false);
+                self.window.set_cursor(CursorIcon::Default.to_winit());
+            }
         }
     }
 
diff --git a/crates/zng-wgt-input/src/misc.rs b/crates/zng-wgt-input/src/misc.rs
index cc6a4026e..f78348d63 100644
--- a/crates/zng-wgt-input/src/misc.rs
+++ b/crates/zng-wgt-input/src/misc.rs
@@ -9,25 +9,20 @@ use zng_wgt::prelude::*;
 
 pub use zng_view_api::window::CursorIcon;
 
-pub use zng_ext_window::CursorImg;
+pub use zng_ext_window::{CursorImg, CursorSource};
 
 context_local! {
-    static CHILD_SETS: AtomicBool = AtomicBool::new(false);
+    static CHILD_SETS_CURSOR: AtomicBool = AtomicBool::new(false);
 }
 
-/// Sets the [`CursorIcon`] displayed when hovering the widget.
+/// Sets the mouse pointer cursor displayed when hovering the widget.
 ///
-/// This cursor is only used if [`cursor_img`] is not set on the widget, or the cursor image cannot be shown.
-/// Note that this property clears the `cursor_img` in the context so if a parent has a `cursor_img` if
-/// will be overridden by this cursor, but if the widget also sets `cursor_img` the custom cursor is used instead.
-///
-/// If set to `None` no cursor is shown.
-///
-/// [`cursor_img`]: fn@cursor_img
+/// You can set this property to a [`CursorIcon`] for a named platform dependent icon, [`CursorImg`] for a custom image,
+/// or to `false` that converts to [`CursorSource::Hidden`].
 #[property(CONTEXT, default(CursorIcon::Default))]
-pub fn cursor(child: impl UiNode, cursor: impl IntoVar<Option<CursorIcon>>) -> impl UiNode {
+pub fn cursor(child: impl UiNode, cursor: impl IntoVar<CursorSource>) -> impl UiNode {
     let cursor = cursor.into_var();
-    let mut hovered_binding = None;
+    let mut binding = None;
     let mut child_sets_ctx = None::<Arc<AtomicBool>>;
 
     match_node(child, move |c, op| {
@@ -49,7 +44,7 @@ pub fn cursor(child: impl UiNode, cursor: impl IntoVar<Option<CursorIcon>>) -> i
                         }
 
                         // if a child also sets cursor, it will flag our context.
-                        CHILD_SETS.with_context(&mut child_sets_ctx, || c.event(update));
+                        CHILD_SETS_CURSOR.with_context(&mut child_sets_ctx, || c.event(update));
 
                         if !child_sets_ctx.as_ref().unwrap().swap(false, Ordering::Relaxed) {
                             // no descendant sets cursor, it is ours.
@@ -57,20 +52,13 @@ pub fn cursor(child: impl UiNode, cursor: impl IntoVar<Option<CursorIcon>>) -> i
                         }
 
                         // flag parent context.
-                        CHILD_SETS.get().store(true, Ordering::Relaxed);
+                        CHILD_SETS_CURSOR.get().store(true, Ordering::Relaxed);
                     }
 
                     if bind {
-                        if hovered_binding.is_none() {
+                        if binding.is_none() {
                             // we are not already set, setup binding.
-
-                            let vars = WINDOW.vars();
-
-                            vars.cursor_img().set(None);
-
-                            let c = vars.cursor();
-                            c.set_from(&cursor);
-                            hovered_binding = Some(cursor.bind(&c));
+                            binding = Some(cursor.set_bind(&WINDOW.vars().cursor()));
                         }
                     } else {
                         unbind_restore = true;
@@ -81,66 +69,18 @@ pub fn cursor(child: impl UiNode, cursor: impl IntoVar<Option<CursorIcon>>) -> i
         }
 
         // restore to default, if not set to other value already
-        if unbind_restore && hovered_binding.is_some() {
-            hovered_binding = None;
+        if unbind_restore && binding.is_some() {
+            binding = None;
             let value = cursor.get();
             WINDOW.vars().cursor().modify(move |c| {
                 if c.as_ref() == &value {
-                    *c.to_mut() = Some(CursorIcon::Default);
+                    *c.to_mut() = CursorIcon::Default.into();
                 }
             });
         }
     })
 }
 
-/// Sets the custom [`CursorImg`] displayed when hovering the widget.
-///
-/// If set to `None`, or when the cursor image cannot be shown the [`cursor`] value is used as fallback.
-///
-/// [`cursor`]: fn@cursor
-#[property(CONTEXT+1, default(None))]
-pub fn cursor_img(child: impl UiNode, img: impl IntoVar<Option<CursorImg>>) -> impl UiNode {
-    let img = img.into_var();
-    let mut hovered_binding = None;
-
-    match_node(child, move |_, op| match op {
-        UiNodeOp::Init => {
-            WIDGET.sub_event(&MOUSE_HOVERED_EVENT);
-        }
-        UiNodeOp::Deinit => {
-            hovered_binding = None;
-        }
-        UiNodeOp::Event { update } => {
-            if let Some(args) = MOUSE_HOVERED_EVENT.on(update) {
-                let is_over = args.target.as_ref().map(|t| t.as_path().contains(WIDGET.id())).unwrap_or(false);
-                if is_over {
-                    if hovered_binding.is_none() {
-                        // we are not already set, setup binding.
-
-                        let vars = WINDOW.vars();
-
-                        let c = vars.cursor_img();
-                        c.set_from(&img);
-                        hovered_binding = Some(img.bind(&c));
-                    }
-                } else {
-                    // restore to default, if not set to other value already
-                    if hovered_binding.is_some() {
-                        hovered_binding = None;
-                        let value = img.get();
-                        WINDOW.vars().cursor_img().modify(move |c| {
-                            if c.as_ref() == &value {
-                                *c.to_mut() = None;
-                            }
-                        });
-                    }
-                }
-            }
-        }
-        _ => {}
-    })
-}
-
 /// Defines how click events are generated for the widget.
 ///
 /// Setting this to `None` will cause the widget to inherit the parent mode, or [`ClickMode::default`] if
diff --git a/crates/zng-wgt-inspector/src/live.rs b/crates/zng-wgt-inspector/src/live.rs
index e7fa9ba1c..d5b5a004c 100644
--- a/crates/zng-wgt-inspector/src/live.rs
+++ b/crates/zng-wgt-inspector/src/live.rs
@@ -8,6 +8,7 @@ use zng_ext_input::{
 use zng_ext_window::{WINDOW_Ext as _, WINDOWS};
 use zng_view_api::window::CursorIcon;
 use zng_wgt::prelude::*;
+use zng_wgt_input::CursorSource;
 
 use crate::INSPECT_CMD;
 
@@ -184,13 +185,14 @@ fn select_on_click(child: impl UiNode, hit_select: impl Var<HitSelect>) -> impl
                     let cursor = WINDOW.vars().cursor();
 
                     // set cursor to Crosshair and lock it in by resetting on a hook.
-                    cursor.set(CursorIcon::Crosshair);
+                    let locked_cur = CursorSource::Icon(CursorIcon::Crosshair);
+                    cursor.set(locked_cur.clone());
                     let weak_cursor = cursor.downgrade();
                     _cursor_handle = cursor.hook(move |a| {
-                        let icon = *a.value();
-                        if icon != Some(CursorIcon::Crosshair) {
+                        let icon = a.value();
+                        if icon != &locked_cur {
                             let cursor = weak_cursor.upgrade().unwrap();
-                            cursor.set(CursorIcon::Crosshair);
+                            cursor.set(locked_cur.clone());
                         }
                         true
                     });
diff --git a/crates/zng-wgt-layer/src/lib.rs b/crates/zng-wgt-layer/src/lib.rs
index f17a879dd..e7c93e012 100644
--- a/crates/zng-wgt-layer/src/lib.rs
+++ b/crates/zng-wgt-layer/src/lib.rs
@@ -410,7 +410,7 @@ impl LAYERS {
                                         if let Some((img, spot)) = vars.actual_cursor_img().get() {
                                             (img.size(), spot)
                                         } else {
-                                            vars.cursor().get().map(|c| c.size_and_spot(fct)).unwrap_or_default()
+                                            vars.cursor().with(|s| s.icon()).map(|i| i.size_and_spot(fct)).unwrap_or_default()
                                         }
                                     };
                                     let cursor_rect = PxRect::new((pos - cursor_spot).to_point(), cursor_size);
diff --git a/crates/zng/src/mouse.rs b/crates/zng/src/mouse.rs
index 6c8025031..a37f76593 100644
--- a/crates/zng/src/mouse.rs
+++ b/crates/zng/src/mouse.rs
@@ -54,7 +54,7 @@ pub use zng_wgt_input::mouse::{
     on_pre_mouse_wheel, on_pre_mouse_zoom,
 };
 
-pub use zng_wgt_input::{click_mode, cursor, cursor_img, is_cap_mouse_pressed, is_mouse_pressed, CursorIcon, CursorImg};
+pub use zng_wgt_input::{click_mode, cursor, is_cap_mouse_pressed, is_mouse_pressed, CursorIcon, CursorImg, CursorSource};
 
 /// Raw mouse hardware events, received independent of what window is under the pointer.
 ///
diff --git a/examples/cursor.rs b/examples/cursor.rs
index 6619b484b..4d62c953f 100644
--- a/examples/cursor.rs
+++ b/examples/cursor.rs
@@ -10,15 +10,15 @@ use zng::{
     prelude_wgt::NilUiNode,
 };
 
-use zng::view_process::prebuilt as view_process;
+use zng::view_process::default as view_process;
 
 fn main() {
     examples_util::print_info();
-    view_process::init();
+    // view_process::init();
     zng::app::crash_handler::init_debug();
 
-    // view_process::run_same_process(app_main);
-    app_main();
+    view_process::run_same_process(app_main);
+    // app_main();
 }
 
 fn app_main() {
@@ -51,7 +51,10 @@ fn app_main() {
 
 fn cursor_demo(icon: Option<(CursorIcon, &'static [u8])>) -> impl UiNode {
     widgets::DemoEntry! {
-        mouse::cursor = icon.map(|i| i.0);
+        mouse::cursor = match icon {
+            Some(i) => i.0.into(),
+            None => mouse::CursorSource::Hidden
+        };
 
         widget::background = match icon {
             Some((_, img)) => Image!{
@@ -78,10 +81,10 @@ fn cursor_demo(icon: Option<(CursorIcon, &'static [u8])>) -> impl UiNode {
 
 fn cursor_img_demo(label: &'static str, img: &'static [u8], hotspot: (i32, i32), fallback: CursorIcon) -> impl UiNode {
     widgets::DemoEntry! {
-        mouse::cursor = fallback;
-        mouse::cursor_img = mouse::CursorImg {
+        mouse::cursor = mouse::CursorImg {
             source: img.into(),
             hotspot: hotspot.into(),
+            fallback,
         };
 
         widget::background = Image! {
diff --git a/examples/window.rs b/examples/window.rs
index 77bbd4f2d..a39b91dd9 100644
--- a/examples/window.rs
+++ b/examples/window.rs
@@ -495,7 +495,7 @@ fn custom_chrome(title: impl Var<Txt>) -> impl UiNode {
         }
     }
 
-    let cursor = var(None::<mouse::CursorIcon>);
+    let cursor = var(mouse::CursorSource::Hidden);
 
     Container! {
         visibility = expr_var!((#{vars.state()}.is_fullscreen() || !*#{vars.chrome()}).into());
@@ -507,7 +507,10 @@ fn custom_chrome(title: impl Var<Txt>) -> impl UiNode {
             widget::border = 5, color_scheme_map(colors::BLACK, colors::WHITE);
             mouse::cursor = cursor.clone();
             mouse::on_mouse_move = hn!(|args: &mouse::MouseMoveArgs| {
-                cursor.set(args.position_wgt().and_then(resize_direction).map(mouse::CursorIcon::from));
+                cursor.set(match args.position_wgt().and_then(resize_direction) {
+                    Some(d) => mouse::CursorIcon::from(d).into(),
+                    None => mouse::CursorSource::Hidden,
+                });
             });
             mouse::on_mouse_down = hn!(|args: &mouse::MouseInputArgs| {
                 if args.is_primary() {


Test patch:
diff --git a/tests/macro-tests/cases/widget_new/missing_value3.stderr b/tests/macro-tests/cases/widget_new/missing_value3.stderr
index 23dedc7a5..a45c2200e 100644
--- a/tests/macro-tests/cases/widget_new/missing_value3.stderr
+++ b/tests/macro-tests/cases/widget_new/missing_value3.stderr
@@ -14,7 +14,7 @@ error[E0061]: this method takes 1 argument but 0 arguments were supplied
 note: method defined here
   --> $WORKSPACE/crates/zng-wgt-input/src/misc.rs
    |
-   | pub fn cursor(child: impl UiNode, cursor: impl IntoVar<Option<CursorIcon>>) -> impl UiNode {
+   | pub fn cursor(child: impl UiNode, cursor: impl IntoVar<CursorSource>) -> impl UiNode {
    |        ^^^^^^
 help: provide the argument
   --> $WORKSPACE/crates/zng-wgt/src/wgt.rs
diff --git a/tests/macro-tests/cases/widget_new/missing_value4.stderr b/tests/macro-tests/cases/widget_new/missing_value4.stderr
index 5f43293ad..a7a91ed5b 100644
--- a/tests/macro-tests/cases/widget_new/missing_value4.stderr
+++ b/tests/macro-tests/cases/widget_new/missing_value4.stderr
@@ -20,7 +20,7 @@ error[E0061]: this method takes 1 argument but 0 arguments were supplied
 note: method defined here
   --> $WORKSPACE/crates/zng-wgt-input/src/misc.rs
    |
-   | pub fn cursor(child: impl UiNode, cursor: impl IntoVar<Option<CursorIcon>>) -> impl UiNode {
+   | pub fn cursor(child: impl UiNode, cursor: impl IntoVar<CursorSource>) -> impl UiNode {
    |        ^^^^^^
 help: provide the argument
   --> $WORKSPACE/crates/zng-wgt/src/wgt.rs