Introduce the View trait and ViewElement, a synthesis of the Entity and Component models.

Mikayla Maki created

Change summary

crates/gpui/src/view.rs | 322 ++++++++++++++++++++++++++++++++++++++++++
1 file changed, 321 insertions(+), 1 deletion(-)

Detailed changes

crates/gpui/src/view.rs 🔗

@@ -9,7 +9,7 @@ use collections::FxHashSet;
 use refineable::Refineable;
 use std::mem;
 use std::rc::Rc;
-use std::{any::TypeId, fmt, ops::Range};
+use std::{any::TypeId, fmt, hash::Hash, ops::Range};
 
 struct AnyViewState {
     prepaint_range: Range<PrepaintStateIndex>,
@@ -310,6 +310,326 @@ mod any_view {
     }
 }
 
+/// A component backed by an entity that participates in GPUI's reactive graph.
+///
+/// Views combine the ergonomic builder-pattern API of components ([`RenderOnce`](crate::RenderOnce))
+/// with the push-pull reactivity of entities. When a view's entity calls
+/// `cx.notify()`, only this view (and its dirty ancestors/children) need to
+/// re-render.
+///
+/// Unlike [`Render`], which puts the rendering trait on the entity's state type,
+/// `View` goes on the *component* type — the struct that holds both the entity
+/// handle and any display props. This means the consumer controls styling
+/// through the builder pattern, while the entity provides reactive state.
+///
+/// # Example
+///
+/// ```ignore
+/// struct CounterState {
+///     count: usize,
+/// }
+///
+/// struct Counter {
+///     state: Entity<CounterState>,
+///     label: SharedString,
+/// }
+///
+/// impl Counter {
+///     fn new(state: Entity<CounterState>) -> Self {
+///         Self { state, label: "Count".into() }
+///     }
+///
+///     fn label(mut self, label: impl Into<SharedString>) -> Self {
+///         self.label = label.into();
+///         self
+///     }
+/// }
+///
+/// impl View for Counter {
+///     type State = CounterState;
+///
+///     fn entity(&self) -> &Entity<CounterState> {
+///         &self.state
+///     }
+///
+///
+///     fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
+///         let count = self.state.read(cx).count;
+///         div().child(format!("{}: {}", self.label, count))
+///     }
+/// }
+///
+/// // Usage in a parent's render:
+/// // Counter::new(my_counter_entity).label("Total")
+/// ```
+pub trait View: 'static + Sized + Hash {
+    /// The entity type that backs this view's state.
+    type State: 'static;
+
+    /// Returns a reference to the entity that backs this view.
+    fn entity(&self) -> &Entity<Self::State>;
+
+    /// Render this view into an element tree. Takes ownership of self,
+    /// consuming the component props. The entity state persists across frames.
+    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement;
+}
+
+/// An element that wraps a [`View`], creating a reactive boundary in the element tree.
+///
+/// This is the stateful counterpart to [`Component`](crate::Component) — where `Component<C>`
+/// wraps a stateless [`RenderOnce`](crate::RenderOnce) type, `ViewElement<V>` wraps a stateful
+/// [`View`] type and hooks its entity into GPUI's push-pull reactive graph.
+///
+/// You don't construct this directly. Instead, implement [`IntoElement`] for your
+/// [`View`] type using [`ViewElement::new`]:
+///
+/// ```ignore
+/// impl IntoElement for Counter {
+///     type Element = ViewElement<Self>;
+///     fn into_element(self) -> Self::Element {
+///         ViewElement::new(self)
+///     }
+/// }
+/// ```
+#[doc(hidden)]
+pub struct ViewElement<V: View> {
+    view: Option<V>,
+    entity_id: EntityId,
+    props_hash: u64,
+    cached_style: Option<StyleRefinement>,
+    #[cfg(debug_assertions)]
+    source: &'static core::panic::Location<'static>,
+}
+
+impl<V: View> ViewElement<V> {
+    /// Create a new `ViewElement` wrapping the given [`View`].
+    ///
+    /// Use this in your [`IntoElement`] implementation.
+    #[track_caller]
+    pub fn new(view: V) -> Self {
+        use std::hash::Hasher;
+        let entity_id = view.entity().entity_id();
+        let mut hasher = std::collections::hash_map::DefaultHasher::new();
+        view.hash(&mut hasher);
+        let props_hash = hasher.finish();
+        ViewElement {
+            entity_id,
+            props_hash,
+            cached_style: None,
+            view: Some(view),
+            #[cfg(debug_assertions)]
+            source: core::panic::Location::caller(),
+        }
+    }
+
+    /// Enable caching for this view element. When cached, the view's render,
+    /// prepaint, and paint will all be reused from the previous frame if the
+    /// entity hasn't been notified and the props hash hasn't changed.
+    ///
+    /// The provided style defines the outer layout of this view in its parent
+    /// (since the actual content render is deferred until we know if caching
+    /// can be used).
+    pub fn cached(mut self, style: StyleRefinement) -> Self {
+        self.cached_style = Some(style);
+        self
+    }
+}
+
+impl<V: View> IntoElement for ViewElement<V> {
+    type Element = Self;
+
+    fn into_element(self) -> Self::Element {
+        self
+    }
+}
+
+struct ViewElementState {
+    prepaint_range: Range<PrepaintStateIndex>,
+    paint_range: Range<PaintIndex>,
+    cache_key: ViewElementCacheKey,
+    accessed_entities: FxHashSet<EntityId>,
+}
+
+struct ViewElementCacheKey {
+    bounds: Bounds<Pixels>,
+    content_mask: ContentMask<Pixels>,
+    text_style: TextStyle,
+    props_hash: u64,
+}
+
+impl<V: View> Element for ViewElement<V> {
+    type RequestLayoutState = Option<AnyElement>;
+    type PrepaintState = Option<AnyElement>;
+
+    fn id(&self) -> Option<ElementId> {
+        Some(ElementId::View(self.entity_id))
+    }
+
+    fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
+        #[cfg(debug_assertions)]
+        return Some(self.source);
+
+        #[cfg(not(debug_assertions))]
+        return None;
+    }
+
+    fn request_layout(
+        &mut self,
+        _id: Option<&GlobalElementId>,
+        _inspector_id: Option<&InspectorElementId>,
+        window: &mut Window,
+        cx: &mut App,
+    ) -> (LayoutId, Self::RequestLayoutState) {
+        window.with_rendered_view(self.entity_id, |window| {
+            let caching_disabled = window.is_inspector_picking(cx);
+            match self.cached_style.as_ref() {
+                // Cached path: defer render, use style for layout.
+                // Render will happen in prepaint only on cache miss.
+                Some(style) if !caching_disabled => {
+                    let mut root_style = Style::default();
+                    root_style.refine(style);
+                    let layout_id = window.request_layout(root_style, None, cx);
+                    (layout_id, None)
+                }
+                // Non-cached path: render eagerly.
+                _ => {
+                    let mut element = self
+                        .view
+                        .take()
+                        .unwrap()
+                        .render(window, cx)
+                        .into_any_element();
+                    let layout_id = element.request_layout(window, cx);
+                    (layout_id, Some(element))
+                }
+            }
+        })
+    }
+
+    fn prepaint(
+        &mut self,
+        global_id: Option<&GlobalElementId>,
+        _inspector_id: Option<&InspectorElementId>,
+        bounds: Bounds<Pixels>,
+        element: &mut Self::RequestLayoutState,
+        window: &mut Window,
+        cx: &mut App,
+    ) -> Option<AnyElement> {
+        window.set_view_id(self.entity_id);
+        window.with_rendered_view(self.entity_id, |window| {
+            // Non-cached path: element was rendered in request_layout, just prepaint it.
+            if let Some(mut element) = element.take() {
+                element.prepaint(window, cx);
+                return Some(element);
+            }
+
+            // Cached path: check cache, render only on miss.
+            window.with_element_state::<ViewElementState, _>(
+                global_id.unwrap(),
+                |element_state, window| {
+                    let content_mask = window.content_mask();
+                    let text_style = window.text_style();
+
+                    // Cache hit: entity clean, props unchanged, bounds stable.
+                    // Skip render, prepaint, and paint entirely.
+                    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.props_hash == self.props_hash
+                        && !window.dirty_views.contains(&self.entity_id)
+                        && !window.refreshing
+                    {
+                        let prepaint_start = window.prepaint_index();
+                        window.reuse_prepaint(element_state.prepaint_range.clone());
+                        cx.entities
+                            .extend_accessed(&element_state.accessed_entities);
+                        let prepaint_end = window.prepaint_index();
+                        element_state.prepaint_range = prepaint_start..prepaint_end;
+
+                        return (None, element_state);
+                    }
+
+                    // Cache miss: render now, layout as root, prepaint.
+                    let refreshing = mem::replace(&mut window.refreshing, true);
+                    let prepaint_start = window.prepaint_index();
+                    let (mut element, accessed_entities) = cx.detect_accessed_entities(|cx| {
+                        let mut element = self
+                            .view
+                            .take()
+                            .unwrap()
+                            .render(window, cx)
+                            .into_any_element();
+                        element.layout_as_root(bounds.size.into(), window, cx);
+                        element.prepaint_at(bounds.origin, window, cx);
+                        element
+                    });
+
+                    let prepaint_end = window.prepaint_index();
+                    window.refreshing = refreshing;
+
+                    (
+                        Some(element),
+                        ViewElementState {
+                            accessed_entities,
+                            prepaint_range: prepaint_start..prepaint_end,
+                            paint_range: PaintIndex::default()..PaintIndex::default(),
+                            cache_key: ViewElementCacheKey {
+                                bounds,
+                                content_mask,
+                                text_style,
+                                props_hash: self.props_hash,
+                            },
+                        },
+                    )
+                },
+            )
+        })
+    }
+
+    fn paint(
+        &mut self,
+        global_id: Option<&GlobalElementId>,
+        _inspector_id: Option<&InspectorElementId>,
+        _bounds: Bounds<Pixels>,
+        _request_layout: &mut Self::RequestLayoutState,
+        element: &mut Self::PrepaintState,
+        window: &mut Window,
+        cx: &mut App,
+    ) {
+        window.with_rendered_view(self.entity_id, |window| {
+            let caching_disabled = window.is_inspector_picking(cx);
+            if self.cached_style.is_some() && !caching_disabled {
+                // Cached path: check if we rendered or reused.
+                window.with_element_state::<ViewElementState, _>(
+                    global_id.unwrap(),
+                    |element_state, window| {
+                        let mut element_state = element_state.unwrap();
+
+                        let paint_start = window.paint_index();
+
+                        if let Some(element) = element {
+                            let refreshing = mem::replace(&mut window.refreshing, true);
+                            element.paint(window, cx);
+                            window.refreshing = refreshing;
+                        } else {
+                            window.reuse_paint(element_state.paint_range.clone());
+                        }
+
+                        let paint_end = window.paint_index();
+                        element_state.paint_range = paint_start..paint_end;
+
+                        ((), element_state)
+                    },
+                )
+            } else {
+                // Non-cached path: just paint the element.
+                element.as_mut().unwrap().paint(window, cx);
+            }
+        });
+    }
+}
+
 /// A view that renders nothing
 pub struct EmptyView;