Repository navigation
gpui: add ElementTransform for pan and zoom - #17
Merged
Merged
Conversation
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 zed-industries#53306, branch `feature/53306-07-text-raster-subpixel-regressions` at fc4c158 (2026-05-20), re-resolved against this fork's newer `div`/`view`/`window`. `crates/gpui` is Apache-2.0.
Cyberistic
pushed a commit
to Cyberistic/GPUIXUI
that referenced
this pull request
Oct 25, 2026
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.
`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 <noreply@anthropic.com>
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
There was a problem hiding this comment.
🟡 Changes recommended
Transformed controls retain incorrect accessibility and click bounds that can activate the wrong control, while rendering, text hit-testing, and deferred clipping also handle transforms inconsistently.
6 open issues
Accessibility bounds ignore element and ancestor transforms · New Deferred masks apply the transform twice · New Inset shadows ignore the active transform · New Glyph origins are quantized at the wrong scale · New GPU surfaces ignore the active transform · New InteractiveText hit testing ignores layout transforms · New
What changed in this PR
Adds post-layout pan and zoom to GPUI through ElementTransform, providing a foundation for GPUIx scale and translation styling without changing child layout coordinates.
Changes:
- Adds transform styling and applies it throughout div subtree rendering, clipping and hit testing.
- Makes view caching and text rendering transform-aware, including zoom-dependent glyph rasterization.
- Adds regression tests and documents coordinate conversion for custom elements.
| File | Description |
|---|---|
crates/gpui/src/window.rs |
Tracks transforms across drawing, clipping, hitboxes and deferred rendering; adjusts glyph rasterization. |
crates/gpui/src/view.rs |
Includes transforms in view cache keys. |
crates/gpui/src/text_system/line.rs |
Uses transformed glyph bounds for clipping checks. |
crates/gpui/src/styled.rs |
Adds the fluent .transform() styling method. |
crates/gpui/src/style.rs |
Defines ElementTransform and resolves scale, translation and origin. |
crates/gpui/src/scene.rs |
Transforms primitives and adds matrix inversion and bounds helpers. |
crates/gpui/src/elements/div.rs |
Applies style transforms during subtree prepaint and paint. |
crates/gpui/README.md |
Documents pan and zoom, coordinate conversion and limitations. |
Review effort: Balanced
| } else { | ||
| None | ||
| }; | ||
| window.with_style_transform(style.transform, bounds, |window| { |
Comment on lines
+3763
to
+3766
| let mask = ContentMask { | ||
| bounds: self.transform_bounds(mask.bounds), | ||
| } | ||
| .intersect(&self.content_mask()); |
| } | ||
| let shadow_bounds = (bounds + shadow.offset).dilate(shadow.spread_radius); | ||
| self.next_frame.scene.insert_primitive(Shadow { | ||
| self.insert_primitive(Shadow { |
Comment on lines
+4545
to
+4546
| let raster_multiplier = transform_raster_multiplier(current_transform); | ||
| let effective_scale_factor = glyph_raster_scale_factor(scale_factor, current_transform); |
| let bounds = self.snap_bounds(bounds); | ||
| let content_mask = self.snapped_content_mask(); | ||
| self.next_frame.scene.insert_primitive(PaintSurface { | ||
| self.insert_primitive(PaintSurface { |
| let hitbox = Hitbox { | ||
| id, | ||
| bounds, | ||
| bounds: self.transform_bounds(bounds), |
remorses
added a commit
to Cyberistic/zed
that referenced
this pull request
Nov 2, 2026
Brings in remorses#12 (wgpu render_to_image / Linux visual test renderer), remorses#16 (dock badge) and remorses#17 (ElementTransform). Conflict resolution: - crates/gpui_macos/Cargo.toml: union of objc2-app-kit features (NSApplication, NSDockTile from remorses#16 + tray's NSCell, NSImage, NSMenu, NSMenuItem, NSStatusBar, NSStatusBarButton, NSStatusItem) - Cargo.lock: keep ksni in gpui_linux deps - crates/gpui_linux/Cargo.toml (auto-merged into a duplicate `image` key): remorses#12 added `image` as optional behind test-support, tray needs it always for icon decoding. Keep one non-optional `image.workspace = true` and drop `dep:image` from test-support; render_to_image still compiles. Discord: https://discord.com/channels/1422625037164351591/1566742535496404992 Session: ses_e63f4dc6dffe630PwZzgSaMoYR
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.


Plan is to have GPUIx expose
MotionStylescale/translate.Adds
ElementTransformto GPUI:Style::transformwithtranslate,scale.ported from zed-industries/zed#53306
as a feature-request discussion in April, implemented it on
feature/53306-07-text-raster-subpixel-regressionsslop below:
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. This is what makes a camera pan/zoom a single property on a container
instead of arithmetic on every node.
]
This commit is that branch, re-resolved against this fork's newer GPUI (the
branch is based on
2026-05-20;gpuixis2565b8a→f77e5cf). Conflictresolution was mechanical: import unions,
ViewElement's cache key replacingthe branch's
AnyViewState, GPUix'selement.take()in deferred prepaint,paint_quad's transparent-border strip split,Hitbox.identity, andShadow.element_boundsnow transformed alongsidebounds. The author field ishis; the merge and the formatting are mine.
crates/gpuiis Apache-2.0, so carrying it in this fork is a licence-compatibleuse; the fork already publishes
gpuiand this adds no dependency.transformed, so a rotation would paint an axis-aligned box under rotated text.
Scale transforms bounds, not decoration geometry— fixed in951ff5f: aquad, shadow and underline now scale their corner radii, border widths and
blur with the element. A rotated or anisotropic matrix still leaves the
decorations alone, because a rounded corner cannot become an ellipse.
div(andtext, which builds a div) honour it today.Anchoredlayers position in layout space, so do not nest an overlay inside atransformed subtree.
used for unit or integer scales); raster scale is clamped at 4×.