Add an animation to the LSP checking indicator (#9463)

Mikayla Maki and Dzmitry Malyshau created

Spinner go spinny.

Extra thanks to @kvark for helping me with the shaders.



https://github.com/zed-industries/zed/assets/2280405/9d5f4f4e-0d43-44d2-a089-5d69939938e9


Release Notes:

- Added a spinning animation to the LSP checking indicator

---------

Co-authored-by: Dzmitry Malyshau <kvark@fastmail.com>

Change summary

crates/diagnostics/src/items.rs             |  18 +
crates/gpui/Cargo.toml                      |   1 
crates/gpui/build.rs                        |   1 
crates/gpui/examples/animation.rs           |  74 +++++++++
crates/gpui/examples/image/arrow_circle.svg |   6 
crates/gpui/src/elements/animation.rs       | 188 ++++++++++++++++++++++
crates/gpui/src/elements/mod.rs             |   2 
crates/gpui/src/elements/svg.rs             | 100 +++++++++++
crates/gpui/src/geometry.rs                 | 129 +++++++++++++++
crates/gpui/src/key_dispatch.rs             |   4 
crates/gpui/src/platform/blade/shaders.wgsl |  16 +
crates/gpui/src/platform/mac/shaders.metal  |  30 +++
crates/gpui/src/scene.rs                    | 106 ++++++++++++
crates/gpui/src/window.rs                   |  42 ++--
crates/gpui/src/window/element_cx.rs        |  12 +
crates/ui/src/components/icon.rs            |  11 +
16 files changed, 708 insertions(+), 32 deletions(-)

Detailed changes

crates/diagnostics/src/items.rs πŸ”—

@@ -1,8 +1,10 @@
+use std::time::Duration;
+
 use collections::HashSet;
 use editor::Editor;
 use gpui::{
-    rems, EventEmitter, IntoElement, ParentElement, Render, Styled, Subscription, View,
-    ViewContext, WeakView,
+    percentage, rems, Animation, AnimationExt, EventEmitter, IntoElement, ParentElement, Render,
+    Styled, Subscription, Transformation, View, ViewContext, WeakView,
 };
 use language::Diagnostic;
 use lsp::LanguageServerId;
@@ -66,7 +68,17 @@ impl Render for DiagnosticIndicator {
             Some(
                 h_flex()
                     .gap_2()
-                    .child(Icon::new(IconName::ArrowCircle).size(IconSize::Small))
+                    .child(
+                        Icon::new(IconName::ArrowCircle)
+                            .size(IconSize::Small)
+                            .with_animation(
+                                "arrow-circle",
+                                Animation::new(Duration::from_secs(2)).repeat(),
+                                |icon, delta| {
+                                    icon.transform(Transformation::rotate(percentage(delta)))
+                                },
+                            ),
+                    )
                     .child(
                         Label::new("Checking…")
                             .size(LabelSize::Small)

crates/gpui/Cargo.toml πŸ”—

@@ -11,6 +11,7 @@ license = "Apache-2.0"
 workspace = true
 
 [features]
+default = []
 test-support = [
     "backtrace",
     "collections/test-support",

crates/gpui/build.rs πŸ”—

@@ -87,6 +87,7 @@ fn generate_shader_bindings() -> PathBuf {
         "PathSprite".into(),
         "SurfaceInputIndex".into(),
         "SurfaceBounds".into(),
+        "TransformationMatrix".into(),
     ]);
     config.no_includes = true;
     config.enumeration.prefix_with_name = true;

crates/gpui/examples/animation.rs πŸ”—

@@ -0,0 +1,74 @@
+use std::time::Duration;
+
+use gpui::*;
+
+struct Assets {}
+
+impl AssetSource for Assets {
+    fn load(&self, path: &str) -> Result<std::borrow::Cow<'static, [u8]>> {
+        std::fs::read(path).map(Into::into).map_err(Into::into)
+    }
+
+    fn list(&self, path: &str) -> Result<Vec<SharedString>> {
+        Ok(std::fs::read_dir(path)?
+            .filter_map(|entry| {
+                Some(SharedString::from(
+                    entry.ok()?.path().to_string_lossy().to_string(),
+                ))
+            })
+            .collect::<Vec<_>>())
+    }
+}
+
+struct AnimationExample {}
+
+impl Render for AnimationExample {
+    fn render(&mut self, _cx: &mut ViewContext<Self>) -> impl IntoElement {
+        div().flex().flex_col().size_full().justify_around().child(
+            div().flex().flex_row().w_full().justify_around().child(
+                div()
+                    .flex()
+                    .bg(rgb(0x2e7d32))
+                    .size(Length::Definite(Pixels(300.0).into()))
+                    .justify_center()
+                    .items_center()
+                    .shadow_lg()
+                    .text_xl()
+                    .text_color(black())
+                    .child("hello")
+                    .child(
+                        svg()
+                            .size_8()
+                            .path("examples/image/arrow_circle.svg")
+                            .text_color(black())
+                            .with_animation(
+                                "image_circle",
+                                Animation::new(Duration::from_secs(2))
+                                    .repeat()
+                                    .with_easing(bounce(ease_in_out)),
+                                |svg, delta| {
+                                    svg.with_transformation(Transformation::rotate(percentage(
+                                        delta,
+                                    )))
+                                },
+                            ),
+                    ),
+            ),
+        )
+    }
+}
+
+fn main() {
+    App::new()
+        .with_assets(Assets {})
+        .run(|cx: &mut AppContext| {
+            let options = WindowOptions {
+                bounds: Some(Bounds::centered(size(px(300.), px(300.)), cx)),
+                ..Default::default()
+            };
+            cx.open_window(options, |cx| {
+                cx.activate(false);
+                cx.new_view(|_cx| AnimationExample {})
+            });
+        });
+}

crates/gpui/examples/image/arrow_circle.svg πŸ”—

@@ -0,0 +1,6 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M3 8C3 6.67392 3.52678 5.40215 4.46446 4.46447C5.40214 3.52679 6.67391 3.00001 7.99999 3.00001C9.39779 3.00527 10.7394 3.55069 11.7444 4.52223L13 5.77778" stroke="black" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M13 3.00001V5.77778H10.2222" stroke="black" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M13 8C13 9.32608 12.4732 10.5978 11.5355 11.5355C10.5978 12.4732 9.32607 13 7.99999 13C6.60219 12.9947 5.26054 12.4493 4.25555 11.4778L3 10.2222" stroke="black" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M5.77777 10.2222H3V13" stroke="black" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>

crates/gpui/src/elements/animation.rs πŸ”—

@@ -0,0 +1,188 @@
+use std::time::{Duration, Instant};
+
+use crate::{AnyElement, Element, ElementId, IntoElement};
+
+pub use easing::*;
+
+/// An animation that can be applied to an element.
+pub struct Animation {
+    /// The amount of time for which this animation should run
+    pub duration: Duration,
+    /// Whether to repeat this animation when it finishes
+    pub oneshot: bool,
+    /// A function that takes a delta between 0 and 1 and returns a new delta
+    /// between 0 and 1 based on the given easing function.
+    pub easing: Box<dyn Fn(f32) -> f32>,
+}
+
+impl Animation {
+    /// Create a new animation with the given duration.
+    /// By default the animation will only run once and will use a linear easing function.
+    pub fn new(duration: Duration) -> Self {
+        Self {
+            duration,
+            oneshot: true,
+            easing: Box::new(linear),
+        }
+    }
+
+    /// Set the animation to loop when it finishes.
+    pub fn repeat(mut self) -> Self {
+        self.oneshot = false;
+        self
+    }
+
+    /// Set the easing function to use for this animation.
+    /// The easing function will take a time delta between 0 and 1 and return a new delta
+    /// between 0 and 1
+    pub fn with_easing(mut self, easing: impl Fn(f32) -> f32 + 'static) -> Self {
+        self.easing = Box::new(easing);
+        self
+    }
+}
+
+/// An extension trait for adding the animation wrapper to both Elements and Components
+pub trait AnimationExt {
+    /// Render this component or element with an animation
+    fn with_animation(
+        self,
+        id: impl Into<ElementId>,
+        animation: Animation,
+        animator: impl Fn(Self, f32) -> Self + 'static,
+    ) -> AnimationElement<Self>
+    where
+        Self: Sized,
+    {
+        AnimationElement {
+            id: id.into(),
+            element: Some(self),
+            animator: Box::new(animator),
+            animation,
+        }
+    }
+}
+
+impl<E> AnimationExt for E {}
+
+/// A GPUI element that applies an animation to another element
+pub struct AnimationElement<E> {
+    id: ElementId,
+    element: Option<E>,
+    animation: Animation,
+    animator: Box<dyn Fn(E, f32) -> E + 'static>,
+}
+
+impl<E: IntoElement + 'static> IntoElement for AnimationElement<E> {
+    type Element = AnimationElement<E>;
+
+    fn into_element(self) -> Self::Element {
+        self
+    }
+}
+
+struct AnimationState {
+    start: Instant,
+}
+
+impl<E: IntoElement + 'static> Element for AnimationElement<E> {
+    type BeforeLayout = AnyElement;
+
+    type AfterLayout = ();
+
+    fn before_layout(
+        &mut self,
+        cx: &mut crate::ElementContext,
+    ) -> (crate::LayoutId, Self::BeforeLayout) {
+        cx.with_element_state(Some(self.id.clone()), |state, cx| {
+            let state = state.unwrap().unwrap_or_else(|| AnimationState {
+                start: Instant::now(),
+            });
+            let mut delta =
+                state.start.elapsed().as_secs_f32() / self.animation.duration.as_secs_f32();
+
+            let mut done = false;
+            if delta > 1.0 {
+                if self.animation.oneshot {
+                    done = true;
+                    delta = 1.0;
+                } else {
+                    delta = delta % 1.0;
+                }
+            }
+            let delta = (self.animation.easing)(delta);
+
+            debug_assert!(
+                delta >= 0.0 && delta <= 1.0,
+                "delta should always be between 0 and 1"
+            );
+
+            let element = self.element.take().expect("should only be called once");
+            let mut element = (self.animator)(element, delta).into_any_element();
+
+            if !done {
+                let parent_id = cx.parent_view_id();
+                cx.on_next_frame(move |cx| {
+                    if let Some(parent_id) = parent_id {
+                        cx.notify(parent_id)
+                    } else {
+                        cx.refresh()
+                    }
+                })
+            }
+
+            ((element.before_layout(cx), element), Some(state))
+        })
+    }
+
+    fn after_layout(
+        &mut self,
+        _bounds: crate::Bounds<crate::Pixels>,
+        element: &mut Self::BeforeLayout,
+        cx: &mut crate::ElementContext,
+    ) -> Self::AfterLayout {
+        element.after_layout(cx);
+    }
+
+    fn paint(
+        &mut self,
+        _bounds: crate::Bounds<crate::Pixels>,
+        element: &mut Self::BeforeLayout,
+        _: &mut Self::AfterLayout,
+        cx: &mut crate::ElementContext,
+    ) {
+        element.paint(cx);
+    }
+}
+
+mod easing {
+    /// The linear easing function, or delta itself
+    pub fn linear(delta: f32) -> f32 {
+        delta
+    }
+
+    /// The quadratic easing function, delta * delta
+    pub fn quadratic(delta: f32) -> f32 {
+        delta * delta
+    }
+
+    /// The quadratic ease-in-out function, which starts and ends slowly but speeds up in the middle
+    pub fn ease_in_out(delta: f32) -> f32 {
+        if delta < 0.5 {
+            2.0 * delta * delta
+        } else {
+            let x = -2.0 * delta + 2.0;
+            1.0 - x * x / 2.0
+        }
+    }
+
+    /// Apply the given easing function, first in the forward direction and then in the reverse direction
+    pub fn bounce(easing: impl Fn(f32) -> f32) -> impl Fn(f32) -> f32 {
+        move |delta| {
+            if delta < 0.5 {
+                easing(delta * 2.0)
+            } else {
+                easing((1.0 - delta) * 2.0)
+            }
+        }
+    }
+}

crates/gpui/src/elements/mod.rs πŸ”—

@@ -1,3 +1,4 @@
+mod animation;
 mod canvas;
 mod deferred;
 mod div;
@@ -8,6 +9,7 @@ mod svg;
 mod text;
 mod uniform_list;
 
+pub use animation::*;
 pub use canvas::*;
 pub use deferred::*;
 pub use div::*;

crates/gpui/src/elements/svg.rs πŸ”—

@@ -1,12 +1,14 @@
 use crate::{
-    Bounds, Element, ElementContext, Hitbox, InteractiveElement, Interactivity, IntoElement,
-    LayoutId, Pixels, SharedString, StyleRefinement, Styled,
+    geometry::Negate as _, point, px, radians, size, Bounds, Element, ElementContext, Hitbox,
+    InteractiveElement, Interactivity, IntoElement, LayoutId, Pixels, Point, Radians, SharedString,
+    Size, StyleRefinement, Styled, TransformationMatrix,
 };
 use util::ResultExt;
 
 /// An SVG element.
 pub struct Svg {
     interactivity: Interactivity,
+    transformation: Option<Transformation>,
     path: Option<SharedString>,
 }
 
@@ -14,6 +16,7 @@ pub struct Svg {
 pub fn svg() -> Svg {
     Svg {
         interactivity: Interactivity::default(),
+        transformation: None,
         path: None,
     }
 }
@@ -24,6 +27,13 @@ impl Svg {
         self.path = Some(path.into());
         self
     }
+
+    /// Transform the SVG element with the given transformation.
+    /// Note that this won't effect the hitbox or layout of the element, only the rendering.
+    pub fn with_transformation(mut self, transformation: Transformation) -> Self {
+        self.transformation = Some(transformation);
+        self
+    }
 }
 
 impl Element for Svg {
@@ -59,7 +69,16 @@ impl Element for Svg {
         self.interactivity
             .paint(bounds, hitbox.as_ref(), cx, |style, cx| {
                 if let Some((path, color)) = self.path.as_ref().zip(style.text.color) {
-                    cx.paint_svg(bounds, path.clone(), color).log_err();
+                    let transformation = self
+                        .transformation
+                        .as_ref()
+                        .map(|transformation| {
+                            transformation.into_matrix(bounds.center(), cx.scale_factor())
+                        })
+                        .unwrap_or_default();
+
+                    cx.paint_svg(bounds, path.clone(), transformation, color)
+                        .log_err();
                 }
             })
     }
@@ -84,3 +103,78 @@ impl InteractiveElement for Svg {
         &mut self.interactivity
     }
 }
+
+/// A transformation to apply to an SVG element.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct Transformation {
+    scale: Size<f32>,
+    translate: Point<Pixels>,
+    rotate: Radians,
+}
+
+impl Default for Transformation {
+    fn default() -> Self {
+        Self {
+            scale: size(1.0, 1.0),
+            translate: point(px(0.0), px(0.0)),
+            rotate: radians(0.0),
+        }
+    }
+}
+
+impl Transformation {
+    /// Create a new Transformation with the specified scale along each axis.
+    pub fn scale(scale: Size<f32>) -> Self {
+        Self {
+            scale,
+            translate: point(px(0.0), px(0.0)),
+            rotate: radians(0.0),
+        }
+    }
+
+    /// Create a new Transformation with the specified translation.
+    pub fn translate(translate: Point<Pixels>) -> Self {
+        Self {
+            scale: size(1.0, 1.0),
+            translate,
+            rotate: radians(0.0),
+        }
+    }
+
+    /// Create a new Transformation with the specified rotation in radians.
+    pub fn rotate(rotate: impl Into<Radians>) -> Self {
+        let rotate = rotate.into();
+        Self {
+            scale: size(1.0, 1.0),
+            translate: point(px(0.0), px(0.0)),
+            rotate,
+        }
+    }
+
+    /// Update the scaling factor of this transformation.
+    pub fn with_scaling(mut self, scale: Size<f32>) -> Self {
+        self.scale = scale;
+        self
+    }
+
+    /// Update the translation value of this transformation.
+    pub fn with_translation(mut self, translate: Point<Pixels>) -> Self {
+        self.translate = translate;
+        self
+    }
+
+    /// Update the rotation angle of this transformation.
+    pub fn with_rotation(mut self, rotate: impl Into<Radians>) -> Self {
+        self.rotate = rotate.into();
+        self
+    }
+
+    fn into_matrix(self, center: Point<Pixels>, scale_factor: f32) -> TransformationMatrix {
+        //Note: if you read this as a sequence of matrix mulitplications, start from the bottom
+        TransformationMatrix::unit()
+            .translate(center.scale(scale_factor) + self.translate.scale(scale_factor))
+            .rotate(self.rotate)
+            .scale(self.scale)
+            .translate(center.scale(scale_factor).negate())
+    }
+}

crates/gpui/src/geometry.rs πŸ”—

@@ -26,7 +26,7 @@ pub enum Axis {
 
 impl Axis {
     /// Swap this axis to the opposite axis.
-    pub fn invert(&self) -> Self {
+    pub fn invert(self) -> Self {
         match self {
             Axis::Vertical => Axis::Horizontal,
             Axis::Horizontal => Axis::Vertical,
@@ -160,6 +160,12 @@ impl<T: Clone + Debug + Default> Along for Point<T> {
     }
 }
 
+impl<T: Clone + Debug + Default + Negate> Negate for Point<T> {
+    fn negate(self) -> Self {
+        self.map(Negate::negate)
+    }
+}
+
 impl Point<Pixels> {
     /// Scales the point by a given factor, which is typically derived from the resolution
     /// of a target display to ensure proper sizing of UI elements.
@@ -421,6 +427,19 @@ where
     }
 }
 
+impl<T> Size<T>
+where
+    T: Clone + Default + Debug + Half,
+{
+    /// Compute the center point of the size.g
+    pub fn center(&self) -> Point<T> {
+        Point {
+            x: self.width.half(),
+            y: self.height.half(),
+        }
+    }
+}
+
 impl Size<Pixels> {
     /// Scales the size by a given factor.
     ///
@@ -1970,6 +1989,66 @@ impl From<Pixels> for Corners<Pixels> {
     }
 }
 
+/// Represents an angle in Radians
+#[derive(
+    Clone,
+    Copy,
+    Default,
+    Add,
+    AddAssign,
+    Sub,
+    SubAssign,
+    Neg,
+    Div,
+    DivAssign,
+    PartialEq,
+    Serialize,
+    Deserialize,
+    Debug,
+)]
+#[repr(transparent)]
+pub struct Radians(pub f32);
+
+/// Create a `Radian` from a raw value
+pub fn radians(value: f32) -> Radians {
+    Radians(value)
+}
+
+/// A type representing a percentage value.
+#[derive(
+    Clone,
+    Copy,
+    Default,
+    Add,
+    AddAssign,
+    Sub,
+    SubAssign,
+    Neg,
+    Div,
+    DivAssign,
+    PartialEq,
+    Serialize,
+    Deserialize,
+    Debug,
+)]
+#[repr(transparent)]
+pub struct Percentage(pub f32);
+
+/// Generate a `Radian` from a percentage of a full circle.
+pub fn percentage(value: f32) -> Percentage {
+    debug_assert!(
+        value >= 0.0 && value <= 1.0,
+        "Percentage must be between 0 and 1"
+    );
+    Percentage(value)
+}
+
+impl From<Percentage> for Radians {
+    fn from(value: Percentage) -> Self {
+        radians(value.0 * std::f32::consts::PI * 2.0)
+    }
+}
+
 /// Represents a length in pixels, the base unit of measurement in the UI framework.
 ///
 /// `Pixels` is a value type that represents an absolute length in pixels, which is used
@@ -2761,6 +2840,54 @@ impl Half for GlobalPixels {
     }
 }
 
+/// Provides a trait for types that can negate their values.
+pub trait Negate {
+    /// Returns the negation of the given value
+    fn negate(self) -> Self;
+}
+
+impl Negate for i32 {
+    fn negate(self) -> Self {
+        -self
+    }
+}
+
+impl Negate for f32 {
+    fn negate(self) -> Self {
+        -self
+    }
+}
+
+impl Negate for DevicePixels {
+    fn negate(self) -> Self {
+        Self(-self.0)
+    }
+}
+
+impl Negate for ScaledPixels {
+    fn negate(self) -> Self {
+        Self(-self.0)
+    }
+}
+
+impl Negate for Pixels {
+    fn negate(self) -> Self {
+        Self(-self.0)
+    }
+}
+
+impl Negate for Rems {
+    fn negate(self) -> Self {
+        Self(-self.0)
+    }
+}
+
+impl Negate for GlobalPixels {
+    fn negate(self) -> Self {
+        Self(-self.0)
+    }
+}
+
 /// A trait for checking if a value is zero.
 ///
 /// This trait provides a method to determine if a value is considered to be zero.

crates/gpui/src/key_dispatch.rs πŸ”—

@@ -202,6 +202,10 @@ impl DispatchTree {
         self.focusable_node_ids.insert(focus_id, node_id);
     }
 
+    pub fn parent_view_id(&mut self) -> Option<EntityId> {
+        self.view_stack.last().copied()
+    }
+
     pub fn set_view_id(&mut self, view_id: EntityId) {
         if self.view_stack.last().copied() != Some(view_id) {
             let node_id = *self.node_stack.last().unwrap();

crates/gpui/src/platform/blade/shaders.wgsl πŸ”—

@@ -49,6 +49,11 @@ struct AtlasTile {
     bounds: AtlasBounds,
 }
 
+struct TransformationMatrix {
+    rotation_scale: mat2x2<f32>,
+    translation: vec2<f32>,
+}
+
 fn to_device_position_impl(position: vec2<f32>) -> vec4<f32> {
     let device_position = position / globals.viewport_size * vec2<f32>(2.0, -2.0) + vec2<f32>(-1.0, 1.0);
     return vec4<f32>(device_position, 0.0, 1.0);
@@ -59,6 +64,13 @@ fn to_device_position(unit_vertex: vec2<f32>, bounds: Bounds) -> vec4<f32> {
     return to_device_position_impl(position);
 }
 
+fn to_device_position_transformed(unit_vertex: vec2<f32>, bounds: Bounds, transform: TransformationMatrix) -> vec4<f32> {
+    let position = unit_vertex * vec2<f32>(bounds.size) + bounds.origin;
+    //Note: Rust side stores it as row-major, so transposing here
+    let transformed = transpose(transform.rotation_scale) * position + transform.translation;
+    return to_device_position_impl(transformed);
+}
+
 fn to_tile_position(unit_vertex: vec2<f32>, tile: AtlasTile) -> vec2<f32> {
   let atlas_size = vec2<f32>(textureDimensions(t_sprite, 0));
   return (vec2<f32>(tile.bounds.origin) + unit_vertex * vec2<f32>(tile.bounds.size)) / atlas_size;
@@ -476,6 +488,7 @@ struct MonochromeSprite {
     content_mask: Bounds,
     color: Hsla,
     tile: AtlasTile,
+    transformation: TransformationMatrix,
 }
 var<storage, read> b_mono_sprites: array<MonochromeSprite>;
 
@@ -492,7 +505,8 @@ fn vs_mono_sprite(@builtin(vertex_index) vertex_id: u32, @builtin(instance_index
     let sprite = b_mono_sprites[instance_id];
 
     var out = MonoSpriteVarying();
-    out.position = to_device_position(unit_vertex, sprite.bounds);
+    out.position = to_device_position_transformed(unit_vertex, sprite.bounds, sprite.transformation);
+
     out.tile_position = to_tile_position(unit_vertex, sprite.tile);
     out.color = hsla_to_rgba(sprite.color);
     out.clip_distances = distance_from_clip_rect(unit_vertex, sprite.bounds, sprite.content_mask);

crates/gpui/src/platform/mac/shaders.metal πŸ”—

@@ -6,6 +6,10 @@ using namespace metal;
 float4 hsla_to_rgba(Hsla hsla);
 float4 to_device_position(float2 unit_vertex, Bounds_ScaledPixels bounds,
                           constant Size_DevicePixels *viewport_size);
+float4 to_device_position_transformed(float2 unit_vertex, Bounds_ScaledPixels bounds,
+                          TransformationMatrix transformation,
+                          constant Size_DevicePixels *input_viewport_size);
+
 float2 to_tile_position(float2 unit_vertex, AtlasTile tile,
                         constant Size_DevicePixels *atlas_size);
 float4 distance_from_clip_rect(float2 unit_vertex, Bounds_ScaledPixels bounds,
@@ -301,7 +305,7 @@ vertex MonochromeSpriteVertexOutput monochrome_sprite_vertex(
   float2 unit_vertex = unit_vertices[unit_vertex_id];
   MonochromeSprite sprite = sprites[sprite_id];
   float4 device_position =
-      to_device_position(unit_vertex, sprite.bounds, viewport_size);
+      to_device_position_transformed(unit_vertex, sprite.bounds, sprite.transformation, viewport_size);
   float4 clip_distance = distance_from_clip_rect(unit_vertex, sprite.bounds,
                                                  sprite.content_mask.bounds);
   float2 tile_position = to_tile_position(unit_vertex, sprite.tile, atlas_size);
@@ -582,6 +586,30 @@ float4 to_device_position(float2 unit_vertex, Bounds_ScaledPixels bounds,
   return float4(device_position, 0., 1.);
 }
 
+float4 to_device_position_transformed(float2 unit_vertex, Bounds_ScaledPixels bounds,
+                          TransformationMatrix transformation,
+                          constant Size_DevicePixels *input_viewport_size) {
+  float2 position =
+      unit_vertex * float2(bounds.size.width, bounds.size.height) +
+      float2(bounds.origin.x, bounds.origin.y);
+
+  // Apply the transformation matrix to the position via matrix multiplication.
+  float2 transformed_position = float2(0, 0);
+  transformed_position[0] = position[0] * transformation.rotation_scale[0][0] + position[1] * transformation.rotation_scale[0][1];
+  transformed_position[1] = position[0] * transformation.rotation_scale[1][0] + position[1] * transformation.rotation_scale[1][1];
+
+  // Add in the translation component of the transformation matrix.
+  transformed_position[0] += transformation.translation[0];
+  transformed_position[1] += transformation.translation[1];
+
+  float2 viewport_size = float2((float)input_viewport_size->width,
+                                (float)input_viewport_size->height);
+  float2 device_position =
+      transformed_position / viewport_size * float2(2., -2.) + float2(-1., 1.);
+  return float4(device_position, 0., 1.);
+}
+
+
 float2 to_tile_position(float2 unit_vertex, AtlasTile tile,
                         constant Size_DevicePixels *atlas_size) {
   float2 tile_origin = float2(tile.bounds.origin.x, tile.bounds.origin.y);

crates/gpui/src/scene.rs πŸ”—

@@ -3,7 +3,7 @@
 
 use crate::{
     bounds_tree::BoundsTree, point, AtlasTextureId, AtlasTile, Bounds, ContentMask, Corners, Edges,
-    Hsla, Pixels, Point, ScaledPixels,
+    Hsla, Pixels, Point, Radians, ScaledPixels, Size,
 };
 use std::{fmt::Debug, iter::Peekable, ops::Range, slice};
 
@@ -504,6 +504,109 @@ impl From<Shadow> for Primitive {
     }
 }
 
+/// A data type representing a 2 dimensional transformation that can be applied to an element.
+#[derive(Debug, Clone, Copy, PartialEq)]
+#[repr(C)]
+pub struct TransformationMatrix {
+    /// 2x2 matrix containing rotation and scale,
+    /// stored row-major
+    pub rotation_scale: [[f32; 2]; 2],
+    /// translation vector
+    pub translation: [f32; 2],
+}
+
+impl Eq for TransformationMatrix {}
+
+impl TransformationMatrix {
+    /// The unit matrix, has no effect.
+    pub fn unit() -> Self {
+        Self {
+            rotation_scale: [[1.0, 0.0], [0.0, 1.0]],
+            translation: [0.0, 0.0],
+        }
+    }
+
+    /// Move the origin by a given point
+    pub fn translate(mut self, point: Point<ScaledPixels>) -> Self {
+        self.compose(Self {
+            rotation_scale: [[1.0, 0.0], [0.0, 1.0]],
+            translation: [point.x.0, point.y.0],
+        })
+    }
+
+    /// Clockwise rotation in radians around the origin
+    pub fn rotate(self, angle: Radians) -> Self {
+        self.compose(Self {
+            rotation_scale: [
+                [angle.0.cos(), -angle.0.sin()],
+                [angle.0.sin(), angle.0.cos()],
+            ],
+            translation: [0.0, 0.0],
+        })
+    }
+
+    /// Scale around the origin
+    pub fn scale(self, size: Size<f32>) -> Self {
+        self.compose(Self {
+            rotation_scale: [[size.width, 0.0], [0.0, size.height]],
+            translation: [0.0, 0.0],
+        })
+    }
+
+    /// Perform matrix multiplication with another transformation
+    /// to produce a new transformation that is the result of
+    /// applying both transformations: first, `other`, then `self`.
+    #[inline]
+    pub fn compose(self, other: TransformationMatrix) -> TransformationMatrix {
+        if other == Self::unit() {
+            return self;
+        }
+        // Perform matrix multiplication
+        TransformationMatrix {
+            rotation_scale: [
+                [
+                    self.rotation_scale[0][0] * other.rotation_scale[0][0]
+                        + self.rotation_scale[0][1] * other.rotation_scale[1][0],
+                    self.rotation_scale[0][0] * other.rotation_scale[0][1]
+                        + self.rotation_scale[0][1] * other.rotation_scale[1][1],
+                ],
+                [
+                    self.rotation_scale[1][0] * other.rotation_scale[0][0]
+                        + self.rotation_scale[1][1] * other.rotation_scale[1][0],
+                    self.rotation_scale[1][0] * other.rotation_scale[0][1]
+                        + self.rotation_scale[1][1] * other.rotation_scale[1][1],
+                ],
+            ],
+            translation: [
+                self.translation[0]
+                    + self.rotation_scale[0][0] * other.translation[0]
+                    + self.rotation_scale[0][1] * other.translation[1],
+                self.translation[1]
+                    + self.rotation_scale[1][0] * other.translation[0]
+                    + self.rotation_scale[1][1] * other.translation[1],
+            ],
+        }
+    }
+
+    /// Apply transformation to a point, mainly useful for debugging
+    pub fn apply(&self, point: Point<Pixels>) -> Point<Pixels> {
+        let input = [point.x.0, point.y.0];
+        let mut output = self.translation;
+        for i in 0..2 {
+            for k in 0..2 {
+                output[i] += self.rotation_scale[i][k] * input[k];
+            }
+        }
+        Point::new(output[0].into(), output[1].into())
+    }
+}
+
+impl Default for TransformationMatrix {
+    fn default() -> Self {
+        Self::unit()
+    }
+}
+
 #[derive(Clone, Debug, Eq, PartialEq)]
 #[repr(C)]
 pub(crate) struct MonochromeSprite {
@@ -513,6 +616,7 @@ pub(crate) struct MonochromeSprite {
     pub content_mask: ContentMask<ScaledPixels>,
     pub color: Hsla,
     pub tile: AtlasTile,
+    pub transformation: TransformationMatrix,
 }
 
 impl Ord for MonochromeSprite {

crates/gpui/src/window.rs πŸ”—

@@ -631,6 +631,28 @@ impl<'a> WindowContext<'a> {
         }
     }
 
+    /// Indicate that this view has changed, which will invoke any observers and also mark the window as dirty.
+    /// If this view or any of its ancestors are *cached*, notifying it will cause it or its ancestors to be redrawn.
+    pub fn notify(&mut self, view_id: EntityId) {
+        for view_id in self
+            .window
+            .rendered_frame
+            .dispatch_tree
+            .view_path(view_id)
+            .into_iter()
+            .rev()
+        {
+            if !self.window.dirty_views.insert(view_id) {
+                break;
+            }
+        }
+
+        if self.window.draw_phase == DrawPhase::None {
+            self.window.dirty.set(true);
+            self.app.push_effect(Effect::Notify { emitter: view_id });
+        }
+    }
+
     /// Close this window.
     pub fn remove_window(&mut self) {
         self.window.removed = true;
@@ -2159,25 +2181,7 @@ impl<'a, V: 'static> ViewContext<'a, V> {
     /// Indicate that this view has changed, which will invoke any observers and also mark the window as dirty.
     /// If this view or any of its ancestors are *cached*, notifying it will cause it or its ancestors to be redrawn.
     pub fn notify(&mut self) {
-        for view_id in self
-            .window
-            .rendered_frame
-            .dispatch_tree
-            .view_path(self.view.entity_id())
-            .into_iter()
-            .rev()
-        {
-            if !self.window.dirty_views.insert(view_id) {
-                break;
-            }
-        }
-
-        if self.window.draw_phase == DrawPhase::None {
-            self.window_cx.window.dirty.set(true);
-            self.window_cx.app.push_effect(Effect::Notify {
-                emitter: self.view.model.entity_id,
-            });
-        }
+        self.window_cx.notify(self.view.entity_id());
     }
 
     /// Register a callback to be invoked when the window is resized.

crates/gpui/src/window/element_cx.rs πŸ”—

@@ -35,8 +35,8 @@ use crate::{
     GlyphId, Hsla, ImageData, InputHandler, IsZero, KeyContext, KeyEvent, LayoutId,
     LineLayoutIndex, MonochromeSprite, MouseEvent, PaintQuad, Path, Pixels, PlatformInputHandler,
     Point, PolychromeSprite, Quad, RenderGlyphParams, RenderImageParams, RenderSvgParams, Scene,
-    Shadow, SharedString, Size, StrikethroughStyle, Style, TextStyleRefinement, Underline,
-    UnderlineStyle, Window, WindowContext, SUBPIXEL_VARIANTS,
+    Shadow, SharedString, Size, StrikethroughStyle, Style, TextStyleRefinement,
+    TransformationMatrix, Underline, UnderlineStyle, Window, WindowContext, SUBPIXEL_VARIANTS,
 };
 
 pub(crate) type AnyMouseListener =
@@ -1007,6 +1007,7 @@ impl<'a> ElementContext<'a> {
                     content_mask,
                     color,
                     tile,
+                    transformation: TransformationMatrix::unit(),
                 });
         }
         Ok(())
@@ -1072,6 +1073,7 @@ impl<'a> ElementContext<'a> {
         &mut self,
         bounds: Bounds<Pixels>,
         path: SharedString,
+        transformation: TransformationMatrix,
         color: Hsla,
     ) -> Result<()> {
         let scale_factor = self.scale_factor();
@@ -1103,6 +1105,7 @@ impl<'a> ElementContext<'a> {
                 content_mask,
                 color,
                 tile,
+                transformation,
             });
 
         Ok(())
@@ -1266,6 +1269,11 @@ impl<'a> ElementContext<'a> {
         self.window.next_frame.dispatch_tree.set_view_id(view_id);
     }
 
+    /// Get the last view id for the current element
+    pub fn parent_view_id(&mut self) -> Option<EntityId> {
+        self.window.next_frame.dispatch_tree.parent_view_id()
+    }
+
     /// Sets an input handler, such as [`ElementInputHandler`][element_input_handler], which interfaces with the
     /// platform to receive textual input with proper integration with concerns such
     /// as IME interactions. This handler will be active for the upcoming frame until the following frame is

crates/ui/src/components/icon.rs πŸ”—

@@ -1,4 +1,4 @@
-use gpui::{svg, IntoElement, Rems};
+use gpui::{svg, IntoElement, Rems, Transformation};
 use strum::EnumIter;
 
 use crate::prelude::*;
@@ -219,6 +219,7 @@ pub struct Icon {
     path: SharedString,
     color: Color,
     size: IconSize,
+    transformation: Transformation,
 }
 
 impl Icon {
@@ -227,6 +228,7 @@ impl Icon {
             path: icon.path().into(),
             color: Color::default(),
             size: IconSize::default(),
+            transformation: Transformation::default(),
         }
     }
 
@@ -235,6 +237,7 @@ impl Icon {
             path: path.into(),
             color: Color::default(),
             size: IconSize::default(),
+            transformation: Transformation::default(),
         }
     }
 
@@ -247,11 +250,17 @@ impl Icon {
         self.size = size;
         self
     }
+
+    pub fn transform(mut self, transformation: Transformation) -> Self {
+        self.transformation = transformation;
+        self
+    }
 }
 
 impl RenderOnce for Icon {
     fn render(self, cx: &mut WindowContext) -> impl IntoElement {
         svg()
+            .with_transformation(self.transformation)
             .size(self.size.rems())
             .flex_none()
             .path(self.path)