Skip to content

gpui: add ElementTransform for pan and zoom - #17

Merged
remorses merged 5 commits into
remorses:gpuixfrom
Cyberistic:feat/transform
Nov 2, 2026
Merged

remorses merged 5 commits into
remorses:gpuixfrom
Cyberistic:feat/transform

Conversation

@Cyberistic

@Cyberistic Cyberistic commented Oct 25, 2026 •

Copy link
Copy Markdown

Plan is to have GPUIx expose MotionStyle scale/translate.
Adds ElementTransform to GPUI: Style::transform with translate, scale.

div()
    .transform(
        ElementTransform::default()
            .origin(point(0.5, 0.5))
            .translate(point(px(pan_x), px(pan_y)))
            .scale(size(zoom, zoom)),
    )

ported from zed-industries/zed#53306
as a feature-request discussion in April, implemented it on
feature/53306-07-text-raster-subpixel-regressions

slop 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; gpuix is 2565b8a → f77e5cf). Conflict
resolution was mechanical: import unions, ViewElement's cache key replacing
the branch's AnyViewState, GPUix's element.take() in deferred prepaint,
paint_quad's transparent-border strip split, Hitbox.identity, and
Shadow.element_bounds now transformed alongside bounds. The author field is
his; the merge and the formatting are mine.

crates/gpui is Apache-2.0, so carrying it in this fork is a licence-compatible
use; the fork already publishes gpui and this adds no dependency.

  • No rotation. The public API omits it; quads and shadows only have bounds
    transformed, so a rotation would paint an axis-aligned box under rotated text.
  • Scale transforms bounds, not decoration geometry — fixed in 951ff5f: a
    quad, 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.
  • Only div (and text, which builds a div) honour it today.
  • Anchored layers position in layout space, so do not nest an overlay inside a
    transformed subtree.
  • Fractional scale drops text to grayscale AA at the endpoint (subpixel is only
    used for unit or integer scales); raster scale is clamped at 4×.

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
Cyberistic and others added 4 commits October 25, 2026 17:57
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
Copilot AI balanced review requested due to automatic review settings November 2, 2026 09:27

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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
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 thread crates/gpui/src/window.rs
Comment on lines +3763 to +3766
let mask = ContentMask {
bounds: self.transform_bounds(mask.bounds),
}
.intersect(&self.content_mask());
Comment thread crates/gpui/src/window.rs
}
let shadow_bounds = (bounds + shadow.offset).dilate(shadow.spread_radius);
self.next_frame.scene.insert_primitive(Shadow {
self.insert_primitive(Shadow {
Comment thread crates/gpui/src/window.rs
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);
Comment thread crates/gpui/src/window.rs
let bounds = self.snap_bounds(bounds);
let content_mask = self.snapped_content_mask();
self.next_frame.scene.insert_primitive(PaintSurface {
self.insert_primitive(PaintSurface {
Comment thread crates/gpui/src/window.rs
let hitbox = Hitbox {
id,
bounds,
bounds: self.transform_bounds(bounds),
@remorses
remorses merged commit 1da884b into remorses:gpuix Nov 2, 2026
31 checks passed
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants