From 04016badf275ffbbe95ea0d7a91d9eabac210ac0 Mon Sep 17 00:00:00 2001 From: Ignacio Perez Date: Sun, 25 Oct 2026 08:08:35 +0300 Subject: [PATCH 1/4] gpui: add ElementTransform for pan and zoom MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `Style::transform` — an `ElementTransform` with `translate`, `scale` and an `origin` in fractions of the element's bounds — and threads it through the render pipeline: `div` prepaint/paint, deferred draws, content masks, layers, culling, hit testing, text rasterisation and the view cache. Layout is untouched: Taffy still lays out in base coordinates and the matrix is applied after layout, so children do not multiply their own positions or font sizes. Translation and scale compose in CSS order (translate, then scale), and nested transforms compose parent · child. Not included: rotation. `TransformationMatrix` can express it, but quads and shadows only have their bounds transformed, so a rotated element would paint an axis-aligned box under rotated text. Ported from Ignacio Perez's (0xErwin1) implementation in https://github.com/zed-industries/zed/discussions/53306, branch `feature/53306-07-text-raster-subpixel-regressions` at fc4c1586 (2026-05-20), re-resolved against this fork's newer `div`/`view`/`window`. `crates/gpui` is Apache-2.0. --- crates/gpui/src/elements/div.rs | 184 +++-- crates/gpui/src/scene.rs | 391 ++++++++- crates/gpui/src/style.rs | 64 +- crates/gpui/src/styled.rs | 12 +- crates/gpui/src/text_system/line.rs | 56 +- crates/gpui/src/view.rs | 64 +- crates/gpui/src/window.rs | 1186 +++++++++++++++++++++++++-- 7 files changed, 1802 insertions(+), 155 deletions(-) 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..64e8b01b78195a 100644 --- a/crates/gpui/src/scene.rs +++ b/crates/gpui/src/scene.rs @@ -42,6 +42,7 @@ pub struct Scene { pub(crate) paint_operations: Vec, primitive_bounds: BoundsTree, layer_stack: Vec, + transform_stack: Vec, pub shadows: Vec, pub quads: Vec, pub paths: Vec>, @@ -58,6 +59,7 @@ impl Scene { self.paint_operations.clear(); self.primitive_bounds.clear(); self.layer_stack.clear(); + self.transform_stack.clear(); self.paths.clear(); self.shadows.clear(); self.quads.clear(); @@ -73,21 +75,38 @@ impl Scene { } pub fn push_layer(&mut self, bounds: Bounds) { + let bounds = transform_bounds(bounds, self.current_transform()); + self.push_replayed_layer(bounds); + } + + fn push_replayed_layer(&mut self, bounds: Bounds) { let order = self.primitive_bounds.insert(bounds); self.layer_stack.push(order); self.paint_operations .push(PaintOperation::StartLayer(bounds)); } + pub(crate) fn push_transformed_layer(&mut self, bounds: Bounds) { + self.push_replayed_layer(bounds); + } + pub fn pop_layer(&mut self) { self.layer_stack.pop(); self.paint_operations.push(PaintOperation::EndLayer); } pub fn insert_primitive(&mut self, primitive: impl Into) { - let mut primitive = primitive.into(); + self.insert_transformed_primitive(primitive, self.current_transform()); + } + + 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() { @@ -138,11 +157,29 @@ impl Scene { .push(PaintOperation::Primitive(primitive)); } + #[allow(dead_code)] + pub(crate) fn with_transform( + &mut self, + transform: TransformationMatrix, + f: impl FnOnce(&mut Self) -> R, + ) -> R { + let transform = self.current_transform().compose(transform); + self.transform_stack.push(transform); + let result = f(self); + self.transform_stack.pop(); + result + } + + pub(crate) fn current_transform(&self) -> TransformationMatrix { + self.transform_stack.last().copied().unwrap_or_default() + } + pub fn replay(&mut self, range: Range, prev_scene: &Scene) { for operation in &prev_scene.paint_operations[range] { match operation { - PaintOperation::Primitive(primitive) => self.insert_primitive(primitive.clone()), - PaintOperation::StartLayer(bounds) => self.push_layer(*bounds), + PaintOperation::Primitive(primitive) => self + .insert_transformed_primitive(primitive.clone(), TransformationMatrix::unit()), + PaintOperation::StartLayer(bounds) => self.push_replayed_layer(*bounds), PaintOperation::EndLayer => self.pop_layer(), } } @@ -257,6 +294,100 @@ 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); + Primitive::Shadow(shadow) + } + Primitive::Quad(mut quad) => { + quad.bounds = transform_bounds(quad.bounds, transform); + Primitive::Quad(quad) + } + Primitive::Path(mut path) => { + path.transform(transform); + Primitive::Path(path) + } + Primitive::Underline(mut underline) => { + underline.bounds = transform_bounds(underline.bounds, transform); + 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); + Primitive::PolychromeSprite(sprite) + } + Primitive::Surface(mut surface) => { + surface.bounds = transform_bounds(surface.bounds, transform); + Primitive::Surface(surface) + } + } + } +} + +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 +734,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,6 +817,31 @@ impl TransformationMatrix { } } + pub(crate) fn inverse(self) -> Option { + let [[a, b], [c, d]] = self.rotation_scale; + let determinant = a * d - b * c; + if determinant.abs() <= f32::EPSILON { + return None; + } + + let inverse_determinant = determinant.recip(); + let rotation_scale = [ + [d * inverse_determinant, -b * inverse_determinant], + [-c * inverse_determinant, a * inverse_determinant], + ]; + let translation = [ + -(rotation_scale[0][0] * self.translation[0] + + rotation_scale[0][1] * self.translation[1]), + -(rotation_scale[1][0] * self.translation[0] + + rotation_scale[1][1] * self.translation[1]), + ]; + + Some(TransformationMatrix { + rotation_scale, + translation, + }) + } + /// Apply transformation to a point, mainly useful for debugging pub fn apply(&self, point: Point) -> Point { let input = [point.x.0, point.y.0]; @@ -697,6 +853,17 @@ impl TransformationMatrix { } Point::new(output[0].into(), output[1].into()) } + + 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])) + } } impl Default for TransformationMatrix { @@ -943,12 +1110,226 @@ 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 transform_stack_composes_parent_then_child() { + let mut scene = Scene::default(); + let parent = + TransformationMatrix::unit().translate(point(ScaledPixels(10.), ScaledPixels(0.))); + let child = TransformationMatrix::unit().scale(size(2., 2.)); + + scene.with_transform(parent, |scene| { + scene.with_transform(child, |scene| { + let transformed = scene + .current_transform() + .apply(point(Pixels(3.), Pixels(4.))); + + assert_eq!(transformed, point(Pixels(16.), Pixels(8.))); + }); + }); + } + + #[test] + fn nested_transform_matches_direct_composed_sprite() { + let parent = + TransformationMatrix::unit().translate(point(ScaledPixels(10.), ScaledPixels(0.))); + let child = TransformationMatrix::unit().scale(size(2., 2.)); + let direct_transform = parent.compose(child); + + let mut direct_scene = Scene::default(); + direct_scene.with_transform(direct_transform, |scene| { + scene.insert_primitive(test_sprite()); + }); + + let mut nested_scene = Scene::default(); + nested_scene.with_transform(parent, |scene| { + scene.with_transform(child, |scene| { + scene.insert_primitive(test_sprite()); + }); + }); + + assert_eq!(direct_scene.monochrome_sprites.len(), 1); + assert_eq!(nested_scene.monochrome_sprites.len(), 1); + + let Some(direct_sprite) = direct_scene.monochrome_sprites.first() else { + panic!("direct scene should emit one monochrome sprite"); + }; + let Some(nested_sprite) = nested_scene.monochrome_sprites.first() else { + panic!("nested scene should emit one monochrome sprite"); + }; + + assert_eq!(nested_sprite.transformation, direct_sprite.transformation,); + } + + #[test] + fn replay_preserves_previously_transformed_primitives() { + let transform = + TransformationMatrix::unit().translate(point(ScaledPixels(10.), ScaledPixels(20.))); + + let mut previous_scene = Scene::default(); + previous_scene.with_transform(transform, |scene| { + scene.insert_primitive(test_quad()); + }); + + let mut replayed_scene = Scene::default(); + replayed_scene.with_transform(transform, |scene| { + scene.replay(0..previous_scene.len(), &previous_scene); + }); + + assert_eq!(previous_scene.quads.len(), 1); + assert_eq!(replayed_scene.quads.len(), 1); + + let Some(previous_quad) = previous_scene.quads.first() else { + panic!("previous scene should emit one quad"); + }; + let Some(replayed_quad) = replayed_scene.quads.first() else { + panic!("replayed scene should emit one quad"); + }; + + assert_eq!(replayed_quad.bounds, previous_quad.bounds); + } + + #[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(), + } + } + + 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 076d3c828e4d63..ffa0ce980d8f0f 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.)); @@ -834,6 +915,14 @@ pub struct Hitbox { identity: Option, } +#[derive(Clone, Debug)] +struct HitboxTransformMetadata { + id: HitboxId, + local_bounds: Bounds, + inverse_transform: Option, + scale_factor: f32, +} + impl Hitbox { /// Checks if the hitbox is currently hovered. Returns `false` during keyboard input modality /// so that keyboard navigation suppresses hover highlights. Except when handling @@ -961,6 +1050,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, @@ -977,6 +1067,7 @@ pub(crate) struct Frame { pub(crate) dispatch_tree: DispatchTree, pub(crate) scene: Scene, pub(crate) hitboxes: Vec, + hitbox_transform_metadata: Vec, pub(crate) window_control_hitboxes: Vec<(WindowControlArea, Hitbox)>, pub(crate) deferred_draws: Vec, pub(crate) input_handlers: Vec>, @@ -994,6 +1085,7 @@ pub(crate) struct Frame { #[derive(Clone, Default)] pub(crate) struct PrepaintStateIndex { hitboxes_index: usize, + hitbox_transform_metadata_index: usize, tooltips_index: usize, deferred_draws_index: usize, dispatch_tree_index: usize, @@ -1025,6 +1117,7 @@ impl Frame { dispatch_tree, scene: Scene::default(), hitboxes: Vec::new(), + hitbox_transform_metadata: Vec::new(), window_control_hitboxes: Vec::new(), deferred_draws: Vec::new(), input_handlers: Vec::new(), @@ -1054,6 +1147,7 @@ impl Frame { self.tooltip_requests.clear(); self.cursor_styles.clear(); self.hitboxes.clear(); + self.hitbox_transform_metadata.clear(); self.window_control_hitboxes.clear(); self.deferred_draws.clear(); self.tab_stops.clear(); @@ -1090,8 +1184,7 @@ impl Frame { let mut set_hover_hitbox_count = false; let mut hit_test = HitTest::default(); for hitbox in self.hitboxes.iter().rev() { - let bounds = hitbox.bounds.intersect(&hitbox.content_mask.bounds); - if bounds.contains(&position) { + if self.hitbox_contains_position(hitbox, position) { hit_test.ids.push(hitbox.id); if !set_hover_hitbox_count && hitbox.behavior == HitboxBehavior::BlockMouseExceptScroll @@ -1110,6 +1203,31 @@ impl Frame { hit_test } + fn hitbox_contains_position(&self, hitbox: &Hitbox, position: Point) -> bool { + let bounds = hitbox.bounds.intersect(&hitbox.content_mask.bounds); + if !bounds.contains(&position) { + return false; + } + + let Some(metadata) = self + .hitbox_transform_metadata + .iter() + .find(|metadata| metadata.id == hitbox.id) + else { + return true; + }; + + let Some(inverse_transform) = metadata.inverse_transform else { + return false; + }; + + let local_position = inverse_transform + .apply_scaled(position.scale(metadata.scale_factor)) + .map(|value| px(value.0 / metadata.scale_factor)); + + metadata.local_bounds.contains(&local_position) + } + pub(crate) fn focus_path(&self) -> SmallVec<[FocusId; 8]> { self.focus .map(|focus_id| self.dispatch_tree.focus_path(focus_id)) @@ -1167,6 +1285,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 +1974,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 +3505,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 +3526,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 +3536,13 @@ 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 +3582,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); + }); }); }) }) @@ -3475,6 +3613,7 @@ impl Window { pub(crate) fn prepaint_index(&self) -> PrepaintStateIndex { PrepaintStateIndex { hitboxes_index: self.next_frame.hitboxes.len(), + hitbox_transform_metadata_index: self.next_frame.hitbox_transform_metadata.len(), tooltips_index: self.next_frame.tooltip_requests.len(), deferred_draws_index: self.next_frame.deferred_draws.len(), dispatch_tree_index: self.next_frame.dispatch_tree.len(), @@ -3489,6 +3628,14 @@ impl Window { .iter() .cloned(), ); + self.next_frame.hitbox_transform_metadata.extend( + self.rendered_frame.hitbox_transform_metadata[range + .start + .hitbox_transform_metadata_index + ..range.end.hitbox_transform_metadata_index] + .iter() + .cloned(), + ); self.next_frame.tooltip_requests.extend( self.rendered_frame.tooltip_requests [range.start.tooltips_index..range.end.tooltips_index] @@ -3525,6 +3672,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 +3802,9 @@ impl Window { ) -> R { self.invalidator.debug_assert_paint_or_prepaint(); if let Some(mask) = mask { - let mask = mask.intersect(&self.content_mask()); + let mask = self + .transform_content_mask(mask) + .intersect(&self.content_mask()); self.content_mask_stack.push(mask); let result = f(self); self.content_mask_stack.pop(); @@ -3664,6 +3814,72 @@ impl Window { } } + #[allow(dead_code)] + 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() + } + + fn transform_content_mask(&self, mask: ContentMask) -> ContentMask { + let transform = self.current_transform(); + if transform == TransformationMatrix::unit() { + return mask; + } + + let scale_factor = self.scale_factor(); + let bounds = crate::scene::transform_bounds(mask.bounds.scale(scale_factor), transform) + .map(|value| px(value.0 / scale_factor)); + + ContentMask { bounds } + } + + fn transform_hitbox_bounds( + &self, + bounds: Bounds, + transform: TransformationMatrix, + ) -> Bounds { + 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 +4247,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 +4265,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_transformed_layer(clipped_bounds); } let result = f(self); @@ -4087,7 +4304,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 +4430,7 @@ impl Window { }; if !quad.background.is_transparent() { - self.next_frame.scene.insert_primitive(quad); + self.insert_primitive(quad); return; } @@ -4223,7 +4440,7 @@ impl Window { let inner_bounds = Self::largest_border_interior(&quad); if inner_bounds.is_empty() { - self.next_frame.scene.insert_primitive(quad); + self.insert_primitive(quad); return; } @@ -4253,7 +4470,7 @@ impl Window { for strip in strips { 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 +4492,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 +4519,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 +4549,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 +4580,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 +4603,7 @@ impl Window { glyph_id, font_size, subpixel_variant, - scale_factor, + scale_factor: effective_scale_factor, is_emoji: false, subpixel_rendering, dilation, @@ -4400,14 +4618,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 +4642,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 +4665,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 +4697,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 +4707,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 +4723,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 +4803,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 +4951,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 +4975,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, @@ -4927,6 +5166,7 @@ impl Window { self.invalidator.debug_assert_prepaint(); let content_mask = self.content_mask(); + let transform = self.current_transform(); let mut id = self.next_hitbox_id; self.next_hitbox_id = self.next_hitbox_id.next(); let identity = (!self.element_id_stack.is_empty()) @@ -4934,13 +5174,26 @@ impl Window { if identity.is_some() && self.captured_identity == identity { self.captured_hitbox = Some(id); } + let transformed_bounds = self.transform_hitbox_bounds(bounds, transform); let hitbox = Hitbox { id, - bounds, + bounds: transformed_bounds, content_mask, behavior, identity, }; + + if transform != TransformationMatrix::unit() { + self.next_frame + .hitbox_transform_metadata + .push(HitboxTransformMetadata { + id, + local_bounds: bounds, + inverse_transform: transform.inverse(), + scale_factor: self.scale_factor(), + }); + } + self.next_frame.hitboxes.push(hitbox.clone()); hitbox } @@ -7959,3 +8212,834 @@ 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, radians, 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 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_inverse_maps_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 reuse_prepaint_preserves_transformed_hitbox_inverse_metadata(cx: &mut TestAppContext) { + let window = cx.add_window(|_, _| Empty); + let window = window.into(); + + let (hitbox_id, reused_hitbox_ids, inside_hit, outside_local_hit) = cx + .update_window(window, |_, window, _| { + window.next_frame.hitboxes.clear(); + window.next_frame.hitbox_transform_metadata.clear(); + window.rendered_frame.hitboxes.clear(); + window.rendered_frame.hitbox_transform_metadata.clear(); + window.invalidator.set_phase(DrawPhase::Prepaint); + + window.with_transform( + TransformationMatrix::unit() + .translate(point(ScaledPixels(100.), ScaledPixels(100.))), + |window| { + window.insert_hitbox( + Bounds::new(point(px(0.), px(0.)), size(px(10.), px(10.))), + HitboxBehavior::Normal, + ) + }, + ); + + let prepaint_start = window.prepaint_index(); + let transform = + TransformationMatrix::unit().rotate(radians(std::f32::consts::FRAC_PI_4)); + let hitbox = window.with_transform(transform, |window| { + window.insert_hitbox( + Bounds::new(point(px(0.), px(0.)), size(px(10.), px(10.))), + HitboxBehavior::Normal, + ) + }); + let prepaint_end = window.prepaint_index(); + + window.rendered_frame.hitboxes = window.next_frame.hitboxes.clone(); + window.rendered_frame.hitbox_transform_metadata = + window.next_frame.hitbox_transform_metadata.clone(); + window.next_frame.hitboxes.clear(); + window.next_frame.hitbox_transform_metadata.clear(); + + window.reuse_prepaint(prepaint_start..prepaint_end); + + let reused_hitbox_ids = window + .next_frame + .hitboxes + .iter() + .map(|hitbox| hitbox.id) + .collect::>(); + let inside_hit = window.next_frame.hit_test(point(px(0.), px(7.))); + let outside_local_hit = window.next_frame.hit_test(point(px(6.), px(1.))); + + window.invalidator.set_phase(DrawPhase::None); + (hitbox.id, reused_hitbox_ids, inside_hit, outside_local_hit) + }) + .expect("test window should still exist"); + + assert_eq!(reused_hitbox_ids.as_slice(), &[hitbox_id]); + assert_eq!(inside_hit.ids.as_slice(), &[hitbox_id]); + assert_eq!(inside_hit.hover_hitbox_count, 1); + assert!(outside_local_hit.ids.is_empty()); + assert_eq!(outside_local_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"); + } +} From 951ff5fecb91b639e75d49edbbf3738902155259 Mon Sep 17 00:00:00 2001 From: Cyberistic Date: Sun, 25 Oct 2026 17:57:39 +0300 Subject: [PATCH 2/4] gpui: scale a transformed element's decorations with its bounds A transform moved a quad's bounds and left its corner radii, border widths and shadow blur at their authored device size, so a 2x scaled card kept a 1px border and a 6px corner. The matrix now reports the uniform scale it applies, and a quad, shadow or underline multiplies its decoration geometry by it. A rotated or anisotropic matrix reports no uniform scale: a rounded corner cannot become an ellipse, so those keep the previous behaviour rather than guessing. Subpixel AA is unchanged. It stays enabled only at integer scales, so text sharpens once when an animation settles; enabling it at fractional scales would trade that frame for colour fringing while the element moves. --- crates/gpui/src/scene.rs | 111 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 111 insertions(+) diff --git a/crates/gpui/src/scene.rs b/crates/gpui/src/scene.rs index 64e8b01b78195a..b4247c8c7569c2 100644 --- a/crates/gpui/src/scene.rs +++ b/crates/gpui/src/scene.rs @@ -316,10 +316,21 @@ impl Primitive { 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) => { @@ -328,6 +339,9 @@ impl Primitive { } 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) => { @@ -350,6 +364,10 @@ impl Primitive { } } +fn scale_corners(corners: Corners, scale: f32) -> Corners { + corners.map(|radius| ScaledPixels(radius.0 * scale)) +} + pub(crate) fn transform_bounds( bounds: Bounds, transform: TransformationMatrix, @@ -864,6 +882,17 @@ impl TransformationMatrix { } 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 { @@ -1284,6 +1313,88 @@ mod tests { } } + #[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, From a0d8142e4087e39f4b4552ab09744ec474ca1376 Mon Sep 17 00:00:00 2001 From: Cyberistic Date: Sat, 31 Oct 2026 00:16:06 +0300 Subject: [PATCH 3/4] gpui: hit-test transformed elements by their painted box `ElementTransform` only translates and scales, so a hitbox's transformed bounds are exactly the region the element paints. The per-hitbox inverse matrices existed only for rotation, which nothing can request, and hit testing searched that list linearly for every hitbox, which is quadratic in a large transformed subtree. They go, along with `TransformationMatrix::inverse` and the rotation-only test. The scene-level transform stack was only ever pushed by tests (the window passes its transform to `insert_transformed_primitive`), so `Scene` keeps its original `push_layer` and `replay`. The content-mask and hitbox paths share one `Window::transform_bounds`. Co-Authored-By: Claude Opus 5.5 --- crates/gpui/src/scene.rs | 138 +----------------------------- crates/gpui/src/window.rs | 176 +++++--------------------------------- 2 files changed, 25 insertions(+), 289 deletions(-) diff --git a/crates/gpui/src/scene.rs b/crates/gpui/src/scene.rs index b4247c8c7569c2..cb52754a91f801 100644 --- a/crates/gpui/src/scene.rs +++ b/crates/gpui/src/scene.rs @@ -42,7 +42,6 @@ pub struct Scene { pub(crate) paint_operations: Vec, primitive_bounds: BoundsTree, layer_stack: Vec, - transform_stack: Vec, pub shadows: Vec, pub quads: Vec, pub paths: Vec>, @@ -59,7 +58,6 @@ impl Scene { self.paint_operations.clear(); self.primitive_bounds.clear(); self.layer_stack.clear(); - self.transform_stack.clear(); self.paths.clear(); self.shadows.clear(); self.quads.clear(); @@ -75,28 +73,19 @@ impl Scene { } pub fn push_layer(&mut self, bounds: Bounds) { - let bounds = transform_bounds(bounds, self.current_transform()); - self.push_replayed_layer(bounds); - } - - fn push_replayed_layer(&mut self, bounds: Bounds) { let order = self.primitive_bounds.insert(bounds); self.layer_stack.push(order); self.paint_operations .push(PaintOperation::StartLayer(bounds)); } - pub(crate) fn push_transformed_layer(&mut self, bounds: Bounds) { - self.push_replayed_layer(bounds); - } - pub fn pop_layer(&mut self) { self.layer_stack.pop(); self.paint_operations.push(PaintOperation::EndLayer); } pub fn insert_primitive(&mut self, primitive: impl Into) { - self.insert_transformed_primitive(primitive, self.current_transform()); + self.insert_transformed_primitive(primitive, TransformationMatrix::unit()); } pub(crate) fn insert_transformed_primitive( @@ -157,29 +146,11 @@ impl Scene { .push(PaintOperation::Primitive(primitive)); } - #[allow(dead_code)] - pub(crate) fn with_transform( - &mut self, - transform: TransformationMatrix, - f: impl FnOnce(&mut Self) -> R, - ) -> R { - let transform = self.current_transform().compose(transform); - self.transform_stack.push(transform); - let result = f(self); - self.transform_stack.pop(); - result - } - - pub(crate) fn current_transform(&self) -> TransformationMatrix { - self.transform_stack.last().copied().unwrap_or_default() - } - pub fn replay(&mut self, range: Range, prev_scene: &Scene) { for operation in &prev_scene.paint_operations[range] { match operation { - PaintOperation::Primitive(primitive) => self - .insert_transformed_primitive(primitive.clone(), TransformationMatrix::unit()), - PaintOperation::StartLayer(bounds) => self.push_replayed_layer(*bounds), + PaintOperation::Primitive(primitive) => self.insert_primitive(primitive.clone()), + PaintOperation::StartLayer(bounds) => self.push_layer(*bounds), PaintOperation::EndLayer => self.pop_layer(), } } @@ -835,31 +806,6 @@ impl TransformationMatrix { } } - pub(crate) fn inverse(self) -> Option { - let [[a, b], [c, d]] = self.rotation_scale; - let determinant = a * d - b * c; - if determinant.abs() <= f32::EPSILON { - return None; - } - - let inverse_determinant = determinant.recip(); - let rotation_scale = [ - [d * inverse_determinant, -b * inverse_determinant], - [-c * inverse_determinant, a * inverse_determinant], - ]; - let translation = [ - -(rotation_scale[0][0] * self.translation[0] - + rotation_scale[0][1] * self.translation[1]), - -(rotation_scale[1][0] * self.translation[0] - + rotation_scale[1][1] * self.translation[1]), - ]; - - Some(TransformationMatrix { - rotation_scale, - translation, - }) - } - /// Apply transformation to a point, mainly useful for debugging pub fn apply(&self, point: Point) -> Point { let input = [point.x.0, point.y.0]; @@ -1162,84 +1108,6 @@ mod tests { use super::*; use crate::{AtlasTextureKind, DevicePixels, TileId, size}; - #[test] - fn transform_stack_composes_parent_then_child() { - let mut scene = Scene::default(); - let parent = - TransformationMatrix::unit().translate(point(ScaledPixels(10.), ScaledPixels(0.))); - let child = TransformationMatrix::unit().scale(size(2., 2.)); - - scene.with_transform(parent, |scene| { - scene.with_transform(child, |scene| { - let transformed = scene - .current_transform() - .apply(point(Pixels(3.), Pixels(4.))); - - assert_eq!(transformed, point(Pixels(16.), Pixels(8.))); - }); - }); - } - - #[test] - fn nested_transform_matches_direct_composed_sprite() { - let parent = - TransformationMatrix::unit().translate(point(ScaledPixels(10.), ScaledPixels(0.))); - let child = TransformationMatrix::unit().scale(size(2., 2.)); - let direct_transform = parent.compose(child); - - let mut direct_scene = Scene::default(); - direct_scene.with_transform(direct_transform, |scene| { - scene.insert_primitive(test_sprite()); - }); - - let mut nested_scene = Scene::default(); - nested_scene.with_transform(parent, |scene| { - scene.with_transform(child, |scene| { - scene.insert_primitive(test_sprite()); - }); - }); - - assert_eq!(direct_scene.monochrome_sprites.len(), 1); - assert_eq!(nested_scene.monochrome_sprites.len(), 1); - - let Some(direct_sprite) = direct_scene.monochrome_sprites.first() else { - panic!("direct scene should emit one monochrome sprite"); - }; - let Some(nested_sprite) = nested_scene.monochrome_sprites.first() else { - panic!("nested scene should emit one monochrome sprite"); - }; - - assert_eq!(nested_sprite.transformation, direct_sprite.transformation,); - } - - #[test] - fn replay_preserves_previously_transformed_primitives() { - let transform = - TransformationMatrix::unit().translate(point(ScaledPixels(10.), ScaledPixels(20.))); - - let mut previous_scene = Scene::default(); - previous_scene.with_transform(transform, |scene| { - scene.insert_primitive(test_quad()); - }); - - let mut replayed_scene = Scene::default(); - replayed_scene.with_transform(transform, |scene| { - scene.replay(0..previous_scene.len(), &previous_scene); - }); - - assert_eq!(previous_scene.quads.len(), 1); - assert_eq!(replayed_scene.quads.len(), 1); - - let Some(previous_quad) = previous_scene.quads.first() else { - panic!("previous scene should emit one quad"); - }; - let Some(replayed_quad) = replayed_scene.quads.first() else { - panic!("replayed scene should emit one quad"); - }; - - assert_eq!(replayed_quad.bounds, previous_quad.bounds); - } - #[test] fn transformed_monochrome_sprite_is_culled_by_visual_bounds() { let transform = diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index ffa0ce980d8f0f..c7b3a38229507d 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -915,14 +915,6 @@ pub struct Hitbox { identity: Option, } -#[derive(Clone, Debug)] -struct HitboxTransformMetadata { - id: HitboxId, - local_bounds: Bounds, - inverse_transform: Option, - scale_factor: f32, -} - impl Hitbox { /// Checks if the hitbox is currently hovered. Returns `false` during keyboard input modality /// so that keyboard navigation suppresses hover highlights. Except when handling @@ -1067,7 +1059,6 @@ pub(crate) struct Frame { pub(crate) dispatch_tree: DispatchTree, pub(crate) scene: Scene, pub(crate) hitboxes: Vec, - hitbox_transform_metadata: Vec, pub(crate) window_control_hitboxes: Vec<(WindowControlArea, Hitbox)>, pub(crate) deferred_draws: Vec, pub(crate) input_handlers: Vec>, @@ -1085,7 +1076,6 @@ pub(crate) struct Frame { #[derive(Clone, Default)] pub(crate) struct PrepaintStateIndex { hitboxes_index: usize, - hitbox_transform_metadata_index: usize, tooltips_index: usize, deferred_draws_index: usize, dispatch_tree_index: usize, @@ -1117,7 +1107,6 @@ impl Frame { dispatch_tree, scene: Scene::default(), hitboxes: Vec::new(), - hitbox_transform_metadata: Vec::new(), window_control_hitboxes: Vec::new(), deferred_draws: Vec::new(), input_handlers: Vec::new(), @@ -1147,7 +1136,6 @@ impl Frame { self.tooltip_requests.clear(); self.cursor_styles.clear(); self.hitboxes.clear(); - self.hitbox_transform_metadata.clear(); self.window_control_hitboxes.clear(); self.deferred_draws.clear(); self.tab_stops.clear(); @@ -1184,7 +1172,8 @@ impl Frame { let mut set_hover_hitbox_count = false; let mut hit_test = HitTest::default(); for hitbox in self.hitboxes.iter().rev() { - if self.hitbox_contains_position(hitbox, position) { + let bounds = hitbox.bounds.intersect(&hitbox.content_mask.bounds); + if bounds.contains(&position) { hit_test.ids.push(hitbox.id); if !set_hover_hitbox_count && hitbox.behavior == HitboxBehavior::BlockMouseExceptScroll @@ -1203,31 +1192,6 @@ impl Frame { hit_test } - fn hitbox_contains_position(&self, hitbox: &Hitbox, position: Point) -> bool { - let bounds = hitbox.bounds.intersect(&hitbox.content_mask.bounds); - if !bounds.contains(&position) { - return false; - } - - let Some(metadata) = self - .hitbox_transform_metadata - .iter() - .find(|metadata| metadata.id == hitbox.id) - else { - return true; - }; - - let Some(inverse_transform) = metadata.inverse_transform else { - return false; - }; - - let local_position = inverse_transform - .apply_scaled(position.scale(metadata.scale_factor)) - .map(|value| px(value.0 / metadata.scale_factor)); - - metadata.local_bounds.contains(&local_position) - } - pub(crate) fn focus_path(&self) -> SmallVec<[FocusId; 8]> { self.focus .map(|focus_id| self.dispatch_tree.focus_path(focus_id)) @@ -3539,9 +3503,12 @@ impl Window { 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); - }); + window.with_absolute_element_offset( + absolute_offset, + |window| { + element.prepaint(window, cx); + }, + ); }); }); }); @@ -3613,7 +3580,6 @@ impl Window { pub(crate) fn prepaint_index(&self) -> PrepaintStateIndex { PrepaintStateIndex { hitboxes_index: self.next_frame.hitboxes.len(), - hitbox_transform_metadata_index: self.next_frame.hitbox_transform_metadata.len(), tooltips_index: self.next_frame.tooltip_requests.len(), deferred_draws_index: self.next_frame.deferred_draws.len(), dispatch_tree_index: self.next_frame.dispatch_tree.len(), @@ -3628,14 +3594,6 @@ impl Window { .iter() .cloned(), ); - self.next_frame.hitbox_transform_metadata.extend( - self.rendered_frame.hitbox_transform_metadata[range - .start - .hitbox_transform_metadata_index - ..range.end.hitbox_transform_metadata_index] - .iter() - .cloned(), - ); self.next_frame.tooltip_requests.extend( self.rendered_frame.tooltip_requests [range.start.tooltips_index..range.end.tooltips_index] @@ -3802,9 +3760,10 @@ impl Window { ) -> R { self.invalidator.debug_assert_paint_or_prepaint(); if let Some(mask) = mask { - let mask = self - .transform_content_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(); @@ -3814,7 +3773,6 @@ impl Window { } } - #[allow(dead_code)] pub(crate) fn with_transform( &mut self, transform: TransformationMatrix, @@ -3847,24 +3805,9 @@ impl Window { self.transform_stack.last().copied().unwrap_or_default() } - fn transform_content_mask(&self, mask: ContentMask) -> ContentMask { + /// 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 mask; - } - - let scale_factor = self.scale_factor(); - let bounds = crate::scene::transform_bounds(mask.bounds.scale(scale_factor), transform) - .map(|value| px(value.0 / scale_factor)); - - ContentMask { bounds } - } - - fn transform_hitbox_bounds( - &self, - bounds: Bounds, - transform: TransformationMatrix, - ) -> Bounds { if transform == TransformationMatrix::unit() { return bounds; } @@ -4269,7 +4212,7 @@ impl Window { 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_transformed_layer(clipped_bounds); + self.next_frame.scene.push_layer(clipped_bounds); } let result = f(self); @@ -5166,7 +5109,6 @@ impl Window { self.invalidator.debug_assert_prepaint(); let content_mask = self.content_mask(); - let transform = self.current_transform(); let mut id = self.next_hitbox_id; self.next_hitbox_id = self.next_hitbox_id.next(); let identity = (!self.element_id_stack.is_empty()) @@ -5174,26 +5116,16 @@ impl Window { if identity.is_some() && self.captured_identity == identity { self.captured_hitbox = Some(id); } - let transformed_bounds = self.transform_hitbox_bounds(bounds, transform); + // `ElementTransform` only translates and scales, so the transformed + // box is exactly the region the element paints. let hitbox = Hitbox { id, - bounds: transformed_bounds, + bounds: self.transform_bounds(bounds), content_mask, behavior, identity, }; - if transform != TransformationMatrix::unit() { - self.next_frame - .hitbox_transform_metadata - .push(HitboxTransformMetadata { - id, - local_bounds: bounds, - inverse_transform: transform.inverse(), - scale_factor: self.scale_factor(), - }); - } - self.next_frame.hitboxes.push(hitbox.clone()); hitbox } @@ -8218,7 +8150,7 @@ mod transform_tests { use super::*; use crate::{ Element, ElementTransform, Empty, InspectorElementId, IntoElement, ParentElement, Styled, - TestAppContext, div, radians, white, + TestAppContext, div, white, }; struct TestQuadElement { @@ -8699,7 +8631,7 @@ mod transform_tests { } #[gpui::test] - fn hit_test_inverse_maps_scaled_visual_region(cx: &mut TestAppContext) { + fn hit_test_uses_scaled_visual_region(cx: &mut TestAppContext) { let window = cx.add_window(|_, _| Empty); let window = window.into(); @@ -8732,70 +8664,6 @@ mod transform_tests { assert_eq!(outside_hit.hover_hitbox_count, 0); } - #[gpui::test] - fn reuse_prepaint_preserves_transformed_hitbox_inverse_metadata(cx: &mut TestAppContext) { - let window = cx.add_window(|_, _| Empty); - let window = window.into(); - - let (hitbox_id, reused_hitbox_ids, inside_hit, outside_local_hit) = cx - .update_window(window, |_, window, _| { - window.next_frame.hitboxes.clear(); - window.next_frame.hitbox_transform_metadata.clear(); - window.rendered_frame.hitboxes.clear(); - window.rendered_frame.hitbox_transform_metadata.clear(); - window.invalidator.set_phase(DrawPhase::Prepaint); - - window.with_transform( - TransformationMatrix::unit() - .translate(point(ScaledPixels(100.), ScaledPixels(100.))), - |window| { - window.insert_hitbox( - Bounds::new(point(px(0.), px(0.)), size(px(10.), px(10.))), - HitboxBehavior::Normal, - ) - }, - ); - - let prepaint_start = window.prepaint_index(); - let transform = - TransformationMatrix::unit().rotate(radians(std::f32::consts::FRAC_PI_4)); - let hitbox = window.with_transform(transform, |window| { - window.insert_hitbox( - Bounds::new(point(px(0.), px(0.)), size(px(10.), px(10.))), - HitboxBehavior::Normal, - ) - }); - let prepaint_end = window.prepaint_index(); - - window.rendered_frame.hitboxes = window.next_frame.hitboxes.clone(); - window.rendered_frame.hitbox_transform_metadata = - window.next_frame.hitbox_transform_metadata.clone(); - window.next_frame.hitboxes.clear(); - window.next_frame.hitbox_transform_metadata.clear(); - - window.reuse_prepaint(prepaint_start..prepaint_end); - - let reused_hitbox_ids = window - .next_frame - .hitboxes - .iter() - .map(|hitbox| hitbox.id) - .collect::>(); - let inside_hit = window.next_frame.hit_test(point(px(0.), px(7.))); - let outside_local_hit = window.next_frame.hit_test(point(px(6.), px(1.))); - - window.invalidator.set_phase(DrawPhase::None); - (hitbox.id, reused_hitbox_ids, inside_hit, outside_local_hit) - }) - .expect("test window should still exist"); - - assert_eq!(reused_hitbox_ids.as_slice(), &[hitbox_id]); - assert_eq!(inside_hit.ids.as_slice(), &[hitbox_id]); - assert_eq!(inside_hit.hover_hitbox_count, 1); - assert!(outside_local_hit.ids.is_empty()); - assert_eq!(outside_local_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); @@ -8945,8 +8813,8 @@ mod transform_tests { window.with_rendered_view(current_view, |window| { window.with_transform(transform, |window| { - let mut element = TestQuadElement::new(deferred_bounds, white()) - .into_any_element(); + 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); }); From d2a29edf5d545bfb92f9a6c12a6a8da6a0c46e04 Mon Sep 17 00:00:00 2001 From: "Tommy D. Rossi" Date: Mon, 2 Nov 2026 10:27:42 +0100 Subject: [PATCH 4/4] gpui: fix transformed clipping and expose coordinate conversion Transform border-only strip masks into screen space before clipping. Keep whole quads for non-uniform transforms, whose border widths remain unscaled. Scale image corner radii alongside uniformly transformed bounds. Expose a logical-pixel element transform that custom elements can capture during drawing and retain for input callbacks, automation bounds and IME rectangles. Add inverse and bounds conversion, document the coordinate contract, and cover the rendering regressions and nested transforms at retina density. Validated: 308 GPUI library tests pass; gpui_platform all-target check and macOS library build pass with font-kit and test-support. Session: ses_e647520f7ffeMI4noHdDXRRfFg Discord: https://discord.com/channels/1422625037164351591/1566742546699517964 --- crates/gpui/README.md | 33 ++++++++++++ crates/gpui/src/scene.rs | 61 +++++++++++++++++++++- crates/gpui/src/window.rs | 107 +++++++++++++++++++++++++++++++++++++- 3 files changed, 199 insertions(+), 2 deletions(-) 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/scene.rs b/crates/gpui/src/scene.rs index cb52754a91f801..a81ff4fd242386 100644 --- a/crates/gpui/src/scene.rs +++ b/crates/gpui/src/scene.rs @@ -325,6 +325,9 @@ impl Primitive { } 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) => { @@ -806,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; @@ -818,6 +821,38 @@ 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; @@ -1181,6 +1216,30 @@ mod tests { } } + #[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 { diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index d902383c794e7e..c9b48efa7438ac 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -3805,6 +3805,22 @@ impl Window { 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(); @@ -4382,7 +4398,8 @@ impl Window { let outer_bounds = quad.bounds; let inner_bounds = Self::largest_border_interior(&quad); - if inner_bounds.is_empty() { + // 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; } @@ -4411,6 +4428,7 @@ 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.insert_primitive(Quad { @@ -8332,6 +8350,93 @@ mod transform_tests { .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(