diff --git a/src/canvas.rs b/src/canvas.rs index 45840f7..12aec48 100644 --- a/src/canvas.rs +++ b/src/canvas.rs @@ -442,6 +442,14 @@ impl InfiniteCanvas { self.camera.visible(to_plane_size(self.viewport.size)) } + pub fn framed_bounds(&self) -> gpui::Bounds { + let camera = self + .animation + .as_ref() + .map_or(self.camera, |animation| animation.to); + to_gpui_bounds(camera.visible(to_plane_size(self.viewport.size))) + } + pub fn nearest_fit( &self, desired: gpui::Point, @@ -454,25 +462,42 @@ impl InfiniteCanvas { to_gpui_point(deflate_point(origin, inset)) } - pub fn fit_bounds(&mut self, rect: gpui::Bounds, cx: &mut Context) { + pub fn fit_bounds(&mut self, rects: &[gpui::Bounds], cx: &mut Context) { let transition = self.config.camera.transitions.fit; - self.frame(rect, f32::INFINITY, transition, cx); + self.frame(rects, f32::INFINITY, transition, cx); } pub fn bring_into_view(&mut self, rect: gpui::Bounds, cx: &mut Context) { let transition = self.config.camera.transitions.reveal; - self.frame(rect, self.camera.zoom, transition, cx); + match self.config.camera.on_focus { + config::OnFocus::Centered => self.frame(&[rect], self.camera.zoom, transition, cx), + config::OnFocus::Minimal => { + let current = self + .animation + .as_ref() + .map_or(self.camera, |animation| animation.to); + let Some(target) = current.revealing( + to_plane_rect(rect), + to_plane_size(self.viewport.size), + self.plane.config(), + ) else { + return; + }; + self.move_camera(target, transition, cx); + } + } } fn frame( &mut self, - rect: gpui::Bounds, + rects: &[gpui::Bounds], zoom_ceiling: f32, transition: Option, cx: &mut Context, ) { + let plane_rects: Vec = rects.iter().copied().map(to_plane_rect).collect(); let Some(target) = plane::Camera::framing( - to_plane_rect(rect), + &plane_rects, to_plane_size(self.viewport.size), zoom_ceiling, self.plane.config(), @@ -482,8 +507,7 @@ impl InfiniteCanvas { debug!( from = self.camera.zoom, to = target.zoom, - width = f32::from(rect.size.width), - height = f32::from(rect.size.height), + rects = rects.len(), "framing" ); @@ -537,7 +561,7 @@ impl InfiniteCanvas { pub fn zoom_to_fit(&mut self, cx: &mut Context) { match self.plane.bounding_rect() { - Some(rect) => self.fit_bounds(to_gpui_bounds(rect), cx), + Some(rect) => self.fit_bounds(&[to_gpui_bounds(rect)], cx), None => { let transition = self.config.camera.transitions.fit; self.move_camera(plane::Camera::default(), transition, cx) @@ -549,7 +573,7 @@ impl InfiniteCanvas { if let Some(index) = self.focused_index(window) && let Some(rect) = self.plane.get(shape_id(self.items[index].id)) { - self.fit_bounds(to_gpui_bounds(rect), cx); + self.fit_bounds(&[to_gpui_bounds(rect)], cx); } } @@ -1125,7 +1149,7 @@ impl InfiniteCanvas { if event.click_count >= 2 { debug!(?id, "grab area double-clicked; framing the item"); if let Some(rect) = self.plane.get(shape_id(id)) { - self.fit_bounds(to_gpui_bounds(rect), cx); + self.fit_bounds(&[to_gpui_bounds(rect)], cx); } } else { self.start_move(id, event.position); diff --git a/src/config.rs b/src/config.rs index e35ef10..bf1d264 100644 --- a/src/config.rs +++ b/src/config.rs @@ -75,6 +75,7 @@ pub struct Camera { pub min_zoom: f32, pub max_zoom: f32, pub fit_padding: f32, + pub on_focus: OnFocus, pub transitions: Transitions, } @@ -84,11 +85,20 @@ impl Default for Camera { min_zoom: 0.1, max_zoom: 8., fit_padding: 0.05, + on_focus: OnFocus::default(), transitions: Transitions::default(), } } } +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum OnFocus { + #[default] + Centered, + Minimal, +} + #[derive(Clone, Copy, Debug)] pub struct Transitions { pub fit: Option, diff --git a/src/plane/camera.rs b/src/plane/camera.rs index 971c84d..a462e5b 100644 --- a/src/plane/camera.rs +++ b/src/plane/camera.rs @@ -78,7 +78,7 @@ impl Camera { } pub fn framing( - rect: geometry::Rect, + rects: &[geometry::Rect], viewport: geometry::Size, zoom_ceiling: f32, config: &plane::LayoutConfig, @@ -86,6 +86,7 @@ impl Camera { if viewport.width <= geometry::Px::ZERO || viewport.height <= geometry::Px::ZERO { return None; } + let rect = rects.iter().copied().reduce(geometry::Rect::union)?; let available = geometry::Size { width: viewport.width * (1. - config.fit_padding), @@ -112,6 +113,36 @@ impl Camera { }) } + pub fn revealing( + &self, + rect: geometry::Rect, + viewport: geometry::Size, + config: &plane::LayoutConfig, + ) -> Option { + let framed = Camera::framing(&[rect], viewport, self.zoom, config)?; + if framed.zoom != self.zoom { + return Some(framed); + } + + let visible = self.visible(viewport); + let margin_x = visible.size.width * config.fit_padding * 0.5; + let margin_y = visible.size.height * config.fit_padding * 0.5; + let dx = nudge( + (rect.left(), rect.right()), + (visible.left() + margin_x, visible.right() - margin_x), + framed.focus.x - self.focus.x, + ); + let dy = nudge( + (rect.top(), rect.bottom()), + (visible.top() + margin_y, visible.bottom() - margin_y), + framed.focus.y - self.focus.y, + ); + Some(Camera { + focus: geometry::point(self.focus.x + dx, self.focus.y + dy), + zoom: self.zoom, + }) + } + pub fn lerp(from: Camera, to: Camera, progress: f32) -> Camera { let progress = progress.clamp(0., 1.); Camera { @@ -130,3 +161,19 @@ impl Camera { } } } + +fn nudge( + (start, end): (geometry::Px, geometry::Px), + (view_start, view_end): (geometry::Px, geometry::Px), + centred: geometry::Px, +) -> geometry::Px { + if end - start > view_end - view_start { + centred + } else if start < view_start { + start - view_start + } else if end > view_end { + end - view_end + } else { + geometry::Px::ZERO + } +} diff --git a/src/plane/geometry.rs b/src/plane/geometry.rs index 3601fe3..7e79151 100644 --- a/src/plane/geometry.rs +++ b/src/plane/geometry.rs @@ -200,6 +200,16 @@ impl Rect { ) } + pub fn union(self, other: Rect) -> Rect { + Rect::from_corners( + point(self.left().min(other.left()), self.top().min(other.top())), + point( + self.right().max(other.right()), + self.bottom().max(other.bottom()), + ), + ) + } + pub fn area(self) -> f32 { self.size.width.get() * self.size.height.get() } diff --git a/src/plane/properties.rs b/src/plane/properties.rs index 7616c81..3f10989 100644 --- a/src/plane/properties.rs +++ b/src/plane/properties.rs @@ -708,7 +708,7 @@ proptest! { let fit = (available_w / rect.size.width.get()).min(available_h / rect.size.height.get()); prop_assume!(fit >= config.min_zoom && fit <= config.max_zoom); - let cam = plane::Camera::framing(rect, viewport, f32::INFINITY, &config).expect("fits within limits"); + let cam = plane::Camera::framing(&[rect], viewport, f32::INFINITY, &config).expect("fits within limits"); let centre = cam.to_view(rect.center()); prop_assert!(approx_px(centre.x, viewport.width / 2.), "not centred horizontally"); prop_assert!(approx_px(centre.y, viewport.height / 2.), "not centred vertically"); @@ -722,7 +722,7 @@ proptest! { #[test] fn framing_needs_an_area(rect in arbitrary_rect(), viewport in arbitrary_size_any(), config in arbitrary_config()) { - let result = plane::Camera::framing(rect, viewport, f32::INFINITY, &config); + let result = plane::Camera::framing(&[rect], viewport, f32::INFINITY, &config); if viewport.width <= geometry::Px::ZERO || viewport.height <= geometry::Px::ZERO { prop_assert_eq!(result, None); } else { @@ -730,6 +730,62 @@ proptest! { } } + #[test] + fn framing_many_contains_them_all(rects in prop::collection::vec(arbitrary_rect(), 1..5), viewport in arbitrary_viewport(), config in arbitrary_config()) { + let union = rects.iter().copied().reduce(geometry::Rect::union).unwrap(); + let available_w = viewport.width.get() * (1. - config.fit_padding); + let available_h = viewport.height.get() * (1. - config.fit_padding); + let fit = (available_w / union.size.width.get()).min(available_h / union.size.height.get()); + prop_assume!(fit >= config.min_zoom && fit <= config.max_zoom); + + let cam = plane::Camera::framing(&rects, viewport, f32::INFINITY, &config).expect("fits within limits"); + let visible = cam.visible(viewport); + let slack = geometry::px(1.); + for rect in rects { + prop_assert!(visible.left() <= rect.left() + slack && visible.right() >= rect.right() - slack); + prop_assert!(visible.top() <= rect.top() + slack && visible.bottom() >= rect.bottom() - slack); + } + } + + #[test] + fn framing_nothing_is_nothing(viewport in arbitrary_viewport(), config in arbitrary_config()) { + prop_assert_eq!(plane::Camera::framing(&[], viewport, f32::INFINITY, &config), None); + } + + #[test] + fn revealing_a_visible_rect_stays_put(camera in arbitrary_camera(), viewport in arbitrary_viewport(), config in arbitrary_config(), fx in 0.1f32..=0.4, fy in 0.1f32..=0.4) { + let camera = camera.clamped(&config); + let visible = camera.visible(viewport); + let rect = geometry::Rect { + origin: geometry::point(visible.left() + visible.size.width * fx, visible.top() + visible.size.height * fy), + size: geometry::size(visible.size.width * 0.5, visible.size.height * 0.5), + }; + + let revealed = camera.revealing(rect, viewport, &config).expect("viewport has area"); + prop_assert_eq!(revealed, camera); + } + + #[test] + fn revealing_brings_the_rect_just_into_view(camera in arbitrary_camera(), viewport in arbitrary_viewport(), config in arbitrary_config(), rect in arbitrary_rect()) { + let camera = camera.clamped(&config); + let visible = camera.visible(viewport); + prop_assume!(rect.size.width <= visible.size.width * 0.9 && rect.size.height <= visible.size.height * 0.9); + + let revealed = camera.revealing(rect, viewport, &config).expect("viewport has area"); + prop_assert!(approx(revealed.zoom, camera.zoom)); + + let after = revealed.visible(viewport); + let slack = geometry::px(1e-3 * (rect.left().get().abs() + rect.top().get().abs() + after.size.width.get() + after.size.height.get() + 1.)); + prop_assert!(after.left() <= rect.left() + slack && after.right() >= rect.right() - slack); + prop_assert!(after.top() <= rect.top() + slack && after.bottom() >= rect.bottom() - slack); + + let shifted_x = (revealed.focus.x - camera.focus.x).get().abs(); + let shifted_y = (revealed.focus.y - camera.focus.y).get().abs(); + let centred = plane::Camera::framing(&[rect], viewport, camera.zoom, &config).unwrap(); + prop_assert!(shifted_x <= (centred.focus.x - camera.focus.x).get().abs() + slack.get()); + prop_assert!(shifted_y <= (centred.focus.y - camera.focus.y).get().abs() + slack.get()); + } + #[test] fn lerp_reaches_its_endpoints(from in arbitrary_camera(), to in arbitrary_camera()) { let at_zero = plane::Camera::lerp(from, to, 0.0);