diff --git a/crates/gpui/README.md b/crates/gpui/README.md index 132e5ac1e9f177..42d6f5fd72df82 100644 --- a/crates/gpui/README.md +++ b/crates/gpui/README.md @@ -42,6 +42,39 @@ The features on `gpui_platform` are platform-specific, so the list above is a sa - **Windows** — no features are required. Windowing uses Win32 and text uses DirectWrite. `font-kit` has no effect here. +### Pan and zoom + +`ElementTransform` applies translation and scale to a `div` subtree after layout. +Children keep their layout coordinates. Paint, clipping and hitboxes move together. + +```rust +div().transform( + ElementTransform::default() + .origin(point(0.5, 0.5)) + .translate(point(px(40.), px(10.))) + .scale(size(2., 2.)), +) +``` + +**Custom elements** must convert coordinates when they compare mouse input with +layout bounds, report automation bounds, or return IME rectangles. Capture +`window.element_transform()` during prepaint or paint and retain it for later +callbacks. Its translation uses logical pixels, independent of display density. + +```rust +let transform = window.element_transform(); // capture while drawing +let screen_bounds = transform.apply_bounds(layout_bounds); +// Later, in a mouse callback, after the drawing scope has ended: +let local_position = transform.inverse().map(|inverse| inverse.apply(event.position)); +``` + +`inverse()` returns `None` for singular or non-finite transforms, including zero +scale. Mouse event positions themselves remain in window coordinates. +Only `div` and text elements apply their own `Style::transform`; other elements +can be transformed through a parent `div`. Keep anchored overlays outside a +transformed subtree. Uniform positive scale also scales decoration geometry; +non-uniform scale leaves corner radii, border widths and shadow blur unchanged. + ### Additional Topics - [Ownership and data flow](_ownership_and_data_flow) diff --git a/crates/gpui/src/elements/div.rs b/crates/gpui/src/elements/div.rs index 51379b8eee0722..b55ac4e0f70a3d 100644 --- a/crates/gpui/src/elements/div.rs +++ b/crates/gpui/src/elements/div.rs @@ -2309,21 +2309,23 @@ impl Interactivity { } window.with_text_style(style.text_style().cloned(), |window| { - window.with_content_mask( - style.overflow_mask(bounds, window.rem_size()), - |window| { - let hitbox = if self.should_insert_hitbox(&style, window, cx) { - Some(window.insert_hitbox(bounds, self.hitbox_behavior)) - } else { - None - }; + window.with_style_transform(style.transform, bounds, |window| { + window.with_content_mask( + style.overflow_mask(bounds, window.rem_size()), + |window| { + let hitbox = if self.should_insert_hitbox(&style, window, cx) { + Some(window.insert_hitbox(bounds, self.hitbox_behavior)) + } else { + None + }; - let scroll_offset = - self.clamp_scroll_position(bounds, &style, window, cx); - let result = f(&style, scroll_offset, hitbox, window, cx); - (result, element_state) - }, - ) + let scroll_offset = + self.clamp_scroll_position(bounds, &style, window, cx); + let result = f(&style, scroll_offset, hitbox, window, cx); + (result, element_state) + }, + ) + }) }) }, ) @@ -2470,93 +2472,101 @@ impl Interactivity { } window.with_element_opacity(style.opacity, |window| { - style.paint(bounds, window, cx, |window: &mut Window, cx: &mut App| { - window.with_text_style(style.text_style().cloned(), |window| { - window.with_content_mask( - style.overflow_mask(bounds, window.rem_size()), - |window| { - window.with_tab_group(tab_group, |window| { - // Register the container's own focus handle *inside* its - // tab group, so that focusing the container and then - // calling `focus_next` descends into this group's first - // item. Inserting it before `with_tab_group` would give the - // container a shallower tab path than its children; with - // sibling groups every container would then sort ahead of - // every item, and `focus_next` from a container would jump - // to the first item in the whole window instead of its own. - if let Some(focus_handle) = &self.tracked_focus_handle { - window.next_frame.tab_stops.insert(focus_handle); - } - if let Some(hitbox) = hitbox { - #[cfg(debug_assertions)] - self.paint_debug_info( - global_id, hitbox, &style, window, cx, - ); - - if let Some(drag) = cx.active_drag.as_ref() { - if let Some(mouse_cursor) = drag.cursor_style { - window.set_window_cursor_style(mouse_cursor); - } - } else { - if let Some(mouse_cursor) = style.mouse_cursor { + window.with_style_transform(style.transform, bounds, |window| { + style.paint(bounds, window, cx, |window: &mut Window, cx: &mut App| { + window.with_text_style(style.text_style().cloned(), |window| { + window.with_content_mask( + style.overflow_mask(bounds, window.rem_size()), + |window| { + window.with_tab_group(tab_group, |window| { + // Register the container's own focus handle *inside* its + // tab group, so that focusing the container and then + // calling `focus_next` descends into this group's first + // item. Inserting it before `with_tab_group` would give the + // container a shallower tab path than its children; with + // sibling groups every container would then sort ahead of + // every item, and `focus_next` from a container would jump + // to the first item in the whole window instead of its own. + if let Some(focus_handle) = &self.tracked_focus_handle { + window.next_frame.tab_stops.insert(focus_handle); + } + if let Some(hitbox) = hitbox { + #[cfg(debug_assertions)] + self.paint_debug_info( + global_id, hitbox, &style, window, cx, + ); + + if let Some(drag) = cx.active_drag.as_ref() { + if let Some(mouse_cursor) = drag.cursor_style { + window + .set_window_cursor_style(mouse_cursor); + } + } else if let Some(mouse_cursor) = + style.mouse_cursor + { window.set_cursor_style(mouse_cursor, hitbox); } - } - if let Some(group) = self.group.clone() { - GroupHitboxes::push(group, hitbox.id, cx); - } + if let Some(group) = self.group.clone() { + GroupHitboxes::push(group, hitbox.id, cx); + } - if let Some(area) = self.window_control { - window.insert_window_control_hitbox( - area, - hitbox.clone(), + if let Some(area) = self.window_control { + window.insert_window_control_hitbox( + area, + hitbox.clone(), + ); + } + + self.paint_mouse_listeners( + hitbox, + element_state.as_mut(), + window, + cx, + ); + self.paint_scroll_listener( + hitbox, &style, window, cx, ); } - self.paint_mouse_listeners( - hitbox, - element_state.as_mut(), - window, - cx, - ); - self.paint_scroll_listener(hitbox, &style, window, cx); - } - - self.paint_keyboard_listeners(window, cx); - - if window.a11y.is_active() { - if let Some(global_id) = global_id { - if !self.a11y_action_listeners.is_empty() { - let node_id = global_id.accesskit_node_id(); - for (action, listener) in - self.a11y_action_listeners.drain(..) - { - window.on_a11y_action( - node_id, action, listener, - ); + self.paint_keyboard_listeners(window, cx); + + if window.a11y.is_active() { + if let Some(global_id) = global_id { + if !self.a11y_action_listeners.is_empty() { + let node_id = global_id.accesskit_node_id(); + for (action, listener) in + self.a11y_action_listeners.drain(..) + { + window.on_a11y_action( + node_id, action, listener, + ); + } } } } - } - f(&style, window, cx); + f(&style, window, cx); - if let Some(_hitbox) = hitbox { - #[cfg(any(feature = "inspector", debug_assertions))] - window.insert_inspector_hitbox( - _hitbox.id, - _inspector_id, - cx, - ); + if let Some(_hitbox) = hitbox { + #[cfg(any( + feature = "inspector", + debug_assertions + ))] + window.insert_inspector_hitbox( + _hitbox.id, + _inspector_id, + cx, + ); - if let Some(group) = self.group.as_ref() { - GroupHitboxes::pop(group, cx); + if let Some(group) = self.group.as_ref() { + GroupHitboxes::pop(group, cx); + } } - } - }) - }, - ); + }) + }, + ); + }); }); }); }); diff --git a/crates/gpui/src/scene.rs b/crates/gpui/src/scene.rs index 122f7b2d7c1a99..a81ff4fd242386 100644 --- a/crates/gpui/src/scene.rs +++ b/crates/gpui/src/scene.rs @@ -85,9 +85,17 @@ impl Scene { } pub fn insert_primitive(&mut self, primitive: impl Into) { - let mut primitive = primitive.into(); + self.insert_transformed_primitive(primitive, TransformationMatrix::unit()); + } + + pub(crate) fn insert_transformed_primitive( + &mut self, + primitive: impl Into, + transform: TransformationMatrix, + ) { + let mut primitive = primitive.into().transform(transform); let clipped_bounds = primitive - .bounds() + .visual_bounds() .intersect(&primitive.content_mask().bounds); if clipped_bounds.is_empty() { @@ -257,6 +265,121 @@ impl Primitive { Primitive::Surface(surface) => &surface.content_mask, } } + + fn visual_bounds(&self) -> Bounds { + match self { + Primitive::MonochromeSprite(sprite) => { + transform_bounds(sprite.bounds, sprite.transformation) + } + Primitive::SubpixelSprite(sprite) => { + transform_bounds(sprite.bounds, sprite.transformation) + } + _ => *self.bounds(), + } + } + + fn transform(self, transform: TransformationMatrix) -> Self { + if transform == TransformationMatrix::unit() { + return self; + } + + match self { + Primitive::Shadow(mut shadow) => { + shadow.bounds = transform_bounds(shadow.bounds, transform); + shadow.element_bounds = transform_bounds(shadow.element_bounds, transform); + if let Some(scale) = transform.uniform_scale() { + shadow.blur_radius = ScaledPixels(shadow.blur_radius.0 * scale); + shadow.corner_radii = scale_corners(shadow.corner_radii, scale); + shadow.element_corner_radii = scale_corners(shadow.element_corner_radii, scale); + } + Primitive::Shadow(shadow) + } + Primitive::Quad(mut quad) => { + quad.bounds = transform_bounds(quad.bounds, transform); + if let Some(scale) = transform.uniform_scale() { + quad.corner_radii = scale_corners(quad.corner_radii, scale); + quad.border_widths = quad + .border_widths + .map(|width| ScaledPixels(width.0 * scale)); + } + Primitive::Quad(quad) + } + Primitive::Path(mut path) => { + path.transform(transform); + Primitive::Path(path) + } + Primitive::Underline(mut underline) => { + underline.bounds = transform_bounds(underline.bounds, transform); + if let Some(scale) = transform.uniform_scale() { + underline.thickness = ScaledPixels(underline.thickness.0 * scale); + } + Primitive::Underline(underline) + } + Primitive::MonochromeSprite(mut sprite) => { + sprite.transformation = transform.compose(sprite.transformation); + Primitive::MonochromeSprite(sprite) + } + Primitive::SubpixelSprite(mut sprite) => { + sprite.transformation = transform.compose(sprite.transformation); + Primitive::SubpixelSprite(sprite) + } + Primitive::PolychromeSprite(mut sprite) => { + sprite.bounds = transform_bounds(sprite.bounds, transform); + if let Some(scale) = transform.uniform_scale() { + sprite.corner_radii = scale_corners(sprite.corner_radii, scale); + } + Primitive::PolychromeSprite(sprite) + } + Primitive::Surface(mut surface) => { + surface.bounds = transform_bounds(surface.bounds, transform); + Primitive::Surface(surface) + } + } + } +} + +fn scale_corners(corners: Corners, scale: f32) -> Corners { + corners.map(|radius| ScaledPixels(radius.0 * scale)) +} + +pub(crate) fn transform_bounds( + bounds: Bounds, + transform: TransformationMatrix, +) -> Bounds { + let top_left = transform.apply_scaled(bounds.origin); + let top_right = transform.apply_scaled(point(bounds.right(), bounds.top())); + let bottom_left = transform.apply_scaled(point(bounds.left(), bounds.bottom())); + let bottom_right = transform.apply_scaled(point(bounds.right(), bounds.bottom())); + + let left = top_left + .x + .0 + .min(top_right.x.0) + .min(bottom_left.x.0) + .min(bottom_right.x.0); + let top = top_left + .y + .0 + .min(top_right.y.0) + .min(bottom_left.y.0) + .min(bottom_right.y.0); + let right = top_left + .x + .0 + .max(top_right.x.0) + .max(bottom_left.x.0) + .max(bottom_right.x.0); + let bottom = top_left + .y + .0 + .max(top_right.y.0) + .max(bottom_left.y.0) + .max(bottom_right.y.0); + + Bounds::from_corners( + point(ScaledPixels(left), ScaledPixels(top)), + point(ScaledPixels(right), ScaledPixels(bottom)), + ) } #[cfg_attr( @@ -603,7 +726,7 @@ pub enum BorderStyle { } /// A data type representing a 2 dimensional transformation that can be applied to an element. -#[derive(Debug, Clone, Copy, PartialEq)] +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, JsonSchema)] #[repr(C)] pub struct TransformationMatrix { /// 2x2 matrix containing rotation and scale, @@ -686,7 +809,7 @@ impl TransformationMatrix { } } - /// Apply transformation to a point, mainly useful for debugging + /// Applies this transform to a point in the same coordinate space as its translation. pub fn apply(&self, point: Point) -> Point { let input = [point.x.0, point.y.0]; let mut output = self.translation; @@ -697,6 +820,60 @@ impl TransformationMatrix { } Point::new(output[0].into(), output[1].into()) } + + /// Returns the inverse, or `None` for a singular or non-finite transform. + pub fn inverse(self) -> Option { + let [[xx, xy], [yx, yy]] = self.rotation_scale; + let determinant = xx * yy - xy * yx; + if determinant == 0. || !determinant.is_finite() { + return None; + } + let rotation_scale = [ + [yy / determinant, -xy / determinant], + [-yx / determinant, xx / determinant], + ]; + let translation = rotation_scale + .map(|row| -(row[0] * self.translation[0] + row[1] * self.translation[1])); + let inverse = Self { + rotation_scale, + translation, + }; + inverse + .rotation_scale + .iter() + .flatten() + .chain(inverse.translation.iter()) + .all(|value| value.is_finite()) + .then_some(inverse) + } + + /// Returns the axis-aligned bounds enclosing the transformed rectangle. + pub fn apply_bounds(self, bounds: Bounds) -> Bounds { + transform_bounds(bounds.map(|value| ScaledPixels(value.0)), self) + .map(|value| Pixels(value.0)) + } + + pub(crate) fn apply_scaled(&self, point: Point) -> Point { + let input = [point.x.0, point.y.0]; + let mut output = self.translation; + for (i, output_cell) in output.iter_mut().enumerate() { + for (k, input_cell) in input.iter().enumerate() { + *output_cell += self.rotation_scale[i][k] * *input_cell; + } + } + Point::new(ScaledPixels(output[0]), ScaledPixels(output[1])) + } + + /// The scale this matrix applies, when it is a scale and a translation. + /// + /// Decorations — corner radii, border widths, shadow blur — are painted in + /// device pixels and have no elliptical form, so a rotated or anisotropic + /// matrix reports `None` and they keep their unscaled size. + pub(crate) fn uniform_scale(&self) -> Option { + let [[xx, xy], [yx, yy]] = self.rotation_scale; + let uniform = xy.abs() < 1e-5 && yx.abs() < 1e-5 && xx > 0.0 && (xx - yy).abs() < 1e-5; + uniform.then_some(xx) + } } impl Default for TransformationMatrix { @@ -943,12 +1120,254 @@ where } } +impl Path { + fn transform(&mut self, transform: TransformationMatrix) { + self.bounds = transform_bounds(self.bounds, transform); + self.start = transform.apply_scaled(self.start); + self.current = transform.apply_scaled(self.current); + + for vertex in &mut self.vertices { + vertex.xy_position = transform.apply_scaled(vertex.xy_position); + } + } +} + impl From> for Primitive { fn from(path: Path) -> Self { Primitive::Path(path) } } +#[cfg(test)] +mod tests { + use super::*; + use crate::{AtlasTextureKind, DevicePixels, TileId, size}; + + #[test] + fn transformed_monochrome_sprite_is_culled_by_visual_bounds() { + let transform = + TransformationMatrix::unit().translate(point(ScaledPixels(100.), ScaledPixels(0.))); + + let mut sprite = test_sprite(); + sprite.content_mask = ContentMask { + bounds: Bounds::new( + point(ScaledPixels(100.), ScaledPixels(0.)), + size(ScaledPixels(10.), ScaledPixels(10.)), + ), + }; + + let mut scene = Scene::default(); + scene.insert_transformed_primitive(sprite, transform); + + assert_eq!(scene.monochrome_sprites.len(), 1); + + let Some(sprite) = scene.monochrome_sprites.first() else { + panic!("scene should emit one transformed monochrome sprite"); + }; + + assert_eq!(sprite.bounds, test_sprite().bounds); + assert_eq!(sprite.transformation, transform); + } + + #[test] + fn transformed_subpixel_sprite_is_culled_by_visual_bounds() { + let transform = + TransformationMatrix::unit().translate(point(ScaledPixels(0.), ScaledPixels(100.))); + + let mut sprite = test_subpixel_sprite(); + sprite.content_mask = ContentMask { + bounds: Bounds::new( + point(ScaledPixels(0.), ScaledPixels(100.)), + size(ScaledPixels(10.), ScaledPixels(10.)), + ), + }; + + let mut scene = Scene::default(); + scene.insert_transformed_primitive(sprite, transform); + + assert_eq!(scene.subpixel_sprites.len(), 1); + + let Some(sprite) = scene.subpixel_sprites.first() else { + panic!("scene should emit one transformed subpixel sprite"); + }; + + assert_eq!(sprite.bounds, test_subpixel_sprite().bounds); + assert_eq!(sprite.transformation, transform); + } + + fn test_quad() -> Quad { + Quad { + order: 0, + border_style: BorderStyle::default(), + bounds: Bounds::new( + point(ScaledPixels(1.), ScaledPixels(2.)), + size(ScaledPixels(3.), ScaledPixels(4.)), + ), + content_mask: ContentMask { + bounds: Bounds::new( + point(ScaledPixels(0.), ScaledPixels(0.)), + size(ScaledPixels(100.), ScaledPixels(100.)), + ), + }, + background: Background::default(), + border_color: Hsla::default(), + corner_radii: Corners::default(), + border_widths: Edges::default(), + } + } + + #[test] + fn uniform_transform_scales_image_corner_radii() { + let sprite = test_sprite(); + let image = PolychromeSprite { + order: 0, + nearest_neighbor: false.into(), + grayscale: false.into(), + opacity: 1., + bounds: sprite.bounds, + content_mask: sprite.content_mask, + corner_radii: Corners::all(ScaledPixels(2.)), + tile: sprite.tile, + }; + let Primitive::PolychromeSprite(image) = Primitive::from(image).transform(scale_by(2., 2.)) + else { + panic!("an image must stay an image"); + }; + assert_eq!( + image.bounds.size, + size(ScaledPixels(20.), ScaledPixels(20.)) + ); + assert_eq!(image.corner_radii.top_left, ScaledPixels(4.)); + } + + #[test] + fn a_uniform_transform_scales_the_quad_decorations_with_its_bounds() { + let Primitive::Quad(scaled) = decorated_quad().transform(scale_by(2., 2.)) else { + panic!("a quad must stay a quad"); + }; + + assert_eq!(scaled.bounds.size, size(ScaledPixels(6.), ScaledPixels(8.))); + assert_eq!(scaled.corner_radii.top_left, ScaledPixels(8.)); + assert_eq!(scaled.border_widths.top, ScaledPixels(2.)); + } + + /// A rounded, bordered box cannot become an ellipse, so an anisotropic + /// scale leaves the decorations at their authored size. + #[test] + fn an_anisotropic_transform_leaves_the_quad_decorations_alone() { + let Primitive::Quad(scaled) = decorated_quad().transform(scale_by(2., 1.)) else { + panic!("a quad must stay a quad"); + }; + + assert_eq!(scaled.bounds.size, size(ScaledPixels(6.), ScaledPixels(4.))); + assert_eq!(scaled.corner_radii.top_left, ScaledPixels(4.)); + assert_eq!(scaled.border_widths.top, ScaledPixels(1.)); + } + + #[test] + fn a_uniform_transform_scales_the_shadow_geometry() { + let Primitive::Shadow(scaled) = test_shadow().transform(scale_by(2., 2.)) else { + panic!("a shadow must stay a shadow"); + }; + + assert_eq!(scaled.blur_radius, ScaledPixels(16.)); + assert_eq!(scaled.corner_radii.top_left, ScaledPixels(6.)); + assert_eq!(scaled.element_corner_radii.bottom_right, ScaledPixels(6.)); + } + + fn scale_by(x: f32, y: f32) -> TransformationMatrix { + TransformationMatrix::unit().scale(size(x, y)) + } + + fn decorated_quad() -> Primitive { + let mut quad = test_quad(); + let corners = Corners { + top_left: ScaledPixels(4.), + top_right: ScaledPixels(4.), + bottom_right: ScaledPixels(4.), + bottom_left: ScaledPixels(4.), + }; + quad.corner_radii = corners; + quad.border_widths = Edges { + top: ScaledPixels(1.), + right: ScaledPixels(1.), + bottom: ScaledPixels(1.), + left: ScaledPixels(1.), + }; + Primitive::Quad(quad) + } + + fn test_shadow() -> Primitive { + let bounds = Bounds::new( + point(ScaledPixels(1.), ScaledPixels(2.)), + size(ScaledPixels(3.), ScaledPixels(4.)), + ); + let corners = Corners { + top_left: ScaledPixels(3.), + top_right: ScaledPixels(3.), + bottom_right: ScaledPixels(3.), + bottom_left: ScaledPixels(3.), + }; + Primitive::Shadow(Shadow { + order: 0, + blur_radius: ScaledPixels(8.), + bounds, + corner_radii: corners, + content_mask: ContentMask { bounds }, + color: Hsla::default(), + element_bounds: bounds, + element_corner_radii: corners, + inset: 0, + pad: 0, + }) + } + + fn test_sprite() -> MonochromeSprite { + MonochromeSprite { + order: 0, + pad: 0, + bounds: Bounds::new( + point(ScaledPixels(0.), ScaledPixels(0.)), + size(ScaledPixels(10.), ScaledPixels(10.)), + ), + content_mask: ContentMask { + bounds: Bounds::new( + point(ScaledPixels(0.), ScaledPixels(0.)), + size(ScaledPixels(100.), ScaledPixels(100.)), + ), + }, + color: Hsla::default(), + tile: AtlasTile { + texture_id: AtlasTextureId { + index: 0, + kind: AtlasTextureKind::Monochrome, + }, + tile_id: TileId(0), + padding: 0, + bounds: Bounds::new( + point(DevicePixels(0), DevicePixels(0)), + size(DevicePixels(10), DevicePixels(10)), + ), + }, + transformation: TransformationMatrix::unit(), + } + } + + fn test_subpixel_sprite() -> SubpixelSprite { + let sprite = test_sprite(); + + SubpixelSprite { + order: sprite.order, + pad: sprite.pad, + bounds: sprite.bounds, + content_mask: sprite.content_mask, + color: sprite.color, + tile: sprite.tile, + transformation: sprite.transformation, + } + } +} + #[derive(Clone, Debug)] #[repr(C)] #[expect(missing_docs)] diff --git a/crates/gpui/src/style.rs b/crates/gpui/src/style.rs index 4e61a2c927ef0c..649d2bd69a643b 100644 --- a/crates/gpui/src/style.rs +++ b/crates/gpui/src/style.rs @@ -8,8 +8,8 @@ use crate::{ AbsoluteLength, App, Background, BackgroundTag, BorderStyle, Bounds, ContentMask, Corners, CornersRefinement, CursorStyle, DefiniteLength, DevicePixels, Edges, EdgesRefinement, Font, FontFallbacks, FontFeatures, FontStyle, FontWeight, GridLocation, Hsla, Length, Pixels, Point, - PointRefinement, Rgba, SharedString, Size, SizeRefinement, Styled, TextRun, Window, black, phi, - point, px, quad, rems, size, + PointRefinement, Rgba, ScaledPixels, SharedString, Size, SizeRefinement, Styled, TextRun, + TransformationMatrix, Window, black, phi, point, px, quad, rems, size, }; use collections::HashSet; use refineable::Refineable; @@ -305,6 +305,9 @@ pub struct Style { /// The opacity of this element pub opacity: Option, + /// The transform applied to this element and its descendants after layout. + pub transform: Option, + /// The grid columns of this element /// Roughly equivalent to the Tailwind `grid-cols-` pub grid_cols: Option, @@ -325,6 +328,62 @@ pub struct Style { pub debug_below: bool, } +/// A 2D transform applied to a styled element subtree after layout. +#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct ElementTransform { + matrix: TransformationMatrix, + origin: Point, +} + +impl ElementTransform { + /// Moves the transformed subtree by the given pixel offset. + pub fn translate(mut self, offset: Point) -> Self { + self.matrix = self.matrix.compose(TransformationMatrix { + rotation_scale: [[1.0, 0.0], [0.0, 1.0]], + translation: [offset.x.0, offset.y.0], + }); + self + } + + /// Scales the transformed subtree. + pub fn scale(mut self, scale: Size) -> Self { + self.matrix = self.matrix.scale(scale); + self + } + + /// Sets the transform origin as fractions of the element bounds. + pub fn origin(mut self, origin: Point) -> Self { + self.origin = origin; + self + } + + pub(crate) fn resolve( + &self, + bounds: Bounds, + scale_factor: f32, + ) -> TransformationMatrix { + let origin = point( + ScaledPixels((bounds.origin.x.0 + bounds.size.width.0 * self.origin.x) * scale_factor), + ScaledPixels((bounds.origin.y.0 + bounds.size.height.0 * self.origin.y) * scale_factor), + ); + let matrix = TransformationMatrix { + rotation_scale: self.matrix.rotation_scale, + translation: [ + self.matrix.translation[0] * scale_factor, + self.matrix.translation[1] * scale_factor, + ], + }; + + TransformationMatrix::unit() + .translate(origin) + .compose(matrix) + .compose( + TransformationMatrix::unit() + .translate(point(ScaledPixels(-origin.x.0), ScaledPixels(-origin.y.0))), + ) + } +} + impl Styled for StyleRefinement { fn style(&mut self) -> &mut StyleRefinement { self @@ -854,6 +913,7 @@ impl Default for Style { text: TextStyleRefinement::default(), mouse_cursor: None, opacity: None, + transform: None, grid_rows: None, grid_cols: None, grid_location: None, diff --git a/crates/gpui/src/styled.rs b/crates/gpui/src/styled.rs index 2e1d482860071b..e7743a15d5803d 100644 --- a/crates/gpui/src/styled.rs +++ b/crates/gpui/src/styled.rs @@ -1,8 +1,8 @@ use crate::{ self as gpui, AbsoluteLength, AlignContent, AlignItems, AlignSelf, BorderStyle, CursorStyle, - DefiniteLength, Display, Fill, FlexDirection, FlexWrap, Font, FontFeatures, FontStyle, - FontWeight, GridPlacement, GridTemplate, GridTemplateMinSize, Hsla, JustifyContent, Length, - SharedString, StrikethroughStyle, StyleRefinement, TextAlign, TextOverflow, + DefiniteLength, Display, ElementTransform, Fill, FlexDirection, FlexWrap, Font, FontFeatures, + FontStyle, FontWeight, GridPlacement, GridTemplate, GridTemplateMinSize, Hsla, JustifyContent, + Length, SharedString, StrikethroughStyle, StyleRefinement, TextAlign, TextOverflow, TextStyleRefinement, UnderlineStyle, WhiteSpace, px, relative, rems, }; pub use gpui_macros::{ @@ -503,6 +503,12 @@ pub trait Styled: Sized { self } + /// Sets the transform applied to this element and its descendants after layout. + fn transform(mut self, transform: ElementTransform) -> Self { + self.style().transform = Some(transform); + self + } + /// Sets the border style of the element. fn border_dashed(mut self) -> Self { self.style().border_style = Some(BorderStyle::Dashed); diff --git a/crates/gpui/src/text_system/line.rs b/crates/gpui/src/text_system/line.rs index 15101e6a2f39b5..2580ccd3620de6 100644 --- a/crates/gpui/src/text_system/line.rs +++ b/crates/gpui/src/text_system/line.rs @@ -1,7 +1,7 @@ use crate::{ App, Bounds, DevicePixels, Half, Hsla, LineLayout, Pixels, Point, RenderGlyphParams, Result, - SharedString, StrikethroughStyle, TextAlign, UnderlineStyle, Window, WrapBoundary, - WrappedLineLayout, black, fill, point, px, size, + ScaledPixels, SharedString, StrikethroughStyle, TextAlign, TransformationMatrix, + UnderlineStyle, Window, WrapBoundary, WrappedLineLayout, black, fill, point, px, size, }; use derive_more::{Deref, DerefMut}; use smallvec::SmallVec; @@ -532,7 +532,12 @@ fn paint_line( }; let content_mask = window.content_mask(); - if max_glyph_bounds.intersects(&content_mask.bounds) { + if glyph_bounds_intersects_content_mask( + max_glyph_bounds, + content_mask.bounds, + window.current_transform(), + window.scale_factor(), + ) { let vertical_offset = point(px(0.0), glyph.position.y); if glyph.is_emoji { window.paint_emoji( @@ -759,6 +764,22 @@ fn aligned_origin_x( } } +fn glyph_bounds_intersects_content_mask( + glyph_bounds: Bounds, + content_mask_bounds: Bounds, + transform: TransformationMatrix, + scale_factor: f32, +) -> bool { + let visual_bounds = if transform == TransformationMatrix::unit() { + glyph_bounds + } else { + crate::scene::transform_bounds(glyph_bounds.scale(scale_factor), transform) + .map(|coordinate: ScaledPixels| px(coordinate.0 / scale_factor)) + }; + + visual_bounds.intersects(&content_mask_bounds) +} + #[cfg(test)] mod tests { use super::*; @@ -1022,4 +1043,33 @@ mod tests { assert_eq!(right.decoration_runs[1].len, 1); assert_eq!(right.decoration_runs[1].color, blue); } + + #[test] + fn translated_glyph_culling_uses_visual_bounds() { + let glyph_bounds = Bounds::new(point(px(0.0), px(0.0)), size(px(10.0), px(10.0))); + let content_mask = Bounds::new(point(px(100.0), px(0.0)), size(px(10.0), px(10.0))); + let transform = + TransformationMatrix::unit().translate(point(ScaledPixels(100.0), ScaledPixels(0.0))); + + assert!(glyph_bounds_intersects_content_mask( + glyph_bounds, + content_mask, + transform, + 1.0 + )); + } + + #[test] + fn scaled_glyph_culling_uses_visual_bounds() { + let glyph_bounds = Bounds::new(point(px(10.0), px(10.0)), size(px(10.0), px(10.0))); + let content_mask = Bounds::new(point(px(20.0), px(20.0)), size(px(20.0), px(20.0))); + let transform = TransformationMatrix::unit().scale(size(2.0, 2.0)); + + assert!(glyph_bounds_intersects_content_mask( + glyph_bounds, + content_mask, + transform, + 1.0 + )); + } } diff --git a/crates/gpui/src/view.rs b/crates/gpui/src/view.rs index f8343be29a09d4..e294934b0330ee 100644 --- a/crates/gpui/src/view.rs +++ b/crates/gpui/src/view.rs @@ -1,7 +1,8 @@ use crate::{ AnyElement, AnyEntity, AnyWeakEntity, App, Bounds, ContentMask, Context, Element, ElementId, Entity, EntityId, GlobalElementId, InspectorElementId, IntoElement, LayoutId, PaintIndex, - Pixels, PrepaintStateIndex, Render, RenderOnce, Style, StyleRefinement, TextStyle, WeakEntity, + Pixels, PrepaintStateIndex, Render, RenderOnce, Style, StyleRefinement, TextStyle, + TransformationMatrix, WeakEntity, }; use crate::{Empty, Window}; use anyhow::Result; @@ -100,6 +101,43 @@ impl IntoElement for Entity { } } +#[cfg(test)] +mod tests { + use super::*; + use crate::{ScaledPixels, point, px, size}; + + fn bounds() -> Bounds { + Bounds::new(point(px(0.), px(0.)), size(px(10.), px(10.))) + } + + fn cache_key(transform: TransformationMatrix) -> ViewElementCacheKey { + let bounds = bounds(); + ViewElementCacheKey { + bounds, + content_mask: ContentMask { bounds }, + text_style: TextStyle::default(), + transform, + } + } + + #[test] + fn view_cache_key_rejects_a_changed_transform() { + let unit = TransformationMatrix::unit(); + let translated = + unit.translate(point(ScaledPixels(10.), ScaledPixels(0.))); + let key = cache_key(unit); + let bounds = bounds(); + + assert!(key.matches(bounds, ContentMask { bounds }, &TextStyle::default(), unit)); + assert!(!key.matches( + bounds, + ContentMask { bounds }, + &TextStyle::default(), + translated + )); + } +} + impl IntoElement for AnyView { type Element = ViewElement; @@ -293,6 +331,22 @@ struct ViewElementCacheKey { bounds: Bounds, content_mask: ContentMask, text_style: TextStyle, + transform: TransformationMatrix, +} + +impl ViewElementCacheKey { + fn matches( + &self, + bounds: Bounds, + content_mask: ContentMask, + text_style: &TextStyle, + transform: TransformationMatrix, + ) -> bool { + self.bounds == bounds + && self.content_mask == content_mask + && self.text_style == *text_style + && self.transform == transform + } } impl Element for ViewElement { @@ -382,11 +436,12 @@ impl Element for ViewElement { |element_state, window| { let content_mask = window.content_mask(); let text_style = window.text_style(); + let transform = window.current_transform(); if let Some(mut element_state) = element_state - && element_state.cache_key.bounds == bounds - && element_state.cache_key.content_mask == content_mask - && element_state.cache_key.text_style == text_style + && element_state + .cache_key + .matches(bounds, content_mask, &text_style, transform) && !window.dirty_views.contains(&entity_id) && !window.refreshing { @@ -427,6 +482,7 @@ impl Element for ViewElement { bounds, content_mask, text_style, + transform, }, }, ) diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index 4c18b1c46ddbc3..c9b48efa7438ac 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -8,21 +8,21 @@ use crate::{ Action, AnyDrag, AnyElement, AnyImageCache, AnyTooltip, AnyView, App, AppContext, Arena, Asset, AsyncWindowContext, AtlasTile, AvailableSpace, Background, BorderStyle, Bounds, BoxShadow, Capslock, Context, Corners, CursorHideMode, CursorStyle, Decorations, DevicePixels, - DispatchActionListener, DispatchNodeId, DispatchTree, DisplayId, Edges, Effect, Entity, - EntityId, EventEmitter, FileDropEvent, FontId, Global, GlobalElementId, GlyphId, GpuSpecs, - Hsla, InputHandler, IsZero, KeyBinding, KeyContext, KeyDownEvent, KeyEvent, KeyListener, - Keystroke, KeystrokeEvent, LayoutId, LineLayoutIndex, Modifiers, ModifiersChangedEvent, - MonochromeSprite, MouseButton, MouseEvent, MouseMoveEvent, MouseUpEvent, Path, Pixels, - PlatformAtlas, PlatformDisplay, PlatformInput, PlatformInputHandler, PlatformWindow, Point, - PolychromeSprite, Priority, PromptButton, PromptLevel, Quad, Render, RenderGlyphParams, - RenderImage, RenderImageParams, RenderSvgParams, Replay, ResizeEdge, SMOOTH_SVG_SCALE_FACTOR, - SUBPIXEL_VARIANTS_X, SUBPIXEL_VARIANTS_Y, ScaledPixels, Scene, Shadow, SharedString, Size, - StrikethroughStyle, Style, SubpixelSprite, SubscriberSet, Subscription, SystemWindowTab, - SystemWindowTabController, TabStopMap, TaffyLayoutEngine, Task, TextRenderingMode, TextStyle, - TextStyleRefinement, ThermalState, TransformationMatrix, Underline, UnderlineStyle, - WindowAppearance, WindowBackgroundAppearance, WindowBounds, WindowControls, WindowDecorations, - WindowInsets, WindowOptions, WindowParams, WindowTextSystem, point, prelude::*, px, rems, size, - transparent_black, + DispatchActionListener, DispatchNodeId, DispatchTree, DisplayId, Edges, Effect, + ElementTransform, Entity, EntityId, EventEmitter, FileDropEvent, FontId, Global, + GlobalElementId, GlyphId, GpuSpecs, Hsla, InputHandler, IsZero, KeyBinding, KeyContext, + KeyDownEvent, KeyEvent, KeyListener, Keystroke, KeystrokeEvent, LayoutId, LineLayoutIndex, + Modifiers, ModifiersChangedEvent, MonochromeSprite, MouseButton, MouseEvent, MouseMoveEvent, + MouseUpEvent, Path, Pixels, PlatformAtlas, PlatformDisplay, PlatformInput, + PlatformInputHandler, PlatformWindow, Point, PolychromeSprite, Priority, PromptButton, + PromptLevel, Quad, Render, RenderGlyphParams, RenderImage, RenderImageParams, RenderSvgParams, + Replay, ResizeEdge, SMOOTH_SVG_SCALE_FACTOR, SUBPIXEL_VARIANTS_X, SUBPIXEL_VARIANTS_Y, + ScaledPixels, Scene, Shadow, SharedString, Size, StrikethroughStyle, Style, SubpixelSprite, + SubscriberSet, Subscription, SystemWindowTab, SystemWindowTabController, TabStopMap, + TaffyLayoutEngine, Task, TextRenderingMode, TextStyle, TextStyleRefinement, ThermalState, + TransformationMatrix, Underline, UnderlineStyle, WindowAppearance, WindowBackgroundAppearance, + WindowBounds, WindowControls, WindowDecorations, WindowInsets, WindowOptions, WindowParams, + WindowTextSystem, point, prelude::*, px, rems, size, transparent_black, }; use anyhow::{Context as _, Result, anyhow}; @@ -75,6 +75,87 @@ use crate::util::{ }; pub use prompts::*; +const MIN_TEXT_RASTER_TRANSFORM_MULTIPLIER: f32 = 1.0; +const MAX_TEXT_RASTER_TRANSFORM_MULTIPLIER: f32 = 4.0; +const TEXT_RASTER_TRANSFORM_MULTIPLIER_STEP: f32 = 0.125; +const SUBPIXEL_SAFE_TRANSFORM_EPSILON: f32 = 0.001; + +fn transform_raster_multiplier(transform: TransformationMatrix) -> f32 { + let [[xx, xy], [yx, yy]] = transform.rotation_scale; + let x_scale = xx.hypot(yx); + let y_scale = xy.hypot(yy); + let multiplier = x_scale.max(y_scale); + + if !multiplier.is_finite() { + return MIN_TEXT_RASTER_TRANSFORM_MULTIPLIER; + } + + let multiplier = multiplier.clamp( + MIN_TEXT_RASTER_TRANSFORM_MULTIPLIER, + MAX_TEXT_RASTER_TRANSFORM_MULTIPLIER, + ); + + (multiplier / TEXT_RASTER_TRANSFORM_MULTIPLIER_STEP).round() + * TEXT_RASTER_TRANSFORM_MULTIPLIER_STEP +} + +fn glyph_raster_scale_factor(scale_factor: f32, transform: TransformationMatrix) -> f32 { + scale_factor * transform_raster_multiplier(transform) +} + +fn transform_allows_subpixel_rendering(transform: TransformationMatrix) -> bool { + let [[xx, xy], [yx, yy]] = transform.rotation_scale; + let x_scale = xx.hypot(yx); + let y_scale = xy.hypot(yy); + + if !x_scale.is_finite() || !y_scale.is_finite() { + return false; + } + + if !approximately_zero(xy) || !approximately_zero(yx) { + return false; + } + + if xx <= 0.0 || yy <= 0.0 || !approximately_equal(x_scale, y_scale) { + return false; + } + + let rounded_scale = x_scale.round(); + rounded_scale >= MIN_TEXT_RASTER_TRANSFORM_MULTIPLIER + && approximately_equal(x_scale, rounded_scale) +} + +fn approximately_zero(value: f32) -> bool { + value.abs() <= SUBPIXEL_SAFE_TRANSFORM_EPSILON +} + +fn approximately_equal(left: f32, right: f32) -> bool { + (left - right).abs() <= SUBPIXEL_SAFE_TRANSFORM_EPSILON +} + +fn compensate_glyph_sprite_bounds( + bounds: Bounds, + raster_multiplier: f32, +) -> Bounds { + if raster_multiplier == MIN_TEXT_RASTER_TRANSFORM_MULTIPLIER { + return bounds; + } + + bounds.map(|value| ScaledPixels(value.0 / raster_multiplier)) +} + +#[cfg(test)] +fn transformed_glyph_visual_bounds( + raster_bounds: Bounds, + transform: TransformationMatrix, +) -> Bounds { + let raster_multiplier = transform_raster_multiplier(transform); + crate::scene::transform_bounds( + compensate_glyph_sprite_bounds(raster_bounds, raster_multiplier), + transform, + ) +} + /// Default window size used when no explicit size is provided. pub const DEFAULT_WINDOW_SIZE: Size = size(px(1536.), px(1095.)); @@ -961,6 +1042,7 @@ pub(crate) struct DeferredDraw { text_style_stack: Vec, content_mask: Option>, rem_size: Pixels, + transform: TransformationMatrix, element: Option, absolute_offset: Point, prepaint_range: Range, @@ -1167,6 +1249,7 @@ pub struct Window { pub(crate) rendered_entity_stack: Vec, pub(crate) element_offset_stack: Vec>, pub(crate) element_opacity: f32, + pub(crate) transform_stack: Vec, pub(crate) content_mask_stack: Vec>, pub(crate) requested_autoscroll: Option>, pub(crate) image_cache_stack: Vec, @@ -1855,6 +1938,7 @@ impl Window { text_style_stack: Vec::new(), rendered_entity_stack: Vec::new(), element_offset_stack: Vec::new(), + transform_stack: Vec::new(), content_mask_stack: Vec::new(), element_opacity: 1.0, requested_autoscroll: None, @@ -3385,7 +3469,16 @@ impl Window { traversal_order.sort_by_key(|ix| self.next_frame.deferred_draws[*ix].priority); for deferred_draw_ix in traversal_order { - let (element, parent_node, current_view, rem_size, absolute_offset, prepaint_range) = { + let ( + element, + parent_node, + current_view, + rem_size, + absolute_offset, + content_mask, + transform, + prepaint_range, + ) = { let deferred_draw = &mut self.next_frame.deferred_draws[deferred_draw_ix]; self.element_id_stack .clone_from(&deferred_draw.element_id_stack); @@ -3397,6 +3490,8 @@ impl Window { deferred_draw.current_view, deferred_draw.rem_size, deferred_draw.absolute_offset, + deferred_draw.content_mask, + deferred_draw.transform, deferred_draw.prepaint_range.clone(), ) }; @@ -3405,9 +3500,16 @@ impl Window { let prepaint_start = self.prepaint_index(); if let Some(mut element) = element { self.with_rendered_view(current_view, |window| { - window.with_rem_size(Some(rem_size), |window| { - window.with_absolute_element_offset(absolute_offset, |window| { - element.prepaint(window, cx); + window.with_transform(transform, |window| { + window.with_content_mask(content_mask, |window| { + window.with_rem_size(Some(rem_size), |window| { + window.with_absolute_element_offset( + absolute_offset, + |window| { + element.prepaint(window, cx); + }, + ); + }); }); }); }); @@ -3447,11 +3549,14 @@ impl Window { let paint_start = self.paint_index(); let content_mask = deferred_draw.content_mask; + let transform = deferred_draw.transform; if let Some(element) = deferred_draw.element.as_mut() { self.with_rendered_view(deferred_draw.current_view, |window| { - window.with_content_mask(content_mask, |window| { - window.with_rem_size(Some(deferred_draw.rem_size), |window| { - element.paint(window, cx); + window.with_transform(transform, |window| { + window.with_content_mask(content_mask, |window| { + window.with_rem_size(Some(deferred_draw.rem_size), |window| { + element.paint(window, cx); + }); }); }) }) @@ -3525,6 +3630,7 @@ impl Window { text_style_stack: deferred_draw.text_style_stack.clone(), content_mask: deferred_draw.content_mask, rem_size: deferred_draw.rem_size, + transform: deferred_draw.transform, priority: deferred_draw.priority, element: None, absolute_offset: deferred_draw.absolute_offset, @@ -3654,7 +3760,10 @@ impl Window { ) -> R { self.invalidator.debug_assert_paint_or_prepaint(); if let Some(mask) = mask { - let mask = mask.intersect(&self.content_mask()); + let mask = ContentMask { + bounds: self.transform_bounds(mask.bounds), + } + .intersect(&self.content_mask()); self.content_mask_stack.push(mask); let result = f(self); self.content_mask_stack.pop(); @@ -3664,6 +3773,72 @@ impl Window { } } + pub(crate) fn with_transform( + &mut self, + transform: TransformationMatrix, + f: impl FnOnce(&mut Self) -> R, + ) -> R { + self.invalidator.debug_assert_paint_or_prepaint(); + + let transform = self.current_transform().compose(transform); + self.transform_stack.push(transform); + let result = f(self); + self.transform_stack.pop(); + result + } + + pub(crate) fn with_style_transform( + &mut self, + transform: Option, + bounds: Bounds, + f: impl FnOnce(&mut Self) -> R, + ) -> R { + if let Some(transform) = transform { + self.with_transform(transform.resolve(bounds, self.scale_factor()), f) + } else { + f(self) + } + } + + pub(crate) fn current_transform(&self) -> TransformationMatrix { + self.invalidator.debug_assert_paint_or_prepaint(); + self.transform_stack.last().copied().unwrap_or_default() + } + + /// Captures the current layout-to-window transform in logical pixels. + /// + /// Capture during prepaint or paint and retain it for event callbacks. Use + /// `inverse().map(|inverse| inverse.apply(event.position))` for local input, + /// and `apply_bounds` for painted bounds, automation and IME rectangles. + /// Do not compose the captured transform with device-pixel matrices. + pub fn element_transform(&self) -> TransformationMatrix { + let transform = self.current_transform(); + TransformationMatrix { + rotation_scale: transform.rotation_scale, + translation: transform + .translation + .map(|value| value / self.scale_factor()), + } + } + + /// Where layout `bounds` land on screen under the current transform. + fn transform_bounds(&self, bounds: Bounds) -> Bounds { + let transform = self.current_transform(); + if transform == TransformationMatrix::unit() { + return bounds; + } + + let scale_factor = self.scale_factor(); + crate::scene::transform_bounds(bounds.scale(scale_factor), transform) + .map(|value| px(value.0 / scale_factor)) + } + + fn insert_primitive(&mut self, primitive: impl Into) { + self.next_frame + .scene + .insert_transformed_primitive(primitive, self.current_transform()); + } + /// Updates the global element offset relative to the current offset. This is used to implement /// scrolling. This method should only be called during the prepaint phase of element drawing. pub fn with_element_offset( @@ -4031,6 +4206,7 @@ impl Window { text_style_stack: self.text_style_stack.clone(), content_mask, rem_size: self.rem_size(), + transform: self.current_transform(), priority, element: Some(element), absolute_offset, @@ -4048,11 +4224,11 @@ impl Window { self.invalidator.debug_assert_paint(); let content_mask = self.content_mask(); - let clipped_bounds = bounds.intersect(&content_mask.bounds); + let transformed_bounds = + crate::scene::transform_bounds(self.cover_bounds(bounds), self.current_transform()); + let clipped_bounds = transformed_bounds.intersect(&self.cover_bounds(content_mask.bounds)); if !clipped_bounds.is_empty() { - self.next_frame - .scene - .push_layer(self.cover_bounds(clipped_bounds)); + self.next_frame.scene.push_layer(clipped_bounds); } let result = f(self); @@ -4087,7 +4263,7 @@ impl Window { continue; } let shadow_bounds = (bounds + shadow.offset).dilate(shadow.spread_radius); - self.next_frame.scene.insert_primitive(Shadow { + self.insert_primitive(Shadow { order: 0, blur_radius: shadow.blur_radius.scale(scale_factor), bounds: self.cover_bounds(shadow_bounds), @@ -4213,7 +4389,7 @@ impl Window { }; if !quad.background.is_transparent() { - self.next_frame.scene.insert_primitive(quad); + self.insert_primitive(quad); return; } @@ -4222,8 +4398,9 @@ impl Window { let outer_bounds = quad.bounds; let inner_bounds = Self::largest_border_interior(&quad); - if inner_bounds.is_empty() { - self.next_frame.scene.insert_primitive(quad); + // Non-uniform transforms leave border widths unscaled, so local strips cannot mask them. + if inner_bounds.is_empty() || self.current_transform().uniform_scale().is_none() { + self.insert_primitive(quad); return; } @@ -4251,9 +4428,10 @@ impl Window { ]; for strip in strips { + let strip = crate::scene::transform_bounds(strip, self.current_transform()); let content_mask_bounds = quad.content_mask.bounds.intersect(&strip); if !content_mask_bounds.is_empty() { - self.next_frame.scene.insert_primitive(Quad { + self.insert_primitive(Quad { content_mask: ContentMask { bounds: content_mask_bounds, }, @@ -4275,9 +4453,7 @@ impl Window { path.content_mask = content_mask; let color: Background = color.into(); path.color = color.opacity(opacity); - self.next_frame - .scene - .insert_primitive(path.scale(scale_factor)); + self.insert_primitive(path.scale(scale_factor)); } /// Paint an underline into the scene for the next frame at the current z-index. @@ -4304,7 +4480,7 @@ impl Window { }; let element_opacity = self.element_opacity(); - self.next_frame.scene.insert_primitive(Underline { + self.insert_primitive(Underline { order: 0, pad: 0, bounds, @@ -4334,7 +4510,7 @@ impl Window { }; let opacity = self.element_opacity(); - self.next_frame.scene.insert_primitive(Underline { + self.insert_primitive(Underline { order: 0, pad: 0, bounds, @@ -4365,6 +4541,9 @@ impl Window { let element_opacity = self.element_opacity(); let scale_factor = self.scale_factor(); + let current_transform = self.current_transform(); + let raster_multiplier = transform_raster_multiplier(current_transform); + let effective_scale_factor = glyph_raster_scale_factor(scale_factor, current_transform); let glyph_origin = origin.scale(scale_factor); let quantized_origin = Point::new( @@ -4385,7 +4564,7 @@ impl Window { glyph_id, font_size, subpixel_variant, - scale_factor, + scale_factor: effective_scale_factor, is_emoji: false, subpixel_rendering, dilation, @@ -4400,14 +4579,21 @@ impl Window { Ok(Some((size, Cow::Owned(bytes)))) })? .expect("Callback above only errors or returns Some"); + let local_bounds = compensate_glyph_sprite_bounds( + Bounds { + origin: raster_bounds.origin.map(Into::into), + size: tile.bounds.size.map(Into::into), + }, + raster_multiplier, + ); let bounds = Bounds { - origin: integer_origin + raster_bounds.origin.map(Into::into), - size: tile.bounds.size.map(Into::into), + origin: integer_origin + local_bounds.origin, + size: local_bounds.size, }; let content_mask = self.snapped_content_mask(); if subpixel_rendering { - self.next_frame.scene.insert_primitive(SubpixelSprite { + self.insert_primitive(SubpixelSprite { order: 0, pad: 0, bounds, @@ -4417,7 +4603,7 @@ impl Window { transformation: TransformationMatrix::unit(), }); } else { - self.next_frame.scene.insert_primitive(MonochromeSprite { + self.insert_primitive(MonochromeSprite { order: 0, pad: 0, bounds, @@ -4440,6 +4626,10 @@ impl Window { return false; } + if !transform_allows_subpixel_rendering(self.current_transform()) { + return false; + } + let mode = match self.text_rendering_mode.get() { TextRenderingMode::PlatformDefault => self .text_system() @@ -4468,6 +4658,9 @@ impl Window { self.invalidator.debug_assert_paint(); let scale_factor = self.scale_factor(); + let current_transform = self.current_transform(); + let raster_multiplier = transform_raster_multiplier(current_transform); + let effective_scale_factor = glyph_raster_scale_factor(scale_factor, current_transform); let glyph_origin = origin.scale(scale_factor); let integer_origin = glyph_origin.map(|c| ScaledPixels(round_half_toward_zero(c.0))); let params = RenderGlyphParams { @@ -4475,7 +4668,7 @@ impl Window { glyph_id, font_size, subpixel_variant: Default::default(), - scale_factor, + scale_factor: effective_scale_factor, is_emoji: true, subpixel_rendering: false, dilation: 0, @@ -4491,14 +4684,21 @@ impl Window { })? .expect("Callback above only errors or returns Some"); + let local_bounds = compensate_glyph_sprite_bounds( + Bounds { + origin: raster_bounds.origin.map(Into::into), + size: tile.bounds.size.map(Into::into), + }, + raster_multiplier, + ); let bounds = Bounds { - origin: integer_origin + raster_bounds.origin.map(Into::into), - size: tile.bounds.size.map(Into::into), + origin: integer_origin + local_bounds.origin, + size: local_bounds.size, }; let content_mask = self.snapped_content_mask(); let opacity = self.element_opacity(); - self.next_frame.scene.insert_primitive(PolychromeSprite { + self.insert_primitive(PolychromeSprite { order: 0, nearest_neighbor: false.into(), grayscale: false.into(), @@ -4564,7 +4764,7 @@ impl Window { .map_origin(|value| ScaledPixels(round_half_toward_zero(value.0))) .map_size(|size| size.ceil()); - self.next_frame.scene.insert_primitive(MonochromeSprite { + self.insert_primitive(MonochromeSprite { order: 0, pad: 0, bounds: final_bounds, @@ -4712,7 +4912,7 @@ impl Window { .scale(self.scale_factor()); let opacity = self.element_opacity(); - self.next_frame.scene.insert_primitive(PolychromeSprite { + self.insert_primitive(PolychromeSprite { order: 0, nearest_neighbor: nearest_neighbor.into(), grayscale: grayscale.into(), @@ -4736,7 +4936,7 @@ impl Window { let bounds = self.snap_bounds(bounds); let content_mask = self.snapped_content_mask(); - self.next_frame.scene.insert_primitive(PaintSurface { + self.insert_primitive(PaintSurface { order: 0, bounds, content_mask, @@ -4934,13 +5134,16 @@ impl Window { if identity.is_some() && self.captured_identity == identity { self.captured_hitbox = Some(id); } + // `ElementTransform` only translates and scales, so the transformed + // box is exactly the region the element paints. let hitbox = Hitbox { id, - bounds, + bounds: self.transform_bounds(bounds), content_mask, behavior, identity, }; + self.next_frame.hitboxes.push(hitbox.clone()); hitbox } @@ -7960,3 +8163,857 @@ mod tests { assert_eq!(b_focus_count.get(), 1); } } + +#[cfg(test)] +mod transform_tests { + use super::*; + use crate::{ + Element, ElementTransform, Empty, InspectorElementId, IntoElement, ParentElement, Styled, + TestAppContext, div, white, + }; + + struct TestQuadElement { + bounds: Bounds, + color: Hsla, + } + + impl TestQuadElement { + fn new(bounds: Bounds, color: Hsla) -> Self { + Self { bounds, color } + } + } + + impl Element for TestQuadElement { + type RequestLayoutState = (); + type PrepaintState = (); + + fn id(&self) -> Option { + None + } + + fn source_location(&self) -> Option<&'static std::panic::Location<'static>> { + None + } + + fn request_layout( + &mut self, + _id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + window: &mut Window, + cx: &mut App, + ) -> (LayoutId, Self::RequestLayoutState) { + let layout_id = window.request_layout( + Style { + display: crate::Display::None, + ..Default::default() + }, + None, + cx, + ); + + (layout_id, ()) + } + + fn prepaint( + &mut self, + _id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + _bounds: Bounds, + _request_layout: &mut Self::RequestLayoutState, + _window: &mut Window, + _cx: &mut App, + ) { + } + + fn paint( + &mut self, + _id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + _bounds: Bounds, + _request_layout: &mut Self::RequestLayoutState, + _prepaint: &mut Self::PrepaintState, + window: &mut Window, + _cx: &mut App, + ) { + window.paint_quad(fill(self.bounds, self.color)); + } + } + + impl IntoElement for TestQuadElement { + type Element = Self; + + fn into_element(self) -> Self::Element { + self + } + } + + struct TestHitboxElement { + bounds: Bounds, + } + + impl TestHitboxElement { + fn new(bounds: Bounds) -> Self { + Self { bounds } + } + } + + impl Element for TestHitboxElement { + type RequestLayoutState = (); + type PrepaintState = (); + + fn id(&self) -> Option { + None + } + + fn source_location(&self) -> Option<&'static std::panic::Location<'static>> { + None + } + + fn request_layout( + &mut self, + _id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + window: &mut Window, + cx: &mut App, + ) -> (LayoutId, Self::RequestLayoutState) { + let layout_id = window.request_layout( + Style { + display: crate::Display::None, + ..Default::default() + }, + None, + cx, + ); + + (layout_id, ()) + } + + fn prepaint( + &mut self, + _id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + _bounds: Bounds, + _request_layout: &mut Self::RequestLayoutState, + window: &mut Window, + _cx: &mut App, + ) { + window.insert_hitbox(self.bounds, HitboxBehavior::Normal); + } + + fn paint( + &mut self, + _id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + _bounds: Bounds, + _request_layout: &mut Self::RequestLayoutState, + _prepaint: &mut Self::PrepaintState, + _window: &mut Window, + _cx: &mut App, + ) { + } + } + + impl IntoElement for TestHitboxElement { + type Element = Self; + + fn into_element(self) -> Self::Element { + self + } + } + + fn paint_test_root(root: impl IntoElement, cx: &mut TestAppContext) -> Vec { + let window = cx.add_window(|_, _| Empty); + let current_view = window.root(cx).expect("root view should exist").entity_id(); + let window = window.into(); + + cx.update_window(window, |_, window, cx| { + window.next_frame.scene.clear(); + window.next_frame.dispatch_tree.clear(); + + let mut root = root.into_any_element(); + window.with_rendered_view(current_view, |window| { + window.invalidator.set_phase(DrawPhase::Prepaint); + root.prepaint_as_root( + Point::default(), + size(px(100.), px(100.)).into(), + window, + cx, + ); + + window.invalidator.set_phase(DrawPhase::Paint); + root.paint(window, cx); + }); + + window.invalidator.set_phase(DrawPhase::None); + window.next_frame.scene.quads.clone() + }) + .expect("test window should still exist") + } + + #[gpui::test] + fn captured_element_transform_converts_input_after_scope_ends(cx: &mut TestAppContext) { + let window = cx.add_window(|_, _| Empty); + cx.update_window(window.into(), |_, window, _| { + window.invalidator.set_phase(DrawPhase::Prepaint); + let bounds = Bounds::new(point(px(10.), px(20.)), size(px(20.), px(20.))); + let captured = window.with_style_transform( + Some(ElementTransform::default().translate(point(px(5.), px(6.)))), + bounds, + |window| { + window.with_style_transform( + Some( + ElementTransform::default() + .origin(point(0.5, 0.5)) + .translate(point(px(40.), px(10.))) + .scale(size(2., 2.)), + ), + bounds, + |window| window.element_transform(), + ) + }, + ); + assert_eq!(window.element_transform(), TransformationMatrix::unit()); + window.invalidator.set_phase(DrawPhase::None); + + let local = point(px(15.), px(25.)); + let screen = point(px(55.), px(36.)); + assert_eq!(captured.apply(local), screen); + assert_eq!( + captured.inverse().expect("invertible zoom").apply(screen), + local + ); + assert_eq!( + captured.apply_bounds(bounds), + Bounds::new(point(px(45.), px(26.)), size(px(40.), px(40.))) + ); + assert!( + TransformationMatrix::unit() + .scale(size(0., 1.)) + .inverse() + .is_none() + ); + assert!( + TransformationMatrix::unit() + .translate(point(ScaledPixels(f32::NAN), ScaledPixels(0.))) + .inverse() + .is_none() + ); + }) + .expect("test window should still exist"); + } + + #[gpui::test] + fn transformed_border_only_quads_keep_complete_geometry(cx: &mut TestAppContext) { + let quads = paint_test_root( + div() + .w(px(20.)) + .h(px(20.)) + .border_1() + .border_color(white()) + .transform(ElementTransform::default().translate(point(px(40.), px(0.)))), + cx, + ); + + assert_eq!(quads.len(), 4); + for quad in quads { + assert!(!quad.bounds.intersect(&quad.content_mask.bounds).is_empty()); + assert!(quad.content_mask.bounds.left() >= ScaledPixels(80.)); + } + + let quads = paint_test_root( + div() + .w(px(20.)) + .h(px(20.)) + .border_1() + .border_color(white()) + .transform(ElementTransform::default().scale(size(1., 0.5))), + cx, + ); + let top_border_point = point(ScaledPixels(20.), ScaledPixels(1.5)); + assert!(quads.iter().any(|quad| { + quad.border_widths.top == ScaledPixels(2.) + && quad.bounds.contains(&top_border_point) + && quad.content_mask.bounds.contains(&top_border_point) + })); + } + + #[gpui::test] + fn public_style_transform_translates_subtree(cx: &mut TestAppContext) { + let quads = paint_test_root( + div() + .w(px(20.)) + .h(px(20.)) + .transform(ElementTransform::default().translate(point(px(5.), px(10.)))) + .child(TestQuadElement::new( + Bounds::new(point(px(1.), px(2.)), size(px(3.), px(4.))), + white(), + )), + cx, + ); + + assert_eq!(quads.len(), 1); + + let Some(quad) = quads.first() else { + panic!("styled transform should emit one quad"); + }; + assert_eq!( + quad.bounds.origin, + point(ScaledPixels(12.), ScaledPixels(24.)) + ); + assert_eq!(quad.bounds.size, size(ScaledPixels(6.), ScaledPixels(8.))); + } + + #[gpui::test] + fn public_style_transform_scales_around_origin(cx: &mut TestAppContext) { + let quads = paint_test_root( + div() + .w(px(20.)) + .h(px(20.)) + .transform( + ElementTransform::default() + .scale(size(2., 2.)) + .origin(point(0.5, 0.5)), + ) + .child(TestQuadElement::new( + Bounds::new(point(px(11.), px(12.)), size(px(3.), px(4.))), + white(), + )), + cx, + ); + + assert_eq!(quads.len(), 1); + + let Some(quad) = quads.first() else { + panic!("origin-scaled transform should emit one quad"); + }; + assert_eq!( + quad.bounds.origin, + point(ScaledPixels(24.), ScaledPixels(28.)) + ); + assert_eq!(quad.bounds.size, size(ScaledPixels(12.), ScaledPixels(16.))); + } + + #[gpui::test] + fn public_style_transform_composes_nested_origins(cx: &mut TestAppContext) { + let quads = paint_test_root( + div() + .w(px(20.)) + .h(px(20.)) + .transform( + ElementTransform::default() + .scale(size(2., 2.)) + .origin(point(0.5, 0.5)), + ) + .child( + div() + .w(px(10.)) + .h(px(10.)) + .transform( + ElementTransform::default() + .scale(size(2., 2.)) + .origin(point(0.5, 0.5)), + ) + .child(TestQuadElement::new( + Bounds::new(point(px(6.), px(7.)), size(px(2.), px(3.))), + white(), + )), + ), + cx, + ); + + assert_eq!(quads.len(), 1); + + let Some(quad) = quads.first() else { + panic!("nested origin transforms should emit one quad"); + }; + assert_eq!( + quad.bounds.origin, + point(ScaledPixels(8.), ScaledPixels(16.)) + ); + assert_eq!(quad.bounds.size, size(ScaledPixels(16.), ScaledPixels(24.))); + } + + #[gpui::test] + fn window_with_transform_applies_to_painted_quad(cx: &mut TestAppContext) { + let window = cx.add_window(|_, _| Empty); + let window = window.into(); + + let quads = cx + .update_window(window, |_, window, _| { + window.next_frame.scene.clear(); + window.invalidator.set_phase(DrawPhase::Paint); + + let transform = TransformationMatrix::unit() + .translate(point(ScaledPixels(10.), ScaledPixels(20.))); + window.with_transform(transform, |window| { + window.paint_quad(fill( + Bounds::new(point(px(1.), px(2.)), size(px(3.), px(4.))), + white(), + )); + }); + + assert_eq!(window.current_transform(), TransformationMatrix::default()); + + window.invalidator.set_phase(DrawPhase::None); + window.next_frame.scene.quads.clone() + }) + .expect("test window should still exist"); + + assert_eq!(quads.len(), 1); + + let Some(quad) = quads.first() else { + panic!("window transform should emit one quad"); + }; + assert_eq!( + quad.bounds.origin, + point(ScaledPixels(12.), ScaledPixels(24.)) + ); + assert_eq!(quad.bounds.size, size(ScaledPixels(6.), ScaledPixels(8.))); + } + + #[test] + fn transform_raster_multiplier_is_quantized_and_clamped() { + assert_eq!( + transform_raster_multiplier(TransformationMatrix::unit()), + 1.0 + ); + assert_eq!( + transform_raster_multiplier(TransformationMatrix::unit().scale(size(1.24, 1.24))), + 1.25 + ); + assert_eq!( + transform_raster_multiplier(TransformationMatrix::unit().scale(size(1.26, 1.26))), + 1.25 + ); + assert_eq!( + transform_raster_multiplier(TransformationMatrix::unit().scale(size(8.0, 8.0))), + 4.0 + ); + } + + #[test] + fn transform_raster_multiplier_ignores_translation() { + let base = TransformationMatrix::unit().scale(size(2.0, 2.0)); + let translated = base.translate(point(ScaledPixels(100.0), ScaledPixels(50.0))); + + assert_eq!( + transform_raster_multiplier(base), + transform_raster_multiplier(translated) + ); + + let base_params = RenderGlyphParams { + font_id: FontId(1), + glyph_id: GlyphId(2), + font_size: px(16.0), + subpixel_variant: point(0, 0), + scale_factor: glyph_raster_scale_factor(1.0, base), + is_emoji: false, + subpixel_rendering: false, + dilation: 0, + }; + let translated_params = RenderGlyphParams { + scale_factor: glyph_raster_scale_factor(1.0, translated), + ..base_params + }; + + assert_eq!(base_params, translated_params); + + let mut base_hasher = collections::FxHasher::default(); + base_params.hash(&mut base_hasher); + + let mut translated_hasher = collections::FxHasher::default(); + translated_params.hash(&mut translated_hasher); + + assert_eq!(base_hasher.finish(), translated_hasher.finish()); + } + + #[test] + fn zoom_transform_increases_raster_scale_and_compensates_sprite_bounds() { + let zoom_transform = TransformationMatrix::unit().scale(size(2.0, 2.0)); + let raster_multiplier = transform_raster_multiplier(zoom_transform); + + assert_eq!( + glyph_raster_scale_factor(1.0, TransformationMatrix::unit()), + 1.0 + ); + assert_eq!(glyph_raster_scale_factor(1.0, zoom_transform), 2.0); + + let raster_bounds = Bounds::new( + point(ScaledPixels(4.0), ScaledPixels(6.0)), + size(ScaledPixels(20.0), ScaledPixels(30.0)), + ); + let compensated_bounds = compensate_glyph_sprite_bounds(raster_bounds, raster_multiplier); + + assert_eq!( + compensated_bounds.origin, + point(ScaledPixels(2.0), ScaledPixels(3.0)) + ); + assert_eq!( + compensated_bounds.size, + size(ScaledPixels(10.0), ScaledPixels(15.0)) + ); + } + + #[test] + fn transform_subpixel_policy_rejects_fractional_and_anisotropic_scale() { + assert!(!transform_allows_subpixel_rendering( + TransformationMatrix::unit().scale(size(1.25, 1.25)) + )); + assert!(!transform_allows_subpixel_rendering( + TransformationMatrix::unit().scale(size(2.0, 1.0)) + )); + assert!(!transform_allows_subpixel_rendering( + TransformationMatrix::unit().scale(size(1.0, 2.0)) + )); + } + + #[test] + fn transform_subpixel_policy_preserves_unit_and_integer_safe_scale() { + assert!(transform_allows_subpixel_rendering( + TransformationMatrix::unit() + )); + assert!(transform_allows_subpixel_rendering( + TransformationMatrix::unit().scale(size(2.0, 2.0)) + )); + assert!(transform_allows_subpixel_rendering( + TransformationMatrix::unit() + .scale(size(2.0, 2.0)) + .translate(point(ScaledPixels(100.0), ScaledPixels(50.0))) + )); + } + + #[test] + fn transformed_glyph_visual_bounds_match_scaled_layout_bounds() { + let raster_bounds = Bounds::new( + point(ScaledPixels(4.0), ScaledPixels(6.0)), + size(ScaledPixels(20.0), ScaledPixels(30.0)), + ); + let transform = TransformationMatrix::unit().scale(size(2.0, 2.0)); + + assert_eq!( + transformed_glyph_visual_bounds(raster_bounds, transform), + Bounds::new( + point(ScaledPixels(4.0), ScaledPixels(6.0)), + size(ScaledPixels(20.0), ScaledPixels(30.0)) + ) + ); + } + + #[gpui::test] + fn hit_test_uses_transformed_visual_region(cx: &mut TestAppContext) { + let window = cx.add_window(|_, _| Empty); + let window = window.into(); + + let (hitbox_id, hitbox_bounds, visual_hit, local_hit) = cx + .update_window(window, |_, window, _| { + window.next_frame.hitboxes.clear(); + window.invalidator.set_phase(DrawPhase::Prepaint); + + let transform = TransformationMatrix::unit() + .translate(point(ScaledPixels(10.), ScaledPixels(20.))); + let hitbox = window.with_transform(transform, |window| { + window.insert_hitbox( + Bounds::new(point(px(1.), px(2.)), size(px(3.), px(4.))), + HitboxBehavior::Normal, + ) + }); + + assert_eq!(window.current_transform(), TransformationMatrix::default()); + + let visual_hit = window.next_frame.hit_test(point(px(6.), px(12.))); + let local_hit = window.next_frame.hit_test(point(px(1.), px(2.))); + + window.invalidator.set_phase(DrawPhase::None); + (hitbox.id, hitbox.bounds, visual_hit, local_hit) + }) + .expect("test window should still exist"); + + assert_eq!(hitbox_bounds.origin, point(px(6.), px(12.))); + assert_eq!(hitbox_bounds.size, size(px(3.), px(4.))); + assert_eq!(visual_hit.ids.as_slice(), &[hitbox_id]); + assert_eq!(visual_hit.hover_hitbox_count, 1); + assert!(local_hit.ids.is_empty()); + assert_eq!(local_hit.hover_hitbox_count, 0); + } + + #[gpui::test] + fn hit_test_uses_scaled_visual_region(cx: &mut TestAppContext) { + let window = cx.add_window(|_, _| Empty); + let window = window.into(); + + let (hitbox_id, hitbox_bounds, inside_hit, outside_hit) = cx + .update_window(window, |_, window, _| { + window.next_frame.hitboxes.clear(); + window.invalidator.set_phase(DrawPhase::Prepaint); + + let transform = TransformationMatrix::unit().scale(size(2., 2.)); + let hitbox = window.with_transform(transform, |window| { + window.insert_hitbox( + Bounds::new(point(px(2.), px(3.)), size(px(4.), px(5.))), + HitboxBehavior::Normal, + ) + }); + + let inside_hit = window.next_frame.hit_test(point(px(10.), px(12.))); + let outside_hit = window.next_frame.hit_test(point(px(13.), px(17.))); + + window.invalidator.set_phase(DrawPhase::None); + (hitbox.id, hitbox.bounds, inside_hit, outside_hit) + }) + .expect("test window should still exist"); + + assert_eq!(hitbox_bounds.origin, point(px(4.), px(6.))); + assert_eq!(hitbox_bounds.size, size(px(8.), px(10.))); + assert_eq!(inside_hit.ids.as_slice(), &[hitbox_id]); + assert_eq!(inside_hit.hover_hitbox_count, 1); + assert!(outside_hit.ids.is_empty()); + assert_eq!(outside_hit.hover_hitbox_count, 0); + } + + #[gpui::test] + fn non_transformed_hit_test_still_uses_inserted_bounds(cx: &mut TestAppContext) { + let window = cx.add_window(|_, _| Empty); + let window = window.into(); + + let (hitbox_id, inside_hit, outside_hit) = cx + .update_window(window, |_, window, _| { + window.next_frame.hitboxes.clear(); + window.invalidator.set_phase(DrawPhase::Prepaint); + + let hitbox = window.insert_hitbox( + Bounds::new(point(px(1.), px(2.)), size(px(3.), px(4.))), + HitboxBehavior::Normal, + ); + + let inside_hit = window.next_frame.hit_test(point(px(1.), px(2.))); + let outside_hit = window.next_frame.hit_test(point(px(6.), px(12.))); + + window.invalidator.set_phase(DrawPhase::None); + (hitbox.id, inside_hit, outside_hit) + }) + .expect("test window should still exist"); + + assert_eq!(inside_hit.ids.as_slice(), &[hitbox_id]); + assert_eq!(inside_hit.hover_hitbox_count, 1); + assert!(outside_hit.ids.is_empty()); + assert_eq!(outside_hit.hover_hitbox_count, 0); + } + + #[gpui::test] + fn content_mask_entered_under_transform_clips_transformed_quad(cx: &mut TestAppContext) { + let window = cx.add_window(|_, _| Empty); + let window = window.into(); + + let quads = cx + .update_window(window, |_, window, _| { + window.next_frame.scene.clear(); + window.invalidator.set_phase(DrawPhase::Paint); + + let transform = TransformationMatrix::unit() + .translate(point(ScaledPixels(10.), ScaledPixels(20.))); + let mask = ContentMask { + bounds: Bounds::new(point(px(1.), px(2.)), size(px(3.), px(4.))), + }; + + window.with_transform(transform, |window| { + window.with_content_mask(Some(mask), |window| { + window.paint_quad(fill( + Bounds::new(point(px(1.), px(2.)), size(px(3.), px(4.))), + white(), + )); + }); + }); + + assert_eq!(window.current_transform(), TransformationMatrix::default()); + assert_eq!(window.content_mask_stack.len(), 0); + + window.invalidator.set_phase(DrawPhase::None); + window.next_frame.scene.quads.clone() + }) + .expect("test window should still exist"); + + assert_eq!(quads.len(), 1); + + let Some(quad) = quads.first() else { + panic!("transformed mask should retain the transformed quad"); + }; + assert_eq!( + quad.bounds.origin, + point(ScaledPixels(12.), ScaledPixels(24.)) + ); + assert_eq!(quad.bounds.size, size(ScaledPixels(6.), ScaledPixels(8.))); + assert_eq!(quad.content_mask.bounds.origin, quad.bounds.origin); + assert_eq!(quad.content_mask.bounds.size, quad.bounds.size); + } + + #[gpui::test] + fn paint_layer_under_transform_uses_transformed_content_mask(cx: &mut TestAppContext) { + let window = cx.add_window(|_, _| Empty); + let window = window.into(); + + let layer_bounds = cx + .update_window(window, |_, window, _| { + window.next_frame.scene.clear(); + window.invalidator.set_phase(DrawPhase::Paint); + + let transform = TransformationMatrix::unit() + .translate(point(ScaledPixels(10.), ScaledPixels(20.))); + let mask = ContentMask { + bounds: Bounds::new(point(px(1.), px(2.)), size(px(3.), px(4.))), + }; + + window.with_transform(transform, |window| { + window.with_content_mask(Some(mask), |window| { + window.paint_layer( + Bounds::new(point(px(1.), px(2.)), size(px(3.), px(4.))), + |window| { + window.paint_quad(fill( + Bounds::new(point(px(1.), px(2.)), size(px(3.), px(4.))), + white(), + )); + }, + ); + }); + }); + + assert_eq!(window.current_transform(), TransformationMatrix::default()); + assert_eq!(window.content_mask_stack.len(), 0); + + window.invalidator.set_phase(DrawPhase::None); + window.next_frame.scene.paint_operations.iter().find_map( + |operation| match operation { + crate::scene::PaintOperation::StartLayer(bounds) => Some(*bounds), + _ => None, + }, + ) + }) + .expect("test window should still exist"); + + let Some(layer_bounds) = layer_bounds else { + panic!("transformed paint_layer should push a layer"); + }; + assert_eq!( + layer_bounds.origin, + point(ScaledPixels(12.), ScaledPixels(24.)) + ); + assert_eq!(layer_bounds.size, size(ScaledPixels(6.), ScaledPixels(8.))); + } + + #[gpui::test] + fn defer_draw_under_transform_preserves_z_order_and_transform_scope(cx: &mut TestAppContext) { + let window = cx.add_window(|_, _| Empty); + let current_view = window.root(cx).expect("root view should exist").entity_id(); + let window = window.into(); + + let quads = cx + .update_window(window, |_, window, cx| { + window.next_frame.scene.clear(); + window.next_frame.dispatch_tree.clear(); + window.invalidator.set_phase(DrawPhase::Prepaint); + + let transform = TransformationMatrix::unit() + .translate(point(ScaledPixels(10.), ScaledPixels(20.))); + let deferred_bounds = Bounds::new(point(px(1.), px(2.)), size(px(3.), px(4.))); + let immediate_bounds = Bounds::new(point(px(6.), px(12.)), size(px(3.), px(4.))); + let parent_node = window.next_frame.dispatch_tree.push_node(); + + window.with_rendered_view(current_view, |window| { + window.with_transform(transform, |window| { + let mut element = + TestQuadElement::new(deferred_bounds, white()).into_any_element(); + element.request_layout(window, cx); + window.defer_draw(element, point(px(0.), px(0.)), 0, None); + }); + }); + + window.next_frame.dispatch_tree.pop_node(); + assert_eq!(window.current_transform(), TransformationMatrix::default()); + assert_eq!(window.next_frame.deferred_draws.len(), 1); + assert_eq!(window.next_frame.deferred_draws[0].parent_node, parent_node); + + window.prepaint_deferred_draws(cx); + + window.invalidator.set_phase(DrawPhase::Paint); + window.paint_quad(fill(immediate_bounds, white())); + window.paint_deferred_draws(cx); + + assert_eq!(window.current_transform(), TransformationMatrix::default()); + window.invalidator.set_phase(DrawPhase::None); + window.next_frame.scene.quads.clone() + }) + .expect("test window should still exist"); + + assert_eq!(quads.len(), 2); + + let Some(immediate_quad) = quads.first() else { + panic!("immediate quad should be painted before deferred quad"); + }; + assert_eq!( + immediate_quad.bounds.origin, + point(ScaledPixels(12.), ScaledPixels(24.)) + ); + + let Some(deferred_quad) = quads.get(1) else { + panic!("deferred quad should be painted after immediate quad"); + }; + assert_eq!( + deferred_quad.bounds.origin, + point(ScaledPixels(12.), ScaledPixels(24.)) + ); + assert_eq!( + deferred_quad.bounds.size, + size(ScaledPixels(6.), ScaledPixels(8.)) + ); + assert!(immediate_quad.order < deferred_quad.order); + } + + #[gpui::test] + fn defer_draw_prepaint_applies_content_mask_to_hitboxes(cx: &mut TestAppContext) { + let window = cx.add_window(|_, _| Empty); + let current_view = window.root(cx).expect("root view should exist").entity_id(); + let window = window.into(); + + cx.update_window(window, |_, window, cx| { + window.next_frame.dispatch_tree.clear(); + window.invalidator.set_phase(DrawPhase::Prepaint); + + let content_mask = ContentMask { + bounds: Bounds::new(point(px(0.), px(0.)), size(px(10.), px(10.))), + }; + let inside_bounds = Bounds::new(point(px(2.), px(2.)), size(px(4.), px(4.))); + let outside_bounds = Bounds::new(point(px(20.), px(2.)), size(px(4.), px(4.))); + + window.next_frame.dispatch_tree.push_node(); + window.with_rendered_view(current_view, |window| { + let mut inside_element = TestHitboxElement::new(inside_bounds).into_any_element(); + inside_element.request_layout(window, cx); + window.defer_draw(inside_element, point(px(0.), px(0.)), 0, Some(content_mask)); + + let mut outside_element = TestHitboxElement::new(outside_bounds).into_any_element(); + outside_element.request_layout(window, cx); + window.defer_draw( + outside_element, + point(px(0.), px(0.)), + 1, + Some(content_mask), + ); + }); + window.next_frame.dispatch_tree.pop_node(); + + window.prepaint_deferred_draws(cx); + + let inside_hitbox = window.next_frame.hitboxes[0].clone(); + let outside_hitbox = window.next_frame.hitboxes[1].clone(); + let inside_hit_test = window.next_frame.hit_test(point(px(3.), px(3.))); + let outside_hit_test = window.next_frame.hit_test(point(px(21.), px(3.))); + + assert!(inside_hit_test.ids.contains(&inside_hitbox.id)); + assert!(!outside_hit_test.ids.contains(&outside_hitbox.id)); + assert_eq!(inside_hitbox.content_mask, content_mask); + assert_eq!(outside_hitbox.content_mask, content_mask); + + window.invalidator.set_phase(DrawPhase::None); + }) + .expect("test window should still exist"); + } +}