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

Issue:
Implement window "drag regions"
That is a property that moves or resizes the window when the widget is dragged.

The drag operation is managed by the OS, need to add a view API. Winit provides the [`drag_window`] and [`drag_resize_window`] methods.


[`drag_window`]: https://docs.rs/winit/latest/winit/window/struct.Window.html#method.drag_window
[`drag_resize_window`]: https://docs.rs/winit/latest/winit/window/struct.Window.html#method.drag_resize_window


Discussion / hints:

Fix patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index cf6d9a3b3..8a53aa25a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,7 +1,13 @@
 # Unpublished
 
-* **Breaking** Add `open_title_bar_context_menu` to view API.
+* Add `OPEN_TITLE_BAR_CONTEXT_MENU_CMD` for windows.
+* Add `DRAG_MOVE_RESIZE_CMD` for windows.
+* **Breaking** View API changes:
+    - Add `open_title_bar_context_menu`.
+    - Rename `close_window` to `close`.
+    - Rename `focus_window` to `focus`.
     - Note that this is only a breaking change for direct dependents of `zng-view-api`.
+* Better "custom chrome" example in `examples/window.rs`.
 * Add `OPEN_TITLE_BAR_CONTEXT_MENU_CMD` to window API.
 * Fix `WIDGET.border().offsets()` not including the innermost border offset.
 
diff --git a/crates/zng-app/src/view_process.rs b/crates/zng-app/src/view_process.rs
index e07f359cb..0e378615d 100644
--- a/crates/zng-app/src/view_process.rs
+++ b/crates/zng-app/src/view_process.rs
@@ -31,7 +31,7 @@ use zng_view_api::{
     ipc::{IpcBytes, IpcBytesReceiver},
     window::{
         CursorIcon, FocusIndicator, FrameRequest, FrameUpdateRequest, HeadlessOpenData, HeadlessRequest, MonitorInfo, RenderMode,
-        VideoMode, WindowRequest, WindowStateAll,
+        ResizeDirection, VideoMode, WindowRequest, WindowStateAll,
     },
     Event, ViewProcessGen, ViewProcessOffline,
 };
@@ -768,7 +768,7 @@ impl ViewWindow {
     ///
     /// This request can steal focus from other apps disrupting the user, be careful with it.
     pub fn focus(&self) -> Result<()> {
-        self.0.call(|id, p| p.focus_window(id))
+        self.0.call(|id, p| p.focus(id))
     }
 
     /// Sets the user attention request indicator, the indicator is cleared when the window is focused or
@@ -777,6 +777,20 @@ impl ViewWindow {
         self.0.call(|id, p| p.set_focus_indicator(id, indicator))
     }
 
+    /// Moves the window with the left mouse button until the button is released.
+    ///
+    /// There's no guarantee that this will work unless the left mouse button was pressed immediately before this function is called.
+    pub fn drag_move(&self) -> Result<()> {
+        self.0.call(|id, p| p.drag_move(id))
+    }
+
+    /// Resizes the window with the left mouse button until the button is released.
+    ///
+    /// There's no guarantee that this will work unless the left mouse button was pressed immediately before this function is called.
+    pub fn drag_resize(&self, direction: ResizeDirection) -> Result<()> {
+        self.0.call(|id, p| p.drag_resize(id, direction))
+    }
+
     /// Open system title bar context menu.
     pub fn open_title_bar_context_menu(&self, position: DipPoint) -> Result<()> {
         self.0.call(|id, p| p.open_title_bar_context_menu(id, position))
@@ -839,7 +853,7 @@ impl Drop for ViewWindowData {
         if VIEW_PROCESS.is_available() {
             let mut app = VIEW_PROCESS.handle_write(self.app_id);
             if self.generation == app.process.generation() {
-                let _ = app.process.close_window(self.id);
+                let _ = app.process.close(self.id);
             }
         }
     }
diff --git a/crates/zng-ext-window/src/cmd.rs b/crates/zng-ext-window/src/cmd.rs
index 354be9321..4338bfd28 100644
--- a/crates/zng-ext-window/src/cmd.rs
+++ b/crates/zng-ext-window/src/cmd.rs
@@ -11,6 +11,8 @@ use zng_view_api::window::WindowState;
 
 use crate::{WindowVars, WINDOWS};
 
+pub use zng_view_api::window::ResizeDirection;
+
 command! {
     /// Represents the window **close** action.
     pub static CLOSE_CMD = {
@@ -74,6 +76,16 @@ command! {
     /// If any IME preview is active close it without committing.
     pub static CANCEL_IME_CMD;
 
+    /// Represents the window **drag-move** and **drag-resize** actions.
+    ///
+    /// There's no guarantee that this will work unless the left mouse button was pressed immediately before this command is called.
+    ///
+    /// # Parameter
+    ///
+    /// If this command is called without parameter the window will drag-move, if it is called with a [`ResizeDirection`] the
+    /// window will drag-resize.
+    pub static DRAG_MOVE_RESIZE_CMD;
+
     /// Represents the window **open title bar context menu** action.
     ///
     /// # Parameter
diff --git a/crates/zng-ext-window/src/control.rs b/crates/zng-ext-window/src/control.rs
index 96bbf6fae..6b88bf108 100644
--- a/crates/zng-ext-window/src/control.rs
+++ b/crates/zng-ext-window/src/control.rs
@@ -107,6 +107,7 @@ struct HeadedCtrl {
     ime_info: Option<ImeInfo>,
     cancel_ime_handle: CommandHandle,
     open_title_menu_handle: CommandHandle,
+    drag_move_handle: CommandHandle,
 }
 impl HeadedCtrl {
     pub fn new(vars: &WindowVars, commands: WindowCommands, content: WindowRoot) -> Self {
@@ -138,6 +139,7 @@ impl HeadedCtrl {
             ime_info: None,
             cancel_ime_handle: CommandHandle::dummy(),
             open_title_menu_handle: CommandHandle::dummy(),
+            drag_move_handle: CommandHandle::dummy(),
         }
     }
 
@@ -674,6 +676,7 @@ impl HeadedCtrl {
                 self.window = Some(args.window.clone());
                 self.cancel_ime_handle = super::cmd::CANCEL_IME_CMD.scoped(WINDOW.id()).subscribe(true);
                 self.open_title_menu_handle = super::cmd::OPEN_TITLE_BAR_CONTEXT_MENU_CMD.scoped(WINDOW.id()).subscribe(true);
+                self.drag_move_handle = super::cmd::DRAG_MOVE_RESIZE_CMD.scoped(WINDOW.id()).subscribe(true);
 
                 self.vars.0.render_mode.set(args.data.render_mode);
                 self.vars.state().set(args.data.state.state);
@@ -772,6 +775,7 @@ impl HeadedCtrl {
                     self.window = None;
                     self.cancel_ime_handle = CommandHandle::dummy();
                     self.open_title_menu_handle = CommandHandle::dummy();
+                    self.drag_move_handle = CommandHandle::dummy();
                     self.waiting_view = false;
                     self.delayed_view_updates = vec![];
                     self.respawned = true;
@@ -793,6 +797,17 @@ impl HeadedCtrl {
             if let Some(w) = &self.window {
                 let _ = w.set_ime_area(None);
             }
+        } else if let Some(args) = super::cmd::DRAG_MOVE_RESIZE_CMD.scoped(WINDOW.id()).on(update) {
+            let r = args.handle_enabled(&self.drag_move_handle, |args| args.param::<crate::cmd::ResizeDirection>().copied());
+            if let Some(r) = r {
+                self.view_task(Box::new(move |w| {
+                    let _ = if let Some(r) = r {
+                        w.unwrap().drag_resize(r)
+                    } else {
+                        w.unwrap().drag_move()
+                    };
+                }));
+            }
         } else if let Some(args) = super::cmd::OPEN_TITLE_BAR_CONTEXT_MENU_CMD.scoped(WINDOW.id()).on(update) {
             let pos = args.handle_enabled(&self.open_title_menu_handle, |args| {
                 let pos = if let Some(p) = args.param::<DipPoint>() {
diff --git a/crates/zng-view-api/src/lib.rs b/crates/zng-view-api/src/lib.rs
index cbcae8b27..559840751 100644
--- a/crates/zng-view-api/src/lib.rs
+++ b/crates/zng-view-api/src/lib.rs
@@ -287,7 +287,7 @@ declare_api! {
     /// Close the window or headless surface.
     ///
     /// All documents associated with the window or surface are also closed.
-    pub fn close_window(&mut self, id: WindowId);
+    pub fn close(&mut self, id: WindowId);
 
     /// Set window title.
     pub fn set_title(&mut self, id: WindowId, title: Txt);
@@ -337,7 +337,17 @@ declare_api! {
     /// window is not visible, minimized or already focused.
     ///
     /// This request can steal focus from other apps disrupting the user, be careful with it.
-    pub fn focus_window(&mut self, id: WindowId);
+    pub fn focus(&mut self, id: WindowId);
+
+    /// Moves the window with the left mouse button until the button is released.
+    ///
+    /// There's no guarantee that this will work unless the left mouse button was pressed immediately before this function is called.
+    pub fn drag_move(&mut self, id: WindowId);
+
+    /// Resizes the window with the left mouse button until the button is released.
+    ///
+    /// There's no guarantee that this will work unless the left mouse button was pressed immediately before this function is called.
+    pub fn drag_resize(&mut self, id: WindowId, direction: window::ResizeDirection);
 
     /// Open the system title bar context menu.
     pub fn open_title_bar_context_menu(&mut self, id: WindowId, position: DipPoint);
diff --git a/crates/zng-view-api/src/window.rs b/crates/zng-view-api/src/window.rs
index 318bba1c7..680a91305 100644
--- a/crates/zng-view-api/src/window.rs
+++ b/crates/zng-view-api/src/window.rs
@@ -657,12 +657,10 @@ pub enum CursorIcon {
     /// magnifying glass with a "-" in the center of the glass.
     ZoomOut,
 }
-
 #[cfg(feature = "var")]
 zng_var::impl_from_and_into_var! {
     fn from(some: CursorIcon) -> Option<CursorIcon>;
 }
-
 impl CursorIcon {
     /// All cursor icons.
     pub const ALL: &'static [CursorIcon] = {
@@ -747,6 +745,66 @@ pub struct CursorImage {
     pub hotspot: PxPoint,
 }
 
+/// Defines the orientation that a window resize will be performed.
+#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
+pub enum ResizeDirection {
+    /// The east border will be moved.
+    East,
+    /// The north border will be moved.
+    North,
+    /// The north-east corner will be moved.
+    NorthEast,
+    /// The north-west corner will be moved.
+    NorthWest,
+    /// The south border will be moved.
+    South,
+    /// The south-east corner will be moved.
+    SouthEast,
+    /// The south-west corner will be moved.
+    SouthWest,
+    /// The west border will be moved.
+    West,
+}
+impl From<ResizeDirection> for CursorIcon {
+    fn from(direction: ResizeDirection) -> Self {
+        use ResizeDirection::*;
+        match direction {
+            East => CursorIcon::EResize,
+            North => CursorIcon::NResize,
+            NorthEast => CursorIcon::NeResize,
+            NorthWest => CursorIcon::NwResize,
+            South => CursorIcon::SResize,
+            SouthEast => CursorIcon::SeResize,
+            SouthWest => CursorIcon::SwResize,
+            West => CursorIcon::WResize,
+        }
+    }
+}
+#[cfg(feature = "var")]
+zng_var::impl_from_and_into_var! {
+    fn from(some: ResizeDirection) -> Option<ResizeDirection>;
+    fn from(some: ResizeDirection) -> Option<CursorIcon> {
+        Some(some.into())
+    }
+}
+impl ResizeDirection {
+    /// All directions.
+    pub const ALL: &'static [ResizeDirection] = {
+        use ResizeDirection::*;
+        &[East, North, NorthEast, NorthWest, South, SouthEast, SouthWest, West]
+    };
+
+    /// Gets if this resize represents two directions.
+    pub const fn is_corner(self) -> bool {
+        matches!(self, Self::NorthEast | Self::NorthWest | Self::SouthEast | Self::SouthWest)
+    }
+
+    /// Gets if this resize represents a single direction.
+    pub const fn is_border(self) -> bool {
+        !self.is_corner()
+    }
+}
+
 /// Window state.
 #[derive(Debug, PartialEq, Eq, Clone, Copy, Serialize, Deserialize, Default)]
 pub enum WindowState {
diff --git a/crates/zng-view/src/lib.rs b/crates/zng-view/src/lib.rs
index 81c6fcbdf..23e157893 100644
--- a/crates/zng-view/src/lib.rs
+++ b/crates/zng-view/src/lib.rs
@@ -1464,7 +1464,7 @@ impl Api for App {
     }
 
     fn open_window(&mut self, mut config: WindowRequest) {
-        let _s = tracing::debug_span!("open_window", ?config).entered();
+        let _s = tracing::debug_span!("open", ?config).entered();
 
         config.state.clamp_size();
         config.enforce_kiosk();
@@ -1537,7 +1537,7 @@ impl Api for App {
         self.notify(Event::HeadlessOpened(id, msg));
     }
 
-    fn close_window(&mut self, id: WindowId) {
+    fn close(&mut self, id: WindowId) {
         let _s = tracing::debug_span!("close_window", ?id);
 
         self.assert_started();
@@ -1614,7 +1614,7 @@ impl Api for App {
         self.with_window(id, |w| w.set_focus_request(request), || ())
     }
 
-    fn focus_window(&mut self, id: WindowId) {
+    fn focus(&mut self, id: WindowId) {
         #[cfg(windows)]
         {
             self.skip_ralt = self.with_window(id, |w| w.focus(), || false);
@@ -1626,6 +1626,14 @@ impl Api for App {
         }
     }
 
+    fn drag_move(&mut self, id: WindowId) {
+        self.with_window(id, |w| w.drag_move(), || ())
+    }
+
+    fn drag_resize(&mut self, id: WindowId, direction: zng_view_api::window::ResizeDirection) {
+        self.with_window(id, |w| w.drag_resize(direction), || ())
+    }
+
     fn open_title_bar_context_menu(&mut self, id: WindowId, position: DipPoint) {
         self.with_window(id, |w| w.open_title_bar_context_menu(position), || ())
     }
diff --git a/crates/zng-view/src/util.rs b/crates/zng-view/src/util.rs
index 3fac7dc29..230e87b39 100644
--- a/crates/zng-view/src/util.rs
+++ b/crates/zng-view/src/util.rs
@@ -12,7 +12,7 @@ use zng_unit::*;
 use zng_view_api::access::AccessNodeId;
 use zng_view_api::clipboard as clipboard_api;
 use zng_view_api::keyboard::NativeKeyCode;
-use zng_view_api::window::{FrameCapture, FrameRequest, FrameUpdateRequest};
+use zng_view_api::window::{FrameCapture, FrameRequest, FrameUpdateRequest, ResizeDirection};
 use zng_view_api::{
     config::ColorScheme,
     keyboard::{Key, KeyCode, KeyState},
@@ -207,6 +207,24 @@ impl WinitToPx for winit::dpi::PhysicalSize<u32> {
         PxSize::new(Px(self.width as i32), Px(self.height as i32))
     }
 }
+pub trait ResizeDirectionToWinit {
+    fn to_winit(self) -> winit::window::ResizeDirection;
+}
+impl ResizeDirectionToWinit for ResizeDirection {
+    fn to_winit(self) -> winit::window::ResizeDirection {
+        use winit::window::ResizeDirection::*;
+        match self {
+            ResizeDirection::East => East,
+            ResizeDirection::North => North,
+            ResizeDirection::NorthEast => NorthEast,
+            ResizeDirection::NorthWest => NorthWest,
+            ResizeDirection::South => South,
+            ResizeDirection::SouthEast => SouthEast,
+            ResizeDirection::SouthWest => SouthWest,
+            ResizeDirection::West => West,
+        }
+    }
+}
 
 pub trait CursorToWinit {
     fn to_winit(self) -> winit::window::CursorIcon;
diff --git a/crates/zng-view/src/window.rs b/crates/zng-view/src/window.rs
index 7e3cb08d9..71ed22a05 100644
--- a/crates/zng-view/src/window.rs
+++ b/crates/zng-view/src/window.rs
@@ -29,8 +29,8 @@ use zng_view_api::{
     font::{FontFaceId, FontId, FontOptions, FontVariationName},
     image::{ImageId, ImageLoadedData, ImageMaskMode, ImageTextureId},
     window::{
-        CursorIcon, FocusIndicator, FrameCapture, FrameId, FrameRequest, FrameUpdateRequest, RenderMode, VideoMode, WindowId,
-        WindowRequest, WindowState, WindowStateAll,
+        CursorIcon, FocusIndicator, FrameCapture, FrameId, FrameRequest, FrameUpdateRequest, RenderMode, ResizeDirection, VideoMode,
+        WindowId, WindowRequest, WindowState, WindowStateAll,
     },
     DeviceId, Event, ViewProcessGen,
 };
@@ -49,7 +49,10 @@ use crate::{
     gl::{GlContext, GlContextManager},
     image_cache::{Image, ImageCache, ImageUseMap, WrImageCache},
     px_wr::PxToWr as _,
-    util::{frame_render_reasons, frame_update_render_reasons, CursorToWinit, DipToWinit, PxToWinit, WinitToDip, WinitToPx},
+    util::{
+        frame_render_reasons, frame_update_render_reasons, CursorToWinit, DipToWinit, PxToWinit, ResizeDirectionToWinit as _, WinitToDip,
+        WinitToPx,
+    },
     AppEvent, AppEventSender, FrameReadyMsg, WrNotifier,
 };
 
@@ -1098,6 +1101,20 @@ impl Window {
         true
     }
 
+    /// Moves the window with the left mouse button until the button is released.
+    pub fn drag_move(&self) {
+        if let Err(e) = self.window.drag_window() {
+            tracing::error!("failed to drag_move, {e}");
+        }
+    }
+
+    /// Resizes the window with the left mouse button until the button is released.
+    pub fn drag_resize(&self, direction: ResizeDirection) {
+        if let Err(e) = self.window.drag_resize_window(direction.to_winit()) {
+            tracing::error!("failed to drag_resize, {e}");
+        }
+    }
+
     /// Open windows title bar context menu.
     pub fn open_title_bar_context_menu(&self, pos: DipPoint) {
         self.window.show_window_menu(pos.to_winit())
diff --git a/examples/window.rs b/examples/window.rs
index 827198f6e..b1bb8a91a 100644
--- a/examples/window.rs
+++ b/examples/window.rs
@@ -59,23 +59,9 @@ async fn main_window() -> window::WindowRoot {
         move |p: &DipPoint, s: &DipSize, f: &Factor| { formatx!("Window Example - position: {p:.0?}, size: {s:.0?}, factor: {f:?}") }
     );
 
-    let background = var(colors::BLACK);
-
-    let custom_chrome = Text! {
-        visibility = expr_var!((#{window_vars.state()}.is_fullscreen() || !*#{window_vars.chrome()}).into());
-        txt = title.clone();
-        align = Align::TOP;
-        background_color = color_scheme_map(colors::BLACK, colors::WHITE);
-        padding = 4;
-        corner_radius = (0, 0, 5, 5);
-        gesture::on_context_click = hn!(|args: &gesture::ClickArgs| {
-            if let Some(p) = args.position() {
-                window::cmd::OPEN_TITLE_BAR_CONTEXT_MENU_CMD.scoped(WINDOW.id()).notify_param(p);
-            }
-        });
-    };
-    LAYERS.insert(LayerIndex::TOP_MOST, custom_chrome);
+    LAYERS.insert(LayerIndex::TOP_MOST, custom_chrome(title.clone()));
 
+    let background = var(colors::BLACK);
     Window! {
         background_color = background.easing(150.ms(), easing::linear);
         clear_color = rgba(0, 0, 0, 0);
@@ -435,6 +421,105 @@ fn visibility_example() -> impl UiNode {
     section("Visibility", ui_vec![btn, chrome])
 }
 
+fn custom_chrome(title: impl Var<Txt>) -> impl UiNode {
+    let vars = WINDOW.vars();
+
+    let can_move = vars.state().map(|s| matches!(s, WindowState::Normal | WindowState::Maximized));
+    let title = Text! {
+        txt = title.clone();
+        align = Align::TOP;
+        background_color = color_scheme_map(colors::BLACK, colors::WHITE);
+        padding = 4;
+        corner_radius = (0, 0, 5, 5);
+
+        mouse::on_mouse_down = hn!(can_move, |args: &mouse::MouseInputArgs| {
+            if args.is_primary() && can_move.get() {
+                window::cmd::DRAG_MOVE_RESIZE_CMD.scoped(WINDOW.id()).notify();
+            }
+        });
+        when *#{can_move} {
+            mouse::cursor = mouse::CursorIcon::Move;
+        }
+
+        gesture::on_context_click = hn!(|args: &gesture::ClickArgs| {
+            if matches!(WINDOW.vars().state().get(), WindowState::Normal | WindowState::Maximized) {
+                if let Some(p) = args.position() {
+                    window::cmd::OPEN_TITLE_BAR_CONTEXT_MENU_CMD.scoped(WINDOW.id()).notify_param(p);
+                }
+            }
+        });
+    };
+
+    use window::cmd::ResizeDirection as RD;
+
+    fn resize_direction(wgt_pos: PxPoint) -> Option<RD> {
+        let p = wgt_pos;
+        let s = WIDGET.bounds().inner_size();
+        let b = WIDGET.border().offsets();
+        let corner_b = b * FactorSideOffsets::from(3.fct());
+
+        if p.x <= b.left {
+            if p.y <= corner_b.top {
+                Some(RD::NorthWest)
+            } else if p.y >= s.height - corner_b.bottom {
+                Some(RD::SouthWest)
+            } else {
+                Some(RD::West)
+            }
+        } else if p.x >= s.width - b.right {
+            if p.y <= corner_b.top {
+                Some(RD::NorthEast)
+            } else if p.y >= s.height - corner_b.bottom {
+                Some(RD::SouthEast)
+            } else {
+                Some(RD::East)
+            }
+        } else if p.y <= b.top {
+            if p.x <= corner_b.left {
+                Some(RD::NorthWest)
+            } else if p.x >= s.width - corner_b.right {
+                Some(RD::NorthEast)
+            } else {
+                Some(RD::North)
+            }
+        } else if p.y >= s.height - b.bottom {
+            if p.x <= corner_b.left {
+                Some(RD::SouthWest)
+            } else if p.x >= s.width - corner_b.right {
+                Some(RD::SouthEast)
+            } else {
+                Some(RD::South)
+            }
+        } else {
+            None
+        }
+    }
+
+    let cursor = var(None::<mouse::CursorIcon>);
+
+    Container! {
+        visibility = expr_var!((#{vars.state()}.is_fullscreen() || !*#{vars.chrome()}).into());
+        widget::hit_test_mode = widget::HitTestMode::Detailed;
+
+        child = title;
+
+        when matches!(#{vars.state()}, WindowState::Normal) {
+            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));
+            });
+            mouse::on_mouse_down = hn!(|args: &mouse::MouseInputArgs| {
+                if args.is_primary() {
+                    if let Some(d) = args.position_wgt().and_then(resize_direction) {
+                        window::cmd::DRAG_MOVE_RESIZE_CMD.scoped(WINDOW.id()).notify_param(d);
+                    }
+                }
+            });
+        }
+    }
+}
+
 fn misc() -> impl UiNode {
     let window_vars = WINDOW.vars();
     let window_id = WINDOW.id();


Test patch: