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

Issue:
Implement scroll to fit/fill
We already have scroll_to with scale change, should be easy to calculate the fit/fill scale and offsets.


Discussion / hints:

Fix patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 61434466d..ee3bb1b77 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,8 @@
 * Add `SCROLL.context_values_set`.
 * Fix `WidgetInfo::new_interaction_path` always detecting change.
 * Improve iterators in `InteractionPath`, `interaction_path` and `zip` are now double ended and exact sized.
+* Add `ZOOM_TO_FIT_CMD`.
+    - The `CTRL+'0'` shortcut is now used for this command, not `ZOOM_RESET_CMD`.
 
 # 0.5.0
 
diff --git a/crates/zng-wgt-scroll/src/cmd.rs b/crates/zng-wgt-scroll/src/cmd.rs
index b3d6641b9..082302972 100644
--- a/crates/zng-wgt-scroll/src/cmd.rs
+++ b/crates/zng-wgt-scroll/src/cmd.rs
@@ -214,10 +214,20 @@ command! {
         shortcut_filter: ShortcutFilter::FOCUSED | ShortcutFilter::CMD_ENABLED,
     };
 
+    /// Represents the **zoom to fit** action.
+    ///
+    /// The content is scaled to fit the viewport, the equivalent to `ImageFit::Contain`.
+    pub static ZOOM_TO_FIT_CMD = {
+        name: "Zoom to Fit",
+        shortcut: shortcut!(CTRL+'0'),
+        shortcut_filter: ShortcutFilter::FOCUSED | ShortcutFilter::CMD_ENABLED,
+    };
+
     /// Represents the **reset zoom** action.
+    ///
+    /// The content is scaled back to 100%, without adjusting the scroll.
     pub static ZOOM_RESET_CMD = {
         name: "Reset Zoom",
-        shortcut: shortcut!(CTRL+'0'),
         shortcut_filter: ShortcutFilter::FOCUSED | ShortcutFilter::CMD_ENABLED,
     };
 
diff --git a/crates/zng-wgt-scroll/src/lib.rs b/crates/zng-wgt-scroll/src/lib.rs
index db8df268c..844181c51 100644
--- a/crates/zng-wgt-scroll/src/lib.rs
+++ b/crates/zng-wgt-scroll/src/lib.rs
@@ -37,7 +37,7 @@ use zng_ext_input::focus::FocusScopeOnFocus;
 use zng_wgt_container::{child_align, Container};
 use zng_wgt_input::focus::{focus_scope, focus_scope_behavior};
 
-/// A single content container that can be larger on the inside.
+/// A container that can pan and zoom a child of any size.
 ///
 /// # Shorthand
 ///
diff --git a/crates/zng-wgt-scroll/src/node.rs b/crates/zng-wgt-scroll/src/node.rs
index 2a4cc7c96..1ab7125f0 100644
--- a/crates/zng-wgt-scroll/src/node.rs
+++ b/crates/zng-wgt-scroll/src/node.rs
@@ -242,7 +242,7 @@ pub fn viewport(child: impl UiNode, mode: impl IntoVar<ScrollMode>, child_align:
 
             *final_size = viewport_size;
 
-            scroll_info().set_content(PxRect::new(content_offset.to_point(), content_size));
+            scroll_info().set_content(PxRect::new(content_offset.to_point(), content_size), content_scale);
         }
         UiNodeOp::Render { frame } => {
             scroll_info().set_viewport_transform(*frame.transform());
@@ -560,29 +560,39 @@ pub fn scroll_to_edge_commands_node(child: impl UiNode) -> impl UiNode {
     })
 }
 
-/// Create a node that implements [`ZOOM_IN_CMD`], [`ZOOM_OUT_CMD`],
+/// Create a node that implements [`ZOOM_IN_CMD`], [`ZOOM_OUT_CMD`], [`ZOOM_TO_FIT_CMD`],
 /// and [`ZOOM_RESET_CMD`] scoped on the widget.
 pub fn zoom_commands_node(child: impl UiNode) -> impl UiNode {
     let mut zoom_in = CommandHandle::dummy();
     let mut zoom_out = CommandHandle::dummy();
+    let mut zoom_to_fit = CommandHandle::dummy();
     let mut zoom_reset = CommandHandle::dummy();
 
     let mut scale_delta = 0.fct();
     let mut origin = Point::default();
 
+    fn fit_scale() -> Factor {
+        let scroll = WIDGET.info().scroll_info().unwrap();
+        let viewport = (scroll.viewport_size() + scroll.joiner_size()).to_f32(); // viewport without scrollbars
+        let content = scroll.content().size.to_f32() / scroll.zoom_scale();
+        (viewport.width / content.width).min(viewport.height / content.height).fct()
+    }
+
     match_node(child, move |child, op| match op {
         UiNodeOp::Init => {
             let scope = WIDGET.id();
 
             zoom_in = ZOOM_IN_CMD.scoped(scope).subscribe(SCROLL.can_zoom_in());
             zoom_out = ZOOM_OUT_CMD.scoped(scope).subscribe(SCROLL.can_zoom_out());
-            zoom_reset = ZOOM_RESET_CMD.scoped(scope).subscribe(SCROLL.zoom_scale().get() != 1.fct());
+            zoom_to_fit = ZOOM_TO_FIT_CMD.scoped(scope).subscribe(true);
+            zoom_reset = ZOOM_RESET_CMD.scoped(scope).subscribe(true);
         }
         UiNodeOp::Deinit => {
             child.deinit();
 
             zoom_in = CommandHandle::dummy();
             zoom_out = CommandHandle::dummy();
+            zoom_to_fit = CommandHandle::dummy();
             zoom_reset = CommandHandle::dummy();
         }
         UiNodeOp::Event { update } => {
@@ -604,6 +614,11 @@ pub fn zoom_commands_node(child: impl UiNode) -> impl UiNode {
 
                     WIDGET.layout();
                 });
+            } else if let Some(args) = ZOOM_TO_FIT_CMD.scoped(scope).on(update) {
+                args.handle_enabled(&zoom_to_fit, |_| {
+                    let scale = fit_scale();
+                    SCROLL.chase_zoom(|_| scale);
+                });
             } else if let Some(args) = ZOOM_RESET_CMD.scoped(scope).on(update) {
                 args.handle_enabled(&zoom_reset, |_| {
                     SCROLL.chase_zoom(|_| 1.fct());
@@ -616,7 +631,9 @@ pub fn zoom_commands_node(child: impl UiNode) -> impl UiNode {
 
             zoom_in.set_enabled(SCROLL.can_zoom_in());
             zoom_out.set_enabled(SCROLL.can_zoom_out());
-            zoom_reset.set_enabled(SCROLL.zoom_scale().get() != 1.fct());
+            let scale = SCROLL.zoom_scale().get();
+            zoom_to_fit.set_enabled(scale != fit_scale());
+            zoom_reset.set_enabled(scale != 1.fct());
 
             if scale_delta != 0.fct() {
                 let scroll_info = WIDGET.info().scroll_info().unwrap();
diff --git a/crates/zng-wgt-scroll/src/types.rs b/crates/zng-wgt-scroll/src/types.rs
index 01c150d61..a1ece3dcf 100644
--- a/crates/zng-wgt-scroll/src/types.rs
+++ b/crates/zng-wgt-scroll/src/types.rs
@@ -934,12 +934,24 @@ impl WidgetInfoExt for WidgetInfo {
     }
 }
 
-#[derive(Debug, Default)]
+#[derive(Debug)]
 struct ScrollData {
     viewport_transform: PxTransform,
     viewport_size: PxSize,
     joiner_size: PxSize,
     content: PxRect,
+    zoom_scale: Factor,
+}
+impl Default for ScrollData {
+    fn default() -> Self {
+        Self {
+            viewport_transform: Default::default(),
+            viewport_size: Default::default(),
+            joiner_size: Default::default(),
+            content: Default::default(),
+            zoom_scale: 1.fct(),
+        }
+    }
 }
 
 /// Shared reference to the viewport bounds of a scroll.
@@ -979,6 +991,11 @@ impl ScrollInfo {
         self.0.lock().content
     }
 
+    /// Latest zoom scale.
+    pub fn zoom_scale(&self) -> Factor {
+        self.0.lock().zoom_scale
+    }
+
     pub(super) fn set_viewport_size(&self, size: PxSize) {
         self.0.lock().viewport_size = size;
     }
@@ -991,8 +1008,10 @@ impl ScrollInfo {
         self.0.lock().joiner_size = size;
     }
 
-    pub(super) fn set_content(&self, content: PxRect) {
-        self.0.lock().content = content;
+    pub(super) fn set_content(&self, content: PxRect, scale: Factor) {
+        let mut m = self.0.lock();
+        m.content = content;
+        m.zoom_scale = scale;
     }
 }
 
diff --git a/crates/zng/src/scroll.rs b/crates/zng/src/scroll.rs
index 2ae3a0004..b50e6b9e1 100644
--- a/crates/zng/src/scroll.rs
+++ b/crates/zng/src/scroll.rs
@@ -1,7 +1,7 @@
 //! Scroll widgets, commands and properties.
 //!
-//! The [`Scroll!`](struct@Scroll) widget allows content to be of any size, overflow is clipped and can be brought
-//! into view by scrolling, the widget also supports content zooming and scrolling in only one dimension. The
+//! The [`Scroll!`](struct@Scroll) widget accepts a single child of any size, overflow is clipped and can be brought
+//! into view by scrolling, the widget also supports content zooming and panning. The
 //! [`mode`](struct@Scroll#method.mode) property can be used to dynamically change the [`ScrollMode`].
 //!
 //! ```
@@ -82,5 +82,6 @@ pub mod cmd {
         scroll_to, scroll_to_zoom, ScrollRequest, ScrollToMode, ScrollToRequest, ScrollToTarget, PAGE_DOWN_CMD, PAGE_LEFT_CMD,
         PAGE_RIGHT_CMD, PAGE_UP_CMD, SCROLL_DOWN_CMD, SCROLL_LEFT_CMD, SCROLL_RIGHT_CMD, SCROLL_TO_BOTTOM_CMD, SCROLL_TO_CMD,
         SCROLL_TO_LEFTMOST_CMD, SCROLL_TO_RIGHTMOST_CMD, SCROLL_TO_TOP_CMD, SCROLL_UP_CMD, ZOOM_IN_CMD, ZOOM_OUT_CMD, ZOOM_RESET_CMD,
+        ZOOM_TO_FIT_CMD,
     };
 }
diff --git a/examples/scroll.rs b/examples/scroll.rs
index dc32ece97..9b55a316f 100644
--- a/examples/scroll.rs
+++ b/examples/scroll.rs
@@ -116,6 +116,7 @@ fn commands(mouse_pan: impl Var<bool>, smooth_scrolling: impl Var<bool>) -> impl
                 ui_vec![
                     Button!(ZOOM_IN_CMD.scoped(scope)),
                     Button!(ZOOM_OUT_CMD.scoped(scope)),
+                    Button!(ZOOM_TO_FIT_CMD.scoped(scope)),
                     Button!(ZOOM_RESET_CMD.scoped(scope)),
                     Hr!(),
                     scroll_to_zoom_btn(WidgetId::named("Lorem 2"), 200.pct()),


Test patch: