diff --git a/internal/core/api.rs b/internal/core/api.rs index cd7fbff9e39..c42b7e590a3 100644 --- a/internal/core/api.rs +++ b/internal/core/api.rs @@ -674,6 +674,9 @@ impl Window { crate::platform::WindowEvent::Resized { size } => { self.0.set_window_item_geometry(size.to_euclid()); self.0.window_adapter().renderer().resize(size.to_physical(self.scale_factor()))?; + if let Some(item_rc) = self.0.focus_item.borrow().upgrade() { + item_rc.try_scroll_into_visible(); + } } crate::platform::WindowEvent::CloseRequested => { if self.0.request_close() { diff --git a/internal/core/item_tree.rs b/internal/core/item_tree.rs index 75f2e3ccf57..0695e8e4b11 100644 --- a/internal/core/item_tree.rs +++ b/internal/core/item_tree.rs @@ -470,7 +470,7 @@ impl ItemRc { /// Returns an absolute position of `p` in the parent item coordinate system /// (does not add this item's x and y) pub fn map_to_window(&self, p: LogicalPoint) -> LogicalPoint { - self.map_to_item_tree_impl(p, None) + self.map_to_item_tree_impl(p, |_| false) } /// Returns an absolute position of `p` in the `ItemTree`'s coordinate system @@ -480,24 +480,31 @@ impl ItemRc { p: LogicalPoint, item_tree: &vtable::VRc, ) -> LogicalPoint { - self.map_to_item_tree_impl(p, Some(item_tree)) + self.map_to_item_tree_impl(p, |current| current.is_root_item_of(item_tree)) + } + + /// Returns an absolute position of `p` in the `ancestor`'s coordinate system + /// (does not add this item's x and y) + /// Don't rely on any specific behavior if `self` isn't a descendant of `ancestor`. + fn map_to_ancestor(&self, p: LogicalPoint, ancestor: &Self) -> LogicalPoint { + self.map_to_item_tree_impl(p, |parent| parent == ancestor) } fn map_to_item_tree_impl( &self, p: LogicalPoint, - item_tree: Option<&vtable::VRc>, + stop_condition: impl Fn(&Self) -> bool, ) -> LogicalPoint { let mut current = self.clone(); let mut result = p; - if item_tree.is_some_and(|item_tree| current.is_root_item_of(item_tree)) { + if stop_condition(¤t) { return result; } let supports_transformations = self .window_adapter() .is_none_or(|adapter| adapter.renderer().supports_transformations()); while let Some(parent) = current.parent_item(ParentItemTraversalMode::StopAtPopups) { - if item_tree.is_some_and(|item_tree| current.is_root_item_of(item_tree)) { + if stop_condition(&parent) { break; } let geometry = parent.geometry(); @@ -507,7 +514,7 @@ impl ItemRc { } } result += geometry.origin.to_vector(); - current = parent.clone(); + current = parent; } result } @@ -847,6 +854,40 @@ impl ItemRc { // Should practically always be possible. .and_then(|child_transform| child_transform.inverse()) } + + pub(crate) fn try_scroll_into_visible(&self) { + let mut parent = self.parent_item(ParentItemTraversalMode::StopAtPopups); + while let Some(item_rc) = parent.as_ref() { + let item_ref = item_rc.borrow(); + if let Some(flickable) = vtable::VRef::downcast_pin::(item_ref) + { + let geo = self.geometry(); + + flickable.reveal_points( + item_rc, + &[ + self.map_to_ancestor( + LogicalPoint::new( + geo.origin.x - flickable.viewport_x().0, + geo.origin.y - flickable.viewport_y().0, + ), + &item_rc, + ), + self.map_to_ancestor( + LogicalPoint::new( + geo.max_x() - flickable.viewport_x().0, + geo.max_y() - flickable.viewport_y().0, + ), + &item_rc, + ), + ], + ); + break; + } + + parent = item_rc.parent_item(ParentItemTraversalMode::StopAtPopups); + } + } } impl PartialEq for ItemRc { diff --git a/internal/core/items/flickable.rs b/internal/core/items/flickable.rs index 802c92e9f70..a7578b7b117 100644 --- a/internal/core/items/flickable.rs +++ b/internal/core/items/flickable.rs @@ -211,6 +211,83 @@ impl ItemConsts for Flickable { Self::FIELD_OFFSETS.cached_rendering_data.as_unpinned_projection(); } +impl Flickable { + fn choose_min_move( + current_view_start: Coord, // vx or vy + view_len: Coord, // w or h + content_len: Coord, // vw or vh + points: impl Iterator, + ) -> Coord { + // Feasible translations t such that for all p: vx+t <= p <= vx+t+w + // -> t in [max_i(p_i - (vx + w)), min_i(p_i - vx)] + let zero = 0 as Coord; + let mut lower = Coord::MIN; + let mut upper = Coord::MAX; + + for p in points { + lower = lower.max(p - (current_view_start + view_len)); + upper = upper.min(p - current_view_start); + } + + if lower > upper { + // No translation can include all points simultaneously; pick nearest bound direction. + // This happens only with NaNs; guard anyway. + return zero; + } + + // Allowed translation interval due to scroll limits + let max_scroll = (content_len - view_len).max(zero); + let tmin = -current_view_start; // cannot scroll before 0 + let tmax = max_scroll - current_view_start; // cannot scroll past max + + let i_min = lower.max(tmin); + let i_max = upper.min(tmax); + + if i_min <= i_max { + if zero < i_min { + i_min + } else if zero > i_max { + i_max + } else { + zero + } + // Intervals disjoint: choose closest allowed translation to feasible interval + // either entirely left or right + } else if tmax < lower { + tmax + } else { + tmin + } + } + + /// Scroll the Flickable so that all of the points are visible at the same time (if possible). + /// The points have to be in the parent's coordinate space. + pub(crate) fn reveal_points(self: Pin<&Self>, self_rc: &ItemRc, pts: &[LogicalPoint]) { + if pts.is_empty() { + return; + } + + // visible viewport size from base Item + let geo = self_rc.geometry(); + + // content extents and current viewport origin (content coords) + let vw = Self::FIELD_OFFSETS.viewport_width.apply_pin(self).get().0; + let vh = Self::FIELD_OFFSETS.viewport_height.apply_pin(self).get().0; + let vx = -Self::FIELD_OFFSETS.viewport_x.apply_pin(self).get().0; + let vy = -Self::FIELD_OFFSETS.viewport_y.apply_pin(self).get().0; + + // choose minimal translation along each axis + let tx = Self::choose_min_move(vx, geo.width(), vw, pts.iter().map(|p| p.x)); + let ty = Self::choose_min_move(vy, geo.height(), vh, pts.iter().map(|p| p.y)); + + let new_vx = vx + tx; + let new_vy = vy + ty; + + Self::FIELD_OFFSETS.viewport_x.apply_pin(self).set(euclid::Length::new(-new_vx)); + Self::FIELD_OFFSETS.viewport_y.apply_pin(self).set(euclid::Length::new(-new_vy)); + } +} + #[repr(C)] /// Wraps the internal data structure for the Flickable pub struct FlickableDataBox(core::ptr::NonNull); diff --git a/internal/core/window.rs b/internal/core/window.rs index 1220030ebce..30c68a9a9c0 100644 --- a/internal/core/window.rs +++ b/internal/core/window.rs @@ -958,11 +958,17 @@ impl WindowInner { match item { Some(item) => { *self.focus_item.borrow_mut() = item.downgrade(); - item.borrow().as_ref().focus_event( + let result = item.borrow().as_ref().focus_event( &FocusEvent::FocusIn(reason), &self.window_adapter(), item, - ) + ); + // Reveal offscreen item when it gains focus + if result == crate::input::FocusEventResult::FocusAccepted { + item.try_scroll_into_visible(); + } + + result } None => { *self.focus_item.borrow_mut() = Default::default(); diff --git a/tests/cases/elements/flickable_focus.slint b/tests/cases/elements/flickable_focus.slint new file mode 100644 index 00000000000..2146ba56ae8 --- /dev/null +++ b/tests/cases/elements/flickable_focus.slint @@ -0,0 +1,80 @@ +// Copyright © SixtyFPS GmbH +// SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0 + +TestCase := Window { + preferred-width: 500px; + preferred-height: 500px; + no-frame: false; + + f := Flickable { + x: 10px; + y: 10px; + width: parent.width - 20px; + height: parent.height - 20px; + viewport_width: 2100px; + viewport_height: 2100px; + + VerticalLayout { + padding: 0; + Rectangle { + width: 100%; + height: 20px; + } + + HorizontalLayout { + Rectangle { + width: 20px; + height: 100%; + } + + inner_ta := TouchArea { + width: 50px; + height: 50px; + fs := FocusScope { + Rectangle { } + } + } + } + } + } + + public function set-focus() { + fs.focus(); + } + public function remove-focus() { + fs.clear-focus(); + } + + property offset_x: -f.viewport_x; + property offset_y: -f.viewport_y; +} + +/* +```rust +// Test that focused items stay visible when resizing the window + +use slint::{LogicalSize, LogicalPosition, platform::WindowEvent}; +let instance = TestCase::new().unwrap(); +instance.window().dispatch_event(WindowEvent::Resized { size: LogicalSize::new(70., 70.) }); +assert_eq!(instance.get_offset_x(), 0.); +assert_eq!(instance.get_offset_y(), 0.); +instance.window().dispatch_event(WindowEvent::Resized { size: LogicalSize::new(500., 500.) }); +assert_eq!(instance.get_offset_x(), 0.); +assert_eq!(instance.get_offset_y(), 0.); +instance.invoke_set_focus(); +instance.window().dispatch_event(WindowEvent::Resized { size: LogicalSize::new(70., 70.) }); +assert_eq!(instance.get_offset_x(), 20.); +assert_eq!(instance.get_offset_y(), 20.); + +// Test that items that gain focus scroll to visible + +instance.invoke_remove_focus(); +instance.window().dispatch_event(WindowEvent::PointerScrolled { position: LogicalPosition::new(25.0, 25.0), delta_x: 20.0, delta_y: 20.0 }); +assert_eq!(instance.get_offset_x(), 0.); +assert_eq!(instance.get_offset_y(), 0.); +instance.invoke_set_focus(); +assert_eq!(instance.get_offset_x(), 20.); +assert_eq!(instance.get_offset_y(), 20.); + +``` +*/